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]
相信很多同學看到這道題目都冥冥之中感覺要排序,但是究竟是按照右邊界排序,還是按照左邊界排序呢?
其實都可以。主要就是為了讓區間儘可能的重疊。
我來按照右邊界排序,從左向右記錄非交叉區間的個數。最後用區間總數減去非交叉區間的個數就是需要移除的區間個數了。
此時問題就是要求非交叉區間的最大個數。
這裡記錄非交叉區間的個數還是有技巧的,如圖:
區間,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]
一想到分割字串就想到了回溯,但本題其實不用回溯去暴力搜尋。
題目要求同一字母最多出現在一個片段中,那麼如何把同一個字母的都圈在同一個區間裡呢?
如果沒有接觸過這種題目的話,還挺有難度的。
在遍歷的過程中相當於是要找每一個字母的邊界,如果找到之前遍歷過的所有字母的最遠邊界,說明這個邊界就是分割點了。此時前面出現過所有字母,最遠也就到這個邊界了。
可以分為如下兩步:
- 統計每一個字元最後出現的位置
- 從頭遍歷字元,並更新字元的最遠出現下標,如果找到字元最遠出現位置下標和當前下標相等了,則找到了分割點
如圖:
明白原理之後,程式碼並不複雜,如下:
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]的右邊界,則一定有重疊。(本題相鄰區間也算重貼,所以是<=)這麼說有點抽象,看圖:(注意圖中區間都是按照左邊界排序之後了)
知道如何判斷重複之後,剩下的就是合併了,如何去模擬合併區間呢?
其實就是用合併區間後左邊界和右邊界,作為一個新的區間,加入到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]
當且僅當每個相鄰位數上的數字
x
和y
滿足x <= y
時,我們稱這個整數是單調遞增的。給定一個整數
n
,返回 小於或等於n
的最大數字,且數字呈 單調遞增 。示例 1:
輸入: n = 10 輸出: 9
示例 2:
輸入: n = 1234 輸出: 1234
示例 3:
輸入: n = 332 輸出: 299
提示:
- 0 <= n <= 109
思路:
[!TIP]
暴力解法
題意很簡單,那麼首先想的就是暴力解法了,來我替大家暴力一波,結果自然是超時!
程式碼如下:
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:
輸入:[0,0,null,0,0] 輸出:1 解釋:如圖所示,一臺攝像頭足以監控所有節點。
示例 2:
輸入:[0,0,null,0,null,0,null,null,0] 輸出:2 解釋:需要至少兩個攝像頭來監視樹的所有節點。 上圖顯示了攝像頭放置的有效位置之一。
提示:
- 給定樹的節點數的範圍是
[1, 1000]
。- 每個節點的值都是 0。
思路:
[!TIP]
這道題目首先要想,如何放置,才能讓攝像頭最小的呢?
從題目中示例,其實可以得到啟發,我們發現題目示例中的攝像頭都沒有放在葉子節點上!
這是很重要的一個線索,攝像頭可以覆蓋上中下三層,如果把攝像頭放在葉子節點上,就浪費的一層的覆蓋。
所以把攝像頭放在葉子節點的父節點位置,才能充分利用攝像頭的覆蓋面積。
那麼有同學可能問了,為什麼不從頭結點開始看起呢,為啥要從葉子節點看呢?
因為頭結點放不放攝像頭也就省下一個攝像頭, 葉子節點放不放攝像頭省下了的攝像頭數量是指數階別的。
所以我們要從下往上看,區域性最優:讓葉子節點的父節點安攝像頭,所用攝像頭最少,整體最優:全部攝像頭數量所用最少!
區域性最優推出全域性最優,找不出反例,那麼就按照貪心來!
此時,大體思路就是從低到上,先給葉子節點父節點放個攝像頭,然後隔兩個節點放一個攝像頭,直至到二叉樹頭結點。
此時這道題目還有兩個難點:
- 二叉樹的遍歷
- 如何隔兩個節點放一個攝像頭
確定遍歷順序確定遍歷順序
在二叉樹中如何從低向上推導呢?
可以使用
後序遍歷
也就是左右中的順序,這樣就可以在回溯的過程中從下到上進行推導了。後序遍歷程式碼如下:
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:左右節點都有覆蓋
左孩子有覆蓋,右孩子有覆蓋,那麼此時中間節點應該就是無覆蓋的狀態了。
如圖:
程式碼如下:
// 左右節點都有覆蓋 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中已經判斷過了,如圖:
這種情況也是大多數同學容易迷惑的情況。
- 情況4:頭結點沒有覆蓋
以上都處理完了,遞迴結束之後,可能頭結點 還有一個無覆蓋的情況,如圖:
所以遞迴結束之後,還要判斷根節點,如果沒有覆蓋,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)中,我們就講解了大家對貪心的普遍疑惑。
- 貪心很簡單,就是常識?
跟著一起刷題的錄友們就會發現,貪心思路往往很巧妙,並不簡單。
- 貪心有沒有固定的套路?
貪心無套路,也沒有框架之類的,需要多看多練培養感覺才能想到貪心的思路。
- 究竟什麼題目是貪心呢?
Carl個人認為:如果找出區域性最優並可以推出全域性最優,就是貪心,如果區域性最優都沒找出來,就不是貪心,可能是單純的模擬。(並不是權威解讀,一家之辭哈)
但我們也不用過於強調什麼題目是貪心,什麼不是貪心,那就太學術了,畢竟學會解題就行了。
- 如何知道區域性最優推出全域性最優,有數學證明麼?
在做貪心題的過程中,如果再來一個資料證明,其實沒有必要,手動模擬一下,如果找不出反例,就試試貪心。面試中,程式碼寫出來跑過測試用例即可,或者自己能自圓其說理由就行了
就像是 要用一下 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),不僅貪心的思路不好想,而且需要對二叉樹的操作特別嫻熟,這就是典型的交叉類難題了。