树—最“有套路”的数据结构
前言
标题用“有套路”来形容一种数据结构,似乎有点不尊重的意思。不过,我倒是觉得,一种实用的学科,就是应该产生一点套路,这才能发挥体系化研究的优势,套路就是一种保证:在不投入更多创造性与努力的情况下,依旧能获得比起随意进行相关操作更好的结果。一门成熟的学科都应如是,如果研究许久,在学科所研究的许多问题的实践上还不如一些“天赋”“灵感”,那就不得不说这门学科的“伪科学”或者“水分”还是蛮大的了。
言归正传,这篇文章将会是一系列寻找算法与数据结构的文章的开篇,树由于其特性,是递归、分治等等重要算法思想的典型载体,同时套路性较强又具有一定规律和难度,上手后,也可以获得总结其他算法“套路”的必要经验。作为一个训练的开头,还是很合适了。
树的定义与理解
先简要谈谈树的抽象定义:树本质上是一种无向图(图:由顶点与路径构成的数据结构),其中,任意两个顶点之间有且只有一条路径。简而言之,树是一种具有特殊性质的图。
树的结构非常直观,而且树的大多数结构具有一个重要性质:递归。主要来说,就是树具有某一性质时,往往其子树也具有同样的性质。比如说,一个树如果是二叉搜索树,其子树也必须是二叉搜索树。
根据这样的性质,遇到树的问题,很自然会考虑如何合理使用递归算法,其实质就是:分解为子问题,最后解决基本情况,把复杂的递归过程交给计算机来处理。所以,树类型代码的特点就是简洁(不过换句话说,由于太简洁,很多思维过程又交给了计算机,有时候写对了没有都不知道:) )
所以,面试官往往是在用树来考察你对递归算法的理解。
一般来说,树具有以下可能的形状: 普通二叉树、平衡二叉树(即叶子节点的深度相差不超过1)、完全二叉树(除了最后一层外,每层节点全部填满,最后一层若不满,必须是右边的节点缺少;每层都填满的,被称为完美二叉树)、四叉树(quadtree,即每个节点最多有四个孩子)、N叉树(每个节点最多有N个孩子)。
树的遍历
先谈谈为什么要遍历。其实对于一个数据结构的基础算法(不讨论那些奇技淫巧),重要的不过增查改删(CRUD)。对于树这样一个结构,在没有先验知识的情况下,为了使得代码量可控,考虑到树的结构是固定的(或者说每个节点/子树都具有一样的结构),我们就得依照其连接关系借助递归方法,以一种特定的顺序进行访问。当全部的节点都被访问时,这样的操作就是一个遍历。
前序遍历(pre-order traversal):
访问顺序:根-左子树-右子树
简单实现代码:
1public void preorderTraverse(TreeNode root){
2 visit(root); // 此步执行遍历的功能,比如说打印、比较、存储等等
3 preorderTraverse(root.left);
4 preorderTraverse(root.right);
5}
应用场景:在树中进行搜索、创建一棵新的树(没有根,左右子树创建后不便于连接)。
中序遍历(inorder Traverse)与后序遍历(postorder Traverse):
基本同前序遍历,只是顺序上,中序是左-根-右,后序是左-右-根。
中序遍历可写作这样的递归形式:
1public void inorderTraverse(TreeNode root){
2 inorderTraverse(root.left);
3 visit(root); // 此步执行遍历的功能,比如说打印、比较、存储等等
4 inorderTraverse(root.right);
5}
可以看到,此时也并不复杂,只是单纯地换序而已,将在根处的操作移动到了中间,规律明显,代码也易于阅读。
对于中序遍历,其应用场景最常见的是二叉搜索树,按这样的顺序遍历出来,输出的结果是按大小顺序的(比如说递增)。
关于后序遍历,如果在对某个节点进行分析时,需要其左右子树的信息(大小、存在与否等等),那么可以考虑后序遍历,仿佛就是在修剪一棵树的叶子,可以从外向内不断深入修剪。
由于三种遍历说的前中后都是针对根节点,国内也有教材把上述遍历方式称为,先根遍历、中根遍历、后根遍历,前中后决定的只是根的位置,左子树都在右子树之前,这样记忆可能简单些(虽然最好的记忆方法永远是实现几个代码)。
不过,递归写法虽然容易,非递归写法也需要掌握。
这里提供一种前序遍历的非递归写法的分析思路:
主要考虑用栈来模拟上面的递归(递归本质上也是操作系统/OS在帮你压栈,所以递归深度过深时报错也是所谓“stack overflow”,即栈溢出):
以下出自leetcode144题,即二叉树前序遍历:
1public List<Integer> preorderTraversal(TreeNode root){
2 List<Integer> res = new ArrayList<>(); // 用于保存遍历结果
3 Stack<TreeNode> stack = new Stack<>();
4 TreeNode curr = root; // 用于指示当前节点
5 while(curr != null || !stack.isEmpty()){
6 if(curr != null){
7 res.add(curr.val);
8 stack.push(curr);
9 curr = curr.left; // 考虑左子树
10 } else{
11 // 节点为空,弹栈,回溯到上一层
12 curr = stack.pop();
13 // 此时考虑右子树
14 curr = curr.right;
15 }
16 }
17 return res;
18}
类似地,中序遍历可以写作以下非递归形式,除了稍微改变顺序以外,几乎跟前序遍历一样:
1public List<Integer> inorderTraversal(TreeNode root) {
2 List<Integer> res = new ArrayList();
3 Stack<TreeNode> stack = new Stack();
4 TreeNode curr = root; // 指示当前位置
5
6 while(curr != null || !stack.isEmpty()){
7 if(curr != null){
8 stack.push(curr);
9 curr = curr.left; //先入栈,并指向左节点
10 } else{
11 //当前节点为空时,出栈,并进行操作,随后指向右节点
12 curr = stack.pop();
13 res.add(curr.val);
14 curr = curr.right;
15 }
16 }
17
18 return res;
19 }
不同于上面两个,后序遍历的非递归略微难写一点,主要是因为需要考虑根节点的问题,在整个遍历过程中,根节点会被访问两次,需要判断是否右子树已经被访问过了,才能确认根节点是否需要被保存。最简单粗暴的想法就是用hashset保存已经访问过的节点:
1class Solution {
2 public List<Integer> postorderTraversal(TreeNode root) {
3 List<Integer> res = new ArrayList();
4 Stack<TreeNode> stack = new Stack();
5 Set<TreeNode> set = new HashSet<>();
6 TreeNode curr = root;
7
8 while(curr != null || !stack.isEmpty()){
9 // 首先不断向左子树运动
10 while(curr != null && !set.contains(curr)){
11 stack.push(curr);
12 curr = curr.left;
13 }
14 // 此时不能直接保存根节点再去右子树,所以只能利用peek来寻找右子树
15 curr = stack.peek();
16 // 若右子树为空,或者已经访问过一次这个节点,可以弹出
17 if(curr.right == null || set.contains(curr)){
18 res.add(curr.val);
19 set.add(curr);
20 stack.pop();
21 // 若栈此时已经弹空,可以返回结果
22 if(stack.isEmpty()){
23 return res;
24 }
25 curr = stack.peek();
26 curr = curr.right;
27 } else{
28 // 若不然,先向右移动,保存该点到hashset中确认已经访问过一次
29 set.add(curr);
30 curr = curr.right;
31 }
32 }
33 return res;
34 }
35}
上述解法是利用hashset来保存根节点是否已经被越过一次,但是重要的其实只是确认根节点的右子树是否被访问过,所以只需要知道上一个节点是不是其右子树就行,因此保留一个上一个节点的变量即可:
1class Solution {
2 public List<Integer> postorderTraversal(TreeNode root) {
3 List<Integer> res = new ArrayList();
4 Stack<TreeNode> stack = new Stack();
5 TreeNode curr = root;
6 TreeNode pre = null;
7
8 while(curr != null || !stack.isEmpty()){
9 if(curr != null){
10 stack.push(curr);
11 curr = curr.left;
12 } else{
13 TreeNode temp = stack.peek();
14 // 考虑是否变为右子树
15 if(temp.right != null && temp.right != pre){
16 curr = temp.right;
17 } else{
18 res.add(temp.val);
19 pre = temp;
20 stack.pop();
21 }
22 }
23 }
24 return res;
25 }
26}
官方题解的方法则更为巧妙一点,即考虑将相关的内容逆序保存,这样巧妙地规避了根节点不便处理的问题:实现一个右、左、中的保存顺序,实现时还是按中、右、左来进行,保存数字时逆序。
利用了LinkedList特有的addFirst方法,将新出现的数值插入到链表的开头,同事时,由于stack的顺序是先进后出,所以看似是中、左、右,出栈后又是中、右、左,由于保存的顺序是每次加入到链表开头,所以实际上进行了一个逆序,完成了一个左、右、中的过程:
1class Solution {
2 public List<Integer> postorderTraversal(TreeNode root) {
3 LinkedList<TreeNode> stack = new LinkedList<>();
4 LinkedList<Integer> output = new LinkedList<>();
5 if (root == null) {
6 return output;
7 }
8
9 stack.add(root);
10 while (!stack.isEmpty()) {
11 TreeNode node = stack.pollLast();
12 output.addFirst(node.val);
13 if (node.left != null) {
14 stack.add(node.left);
15 }
16 if (node.right != null) {
17 stack.add(node.right);
18 }
19 }
20 return output;
21 }
22}
除了使用那么复杂的办法,也有一种“投机取巧”的办法,就是先做中-右-左的遍历,然后把遍历结果逆序,也就得到了后序遍历结果了。修改我们上面出现过的前序遍历代码也不难得到类似结果:
1public List<Integer> postorderTraversal(TreeNode root){
2 List<Integer> res = new ArrayList<>(); // 用于保存遍历结果
3 Stack<TreeNode> stack = new Stack<>();
4 TreeNode curr = root; // 用于指示当前节点
5 while(curr != null || !stack.isEmpty()){
6 if(curr != null){
7 res.add(curr.val);
8 stack.push(curr);
9 curr = curr.right; // 考虑右子树
10 } else{
11 // 节点为空,弹栈,回溯到上一层
12 curr = stack.pop();
13 // 此时考虑右子树
14 curr = curr.left;
15 }
16 }
17 Collections.reverse(res); // 直接使用内置的逆序,不必在插入时一一进行操作
18 return res;
19}
当然,如果要多做一点优化,可以模仿前一个结果,在添加到res时使用addFirst方法。对于java实现,这里有一个小细节,在声明时,必须使用LinkedList,因为List不提供addFirst方法接口。
经典例题回顾:
讲了基本的定义与三个遍历的递归与非递归写法,其实树的基础知识已经足够了,基本上遇到题目稍做变化就行了,不信?直接上一些看似复杂的题目看看吧。
leetcode250题(后序遍历):统计有多少个子树拥有相同的数字。
Given a binary tree, count the number of uni-value subtrees.
A Uni-value subtree means all nodes of the subtree have the same value.
给定一个二叉树,统计其uni-value子树的个数。
一个uni-value子树指的是这个子树的全部节点的值相同。
一段有问题的代码(对于测试样例[5,1,5,5,5,null,5],正确答案4,输出结果2):
1class Solution {
2 int count = 0;
3 public int countUnivalSubtrees(TreeNode root) {
4 postorder(root); // 从根节点开始遍历
5 return count;
6 }
7
8 public boolean postorder(TreeNode root){
9 if(root == null) return true;
10 if(postorder(root.left) && postorder(root.right)){
11 if(root.left != null && root.left.val != root.val) return false;
12 if(root.right != null && root.right.val != root.val) return false;
13 count++;
14 return true;
15 }
16 return false;
17 }
18}
可通过的代码:
1class Solution {
2 int count = 0;
3 public int countUnivalSubtrees(TreeNode root) {
4 postorder(root);
5 return count;
6 }
7
8 public boolean postorder(TreeNode root){
9 if(root == null) return true;
10 if(postorder(root.left) & postorder(root.right)){
11 if(root.left != null && root.left.val != root.val) return false;
12 if(root.right != null && root.right.val != root.val) return false;
13 count++;
14 return true;
15 }
16 return false;
17 }
18}
在完成这题的过程中,我犯了一些错误,主要在于没有深刻理解递归:
- 在使用判断条件postorder(root.left)以及postorder(root.right)时,实际上已经发生了递归;(所以假定这题是前序遍历或中序遍历会很难完成代码的构造)
- 在java中,使用&&会出现短路,即前半部分已经false了之后,会自动不执行后半部分,因此为了逻辑的正确,此处只能使用&。
leetcode230题:
求在二叉搜索树(BST)中第k小的值,本题主要考虑使用中序遍历,因为二叉搜索树中序遍历是一个递增的数列:
唯一需要注意的就是左子树遍历完成后,有可能还没有达到k,那么可以设定Integer.MAX_VALUE作为没有找到的标志,防止未找到。
这个算法遍历的时间空间复杂度是O(k),最坏的情况下有可能达到O(N)。(在leetcode平台上打败了100%的java程序)
1class Solution {
2 int count = 0;
3 public int kthSmallest(TreeNode root, int k) {
4 if(root.left != null){
5 int res = kthSmallest(root.left, k);
6 if(res != Integer.MAX_VALUE) return res; // 若未能寻找到,应继续向右搜索
7 }
8 count++;
9 if(count == k) return root.val;
10 if(root.right != null) return kthSmallest(root.right, k); // 题目中保证了能搜索到,所以此处不必再加判断
11
12 return Integer.MAX_VALUE; // 若在左侧节点未寻找到,返回这个标志
13 }
14}
相比之下,官方题解则先将全部的节点遍历到一个数组中,然后再寻找第k个,问题就在于将时间复杂度因而扩大到了O(N)。(使用这个方法,时间上只能打败49.01%的程序)
1class Solution {
2 public ArrayList<Integer> inorder(TreeNode root, ArrayList<Integer> arr) {
3 if (root == null) return arr;
4 inorder(root.left, arr);
5 arr.add(root.val);
6 inorder(root.right, arr);
7 return arr;
8 }
9
10 public int kthSmallest(TreeNode root, int k) {
11 ArrayList<Integer> nums = inorder(root, new ArrayList<Integer>());
12 return nums.get(k - 1);
13 }
14}
leetcode366题:
Given a binary tree, collect a tree's nodes as if you were doing this: Collect and remove all leaves, repeat until the tree is empty.
给定一个二叉树,返回一个树的节点的集合的集合,每次都收集并移除当前的所有的叶子节点,直到整个树为空。
这是一个很有趣的题目,因为如果从上向下遍历,是不难的,直接使用BFS(宽度优先搜索),这样的方式又叫“层次遍历”(level-order traversal);然而,从下往上则无这个顺序,左右子树的深度非常有可能不同,而叶子节点只需要左右子树不存在即可。
本题可采用了刚才所谓的“剥洋葱”的办法,因此可以先考虑后序遍历(本题算是一种变体):
1class Solution {
2 public List<List<Integer>> findLeaves(TreeNode root) {
3 List<List<Integer>> res = new ArrayList();
4 while(root != null){
5 List<Integer> curr = new ArrayList();
6 root = upward(root, curr);
7 res.add(curr);
8 }
9 return res;
10 }
11
12 public TreeNode upward(TreeNode root, List<Integer> curr){
13 // 函数的功能是将所有叶子节点保存到curr中,然后把叶子节点置为null
14 if(root == null) return null;
15 if(root.left == null & root.right == null){
16 // 如果是叶子节点,加入curr,然后返回null
17 curr.add(root.val);
18 return null;
19 }
20 // 如果不是叶子节点,递归调用其左右子树,保存新的情况
21 root.left = upward(root.left, curr);
22 root.right = upward(root.right, curr);
23 return root;
24 }
25}
这一题算是使用了一种变体方法,不过本质上还是一种后序遍历。
当然,这题也可以不修改树,直接考虑DFS(深度优先搜索)再回溯,但是就需要引入一个判断深度的指标来进行控制了:
1class Solution {
2 public List<List<Integer>> findLeaves(TreeNode root) {
3 List<List<Integer>> res = new ArrayList();
4 postorder(root,res);
5 return res;
6 }
7
8 public int postorder(TreeNode root, List<List<Integer>> res){
9 if(root == null) return -1; // 空节点置为-1,因为叶子节点是0
10 // 递归调用获取当前深度(准确的说,其实是从叶子节点开始的高度)
11 int depth = 1 + Math.max(postorder(root.left,res),postorder(root.right,res));
12 // 根据java语法,如果还没有list对象,需要先创建
13 if(depth >= res.size()) res.add(new ArrayList());
14 // 将节点加入对应深度,由于递归必然从最深处开始,顺序不会有问题
15 res.get(depth).add(root.val);
16 return depth;
17 }
18}
leetcode285:
Given a binary search tree and a node in it, find the in-order successor of that node in the BST.
The successor of a node
p
is the node with the smallest key greater thanp.val
.给定一个二叉搜索树,并给定一个节点,寻找这个节点的中序遍历的后续节点。
寻找二叉搜索树的中序遍历后继,可以从递归和迭代两种方法完成,由于已经暗示明白了,直接使用中序遍历即可解决这一问题:
递归法依旧有容易阅读的优点,不过构造时不必想太复杂,直接使用前序节点来保存前一个数据即可。(此时已经达到了不错的结果,时间达到了92%)
1class Solution {
2 TreeNode pre = null;
3 TreeNode ans = null;
4 public TreeNode inorderSuccessor(TreeNode root, TreeNode p) {
5 if(root == null || p == null) return null;
6 inorderSuccessor(root.left, p);
7 if(pre == p){ // 条件也可改为 pre != null && pre.val == p.val
8 // 如果其前继节点已经满足条件,则保存结果
9 ans = root;
10 }
11 pre = root;
12 inorderSuccessor(root.right,p);
13 return ans;
14 }
15}
非递归方法:这一方法直接改自中序遍历,不过实际上没有充分利用BST暗含的规律,所以速度不是最快的,仅仅达到了28.88%。
1class Solution {
2 public TreeNode inorderSuccessor(TreeNode root, TreeNode p) {
3 if(curr == null || p == null) return null;
4 Stack<TreeNode> stack = new Stack();
5 TreeNode curr = root;
6 boolean foundP = false;
7 while(root != null || !stack.isEmpty()){
8 if(curr != null){
9 stack.push(curr);
10 curr = curr.left;
11 } else{
12 curr = stack.pop();
13 if(foundP == true){
14 return curr;
15 }
16 if(curr.val == p.val){
17 foundP = true;
18 }
19 curr = curr.right;
20 }
21 }
22 return null;
23 }
24}
不得不说,使用遍历来解决树问题,实在是颇有“一招鲜吃遍天”的感觉,甚至略加改进就能解决hard级的leetcode99题。
leetcode99题:
Two elements of a binary search tree (BST) are swapped by mistake.
Recover the tree without changing its structure.
一个二叉搜索树的两个元素被交换了。
复原这个树,使得它符合BST结构,但不改变树的整体结构。
这题就是恢复二叉搜索树的问题。看到BST,几乎是一个中序遍历的问题。本题需要注意的地方就是,被交换的节点存在两种可能性,一种是相邻节点的顺序反了,那么,直接交换值即可,另外一种是相隔的节点顺序错了,因此还需要保存第二次发生前序节点大于当前节点的位置。(以下主要由中序遍历模板改出)
非递归:
1class Solution {
2 public void recoverTree(TreeNode root) {
3 Stack<TreeNode> stack = new Stack();
4 TreeNode prev = null, curr = root;
5 TreeNode first = null, second = null; // 用于保存改变的第一个和第二个位置
6
7 while(curr != null || !stack.isEmpty()){
8 if(curr != null){
9 stack.push(curr);
10 curr = curr.left;
11 } else{
12 curr = stack.pop();
13 // 如果发生了前序节点大于当前节点,分两种情况
14 if(prev != null && prev.val > curr.val){
15 // 如果是第一次遇到,保存first为前序,second为当前
16 if(first == null){
17 first = prev;
18 second = curr;
19 } else{
20 // 如果第二次遇到,只重新保存当前值到second中
21 second = curr;
22 }
23 }
24 prev = curr;
25 curr = curr.right;
26 }
27 }
28 int temp = first.val;
29 first.val = second.val;
30 second.val = temp;
31 }
32}
递归:
1class Solution {
2 TreeNode first = null;
3 TreeNode second = null;
4 TreeNode prev = null;
5 public void recoverTree(TreeNode root) {
6 inorder(root);
7 int temp = first.val;
8 first.val = second.val;
9 second.val = temp;
10 }
11
12 public void inorder(TreeNode root){
13 if(root == null) return;
14 inorder(root.left);
15 // 对前序节点进行判断
16 if(prev != null && prev.val > root.val){
17 // 第一次遇到则全部保存
18 if(first == null){
19 first = prev;
20 second = root;
21 } else{
22 // 第二次遇到只保存second
23 second = root;
24 }
25 }
26 // 更新prev节点
27 prev = root;
28 inorder(root.right);
29 }
30}
简要总结
树的题目大多来自三种遍历的变体,一般而言,稍作一些修改就能得到一个能运行的结果(实在不行先全部遍历一遍保存到数组里来解决);不过,为了得到最优解,往往还需要根据题目已有的一些条件或者性质,对递归进行一些优化(如结束递归的条件,递归的方式等等);有时递归不便于进行一些细节操作时,可以考虑用非递归写法(当然,由于递归写法解题有时太轻松,出于面试难度问题,面试官也往往会要求非递归,也算是为学习非递归多一个理由吧!)。