我的面经里有两颗树:一颗是二叉树,另一棵也是二叉树

118 阅读12分钟

事情的起因是最近在刷题时刷到了一道算法题:数组中第K个最大的元素,这道题的一种解法是改造堆排序,并且官方题解中的一句话提醒了我: image.png 于是想到,关于二叉树的知识点早已忘得干干净净,于是打算写篇文章总结下,也方便以后看到这篇文章可以快速回想起相关的知识点。

树的基本概念

树高:直观理解就是树的层数,每一层的最大节点数呈指数级递增,根节点所在的树高为一。
叶子节点:没有子节点的节点,一般都在树的最后一层
非叶子节点:有子节点的节点。

计算机如何存储树形结构

在计算机世界中,内存结构是线性的,所以在物理上只能识别和存储线性结构。不太严谨的说,也就是计算机内存只能存储一维数组,多维数组在存储时需要转化为多个一维数组然后再保存到内存中。

同样,计算机内存也不支持直接存储树形结构,想要存储树形结构一般有两种方式:一种是通过指针将每个节点通过树的定义关联起来,但是这样数据比较分散,在存储时也不是连续的,看起来不太直观;另一种是通过数组的形式,按照某种规则组织起来放到数组中进行存储,存储是连续的,而且看起来也比较直观,一般在算法题中都是通过数组存储树形结构。

上面说到的存储规则是可以自己推导出来的,以一颗树高为4的满二叉树为例:

image.png 每个节点按照从上到下,从左到右的顺序添加编号,编号从0开始,到14结束。仔细观察可以发现,父节点的编号和左右子节点的编号满足如下关系:
1.左子节点的编号=父节点编号*2+1
2.右子节点的编号=父节点编号*2+2
3.最后一个非叶子节点的编号(从零开始)=数的总节点个数/2-1
利用这两个公式就可以把一颗二叉树保存到一维数组当中了,而且还可以从一维数组还原回来。

二叉树介绍

二叉树是最简单的树形结构,同时也是面试中考察最多的,设计到树的算法基本都是二叉树。二叉树可以分为三种:满二叉树,完全二叉树和非完全二叉树。满二叉树是一种特殊的完全二叉树。举个例子:

tree.drawio.png

满二叉树是指所有节点包括叶子都完整的二叉树,即树的每一层的节点数量满足n=2^h,h是树的高度,根节点的树高是零。

完全二叉树可以这么理解,就是满二叉树按照从右往下,从下往上的顺序砍掉若干个节点后的二叉树。比如上面图中的满二叉树砍掉节点15是一颗完全二叉树,砍掉节点15和14也是一颗完全二叉树,但是只砍掉节点14保留15节点就是不是完全二叉树。

其他情况都是非完全二叉树。

二叉树必知必会

DFS遍历

树的遍历基本上可以分为两类,一类是深度优先遍历DFS,另一类是广度优先遍历BFS。常见的DFS有先,中,后序遍历三种,常见的BFS有层序遍历。下面以二叉树为例,详细说说如何遍历二叉树。

我们先来直观感受下先,中,后序三种遍历方式,在脑海中建立起一个大致的印象。见下图:
先序遍历:根节点,左子树,右子树

先序遍历.png

中序遍历:左子树,根节点,右子树

中序遍历.png

后序遍历:左子树,右子树,根节点

后序遍历.png

不理解上面的图没关系,我们先来看代码实现,结合代码一起看就能理解了。在代码实现上有两种方法,递归法和迭代法。递归法简单也易于理解,迭代法烧脑且不易理解。然而无论是递归法还是迭代法都用到了一种数据结构-栈,只不过递归法是程序帮我们隐式维护了一个栈,而迭代法是自己维护一个栈。树的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
}

堆排序

堆排序可以分为以下三个步骤:

  1. 创建大顶堆或者小顶堆,这一步也可以称之为堆化
  2. 将堆顶元素与末尾未排序的元素进行交换
  3. 重新堆化,然后重复上面的步骤,直到所有元素都有序

在实现之前,先回想一下上面提到的三个重要公式:
左子节点的编号=父节点编号*2+1
右子节点的编号=父节点编号*2+2
最后一个非叶子节点的编号=数的总节点个数/2-1

理解了上面的三个公式,接下来的分析就很轻松了。

堆排序需要先构建大顶堆或者小顶堆,大顶堆的通俗理解就是,每个父节点的值大于左右子节点的值,小顶堆则与之相反。以大顶堆为例,一颗典型的大顶堆树如下图所示: image.png 那么如何来构建堆呢? 我们需要遍历树的所有非叶子节点,每个非叶子节点都是一棵子树,使每颗子树都满足大顶堆的定义即可。为了使整个树都满足堆定义,我们还需要按照从下往上,从右往左的顺序遍历所有子树。

构建好大顶堆之后,只需要从下往上,从右往左,依次将树的末尾元素和堆顶元素进行交换,交换之后,需要重新“堆化”,这是因为此时堆顶元素不一定满足堆的定义。

// 堆排序算法
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)
}

后续遇到其他二叉树相关的知识点会持续更新。