五、贪心算法

1.基本原理

(1)基本概念

  • 分步骤实施,它在每一步仅作出当时看起来最佳的选择,即局部最优的选择,并寄希望这样的选择最终能导致全局最优解
  • 实例:最小生成树问题的$ Prim $算法、$ Kruskal $算法,单源最短路径$ Dijkstra $算法,分数背包
  • 贪心算法不总能对所有问题能求解,只是对一些问题确实有效,可以求出最优解或近似最优解。

(2)贪心算法的步骤

  • 提出贪心策略:观察问题特征,构造贪心选择;

  • 证明策略正确:假设最优方案,通过替换证明。

    对应每个贪心算法,都有一个动态规划算法,但动态规划算法要繁琐的多。

(3)贪心选择性质

可以通过做出局部最优(贪心)选择来构造全局最优解的性质。

贪心选择性使得我们进行选择时, 只需做出当前看起来最优的选择,而不用考虑子问题的解。

2.活动选择问题

(1)问题描述

  • 假定有一个活动的集合$ S $​含有$ n $​个活动$ {a_1,a_2,\dots,a_n} $​,每个活动$ a_i $​都有一个开始时间$ s_i $​和结束时间$ f_i $​且$ 0\leq s_i\lt f_i\lt\infty $。同时,这些活动都要使用同一资源(如演讲会场),而这个资源在任何时刻只能供一个活动使用。

  • 活动的兼容性:如果选择了活动$ a_i $,则它在半开时间区间 $ [s_i, f_i) $内占用资源。若两个活动$ a_i $和$ a_j $满足$ [s_i, f_i) $与区间$ [s_j, f_j) $不重叠,则称它们是兼容的。

  • 活动选择问题:假设活动按照结束时间单调递增排序,对给定的包含$ n $个活动的集合$ S $,在已知每个活动开始时间和结束时间的条件下,从中选出最多可兼容活动的子集合,称为最大兼容活动集合

    考虑下列活动集合$ S $:

(2)问题分析

  • 设$ S_{ij} $表示在$ a_i $结束之后开始且在$ a_j $开始之前结束的活动集合,$ A_{ij} $表示$ S_{ij} $的一个最大兼容活动子集,设$ A_{ij} $包括活动$ a_k $,则得到两个子问题——寻找$ S_{ik} $和$ S_{kj} $的最大兼容活动集合。

    图解如下:

  • 必有:$ A_{ik} $是$ S_{ik} $一个最大兼容活动子集,$ A_{kj} $是$ S_{kj} $一个最大兼容活动子集。

    $ A_{ij}=A_{ik}∪{a_k}∪A_{kj} $
  • 令$ c[i,j] $表示集合$ S_{ij} $的最优解大小,可使用动态规划方法解决

(3)贪心算法

  • 每次总选择具有最早结束时间的兼容活动加入到集合$ A $中——使剩余的可安排时间段最大化,以便安排尽可能多的兼容活动。

  • 当输入的活动已按结束时间的递增顺序排列,贪心算法只需$ O(n) $的时间即可选择出来$ n $个活动的最大兼容活动集合。

    考虑任意非空子问题$ S_k $,令$ a_m $是$ S_k $中结束时间最早的活动,则$ a_m $必在$ S_k $的某个最大兼容活动子集中。

  • 自顶向下的递归方法

    首先做出一个选择,然后求解剩下的子问题。每次选择将问题转化成一个规模更小的问题。

    伪代码如下:

    第$ 2\thicksim 3 $行查找$ S_k $中最早结束的活动,循环检查$ a_{k+1},a_{k+2},\dots,a_n $,直至找到第一个与$ a_k $兼容的活动$ a_m $,也即满足$ s_m\geq f_k $。

    如果成功找到$ m $(也即$ m\leq n $),则返回$ {a_m} $与$ RECURSIVE-ACTIVITY-SELECTOR(s,f,m,n) $返回的$ S_m $的最大子集的并集。

    如果未成功找到$ m $,则说明未找到与$ a_k $兼容的活动,则返回$ \Phi $。

  • 迭代实现的贪心算法

    伪代码如下:

    $ k $对应最后一个加入$ A $的活动,$ f_k $是$ A $中活动的最大结束时间,若$ m $的开始时间大于$ f_k $,则$ m $就是下一个被选中的活动。

    算法的运行时间是$ O(n) $。

3.带权活动选择问题

(1)问题描述

  • 在活动选择问题中,如果每个活动都具有权重$ w $,现寻找活动子集$ S' $,使得权重和最大

(2)问题分析

  • 存在重叠子问题,可以使用动态规划求解
  • 设$ p[i] $表示在$ a_i $开始前最后结束的活动编号
  • 设$ Rec[i] $表示是否选择问题$ i $
  • 设$ D[i] $表示集合$ \{a_1,a_2,a_3,\dots,a_i\} $中兼容活动最大权重和
  • 将活动按照结束时间升序进行排序,则可得到$ D[i]=max\{D[p[i]]+w_i,D[i-1]\} $;其中不选择$ a_i $时,其最大权重和即为$ D[i-1] $,选择$ a[i] $时,其最大权重和应为在$ a_i $开始前最后结束的活动编号对应的最大权重和加上$ w_i $,即$ D[p[i]]+w_i $。

(3)动态规划

  • 伪代码如下

  • 时间复杂度为$ O(n\log n) $

4.$ Huffman $编码

(1)基本概念

  • 码字:每个字符用唯一的二进制串表示,称为码字。

  • 定长编码:每个字符的编码长度一样。

    对于$ a\thicksim f $六个字符,应采用3位码字编码,则10万个字符需用30万个二进制位编码。

  • 变长编码:每个字符赋予不同长度的码字。

    赋予高频字符短码字,低频字符长码字,字符的码字互不为前缀,这样才能唯一解码,同时能够提高编码效率。如$ a $用1位的串0表示,$ b $用3位的串101表示,$ f $用4位的串1100表示等。

  • 前缀码$ (Prefix code) $:任何码字都不是其它码字的前缀。

    前缀码可以简化解码过程,由于没有码字是其它码字的前缀,所以编码文件的开始部分是没有歧义的,可以唯一地转换回原字符。

  • 编码树:一种为表示字符二进制编码而构造的二叉树。

    叶子结点:对应给定的字符,每个字符对应一个叶子结点。

    编码构造:字符的二进制码字由根结点到该字符叶子结点的简单路径

    路径表示:0代表转向左孩子,1代表转向右孩子

  • 最优字符编码方案总对应一棵满二叉树, 即每个非叶子结点都有两个孩子结点。

(2)最优字符编码方案

  • 符号表示

    • 设$ C $为字母表

      对字母表$ C $中的任意字符$ c $,令属性$ c.freq $表示字符$ c $在文件中出现的频率

      最优前缀码对应的树中恰好有$ \vert C\vert $个叶子结点,每个叶子结点对应字母表中的一个字符,且恰有$ \vert C\vert-1 $个内部结点。

    • 设$ T $表示一棵前缀编码树

    • 设$ d_T(c) $表示c的叶子结点在树T中的深度(根到叶子结点的路径长度)

      $ d_T(c) $也表示字符$ c $的码字长度
    • 设$ B(T) $表示采用$ T $编码时的文件编码长度,即$ B(T)=\sum\limits_{c\in C}c.freq\cdot d_T(c) $,称$ B(T) $为T的代价。

      使得$ B(T) $最小的编码称为最优编码。

      对给定的字符集和文件,$ Huffman $编码是一种最优编码。

(3)$ Huffman $编码

  • 算法$ HUFFMAN $从$ \vert C\vert $个叶子结点开始,每次选择频率最低的两个结点合并,将得到的新结点加入集合继续合并,这样执行$ \vert C\vert-1 $次合并后即可构造出一棵编码树——$ Huffman $树。

    伪代码如下:

    第2行用$ C $中字符初始化最小优先队列$ Q $;

    第$ 3\thicksim 8 $行的循环反复从队列中合并频率最低的结点$ x $和$ y $,合并为新结点$ z $并替代之;

    经过$ n-1 $次合并后,最后返回剩下的唯一结点——编码树的根结点

    示例如下:

  • 时间复杂度的分析

    • 假设$ Q $使用最小二叉堆实现,则其初始化花费$ O(n) $的时间

    • 循环的总代价是$ O(n\lg n) $

      $ for $循环共执行了$ n-1 $次,每次从堆中找出当前频率最小的两个结 点及把合并得到的新结点插入到堆中均花费$ O(\lg n) $,所以循环的总代价是$ O(n\lg n) $。
    • 因此,$ HUFFMAN $的总运行时间$ O(n\lg n) $

  • ※$ Huffman $算法的正确性

    • 证明贪心选择性
    • 证明最优子结构性

5.分数背包问题

(1)问题描述

  • 已知$ n $个物品组成的集合O,每个物品有两个属性$ v_i $和$ p_i $,分别表示体积和价格;
  • 背包容量为$ C $;
  • 试求解$ S=\{x_i|1\leq i\leq n,0\leq x_i\leq 1\} $,使得$ max\sum\limits_{x_i\in S}x_ip_i $且$ \sum\limits_{x_i\in S}x_iv_i\leq C $。

(2)问题分析

  • 采用贪心算法,每次选择最高性价比($ p_i/v_i $)的物品,证明可得贪心解不劣于最优解

  • 伪代码如下

    $ FractionalKnapsack(n,p,v,C) $

    当背包未装满且商品未装完时填入商品,商品体积不大于容量则全部装入,否则装入部分商品填满背包

  • 算法复杂度为$ O(n\log n) $

六、基本图算法

1.基本概念

  • 图的定义

    图可以表示为一个二元组$ G=\langle V,E\rangle $

    相关术语:

    $ V $表示非空顶点集,其元素称为顶点(Vertex) ,$ \vert V\vert $表示顶点数; $ E $表示边集,其元素称为边(Edge),$ \vert E $表示顶点数 ; $ e=(u,v) $表示一条边,其中$ u\in V,v\in V,e\in E $;

    相邻$ (Adjacent) $:边$ (u,v) $连接的顶点$ u $和$ v $相邻;

    关联$ (Incident) $:边$ (u,v) $和其连接的顶点$ u(or\,\,v) $相互关联。

  • 相关数据结构

    • 子图:如果$ V'\subseteq V,E'\subseteq E $,则称图$ G'=\langle V',E'\rangle $为G的一个子图
    • 生成子图:如果$ V'=V,E'\subseteq E $,则称图$ G'=\langle V',E'\rangle $为G的一个生成子图
    • 树:连通、无环图$ T=\langle V_T,E_T\rangle $,树有$ \vert V_T\vert-1 $条边
    • 森林:一至多棵树组成的无环图
  • 图的表示

    • 邻接表
      • 邻接表是一个包含$ \vert V\vert $条链表的数组$ Adj $;
      • 在$ Adj $中,每个结点$ u\in V $有一条链表$ Adj[u] $,包含所有与结点$ u $之间有边相连的结点$ v $;
      • 用$ G.Adj[u] $表示结点$ u $在邻接表$ Adj $中的邻接链表;
      • 稀疏图一般用邻接表表示;
      • 可用于表示有向图也可用于表示无向图,空间需求均为$ O(V+E) $。
    • 邻接矩阵
      • 将图$ G $中的结点编号为$ 1,2,\dots,\vert V\vert $,则图$ G $的邻接矩阵是一个$ \vert V\vert\times\vert V\vert $的矩阵$ A=(a_{ij}) $;
      • 当$ (i,j)\in E $,$ a_{ij}=1 $;否则$ a_{ij}=0 $;
      • 稠密图更倾向于用邻接矩阵表示;
      • 可以快速判断任意两个结点之间是否有边相连,空间需求为$ O(V^2) $。
    • 权重图
      • 权重值通常以权重函数$ \omega:E\to R $给出;
      • 用邻接表表示权重图:
        • 将边$ (u,v)\in E $的权重值$ ω(u,v) $存放在$ u $的邻接链表结点中, 作为其属性。
      • 用邻接矩阵表示权重图:
        • 对于边$ (u,v)\in E $,令邻接矩阵$ A[u][v]=ω(u,v) $;
        • 若$ (u,v) $不是$ E $中的边,则令$ A[u][v]=NIL $,或$ \infty $、0。

2.图的搜索与周游

(1)宽度优先搜索与周游

①宽度优先搜索
  • 算法过程描述

    • 从结点$ v $开始,首先访问结点$ v $,给$ v $标上已访问标记;
    • 访问邻接于$ v $且目前尚未被访问的所有结点,此时结点$ v $被检测,而$ v $的邻接结点是新的未被检测结点。将这些结点依次放置到一个称为未检测结点表的队列(Q)中;
    • 若未检测结点表为空,则算法终止;
    • 否则取Q的表头作为下一个检测结点,重复上述过程。直到$ Q $为空,算法终止。
  • 算法伪代码

  • 复杂度分析

    • 空间复杂度:$ s(V,E)=\Theta(n) $
    • 采用邻接表的时间复杂度:$ t(V,E)=O(n+e) $
    • 采用邻接矩阵的时间复杂度:$ t(V,E)=O(n^2) $
②宽度优先周游
  • 若$ G $是无向连通图或强连通有向图,则一次调用$ BFS $即可完成对$ G $的周游。否则,需要多次调用$ BFS $

  • 算法伪代码

③宽度优先生成树
  • 向前边:$ BFS $中由$ u $到达未访问结点$ w $的边$ (u,w) $称为向前边。

  • 宽度优先生成树: 记$ T $是$ BFS $中处理的所有向前边集合。若$ G $是连通图,则$ BFS $终止时,$ T $构成一棵生成树,称为图$ G $的宽度优先生成树。

  • 对于图$ G=(V,E) $和源结点$ s $,定义图$ G $的前驱子图为$ G_\pi=(V_\pi,E_\pi) $,其中$ V_\pi=\{v\in V:v.\pi\ne NIL\}\cup\{s\} $,$ E_\pi=\{(v.\pi,v):v\in V_\pi-\{s\}\} $。该前驱子图构成一棵广度优先树。

(2)深度优先搜索与周游

①深度优先搜索
  • 算法过程描述

    • 从结点$ v $开始,首先访问$ v $, 给$ v $标上已访问标记;
    • 然后中止对$ v $的检测,并从邻接于$ v $且尚未被访问的结点的中找出一个结点$ w $开始新的检测;
    • 在$ w $被检测后,再恢复对$ v $的检测。当所有可到达的结点全部被检测完毕后,算法终止
  • 算法伪代码

  • 复杂度分析

    • 运行时间为$ \Theta(V+E) $
②深度优先周游
  • 算法伪代码

(3)深度检索

  • 改造$ BFS $算法,用来保存未被检测的结点

3.回溯法

(1)$ n $-皇后问题

(2)子集和问题

4.分支-限界法

(1)$ n $-皇后问题

(2)子集和问题

七、最小生成树

1.问题背景

  • 生成树(Spanning Tree)
    • 图$ T’=\langle V’,E’\rangle $是无向图$ G\langle V,E,W\rangle $的一个生成子图,并且是连通、无环路的(树)
    • 权重最小的生成树可能不唯一

2.通用框架

  • 新建一个空边集$ A $,边集$ A $可逐步扩展为最小生成树

  • 每次向边集$ A $中新增加一条边,需保证边集$ A $仍是一个无环图,且仍是最小生成树的子集

    $ A $是某棵最小生成树$ T $边的子集,$ A\subseteq T $; $ A\cup\{(u,v)\} $仍是$ T $边的一个子集,则称$ (u,v) $是$ A $的安全边。

    若每次向边集$ A $中新增安全边,可保证边集$ A $是最小生成树的子集

  • $ Generic-MST(G) $
  • 为了有效辨识安全边,给出以下定义

    割:对于连通无向图$ G=\langle V,E\rangle $,$ (S,V-S) $将顶点集$ V $划分为两部分

    给定割$ (S,V-S) $和边$ (u,v) $,$ u\in S,v\in V-S $,称边$ (u,v) $横跨割$ (S,V-S) $

    轻边:横跨割的所有边中,权重最小的称为横跨这个割的一条轻边

    如果一个边集$ A $中没有边横跨某割,则称该割不妨害边集$ A $

  • 安全边辨识定理

    给定图$ G=\langle V,E\rangle $是一个带权的连通无向图,令$ A $为边集$ E $的一个子集,且$ A $包含在图$ G $的某棵最小生成树中。若割$ (S,V-S) $是图$ G $中不妨害边集$ A $的任意割,且$ (u,v) $是横跨该割的轻边,则对于边集$ A $,边$ (u,v) $是其安全边。

  • 在算法推进的过程中,集合$ A $始终保持无环状态;算法执行的任意时刻,图$ G_A=(V,A) $是一个森林。对于安全边$ (u,v) $,由于$ A\cup\{(u,v)\} $必须无环,所以 $ (u,v) $连接的是$ G_A $中的两个不同连通分量。

3.$ Prim $算法

贪心策略:集合$ A $始终是一棵树,每次加入到$ A $中的安全边是连接$ A $和$ A $之外某个结点的边中权重最小的边。

  • 采用的数据结构:最小优先队列

  • 步骤1:选择任意一个顶点,作为生成树的起始顶点

  • 步骤2:保持边集$ A $始终为一棵树,选择割$ (V_A,V-V_A) $

  • 步骤3:选择横跨割$ (V_A,V-V_A) $的轻边,添加到边集$ A $中

  • 步骤4:重复步骤2和步骤3,直至覆盖所有顶点

  • 伪代码:

    第$ 1\thicksim 5 $行将每个结点的$ key $值设为$ \infty $(除了根节点$ r $的$ key $值为0,以便其为第一个被处理的结点),将每个结点的父节点设为$ NIL $,并对最小优先队列$ Q $进行初始化;

    在每遍$ while $循环前,有

    • $ A=\{(v,v.\pi):v\in V-\{r\}-Q\} $
    • 已经加入到最小生成树的结点为集合$ V-Q $
    • 对于所有结点$ v\in Q $,如果$ v.\pi\ne NIL $,则$ v.key\lt \infty $且$ v.key $是连接结点$ v $和最小生成树中某个结点的轻边$ (v,v.\pi) $的权重。

    第7行找出结点$ u\in Q $,该结点是某条横跨割$ (V-Q,Q) $的轻边的一个端点,将$ u $从$ Q $中删除并将$ (u,u.\pi) $加入集合$ A $中;

    $ for $循环对与$ u $相邻却不在树中的结点$ v $的属性进行更新。
  • 优先队列

    • 采用二叉堆实现

    • 队列中每个元素有一个关键字,依据关键字大小离开队列

算法 说明 时间复杂度
$ INSERT() $ 向优先队列中插入元素 $ O(\log n) $
$ EXTRACT-MIN(Q) $ 移除优先队列中的最小元素 $ O(\log n) $
$ DECREASE-KEY(u,u.d) $ 更新距离数组,调整优先队列 $ O(\log n) $
  • 算法复杂度

    • 算法运行时间取决于$ Q $的实现方式,如果实现为二叉最小优先队列,则可以使用$ BUILD-MIN-HEAP $执行第$ 1\thicksim 5 $行,时间成本为$ O(V) $;
    • $ while $循环一共执行$ \vert V\vert $次,$ EXTRACT-MIN $需要时间成本为$ O(\lg V) $,$ for $循环执行次数为$ O(E) $,第11行隐藏$ DECREASE-KEY $操作,在二叉最小堆上执行时间成本为$ O(\lg V) $;
    • 总成本为$ O(E\lg V) $。

4.$ Kruskal $算法

贪心策略:集合$ A $始终是一个森林,开始时,其结点集就是$ G $的结点集,并且$ A $是所有单节点树构成的森林。之后每次加入到集合$ A $中的安全边是$ G $中连接$ A $的两个不同分量的权重最小的边。

  • 采用的数据结构:不相交集合

  • 伪代码:

    第$ 1\thicksim 3 $行将$ A $初始化为空集,并创建$ \vert V\vert $棵树,每棵树只包含一个结点;

    第$ 5\thicksim 8 $行的$ for $循环按照权重从低到高的次序对每条边进行检查,如果不在同一棵树中,则加入到集合$ A $中,并将两棵树的结点进行合并。

    证明算法保证选择的边为安全边:

  • 不相交集合

    • $ MAKE-SET(v) $
      • 初始化集合:创建根结点,并设置一条指向自身的边

      • 时间复杂度为$ O(1) $

        1
        2
        x.parent=x
        return x
    • $ FIND-SET(v) $
      • 判定顶点是否在同一集合:回溯查找树根,检查树根是否相同

      • 时间复杂度为$ O(h) $,且$ \vert V\vert\geq 2^h $,则为$ O(\log\vert V\vert) $

        1
        2
        3
        4
        while x.parent ≠ x do
        x=x.parent
        end
        return x
    • $ UNION(u,v) $
      • 合并两棵树

      • 时间复杂度为$ O(h) $,且$ \vert V\vert\geq 2^h $,则为$ O(\log\vert V\vert) $

        1
        2
        3
        4
        5
        6
        7
        8
        9
        10
        11
        a=FIND-SET(x)
        b=FIND-SET(y)
        if a.height ≤ b.height then
        if a.height = b.height then
        b.height=b.height+1
        end
        a.parent=b
        end
        else
        b.parent=a
        end
  • 算法复杂度

    • 将边按照权重升序排序的时间成本为$ O(E\log E) $;
    • 建立不相交集合的时间成本为$ O(V) $;
    • $ while $循环进行了$ \vert E\vert $次,内部时间复杂度为$ O(\log V) $,也即$ while $循环总时间复杂度为$ O(E\log V) $;
    • 假设$ E=O(V^2) $,则总成本为$ O(E\lg V) $。

5.算法对比

Prim算法 Kruskal算法
核心思想 保持一颗树,不断扩展 子树森林,合并为一棵树
数据结构 优先队列 不相交集合
求解视角 微观视角,基于当前点选边 宏观视角,基于全局顺序选边
算法策略 采用贪心策略的图算法 采用贪心策略的图算法