第3天早上打开 HOT100 题单,我第一反应还是想按题目顺序往下啃。但前面 DAY1、DAY2 的体验告诉我,这样看上去很努力,实际上没太多沉淀:同类型题目隔得太远,好不容易建立的手感很快又会凉掉。所以 DAY3 我临时改了策略,不再顺着题号走,而是把当前节点最需要补的“双指针”单独拿出来做一次专题突击。
这天的题量定在 4 道,由易到难分别是移动零、盛最多水的容器、三数之和、接雨水。全部做完后,比对前两天的效率,我明显感觉到:按主题刷比按题号刷更像是在掌握“解题能力”,而不是在收集“答案”。如果你也在跟着 HOT100 刷题,尤其是刷到第 3 天左右开始觉得题目越做越散,这篇文章应该能给你一个不太一样的节奏参考。
1. 为什么 DAY3 先刷双指针:前三天排兵布阵
1.1 前三天我给 HOT100 安排的刷法
我很早就发现,HOT100 这种题单如果从第 1 题硬刷到第 100 题,很容易在“刚接触一个新题型、还没完全吃透”的状态下,马上被另一类题目打断。所以我不把 HOT100 当成普通列表,而是先按自己的薄弱点拆成几个小专题,每 1 到 2 天集中解决一个。
DAY1 我做的是哈希表与数组基础,像两数之和、最大子数组和这类“同数组、同哈希”的题目,用来热身和恢复手感。DAY2 切到链表和滑动窗口,把反转链表、环形链表、无重复字符的最长子串放一起,重点练“线性结构上的连续移动”。到了 DAY3,顺理成章应该处理的是双指针的几种典型打法。
为什么把双指针放在第 3 天?因为很多题单里的高频题,最终解法都会退化到一句“用两个指针维护区间”。如果对双指针没有形成条件反射,后面做链表删除、滑动窗口、左右夹逼、快慢指针找环,都会反复卡在同一个原因上。所以第三天的定位,不是为了“完成题目”,而是为了搭一个解题脚手架。
1.2 为什么是 283-11-15-42 这条线
移动零、盛最多水的容器、三数之和、接雨水,这四道题在 HOT100 里都属于高频常客。但把它们串成一条线,不是因为“名气大”,而是因为它的难度和思维类型刚好是一个阶梯式上升。
移动零是典型的同向快慢指针,解决的是“原地整理数组”这类场景。盛最多水的容器第一次引入“左右夹逼”思路,需要你敢于丢弃一部分无效组合。三数之和把双指针和排序结合起来,同时引出了大量边界去重细节。接雨水则把两根指针和动态维护的左右最大值放在一起,属于双指针里比较高级的用法。
这四题的逻辑关系大约是:先解决“两个指针同向移动时谁负责覆盖”,再理解“相向移动时丢弃哪一边不会丢答案”,然后处理“双指针如何配合排序找到确定目标”,最后是“双指针如何维护左右峰值来计算区间贡献”。从单指针到双指针,再从同向到相向,思维负担是逐渐增加的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四道真题拆解:双指针如何替代掉那层无效循环
2.1 移动零:快慢双指针处理原地覆盖
这题的字面要求很简单:把数组中的 0 全部挪到末尾,同时保持非零元素的相对顺序。很多人第一反应是另开一个临时数组,把非 0 放前面、0 放后面,最后再复制回去。这个思路正确,但没有满足题目“原地”的隐含意思,而且面试时通常不会止步于此。
双指针解法里,我让慢指针 slow 表示“下一个非零元素应该放置的下标”,快指针 fast 负责扫描整个数组。每次 fast 遇到非 0,就把这个值写到 slow 指向的位置;如果 fast 和 slow 不是同一个位置,就把 fast 原位置清成 0。这个写法的好处是把原来的“删除 0,再在末尾补 0”操作变成了线性时间内的原地覆盖。
python复制def moveZeroes(nums):
n = len(nums)
slow = 0
for fast in range(n):
if nums[fast] != 0:
nums[slow] = nums[fast]
if fast != slow:
nums[fast] = 0
slow += 1
手动跑一遍 [0, 1, 0, 3, 12] 就能感觉到它的逻辑:fast=1 时,把 1 放到 nums[0],再把原位置清零;fast=3 时,把 3 放到 nums[1],把原 fast 位置清零;fast=4 时,把 12 放到 nums[2],原位置清零。最终数组变成 [1, 3, 12, 0, 0],slow 指向位置 3,恰好是第一个 0 出现的位置。
为什么这样不会丢元素?因为 fast 永远走在 slow 前面,当 fast 指向一个非 0 值时,slow 的位置要么是已经处理过的非零区间的末尾,要么是一个更早出现过的 0,不存在覆盖未处理有效数据的问题。时间复杂度是 O(n),额外空间是 O(1)。
2.2 盛最多水的容器:相向移动时的淘汰论据
这道题最容易想到的解法是:枚举所有左右柱子的组合,计算面积,取最大值。数组长度一大,这种 O(n²) 就会超时。但真正难的不是知道要用双指针,而是想明白一件事:为什么内层循环可以被安全地跳过?
粗暴一点说,双指针做法是让 left 指向最左,right 指向最右,每次把高度较矮的那一侧向中间移动。移动之后宽度变小,但如果矮边变成了更高的柱子,面积就有可能超过当前最佳值。反过来,如果移动高的一侧,另一个矮边没有变,高度上界永远被矮边限制,宽度又比现在小,面积只可能更差。
python复制def maxArea(height):
left, right = 0, len(height) - 1
ans = 0
while left < right:
h = min(height[left], height[right])
ans = max(ans, h * (right - left))
if height[left] <= height[right]:
left += 1
else:
right -= 1
return ans
这里可以给出更有说服力的“淘汰证明”。当 height[left] <= height[right] 时,当前面积是 height[left] * (right - left)。如果固定 left 这一侧不变,把 right 往左移动到任意位置,宽度一定比现在小,而且高度最多也不会超过 height[left],因为短板仍是 left。所以所有以当前 left 作为左边界、右边界在 right 左侧的组合,都不可能比当前面积更大。于是 left 这根柱子可以直接丢弃,指针左移。
理解这点后,接雨水的双指针思路也会顺很多,因为两者都建立在一个共同直觉上:最大值由较矮一侧决定,较矮的一侧才有机会改变局面。
2.3 三数之和:排序后收紧双指针,真正的难点在去重
三数之和的暴力版本是三重循环,复杂度 O(n³),显然不可行。另一种常见思路是用哈希表仿照两数之和去做,但三个数的组合需要严格控制去重,哈希实现起来很容易引入重复答案。更稳的做法是先排序,再固定一个数,把问题转换成“两数之和”用双指针搜索。
排序的好处是让整个数组有序,于是固定 nums[i] 后,可以在 i 后面的区间里用 left 和 right 从两端逼近,寻找 nums[left] + nums[right] 等于目标值。总复杂度是排序 O(n log n) 加上两数搜索 O(n²),比三重循环低一个数量级。
python复制def threeSum(nums):
nums.sort()
n = len(nums)
ans = []
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:
total = nums[i] + nums[left] + nums[right]
if total == 0:
ans.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
elif total < 0:
left += 1
else:
right -= 1
return ans
这道题代码不长,但隐蔽问题很多,最常见的是不知道去重条件放哪里。后面我会单独讲翻车细节,这里只先记住结论:外层去重必须判断 nums[i] 和 nums[i - 1] 是否相等,而不是和 nums[i + 1] 比较;找到答案后,left 和 right 都要跳过重复值,不能只移动一边。
2.4 接雨水:双指针版本里藏着木桶原理
接雨水是我早期刷题时很怕的一道 hard,因为如果直接从暴力去想会非常绕,可一旦把“每个柱子最多装多少水”拆开,它反而比很多 medium 更清晰。
先明确每个位置能接多少水的公式:某个柱子能装的水,等于它左边最高柱子和右边最高柱子中较矮的那个,再减去当前柱子的高度。例如当前柱子高度是 2,左边最高是 5,右边最高是 4,那么水位最多到 4,这一列能存 4 - 2 = 2 的水。
基于这个公式,第一层直觉是:遍历每个位置,分别向左、向右扫描找最大值。这是 O(n²),能过小数据但无法满足大数据量。优化方向是提前把所有位置的左边最大值和右边最大值用数组存起来,一次预处理,再遍历一次累加。复杂度降到 O(n),但额外空间是 O(n)。
双指针版本继续把空间压到 O(1)。我们维护 left_max 表示从 0 到 left 出现过的最高柱子,right_max 表示从 right 到数组末尾出现过的最高柱子。每一轮先让 left_max 和 right_max 吸收当前指针指向的柱子,然后比较两侧最大值中谁更矮,哪边更矮就结算哪边当前位置的积水量,再让对应指针前进。
python复制def trap(height):
if len(height) < 3:
return 0
left, right = 0, len(height) - 1
left_max = right_max = 0
ans = 0
while left < right:
left_max = max(left_max, height[left])
right_max = max(right_max, height[right])
if left_max <= right_max:
ans += left_max - height[left]
left += 1
else:
ans += right_max - height[right]
right -= 1
return ans
一句话解释这个更新逻辑:当 left_max 小于等于 right_max 时,说明当前 left 位置的积水高度已经被左侧最高柱子锁死,右边至少有一根不低于 left_max 的柱子作为“右墙”,所以可以放心计算当前柱子的积水量。反过来,当 right_max 小于 left_max 时,就结算右指针所在的柱子。
理解这道题后,你会意识到双指针并不只是“两个下标移来移去”,它背后是空间压缩。很多能用前缀数组、后缀数组解决的问题,只要具备“从两端逼近时,当前结果只取决于已遍历部分的最值”这样的性质,都可以进一步改写成双指针。
3. 翻车现场复盘:三处看着能过实际会错的编码细节
3.1 erase+push_back 的陷阱:不是算法错,是复杂度错
移动零这道题,我第一次刷的时候思路特别直白:碰到 0 就把这个元素从数组中移除,然后再在末尾补一个 0。听起来简单,实际写了一遍,测试样例也能过,但心里总觉得不舒服,因为这会导致一个很尴尬的问题——数组元素的移动次数和 0 的数量不成正比。
如果在一个长度为 n 的数组里,0 出现在前半段,每次 erase 都可能把后面大量元素往前挪一次,整体复杂度会退化到 O(n²)。尤其在连续出现多个 0 的场景里,一边删除一边追加还会让遍历指针的下标变化变得非常隐晦,很容易出现少处理元素或死循环。
所以我后来统一改成“快慢指针 + 覆盖”的写法。不少刚刷双指针的人会纠结:别的语言没有 erase,怎么写?这就是慢指针方案的优势所在。你不需要真正删除某个元素,只需要把所有非 0 元素搬到前面,最后再把 slow 之后的位置全部填 0。当你意识到“原地操作不等于原地删除”,很多问题都会瞬间变得直白。
3.2 三数之和把去重条件写错,直接丢掉合法答案
三数之和里最折磨人的不是双指针移动,而是去重。很多人写成:
python复制if nums[i] == nums[i + 1]:
continue
这个写法看起来天经地义,我也会经常顺手一写。但它是错误的,因为它会误伤掉合法组合。用数组 [-1, -1, 2] 来看,排序后数组只有唯一答案 [-1, -1, 2];当 i 还在第一个 -1 时,判断 nums[0] == nums[1] 成立,于是 continue,直接在 i 这一层把这个组合丢掉。可事实上,这里连续两个 -1 正好是我们要的两个数,不是重复结果。
正确做法是把外层去重写成:
python复制if i > 0 and nums[i] == nums[i - 1]:
continue
这样数组里的第一个 -1 不会被跳过,第二个 -1 因为和前一个相同会被跳过,行为刚好符合预期:遇到同值且前一个已经处理过时,才跳过重复枚举。这个边界看似微小,却决定整题正确性,也是面试官最爱追问的点。
找到答案后的左右指针去重同样有讲究。我的习惯是找到一组答案后,先将 left 和 right 同时移动,然后分别跳过与自身前一个状态相同的元素。只移动一端会让另一端停在重复值上,下次循环又可能产生相同答案,甚至因为左右指针没有同时逼近而陷入长时间空转。
3.3 接雨水的 max 更新位置,差一行结果就不对
接雨水双指针版本容易犯的一个错,是把“更新 left_max”和“计算积水量”的顺序搞反。
我的错误版本曾经长这样:
python复制# 错误示范版:在结算积水之后才更新max
if left_max <= right_max:
ans += left_max - height[left]
left_max = max(left_max, height[left])
left += 1
这看起来只是移了一行,但逻辑大变。比如数组 [1, 3, 2, 4],当左指针移动到高度为 3 的柱子时,left_max 仍然停留在此前的 1。这一轮先计算 1 - 3,会得到一个负数,显然不合理。left_max 的定义必须包含当前柱子,因为当前柱子本身也可能是左侧“墙”的一部分,所以必须先把它吸收进 left_max,再结算积水。
正确的做法是进入循环后,立刻执行 left_max = max(left_max, height[left]) 和对应右侧的更新,然后再比较谁更矮。这一步也提醒我:写双指针相关逻辑时,不要只盯着“指针怎么移动”,还要想清楚每轮开始前,我们维护的辅助变量应该处于什么状态。
4. 从这四题提取出双指针识别框架
4.1 双指针不是一板斧,先看数据形态选类型
把 DAY3 的题目做完后,我习惯性地整理了一份“什么情况下该用哪种双指针”的小表。这比单纯背题解有用得多,因为面试题不太可能出原题,但底层模型往往跑不出这几类。
| 双指针类型 | 代表场景 | 特征信号 | 指针移动方式 |
|---|---|---|---|
| 同向快慢指针 | 移动零、数组去重、移除元素 | 原地整理、保留相对顺序 | 快指针负责扫描,慢指针负责写入位置 |
| 相向双指针 | 盛最多水的容器、三数之和 | 有序数组或可以排序 | 左侧向右、右侧向左,逐步逼近 |
| 滑动窗口 | 无重复字符的最长子串 | 连续子数组/子串问题 | 左右边界同向移动,窗口维护区间状态 |
| 链表快慢指针 | 环形链表、删除倒数第 N 个节点 | 链表找环、找中点、找倒数位 | 快指针每次走两步,慢指针每次走一步 |
DAY3 的四道题主要是前两类,但整理时我会把后两类也放进同一份笔记里。因为双指针的各种形态是互通的:快慢指针关注“谁负责找到候选值”,相向指针关注“舍弃哪一侧”,滑动窗口关注“什么时候扩展右边界、什么时候收缩左边界”,链表快慢则是在非线性结构上运用速度差。
4.2 识别“单层循环解决两层枚举”的信号
双指针最核心的价值,是减少一层不必要的循环。什么时候可以减?我总结出三个信号。
第一,题目是在两个端点之间找某个最优结果,而不是遍历单个元素。比如盛最多水的容器,一个左端点和一个右端点确定一个面积,暴力解法需要枚举所有端点对;但如果存在“移动某侧不会让答案更优”的证明,就可以用相向双指针。
第二,数据可以被排序。三数之和如果没有排序这一步,双指针无法决定 left 和 right 该往哪走。排序让数组具备单调性,while 内部才能通过当前和与目标值的大小关系调整方向。
第三,问题的答案只依赖局部最值或区间边界。接雨水的每个位置存水量,依赖左右两侧最大值。如果已经证明了“最小侧决定水位”,双指针就能边扫描边结算,不需要为每一个位置重新扫描整段数据。
如果面试时卡住了,我会先问自己一句:如果暴力是 O(n²),是不是因为有两个下标都需要枚举?这两个下标组成的组合中,有没有哪一大类是可以被排除的?只要存在“丢弃一部分组合不影响最优解”的理由,双指针大概率可行。
4.3 一段可复用的双指针模板
很多初学者觉得双指针没有固定模板,所以每次都要从头推理。我的经验是,与其背模板,不如掌握两个基本骨架。
相向双指针骨架大概长这样:
python复制left, right = 0, len(data) - 1
while left < right:
# 根据当前状态,决定是否更新答案
# 判断该移动 left 还是移动 right
if should_move_left(left, right):
left += 1
else:
right -= 1
同向双指针骨架则是:
python复制slow = 0
for fast
