vlambda博客
学习文章列表

二叉树的实现、遍历及面试题


如图就是一个树结构,最上边的叫「根节点」,最下边的叫「叶子节点」。根节点有多个子节点,叶子节点没有子节点。二叉树就是,每个节点最多有两个子节点的树。

树还有两种特殊类型,叫做「满二叉树」和「完全二叉树」。

满二叉树和完全二叉树

完全二叉树是指「最底层的叶子节点全都集中在左边,且其它层的节点数达到最大值」的树,当完全二叉树最底层的叶子结点树也达到最大时,该树被称为满二叉树。

在实际应用中,为了解决一些特定的问题,我们为二叉树增加了一些特性和功能,如,为了方便数据的搜索,实现了二叉搜索树等。

二叉搜索树

二叉搜索树又叫二叉排序树,顾名思义就是数据是经过排序的,很适合数据搜索。二叉搜索树的特性为:「每个节点的值都比他的左子树的值大,比右子树的值大」。这样我们搜索一个比根节点大的值,只需要向右搜索右子树,极大的提高了搜索效率。

代码实现二叉搜索树:

class Node {
  constructor(value){
    this.value = value
    this.left = null
    this.right = right
  }
}

class BST {
  constructor() {
    this.root = null
    this.size = 0
  }
    
  getSize() {
    return this.size
  }
    
  isEmpty() {
    return this.size === 0
  }
    
  addNode(v) {
    this.node = this._addChild(this.root, v)
  }
    
  addChild(node, v){
    if (!node) {
      this.size++
      return new Node(v)
    }
    if (node.value > v) {
      node.left = addChild(node.left, v)
    }
    if (node.value < v) {
      node.right = addChild(node.right, v)
    }
    return node
  }
}

一个炒鸡简单的二叉搜索树就写好了,写的简单是为了方便写二叉搜索树的遍历。二叉树的遍历方式有「深度优先遍历和广度优先遍历」两种思路。深度优先遍历又分为「先序遍历、中序遍历、后序遍历」三种方式;广度优先遍历主要是「层次遍历」。

先序遍历顺序:根、左、右

中序遍历顺序:左、根、右

后序遍历顺序:左、右、根

二叉树的遍历

如图所示的二叉树的遍历结果分别为:

先序遍历结果:F B A D C E G I H

中序遍历结果:A B C D E F G H I

后序遍历结果:A C E D B H I G F

层次遍历结果:F B G A D I C E H

二叉树的遍历

递归遍历的代码非常简单:

// 先序遍历的顺序:根节点、左节点、右节点
// 先序遍历可以打印出树的结构
root = this.root

DLR(root) {
  if(root) {
    console.(root.value)
    this.DLR(root.left)
    this.DLR(root.right)
  }
}

// 中序遍历顺序:左节点、根节点、右节点
// 二叉搜索树的中序遍历结果得到的就是有序的值
LDR(root) {
  if(root) {
    this.LDR(root.left)
    console.log(root.value)
    this.LDR(root.right)
  }
}

// 后序遍历顺序:左节点、右节点、根节点
// 后序遍历可以用于从叶子节点向根节点访问
LRD(root) {
  if(root) {
    this.LRD(root.left)
    this.LRD(root.right)
    console.log(root.value)
  }
}

非递归遍历比递归遍历要复杂,我们需要借助到栈这种数据结构。

栈的特点是先进后出,所以我们可以「将后处理的节点先进栈,先处理的节点后进栈」。

root = this.root
// 先序遍历的顺序是根左右,所以先入栈右节点,再入栈左节点
Dlr() {
  if(!root) return null
  let s = new Stack()
  
  s.push(root)
  while(s.getCount()) {
    node = s.pop()
    console.log(node.value)
    if(node.right) {
      s.push(node.right)
    }
    if(node.left) {
      s.push(node.left)
    }
  }
}

// 中序遍历顺序是左根右,先将根节点和所有的左节点入栈
LDR() {
  if(!node) return null
  let s = new Stack()
  
  s.push(root)
  while(s.getCount()) {
    if(root) {
      s.push(root)
      root = root.left
    }
    node = s.pop()
    console.log(node.value)
    // 如果出栈的节点存在右子树,将该子树的所有左节点入栈
    root = node.right()
  }
}

// 由于左右节点没有直接的访问方法,所以先入栈右子树,然后入栈左子树,将得到的结果数组翻转
Lrd(){
  if(!root) return null
  let s = new Stack()
  let res = []
  
  s.push(root)
  while(s.getCount()){
    node = s.pop()
    res.push(node.value)
    if(node.left) s.push(node.left)
    if(node.right) s.push(node.right)
  }
  return res.reverse()
}

接下来就是层次遍历,层次遍历也需要借助到别的数据结构,这里使用队列实现。

BFS() {
  if (!this.root) return null
  let q = new Queue()
  
  q.enqueue(this.root)
  while (!q.isEmpty()) {
    let n = q.deQueue()
    console.log(n.value)
    if (n.left) q.enQueue(n.left)
    if (n.right) q.enQueue(n.right)
  }
}

层次遍历的思路是「将一个节点出栈的同时,将它的左节点和右节点入栈」。队列先进先出的特点可以保证一层的节点遍历完再遍历下一层的节点。

二叉树的其他操作

「获取最大或最小值」。由于二叉搜索树的特性,小值在左子树,大值在右子树,所以我们只需要比较当前节点的值,就知道向左还是向右遍历。找最小值一直向左遍历,最大值一直向右遍历。

root = this.root

getMin() {
  return this._getMin(root).value
}
_getMin(node) {
  if(!node.left) return node
  return this.getMin(node.left)
}

getMax() {
  return this._getMax(root).value
}

_getMax(node) {
  if(!node.left) return node.value
  return this.getMax(node.right)
}

「第n大的值」。二叉搜索树由于只能从根节点往子节点遍历,所以我们没办法直接获取到节点的排名。所以我们需要对二叉树的定义改一下,为每一个节点添加一个size属性,直接标明该节点前有多少节点,为了方便计算,包含自身。

class Node{
  constructor(value) {
    this.value = value
    this.left = null
    this.right= null
    this.size = 1
  }
    
  _getSize(node) {
    return node ? node.size : 0
  }
    
  addChild(node, v) {
    if (!node) return new Node(v)
    // 节点下插入一个子节点时,该节点的size+1
    if (node.value > v) {
      node.size++
      node.left = this.addChild(node.left, v)
    } else if (node.value < v) {
      node.size++
      node.right = this.addChild(node.right, v)
    }
    return node
  }
    
  select(k) {
    let node = this._select(this.root, k)
    return node ? node.value : null
  }
    
  _select(node, k) {
    if (!node) return null
    // 先获取根节点的左子树的size,如果k小于size,就在左子树查找,如果k大于size就在右子树寻找
    let size = node.left ? node.left.size : 0
    if (size > k) return this._select(node.left, k)
    // 在右子树的情况下,减去左子树和根节点,问题就变成在右子树中,第(k - size - 1)个节点
    if (size < k) return this._select(node.right, k - size - 1)
    return node
  }
}

「删除节点」。在二叉树上删除一个节点是一个复杂的操作,首先要分多种情况考虑,其次删除节点后,还要保证二叉搜索树的特性。删除节点有三种情况:

  • 没有子节点
  • 有一条子树
  • 有两条子树

第一种情况很好操作,只需要判断没有子节点就可以直接删除。

第二种情况也好操作,只需要将子树再接上去就可以。

第三种情况由于删除一个节点会产生两条子树,我们需要一个节点作为父节点来连接两条子树,并且再接到原来的树上。由于二叉搜索树的性质,该节点的值需要大于左子树且小于左子树,该节点有两个选择方案,一是左子树最大的节点,二是右子树最小的节点。两种操作是相反的,我们选择右子树的最小节点来实现。

// 删除最小节点操作
delectMin() {
  this.root = this._delectMin(this.root)
  return this.root
}

_delectMin(node) {
  // 一直向左遍历,直到没有左子树的节点,访问该节点是否有右子树
  // 如果有继续之前的遍历,如果没有,用右子树代替删除的节点
  if ((node != null) & (!node.left) return node.right
  node.left = this._delectMin(node.left)
  // 删除节点之后需要重新维护树的size属性
  node.size = this._getSize(node.left) + this._getSize(node.right) + 1
  return node
}

// 删除一个节点
delect(v) {
  this.root = this._delect(this.root, v)
}

_delect(node, v) {
  if (!node) return null
  if (node.value < v) {
    node.right = this._delect(node.right, v)
  } else if (node.value > v) {
    node.left = this._delect(node.left, v)
  } else {
    if (!node.left) return node.right
    if (!node.right) return node.left
    let min = this._getMin(node.right)
    min.right = this._delectMin(node.right)
    min.left = node.left
    node = min
  }
  node.size = this._getSize(node.left) + this._getSize(node.right) + 1
  return node
}

二叉搜索树也是比较基础的树,还有很多其他的树,如二叉树优化后的「自平衡二叉搜索树」,红黑树等等。

二叉树相关的面试题也是相当的多,可以先罗列,后续在实现:

  • 重建二叉树
  • 树的子结构
  • 二叉树的镜像
  • 从上往下打印二叉树
  • 二叉树中和为某一值的路径
  • 二叉树的深度
  • 二叉树与双向链表
  • 对称的二叉树
  • 二叉搜索树的第k小的节点
  • ...

下一篇文章会再写一写自平衡二叉树,这属于对于二叉搜索树的一种优化,对二叉树分别做时间复杂度分析。