C++ 贪心算法(Greedy Algorithm)详解:从思想到实战

贪心算法是一种在每一步决策中都采取当前 "当前最优" 选择的算法思想,它通过局部局部最优解来期望获得全局最优解。尽管并非所有问题都适用,但在具有贪心选择性质最优子结构的问题中,贪心算法能以极高的效率(通常是线性或线性对数级)给出最优解。本文将从贪心算法的核心思想出发,结合 C++ 实现,解析其适用场景、设计策略及经典应用。

一、贪心算法的核心思想

贪心算法的本质是逐步构建解决方案,每一步都选择对当前而言最优的选项,不回溯。其核心要素包括:

1.1 贪心选择性质

问题的全局最优解可以通过一系列局部最优选择(贪心选择)来获得。即,每次选择只依赖于已做出的选择,不依赖未做出的选择。

1.2 最优子结构

问题的最优解包含其子问题的最优解。这是所有可以用动态规划或贪心算法解决的问题的共同特征。

1.3 与动态规划的区别

  • 贪心算法:自顶向下,每次做局部最优选择,不回溯,不保存子问题解。
  • 动态规划:通常自底向上(或备忘录法),保存子问题解,根据子问题解做出选择。

贪心算法的优势在于时间和空间效率更高,但适用范围更窄;动态规划适用范围更广,但通常更复杂。

二、贪心算法的设计步骤

设计贪心算法需遵循以下步骤:

  1. 问题分析:判断问题是否具有贪心选择性质和最优子结构。
  2. 确定贪心策略:明确 "当前最优" 的标准(如最大、最小、最早、最晚等)。
  3. 排序预处理:多数贪心问题需要先对数据排序(按特定维度)。
  4. 迭代选择:按贪心策略逐步选择,构建解决方案。
  5. 验证正确性:证明贪心选择能得到全局最优解(关键且困难的一步)。

三、经典贪心问题与 C++ 实现

3.1 活动选择问题(Activity Selection)

问题 :有 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;
    
    // 排序
    sort(activities.begin(), activities.end(), compare);
    
    int count = 1;  // 至少选择第一个活动
    int last_end = activities[0].end;
    
    // 迭代选择
    for (size_t i = 1; i < activities.size(); ++i) {
        // 如果当前活动开始时间 >= 上一个活动结束时间,选择它
        if (activities[i].start >= last_end) {
            count++;
            last_end = 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
    return 0;
}

正确性证明:假设存在最优解包含的活动数多于贪心选择的结果,通过替换可推出矛盾,故贪心策略有效。

3.2 零钱兑换问题(Coin Change)

问题 :用面额为coins的硬币兑换金额amount,求最少硬币数(假设每种硬币数量无限,且存在 1 元硬币)。

贪心策略 :每次选择最大面额的硬币,尽可能用大面额硬币覆盖金额。

实现

cpp

运行

复制代码
#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

int coinChange(vector<int>& coins, int amount) {
    if (amount == 0) return 0;
    
    // 排序(从大到小)
    sort(coins.rbegin(), coins.rend());
    
    int count = 0;
    int remaining = amount;
    
    for (int coin : coins) {
        if (remaining >= coin) {
            // 尽可能用当前最大面额
            int num = remaining / coin;
            count += num;
            remaining -= num * coin;
        }
        if (remaining == 0) break;
    }
    
    // 如果无法兑换(remaining未减到0)
    return remaining == 0 ? count : -1;
}

int main() {
    vector<int> coins = {25, 10, 5, 1};  // 美元硬币面额
    int amount = 37;
    
    cout << "最少硬币数: " << coinChange(coins, amount) << endl;  // 3(25+10+1+1 → 实际是25+10+1+1?不,37=25+10+1+1是4枚?哦,正确应为25+10+1+1是4枚?实际正确计算:25+10+1+1=37,确实4枚)
    return 0;
}

注意 :贪心策略仅适用于 "canonical coin systems"(如大多数国家的货币体系)。对于非标准面额(如{1, 3, 4}兑换 6),贪心可能失效,需用动态规划。

3.3 区间覆盖问题(Interval Covering)

问题 :用最少的区间覆盖目标区间[target_start, target_end],给定一系列候选区间intervals

贪心策略

  1. 先选择所有与目标区间起点重叠且终点最远的区间。
  2. 更新目标起点为选中区间的终点,重复步骤 1,直到覆盖整个目标区间。

实现

cpp

运行

复制代码
#include <iostream>
#include <vector>
#include <algorithm>

using namespace std;

struct Interval {
    int start;
    int end;
};

int minIntervalsToCover(vector<Interval>& intervals, int target_start, int target_end) {
    // 按起点排序
    sort(intervals.begin(), intervals.end(), [](const Interval& a, const Interval& b) {
        return a.start < b.start;
    });
    
    int count = 0;
    int current_end = target_start;
    int next_end = target_start;
    size_t i = 0;
    int n = intervals.size();
    
    while (current_end < target_end) {
        // 找到所有与当前起点重叠的区间,记录最远终点
        while (i < n && intervals[i].start <= current_end) {
            next_end = max(next_end, intervals[i].end);
            i++;
        }
        
        // 没有可覆盖的区间,无法完成
        if (next_end == current_end) return -1;
        
        count++;
        current_end = next_end;
    }
    
    return count;
}

int main() {
    vector<Interval> intervals = {
        {1, 4}, {2, 3}, {5, 7}, {6, 8}, {9, 10}
    };
    int target_start = 1, target_end = 10;
    
    cout << "最少需要的区间数: " << minIntervalsToCover(intervals, target_start, target_end) << endl;  // 3([1,4], [5,8], [9,10])
    return 0;
}

3.4 哈夫曼编码(Huffman Coding)

问题:为字符构建前缀编码,使总编码长度最短(字符出现频率已知)。

贪心策略 :每次选择频率最低的两个节点合并,生成新节点,重复直到只剩一个节点(根节点)。

实现

cpp

运行

复制代码
#include <iostream>
#include <queue>
#include <vector>
#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;  // 处理只有一个字符的情况
    }
    
    generateCodes(root->left, code + "0", codes);
    generateCodes(root->right, code + "1", codes);
}

// 构建哈夫曼树并返回编码
unordered_map<char, string> huffmanCoding(unordered_map<char, int>& freq) {
    // 最小堆存储节点
    priority_queue<HuffmanNode*, vector<HuffmanNode*>, Compare> minHeap;
    
    // 初始化叶子节点
    for (auto& pair : freq) {
        minHeap.push(new HuffmanNode(pair.first, pair.second));
    }
    
    // 合并节点
    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);
    }
    
    // 生成编码
    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& pair : codes) {
        cout << pair.first << ": " << pair.second << endl;
    }
    return 0;
}

输出说明 :频率高的字符(如f:45)编码短,频率低的字符(如a:5)编码长,符合贪心策略。

3.5 买卖股票的最佳时机 II

问题 :给定股票价格数组prices,可多次买卖(买入后必须卖出才能再买),求最大利润。

贪心策略:只要当天价格高于前一天,就前一天买入、当天卖出,累积所有正收益。

实现

cpp

运行

复制代码
#include <iostream>
#include <vector>

using namespace std;

int maxProfit(vector<int>& prices) {
    if (prices.size() < 2) return 0;
    
    int max_profit = 0;
    for (size_t i = 1; i < prices.size(); ++i) {
        // 只要有收益就交易
        if (prices[i] > prices[i-1]) {
            max_profit += prices[i] - prices[i-1];
        }
    }
    return max_profit;
}

int main() {
    vector<int> prices = {7, 1, 5, 3, 6, 4};
    cout << "最大利润: " << maxProfit(prices) << endl;  // 7((5-1)+(6-3)=4+3=7)
    return 0;
}

原理:所有上升区间的收益之和即为最大利润,贪心策略通过捕捉每一个微小的上升区间实现全局最优。

四、贪心算法的局限性与适用场景

4.1 局限性

  1. 并非所有问题都适用:仅当问题具有贪心选择性质时有效,否则可能得到次优解。

    • 反例:零钱兑换问题中,若面额为{1, 3, 4},兑换 6 元,贪心策略(4+1+1,3 枚)不如最优解(3+3,2 枚)。
  2. 局部最优≠全局最优:短视的选择可能导致后续可选方案受限。

  3. 难以证明正确性:贪心策略的正确性证明通常需要复杂的数学推导(如交换论证法)。

4.2 适用场景

贪心算法适用于以下类型的问题:

  1. 调度问题:如活动选择、任务调度、CPU 调度等(通常选择最早结束 / 开始的)。
  2. 覆盖问题:如区间覆盖、集合覆盖(选择覆盖范围最大的)。
  3. 编码问题:如哈夫曼编码、前缀编码(优先处理频率高 / 低的)。
  4. 资源分配问题:如水资源分配、带宽分配(按某种优先级分配)。
  5. 排序相关问题:如根据两个维度排序(如根据结束时间、重量、价值等)。

五、贪心算法的优化与技巧

5.1 排序是关键

多数贪心问题需要先排序,排序的维度直接决定贪心策略的有效性:

  • 活动选择:按结束时间排序
  • 区间覆盖:按起点排序
  • fractional knapsack(部分背包):按单位价值排序

5.2 贪心 + 数据结构

复杂的贪心问题常需结合高效数据结构:

  • 优先队列(堆):如哈夫曼编码、实时任务调度
  • 并查集:如最小生成树的 Kruskal 算法
  • 平衡树:如维护动态区间的贪心选择

5.3 多维度贪心

当问题涉及多个维度时,需找到正确的贪心标准:

示例:根据身高和人数排列队列

cpp

运行

复制代码
// 问题:people[i] = [h, k],k表示该人前面应有k个身高≥他的人
// 贪心策略:先按身高降序排序,身高相同则按k升序排序,然后按k插入对应位置
vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {
    // 排序:身高降序,k升序
    sort(people.begin(), people.end(), [](const vector<int>& a, const vector<int>& b) {
        return a[0] > b[0] || (a[0] == b[0] && a[1] < b[1]);
    });
    
    vector<vector<int>> result;
    for (auto& p : people) {
        // 按k值插入到对应位置
        result.insert(result.begin() + p[1], p);
    }
    return result;
}

六、贪心算法与其他算法的结合

6.1 贪心 + 动态规划

某些问题可先用贪心简化,再用动态规划求解:

  • 旅行商问题(TSP):贪心算法(如最近邻)可得到近似解,动态规划可得到精确解但复杂度高。
  • 最长上升子序列(LIS):贪心 + 二分查找可将 O (n²) 优化为 O (n log n)。

cpp

运行

复制代码
// 贪心+二分查找求LIS长度
int lengthOfLIS(vector<int>& nums) {
    vector<int> tails;
    for (int num : nums) {
        // 找到第一个≥num的位置
        auto it = lower_bound(tails.begin(), tails.end(), num);
        if (it == tails.end()) {
            tails.push_back(num);  // 扩展序列
        } else {
            *it = num;  // 替换为更小的数,为后续元素留出空间
        }
    }
    return tails.size();
}

6.2 贪心 + 图论

图论中的经典算法常基于贪心思想:

  • 最小生成树:Kruskal 算法(按边权排序,选不形成环的边)和 Prim 算法(每次加最近的点)
  • 最短路径:Dijkstra 算法(每次选当前最短距离的点)

七、总结

贪心算法是一种高效且直观的算法思想,其核心在于每一步都做出局部最优选择,适用于具有贪心选择性质和最优子结构的问题。与动态规划相比,贪心算法通常更简单、效率更高,但适用范围较窄。

在 C++ 实现中,贪心算法常与排序(<algorithm>中的sort)和优先队列(priority_queue)结合,通过合理的排序策略和数据结构选择,可高效解决各类问题。

学习贪心算法的关键在于:

  1. 识别问题是否具有贪心选择性质(这是最困难的一步)。
  2. 设计正确的贪心策略(如选择最早结束、最大价值、最高频率等)。
  3. 通过数学证明或反例验证策略的正确性。

尽管贪心算法不能解决所有优化问题,但在调度、编码、覆盖等领域,它是不可或缺的工具。掌握贪心算法不仅能提高解题效率,更能培养 "抓主要矛盾" 的问题分析能力,这对于复杂系统设计同样具有重要意义。

相关推荐
再睡一夏就好2 小时前
【C++闯关笔记】unordered_map与unordered_set的底层:哈希表(哈希桶)
开发语言·c++·笔记·学习·哈希算法·散列表
potato_15542 小时前
现代C++核心特性——内存篇
开发语言·c++·学习
沐怡旸2 小时前
【穿越Effective C++】条款13:以对象管理资源——RAII原则的基石
c++·面试
一个不知名程序员www3 小时前
算法学习入门---二分查找(C++)
c++·算法
2301_807997383 小时前
代码随想录-day26
数据结构·c++·算法·leetcode
闭着眼睛学算法3 小时前
【双机位A卷】华为OD笔试之【排序】双机位A-银行插队【Py/Java/C++/C/JS/Go六种语言】【欧弟算法】全网注释最详细分类最全的华子OD真题题解
java·c语言·javascript·c++·python·算法·华为od
小欣加油3 小时前
leetcode 3318 计算子数组的x-sum I
c++·算法·leetcode·职场和发展
Digitally4 小时前
如何在iPhone 17/16/15上显示电池百分比
ios·cocoa·iphone
j_xxx404_4 小时前
C++ STL:list|了解list|相关接口|相关操作
开发语言·c++