知识焦点
先建立概念地图
- Prim's algorithm
- minimum spanning tree
- Dijkstra's algorithm
- graph algorithms
- greedy algorithms
- Prim's Algorithm
- Set Cover
- Greedy Algorithm
CS170 · 讨论课 6
本讲介绍 Prim 最小生成树算法,通过网格图演示算法执行过程,并与 Dijkstra 最短路径算法进行对比。仅供课程学习练习使用,不得传播。
知识焦点
建议顺序
先完整作答,再按需展开提示;最后才展开官方参考解答并逐步核对证明与计算。
本地来源
题面与解答均由本地一手资料整理:dis06.pdf、dis06-sol.pdf
本地一手资料完整提取 · 讨论课
本节按本地一手资料完整整理,共 4 道大题。本地官方解答已完整收录并默认折叠;建议先独立完成,再逐题核对推导。
dis06.pdf、dis06-sol.pdf列出 Prim 算法按添加顺序的前六条边。假设 Prim 算法从顶点 A 开始,并按字典序打破平局。
(图:包含顶点 A-L 的网格图,边权如图所示)
| Edge | Weight |
|---|---|
| AB | 1 |
| BC | 8 |
| AD | 34 |
| BE | 2 |
| CF | 9 |
| DE | 5 |
| EF | 9 |
| DG | 9 |
| EH | 6 |
| FI | 9 |
| GH | 7 |
| HI | 7 |
| GJ | 10 |
| JK | 10 |
| KL | 11 |
来源:dis06.pdf · 1
Prim 算法每次选择连接已访问顶点集 \(S\) 与未访问顶点集 \(V\setminus S\) 的最小权边加入 MST。当多条边权相同时,按字典序选择顶点编号较小的边。
前六条边依次为:AB, BE, DE, EH, GH, HI。
算法过程:
Prim 算法从顶点 A 开始,维护已访问集合 \(S\)。每次选择连接 \(S\) 与 \(V\setminus S\) 的最小权边加入 MST,若有多个相同权重的边,按字典序选择。
时间复杂度: 使用二叉堆实现为 \(O((V+E) \log V)\),对于连通图可简化为 \(O(E \log V)\)。
解答来源:dis06-sol.pdf · 1 · confidence: high
题面来源:dis06.pdf · 1 · confidence: high
在课上我们证明了:对于寻找最小集合覆盖(minimum set cover)的贪心策略,其给出的集合数至多比最优解高一个因子 \(O(\log n)\),其中 \(n = |U|\)。本题我们证明这个界是紧的(tight)。
证明:对于任意是 2 的幂的整数 \(n\),存在一个 Set Cover 问题的实例(即一组集合 \(S_1, \ldots, S_m\)),满足以下性质:
| 集合 | 元素 | 大小 |
|---|---|---|
| T1 | {1,3,...,63} | 32 |
| T2 | {2,4,...,64} | 32 |
| S1 | {1,...,34} | 34 = 32+2 |
| S2 | {35,...,50} | 16 |
| S3 | {51,...,58} | 8 |
| S4 | {59,...,62} | 4 |
| S5 | {63,64} | 2 |
来源:dis06-sol.pdf · 4
提示:考虑将全集 \(U = \{1,2,\ldots,2^k\}\) 分成奇数位置和偶数位置的两个集合作为最优覆盖。然后构造一组集合 \(S_1, \ldots, S_{k-1}\),使得每个 \(S_i\) 比剩余未覆盖部分的 \(1/2^i\) 略多一点点,从而让贪心算法在每一步都"上当"去选 \(S_i\) 而不是那两个最优集合。
考虑基础集合 \(U = \{1, 2, 3, \ldots, 2^k\}\),其中 \(k \geq 2\)。令
\[T_1 = \{1, 3, 5, \ldots, 2^k - 1\}, \quad T_2 = \{2, 4, 6, \ldots, 2^k\}.\]
这两个集合 \(T_1\) 与 \(T_2\) 构成一个最优覆盖(optimal cover)。
我们再添加集合 \(S_1, \ldots, S_{k-1}\),定义
\[\ell_i = 2 + \sum_{j=1}^{i} 2^{k-j} \quad (\text{取 } \ell_0 = 0),\]
并令
\[S_i = \{\ell_{i-1} + 1, \ldots, \ell_i\}.\]
直观上,\(S_i\) 覆盖了略多于全集 \(1/2^i\) 比例的元素,且特别地 \(S_1\) 比 \(T_1\) 和 \(T_2\) 都大。
贪心选择分析: 由于 \(S_1\) 包含 \(2^{k-1} + 2\) 个元素,贪心算法会首先选中 \(S_1\)。当算法已选中 \(\{S_1, S_2, \ldots, S_i\}\) 后,\(T_1\) 与 \(T_2\) 各自仅能再覆盖 \(2^{k-i-1} - 1\) 个新元素,而 \(S_{i+1}\) 能覆盖 \(2^{k-i-1}\) 个新元素。因此在每一步中,\(S_{i+1}\) 覆盖的新元素都多于 \(T_1\) 或 \(T_2\) 单独覆盖的新元素,贪心算法会继续选择 \(S_{i+1}\)。
因此贪心算法最终选出的覆盖为 \(\{S_1, S_2, \ldots, S_{k-1}\}\),共 \(k - 1 = \log n - 1\) 个集合,即 \(\Omega(\log n)\) 个集合。
具体示例(\(n = 64\)): 令 \(T_1 = \{1, 3, \ldots, 63\}\),\(T_2 = \{2, 4, \ldots, 64\}\),选 \(T_1, T_2\) 为最优解。再令
\(S_1 = \{1, 2, \ldots, 32, 33, 34\}\),即比 \(64/2\) 多 2 个元素;
\(S_2\) 包含接下来的 \(64/4 = 16\) 个元素;
\(S_3\) 包含接下来的 \(64/8 = 8\) 个元素;
\(S_4\) 包含接下来的 \(64/16 = 4\) 个元素;
\(S_5\) 包含最后剩下的 \(2\) 个元素。
这是因为 \(64 = 32 + 16 + 8 + 4 + 2 + 2\),我们只是将项重新分组为 \(64 = (32 + 2) + 16 + 8 + 4 + 2\) 并据此构造集合。
贪心算法将选中 \(S_1, S_2, S_3, S_4, S_5\) 而非 \(T_1, T_2\)。由于 \(n = 64\) 时 \(k = 6\),贪心算法共选中 \(k - 1 = 5\) 个集合,即 \(\log n - 1\) 个集合。前两段的推导将此推广到了所有 \(k\)。
复杂度与结论: 该实例中贪心算法选出 \(\Theta(\log n)\) 个集合,而最优解仅需 2 个集合,因此贪心算法的近似比达到 \(\Theta(\log n)\),证明了课上给出的 \(O(\log n)\) 上界是紧的。
解答来源:dis06-sol.pdf · 4 · confidence: high
题面来源:dis06.pdf · 2 · confidence: high
在最大独立集(Max Independent Set)问题中,给定一个图 \(G = (V, E)\),要求找出最大的顶点集 \(V' \subseteq V\),使得 \(V'\) 中任意两个顶点之间都没有边相连。
给定一个无向图 \(G = (V, E)\),其中每个顶点的度数都不超过 \(d\),给出一个高效算法,找出一个独立集,其大小至少是最大独立集的 \(\frac{1}{d+1}\)。
描述你的算法;
证明它找到的独立集大小至少是最优解的 \(\frac{1}{d+1}\);
你的算法时间复杂度应为 \(O(|V| \cdot |E|)\)(或更低)。
考虑贪心地构造独立集:每次任选一个顶点加入独立集,然后将其与它的所有邻居从图中删除。思考每一轮最多会删除多少个顶点,以及这对总轮数与最优解大小的关系有什么影响。
解:初始时令 \(G\) 为原图,\(I = \emptyset\)。重复以下过程直到 \(G = \emptyset\):
根据构造方式,\(I\) 显然是一个独立集(每次加入 \(v\) 时,它的所有邻居都会被删除,不可能再被加入)。每一步中,\(I\) 恰好增加一个顶点,而从图中最多删除 \(d + 1\) 个顶点(因为 \(v\) 最多有 \(d\) 个邻居,加上 \(v\) 本身)。因此,整个算法至少执行 \(\frac{|V|}{d+1}\) 轮。
设 \(K\) 为最大独立集的大小,显然 \(K \leq |V|\)。利用上面的讨论可得:
\[ |I| \geq \frac{|V|}{d+1} \geq \frac{K}{d+1} \]
因此,算法找到的独立集大小至少是最优解的 \(\frac{1}{d+1}\)。
解答来源:dis06-sol.pdf · 5 · confidence: high
题面来源:dis06.pdf · 3 · confidence: high
斐波那契数列由以下递推关系定义: \[ F_n = F_{n-1} + F_{n-2}, \] 初始条件为 \(F_0 = 0\),\(F_1 = 1\)。在 CS 61A 中,我们学过如何写程序计算第 \(n\) 个斐波那契数,代码如下:
def fibo(n):
if n <= 1:
return n
return fibo(n-1) + fibo(n-2)
(a) 这个程序实际上非常慢!请说明调用 fibo(n) 需要 \(2^{\Theta(n)}\) 时间。
挑战: 证明运行时间为 \(\Theta\bigl(\bigl(\frac{1+\sqrt{5}}{2}\bigr)^n\bigr)\)。
(b) 如果刚才没有做,请画出调用 fibo(5) 时产生的递推树。你有没有发现重复计算的子问题(即重复的节点)?
(c) 在递推树中,我们发现许多值被重复计算了很多次(例如 \(F_1\) 被计算了 5 次!)。为了减少重复计算,我们可以在计算出每个斐波那契数后将其存入数组,之后需要时直接查表。请补全以下代码中的空白行:
def optimized_fibo(n):
stored_fibos = [-1 for _ in range(n+1)]
def fibo(n):
# base case
if __________:
return __________
# if we've already computed fibo(n) before, we can reuse it via stored_fibos!
if ____________________:
return ____________________
# if we haven't already computed fibo(n), then we need to recurse as before:
# make sure to store it in stored_fibos so that we can use it in the future!
______________________________
return ____________________
(d) 这个新算法的运行时间是多少?
恭喜,你刚刚实现了你的第一个动态规划(DP)算法!DP 本质上就是:递归 + 存储(记忆化),使得每个子问题不必被重复求解。
实际上 DP 有两种实现方式。上面你实现的是自顶向下(top-down)的方法,即从最大子问题出发,不断递归到更小的子问题。另一种是自底向上(bottom-up)的方法,从最小子问题(初始条件)出发,迭代地构建更大的子问题。
(e) 请参照你之前的自顶向下实现,补全以下代码中的空白行,完成斐波那契 DP 算法的自底向上实现:
def fibo_dp_bottom_up(n):
stored_fibos = [-1 for _ in range(n+1)]
# define base cases at the "bottom"
stored_fibos[0] = 0
stored_fibos[1] = 1
# build your subproblems "up" from your smaller subproblems
for i in range(2, n+1):
________________________________________
# what element in stored_fibos represents the nth fibonacci number?
answer = ____________________
return answer
(f)(挑战) 你算法的空间复杂度是多少?能否将其修改为只使用 \(O(1)\) 的额外空间?
def fibo_dp_bottom_up(n):
# define base cases here
if __________:
____________________
____________________
____________________
# build your subproblems "up" from your smaller subproblems
for i in range(2, n+1):
______________________________________
# where are you storing the nth fibonacci number?
answer = ____________________
return answer
fibo(5)
/ \\
fibo(4) fibo(3)
/ \\ / \\
fibo(3) fibo(2) fibo(2) fibo(1)
/ \\ / \\ / \\
fibo(2) fibo(1) fibo(1) fibo(0) fibo(1) fibo(0)
/ \\
fibo(1) fibo(0)
叶子节点统计:fibo(1) 出现 5 次,fibo(0) 出现 3 次。
来源:dis06-sol.pdf · 7
对于运行时间,写出 \(T(n)\) 的递推式,用代入法或递归树给出指数形式的上下界。
递推树:从 fibo(5) 开始,每个节点展开为它的两个子调用,直到达到初始条件。
记忆化:先检查是否已经计算过,若已计算则直接返回保存的值;否则递归计算并保存。
自底向上:从最小的子问题开始,按顺序填充数组。
空间优化:每个斐波那契数只依赖于前两个数,因此只需保留最近的两个值。
(a)运行时间分析
运行时间的递推式为 \(T(n) = T(n-1) + T(n-2)\)。
下界:\(T(n) \geq 2T(n-2)\),因此 \(T(n) = \Omega(2^{n/2})\)。
上界:\(T(n) \leq 2T(n-1)\),因此 \(T(n) = O(2^n)\)。
综上,\(T(n) = 2^{\Theta(n)}\)。
更精确的运行时间:设 \(T(n) = \Theta(a^n)\),代入递推式得 \(a^n = a^{n-1} + a^{n-2}\),即 \(a^2 = a + 1\),\(a^2 - a - 1 = 0\)。由求根公式得 \(a = \frac{1 \pm \sqrt{5}}{2}\)。由于 \(a\) 必须为正,故 \(a = \frac{1+\sqrt{5}}{2}\),因此 \[ T(n) = \Theta\left(\left(\frac{1+\sqrt{5}}{2}\right)^n\right). \]
(b)fibo(5) 的递推树
fibo(5)
/ \\
fibo(4) fibo(3)
/ \\ / \\
fibo(3) fibo(2) fibo(2) fibo(1)
/ \\ / \\ / \\
fibo(2) fibo(1) fibo(1) fibo(0) fibo(1) fibo(0)
/ \\
fibo(1) fibo(0)
可以观察到大量重复计算的节点(例如 fibo(1) 被计算了 5 次,fibo(0) 被计算了 3 次)。
(c)记忆化实现
def optimized_fibo(n):
stored_fibos = [-1 for _ in range(n+1)]
def fibo(n):
# base case
if n <= 1:
return n
# if we've already computed fibo(n) before, we can reuse it via stored_fibos!
if stored_fibos[n] != -1:
return stored_fibos[n]
# if we haven't already computed fibo(n), then we need to recurse as before:
# make sure to store it in stored_fibos so that we can use it in the future!
stored_fibos[n] = fibo(n-1) + fibo(n-2)
return stored_fibos[n]
(d)新算法的运行时间
观察:对于每个 \(k \leq n\),第 \(k\) 个斐波那契数只会被计算一次,其余时候直接返回已保存的值。此外,fib(k) 最多被调用两次(分别由 fib(k+1) 和 fib(k+2) 调用)。
因此,运行时间为 \(O(n)\)。
(e)自底向上实现
def fibo_dp_bottom_up(n):
stored_fibos = [-1 for _ in range(n+1)]
# define base cases at the "bottom"
stored_fibos[0] = 0
stored_fibos[1] = 1
# build your subproblems "up" from your smaller subproblems
for i in range(2, n+1):
stored_fibos[i] = stored_fibos[i-1] + stored_fibos[i-2]
# what element in stored_fibos represents the nth fibonacci number?
answer = stored_fibos[n]
return answer
(f)空间复杂度与 \(O(1)\) 优化
原算法的空间复杂度为 \(O(n)\),因为我们需要创建一个长度为 \(n+1\) 的数组来保存 \(F_0, F_1, \dots, F_n\)。
注意到每个斐波那契数只依赖于前两个数,因此我们只需保存最近计算的两个斐波那契数。优化后的实现如下:
def fibo_dp_bottom_up(n):
# define base cases here
if n <= 1:
cur_fib = n
prev_2_fib = 0
prev_1_fib = 1
# build your subproblems "up" from your smaller subproblems
for i in range(2, n+1):
cur_fib = prev_2_fib + prev_1_fib
# update prev_2_fib and prev_1_fib for i+1
prev_2_fib = prev_1_fib
prev_1_fib = cur_fib
# where are you storing the nth fibonacci number?
answer = cur_fib
return answer
该优化版本只使用了常数个额外变量,因此额外空间复杂度为 \(O(1)\)。
解答来源:dis06-sol.pdf · 6-10 · confidence: high
题面来源:dis06.pdf · 5-7 · confidence: high
dis06.pdfdis06-sol.pdf