End

数据结构与算法之美-9 二叉树 红黑树

本文地址


目录

二叉树

前面讲的都是线性表结构,栈、队列等等。今天讲的树是一种非线性表结构,树这种数据结构比线性表的数据结构要复杂得多,内容也比较多:

  • 数、二叉树
  • 二叉查找树
  • 平衡二叉查找树、红黑树
  • 递归树

23 | 二叉树基础(上):什么样的二叉树适合用数组来存储?

树的相关概念

比如下面这幅图,A 节点就是 B 节点的父节点,B 节点是 A 节点的子节点。B、C、D 这三个节点的父节点是同一个节点,所以它们之间互称为兄弟节点。我们把没有父节点的节点叫做根节点,也就是图中的节点 E。我们把没有子节点的节点叫做叶子节点,比如图中的 G、H、I、J、K、L 都是叶子节点。

高度(Height)、深度(Depth)、层(Level)

  • 在我们的生活中,高度这个概念就是从下往上度量,从最底层开始计数,并且计数的起点是 0。
  • 深度这个概念在生活中是从上往下度量的,从根结点开始度量,并且计数起点也是 0。
  • 层数跟深度的计算类似,不过,计数起点是 1,也就是说根节点位于第 1 层。

二叉树 Binary Tree

二叉树,顾名思义,每个节点最多有两个,也就是两个子节点,分别是左子节点右子节点

上图中,编号 2 的二叉树中,叶子节点全都在最底层,除了叶子节点之外,每个节点都有左右两个子节点,这种二叉树就叫做满二叉树

编号 3 的二叉树中,叶子节点都在最底下两层最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大,这种二叉树叫做完全二叉树

如何存储一棵二叉树

链式存储法

每个节点有三个字段,其中一个存储数据,另外两个是指向左右子节点的指针。我们只要拎住根节点,就可以通过左右子节点的指针,把整棵树都串起来。

这种存储方式我们比较常用,大部分二叉树代码都是通过这种结构来实现的。

顺序存储法

我们把根节点 A 存储在下标 i = 1 的位置,左子节点 B 存储在下标 2 * i = 2 的位置,右子节点 C 存储在 2 * i + 1 = 3 的位置。以此类推,B 节点的左子节点 D 存储在 2 * i = 2 * 2 = 4 的位置,右子节点 E 存储在 2 * i + 1 = 2 * 2 + 1 = 5 的位置。

总结一下,如果节点 X 存储在数组中下标为 i 的位置,下标为 2 * i 的位置存储的就是左子节点,下标为 2 * i + 1 的位置存储的就是右子节点。反过来,下标为 i/2 的位置存储就是它的父节点。通过这种方式,我们只要知道根节点存储的位置(一般情况下,为了方便计算子节点,根节点会存储在下标为 1 的位置),这样就可以通过下标计算,把整棵树都串起来。

不过,刚刚举的例子是一棵完全二叉树(而非满二叉树),所以仅仅浪费了一个下标为 0 的存储位置。如果是非完全二叉树,其实会浪费比较多的数组存储空间。比如下面这个例子。

所以,如果某棵二叉树是一棵完全二叉树,那用数组存储无疑是最节省内存的一种方式。因为数组的存储方式并不需要像链式存储法那样,要存储额外的左右子节点的指针。这也是为什么完全二叉树会单独拎出来的原因,也是为什么完全二叉树要求最后一层的子节点都靠左的原因。

堆其实就是一种完全二叉树,最常用的存储方式就是数组。

二叉树的遍历(深度遍历)

前序遍历、中序遍历和后序遍历中的前中后,表示的是节点与它的左右子树节点遍历打印的先后顺序。

  • 前序遍历是指,对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树
  • 中序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印节点本身,最后打印它的右子树
  • 后序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印节点本身

实际上,二叉树的前、中、后序遍历就是一个递归的过程。比如,前序遍历,其实就是先打印根节点,然后再递归地打印左子树,最后递归地打印右子树。

写递归代码的关键,就是看能不能写出递推公式,而写递推公式的关键就是,如果要解决问题 A,就假设子问题 B、C 已经解决,然后再来看如何利用 B、C 来解决 A。所以,我们可以把前、中、后序遍历的递推公式都写出来。

前序遍历的递推公式: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

时间复杂度是 O(n)

24 | 二叉树基础(下):二叉查找树

二叉查找树最大的特点就是,支持动态数据集合的快速插入、删除、查找操作。

我们之前说过,散列表也是支持这些操作的,并且散列表的这些操作比二叉查找树更高效,时间复杂度是 O(1)。既然有了这么高效的散列表,使用二叉树的地方是不是都可以替换成散列表呢?有没有哪些地方是散列表做不了,必须要用二叉树来做的呢?

二叉查找树 Binary Search Tree

二叉查找树是二叉树中最常用的一种类型,也叫二叉搜索树。顾名思义,二叉查找树是为了实现快速查找而生的。不过,它不仅仅支持快速查找一个数据,还支持快速插入、删除一个数据。它是怎么做到这些的呢?

这些都依赖于二叉查找树的特殊结构。二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。

查找操作

  • 我们先取根节点,如果它等于我们要查找的数据,那就返回
  • 如果要查找的数据比根节点的值,那就在左子树中递归查找
  • 如果要查找的数据比根节点的值,那就在右子树中递归查找

public class BinarySearchTree {
  private Node tree;

  public Node find(int data) {
    Node p = tree;
    while (p != null) {
      if (data < p.data) p = p.left;
      else if (data > p.data) p = p.right;
      else return p;
    }
    return null;
  }

  public static class Node {
    private int data;
    private Node left;
    private Node right;

    public Node(int data) {
      this.data = data;
    }
  }
}

插入操作

新插入的数据都是在叶子节点上,所以我们只需要从根节点开始,依次比较要插入的数据和节点的大小关系。

  • 如果要插入的数据比节点的数据
    • 如果节点的右子树为空,就将新数据直接插到右子节点的位置
    • 如果不为空,就再递归遍历右子树,查找插入位置
  • 如果要插入的数据比节点数值
    • 如果节点的左子树为空,就将新数据插入到左子节点的位置
    • 如果不为空,就再递归遍历左子树,查找插入位置

public void insert(int data) {
  if (tree == null) {
    tree = new Node(data);
    return;
  }

  Node p = tree;
  while (p != null) {
    if (data > p.data) {
      if (p.right == null) {
        p.right = new Node(data);
        return;
      }
      p = p.right;
    } else { //即 data < p.data
      if (p.left == null) {
        p.left = new Node(data);
        return;
      }
      p = p.left;
    }
  }
}

删除操作

二叉查找树的删除操作相对就比较复杂了,针对要删除节点的子节点个数的不同,我们需要分三种情况来处理。

  • 如果要删除的节点没有子节点,我们只需要直接将父节点中,指向要删除节点的指针置为 null。比如图中的删除节点 55
  • 如果要删除的节点只有一个左子节点或者右子节点,我们只需要更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点就可以了。比如图中的删除节点 13
  • 如果要删除的节点有两个子节点
    • 我们需要找到这个节点的右子树中的最小节点,把它替换到要删除的节点上
    • 然后再删除掉这个最小节点
    • 因为最小节点肯定没有左子节点,所以,我们可以应用上面两条规则来删除这个最小节点。比如图中的删除节点 18

public void delete(int data) {
  Node p = tree; // p指向要删除的节点,初始化指向根节点
  Node pp = null; // pp记录的是p的父节点
  while (p != null && p.data != data) {
    pp = p;
    if (data > p.data) p = p.right;
    else p = p.left;
  }
  if (p == null) return; // 没有找到

  // 要删除的节点有两个子节点
  if (p.left != null && p.right != null) { // 查找右子树中最小节点
    Node minP = p.right;
    Node minPP = p; // minPP表示minP的父节点
    while (minP.left != null) { //最小节点就是最左的子节点,也就是没有左子节点的结点
      minPP = minP;
      minP = minP.left;
    }
    p.data = minP.data; // 将minP的数据替换到p中
    p = minP; // 下面就变成了删除minP了(即被删除的结点不可能有两个子节点了)
    pp = minPP;
  }

  // 删除节点是叶子节点或者【仅有一个子节点】
  Node child; // p的子节点
  if (p.left != null) child = p.left; //如果有左子节点,就不会有右子节点
  else if (p.right != null) child = p.right; //如果有又子节点,就不会有左子节点
  else child = null;

  if (pp == null) tree = child; // 删除的是根节点
  else if (pp.left == p) pp.left = child;
  else pp.right = child;
}

实际上,关于二叉查找树的删除操作,还有个非常简单、取巧的方法,就是单纯将要删除的节点标记为已删除,但是并不真正从树中将这个节点去掉。这样原本删除的节点还需要存储在内存中,比较浪费内存空间,但是删除操作就变得简单了很多。而且,这种处理方法也并没有增加插入、查找操作代码实现的难度。

其他操作:二叉排序树

除了插入、删除、查找操作之外,二叉查找树中还可以支持快速地查找最大/小节点前驱节点后继节点

二叉查找树除了支持上面几个操作之外,还有一个重要的特性,就是中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度是 O(n),因此,二叉查找树也叫作二叉排序树

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

有两种解决方法

  • 第一种方法:可以通过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节点上
  • 第二种方法:每个节点仍然只存储一个数据
    • 在查找插入位置的过程中,如果碰到一个节点的值与要插入数据的值相同,我们就将这个要插入的数据放到这个节点的右子树,也就是说,把这个新插入的数据当作大于这个节点的值来处理。
    • 查找数据的时候,如果遇到值相同的节点,我们并不停止查找操作,而是继续在右子树中查找,直到遇到叶子节点才停止。这样就可以把键值等于要查找值的所有节点都找出来。
    • 对于删除操作,我们也需要先查找到每个要删除的节点,然后再按前面讲的删除操作的方法,依次删除。

时间复杂度分析

完全二叉树的层数小于等于 logn + 1高度小于等于 logn

确定二叉树高度的一种方案:采用深度优先的递归,分别求左右子树的高度,当前节点的高度就是左右子树中较大的那个+1

在二叉查找树中,查找、插入、删除等很多操作的时间复杂度都跟树的高度成正比。两个极端情况的时间复杂度分别是 O(n)O(logn),分别对应二叉树退化成链表是完全二叉树的情况。

下一节要讲的平衡二叉查找树的高度接近 logn,所以插入、删除、查找操作的时间复杂度也比较稳定,是 O(logn)

解答开篇

相对散列表,二叉查找树有什么优势呢?

第一,散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序。而对于二叉查找树来说,我们只需要中序遍历,就可以在 O(n) 的时间复杂度内,输出有序的数据序列。

第二,散列表扩容耗时很多,而且当遇到散列冲突时,性能不稳定,尽管二叉查找树的性能不稳定,但是在工程中,我们最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在 O(logn)

第三,笼统地来说,尽管散列表的查找等操作的时间复杂度是常量级的,但因为哈希冲突的存在,这个常量不一定比 logn 小,所以实际的查找速度可能不一定比 O(logn) 快。加上哈希函数的耗时,也不一定就比平衡二叉查找树的效率高。

第四,散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计、冲突解决办法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定。

最后,为了避免过多的散列冲突,散列表装载因子不能太大,特别是基于开放寻址法解决冲突的散列表,不然会浪费一定的存储空间。

综合这几点,平衡二叉查找树在某些方面还是优于散列表的,所以,这两者的存在并不冲突。我们在实际的开发过程中,需要结合具体的需求来选择使用哪一个。

红黑树

25 | 红黑树(上):为什么工程中都用红黑树这种二叉树?

平衡二叉查找树

  • 平衡二叉树:二叉树中任意一个节点的左右子树的高度相差不能大于 1
  • 平衡二叉查找树:除了满足上面平衡二叉树的定义,还满足二叉查找树的特点。

最先被发明的平衡二叉查找树是 AVL 树,它严格符合平衡二叉查找树的定义,即任何节点的左右子树高度相差不超过 1,是一种高度平衡的二叉查找树。但是很多平衡二叉查找树其实并没有严格符合上面的定义,比如下面要讲的红黑树,它从根节点到各个叶子节点的最长路径,有可能会比最短路径大一倍。

发明平衡二叉查找树这类数据结构的初衷是,解决普通二叉查找树在频繁的插入、删除等动态更新的情况下,出现时间复杂度退化的问题。

平衡二叉查找树中“平衡”的意思,其实就是让整棵树左右看起来比较“对称”、比较“平衡”,不要出现左子树很高、右子树很矮的情况。这样就能让整棵树的高度相对来说低一些,相应的插入、删除、查找等操作的效率高一些。

所以,只要一个平衡二叉查找树的高度不比 logn 大很多,尽管它不严格符合平衡二叉查找树的定义,但仍然是一个合格的平衡二叉查找树。

红黑树的定义

平衡二叉查找树其实有很多,比如,Splay Tree(伸展树)、Treap(树堆)等,但是提到平衡二叉查找树,听到的基本都是红黑树。它的出镜率甚至要高于“平衡二叉查找树”这几个字,有时候,我们甚至默认平衡二叉查找树就是红黑树。

红黑树的英文是Red-Black Tree,简称 R-B Tree,它是一种不严格的平衡二叉查找树。

红黑树中的节点,一类被标记为黑色,一类被标记为红色。除此之外,一棵红黑树还需要满足这样几个要求:

  • 根节点是黑色的
  • 每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据
    • 这一条主要是为了简化红黑树的代码实现而设置的
    • 下面在画图和讲解的时候,会将黑色的、空的叶子节点都省略掉
  • 任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的
  • 每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点

为什么说红黑树是“近似平衡”的?

平衡二叉查找树的初衷是为了解决二叉查找树因为动态更新导致的性能退化问题。所以,“平衡”的意思可以等价为性能不退化。“近似平衡”就等价为性能不会退化得太严重。

一棵极其平衡的二叉树(满二叉树或完全二叉树)的高度大约是 logn,所以如果要证明红黑树是近似平衡的,只需要分析红黑树的高度是否比较稳定地趋近 logn 就好了。

后面省略若干内容......

解答开篇

前面提到的 Treap、Splay Tree,绝大部分情况下,它们操作的效率都很高,但是也无法避免极端情况下时间复杂度的退化。尽管这种情况出现的概率不大,但是对于单次操作时间非常敏感的场景来说,它们并不适用。

AVL 树是一种高度平衡的二叉树,所以查找的效率非常高,但是,有利就有弊,AVL 树为了维持这种高度的平衡,就要付出更多的代价。每次插入、删除都要做调整,就比较复杂、耗时。所以,对于有频繁的插入、删除操作的数据集合,使用 AVL 树的代价就有点高了。

红黑树只是做到了近似平衡,并不是严格的平衡,所以在维护平衡的成本上,要比 AVL 树要低。所以,红黑树的插入、删除、查找各种操作性能都比较稳定,时间复杂度都是 O(logn)。对于工程应用来说,要面对各种异常情况,为了支撑这种工业级的应用,更倾向于这种性能稳定的平衡二叉查找树。

三种动态数据结构

支持动态的数据插入、删除、查找操作的动态数据结构:

  • 散列表:插入删除查找都是O(1),是最常用的,但其缺点是不能顺序遍历以及扩容缩容的性能损耗。适用于那些不需要顺序遍历,数据更新不那么频繁的。
  • 跳表:插入删除查找都是O(logn),并且能顺序遍历。缺点是空间复杂度O(n)。适用于不那么在意内存空间的,其顺序遍历和区间查找非常方便。
  • 红黑树:插入删除查找都是O(logn),中序遍历即是顺序遍历,稳定。缺点是难以实现,去查找不方便。其实跳表更佳,但红黑树已经用于很多地方了。

26 | 红黑树(下):掌握这些技巧,你也可以实现一个红黑树

实现红黑树的基本思想

实际上,魔方的复原解法是有固定算法的:遇到哪几面是什么样子,对应就怎么转几下。你只要跟着这个复原步骤,就肯定能将魔方复原。

红黑树的平衡过程跟魔方复原非常神似,大致过程就是:遇到什么样的节点排布,我们就对应怎么去调整。只要按照这些固定的调整规则来操作,就能将一个非平衡的红黑树调整成平衡的。

  • 左旋(rotate left):围绕某个节点的左旋
  • 右旋(rotate right):围绕某个节点的右旋

下图中的 a,b,r 表示子树,可以为空

插入操作的平衡调整

红黑树规定,插入的节点必须是红色的。而且,二叉查找树中新插入的节点都是放在叶子节点上。所以,关于插入操作的平衡调整,有这样两种非常好处理特殊情况:

  • 如果插入节点的父节点是黑色的,那我们什么都不用做,它仍然满足红黑树的定义
  • 如果插入的节点是根节点,那我们直接改变它的颜色,把它变成黑色就可以了

除此之外,其他情况都会违背红黑树的定义,于是我们就需要进行调整,调整的过程包含两种基础的操作:左右旋转改变颜色

红黑树的平衡调整过程是一个迭代的过程。我们把正在处理的节点叫做关注节点。关注节点会随着不停地迭代处理,而不断发生变化。最开始的关注节点就是新插入的节点。

新节点插入之后,如果红黑树的平衡被打破,那一般会有下面三种情况。我们只需要根据每种情况的特点,不停地调整,就可以让红黑树继续符合定义,也就是继续保持平衡。

为了简化描述,我把父节点的兄弟节点叫做叔叔节点,父节点的父节点叫做祖父节点

CASE 1:如果关注节点是 a,它的叔叔节点 d 是红色

我们就依次执行下面的操作:

  • 将关注节点 a 的父节点 b、叔叔节点 d 的颜色都设置成黑色
  • 将关注节点 a 的祖父节点 c 的颜色设置成红色
  • 关注节点变成 a 的祖父节点 c
  • 跳到 CASE 2 或者 CASE 3

CASE 2:如果关注节点是 a,它的叔叔节点 d 是黑色,关注节点 a 是其父节点 b 的右子节点

我们就依次执行下面的操作:

  • 关注节点变成节点 a 的父节点 b
  • 围绕新的关注节点 b 左旋
  • 跳到 CASE 3

CASE 3:如果关注节点是 a,它的叔叔节点 d 是黑色,关注节点 a 是其父节点 b 的左子节点

我们就依次执行下面的操作:

  • 围绕关注节点 a 的祖父节点 c 右旋
  • 将关注节点 a 的父节点 b、兄弟节点 c 的颜色互换
  • 调整结束

删除操作的平衡调整

红黑树插入操作的平衡调整还不是很难,但是它的删除操作的平衡调整相对就要难多了。不过原理都是类似的,我们依旧只需要根据关注节点与周围节点的排布特点,按照一定的规则去调整就行了。

删除操作的平衡调整分为两步,第一步是针对删除节点初步调整。初步调整只是保证整棵红黑树在一个节点删除之后,仍然满足最后一条定义的要求,也就是说,每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点;第二步是针对关注节点进行二次调整,让它满足红黑树的第三条定义,即不存在相邻的两个红色节点。

针对删除节点初步调整

这里需要注意一下,红黑树的定义中“只包含红色节点和黑色节点”,经过初步调整之后,为了保证满足红黑树定义的最后一条要求,有些节点会被标记成两种颜色,“红 - 黑”或者“黑 - 黑”。如果一个节点被标记为了“黑 - 黑”,那在计算黑色节点个数的时候,要算成两个黑色节点。

在下面的讲解中,如果一个节点既可以是红色,也可以是黑色,在画图的时候,我会用一半红色一半黑色来表示。如果一个节点是“红 - 黑”或者“黑 - 黑”,我会用左上角的一个小黑点来表示额外的黑色。

CASE 1:如果要删除的节点是 a,它只有一个子节点 b

那我们就依次进行下面的操作:

  • 删除节点 a,并且把节点 b 替换到节点 a 的位置,这一部分操作跟普通的二叉查找树的删除操作一样
  • 节点 a 只能是黑色,节点 b 也只能是红色,其他情况均不符合红黑树的定义。这种情况下,我们把节点 b 改为黑色
  • 调整结束,不需要进行二次调整

CASE 2:如果要删除的节点 a 有两个非空子节点,并且它的后继节点就是节点 a 的右子节点 c

我们就依次进行下面的操作:

  • 如果节点 a 的后继节点就是右子节点 c,那右子节点 c 肯定没有左子树。我们把节点 a 删除,并且将节点 c 替换到节点 a 的位置。这一部分操作跟普通的二叉查找树的删除操作无异
  • 然后把节点 c 的颜色设置为跟节点 a 相同的颜色
  • 如果节点 c 是黑色,为了不违反红黑树的最后一条定义,我们给节点 c 的右子节点 d 多加一个黑色,这个时候节点 d 就成了“红 - 黑”或者“黑 - 黑”
  • 这个时候,关注节点变成了节点 d,第二步的调整操作就会针对关注节点来做

CASE 3:如果要删除的是节点 a,它有两个非空子节点,并且节点 a 的后继节点不是右子节点

我们就依次进行下面的操作:

  • 找到后继节点 d,并将它删除,删除后继节点 d 的过程参照 CASE 1
  • 将节点 a 替换成后继节点 d
  • 把节点 d 的颜色设置为跟节点 a 相同的颜色
  • 如果节点 d 是黑色,为了不违反红黑树的最后一条定义,我们给节点 d 的右子节点 c 多加一个黑色,这个时候节点 c 就成了“红 - 黑”或者“黑 - 黑”
  • 这个时候,关注节点变成了节点 c,第二步的调整操作就会针对关注节点来做

针对关注节点进行二次调整

经过初步调整之后,关注节点变成了“红 - 黑”或者“黑 - 黑”节点。针对这个关注节点,我们再分四种情况来进行二次调整。二次调整是为了让红黑树中不存在相邻的红色节点。

CASE 1:如果关注节点是 a,它的兄弟节点 c 是红色的

我们就依次进行下面的操作:

  • 围绕关注节点 a 的父节点 b 左旋
  • 关注节点 a 的父节点 b 和祖父节点 c 交换颜色
  • 关注节点不变
  • 继续从四种情况中选择适合的规则来调整

CASE 2:如果关注节点是 a,它的兄弟节点 c 是黑色的,并且节点 c 的左右子节点 d、e 都是黑色的

我们就依次进行下面的操作:

  • 将关注节点 a 的兄弟节点 c 的颜色变成红色
  • 从关注节点 a 中去掉一个黑色,这个时候节点 a 就是单纯的红色或者黑色
  • 给关注节点 a 的父节点 b 添加一个黑色,这个时候节点 b 就变成了“红 - 黑”或者“黑 - 黑”
  • 关注节点从 a 变成其父节点 b
  • 继续从四种情况中选择符合的规则来调整

CASE 3:如果关注节点是 a,它的兄弟节点 c 是黑色,c 的左子节点 d 是红色,c 的右子节点 e 是黑色

我们就依次进行下面的操作:

  • 围绕关注节点 a 的兄弟节点 c 右旋
  • 节点 c 和节点 d 交换颜色
  • 关注节点不变
  • 跳转到 CASE 4,继续调整

CASE 4:如果关注节点 a 的兄弟节点 c 是黑色的,并且 c 的右子节点是红色的

我们就依次进行下面的操作:

  • 围绕关注节点 a 的父节点 b 左旋
  • 将关注节点 a 的兄弟节点 c 的颜色,跟关注节点 a 的父节点 b 设置成相同的颜色
  • 将关注节点 a 的父节点 b 的颜色设置为黑色
  • 从关注节点 a 中去掉一个黑色,节点 a 就变成了单纯的红色或者黑色
  • 将关注节点 a 的叔叔节点 e 设置为黑色
  • 调整结束

解答开篇

为什么红黑树的定义中,要求叶子节点是黑色的空节点?

之所以有这么奇怪的要求,其实就是为了实现起来方便。只要满足这一条要求,那在任何时刻,红黑树的平衡操作都可以归结为我们刚刚讲的那几种情况。

假设红黑树的定义中不包含刚刚提到的那一条“叶子节点必须是黑色的空节点”,我们往一棵红黑树中插入一个数据,新插入节点的父节点也是红色的,两个红色的节点相邻,这个时候,红黑树的定义就被破坏了。那我们应该如何调整呢?

你会发现,这个时候,我们前面在讲插入时,三种情况下的平衡调整规则,没有一种是适用的。但是,如果我们把黑色的空节点都给它加上,变成下面这样,你会发现,它满足 CASE 2 了。

你可能会说,你可以调整一下平衡调整规则啊。比如把 CASE 2 改为“如果关注节点 a 的叔叔节点 b 是黑色或者不存在,a 是父节点的右子节点,就进行某某操作”。当然可以,但是这样的话规则就没有原来简洁了。

你可能还会说,这样给红黑树添加黑色的空的叶子节点,会不会比较浪费存储空间呢?答案是不会的。虽然我们在讲解或者画图的时候,每个黑色的、空的叶子节点都是独立画出来的。实际上,在具体实现的时候,我们只需要像下面这样,共用一个黑色的、空的叶子节点就行了。

内容小结

很多人都认为红黑树很难学,其实主要原因是,他们都试图去记住它的平衡调整策略。实际上,你只需要理解这个调整过程就可以了,没有必要去记。

现在,我就来总结一下,如何比较轻松地看懂我今天讲的操作过程。

  • 第一点,把红黑树的平衡调整的过程比作魔方复原,不要过于深究这个算法的正确性。你只需要明白,只要按照固定的操作步骤,保持插入、删除的过程,不破坏平衡树的定义就行了。
  • 第二点,找准关注节点,不要搞丢、搞错关注节点。因为每种操作规则,都是基于关注节点来做的,只有弄对了关注节点,才能对应到正确的操作规则中。在迭代的调整过程中,关注节点在不停地改变,所以,这个过程一定要注意,不要弄丢了关注节点。
  • 第三点,插入操作的平衡调整比较简单,但是删除操作就比较复杂。针对删除操作,我们有两次调整,第一次是针对要删除的节点做初步调整,让调整后的红黑树继续满足第四条定义。但是这个时候,第三条定义就不满足了,有可能会存在两个红色节点相邻的情况。第二次调整就是解决这个问题,让红黑树不存在相邻的红色节点。

递归树

27 | 递归树:如何借助树来求解递归算法的时间复杂度?

递归代码的时间复杂度分析起来很麻烦,前面我们讲过如何利用递推公式求解归并排序、快速排序的时间复杂度。但是,有些情况用递推公式的话,会涉及非常复杂的数学推导。今天,我们就来学习另外一种方法,借助递归树来分析递归算法的时间复杂度。

递归树

递归的思想就是将大问题分解为小问题来求解,然后再将小问题分解为小小问题。这样一层一层地分解,直到问题的数据规模被分解得足够小,不用继续递归分解为止。

如果我们把这个一层一层的分解过程画成图,它其实就是一棵树。我们给这棵树起一个名字,叫作递归树

下图是斐波那契数列的递归树,节点里的数字表示数据的规模,一个节点的求解可以分解为左右子节点两个问题的求解。

利用递归树的时间复杂度分析方法并不难理解,关键还是在实战。

归并排序

归并排序每次会将数据规模一分为二,现在我们就借助归并排序来看看如何用递归树,分析递归代码的时间复杂度。

我们把归并排序画成递归树,就是下面这个样子:

归并算法中比较耗时的是归并操作,也就是把两个子数组合并为大数组。从图中我们可以看出,每一层归并操作消耗的时间总和是一样的,跟要排序的数据规模有关,我们把每一层归并操作消耗的时间记作 n。

现在,我们只需要知道这棵树的高度 h,用高度 h 乘以每一层的时间消耗 n,就可以得到总的时间复杂度 O(n∗h)

从归并排序的原理和递归树可以看出来,归并排序递归树是一棵满二叉树,满二叉树的高度大约是 logn,所以,归并排序递归实现的时间复杂度就是 O(nlogn)

快速排序

为什么用递推公式来求解平均时间复杂度非常复杂?

快速排序在最好情况下,每次分区都能一分为二,这个时候用递推公式 T(n) = 2*T(n/2) + n,很容易就能推导出时间复杂度是 O(nlogn)。但是,我们并不可能每次分区都正好一分为二。

假设平均情况下,每次分区之后,两个分区的大小比例为 1:k。当 k=9 时,用递推公式的方法来求解时间复杂度的话,递推公式就写成 T(n)=T(n/10) + T(9n/10) + n

这个公式可以推导出时间复杂度,但是推导过程非常复杂。

如果我们把递归分解的过程画成递归树,就是下面这个样子:

快速排序的过程中,每次分区都要遍历待分区区间的所有数据,所以,每一层分区操作所遍历的数据的个数之和就是 n。我们现在只要求出递归树的高度 h,这个快排过程遍历的数据个数就是 h∗n ,也就是说,时间复杂度就是 O(h∗n)

因为每次分区并不是均匀地一分为二,所以递归树并不是满二叉树。这样一个递归树的高度是多少呢?

我们知道,快速排序结束的条件就是待排序的小区间大小为 1,也就是说叶子节点里的数据规模是 1。从根节点 n 到叶子节点 1,递归树中最短的一个路径每次都乘以 1/10,最长的一个路径每次都乘以 9/10。通过计算,我们可以得到:

所以,遍历数据的个数总和就介于最短路径和最长路径之间,所以,当分区大小比例是 1:9 时,快速排序的时间复杂度仍然是 O(nlogn)

刚刚我们假设 k=9,那如果 k=99,也就是说,每次分区极其不平均,两个区间大小是 1:99,这个时候的时间复杂度是多少呢?

我们可以类比上面 k=9 的分析过程。当 k=99 的时候,尽管底数变了,但是时间复杂度也仍然是 O(nlogn)

也就是说,对于 k 等于 9,99,甚至是 999,9999……,只要 k 的值不随 n 变化,是一个事先确定的常量,那快排的时间复杂度就是 O(nlogn)

斐波那契数列

斐波那契数列的代码实现

int f(int n) {
  if (n == 1) return 1;
  if (n == 2) return 2;
  return f(n-1) + f(n-2);
}

把上面的递归代码画成递归树,就是下面这个样子:

这棵递归树的高度是多少呢?

f(n) 分解为 f(n−1)f(n−2),每次数据规模都是 −1 或者 −2,所以,从根节点走到叶子节点,每条路径是长短不一的。如果每次都是 −1,那最长路径大约就是 n;如果每次都是 −2,那最短路径大约就是 n/2

每次分解之后的合并操作只需要一次加法运算,我们把这次加法运算的时间消耗记作 1。所以,从上往下,第一层的总时间消耗是 1,第二层的总时间消耗是 2,第三层的总时间消耗就是 2^2。依次类推,第 k 层的时间消耗就是 2^(k−1),那整个算法的总的时间消耗就是每一层时间消耗之和。

如果路径长度都为 n,那这个总和就是 2^n − 1

所以,这个算法的时间复杂度就介于 O(2^n)O(2^n/2) 之间。

全排列

如何把 n 个数据的所有排列都找出来,这就是全排列的问题。

比如,1,2,3 这样 3 个数据,有下面这几种不同的排列:

1, 2, 3
1, 3, 2
2, 1, 3
2, 3, 1
3, 1, 2
3, 2, 1

如何编程打印一组数据的所有排列呢?这里就可以用递归来实现。

如果我们确定了最后一位数据,那就变成了求解剩下 n−1 个数据的排列问题。而最后一位数据可以是 n 个数据中的任意一个,因此它的取值就有 n 种情况。所以,n 个数据的全排列问题,就可以分解成 n 个 n−1 个数据的全排列的子问题。

递推公式:

假设数组中存储的是1,2, 3...n
f(1,2,...n) = {最后一位是1, f(n-1)} + {最后一位是2, f(n-1)} + ... + {最后一位是n, f(n-1)}
// 调用方式:
// int[]a = a={1, 2, 3, 4}; printPermutations(a, 4, 4);
// k表示要处理的子数组的数据个数
public void printPermutations(int[] data, int n, int k) {
  if (k == 1) {
    for (int i = 0; i < n; ++i) {
      System.out.print(data[i] + " ");
    }
    System.out.println();
  }

  for (int i = 0; i < k; ++i) {
    int tmp = data[i];
    data[i] = data[k-1];
    data[k-1] = tmp;

    printPermutations(data, n, k - 1);

    tmp = data[i];
    data[i] = data[k-1];
    data[k-1] = tmp;
  }
}

如果不用递归树分析方法,这个递归代码的时间复杂度会比较难分析。

首先,我们还是画出递归树。不过,现在的递归树已经不是标准的二叉树了。

第一层分解有 n 次交换操作,第二层有 n 个节点,每个节点分解需要 n−1 次交换,所以第二层总的交换次数是 n∗(n−1)。第三层有 n∗(n−1) 个节点,每个节点分解需要 n−2 次交换,所以第三层总的交换次数是 n∗(n−1)∗(n−2)

以此类推,第 k 层总的交换次数就是 n∗(n−1)∗(n−2)∗...∗(n−k+1)。最后一层的交换次数就是 n∗(n−1)∗(n−2)∗...∗2∗1。每一层的交换次数之和就是总的交换次数。

n + n*(n-1) + n*(n-1)*(n-2) +... + n*(n-1)*(n-2)*...*2*1

这个公式的求和比较复杂,我们看最后一个数,n∗(n−1)∗(n−2)∗...∗2∗1 等于 n!,而前面的 n−1 个数都小于最后一个数,所以,总和肯定小于 n∗n!,也就是说,全排列的递归算法的时间复杂度大于 O(n!),小于 O(n∗n!),虽然我们没法知道非常精确的时间复杂度,但是这样一个范围已经让我们知道,全排列的时间复杂度是非常高的。

掌握分析的方法很重要,思路是重点,不要纠结于精确的时间复杂度到底是多少。

内容小结

  • 有些代码的时间复杂度比较适合用递推公式来分析,比如归并排序、快速排序的最好情况时间复杂度
  • 有些比较适合采用递归树来分析,比如快速排序的平均时间复杂度
  • 有些可能两个都不怎么适合使用,比如二叉树的递归前中后序遍历

2021-9-1

posted @ 2021-09-01 21:25  白乾涛  阅读(196)  评论(0编辑  收藏  举报