文章

【算法】双指针与滑动窗口(三):相向双指针——比较、排除、收缩

【算法】双指针与滑动窗口(三):相向双指针——比较、排除、收缩 摘要 双指针系列第三篇,相向双指针专题。三道题讲透"比较、排除、收缩":LC167 两数之和 II(裸题;一版过,但这题的判决书有个反直觉的结构——淘汰 r 时,论证的全部内容却是 l 的未来:"不动的那边"才是死刑判决的依据);LC11 盛最多水的容器(排除论证的原题;代码一次过、证明欠着的病历——“谁矮动谁"凭什么安全,矮边的所有

【算法】双指针与滑动窗口(三):相向双指针——比较、排除、收缩

文章信息

  • 原文链接:https://jiayq.blog.csdn.net/article/details/164820718
  • 发布时间:2026-09-19 12:26:19
  • 标签:#Golang, #算法, ##开学季·九月创作之星博客挑战赛, ##算法讲解, ##原理

【算法】双指针与滑动窗口(三):相向双指针——比较、排除、收缩

摘要

双指针系列第三篇,相向双指针专题。三道题讲透”比较、排除、收缩”:LC167 两数之和 II (裸题;一版过,但这题的判决书有个反直觉的结构——淘汰 r 时,论证的全部内容却是 l 的未来 :”不动的那边”才是死刑判决的依据);LC11 盛最多水的容器 (排除论证的原题;代码一次过、证明欠着的病历——“谁矮动谁”凭什么安全,矮边的所有剩余配对上限 ≤ 已记录值;以及”排序的自由度”:LC15 的下标是身份标签、LC11 的下标是坐标);LC15 三数之和 (boss;四版弧线——把双指针写成递归回溯的六 if 平行惨案、“去重的宿主”:回溯的同层剪枝不能字面搬家、l=i+1 的区间设计、以及”第二个数可以和固定数同值”的暗坑)。相向双指针的全部灵魂一句话:一次比较必须买到”排除一整排候选”的信息,买不到就退化

前置阅读:双指针与滑动窗口(一):框架总纲——三类问题、一个原理与判决书。配套代码仓库(按题号分目录):https://github.com/a18792721831/studyleetCode

1. 模板与识别信号

1
2
3
4
5
6
7
l, r := 0, len(nums)-1        // 两端起步
for l < r {
    // 用有序性做一次 O(1) 比较
    // → 排除一整排候选(l 的右半排 或 r 的左半排)
    // → 只动一个指针
}

识别信号:排序后找配对 ——两数之和、三数之和、区间极值。两个前提:

  1. 有序性是弹药 :排序给指针方向感(和大 r–、和小 l++、谁矮动谁)——每次比较必须买到信息
  2. 一轮一动 :每轮只动一个指针,动作互斥。我在这上面栽过”平行 if 无互斥”四连犯,最壮观的是 LC15 第一版——六行平行 if 递归,同一轮指针同时走多条路

2. 第一课 LC167:裸题与”不动的那边”

有序数组找两数之和等于 target,下标从 1 返回。numbers = [2,7,11,15], target = 9[1,2]

这题一版过,代码六行:

1
2
3
4
5
6
7
8
9
10
11
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 身上

1
2
3
4
5
6
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 代码一次过,证明欠着

1
2
3
4
5
6
7
8
9
10
11
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, height[r’]) × (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 找两数之和 = -nums[i] ——相向双指针的嵌套。难的全在工程。

4.1 第一版:双指针写成递归回溯

手推指针演化表全对(和小于 l++、和大于 r–、同层剪枝,动作全对),然后代码写成了这个:

1
2
3
4
5
6
7
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]

第三版去填去重,填成了这样:

1
2
3
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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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,要么值还是 nums[r]——但那已经是同一个三元组,纯重复。跳同值就是在跳”值层面已注定重复”的候选。

4.4 三层去重地图 + 一个暗坑

位置手段相邻的坑
固定数 ii>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==idxr==idx 两个特判整个消失。特判是结构缺陷的补丁,结构对了特判蒸发。

5. 相向双指针速查表

问题判据/口诀出处
识别信号排序后找配对;有序性是弹药,每次比较必须买到”排除一排”的信息全类
淘汰判决书主体是”不动的那边” :它的单调性证明被淘汰者的剩余配对全灭(≤ 或 ≥ 已记录/已判定值)LC167/LC11
排除论证模板被淘汰候选的所有剩余配对上限 ≤ 已记录值LC11
能不能排序下标是身份标签可排,是坐标不可排LC15 vs LC11
宿主for 循环,一轮一动、互斥;== 分支收集后 continueLC15 四版
去重的家收集之后跳同值(不是每轮开头剪)LC15
固定数 + 内层双指针l 从 i+1 起步,消灭 idx 特判LC15
同值暗坑第二个数可以和固定数同值——收集后去重天然不误伤LC15 [-1,-1,2]
手推工具指针演化表:i / l / r / 和 / 动谁 逐行推全类

总结

三道题,一个动作循环(比较、排除、收缩),三个层次的收获:

判决书的”镜像结构”是相向双指针的核心直觉。 淘汰谁,就论证谁的对面——LC167 淘汰 r 时盯着 l 的单调未来,LC11 淘汰矮边时算出矮边的配对上限。养成习惯:每次动指针前,先说出”不动的那边”保证了什么

原则不变,宿主会变。 同层去重从回溯带进双指针,家从”层循环开头”搬到”收集之后”——字面搬家让剪枝成了永远执行不到的摆设。这和滑窗篇的”断链套收缩”是同一类病:学新范式时最危险的不是新知识,是旧知识的字面迁移

一次过不等于毕业。 LC11 代码满分、证明欠着;LC15 手推全对、代码另起炉灶。”看懂题解”和”能讲明白”之间的距离,这个系列每篇都在量——量的工具就是判决书:对着最险的一行,讲出它为什么对。

下一篇:二分——找点与找边界,两类模板与判定设计的三课。

参考资料


版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

本文由作者按照 CC BY 4.0 进行授权