7.5 - 貪心篇完結

七龙猪發表於2024-07-06

435. 無重疊區間

題意描述:

[!WARNING]

給定一個區間的集合 intervals ,其中 intervals[i] = [starti, endi] 。返回 需要移除區間的最小數量,使剩餘區間互不重疊

示例 1:

輸入: intervals = [[1,2],[2,3],[3,4],[1,3]]
輸出: 1
解釋: 移除 [1,3] 後,剩下的區間沒有重疊。

示例 2:

輸入: intervals = [ [1,2], [1,2], [1,2] ]
輸出: 2
解釋: 你需要移除兩個 [1,2] 來使剩下的區間沒有重疊。

示例 3:

輸入: intervals = [ [1,2], [2,3] ]
輸出: 0
解釋: 你不需要移除任何區間,因為它們已經是無重疊的了。

提示:

  • 1 <= intervals.length <= 105
  • intervals[i].length == 2
  • -5 * 104 <= starti < endi <= 5 * 104

思路:

[!TIP]

相信很多同學看到這道題目都冥冥之中感覺要排序,但是究竟是按照右邊界排序,還是按照左邊界排序呢?

其實都可以。主要就是為了讓區間儘可能的重疊。

我來按照右邊界排序,從左向右記錄非交叉區間的個數。最後用區間總數減去非交叉區間的個數就是需要移除的區間個數了

此時問題就是要求非交叉區間的最大個數。

這裡記錄非交叉區間的個數還是有技巧的,如圖:

img

區間,1,2,3,4,5,6都按照右邊界排好序。

當確定區間 1 和 區間2 重疊後,如何確定是否與 區間3 也重貼呢?

就是取 區間1 和 區間2 右邊界的最小值,因為這個最小值之前的部分一定是 區間1 和區間2 的重合部分,如果這個最小值也觸達到區間3,那麼說明 區間 1,2,3都是重合的。

接下來就是找大於區間1結束位置的區間,是從區間4開始。那有同學問了為什麼不從區間5開始?別忘了已經是按照右邊界排序的了

區間4結束之後,再找到區間6,所以一共記錄非交叉區間的個數是三個。

總共區間個數為6,減去非交叉區間的個數3。移除區間的最小數量就是3。

C++程式碼如下:

class Solution {
public:
    // 按照區間右邊界排序
    static bool cmp (const vector<int>& a, const vector<int>& b) {
        return a[1] < b[1];
    }
    int eraseOverlapIntervals(vector<vector<int>>& intervals) {
        if (intervals.size() == 0) return 0;
        sort(intervals.begin(), intervals.end(), cmp);
        int count = 1; // 記錄非交叉區間的個數
        int end = intervals[0][1]; // 記錄區間分割點
        for (int i = 1; i < intervals.size(); i++) {
            if (end <= intervals[i][0]) {
                end = intervals[i][1];
                count++;
            }
        }
        return intervals.size() - count;
    }
};
  • 時間複雜度:O(nlog n) ,有一個快排
  • 空間複雜度:O(n),有一個快排,最差情況(倒序)時,需要n次遞迴呼叫。因此確實需要O(n)的棧空間

大家此時會發現如此複雜的一個問題,程式碼實現卻這麼簡單!

補充

補充(1)

左邊界排序可不可以呢?

也是可以的,只不過左邊界排序我們就是直接求重疊的區間,count為記錄重疊區間數。

class Solution {
public:
    static bool cmp (const vector<int>& a, const vector<int>& b) {
        return a[0] < b[0]; // 改為左邊界排序
    }
    int eraseOverlapIntervals(vector<vector<int>>& intervals) {
        if (intervals.size() == 0) return 0;
        sort(intervals.begin(), intervals.end(), cmp);
        int count = 0; // 注意這裡從0開始,因為是記錄重疊區間
        int end = intervals[0][1]; // 記錄區間分割點
        for (int i = 1; i < intervals.size(); i++) {   
            if (intervals[i][0] >= end)  end = intervals[i][1]; // 無重疊的情況
            else { // 重疊情況 
                end = min(end, intervals[i][1]);
                count++;
            }
        }
        return count;
    }
};

其實程式碼還可以精簡一下, 用 intervals[ i ] [ 1 ] 替代 end變數,只判斷 重疊情況就好

class Solution {
public:
    static bool cmp (const vector<int>& a, const vector<int>& b) {
        return a[0] < b[0]; // 改為左邊界排序
    }
    int eraseOverlapIntervals(vector<vector<int>>& intervals) {
        if (intervals.size() == 0) return 0;
        sort(intervals.begin(), intervals.end(), cmp);
        int count = 0; // 注意這裡從0開始,因為是記錄重疊區間
        for (int i = 1; i < intervals.size(); i++) {
            if (intervals[i][0] < intervals[i - 1][1]) { //重疊情況
                intervals[i][1] = min(intervals[i - 1][1], intervals[i][1]);
                count++;
            }
        }
        return count;
    }
};

補充(2)

本題其實和452.用最少數量的箭引爆氣球 (opens new window)非常像,弓箭的數量就相當於是非交叉區間的數量,只要把弓箭那道題目程式碼裡射爆氣球的判斷條件加個等號(認為 [0 , 1] [ 1 , 2 ]不是相鄰區間),然後用總區間數減去弓箭數量 就是要移除的區間數量了。

452.用最少數量的箭引爆氣球 (opens new window)程式碼稍做修改,就可以AC本題。

class Solution {
public:
    // 按照區間右邊界排序
    static bool cmp (const vector<int>& a, const vector<int>& b) {
        return a[1] < b[1]; // 右邊界排序 
    }
    int eraseOverlapIntervals(vector<vector<int>>& intervals) {
        if (intervals.size() == 0) return 0;
        sort(intervals.begin(), intervals.end(), cmp);

        int result = 1; // points 不為空至少需要一支箭
        for (int i = 1; i < intervals.size(); i++) {
            if (intervals[i][0] >= intervals[i - 1][1]) {
                result++; // 需要一支箭
            }
            else {  // 氣球i和氣球i-1挨著
                intervals[i][1] = min(intervals[i - 1][1], intervals[i][1]); // 更新重疊氣球最小右邊界
            }
        }
        return intervals.size() - result;
    }
};

這裡按照 左邊界排序,或者按照右邊界排序,都可以AC,原理是一樣的。

class Solution {
public:
    // 按照區間左邊界排序
    static bool cmp (const vector<int>& a, const vector<int>& b) {
        return a[0] < b[0]; // 左邊界排序
    }
    int eraseOverlapIntervals(vector<vector<int>>& intervals) {
        if (intervals.size() == 0) return 0;
        sort(intervals.begin(), intervals.end(), cmp);

        int result = 1; // points 不為空至少需要一支箭
        for (int i = 1; i < intervals.size(); i++) {
            if (intervals[i][0] >= intervals[i - 1][1]) {
                result++; // 需要一支箭
            }
            else {  // 氣球i和氣球i-1挨著
                intervals[i][1] = min(intervals[i - 1][1], intervals[i][1]); // 更新重疊氣球最小右邊界
            }
        }
        return intervals.size() - result;
    }
};

763.劃分字母區間

題意描述:

[!WARNING]

給你一個字串 s 。我們要把這個字串劃分為儘可能多的片段,同一字母最多出現在一個片段中。

注意,劃分結果需要滿足:將所有劃分結果按順序連線,得到的字串仍然是 s

返回一個表示每個字串片段的長度的列表。

示例 1:

輸入:s = "ababcbacadefegdehijhklij"
輸出:[9,7,8]
解釋:
劃分結果為 "ababcbaca"、"defegde"、"hijhklij" 。
每個字母最多出現在一個片段中。
像 "ababcbacadefegde", "hijhklij" 這樣的劃分是錯誤的,因為劃分的片段數較少。 

示例 2:

輸入:s = "eccbbbbdec"
輸出:[10]

提示:

  • 1 <= s.length <= 500
  • s 僅由小寫英文字母組成

思路:

[!TIP]

一想到分割字串就想到了回溯,但本題其實不用回溯去暴力搜尋。

題目要求同一字母最多出現在一個片段中,那麼如何把同一個字母的都圈在同一個區間裡呢?

如果沒有接觸過這種題目的話,還挺有難度的。

在遍歷的過程中相當於是要找每一個字母的邊界,如果找到之前遍歷過的所有字母的最遠邊界,說明這個邊界就是分割點了。此時前面出現過所有字母,最遠也就到這個邊界了。

可以分為如下兩步:

  • 統計每一個字元最後出現的位置
  • 從頭遍歷字元,並更新字元的最遠出現下標,如果找到字元最遠出現位置下標和當前下標相等了,則找到了分割點

如圖:

763.劃分字母區間

明白原理之後,程式碼並不複雜,如下:

class Solution {
public:
    vector<int> partitionLabels(string S) {
        int hash[27] = {0}; // i為字元,hash[i]為字元出現的最後位置
        for (int i = 0; i < S.size(); i++) { // 統計每一個字元最後出現的位置
            hash[S[i] - 'a'] = i;
        }
        vector<int> result;
        int left = 0;
        int right = 0;
        for (int i = 0; i < S.size(); i++) {
            right = max(right, hash[S[i] - 'a']); // 找到字元出現的最遠邊界
            if (i == right) {
                result.push_back(right - left + 1);
                left = i + 1;
            }
        }
        return result;
    }
};
  • 時間複雜度:O(n)
  • 空間複雜度:O(1),使用的hash陣列是固定大小

總結

這道題目leetcode標記為貪心演算法,說實話,我沒有感受到貪心,找不出區域性最優推出全域性最優的過程。就是用最遠出現距離模擬了圈字元的行為。

但這道題目的思路是很巧妙的,所以有必要介紹給大家做一做,感受一下。

補充

這裡提供一種與452.用最少數量的箭引爆氣球 (opens new window)435.無重疊區間 (opens new window)相同的思路。

統計字串中所有字元的起始和結束位置,記錄這些區間(實際上也就是435.無重疊區間 (opens new window)題目裡的輸入),將區間按左邊界從小到大排序,找到邊界將區間劃分成組,互不重疊。找到的邊界就是答案。

class Solution {
public:
    static bool cmp(vector<int> &a, vector<int> &b) {
        return a[0] < b[0];
    }
    // 記錄每個字母出現的區間
    vector<vector<int>> countLabels(string s) {
        vector<vector<int>> hash(26, vector<int>(2, INT_MIN));
        vector<vector<int>> hash_filter;
        for (int i = 0; i < s.size(); ++i) {
            if (hash[s[i] - 'a'][0] == INT_MIN) {
                hash[s[i] - 'a'][0] = i;
            }
            hash[s[i] - 'a'][1] = i;
        }
        // 去除字串中未出現的字母所佔用區間
        for (int i = 0; i < hash.size(); ++i) {
            if (hash[i][0] != INT_MIN) {
                hash_filter.push_back(hash[i]);
            }
        }
        return hash_filter;
    }
    vector<int> partitionLabels(string s) {
        vector<int> res;
        // 這一步得到的 hash 即為無重疊區間題意中的輸入樣例格式:區間列表
        // 只不過現在我們要求的是區間分割點
        vector<vector<int>> hash = countLabels(s);
        // 按照左邊界從小到大排序
        sort(hash.begin(), hash.end(), cmp);
        // 記錄最大右邊界
        int rightBoard = hash[0][1];
        int leftBoard = 0;
        for (int i = 1; i < hash.size(); ++i) {
            // 由於字串一定能分割,因此,
            // 一旦下一區間左邊界大於當前右邊界,即可認為出現分割點
            if (hash[i][0] > rightBoard) {
                res.push_back(rightBoard - leftBoard + 1);
                leftBoard = hash[i][0];
            }
            rightBoard = max(rightBoard, hash[i][1]);
        }
        // 最右端
        res.push_back(rightBoard - leftBoard + 1);
        return res;
    }
};

56. 合併區間

題意描述:

[!WARNING]

以陣列 intervals 表示若干個區間的集合,其中單個區間為 intervals[i] = [starti, endi] 。請你合併所有重疊的區間,並返回 一個不重疊的區間陣列,該陣列需恰好覆蓋輸入中的所有區間

示例 1:

輸入:intervals = [[1,3],[2,6],[8,10],[15,18]]
輸出:[[1,6],[8,10],[15,18]]
解釋:區間 [1,3] 和 [2,6] 重疊, 將它們合併為 [1,6].

示例 2:

輸入:intervals = [[1,4],[4,5]]
輸出:[[1,5]]
解釋:區間 [1,4] 和 [4,5] 可被視為重疊區間。

提示:

  • 1 <= intervals.length <= 104
  • intervals[i].length == 2
  • 0 <= starti <= endi <= 104

思路:

[!TIP]

本題的本質其實還是判斷重疊區間問題。

大家如果認真做題的話,話發現和我們剛剛講過的452. 用最少數量的箭引爆氣球 (opens new window)435. 無重疊區間 (opens new window)都是一個套路。

這幾道題都是判斷區間重疊,區別就是判斷區間重疊後的邏輯,本題是判斷區間重貼後要進行區間合併。

所以一樣的套路,先排序,讓所有的相鄰區間儘可能的重疊在一起,按左邊界,或者右邊界排序都可以,處理邏輯稍有不同。

按照左邊界從小到大排序之後,如果 intervals[i][0] <= intervals[i - 1][1] 即intervals[i]的左邊界 <= intervals[i - 1]的右邊界,則一定有重疊。(本題相鄰區間也算重貼,所以是<=)

這麼說有點抽象,看圖:(注意圖中區間都是按照左邊界排序之後了

56.合併區間

知道如何判斷重複之後,剩下的就是合併了,如何去模擬合併區間呢?

其實就是用合併區間後左邊界和右邊界,作為一個新的區間,加入到result陣列裡就可以了。如果沒有合併就把原區間加入到result陣列。

C++程式碼如下:

class Solution {
public:
    vector<vector<int>> merge(vector<vector<int>>& intervals) {
        vector<vector<int>> result;
        if (intervals.size() == 0) return result; // 區間集合為空直接返回
        // 排序的引數使用了lambda表示式
        sort(intervals.begin(), intervals.end(), [](const vector<int>& a, const vector<int>& b){return a[0] < b[0];});

        // 第一個區間就可以放進結果集裡,後面如果重疊,在result上直接合並
        result.push_back(intervals[0]); 

        for (int i = 1; i < intervals.size(); i++) {
            if (result.back()[1] >= intervals[i][0]) { // 發現重疊區間
                // 合併區間,只更新右邊界就好,因為result.back()的左邊界一定是最小值,因為我們按照左邊界排序的
                result.back()[1] = max(result.back()[1], intervals[i][1]); 
            } else {
                result.push_back(intervals[i]); // 區間不重疊 
            }
        }
        return result;
    }
};
  • 時間複雜度: O(nlogn)
  • 空間複雜度: O(logn),排序需要的空間開銷

補充

[!CAUTION]

為什麼cmp函式在作為類成員函式的時候一定需要static修飾呢?這是因為所有我們在類內定義的非static成員函式在經過編譯後隱式的為他們新增了一個this指標引數!變為了:

bool cmp(Solution *this, int a, int b)

而標準庫的sort()函式的第三個cmp函式指標引數中並沒有這樣this指標引數,因此會出現輸入的cmp引數和sort()要求的引數不匹配,從而導致了:

error: reference to non-static member function must be called

而我們知道static靜態類成員函式是不需要this指標的,因此改為靜態成員函式即可透過!

cmp函式時的格式:

static bool cmp(const vector<int>& a , const vector<int>& b){
  return a[0] < b[0];
}

738.單調遞增的數字

題意描述:

[!WARNING]

當且僅當每個相鄰位數上的數字 xy 滿足 x <= y 時,我們稱這個整數是單調遞增的。

給定一個整數 n ,返回 小於或等於 n 的最大數字,且數字呈 單調遞增

示例 1:

輸入: n = 10
輸出: 9

示例 2:

輸入: n = 1234
輸出: 1234

示例 3:

輸入: n = 332
輸出: 299

提示:

  • 0 <= n <= 109

思路:

[!TIP]

暴力解法

題意很簡單,那麼首先想的就是暴力解法了,來我替大家暴力一波,結果自然是超時!

image-20240706004552320

程式碼如下:

class Solution {
private:
    // 判斷一個數字的各位上是否是遞增
    bool checkNum(int num) {
        int max = 10;
        while (num) {
          //t為最後一位
            int t = num % 10;
            if (max >= t) max = t;
            else return false;
            num = num / 10;
        }
        return true;
    }
public:
    int monotoneIncreasingDigits(int N) {
        for (int i = N; i > 0; i--) { // 從大到小遍歷
            if (checkNum(i)) return i;
        }
        return 0;
    }
};
  • 時間複雜度:O(n × m) m為n的數字長度
  • 空間複雜度:O(1)

貪心演算法

題目要求小於等於N的最大單調遞增的整數,那麼拿一個兩位的數字來舉例。

例如:98,一旦出現strNum[i - 1] > strNum[i]的情況(非單調遞增),首先想讓strNum[i - 1]--,然後strNum[i]給為9,這樣這個整數就是89,即小於98的最大的單調遞增整數。

這一點如果想清楚了,這道題就好辦了。

此時是從前向後遍歷還是從後向前遍歷呢?

從前向後遍歷的話,遇到strNum[i - 1] > strNum[i]的情況,讓strNum[i - 1]減一,但此時如果strNum[i - 1]減一了,可能又小於strNum[i - 2]

這麼說有點抽象,舉個例子,數字:332,從前向後遍歷的話,那麼就把變成了329,此時2又小於了第一位的3了,真正的結果應該是299。

那麼從後向前遍歷,就可以重複利用上次比較得出的結果了,從後向前遍歷332的數值變化為:332 -> 329 -> 299

確定了遍歷順序之後,那麼此時區域性最優就可以推出全域性,找不出反例,試試貪心。

C++程式碼如下:

class Solution {
public:
    int monotoneIncreasingDigits(int N) {
        string strNum = to_string(N);
        // flag用來標記賦值9從哪裡開始,因為9後面的必定都是9
        // 設定為這個預設值,為了防止第二個for迴圈在flag沒有被賦值的情況下執行
        int flag = strNum.size();
        for (int i = strNum.size() - 1; i > 0; i--) {
            if (strNum[i - 1] > strNum[i] ) {
                flag = i;
                strNum[i - 1]--;
            }
        }
        for (int i = flag; i < strNum.size(); i++) {
            strNum[i] = '9';
        }
        return stoi(strNum);
    }
};
  • 時間複雜度:O(n),n 為數字長度
  • 空間複雜度:O(n),需要一個字串,轉化為字串操作更方便

總結

本題只要想清楚個例,例如98,一旦出現strNum[i - 1] > strNum[i]的情況(非單調遞增),首先想讓strNum[i - 1]減一,strNum[i]賦值9,這樣這個整數就是89。就可以很自然想到對應的貪心解法了。

想到了貪心,還要考慮遍歷順序,只有從後向前遍歷才能重複利用上次比較的結果

最後程式碼實現的時候,也需要一些技巧,例如用一個flag來標記從哪裡開始賦值9。

補充

stoi()to_string 這兩個函式都是對字串處理的函式,**前者是將字串轉化為十進位制 int 型別,最後一個是將十進位制型別 int、double 等轉化為string。
標頭檔案都是:#include


968.監控二叉樹

題意描述:

[!CAUTION]

給定一個二叉樹,我們在樹的節點上安裝攝像頭。

節點上的每個攝影頭都可以監視其父物件、自身及其直接子物件。

計算監控樹的所有節點所需的最小攝像頭數量。

示例 1:

img

輸入:[0,0,null,0,0]
輸出:1
解釋:如圖所示,一臺攝像頭足以監控所有節點。

示例 2:

img

輸入:[0,0,null,0,null,0,null,null,0]
輸出:2
解釋:需要至少兩個攝像頭來監視樹的所有節點。 上圖顯示了攝像頭放置的有效位置之一。

提示:

  1. 給定樹的節點數的範圍是 [1, 1000]
  2. 每個節點的值都是 0。

思路:

[!TIP]

這道題目首先要想,如何放置,才能讓攝像頭最小的呢?

從題目中示例,其實可以得到啟發,我們發現題目示例中的攝像頭都沒有放在葉子節點上!

這是很重要的一個線索,攝像頭可以覆蓋上中下三層,如果把攝像頭放在葉子節點上,就浪費的一層的覆蓋。

所以把攝像頭放在葉子節點的父節點位置,才能充分利用攝像頭的覆蓋面積。

那麼有同學可能問了,為什麼不從頭結點開始看起呢,為啥要從葉子節點看呢?

因為頭結點放不放攝像頭也就省下一個攝像頭, 葉子節點放不放攝像頭省下了的攝像頭數量是指數階別的。

所以我們要從下往上看,區域性最優:讓葉子節點的父節點安攝像頭,所用攝像頭最少,整體最優:全部攝像頭數量所用最少!

區域性最優推出全域性最優,找不出反例,那麼就按照貪心來!

此時,大體思路就是從低到上,先給葉子節點父節點放個攝像頭,然後隔兩個節點放一個攝像頭,直至到二叉樹頭結點。

此時這道題目還有兩個難點:

  1. 二叉樹的遍歷
  2. 如何隔兩個節點放一個攝像頭

確定遍歷順序確定遍歷順序

在二叉樹中如何從低向上推導呢?

可以使用後序遍歷也就是左右中的順序,這樣就可以在回溯的過程中從下到上進行推導了。

後序遍歷程式碼如下:

int traversal(TreeNode* cur) {

    // 空節點,該節點有覆蓋
    if (終止條件) return ;

    int left = traversal(cur->left);    // 左
    int right = traversal(cur->right);  // 右

    邏輯處理                            // 中
    return ;
}

注意在以上程式碼中我們取了左孩子的返回值,右孩子的返回值,即left right, 以後推導中間節點的狀態

如何隔兩個節點放一個攝像頭

此時需要狀態轉移的公式,大家不要和動態的狀態轉移公式混到一起,本題狀態轉移沒有擇優的過程,就是單純的狀態轉移!

來看看這個狀態應該如何轉移,先來看看每個節點可能有幾種狀態:

有如下三種:

  • 該節點無覆蓋
  • 本節點有攝像頭
  • 本節點有覆蓋

我們分別有三個數字來表示:

  • 0:該節點無覆蓋
  • 1:本節點有攝像頭
  • 2:本節點有覆蓋

大家應該找不出第四個節點的狀態了。

一些同學可能會想有沒有第四種狀態:本節點無攝像頭,其實無攝像頭就是 無覆蓋 或者 有覆蓋的狀態,所以一共還是三個狀態。

因為在遍歷樹的過程中,就會遇到空節點,那麼問題來了,空節點究竟是哪一種狀態呢? 空節點表示無覆蓋? 表示有攝像頭?還是有覆蓋呢?

迴歸本質,為了讓攝像頭數量最少,我們要儘量讓葉子節點的父節點安裝攝像頭,這樣才能攝像頭的數量最少。

那麼空節點不能是無覆蓋的狀態,這樣葉子節點就要放攝像頭了,空節點也不能是有攝像頭的狀態,這樣葉子節點的父節點就沒有必要放攝像頭了,而是可以把攝像頭放在葉子節點的爺爺節點上。

所以空節點的狀態只能是有覆蓋,這樣就可以在葉子節點的父節點放攝像頭了

接下來就是遞推關係。

那麼遞迴的終止條件應該是遇到了空節點,此時應該返回2(有覆蓋),原因上面已經解釋過了。

程式碼如下:

// 空節點,該節點有覆蓋
if (cur == NULL) return 2;

遞迴的函式,以及終止條件已經確定了,再來看單層邏輯處理。

主要有如下四類情況:

  • 情況1:左右節點都有覆蓋

左孩子有覆蓋,右孩子有覆蓋,那麼此時中間節點應該就是無覆蓋的狀態了。

如圖:

968.監控二叉樹2

程式碼如下:

// 左右節點都有覆蓋
if (left == 2 && right == 2) return 0;
  • 情況2:左右節點至少有一個無覆蓋的情況

如果是以下情況,則中間節點(父節點)應該放攝像頭:

  • left == 0 && right == 0 左右節點無覆蓋
  • left == 1 && right == 0 左節點有攝像頭,右節點無覆蓋
  • left == 0 && right == 1 左節點有無覆蓋,右節點攝像頭
  • left == 0 && right == 2 左節點無覆蓋,右節點覆蓋
  • left == 2 && right == 0 左節點覆蓋,右節點無覆蓋

這個不難理解,畢竟有一個孩子沒有覆蓋,父節點就應該放攝像頭

此時攝像頭的數量要加一,並且return 1,代表中間節點放攝像頭。

程式碼如下:

if (left == 0 || right == 0) {
    result++;
    return 1;
}
  • 情況3:左右節點至少有一個有攝像頭

如果是以下情況,其實就是 左右孩子節點有一個有攝像頭了,那麼其父節點就應該是2(覆蓋的狀態)

  • left == 1 && right == 2 左節點有攝像頭,右節點有覆蓋
  • left == 2 && right == 1 左節點有覆蓋,右節點有攝像頭
  • left == 1 && right == 1 左右節點都有攝像頭

程式碼如下:

if (left == 1 || right == 1) return 2;

從這個程式碼中,可以看出,如果left == 1, right == 0 怎麼辦?其實這種條件在情況2中已經判斷過了,如圖:

968.監控二叉樹1

這種情況也是大多數同學容易迷惑的情況。

  • 情況4:頭結點沒有覆蓋

以上都處理完了,遞迴結束之後,可能頭結點 還有一個無覆蓋的情況,如圖:

968.監控二叉樹3

所以遞迴結束之後,還要判斷根節點,如果沒有覆蓋,result++,程式碼如下:

int minCameraCover(TreeNode* root) {
    result = 0;
    if (traversal(root) == 0) { // root 無覆蓋
        result++;
    }
    return result;
}

以上四種情況我們分析完了,程式碼也差不多了,整體程式碼如下:

以下我的程式碼註釋很詳細,為了把情況說清楚,特別把每種情況列出來。

C++程式碼如下:

// 版本一
class Solution {
private:
    int result;
    int traversal(TreeNode* cur) {

        // 空節點,該節點有覆蓋
        if (cur == NULL) return 2;

        int left = traversal(cur->left);    // 左
        int right = traversal(cur->right);  // 右

        // 情況1
        // 左右節點都有覆蓋
        if (left == 2 && right == 2) return 0;

        // 情況2
        // left == 0 && right == 0 左右節點無覆蓋
        // left == 1 && right == 0 左節點有攝像頭,右節點無覆蓋
        // left == 0 && right == 1 左節點有無覆蓋,右節點攝像頭
        // left == 0 && right == 2 左節點無覆蓋,右節點覆蓋
        // left == 2 && right == 0 左節點覆蓋,右節點無覆蓋
        if (left == 0 || right == 0) {
            result++;
            return 1;
        }

        // 情況3
        // left == 1 && right == 2 左節點有攝像頭,右節點有覆蓋
        // left == 2 && right == 1 左節點有覆蓋,右節點有攝像頭
        // left == 1 && right == 1 左右節點都有攝像頭
        // 其他情況前段程式碼均已覆蓋
        if (left == 1 || right == 1) return 2;

        // 以上程式碼我沒有使用else,主要是為了把各個分支條件展現出來,這樣程式碼有助於讀者理解
        // 這個 return -1 邏輯不會走到這裡。
        return -1;
    }

public:
    int minCameraCover(TreeNode* root) {
        result = 0;
        // 情況4
        if (traversal(root) == 0) { // root 無覆蓋
            result++;
        }
        return result;
    }
};

在以上程式碼的基礎上,再進行精簡,程式碼如下:

// 版本二
class Solution {
private:
    int result;
    int traversal(TreeNode* cur) {
        if (cur == NULL) return 2;
        int left = traversal(cur->left);    // 左
        int right = traversal(cur->right);  // 右
        if (left == 2 && right == 2) return 0;
        else if (left == 0 || right == 0) {
            result++;
            return 1;
        } else return 2;
    }
public:
    int minCameraCover(TreeNode* root) {
        result = 0;
        if (traversal(root) == 0) { // root 無覆蓋
            result++;
        }
        return result;
    }
};
  • 時間複雜度: O(n),需要遍歷二叉樹上的每個節點
  • 空間複雜度: O(n)

大家可能會驚訝,居然可以這麼簡短,其實就是在版本一的基礎上,使用else把一些情況直接覆蓋掉了

在網上關於這道題解可以搜到很多這種神級別的程式碼,但都沒講不清楚,如果直接看程式碼的話,指定越看越暈,所以建議大家對著版本一的程式碼一步一步來,版本二中看不中用!

總結

本題的難點首先是要想到貪心的思路,然後就是遍歷和狀態推導。

在二叉樹上進行狀態推導,其實難度就上了一個臺階了,需要對二叉樹的操作非常嫻熟。

這道題目是名副其實的hard,大家感受感受。


總結:

貪心理論基礎

在貪心繫列開篇詞關於貪心演算法,你該瞭解這些! (opens new window)中,我們就講解了大家對貪心的普遍疑惑。

  1. 貪心很簡單,就是常識?

跟著一起刷題的錄友們就會發現,貪心思路往往很巧妙,並不簡單。

  1. 貪心有沒有固定的套路?

貪心無套路,也沒有框架之類的,需要多看多練培養感覺才能想到貪心的思路。

  1. 究竟什麼題目是貪心呢?

Carl個人認為:如果找出區域性最優並可以推出全域性最優,就是貪心,如果區域性最優都沒找出來,就不是貪心,可能是單純的模擬。(並不是權威解讀,一家之辭哈)

但我們也不用過於強調什麼題目是貪心,什麼不是貪心,那就太學術了,畢竟學會解題就行了。

  1. 如何知道區域性最優推出全域性最優,有數學證明麼?

在做貪心題的過程中,如果再來一個資料證明,其實沒有必要,手動模擬一下,如果找不出反例,就試試貪心。面試中,程式碼寫出來跑過測試用例即可,或者自己能自圓其說理由就行了

就像是 要用一下 1 + 1 = 2,沒有必要再證明一下 1 + 1 究竟為什麼等於 2。(例子極端了點,但是這個道理)

貪心簡單題

以下三道題目就是簡單題,大家會發現貪心感覺就是常識。是的,如下三道題目,就是靠常識,但我都具體分析了區域性最優是什麼,全域性最優是什麼,貪心也要貪的有理有據!

  • 貪心演算法:分發餅乾(opens new window)
  • 貪心演算法:K次取反後最大化的陣列和(opens new window)
  • 貪心演算法:檸檬水找零(opens new window)

貪心中等題

貪心中等題,靠常識可能就有點想不出來了。開始初現貪心演算法的難度與巧妙之處。

  • 貪心演算法:擺動序列(opens new window)
  • 貪心演算法:單調遞增的數字(opens new window)

貪心解決股票問題

大家都知道股票系列問題是動規的專長,其實用貪心也可以解決,而且還不止就這兩道題目,但這兩道比較典型,我就拿來單獨說一說

  • 貪心演算法:買賣股票的最佳時機II(opens new window)
  • 貪心演算法:買賣股票的最佳時機含手續費 (opens new window)本題使用貪心演算法比較繞,建議後面學習動態規劃章節的時候,理解動規就好

兩個維度權衡問題

在出現兩個維度相互影響的情況時,兩邊一起考慮一定會顧此失彼,要先確定一個維度,再確定另一個一個維度。

  • 貪心演算法:分發糖果(opens new window)
  • 貪心演算法:根據身高重建佇列(opens new window)

在講解本題的過程中,還強調了程式語言的重要性,模擬插隊的時候,使用C++中的list(連結串列)替代了vector(動態陣列),效率會高很多。

所以在貪心演算法:根據身高重建佇列(續集) (opens new window)詳細講解了,為什麼用list(連結串列)更快!

大家也要掌握自己所用的程式語言,理解其內部實現機制,這樣才能寫出高效的演算法!

貪心難題

這裡的題目如果沒有接觸過,其實是很難想到的,甚至接觸過,也一時想不出來,所以題目不要做一遍,要多練!

貪心解決區間問題

關於區間問題,大家應該印象深刻,有一週我們專門講解的區間問題,各種覆蓋各種去重。

  • 貪心演算法:跳躍遊戲(opens new window)
  • 貪心演算法:跳躍遊戲II(opens new window)
  • 貪心演算法:用最少數量的箭引爆氣球(opens new window)
  • 貪心演算法:無重疊區間(opens new window)
  • 貪心演算法:劃分字母區間(opens new window)
  • 貪心演算法:合併區間(opens new window)

其他難題

貪心演算法:最大子序和 (opens new window)其實是動態規劃的題目,但貪心效能更優,很多同學也是第一次發現貪心能比動規更優的題目。

貪心演算法:加油站 (opens new window)可能以為是一道模擬題,但就算模擬其實也不簡單,需要把while用的很嫻熟。但其實是可以使用貪心給時間複雜度降低一個數量級。

最後貪心繫列壓軸題目貪心演算法:我要監控二叉樹! (opens new window),不僅貪心的思路不好想,而且需要對二叉樹的操作特別嫻熟,這就是典型的交叉類難題了。

貪心專題匯聚為一張圖:

img

相關文章