Jay Wengrow - A Common-Sense Guide to Data Structures and Algorithms【自译】第15章

第15章 加速所有操作:使用二叉搜索树

有时,我们可能希望以特定顺序排列我们的数据。例如,我们可能希望按字母顺序列出姓名,或按价格从低到高列出产品清单。

虽然我们可以使用诸如快速排序之类的排序算法将数据排列成完美的升序,但这是有成本的。正如我们所见,即使是最快的算法也需要 O(N log N) 的时间。因此,如果我们经常希望数据排序,将数据一开始就保持在排序状态是明智的,这样我们就不需要重复进行排序。

有序数组是保持数据有序的一种简单而有效的工具。对于某些操作,它也很快,因为它具有 O(1) 的读取速度和 O(log N) 的搜索速度(使用二分搜索)。

然而,有序数组也有一个缺点。

在插入和删除方面,有序数组相对较慢。每当向有序数组插入一个值时,我们首先需要将所有更大的值向右移动一个单元。当从有序数组中删除一个值时,我们需要将所有更大的值向左移动一个单元。在最坏情况下(插入或删除第一个数组单元格),这需要 N 步,平均下来大约是 N / 2 步。无论如何,它的时间复杂度都是 O(N),对于简单的插入或删除而言,O(N) 相对较慢。

如果我们正在寻找一种在各方面都提供惊人速度的数据结构,哈希表是一个不错的选择。它们在搜索、插入和删除方面的时间复杂度都是 O(1)。然而,它们不保持顺序,而顺序对于我们的按字母顺序列出清单应用是必要的。

那么,如果我们希望有一种数据结构来保持顺序,并且搜索、插入和删除速度快,那该怎么办?有序数组和哈希表都不是理想的选择。

于是,二叉搜索树登场。

在前一章中,你接触到了基于节点的数据结构,即链表。在经典链表中,每个节点包含一个将该节点连接到单个其他节点的链接。而树也是一种基于节点的数据结构,但在树中,每个节点可以链接到多个节点。

以下是一个简单树的可视化表示:

在这里插入图片描述

在这个例子中,每个节点都有链接到两个其他节点的链接。为了简单起见,我们可以在可视化表示中不显示所有的内存地址。

在这里插入图片描述

树具有自己独特的命名规则:

  • 最上方的节点(在我们的例子中是“j”)被称为根节点。是的,在我们的图中,根节点位于树的顶部;这是树通常被描绘的方式。
  • 在我们的例子中,“j”是“m”和“b”的父节点。反过来,“m”和“b”是“j”的子节点。类似地,“m”是“q”和“z”的父节点,“q”和“z”是“m”的子节点。
  • 就像家谱一样,一个节点可以有后代和祖先。一个节点的后代是从该节点发出的所有节点,而祖先则是所有指向该节点的节点。在我们的例子中,“j”是树中所有其他节点的祖先,因此,所有其他节点都是“j”的后代。
  • 树有所谓的层级。每个层级是树中的一行。我们的例子树有三个层级。

在这里插入图片描述

  • 树的一个特性是它的平衡性。当树的节点子树中包含相同数量的节点时,树就是平衡的

例如,上面的树被认为是完全平衡的。如果你观察每个节点,你会发现它的两个子树包含相同数量的节点。根节点(“j”)有两个子树,每个子树包含三个节点。你会发现这对树中的每个节点也是成立的。例如,“m”节点也有两个子树,其中每个子树包含一个节点。

另一方面,以下树是不平衡的:
在这里插入图片描述

你可以看到,根节点的右子树包含的节点数多于其左子树,导致了不平衡状态。

二叉搜索树

有许多不同类型的基于树的数据结构,但在本章中,我们将专注于一种特定的树,称为二叉搜索树。

请注意这里有两个形容词:binary 和 search。
二叉树是一种每个节点最多有零个、一个或两个子节点的树
二叉搜索树是一种二叉树,它还遵循以下规则:

  • 每个节点最多可以有一个“左”子节点和一个“右”子节点。
  • 一个节点的“左”后代只能包含小于该节点本身的值。同样,一个节点的“右”后代只能包含大于该节点本身的值

以下是一个二叉搜索树的例子,其中值为数字:

在这里插入图片描述

请注意,每个节点都有一个比自身值小的子节点(用左箭头表示),以及一个比自身值大的子节点(用右箭头表示)。

另外,请注意,所有值为50的节点的左后代都小于它。同时,所有值为50的节点的右后代都大于它。每个节点都遵循相同的模式。

下面的例子是一个二叉树,但不是二叉搜索树:

在这里插入图片描述

这是一个二叉树,因为每个节点都有零个、一个或两个子节点。但它不是二叉搜索树,因为根节点有两个“左”子节点。也就是说,它有两个比它自己小的子节点。对于有效的二叉搜索树来说,它最多可以有一个左(较小)子节点和一个右(较大)子节点。

在Python中,树节点的实现可能如下所示:

class TreeNode:
    def __init__(self, val, left=None, right=None):
        self.value = val
        self.leftChild = left
        self.rightChild = right

然后我们可以构建一个简单的树:

node1 = TreeNode(25)
node2 = TreeNode(75)
root = TreeNode(50, node1, node2)

由于二叉搜索树具有独特的结构,我们可以非常快速地在其中搜索任何值,接下来将会看到。

搜索

这里再次是一个二叉搜索树:

在这里插入图片描述

在二叉搜索树中搜索的算法步骤如下:

  1. 指定一个节点作为“当前节点”。(在算法开始时,根节点是第一个“当前节点”。)
  2. 检查当前节点的值。
  3. 如果我们找到了正在寻找的值,那太好了!
  4. 如果正在寻找的值小于当前节点的值,在其左子树中搜索它。
  5. 如果正在寻找的值大于当前节点的值,在其右子树中搜索它。
  6. 重复步骤1到5,直到找到我们正在搜索的值,或者直到我们到达树的底部,这种情况下,我们的值必定不在树中。

假设我们要搜索61。让我们通过可视化方式看看需要多少步骤来完成。

在遍历树时,我们必须始终从根节点开始:

在这里插入图片描述

接下来,计算机要问自己:我们要搜索的数字(61)比这个节点的值大还是小?如果我们要找的值小于当前节点,则在其左子节点中查找。如果它大于当前节点,则在其右子节点中查找。

在这个例子中,因为61大于50,我们知道它一定在右边,所以我们搜索右子节点。在下面的图片中,我们已经阴影掉了我们搜索中排除的所有节点,因为我们知道61不可能在那里:

在这里插入图片描述

“你是我的妈妈吗?”算法问道。因为75不是我们正在寻找的61,所以我们需要进入下一层。因为61小于75,我们将检查左子节点,因为61只能在左子树中,如253页的图表所示。

在这里插入图片描述

56不是我们正在寻找的61,因此我们继续搜索。因为61大于56,我们在56的右子节点中搜索61:

在这里插入图片描述

我们找到了!在这个例子中,我们花了四步找到我们想要的值。

二叉搜索树的效率

如果再仔细看一下我们刚刚讲过的步骤,你会注意到每一步都会将剩余节点的一半排除在搜索之外。例如,当我们开始搜索时,我们从根节点开始,我们想要的值可能在根节点的任何后代中。然而,当我们决定继续搜索,比如说使用根节点的右子节点时,我们就排除了左子节点和其所有后代的搜索。

因此,我们可以说,在二叉搜索树中进行搜索的时间复杂度是 O(log N),这是对每个步骤都排除了剩余值的一半的算法的恰当描述。(不过,很快我们会看到,这只适用于完美平衡的二叉搜索树,这是最佳情况。)

Log(N)个级别

以下是另一种解释为什么二叉搜索树中的搜索是 O(log N) 的方法,它将揭示关于二叉树的另一个特性:如果平衡的二叉树中有 N 个节点,那么大约会有 log N 个级别(也就是,行数)。

为了理解这一点,让我们假设树中的每一行都被节点完全填满,没有任何空位置。如果你仔细思考一下,每当我们向树中添加一个新的完整级别时,我们大致上将树的节点数翻倍(实际上是翻倍并加一)。

例如,一个有四个完整级别的二叉树有 15 个节点。(你可以数一下。)如果我们添加第五个完整级别,这意味着我们给第四级的每个八个节点都加了两个子节点。这意味着我们添加了 16 个新节点,大致翻倍了树的大小。

逐级增加树的大小的结果是每个新级别将使树的大小翻倍。因此,一个包含 N 个节点的树需要 log(N) 个级别来容纳所有节点。

在二叉搜索中,我们注意到 log(N) 的模式是,每次搜索步骤都可以排除剩余数据的一半。二叉树所需的级别数也遵循这个模式。

让我们看一个需要容纳 31 个节点的二叉树。在我们的第五级中,我们可以容纳其中的 16 个节点。这大致照顾了一半的数据,剩下的 15 个节点我们仍然需要为其找到空间。在第四级中,我们处理了其中的八个节点,留下了七个没有处理。在第三级中,我们处理了其中的四个节点,以此类推。

事实上,log 31 大约是 5。所以,我们现在得出结论,具有 N 个节点的平衡树将具有 log(N) 个级别。

既然如此,在二叉搜索树中进行搜索最多需要 log(N) 步骤,因为每一步搜索都会导致我们向下移动一级,我们最多需要与树中的级别数一样多的步骤。

不管你怎么想,二叉搜索树中的搜索需要 O(log N) 的时间复杂度。

然而,二叉搜索树中的搜索是 O(log N),有序数组中的二分查找也是如此,在有序数组中,每次选择的数字也排除了剩余可能值的一半。从这个角度来看,在二叉搜索树中进行搜索与有序数组中的二分搜索具有相同的效率。

然而,在这方面,二叉搜索树比有序数组更突出的是插入操作。我们很快会讲到。

代码实现:二叉搜索树

要实现搜索操作,以及其他二叉搜索树操作,我们将大量使用递归。你在《递归中的递归》中学到递归在处理具有任意层深度的数据结构时非常重要。树就是这样的数据结构,因为它可以有无限多个级别。

这是我们如何使用递归来实现 Python 中的搜索:

def search(searchValue, node):
    # 基本情况:如果节点不存在或者我们找到了需要的值:
    if node is None or node.value == searchValue:
        return node

    # 如果值小于当前节点的值,在左子节点上进行搜索:
    elif searchValue < node.value:
        return search(searchValue, node.leftChild)

    # 如果值大于当前节点的值,在右子节点上进行搜索:
    else:  # searchValue > node.value
        return search(searchValue, node.rightChild)

这个搜索函数接受我们要搜索的 searchValue 以及我们将用作搜索基础的节点。第一次调用搜索时,节点将是根节点。然而,在后续的递归调用中,节点可能是树中的另一个节点。

我们的函数处理了四种可能的情况,其中两种是基本情况:

if node is None or node.value == searchValue:
    return node

一个基本情况是节点包含了我们正在寻找的 searchValue,在这种情况下,我们可以返回节点并且不进行任何递归调用。

另一个基本情况是当节点为 None 时。在我们检查其他情况之后,这将更加清晰,所以让我们稍后回来再看这个基本情况。

下一个情况是当 searchValue 小于当前节点的值时:

elif searchValue < node.value:
    return search(searchValue, node.leftChild)

在这种情况下,我们知道如果它存在于树中,searchValue 将会在当前节点的左子节点之间找到。因此,我们递归调用搜索函数来搜索当前节点的左子节点。

下一个情况是当 searchValue 大于当前节点的值时:

else:  # searchValue > node.value
    return search(searchValue, node.rightChild)

在这种情况下,我们递归调用搜索函数来搜索当前节点的右子节点。

现在,当我们在当前节点的子节点上进行这些递归调用时,要注意的是我们并没有检查当前节点是否有子节点。这就是第一个基本情况的用处:

if node is None

也就是说,如果我们调用搜索的是一个实际不存在的子节点,我们最终将返回 None(因为该节点实际上将包含 None)。这种情况会发生在搜索值不存在于树中时,因为我们将尝试访问应该找到搜索值的节点,但是我们的搜索遇到了死胡同。在这种情况下,返回 None 是合适的,表示搜索值不在树中。

插入

如前所述,当涉及插入操作时,二叉搜索树处于最佳状态。现在我们将看到为什么如此。

假设我们想要将数字 45 插入到我们的示例树中。我们首先要做的是找到要将 45 连接到的正确节点。开始搜索时,我们从根节点开始:

在这里插入图片描述

由于 45 小于 50,我们向左子节点钻取:

在这里插入图片描述

由于 45 大于 25,我们必须检查右子节点:

在这里插入图片描述

由于 45 大于 33,我们检查 33 的右子节点:

在这里插入图片描述

此时,我们已经到达一个没有子节点的节点,所以我们无处可去。这意味着我们准备进行插入操作。

由于 45 大于 40,我们将其插入为 40 的右子节点:

在这里插入图片描述

在这个例子中,插入操作需要五个步骤,包括四个搜索步骤和一个插入步骤。插入始终比搜索多一步,这意味着插入需要 (log N) + 1 步。在忽略常数的大 O 记法中,这是 O(log N)。

相比之下,在有序数组中,插入操作需要 O(N),因为除了搜索之外,我们还必须将大量数据向右移动,为要插入的值腾出空间。

这就是使得二叉搜索树如此高效的原因。虽然有序数组的搜索复杂度为 O(log N),插入复杂度为 O(N),但二叉搜索树的搜索复杂度为 O(log N),插入复杂度也为 O(log N)。在预期数据变化频繁的应用中,这变得至关重要。

代码实现:二叉搜索树插入

这是将新值插入到二叉搜索树中的 Python 实现。与搜索函数一样,它是递归的:

def insert(value, node):
    if value < node.value:
        # 如果左子节点不存在,我们想将该值插入为左子节点:
        if node.leftChild is None:
            node.leftChild = TreeNode(value)
        else:
            insert(value, node.leftChild)
    elif value > node.value:
        # 如果右子节点不存在,我们想将该值插入为右子节点:
        if node.rightChild is None:
            node.rightChild = TreeNode(value)
        else:
            insert(value, node.rightChild)

insert 函数接受一个我们将要插入的值,以及一个作为祖先节点的节点,我们要把该值作为其后代节点。

首先,我们检查该值是否小于当前节点的值:

if value < node.value:

如果值小于节点的值,我们知道我们需要在节点的左子节点中间插入该值。

接着我们检查当前节点是否有一个左子节点。如果节点没有左子节点,我们就将该值作为左子节点,因为值应该放置在这个位置上:

if node.leftChild is None:
    node.leftChild = TreeNode(value)

这是基本情况,因为我们不需要进行任何递归调用。但是,如果节点已经有一个左子节点,我们就无法将值放在那里。相反,我们在左子节点上递归调用 insert,以便继续搜索我们要放置值的位置:

else:
    insert(value, node.leftChild)

最终,我们会到达一个没有自己子节点的后代节点,这就是值将要插入的地方。

函数的其余部分完全相反;它处理了值大于当前节点的情况。

插入顺序

值得注意的是,只有在使用随机排序数据创建树时,树通常才会保持良好的平衡。然而,如果我们按顺序插入已排序的数据到树中,可能会导致树失衡并且效率降低。例如,如果我们按照这个顺序插入数据——1、2、3、4、5——我们得到的树是这样的:

在这里插入图片描述

这个树完全是线性的,所以在这个树中搜索5将需要O(N)的时间。

然而,如果我们按照以下顺序插入相同的数据——3、2、4、1、5——树将会是均衡的:

在这里插入图片描述

只有在树是平衡的情况下,搜索才是O(log N)的。

因此,如果你想将一个有序数组转换为二叉搜索树,最好先随机化数据的顺序

总结一下,在最坏的情况下,当树完全失衡时,搜索是O(N)。在最好的情况下,当树完美平衡时,搜索是O(log N)。在典型情况下,即数据按随机顺序插入时,树会比较均衡,搜索大约会花费O(log N)的时间。

删除

删除是二叉搜索树中最不直接的操作,需要一些小心的操作。

假设我们想从这棵二叉搜索树中删除4:

在这里插入图片描述

首先,我们执行搜索来找到4。我们不会再次可视化这个搜索,因为你已经了解了。

一旦我们找到了4,我们可以一步删除它:

在这里插入图片描述

这很简单。但是让我们看看当我们尝试删除10时会发生什么:

在这里插入图片描述

我们最终得到一个不再连接到树的11。我们不能这样,因为我们会永远失去11。
不过,这个问题有解决办法:我们可以将11填入10原来的位置:
在这里插入图片描述

到目前为止,我们的删除算法遵循以下规则:

  • 如果要删除的节点没有子节点,直接删除它。
  • 如果要删除的节点有一个子节点,删除该节点并将子节点插入到被删除节点的位置。

删除具有两个子节点的节点

删除具有两个子节点的节点是最复杂的情况。比如说我们想要删除这棵树中的 56:

在这里插入图片描述

对于它之前的子节点 52 和 61,我们不能同时移动它们到 56 的位置。这时就需要删除算法的下一个规则:

  • 当删除具有两个子节点的节点时,用后继节点替换被删除的节点。后继节点是所有大于被删除节点值中最小的子节点

这句话可能有些棘手。换句话说:如果我们将被删除节点及其所有后代按升序排列,后继节点将是刚刚删除的节点之后的下一个数字。

在这种情况下,很容易确定后继节点,因为被删除节点只有两个后代。如果我们将数字 52-56-61 按升序排列,56 之后的下一个数字是 61。

一旦找到后继节点,我们将其放到被删除节点的位置。所以,我们用 61 替换了 56:

在这里插入图片描述

找到后继节点

计算机如何找到后继节点?当我们删除树中较高位置的节点时,这可能有些棘手。

这是找到后继节点的算法:

  • 访问被删除值的右子节点,然后继续访问每个后续子节点的左子节点,直到没有更多左子节点为止。最底部的值就是后继节点。

让我们再次看一个更复杂的例子来演示这个过程。让我们删除根节点:

在这里插入图片描述

现在,我们需要将后继节点插入到原来 50 的位置,并将其变成根节点。所以,让我们找到后继节点。

为了做到这一点,我们首先访问被删除节点的右子节点,然后不断向左下访问,直到我们到达一个没有左子节点的节点:

在这里插入图片描述

结果表明 52 就是后继节点。

现在我们已经找到了后继节点,我们将其放入我们删除的节点中:

在这里插入图片描述

完成了!

自带右子节点的后继节点

然而,有一个情况我们还没有考虑到,那就是后继节点本身有一个右子节点的情况。让我们重新构建之前的树,但是给 52 添加一个右子节点,如第 265 页的图表所示。

在这里插入图片描述

在这种情况下,我们不能简单地将后继节点——52——直接放到根节点,因为这样会让它的子节点 55 悬空。因此,我们的删除算法中还有一个规则:

  • 如果后继节点有一个右子节点,在将后继节点插入到被删除节点的位置后,将后继节点的原右子节点作为后继节点的原父节点的左子节点。

这又是一个棘手的句子,让我们一步步来说明。
首先,我们将后继节点(52)放到根节点。这样会让 55 没有父节点而悬空:

在这里插入图片描述

接下来,我们将 55 变成后继节点原父节点的左子节点。在这种情况下,61 曾经是后继节点的父节点,所以我们将 55 变成 61 的左子节点:

在这里插入图片描述

这样我们就完成了。

完整的删除算法

删除二叉搜索树中节点的算法如下:

  • 如果要删除的节点没有子节点,则直接删除它。
  • 如果要删除的节点有一个子节点,则删除该节点,并将子节点插入到被删除节点的位置。
  • 当删除具有两个子节点的节点时,用后继节点替换被删除的节点。后继节点是所有大于被删除节点值中最小的子节点。
  • 寻找后继节点的方法:访问被删除节点值的右子节点,然后持续访问每个后续子节点的左子节点,直到没有更多左子节点为止。最底部的值就是后继节点。
  • 如果后继节点有一个右子节点,在将后继节点插入到被删除节点的位置后,将后继节点的原右子节点作为后继节点的原父节点的左子节点。

代码实现:二叉搜索树删除

以下是一个用 Python 实现的递归删除二叉搜索树节点的代码:

def delete(valueToDelete, node):
    if node is None:
        return None
    
    elif valueToDelete < node.value:
        node.leftChild = delete(valueToDelete, node.leftChild)
        return node
    
    elif valueToDelete > node.value:
        node.rightChild = delete(valueToDelete, node.rightChild)
        return node
    
    elif valueToDelete == node.value:
        if node.leftChild is None:
            return node.rightChild
        
        elif node.rightChild is None:
            return node.leftChild
        
        else:
            node.rightChild = lift(node.rightChild, node)
            return node

def lift(node, nodeToDelete):
    if node.leftChild:
        node.leftChild = lift(node.leftChild, nodeToDelete)
        return node
    else:
        nodeToDelete.value = node.value
        return node.rightChild

诚然,这段代码有些复杂,让我们逐步解释一下。

这个函数接受两个参数:

def delete(valueToDelete, node):

其中,valueToDelete 是我们要从树中删除的值,node 是树的根节点。当我们第一次调用这个函数时,node 将是实际的根节点,但由于这个函数递归调用自身,node 可能会是树中更低的节点,只是更小子树的根节点。无论如何,node 是整个树或其子树的根节点。

基本情况是当节点实际上不存在时:

if node is None:
    return None

这种情况发生在递归调用此函数尝试访问不存在的子节点时。在这种情况下,我们返回 None

接下来,我们检查 valueToDelete 是否小于或大于当前节点的值:

elif valueToDelete < node.value:
    node.leftChild = delete(valueToDelete, node.leftChild)
    return node
elif valueToDelete > node.value:
    node.rightChild = delete(valueToDelete, node.rightChild)
    return node

这段代码可能不太直观,但它的工作原理是这样的:如果 valueToDelete 小于当前节点的值,我们知道如果这个值在树中存在,它会在当前节点的左侧后代中找到。

这里的巧妙之处在于:我们然后将当前节点的左子节点重写为在当前节点的左子节点上递归调用删除函数的结果。删除函数本身最终返回一个节点,所以我们将这个结果作为当前节点的左子节点。

然而,通常情况下,这种“重写”实际上并不会改变左子节点,因为对左子节点调用删除操作可能会返回同一个左子节点。为了理解这一点,想象一下我们要从这个示例树中删除数字 4 的情况:

在这里插入图片描述

最初,node 是根节点,其值为 50。由于 4(要删除的值)小于 50,我们说 50 的左子节点现在应该是对 50 的当前左子节点 25 进行删除操作的结果。

那么,50 的左子节点将是什么?让我们来看一下。

当我们对 25 递归调用删除操作时,我们再次确定 4 小于 25(当前节点),所以我们继续递归调用 25 的左子节点,即 10。但是,无论 25 的左子节点最终是什么,因为我们说返回 node,在当前函数调用结束时我们返回的是节点 25。

这意味着当我说 50 的左子节点应该是对 25 进行删除操作的结果时,我们最终得到的是节点 25 本身,因此 50 的左子节点实际上保持不变。

然而,如果下一个递归调用的结果涉及实际的删除操作,当前节点的左子节点或右子节点将会改变。

接下来我们看一下代码的这一部分:

elif valueToDelete == node.value:

这意味着当前节点就是我们想要删除的节点。为了正确删除它,我们首先需要确定当前节点是否有任何子节点,因为这将影响删除算法

我们从检查我们要删除的节点是否有任何左子节点开始:

if node.leftChild is None:
return node.rightChild

如果当前节点没有左子节点,我们可以将当前节点的右子节点作为此函数的结果返回。请记住,无论返回哪个节点,它都将成为调用堆栈中上一个节点的左子节点或右子节点。因此,假设当前节点有右子节点。在这种情况下,通过返回右子节点,我们将其作为调用堆栈中上一个节点的子节点,有效地从树中删除当前节点。

现在,如果当前节点没有右子节点,那也没关系,因为我们最终会将 None 作为此函数的结果返回,这也将有效地从树中删除当前节点。

继续我们的代码,如果当前节点有左子节点但没有右子节点,我们仍然可以轻松删除当前节点:

elif node.rightChild is None:
    return node.leftChild

在这种情况下,我们通过返回其左子节点来删除当前节点,使其成为调用堆栈中上一个节点的子节点。

最后,我们遇到了最复杂的情况,即要删除的节点有两个子节点:

else:
    node.rightChild = lift(node.rightChild, node)
    return node

在这种情况下,我们调用 lift 函数,获取其结果,并将其作为当前节点的右子节点。

lift 函数做了什么呢?
调用此函数时,我们除了节点本身之外还传入当前节点的右子节点。lift 函数完成了四件事情:

  1. 它找到了后继节点。
  2. 它重写了 nodeToDelete 的值,并将其设置为后继节点的值。这样我们就将后继节点放到了正确的位置上。请注意,我们并没有移动实际的后继节点对象;我们只是将其值复制到我们要“删除”的节点中。
  3. 为了消除原始后继节点对象,函数将原始后继节点的右子节点变成其父节点的左子节点。
  4. 在所有递归完成后,它最终返回最初传入的原始 rightChild,或者如果原始 rightChild 最终作为后继节点(如果它自己没有左子节点),则返回 None。

然后我们取 lift 的返回值,并将其作为当前节点的右子节点。这将使右子节点保持不变,或者如果右子节点用作后继节点,则将其更改为 None。

不仅你会觉得如此,删除函数是书中最复杂的代码之一。即使经过这样的详细说明,也可能需要仔细研究才能理解整个过程。

二叉搜索树删除的效率

与搜索和插入一样,从树中删除元素通常也是 O(log N) 的。这是因为删除需要进行搜索,再加上一些额外的步骤来处理任何悬空的子节点。与从有序数组中删除值形成对比,后者因为需要将元素左移以填补被删除值的空隙,所以是 O(N) 的。

二叉搜索树实操

我们已经看到,二叉搜索树在搜索、插入和删除方面的效率为 O(log N),这使它成为需要存储和操作有序数据的高效选择。特别是在需要频繁修改数据的场景下,因为虽然有序数组在搜索数据时与二叉搜索树一样快,但在插入和删除数据时,二叉搜索树显然更快。

例如,假设我们正在创建一个应用程序,用于维护一系列书籍标题。我们希望我们的应用程序具备以下功能:

• 能够按字母顺序打印书名列表。
• 允许对列表进行频繁的更改。
• 允许用户在列表中搜索标题。

如果我们预期书籍列表不会经常变化,有序数组将是一个适合的数据结构来存储我们的数据。然而,我们正在构建一个可以实时处理许多变化的应用程序。如果我们的列表有数百万个标题,二叉搜索树可能是一个更好的选择。

这样的树可能如下所示:

在这里插入图片描述

在这里,标题根据它们的字母顺序进行了排列。字母表顺序较早的标题被视为“较小”值,而较晚出现的标题被视为“较大”值。

二叉搜索树遍历

我们已经了解了如何从二叉搜索树中搜索、插入和删除数据。我提到,我们还希望能够按字母顺序打印整个书名列表。我们该如何做呢?

首先,我们需要访问树中的每个节点。访问节点其实就是获取它们的另一种说法。在数据结构中访问每个节点的过程称为遍历该数据结构。

其次,我们需要确保按字母升序遍历树,以便按照这个顺序打印列表。有多种遍历树的方法,但对于这个应用程序,我们将执行所谓的中序遍历,以便按字母顺序打印每个标题。
递归是进行遍历的一个很好的工具。我们将创建一个名为 traverse 的递归函数,可以对特定节点进行调用。然后,函数执行以下步骤:

  1. 递归调用自身(traverse)来访问节点的左子节点。该函数将不断被调用,直到我们到达一个没有左子节点的节点为止。
  2. “访问”节点。(对于我们的书名应用程序,我们在此步骤中打印节点的值。)
  3. 递归调用自身(traverse)来访问节点的右子节点。该函数将不断被调用,直到我们到达一个没有右子节点的节点为止。

对于这个递归算法,基本情况是当我们在一个实际不存在的子节点上调用 traverse 时,我们会立即返回而不进行进一步操作。

这里是一个适用于我们书名列表的 Python traverse_and_print 函数。注意它的简洁性:

def traverse_and_print(node):
    if node is None:
        return
    traverse_and_print(node.leftChild)
    print(node.value)
    traverse_and_print(node.rightChild)

让我们逐步解释中序遍历的步骤。我们首先在《Moby Dick》上调用 traverse_and_print。接着,在《Moby Dick》的左子节点上调用 traverse_and_print,这个左子节点是《Great Expectations》:

traverse_and_print(node.leftChild)

然而,在我们继续之前,我们将在调用堆栈中添加这样一个事实:我们正在执行《Moby Dick》的函数,并且正在遍历它的左子节点:

在这里插入图片描述

然后,我们继续执行 traverse_and_print(“Great Expectations”),这会调用 traverse_and_print 在《Great Expectations》的左子节点上,而这个左子节点是《Alice in Wonderland》。在我们继续之前,让我们将 traverse_and_print(“Great Expectations”) 添加到调用堆栈中:

在这里插入图片描述

接着,traverse_and_print(“Alice in Wonderland”) 在《Alice in Wonderland》的左子节点上调用 traverse_and_print。然而,这里没有左子节点(基本情况),所以什么都不会发生。traverse_and_print 的下一行是:

print(node.value)

它打印了《Alice in Wonderland》。

接下来,函数尝试遍历并打印《Alice in Wonderland》的右子节点:

traverse_and_print(node.rightChild)

然而,这里没有右子节点(基本情况),所以函数返回而不再执行任何操作。

由于我们已经完成了函数 traverse_and_print(“Alice in Wonderland”),我们检查调用堆栈以查看我们在这个递归过程中进行到哪一步:

在这里插入图片描述

啊,是的。我们正在执行 traverse_and_print(“Great Expectations”),并且刚刚完成了对其左子节点的调用。让我们从调用堆栈中弹出这个:

在这里插入图片描述

然后我们继续。函数接下来打印了 “Great Expectations”,然后调用其右子节点的 traverse_and_print,这个右子节点是《Lord of the Flies》。然而,在继续之前,让我们在调用堆栈中保持这个函数的位置:

在这里插入图片描述

现在,我们执行 traverse_and_print(“Lord of the Flies”)。首先,我们调用其左子节点的 traverse_and_print,但它没有左子节点。接下来,我们打印了《Lord of the Flies》。最后,我们调用其右子节点的 traverse_and_print,但是右子节点也不存在,所以函数已经完成。

我们查看调用堆栈,发现我们正在执行对《Great Expectations》的右子节点的 traverse_and_print。我们可以从堆栈中弹出这个,然后继续,就像下面图表所示。

在这里插入图片描述

现在碰巧,我们也完成了对 traverse_and_print("Great Expectations") 的所有操作,所以我们可以回到调用堆栈,看看接下来要做什么:

在这里插入图片描述

我们可以看到,我们正在执行 Moby Dick 的左子节点的 traverse_and_print。我们可以从调用堆栈中弹出它(这将暂时留下空堆栈),然后继续执行 traverse_and_print("Moby Dick") 中的下一步,即打印 Moby Dick。

然后,我们调用 Moby Dick 的右子节点的 traverse_and_print。我们将这个加入调用堆栈:

在这里插入图片描述

为了简洁起见(虽然现在可能有点晚),我让你继续走下去 traverse_and_print 函数的剩余部分。

当我们的函数执行完成时,我们将按照这个顺序打印节点:

在这里插入图片描述

这就是我们如何实现按字母顺序打印书名的目标。请注意,树的遍历是 O(N),因为按定义,遍历会访问树的所有 N 个节点。

总结

二叉搜索树是一种强大的基于节点的数据结构,它提供了顺序维护,并且具有快速的搜索、插入和删除功能。相较于它的链表近亲更为复杂,但它提供了巨大的价值。

然而,二叉搜索树只是树的一种类型。有许多不同类型的树,每种树都在特定情况下带来独特的优势。在下一章中,我们将探索另一种树,它将为特定但常见的场景带来独特的速度优势。

练习

  1. 想象一下,如果你要在一个空的二叉搜索树中按照以下顺序插入数字序列:[1, 5, 9, 2, 4, 10, 6, 3, 8],画出二叉搜索树的图示。记住,数字是按照这里给出的顺序插入的。

  2. 如果一个平衡良好的二叉搜索树包含 1,000 个值,搜索其中一个值最多需要多少步骤?

  3. 编写一个算法,找出二叉搜索树中的最大值。

  4. 在文本中,我展示了如何使用中序遍历来打印所有书名的列表。另一种遍历树的方法称为先序遍历。以下是应用于我们的书籍应用的代码:

    def traverse_and_print(node):
        if node is None:
            return
        print(node.value)
        traverse_and_print(node.leftChild)
        traverse_and_print(node.rightChild)
    

    对于文本中的示例树(其中包含《白鲸记》和其他书名),按照先序遍历打印书名的顺序写出来。作为提醒,在下图示中,这是示例树的样子。

在这里插入图片描述

  1. 还有一种称为后序遍历的遍历形式。以下是应用于我们的书籍应用的代码:

    def traverse_and_print(node):
        if node is None:
            return
        traverse_and_print(node.leftChild)
        traverse_and_print(node.rightChild)
        print(node.value)
    

    对于文本中的示例树(也出现在前一个练习中),按照后序遍历打印书名的顺序写出来。

答案

  1. 树应该看起来是这样的。请注意,它并不平衡,因为根节点只有一个右子树,没有左子树。

在这里插入图片描述

  1. 在一个平衡的二叉搜索树中搜索最多需要大约 log(N) 步骤。因此,如果 N 是 1,000,搜索最多应该需要大约 10 步。
  2. 二叉搜索树中的最大值总是在最底部最右边的节点。我们可以通过递归地沿着每个节点的右子节点查找,直到到达底部来找到它:
    def max(node):
        if node.rightChild:
            return max(node.rightChild)
        else:
            return node.value
    
  3. 这是先序遍历的顺序:

在这里插入图片描述

  1. 这是后序遍历的顺序:

在这里插入图片描述

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值