二、二叉树(纲领篇)
参考链接东哥带你刷二叉树(纲领篇) | labuladong 的算法笔记
先在开头总结一下,二叉树解题的思维模式分两类:
1、是否可以通过遍历一遍二叉树得到答案?
如果可以,用一个 traverse
函数配合外部变量来实现,这叫「遍历」的思维模式。
2、是否可以定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案?
如果可以,写出这个递归函数的定义,并充分利用这个函数的返回值,这叫「分解问题」的思维模式。
无论使用哪种思维模式,你都需要思考:
如果单独抽出一个二叉树节点,它需要做什么事情?需要在什么时候(前/中/后序位置)做?其他的节点不用你操心,递归函数会帮你在所有节点上执行相同的操作。
1.二叉树的重要性
举个例子,比如两个经典排序算法 快速排序 和 归并排序,对于它俩,你有什么理解?
如果你告诉我,快速排序就是个二叉树的前序遍历,归并排序就是个二叉树的后序遍历,那么我就知道你是个算法高手了。
…
如果你一眼就识破这些排序算法的底细,还需要背这些经典算法吗?不需要。你可以手到擒来,从二叉树遍历框架就能扩展出算法了。
说了这么多,旨在说明,二叉树的算法思想的运用广泛,甚至可以说,只要涉及递归,都可以抽象成二叉树的问题。
2.深入理解前中后序
根据几个问题引发思考
1、你理解的二叉树的前中后序遍历是什么,仅仅是三个顺序不同的 List 吗?
2、请分析,后序遍历有什么特殊之处?
3、请分析,为什么多叉树没有中序遍历?
鄙人是肯定答不上来的
void traverse(TreeNode root) {
if (root == null) {
return;
}
// 前序位置
traverse(root.left);
// 中序位置
traverse(root.right);
// 后序位置
}
你也注意到了,只要是递归形式的遍历,都可以有前序位置和后序位置,分别在递归之前和递归之后。
所谓前序位置,就是刚进入一个节点(元素)的时候,后序位置就是即将离开一个节点(元素)的时候,那么进一步,你把代码写在不同位置,代码执行的时机也不同:
比如说,如果让你倒序打印一条单链表上所有节点的值,你怎么搞?
实现方式当然有很多,但如果你对递归的理解足够透彻,可以利用后序位置来操作
/* 递归遍历单链表,倒序打印链表元素 */
void traverse(ListNode head) {
if (head == null) {
return;
}
traverse(head.next);
// 后序位置
print(head.val);
}
教科书里只会问你前中后序遍历结果分别是什么,所以对于一个只上过大学数据结构课程的人来说,他大概以为二叉树的前中后序只不过对应三种顺序不同的 List<Integer>
列表。
但是我想说,前中后序是遍历二叉树过程中处理每一个节点的三个特殊时间点,绝不仅仅是三个顺序不同的 List:
- 前序位置的代码在刚刚进入一个二叉树节点的时候执行;
- 后序位置的代码在将要离开一个二叉树节点的时候执行;
- 中序位置的代码在一个二叉树节点左子树都遍历完,即将开始遍历右子树的时候执行。
这里你也可以理解为什么多叉树没有中序位置,因为二叉树的每个节点只会进行唯一一次左子树切换右子树,而多叉树节点可能有很多子节点,会多次切换子树去遍历,所以多叉树节点没有「唯一」的中序遍历位置。
重点1
二叉树的所有问题,就是让你在前中后序位置注入巧妙的代码逻辑,去达到自己的目的,你只需要单独思考每一个节点应该做什么,其他的不用你管,抛给二叉树遍历框架,递归会在所有节点上做相同的操作。
3.两种解题思路
二叉树题目的递归解法可以分两类思路,第一类是遍历一遍二叉树得出答案,第二类是通过分解问题计算出答案,这两类思路分别对应着 回溯算法核心框架 和 动态规划核心框架。
4.后序位置的特殊之处
中序位置主要用在 BST 场景中,你完全可以把 BST 的中序遍历认为是遍历有序数组。
前序位置本身其实没有什么特别的性质,之所以你发现好像很多题都是在前序位置写代码,实际上是因为我们习惯把那些对前中后序位置不敏感的代码写在前序位置罢了。
你可以发现,前序位置的代码执行是自顶向下的,而后序位置的代码执行是自底向上的:
重点2
但这里面大有玄妙,意味着前序位置的代码只能从函数参数中获取父节点传递来的数据,而后序位置的代码不仅可以获取参数数据,还可以获取到子树通过函数返回值传递回来的数据。
举具体的例子,现在给你一棵二叉树,我问你两个简单的问题:
1、如果把根节点看做第 1 层,如何打印出每一个节点所在的层数?
2、如何打印出每个节点的左右子树各有多少节点?
第一个问题从根节点就能给出答案,而第二个问题必须遍历完子树之后才能给出答案
结合这两个简单的问题,你品味一下后序位置的特点,只有后序位置才能通过返回值获取子树的信息。
那么换句话说,一旦你发现题目和子树有关,那大概率要给函数设置合理的定义和返回值,在后序位置写代码了。
例题lc543题 二叉树的直径
5.以树的视角看 动归/回溯/DFS算法的区别和联系
DFS 算法和回溯算法非常类似,只是在细节上有所区别。
这个细节上的差别是什么呢?其实就是「做选择」和「撤销选择」到底在 for 循环外面还是里面的区别,DFS 算法在外面,回溯算法在里面。
为什么有这个区别?还是要结合着二叉树理解。这一部分我就把回溯算法、DFS 算法、动态规划三种经典的算法思想,以及它们和二叉树算法的联系和区别,用一句话来说明:
动归/DFS/回溯算法都可以看做二叉树问题的扩展,只是它们的关注点不同:
- 动态规划算法属于分解问题的思路,它的关注点在整棵「子树」。
- 回溯算法属于遍历的思路,它的关注点在节点间的「树枝」。
- DFS 算法属于遍历的思路,它的关注点在单个「节点」。
三个例子解释三种情况
1.计算一棵二叉树有多少个节点?
// 定义:输入一棵二叉树,返回这棵二叉树的节点总数
int count(TreeNode root) {
if (root == null) {
return 0;
}
// 我这个节点关心的是我的两个子树的节点总数分别是多少
int leftCount = count(root.left);
int rightCount = count(root.right);
// 后序位置,左右子树节点数加上自己就是整棵树的节点数
return leftCount + rightCount + 1;
}
你看,这就是动态规划分解问题的思路,它的着眼点永远是结构相同的整个子问题,类比到二叉树上就是「子树」。
你再看看具体的动态规划问题,比如 动态规划框架套路详解 中举的斐波那契的例子,我们的关注点在一棵棵子树的返回值上:
2.使用遍历的思路写一个traverse函数,打印出遍历这棵二叉树的过程
…
回溯算法遍历的思路,它的着眼点永远是在节点之间移动的过程,类比到二叉树上就是[树枝]
3.把二叉树的每个节点值都+1
void traverse(TreeNode root) {
if (root == null) return;
// 遍历过的每个节点的值加一
root.val++;
traverse(root.left);
traverse(root.right);
}
你看,这就是 DFS 算法遍历的思路,它的着眼点永远是在单一的节点上,类比到二叉树上就是处理每个「节点」。
你再看看具体的 DFS 算法问题,比如 一文秒杀所有岛屿题目 中讲的前几道题,我们的关注点是 grid
数组的每个格子(节点),我们要对遍历过的格子进行一些处理,所以我说是用 DFS 算法解决这几道题的:
有了这些铺垫,你就很容易理解为什么回溯算法和 DFS 算法代码中「做选择」和「撤销选择」的位置不同了,看下面两段代码:
// DFS 算法把「做选择」「撤销选择」的逻辑放在 for 循环外面
void dfs(Node root) {
if (root == null) return;
// 做选择
print("我已经进入节点 %s 啦", root)
for (Node child : root.children) {
dfs(child);
}
// 撤销选择
print("我将要离开节点 %s 啦", root)
}
// 回溯算法把「做选择」「撤销选择」的逻辑放在 for 循环里面
void backtrack(Node root) {
if (root == null) return;
for (Node child : root.children) {
// 做选择
print("我站在节点 %s 到节点 %s 的树枝上", root, child)
backtrack(child);
// 撤销选择
print("我将要离开节点 %s 到节点 %s 的树枝上", child, root)
}
}
看到了吧,你回溯算法必须把「做选择」和「撤销选择」的逻辑放在 for 循环里面,否则怎么拿到「树枝」的两个端点?
6.层序遍历(简单过一下)
二叉树题型主要是用来培养递归思维的,而层序遍历属于迭代遍历,也比较简单,这里就过一下代码框架吧:
// 输入一棵二叉树的根节点,层序遍历这棵二叉树
void levelTraverse(TreeNode root) {
if (root == null) return;
Queue<TreeNode> q = new LinkedList<>();
q.offer(root);
// 从上到下遍历二叉树的每一层
while (!q.isEmpty()) {
int sz = q.size();
// 从左到右遍历每一层的每个节点
for (int i = 0; i < sz; i++) {
TreeNode cur = q.poll();
// 将下一层节点放入队列
if (cur.left != null) {
q.offer(cur.left);
}
if (cur.right != null) {
q.offer(cur.right);
}
}
}
}
437.路径总和3
前缀和技巧 + 前序后序遍历 + 二叉树
class Solution {
// 记录前缀和
// 定义:从二叉树的根节点开始,路径和为 pathSum 的路径有 preSumCount.get(pathSum) 个
HashMap<Integer, Integer> preSumCount = new HashMap<>();
int pathSum, targetSum;
int res = 0;
public int pathSum(TreeNode root, int targetSum) {
if (root == null) {
return 0;
}
this.pathSum = 0;
this.targetSum = targetSum;
this.preSumCount.put(0, 1);
traverse(root);
return res;
}
void traverse(TreeNode root) {
if (root == null) {
return;
}
// 前序遍历位置
pathSum += root.val;
// 从二叉树的根节点开始,路径和为 pathSum - targetSum 的路径条数
// 就是路径和为 targetSum 的路径条数
res += preSumCount.getOrDefault(pathSum - targetSum, 0);
// 记录从二叉树的根节点开始,路径和为 pathSum 的路径条数
preSumCount.put(pathSum, preSumCount.getOrDefault(pathSum, 0) + 1);
traverse(root.left);
traverse(root.right);
// 后序遍历位置
preSumCount.put(pathSum, preSumCount.get(pathSum) - 1);
pathSum -= root.val;
}
}
单调栈模板
1.下一个元素1 496
class Solution {
public int[] nextGreaterElement(int[] nums1, int[] nums2) {
// 1.单调栈,倒序输入相当于正序输出,
// 2.抽象思想,将数值抽象成 “身高”,身高高的将身高矮的挡住
// for循环从后向前扫描元素,while循环把两个 "个字高" 之间的元素排除
// 算法时间复杂度为O(n),每个元素都被push入栈,而最多会被pop一次,也就是O(n)
// 记录nums2中每个元素的下一个更大元素
int[] greater = nextGreaterElement1(nums2);
//转化成映射,元素 x -> x的下一个更大元素
HashMap<Integer, Integer> greaterMap = new HashMap<>();
for(int i = 0; i < nums2.length; i++){
// 注意一个重点,nums1 & nums2 中的元素都是不重复的,才能使用这个方法
greaterMap.put(nums2[i],greater[i]);
}
//nums1是nums2的子集, 所以根据greatMap可以得到结果
int[] res = new int[nums1.length];
for(int i = 0; i < nums1.length; i++){
res[i] = greaterMap.get(nums1[i]);
}
return res;
}
int[] nextGreaterElement1(int[] nums){
int n = nums.length;
//存放答案的数组
int[] res = new int[n];
Stack<Integer> s = new Stack<>();
//倒着往栈里放
for(int i = n - 1; i >= 0; i--){
//判定个字高矮
while(!s.isEmpty() && s.peek() <= nums[i]){
//矮个 小于 当前的值,那么下一个最大的值就不是它
//pop() 之后继续循环,找有没有比当前值大的元素
s.pop();
}
//nums[i],在当前元素之后的元素都比当前元素小,那么返回-1
res[i] = s.isEmpty() ? -1 : s.peek();
s.push(nums[i]);
}
return res;
}
}
2.下一个元素2 503
class Solution {
public int[] nextGreaterElements(int[] nums) {
int n = nums.length;
int[] res = new int[n];
Stack<Integer> s = new Stack<>();
// 数组长度加倍模拟环形数组
// n - 1 - 0 + 1 = n个元素, 改成 2n - 1 - 0 + 1 = 2n个元素
// 其实这里改成 2n - 2也行,就少复制了一个数组尾部元素,不影响 n - 1之前索引元素的计算
// n - 1 的索引计算 也只依靠 复制后的 0 ~ n-2索引的元素
for (int i = 2 * n - 1; i >= 0; i--) {
// 索引 i 要求模,其他的和模板一样
while (!s.isEmpty() && s.peek() <= nums[i % n]) {
s.pop();
}
res[i % n] = s.isEmpty() ? -1 : s.peek();
s.push(nums[i % n]);
}
return res;
}
}
3.每日温度 739
class Solution {
public int[] dailyTemperatures(int[] temperatures) {
int n = temperatures.length;
int[] res = new int[n];
// 这里放元素索引,而不是元素
Stack<Integer> s = new Stack<>();
/* 单调栈模板 */
for (int i = n - 1; i >= 0; i--) {
while (!s.isEmpty() && temperatures[s.peek()] <= temperatures[i]) {
s.pop();
}
// 得到索引间距
res[i] = s.isEmpty() ? 0 : (s.peek() - i);
// 将索引入栈,而不是元素
s.push(i);
}
return res;
}
}