01 / 本课学习路线
本课学习路线
阅读与推演约 122 分钟,练习约 60 分钟,进阶练习另需约 45 分钟
02 / 学习目标与先修自测
学完本课你能做什么,以及开始前需要会什么
每次移动指针,都要说得出被排除的组合为什么不会更优;再据此确定收缩条件与答案更新的位置。
| 学完后能做的事 | 正文位置 | 检查方式 |
|---|---|---|
| 拿到题先判断属于对向 / 同向 / 定长 / 变长哪种形态,并各举一例 | 第 03 节 | 自查第 2 条 |
| 排序后对向夹逼,先记录再移动,通过(AC)P3003 | 第 04、09 节 | 必做任务 2 |
| 定长窗口首窗求和、之后加新减旧,答案初值取首窗和,通过 P3250 | 第 05、09 节 | 必做任务 3 |
| 不看资料写出变长窗口模板:r 扩张、循环收缩、增量维护 | 第 06 节 | 自查第 3 条、练习 2 |
| 用交换论证解释对向夹逼为什么不漏解;举出含负数时窗口法失效的例子 | 第 04、07 节 | 自查第 4、5 条 |
先修自测:下面 5 题请先自己写答案,再展开对照。答不出的按括号里的位置补看再回来。本课假定你已完成「复合排序与并列规则」与上一课。
| 题号 | 题目 | 补看位置 |
|---|---|---|
| 自测 1 | sorted([-1, -3, 7, 5, 11, 15]) 得到什么?排序后原来的下标还能找回来吗? | 复合排序与并列规则 |
| 自测 2 | a = [2, 1, 5, 1, 3, 2],sum(a[:3]) 是多少?这个求和的复杂度与切片长度有什么关系? | 列表切片 |
| 自测 3 | 写一个 while l < r: 循环,每轮要么 l += 1 要么 r -= 1,说出它最多执行多少轮。 | while 循环 |
| 自测 4 | 用字典记录列表 [1, 2, 1] 里每个数最近一次出现的下标,遍历完后字典是什么? | 字典与哈希计数与统计 |
| 自测 5 | abs(-3 + 5) 与 abs(-8 + 6) 各是多少?哪个更小? | 常用内置函数 |
展开先修自测答案
自测 1:[-3, -1, 5, 7, 11, 15]。排序后原下标丢失;P3003 只要求输出数值,所以不需要下标——要下标的题得排 (值, 下标) 元组。
自测 2:8。切片求和是 O(k):在循环里对每个窗口都这样求和就是 O(nk),第 05 节改成加新减旧的 O(1)。
自测 3:最多 n − 1 轮——每轮 l 与 r 的距离减 1,这就是对向双指针 O(n) 的原因。
自测 4:{1: 2, 2: 1}。遇到重复的 1 时把它的值从 0 改成 2,「最近一次」是 P2805 的关键。
自测 5:2 与 2,一样小。P3003 题面保证每种输入只有一个答案,本课的更新用严格小于,先出现的候选不会被并列值覆盖。
03 / 概念与术语
形态、不变量、扩张、收缩、增量维护
n = 10⁵ 时候选子数组约 n(n+1)/2 ≈ 5×10⁹ 个,逐个求和走不通;两个指针各自最多走 n 步。省下的前提是每次移动都说得出丢掉了什么。
| 术语 | 含义 | 代码写法 |
|---|---|---|
| 对向双指针 | 排序后一个指针从最小端、一个从最大端向中间走 | l, r = 0, n - 1;while l < r |
| 同向双指针 | 两个指针同方向前进,快指针探路、慢指针记录 | slow / fast 同向 |
| 定长窗口 | 窗口长度固定为 k,右滑一格加新减旧 | win += a[r] - a[r - k] |
| 变长窗口 | 右端扩张纳入新元素,约束被违反时左端循环收缩 | for r …: 纳入; while 违反: 移出 a[l]; l += 1 |
| 不变量 | 循环每一轮结束时都成立的性质(如「窗口内和 < target」) | 写在收缩循环的条件里 |
| 增量维护 | 窗口变化一格,统计量只做 O(1) 更新,不重扫 | win += 新; win -= 旧 |
| 交换论证 | 说明「丢掉的组合里挑不出比当前更优的」 | 决定移动哪个指针的依据 |
| 单调性 | 窗口变长和只增、变短和只减(元素非负时成立) | 变长窗口成立的前提 |
| 题目特征 | 形态 | 本课的题 |
|---|---|---|
| 从一组数里选两个,使某个和 / 差最接近目标 | 对向夹逼(先排序) | P3003 |
| 连续 k 个元素的最大 / 最小统计量 | 定长窗口 | P3250 |
| 最短 / 最长的满足约束的连续段,元素非负 | 变长窗口 | 第 06 节的 min_sub_len |
| 两个相同元素的位置差不超过 k | 窗口 + 字典(记最近位置) | P2805 |
| 固定大小的子矩阵统计 | 二维定长窗口 或 二维前缀和 | P3282 |
滑动窗口的三个组成部分
窗口要满足的约束、循环里始终保持的不变量、触发收缩的时机。触发条件在变,「r 扩张、l 收缩、各自最多遍历一次」的模板不变——每个元素至多被两个指针各碰一次,总步数 ≤ 2n。
04 / 对向夹逼:P3003
排序后按和的符号决定移动哪个指针
P3003「两数之和绝对值最小」:一行空格分隔的整数(最多 1000 个),找两个不同位置的数使和的绝对值最小,按从小到大输出这两个数,再输出绝对值;题面保证答案唯一。
| l | r | nums[l] + nums[r] | |和| | 记录(严格更小才更新) | 移动 |
|---|---|---|---|---|---|
| 0 | 5 | −3 + 15 = 12 | 12 | (−3, 15, 12) | 和为正 → r = 4 |
| 0 | 4 | −3 + 11 = 8 | 8 | (−3, 11, 8) | 和为正 → r = 3 |
| 0 | 3 | −3 + 7 = 4 | 4 | (−3, 7, 4) | 和为正 → r = 2 |
| 0 | 2 | −3 + 5 = 2 | 2 | (−3, 5, 2) | 和为正 → r = 1 |
| 0 | 1 | −3 + (−1) = −4 | 4 | 不更新 | 和为负 → l = 1 |
| 1 | 1 | l = r,停止 | 输出 -3 5 2 |
与题面一致。每一步「和为正就 r 左移」丢掉的是 (nums[l'], nums[r]) 里 l' > l 的组合——nums[l'] 只会更大,它们的和只会比当前更大、离 0 更远;和为负时对称。这就是不漏解的理由(交换论证)。
第二组:1 4 -3 6 -8 → 排序后 [-8, -3, 1, 4, 6]
l=0,r=4: -8+6=-2 |·|=2 记录 (-8,6,2) 和为负→l=1 l=1,r=4: -3+6= 3 |·|=3 不更优 和为正→r=3 l=1,r=3: -3+4= 1 |·|=1 更新 (-3,4,1) 和为正→r=2 l=1,r=2: -3+1=-2 |·|=2 不更优 和为负→l=2 l=2,r=2 相遇停止 → 输出 -3 4 1
记录必须在移动之前:移动后指针可能相遇,最后一对就没机会被记录;只有两个数时移动后 l = r,循环直接结束,记录被跳过(第 10 节错误表)。和恰为 0 时可以直接停止,因为绝对值不可能更小。
补充学习(选学)对向双指针为什么不漏解约 4 分钟交换论证:每步丢掉的组合里挑不出更优解
排序后和为负时,left 与更小的 right 组合只会更负——|和| 只会更大,这一批组合整段丢弃是安全的;和为正时对称。想丢掉一批候选,先说明它们里挑不出比当前更好的,这套说理叫交换论证,下一课「二分边界与二分答案」二分「丢掉判定失败的半边」用的也是同一个逻辑。
05 / 定长窗口:P3250
首窗求和,之后每步加新减旧
P3250「连续 k 个箱子的最大和」:第一行逗号分隔的整数(最多 10⁵ 个,每个在 [−10000, 10000]),第二行 k(小于箱子个数);输出连续 k 个数之和的最大值。
| right | 动作 | 窗口 | 窗口和 | 最大值 |
|---|---|---|---|---|
| — | 首窗 sum(nums[:4]) | [2, 10, −3, −8] | 1 | 1 |
| 4 | + nums[4] − nums[0]:+40 − 2 | [10, −3, −8, 40] | 39 | 39 |
| 5 | + nums[5] − nums[1]:+5 − 10 | [−3, −8, 40, 5] | 34 | 39 |
输出 39,与题面一致。移出的元素下标是 right − k:right = 4 时移出下标 0。
第二组:a=[2,1,5,1,3,2]、k=3
首窗 [2,1,5] = 8 滑动: 8 - 2 + 1 = 7 → 7 - 1 + 3 = 9 → 9 - 5 + 2 = 6 max = 9(窗口 [5,1,3])
两个约定决定对错:① 答案初值是首窗和,不是 0——全负数输入(如 -5,-3,-9、k=2)正确答案是 −8,初值 0 会输出 0;② 滑动从下标 k 开始,从 0 开始会用 nums[right - k] 访问负下标,Python 不报错而是从末尾取值,结果悄悄错掉(第 10 节错误表给出具体输出)。题面示例 2「8 / 1」只有一个数、k = 1:首窗就是答案 8。
06 / 变长窗口
变长窗口:按目标决定收缩与记录时机
每轮先把 a[r] 纳入统计量。求最长合法窗口时,违反约束就循环移出 a[l],恢复合法后更新答案;求最短达标窗口时,只要仍然达标,就先记录答案再移出 a[l]。收缩写成 while 而不是 if——一次纳入可能要连收多格。
变长窗口练习模板(按注释补全五个步骤)
Pythonimport sys
def main():
data = sys.stdin.read().split()
# 步骤 1:在这里读入数组与窗口参数
...
left = 0
state = 0 # 窗口内统计量(和 / 计数表)
best = None
for right in range(n):
# 步骤 2:把 a[right] 纳入 state
...
while False: # 步骤 3:把 False 改成「约束被违反」的判断条件
# 步骤 4:把 a[left] 移出 state,left += 1
...
# 步骤 5:用当前合法窗口更新 best
...
main()state 选什么(和 / 计数表 / 最后出现位置)由约束决定;收缩条件就是「约束被违反」的直译;定长窗口把 while 换成 if right >= k−1 的出窗逻辑。
下面用「和 ≥ target 的最短非空子数组长度」(要求 target > 0 且元素全为非负)演示另一种问法:r 纳入后,只要窗口仍然达标就先记长度、再从左端收一格;无解返回 0。注意它与上面的练习模板是两种写法:模板在约束被违反时才收缩、收缩之后再更新答案;本例在满足约束时先记录答案、再继续收缩。
| r 纳入 | 纳入后窗口 | 窗口和 | 收缩过程(合法就先记长度再收一格) | 当前最短长度(best) |
|---|---|---|---|---|
| r=0 加 2 | [2] | 2 | 2 < 7,不收缩 | 0(还没找到) |
| r=1 加 3 | [2,3] | 5 | 5 < 7,不收缩 | 0 |
| r=2 加 1 | [2,3,1] | 6 | 6 < 7,不收缩 | 0 |
| r=3 加 2 | [2,3,1,2] | 8 | 记长度 4 → 移出 2,和 6 停 | 4 |
| r=4 加 4 | [3,1,2,4] | 10 | 记 4 → 移出 3,和 7;记 3 → 移出 1,和 6 停 | 3 |
| r=5 加 3 | [2,4,3] | 9 | 记 3 → 移出 2,和 7;记 2 → 移出 4,和 3 停 | 2 |
答案是 [4,3],长度 2。r=4 与 r=5 这两轮各收了两格,所以收缩必须写成循环(while)。当前最短长度(best)初值 0 表示还没找到;整轮结束仍是 0 就是无解。
最短合法窗口的完整参考实现
Python# 变长窗口的完整参考实现:和 >= target 的最短非空子数组长度;要求 target > 0、元素全为非负,无解返回 0
def min_sub_len(target, nums):
if target <= 0: # 非正 target 不在本例范围内:直接报错,不返回容易被误读成「无解」的 0
raise ValueError("target 必须为正")
total = 0
best = 0 # 0 表示还没找到
l = 0
for r, x in enumerate(nums):
total += x # r 进一格
while total >= target: # 合法就记答案、继续收
length = r - l + 1
if best == 0 or length < best:
best = length
total -= nums[l] # l 收一格
l += 1
return best
assert min_sub_len(7, [2, 3, 1, 2, 4, 3]) == 2 # [4,3]
assert min_sub_len(11, [1, 1, 1, 1, 1, 1]) == 0 # 整段加起来也不够:无解记 0
assert min_sub_len(4, [4]) == 1 # 单个元素恰好达标
assert min_sub_len(3, [1, 1, 1]) == 3 # 整段才够
assert min_sub_len(1, []) == 0 # 空数组
assert min_sub_len(5, [0, 0, 5]) == 1 # 含 0 元素:0 不影响收缩,答案是 [5]
# 非正 target 必须报错,不能悄悄返回 0
for bad in (0, -1):
try:
min_sub_len(bad, [1, 2])
except ValueError:
pass
else:
raise AssertionError("target 非正时应报错")六组断言分别覆盖:常规输入、整段和都不够的无解、单元素恰好达标、整段才够、空数组、数组里含 0 元素;最后一段循环检查非正 target 会不会报错。本实现要求 target > 0 且元素全为非负:target ≤ 0 时任何空窗口都「已达标」,最短长度没有意义,所以函数第一行直接抛出参数错误(ValueError),而不是返回和「无解」同值的 0。非负元素这一前提的原因见第 07 节。
补充学习(选学)参考实现:最短合法窗口的两种写法对照约 5 分钟「违反才收缩」与「达标就记录再收缩」的差别
练习模板(违反才收缩、收缩后更新答案)适合「最长的合法窗口」:窗口一旦合法就尽量长,答案在收缩结束后取。第 06 节的 min_sub_len(达标就记录再收缩)适合「最短的达标窗口」:达标时窗口可能还能更短,所以先记再收。两者循环条件与答案更新时机都不同,不能把后者当成模板按注释填完的样子。判断用哪种:问「最长」用前者,问「最短」用后者。
07 / 不适用的情况
存在负数时窗口法失效
「和 ≥ target 就收缩」的正确性来自:元素全为非负,窗口一变短和一定不增,收缩不会错过答案。混进负数后删掉左端的负数反而让和变大,「左端只前进」失去保证。
最小反例:a = [1, −1, 5]、target = 5
r=0: 窗 [1] 和 1 < 5 r=1: 窗 [1,-1] 和 0 < 5 r=2: 窗 [1,-1,5] 和 5 ≥ 5 → 记长度 3;移出 1 → 和 4 < 5 停 窗口法答案 3;正确答案是 [5],长度 1 —— 漏掉了
选型判断:全为非负、伸缩对合法性单调、问「最短/最长合法窗口」→ 滑动窗口;问「和恰好为 k 的子数组个数」→ 上一课的前缀和 + 哈希,它对负数同样成立。要注意这两条解决的不是同一个问题:前缀和 + 哈希数的是「和恰好等于 k」的子数组个数,不是「和 ≥ k 的最短长度」;含负数时求后者需要单调队列维护前缀最小值一类的方法,本课程不展开。上一课的进阶题 P4203 正好站在两者的分界线上。
补充学习(选学)滑动窗口不适用的情况:存在负数约 4 分钟正数是收缩的前提,负数回到前缀和
「和 ≥ target 就收缩」的正确性来自:元素全为正,窗口一变短和一定变小,收缩不会错过答案。混进负数后删掉左端的负数反而让和变大,「左端只前进」失去保证。一个最小反例:a = [1, −1, 5]、target = 5。窗口法在 r = 2 时和为 5,记下长度 3;再把左端的 1 移出,和变成 4 不再满足条件,循环停止,于是漏掉了长度只有 1 的 [5]。所以「和 ≥ target 的最短子数组」用单调滑动窗口的前提是元素全为非负。
补充学习(选学)滑动窗口在限流、移动平均与注意力中的应用约 4 分钟限流统计、移动平均与滑窗注意力
网关限流要随时回答「最近 60 秒有多少次请求」:时间窗口左端随时间收缩、右端随请求扩张。训练日志的损失(loss)曲线做移动平均:进一个加、出一个减,10⁶ 个点只扫一遍。长文本模型的滑窗注意力(每个词元只看邻近一段)也是同一个形状——局部注意力课(模块 7 · 第 2 课)会做到它对应的练习题。
08 / 窗口接上哈希与二维前缀和
P2805 记最近位置,P3282 用二维前缀和
两道进阶题各换一个「统计量」:P2805 的窗口约束在编号差上,用字典记每个数字最近一次出现的编号;P3282 数 c×c 正方形,用上一课补充里的二维前缀和把每个正方形的和降到 O(1)。
P2805「储物箱配对」:第一行逗号分隔的数字(最多 10⁵ 个),第二行 k;找两个数字相同、编号差 ≤ k 的箱子,输出最先找到的那对里左边的编号,没有输出 −1。遍历时每遇到一个数字,先看它最近一次出现的编号与当前编号之差是否 ≤ k,是就输出那个较早的编号;否则把最近位置更新为当前编号——不更新就会漏掉后面更近的一对。
| i | num | 字典里 num 最近的编号 | 差 | 动作 |
|---|---|---|---|---|
| 0 | 1 | 无 | — | 记 1 → 0 |
| 1 | 2 | 无 | — | 记 2 → 1 |
| 2 | 3 | 无 | — | 记 3 → 2 |
| 3 | 1 | 0 | 3 ≤ 3 | 输出 0,停止 |
| i | num | 最近编号 | 差 | 动作 |
|---|---|---|---|---|
| 3 | 1 | 0 | 3 > 1 | 不成对;把 1 的最近编号更新为 3 |
| 4 | 1 | 3 | 1 ≤ 1 | 输出 3,停止 |
如果 i = 3 时不更新,i = 4 看到的还是编号 0,差 4 > 1,输出 −1(第 10 节错误表)。
P3282「探索地块建立」:第一行 n m c k,之后 n 行每行 m 个整数;数有多少个 c×c 正方形的和 ≥ k。二维前缀和数组(pre)里 pre[i][j] = 左上 i 行 j 列的矩形和;左上角 (i, j) 的 c×c 正方形和 = pre[i+c][j+c] − pre[i][j+c] − pre[i+c][j] + pre[i][j](大 − 上 − 左 + 左上,加回被减两次的角)。c 大于 n 或 m 时一个都放不下,输出 0。
| 左上角 (i, j) | 正方形 | 用 pre 计算 | 和 | ≥ 15? |
|---|---|---|---|---|
| (0, 0) | 1 3 / 2 3 | pre[2][2] − pre[0][2] − pre[2][0] + pre[0][0] = 9 | 9 | 否 |
| (0, 1) | 3 4 / 3 6 | pre[2][3] − pre[0][3] − pre[2][1] + pre[0][1] = 19 − 0 − 3 + 0 | 16 | 是 |
| (0, 2) | 4 5 / 6 7 | 31 − 0 − 9 + 0 | 22 | 是 |
| (0, 3) | 5 8 / 7 1 | 40 − 0 − 19 + 0 | 21 | 是 |
前缀和数组(pre)的第 2 行是 [0, 3, 9, 19, 31, 40](两行各列累加),答案 3。也可以按参考题解的二维定长窗口做:先横向滑一整行、再纵向换行,每次只加减一列或一行。
09 / 从步骤到程序
四道题的完整参考程序,每一步落在哪几行
先自己写完并提交一次,再展开对照。
| 题 | 步骤 | 代码 | 说明 |
|---|---|---|---|
| P3003 | 排序、两端指针、先记录再移动 | sorted(...);if abs(s) < best[2] 在 if s > 0 之前 | 和为 0 直接 break |
| P3250 | 首窗求和、从 k 起加新减旧 | win = sum(nums[:k]);for right in range(k, n) | 答案初值 = 首窗和 |
| P2805 | 字典记最近编号,先判再更新 | if num in last and i - last[num] <= k;last[num] = i | 无论是否成对都更新 |
| P3282 | 二维前缀和、枚举左上角 | pre[i+1][j+1] = 上 + 左 − 左上 + 自己;s = 大 − 上 − 左 + 左上 | c 超过 n 或 m 输出 0 |
展开完整参考程序 1:P3003 两数之和绝对值最小(先自己写完并提交一次,再展开对照)
完整程序:P3003(标准输入 → 标准输出)
Pythonimport sys
nums = sorted(int(x) for x in sys.stdin.read().split()) # 先排序,原下标不再需要
l, r = 0, len(nums) - 1
best = None # (较小数, 较大数, |和|)
while l < r:
s = nums[l] + nums[r]
if best is None or abs(s) < best[2]: # 先记录,再决定移动哪个指针
best = (nums[l], nums[r], abs(s))
if s > 0:
r -= 1 # 和为正:换更小的右端
elif s < 0:
l += 1 # 和为负:换更大的左端
else:
break # 和为 0 已是最小
print(best[0], best[1], best[2])自测用例:题面示例、第 04 节第二组、只有两个数的 2 3(→ 2 3 5)与和为 0 的 3 -3 4(→ -3 3 0)。
展开完整参考程序 2:P3250 连续 k 个箱子的最大和
完整程序:P3250(标准输入 → 标准输出)
Pythonimport sys
lines = sys.stdin.read().split("\n")
nums = [int(x) for x in lines[0].strip().split(",")] # 第一行逗号分隔
k = int(lines[1].strip()) # 第二行窗口长度
win = sum(nums[:k]) # 首窗:下标 0..k-1
best = win # 答案初值必须是首窗和,不能是 0(全负数时 0 是错的)
for right in range(k, len(nums)): # 从下标 k 起,每步加新减旧
win += nums[right]
win -= nums[right - k]
if win > best:
best = win
print(best)自测用例:题面两个示例、全负数的 -5,-3,-9 / 2(→ -8)、练习 1 的输入(→ 8)。
展开完整参考程序 3:P2805 储物箱配对(进阶)
完整程序:P2805(标准输入 → 标准输出)
Pythonimport sys
lines = sys.stdin.read().split("\n")
nums = [int(x) for x in lines[0].strip().split(",")]
k = int(lines[1].strip())
last = {} # 数字 → 它最近一次出现的编号
ans = -1
for i, num in enumerate(nums):
if num in last and i - last[num] <= k: # 与最近一次出现的编号差不超过 k
ans = last[num] # 输出这对里左边的编号
break
last[num] = i # 无论有没有出现过,都更新为最近位置
print(ans)自测用例:第 08 节两组手算,以及不存在配对的 1,2,3 / 2(→ -1)。
展开完整参考程序 4:P3282 探索地块建立(进阶)
完整程序:P3282(标准输入 → 标准输出)
Pythonimport sys
data = sys.stdin.read().split()
n, m, c, k = int(data[0]), int(data[1]), int(data[2]), int(data[3])
vals = [int(x) for x in data[4:4 + n * m]]
grid = [vals[i * m:(i + 1) * m] for i in range(n)]
if c > n or c > m: # 放不下任何一个 c×c 正方形
print(0)
else:
pre = [[0] * (m + 1) for _ in range(n + 1)] # pre[i][j] = 左上 i 行 j 列的矩形和
for i in range(n):
for j in range(m):
pre[i + 1][j + 1] = pre[i][j + 1] + pre[i + 1][j] - pre[i][j] + grid[i][j]
ans = 0
for i in range(n - c + 1): # 左上角 (i, j)
for j in range(m - c + 1):
s = pre[i + c][j + c] - pre[i][j + c] - pre[i + c][j] + pre[i][j] # 大 − 上 − 左 + 左上
if s >= k:
ans += 1
print(ans)自测用例:第 08 节手算例(→ 3)、练习 5 的 3×3 输入(→ 4)、c 大于 n 或 m 的 2 2 3 1 / 1 1 / 1 1(→ 0)。
10 / 边界、反例与复杂度
错误做法在具体输入上各输出什么
下面每一行都是一个具体的错误程序,给出输入、错误输出与正确输出;把参考程序改成对应写法就能复现。
| 错误做法 | 输入 | 错误输出 | 正确输出 | 判题结果 |
|---|---|---|---|---|
| P3003 不排序直接夹逼 | -1 -3 7 5 11 15 | -1 5 4 | -3 5 2 | 答案错误(WA) |
| P3003 先移动指针再记录 | 2 3 | 移动后 l = r,没记录任何答案,输出时 best 为 None 抛 TypeError | 2 3 5 | 运行错误(RE) |
| P3003 输出顺序写成「|和| 小 大」 | 题面示例 | 2 -3 5 | -3 5 2 | 答案错误(WA) |
| P3250 从下标 0 就减 nums[right − k](负下标从末尾取值) | 2,10,-3,-8,40,5 / 4 | 24 | 39 | 答案错误(WA) |
| P3250 答案初值 0 | -5,-3,-9 / 2 | 0 | -8 | 答案错误(WA) |
| P3250 循环里 sum(nums[right−k+1 : right+1]) | n = 10⁵、k = 5×10⁴ | 结果正确但约 (n−k+1)×k ≈ 2.5×10⁹ 次加法 | 同左 | 超时(TLE) |
| P2805 差太大时不更新最近编号 | 1,2,3,1,1 / 1 | -1 | 3 | 答案错误(WA) |
| P2805 输出当前编号而不是较早编号 | 1,2,3,1 / 3 | 3 | 0 | 答案错误(WA) |
| P2805 条件写成差 < k | 1,2,3,1 / 3 | -1 | 0 | 答案错误(WA) |
| P3282 左上角枚举写成 range(n − c)(少最后一行一列) | 3 3 2 10 / 1 2 3 / 4 5 6 / 7 8 9 | 1(只看了左上角 (0,0)) | 4 | 答案错误(WA) |
第一行的 −1 5 4:不排序时两端是 −1 与 15,和为正就一直左移右指针,最后停在 (−1, 5)。第四行的 24:right = 0 时减掉的是 nums[-4] = −3,right = 1 时减掉的是 nums[-3] = −8——首窗和 1 被错误地加成 6、再加成 24,之后再没有更大的窗口和。
| 做法 | 时间 | n = 10⁵ 时 |
|---|---|---|
| 枚举所有数对 / 所有子数组 | O(n²) | ≈ 5×10⁹,超时 |
| 排序 + 对向夹逼 | O(n log n) | ≈ 1.7×10⁶ |
| 定长 / 变长窗口 | O(n) | 每个元素最多进出各一次,≤ 2×10⁵ 步 |
| 二维前缀和(n×m 网格) | O(nm) | 预处理一次,每个正方形 O(1) |
11 / 渐进练习与参考答案
跟做 → 改一个条件 → 独立实现 → 迁移
每题先在纸上或文件里做完,再展开答案。
练习 1(跟做):按第 05 节表的格式,对 P3250 输入 3,-1,4,-1,5,-9,2 / 3 逐步写出每个窗口的和与最大值。
展开练习 1 答案
首窗 [3, −1, 4] = 6;right=3:6 + (−1) − 3 = 2;right=4:2 + 5 − (−1) = 8;right=5:8 + (−9) − 4 = −5;right=6:−5 + 2 − (−1) = −2。最大值 8,窗口 [4, −1, 5]。做错最常见的原因:减去负数时符号写错。
练习 2(改一个条件):把第 06 节的 min_sub_len 改成求「和 ≤ target 的最长子数组长度」(元素非负)。收缩条件和答案更新时机各怎么改?对 target = 7、a = [2, 3, 1, 2, 4, 3] 手算。
展开练习 2 答案
改成练习模板的形态:收缩条件是「和 > target」(约束被违反),收缩完再用 r − l + 1 更新最大值。逐轮:r=0 [2] 长 1;r=1 [2,3] 长 2;r=2 [2,3,1] 和 6 长 3;r=3 和 8 > 7 → 移出 2 → [3,1,2] 和 6 长 3;r=4 和 10 > 7 → 移出 3 → 7 → [1,2,4] 长 3;r=5 和 10 > 7 → 移出 1 → 9 > 7 → 移出 2 → [4,3] 和 7 长 2。答案 3。
练习 3(改一个条件):P3003 改成「若有多对答案,输出较小数更小的那一对」,第 04 节的严格小于要改吗?用 -5 -2 1 4 说明。
展开练习 3 答案
排序后 [−5, −2, 1, 4]:l=0,r=3 和 −1 → 记 (−5, 4, 1),负 → l=1;l=1,r=3 和 2 → 不更新,正 → r=2;l=1,r=2 和 −1 → 与当前 1 并列。严格小于保留先记的 (−5, 4),恰好是「较小数更小」的那对——对向夹逼先访问的总是更靠两端的组合,所以严格小于已满足该规则;若规则改成「较小数更大」,就要在并列时用 ≤ 更新,并且和为 0 时也不能提前退出——-3 -1 1 3 先遇到 (−3, 3) 和为 0,原程序在这里 break 会输出 -3 3 0,按变式要继续推进到 (−1, 1),输出 -1 1 0。
练习 4(独立实现):完成「必做任务 1」的 max_fixed_window 与 closest_abs_pair,再各加一条断言:全负数 [−5, −3, −9]、k=2 应得 −8;只有两个数 [2, 3] 应得 (2, 3, 5)。
展开练习 4 答案
max_fixed_window / closest_abs_pair 的参考实现(自带断言)
Pythondef max_fixed_window(a, k):
win = sum(a[:k]) # 首窗:下标 0..k-1
best = win # 初值是首窗和,不是 0
for right in range(k, len(a)): # 从下标 k 起,每步加新减旧
win += a[right] - a[right - k]
best = max(best, win)
return best
assert max_fixed_window([2, 1, 5, 1, 3, 2], 3) == 9 # 窗口 [5,1,3]
assert max_fixed_window([1, 2], 2) == 3
assert max_fixed_window([-5, -3, -9], 2) == -8 # 全负数:初值为 0 会错
def closest_abs_pair(nums):
a = sorted(nums)
l, r = 0, len(a) - 1
best = None
while l < r:
s = a[l] + a[r]
if best is None or abs(s) < best[2]: # 先记录再移动
best = (a[l], a[r], abs(s))
if s > 0:
r -= 1
elif s < 0:
l += 1
else:
break
return best
assert closest_abs_pair([1, 4, -3, 6, -8]) == (-3, 4, 1)
assert closest_abs_pair([-1, -3, 7, 5, 11, 15]) == (-3, 5, 2) # P3003 题面示例
assert closest_abs_pair([2, 3]) == (2, 3, 5) # 只有一对定长窗口的初值取首窗和;对向夹逼先记录再移动。两个新增断言分别对应第 10 节错误表的「初值 0」与「先移动再记录」。
练习 5(迁移):P3282 输入 3 3 2 10 / 1 2 3 / 4 5 6 / 7 8 9,先写出 4×4 的前缀和数组(pre),再用「大 − 上 − 左 + 左上」算出四个 2×2 正方形的和与答案。
展开练习 5 答案
pre 的四行:[0,0,0,0]、[0,1,3,6]、[0,5,12,21]、[0,12,27,45]。四个正方形:(0,0) = pre[2][2] − 0 − 0 + 0 = 12;(0,1) = pre[2][3] − pre[0][3] − pre[2][1] + pre[0][1] = 21 − 0 − 5 + 0 = 16;(1,0) = pre[3][2] − pre[1][2] − pre[3][0] + pre[1][0] = 27 − 3 = 24;(1,1) = 45 − 6 − 12 + 1 = 28。四个都 ≥ 10,答案 4。做错最常见的原因:忘了加回左上角((1,1) 会算成 27)。
12 / 读题要求与复习自评
四道题的要求对照,以及完成本课之后怎么复习
提交前把下表过一遍;题目页的题面与样例是最终依据。
| 项目 | P3003 | P3250 | P2805 | P3282 |
|---|---|---|---|---|
| 输入 | 一行空格分隔的整数 | 第一行逗号分隔;第二行 k | 第一行逗号分隔;第二行 k | 第一行 n m c k;n 行各 m 个整数 |
| 输出 | 小 大 |和|(空格分隔) | 最大窗口和 | 左边编号或 −1 | 满足的正方形个数 |
| 形态 | 对向夹逼(先排序) | 定长窗口 | 字典记最近位置 | 二维前缀和 / 二维定长窗口 |
| 数据范围 | 最多 1000 个,值 ±65535 | 最多 10⁵ 个,值 ±10⁴,k < 个数 | 最多 10⁵ 个,值 ±10⁵,1 ≤ k ≤ 10⁵ | 以题目页为准 |
| 样例 | -1 -3 7 5 11 15 → -3 5 2 | 2,10,-3,-8,40,5 / 4 → 39 | 1,2,3,1 / 3 → 0(题面输入格式示例) | 题目页为准(自拟 2 5 2 15 例 → 3) |
题解入口:需要对照解法时,先展开本课第 09 节的四份完整参考程序;四道题的题目页另有思路与参考代码,可在题目页查看。复习与自评:本课算完成 = 两道必做题 P3003、P3250 都通过判题,并勾选全部六条「学习完成检查」(含复习题 AI023 那一条);进阶练习不影响完成状态。六条检查是自评,不改变题目的通过(AC)状态。复习时用三个问题自测:① 不看正文,说出 P3003 和为正时为什么移动右指针、丢掉了哪些组合;② 不看表格,重算 2,10,-3,-8,40,5、k=4 的三个窗口和;③ 举出一个含负数时「和 ≥ target 就收缩」失效的输入。答不出哪一条,就回到对应的节重读,再做第 11 节对应的练习。
基础加练(选做)
同一主题的 5 道基础题
这组题目采用标准输入输出(ACM 模式):程序从标准输入读取数据、把结果写到标准输出。它们难度低于必做题,适合在必做题之前热身,或在未通过时回来巩固;不计入本课完成标准。每道题给出练习重点、完成标准与提示;已在本站通过的题目会直接显示为已通过。
- H100002未开始
无重复字符的最长子串
进阶练习:窗口 + 哈希 · 预计 20 分钟
- 练习重点:
- 用最后出现位置表让左端 l 一步跳到位
- 完成标准:
- 能说清字符串
abba里 l 为什么不能回退
提示
记最后出现下标表
last[ch];遇到重复且last[ch] >= l时,l 跳到last[ch] + 1。l 只能前进,回退会把重复字符重新包含进窗口。
13 / 练习
按顺序完成本课的任务
必做题已通过 0/2 道;进阶练习已通过 0/2 道
实现定长窗口与对向夹逼两种形态(max_fixed_window、closest_abs_pair)
必做任务 1:代码自测自主练习练习重点:增量维护窗口和;排序后按符号决定移动哪个指针;预计用时:15 分钟
完成标准:能分别说出两种形态每步丢掉了什么
需要时查看提示
定长窗口先算首窗,再「加右减左」滑动;对向夹逼记录答案要在移动前。两组断言的期望值都来自第 04、05 节的手推表。参考实现在第 11 节练习 4 的展开区。
自测代码(复制到你的代码文件中运行,检查输出是否一致)
def max_fixed_window(a, k):
# 请在这里实现:长度恰为 k 的窗口和的最大值(增量维护,不要重复调用 sum)
...
assert max_fixed_window([2, 1, 5, 1, 3, 2], 3) == 9 # 窗口 [5,1,3]
assert max_fixed_window([1, 2], 2) == 3
def closest_abs_pair(nums):
# 请在这里实现:排序后对向夹逼,返回 (较小数, 较大数, 最小|和|)
...
assert closest_abs_pair([1, 4, -3, 6, -8]) == (-3, 4, 1)P3003 · 两数之和绝对值最小
必做任务 2练习重点:排序后夹逼,按和的符号决定移动方向;预计用时:20 分钟
完成标准:能解释「和为负移动 left」丢掉的组合为什么安全
需要时查看提示
输出按从小到大给两个数再给 |和|。排序后原下标就丢了——这题只要值,不用记下标;更新答案的判断用严格小于,可保持并列结果稳定。第 04 节把题面示例逐步列出。
P3250 · 连续 k 个箱子的最大和
必做任务 3练习重点:定长窗口增量维护,首窗前不输出;预计用时:25 分钟
完成标准:能说出为什么每步是 O(1)、总时间 O(n)
需要时查看提示
首窗在下标 k−1 处成形;之后每步「加新减旧」。不要在循环里 sum(a[l:r+1])——那是 O(nk),n=10⁵ 时会超时。答案初值取首窗和,第 05 节有逐步表。
P2805 · 储物箱配对(编号差约束)
进阶练习 1进阶练习练习重点:维护「最近 k 个编号」的滑动集合,查同数字是否在窗口内;预计用时:20 分钟
完成标准:能说清哈希表的值(value)存什么(该数字最后出现的编号)
需要时查看提示
约束在编号差上不在数值上——哈希记每个数字最后出现的编号,遇重复先判断当前编号与上次出现编号(last)之差 |i − last| ≤ k,再更新。找到第一对立即输出左边编号。第 08 节两组手算说明为什么必须更新。
P3282 · 探索地块建立
进阶练习 2进阶练习练习重点:二维前缀和 O(1) 查每个 c×c 正方形的和;预计用时:25 分钟
完成标准:能写出容斥公式:大 − 上 − 左 + 左上
需要时查看提示
上一课「前缀和与差分数组」补充学习里介绍过的二维前缀和在这里直接使用。前缀数组(pre)开 (n+1)×(m+1),枚举每个右下角 O(1) 判和 ≥ k;总复杂度 O(nm)。先做第 11 节练习 5 的手算。
提交结果
提交结果说明与处理方法
- WA
答案错误
收缩条件写反(「和 ≥ s 收」写成「< s 收」)窗口永不合法;对向题记录答案的时机在移动之前;定长窗口答案初值不能是 0——第 10 节的表给出了每种错误的具体输出
- PE
格式错误
P3003 输出三个数的顺序是「小 大 |和|」;逐字符对样例
- RE
运行错误
定长窗口在首窗成形前就减左端元素会越界或取到负下标;l 收缩不要越过 r;只有两个数时先移动再记录会让答案为空
- TLE
超时
窗口统计量必须增量维护;循环里重新调用求和函数(sum)是 O(nk)
- AC
通过
再测全相同值、k=1、k=n 三个边界;把每题的不变量各说一遍
14 / 学习完成检查
本课学习完成检查
完成本课需要:必做题全部通过,并勾选本课的全部学习完成检查;进阶练习、基础加练与复习题单独统计,不影响完成状态。登录后,勾选记录会保存到账号,并更新课程总览的完成状态。