0. 框架模型
基本的框架已经在基础知识介绍过了,比如前中后序的递归,非递归遍历,层次遍历等。
a. 向左右子树要信息的框架
我们的这款框架中要列出所有的可能性,比如说和根节点有关 和 跟节点无关两种。下面介绍解题思路步骤,
- 首先根据二叉树递归解法的前提,假定可以向左右子树要的信息
- 考虑得到答案的可能性?列出可能性,和根节点有关么?
- 根据可能性,确定左右子树分别需要什么信息
- 信息汇总,求全集定信息体
- 写递归
写递归时的步骤如下,
- 定义好Info结构体
- 递归函数process的返回Info
- base case也就是空树返回
- 从左右子树要信息
- 根据列出的可能性,把Info中所有的信息加工出来,如果base case处理返回是null节点,这时候就要注意判空!
b. 一次性遍历一层的层次遍历
在一些问题中我们希望,如果能一次性的把一层的节点遍历完,把这一层节点暂存在队列中再作处理。
private void widthOfBinaryTree(TreeNode root) { // 层次遍历中的,一次能遍历一层的框架 if (root == null){ return; } // 选用双端队列实现队列,可以做到对队首和队尾元素的选取 Deque<TreeNode> queue = new LinkedList<NodeWithIndex>(); queue.offer(root); int size = 0; while (!queue.isEmpty()){ // 统计这一层节点的数目 size = queue.size(); // 一次性的遍历完 for (int i = 0; i < size; i++){ cur = queue.poll(); if (cur.left!=null){ queue.offer(cur.left); } if (cur.right!=null){ queue.offer(cur.right); } } // 做处理 dosomething; } }
c. BST的框架
查找框架不涉及到修改二叉树,返回值为void
void BST(TreeNode root, int target) { if (root.val == target) // 找到目标,做点什么 // 类比于有序数组的二分查找,root.val就是arr[mid] if (root.val < target) BST(root.right, target); if (root.val > target) BST(root.left, target); }
插入和删除涉及到修改二叉树,返回值为TreeNiode
,一般返回修改后的root node
TreeNode insertIntoBST(TreeNode root, int val) { // 找到空位置插入新节点 if (root == null) return new TreeNode(val); if (root.val < val) root.right = insertIntoBST(root.right, val); if (root.val > val) root.left = insertIntoBST(root.left, val); return root; } TreeNode deleteNode(TreeNode root, int key) { if (root == null){ // 如果没有找到要删除的节点 return; } if (root.val == key) { // 找到啦,进行删除 } else if (root.val > key) { // 去左子树找 root.left = deleteNode(root.left, key); } else if (root.val < key) { // 去右子树找 root.right = deleteNode(root.right, key); } return root; }
1. 递归框架
1.1 采用基本框架
二叉树镜像
ZJ-27 : 请完成一个函数,输入一个二叉树,该函数输出它的镜像。
解题思路:
递归和遍历都是一样的思路,遍历每个点,交换它的左右子树。
时间复杂度:O(N);空间复杂度:O(N)
方法一: 递归方法
public TreeNode mirrorTree(TreeNode root) { if(root == null){ return root; } process(root); return root; } public static void process(TreeNode node){ if(node == null){ return; } // 方法一: 先序遍历 // 根据递归序,第一次来到这个节点,交换左右子树 TreeNode temp = node.left; node.left = node.right; node.right = temp; process(node.left); process(node.right); // 方法二: 中序遍历 // 递归序,第二次来到这个节点,注意是遍历两次left节点,因为交换过了 process(node.left); TreeNode temp = node.left; node.left = node.right; node.right = temp; process(node.left); // 方法三: 后序遍历 process(node.left); process(node.right); TreeNode temp = node.left; node.left = node.right; node.right = temp; }
方法二: 非递归, 和递归思路一样,人工压栈,但还是每个节点交换左右子数
// 层次遍历 public TreeNode mirrorTree(TreeNode root) { if(root == null){ return root; } // 层次遍历的方法 Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); // 遍历队列 while(!queue.isEmpty()){ TreeNode cur = queue.poll(); if(cur.left!=null){ queue.offer(cur.left); } if(cur.right!=null){ queue.offer(cur.right); } // 交换左右子树 TreeNode temp = cur.left; cur.left = cur.right; cur.right = temp; } return root; } // 非递归中序遍历 public TreeNode mirrorTree(TreeNode root) { if(root == null){ return root; } // 非递归的中序遍历 Deque<TreeNode> stack = new ArrayDeque<>(); TreeNode cur = root; while(cur!=null || !stack.isEmpty()){ if(cur!=null){ stack.push(cur); cur = cur.left; }else{ TreeNode curTemp = stack.pop(); // 先记录原本的右子树 cur = curTemp.right; // 交换左右子树 TreeNode temp = curTemp.left; curTemp.left = curTemp.right; curTemp.right = temp; // ***** 也可以这样 ***** cur = stack.pop(); // 交换左右子树 TreeNode temp = cur.left; cur.left = cur.right; cur.right = temp; // 再次左分解,左子树,其实是右子树 cur = cur.left; } } return root; }
1.2 单独考虑根节点
直径的二叉树
给定一棵二叉树,你需要计算它的直径长度。一棵二叉树的直径长度是任意两个结点路径长度中的最大值。这条路径可能穿过也可能不穿过根结点。
示例 :
给定二叉树
1 / \ 2 3 / \ 4 5
返回 3, 它的长度是路径 [4,2,1,3] 或者 [5,2,1,3]。
注意:两结点之间的路径长度是以它们之间边的数目表示。
解题思路:
注意题目中的提示,这个最大路径可能穿过也可能不穿过根节点,也就是说得到答案可能性有多种,路径的最大值可能不需要经过根节点计算,所以我们需要一个全局变量记录最大值。
如上图所示,和根节点无关,答案是来自右子树的最大距离。假设我们知道对于该节点的左儿子向下遍历经过最多的节点数 L(即以左儿子为根的子树的深度) 和其右儿子向下遍历经过最多的节点数 R (即以右儿子为根的子树的深度),那么以该节点为起点的路径经过节点数的最大值即为 L+R+1 。我们得到经过该节点的最多路径节点数是dNode
,那么其直径就是dNode - 1
,因为路径长度是以它们之间边的数目表示,边和点的数目正好差一。
我们想要找到最大直接,就要找到整颗树上对每个点计算其经过的最大经过的节点数,用一个全局变量去找到最大值,这里值得注意的就是,根节点并不代表着最大值。
最后的算法流程为:
我们定义一个递归函数 depth(node) 计算最大经过的节点数 ,函数返回该节点为根的深度。先递归调用左儿子和右儿子求得它们为根的树的深度 L
和 R
,则该节点为根的子树的深度即为max(L,R)+1
,该节点的最大经过的节点数为L+R+1
。
递归搜索每个节点并设一个全局变量ans
,记录并比较每个节点的最大经过节点数,最后找到最大的减一返回。
class Solution { // 假设初始化一个节点,不要随意使用static变量 int ans; public int diameterOfBinaryTree(TreeNode root) { ans = 1; if (root == null){ return 0; } process(root); // 最长路径就是最大节点数-1 return ans - 1; } private int process(TreeNode root){ // base case: 空节点返回0 if (root == null){ return 0; } // 深度代表了最大经历的节点数目 int leftInfo = process(root.left); int rightInfo = process(root.right); // 根节点的信息是,左数目+右数目+1 // 最大节点数就是左节点数+右节点数+自己(1) // 再比较每个节点的信息,找到最大值 ans = Math.max(ans, leftInfo+rightInfo+1); // 求根节点的深度, 左右深度最大值 + 1 return Math.max(leftInfo, rightInfo) + 1; } }
1.3 复杂度优化
完全二叉树的节点个数
力扣222,给出一个完全二叉树,求出该树的节点个数。
说明:
完全二叉树的定义如下:在完全二叉树中,除了最底层节点可能没填满外,其余每层节点数都达到最大值,并且最下面一层的节点都集中在该层最左边的若干位置。若最底层为第 h 层,则该层包含 1~ 2h 个节点。
示例:输入:
1
/
2 3
/ \ /
4 5 6输出: 6
解题思路:
这道题要结合完全二叉树的性质来做,时间复杂度要小于O(N)
。完全二叉树(Complete Binary Tree
)的定义可以理解为,它是一颗满二叉树或者正在依次逐层成为满二叉树,这里的依次是指从左子树到右子树。它的性质就是,一颗完全二叉树的左右子树,至少有一颗是满二叉树,下图参考labuladong公众号
public static int countNodes(TreeNode root) { TreeNode leftNode = root; TreeNode rightNode = root; int leftHeight = 0; int rightHeight = 0; // 统计左子树的高度 while (leftNode != null){ leftNode = leftNode.left; leftHeight++; } // 统计右子树的高度 while (rightNode != null){ rightNode = rightNode.right; rightHeight++; } // 如果是左右等高,则是满二叉树 if (rightHeight == leftHeight){ return (int)Math.pow(2, rightHeight)-1; } // 如果不等高,根左右累加起来 // 复杂度计算,只会有一个递归到底,所以复杂度是log(N) // 每次递归时,计算复杂度是log(N) // 所以最后的复杂度是,(h)ˆ2,h=log(N) return 1 + countNodes(root.left) + countNodes(root.right); }
分析上述代码的时间复杂度,最后一行的左右子树递归中,只会有一个递归到底,另一个会因为是满树而提前返回。比如说root.left
是满树,关于它的递归函数,只会一次执行中间的判断树的高度的代码,从而证明他是满树直接通过公式计算返回节点数目,其复杂度是O(log(N))
。
root.right
不是满树的话,最多会递归h
层,h
就是树的高度,每层会执行log(N)
的操作,因此复杂是O(log(h)ˆ2)
,是远远低于O(N)
的复杂度。所以说,完全二叉树这个概念还是有它存在的原因的,不仅适用于数组实现二叉堆,而且连计算节点总数这种看起来简单的操作都有高效的算法实现。
备注:
// 普通二叉树计算节点个数,复杂度是O(N) public int countNodes(TreeNode root) { if (root == null) return 0; return 1 + countNodes(root.left) + countNodes(root.right); } // 如果是一棵满二叉树,节点总数就和树的高度呈指数关系,时间复杂度 O(logN) 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; }
1.4 层次遍历是更好解法
BT的最大宽度
给定一个二叉树,编写一个函数来获取这个树的最大宽度。树的宽度是所有层中的最大宽度。这个二叉树与满二叉树(full binary tree)结构相同,但一些节点为空。
每一层的宽度被定义为两个端点(该层最左和最右的非空节点,两端点间的null节点也计入长度)之间的长度。
示例 1:
输入:
1 / \ 3 2 / \ \ 5 3 9
输出: 4
解释: 最大值出现在树的第 3 层,宽度为 4 (5,3,null,9)。
解题思路:
设想给每个节点编号,其子节点的编号就是left = 2*index, right = 2*index+1
,再利用一次性宽度的层次遍历框架,计算这一层的第一个节点和最后一个节点的差值就是这层的宽度,最后再找到所有层中的最大宽度即可。
private static int widthOfBinaryTree(TreeNode root) { // 层次遍历中的,一次能遍历一层的框架 if (root == null){ return 0; } // 选用双端队列实现队列,可以做到对队首和队尾元素的选取 Deque<NodeWithIndex> queue = new LinkedList<NodeWithIndex>(); queue.offer(new NodeWithIndex(root, 1)); NodeWithIndex cur = null; int index = 0; int size = 0; int width = 0; while (!queue.isEmpty()){ // 最大宽度就是,找到每层中最后节点减去首节点再加一的最大值 width = Math.max(width, queue.peekLast().index - queue.peekFirst().index + 1); size = queue.size(); for (int i = 0; i < size; i++){ cur = queue.poll(); root = cur.node; index = cur.index; if (root.left!=null){ queue.offer(new NodeWithIndex(root.left, 2*index)); } if (root.right!=null){ queue.offer(new NodeWithIndex(root.right, 2*index + 1)); } } //width = Math.max(width, queue.peekLast().index - queue.peekFirst().index + 1); } return width; } // 对现有节点改造下,加入一个index的新属性 private static class NodeWithIndex{ TreeNode node; int index; public NodeWithIndex(TreeNode node, int index) { this.node = node; this.index = index; } }
此外也可以用深度优先遍历解决,也需要给每个节点编号,并需要一个depth
来记录当前节点的深度,并巧妙的利用一个map
来记录,当每层的第一次加入节点的编号,因为是先序框架,所以第一个节点一定是最左边的节点,在后序的递归过程中,可以查询表,让当前层的所有节点依次去计算和这个最左节点的距离,找到最大值就是最大宽度。
// 查询表 static HashMap<Integer, Integer> map; static int ans; private static int widthOfBinaryTreeRecursive(TreeNode root) { if (root == null){ return 0; } map = new HashMap<Integer, Integer>(); // 根节点 map.put(0, 0); ans = 0; dfs(root, 0, 0); return ans; } private static void dfs(TreeNode root, int depth, int pos){ if (root == null){ return; } // 如果depth不存在,就按照方程加入pos,保证加入的都是每一层的最左pos map.computeIfAbsent(depth, x->pos); // 最奇妙的地方,每次查询depth得到当前层的最左节点! int left = map.get(depth); ans = Math.max(ans, pos - left + 1); dfs(root.left, depth+1, 2*pos); dfs(root.right, depth+1, 2*pos+1); }
2. 序列化框架
参考资料,东哥公共号lobuladong
序列化和反序列化的目的,以某种固定格式组织字符串,使得数据可以独立于编程语言。那么假设现在有一棵用 Java 实现的二叉树,我想把它序列化字符串,然后用 C++ 读取这棵并还原这棵二叉树的结构,怎么办?这就需要对二叉树进行序列化和反序列化了。
可以解决题目LC297,给你输入一棵二叉树的根节点 root
,要求你实现如下一个类:
public class Codec { // 把一棵二叉树序列化成字符串 public String serialize(TreeNode root) {} // 把字符串反序列化成二叉树 public TreeNode deserialize(String data) {} }
我们可以用 serialize
方法将二叉树序列化成字符串,用 deserialize
方法将序列化的字符串反序列化成二叉树,至于以什么格式序列化和反序列化,这个完全由你决定。
serialize
方法也许会把它序列化成字符串 2,1,#,6,3,#,#
,其中 #
表示 null
指针,,
表示分隔符,代表一个值的结束,必须有分隔符,它可以唯一的区分一棵二叉树,否则在序列中123,根节点是12还是23呢?把这个字符串再输入 deserialize
方法,依然可以还原出这棵二叉树。也就是说,这两个方***成对儿使用,你只要保证他俩能够自洽就行了。
PS:一般语境下,单单前序遍历结果是不能还原二叉树结构的,因为缺少空指针的信息,至少要得到前、中、后序遍历中的两种才能还原二叉树。但是这里的 node
列表包含空指针的信息,所以只使用 node
列表就可以还原二叉树。
2.1 前中后序遍历
前序方法:
序列化的方法很简单,就是在前序遍历的框架上加上添加字符的部分,注意在base case
时当节点为空,添加 #
。采用StringBuilder()
可以更高效稳定的拼接字符串。
// 把一棵二叉树序列化成字符串 private static String serialize(TreeNode root) { StringBuilder sb = new StringBuilder(); preTraversal(root, sb); return sb.toString(); } // 序列化是需要递归的,需要一个辅助函数 private static void preTraversal(TreeNode root, StringBuilder sb){ // base case: if (root == null){ sb.append("#").append(","); return; } // 加入字符,用分隔符分开 // 前序遍历框架 sb.append(root.val).append(","); preTraversal(root.left, sb); preTraversal(root.right, sb); }
反序列化的方法,参考接收到的序列化后的结果,首先根据分隔符就可以得到节点列表,观察就可以发现第一个节点就是根节点,这样我们就可以借助一个队列采用递归的方式恢复出这个二叉树。下图作者是dong哥,其图片来自其公众号,水印是labuladong。
先确定根节点 root
,然后遵循前序遍历的规则,递归生成左右子树。这里巧妙的是,因为序列化的时候记录了空节点,所以当递归恢复叶子节点时,递归函数会在执行到叶子节点的左右子树时,返回空,这样叶子节点就只会返回它本身。序列化时是采用前序遍历,反序列化时也采用根,左孩子,右孩子的顺序递归恢复,只需要额外的把队列中的元素从头到尾依次弹出。
// 把字符串反序列化成二叉树 private static TreeNode deserialize(String data) { // 把接收到的字符串,先分割再依次装入到队列中 Queue<String> str = new LinkedList<String>(); for(String string : data.split(",")){ str.offer(string); } return deserialize_process(str); } // 反序列化是需要递归的,需要一个辅助函数 private static TreeNode deserialize_process(Queue<String> str){ // 力扣做题防止空输入 if (str.isEmpty()){ return null; } String cur = str.poll(); // base case: if (cur.equals("#")){ return null; } TreeNode root = new TreeNode(Integer.parseInt(cur)); root.left = deserialize_process(str); root.right = deserialize_process(str); return root; }
后序方法:
序列化的方法和前序的一致,注意添加字符的顺序放在遍历之后,根据递归序,根节点会最后加入序列。
// Encodes a tree to a single string. private static String serialize(TreeNode root) { StringBuilder sb = new StringBuilder(); serialize_process(root, sb); return sb.toString(); } // 后序遍历的序列化辅助递归函数 private static void serialize_process(TreeNode root, StringBuilder sb){ // base case: if (root == null){ sb.append("#").append(","); return; } serialize_process(root.left, sb); serialize_process(root.right, sb); // 后序遍历 sb.append(root.val).append(","); }
反序列化的方法,要根据序列化的顺序做出改变,因为根节点在序列的最后,我们应该从后往前取出列表元素,先用最后一个元素构造 root
,然后递归调用生成 root
的左右子树。注意,根据上图,从后往前在 nodes
列表中取元素,一定要先构造 root.right
子树,后构造 root.left
子树。
我们在从序列化的结果恢复成节点列表时,采用栈这个容器,就可以保证本来在末尾的根节点可以第一个被处理,从而完成从后往前的从列表中取元素。
// Decodes your encoded data to tree. // 反序列化 private static TreeNode deserialize(String data) { // 先把字符串分割提取,针对后序遍历要用栈,因为根节点是最后加入的 Deque<String> stack = new ArrayDeque<String>(); for (String str : data.split(",")){ stack.push(str); } return deserialize_process(stack); } // 后序遍历反序列化需要借助递归辅助函数 private static TreeNode deserialize_process(Deque<String> stack){ if (stack.isEmpty()){ return null; } String cur = stack.pop(); if (cur.equals("#")){ return null; } TreeNode root = new TreeNode(Integer.parseInt(cur)); // 因为后序遍历的顺序,所以递归先建立出的是右子树,再是左子树 root.right = deserialize_process(stack); root.left = deserialize_process(stack); return root; }
中序方法:
中序方法无法做到反序列化,原因是,不同于前序和后序遍历,中序遍历的根节点在中间无法定位到他的具***置。
至此可以小结下,这种生成一个二叉树的模版就是,先要生成根节点,再递归的产生左右子树。
// base case,基于某个条件返回null if (cur.equals("#")){ return null; } TreeNode root = new TreeNode(Integer.parseInt(cur)); // 递归建立出右子树,左子树 root.right = deserialize_process(stack); root.left = deserialize_process(stack); return root;
2.2 层次遍历
序列化的方法,和前后序遍历很相似,就是在遍历节点的过程中加入转换字符的功能,在标准的二叉树层级遍历框架中,从上到下,从左到右打印每一层二叉树节点的值,可以看到,队列 q
中不会存在null
指针。不过我们在反序列化的过程中是需要记录空指针null
的,所以可以把标准的层级遍历框架略作修改:
// 把一棵树序列化,再逆序列化,采用层次遍历模式 // 借助层次遍历的框架,需要一个队列 private static String serialize(TreeNode root) { // 如果是空树,直接返回 if (root == null){ return new String("#,"); } StringBuilder sb = new StringBuilder(); Queue<TreeNode> queue = new LinkedList<TreeNode>(); queue.offer(root); // 注意根节点要单独的在这里要添加字符! sb.append(root.val).append(","); TreeNode cur = null; while (!queue.isEmpty()){ cur = queue.poll(); if (cur.left!=null){ sb.append(cur.left.val).append(","); queue.offer(cur.left); }else { sb.append("#,"); } if (cur.right!=null){ sb.append(cur.right.val).append(","); queue.offer(cur.right); }else{ sb.append("#,"); } } return sb.toString(); }
反序列化方法,类比前后遍历递归的恢复树,层次遍历方法没有递归时的系统栈,因此需要自己额外建立一个队列帮助自己。
可以看到,每一个非空节点都会对应两个子节点,那么反序列化的思路也是用队列进行层级遍历,同时用索引 i
记录对应子节点的位置,序列化时操作二叉树节点 TreeNode
,反序列化时我们的函数在操作 nodes[i]
。
// 反序列化 // 序列化是在二叉树上层次遍历,反序列化是在数组上利用下标i遍历左右节点 // 反序列化要借助辅助队列 private static TreeNode deserialize(String data) { // 根据分隔符获得节点数组 String[] nodes = data.split(","); int i = 0; // 第一个元素就是根节点 TreeNode root = generateNodeByStr(nodes[i++]); Queue<TreeNode> queue = new LinkedList<TreeNode>(); TreeNode cur = null; // 头节点不是空是,才加入队列 if (root!=null){ queue.offer(root); } while(!queue.isEmpty()){ cur = queue.poll(); // 生产左右子树 TreeNode left = generateNodeByStr(nodes[i++]); TreeNode right = generateNodeByStr(nodes[i++]); cur.left = left; cur.right = right; // 检查左右子树 if (left!=null){ queue.offer(left); } if (right!=null){ queue.offer(right); } } return root; } private static TreeNode generateNodeByStr(String str){ if (str.equals("#")){ return null; }else { return new TreeNode(Integer.parseInt(str)); } }
2.3 应用
寻找重复的子树
给定一棵二叉树,返回所有重复的子树。对于同一类的重复子树,你只需要返回其中任意一棵的根结点即可。两棵树重复是指它们具有相同的结构以及相同的结点值。
以下图的树为例,应该返回节点4和2,分别代表子树[4]
和[2,4]
。
解题思路,二叉树的老套路,先思考,对于某一个节点,它应该做什么。如果你想知道以自己为根的子树是不是重复的,是否应该被加入结果列表中,你需要知道什么信息?
你需要知道以下两点:
1、以我为根的这棵二叉树(子树)长啥样?
2、以其他节点为根的子树都长啥样?
针对第一个问题,可以采用之前学习到的序列化的知识,可以把这颗二叉树唯一的序列化表示。可以采用后序框架,此时可以完整的描述以“我”为根的这个树的序列化,至于按什么顺序拼接序列化的字符串,这个随意。
针对第二个问题,可以借助外部查询表,因为是找到重复的子树,所以表中重复的元素就是想要的答案,此外还要注意,题目要求只把重复的节点加入一次,所以只需要一个小技巧,就是当加入第二次时,才把元素加入答案容器中。
static HashMap<String, Integer> memo; static List<TreeNode> list; private static List<TreeNode> findDuplicateSubtrees(TreeNode root) { // 建立外部变量 memo = new HashMap<String, Integer>(); list = new ArrayList(); traversal(root); return list; } private static String traversal(TreeNode root){ // base case: if (root == null){ return "#"; } // 选择后序遍历 String res = root.val + "," + traversal(root.left) + "," + traversal(root.right); if (!memo.containsKey(res)){ memo.put(res, 1); }else { int times = memo.get(res); // 说明已经一次记录,这次就会重复第一次 if (times == 1){ list.add(root); } memo.put(res, times+1); } // 可以简化查询代码,getOrDefault方法就是没有查询到key的记录时,返回用户设置的defaultValue memo.put(res, memo.getOrDefault(res, 0) + 1); if (memo.get(res) == 2) list.add(root); return res; }
3. 重建二叉树
4. 二叉搜索树
二叉搜索树(BST)的定义:
1、对于 BST 的每一个节点node
,左子树节点的值都比node
的值要小,右子树节点的值都比node
的值大。
2、对于 BST 的每一个节点node
,它的左侧子树和右侧子树都是 BST。
此外还有一个重要的性质:BST 的中序遍历结果是有序的(升序)。
4.1 增删改查
BST的第K大节点
力扣题号: 54 - 给定一棵二叉搜索树,请找出其中第k大的节点。
解题思路:
正常的中序遍历是,左中右,遍历下来的数组是从小到大,而题目要求是第K大,我们可以自己加工一个中序遍历是,右中左,这样数组是从大到小,遍历到第K个就是想要的答案。
public int kthLargest(TreeNode root, int k) { // 自己构造一种遍历方法,右-中-左 // 用非递归的方法中序遍历二叉树 TreeNode cur = root; int index = 1; int KthValue = -1; Deque<TreeNode> stack = new ArrayDeque<>(); while(cur!=null || !stack.isEmpty()){ // 不停地压入右孩子 if(cur!=null){ stack.push(cur); cur = cur.right; }else{ // 弹出栈顶 cur = stack.pop(); if(index == k){ KthValue = cur.val; } index++; cur = cur.left; } } return KthValue; }
在java官网中,推荐我们不要使用Stack类,而是使用Deque类
Deque<TreeNode> stack = new ArrayDeque<>(); // 压入 stack.push(cur); // 弹出 cur = stack.pop(); // 栈顶元素 cur = stack.peek();
当然也可以采用中序遍历的递归框架:
int res = 0; int rank = 0; public int kthLargest(TreeNode root, int k) { inOrderTraverse(root, k); return res; } public void inOrderTraverse(TreeNode root, int k){ if(root == null){ return; } // 稍微改造下,先遍历右边 inOrderTraverse(root.right, k); rank++; if(rank == k){ res = root.val; return; } inOrderTraverse(root.left, k); }
BST的第K小节点
给定一个二叉搜索树,编写一个函数 kthSmallest 来查找其中第 k 个最小的元素。
说明:
你可以假设 k 总是有效的,1 ≤ k ≤ 二叉搜索树元素个数。
解题思路:
利用 BST 的中序遍历其实就是升序排序,找到第 K 个元素,可以采用递归和非递归框架。
int res = 0; int nodeInd = 0; public int kthSmallest(TreeNode root, int k) { process(root, k); return res; } // 返回值类型是void,突发性的返回一个值 private void process(TreeNode root, int k){ if (root == null){ return; } process(root.left, k); // 递归序中,第二次来到自己时才更新序号 nodeInd++; if (nodeInd == k){ res = root.val; return; } process(root.right, k); }
进阶思路:
参考大道至简
如果我们可以改造节点,让每个节点知道自己的排名,就可以采用二分的方法去查找。如何知道自己的排名呢,需要统计每个节点,以自己为根的二叉树有多少个节点,对于每个节点node
就可以通过node.left
推导出node
的排名。node
的排名等于getSize(node.left)+1
。
// 第三种进阶方法,知道每个节点的size,就是以这个节点为根的子树的节点个数 // 前提:一定会有这个节点的 public int kthSmallest(TreeNode root, int k) { int num = getNodeNum(root.left); while (true){ if (num+1 == k){ return root.val; }else if (num+1 > k){ // root.left的排名大于k,则在left子树中寻找 root = root.left; num = getNodeNum(root.left); }else { // num+1 < k // root.left的排名小于k,则在right子树中寻找 root = root.right; // k要更新为 在右子树中的相对kth,因为k已经比整个左子树的节点数大 k = k - (num+1); num = getNodeNum(root.left); } } } // 以当前节点为根的子树的节点总数 public int getNodeNum(TreeNode node){ if (node == null){ return 0; } return 1 + getNodeNum(node.left) + getNodeNum(node.right); }
BST的搜索
给定二叉搜索树(BST)的根节点和一个值。 你需要在BST中找到节点值等于给定值的节点。 返回以该节点为根的子树。 如果节点不存在,则返回 NULL。
解题思路:
public TreeNode searchBST(TreeNode root, int val) { if(root == null){ return null; } if(val == root.val){ return root; }else if(root.val > val){ // 中间值大于目标值,在左半部分寻找 return searchBST(root.left, val); }else{ return searchBST(root.right, val); } } // 迭代框架 public TreeNode searchBST(TreeNode root, int val) { // 迭代模版的 while(root!=null && root.val!=val){ root = root.val > val ? root.left : root.right; } return root; }
BST的插入
给定二叉搜索树(BST)的根节点和要插入树中的值,将值插入二叉搜索树。 返回插入后二叉搜索树的根节点。 输入数据 保证 ,新值和原始二叉搜索树中的任意节点值都不同。
解题思路:
public TreeNode insertIntoBST(TreeNode root, int val) { // 找到空位置,插入 if(root == null){ return new TreeNode(val); } if(root.val > val){ root.left = insertIntoBST(root.left, val); }else{ root.right = insertIntoBST(root.right, val); } return root; }
BST的删除
给定一个二叉搜索树的根节点 root 和一个值 key,删除二叉搜索树中的 key 对应的节点,并保证二叉搜索树的性质不变。返回二叉搜索树(有可能被更新)的根节点的引用。
一般来说,删除节点可分为两个步骤:
首先找到需要删除的节点;
如果找到了,删除它。
说明: 要求算法时间复杂度为 O(h),h 为树的高度。
解题思路:
先采用查找的框架,去寻找这个节点,如果它不存在就不作修改,返回原来的根节点。如果找到了要删除的节点,则需要分情况讨论:
情况一:node
恰好是末端节点,两个子节点都为空,可以直接删除,图片来自力扣,下同
情况二:node
只有一个孩子,则直接让它的孩子接替它的位置
情况三:node
有两个孩子,必须找到左子树中最大的那个节点,或者右子树中最小的那个节点来接替自己
// 返回二叉搜索树(有可能被更新)的根节点的引用。 private TreeNode deleteNode(TreeNode root, int key) { // 框架是,查找加上修改 if (root == null){ return null; } if (key > root.val){ root.right = deleteNode(root.right, key); }else if (key < root.val){ root.left = deleteNode(root.left, key); }else { // 找到了待删除的节点了,分情况讨论 // 情况1:叶子结点 和 情况二:单孩子节点 if (root.left == null){ return root.right; } if (root.right == null){ return root.left; } // 情况三:左右孩子都不为空 // 1. 记录待删除的节点 TreeNode t = root; // 2. 找到左子树中的最小节点,接替它 root = getMin(t.right); // 3. 左子树中的最小节点,它的左孩子一定为空,但还要处理它可能不为空的右孩子 root.right = deleteMin(t.right); root.left = t.left; } // 返回递归修改后的根 return root; } // 找到并返回,二叉树以node为根的最小节点,也就是最左节点 private TreeNode getMin(TreeNode node){ if (node == null){ return null; } while (node.left != null){ node = node.left; } return node; } // 以node为根,找到树上的最小节点,并把它的右孩子接替它的位置,并返回根节点(可能更新了) private TreeNode deleteMin(TreeNode node){ // 如果node的left为空,就 if (node.left == null){ return node.right; } // 跨越两层节点 node.left = deleteMin(node.left); return node; // 非递归的容易理解 // 记录原始节点 TreeNode root = node; // 如果第一个节点就是最右节点,直接返回它的右孩子 if (node.left == null){ return node.right; } // 递推更新,保留到祖父节点 while(node.left.left!=null){ node = node.left; } // 看图说话 node.left = node.left.right; return root; }
4.2 利用BST的性质
验证BST
给定一个二叉树,判断其是否是一个有效的二叉搜索树。
假设一个二叉搜索树具有如下特征:
节点的左子树只包含小于当前节点的数。
节点的右子树只包含大于当前节点的数。
所有左子树和右子树自身必须也是二叉搜索树。
解题思路:
利用BST中序遍历是生序的性质,依次判断遍历的数,如果不符合立刻返回false
long pre = Long.MIN_VALUE; boolean res = true; public boolean isValidBST(TreeNode root) { if(root == null){ return true; } process(root); return res; } public void process(TreeNode root){ if(root == null){ return; } process(root.left); // 比较当前遍历值 和 之前的值 if(pre >= root.val){ res = false; } // 更新当前值 pre = root.val; process(root.right); }
非递归框架:
public boolean isValidBST(TreeNode root) { if (root==null){ return true; } Stack<TreeNode> stack = new Stack<>(); TreeNode node = root; double pre = - Double.MAX_VALUE; while(!stack.isEmpty() || node!=null){ // 左遍历 if(node!=null){ stack.push(node); node = node.left; } // 退出左遍历,先弹出一个节点,在对右节点执行左遍历 else{ node = stack.pop(); if(node.val <= pre){ return false; } pre = node.val; node = node.right; } } return true; }
最低公共祖先
力扣236:给定一个二叉树, 找到该树中两个指定节点p
和q
(一定在树上)的最近公共祖先。
解题思路:
1.递归方法
参考力扣题解Krahets , Anish.Hui 和 labuladong
递归套路思考:
- 本身函数需要返回什么?最低公共父节点
- 整合左右子树返回的信息?以子树为头节点的整棵树的最低公共父节点
- 整合规则?
递归解析:
终止条件(base case):
- 当到达叶子结点的子树时,返回
Null
- 当
root=q / p
时,返回根节点本身
- 当到达叶子结点的子树时,返回
递推工作:
- 开启递归左子节点,返回值记为
left
- 开启递归右子节点,返回值记为
right
- 开启递归左子节点,返回值记为
整合规则:
- 当左右子树的返回都是空,说明查询节点都不在左右子树上,则返回空
- 当左右子树都不是空,说明
p
和q
恰好分布在左右子树上,此时root
就是他们的最低公共祖先,并返回 - 当左子树不为空,右子树为空,则返回左子树,说明
p
和q
都分布在左子树上,p
和q
可能均匀的分布在左子树为根的树的两侧,也可能其中一个就是左子树 - 同理,右子树不为空时,返回右子树
对于整合情况二中,left
和right
非空,分别是p
和q
,可以说明root
是它们的公共祖先,但能确定root
就是「最近」公共祖先吗?这就是一个巧妙的地方了,因为这里是二叉树的后序遍历!前序遍历可以理解为是从上往下,而后序遍历是从下往上,就好比从p
和q
出发往上走,第一次相交的节点就是这个root
,也当然是最近公共祖先!
时间复杂度:O(N)
,其中N
是二叉树的节点数。二叉树的所有节点有且只会被访问一次,因此时间复杂度为 O(N)
。
空间复杂度:O(N)
,其中N
是二叉树的节点数。递归调用的栈深度取决于二叉树的高度,二叉树最坏情况下为一条链,此时高度为 N
,因此时间复杂度为 O(N)
。
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) { // base case: // 1. 如果是叶子节点的子节点(null)返回null // 2. 如果遇到p 或者 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); // 加工处理左右信息 // 1. 如果左不为空,右不为空,则返回root,因为p,q恰好分布在左右两边 // 此时root就是他们的最近公共父节点 if (left != null && right != null){ return root; } // 如果左为空,则返回右,p,q都在右子树中 if (left == null){ return right; } // 同理,如果右为空,则返回左,p,q都在左子树中 if (right == null){ return left; } // 左右子树没有,则返回空 return null; }
2.批量查询方法
先花较大的力气建立一种记录,以后执行每次查询时就可以完全根据记录进行查询。
- 利用层次遍历,记录每个节点和它的父节点,用哈希表建立映射关系
- 当要查询时,从
node1
开始,把node1
自身和其所有的祖先节点放入一个无序集合中,最后一个祖先是根节点,他的祖先是null
- 从
node2
开始,检查node2
是否在之前无序集合中,如果没有node2
更新为它的父节点再检查 - 最后返回
node2
,就是lowest Common ancestor
比如我们在下图中,查询node(4)
和node(8)
的LCA
节点 | 父节点 |
---|---|
Node 8 | Node 7 |
Node 7 | Node 3 |
Node 6 | Node 3 |
Node 5 | Node 2 |
Node 4 | Node 2 |
Node 3 | Node 1 |
Node 2 | Node 1 |
Node 1 | Null |
其中node(4)
的父节点路径是APath(4) = {4, 2, 1}
,然后node(8)
去在这个路径中查询自己,第一次不在,查询node(8)
的父节点node(7)
,也不在,依次向上查询,最后找到node(1)
!
结构一建立记录的过程时间复杂度为O(N)、额外空间复杂度为O(N)。查询操作时,时间复杂度为O(h),其中,h为二叉树的高度。
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) { if (root == null){ return null; } // 建立祖先查询表 HashMap<TreeNode, TreeNode> ancestorMap = new HashMap<>(); // 建立单个节点查询表 HashSet<TreeNode> ancestorPath = new HashSet<>(); // 建立辅助队列,进行层次遍历 Queue<TreeNode> queue = new LinkedList<>(); queue.offer(root); // 根节点的祖先是 null ancestorMap.put(root, null); // 制作查询表 while(!queue.isEmpty()){ root = queue.poll(); if (root.left!=null){ queue.offer(root.left); ancestorMap.put(root.left, root); } if (root.right!=null){ queue.offer(root.right); ancestorMap.put(root.right, root); } } // 制作单个节点的 祖先路径 TreeNode path = p; while (path != null){ ancestorPath.add(path); // 依次把p 和 p的所有祖先节点放入set中 path = ancestorMap.get(path); } // 查询节点 path = q; while (!ancestorPath.contains(path)){ path = ancestorMap.get(path); } return path; }
BST的LCA
上面介绍了普通二叉树的LCA
,结下来利用BST的性质,可以更快速的求解。
解题思路:
- 我们从根节点开始遍历
- 如果待查询的两个节点的值都大于根节点,说明这两个节点在右子树,则在左子树中寻找
- 如果待查询的两个节点的值都小于根节点,说明这两个节点在右子树,则在左子树中寻找
- 如果当前节点的值不满足上述两条要求,那么说明当前节点就是「分岔点」。此时这两个节点要么在当前节点的不同的子树中,要么其中一个就是当前节点。返回分岔点就是答案。
public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) { if (root == null){ return null; } while (true){ if (p.val > root.val && q.val > root.val){ root = root.right; } else if (p.val < root.val && q.val < root.val){ root = root.left; }else { break; } } // 跳出循环时,说明p,q均匀分布在左右子树,或者有一个在根,另一个在其子树上 return root; }
转化成累加树
给出二叉 搜索 树的根节点,该树的节点值各不相同,请你将其转换为累加树(Greater Sum Tree),使每个节点 node 的新值等于原树中大于或等于 node.val 的值之和。
解题思路:
观察给出的实例图片可知,从大到小降序打印 BST 节点的值,如果维护一个外部累加变量sum
,然后把sum
赋值给 BST 中的每一个节点,就可以将 BST 转化成累加树。
public TreeNode convertBST(TreeNode root) { if (root == null){ return null; } convertBST_process(root); return root; } int sum = 0; public void convertBST_process(TreeNode root) { if (root == null){ return; } // 传统中序遍历的倒叙 convertBST_process(root.right); sum += root.val; root.val = sum; convertBST_process(root.left); }
非递归框架:
public TreeNode convertBST(TreeNode root) { if (root == null){ return null; } // 借助辅助栈 Deque<TreeNode> stack = new ArrayDeque<>(); TreeNode node = root; int sum = 0; while (!stack.isEmpty() || node!=null){ if (node != null){ stack.push(node); node = node.right; }else { node = stack.pop(); sum += node.val; node.val = sum; node = node.left; } } return root; }