📚 覆盖:第 1–8 讲 · 建议:先独立完成再看解答;证明题要写完整论证,不能只给结论。
第一部分:正确性与渐近分析(第 1–2 讲)
题 1(5 分)
对下面的函数:
func Search(a []int, x int) int {
for i := 0; i < len(a); i++ {
if a[i] == x { return i }
}
return -1
}
(a) 写出一个循环不变式。 (b) 完成初始化、保持、终止三步证明。 (c) 为什么"终止"这一步不能省略?
题 2(6 分)
用定义(给出具体的 c、n₀)证明或反驳:
(a) n² + 3n = Θ(n²)
(b) 2^(n+1) = O(2ⁿ)
(c) 2^(2n) = O(2ⁿ)
(d) n log n = O(n^1.01)
(e) log(n!) = Θ(n log n)
题 3(4 分)
按增长率从慢到快排序,相同量级的标出:
n^(1/log₂ n)、4^(log₂ n)、log(n!)、n log n、2^(log₂ n)、n!、(log n)^(log n)、n²
题 4(4 分)
求下列代码段的复杂度并说明理由:
// (a)
for i := 1; i <= n; i++ {
for j := i; j <= n; j += i { count++ }
}
// (b)
for i := n; i > 0; i /= 2 {
for j := 0; j < i; j++ { count++ }
}
// (c)
for i := 1; i*i <= n; i++ { count++ }
// (d)
for i := 1; i <= n; i++ {
j := i
for j > 0 { j /= 2 }
}
题 5(3 分)
你把 n 从 10⁴ 提到 10⁵,运行时间从 0.2 s 变成 20 s。
(a) 最可能的复杂度是什么? (b) 若 n 再提到 10⁶,预计多久? (c) 若目标是 10⁶ 时不超过 10 s,需要把算法改进到什么量级?
第二部分:递归式(第 3 讲)
题 6(6 分)
用主定理求解,指出情形编号:
(a) T(n) = 4T(n/2) + n
(b) T(n) = 4T(n/2) + n²
(c) T(n) = 4T(n/2) + n³
(d) T(n) = 2T(n/4) + √n
(e) T(n) = 3T(n/3) + n/2
(f) T(n) = 16T(n/4) + n²
题 7(4 分)
T(n) = 2T(n/2) + n/log n
(a) 为什么主定理三种情形都不适用? (b) 用递归树求出它的解。
题 8(4 分)
指出下面"证明"的错误,并给出正确的论证:
设
T(n) = 2T(n/2) + n。归纳假设T(n/2) = O(n/2),则T(n) = 2·O(n/2) + n = O(n) + n = O(n)。
题 9(3 分)
用换元法求解 T(n) = 2T(√n) + log n。
第三部分:摊还分析(第 4 讲)
题 10(6 分)
一个动态数组,容量满时扩容为原来的 3 倍。
(a) n 次 append 的总代价是多少?摊还代价呢? (b) 若改成每次扩容 +100 个位置,总代价是多少? (c) 从(a)(b)总结出扩容策略的一般规律。
题 11(6 分)
k 位二进制计数器同时支持 INCREMENT 和 DECREMENT。
(a) 证明摊还代价不再是 O(1)。 (b) 构造一个使摊还退化为 Θ(k) 的操作序列。 (c) 若只支持 INCREMENT,用势能法证明摊还 O(1)。
题 12(5 分)
一个数据结构支持 Push(O(1))和 Flush(把当前所有元素清空并处理,代价 = 元素个数)。
(a) 用聚合法证明每个操作摊还 O(1)。 (b) 用记账法给出摊还代价的分配。 (c) 给出一个势能函数并验证。
题 13(4 分)
动态数组的收缩策略:
(a) 若"size < capacity/2 就收缩到一半",构造一个使摊还退化为 Θ(n) 的操作序列。 (b) 为什么改成 “size < capacity/4 才收缩” 就能恢复 O(1)? (c) 这个"滞后区"思想还能用在什么工程场景?
第四部分:线性结构(第 5–6 讲)
题 14(5 分)
(a) 列出数组、动态数组、单链表、双链表在六个操作上的复杂度。 (b) 双链表唯一无可替代的能力是什么? (c) 设计一个 O(1) 的 LRU 缓存,说明为什么必须"散列表 + 双链表"。
题 15(4 分)
Go 中下面的代码有什么问题?
type Queue struct{ data []*Item }
func (q *Queue) Push(x *Item) { q.data = append(q.data, x) }
func (q *Queue) Pop() *Item {
x := q.data[0]
q.data = q.data[1:]
return x
}
(a) 指出两个独立的问题。 (b) 给出正确的实现。
题 16(5 分)
用两个栈实现一个队列。
(a) 给出实现。
(b) 证明每个操作的摊还代价是 O(1)。
(c) 单次 Dequeue 的最坏代价是多少?
题 17(5 分)
数组 a,对每个 i 求"右边第一个比 a[i] 大的元素下标"。
(a) 写出单调栈解法。 (b) 证明它是 Θ(n) 而不是 Θ(n²)。 (c) 如果改求"左边第一个更小的",代码怎么改?
第五部分:散列表(第 7–8 讲)
题 18(6 分)
链地址法,n 个键、m 个槽。
(a) 推导 SUHA 下不成功查找的期望代价。 (b) 推导成功查找的期望代价。 (c) 为什么维持 α = O(1) 就能得到 O(1) 操作?这需要什么机制,代价是多少?
题 19(6 分)
开放寻址,装填因子 α。
(a) 推导不成功查找的期望探查次数 1/(1−α)。 (b) 填一张 α = 0.5 / 0.75 / 0.9 / 0.95 / 0.99 时的探查次数表。 (c) 为什么链地址法可以容忍更高的 α?
题 20(8 分)
(a) 全域散列的定义中,概率是对什么取的?为什么这一点关键? (b) 证明:若 H 是全域族,任意键 k 所在链的期望长度 < α + 1。 (c) 什么是散列洪水攻击?为什么"换更复杂的散列函数"防不住它? (d) Java 的 HashMap 把长链转成红黑树,这个防御的思路与随机化种子有何不同?
题 21(6 分)
布隆过滤器,n 个元素、m 位、k 个散列函数。
(a) 推导误判率公式与最优 k。 (b) 目标误判率 1%,每元素需要多少位? (c) 内存只够 5 位/元素时,最优 k 和误判率各是多少? (d) 为什么不能删除?计数布隆过滤器的代价是什么?
参考解答
题 1
(a) 不变式:每次迭代开始前,a[0..i-1] 中不含 x。
(b)
- 初始化:i = 0 时
a[0..-1]是空切片,平凡成立。✓ - 保持:若迭代开始前
a[0..i-1]不含 x,本次检查a[i]:若相等则返回 i(正确);否则a[0..i]仍不含 x,i 加 1 后不变式成立。✓ - 终止:循环在 i = len(a) 时结束,不变式给出
a[0..len(a)-1]不含 x,返回 −1 正确。✓
(c) 因为终止步是把"循环中途的部分性质"兑换成"我们真正要的结论"的唯一环节。一个不变式如果在终止时推不出目标结论,它再真也没有用。
题 2
(a) 成立。取 c₁ = 1、c₂ = 4、n₀ = 1:对 n ≥ 1,n² ≤ n²+3n ≤ 4n²。
(b) 成立。2^(n+1) = 2·2ⁿ,取 c = 2、n₀ = 1。
(c) 不成立。2^(2n)/2ⁿ = 2ⁿ → ∞,不存在常数 c 使 2^(2n) ≤ c·2ⁿ。⭐ 这是"指数的底/系数不能随意吸收"的关键对照。
(d) 成立。n log n / n^1.01 = log n / n^0.01 → 0(任何多项式打败任何多对数)。
(e) 成立。由 Stirling:log(n!) = n log n − n log e + O(log n) = Θ(n log n)。
题 3
n^(1/log₂ n) = 2 (常数)
< 2^(log₂ n) = n ——等于 n
< n log n = log(n!) ——这两个同量级 Θ(n log n)
< n² = 4^(log₂ n) ——这两个同量级 Θ(n²)
< (log n)^(log n) ——= 2^(log log n · log n),超多项式但次指数
< n!
排序:n^(1/log n) ≺ 2^(log₂n) ≺ n log n = log(n!) ≺ n² = 4^(log₂n) ≺ (log n)^(log n) ≺ n!
题 4
(a) Θ(n log n)。内层执行 n/i 次,总和 Σ n/i = n·Hₙ = Θ(n log n)。⚠️ 不是 Θ(n²)。
(b) Θ(n)。总和 n + n/2 + n/4 + … < 2n(几何级数)。
(c) Θ(√n)。
(d) Θ(n log n)。外层 n 次,内层每次 log i ≤ log n 次;下界由后一半的 i ≥ n/2 给出 Θ(n log n)。
题 5
(a) n 增大 10 倍,时间增大 100 倍 ⟹ Θ(n²)。
(b) 再增 10 倍 ⟹ 时间 ×100 ⟹ 约 2000 s ≈ 33 分钟。
(c) 需要在 10⁶ 时 ≤ 10 s。若是 Θ(n log n):从 10⁴ 的基准外推,10⁶ 时约 0.2 × (10⁶·20)/(10⁴·13) ≈ 30 s——仍略超。目标应是 Θ(n log n) 并优化常数,或 Θ(n)。
题 6
| log_b a | f(n) | 情形 | 解 | |
|---|---|---|---|---|
| (a) | log₂4 = 2 | n = O(n^(2−ε)) | 1 | Θ(n²) |
| (b) | 2 | n² = Θ(n²) | 2 (k=0) | Θ(n² log n) |
| (c) | 2 | n³ = Ω(n^(2+ε)),正则 ✓ | 3 | Θ(n³) |
| (d) | log₄2 = 0.5 | √n = Θ(n^0.5) | 2 (k=0) | Θ(√n log n) |
| (e) | log₃3 = 1 | n/2 = Θ(n) | 2 (k=0) | Θ(n log n) |
| (f) | log₄16 = 2 | n² = Θ(n²) | 2 (k=0) | Θ(n² log n) |
题 7
(a) log₂2 = 1,f(n) = n/log n。与 n¹ 比:f(n)/n = 1/log n → 0,所以 f 比 n 小;但小的程度不是多项式级(不存在 ε 使 n/log n = O(n^(1−ε))),情形 1 不适用;显然也不是 Θ(n log^k n)(k ≥ 0),情形 2 不适用;f 更小,情形 3 不适用。
(b) 递归树第 i 层有 2ⁱ 个节点,每个代价 (n/2ⁱ)/log(n/2ⁱ),该层总和 n/(log n − i)。求和:
T(n) = Σ_{i=0}^{log n − 1} n/(log n − i) = n · Σ_{j=1}^{log n} 1/j = n·H_{log n} = Θ(n log log n)
题 8
错误在最后一步。归纳法必须精确证出归纳假设的同一形式:要证 T(n) ≤ cn(同一个 c),但推导只得到 T(n) ≤ cn + n = (c+1)n——常数变了。这种"渐近地做归纳"能证明任何结论,是无效的。
正确论证:T(n) = 2T(n/2)+n 的解是 Θ(n log n),不是 O(n)。用代换法猜 T(n) ≤ cn log n:T(n) ≤ 2·c(n/2)log(n/2) + n = cn log n − cn + n ≤ cn log n(取 c ≥ 1)。✓
题 9
令 m = log₂ n(n = 2^m),S(m) = T(2^m):
S(m) = 2S(m/2) + m ⟹ 主定理情形 2 ⟹ S(m) = Θ(m log m)
⟹ T(n) = Θ(log n · log log n)
题 10
(a) 扩容发生在容量 1, 3, 9, …, 3^k 处,拷贝总量 1+3+9+…+3^k < 3^(k+1)/2 = O(n)。加上 n 次写入,总代价 Θ(n),摊还 Θ(1)。
(b) 扩容在 100, 200, …, n 处,拷贝总量 100+200+…+n = Θ(n²/100) = Θ(n²),摊还 Θ(n)。
(c) ⭐ 必须几何增长(增长因子 > 1 的任意常数都行),线性增量会导致摊还退化为线性。因子取 2 还是 1.5 或 3 只影响常数与内存复用效率。
题 11
(a)(b) 取计数器处于 0111…1(k−1 个 1)与 1000…0 的边界,交替执行 INCREMENT 和 DECREMENT:
0111…1 --INC--> 1000…0 翻转 k 位
1000…0 --DEC--> 0111…1 翻转 k 位
每次操作都是 Θ(k),摊还退化为 Θ(k)。势能法失效的原因:INCREMENT 减少的势能被 DECREMENT 立刻加回来。
(c) 取 Φ = 计数器中 1 的个数 b。第 i 次 INCREMENT 把 tᵢ 个 1 变 0、1 个 0 变 1:
cᵢ = tᵢ + 1,ΔΦ = 1 − tᵢ ⟹ ĉᵢ = 2 = Θ(1)
题 12
(a) 聚合法:n 个操作中,每个元素最多被 Flush 处理一次,而它必须先被 Push 进来。故总代价 ≤ 2n,摊还 O(1)。
(b) 记账法:Push 收 2 元(1 元付压入,1 元存在该元素上);Flush 收 0 元(每个元素用自己的存款付处理费)。总信用永不为负。✓
(c) 势能法:取 Φ = 当前元素个数。
- Push:c = 1,ΔΦ = +1,ĉ = 2
- Flush(清 k 个):c = k,ΔΦ = −k,ĉ = 0 ✓
题 13
(a) 数组刚好半满时:push(触发扩容拷贝 n)→ pop(size 降到 capacity/2 以下,触发收缩拷贝 n)→ push → pop → …每次操作 Θ(n)。
(b) 收缩阈值 capacity/4 时,一次扩容或收缩之后,size 距离下一个触发点至少还有 capacity/4,需要至少 capacity/4 次操作。几何级数论证重新成立,摊还回到 O(1)。
(c) 任何"自动伸缩"机制都需要滞后区:线程池扩缩容、连接池、Kubernetes HPA、缓存淘汰阈值、UI 的自动滚动触发。否则会在阈值处抖动。
题 14
(a)
| 操作 | 静态数组 | 动态数组 | 单链表 | 双链表 |
|---|---|---|---|---|
| Get(i) | Θ(1) | Θ(1) | Θ(n) | Θ(n) |
| InsertFirst | Θ(n) | Θ(n) | Θ(1) | Θ(1) |
| DeleteFirst | Θ(n) | Θ(n) | Θ(1) | Θ(1) |
| InsertLast | — | Θ(1)摊还 | Θ(1) | Θ(1) |
| DeleteLast | Θ(1) | Θ(1) | Θ(n) | Θ(1) |
| 持有指针时删除 | Θ(n) | Θ(n) | Θ(n) | Θ(1) |
(b) 已持有节点指针时的 O(1) 删除,且删除不会使其他节点的指针失效。
(c) LRU 需要两件事:① 按 key 在 O(1) 内定位节点(散列表);② 把该节点从访问顺序链表中 O(1) 摘除并移到表头(双链表)。散列表存 key → *Node,链表维护访问顺序。单用散列表无法维护顺序;单用链表无法 O(1) 定位。
题 15
(a)
- 内存泄漏:
q.data = q.data[1:]只是移动切片头,底层数组前段永远不被回收;且出队元素是指针,被底层数组持续持有,GC 无法回收它指向的对象。长期运行内存单调增长。 - 底层数组不断重新分配:切片头不断右移、容量递减,
append会反复触发新分配和整体拷贝。
(b) 用环形缓冲,且出队时把槽位清零:
func (q *Queue) Pop() *Item {
x := q.data[q.head]
q.data[q.head] = nil // 断开引用
q.head = (q.head + 1) % len(q.data)
q.size--
return x
}
题 16
(a)
type MyQueue struct{ in, out []int }
func (q *MyQueue) Enqueue(x int) { q.in = append(q.in, x) }
func (q *MyQueue) Dequeue() (int, bool) {
if len(q.out) == 0 {
for len(q.in) > 0 { // 整体倒过来,顺序自然翻转
n := len(q.in) - 1
q.out = append(q.out, q.in[n])
q.in = q.in[:n]
}
}
if len(q.out) == 0 { return 0, false }
n := len(q.out) - 1
x := q.out[n]
q.out = q.out[:n]
return x, true
}
(b) 记账法:Enqueue 收 3 元(1 元压入 in,1 元将来从 in 弹出,1 元将来压入 out);Dequeue 收 1 元。每个元素最多被搬运一次,摊还 O(1)。
(c) 最坏 Θ(n)——恰好在 out 为空且 in 有 n 个元素时。
题 17
(a) 见第 6 讲的 NextGreater。
(b) 每个下标恰好入栈一次、出栈一次。内层 while 的总执行次数 = 总出栈次数 ≤ n。故总代价 Θ(n)。(记账法:入栈时预付出栈费用。)
(c) 从右往左遍历,或把比较符号从 < 改为 >——即维护一个单调递增栈。关键是想清楚"栈中保留的是什么样的候选"。
题 18
(a) 链 T[h(k)] 的期望长度 = Σ_{i=1}^{n} Pr[键 i 落在该槽] = n/m = α。加上计算散列与寻址的 Θ(1),期望代价 Θ(1+α)。
(b) 设查找第 i 个插入的键,头插法下需检查 1 + (在它之后插入且同槽的键数):
E = (1/n)Σ_{i=1}^{n}(1 + Σ_{j=i+1}^{n} 1/m) = 1 + (n−1)/(2m) = Θ(1+α)
(c) α = n/m = O(1) 意味着 m = Θ(n),代价 Θ(1+α) = Θ(1)。需要再散列(rehash) 机制:α 超阈值时把 m 翻倍并重新插入所有元素,单次 Θ(n),但由几何增长论证摊还 O(1)。
题 19
(a) 每次探查命中空槽的概率约为 1−α(因为约 α 比例的槽被占)。探查次数服从几何分布,期望 1/(1−α)。
(b)
| α | 0.5 | 0.75 | 0.9 | 0.95 | 0.99 |
|---|---|---|---|---|---|
| 1/(1−α) | 2 | 4 | 10 | 20 | 100 |
(c) 链地址法的代价是 Θ(1+α)——线性,α = 5 也只是平均查 5 个元素;开放寻址是 1/(1−α)——在 α→1 时爆炸,且 α 不能超过 1。
题 20
(a) 概率是对 h 从函数族 H 中的随机选择取的,不是对键的分布取的。这一点关键:因为随机性来自算法自己,结论对任意输入都成立,攻击者无法构造最坏输入。
(b) 对键 k,定义指示变量 X_{kl} = 1[h(k)=h(l)],由全域性 E[X_{kl}] ≤ 1/m。k 所在槽的其他元素数:
E[Nₖ] = Σ_{l≠k} E[X_{kl}] ≤ (n−1)/m < α
加上 k 自己,期望链长 < α + 1。∎
(c) 攻击者离线构造出大量散列值相同的键(如 HTTP POST 参数名),一次请求触发 n 次同槽插入,Θ(n²),CPU 打满。换更复杂的散列函数没用,只要它是固定且公开的,攻击者就能离线算出碰撞集。必须引入攻击者不知道的随机性(进程启动时的随机种子)。
(d) 随机化种子是预防——让攻击者无法构造碰撞集;红黑树是兜底——承认碰撞可能发生,但把最坏代价从 O(n) 降到 O(log n),使攻击收益不足。两者可以并用。
题 21
(a) 插入 n 个元素后某位仍为 0 的概率 ≈ e^{−kn/m},误判率 FP ≈ (1−e^{−kn/m})^k。对 k 求导取极值得 k* = (m/n)ln2,代入得 FP = 0.6185^{m/n}。
(b) m/n = −log₂(0.01)/ln2 ≈ 6.64/0.693 ≈ **9.6 位**(约 1.2 字节),最优 k = 7。
(c) m/n = 5 ⟹ k* = 5×0.693 ≈ 3.5 → 取 3 或 4;FP = 0.6185^5 ≈ **9%**。
(d) 一个位可能被多个元素共享,清零会导致其他元素被误判为"不存在"(产生假阴性,破坏了布隆过滤器的核心保证)。计数布隆过滤器把每位换成 4 位计数器,空间变成 4 倍。