1. 树的层次遍历与相关面试题
1.1 层次遍历简介
广度优先在面试里出现的频率非常高,但是相对简单,题目也比较少,常见的题目也就七八道。
广度优先又叫层次遍历,基本过程如下:
层次遍历就是从根节点开始,先访问根节点下面一层全部元素,再访问之后的层次,类似金字塔一样一层层访问。上面的图示按照层次访问的结果就是:[1,2,3,4,5,6,7]
我们可以看到这里就是从左到右一层一层的去遍历二叉树,先访问1,之后访问1的左右子孩子2和3,之后分别访问2和3的左右子孩子[4,5]和[6,7]。
由此我们发现如果使用队列来存储的话,访问某一层的时候就将该层的元素全部入队,某个元素出队的时候,就将该元素的左右子节点分别入队,就能保证完美访问所有元素。例如上面的图中:
- 首先1入队。
- 然后1出队,之后将1的左右子孩子2和3入队。
- 之后2出队,将2的左右子孩子4和5入队。
- 之后3出队,将3的左右子孩子6和7入队。
- 之后4,5,6,7分别出队,因为都没有子孩子了,所以都只出队就行了。
该过程不复杂,我们再来看一下,如果能将层次分开了,那我们能整出什么花样?
首先,我能否将每层的顺序反转一下呢?那这就是一棵树的镜像问题了。那能否奇数行不变,偶数行反转呢?那就是锯齿状输出元素了。我能否将输出层次从低到root逐层输出呢?那这就是层的反转问题了。
再来,既然能拿到每一层的元素了,我能否找到当前层最大的元素?最小的元素?最右的元素(右视图)?最左的元素(左视图)?整个层的平均值?
很明显都可以,但是这么折腾有啥用呢?没啥用,但是如果告诉你这几种情况就是层次遍历的常见算法题,你还觉得没用吗?如果告诉你上述几个折腾就是下面的LeetCode题,你还觉得没用吗?如果告诉你研究清楚如何将层次分开,这些问题就不用做了,你还觉得没用吗?
LeetCode 102.二叉树的层序遍历
LeetCode 107.二叉树的层次遍历II
LeetCode 199.二叉树的右视图
LeetCode 637.二叉树的层平均值
LeetCode 429.N叉树的前序遍历
LeetCode 515.在每个树行中找最大值
LeetCode 116.填充每个节点的下一个右侧节点指针
LeetCode 117.填充每个节点的下一个右侧节点指针II
LeetCode 103.锯齿层序遍历
1.2 基本的层序遍历与变换
我们先看最简单的情况,仅仅遍历并输出一遍,不考虑分层的问题。基本的层次遍历方法如下,这个代码很容易理解,就是先访问根节点,然后将其左右子孩子放到队列里,接着继续出队,出来的元素都将其左右自孩子放到队列里知道队列为空了就退出。
/**
*树结构如下
* 3
/ \
9 20
/ \
15 7
* 应输出结果 [3, 9, 20, 15, 7]
*/
public static List<Integer> simpleLevelOrder(TreeNode root) {
if (root == null) {
return new ArrayList<Integer>();
}
List<Integer> res = new ArrayList<Integer>();
LinkedList<TreeNode> queue = new LinkedList<TreeNode>();
//将根节点放入队列中,然后不断遍历队列
queue.add(root);
//有多少元素执行多少次
while (queue.size() > 0) {
//获取当前队列的长度,这个长度相当于当前这一层的节点个数
TreeNode t = queue.remove();
res.add(t.val);
if (t.left != null) {
queue.add(t.left);
}
if (t.right != null) {
queue.add(t.right);
}
}
return res;
}
根据树的结构可以看到,一个结点在一层访问之后,其子孩子都是在下层按照FIFO的顺序才处理的,因此队列就是一个缓存的作用。
我们继续加码?我们该如何将每层的元素分开呢?请看一下题。
1.2.1 LeetCode102 二叉树的层序遍历
题目要求:给你一个二叉树,请你返回其按层序遍历得到的节点值。(即逐层地,从左到右访问所有节点)。
示例1:
二叉树:[3,9,20,null,null,15,7]
3
/ \
9 20
/ \
15 7
输出:[[3],[9,20],[15,7]]
示例 2:
输入:root = [1]
输出:[[1]]
示例 3:
输入:root = []
输出:[]
广度优先需要用队列作为辅助结构,我们先将根节点放到队列中,然后不断遍历队列。这里的问题是如何判断某一层访问完了呢?很简单,用一个变量size就完了,size表示某一层的元素个数,只要出队,就将size减1,见到0就说明该层元素访问完了。
很容易想到,size变成0之后,就该为其设置下一层的元素个数。那问题又来了,size该怎么知道每层的元素是多少呢?为了更清晰,我们增加几个结点,然后看一下执行的过程图:
从图中可以看出,首先拿出根节点,如果左子树/右子树不为空,就将他们放入队列中。处理完后,根节点已经从队列中拿走了,而根节点的两个孩子已放入队列中了,现在队列中就有两个节点9和20。恰好就是第二层的所有结点。
继续,我们将9从队列中拿走,并将其子孩子8和13入队。之后再将20出队,并将其子孩子15和7入队,这是我们发现当第二层的结点访问完的时候,队列的里的元素恰好都是第三层的元素。
综上,我们可以得到结论:当某一层元素访问完毕(size–一直到0就表示该层访问完),当size变成0时,只要让size重新等于队列元素的个数(size = queue.size())就行了。
最后,我们把每层遍历到的节点都放入到一个结果集中,最后返回这个结果集就可以了。代码如下:
class Solution {
public List<List<Integer>> level102Order(TreeNode root) {
if(root==null) {
return new ArrayList<List<Integer>>();
}
List<List<Integer>> res = new ArrayList<List<Integer>>();
LinkedList<TreeNode> queue = new LinkedList<TreeNode>();
//将根节点放入队列中,然后不断遍历队列
queue.add(root);
while(queue.size()>0) {
//获取当前队列的长度,这个长度相当于当前这一层的节点个数
int size = queue.size();
ArrayList<Integer> tmp = new ArrayList<Integer>();
//将队列中的元素都拿出来(也就是获取这一层的节点),放到临时list中
//如果节点的左/右子树不为空,也放入队列中
for(int i=0;i<size;++i) {
TreeNode t = queue.remove();
tmp.add(t.val);
if(t.left!=null) {
queue.add(t.left);
}
if(t.right!=null) {
queue.add(t.right);
}
}
//将临时list加入最终返回结果中
res.add(tmp);
}
return res;
}
}
上面的代码是本章最重要的算法之一,也是整个算法体系的核心算法之一,与链表反转、二分查找属于同一个级别,务必认真学习!理解透彻,然后记住!
上面的算法理解了,那接下来一些列的问题就轻松搞定了。
1.2.2 LeetCode 107 层序遍历-自底向上
题目要求:给定一个二叉树,返回其节点值自底向上的层序遍历。(即按从叶子节点所在层到根节点所在的层,逐层从左向右遍历)。
例如给定的二叉树为:
3
/ \
9 20
/ \
15 7
返回结果为:
[[15,7],[9,20],[3]]
如果要求从上到下输出每一层的节点值,做法是很直观的,在遍历完一层节点之后,将存储该层节点值的列表添加到结果列表的尾部。这道题要求从下到上输出每一层的节点值,只要对上述操作稍作修改即可,在遍历完一层节点之后,将存储该层节点值的列表添加到结果列表的头部。
为了降低在结果列表的头部添加一层节点值的列表的时间复杂度,结果列表可以使用链表的结构,在链表头部添加一层节点值的列表的时间复杂度是O(1)。在Java中,由于我们需要返回的List是一个接口,这里可以使用链表实现。
class Solution {
public List<List<Integer>> levelOrderBottom(TreeNode root) {
List<List<Integer>> levelOrder = new LinkedList<List<Integer>>();
if (root == null) {
return levelOrder;
}
Queue<TreeNode> queue = new LinkedList<TreeNode>();
queue.offer(root);
while (!queue.isEmpty()) {
List<Integer> level = new ArrayList<Integer>();
int size = queue.size();
for (int i = 0; i < size; i++) {
TreeNode node = queue.poll();
level.add(node.val);
TreeNode left = node.left, right = node.right;
if (left != null) {
queue.offer(left);
}
if (right != null) {
queue.offer(right);
}
}
levelOrder.add(0, level);
}
return levelOrder;
}
}
1.2.3 LeetCode103 二叉树的锯齿形层序遍历
这个题的要求是:给定一个二叉树,返回其节点值的锯齿形层序遍历。(即先从左往右,再从右往左进行下一层遍
历,以此类推,层与层之间交替进行)。
例如给定的二叉树为:
3
/ \
9 20
/ \
15 7
返回结果为:
[[3],[20,9],[15,7]]
这个题也是102的变种,只是最后输出的要求有所变化,要求我们按层数的奇偶来决定每一层的输出顺序。规定二叉树的根节点为第 0层,如果当前层数是偶数,从左至右输出当前层的节点值,否则,从右至左输出当前层的节点 值。
我们依然可以沿用第 102 题的思想,修改广度优先搜索,对树进行逐层遍历,用队列维护当前层的所有元素,当队列不为空的时候,求得当前队列的长度 size,每次从队列中取出 size 个元素进行拓展,然后进行下一次迭代。
为了满足题目要求的返回值为「先从左往右,再从右往左」交替输出的锯齿形,我们可以利用「双端队列」的数据结构来维护当前层节点值输出的顺序。
双端队列是一个可以在队列任意一端插入元素的队列。在广度优先搜索遍历当前层节点拓展下一层节点的时候我们仍然从左往右按顺序拓展,但是对当前层节点的存储我们维护一个变量 isOrderLeft 记录是从左至右还是从右至左的:
如果从左至右,我们每次将被遍历到的元素插入至双端队列的末尾。 如果从右至左,我们每次将被遍历到的元素插入至双端队列的头部。
class Solution {
public List<List<Integer>> zigzagLevelOrder(TreeNode root) {
List<List<Integer>> ans = new LinkedList<List<Integer>>();
if (root == null) {
return ans;
}
Queue<TreeNode> nodeQueue = new ArrayDeque<TreeNode>();
nodeQueue.offer(root);
boolean isOrderLeft = true;
while (!nodeQueue.isEmpty()) {
Deque<Integer> levelList = new LinkedList<Integer>();
int size = nodeQueue.size();
for (int i = 0; i < size; ++i) {
TreeNode curNode = nodeQueue.poll();
if (isOrderLeft) {
levelList.offerLast(curNode.val);
} else {
levelList.offerFirst(curNode.val);
}
if (curNode.left != null) {
nodeQueue.offer(curNode.left);
}
if (curNode.right != null) {
nodeQueue.offer(curNode.right);
}
}
ans.add(new LinkedList<Integer>(levelList));
isOrderLeft = !isOrderLeft;
}
return ans;
}
}
1.2.4 LeetCode429 N 叉树的层序遍历
给定一个 N 叉树,返回其节点值的层序遍历。(即从左到右,逐层遍历)。
树的序列化输入是用层序遍历,每组子节点都由 null 值分隔(参见示例)。
示例 1:
输入:root = [1,null,3,2,4,null,5,6]
输出:[[1],[3,2,4],[5,6]]
示例 2:
输入:root = [1,null,2,3,4,5,null,null,6,7,null,8,null,9,10,null,null,11,null,12,null,13,null,null,14]
输出:[[1],[2,3,4,5],[6,7,8,9,10],[11,12,13],[14]]
N叉树的定义为:
class Node {
public int val;
public List<Node> children;
public Node() {}
public Node(int _val) {
val = _val;
}
public Node(int _val, List<Node> _children) {
val = _val;
children = _children;
}
}
这个也是102的扩展,很简单的广度优先,与二叉树的层序遍历基本一样,借助队列即可实现。
class Solution {
public List<List<Integer>> levelOrder(Node root) {
if (root == null) {
return new ArrayList<List<Integer>>();
}
List<List<Integer>> ans = new ArrayList<List<Integer>>();
Queue<Node> queue = new ArrayDeque<Node>();
queue.offer(root);
while (!queue.isEmpty()) {
int cnt = queue.size();
List<Integer> level = new ArrayList<Integer>();
for (int i = 0; i < cnt; ++i) {
Node cur = queue.poll();
level.add(cur.val);
for (Node child : cur.children) {
queue.offer(child);
}
}
ans.add(level);
}
return ans;
}
}
1.3 几个处理每层元素的题目
如果我们拿到了每一层的元素,那是不是可以利用一下造几个题呢?例如每层找最大值、平均值、最右侧的值呢? 当然可以。 LeetCode里就有三道非常明显的题目。
LeetCode 515.在每个树行中找最大值(最小)
LeetCode 637.二叉树的层平均值
LeetCode 199.二叉树的右视图
既然能这么干,为啥我们自己不能造几个题:求每层最小值可以不?求每层最左侧的可以不?我们是不是可以给LeetCode贡献几道题了?
1.3.1 LeetCode 515 在每个树行中找最大值
题目要求:给定一棵二叉树的根节点 root ,请找出该二叉树中每一层的最大值。
示例1:
1
/ \
3 2
/ \ \
5 3 9
输入: root = [1,3,2,5,3,null,9]
输出: [1,3,9]
示例2:
输入: root = [1,2,3]
输出: [1,3]
这里其实就是在得到一层之后使用一个变量来记录当前得到的最大值:
class Solution {
public List<Integer> largestValues(TreeNode root) {
if (root == null) {
return new ArrayList<Integer>();
}
List<Integer> res = new ArrayList<Integer>();
dfs(res, root, 0);
return res;
}
public void dfs(List<Integer> res, TreeNode root, int curHeight) {
if (curHeight == res.size()) {
res.add(root.val);
} else {
res.set(curHeight, Math.max(res.get(curHeight), root.val));
}
if (root.left != null) {
dfs(res, root.left, curHeight + 1);
}
if (root.right != null) {
dfs(res, root.right, curHeight + 1);
}
}
}
1.3.2 LeetCode 637 在每个树行中找平均值
给定一个非空二叉树的根节点 root , 以数组的形式返回每一层节点的平均值。与实际答案相差 10-5 以内的答案可以被接受。
示例 1:
3
/ \
9 20
/ \
15 7
输入:root = [3,9,20,null,null,15,7]
输出:[3,14.5,11]
解释:第 0 层的平均值为 3,第 1 层的平均值为 14.5,第 2 层的平均值为 11 。
因此返回 [3, 14.5, 11] 。
这个题和前面的几个一样,只不过是每层都先将元素保存下来,最后求平均就行了:
class Solution {
public List<Double> averageOfLevels(TreeNode root) {
List<Double> res = new ArrayList<>();
if (root == null) return res;
Queue<TreeNode> list = new LinkedList<>();
list.add(root);
while (list.size() != 0){
int len = list.size();
double sum = 0;
for (int i = 0; i < len; i++){
TreeNode node = list.poll();
sum += node.val;
if (node.left != null)
list.add(node.left);
if (node.right != null)
list.add(node.right);
}
res.add(sum/len);
}
return res;
}
}
1.3.3 LeetCode 199 二叉树的右视图
题目要求是:给定一个二叉树的 根节点 root,想象自己站在它的右侧,按照从顶部到底部的顺序,返回从右侧所能看到的节点值。
输入: [1,2,3,null,5,null,4]
输出: [1,3,4]
class Solution {
public List<Integer> rightSideView(TreeNode root) {
List<Integer> res = new ArrayList<>();
if (root == null) {
return res;
}
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
while (!queue.isEmpty()) {
int size = queue.size();
for (int i = 0; i < size; i++) {
TreeNode node = queue.poll();
if (node.left != null) {
queue.offer(node.left);
}
if (node.right != null) {
queue.offer(node.right);
}
//将当前层的最后一个节点放入结果列表
if (i == size - 1) {
res.add(node.val);
}
}
}
return res;
}
}
是不是很简单,轻轻松松搞定3道题,而这三个题本质都是层次遍历的调整。
1.3.4 最底层最左边的值
给定一个二叉树的根节点root,请找出该二叉树的最底层最左边节点的值。
假设二叉树中至少有一个节点。
示例1:
2
/ \
1 3
输入 : root = [2,1,3]
输出 : 1
示例2:
1
/ \
2 3
/ / \
4 5 6
/
7
输入 : [1,2,3,4,null,5,6,null,null,7]
输出 : 7
很明显,找最后一行第一个,使用层次遍历最方便了。层次遍历,我们前面分析很多了,这里直接上代码:
class Solution {
public int findBottomLeftValue(TreeNode root) {
if (root.left == null && root.right == null) {
return root.val;
}
Queue<TreeNode> queue = new LinkedList<>();
queue.offer(root);
TreeNode temp = new TreeNode(-100);
while (!queue.isEmpty()) {
temp = queue.poll();
if (temp.right != null) {
// 先把右节点加入 queue
queue.offer(temp.right);
}
if (temp.left != null) {
// 再把左节点加入 queue
queue.offer(temp.left);
}
}
return temp.val;
}
}
如果使用深度优先可以吗?也可以,难点在于如何判断是最后一行呢,其实就是深度最大的叶子节点一定是最后一行,所以要找深度最大的叶子节点。
那么如果找最左边的呢?可以使用前序遍历,这样才先优先左边搜索,然后记录深度最大的叶子节点,此时就是树的最后一行最左边的值。不过这个想想就感觉复杂。
由此可见,很多题目可以使用多种方式进行,但是难易程度会有很大差异,所以我们将每一道题都想想有几种方式做,哪种简单哪种复杂,心里有数再下笔写。
1.5 我们也来造题
前面我们看到LeetCode里有右视图,那能否再来个俯视图呢?从上向下看,不就是每一层最左和最右吗?这里其实有问题的,直接取每层最左和最右是不行的。如果我们这里加个条件,完全二叉树行吗?貌似也不行,如果是满 二叉树呢?貌似用不上层次也能解决,所以这个题不适合考试,但是练习了我们的思维。
如果将要求换成左视图呢?请读者自行思考。