还要打家劫舍
思路
這道題目和 198.打家劫舍,213.打家劫舍II也是如出一轍,只不過這個換成了樹。
對于樹的話,首先就要想到遍歷方式,前中后序(深度優先搜索)還是層序遍歷(廣度優先搜索)。
本題一定是要后序遍歷,因為通過遞歸函數的返回值來做下一步計算。
與198.打家劫舍,213.打家劫舍II一樣,關鍵是要討論當前節點搶還是不搶。
如果搶了當前節點,兩個孩子就不能搶,如果沒搶當前節點,就可以考慮搶左右孩子(注意這里說的是“考慮”)
暴力遞歸
代碼如下:
class Solution { public:int rob(TreeNode* root) {if (root == NULL) return 0;if (root->left == NULL && root->right == NULL) return root->val;// 偷父節點int val1 = root->val;if (root->left) val1 += rob(root->left->left) + rob(root->left->right); // 跳過root->left,相當于不考慮左孩子了if (root->right) val1 += rob(root->right->left) + rob(root->right->right); // 跳過root->right,相當于不考慮右孩子了// 不偷父節點int val2 = rob(root->left) + rob(root->right); // 考慮root的左右孩子return max(val1, val2);} }; * 時間復雜度:O(n^2) 這個時間復雜度不太標準,也不容易準確化,例如越往下的節點重復計算次數就越多 * 空間復雜度:O(logn) 算上遞推系統棧的空間當然以上代碼超時了,這個遞歸的過程中其實是有重復計算了。
我們計算了root的四個孫子(左右孩子的孩子)為頭結點的子樹的情況,又計算了root的左右孩子為頭結點的子樹的情況,計算左右孩子的時候其實又把孫子計算了一遍。
記憶化遞推
所以可以使用一個map把計算過的結果保存一下,這樣如果計算過孫子了,那么計算孩子的時候可以復用孫子節點的結果。
代碼如下:
class Solution { public:unordered_map<TreeNode* , int> umap; // 記錄計算過的結果int rob(TreeNode* root) {if (root == NULL) return 0;if (root->left == NULL && root->right == NULL) return root->val;if (umap[root]) return umap[root]; // 如果umap里已經有記錄則直接返回// 偷父節點int val1 = root->val;if (root->left) val1 += rob(root->left->left) + rob(root->left->right); // 跳過root->leftif (root->right) val1 += rob(root->right->left) + rob(root->right->right); // 跳過root->right// 不偷父節點int val2 = rob(root->left) + rob(root->right); // 考慮root的左右孩子umap[root] = max(val1, val2); // umap記錄一下結果return max(val1, val2);} }; * 時間復雜度:O(n) * 空間復雜度:O(logn) 算上遞推系統棧的空間動態規劃
在上面兩種方法,其實對一個節點 投與不投得到的最大金錢都沒有做記錄,而是需要實時計算。
而動態規劃其實就是使用狀態轉移容器來記錄狀態的變化,這里可以使用一個長度為2的數組,記錄當前節點偷或不偷所得到的最大金錢。
1、 確定遞歸函數的參數和返回值
這里我們要求一個節點 偷與不偷的兩個狀態所得到的金錢,那么返回值就是一個長度為2的數組。
參數為當前節點,代碼如下:
vector<int> robTree(TreeNode* cur)其實這里的返回數組就是dp數組。
所以dp數組(dp table)以及下標的含義:
下標為0記錄不偷該節點所得到的最大金錢,下標為1記錄偷該節點所得到的最大金錢。
所以本題dp數組就是一個長度為2的數組!
那么有同學可能疑惑,長度為2的數組怎么標記樹中每個節點的狀態呢?
別忘了在遞歸的過程中,系統棧會保存每一層遞歸的參數。
如果還不理解的話,就接著往下看,看到代碼就理解了哈。
2. 確定終止條件
在遍歷的過程中,如果遇到空結點的話,很明顯,無論偷還是不偷都是0,所以就返回
if (cur == NULL) return vector<int>{0, 0};這也相當于dp數組的初始化
3. 確定遍歷順序
首先明確的是使用后序遍歷。 因為通過遞歸函數的返回值來做下一步計算。
通過遞歸左節點,得到左節點偷與不偷的金錢。
通過遞歸右節點,得到右節點偷與不偷的金錢。
代碼如下:
// 下標0:不偷,下標1:偷 vector<int> left = robTree(cur->left); // 左 vector<int> right = robTree(cur->right); // 右 // 中4. 確定單層遞歸的邏輯
如果是偷當前節點,那么左右孩子就不能偷
val1 = cur->val + left[0] + right[0];(如果對下標含義不理解就在回顧一下dp數組的含義)
如果不偷當前節點,那么左右孩子就可以偷,至于到底偷不偷一定是選一個最大的val2 = max(left[0], left[1]) + max(right[0], right[1]);
最后當前節點的狀態就是{val2, val1}; 即:{不偷當前節點得到的最大金錢,偷當前節點得到的最大金錢}
代碼如下:
vector<int> left = robTree(cur->left); // 左 vector<int> right = robTree(cur->right); // 右// 偷cur int val1 = cur->val + left[0] + right[0]; // 不偷cur int val2 = max(left[0], left[1]) + max(right[0], right[1]); return {val2, val1};5. 舉例推導dp數組
以示例1為例,dp數組狀態如下:(注意用后序遍歷的方式推導)
最后頭結點就是 取下標0 和 下標1的最大值就是偷得的最大金錢。
遞歸三部曲與動規五部曲分析完畢,C++代碼如下:
/*** Definition for a binary tree node.* struct TreeNode {* int val;* TreeNode *left;* TreeNode *right;* TreeNode() : val(0), left(nullptr), right(nullptr) {}* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left), right(right) {}* };*///左孩子準備兩個數組dp[0],dp[1],然后返回來告訴父親,不選我得到dp[0],選我得到dp[1]//右孩子也是如此 class Solution { public:// 長度為2的數組,0:不偷,1:偷vector<int> traversal(TreeNode* cur){if(cur==nullptr) return {0,0};vector<int> left=traversal(cur->left);vector<int> right=traversal(cur->right);// 偷curint val1=cur->val+left[0]+right[0];// 不偷curint val2=max(left[0],left[1])+max(right[0],right[1]);return {val2,val1};}int rob(TreeNode* root) {vector<int> res=traversal(root);return max(res[0],res[1]);} }; * 時間復雜度:O(n) 每個節點只遍歷了一次 * 空間復雜度:O(logn) 算上遞推系統棧的空間總結
這道題是樹形DP的入門題目,所謂樹形DP就是在樹上進行遞歸公式的推導。
所以樹形DP也沒有那么神秘!
只不過平時我們習慣了在一維數組或者二維數組上推導公式,一下子換成了樹,就需要對樹的遍歷方式足夠了解!
總結