目录
LeetCode113.路径总和II(注意广搜也可处理所有符合路径):
LeetCode105/106.从前中/中后遍历序列构造二叉树(中):
LeetCode235/236.二叉树/搜索树的最近公共祖先(LCA):
LeetCode701/450.二叉搜索树中的插入/删除操作:
LeetCode108/109.将有序数组/链表转换为二叉搜索树:
LeetCode96/95.不同的二叉搜索树I/II(难):
LeetCode297/449.二叉树/搜索树的序列化与反序列化:
LeetCode二叉树的基本遍历(难):
写在前面:
此类题我们忽略所有的递归解法,即回溯算法的雏形,其思想相信不难理解。我们只深入探讨迭代解法和Morris遍历。这一块没有捷径(不同版本的写法甚至可以是Medium或Hard),可能某天费了九牛二虎之力看题解看懂了,但是一个多月后甚至不到,又无法独立写出或者忘了。解决办法就是,有时间精力的多做一些用到迭代和Morris的类似题目来巩固升华,没时间没精力的,最起码也要把这块周期性复习背模板温习思想。最好能将不同版本的写法在一起比对,总结模板或框架,这样,下一次重新捡起来就会快很多
前序遍历:
首先我们应该创建一个 Stack 用来存放节点,首先我们想要打印根节点的数据,此时 Stack 里面的内容为空,所以我们优先将头结点加入 Stack,然后打印。之后我们应该先打印左子树,然后右子树。所以先加入 Stack 的就是右子树,然后左子树。 此时你能得到的流程如下:
对应的代码为:
private static List<Integer> postorderTraversal(TreeNode root) { List<Integer> res = new ArrayList<>(); if (root == null) { return res; } Deque<TreeNode> stack = new LinkedList<>(); stack.push(root); while (!stack.isEmpty()) { TreeNode temp = stack.poll(); res.add(temp.val); if (temp.right != null) { stack.push(temp.right); } if (temp.left != null) { stack.push(temp.left); } } return res; } 作者:南荣牧歌
上述解法比官解和大多数教材上的迭代解法更容易理解,比较巧妙通俗易懂。
下面来看常用的官解写法,请注意比对,以示区分:
class Solution { public: vector<int> preorderTraversal(TreeNode* root) { vector<int> res; if (root == nullptr) { return res; } stack<TreeNode *> stk; TreeNode *node = root; while (!stk.empty() || node != nullptr) { while (node != nullptr) { res.emplace_back(node->val); stk.emplace(node); node = node->left; } node = stk.top(); stk.pop(); node = node->right; } return res; } }; 作者:力扣官解
迭代、递归的时空复杂度相同。
n 是二叉树的节点数。每一个节点恰好被遍历一次。时间复杂度O(n)。
空间复杂度为递归过程中栈的开销,平均情况下为 O(logn),最坏情况下树呈现链状,为 O(n)。
Morris遍历:
Morris 的整体思路就是以某个根结点开始,找到它左子树的最右侧节点之后与这个根结点进行连接。我们可以从 图2 看到,如果这么连接之后,cur 这个指针是可以完整的从一个节点顺着下一个节点遍历,将整棵树遍历完毕,直到 7 这个节点右侧没有指向。
class Solution { public: vector<int> preorderTraversal(TreeNode *root) { vector<int> res; if (root == nullptr) { return res; } TreeNode *p1 = root, *p2 = nullptr; //p2记录当前节点的左子树 while (p1 != nullptr) { p2 = p1->left; if (p2 != nullptr) { while (p2->right != nullptr && p2->right != p1) { //找到当前左子树的最右侧节点,且这个节点应该在指向根结点之前,否则整个节点又回到了根结点。 p2 = p2->right; } if (p2->right == nullptr) { //这个时候如果最右侧这个节点的右指针没有指向根结点,创建连接然后往下一个左子树的根结点重复进行连接操作。 res.emplace_back(p1->val); p2->right = p1; p1 = p1->left; continue; } else { //当左子树的最右侧节点有指向根结点,此时说明我们已经回到了根结点并重复了之前的操作,同时在回到根结点的时候我们应该已经处理完 左子树的最右侧节点了,把路断开。 p2->right = nullptr; } } else { //打印自身无法创建连线的节点,也就是叶子节点。 res.emplace_back(p1->val); } 两种情况:1.当前子树的根节点的左子树已经探索至尽(p2即p1->left指向空),需要顺着连线返回 2.p2->right路断开后,我们知道左子树在之前已经走过,我们需要当前根节点的右子树探索 p1 = p1->right; } return res; } }; 作者:力扣官解
n 是二叉树的节点数。没有左子树的节点只被访问一次,有左子树的节点被访问两次。时间复杂度为O(n)。
只操作已经存在的指针(树的空闲指针),因此只需要常数的额外空间。空间复杂度为O(1)。
Morris法将空间复杂度降了下来,中间虽然改变了树结构,但最终恢复了,十分精妙。
中序遍历:
- 同理创建一个 Stack,然后按 左 中 右的顺序输出节点。
- 尽可能的将这个节点的左子树压入 Stack,此时栈顶的元素是最左侧的元素,其目的是找到一个最小单位的子树(也就是最左侧的一个节点),并且在寻找的过程中记录了来源,才能返回上层,同时在返回上层的时候已经处理完毕左子树了。
- 当处理完最小单位的子树时,返回到上层处理了中间节点。(如果把整个左中右的遍历都理解成子树的话,就是处理完 左子树->中间(就是一个节点)->右子树)
- 如果当前栈顶元素有右节点,则重复进行上述操作;若无,则此时cur是指向空的,持续弹栈并判断栈顶元素是否有右节点。
当整个左子树退栈的时候这个时候输出了该子树的根节点 2,之后输出中间节点 1。然后处理根节点为3右子树。
public List<Integer> inorderTraversal(TreeNode root) { List<Integer> res = new ArrayList<>(); if (root == null) { return res; } TreeNode cur = root; Stack<TreeNode> stack = new Stack<>(); while (!stack.isEmpty() || cur != null) { while (cur != null) { stack.push(cur); cur = cur.left; } TreeNode node = stack.pop(); res.add(node.val); if (node.right != null) { cur = node.right; } } return res; } 作者:golandscape
Morris遍历:
- 如果 x 无左孩子,先将 x 的值加入答案数组,再访问 x 的右孩子,即 x=x.right。
- 如果 x 有左孩子,则找到 x 左子树上最右的节点(即左子树中序遍历的最后一个节点,x 在中序遍历中的前驱节点),我们记为 predecessor。
第二点中,根据 predecessor 的右孩子是否为空,进行如下操作:
- 如果 predecessor 的右孩子为空,则将其右孩子指向 x,然后访问 x 的左孩子,即 x=x.left。
- 如果 predecessor 的右孩子不为空,则此时其右孩子指向 x,说明我们已经遍历完 x 的左子树,我们将 predecessor 的右孩子置空,将 x 的值加入答案数组,然后访问 x 的右孩子,即 x=x.right。
重复上述操作直至访问完整棵树。
public static void inOrderMorris(TreeNode head) { List<Integer> res = new ArrayList<>(); if (head == null) { return res; } TreeNode cur1 = head; TreeNode cur2 = null; while (cur1 != null) { cur2 = cur1.left; //构建连接线 if (cur2 != null) { while (cur2.right != null && cur2.right != cur1) { cur2 = cur2.right; } if (cur2.right == null) { cur2.right = cur1; cur1 = cur1.left; continue; } else { cur2.right = null; } } res.add(cur1.val); cur1 = cur1.right; } return res; } 作者:golandscape
其实我们发现,无论是中序还是先序的Morris遍历,线索化的逻辑和代码一模一样,只是何时打印/加入答案数组的时机不同,而这时机的选取,决定了先序或是中序。后序稍微有点小不同。
后序遍历:
较为复杂了。
我们先看一个好理解的巧妙思路:
先序遍历是中左右,后续遍历是左右中,那么我们只需要调整一下先序遍历的代码顺序,就变成中右左的遍历顺序,然后再反转result数组,输出的结果顺序就是左右中了,如下图:
所以后序遍历只需要前序遍历的代码稍作修改就可以了,代码如下:
class Solution { public: vector<int> postorderTraversal(TreeNode* root) { stack<TreeNode*> stk; vector<int> result; if (root == NULL) return result; stk.push(root); while (!stk.empty()) { TreeNode* node = stk.top(); stk.pop(); result.push_back(node->val); if (node->left) stk.push(node->left); // 相对于前序遍历,这更改一下入栈顺序 (空节点不入栈) if (node->right) stk.push(node->right); // 空节点不入栈 } reverse(result.begin(), result.end()); // 将结果反转之后就是左右中的顺序了 return result; } }; 作者:代码随想录
Morris遍历:
比较复杂,但是有比较巧妙的理解方式:
当我们到达最左侧,也就是左边连线已经创建完毕了。打印 4 打印 5、2 打印 6 打印 7、3、1 。我们将一个节点的连续右节点当成一个单链表(在图上表现为同一层)来看待。 当我们返回上层之后,也就是将连线断开的时候,打印下层的单链表。 比如返回到 2,此时打印 4 ;比如返回到 1,此时打印 5 2 ;比如返回到 3,此时打印 6 。那么我们只需要将这个单链表逆序打印就行了,下文也给出了单链表逆序代码。这里不应该打印当前层,而是下一层,否则根结点会先于右边打印。
//后序Morris public static void postOrderMorris(TreeNode head) { List<Integer> res = new ArrayList<>(); if (head == null) { return res; } TreeNode cur1 = head;//遍历树的指针变量 TreeNode cur2 = null;//当前子树的最右节点 while (cur1 != null) { cur2 = cur1.left; if (cur2 != null) { while (cur2.right != null && cur2.right != cur1) { cur2 = cur2.right; } if (cur2.right == null) { cur2.right = cur1; cur1 = cur1.left; continue; } else { cur2.right = null; postMorrisPrint(cur1.left); } } cur1 = cur1.right; } postMorrisPrint(head); } //打印函数 public static void postMorrisPrint(TreeNode head) { TreeNode reverseList = postMorrisReverseList(head); TreeNode cur = reverseList; while (cur != null) { System.out.print(cur.value + " "); //此时right指针相当于链表的next指针 cur = cur.right; } //将“链表结构”复原 postMorrisReverseList(reverseList); } //翻转单链表 public static TreeNode postMorrisReverseList(TreeNode head) { TreeNode cur = head; TreeNode pre = null; while (cur != null) { TreeNode next = cur.right; cur.right = pre; pre = cur; cur = next; } return pre; } 作者:golandscape
二叉树前中后迭代方式同一写法:
实践过的同学,会发现使用迭代法实现先中后序遍历,很难写出统一的代码,不像是递归法,实现了其中的一种遍历方式,其他两种只要稍稍改一下节点顺序就可以了。
重头戏来了,接下来介绍一下统一写法也是有的!
我们以中序遍历为例,无法同时解决访问节点(遍历节点)和处理节点(将元素放进结果集)时机不一致的情况。
那我们索性就将访问的节点放入栈中,把要处理的节点也一并放入栈中但是紧接着放入一个空指针作为标记。
class Solution { public: vector<int> inorderTraversal(TreeNode* root) { vector<int> result; stack<TreeNode*> st; if (root != NULL) st.push(root); while (!st.empty()) { TreeNode* node = st.top(); if (node != NULL) { st.pop(); // 将该节点弹出,避免重复操作,下面再将右中左节点添加到栈中 if (node->right) st.push(node->right); // 添加右节点(空节点不入栈) st.push(node); // 添加中节点 st.push(NULL); // 中节点访问过,但是还没有处理,加入空节点做为标记。 if (node->left) st.push(node->left); // 添加左节点(空节点不入栈) } else { // 只有遇到空节点的时候,才将下一个节点放进结果集 st.pop(); // 将空节点弹出 node = st.top(); // 重新取出栈中元素 st.pop(); result.push_back(node->val); // 加入到结果集 } } return result; } }; 作者:代码随想录
迭代法前序遍历代码如下: (注意此时我们和中序遍历相比仅仅改变了两行代码的顺序)
class Solution { public: vector<int> preorderTraversal(TreeNode* root) { vector<int> result; stack<TreeNode*> st; if (root != NULL) st.push(root); while (!st.empty()) { TreeNode* node = st.top(); if (node != NULL) { st.pop(); if (node->right) st.push(node->right); // 右 if (node->left) st.push(node->left); // 左 st.push(node); // 中 st.push(NULL); } else { st.pop(); node = st.top(); st.pop(); result.push_back(node->val); } } return result; } }; 作者:代码随想录
后续遍历代码如下: (注意此时我们和中序遍历相比仅仅改变了两行代码的顺序)
class Solution { public: vector<int> postorderTraversal(TreeNode* root) { vector<int> result; stack<TreeNode*> st; if (root != NULL) st.push(root); while (!st.empty()) { TreeNode* node = st.top(); if (node != NULL) { st.pop(); st.push(node); // 中 st.push(NULL); if (node->right) st.push(node->right); // 右 if (node->left) st.push(node->left); // 左 } else { st.pop(); node = st.top(); st.pop(); result.push_back(node->val); } } return result; } }; 作者:代码随想录
太妙了!顶礼膜拜!!!终于前中后序遍历也有规可循了!!!
鸣谢:
题解参考了 力扣官方题解、golandscape、代码随想录 等。可以理解为咀嚼勾画摘选精彩重点部分,仅用于个人学习分享等。引用之处均已清晰说明。支持原创和知识成果!
LeetCode二叉树的层序遍历(难):
写在前面:
本题涉及到的知识点层面比较多,所以没有和二叉树的基本遍历放在一起,打算单独拎出来细讲。也不打算放到栈和队列或者BFS/DFS专题去,就在本专题做一个合并吧,把树和回溯(主要是dfs/bfs,图论可能会根据后续情况单开专题)放一起。题目都是相互关联的,无伤大雅。
快速入门:
本人选取的入门资料为《啊哈!算法》,对新人十分友好。想要快速搞懂dfs/bfs的大致框架,我们需要阅读以下资料:
以上内容均摘自这本书,相信看完理解后对于DFS/BFS的认识已经比较深刻了。
BFS 的使用场景总结:层序遍历、最短路径问题
BFS 的应用一:层序遍历
如果我们使用 DFS/BFS 只是为了遍历一棵树、一张图上的所有结点的话,那么 DFS 和 BFS 的能力没什么差别,我们当然更倾向于更方便写、空间复杂度更低的 DFS 遍历。不过,某些使用场景是 DFS 做不到的,只能使用 BFS 遍历。
我们先看看在二叉树上进行 DFS 遍历和 BFS 遍历的代码比较。
/*先序遍历dfs*/ void dfs(TreeNode root) { if (root == null) { return; } dfs(root.left); dfs(root.right); } 作者:nettee
/*先序遍历bfs*/ void bfs(TreeNode root) { 不用数组模拟,用库函数 Queue<TreeNode> queue = new ArrayDeque<>(); queue.add(root); while (!queue.isEmpty()) { // while(head < tail) TreeNode node = queue.poll(); // 从队首弹出一个节点做拓展,将拓展出的节点放入队尾 if (node.left != null) { queue.add(node.left); } if (node.right != null) { queue.add(node.right); } } } 作者:nettee
乍一看来,这个遍历顺序和 BFS 是一样的,我们可以直接用 BFS 得出层序遍历结果。然而,层序遍历要求的输入结果和 BFS 是不同的。层序遍历要求我们区分每一层,也就是返回一个二维数组。而 BFS 的遍历结果是一个一维数组,无法区分每一层。
那我们如何改进BFS才能给遍历结果分层呢?
不妨截取如下的时刻状态:
可以看到,此时队列中的结点是 3、4、5,分别来自第 1 层和第 2 层。这个时候,第 1 层的结点还没出完,第 2 层的结点就进来了,而且两层的结点在队列中紧挨在一起,我们无法区分队列中的结点来自哪一层。
因此,我们需要稍微修改一下代码,在每一层遍历开始前,先记录队列中的结点数量 n(也就是这一层的结点数量),然后一口气处理完这一层的 n 个结点!!!
// 二叉树的层序遍历 void bfs(TreeNode root) { Queue<TreeNode> queue = new ArrayDeque<>(); queue.add(root); while (!queue.isEmpty()) { int n = queue.size(); 对于队列中的每一个节点,分别出队做扩展。全部扩展完才进入下一轮。 for (int i = 0; i < n; i++) { // 变量 i 无实际意义,只是为了循环 n 次 TreeNode node = queue.poll(); if (node.left != null) { queue.add(node.left); } if (node.right != null) { queue.add(node.right); } } } } 作者:nettee
以上是 102. 二叉树的层序遍历 的核心部分。
题解完整代码如下:
class Solution { public List<List<Integer>> levelOrder(TreeNode root) { List<List<Integer> > res = new ArrayList<>(); Queue<TreeNode> queue = new ArrayDeque<>(); if(root != null) { queue.add(root); } while(!queue.isEmpty()) { int n = queue.size(); //每层的拓展结果 List<Integer> level = new ArrayList<>(); for(int i = 1; i <= n; ++i) { TreeNode node = queue.poll(); level.add(node.val); if(node.left != null) queue.add(node.left); if(node.right != null) queue.add(node.right); } res.add(level); } return res; } } 作者:nettee
BFS 的应用二:最短路径
在一棵树中,一个结点到另一个结点的路径是唯一的,但在图中,结点之间可能有多条路径,其中哪条路最近呢?这一类问题称为最短路径问题。最短路径问题也是 BFS 的典型应用,而且其方法与层序遍历关系密切。
在二叉树中,BFS 可以实现一层一层的遍历。在图中同样如此。从源点出发,BFS 首先遍历到第一层结点,到源点的距离为 1,然后遍历到第二层结点,到源点的距离为 2… 可以看到,用 BFS 的话,距离源点更近的点会先被遍历到,这样就能找到到某个点的最短路径了。
- 很多同学一看到「最短路径」,就条件反射地想到「Dijkstra 算法」。为什么 BFS 遍历也能找到最短路径呢?
- 这是因为,Dijkstra 算法解决的是带权最短路径问题,而我们这里关注的是无权最短路径问题。也可以看成每条边的权重都是 1。这样的最短路径问题,用 BFS 求解就行了。
- 在面试中,你可能更希望写 BFS 而不是 Dijkstra。毕竟,敢保证自己能写对 Dijkstra 算法的人不多。
最短路径问题属于图算法。由于图的表示和描述比较复杂,本章回溯专题用比较简单的网格结构代替。网格结构是一种特殊的图,它的表示和遍历都比较简单,适合作为练习题。在 LeetCode 中,最短路径问题也以网格结构为主。
最短路径例题讲解:
LeetCode 1162. As Far from Land as Possible 离开陆地的最远距离(Medium)
你现在手里有一份大小为 n×n 的地图网格 grid,上面的每个单元格都标记为 0 或者 1,其中 0 代表海洋,1 代表陆地。 请你找出一个海洋区域,这个海洋区域到离它最近的陆地区域的距离是最大的。 我们这里说的距离是「曼哈顿距离」。 (x0,y0) 和 (x1,y1) 这两个区域之间的距离是 ∣x0−x1∣+∣y0−y1∣ 。 如果我们的地图上只有陆地或者海洋,请返回 -1。
这道题要找的是距离陆地最远的海洋格子。假设网格中只有一个陆地格子,我们可以从这个陆地格子出发做层序遍历,直到所有格子都遍历完。最终遍历了几层,海洋格子的最远距离就是几。
那么有多个陆地格子的时候怎么办呢?一种方法是将每个陆地格子都作为起点做一次层序遍历,但是这样的时间开销太大。BFS 完全可以以多个格子同时作为起点。我们可以把所有的陆地格子同时放入初始队列,然后开始层序遍历。
这种遍历方法实际上叫做「多源 BFS」。多源 BFS 的定义不是今天讨论的重点,你只需要记住多源 BFS 很方便,只需要把多个源点同时放入初始队列即可。
public int maxDistance(int[][] grid) { int N = grid.length; Queue<int[]> queue = new ArrayDeque<>(); // 将所有的陆地格子加入队列 for (int i = 0; i < N; i++) { for (int j = 0; j < N; j++) { if (grid[i][j] == 1) { queue.add(new int[]{i, j}); } } } // 如果地图上只有陆地或者海洋,返回 -1 if (queue.isEmpty() || queue.size() == N * N) { return -1; } int[][] moves = { {-1, 0}, {1, 0}, {0, -1}, {0, 1}, }; int distance = -1; // 记录当前遍历的层数(距离) while (!queue.isEmpty()) { distance++; int n = queue.size(); for (int i = 0; i < n; i++) { int[] node = queue.poll(); int r = node[0]; int c = node[1]; for (int[] move : moves) { int r2 = r + move[0]; int c2 = c + move[1]; if (inArea(grid, r2, c2) && grid[r2][c2] == 0) { grid[r2][c2] = 2; queue.add(new int[]{r2, c2}); } } } } return distance; } // 判断坐标 (r, c) 是否在网格中 boolean inArea(int[][] grid, int r, int c) { return 0 <= r && r < grid.length && 0 <= c && c < grid[0].length; } 作者:nettee
总结:
可以看到,「BFS 遍历」、「层序遍历」、「最短路径」实际上是递进的关系。在 BFS 遍历的基础上区分遍历的每一层,就得到了层序遍历。在层序遍历的基础上记录层数,就得到了最短路径。
层序遍历的一些变种题目:
- LeetCode 103. Binary Tree Zigzag Level Order Traversal
- LeetCode 199. Binary Tree Right Side View
- LeetCode 515. Find Largest Value in Each Tree Row
- LeetCode 637. Average of Levels in Binary Tree
对于最短路径问题,还有两道题目也是求网格结构中的最短路径,和我们讲解的距离岛屿的最远距离非常类似:
还有一道在真正的图结构中求最短路径的问题:
—— by nettee
网格结构中的DFS:
以上讲解了网格结构中的BFS,我们再来谈一谈DFS方面
DFS与BFS书写的细节问题:
先看代码示例:695. 岛屿的最大面积
//版本一 class Solution { private: int count; int dir[4][2] = {0, 1, 1, 0, -1, 0, 0, -1}; // 四个方向 void dfs(vector<vector<int>>& grid, vector<vector<bool>>& visited, int x, int y) { for (int i = 0; i < 4; i++) { int nextx = x + dir[i][0]; int nexty = y + dir[i][1]; if (nextx < 0 || nextx >= grid.size() || nexty < 0 || nexty >= grid[0].size()) continue; // 越界了,直接跳过 if (!visited[nextx][nexty] && grid[nextx][nexty] == 1) { // 没有访问过的 同时 是陆地的 visited[nextx][nexty] = true; count++; dfs(grid, visited, nextx, nexty); } } } public: int maxAreaOfIsland(vector<vector<int>>& grid) { int n = grid.size(), m = grid[0].size(); vector<vector<bool>> visited = vector<vector<bool>>(n, vector<bool>(m, false)); int result = 0; for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { if (!visited[i][j] && grid[i][j] == 1) { count = 1; visited[i][j] = true; dfs(grid, visited, i, j); // 将与其链接的陆地都标记上 true result = max(result, count); } } } return result; } }; 作者:代码随想录
- 你可能有疑惑,为什么 以上代码中的dfs函数,没有终止条件呢? 感觉递归没有终止很危险。
- 其实终止条件 就写在了,调用dfs的地方,如果遇到不合法的方向,直接不会去调用dfs。
- 当然,也可以这么写:
//版本二 class Solution { private: int count; int dir[4][2] = {0, 1, 1, 0, -1, 0, 0, -1}; // 四个方向 void dfs(vector<vector<int>>& grid, vector<vector<bool>>& visited, int x, int y) { if (visited[x][y] || grid[x][y] == 0) return; // 终止条件:访问过的节点 或者 遇到海水 visited[x][y] = true; // 标记访问过 count++; for (int i = 0; i < 4; i++) { int nextx = x + dir[i][0]; int nexty = y + dir[i][1]; if (nextx < 0 || nextx >= grid.size() || nexty < 0 || nexty >= grid[0].size()) continue; // 越界了,直接跳过 dfs(grid, visited, nextx, nexty); } } public: int maxAreaOfIsland(vector<vector<int>>& grid) { int n = grid.size(), m = grid[0].size(); vector<vector<bool>> visited = vector<vector<bool>>(n, vector<bool>(m, false)); int result = 0; for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { if (!visited[i][j] && grid[i][j] == 1) { count = 0; dfs(grid, visited, i, j); // 将与其链接的陆地都标记上 true result = max(result, count); } } } return result; } }; 作者:代码随想录
这里大家应该能看出区别了,无疑就是版本一中调用下一轮dfs的条件,放在了版本二的本轮dfs终止条件位置上。
版本一的写法是 :下一个节点是否能合法已经判断完了,只要调用dfs就是可以合法的节点。
版本二的写法是:不管节点是否合法,上来就dfs,然后在终止条件的地方进行判断,不合法再return。
理论上来讲,版本一的效率更高一些,因为避免了 没有意义的递归调用,在调用dfs之前,就做合法性判断。 但从写法来说,可能版本二 更利于理解一些。(不过其实都差不太多)
很多同学看了同一道题目,都是dfs,写法却不一样,有时候有终止条件,有时候连终止条件都没有,其实这就是根本原因,两种写法而已。
我们再以 200. 岛屿数量 为例:
不少同学用广搜做这道题目的时候,超时了。 这里有一个广搜中很重要的细节:
根本原因是只要加入队列就代表走过,就需要标记,而不是从队列拿出来的时候再去标记走过。
很多同学可能感觉这有区别吗?如果从队列拿出节点,再去标记这个节点走过,就会发生下图所示的结果,会导致很多节点重复加入队列。
超时写法 (从队列中取出节点再标记):
int dir[4][2] = {0, 1, 1, 0, -1, 0, 0, -1}; // 四个方向 void bfs(vector<vector<char>>& grid, vector<vector<bool>>& visited, int x, int y) { queue<pair<int, int>> que; que.push({x, y}); while(!que.empty()) { pair<int ,int> cur = que.front(); que.pop(); int curx = cur.first; int cury = cur.second; visited[curx][cury] = true; // 从队列中取出在标记走过 for (int i = 0; i < 4; i++) { int nextx = curx + dir[i][0]; int nexty = cury + dir[i][1]; if (nextx < 0 || nextx >= grid.size() || nexty < 0 || nexty >= grid[0].size()) continue; // 越界了,直接跳过 if (!visited[nextx][nexty] && grid[nextx][nexty] == '1') { que.push({nextx, nexty}); } } } } 作者:代码随想录
理应加入队列就代表走过,立刻标记,正确写法:
int dir[4][2] = {0, 1, 1, 0, -1, 0, 0, -1}; // 四个方向 void bfs(vector<vector<char>>& grid, vector<vector<bool>>& visited, int x, int y) { queue<pair<int, int>> que; que.push({x, y}); visited[x][y] = true; // 只要加入队列,立刻标记 while(!que.empty()) { pair<int ,int> cur = que.front(); que.pop(); int curx = cur.first; int cury = cur.second; for (int i = 0; i < 4; i++) { int nextx = curx + dir[i][0]; int nexty = cury + dir[i][1]; if (nextx < 0 || nextx >= grid.size() || nexty < 0 || nexty >= grid[0].size()) continue; // 越界了,直接跳过 if (!visited[nextx][nexty] && grid[nextx][nexty] == '1') { que.push({nextx, nexty}); visited[nextx][nexty] = true; // 只要加入队列立刻标记 } } } } 作者:代码随想录
visited[x][y] = true放在的地方,取决于我们对代码中队列的定义,队列中的节点就表示已经走过的节点。所以只要加入队列,立即标记该节点走过。
完整广搜代码:
class Solution { private: int dir[4][2] = {0, 1, 1, 0, -1, 0, 0, -1}; // 四个方向 void bfs(vector<vector<char>>& grid, vector<vector<bool>>& visited, int x, int y) { queue<pair<int, int>> que; que.push({x, y}); visited[x][y] = true; // 只要加入队列,立刻标记 while(!que.empty()) { pair<int ,int> cur = que.front(); que.pop(); int curx = cur.first; int cury = cur.second; for (int i = 0; i < 4; i++) { int nextx = curx + dir[i][0]; int nexty = cury + dir[i][1]; if (nextx < 0 || nextx >= grid.size() || nexty < 0 || nexty >= grid[0].size()) continue; // 越界了,直接跳过 if (!visited[nextx][nexty] && grid[nextx][nexty] == '1') { que.push({nextx, nexty}); visited[nextx][nexty] = true; // 只要加入队列立刻标记 } } } } public: int numIslands(vector<vector<char>>& grid) { int n = grid.size(), m = grid[0].size(); vector<vector<bool>> visited = vector<vector<bool>>(n, vector<bool>(m, false)); int result = 0; for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { if (!visited[i][j] && grid[i][j] == '1') { result++; // 遇到没访问过的陆地,+1 bfs(grid, visited, i, j); // 将与其链接的陆地都标记上 true } } } return result; } }; 作者:代码随想录
例题:填海造陆问题 827. 最大人工岛
这道题是 695. 岛屿的最大面积 的升级版。现在我们有填海造陆的能力,可以把一个海洋格子变成陆地格子,进而让两块岛屿连成一块。那么填海造陆之后,最大可能构造出多大的岛屿呢?
大致的思路我们不难想到,我们先计算出所有岛屿的面积,在所有的格子上标记出岛屿的面积。然后搜索哪个海洋格子相邻的两个岛屿面积最大。例如下图中红色方框内的海洋格子,上边、左边都与岛屿相邻,我们可以计算出它变成陆地之后可以连接成的岛屿面积为7+1+2=10。
然而,这种做法可能遇到一个问题。如下图中红色方框内的海洋格子,它的上边、左边都与岛屿相邻,这时候连接成的岛屿面积难道是7+1+7?显然不是。这两个 7 来自同一个岛屿,所以填海造陆之后得到的岛屿面积应该只有7+1=8。
可以看到,要让算法正确,我们需要能区分一个海洋格子相邻的两个 7 是不是来自同一个岛屿。那么,我们不能在方格中标记岛屿的面积,而应该标记岛屿的索引(下标),另外用一个数组记录每个岛屿的面积,如下图所示。这样我们就可以发现红色方框内的海洋格子,它的「两个」相邻的岛屿实际上是同一个。
可以看到,这道题实际上是对网格做了两遍 DFS:第一遍 DFS 遍历陆地格子,计算每个岛屿的面积并标记岛屿;第二遍 DFS 遍历海洋格子,观察每个海洋格子相邻的陆地格子。
class Solution { private: int count; int dir[4][2] = {0, 1, 1, 0, -1, 0, 0, -1}; // 四个方向 void dfs(vector<vector<int>>& grid, vector<vector<bool>>& visited, int x, int y, int mark) { if (visited[x][y] || grid[x][y] == 0) return; // 终止条件:访问过的节点 或者 遇到海水 visited[x][y] = true; // 标记访问过 grid[x][y] = mark; // 给陆地标记新标签 count++; for (int i = 0; i < 4; i++) { int nextx = x + dir[i][0]; int nexty = y + dir[i][1]; if (nextx < 0 || nextx >= grid.size() || nexty < 0 || nexty >= grid[0].size()) continue; // 越界了,直接跳过 dfs(grid, visited, nextx, nexty, mark); } } public: int largestIsland(vector<vector<int>>& grid) { int n = grid.size(), m = grid[0].size(); vector<vector<bool>> visited = vector<vector<bool>>(n, vector<bool>(m, false)); // 标记访问过的点 unordered_map<int ,int> gridNum; int mark = 2; // 记录每个岛屿的编号 bool isAllGrid = true; // 标记是否整个地图都是陆地 for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { if (grid[i][j] == 0) isAllGrid = false; if (!visited[i][j] && grid[i][j] == 1) { count = 0; dfs(grid, visited, i, j, mark); // 将与其链接的陆地都标记上 true gridNum[mark] = count; // 记录每一个岛屿的面积 mark++; // 记录下一个岛屿编号 } } } if (isAllGrid) return n * m; // 如果都是陆地,返回全面积 // 以下逻辑是根据添加陆地的位置,计算周边岛屿面积之和 int result = 0; // 记录最后结果 unordered_set<int> visitedGrid; // 标记访问过的岛屿 for (int i = 0; i < n; i++) { for (int j = 0; j < m; j++) { int count = 1; // 记录连接之后的岛屿数量 visitedGrid.clear(); // 每次使用时,清空 if (grid[i][j] == 0) { for (int k = 0; k < 4; k++) { int neari = i + dir[k][1]; // 计算相邻坐标 int nearj = j + dir[k][0]; if (neari < 0 || neari >= grid.size() || nearj < 0 || nearj >= grid[0].size()) continue; if (visitedGrid.count(grid[neari][nearj])) continue; // 添加过的岛屿不要重复添加 // 把相邻四面的岛屿数量加起来 count += gridNum[grid[neari][nearj]]; visitedGrid.insert(grid[neari][nearj]); // 标记该岛屿已经添加过 } } result = max(result, count); } } return result; } }; 作者:代码随想录
例题:岛屿的周长 463. 岛屿的周长
实话说,这道题用 DFS 来解并不是最优的方法。对于岛屿,直接用数学的方法求周长会更容易。不过这道题是一个很好的理解 DFS 遍历过程的例题。
实际上,岛屿的周长是计算岛屿全部的「边缘」,而这些边缘就是我们在 DFS 遍历中,
dfs
函数返回的位置。观察题目示例,我们可以将岛屿的周长中的边分为两类,如下图所示。黄色的边是与网格边界相邻的周长,而蓝色的边是与海洋格子相邻的周长。当我们的
dfs
函数因为「坐标(r, c)
超出网格范围」返回的时候,实际上就经过了一条黄色的边;而当函数因为「当前格子是海洋格子」返回的时候,实际上就经过了一条蓝色的边。这样,我们就把岛屿的周长跟 DFS 遍历联系起来了,我们的题解代码也呼之欲出:public int islandPerimeter(int[][] grid) { for (int r = 0; r < grid.length; r++) { for (int c = 0; c < grid[0].length; c++) { if (grid[r][c] == 1) { // 题目限制只有一个岛屿,计算一个即可 return dfs(grid, r, c); } } } return 0; } int dfs(int[][] grid, int r, int c) { // 函数因为「坐标 (r, c) 超出网格范围」返回,对应一条黄色的边 if (!inArea(grid, r, c)) { return 1; } // 函数因为「当前格子是海洋格子」返回,对应一条蓝色的边 if (grid[r][c] == 0) { return 1; } // 函数因为「当前格子是已遍历的陆地格子」返回,和周长没关系 if (grid[r][c] != 1) { return 0; } grid[r][c] = 2; return dfs(grid, r - 1, c) + dfs(grid, r + 1, c) + dfs(grid, r, c - 1) + dfs(grid, r, c + 1); } // 判断坐标 (r, c) 是否在网格中 boolean inArea(int[][] grid, int r, int c) { return 0 <= r && r < grid.length && 0 <= c && c < grid[0].length; }
LeetCode101.对称二叉树:
问题描述:
给你一个二叉树的根节点
root
, 检查它是否轴对称。示例 1:
输入:root = [1,2,2,3,4,4,3] 输出:true
代码分析:
镜像对称问题。
我们可以实现这样一个递归函数,通过「同步移动」两个指针的方法来遍历这棵树,p 指针和 q 指针一开始都指向这棵树的根,随后 p 右移时,q 左移,p 左移时,q 右移。每次检查当前 p 和 q 节点的值是否相等,如果相等再判断左右子树是否对称。
代码如下:
class Solution { public: bool check(TreeNode *p, TreeNode *q) { if (!p && !q) return true; if (!p || !q) return false; return p->val == q->val && check(p->left, q->right) && check(p->right, q->left); } bool isSymmetric(TreeNode* root) { return check(root, root); } }; 作者:力扣官解
以上方法中我们用递归的方法实现了对称性的判断,那么如何用迭代的方法实现呢?首先我们引入一个队列,这是把递归程序改写成迭代程序的常用方法。初始化时我们把根节点入队两次。每次提取两个结点并比较它们的值(队列中每两个连续的结点应该是相等的,而且它们的子树互为镜像),然后将两个结点的左右子结点按相反的顺序插入队列中。当队列为空时,或者我们检测到树不对称(即从队列中取出两个不相等的连续结点)时,该算法结束。
class Solution { public: bool check(TreeNode *u, TreeNode *v) { queue <TreeNode*> q; q.push(u); q.push(v); while (!q.empty()) { u = q.front(); q.pop(); v = q.front(); q.pop(); if (!u && !v) continue; if ((!u || !v) || (u->val != v->val)) return false; q.push(u->left); q.push(v->right); q.push(u->right); q.push(v->left); } return true; } bool isSymmetric(TreeNode* root) { return check(root, root); } }; 作者:力扣官解
但是这样会隐藏一个小小的可优化之处!从root作为起点会将每个点比较两次,虽然时间复杂度不变但是时间翻倍!
因此以上代码我们均需要把起点处的check(root, root)改为check(root->left, root->right)!!!
此外我们把与本题相关且类似的题目代码综合到一起,也便是本题的解答:
class Solution { // 翻转后,与原树相同,则为轴对称 public boolean isSymmetric(TreeNode root) { return isSameTree(cloneTree(root), invertTree(root)); } // 克隆二叉树 public TreeNode cloneTree(TreeNode root) { if (root == null) { return null; } return new TreeNode(root.val, cloneTree(root.left), cloneTree(root.right)); } // 226.翻转二叉树 public TreeNode invertTree(TreeNode root) { if (root == null) return null; TreeNode left = root.left; root.left = root.right; root.right = left; invertTree(root.left); invertTree(root.right); return root; } // 100.相同的树 public boolean isSameTree(TreeNode p, TreeNode q) { if (p == null) { return q == null; } return q != null && p.val == q.val && isSameTree(p.left, q.left) && isSameTree(p.right, q.right); } }
LeetCode104.二叉树的最大&最小深度:
问题描述:
最大/最小深度指的是从根节点到最远/最近叶子节点的最长/最短路径上的节点数量。
代码分析:
不要小瞧这类递归题,它能很好地检查你对于搜索与回溯算法的掌握程度。往往是一看就会,一写就像无头苍蝇。
我们先看最大深度,做完后再看一道类似的 559. N 叉树的最大深度 。
递归:
本题可以使用前序(中左右),也可以使用后序遍历(左右中),使用前序求的是深度,使用后序求的是高度。
- 二叉树节点的深度:指从根节点到该节点的最长简单路径边的条数或者节点数(取决于深度从0开始还是从1开始)
- 二叉树节点的高度:指从该节点到叶子节点的最长简单路径边的条数或者节点数(取决于高度从0开始还是从1开始)
而根节点的高度就是二叉树的最大深度,所以本题中我们可以通过后序求的根节点高度来求的二叉树最大深度。(这一点其实是很多同学没有想清楚的,很多题解同样没有讲清楚。在此感谢 代码随想录)
我们先用后序遍历(左右中)来计算根节点的高度:
- 确定递归函数的参数和返回值:参数就是传入树的根节点,返回根节点的高度,所以返回值为int类型。
- 确定终止条件:如果为空节点的话,就返回0,表示高度为0。
- 确定单层递归的逻辑:先求它的左子树的高度,再求的右子树的高度,最后取左右高度最大的数值再+1 (加1是因为算上当前中间节点)就是目前节点为根节点的高度。
整体如下:
class solution { public: int getdepth(treenode* node) { if (node == NULL) return 0; int leftdepth = getdepth(node->left); // 左 int rightdepth = getdepth(node->right); // 右 int depth = 1 + max(leftdepth, rightdepth); // 中 return depth; } int maxdepth(treenode* root) { return getdepth(root); } }; 作者:代码随想录
简化后就是我们熟知的:
class solution { public: int maxdepth(treenode* root) { if (root == null) return 0; return 1 + max(maxdepth(root->left), maxdepth(root->right)); } }; 作者:代码随想录
精简之后的代码根本看不出是哪种遍历方式,也看不出递归三部曲的步骤,所以如果对二叉树的操作还不熟练,尽量不要直接照着精简代码来学。
本题当然也可以使用前序,代码如下:(充分表现出求深度回溯的过程)
class solution { public: int result; void getdepth(treenode* node, int depth) { result = depth > result ? depth : result; // 中 if (node->left == NULL && node->right == NULL) return ; if (node->left) { // 左 depth++; // 深度+1 getdepth(node->left, depth); depth--; // 回溯,深度-1 } if (node->right) { // 右 depth++; // 深度+1 getdepth(node->right, depth); depth--; // 回溯,深度-1 } return ; } int maxdepth(treenode* root) { result = 0; if (root == NULL) return result; getdepth(root, 1); return result; } }; 作者:代码随想录
简化后:
class solution { public: int result; void getdepth(treenode* node, int depth) { result = depth > result ? depth : result; // 中 if (node->left == NULL && node->right == NULL) return ; if (node->left) { // 左 getdepth(node->left, depth + 1); } if (node->right) { // 右 getdepth(node->right, depth + 1); } return ; } int maxdepth(treenode* root) { result = 0; if (root == 0) return result; getdepth(root, 1); return result; } }; 作者:代码随想录
可以看出使用了前序(中左右)的遍历顺序,这才是真正求深度的逻辑!
迭代法:
使用迭代法的话,使用层序遍历是最为合适的,因为最大的深度就是二叉树的层数,和层序遍历的方式极其吻合。
class solution { public: int maxdepth(treenode* root) { if (root == NULL) return 0; int depth = 0; queue<treenode*> que; que.push(root); while(!que.empty()) { int size = que.size(); depth++; // 记录深度 for (int i = 0; i < size; i++) { treenode* node = que.front(); que.pop(); if (node->left) que.push(node->left); if (node->right) que.push(node->right); } } return depth; } }; 作者:代码随想录
LeetCode559.N叉树的最大深度
我们顺便也来解决一下这题
class solution { public: int maxdepth(node* root) { if (root == 0) return 0; int depth = 0; for (int i = 0; i < root->children.size(); i++) { depth = max (depth, maxdepth(root->children[i])); } return depth + 1; } }; 作者:代码随想录
class solution { public: int maxdepth(node* root) { queue<node*> que; if (root != NULL) que.push(root); int depth = 0; while (!que.empty()) { int size = que.size(); depth++; // 记录深度 for (int i = 0; i < size; i++) { node* node = que.front(); que.pop(); for (int j = 0; j < node->children.size(); j++) { if (node->children[j]) que.push(node->children[j]); } } } return depth; } }; 作者:代码随想录
我们接着来看最小深度:
直觉上好像和求最大深度差不多,其实还是差不少的。
本题依然是前序遍历和后序遍历都可以,前序求的是深度,后序求的是高度。
本题还有一个误区,在处理节点的过程中,最大深度很容易理解,最小深度就不那么好理解,如图:
题目中说的是:最小深度是从根节点到最近叶子节点的最短路径上的节点数量。注意是最近的叶子节点。
我们仍从递归和迭代两个层面解题,递归中采用后序和前序。
还是要注意与最大深度的区别!在确定单层递归逻辑这块,有的同学可能会写成如下:
int leftDepth = getDepth(node->left); int rightDepth = getDepth(node->right); int result = 1 + min(leftDepth, rightDepth); return result;
这就犯了上图所示的错误了。
所以,正确逻辑应为:
- 如果左子树为空,右子树不为空,说明最小深度是 1 + 右子树的最小深度。
- 反之,右子树为空,左子树不为空,最小深度是 1 + 左子树的最小深度。
- 最后如果左右子树都不为空,返回1 + 左右子树深度最小值。
代码如下:
class Solution { public: int getDepth(TreeNode* node) { if (node == NULL) return 0; int leftDepth = getDepth(node->left); // 左 int rightDepth = getDepth(node->right); // 右 // 中 // 当一个左子树为空,右不为空,这时并不是最低点 if (node->left == NULL && node->right != NULL) { return 1 + rightDepth; } // 当一个右子树为空,左不为空,这时并不是最低点 if (node->left != NULL && node->right == NULL) { return 1 + leftDepth; } //当左右子树均存在或均不存在时 int result = 1 + min(leftDepth, rightDepth); return result; } int minDepth(TreeNode* root) { return getDepth(root); } }; 作者:代码随想录
精简之后:
class Solution { public: int minDepth(TreeNode* root) { if (root == NULL) return 0; if (root->left == NULL && root->right != NULL) { return 1 + minDepth(root->right); } if (root->left != NULL && root->right == NULL) { return 1 + minDepth(root->left); } return 1 + min(minDepth(root->left), minDepth(root->right)); } }; 作者:代码随想录
精简之后的代码根本看不出是哪种遍历方式,所以依然还要强调一波:如果对二叉树的操作还不熟练,尽量不要直接照着精简代码来学。
前序遍历的方式:
class Solution { private: int result; void getdepth(TreeNode* node, int depth) { //到达目标:叶子结点。开始更新答案 if (node->left == NULL && node->right == NULL) { result = min(depth, result); return; } // 中 只不过中没有处理的逻辑 if (node->left) { // 左 getdepth(node->left, depth + 1); } if (node->right) { // 右 getdepth(node->right, depth + 1); } return ; } public: int minDepth(TreeNode* root) { if (root == NULL) return 0; result = INT_MAX; getdepth(root, 1); return result; } }; 作者:代码随想录
至于迭代,还是要层序遍历bfs,只不过由于bfs的特性,当我们第一次找到一个叶子结点时就立刻返回答案。
class Solution { public: int minDepth(TreeNode* root) { if (root == NULL) return 0; int depth = 0; queue<TreeNode*> que; que.push(root); while(!que.empty()) { int size = que.size(); depth++; // 记录最小深度 for (int i = 0; i < size; i++) { TreeNode* node = que.front(); que.pop(); if (node->left) que.push(node->left); if (node->right) que.push(node->right); if (!node->left && !node->right) { // 当左右***都为空的时候,说明是最低点的一层了,退出 return depth; } } } return depth; } }; 作者:代码随想录
LeetCode226.翻转二叉树:
问题描述:
给你一棵二叉树的根节点
root
,翻转这棵二叉树,并返回其根节点。输入:root = [4,2,7,1,3,6,9] 输出:[4,7,2,9,6,3,1]
代码分析:
说到这题,肯定就想到前面写过的 101. 对称二叉树 。但是前面是一棵树的自我对称,本题是两棵树的镜像对称。
如果要从整个树来看,翻转还真的挺复杂,整个树以中间分割线进行翻转,如图:
但我们发现想要翻转它,其实只要把每一个节点的左右孩子翻转一下,就可以达到整体翻转的效果。
关键在于遍历顺序,前中后序应该选哪一种遍历顺序? (还是老生长谈的问题,一些同学这道题都过了,但是不知道自己用的是什么顺序)
这道题目使用前序遍历、后序遍历和层序遍历都可以,唯独中序遍历不方便,因为中序遍历会把某些节点的左右孩子翻转了两次!建议拿纸画一画,就理解了。
递归:
还是递归三部曲:
- 通常此时定下来主要参数和返回值类型,如果在写递归的逻辑中发现还需要其他参数的时候,随时补充。
- 确定终止条件为:节点为空即返回。
- 确定单层递归的逻辑:因为是先前序遍历,所以先进行交换左右孩子节点,然后反转左子树,反转右子树。
class Solution { public: TreeNode* invertTree(TreeNode* root) { if (root == NULL) return root; swap(root->left, root->right); // 中 invertTree(root->left); // 左 invertTree(root->right); // 右 //swap()也可放在这,就变成了后序遍历 return root; } }; 作者:代码随想录
迭代:
让我们来复习一下前面学过的前序遍历迭代法,这里先给出好理解的版本:
class Solution { public: TreeNode* invertTree(TreeNode* root) { if (root == NULL) return root; stack<TreeNode*> st; st.push(root); while(!st.empty()) { TreeNode* node = st.top(); // 中 st.pop(); swap(node->left, node->right); if(node->right) st.push(node->right); // 右 if(node->left) st.push(node->left); // 左 } return root; } }; 作者:代码随想录
我们再给出前中后遍历的统一写法:
class Solution { public: TreeNode* invertTree(TreeNode* root) { stack<TreeNode*> st; if (root != NULL) st.push(root); while (!st.empty()) { TreeNode* node = st.top(); if (node != NULL) { st.pop(); if (node->right) st.push(node->right); // 右 if (node->left) st.push(node->left); // 左 st.push(node); // 中 st.push(NULL); } else { st.pop(); node = st.top(); st.pop(); swap(node->left, node->right); // 节点处理逻辑 } } return root; } }; 作者:代码随想录
层序遍历也就是广度优先遍历:
class Solution { public: TreeNode* invertTree(TreeNode* root) { queue<TreeNode*> que; if (root != NULL) que.push(root); while (!que.empty()) { int size = que.size(); for (int i = 0; i < size; i++) { TreeNode* node = que.front(); que.pop(); swap(node->left, node->right); // 节点处理 if (node->left) que.push(node->left); if (node->right) que.push(node->right); } } return root; } }; 作者:代码随想录
拓展补充:
标准的递归中序遍历是不行的,因为如果使用标准的递归中序遍历,某些节点的左右孩子会翻转两次。若要如下修改,虽然能通过,但已经是个“四不像了”:
class Solution { public: TreeNode* invertTree(TreeNode* root) { if (root == NULL) return root; invertTree(root->left); // 左 swap(root->left, root->right); // 中 invertTree(root->left); // 注意 这里依然要遍历左孩子,因为中间节点已经翻转了 return root; } }; 作者:代码随想录
但是标准的迭代中序遍历是可以的,因为这是用栈来遍历,而不是靠指针来遍历,避免了递归法中翻转了两次的情况。这里不贴出了。
LeetCode112.路径总和:
问题描述:
给你二叉树的根节点
root
和一个表示目标和的整数targetSum
。判断该树中是否存在 根节点到叶子节点 的路径,这条路径上所有节点值相加等于目标和targetSum
。如果存在,返回true
;否则,返回false
。
代码分析:
相信很多同学都会疑惑,递归函数什么时候要有返回值,什么时候没有返回值,特别是有的时候递归函数返回类型为bool类型。
我们通过本题和其升级题 113. 路径总和 II 来具体讲解,在最后,我们顺便贴出本题的变型题 257. 二叉树的所有路径 的代码。与二叉树路径相关的题目还有很多,以后有空直接往本题后补充。
递归:
首先插一嘴,
-1000 <= Node.val <= 1000,因为有负数的存在,就不能采用在累计总和超过targetSum时直接return;这种剪枝方式!而应严格判断是否等于targetSum。
使用深度优先遍历的方式(本题前中后序都可以,无所谓,因为中节点也没有处理逻辑)来遍历二叉树。
我们对上面的问题总结三点
- 如果需要搜索整棵二叉树且不用处理递归返回值,递归函数就不要返回值。(这种情况就是本文下半部分介绍的 113. 路径总和 II)
- 如果需要搜索整棵二叉树且需要处理递归返回值,递归函数就需要返回值。 (这种情况我们在236. 二叉树的最近公共祖先中介绍)
- 如果要搜索其中一条符合条件的路径,那么递归一定需要返回值,因为遇到符合条件的路径了就要及时返回。(本题的情况)
此外,我们也可以通过添加无返回值的辅助dfs()函数与有返回值的主函数相结合来实现有返回值的主递归函数。
请对比一下如下两段代码:
class Solution { public: bool flag = false; 外设无返回值的辅助函数,其通过修改全局变量flag,使得有返回值的主函数返回该变量。 void dfs(TreeNode *root, int targetSum, int sum) { root为空的逻辑判断已写在主函数中,此处不用考虑。 if(!root->left && !root->right) { if(sum == targetSum) { flag = true; } return; } if(root->left) dfs(root->left, targetSum, sum + root->left->val); if(root->right) dfs(root->right, targetSum, sum + root->right->val); } bool hasPathSum(TreeNode* root, int targetSum) { if(!root) return flag; dfs(root, targetSum, root->val); return flag; } };
class solution { public: bool hasPathSum(TreeNode* root, int sum) { if (root == null) return false; if (!root->left && !root->right && sum == root->val) { return true; } return haspathsum(root->left, sum - root->val) || haspathsum(root->right, sum - root->val); } }; 作者:代码随想录
当然,这第二段代码是经过简化的,可能不好理解。
第二段代码拆解后如下所示:
class Solution { private: bool traversal(TreeNode* cur, int count) { if (!cur->left && !cur->right && count == 0) return true; // 遇到叶子节点,并且计数为0 if (!cur->left && !cur->right) return false; // 遇到叶子节点直接返回 if (cur->left) { // 左 (空节点不遍历) // 遇到叶子节点返回true,则直接返回true if (traversal(cur->left, count - cur->left->val)) return true; // 注意这里有回溯的逻辑 } if (cur->right) { // 右 (空节点不遍历) // 遇到叶子节点返回true,则直接返回true if (traversal(cur->right, count - cur->right->val)) return true; // 注意这里有回溯的逻辑 } return false; } public: bool hasPathSum(TreeNode* root, int sum) { if (root == NULL) return false; return traversal(root, sum - root->val); } }; 作者:代码随想录
迭代:
我们思考,如果用栈模拟递归,那么如何做回溯逻辑呢?
此时栈里一个元素不仅要记录该节点指针,还要记录从头结点到该节点的路径数值总和!
我们可以用二元组的形式
pair<TreeNode*, int>
pair<节点指针,路径数值>。迭代前序遍历代码如下:
class solution { public: bool haspathsum(TreeNode* root, int sum) { if (root == null) return false; // 此时栈里要放的是pair<节点指针,路径数值> stack<pair<TreeNode*, int>> st; st.push(pair<TreeNode*, int>(root, root->val)); while (!st.empty()) { pair<TreeNode*, int> node = st.top(); st.pop(); // 如果该节点是叶子节点了,同时该节点的路径数值等于sum,那么就返回true if (!node.first->left && !node.first->right && sum == node.second) return true; // 右节点,压进去一个节点的时候,将该节点的路径数值也记录下来 if (node.first->right) { st.push(pair<TreeNode*, int>(node.first->right, node.second + node.first->right->val)); } // 左节点,压进去一个节点的时候,将该节点的路径数值也记录下来 if (node.first->left) { st.push(pair<TreeNode*, int>(node.first->left, node.second + node.first->left->val)); } } return false; } }; 作者:代码随想录
到这,112.路径总和讲解完毕,我们马不停蹄来看113.路径总和II。
LeetCode113.路径总和II(注意广搜也可处理所有符合路径):
注意到本题的要求是,找到所有满足从「根节点」到某个「叶子节点」经过的路径上的节点之和等于目标和的路径。核心思想是对树进行一次遍历,在遍历时记录从根节点到当前节点的路径和,以防止重复计算。
深搜:
class Solution { public: vector<vector<int>> ret; vector<int> path; void dfs(TreeNode* root, int targetSum) { if (root == nullptr) { return; } path.emplace_back(root->val); targetSum -= root->val; if (root->left == nullptr && root->right == nullptr && targetSum == 0) { ret.emplace_back(path); } dfs(root->left, targetSum); dfs(root->right, targetSum); path.pop_back(); } vector<vector<int>> pathSum(TreeNode* root, int targetSum) { dfs(root, targetSum); return ret; } }; 作者:力扣官解
广搜:
为了节省空间,我们使用哈希表记录树中的每一个节点的父节点。每次找到一个满足条件的节点,我们就从该节点出发不断向父节点迭代,即可还原出从根节点到当前节点的路径!!!
class Solution { public: vector<vector<int>> ret; unordered_map<TreeNode*, TreeNode*> parent; void getPath(TreeNode* node) { vector<int> tmp; while (node != nullptr) { tmp.emplace_back(node->val); node = parent[node]; } reverse(tmp.begin(), tmp.end()); ret.emplace_back(tmp); } vector<vector<int>> pathSum(TreeNode* root, int targetSum) { if (root == nullptr) { return ret; } queue<TreeNode*> que_node; queue<int> que_sum; que_node.emplace(root); que_sum.emplace(0); while (!que_node.empty()) { TreeNode* node = que_node.front(); que_node.pop(); int rec = que_sum.front() + node->val; que_sum.pop(); if (node->left == nullptr && node->right == nullptr) { if (rec == targetSum) { getPath(node); } } else { if (node->left != nullptr) { parent[node->left] = node; que_node.emplace(node->left); que_sum.emplace(rec); } if (node->right != nullptr) { parent[node->right] = node; que_node.emplace(node->right); que_sum.emplace(rec); } } } return ret; } }; 作者:力扣官方题解
LeetCode257.二叉树的所有路径:
与113.类似,直接上代码,就是注意to_string函数。
class Solution { public: vector<string> v; void dfs(TreeNode *root, string s) { if(!root->left && !root->right) { v.emplace_back(s); return; } if(root->left) dfs(root->left, s + "->" + to_string(root->left->val)); if(root->right) dfs(root->right, s + "->" + to_string(root->right->val)); } vector<string> binaryTreePaths(TreeNode* root) { if(!root) return v; dfs(root, to_string(root->val)); return v; } };
LeetCode129. 求根节点到叶节点数字之和:
常规题,我们直接上代码:
深搜:
class Solution { public int sumNumbers(TreeNode root) { return dfs(root, 0); } public int dfs(TreeNode root, int prevSum) { if (root == null) { return 0; } int sum = prevSum * 10 + root.val; if (root.left == null && root.right == null) { return sum; } else { return dfs(root.left, sum) + dfs(root.right, sum); } } } 作者:力扣官解
广搜:
class Solution { public: int sumNumbers(TreeNode* root) { if (root == nullptr) { return 0; } int sum = 0; queue<TreeNode*> nodeQueue; queue<int> numQueue; nodeQueue.push(root); numQueue.push(root->val); while (!nodeQueue.empty()) { TreeNode* node = nodeQueue.front(); int num = numQueue.front(); nodeQueue.pop(); numQueue.pop(); TreeNode* left = node->left; TreeNode* right = node->right; if (left == nullptr && right == nullptr) { sum += num; } else { if (left != nullptr) { nodeQueue.push(left); numQueue.push(num * 10 + left->val); } if (right != nullptr) { nodeQueue.push(right); numQueue.push(num * 10 + right->val); } } } return sum; } }; 作者:力扣官解
LeetCode124.二叉树中的最大路径和(难):
本题难点在于理解和转化题意。
- 空节点的最大贡献值等于 0。
- 非空节点的最大贡献值等于节点值与其子节点中的最大贡献值之和(对于叶节点而言,最大贡献值等于节点值)。
例如,考虑如下二叉树:
-10 / \ 9 20 / \ 15 7
叶节点 9、15、7 的最大贡献值分别为 9、15、7。
得到叶节点的最大贡献值之后,再计算非叶节点的最大贡献值。节点 20 的最大贡献值等于 20+max(15,7)=35,节点 −10 的最大贡献值等于 −10+max(9,35)=25。
上述计算过程是递归的过程,因此,对根节点调用函数
maxGain
,即可得到每个节点的最大贡献值。根据函数 maxGain 得到每个节点的最大贡献值之后,如何得到二叉树的最大路径和?对于二叉树中的一个节点,该节点的最大路径和取决于该节点的值与该节点的左右子节点的最大贡献值,如果子节点的最大贡献值为正,则计入该节点的最大路径和,否则不计入该节点的最大路径和。维护一个全局变量 maxSum 存储最大路径和,在递归过程中更新 maxSum 的值,最后得到的 maxSum 的值即为二叉树中的最大路径和。
class Solution { private: int maxSum = INT_MIN; public: //递归计算单边最大路径贡献值的同时更新着节点的最大路径。 int maxGain(TreeNode* node) { if (node == nullptr) { return 0; } // 递归计算左右子节点的最大贡献值 // 只有在最大贡献值大于 0 时,才会选取对应子节点 //即左/右侧路径可以取也可以不取 int leftGain = max(maxGain(node->left), 0); int rightGain = max(maxGain(node->right), 0); // 节点的最大路径和取决于该节点的值与该节点的左右子节点的最大贡献值 int priceNewpath = node->val + leftGain + rightGain; // 更新答案 maxSum = max(maxSum, priceNewpath); // 返回节点的最大贡献值 return node->val + max(leftGain, rightGain); } int maxPathSum(TreeNode* root) { maxGain(root); return maxSum; } }; 作者:力扣官解
注意本题的递归代码不是很通俗易懂,需要反复阅读理解。
LeetCode437. 路径总和III(难):
从一定角度来看,本题与上一题124.的难度类似,甚至还要难上一个层次。
深搜:
我们首先想到的解法是穷举所有的可能,我们访问每一个节点 node,检测以 node 为起始节点且向下延深的路径有多少种。我们递归遍历每一个节点的所有可能的路径,然后将这些路径数目加起来即为返回结果。
- 我们首先定义 rootSum(p,val) 表示以节点 p 为起点向下且满足路径总和为 val 的路径数目。我们对二叉树上每个节点 p 求出 rootSum(p,targetSum),然后对这些路径数目求和即为返回结果。
- 我们对节点 p 求 rootSum(p,targetSum) 时,以当前节点 p 为目标路径的起点递归向下进行搜索。假设当前的节点 p 的值为 val,我们对左子树和右子树进行递归搜索,对节点 p 的左孩子节点 pl 求出 rootSum(pl,targetSum−val),以及对右孩子节点 pr 求出 rootSum(pr,targetSum−val)。节点 p 的 rootSum(p,targetSum) 即等于 rootSum(pl,targetSum−val) 与 rootSum(pr,targetSum−val) 之和,同时我们还需要判断一下当前节点 p 的值是否刚好等于 targetSum。
- 我们采用递归遍历二叉树的每个节点 p,对节点 p 求 rootSum(p,val),然后将每个节点所有求的值进行相加求和返回。
/*DFS+DFS(带返回值)*/ class Solution { public: int rootSum(TreeNode* root, long long targetSum) { if (!root) { return 0; } int ret = 0; if (root->val == targetSum) { ret++; } ret += rootSum(root->left, targetSum - root->val); ret += rootSum(root->right, targetSum - root->val); return ret; } int pathSum(TreeNode* root, long long targetSum) { if (!root) { return 0; } int ret = rootSum(root, targetSum); ret += pathSum(root->left, targetSum); ret += pathSum(root->right, targetSum); return ret; } }; 作者:力扣官解
/*DFS+DFS(不带返回值)*/ // 版本二:DFS + DFS(不带返回值) class Solution { int count, num, targetSum; public int pathSum(TreeNode root, int targetSum) { if(root == null) return 0; this.targetSum = targetSum; dfs(root); // DFS遍历所有结点 return count; } private void dfs(TreeNode node){ if(node == null) return; check(node, 0); // 考察以当前结点为起始的满足要求的路径数量 dfs(node.left); dfs(node.right); } private void check(TreeNode node, int sum){ if(node == null) return; sum = sum + node.val; if(sum == targetSum) count++; // 一旦满足,立即累计 check(node.left, sum); check(node.right, sum); } } 作者:yukiyama
/*BFS+DFS*/ // 版本一:BFS + DFS class Solution { int count, num, targetSum; public int pathSum(TreeNode root, int targetSum) { if(root == null) return 0; this.targetSum = targetSum; Queue<TreeNode> q = new ArrayDeque<>(); q.add(root); while(!q.isEmpty()){ // BFS遍历所有结点 TreeNode head = q.remove(); check(head, 0); // 考察以当前结点为起始的满足要求的路径数量 if(head.left != null) q.add(head.left); if(head.right != null) q.add(head.right); } return count; } private void check(TreeNode node, int sum){ if(node == null) return; sum = sum + node.val; if(sum == targetSum) count++; // 一旦满足,立即累计 check(node.left, sum); check(node.right, sum); } } 作者:yukiyama
前缀和:
我们仔细思考一下,解法一中应该存在许多重复计算。本题在父子链路上体现出从某点到某点的连续性,这启发我们使用前缀和方法处理。我们定义节点的前缀和为:由根结点到当前结点的路径上所有节点的和。我们利用先序遍历二叉树,记录下根节点 root 到当前节点 p 的路径上除当前节点以外所有节点的前缀和,在已保存的路径前缀和中查找是否存在前缀和刚好等于当前节点到根节点的前缀和 curr 减去 targetSum。
- 对于空路径我们也需要保存预先处理一下,此时因为空路径不经过任何节点,因此它的前缀和为 0。
- 假设根节点为 root,我们当前刚好访问节点 node,则此时从根节点 root 到节点 node 的路径(无重复节点)刚好为 root→p1→p2→…→pk→node,此时我们可以已经保存了节点 p1,p2,p3,…,pk 的前缀和,并且计算出了节点 node 的前缀和。
- 假设当前从根节点 root 到节点 node 的前缀和为 curr,则此时我们在已保存的前缀和查找是否存在前缀和刚好等于 curr−targetSum。假设从根节点 root 到节点 node 的路径中存在节点 pi 到根节点 root 的前缀和为 curr−targetSum,则节点 pi+1 到 node 的路径上所有节点的和一定为 targetSum。
- 我们利用深度搜索遍历树,当我们退出当前节点时,我们需要及时更新已经保存的前缀和。
需要注意的是,前缀和求差的对象是同一条路径上的结点,因此在dfs遍历树的过程中,当到达叶子结点,之后向上返回时,路径退缩,使得当前结点将退出后续路径。
对前缀和求差的前提是要保证map中所保存的前缀和均为同一路径上的结点的前缀和,因此需要删除返回前的节点所代表的前缀和。
class Solution { public: unordered_map<long long, int> prefix; int dfs(TreeNode *root, long long curr, int targetSum) { if (!root) { return 0; } int ret = 0; curr += root->val; if (prefix.count(curr - targetSum)) { ret = prefix[curr - targetSum]; } prefix[curr]++; ret += dfs(root->left, curr, targetSum); ret += dfs(root->right, curr, targetSum); prefix[curr]--; return ret; } int pathSum(TreeNode* root, int targetSum) { prefix[0] = 1; return dfs(root, 0, targetSum); } }; 作者:力扣官解
LeetCode222.完全二叉树的节点个数:
问题描述:
给你一棵 完全二叉树 的根节点
root
,求出该树的节点个数。遍历树来统计节点是一种时间复杂度为
O(n)
的简单解决方案。你可以设计一个更快的算法吗?
代码分析:
这是一道很好的题目,考察了完全二叉树和满二叉树的基础知识。
如果没有充足的时间去理解官解,按照东哥或代码随想录的理解来也可以。
如果是一个普通二叉树,显然只要向下面这样遍历一边即可,时间复杂度 O(n):
public int countNodes(TreeNode root) { if (root == null) return 0; return 1 + countNodes(root.left) + countNodes(root.right); }
那如果是一棵满二叉树,节点总数就和树的高度呈指数关系:
public int countNodes(TreeNode root) { int h = 0; // 计算树的高度 while (root != null) { root = root.left; h++; } // 节点总数就是 2^h - 1 return (int)Math.pow(2, h) - 1; }
完全二叉树比普通二叉树特殊,但又没有满二叉树那么特殊,计算它的节点总数,可以说是普通二叉树和完全二叉树的结合版,先看代码:
public int countNodes(TreeNode root) { TreeNode l = root, r = root; // 沿最左侧和最右侧分别计算高度 int hl = 0, hr = 0; while (l != null) { l = l.left; hl++; } while (r != null) { r = r.right; hr++; } // 如果左右侧计算的高度相同,则是一棵满二叉树 if (hl == hr) { return (int)Math.pow(2, hl) - 1; } // 如果左右侧的高度不同,则按照普通二叉树的逻辑计算 return 1 + countNodes(root.left) + countNodes(root.right); } 作者:labuladong
这个目前最佳算法的时间复杂度是 O(logN*logN),这是怎么算出来的呢?
直觉感觉好像最坏情况下是 O(N*logN) 吧,因为之前的 while 需要 logN 的时间,最后要 O(N) 的时间向左右子树递归:
return 1 + countNodes(root.left) + countNodes(root.right);
关键点在于,这两个递归只有一个会真的递归下去,另一个一定会触发
hl == hr
而立即返回,不会递归下去。原因如下:
一棵完全二叉树的两棵子树,至少有一棵是满二叉树:
由于完全二叉树的性质,其子树一定有一棵是满的,所以一定会触发
hl == hr
,只消耗 O(logN) 的复杂度而不会继续递归。综上,算法的递归深度就是树的高度 O(logN),每次递归所花费的时间就是 while 循环,需要 O(logN),所以总体的时间复杂度是 O(logN*logN)。
LeetCode110.平衡二叉树:
问题描述:
给定一个二叉树,判断它是否是高度平衡的二叉树。
一棵高度平衡二叉树定义为:
一个二叉树每个节点的左右两个子树的高度差的绝对值不超过1。
代码分析:
都是老熟人了,要么自顶向下要么自底向上。
结合前面做过的求二叉树的最大深度,我们不难得出如下:
class Solution { public: int dfs(TreeNode *node) { if(!node) return 0; return 1 + max(dfs(node->left), dfs(node->right)); } bool isBalanced(TreeNode* root) { if(!root) return true; return abs(dfs(root->left) - dfs(root->right)) <=1 && isBalanced(root->left) && isBalanced(root->right); } };
时间复杂度为O(n^2)。 最坏情况下,二叉树是满二叉树,遍历二叉树中所有节点的时间复杂度是 O(n)。对于节点 p,如果它的高度是 d,则 height(p) 最多会被调用 d 次(即遍历到它的每一个祖先节点时)。对于平均的情况,一棵树的高度 h 满足 O(h)=O(logn),因为 d≤h,所以总时间复杂度为 O(nlogn)。对于最坏的情况,二叉树形成链式结构,高度为 O(n),此时总时间复杂度为 O(n^2)。
上面的解法由于是自顶向下递归,因此对于同一个节点,函数 height 会被重复调用,导致时间复杂度较高。如果使用自底向上的做法,则对于每个节点,函数 height 只会被调用一次。
自底向上递归的做法类似于后序遍历,我们是这样设计的:
对于当前遍历到的节点,先递归地判断其左右子树是否平衡,再判断以当前节点为根的子树是否平衡。如果一棵子树是平衡的,则返回其高度(高度一定是非负整数),否则返回 −1。如果存在一棵子树不平衡,则整个二叉树一定不平衡。(即返回-1就代表这棵子树、子树所在的父树、整个二叉树一定不平衡,没必要返回高度了,直接把-1层层返回直到最终)
class Solution { public: // 返回以该节点为根节点的二叉树的高度,如果不是平衡二叉树了则返回-1 int getHeight(TreeNode* node) { if (node == NULL) { return 0; } int leftHeight = getHeight(node->left); if (leftHeight == -1) return -1; int rightHeight = getHeight(node->right); if (rightHeight == -1) return -1; return abs(leftHeight - rightHeight) > 1 ? -1 : 1 + max(leftHeight, rightHeight); } bool isBalanced(TreeNode* root) { return getHeight(root) == -1 ? false : true; } }; 作者:代码随想录
LeetCode404.左子叶之和:
问题描述:
给定二叉树的根节点
root
,返回所有左叶子节点之和。
代码分析:
如果题目改为求所有左节点之和,代码如下:
class Solution { public: int sumOfLeftLeaves(TreeNode* root) { if(!root) return 0; int leftSum = sumOfLeftLeaves(root->left); int rightSum = sumOfLeftLeaves(root->right); return root->left ? root->left->val + leftSum + rightSum : rightSum; } };
求所有左叶子节点之和:
class Solution { public: int sumOfLeftLeaves(TreeNode* root) { if(!root) return 0; int leftSum = sumOfLeftLeaves(root->left); int rightSum = sumOfLeftLeaves(root->right); return root->left && !root->left->left && !root->left->right ? root->left->val + leftSum + rightSum : leftSum + rightSum; } };
LeetCode513.找树左下角的值:
问题描述:
给定一个二叉树的 根节点
root
,请找出该二叉树的 最底层 最左边 节点的值。假设二叉树中至少有一个节点。
代码分析:
深搜:
先序遍历。使用 height 记录遍历到的节点的深度,curVal 记录深度在 curHeight 的最左节点的值。我们先判断当前节点的高度 height 是否大于 curHeight,如果是,那么将 curVal 设置为当前结点的值,curHeight 设置为 height,然后搜索当前节点的左子节点,再搜索当前节点的右子节点。
因为我们先遍历左子树,然后再遍历右子树,所以对同一高度的所有节点,最左节点肯定是最先被遍历到的。
class Solution { public: void dfs(TreeNode *root, int height, int &curVal, int &curHeight) { if (root == nullptr) { return; } height++; if (height > curHeight) { curHeight = height; curVal = root->val; } dfs(root->left, height, curVal, curHeight); dfs(root->right, height, curVal, curHeight); } int findBottomLeftValue(TreeNode* root) { int curVal, curHeight = 0; dfs(root, 0, curVal, curHeight); return curVal; } }; 作者:力扣官解
广搜:
层序遍历代码+记录最后一行第一个节点的数值。
class Solution { public: int findBottomLeftValue(TreeNode* root) { queue<TreeNode*> que; if (root != NULL) que.push(root); int result = 0; while (!que.empty()) { int size = que.size(); for (int i = 0; i < size; i++) { TreeNode* node = que.front(); que.pop(); if (i == 0) result = node->val; // 记录最后一行第一个元素 if (node->left) que.push(node->left); if (node->right) que.push(node->right); } } return result; } };
LeetCode105/106.从前中/中后遍历序列构造二叉树(中):
问题描述:
105.给定两个整数数组
preorder
和inorder
,其中preorder
是二叉树的先序遍历,inorder
是同一棵树的中序遍历,请构造二叉树并返回其根节点。106.给定两个整数数组
inorder
和postorder
,其中inorder
是二叉树的中序遍历,postorder
是同一棵树的后序遍历,请你构造并返回这颗二叉树 。
代码分析:
手算的逻辑都会,但是如何用代码表示出来呢?
105:
请先看106题解再回头做本题。
思路相同直接上代码:
class Solution { private: TreeNode* traversal (vector<int>& inorder, int inorderBegin, int inorderEnd, vector<int>& preorder, int preorderBegin, int preorderEnd) { if (preorderBegin == preorderEnd) return NULL; int rootValue = preorder[preorderBegin]; // 注意用preorderBegin 不要用0 TreeNode* root = new TreeNode(rootValue); if (preorderEnd - preorderBegin == 1) return root; int delimiterIndex; for (delimiterIndex = inorderBegin; delimiterIndex < inorderEnd; delimiterIndex++) { if (inorder[delimiterIndex] == rootValue) break; } // 切割中序数组 // 中序左区间,左闭右开[leftInorderBegin, leftInorderEnd) int leftInorderBegin = inorderBegin; int leftInorderEnd = delimiterIndex; // 中序右区间,左闭右开[rightInorderBegin, rightInorderEnd) int rightInorderBegin = delimiterIndex + 1; int rightInorderEnd = inorderEnd; // 切割前序数组 // 前序左区间,左闭右开[leftPreorderBegin, leftPreorderEnd) int leftPreorderBegin = preorderBegin + 1; int leftPreorderEnd = preorderBegin + 1 + delimiterIndex - inorderBegin; // 终止位置是起始位置加上中序左区间的大小size // 前序右区间, 左闭右开[rightPreorderBegin, rightPreorderEnd) int rightPreorderBegin = preorderBegin + 1 + (delimiterIndex - inorderBegin); int rightPreorderEnd = preorderEnd; root->left = traversal(inorder, leftInorderBegin, leftInorderEnd, preorder, leftPreorderBegin, leftPreorderEnd); root->right = traversal(inorder, rightInorderBegin, rightInorderEnd, preorder, rightPreorderBegin, rightPreorderEnd); return root; } public: TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) { if (inorder.size() == 0 || preorder.size() == 0) return NULL; // 参数坚持左闭右开的原则 return traversal(inorder, 0, inorder.size(), preorder, 0, preorder.size()); } }; 作者:代码随想录
106:
首先回忆一下如何根据两个顺序构造一个唯一的二叉树,相信理论知识大家应该都清楚,就是以后序数组的最后一个元素为切割点,先切中序数组,根据中序数组,反过来再切后序数组。一层一层切下去,每次后序数组最后一个元素就是节点元素。流程如图:
说到一层一层切割,就应该想到了递归。
- 如果数组大小为零的话,说明是空节点了。
- 如果不为空,那么取后序数组最后一个元素作为节点元素。
- 找到后序数组最后一个元素在中序数组的位置,作为切割点。
- 切割中序数组,切成中序左数组和中序右数组 (顺序别搞反了,一定是先切中序数组)
- 切割后序数组,切成后序左数组和后序右数组。
- 递归处理左区间和右区间
得出以下代码框架:
TreeNode* traversal (vector<int>& inorder, vector<int>& postorder) { // 第一步 if (postorder.size() == 0) return NULL; // 第二步:后序遍历数组最后一个元素,就是当前的中间节点 int rootValue = postorder[postorder.size() - 1]; TreeNode* root = new TreeNode(rootValue); // 叶子节点 if (postorder.size() == 1) return root; // 第三步:找切割点 int delimiterIndex; for (delimiterIndex = 0; delimiterIndex < inorder.size(); delimiterIndex++) { if (inorder[delimiterIndex] == rootValue) break; } // 第四步:切割中序数组,得到 中序左数组和中序右数组 // 第五步:切割后序数组,得到 后序左数组和后序右数组 // 第六步 root->left = traversal(中序左数组, 后序左数组); root->right = traversal(中序右数组, 后序右数组); return root; } 作者:代码随想录
难点是如何切割、切割的标准并且边界值找不好很容易乱套。
以后序遍历最后一个元素为分界线来切割中序数组,可以坚持左闭右开的原则,代码如下:
// 找到中序遍历的切割点 int delimiterIndex; for (delimiterIndex = 0; delimiterIndex < inorder.size(); delimiterIndex++) { if (inorder[delimiterIndex] == rootValue) break; } // 左闭右开区间:[0, delimiterIndex) vector<int> leftInorder(inorder.begin(), inorder.begin() + delimiterIndex); // [delimiterIndex + 1, end) vector<int> rightInorder(inorder.begin() + delimiterIndex + 1, inorder.end() ); 作者:代码随想录
接下来就要切割后序数组了。
首先后序数组的最后一个元素指定不能要了,这是切割点也是当前二叉树中间节点的元素,已经用了。由于后序数组的左和右是挨在一起的,所以分割点如何寻找?
关键点是中序数组切割后的左和右长度一定是和后序数组的左和右长度分别相同的。
代码如下:
// postorder 舍弃末尾元素,因为这个元素就是中间节点,已经用过了 postorder.resize(postorder.size() - 1); // 左闭右开,注意这里使用了左中序数组大小作为切割点:[0, leftInorder.size) vector<int> leftPostorder(postorder.begin(), postorder.begin() + leftInorder.size()); // [leftInorder.size(), end) vector<int> rightPostorder(postorder.begin() + leftInorder.size(), postorder.end()); 作者:代码随想录
完整代码如下:
class Solution { private: TreeNode* traversal (vector<int>& inorder, vector<int>& postorder) { if (postorder.size() == 0) return NULL; // 后序遍历数组最后一个元素,就是当前的中间节点 int rootValue = postorder[postorder.size() - 1]; TreeNode* root = new TreeNode(rootValue); // 叶子节点 if (postorder.size() == 1) return root; // 找到中序遍历的切割点 int delimiterIndex; for (delimiterIndex = 0; delimiterIndex < inorder.size(); delimiterIndex++) { if (inorder[delimiterIndex] == rootValue) break; } // 切割中序数组 // 左闭右开区间:[0, delimiterIndex) vector<int> leftInorder(inorder.begin(), inorder.begin() + delimiterIndex); // [delimiterIndex + 1, end) vector<int> rightInorder(inorder.begin() + delimiterIndex + 1, inorder.end() ); // postorder 舍弃末尾元素 postorder.resize(postorder.size() - 1); // 切割后序数组 // 依然左闭右开,注意这里使用了左中序数组大小作为切割点 // [0, leftInorder.size) vector<int> leftPostorder(postorder.begin(), postorder.begin() + leftInorder.size()); // [leftInorder.size(), end) vector<int> rightPostorder(postorder.begin() + leftInorder.size(), postorder.end()); root->left = traversal(leftInorder, leftPostorder); root->right = traversal(rightInorder, rightPostorder); return root; } public: TreeNode* buildTree(vector<int>& inorder, vector<int>& postorder) { if (inorder.size() == 0 || postorder.size() == 0) return NULL; return traversal(inorder, postorder); } }; 作者:代码随想录
此时应该发现了,如上的代码性能并不好,因为每层递归定义了新的vector,既耗时又耗空间,但上面的代码是最好理解的,为了方便读者理解,所以本人偏偏只摘录了代码随想录的题解。
下面给出用下标索引分割的代码版本:
class Solution { private: // 中序区间:[inorderBegin, inorderEnd),后序区间[postorderBegin, postorderEnd) TreeNode* traversal (vector<int>& inorder, int inorderBegin, int inorderEnd, vector<int>& postorder, int postorderBegin, int postorderEnd) { if (postorderBegin == postorderEnd) return NULL; int rootValue = postorder[postorderEnd - 1]; TreeNode* root = new TreeNode(rootValue); if (postorderEnd - postorderBegin == 1) return root; int delimiterIndex; for (delimiterIndex = inorderBegin; delimiterIndex < inorderEnd; delimiterIndex++) { if (inorder[delimiterIndex] == rootValue) break; } // 切割中序数组 // 左中序区间,左闭右开[leftInorderBegin, leftInorderEnd) int leftInorderBegin = inorderBegin; int leftInorderEnd = delimiterIndex; // 右中序区间,左闭右开[rightInorderBegin, rightInorderEnd) int rightInorderBegin = delimiterIndex + 1; int rightInorderEnd = inorderEnd; // 切割后序数组 // 左后序区间,左闭右开[leftPostorderBegin, leftPostorderEnd) int leftPostorderBegin = postorderBegin; int leftPostorderEnd = postorderBegin + delimiterIndex - inorderBegin; // 终止位置是 需要加上 中序区间的大小size // 右后序区间,左闭右开[rightPostorderBegin, rightPostorderEnd) int rightPostorderBegin = postorderBegin + (delimiterIndex - inorderBegin); int rightPostorderEnd = postorderEnd - 1; // 排除最后一个元素,已经作为节点了 root->left = traversal(inorder, leftInorderBegin, leftInorderEnd, postorder, leftPostorderBegin, leftPostorderEnd); root->right = traversal(inorder, rightInorderBegin, rightInorderEnd, postorder, rightPostorderBegin, rightPostorderEnd); return root; } public: TreeNode* buildTree(vector<int>& inorder, vector<int>& postorder) { if (inorder.size() == 0 || postorder.size() == 0) return NULL; // 左闭右开的原则 return traversal(inorder, 0, inorder.size(), postorder, 0, postorder.size()); } }; 作者:代码随想录
此外,考研党需要注意一个重要的点:
前序和后序不能唯一确定一棵二叉树!因为没有中序遍历无法确定左右部分,也就是无法分割。
LeetCode654.最大二叉树:
问题描述:
给定一个不重复的整数数组
nums
。 最大二叉树 可以用下面的算法从nums
递归地构建:
- 创建一个根节点,其值为
nums
中的最大值。- 递归地在最大值 左边 的 子数组前缀上 构建左子树。
- 递归地在最大值 右边 的 子数组后缀上 构建右子树。
返回
nums
构建的 最大二叉树 。
示例 1:
输入:nums = [3,2,1,6,0,5] 输出:[6,3,5,null,2,0,null,null,1] 解释:递归调用如下所示: - [3,2,1,6,0,5] 中的最大值是 6 ,左边部分是 [3,2,1] ,右边部分是 [0,5] 。 - [3,2,1] 中的最大值是 3 ,左边部分是 [] ,右边部分是 [2,1] 。 - 空数组,无子节点。 - [2,1] 中的最大值是 2 ,左边部分是 [] ,右边部分是 [1] 。 - 空数组,无子节点。 - 只有一个元素,所以子节点是一个值为 1 的节点。 - [0,5] 中的最大值是 5 ,左边部分是 [0] ,右边部分是 [] 。 - 只有一个元素,所以子节点是一个值为 0 的节点。 - 空数组,无子节点。
代码分析:
上一题我们收获了切割遍历数组以及构建树的相关技巧,我们来看一道相关题目。
法一:递归切割数组
思路与105.106.完全一样。
class Solution { public: TreeNode *dfs(vector<int> &nums, int left, int right) { if(right ==left) return nullptr; int delimiterIndex, maxNumber = INT_MIN; for(int i = left; i < right; ++i) { if(nums[i] > maxNumber) { maxNumber = nums[i]; delimiterIndex = i; } } TreeNode *root = new TreeNode(nums[delimiterIndex]); if(right - left == 1) return root; int lLeft = left; int lRight = delimiterIndex; root->left = dfs(nums, lLeft, lRight); int rLeft = delimiterIndex + 1; int rRight = right; root->right = dfs(nums, rLeft, rRight); return root; } TreeNode* constructMaximumBinaryTree(vector<int>& nums) { if(nums.size() == 0) return nullptr; return dfs(nums, 0, nums.size()); } };
当然本题代码有很多种书写形式,取决于在程序中设定的区间切割边界范围问题。如果将left和right设定为双闭,得到的代码如下:
class Solution { public: TreeNode* constructMaximumBinaryTree(vector<int>& nums) { return construct(nums, 0, nums.size() - 1); } TreeNode* construct(const vector<int>& nums, int left, int right) { if (left > right) { return nullptr; } int best = left; for (int i = left + 1; i <= right; ++i) { if (nums[i] > nums[best]) { best = i; } } TreeNode* node = new TreeNode(nums[best]); node->left = construct(nums, left, best - 1); node->right = construct(nums, best + 1, right); return node; } }; 作者:力扣官解
法二:单调栈
我们可以将题目中构造树的过程等价转换为下面的构造过程:
- 初始时,我们只有一个根节点,其中存储了整个数组;
- 在每一步操作中,我们可以「任选」一个存储了超过一个数的节点(一定是当前树的叶子结点),找出其中的最大值并存储在该节点。最大值左侧的数组部分下放到该节点的左子节点,右侧的数组部分下放到该节点的右子节点;
- 如果所有的节点都恰好存储了一个数,那么构造结束。
由于按照上述步骤最终一定会构造出一棵二叉树,因此无需按照题目的要求「递归」地进行构造,而是每次可以「任选」一个节点进行构造。这里可以类比一棵树的「深度优先搜索」和「广度优先搜索」,二者都可以起到遍历整棵树的效果。
既然可以任意进行选择,那么我们不妨每次选择数组中最大的那个节点进行构造。这样一来,我们就可以保证按照数组中元素降序排序的顺序依次构造每个节点。因此:
- 当我们选择的节点中数组的最大值为 nums[i] 时,所有大于 nums[i] 的元素已经被构造过(即被单独放入某一个节点中),所有小于 nums[i] 的元素还没有被构造过。
这就说明:
- 在最终构造出的树上,以 nums[i] 为根节点的子树,在原数组中对应的区间,左边界为 nums[i] 左侧第一个比它大的元素所在的位置,右边界为 nums[i] 右侧第一个比它大的元素所在的位置。左右边界均为开边界。
- 如果某一侧边界不存在,则那一侧边界为数组的边界。如果两侧边界均不存在,说明其为最大值,即根节点。
并且:
- nums[i] 的父结点是两个边界中较小的那个元素对应的节点。
因此,我们的任务变为:找出每一个元素左侧和右侧第一个比它大的元素所在的位置。这就是一个经典的单调栈问题了,可以参考 LeetCode专题:栈和队列(持续更新,已更17题)。如果左侧的元素较小,那么该元素就是左侧元素的右子节点;如果右侧的元素较小,那么该元素就是右侧元素的左子节点。
class Solution { public: TreeNode* constructMaximumBinaryTree(vector<int>& nums) { int n = nums.size(); vector<int> stk; vector<int> left(n, -1), right(n, -1); vector<TreeNode*> tree(n); for (int i = 0; i < n; ++i) { tree[i] = new TreeNode(nums[i]); //从左到右维护一个单减栈 while (!stk.empty() && nums[i] > nums[stk.back()]) { right[stk.back()] = i; stk.pop_back(); } if (!stk.empty()) { left[i] = stk.back(); } //回顾一下,如果栈为空代表左边第一个大于nums[i]的假想为-1处的哨兵 stk.push_back(i); } TreeNode* root = nullptr; for (int i = 0; i < n; ++i) { if (left[i] == -1 && right[i] == -1) { root = tree[i]; } else if (right[i] == -1 || (left[i] != -1 && nums[left[i]] < nums[right[i]])) { tree[left[i]]->right = tree[i]; } else { tree[right[i]]->left = tree[i]; } } return root; } }; 作者:力扣官解
我们还可以把最后构造树的过程放进单调栈求解的步骤中,省去用来存储左右边界的数组。下面的代码理解起来较为困难,同一个节点的左右子树会被多次赋值,读者可以仔细品味其妙处所在。
class Solution { public: TreeNode* constructMaximumBinaryTree(vector<int>& nums) { int n = nums.size(); vector<int> stk; vector<TreeNode*> tree(n); for (int i = 0; i < n; ++i) { tree[i] = new TreeNode(nums[i]); while (!stk.empty() && nums[i] > nums[stk.back()]) { tree[i]->left = tree[stk.back()]; stk.pop_back(); } if (!stk.empty()) { tree[stk.back()]->right = tree[i]; } stk.push_back(i); } return tree[stk[0]]; } }; 作者:力扣官解
当然,既然能用单调栈,就能用 RMQ(线段树)解法 。
关于线段树、ST表的入门和进阶请关注CSDN博主繁凡さん。
LeetCode617.合并二叉树:
问题描述:
给你两棵二叉树:
root1
和root2
。想象一下,当你将其中一棵覆盖到另一棵之上时,两棵树上的一些节点将会重叠(而另一些不会)。你需要将这两棵树合并成一棵新二叉树。合并的规则是:如果两个节点重叠,那么将这两个节点的值相加作为合并后节点的新值;否则,不为 null 的节点将直接作为新二叉树的节点。
返回合并后的二叉树。
注意: 合并过程必须从两个树的根节点开始。
示例 1:
输入:root1 = [1,3,2,5], root2 = [2,1,3,null,4,null,7] 输出:[3,4,5,5,4,null,7]
代码分析:
深搜:
class Solution { public: TreeNode* mergeTrees(TreeNode* t1, TreeNode* t2) { if(!t1 && !t2) return nullptr; TreeNode *root = new TreeNode((t1 ? t1->val : 0) + (t2 ? t2->val : 0)); root->left = mergeTrees(t1 ? t1->left : nullptr, t2 ? t2->left : nullptr); root->right = mergeTrees(t1 ? t1->right : nullptr, t2 ? t2->right : nullptr); return root; } };
时间复杂度:O(min(m,n)),其中 m 和 n 分别是两个二叉树的节点个数。对两个二叉树同时进行深度优先搜索,只有当两个二叉树中的对应节点都不为空时才会对该节点进行显性合并操作,因此被访问到的节点数不会超过较小的二叉树的节点数。
广搜:
纯粹模拟栈的过程,需要注意判断条件处理,就不贴出了。
LeetCode700.二叉搜索树中的搜索:
问题描述:
给定二叉搜索树(BST)的根节点
root
和一个整数值val
。你需要在 BST 中找到节点值等于
val
的节点。 返回以该节点为根的子树。 如果节点不存在,则返回null
。
示例 1:
输入:root = [4,2,7,1,3], val = 2 输出:[2,1,3]
代码分析:
二叉搜索树是一个有序树:
- 左子树所有节点的元素值均小于根的元素值;
- 右子树所有节点的元素值均大于根的元素值。
据此可以得到如下算法:
- 若 root 为空则返回空节点;
- 若 val=root.val,则返回 root;
- 若 val<root.val,递归左子树;
- 若 val>root.val,递归右子树。
class Solution { public: TreeNode *searchBST(TreeNode *root, int val) { if (root == nullptr) { return nullptr; } if (val == root->val) { return root; } return searchBST(val < root->val ? root->left : root->right, val); } }; 作者:力扣官解
改为迭代后:
class Solution { public: TreeNode *searchBST(TreeNode *root, int val) { while (root) { if (val == root->val) { return root; } root = val < root->val ? root->left : root->right; } return nullptr; } }; 作者:力扣官解
LeetCode98.验证二叉搜索树:
问题描述:
给你一个二叉树的根节点
root
,判断其是否是一个有效的二叉搜索树。有效二叉搜索树定义如下:
- 节点的左子树只包含 小于 当前节点的数。
- 节点的右子树只包含 大于 当前节点的数。
- 所有左子树和右子树自身必须也是二叉搜索树。
代码分析:
典型题具有代表性。
法一:
中序遍历下,输出的二叉搜索树节点的数值是有序序列。
有了这个特性,验证二叉搜索树,就相当于变成了判断一个序列是不是递增的了。
class Solution { private: vector<int> vec; void traversal(TreeNode* root) { if (root == NULL) return; traversal(root->left); vec.push_back(root->val); // 将二叉搜索树转换为有序数组 traversal(root->right); } public: bool isValidBST(TreeNode* root) { vec.clear(); // 不加这句在leetcode上也可以过,但最好加上 traversal(root); for (int i = 1; i < vec.size(); i++) { // 注意要小于等于,搜索树里不能有相同元素 if (vec[i] <= vec[i - 1]) return false; } return true; } }; 作者:代码随想录
当然,我们也可以在递归遍历的过程中直接判断是否有序。
这道题目比较容易陷入两个陷阱:
- 不能单纯的比较左节点小于中间节点,右节点大于中间节点就完事了。
- 样例中最小节点可能是int的最小值,如果这样使用最小的int来比较也是不行的。此时可以初始化比较元素为long long的最小值。
class Solution { public: long long maxVal = LONG_MIN; // 因为后台测试数据中有int最小值 bool isValidBST(TreeNode* root) { if (root == NULL) return true; bool left = isValidBST(root->left); // 中序遍历,验证遍历的元素是不是从小到大 if (maxVal < root->val) maxVal = root->val; else return false; bool right = isValidBST(root->right); return left && right; } }; 作者:代码随想录
如果测试数据中有 long long的最小值,怎么办?
不可能在初始化一个更小的值了吧。建议避免初始化最小值,如下方法取到最左面节点的数值来比较。
代码如下:
class Solution { public: TreeNode* pre = NULL; // 用来记录前一个节点 bool isValidBST(TreeNode* root) { if (root == NULL) return true; bool left = isValidBST(root->left); if (pre != NULL && pre->val >= root->val) return false; pre = root; // 记录前一个节点 bool right = isValidBST(root->right); return left && right; } }; 作者:代码随想录
法二:
当然,以上解法充分运用了二叉搜索树中序遍历下为从小到大的特性。
我们也可以换种普通思路。
直接上代码:
class Solution { public: bool helper(TreeNode* root, long long lower, long long upper) { if (root == nullptr) { return true; } if (root -> val <= lower || root -> val >= upper) { return false; } return helper(root -> left, lower, root -> val) && helper(root -> right, root -> val, upper); } bool isValidBST(TreeNode* root) { return helper(root, LONG_MIN, LONG_MAX); } }; 作者:力扣官解
LeetCode530.二叉搜索树的最小绝对差:
问题描述:
给你一个二叉搜索树的根节点
root
,返回 树中任意两不同节点值之间的最小差值 。差值是一个正数,其数值等于两值之差的绝对值。
代码分析:
老熟人了。普通办法还是采取中序遍历的过程中相邻两个元素作差。
class Solution { public: void dfs(TreeNode* root, int& pre, int& ans) { if (root == nullptr) { return; } dfs(root->left, pre, ans); if (pre == -1) { pre = root->val; } else { ans = min(ans, root->val - pre); pre = root->val; } dfs(root->right, pre, ans); } int getMinimumDifference(TreeNode* root) { int ans = INT_MAX, pre = -1; dfs(root, pre, ans); return ans; } };
LeetCode501. 二叉搜索树中的众数:
问题描述:
给你一个含重复值的二叉搜索树(BST)的根节点
root
,找出并返回 BST 中的所有 众数(即,出现频率最高的元素)。如果树中有不止一个众数,可以按 任意顺序 返回。
假定 BST 满足如下定义:
- 结点左子树中所含节点的值 小于等于 当前节点的值
- 结点右子树中所含节点的值 大于等于 当前节点的值
- 左子树和右子树都是二叉搜索树
进阶:你可以不使用额外的空间吗?(假设由递归产生的隐式调用栈的开销不被计算在内)
不用说迭代法的显式栈了,如果递归的开销也被计算呢?
代码分析:
首先,我们考虑在寻找出现次数最多的数时,不使用哈希表。 这个优化是基于二叉搜索树中序遍历的性质:一棵二叉搜索树的中序遍历序列是一个非递减的有序序列。例如:
1 / \ 0 2 / \ / -1 0 2
这样一颗二叉搜索树的中序遍历序列是 {−1,0,0,1,2,2}。我们可以发现重复出现的数字一定是一个连续出现的,例如这里的 0 和 2,它们都重复出现了,并且所有的 0 都集中在一个连续的段内,所有的 2 也集中在一个连续的段内。我们可以顺序扫描中序遍历序列,用 base 记录当前的数字,用 count 记录当前数字重复的次数,用 maxCount 来维护已经扫描过的数当中出现最多的那个数字的出现次数,用 answer 数组记录出现的众数。每次扫描到一个新的元素:
- 首先更新 base 和 count:如果该元素和 base 相等,那么 count 自增 1;否则将 base 更新为当前数字,count 复位为 1。
- 然后更新 maxCount:如果 count=maxCount,那么说明当前的这个数字 base 出现的次数等于当前众数出现的次数,将 base 加入 answer 数组;如果 count>maxCount,那么说明当前的这个数字 base 出现的次数大于当前众数出现的次数,因此,我们需要将 maxCount 更新为 count,清空 answer 数组后将 base 加入 answer 数组。
我们可以把这个过程写成一个 update 函数。这样我们在寻找出现次数最多的数字的时候就可以省去一个哈希表带来的空间消耗。然后,我们考虑不存储这个中序遍历序列。 如果我们在递归进行中序遍历的过程中,访问当了某个点的时候直接使用上面的 update 函数,就可以省去中序遍历序列的空间,代码如下。
class Solution { public: vector<int> answer; int base, count, maxCount; void update(int x) { //要注意判断的先后顺序 if (x == base) { ++count; } else { count = 1; base = x; } if (count == maxCount) { answer.push_back(base); } if (count > maxCount) { maxCount = count; //用base初始化也即清空 answer = vector<int> {base}; } } void dfs(TreeNode* o) { if (!o) { return; } dfs(o->left); update(o->val); dfs(o->right); } vector<int> findMode(TreeNode* root) { dfs(root); return answer; } }; 作者:力扣官解
想要把空间复杂度降到O(1),需要采用Morris中序遍历。
复习一下前面学过的内容,在这里我们只需要把所有遍历当前节点的操作改为update()即可。
class Solution { int base, count, maxCount; List<Integer> answer = new ArrayList<Integer>(); public int[] findMode(TreeNode root) { TreeNode cur = root; TreeNode pre = null; while (cur != null) { pre = cur.left; //构建连接线 if (pre != null) { while (pre.right != null && pre.right != cur) { pre = pre.right; } if (pre.right == null) { pre.right = cur; cur = cur.left; continue; } else { pre.right = null; } } update(cur.val); cur = cur.right; } //Java不要忘记把List<Integer>转换为int[] int[] mode = new int[answer.size()]; for (int i = 0; i < answer.size(); ++i) { mode[i] = answer.get(i); } return mode; } public void update(int x) { if (x == base) { ++count; } else { count = 1; base = x; } if (count == maxCount) { answer.add(base); } if (count > maxCount) { maxCount = count; answer.clear(); answer.add(base); } } }
LeetCode538.把二叉搜索树转换为累加树:
问题描述:
给出二叉 搜索 树的根节点,该树的节点值各不相同,请你将其转换为累加树(Greater Sum Tree),使每个节点
node
的新值等于原树中大于或等于node.val
的值之和。输入:[4,1,6,0,2,5,7,null,null,null,3,null,null,null,8] 输出:[30,36,21,36,35,26,15,null,null,null,33,null,null,null,8]
代码分析:
反向的中序遍历:
class Solution { public: int sum = 0; void dfs(TreeNode *root) { if(!root) return; dfs(root->right); sum += root->val; root->val = sum; dfs(root->left); } TreeNode* convertBST(TreeNode* root) { dfs(root); return root; } };
LeetCode235/236.二叉树/搜索树的最近公共祖先(LCA):
问题描述:
给定一个二叉树(二叉搜索树), 找到该树中两个指定节点的最近公共祖先。
百度百科中最近公共祖先的定义为:“对于有根树 T 的两个节点 p、q,最近公共祖先表示为一个节点 x,满足 x 是 p、q 的祖先且 x 的深度尽可能大(一个节点也可以是它自己的祖先)。”
示例 1:
输入:root = [3,5,1,6,2,0,8,null,null,7,4], p = 5, q = 1 输出:3 解释:节点 5 和节点 1 的最近公共祖先是节点 3 。
代码分析:
236:
本题较为困难,本人翻阅了许多题解,鲜有让小白眼前一亮的题解,绝大多数都要沉下心去花一番功夫理解,直到这篇来自于 Moment 。
当我们用递归去做这个题时不要被题目误导,应该要明确一点
这个函数的功能有三个
:
- 给定两个节点 p 和 q ,如果 p 和 q 都存在,则返回它们的公共祖先;
- 如果只存在一个,则返回存在的一个;
- 如果 p 和 q 都不存在,则返回 NULL。
本题说给定的两个节点都存在,那自然还是能用上面的函数来解决:
- 如果当前结点 root 等于 NULL,则直接返回 NULL;
- 如果 root 等于 p 或者 q ,那这棵树一定返回 p 或者 q;
- 然后递归左右子树,因为是递归,使用函数后可认为左右子树已经算出结果,用 left 和 right 表示;
- 此时若left为空,那最终结果只要看 right;若 right 为空,那最终结果只要看 left;
- 如果 left 和 right 都非空,因为只给了 p 和 q 两个结点,都非空,说明一边一个,因此 root 是他们的最近公共祖先;
- 如果 left 和 right 都为空,则返回空(其实已经包含在前面的情况中了)。
到这,令人心旷神怡,五体投地!
class Solution { public: TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) { if(root == NULL) return NULL; if(root == p || root == q) return root; TreeNode* left = lowestCommonAncestor(root->left, p, q); TreeNode* right = lowestCommonAncestor(root->right, p, q); if(left == NULL) return right; if(right == NULL) return left; if(left && right) // p和q在两侧 return root; return NULL; // 必须有返回值 } }; 作者:Moment
当然也可以把代码写得再优雅一点:
TreeNode* lowestCommonAncestor(TreeNode* root, TreeNode* p, TreeNode* q) { if (root == NULL || root == p || root == q) return root; TreeNode* left = lowestCommonAncestor(root->left, p, q); TreeNode* right = lowestCommonAncestor(root->right, p, q); if (left != NULL && right != NULL) return root; return left != NULL ? left : right; }
235:
用上搜索树的性质后,不再漫无目的地胡乱搜索p和q,而是一直沿着p/q的路径追寻下去;并且一行解决:
class Solution { public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) { return ((long)root.val - p.val) * ((long)root.val - q.val) <= 0 ? root : lowestCommonAncestor(p.val < root.val ? root.left : root.right, p, q); } }
LeetCode701/450.二叉搜索树中的插入/删除操作:
701.问题描述:
给定二叉搜索树(BST)的根节点
root
和要插入树中的值value
,将值插入二叉搜索树。返回插入后二叉搜索树的根节点。输入数据保证,新值和原始二叉搜索树中的任意节点值都不同。注意,可能存在多种有效的插入方式,只要树在插入后仍保持为二叉搜索树即可。你可以返回任意有效的结果 。
示例 1:
输入:root = [4,2,7,1,3], val = 5 输出:[4,2,7,1,3,5] 解释:另一个满足题目要求可以通过的树是:
450.问题描述:
给定一个二叉搜索树的根节点 root 和一个值 key,删除二叉搜索树中的 key 对应的节点,并保证二叉搜索树的性质不变。返回二叉搜索树(有可能被更新)的根节点的引用。
示例 1:
输入:root = [5,3,6,2,4,null,7], key = 3 输出:[5,4,6,2,null,null,7] 解释:给定需要删除的节点值是 3,所以我们首先找到 3 这个节点,然后删除它。 一个正确的答案是 [5,4,6,2,null,null,7], 如下图所示。 另一个正确答案是 [5,2,6,null,4,null,7]。
代码分析:
701:
其实这道题目其实是一道简单题目,但是题目中的提示:有多种有效的插入方式,还可以重构二叉搜索树,一下子吓退了不少人,瞬间感觉题目复杂了很多。
其实可以不考虑题目中提示所说的改变树的结构的插入方式。只要按照二叉搜索树的规则去遍历,遇到空节点就插入节点就可以了。
/*有返回值*/ class Solution { public: TreeNode* insertIntoBST(TreeNode* root, int val) { if (root == nullptr) { TreeNode* node = new TreeNode(val); return node; } if (root->val > val) root->left = insertIntoBST(root->left, val); if (root->val < val) root->right = insertIntoBST(root->right, val); return root; } };
/*无返回值*/ class Solution { private: TreeNode *parent; void traversal(TreeNode *cur, int val) { if (cur == nullptr) { TreeNode* node = new TreeNode(val); if (val > parent->val) parent->right = node; else parent->left = node; return; } parent = cur; if (cur->val > val) traversal(cur->left, val); if (cur->val < val) traversal(cur->right, val); return; } public: TreeNode* insertIntoBST(TreeNode *root, int val) { parent = new TreeNode(0); if (root == NULL) { root = new TreeNode(val); } traversal(root, val); return root; } };
450:
我们的 白 、梦璃夜·天星 二位神提出了双指针做法,这样可以避免掉官解的分类讨论:
class Solution { public: TreeNode* deleteNode(TreeNode* root, int key) { TreeNode **p = &root; while(*p && (*p)->val != key) p = (*p)->val < key ? &(*p)->right : &(*p)->left; if(!*p) return root; TreeNode **t = &(*p)->right; while(*t) t = &(*t)->left; *t = (*p)->left; *p = (*p)->right; return root; } }; 作者:梦璃夜天星
class Solution { public: TreeNode* mergeTree(TreeNode* lt, TreeNode* rt) { if (!lt) return rt; auto it = lt; while (it->right) it = it->right; it->right = rt; return lt; } TreeNode* deleteNode(TreeNode* root, int key) { TreeNode** it = &root; while (*it && (*it)->val != key) { if (key < (*it)->val) it = &(*it)->left; else it = &(*it)->right; } if (*it) { const auto to_delete = *it; *it = mergeTree((*it)->left, (*it)->right); delete to_delete; } return root; } }; 作者:白
二级指针能做到不改变节点内部的的值,因为即使是空指针也是会有自己的地址,直接通过更换指向子树根节点的指针的地址做到子树的移动。
当然他们采取的是用右孩子(右为空把左树合到右树)替换删除的节点 p,然后把 p 的左子树挂到 p 右子树的最小节点上。这样虽然可以保证删除后还是二叉搜索树,但是树的高度可能会变高,搜索树的性能变差。
你可能会有疑惑,为什么力扣的题目代码形参中传递的都是一级指针没见过二级指针呢?
到这里,我们先来总结一下二叉搜索树的性质,再来探讨别的解法。
二叉搜索树性质总结:
基本信息(采用ACM代码模式):
typedef int Elementtype; typedef struct TreeNode{ Elementtype val; struct TreeNode *left; struct TreeNode *right; }Tree; Tree *FindNode(Tree *obj, Elementtype x);//查找 int MaxData(Tree *obj);//找树的最大值 int MinData(Tree *obj);//找树的最小值 Tree *InsertNode(Tree **obj,Elementtype x);//插入, Tree *DeletNode(Tree **obj, Elementtype x);//删除
这里的删除与插入为什么传入二级指针,而查找不需要二级指针?
因为插入与删除改变的是树的结构,要往树里插入或删除结点,而在主函数里定义的是一个树指针变量初始化为空,改变树结构要将地址作为参数传递(传址)。而查找并没有改变树结构,所以只要传参。
如果,你是封装一个函数创建一树节点,即动态申请内存,将树信息值初始化后返回一地址,就不需要将定义的树指针的地址作为参数传入函数了。树指针变量里存的就是结点地址。总结就是,修改结构用一级指针就好,用二级指针(或指针的引用)作形参的原因是因为要修改函数外已经定义并初始化好的一级指针!!!我们来重点讲删除功能。
删除时要考虑四种情况:
情况4:既有左子树又有右子树 可以有两种解决办法: 1.找到左子树中的最大值,将值赋给给节点,然后将左子树最大值这个节点删除(删除可以用递归实现) 2.找到左子树中的最小值,将值赋给给节点,然后将右子树最小值这个节点删除 当时这样会有个弊端:当一直删除时,会导致树高度失衡,导致一边高,一边低,解决这样的办法可以删除左右子树最大最小节点交替实行。 或者记录一高度,主要删除,左子树或者右子树高的那一边。
class Solution { public: TreeNode* deleteNode(TreeNode* root, int key) { if (root == nullptr) { return nullptr; } if (root->val > key) { root->left = deleteNode(root->left, key); return root; } if (root->val < key) { root->right = deleteNode(root->right, key); return root; } if (root->val == key) { if (!root->left && !root->right) { return nullptr; } if (!root->right) { return root->left; } if (!root->left) { return root->right; } TreeNode *successor = root->right; while (successor->left) { successor = successor->left; } //注意此处的递归一定要有! root->right = deleteNode(root->right, successor->val); successor->right = root->right; successor->left = root->left; return successor; } return root; } }; 作者:力扣官解
至于如何变平衡,请见平衡二叉树相关题目。
LeetCode669.修剪二叉搜索树:
问题描述:
给你二叉搜索树的根节点
root
,同时给定最小边界low
和最大边界high
。通过修剪二叉搜索树,使得所有节点的值在[low, high]
中。修剪树不应该改变保留在树中的元素的相对结构 (即,如果没有被移除,原有的父代子代关系都应当保留)。 可以证明,存在唯一的答案 。
所以结果应当返回修剪好的二叉搜索树的新的根节点。注意,根节点可能会根据给定的边界发生改变。
示例 1:
输入:root = [1,0,2], low = 1, high = 2 输出:[1,null,2]
代码分析:
有同学做过450.题后,这题也用删除节点的逻辑来做,暗自高兴。
其实已经将本题复杂化了,注意审题,本题并非修剪的是节点,而是从符合节点开始的一整条枝干(充分利用二叉搜索树的性质),所以直接用基础递归即可。
class Solution { public: TreeNode* trimBST(TreeNode* root, int low, int high) { if(!root) return nullptr; //直接舍弃掉root->left,使当前节点覆盖为root->right经过修剪后的部分 if(root->val < low) return trimBST(root->right, low, high); //直接舍弃掉root->right,使当前节点覆盖为root->left经过修剪后的部分 if(root->val > high) return trimBST(root->left, low, high); //当前节点没问题 root->left = trimBST(root->left, low, high); root->right = trimBST(root->right, low, high); return root; } };
LeetCode108/109.将有序数组/链表转换为二叉搜索树:
问题描述:
给你一个整数数组
nums(
单链表的头节点head)
,其中元素已经按 升序 排列,请你将其转换为一棵 高度平衡 二叉搜索树。高度平衡 二叉树是一棵满足「每个节点的左右两个子树的高度差的绝对值不超过 1 」的二叉树。
代码分析:
108:
我们采用二分的思路切割出来的树结构其实已经平衡了,即左右子树的高度差绝对值不超过1。也可以通过此题回顾一下105、106、654 三题。
class Solution { public: TreeNode* sortedArrayToBST(vector<int>& nums) { return helper(nums, 0, nums.size() - 1); } TreeNode *helper(vector<int> & nums, int left, int right) { if(left > right) return nullptr; int mid = (left + right) / 2; TreeNode *root = new TreeNode(nums[mid]); if(left == right) return root; root->left = helper(nums, left, mid - 1); root->right = helper(nums, mid + 1, right); return root; } };
109:
将数组换成链表,做法完全一样,不过链表无法像数组一样直接索引到中间元素,链表找中间节点可以用快慢指针法,详见 876. 链表的中间结点。并且我们要设置为[left, right)区间!
- 由于题目中给定的链表为单向链表,访问后继元素十分容易,但无法直接访问前驱元素。因此在找出链表的中位数节点 mid 之后,如果设定「左闭右开」的关系,我们就可以直接用 [left,mid) 以及 [mid->next,right) 来表示左右子树对应的列表了。并且,初始的列表也可以用 [head,null) 方便地进行表示。
class Solution { public: ListNode* getMedian(ListNode* left, ListNode* right) { ListNode* fast = left; ListNode* slow = left; while (fast != right && fast->next != right) { fast = fast->next; fast = fast->next; slow = slow->next; } return slow; } TreeNode* buildTree(ListNode* left, ListNode* right) { if (left == right) { return nullptr; } ListNode* mid = getMedian(left, right); TreeNode* root = new TreeNode(mid->val); root->left = buildTree(left, mid); root->right = buildTree(mid->next, right); return root; } TreeNode* sortedListToBST(ListNode* head) { return buildTree(head, nullptr); } };
上述方法的时间复杂度的瓶颈在于寻找中位数节点。由于构造出的二叉搜索树的中序遍历结果就是链表本身,因此我们可以将分治和中序遍历结合起来,减少时间复杂度。
具体地,设当前链表的左端点编号为 left,右端点编号为 right。 包含关系为「双闭」,即 left 和 right 均包含在链表中。 链表节点的编号为 [0,n)。中序遍历的顺序是「左子树 - 根节点 - 右子树」。 那么在分治的过程中,我们不用急着找出链表的中位数节点,而是使用一个占位节点。 等到中序遍历到该节点时,再填充它的值。
- 中位数节点对应的编号为 mid=(left+right+1)/2;( (left+right)/2也可以 )
- 左右子树对应的编号范围分别为 [left,mid−1] 和 [mid+1,right]。
- 如果 left>right,那么遍历到的位置对应着一个空节点,否则对应着二叉搜索树中的一个节点。
这样一来,我们其实已经知道了这棵二叉搜索树的结构,并且题目给定了它的中序遍历结果,那么我们只要对其进行中序遍历,就可以还原出整棵二叉搜索树了。(注意使用了指针的引用)
class Solution { public: int getLength(ListNode* head) { int ret = 0; for (; head != nullptr; ++ret, head = head->next); return ret; } TreeNode* buildTree(ListNode*& head, int left, int right) { if (left > right) { return nullptr; } int mid = (left + right + 1) / 2; TreeNode* root = new TreeNode(); root->left = buildTree(head, left, mid - 1); root->val = head->val; head = head->next; root->right = buildTree(head, mid + 1, right); return root; } TreeNode* sortedListToBST(ListNode* head) { int length = getLength(head); return buildTree(head, 0, length - 1); } }; 作者:力扣官解
LeetCode74/240.搜索二维矩阵I/II:
问题描述:
编写一个高效的算法来判断
m x n
矩阵中,是否存在一个目标值。该矩阵具有如下特性:
- 每行中的整数从左到右按升序排列。
- 每行的第一个整数大于前一行的最后一个整数。
代码分析:
74.二分:
由于二维矩阵固定列的「从上到下」或者固定行的「从左到右」都是升序的。
因此我们可以使用两次二分来定位到目标位置:
- 第一次二分:从第 0 列中的「所有行」开始找,找到合适的行
row
- 第二次二分:从
row
中「所有列」开始找,找到合适的列col
class Solution { public boolean searchMatrix(int[][] mat, int t) { int m = mat.length, n = mat[0].length; // 第一次二分:定位到所在行(从上往下,找到最后一个满足 mat[x]][0] <= t 的行号) int l = 0, r = m - 1; while (l < r) { int mid = l + r + 1 >> 1; if (mat[mid][0] <= t) { l = mid; } else { r = mid - 1; } } int row = r; if (mat[row][0] == t) return true; if (mat[row][0] > t) return false; // 第二次二分:从所在行中定位到列(从左到右,找到最后一个满足 mat[row][x] <= t 的列号) l = 0; r = n - 1; while (l < r) { int mid = l + r + 1 >> 1; if (mat[row][mid] <= t) { l = mid; } else { r = mid - 1; } } int col = r; return mat[row][col] == t; } } 作者:宫水三叶
时间复杂度:O(logm+logn)。
当然,因为将二维矩阵的行尾和行首连接,也具有单调性。我们可以将「二维矩阵」当做「一维矩阵」来做。
class Solution { public boolean searchMatrix(int[][] mat, int t) { int m = mat.length, n = mat[0].length; int l = 0, r = m * n - 1; while (l < r) { int mid = l + r + 1 >> 1; if (mat[mid / n][mid % n] <= t) { l = mid; } else { r = mid - 1; } } return mat[r / n][r % n] == t; } } 作者:宫水三叶
时间复杂度:O(log(m∗n))
74.BST:
我们可以将二维矩阵抽象成「以右上角为根的 BST」:
那么我们可以从根(右上角)开始搜索,如果当前的节点不等于目标值,可以按照树的搜索顺序进行:
- 当前节点「大于」目标值,搜索当前节点的「左子树」,也就是当前矩阵位置的「左方格子」,即 y--
- 当前节点「小于」目标值,搜索当前节点的「右子树」,也就是当前矩阵位置的「下方格子」,即 x++
class Solution { public: bool searchMatrix(vector<vector<int>>& matrix, int target) { int row = matrix.size(), col = matrix[0].size(); // 右上角开始查找 for(int i = 0, j = col-1; i < row && j >= 0;) { if(matrix[i][j] == target) return true; else if(matrix[i][j] > target) j--; else if(matrix[i][j] < target) i++; } return false; } };
时间复杂度:O(m+n)
240.二分:
本题没有确保「每行的第一个整数大于前一行的最后一个整数」,因此我们无法采取「两次二分」的做法。只能退而求之,遍历行/列,然后再对列/行进行二分。
class Solution { public boolean searchMatrix(int[][] matrix, int target) { int m = matrix.length, n = matrix[0].length; for (int i = 0; i < m; i++) { int l = 0, r = n - 1; while (l < r) { int mid = l + r + 1 >> 1; if (matrix[i][mid] <= target) l = mid; else r = mid - 1; } if (matrix[i][r] == target) return true; } return false; } }
时间复杂度:O(m∗logn) 或 O(n∗logm)
240.BST:
该解法与上一题一致。
class Solution { public boolean searchMatrix(int[][] matrix, int target) { int m = matrix.length, n = matrix[0].length; int r = 0, c = n - 1; while (r < m && c >= 0) { if (matrix[r][c] < target) r++; else if (matrix[r][c] > target) c--; else return true; } return false; } }
LeetCode96/95.不同的二叉搜索树I/II(难):
问题描述:
给你一个整数
n
,求恰由n
个节点组成且节点值从1
到n
互不相同的 二叉搜索树 有多少种?返回满足题意的二叉搜索树的种数。96题在95题的基础上,需要返回所有符合条件的BST集合。
代码分析:
96:
给定一个有序序列 1⋯n,为了构建出一棵二叉搜索树,我们可以遍历每个数字 i,将该数字作为树根,将 1⋯(i−1) 序列作为左子树,将 (i+1)⋯n 序列作为右子树。接着我们可以按照同样的方式递归构建左子树和右子树。
在上述构建的过程中,由于根的值不同,因此我们能保证每棵二叉搜索树是唯一的。
由此可见,原问题可以分解成规模较小的两个子问题,且子问题的解可以复用。因此,我们可以想到使用动态规划来求解本题。
取其中任意一个数i作为根,取这个数左边的数[0,i-1]构建左子树,取这个数右边的数[i+1,n]构建右子树 此时左子树本身可能有m种构建方式,右子树有n种构建方式,所以取i作为根的二叉搜索树的种类为m*n 对于整个序列1-n来说,根有1-n种可能,所以整个序列构建二叉搜索树的所有种类数量为分别取1-n的二叉搜索树的种类数量的和
代码归纳: 假设序列为 [1-n] 记k[i]为根取第i个数时的二叉搜索树的种类 记dp[n]为n个数构成的二叉树的种类(种类只和数量有关,例如 【1-5】5个数和【6-10】5个数构建的二叉搜索树种类是一样的 ) 那么k[i]=左侧i-1个数的二叉树的种类dp[i-1]*右侧n-i个数的二叉树的种类dp[n-i] 即k[i] = dp[i-1]*dp[n-i] (挑选i作为根,剩余左侧和右侧的子树种类乘积) 所以dp[n] = k[1] + k[2] + ... + k[n-1] + k[n] = dp[0]*dp[n-1] + dp[1]*dp[n-2]...... + dp[n-2]*dp[1] + dp[n-1]*dp[0] (解释:选第1个数时,左侧数为0个,右侧数为n-1个;选第n个数时,左侧数为n-1个,右侧数为0个) 特殊边界:dp[0]为0个数构成的二叉搜索树种类为1,因为null可以认为是一颗空的二叉搜索树
class Solution { public: int numTrees(int n) { vector<int> dp(n + 1, 0); dp[0] = 1; dp[1] = 1; for (int i = 2; i <= n; ++i) { for (int j = 1; j <= i; ++j) { dp[i] += dp[j - 1] * dp[i - j]; } } return dp[n]; } };
事实上我们在以上推导出的 dp(n) 函数的值在数学上被称为卡塔兰数 Cn 。卡塔兰数更便于计算的定义如下:
95:
找了个通俗易懂的解释 Krains 。
在之前做过的题目中,构建一颗二叉搜索树很简单,只需要选择一个根结点,然后递归去构建其左右子树(还记得前面的二分法构建平衡的二叉搜索树吗)。
要构建多颗二叉树,问题就在于如何选择不同的根节点,以构建不同的树和子树,如下:
// 选择所有可能的根结点 for(int i = start; i <= end; i++){ TreeNode root = new TreeNode(i); ... } 作者:Krains
我们需要的是多颗树,我们可以将不同的根结点装入
List
然后返回,如下:public List<TreeNode> helper(int start, int end){ List<TreeNode> list = new ArrayList<>(); if(start > end){ list.add(null); return list; } for(int i = start; i <= end; i++){ TreeNode root = new TreeNode(i); ... ... // 装入所有根结点 list.add(root); } return list; } 作者:Krains
很显然,现在问题变成了如何构建 root 的左右子树,我们抛开复杂的递归函数,只关心递归的返回值,每次选择根结点 root ,我们
- 递归构建左子树,并拿到左子树所有可能的根结点列表left
- 递归构建右子树,并拿到右子树所有可能的根结点列表right
这个时候我们有了左右子树列表,我们的左右子树都是各不相同的,因为根结点不同,我们如何通过左右子树列表构建出所有的以 root 为根的树呢?
我们固定一个左孩子,遍历右子树列表,那么以当前为
root
根结点的树个数就为left.size() * right.size()
个。//代码每一步的写法都很巧妙需要仔细品味!!! class Solution { public List<TreeNode> generateTrees(int n) { if(n < 1) return new ArrayList<>(); return helper(1, n); } public List<TreeNode> helper(int start, int end){ // list 存的是只是当前深度下所有可能的子树的根节点的排列组合情况,需要return上去变成left或者right,所以需要覆盖。 List<TreeNode> list = new ArrayList<>(); if(start > end){ // 如果当前子树为空,不加null行吗? list.add(null); return list; } for(int i = start; i <= end; i++){ // 想想为什么这行不能放在这里,而放在下面? // TreeNode root = new TreeNode(i); List<TreeNode> left = helper(start, i-1); List<TreeNode> right = helper(i+1, end); // 固定左孩子,遍历右孩子 for(TreeNode l : left){ for(TreeNode r : right){ TreeNode root = new TreeNode(i); root.left = l; root.right = r; list.add(root); } } } return list; } } 作者:Krains
- 关于TreeNode root = new TreeNode(i)的放置的位置问题。如果这行代码放置在注释的地方,会造成一个问题,就是以当前为root根结点的树个数就 num = left.size() * right.size() > 1时,num棵子树会共用这个root结点,在下面两层for循环中,root的左右子树一直在更新,如果每次不新建一个root,就会导致num个root为根节点的树都相同。
- 关于如果当前子树为空,不加null行不行的问题。显然,如果一颗树的左子树为空,右子树不为空,要正确构建所有树,依赖于对左右子树列表的遍历,也就是上述代码两层for循环的地方,如果其中一个列表为空,那么循环都将无法进行。
LeetCode230.二叉搜索树中第K小的元素:
问题描述:
给定一个二叉搜索树的根节点
root
,和一个整数k
,请你设计一个算法查找其中第k
个最小元素(从 1 开始计数)。
代码分析:
朴素做法:
/*无返回值的dfs*/ class Solution { public: int res, now = 0; void dfs(TreeNode *root, int k) { 即使中途找到了符合要求的节点,也会一直把树递归遍历完 if(!root) return; dfs(root->left, k); now++; if(now == k) res = root->val; dfs(root->right, k); } int kthSmallest(TreeNode* root, int k) { dfs(root, k); return res; } };
/*有返回值的dfs*/ class Solution { public: int now = 0; int dfs(TreeNode *root, int k) { 可以理解为宏观上分为三部分 执行完dfs函数则认为计算出了结果。宏观左/右子树如果不符合要求则一定返回的是-1 if(!root) return -1; int res = dfs(root->left, k); if(res != -1) return res; if(++now == k) return root->val; return dfs(root->right, k); } int kthSmallest(TreeNode* root, int k) { return dfs(root, k); } };
上面第二种写法在效率上优于第一种写法。当然,如果想要达到一旦找到就立即返回出来的效果,推荐用迭代法。
试想一下,如果本题换成一棵普通的树,如何求解第K小呢?
第一种普通树的遍历+排序结果数组;第二种在遍历过程中维护优先队列(大根堆)。
我们来详解一下第二种方法:
由于我们返回的是第 k 小的数,因此我们可以构建一个容量为 k 的大根堆。(遍历可用BFS或DFS)
- 大根堆元素不足 k 个:直接将当前节点值放入大根堆;
- 大根堆元素为 k 个。如果当前节点值元素大于堆顶元素,说明当前节点值不可能在第 k 小的范围内,直接丢弃;
- 大根堆元素为 k 个。如果当前节点值元素小于堆顶元素,说明当前节点值可能在第 k 小的范围内,先
poll
一个再add
进去。class Solution { public int kthSmallest(TreeNode root, int k) { PriorityQueue<Integer> q = new PriorityQueue<>((a,b)->b-a); Deque<TreeNode> d = new ArrayDeque<>(); d.addLast(root); while (!d.isEmpty()) { TreeNode node = d.pollFirst(); if (q.size() < k) { q.add(node.val); } else if (q.peek() > node.val) { q.poll(); q.add(node.val); } if (node.left != null) d.addLast(node.left); if (node.right != null) d.addLast(node.right); } return q.peek(); } } 作者:宫水三叶
如果需要频繁查找第K小的值,如何优化算法?
在上述方法中,我们之所以需要中序遍历前 k 个元素,是因为我们不知道子树的结点数量,不得不通过遍历子树的方式来获知。
因此,我们可以记录下以每个结点为根结点的子树的结点数,并在查找第 k 小的值时,使用如下方法搜索:
- 令 node 等于根结点,开始搜索。
- 对当前结点 node 进行如下操作:如果 node 的左子树的结点数 left 小于 k−1,则第 k 小的元素一定在 node 的右子树中,令 node 等于其的右子结点,k 等于 k−left−1,并继续搜索;
- 如果 node 的左子树的结点数 left 等于 k−1,则第 k 小的元素即为 node ,结束搜索并返回 node 即可;
- 如果 node 的左子树的结点数 left 大于 k−1,则第 k 小的元素一定在 node 的左子树中,令 node 等于其左子结点,并继续搜索。
在实现中,我们既可以将以每个结点为根结点的子树的结点数存储在结点中,也可以将其记录在哈希表中。
class Solution { public int kthSmallest(TreeNode root, int k) { MyBst bst = new MyBst(root); return bst.kthSmallest(k); } } class MyBst { TreeNode root; Map<TreeNode, Integer> nodeNum; public MyBst(TreeNode root) { this.root = root; this.nodeNum = new HashMap<TreeNode, Integer>(); countNodeNum(root); } // 返回二叉搜索树中第k小的元素 public int kthSmallest(int k) { TreeNode node = root; while (node != null) { int left = getNodeNum(node.left); if (left < k - 1) { node = node.right; k -= left + 1; } else if (left == k - 1) { break; } else { node = node.left; } } return node.val; } // 统计以node为根结点的子树的结点数 该有返回值的递归函数最外层返回值没有用处,但对内部的递归有用处 private int countNodeNum(TreeNode node) { if (node == null) { return 0; } nodeNum.put(node, 1 + countNodeNum(node.left) + countNodeNum(node.right)); return nodeNum.get(node); } // 获取以node为根结点的子树的结点数 private int getNodeNum(TreeNode node) { return nodeNum.getOrDefault(node, 0); } } 作者:力扣官解
时间复杂度:预处理的时间复杂度为 O(N),其中 N 是树中结点的总数;我们需要遍历树中所有结点来统计以每个结点为根结点的子树的结点数;搜索的时间复杂度为 O(H),其中 H 是树的高度。当树是平衡树时,时间复杂度取得最小值 O(logN),当树是线性树时,时间复杂度取得最大值 O(N)。
如果二叉搜索树经常被修改(插入/删除操作)并且需要频繁地查找第 k 小的值,将如何优化算法?
我们注意到在方法二中搜索二叉搜索树的时间复杂度为 O(H),其中 H 是树的高度;当树是平衡树时,时间复杂度取得最小值 O(logN)。因此,我们在记录子树的结点数的基础上,将二叉搜索树转换为平衡二叉搜索树,并在插入和删除操作中维护它的平衡状态。
当然手撕平衡二叉树的部分如果不是特别紧急的情况下,理解的优先级可以往后放一放。
class AVLTree{ TreeNode* root; unordered_map<TreeNode*,int>check_height; public: AVLTree(TreeNode* root):root(root){ update_height(root); root = convToAVL(root); } public: //直接转AVL,转之前先记录好每一颗树的高度,由于不再好重新设计数据的结点结构,所以直接用哈希表记录每棵树的高度 int update_height(TreeNode* root){ if(root==nullptr) return 0; int Llen = update_height(root->left)+1; int Rlen = update_height(root->right)+1; return check_height[root] = Llen>Rlen?Llen:Rlen; } int get_height(TreeNode* root){ if(root==nullptr) return 0; return check_height[root]; } void update(TreeNode* root){ check_height[root] = get_height(root->left)>get_height(root->right)?get_height(root->left):get_height(root->right); } TreeNode* rotateLeft(TreeNode* root){ TreeNode* son = root->right; root->right = son->left; son->left = root; update(root); update(son); return son; } TreeNode* rotateRight(TreeNode* root){ TreeNode* son = root->left; root->left = son->right; son->right = root; update(root); update(son); return son; } TreeNode* rotateLeftRight(TreeNode* root){ root->left = rotateLeft(root->left); return rotateLeft(root); } TreeNode* rotateRightLeft(TreeNode* root){ root->right = rotateRight(root->right); return rotateLeft(root); } TreeNode* convToAVL(TreeNode* root){ if(root==nullptr) return 0; root->left = convToAVL(root->left); root->right = convToAVL(root->right); if(get_height(root->left)-get_height(root->right)==2){ root = get_height(root->left->left)>get_height(root->left->right)?rotateRight(root):rotateLeftRight(root); }else if(get_height(root->left)-get_height(root->right)==-2){ root = get_height(root->right->right)>get_height(root->right->left)?rotateLeft(root):rotateRightLeft(root); } update(root); return root; } int kth(int k){ stack<TreeNode *> stack; while (root != nullptr || stack.size() > 0) { while (root != nullptr) { stack.push(root); root = root->left; } root = stack.top(); stack.pop(); --k; if (k == 0) { break; } root = root->right; } return root->val; } }; class Solution { public: int kthSmallest(TreeNode* root, int k) { AVLTree s(root); return s.kth(k); } };
LeetCode297/449.二叉树/搜索树的序列化与反序列化:
问题描述:
请设计一个算法来实现二叉树(搜索树)的序列化与反序列化。这里不限定你的序列 / 反序列化算法执行逻辑,你只需要保证一个二叉树可以被序列化为一个字符串并且将这个字符串反序列化为原始的树结构。
代码分析:
297:
这里,我们选择先序遍历的编码方式,我们可以通过这样一个例子简单理解:
None,是用来标记缺少左/右子节点,这就是我们在序列化期间保存树结构的方式。
那么我们如何反序列化呢?首先我们需要根据
","
把原先的序列分割开来得到先序遍历的元素列表,然后从左向右遍历这个序列:
- 如果当前的元素为
None
,则当前为空树- 否则先解析这棵树的左子树,再解析它的右子树
class Codec { public: //序列化辅助函数 void rserialize(TreeNode* root, string& str) { if (root == nullptr) { str += "None,"; } else { str += to_string(root->val) + ","; rserialize(root->left, str); rserialize(root->right, str); } } //序列化主函数 string serialize(TreeNode* root) { string ret; rserialize(root, ret); return ret; } //反序列化辅助函数 TreeNode* rdeserialize(list<string>& dataArray) { if (dataArray.front() == "None") { dataArray.erase(dataArray.begin()); return nullptr; } TreeNode* root = new TreeNode(stoi(dataArray.front())); dataArray.erase(dataArray.begin()); root->left = rdeserialize(dataArray); root->right = rdeserialize(dataArray); return root; } //反序列化主函数 TreeNode* deserialize(string data) { //底层为双向链表 list<string> dataArray; string str; for (auto& ch : data) { if (ch == ',') { //将,之前的内容存入list,清空准备存储下一个 dataArray.push_back(str); str.clear(); } else { str.push_back(ch); } } if (!str.empty()) { dataArray.push_back(str); str.clear(); } return rdeserialize(dataArray); } }; 作者:力扣官解
注意:dataArray的类型不能换成vector!vector是数组,连续存储的,每次删除开头都是n的复杂度。list是链表,链表增删都是常数,其实用队列queue也行。
当然,还有一个特别值得注意的地方,一定有很多人有如下疑惑:
解答如下:
还有一个题干理解方面的小问题,有人发现官方示例用的是层序遍历表示树,而我们用的是前序(中后也可),解答如下:
还有一个书写方面的小细节:
449:
二叉树和二叉搜索树做这道题的区别在于,二叉树需要额外的字符来存储空节点才能在解析字符串时确定树的结构,而二叉搜索树可以利用其性质来判断是否到达空节点,所以不需要在字符串中存储空节点,也就是题目中说的“编码的字符串应尽可能紧凑”。
在这里只重点讲一下反序列化的过程(我们还是采用前序遍历):
- 前序遍历得到的数组的第一个值就是 BST 的根节点
- 数组后面的这些数中比根节点的值小的是根节点的左子树,比根节点值大的是根节点的右子树
- 递归就可以反序列化出原本的 BST
class Codec { public: void preOrder(TreeNode* root, vector<int>& res) { if (!root) return; res.push_back(root->val); preOrder(root->left, res); preOrder(root->right, res); } string vector2string(vector<int>& vals) { string res; if (vals.empty()) return res; for (int i = 0; i < vals.size() - 1; ++i) { res += to_string(vals[i]) + ","; } res += to_string(vals[vals.size() - 1]); return res; } vector<int> split(string& s) { vector<int> res; size_t pos = 0; std::string token; while ((pos = s.find(",")) != std::string::npos) { token = s.substr(0, pos); res.push_back(stoi(token)); s.erase(0, pos + 1); } res.push_back(stoi(s)); return res; } // Encodes a tree to a single string. string serialize(TreeNode* root) { vector<int> vals; preOrder(root, vals); return vector2string(vals); } // Decodes your encoded data to tree. TreeNode* deserialize(string data) { if (data.empty()) return nullptr; vector<int> vals = split(data); TreeNode* root = new TreeNode(vals[0]); vector<int> leftVals; vector<int> rightVals; for (int val : vals) { if (val < vals[0]) { leftVals.push_back(val); } else if (val > vals[0]) { rightVals.push_back(val); } } root->left = deserialize(vector2string(leftVals)); root->right = deserialize(vector2string(rightVals)); return root; } }; // Your Codec object will be instantiated and called as such: // Codec* ser = new Codec(); // Codec* deser = new Codec(); // string tree = ser->serialize(root); // TreeNode* ans = deser->deserialize(tree); // return ans; 作者:负雪明烛
LeetCode662.二叉树最大宽度:
问题描述:
给你一棵二叉树的根节点
root
,返回树的 最大宽度 。树的 最大宽度 是所有层中最大的 宽度 。
每一层的 宽度 被定义为该层最左和最右的非空节点(即,两个端点)之间的长度。将这个二叉树视作与满二叉树结构相同,两端点间会出现一些延伸到这一层的
null
节点,这些null
节点也计入长度。题目数据保证答案将会在 32 位 带符号整数范围内。
示例 1:
输入:root = [1,3,2,5,3,null,9] 输出:4 解释:最大宽度出现在树的第 3 层,宽度为 4 (5,3,null,9) 。
代码分析:
字节原题,第一眼看上去是一道普通的BFS题,但框架搭建好之后,你会发现实现的细节特别多还有空节点处理,宽度的表示等等,稍不留神就会陷入到依托答辩的琐碎中去,如下所示:
class Solution { public: int widthOfBinaryTree(TreeNode* root) { int count = 0, num1 = 0, num2 = 0; bool flag = false, stop = true; stack<TreeNode *> stk; //栈的遍历不如vector方便,也是制约本解法的因素 int ans = INT_MAX; stk.push(root); while(stop) { int n = stk.size(); stop = false; count = 0; num1 = num2 = 0; 采取的算法是多源BFS,在本轮更新当前栈中上一层元素的同时计算上一层的最大宽度。这样可谓是很紧凑,但是编码难度就上升了。 所以有时候选择比努力更重要 for(int i = 0; i <= n - 1; ++i) { TreeNode *node = stk.top(); ++count; if(node == nullptr) {stk.push(nullptr);stk.push(nullptr);stk.pop();continue;} stop = true; if(flag == true) num2 = count; else num1 = count; flag = true; if(num1 == 0 || num2 == 0) ans = max(ans, 1); else ans = max(ans, num2 - num1 + 1); if(node->left) stk.push(node->left); if(!node->left) stk.push(nullptr); if(node->right) stk.push(node->right); if(!node->right) stk.push(nullptr); stk.pop(); } } //循环结束还要再来一次 count = 0; num1 = num2 = 0; int n = stk.size(); for(int i = 0; i <= n - 1; ++i) { TreeNode *node = stk.top(); ++count; if(node == nullptr) continue; if(flag == true) num2 = count; else num1 = count; flag = true; if(num1 == 0 || num2 == 0) ans = max(ans, 1); else ans = max(ans, num2 - num1 + 1); } return ans; } };
你可能想得跟我的解法类似:
根据题意,要统计树的最大宽度是所有层中最大的宽度。那么,既然涉及到要对每一层的树节点进行操作,我们会很自然的想到要是用广度优先遍历。但是,如果采用广度优先算法,我们遇到的一个麻烦就是,如何去处理空节点?类似于,一个节点它只有一个子节点。这种空节点也是需要被统计在内的。所以,首先考虑的一个办法是,采用构建一个空的虚拟节点,由于题目中提示:-100 <= Node.val <= 100,所以我们可以指定虚拟节点的val值为-101,即:如果发现没有左子节点或者右子节点的话,我们就创建一个new TreeNode(-101, null, null)。这样,就可以构建一个全都有字节点的二叉树了。
按照上面这样的话,由于没有子节点就创建空的虚拟节点,如果不添加某个判断条件,这种构建空节点的操作将会无限的创建下去。那么我们可以通过判断某一层的节点值是否有非-101的,如果节点的val值都是-101,则说明这一层都是空节点,结束循环操作。如下图所示:
构建虚拟的空节点虽然可以满足题目的计算逻辑,但是,由于要大量的创建空的虚拟节点,而且越到层级越深且该层级真是节点越少,那么创建的空节点将会非常的多,那么在提交的时候,就会造成超出内存限制的问题。
让我们来看应该如何正确选择吧:
法一:编号
因为两端点间的 null 节点也需要计入宽度,因此可以对节点进行编号。一个编号为 index 的左子节点的编号记为 2×index,右子节点的编号记为 2×index+1,计算每层宽度时,用每层节点的最大编号减去最小编号再加 1 即为宽度。
class Solution { public: int widthOfBinaryTree(TreeNode* root) { unsigned long long res = 1; vector<pair<TreeNode *, unsigned long long> > arr; //二元组形式 arr.emplace_back(root, 1L); while(!arr.empty()) { //将当前层的节点拓展出的下一层节点暂存起来留到下一轮循环再计算结果 vector<pair<TreeNode *, unsigned long long> > tmp; for(auto &[node, index] : arr) { if(node->left) tmp.emplace_back(node->left, index * 2); if(node->right) tmp.emplace_back(node->right, index * 2 + 1); } res = max(res, arr.back().second - arr[0].second + 1); arr = move(tmp); } return res; } };
注意二元组的写法和auto &[a, b] : c的语法即可。我们发现宽度的表示若合理就是降维打击。
法二:
仍然按照上述方法编号,可以用深度优先搜索来遍历。遍历时如果是先访问左子节点,再访问右子节点,每一层最先访问到的节点会是最左边的节点,即每一层编号的最小值,需要记录下来进行后续的比较。一次深度优先搜索中,需要当前节点到当前行最左边节点的宽度,以及对子节点进行深度优先搜索,求出最大宽度,并返回最大宽度。
using ULL = unsigned long long; class Solution { public: int widthOfBinaryTree(TreeNode* root) { unordered_map<int, ULL> levelMin; //函数式表达式std :: function是一个通用的多态函数包装器。 std :: function的实例可以存储,复制和调用任何可调用的目标 :包括函数,lambda表达式,绑定表达式或其他函数对象,以及指向成员函数和指向数据成员的指针。当std::function对象未包裹任何实际的可调用元素,调用该std::function对象将抛出std::bad_function_call异常。 function<ULL(TreeNode*, int, ULL)> dfs = [&](TreeNode* node, int depth, ULL index)->ULL { if (node == nullptr) { return 0LL; } //当前depth处还没被搜索到时对应的levelMin为空 if (!levelMin.count(depth)) { levelMin[depth] = index; // 每一层最先访问到的节点会是最左边的节点,即每一层编号的最小值 } return max({index - levelMin[depth] + 1LL, dfs(node->left, depth + 1, index * 2), dfs(node->right, depth + 1, index * 2 + 1)}); }; return dfs(root, 1, 1LL); } }; 作者:力扣官解
class Solution { Map<Integer, Integer> levelMin = new HashMap<Integer, Integer>(); public int widthOfBinaryTree(TreeNode root) { return dfs(root, 1, 1); } public int dfs(TreeNode node, int depth, int index) { if (node == null) { return 0; } levelMin.putIfAbsent(depth, index); // 每一层最先访问到的节点会是最左边的节点,即每一层编号的最小值 return Math.max(index - levelMin.get(depth) + 1, Math.max(dfs(node.left, depth + 1, index * 2), dfs(node.right, depth + 1, index * 2 + 1))); } } 作者:力扣官解
LeetCode987.二叉树的垂序遍历(难):
问题描述:
给你二叉树的根结点
root
,请你设计算法计算二叉树的 垂序遍历 序列。对位于
(row, col)
的每个结点而言,其左右子结点分别位于(row + 1, col - 1)
和(row + 1, col + 1)
。树的根结点位于(0, 0)
。二叉树的 垂序遍历 从最左边的列开始直到最右边的列结束,按列索引每一列上的所有结点,形成一个按出现位置从上到下排序的有序列表。如果同行同列上有多个结点,则按结点的值从小到大进行排序。
返回二叉树的 垂序遍历 序列。
示例 1:
输入:root = [3,9,20,null,null,15,7] 输出:[[9],[3,15],[20],[7]] 解释: 列 -1 :只有结点 9 在此列中。 列 0 :只有结点 3 和 15 在此列中,按从上到下顺序。 列 1 :只有结点 20 在此列中。 列 2 :只有结点 7 在此列中。
代码分析:
还是说选择大于努力,读题严谨也很重要。否则会写出以下代码:
class Solution { public: int count1 = 0, count2 = 0; void dfs(TreeNode *root, vector<vector<int> > &v, int cur) { //注意偏移量,前序遍历 v[cur + count1 - 1].emplace_back(root->val); //仅凭cur是无法完成题目要求的排序的 dfs(root->left, v, cur - 1); dfs(root->right, v, cur + 1); } vector<vector<int>> verticalTraversal(TreeNode* root) { TreeNode *node1 = root, *node2 = root; //分别统计根节点左右两边的列数 while(node1) { node1 = node1->left; ++count1; } while(node2) { node2 = node2->right; ++count2; } //每列的所有元素都放在一起 vector<vector<int> > v(count1 + count2 - 1); v.clear(); dfs(root, v, 0); //想当然了,误认为只要简单排序就能完成 for(int i = 0; i <= count1 + count2 - 2; ++i) { sort(v[i].begin(), v[i].end()); } return v; } };
代码框架看起来很对,即使最后调试通过,逻辑也是错误的。此时需要及时止损,学习正确的思路和方法。
根据题意,我们需要按照优先级「“列号从小到大”,对于同列节点,“行号从小到大”,对于同列同行元素,“节点值从小到大”」进行答案构造。
按照上述错误解法,普通的解法和sort只能满足列号从小到大和同行同列节点值从大到小。而行号从小到大无法满足!
法一:DFS+哈希表+排序
我们可以对树进行遍历,遍历过程中记下这些信息 (col,row,val)(顺序对应了上面的优先级),然后根据规则进行排序,并构造答案。
我们可以先使用「哈希表」进行存储,最后再进行一次性的排序。
class Solution { Map<TreeNode, int[]> map = new HashMap<>(); //存放节点及其三要素 public List<List<Integer>> verticalTraversal(TreeNode root) { map.put(root, new int[]{0, 0, root.val}); dfs(root); //单独获取节点的三要素集合 List<int[]> list = new ArrayList<>(map.values()); //第二个参数为lamda表达式 Collections.sort(list, (a, b)->{ //按照优先级来排序 if(a[0] != b[0]) return a[0] - b[0]; if(a[1] != b[1]) return a[1] - b[1]; return a[2] - b[2]; }); //虽然节点的优先级排好了,但是并不符合垂序遍历的分组! int n = list.size(); List<List<Integer> > ans = new ArrayList<>(); //尽可能地尝试每个节点 for(int i = 0; i < n; ) { //寻找与当前节点i共用相同列号的连续相邻节点j,将其归为一组 //i直接跳到j表示忽略掉的节点位置连续且相同,不用再尝试了 int j = i; List<Integer> tmp = new ArrayList<>(); while(j < n && list.get(j)[0] == list.get(i)[0]) tmp.add(list.get(j++)[2]); ans.add(tmp); i = j; } return ans; } void dfs(TreeNode root) { //先序遍历同时在map中存储每个节点及其对应的三要素 if(root == null) return; int[] info = map.get(root); int col = info[0], row = info[1], val = info[2]; if(root.left != null) { map.put(root.left, new int[]{col - 1, row + 1, root.left.val}); dfs(root.left); } if(root.right != null) { map.put(root.right, new int[]{col + 1, row + 1, root.right.val}); dfs(root.right); } } }
时间复杂度:令总节点数量为 n,填充哈希表时进行树的遍历,复杂度为 O(n);构造答案时需要进行排序,复杂度为 O(nlogn)。整体复杂度为 O(nlogn)。
有一定的书写和理解难度。
其中Collections.sort()的第二个参数除了用lambda表达式,还可以用Comparator比较器!代码如下:
class Solution { public List<List<Integer>> verticalTraversal(TreeNode root) { List<int[]> nodes = new ArrayList<int[]>(); //nodes可以理解为类似于上面一种写法中的哈希表用来存储三要素,需要在前序遍历的过程中被更新 dfs(root, 0, 0, nodes); Collections.sort(nodes, new Comparator<int[]>() { 注意匿名内部类的使用 public int compare(int[] tuple1, int[] tuple2) { if (tuple1[0] != tuple2[0]) { return tuple1[0] - tuple2[0]; } else if (tuple1[1] != tuple2[1]) { return tuple1[1] - tuple2[1]; } else { return tuple1[2] - tuple2[2]; } } }); List<List<Integer>> ans = new ArrayList<List<Integer>>(); int size = 0; int lastcol = Integer.MIN_VALUE; for (int[] tuple : nodes) { int col = tuple[0], row = tuple[1], value = tuple[2]; if (col != lastcol) { lastcol = col; ans.add(new ArrayList<Integer>()); size++; } ans.get(size - 1).add(value); } return ans; } public void dfs(TreeNode node, int row, int col, List<int[]> nodes) { if (node == null) { return; } nodes.add(new int[]{col, row, node.val}); dfs(node.left, row + 1, col - 1, nodes); dfs(node.right, row + 1, col + 1, nodes); } } 作者:力扣官解
我们再来欣赏一下C++版的写法,注意语法细节和比照与662题二元组的区别!C++中提供了三元组tuple<>()!
class Solution { public: vector<vector<int>> verticalTraversal(TreeNode* root) { //在遍历完成后,我们按照 col\textit{col}col 为第一关键字升序,row\textit{row}row 为第二关键字升序,value\textit{value}value 为第三关键字升序,对所有的节点进行排序即可。 vector<tuple<int, int, int>> nodes; function<void(TreeNode*, int, int)> dfs = [&](TreeNode* node, int row, int col) { if (!node) { return; } nodes.emplace_back(col, row, node->val); dfs(node->left, row + 1, col - 1); dfs(node->right, row + 1, col + 1); }; dfs(root, 0, 0); 直接对三元组排序是可行的 sort(nodes.begin(), nodes.end()); vector<vector<int>> ans; int lastcol = INT_MIN; //我们可以对 nodes\textit{nodes}nodes 进行一次遍历,并在遍历的过程中记录上一个节点的列号 lastcol\textit{lastcol}lastcol。如果当前遍历到的节点的列号 col\textit{col}col 与 lastcol\textit{lastcol}lastcol 相等,则将该节点放入与上一个节点相同的数组中,否则放入不同的数组中。 for (const auto& [col, row, value]: nodes) { if (col != lastcol) { lastcol = col; ans.emplace_back(); } ans.back().push_back(value); } return ans; } }; 作者:力扣官解
法二:DFS+优先队列
在法一的基础上进行改动。显然,最终要让所有节点的相应信息有序,可以使用「优先队列(堆)」边存储边维护有序性。
class Solution { PriorityQueue<int[]> q = new PriorityQueue<>((a, b)->{ //还是lamda表达式 if (a[0] != b[0]) return a[0] - b[0]; if (a[1] != b[1]) return a[1] - b[1]; return a[2] - b[2]; }); public List<List<Integer>> verticalTraversal(TreeNode root) { int[] info = new int[]{0, 0, root.val}; q.add(info); dfs(root, info); List<List<Integer>> ans = new ArrayList<>(); while (!q.isEmpty()) { List<Integer> tmp = new ArrayList<>(); int[] poll = q.peek(); while (!q.isEmpty() && q.peek()[0] == poll[0]) tmp.add(q.poll()[2]); ans.add(tmp); } return ans; } void dfs(TreeNode root, int[] fa) { if (root.left != null) { int[] linfo = new int[]{fa[0] - 1, fa[1] + 1, root.left.val}; q.add(linfo); dfs(root.left, linfo); } if (root.right != null) { int[] rinfo = new int[]{fa[0] + 1, fa[1] + 1, root.right.val}; q.add(rinfo); dfs(root.right, rinfo); } } } 作者:宫水三叶
时间复杂度:令总节点数量为 n,将节点信息存入优先队列(堆)复杂度为 O(nlogn);构造答案复杂度为 O(nlogn)。整体复杂度为 O(nlogn)。
当然,在C++中,可以为priority_queue指定greater或者less,以避免自定义比较函数或结构体。
剑指offer32.从上到下打印二叉树III:
问题描述:
请实现一个函数按照之字形顺序打印二叉树,即第一行按照从左到右的顺序打印,第二层按照从右到左的顺序打印,第三行再按照从左到右的顺序打印,其他行以此类推。
例如:
给定二叉树:[3,9,20,null,null,15,7]
,3 / \ 9 20 / \ 15 7返回其层次遍历结果:
[ [3], [20,9], [15,7] ]
代码分析:
简而言之就是层序遍历的之字打印形式,换汤不换药。
我们来做一下这道简单题来换换脑子,顺便加深一下层序遍历和多源BFS。
class Solution { public: vector<vector<int>> levelOrder(TreeNode* root) { //不管三七二十一,上来先特判再说 if(!root) { vector<vector<int> > v; return v; } //辅助判断翻转时机 int num = 0; //返回答案 vector<vector<int> > res; queue<TreeNode *> q; q.push(root); vector<int> temp; temp.emplace_back(root->val); //从左到右只有一个节点无影响 res.emplace_back(temp); while(!q.empty()) { ++num; int n = q.size(); //当前拓展结果 vector<int> vv; for(int i = 0; i < n; ++i) { TreeNode *node = q.front(); q.pop(); if(node->left) { q.push(node->left); vv.emplace_back(node->left->val); } if(node->right) { q.push(node->right); vv.emplace_back(node->right->val); } } //对当前拓展结果集进行处理 if(num % 2 != 0) { reverse(vv.begin(), vv.end()); } if(!vv.empty()) res.emplace_back(vv); } return res; } };
当然我们可以把普通的队列换成双端队列,不同的时机分别用pop_front()和pop_back()。
class Solution { public: vector<vector<int>> levelOrder(TreeNode* root) { vector<vector<int>>ans; if(!root)return ans; deque<TreeNode*> deq; deq.emplace_front(root); bool dir=1;//l to r while(!deq.empty()){ int len=deq.size(); vector<int> vec; for(int i=0;i!=len;++i){ if(dir){ root=deq.front(); deq.pop_front(); vec.emplace_back(root->val); if(root->left)deq.emplace_back(root->left); if(root->right)deq.emplace_back(root->right); } else{ root=deq.back(); deq.pop_back(); vec.emplace_back(root->val); if(root->right)deq.emplace_front(root->right); if(root->left)deq.emplace_front(root->left); } } dir=!dir; ans.emplace_back(vec); } return ans; } }; 作者:piwoyixia
不再过多解释。
面金04.06.后继者:
问题描述:
设计一个算法,找出二叉搜索树中指定节点的“下一个”节点(也即中序后继)。
如果指定节点没有对应的“下一个”节点,则返回
null
。示例 1:
输入: root =[2,1,3], p = 1 2 / \ 1 3
输出: 2
代码分析:
这题最舒适的做法肯定是中序遍历并记录当前节点的上一节点。此做法省略。
我们来看不那么容易接受的几个解法来磨一磨。
/*利用二叉搜索树的性质*/ class Solution { public: TreeNode* inorderSuccessor(TreeNode* root, TreeNode* p) { if(!root) return nullptr; //我们可以预设本函数返回的即是要求的节点 if(root->val <= p->val) return inorderSuccessor(root->right, p); //否则,要求节点可能是当前节点root也可能在root的左子树中 TreeNode *node = inorderSuccessor(root->left, p); //左子树没有就一定是root本身了 return node == nullptr ? root : node; } };
上面这种解法其思维是有违我们日常的思考方式的,所以可能比较难掌握,尽管我们之前练了许多递归题目。
我们还可以通过提供的p节点的位置来搜索下一个比它大的节点。
TreeNode *successor = nullptr; if (p->right != nullptr) { successor = p->right; while (successor->left != nullptr) { successor = successor->left; } return successor; } //但是右子树存在的情况下 TreeNode *node = root; while (node != nullptr) { if (node->val > p->val) { successor = node; node = node->left; } else { //想想此处为什么没有successor标记 node = node->right; } } return successor; 作者:力扣官解
LeetCode78/90.子集I/II:
问题描述:
78:
给你一个整数数组
nums
,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。
90:
除了数组中的元素可能重复,其余与上述题干相同。
代码分析:
78:
看到这题瞬间想起来了《啊哈算法》中的全排列问题(毕竟这本书是我的启蒙读物,我对它的执念还是很深的)。不同的是,那道题是定长度的排列组合并且数据量规模小可以用桶来标记是否重复取用,而本题所符合条件的子集长度范围是[0, nums.size()-1]并且
-10 <= nums[i] <= 10(数据强度要是很大呢)
存在负数不能用bool数组来标记
,如何选取设计判断等功能的数据结构的工作量已经不小;也不能与树专题的所有符合条件路径相提并论,该题按照一定的遍历和递归方式就能保证所有的路径不重复,但是在本题子集概念中{1, 2, 3}与{3, 2, 1}是相同的,如何去重或防止重复呢。当然看到这题比较困惑也是非常正常的,这体现了回溯这个专题的博大精深。这题属于回溯的子集问题,做之前如果能预先解决回溯的组合和分割两大块的话会比较轻松。
本专题截止2023/02/28已经连续更了40多道题目,累计字数超过100000。因不方便在如此庞大的基础上继续编辑更新文章,我们把回溯专题剩下的题目和图论专题放在一起。
我们来细品 代码随想录 的题解:
求子集问题和 77.组合 和 131.分割回文串 又不一样了。
如果把子集问题、组合问题、分割问题都抽象为一棵树的话,那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点!
其实子集也是一种组合问题,因为它的集合是无序的,子集{1,2} 和 子集{2,1}是一样的。那么既然是无序,取过的元素不会重复取,写回溯算法的时候,for就要从startIndex开始,而不是从0开始!
- 有同学问了,什么时候for可以从0开始呢?
- 求排列问题的时候,就要从0开始,因为集合是有序的,{1, 2} 和{2, 1}是两个集合,排列问题我们后续的文章就会讲到的。
以示例中nums = [1,2,3]为例把求子集抽象为树型结构,如下:
从图中红线部分,可以看出遍历这个树的时候,把所有节点都记录下来,就是要求的子集集合。
- 全局变量数组 path为子集收集元素,二维数组 result存放子集组合。(也可以放到递归函数参数里)递归函数参数在上面讲到了,需要 startIndex。
vector<vector<int> res; vector<int> path; void dfs(vector<int> &nums, int startIndex);
- 递归终止条件:从上面树状图可以看出剩余集合为空的时候,就是叶子节点。那么什么时候剩余集合为空呢?就是startIndex已经大于数组的长度了,就终止了,因为没有元素可取了。代码如下:(其实可以不需要加终止条件,因为startIndex >= nums.size(),本层for循环本来也结束了)
if (startIndex >= nums.size()) { return; }
- 单层递归搜索逻辑:求取子集问题,不需要任何剪枝!因为子集就是要遍历整棵树。
for (int i = startIndex; i < nums.size(); i++) { path.push_back(nums[i]); // 子集收集元素 dfs(nums, i + 1); // 注意从i+1开始,元素不重复取 path.pop_back(); // 回溯 }
class Solution { public: //返回结果集 vector<vector<int> > res; //在dfs中修改更新,均设置为全局变量 vector<int> path; void dfs(vector<int>& nums, int startIndex) { //注意添加到res结果集的时机,放在开头也恰好能添加一开始的空集 res.emplace_back(path); //终止条件可以不加 if(startIndex >= nums.size()) return; for(int i = startIndex; i <= nums.size() - 1; ++i) { path.emplace_back(nums[i]); dfs(nums, i + 1); path.pop_back(); } } vector<vector<int>> subsets(vector<int>& nums) { res.clear(); path.clear(); dfs(nums, 0); return res; } };
90:
我们可能都会比照这道题比上一题多了一步给结果集去重,当然我们的思维可以不仅局限于此,但我还是想讲一下在上一题的基础上进行去重的做法。
一下解析来自 宝宝可乖了 。思路基于元素列已经被排序。
为什么会有重复序列?
- 该解法生成子列是在之前已经生成的所有子列上依次加上新的元素,生成新的子列。
- 如果元素列(nums)中存在重复元素,则当前元素生成新子列的过程会与前面重复元素生成新子列的过程部分重复,则生成的子列也是部分重复的。
重复序列出现在哪些部分?假设元素列(nums)为:[1, 2, 2],下面进行模拟:
- 遍历完第一个元素--->[[], [1]]
- 遍历完第二个元素--->[[], [1], [2], [1, 2]]
- 遍历完第三个元素--->[[], [1], [2], [1, 2], [2], [1, 2], [2, 2], [1, 2, 2]]。
加红部分是第三个元素遍历以后,产生的重复部分。[[2], [1, 2]]是因为元素列中第三个元素
2
,在与[[], [1]]生成新序列时生成的,可以看到这个过程与第二个元素2
生成新序列的过程时一样的。即重复序列就是第三个元素在与 [[], [1]] 生成新序列时生成的,同时注意到 [[], [1]] 之后的序列就是第二元素遍历时生成的新序列,而第三个序列与这些新序列不会生成重复序列,同时注意每次生成的新序列的长度是可以被记录的。如何避免生成重复序列在了解重复序列出现的原因和位置以后,就可以去重操作了:
- 先进行排序,保证重复元素挨在一起
- 记录每次遍历生成的新序列的长度,这里用
left
表示每次遍历的开始位置,right
结束位置,len
表示长度- 根据与前面元素是否重复,来决定
left
的取值,也就是开始遍历的位置这几个步骤就能有效避免当前元素与之前元素的遍历过程发生重叠。
class Solution { public: vector<vector<int>> subsetsWithDup(vector<int>& nums) { //上一题因为无重复元素所以无需排序,本题想让重复元素尽可能挨在一起,先排好序 sort(nums.begin(), nums.end()); vector<vector<int>> ans; vector<int> v; //放入空集 ans.push_back(v); int right = 1, left = 0, len = 0; //遍历每一个元素 for (int i = 0; i < nums.size(); i++) { //从第二个元素开始就要判断是否重复了 //left与right之间应该是上一轮生成的新序列,这段区间与新遍历到的nums[i]不会产生重复序列 if (i != 0 && (nums[i] == nums[i-1])) left = ans.size() - len; else left = 0; //right的位置是不变的,需要扫到结尾 right = ans.size(); 本轮新生成所需要的的区间长度 len = right - left; for (int j = left; j < right; ++j) { //拿新元素和区间的元素去拼凑 v = ans[j]; v.push_back(nums[i]); ans.push_back(v); } } return ans; } }; 作者:宝宝可乖了
当然上述去重的逻辑不好理解(用双指针圈出的可行区间),我们在抽象出来的递归树中进行剪枝操作:
以下解法来自 代码随想录 。
关于回溯算法中的去重问题,Carl哥在40.组合总和II中已经详细讲解过了,和本题是一个套路。我们在下个专题中再去刷前置知识点,本专题写不下了。后期要讲解的排列问题里去重也是这个套路,所以理解“树层去重”和“树枝去重”非常重要。
用示例中的[1, 2, 2] 来举例,如图所示: (注意去重需要先对集合排序)
从图中可以看出,同一树层上重复取2就要过滤掉,同一树枝上就可以重复取2,因为同一树枝上元素的集合才是唯一子集!
class Solution { private: vector<vector<int>> result; vector<int> path; void backtracking(vector<int>& nums, int startIndex, vector<bool>& used) { //放在开头加入结果集的好处:无论是树层还是树枝,上一轮递归的结果能立马被放进结果集 result.push_back(path); for (int i = startIndex; i < nums.size(); i++) { // used[i - 1] == true,说明同一树枝candidates[i - 1]使用过 // used[i - 1] == false,说明同一树层candidates[i - 1]使用过 // 而我们要对同一树层使用过的元素进行跳过 if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) { continue; } path.push_back(nums[i]); used[i] = true; backtracking(nums, i + 1, used); used[i] = false; path.pop_back(); } } public: vector<vector<int>> subsetsWithDup(vector<int>& nums) { result.clear(); path.clear(); //标记数组,因为nums.size()只有在subsetsWithDup范围内有效,所以将标记数组作为参数传给dfs函数 vector<bool> used(nums.size(), false); sort(nums.begin(), nums.end()); // 去重需要排序 backtracking(nums, 0, used); return result; } }; 作者:代码随想录
class Solution { private: vector<vector<int>> result; vector<int> path; void backtracking(vector<int>& nums, int startIndex) { result.push_back(path); //注意set的定义位置!能保证树层间记忆而树枝间遗忘 unordered_set<int> uset; for (int i = startIndex; i < nums.size(); i++) { if (uset.find(nums[i]) != uset.end()) { continue; } uset.insert(nums[i]); path.push_back(nums[i]); backtracking(nums, i + 1); path.pop_back(); } } public: vector<vector<int>> subsetsWithDup(vector<int>& nums) { result.clear(); path.clear(); sort(nums.begin(), nums.end()); // 去重需要排序 backtracking(nums, 0); return result; } }; 作者:代码随想录
第一种写法的去重逻辑也可以写为:
if(i > startIndex && nums[i] == nums[i - 1]) { continue; }