拜托,別再問我貪心算法了!
前言
在求三角形最短路徑和時(shí),能否用貪心算法求解。所以本文打算對(duì)貪心算法進(jìn)行簡單地介紹,介紹完之后我們?cè)賮砜纯词欠襁@道三角形最短路徑問題能用貪心算法來求解。
本文將會(huì)從以下幾個(gè)方面來介紹貪心算法
- 什么是貪心算法
- 貪心算法例題詳題
- 貪心算法適用場景
- 再看三角形最短路徑和是否能用貪心算法求解
什么是貪心算法
貪心算法是指在每個(gè)階段做選擇的時(shí)候都做出當(dāng)前階段(或狀態(tài))最好的選擇,并且期望這樣做到的結(jié)果是全局最優(yōu)解(但未必是全局最優(yōu)解)
貪心算法其實(shí)是動(dòng)態(tài)規(guī)劃的一種,由于它的「貪心」,只著眼于當(dāng)前階段的最優(yōu)解,所以每個(gè)子問題只會(huì)被計(jì)算一次,如果由此能得出全局最優(yōu)解,相對(duì)于動(dòng)態(tài)規(guī)劃要對(duì)每個(gè)子問題求全局最優(yōu)解,它的時(shí)間復(fù)雜度無疑是會(huì)下降一個(gè)量級(jí)的。
舉個(gè)簡單的例子,比如給定某個(gè)數(shù)字的金額(如 250)與 100, 50, 10, 5, 1 這些紙幣(不限量),怎么能用最少張的紙幣來兌換這張金額呢,顯然每次兌換應(yīng)該先從大額的紙幣兌換起,第一次選 100, 第二次還是選 100, 第三次選第二大的 50 元,每次都選小于剩余金額中的最大面額的紙幣,這樣得出的解一定是全局最優(yōu)解!時(shí)間復(fù)雜度無疑是線性的。
我們先來看幾道可以用貪心算法來求解的例題
貪心算法例題詳題
分糖果
- 有 m 個(gè)糖果和 n 個(gè)孩子。我們現(xiàn)在要把糖果分給這些孩子吃,但是糖果少,孩子多(m < n),所以糖果只能分配給一部分孩子。每個(gè)糖果的大小不等,這 m 個(gè)糖果的大小分別是s1,s2,s3,……,sm。除此之外,每個(gè)孩子對(duì)糖果大小的需求也是不一樣的,只有糖果的大小大于等于孩子的對(duì)糖果大小的需求的時(shí)候,孩子才得到滿足。假設(shè)這 n 個(gè)孩子對(duì)糖果大小的需求分別是 g1,g2,g3,……,gn。那么如何分配糖果,能盡可能滿足最多數(shù)量的孩子呢?
求解:這道題如果用貪心來解解題思路還是比較明顯的,對(duì)于每個(gè)小孩的需求 gn,只要給他所有大小大于 gn 的糖果中的最小值即可,這樣就能把更大的糖果讓給需求更大的小孩。整個(gè)代碼如下:
- public class Solution {
- /**
- * 獲取能分配給小孩且符合條件的最多糖果數(shù)
- */
- private static int dispatchCandy(int[] gList, int[] sList) {
- Arrays.sort(gList); // 小孩對(duì)糖果的需求從小到大排列
- Arrays.sort(sList); // 糖果大小從小到大排列
- int maximumCandyNum = 0;
- for (int i = 0; i < gList.length; i++) {
- for (int j = 0; j < sList.length; j++) {
- // 選擇最接近小孩需求的糖果,以便讓更大的糖果滿足需求更大的小孩
- if (gList[i] <= sList[j]) {
- maximumCandyNum++;
- // 糖果被選中,將其置為-1,代表無效了
- sList[j] = -1;
- // 當(dāng)前小孩糖果已選中,跳出
- break;
- }
- }
- }
- return maximumCandyNum;
- }
- public static void main(String[] args) {
- // 小孩對(duì)糖果的需求
- int[] gList = {1,2,4,6};
- // 糖果實(shí)際大小
- int[] sList = {1,2,7,3};
- int result = dispatchCandy(gList, sList);
- System.out.println("result = " + result);
- }
- }
無重疊區(qū)間
- 給定一個(gè)區(qū)間的集合,找到需要移除區(qū)間的最小數(shù)量,使剩余區(qū)間互不重疊。注意:可以認(rèn)為區(qū)間的終點(diǎn)總是大于它的起點(diǎn)。區(qū)間 [1,2] 和 [2,3] 的邊界相互“接觸”,但沒有相互重疊。
- 示例 1: 輸入: [ [1,2], [2,3], [3,4], [1,3] ] 輸出: 1 解釋: 移除 [1,3] 后,剩下的區(qū)間沒有重疊。
- 示例 2: 輸入: [ [1,2], [1,2], [1,2] ] 輸出: 2 解釋: 你需要移除兩個(gè) [1,2] 來使剩下的區(qū)間沒有重疊。
- 示例 3: 輸入: [ [1,2], [2,3] ] 輸出: 0 解釋: 你不需要移除任何區(qū)間,因?yàn)樗鼈円呀?jīng)是無重疊的了。
區(qū)間重疊可以在生活中的很多場景里找到,比如任務(wù)調(diào)度,一個(gè)工人在一段時(shí)間內(nèi)需要完成多項(xiàng)任務(wù),每個(gè)任務(wù)需要完成的時(shí)間不同,如何在這段時(shí)間內(nèi)讓工人盡可能多地完成這些任務(wù)呢(任務(wù)與任務(wù)之間進(jìn)行的時(shí)間不能重疊,一個(gè)工人不可能在同一段時(shí)間內(nèi)同時(shí)進(jìn)行兩項(xiàng)任務(wù))
解題思路: 這道題我們分別用動(dòng)態(tài)規(guī)劃和貪心算法來解一下,以便對(duì)比一下兩者的時(shí)間復(fù)雜度,看下貪心算法是否在時(shí)間復(fù)雜度上更優(yōu)勝一些。
動(dòng)態(tài)規(guī)劃解法
首先為了方便求解,我們把每個(gè)區(qū)間按區(qū)間的起始點(diǎn)從小到大進(jìn)行排序,如圖示
接下來我們套用上篇中的說的動(dòng)態(tài)規(guī)劃解題四步曲來看看怎么用動(dòng)態(tài)規(guī)劃進(jìn)行求解。
1、 判斷是否可用遞歸來解
其實(shí)直觀地看上面我們排好序的各個(gè)區(qū)間,這不就是個(gè)組合嗎,每個(gè)區(qū)間要么選,要么不選,把所有的組合窮舉出來,再看哪個(gè)組合最符合題目中的條件,所以無疑是可以用遞歸的(組合用遞歸怎么解,強(qiáng)烈建議看下這篇文章)。
不過這題的組合有點(diǎn)特殊,前后兩個(gè)區(qū)間有條件限制,如果當(dāng)前區(qū)間與前一個(gè)區(qū)間重疊,則這兩者只能取其一(另一個(gè)需要剔除掉防止重疊),于是我們有如下思路:
定義兩個(gè)值, pre , cur ,分別代表前一個(gè)區(qū)間與當(dāng)前區(qū)間,需要進(jìn)行如下步驟
- 比較前一個(gè)區(qū)間的終點(diǎn)與當(dāng)前區(qū)間的起始值
- 如果前一個(gè)區(qū)間的終點(diǎn)小于當(dāng)前區(qū)間的起始值,代表兩區(qū)間不重疊,則將 pre 置為 cur, cur 置為 cur + 1, 開始接下來緊鄰的兩個(gè)區(qū)間的判斷(即重復(fù)步驟 1)。
- 如果前一個(gè)區(qū)間的終點(diǎn)大于當(dāng)前區(qū)間的起始值,代表兩區(qū)間重疊,則 pre 不變, cur 置為 cur + 1 (即將 cur 對(duì)應(yīng)的區(qū)間移除),注意此時(shí)移除區(qū)間數(shù)要加 1, 然后開始接下來 pre,cur+1 區(qū)間的判斷(即重復(fù)步驟 1)。
注:首次區(qū)間遍歷我們定義 pre = -1,cur = 0
從以上的描述中可以很明顯地看到存在遞歸現(xiàn)象,于是我們寫出了如下代碼,關(guān)鍵代碼都作了詳細(xì)的注釋。
- public class Solution {
- // 區(qū)間類,包括起始值和終止值
- private static class Interval {
- int start;
- int end;
- Interval(int start, int end) {
- this.start = start;
- this.end = end;
- }
- }
- private static Integer removeDuplicateIntervas(Interval[] intervals) {
- // 將區(qū)間按起始點(diǎn)由小到大進(jìn)行排序
- Arrays.sort(intervals, Comparator.comparingInt(a -> a.start));
- // 首次遍歷從 -1,0 開始
- return removeSubDuplicate(-1, 0, intervals);
- }
- private static Integer removeSubDuplicate(int pre, int cur, Interval[] intervals) {
- if (cur == intervals.length) {
- return 0;
- }
- int notRemove = Integer.MAX_VALUE;
- if (pre == -1 || intervals[pre].end <= intervals[cur].start) {
- /**
- * 如果是首次遍歷,或者 pre 區(qū)間的終點(diǎn)小于 cur 區(qū)間的起點(diǎn)(即區(qū)間不重疊),
- * 則 pre = cur; cur = cur+1
- */
- notRemove = removeSubDuplicate(cur, cur+1, intervals);
- }
- /**
- * 如果 pre 區(qū)間的終點(diǎn)大于 cur 區(qū)間的起點(diǎn),代表兩區(qū)間重疊,cur 指向后一個(gè)區(qū)間,即 cur = cur + 1
- * 代表 cur 被移除,所以需要加1(代表這個(gè)區(qū)間被移除了)
- */
- int remove = removeSubDuplicate(pre, cur+1, intervals) + 1;
- // 取兩者的較小值
- return Math.min(notRemove, remove);
- }
- public static void main(String[] args) {
- // 初始化區(qū)間
- Interval[] intervals = {
- new Interval(1, 2),
- new Interval(3, 5),
- new Interval(4, 7),
- new Interval(8, 10),
- new Interval(9, 11)
- };
- int result = removeDuplicateIntervas(intervals);
- System.out.println("result = " + result);
- }
- }
2、 分析在遞歸的過程中是否存在大量的重復(fù)子問題
怎么判斷是否存在大量的重復(fù)子問題,在一文學(xué)會(huì)動(dòng)態(tài)規(guī)劃解題技巧 我們提出一種方案,畫出遞歸樹 ,不過這題如果畫出遞歸樹比較麻煩,其實(shí)對(duì)于組合問題我們可以簡單分析一下,對(duì)于每個(gè)區(qū)間要么選,要么不選,我們以 1 代表該區(qū)間被選擇,以 0 代表該區(qū)間不選,則簡單考慮一下如下兩個(gè)組合
- 11010
- 11001
仔細(xì)看,紅框 部分,就是重復(fù)子問題!
可能有人會(huì)說這樣分析也有點(diǎn)難,那我再教大家一招,打印! 如圖示
在遞歸函數(shù)中打印出來,然后分析打印的規(guī)律
可以看到,確實(shí)存在著重復(fù)子問題,時(shí)間復(fù)雜度是多少呢,每個(gè)區(qū)間要么選,要么不選,共有兩個(gè)狀態(tài),如果有 n 個(gè)區(qū)間的話,就是 2^n,于是我們知道時(shí)間復(fù)雜度是 O(2^n),指數(shù)級(jí)!顯然無法接受
既然存在重復(fù)子問題,那我們進(jìn)入動(dòng)態(tài)規(guī)劃第三步
3、采用備忘錄的方式來存子問題的解以避免大量的重復(fù)計(jì)算(剪枝)
在以上的分析中基本我們看到,其實(shí)就是 pre, cur 有可能存在大量重復(fù),于是我們保存 pre, cur 對(duì)應(yīng)的中間結(jié)果,如下
- // 保存中間結(jié)果
- private static HashMap<String, Integer> map = new HashMap();
- private static Integer removeSubDuplicate(int pre, int cur, Interval[] intervals) {
- String key = pre + "," + cur;
- if (map.get(key) != null) {
- return map.get(key);
- }
- if (cur == intervals.length) {
- return 0;
- }
- int notRemove = Integer.MAX_VALUE;
- if (pre == -1 || intervals[pre].end <= intervals[cur].start) {
- notRemove = removeSubDuplicate(cur, cur+1, intervals);
- }
- int remove = removeSubDuplicate(pre, cur+1, intervals) + 1;
- int result = Math.min(notRemove, remove);
- map.put(key, result);
- return result;
- }
采用備忘錄的方式緩存子問題的中間結(jié)果后,時(shí)間復(fù)雜度直線下降,達(dá)到 O(n^2)(因?yàn)?pre, cur 兩個(gè)變量不斷往后移,即兩層循環(huán),所以是 O(n^2)) 。
4、改用自底向上的方式來遞推,即動(dòng)態(tài)規(guī)劃
我們定義 dp[i] 為 從 0 到 第 i 個(gè)區(qū)間的最大不重疊區(qū)間數(shù),于是我們得出了狀態(tài)轉(zhuǎn)移方程
- dp[i] = max{dp[j]} + 1, 其中 0 <=j < i 并且需要滿足一個(gè)條件 interval[i].start > interval[j].end,即保證 i, j 指向的區(qū)間不重疊。
則最終的 dp[區(qū)間總個(gè)數(shù)-1] 即為最大的連續(xù)不重疊區(qū)間個(gè)數(shù),那么區(qū)間總個(gè)數(shù) - 最大的連續(xù)不重疊區(qū)間個(gè)數(shù)不就是最小要移除的區(qū)間數(shù)了,有了 dp 方程,寫起代碼來就快了,如下
- /**
- * 判斷兩區(qū)間是否重疊, i 區(qū)間的起點(diǎn)比 j 區(qū)間的大, 如果 j 區(qū)間的終點(diǎn)比 i 區(qū)間的起點(diǎn)大,則重疊
- */
- private static boolean isOverlapping(Interval i, Interval j) {
- return j.end > i.start;
- }
- /**
- * 動(dòng)態(tài)規(guī)劃求解
- */
- private static Integer removeSubDuplicateWithDP(Interval[] intervals) {
- // 將區(qū)間按起始點(diǎn)由小到大進(jìn)行排序
- Arrays.sort(intervals, Comparator.comparingInt(a -> a.start));
- int[] dp = new int[intervals.length];
- Arrays.fill(dp, 0);
- dp[0] = 1; // 將 dp[0] 置為 1, 因?yàn)榫退闼械膮^(qū)間都重疊,則連續(xù)不重疊區(qū)間到少也為 1
- int result = 1;
- for (int i = 1; i < intervals.length; i ++) {
- int max = 0;
- for (int j = 0; j < i; j ++) {
- if (!isOverlapping(intervals[i], intervals[j])) {
- max = Math.max(dp[j], max);
- }
- }
- dp[i] = max + 1;
- }
- return intervals.length - dp[intervals.length - 1];
- }
空間復(fù)雜度是多少,由于只有一個(gè) dp 一維數(shù)組,所以是 O(n),時(shí)間復(fù)雜度呢, 兩重循環(huán),所以是 O(n^2)??梢钥吹胶筒捎眠f歸+備忘錄的時(shí)間復(fù)雜度一樣,不過之前其實(shí)說了很多次,遞歸容易導(dǎo)致棧溢出,所以建議還是采用動(dòng)態(tài)規(guī)劃的方式來求解。
接下來重點(diǎn)來了,來看看如何用貪心算法來求解。首先要把各個(gè)區(qū)間按照區(qū)間的終點(diǎn)從小到大排列,如下
我們的思路與上文中的動(dòng)態(tài)規(guī)劃一樣,先求出最大不重疊子區(qū)間個(gè)數(shù),再用「區(qū)間總數(shù)-最大不重疊子區(qū)間個(gè)數(shù)」即為最小要移除的重疊區(qū)間數(shù)。
用貪心算法求最大不重大子區(qū)間個(gè)數(shù)步驟如下
- 選擇終點(diǎn)最小的區(qū)間,設(shè)置為當(dāng)前區(qū)間 cur 。
- 按區(qū)間終點(diǎn)從小到大尋找下一個(gè)與區(qū)間 cur 不重疊的區(qū)間,然后將此區(qū)間設(shè)置為當(dāng)前區(qū)間 cur(注意此時(shí)最大不重疊子區(qū)間個(gè)數(shù)要加1),不斷重復(fù)步驟 2, 直到遍歷所有的區(qū)間。
動(dòng)圖如下,相信大家看完動(dòng)圖會(huì)更容易理解
知道了解題思路,寫代碼就很簡單了
- /**
- * 貪心算法求解
- */
- private static Integer removeSubDuplicateWithGreedy(Interval[] intervals) {
- // 將區(qū)間終點(diǎn)由小到大進(jìn)行排序
- Arrays.sort(intervals, Comparator.comparingInt(a -> a.end));
- int cur = 0; // 設(shè)置第一個(gè)為當(dāng)前區(qū)間
- int count = 1; // 最大不重疊區(qū)間數(shù),最小為1
- for (int i = 1; i < intervals.length; i++) {
- // 不重疊
- if (intervals[cur].end < intervals[i].start) {
- cur = i;
- count++;
- }
- }
- // 總區(qū)間個(gè)數(shù)減去最大不重疊區(qū)間數(shù)即最小被移除重疊區(qū)間
- return intervals.length - count;
- }
時(shí)間復(fù)雜度是多少呢,只有一個(gè)循環(huán),所以是 O(n), 比起動(dòng)態(tài)規(guī)劃的 O(n^2),確實(shí)快了一個(gè)數(shù)量級(jí),簡單分析下為啥貪心算法這么快,由以上代碼可以看到,它只關(guān)心眼前的最優(yōu)解(選擇下一個(gè)與當(dāng)前區(qū)間不重疊的區(qū)間再依次遍歷,選完之后再也無需關(guān)心之前的區(qū)間了!)而動(dòng)態(tài)規(guī)劃呢,從它的 dp 方程(dp[i] = max{dp[j]} + 1)可以看出,對(duì)于每個(gè) i ,都要自底向上遍歷一遍 0 到 i 的解以求出最大值,也就是說對(duì)于動(dòng)態(tài)規(guī)劃的子問題而言,由于它追求的是全局最優(yōu)解,所以它有一個(gè)回溯(即自底向上求出所有子問題解的最優(yōu)解)的過程,回溯的過程中就有一些重復(fù)的子問題計(jì)算,而貪心算法由于追求的是眼前的最優(yōu)解,所以不會(huì)有這種回溯的求解,也就省去了大量的操作,所以如果可以用貪心算法求解,時(shí)間復(fù)雜度無疑是能上升一個(gè)量級(jí)的。
貪心算法適用場景
簡單總結(jié)一下貪心算法,它指的是每一步只選最優(yōu)的,并且期望每一步選擇的最優(yōu)解能達(dá)成全局的最優(yōu)解,說實(shí)話這太難了,因?yàn)橐话阋粋€(gè)問題的選擇都會(huì)影響下一個(gè)問題的選擇,除非子問題之間完全獨(dú)立,沒有關(guān)聯(lián),比如我們?cè)谖闹虚_頭說的湊零錢的例子, 如果一個(gè)國家的鈔票比較奇葩,只有 1,5,11 這三種面值的鈔票,如何用最少的鈔票湊出 15 呢,如果用貪心第一次選 11, 那之后只能選 4 張 1 了,即 15 = 1 x 11 + 4 x1。其實(shí)最優(yōu)解應(yīng)該是 3 張 5 元的鈔票,為啥這種情況下用貪心不適用呢,因?yàn)榈谝淮芜x了 11,影響了后面鈔票的選擇,也就是說子問題之間并不是獨(dú)立的,而是互相制約,互有影響的,所以我們選貪心的時(shí)候一定要注意它的適用場景。
再看三角形最短路徑和是否能用貪心算法求解
回過頭來看開頭的問題,三角形最短路徑和能否用貪心算法求解呢
先回顧一下這個(gè)題目:
如圖示,以上三角形由一連串的數(shù)字構(gòu)成,要求從頂點(diǎn) 2 開始走到最底下邊的最短路徑,每次只能向當(dāng)前節(jié)點(diǎn)下面的兩個(gè)節(jié)點(diǎn)走,如 3 可以向 6 或 5 走,不能直接走到 7。
如圖示:要求節(jié)點(diǎn) 2 到底部的最短路徑,它只關(guān)心節(jié)點(diǎn) 9, 10,之前層數(shù)的節(jié)點(diǎn)無需再關(guān)心!因?yàn)? 9,10 已經(jīng)是最優(yōu)子結(jié)構(gòu)了,所以只保存每層節(jié)點(diǎn)(即一維數(shù)組)的最值即可!
如果用貪心算法怎么求解
1、 第一步:由 2 往下走,由于 3 比 4 小,所以選擇 3
2、 第二步:由 3 往下走,由于 5 比 6 小,所以選擇 5
第三步: 從 5 往下走, 1 比 8 小,選擇 1
答案是 11 ,與動(dòng)態(tài)規(guī)劃得出的解一模一樣!那是否說明這道題可以用貪心算法求解?
答案是否定的!上面的解之所以是正確的,是因?yàn)檫@些數(shù)字恰好按貪心求解出來得出了全局最優(yōu)解,如果我們換一下數(shù)字,看看會(huì)如何
如圖示,如果數(shù)字換成如圖中所示,則按貪心得出的最短路徑是 66, 而實(shí)際上最短路徑應(yīng)該為 16,如下圖所示
為啥用貪心行不通呢,因?yàn)樨澬淖非蟮氖敲恳徊窖矍暗淖顑?yōu)解,一旦它作出了選擇,就會(huì)影響后面子問題的選擇,比如如果選擇了 3,就再也沒法選擇 7 了!所以再次強(qiáng)調(diào),一定要注意貪心的適用場景,子問題之間是否相互制約,相互影響!
總結(jié)
本文簡單講述了貪心算法的適用場景,相信大家對(duì)貪心的優(yōu)劣性應(yīng)該有了比較清晰的認(rèn)識(shí),貪心追求的是眼前最優(yōu)解(要最好的,就現(xiàn)在!)不管這次選擇對(duì)后面的子問題造成的影響,所以貪心求得解未必是全局最優(yōu)解,這就像我們做職業(yè)規(guī)劃一樣,千萬不可因?yàn)橐粫r(shí)的利益只考慮當(dāng)下的利益,要作出對(duì)長遠(yuǎn)的職業(yè)生涯能持續(xù)有益的選擇, 所以貪心的使用場景比較小,它是動(dòng)態(tài)規(guī)劃的特例,所以如果能用貪心來解的也都可以用動(dòng)態(tài)規(guī)劃來解。