news 2026/10/27 15:10:01

双指针算法详解:左右指针、快慢指针与滑动窗口经典题拆解

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
双指针算法详解:左右指针、快慢指针与滑动窗口经典题拆解

刷题一段时间后你会发现,双指针几乎就是那类“一看答案秒懂,自己写就大脑空白”的题。原因很简单:双指针题的根本目的不是让你用一个指针遍历数组,而是让你用两个指针去消掉本来冗余的一层循环,把暴力枚举的 O(n²) 直接压到 O(n)。这篇是我整理的**【优选算法1】双指针经典算法题**,包含了左右指针、快慢指针、滑动窗口的题目拆解、完整代码和现场调试心得。适合正在准备面试、刚开始系统刷算法题、或者想把这套技巧一次理顺的同学参考。

我不打算按题单流水账地贴答案,而是想把每道题背后“为什么指针要往这边走”“为什么不会漏答案”的原因讲透。毕竟面试考算法,考的不只是背题,而是你清不清楚每一步移动的依据。下面直接进入正文。

1. 双指针的本质与两种常见形态

1.1 它本质上是“有方向地剪枝”

先看一个最简单的例子:给你一个递增数组,找两个数之和等于 target。如果用暴力,那就是双重循环固定 i、枚举 j,每次重新累加,复杂度 O(n²)。但双指针的思路是:让两个指针分别站在数组最左和最右,根据当前和与 target 的大小关系,决定往中间调整哪一侧。为什么能这么做?因为数组有序,当你把右指针往左移动时,总和只会变小;把左指针往右移动时,总和只会变大。也就是说,每次比较之后,你都能确定地排除了一个方向的全部可能性。

这就是双指针的核心:利用数据的单调性,让每一次指针移动都等价于砍掉一片无效搜索空间。它不是玄学,也没有魔法,只是把“本来要反复试探的枚举过程”压缩成了一条从两端向中间逼近的路径。

1.2 左右指针、快慢指针、滑动窗口

双指针在题目里一般有这几种表现形态,先给自己建立一个整体框架:

  • 左右指针(对撞指针):一个在数组头部、一个在尾部,通常针对有序数组或需要两端夹逼的问题,典型题包括两数之和、三数之和、盛最多水的容器、接雨水。
  • 快慢指针:一个走得慢、一个走得快,主要用于链表判环、找链表中点、找倒数第 k 个节点,也可以用来处理原地数组覆盖问题。
  • 滑动窗口:本质上是两个同向指针,左边界负责收缩、右边界负责扩张,维护一个“当前窗口”的区间信息,典型题包括最长无重复子串、最小覆盖子串、长度最小的子数组。

这三种形态并不是互相独立的,很多题其实是同一种思想换了个包装。你只要抓住“指针为什么要移动”和“移动后哪些信息可以稳定复用”这两点,就不会被题目表象绕晕。

1.3 什么样的信号提示你可以用双指针

我自己总结出几个信号,听到之后优先往双指针上靠:

  • 题目要求找“一对元素”,并且数组是有序的,或者可以排序;
  • 要求最短/最长连续子区间,且区间边界随着枚举可以线性推进;
  • 链表题里出现“是否有环”“倒数第几个”“中间位置”这些关键词;
  • 要求原地删除某些元素、去重,并且只允许使用常数额外空间。

当然,这些信号不是绝对的,但作为刷题阶段的思维习惯,它足以帮你快速圈定尝试方向。

2. 从零上手:两道开胃菜的完整拆解

2.1 有序数组两数之和:为什么指针不会漏答案

题目要求:在一个升序整数数组中,找到两个数使得它们的和等于目标值 target,返回下标(假设存在唯一答案)。

暴力写法大家都会,这里直接看双指针版本。我用 C++ 写:

vector<int> twoSum(vector<int>& nums, int target) { int left = 0, right = nums.size() - 1; while (left < right) { int sum = nums[left] + nums[right]; if (sum == target) { return {left, right}; } else if (sum < target) { left++; } else { right--; } } return {}; }

为什么可以这样移动?关键在“有序”两个字。当 sum < target 时,说明整体太小,需要把和变大。此时右指针已经是当前区间里最大的元素,为了让和变大,只能把左指针向右移动,换个更大的数。反过来,当 sum > target 时,说明整体太大,右指针向左移动,换个更小的数。

一个常见的疑问是:如果我把左指针往右移动了,会不会错过某个正确答案?答案是“不会,因为这个正确答案根本不在刚才那个 left 位置与当前 right 及更右侧元素的组合中”。在 sum < target 时,左侧当前值加右侧任何元素都会小于 target(因为右边最大值的组合都已经不够大),所以这个 left 位置可以直接丢弃。每次移动都是在“排除一个不可能出现答案的区域”,这就是双指针不重不漏的根本原因。

2.2 最长无重复子串:滑动窗口其实也是双指针

如果说左右指针是“对撞”,那滑动窗口就是“并肩走”。经典的“无重复字符的最长子串”可以写得非常简短,但前提是你理解左指针的跳跃逻辑。

int lengthOfLongestSubstring(string s) { unordered_map<char, int> last; int left = 0, ans = 0; for (int right = 0; right < s.size(); right++) { if (last.count(s[right]) && last[s[right]] >= left) { left = last[s[right]] + 1; } last[s[right]] = right; ans = max(ans, right - left + 1); } return ans; }

这里的左指针 left 表示当前窗口的左边界,右指针 right 不断向右扩展。每次遇到一个已经出现过的字符,且它的上一次出现位置还落在当前窗口内,说明出现了重复,就需要把左边界直接跳到该字符上次出现位置的下一个位置。

为什么是“直接跳”而不是“一格一格左移”?因为从上一个 left 到 last[s[right]] 这个区间内的所有位置,都可以作为新的左边界尝试,但只要重复字符还在窗口内,窗口就永远不会合法。与其逐个尝试,不如一步跳到不会冲突的最近位置,这一步包含了一个贪心思想:把窗口收缩到“刚好解决冲突”的状态,既不会扩大错误的范围,也不会错过更长窗口。记住维护哈希表里保存的是“上一次出现位置”,滑动窗口判断时用last[s[right]] >= left来判断重复是否真正发生在当前窗口内,这是很多人容易写错的细节。

2.3 把“单调性”讲得更直观一点

如果你觉得上面两题有点抽象,可以这样想:两个指针在一个平面上走,每次移动都是沿着一个方向前进,走过的区域就被划掉了。比如两数之和里,left 向右、right 向左,所有被划掉的区域都不可能是答案;最长无重复子串里,left 一旦跳到新位置,旧窗口里所有可能的起点也都已经被“证明不优”或者“必然非法”。理解到这一层,双指针就不再是死记硬背,而是一种可以迁移的思维模型。

3. 链表场景:快慢指针的经典玩法

3.1 链表中环的检测:为什么快指针只比慢指针快一步

链表题里,快慢指针最出名的应用就是判断链表是否有环,并且找到环的入口。常规思路是拿哈希表存访问过的节点,但空间复杂度是 O(n)。快慢指针能把额外空间降到 O(1)。

核心代码:

ListNode* detectCycle(ListNode* head) { ListNode* slow = head; ListNode* fast = head; while (fast != nullptr && fast->next != nullptr) { slow = slow->next; fast = fast->next->next; if (slow == fast) { ListNode* ptr = head; while (ptr != slow) { ptr = ptr->next; slow = slow->next; } return ptr; } } return nullptr; }

为什么快指针走两步,慢指针走一步?因为两者的相对速度是“每轮距离减少 1”。如果链表有环,慢指针进入环后,快指针会在环里以每次接近一步的速度追上它,最终必然相遇。如果快指针走三步甚至四步,反而可能出现“跨越”慢指针却不相遇的尴尬情况,比如环长恰好能被相对速度整除时也难以保证稳定相遇。

入口推导也不复杂。设链表头到环入口的距离是 D,环长为 C,快慢指针相遇时慢指针在环内走了 X 步。慢指针总共走了 D + X,快指针走了 2(D + X)。两者相差 D + X 正好等于快指针在环内多跑的圈数的整数倍,即 D + X = kC。于是 D = kC - X,也就是说:从当前相遇点继续往前走到环入口的步数,等于从链表头走到环入口的步数(模环长意义下)。所以让一个指针从链表头重新出发,另一个从相遇点继续走,两者每次各走一步,第一次相遇处就是环入口。这个数学推导我第一次看的时候绕了很久,但一旦写下来会发现每一步都非常清爽。

3.2 找链表倒数第 k 个节点的一段小技巧

同样的快慢思想还能用来找倒数第 k 个节点。让快指针先走 k 步,然后快慢指针同步前进。当快指针到达链表末尾时,慢指针指向的就是倒数第 k 个节点。

这个思路之所以有效,是因为它把“倒数”转化成了“正数距离差”。先走 k 步,相当于快慢指针之间建立了一个长度为 k 的固定标尺;之后同步移动时,快指针每走一步,慢指针也走一步,始终保持这个距离差。快指针到终点时,慢指针自然停在“从终点往回数第 k 个”的位置。还需要注意 k 大于链表长度时的边界判断,真实面试里经常会在这种细节上埋坑。

3.3 快慢指针在数组里的变体:读写指针

快慢指针不只用在链表上,数组里的“原地删除元素”“移动零”“有序数组去重”本质都是同一套玩法。我在这里放一个最经典的“去除指定元素”模板:

int removeElement(vector<int>& nums, int val) { int write = 0; for (int read = 0; read < nums.size(); read++) { if (nums[read] != val) { nums[write++] = nums[read]; } } return write; }

这里的 read 是快指针,负责扫描所有元素;write 是慢指针,指向下一个可写入位置。快指针判断当前元素是否满足保留条件,慢指针负责覆盖旧值。之所以能原地操作,是因为 write 的移动速度永远不超过 read,旧元素在被覆盖前已经完全失去价值,所以不存在信息丢失问题。移动零的题目只需要在这个模板之后再把剩余位置补上 0;去重则是维护一个“已保留区间”,只要当前扫描值不等于区间末尾值就扩展区间。一个模板能串起好几道面试常考题,非常划算。

4. 进阶实战:几道高频双指针题的做法与权衡

4.1 盛最多水的容器:最短的那块板决定高度

题目给一个数组 height,height[i] 表示第 i 条垂线的高度,找两条线,使得它们与 x 轴构成的容器能装最多水。

这题标准的双指针解法是从两端开始,维护当前最宽区间,然后移动较矮的那一边。

int maxArea(vector<int>& height) { int left = 0, right = height.size() - 1, ans = 0; while (left < right) { int cur = (right - left) * min(height[left], height[right]); ans = max(ans, cur); if (height[left] < height[right]) { left++; } else { right--; } } return ans; }

为什么要移动较矮的那一边?假设当前左边高度 3、右边高度 7,容器容量受限于左边这块短板。如果把较高的一边往里移,新的高度顶多还是 7,但宽度变小,容量一定下降。所以较高边在当前状态下已经没有“留下来变优”的潜力。相反,把较矮的一边往里移动,虽然宽度变小,但有可能遇到一个更高的新边,从而提升容量上限。这个逻辑本质上还是“单调性剪枝”:移动高边这一侧的所有剩余状态,都不可能超过当前已经记录过的某个解;而移动矮边一侧,才有保留探索价值的必要。

4.2 接雨水:左右指针加两条“前缀最大值”动态维护

接雨水是双指针题里的进阶代表。暴力枚举每个位置,往左看最高柱子、往右看最高柱子,取二者较小值减去当前位置高度就是积水量,复杂度 O(n²)。经典优化之一是先用数组预处理左右两侧最大值,但双指针可以连额外数组都不要。

int trap(vector<int>& height) { int left = 0, right = height.size() - 1; int leftMax = 0, rightMax = 0, ans = 0; while (left < right) { leftMax = max(leftMax, height[left]); rightMax = max(rightMax, height[right]); if (leftMax < rightMax) { ans += leftMax - height[left]; left++; } else { ans += rightMax - height[right]; right--; } } return ans; }

关键理解:对于当前 left 位置,它能接到的雨水取决于min(左侧最大值, 右侧最大值)。如果此时leftMax < rightMax,哪怕右侧最大值是暂时的,但至少说明 left 位置右侧已经存在一个足够高的边界,真正限制它积水量的就是左侧最大值 leftMax。所以当前位置的积水量可以直接算出来,算完 left 向右移动。反之右侧同理。这里的 leftMax 和 rightMax 不是预先算出来的,而是随着指针移动动态维护的,这比先扫一遍数组求前缀最大值再计算更省空间,也是双指针代码最妙的地方。

4.3 三数之和与更多变形:双指针如何套进循环

三数之和要求找到所有三元组[nums[i], nums[j], nums[k]],满足三者之和为 0 且不重复。它的标准做法是:先排序,然后固定一个位置 k,在剩余区间里用双指针找两数之和等于-nums[k]。

vector<vector<int>> threeSum(vector<int>& nums) { sort(nums.begin(), nums.end()); vector<vector<int>> ans; int n = nums.size(); for (int i = 0; i < n - 2; i++) { if (i > 0 && nums[i] == nums[i - 1]) continue; int left = i + 1, right = n - 1; while (left < right) { int sum = nums[i] + nums[left] + nums[right]; if (sum == 0) { ans.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 ans; }

这题最容易错的是去重。固定位置的去重要在进入双指针之前处理,否则同一个 i 会重复计算;找到一组答案之后,还要让左右指针跳过所有重复值,否则会输出相同三元组。双指针在这里的作用是把“内层两数之和”从 O(n²) 降到 O(n),整体从 O(n³) 降到 O(n²)。

类似套路的题目还包括最接近的三数之和、四数之和,它们的骨架都是“排序 + 外层枚举固定几个位置 + 内层双指针收缩”,熟练之后可以形成一套统一的解题模板。

5. 初学双指针的五个高频问题与排查思路

5.1 死循环是怎么产生的

双指针最常见的死循环原因是:指针移动条件写反,或者某种情况下指针完全没移动。举个例子,如果在两数之和里你把sum < target写成sum <= target,当遇到恰好相等时仍然移动左指针,看起来没毛病,但后续可能出现和始终相等或左右交替移动,最终 left 超过 right 还在循环里出不来。

排查时可以打印每个循环的 left、right 和当前计算结果,观察指针是否每轮至少移动一位。双指针的代码原则上每一轮都必须让 left 或 right 至少移动一位,否则区间不会收缩,算法不可能终止。这是我写题时一定会检查的第一件事。

5.2 left < right 还是 left <= right

这取决于你的求解目标。两数之和要求两个不同的下标,所以条件是left < right。有些题目需要包括“自身与自身”的情况,比如在有些数组题里如果允许同一个元素被使用两次,条件就要写成left <= right。更稳妥的判断方式是:先想清楚循环结束时指针相遇的那个位置还有没有可能构成答案,如果不可能就用<,可能就需要用<=。这个边界问题看起来小,但恰恰是面试手写代码时最容易画蛇添足或漏答案的地方。

5.3 我的指针移动为什么“少了半截”

还有一类典型错误是移动幅度不够。滑动窗口题里,窗口左边界应该在发现重复时跳到重复位置的下一位,而不是只加一。如果只加一,重复字符仍然在窗口内,问题没有真正解决;如果跳过头,又会漏掉潜在可行解。这类错误的本质是没有想清楚“状态合法条件”,每次移动不仅要让指针动,还要让状态重新变成合法。

5.4 数据规模很小就不需要双指针吗

如果在笔试环节遇到 n 很小的情况,暴力也许能通过,但面试官考察的往往是你能不能给出更优的思路。我曾经在模拟面试里用暴力通过了一道数组题,结果被追问“如果 n 扩大到十万呢”,那一刻只能现场重新推导。所以刷题阶段建议养成习惯:不管数据规模如何,先明确暴力复杂度是多少、最优复杂度能到多少、为什么双指针能消掉一层循环。这不只是为面试,更是为以后做工程时选择更优解打底。

5.5 经典问题排查速查表

现象可能原因处理方式
指针相遇后仍继续计算边界条件用了<=但题目要求不能取相同位置根据题意改成left < right
结果重复或漏解找到一组答案后没有去重,或移动位置不对添加 while 跳过相同值,再同步收缩左右指针
滑动窗口答案偏大左边界收缩幅度太小,重复点还在窗口内把 left 跳到冲突字符上次出现位置的下一位
死循环某分支没有移动任何指针保证每轮至少移动 left 或 right 中的一位
快慢指针相遇但无法定位入口推导混淆了“相遇点”与“入口”从 head 与相遇点同步走,第一次相等即入口

这个表基本覆盖了我自己刷题时踩过的坑,建议大家把它当成自查清单。

5.6 一个我自己常用的调试习惯

双指针题的代码往往很短,但越短越容易在细节上翻车。我的做法是:写代码之前先在注释里写清楚三件事。第一,循环不变量是什么,也就是 left 和 right 分别代表什么含义;第二,指针移动到底改变了哪个条件;第三,循环终止时 left 停在的位置还有没有价值。写完注释再动手写循环,基本能避免 80% 的边界错误。比如两数之和里,循环不变量是“当前 left 前面所有位置都已经作为左端点被排除”,想到这里,你就不会在sum < target时去移动 right。

另外我会在本地调试时故意构造几组极简用例,比如空数组、长度为 1 的数组、全相同元素的数组,以及答案恰好在一端或刚好在正中间的用例。双指针代码对这类极端输入最敏感,先拿它们跑一遍,再上随机测试,踩坑概率会低很多。

一些个人经验

如果让我只说一条最值得分享的心得,那就是:双指针不是模板,而是一种“证明无效区间”的练习。每道题都能先想清楚“为什么可以排除那部分区间”,代码只是把这种排除过程翻译出来。我第一次正经学双指针时,也是对着答案看半天,自己写却漏洞百出;后来每写一题都强制自己先用中文把指针移动的依据写出来,再动手敲代码,效果立竿见影。建议你也试试,先别急着背模板,先把“为什么向右移而不是向左移”这个问题说清楚。能把这句话说明白,双指针这关就算真正过了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 15:08:52

KKT条件实战指南:带约束优化的工程验证与排障

1. 这不是数学考试&#xff0c;而是工程决策的底层语言“KKT条件”这四个字刚出现在凸优化学习笔记第12节时&#xff0c;我正坐在某高校实验室调试一个电池SOC&#xff08;荷电状态&#xff09;在线估计模型。当时手头的优化问题约束越来越多&#xff1a;电压不能超限、电流变化…

作者头像 李华
网站建设 2026/10/27 15:08:33

从单帧到整组相机:浙大 StreamRig 将 rig 内几何引入流式里程计

目录 01 研究动机 任务与问题设定 现有方法的局限 需要解决的三个问题 02 方法&#xff1a;Freeze-and-Stream ① 逐组联合感知 ② 压缩&#xff1a;Rig-Resampler ③ 时序&#xff1a;CausalBridge ④ 锚快照&#xff1a;因果前提下的双向交互 ⑤ 分块重锚 两…

作者头像 李华
网站建设 2026/10/23 14:46:09

agent-skills工程化实战:构建可插拔的智能体技能系统

1. 从"agent-skills"这个标题说起&#xff1a;一个被低估的工程化命题第一次看到"agent-skills"这个命名&#xff0c;我的直觉是——这大概率不是一个纯算法项目&#xff0c;而是一个偏工程化、偏架构设计的系统。为什么这么说&#xff1f;因为"skill…

作者头像 李华
网站建设 2026/10/22 7:17:41

基金赎回前,你必须知道的几件事

基金赎回是投资者在基金投资过程中经常会遇到的一个环节。然而&#xff0c;在赎回基金之前&#xff0c;有一些关键事项是投资者必须了解和考虑的。以下是一些在基金赎回前必须知道的重要事项&#xff1a;一、了解赎回规定不同的基金公司和基金产品对于赎回的规定可能有所不同。…

作者头像 李华
网站建设 2026/10/23 15:40:23

SSM框架网上订餐系统开发实战与优化

1. 项目背景与核心价值每次饭点看到食堂排起的长龙&#xff0c;或是加班时纠结外卖选择的时候&#xff0c;我都在想&#xff1a;如果能有个操作简单、响应迅速的订餐系统该多好。这个基于SSM框架的网上订餐系统&#xff0c;正是为了解决这些实际痛点而设计的毕业设计项目。作为…

作者头像 李华
网站建设 2026/10/22 10:45:56

从零搭建轻量级地图可视化组件:Canvas 2D 渲染与交互实战

1. 从零搭建 lingbot-map&#xff1a;一个轻量级地图可视化组件的完整实践第一次看到lingbot-map这个命名&#xff0c;我的直觉是&#xff1a;这大概率是一个围绕地图能力做封装的轻量组件&#xff0c;名字里的lingbot像是项目代号或作者昵称&#xff0c;map则直接点明了核心功…

作者头像 李华