跟着卡哥学算法Day 13:二叉树 & 遍历

2422 字
12 分钟
跟着卡哥学算法Day 13:二叉树 & 遍历

二叉树基础#

树形结构,每个节点最多有两个子节点,称为左子节点和右子节点

  • 根节点:顶级节点,没有父节点
  • 叶子结点:没有子节点的节点
  • 子树:每个节点的左、右子节点分别构成左子树和右子树
  • 深度:从根节点到最远叶子节点的最长路径上的节点数

分类#

// 构建示例树:
// 1
// / \
// 2 3
// / \
// 4 5

普通二叉树#

任意节点最多有两个子节点

满二叉树#

  1. 所有叶子结点都在同一层
  2. 深度为 k 的满二叉树,最多有 2^k - 1 个节点

完全二叉树#

  1. 除最后一层外,其他层节点必须填满,并且最后一层叶子节点必须靠左
  2. 若最底层为第 h 层(h 从 1 开始),则该层包含 1~ 2^(h-1) 个节点。

二叉搜索树 BST#

  • 左子树所有节点值 < 根节点值
  • 右子树所有节点值 > 根节点值

平衡二叉搜索树 AVL#

  • 任意节点的左右子树高度差不超过 1
  • 左右子树都会平衡二叉树

二叉树的存储方式#

  • 链式存储
  • 顺序存储

一般使用链式存储

二叉树的遍历#

深度优先遍历#

先往深走,遇到叶子结点再往回走

  • 前序遍历:中左右
  • 中序遍历:左中右
  • 后序遍历:左右中

如下图例子所示:

binary-tree-sort
binary-tree-sort

这里的前中后,指的是中间节点的遍历顺序

广度优先遍历#

一层一层遍历

二叉树的定义#

// ES5
function TreeNode(val) {
this.val = val // 节点值
this.left = null // 左子节点(默认为空)
this.right = null // 右子节点(默认为空)
}
// ES6
class TreeNode {
constructor(val) {
this.val = val
this.left = null
this.right = null
}
}

二叉树递归排序#

递归方法论:

  1. 确定递归的参数和返回值
  2. 确定终止条件,如果没有终止,会导致栈溢出
  3. 确定单层递归的逻辑

以前序遍历(根 -> 左 -> 右)为例:

  1. 确定递归的参数和返回值

    • 递归参数:当前遍历节点
    • 返回值:存储遍历节点的数组
  2. 确定终止条件

    • 如果本层遍历节点为空,则直接 return
  3. 确定单层递归的逻辑

    • 前序遍历:中 左 右
// 前序遍历
function preorderTraversal(root) {
if (!root) return []
return [
root.val,
...perorderTraversal(root.left),
...preorderTraversal(root.right),
]
}
// 中序遍历
function inorderTraversal(root) {
if (!root) return []
return [
...perorderTraversal(root.left),
root.val,
...preorderTraversal(root.right),
]
}
// 后序遍历
function postorderTraversal(root) {
if (!root) return []
return [
...perorderTraversal(root.left),
...preorderTraversal(root.right),
root.val,
]
}

二叉树迭代遍历(非递归)#

递归在底层也是通过栈来实现的

通过栈来模拟现二叉树的迭代遍历,避免递归造成的溢出风险

前序遍历#

  1. 初始化栈,将根节点入栈
  2. 循环处理:
    1. 弹出栈顶元素,数值保存到数组中
    2. 如果有右子节点,入栈(先右后左,保证左子树先出栈
    3. 如果有左子节点,入栈
  3. 如果栈为空,则遍历结束
function preorderTraversal(root) {
if (!root) return []
const stack = [root]
const result = []
while (stack.length) {
const current = stack.pop()
result.push(current.val)
if (current.right) stack.push(current.right)
if (current.left) stack.push(current.left)
}
return result
}

中序遍历#

遍历二叉树的迭代过程,主要有两个操作:

  1. 访问:遍历节点
  2. 处理:将元素放进 result 数组中

中序遍历和前序遍历不同的是:

  1. 前序遍历:先访问中间节点,同时处理的也是中间节点,即访问和处理的元素顺序一致
  2. 中序遍历:先访问根结点,然后一层一层向下访问,直到到达左子树的最底部,再进行处理
function inorderTraversal(root) {
if (!root) return []
const result = []
const stack = []
let cur = root
while (cur || stack.length) {
// 一直访问左子树,直到左子树为空
while (cur) {
stack.push(cur)
cur = cur.left
}
// 循环处理栈
cur = stack.pop()
result.push(cur.val)
// 处理右子树
cur = cur.right
}
return result
}

后序遍历#

  • 前序遍历是 中 -> 左 -> 右
  • 后序遍历是 左 -> 右 -> 中
  • 只需要把前序遍历修改顺序成 中 -> 右 -> 左 的顺序,即先入栈左子树,再入栈右子树
  • 再将数组结果进行反转(数指针)
function postorderTraversal(root) {
if (!root) return []
const result = []
const stack = [root]
while (stack.length) {
const current = stack.pop()
result.push(current.val)
if (current.left) stack.push(current.left)
if (current.right) stack.push(current.right)
}
return result.reverse()
}

二叉树的统一迭代法#

上面写出的中序遍历与其他两个的迭代法风格不统一,其主要原因是:要访问和要处理的节点不一致

此时可以将访问的节点放入栈中,再把要处理的节点也放入栈中但是需要另外做标记

如何标记#

空指针标记法:要处理的节点放入栈后,紧接这放入一个空指针作为标记,当栈中弹出 null 时,表示下一个节点需要被处理(即加入结果)。

那么此时压栈顺序应该如下:

  • 前序遍历:中 -> 左 -> 右,压栈顺序为:右 -> 左 -> 中 -> null
  • 中序遍历:左 -> 中 -> 右,压栈顺序为:右 -> 中 -> null -> 左
  • 后序遍历:左 -> 右 -> 中,压栈顺序为:中 -> null -> 右 -> 左

如:

// 构建示例树:
// 1
// / \
// 2 3
// / \
// 4 5

前序遍历:中 -> 左 -> 右,压栈顺序为:右 -> 左 -> 中 -> null

  1. 初始化栈,将 1 压入栈
  2. 循环处理:
    1. 弹出栈顶元素
      1. 为 null,则下个元素就是要处理的元素,添加到结果中,继续处理下一个元素
      2. 不为 null,则继续处理当前元素
    2. 压入 3,先右后左
    3. 压入 2
    4. 压入 null
  3. 返回结果
/**
* 前序遍历统一迭代法
* 顺序:中 -> 左 -> 右
* 压栈顺序:右 -> 左 -> 中 -> null
*/
function preorderTraversal(root) {
const result = []
const stack = [root]
while (stack.length) {
const cur = stack.pop()
if (!cur) {
result.push(cur.pop().val)
continue
}
if (cur.right) stack.push(cur.right)
if (cur.left) stack.push(cur.left)
stack.push(cur)
stack.push(null)
}
return result
}

通过入栈顺序调整处理时机#

入栈顺序决定了根节点的处理时机:

前序遍历:

stack.push(node)
stack.push(null) // 根节点标记为待处理
stack.push(node.right) // 右子节点入栈(后处理)
stack.push(node.left) // 左子节点入栈(先处理)

根节点先被处理(标记后立即弹出 null)。

中序遍历:

stack.push(node.right) // 右子节点入栈(后处理)
stack.push(node)
stack.push(null) // 根节点标记为待处理
stack.push(node.left) // 左子节点入栈(先处理)

根节点在左子树处理后处理。

后序遍历:

stack.push(node)
stack.push(null) // 根节点标记为待处理
stack.push(node.right) // 右子节点入栈(后处理)
stack.push(node.left) // 左子节点入栈(先处理)

根节点在左右子树均处理后处理。

统一迭代法的核心逻辑#

通过 空指针标记入栈顺序控制,将三种遍历统一为同一框架:

  1. 标记待处理的根节点:将根节点与空指针一起入栈(在统一迭代法中,无论是哪种遍历方式,处理节点的时机都是在中节点的位置),表示该节点需要后续处理。
  2. 控制子节点的入栈顺序:调整左右子节点的入栈顺序,间接决定根节点的处理时机。

二叉树的层序遍历#

  • 深度优先遍历:递归和迭代都使用栈来实现
  • 广度优先遍历:二叉树的层序遍历(一层一层遍历二叉树),需要借助队列来实现

思路#

  1. 初始化队列:将根节点加入队列
  2. 循环处理队列:
    1. 记录当前层的节点数量(队列长度)
    2. 依次取出当前层的所有节点,并将它们的值存入当前层的结果列表
    3. 将每个节点的非空左、右子节点加入队列
  3. 逐层收集结果:将每层的节点值列表合并为最终结果
// 1
// / \
// 2 3
// / \ \
// 4 5 6

层序遍历过程:

  1. 初始队列:[1] → 处理第 1 层 → result = [[1]],子节点入队 [2, 3]
  2. 处理第 2 层:弹出 2 和 3 → result = [[1], [2, 3]],子节点入队 [4, 5, 6]
  3. 处理第 3 层:弹出 4、5、6 → result = [[1], [2, 3], [4, 5, 6]]

代码实现#

function levelOrder(root) {
if (!root) return []
const result = []
const queue = [root]
while (queue.length) {
const size = queue.length
const curLevel = []
for (let i = 0; i < size; i++) {
const cur = queue.shift()
curLevel.push(cur.val)
if (cur.left) queue.push(cur.left)
if (cur.right) queue.push(cur.right)
}
result.push(curLevel)
}
return result
}

leetcode 相关题目:

跟着卡哥学算法Day 13:二叉树 & 遍历
https://wangxiang.website/posts/算法/binary-tree-code/
作者
翔子
发布于
2025-02-24
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
翔子
前端开发工程师
公告
博客已从 VitePress 迁移到 Astro + Firefly 主题,223 篇文章全部保留。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
221
分类
9
标签
28
总字数
411,914
运行时长
0
最后活动
0 天前

文章目录