最近刷题网站看到一道很有意思的题目:找出最长的连续重复正整数。题目越短,越容易让人掉沟里。我第一次读题时,脑子里蹦出来的是“最长连续递增序列”,直到盯着“重复”两个字看了半天,才意识到它要的是完全相同的正整数在数组里挨着出现,找到最长的那一段。比如[1, 2, 2, 2, 3, 3, 4],答案应该是3,因为2, 2, 2连续出现了3次,没有哪一段比它更长了。
这道题难度不大,但特别适合拿来练手:它同时涉及滑动窗口、线性扫描、边界处理,往深了说还能延伸出好几个经典问题。我这段时间反复用不同的写法实现它,踩了几个不算大的坑,也总结出一些容易被忽略的细节。这篇文章就把这道题拆开揉碎,从题目理解到暴力解、最优解,再到各种变体,一次性讲清楚。如果你刚接触算法,可以把它当成第一道滑动窗口入门题;如果你在准备面试,也可以用它来检验自己写代码时是不是够严谨。
1. 题目拆解:到底在找什么
1.1 “连续重复”的确切含义
很多算法题的麻烦不在解法,而在题目本身有歧义。“找出最长的连续重复正整数”里的“连续重复”其实包含两层意思:第一,这一段数字必须是连续相邻的;第二,这一段里的数字必须完全相同,也就是“重复出现”。举例来说:
[1, 2, 2, 3]中,2连续重复了2次,答案是2。[1, 1, 1, 2, 2, 1, 1]中,1在开头连续重复了3次,结尾的1只连续重复2次,所以答案是3。[1, 2, 3, 4]中,每个数都只出现一次,没有任何“重复段”,答案就是1。
这里有一个容易混淆的地方:题目强调“正整数”,但算法本身并不依赖“正整数”这个性质。正整数只是限制输入范围,避免用户传字符、负数和None。真正判断时候只需要比较相邻两个元素是否相等。所以不管面试官把题目改成“连续重复字符串”还是“连续重复浮点数”,核心逻辑完全不变。
那返回值到底应该是什么?不同版本的题目要求可能不一样。最常见的问法是“返回最长连续重复段的长度”,也就是一个整数。但也可能要求“返回重复的那个值”,甚至“返回起始下标”。我建议动手写代码之前,先把这一点确认清楚。如果是自己做练习,就默认返回长度,同时我会在后面的实现里给出一个能顺便返回值和起始下标的版本,方便应对不同需求。
1.2 输入边界与函数签名
先说清楚输入输出。一个典型的函数签名可以这样写:
from typing import List def longest_consecutive_duplicate(nums: List[int]) -> int: """返回最长连续重复正整数的长度"""这个函数需要处理下面这些情况:
| 输入 | 期望输出 | 说明 |
|---|---|---|
[] | 0 | 空数组里没有元素,长度为 0 |
[7] | 1 | 只有一个元素,它本身就是最长连续重复段 |
[3, 3, 3, 3] | 4 | 所有元素都相同,整个数组就是答案 |
[1, 2, 3, 4] | 1 | 每个元素都是独立的一段,取最大长度 1 |
[1, 1, 2, 2, 2, 1] | 3 | 中间那段2最长,长度是 3 |
如果题目规定输入一定是正整数列表,那你可以完全忽略非正数的情况。但在工程实现里,我通常不依赖这个约定,因为一旦测试数据里混入0或者None,算法也不能崩。下面所有解法都能直接处理任意可比较的元素,只是我们仍然围绕“正整数”来描述。
时间复杂度要求一般是O(n),空间复杂度尽量做到O(1)。这题最朴素的暴力写法是O(n^2),优化后可以稳定O(n)。面试官考察的往往不是你能不能写出来,而是你能不能用最清晰、最少 bug 的方式写出来。
2. 解法演进:从暴力到一次扫描
2.1 暴力解法:枚举起点再向右延伸
最容易想到的做法是枚举每一个起点,然后从它开始向右延伸,直到遇到不一样的数字为止,统计这一段长度,不断更新最大值。代码写起来很简单:
def longest_consecutive_duplicate_brute(nums: List[int]) -> int: n = len(nums) best = 0 for i in range(n): j = i while j + 1 < n and nums[j + 1] == nums[i]: j += 1 best = max(best, j - i + 1) return best这个解法的思想很直白:以每个位置作为“重复段”的起点,尽可能往右撑开,记录最大长度。但它的性能不够好。假设数组是[1, 1, 1, 1, 1],第一个起点向右走到末尾,复杂度O(n);第二个起点又从第二个元素开始向右走到末尾,又是O(n)。全部五个起点都走一遍,复杂度接近O(n^2)。
为什么暴力解法不是首选?因为这里面存在大量重复计算。起点i向右扫描到的信息,起点i+1又扫描了一遍。如果数组长度为十万、百万,这个性能差距会非常明显。虽然暴力解可以作为“验证正确答案”的基准,但实际面试中几乎不会让你停留在这层,至少要说出“可以优化到一次遍历”。
2.2 单遍扫描:最实用的正统解法
既然暴力的问题在于重复扫描,那我们就想办法只扫描一遍。核心思路是:维护一个cur,表示“以当前位置为结尾的最长连续重复段长度”。遍历数组时,如果当前元素和前一个元素相等,说明它接在前面的重复段后面,cur加一;如果不等,说明重复段断了,当前元素自己组成新的长度为 1 的段。
每更新一次cur,就用它去刷新全局答案best。写成代码:
def longest_consecutive_duplicate(nums: List[int]) -> int: if not nums: return 0 cur = 1 best = 1 for i in range(1, len(nums)): if nums[i] == nums[i - 1]: cur += 1 else: cur = 1 if cur > best: best = cur return best你仔细看这个过程:它不需要回退,不需要缓存一整段的位置,只需要记住前一个元素是什么。所以空间复杂度只有O(1),时间复杂度是严格的O(n)。这个解法就是整个题目的最佳模板,简短、清晰、不容易出错。
为什么cur重置为1而不是0?因为当发现nums[i] != nums[i - 1]时,当前元素本身已经是一个长度为 1 的重复段了。如果重置为0,下一次遇到相等情况就会少算一个。这是我见过最多的写错位置之一。
另外,如果题目要求返回最长的“值”而不是长度,可以在更新best的时候把对应的数字记录下来。稍后我会给出完整版本。
2.3 双指针 / 滑动窗口:换个角度同样优雅
单遍扫描已经足够好,但有些面试官喜欢用“双指针”来问这道题,你最好也能熟练切换表达方式。思路是把每一个连续重复段看成一个窗口,窗口内的所有元素都相等。
维护左指针left,初始指向当前段的第一个元素。让右指针right从left开始不断向右移动,直到遇到第一个不同的元素。此时窗口范围是[left, right],窗口长度就是当前连续重复段的长度。然后直接把left移动到right + 1,开始处理下一段。
def longest_consecutive_duplicate_two_pointer(nums: List[int]) -> int: n = len(nums) best = 0 left = 0 while left < n: right = left while right + 1 < n and nums[right + 1] == nums[left]: right += 1 best = max(best, right - left + 1) left = right + 1 return best这里的关键是left = right + 1,而不是left = left + 1。有人会担心:如果left只移动一格,会不会更保险?答案是不会。因为left到right之间的所有元素都和nums[left]相同,它们组成的重复段长度只会比当前窗口短,不可能超过right - left + 1。所以直接跳到下一个不同元素是安全的,也保证了每个元素最多被访问常数次,整体复杂度仍是O(n)。
双指针版本和单遍扫描本质上是在描述同一件事:前者从窗口角度切分数组,后者从“相邻元素关系”角度累计长度。面试时你可以选自己更顺手的写法,但最好两种都能解释清楚。
2.4 Python 的 groupby 一行流
如果是在日常脚本或者数据分析场景里用 Python,还有一个更优雅的写法:用标准库itertools.groupby。这个函数会把连续相同的元素自动分到一组,我们只需要求每组长度的最大值即可。
from itertools import groupby def longest_consecutive_duplicate_groupby(nums: List[int]) -> int: return max((sum(1 for _ in group) for _, group in groupby(nums)), default=0)groupby(nums)会返回一连串(元素值, 迭代器),迭代器里就是这一组连续相同的元素。sum(1 for _ in group)是在计算这一组的长度。因为group是生成器,不能直接len(group),所以用sum(1 for ...)是标准姿势。default=0是为了处理空数组。
这个写法虽然简短,但并不适合在面试里作为主答案,因为面试官往往想看你手动实现逻辑,而不是甩一个封装好的库函数。不过它可以作为加分项提起,展示你知道 Python 标准库能简化这类问题。实际工程里,我会更倾向于用它,因为代码更可读,也不容易错。
如果你还需要知道“最长的那个数是什么”,可以这样改:
from itertools import groupby def longest_consecutive_duplicate_with_value(nums: List[int]) -> tuple: best_len = 0 best_val = None for value, group in groupby(nums): length = sum(1 for _ in group) if length > best_len: best_len = length best_val = value return best_len, best_val3. 边界、陷阱与工程化细节
3.1 空数组、单元素、全是相同值
这三类边界测试是刷题时的常规检查项。空数组如果直接遍历,cur根本没有初始值,所以要在开头单独处理。单元素数组比较容易出问题:很多人会把cur初始化为0,导致输出0,但事实上单个元素本身就是长度为 1 的重复段。全是相同值的数组则用来检验你的更新逻辑是否能在整个扫描过程中持续增大cur。
我建议每次写完解法后,至少跑这样一组用例:
[] -> 0 [5] -> 1 [2, 2, 2] -> 3 [1, 2, 2, 1] -> 2这几个用例覆盖了“空、单元素、全相同、中间断档”四种典型情况。别看它们简单,很多写错的实现都会在其中一个用例上暴露问题。
3.2 多个最长段:取第一个还是最后一个
如果输入是[1, 1, 2, 2, 1, 1],最长的连续重复段有两个:开头的两个1和结尾的两个1,长度都是2。如果只返回长度2,没有任何歧义。但如果面试官要你返回起始下标或者元素值,就需要约定:取第一个还是最后一个?
我在代码里用的是if cur > best,也就是只有严格大于才会更新。这样遇到并列长度时会保留最靠前的那一段,因为后面同等长度的段不会覆盖它。如果你想取最后一个,把判断改成if cur >= best就行。这个细节看起来很微小,但往往会被面试官追问。主动说明“我默认取第一个最长段,如果需要最后一个可以改成大于等于”,会显得你考虑周全。
3.3 “正整数”不等于“不需要判空”
题目限定为正整数,看起来可以省掉空数组判断,因为“空数组里没有正整数”似乎就不用管。但函数签名不会因为语义限制就让你跳过边界处理,空数组依然是合法输入,返回0是合理且必要的。
另外,不要因为元素是正整数就在判断相等时附加数学性质,比如“正整数一定正数,所以可以用 0 做哨兵”。这在做题时没问题,但在工程里一旦输入变成0或者负数,哨兵逻辑就可能出错。我的建议是:把“正整数”只当成输入约束,不要让算法逻辑依赖它。最稳妥的写法就是只用相邻元素比较,不引入额外假设。
3.4 流式读取:如果数组太大无法一次性装入内存
有时题目会换个马甲:数据不是数组,而是一个持续产生正整数的数据流。你没法一次性拿到全部数据,只能一个元素一个元素地读取。这种场景下,单遍扫描依然适用,因为我们只需要记住前一个元素和当前连续长度。
def longest_consecutive_duplicate_stream(stream) -> int: prev = None cur = 0 best = 0 for x in stream: if x == prev: cur += 1 else: cur = 1 prev = x if cur > best: best = cur return best这个版本和数组版几乎一样,只是把nums[i]和nums[i-1]的比较换成了“当前元素”和“上一个元素”的比较。它天然支持生成器、文件流、网络流等场景。工程中如果你在处理日志、传感器数据,这种写法可以直接复用,不需要额外开数组缓存。
3.5 时间复杂度“看起来有 while,但还是 O(n)”
双指针解法和暴力解法都用了while向右延伸,但复杂度完全不同。暴力的while会反复扫描同一个元素;双指针中left每次直接跳到right + 1,每个元素最多被right访问一次,又被left当段起点访问一次,总访问次数是常数倍。所以就算内层有while,整体依然线性。
我把这两种while的区别总结成一个对比:
| 写法 | 最坏情况 | 原因 |
|---|---|---|
| 暴力枚举起点 | O(n^2) | 同一个元素被多个起点重复扫描 |
| 双指针跳段 | O(n) | 每个元素最多被右指针访问一次 |
理解了这一点,你就能回答“为什么这个 double loop 不是平方复杂度”这个经典追问。
4. 常见变体:一通百通
4.1 无序数组中的最长连续序列
如果把“连续重复”换成“连续递增的值”,并且数组是无序的,就是另一道经典题:给定[100, 4, 200, 1, 3, 2],要找出1, 2, 3, 4这样连续数值的最长长度,答案是4。这和“连续重复正整数”最大的区别是:元素不需要在原数组中相邻,只需要数值上连续。
这个变体的最优解法是哈希集合。先把所有元素放进set,然后遍历每个元素,如果它没有前驱x - 1,就说明它是一段连续序列的起点,接着延展计数。
def longest_consecutive(nums: List[int]) -> int: nums_set = set(nums) best = 0 for x in nums_set: if x - 1 not in nums_set: cur = 1 while x + 1 in nums_set: x += 1 cur += 1 best = max(best, cur) return best为什么只从没有前驱的元素开始?因为如果一个元素有前驱,它所在的连续序列已经被前面的起点统计过了,再从它开始就是重复计算。这样可以保证每个数字只被访问常数次,时间复杂度O(n)。这道题和原题本质上都是“找连续段”,只是一边靠哈希跳过起点,一边靠相邻指针扫描,思路可以互相借鉴。
4.2 最长连续递增子数组
如果只是把“重复”改成“严格递增”,单遍扫描依然适用,只是判断条件从“相等”变成“大于前一个”。比如[1, 2, 2, 3]的答案应该是2,因为2, 3或者1, 2都是长度 2 的递增段,而2, 2不递增。
def longest_increasing_subarray(nums: List[int]) -> int: if not nums: return 0 cur = 1 best = 1 for i in range(1, len(nums)): if nums[i] > nums[i - 1]: cur += 1 else: cur = 1 if cur > best: best = cur return best这个变体几乎是原题的一行改动:把==换成>。这说明很多“最长连续类型”的题目底层都是同一个模板:维护当前段长度,遇到断点就重置。掌握了这一点,你就能快速迁移。
4.3 最长无重复字符子串
原题是“连续重复”,反过来会要求“连续不重复”。给定一个字符串,找出最长的不含重复字符的子串。这就要用到真正的滑动窗口了:窗口内不能有重复字符,所以右指针不断扩展,左指针遇到重复时收缩到新的位置。
def length_of_longest_substring(s: str) -> int: last_seen = {} left = 0 best = 0 for right, ch in enumerate(s): if ch in last_seen and last_seen[ch] >= left: left = last_seen[ch] + 1 last_seen[ch] = right best = max(best, right - left + 1) return best这个题和原题有一个有趣的对应:原题维护“当前连续相同元素长度”,这里维护“当前无重复窗口的起点”。一个是遇到不同就重置,一个是遇到重复就移动左指针。它们是滑动窗口思想的两端,放在一起学效果最好。
4.4 最长重复子数组
如果题目进一步扩展成“两个数组中的最长公共连续子数组”,那就需要二维动态规划了。比如A = [1, 2, 3, 2, 1],B = [3, 2, 1, 4, 7],最长公共连续子数组是[3, 2, 1],长度是3。
状态定义是dp[i][j]表示以A[i]结尾和以B[j]结尾的公共后缀长度。转移公式很优雅:如果A[i] == B[j],则dp[i][j] = dp[i-1][j-1] + 1,否则为0。
def longest_common_subarray(A: List[int], B: List[int]) -> int: m, n = len(A), len(B) dp = [[0] * (n + 1) for _ in range(m + 1)] best = 0 for i in range(1, m + 1): for j in range(1, n + 1): if A[i - 1] == B[j - 1]: dp[i][j] = dp[i - 1][j - 1] + 1 best = max(best, dp[i][j]) return best虽然复杂度上升到O(mn),但核心思想仍然是“连续段”。从单数组到双数组,从相等到递增,这些变体之间的关系其实非常紧密。遇到没见过的题目时,先想它到底在找哪种“连续段”,比死记模板更有效。
4.5 二进制数组中的最长连续 1
最后提一个简化版:数组里的元素只有0和1,要找最长连续出现1的长度。这几乎是原题的一个特例,可以专门针对1写:
def longest_ones(nums: List[int]) -> int: cur = 0 best = 0 for x in nums: if x == 1: cur += 1 best = max(best, cur) else: cur = 0 return best注意这里cur重置为0,因为遇到0时当前连续 1 的个数就是零。如果将来遇到类似题,你可以选择套用通用的“相等段”写法,也可以针对特化值域写更直观的版本。面试时后者往往更受欢迎,因为它直接表达了题目语义。
5. 实操总结:我刷这道题踩过的坑
5.1 写代码前先确认返回值
我第一次自己做这道题时,想当然以为返回最长长度,写完才发现题目要求输出重复的那个数字。后来我养成一个习惯:看到这类标题,先问自己三个问题——返回长度还是元素值?如果元素值,是第一个最长段还是最后一个?空数组怎么定义?确认清楚再动手。在面试环境下,多问一句不是“浪费时间”,而是展示需求分析和沟通能力。
5.2 重置计数器的位置写错
单遍扫描版本里最常见的错误有两个。第一个是在相等分支里先加一,然后重置cur = 1,导致永远数不到两个以上。第二个是在不相等分支忘记重置,导致把不连续的数据也并到同一段里。这两个错误在代码里不明显,但用[1, 2, 2, 3]一跑就能暴露。建议每次写完都在纸上手动模拟一遍:
i=1,2 != 1,重置cur=1。i=2,2 == 2,cur=2。i=3,3 != 2,重置cur=1。
过程很简单,但如果不手动走一遍,写错的时候还真不容易看出来。
5.3 双指针写法中 left 更新错误
双指针版本我也写过“看似正确”的错误版本:把left = right + 1写成了left = left + 1。这样在[1, 1, 1, 1]这种全相同数组里,结果倒是正确,因为while每次都扫描到尾;但在[1, 1, 2, 2, 2, 1]里会多出很多重复扫描,甚至可能因为索引混乱导致答案不对。所以我一直强调,跳段指针必须跨过已经处理完的区间,这是保证线性的关键。
5.4 测试用例快查表
下面这张表是我刷这类题时常用的自查清单,建议直接存下来当模板:
| 输入 | 期望 | 检查点 |
|---|---|---|
[] | 0 | 空数组 |
[9] | 1 | 单元素 |
[4, 4, 4, 4] | 4 | 全部相同 |
[1, 2, 3, 4] | 1 | 完全没有连续重复 |
[1, 1, 2, 2, 2, 1] | 3 | 中间段更长一些 |
[1, 1, 2, 2, 1, 1] | 2 | 多个最长段并列 |
[1, 0, 1, 1, 0] | 2 | 混入非正整数也能运行 |
最后一个用例虽然不符合“正整数”的要求,但它能验证你的逻辑是否依赖了额外假设。如果算法能正确处理它,说明通用性足够。
5.5 个人体会
刷了很多题之后,我的感受是“找出最长的连续重复正整数”这类题,难度不高但特别能反映一个人的代码功底。同样一道题,有人能三分钟写对,有人要反复调试边界。差别不在背没背过答案,而在能不能把一个简单的扫描逻辑讲清楚、写利落。我后来每次处理“连续段”相关的问题,都会下意识回到这道题的模板上,再去根据场景调整判断条件:相等、递增、不同、公共子段,都是同一个骨架上的变化。
如果你现在正在准备面试,我建议不要只满足于写出单遍扫描,可以把双指针、流式读取、groupby 三个版本都写一遍,再用变体题测一测自己是否真的理解了滑动窗口。这个过程比单纯记住答案有价值得多。