数组与字符串:双指针、滑动窗口、前缀和与二分¶
面试里一半以上的题目都落在这一章:给一个数组或字符串,求某种最优的子数组、子串或下标对。它们的共同套路是——暴力解有两层循环,而问题里藏着某种单调性,能让内层循环变成"指针只往前走"或"二分查找",把 O(n²) 降到 O(n) 或 O(n log n)。这一章把四个模板(双指针、滑动窗口、前缀和与差分、二分)写成可以直接套用的形式,并且用分块 prefill 的分块策略作为二分答案的例子。
自测:能答上来就可以跳过本章
- 滑动窗口的三种形态(定长、最长满足、最短满足)有什么共同框架?区别在哪?
- 为什么滑动窗口是 O(n) 而不是 O(n²)?
lower_bound和upper_bound分别返回什么?怎么用它们数一个值出现了几次?- 什么时候能用"二分答案"?举一个推理系统里的例子。
- 前缀和与差分各把什么操作变成了 O(1)?
自测参考答案(先自己答,再展开对照)
- 框架都是"右指针每次前进一格,把新元素加入窗口;然后按条件收缩左边界"。区别只在收缩时机:定长窗口是右进一个、左出一个;求最长时是不满足条件才收缩;求最短时是一满足条件就收缩并记录答案。
- 因为左右两个指针都只往右走,各自最多走 n 步,总共 2n 次移动。虽然代码里有嵌套的
while,但它执行的总次数受"左指针一共能走多远"约束,不是每个右指针位置都要走 n 步。 lower_bound(a, x)是第一个 ≥ x 的位置,upper_bound(a, x)是第一个 > x 的位置(对应 Python 的bisect_left/bisect_right)。出现次数 =upper_bound - lower_bound。- 答案落在一个区间里、并且"某个答案可行 ⇒ 所有更大(或更小)的答案都可行"时,就可以在答案空间上二分,把"求最优值"变成"判定可行性"。推理系统里的例子:分块 prefill 的每块 token 上限、给定延迟目标反推最大 batch、给定显存反推能放多少并发。
- 前缀和把"区间求和"变成 O(1)(预处理 O(n));差分把"区间整体加"变成 O(1)(最后一次前缀和还原)。两者互为逆运算。同时要求区间查询和区间修改时,上树状数组或线段树。
双指针与滑动窗口¶
先一步一步看窗口怎么走:
双指针有两种用法:相向(左右两端往中间走,用于有序数组的两数之和、盛水容器、回文判断)和同向(快慢指针,用于原地删除、去重、移动零)。滑动窗口是同向双指针的一个特例,也是这一章最重要的模板:
# 滑动窗口的三种形态:定长、最长满足条件、最短满足条件。三者共用同一套框架
from collections import Counter
def fixed_window_max_sum(a, k):
"""定长窗口:长度为 k 的子数组里最大的和"""
cur = sum(a[:k])
best = cur
for right in range(k, len(a)):
cur += a[right] - a[right - k] # 进一个、出一个
best = max(best, cur)
return best
def longest_at_most_k_distinct(s, k):
"""最长窗口:最多含 k 种字符的最长子串长度"""
cnt = Counter()
left = best = 0
for right, ch in enumerate(s):
cnt[ch] += 1
while len(cnt) > k: # 不满足条件就收缩左边界
cnt[s[left]] -= 1
if cnt[s[left]] == 0:
del cnt[s[left]]
left += 1
best = max(best, right - left + 1)
return best
def shortest_sum_at_least(a, target):
"""最短窗口:和 >= target 的最短子数组长度(全为正数)"""
left = cur = 0
best = len(a) + 1
for right, x in enumerate(a):
cur += x
while cur >= target: # 满足了就尽量收缩
best = min(best, right - left + 1)
cur -= a[left]
left += 1
return best if best <= len(a) else 0
a = [2, 1, 5, 1, 3, 2]
print("定长窗口 k=3 的最大和:", fixed_window_max_sum(a, 3))
print("最长窗口(最多 2 种字符):", longest_at_most_k_distinct("eceba", 2))
print("最短窗口(和 >= 7):", shortest_sum_at_least(a, 7))
print()
print("三者的共同框架:右指针每步进一个元素,左指针只会往右走,所以总共 O(n)。")
print("区别只在收缩的时机:定长是右进左出,最长是不满足时收缩,最短是满足时收缩。")
定长窗口 k=3 的最大和: 9
最长窗口(最多 2 种字符): 3
最短窗口(和 >= 7): 3
三者的共同框架:右指针每步进一个元素,左指针只会往右走,所以总共 O(n)。
区别只在收缩的时机:定长是右进左出,最长是不满足时收缩,最短是满足时收缩。
套用时问自己三个问题:窗口里维护什么状态(和、计数、最大值)、什么时候收缩、答案在哪一步记录。三者定下来,代码就是模板。
常见变体:
| 题型 | 窗口状态 | 收缩条件 |
|---|---|---|
| 最长无重复子串 | 字符 → 最后出现的位置 | 出现重复时 |
| 最小覆盖子串 | 还差几种字符没凑齐 | 已经凑齐时 |
| 和 ≥ target 的最短子数组 | 窗口和 | 和已经够时 |
| 至多 k 个 0 的最长全 1 段 | 窗口里 0 的个数 | 0 的个数超过 k 时 |
| 定长窗口的最大平均值 | 窗口和 | 每步右进左出 |
窗口里要取最大值或最小值时(比如滑动窗口最大值),需要单调队列,见链表、栈与哈希。
前缀和与差分¶
# 前缀和与差分:把"每次都重新求区间和"降成 O(1),把"区间批量加"降成 O(1)
def prefix_sums(a):
ps = [0] * (len(a) + 1)
for i, x in enumerate(a):
ps[i + 1] = ps[i] + x # ps[i] 是前 i 个元素的和
return ps
a = [3, 1, 4, 1, 5, 9, 2, 6]
ps = prefix_sums(a)
print("数组:", a)
print("前缀和:", ps)
print("区间 [2, 5) 的和 =", ps[5] - ps[2], "(直接算是", sum(a[2:5]), ")")
print()
# 差分数组:把"给 [l, r) 每个元素加 v"变成两次单点修改,最后做一次前缀和还原
n = 8
diff = [0] * (n + 1)
for l, r, v in [(0, 3, 5), (2, 6, -2), (5, 8, 10)]:
diff[l] += v
diff[r] -= v
restored, cur = [], 0
for i in range(n):
cur += diff[i]
restored.append(cur)
print("三次区间加之后的数组:", restored)
print()
# 二维前缀和:统计一块矩形区域的和,常用于"KV 块的占用统计""注意力掩码的分块统计"
grid = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
rows, cols = len(grid), len(grid[0])
ps2 = [[0] * (cols + 1) for _ in range(rows + 1)]
for i in range(rows):
for j in range(cols):
ps2[i + 1][j + 1] = grid[i][j] + ps2[i][j + 1] + ps2[i + 1][j] - ps2[i][j]
def rect_sum(r1, c1, r2, c2): # 左闭右开
return ps2[r2][c2] - ps2[r1][c2] - ps2[r2][c1] + ps2[r1][c1]
print("矩阵:", grid)
print("左上 2x2 的和 =", rect_sum(0, 0, 2, 2), ",右下 2x2 的和 =", rect_sum(1, 1, 3, 3))
print()
print("规律:前缀和把区间查询变成 O(1),差分把区间修改变成 O(1);两者互为逆运算。")
print("需要同时支持区间查询和单点/区间修改时,就该上树状数组或线段树了。")
数组: [3, 1, 4, 1, 5, 9, 2, 6]
前缀和: [0, 3, 4, 8, 9, 14, 23, 25, 31]
区间 [2, 5) 的和 = 10 (直接算是 10 )
三次区间加之后的数组: [5, 5, 3, -2, -2, 8, 10, 10]
矩阵: [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
左上 2x2 的和 = 12 ,右下 2x2 的和 = 28
规律:前缀和把区间查询变成 O(1),差分把区间修改变成 O(1);两者互为逆运算。
需要同时支持区间查询和单点/区间修改时,就该上树状数组或线段树了。
前缀和还有两个常见变体:
- 前缀和 + 哈希表:求"和为 k 的子数组个数"。遍历时维护当前前缀和
cur,查cur - k出现过几次——把两层循环降成一层。这是"子数组和"类题目的标准解法。 - 前缀异或、前缀最大值、前缀乘积:同一个思路换运算。乘积要小心 0(用左右两次遍历代替除法)。
推理系统里的对应:KV 块的占用统计、每层激活的累计显存、按 token 位置统计的注意力掩码,都是"区间查询";而"给某段位置整体加一个偏移"(比如批量更新一批请求的槽位计数)就是差分。
二分:在下标上,也在答案上¶
# 二分的三种用法:找插入位置、在答案空间上二分、以及和标准库对拍
import bisect
import random
def lower_bound(a, x):
"""第一个 >= x 的位置(等价于 bisect.bisect_left)"""
lo, hi = 0, len(a) # 半开区间 [lo, hi)
while lo < hi:
mid = (lo + hi) // 2
if a[mid] < x:
lo = mid + 1
else:
hi = mid
return lo
def upper_bound(a, x):
"""第一个 > x 的位置(等价于 bisect.bisect_right)"""
lo, hi = 0, len(a)
while lo < hi:
mid = (lo + hi) // 2
if a[mid] <= x:
lo = mid + 1
else:
hi = mid
return lo
rng = random.Random(0)
for _ in range(2000): # 和标准库随机对拍
a = sorted(rng.randrange(10) for _ in range(rng.randrange(8)))
x = rng.randrange(10)
assert lower_bound(a, x) == bisect.bisect_left(a, x), (a, x)
assert upper_bound(a, x) == bisect.bisect_right(a, x), (a, x)
print("和 bisect 对拍 2000 组:完全一致")
print("出现次数 = upper_bound - lower_bound,例如 [1,2,2,2,3] 里 2 出现",
upper_bound([1, 2, 2, 2, 3], 2) - lower_bound([1, 2, 2, 2, 3], 2), "次")
print()
def min_chunk_size(lengths, budget, max_chunks):
"""二分答案:把一串请求按顺序切成若干块,每块的 token 数不超过 size,
问 size 最小取多少才能不超过 max_chunks 块(这就是分块 prefill 的分块策略)"""
def chunks_needed(size):
used = cur = 0
for x in lengths:
if x > size:
return float("inf") # 单个请求就超了,这个 size 不可行
if cur + x > size:
used, cur = used + 1, 0
cur += x
return used + (1 if cur else 0)
lo, hi = max(lengths), sum(lengths) # 答案一定在这个范围里
while lo < hi:
mid = (lo + hi) // 2
if chunks_needed(mid) <= max_chunks:
hi = mid # 可行,试试更小的
else:
lo = mid + 1
return lo if lo <= budget else -1
reqs = [512, 1024, 256, 2048, 128, 768]
for k in (2, 3, 4, 6):
print(f"把 {len(reqs)} 个请求切成不超过 {k} 块时,每块至少要能装 {min_chunk_size(reqs, 99999, k)} 个 token")
和 bisect 对拍 2000 组:完全一致
出现次数 = upper_bound - lower_bound,例如 [1,2,2,2,3] 里 2 出现 3 次
把 6 个请求切成不超过 2 块时,每块至少要能装 2944 个 token
把 6 个请求切成不超过 3 块时,每块至少要能装 2048 个 token
把 6 个请求切成不超过 4 块时,每块至少要能装 2048 个 token
把 6 个请求切成不超过 6 块时,每块至少要能装 2048 个 token
两件事:
- 模板要统一。用半开区间
[lo, hi),循环条件while lo < hi,lo = mid + 1/hi = mid,循环结束时lo == hi就是答案。这样写不会死循环,也不用纠结+1、-1。Python 里直接用bisect更省事,但面试常要求手写。 - 二分答案更常考。条件是"答案有单调性":
size可行 ⇒ 所有更大的size也可行。于是求最优值变成了写一个check(x)判定函数,再在答案空间里二分。上面的例子是分块 prefill 的分块策略:把一串请求按顺序切块、每块不超过size个 token,问size最小取多少能不超过max_chunks块(见分块 prefill)。
同类的二分答案题:最小化最大值(分割数组、运送包裹、跳石头)、最大化最小值(爱吃香蕉的珂珂、制作花束)、以及工程上的"给定 SLO 反推最大并发"。
这一章的题在考什么¶
把常见题型和对应的模板对上号:
| 看到这些字眼 | 往哪个模板想 |
|---|---|
| 连续子数组、子串、窗口 | 滑动窗口 |
| 有序数组、两数之和、首尾 | 相向双指针 |
| 原地、O(1) 空间、保持相对顺序 | 快慢指针 |
| 区间和、区间查询 | 前缀和(+ 哈希表) |
| 区间整体修改 | 差分 |
| 有序、找位置、第一个满足 | 二分 |
| 最小化最大值、最大化最小值、能否在 X 内完成 | 二分答案 |
| 第 k 大、Top-K | 堆或快速选择(见排序、堆与贪心) |
怎么讲清楚
这一章的题目几乎都可以用同一句话开头:"暴力解是两层循环 O(n²),但这里有单调性,可以把内层变成指针移动或二分。"然后说清三件事:窗口/指针里维护什么状态、什么时候移动或收缩、答案在哪一步更新。二分要主动说"我用半开区间的模板,循环结束时 lo 就是答案",并说明为什么不会死循环。碰到"最小化最大值"这类,直接点出"这是二分答案:写一个 check(x) 判定可行性,答案有单调性所以可以二分",再分析 check 的复杂度乘上 log。最后主动给复杂度和空间,并说边界:空数组、全相同、窗口比数组长。
小结¶
- 滑动窗口三形态共用一个框架,区别只在收缩时机;左右指针各走一遍所以是 O(n)。
- 前缀和把区间查询变 O(1),差分把区间修改变 O(1);前缀和 + 哈希表解决"和为 k 的子数组"。
- 二分用半开区间模板,
lower_bound/upper_bound的差就是出现次数。 - 二分答案适用于"答案有单调性"的最优化问题,分块 prefill 的块大小就是一个例子。
- 看到"连续子数组"想窗口,看到"原地 O(1)"想快慢指针,看到"最小化最大值"想二分答案。
本章练习题(20 道)
在浏览器里直接写代码、跑测试;也可以在本地用 python practice/judge.py 判题。
- 230. 位运算三题(简单)
- 231. 估算:分块 prefill 的窗口代价(简单)
- 232. 二分查找的两个边界(简单)
- 233. 最大子数组和(简单)
- 234. 合并两个有序数组(简单)
- 235. 移动零(简单)
- 236. 两数之和(简单)
- 237. 字母异位词(简单)
- 238. 盛最多水的容器(中等)
- 239. 最多翻转 k 个 0 的最长全 1 段(中等)
- 240. 最长回文子串(中等)
- 241. 最长无重复子串(中等)
- 242. 除自身以外的乘积(中等)
- 243. 原地旋转矩阵与螺旋遍历(中等)
- 244. 旋转有序数组里查找(中等)
- 245. 颜色分类(中等)
- 246. 二分答案:分块的最大值(中等)
- 247. 和为 k 的子数组(中等)
- 248. 三数之和(中等)
- 249. 最小覆盖子串(困难)
发现错误或有更好的写法?在 GitHub 上编辑此页提交问题