淺入淺出 貪心算法

Intro to Greedy Algorithm



Arvin Liu

前言

簡言之,貪心=通靈

好,我們下課

前言

我是麻瓜,不會通靈

  1. 增加你的靈力,靈感
  2. 背常見套路
  3. 數學證明
  4. 亂猜一通 → 其實這就是通靈

直觀感受...

Greedy by ... Your Primal Instincts...

給你一個整數 N (N ≥ 0),請找到最小的 Q (Q  0)
使得在 Q 中所有數字(digit)的乘積等於 N 。

舉例來說:

  • N = 10, Q = 25,因為 2*5=10。
  • N = 216, Q = 389,因為 3*8*9=216。
  • N = 26, Q = -1,因為找不到這樣的Q。

怎麼做?

給你一個整數 N (N ≥ 0),請找到最小的 Q (Q  0)
使得在 Q 中所有數字(digit)的乘積等於 N 。

怎麼做?

從2找到9,找到因數就放第一位數,以此循環

N = 16,你會輸出 2222。

想辦法輸出的位數最小化!

最後再由小的輸出?

有特例嗎?

給你一個整數 N (N ≥ 0),請找到最小的 Q (Q  0)
使得在 Q 中所有數字(digit)的乘積等於 N 。

T = int(input())
for _ in range(T):
    N = int(input())
    ans = []
    for factor in range(9, 1, -1):
        while N % factor == 0:
            N //= factor
            ans.append(str(factor))
    if N == 1 and ans:
        print(''.join(ans[::-1]))
    elif N == 1:
        print(1)
    else:
        print(-1)
#include <iostream>
#include <algorithm>
using namespace std;
int main() {
    int T;
    scanf("%d", &T);
    while(T--) {
        int N;
        scanf("%d", &N);
        string ans;
        for (int factor=9; factor>1; factor--) {
            while (N % factor == 0) {
                N /= factor;
                ans += '0' + factor;
            }
        }
        reverse(ans.begin(), ans.end());
        if (N == 1 && ans.length())
            cout << ans << endl;
        else if (N==1)
            cout << 1 << endl;
        else
            cout << -1 << endl;
    }
    return 0;
}

給你一個整數 N (N ≥ 0),請找到最小的 Q (Q  0)
使得在 Q 中所有數字(digit)的乘積等於 N 。

雖然可以 AC,但是你真的知道

為什麼可以由9到1嗎?

首先,我們只需要考慮和數:9,8,6,4。

如果 N 有 2 * 2 * 3 你會選擇 4 * 3 還是 2 * 6?

如果 N 有 2 * 3 * 3 你會選擇 2 * 9 還是 3 * 6?

有沒有發現其實這題沒有你想像中的簡單?

如果 N 有 2 * 2 * 2 你會選擇 4 * 2 還是 8?

Set cover problem

你該怎麼選擇 S 才可以把所有點包住呢?

這種題目很難 (被稱為NPC 問題)

所以如果廣泛的說,他其實不能貪心,

只是題目的設計剛好可以讓它貪心。

你其實也很貪心!

找零問題 - Change-making Problem

Coin Change (leetcode 322)

給你一個國家的面額種類,

請問最少可以用多少個硬幣湊出 amount?

舉例來說:

  • 台灣有 [1, 5, 10, 50, 100, 500, 1000]
    • 如果你要找給別人 261 塊你會怎麼做?
    • 1+1+1+1+1+1+1+1+1+1+1+1+1
    • 100 + 100 + 50 + 10 + 1
    • 也就是優先使用大面額

Coin Change (leetcode 322)

給你一個國家的面額種類,

請問最少可以用多少個硬幣湊出 amount?

但考慮比較奇怪的 case:

  • 某怪國有面額 [1, 4, 5]
    • 如果你要找給別人 8 塊你會怎麼做?

目標 8 塊

5 + (目標 3 塊)

4 + (目標 4 塊)

5 + 1 + 1 + 1

(四個硬幣)

4 + 4

(兩個硬幣)

Coin Change (leetcode 322)

給你一個國家的面額種類,

請問最少可以用多少個硬幣湊出 amount?

總結來說,這題只能爆搜。

也就是你幾乎必須試遍所有可能。

那不是很慢嗎?

Coin Change (leetcode 322)

給你一個國家的面額種類,

請問最少可以用多少個硬幣湊出 amount?

想想看,在什麼樣的條件下,
貪心演算法的解才是最佳解呢?

所以...怎麼貪心?

How to Greedy?

什麼是貪心?

找零錢問題

Product of Digits

在所有可選的數字中,

優先選合併最多的

在所有可找的零錢,

優先選面額最大的。

在眾多可能中,只選了一種 "看似最佳" 的解答。

那就表示你很貪心。

當然,有時候貪心解就是最佳解

但大多時候都不是,通常這時就用動態規劃DP。

使用時機

在講之前,回憶一下枚舉吧!

f(8)

5 + f(3)

4 + f(4)

1 + f(7)

5 + 1 + f(2)

4 + 4

4 + 1 + f(3)

5 + 1 + 1 + f(1)

4 + 1 + 1 + f(2)

1 + 5 + f(2)

1 + 4 + f(3)

1 + 1 + f(6)

1 + 5 + 1 + f(1)

1 + 4 + 1 + f(2)

1 + 1 + 5 + f(1)

使用時機

在動態規劃的課程中,

我們說問題如果具有兩個性質

那麼它就適合動態規劃 (DP)

最佳子結構

講人話就是這個大問題可以透過小問題解決。

重複子問題

講人話就是解決大問題時,小問題被問不只一次。

 overlapping subproblems

optimal substructure

找零問題中,答案可以暴搜一開始選的硬幣+遞迴解決

找零問題中,遞迴時會發現每個問題有可能被問很多次

使用時機

貪心演算法的課程中,

我們說問題如果具有兩個性質

那麼它就適合貪心演算法 (Greedy)

最佳子結構

講人話就是這個大問題可以透過小問題解決。

optimal substructure

找零問題中,答案可以暴搜一開始選的硬幣+遞迴解決

貪婪選擇性質

講人話就是你可以在所有可能選擇到最佳解的方向

greedy choice property

找零問題中,優先選最大面額「可能」就會是最佳解

這真的有人講中文嗎?

思考流程

  1. 就說了,通靈
  2. 是不是在茫茫選擇中有幾種看起來特別的優?
  3. 嘗試找反例,想想看有沒有其他反例。
    找不到就表示它很有可能就是最佳解。
    • 但很多時候其實是你沒想到反例 XD
  4. 時間允許的話,嘗試證明它
    這會幫助你思考演算法的腦袋(?)
  5. 有些貪心題,你不換個角度是不好看出來的。
  6. 想想看你寫過的貪心題目,有沒有類似的題目

大膽假設,小心求證

讓我們來看看

經典的貪心題目吧!

餅乾分配

Assign Cookie (leetcode 455)

Assign Cookie (leetcode 455)

現在有一堆餅乾跟一堆小孩,

餅乾有各自的「滿足度」,小孩有各自的「貪心度」。

如果小孩吃的餅乾「滿足度」大於等於自己的「貪心度」,他會很開心。

每個小孩最多只能給一片餅乾,請問你最多可以讓幾位小孩開心?

舉例來說:

  • 有三個小孩,貪心度分別為 [1, 2, 3]
  • 有兩個餅乾,滿足度分別為 [1, 2]
  • 你的解答會是 2,因為 1->1, 2->2。

直觀來說,你會怎麼做?

Assign Cookie (leetcode 455)

現在有一堆餅乾跟一堆小孩,

餅乾有各自的「滿足度」,小孩有各自的「貪心度」。

如果小孩吃的餅乾「滿足度」大於等於自己的「貪心度」,他會很開心。

每個小孩最多只能給一片餅乾,請問你最多可以讓幾位小孩開心?

直觀來說,你會怎麼做?

1. 從貪心度最小的餅乾開始給,
優先滿足最不貪心的小孩。

因為不貪心的小孩最好被滿足。

2. 從貪心度最大的餅乾開始給,
優先滿足最貪心的小孩。

因為可以保證餅乾可以被
最適合的貪心小孩吃掉

Assign Cookie (leetcode 455)

1. 從貪心度最小的餅乾開始給,
優先滿足最不貪心的小孩。

2. 從貪心度最大的餅乾開始給,
優先滿足最貪心的小孩。

g.sort()
s.sort()
ans = 0
while s and g:
  if s[-1] >= g[-1]:
    ans += 1
    del g[-1], s[-1]
  else:
    del g[-1]
return ans
g.sort(reverse=True)
s.sort(reverse=True)
ans = 0
while s and g:
  if s[-1] >= g[-1]:
    ans += 1
    del g[-1], s[-1]
  else:
    del s[-1]
return ans

都可以AC!

Assign Cookie (leetcode 455)

1. 從貪心度最小的餅乾開始給,
優先滿足最不貪心的小孩。

2. 從貪心度最大的餅乾開始給,
優先滿足最貪心的小孩。

sort(g.begin(), g.end());
sort(s.begin(), s.end());
int ans = 0;
while (!s.empty() && !g.empty()) {
  if (s.back() >= g.back()) {
    ans += 1;
    s.pop_back(), g.pop_back();
  } else {
    g.pop_back();
  }
}
return ans;

都可以AC!

sort(g.begin(), g.end(), greater<int>());
sort(s.begin(), s.end(), greater<int>());
int ans = 0;
while (!s.empty() && !g.empty()) {
  if (s.back() >= g.back()) {
    ans += 1;
    s.pop_back(), g.pop_back();
  } else {
    s.pop_back();
  }
}
return ans;

Assign Cookie (leetcode 455)

  • 從貪心度最小的餅乾開始給,

優先滿足最不貪心的小孩。

為什麼是對的?

🐷

  1. 假設小餅乾給小孩是最佳解。
  2. 如果小餅乾給更貪心的小孩 (豬),那麼一定不會比原本的解更好。
    • 你給小孩,答案是 1 + f(2, 2)
    • 你給豬,答案是 1 + f(3, 2)
    • f(2, 2) 顯然比 f(3, 2) 更好,
      因為有多一個選擇。

?

你能證明看看第二個方法的正確性嗎?

誰先晚餐

A. 誰先晚餐 (TIOJ 1072, NPSC 2005 高中決賽 PA)

A. 誰先晚餐 (TIOJ 1072)

現在有一堆人要點餐,每個人點的餐有「煮的時間」跟「吃的時間」。

廚師一次只能準備一個餐點,求出最後一個人吃完時的最短時間

舉例來說:

  • 三個人點的餐點以及
    要吃的時間分別是
    (1, 1), (2, 2), (3, 3)
  • 答案是 7

直觀來說,你會怎麼做?

1

1

2

2

3

3

第一個人

第二個人

第三個人

廚師

7

A. 誰先晚餐 (TIOJ 1072)

現在有一堆人要點餐,每個人點的餐有「煮的時間」跟「吃的時間」。

廚師一次只能準備一個餐點,求出最後一個人吃完時的最短時間

直觀來說,你會怎麼做?

1. 從準備時間最短的開始做。

因為這樣可以讓客人先吃。

3. 從吃最久的的開始做。

因為這樣可以讓廚師沒事做的時間最小

2. 從準備時間最長的開始做。

因為這樣可以優先處理掉最佔時間的

哪個是對的?
找得出反例嗎?

啊我就麻瓜啊?

如果不會通靈,你數學最好一點。

A. 誰先晚餐 (TIOJ 1072)

  • 我們先考慮兩個人的 case,看看是否可以算出要先煮誰的
    • 假設(煮的時間,等的時間) 分別為
  • 不失一般性 (w.l.o.g),假設
  • 我們就可以分出四種 Case,分別會是:
(C_1, E_1), (C_2, E_2)
  • 第一個人吃比較久,先做第一個人
  • 第一個人吃比較久,先第二個人
  • 第二個人吃比較久,先第一個人
  • 第二個人吃比較久,先第二個人
C_1 \le C_2

A. 誰先晚餐 (TIOJ 1072)

  • 第一個人吃比較久,先第一個人
     
  • 第一個人吃比較久,先第二個人
     
  • 第二個人吃比較久,先第一個人
     
  • 第二個人吃比較久,先第二個人

C1

E1

C2

E2

C1

E1

C2

E2

C1

E1

C2

E2

C1

E1

E2

C2

\max(C_1+E_1, C_1+C_2+E_2)
\max(C_2+E_2, C_1+C_2+E_1)
\max(C_1+E_1, C_1+C_2+E_2)
\max(C_2+E_2, C_1+C_2+E_1)

A. 誰先晚餐 (TIOJ 1072)

  • 第一個人吃比較久,先第一個人
     
  • 第一個人吃比較久,先第二個人
     
  • 第二個人吃比較久,先第一個人
     
  • 第二個人吃比較久,先第二個人
\max(C_1+E_1, C_1+C_2+E_2)
C_1+C_2+E_1
C_1+C_2+E_2
\max(C_2+E_2, C_1+C_2+E_1)
\}
\}
\because E_1 \ge E_2 \\ \therefore \text {優先做第一個人}
\because E_2 \ge E_1 \\ \therefore \text {優先做第二個人}

優先做吃
最久的人。

A. 誰先晚餐 (TIOJ 1072)

最後的最後... 

為什麼只需要想兩個人的case可以推到所有人的case?

A. 誰先晚餐 (TIOJ 1072)

#include <iostream>
#include <algorithm>
#include <vector>
using namespace std;
int main(){
    int n, C, E;
    while(cin >> n && n != 0){
        vector< pair<int,int> > V;
        for(int i=0; i<n; i++){
            cin >> C >> E; 
            V.push_back({E,C});
        }
        sort(V.begin(), V.end());
        reverse(V.begin(),V.end());   
        
        int now_time = 0, ans = 0;
        for(auto [E, C] : V){
            // 紀錄現在煮多久了。
            now_time += C;
            // 算出這個客人啥時離開,並更新最大值。
            ans = max(ans, now_time + E);
        }
        cout << ans << endl;
    }
    return 0;
}
while True:
  n = int(input())
  if n == 0:
    break
  V = [] 
  for _ in range(n):
    C, E = map(int, input().split())
    V.append((E, C))
  V.sort(reverse=True)
  now_time, ans = 0, 0
  for E, C in V:
    now_time += C
    ans = max(ans, now_time + E)
  print(ans)

不相交區間

現在有一堆任務,每個任務有起始時間跟結束時間。你一次只能做一個任務,問最少需要放棄任務的數量?

直觀來說,你會怎麼做?

舉例來說:

  • 四個任務的時間是
    [1, 2], [2, 3], [3, 4],
    [1, 3]
  • 答案是 1
    (把 [1, 3] 放棄)

[1, 2]

[2, 3]

[3, 4]

[1, 3]

現在有一堆任務,每個任務有起始時間跟結束時間。你一次只能做一個任務,問最少需要放棄任務的數量?

直觀來說,你會怎麼做?

放棄任務的數量...不太好想。

換個角度想:如果是最多可以做多少任務呢?

1. 從任務所花時間最短的開始選,因為他的費時最小。

3. 從任務結束時間最早的開始做,因為早做完就可以快選下一個。

2. 從任務開始時間最早的開始做,早做完早享受。

哪個是對的?
找得出反例嗎?

不失一般性,我們先根據任務的結束時間做排序。

常用來證明貪心的技巧

接著我們來介紹一種...

[u1, v1]

[?]

[u2, v2]

假設 [u1, v1] 在我們的算法中,會被選為最佳解。

([u_1, v_1] \in OPT)

考量之後會遇到的 [u2, v2] ,他跟 [u1, v1] 重疊
(也就是選了 [u1, v1]後不能選 [u2, v2]),那麼會有幾種狀況。

假設 [u1, v1] 在我們的算法中,會被選為最佳解。

([u_1, v_1] \in OPT)

考量之後會遇到的 [u2, v2] ,他跟 [u1, v1] 重疊
(也就是選了 [u1, v1]後不能選 [u2, v2]),那麼會有幾種狀況。

顯然不管怎麼選
都是[u1,v1]。

[u1, v1]

[u2, v2]

[u1, v1]

[u2, v2]

1. u_2 \le u_1 \le v_1 \le v_2
2. u_1 \le u_2 \le u_1 \le v_2

如果 u1 被選,那麼表示 v2 只會卡到下一個選擇。

假設 [u1, v1] 在我們的算法中,會被選為最佳解。

([u_1, v_1] \in OPT)

顯然不管怎麼選
都是[u1,v1]。

1. u_2 \le u_1 \le v_1 \le v_2
2. u_1 \le u_2 \le u_1 \le v_2

如果 u1 被選,那麼表示 v2 只會卡到下一個選擇。

如果有個答案 [u2, v2] 沒被我們選,
他一定不會比我們選的答案好。QED

\}
\}

Trivial.

如果拿 [u1, v1] 換 [u2, v2]

你有可能會把 [u1, v1] 後面的任務被犧牲。 (可能2換1)

([u_2, v_2] \notin OPT, [u_2, v_2] \in OPT' \\ OPT \ge OPT'
intervals.sort(key = lambda a: a[1])
cur_r = -float('inf')
ans = 0
for l, r in intervals:
  if cur_r <= l:
    cur_r = r
    ans += 1
return len(intervals) - ans
sort(intervals.begin(), intervals.end(), 
     [](auto &a, auto &b) {
       return a[1] < b[1];
     });
int current_end = -50000, chosen = 0;
for (auto &interval : intervals) {
  if (current_end <= interval[0]) {
    current_end = interval[1];
    chosen ++;
  }
}
return intervals.size() - chosen;

[u1, v1]

[u2, v2]

2. u_1 \le u_2 \le u_1 \le v_2

如果 u1 被選,那麼表示 v2 只會卡到下一個選擇。

我們雖然當時這樣講,不過會不會有這樣的情況呢?

1

2

3

1

2

4

3

好好的重新想一遍吧!

貪心小結

再看一次 - 思考流程

  1. 就說了,通靈
  2. 是不是在茫茫選擇中有幾種看起來特別的優?
  3. 嘗試找反例,想想看有沒有其他反例。
    找不到就表示它很有可能就是最佳解。
    • 但很多時候其實是你沒想到反例 XD
  4. 時間允許的話,嘗試證明它
    這會幫助你思考演算法的腦袋(?)
  5. 有些貪心題,你不換個角度是不好看出來的。
  6. 想想看你寫過的貪心題目,有沒有類似的題目

大膽假設,小心求證

練習題!

題目名稱 來源 備註
物品堆疊 (Stacking) Zerojudge c471 APCS 2017/10 - 4
直觀困難,試試數學
The Bus Driver Problem Zerojudge e538 非常經典的題目!
Jump Game Leetcode 55 跟後面題目有關
Jump Game II Leetcode 45  
Largest Number Leetcode 179 Coding Bar py-進階
Largest Merge Of Two Strings Leetcode 1754  

貪心是不是水題?

只是你還沒看過很難的貪心而已 XD

來看看更複雜的貪心題吧!

但通常 APCS 題目的是

警告

在上之後的課程請你複習一下:
Priority Queue ( Python 是 heapq)

set, map, lower_bound
二分搜尋法 (自己用 while 迴圈寫出來的作法)

經典題目!數字合併

Add All (zerojudge d221, UVA 10954)

Add All (zerojudge d221, UVA 10954)

我們定義兩數合併為 merge(x , y) → (x + y),成本為 x + y。

給你一堆數字,求將所有數字合併成一個數字的最小成本。

直觀來說,你會怎麼做?

舉例來說:

現在需要合併的數字為 [1, 2, 3]

那麼答案會是 9。

1

2

3

6

cost : 3

cost : 6

3

Add All (zerojudge d221, UVA 10954)

我們定義兩數合併為 merge(x , y) → (x + y),成本為 x + y。

給你一堆數字,求將所有數字合併成一個數字的最小成本。

直觀來說,你會怎麼做?

由小排到大,從一開始慢慢開始合併。

這樣好像會有點問題...?

合併完的數字要放哪裡?

由小排到大,從一開始慢慢開始合併,合併完放前面

由小排到大,從一開始慢慢開始合併,合併完放後面

Add All (zerojudge d221, UVA 10954)

由小排到大,
合併完
放前面

由小排到大,
合併完
放後面

考慮測資 : [2, 2, 3, 3]

  • Cost 4 - [(2, 2), 3, 3]
  • Cost 4 + 7 [(4, 3), 3]
  • Cost 4 + 7 + 10 [(7, 3)]
  • Cost 4 - [(2, 2), 3, 3]
  • Cost 4 + 6 [4, (3, 3)]
  • Cost 4 + 6 + 10 [(4, 6)]

考慮測資 : [1, 1, 3, 3]

  • Cost 4 - [(1, 1), 3, 3]
  • Cost 4 + 6 [(3, 3), 2]
  • Cost 4 + 6 + 8 [(6, 2)]
  • Cost 4 - [(1, 1), 3, 3]
  • Cost 4 + 5 [(2, 3), 3]
  • Cost 4 + 5 + 8 [(5, 3)]

但最佳解是:

但最佳解是:

不能每次都拿最小的兩個嗎?

Add All (zerojudge d221, UVA 10954)

不能每次都拿最小的兩個嗎?

Priority Queue / heapq!

每次拿最小的兩個合併,合併完丟回去 Priority Queue / heapq

不過,你會證明嗎?

非常的不太適合給高中生證明XD,見這裡

Add All (zerojudge d221, UVA 10954)

from queue import PriorityQueue
while True:
    n = int(input())
    if n == 0:
        break
    Q = PriorityQueue()
    for v in map(int, input().split()):
        Q.put(v)

    cost = 0
    while Q.qsize() > 1:
        A, B = Q.get(), Q.get()
        cost += A+B
        Q.put(A+B)
    print(cost)
#include <bits/stdc++.h>
using namespace std;
int main() {
    int n, x;
    while (cin>>n && n != 0) {
        priority_queue<long long> Q;
        while(n--) {
            cin>>x;
            Q.push(-x);
        }
        long long ans=0, A, B;
        while (Q.size() != 1) {
            A = -Q.top(); Q.pop();
            B = -Q.top(); Q.pop();
            ans += A + B;
            Q.push(-(A + B));
        }
        cout << ans << endl;
    }
}
import heapq
while True:
    n = int(input())
    if n == 0:
        break
    Q = list(map(int, input().split()))
    heapq.heapify(Q)

    cost = 0
    while len(Q) > 1:
        A, B = heapq.heappop(Q), heapq.heappop(Q)
        cost += A+B
        heapq.heappush(Q, A+B)
    print(cost)

這其實很有用!?

我們來看看一個經典算法

霍夫曼編碼

現在有一堆文字,
怎麼編碼才可以讓總編碼的長度期望值最低?

Huffman Coding 霍夫曼編碼

什麼叫做編碼?

文本:

Fuwawa Mococo

字符 次數 編碼
F 1 000
U 2 001
W 2 010
A 2 011
M 1 100
O 3 101
C 2 110

000 001 010 011 010 011
100 101 110 101 110 101

經過編碼:

現在有一堆文字,
怎麼編碼才可以讓總編碼的長度最低呢?

Huffman Coding 霍夫曼編碼

不能亂編碼!會互撞!

字符 次數 編碼
F 1 0
U 2 1
W 2 10
A 2 11
M 1 100
O 3 101
C 2 110

0 1 10 11 10 11
100 101 110 101 110 101

文本:

Fuwawa Mococo

經過編碼:

0 -> F,這沒問題。

110 要看成是 UW 還是 C 呢?

Huffman Coding 霍夫曼編碼

可以把他轉成一個編碼樹,這樣就可以保證不會相撞!

字符 次數 編碼
F 1 000
U 2 001
W 2 010
A 2 011
M 1 100
O 3 101
C 2 110

F

U

W

A

M

O

C

0

0

0

0

0

0

1

1

1

1

1

1

0

開始!

現在有一堆文字,
怎麼編碼才可以讓總編碼的長度最低呢?

Huffman Coding 霍夫曼編碼

字符 次數 編碼
F 1 0000
U 2 001
W 2 010
A 2 011
M 1 0001
O 3 11
C 2 10

F

U

W

A

M

O

C

0

0

0

0

0

1

1

1

1

1

開始!

1

0

讓我們來換個編碼樹!

現在有一堆文字,
怎麼編碼才可以讓總編碼的長度最低呢?

Huffman Coding 霍夫曼編碼

字符 次數 編碼
F 1 0000
U 2 001
W 2 010
A 2 011
M 1 0001
O 3 11
C 2 10

文本:

Fuwawa Mococo

0000 001 010 011 010 011

0001 11 10 11 10 11

經過編碼:

原本: 36個 bit

現在: 33個 bit,更短了!

現在有一堆文字,
怎麼編碼才可以讓總編碼的長度最低呢?

Huffman Coding 霍夫曼編碼

字符 次數 編碼
F 1 0000
U 2 001
W 2 010
A 2 011
M 1 0001
O 3 11
C 2 10

你把兩個點的次數合併起來,
其實就會多花這兩個點的次數來編碼!

把 F 跟 M 合併,
就表示你會花 (F的次數) + (M的次數) 個 bit 來編碼!

這樣你知道跟合併數字的關係了嗎?

F

M

0

1

現在有一堆文字,
怎麼編碼才可以讓總編碼的長度最低呢?

事後貪心選擇

現在有個一維道路。你想開車到 target,但燃料有限。

所幸路上有很多加油站,每個加油站有其位置跟燃料數量。

問你最少加油幾次才可以到target?

start

target

=100

🚘

起始
10 燃料

10

+60 燃料

20

+30 燃料

+30 燃料

30

+40 燃料

60

直觀來說,你會怎麼做?

現在有個一維道路。你想開車到 target,但燃料有限。

所幸路上有很多加油站,每個加油站有其位置跟燃料數量。

問你最少加油幾次才可以到target?

直觀來說,你會怎麼做?

比較多油比較賺的,從最多油的開始加?

如果我們每次看到加油站的時候才決定要不要加,

好像有點困難?

從最多的燃料開始加嗎?

如果從最多燃料的加油站開始加,有沒有什麼條件呢?

start

target

=100

🚘

起始
30 燃料

10

+60 燃料

20

+30 燃料

+30 燃料

40

+40 燃料

60

"後悔沒有加" 的清單

🚘

🚘

🚘

🚘

🚘

  1. 走到 10,油還有剩,放 +60 到後悔清單
  2. 走到 20,油還有剩,放 +30 到後悔清單
  3. 走不到 40,拿最大的後悔清單 (+60)
  4. 走到 40,油還有剩,放+30到後悔清單
  5. 走到 60,油還有剩,放+40到後悔清單
  6. 走不到 100,拿最大的後悔清單 (+40)
  7. 最後答案為 2 

+60 燃料

+30 燃料

+30 燃料

+40 燃料

用過了!

用過了!

🚘

30

事後貪心

通常都會使用 priority_queue 來實作。

核心概念就是先做選擇,出事想辦法彌補!

priority_queue<int> Q;
stations.push_back(vector<int> {target, 0});
sort(stations.begin(), stations.end());
int reach = startFuel, used = 0;
for (auto station : stations) {
  // 如果走不到下個加油站,
  // 就從後悔清單拿到可以走到為止
  while (!Q.empty() && reach < station[0]) {
    reach += Q.top();
    used += 1;
    Q.pop();
  }
  // 已經到達target或者到不了下個加油站,結束。
  if (reach >= target || reach < station[0])
    break;
  Q.push(station[1]);
}
return reach >= target ? used : -1;
reach, used = startFuel, 0
stations.append([target, 0])
stations.sort()
# Heapq is min heap by default. 
# We need *-1 to negate values.
Q = []
for position, fuel in stations:
  # 如果走不到下個加油站,
  # 就從後悔清單拿到可以走到為止
  while Q and reach < position:
    reach += -heapq.heappop(Q)
    used += 1
    # 已經到達target或者到不了下個加油站,結束。
    if reach >= target or reach < position:
      break
    heapq.heappush(Q, -fuel)
return used if reach >= target else -1

貪心+二分搜

基地台 (zj c575, APCS 2017/03-4)

基地台 (zj c575)

你需要放置 k 個基地台在一維城鎮上,目標是想讓所有住家都可以被覆蓋到。

那麼請問基地台的覆蓋半徑最小可以是多少?

(所有基地台的半徑一樣,基地台可以自己設定。)

1

3

11

k = 1
r最低設5
k = 2
r最低設1

1

3

11

直徑=10

直徑=2

直徑=2

完全沒概念... 不妨換個角度想想?

直觀來說,你會怎麼做?

基地台 (zj c575)

放 k 個基地台,
問最小 r 多少才可以覆蓋所有住家?

放 k 個基地台,
給定半徑 r,問可不可以覆蓋所有住家?

k = 3

1

3

11

  1. 從左邊開始看,讓基地台的左界卡到第一個房子
  2. 把第一個基地台可以覆蓋到的房子全部忽略
  3. 重新回到第一步。
  • 為什麼是對的?

5

9

12

原本題目

簡單化題目

直觀來說,你會怎麼做?

二分搜尋很強!

二分搜尋可以在一些很奇怪的地方出現。

跟貪心一樣 - 可能會出現在任何地方!

基地台 (zj c575)

TODO
#include <stdio.h>
#include <algorithm>
int ary[50001], n, k;
bool is_ok(int x){
    int last = -x-1, now_put = 0;  
    for(int i=0; i<n; i++){
        if(last + x < ary[i]){
            now_put ++;
            last = ary[i];
            if(now_put > k){
                return false;
            }
        }
    }
    return true;
}
int main(){
    scanf("%d%d", &n, &k);
    for(int i=0; i<n; i++)
        scanf("%d", &ary[i]);
    std::sort(ary, ary+n);
    int L=0, R=1000000000;
    while(R-L != 1){
        int mid = (L+R) / 2;
        if(is_ok(mid))
            R = mid;
        else
            L = mid;
    }
    printf("%d\n", R);
    return 0;
}

貪心只是一種概念。

能不能夠根據你的貪心來寫出答案,
還是要看你實作的強度。

讓我用下個題目告訴你,實作與想法的碰撞!

一種貪心,各種表述

給你一個直方圖,求其中的最大矩形面積。

舉例來說:

直觀來說不考慮實作,你會怎麼做?

給你一個直方圖,求其中的最大矩形面積。

直觀來說不考慮實作,你會怎麼做?

暴力搜尋它?

有沒有一個比較好的順序去爆搜?

  1. 從最小高度開始。
  2. 從最大高度開始。

直方圖最大矩形 - 解 1

利用線段樹 (Segment Tree) 找區間最小值

給你一個直方圖,求其中的最大矩形面積。

直觀來說不考慮實作,你會怎麼做?

暴力搜尋它?

有沒有一個比較好的順序去爆搜?

  1. 從最小高度開始
  2. 從最大高度開始。

給你一個直方圖,求其中的最大矩形面積。

從最小高度開始

觀察: 如果我們看到最小的數字,那麼以他為準的答案就是 n * 這個數字

[2, 1, 5, 6, 2, 3]

那之後呢? 之後的高度都不該穿過 1,

所以應該把它切開

[2] / [5, 6, 2, 3]

[5, 6] / [3]

/ [6] 

給你一個直方圖,求其中的最大矩形面積。

從最小高度開始

理論通過,開始實作。

[2, 1, 5, 6, 2, 3]

[2] / [5, 6, 2, 3]

將陣列每次都拆開...

  • 如果你每找到最小值就拆開,那麼時間複雜度...
    • O(n^2) 爛掉了 :(。
  • 其實我們不用拆,用左右界就可以表達!
    • 例如 [0, 6) 被 1 拆解後就會變成 [0, 1), [2, 6)。

給你一個直方圖,求其中的最大矩形面積。

從最小高度開始

[2, 1, 5, 6, 2, 3]

[2] / [5, 6, 2, 3]

將陣列每次都拆開 (僅用左右界表示子陣列)...

  • 我們必須找到一個區間的最小值在哪裡。
    • 直接找每個區間的最小值?
      • O(n^2) 又爛掉了 :(
    • 有沒有魔法可以讓我知道區間最小值呢?
      • 有,常見的方法之一,叫做線段樹。

Segment Tree 線段樹

最原始的線段樹是為了解決 RMQ 問題

(Range Minimum Query)。

大方向就是把樹的點當成區間維護。

給你一個直方圖,求其中的最大矩形面積。

從最小高度開始

[2, 1, 5, 6, 2, 3]

[2] / [5, 6, 2, 3]

將陣列每次都拆開 (僅用左右界表示子陣列)...

  1. 我們必須找到一個區間的最小值在哪裡。
    • 使用線段樹找到區間最小值在哪裡。
    • 分治它!
    • 複雜度 
O(n \log n)
struct SegTree {
    // min number, it's index
    pair<int, int> min_info;
    SegTree *L, *R;
    SegTree(SegTree *L, SegTree *R, pair<int, int> min_info={INT_MAX, -1}):
        L(L), R(R), min_info(min_info){} 
    void pull() {
        min_info = min(L->min_info, R->min_info);
    }
};

SegTree* build(int L, int R, vector<int>& A) {
    // [l, r]
    if (L == R)
        return new SegTree(NULL, NULL, {A[L], L});
    int M = (L + R) / 2;
    SegTree *now = new SegTree(build(L, M, A), build(M+1, R, A));
    now->pull();
    return now;
}

pair<int, int> query(SegTree* root, int L, int R, int x, int y) {
    if (R < x || y < L) return {INT_MAX, -1};
    if (x <= L && R <= y) return root->min_info;
    int M = (L + R) / 2;
    return min(query(root->L, L, M, x, y), query(root->R, M+1, R, x, y));
}
class Solution {
public:
    int dc(SegTree *root, int x, int y, int N) {
        auto [min_v, min_idx] = query(root, 0, N-1, x, y);
        if (min_idx == -1)
            return INT_MIN;
        return max({
            (y - x + 1) * min_v,
            dc(root, x, min_idx-1, N),
            dc(root, min_idx+1, y, N)
        });
    }
    int largestRectangleArea(vector<int>& heights) {
        // Special Case:
        bool flag = true;
        for (auto h: heights)
            if (h != heights[0]) {
                flag = false;
                break;
            }
        if (flag)
            return heights[0] * heights.size();
        SegTree *root = build(0, heights.size()-1, heights);
        return dc(root, 0, heights.size()-1, heights.size());
    }
};

直方圖最大矩形 - 解 2

利用 Set 維護區間

給你一個直方圖,求其中的最大矩形面積。

直觀來說不考慮實作,你會怎麼做?

暴力搜尋它?

有沒有一個比較好的順序去爆搜?

  1. 從最小高度開始
  2. 從最大高度開始。

給你一個直方圖,求其中的最大矩形面積。

從最小高度開始

[2, 1, 5, 6, 2, 3]

[2] / [5, 6, 2, 3]

將陣列每次都拆開 (僅用左右界表示子陣列)...

  1. 我們必須找到一個區間的最小值在哪裡。
    • 使用線段樹找到區間最小值在哪裡,然後分治它!
  2. 或是 ... 我們每次都找下一個最小值的區間在哪裡。
    • 把所有區間都存起來查詢!

給你一個直方圖,求其中的最大矩形面積。

[2, 1, 5, 6, 2, 3]

[2] / [5, 6, 2, 3]

或是 ... 我們每次都找下一個最小值的區間在哪裡。

  • 把所有區間都存起來查詢!

數字的排名: (1, 0, 4, 5, 2, 3)

[0, 6)

[0, 1), [2, 6)

/ / [5, 6, 2, 3]

[0, 0), [1, 1), [2, 6)

/ / [5, 6] / [3]

[0, 0), [1, 1), [2, 4), [5, 6)

給你一個直方圖,求其中的最大矩形面積。

或是 ... 我們每次都找下一個最小值的區間在哪裡。

  • 把所有區間都存起來查詢!
    • 什麼資料結構可以動態插入/找到想要的區間呢?
      • 用 set 存後用 lower_bound 查詢!

/ / [5, 6, 2, 3]

[0, 0), [1, 1), [2, 6)

/ / [5, 6] / [3]

[0, 0), [1, 1), [2, 4), [5, 6)

[2] / [5, 6, 2, 3]

[0, 1), [2, 6)

vector<pair<int, int>> order;
for (int i = 0; i < heights.size(); i++) {
	order.push_back({heights[i], i});
}
sort(order.begin(), order.end());
int ans = 0;
set<pair<int, int>> intervals{{0, heights.size()}};
for (auto &[height, idx] : order) {
  auto it = --intervals.lower_bound({idx, INT_MAX});
  ans = max(ans, height * (it->second - it->first));
  intervals.insert({it->first, idx});
  intervals.insert({idx+1, it->second});
  intervals.erase(it);
}
return ans;

Python 沒有內建平衡樹可以用...
所以 Python 不太能這樣寫。

直方圖最大矩形 - 解 3

利用 Linked List 維護區間

給你一個直方圖,求其中的最大矩形面積。

直觀來說不考慮實作,你會怎麼做?

暴力搜尋它?

有沒有一個比較好的順序去爆搜?

  1. 從最小高度開始。
  2. 從最大高度開始。

給你一個直方圖,求其中的最大矩形面積。

最大高度開始。

[2, 1, 5, 6, 2, 3]

觀察: 如果我們看到最大的數字,那麼他為準的答案就是這個數字

  • 接下來我們會想辦法讓他跟其他點合併。

[2, 1, (5, 5), 2, 3]

[2, 1, (5, 5), 2, 3]

[2, 1, (2, 2, 2, 2)]

由大到小看數字,
合併兩側比自己更大的數字。

並且每次更新答案就可以了。

我們用 Doubly Linked List 來合併!

給你一個直方圖,求其中的最大矩形面積。

最大高度開始。

[2, 1, 5, 6, 2, 3]

[2, 1, (5, 5), 2, 3]

[2, 1, (5, 5), 2, 3]

[2, 1, (2, 2, 2, 2)]

2

1

5

6

2

2

3

2

1

5

5

2

2

3

2

1

5 x 2

2

2

3

由大到小看數字,合併兩側比自己更大的數字。

並且每次更新答案就可以了。

6 x 1

5 x 2

答案

3 x 1

2 x 4

2

1

2 x 2

3

2

1

2 x 4

2

struct node {
  node *prev, *next;
  int h, w;
  node(int h, int w): 
    h(h), w(w), prev(NULL), next(NULL) {}
  int merge(set<node *> &deleted) {
    // left
    while (prev && h <= prev->h) {
      w += prev->w;
      node *tmp = prev;
      if (prev->prev)
        prev->prev->next = this;
      prev = prev->prev;
      deleted.insert(tmp);
      delete tmp;
    }
    // right
    while (next && h <= next->h) {
      w += next->w;
      node *tmp = next;
      if (next->next)
        next->next->prev = this;
      next = next->next;
      deleted.insert(tmp);
      delete tmp;
    }
    return w * h;
  }
};
vector<pair<int, node *>> order;
for (int i = 0; i < heights.size(); i++) {
  node *cur = new node(heights[i], 1);
  if (i) {
    order.back().second->next = cur;
    cur->prev = order.back().second;
  }
  order.push_back({heights[i], cur});
}
sort(order.begin(), order.end());
reverse(order.begin(), order.end());
int ans = 0;
set<node *> deleted;
for (auto &[h, cur] : order) {
  if (deleted.find(cur) == deleted.end()) {
    ans = max(ans, cur->merge(deleted));
  }
}
return ans;
  • 如果不判斷刪除,那麼

​遇到重複的數字會很麻煩

TODO

Python 還沒寫

直方圖最大矩形 - 解 4

利用 Disjoint Set 維護區間

給你一個直方圖,求其中的最大矩形面積。

直觀來說不考慮實作,你會怎麼做?

暴力搜尋它?

有沒有一個比較好的順序去爆搜?

  1. 從最小高度開始。
  2. 從最大高度開始。

給你一個直方圖,求其中的最大矩形面積。

最大高度開始。

[2, 1, 5, 6, 2, 3]

觀察: 如果我們看到最大的數字,那麼他為準的答案就是這個數字

  • 接下來我們會想辦法讓他跟其他點合併。

[2, 1, (5, 5), 2, 3]

[2, 1, (5, 5), 2, 3]

[2, 1, (2, 2, 2, 2)]

我們用 Disjoint Set 來合併!

由大到小看數字,
合併兩側比自己更大的數字。

並且每次更新答案就可以了。

給你一個直方圖,求其中的最大矩形面積。

最大高度開始。

由大到小看數字,合併兩側比自己更大的數字。

並且每次更新答案就可以了。

答案

2

1

5

6

2

3

[(1, 1, 1, 1, 1, 1)]

1 x 6

[2, 1, (2, 2, 2, 2)]

2 x 1

[2, 1, (2, 2, 2, 2)]

2 x 4

[2, 1, (5, 5), 2, 3]

3 x 1

[2, 1, (5, 5), 2, 3]

5 x 2

[2, 1, 5, 6, 2, 3]

6 x 1

TODO
boss = [i for i in range(len(nums))]
size = [1 for _ in nums]
ans = 0
def find_boss(i):
	if boss[i] != i:
		boss[i] = find_boss(boss[i])
	return boss[i]
def merge(i, j):
	i, j = find_boss(i), find_boss(j)
	if i != j:
	if nums[i] > nums[j]:
		i, j = j, i
	boss[j] = i
	size[i] += size[j]

ary = sorted([(num, i) for i, num in enumerate(nums)]

for num, i in reversed(ary):
	if i > 0 and nums[i-1] >= nums[i]:
		merge(i-1, i)
	if i+1 < len(nums) and nums[i+1] >= nums[i]:
		merge(i, i+1)
	I = find_boss(i)
	ans = max(ans, nums[I] * size[I])
return ans

直方圖最大矩形 - 解 5

利用 Deque 維護可能的答案

單調對列 (Monotonic Queue)

給你一個直方圖,求其中的最大矩形面積。

直觀來說不考慮實作,你會怎麼做?

暴力搜尋它?

有沒有一個比較好的順序去爆搜?

  1. 從最小高度開始。
  2. 從最大高度開始。

  3. 單調隊列

給你一個直方圖,求其中的最大矩形面積。

單調隊列

我們由左往右看數字:

   2    1    5    6    2    3

你會發現 2 遇到 1 的時候,

2 的作用就完全消失了

   2    1     ?

維護一個遞增數列就好。

給你一個直方圖,求其中的最大矩形面積。

單調隊列

   2    1    5    6    2    3

維護一個遞增數列就好。

怎麼更新答案呢?

考量第二個 2

我們會結算 5 跟 6,
因為之後 5 跟 6 就再也沒用了。

結算方法:(數字大小) x (離 2 的距離)

// Terminate all
heights.push_back(0);
deque<pair<int, int>> Q;
int ans = 0;
for (int i = 0; i < heights.size(); i++) {
  // Pop
  pair<int, int> cur{heights[i], i};
  while (!Q.empty() && Q.back() > cur) {
    ans = max(ans, Q.back().first * (i - Q.back().second));
    cur.second = Q.back().second;
    Q.pop_back();
  }
  Q.push_back(cur);
}
return ans;
nums.append(0)
q = deque()
ans = 0
for i, height in enumerate(nums):
  cur = [height, i]
  while q and q[-1] > cur:
    ans = max(ans, q[-1][0] * (i-q[-1][1]))
    cur[1] = q[-1][1]
    q.pop()
    q.append(cur)
return ans

為了方便,會在最後放 0。

這是為了結算所有的答案。

貪心大結

複習一下所有上課題目

題目名稱 來源 備註
Course Schedule III Leetcode 630 不相交區間困難版

Greedy Algorithm

By Arvin Liu

Greedy Algorithm

Intro to greedy algorithm

  • 1,154