來談談貪心算法

前言

之前講了動態規劃,在翻閱資料的時候看到了不少談論貪心算法的,這兩種算法也很有相似之處,正好最近又做到了有關貪心的題,所以今天寫篇文章來談一談。

貪心算法(英語:greedy algorithm),又稱貪婪算法,是一種在每一步選擇中都採取在當前狀態下最好或最優(即最有利)的選擇,從而希望導致結果是最好或最優的算法。
貪心算法在有最優子結構的問題中尤爲有效。最優子結構的意思是局部最優解能決定全局最優解。簡單地說,問題能夠分解成子問題來解決,子問題的最優解能遞推到最終問題的最優解。
貪心算法與動態規劃的不同在於它對每個子問題的解決方案都做出選擇,不能回退。動態規劃則會保存以前的運算結果,並根據以前的結果對當前進行選擇,有回退功能。
貪心法可以解決一些最優化問題,如:求圖中的最小生成樹、求哈夫曼編碼……對於其他問題,貪心法一般不能得到我們所要求的答案。一旦一個問題可以通過貪心法來解決,那麼貪心法一般是解決這個問題的最好辦法。由於貪心法的高效性以及其所求得的答案比較接近最優結果,貪心法也可以用作輔助算法或者直接解決一些要求結果不特別精確的問題。
——摘自維基百科

動態規劃和貪心算法很像,在各種對它們的描述中都有將問題分解爲子問題的說法,其實還有分治法也是這種模式。但是動態規劃實質上是窮舉法,只是會省去重複計算,而貪心算法,正如它的名字,貪心,每次都選擇局部的最優解,並不考慮這個局部最優選擇對全局的影響。
可以說貪心算法是動態規劃的一種特例,也正由於貪心算法只考慮子問題的最優解,可以說,貪心算法實際上能解決的問題有限,它是一個目光短淺的算法,只考慮當下,只有當這種基於局部最優的選擇最終能導致整體最優解的情形才能用貪心算法來解決。

還是舉個栗子

一起來看一下一道leetcode上的題:

假設你是一位很棒的家長,想要給你的孩子們一些小餅乾。但是,每個孩子最多隻能給一塊餅乾。對每個孩子 i ,都有一個胃口值 gi ,這是能讓孩子們滿足胃口的餅乾的最小尺寸;並且每塊餅乾 j ,都有一個尺寸 sj 。如果 sj >= gi ,我們可以將這個餅乾 j 分配給孩子 i ,這個孩子會得到滿足。你的目標是儘可能滿足越多數量的孩子,並輸出這個最大數值。
注意:
你可以假設胃口值爲正。
一個小朋友最多隻能擁有一塊餅乾。
來源:力扣(LeetCode)
鏈接:https://leetcode-cn.com/probl...
著作權歸領釦網絡所有。商業轉載請聯繫官方授權,非商業轉載請註明出處。

是的,這是一位很棒(摳門)的家長,要儘可能用少的餅乾滿足多的孩子。比如現在有三個孩子胃口是[1,2,3],那麼哪怕家長手上有一百塊尺寸爲1的小餅乾,也只能滿足一個孩子,因爲他每個孩子最多隻給一個餅乾。
讓我們來想一想如何“貪心”呢?
要想最節省餅乾,我們可以把餅乾尺寸孩子胃口這兩個數據先做一下升序排序,然後每次都用最小的餅乾去試試能否滿足胃口最小的孩子,這樣我們需要維護兩個索引。

代碼實現:

class Solution:
    def findContentChildren(self, g: List[int], s: List[int]) -> int:
        count = 0
        g.sort()
        s.sort()
        gi, si = 0, 0
        while gi < len(g) and si < len(s):
            if s[si] >= g[gi]:
                count += 1
                gi += 1
                si += 1
            elif s[si] < g[gi]:
                si += 1
        return count

當餅乾尺寸剛好大於等於孩子胃口,計數+1,兩個索引值+1,否則,餅乾尺寸列表索引+1,看看更大的那塊餅乾能否滿足當前孩子。
題外話:經常看到有的Python代碼中,將某個列表長度值保存到某個變量中,像size = len(alist)這樣,事實上len()函數花費的是O(1)常數時間。Python的設計中一切皆對象,列表當然也是對象,當你創建一個列表後,len()實質上只是去提取了這個列表實例的長度屬性值而已,並沒有遍歷列表之類的操作。

實踐

再來看個題目:

在一條環路上有 N 個加油站,其中第 i 個加油站有汽油 gas[i] 升。
你有一輛油箱容量無限的的汽車,從第 i 個加油站開往第 i+1 個加油站需要消耗汽油 cost[i] 升。你從其中的一個加油站出發,開始時油箱爲空。
如果你可以繞環路行駛一週,則返回出發時加油站的編號,否則返回 -1。
說明: 
如果題目有解,該答案即爲唯一答案。
輸入數組均爲非空數組,且長度相同。
輸入數組中的元素均爲非負數。

首先我們可以想到,有一種情況,是一定不可能跑完全程的,那就是加油站的油量總和小於路上消耗的總油量時。也就是說,如果sum(gas) < sum(cost),那麼就要返回-1
第二點,如果我們選擇一個加油站i爲起始點,如果這個加油站所能夠獲得的油量小於前往下一個加油站所花費的油量,也就是gas[i] < cost[i]的話,說明這個加油站不能做爲起點。

代碼實現:

class Solution:
    def canCompleteCircuit(self, gas: List[int], cost: List[int]) -> int:
        total, curr = 0, 0
        start = 0
        for i in range(len(gas)):
            total += gas[i] - cost[i]
            curr += gas[i] - cost[i]
            if curr < 0:
                start = i + 1
                curr = 0

        return start if total >= 0 else -1

這裏用total保存最終的油量,curr表示當前油箱油量,start表示起點,初值都設爲0,遍歷整個列表,如果在加油站i,gas[i] - cost[i] < 0,那麼就選擇第i+1個加油站做爲起點,最後如果total小於0,返回-1,否則就返回start

  • 時間複雜度:O(n)
  • 空間複雜度:O(1)

看看缺陷

以下例子來自知乎用戶@阮行止:

先來看看生活中經常遇到的事吧——假設您是個土豪,身上帶了足夠的1、5、10、20、50、100元面值的鈔票。現在您的目標是湊出某個金額w,需要用到儘量少的鈔票。  依據生活經驗,我們顯然可以採取這樣的策略:能用100的就儘量用100的,否則儘量用50的……依次類推。在這種策略下,666=6×100+1×50+1×10+1×5+1×1,共使用了10張鈔票。  這種策略稱爲“貪心”:假設我們面對的局面是“需要湊出w”,貪心策略會盡快讓w變得更小。能讓w少100就儘量讓它少100,這樣我們接下來面對的局面就是湊出w-100。長期的生活經驗表明,貪心策略是正確的。  但是,如果我們換一組鈔票的面值,貪心策略就也許不成立了。如果一個奇葩國家的鈔票面額分別是1、5、11,那麼我們在湊出15的時候,貪心策略會出錯:  15=1×11+4×1 (貪心策略使用了5張鈔票)  15=3×5 (正確的策略,只用3張鈔票)
作者:阮行止
鏈接:https://www.zhihu.com/questio...
來源:知乎
著作權歸作者所有。商業轉載請聯繫作者獲得授權,非商業轉載請註明出處。

可以看到,在第一種情況下,使用貪心策略,很快就能得出答案,但是當條件稍微改變,就無法得出正確答案了。貪心算法在這個問題中,每次都選擇面額最大的鈔票,快速減少了最終要湊出的W的量,但是在例子的特殊情況裏,第一次選擇最大的面額11的鈔票,會導致後面只能選擇4張1元鈔票,最終得到的解是不正確的。
可以說,動態規劃是在暴力枚舉的基礎上,避免了重複計算,但是每一個子問題都被考慮到了,而貪心算法則每次都短視的選擇當前最優解而不去考慮剩下的情況。
最後留個思考,試試把這個特殊面額鈔票的問題用動態規劃解決一下。

掃碼關注微信公衆號:
公衆號

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章