序
一个贪心算法总是做出当前最好的选择,也就是说,它期望通过局部最优选择从而得到全局最优的解决方案——《算法导论》
一、概念
贪心算法(又称贪婪算法)是指,在对问题求解时,总是做出在当前看来是最好的选择。也就是说,不从整体最优上加以考虑,他所做出的是在某种意义上的局部最优解。
贪心算法不是对所有问题都能得到整体最优解,关键是贪心策略的选择,选择的贪心策略必须具备无后效性,即某个状态以前的过程不会影响以后的状态,只与当前状态有关。
二、贪心思想
- 贪心选择:所谓贪心选择性质是指原问题的整体最优解可以通过一系列局部最优的选择得到,应用同一规则,将原问题变为一个相似的但规模更小的子问题,而后的每一步都是当前最佳的选择,这种选择依赖于已做出的选择,但不依赖未做出的选择,也就是前面说的选择的贪心策略必须具备无后效性。
- 最优子结构:当一个问题的最优解包含其子问题的最优解时,称此问题具备最优子结构性质。运用贪心策略在每一次转化时都取得最优解。问题的最优子结构性质是该问题能否用贪心算法或者动态规划求解的关键特征。贪心算法每一次操作都对结果产生直接影响,每个子问题的解决方案不能回退,而动态规划有回退功能。动态规划主要运用于二维或三维问题,而贪心一般是一维问题。
- 贪心算法求解步骤
- 确定贪心策略
- 根据贪心策略,一步步得到局部最优解
- 合并局部最优解得到全局最优解
例如冒泡排序就使用了贪心算法:每次选择懵逼队列人数最多的放到最右边,局部最优从而全局最优。
三、应用示例
1、最优装载
问题:某艘船的载重量为C,第 i 件物品的重量为Wi,目的要将尽可能多的物品装到船上
分析:贪心策略,每次选择最轻的,然后再从剩下的n-1件物品中选择最轻的
package Greedy;
import java.util.Arrays;
public class OpticalPacking {
public static int opticalPack(int[] packs, int C) {
Arrays.sort(packs);
int curW = 0;
int count = 0;
for (int w : packs) {
curW += w;
if (curW > C) {
return count;
}
count++;
}
return count;
}
public static void main(String[] args) {
int[] packs = {4, 10, 7, 11, 3, 5, 14, 2};
System.out.println(opticalPack(packs, 30));
}
}
2、背包问题
问题:n种宝物,每种宝物有重量w和价值v,毛驴载重c,一种宝物只能拿一样,宝物可以分割,如何运走最大价值的宝物?
分析:贪心策略,如果选重量最小的,价值不一定大,而价值大重量可能也大,每次选单位重量价值最高的,直到装不下为止
package Greedy;
import java.util.*;
public class Packing {
public static double packingSum(int[] w, int[] v, int C) {
// 维护单价-重量的映射关系
HashMap<Double, Integer> map = new HashMap<>();
for (int i = 0; i < w.length; i++) {
double tmp = (double) v[i] / w[i];
map.put(tmp, w[i]);
}
List<Double> lst = new ArrayList<>(map.keySet());
// 对单价进行排序
Collections.sort(lst, new Comparator<Double>() {
@Override
public int compare(Double o1, Double o2) {
if (o1 < o2) {
return 1;
} else if (o1 == o2) {
return 0;
} else {
return -1;
}
}
});
double sum = 0;
// 由大到小遍历
for (Double d : lst) {
// 如果剩余重量小于当前重量
if (map.get(d) > C) {
// 剩余重量乘以单价,切割宝物
sum += d * C;
break;
// 如果剩余重量大于等于当前重量,直接装
} else {
sum += d * map.get(d);
C -= map.get(d);
}
}
return sum;
}
public static void main(String[] args) {
int[] w = {4, 2, 9, 5, 5, 8, 5, 4, 5, 5};
int[] v = {3, 8, 18, 6, 8, 20, 5, 6, 7, 15};
System.out.println(packingSum(w, v, 30));
}
}
3、任务调度(621. Task Scheduler)
分析:贪心策略,优先队列,词频由大到小排列,取出n+1个或者队列中全部(若没有取出全部,则总长度要加上空闲个数),再把词频-1之后不为0的放回队列中。直到队列空了为止
package Greedy;
import java.util.*;
public class TaskScheduler {
public static int leastInterval(char[] tasks, int n) {
HashMap<Character, Integer> map = new HashMap<>();
for (char c : tasks) {
map.put(c, map.getOrDefault(c, 0) + 1);
}
// 优先队列,词频由大到小排列
PriorityQueue<Map.Entry<Character, Integer>> p = new PriorityQueue<>((a, b) -> b.getValue() - a.getValue());
p.addAll(map.entrySet());
int length = 0;
while (!p.isEmpty()) {
List<Map.Entry<Character, Integer>> list = new ArrayList<>();
int count = n+1;
while (count > 0 && !p.isEmpty()) {
Map.Entry<Character, Integer> entry = p.poll();
entry.setValue(entry.getValue() - 1);
list.add(entry);
count--;
length++;
}
for (Map.Entry<Character, Integer> entry : list) {
if (entry.getValue() > 0) {
p.offer(entry);
}
}
if (p.isEmpty()) {
break;
}
length += count;
}
return length;
}
public static void main(String[] args) {
char[] tasks = {'A', 'A', 'A', 'B', 'B', 'B'};
System.out.println(leastInterval(tasks, 2));
}
}
另一种算法:最大词频为Max,则总长度为 (Max-1)*(n+1)+(词频为Max的字符个数-1),再与tasks长度取二者最大值。
class Solution {
public int leastInterval(char[] tasks, int n) {
int[] taskCounts=new int[26];
for (char c:tasks){
taskCounts[c-'A']++;
}
int Max=0;
for (int k=0;k<taskCounts.length;k++){
if (taskCounts[k]>Max){
Max=taskCounts[k];
}
}
int res=(Max-1)*(n+1);
for (int k=0;k<taskCounts.length;k++){
if (taskCounts[k]==Max){
res++;
}
}
// 特殊情况 ABCABCABAB n=0或1
return Math.max(tasks.length,res);
}
}
4、最短路径 Dijkstra 迪杰斯特拉 算法(传送门)
5、哈夫曼编码 (传送门)
贪心策略是:每次从树的集合中取出没有双亲且权值最小的两棵树作为左右子树,并合并他们
6、最下生成树 (传送门)
7、分发糖果 (传送门)
贪心策略:每次选取最小的数,赋相应的值
class Solution {
public int candy(int[] ratings) {
// 优先队列维护最小堆
PriorityQueue<Integer> p = new PriorityQueue<>();
for (int i : ratings) {
if (!p.contains(i)) {
p.offer(i);
}
}
// 初始化糖果 -1
int[] counts = new int[ratings.length];
Arrays.fill(counts, -1);
while (!p.isEmpty()) {
int tmp = p.poll();
for (int i = 0; i < ratings.length; i++) {
if (ratings[i] == tmp) {
int max = 0;
// 看邻居,比大小
if (i - 1 >= 0 && ratings[i - 1] < ratings[i]) {
max = Math.max(max, counts[i - 1]);
}
if (i + 1 < ratings.length && ratings[i + 1] < ratings[i]) {
max = Math.max(max, counts[i + 1]);
}
counts[i] = max + 1;
}
}
}
// 计算总值
int res = 0;
for (int i : counts) {
res += i;
}
return res;
}
}
class Solution {
public int candy(int[] ratings) {
// 上面的方法,Time Limit Exceeded,应该是优先队列遍历的时间复杂度问题
HashSet<Integer> set = new HashSet<>();
for (int i : ratings) {
set.add(i);
}
List<Integer> list = new ArrayList<>(set);
Collections.sort(list);
int[] counts = new int[ratings.length];
Arrays.fill(counts, -1);
for (int tmp : list) {
for (int i = 0; i < ratings.length; i++) {
if (ratings[i] == tmp) {
int max = 0;
if (i - 1 >= 0 && ratings[i - 1] < ratings[i]) {
max = Math.max(max, counts[i - 1]);
}
if (i + 1 < ratings.length && ratings[i + 1] < ratings[i]) {
max = Math.max(max, counts[i + 1]);
}
counts[i] = max + 1;
}
}
}
int res = 0;
for (int i : counts) {
res += i;
}
return res;
}
}
下面的方法应该是相对最简单的,遍历两次即可
class Solution {
public int candy(int[] ratings) {
int n = ratings.length;
int[] nums = new int[n];
Arrays.fill(nums, 1);
for(int i = 1; i < n; i++) {
if(ratings[i - 1] < ratings[i])
nums[i] = nums[i - 1] + 1;
}
int res = nums[n - 1];
for(int i = n - 2; i >= 0; i--) {
if(ratings[i] > ratings[i + 1])
nums[i] = Math.max(nums[i + 1] + 1, nums[i]);
res += nums[i];
}
return res;
}
}
8、区间覆盖
Non-overlapping 贪心策略:每次比较当前区间左侧与上一区间的右侧,如果不相交,继续;如果相交,res++,并且取两个区间右侧最小的区间作为当前区间(为了尽可能减少和下一区间相交的可能性),题解如下
class Solution {
public int eraseOverlapIntervals(int[][] intervals) {
if (intervals.length<=1){
return 0;
}
Arrays.sort(intervals,(a,b)->{return a[0]-b[0];});
int cur=0;
int res=0;
for (int i=1;i<intervals.length;i++){
// 如果上一区间cur,右侧小于当前区间i的左侧,则无相交,继续
if (intervals[i][0]>=intervals[cur][1]){
cur=i;
}
// 否则有相交,需要删除
else {
// 计数+1
res++;
// 每次删除,右侧区间较大的,留下右侧区间小的,尽可能的减少和下一区间相交可能性
if (intervals[i][1]<intervals[cur][1]){
cur=i;
}
}
}
return res;
}
}
Partition Labels 贪心策略:遍历字符,last变量维护遍历过的字符的最后出现的下标,如果当前下标就是last,说明起点至当前这段字符,是可以划分的最小值。
class Solution {
public List<Integer> partitionLabels(String S) {
// 计算每个单词最后出现的index
Map<Character, Integer> map = new HashMap<>();
for (int i = 0; i < S.length(); i++) {
map.put(S.charAt(i), S.lastIndexOf(S.charAt(i)));
}
int start = 0;
int last = 0;
List<Integer> res = new ArrayList<>();
for (int k = 0; k < S.length(); k++) {
// 维护遍历过的所有单词,最后出现的index
last = Math.max(last, map.get(S.charAt(k)));
// 如果前面所有单词最后出现的index,就是当前,说明当前可以划分一个组(最小组)
if (k == last) {
// 结果入res
res.add(k - start + 1);
// 维护起点
start = k + 1;
}
}
return res;
}
}
9、Best Time to Buy and Sell Stock
贪心策略:维护一个minPrice,初始化为prices[0],遍历数组,更新minPrice,Math.max(max, prices[i]-minPrice)
class Solution {
public int maxProfit(int[] prices) {
// 这种方法复杂度较下一种更小
if (prices.length==0){
return 0;
}
int benefit = 0;
int minPrice = prices[0];
for (int i : prices) {
minPrice = Math.min(minPrice, i);
benefit = Math.max(benefit, i - minPrice);
}
return benefit;
}
}
采用队列的方法,维护非递减队列,更新benefit
class Solution {
public int maxProfit(int[] prices) {
// 利用队列
Deque<Integer> d = new LinkedList<>();
int benefit = 0;
for (int i : prices) {
while (!d.isEmpty() && i < d.peekLast()) {
d.pollLast();
}
d.offer(i);
if (!d.isEmpty()) {
benefit = Math.max(benefit, d.peekLast() - d.peekFirst());
}
}
return benefit;
}
}
贪心策略:只需判断price[i] - price[i-1] > 0 即可,将此差值加入最终盈利中
class Solution {
public int maxProfit(int[] prices) {
int benefit = 0;
if (prices.length < 2) {
return 0;
}
// 判断差值是否为正即可
for (int i = 1; i < prices.length; i++) {
benefit += prices[i] - prices[i - 1] > 0 ? prices[i] - prices[i - 1] : 0;
}
return benefit;
}
}
采用队列的方法,维护非递减队列,更新benefit
class Solution {
public int maxProfit(int[] prices) {
Deque<Integer> d = new LinkedList<>();
int benefit = 0;
for (int i : prices) {
if (!d.isEmpty() && i < d.peekLast()) {
benefit += d.peekLast() - d.peekFirst();
d.clear();
}
d.offer(i);
}
if (!d.isEmpty()) {
benefit += d.peekLast() - d.peekFirst();
}
return benefit;
}
}
贪心策略:最低的价格买入,最高的价格卖出,两次有前后顺序
class Solution {
public int maxProfit(int[] prices) {
int buy1 = Integer.MIN_VALUE;
int sell1 = 0;
int buy2 = Integer.MIN_VALUE;
int sell2 = 0;
for (int i : prices) {
// 最低的价格买入
buy1 = Math.max(buy1, -i);
// 最高的价格卖出
sell1 = Math.max(sell1, buy1 + i);
// 第二次最低的价格买入
buy2 = Math.max(buy2, sell1 - i);
// 第二次最高的价格卖出
sell2 = Math.max(sell2, buy2 + i);
}
return sell2;
}
}
分成两个子序列,每个子序列采用系列1的方法求解,在取和最大值即可,不过这种方法在LeetCode上面超时了,复杂度太大
class Solution {
public int maxProfit(int[] prices) {
int benefit = 0;
for (int i = 0; i < prices.length; i++) {
benefit = Math.max(benefit, maxProfittmp(Arrays.copyOfRange(prices, 0, i)) + maxProfittmp(Arrays.copyOfRange(prices, i, prices.length)));
}
return benefit;
}
public int maxProfittmp(int[] prices) {
Deque<Integer> d = new LinkedList<>();
int benefit = 0;
for (int i : prices) {
while (!d.isEmpty() && i < d.peekLast()) {
d.pollLast();
}
d.offer(i);
if (!d.isEmpty()) {
benefit = Math.max(benefit, d.peekLast() - d.peekFirst());
}
}
return benefit;
}
}
class Solution {
public int maxProfit(int[] prices) {
if (prices.length==0){
return 0;
}
int[] buy = new int[prices.length + 1];
int[] sell = new int[prices.length + 1];
buy[1] = -prices[0];
for (int i = 2; i <= prices.length; i++) {
buy[i] = Math.max(sell[i - 2] - prices[i - 1], buy[i - 1]);
sell[i] = Math.max(sell[i - 1], buy[i - 1] + prices[i - 1]);
}
return sell[prices.length];
}
}
贪心策略:维护两个状态,s0表示没有库存,s1表示有库存,遍历更新即可
class Solution {
public int maxProfit(int[] prices, int fee) {
int s0 = 0;
int s1 = Integer.MIN_VALUE;
for (int i : prices) {
int tmp = s0;
s0 = Math.max(s0, s1 + i);
s1 = Math.max(s1, tmp - fee - i);
}
return s0;
}
}
还有很多经典例子,不断学习更新中