【算法】双指针与滑动窗口(二):滑动窗口——吃进、判定、吐出

【算法】双指针与滑动窗口(二):滑动窗口------吃进、判定、吐出

摘要

双指针系列第二篇,滑动窗口专题。三道题讲透"吃进、判定、吐出":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 是唯一同时满足"合法 + 最长"的起点

注意结构:判决书是双向夹逼 ------更小非法、更大次优,停点唯一。而 lr 的单向性(只进不退)保证每个停点只算一次------那是效率的来源,不是正确性的来源。正确性靠判决书,效率靠单调性,两件事分开记。

最后看一眼 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 的跳跃收缩里翻车。

滑窗三题走完,"吃进、判定、吐出"六个字可以扔给肌肉记忆了。下一篇:相向双指针------比较、排除、收缩。

参考资料


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

相关推荐
铭哥的编程日记1 小时前
从一道 LeetCode Hard 到吃透一类题:加权区间调度「排序 + 二分 + DP」
算法·leetcode·职场和发展
Navigator_Z1 小时前
LeetCode //C - 1248. Count Number of Nice Subarrays
c语言·算法·leetcode
wzdark2 小时前
多维数组在算法设计中的存储映射问题4
算法
Phil3233 小时前
多智能体不是越多越好:Google《Towards a Science of Scaling Agent Systems》论文深度解读
算法
6Hzlia3 小时前
【Classic 150 刷题计划】 LeetCode 26. 删除有序数组中的重复项 | C++ 快慢双指针经典模板
c++·算法·leetcode
huang5791473 小时前
基于滑动窗口的流式数据算法优化思路3
算法
Ulyanov4 小时前
AudioVision Pro:基于 PySide6 + sounddevice 的实时音频可视化播放器设计
python·算法·音视频
土司大王4 小时前
LeetCode hot100——74.搜索二维矩阵:Java 二分模板
java·算法·leetcode
Evand J4 小时前
【MATLAB例程,图像滤波降噪12】加权中值滑动窗口滤波(WMF)图像降噪,包含滤波质量评价。附代码下载链接
开发语言·图像处理·人工智能·计算机视觉·matlab·滑动窗口·滤波