《Alogrithms》算法学习笔记——第五章:贪心

文章介绍了贪心算法的概念,并详细讲解了如何通过Kruskal算法和Prim算法构造最小生成树。接着,讨论了Huffman编码的压缩原理和构建过程。最后提到了Horn公式和集合覆盖问题,指出贪心算法在此类问题中的应用和近似解质量。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

贪心算法

我们假设一个大问题可以被分解为许许多多的子问题,例如归并排序。

在这个前提下,贪心算法就是能够通过一个规则,求解这些子问题,并且在将子问题结合起来之后确实能得出原问题正确结果的方法。

5.1 最小生成树

我们从最小生成树开始贪心问题,先来解释一下什么是最小生成树。

  • 通常在一张无向图中有许多的边,但实际上为了保证图像的连通,并不需要这么多的路径。于是删除多余的边让所有边的权值最小,就是创建最小生成树的过程。

为了更生动形象一点,我决定提前先举个例子:

在这里插入图片描述

其中主要依靠了一条性质:

  • 删除循环中的一条边,不改变图的连通性。

这里补充树的基础性质:

  1. n节点树一定有n-1条边;

证明的话,可以从树的创建上来说,我们将每个连通区看做一个分量。最开始每个节点是一个连通分量,每连接两个连通分量就需要一条边,所以一颗 n n n节点的树,边数一共为 n − 1 n-1 n1

  1. 如果无向图中 ∣ E ∣ = ∣ V ∣ − 1 |E| = |V | − 1 E=V1,那么该图一定是树;

这里我们只需要证明该图是不循环的就行,因为比树多出来的遍,一定会构建循环(边的首尾连接的分量相同)。

然后我们在存在循环的时候就删除一条循环中的边,假设删除后的边集合为 E ′ E' E,因为第一条性质, E ′ E' E的边数为 ∣ V ∣ − 1 |V | − 1 V1,所以 ∣ E ′ ∣ = ∣ E ∣ |E'| = |E| E=E

  1. 树的任意两点间,路径唯一。

这个好说,要是多了边就说明一定有循环,有循环就不满足之前的性质而且就不是树了。

5.1.1 Kruskal算法

讲解Kruskal,从原理-数据结构讲述。


首先我们的Kruskal引入了切分原理:

  • 在一幅加权图中,给定任意切分(图切开为两个部分),所有横切边(两端属于两个不同切分的边)中权重最小的边一定属于图的最小生成树。

要证明这一条我们先提出前提:

  1. 我们现在已经将一张图切分为两部分;
  2. 我们这张图是连通的,说明切分的两边至少得有一条连接边(为什么是至少,因为切分的任何一边不一定要连通);

所以我们就是要证明,这些连接边中一定要有最小边。

为了证明,我们就假设:

  1. 当前切口最小边不在最小生成树T中
  2. 假设 e e e是切口上的最小边,且 e e e不在 T T T中,此时新的最小生成树为 T ′ T' T
  3. 我们再假设其中当前的T在这个切口上的连接边为 e ′ e' e,那么可以得出 T ′ = T ∪ { e } − { e ′ } T' = T ∪ \{e\} − \{e'\} T=T{e}{e}(加上最小边,删除原边)。

根据以上假设我们可以得出以下公式:
w e i g h t ( T ′ ) = w e i g h t ( T ) + w ( e ) − w ( e ′ ) weight(T') = weight(T) + w(e) − w(e') weight(T)=weight(T)+w(e)w(e)
因为 w ( e ) ≤ w ( e ′ ) w(e) ≤ w(e') w(e)w(e),所以 w e i g h t ( T ′ ) ≤ w e i g h t ( T ) weight(T′) ≤ weight(T) weight(T)weight(T)。这说明了,如果切口上如果不是最小边,总权和不会最小,所以切分定理成立。


接下来将并查集,这是一个对图的节点进行分类的操作。我们给每个连通区标识,使得可以快速查找到:哪个节点属于哪个连通区。

那么核心问题就来了,并查集是怎么实现的?

总的来说,并查集其实是一个树状结构(通过父子关系查询连通区):我父节点在的区就是我在的区,根节点用于标记区号。所以集合的操作就可以实现为:

  1. 创建:每个节点都是一个区,区号为自己,每棵树只有自己一个根节点;
  2. 合并:将一棵树的根节点指向另一棵树的任意节点;
  3. 查询:一直查找父节点,直到查找到根节点,表示区号。

既然基础实现都已经理解了,下面讲解两种针对合并与查询到优化方法。

  • 按秩合并

树的秩,简单来说就是树的层高。层级低的树合并到层级高的树的根节点上时,就不会增加树的高度,这样可以最大程度上压缩树的高度,如:

在这里插入图片描述

从上图中可以看到,我们将F1连接到D2上高度还是三层,但反过来就会变成四层了,所以这个方法可以有效减少搜索时间。高度也很好算,因为一边高一边低的时候,高度是不会改变的不用管,高度相等的时候才会+1。所以在高度相等的时候给根节点所表示的高度+1就行。

  • 路径压缩

从创建上无论怎么加快也脱离不开构建一颗树的问题,但是通过查询的时候对树中一条分支的遍历,我们可以改造树的形状,就比如可以减小树的平均深度。

我们在查询一个节点在哪个区的时候,我们要从这个节点一路查到根节点,那我们是不是就可以把这一路上的节点,全部连接到根节点上去了。如下图:

这里按I0,K0的顺序查询了两次,就变成上图了。

考虑到上面讲到了秩的问题,补充一下:秩是不会变的,因为这里的秩不代表具体的高度,只用于表达树的高度上限罢了,所以该用还是继续用。


然后展示一下代码吧:

const int MAXN = 1e5 + 7; // 最大节点数量
const int INF = 0x3f3f3f3f; // 正无穷
struct Node {
    int u, v, w; // 起点、终点、权值
    bool operator<(const Node &v) const {
        return w < v.w;
    }
};
int N; // 节点个数
vector<Node> G; // 图的边集
int fa[MAXN], high[MAXN]; // 每个节点的根节点编号、树的秩
/**
 * @brief 查找x的根节点(含路径压缩)
 * 
 * @param x 查询的节点
 * @return int 根节点名称
 */
int find(int x) {
    return fa[x] == 0 ? x : fa[x] = find(fa[x]);
}
/**
 * @brief 合并两个节点所在集(含按秩合并)
 * 
 * @param x x节点
 * @param y y节点
 * @return true 合并成功:所在集合不同
 * @return false 合并失败:在同一个集合中
 */
bool merge(int x, int y) {
    x = find(x);
    y = find(y);
    if (x == y) return false;
    if (high[x] < high[y]) { // y所在集更高
        fa[x] = y;
    } else {
        fa[y] = x;
        if (high[x] == high[y]) high[y]++;
    }
    return true;
}
/**
 * @brief 最小生成树Kruskal算法
 * 
 * @return vector<Node> 结果集
 */
vector<Node> kruskal() {
    // 初始化
    memset(fa, 0, sizeof fa);
    memset(high, 0, sizeof high);

    // 无向边排序
    sort(G.begin(), G.end());

    // 集合合并
    vector<Node> res;
    for (auto item: G)
        if (merge(item.u, item.v))
            res.push_back(item);

    // 返回结果集
    return res;
}

5.1.2 Prim算法

Prim算法其实就比Kruskal算法要简单许多,通过切分定理我们证明了,无论使用何种贪心方法都可以解决这个问题,只要保证当前边最小就好了。

所以我们从一个节点出发,每次寻找可以查询到的最小边,即可以得到最小生成树(是不是听起来很熟悉,因为和Dijkstra就一模一样)。

然后代码实际上也和Dijkstra是一样的,不过这次最重要的就不是dist,而是pre了:

const int MAXN = 1e5 + 7; // 最大节点数量
const int INF = 0x3f3f3f3f; // 正无穷
struct Node {
    int v, w; // 目标位置与值
    bool operator<(const Node &v) const {
        return w < v.w;
    }
};
int N; // 节点个数
vector<Node> G[MAXN]; // 图的临接表
int dist[MAXN], pre[MAXN]; // 单源最短路径长度&每个节点最短路的前向节点
bool visited[MAXN]; // 节点访问标记
/**
 * @brief 单源最短路径算法
 *
 * @param s 起点
 */
void dijkstra(int s) {
    memset(dist, INF, sizeof dist);
    memset(pre, 0, sizeof pre);
    memset(visited, 0, sizeof visited);
    dist[s] = 0;

    priority_queue<int> pq; pq.push(s);
    while (!pq.empty()) {
        // 取出第一个最小的节点
        int u = pq.top(); pq.pop();
        // 跳过节点被重复遍历的情况
        if (!visited[u]) visited[u] = true;
        else continue;
        // 通过新加入节点的临接边更新状态
        for (auto item : G[u]) {
            int v = item.v, len = item.w;
            if (dist[v] > dist[u] + len) { // 加入新边|更新之前计算的长度
                dist[v] = dist[u] + len;
                pre[v] = u;
                pq.push(v);
            }
        }
    }
}

这里我直接搬运Dijkstra的代码,也没必要改。

5.2 Huffman编码

单刀直入的说:Huffman编码是一种对文档的压缩方式。

我们以前是怎么对编码进行压缩的呢:

  1. 假设现在有一个文档,文档中只有四种字母:A、B、C、D;
  2. 这个时候我们就可以通过二进制的00、01、10、11对这四个字符改变成定长的二进制串进行压缩。

但是我们认为这样还是过于的消耗空间了,所以我们打算从别的角度来思考有没有什么特性:

我们还是假设只有四个字母,这个时候他们在文档里的情况为:

字母数量
A70 million
B3 million
C20 million
D37 million

我们就发现了他们的数量大不相同,那我们怎么利用这个特性呢?

  1. 因为有的字符多有的字符少,从简单思维上来说,我们肯定希望多的字符用更短的编码方式
  2. 所以我们决定不使用定长的二进制,而使用变长的二进制字符串来表示字母;
  3. 这个时候要注意一个问题,就是变长字符串在表示的时候可能会出现歧义,例如: { 0 , 01 , 11 , 001 } \{0, 01, 11, 001\} {0,01,11,001}中,我们就无法区分 0 、 01 0、01 001 001 001 001这两种字符串情况。

针对上面的思路,我们要解决变长字符串的问题。这个时候我们改变一下对二进制的看法:

  • 我们将二进制的字符串定义为一个完全二叉树的分支,如果是左分支则为0,右分支则为1,如图:
    在这里插入图片描述

上图已经给我们展示了这四种字母变长的一种解,但是我们现在有两个问题,我们一个一个解决:

  1. 为什么这些变长字符串不会有理解上的冲突:
    • 有冲突的根本原因是两个字符串可以组成第三个字符串;
    • 既然扯上了“组成”这个概念,其实就是说明最后的结果一定是某一条分支下的结果;
    • 例如 0 0 0 1 1 1可以组成 01 01 01,但由于我们将这些字符定义成树,那么这棵树一定不会断开,所以根本就不会出现 1 1 1的这种情况,所以可以避免。
  2. 怎样才是最优解:
    • 在证明之前我们先提出一条定义:在最优树(最好的结果组成的树)种,最小的两个节点一定在树的地步(为什么呢?因为如果不在底部的话,将最小的与底部进行一次交换,则可以减少字符串长度优化);
    • 既然我们已经确定了最小的两个字符一定在底部,那我们把这两个字符合并成一个字符,大小定义为他们只和,这样又变成了一颗 n − 1 n-1 n1个节点的树。又可以用上面的定义合并了;
    • 通过不断的合并最后就是一颗树了,我们称之为Huffman树。

综上所述,已经确定了全部原理,下面看一下代码:

const int MAXN = 1e5 + 7; // 最大节点数量
struct Node {
    string name; // 符号名
    int size; // 大小
    Node *l, *r; // 左右子节点
};
struct cmp{
	bool operator()(const Node* u, const Node* v) const {
		return u->size> v->size;
	}
};
priority_queue<Node*, std::vector<Node*>, cmp> symbol; // 每种字符的编码

上面是定义的变量,这里构建cmp的原因是优先队列的成员是指针时,在Node内重载<运算符是没用的,所以只能采用这种方法。下面是构建huffman树的算法:

/**
 * @brief 构建haffman树
 * 
 * @return Node* haffman树的头指针
 */
Node* huffman() {
    while (symbol.size() > 1) {
        Node* x = symbol.top(); symbol.pop();
        Node* y = symbol.top(); symbol.pop();
        if (x->size > y->size) swap(x, y);
        Node* z = new Node({x->name + y->name, x->size + y->size, x, y});
        symbol.push(z);
    }
    return symbol.top();
}

顺便附上我的输出函数,用来输出每个成员的编码用的:

void out(Node* root, string str) {
    if (!root->l && !root->r) {
        cout << root->name << ": " << str << endl;
        return;
    }
    out(root->l, str + "0");
    out(root->r, str + "1");
}

至此讲完了huffman编码的内容。

5.3 Horn公式

首先描述一下我们用Horn公式解决的是什么问题:

  1. 我们将一些事件描述为一些变量,如:

    x ≡ \equiv 谋杀发生在厨房

    y ≡ \equiv 管家是无辜的

    z ≡ \equiv 上校在晚上 8 点睡着了

  2. 我们将这些变量定义为bool类型,他们只有true或false两种值;

  3. 我们的工作就是通过一些“条件”来判断,x、y、z到底是true或false;

  4. 条件指的是下方的两种公式:

    • 蕴含

    形如KaTeX parse error: Undefined control sequence: \and at position 4: (z \̲a̲n̲d̲ ̲w) \Rightarrow …的公式,用于表示条件 z z z与条件 w w w都成立的时候,可以得出条件 u u u,也可以说是蕴含了条件 u u u

    • 否定从句

    形如KaTeX parse error: Undefined control sequence: \or at position 10: (\bar{u} \̲o̲r̲ ̲\bar{v} \or \ba…的公式,用于表示通过多个否定条件,通过KaTeX parse error: Undefined control sequence: \or at position 1: \̲o̲r̲组成起来的公式。

从最笨的方法开始,我们应该怎么去解决这个问题呢?我们可以直接使用二进制枚举,枚举x、y、z每一个变量为true和false的情况,直到满足所有的公式条件。但这个方法需要的时间也会增长迅速,所以本章提出了Horn公式。

我们先确定一组条件:
KaTeX parse error: Undefined control sequence: \and at position 5: (w \̲a̲n̲d̲ ̲y \and z) \Righ…

  1. Horn公式从所有条件为false开始;
  2. 我们可以看到在蕴含公式的右边是得出来的结果,根据上面的公式可知,有一个 ⇒ x \Rightarrow x x,所以 x x x必须为真。根据 x ⇒ y x \Rightarrow y xy可以说明 y y y也为真;
  3. 根据上诉的关系,我们可以将一些变量设置为真值,然后与否定从句不相符的话说明正解不存在。(说实话我也不懂这里的判真条件与判定结果为什么只要判定不存在就行)

因为太菜所以待补。

5.4 集合覆盖问题

首先在开头讲一下,其实集合覆盖问题通过贪心解决的问题并非最优解,只是一个近似解,但近似范围在多项式时间算法中,误差最小。这个问题本身是一个NP完全问题。

首先描述一下问题:

  • 假设当前有一些住址,用字母来表示如图:

  • 假如我们现在要在某些点建立学校,并且保证每个住址和学校必须在相同位置或相邻,我们最少要建立多少个学校。

问题已经确定了,那为什么这个问题被叫做集合覆盖呢,因为每个学校所能覆盖的住址被当作集合。

那我们简单用贪心的思路来看,我们会选择这样做:

  1. 首先查找相邻点最多的地址建立学校,例如我们选择 a a a建立学校,则可以覆盖 { a , b , d , e , h , i , k } \{a,b,d,e,h,i,k\} {a,b,d,e,h,i,k},这一定是所有点中能覆盖的最大的集合;
  2. 然后在选取 c 、 j 、 f c、j、f cjf建立学校,这样就可以得出建立4所学校就行。

但实际上这并不是最优解,因为如果我们在 b 、 e 、 i b、e、i bei建立学校的话,只要3所学校就能满足了。但如果真的想解决这个问题,就得一个点一个点的去试,所以作为一个NP完全问题,我们只能去找最优解。

而这里使用贪心是为了大幅节省时间花费,所以我们这里最大的问题是,使用贪心的误差到底会有多大。

本书通过一些我看不懂的数学证明得出贪心与最优解的比率一定小于 l n   n ln \ n ln n,而误差的大小由输入情况决定,根据输入,可能误差会逼近 l n   n ln \ n ln n但不会超过。这是多项式算法中最小的比率。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值