演算法 | 第4章 樹與圖相關《程式設計師面試金典》
阿新 • • 發佈:2021-10-25
目錄
新人制作,如有錯誤,歡迎指出,感激不盡!
歡迎關注公眾號,會分享一些更日常的東西!
如需轉載,請標註出處!
- 前言
- 0. *經驗總結
- 1. 節點間通路 [medium]
- 2. 最小高度樹 [easy]
- 3. 特定深度節點連結串列 [medium]
- 4. 檢查平衡性 [easy]
- 5. 合法二叉搜尋樹 [medium]
- 6. 後繼者 [medium]
- 8. 首個共同祖先 [medium]
- 9. 二叉搜尋樹序列 [hard]
- 10. 檢查子樹 [medium]
- 12. 求和路徑 [medium]
- 最後
前言
本系列筆記主要記錄筆者刷《程式設計師面試金典》演算法的一些想法與經驗總結,按專題分類,主要由兩部分構成:經驗值點和經典題目。其中重點放在經典題目上;
本章有10題,標號到12,沒有第7和第11題;
0. *經驗總結
0.1 程式設計師面試金典 P85
- 樹的基本組成單位是節點node,節點可以封裝左右指標、父節點指標等;
- 可以使用一個名為Tree的類來封裝節點,但在面試中不常用;如果使用Tree類能使程式碼簡單或完善,可以使用Tree類;
- 注意區分以下概念,以及一些樹的基本特點與遍歷特點《詳情見第0.2點 各種樹的特點》:
- 樹與二叉樹;
- 二叉樹和二叉搜尋樹(又稱二叉排序樹);
- 平衡與不平衡;
- 完整二叉樹;
- 滿二叉樹;
- 完美二叉樹;
- 二叉堆(小頂堆和大頂堆)
- 單次查詢樹(前序樹)
- 順序儲存二叉樹;
- 線索化二叉樹;
- 霍夫曼樹;
- B樹、B+樹和B*樹;
0.2 各種樹的特點
1. 二叉搜尋樹(二叉排序樹)
- 全部左邊子孫節點 <= n <= 全部右邊子孫節點;
- 碰到二叉樹時,很多面試者會假定面試官問的是二叉搜尋樹,此時要務必問清是否的二叉搜尋樹;
2. 平衡二叉樹
- 它是一棵空樹或它的左右兩個子樹的高度差的絕對值不超過1,並且左右兩個子樹都是一棵平衡二叉樹;
- 樹是否平衡需要跟面試官確認;
- 思考方向可以有:“平衡”樹實際上多半意味著“不是非常不平衡的樹”。其平衡性足以保證執行insert和find操作可以在O(log n)的時間複雜度內完成,但不一定是嚴格意義上的平衡樹;
- 平衡二叉樹的常用實現方法有紅黑樹、AVL、替罪羊樹、Treap、伸展樹等;
3. 完整二叉樹
- 除最後一層,其它層被填滿,並且最後一層節點從左到右填充;
- 中序遍歷結果按順序排列;
4. 滿二叉樹
- 每個節點都有0個或兩個子節點,不存在只有一個子節點的節點;
5. 完滿二叉樹
- 完滿二叉樹既是完整二叉樹,又是滿二叉樹;
- 正好有2k-1個節點;
6. 二叉堆(小頂堆和大頂堆)
- 一個二叉堆是一個完整二叉樹,每個節點值小於其左右子節點;
- 向最小堆插入元素,總是從最底部、最右邊節點開始,通過與其祖先節點交換來向上傳遞較小值;
- 刪除最小堆最小元素,將堆中最後一個元素放在頂節點,通過與子節點交換來向下傳遞較大值;
- 上述兩個演算法時間複雜度為O(log n);
7. 單次查詢樹(前序樹)
- *節點(有時稱空節點)代表一個完整單次;
- 相比散列表可以識別字符串是否是任何有效單次的字首,可以在O(K)的時間複雜度內檢查字串是否為有效字首;
- 涉及一組有效單詞的問題可以使用單次查詢樹進行優化;
8. 順序儲存二叉樹
- 從資料儲存來看,陣列儲存方式和樹的儲存方式可以相互轉換,即陣列可以轉換成樹,樹也可以轉換成陣列;
- 順序二叉樹通常只考慮完全二叉樹;
- 第n個元素的左子節點為 2*n+1;
- 第n個元素的右子節點為 2*n+2;
- 第n個元素的父節點為 (n-1)/2;
- n : 表示二叉樹中的第幾個元素(按0開始編號如圖所示);
9. 線索化二叉樹
- n個結點的二叉連結串列中含有 2n-(n-1)=n+1 個空指標域。利用二叉連結串列中的空指標域,存放指向該結點在某種遍歷次序下的前驅和後繼結點的指標(這種附加的指標稱為"線索");
10. 霍夫曼樹
- 給定n個權值作為n個葉子結點,構造一棵二叉樹,若該樹的帶權路徑長度(wpl)達到最小,稱這樣的二叉樹為最優二叉樹,也稱為哈夫曼樹(Huffman Tree);
11. B樹、B+樹和B*樹
0.3 樹的三種遍歷方式
中序遍歷:
void inOrderTraversal(TreeNode node){
if(node != null){
inOrderTraversal(node.left);
visit(node);
inOrderTraversal(node.right);
}
}
前序遍歷:
void preOrderTraversal(TreeNode node){
if(node != null){
visit(node);
preOrderTraversal(node.left);
preOrderTraversal(node.right);
}
}
後序遍歷:
void postOrderTraversal(TreeNode node){
if(node != null){
postOrderTraversal(node.left);
postOrderTraversal(node.right);
visit(node);
}
}
0.4 圖的表示形式與搜尋
圖的表示方式:
- 鄰接連結串列法;
- 鄰接矩陣法;
- 鄰接矩陣法使用BFS搜尋,效率會比較低,因為需要迭代所有結點以便找到相鄰節點;
圖的搜尋:
- 深度優先搜尋(DFS);
- 訪問圖中所有節點,或者訪問最少節點直至找到某節點,DFS一般比較簡單;
- 廣度優先搜尋(BFS);
- 如果想找到兩個節點的最短路徑(或任意路徑),BFS一般比較適宜;
- BFS是通過佇列實現;
- 雙向搜尋;
- 雙向搜尋用於查詢起始節點和目的節點間的最短路徑;
- 本質上是從起始節點和目的節點同時開始的兩個廣度優先搜尋;
- 當兩個搜尋相遇時,即找到一條路徑
深度優先搜尋(DFS):
- 前序和樹遍歷的其他形式都是一種DFS;
void search(Node root){
if(root == null){
return;
}
visit(root);
root.visited = true;
for each(Node n in root.adjacent){
if(n.visited == false){
search(n);
}
}
}
廣度優先搜尋(BFS):
void search(Node root){
Queue queue = new Queue();
root.marked = true;
queue.enqueue(root); //加入隊尾
while(!queue.isEmpty()){
Node r = queue.dequeue(); //從佇列頭部刪除
visit(r);
for each(Node n in r.adjacent){
if(n.mark == false){
n.marked = true;
queue.enqueue(n);
}
}
}
}
1. 節點間通路 [medium]
1.1 考慮點
- 詢問面試官重複的邊是否是雙向邊,本題中不是。筆者以為是雙向邊,第五種解法給出思路;
- 可以跟面試官討論廣度優先搜尋和深度優先搜尋的利弊:
- 廣度優先搜尋:適合查詢最短路徑;
- 深度優先搜尋:實現比較簡單,遞迴即可;在訪問鄰近節點之前,可能會深度遍歷其中一個鄰近節點;
1.2 解法
1.2.1 廣度優先遍歷法(優)
public boolean findWhetherExistsPath(int n, int[][] graph, int start, int target) {
Queue<Integer> queue = new LinkedList<>();
boolean[] isVisit = new boolean[n];
isVisit[start] = true;
queue.offer(start);
while( !queue.isEmpty() ){
int thisNode = queue.poll();
int toNode;
for(int i = 0; i < graph.length; i++){
if(graph[i][0] == thisNode){
toNode = graph[i][1];
if(toNode == target){
return true;
}
if(!isVisit[toNode]){
isVisit[toNode] = true;
queue.offer(toNode);
}
}
}
}
return false;
}
- 執行時間:91.21%;記憶體消耗:92.42%;
- 適用於n較少的時候,當n過大會超時;
1.2.2 深度優先遍歷法
private boolean[] visited = null;
public boolean findWhetherExistsPath(int n, int[][] graph, int start, int target) {
this.visited = new boolean[graph.length];
return helper(graph, start, target);
}
private boolean helper(int[][] graph, int start, int target) {
for (int i = 0; i < graph.length; ++i) {
// 確保當前路徑未被訪問(該判斷主要是為了防止圖中自環出現死迴圈的情況)
if (!visited[i]) {
// 若當前路徑起點與終點相符,則直接返回結果
if (graph[i][0] == start && graph[i][1] == target) {
return true;
}
// 設定訪問標誌
visited[i] = true;
// DFS關鍵程式碼,思路:同時逐漸壓縮搜尋區間
if (graph[i][1] == target && helper(graph, start, graph[i][0])) {
return true;
}
// 清除訪問標誌
visited[i] = false;
}
}
return false;
}
- 執行時間:81.17%;記憶體消耗:97.41%;
- 首先設定訪問狀態陣列
- 使用DFS「深度優先搜尋」進行遞迴搜尋,逐漸壓縮搜尋區間,直至最終找到start與target在同一個路徑內,則說明查詢成功;
1.2.3 鄰接矩陣的冪的性質
public boolean findWhetherExistsPath(int n, int[][] graph, int start, int target) {
int[][] adjacentMatrix = new int[n][n];
for (int[] edge : graph) {
adjacentMatrix[edge[0]][edge[1]] = 1;
}
for (int i = 1; i <= n; i++) {
int[][] matrix = matrixPow(adjacentMatrix, i);
if (matrix[start][target] != 0) {
return true;
}
}
return false;
}
public int[][] matrixPow(int[][] matrix, int n) {
int size = matrix.length;
int[][] res = new int[size][];
for (int i = 0; i < size; i++) {
res[i] = Arrays.copyOf(matrix[i], size);
}
for (int i = 1; i < n; i++) {
for (int row = 0; row < size; row++) {
int[] tmp = new int[size];
for (int col = 0; col < size; col++) {
for (int j = 0; j < size; j++) {
tmp[col] += res[row][j] * matrix[j][col];
}
}
res[row] = Arrays.copyOf(tmp, size);
}
}
return res;
}
- 在本測試用例中超時;
- 用到鄰接矩陣的冪的性質;
1.2.4 當重複邊為雙向邊時
public boolean findWhetherExistsPath(int n, int[][] graph, int start, int target) {
int[] count = new int[n*(n-1)/2];
for(int i = 0; i < graph.length; i++){
int mark = (2*n-1-graph[i][0])*graph[i][0]/2-1+graph[i][1]-graph[i][0];
count[mark]++;
if(count[mark] == 2){
int cache = graph[i][0];
graph[i][0] = graph[i][1];
graph[i][1] = cache;
}
}
Queue<Integer> queue = new LinkedList<>();
boolean[] isVisit = new boolean[n*(n-1)/2];
queue.offer(start);
while( !queue.isEmpty() ){
int thisNode = queue.poll();
int toNode;
for(int i = 0; i < graph.length; i++){
int mark = (2*n-1-graph[i][0])*graph[i][0]/2-1+graph[i][1]-graph[i][0];
if(graph[i][0] == thisNode && !isVisit[mark]){
toNode = graph[i][1];
if(toNode == target){
return true;
}
queue.offer(toNode);
}
}
}
return false;
}
- 相比前面做法增加了個方向倒轉,也就是把重複邊的方向倒轉;
2. 最小高度樹 [easy]
2.1 考慮點
- 要建立最小生成樹,就必須讓左右子樹的節點數量儘可能接近;
2.2 解法
2.2.1 遞迴法(優)
public TreeNode sortedArrayToBST(int[] nums) {
if(nums.length == 0){
return null;
}
int l = 0;
int r = nums.length-1;
return recur(l, r, nums);
}
public TreeNode recur(int l, int r, int[] nums){
if(l > r){
return null;
}
int mid = (l+r)/2;
int headVal = nums[mid];
TreeNode head = new TreeNode(headVal);
head.left = recur(l, mid-1, nums);
head.right = recur(mid+1, r, nums);
return head;
}
- 執行時間:100.00%;記憶體消耗:53.37%;
- 時間複雜度:O(n)。陣列中的元素都使用1次,時間複雜度為O(n);
- 空間複雜度:O(log n )。遞迴使用棧輔助空間,空間複雜度O(log n );
- 注意遞迴結束條件,沒有等於是因為遞迴裡有對mid增減進行操作;
- 注意遞迴的引數
l
和r
,表示需要遞迴的範圍,不包括mid頭結點;
2.2.2 BFS廣度優先遍歷
public TreeNode sortedArrayToBST(int[] num) {
if (num.length == 0)
return null;
Queue<int[]> rangeQueue = new LinkedList<>();
Queue<TreeNode> nodeQueue = new LinkedList<>();
int lo = 0;
int hi = num.length - 1;
int mid = (lo + hi) >> 1;
TreeNode node = new TreeNode(num[mid]);
rangeQueue.add(new int[]{lo, mid - 1});
rangeQueue.add(new int[]{mid + 1, hi});
nodeQueue.add(node);
nodeQueue.add(node);
while (!rangeQueue.isEmpty()) {
int[] range = rangeQueue.poll();
TreeNode currentNode = nodeQueue.poll();
lo = range[0];
hi = range[1];
if (lo > hi) {
continue;
}
mid = (lo + hi) >> 1;
int midValue = num[mid];
TreeNode newNode = new TreeNode(midValue);
if (midValue > currentNode.val)
currentNode.right = newNode;
else
currentNode.left = newNode;
if (lo < hi) {
rangeQueue.add(new int[]{lo, mid - 1});
rangeQueue.add(new int[]{mid + 1, hi});
nodeQueue.add(newNode);
nodeQueue.add(newNode);
}
}
return node;
}
- 執行時間:100.00%;記憶體消耗:5.01%;
- 把陣列不停的分為兩部分,儲存在佇列中,然後不停的出隊,建立結點;
3. 特定深度節點連結串列 [medium]
3.1 考慮點
- 不需要逐一遍歷每一層,可以用任意方式遍歷整棵樹,只需要記住節點位於哪一層即可;
3.2 解法
3.2.1 使用棧暴力法
public ListNode[] listOfDepth(TreeNode tree) {
if(tree == null){
return null;
}
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(tree);
int floor = 1; //第x-1層節點個數
List<ListNode> listArr = new ArrayList<>();
//當佇列不為空
while(!queue.isEmpty()){
//按層處理
int count = 0; //count用來儲存第x層節點個數
ListNode head = null;
ListNode cur = null;
for(int i = 0; i < floor; i++){
TreeNode node = queue.poll();
//建立連結串列
if(i == 0){
head = new ListNode(node.val);
cur = head;
} else {
ListNode nodeL = new ListNode(node.val);
cur.next = nodeL;
cur = nodeL;
}
//加入佇列
if( node.left != null){
count++;
queue.offer(node.left);
}
if(node.right != null){
count++;
queue.offer(node.right);
}
}
listArr.add(head);
floor = count; //替換floor
}
ListNode[] list = new ListNode[listArr.size()];
for(int i = 0; i < listArr.size(); i++){
list[i] = listArr.get(i);
}
return list;
}
- 執行時間:89.59%;記憶體消耗:75.08%;
- 需要注意幾個細節:
ListNode cur = null
:需要對cur初始化;floor = count
:記得要替換floor;
- 由於事先不知道陣列個數,先用一個ArrayList裝起來,然後再轉換成陣列;
3.2.2 使用棧暴力法(程式碼優化縮短)
public ListNode[] listOfDepth(TreeNode tree) {
if(tree == null){
return null;
}
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(tree);
List<ListNode> listArr = new ArrayList<>();
//當佇列不為空
while(!queue.isEmpty()){
//按層處理
int size = queue.size();
ListNode head = new ListNode(0);
ListNode cur = head;
for(int i = 0; i < size; i++){
TreeNode node = queue.poll();
//建立連結串列
cur.next = new ListNode(node.val);
//加入佇列
if( node.left != null){
queue.offer(node.left);
}
if(node.right != null){
queue.offer(node.right);
}
cur = cur.next;
}
listArr.add(head.next);
}
return listArr.toArray(new ListNode[] {});
}
- 執行時間:89.59%;記憶體消耗:25.97%;
- 思路同上,更多呼叫java的api簡化程式碼;
3.2.3 遞迴法(優)
public ListNode[] listOfDepth(TreeNode tree) {
List<ListNode> list = new ArrayList<>();
dfs(list, tree, 1);
ListNode[] arr = new ListNode[list.size()];
for (int i = 0; i < arr.length; i++) {
arr[i] = list.get(i);
}
return arr;
}
private void dfs(List<ListNode> list, TreeNode node, int deep) {
if (node == null) {
return;
}
if (deep > list.size()) {
list.add(new ListNode(node.val));
} else {
ListNode n = list.get(deep - 1);
while (n.next != null) {
n = n.next;
}
n.next = new ListNode(node.val);
}
dfs(list, node.left, deep + 1);
dfs(list, node.right, deep + 1);
}
- 執行時間:100.00%;記憶體消耗:96.63%;
- 定義樹深度為deep,同一個深度的儲存到同一個ListNode;
4. 檢查平衡性 [easy]
4.1 考慮點
- 其實,只需要一個checkHeight函式即可,它既可以計算高度,也可以平衡檢查。可以使用整數返回值表示兩者;
4.2 解法
4.2.1 自頂向下的遞迴
public boolean isBalanced(TreeNode root) {
if(root == null){
return true;
}
int deep = 1;
int rootLeftDeep = findDeep(root.left, deep);
int rootRightDeep = findDeep(root.right, deep);
if(rootLeftDeep == -1 || rootRightDeep == -1){
return false;
}
int result = Math.abs(rootLeftDeep - rootRightDeep);
return result<2;
}
public int findDeep(TreeNode node, int deep){
if(node == null){
return deep-1;
}
if(node.left == null && node.right == null){
return deep;
}
deep++;
int leftDeep = findDeep(node.left,deep);
int rightDeep = findDeep(node.right,deep);
int deepDiff = Math.abs(leftDeep-rightDeep);
return deepDiff>1 ? -1 : Math.max(leftDeep,rightDeep);
}
- 執行時間:88.57%;記憶體消耗:20.48%;
- 方法雖然可行,但不高效,Math.abs()方法會被反覆呼叫計算同一個節點的高度;
4.2.2 自頂向下的遞迴
public boolean isBalanced(TreeNode root) {
if (root == null) {
return true;
} else {
return Math.abs(height(root.left) - height(root.right)) <= 1 && isBalanced(root.left) && isBalanced(root.right);
}
}
public int height(TreeNode root) {
if (root == null) {
return 0;
} else {
return Math.max(height(root.left), height(root.right)) + 1;
}
}
-
執行時間:88.57%;記憶體消耗:74.16%;
-
時間複雜度:O(n2),其中 n 是二叉樹中的節點個數。最壞情況下,二叉樹是滿二叉樹,需要遍歷二叉樹中的所有節點,時間複雜度是 O(n)。對於節點 p,如果它的高度是 d,則 height(p) 最多會被呼叫 d 次(即遍歷到它的每一個祖先節點時)。對於平均的情況,一棵樹的高度 h 滿足 O(h)=O(logn),因為 d≤h,所以總時間複雜度為 O(nlogn)。對於最壞的情況,二叉樹形成鏈式結構,高度為 O(n),此時總時間複雜度為 O(n2);
-
空間複雜度:O(n),其中 n 是二叉樹中的節點個數。空間複雜度主要取決於遞迴呼叫的層數,遞迴呼叫的層數不會超過 n;
4.2.3 自底向上的遞迴(優)
public boolean isBalanced(TreeNode root) {
return height(root) >= 0;
}
public int height(TreeNode root) {
if (root == null) {
return 0;
}
int leftHeight = height(root.left);
int rightHeight = height(root.right);
if (leftHeight == -1 || rightHeight == -1 || Math.abs(leftHeight - rightHeight) > 1) {
return -1;
} else {
return Math.max(leftHeight, rightHeight) + 1;
}
}
- 執行時間:88.57%;記憶體消耗:96.72%;
- 時間複雜度:O(n),其中 n 是二叉樹中的節點個數。使用自底向上的遞迴,每個節點的計算高度和判斷是否平衡都只需要處理一次,最壞情況下需要遍歷二叉樹中的所有節點,因此時間複雜度是 O(n);
- 空間複雜度:O(n),其中 n 是二叉樹中的節點個數。空間複雜度主要取決於遞迴呼叫的層數,遞迴呼叫的層數不會超過 n;
5. 合法二叉搜尋樹 [medium]
5.1 考慮點
- 有兩種思路:
- 一是利用中序遍歷;
- 二是建立在 left <current<right 這項特性上;
5.2 解法
5.2.1 遞迴法
class Solution {
Stack<Integer> stack = new Stack<>();
boolean isFlag = false;
public boolean isValidBST(TreeNode root) {
if(root == null){
return true;
}
inOrderTraversal(root);
return !isFlag;
}
public void inOrderTraversal(TreeNode node){
if(isFlag){
return;
}
if(node == null){
return;
}
inOrderTraversal(node.left);
if(stack.isEmpty()){
stack.push(node.val);
} else {
if(stack.peek() >= node.val){
isFlag = true;
return;
} else {
stack.push(node.val);
}
}
inOrderTraversal(node.right);
}
}
- 執行時間:32.30%;記憶體消耗:91.41%;
5.2.2 中序遍歷非遞迴
public boolean isValidBST(TreeNode root) {
Deque<TreeNode> stack = new LinkedList<TreeNode>();
double inorder = -Double.MAX_VALUE;
while (!stack.isEmpty() || root != null) {
while (root != null) {
stack.push(root);
root = root.left;
}
root = stack.pop();
// 如果中序遍歷得到的節點的值小於等於前一個 inorder,說明不是二叉搜尋樹
if (root.val <= inorder) {
return false;
}
inorder = root.val;
root = root.right;
}
return true;
}
- 執行時間:24.37%;記憶體消耗:98.61%;
- 時間複雜度 : O(n),其中 n 為二叉樹的節點個數。二叉樹的每個節點最多被訪問一次,因此時間複雜度為 O(n);
- 空間複雜度 : O(n),其中 n 為二叉樹的節點個數。棧最多儲存 n 個節點,因此需要額外的 O(n) 的空間;
5.2.3 中序遍歷遞迴(優)
//前一個結點,全域性的
TreeNode prev;
public boolean isValidBST(TreeNode root) {
if (root == null)
return true;
//訪問左子樹
if (!isValidBST(root.left))
return false;
//訪問當前節點:如果當前節點小於等於中序遍歷的前一個節點直接返回false。
if (prev != null && prev.val >= root.val)
return false;
prev = root;
//訪問右子樹
if (!isValidBST(root.right))
return false;
return true;
}
- 執行時間:100.00%;記憶體消耗:65.74%;
- 中序遍歷時,判斷當前節點是否大於中序遍歷的前一個節點,也就是判斷是否有序,如果不大於直接返回 false
6. 後繼者 [medium]
6.1 考慮點
- 因為是二叉搜尋樹,可以很方便找到節點,再根據是否有右子樹分類判斷;
6.2 解法
6.2.1 中序遍歷遞迴法
class Solution {
Stack<TreeNode> stack = new Stack<>();
TreeNode findNode = null;
public TreeNode inorderSuccessor(TreeNode root, TreeNode p) {
if(root == null || p == null){
return null;
}
inOrderTravarsal(root,p);
return findNode!=null ? findNode : null;
}
public void inOrderTravarsal(TreeNode node, TreeNode p){
if(findNode != null){
return;
}
if(node == null){
return;
}
inOrderTravarsal(node.left, p);
if(stack.isEmpty()){
stack.push(node);
} else {
if(p.equals(stack.peek())){
findNode = node;
stack.pop(); //忘記pop
return;
} else {
stack.push(node);
}
}
inOrderTravarsal(node.right, p);
}
}
- 執行時間:72.07%;記憶體消耗:9.51%;
6.2.2 中序遍歷非遞迴法(優)
public TreeNode inorderSuccessor(TreeNode root, TreeNode p) {
TreeNode pre = null;
while(root.val!=p.val){
//右邊
if(p.val > root.val){
root = root.right;
}
//左邊
else{
pre = root;
root = root.left;
}
}
//假如沒有右子樹
if(root.right==null){
return pre;
} else {
root = root.right;
while(root.left!=null){
root = root.left;
}
return root;
}
}
- 執行時間:100.00%;記憶體消耗:100.00%;
- 找到節點後,如果右子樹存在,那就是右子樹最左邊的節點。如果右子樹不存在,表示已經訪問n層子樹,需要回到n的父節點q;如果n在q的左邊,那就是q。反之需要往上訪問,直到找到還未完全遍歷的節點x;
8. 首個共同祖先 [medium]
8.1 考慮點
- 如果是二叉搜尋樹,可以看看兩條路徑在哪開始分叉;
8.2 解法
8.2.1 深度優先遍歷
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
if(root == null){
return null;
} else if(root.equals(p)){
return p;
} else if(root.equals(q)){
return q;
}
TreeNode leftNode = lowestCommonAncestor(root.left,p,q);
TreeNode rightNode = lowestCommonAncestor(root.right,p,q);
if(leftNode == null && rightNode == null){
return null;
}
if(leftNode != null && rightNode == null){
if(leftNode.equals(q)){
return q;
} else if(leftNode.equals(p)){
return p;
} else {
return leftNode;
}
}
if(leftNode == null && rightNode != null){
if(rightNode.equals(q)){
return q;
} else if(rightNode.equals(p)){
return p;
} else {
return rightNode;
}
}
//注意非空校驗
if((leftNode.equals(p) && rightNode.equals(q)) || (leftNode.equals(q) && rightNode.equals(p))){
return root;
}
return null;
}
- 執行時間:52.31%;記憶體消耗:5.19%;
8.2.2 深度優先遍歷的簡便寫法(優)
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {
// 到底了還沒找到,返回 null
if (root == null) {
return null;
}
// 如果找到了 p 或 q,返回它
if (root == p || root == q) {
return root;
}
TreeNode left = lowestCommonAncestor(root.left, p, q);
TreeNode right = lowestCommonAncestor(root.right, p, q);
// 如果 left 和 right 都記錄了找到的節點,那麼肯定是一個記錄了 p ,另一個記錄了 q
// 它們分別在以 root 為根的左右子樹中,所以 root 就是它們的最近公共祖先
if (left != null && right != null) {
return root;
}
// 由於節點 p,q 一定在二叉樹中,left和right不會同時為null
// 若 left != null && right == null,說明在左子樹中找到 p 或 q,而在右子樹找不到 p 或 q,則剩下一個也在左子樹
// 所以 left 就是最近公共祖先
// 另一種情況同理
return (left != null) ? left : right;
}
- 執行時間:100.00%;記憶體消耗:91.55%;
9. 二叉搜尋樹序列 [hard]
9.1 考慮點
- 陣列的第一個數必須為頂節點;
- 與左右節點的插入順序無關緊要,但子節點的新增一定要在父節點之後;
9.2 解法
9.2.1 回溯法+廣度優先遍歷
private List<List<Integer>> ans;
public List<List<Integer>> BSTSequences(TreeNode root) {
ans = new ArrayList<>();
List<Integer> path = new ArrayList<>();
// 如果 root==null 返回 [[]]
if (root == null) {
ans.add(path);
return ans;
}
List<TreeNode> queue = new LinkedList<>();
queue.add(root);
// 開始進行回溯
bfs(queue, path);
return ans;
}
// 回溯法+廣度優先遍歷
private void bfs(List<TreeNode> queue, List<Integer> path) {
// queue 為空說明遍歷完了,可以返回了
if (queue.isEmpty()) {
ans.add(new ArrayList<>(path));
return;
}
// 將 queue 拷貝一份,用於稍後回溯
List<TreeNode> copy = new ArrayList<>(queue);
// 對 queue 進行迴圈,每迴圈考慮 “是否 「將當前 cur 節點從 queue 中取出並將其左右子
// 節點加入 queue ,然後將 cur.val 加入到 path 末尾」 ” 的情況進行回溯
for (int i = 0; i < queue.size(); i++) {
TreeNode cur = queue.get(i);
path.add(cur.val);
queue.remove(i);
// 將左右子節點加入佇列
if (cur.left != null) queue.add(cur.left);
if (cur.right != null) queue.add(cur.right);
bfs(queue, path);
// 恢復 path 和 queue ,進行回溯
path.remove(path.size() - 1);
queue = new ArrayList<>(copy);
}
}
- 執行時間:90.30%;記憶體消耗:93.98%;
- 對於這種找出所有情況的題目,回溯法是最容易想到的方法之一了,這道題也可以用回溯法,可以發現剛才選元素的過程和層序遍歷的過程其實是一致的:
- 最開始 queue 中只有 12 ,只能選12,將 12 出隊並將它的兩個子節點入隊,得到 [12];
選了12之後 queue 中剩下 5、19 ,就從 5 和 19 中選一個,得到 [12,5],[12,19];- 如果選了 5 ,將 5 出隊並將它的兩個子節點入隊,那麼此時 queue 中剩下 19、2、9,得到 [12,5,2],[12,5,9],[12,5,19];
- 如果選了 19 ,將 19 出隊並將它的子節點入隊,那麼此時 queue 中剩下 5、15,得到 [12,19,5],[12,19,15];
- 後續同理;
- 最開始 queue 中只有 12 ,只能選12,將 12 出隊並將它的兩個子節點入隊,得到 [12];
10. 檢查子樹 [medium]
10.1 考慮點
- 這裡的“萬”是干擾的;
10.2 解法
10.2.1 遞迴法(優)
boolean isFound =false;
public boolean checkSubTree(TreeNode t1, TreeNode t2) {
if(t1 == null){
return false;
}
if(t1.val == t2.val){
isFound = true;
return true;
} else {
isFound = false;
}
boolean left;
boolean right;
if(isFound){
left = checkSubTree(t1.left, t2.left);
right = checkSubTree(t1.right, t2.right);
return left && right;
} else {
left = checkSubTree(t1.left, t2);
right = checkSubTree(t1.right, t2);
if(left){
return checkSubTree(t1.left, t2);
}
if(right){
return checkSubTree(t1.right, t2);
}
}
return false;
}
- 執行時間:100.00%;記憶體消耗:46.52%;
12. 求和路徑 [medium]
12.1 考慮點
- 可以使用散列表優化演算法,下面解法沒給出;
12.2 解法
12.2.1 暴力法(優)
class Solution {
int res = 0;
public int pathSum(TreeNode root, int sum) {
int dep = depth(root);
int[] paths = new int[dep];
pathSum(root, sum, 0, paths);
return res;
}
public void pathSum(TreeNode root, int sum, int level, int[] paths) {
if (root == null) {
return;
}
paths[level] = root.val;
int t = 0;
for (int i = level; i >= 0; i--) {
t += paths[i];
if (t == sum) {
res += 1;
}
}
pathSum(root.left, sum, level + 1, paths);
pathSum(root.right, sum, level + 1, paths);
}
public int depth(TreeNode root) {
if (root == null) {
return 0;
}
return Math.max(depth(root.left), depth(root.right)) + 1;
}
}
- 執行時間:100.00%;記憶體消耗:69.08%;
12.2.2 回溯法
private int res = 0;
public int pathSum(TreeNode root, int sum) {
if(root == null) return res;
helper(root, sum);
pathSum(root.left, sum);
pathSum(root.right, sum);
return res;
}
private void helper(TreeNode node, int sum){
if(node == null) return;
sum -= node.val;
if(sum == 0)
res ++;
helper(node.left, sum);
helper(node.right, sum);
sum += node.val;
}
- 執行時間:58.34%;記憶體消耗:10.27%;