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

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

摘要

双指针系列第三篇,相向双指针专题。三道题讲透"比较、排除、收缩":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 的左半排)
    // → 只动一个指针
}

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

  1. 有序性是弹药:排序给指针方向感(和大 r--、和小 l++、谁矮动谁)------每次比较必须买到信息
  2. 一轮一动:每轮只动一个指针,动作互斥。我在这上面栽过"平行 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 手推全对、代码另起炉灶。"看懂题解"和"能讲明白"之间的距离,这个系列每篇都在量------量的工具就是判决书:对着最险的一行,讲出它为什么对。

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

参考资料


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

相关推荐
HEJOO916 小时前
指针的算术运算详解
java·数据结构·算法
Coder-magician16 小时前
《代码随想录》刷题打卡day47:并查集-part01
数据结构·算法·图论
垆边人似月.16 小时前
VLAN 资源池(100~200 分档)
数据结构·算法
潼心1412o17 小时前
C++初阶(长期更新)第6讲:类和对象(下)
开发语言·c++·算法
星光开发者17 小时前
基于决策树随机森林算法的小说推荐系统的设计与实现-计算机毕设【课程设计】78485
vue.js·算法·微信小程序·django·php·课程设计·express
-森屿安年-17 小时前
按身高排序
c++·算法
程序员清风18 小时前
算法与数据结构入门:复杂度、数组与链表
数据结构·算法·链表
无定义_18 小时前
Codeforces Round 1124 (Div. 2)
算法
垆边人似月.19 小时前
任务混部(200 分档)
算法
言乐619 小时前
Python区分广度优先深度优先宽度优先的区别
python·算法·深度优先·广度优先·宽度优先