【算法】双指针与滑动窗口(三):相向双指针------比较、排除、收缩
摘要
双指针系列第三篇,相向双指针专题。三道题讲透"比较、排除、收缩":LC167 两数之和 II (裸题;一版过,但这题的判决书有个反直觉的结构------淘汰 r 时,论证的全部内容却是 l 的未来 :"不动的那边"才是死刑判决的依据);LC11 盛最多水的容器 (排除论证的原题;代码一次过、证明欠着的病历------"谁矮动谁"凭什么安全,矮边的所有剩余配对上限 ≤ 已记录值;以及"排序的自由度":LC15 的下标是身份标签、LC11 的下标是坐标);LC15 三数之和 (boss;四版弧线------把双指针写成递归回溯的六 if 平行惨案、"去重的宿主":回溯的同层剪枝不能字面搬家、l=i+1 的区间设计、以及"第二个数可以和固定数同值"的暗坑)。相向双指针的全部灵魂一句话:一次比较必须买到"排除一整排候选"的信息,买不到就退化。
前置阅读:双指针与滑动窗口(一):框架总纲------三类问题、一个原理与判决书。配套代码仓库(按题号分目录):https://github.com/a18792721831/studyleetCode
【算法】双指针与滑动窗口(三):相向双指针------比较、排除、收缩
- 【算法】双指针与滑动窗口(三):相向双指针------比较、排除、收缩
-
- 摘要
- [1. 模板与识别信号](#1. 模板与识别信号)
- [2. 第一课 LC167:裸题与"不动的那边"](#2. 第一课 LC167:裸题与"不动的那边")
- [3. 第二课 LC11:排除论证的原题](#3. 第二课 LC11:排除论证的原题)
-
- [3.1 代码一次过,证明欠着](#3.1 代码一次过,证明欠着)
- [3.2 排序的自由度](#3.2 排序的自由度)
- [4. 第三课 LC15:boss 战的四版弧线](#4. 第三课 LC15:boss 战的四版弧线)
-
- [4.1 第一版:双指针写成递归回溯](#4.1 第一版:双指针写成递归回溯)
- [4.2 第二、三版:空着没填,和剪枝放错家](#4.2 第二、三版:空着没填,和剪枝放错家)
- [4.3 终版:收集 → 跳同值 → 双动 → continue](#4.3 终版:收集 → 跳同值 → 双动 → continue)
- [4.4 三层去重地图 + 一个暗坑](#4.4 三层去重地图 + 一个暗坑)
- [5. 相向双指针速查表](#5. 相向双指针速查表)
- 总结
- 参考资料
1. 模板与识别信号
go
l, r := 0, len(nums)-1 // 两端起步
for l < r {
// 用有序性做一次 O(1) 比较
// → 排除一整排候选(l 的右半排 或 r 的左半排)
// → 只动一个指针
}
识别信号:排序后找配对------两数之和、三数之和、区间极值。两个前提:
- 有序性是弹药:排序给指针方向感(和大 r--、和小 l++、谁矮动谁)------每次比较必须买到信息
- 一轮一动:每轮只动一个指针,动作互斥。我在这上面栽过"平行 if 无互斥"四连犯,最壮观的是 LC15 第一版------六行平行 if 递归,同一轮指针同时走多条路
2. 第一课 LC167:裸题与"不动的那边"
有序数组找两数之和等于 target,下标从 1 返回。
numbers = [2,7,11,15], target = 9→[1,2]。
这题一版过,代码六行:
go
for l < r {
if numbers[l]+numbers[r] == target {
break
} else if numbers[l]+numbers[r] > target {
r--
} else {
l++
}
}
return []int{l + 1, r + 1}
值得记的是判决书的结构------淘汰 r 时,论证的主体全在 l 身上:
sum > target 时淘汰 r 的判决书:
此后 l 只会右移(有序)⟹ 未来的 l' 满足 nums[l'] >= nums[l]
⟹ nums[l'] + nums[r] >= nums[l] + nums[r] > target
⟹ r 的所有剩余配对全部 > target,出局安全
(sum < target 淘汰 l 对称:r 只会左移,值只会更小)
反直觉的地方:你淘汰 r,却要盯着 l 的未来------因为"不动的那边"的单调性才是"动的那边"的死刑依据。这个结构后面两道题原样复用。
两个小病历:注释里残留"右指针从中间开始"的初始想法(幸好没照做------拿 [2,7,11,15] 找 26 验证:r 从中间起步,l 一路右移追上 r,漏掉 11+15 。错误想法留在注释里必须标注"错在哪",否则重读会被自己骗);len==2 特判是化石(l < r 循环天然覆盖)。
3. 第二课 LC11:排除论证的原题
盛最多水的容器。
[1,8,6,2,5,4,8,3,7]→ 49。
3.1 代码一次过,证明欠着
go
l, r := 0, len(height)-1
area := 0
for l < r {
area = max(area, min(height[l], height[r])*(r-l))
if height[l] > height[r] { // 谁矮动谁
r--
} else {
l++
}
}
代码一次过,但真正的考题是:凭什么是矮的一边被淘汰? 假如最优解的边界恰恰是它呢?
拿 [1,8,6,2,5,4,8,3,7] 第一轮论证:l=0(高 1)、r=8(高 7),当前面积 1×8=8。考察"下标 0 作为左边界的所有可能":它配任何 r',面积 = min(1, heightr') × (r'-0) ≤ 1 × 8 = 8------高度被它自己卡死(1),宽度已经到头(8),它的所有剩余配对的上限,就是刚刚记进 area 的这一格。它已经被"代表"过了,除名绝对安全。对称地,任何时候矮的一边都可以除名。
这就是总纲篇那个统一原理的原始形态:被淘汰的候选,其所有剩余配对的上限 ≤ 已记录值。 LC167 的"和超了淘汰 r"、LC3 的"left 只进不退",骨子里全是这一个论证------三道题,一个灵魂。
诚实记录:这段证明是交卷后补的------代码一次过的那版,注释里写的还是"谁大选谁"这种嘴瓢。代码能写对,和能论证它对,隔着"看懂题解"到"能讲明白"的距离。
3.2 排序的自由度
写这题时顺手记了一个观察:"这个就不能排序了 "。对照 LC15 很有意思------15 能排序是因为下标只是元素的身份标签 (无序,随便换,换个身份照样求和);11 不能排序是因为下标是坐标 (宽度 = 下标差,排序等于摧毁题面)。排序的自由度,取决于下标是否承载语义。 这个判据后面在链表题里还会换装出现(数组下标 O(1) 前驱 vs 链表节点无前驱),先立个牌子。
4. 第三课 LC15:boss 战的四版弧线
三数之和。
[-1,0,1,2,-1,-4]→[[-1,-1,2],[-1,0,1]]。不重复,O(n²)。
思路本身不难:排序 → 固定一个数 i → 内层用 LC167 找两数之和 = -numsi------相向双指针的嵌套。难的全在工程。
4.1 第一版:双指针写成递归回溯
手推指针演化表全对(和小于 l++、和大于 r--、同层剪枝,动作全对),然后代码写成了这个:
go
if nums[l]+nums[r] == -nums[idx] { res = append(...); return } // 找到就撤 → 漏答案
if l == idx { backtrack(l+1, r, idx) } // 六个 if 全部平行、
if r == idx { backtrack(l, r-1, idx) } // 各自递归、
if l > 0 && nums[l] == nums[l-1] { ... } // 互不 return ------
if sum > target { backtrack(l, r-1, idx) } // 一轮走多条路!
if sum < target { backtrack(l+1, r, idx) }
外加一个初犯:手推全程在排序数组上做,代码里 sort 蒸发了 。实测壮观:原样传入输出 [[2,-4,2]](同一个 2 被当固定数和 l 各用一次------元素复用 ),[0,0,0] ×4(同一答案四条路径)、九组大乱炖。
病根一句话:双指针的正确宿主是 for 循环 。指针移动是互斥单步,写成平行 if 递归等于同一轮指针同时走多条路。这版唯一值钱的是注释里的自问自答:"为何答案有重复?如果限制 l>=idx? "------它就是后面 l = i+1 的种子。
4.2 第二、三版:空着没填,和剪枝放错家
第二版骨架全对(sort、外层剪枝、l=i+1、for 循环),两个残留:找到答案 return 了(该 l++ r-- 继续找------循环自带"继续",这正是双指针住 for 的原因 );内层去重没写------[-2,0,0,2,2] 里下标不同、值相同的一对又凑出一次 [-2,0,2]。
第三版去填去重,填成了这样:
go
if sum == target { 收集; l++; r--; continue } // == 判定在前
if l+1 < len(nums) && nums[l] == nums[l+1] { l++; continue } // ← 去重排在它后面
实测依旧重复------去重一次都没执行到 :重复对每轮都在 == 分支直接命中、append、continue,排在后面的去重 if 根本没机会被走到。病根是把回溯同层剪枝的位置 字面搬过来了(它长在回溯每层的 for 里),但双指针里去重的正确翻译是"这个值已经配过对了,收集完就把同值跳光 "------同一原则,不同的家。
4.3 终版:收集 → 跳同值 → 双动 → continue
go
for l < r {
sum := nums[l] + nums[r]
if sum == -nums[i] {
res = append(res, []int{nums[i], nums[l], nums[r]})
for l < r && nums[l] == nums[l+1] { l++ } // 收集后立刻跳
for l < r && nums[r] == nums[r-1] { r-- }
l++
r--
continue
}
if sum > -nums[i] { r--; continue }
if sum < -nums[i] { l++; continue }
}
为什么收集后敢整批跳同值------判决书是"唯一性"论证:固定 i 和 l 之后,能凑出 0 的搭档值是唯一确定的 (-nums[i]-nums[l])。同值的 l' 再配任何搭档,要么值不对凑不出 0,要么值还是 numsr------但那已经是同一个三元组,纯重复。跳同值就是在跳"值层面已注定重复"的候选。
4.4 三层去重地图 + 一个暗坑
| 位置 | 手段 | 相邻的坑 |
|---|---|---|
| 固定数 i | i>0 && nums[i]==nums[i-1] 跳过 |
--- |
| 内层 l | 收集后 while nums[l]==nums[l+1] l++ |
第二个数可以和固定数同值 |
| 内层 r | 收集后 while nums[r]==nums[r-1] r-- |
同上 |
暗坑值得单独一行:[-1,-1,2] 是合法答案------第二个数和固定数同值。如果把去重放在每轮开头"看见同值就剪",第一个被误杀的就是它(我手推表里亲手剪掉过这个答案)。收集后去重天然不误伤:它只在"这对已经成立"之后才跳,同值同角色的重复才被清理。
还有那个值钱的小设计:l = i+1------每个三元组只以其最小元素为固定数被发现一次 (值集合 (a≤b≤c) 与"固定 a、在 a 右边找 b c"一一对应),第一版里 l==idx、r==idx 两个特判整个消失。特判是结构缺陷的补丁,结构对了特判蒸发。
5. 相向双指针速查表
| 问题 | 判据/口诀 | 出处 |
|---|---|---|
| 识别信号 | 排序后找配对;有序性是弹药,每次比较必须买到"排除一排"的信息 | 全类 |
| 淘汰判决书 | 主体是"不动的那边":它的单调性证明被淘汰者的剩余配对全灭(≤ 或 ≥ 已记录/已判定值) | LC167/LC11 |
| 排除论证模板 | 被淘汰候选的所有剩余配对上限 ≤ 已记录值 | LC11 |
| 能不能排序 | 下标是身份标签可排,是坐标不可排 | LC15 vs LC11 |
| 宿主 | for 循环,一轮一动、互斥;== 分支收集后 continue |
LC15 四版 |
| 去重的家 | 收集之后跳同值(不是每轮开头剪) | LC15 |
| 固定数 + 内层双指针 | l 从 i+1 起步,消灭 idx 特判 | LC15 |
| 同值暗坑 | 第二个数可以和固定数同值------收集后去重天然不误伤 | LC15 [-1,-1,2] |
| 手推工具 | 指针演化表:i / l / r / 和 / 动谁 逐行推 |
全类 |
总结
三道题,一个动作循环(比较、排除、收缩),三个层次的收获:
判决书的"镜像结构"是相向双指针的核心直觉。 淘汰谁,就论证谁的对面------LC167 淘汰 r 时盯着 l 的单调未来,LC11 淘汰矮边时算出矮边的配对上限。养成习惯:每次动指针前,先说出"不动的那边"保证了什么。
原则不变,宿主会变。 同层去重从回溯带进双指针,家从"层循环开头"搬到"收集之后"------字面搬家让剪枝成了永远执行不到的摆设。这和滑窗篇的"断链套收缩"是同一类病:学新范式时最危险的不是新知识,是旧知识的字面迁移。
一次过不等于毕业。 LC11 代码满分、证明欠着;LC15 手推全对、代码另起炉灶。"看懂题解"和"能讲明白"之间的距离,这个系列每篇都在量------量的工具就是判决书:对着最险的一行,讲出它为什么对。
下一篇:二分------找点与找边界,两类模板与判定设计的三课。
参考资料
- LeetCode 167. 两数之和 II - 输入有序数组
- LeetCode 11. 盛最多水的容器
- LeetCode 15. 三数之和
- LeetCode 718. 最长重复子数组
- 双指针与滑动窗口(一):框架总纲------三类问题、一个原理与判决书
- 双指针与滑动窗口(二):滑动窗口------吃进、判定、吐出
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。