九章算法
前言
第一天的算法都還沒有緩過來,直接就進入了第二天的算法學習。前一天一直在整理Binary Search的筆記,也沒有提前預習一下,好在Binary Tree算是自己最熟的地方了吧(LeetCode上面Binary Tree的題刷了4遍,目前95%以上能夠Bug Free)所以還能跟得上,今天聽了一下,覺得學習到最多的,就是把Traverse和Divide Conquer分開來討論,覺得開啟了一片新的天地!今天寫這個博客我就盡量把兩種方式都寫一寫吧。
Outline:
- 二叉樹的遍歷
- 前序遍歷traverse方法
- 前序遍歷非遞歸方法
- 前序遍歷分治法
- 遍歷方法與分治法
- Maximum Depth of Binary Tree
- Balanced Binary Tree
- 二叉樹的最大路徑和 (root->leaf)
- Binary Tree Maximum Path Sum II (root->any)
- Binary Tree Maximum Path Sum (any->any)
- 二叉查找樹
- Validate Binary Search Tree
- Binary Search Tree Iterator
- 二叉樹的寬度優先搜索
- Binary Tree Level-Order Traversal
課堂筆記
1.二叉樹的遍歷
這個應該是二叉樹里面最基本的題了,但是在面試過程中,不一定會考遞歸的方式,很有可能會讓你寫出非遞歸的方法,上課的時候老師也提到過,應該直接把非遞歸的方法背下來。這里我就不多說了,直接把中序遍歷的兩種方法貼出來吧,最后再加入一個分治法(這也是第一次寫,感覺很棒呢,都不需要太多的思考)。
1.1 前序遍歷traverse方法(Bug Free):
vector<int> res; void helper(TreeNode* root) { if (!root) return; res.push_back(root->val); if (root->left) { helper(root->left); } if (root->right) { helper(root->right); } } vector<int> preorderTraversal(TreeNode *root) { if (!root) { return res; } helper(root); return res; }
1.2 前序遍歷非遞歸方法(Bug Free):
vector<int> preorderTraversal(TreeNode *root) { vector<int> res; if (!root) { return res; } stack<TreeNode*> s; s.push(root); while (!s.empty()) { TreeNode* tmp = s.top(); s.pop(); res.push_back(tmp->val); // 這里注意:棧是先進后出,所以先push右子樹 if (tmp->right) { s.push(tmp->right); } if (tmp->left) { s.push(tmp->left); } } return res; }
1.3 前序遍歷分治法(Java實現):
vector<int> preorderTraversal(TreeNode *root) { vector<int> res; if (!root) { return res; } //Divide vector<int> left = preorderTraversal(root->left); vector<int> right = preorderTraversal(root->right); //Conquer res.push_back(root->val); res.insert(res.end(), left.begin(), left.end()); res.insert(res.end(), right.begin(), right.end()); return res; }
這三種方法也是比較直觀的,前兩個比較基礎,我就不詳細敘述了,但是分治法是值得重點說一說的。前面的遍歷的方法是需要對每一個點進行判斷和處理的,根據DFS進入到每一個節點,然后操作;但是使用分治法的話,就不需要考慮那么多,分治法的核心思想就是把一個整體的問題分為多個子問題來考慮,也就是說:每一個子問題的操作方法都是一樣的,子問題的解是可以合並為原問題的解的(這里就是和動態規划、貪心法不一樣的地方)。所以使用分治法的話,就不需要對每個節點都進行判斷,不管左右子樹的情況(是否存在),直接進行求解,最后把它們合並起來。上課的時候老師也說過分治法就像一個女王大人,處於root的位置,然后派了兩位青蛙大臣去處理一些事物,女王大人只需要管好自己的val是多少,然后把兩個大臣的反饋直接加起來就可以了。個人認為分治法算是比較接近普通人思維的一種方法了。
2. 遍歷方法與分治法
遍歷方法其實在我經過之前各種刷題套模板后算是能夠熟悉掌握了,所謂“雖不知其內涵,但知其模板”的境界,今天這個總結,確實幫助不少。直接承接了上面所說的兩種思考。接下來我就直接用題解來分析一下:
2.1 Maximum Depth of Binary Tree
http://www.lintcode.com/zh-cn/problem/maximum-depth-of-binary-tree/
給定一個二叉樹,找出其最大深度。
二叉樹的深度為根節點到最遠葉子節點的距離。
樣例
給出一棵如下的二叉樹:
1 / \ 2 3 / \ 4 5
這個二叉樹的最大深度為
3
.
這個題目要是在面試的時候面到,那絕對可以一分鍾內寫出來,因為如果考慮分治法的話,就是一個簡單的DFS,代碼如下(Bug Free):
public int maxDepth(TreeNode root) { if (root == null) { return 0; } int left = maxDepth(root.left) + 1; int right = maxDepth(root.right) + 1; return left > right ? left : right; }
就是遞歸查看左右兩邊最大的深度,然后返回就可以。這個思路也比較簡單,我就不多說了。
接下來再來一個題目:
2.2 Balanced Binary Tree
http://www.lintcode.com/zh-cn/problem/balanced-binary-tree/
給定一個二叉樹,確定它是高度平衡的。對於這個問題,一棵高度平衡的二叉樹的定義是:一棵二叉樹中每個節點的兩個子樹的深度相差不會超過1。
樣例
給出二叉樹 A=
{3,9,20,#,#,15,7}
, B={3,#,20,15,7}
A) 3 B) 3 / \ \ 9 20 20 / \ / \ 15 7 15 7二叉樹A是高度平衡的二叉樹,但是B不是
這個題目思路也比較簡單,判斷一下左右子樹的高度差是否小於1,也是一個簡單的分治法問題。因為課上用了一種Java的版本來寫,加入了一個ResultType類,這里我也嘗試着寫了一下代碼(Bug Free):
class ResultType { public boolean isBalanced; public int MaxDepth; public ResultType(boolean isBalanced, int MaxDepth) { this.isBalanced = isBalanced; this.MaxDepth = MaxDepth; } } public class Solution { /** * @param root: The root of binary tree. * @return: True if this Binary tree is Balanced, or false. */ public boolean isBalanced(TreeNode root) { return helper(root).isBalanced; } private ResultType helper(TreeNode root) { if (root == null) { return new ResultType(true, 0); } ResultType left = helper(root.left); ResultType right = helper(root.right); if (!left.isBalanced || !right.isBalanced) { return new ResultType(false, -1); } if (Math.abs(left.MaxDepth - right.MaxDepth) > 1) { return new ResultType(false, -1); } return new ResultType(true, Math.max(left.MaxDepth, right.MaxDepth) + 1); } }
這里的ResultType保存了一個布爾值判斷子樹是否是平衡二叉樹,用一個最大深度表示該子樹的最大深度。然后在Divide階段,分別遞歸調用了左右子樹,之后判斷左右子數的最大深度差,並且判斷它們是否滿足平衡二叉樹,最后返回該子樹的最大深度。這個思考也是比較自然合理的。運用了這種調用類的方式來進行解答,頗有一番面向對象的感覺,但是本人是不太喜歡這種方式的,因為不容易思考,還需要考慮很多自己不熟悉的地方,容易出錯。
接下來就是本篇文章的重要部分了。我要詳細描述一下二叉樹的最大路徑這個問題,記得有一次面試還面到過這個題,我也要把不同的情況寫出來。
先來最簡單的部分吧,給一棵二叉樹,找出從根節點出發到葉節點的路徑中,和最大的一條。這個就比較簡單了,直接遍歷整個樹,然后找到最大的路徑即可,這里我就不多說了,比較簡單。直接上題目吧:
2.3 (1)二叉樹的最大路徑和(root->leaf)
給一棵二叉樹,找出從根節點出發到葉節點的路徑中,和最大的一條。
樣例
給出如下的二叉樹:
1 / \ 2 3
返回
4
。(最大的路徑為1→3)
就不需要多解釋了,我就直接把代碼貼出來(Bug Free):
public int maxPathSum2(TreeNode root) { if (root == null) { return 0; } int left = maxPathSum2(root.left); int right = maxPathSum2(root.right); return root.val + Math.max(left, right); }
(2)二叉樹的最大路徑和(root->any)
2.4 Binary Tree Maximum Path Sum II
http://www.lintcode.com/zh-cn/problem/binary-tree-maximum-path-sum-ii/
給一棵二叉樹,找出從根節點出發的路徑中,和最大的一條。
這條路徑可以在任何二叉樹中的節點結束,但是必須包含至少一個點(也就是根了)。
樣例
給出如下的二叉樹:
1 / \ 2 3
返回
4
。(最大的路徑為1→3)
這個就跟原始版的題目不一樣了,這里是從根到任意的節點,當然就不能采用原始問題的方法了,不然就是指數級別的復雜度了,這里就采用分治法了:
我們把分治的基本思想考慮進去:
1.遞歸的出口:當節點為null
2.Divide:分別對左右進行遞歸
3.Conquer:把得到的結果進行操作。
Java代碼如下(Bug Free):
public int maxPathSum2(TreeNode root) { if (root == null) { return 0; } int left = maxPathSum2(root.left); int right = maxPathSum2(root.right); return root.val + Math.max(0, Math.max(left, right)); }
這里有一個關鍵點,對於某一個節點來說,得到了左右子樹的和,這里我就要判斷是否加上子樹(這個部分就是和原始問題不一樣的地方,保證了是任意的節點),加上子樹的話是加左子樹還是右子樹,然后就能得到最大值了。這個題最大的關鍵還是在於不考慮左右子樹如何,就把他們派出去,得到結果以后再進行判斷。
(3)二叉樹中的最大路徑和(any->any)
2.5 Binary Tree Maximum Path Sum
http://www.lintcode.com/zh-cn/problem/binary-tree-maximum-path-sum/
給出一棵二叉樹,尋找一條路徑使其路徑和最大,路徑可以在任一節點中開始和結束(路徑和為兩個節點之間所在路徑上的節點權值之和)
樣例
給出一棵二叉樹:
1 / \ 2 3返回
6
這個題是上一個題目的升級版,這里求的就是任意兩個點的最大路徑和了。這樣的題其實就是從上面的題做了一個引申,不過之前的題必須考慮到root,所以就直接判斷左右子樹,而這里的話,就不需要考慮root了,所以問題就變成了一個“把每一個節點都當作root來考慮的問題”,這里是我自己的理解,可能我沒有表達清楚,也就是說,在每一步遞歸中,都需要把當前的root考慮為上一題中的root,然后來判斷哪個root得到的值是最大的。所以這里就需要增加一個全局變量來存儲了。代碼如下:
int Max = INT_MIN; int helper(TreeNode *root) { if (!root) { return 0; } int tmp = root->val; //Divide int left = helper(root->left); int right = helper(root->right); //Conquer if (left > 0) { tmp += left; } if (right > 0) { tmp += right; } Max = max(Max, tmp); return max(0,max(left,right)) + root->val; } int maxPathSum(TreeNode *root) { int t = helper(root); return Max; }
這道題其實我在很久前的一次面試中就被問到過,當時面試官的描述就是比較奇怪,並沒有說any to any的問題,而是說任意一段路徑,但是不能有分叉。其實回過頭來思考,這個題也確實需要考慮這個問題:不能有分叉!如果允許分叉的話,那么這個問題就沒有那么簡單了。當時我就半天沒有寫出來,而這次在lintcode上能做到Bug Free,果然還是一個完全不擅於上戰場的人啊( ▼-▼ )。這個題關鍵就在於你要去判斷左右子樹的值是否會讓這一個小團的值變小,如果會,那就不加上左右子樹。最后的return也是一個關鍵的地方:因為不能有分叉,所以只返回一條路徑。
這兩個題目就是充分運用了分治的方法,還需要大家很深刻的去理解一下其中的內涵,還是有一些需要思考的地方。
3. 二叉查找樹
個人認為在樹的題目中,最令人開心的就是二叉查找樹了,因為這種結構本身就帶有一種光環:左子樹小於root,右子樹大於root,這方面的題只需要緊緊圍繞這個概念來做就可以。
直接上一個課上說過的題吧:
3.1 Validate Binary Search Tree
http://www.lintcode.com/zh-cn/problem/validate-binary-search-tree/
給定一個二叉樹,判斷它是否是合法的二叉查找樹(BST)
一棵BST定義為:
- 節點的左子樹中的值要嚴格小於該節點的值。
- 節點的右子樹中的值要嚴格大於該節點的值。
- 左右子樹也必須是二叉查找樹。
- 一個節點的樹也是二叉查找樹。
樣例
一個例子:
2 / \ 1 4 / \ 3 5
上述這棵二叉樹序列化為
{2,1,4,#,#,3,5}
.
看了這道題,我的第一個想法就是,判斷左邊最大的是否小於root,然后判斷右邊最小的是否大於root,然后遞歸去判斷。這個算法復雜度也比較高,最后還是過了,可以貼上來給大家看看:
bool isValidBST(TreeNode *root) { if (!root) { return true; } if (root->left) { TreeNode *left = root->left; while (left->right) { left = left->right; } if (left->val >= root->val) { return false; } } if (root->right) { TreeNode *right = root->right; while (right->left) { right = right->left; } if (right->val <= root->val) { return false; } } return isValidBST(root->left)&&isValidBST(root->right); }
思路很簡單,就是找到左邊,然后找到最右的子樹,然后判斷root的val和它的關系,右子樹同理。之后遞歸往下進行判斷。
課上講過的另一種方法就優化了很多,用一個全局變量來存儲前一個指針,然后和當前的root比較,然后更新這個指針,代碼如下(Bug Free):
TreeNode *lastNode = NULL; bool isValidBST(TreeNode *root) { if (!root) { return true; } if (!isValidBST(root->left)) { return false; } if (lastNode && lastNode->val >= root->val) { return false; } lastNode = root; return isValidBST(root->right); }
這個方法比較直觀,就是利用二叉樹的中序遍歷的方法,其中last每次都更新為當前的節點。
關於二叉查找樹還有一個簡單的設計類的題,我就不多說了,直接上題吧:
3.2 Binary Search Tree Iterator
http://www.lintcode.com/en/problem/binary-search-tree-iterator/
Design an iterator over a binary search tree with the following rules:
- Elements are visited in ascending order (i.e. an in-order traversal)
next()
andhasNext()
queries run in O(1) time in average.Example
For the following binary search tree, in-order traversal by using iterator is
[1, 6, 10, 11, 12]
10 / \ 1 11 \ \ 6 12
我使用了隊列的方式來存儲二叉樹,然后進行相應的操作,代碼如下(Bug Free):
class BSTIterator { private: queue<TreeNode*> res; void helper(TreeNode *root) { if (!root) { return; } helper(root->left); res.push(root); helper(root->right); } public: //@param root: The root of binary tree. BSTIterator(TreeNode *root) { helper(root); } //@return: True if there has next node, or false bool hasNext() { return !res.empty(); } //@return: return next node TreeNode* next() { TreeNode *tmp = res.front(); res.pop(); return tmp; } };
給出一棵二叉樹,返回其節點值的層次遍歷(逐層從左往右訪問)樣例給一棵二叉樹
{3,9,20,#,#,15,7}
:3 / \ 9 20 / \ 15 7
返回他的分層遍歷結果:
[ [3], [9,20], [15,7] ]
vector<vector<int>> levelOrder(TreeNode *root) { vector<vector<int>> result; if (root == NULL) { return result; } queue<TreeNode *> Q; Q.push(root); while (!Q.empty()) { int size = Q.size(); vector<int> level; //這里需要注意的trick for (int i = 0; i < size; i++) { TreeNode *head = Q.front(); Q.pop(); level.push_back(head->val); if (head->left != NULL) { Q.push(head->left); } if (head->right != NULL) { Q.push(head->right); } } result.push_back(level); } return result; }
老師的方法是判斷一下當前隊列的size,然后以此作為分層的判斷,之后進行size次循環,表示一層。
我的方法(Bug Free):
vector<vector<int>> levelOrder(TreeNode *root) { vector<vector<int>> res; vector<int> ans; if (!root) { return res; } queue<TreeNode *> q; q.push(root); //加入一個NULL指針作為層分界 q.push(NULL); while (!q.empty()) { TreeNode *tmp = q.front(); q.pop(); //到達分界點 if (!tmp) { if (!q.empty()) { res.push_back(ans); ans.clear(); q.push(NULL); } else { res.push_back(ans); return res; } } else { ans.push_back(tmp->val); if (tmp->left) { q.push(tmp->left); } if (tmp->right) { q.push(tmp->right); } } } return res; }
我的方法是在每層遍歷完之后加入一個NULL指針作為分界的標准,當到達NULL的時候,判斷q是否為空,不為空則表示當前層已經遍歷結束,然后把當前層push_back到res中,然后清空;q為空則表示到達最后一層,記錄答案然后返回即可。
總結
本文對二叉樹和分治法進行了一個闡述,其實就是把課堂上和面試的一些想法拿到這里來說了一下。在上課之前一直沒有想過太多關於traverse和分治有什么太大的區別,反正就是遞歸,這次好好總結一下覺得有很多地方需要用到分治。我把以前寫的分治法的總結帖在下面吧:
一、概念
對於一個規模為n的問題,若該問題可以容易地解決(比如說規模n較小)則直接解決,否則將其分解為k個規模較小的子問題,這些子問題互相獨立且與原問題形式相同,遞歸地解決這些子問題,然后將各子問題的解合並得到原問題的解。這種算法設計策略叫做分治法。
二、分治法適用情況1)問題的規模縮小到一定程度就可以容易解決2)具有最子結構的性質(遞歸思想)3)子問題的解可以合並為原問題的解(關鍵,否則為貪心法或者動態規划法)4)子問題是相互獨立的 ,子問題之間不包含公共的子子問題(重復解公共的子問題,一般用動態規划法比較好)
三、分治法的步驟step1 分解:將原問題分解為若干個規模較小,相互獨立,與原問題形式相同的子問題step2 解決:子問題規模較小而容易被解決則直接解決,否則遞歸地解各個子問題step3 合並:將各個子問題的解合並為原問題的解
設計模式Divide-and-Conquer(P)if |P|<=N0 then return (ADHOC(P))將P分解為較小的字問題P1,P2,…,Pkfor i<-1 to kßdo Yi <- Divide-and-Conquer(Pi) 遞歸解決PiT <- MERGE(Y1,Y2,…,Yk) 合並子問題return (T)