刷算法题,双指针是我最早掌握的套路之一。原因很简单:它不依赖高深的数据结构,只需要两个下标,跑一跑循环,就能把很多 O(n²) 的暴力解法优化成 O(n)。双指针在面试、竞赛和日常工程优化里都很常见,尤其配合排序数组、链表和滑动窗口,几乎是必考能力。这篇文章会把它的核心思想、通用模板、高频题型和常见的坑一次性讲透,适合刚接触算法的人,也适合刷题冲刺期想系统整理模板的朋友。
1. 双指针算法为什么值得专门练习
1.1 双指针的本质:两个游标代替两重循环
双指针本质上不是一个函数、一个类,而是一种迭代策略。它要处理的最常见场景是:在一个数组、字符串或链表上,需要比较两个位置的信息,或者需要维护一段区间。最直接的做法是两层循环,把所有可能的 i、j 组合遍历一遍,复杂度是 O(n²)。这种做法在数据量上来之后会迅速变得不可用,而双指针的思路是,让两个游标按照序列本身的性质朝确定的方向移动,把大量不需要检查的组合直接跳过。判断能不能跳过的关键,是序列是否具有单调性,或者问题是否只要求局部最优区间。
我最早理解这个套路是解 LeetCode 167 两数之和 II。题目给的是有序数组,暴力跑两层循环当然能过,但面试官一定想看你把时间复杂度降下来。用 left 指向第一个元素、right 指向最后一个元素,每次计算两者的和,如果比 target 大,说明只有把 right 向左移动这一方向能让和变小;如果比 target 小,说明只有把 left 向右移动能让和变大。每一轮都能排除一个位置,保证不会漏过正确答案。
还有一个容易忽略的点:双指针同样是很多高级算法的基础。二分查找可以看作一种特殊的双指针,只不过每次移动的距离是区间的一半;滑动窗口是同向双指针的变体;三数之和、四数之和则是相向双指针的嵌套。所以把双指针吃透,后面学快排、滑窗、二分都会顺手很多。
1.2 适用场景:看到这些线索直接锁定双指针
我刷题时总结了一些关键词,只要题目里出现这些信息,会优先往双指针上想:
- 有序数组或有序链表:排序后产生的单调性让双指针移动有明确依据,比如两数之和、三数之和。
- 原地修改:题目明确说不能开额外数组、只能修改输入数组,快慢指针是标准解法,比如移动零、删除重复项。
- 链表环检测和链表中点:链表不能随机访问,快慢指针用它特殊的步长解决,比如环形链表、链表中点、倒数第k个节点。
- 子串或子数组的最值问题:最长不重复子串、最短覆盖子串,本质上是滑动窗口,也就是同向双指针维护一个动态区间。
- 回文串判断、反转数组:从两端向中间夹逼,左右指针每轮交换一对元素。
这些场景并不是双指针的全部,但覆盖了绝大多数笔试题。看到这些线索,先别急着写两重循环,试着画两个游标走一遍,往往会有奇效。
2. 双指针代码模板与手写注意事项
2.1 同向双指针模板:快慢指针通用写法
同向双指针的特点是 left 和 right 都从序列头部出发,right 负责扩大搜索范围,left 负责维护满足条件的有效区域。最经典的场景是原地删除有序数组中的重复项。
int removeDuplicates(vector<int>& nums) { if (nums.empty()) return 0; int slow = 0; for (int fast = 1; fast < (int)nums.size(); ++fast) { if (nums[fast] != nums[slow]) { ++slow; nums[slow] = nums[fast]; } } return slow + 1; }这段代码的写法可以抽象成一个模板:slow 指向当前有效区间的最后一个位置,fast 负责遍历整个数组。当 fast 发现一个满足保留条件的元素时,先把 slow 前移一位,然后把 nums[fast] 写到 slow 所在位置。这样数组前 slow+1 个元素就是处理后的结果,后面多余的值可以直接忽略。数组长度是 slow+1,而不是 fast 值。
这个模板还能套到很多题上,比如移动零、移除指定值、压缩字符串。核心就一句话:决定当前元素是否保留,保留就赋值给 slow 位置,然后 slow 前进一步。唯一需要注意的是,如果题目要求保留元素的相对顺序,那么快慢指针必须都从左往右走,不能从两端夹逼。
2.2 相向双指针模板:左右夹逼的写法
相向双指针常见于有序数组的查找、反转数组、三数之和等场景。模板的运动方向相反:left 从头部开始,right 从尾部开始,每轮比较两个指针指向的值,再决定移动哪一边。
vector<int> twoSum(vector<int>& numbers, int target) { int left = 0, right = numbers.size() - 1; while (left < right) { int sum = numbers[left] + numbers[right]; if (sum == target) return {left, right}; if (sum < target) ++left; else --right; } return {}; }需要注意这里返回的下标是从 0 开始的版本,如果题目要求返回从 1 开始的下标,记得在返回值里加 1。循环条件是 left < right 而不是 left <= right,因为左右指针代表两个不同的位置,不能用同一个元素两次。
相向双指针的正确性依赖单调性。以两数之和为例,数组排过序,所以当 left 向右移动时 sum 会变大,right 向左移动时 sum 会变小。如果当前 sum 小于 target,right 向左只会让 sum 更小,不可能接近 target,因此唯一合理方向是 left 右移。这种“唯一方向”的推理是双指针能跳过大量无效组合的理论基础。
2.3 滑动窗口模板:同向双指针的进阶版
滑动窗口可以看作同向双指针的特例:right 每次移动一格把新元素纳入窗口,当窗口内不满足条件时,left 不断右移直到重新满足。窗口像一条长度可变的区间,用来解决子串、子数组的极值问题。
以最长无重复子串为例,窗口右端加入新字符,如果发现重复,就把左端收缩到不包含重复字符的位置:
int lengthOfLongestSubstring(string s) { unordered_map<char, int> last; // 字符最后出现的位置 int left = 0, ans = 0; for (int right = 0; right < (int)s.size(); ++right) { char c = s[right]; if (last.count(c)) { left = max(left, last[c] + 1); } last[c] = right; ans = max(ans, right - left + 1); } return ans; }这里用哈希表记录每个字符最近一次出现的位置。当遇到重复字符时,直接把 left 跳到上一次出现位置的后一位,保证窗口内没有任何重复字符。每轮计算当前窗口长度并更新答案。滑动窗口的通用套路可以总结为:right 扩展,调整状态;当窗口不满足约束时 left 收缩;在合适的时机记录答案。记住这个顺序,很多题都能套。
3. 高频题型拆解:这些题目其实都是同一招
3.1 数组原地操作:移动零与删除重复项
移动零是快慢指针的入门题,要求把数组里的 0 移到末尾,同时保持非零元素相对顺序。你没有必要真的“搬运”零,只需要把所有非零元素按顺序写到数组前面,末尾补 0 即可。
void moveZeroes(vector<int>& nums) { int slow = 0; for (int fast = 0; fast < (int)nums.size(); ++fast) { if (nums[fast] != 0) { nums[slow++] = nums[fast]; } } while (slow < (int)nums.size()) nums[slow++] = 0; }这里的 slow 一直指向下一个可填位置。fast 每遇到一个非零元素,就把值放到 slow 位置,然后 slow 加 1。遍历完之后,从 slow 到末尾全部置零。这个写法的时间复杂度是 O(n),空间复杂度 O(1),符合大多数面试对原地操作的期待。我在做移动零时其实踩过一个小坑:如果直接用交换法 swap,也能做,但要注意当 fast 和 slow 指向同一个位置时交换没有意义,虽然不影响结果,但会让代码看起来很奇怪。覆盖法的好处是思路统一,从“剔除”角度去思考,就不容易出错。
删除有序数组重复项和移动零的套路几乎一样。唯一区别是保留条件从“不等于 0”变成了“不等于前一个已经保留的元素”。因为数组有序,重复元素一定连续,所以只需要拿当前 fast 元素和 slow 位置的元素比较。这也是为什么很多题被归类为“一个模板解决所有原地过滤问题”。
3.2 链表里的快慢指针:环检测与倒数第k个节点
链表不能随机访问,所以双指针在链表里的用法又不一样。判断链表是否有环的经典 Floyd 算法,快指针每次走两步,慢指针每次走一步,如果链表有环,两者一定会在环里相遇。快慢指针的速度差是 1,每次循环后距离缩短 1,所以不会跳过相遇点。
bool hasCycle(ListNode *head) { if (!head || !head->next) return false; ListNode *slow = head, *fast = head->next; while (slow != fast) { if (!fast || !fast->next) return false; slow = slow->next; fast = fast->next->next; } return true; }这里的边界条件很关键。如果 head 为空或者只有一个节点,一定无环,直接返回 false。如果 fast 已经为空,说明链表走到结尾,也不可能有环。我见过不少新手把 fast 移动写在判断前面,结果 fast 已经为 null 还在访问 fast->next,直接空指针崩溃。正确顺序是先检查 fast 和 fast->next 是否为空,再决定是否继续移动。
链表里还有一个高频题:找到倒数第 k 个节点。做法是让快指针先走 k 步,然后快慢指针同步走。快指针走到链表末尾时,慢指针正好指向倒数第 k 个节点。这个思路本质上是通过固定间隔制造一个“差 k 步”的滑动窗口,比两次遍历少一次扫描。
3.3 有序数组上的相向双指针:两数之和与三数之和
两数之和 II 已经演示过相向双指针,三数之和则是它的升级版。三数之和要求找到所有和为 0 的三元组,并且不能重复。最自然的方法是先排序,固定第一个数,然后在剩余区间里做两数之和。
vector<vector<int>> threeSum(vector<int>& nums) { sort(nums.begin(), nums.end()); vector<vector<int>> res; int n = nums.size(); for (int i = 0; i < n - 2; ++i) { if (i > 0 && nums[i] == nums[i-1]) continue; if (nums[i] > 0) break; int left = i + 1, right = n - 1; while (left < right) { int sum = nums[i] + nums[left] + nums[right]; if (sum == 0) { res.push_back({nums[i], nums[left], nums[right]}); while (left < right && nums[left] == nums[left+1]) ++left; while (left < right && nums[right] == nums[right-1]) --right; ++left; --right; } else if (sum < 0) { ++left; } else { --right; } } } return res; }这个题有几个细节值得反复咀嚼。第一,外层循环跳过重复值,枚举过的 nums[i] 不会再次作为第一个数出现,避免结果里有重复三元组。第二,因为数组已经排序,如果 nums[i] 大于 0,那么剩下两个数都大于等于它,三数和不可能为 0,可以直接 break,这是最常见的剪枝。第三,找到一组答案后,内层也要跳过重复的 left 和 right,否则同一个三元组会以不同顺序被重复记录。跳过重复后别忘了再做一次 left++、right--,否则指针会卡在重复区间附近。
3.4 子串子数组里的滑动窗口:从最长无重复到最短覆盖
滑动窗口题型有一个很重要的判断:什么时候记录答案,决定了求的是最大值还是最小值。求最长无重复子串时,我们在窗口满足条件后记录长度;求最短覆盖子串时,我们在窗口满足条件时尝试收缩 left,并且每次收缩前记录当前窗口长度。这两个是相反的流程,混了很容易做错。
以最小覆盖子串为例,思路是维护 t 中字符的需求频次。right 扩展时,把对应字符的需求计数减一,表示窗口中多覆盖了一个 t 里的字符;当覆盖的字符数等于 t 的总长度时,说明当前窗口已经包含 t 中所有字符。这时尝试移动 left 收缩窗口,移除 left 指向的字符。如果该字符是 t 需要的,需求计数加一,覆盖数减少,窗口不再满足条件,于是继续扩展 right。
这个过程中 left 和 right 都只向前移动,所以整体复杂度是 O(n),而不是看起来的嵌套循环。使用哈希表记录需求频次后,代码会更统一。我在面试中被问到这类题时,基本会先写一个通用框架:定义两个指针,一个维护窗口右边界,一个维护窗口左边界,外加一个计数器。代码结构固定后,再根据题目要求调整收缩逻辑和答案记录位置,出错率会低很多。
4. 常见错误、边界陷阱与复杂度分析
4.1 最容易踩的五个坑
我把刷题时常见的错误整理成了速查表,这五类错误基本覆盖了双指针问题里 90% 的调试场景。
| 错误类型 | 错误原因 | 正确做法 |
|---|---|---|
| 空指针访问 | 链表题里没区分 fast 和 fast->next 就移动 | 先判断 fast 是否为空,再移动指针 |
| 循环条件写错 | 相向双指针用 left <= right | 两个位置不能重合,通常用 left < right |
| 答案记录时机不对 | 滑动窗口在错误位置更新结果 | 求最长时往往在收缩后更新,求最短时在收缩前更新 |
| 去重后指针没移动 | 跳过重复值后直接进入下一轮 | 跳过重复后需要额外 left++ 或 right-- |
| 忽略输入边界 | 空数组、空链表、单元素数组直接访问下标/节点 | 开头统一处理空输入和极端短输入 |
很多边界错误并不是逻辑想法有错,而是写代码时太着急。比如相向双指针,如果你不小心把循环条件写成 left <= right,当数组长度为偶数时,最终会出现 left 和 right 指向同一个位置的情况,某些题目里会把同一个元素用两次,答案就会出现错误。这种问题在代码 review 阶段很难发现,但写之前先在纸上画一个长度为 2 的例子,很容易暴露出来。
4.2 边界条件怎么想才不会慌
我自己有一个习惯:写代码之前先在草稿纸上画一个最小的例子,比如空数组、只有一个元素、两个重复元素。然后把 left、right 在每一步的取值写出来,看看循环退出时是不是你想要的结果。不要靠猜边界。
例如反转字符串,left 从 0 开始,right 从 len-1 开始,循环条件是 left < right。长度为偶数时,最后一次循环 left 和 right 相邻,交换后 left++、right--,循环退出后正好完成。长度为奇数时,最终 left 和 right 指向同一个中间元素,不需要交换,循环条件让它直接退出。如果写成 left <= right,中间那个元素会被自己交换一次,虽然不报错,但在另一些涉及唯一性的问题里就可能出错。
链表题的边界更直接:head 为空或者只有一个节点时,快指针可能一开始就为空。所以环形链表这类题的经典开头就是 if (!head || !head->next) return false。把这个防御逻辑写在前头,后面才能安心地进行快慢指针追赶。
4.3 复杂度和剪枝收益
双指针能达到 O(n) 的核心原因是每个指针最多移动 n 次。即使滑动窗口内部有 while,由于 right 和 left 都只前进不后退,均摊下来每个元素最多被 left 和 right 各访问一次,所以总操作次数是 O(n)。这是理解滑动窗口复杂度的关键,也是面试时最容易回答卡壳的地方。
如果题目需要排序,比如三数之和,复杂度就是排序的 O(n log n) 加双指针枚举的 O(n²)。表面上看,固定一个数、再双指针扫描整个区间,最坏情况似乎是 O(n²),但这已经比三重循环的 O(n³) 好了一个量级。剪枝还能把常数进一步压下来,比如三数之和遇到 nums[i] > 0 直接 break,后面更大的 i 根本不需要枚举;去重逻辑也会让重复测试减少。
不过剪枝的前提是正确性不受影响。我见过有人为了追求速度,在二数之和里加了一个 if (sum < target-...)” 的提前判断,结果把正确答案剪掉了。所以我的建议是:先把无剪枝的双指针写对,再考虑优化。复杂度分析处不需要过度追求常数级优化,线性或 nlogn 已经满足绝大多数面试要求。
5. 从零到熟练的刷题建议与心得
5.1 推荐练习顺序和题目清单
如果你刚开始练双指针,我建议按下面这个顺序刷题,每一道题都刻意用模板去套。不要只追求 AC,要能把 Simpler 的模板默写过一遍,然后再改条件。
| 题目 | 类型 | 备注 |
|---|---|---|
| 反转字符串 344 | 相向双指针 | 最入门的对撞操作 |
| 移动零 283 | 快慢指针 | 原地覆盖法 |
| 删除有序数组重复项 26 | 快慢指针 | 掌握有效区间维护 |
| 两数之和 II 167 | 相向双指针 | 理解单调性 |
| 环形链表 141 | 快慢指针 | 注意空指针 |
| 盛最多水的容器 11 | 相向双指针 | 证明双指针移动的合理性 |
| 三数之和 15 | 相向双指针+去重 | 高频面试题 |
| 最长无重复子串 3 | 滑动窗口 | 理解收缩时机 |
| 最小覆盖子串 76 | 滑动窗口+频次计数 | 进阶题,值得反复做 |
我自己的感受是,前四道题一天就能刷完,关键是第五题链表环检测能帮你想清楚快慢指针为什么不会死循环。三数之和和滑动窗口是分水岭,做完它们,双指针的整体框架就扎实了。之后碰到新题,你大概率会条件反射地问自己:这个问题能用同向、相向还是滑动窗口?
5.2 面试时的口述思路和调试技巧
面试时不要上来就写代码。先跟面试官讲清楚套路:我准备用两个指针,left 和 right 分别代表什么,每一步移动的条件是什么,为什么这个移动不会漏掉答案。这三句话说清楚,代码基本就顺了。你越早把移动方向的理由说出来,面试官越容易理解你在解哪一类题。
平时练习时可以尝试写完代码后自己模拟一个小样例,把 left、right 的变化打印出来,对比预期。我在调试时会在循环开头加一行输出:cout << left << " " << right << endl; 如果发现指针不按预期移动,或者陷入死循环,这一行输出能最快定位问题。滑动窗口题打印窗口的长度,链表题打印当前节点值,这比看代码空想要快得多。
另外一个小技巧:把模板抄在一张纸上,遇到题目先判断属于同向还是相向还是滑动窗口,再套模板改条件。看着笨,实际稳。我面试前就是靠这张纸恢复手感,遇到剑指 Offer 里的链表倒数第 k 个节点、括号序列等题,都能快速从模板里找思路。
我个人使用双指针的经验是:它是最容易建立“算法直觉”的套路之一。当你刷过几十道题,你会越来越明显地感觉到,很多题表面完全不同,骨子里其实就是那几行循环。双指针不是银弹,但它是性价比极高的工具,值得花几天时间专门吃透。希望这篇总结能帮你把模板真正内化,也欢迎在评论区交流你自己踩过的双指针的坑。