文章目录
数据结构
我们分析问题,一定要有递归的思想,自顶而下,从抽象到具体。
数据结构的存储方式
数据结构的存储方式只有两种:数组(顺序存储)和链表(链式存储)(从最底层看)。像是栈、队列、图、堆、树等(从上往下看),都是建立在这两种存储结构上的。
- 队列和栈:这两种数组结构既可以使用链表也可以使用数组实现。
- 用数组实现,就要处理扩容缩容的问题;
- 用链表实现,没有这个问题,但是需要更多的内存空间存储节点指针
- 图:也有两种表示方法,邻接表就是链表、邻接矩阵就是二维数组。
- 散列表:就是通过散列函数把键映射到一个大数组里,解决散列冲突也有两种方法:
- 拉链法需要链表特性,操作简单,但需要额外的空间存储指针
- 线性探查法就需要数组特性,以便连续寻址,不需要指针的存储空间,但操作稍微复杂些
- 树:
- 用数组实现就是「堆」,因为「堆」是一个完全二叉树,用数组存储不需要节点指针,操作也比较简单
- 用链表实现就是很常见的那种「树」,因为不一定是完全二叉树,所以不适合用数组存储。为此,在这种链表「树」结构之上,又衍生出各种巧妙的设计,比如二叉搜索树、AVL 树、红黑树、区间树、B 树等等,以应对不同的问题。
二者优缺点:
- 数组:
- 由于紧凑连续存储,可以随机访问,通过索引快速找到对应元素,而且相对节约存储空间
- 但是因为连续存储,内存空间必须一次性分配够,所以说数组如果要扩容,需要重新分配一块更大的空间,再把数据全部复制过去,时间复杂度 O(N)
- 如果想在数组中间插入或者删除,每次必须搬移后面的所有数组以保存连续,时间复杂度是O(N)
- 链表:
- 因为元素不连续,而且是靠指针指向下一个元素的位置,所以不存在数组的扩容问题
- 如果知道某一元素的前驱和后驱,操作指针就可以删除元素或者插入新元素,时间复杂度O(1)
- 但是因为存储空间不连续,所以无法根据一个索引算出对应元素的地址,所以不能随机访问
- 而且由于每个元素必须存储指向前后元素位置的指针,会消耗相对更多的存储空间
数据结构的种类很多,但是它们存储的目的都是在不同的应用场景,尽可能高效的增删查改
数据结构的基本操作
对于任何数据结构,其基本操作无非就是遍历+访问,再具体一点:增删查改。
那如何遍历+访问呢?我们还是从上到下看,各种数据结构的遍历+访问无非是两种形式:线性的和非线性的:
- 线性的以
for/while
迭代为代表 - 非线性的以递归为代码
具体来讲,就是下面几种框架:
(1)数组遍历框架,典型的现象迭代结构
void traverse(int[] arr) {
for (int i = 0; i < arr.length; i++) {
// 迭代访问 arr[i]
}
}
(2)链表遍历框架,兼具迭代和递归结构:
/* 基本的单链表节点 */
class ListNode {
int val;
ListNode next;
}
void traverse(ListNode head) {
for (ListNode p = head; p != null; p = p.next) {
// 迭代访问 p.val
}
}
void traverse(ListNode head) {
// 递归访问 head.val
traverse(head.next);
}
(3)二叉树遍历框架,典型的非线性递归遍历结构:
/* 基本的二叉树节点 */
class TreeNode {
int val;
TreeNode left, right;
}
void traverse(TreeNode root) {
// 前序位置
traverse(root.left);
// 中序位置
traverse(root.right);
// 后序位置
}
(4)二叉树框架可以扩展为 N 叉树的遍历框架:
/* 基本的 N 叉树节点 */
class TreeNode {
int val;
TreeNode[] children;
}
void traverse(TreeNode root) {
for (TreeNode child : root.children)
traverse(child);
}
(5)N叉树的遍历又可以扩展为图的遍历,因为图就是好几N叉树的结合体。当然,为了处理环,我们需要用个布尔数组 visited 做标记
不管增删查改,这些代码都是永远无法脱离的结构,我们可以把这个结构作为大纲,根据具体问题在框架上添加代码就行了
二叉树
概述
二叉树解题的思维模式分为两类:
- 是否可以通过遍历一遍二叉树得到答案?如果可以,用一个
traverse
函数配合外部变量来实现。这就是[递归]的思维模式 - 是否可以遍历一个递归函数,通过子问题(子树)的答案来推导出原问题的答案?如果可以,写出这个递归函数的定义,并充分利用这个函数的返回值。这就是[分解问题]的思维模式
无论是哪种思维模式:
- 如果单独抽出一个二叉树节点,它需要做什么事情?需要在什么时候(前/中/后序位置)做。其他节点不需要去思考,递归函数会帮你在所有节点上指向相同的操作。
二叉树的重要性
二叉树极其重要,比如对于快速排序和归并排序:
- 快速排序本质上就是个二叉树的前序遍历
- 归并排序本质上就是个二叉树的后序遍历
为什么这么说呢?
(1)对于快速排序
- 快速排序的逻辑是:如果要对
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);
}
- 可以看出:是先构造出分界点,然后去左右子数组构造分界点。这不就是一个二叉树的前序遍历么?
(2)对于归并排序
- 归并排序的逻辑,如果要对
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);
/*********************/
}
- 可以看出:先对左右子数组排序,然后合并(类似合并有序链表的逻辑),不就是一颗二叉树的后序遍历吗?
如果你一眼就识破这些排序算法的底细,还需要背这些经典算法吗?不需要。你可以手到擒来,从二叉树遍历框架就能扩展出算法了。
二叉树的算法思想运用广泛,甚至可以说,只要涉及到递归,都可以抽象成二叉树的问题
深度理解前中后序
问题:
- 二叉树的前中后序究竟是什么?仅仅是三个顺序不同的list吗?
- 后序遍历有什么特殊之处?
- 为什么多叉树没有中序遍历?
我们来一一分析下。
- 我们知道,二叉树遍历框架基本上都是下面这样的:
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);
// 后序位置
}
- 单链表和数组的遍历可以是迭代的,也可以是递归的,二叉树这种结构无非就是二叉链表,由于没有办法简单改成迭代模式,所以一般来说二叉树的遍历框架但是递归形式。
- 只要是递归形式的遍历,都可以有前序位置和后序位置,分别在递归之前和递归之后
- 所谓前序位置,就是刚进入一个节点(元素)的时候,后序位置就是即将离开一个节点(元素)的时候。那么进一步,你把代码写到不同位置,代码执行的时机也不同
- 比如说,如果想要倒序打印一条单链表上的所有节点的值,可以写成下面这样。
/* 递归遍历单链表,倒序打印链表元素 */
void traverse(ListNode head) {
if (head == null) {
return;
}
traverse(head.next);
// 后序位置
print(head.val);
}
- 也就是说,前中后序是遍历二叉树过程中处理每一个节点的上特殊时间点,而不仅仅是三个顺序不同的list
- 前序位置的代码在刚刚进入一个二叉树节点的时候执行
- 中序位置的代码在一个二叉树节点左子树都遍历完了,即将开始遍历右子树的时候执行
- 后序位置的代码在将要离开一个二叉树节点的时候执行
- 我们可以在前序位置写代码往一个list里赛元素,那最后得到的就是前序遍历结果,除了塞元素外,还可以做一些更复杂的事情
- 可以发现每个节点都有[唯一]的属于自己的前中后序位置,这也是为什么说前中后徐遍历是遍历二叉树过程中处理每一个节点的三个特殊时间点
- 从而我们就可以理解了为什么多叉树没有中序位置:因为二叉树的每个节点只会进行唯一一次左子树切换右子树,而多叉树节点可能有很多子节点,会多次切换子树去遍历,所以多叉树节点没有「唯一」的中序遍历位置。
二叉树的所有问题,就是让你在前中后序位置注入巧妙的代码逻辑,去达到自己的目的,你只需要单独思考每一个节点应该做什么,其他的不用管,抛给二叉树遍历科技,递归会在所有节点上做相同的操作
两种解题思路
二叉树题目的递归解法可以分为两类,第一类是遍历一遍二叉树得出答案,第二类是通过分解问题计算出答案,这两类思路分别对应着回溯算法
和动态规划
下面来举几个例子。
所谓最大深度就是根节点到[最远]叶子节点的最长路径上的节点数,比如下面的二叉树,算法应该返回3
遍历二叉树计算答案的思路
- 遍历一遍二叉树,用一个外部变量记录每个节点所在深度,取最大值可以得到最大深度,代码如下:
// 记录最大深度
int res = 0;
// 记录遍历到的节点的深度
int depth = 0;
// 主函数
int maxDepth(TreeNode root) {
traverse(root);
return res;
}
// 二叉树遍历框架
void traverse(TreeNode root) {
if (root == null) {
// 到达叶子节点,更新最大深度
res = Math.max(res, depth);
return;
}
// 前序位置
depth++;
traverse(root.left);
traverse(root.right);
// 后序位置
depth--;
}
- 为什么需要在前序位置增加 depth,在后序位置减小 depth呢?因为前序位置是进入一个节点的时候,后序位置是离开一个节点的时候,
depth
记录当前递归到的节点深度,我们可以把traverse
当成二叉树上游走的一个指针,所以当然要这样维护。
我们也可以发现,一颗二叉树的最大深度可以通过子树的最大高度推导出来,这就是分解问题计算答案的思路
// 定义:输入根节点,返回这棵二叉树的最大深度
int maxDepth(TreeNode root) {
if (root == null) {
return 0;
}
// 利用定义,计算左右子树的最大深度
int leftMax = maxDepth(root.left);
int rightMax = maxDepth(root.right);
// 整棵树的最大深度等于左右子树的最大深度取最大值,
// 然后再加上根节点自己
int res = Math.max(leftMax, rightMax) + 1;
return res;
}
- 但为什么主要的代码逻辑集中在后序位置呢?我们确实可以通过子树的最大高度推导出原树的高度,所以当然要首先利用递归函数的定义算出左右子树的最大深度,然后推出原树的最大深度,主要逻辑自然放在后序位置
我们再回头看看最基本的二叉树前中后序遍历,以前序遍历为例。
递归思路解法如下:
List<Integer> res = new LinkedList<>();
// 返回前序遍历结果
List<Integer> preorderTraverse(TreeNode root) {
traverse(root);
return res;
}
// 二叉树遍历函数
void traverse(TreeNode root) {
if (root == null) {
return;
}
// 前序位置
res.add(root.val);
traverse(root.left);
traverse(root.right);
}
那能不能用[分界问题]的思路,来计算前序遍历的结果呢?
-
我们知道前序遍历的特点是,根节点的值排在首位,接着是左子树的前序遍历结果,最后是右子树的前序遍历结果:
-
从而就可以分解出问题,一颗二叉树的前序遍历结果 = 根节点 + 左子树的前序遍历结果 + 右子树的前序遍历结果。
-
因此,写法如下:
// 定义:输入一棵二叉树的根节点,返回这棵树的前序遍历结果
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) 放到中序和后序对应的位置就行了。
综上,遇到一道二叉树的题目时的通用思考过程是:
- 是否可以通过遍历一遍二叉树得到答案?如果可以,用一个 traverse 函数配合外部变量来实现。
- 是否可以定义一个递归函数,通过子问题(子树)的答案推导出原问题的答案?如果可以,写出这个递归函数的定义,并充分利用这个函数的返回值。
- 无论使用哪一种思维模式,你都要明白二叉树的每一个节点需要做什么,需要在什么时候(前中后序)做。