貪心、分治
貪心(Greedy)
貪心策略,也稱爲貪婪策略
- 每一步都採取當前狀態下最優的選擇(局部最優解),從而希望推導出全局最優解
貪心的應用
- 哈夫曼樹
- 最小生成樹算法:Prim、Kruskal
- 最短路徑算法:Dijkstra
問題1:最優裝載(加勒比海盜)
貪心策略:每一次都優先選擇重量最小的古董
- ① 選擇重量爲 2 的古董,剩重量 28
② 選擇重量爲 3 的古董,剩重量 25
③ 選擇重量爲 4 的古董,剩重量 21
④ 選擇重量爲 5 的古董,剩重量 16
⑤ 選擇重量爲 7 的古董,剩重量 9 - 根據上面的選擇,最多能裝載 5 個古董
import java.util.Arrays;
/**
* 有一天,海盜們截獲了一艘裝滿各種各樣古董的貨船,每一件古董都價值連城,一旦打碎就失去了它的價值
* 海盜船的載重量爲 W,每件古董的重量爲 𝑤i,海盜們該如何把儘可能多數量的古董裝上海盜船?
* 比如 W 爲 30,Wi 分別爲 3、5、4、10、7、14、2、11
*/
public class Pirate {
public static void main(String[] args) {
int[] weights = {3, 5, 4, 10, 7, 14, 2, 11};
Arrays.sort(weights); // 排序, 默認從小到大
int capacity = 30; // 最大容量
int weight = 0, count = 0;
// 每次優先選擇重量最小的古董
for (int i = 0; i < weights.length && weight < capacity; i++) {
int newWeight = weights[i] + weight;
if (newWeight <= capacity) {
weight = newWeight;
count++;
// System.out.println(weights[i]);
}
}
// System.out.println("裝了 " + count + " 件古董。");
}
}
2
3
4
5
7
裝了 5 件古董。
問題2:零錢兌換
假設有 25 分、10 分、5 分、1 分的硬幣,現要找給客戶 41 分的零錢,如何辦到硬幣個數最少?
貪心策略:每一次都優先選擇面值最大的硬幣
- ① 選擇 25 分的硬幣,剩 16 分
② 選擇 10 分的硬幣,剩 6 分
③ 選擇 5 分的硬幣,剩 1 分
④ 選擇 1 分的硬幣 - 最終的解是共 4 枚硬幣
25 分、10 分、5 分、1 分硬幣各一枚
import java.util.Arrays;
/**
* 假設有 25 分、10 分、5 分、1 分的硬幣,
* 現要找給客戶 41 分的零錢,如何辦到硬幣個數最少?
*/
public class CoinChange {
public static void main(String[] args) {
// 三種寫法, 結果是一樣的
coinChange1(new Integer[] {25, 10, 5, 1}, 41);
coinChange2(new Integer[] {25, 10, 5, 1}, 41);
coinChange3(new Integer[] {25, 10, 5, 1}, 41);
}
// 寫法1
static void coinChange1(Integer[] faces, int money) {
Arrays.sort(faces); // 排序, 默認從小到大
int coins = 0;
// 貪心策略, 選擇面值最大的硬幣, 由於順序小->大, 從後往前放
for (int i = faces.length - 1; i >= 0; i--) {
// 如果面值比我要的錢大, 進行下一輪
if (money < faces[i]) continue;
// System.out.println(faces[i]);
money -= faces[i];
coins++;
i = faces.length;
}
// System.out.println("使用了" + coins + "個硬幣。");
}
// 寫法2
static void coinChange2(Integer[] faces, int money) {
// 排序, 傳入了比較器, 所以是從大到小排序
Arrays.sort(faces, (Integer f1, Integer f2) -> f2 - f1);
// 貪心策略, 選擇面值最大的硬幣, 由於順序大->小, 從前往後放
int coins = 0, i = 0;
while (i < faces.length) {
if (money < faces[i]) {
i++;
continue;
}
// System.out.println(faces[i]);
money -= faces[i];
coins++;
// i = 0; // 這步是不需要的
}
// System.out.println("使用了" + coins + "個硬幣。");
}
// 寫法3
static void coinChange3(Integer[] faces, int money) {
Arrays.sort(faces);
// 貪心策略, 選擇面值最大的硬幣, 由於順序小->大, 從後往前放
int coins = 0, idx = faces.length - 1;
while (idx >= 0) {
while (money >= faces[idx]){
// System.out.println(faces[idx]);
money -= faces[idx];
coins++;
}
idx--;
}
// System.out.println("使用了" + coins + "個硬幣。");
}
}
25
10
5
1
使用了4個硬幣。
零錢兌換的另一個例子
將之前的代碼的輸入修改一下,可以得出結果:發現確實沒有得到最優解。
public static void main(String[] args) {
coinChange1(new Integer[] {25, 10, 5, 1}, 41);
coinChange2(new Integer[] {25, 10, 5, 1}, 41);
coinChange3(new Integer[] {25, 20, 5, 1}, 41);
}
25
5
5
5
1
使用了5個硬幣。
貪心注意點
貪心策略並不一定能得到全局最優解
- 因爲一般沒有測試所有可能的解,容易過早做決定,所以沒法達到最佳解
- 貪圖眼前局部的利益最大化,看不到長遠未來,走一步看一步
優點:簡單、高效、不需要窮舉所有可能,通常作爲其他算法的輔助算法來使用
缺點:鼠目寸光,不從整體上考慮其他可能,每次採取局部最優解,不會再回溯,因此很少情況會得到最優解
問題3:0-1揹包
0-1 揹包 - 實例
Article類 模擬物品:
package com.mj.ks;
public class Article {
int weight; // 重量
int value; // 價值
double valueDensity; // 價值密度
public Article(int weight, int value) {
this.weight = weight;
this.value = value;
valueDensity = value * 1.0 / weight;
}
@Override
public String toString() {
return "Article [weight=" + weight + ", value=" + value + ", ValueDensity=" + valueDensity + "]";
}
}
0-1 揹包問題:分別按照價值主導、重量主導、價值密度主導解決。
package com.mj.ks;
import java.util.Arrays;
import java.util.Comparator;
import java.util.LinkedList;
import java.util.List;
/**
* 0-1 揹包問題
* @author yusael
*/
public class Knapsack {
public static void main(String[] args) {
select("價值主導", (Article a1, Article a2) -> {
// 價值大的優先
return a2.value - a1.value;
});
select("重量主導", (Article a1, Article a2) -> {
// 重量小的優先
return a1.weight - a2.weight;
});
select("價值密度主導", (Article a1, Article a2) -> {
// 價值密度大的優先
return Double.compare(a2.valueDensity, a1.valueDensity);
});
}
/**
* 以一個屬性爲主導實現貪心策略
* @param title 顯示標題
* @param cmp 比較器決定主導屬性, [價值、重量、價值密度]
*/
static void select(String title, Comparator<Article> cmp) {
// 模擬題意的物品
Article[] articles = new Article[] {
new Article(35, 10), new Article(30, 40),
new Article(60, 30), new Article(50, 50),
new Article(40, 35), new Article(10, 40),
new Article(25, 30)
};
// 通過比較器, 按某個主導屬性進行排序
Arrays.sort(articles, cmp);
// 以某個屬性爲主導, 實現貪心策略
int capacity = 150, weight = 0, value = 0;
List<Article> selectedArticles = new LinkedList<Article>(); // 選擇的物品集合
for (int i = 0; i < articles.length && weight < capacity; i++) {
int newWeight = weight + articles[i].weight;
if (newWeight <= capacity) {
weight = newWeight;
value += articles[i].value;
selectedArticles.add(articles[i]);
}
}
System.out.println("-----------------------------");
System.out.println("【" + title + "】");
System.out.println("總價值: " + value);
for (Article article : selectedArticles) {
System.out.println(article);
}
}
}
-----------------------------
【價值主導】
總價值: 165
Article [weight=50, value=50, ValueDensity=1.0]
Article [weight=30, value=40, ValueDensity=1.3333333333333333]
Article [weight=10, value=40, ValueDensity=4.0]
Article [weight=40, value=35, ValueDensity=0.875]
-----------------------------
【重量主導】
總價值: 155
Article [weight=10, value=40, ValueDensity=4.0]
Article [weight=25, value=30, ValueDensity=1.2]
Article [weight=30, value=40, ValueDensity=1.3333333333333333]
Article [weight=35, value=10, ValueDensity=0.2857142857142857]
Article [weight=40, value=35, ValueDensity=0.875]
-----------------------------
【價值密度主導】
總價值: 170
Article [weight=10, value=40, ValueDensity=4.0]
Article [weight=30, value=40, ValueDensity=1.3333333333333333]
Article [weight=25, value=30, ValueDensity=1.2]
Article [weight=50, value=50, ValueDensity=1.0]
Article [weight=35, value=10, ValueDensity=0.2857142857142857]
一些習題
分發餅乾
用最少數量的箭引爆氣球
買賣股票的最佳時機 II
種花問題
分發糖果
分治(Divide And Conquer)
分治,也就是分而治之。它的一般步驟是:
- ① 將原問題分解成若干個規模較小的子問題(子問題和原問題的結構一樣,只是規模不一樣)
② 子問題又不斷分解成規模更小的子問題,直到不能再分解(直到可以輕易計算出子問題的解)
③ 利用子問題的解推導出原問題的解
因此,分治策略非常適合用遞歸。
需要注意的是:子問題之間是相互獨立的。
分治的應用:
- 快速排序
- 歸併排序
- Karatsuba 算法(大數乘法)
主定理(Master Theorem)
問題1:最大連續子序列和
給定一個長度爲 n 的整數序列,求它的最大連續子序列和
- 比如 –2、1、–3、4、–1、2、1、–5、4 的最大連續子序列和是 4 + (–1) + 2 + 1 = 6
這道題也屬於最大切片問題(最大區段,Greatest Slice)
概念區分
- 子串、子數組、子區間:必須是連續的
- 子序列:可以不連續
解法1 – 暴力出奇跡
窮舉出所有可能的連續子序列,分別計算出它們的和,最後取它們中的最大值。
時間複雜度:O(n3),空間複雜度:O(1)
/**
* 暴力
*/
static int maxSubArray(int [] nums) {
if (nums == null || nums.length == 0) return 0;
// 這裏注意, 容易寫成 int max = 0, 可能會出錯, max 默認值必須是最小的值
int max = Integer.MIN_VALUE;
// 窮舉, 列出所有可能的連續子序列, 分別計算它們的和, 最後取出最大值
for (int begin = 0; begin < nums.length; begin++) {
for (int end = begin; end < nums.length; end++) {
int sum = 0; // sum是[begin, end]的和
// nums[begin] 到 nums[end] 求和
for (int i = begin; i <= end; i++) {
sum += nums[i];
}
max = Math.max(max, sum); // 取最大值
}
}
return max;
}
這個結果應該不意外吧…
暴力出奇跡 – 優化
重複利用前面計算過的結果
時間複雜度:O(n2),空間複雜度:O(1)
/**
* 暴力 - 優化
*/
static int maxSubArray(int [] nums) {
if (nums == null || nums.length == 0) return 0;
// 這裏注意, 容易寫成 int max = 0, 可能會出錯, max 默認值必須是最小的值
int max = Integer.MIN_VALUE;
// 窮舉, 列出所有可能的連續子序列, 分別計算它們的和, 最後取出最大值
for (int begin = 0; begin < nums.length; begin++) {
// 重複利用sum, 只有當begin修改纔會重置
int sum = 0;
// begin不動, end修改的話, 子序列的和是疊加的, 無需每次都重新計算
for (int end = begin; end < nums.length; end++) {
sum += nums[end]; // sum是[begin, end]的和
max = Math.max(max, sum); // 取最大值
}
}
return max;
}
至少不超時了。。。
解法2 – 分治
空間複雜度:O(logn)
時間複雜度:O(nlogn),跟歸併排序、快速排序一樣,利用主定理計算:T(n) = 2T(n/2) + O(n)
/**
* 分治
*/
static int maxSubArray(int [] nums) {
if (nums == null || nums.length == 0) return 0;
return maxSubArray(nums, 0, nums.length);
}
static int maxSubArray(int[] nums, int begin, int end) {
// 遞歸基: end - begin < 2, 說明只有一個元素, nums[begin] == nums[end]
if (end - begin < 2) return nums[begin];
int mid = (begin + end) >> 1;
// 最長子序列是 [i, mid) + [mid, j) 的情況
int leftMax = Integer.MIN_VALUE;
int leftSum = 0;
for (int i = mid - 1; i >= begin; i--) { // [i,mid)
leftSum += nums[i];
leftMax = Math.max(leftSum, leftMax);
}
int rightMax = Integer.MIN_VALUE;
int rightSum = 0;
for (int i = mid; i < end; i++) { // [mid, end)
rightSum += nums[i];
rightMax = Math.max(rightSum, rightMax);
}
// 最長子序列在 left部分, right部分的情況
return Math.max(leftMax + rightMax,
Math.max(
maxSubArray(nums, begin, mid), // 最長子串在[begin, mid)的情況
maxSubArray(nums, mid, end) // 最長子串在[mid, end)的情況
));
}
可以看到這種解法特別快。。。
挖個坑,這題其實可以用動態規劃解決。
問題2:大數乘法