C++经典的数据结构与算法之经典算法思想:贪心算法(Greedy)

贪心算法(Greedy Algorithm):通过局部最优达成全局最优的决策策略

贪心算法是一种通过每次选择局部最优解 来期望全局最优解的算法思想。它不考虑未来的影响,仅根据当前信息做出最优选择,适用于具有贪心选择性质最优子结构的问题。与动态规划相比,贪心算法更简单高效,但适用范围更窄。

一、贪心算法的核心要素
  1. 贪心选择性质:全局最优解可通过一系列局部最优选择(贪心选择)达成。即每次选择的局部最优解,最终能累积成全局最优解。

  2. 最优子结构:问题的最优解包含子问题的最优解(与动态规划相同)。

关键区别

  • 贪心算法:自顶向下,每次做贪心选择后,仅留下一个子问题需要解决。
  • 动态规划:自底向上或自顶向下,需考虑所有子问题并存储其解。
二、经典应用:活动选择问题

问题 :有n个活动,每个活动有开始时间start[i]和结束时间end[i],求最多能参加的不重叠活动数量。

贪心策略 :每次选择最早结束的活动,为剩余活动留出更多时间。

cpp 复制代码
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;

// 活动结构:开始时间和结束时间
struct Activity {
    int start;
    int end;
};

// 按结束时间排序
bool compare(const Activity& a, const Activity& b) {
    return a.end < b.end;
}

// 选择最多不重叠活动
int maxActivities(vector<Activity>& activities) {
    if (activities.empty()) return 0;
    
    // 1. 按结束时间排序(贪心选择的前提)
    sort(activities.begin(), activities.end(), compare);
    
    int count = 1; // 至少选择第一个活动
    int lastEnd = activities[0].end;
    
    // 2. 依次选择不重叠的活动
    for (int i = 1; i < activities.size(); i++) {
        // 若当前活动开始时间 >= 上一个活动结束时间,可选择
        if (activities[i].start >= lastEnd) {
            count++;
            lastEnd = activities[i].end;
        }
    }
    return count;
}

int main() {
    vector<Activity> activities = {
        {1, 4}, {3, 5}, {0, 6}, {5, 7}, {3, 9}, {5, 9}, {6, 10}, {8, 11}, {8, 12}, {2, 14}, {12, 16}
    };
    
    cout << "最多可参加的活动数:" << maxActivities(activities) << endl; // 输出4(如{1,4}, {5,7}, {8,11}, {12,16})
    return 0;
}

算法分析

  • 排序时间O(n log n),选择过程O(n),总时间O(n log n)。
  • 正确性:通过数学归纳法可证明,选择最早结束的活动能获得最优解。
三、经典应用:哈夫曼编码(Huffman Coding)

问题:为字符设计变长编码,出现频率高的字符用短编码,频率低的用长编码,实现数据压缩(无歧义解码)。

贪心策略 :每次选择频率最低的两个节点合并为新节点,重复至只剩一个节点(哈夫曼树),路径左0右1即为编码。

cpp 复制代码
#include <iostream>
#include <vector>
#include <queue>
#include <unordered_map>
using namespace std;

// 哈夫曼树节点
struct HuffmanNode {
    char data;          // 字符(叶子节点)
    int freq;           // 频率
    HuffmanNode *left, *right;
    
    HuffmanNode(char d, int f) : data(d), freq(f), left(nullptr), right(nullptr) {}
};

// 优先队列比较器(最小堆,频率低的节点优先)
struct Compare {
    bool operator()(HuffmanNode* a, HuffmanNode* b) {
        return a->freq > b->freq; // 小顶堆(默认是大顶堆,需反向)
    }
};

// 递归生成哈夫曼编码
void generateCodes(HuffmanNode* root, string code, unordered_map<char, string>& codes) {
    if (!root) return;
    
    // 叶子节点:记录编码
    if (!root->left && !root->right) {
        codes[root->data] = code.empty() ? "0" : code; // 处理只有一个字符的情况
        return;
    }
    
    // 左子树加"0",右子树加"1"
    generateCodes(root->left, code + "0", codes);
    generateCodes(root->right, code + "1", codes);
}

// 构建哈夫曼树并生成编码
unordered_map<char, string> huffmanCoding(unordered_map<char, int>& freq) {
    // 1. 创建叶子节点,加入最小堆
    priority_queue<HuffmanNode*, vector<HuffmanNode*>, Compare> minHeap;
    for (auto& p : freq) {
        minHeap.push(new HuffmanNode(p.first, p.second));
    }
    
    // 2. 合并节点直至只剩一个根节点
    while (minHeap.size() > 1) {
        // 取出两个频率最低的节点
        HuffmanNode* left = minHeap.top(); minHeap.pop();
        HuffmanNode* right = minHeap.top(); minHeap.pop();
        
        // 合并为新节点(数据用特殊符号,频率为两者之和)
        HuffmanNode* merged = new HuffmanNode('$', left->freq + right->freq);
        merged->left = left;
        merged->right = right;
        
        minHeap.push(merged);
    }
    
    // 3. 生成编码
    unordered_map<char, string> codes;
    generateCodes(minHeap.top(), "", codes);
    return codes;
}

int main() {
    unordered_map<char, int> freq = {{'a', 5}, {'b', 9}, {'c', 12}, {'d', 13}, {'e', 16}, {'f', 45}};
    auto codes = huffmanCoding(freq);
    
    cout << "哈夫曼编码:" << endl;
    for (auto& p : codes) {
        cout << p.first << ": " << p.second << endl;
    }
    // 输出示例(可能因实现细节略有不同):
    // f: 0
    // c: 100
    // d: 101
    // a: 1100
    // b: 1101
    // e: 111
    return 0;
}

算法分析

  • 时间复杂度O(n log n)(n个节点,每次堆操作O(log n))。
  • 正确性:哈夫曼编码是最优前缀码,总编码长度最短。
四、经典应用:零钱兑换(贪心适用场景)

问题:用最少的硬币凑出指定金额,硬币面额为{25, 10, 5, 1}。

贪心策略 :每次选择最大面额的硬币,直至金额为0。

cpp 复制代码
int coinChangeGreedy(int amount, vector<int>& coins) {
    sort(coins.rbegin(), coins.rend()); // 面额从大到小排序
    int count = 0;
    
    for (int coin : coins) {
        while (amount >= coin) {
            amount -= coin;
            count++;
        }
        if (amount == 0) break;
    }
    return amount == 0 ? count : -1; // 无法凑出返回-1
}

局限性:仅适用于"大面额是小面额倍数"的情况(如美元硬币)。对于{1, 3, 4}面额凑6,贪心会选4+1+1(3枚),但最优解是3+3(2枚),此时需用动态规划。

五、贪心算法 vs 动态规划
特性 贪心算法 动态规划
决策方式 局部最优(仅看当前) 全局最优(考虑所有子问题)
子问题关系 贪心选择后仅一个子问题 需解决多个重叠子问题
时间复杂度 通常更低(如O(n log n)) 较高(如O(n²)或O(nW))
适用场景 具有贪心选择性质的问题 所有具有最优子结构的问题
典型例子 活动选择、哈夫曼编码、最小生成树 背包问题、LCS、最长递增子序列
六、贪心算法的优缺点

优点

  • 实现简单,时间效率高(通常为线性或线性对数级)。
  • 空间开销小(无需存储子问题解)。

缺点

  • 适用范围有限(仅能解决具有贪心选择性质的问题)。
  • 无法回溯,一旦做出选择就无法更改,可能错过全局最优解。
七、如何判断问题是否适合贪心算法
  1. 尝试构造反例 :假设贪心策略不成立,能否找到反例?

    例如:零钱兑换问题中,若存在非倍数面额,贪心可能失效。

  2. 证明贪心选择性质

    假设全局最优解中不包含贪心选择的元素,能否通过替换证明存在更优解?若不能,则贪心策略有效。

  3. 验证最优子结构:问题的最优解包含子问题的最优解。

总结

贪心算法是一种直观高效的算法思想,通过每次选择局部最优解来逼近全局最优解。它特别适合解决资源分配、调度、编码等具有贪心选择性质的问题,如活动选择、哈夫曼编码、最小生成树(Kruskal和Prim算法)等。

尽管贪心算法的适用范围不如动态规划广泛,但其简单性和高效性使其在许多场景中成为首选。掌握贪心算法的关键在于:

  1. 识别问题是否具有贪心选择性质和最优子结构。
  2. 设计合理的贪心策略(如按结束时间排序、选最大面额等)。
  3. 通过数学证明或反例验证策略的正确性。

在实际开发中,贪心算法常与其他算法结合使用(如贪心+动态规划),以平衡效率和通用性。

相关推荐
郝学胜-神的一滴6 小时前
避免使用非const全局变量:C++中的最佳实践 (C++ Core Guidelines)
开发语言·c++·程序人生
中华小当家呐7 小时前
算法之常见八大排序
数据结构·算法·排序算法
沐怡旸7 小时前
【算法--链表】114.二叉树展开为链表--通俗讲解
算法·面试
tju新生代魔迷7 小时前
数据结构:双向链表
数据结构·链表
一只懒洋洋8 小时前
K-meas 聚类、KNN算法、决策树、随机森林
算法·决策树·聚类
晚云与城8 小时前
今日分享:C++ Stack和queue(栈与队列)
开发语言·c++
bikong78 小时前
一种高效绘制余晖波形的方法Qt/C++
数据库·c++·qt
方案开发PCBA抄板芯片解密9 小时前
什么是算法:高效解决问题的逻辑框架
算法
深耕AI9 小时前
【MFC文档与视图结构:数据“仓库”与“橱窗”的梦幻联动 + 初始化“黑箱”大揭秘!】
c++·mfc