漫漫前端路之数据结构与算法基础XII——二叉树|二叉查找树|红黑树篇

208 阅读13分钟

image.png image.png image.png

二叉树

image.png 其中,编号 2 的二叉树中,叶子节点全都在最底层,除了叶子节点之外,每个节点都有左右两个子节点,这种二叉树就叫做满二叉树。 编号 3 的二叉树中,叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大,这种二叉树叫做完全二叉树image.png

二叉树的表示

基于指针或者引用的二叉链式存储法

image.png

基于数组的顺序存储法

image.png 上面是一棵完全二叉树,所以仅仅“浪费”了一个下标为 0 的存储位置。如果是非完全二叉树,其实会浪费比较多的数组存储空间。 image.png

二叉树的遍历

前序遍历、中序遍历和后序遍历

前、中、后序,表示的是节点与它的左右子树节点遍历打印的先后顺序。 image.png 每个节点最多会被访问两次,所以遍历操作的时间复杂度,跟节点的个数 n 成正比,也就是说二叉树遍历的时间复杂度是 O(n)。

前序遍历的递推公式:
preOrder(r) = print r->preOrder(r->left)->preOrder(r->right)

中序遍历的递推公式:
inOrder(r) = inOrder(r->left)->print r->inOrder(r->right)

后序遍历的递推公式:
postOrder(r) = postOrder(r->left)->postOrder(r->right)->print r
void preOrder(Node* root) {
  if (root == null) return;
  print root // 此处为伪代码,表示打印root节点
  preOrder(root->left);
  preOrder(root->right);
}

void inOrder(Node* root) {
  if (root == null) return;
  inOrder(root->left);
  print root // 此处为伪代码,表示打印root节点
  inOrder(root->right);
}

void postOrder(Node* root) {
  if (root == null) return;
  postOrder(root->left);
  postOrder(root->right);
  print root // 此处为伪代码,表示打印root节点
}

层序遍历

深度优先遍历

function levelOrder (root) {
  let res = [];
  function traversal(root, depth){
    if(!root) return;
    if(!res[depth]) res.depth = [];
    else res[depth].push(root.val);
    traversal(root.left, depth+1)
    traversal(root.right, depth+1)
  }
  traversal(root, 0);
  return res;
}

广度优先遍历

function levelOrder (root){
  let res = [];
  let queue = [];
  if(root) queue.push(root)
  while(queue,length){
    let level = []
    for(let i = 0; i < queue.length; i++){
      let cur = queue.shift();
      level.push(cur.val);
      if(cur.left) queue.push(cur.left)
      if(cur.right) queue.push(cur.right)
    }
    res.push(level);
  }
  return res;
}

二叉查找树

在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。因此,中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度是 O(n),非常高效。 image.png

二叉查找树的查找操作

如果要查找的数据比根节点的值小,那就在左子树中递归查找;如果要查找的数据比根节点的值大,那就在右子树中递归查找。 image.png

function Find (p, data){
    while (p != null) {
      if (data < p.data) p = p.left;
      else if (data > p.data) p = p.right;
      else return p;
    }
    return null;
}

二叉查找树的插入操作

如果要插入的数据比节点的数据大,并且节点的右子树为空,就将新数据直接插到右子节点的位置;如果不为空,就再递归遍历右子树,查找插入位置。同理,如果要插入的数据比节点数值小,并且节点的左子树为空,就将新数据插入到左子节点的位置;如果不为空,就再递归遍历左子树,查找插入位置。

function Find (p, data){
  if(!p) return new Node(data)
  while (p != null) {
    if(data > p.data){
      if(!p.right){
        p.right = new Node(data);
        return 
      } 
      p = p.right;
    }
    else{
      if(!p.left){
        p.right = new Node(data);
        return;
      }
      p = p.left;
    }
  }
  return null;
}

二叉查找树的删除操作

image.png

  • 如该节点没有子节点,直接删除,指针指向null即可;
  • 如该节点只有一个子节点,只需将该节点指向子节点即可;
  • 如该节点有两个子节点,需要找到右子树的最小节点,替换要删除的节点,并删除最小节点。
function Delete (root, data){
  let p = root;
  while(p){
    if(p.data === data) break;
    else if(p.data < data) p = p.right;
    else p = p.left;
  }
  if(!p) return;
  // 删除有两个子节点的节点
  if(p.left && p.right){
    let min = p.right;
    while(!min.left){
      min = min.left;
    }
    p.data = min.data;
    min = null;
  }else if(!p.left && !p.right){ // 删除有一个子节点的节点
    p = null
  }else{ // 删除没有子节点的节点
    if(p.left) p = p.left;
    else p = p.right;
  }
}

支持重复数据的二叉查找树

在实际的软件开发中,在二叉查找树中存储的,是一个包含很多字段的对象。利用对象的某个字段作为键值(key)来构建二叉查找树,对象中的其他字段叫作卫星数据。 如果存储的两个对象键值相同,这种情况该怎么处理呢?

  • 二叉查找树中每一个节点不仅会存储一个数据,因此我们通过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节点上。
  • 把这个新插入的数据当作大于这个节点的值来处理。 image.png 当要查找数据的时候,遇到值相同的节点,我们并不停止查找操作,而是继续在右子树中查找,直到遇到叶子节点,才停止。这样就可以把键值等于要查找值的所有节点都找出来。 image.png

二叉查找树的时间复杂度分析

  • 最差情况:第一种二叉查找树,根节点的左右子树极度不平衡,已经退化成了链表,所以查找的时间复杂度就变成了 O(n)。 image.png
  • 理想情况:二叉查找树是一棵完全二叉树(或满二叉树),时间复杂度取决于其高度,节点为n的完全二叉树高度求解:第 K 层包含的节点个数就是 2^(K-1),其中最后一层包含的节点个数在 1 个到 2^(L-1) 个之间(假设最大层数是 L)
n >= 1+2+4+8+...+2^(L-2)+1
n <= 1+2+4+8+...+2^(L-2)+2^(L-1)

L 的范围是[log2(n+1), log2n +1]。完全二叉树的层数小于等于 log2n +1,也就是说,完全二叉树的高度小于等于 log2n。

二叉查找树 vs 散列表

  • 输出有序数据复杂度:散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序。而对于二叉查找树来说,我们只需要中序遍历,就可以在 O(n) 的时间复杂度内,输出有序的数据序列。
  • 性能稳定:散列表扩容耗时很多,而且当遇到散列冲突时,性能不稳定,尽管二叉查找树的性能不稳定,但是在工程中,我们最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在 O(logn)。
  • 查找时间复杂度:尽管散列表的查找等操作的时间复杂度是常量级的,但因为哈希冲突的存在,这个常量不一定比 logn 小,所以实际的查找速度可能不一定比 O(logn) 快。加上哈希函数的耗时,也不一定就比平衡二叉查找树的效率高。
  • 构造复杂度:散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计、冲突解决办法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定。
  • 空间复杂度:为了避免过多的散列冲突,散列表装载因子不能太大,特别是基于开放寻址法解决冲突的散列表,不然会浪费一定的存储空间。

平衡二叉查找树

  • 严格定义:二叉树中任意一个节点的左右子树的高度相差不能大于 1且在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值,比如AVL树image.png
  • 宽松定义:平衡二叉查找树中“平衡”的意思,是让整棵树左右看起来比较“对称”、比较“平衡”,不要出现左子树很高、右子树很矮的情况。这样就能让整棵树的高度相对来说低一些,相应的插入、删除、查找等操作的效率高一些。

红黑树

红黑树中的节点,一类被标记为黑色,一类被标记为红色。且红黑树满足以下要求:

  • 根节点是黑色的;每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据;
  • 任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;
  • 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点; image.png 其中,将黑色的、空的叶子节点都省略掉。

为什么红黑树近似平衡的(高度趋近与log2n)?

“平衡”的意思可以等价为性能不退化。“近似平衡”就等价为性能不会退化得太严重。二叉查找树很多操作的性能都跟树的高度成正比。我们只需要分析,红黑树的高度是否比较稳定地趋近 log2n 就好了。

  • 首先,我们来看,如果我们将红色节点从红黑树中去掉,有些节点就没有父节点了,它们会直接拿这些节点的祖父节点(父节点的父节点)作为父节点。所以,之前的二叉树就变成了四叉树image.png 根据从任意节点到可达的叶子节点的每个路径包含相同数目的黑色节点,我们从四叉树中取出某些节点,放到叶节点位置,四叉树就变成了完全二叉树。所以,仅包含黑色节点的四叉树的高度,比包含相同节点个数的完全二叉树的高度还要小。完全二叉树的高度近似 log2n,这里的四叉“黑树”的高度要低于完全二叉树,所以去掉红色节点的“黑树”的高度也不会超过 log2n。
  • 知道只包含黑色节点的“黑树”的高度,那我们现在把红色节点加回去,红黑树中,红色节点不能相邻,也就是说,有一个红色节点就要至少有一个黑色节点,将它跟其他红色节点隔开。红黑树中包含最多黑色节点的路径不会超过 log2n,所以加入红色节点之后,最长路径不会超过 2log2n,也就是说,红黑树的高度近似 2log2n。

红黑树 vs AVL树

  • AVL 树是一种高度平衡的二叉树,所以查找的效率非常高,但是,有利就有弊,AVL 树为了维持这种高度的平衡,就要付出更多的代价。每次插入、删除都要做调整,就比较复杂、耗时。所以,对于有频繁的插入、删除操作的数据集合,使用 AVL 树的代价就有点高了。
  • 红黑树只是做到了近似平衡,并不是严格的平衡,所以在维护平衡的成本上,要比 AVL 树要低。所以,红黑树的插入、删除、查找各种操作性能都比较稳定。对于工程应用来说,要面对各种异常情况,为了支撑这种工业级的应用,我们更倾向于这种性能稳定的平衡二叉查找树。

红黑树平衡维持

  • 左/右旋 左旋全称其实是叫围绕某个节点的左旋,右旋类似。 image.png 在插入、删除节点的过程中,要求红点必须黑点隔开的要求与从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点这两点要求可能会遭到破坏。
  • 插入操作的平衡调整 红黑树规定,插入的节点必须是红色的。而且,二叉查找树中新插入的节点都是放在叶子节点上。
  1. 如果插入节点的父节点是黑色的,那我们什么都不用做,它仍然满足红黑树的定义。
  2. 如果插入的节点是根节点,那我们直接改变它的颜色,把它变成黑色就可以了。
  3. 如果关注节点是 a,它的叔叔节点 d 是红色
  • 将关注节点 a 的父节点 b、叔叔节点 d 的颜色都设置成黑色;
  • 将关注节点 a 的祖父节点 c 的颜色设置成红色;
  • 关注节点变成 a 的祖父节点 c;
  • 跳到 CASE4或者 CASE5。 image.png
  1. 如果关注节点是 a,它的叔叔节点 d 是黑色,关注节点 a 是其父节点 b 的右子节点。
  • 关注节点变成节点 a 的父节点 b;
  • 围绕新的关注节点b 左旋;
  • 跳到 CASE5。 image.png
  1. 如果关注节点是 a,它的叔叔节点 d 是黑色,关注节点 a 是其父节点 b 的左子节点,我们就依次执行下面的操作: image.png
  • 删除操作的平衡调整
  • 初步调整 经过初步调整之后,为了保证满足红黑树定义的最后一条要求,有些节点会被标记成两种颜色,“红 - 黑”或者“黑 - 黑”。如果一个节点被标记为了“黑 - 黑”,那在计算黑色节点个数的时候,要算成两个黑色节点。
  1. 如果要删除的节点是 a,它只有一个子节点 b
  • 删除节点 a,并且把节点 b 替换到节点 a 的位置,这一部分操作跟普通的二叉查找树的删除操作一样;
  • 节点 a 只能是黑色,节点 b 也只能是红色,其他情况均不符合红黑树的定义。这种情况下,我们把节点 b 改为黑色;
  • 调整结束,不需要进行二次调整。 image.png
  1. 如果要删除的节点 a 有两个非空子节点,并且它的后继节点就是节点 a 的右子节点 c。
  • 如果节点 a 的后继节点就是右子节点 c,那右子节点 c 肯定没有左子树。我们把节点 a 删除,并且将节点 c 替换到节点 a 的位置。这一部分操作跟普通的二叉查找树的删除操作无异;
  • 然后把节点 c 的颜色设置为跟节点 a 相同的颜色;
  • 如果节点 c 是黑色,为了不违反红黑树的最后一条定义,我们给节点 c 的右子节点 d 多加一个黑色,这个时候节点 d 就成了“红 - 黑”或者“黑 - 黑”;
  • 这个时候,关注节点变成了节点 d,第二步的调整操作就会针对关注节点来做 image.png
  1. 如果要删除的是节点 a,它有两个非空子节点,并且节点 a 的后继节点不是右子节点。
  • 找到后继节点 d,并将它删除,删除后继节点 d 的过程参照 CASE 1;
  • 将节点 a 替换成后继节点 d;
  • 把节点 d 的颜色设置为跟节点 a 相同的颜色;
  • 如果节点 d 是黑色,为了不违反红黑树的最后一条定义,我们给节点 d 的右子节点 c 多加一个黑色,这个时候节点 c 就成了“红 - 黑”或者“黑 - 黑”;
  • 这个时候,关注节点变成了节点 c,第二步的调整操作就会针对关注节点来做。 image.png
  • 再次调整
  1. 如果关注节点是 a,它的兄弟节点 c 是红色的:
  • 围绕关注节点 a 的父节点 b 左旋;关注节点 a 的父节点 b 和祖父节点 c 交换颜色;
  • 关注节点不变;继续从四种情况中选择适合的规则来调整。 image.png
  1. 如果关注节点是 a,它的兄弟节点 c 是黑色的,并且节点 c 的左右子节点 d、e 都是黑色的:
  • 将关注节点 a 的兄弟节点 c 的颜色变成红色;
  • 从关注节点 a 中去掉一个黑色,这个时候节点 a 就是单纯的红色或者黑色;
  • 给关注节点 a 的父节点 b 添加一个黑色,这个时候节点 b 就变成了“红 - 黑”或者“黑 - 黑”;
  • 关注节点从 a 变成其父节点 b;
  • 继续从四种情况中选择符合的规则来调整。 image.png
  1. 如果关注节点是 a,它的兄弟节点 c 是黑色,c 的左子节点 d 是红色,c 的右子节点 e 是黑色:
  • 围绕关注节点 a 的兄弟节点 c 右旋;
  • 节点 c 和节点 d 交换颜色;
  • 关注节点不变;
  • 跳转到 CASE 4,继续调整。 image.png
  1. 如果关注节点 a 的兄弟节点 c 是黑色的,并且 c 的右子节点是红色的
  • 围绕关注节点 a 的父节点 b 左旋;
  • 将关注节点 a 的兄弟节点 c 的颜色,跟关注节点 a 的父节点 b 设置成相同的颜色;
  • 将关注节点 a 的父节点 b 的颜色设置为黑色;
  • 从关注节点 a 中去掉一个黑色,节点 a 就变成了单纯的红色或者黑色;
  • 将关注节点 a 的叔叔节点 e 设置为黑色;
  • 调整结束。 image.png

动态数组结构

动态数据结构有链表,栈,队列,哈希表等等。链表适合遍历的场景,插入和删除操作方便,栈和队列可以算一种特殊的链表,分别适用先进后出和先进先出的场景。哈希表适合插入和删除比较少(尽量少的扩容和缩容),查找比较多的时候。红黑树对数据要求有序,对数据增删查都有一定要求的时候。

资源来源

time.geekbang.org/column/arti… time.geekbang.org/column/arti…