风哥带你手撕算法之吃透二叉树(1)(初版)
程序员吴乘风。
很多新手在刷 LeetCode 的时候,看着茫茫多的 tag ,不知从何下手。
风哥的建议是:直接从二叉树开始,带着模板思维去刷。
开门见山,先直接给出二叉树节点数据结构的基本代码和算法模板:
//二叉树节点的Java表示
public class TreeNode {
// 当前节点保存的值
public int val;
// 二叉树的左子树节点
public TreeNode left;
// 二叉树的右子树节点
public TreeNode right;
public TreeNode(int val) {
this.val = val;
}
}
/* 二叉树遍历模板 */
void traverse(TreeNode root) {
// 对节点的操作写在这里即前序遍历
traverse(root.left)
// 写在这里即中序遍历
traverse(root.right)
// 写在这里即后序遍历
}
找到二叉树的 tag ,然后选择简单和中等难度的题目开始刷:
一、为什么要从二叉树开始刷
二叉树的代码实现,和链表是几乎一样的,只是多了一个分叉:
//链表节点的Java代码表示
public class ListNode {
int val;
ListNode next;
ListNode(int x) { val = x; }
}
对二叉树节点的遍历,就涉及到递归操作,而递归是大部分算法学习者最难啃的一块骨头。递归方法的设计、递归条件的选取、递归过程的推导,对于模板思维没有概念的初学者都是一大难题。
所以通过初期强化训练树相关算法题,可以快速掌握链表、递归等算法知识,并且可以触类旁通。
像算法中常见的回溯、分治、动态规划等问题,都可以归纳为树的问题。化成了树的问题以后,就可以用前面给出的二叉树遍历模板进行递归遍历。
举个例子,快速排序、归并排序这两种常见的排序算法,如果光靠死记硬背是很难记住的。
然而风哥告诉你,快速排序就是对二叉树的前序遍历,归并排序就是对二叉树的后序遍历,套用遍历模板,快速排序和归并排序的算法模板也就引刃而解了。
快速排序和二叉树前序遍历之间有何关系呢?
快速排序的算法思想是:在数组 nums 中取一个分界点 p ,通过元素交换使得 nums[low] 到 nums[p - 1] 均小于等于 nums[p] ,nums[p + 1] 到 nums[high] 均大于 nums[p] ,然后递归地去 nums[low] 到 nums[p - 1] 以及 nums[p + 1] 到 nums[high] 中再分别寻找分界点,按照分界点对数组元素进行交换。最终得到的就是一个升序数组。
以下是快速排序模板:
void sort(int[] nums, int low, int high) {
/****** 前序遍历位置 ******/
// 通过交换元素构建分界点 p
int p = partition(nums, low, high);
/************************/
sort(nums, low, p - 1);
sort(nums, p + 1, high);
}
对照着二叉树遍历模板,我们很直观地便可看出快速排序模板与前序遍历的模板几乎一模一样,就是在前序遍历的位置加上交换元素的操作。
而归并排序和二叉树后序遍历之间又有什么关系呢?
归并排序的算法思想:将数组 nums[low...high] 拆分为 nums[low...middle] 和nums[middle + 1...high]两个数组,并且分别递归排序,最终整合在一起就是一个有序数组。
以下是归并排序模板:
void sort(int[] nums, int low, int high) {
int middle = (low + high) / 2;
sort(nums, low, middle);
sort(nums, middle + 1, high);
/****** 后序遍历位置 ******/
// 合并两个排好序的子数组
merge(nums, low middle, high);
/************************/
}
先对左右子数组排序,然后合并。和二叉树后序遍历模板也是如出一辙。这也是传说中的分治算法。
在识别了这些排序算法的底层逻辑以后,不需要死记硬背,就可以融会贯通,从模板慢慢扩展成具体的排序算法。
而说了这么多,风哥想表达的就是,二叉树的算法思路应用广泛。毫不夸张地说,只要涉及到递归的问题,都能抽象成树的问题来解决。
二、写好递归算法的关键
在研究树类问题时,我们主要从数据结构层面和算法层面入手。
在数据结构层面研究的基础是链式结构,也就是链表节点。
这是链表节点的示意图:
二叉树节点示意图:
可以看出,若链表节点的父节点数量不变(保持 1 个),但是节外生枝,拥有了 2 个或多个子节点,就变成了树节点。所以二叉树数据结构层面研究的基础是链式结构。
二叉树的算法层面研究的是递归,也就是如何写好一个递归函数,对二叉树结构按照预期进行遍历。
在设计递归算法时,需要明确这个函数的定义是什么,然后按照这个定义推导出结果,绝对不要跳入递归思维一层一层的运算。
举个例子,对一棵二叉树,计算节点数目:
// 定义:count(root) 返回以 root 为根的树有多少节点
int count(TreeNode root) {
// base case
if (root == null) return 0;
// 自己加上子树的节点数就是整棵树的节点数
return 1 + count(root.left) + count(root.right);
}
这个问题很简单,当进入到当前root节点时,将该节点的左子树、右子树以及自身的一个节点加起来,就得到了最终答案。按照定义,通过递归推导出来。
写树相关的算法,简单说就是,先搞清楚当前root节点该做什么,然后根据函数定义递归调用子节点,递归调用会让孩子节点做相同的事情。
三、算法实战
第一题、翻转二叉树
题号:LeetCode 226
https://leetcode-cn.com/problems/invert-binary-tree/
题目:
翻转一棵二叉树。
按照题目要求,是将每个节点下的左右节点交换位置。
直接上解题代码:
public TreeNode invertTree(TreeNode root) {
traverse(root);
return root;
}
public void traverse(TreeNode root) {
// 为空判断
if (root == null) {
return;
}
/**** 前序遍历位置 ****/
// root 节点需要交换它的左右子节点
TreeNode temp = new TreeNode(root.val);
temp.left = root.left;
root.left = root.right;
root.right = temp.left;
/**** 前序遍历位置结束 ****/
// 让左右子节点继续翻转它们各自的子节点
traverse(root.left);
traverse(root.right);
}
这题比较简单,直接将当前节点的左右子节点调换即可。建议使用前序遍历。
大家也可以思考一下能否用中序遍历和后续遍历完成该题。
从这题中,可以得知,二叉树题目的一个难点就是,如何把题目的要求细化成每个节点需要做的事情。
(待续)