双指针算法详解:三种模式与LeetCode刷题实战技巧 刷题进入第八天今天按计划轮到双指针。Top Interview 150 这个清单前七天我还在数组、哈希和字符串的基础题里打转以为自己已经摸到了刷题的节奏结果双指针专题一上来就让我把很多“我会做”的题重新想了一遍。如果你正在准备面试大概率也绕不开这个专题LeetCode 官方 Top Interview 150 里双指针题目的数量不少而且从数组到字符串再到链表全覆盖。这篇就按我自己真实刷题的过程整理包括题型判断、代码写法、翻车记录不聊虚的。1. 为什么说双指针是面试必拿分项1.1 它在Top Interview 150里的存在感先看清单本身。Top Interview 150 是把面试高频题按专题整理的一份题单双指针作为独立分类出现同时在数组、字符串、链表这些分类里也会反复用到。换句话说双指针不是孤立的技巧它几乎是数组题的默认解法之一。我数了一下双指针单列出来的题目虽然看着不多但加上散落在其他分类里的“两数之和”“回文串”“合并有序数组”这类题数量能翻一倍。如果你面试的是后端、客户端、数据岗算法题里出现双指针的概率会非常高。所以我的建议是这个专题值得花整块时间集中刷不要一天做一道断断续续地练。集中刷的好处是你能很快总结出套路形成条件反射。第八天正好是个合适的节点前面的基础题让你习惯了“暴力解”双指针则是第一次系统性教你“少一个循环”这种思维转变很重要。我第八天的计划很朴素上午先过一遍双指针的理论和模板下午连续写六道经典题晚上复盘错题。一天下来原先看到“有序数组”“原地修改”这些词只会愣住后来基本能条件反射地想到双指针。1.2 双指针到底在考什么很多人以为双指针就是两个下标戳来戳去代码很简短所以不难。但它真正在考的是你对数据结构的理解数组是否有序单调性在哪能不能原地修改两个指针分别代表什么语义我自己的理解是双指针本质上是用两个游标维护一段搜索状态把嵌套循环里很多无用的比较跳过。最典型的例子是两数之和 II如果是无序数组你可以用哈希表 O(n)但如果数组有序用双指针可以不用额外空间边比较边缩小范围。这个“减少冗余”的思路比记住某个具体题的解更重要。面试官也很喜欢追问“为什么你能确定移动这个指针不会漏掉答案”这个问题能答清楚才算真正掌握双指针。2. 先分清三种双指针打法双指针不是只有一种。我第八天刷完才发现把三种形态分清楚比死记题目有效得多。2.1 左右相向两个指针从两端往中间走最常见的是 left0、rightn-1然后根据条件移动 left 或 right。典型场景有几个数组有序且要找目标值、判断回文串、计算面积或水量。这类题的核心逻辑是“排除法”。两数之和 II 里当 leftright 的值比 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。理解这个语义后代码基本不会错。同向快慢还有一个隐藏考点慢指针移动的步数往往对应“答案长度”。比如删除重复项最后返回 slow1移动零最后数组前段是要求保留的元素后段自动变成零。面试里经常会让你解释“为什么慢指针的位置有意义”这就逼你把指针语义说清楚而不是含糊地说“反正就这么写”。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[right1]: 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 是下标所以长度是 slow1。写代码前先明确指针语义能少犯这种低级错误。两道题做完我对“快指针探索、慢指针存储”这个模式的肌肉记忆强了很多。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(fleft{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 对应的左右指针移动逻辑与两数之和完全一致只是多了去重”。这种“一句话解释”比刷完就忘有用得多。