【算法】双指针与滑动窗口(二):滑动窗口------吃进、判定、吐出
摘要
双指针系列第二篇,滑动窗口专题。三道题讲透"吃进、判定、吐出":LC3 无重复最长子串 (最长型;从 DP 的"断链"惯性走到滑窗的"收缩",五版弧线的教训,以及重做时的一次架构升级------计数版把哨兵坑和窗口外旧出现坑整类消灭);LC209 长度最小的子数组 (最短型;"移出前预判"与"破坏后记录"两种收缩写法;以及本文最值钱的一段------负数死亡证明 :正整数给滑窗的命根子是"移出单调减",负数打破它之后存在"先蹲后跳"的移出路径,双指针的 l 只进不退、无法表达起点回退,滑窗当场死亡------这就是 LC862 必须换前缀和 + 单调队列的原因);LC76 最小覆盖子串 (boss;三轮手推的教训:收缩不是腾位置,收缩即产出;计数三件套的两个"刚"字;一个省掉中间格比较的"聪明"写法靠两根支柱立住------时机与内容)。前置框架见总纲篇:三大类地图、统一原理、判决书三规范。
前置阅读:双指针与滑动窗口(一):框架总纲------三类问题、一个原理与判决书。配套代码仓库(按题号分目录):https://github.com/a18792721831/studyleetCode
【算法】双指针与滑动窗口(二):滑动窗口------吃进、判定、吐出
- 【算法】双指针与滑动窗口(二):滑动窗口------吃进、判定、吐出
-
- 摘要
- [1. 模板与两个旋钮](#1. 模板与两个旋钮)
- [2. 第一课 LC3:从断链到收缩,再到换架构](#2. 第一课 LC3:从断链到收缩,再到换架构)
-
- [2.1 五版弧线的三个教训(首次做的病历)](#2.1 五版弧线的三个教训(首次做的病历))
- [2.2 重做时的架构升级:计数版](#2.2 重做时的架构升级:计数版)
- [2.3 收缩停止位置的判决书](#2.3 收缩停止位置的判决书)
- [3. 第二课 LC209:最短型与负数死亡证明](#3. 第二课 LC209:最短型与负数死亡证明)
-
- [3.1 最短型的骨架](#3.1 最短型的骨架)
- [3.2 负数死亡证明(本文最值钱的一段)](#3.2 负数死亡证明(本文最值钱的一段))
- [4. 第三课 LC76:收缩即产出(boss)](#4. 第三课 LC76:收缩即产出(boss))
-
- [4.1 三轮手推的教训](#4.1 三轮手推的教训)
- [4.2 计数三件套的两个"刚"字](#4.2 计数三件套的两个"刚"字)
- [4.3 双支柱:一个"聪明"写法的正确性](#4.3 双支柱:一个"聪明"写法的正确性)
- [5. 滑窗速查表](#5. 滑窗速查表)
- 总结
- 参考资料
1. 模板与两个旋钮
总纲篇给了滑动窗口的骨架,本篇展开成可执行的形态:
go
l := 0
for r := 0; r < n; r++ {
// 吃进:window 加入 s[r],维护状态
for 收缩条件 {
// 吐出:window 移除 s[l],维护状态
l++
}
// 记录答案
}
骨架固定,每题只调两个旋钮:
| 旋钮 | 最长型(窗口必须合法) | 最短型(窗口必须覆盖) |
|---|---|---|
| 收缩条件 | 不合法时收缩(恢复合法) | 合法时收缩(收缩即产出) |
| 记录时机 | 每轮扩张后 | 收缩循环内每格 |
| 例题 | LC3 | LC209 / LC76 |
两个纪律贯穿所有旋钮组合:
- 吃吐销账:吃进动了什么账,吐出就销什么账------进出各维护一次,缺半个就是 bug
- 指针一轮一步:吃和吐是互斥的两个阶段,同一轮里指针不并行移动
2. 第一课 LC3:从断链到收缩,再到换架构
最长无重复字符子串。
"abcabcbb"→ 3。
2.1 五版弧线的三个教训(首次做的病历)
这题我首次做了五版,教训密度是全系列之最,挑三个最值钱的:
教训一:重复的语义是"收缩"不是"断链"。 双序列 DP 学的断链(最长重复子数组:不匹配归 0 重来)是我的熟招,遇重复第一反应 dp[i]=1; 重置 used。但 718 的"不匹配"是两序列同步走、断了全断;LC3 的"重复"只废掉重复字符上次出现之前 的部分,窗口里其余字符仍然有效------第二个 a 结尾的最长是 "bca"(长度 3),不是 "a"。断链是推倒重来,收缩是挤掉一头。
教训二:手推表错了但答案碰巧对。 我的表把第二个 a 推成 1(正确是 3),但整体最大值 3 在前缀就达到了------错格子全藏在后面。用例巧合家族保护的不是代码,是手推表。对策:窗口演化表,每行五列逐格自问"以它结尾到底最长是谁":
i s[i] last[s[i]] left 窗口内容 ans
3 a 0(窗口内) 1 {b,c,a} 3 ← 收缩:a 的旧出现踢出去
教训三:哨兵必须落在合法值域之外。 last 数组零值 0 想表达"没出现过",但 0 也是合法下标------首见字符全被误判"出现在位置 0"。修法是存 i+1 让 0 归位。这坑在计数版架构下根本不存在(见 2.2)。
2.2 重做时的架构升级:计数版
重做这题时我换了架构------last[c](下标算术)换成 win[c](计数):
go
func lengthOfLongestSubstring(s string) int {
l, r, ans := 0, 0, 0
win := make([]int, 128)
for ; r < len(s); {
win[s[r]]++ // 吃进
for ; win[s[r]] > 1 && l < r; { // 新字符重复了:收缩
win[s[l]-- // 吐出(吃吐销账)
l++
}
r++
ans = max(ans, r-l) // 记录:dp[i] = r-l,dp 数组蒸发
}
return ans
}
两版对照,计数版赢在让两类坑无法存在:
| last 版 | 计数版 | |
|---|---|---|
| 哨兵风险 | 有(0 是合法下标,"没出现过"要靠存 i+1 躲) | 无(计数语义里 0 就是"没有") |
abba/tmmzuxt 风险 |
有(窗口外旧出现的判定 last[c] > left) |
无(旧出现被逐格物理吐出) |
| 收缩表达 | left = last[c](跳跃) |
逐格吐("挤掉一头"的直接表达) |
方法论上这是"改代码用加法"之外的第二种武器:一类坑反复出现时,问能不能换个让它无法存在的架构。修 bug 修的是实例,换架构消灭的是物种。
2.3 收缩停止位置的判决书
计数版为什么对?判决书落在收缩停止的 l 上:
l' < l(再往左):s[r] 的旧出现还在窗口里 → 重复 → 非法起点
l' > l(再往右):窗口白白变短 → 不是"以 r 结尾"的最长
⟹ 收缩停止的 l 是唯一同时满足"合法 + 最长"的起点
注意结构:判决书是双向夹逼 ------更小非法、更大次优,停点唯一。而 l、r 的单向性(只进不退)保证每个停点只算一次------那是效率的来源,不是正确性的来源。正确性靠判决书,效率靠单调性,两件事分开记。
最后看一眼 ans = max(ans, r-l):这就是 dp[i] = i - left + 1。DP 的整张表退化成两个下标------总纲篇说"双指针是 DP 折叠的极限",这里是现场。
3. 第二课 LC209:最短型与负数死亡证明
和 ≥ target 的最短连续子数组(正整数 数组)。
target=7, nums=[2,3,1,2,4,3]→ 2([4,3])。
3.1 最短型的骨架
go
window, l := 0, 0
for r := 0; r < n; r++ {
window += nums[r] // 吃进
for window >= target && window-nums[l] >= target { // 移出后仍合法才移(预判型)
window -= nums[l] // 吐出
l++
ans = min(ans, r-l+1) // 收缩循环内记录:收缩即产出
}
}
我用的是"移出前预判 "型收缩(window-nums[l] >= target 才移)------和 LC76 的"破坏后记录"型殊途同归:预判型每格记录的是移出后的窗口,破坏型记录的是移出前的窗口,两者覆盖的候选集合相同(收缩单调变短,最短候选都在停止点附近)。最长型的收缩是恢复合法(把重复挤掉),最短型的收缩是产出候选(越收越短,每一格都可能是答案)------这是两个旋钮的本质区别。
一个坏味道记一笔:无解判定我绕了远路(ans 初始化 len(nums) + 末尾 window < target 检查,依赖收缩循环的隐含性质)。干净的做法:ans 初始化 len(nums)+1,末尾 if ans > len(nums) { return 0 }------幸存者验明身份,一行搞定,不依赖副作用。
3.2 负数死亡证明(本文最值钱的一段)
题面特意写"正整数"------这个条件是滑窗的命根子。问自己:含负数会死在哪?
先修正一个我自己的自相矛盾:我一开始答"负数不能收缩"------错。负数恰恰要大胆收 (移出负数 window 变大,只会更合法)。真正死掉的不是"收负数",是整个收缩策略的停车条件:
正整数 ⟹ 移出单调减 ⟹ 和一旦跌破 target,再移只会更低
⟹ "移出后仍合法才移"的保守停车是安全的------非法区后面没有合法区
负数打破单调 ⟹ 存在"先蹲后跳"的移出路径:
先吐一个正数,和临时掉进非法区(蹲);
再吐一个负数,和跳回合法区------而且窗口更短(跳)!
杀手用例,亲手跑一遍:
target = 5, nums = [2, -1, 2, 1, 2, -1, 2]
正确答案:3([2,1,2],下标 2~4,和 5)
滑窗走到 r=4:窗口 [2,-1,2,1,2],sum=6 ≥ 5
收缩预判:sum - nums[0] = 6-2 = 4 < 5 → 停车!
但最优 [2,1,2] 需要连续移出【两格】:
移出下标 0(值 2):sum = 4 ------ 蹲进非法区
移出下标 1(值 -1):sum = 5 ------ 跳回合法区,长度 3,最优解
滑窗在蹲点停了车,永远到不了跳点 → 最终输出 4,漏掉 3
死亡机理一句话:最优解需要起点回退(跳过非法区继续收),而双指针的 l 只进不退,无法表达回退。这不是修修补补能救的------是架构性的死亡。所以 LC862(含负数版最短子数组)必须换武器:前缀和 + 单调队列(把"起点回退"交给队列的单调性去表达)。
这也是"判决书"规范的第三条用武之地:买不到信息就退化,退化也救不了就换武器。知道一种技术的死亡条件,比多会三道模板题值钱。
4. 第三课 LC76:收缩即产出(boss)
最小覆盖子串。
s = "ADOBECODEBANC", t = "ABC"→"BANC"。
4.1 三轮手推的教训
这题代码一版过,但手推卡了三轮,每轮的病历都指向同一个认知错误------把收缩当腾位置:
第一轮:LC3 的"跳跃收缩"惯性复发,l 直接跳(l=5,r=5 这种鬼窗口)------LC3 能跳是因为"无重复"的收缩目标恰好可计算(旧出现+1),LC76 的收缩每吐一格都在改计数,没有捷径,只有一格一格的账。
第二轮:逐格吐修好了,还总结出规则"count == len(need) 就可以移动 l"------但收缩过程一笔带过,BANC 在表里凭空消失 。病根:LC3 的收缩是约束恢复(不产出),LC76 的收缩本身就是产出 ------每收一格窗口短一格,每一格都是新候选。手推表跳过的格子,就是代码漏掉的答案。
4.2 计数三件套的两个"刚"字
go
need[c] :t 中字符 c 需要几个(欠账表,建好不动)
window[c] :当前窗口中 c 有几个
count :已还清的字符种数 ------ count == len(need) ⟺ 覆盖成立(O(1) 判断)
吃进:window[c]++;**刚追平** need 时 count++(==,不是 >=)
吐出:window[c]--;**刚跌破** need 时 count--(<,不是 <=)
两个"刚"字保证 count 每次 ±1 都对应一种字符的债务状态翻转。用 >= 代替 == 是这题最常见的错法------count 被多加多减,覆盖判断失真。
4.3 双支柱:一个"聪明"写法的正确性
一版过的代码里有个和骨架不一样的地方:只在覆盖破坏的那一刻记录答案,收缩中间"吐了但没破坏"的格子一个都不记。凭什么安全?两根支柱:
支柱一(时机) :同一轮收缩窗口随 l++ 单调变短,且每格合法(覆盖成立才继续收)------破坏前的最后一格是本轮最短合法窗口,所有中间格被它单调支配,记了也赢不了。省掉的是注定输的比较。
支柱二(内容) :记录那一刻,window[s[l]]-- 只改了计数,l 还没自增、r 早已自增------s[l:r] 切出来的是破坏前那格(恰好包含正在被吐出的 sl)。顺序的机关全在"先收集答案,再移动 l"。
两根支柱各配一个反例:支柱一反例只浪费不炸;支柱二反例把记录挪到 l++ 之后------吐 s9=B 破坏后记录 s[10:13]="ANC",一个缺 B 的非法窗口,但它比所有真候选都短,必然抢走 ans------时机对、内容错,错误答案短得理直气壮。
5. 滑窗速查表
| 问题 | 判据/口诀 | 出处 |
|---|---|---|
| 遇到"重复" | 先分家:断链(归 0 重来)还是收缩(挤掉一头) | 718 vs LC3 |
| 收缩停止位置 | 双向夹逼判决:更小非法、更大次优,停点唯一 | LC3 |
| 最短型收缩 | 收缩即产出------收缩循环内每格记录,手推表不许一笔带过 | LC209/LC76 |
| 覆盖类判定 | 计数不种类、顺序无关;两个"刚"字:刚追平 ++、刚跌破 -- | LC76 |
| 记录时机的优化 | 破坏那一刻记录 = 本轮最短(单调支配)+ 切片含 sl(l 最后动) | LC76 |
| 窗口状态选型 | 计数版 vs 下标版:计数无哨坑、无窗口外旧出现坑;下标版省一个数组但引入算术 | LC3 重做 |
| 滑窗的适用边界 | 值域全正 ⟹ 移出单调 ⟹ 停车安全;含负 ⟹ 先蹲后跳 ⟹ l 无法回退 ⟹ 死亡,换前缀和+单调队列 | LC209/LC862 |
| 无解判定 | ans 初始化 len+1,末尾 ans > len 判------不依赖收缩循环副作用 |
LC209 |
| 手推工具 | 窗口演化表:i / left / 窗口内容 / ans 逐行推 |
LC3/LC76 |
总结
三道题,一个动作循环(吃进、判定、吐出),三个层次的收获:
层次一:模板。 最长型恢复合法、最短型收缩即产出------两个旋钮背下来,LC3 和 LC209/LC76 分别是各自的原型。加上吃吐销账和一轮一步两条纪律,滑窗题的骨架就齐了。
层次二:架构意识。 修 bug 修的是实例,换架构消灭的是物种------LC3 重做时 last 版换计数版,哨坑和窗口外旧出现坑从"要小心"变成"不存在"。以及死亡意识:负数打破移出单调后,"先蹲后跳"杀死了 l 只进不退的双指针------知道一种技术死在哪,才知道它活在哪。
层次三:判决书思维。 收缩停止位置靠双向夹逼论证(更小非法、更大次优);"只在破坏时记录"靠两根支柱(单调支配 + 切片边界);正确性靠判决书、效率靠单调性------分开记,混在一起就会像我最初那样,把"l 不回头"既当正确性又当效率,然后在 LC76 的跳跃收缩里翻车。
滑窗三题走完,"吃进、判定、吐出"六个字可以扔给肌肉记忆了。下一篇:相向双指针------比较、排除、收缩。
参考资料
- LeetCode 3. 无重复字符的最长子串
- LeetCode 209. 长度最小的子数组
- LeetCode 76. 最小覆盖子串
- LeetCode 862. 和至少为 K 的最短子数组
- LeetCode 718. 最长重复子数组
- 双指针与滑动窗口(一):框架总纲------三类问题、一个原理与判决书
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。