事情的起因是最近在刷题时刷到了一道算法题:数组中第K个最大的元素,这道题的一种解法是改造堆排序,并且官方题解中的一句话提醒了我:
于是想到,关于二叉树的知识点早已忘得干干净净,于是打算写篇文章总结下,也方便以后看到这篇文章可以快速回想起相关的知识点。
树的基本概念
树高:直观理解就是树的层数,每一层的最大节点数呈指数级递增,根节点所在的树高为一。
叶子节点:没有子节点的节点,一般都在树的最后一层
非叶子节点:有子节点的节点。
计算机如何存储树形结构
在计算机世界中,内存结构是线性的,所以在物理上只能识别和存储线性结构。不太严谨的说,也就是计算机内存只能存储一维数组,多维数组在存储时需要转化为多个一维数组然后再保存到内存中。
同样,计算机内存也不支持直接存储树形结构,想要存储树形结构一般有两种方式:一种是通过指针将每个节点通过树的定义关联起来,但是这样数据比较分散,在存储时也不是连续的,看起来不太直观;另一种是通过数组的形式,按照某种规则组织起来放到数组中进行存储,存储是连续的,而且看起来也比较直观,一般在算法题中都是通过数组存储树形结构。
上面说到的存储规则是可以自己推导出来的,以一颗树高为4的满二叉树为例:
每个节点按照从上到下,从左到右的顺序添加编号,编号从0开始,到14结束。仔细观察可以发现,父节点的编号和左右子节点的编号满足如下关系:
1.左子节点的编号=父节点编号*2+1
2.右子节点的编号=父节点编号*2+2
3.最后一个非叶子节点的编号(从零开始)=数的总节点个数/2-1
利用这两个公式就可以把一颗二叉树保存到一维数组当中了,而且还可以从一维数组还原回来。
二叉树介绍
二叉树是最简单的树形结构,同时也是面试中考察最多的,设计到树的算法基本都是二叉树。二叉树可以分为三种:满二叉树,完全二叉树和非完全二叉树。满二叉树是一种特殊的完全二叉树。举个例子:
满二叉树是指所有节点包括叶子都完整的二叉树,即树的每一层的节点数量满足n=2^h,h是树的高度,根节点的树高是零。
完全二叉树可以这么理解,就是满二叉树按照从右往下,从下往上的顺序砍掉若干个节点后的二叉树。比如上面图中的满二叉树砍掉节点15是一颗完全二叉树,砍掉节点15和14也是一颗完全二叉树,但是只砍掉节点14保留15节点就是不是完全二叉树。
其他情况都是非完全二叉树。
二叉树必知必会
DFS遍历
树的遍历基本上可以分为两类,一类是深度优先遍历DFS,另一类是广度优先遍历BFS。常见的DFS有先,中,后序遍历三种,常见的BFS有层序遍历。下面以二叉树为例,详细说说如何遍历二叉树。
我们先来直观感受下先,中,后序三种遍历方式,在脑海中建立起一个大致的印象。见下图:
先序遍历:根节点,左子树,右子树
中序遍历:左子树,根节点,右子树
后序遍历:左子树,右子树,根节点
不理解上面的图没关系,我们先来看代码实现,结合代码一起看就能理解了。在代码实现上有两种方法,递归法和迭代法。递归法简单也易于理解,迭代法烧脑且不易理解。然而无论是递归法还是迭代法都用到了一种数据结构-栈,只不过递归法是程序帮我们隐式维护了一个栈,而迭代法是自己维护一个栈。树的DFS本质上是对栈的灵活运用。
看到这里,请大家思考一个问题,为什么树的遍历需要使用栈?
答:为了记住来时的路
我们知道递归时并不是走到底就完事了,还需要沿着原路返回,那么怎么做到原路返回呢,这时候就需要把之前走过的节点保存起来,因为来和回去的方向是相反的,所以在保存的时候,先遇到的节点要放到后面,后遇到的节点要放在前面。毫无疑问,栈这种数据结果最适合这种场景。
递归版
// TreeNode 树节点声明
type TreeNode struct {
Val int
Left *TreeNode
Right *TreeNode
}
// PreOrderTraversal 二叉树的前序遍历(递归版)
func PreOrderTraversal(root *TreeNode) []int {
if root == nil {
return nil
}
var res []int
var inOrder func(node *TreeNode)
inOrder = func(node *TreeNode) {
if node == nil {
return
}
res = append(res, node.Val)
inOrder(node.Left)
inOrder(node.Right)
}
inOrder(root)
return res
}
// InOrderTraversal 二叉树的中序遍历(递归版)
func InOrderTraversal(root *TreeNode) []int {
if root == nil {
return nil
}
var res []int
var inOrder func(node *TreeNode)
inOrder = func(node *TreeNode) {
if node == nil {
return
}
inOrder(node.Left)
res = append(res, node.Val)
inOrder(node.Right)
}
inOrder(root)
return res
}
func PostOrderTraversal(root *TreeNode) []int {
if root == nil {
return nil
}
var res []int
var postOrder func(root *TreeNode)
postOrder = func(node *TreeNode) {
if node == nil {
return
}
postOrder(node.Left)
postOrder(node.Right)
res = append(res, node.Val)
}
postOrder(root)
return res
}
迭代版
递归版的代码比较简单且好记,这里就不再多说了,重点来看下迭代的代码实现。
迭代版的实现有多种写法,为了方便记忆,花了些时间将三种遍历的实现方式进行了统一,并且提炼出了一个模版,记住这个模版,就可以快速的写出三种遍历的代码。
模版如下:
// 循环退出条件:遍历指针root为nil并且栈为空,栈为空说明已经回到了最上层
for root != nil || stack.Len() > 0 {
for root != nil {
stack.PushFront(root)
root = root.Left
}
top := stack.Remove(stack.Front()).(*TreeNode)
// 根据栈顶节点的属性分别进行处理
// case01: ...
// case02: ...
// cose03: ...
// 转到右子树继续上面的过程
}
记住这个模版再分别来看下三种遍历方式是如何实现的。
// PreOrderTraversalV2 二叉树的前序遍历(非递归版)
func PreOrderTraversalV2(root *TreeNode) []int {
if root == nil {
return nil
}
var res []int
stack := list.New()
// 循环退出条件:遍历指针为nil并且栈为空,栈为空说明已经回到了最上层
for root != nil || stack.Len() > 0 {
// 遍历根节点的左子树,记录根节点或者左节点的值后再将它们全部入栈
for root != nil {
res = append(res, root.Val) // 记录根节点或者左节点的值
stack.PushFront(root)
root = root.Left
}
// 弹出的栈顶元素有两种情况:根节点或者根节点的左节点
// 这两种情况都不需要记录值,因为前面遍历的时候已经记录过了
top := stack.Remove(stack.Front()).(*TreeNode)
// 如果是根节点,并且有右节点,遍历指针指向右节点,转到右子树重复上面的步骤,
if top.Right != nil {
root = top.Right
}
}
return res
}
// InOrderTraversalV2 二叉树的中序遍历(非递归版)
func InOrderTraversalV2(root *TreeNode) []int {
if root == nil {
return nil
}
var res []int
stack := list.New()
// 循环退出条件:遍历指针为nil并且栈为空,栈为空说明已经回到了最上层
for root != nil || stack.Len() > 0 {
// 遍历根节点的左子树,将根节点和所有左节点都入栈
for root != nil {
stack.PushFront(root)
root = root.Left
}
// 弹出的栈顶元素有两种情况:根节点或者根节点的左节点
top := stack.Remove(stack.Front()).(*TreeNode)
// 无论是根节点还是根节点的左节点,处理的操作是一样,都是记录元素的值
res = append(res, top.Val)
if top.Right != nil { // 如果存在右节点,说明是右节点,遍历指针指向右节点,转到右子树重复上面的过程
root = top.Right
}
}
return res
}
理解后序遍历的关键是,遍历时会经过两次根节点,也就是会入栈出栈两次。如何判断根节点是否被访问过,一种方式是记录上一个节点,所以需要一个辅助指针prev。对于后续遍历中,每一个存在右节点的根节点,一定满足:根节点的上一个被访问节点是其右节点,也就是cur.Right=prev。
// PostOrderTraversalV2 二叉树后序遍历(迭代版)
func PostOrderTraversalV2(root *TreeNode) []int {
if root == nil {
return []int{}
}
var prev *TreeNode
var res []int
stack := list.New()
// 循环终止条件:遍历指针为nil或者栈为空,栈为空说明已经回到了最上层
for root != nil || stack.Len() > 0 {
// 遍历根节点的左子树,将根节点和所有左节点都入栈
for root != nil {
stack.PushFront(root)
root = root.Left
}
// 走到头了,回到上一层:弹出栈顶元素
// 这里弹出的栈顶元素有三种情况:第一次遇到的根节点,第二次遇到的根节点,根节点的左节点
top := stack.Remove(stack.Front()).(*TreeNode)
// 将根节点的左节点和第二次遇到的根节点这两种情况合并,将prev指向当前节点,并记录当前节点的值,然后回到上一层:弹出栈顶元素
// top.Right == nil 说明是根节点的左节点
// top.Right == prev 说明是第二次遇到的根节点
if top.Right == nil || top.Right == prev {
res = append(res, top.Val)
prev = top
root = nil // 遍历指针置为nil,继续弹出栈顶元素
} else { // 第一次遇到的根节点,将根节点入栈,转到根节点的右子树,重复上面的步骤
stack.PushFront(top)
root = top.Right // 遍历指针指向右节点,转到根节点的右子树
}
}
return res
}
BFS遍历
一种典型的BFS就是二叉树的层序遍历。
层序遍历
层序遍历需要借助队列这种数据结构。核心思路是,遍历当前层的所有节点,对于出队列的所有节点,记录值后,依次将其左节点和右节点放入队列中,构造下一层的节点队列。然后重复前面的步骤,直到树的最后一层。
// LevelOrderTraversal 层序遍历
func LevelOrderTraversal(root *TreeNode) [][]int {
if root == nil {
return nil
}
var res [][]int
queue := list.New() // 队列
queue.PushFront(root) // 根节点先入栈,从第一层开始遍历
for queue.Len() > 0 {
// 因为每层遍历都是复用同一个队列,这里需要先记录每次循环的队列长度,此时的队列长度也就是上一层的节点个数
n := queue.Len()
var temp []int // 保存上一层的值
for j := 0; j < n; j++ { // 循环n次,刚好将上一层的节点全部取出来
back := queue.Remove(queue.Back()).(*TreeNode)
temp = append(temp, back.Val)
// 对于出队列的每一个节点,依次将节点的左节点和右节点放入队列
if back.Left != nil {
queue.PushFront(back.Left)
}
if back.Right != nil {
queue.PushFront(back.Right)
}
}
// 保存每一层的结果
res = append(res, temp)
}
return res
}
堆排序
堆排序可以分为以下三个步骤:
- 创建大顶堆或者小顶堆,这一步也可以称之为堆化
- 将堆顶元素与末尾未排序的元素进行交换
- 重新堆化,然后重复上面的步骤,直到所有元素都有序
在实现之前,先回想一下上面提到的三个重要公式:
左子节点的编号=父节点编号*2+1
右子节点的编号=父节点编号*2+2
最后一个非叶子节点的编号=数的总节点个数/2-1
理解了上面的三个公式,接下来的分析就很轻松了。
堆排序需要先构建大顶堆或者小顶堆,大顶堆的通俗理解就是,每个父节点的值大于左右子节点的值,小顶堆则与之相反。以大顶堆为例,一颗典型的大顶堆树如下图所示:
那么如何来构建堆呢?
我们需要遍历树的所有非叶子节点,每个非叶子节点都是一棵子树,使每颗子树都满足大顶堆的定义即可。为了使整个树都满足堆定义,我们还需要按照从下往上,从右往左的顺序遍历所有子树。
构建好大顶堆之后,只需要从下往上,从右往左,依次将树的末尾元素和堆顶元素进行交换,交换之后,需要重新“堆化”,这是因为此时堆顶元素不一定满足堆的定义。
// 堆排序算法
func HeapSort(arr []int) {
// 从下到上,从右往左,遍历所有非叶子节点,构建大顶堆
// 最后一个非叶子节点的索引是:len(arr)/2 - 1
for i := len(arr)/2 - 1; i >= 0; i-- {
heapify(arr, i, len(arr)) // 堆化
}
// 将堆顶元素与数组最后一个元素交换,然后重新构建大顶堆
for j := len(arr) - 1; j > 0; j-- {
arr[0], arr[j] = arr[j], arr[0]
heapify(arr, 0, j) // 堆顶元素和末尾元素交换后不一定满足堆定义,需要重新堆化
}
}
// 这个函数有两中执行逻辑:
// 一是递归零次,可以调节当前非叶子节点,使得左右子节点小于父节点;
// 二是递归多次,将当前节点的值放到合适的位置,使得大顶堆定义成立
// curNodeIdx: 当前父节点在数组中的下标
// length: 数组的长度
func heapify(arr []int, curNodeIdx, length int) {
left := 2*curNodeIdx + 1
// 递归终止条件,当前节点的左子节点不存在,说明后面已经没有要比较的元素了
if left >= length {
return
}
greater := left // 左右子节点较大值的索引暂定为left
right := 2*curNodeIdx + 2
if right < length && arr[greater] < arr[right] {
greater = right
}
// 递归终止条件:此时说明当前节点的值已经大于左右子节点的值,到达了合适的位置,不用再递归下去了
if arr[greater] < arr[curNodeIdx] {
return
}
// 如果左右子节点中的较大值大于当前父节点的值,交换值
if arr[greater] > arr[curNodeIdx] {
arr[greater], arr[curNodeIdx] = arr[curNodeIdx], arr[greater]
}
// 走到这里说明上面的值交换逻辑一定执行到了,这个时候被交换下来的值(arr[greater])可能小于其左右子节点的值
// 所以需要递归继续进行调整,直到到达了合适的位置
heapify(arr, greater, length)
}
后续遇到其他二叉树相关的知识点会持续更新。