算法设计与分析


贪心

计算机学院    张腾

tengzhang@hust.edu.cn

课程大纲

算法分治动态规划贪心回溯分支限界迭代改进磁带存储排列问题活动选择问题霍夫曼编码最小生成树单源最短路径

磁带存储

磁带特点:读取文件时需将目标文件之前的文件顺序读一遍

磁带存储

现有$n$个文件,编号分别为$1, \ldots, n$,长度分别为$L[1], \ldots, L[n]$

将这些文件按$\pi(1), \ldots, \pi(n)$的顺序保存到磁带上,其中$\pi$是一个排列,$\pi(i)$表示磁带上第$i$个文件的实际文件编号

读取第$k$个文件的开销为$c(k) = \sum_{i=1}^k L[\pi(i)]$,所有文件的平均读取开销为$\Ebb_{\pi} [c] = \frac{1}{n} \sum_{k=1}^n c(k) = \frac{1}{n} \sum_{k=1}^n \sum_{i=1}^k L[\pi(i)]$

问题:求最优排列$\pi^\star$使平均读取开销$\Ebb_{\pi} [c]$最小

磁带存储

共享资源的任务调度都属于这类问题

暴力穷举:$\Omega(n!)$,动态规划?

最优子结构性:$\pi^\star(1), \ldots, \pi^\star(n)$是所有文件的最优存储顺序,易知$\pi^\star(2), \ldots, \pi^\star(n)$是除文件$\pi^\star(1)$外的所有文件的最优存储顺序

确定$\pi^\star(1)$会产生$n$个规模为$n-1$的子问题;对每个子问题,确定$\pi^\star(2)$会产生$n-1$个规模为$n-2$的子问题;……;对每个子问题,确定$\pi^\star(n-1)$会产生$2$个规模为$1$的子问题;这些子问题各不相同,总数为$n!$,因此动态规划与暴力穷举复杂度相当!

磁带存储 贪心

当前存入磁带的文件会出现在后续存入文件的读取开销计算中,故应优先存长度短的文件

贪心法:初始磁带为空,每轮迭代做贪心选择 - 将剩余文件中长度最短的文件存入磁带

和动态规划不同,贪心法每轮贪心选择后只产生一个子问题,时间复杂度与排序相同,$\Theta ( n \lg n )$

磁带存储 贪心正确性

贪心法给出的就是$L[1], \ldots, L[n]$的增序排列$\pi^{\text{ins}}$

对任意排列$\pi \ne \pi^{\text{ins}}$,必存在$i$使得$L[\pi(i)] \ge L[\pi(i+1)]$,交换文件$\pi(i)$$\pi(i+1)$后得到的新排列为$\pi'$,则$\Ebb_{\pi'} [c] \le \Ebb_{\pi} [c]$

证明:交换文件$\pi(i)$、文件$\pi(i+1)$后

  • 文件$\pi(i)$在磁带上的位置往后挪了一位,读取开销增加$L[\pi(i+1)]$
  • 文件$\pi(i+1)$在磁带上的位置往前挪了一位,读取开销减少$L[\pi(i)]$

平均读取开销变化$\Ebb_{\pi'} [c] - \Ebb_{\pi} [c] = L[\pi(i+1)] - L[\pi(i)] \le 0$

对任意排列,包括最优的$\pi^\star$,都可以通过变化非正的交换操作使其变成$\pi^{\text{ins}}$,因此$\pi^{\text{ins}}$就是使得平均读取开销最小的排列

磁带存储 带读取频率

假设除长度外,还有读取频率$F[1], \ldots, F[n]$

读取第$k$个文件的开销为$c(k) = \sum_{i=1}^k L[\pi(i)] F[\pi(k)]$,所有文件的平均读取开销为

$$ \begin{align*} \quad \Ebb_{\pi} [c] = \frac{1}{n} \sum_{k=1}^n c(k) = \frac{1}{n} \sum_{k=1}^n \sum_{i=1}^k L[\pi(i)] F[\pi(k)] \end{align*} $$

假设所有文件长度相同,则应优先存高频文件,不难猜正确的贪心选择为:将剩余文件中长度/频率最小的文件存入磁带

磁带存储 带读取频率

对任意排列$\pi \ne \pi^{\text{greedy}}$,必存在$i$使得$\frac{L[\pi(i)]}{F[\pi(i)]} \ge \frac{L[\pi(i+1)]}{F[\pi(i+1)]}$,交换文件$\pi(i)$$\pi(i+1)$后得到的新排列为$\pi'$,则$\Ebb_{\pi'} [c] \le \Ebb_{\pi} [c]$

证明:交换文件$\pi(i)$、文件$\pi(i+1)$后

  • 文件$\pi(i)$往后挪了一位,读取开销增加$F[\pi(i)] L[\pi(i+1)]$
  • 文件$\pi(i+1)$往前挪了一位,读取开销减少$F[\pi(i+1)] L[\pi(i)]$

平均读取开销变化非正:

$$ \begin{align*} \quad \Ebb_{\pi'} [c] - \Ebb_{\pi} [c] & = F[\pi(i)] L[\pi(i+1)] - F[\pi(i+1)] L[\pi(i)] \\ & = F[\pi(i+1)] F[\pi(i)] \left( \frac{L[\pi(i+1)]}{F[\pi(i+1)]} - \frac{L[\pi(i)]}{F[\pi(i)]} \right) \le 0 \end{align*} $$

贪心法

贪心法:将问题转化为连续决策,每次决策做贪心选择,只产生一个规模更小的子问题

贪心法的优缺点

  • 优点:易设计贪心选择 (不一定正确),易分析时间复杂度
  • 缺点:贪心选择的正确性证明较难,只对部分最优化问题有效

经典算法

  • 最小生成树的 Prim 算法、Kruskal 算法
  • 单源最短路径的 Dijkstra 算法
活动安排 延迟之和

现有$n$个活动,编号分别为$1, \ldots, n$,时长分别为$L[1], \ldots, L[n]$,最后期限为$D[1], \ldots, D[n]$

在安排方案$\pi$中,活动$k$的结束时间为$c_{\pi} (k) = \sum_{i=1}^k L[\pi(i)]$,记延迟$\lambda_{\pi} (k) = \max \{ 0, c_{\pi} (k) - D[k] \}$

问题:求最优排列$\pi^\star$使延迟之和$\sum_{k=1}^n \lambda_{\pi} (k)$最小

贪心选择:

  • 按照时长$L[k]$的升序,优先安排不太耗时的活动
  • 按照最后期限$D[k]$的升序,优先安排时间紧的活动
  • 按照$L[k] + D[k]$的升序安排活动
  • 按照$L[k] \cdot D[k]$的升序安排活动
活动安排 反例

举反例可以排除错误选项,假设有两个活动:

  • 活动$1$$L[1] = 10$$D[1] = 25$
  • 活动$2$$L[2] = 20$$D[2] = 20$
  • 先活动$1$、后活动$2$$\lambda (1) = 0$$\lambda (2) = 10$
  • 先活动$2$、后活动$1$$\lambda (1) = 5$$\lambda (2) = 0$

$L[k]$升序、按$L[k] + D[k]$升序、按$L[k] \cdot D[k]$升序都是先活动$1$、后活动$2$,因此都不是正确的贪心选择

活动安排 反例

举反例可以排除错误选项,假设有两个活动:

  • 活动$1$$L[1] = 20$$D[1] = 10$
  • 活动$2$$L[2] = 10$$D[2] = 15$
  • 先活动$1$、后活动$2$$\lambda (1) = 10$$\lambda (2) = 15$
  • 先活动$2$、后活动$1$$\lambda (1) = 20$$\lambda (2) = 0$

$D[k]$升序是先活动$1$、后活动$2$,不是正确的贪心选择

活动安排 最大延迟

问题:延迟之和$\sum_{k=1}^n \lambda_{\pi} (k)$最小 => 最大延迟$\max_k \lambda_{\pi} (k)$最小

假设有两个活动:

  • 活动$1$$L[1] = 10$$D[1] = 50$
  • 活动$2$$L[2] = 100$$D[2] = 20$
  • 先活动$1$、后活动$2$$\lambda (1) = 0$$\lambda (2) = 90$
  • 先活动$2$、后活动$1$$\lambda (1) = 80$$\lambda (2) = 60$

除按$D[k]$升序外,其它都是先活动$1$、后活动$2$,都不是正确的贪心选择

$D[k]$升序是正确的贪心选择

正确性证明

对任意活动排列$\pi$,若存在$i$使得$D[\pi(i)] \ge D[\pi(i+1)]$,交换活动$\pi(i)$$\pi(i+1)$后得到新排列$\pi'$

其余$n-2$个活动的延迟情况不变,记$M = \sum_{j=1}^{i-1} L[\pi(j)]$

  • $\lambda_\pi (i) = \max \{0, M + L[\pi(i)] - D[\pi(i)]\}$
  • $\lambda_\pi (i+1) = \max \{0, M + L[\pi(i)] + L[\pi(i+1)] - D[\pi(i+1)]\}$
  • $\lambda_{\pi'} (i) = \max \{0, M + L[\pi(i+1)] - D[\pi(i+1)]\}$
  • $\lambda_{\pi'} (i+1) = \max \{0, M + L[\pi(i)] + L[\pi(i+1)] - D[\pi(i)]\}$

显然$\lambda_{\pi'} (i) \le \lambda_\pi (i+1)$$\lambda_{\pi'} (i+1) \le \lambda_\pi (i+1)$,故

$$ \begin{align*} \quad \max \{ \lambda_{\pi'} (i), \lambda_{\pi'} (i+1) \} \le \lambda_\pi (i+1) \le \max \{ \lambda_\pi (i), \lambda_\pi (i+1) \} \end{align*} $$

活动选择

现有$n$个活动$\Scal = \{ a_1, a_2, \ldots, a_n \}$,活动$a_i$的时间段为$[s_i, f_i)$

这些活动使用同一资源且不能共用,如会场等

如果两个活动的时间段不重叠,则称它们是兼容

输入:$\Scal = \{ a_1 = [s_1, f_1), a_2 = [s_2, f_2), \ldots, a_n = [s_n, f_n) \}$

输出:从$\Scal$中选出最大兼容活动集合

假设活动已按结束时间单调递增排序$f_1 \le f_2 \le \cdots \le f_n$

活动选择 例子

$n = 11$个活动

$i$ 1 2 3 4 5 6 7 8 9 10 11
$s_i$ 1 3 0 5 3 5 6 8 8 2 12
$f_i$ 4 5 6 7 9 9 10 11 12 14 16

$\{ a_3, a_9, a_{11} \}$、$\{ a_1, a_4, a_8, a_{11} \}$、$\{ a_2, a_4, a_9, a_{11} \}$是兼容活动集合

后两者都是最大兼容活动集合,最大兼容活动集合不唯一

暴力穷举:$\Omega(2^n)$

活动选择 最优子结构

$\Scal_{ij}$表示$a_i$结束后开始、$a_j$开始前结束的活动集合

$$ \begin{align*} \quad \Scal_{ij} & = \{ a_k = [s_k, f_k) \mid f_i \le s_k < f_k \le s_j \} \\[10px] a_1, & ~ \ldots, ~ a_i, ~ \underbrace{\overbrace{a_{i+1}, ~ \ldots, ~ a_{k-1}}^{\Scal_{ik}}, ~ a_k, ~ \overbrace{a_{k+1}, ~ \ldots, ~ a_{j-1}}^{\Scal_{kj}}}_{\Scal_{ij}}, ~ a_j, ~ \ldots, ~ a_n \end{align*} $$

最优子结构性:设$\Acal_{ij}$是$\Scal_{ij}$的最大兼容活动集合并包含$a_k$

  • 设$\Acal_{ik}$为$\Acal_{ij}$中$a_k$开始前的活动集合,则也是$\Scal_{ik}$的最大兼容活动集合
  • 设$\Acal_{kj}$为$\Acal_{ij}$中$a_k$结束后的活动集合,则也是$\Scal_{kj}$的最大兼容活动集合
  • $\Acal_{ij} = \Acal_{ik} \cup \{a_k\} \cup \Acal_{kj}$

若$\Acal_{ik}$不是最大,存在$\Acal'_{ik}$更大,则$\Acal'_{ik} \cup \{a_k\} \cup \Acal_{kj}$更大,矛盾

活动选择 动态规划

$\Acal_{ij}$包含$a_k$$\Acal_{ij} = \Acal_{ik} \cup \{a_k\} \cup \Acal_{kj}$

$c[i,j] = |\Acal_{ij}|$表示$\Scal_{ij}$的最大兼容活动集合的大小

$$ \begin{align*} \quad c[i,j] & = c[i,k] + c[k,j] + 1 \\[6px] \Longrightarrow & ~ c[i,j] = \begin{cases} 0, & \Scal_{ij} = \emptyset \\ \max_{a_k \in \Scal_{ij}} \{ c[i,k] + c[k,j] + 1 \}, & \Scal_{ij} \ne \emptyset \end{cases} \end{align*} $$

动态规划:时间复杂度$\Theta(n^3)$,空间复杂度$\Theta(n^2)$

活动选择 贪心法

初始化最大兼容活动集合$\Acal = \emptyset$,不断将与$\Acal$兼容的活动加入$\Acal$

贪心选择:选择与$\Acal$兼容的活动中结束时间最早的活动,使剩余可安排时间最大化,给未来留下足够的余地

  • 首次选择$a_1$
  • 其后选择结束时间最早 (贪心) 且开始时间不早于前一个所选活动结束时间 (兼容) 的活动

时间复杂度

  • 若活动已排好序,只需$\Theta(n)$的时间遍历一遍活动集合
  • 若活动未排好序,可先用$\Theta(n \lg n)$的时间重排序
活动选择 例子

gantt todayMarker off dateFormat HH axisFormat %H section 选第2个活动 a1: active, 01, 04 a2: done, 03, 05 a3: done, 00, 06 a4: done, 05, 07 section 选第3个活动 a1: active, 01, 04 a4: active, 05, 07 a5: done, 03, 09 a6: done, 05, 09 a7: done, 06, 10 a8: done, 08, 11 section 选第4个活动 a1: active, 01, 04 a4: active, 05, 07 a8: active, 08, 11 a9: done, 08, 12 a10: done, 02, 14 a11: done, 12, 16 section 兼容活动 a1: active, 01, 04 a4: active, 05, 07 a8: active, 08, 11 a11: active, 12, 16
活动选择 实现

自顶向下递归实现,每次选择将问题转化成一个规模更小的问题

def activity_selector_rec(k, a):
    m = k + 1
    while m < n and s[m] < f[k]:  # 贪心选择寻找ak之后最早结束的活动
        m = m + 1
    if m < n:        # 若找到 将其加入集合 递归寻找下一个兼容活动
        a.append(m)  # 将am加入集合
        activity_selector_rec(m, a)

尾递归 => 迭代,一重 for 循环时间复杂度$\Theta(n)$

def activity_selector_greedy():
    a = [1]                # a1直接选择
    k = 1
    for m in range(1, n):  # 从前向后遍历a1之后的活动
        if s[m] > f[k]:    # 若找到ak之后最早结束的活动am
            a.append(m)    # 将am加入集合
            k = m          # 从am之后的活动中继续寻找
    return a
活动选择 贪心正确性

设贪心法得到的兼容活动集合为

$$ \begin{align*} \quad \Scal^{\text{greedy}} = \{ g_1, ~ g_2, ~ \ldots, g_{i-1}, ~ g_i, ~ g_{i+1}, ~ \ldots, ~ g_k \} \end{align*} $$

任意最大兼容活动集合$\Scal \ne \Scal^{\text{greedy}}$可表示为

$$ \begin{align*} \quad \Scal = \{ g_1, ~ g_2, ~ \ldots, g_{i-1}, ~ c_i, ~ c_{i+1}, ~ \ldots, ~ c_l \} \end{align*} $$

$c_i$替换为$g_i$得到新的最大兼容活动集合 (替换的合法性?)

$$ \begin{align*} \quad \Scal' = \{ g_1, ~ g_2, ~ \ldots, g_{i-1}, ~ g_i, ~ c_{i+1}, ~ \ldots, ~ c_l \} \end{align*} $$

对任意最大兼容活动集合,都可以通过替换活动使其变成$\Scal^{\text{greedy}}$,因此$\Scal^{\text{greedy}}$就是最大兼容活动集合

贪心法的一般步骤

  1. 确定问题的最优子结构
  2. 将问题转化为连续决策,每次贪心选择后产生一个小规模的子问题
  3. 证明:若最优解不等于贪心解,对最优解进行剪切-粘贴,将其一部分替换为贪心选择,这样构造出的解也是最优解

最优子结构可贪心选择是贪心法的两个关键

文件编码

压缩一个只包含$a$$b$$c$$d$$e$$f$$100$个字符的数据文件

字符 $a$ $b$ $c$ $d$ $e$ $f$
频率 $45$ $13$ $12$ $16$ $9$ $5$
定长编码 $000$ $001$ $010$ $011$ $100$ $101$
变长编码 $0$ $101$ $100$ $111$ $1101$ $1100$
  • 定长编码:$3 \cdot 100 = 300$个二进制位
  • 变长编码:$1 \cdot 45 + 3 \cdot 13 + 3 \cdot 12 + 3 \cdot 16 + 4 \cdot 9 + 4 \cdot 5 = 224$个二进制位,节省$25.3\%$空间

问题:最优编码方案?

编码树

任何二进制编码都可以表示成一个二叉树,根节点到字符结点路径上遇到的$01$串就是其编码,以表示$a$$b$$c$$d$四字符为例

  • 左:$a:0$$b:00$$c:1$$d:11$$a$的编码是$b$的前缀,解码时有歧义
  • 中:$a:00$$b:01$$c:10$$d:11$,等长编码,解码时无歧义
  • 右:$a:0$$b:10$$c:110$$d:111$,变长编码,解码时无歧义

解码:以右图方案为例,010110 -> abc

最优非前缀编码树

输入:字符表$\Ccal$,字符个数$n = |\Ccal| \ge 2$,每个字符$c$的频率$c.f$

输出:最优非前缀编码树

两点说明:

  • 每个字符对应一个叶结点,否则不是非前缀码
  • 最优编码树必然是满二叉树,每个内部结点有两个子结点
霍夫曼算法

基本想法:维护一个森林,初始为每个字符对应的单结点树,每次合并两棵树,直到所有树合并成一棵树

字符$c$的编码长度是其在编码树$\Tcal$中的深度$d_{\Tcal}(c)$,所有字符的总编码长度$B(\Tcal) = \sum_{c \in \Ccal} c.f \cdot d_{\Tcal}(c)$

每次合并会导致树中结点深度$+1$,总编码长度$B(\Tcal)$的增量为两棵树中字符对应的叶结点的频率和

霍夫曼算法的贪心选择:合并叶结点频率和最低的两棵树

霍夫曼算法来自他在 MIT 攻读博士学位时修读的信息论课程的学期报告:最有效的二进制编码,老师法诺曾提出自顶向下构建树的香农-范诺编码,霍夫曼使用自底向上构建树得到了更优的编码。

霍夫曼算法 具体实现

霍夫曼算法的贪心选择:合并叶结点频率和最低的两棵树

  • 在字符对应的叶结点中保存字符的频率
  • 每次合并产生一个新的根节点,根节点中保存两个子结点的频率和,于是每个结点中保存的就是以其为根结点的子树中叶结点的频率和
  • 每次选择两个频率最低的结点进行合并
霍夫曼算法 具体实现

初级实现:利用列表保存结点,总时间复杂度$\Theta(n^2)$

  • 初始化列表$\Theta(n)$
  • 每次合并两个结点,共合并$\Theta(n)$
  • 弹出频率最低的两个结点,线性查找$\Theta(n)$、删除$\Theta(1)$
  • 插入合并的结点$\Theta(1)$

高级实现:利用二叉堆保存结点,总时间复杂度$\Theta(n \lg n)$

  • 初始化二叉堆,逐元素加入则为$\Theta(n \lg n)$、从列表原地转换则为$\Theta(n)$
  • 每次合并两个结点,共合并$\Theta(n)$次
  • 每次合并需要查找频率最低的两个结点$\Theta(1)$
  • 弹出频率最低的两个结点,查找$\Theta(1)$、删除$\Theta(\lg n)$
  • 插入合并的结点$\Theta(\lg n)$
霍夫曼算法 具体实现

import heapq  # python自带的二叉堆库


class HuffmanNode:  # 编码树结点类
    def __init__(self, symbol, frequency):
        self.symbol = symbol
        self.frequency = frequency
        self.left = None
        self.right = None

    def __lt__(self, other):  # 比较两个HuffmanNode实例时调用
        return self.frequency < other.frequency


def get_min_freq(l):  # 删除并返回结点列表中频率最小的结点
    node, index = l[0], 0
    for i in range(1, len(l)):
        if l[i] < node:
            node, index = l[i], i
    return l.pop(index)


def merge(left_node, right_node):  # 合并两个结点
    merged_node = HuffmanNode(None, left_node.frequency + right_node.frequency)  # 合并两个结点
    merged_node.left, merged_node.right = left_node, right_node
    return merged_node


def build_huffman_tree_elementary(C):  # 用列表保存结点
    l = [HuffmanNode(symbol, frequency) for symbol, frequency in C.items()]
    while len(l) > 1:  # 最终l中只剩编码树的根结点
        left_node = get_min_freq(l)  # 寻找频率最小的结点
        right_node = get_min_freq(l)  # 寻找频率次小的结点
        l.append(merge(left_node, right_node))  # 插入合并的结点
    return l[0]


def build_huffman_tree_advanced(C):  # 用二叉堆保存结点
    l = [HuffmanNode(symbol, frequency) for symbol, frequency in C.items()]
    heapq.heapify(l)  # 将列表l转换成二叉堆 原地 线性时间
    while len(l) > 1:   # 最终l中只剩编码树的根结点
        left_node = heapq.heappop(l)  # 寻找频率最小的结点
        right_node = heapq.heappop(l)  # 寻找频率次小的结点
        heapq.heappush(l, merge(left_node, right_node))  # 插入合并的结点
    return l[0]


def huffman_codes(node, current_code="", code_map=None):  # 递归保存字符的编码
    if code_map is None:
        code_map = {}
    if node is not None:
        if node.symbol is not None:  # 字符对应的结点
            code_map[node.symbol] = current_code
        huffman_codes(node.left, current_code + "0", code_map)
        huffman_codes(node.right, current_code + "1", code_map)
    return code_map


C = {"a": 45, "b": 13, "c": 12, "d": 16, "e": 9, "f": 5}
for f in [build_huffman_tree_elementary, build_huffman_tree_advanced]:
    huffman_tree_root = f(C)
    huffman_code_map = huffman_codes(huffman_tree_root)
    for symbol, code in huffman_code_map.items():
        print(f"{symbol}: {code}")
# ----------------------------------------------------
# a: 0
# c: 100
# b: 101
# f: 1100
# e: 1101
# d: 111
# a: 0
# c: 100
# b: 101
# f: 1100
# e: 1101
# d: 111

算法正确性

磁带存储问题和最大兼容活动问题的解的形式都是序列

算法正确性的证明思路:

  • 假设贪心法给出解序列$s^{\text{greedy}}$,最优解序列$s^\star \ne s^{\text{greedy}}$
  • 证明通过一些不丢失最优性的操作可将$s^\star$变成$s^{\text{greedy}}$,从而$s^{\text{greedy}}$也是最优解

以此类推:文件编码问题的解的形式是编码树 (满二叉树)

  • 假设贪心法给出编码树$\Tcal^{\text{greedy}}$,最优编码树$\Tcal^\star \ne \Tcal^{\text{greedy}}$
  • 如何通过不丢失最优性的操作将$\Tcal^\star$变成$\Tcal^{\text{greedy}}$
算法正确性

$x$$y$$\Ccal$中频率最低的两个字符,霍夫曼算法算法首轮将$x$$y$合并成字符$z$,且其频率$z.f = x.f + y.f$,此时字符表变成$\Ccal' = \Ccal \setminus \{ x,y \} \cup \{ z \}$,只剩$n-1$个字符,数学归纳法?

对字符表的字符个数$n$做归纳

当$n = 2$时,只有$2$个字符,显然最优编码为一个字符编码为$0$,另一个编码为$1$,霍夫曼算法的结果亦是如此

归纳假设:假设霍夫曼算法对$n-1$个字符的字符表能输出最优编码树,特别的对$\Ccal'$输出最优编码树$\Tcal'$,将$\Tcal'$中$z$对应的叶结点替换为以$x$、$y$为叶子结点的内部结点,得到编码树$\Tcal$

$\Tcal$是关于$\Ccal$的编码树,其最优性如何?若为最优,如何证明?

算法正确性

$\Tcal'$$z$对应的叶结点替换为以$x$$y$为叶子结点的内部结点得到$\Tcal$,总编码长度的变化是个定值

$$ \begin{align*} \quad B(\Tcal) & - B(\Tcal') = x.f \cdot d_{\Tcal}(x) + y.f \cdot d_{\Tcal}(y) - z.f \cdot d_{\Tcal'}(z) \\ & = x.f \cdot (d_{\Tcal'}(z) + 1) + y.f \cdot (d_{\Tcal'}(z) + 1) - (x.f+y.f) \cdot d_{\Tcal'}(z) \\ & = x.f+y.f \end{align*} $$

不仅仅是$\Tcal'$,对任一关于$\Ccal'$的编码树都是如此

$\Tcal'$关于$\Ccal'$最优 => $\Tcal$在关于$\Ccal$且以$x$、$y$为一对兄弟结点的编码树中最优

霍夫曼算法给出的编码树$\Tcal^{\text{greedy}}$中,$x$、$y$也是一对兄弟结点且深度最深

算法正确性

假设$\Tcal$$x$$y$不是最深,最深的是$a$$b$,交换$a$$x$

$$ \begin{align*} \quad \Delta B & = a.f \cdot (d_{\Tcal}(x) - d_{\Tcal}(a)) + x.f \cdot (d_{\Tcal}(a) - d_{\Tcal}(x)) \\ & = \underbrace{(a.f - x.f)}_{\ge ~ 0} \underbrace{(d_{\Tcal}(x) - d_{\Tcal}(a))}_{\le ~ 0} \le 0 \end{align*} $$

同理,交换$b$和$y$,总编码长度的变化非正

$\Tcal^{\text{greedy}}$在关于$\Ccal$且以$x$、$y$为一对兄弟结点的编码树中最优

算法正确性

最后还需证明:$\Ccal$的最优编码树中,存在以$x$$y$为一对最深兄弟结点的

假设最优编码树$\Tcal^\star$中,$x$$y$不是一对最深的兄弟结点,将其与最深的一对兄弟结点$a$$b$交换,总编码长度的变化非正,因此存在以$x$$y$为一对最深兄弟结点的最优编码树

最小生成树

输入:带权无向连通图$\Gcal = (\Vcal, \Ecal)$,其中$\Vcal$为边集、$\Ecal$为点集,边上的权重由函数$w: \Ecal \mapsto \Rbb$给出

输出:最小生成树 (minimum spanning tree, MST)

去掉边$(b,c)$,加上边$(a,h)$,也是 MST,MST 不唯一!

最小生成树 唯一性

唯一性与权重相有关,若所有边权重相同,则任意 ST 都是 MST

定理:若$\Gcal$的边权重各不相同,则其 MST 唯一

证明逆否命题:若$\Tcal$$\Tcal'$是不同的 MST,则$\Gcal$存在权重相同的边

$\Tcal$$\Tcal'$不同 => $\Tcal$中存在不属于$\Tcal'$的边,设权重最小的为$e$$\Tcal'$中存在不属于$\Tcal$的边,设权重最小的为$e'$

$\Tcal'$是树 => $\Tcal' \cup \{ e \}$包含环$\Ccal$

$\Tcal$是树 => $\Ccal$中包含边$e'' \not \in \Tcal$ => $w(e'') \ge w(e') \ge w(e)$

生成树$\Tcal'' = \Tcal' \cup \{ e \} \setminus \{ e'' \}$也是最小生成树 => $w(e) = w(e'')$

最小生成树 贪心

连续决策:

  • 初始化边集$\Acal = \emptyset$
  • 每轮选择一条边$(u,v)$加入$\Acal$,保证$\Acal$始终是某棵 MST 的子集

问题:如何选择边$(u,v)$?

切割 轻量边

切割:无向图$\Gcal = (\Vcal, \Ecal)$的切割$(\Scal, \Vcal \setminus \Scal)$$\Vcal$的一个划分

横跨切割边:边的两个端点分属于$\Scal$$\Vcal \setminus \Scal$,例如$(a,h)$

尊重:如果集合$\Acal$中没有横跨切割的边,称该切割尊重$\Acal$

轻量边:横跨切割边中权重最小的边,例如$(c,d)$

如何选择边

贪心:每轮对任意尊重当前$\Acal$的切割,选择轻量边$(u,v)$加入

正确性证明:设$\Tcal$是任一包含$\Acal$的 MST,但$(u,v) \not \in \Tcal$

$\Tcal$中存在$u$$v$的路径$p = \class{yellow}{u \rightsquigarrow w \rightsquigarrow x} \rightsquigarrow \class{blue}{b \rightsquigarrow a \rightsquigarrow v}$

$p$中必有一边横跨切割,不妨设为$(x,b)$

$\Tcal' = \Tcal \setminus \{ (x,b) \} \cup \{ (u,v) \}$也是 MST

$\Acal \cup \{ (u,v) \} \subseteq \Tcal'$

Prim 算法

$\Acal$始终是一棵树

  • 初始时$\Acal$是某个单结点树
  • 每次加入连接$\Acal$$\Acal$之外结点的权重最小的边
  • $\Acal$作为切割的一方,$\Acal$之外的结点作为切割另一方
Prim 算法

$\Acal$始终是一棵树,每次加入连接$\Acal$$\Acal$之外结点的权重最小边

Prim 算法

基于二叉堆的实现,时间复杂度$O((|\Vcal| + |\Ecal|) \lg |\Vcal|)$

def prim(graph, start):
    min_spanning_tree = []
    in_mst = {start}  # 已在mst中的结点

    # 初始点出发的边组成二叉堆
    h = [(weight, start, neighbor) for neighbor, weight in graph[start].items()]
    heapq.heapify(h)

    while h:
        weight, node1, node2 = heapq.heappop(h)  # 弹出堆中权重最小的边
        if node2 not in in_mst:  # 若是横跨切割的轻量边
            in_mst.add(node2)
            min_spanning_tree.append((weight, f"{node1} - {node2}"))
            for node2_neighbor, weight in graph[node2].items(): 
                if node2_neighbor not in in_mst:  # node2出发的横跨切割的边入堆
                    heapq.heappush(h, (weight, node2, node2_neighbor))

    return min_spanning_tree


graph = {
    'a': {'b': 4, 'h': 8},
    'b': {'a': 4, 'c': 8, 'h': 11},
    'c': {'b': 8, 'd': 7, 'i': 2, 'f': 4},
    'd': {'c': 7, 'e': 9, 'f': 14},
    'e': {'d': 9, 'f': 10},
    'f': {'c': 4, 'd': 14, 'e': 10, 'g': 2},
    'g': {'f': 2, 'h': 1, 'i': 6},
    'h': {'a': 8, 'b': 11, 'g': 1, 'i': 7},
    'i': {'c': 2, 'g': 6, 'h': 7}
}

mst_weight = 0
for edge in prim(graph, 'a'):
    mst_weight += edge[0]
    print(edge)
print(f"最小生成树权重和为{mst_weight}")
# -------------------------------------------
# (4, 'a - b')
# (8, 'a - h')
# (1, 'h - g')
# (2, 'g - f')
# (4, 'f - c')
# (2, 'c - i')
# (7, 'c - d')
# (9, 'd - e')
# 最小生成树权重和为37

Kruskal 算法

$\Acal$始终是一个森林

  • 初始时$\Acal$是所有单结点树构成的森林
  • 每次加入连接$\Acal$中不同两棵树的权重最小的边
  • 两棵树作为切割的两方,其它树随意属于某一方
Kruskal 算法

$\Acal$始终是一个森林,每次加入连接$\Acal$中两棵树的权重最小边

Kruskal 算法

基于父图的实现,时间复杂度$O((|\Vcal| + |\Ecal|) \lg |\Vcal|)$

def find(parent, node):  # 返回给定结点所在树的根结点
    if parent[node] != node:
        parent[node] = find(parent, parent[node])
    return parent[node]


def union(parent, size, node1, node2):
    root1 = find(parent, node1)
    root2 = find(parent, node2)

    if root1 != root2:  # 结点少的树作为结点多的树的子树
        if size[root1] >= size[root2]:
            parent[root2] = root1
            size[root1] += size[root2]
        else:
            parent[root1] = root2
            size[root2] += size[root1]


def kruskal(graph):
    min_spanning_tree = []

    # 保存图中所有边并排序
    edges = []
    for node, neighbors in graph.items():
        for neighbor, weight in neighbors.items():
            edges.append((weight, node, neighbor))
    edges.sort()

    # 父图初始化
    parent = {node: node for node in graph}
    size = {node: 1 for node in graph}

    # 遍历所有边 若为横跨切割的轻量边 合并两个端点所在的树
    for edge in edges:
        weight, node1, node2 = edge
        if find(parent, node1) != find(parent, node2):
            union(parent, size, node1, node2)
            min_spanning_tree.append((weight, f"{node1} - {node2}"))

    return min_spanning_tree


graph = {
    'a': {'b': 4, 'h': 8},
    'b': {'a': 4, 'c': 8, 'h': 11},
    'c': {'b': 8, 'd': 7, 'i': 2, 'f': 4},
    'd': {'c': 7, 'e': 9, 'f': 14},
    'e': {'d': 9, 'f': 10},
    'f': {'c': 4, 'd': 14, 'e': 10, 'g': 2},
    'g': {'f': 2, 'h': 1, 'i': 6},
    'h': {'a': 8, 'b': 11, 'g': 1, 'i': 7},
    'i': {'c': 2, 'g': 6, 'h': 7}
}

mst_weight = 0
for edge in kruskal(graph):
    mst_weight += edge[0]
    print(edge)
print(f"最小生成树权重和为{mst_weight}")
# -------------------------------------------
# (1, 'g - h')
# (2, 'c - i')
# (2, 'f - g')
# (4, 'a - b')
# (4, 'c - f')
# (7, 'c - d')
# (8, 'a - h')
# (9, 'd - e')
# 最小生成树权重和为37

作业

算法导论 3rd

16.1-4、16.2-7、16.3-3、16-1

求以下背包问题的最优解:
$n=7$$M=15$$\pv = [10,5,15,7,6,18,3]$$\wv = [2,3,5,7,1,4,1]$