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

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

摘要

双指针系列第三篇,相向双指针专题。三道题讲透"比较、排除、收缩":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==idxr==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 版权协议,转载请附上原文出处链接和本声明。

相关推荐
AI 小老六1 小时前
Agent 记忆系统难在取舍
人工智能·算法·架构·agent·memory·harness
大熊背1 小时前
《Color constancy by characterization of illumination chromaticity》之色度色域最大化算法(一)
数码相机·算法·白平衡·色度色域
不会就选b1 小时前
算法日常・每日刷题--<贪心>13
算法
带多刺的玫瑰2 小时前
Leecode#35刷题之搜索插入位置
java·python·算法
我不会起名字3222 小时前
万字总结Golang入门项目
数据结构·经验分享·算法·golang·项目实战·复盘
6Hzlia2 小时前
【Classic 150 刷题计划】 LeetCode 14. 最长公共前缀 | C++ 纵向扫描法与防越界细节
c++·算法·leetcode
Niuguangshuo10 小时前
论文解读:CTC,端到端序列识别的开山之作
算法·语音识别
讳疾忌医丶11 小时前
深度拆解 RocksDB 内核:基于 C++17 的 FIFO 调度状态机与温度阶梯自愈设计
java·c++·算法·架构
高亦真11 小时前
今天是学习嵌入式的第37天
linux·学习·算法