☰
最长连续重复子段求解:一次遍历与边界条件全解析
2026/10/15 6:07:00 网站建设 项目流程

翻日历的时候看到2025年1月11日那天记的一条练习记录,标题就一行字:“找出最长的连续重复正整数”。题目本身不复杂,但我当时从读题、写码到补边界条件,完整走了一遍,中间还踩了几个自己没意识到的坑,今天把整个过程整理出来。这篇内容适合两类人:一类是刚开始练算法题、想找一道“简单但不 trivial”的题目来建立手感的朋友;另一类是已经在写业务代码、想在代码 review 里快速识别类似连续状态问题的同学。核心就一句话:给定一个整数序列,找出数值相同、并且位置连续的最长子段的长度。这个问题的解法不长,但它背后涉及的状态维护、边界判断和变体扩展,比表面看起来有嚼头得多。

1. 先把题面拆清楚:“连续”“重复”“最长”各指什么

很多朋友看到“连续重复正整数”这几个字,第一反应是“这有什么好写的”,但真正动手时才发现题意理解上就有分歧。这里必须先统一口径:所谓连续重复,指的是数组下标相邻的一段区域内,元素值完全相同。比如序列[3, 3, 3, 5, 5, 2, 2, 2, 2, 9],连续重复的子段有:3出现连续 3 次,5出现连续 2 次,2出现连续 4 次。题目要的答案就是其中最大的那个长度,也就是4。

1.1 三个关键词的精确含义

第一个关键词是“连续”。连续在数据结构里通常指下标上的紧密相邻,a[i]和a[i+1]之间不允许隔着其他元素。这和“子序列”完全不同:子序列允许跳过中间元素,只要原顺序不变即可。题目要的是子段,不是子序列,所以[3, 5, 3]里两个3虽然值相同,但中间被5隔开了,不能算连续重复。

第二个关键词是“重复”。重复在这里等于“相同值连续出现”,哪怕这个值在整段序列里只出现一次,它也能构成长度为 1 的连续段。很多人会下意识认为答案至少是 2,事实证明这是错的:[1, 2, 3]这种整个数组都不存在相邻相等元素的输入,答案就是 1,因为每个单独元素都可以视为连续重复长度为 1 的子段。

第三个关键词是“最长”。最长要求我们维护所有连续段长度中的最大值,而不是最后一个长度,也不是第一个长度。这个“极值”意识在实现时直接决定状态变量要怎么更新。

1.2 手算几个例子建立直觉

先拿一个标准例子练手:[1, 1, 2, 2, 2, 2, 3, 3, 3]。从左往右扫,第一段是值 1,长度 2;第二段是值 2,长度 4;第三段是值 3,长度 3。最大是 4,输出 4。

换一个交替出现的例子:[1, 2, 1, 2, 1, 2]。这里没有任何两个相邻元素相等,每个连续段长度都是 1,所以答案是 1。

再换一个全部相同的例子:[7, 7, 7, 7, 7, 7]。整段就是一个连续段,长度 6,答案就是 6,等于数组长度。这些手算结果会作为后面测试用例的基准,防止代码写出来自己都拿不准。

1.3 常见理解误区:把“连续”当成“出现次数”

这个误区太常见了。有人会把题目理解成“找出整个数组里出现次数最多的正整数,然后输出次数”。比如[1, 1, 1, 2, 2, 3],按出现次数答案是 3(数字 1 出现 3 次),按连续重复答案也是 3,两个口径恰好一样,容易让人产生错觉。

但换成[1, 1, 2, 1, 1],按出现次数,数字 1 总共出现 4 次;按连续重复,最长的连续段是1,1或后面的1,1,长度只有 2。如果面试时把这个测例摆在面前,就能立刻看出你到底有没有真懂题意。另一个误区是“连续段必须从第一个元素开始”,其实任何位置开始都可以,这要求遍历时每一段都要独立评估。

2. 一趟遍历加两个计数器:最朴素也最稳的解法

搞懂题意之后,解法其实很直观。从头到尾扫一遍序列,同时记住两件事:当前正在看的连续段的长度,以及到目前为止出现过的最大连续段长度。每读一个新元素,如果它和上一个元素相等,就把当前段长度加一,然后顺手更新最大长度;如果不等,说明上一段结束,当前段长度重置为 1,开始数下一段。

2.1 为什么一趟遍历就够了

这个问题本质上是一个在线状态判断:读到一个新值时,只需要知道“上一个值是什么”和“当前段已经连续了多少个”,就能决定这段是该延长还是该重开。它不依赖未来的信息,所以不需要预先知道数组的全貌,也不必缓存历史统计。这种“滚动维护两个变量”的思路,在算法里属于最基础的滑尺思想,很多复杂问题——比如找最长连续递增子段、找股票价格连涨天数——都是这套打法的变体。

一趟遍历的另一个优势是能够支持流式数据。你不需要把整个数组加载进内存,来一个元素处理一个元素,对超大文件、传感器实时数据这类场景特别友好。这一点后面在扩展场景里还会再提。

2.2 核心代码:从伪代码到可执行版本

先给一份简明 Python 版本:

def longest_repeating_run(nums): if not nums: return 0 max_len = 1 cur_len = 1 for i in range(1, len(nums)): if nums[i] == nums[i - 1]: cur_len += 1 max_len = max(max_len, cur_len) else: cur_len = 1 return max_len

这段代码里最关键的逻辑是:只有“相等”分支才需要尝试更新max_len,不等分支只负责重置。你可以把max_len的更新语句放到循环外面再写一次,也可以像上面这样在相等分支里顺手更新,两种都能得到正确答案,但放到分支里代码更短,也避免在循环结束后补一次额外比较。

如果想稍微压缩行数,还可以这样写:

from itertools import groupby def longest_repeating_run(nums): if not nums: return 0 return max(len(list(group)) for _, group in groupby(nums))

用内置的groupby可以把相邻相同元素自动分组,然后取所有组长度的最大值。这个写法更适合 Python 风格浓厚的项目,但缺点是会把数据完整拉成列表,对超大输入不太友好。面试或者写算法练习时,我仍然建议手写遍历版本,一方面展示基本功力,另一方面更容易处理流式输入。

2.3 三个状态变量如何协作

细看上面的代码,其实真正参与计算的变量有三个:cur_len负责记当前段长度,max_len负责记历史最大值,还有一个隐式的变量prev_value——在代码里体现为nums[i - 1]。每轮循环都做两件事:比较当前值和前一个值;根据比较结果决定更新cur_len还是重置它。这个“比较—更新/重置”的二选一结构,就是整个算法的骨架。

理解了这个骨架,后面所有变体题都能往上套。比如题目改成“求最长连续奇数子段”,你只需把相等比较改成“两个值都是奇数”;改成“求最长连续非降子段”,只需把相等比较改成“大于等于”判断。核心理念不变:用一个状态变量记录上一个有效边界,另一个状态变量记录当前累计长度。

3. 空数组、单元素、全同序列:边界条件才是真正的分水岭

我最初写这道题时,第一版代码长这样:

def longest_repeating_run(nums): max_len = 0 cur_len = 0 prev = None for num in nums: if num == prev: cur_len += 1 else: cur_len = 1 prev = num max_len = max(max_len, cur_len) return max_len

看起来逻辑没问题,但提交之后我才发现,prev = None在遇到元素值也为None(如果数组允许)时会出错;更重要的是这个版本依赖max_len从 0 开始,对空数组倒是能正确处理,但单元素数组[5]也能得到 1,问题不大。可如果数组中本来就有None占位,或者题目说“正整数”但测试数据混入了 0 和负数,情况就不一样了。

3.1 空输入怎么定义

题目没明说输入为空时该返回什么,但从语义上,没有任何元素就不存在任何连续段,返回 0 是唯一合理的值。如果用我的第一版代码,空数组天然会返回 0,没有问题。

但如果照一些网上的答案直接初始化max_len = 1,空数组就会被错判成 1,这是因为“本来至少有一个段”的假设对空数组不成立。所以写代码之前,一定要先明确输入约束:要么题目保证非空,要么自己在函数开头单独处理if not nums: return 0。

3.2 单元素数组和“至少为 1”的语义

单元素数组[42]的正确答案是 1,因为这一个元素本身就是一个长度为 1 的连续重复段。这意味着算法的初始状态不能把max_len设为 0 然后啥也不干,它必须能在“循环一次都没运行”的情况下仍然给出 1。所以更稳妥的做法是:先处理空数组返回 0,然后无条件把max_len和cur_len都初始化为 1,从下标 1 开始遍历。

这也是为什么我在最终代码里把两个计数器都初始化为 1,而从range(1, len(nums))开始循环的原因。初看上去好像“浪费”了第一个元素,实际上正好用第一个元素奠定初始状态。

3.3 相等比较的更新顺序:先更新再比较,还是先比较再更新

这里的细节非常容易搞错。假设现在cur_len = 3,遇到的新元素与上一个相等。如果先更新max_len,再更新cur_len,等价于用旧值去比较,虽然不会错,但会让人困惑。反过来,如果先更新cur_len,再和max_len比较,语义更自然:当前段的长度已经变成 4 了,再去看历史最大值是否需要刷新。

再考虑一种更隐蔽的写法:

max_len = max(max_len, cur_len) cur_len += 1

这种“先比较再加”的顺序,在每次相等时都会让max_len停留在比cur_len小 1 的值上,直到循环结束后再补一次比较。最终结果是对的,但中途的max_len状态是错的。假如有人在循环里提前 return 或者用这个中间状态去判断,就会拿到错误数值。所以我建议保持“先加长、后比较”的顺序,让所有中间状态都真实可信。

3.4 数值范围和相等性判断对结果的影响

题目写“正整数”,但很多随机测试数据会把 0、负数、甚至浮点数混进来。好在这个算法只做相等比较,不涉及大小排序,所以任何可比较相等性的类型都能处理。唯一需要注意的是:如果使用浮点数,NaN和NaN是否相等的判定在不同语言里并不一致;如果使用对象类型,自定义相等规则也会产生影响。练习时假定输入是整数,直接用==最省心。

还有一个隐藏问题:如果题目要求返回“最长连续重复的数字本身”而不是长度,那么你需要额外记录“当前连续段的值”,并在每次max_len被刷新的同时更新答案值。比如:

def longest_repeating_value(nums): if not nums: return None max_len = 1 cur_len = 1 best_val = nums[0] for i in range(1, len(nums)): if nums[i] == nums[i - 1]: cur_len += 1 else: cur_len = 1 if cur_len > max_len: max_len = cur_len best_val = nums[i] return best_val

细心的朋友会发现,这里把max_len的更新从“相等分支”挪到了循环末尾,这样无论相等还是重置,都能正确比较。这种写法在逻辑上更统一,也更适合扩展出去记录“哪一段是最长的”这样的需求。不过要注意初始条件下best_val = nums[0],当数组全不相等时答案返回第一个元素,符合预期。

4. 从“连续”到“不连续”再到“循环”:变体问题带来思路升级

原题做熟之后,我建议你把它放到一个更大的问题谱系里看,因为面试题和实际业务很少只考孤立的“连续重复长度”,它们常常在此基础上加一层变化。这里列出四个我认为最值得琢磨的变体,它们各有各的考点。

4.1 变体一:不要求连续,求出现次数最多的数字

如果去掉“连续”这个限制,问题就变成“数组中出现次数最多的元素是什么”。这时候一趟遍历加一个计数器的方案就不够了,必须用一个哈希表记录每个值出现的次数,然后取最大值。时间和空间都会变成 O(n) 和 O(k),k 是不同值的数量。

from collections import Counter def most_frequent(nums): if not nums: return None cnt = Counter(nums) return max(cnt.items(), key=lambda x: x[1])[0]

这和原题的差异在于:原题只需要记住“上一个值”,因为它只处理相邻关系;而“出现次数最多”需要同时记住“每个值出现了多少次”,状态空间从 O(1) 膨胀到 O(k)。这个对比能帮助你理解,一个看似微小的限定词“连续”,其实能大幅降低算法复杂度。

4.2 变体二:允许 K 次中断,找最长的“近似连续段”

实际业务里,传感器数据经常有毛刺,股票K线偶尔有一两根阴线。这时候你希望找出的不是严格连续相等的子段,而是“允许把 K 个不同值当作噪声剔除后,依然能拼成的最长相等段”。这个问题的解法是滑动窗口加计数:维护一个窗口的左边界,用哈希表记录窗口内每个值的次数,窗口里“非主值元素”的个数不超过 K,然后不断移动右边界,同时调整左边界。

def longest_run_with_noise(nums, k): if not nums: return 0 left = 0 count = {} max_len = 0 for right, val in enumerate(nums): count[val] = count.get(val, 0) + 1 # 窗口内“不是最有优势值的元素”太多时,收缩左边界 while right - left + 1 - max(count.values()) > k: count[nums[left]] -= 1 if count[nums[left]] == 0: del count[nums[left]] left += 1 max_len = max(max_len, right - left + 1) return max_len

这里每个右边界移动时都要计算max(count.values()),整体复杂度偏高,但思路是对的:先让右边界扩张,再把不满足“K 个噪声”条件的左边界收缩。面试时能说到这个程度,已经证明你对“窗口”概念有真实理解。

4.3 变体三:环形数组里的最长连续重复

如果把普通数组首尾相接,形成一个环形,那么原来边界处的两个段有可能合并。比如数组[3, 3, 1, 3, 3],普通视角下最长连续段长度是 2;但在环形视角下,首部的两个 3 和尾部的两个 3 连在一起,形成长度 4 的连续段。

解决思路有两种:第一种是把数组复制一份拼接在后面,然后对长度为 2n 的新数组跑一次普通算法,但答案不能超过 n;第二种是先算出首尾是否同值,如果同值,就把头部段长加尾部段长,再和中间最大段比较。第二种更省空间,也更体现你对环形结构的理解。

4.4 变体四:改成“最长连续递增子段”后,比较逻辑怎么变

严格连续递增和连续重复其实是同族问题,只是把nums[i] == nums[i - 1]换成nums[i] > nums[i - 1]。有人觉得这很简单,但真正写起来容易忘记更新cur_len的时机:递增时cur_len加一,非递增时重置为 1。更有意思的是“最长连续非递减子段”,它把相等也视为有效,这时判断条件变成nums[i] >= nums[i - 1]。我把三个变体画成一张对比表,方便你记忆:

问题类型同一段的判定条件重置条件初始状态
连续重复nums[i] == nums[i-1]不等1
连续递增nums[i] > nums[i-1]不大于1
连续非递减nums[i] >= nums[i-1]小于1

核心状态机没变,变的只是一行比较逻辑。这也是这类题目最有价值的练习点:你学会的不是某个特定题目的答案,而是一类“按条件滚动累计”的思维模式。

5. 多语言实现思路:Python、Java、C++ 与 SQL 各有各的套路

原题属于“实现一次马上就能平移”的类型。我自己用几种常见语言写下来,发现细节上差别不小,尤其是语言内建 API 是否提供分组能力,会影响代码风格。

5.1 Python:用itertools.groupby一行流,或用循环保持可控

如果你只想快速得到答案,groupby是最简洁的:

from itertools import groupby def longest_repeating_run(nums): if not nums: return 0 return max(sum(1 for _ in group) for _, group in groupby(nums))

注意这里不能用len(group),因为groupby返回的 group 是迭代器,没有长度。需要像上面这样用sum(1 for _ in group)求长度。如果你想兼顾可读性和效率,还是建议手写循环,也就是第 2 节里给出的版本。手写循环还有一个好处:中途可以轻松加入“记录起始位置”“记录最长段的值”这类额外需求。

5.2 Java 与 C++:警惕Integer缓存和迭代器解包的开销

Java 版本通常会这样写:

public int longestRepeatingRun(int[] nums) { if (nums.length == 0) return 0; int maxLen = 1, curLen = 1; for (int i = 1; i < nums.length; i++) { if (nums[i] == nums[i - 1]) { curLen++; maxLen = Math.max(maxLen, curLen); } else { curLen = 1; } } return maxLen; }

这里没有太多坑,只要注意nums.length == 0时不能直接返回 1。C++ 版本也几乎一样,唯一区别是有些人会习惯用for (int i = 1; i < nums.size(); ++i),要小心nums.size()是无符号整数,在数组为空时i < nums.size()可能产生潜在问题,所以务必先判空。凡是写 C++ 的,我建议一开始就用int n = nums.size(); if (n == 0) return 0;,后面循环用n,既避免类型混合,又不用反复调用成员函数。

5.3 SQL 也能解:连续分组问题用窗口函数

看到这里你可能会问,这种问题跟 SQL 有什么关系?实际上“找连续段长度”在 SQL 里是经典的行号分组问题。假设有一张表t(id, num),id 是自增序号,num 是要判断的数值,找最长连续重复段可以用:

SELECT MAX(cnt) FROM ( SELECT num, COUNT(*) AS cnt FROM ( SELECT id, num, ROW_NUMBER() OVER (ORDER BY id) - ROW_NUMBER() OVER (PARTITION BY num ORDER BY id) AS grp FROM t ) grouped GROUP BY num, grp ) length_counts;

原理是利用“行号减去分组内行号”这个差值:遇到连续相同值,差值不变;一旦值切换,行号差会产生跳变。这样每一段相同值都会被赋予同一个grp,再按num, grp分组就能算出每段长度。这个技巧在订单连续状态、用户连续打卡数据里非常常用,值得记下来。

5.4 测试用例:四组边界用例覆盖 90% 的问题

不管用什么语言实现,我推荐的测试集是下面这组:

输入期望输出覆盖点
[]0空输入
[5]1单元素
[1, 1, 1, 1]4全相同序列
[1, 2, 3, 4]1无任何相邻相等
[1, 1, 2, 2, 2, 3, 3]3多段并存,取最大
[1, 1, 2, 1, 1, 1]3后段覆盖前段最大值

特别是最后一条,它测试的是“后面出现的更长段会不会覆盖前面”。如果代码在分支里把max_len写死成第一次出现的结果,就会在这里露出马脚。写任何算法题,我强烈建议先建好这组测试用例,再开始写函数体。

6. 业务场景映射:这个“小算法”到底用在哪

题目虽小,但同构问题在真实系统里遍地都是。我在实际项目里用到过至少三个场景,这里逐一说明,帮助你下次看到需求时能条件反射地想到它。

6.1 日志系统中的连续失败检测

监控告警系统经常需要判断“服务是否连续失败了 N 次”。如果你的日志流是[success, fail, fail, fail, success, fail],那么最长连续失败次数就是 3。这本质上就是一个“找最长连续重复值”的问题,只是把“重复值”做了一次抽象:值等于FAIL的连续段长度。很多监控平台会把这个逻辑写得很复杂,其实用一趟循环加两个计数器就能搞定,还能顺便在每轮循环里输出“当前连续失败的进度”,方便做阈值告警。

6.2 行情数据的连续涨跌天数

股票、加密货币、基金净值的“连续上涨天数”也是同款问题。你只需要把每日涨跌标记成 1 和 0,然后求最长的连续 1 段。这个指标在行情展示页上很常见,名叫“连涨天数”。如果数据源是数据库,上面 SQL 解法里的ROW_NUMBER技巧可以直接用;如果数据源是实时推送流,那么流式的一次遍历解法更合适。无论哪种,核心算法都和本题完全一致。

6.3 传感器与设备状态去抖

我在某次智能硬件调试中遇到过这样一个场景:设备上报状态值,有时会瞬间跳变一个错误值然后又恢复正常。我需要在状态流里判断“设备是否稳定处于某状态”,办法就是统计连续上报同一个状态值的次数,当次数超过某个阈值时才认为状态真正切换。这样既能过滤毛刺,又不牺牲实时性。这个做法的本质,就是一边流式读入一边维护当前连续段长度,和本题代码几乎一一对应。

6.4 算法题练习之外的收获:把“一道题”变成“一类题”

当你把这道题和上面这些场景放在一起看,会发现最有价值的不是能默写那五六行循环,而是养成一种识别模式的直觉。看到“连续”“重复”“最长”这些词,第一反应不再是翻答案,而是能想到“我只需要维护当前段的长度和历史最大长度,再根据切换条件重置”。这种状态机的思维,比记住任何一道具体题目的解法都重要。

我后来在带几位同学练代码时,经常把这道题作为“状态维护入门”的第一道题。原因也很简单:它没有复杂的递归,没有动态规划的状态转移表,没有二分答案,只有一个朴素但完整的“读新值、比边界、更新状态”流程。把这道题吃透,再去碰“最长括号子串”“无重复字符的最长子串”这类滑动窗口题,手感和理解都会顺畅很多。

实战里最容易栽跟头的往往不是核心逻辑,而是边界条件的处理。我自己就因为在空数组时返回了 1 而踩过坑,后来养成了“动手前先列测试用例”的习惯。现在把这个习惯也推荐给你:拿到任何需要处理序列的题目,先写四组用例——空、单元素、全相同、无相邻相等,再写主逻辑。四组跑通了,大概率已经避开 90% 的隐藏雷区。

最后分享一个小技巧:如果你在做这个题时还想顺便输出“顶峰时刻”的上下文,比如最长的那个连续段到底出现在哪个下标范围,可以在更新max_len的同时记录起始下标。初始化best_start = 0,当cur_len > max_len时,用i - cur_len + 1去更新best_start。这个写法一开始看起来多算了一步,但在日志分析和数据回放里特别有用,因为它让你能从结果反查原始区间,而不只是得到一个孤零零的长度值。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询