刷LeetCode刷到数组这一块的人,大概率都绕不开“代码随想录”这个刷题路线。数组专题排在最前面不是没有道理——它本身知识点不复杂,但几乎所有后续的链表、字符串、哈希表、滑动窗口、二分查找,底子都在数组操作上。尤其是“数组 + 双指针”这个组合,简直是刷题前期性价比最高的一套方法论:理解成本低、代码量少、运行效率提升显著,而且稍加变形就能覆盖快慢指针、左右指针、滑动窗口三大类高频题型。
这篇文章就把数组01专题里的双指针问题一次性拆透。先讲双指针在数组题里的底层逻辑,再用三道LeetCode经典题——移除元素、有序数组的平方、长度最小的子数组——完整走一遍实操流程,最后把大家最容易踩的边界条件坑、超时坑、返回值坑集中列出来。无论你是刚开始刷数组题的新手,还是已经刷了一半但总觉得“暴力解也能过、双指针就是别扭”的选手,这篇文章都值得静下心看完。
1. 双指针为什么是数组题的核心考点
1.1 数组操作的本质:随机访问快,搬移成本高
数组是一段连续的内存空间,读任何一个位置都是O(1)的时间。听起来已经很快了,但换个角度来看,数组最大的弱点是“插入”和“删除”代价很高:要在中间删一个元素,你没法像链表那样改指针就完事,必须把后面的元素一个个往前挪,时间复杂度O(n)。
LeetCode上很多数组题,核心矛盾就在这——题目要你“原地删除某个值”或者“原地调整顺序”,你每删一个元素就要搬一次数据。暴力解法写起来非常自然:外层遍历,找到了就内层循环把后面的元素整体前移。但这样一算,最坏情况是O(n^2),数据量一大就直接超时。
双指针解决的问题,从本质上看正是“用一次线性遍历,代替嵌套循环里的重复扫描”。它利用了数组随机访问的优势,用一个指针负责“读”(遍历整个数组),另一个指针负责“写”(维护新数组的有效位置),把原来“删哪个、挪哪些”的复杂操作,压缩成“一路读、有条件地写”的简单逻辑。
1.2 三种双指针模式,先分清再动手
数组双指针看起来都差不多,但实际有三条完全不同的路线。很多人在刷题时觉得“双指针我会,但看到题目不知道用哪个”,就是没先做这层分类。
第一种是快慢指针,也叫同向双指针。两个指针从同一端出发,一个走得快,一个走得慢。典型应用是“原地删除元素”“去除有序数组重复项”。快指针负责探索数组,慢指针负责记录有效输出位置。
第二种是左右对撞指针,两个指针分别从数组两端出发,向中间靠拢。典型应用是“有序数组的平方排序”“两数之和”“反转数组”。它能够利用数组的某种有序性,每走一步就排除掉一半可能,效率极高。
第三种是滑动窗口,本质也是同向双指针,但强调的是左右指针共同“夹”出一个区间窗口,通过右指针扩张、左指针收缩,动态寻找满足条件的子区间。典型应用是“长度最小的子数组”“无重复字符的最长子串”。
为了便于快速对照,我做一个表:
| 模式 | 指针方向 | 典型场景 | 时间复杂度 | 代表题目 |
|---|---|---|---|---|
| 快慢指针 | 同向,一快一慢 | 原地删除、去重 | O(n) | 移除元素、删除有序数组重复项 |
| 左右对撞 | 相向,两端向中 | 有序数组、回文、反转 | O(n) | 有序数组的平方、反转字符串 |
| 滑动窗口 | 同向,区间伸缩 | 子数组、子串最值 | O(n) | 长度最小的子数组、无重复字符的最长子串 |
判断一道题该用哪种,我的经验是看三件事:第一,题目是否要求“原地操作”;第二,数组本身是否有序,或者处理后是否有某种顺序可以利用;第三,要找的结果是不是一个“连续子区间”。把这三点对号入座,基本就不会选错方向。
1.3 不要迷信模板,先理解“不变量”
网上双指针的模板很多,但直接背模板有一个隐患:你很容易在边界条件上翻车。比如同样是fast和slow两个指针,为什么有些题写while (fast < nums.length),有些题却写while (slow <= fast)?这取决于你定义的循环不变量是什么。
所谓循环不变量,就是循环过程中始终保持不变的逻辑规则。你在每个循环步骤开始前定义一个状态,然后保证循环体执行完毕后这个状态依然成立,循环结束时答案自然也就正确了。写双指针代码前,先把“slow指向什么、fast指向什么、区间是左闭右开还是左闭右闭”这三句话写清楚,代码只是把这三句话翻译成语法而已。
我刷题时踩过最深的坑,就是没想清楚区间开闭,直接套模板。明明题目过了,换一组测试用例就溢出或者漏元素。所以下面每一道题,我都会先把“我定义的不变量是什么”讲清楚,再给代码。
2. 三道经典LeetCode题目实操拆解
2.1 移除元素:快慢指针的入门第一课
题目是LeetCode 27题:给你一个数组nums和一个值val,要求原地移除所有数值等于val的元素,返回移除后数组的新长度。不需要考虑超出新长度后面的元素是什么。
这道题最直观的暴力解法,是外层循环遍历数组,遇到等于val的元素,就内层循环把这个位置后面的所有元素整体前移一位。如果数组里有很多个val,每删一个就要搬一次,最坏情况下时间复杂度是O(n^2)。“能过”和“高效”的区别就在这里。
快慢指针解法就优雅得多。定义一个slow指针,它的含义是“下一个需要写入有效元素的位置”;再定义一个fast指针,负责从头到尾遍历原数组中的每一个元素。fast每遇到一个不等于val的元素,就把它写到slow指向的位置,然后slow向前走一步;fast遇到等于val的元素,直接跳过,什么都不做。
代码写出来是这样:
def removeElement(nums, val): slow = 0 for fast in range(len(nums)): if nums[fast] != val: nums[slow] = nums[fast] slow += 1 return slow整个过程只有一个for循环,时间复杂度O(n),空间复杂度O(1),完美满足原地操作的要求。
为什么这里不能用左右对撞指针?因为题目要求“移除等于val的元素,保持其余元素的相对顺序不变”。对撞指针对元素顺序的扰动很大,比如把后面的元素往前填,顺序就乱了。而快慢指针是严格按原顺序扫描的,后面写入的每一个元素都来自前面扫描过的合法元素,相对顺序天然不变。这就是选择快慢指针的根本原因,不是谁更简单,是题目要求决定了思路。
实操时我一般会手工跑一遍例子来验证不变量。比如nums = [3, 2, 2, 3], val = 3:
- 初始:slow=0, fast读nums[0]=3,等于val,跳过。
- fast读nums[1]=2,不等于val,写入nums[0]=2,slow变1。
- fast读nums[2]=2,不等于val,写入nums[1]=2,slow变2。
- fast读nums[3]=3,等于val,跳过。
- 结束,slow=2,新数组前两位是[2,2],返回2。
这里有个容易被忽略的细节:赋值操作用的是nums[slow] = nums[fast],不是交换。如果写成交换,数组里val的值虽然会被推到后面,但“移除后新长度后面元素无所谓”这个条件虽然允许这样,但当fast和slow指向同一个元素时交换没事,一旦指向不同元素,写错位置就会把还没扫描的元素提前覆盖掉,而且后续逻辑也会乱。这道题用覆盖就对了,没必要交换。
2.2 有序数组的平方:左右对撞指针的典型应用
题目是LeetCode 977题:给你一个按非递减顺序排序的整数数组nums,返回每个数字的平方组成的新数组,要求也按非递减顺序排序。
最暴力的方法,把每个元素平方,然后排序,时间复杂度O(n log n)。能过测试,但这道题的存在意义就在于检验你是否能利用“原数组已经有序”这个信息,把复杂度降到O(n)。
很多人第一反应是:数组有序,平方之后负数变正数,顺序可能就乱了,该怎么办?这里的关键洞察是,平方后的最大值一定出现在数组的两端。因为一个有序数组里,越靠左的负数绝对值可能越大,越靠右的正数绝对值也可能更大,所以整个平方数组的最大值,只会来自最左边或最右边。
于是左右对撞指针的思路就很自然了:让left指向最左边,right指向最右边,比较两个指针对应元素的平方大小,把更大的那个从结果数组的末尾往前填。填一个,就移动对应的指针,继续比较下一对。
def sortedSquares(nums): n = len(nums) result = [0] * n left, right = 0, n - 1 pos = n - 1 while left <= right: left_sq = nums[left] ** 2 right_sq = nums[right] ** 2 if left_sq > right_sq: result[pos] = left_sq left += 1 else: result[pos] = right_sq right -= 1 pos -= 1 return result为什么结果数组要“从后往前填”?因为每次比较选出来的都是当前剩余元素中平方最大的那个,如果从前往后填,先填入的是最大值,最终结果会是降序的,还得再反转一次。从后往前填,直接得到升序结果,一步到位。
这里还有一个容易犯浑的细节:while left <= right还是while left < right。如果数组只有一个元素,left == right,用left < right会漏掉这个元素,result最后一个位置永远空着不为0初始化值。这道题因为用pos记录填充位置,当left == right时,这个元素是剩余最后一个,必须填进去,所以必须用<=。这正好呼应前面说的“循环不变量”——你要保证每一个原始元素都被考虑过,一个都不能少。
不用调排序接口,也不引入额外数组来暂存,空间复杂度O(1)就完成了排序效果,这就是双指针的魅力。
2.3 长度最小的子数组:滑动窗口的收缩逻辑
题目是LeetCode 209题:给定一个含有n个正整数的数组和一个正整数target,找出该数组中满足其和大于等于target的长度最小的连续子数组,并返回其长度。如果不存在符合条件的子数组,返回0。
暴力解法很好想:枚举每一个起点,然后向后累加,直到和大于等于target就记录长度。两层循环,最坏O(n^2)。问题出在哪呢?每一轮内层循环都在重复累加之前已经加过的元素,这部分计算是冗余的。
滑动窗口的思路,是把“起点固定、终点移动”改成“窗口整体滑动”。用right指针不断向右扩张窗口,同时维护一个window_sum记录窗口内元素的和。每次right前进一步,就把新元素加入window_sum。一旦window_sum大于等于target,说明当前窗口满足条件了,记录长度。然后想办法缩小窗口——怎么缩?让left指针向右移动,每移一位,就从window_sum里减掉对应元素,再检查一次是否依然满足,如果满足就继续缩。直到不满足了,再让right继续向右扩。
def minSubArrayLen(target, nums): left = 0 window_sum = 0 min_len = float('inf') for right in range(len(nums)): window_sum += nums[right] while window_sum >= target: min_len = min(min_len, right - left + 1) window_sum -= nums[left] left += 1 return 0 if min_len == float('inf') else min_len这段代码有一个很关键的思维转变:while循环收缩左边界,实际上就是在枚举所有可能的子数组终点,同时以最经济的方式枚举起点。暴力解法的冗余在于,固定一个起点后,要一直加到很远才知道结果;而滑动窗口一旦发现当前窗口满足条件,立刻收缩左边界,尽力寻找更短的窗口,不满足条件后再向右扩展。这样left和right各自最多遍历数组一次,总复杂度是O(n)。
我在实际讲这道题的时候,常用一个类比:好比手里拿一把卷尺,右端先往外拉,拉到长度够了,左端就往回收,看能不能更短;左端收到不够长了,右端再继续拉。整个过程卷尺一直没离开过测量范围,这就是滑动窗口的直观画面。
有一点必须提醒:这题能用滑动窗口的前提,从题目描述就能看出来——数组是正整数。如果数组有负数,窗口变长的同时sum不一定是单调增加的,收缩逻辑就不成立了。所以刷题遇滑动窗口,先确认数组元素的非负性,这是一个极其重要的前置条件。
3. 边界条件与易错点排查
3.1 数组越界的几种死法
刷数组题,越界是所有错误的根源之一。双指针尤其容易在循环条件和指针自增顺序上出错。最常见的三种死法,我都踩过。
第一种,fast指针越界。快慢指针遍历时,习惯性写成while (fast <= nums.length - 1),本来没错,但如果你在循环体里写了nums[fast + 1],当fast指向最后一个元素时就会越界。解决方法是:如果明确只需要访问当前fast位置,写成while (fast < nums.length)即可;要访问fast + 1位置的话,得把循环条件写成while (fast + 1 < nums.length),思路完全不同。
第二种,left指针越过right指针后还在交换。对撞指针的标准写法是while (left <= right),但如果你在循环体里先移动left再判断,就可能在left > right之后又执行了一次赋值操作,把已经处理过的区域重新覆盖。我的习惯是,所有指针更新都放在循环体最后,并且每更新完一个指针就重新检查一次边界,不要等到下一轮循环再检查。
第三种,滑动窗口right跑到尽头后,left还在继续右移。注意上面209题的 while 循环是嵌套在 for 内部的,for right in range(len(nums))结束后外层循环就终止了。如果在同一个循环里又对left做了其它操作,或者想继续收缩窗口找更短答案,一定要先封装好递归或者继续单独一个while,避免凭空访问不存在的元素。
3.2 循环不变量:先写清楚再用代码翻译
我在前面反复强调“循环不变量”这个词,是因为它是双指针调试的核心工具。数组题不像设计模式,它没有太多“封装”的余地,所有逻辑就是指针的移动和数组元素的读写。如果循环不变量不清晰,代码是写不对的。
拿移除元素举例,我的不变量定义是:nums[0...slow)这个左闭右开区间内,存放的是所有已扫描过的、不等于val的元素,而这个区间的长度正好是slow本身。基于这个不变量,结尾直接return slow就是合法长度,根本不需要再数一遍元素个数。
拿有序数组的平方举例,不变量是:result[pos+1...n-1]已经存放了平方值最大的若干个元素,且排列是递增的。每次比较两端平方后填入result[pos],这个不变量就一直保持,最后整个result有序。
在纸上写下这行定义,通常只需要一分钟。但这一分钟能避免你在写错条件后在debug里浪费一个小时。我们刷题追求的不是“跑过了就算”,而是“每一步都有逻辑依据”。
3.3 调试与边界用例设计
写完整段代码,不要直接提交。先自己构造几个极端用例跑一遍,这几类用例我屡试不爽:
- 空数组:
[],候补答案是0还是-1,接口返回值是否符合预期。 - 单元素数组:
[5],val正好等于5,或者不等于5。 - 全相同数组:
[2,2,2],val=2,快慢指针的slow全程不动,看会不会越界。 - 目标值恰好等于边界值:比如target=6,数组
[2, 3, 1, 2],只有整个数组加起来才刚好等于6或者差一点点。 - 数组元素全为负数或全为正数,验证对撞指针的平方排序是否依然成立。
我有一个超实用的调试技巧:在循环里加一行临时打印,把slow和fast(或left和right)以及当前数组状态输出出来。跑两三轮,马上就能看出指针是不是按照预期在移动。很多代码看起来“逻辑没问题”,一跑起来才发现指针顺序反了、先写后读搞反了。打印就是最快的定位方式。
4. 常见问题排查与刷题节奏建议
4.1 常见问题速查表
把双指针数组题最常遇到的报错和维护问题整理成一个表,方便遇到问题直接对号入座:
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 运行超时(TLE) | 仍在使用嵌套循环;每次滑动窗口重新求和 | 检查是否存在O(n^2)代码;维护窗口累加值 |
| 数组下标越界(IndexOutOfBounds) | 循环条件写错;fast访问了fast+1;left超过right后继续操作 | 统一用<而非<=;所有指针移动放循环尾;先判定边界再读写 |
| 返回结果长度不对 | 忘记slow本身就是新长度;或误用了原数组长度 | 确认返回值是slow、left等指针的最终位置 |
| 结果顺序不对 | 对撞指针从前往后填;区间开闭定义不一致 | 明确升序/降序要求;必要时从结果数组尾部倒序填入 |
| 某些测试用例通过,某些失败 | 循环内提前返回;边界判断与主体逻辑混在一起 | 单独设计空数组、单元素数组等极端用例逐一调试 |
4.2 为什么推荐按照代码随想录数组专题的顺序刷
代码随想录把数组01放在最前面,接着就是二分查找、移除元素、有序数组的平方、长度最小的子数组,这个顺序不是随便排的。它是在用几个最典型的题目,把数组题最核心的四种操作——查找、删除、排序、子区间——都覆盖一遍。而双指针恰好是这四类操作里最有复用价值的工具。
我建议第一遍刷的时候,每道题都按“暴力解法 -> 双指针解法 -> 总结不变量”三部曲来走。不要一上来就想着最优解,暴力代码虽然效率低,但它能帮你理清问题本身到底在要求什么。然后在此基础上推导,为什么可以省掉一层循环,其实就是在逐步接近双指针的设计思路。
第二遍刷的时候,尝试“裸写”——不翻笔记,不看题解,直接写代码。你会发现第一遍理解的“不变量”在裸写时会暴露大量漏洞,因为参考答案时你只是看懂,自己写时需要真正组织逻辑。第三遍隔一周再回来写,重点写变体题。比如移除元素改一个条件,变成“删除所有重复项”“移动零到数组末尾”;滑动窗口改一个维护指标,变成“乘积小于k的子数组”。这种变体训练比做十道新题还有用。
4.3 双指针学到什么程度算“入门了”
我的判断标准很简单。第一,看到一道数组题,能快速说出它属于快慢、对撞、滑动窗口中的哪一类,或者能明确说出为什么不属于任何一类;第二,能写出循环不变量,哪怕不写在纸上,心里要有一句清晰的话;第三,能在不查资料的情况下写出O(n)的解法,并且能对着极端用例解释代码为什么不会越界。
这三点都做到了,数组01专题就可以算过。之后遇到的链表双指针(环形链表判断、寻找倒数第k个节点)、字符串双指针(反转字符串、验证回文串)、哈希表与双指针的组合题(三数之和、四数之和),其实都是今天这三道题的变形。底子打牢了,后面的路会顺畅很多。
最后分享一个我个人的习惯:每次做完一道双指针题,我都会在代码注释里写一行“这里如果改成XX条件就会用到另一种指针模式”,算是给自己留一条思考的记录。三个月后回头看,你会发现当初在评论区、题解里死记硬背的东西,突然都能串起来了。数组双指针是算法面试前几关中最值得反复打磨的一关,练熟它,性价比极高。