介绍
贪心算法是一种在每一步选择中都力图使局部最优解能够逐步达到全局最优解的算法。与其他算法不同的是,贪心算法在选择过程中不进行回溯,选择的过程往往基于某种局部最优策略。在一些特定的问题中,贪心算法可以通过逐步构建最优解来实现全局最优。
贪心算法的基本原理
贪心算法的核心思想是通过一系列局部最优选择来构建全局最优解。
步骤
选择初始状态
·确定算法的起点。
应用贪心选择性
·在当前状态下选择一个局部最优的动作,以期望能达到全局最优。
检查算法的可行性
·判断当前选择是否满足问题的约束条件。
更新状态
·根据贪心选择的结果更新当前状态。
结束条件
·检查是否达到算法的结束条件。
经典问题示例
活动选择问题
问题描述
有n个活动,每个活动都有一个开始时间和结束时间。你要安排尽可能多的活动,但同一时间内只能参加一个活动。
问题分析
解题思路
每次选择结束时间最早且与已选择活动不冲突的活动。选择的贪心策略是优先选择结束时间最早的活动。
解题步骤
1.将活动按照结束时间从小到大排序。
2.初始化:选择第一个活动,并将其添加到结果集中。
3.对于每个后续活动,若其开始时间不早于上一个选择的活动的结束时间,则选择该活动。
4.返回结果集中的活动数。
画图解析
在活动选择问题中,我们首先将所有活动按照结束时间从小到大排序。然后,从第一个活动开始选择,依次检查后续活动的开始时间是否不早于前一个选择的活动的结束时间。
活动 : A1 A2 A3 A4 A5 A6
开始时间: 1 2 4 1 5 8
结束时间: 3 5 7 8 9 10
排序后活动: A1 A2 A3 A5 A6 A4
选择顺序: A1 A3 A6
首先选择A1,然后选择与A1不冲突的A3,最后选择与A3不冲突的A6。最终选出的活动为A1、A3和A6。
代码实现
源代码
#include <iostream>
#include <vector>
#include <algorithm>
// 活动结构体
struct Activity {
int start;
int end;
int index; // 活动的原始索引
};
// 比较器函数,用于按结束时间排序活动
bool compare(Activity a, Activity b) {
return a.end < b.end;
}
// 贪心算法选择最大数量的非重叠活动
std::vector<int> select_activities(std::vector<Activity>& activities) {
// 按结束时间排序活动
std::sort(activities.begin(), activities.end(), compare);
std::vector<int> selected_activities;
int last_end_time = 0;
for (const auto& activity : activities) {
if (activity.start >= last_end_time) {
selected_activities.push_back(activity.index);
last_end_time = activity.end;
}
}
return selected_activities;
}
int main() {
// 定义活动的开始和结束时间
std::vector<Activity> activities = {
{1, 3, 1},
{2, 5, 2},
{4, 7, 3},
{1, 8, 4},
{5, 9, 5},
{8, 10, 6}
};
// 选择活动
std::vector<int> selected_activities = select_activities(activities);
// 打印选择的活动
std::cout << "选择的活动顺序: ";
for (int index : selected_activities) {
std::cout << "A" << index << " ";
}
std::cout << std::endl;
return 0;
}
代码解析
活动结构体
①、Activity结构体包含活动的开始时间(start)、结束时间(end)和原始索引(index)。
比较器函数
①、compare函数用于按活动的结束时间从小到大排序活动。
贪心算法函数
①、select_activities函数接收活动列表并返回选择的活动的原始索引列表。
②、首先,按结束时间对活动进行排序。
③、然后,遍历排序后的活动,每次选择开始时间不早于上一个选择活动的结束时间的活动。
主函数
①、在main函数中定义活动的开始和结束时间,并调用select_activities函数选择最大数量的非重叠活动。
②、最后,打印选择的活动顺序。
🍌运行结果
效率
时间复杂度
·按结束时间排序活动:
std::sort 函数对活动按结束时间排序。std::sort 的时间复杂度为 O(n log n),其中 n 是活动的数量。
·选择活动:
选择活动的过程是线性的,因为它只需要遍历一次排序后的活动列表。遍历的时间复杂度为 O(n)。
因此,总的时间复杂度为:O(nlogn)+O(n)=O(nlogn)
空间复杂度
·活动列表:
活动列表 activities 占用 O(n) 的空间,其中 n 是活动的数量。
·排序过程中使用的额外空间:
std::sort 通常使用 O(log n) 的额外空间(用于栈上的递归调用)。
·选择的活动索引列表:
存储选择的活动索引的 selected_activities 列表占用 O(n) 的空间,因为在最坏情况下,所有活动都可能被选择。
因此,总的空间复杂度为:O(n)+O(logn)+O(n)=O(n)
哈夫曼编码
问题描述
给定一组字符及其出现频率,构建最优前缀编码(哈夫曼编码),以最小化编码的总长度。
问题分析
解题思路
每次从频率表中选择频率最小的两个字符合并,形成一个新的字符,并更新频率表。重复此操作直到所有字符都被合并。
解题步骤
1.构建一个优先队列,将所有字符及其频率放入队列。
2.从队列中取出频率最小的两个节点,将其合并为一个新的节点,并将合并后的节点重新加入队列。
3.重复步骤2,直到队列中只剩下一个节点,该节点即为哈夫曼树的根节点。
4.通过遍历哈夫曼树生成哈夫曼编码。
🍌画图解析
在哈夫曼编码问题中,我们通过构建一棵哈夫曼树来生成最优前缀编码
字符及频率: a:45, b:13, c:12, d:16, e:9, f:5
步骤 1: 取频率最小的 'f' 和 'e'
5 9
| |
f e
步骤 2: 合并 'f' 和 'e'
14
/ \
f e
步骤 3: 取频率最小的 'c' 和 'b'
12 13
| |
c b
步骤 4: 合并 'c' 和 'b'
25
/ \
c b
步骤 5: 取频率最小的 '14' 和 'd'
14 16
/ \ |
f e d
步骤 6: 合并 '14' 和 'd'
30
/ \
14 d
/ \
f e
步骤 7: 取频率最小的 '25' 和 '30'
30 25
/ \ / \
14 d c b
/ \
f e
步骤 8: 合并 '25' 和 '30'
55
/ \
30 25
/ \ / \
14 d c b
/ \
f e
哈夫曼树的构建过程如上图所示。通过不断合并频率最小的节点,我们最终得到了哈夫曼树。根据哈夫曼树的结构,我们可以生成字符的编码。
代码实现
源代码(C++)
#include <iostream>
#include <vector>
#include <queue>
#include <unordered_map>
#include <algorithm>
// 节点结构体
struct Node {
char character;
int frequency;
Node *left, *right;
Node(char ch, int freq, Node* l = nullptr, Node* r = nullptr)
: character(ch), frequency(freq), left(l), right(r) {}
};
// 比较器,用于优先队列
struct Compare {
bool operator()(Node* l, Node* r) {
return l->frequency > r->frequency;
}
};
// 递归生成编码
void generate_codes(Node* root, const std::string& str, std::unordered_map<char, std::string>& huffman_codes) {
if (!root) return;
if (!root->left && !root->right) {
huffman_codes[root->character] = str;
}
generate_codes(root->left, str + "0", huffman_codes);
generate_codes(root->right, str + "1", huffman_codes);
}
// 哈夫曼编码主函数
std::unordered_map<char, std::string> huffman_encoding(const std::unordered_map<char, int>& frequencies) {
std::priority_queue<Node*, std::vector<Node*>, Compare> heap;
for (const auto& pair : frequencies) {
heap.push(new Node(pair.first, pair.second));
}
while (heap.size() > 1) {
Node* left = heap.top(); heap.pop();
Node* right = heap.top(); heap.pop();
int sum = left->frequency + right->frequency;
heap.push(new Node('\0', sum, left, right));
}
Node* root = heap.top();
std::unordered_map<char, std::string> huffman_codes;
generate_codes(root, "", huffman_codes);
return huffman_codes;
}
// 打印哈夫曼编码
void print_huffman_codes(const std::unordered_map<char, std::string>& huffman_codes) {
for (const auto& pair : huffman_codes) {
std::cout << pair.first << ": " << pair.second << std::endl;
}
}
// 测试哈夫曼编码
int main() {
std::unordered_map<char, int> frequencies = { {'a', 45}, {'b', 13}, {'c', 12}, {'d', 16}, {'e', 9}, {'f', 5} };
std::unordered_map<char, std::string> huffman_codes = huffman_encoding(frequencies);
print_huffman_codes(huffman_codes);
return 0;
}
代码解析
使用Node结构体表示哈夫曼树的节点。
使用优先队列(std::priority_queue)来处理节点的最小堆。
generate_codes函数递归生成每个字符的哈夫曼编码。
huffman_encoding函数构建哈夫曼树并生成编码。
main函数测试哈夫曼编码并输出结果。
运行结果
效率
时间复杂度
1.构建初始堆: 将所有字符及其频率插入到优先队列(最小堆)中,使用heap.push方法。
①插入每个元素到堆中的时间复杂度为 O(log n),其中 n 是字符的数量。
②因此,构建初始堆的总时间复杂度为 O(n log n)。
2.合并节点: 每次从堆中取出两个节点并合并,再将新的节点插入堆中,重复此过程直到堆中只剩下一个节点。
①每次从堆中取出两个节点的时间复杂度为 O(log n)。
②插入新节点的时间复杂度为 O(log n)。
③总共进行 n - 1 次合并操作。
④因此,合并节点的总时间复杂度为 O(n log n)。
3.生成哈夫曼编码: 递归遍历哈夫曼树生成编码。
1.遍历哈夫曼树的时间复杂度为 O(n),因为每个节点都访问一次。
结合上述各步骤,哈夫曼编码算法的总体时间复杂度为:O(nlogn)
空间复杂度
1.存储哈夫曼树:
①每个字符对应一个节点,树中总共有 2n - 1 个节点(n 个叶子节点和 n - 1 个内部节点)。
②每个节点占用 O(1) 空间。
③因此,存储哈夫曼树的空间复杂度为 O(n)。
2.优先队列:
①优先队列最多同时存储 n 个节点。
②因此,优先队列的空间复杂度为 O(n)。
3.哈夫曼编码表:
1.编码表中存储每个字符及其对应的编码,最多存储 n 个字符,每个字符的编码长度与字符数量有关。
2.最坏情况下,每个编码的长度为 O(n)。
3.因此,哈夫曼编码表的空间复杂度为 O(n)。
结合上述各项,哈夫曼编码算法的总体空间复杂度为:O(n)
贪心算法的优缺点
优点
简单易懂:贪心算法通常非常直观和易于理解,解决问题的思路简单直接。
局部最优解:贪心算法通过每一步选择当前最优解(局部最优解),尝试构建全局最优解。
高效:贪心算法的时间复杂度通常较低,适合解决某些大规模问题。常见的时间复杂度为 O(n log n) 或 O(n)。
应用广泛:贪心算法在诸如图论(如最小生成树、最短路径)、任务调度、资源分配等多个领域有广泛的应用。
缺点
局限性:贪心算法并不总能找到问题的最优解。它依赖于每一步的局部最优选择,可能会错过全局最优解。例如,背包问题的贪心算法不能保证找到最优解。
问题依赖性:贪心算法只有在某些特定类型的问题(满足贪心选择性质和最优子结构性质)中才能有效工作。对于不满足这些性质的问题,贪心算法无法保证最优解。
分析复杂:对于某些问题,证明贪心算法的正确性和最优性可能较为复杂,需要仔细的数学推导和验证。
无回溯:贪心算法一旦做出选择,就不会回溯或改变决定。因此,一旦做出错误的选择,就无法修正。
适用问题的特性
为了确保贪心算法能够找到问题的最优解,需要满足以下特性
贪心选择性质:在每一步选择中,局部最优解可以导向全局最优解。即当前的贪心选择不影响问题的整体最优性。
最优子结构性质:一个问题的最优解包含其子问题的最优解。即问题可以分解成若干子问题,子问题的最优解组合成原问题的最优解。
示例
活动选择问题:
贪心算法按结束时间最早的活动排序,每次选择不与已选活动冲突的活动,能保证选择到最多数量的活动。
最小生成树(Kruskal和Prim算法):
通过选择最小权重边或节点逐步构建最小生成树,贪心算法在图论中有显著应用。
最短路径问题(Dijkstra算法):
每次选择当前最短路径并更新相邻节点的最短路径,能够找到从源点到其他所有节点的最短路径。
总结
贪心算法通过每一步的局部最优选择,逐步构建全局最优解。虽然贪心算法不能保证在所有情况下都能找到最优解,但在许多经典问题中,它都能提供一个高效的解决方案。
————————————————
本文转载自CSDN博主:Papicatch
版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
原文链接:https://blog.csdn.net/2302_76516899/article/details/139001947