二叉树(纲领篇)
二叉树(纲领篇)
二叉树解题的思维模式分两类:
1、是否可以通过遍历一遍二叉树得到答案?如果可以,用一个 traverse
函数配合外部变量来实现,这叫「遍历」的思维模式。
2、是否可以定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案?如果可以,写出这个递归函数的定义,并充分利用这个函数的返回值,这叫「分解问题」的思维模式。
无论使用哪种思维模式,你都需要思考:
如果单独抽出一个二叉树节点,它需要做什么事情?需要在什么时候(前/中/后序位置)做?
二叉树的重要性
快速排序就是个二叉树的前序遍历,归并排序就是个二叉树的后序遍历。
为什么快速排序和归并排序能和二叉树扯上关系?我们来简单分析一下他们的算法思想和代码框架:
快速排序的逻辑是,若要对 nums[lo..hi]
进行排序,我们先找一个分界点 p
,通过交换元素使得 nums[lo..p-1]
都小于等于 nums[p]
,且 nums[p+1..hi]
都大于 nums[p]
,然后递归地去 nums[lo..p-1]
和 nums[p+1..hi]
中寻找新的分界点,最后整个数组就被排序了。
快速排序的代码框架如下:
void sort(int[] nums, int lo, int hi) {
/****** 前序遍历位置 ******/
// 通过交换元素构建分界点 p
int p = partition(nums, lo, hi);
/************************/
sort(nums, lo, p - 1);
sort(nums, p + 1, hi);
}
先构造分界点,然后去左右子数组构造分界点,你看这不就是一个二叉树的前序遍历吗?
再说说归并排序的逻辑,若要对 nums[lo..hi]
进行排序,我们先对 nums[lo..mid]
排序,再对 nums[mid+1..hi]
排序,最后把这两个有序的子数组合并,整个数组就排好序了。
归并排序的代码框架如下:
// 定义:排序 nums[lo..hi]
void sort(int[] nums, int lo, int hi) {
int mid = (lo + hi) / 2;
// 排序 nums[lo..mid]
sort(nums, lo, mid);
// 排序 nums[mid+1..hi]
sort(nums, mid + 1, hi);
/****** 后序位置 ******/
// 合并 nums[lo..mid] 和 nums[mid+1..hi]
merge(nums, lo, mid, hi);
/*********************/
}
先对左右子数组排序,然后合并(类似合并有序链表的逻辑),这是二叉树的后序遍历框架
深入理解前中后序
二叉树遍历框架:
void traverse(TreeNode root) {
if (root == null) {
return;
}
// 前序位置
traverse(root.left);
// 中序位置
traverse(root.right);
// 后序位置
}
先不管所谓前中后序,单看 traverse
函数,你说它在做什么事情?
其实它就是一个能够遍历二叉树所有节点的一个函数,和你遍历数组或者链表本质上没有区别:
/* 迭代遍历数组 */
void traverse(int[] arr) {
for (int i = 0; i < arr.length; i++) {
}
}
/* 递归遍历数组 */
void traverse(int[] arr, int i) {
if (i == arr.length) {
return;
}
// 前序位置
traverse(arr, i + 1);
// 后序位置
}
/* 迭代遍历单链表 */
void traverse(ListNode head) {
for (ListNode p = head; p != null; p = p.next) {
}
}
/* 递归遍历单链表 */
void traverse(ListNode head) {
if (head == null) {
return;
}
// 前序位置
traverse(head.next);
// 后序位置
}
单链表和数组的遍历可以是迭代的,也可以是递归的,二叉树这种结构无非就是二叉链表,由于没办法简单改写成迭代形式,所以一般说二叉树的遍历框架都是指递归的形式。
所谓前序位置,就是刚进入一个节点(元素)的时候,后序位置就是即将离开一个节点(元素)的时候
把代码写在不同位置,代码执行的时机也不同:
比如说,如果让你倒序打印一条单链表上所有节点的值,你怎么搞?
实现方式当然有很多,但如果你对递归的理解足够透彻,可以利用后序位置来操作
前中后序是遍历二叉树过程中处理每一个节点的三个特殊时间点,绝不仅仅是三个顺序不同的 List:
前序位置的代码在刚刚进入一个二叉树节点的时候执行;
后序位置的代码在将要离开一个二叉树节点的时候执行;
中序位置的代码在一个二叉树节点左子树都遍历完,即将开始遍历右子树的时候执行。
画成图,前中后序三个位置在二叉树上是这样:
你可以发现每个节点都有「唯一」属于自己的前中后序位置,所以我说前中后序遍历是遍历二叉树过程中处理每一个节点的三个特殊时间点。
二叉树的所有问题,就是让你在前中后序位置注入巧妙的代码逻辑,去达到自己的目的,你只需要单独思考每一个节点应该做什么,其他的不用你管,抛给二叉树遍历框架,递归会在所有节点上做相同的操作。
前序遍历的特点是,根节点的值排在首位,接着是左子树的前序遍历结果,最后是右子树的前序遍历结果:
那这不就可以分解问题了么,一棵二叉树的前序遍历结果 = 根节点 + 左子树的前序遍历结果 + 右子树的前序遍历结果。
所以,你可以这样实现前序遍历算法:
// 定义:输入一棵二叉树的根节点,返回这棵树的前序遍历结果
List<Integer> preorderTraverse(TreeNode root) {
List<Integer> res = new LinkedList<>();
if (root == null) {
return res;
}
// 前序遍历的结果,root.val 在第一个
res.add(root.val);
// 利用函数定义,后面接着左子树的前序遍历结果
res.addAll(preorderTraverse(root.left));
// 利用函数定义,最后接着右子树的前序遍历结果
res.addAll(preorderTraverse(root.right));
return res;
}
中序和后序遍历也是类似的,只要把 add(root.val)
放到中序和后序对应的位置就行了。
总之:
1、是否可以通过遍历一遍二叉树得到答案?如果可以,用一个 traverse
函数配合外部变量来实现。
2、是否可以定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案?如果可以,写出这个递归函数的定义,并充分利用这个函数的返回值。
3、无论使用哪一种思维模式,你都要明白二叉树的每一个节点需要做什么,需要在什么时候(前中后序)做。
后序位置的特殊之处
中序位置主要用在 BST 场景中,完全可以把 BST 的中序遍历认为是遍历有序数组。
前序位置的代码执行是自顶向下的,而后序位置的代码执行是自底向上的
但这里面大有玄妙,意味着前序位置的代码只能从函数参数中获取父节点传递来的数据,而后序位置的代码不仅可以获取参数数据,还可以获取到子树通过函数返回值传递回来的数据。
一旦发现题目和子树有关,那大概率要给函数设置合理的定义和返回值,在后序位置写代码了。
例如:力扣第 543 题「 二叉树的直径」
题目不在贴出,直接写出代码:
class Solution {
public:
int maxDiameter=0;
int diameterOfBinaryTree(TreeNode* root) {
//最大直径长度就是左子树最大深度和右子树最大深度和
maxDepth(root);
return maxDiameter;
}
int maxDepth(TreeNode* root){
if(root==nullptr)return 0;
int leftMax=(maxDepth(root->left));
int rightMax=(maxDepth(root->right));
//后序遍历计算最大直径
maxDiameter=max(maxDiameter,leftMax+rightMax);
return 1+max(leftMax,rightMax);
}
};
时间复杂度只有 maxDepth
函数的 O(N)
层序遍历
二叉树题型主要是用来培养递归思维的,而层序遍历属于迭代遍历,也比较简单.
层序遍历基本上就是BFS。每次遍历一层中所有点齐步向前就行。
本文来自博客园,作者:{BailanZ},转载请注明原文链接:https://www.cnblogs.com/BailanZ/p/16090711.html