CS170 · 讨论课 6

Prim 算法与集合覆盖

本讲介绍 Prim 最小生成树算法,通过网格图演示算法执行过程,并与 Dijkstra 最短路径算法进行对比。仅供课程学习练习使用,不得传播。

知识焦点

先建立概念地图

  • Prim's algorithm
  • minimum spanning tree
  • Dijkstra's algorithm
  • graph algorithms
  • greedy algorithms
  • Prim's Algorithm
  • Set Cover
  • Greedy Algorithm

建议顺序

题面 → 提示 → 答案

先完整作答,再按需展开提示;最后才展开官方参考解答并逐步核对证明与计算。

本地来源

可离线完成

题面与解答均由本地一手资料整理:dis06.pdf、dis06-sol.pdf

本地一手资料完整提取 · 讨论课

完整题目、提示与答案

本节按本地一手资料完整整理,共 4 道大题。本地官方解答已完整收录并默认折叠;建议先独立完成,再逐题核对推导。

使用说明

  • 所有题面均已本土化,并补足网页离线作答所需的数值、代码、表格与约束。
  • 题目与答案分层展示,展开答案前可先查看提示。
  • 本地来源:dis06.pdf、dis06-sol.pdf
1Prim 算法边的添加顺序题目 · 提示 · 答案
官方题面翻译可离线作答官方解答已收录

完整题面

列出 Prim 算法按添加顺序的前六条边。假设 Prim 算法从顶点 A 开始,并按字典序打破平局。

(图:包含顶点 A-L 的网格图,边权如图所示)

图表条件 · Prim 算法教程图:包含 12 个顶点 (A-L) 的网格图,A-I 构成 3×3 网格,J-K-L 为 G 下方的竖直链

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,若有多个相同权重的边,按字典序选择。

  1. \(S = \{A\}\),可选边 AB(1), AD(34),选择 AB。
  2. \(S = \{A,B\}\),可选边 BE(2), AD(34), BC(8),选择 BE。
  3. \(S = \{A,B,E\}\),可选边 DE(5), EH(6), AD(34), BC(8), EF(9),选择 DE。
  4. \(S = \{A,B,D,E\}\),可选边 EH(6), AD(34), BC(8), CF(9), DG(9), EF(9),选择 EH。
  5. \(S = \{A,B,D,E,H\}\),可选边 GH(7), HI(7), AD(34), BC(8), CF(9), DG(9), EF(9), FI(9),选择 GH(字典序 \(G < I\))。
  6. \(S = \{A,B,D,E,G,H\}\),可选边 HI(7), AD(34), BC(8), CF(9), DG(9), EF(9), FI(9), GJ(10),选择 HI。

时间复杂度: 使用二叉堆实现为 \(O((V+E) \log V)\),对于连通图可简化为 \(O(E \log V)\)。

解答来源:dis06-sol.pdf · 1 · confidence: high

题面来源:dis06.pdf · 1 · confidence: high

2贪心 Set Cover 的最坏情况实例题目 · 提示 · 答案
官方题面翻译可离线作答官方解答已收录

完整题面

在课上我们证明了:对于寻找最小集合覆盖(minimum set cover)的贪心策略,其给出的集合数至多比最优解高一个因子 \(O(\log n)\),其中 \(n = |U|\)。本题我们证明这个界是紧的(tight)。

证明:对于任意是 2 的幂的整数 \(n\),存在一个 Set Cover 问题的实例(即一组集合 \(S_1, \ldots, S_m\)),满足以下性质:

  1. 全集 \(U\) 中有 \(n\) 个元素;
  2. 最优覆盖(optimal cover)仅使用两个集合;
  3. 贪心算法(greedy algorithm)至少选出 \(\Omega(\log n)\) 个集合。

图表条件 · n=64 示例中 T1、T2、S1、S2、S3、S4、S5 各集合的元素范围与大小对照表

集合 元素 大小
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

3独立集近似算法题目 · 提示 · 答案
官方题面翻译可离线作答官方解答已收录

完整题面

在最大独立集(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\):

  1. 在 \(G\) 中任选一个顶点 \(v\),令 \(I = I \cup \{v\}\)。
  2. 从图中删除 \(v\) 及其所有邻居。
  3. 令 \(G\) 为新的图。

根据构造方式,\(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

4动态规划入门:斐波那契数列题目 · 提示 · 答案
官方题面翻译可离线作答官方解答已收录

完整题面

斐波那契数列由以下递推关系定义: \[ 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(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


一手资料