第4章 贪心算法
4.1 学习要点
- 理解贪心算法的概念
- 掌握贪心算法的基本要素
- 最优子结构性质
- 贪心选择性质
- 通过应用范例学习贪心设计策略
4.2 问题提出
例:货币兑付问题
银行出纳员支付一定量的现金,其手中有各种面值的货币,要求用最少的货币张数来支付现金。
4.3 贪心算法概述
顾名思义,贪心算法总是作出在当前看来最好的选择。也就是说,贪心算法并不从整体最优考虑,它所作出的选择只是在某种意义上的局部最优选择。当然,希望贪心算法得到的最终结果也是整体最优的。虽然贪心算法不能对所有问题都得到整体最优解,但对许多问题它能产生整体最优解,如单源最短路径问题等。在一些情况下,即使贪心算法不能得到整体最优解,其最终结果却是最优解的很好近似,如多机调度问题。
4.4 本章主要内容
- 活动安排问题
- 贪心算法的基本要素
- 最优装载
- 哈夫曼编码
- 单源最短路径
- 多机调度问题
4.5 活动安排问题
4.5.1 问题描述
设有 n
个活动的集合 E = {1, 2, …, n}
,其中每个活动都要求使用同一资源,如演讲会场等,而在同一时间内只有一个活动能使用这一资源。要求高效地安排这些争用公共资源的活动。
相容的定义:每个活动 i
都有一个要求使用该资源的起始时间 si
和一个结束时间 fi
,且 si < fi
。如果选择了活动 i
,则它在半开时间区间 [si, fi)
内占用资源。若区间 [si, fi)
与区间 [sj, fj)
不相交,则称活动 i
与活动 j
是相容的。也就是说,当 sj ≥ fi
或 si ≥ fj
时,活动 i
与活动 j
相容。
活动安排问题:在所给活动集合 E
中选出最大的相容活动子集 A
。
贪心算法提供了一个简单、有效的方法,使得尽可能多的活动能相容地使用公共资源。
4.5.2 贪心算法实现
public class ActivitySelector {
/**
* 贪心选择活动的算法
* @param s 活动的起始时间数组,索引从1开始
* @param f 活动的结束时间数组,索引从1开始
* @param a 记录活动是否被选择的布尔数组,索引从1开始
* @return 选择的活动数量
*/
public static int greedySelector(int[] s, int[] f, boolean[] a) {
int n = s.length - 1; // 活动数量
a[1] = true; // 选择第一个活动
int j = 1; // 最近选择的活动
int count = 1; // 选择的活动数量
// 依次检查活动i是否与当前已选择的活动相容
for (int i = 2; i <= n; i++) {
// 如果活动i与活动j相容
if (s[i] >= f[j]) {
a[i] = true; // 选择活动i
j = i; // 更新最近选择的活动
count++;
} else {
a[i] = false; // 不选择活动i
}
}
return count;
}
public static void main(String[] args) {
int[] s = {0, 1, 3, 0, 5, 8, 5, 6, 8, 8, 2}; // 活动起始时间,索引从1开始
int[] f = {0, 4, 5, 6, 7, 9, 9, 10, 11, 12, 14}; // 活动结束时间,索引从1开始
boolean[] a = new boolean[s.length];
int count = greedySelector(s, f, a);
System.out.println("选择的活动数量为: " + count);
System.out.print("选择的活动序号为: ");
for (int i = 1; i < a.length; i++) {
if (a[i]) {
System.out.print(i + " ");
}
}
}
}
4.5.3 算法分析
由于输入的活动已按结束时间的非减序排列,算法 greedySelector
每次总是选择具有最早结束时间的相容活动加入集合 A
中。直观上,按这种方法选择相容活动为未安排活动留下尽可能多的时间。即,该算法的贪心选择策略是使剩余的可安排时间段最大化,以便安排尽可能多的相容活动。
算法效率分析:
- 时间复杂度:
O(n)
,因为只需一次遍历即可安排n
个活动。 - 如果活动未排序:需要先对活动按结束时间排序,时间复杂度为
O(n log n)
。
4.5.4 数学归纳法证明
证明1:存在一个最优解包含第一个活动。
设集合 E = {1, 2, …, n}
,活动按结束时间非减序排列。设 A
是一个最优解,且 A
中的第一个活动为 k
。由于活动1具有最早结束时间,若 k ≠ 1
,则将 k
替换为活动1不会造成任何冲突,因为 f1 ≤ fk
。因此,存在一个包含活动1的最优解。
证明2:贪心选择后,剩余问题仍为最优子问题。
假设 A
是包含活动1的最优解,A' = A - {1}
是剩余问题的一个最优解。由于 A'
是最优的,结合第一个贪心选择,A
也是最优的。
4.6 贪心算法的基本要素
4.6.1 最优子结构性质
当一个问题的最优解包含其子问题的最优解时,称此问题具有最优子结构性质。贪心算法和动态规划算法都依赖于最优子结构性质。
4.6.2 贪心选择性质
贪心选择性质指的是所求问题的整体最优解可以通过一系列局部最优的选择来达到。即每一步的贪心选择都是为了最终的全局最优解。
4.6.3 贪心算法与动态规划算法的差异
相同点:
- 都依赖于最优子结构性质,将问题分解为子问题并解决。
不同点:
- 子问题独立性:
- 分治法:子问题相互独立,不包含公共子子问题。
- 动态规划:子问题可能包含公共子子问题,具有重叠子问题性质。
- 选择策略:
- 分治法:通过递归求解,合并子问题的解。
- 贪心算法:通过局部最优选择构建全局最优解,通常不需要存储子问题的解。
4.7 最优装载
4.7.1 问题描述
有一批集装箱要装上一艘载重量为 C
的轮船。其中集装箱 i
的重量为 wi
。最优装载问题要求确定在装载体积不受限制的情况下,将尽可能多的集装箱装上轮船。
数学模型:
最大化 Σ xi
s.t. Σ wi * xi ≤ C, for all i = 1, 2, ..., n
xi ∈ {0, 1}, for all i = 1, 2, ..., n
其中,xi = 1
表示装入集装箱 i
,xi = 0
表示不装入。
4.7.2 贪心算法实现
贪心策略:选择重量最轻的集装箱先装入,以尽可能多地装载集装箱。
public class KnapsackGreedy {
/**
* 最优装载的贪心算法
* @param c 轮船的载重量
* @param w 集装箱的重量数组
* @param x 装载结果数组,1表示装入,0表示不装入
* @return 装载的总重量
*/
public static float loading(float c, float[] w, int[] x) {
int n = w.length;
Element[] d = new Element[n];
for (int i = 0; i < n; i++) {
d[i] = new Element(w[i], i);
}
MergeSort.mergeSort(d); // 按重量从小到大排序
float opt = 0;
for (int i = 0; i < n; i++) {
x[i] = 0; // 初始化
}
for (int i = 0; i < n && d[i].w <= c; i++) {
x[d[i].i] = 1; // 选择集装箱
opt += d[i].w;
c -= d[i].w;
}
return opt;
}
static class Element implements Comparable<Element> {
float w;
int i;
Element(float w, int i) {
this.w = w;
this.i = i;
}
@Override
public int compareTo(Element other) {
return Float.compare(this.w, other.w);
}
}
// 假设MergeSort已经实现
static class MergeSort {
public static void mergeSort(Element[] arr) {
if (arr.length < 2) return;
int mid = arr.length / 2;
Element[] left = Arrays.copyOfRange(arr, 0, mid);
Element[] right = Arrays.copyOfRange(arr, mid, arr.length);
mergeSort(left);
mergeSort(right);
merge(arr, left, right);
}
private static void merge(Element[] arr, Element[] left, Element[] right) {
int i = 0, j = 0, k = 0;
while (i < left.length && j < right.length) {
if (left[i].compareTo(right[j]) <= 0) {
arr[k++] = left[i++];
} else {
arr[k++] = right[j++];
}
}
while (i < left.length) {
arr[k++] = left[i++];
}
while (j < right.length) {
arr[k++] = right[j++];
}
}
}
public static void main(String[] args){
float C = 10;
float[] w = {2, 2, 6, 5, 4};
int[] x = new int[w.length];
float totalWeight = loading(C, w, x);
System.out.println("装载的总重量为: " + totalWeight);
System.out.print("装载的集装箱序号为: ");
for (int i = 0; i < x.length; i++) {
if (x[i] == 1) {
System.out.print((i + 1) + " ");
}
}
}
}
4.7.3 算法分析
贪心选择性质:选择重量最轻的集装箱可以为后续装载留下尽可能多的空间,从而增加装载的集装箱数量。
最优子结构性质:装载的最优解包含装载子问题的最优解。
时间复杂度:
- 排序步骤:
O(n log n)
- 选择步骤:
O(n)
- 总体时间复杂度:
O(n log n)
4.8 哈夫曼编码
4.8.1 问题描述
哈夫曼编码是广泛用于数据文件压缩的高效编码方法。其压缩率通常在20%~90%之间。使用字符在文件中出现的频率表来建立一个用0和1表示各字符的最优表示方式。高频字符使用较短的编码,低频字符使用较长的编码,可以大大缩短总码长。
前缀码:对每一个字符规定一个0、1串作为其代码,并要求任一字符的代码都不是其他字符代码的前缀。这种编码称为前缀码。编码的前缀性质可以使译码方法非常简单。
4.8.2 最优前缀码
平均码长:
B(T) = Σ f(c) * d(c)
c ∈ C
其中,C
为编码字符集,f(c)
表示字符 c
在文件中出现的频率,d(c)
表示字符 c
的编码长度。
最优前缀码对应的二叉树总是一棵完全二叉树,且恰有 |C|
个叶子结点,每个叶子结点对应字符集 C
中的一个字符。
4.8.3 哈夫曼算法
算法步骤:
- 初始化:将所有字符按照其频率构建为叶子结点,并插入一个最小堆(优先队列)。
- 构建树:
- 从最小堆中取出频率最小的两个结点
x
和y
。 - 创建一个新结点
z
,其频率为f(z) = f(x) + f(y)
。 - 将
z
作为x
和y
的父结点,并将z
插入最小堆。
- 从最小堆中取出频率最小的两个结点
- 重复:重复步骤2,直到最小堆中只剩下一个结点,该结点即为哈夫曼树的根。
- 生成编码:通过遍历哈夫曼树,为每个字符分配编码。左子树分配
0
,右子树分配1
。
代码实现:
import java.util.PriorityQueue;
import java.util.Map;
import java.util.HashMap;
public class HuffmanCoding {
static class Node implements Comparable<Node> {
char c;
int freq;
Node left, right;
Node(char c, int freq){
this.c = c;
this.freq = freq;
}
Node(int freq, Node left, Node right){
this.c = '\0'; // 非叶子结点
this.freq = freq;
this.left = left;
this.right = right;
}
@Override
public int compareTo(Node other){
return this.freq - other.freq;
}
// 判断是否为叶子结点
public boolean isLeaf(){
return (this.left == null) && (this.right == null);
}
}
// 构建哈夫曼树
public static Node buildHuffmanTree(char[] chars, int[] freqs){
PriorityQueue<Node> pq = new PriorityQueue<>();
for(int i = 0; i < chars.length; i++){
pq.offer(new Node(chars[i], freqs[i]));
}
while(pq.size() > 1){
Node x = pq.poll();
Node y = pq.poll();
Node z = new Node(x.freq + y.freq, x, y);
pq.offer(z);
}
return pq.poll();
}
// 生成哈夫曼编码
public static void generateHuffmanCodes(Node root, String s, Map<Character, String> map){
if(root == null)
return;
if(root.isLeaf()){
map.put(root.c, s);
}
generateHuffmanCodes(root.left, s + "0", map);
generateHuffmanCodes(root.right, s + "1", map);
}
public static void main(String[] args){
char[] chars = {'A', 'B', 'C', 'D', 'E', 'F'};
int[] freqs = {45, 13, 12, 16, 9, 5};
Node root = buildHuffmanTree(chars, freqs);
Map<Character, String> huffmanCodes = new HashMap<>();
generateHuffmanCodes(root, "", huffmanCodes);
System.out.println("哈夫曼编码结果:");
for(char c : chars){
System.out.println(c + ": " + huffmanCodes.get(c));
}
}
}
示例输出:
哈夫曼编码结果:
A: 0
B: 101
C: 100
D: 111
E: 1101
F: 1100
复杂度分析:
- 时间复杂度:
O(n log n)
,主要耗时在构建最小堆和合并结点。 - 空间复杂度:
O(n)
,存储哈夫曼树所需的空间。
4.8.4 哈夫曼算法的正确性
要证明哈夫曼算法的正确性,只需证明最优前缀码问题具有贪心选择性质和最优子结构性质。
(1) 贪心选择性质(证明)
设 C
是编码的字符集,C
中的字符 c
的频率为 f(c)
;x
和 y
是 C
中具有最小频率的两个字符。
求证:存在 C
的最优前缀码,使得 x
和 y
具有相同最长码长,且只有最后一位编码不同。
- 构造哈夫曼树时,
x
和y
被合并为一个父节点z
,它们的编码在哈夫曼树中作为兄弟节点,只有最后一位不同。 - 通过这种方式,较低频率的字符拥有较长的编码,符合贪心策略。
(2) 最优子结构性质(证明)
设 T
是表示字符集 C
的一个最优前缀码的完全二叉树,C
中的字符 c
的频率为 f(c)
;x
和 y
是树 T
中的两个叶子且为兄弟,z
是它们的父节点。
- 将
x
和y
合并为一个新节点z
,频率为f(z) = f(x) + f(y)
。 - 构造新的哈夫曼树
T'
,对应字符集C' = C - {x, y} ∪ {z}
。 - 递归地应用哈夫曼算法,确保
T'
是C'
的最优前缀码。 - 由此可得,哈夫曼树的构建符合最优子结构性质,保证了整体最优解。
4.9 单源最短路径
4.9.1 问题描述
给定一个带权有向图 G = (V, E)
,其中每条边的权是非负实数。给定 V
中的一个顶点作为源,计算从源到所有其他各顶点的最短路径长度。路径长度是指路径上各边权之和。
4.9.2 Dijkstra算法
算法思想:
Dijkstra算法是解决单源最短路径问题的贪心算法。其基本思想是:
-
初始化:
- 设置一个顶点集合
S
,初始时仅包含源顶点。 - 用数组
dist
记录当前每个顶点的最短特殊路径长度,初始时dist[source] = 0
,其他顶点的dist
设为无穷大。
- 设置一个顶点集合
-
选择:
- 每次从
V - S
集合中选择具有最短dist
值的顶点u
,并将u
添加到S
中。
- 每次从
-
更新:
- 对于每个与
u
相邻的顶点v
,如果通过u
到v
的路径比当前记录的dist[v]
更短,则更新dist[v]
。
- 对于每个与
-
重复:
- 重复步骤2和3,直到所有顶点都被加入到
S
中。
- 重复步骤2和3,直到所有顶点都被加入到
代码实现:
import java.util.*;
public class Dijkstra {
static class Edge {
int to;
int weight;
Edge(int to, int weight){
this.to = to;
this.weight = weight;
}
}
static class Node implements Comparable<Node> {
int vertex;
int dist;
Node(int vertex, int dist){
this.vertex = vertex;
this.dist = dist;
}
@Override
public int compareTo(Node other){
return this.dist - other.dist;
}
}
public static int[] dijkstra(int n, List<Edge>[] adj, int source){
int[] dist = new int[n];
Arrays.fill(dist, Integer.MAX_VALUE);
dist[source] = 0;
PriorityQueue<Node> pq = new PriorityQueue<>();
pq.offer(new Node(source, 0));
while(!pq.isEmpty()){
Node current = pq.poll();
int u = current.vertex;
for(Edge edge : adj[u]){
int v = edge.to;
int weight = edge.weight;
if(dist[u] + weight < dist[v]){
dist[v] = dist[u] + weight;
pq.offer(new Node(v, dist[v]));
}
}
}
return dist;
}
public static void main(String[] args){
int n = 5; // 顶点数量
List<Edge>[] adj = new ArrayList[n];
for(int i = 0; i < n; i++) adj[i] = new ArrayList<>();
// 添加边,例子:从顶点0到顶点1,权重10
adj[0].add(new Edge(1, 10));
adj[0].add(new Edge(3, 5));
adj[1].add(new Edge(2, 1));
adj[1].add(new Edge(3, 2));
adj[2].add(new Edge(4, 4));
adj[3].add(new Edge(1, 3));
adj[3].add(new Edge(2, 9));
adj[3].add(new Edge(4, 2));
adj[4].add(new Edge(0, 7));
adj[4].add(new Edge(2, 6));
int source = 0;
int[] distances = dijkstra(n, adj, source);
System.out.println("从源顶点 " + source + " 到各顶点的最短路径长度:");
for(int i = 0; i < n; i++){
System.out.println("到顶点 " + i + " 的最短路径长度为: " + (distances[i] == Integer.MAX_VALUE ? "∞" : distances[i]));
}
}
}
示例输出:
从源顶点 0 到各顶点的最短路径长度:
到顶点 0 的最短路径长度为: 0
到顶点 1 的最短路径长度为: 8
到顶点 2 的最短路径长度为: 9
到顶点 3 的最短路径长度为: 5
到顶点 4 的最短路径长度为: 7
算法复杂度分析:
- 时间复杂度:
O(E log V)
,其中E
是边的数量,V
是顶点的数量。使用优先队列(最小堆)可以高效地选择具有最短距离的顶点。 - 空间复杂度:
O(V + E)
,用于存储图的邻接表和距离数组。
4.10 多机调度问题
4.10.1 问题描述
多机调度问题要求给出一种作业调度方案,使所给的 n
个作业在尽可能短的时间内由 m
台机器加工处理完成。
约定:
- 每个作业均可在任何一台机器上加工处理,但未完成前不允许中断处理。
- 作业不能拆分成更小的子作业。
性质:该问题是NP完全问题,目前尚无有效的多项式时间算法。对于这一类问题,贪心选择策略有时可以设计出较好的近似算法。
4.10.2 贪心算法实现
贪心策略:采用最长处理时间作业优先的贪心选择策略。
算法步骤:
- 排序:将所有作业按其处理时间从大到小排序。
- 分配:依次将作业分配给当前最空闲的机器。
- 重复:直到所有作业都被分配完毕。
代码实现:
import java.util.Arrays;
import java.util.Comparator;
public class MultiMachineScheduling {
/**
* 多机调度的贪心算法:最长处理时间优先
* @param m 机器数量
* @param jobs 作业处理时间数组
* @return 最短完成时间
*/
public static int schedule(int m, int[] jobs) {
// 将作业按处理时间从大到小排序
Integer[] sortedJobs = new Integer[jobs.length];
for(int i = 0; i < jobs.length; i++) {
sortedJobs[i] = jobs[i];
}
Arrays.sort(sortedJobs, Comparator.reverseOrder());
int[] machineTime = new int[m];
for(int job : sortedJobs){
// 找到当前最空闲的机器
int minMachine = 0;
for(int i = 1; i < m; i++) {
if(machineTime[i] < machineTime[minMachine]){
minMachine = i;
}
}
// 将作业分配给最空闲的机器
machineTime[minMachine] += job;
}
// 找到最慢的机器完成时间
int makespan = machineTime[0];
for(int i = 1; i < m; i++) {
if(machineTime[i] > makespan){
makespan = machineTime[i];
}
}
return makespan;
}
public static void main(String[] args){
int m = 3; // 机器数量
int[] jobs = {2, 14, 4, 16, 6, 5, 3}; // 作业处理时间
int makespan = schedule(m, jobs);
System.out.println("最短完成时间为: " + makespan);
}
}
示例输出:
最短完成时间为: 17
算法分析
属性 | 说明 |
---|---|
贪心选择性质 | 选择最长处理时间的作业优先分配,可以有效地减少机器的空闲时间,从而缩短总完成时间。 |
时间复杂度 | O(n log n) ,主要耗时在排序步骤。 |
空间复杂度 | O(m) ,用于存储每台机器的当前处理时间。 |
示例说明
作业与机器信息
- 作业:{1, 2, 3, 4, 5, 6, 7}
- 机器:M1, M2, M3
- 处理时间:{2, 14, 4, 16, 6, 5, 3}
作业排序
按照贪心算法排序后的作业顺序为:{16, 14, 6, 5, 4, 3, 2}
分配步骤
步骤 | 作业 | 处理时间 | 分配给机器 | 机器负载 |
---|---|---|---|---|
1 | 作业4 | 16 | M1 | M1: 16 |
2 | 作业2 | 14 | M2 | M2: 14 |
3 | 作业5 | 6 | M3 | M3: 6 |
4 | 作业6 | 5 | M3 | M3: 11 (6 + 5) |
5 | 作业3 | 4 | M3 | M3: 15 (11 + 4) |
6 | 作业7 | 3 | M2 | M2: 17 (14 + 3) |
7 | 作业1 | 2 | M1 | M1: 18 (16 + 2) |
最终完成时间: 18
注:通过其他调度策略,可能达到更优的完成时间 17。
4.11 贪心算法小结
贪心算法:总是作出在当前看来最好的选择,而不从整体最优考虑。它所作出的选择只是在某种意义上的局部最优选择。
优点:
- 简单、直观,容易实现。
- 在适用的情况下,能够提供高效且最优的解决方案。
缺点:
- 不能保证对所有问题都能得到整体最优解。
- 适用范围有限,需满足贪心选择性质和最优子结构性质。
适用条件:
- 贪心选择性质:问题的全局最优可以通过一系列局部最优的选择来实现。
- 最优子结构性质:问题的最优解包含其子问题的最优解。
总结:
贪心算法是一种强大的算法设计技术,适用于具有贪心选择性质和最优子结构性质的优化问题。通过合理的贪心选择策略,能够在许多实际问题中提供高效的解决方案。然而,对于不满足这些性质的问题,贪心算法可能无法得到最优解,此时需要考虑其他算法设计方法,如动态规划或分治法。
### 优化说明
根据您的反馈,以下是对内容的部分优化:
1. **一致性**:
- 确保所有代码示例中函数和变量的命名风格一致,并添加必要的注释,以便读者更好地理解代码逻辑。
2. **代码注释**:
- 在`greedySelector`和`loading`函数中添加了详细的注释,解释每一步的作用。
- 在`Dijkstra`和`HuffmanCoding`类的代码中,添加了注释以说明各部分的功能。
3. **格式和排版**:
- 使用正确的标题层级和有序列表,确保内容结构清晰。
- 代码块使用了适当的语言标识(如`java`),增强了可读性和语法高亮效果。
4. **图表和示意图**:
- 虽然Markdown支持插入图片,但由于无法直接嵌入图片链接,建议在相关部分(如算法步骤和示例说明)添加注释,提示读者查看书中相关图表或流程图。
5. **小结部分**:
- 在“小结”部分添加了总结本章关键点的内容,帮助读者回顾和巩固所学内容。
6. **语言流畅性和排版美观**:
- 对部分段落进行了润色,确保语言表达更加流畅,专业术语使用准确。
- 适当增加了空行和缩进,使内容排版更为美观,提升了阅读体验。
这些优化旨在提高内容的准确性、可读性和整体质量,确保读者能够更好地理解和应用贪心算法的相关知识。