算法笔记17 - 贪心算法介绍与思路 | 路灯摆放问题 | 活动安排问题 | 最低字典序拼接 | 金条分割问题 | 项目投资问题
文章目录
- 1. 引言:什么是贪心算法?
- 2. 贪心算法的实战思路:策略与验证
- 3. 贪心算法题目解析
- 3.1 题目一:会议室宣讲(活动安排问题)
- 代码实现:
- 对数器:
- 3.2 题目二:最低字典序拼接
- 代码实现:
- 对数器:
- 3.3 题目三:金条分割(哈夫曼编码应用)
- 代码实现:
- 对数器:
- 3.4 题目案例四:项目投资(IPO问题)
- 3.5 题目五:放置路灯问题
1. 引言:什么是贪心算法?
贪心算法(Greedy Algorithm)是一种在每一步决策中都采取当前状态下最好或最优(即最有利)的选择,从而希望导致结果是全局最好或最优的算法策略。
贪心算法的核心思想是“目光短浅”,它不从整体最优上加以考虑,而是只做出在当前看来是最好的选择。这种策略并不总是能得到全局最优解,但对于许多问题,它却能产生全局最优解,或者至少是一个可接受的近似解。
2. 贪心算法的实战思路:策略与验证
在解决算法问题时,贪心策略因其高效和直观而备受青睐。但其最大的难点在于证明其正确性。在实际工程或竞赛中,我们往往采用一种更务实的方法:
- 贪心策略的选择
首先,我们需要根据问题情境,构思出一个局部的最优解。例如:
- 在安排会议时,是优先选择开始早的,还是结束早的?
- 在拼接字符串时,是按字典序排,还是按其他规则?
选定一个策略后,我们先假设它是正确的,并着手实现。
- “对数器”:验证贪心策略
与其陷入复杂的数学归纳法来证明贪心策略,不如实现一个“对数器”来验证其正确性(或者用一个绝对正确但效率低的方法)。
“对数器”本质上是一个暴力解法。这个解法必须保证绝对正确,但可以不考虑时间复杂度(例如,使用全排列或递归搜索)。
如在接下来的代码中所示,验证步骤如下:
- 实现我们的贪心算法(例如
greedy方法)。 - 实现一个绝对正确但低效的暴力解法(例如
brute方法)。 - 编写一个随机样本生成器(例如
randomPrograms)。 - 进行成千上万次测试:用同一个随机样本,分别调用贪心解法和暴力解法,对比它们的结果。
- 如果所有测试结果都一致,我们就可以在工程上高度确信这个贪心策略是正确的。
接下来,我们将解析贪心策略的应用。
3. 贪心算法题目解析
3.1 题目一:会议室宣讲(活动安排问题)
问题描述:
给定一系列项目的开始时间和结束时间,一个会议室同一时间只能安排一个项目。求如何安排才能使得宣讲的场次最多。
贪心策略:
这个问题的核心贪心策略是:优先安排结束时间最早的项目。
直观理解是,一个项目结束得越早,会议室就能越早被释放出来,从而为后续安排更多项目提供可能性。
实现细节:
我们使用一个“小根堆”(PriorityQueue)来存储所有项目,并自定义比较器,使其按照项目的结束时间 end 从小到大排序。
我们维护一个变量 curTime,表示当前会议室可用的最早时间点(初始为0)。
- 不断从小根堆中弹出结束时间最早的项目
poll。 - 如果该项目的开始时间
poll.start大于等于curTime,说明这个项目可以被安排。 - 安排该项目后,更新
curTime为该项目的结束时间poll.end,并将场次sum加一。 - 如果项目开始时间早于
curTime,则跳过该项目(因为它与已安排的项目冲突)。
代码实现:
public class ProgramArrange {public static class Program {public int start;public int end;public Program(int start, int end) {this.start = start;this.end = end;}}// ============= 贪心策略 ==============public static int greedy(Program[] arr) {if (arr == null || arr.length == 0) {return 0;}PriorityQueue<Program> pq = new PriorityQueue<>(new ProgramComparator());pq.addAll(Arrays.asList(arr));int sum = 0;int curTime = 0;while(!pq.isEmpty()) {Program poll = pq.poll();if (poll.start >= curTime) {sum++;curTime = poll.end;}}return sum;}public static class ProgramComparator implements Comparator<Program> {@Overridepublic int compare(Program o1, Program o2) {return o1.end - o2.end;}}}
对数器:
暴力解法思路: 为了找到字典序最小的拼接结果,暴力解法会尝试所有可能的字符串排列组合。它使用递归(process 方法)来生成全排列。getArrWithoutI 辅助函数用于在递归中获取“剩余”的字符串数组。最后,它使用 TreeSet 数据结构,该结构会自动按字典序排序所有结果,我们只需取出第一个(最小的)即可。
/*** 暴力方法解决*/
public static String brute(String[] arr) {if (arr == null || arr.length == 0) {return "";}TreeSet<String> rst = process(arr);return rst.first();
}// 方法作用:返回arr字符串数组中所有可能的排列方式
public static TreeSet<String> process(String[] arr) {TreeSet<String> rst = new TreeSet<>();if (arr == null || arr.length == 0) {rst.add("");return rst;}for (int i = 0; i < arr.length; i++) {String first = arr[i];TreeSet<String> rest = process(getArrWithoutI(arr, i));for (String str : rest) {rst.add(first + str);}}return rst;
}public static String[] getArrWithoutI(String[] arr, int idx) {if (arr == null || arr.length == 0) return null;String[] rst = new String[arr.length - 1];int rstIdx = 0;for (int i = 0; i < arr.length; i++) {if (i == idx) {continue;}rst[rstIdx++] = arr[i];}return rst;
}
3.2 题目二:最低字典序拼接
问题描述:
给定一个由字符串组成的数组,必须把所有字符串拼接起来,返回所有可能的拼接结果中字典序最小的结果。
贪心策略:
此问题的贪心策略体现在排序规则上。我们不能简单地按单个字符串的字典序排序。
正确的贪心策略是:对于任意两个字符串 o1 和 o2,如果 (o1 + o2) 的字典序小于 (o2 + o1),那么 o1 就应该排在 o2 的前面。
实现细节:
- 自定义一个比较器
MyComparator。 - 在
compare方法中,返回(o1 + o2).compareTo(o2 + o1)的结果。 - 使用
Arrays.sort和这个自定义比较器对原数组进行排序。 - 最后,将排序后的字符串依次拼接起来即可。
代码实现:
public class LowestLexicography {// 比较器public static class MyComparator implements Comparator<String> {@Overridepublic int compare(String o1, String o2) {return (o1 + o2).compareTo(o2 + o1);}}// 贪心方法public static String lowestLexicography(String[] arr) {if (arr == null || arr.length == 0) return "";Arrays.sort(arr, new MyComparator());StringBuilder rst = new StringBuilder();for (String curr : arr) {rst.append(curr);}return rst.toString();}}
对数器:
暴力解法思路: 同样使用递归(process 方法)。process 方法的含义是:在 programs 数组中,从 curTime 这个时间点开始,最多还能安排 done 场会议。 它遍历所有剩余的会议,尝试两种选择:
- 不安排当前会议(通过循环到下一个
i实现)。 - 如果当前会议
programs[i]的开始时间晚于curTime,则安排它 (done + 1),并从这个会议的结束时间 (programs[i].end) 开始,递归地去安排剩下的会议。 最后返回所有可能性中的最大值max。
public static int brute(Program[] arr) {if (arr == null || arr.length == 0) {return 0;}return process(arr, 0, 0);
}// 方法作用:返回能安排的最多的会议
// 还剩下的会议都放在programs里
// done之前已经安排了多少会议的数量
// curTime目前来到的时间点是什么
public static int process(Program[] programs, int done, int curTime) {if (programs.length == 0) {return done;}int max = done;for (int i = 0; i < programs.length; i++) {if (programs[i].start >= curTime) {max = Math.max(max, process(restPrograms(programs, i), done + 1, programs[i].end));}}return max;
}public static Program[] restPrograms(Program[] arr, int idx) {Program[] rst = new Program[arr.length - 1];int rstIdx = 0;for (int i = 0; i < arr.length; i++) {if (i == idx) {continue;}rst[rstIdx++] = arr[i];}return rst;
}
3.3 题目三:金条分割(哈夫曼编码应用)
问题描述:
一块金条切成两半,需要花费和长度数值一样的铜板。给定一个数组,代表最终要分成的几部分。求如何分割能使总花费(铜板)最少。
贪心策略:
这个问题可以逆向思考:看作是将几块小金条合并成一块大金条,每次合并的代价是两块金条的长度之和。
这完全符合哈夫曼编码的思想。贪心策略是:永远优先合并当前最小的两块金条。
实现细节:
- 使用一个小根堆(
PriorityQueue)。 - 将所有目标长度(数组元素)加入小根堆。
- 当堆中元素多于一个时,循环执行:
- 弹出堆顶两个最小元素(
pq.poll()两次)。 - 将这两个元素相加得到
cur,这就是本次合并的代价。 - 将
cur累加到总代价cost中。 - 将
cur(合并后的新长度)重新放回小根堆。
- 弹出堆顶两个最小元素(
- 循环结束时,
cost即为最小总代价。
代码实现:
public class LessMoneySplitGold {// =========== 贪心 ===========public static int huffmanSolution(int[] gold) {if (gold == null || gold.length == 0) {return 0;}PriorityQueue<Integer> pq = new PriorityQueue<>();int cost = 0;for (int i = 0; i < gold.length; i++) {pq.add(gold[i]);}while (pq.size() > 1) {int cur = pq.poll() + pq.poll();cost += cur;pq.add(cur);}return cost;}}
对数器:
暴力解法思路: 这个问题(哈夫曼编码问题)的暴力解法是:尝试所有可能的合并顺序。 process 方法递归地尝试合并 arr 数组中的任意两个元素(i 和 j)。每次合并,都会产生 arr[i] + arr[j] 的代价,这个代价被累加到 pre 中。然后,它将合并后的新数组(通过 mergeResult 获得)传入下一层递归。 ans 变量会取所有不同合并路径下的最小值,最终返回全局最小代价。
public static int brute(int[] gold) {if (gold == null || gold.length == 0) {return 0;}return process(gold, 0);
}// 作用:返回合并arr最小的总代价
// 等待合并的数都在arr里,pre之前的合并行为产生了多少总代价
// arr中只剩一个数字的时候,停止合并
public static int process(int[] arr, int pre) {if (arr.length == 1) return pre;// 依次遍历,把每两个都合并一遍,看看哪个最小int ans = Integer.MAX_VALUE;for (int i = 0; i < arr.length; i++) {for (int j = i + 1; j < arr.length; j++) {ans = Math.min(ans, process(mergeResult(arr, i, j), pre + arr[i] + arr[j]));}}return ans;
}public static int[] mergeResult(int[] arr, int a, int b) {int[] rst = new int[arr.length - 1];int sum = arr[a] + arr[b];for (int i = 0, idx = 0; i < arr.length; i++) {if (i == a || i == b) {continue;}rst[idx++] = arr[i];}rst[rst.length - 1] = sum;return rst;
}
3.4 题目案例四:项目投资(IPO问题)
问题描述:
给定项目的花费数组 costs、利润数组 profits、最多可做项目数 K 和初始资金 M。串行做项目,收益可以用于下一个项目。求最后获得的最大钱数。
贪心策略:
这是一个复合贪心策略。我们最多只能做 K 个项目,在每一步(选择第1个、第2个…第K个项目)时,我们都应该做出最优选择。
最优选择是:在所有当前资金 M 允许(cost <= M)的项目中,选择利润 profit 最高的那个项目来做。
实现细节:
为了高效实现这个策略,我们使用两个堆:
- 成本小根堆 (
small):按cost排序。存放所有“目前还买不起”或“还未解锁”的项目。 - 利润大根堆 (
big):按profit排序。存放所有“已经买得起”(cost <= M)的项目。
流程如下:
-
将所有项目按成本加入成本小根堆
small。 -
循环 K 次(或直到无法再做项目):
a. [解锁] 将 small 堆中所有 cost <= M 的项目,全部“解锁”并移入 big 堆(利润大根堆)。
b. [选择] 如果此时 big 堆为空,说明当前资金买不起任何新项目,提前结束。
c. [执行] 从 big 堆弹出一个项目(即利润最高的项目),将其利润加到 M 上。
-
返回最终的
M。
代码实现:
public class IPO {// ============== 贪心 =============public static class Program {public int cost;public int profit;public Program(int cost, int profit) {this.cost = cost;this.profit = profit;}}public static int greedy(int[] costs, int[] profits, int K, int M) {PriorityQueue<Program> small = new PriorityQueue<>(new MinCostComparator());PriorityQueue<Program> big = new PriorityQueue<>(new MaxProfitComparator());int length = costs.length;for (int i = 0; i < length; i++) {small.add(new Program(costs[i], profits[i]));}int count = 0;while (count < K || !small.isEmpty() || !big.isEmpty()) {while (!small.isEmpty() && small.peek().cost <= M) {Program poll = small.poll();big.add(poll);}if (!big.isEmpty()) {Program curProgram = big.poll();M += curProgram.profit;count++;} else {break;}}return M;}public static class MinCostComparator implements Comparator<Program> {@Overridepublic int compare(Program o1, Program o2) {return o1.cost - o2.cost;}}public static class MaxProfitComparator implements Comparator<Program> {@Overridepublic int compare(Program o1, Program o2) {return o2.profit - o1.profit;}}
}
3.5 题目五:放置路灯问题
问题描述:
给定一个由 X(墙)和 .(居民点)组成的字符串。X 不需点亮,. 需点亮。灯放在 i 位置,可以点亮 i-1, i, i+1。求点亮所有 . 所需的最少灯数。
贪心策略:
我们从左到右遍历字符串。当遇到一个 .(居民点)时,这个点必须被照亮。我们总希望一盏灯能发挥最大效率。
- 当
i位置是.时,我们必须放一盏灯。 - 为了让这盏灯覆盖尽可能多的新区域(即右侧),我们优先考虑放在
i+1位置。- 情况A: 如果
i+1也是.(且未越界),我们就把灯放在i+1。这盏灯能覆盖i,i+1,i+2。因此,我们下一个需要检查的位置是i+3。 - 情况B: 如果
i+1是X,或者i已经是最后一个字符,我们别无选择,灯只能放在i位置。这盏灯覆盖i-1,i,i+1。因此,我们下一个需要检查的位置是i+2。
- 情况A: 如果
- 如果
i位置是X,则跳过。
代码实现:
public class LightProblem {// ======== 贪心 =========public static int light(String str) {if (str == null) {return 0;}char[] arr = str.toCharArray();int light = 0;int i = 0;while (i < arr.length) {if (arr[i] == 'X') {i++;} else {light++;if (i + 1 == arr.length) {break;} else {if (arr[i + 1] == 'X') {i = i + 2;} else {i = i + 3;}}}}return light;}// ... [对数器和其他方法省略] ...
}
