☰
双指针算法详解:三种模式与LeetCode刷题实战技巧
2026/10/10 12:38:12 网站建设 项目流程

刷题进入第八天,今天按计划轮到双指针。Top Interview 150 这个清单,前七天我还在数组、哈希和字符串的基础题里打转,以为自己已经摸到了刷题的节奏,结果双指针专题一上来,就让我把很多“我会做”的题重新想了一遍。如果你正在准备面试,大概率也绕不开这个专题:LeetCode 官方 Top Interview 150 里双指针题目的数量不少,而且从数组到字符串再到链表全覆盖。这篇就按我自己真实刷题的过程整理,包括题型判断、代码写法、翻车记录,不聊虚的。

1. 为什么说双指针是面试必拿分项

1.1 它在Top Interview 150里的存在感

先看清单本身。Top Interview 150 是把面试高频题按专题整理的一份题单,双指针作为独立分类出现,同时在数组、字符串、链表这些分类里也会反复用到。换句话说,双指针不是孤立的技巧,它几乎是数组题的默认解法之一。我数了一下,双指针单列出来的题目虽然看着不多,但加上散落在其他分类里的“两数之和”“回文串”“合并有序数组”这类题,数量能翻一倍。如果你面试的是后端、客户端、数据岗,算法题里出现双指针的概率会非常高。

所以我的建议是:这个专题值得花整块时间集中刷,不要一天做一道断断续续地练。集中刷的好处是,你能很快总结出套路,形成条件反射。第八天正好是个合适的节点,前面的基础题让你习惯了“暴力解”,双指针则是第一次系统性教你“少一个循环”,这种思维转变很重要。我第八天的计划很朴素:上午先过一遍双指针的理论和模板,下午连续写六道经典题,晚上复盘错题。一天下来,原先看到“有序数组”“原地修改”这些词只会愣住,后来基本能条件反射地想到双指针。

1.2 双指针到底在考什么

很多人以为双指针就是两个下标戳来戳去,代码很简短,所以不难。但它真正在考的是你对数据结构的理解:数组是否有序?单调性在哪?能不能原地修改?两个指针分别代表什么语义?

我自己的理解是,双指针本质上是用两个游标维护一段搜索状态,把嵌套循环里很多无用的比较跳过。最典型的例子是两数之和 II:如果是无序数组,你可以用哈希表 O(n);但如果数组有序,用双指针可以不用额外空间,边比较边缩小范围。这个“减少冗余”的思路,比记住某个具体题的解更重要。面试官也很喜欢追问:“为什么你能确定移动这个指针不会漏掉答案?”这个问题能答清楚,才算真正掌握双指针。

2. 先分清三种双指针打法

双指针不是只有一种。我第八天刷完才发现,把三种形态分清楚,比死记题目有效得多。

2.1 左右相向:两个指针从两端往中间走

最常见的是 left=0、right=n-1,然后根据条件移动 left 或 right。典型场景有几个:数组有序且要找目标值、判断回文串、计算面积或水量。

这类题的核心逻辑是“排除法”。两数之和 II 里,当 left+right 的值比 target 大,说明 right 指向的这个数太大,因为数组有序,right 与任何更靠左的数相加会更大?不,更靠左的数更小,所以 right 与 left 之间任何数相加只会比当前组合更小?等一等,这里应该这样说:当前 left 是最左边的数,right 是最右边的数,所以当前组合是“left 与所有右侧数配对中可能的最大和”?严格推导要仔细,面试时可以说:如果当前和小于 target,说明对于当前 left 来说,right 已经是能配到的最大数,和都不够,那么 left 与更小的数配对更不可能满足,所以 left 应该右移;反之如果当前和大于 target,说明对于当前 right 来说,left 已经是最小的可选数,和都过大,那么 right 与更大的数配对只会更大,所以 right 左移。每次移动都排除了一批不可能的解,所以从 O(n^2) 降到 O(n)。这种解释面试官听起来最顺。

写左右相向代码时,我习惯先确定“循环里移动指针后,区间是否还能覆盖正确答案”,而不是死记“哪个大移动哪个”。想通这一点,回文串、三数之和、接雨水这些题都能套同一个思维。

2.2 同向快慢:快指针负责探路,慢指针负责覆盖

快慢指针一般从同一起点出发一前一后移动,比如删除有序数组中的重复项、移动零、判断链表是否有环。快指针扫描全表,慢指针指向下一个要写入的位置。这类题通常要求原地修改、O(1)额外空间。

第一次写的时候,我最容易搞混的是慢指针到底指向“已经处理好的最后一个元素”还是“下一个待写入位置”。建议统一一个习惯:让 slow 指向下一个待写入位置。这样循环里只需要判断 nums[fast] 是否应该保留,然后写入 nums[slow],slow++。理解这个语义后,代码基本不会错。

同向快慢还有一个隐藏考点:慢指针移动的步数往往对应“答案长度”。比如删除重复项,最后返回 slow+1;移动零,最后数组前段是要求保留的元素,后段自动变成零。面试里经常会让你解释“为什么慢指针的位置有意义”,这就逼你把指针语义说清楚,而不是含糊地说“反正就这么写”。

2.3 滑动窗口:双指针的变体,别和前面两种混淆

严格来说,滑动窗口也是双指针,但它的左右边界通常都向右移动,维护一个“窗口”。比如无重复字符的最长子串、最小覆盖子串。它和左右相向最大的区别是:窗口的两个指针都往一个方向走,而且关注的是窗口内部的连续片段。很多同学一看到“连续子数组”“子串”这类词就该想滑动窗口,而不是左右相向。

第八天我没有把滑动窗口作为重点,但建议你至少知道它属于双指针的“旁支”,因为 Top Interview 150 里后面字符串专题一定会遇到。先有这个概念,等刷到那边就不慌。我当时就是听说“双指针”很厉害,硬要用相向指针去做滑动窗口题,结果做不出来,后来才明白自己把两个工具混在一起了。

2.4 怎么快速判断该用哪种

我自己总结了一个粗糙但好用的判断口诀:有序数组找目标、回文、面积——相向;原地去重、移动零、链表环——快慢;连续子数组、子串最优值——窗口。当然不是绝对,但作为起步,命中率很高。

我把判断逻辑整理成了一张表:

题目特征优先考虑代表题
有序数组+目标值左右相向两数之和II
判断回文/删除一个字符后是否回文左右相向验证回文串
求面积/水量极值左右相向盛最多水的容器、接雨水
原地去重/移动元素同向快慢删除有序数组中的重复项、移动零
链表环入口/中点快慢指针环形链表、链表的中间结点
连续区间最值/子串最值滑动窗口无重复字符的最长子串

这张表不是标准答案,但我实测下来做 150 题很够用。你刷多了以后可以自己扩充修正。

3. 第八天实操记录:几道题逐个拆

这一天我实际写了大概六道题,下面挑有代表性的详细讲。代码用 Python,因为刷题时验证思路最快,面试时你也可以快速转成自己熟悉的语言。

3.1 两数之和 II:有序数组的相向入门

题目简单描述:给一个按非递减顺序排列的整数数组和一个目标值,找到两个数,使它们的和等于目标值,返回下标(从1开始),保证唯一解。

我一开始还是惯性思维,先想哈希表。但看到题目强调“有序”,立刻切回双指针。代码:

def twoSum(numbers, target): left, right = 0, len(numbers) - 1 while left < right: cur = numbers[left] + numbers[right] if cur == target: return [left + 1, right + 1] elif cur < target: left += 1 else: right -= 1 return [-1, -1]

代码很短,但面试时要能解释清楚为什么 left 加一或 right 减一不会漏解。数组有序,如果当前和小于 target,说明对于当前 left 来说,right 已经是右侧最大的元素,与其配对都小了;那 left 和更小的元素配对只会更小,可以直接放弃 left,所以 left 右移。如果当前和大于 target,说明当前 right 和左侧最小的元素配对都大了;right 和更大的元素配对只会更大,可以直接放弃 right,所以 right 左移。这样每一步都排除一个不可能的位置,总复杂度 O(n)。

我当时顺手比较了三种解法:暴力 O(n^2)、哈希 O(n) 但空间 O(n)、双指针 O(n) 且空间 O(1)。在面试场景里,如果题目强调“有序”,双指针通常是面试官期望的答案;如果不要求空间,哈希也是可以接受的,但双指针更显功力。

3.2 三数之和:排序之后的双指针走上正轨

三数之和是面试常客。题目:给一个整数数组,返回所有和为0且不重复的三元组。

我的第一版解法很蠢:三重循环,然后去重。结果去重的逻辑写了一堆,还是超时。正解是先排序,再固定一个数,剩下两个数用相向双指针。

代码框架:

def threeSum(nums): nums.sort() n = len(nums) res = [] for i in range(n - 2): if nums[i] > 0: break if i > 0 and nums[i] == nums[i-1]: continue left, right = i + 1, n - 1 while left < right: s = nums[i] + nums[left] + nums[right] if s < 0: left += 1 elif s > 0: right -= 1 else: res.append([nums[i], nums[left], nums[right]]) left += 1 right -= 1 while left < right and nums[left] == nums[left-1]: left += 1 while left < right and nums[right] == nums[right+1]: right -= 1 return res

关键点有三个:外层 i 要去重,找到一组答案后 left/right 都要跳过重复值,还要注意排序后如果 nums[i] 已经大于 0,可以提前 break,因为后面的数更大,三元组和一定大于 0。这个剪枝能让大量测试用例跑得快。

我刚写时漏了最外层的去重,结果每次都有重复。调试时打印结果才发现 i 相同的三元组重复出现。这个错误很典型,建议你写的时候先想清楚:重复的来源是同一个 i 和重复的 left/right 组合,所以每个层面都要去重。整体复杂度是排序 O(n log n) 加上双指针 O(n^2),最终 O(n^2)。很多人问为什么不用哈希表,其实用哈希也能做,但去重会更麻烦,排序加双指针是目前最顺手的方案。

3.3 盛最多水的容器:移动矮的那一边

题目:给一个整数数组 height,每条垂线的高度是 height[i],选择两条线和 x 轴组成容器,求最多能装多少水。

这题如果用暴力是 O(n^2),双指针 O(n)。左右指针从两端开始,面积 = min(height[left], height[right]) * (right - left)。每次比较左右高度,移动较矮的那一根。原因是:容器高度由短板决定,如果移动较高的那一根,宽度减小,高度不可能超过原来的短板,面积必然减小;而移动较矮的那一根,虽然宽度也减小,但有机会遇到更高的板子,面积可能变大。

代码:

def maxArea(height): left, right = 0, len(height) - 1 ans = 0 while left < right: area = min(height[left], height[right]) * (right - left) ans = max(ans, area) if height[left] < height[right]: left += 1 else: right -= 1 return ans

注意如果两边高度相等,移动左边还是右边其实都可以,因为移动任意一边,高度都不变(都是这个相同高度),但宽度减小;如果存在更优解,必然在跳过这一对之后。我在这一步卡了很久,后来用一个反例说服自己:两边高度相等时,无论保留哪边,都不可能得到比当前更高的高度,所以当前对不可能成为后续最优的基础。

这个题的代码量很少,但面试官经常变着法问:“如果一定要你证明为什么移动短的不会漏解,你怎么说?”我的回答套路是:容器面积由短板决定,当固定短板的这一端时,另一端在什么位置面积都不会超过当前面积,因为宽度只会更小、高度不会更高。所以每次移动短板位置是安全的。

3.4 移动零与删除重复项:快慢指针的肌肉记忆

移动零:给定数组 nums,把所有 0 移到末尾,同时保持非零元素相对顺序。要求原地操作。

def moveZeroes(nums): slow = 0 for fast in range(len(nums)): if nums[fast] != 0: nums[slow], nums[fast] = nums[fast], nums[slow] slow += 1

这题我在第一版写成了“先把非零往前挪,再末尾补零”,也能过,但比交换做法多了一轮循环。交换做法把“非零只保留一个名额”和“旧位置自动变零”合在一起,更简洁。关键在于 slow 指向下一个非零应该放的位置,fast 每找到一个非零就交换,slow 后移。

删除有序数组中的重复项则更像:让 slow 指向下一个不同元素要放的位置,遍历数组时遇到和上一位置不同的值就写入。

def removeDuplicates(nums): if not nums: return 0 slow = 0 for fast in range(1, len(nums)): if nums[fast] != nums[slow]: slow += 1 nums[slow] = nums[fast] return slow + 1

这题要注意返回值是去重后数组长度,而不是数组本身。一开始我返回了 slow,结果长度少 1。后来记住 slow 是下标,所以长度是 slow+1。写代码前先明确指针语义,能少犯这种低级错误。两道题做完,我对“快指针探索、慢指针存储”这个模式的肌肉记忆强了很多。

3.5 接雨水:相向双指针的进阶形态

如果当天还有余力,强烈建议做一下接雨水。题目:给定 n 个非负整数表示每个宽度为 1 的柱子高度,计算按此排列的柱子下雨后能接多少雨水。

这题双指针解法是比较进阶的。核心思想是:左右两侧各自维护一个“当前见过的最高柱子”。对于 left 位置,它能接的水量取决于左边最高柱子和右边最高柱子中较矮的那个;因为较矮的那一边决定水会不会漏。如果 left_max <= right_max,就处理左边:水量 = left_max - height[left],然后 left++;否则处理右边。这样不用额外数组,O(1) 空间。

def trap(height): if not height: return 0 left, right = 0, len(height) - 1 left_max, right_max = 0, 0 ans = 0 while left < right: if height[left] <= height[right]: left_max = max(left_max, height[left]) ans += left_max - height[left] left += 1 else: right_max = max(right_max, height[right]) ans += right_max - height[right] right -= 1 return ans

这题第一次看不懂很正常。我第一次看答案是拒绝的,直到把样例的每个位置的 left_max、right_max 手动画在纸上才明白。我的建议是:不要只看代码,拿一个实例一步步走指针。比如 [0,1,0,2,1,0,1,3,2,1,2,1],走一遍比盯着屏幕十分钟都有效。我当时卡在“为什么要比较 left_max 和 right_max”,后来想明白了:某一点能不能接水,要看它左右两侧最高柱子的较小值是否比当前高度高;双指针就是不断确认“哪一侧的最高已知值更小,就先处理那一侧”。

4. 实战后总结的踩坑清单与调试方法

这些坑是我第八天真实踩过的,不是理论,写下来提醒自己也给你避雷。

4.1 指针移动条件是最容易出bug的地方

最常见的问题是 while 循环里的等号。左右相向时,我习惯写while left < right。有的题(比如判断回文串)你可能会下意识写成left <= right,于是漏掉中间元素或陷入死循环。核心判断:如果两个指针指向同一个位置时没有意义,就用<;如果最后需要检查单独元素,才考虑<=。

另一个问题是三数之和里找完答案后连续跳过重复元素。跳过重复时,往往需要再补一个left < right条件,否则下标直接越界。比如:

while left < right and nums[left] == nums[left - 1]: left += 1

少了前面的left < right,在极端情况下就会越界。这种小问题在面试白板上很容易被扣分。还有个细节:跳过重复元素时,我一开始在left += 1之后直接比较nums[left] == nums[left + 1],结果方向反了,跳过头。后来我统一写法:先移动,再和上一个位置比较,也就是nums[left] == nums[left - 1],逻辑顺很多。

4.2 边界值:空数组、单元素、全相同元素

我写题时习惯先测三个边界输入:空数组、只有一个元素、所有元素都相同。双指针题目里,空数组和单元素经常让指针直接越界或返回错误长度。比如删除重复项,空数组得单独处理;移动零,空数组直接跳过循环也没事。建议在写完代码后,先自己补上这几种输入,不要急着提交。

全相同元素的数组最能暴露去重逻辑问题。三数之和如果输入全 0,答案应该只有[0,0,0]一个。但如果你只在 left/right 去重、忘了外层 i 去重,结果里就会出现多个相同三元组。接雨水如果全是 0,返回值应该是 0,但我的第一版陷阱是 height[left] 为 0 时计算水量时 left_max 也是 0,相减得 0,没问题;真正要注意的是如果 height 为空或只有一个元素,必须提前返回 0,否则指针会越界。

4.3 原地修改时,不要覆盖还没读到的值

快慢指针做原地修改时,慢指针写入的位置可能还没被快指针探索?实际上因为 slow <= fast,覆盖的位置一定是已经读过的位置,所以安全。但有一种情况要小心:交换或写入时,如果 fast 和 slow 重叠,就没必要交换,虽然交换也没问题。移动零的交换法里,如果数组没有 0,每次循环都是自我交换,效率略低但正确。追求极致的话,可以加一个if slow != fast再交换。但面试中一般不会因为这点扣分。

真正的坑是:你需要在遍历中同时保留“原来的值”和“新写入的值”,有时候会覆盖丢失。比如删除重复项,如果写nums[slow] = nums[fast],slow 位置原来的值已经不重要,所以没问题。但如果你想用同一个数组同时做两件事,就要想清楚每个位置的旧值是否还有用。我曾经把移动零和去重逻辑混在一起写,结果非零元素顺序乱了,调试半天才反应过来是覆盖顺序问题。

4.4 调试技巧:打印下标和值,画指针移动图

双指针题目的 bug 往往不是逻辑复杂,而是指针移动时机不对。我调试时最喜欢在循环里打印 left、right、slow、fast 以及当前数组状态:

print(f"left={left}, right={right}, val_left={nums[left]}, val_right={nums[right]}")

对于相向指针,一眼就能看出是不是指针移动方向反了。对于快慢指针,打印 slow 和 fast 指向位置的值,能立刻发现覆盖顺序问题。

另外,强烈建议在草稿纸上画双指针的移动图。用一个长度 6 的小数组,手动模拟每一步,比在IDE里瞎试快得多。我周围很多朋友觉得画图浪费时间,其实这才是最快定位问题的方式。比如接雨水,我画了三个柱子的小例子,马上理解为什么 left_max 要更新,因为当前位置的墙高度如果比之前最高还高,说明这个点本身是凸起,不能接水,水量就是 0;只有当前位置比一侧最高低,才有“坑”。

5. 我常用的双指针训练方法

最后分享一点个人经验。这部分不是标准教程,是我自己刷题调整后觉得有效的方法,仅供参考。

5.1 先背最小模板,再扩展到题目

注意,这里说的背不是死记题解,而是背“指针移动的最小骨架”。比如相向双指针最小骨架:

left, right = 0, len(data) - 1 while left < right: if 满足条件: pass elif 需要更大的值: left += 1 else: right -= 1

快慢指针最小骨架:

slow = 0 for fast in range(len(nums)): if 需要保留: nums[slow] = nums[fast] slow += 1

有了骨架,做题时先套骨架,再修改条件,思路会清晰很多。不要一上来就根据题目改指针位置,那样容易把基础形状搞乱。我以前写过一题直接左移右移混着来,最后完全不知道在干什么,套骨架之后就稳多了。

5.2 每天十分钟题型识别训练

我刷双指针最大的收获不是代码,而是“识别题型”的速度。Top Interview 150 里题目很多,如何快速判断这题该用双指针?我给自己定了个规则:每天随机翻 10 道没做过的题,只看题目描述,不写代码,用 10 秒说出属于哪种双指针,并解释一句为什么。这个习惯坚持了一周,效果非常明显。

判断依据就是前面那张表:出现“有序”“目标值”“回文”“面积/水量”,优先想左右相向;出现“原地”“保持顺序”“删除重复”,优先想快慢;出现“连续子数组/子串”,优先想滑动窗口。10 秒说不出来,就标记为弱项,回头专门看。这个方法特别适合通勤或排队时做,不用开电脑,只用脑子过一遍。

5.3 给后来者的一个具体建议

如果你也打算按 Top Interview 150 准备面试,我的建议是不要把双指针当成一个“很简单”的专题跳过。它代码量少,但思维密度高。第八天如果你只刷完基础题就觉得自己会了,等到链表那几题用快慢指针时很容易被打回原形。我当时就是急于求成,后来专门回头把链表里的快慢指针题又过了一遍,才把“慢指针的位移和快指针的位移关系”彻底弄明白。

一个小技巧:每道题写完,试着用一句话说明“为什么这个指针移动不会漏解/不会出错”。能说出来,这题才算吃透。这也是我后来面试被追问时的底气来源。比如两数之和 II,我会说“当前和偏小就排除左指针,当前和偏大就排除右指针”;三数之和,我会说“排序后每个 i 对应的左右指针移动逻辑与两数之和完全一致,只是多了去重”。这种“一句话解释”比刷完就忘有用得多。

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

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

立即咨询