二叉树
很多经典算法,比如回溯、动归、分治算法,其实都是树的问题
而树的问题就永远逃不开 树的递归遍历框架 这几行代码:
/* 二叉树遍历框架 */
void traverse(TreeNode root) {
// 前序遍历
traverse(root.left)
// 中序遍历
traverse(root.right)
// 后序遍历
}
行云流水地写递归代码是学好算法的基本功
二叉树相关的题目就是最练习递归基本功,最练习框架思维的
一、二叉树的重要性
经典算法「快速排序」和「归并排序」,其实,快速排序就是个二叉树的前序遍历,归并排序就是个二叉树的后序遍历
快速排序的逻辑是,若要对 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]
排序,最后把这两个有序的子数组合并,整个数组就排好序了。
归并排序的代码框架如下:
void sort(int[] nums, int lo, int hi) {
int mid = (lo + hi) / 2;
sort(nums, lo, mid);
sort(nums, mid + 1, hi);
/****** 后序遍历位置 ******/
// 合并两个排好序的子数组
merge(nums, lo, mid, hi);
/************************/
}
# 先对左右子数组排序,然后合并(类似合并有序链表的逻辑),你看这是不是二叉树的后序遍历框架?
# 另外,这不就是传说中的分治算法嘛
二叉树的算法思想的运用广泛,甚至可以说,只要涉及递归,都可以抽象成二叉树的问题
二、写递归算法的秘诀
写递归算法的关键是要明确函数的「定义」是什么,然后相信这个定义,利用这个定义推导最终结果,绝不要跳入递归的细节。
用一个具体的例子来说,比如说让你计算一棵二叉树共有几个节点:
// 定义:count(root) 返回以 root 为根的树有多少节点
int count(TreeNode root) {
// base case
if (root == null) return 0;
// 自己加上子树的节点数就是整棵树的节点数
return 1 + count(root.left) + count(root.right);
}
root
本身就是一个节点,加上左右子树的节点数就是以 root
为根的树的节点总数。左右子树的节点数怎么算?其实就是计算根为 root.left
和 root.right
两棵树的节点数呗,按照定义,递归调用 count
函数即可算出来。
写树相关的算法,简单说就是,先搞清楚当前 root
节点该做什么,然后根据函数定义递归调用子节点,递归调用会让孩子节点做相同的事情。
三、算法实践
leetcode226:「翻转二叉树」,输入一个二叉树根节点 root
,让你把整棵树镜像翻转,比如输入的二叉树如下:
4
/
2 7
/ /
1 3 6 9
翻转之后:
4
/
7 2
/ /
9 6 3 1
我们发现只要把二叉树上的每一个节点的左右子节点进行交换,最后的结果就是完全翻转之后的二叉树。
// 将整棵树的节点翻转
TreeNode invertTree(TreeNode root) {
// base case
if (root == null) {
return null;
}
/**** 前序遍历位置 ****/
// root 节点需要交换它的左右子节点
TreeNode tmp = root.left;
root.left = root.right;
root.right = tmp;
// 让左右子节点继续翻转它们的子节点
invertTree(root.left);
invertTree(root.right);
return root;
}
关键思路在于我们发现翻转整棵树就是交换每个节点的左右子节点,于是我们把交换左右子节点的代码放在了前序遍历的位置。
值得一提的是,如果把交换左右子节点的代码放在后序遍历的位置也是可以的,但是放在中序遍历的位置是不行的,因为中序遍历的遍历顺序是左根右,即 先对左节点操作,之后在中序遍历位置上的操作是左右调换,原来的左节点变成了右节点,遍历的最后一步又落在了右节点,即原来的左节点被处理了两次,右节点没有被处理到。)
二叉树题目的一个难点就是,如何把题目的要求细化成每个节点需要做的事情。
leetcode116 填充二叉树节点的右侧指针
题目的意思就是把二叉树的每一层节点都用 next
指针连接起来:
而且题目说了,输入是一棵「完美二叉树」,形象地说整棵二叉树是一个正三角形,除了最右侧的节点 next
指针会指向 null
,其他节点的右侧一定有相邻的节点。
把每一层的节点穿起来,并不是只要把每个节点的左右子节点都穿起来就行了。这种思想的做法是
Node connect(Node root) {
if (root == null || root.left == null) {
return root;
}
root.left.next = root.right;
connect(root.left);
connect(root.right);
return root;
}
但这样无法做到连接图中的5和6
二叉树的问题难点在于,如何把题目的要求细化成每个节点需要做的事情,但是如果只依赖一个节点的话,肯定是没办法连接「跨父节点」的两个相邻节点的。
我们的做法就是增加函数参数,一个节点做不到,我们就给他安排两个节点,「将每一层二叉树节点连接起来」可以细化成「将每两个相邻节点都连接起来」:
// 主函数
Node connect(Node root) {
if (root == null) return null;
connectTwoNode(root.left, root.right);
return root;
}
// 辅助函数
void connectTwoNode(Node node1, Node node2) {
if (node1 == null || node2 == null) {
return;
}
/**** 前序遍历位置 ****/
// 将传入的两个节点连接
node1.next = node2;
// 连接相同父节点的两个子节点
connectTwoNode(node1.left, node1.right);
connectTwoNode(node2.left, node2.right);
// 连接跨越父节点的两个子节点
connectTwoNode(node1.right, node2.left);
}
递归都可看成二叉树的某种遍历,以前序遍历为例:前序遍历的顺序是 根左右,即先对根操作,接着就是左子树递归和右子树递归,换算成写题目的想法就是,先考虑跟节点上要干什么,再考虑左子树要干什么(如何递归),右子树要怎么递归
leetcode114:
给出函数签名:
void flatten(TreeNode root);
给 flatten
函数输入一个节点 root
****,那么以 root
为根的二叉树就会被拉平为一条链表。
我们再梳理一下,如何按题目要求把一棵树拉平成一条链表?很简单,以下流程:
1、将 root
的左子树和右子树拉平。
2、将 root
的右子树接到左子树下方,然后将整个左子树作为右子树。
如何把 root
的左右子树拉平?其实很简单,按照 flatten
函数的定义,对 root
的左右子树递归调用 flatten
函数即可:
// 定义:将以 root 为根的树拉平为链表
void flatten(TreeNode root) {
// base case
if (root == null) return;
flatten(root.left);
flatten(root.right);
/**** 后序遍历位置 ****/
// 1、左右子树已经被拉平成一条链表
TreeNode left = root.left;
TreeNode right = root.right;
// 2、将左子树作为右子树
root.left = null;
root.right = left;
// 3、将原先的右子树接到当前右子树的末端
TreeNode p = root;
while (p.right != null) {
p = p.right;
}
p.right = right;
}
四、最后总结
递归算法的关键要明确函数的定义,相信这个定义,而不要跳进递归细节。
写二叉树的算法题,都是基于递归框架的,我们先要搞清楚 root
节点它自己要做什么,然后根据题目要求选择使用前序,中序,后续的递归框架。
二叉树题目的难点在于如何通过题目的要求思考出每一个节点需要做什么,这个只能通过多刷题进行练习了。