UIUC-计算机科学基础笔记-全-

UIUC 计算机科学基础笔记(全)

001:数组

概述

在本节课中,我们将要学习数据结构的基础——数组。我们将了解数组如何存储数据、如何访问数据,以及它的一些关键特性和局限性。


数组的存储方式 📦

数组将数据存储在连续的内存块中。我们可以将一个数组可视化为一个大矩形,里面包含许多小矩形。

最左侧是索引0,这是数组的第一个元素。随后的每个元素,其索引依次递增1。

整个数组被放置在一个连续的内存块中,这意味着一个元素结束后,下一个元素紧接着开始。

以下是数组的两个例子:

  • 一个包含前10个质数的数组。在索引0处是第一个质数2,紧接着在内存中是下一个质数3,然后是5、7,依此类推。
  • 一个包含8个字符的数组。

C++中的数组示例 💻

让我们看一个包含基本数组的C++程序。在C++中,创建固定大小数组的语法如下:

int values[10] = {2, 3, 5, 7, 11, 13, 17, 19, 23, 29};

这里,数组的类型是int,它包含10个整数,即前10个质数。

我们可以使用方括号访问数组的每个元素。例如,values[3]将访问数组中的第三个索引。请记住,索引从0开始计数,所以索引0、1、2、3对应的元素分别是2、3、5、7。因此,我们期望values[3]输出7。

运行程序后,输出结果确实是7,这与我们的预期一致。


数组的特性与计算偏移量 🧮

数组有一些局限性。第一个局限是:数组中存储的所有数据必须是相同类型。整数数组只能包含整数,字符数组只能包含字符。

由于我们知道数组的两个事实:所有元素类型相同,并且特定类型的数据在内存中占用的空间大小相同,因此我们可以从数组的固定起始点计算任何元素的偏移量。

例如,对于一个整数数组,如果我们想知道索引5的位置,我们知道它距离数组起始点有5个整数的距离。在C++中,我们可以使用sizeof运算符来获取某种类型(如int或自定义的Cube类)在内存中占用的字节数。

如果我们发现每个Cube对象占用8字节,那么要访问索引3,我们可以计算偏移量:3 * 8字节 = 24字节。这意味着我们需要从数组起始点前进24字节。

这种计算非常强大,因为它允许我们使用一个简单的公式直接访问所需的内存位置,而无需逐个查看中间的元素。这是一个非常快速的操作。


验证偏移量计算 🔍

让我们构建一个C++程序来展示sizeof运算符以及内存地址的计算。

在第一个例子中,我们计算int类型的大小以及数组中索引2和索引0之间的内存地址差。如果int大小为4字节,那么两个int的偏移量应为8字节。运行程序后,我们确实得到了4和8,验证了我们的计算。

在第二个例子中,我们使用一个Cube对象数组。运行程序后,我们发现Cube的大小是8字节,索引2和索引0之间的偏移量是16字节。这证明了即使访问数组的语法完全相同,但由于数组内容的类型不同,内存偏移量也会不同。


数组的容量限制与动态调整 📏

数组的第二个局限性是:数组必须具有固定的容量。容量是数组可以存储的最大元素数量,而大小是当前存储的元素数量。

一旦数组中的元素数量超过其容量,我们就必须调整数组的大小以获得更多内存。我们不能简单地拥有无限量的连续内存,因为内存中还有其他数据。

调整大小时,我们需要分配一个新的、更大的内存块,并将所有旧数据复制到新位置。只有这样,我们才能在新数组的末尾添加新元素。

C++标准模板库中的vector(向量)就是一个实现了动态增长数组的容器。在vector内部,当元素数量达到当前容量时,它必须执行调整大小的操作。


探索Vector的内部机制 🛠️

让我们看一个深入探讨vector工作原理的例子。我们创建一个包含三个Cube对象的vector

程序首先输出vector的初始容量。然后,我们向vector添加一个新元素,并输出添加后的新大小和新容量。通常,vector的容量会增长到大约原来的两倍,这是为了预留空间,避免每次添加元素都触发调整大小。

接着,我们计算vector中索引2和索引0元素的内存地址差。结果是16字节,这证明vector内部的数据确实是连续存储的,就像一个普通数组一样。

最后,我们使用一个for循环遍历数组,来查找一个特定的Cube对象(例如值为400的对象),这展示了如何在数组中顺序搜索元素。

运行程序后,所有输出都符合我们的预期:容量动态增长,数据连续存储,并且可以成功遍历查找。


总结

本节课中,我们一起学习了数组的基础知识。我们了解到数组在连续内存块中存储相同类型的数据,可以通过索引快速访问元素。我们也探讨了数组的局限性,如固定容量和单一数据类型要求,并看到了C++中vector如何封装数组以实现动态扩容。理解数组是学习更复杂数据结构(如下一节课将介绍的链表)的重要基础。

002:链式内存

在本节课中,我们将要学习一种名为“链式内存”的数据存储方式。我们将了解它的基本结构、工作原理,并与之前学过的数组进行对比,分析其优缺点。

链式内存的基本概念

链式内存将数据与指向内存中下一个位置的链接一起存储。通过利用这个链接,而不是像数组那样在内存中顺序存储所有数据,我们可以获得一些优势。让我们先探索其工作原理,然后讨论使用它的优缺点。

在C++中,我们将每个列表称为节点的顺序列表。这些列表节点既存储了数据(在左侧),也包含一个指向列表中下一个部分的指针。

例如,观察一个列表节点类。它是一个模板类,因为我们希望用它来存储整数、字符、字符串、立方体等各种类型。因此,它有一个模板类型 T。类名是 ListNode。在公共部分,我们将有数据成员(对应左侧的数据块),以及一个指向列表中下一个元素的 next 指针。

以下是一个简单的构造函数示例:

ListNode(const T& data) : data(data), next(nullptr) {}

我们将链接零个或多个这样的 ListNode 元素,形成所谓的“链表”。这个链表有两个特殊属性:

  1. 一个头指针,用于标记链表的开始,它指向第一个元素(相当于索引0)。
  2. 一个指向空指针的指针,用于标记链表的结束。只要指针不指向 nullptr,我们就知道还没有到达链表末尾。

观察头指针,如果我们从头部开始,可以看到索引0处的值是2,索引1处的值是3,索引2处的值是5,以此类推。我们只需每次跟随指针到下一个位置即可遍历链表。

链表的实现结构

观察这段代码的实现,我们有一个 List 类文件。注意,我们有一个模板类 List。在这个类中:

  • 公共部分包含一个 operator[] 运算符重载,允许我们像访问数组一样通过索引访问列表元素(例如 L[0])。
  • 还有一个名为 InsertAtFront 的成员函数,用于在链表前端插入元素。
  • 在类的私有区域,有一个内部的 ListNode 类,它是 List 类的一部分。这个 ListNode 包含数据 data、下一个指针 next 以及构造函数。
  • 最后,List 类本身还包含一个指向 ListNode 的指针 head_

因此,List 类由 operator[]InsertAtFront、内部的 ListNode 类以及头指针 head_ 这四部分组成。

实现索引访问操作

上一节我们介绍了链表的基本结构,本节中我们来看看如何实现通过索引访问元素的操作。如果我们想知道索引4(或索引K)处是什么,该如何实现?

以访问索引4为例,我们需要从头指针 head_ 开始,先前进到索引0,然后持续前进到索引1、索引2、索引3,最后到达索引4。只有到达索引4后,我们才能返回该位置的值。请注意,我们必须遍历中间的所有节点,不存在直接指向索引4的箭头,我们必须沿着 next 指针的路径前进。

实现这个操作看起来像一个简单的循环。以下是实现 operator[] 函数以检索特定索引的伪代码逻辑:

T& operator[](int index) {
    ListNode* current = head_; // 遍历指针,从头部开始
    while (index > 0 && current != nullptr) {
        current = current->next; // 移动到下一个节点
        index--; // 索引减1
    }
    // 假设索引有效,返回当前节点的数据
    return current->data;
}

随着循环推进四次,current 指针最终将指向我们感兴趣的实际数据节点。我们不希望向用户返回 ListNode 对象本身,因为用户只关心索引处的数据。因此,我们返回 current->data,即访问列表节点的数据元素。

链表访问的时间复杂度

思考这个算法的运行时间,它比数组访问要慢得多。对于数组,我们可以直接计算到特定元素的精确偏移量。例如,要找到第10000个元素,我们只需计算 10000 * 元素大小(例如4字节),然后直接前进40000字节即可。

对于链表,我们无法通过一次操作完成。如果想访问链表中的第10000个元素,我们必须跟随10000个 next 指针。因此,随着链表增长,访问特定元素所需的时间也与链表大小成比例增长。这听起来可能是个缺点,但它也可能带来一些优势。我们将在接下来的视频中进行正式分析。

在链表中插入元素

在运行完整代码之前,我们还需要知道如何向链表中插入元素。假设我们有一个立方体链表(原理与整数链表相同,因为使用了模板类)。现在,我们想在链表开头插入一个红色立方体。

在链表开头插入是一个特别简单的操作。当前,头指针 head_ 指向橙色立方体。我们只需要改变 head_,让它指向新的红色立方体。这样,链表的第一个元素就变成了红色立方体。同时,我们需要更新红色立方体的 next 指针,使其指向原来的橙色立方体。通过这个操作,我们改变了头部的位置,并确保重新连接了链表的其余部分,从而创建了一个更大的链表,而且我们甚至不需要查看链表中的任何其他元素。

以下是代码实现的三个步骤:

  1. 在堆内存中创建一个新的 ListNode 节点(使用堆内存是因为链表可能在此函数作用域之外继续存在)。
  2. 让新节点的 next 指针指向当前头指针 head_ 所指向的节点。
  3. 更新头指针 head_,使其指向这个新创建的节点。

通过这三步,head_ 现在指向新数据,而新数据的 next 指针指向了 head_ 原来指向的节点,从而保持了链表的完整性,只是在链表前端添加了一个新元素。

链表与数组的对比

与数组不同,链表的容量仅受计算机内存总量的限制。相比之下,数组具有固定容量,需要调整大小。链表则没有这个问题。事实上,我们可以不断向链表开头添加元素,链表会持续增长。这是数组所不具备的强大能力。

然而,与数组相似,链表中的每个元素必须是相同类型。整数链表只能包含整数,字符串链表只能包含字符串。这一点在数组和链表中都是成立的。因此,在这两种数据结构中,我们存储数据的方式在类型上是固定的,即它们必须包含完全相同类型的元素。

实践:编写主函数

让我们编写一个主函数来看看这个链表是如何工作的。

int main() {
    List<int> list; // 创建一个新的整数链表
    list.InsertAtFront(3); // 在链表前端插入数据3
    std::cout << "list[0] is " << list[0] << std::endl; // 输出 list[0],应为3

    list.InsertAtFront(30); // 在链表前端插入数据30
    std::cout << "list[0] is " << list[0] << std::endl; // 输出 list[0],应为30
    std::cout << "list[1] is " << list[1] << std::endl; // 输出 list[1],应为3
    return 0;
}

程序逻辑如下:

  1. 创建一个初始为空的链表。
  2. 在链表前端插入元素3。此时链表为 [3]list[0] 应为3。
  3. 在链表前端插入元素30。此时链表变为 [30, 3]list[0] 变为30,list[1] 为3。

运行程序,我们得到预期输出:插入3后 list[0] 是3,插入30后 list[0] 是30,list[1] 是3。

总结

本节课中我们一起学习了链式内存。链式内存将数据存储在通过链接或指针连接在一起的节点中。链表是链式内存中的基本结构,由零个或多个链接在一起的链表节点组成。链表提供了一种比数组更灵活的替代方案,但在运行时效率方面存在一些劣势。我们将在接下来的课程中正式探讨这些差异的含义。

003:运行时分析

概述

在本节课中,我们将学习运行时分析。这是一种形式化的方法,用于比较算法在处理不同规模输入时的速度。我们将通过对比数组链表这两种数据结构来理解这个概念,并学习如何使用大O符号来描述算法的时间复杂度。


数组与链表的访问时间对比

上一节我们提到了数据结构,本节中我们来看看两种基本数据结构——数组和链表——在访问元素时的性能差异。

  • 数组:内存中连续的数据块。访问特定索引的元素时,只需知道每个数据单元的宽度,然后执行一次乘法运算:目标地址 = 起始地址 + 索引 * 数据宽度。无论数组有多大,这都是一次操作。
  • 链表:通过节点指针链接在一起的数据。要访问第 n 个元素,必须从头节点开始,依次跟随 nnext 指针。

以下是访问不同索引元素所需操作的对比表:

要访问的元素 数组所需操作 链表所需操作
第3个元素 1次乘法 3次指针跳转
第4285个元素 1次乘法 4285次指针跳转
第1,250,000个元素 1次乘法 1,250,000次指针跳转

n 表示要访问的元素索引:

  • 数组的访问时间总是 1次操作
  • 链表的访问时间需要 n次操作

我们使用大O符号来描述这种随着输入规模 n 增长,运行时间的变化趋势:

  • 数组访问是 O(1),称为常数时间
  • 链表访问是 O(n),称为线性时间

数组扩容策略的运行时分析

现在,让我们将运行时分析应用到另一个场景:动态数组的扩容。不同的扩容策略会导致完全不同的时间复杂度。

策略一:每次扩容增加固定容量(+2)

假设数组初始容量为2。每当数组满时,我们将其容量增加2,以容纳新元素并预留一点空间。

以下是扩容过程的简化描述:

  1. 初始容量为2。
  2. 第一次满时,扩容至4,需要复制2个元素。
  3. 第二次满时,扩容至6,需要复制4个元素。
  4. 第三次满时,扩容至8,需要复制6个元素。
  5. ... 以此类推,直到容量达到 n

设总共进行了 R 轮扩容(R = n/2),那么总复制次数是各轮复制次数的和:
总复制次数 = 2 + 4 + 6 + ... + 2R

这个求和公式可以表示为:
总复制次数 = Σ (k=1 到 R) 2k = 2 * [R(R+1)/2] = R² + R

由于 R = n/2,代入公式得到:
总复制次数 = (n/2)² + (n/2) = (n² + 2n) / 4

大O符号中,我们只关注增长最快的项,并忽略常数系数。这里增长最快的是 。因此,这种策略下,插入 n 个元素的总时间复杂度是 O(n²)

策略二:每次扩容容量翻倍

现在,我们换一种策略:数组满时,将其容量翻倍

扩容过程如下:

  1. 初始容量为2。
  2. 第一次满时,扩容至4,复制2个元素。
  3. 第二次满时,扩容至8,复制4个元素。
  4. 第三次满时,扩容至16,复制8个元素。
  5. ... 以此类推。

设总共进行了 R 轮扩容(此时 R = log₂(n)),总复制次数为:
总复制次数 = 2 + 4 + 8 + ... + 2^R

这是一个等比数列求和:
总复制次数 = Σ (k=1 到 R) 2^k = 2^(R+1) - 2

代入 R = log₂(n)
总复制次数 = 2^(log₂(n)+1) - 2 = 2n - 2

大O符号中,增长最快的项是 n。因此,这种策略下,插入 n 个元素的总时间复杂度是 O(n)

策略对比与均摊分析

对比两种策略:

  • 策略一(+2):总时间复杂度为 O(n²)
  • 策略二(翻倍):总时间复杂度为 O(n)

O(n) 远快于 O(n²),这说明扩容策略的选择至关重要。

我们更关心单次插入操作的平均成本。虽然翻倍策略在某次扩容时需要复制大量数据(O(n) 操作),但在此之后可以进行很多次简单的插入(O(1) 操作),直到再次需要扩容。

将总时间 O(n) 平摊到 n 次插入操作上,平均每次插入的成本是 O(1)。这被称为均摊时间复杂度。这意味着,从整体运行时间来看,每次插入的平均耗时是一个常数。


总结

本节课中我们一起学习了运行时分析的核心内容:

  1. 目的:形式化地比较算法速度随输入规模增长的变化。
  2. 核心工具大O符号,用于描述算法时间复杂度的上界,通常只保留公式中增长最快的项。
  3. 常见时间复杂度
    • O(1) - 常数时间:运行时间不随输入规模变化(如数组访问)。
    • O(n) - 线性时间:运行时间与输入规模成线性正比(如链表访问,翻倍扩容策略的总时间)。
    • O(n²) - 平方时间:运行时间与输入规模的平方成正比(如固定增量扩容策略的总时间)。通常比线性时间慢得多。
  4. 关键洞见:算法或策略的微小改变(如数组扩容从“+2”改为“翻倍”)可能带来时间复杂度数量级的提升(从 O(n²) 到 O(n))。通过均摊分析,我们可以理解某些偶尔昂贵操作的平均成本其实很低(如 O(1))。

在接下来的视频中,我们将继续探讨更多的运行时分析案例。

004:数组与列表操作

概述

在本节课中,我们将要学习数组和链表这两种有序数据集合的核心操作,并对比它们在不同场景下的性能差异。我们将重点关注访问、查找、插入和删除等操作的运行时间,并理解其背后的原理。


数组与链表的基本概念

数组和链表都是有序的数据集合。

上一节我们介绍了访问集合中给定索引的操作。本节中我们来看看查找、插入和删除等操作的性能对比。

访问给定索引

在之前的视频中我们看到,数组可以通过公式直接访问,时间复杂度为 O(1)。而链表必须遍历每个节点才能到达特定索引,因此访问给定索引的时间复杂度为 O(n)

  • 数组访问时间: O(1)
  • 链表访问时间: O(n)

查找操作分析

给定数据,我们如何在集合中查找该数据的位置?

线性查找(未排序数据)

在数组中查找橙色方块时,我们别无选择,只能从第一个元素开始,逐个检查,直到找到目标。这需要检查集合中的每一个数据,因此时间复杂度为 O(n)

在链表中查找紫色方块时,过程类似:从链表头部开始,逐个节点检查,直到找到目标或遍历完所有节点。因此,在链表中查找元素的时间复杂度也是 O(n)

综上所述,给定数据并查找其位置:

  • 未排序的数组中,时间复杂度为 O(n)
  • 链表中,时间复杂度也为 O(n)

以下是查找操作的代码逻辑示意:

数组查找(伪代码):

for i in range(len(array)):
    if array[i] == target:
        return i
return -1

链表查找(伪代码):

current = head
while current is not None:
    if current.data == target:
        return current
    current = current.next
return None

利用排序优化查找

但是,如果我们的数据是已排序的,查找算法可以大幅改进。

二分查找(排序数组)

假设我们要在已排序的数组中查找数字17。我们不再从开头线性搜索,而是可以像查电话簿一样,从中间开始。

  1. 跳转到数组中间。
  2. 比较中间值(例如13)与目标值(17)。因为13 < 17,所以目标值必然在右半部分。
  3. 在剩下的右半部分重复此过程,继续对半分割。

这种每次排除一半数据的策略称为二分查找。由于每次都将数据量减半,其时间复杂度为 O(log n),其中 log 指以2为底的对数。

  • 排序数组查找时间(二分查找): O(log n)

链表查找的局限性

现在来看一个已排序的链表。我们想要查找17。问题在于,我们无法“跳转”到链表的中间。即使有一个指针指向链表中心,在链表头部插入新节点后,这个指针也会立刻失效。

因此,对于链表,即使数据已排序,我们仍然只能进行线性搜索,逐个节点检查。所以,链表的查找时间复杂度仍然是 O(n)

  • 排序链表查找时间: O(n)

查找操作总结:

  • 未排序数组: O(n)
  • 排序数组: O(log n) (使用二分查找)
  • 链表(无论是否排序): O(n)

插入与删除操作

最后,我们来看一个非常实用的操作:在给定元素之后插入新元素(或删除其后的元素)。

在给定位置后插入

数组中的插入
如果要在数组的橙色方块之后插入一个紫色方块,由于数组元素在内存中是连续存储的,我们无法直接在中间“塞入”新元素。我们必须将橙色方块之后的所有数据都向后移动一个位置,为新元素腾出空间。

如果插入点位于列表中部,平均需要移动 n/2 个元素。用大O表示法,这就是 O(n) 的时间复杂度。

链表中的插入
如果给定一个链表节点,要在它之后插入新节点,过程就简单多了:

  1. 创建一个新节点。
  2. 将新节点的next指针指向原节点的下一个节点。
  3. 将原节点的next指针指向新节点。

这个过程只涉及修改几个指针,没有循环,无论链表多长,时间复杂度都是 O(1)

  • 数组插入(给定索引后): O(n)
  • 链表插入(给定节点后): O(1)

删除操作

删除给定元素之后的节点,其时间复杂度分析与插入操作完全相同。

  • 在数组中,需要移动后续元素来填补空隙,时间复杂度为 O(n)
  • 在链表中,只需重新连接指针,时间复杂度为 O(1)

总结

本节课中我们一起学习了数组和链表这两种基础线性数据结构的核心操作及其性能对比。

  • 访问: 数组 O(1), 链表 O(n)
  • 查找:
    • 未排序数组 O(n)
    • 排序数组 O(log n)
    • 链表 O(n)
  • 在给定位置后插入/删除: 数组 O(n), 链表 O(1)

可以看到,数组和链表各有优劣,在运行时间和实现灵活性之间存在权衡。我们后续将要构建的许多高级数据结构,都会基于数组或链表来实现。理解这些基础结构的特性,对于选择正确的底层实现至关重要。

005:队列数据结构 🚶‍♂️➡️🚶‍♀️

在本节课中,我们将要学习一种名为“队列”的数据结构。队列遵循“先进先出”的原则,就像人们在咖啡店排队一样。我们将了解它的抽象数据类型、基本操作,并探讨其两种主要实现方式:基于数组和基于链表。最后,我们会分析这些操作的时间复杂度。


队列的抽象数据类型

上一节我们介绍了队列的基本概念,本节中我们来看看如何从抽象层面定义队列的操作,而不涉及具体实现。这被称为抽象数据类型。

抽象数据类型描述了数据结构能做什么,而不是如何做。对于队列,其ADT包含四个核心操作:

以下是队列ADT的四个基本函数:

  1. 创建队列:创建一个空队列。
  2. 入队:将数据添加到队列的末尾
  3. 出队:从队列的前端移除并返回数据。
  4. 判空:检查队列是否为空。

队列操作示例

理解了抽象数据类型后,让我们通过一个简单的例子来看看队列如何工作。

我们首先创建一个整数队列。然后,我们将数字4和2依次入队。由于4先入队,它位于队列前端。当我们执行出队操作时,4将被移除。此时,2成为新的队首元素,等待下一次出队。


C++标准库中的队列

在实际编程中,我们通常使用语言提供的标准库。C++标准库提供了 std::queue 模板类。

以下是一个使用 std::queue 的代码示例:

#include <queue>
#include <string>
#include <iostream>

int main() {
    std::queue<std::string> q;

    // 入队操作
    q.push("orange");
    q.push("blue");
    q.push("Illinois");

    // 第一次出队,应输出 "orange"
    std::cout << q.front() << std::endl;
    q.pop();

    // 再次入队
    q.push("Alina");

    // 第二次出队,此时队首是 "blue"
    std::cout << q.front() << std::endl;
    q.pop();

    return 0;
}

运行此程序,第一次出队输出 orange,第二次出队输出 blue,这与我们基于“先进先出”原则的预期完全一致。


队列的内部实现与性能

我们已经看到了队列如何使用,现在让我们深入其内部,看看它是如何实现的,以及其操作的运行速度。

队列主要有两种实现方式:基于数组基于链表。两种方式的目标都是使入队和出队操作非常高效。

基于数组的实现

在数组实现中,我们使用一个数组来存储元素,并维护两个索引或指针:一个指向队首(出队位置),一个指向队尾的下一个空位(入队位置)。

  • 入队:在队尾索引处添加元素,然后更新队尾索引。时间复杂度为 O(1)
  • 出队:从队首索引处取出元素,然后更新队首索引。时间复杂度为 O(1)
  • 数组扩容:当数组空间不足时,需要分配一个更大的新数组(通常是原大小的两倍)并将旧数据复制过去。这个操作的成本是 O(n),但平均分摊到每次入队操作上,其摊还时间复杂度仍可视为 O(1)

基于链表的实现

在链表实现中,我们使用一个双向链表,并维护两个指针:一个指向链表的头部(队首),一个指向链表的尾部(队尾)。

  • 入队:在链表尾部添加新节点,并更新尾指针。借助尾指针,此操作可在 O(1) 时间内完成。
  • 出队:移除链表头部的节点,并更新头指针。此操作同样可在 O(1) 时间内完成。

总结 🎯

本节课中我们一起学习了队列数据结构。

  • 队列是一种先进先出的数据结构,模拟了排队的场景。
  • 其抽象数据类型包括创建、入队、出队和判空四个基本操作。
  • 队列可以通过数组双向链表高效实现。
  • 在精心设计下,队列的入队和出队操作的时间复杂度都是 O(1),即常数时间复杂度。这意味着无论队列中有1个还是100万个元素,这些操作的速度都同样快。

队列是一种强大且高效的数据结构,在后续课程中我们将用它来解决许多问题。与队列相对应的“后进先出”数据结构是,我们将在下一个视频中讨论它。

006:栈数据结构 📚

在本节课中,我们将要学习一种名为“栈”的数据结构。栈是一种“后进先出”的数据结构,其行为类似于桌上的一叠文件。我们将了解它的抽象数据类型、如何使用C++标准库中的栈,以及如何用数组和链表自行实现它。

栈的概念与抽象数据类型 📄

上一节我们介绍了队列,本节中我们来看看栈。栈是一种“后进先出”的数据结构,类似于桌上的一叠文件。

想象一下,你有一张写有数字4的纸,放在桌上。然后,你将一张写有数字5的纸放在数字4的上面。接着,在数字5上面放上数字8,最后在数字8上面放上数字3。观察这叠纸,最上面的数字3将是你最先拿起的纸。这样,最后放下的纸会最先被取出。

我们通常用两边有边界的列表语法来可视化表示栈,输入和输出的箭头都在同一个位置。

与所有数据结构一样,我们首先关注其抽象数据类型。栈有四种基本操作:

  • 创建:创建一个空栈。
  • 入栈:向栈顶添加数据。
  • 出栈:从栈顶移除数据。
  • 判空:返回栈是否为空。

注意,这个抽象数据类型与队列的抽象数据类型是相同的,但操作的行为不同。

让我们考虑一个整数栈的例子。首先,我们将数字4压入栈顶。接着,将数字2压入栈顶。下一步,我们从栈顶弹出一个数字。查看栈顶元素,我们移除并取出了2。因此,在使用栈数据类型时,数字2会从栈中出来。

使用C++标准库中的栈 🛠️

C++标准模板库为我们提供了栈作为基础库的一部分。

以下是使用C++标准库栈的示例代码:

std::stack<std::string> s;
s.push("orange");
s.push("blue");
s.push("Illinois");
std::cout << s.top() << std::endl; // 查看栈顶元素
s.pop(); // 弹出栈顶元素
s.push("Ainai");
std::cout << s.top() << std::endl;
s.pop();

这段代码与我们之前看到的队列代码几乎相同,唯一的区别是我们在这里使用了栈而不是队列。pushpop操作名称相同,但数据进入和离开数据结构的方式发生了巨大变化。在这里,我们期望的输出结果是“Illinois”和“Ainai”。

运行此代码,控制台输出结果如下:

Illinois
Ainai

我们的第一次pop确实得到了“Illinois”,第二次pop得到了“Ainai”。这样,我们就有了可以实际运行并观察栈如何工作的代码。

栈的实现方式 🔧

现在,让我们探讨一下如果我们想自己构建一个栈,可以如何实现。栈可以用任何类型的集合来构建。这里,我们将探讨基于数组和基于链表的实现。

基于数组的实现

使用数组实现栈时,我们通常将元素插入到数组容量的末端,即最后一个可用的索引位置。

例如,插入元素4, 2, 8, 7。我们会维护一个计数器来跟踪当前插入的索引位置。假设当前插入位置是索引5,下一个插入位置将是索引4。当需要移除元素时,我们从索引5处移除。如果空间用完,到达了索引0,我们可以通过扩展数组来提供更多空间。

让我们看一个具体的例子。假设我们有一个数组,并标记其索引。我们跟踪插入位置,比如从索引4开始。执行以下操作序列:

  1. 入栈1 -> 插入到索引4,更新插入位置为3。
  2. 入栈2 -> 插入到索引3,更新插入位置为2。
  3. 入栈3 -> 插入到索引2,更新插入位置为1。
  4. 出栈 -> 移除栈顶元素3(索引2处),更新插入位置为2。
  5. 入栈4 -> 插入到索引2,更新插入位置为1。
  6. 入栈5 -> 插入到索引1,更新插入位置为0。
  7. 出栈 -> 移除栈顶元素5(索引1处),更新插入位置为1。
  8. 入栈6 -> 插入到索引1,更新插入位置为0。
  9. 入栈7 -> 插入到索引0,更新插入位置为-1(表示空间已满)。

当需要入栈8时,我们发现下一个插入位置是-1,没有这个索引。因此,我们需要扩展数组。一个简单的策略是将数组大小加倍,创建一个有10个索引的新数组,并将旧数据(1, 2, 4, 6, 7)复制过来。新的插入位置将是新数组的索引4,然后我们可以插入8。

基于链表的实现

使用链表实现栈则更为简单。当栈初始为空时,链表头指针指向nullptr

以下是链表栈的核心操作逻辑:

  • 入栈:在链表头部插入新节点,并更新头指针。
  • 出栈:移除链表头部的节点,并更新头指针指向下一个节点。

例如,一个已有4个元素(4, 2, 8, 3)的栈。当我们想从栈中移除元素时,只需移除头节点并更新头指针即可。这只需要几行代码,极其简单。

栈的性能与总结 🎯

只要我们在选择数据结构时足够明智,并使用最优的实现(例如在数组实现中,当空间不足时将其大小加倍),栈的所有操作都可以在O(1) 的时间复杂度内完成。这意味着无论栈中有一个元素还是一百万个元素,这些操作所花费的时间都是恒定的。

与队列不同,对于栈来说,单向链表就足够了,因为我们不需要存储尾指针,所有操作都发生在链表头部。

本节课中我们一起学习了栈数据结构。栈是一种模拟桌上文件堆叠的“后进先出”数据结构。它可以用数组或链表来实现,并且所有操作都能在常数时间内完成。这个强大的特性将帮助我们构建更高效、更强大的数据结构。我们将继续深入探索更多数据结构,下节课再见。

007:树结构术语 🌳

在本节课中,我们将要学习一种新的数据结构——树。与之前讨论的线性或扁平数据结构不同,树结构能够表示复杂的数据关系,例如父子关系和兄弟关系。我们将从树的基本术语开始,为后续深入学习特定类型的树结构打下基础。


节点与边

上一节我们提到了树结构能表示复杂关系,本节中我们来看看构成树的基本元素。

树由节点构成。在计算机科学中,我们通常用一个圆圈来表示一个节点。节点用于存储数据。例如,在一个存储字母的树中,每个节点可以包含一个字母。

边用于连接节点。在树中,边总是有方向的,并且方向总是远离根节点。边本身通常不存储数据,我们通过它所连接的两个节点来标识它,例如连接节点K和M的边可以称为边KM。

以下是树的基本构成元素:

  • 节点:存储数据的基本单位。
  • :连接两个节点的有向连线。

根节点与叶节点

了解了节点和边后,我们来看看树中两种特殊的节点。

每棵树都必须包含一个根节点。根节点位于树的顶端,它没有入边(即没有指向它的边),只有出边。树中有且仅有一个根节点。

与根节点相对的是叶节点。叶节点位于树的末端,它们没有出边(即没有从它出发指向其他节点的边)。叶节点可以出现在树的任何层级。


节点间的关系

现在我们已经认识了根节点和叶节点,接下来探讨树中节点之间的家族式关系。

除了根节点,每个节点都有一个父节点,即其上一层直接连接它的节点。例如,节点B的父节点是A。

反过来,一个节点的子节点是指那些将该节点作为父节点的节点。一个节点可以有零个、一个或多个子节点。叶节点没有子节点。

以下是节点间的主要关系:

  • 父节点:一个节点的直接上层节点。
  • 子节点:一个节点的直接下层节点。
  • 兄弟节点:拥有相同父节点的节点。
  • 祖先节点:从根节点到该节点路径上的所有节点(不包括该节点本身)。
  • 后代节点:从该节点出发,向下可达的所有节点。

这些关系与家谱中的概念完全一致。


树的定义

在介绍了各种术语之后,我们最后来明确一下树的正式定义。

一个结构要被称为树,必须满足三个条件:

  1. 存在一个唯一的根节点。
  2. 所有边都是有向的,且方向远离根节点。
  3. 结构中不包含

“无环”意味着不能从某个节点出发,沿着有向边行走,最终又回到该节点。只要所有边都指向下一层,就不可能形成环。因此,树是一种有根的、有向的、无环的结构。


本节课中我们一起学习了树结构的基本术语。我们了解到树由节点和边组成,必须具有根节点、有向边且无环。节点间的关系遵循类似家谱的父子、兄弟等模式。掌握这些术语是理解和使用二叉树、搜索树等更复杂树形数据结构的基础。

008:二叉树

在本节课中,我们将要学习二叉树的基本概念、定义及其核心属性。我们将了解二叉树的代码实现结构,并探讨几种特殊的二叉树类型:满二叉树、完美二叉树和完全二叉树。理解这些基础概念是后续构建更复杂数据结构的关键。

二叉树定义

二叉树是一种树形数据结构,其中每个节点最多拥有两个子节点。这两个子节点通常被称为左子节点和右子节点。

以下是二叉树的几个示例:

  • 树1:根节点有两个子节点。右子节点又有两个子节点,其他节点各有一个子节点。这是一棵二叉树。
  • 树2:根节点有两个子节点,且每个子节点也都有两个子节点。这也是一棵二叉树。
  • 树3:根节点有三个子节点。由于存在一个节点拥有超过两个子节点,因此这不是一棵二叉树。

在二叉树中,对于任意节点 T,我们可以用 T_L 表示其左子节点,用 T_R 表示其右子节点。

代码实现结构

二叉树的实现结构与链表非常相似。

在链表中,我们有 ListNode 结构,它包含一个指向数据的引用和一个指向下一个节点的指针 next

在二叉树中,我们有 TreeNode 结构,它同样包含一个指向数据的引用,但拥有两个指针:一个指向左子节点 left,一个指向右子节点 right

代码示例:

template <typename T>
class TreeNode {
public:
    T& data; // 节点存储的数据
    TreeNode* left; // 指向左子节点的指针
    TreeNode* right; // 指向右子节点的指针
    // ... 构造函数等
};

本质上,二叉树可以看作每个节点都带有两个指针的链表,而不是一个。

二叉树的高度

二叉树(以及所有树)的一个重要属性是高度。树的高度定义为从根节点到最远叶子节点的路径上所经过的边的数量

  • 在示例树1中,最长路径包含2条边,因此其高度 H = 2
  • 在示例树2中,从根节点到最底层叶子节点的最长路径包含4条边,因此其高度 H = 4

特殊类型的二叉树

接下来,我们介绍几种具有特定性质的二叉树。

满二叉树

一棵二叉树是满二叉树,当且仅当它的每个节点要么有0个子节点(叶子节点),要么有2个子节点。

以下是判断示例:

  • 示例树1:检查每个节点,它们都满足“0个或2个子节点”的条件,因此它是满二叉树。
  • 示例树2:存在一个节点只有一个子节点,因此它不是满二叉树。

完美二叉树

一棵二叉树是完美二叉树,当且仅当所有内部节点都有两个子节点,并且所有叶子节点都处于同一层级。

以下是判断示例:

  • 示例树1:所有内部节点都有两个子节点,且所有叶子节点都在最底层,因此它是完美二叉树。
  • 示例树2:存在一个内部节点只有一个子节点,因此它不是完美二叉树。

注意:如果从完美二叉树中移除最右边的叶子节点,那么该树将不再是完美二叉树,因为会出现只有一个子节点的内部节点。

完全二叉树

一棵二叉树是完全二叉树,当且仅当:

  1. 除了最后一层外,其余层构成一棵完美二叉树。
  2. 最后一层的所有节点都尽可能地向左排列。

以下是判断示例:

  • 一棵高度为2的完美二叉树本身也是完全二叉树。
  • 如果在一棵高度为2的完美二叉树的最后一层左侧添加几个节点,那么它仍然是一棵完全二叉树。

完全二叉树的概念在后续学习这种数据结构时将变得极其重要。

概念辨析

基于以上定义,我们可以思考一些有趣的问题:

问题一:满二叉树一定是完全二叉树吗?

答案是否定的。我们可以构造一个反例:一棵树,其根节点有两个子节点,但这两个子节点一个有两个子节点,另一个是叶子节点。这棵树是满的,但最后一层的节点并未全部靠左排列,因此不是完全二叉树。

问题二:完全二叉树一定是满二叉树吗?

答案也是否定的。参考完全二叉树的定义,其最后一层的节点可能只有一个左子节点,这违反了满二叉树“节点必须有0个或2个子节点”的规则。因此,完全二叉树不一定是满二叉树。

总结

本节课中,我们一起学习了二叉树的基础知识。我们明确了二叉树的定义,了解了其代码实现与链表的相似性,并掌握了树的高度的计算方法。我们还深入探讨了三种特殊的二叉树:满二叉树、完美二叉树和完全二叉树,并辨析了它们之间的关系。理解这些基本概念和性质,是我们后续利用二叉树构建更高效数据结构(其运行速度在某些情况下优于数组或链表)的坚实基础。在接下来的课程中,我们将开始学习如何向二叉树中添加数据,并利用树形结构来组织数据。

009:2-3树的遍历

在本节课中,我们将要学习树结构中的一个核心操作:遍历。我们将探讨如何访问树中的每一个节点,以及访问这些节点的不同顺序。理解遍历是理解树如何存储和检索数据的关键。

树的遍历概述

树最重要的方面之一是我们如何从中获取数据。我们如何访问每一个节点?以及以何种顺序访问这些节点?所有这些主题都涵盖在“树遍历”这个概念之下。有几种不同的方式来看待一棵树,也有几种不同的方式来进行树遍历。

让我们来看一棵示例树。这是一棵包含九个节点的树。这九个节点以这里的加号节点为根。在左侧,我们有减号节点;在右侧,我们有乘号节点。我们可以看到,如果我们以一种特定的方式看待这棵树,它可能表示一个数学方程式。让我们看看如何做到这一点,并了解不同类型的遍历。

前序遍历

第一种遍历形式是,我们先访问(例如“输出”)当前节点,然后前往左子树,最后前往右子树。让我们看看如果我们在每个节点先“输出”,然后向左走,再向右走会发生什么。

再次观察这棵树,如果我们先在每个节点“输出”,然后向左走,再向右走。在加号节点,我们先输出它。所以我们说的第一件事是“加号”。然后,既然我们已经输出了它,我们就向左走。在这里,我们再次输出节点,输出“减号”。接着我们需要向左和向右走,所以先向左走。在这个节点A,我们输出它,输出“A”。最后,我们需要向左走,左边没有节点,然后我们回到A,意味着需要向右走,右边也没有节点,所以A的访问完全结束。现在我们可以回到减号节点。我们已经完成了左子树,现在可以去右子树。在右子树,我们输出“除号”。然后我们向左走,到达B,输出“B”。然后去B的左边(没有节点),B的右边(没有节点),现在B的访问完成,我们返回到除号节点。我们已经输出了节点,完成了左子树,现在需要去右子树,输出“C”。左边和右边都没有节点,C的访问完成,除号节点的访问完成,减号节点的访问完成,回到根节点加号。现在我们需要去右边,到达乘号节点,输出它。然后向左走,到达D,输出D;向右走,到达E,输出E。

这个过程结束时,我们已经输出了这棵树中的每一个节点。这正是遍历所做的。遍历需要访问树中的每个节点恰好一次,并对该数据执行某些操作(如输出)。

我们刚才做的遍历被称为前序遍历。我们所做的是先输出节点,然后去左子树,最后去右子树。之所以称为“前序”,是因为我们是在访问节点的第一时间进行输出。

如果我们思考其源代码,这仅仅意味着我们需要先打印出节点的值,然后递归调用左孩子和右孩子。我们可以快速写出这段代码。

void preorderTraversal(TreeNode* cur) {
    if (cur != nullptr) {
        shout(cur); // 访问当前节点,例如打印值
        preorderTraversal(cur->left);
        preorderTraversal(cur->right);
    }
}

中序遍历

上一节我们介绍了前序遍历,本节中我们来看看中序遍历。中序遍历意味着我们先去左子树,然后输出节点,最后去右子树。

以下是中序遍历的源代码逻辑:

void inorderTraversal(TreeNode* cur) {
    if (cur != nullptr) {
        inorderTraversal(cur->left);
        shout(cur); // 访问当前节点
        inorderTraversal(cur->right);
    }
}

让我们运行这个逻辑。中序遍历意味着我们执行“左、输出、右”。在根节点,我们先去左子树。到达减号节点,继续向左,到达A节点。A的左孩子为空,所以我们输出A。然后我们回到减号节点,输出减号。现在我们去右子树,到达除号节点。我们先去左子树,到达B节点。B没有左孩子,所以我们输出B。然后回到除号节点,输出除号。接着去右子树,到达C节点,输出C。现在回到根节点加号,输出加号。最后去右子树,对乘号节点执行中序遍历:先去左孩子D,输出D;然后输出乘号;最后去右孩子E,输出E。

最终我们得到输出序列:A - B / C + D * E。看,我们得到了一个看起来像数学表达式的东西。中序遍历恰好按照操作数-运算符-操作数的顺序输出,这使得它非常适合表示编码在树中的代数表达式(如果加上括号来明确层次关系的话)。

后序遍历

接下来,我们探讨最后一种典型遍历方式:后序遍历。后序遍历遵循相同的递归逻辑,但我们在最后才输出节点。顺序是:先去左子树,然后去右子树,最后输出节点。

以下是后序遍历的源代码:

void postorderTraversal(TreeNode* cur) {
    if (cur != nullptr) {
        postorderTraversal(cur->left);
        postorderTraversal(cur->right);
        shout(cur); // 访问当前节点
    }
}

让我们快速过一遍示例。顺序是“左、右、输出”。从根节点加号开始,先去左子树。到达减号节点,继续向左到A。A没有孩子,所以输出A。回到减号节点,现在去右子树。到达除号节点,先去左子树B,输出B;然后去右子树C,输出C;最后输出除号。现在回到减号节点,输出减号。根节点的左子树访问完毕,现在去右子树。到达乘号节点,先去左孩子D,输出D;然后去右孩子E,输出E;最后输出乘号。最终回到根节点,输出加号。

后序遍历的输出序列是:A B C / - D E * +。这意味着根节点最后被输出,因为它需要先访问完整个左子树和整个右子树。

层序遍历

以上我们介绍了三种基于深度优先策略的遍历方式。但你可能不想深入左子树和右子树,而想要一种完全不同的遍历形式。为此,有一种被广泛使用的特殊遍历:层序遍历

层序遍历按层级读取节点,一次读取一层。它先读取根层级,然后是第二层,接着是第三层,依此类推,每层从左到右。

以下是层序遍历访问示例树的顺序:

  1. 第一层:+
  2. 第二层:-*
  3. 第三层:A/DE
  4. 第四层:BC

这是一种访问同一棵树的完全不同的方式。我们做的每一种遍历都产生了独特的节点顺序,但每一种遍历都恰好访问了每个节点一次。

遍历与搜索的区别

在本视频结束前,我想提一下遍历和搜索概念的区别,因为这两个术语经常被互换使用。

进行遍历要求访问每一个节点。另一方面,搜索允许我们在整个树中发现一个特定的节点。当我们在树中找到目标节点时,我们可能不会访问每一个节点。我们可能会利用遍历中开发的策略来帮助我们快速找到一个节点。但搜索在找到该节点时就结束了,而遍历则要访问到每一个节点。

这是一个关于搜索和遍历含义的概述,以及在整个树中进行遍历的不同形式,这些形式可能会影响我们后续使用的搜索策略。

总结

本节课中我们一起学习了树的遍历。我们探讨了四种主要的遍历方式:

  1. 前序遍历:按照“节点 -> 左子树 -> 右子树”的顺序访问。
  2. 中序遍历:按照“左子树 -> 节点 -> 右子树”的顺序访问,常用于输出二叉搜索树的有序序列或表示表达式。
  3. 后序遍历:按照“左子树 -> 右子树 -> 节点”的顺序访问,常用于先处理子节点再处理父节点的场景。
  4. 层序遍历:按树的层级从上到下、从左到右访问节点。

我们还区分了遍历(必须访问所有节点)和搜索(找到目标即可能停止)的概念。理解这些遍历方法是有效操作和分析树结构数据的基础。在下一个视频中,我们将更深入地研究树。

010:二叉搜索树 🧮

在本节课中,我们将要学习一种重要的数据结构——二叉搜索树。它是一种有序的二叉树,能够被用作高效的搜索结构。我们将了解它的定义、核心操作(查找、插入、删除)的实现原理,并分析其性能。

二叉搜索树的定义

二叉搜索树是一种有序的二叉树,它能够被用作一种搜索结构。

一个有序二叉树或二叉搜索树,当且仅当对于树中的每一个节点,其左子树中的所有节点都小于该节点,而其右子树中的所有节点都大于该节点时,它才是一个二叉搜索树。

这意味着我们可以递归地应用这个定义。例如,观察节点19,我们知道其左侧的所有节点(4, 11)都必须小于19,而其右侧的所有节点(22, 20)都必须大于19。

我们可以利用二叉搜索树的这个特性来存储大量需要快速查找的数据。通常,我们会使用字典作为二叉搜索树的实现结构。这意味着我们将用二叉搜索树来实现一个字典。

字典的概念

字典总是将某个数据关联起来。例如:

  • 你的电子邮箱登录名关联着你的个人资料数据。
  • 你的电话号码关联着你在电话公司的通话记录。
  • 一个网站URL是唯一的键,它能带你找到特定的网站。
  • 你的街道地址是一个唯一的值,它告诉世界你的房子在哪里。

所有这些例子都表明,字典接受一个唯一的键值,并返回与该键值关联的数据。需要记住的是,键必须是唯一的标识符,它们必须是独特且唯一的,不能与其他人的键相同。

字典的核心操作

当我们思考字典时,有四个关键功能需要实现:

以下是字典的四个核心操作:

  1. 查找:给定一个键,找到与该键关联的数据。
  2. 插入:当有新数据(如新用户、新电话号码)时,需要能够将其插入字典。
  3. 删除:需要能够从字典中移除不再使用的数据。
  4. 判空:需要能够判断字典是否为空。

当我们绘制二叉搜索树时,通常只绘制键,尽管每个节点都关联着数据。例如,节点37代表一个键,它关联着某些数据。我们查找时使用键,返回的是数据。

二叉搜索树的实现

字典数据结构的实现代码与你之前见过的树代码非常相似。

实际上,它的公共部分将包含一些常见函数,而私有部分将包含一个TreeNode类。在TreeNode类内部,我们将有之前见过的左、右子节点指针,但现在节点值将同时包含一个key和一个data

我们有一个TreeNode构造函数,并且仍然有一个指向树顶部的head指针。所有这些都与我们之前介绍的树类似。但现在,我们将实现一个Dictionary类,它使用二叉搜索树来使搜索更高效。

查找算法

让我们思考如何编写算法来在树中查找特定元素。

查找元素42的步骤如下:

  1. 总是从根节点开始。
  2. 查看根节点元素,判断需要向左还是向右移动。
  3. 37小于42,所以向右移动。
  4. 查看51,42小于51,所以必须出现在其左子树中。
  5. 最终找到节点42。

在每一层,我们都从根节点开始,判断向左还是向右移动,直到找到目标节点。

如果要查找树中不存在的元素,例如17:

  1. 从根节点37开始,向左到19。
  2. 19大于17,向左到4。
  3. 4小于17,向右到11。
  4. 11小于17,应继续向右,但11的右子节点为空,我们到达了树的末端。
  5. 由于我们遵循规则遍历了一条路径并到达了叶子节点仍未找到数据,我们可以确定该数据不在我们的数据结构中。

让我们思考查找操作的最坏情况。在最坏情况下,我们可能找不到一个元素,并且不得不遍历树中最长的路径来确认这一点。

虽然我们遍历了许多步骤,但请注意我们并没有检查所有数据。这不像数组或链表那样需要遍历每一个数据元素。在这里,我们只需要遍历树中的一条路径。

因此,最坏情况是访问树中最长的路径。访问最长路径意味着我们将访问与树的高度成比例的节点数量。使用大O表示法,最坏情况运行时间是O(h),其中h是树的高度。

我们可能构建的最坏情况树看起来像一个链表。如果我们绝对需要用n(节点总数)来表示,我们可以说O(h)的上界是O(n)。所以,最坏的情况是我们的树看起来像一个链表,它不是一个非常平衡的树,只是一条向左或向右延伸的长链。这条长链的路径长度将等于树中的节点数,高度将与节点数成比例。

因此,一般来说,最坏情况运行时间是树的高度。在不知道树结构的具体情况下,用数据项数表示的最坏情况运行时间是O(n)

让我们看看实际运行这个算法的代码。

这里有两个不同的函数。第一个是find函数,它只是调用我们的辅助函数,传入键和头节点(根节点)。然后我们查看辅助函数_find,它有四种情况:

  1. 如果当前指针为空,则返回该空指针。
  2. 如果当前节点的键等于我们要搜索的键,则返回当前节点。
  3. 如果要查找的键小于当前节点的键,则向左移动。
  4. 否则(即键大于当前节点的键),则向右移动。

这就是我们需要做的全部。我们只需要从树的根节点开始,根据键与当前节点值的比较结果向左或向右遍历树,直到找到数据或到达树末端,然后返回该节点(数据本身)或空指针。

你会注意到代码的最后两行:如果我们找到的是空指针,就抛出一个错误;否则,返回该节点的数据。

插入算法

既然我们知道如何查找,现在让我们讨论如何向二叉搜索树中插入数据。

插入到二叉搜索树中应该相当简单。我们需要做的就是找到它应该被插入的确切位置,然后在那里插入。

例如,要插入17,我们使用刚才讨论的查找算法:从根节点37开始,向左到19,17小于19,再向左到4,4小于17,需要向右,11小于17,需要向右。实际上,我们已经找到了想要插入树中的确切位置,因为我们到达了一个叶子节点(11没有右子节点)。现在我们可以在这里添加17,并且我们保持了二叉搜索树的性质。之后搜索17时,我们会在插入的位置找到它。

插入算法很简单,因为我们可以利用查找算法返回一个指向我们需要插入的确切位置的引用指针这一事实。

这里的实现只有两行:使用我们的_find函数找到应该插入二叉搜索树的确切位置,然后直接将该位置(我们返回的节点指针)设置为一个新的树节点。

这就是最优雅的插入实现,只有两行长。

删除算法

我们几乎完成了,但还需要讨论一个算法,那就是删除。我们有查找和插入的能力,现在让我们确保也有删除的能力。

假设我们要删除元素42。让我们看看这将如何工作。

使用我们一直使用的相同查找函数,我们向右、向左移动,在这里找到了42。在这种情况下删除42非常容易。我们可以简单地删除这个链接和节点,将这个指针设置为空。我们仍然有一个完全可接受、完全正确的二叉搜索树,并且我们已经删除了节点。这就像从链表中删除一样。

这是一个特别简单的删除情况。让我们看看如果删除另一个元素,情况是否会变得更复杂。

假设我们删除22。从37开始,向左到19,向右到22。糟糕,我们不能简单地删除22,因为我们会丢失对20的跟踪。但这又看起来像一个链表。如果我们考虑到节点只是像这样链接在一起,我们确切地知道如何修复它,因为我们修复过链表。我们不是简单地删除这个指针处的所有内容并将其设置为空,而是删除节点并通过将这个指针移动到其唯一子节点来修复它。

因此,在单子节点删除的情况下,我认为这是一个简单的情况,因为它看起来像一个链表,而我们已经知道如何从链表中删除。

现在,你可能在想一种情况:如果发生双子节点删除怎么办?我们不能像链表那样简单地更新它,因为我们不知道如何处理左右两个子节点。

让我们看一个这样的例子。假设我们已经从这棵树中移除了两个元素,现在需要移除37。我们再次从根开始,哦,我们已经找到了要删除的元素(根节点)。从树中移除根节点可能是最困难的节点移除操作。

移除这个节点意味着我们需要思考一个移除策略。如果我们简单地完全移除它,那么我们需要决定是19还是51应该成为新的根,然后如何修复这棵树会变得极其复杂。根据下面树的结构,会有很多边界情况,这比思考一个总是有效的通用算法要复杂得多。

那么,思考一下37,如果我要移除树的根,哪个节点是成为新根的最佳候选者?最好的候选者可能是最适合位于树根位置且不会扰乱树中其他任何部分的节点。

考虑到所有这些节点,替换37的最佳节点将是与37最接近的节点,无论是小于它还是大于它。在这个左子树中,20是替换37的最佳节点。想象一下根现在是20,其他所有节点保持不变。给定这个元素现在是20,19、4、2和11都位于左侧,这看起来很好。51和55都位于右侧。所以这是合理的、正确的。

我们将把这个想法量化成一个算法。我们将说,我们将用一个节点的中序前驱(IOP)来替换树的根。一个节点的中序前驱是在对二叉搜索树进行中序遍历时,紧挨着出现在该节点之前的元素。根是37,所以中序前驱将是元素20。

我们也可以开发一种巧妙的方法来找到IOP。IOP总是左子树中最右边的节点。所以,如果我们查看需要移除的根节点,进入其左子树,然后只需要找到该子树中最右边的节点。左子树中最右边的节点将是左子树中最大的值。我们找到20,将其与37交换。通过将20与37交换,我们可以构建一个正确的二叉搜索树,而无需担心任何特定的边界情况。

最后一点是,当我们交换37和20后,我们仍然需要从树中移除37。我们仍然有这个节点在这里。但当37与20交换后,我们知道20是树中最右边的节点,因此它一定没有右子节点。所以它要么是零子节点删除,要么是单子节点删除的情况。鉴于它要么有0个,要么有1个子节点,我们可以使用前面讨论的两种情况之一有效地移除它。

删除操作总结

总结一下,当从二叉搜索树中删除时,我们需要考虑三种不同的特殊情况:

以下是删除节点的三种情况:

  1. 零子节点删除:要删除的节点没有子节点。这很简单,我们直接删除该节点,因为下面没有需要跟踪的子节点。
  2. 单子节点删除:要删除的节点有一个子节点。这种结构看起来像一个链表。对于链表,我们已经知道如何从中删除元素,所以通过简单地将指针设置为下一个元素来修复链表,一切都会很好。
  3. 双子节点删除:要删除的节点有两个子节点。这种情况比较有趣。我们需要找到中序前驱(IOP),将需要删除的节点与IOP交换,然后在新的位置删除该节点(这将是单子节点或零子节点删除的情况)。这个算法在某种意义上是递归定义的,因为我们交换了某些东西,然后使用相同的删除算法来移除它。但通过这样做,我们保证了交换后的情况会更容易处理。

代码实现与演示

让我们看看remove函数的实现是什么样的。就像所有将在这棵树上递归运行的函数一样,我们有一个公开可用的remove函数,它通过引用返回数据。TreeNode节点将使用我们的_find算法来找到应该被删除的确切节点,然后调用_remove来删除我们找到的节点。

在字典的_remove函数中:

  • 我们处理零子节点删除的情况,直接删除节点。
  • 我们处理单子节点删除的情况,我将其分为左子节点和右子节点两种情况。你会注意到代码是相同的,只是根据是左子节点还是右子节点在一个给定位置交换了方向。我们只是查找数据,并根据是否存在左子节点或右子节点来修复链表。
  • 最后一种情况(双子节点)很简单:我们找到IOP,交换那个节点,然后在它新的位置上调用_remove函数来删除刚刚被交换过来的节点。

我已经设置了一个小程序,我们可以实际运行它,看看这段代码是如何工作的,并获取一些输出。你可以深入研究并编辑这段代码,尝试运行它。

在控制台中,我们进入二叉搜索树目录,编译并运行程序。在我们的main.cpp中,我们创建了一个基于刚才讨论的字典,并插入了一些元素(37, 19, 51, 55, 4, 11, 20, 2)。注意与所有这些元素关联的数据都是表示该元素本身值的字符串。

然后我们运行几个操作:查找51,删除11、51和19(分别对应零子节点、单子节点和双子节点删除的情况)。最后,我们再次尝试查找51,看看在刚刚删除51后会发生什么。

运行程序后,我们看到:

  • t.find(51)找到了元素51,正如我们所料。
  • t.remove(11)移除了元素11。
  • t.remove(51)移除了元素51。
  • t.remove(19)移除了元素19。
  • 当我们再次查找51时,程序中止了,抛出了一个异常,显示“未捕获的异常...运行时错误:键未找到”,因为删除后我们无法找到51。发生了错误,这正是我们期望代码做的事情。

我们有了一个可以工作的二叉搜索树,你可以尝试修改和运行这段代码。

总结

本节课中,我们一起学习了二叉搜索树。我们了解了它的定义和作为字典实现的核心思想,并详细探讨了查找、插入和删除这三个关键操作的算法原理与实现。我们发现,二叉搜索树的效率高度依赖于树的形状(高度),在最坏情况下(退化成链表)性能会下降。在接下来的视频中,我们将对二叉搜索树进行更深入的分析。

011:二叉搜索树分析 📊

在本节课中,我们将要学习二叉搜索树的不同形态及其性能分析。我们将探讨最坏情况、平均情况以及如何量化树的平衡性,为后续学习平衡算法打下基础。

二叉搜索树的不同形态 🌳

二叉搜索树可以呈现出多种不同的形式和结构,即使它们包含完全相同的数据。考虑以下两棵二叉树。

这两棵二叉树都是正确的二叉搜索树,因为左侧始终包含小于根节点的节点,而右侧仅包含大于根节点的节点。

它们都包含数字1到7,结构却截然不同。

数据插入顺序的影响 🔄

上一节我们看到了结构不同的二叉搜索树,本节中我们来看看造成这种差异的原因。这两棵树包含相同的值,其结构差异源于插入顺序的不同。

以下是构建这两棵树的方式:

  • 我通过按顺序插入 4236715 构建了一棵树。这样创建出的树是完美平衡的,左右两侧的节点数量相等。
  • 另一方面,我创建了一棵完全向右倾斜的树,根节点最终是 1。因此所有节点都大于1,并且全部位于树的右侧。

性能分析:最坏情况与平均情况 ⚖️

我们希望思考如何构建高效的树,以及当构建的树不理想时会发生什么。什么是最坏情况?什么是平均情况?我们能做到的最好情况是什么?

首先,思考将相同数据插入二叉搜索树有多少种可能的方式。如果我们有数字1到7,我们可以考虑有多少种可能的插入顺序。

这是一个相当简单的计算:我们可以先选择任意一个数字作为第一个插入。假设我们先插入 5,那么 5 将成为根节点。此时,我们有 6 个其他节点可供选择作为第二个插入,以此类推。

因此,插入完全相同数据的不同方式数量等于节点数量的阶乘,即 n!。这意味着二叉搜索树的结构存在大量可能性。

  • 最坏情况:最坏情况下,二叉搜索树可能只是一个链表,最小的节点在根,并按递增顺序排列。例如,按 12345 的顺序插入,树在每个位置都向右延伸,形如链表。在这种最坏情况的二叉搜索树中查找一个节点,时间复杂度将是 O(n),这与遍历链表搜索相同,因为我们无法跳过任何元素。
  • 对比有序数组:另一方面,有序数组允许我们直接跳转到中间。如果所有元素都在一个有序数组中,我们可以直接跳到数组中间进行比较,然后根据比较结果向左或向右移动。这导致对数级的运行时间,即 O(log n)
  • 平均情况:二叉搜索树的平均情况是一棵相当平衡的树。考虑到所有可能的插入方式,只有一种方式会导致树完全向右倾斜,也只有一种方式会导致完全向左倾斜。平均而言,一半数据在左,一半数据在右。因此,平均情况下的二叉搜索树性能类似于有序数组,为 O(log n) 时间。

操作时间复杂度总结 ⏱️

记住,我们所有的操作都依赖于 find(查找)操作。

  • 在二叉搜索树中,insert(插入)只是在找到位置后添加一行代码;remove(删除)也只是在找到节点后处理几种特定情况。因此,二叉搜索树的运行时间主要由 find 决定,insertremove 在找到元素后只进行常数时间操作。
  • 另一方面,对于有序数组,插入和删除需要移动元素,是 O(n) 时间。对于有序链表,虽然插入删除快,但查找需要 O(n) 时间。

最佳结果:唯一能在 findinsertremove 操作上都达到 O(log n) 时间复杂度的,就是平均情况下的二叉搜索树。数组和链表都无法做到这一点。因此,实现平均情况的二叉搜索树是我们追求的理想目标,而最坏情况(O(n))则是我们需要避免的。

平衡二叉搜索树的定义 ⚖️

为了帮助我们量化什么是平衡的二叉搜索树,让我们定义一个新术语:高度平衡因子,记作 B

一个节点的高度平衡因子是其右子树高度与左子树高度之差。

公式表示为:
B = height(right subtree) - height(left subtree)

例如:

  • 在一棵平衡树中,根节点右子树高度为1,左子树高度为1,则 B = 1 - 1 = 0。我们说该根节点的平衡因子为0,处于完美平衡。
  • 在一棵严重右倾的树中,左子树为空(高度定义为-1),右子树高度为4,则 B = 4 - (-1) = 5。该树的平衡因子为+5,意味着树严重向右倾斜。

理想情况下,我们希望保持平衡因子较小。

平衡的定义 ✅

我们将平衡二叉搜索树定义为:树中每个节点的平衡因子绝对值均为 0 或 1

这意味着每个节点的平衡因子只能是 -101。只有当树中每个节点都满足此条件时,我们才称这棵二叉树是平衡的。

让我们用这个定义检查之前的例子:

  • 在左边的平衡树中,每个节点的平衡因子都是0,符合条件。
  • 在右边的不平衡树中,节点5的平衡因子为2(右子树比左子树高2),节点4的平衡因子为3,根节点1的平衡因子为5。这些节点违反了平衡条件。

总结 📝

本节课中我们一起学习了:

  1. 相同的数字可以通过 n! 种不同的插入顺序构成结构各异的二叉搜索树。
  2. 最坏情况的二叉搜索树会退化成链表,导致所有操作的时间复杂度为 O(n)
  3. 平均情况的二叉搜索树是相对平衡的,其高度与节点数的对数成正比,操作时间复杂度为 O(log n),这是我们的性能目标。
  4. 我们通过引入平衡因子 B 来量化树的平衡性,其计算公式为 B = 右子树高度 - 左子树高度
  5. 我们定义了平衡二叉搜索树:树中每个节点的平衡因子绝对值不大于1(即仅为-1, 0, 或1)。

在下一个视频中,我们将深入探讨如何创建一种能维持二叉搜索树平衡的算法。

012:平衡二叉搜索树 🧮

在本节课中,我们将要学习平衡二叉搜索树。我们将了解什么是平衡因子,如何识别树中的不平衡结构,以及如何使用旋转操作来恢复树的平衡。通过本课的学习,你将掌握保持二叉搜索树高效运行的核心机制。

平衡二叉搜索树概述

上一节我们介绍了二叉树的平衡因子。本节中我们来看看平衡二叉搜索树。这种树是高度平衡的,以确保大约一半的数据存在于树的左半部分,另一半数据存在于右半部分。

左侧是一个非常平衡的二叉搜索树。而右侧的这个二叉搜索树则非常不平衡。

识别不平衡结构

让我们分析这棵树的结构,并开发一种算法,以确保我们可以将不平衡的二叉搜索树转变为完全平衡的树。

我们将在图中识别两种子结构。我们将识别一种称为“山峰”的子结构,即一个同时拥有左孩子和右孩子的节点。以及一种称为“棍子”的子结构,即一个孩子都在同一方向,而另一方向没有孩子的节点。

“棍子”是我们希望通过转换将其变为“山峰”的结构。通过将“棍子”转换为“山峰”,我们将确保二叉树尽可能保持平衡。

平衡因子与失衡检测

让我们考虑一个二叉搜索树的简单例子。

这棵二叉搜索树最初是平衡的。我们知道它是平衡的,因为我们可以检查每个节点的平衡因子,即右子树高度减去左子树高度。

原始树中根节点右子树的高度为1,左子树高度为0,计算1-0,我们看到根节点的平衡因子是1。

通过向这棵树的末端添加一个新节点,现在右子树的高度变为2,2-0等于2。因为平衡因子的绝对值大于1,我们知道这棵树整体上失去了平衡。

当我们观察这棵树时,我们希望找到最深的不平衡节点。这里新添加的节点平衡因子为0,其左右两侧的孩子数量相同。它的父节点平衡因子为1,因为其右侧比左侧高出一层。再上一层的节点平衡因子也是1。只有在根节点处,我们看到平衡因子是2。这是树中最深的失衡点。

旋转操作:修复“棍子”结构

一旦我们确定了树中最深的失衡点,我们将识别导致该节点失衡的“棍子”结构。

我们在这里完成了这一步。从U到V再到一个未标记的节点,这三个节点构成了一根“棍子”。这就是这棵树失衡的原因。让我们尝试将其转换为“山峰”。

为此,我将拾取“棍子”中间的节点V,将其提升。通过提升这个中间节点,我们看到这个节点将变成一个“山峰”。然后,我们只需修复所有指针:V的左侧有一个指向U的指针,V的右侧有一个指向Y的指针。原来在Y左侧的节点现在将位于U的右侧,而Y的右侧将保持不变。

我们所做的是创建了一棵保持二叉搜索树性质的树,所有顺序都得以维持。并且我们降低了根节点的平衡因子。现在,平衡因子从2通过一次简单的二叉搜索树旋转,变成了0。

通用左旋转

如果我们泛化地考虑这个问题,可以讨论通用的左旋转。

左旋转的思想是,我们将树中最深的失衡点视为节点B。B是我们在树中检测到失衡的最低或最深的点。失衡是指平衡因子的绝对值大于1。

当平衡因子为2时,我们需要查看下一个节点C。给定我们找到了B,我们现在查看C的平衡因子。如果C的平衡因子是1,我们知道B严重向右失衡,C也几乎向右失衡。这意味着我们的树明显向右倾斜。

我们可以执行所谓的左旋转来解决这个问题。进行左旋转,意味着将C提升为“山峰”的顶端,并将所有节点向左旋转,使B下降,并将C连接到B。

我们称此操作为左旋转。当最深失衡点的平衡因子为2,且其后继节点的平衡因子为1时,就会发生这种旋转。任何时候发生这种情况,我们都可以通过左旋转来修复这种失衡。

处理“肘部”结构与双旋转

让我们考虑第二个例子。这与第一个例子非常相似,但节点被添加到了右侧。如果我们识别导致问题的子结构,最深的失衡点现在在这里。它不再是根节点,但这个节点的平衡因子是2。右子树的高度是2,左子树的高度是0,2-0等于2。

不幸的是,这不符合我们目前看到的情况。相反,我们识别出一种看起来更像“肘部”而不是“棍子”的结构。这确实是我们的第三种结构。这是“棍子”和“山峰”之间的混合体。

每当我们识别出一个“肘部”时,我们需要做的是修复这个“肘部”,使其变成一根“棍子”。我们通过围绕“肘部”的弯曲处进行旋转来实现。

我们进行一个转换,将黄色节点上移,这个操作将“肘部”转换成了“棍子”。一旦我们有了“棍子”,我们就知道该怎么做。我们将其视为一个右左旋转,因为我们要做的是先围绕D进行右旋转将其放置到位,然后再围绕C进行左旋转来修复“棍子”。

因此,这里当我们在树中识别出失衡点B,且B的平衡因子为2,而后继节点的平衡因子为-1时,就需要进行右左旋转。插入到T2或T3会导致这个失衡点,我们只需要进行两种不同的旋转来修复这种失衡。

完整地看这个过程,我们注意到首先将“肘部”转换为“棍子”。一旦将“肘部”转换为“棍子”,我们就提升“棍子”的中心节点,使其成为“山峰”。这样,我们就从“肘部”到“棍子”,再到“山峰”。最终,我们得到一棵从完全失衡变为完美平衡的二叉搜索树。

旋转规则总结

我们可以查看所有这些旋转,并思考如何应用这个理念:当我们进行左旋转时,如果树向完全相反的方向失衡,我们只需要进行右旋转。查看树中最深失衡节点的平衡因子。

如果它是2,我们从左旋转开始。如果它是-2,我们从右旋转开始。只有当我们查看下一个节点时,才能确定是否是单旋转。如果两个符号匹配,我们说只需要一次左旋转(或右旋转),这会将“棍子”转换为“山峰”。

当符号不同时,我们知道遇到了“肘部”情况。当遇到“肘部”情况时,我们需要进行双旋转,即左右旋转或右左旋转。这会将“肘部”拉直,然后进行简单的转换,将“棍子”变成“山峰”。

AVL树与算法特性

我用非常简单的术语介绍了这些概念,以便我们可以讨论“棍子”、“山峰”和“肘部”。我们直观地看到了代码如何能够在检测到失衡的瞬间,将一个失衡的二叉搜索树恢复平衡。

因此,在插入节点时,我们将确保二叉搜索树保持平衡。每次插入完成后,在将结果返回给用户之前,我们将确保该二叉搜索树是平衡的。

二叉搜索树旋转是恢复平衡的机制。我们知道有四种不同的旋转:左旋转、右旋转、左右旋转和右左旋转。这些旋转都是局部操作,我们所做的只是改变失衡点,重新排列几个指针。因此,我们可以说这些操作在常数时间内运行。我们编写代码来实现这些转换将非常简单,可能只需四行。

最后要提到的是,这个算法实际上有一个名字,它被称为AVL树。我们将在下一讲中深入探讨AVL树的所有细节并进一步讨论它。

本节课中我们一起学习了平衡二叉搜索树的核心概念,包括平衡因子、失衡的识别(“棍子”和“肘部”结构),以及通过单旋转(左旋、右旋)和双旋转(左右旋、右左旋)来恢复平衡。我们还了解到这种高效平衡算法的名称是AVL树,其旋转操作是常数时间的局部调整,是保持二叉搜索树性能的关键。

013:AVL树分析 🧮

在本节课中,我们将要学习一种特殊的二叉搜索树——AVL树。我们将了解它如何通过旋转操作在插入和删除元素后保持平衡,并分析其核心的实现逻辑。


上一节我们介绍了二叉搜索树的基本概念。本节中我们来看看如何让二叉搜索树保持平衡。

平衡二叉搜索树通过执行树旋转操作来在插入和删除后保持平衡。我们称这类树为AVL树,它以两位计算机科学家Adelson-Velsky和Landis的名字命名。

AVL树本质上就是一棵二叉搜索树。关于二叉搜索树的所有知识,包括查找、插入、删除等操作,在AVL树中依然成立。

AVL树只在两件事上有所不同:

  1. 在插入和删除时,我们会做一些额外的工作来确保树保持平衡。
  2. 为了能方便地计算平衡因子,我们将在树的每个节点中存储其高度。

我们将通过示例来了解其工作原理。首先来看一个插入操作。


插入操作示例

假设我们想将节点14插入到我们的AVL树中。

就像之前讨论的插入算法一样,我们从根节点18开始。比较14和18,发现需要向左移动。查看节点5,14大于5,需要向右移动。查看节点10,14大于10,继续向右。查看节点12,14大于12,继续向右。最终,我们可以在路径末端将14作为一个新的叶节点插入。

我们通过递归算法进行插入,这样在回溯时,我们可以检查路径上的每个节点。

以下是执行此操作的逐步过程:

第一步是在正确的位置插入节点。我们知道插入操作是沿着这条路径进行的,最终在14的位置插入。

当我们回溯时,将在每个位置检查是否失衡,必要时进行旋转,并更新节点高度。

在这里,节点12的高度现在变为1,因为它下方有一个节点。回溯到节点10,其高度变为2,因为它下方有两个节点。

但这里发生了什么?此处出现了失衡。树的右侧高度差超过了1,具体是2。因此我们知道树在此处不平衡,需要进行修复。

我们通过一个简单的旋转来修复。将节点12上移,使其成为顶部节点。节点10成为其左子节点,节点14成为其右子节点。这样我们就修复了二叉搜索树。节点12的高度为1,节点10和14的高度为0。

现在我们可以回溯到节点5,检查其高度是否仍为3(是的),然后检查节点18的高度是否仍为4。

通过执行插入并沿路径回溯,我们看到只需要在插入路径上的某个位置进行一次旋转。这次我们发现需要进行一次旋转,但最终成功插入了14,并确保在将树返回给用户时,AVL树是一棵平衡的二叉搜索树。


确保平衡的代码实现

确保平衡的代码被添加到了上一讲中看到的二叉搜索树代码中。现在,我们的AVL树中有了一个名为 _ensure_balance 的额外辅助函数。

这个 _ensure_balance 函数的作用是:如果平衡因子为-2,我们知道需要执行右旋或左右双旋;如果平衡因子为2,则需要执行左旋或右左双旋。具体执行哪种旋转,取决于下一个节点的平衡因子。

这里我们看到计算了当前节点左子树和右子树的高度差,以及左子节点的左右子树高度差。这与我们之前图表中看到的情况完全一致。

因此,上一讲和本视频中的所有内容都汇聚在这个函数中,以确保当树失衡时,我们能根据上次讨论的规则表执行正确的旋转,使树恢复平衡。

查看其余代码,我们需要一个 _update_height 函数来更新高度,以及一个 _rotate_left 函数来执行实际的旋转操作。

唯一另一个需要实际进行操作的地方是在删除函数中。


删除操作示例

让我们看看从原始二叉搜索树中删除节点5的可能性。

我们将像以前一样找到节点5:从根节点18开始,向左移动到5,找到需要删除的节点。记住,要删除一个节点,需要找到它的中序前驱节点,即左子树中最右侧的节点。

左子树中最右侧的节点是元素4。因此,我们将5与4交换。然后需要删除我们刚刚交换过的这个节点(即原来的5)。

删除5后,当我们从5的位置向上回溯时,会发现节点3现在失衡了,其左右子树高度差为2。

旋转这个节点时,我们发现它是一个“肘型”结构。首先要做的是将“肘型”转换为“直棍型”,然后提升“直棍”的中间元素。这样,元素4的两侧就分别挂着节点2(及其子节点1)和节点3。

现在,左侧高度为1,右侧高度为1,中间元素的平衡因子正好合适。实际上,树的高度略有降低,此处高度现在为2。我们向上移动,看到根节点的高度为3。

与插入类似,我们只需要更新执行删除操作所经过路径上的节点。当我们删除或插入一个元素时,只需要修改该路径上的节点。


删除操作的代码修改

修改删除操作的代码很简单,主要是添加了一个名为 _iop_remove 的额外函数。这个函数将负责删除中序前驱节点。它通过查找左子树中最右侧的指针来定位中序前驱节点。找到后,简单地交换元素,然后调用我们的删除函数将其完全移除。

所有这些代码都已提供,我们稍后会查看。


总结与代码实践

综上所述,我们知道AVL树是平衡二叉搜索树的一种实现。AVL树的实现始于二叉搜索树的实现,然后只添加了两样东西:

  1. 我们存储了每个节点自身的高度。
  2. 我们在每次插入和删除后维护树的平衡因子。

仅通过这两件事,我们就确保了拥有一棵平衡的二叉搜索树。

在结束之前,让我们看一下代码,然后在下一讲中详细讨论这个AVL树是如何运行的。

进入AVL目录,查看 main.cpp 文件的内容。

main 函数中,我们创建了一个AVL树。这个AVL树将是一个字典,其中整数作为键,字符串作为值。我们像在二叉搜索树中一样,向AVL树中插入许多元素。键是整数,值是对应的字符串。然后我们执行一系列操作。

首先,使用AVL树的代码查找键51。然后依次删除11、51、19和6。删除11是一个简单的无子节点删除。删除51是有一个子节点的简单情况。而删除19和6则涉及两个子节点,是更复杂的操作,以确保我们正确处理了中序前驱的情况。最后,再次尝试查找51,我们预期这会是一个错误,因为之前已经删除了51。

让我们编译并运行这个程序。运行 make 编译,然后执行 ./main

我们看到,第一次查找51时,确实输出了对应的值。随后,依次显示移除了11、51、19、6。当最后再次查找51时,我们发现了一个未捕获的异常:未找到。这完全符合预期,因为自从第一次找到51后,我们已经将其删除,现在51已不存在。

你拥有AVL树的源代码和所有这些信息,可以自己尝试对AVL树进行各种操作。希望你花些时间实践一下。

祝你学习愉快,我们下次再见!

014:B树简介

在本节课中,我们将要学习一种名为B树的数据结构。我们将探讨为什么需要B树,理解其基本概念,并了解它如何优化对大量数据的访问,尤其是在数据无法完全放入内存的情况下。

课程概述

到目前为止,我们已经讨论了诸如二叉树、AVL树、数组和链表等算法。这些算法在大O表示法下具有出色的运行时性能。

但是,大O表示法并不能解释一切。实际上,大O表示法假设对所有数据的访问时间是均匀的。然而在现实中,对所有数据的访问时间并不总是均匀的。

现实世界的数据挑战

让我们来看一个例子。例如,如果我们考虑Facebook上的所有用户资料,保守估计可能有5亿用户。每个用户资料的总数据量至少为5MB。这又是一个保守估计,因为所有照片和信息很可能远超5MB。

计算存储所有这些数据所需的总空间,我们可以将5亿乘以5兆字节。这将得到2500万亿字节,即2.5PB的数据。这是一个巨大的数据量,远超任何系统的主内存容量。

因此,我们必须将部分数据存储在磁盘上。而存储在磁盘上意味着操作会比在主内存中慢一些。

B树的设计目标

B树的目标是创建一种数据结构,使其在主内存和磁盘上都能表现出色。具体来说,我们希望优化设计,尽可能减少磁盘寻道次数,从而使算法尽可能高效。

实际上,我提到了“磁盘寻道”,但我们真正考虑的是数据存储在任何其他地方的情况,例如云端或其他地方。

B树的基本结构

接下来,我们看看如何构建这样的数据结构。B树通过包含多个键的节点来构建。我将节点画成一个大矩形,作为节点的一部分,内部会有几个键。键可以是任何值,这里假设开始时是整数。

第一个键的值可能是1,第二个键可能是100,然后是250、400、900和1600。每个键都会有一个指向树内另一个节点的指针。例如,在1和250之间的这个指针,将指向另一个包含另一组键的节点。该节点中的所有内容都将在数字100和250之间。

B树的阶

我们将定义每个B树都有一个“阶”。B树的阶指的是节点的大小,而不是指键按排序顺序插入这一事实。B树的阶是一个给定节点可以拥有的最大键数加一。

例如,这是一个阶为9的B树示例,该节点中有8个键。每个B树的目标都是最小化访问数据所需的网络数据包、磁盘寻道或其他操作的次数。因此,我们希望最小化到达数据所需的寻道次数。

总结与预告

现在我们已经理解了B树的动机,接下来我们想要真正理解如何实现它,以及如何在B树上构建插入和查找等操作。我们将在下一讲中完成这些内容。

015:B树插入算法详解

在本节课中,我们将要学习B树数据结构中的核心操作之一:插入。我们将详细探讨如何在B树中插入一个新键值,并理解这一过程如何维持B树的关键性质,例如所有叶子节点位于同一层以及节点至少半满。

B树回顾

上一节我们介绍了B树的基本概念,本节中我们来看看具体的插入操作。

一棵阶数为M的B树是一种数据结构,旨在优化算法,以最小化访问数据所需的磁盘寻道或网络寻道次数。

具体来说,一棵阶数为M的B树包含阶数为M的节点。每个节点最多包含 M - 1 个键。每个键的左右两侧都可能有一个指针,因此一个节点最多可能有 M 个子节点,因为该节点总共有M个指针。

插入操作详解

让我们从一个简单的B树开始,想象一棵阶数为5的B树。这意味着每个节点最多可以容纳4个键。

以下是插入键值14、19、47和81的步骤:

  1. 插入14:根节点为空,直接放入14。
  2. 插入19:根节点未满,将19按顺序放入。
  3. 插入47:根节点未满,将47按顺序放入。
  4. 插入81:根节点未满,将81按顺序放入。

此时,根节点已满(包含4个键)。根据B树的定义,一个节点最多只能有 M - 1 个键,因此无法再直接插入新键。

节点分裂与中间值提升

当尝试插入下一个键值(例如42)时,由于根节点已满,不能直接插入。B树的插入算法要求我们进行“节点分裂”。

以下是处理插入42的步骤:

  1. 定位插入点:42应位于19和47之间。
  2. 节点分裂:由于目标节点(根节点)已满,需要将其分裂。我们取当前节点键值的中位数(此处为42)。
  3. 创建新结构
    • 将中位数42提升为新的根节点。
    • 将小于42的键(14和19)放入一个新的左子节点。
    • 将大于42的键(47和81)放入一个新的右子节点。

现在,我们得到了一棵高度为2的B树。根节点包含一个键(42),左子节点包含两个键(14, 19),右子节点包含两个键(47, 81)。

递归插入过程

B树的插入是递归的。让我们观察一棵阶数为3的B树,其中所有节点都已满。

假设我们要插入数字30。以下是过程:

  1. 查找插入位置:从根节点开始,30介于23和42之间,因此进入中间的子树。在该子树中,30介于25和31之间,应插入此节点。
  2. 节点分裂:目标节点插入30后将拥有3个键(25, 30, 31),超过了阶数3的节点容量(M - 1 = 2)。因此,需要分裂该节点。
    • 取中位数30,将其提升到父节点(原包含23和42的节点)。
  3. 父节点处理:父节点现在包含23、30、42三个键。这导致父节点也满了(因为阶数M=3,最多2个键)。
  4. 再次分裂:必须分裂这个父节点。取其中位数30,再次提升,使其成为新的根节点。
    • 原父节点中小于30的键(23)及其子树成为新根节点的左子节点。
    • 大于30的键(42)及其子树成为新根节点的右子节点。

通过这个过程,我们可以看到插入算法如何递归地向下一层层查找插入位置,仅在节点已满时才分裂节点并将中位数提升到父节点,直到找到一个未满的节点为止。

B树的性质

理解了插入算法后,我们可以总结B树的四个关键性质:

以下是B树必须满足的条件:

  1. 键值有序:每个节点内的键值必须保持排序(升序或降序)。注意,此处的“有序”指键值排序,与“阶数M”中的“阶”含义不同。
  2. 节点容量:每个节点最多包含 M - 1 个键。
  3. 内部节点子节点数:对于每个内部节点(非叶子节点),其子节点数量正好比键的数量多1。这是因为每个键的左右都有指针,两端的指针还指向额外的子树。因此,一个包含k个键的节点,正好有 k + 1 个子节点。
  4. 子节点数范围
    • 根节点至少有2个子节点(除非它是叶子节点),最多有M个子节点。
    • 非根节点(包括内部节点和叶子节点)的子节点数(对于叶子节点,子节点数为0)必须在 ⌈M/2⌉M 之间。这意味着每个节点至少是半满的。这很合理,因为节点只有在满的时候才会分裂,分裂后产生的两个新节点正好是半满的。
  5. 叶子层等高:B树中所有叶子节点都位于同一层级。这是由插入和分裂算法保证的,确保了树的平衡性,无论沿哪条路径向下,高度都相同。

实例分析:确定B树的阶数

观察给定的B树示例(图中未标明阶数M),我们可以通过其性质推断阶数。

以下是推理步骤:

  1. 图中有一个叶子节点包含4个键。根据性质2,一个节点最多有 M - 1 个键,因此 M - 1 ≥ 4,推出 M ≥ 5
  2. 图中有一个内部节点有3个子节点。根据性质4,非根节点的子节点数至少为 ⌈M/2⌉,且该节点有3个子节点,因此 ⌈M/2⌉ ≤ 3。这意味着 M/2 ≤ 3(向上取整前),所以 M ≤ 6
  3. 综合条件 M ≥ 5M ≤ 6,可能的阶数是5或6。
  4. 考虑到B树通常在节点满时分裂,选择奇数阶数(如5)可以使分裂时中位数更明确,因此该树很可能是阶数为5的B树。

通过这个练习,你应该能够分析任何B树并推断其阶数。

总结

本节课中我们一起学习了B树的插入算法。我们看到了如何在B树中查找插入位置,以及当节点满时,如何通过分裂节点并将中位数键提升到父节点来完成插入。这一过程是递归的,确保了B树始终保持平衡和所有叶子在同一层的特性。我们还回顾并运用了B树的关键性质来分析给定的树结构。

接下来,我们将深入分析B树的性能,特别是搜索任意节点所需的代价。

016:B树搜索分析

在本节课中,我们将深入分析B树的搜索过程。我们将探讨其代码实现、时间复杂度,并理解为何B树在涉及磁盘访问的场景下如此高效。

B树搜索概述

上一节我们详细介绍了B树的结构。本节中,我们来看看如何在B树中执行搜索操作,并分析其性能。

搜索过程与代码分析

B树的搜索过程从根节点开始,逐层向下比较。搜索所需的时间主要取决于树的高度。

以下是搜索操作的伪代码,它清晰地展示了这一过程:

def b_tree_search(node, key):
    # 在当前节点内进行线性搜索,找到第一个大于或等于目标key的位置
    i = 0
    while i < node.num_keys and key > node.keys[i]:
        i += 1

    # 如果找到了完全匹配的key,则搜索成功
    if i < node.num_keys and key == node.keys[i]:
        return (node, i)

    # 如果是叶子节点且未找到,则搜索失败
    if node.is_leaf:
        return None

    # 否则,获取下一个子节点并递归搜索
    next_child = fetch_child(node, i)
    return b_tree_search(next_child, key)

这段代码有两个关键点需要注意:

  1. 节点内搜索:代码在节点内部使用了线性搜索(O(n)),即使节点内的键值是排序好的,本可以使用二分搜索(O(log n))。之所以选择线性搜索,是因为其开销与后续操作相比微不足道。
  2. 获取子节点fetch_child 操作是性能关键。它通常需要从磁盘(或网络、云存储)中读取数据,这是一个非常缓慢的操作。

搜索性能分析

B树的设计核心是最小化磁盘访问次数。搜索性能由树的高度决定。

  • 树的高度:对于一个包含 N 个键值、阶数为 M 的B树,其最大高度近似为 log_M (N)。这是因为每一层都将搜索范围缩小了约 M 倍。
  • 磁盘访问次数:因此,在最坏情况下,搜索所需的磁盘访问(fetch_child 调用)次数也约为 log_M (N) 次。

这个特性使得B树在处理海量数据时极其高效。例如,考虑一个阶数为 100 的B树:

  • 要搜索 3 万亿(3e12)条数据,所需的最大磁盘访问次数约为 log_100 (3e12) ≈ 5 次。
  • 相比之下,如果使用AVL树等平衡二叉搜索树来存储同样规模的数据,树高约为 log_2 (3e12) ≈ 42,这意味着需要多达约 42 次磁盘访问。

B树的优势总结

本节课中我们一起学习了B树搜索的机制与性能。

B树是一种混合数据结构,它结合了内存中树形结构的搜索效率与面向磁盘的算法的低访问成本。它专门优化了在数据无法全部装入内存时,最小化缓慢的磁盘访问次数这一核心问题。

通过将数据组织成宽而矮的树形结构,B树确保了即使面对海量数据集,搜索操作也只需要极少数几次磁盘读取。这解释了为何B树及其变种(如B+树)被广泛应用于数据库和文件系统等需要高效管理磁盘数据的领域。

下一节,我们将探讨基于不同思想构建的其他算法。

017:堆简介

概述

在本节课中,我们将要学习一种全新的数据结构——堆。堆结合了多种算法的优点,能够高效地处理数据的插入和删除最小(或最大)元素的操作。我们将了解堆的基本概念、性质,以及它如何通过数组来实现。

堆的引入与目标

本周,我们将讨论一种全新的算法,它将结合不同算法的最佳特性。

引入这种算法的最佳方式是思考如何处理大量数据。我们可能有一大堆数字需要插入到算法中。这种神奇的数据结构将接受数字或任何其他可比较类型的数据。

让我们看一个例子。这里可能有数字3、数字-17、数字42、数字-10,甚至数字-π。这些都是数字。我们可以考虑任何可比较类型的数据。

在这个数据结构中,我们不想维护一个完全有序的序列。我们不想要一个已排序的数据集,因为我们知道维护排序数据需要很长时间。

相反,我们在这个数据结构中唯一关心的操作是:我们希望有一个数据结构能够高效地移除最小值。

因此,如果我对这个数据结构执行移除操作,我可以直接说“移除”,它将移除-17。然后如果我再次执行移除,它将移除-10,再次移除,它将移除-π。

请注意,每次移除操作都不带任何参数,它只是简单地取出整个数据结构中的最小值,将其移除并返回给用户。

我们希望为此进行优化,以构建一个称为优先队列的东西,其中每个值都可以有一个优先级,我们可以根据具有最高或最低优先级的值进行移除。

与传统数据结构的比较

为了确切理解我们为什么要构建这个,我们可以看看已经讨论过的一些经典数据结构。这里有四种。

我们有一个未排序的数组和一个未排序的链表。我们可以看到这些列表具有很好的插入时间,但移除最小元素的时间非常糟糕。我们必须搜索整个列表才能找到最小的元素。

另一方面,已排序的数组和已排序的链表能够以非常慢的方式插入元素以维持排序顺序。但之后我们总是可以在O(1)时间内移除最前面的最小元素。

不幸的是,这两种结构都有一个O(n)的操作,而我们希望避免使用O(n)的操作。

相反,我们将尝试构建一个数据结构,使两种操作都能在小于O(n)的时间内完成。

堆的概念与性质

为此,我们将考虑使用类似树的结构。树可以像我们之前讨论的二叉搜索树一样维护,即总是有左孩子和右孩子。

然而,这棵树的排序不会像二叉搜索树那样严格。因为在二叉搜索树中,我们对每一边需要什么有详细的解释,并且我们发现这种结构会导致一些我们无法接受的性能下降。

相反,我们可以利用树的概念并为其附加一个属性来构建一个新的数据结构。

具体来说,我们将这种新型的树称为

堆将有几个属性。第一个属性是:一棵二叉树T是一个最小堆,如果要么树是空的(即没有节点),要么树有左右孩子,并且左右孩子节点的值都大于根节点的值。

在堆中,我们只关心当前节点下方的所有内容,即所有后代节点都必须大于当前节点。我们不关心兄弟节点或树中其他部分发生的事情。

堆唯一重要的属性是:从一个节点出发的所有后代节点,在最小堆中必须大于该节点本身,在最大堆中必须小于该节点本身。

这意味着,如果我们看这里的堆,左边的一切都大于4。当我们只看左边时,我们不关心右边的情况。同样,右边的一切也将大于4,不关心左边的情况。当然,这递归地适用。所以5的左子树中的所有节点都将大于5,不关心树中的其他任何部分。

我们只关心这个局部属性:父节点将小于其所有后代节点。

堆的数组表示

我们将使用一个巧妙的数据结构来表示这个属性。因为我们理想地喜欢未排序数组的性能,它具有O(1)查找的出色保证,而这在二叉搜索树中绝对没有。

因此,我们将始终确保堆是一棵完全二叉树。请记住,完全二叉树是一棵完美的树,直到最后一层之前每个节点都被填满,并且在最后一层,所有节点都向左靠拢。

请注意这里的这棵完全树。一旦我们有了完全树,我们就可以将其完全映射到一个我们非常熟悉的数据结构。

当我们在内存中有这棵树时,我们将把它存储为一个数组。具体来说,让我们看看这个映射。

当我们有这棵树时,我们实际上将在内存中用一个数组来表示这棵树。所以看这个例子,我们看到4是树的根,它将是数组的第一个元素。5和6将是数组的下两个元素。然后这些元素的四个孩子将是接下来的四个元素。请注意颜色的渐变,5和6都大于4。所有这些节点都将大于它们的父节点。

我将暂时不像计算机科学家那样思考,我将从值1开始索引这个结构。为了不让自己混淆,我将在前面放一个第0个索引,并用一个x标记不使用它。所以这里是索引0, 1, 2, 3, 4, 5, 6, 7。

给定任意一个节点。让我们看节点9,它的索引是5。如果我们知道节点的索引,让我们看看它的父节点在哪里。元素9的父节点将是值5。值5在这里的索引是2。我们如何从5得到2?这看起来像整数除法。所以5除以2使用整数除法是2。

让我们看另一个孩子,15是另一个孩子,15的索引是4,4除以2也是2。

因此,在我们的数组中,我们总是可以导航到父节点,不是通过父指针,而是我们知道父节点等于我们的索引除以2。

同样,我们可以通过反转这个方程来导航到我们的孩子。我们知道,给定一个父节点,其左孩子将等于I * 2。例如,看值20,我们知道20没有左孩子,因为7乘以2是14。看20上面的值6,6的左孩子是7,所以6,3乘以2是6,因此左孩子可以通过当前索引乘以2得到。右孩子可以通过当前索引乘以2再加1得到。

总结

本节课中我们一起学习了堆的基本概念。我们拥有的是整个内存表示,堆将完全是一个数组。我们在内存中只有数组,根本没有树结构。但当我们进行分析时,我们会将其画成一棵树,因为用树来思考要容易得多。请注意,我们拥有与之前完全相同的树结构,这就像你之前学过的二叉树,但现在我们将把这棵二叉树压缩成一个数组。

我们将在这个数组上设置特殊属性,以便我们总能快速找到父节点和孩子节点。我们知道这些操作都有简单的数学公式,可以让我们极其快速地计算它们,因为一切都是2的幂,我们将看到计算机执行这些操作甚至比我们想象的还要快。

这是堆的构建基础。在下一个视频中,我将讨论如何实际向堆中插入数据,并在构建优先队列数据结构时维护这个堆。

018:堆插入操作详解 🧩

在本节课中,我们将学习如何通过一系列插入操作来构建一个最小堆。我们将从一个已有的树结构开始,演示插入过程,然后将其推广到如何从一个空数组开始,通过插入操作构建出堆树。核心操作是每次插入后,通过向上调整(heapify up)来维持堆的性质。


理解最小堆插入

上一节我们明确了最小堆的定义。本节中,我们来看看如何通过插入操作来构建一个最小堆。

我们将使用一个现有的树,观察如何向其中插入元素,然后将此过程一般化,理解如何从空数组开始,通过插入创建堆树。在接下来的例子中,我将进行两次插入操作。请注意,在每次插入后,我都会检查并确保堆的每一层都保持平衡,并且最小堆的性质始终成立。我们将在两次插入后都进行这个操作,并观察哪些元素需要交换。

让我们看这个例子。

首先,插入元素 8。为了插入 8,我将其放在数组的最后一个位置。在数组的视觉树模型中,这相当于在这里放置一个 8

现在需要确保每个父节点的值都小于其子节点。这里我们比较 8 和其父节点 7,发现 7 更小,符合条件。我们知道之前的插入操作已经保证了 7 小于 66 小于 4

现在插入第二个元素 3。同样,将 3 放在数组末尾。在树模型中,它成为 20 的左子节点。

这里出现了问题。203 的顺序不对,3 是更小的元素,它应该成为父节点。为了修复这个问题,我们只需交换这两个元素。我们在数组中找到父节点,然后交换 320。现在 3 在这里,20 在末尾。

我们可以再次向上检查,比较 3 和其新的父节点 6,顺序依然不对,因此交换 36。这将在视觉结构中更新,3 在这里,6 在这里。

接下来,交换 34。现在 3 位于顶部,4 在这里。在视觉结构中,3 是根节点,4 是根节点的右子节点。

至此,我们成功将元素插入数组,并通过重复检查新插入节点的值是否小于其父节点,维护了从该数组构建的树的堆性质。


插入操作的源代码分析

让我们查看源代码,了解这是如何实现的。

每次插入元素时,首先需要检查数组是否有足够容量容纳新元素。在第5行,我们将检查数组的当前大小与其容量。如果两者相等,则没有空间进行插入,因此需要扩展数组。

数组在数据结构意义上是一个无序数组。作为一个无序数组,我们需要考虑如何扩展它。回顾前几周的内容,我们知道为了获得 O(1) 的摊还运行时性能,必须在每次需要时将数组大小翻倍。翻倍数组意味着我们需要为堆树添加全新的一层。

如我下一张幻灯片所示,当我们翻倍数组时,请注意,旧的数组包含了这里蓝色和橙色层的所有元素。当我填满最后一个蓝色层时,需要扩展数组以添加树的另一整层,这相当于将数组大小翻倍。

通过为每个节点添加左子节点和右子节点,通过向树中添加这16个元素(堆已有15个元素,加上开头表示第0索引的一个节点),我们的数组大小从16增长到32,然后到64,再到128。每次我们都将大小翻倍,因此我们知道这是一个摊还 O(1) 的操作。

我们知道了如何扩展数组,并且知道它是摊还 O(1) 的。现在让我们看下一段代码。

下一行你会看到第8行。我们将 item(这是我们的数组)插入到数组末尾,即在 size + 1 的位置插入键值。

// 伪代码示意
if (size == capacity) {
    resize_array(capacity * 2); // 翻倍数组容量
}
array[size + 1] = key; // 在末尾插入新元素
size++;
heapify_up(size); // 向上调整以维持堆性质

只有在这之后,我们才执行 heapify_up。这是用来确保堆性质得以维持的函数名称。

注意传入 heapify_up 的参数。它是 size,是数组的一个索引。我们知道它是一个整数索引,这就是我们将要执行“堆化上浮”操作的位置。它不是值,而是一个索引。因此我们可以称这个参数为 int index

然后,这个参数将在第3行被检查,以确保我们不在根节点(基本情况)。记住,根节点是索引 1(因为我们跳过了索引 0)。因此,如果 index 大于 1,说明我们不在根节点。

接着,如果我们的值小于父节点的值,我们就交换这两个值,然后在父节点上递归调用 heapify_up 以继续此操作。父节点的索引是 floor(index / 2)

// heapify_up 函数伪代码
void heapify_up(int index) {
    if (index > 1) { // 不是根节点
        int parent_index = index / 2; // 计算父节点索引
        if (array[index] < array[parent_index]) {
            swap(array[index], array[parent_index]); // 交换
            heapify_up(parent_index); // 在父节点位置递归调用
        }
    }
}

heapify_up 结束时,我们要么已经到达根节点,并将刚插入的节点一路交换到了根,要么已经将其交换到了正确的位置,可以停止运行算法。无论哪种情况,我们都能确保在算法完成时堆性质得以维持。


总结与预告

现在,你已经了解了堆的两个基本操作之一,确切知道了如何插入元素。

在下一个视频中,我们将深入探讨如何通过移除最小元素来从堆中删除元素。敬请期待。

019:堆移除最小值 🗑️

在本节课中,我们将要学习如何从一个最小堆中移除元素。具体来说,我们将专注于移除堆中的最小元素,并了解如何通过“向下堆化”操作来维护堆的性质。

上一节我们介绍了如何向堆中插入元素,本节中我们来看看如何移除元素。

移除最小元素概述

从最小堆中移除元素,总是移除堆顶的根节点,即最小元素。移除后,我们需要重新调整堆的结构以维持其最小堆的性质。这个过程的核心是“向下堆化”。

移除最小值的步骤

以下是移除最小元素并维护堆性质的关键步骤。

  1. 交换根节点与最后一个节点:首先,将堆的根节点(最小元素)与堆数组中的最后一个元素交换。
  2. 移除原根节点:移除(或弹出)现在位于数组末尾的原根节点。
  3. 向下堆化:从新的根节点开始,执行“向下堆化”操作,将其与子节点比较并交换,直到堆的性质恢复。

向下堆化算法详解

向下堆化的过程是递归或迭代地将一个可能“过大”的节点向下移动,直到它找到正确的位置。

  1. 从当前节点(初始为根节点)开始。
  2. 如果当前节点是叶子节点,则停止。
  3. 否则,找出当前节点两个子节点中值较小的那个
  4. 将当前节点的值与这个较小子节点的值进行比较。
  5. 如果当前节点的值大于这个较小子节点的值,则交换这两个节点。
  6. 将当前节点的索引更新为这个较小子节点的索引,然后重复步骤2。

代码实现

让我们通过代码来看看如何实现移除最小值的操作。

def remove_min(self):
    # 步骤1: 交换根节点与最后一个节点
    min_val = self.heap[1]
    last_val = self.heap.pop() # 移除最后一个元素
    if len(self.heap) > 1:
        self.heap[1] = last_val
        # 步骤2: 从根节点开始向下堆化
        self._heapify_down(1)
    return min_val

def _heapify_down(self, index):
    smallest = index
    left_child = 2 * index
    right_child = 2 * index + 1

    # 找出当前节点、左子节点、右子节点中的最小值索引
    if left_child < len(self.heap) and self.heap[left_child] < self.heap[smallest]:
        smallest = left_child
    if right_child < len(self.heap) and self.heap[right_child] < self.heap[smallest]:
        smallest = right_child

    # 如果最小值不是当前节点,则交换并继续向下堆化
    if smallest != index:
        self.heap[index], self.heap[smallest] = self.heap[smallest], self.heap[index]
        self._heapify_down(smallest)

操作示例

为了更直观地理解,我们通过两个例子来演示移除操作。

示例一:移除根节点4

  1. 初始堆(树形表示):
          4
         / \
        5   8
       / \ / \
      11 9 14 12
    
  2. 移除最小值4:将根节点4与最后一个节点11交换,然后移除4。
          11
         / \
        5   8
       / \ / \
      9  14 12
    
  3. 向下堆化(根节点11):
    • 比较子节点5和8,5更小,交换11和5。
          5
         / \
        11  8
       / \ / \
      9  14 12
    
    • 在新的位置(节点11),比较子节点9和14,9更小,交换11和9。
          5
         / \
        9   8
       / \ / \
      11 14 12
    
    • 节点11现在没有子节点(或子节点值更大),堆化停止。堆性质恢复。

示例二:再次移除最小值(现在根节点是5)

  1. 从上一结果开始:
          5
         / \
        9   8
       / \ / \
      11 14 12
    
  2. 移除最小值5:将根节点5与最后一个节点12交换,然后移除5。
          12
         / \
        9   8
       / \
      11 14
    
  3. 向下堆化(根节点12):
    • 比较子节点9和8,8更小,交换12和8。
          8
         / \
        9   12
       / \
      11 14
    
    • 在新的位置(节点12),比较子节点11和14,11更小,交换12和11。
          8
         / \
        9   11
       / \
      12 14
    
    • 节点12成为叶子节点,堆化停止。堆性质再次恢复。

总结

本节课中我们一起学习了最小堆的移除操作。我们了解到:

  • 移除操作总是针对堆中的最小元素(根节点)。
  • 核心步骤是交换根节点与末位元素,然后对新的根节点执行向下堆化
  • 向下堆化通过不断将节点与其较小的子节点交换,确保最小堆的性质得以维持。
  • 通过“向上堆化”(用于插入)和“向下堆化”(用于移除)这两种巧妙的算法,我们可以在数组中以极高的效率维护一个部分有序的结构,而无需完全排序整个数组。

这种高效性使得堆成为实现优先队列等数据结构的理想基础,我们将在接下来的课程中基于堆构建更多有用的特性。

020:堆的构建 🏗️

概述

在本节课中,我们将要学习如何高效地从一个无序数组构建一个堆数据结构。我们将探讨几种不同的构建方法,并重点介绍一种时间复杂度仅为 O(n) 的巧妙算法。


上一节我们介绍了堆的基本操作。本节中我们来看看为什么我们选择使用堆而不是其他数据结构,即使它看起来与二叉树非常相似。

我们使用堆的主要原因在于,利用已有的堆操作,我们可以非常高效地构建一个堆。让我们考虑一个例子:我们想从一个字符串构建一个堆。

观察这个例子,我们有字符串 “build Heap now”。这个字符串只是一个无序数组。这个“构建堆”操作中的字符没有任何顺序。我们也可以将其以树的形式列出。

我们可以思考三种可能的插入方法来构建堆。

以下是三种构建堆的方法:

  1. 排序数组:我们可以直接对数组进行排序。如果数组已排序,最小的元素在顶部,最大的元素在数组末尾,那么根据定义我们就得到了一个堆(如果最小元素在索引1,那么它必然小于数组中后面的所有元素)。这种方法很好,但它的运行时间是 O(n log n)。如果我们想设计一个高效的算法,我们没有时间进行排序。
  2. 多次调用插入:我们可以考虑多次调用 insert 操作。每次调用 insert,我们都需要执行一次 heapify_up 操作。实际上,我们需要执行 n 次操作,每次操作需要 O(log n) 时间来维护树结构。因此,多次调用 insert 同样会导致 O(n log n) 的运行时间,这仍然不够高效。
  3. 更优的 O(n) 操作:我们能否找到一种 O(n) 时间复杂度的构建方法?这需要我们结合不同的思路。

思考这些方法,我们可以考虑第三种方案,它结合了两种不同的想法,用于从字符串或任何传入的无序列表构建堆。

观察这个例子,第一种方法是排序,第二种方法是使用 heapify_up 来构建堆。第三种方法非常巧妙:让我们思考只使用 heapify_down 操作

通过只使用 heapify_down 操作,我们实际上是在说:树的最底层内容是什么并不重要。因为树的最底层意味着这里的节点已经是平衡的,并且满足最小堆性质。事实上,不仅仅是最后一层,还包括那些没有子节点的节点(例如树中的节点 ‘E’)。也就是说,只有数组前半部分的节点(即最后一层节点的父节点及以上)才需要恢复其堆性质。

记住 heapify_down 的作用:它接受一个节点,并确保该节点在树中被放置到正确的位置。

在操作时,我们首先要查看的节点是 ‘H’。查看节点 ‘H’ 意味着我们需要确保以 ‘H’ 为根的子树是一个堆。在这个例子中,它已经是一个堆了。接着,我们查看 ‘D’。我们判断 ‘D’ 是否同时小于 ‘N’ 和 ‘O’。只有当 ‘D’ 同时小于两者时,堆性质才得以维持。这里也维持了。

我们可以回到之前的图示继续查看。我们比较 ‘L’ 与 ‘A’ 和 ‘P’。这里,我们可以交换 ‘A’ 和 ‘L’。注意,现在我们维护了堆性质。在数组中进行同样的操作:我们交换这里的 ‘A’,‘L’ 下移到这里。此时,我们知道底部两层的所有内容都已经是一个正确的堆了。

因此,我们只进行了三次操作,三次 heapify_down 操作,就确保了此时树中我们拥有一个正确的堆。接下来,我们只需要完成最后三个元素的操作。

在完成最后三个元素后,我们能够执行一系列总共约 n/2 次的 heapify_down 操作。我们知道,在执行 heapify_down 之后,当前节点之下的所有内容实际上已经是平衡的。

因为我们只进行大约 n/2 次操作,并且每次操作的工作量是有限的(每次操作不超过常数时间),所以我们知道我们实际上可以在 O(n) 时间内构建一个堆。

因此,我们真正关心堆构建和堆算法的关键原因在于:给定任何数据结构或数组表示,我们都可以在 O(n) 时间内构建出它的堆表示。这远比排序数组或插入到二叉树中要快得多。

这意味着,当我们在思考算法时,如果我们需要知道列表中一系列元素的最小值,我们可以通过一个 O(n) 的操作来实现。我们将在下一个视频中深入分析这些操作的性能以及我们为什么关心堆。


总结

本节课中我们一起学习了堆的构建。我们比较了排序、多次插入和使用 heapify_down 三种方法,并重点掌握了第三种 O(n) 时间复杂度的高效构建算法。该算法的核心在于自底向上、只对非叶子节点执行 heapify_down 操作,从而快速地将一个无序数组转化为一个合法的堆结构。

021:堆的运行时间分析与堆排序

在本节课中,我们将深入探讨堆的运行时间分析,并学习一个基于堆的强大算法——堆排序。我们将了解堆排序的步骤、时间复杂度,以及它在内存使用上的优势。

堆的运行时间回顾

上一节我们介绍了堆的基本操作。现在我们来回顾一下堆操作的时间复杂度。

我们知道,对于堆的插入和删除操作,由于堆是一棵完全二叉树,这些操作的时间复杂度是 O(log n)

此外,我们还可以在 O(n) 的线性时间内构建一个堆。这是一个非常出色的结果,它使得我们可以利用堆来做一些有趣的事情。

堆排序算法

基于堆的特性,我们可以实现一种高效的排序算法,即堆排序。以下是堆排序的三个主要步骤。

第一步:构建堆

首先,我们需要从一个无序的列表中构建一个堆。正如之前提到的,这个操作可以在 O(n) 的时间内完成。

第二步:反复移除最小元素

堆构建完成后,为了得到一个有序列表,我们需要反复调用 removeMin 操作。每次 removeMin 操作的时间复杂度是 O(log n)

第三步:调整顺序(可选)

如果需要,我们可以交换元素,以确保最终列表是按升序或降序排列的。

通过持续从堆中移除最小元素,我们将得到一个有序序列。例如,最小元素是4,然后是5、6、7、9,依此类推。

堆排序的时间复杂度分析

现在我们来分析堆排序的整体时间复杂度。

  • 构建堆:时间复杂度为 O(n)
  • n 次移除操作:每次移除操作的时间复杂度为 O(log n),因此总时间为 n * O(log n) = O(n log n)

在最坏情况下,O(n log n) 的移除操作时间将主导整个排序过程,因此堆排序的最坏情况时间复杂度是 O(n log n)

尽管我们能在 O(n) 时间内构建堆,但堆排序的最优时间复杂度依然是 O(n log n),这与许多高效的比较排序算法相同。

堆排序的优势

我们关注堆排序,是因为它在某些情况下非常方便。

如果你巧妙地利用我们留空的数组第0个索引,堆排序可以完全在内存中进行,不需要使用任何额外的存储空间。这意味着,只要我们的数据以数组形式存储,堆排序就是一种理想的原地排序算法。

它在内存中操作的优势,以及在数据结构已经部分有序时可能带来的好处,使其在某些应用场景中具有优势。

总结与展望

本节课中,我们一起学习了:

  1. 回顾了最小堆的定义及其插入、删除操作的时间复杂度 O(log n)
  2. 深入分析了堆排序算法的三个步骤。
  3. 推导出堆排序的时间复杂度为 O(n log n)
  4. 了解了堆排序作为原地排序算法的优势。

我们已经掌握了堆的基本概念、操作、运行时间分析以及一个实际应用(堆排序)。接下来,我们将运用这些概念来构建更强大的工具。

在下节课中,我们将开始学习另一个重要的算法——并查集。然后,我们将以深入讲解图这一数据结构来结束本学期的内容。图是一种非常强大的数据结构,它将为我们解锁解决大量有趣问题的潜力。

敬请期待。😊

022:哈希简介 🔑

在本节课中,我们将要学习一种全新的算法类型——哈希。哈希算法因其能为特定操作提供极佳的运行时间,而常成为计算机科学家最喜爱的算法之一。我们将深入探讨哈希的含义、其核心组成部分以及工作原理。

什么是哈希?🔍

从数学上讲,哈希被定义为将一个键空间转换为一组不同值的过程。键空间包含了我们字典中所有可能的键。哈希表的目标是能够将每一个键快速高效地映射到其在字典中对应的值。

如果你曾使用过 Python 或 JavaScript 等语言中的字典结构,那么你可能已经对哈希有所了解。这些原生数据类型的核心思想就是哈希。

一个直观的例子:学校储物柜 🗄️

为了更好地理解哈希,让我们看一个生活中的例子。回想一下高中时期,学校通常有许多储物柜,每个学生被分配一个唯一的柜子。

  • 在这个“哈希表”中,每个储物柜编号(例如 103、92)就是一个唯一的
  • 每个键关联着一些数据,即拥有该储物柜的学生的姓名。
  • 学校的管理部门保存着学生与储物柜编号之间的映射关系。给定任何一个储物柜编号,你都可以快速查找到对应的学生。

哈希表允许我们做的正是这样的事情。我们输入一个数字(键),通过某个函数,将任何可能的输入映射到一个固定大小的输出范围内。

哈希的核心概念与目标 🎯

哈希的整个目标都围绕着下图所示的流程:

  1. 输入:这可以是一个整数、字符串或其他任何类型的数据。
  2. 哈希函数:这是一个我们将要定义的函数,它负责将这个输入(整数、字符串等)转换成一个数字。这个数字的范围在 0 到我们数组的大小 n-1 之间。
  3. 数组:这个转换后的数字用作数组的索引,数组则用于存储实际的数据。

然而,有时两个不同的输入可能会被哈希函数映射到数组的同一个位置,这种情况称为冲突。如何管理这个数组以及如何处理冲突,正是我们讨论哈希时要解决的核心问题。

哈希表的三大组成部分 ⚙️

具体来说,当我们定义一个哈希表时,总有三样东西在起作用。接下来我们将深入探讨这三者的含义。

以下是构成一个哈希表的三个关键部分:

  1. 哈希函数:这是一个将我们的输入空间映射到数组索引的函数。例如,如果我们的输入是字符串,我们需要一个函数将其转换为一个介于 0n-1 之间的整数。其作用可以表示为:index = hash_function(key) % array_size
  2. 存储数组:这是一个实际存储数据的数组。数据通过哈希函数计算出的索引被存入或取出。
  3. 冲突处理策略:我们需要一种方法来决定当哈希函数将两个不同的值映射到数组的同一位置(即发生冲突)时该如何处理。

这三者的结合将是本周视频的主题。我们将深入探讨如何构建一个好的哈希函数、如何有效地使用数组,以及当确实发生冲突时我们该怎么做。

总结 📝

本节课中,我们一起学习了哈希的基本概念。我们了解到哈希是一种将键映射到值的快速查找技术,其核心在于哈希函数、存储数组和冲突处理策略三者的协同工作。虽然目前可能还有些困惑,但随着后续课程的学习,一切都会变得清晰。在下一个视频中,我们将从哈希函数开始,正式开启我们的哈希之旅。

023:哈希函数

在本节课中,我们将要学习哈希函数的核心概念。哈希函数是数据结构中用于将任意大小的数据映射到固定大小值(通常是数组索引)的关键工具。我们将通过具体例子来理解它的工作原理、特性以及设计一个优秀哈希函数所需考虑的因素。

哈希函数示例分析

为了真正理解哈希函数的作用,我们通过几个将数据放入哈希表的例子来观察不同的哈希函数。

示例一:教授姓名映射

第一个例子是关于伊利诺伊大学教授及其所授课程的映射。我们有一系列教授,如教授 Lawrence Engrave 教授 241 课程,教授 Beckman 教授 421 课程等。我们需要找到一个函数,将这些教授姓名映射到数组索引。

我特意选择了一组独特的教授并按特定顺序排列,以便展示一个理想的函数。请注意,名单中恰好包含了字母表中的每一个首字母。

我们设计的哈希函数是:查看键(姓名)字符串的第一个字符,并减去字符 ‘A’ 的值。

用公式表示即:
hash(key) = int(key[0]) - int(‘A’)

  • Engrave‘E’ - ‘A’ = 4,因此映射到索引 4,其值为 241。
  • Beckman‘B’ - ‘A’ = 1,因此映射到索引 1,其值为 421。

以此类推,C 映射到索引 2,D、E、F、G、H 也各自有唯一的映射。最终,数组中的每个位置都被填满,并且每个数据元素都有唯一的映射。

这个理想的哈希函数,我们称之为完美哈希函数,在数学上被称为满射函数。数组的每个元素都被占用,并且我们可以将数据中的每个元素映射到该数组上。这是我们追求的理想目标。

但是,这里存在一个问题。如果新来了一位名字首字母与现有教授相同的教员,例如另一位姓 Cunningham 的教授,他将试图映射到已被占用的索引位置。这就产生了冲突。我们需要处理这个问题,稍后会再次讨论。

示例二:骰子游戏“花瓣环绕”

第二个例子是我最喜欢的骰子游戏。我展示一组骰子的点数,然后给出一个映射数字。例如,对于点数为 [1, 2, 3, 4, 1] 的骰子组,我给出的映射数字是 2。

我使用的哈希函数叫做“花瓣环绕”。规则是:只计算那些中心点被激活的骰子(即点数为 1, 3, 5 的骰子),并统计围绕该中心点的“花瓣”数量。

用代码逻辑描述即:

def petals_around_the_rolls(dice_list):
    count = 0
    for dice in dice_list:
        if dice == 3:
            count += 2  # 3点骰子有2个花瓣
        elif dice == 5:
            count += 4  # 5点骰子有4个花瓣
        # 点数为1的骰子中心点激活,但无花瓣,不计入
        # 点数为2,4,6的骰子中心点未激活,不计入
    return count

因此,对于骰子组 [1, 2, 3, 4, 1],只有骰子 3 被计数,贡献 2 个花瓣,所以哈希值为 2。这意味着在哈希表中,键 2 对应着这组骰子数据。

现在,让我们分析这个哈希函数的问题:

  1. 未充分利用空间:哈希结果只能是 2 或 4(因为只有3和5点骰子贡献花瓣)。因此,哈希表中索引为 1、3、5 等奇数位置永远不会被映射到,造成空间浪费。
  2. 冲突频繁:所有只包含一个5点骰子的不同骰子组合(如 [5], [1,5], [2,5])都会被映射到值 4,导致大量冲突。

这两个问题都是我们在设计优秀哈希函数时需要解决的。

优秀哈希函数的特性

为了确保我们拥有一个优秀的哈希函数,需要考虑三个关键特性来分析它。

首先,我们需要将哈希函数分为两个部分来看待:

  1. 转换函数:将任意输入转换为一个整数。hash(input) -> integer。在这个阶段,我们通常不关心整数的范围。
  2. 压缩函数:确保哈希值落在数组边界内。这可以通过取模运算轻松完成,例如:final_index = hash_value % array_size

在思考哈希函数时,我建议你先不要急于自己创建新的哈希函数。设计一个好的哈希函数非常困难,市面上已经存在一些优秀的哈希函数实现。我们更应先学会如何分析一个哈希函数。

以下是构建哈希函数时我们必须关注的三个特性:

特性一:恒定时间复杂度

哈希函数的计算必须在常数时间内完成。我们希望计算哈希值的时间复杂度是 O(1)。这一点至关重要,因为每次我们访问数据时都需要计算哈希值。如果哈希计算耗时很长,整个算法的效率就会很低。因此,优秀哈希函数的第一个要求是必须在 O(1) 时间内运行

特性二:确定性

哈希函数必须是确定性的。这意味着,如果你对同一个字符串进行哈希计算两次,两次的结果必须完全相同。我们不能在哈希过程中引入随机数。虽然引入随机数可能有助于分散数据,但一旦引入,哈希函数就不再是确定性的。每次你哈希数字 103 或字符串 “weiade” 时,都必须确保输出相同的数组索引。

特性三:简单均匀散列假设

第三个要求是最难保证的,称为简单均匀散列假设

该假设指出,我们的哈希算法结果在整个键值空间上必须是均匀分布的。

用公式化的语言描述:对于两个不同的值 A 和 B,在 SUHA 下,hash(A) 等于 hash(B) 的概率应等于 1 / 数组大小

P(hash(A) == hash(B)) = 1 / M,当 A != B 时(M 为数组大小)。

这意味着,任意两个不同的键被哈希到数组中同一位置的概率是均等的,且仅取决于数组大小。如果出现数据聚集现象,就像“花瓣环绕”例子中那样,大量数据哈希到值 2 或 4,而有些值(如 1)永远无法被哈希到,那么我们就违反了简单均匀散列假设,因为哈希到 1 的概率是 0,而哈希到 2 或 4 的概率却很高。

总结

本节课中,我们一起学习了哈希函数的核心概念。我们通过教授姓名映射和骰子游戏两个例子,直观地理解了哈希函数如何工作以及可能遇到的问题(如冲突和分布不均)。我们深入探讨了优秀哈希函数必须具备的三大特性:恒定时间复杂度 (O(1))确定性 以及满足 简单均匀散列假设。具备这些特性的哈希函数能为高效的数据存储和检索奠定坚实基础。由于保证均匀分布非常困难,在实践中我们通常会使用经过验证的现有哈希函数。在下一讲中,我们将进一步探讨哈希函数的分析,并研究更多示例。

024:哈希函数示例 🧮

在本节课中,我们将通过一个具体的例子来探讨设计哈希函数时可能遇到的挑战。我们将看到,一个在特定数据集上表现良好的哈希函数,在应用到不同场景时可能会完全失效。

哈希函数设计的初步印象

上一节我们介绍了哈希函数的基本概念。本节中我们来看看一个具体的哈希函数设计示例。

设计哈希函数最初看起来可能非常简单。我们可以考虑所有可能作为哈希函数输入的数据。我们可以构思一个相对简单的映射关系。事实上,对于输入值较小的情况,这确实非常容易。我可以思考我的数据是什么,我可以预测用户会输入什么,并且我可以以一种相对均匀的方式进行映射。这并不困难。

现实世界中的复杂性

然而,当代码真正投入生产环境,而不仅仅是在办公室里构思时,情况往往不同。你会发现实际的键空间(key space)远比你想象的要大。我们需要一个能考虑整个键空间,而不仅仅是用户预期输入的哈希函数。

因此,我们不仅要映射到一个小键空间,还必须考虑到数据点可能出现在这里,也可能出现在很远的地方。这样一来,我们需要考虑键空间的不同部分。所有这些键空间的子集都需要被均匀地映射。要做到这一点极其困难。

一个具体的失败案例

以下是我个人尝试创建新哈希函数时遇到的一个例子。

我想映射任意长度的字符串。我创建了一个哈希函数,它通过仅映射字符串的前八个字符来处理任意长度的字符串。我认为,一个八字符的字符串很容易处理,我可以对它们的值求和,进行一些取模和乘法运算,就能建立一个很好的系统,确保每个字符串都被均匀且不同地映射。

例如,我这里有《爱丽丝梦游仙境》的文本。这段文本有很多不同的句子。如果我查看每一行的前八个字符,会发现它们都大不相同,没有两行是相同的。如果我对这些前八个字符进行一些巧妙的数学运算,我可以得到每次都不相同的数字。我当时非常自信,认为自己能够创建一个哈希函数,将《爱丽丝梦游仙境》的每一行都映射到一个特定的唯一值,或者至少是均匀地映射。

创建这个哈希函数后,它在某些文本语料库上运行得很好。

问题浮现

但是,当我将它应用到正在构建的另一个不同程序时,一些奇怪的事情发生了。

我接下来构建的是一个网络爬虫,用于抓取维基百科文章,以研究其中的文本。在这个过程中,我哈希表的键——即描述我拥有什么数据的数据点——是URL。

使用完全相同的哈希函数,我查看了每一个URL。让我们看看这些URL的前八个字符。在维基百科上,所有URL的前八个字符完全一样

这意味着,我的数据集中每一个点都映射到了完全相同的值。

后果与教训

由此产生的结果是,每一个点都发生了碰撞,并且发生了大量不同的碰撞。我不得不解决所有这些碰撞,而我绝对没有获得一个均匀的函数集合。

正因如此,我实际上开发了一个在一种数据上运行得非常出色的哈希函数,但一旦用于另一个应用,这个哈希函数就彻底失败了。

核心挑战总结

这些就是我们在处理哈希函数时会遇到的一些问题。要考虑到用户所有可能的使用场景是极其困难的。

因此,每当我们考虑一个哈希函数时,我们都希望非常、非常小心。如果可能的话,我们将使用一些已经存在多年的成熟哈希函数,因为测试一个哈希函数是否优秀的最佳方法,就是长时间地使用它。

过渡到下一主题

我们已经讨论了很多关于哈希函数的内容。现在,我们将更深入地探讨下一个方面,即数组以及我们如何处理碰撞。我们下节再见。


本节课总结:我们一起学习了设计哈希函数的实际挑战。通过一个将字符串前八字符作为哈希依据的失败案例,我们明白了哈希函数必须在整个潜在的键空间上保持均匀性,而不仅仅在测试数据上。这个例子强调了使用经过时间检验的成熟哈希函数的重要性,并为接下来学习处理碰撞的策略做好了铺垫。

025:冲突处理之一——分离链接法 🔗

在本节课中,我们将要学习哈希表中一个核心问题的解决方案:如何处理哈希冲突。具体来说,我们将深入探讨第一种策略——分离链接法。

概述

到目前为止,我们已经讨论了很多关于哈希函数的内容。哈希函数有时会导致冲突,即两个不同的输入在哈希函数下计算出完全相同的值。这种情况虽然应该很少见,但如果哈希算法不够强健,也可能频繁发生。现在,让我们看看处理这个问题的一些策略。

分离链接法的原理

第一种策略是分离链接法。其核心思想是,我们将数组的每个槽位视为一个链表的头节点。每当发生冲突时,我们只需将新元素添加到对应槽位的链表中。

让我们通过一个例子来看看这是如何工作的。

操作示例

假设我们有一个集合 S,需要将其元素存入哈希表。我们的哈希函数是 K mod 7。以下是操作步骤:

以下是插入过程的逐步说明:

  1. 插入 1616 mod 7 = 2。将 16 放入索引为 2 的槽位。此时,每个槽位都是一个初始为空的链表。
  2. 插入 88 mod 7 = 1。将 8 放入索引为 1 的槽位。
  3. 插入 44 mod 7 = 4。将 4 放入索引为 4 的槽位。
  4. 插入 1313 mod 7 = 6。将 13 放入索引为 6 的槽位。
  5. 插入 2929 mod 7 = 1。此时,索引 1 处已存在元素 8,发生冲突。在分离链接法中,我们只需将新元素插入到链表头部。因此,将 29 插入索引 1 的链表头部,并使其指向原来的 8。
  6. 插入 1111 mod 7 = 4。索引 4 处已存在元素 4,再次冲突。将 11 插入索引 4 的链表头部,并指向 4。
  7. 插入 2222 mod 7 = 1。索引 1 处链表已有 29 -> 8。将 22 插入链表头部,形成 22 -> 29 -> 8。

通过分离链接法,我们能够通过简单地添加到链表中来处理冲突。

性能分析

你可以想象,这种算法在最坏情况下的表现会非常糟糕。如果我们有一个总是返回相同哈希值的哈希函数,那么我们就会得到一个长度等于哈希表中数据量的链表。这意味着在该哈希表中查找任何元素,算法都需要 O(n) 的时间,这非常低效。

以下是分离链接法的主要操作时间复杂度:

  • 插入:最坏情况为 O(1)。我们总是可以在链表头部快速插入。
  • 查找/删除:最坏情况为 O(n),发生在所有元素都哈希到同一个槽位时。

然而,在简单均匀哈希的理想假设下,插入时间仍为 O(1)。为了更精确地分析查找和删除的性能,我们引入一个新变量:装载因子 α

装载因子 α 定义为哈希表中元素数量 n 与数组大小 N 的比值:

α = n / N

这个术语决定了哈希表的“满度”:

  • 装载因子为 1.0 意味着表中的数据量正好等于数组的空间。
  • 装载因子为 0.5 意味着平均而言,数组有一半是满的,或者链表平均长度为 0.5。
  • 装载因子为 2 意味着平均每个槽位的链表中有两个元素。

随着装载因子 α 的增长,运行此算法所需的时间也会增长。在均匀哈希下,查找的平均时间复杂度可以表示为 O(1 + α)

总结

本节课中,我们一起学习了处理哈希冲突的第一种策略——分离链接法。我们了解了其工作原理,即通过将冲突元素存储在链表中来解决冲突。我们还分析了其时间复杂度,并引入了装载因子这一重要概念来衡量哈希表的性能。分离链接法实现简单,但最坏情况性能可能退化。

在下一个视频中,我们将讨论另一种采用完全不同思路的冲突解决策略。

026:冲突处理之二——探测法与双重哈希 🔍

在本节课中,我们将学习哈希表中处理冲突的另外两种核心策略:线性探测法和双重哈希法。我们将了解它们的工作原理、各自的优缺点,以及如何通过控制负载因子来保证哈希表的高效运行。


冲突处理策略:线性探测法

上一节我们介绍了使用分离链接法(链表)处理冲突。本节中,我们来看看另一种完全不同的策略——线性探测法。这种方法不使用额外的链表结构,而是将数据直接存储在数组中。当发生冲突时,它会顺序地查找数组中的下一个空位。

以下是线性探测法的基本步骤:

  1. 使用哈希函数计算键的初始索引。
  2. 如果目标索引为空,则直接插入数据。
  3. 如果目标索引已被占用(发生冲突),则顺序检查下一个索引(index + 1, index + 2, ...),直到找到空位并插入数据。

让我们通过一个例子来理解这个过程。假设我们有一个大小为7的数组,哈希函数为 h(k) = k mod 7,并按顺序插入数据 {16, 8, 4, 13, 29, 11, 22}

  • 插入 1616 mod 7 = 2。索引2为空,插入。
  • 插入 88 mod 7 = 1。索引1为空,插入。
  • 插入 44 mod 7 = 4。索引4为空,插入。
  • 插入 1313 mod 7 = 6。索引6为空,插入。
  • 插入 2929 mod 7 = 1。索引1已有数据8,发生冲突。开始线性探测:
    • 检查索引2(已有16)。
    • 检查索引3(空)。将29插入索引3。
  • 插入 1111 mod 7 = 4。索引4已有数据4,发生冲突。开始线性探测:
    • 检查索引5(空)。将11插入索引5。
  • 插入 2222 mod 7 = 1。索引1有8,冲突。探测过程为:
    • 索引2(有16)
    • 索引3(有29)
    • 索引4(有4)
    • 索引5(有11)
    • 索引6(有13)
    • 索引0(空)。最终将22插入索引0。

从最后一个例子可以看出,当数组变得拥挤时,线性探测可能需要检查很多位置才能找到空位,这会导致性能下降。


线性探测的问题:主集群

线性探测的一个主要问题是会形成“主集群”。当数组中连续出现多个被占用的位置时,任何哈希到该集群范围内的键,都需要一直探测到集群的末端才能找到空位。即使数据原本是均匀分布的,概率上也会导致这种集群的形成,使得数组的一部分非常拥挤,而另一部分相对稀疏。

这会导致性能恶化。我们可以用以下公式描述线性探测的预期探查次数:
E[探查次数] ≈ 1 / (1 - α)
其中 α 是负载因子(已存储元素数量 / 数组总大小)。随着 α 增大(数组变满),分母 (1 - α) 变小,探查次数会急剧增加。


解决方案:双重哈希法

为了解决主集群问题,我们引入双重哈希法。这种方法不是固定地每次只探测下一个位置,而是使用第二个哈希函数来计算一个“步长”。这样,发生冲突时,探测的间隔是变化的,从而有效分散集群。

双重哈希的哈希函数定义如下:
h(k, i) = (h₁(k) + i * h₂(k)) mod m
其中:

  • h₁(k) 是主哈希函数(例如 k mod m)。
  • h₂(k) 是次哈希函数,用于计算步长。它必须保证输出不为0,且与数组大小 m 互质(通常设计为 h₂(k) = R - (k mod R)R 是一个小于 m 的质数)。
  • i 是尝试次数(i = 0, 1, 2, ...)。
  • m 是数组大小。

让我们用同样的数据 {16, 8, 4, 13, 29, 11, 22} 和数组大小 m=7 来演示。设主哈希函数 h₁(k) = k mod 7,次哈希函数 h₂(k) = 5 - (k mod 5)

  • 插入 16, 8, 4, 13:过程与之前相同,分别放入索引2, 1, 4, 6。
  • 插入 29
    • h₁(29) = 29 mod 7 = 1(冲突)。
    • h₂(29) = 5 - (29 mod 5) = 5 - 4 = 1
    • 第一次尝试 (i=1):(1 + 1 * 1) mod 7 = 2(冲突,有16)。
    • 第二次尝试 (i=2):(1 + 2 * 1) mod 7 = 3(空)。将29插入索引3。
  • 插入 11
    • h₁(11) = 11 mod 7 = 4(冲突,有4)。
    • h₂(11) = 5 - (11 mod 5) = 5 - 1 = 4
    • 第一次尝试 (i=1):(4 + 1 * 4) mod 7 = 8 mod 7 = 1(冲突,有8)。
    • 第二次尝试 (i=2):(4 + 2 * 4) mod 7 = 12 mod 7 = 5(空)。将11插入索引5。

可以看到,由于步长不同,数据被更均匀地分散开了。双重哈希的预期探查次数公式为:
E[探查次数] ≈ 1 / (1 - α)
虽然公式形式与线性探测类似,但由于避免了主集群,在实际中性能更优,尤其是在高负载因子时。


性能关键:负载因子与动态扩容

无论使用分离链接、线性探测还是双重哈希,哈希表的性能都高度依赖于负载因子 α。下图展示了不同方法的性能随 α 变化的趋势:

核心结论是:只要将负载因子 α 维持在一个较低的水平(例如 ≤ 0.6),哈希表的所有操作(插入、查找、删除)的平均时间复杂度都可以接近 O(1)。

这意味着,即使哈希表中存储了十亿条数据,只要数组足够大(保持 α 很小),操作速度依然会非常快。运行时间取决于负载因子,而不是数据总量的绝对值。

为了维持较小的 α,当元素增多导致 α 超过阈值时,我们必须对数组进行扩容(通常是加倍)。扩容后,有一个至关重要的步骤:重哈希。因为数组大小 m 改变了,所有元素的主哈希值 h₁(k) mod m 都可能发生变化。因此,必须将旧数组中的每个元素重新计算哈希值,并插入到新数组的正确位置。


总结

本节课中我们一起学习了哈希表冲突处理的另外两种重要方法:

  1. 线性探测法:发生冲突时,顺序查找数组中的下一个空位。实现简单,但容易产生“主集群”,导致性能下降。
  2. 双重哈希法:使用第二个哈希函数计算探测步长,有效避免了主集群问题,是更高效的开放寻址策略。

我们认识到,无论采用哪种冲突解决策略,控制负载因子 α 是保证哈希表高效运行(O(1) 平均时间复杂度)的关键。这需要通过动态扩容和重哈希来实现。一个完整的哈希表系统需要:一个均匀、快速、确定的哈希函数;一个能动态调整大小的数组;以及一个有效的冲突处理策略。在接下来的课程中,我们将对哈希表进行整体分析。

027:哈希分析 🔍

在本节课中,我们将要学习哈希表的核心概念,并分析其在不同应用场景下的性能表现和选择策略。我们将对比哈希表与其他数据结构(如二叉搜索树)的优劣,帮助你理解如何根据具体需求选择最合适的工具。


上一节我们介绍了哈希表的基本工作原理,本节中我们来看看如何分析哈希表的性能,并探讨它在不同场景下的适用性。

在讨论哈希时,我们需要从宏观角度考虑一些关键问题。其中一个问题是,我们需要确定哪种策略是更好的选择。

以下是两种主要的哈希冲突解决策略:

  • 分离链接法:在数组的每个槽位使用一个链表来存储冲突的元素。
  • 开放地址法(如线性探测或双重哈希):将数据直接存储在数组本身中,通过探测寻找空位。

我们发现,根据具体应用场景,正确的答案是不同的。如果你要存储的数据对象很大,将其复制到数组内部会耗费大量时间,那么你绝对不应该使用数组。你更希望使用指针,即通过链表将数据存储在别处。

因此,当我们考虑存储大型记录时,我们会希望使用像分离链接法这样的策略来处理。

既然分离链接法适用于大型记录,意味着另一种解决方案在某些其他方面可能更好。事实上,双重哈希在结构速度上表现优异。

请记住,数组操作在内存访问上是优化的,因为它们在内存中是连续存储的。所以,如果我们只关心原始的结构速度,并且知道我们的数据本身会比较小,我们可以使用像线性探测或双重哈希这样的开放地址技术,来实现一个真正高效的数据结构。

所以,为了追求结构速度,我们会希望采用像双重哈希这样的方法。


接下来我们可能会问,哈希表替代了哪种数据结构?这种结构就是字典。

AVL树也提供了一种字典实现。正如我们讨论过的,AVL树在范围查找和最近邻搜索方面做得非常出色,而哈希表在这方面的表现则非常糟糕。你无法询问“哪个值接近42”,因为42被哈希到数组中的一个特定位置,而数组实际存储中,41和43可能被哈希到与42完全不同的位置。

因此,为我们的应用选择正确的算法至关重要。因为如果我们需要在哈希表上进行最近邻搜索,我们将不得不面对O(n)的时间复杂度。

那么,哈希表替代了哪种结构?它替代了字典。哈希表存在哪些二叉搜索树所没有的限制?正如我们刚刚讨论的,一个主要的限制是二叉搜索树拥有优秀的最近邻搜索能力,而哈希表完全没有这种能力。

因此,当我们考虑使用哪种算法时,如果需要范围查找或最近邻搜索,我们希望使用树形结构。如果我们总是使用确切的键进行查找,哈希表是绝佳的选择。哈希表能以O(1)的时间复杂度完成查找,而哈希表和AVL树的查找时间复杂度是O(log n)。但当我们需要进行附近值搜索时,在AVL树上是O(log n),而在哈希表上则是O(n)。


最后一个问题是,我们为什么还要讨论二叉搜索树?我们讨论BST,既是为了介绍字典结构,也是因为我们将要解决的一些最有趣的问题无法用哈希表来解决。

哈希表是一种出色的通用数据结构,而AVL树将能特别有效地解决某些特定问题。如果你只关心查找,哈希表就是适合你的算法。下周我们将开始使用哈希表来构建更复杂的算法。

我希望你们喜欢学习哈希表,它是我个人最喜欢的算法之一。下周我将带来一系列关于全新数据结构的新视频,到时再见。🎬


本节课总结
本节课我们一起学习了如何分析哈希表的性能。我们对比了分离链接法和开放地址法(如双重哈希)的适用场景:大型记录适合用分离链接法,而追求结构速度和小型数据则适合用双重哈希。我们明确了哈希表的核心作用是实现字典,并重点比较了它与AVL树的区别:哈希表在精确查找(O(1)) 上效率极高,但在范围查找和最近邻搜索上能力很弱,而这正是AVL树(O(log n))的优势所在。因此,选择数据结构的关键在于根据具体的应用需求(是精确查找还是范围查询)做出权衡。

028:C++中的哈希表

在本节课中,我们将学习如何在C++标准模板库中使用内置的哈希表数据结构,即 unordered_map。我们将了解它与 map 的区别,以及如何利用其特性进行高效的数据操作。


在C++中,哈希表已经内置于C++标准模板库中。因此,我们可以在C++中以极少的代码量使用哈希表。

人们通常使用 map 来实现字典功能。map 提供了几个运行效率很高的操作,包括索引操作、插入操作和删除操作。此外,map 还提供了 lower_boundupper_bound 操作。回顾之前的内容,map 确实实现了字典,但它并非基于哈希表实现。实际上,map 是红黑树的一种实现。因此,这些操作的时间复杂度是 O(log n)

然而,C++提供了另一种数据结构来实现哈希表,这种数据结构被称为 unordered_map


unordered_map 与基于树的 map 不同。在树结构中,我们可以进行 upper_boundlower_bound 搜索,具备范围查找的能力。正如我们之前讨论的,哈希表内部不具备范围查找的能力。

尽管如此,unordered_map 仍然提供了相同的基本操作。我们仍然可以在哈希表中查找、插入和删除项目,但由于它基于哈希函数,因此使用的函数有所不同。

哈希表中的函数包括获取诸如负载因子之类的信息。在C++中,我们可以查询哈希表的负载因子大小,例如负载因子是0.5还是0.6。我们还可以做一件非常有趣的事情:设定我们希望算法达到的目标负载因子。


因此,我们可以将目标负载因子设置为一个非常糟糕的值,例如1.0,此时算法的性能将开始急剧下降。如果你想故意让程序表现不佳,可以要求标准模板库设置一个非常、非常、非常糟糕的负载因子。

所有这些功能使我们能够使用 unordered_map 编写C++代码来实现哈希表,只要负载因子设置得当,就能获得 O(1) 时间复杂度的保证。现在,你永远不需要自己编写哈希表,可以直接使用C++中内置的功能。

我期待看到你们在使用哈希表时编写的一些代码。我们下次再见。

029:不相交集合简介

概述

在本节课中,我们将要学习一种名为“不相交集合”的数据结构。这种数据结构虽然看似专门化,但在后续学习图论时将有巨大的应用价值。我们将了解它的基本概念、核心操作以及其重要性。

不相交集合的概念

不相交集合是由一系列互不相交的集合组成的。每个集合内的所有元素在该集合内被认为是等价的。这类似于数学中的等价关系。

为了更清晰地理解,让我们看一个例子。

以下是四个不相交集合:

  • 一个集合包含元素 2, 5, 9。
  • 一个集合包含元素 7。
  • 一个集合包含元素 0, 1, 4, 8。
  • 一个集合包含元素 3, 6。

在每个集合内部,所有元素被视为完全等同。例如,在集合 {2, 5, 9} 中,元素 2、5 和 9 是完全不可区分的。

核心操作:查找

我们可以对不相交集合执行“查找”操作。find(x) 操作会找到包含元素 x 的那个集合的“身份标识”。

我们可以选择集合中的任意一个元素作为该集合的身份标识。为了清晰起见,我们通常选择每个集合中的第一个元素作为其身份标识。

根据这个规则:

  • 集合 {2, 5, 9} 的身份标识是 2
  • 集合 {7} 的身份标识是 7
  • 集合 {0, 1, 4, 8} 的身份标识是 0
  • 集合 {3, 6} 的身份标识是 3

关键点在于,每个集合的身份标识必须是唯一的,以便我们能区分不同的集合。同时,同一个集合内的所有元素,其查找结果(即身份标识)必须相同。

例如,执行 find(4) 会返回其所在集合的身份标识 0。同样,find(8) 也会返回 0。因为 find(4) == find(8) 成立,所以我们知道元素 4 和 8 属于同一个集合。

查找操作的核心公式可以表示为:
find(x) == find(y) 当且仅当元素 xy 属于同一个集合。

核心操作:合并

除了查找,我们还需要能够将两个集合合并在一起。这就是“合并”操作。union(x, y) 操作会将包含元素 x 的集合与包含元素 y 的集合合并成一个新的、更大的集合。

重要特性:一旦元素被加入某个集合,就无法再将其分离。合并操作是不可逆的。

让我们看一个例子。假设我们要合并包含元素 2 的集合和包含元素 7 的集合。

  • 包含 2 的集合是 {2, 5, 9},身份标识为 2。
  • 包含 7 的集合是 {7},身份标识为 7。

执行 union(2, 7) 后,这两个集合合并成一个新的集合 {2, 5, 9, 7}。此时,这个新集合需要一个新的统一身份标识。按照我们的规则(选择第一个元素),新集合的身份标识变为 2。因此,find(7) 的结果也将变为 2。

数据结构定义与操作总结

从数学上讲,不相交集合是一个集合的集合,其中每个子集都有一个唯一的身份标识。

每个子集都有一个“代表元”,即唯一标识该集合的元素。

我们需要为这个数据结构编程实现三个基本操作:

  1. 创建集合makeSet(x) - 创建一个仅包含元素 x 的新集合。
  2. 查找find(x) - 返回包含 x 的集合的代表元。
  3. 合并union(x, y) - 合并包含 x 和包含 y 的集合。

上一节我们介绍了不相交集合的基本概念和操作,本节中我们来看看如何实现它。

以下是实现不相交集合需要满足的基本要求:

  • 能够创建单元素集合。
  • 能够快速查找任意元素所属集合的代表元。
  • 能够高效地合并两个集合。

后续内容预告

我们将深入探讨如何构建一个非常高效的算法来解决这个问题。这将是本周课程的全部内容。

在接下来的讲座中,我们将首先从如何构建这种数据结构开始。我们下次课再见。

总结

本节课中我们一起学习了不相交集合数据结构。我们理解了它是由多个互不相交的子集构成,每个子集内的元素等价。我们掌握了两个核心操作:查找(用于确定元素所属集合)和合并(用于连接两个集合)。我们还明确了实现该结构所需的三个基本方法:makeSetfindunion。这些概念是后续学习高效算法和图论应用的基础。

030:不相交集合的简单实现

在本节课中,我们将学习不相交集合数据结构的一种简单实现方法。我们将探讨其核心思想、数据结构的设计,并分析其操作的运行时间效率。

概述

不相交集合数据结构用于管理一组被划分为多个不相交子集的元素。它支持两个核心操作:find(查找元素所属集合)和union(合并两个集合)。本节我们将构建一个最直观的实现,并分析其性能瓶颈。

数据结构设计

我们考虑如何实现不相交集合算法。我们将创建一个最简单的实现,并观察其性能表现以及存在的问题,以便后续构建更复杂的解决方案。

一种直接的方法是使用一个数组来存储所有元素。数组的索引代表元素本身,而数组在该索引处存储的值代表该元素所属集合的“身份标识”。

以下是具体实现思路:

  • 给定一组元素,例如 {0, 1, 4, 2, 7, 3, 5, 6}
  • 我们可以创建一个数组,其索引范围覆盖这些元素。
  • 对于每个元素 xarray[x] 中存储的值就是元素 x 所属集合的身份标识。

如果元素本身不是连续的整数,我们可以在数组前使用哈希表,将元素映射为数组索引。

操作实现与分析

上一节我们介绍了数据结构的设计,本节中我们来看看核心操作 findunion 是如何实现的,以及它们的效率如何。

Find 操作

find 操作的目的是确定给定元素属于哪个集合。

根据我们的设计,要查找元素 x 的集合身份,我们只需访问 array[x] 即可。这是一个直接的数组查找操作。

代码示例:

def find(x):
    return array[x]

这个操作的运行时间是 O(1),即常数时间。这是一个非常高效的查找速度。

Union 操作

union 操作的目的是合并两个元素各自所属的集合。

假设我们要合并元素 4 和元素 2。这意味着元素 4 所在的集合(身份标识为 0)和元素 2 所在的集合(身份标识为 2)需要合并成一个集合。

合并后,原属于身份标识为 2 的集合的所有元素(例如元素 27),其身份标识都需要更新为新的集合标识(例如 0)。

然而,在我们的简单数组实现中,存在一个关键问题:我们无法直接知道哪些元素的身份标识是 2。数组只记录了“从元素到标识”的映射,但没有记录“从标识到元素”的映射。

因此,执行 union 操作的唯一方法是遍历整个数组,检查每个位置存储的标识。如果某个元素的标识等于需要被合并的旧标识(例如 2),我们就将其更新为新的标识(例如 0)。

以下是执行此操作的步骤:

  1. 遍历数组中的每一个索引 i
  2. 检查 array[i] 是否等于旧集合标识(例如 2)。
  3. 如果相等,则将 array[i] 更新为新集合标识(例如 0)。

代码示例:

def union(x, y):
    rootX = find(x)
    rootY = find(y)
    if rootX != rootY:
        for i in range(len(array)):
            if array[i] == rootY:
                array[i] = rootX

这个操作的运行时间是 O(N),其中 N 是数组中元素的总数。当处理大量数据时,这是一个非常低效的运行时间。

总结

本节课中我们一起学习了不相交集合数据结构的一种简单实现。我们使用数组来存储集合身份标识,使得 find 操作达到了极快的 O(1) 常数时间复杂度。然而,union 操作由于需要遍历整个数组来更新所有相关元素的标识,其时间复杂度为 O(N),这在大多数应用场景下是无法接受的。

这种性能上的不平衡(find 极快,union 极慢)促使我们去寻找更优的解决方案。在下一讲中,我们将以此为基础,构建一个更高效的算法,目标是显著降低 union 操作的运行时间,使其远低于 O(N)

031:不相交集合的优化实现——树结构 🌳

在本节课中,我们将要学习如何优化不相交集合的实现。我们将从之前讨论的简单数组实现出发,分析其优缺点,并引入一种更高效的数据结构——上树。通过这种结构,我们可以显著提升 union 操作的效率。

回顾与优化动机

上一节我们介绍了不相交集合的一种简单实现,它允许我们进行高效的 find 操作。但我们发现,union 操作需要遍历整个数组,这在元素众多时效率不高。

这种实现有很多优点,但也存在一些需要改进的地方。因此,我们将保留其核心——使用数组进行查找,但会存储额外的信息,以构建一个更容易进行合并操作的结构。

上树结构详解

在这个新的实现中,我们仍然使用一个数组,其中索引代表元素本身。但数组存储的值不再是简单的根元素标识,而是包含更多信息:

  • 当某个元素是所在集合的代表元时,其数组值存储为 -1
  • 否则,其数组值存储为其父节点的索引。

我们称这种结构为 上树。之所以叫“上树”,是因为在视觉上,所有元素都向上指向其父节点,最终指向根节点(代表元)。

初始状态

最初,我们有四个元素:0, 1, 2, 3,它们各自构成一个独立的集合。此时,上树在数组中的表示如下:

索引:  0   1   2   3
值:   -1  -1  -1  -1

这表示每个元素都是自己集合的代表元。我们可以将其可视化为四个独立的、只包含一个节点的树。

执行合并操作

以下是合并操作的步骤演示:

  1. Union(0, 3)
    假设我们希望合并元素 0 和 3。我们选择让 0 作为新集合的代表元,让 3 指向 0。

    • 数组变化:索引 3 的值从 -1 变为 0。
    • 树形结构:元素 3 成为元素 0 的子节点。
    数组: [-1, -1, -1,  0]
    树:   0        1     2
           \
            3
    
  2. Union(1, 2)
    接下来,合并元素 1 和 2。我们选择让 1 作为代表元,让 2 指向 1。

    • 数组变化:索引 2 的值从 -1 变为 1。
    • 树形结构:元素 2 成为元素 1 的子节点。
    数组: [-1, -1,  1,  0]
    树:   0        1     3
           \       \
            3       2
    
  3. Union(0, 1)
    现在,合并两个已存在的集合(以 0 和 1 为代表元)。关键优化在于:我们不需要遍历并更新集合 1 中所有元素的指针。

    • 操作:我们只需将集合 1 的根节点(元素 1)直接指向集合 0 的根节点(元素 0)。
    • 数组变化:索引 1 的值从 -1 变为 0。
    • 树形结构:元素 1(及其子树)成为元素 0 的子树。
    数组: [-1,  0,  1,  0]
    树:       0
            /   \
           3     1
                  \
                   2
    

通过这种方式,union 操作仅需更新一个指针(即一个数组元素的值),就完成了两个集合的合并,效率远高于遍历整个数组的简单实现。

案例分析

让我们看一个更复杂的例子,它包含了多个集合。下图展示了一个上树及其对应的数组表示:

观察这个上树,我们可以识别出代表元(根节点)是 5, 7, 4 和 3。在数组中,代表元对应的值应为 -1。

  • 索引 5:值为 -1 ✅
  • 索引 7:值为 -1 ✅
  • 索引 4:值为 -1 ✅
  • 索引 3:值为 6 ❌

这里存在一个错误。元素 3 应该是其所在集合的代表元,因此它的值应为 -1,而不是指向 6。正确的数组表示应该是索引 3 的值为 -1。

这个例子强调了正确维护数组值的重要性:只有代表元的数组值才是 -1,其他元素的值是其父节点的索引

性能与展望

我们找到了一种算法,能够快速高效地更新和维护不相交集合数据结构。union 操作现在只需常数时间(更新一个指针)。

然而,这种实现的最坏情况是可能形成一条长长的“链”(类似于链表),此时 find 操作需要从叶子节点一直回溯到根节点,效率会下降。

在下一节视频中,我们将探讨如何进一步改进,构建更理想的树形结构,以优化 find 操作的性能。


本节课中我们一起学习了不相交集合的优化实现——上树结构。我们了解到,通过让数组存储父节点索引,并在合并时仅更新根节点的指针,可以极大地提升 union 操作的效率。同时,我们也认识到当前实现中 find 操作在最坏情况下可能较慢,这为后续的优化(如按秩合并和路径压缩)留下了空间。

032:树结构的简单时间复杂度分析

在本节课中,我们将对上节课介绍的Uptree(上树)数据结构进行更深入的时间复杂度分析。我们将重点关注find算法的性能,并探讨如何构建接近理想形态的树结构,以实现高效的常数级查找。

回顾find算法

上一节我们介绍了Uptree的基本概念和实现。现在,我们来具体分析其核心操作find的代码与性能。

以下是find算法的代码描述:

def find(element):
    if parent[element] < 0:
        return element  # 找到根(身份)节点
    else:
        return find(parent[element])  # 递归向上查找

该算法逻辑很简单:如果当前节点的父指针值小于0(例如-1),则表示找到了根节点(即集合的代表元素);否则,算法需要递归地查找当前节点父节点所指向的元素。

时间复杂度分析

接下来,我们分析这个算法的时间开销。

算法的运行时间与树的高度成正比。原因在于,查找一个节点需要沿着父指针链一直向上追溯到根节点。

考虑一个最坏情况的树结构:假设我们有一个Uptree,其中根节点是4,其下是3,再下是2,最下是1。查找节点1时,需要先查2,再查3,最后查4。因此,树中最深的节点决定了查找所需的时间。

所以,Uptree中find操作的运行时间复杂度是 O(H),其中H是树的高度。

在最坏的情况下,所有数据可能形成一个单链表,此时树的高度H等于节点总数n。在这种情况下,我们的性能并没有比最初朴素的实现方法更好。

理想的树结构

既然最坏情况不理想,我们期望构建一种具有理想结构的Uptree。

我们知道,不可能所有节点都是根节点(即值都为-1)。但是,我们可以构建一个极其扁平的树:只有一个节点作为根节点(身份元素),而所有其他节点都直接指向这个根节点。

让我们看看这可能是什么样子。假设元素4是我们的根节点。在一个理想的Uptree中,元素3、2、1、0以及其他所有节点都直接指向4。

这种树结构非常扁平,整个树的高度仅为1。这非常理想,因为这意味着即使查找任何一个非根节点,最坏情况下也只需要向上查找一次,就能发现其父节点的值为负数,从而立即找到根节点。

这保证了我们能够获得常数级的运行时间,即 O(1)

目标与后续内容

因此,我们的目标是构建尽可能接近这种理想形态的树,以避免陷入最坏情况下的 O(n) 时间复杂度。

为了实现这个目标,我们将在接下来的视频中研究几种技术:

  • 智能合并(Smart Union):在执行union操作时,采用更智能的策略来保持树的低矮。
  • 路径压缩(Path Compression):在执行find操作时,对路径进行压缩,使得后续的find操作运行得更快。

我们将学习如何通过这些技术来优化Uptree的性能。

总结

本节课中,我们一起学习了Uptree中find算法的时间复杂度。我们了解到其性能与树高 O(H) 相关,最坏情况下会退化为 O(n)。为了获得高效的常数级查找,我们追求构建高度为1的理想扁平树结构。在接下来的课程中,我们将探讨“智能合并”与“路径压缩”这两种关键技术,以实现这一优化目标。

033:智能合并与路径压缩

在本节课中,我们将学习如何优化并查集(Up-Trees)的性能。上一节我们介绍了并查集的基本概念,并了解到其最坏情况(如链表结构)下的性能很差。本节中,我们将探讨两种智能合并策略(按高度合并与按大小合并)以及一种路径压缩技术,它们能显著提升并查集操作的效率。

优化合并策略

回顾并查集的运行时间与树的高度成正比,因此我们的目标是尽可能降低树的高度。观察两种树结构:一种是理想的矮而宽的树,另一种是类似链表的最坏情况结构。我们需要在合并两棵树时,有策略地避免增加整体树高。

在数组实现中,根节点通常用 -1 表示。我们可以利用这个位置存储额外的结构信息,而不是固定的 -1。以下是两种利用树结构信息的智能合并方法。

按高度合并

第一种思路是在根节点存储树的高度信息。这样,在合并时,我们总是将较矮的树作为子树连接到较高的树下,从而避免增加较高树的高度。

具体实现时,根节点存储的值是 -(height + 1)。例如:

  • 高度为 0 的树,根节点值为 -1
  • 高度为 3 的树,根节点值为 -4

以下是按高度合并的示意图:

通过这种方式,合并操作不会增加整个并查集的最大高度,从而将树高控制在较低水平。不过,这种方法的一个缺点是,较大树中的所有节点高度都会因新子树的加入而增加 1。

按大小合并

为了最小化受合并影响的节点数量,我们可以采用第二种策略:按大小合并。此时,根节点存储的值是 -(set_size),即集合中元素总数的负值。

例如:

  • 只有一个元素的集合,根节点值为 -1
  • 包含 4 个元素的集合,根节点值为 -4

合并时,我们将元素数量较少的集合合并到元素数量较多的集合下。这样做的好处是,每次合并时,只有较少集合中的节点高度会增加,受影响的节点总数更少。

以下是按大小合并的示意图:

无论是按高度合并还是按大小合并,这两种算法都能确保并查集树的高度被限制在 O(log n) 级别,使其具备类似二叉树的结构特性。

优化查找操作:路径压缩

尽管智能合并能将树高控制在对数级别,但我们希望性能更优。除了优化合并,我们还可以让查找操作变得更智能,这就是路径压缩技术。

考虑对一个很深的树执行 find(5) 操作。查找路径为:5 -> 4 -> 2 -> 7 -> 9 -> 10(根节点)。

在递归查找并返回根节点的过程中,我们可以顺便更新路径上所有节点的父指针,让它们直接指向根节点。具体步骤如下:

  1. 查找节点 5 的根节点,最终找到 10。
  2. 在递归返回时,将节点 5 的父指针直接改为指向 10。
  3. 同样,将路径上的节点 4、2、7、9 的父指针也直接改为指向 10。

路径压缩后的效果如下图所示,所有中间节点现在都直接连接到根节点,树的高度被极大地扁平化了。

路径压缩的目标是让并查集的结构不断趋近于理想状态:一个根节点下直接连接所有其他节点。这能使得后续的查找操作变得非常快。

总结

本节课中我们一起学习了并查集的两种核心优化技术:

  1. 智能合并:通过按高度或按大小合并,确保合并后树高增长缓慢,将单次操作时间复杂度控制在 O(log n)
  2. 路径压缩:在查找操作中,将路径上的所有节点直接连接到根节点,极大地扁平化树结构,加速后续查找。

这些优化使得并查集成为一种极其高效的数据结构。在接下来的课程中,我们将分析优化后并查集的运行时间复杂度,并探讨如何在图算法中利用并查集构建强大的解决方案。

034:并查集算法的时间复杂度分析

在本节课中,我们将要学习并查集(Disjoint Sets)算法的时间复杂度。我们将重点分析其近乎常数的运行时间,并理解“迭代对数”这一概念。

近乎常数的运行时间

上一节我们介绍了并查集的“智能合并”与“路径压缩”优化。本节中我们来看看这些优化带来的惊人效果。

整个并查集算法最精妙之处在于,其运行时间变得非常出色。当我们合并两个集合时,一旦找到根节点,我们只需要更新一个指针。我们能够以最快的速度找到根节点,因为每次执行 find 操作时,我们都在压缩路径,使其变得越来越短。这使得运行时间几乎达到了常数级别。

迭代对数:log* n

我确实希望能告诉你并查集的操作是常数时间,但这不完全准确。实际上,并查集的运行时间是计算机科学中我们见过的最佳运行时间之一。这个算法的时间复杂度被称为迭代对数

让我们看看什么是迭代对数。迭代对数函数表示为 log n*。这意味着它是 n 的迭代对数。迭代对数函数的定义是:

  • log n = 0*,当 n ≤ 1。
  • log n = 1 + log (log n)**,当 n > 1。

这意味着,迭代对数是你可以对一个数连续取对数的次数。

让我们计算以2为底的 log (2^65536)*。这是一个极其巨大的数字。

  1. log(2^65536) = 65536
  2. log 65536 = 1 + log (log 65536) = 1 + log* 16**。
  3. log 16 = 1 + log (log 16) = 1 + log* 4**。
  4. log 4 = 1 + log (log 4) = 1 + log* 2**。
  5. log 2 = 1 + log (log 2) = 1 + log* 1**。
  6. log 1 = 0*。

因此,log* (2^65536) = 5。这意味着,对于一个天文数字,我们只需要经过5到6次迭代对数计算。

摊还分析与实际应用

这个算法的运行时间增长与你对一个给定数字取对数的次数成正比。2^65536 是一个极其巨大的数字,而它的迭代对数仅为5。这已经非常、非常、非常接近常数时间了。我们不能说它完全是 O(1),因为它确实会随着输入规模增长,但这种增长极其微小,小到几乎可以视为常数。

为了表示整个操作的运行时间,我们知道有些操作会比另一些耗时更长。实际上,在一系列共 Mfindunion 操作后,总操作时间将与 M * log n* 成正比。

因为 log n* 这个值非常小,M * log* n 非常接近 M,所以出于所有实际目的,我们将认为我们算法的摊还平均运行时间O(1) 摊还时间。

因此,当我们在循环中看到使用并查集时,即使我们知道它不是严格的常数时间,我们在算法分析中也会将其视为常数时间运行。因为这种常数时间的假设非常接近现实,迭代对数实在太小了,我们得到的实际上是一个有效运行在常数时间的算法。

我们知道少数操作可能会花费更长的时间,这就是为什么我们说它是摊还常数时间。我们也知道这有一点不精确。但是,当我们在更复杂的算法和数据结构中使用它时,我们可以直接假设这个算法是常数时间的。这将帮助我们真正理解如何利用并查集在未来构建真正出色的算法,例如我们即将在课程中讨论的最后一个主题——图算法。

总结

本节课中我们一起学习了并查集算法的时间复杂度。我们了解到,通过路径压缩和智能合并优化,并查集的操作具有近乎常数的运行时间,其复杂度由迭代对数 log n* 描述。尽管严格来说不是 O(1),但由于 log n* 增长极其缓慢,在实际算法分析中我们通常将其视为摊还常数时间。这为我们在后续学习图算法等高级主题时,高效使用并查集这一强大工具奠定了坚实的基础。

035:图论简介 📊

在本节课中,我们将要学习一种极其强大的数据结构——图。我们将了解图的基本概念,并通过几个生动的例子来展示图如何被用来解决现实世界中的复杂问题。

什么是图?

到目前为止,我们已经学习了多种数据结构,如二叉搜索树、AVL树、数组、栈和队列,以及在这些结构上运行的各种算法。现在,我们将迎来一个终极数据结构,我们将在接下来的四周里深入探讨它。

在深入细节之前,我们先通过几个例子来感受一下图这种数据结构能做什么。

图的实例与应用

以下是几个图在现实世界中的具体应用,它们展示了图如何将复杂的关系网络化。

互联网结构图 🌐

第一个例子是我最喜欢的图之一:2003年的互联网结构图。虽然年代有些久远,但它清晰地展示了图的基本要素。

  • 节点:图中的每一个点代表一台计算机。
  • 聚类:这些计算机根据其物理位置聚集在一起。图中大块的白色集群通常被认为是大学或大型公司。
  • 边与颜色:连接节点的线称为“边”。边的颜色表示其所属区域:绿色代表美洲,蓝色代表欧洲,红色代表亚洲。

所有这些边共同定义了图的连通性,而这个图的连通性正是当时互联网的连通性。虽然如今的互联网规模已庞大到难以用如此简洁的方式可视化,但这个例子完美地展示了图如何描绘一个庞大系统的连接关系。

课程先修关系图 📚

第二个可视化图表是我几年前与学生共同创建的,它描绘了伊利诺伊大学所有课程的先修关系。这种图有时被称为“星座图”。

  • 节点:每个节点代表一门课程。
  • 节点大小:圆圈的大小表示有多少门课程是它的先修课。
  • :如果一门课程是另一门课程的先修课,它们之间就有一条边。
  • 结构与颜色:你会看到,科学、技术、工程和数学领域的课程形成了一个密集的核心集群,课程间有很长的依赖链。外围则是一些小型的“星座”,它们是仅内部相互依赖的课程序列。每个学科通常用独特的颜色标识。

例如,西班牙语102是西班牙语202的先修课,而西班牙语202又是西班牙语302的先修课。这种“先入门,再中级,后高级”的结构,通过节点和边清晰地展现出来,创造了富有意义的美丽图案。

无冲突考试安排图 🗓️

图最有趣的应用之一是解决影响日常生活的实际问题。在伊利诺伊大学,一个重大难题是如何安排期末考试时间,确保没有学生的时间冲突。

这个图解决了无冲突考试安排问题。它的工作原理如下:

  • 节点:每个节点代表伊利诺伊大学的一门课程(例如CS400或西班牙语202)。
  • :如果至少有一名学生同时选修了课程A和课程B,那么节点A和节点B之间就存在一条边。

这意味着,如果两门课程之间有边相连,我们就知道有学生同时选修了这两门课,它们的考试时间不能冲突。反之,如果两门课程之间没有边(例如CS400和农业科学418),则意味着本学期没有学生同时选修这两门课,它们的考试可以安排在同一时间。

这个问题的解决方案是图着色问题:为图中的每个节点分配一种颜色(或形状),确保任何有边相连的两个节点颜色不同。这样,所有颜色相同的节点就可以被安排在同一时间考试,而不会产生冲突。

在上图中,我们使用了大约12种不同的形状来标记节点。这12种形状代表了安排考试所需的12个不同时间段。由于图中任何两个相同形状的节点之间都没有边相连,因此确保了整个大学的考试安排没有任何冲突。

总结

本节课中,我们一起学习了图论的基本介绍。我们看到了图如何通过节点来建模复杂的关系网络,并探讨了它在描绘互联网结构、分析课程依赖关系以及解决无冲突考试安排等实际问题中的强大应用。图着色问题是一个经典的难题,它展示了图论在优化和调度领域的价值。在接下来的课程中,我们将开始学习如何具体实现和操作图这一数据结构。

036:图论术语 📚

在本节课中,我们将学习图论中的基本术语。理解这些术语是后续学习图算法和数据结构的基础。我们将介绍图的基本组成部分、相关概念,并通过一些简单的数学计算来加深理解。


图的基本构成

在开始实际实现图之前,我们先来了解一些关于图的词汇,以便我们对讨论的内容有共同的理解。

观察一个示例图,这里有一系列图。我将始终用大写字母 G 来指代整个大图。G 是顶点(vertices)和边(edges)的集合。

在这个图 G 内部,我们有三个子图:G1G2G3。这些图是不连通的,因为 G1G2 之间没有共享的边。但 G1 本身是一个连通图,因为 G1 内部的每个节点都可以通过各种边连接起来。

我将交替使用术语“节点”和“顶点”。当我说节点或顶点时,你可以将它们视为同一概念。如果你有数学背景,可能更习惯“顶点”一词;如果你有网络背景,可能更习惯“节点”一词。另一方面,“边”这个术语是普遍使用的。所以,每当你听到“边”,它总是指两个节点或两个顶点之间的连接。

我们将定义变量 n 等于图中顶点的数量。所以,如果我们计算顶点的数量,它将被定义为 n。如果我们计算边的数量,它将被定义为变量 m。因此,nm 是我们将经常讨论的两个术语。


节点与边的相关概念

以下是关于图中节点和边的一些重要概念。

邻接边:如果一条边直接连接到一个节点,那么这条边就是该节点的邻接边。所以,一个节点上的所有边都是它的邻接边。

节点的度:节点的度是它拥有的邻接边的数量。例如,观察这个节点有多少条边,如果它有三条邻接边,我们就说这个节点的度是

邻接顶点:邻接顶点是指所有通过一条边直接连接到某个节点的顶点。如果我们遍历一个节点的所有邻接边,并查看连接到它的节点,那些就是邻接节点或邻接顶点。

因此,一个节点有一定数量的邻接边,这个数量就是该节点的度,而这些边另一端的节点就是邻接节点列表。


路径、环与简单图

我们也可以使用一些你可能在树结构中见过的术语。

路径:路径是图中一系列顶点的序列。

:环是一条起点和终点是同一个节点的路径。如果我们遍历一系列节点,最终回到起点,这条路径就是一个环,我们称之为图中的环。

在本课程中,我们将主要讨论简单图。简单图是指没有自环的图,这意味着没有边连接回节点自身,因为这在遍历图时会导致一些问题。同时,简单图也没有多重边,即连接两个相同顶点的边只有一条。如果顶点 A 和顶点 B 之间有连接,那么恰好只有一条边连接它们。


子图与其他术语

我们已经讨论过子图这个术语,即图的一个子集。任何子图都包含该特定子图中的所有顶点和所有边。正如前面提到的,这里有三个子图。

最后,我们还有一些其他术语,将在后续课程中遇到时介绍。这些术语包括完全子图连通子图连通分量无环图以及生成树。我们将在讲解图的过程中深入探讨所有这些术语。

这些词汇只是为了确保我们在讨论将要学习的图算法时,能够保持一致的理解。


图的基本数学性质

为了开始讨论,让我们做一些简单的数学计算,看看仅从已介绍的术语中,我们能了解图的哪些性质。

其中一部分是关于图意味着什么的一些简单问题。

一个图最多可以有多少条边?

我们假设在大多数情况下,我们的图总是简单图。这意味着没有自环,也没有多重边。如果我们开始画图,可以看到:

  • n = 1 时,只有一个节点,边数为 0
  • n = 2 时,有两个节点,边数为 1
  • n = 3 时,有三个节点,边数为 3
  • n = 4 时,有四个节点,边数为 6

这看起来像一个漂亮的序列。我们可以将其定义为:
n * (n - 1) / 2
这等于 O(n²) 条边。对于每个节点,它都将连接到其他每个节点。所以基本上是 n 个节点,每个节点连接到其他 n-1 个节点。但我们知道只有一半的边可以存在,因为你不会同时有从 A 到 B 和从 B 到 A 的边(在无向图中,这是一条边)。因此,我们将总数除以 2。这正是我们发现的:n * (n - 1) / 2 是一个连通图中的边数。

如果图不是简单图,允许存在多重边,那么在连接完所有顶点之前,我们可以有无限多条边。想象一个只有两个节点的图,如果不是简单图,我们可以有任意多条多重边。因此,在一个包含多重边的非简单图中,可以存在无限多条边。因此,本学期我们将始终将自己限制在简单图中。在未来的课程中,你可以深入研究当我们开始有多重边时的具体含义。

不连通图的最小边数是多少?

如果图是不连通的,我们知道根本不需要存在任何边。一个不连通图可以是图 A、B、C,它们都是没有连接的独立子图。因此,不连通图的最小边数是 0

连通图的最小边数是多少?

如果我们连接这个图,那么我们可以看到每个节点都必须连接到其他每个节点。因此,最小连通图将是一个图,其中我们有一条从一个节点到图中其他每个节点的路径,并且只有一条路径可以到达那里。这里是一个最小连通图,你会注意到我们需要的边数恰好比节点数少一。因此,连通图中的最小边数是总节点数 - 1

所有节点的度之和是多少?

如果我们考虑一个顶点的度,我们知道那是它有多少条边。如果我们把它们全部加起来,我们知道每条出边最终都会有一条入边。因此,所有顶点的度之和将等于 2m,即图中边数的两倍。因为在这里我们重复计算了边:一条边在 A 处被计为出边,在 B 处被计为入边。尽管入边和出边实际上是同一条边(即 A 和 B 之间的边),但 A 的度计算了这条出边,B 的度计算了这条入边。


总结

本节课中,我们一起学习了图论的基本术语。我们定义了图(G)顶点/节点(n)边(m)邻接边节点的度邻接顶点路径以及简单图子图等核心概念。我们还通过数学计算探讨了图中边数的上限(n*(n-1)/2)、连通图的最小边数(n-1)以及所有节点度之和(2m)等重要性质。理解这些术语和性质是后续学习图数据结构和算法的基础。在下一讲中,我们将开始用 C++ 实现图,并对图进行一些有趣的操作。

037:图论-边表实现 📊

在本节课中,我们将学习如何实现一个图数据结构。我们将从定义图的抽象数据类型开始,然后深入探讨一种称为“边表”的具体实现方法,并分析其各项操作的性能。

图的抽象数据类型

在开始实现图之前,我们首先需要讨论图的抽象数据类型,并理解我们需要构建哪些功能,以便创建一个实用的类。

为了实现图,我们将存储一系列向量(或列表),即一系列边,以及维护这些顶点和边之间关系的某种数据结构。为此,我们将在图上定义至少八个不同的函数:

  1. 插入顶点:给定一个键值,能够插入一个顶点。
  2. 插入边:在两个顶点之间插入一条边,并可选择关联一些数据。
  3. 移除顶点:移除之前插入的顶点。
  4. 移除边:移除一条边。
  5. 获取关联边:给定一个顶点,询问图中与其关联的所有边。
  6. 检查邻接性:检查两个顶点是否相邻,即它们之间是否存在边。
  7. 获取边的起点:如果图是有向的(例如A指向B,但B不指向A),需要知道一条边的起点。
  8. 获取边的终点:如果图是有向的,需要知道一条边的终点。

这是我们研究不同图实现时将参考的基本抽象数据类型。

边表实现

我们将要看的第一个实现被称为边表实现。这是一种较为朴素的实现方式,能让我们快速构建一个图。在理解其工作原理后,我们将讨论如何改进这种实现。

在边表实现中,我们只需维护一个顶点列表和一个边列表,它们可以存储在向量或哈希表等数据结构中。

考虑一个简单的图,包含顶点 U、V、W 和 Z。在我们的顶点列表中,我们有顶点 U、V、W 和 Z。在我们的边列表中,我们简单地维护一个边的列表,并将它们所连接的顶点作为边的一部分存储。

例如:

  • 边 A 连接顶点 U 和 V。
  • 边 B 连接顶点 V 和 W。
  • 边 C 连接顶点 U 和 W。
  • 边 D 连接顶点 W 和 Z。

边表上的操作

现在我们已经理解了边表的含义,接下来可以看看在这种结构上可能执行的操作。

以下是边表实现中几个关键操作的性能分析:

插入顶点

插入一个顶点非常简单。如果想添加一个名为 K 的新顶点,只需将其添加到数组或哈希表的末尾即可。这是一个 O(1) 操作(如果使用向量,考虑到可能的扩容,可以说是均摊 O(1) 时间)。

移除顶点

移除顶点操作将从列表中移除一个顶点。如果使用哈希表来实现顶点列表,这也可以在 O(1) 时间内完成。

检查邻接性

检查两个节点是否相邻则稍微复杂一些。给定顶点1和顶点2,检查它们是否相邻需要我们遍历整个边列表。我们必须查看列表中的每一个元素,判断这两个顶点是否通过某条边相连。

例如,检查 W 和 Z 是否相邻。我们需要遍历边 A、B、C,直到边 D 才发现它们相连。因此,这是一个 O(M) 的算法,其中 M 是图中的边数。我们必须查看每一条边才能确定两个顶点是否相邻。

获取关联边

同样地,当我们想要计算一个顶点的所有关联边时,也需要遍历整个边列表。为了获取与给定顶点关联的所有边的集合,这也是一个 O(M) 的操作,因为我们不知道哪些边关联到该顶点。

性能总结

以上是你在图上会看到的四个关键操作。在边表实现中:

  • 插入和移除顶点可以在常数时间内完成。
  • 但是,当你需要查询图的属性或连接关系(如检查邻接性或获取关联边)时,其运行时间与图中的总边数 M 成正比。

在边数非常多的大型图中,这可能会变得非常低效,可能不是理想的选择。不过在某些情况下,边表实现是合适的。

在下一节中,我们将评估这种实现与其他实现方式的优劣,并深入探讨更多的图实现方法。

038:邻接矩阵实现图论

概述

在本节课中,我们将学习图的另一种实现方式:邻接矩阵。我们将探讨其数据结构、核心操作的时间复杂度,并理解它与之前学习的边列表实现之间的权衡。


回顾与引入

上一节我们介绍了使用边列表(Edge List)实现图的方法。本节中,我们将看看一种完全不同的实现方式——邻接矩阵(Adjacency Matrix)。

邻接矩阵数据结构

邻接矩阵实现图时,主要维护两个核心数据结构。

以下是其组成部分:

  1. 顶点列表(Vertex List):一个存储图中所有顶点的数据结构。通常使用哈希表实现,以保证 O(1) 的访问时间。例如,对于包含顶点 U、V、W、Z 的图,顶点列表将存储它们。
  2. 邻接矩阵(Adjacency Matrix):一个二维矩阵(或表格),用于表示顶点之间的连接关系。

邻接矩阵的工作原理

我们用一个简单的无向图来演示。假设图中有顶点 U、V、W、Z,以及连接它们的边 A(U-V)、B(V-W)、C(U-W)、D(W-Z)。

在邻接矩阵中:

  • 如果两个顶点之间存在边,则对应单元格存储 True1
  • 如果两个顶点之间没有边,则对应单元格存储 False0

对于无向图,矩阵关于主对角线对称,因此通常只需存储上三角部分。

初始矩阵可能如下所示(0 表示无连接,1 表示有连接):

U V W Z
U 0 1 1 0
V 0 1 0
W 0 1
Z 0

优化:链接到边对象

我们可以做得比存储 0 和 1 更好。邻接矩阵的单元格可以直接存储指向对应边对象的指针或引用,该边对象包含了边的具体信息(如权重)。

优化后的数据结构如下:

  • 单元格 [U][V] 指向边列表中的边 A 对象。
  • 单元格 [U][W] 指向边 C 对象。
  • 单元格 [V][W] 指向边 B 对象。
  • 单元格 [W][Z] 指向边 D 对象。
  • 其他单元格为 null

这样,邻接矩阵不仅告诉我们顶点是否相连,还能直接获取边的详细信息。

核心操作的时间复杂度分析

使用邻接矩阵,图的各种操作效率发生了变化。

以下是主要操作的时间复杂度:

  1. 插入顶点(Insert Vertex):需要向顶点列表添加顶点(O(1)),但必须在矩阵中增加一行和一列,并初始化所有新单元格(通常为 null)。这需要与当前顶点数 n 成比例的时间,因此时间复杂度为 O(n)
  2. 删除顶点(Remove Vertex):与插入类似,需要从矩阵中移除一行和一列,并可能压缩矩阵,时间复杂度也是 O(n)
  3. 判断顶点是否相邻(Are Adjacent):这是邻接矩阵的最大优势。只需通过两个顶点的索引在矩阵中查找对应单元格。这是一个数组访问操作,时间复杂度为 O(1)
  4. 获取顶点的关联边(Incident Edges):要找到与某个顶点(例如 V)关联的所有边,需要检查该顶点对应的整行和整列(对于无向图)。这需要检查大约 2n 个单元格,因此时间复杂度为 O(n)

总结与权衡

本节课我们一起学习了图的邻接矩阵实现。

邻接矩阵的核心优势在于能以 O(1) 的常数时间快速判断任意两个顶点是否相邻。其代价是插入和删除顶点操作较慢,需要 O(n) 的时间来调整矩阵大小。

这体现了算法设计中常见的权衡(Trade-off):我们通过牺牲一部分操作(增删顶点)的性能,换来了另一部分操作(查询邻接关系)的极致优化。根据应用程序最频繁的操作,我们可以选择最合适的图实现方式。

在下一节中,我们将探讨图的第三种实现方式,它将带来另一组不同的性能权衡。之后,我们将综合分析这三种实现方式。

039:图论-邻接表实现

在本节课中,我们将要学习图的第三种,也是最后一种实现方式:邻接表。这种实现方式结合了前两种实现(邻接矩阵和边列表)的一些特点,形成了一种具有独特性质的新结构。

概述

上一节我们介绍了邻接矩阵,本节中我们来看看邻接表实现。这种结构通过维护一个顶点列表,并为每个顶点关联一个链表来存储其所有邻接边,从而在空间和某些操作的时间效率上取得了平衡。

邻接表结构解析

邻接表的核心思想是:我们有一个顶点列表(通常用哈希表实现,以实现O(1)的访问时间),同时,对于顶点列表中的每一个顶点,我们维护一个链表,用于存储所有与该顶点相连的边。

具体来说,对于顶点 U,它的链表会包含指向边 E_A 和边 E_C 的指针。这里的边节点不仅存储了边的信息,还存储了指回顶点在邻接表中位置的指针。

因此,我们得到了一个指针网络:从顶点列表中的链表节点指向边列表,边列表中的节点又指回顶点邻接链表中的对应元素。这使得顶点列表和边列表之间通过指针相互连接。

操作时间复杂度分析

以下是邻接表支持的各种图操作及其时间复杂度:

  • 插入顶点:只需在顶点列表中添加新顶点,其初始邻接链表为空。时间复杂度为 O(1)(摊销时间)。
  • 删除顶点:删除顶点 W 时,需要遍历 W 的所有邻接边(即其度数),并清理相关的链表节点和边节点。时间复杂度为 O(deg(W))
  • 插入边:在边列表中添加新边,并更新两个相关顶点的邻接链表。时间复杂度为 O(1)
  • 删除边:从边列表中删除边,并更新两个相关顶点的邻接链表。时间复杂度为 O(1)
  • 查询邻接边:要获取顶点 V 的所有邻接边,只需遍历其邻接链表。时间复杂度为 O(deg(V))
  • 查询两顶点是否邻接:要判断顶点 UW 是否邻接,需要遍历 UW 中较小的那个邻接链表。时间复杂度为 O(min(deg(U), deg(W)))

三种实现方式对比

为了全面理解,我们将邻接表与之前学过的边列表、邻接矩阵进行对比。

操作 / 属性 边列表 邻接矩阵 邻接表
所需空间 O(n + m) O(n²) O(n + m) ✅
插入顶点 O(1) ✅ O(n) O(1) ✅
删除顶点及关联边 O(m) O(n²) O(deg(v)) ✅
插入边 O(1) ✅ O(1) ✅ O(1) ✅
删除边 O(1) ✅ O(1) ✅ O(1) ✅
查询顶点的所有边 O(m) O(n) O(deg(v)) ✅
查询两顶点是否邻接 O(m) O(1) ✅ O(min(deg(u), deg(v)))

(注:n 代表顶点数,m 代表边数,deg(v) 代表顶点 v 的度数)

从上表可以看出:

  • 邻接矩阵查询两顶点是否邻接的操作上具有无可比拟的 O(1) 优势。
  • 邻接表空间效率插入/删除顶点以及查询顶点的所有边等操作上通常表现更优。
  • 边列表插入顶点和边方面也很高效,但在查询类操作上代价较高。

总结

本节课中我们一起学习了图的邻接表实现。我们分析了它的数据结构、各种图操作的时间复杂度,并将其与边列表、邻接矩阵进行了全面对比。

关键结论是:没有一种图实现方式在所有应用场景下都是最优的。邻接矩阵适合需要频繁进行邻接关系查询的场景;邻接表在需要高效管理顶点和查询邻接边的场景中表现突出;边列表则在简单添加操作的场景中占优。

在后续课程中,当我们开始探讨图的各种具体应用(如路径查找、网络流等)时,我们将根据具体需求来选择最合适的图实现方式。

040:图论-广度优先搜索遍历

概述

在本节课中,我们将要学习图论中的一种基础遍历算法——广度优先搜索。我们将了解其工作原理、与树遍历的异同,并通过一个具体的例子来演示其执行过程。

图遍历的挑战

上一节我们介绍了如何在C++中实现图数据结构。本节中,我们来看看如何对图进行遍历。遍历是我们已经接触过的概念,例如在树结构中。现在,我们将相同的遍历思想应用到图上,会发现两者有相似之处,但也存在显著差异。

与树遍历类似,图遍历也需要访问图中的每一个节点恰好一次。然而,图结构带来了一些树结构所没有的问题:

以下是图遍历面临的几个主要挑战:

  • 无序性:图中的节点没有像树那样的固有顺序(例如左子树、右子树)。
  • 无明确起点:图中没有像树的根节点那样明显的遍历起点。
  • 无内置的完成概念:图可能包含环,因此我们需要维护一个数据结构来确保不会重复访问节点。

广度优先搜索原理

我们要讨论的第一种遍历方法是广度优先搜索。这种遍历的思想与在树中进行广度优先搜索完全相同:在访问任何“孙子”节点之前,先访问所有的“孩子”节点。这意味着,我们将先访问一个节点的所有邻接节点,然后再去访问这些邻接节点的邻接节点。

为了实现这一过程,我们将使用一个队列数据结构来帮助我们组织图的遍历顺序。

广度优先搜索示例

让我们通过一个具体的例子来理解这个过程。假设我们有一个包含节点A到H的图,其邻接关系如下:

  • A: B, C, D
  • B: A, C, E
  • C: A, B, D, E, F
  • D: A, C, F, H
  • E: B, C, G
  • F: C, D, G
  • G: E, F, H
  • H: D, G

我们选择节点A作为起点(起点可以是任意节点)。以下是遍历步骤:

以下是广度优先搜索的逐步过程:

  1. 将起点A加入队列。队列状态:[A]
  2. 从队列前端取出A,标记A为“已访问”。将A未被访问过的邻接节点B、C、D加入队列末尾。队列状态:[B, C, D]
  3. 取出B,标记为已访问。将B的未访问邻接节点E加入队列。队列状态:[C, D, E]
  4. 取出C,标记为已访问。将C的未访问邻接节点F加入队列。队列状态:[D, E, F]
  5. 取出D,标记为已访问。将D的未访问邻接节点H加入队列。队列状态:[E, F, H]
  6. 取出E,标记为已访问。将E的未访问邻接节点G加入队列。队列状态:[F, H, G]
  7. 取出F,标记为已访问。F的邻接节点C、D、G均已访问或已在队列中,无新节点加入。队列状态:[H, G]
  8. 取出H,标记为已访问。队列状态:[G]
  9. 取出G,标记为已访问。队列为空,遍历结束。

最终访问顺序为:A, B, C, D, E, F, H, G。可以看到,所有与A直接相邻的节点(B, C, D)都在距离A两步的节点(E, F, H)之前被访问,而距离三步的节点(G)最后被访问。这体现了“广度优先”的特性。

边的分类与代码结构

在广度优先搜索遍历中,我们可以对边进行有趣的分类:

  • 发现边:当通过一条边首次访问到一个新节点时,这条边称为发现边。在示例中,边A-B、A-C、A-D、B-E、C-F、D-H、E-G都是发现边。它们在代码或图示中通常被加粗或高亮。
  • 交叉边:如果一条边连接的两个节点都已被发现(例如,一个节点已被其他边访问过),那么这条边称为交叉边。

在算法开始时,所有节点和边都被标记为“未探索”。遍历过程中,每条边会被标记为“发现边”或“交叉边”。相关的示例代码已随课程提供,你可以通过代码仓库查看具体实现。

总结

本节课中我们一起学习了图论中的广度优先搜索遍历算法。我们了解了它通过队列实现、按“层”访问节点的特性,并认识了发现边与交叉边的概念。下一节,我们将探讨由BFS遍历产生的这个子结构(主要由发现边构成)能告诉我们关于图整体结构的哪些信息。

041:图论-广度优先搜索分析

概述

在本节课中,我们将对上一节实现的广度优先搜索算法进行分析。我们将探讨该算法如何检测图中的连通分量和环,分析其时间复杂度,并理解由算法生成的“发现边”所构成的结构特性。


连通分量的检测

上一节我们介绍了BFS算法的实现,本节中我们来看看该算法如何识别图中的不同连通分量。

观察代码第10至12行,算法初始化时将所有节点标记为“未探索”。随后,一个循环会遍历所有节点。如果某个节点仍处于“未探索”状态,就对其启动一次BFS遍历。这意味着,对于一个包含多个连通分量的图,算法会为每个未被访问过的分量启动一次独立的BFS。因此,我们可以通过统计BFS被调用的次数来计算连通分量的数量。

以下是检测连通分量的核心逻辑:

int componentCount = 0;
for (int i = 0; i < numVertices; i++) {
    if (explored[i] == false) {
        BFS(i); // 对未探索的节点启动BFS
        componentCount++; // 每次调用BFS,意味着发现一个新分量
    }
}

核心概念:通过遍历所有节点并为每个“未探索”节点启动BFS,算法可以识别并计数所有连通分量。


环的检测

接下来,我们探讨BFS算法如何检测图中是否存在环。

在树结构中,所有边都是“发现边”,用于探索新节点。如果在遍历过程中,我们遇到一条连接同一层级或已访问节点的边(即“交叉边”),则意味着图中存在环。因为这条边提供了返回已访问节点的路径,从而形成了环。

在代码中,当处理邻接节点时,如果发现该节点已被探索且不是当前节点的直接前驱(即不是“发现边”),那么这条边就是交叉边,表明存在环。

以下是检测环的逻辑更新:

// 在BFS内部,当检查边(u, v)时
if (explored[v] == true) {
    // 如果v已被探索,且v不是u的前驱节点,则(u, v)是交叉边,表明有环
    if (v != predecessor[u]) {
        cycleFound = true; // 标记检测到环
    }
}

核心概念:在BFS遍历中,交叉边的存在等价于图中存在


时间复杂度分析

现在我们来分析BFS算法的运行效率。分析将聚焦于两个主要循环。

第一个是位于第19行的while循环。只要队列不为空,它就会继续执行。由于每个节点在首次被发现时都会入队一次,因此这个循环总共会运行 n 次(n为顶点数)。

第二个是位于第21行的for循环。它遍历当前节点的所有邻接节点。对于整个图而言,所有节点的邻接列表遍历加起来,每条边会被访问两次(一次从u到v,一次从v到u)。因此,这部分的总操作次数是 2m(m为边数)。

综合起来,算法的总操作次数为 n + 2m。在渐进时间复杂度表示中,我们忽略常数,得到 O(n + m)

公式T(n, m) = O(n + m)

  • 在稀疏图(m ≈ n)中,时间复杂度接近 O(n)
  • 在稠密图(m ≈ n²)中,时间复杂度为 O(n²)

BFS生成树的性质

由BFS算法得到的“发现边”构成了一棵生成树。我们来探讨这棵树的一些关键性质。

1. 最短路径
从BFS的起点到任何其他节点的最短路径,恰好由生成树中连接两者的“发现边”路径给出。例如,从起点A到H的最短路径就是树中的路径 A -> D -> H。

2. 路径查询的局限性
BFS只能给出从起始节点出发到其他所有节点的最短路径。如果想知道图中任意两点(如E到H)的最短路径,必须将E作为起点重新运行BFS,或者使用其他算法(如Dijkstra算法)。

3. 交叉边与距离的关系
交叉边连接的是距离起点深度相同或相差1的节点。这意味着,沿着交叉边移动,不会显著增加你与起点的距离,你始终停留在起点周围“相近”的层级中。

4. 生成树
BFS“发现边”构成的树是一种特殊的生成树。它“跨越”了图中的所有顶点,意味着通过这棵树中的边,可以从根节点访问到图中的每一个顶点。


总结

本节课中我们一起学习了BFS算法的深入分析。

  1. 连通分量:算法通过遍历所有节点并为未探索节点启动BFS,可以识别和计数图中的连通分量。
  2. 环检测:通过识别“交叉边”,BFS算法能够判断图中是否存在环。
  3. 时间复杂度:BFS的运行时间为 O(n + m),其效率取决于顶点数和边数。
  4. 生成树性质:BFS生成一棵生成树,它给出了从起点到所有节点的最短路径,但该路径信息仅限于这个特定的起点。

在接下来的课程中,我们将更深入地探讨遍历算法和生成树的其他性质。

042:图论-深度优先搜索遍历

概述

在本节课中,我们将要学习图论中的另一种重要遍历算法——深度优先搜索。我们将了解其工作原理、与广度优先搜索的区别,并分析其时间复杂度。

从广度优先到深度优先

上一节我们介绍了广度优先搜索遍历。本节中,我们来看看深度优先搜索遍历。在广度优先遍历中,我们首先访问所有相邻节点,然后再访问它们相邻的节点。而在深度优先搜索遍历中,我们希望尽可能快地深入到图的深处。

在BFS中,我们使用队列结构来管理待访问节点。在DFS中,我们将使用结构。由于C++代码调用时会使用调用栈,我们可以利用递归轻松实现DFS,而无需显式地管理栈结构。因此,这个算法会非常容易理解。

深度优先搜索遍历过程

让我们从节点A开始,像之前一样遍历这个图。

  1. 从节点A开始。我们打算访问A周围的所有节点。当我们穿过一条边时,需要判断:我之前见过这条边吗?我访问过D吗?没有。所以这是一条发现边。现在我们从A移动到了D。
  2. 在D节点,我们做同样的事情。从D开始,寻找新的边。我们发现了一条新边,它通向新的节点,所以我们继续深入,进行深度优先遍历。我们移动到H。
  3. 在H节点,重复此过程。H通向G,我们在G发现了一个新的顶点。我们继续做同样的事情,然后前进到J。
  4. 在J节点,我们进行同样的操作。查看J的所有可能访问的边,我们发现有一条从J到K的边,这让我们发现了一个新节点K。
  5. 最后在K节点,我们重复这个过程。在K,我们查看其边。这里有一条边通向A。但A已经被发现了。所以我们不将其标记为新的发现边,而是将K到A的边标记为后向边。它指向了我们遍历过程中更早的节点。就像BFS中的交叉边一样,在DFS中,后向边是非发现边。我们用点来标记它。
  6. 继续查看K的其他边,我们发现K到E是一条发现边,我们发现了关于E的新信息。
  7. 现在在E节点,我们继续。E到D是一条后向边,因为我们已经见过节点D了,所以这不是一条新的发现边。
  8. 完成E的所有边访问后,我们回溯到K。完成K后,回溯到J,然后回溯到G。
  9. 回到G后,我们需要继续我们的遍历。我们发现一条新边通向C。C是一条发现边。C有一条后向边指向D(D已被发现),以及一条发现边指向B。
  10. 最后,当访问G时,F被发现,并且F有一条后向边指向D。

注意,我们在整个算法中从未考虑过显式的栈数据结构。递归算法中的调用栈形成了一个隐式栈。通过非常简单的代码,我们就能构建出整个遍历过程。你可以看到,这个算法与BFS算法有相同的根源,只是访问节点的顺序不同。

代码实现:从BFS到DFS

以下是BFS的代码框架。要将其转换为DFS,我只需要移除所有与队列相关的部分,就能得到一个完美的调用栈实现。

具体修改如下:

  • 移除所有与队列 Q 相关的代码行。
  • 将处理交叉边的逻辑改为处理发现边。
  • 确保不再将节点入队,而是递归调用深度优先搜索函数来处理新节点。

通过进行这些全局修改,我可以通过简单地移除几行与队列相关的代码、改为调用DFS、并将边标记为后向边,就将BFS算法修改为DFS算法。

现在我们有了进行DFS遍历的代码。其方式与BFS遍历完全相同,但我们在过程中非常早地访问了深处的节点。

时间复杂度分析

之前我们通过分析代码来理解其工作原理。这里,我想用一种不同的分析形式来看看BFS和DFS算法的运行时间。你会发现这种分析对两种算法都适用,并且是计算相同运行时间的另一种方式。

我们知道,在此类数据结构上执行的唯一操作将涉及标记我们的节点。每当我们执行任何操作时,总是会进行标记。因此,让我们计算在标记所有顶点以及查询它们以找出下一个顶点时,进行了多少次标记。

  1. 标记顶点:每个顶点最初都是“未发现”状态,然后在某个时间点被标记为“已发现”。因此,我们总共进行 2n 次标记,即 O(n)
  2. 标记边:每条边最初也是“未发现”状态,然后要么被标记为“发现边”,要么被标记为“后向边”。因此,我们进行 2m 次标记,即 O(m)
  3. 查询操作(访问顶点):我们知道会访问每个顶点恰好一次,所以这是 O(n)
  4. 查询操作(检查边):查看单个顶点时,我们会访问连接到它的所有边。对于一个顶点 v,这最多是 degree(v) 次。由于我们会访问每个顶点,所以总次数是 sum(degree(v))。所有顶点度数之和等于 2m,即 O(m)

综上所述,总运行时间为 O(n) + O(m) + O(n) + O(m) = O(n + m)

这与进行BFS遍历的运行时间完全相同。通过两种不同的分析,我们得到了两种不同的遍历形式(BFS和DFS),它们都将在 O(n + m) 时间内运行。你会注意到,这是运行此算法的最优时间,因为我们必须恰好访问每个节点一次,也必须恰好访问每条边一次。所以,我们不可能做得比 O(n + m) 更好。因此,BFS和DFS遍历都是图的最优遍历算法。

总结

本节课中,我们一起学习了深度优先搜索遍历。我们了解了DFS如何通过递归(隐式栈)深入探索图,区分了发现边后向边,并看到了如何轻松地将BFS代码修改为DFS代码。最重要的是,我们分析了DFS的时间复杂度为 O(n + m),这与BFS相同,并且都是图遍历的最优时间复杂度。DFS和BFS都能为我们提供关于图子结构(如BFS中讨论的生成树)的有趣信息。

接下来,我们将深入探讨如何利用这些遍历进行更有趣的数据结构操作,例如找出图中的生成树和环。我们将在下一个视频中详细讨论生成树。

043:最小生成树(MST)简介 🌲

在本节课中,我们将要学习最小生成树的基本概念。我们将了解什么是生成树,以及为什么需要寻找“最小”的那一棵。这是图论中一个非常实用的主题,常用于网络设计、电路连接等场景。


在过去的几周里,我们介绍了图的概念,并深入探讨了如何使用广度优先搜索或深度优先搜索遍历来对图进行简单的遍历。

在BFS遍历中,你可能会注意到我们创建了一棵生成树,它能够覆盖图中的所有顶点。

本周,我们将重点讨论如何创建一棵最小生成树

我们希望找到一棵能够覆盖图中所有顶点的树,并且这棵树所有边的总权重在所有可能的生成树中是最小的。

让我们具体看看这意味着什么。


问题定义

输入通常是一个无向图 G,图中的边带有权重。这些权重可以是任意值,但必须能够相加。我们通常使用整数,但在某些应用中,权重可能是更复杂的、但同样可以相加的数值。

给定这个带权重的图 G,我们希望创建一个新的图 G‘,它需要满足以下条件:

  1. G’ 必须是原图 G 的一棵生成树
  2. G‘ 必须是一棵,这意味着它不能包含环,并且需要恰好访问每个节点一次。
  3. G’ 在所有可能的生成树中,其总边权之和必须最小

你可以想象,连接所有节点的路径有很多种画法,但我们想知道如何以总代价最小的方式画出一棵连接所有节点的树。


算法简介

有多种算法可以用来解决这个问题。在本课程中,我们将讨论其中两种经典算法:

  • Kruskal算法
  • Prim算法

这两种算法将采用不同的策略,在给定一系列边权重的情况下,构建出能够覆盖整个图的最小可能树。


应用场景

最小生成树是一个非常重要的应用。每当我们需要了解一个系统的连通性,或者寻找在两个不同地点之间进行连接或旅行的最低成本方案时,都会用到它。例如:

  • 设计通信网络(如电话线、光纤)
  • 规划交通路线
  • 电路板布线

在下一节视频中,我们将开始深入探讨第一种算法——Kruskal算法,并学习它如何在任意给定的图上构建最小生成树。


本节课中,我们一起学习了最小生成树的基本定义和目标。我们了解到,最小生成树是一棵连接图中所有顶点且总权重最小的树,它在网络设计和路径规划中有着广泛的应用。接下来,我们将学习具体的构建算法。

044:最小生成树之克鲁斯卡尔算法 I 🧩

在本节课中,我们将要学习寻找图的最小生成树(MST)的两种主要算法之一:克鲁斯卡尔算法。我们将了解该算法的工作原理,并通过一个简单的图例来演示它如何逐步构建出最小生成树。


算法概述与准备

克鲁斯卡尔算法的运行需要依赖两种你已经熟悉的数据结构。让我们先明确运行该算法所需的条件,然后通过一个简单的图例来演示其构建最小生成树的过程。

我提供了一个简单的图,其中包含连接各个节点的边,每条边都附有一个权重,代表通过这条边的“成本”。你可以将其想象成一个道路网络,权重就是每段道路的里程数。整个算法的核心在于找出整个图中成本最低的连接方式。

为此,我们需要维护一个在本课程早期学过的数据结构:最小堆。作为算法的一部分,我们将利用最小堆来组织所有边,确保权重最小的边始终位于堆顶。

除了最小堆,我们还需要引入本课程中讨论过的不相交集合的概念。在算法开始时,图中的每个顶点都将被视为一个独立的不相交集合。我们使用向上树来表示这些不相交集合。


算法运行步骤

以下是克鲁斯卡尔算法的具体运行步骤。我们将从一个包含所有边的最小堆,以及每个顶点作为独立集合的不相交集合结构开始。

算法的核心流程是:始终从堆中移除权重最小的边,然后检查这条边连接的两个顶点是否已经属于同一个集合。

  • 如果它们已经在同一个集合中,则忽略这条边。
  • 如果它们属于不同的集合,则将这两个集合合并。

让我们通过图示来一步步执行这个算法。

  1. 处理边 A-D (权重 2): A 和 D 目前在不同的集合中,因此合并包含 A 和 D 的集合。
  2. 处理边 E-H (权重 2): E 和 H 在不同的集合中,合并它们。
  3. 处理边 F-G (权重 4): F 和 G 在不同的集合中,合并它们。
  4. 处理边 A-B (权重 5): A 和 B 在不同的集合中,合并它们(此时 A、B、D 在同一集合)。
  5. 处理边 B-D (权重 6): B 和 D 现在都在以 A 为根的集合中(通过向上指针查找),因此它们已在同一集合。忽略此边,不将其加入最小生成树。
  6. 处理边 G-E (权重 7): G 和 E 在不同的集合中,合并包含 G 的集合和包含 E 的集合。
  7. 处理边 G-H (权重 8): G 和 H 现在都在以 E 为根的集合中,已在同一集合。忽略此边
  8. 处理边 E-C (权重 9): E 和 C 在不同的集合中,合并它们。
  9. 处理边 C-H (权重 10): C 和 H 现在都在以 E 为根的集合中,已在同一集合。忽略此边
  10. 处理边 E-F (权重 11): E 和 F 已在同一集合。忽略此边
  11. 处理边 F-C (权重 12): F 和 C 已在同一集合。忽略此边
  12. 处理边 D-E (权重 15): 包含 A、B、D 的集合与包含 C、E、F、G、H 的集合不同,合并这两个大集合。

至此,所有顶点都连通到了同一个不相交集合中,算法结束。


生成树可视化

现在,让我们将算法选中的边在图上加粗,直观地看到最终的最小生成树。

被加入的边依次是:

  • A-D
  • E-H
  • F-G
  • A-B
  • G-E
  • E-C
  • D-E

请注意,在加入最后一条边 D-E 之前,图被分成了两个连通部分。加入 D-E 后,整个图才完全连通,形成了一棵生成树。在这棵树上,从任意一个节点出发,都可以访问到图中的所有其他节点。

这是一个非常棒的特性,克鲁斯卡尔算法不仅保证我们能得到一棵生成树,而且保证这是整个图所有可能的生成树中总权重最小的那一棵。你无法找到另一棵总权重比这更小的生成树。这个结果非常强大,它确保了我们获得的是连接所有节点的成本最低的路径方案。


总结与预告

本节课中,我们一起学习了克鲁斯卡尔算法的基本原理和逐步执行过程。该算法巧妙地结合了最小堆不相交集合两种数据结构,通过始终选取当前未连接部分之间权重最小的边来构建最小生成树,并有效避免了环路的产生。

在下一个视频中,我们将深入分析实际运行这个算法的时间复杂度,并查看一些用于实现克鲁斯卡尔算法的代码。

045:最小生成树之克鲁斯卡尔算法II 🧩

在本节课中,我们将学习克鲁斯卡尔算法的具体代码实现,并分析其运行时间复杂度。我们将看到算法如何利用最小堆(优先队列)和并查集这两种数据结构,并比较使用不同优先队列实现(堆与排序数组)对性能的影响。


在上一节视频中,我们讨论了如何运行克鲁斯卡尔算法。本节中,我们来看看实际运行的代码,以便理解其具体实现,并探讨运行克鲁斯卡尔算法的时间复杂度。

让我们开始分析代码。以下是克鲁斯卡尔最小生成树算法,该算法将同时使用我们的最小堆(即优先队列)和并查集

代码的前几行将执行以下操作:首先,为图中的每个顶点初始化一个并查集。接着,设置我们的优先队列。然后,初始化最终的最小生成树图 T,我们将从最小堆中移除边,并检查它们是否属于同一个并查集,以决定是否将其加入 T

为了进行时间复杂度分析,我们将重点关注代码的两个部分,以了解其总耗时。第一部分是构建数据结构(优先队列)所需的时间,第二部分是从数据结构中移除最小元素所需的时间。我们将分别分析使用排序数组来实现优先队列的情况,因为我们可以选择使用堆并每次移除最小元素,也可以选择对数组进行一次排序以获得完全有序的序列。我们将看到不同的运行时间并进行比较。

首先,我们来看构建优先队列。如果我们想用堆来构建优先队列,根据之前的讨论,我们知道可以在 O(m) 时间内完成(其中 m 是边的数量)。这是一个非常好的结果,因为如果我们使用排序,要对所有数据进行排序,则需要 O(m log m) 的时间。请注意,这里的堆存储的是边,因此其大小是 m

接下来,我们看看使用这些结构所需的时间。在代码的第13行,我们看到一个循环:当我们的边列表(优先队列)中还有边时,我们需要执行 remove_min 操作。

  • 对于,每次 remove_min 操作需要 O(log m) 的时间,因为移除最小元素后,需要将最后一个元素交换到堆顶并进行“堆化下沉”操作。
  • 对于排序数组,我们可以简单地以常数时间 O(1) 移除最小元素,这非常简单。

然而,在构建最小生成树的过程中,我们可能需要检查每一条边,因此这些 remove_min 操作总共需要执行 m 次。所以,总时间需要乘以 m

综上所述,算法的总运行时间如下:

  • 对于:构建堆需要 O(m) 时间,执行 mremove_min 需要 O(m log m) 时间。
  • 对于排序数组:排序数组需要 O(m log m) 时间,而遍历所有顶点(边)进行移除操作只需要 O(m) 时间。

因此,使用不同结构的总运行时间要么是 O(m + m log m),要么是 O(m log m + m)。最终,克鲁斯卡尔算法的总运行时间都是 O(m log m),即与边的数量乘以边数量的对数成正比。

这里还有一个我们尚未考虑的项需要提及:我们完全忽略了代码第2至第4行。创建包含 n 个顶点的并查集需要 O(n) 时间。所以严格来说,总时间可以表示为 O(m log m + n)。但请注意,n(顶点数)总是小于或等于边的数量 m,因为图必须是连通的,所以至少需要 n-1 条边。因此,m 已经包含了这部分开销。运行克鲁斯卡尔算法的总时间主要由 O(m log m) 决定。


本节课中,我们一起学习了克鲁斯卡尔算法的代码实现及其时间复杂度分析。我们了解到,无论使用堆还是排序数组作为优先队列,算法的核心时间复杂度均为 O(m log m)。克鲁斯卡尔算法是构建最小生成树的一种方法。接下来,我们将讨论另一种全新的方法——普里姆算法,它将以完全不同的视角来构建相同的最小生成树结构。我们下节课再见。

046:最小生成树之普里姆算法

在本节课中,我们将要学习另一种寻找最小生成树的重要算法——普里姆算法。我们将了解其核心思想、执行步骤、代码实现思路,并分析其时间复杂度。


算法核心思想

上一节我们介绍了克鲁斯卡尔算法,本节中我们来看看普里姆算法。两种算法都能找到最小生成树,但实现方式完全不同。

普里姆算法基于一个关于图的数学性质:如果图中有两个互不相连的组件(集合)U和V,那么连接这两个组件的最小权重边,必定属于该图的某个最小生成树。

用公式描述这个性质:给定图 G=(V, E),将顶点集 V 划分为两个不相交的子集 U 和 V-U。设连接 U 和 V-U 的所有边为 E_cross,则边 e = argmin_{e' ∈ E_cross} weight(e') 必定包含在 G 的某个最小生成树中。

这意味着,只要我们从一个已标记的节点集合出发,不断寻找连接该集合与未标记集合的最小权重边,并将其加入,最终就能构建出一棵最小生成树。


算法执行步骤

以下是普里姆算法的具体执行过程,我们将通过一个例子来演示。

  1. 初始化:选择任意一个顶点作为起始点,将其加入“已标记集合”。其余所有顶点属于“未标记集合”。
  2. 寻找最小横切边:找出所有连接“已标记集合”与“未标记集合”的边(即横切边),并选择其中权重最小的那条。
  3. 扩展集合:将这条最小边及其连接的未标记顶点加入“已标记集合”。
  4. 重复:重复步骤2和步骤3,直到所有顶点都被标记,此时由已添加的边构成的树即为最小生成树。

让我们通过图示来一步步执行。假设我们从顶点A开始。

  • 步骤1:标记顶点A。
  • 步骤2:横切边有(A,B:2), (A,D:17), (A,F:16)。最小边是(A,B:2)。将边(A,B)和顶点B加入树中。
  • 步骤3:当前已标记集合为{A, B}。横切边有(B,D:5), (B,E:15), (A,D:17), (A,F:16)。最小边是(B,D:5)。将边(B,D)和顶点D加入树中。
  • 步骤4:继续此过程,依次添加边(D,E:8), (D,F:9), (F,G:4), (E,C:10), (C,H:5)。

最终,所有顶点被连接,我们得到了一棵最小生成树。


算法实现思路

让我们看看实现这个算法的代码逻辑。算法需要一些数据结构来高效地追踪顶点和边的状态。

算法的输入是一个图和起始顶点,输出是最小生成树。在实现中,我们需要为每个顶点维护两个值:

  • key (或 distance):表示从当前已标记集合到达该顶点的已知最小边的权重。初始值设为无穷大 ()。
  • predecessor (或 parent):表示是通过哪条边(即来自哪个前驱顶点)以这个最小权重到达该顶点的,用于最后构建树。

以下是算法的主要步骤:

  1. 初始化所有顶点的 key 为 ∞,predecessor 为空。将起始顶点的 key 设为 0。
  2. 将所有顶点按其当前的 key 值放入一个最小优先队列(通常用二叉堆实现)中。
  3. 当优先队列不为空时:
    a. 从队列中取出 key 值最小的顶点 u(即“未标记集合”中距离“已标记集合”最近的顶点)。
    b. 将 u 视为加入“已标记集合”。如果 u 不是起始点,则将边 (u.predecessor, u) 加入生成树。
    c. 遍历 u 的所有邻接顶点 v
    * 如果 v 仍在队列中(即未标记),且连接 uv 的边的权重 w(u, v) 小于 v 当前的 key 值。
    * 则更新 vpredecessoru,并将 vkey 值更新为 w(u, v)
    * 同时,需要在优先队列中调整 v 的位置(decrease-key 操作)。

通过不断从优先队列中取出最小 key 的顶点并更新其邻居,我们模拟了“寻找最小横切边”的过程。


时间复杂度分析

与克鲁斯卡尔算法类似,我们需要分析普里姆算法的时间复杂度,这取决于图的数据结构(邻接表或邻接矩阵)和优先队列的实现。

使用二叉堆作为优先队列和邻接表存储图时,时间复杂度分析如下:

  • 初始化:O(V)
  • 每个顶点执行一次 extract-min 操作:O(V log V)
  • 每条边可能触发一次 decrease-key 操作:O(E log V)

因此,总时间复杂度为 O((V + E) log V)。由于在连通图中 E ≥ V-1,通常简化为 O(E log V)

对于稀疏图(E ≈ V),运行时间约为 O(V log V)。
对于稠密图(E ≈ V²),运行时间约为 O(V² log V)。在这种情况下,使用更复杂的斐波那契堆实现优先队列,可以将 decrease-key 操作降至均摊 O(1),从而将总时间复杂度优化至 O(E + V log V)


总结

本节课中我们一起学习了普里姆算法。其核心思想是从一个点出发,像生长一棵树一样,每次选择连接“树”与“外界”的最短边来扩展这棵树。

关键要点:

  • 核心操作:不断寻找并添加连接已访问集合与未访问集合的最小权重边。
  • 关键数据结构:使用优先队列(如二叉堆)来高效地选择下一个要访问的顶点。
  • 时间复杂度:使用二叉堆和邻接表时,为 O(E log V),与克鲁斯卡尔算法效率相当。
  • 适用场景:在稠密图中,普里姆算法通常表现良好。

我们学习了两大最小生成树算法,它们都利用了贪心策略,并能通过高效的数据结构实现。掌握了如何寻找连接所有节点的最小代价方案后,我们接下来将解决图论中的另一个经典问题:如何找到图中两点之间的最短路径。下周,我们将深入计算机科学中最著名的算法之一——迪杰斯特拉算法。

047:图论-迪杰斯特拉算法 🧭

在本节课中,我们将要学习计算机科学中最著名的算法之一——迪杰斯特拉算法。该算法用于在图中寻找从一个特定起点到所有其他节点的最短路径。我们将通过一个具体的例子,详细拆解算法的每一步,并与之前学过的普里姆算法进行对比,以帮助你理解其核心思想和工作原理。

算法概述

迪杰斯特拉算法与上周学习的普里姆算法非常相似,但有一个根本性的不同。普里姆算法用于寻找最小生成树,而迪杰斯特拉算法的目标是找到图中从一个源节点出发到所有其他节点的最短路径。

让我们通过观察一个具体的图来了解算法是如何工作的。图中包含一系列节点和连接它们的边。请注意,这些边是有向边,每条边都有一个源节点和一个目标节点,用箭头表示。

算法流程详解

以下是迪杰斯特拉算法的核心代码逻辑。我们将逐一解释每个步骤。

# 伪代码示意
for each vertex v in Graph:
    v.distance = INFINITY
    v.previous = None
source.distance = 0
Q = priority_queue_of_all_vertices
labeled_set = empty_set

while Q is not empty:
    u = Q.remove_min()
    labeled_set.add(u)
    for each neighbor v of u:
        # 核心更新逻辑
        alt = u.distance + weight(u, v)
        if alt < v.distance:
            v.distance = alt
            v.previous = u

算法的前几行代码会遍历图中的每一个顶点,将其距离初始化为无穷大,前驱指针设为空。这与普里姆算法的初始化步骤完全相同。接着,我们将起始顶点的距离设为0。我们建立一个以顶点距离为优先级的优先队列(通常用堆实现),并初始化一个空集合,用于存放已确定最短路径的“已标记”节点。

然后,算法进入循环。在每一步中,我们从优先队列中移除距离最小的节点,将其加入“已标记”集合。接着,我们遍历这个节点的所有邻居,并更新它们的权重。到目前为止,这些步骤都与普里姆算法一致。

核心差异:路径总成本

迪杰斯特拉算法与普里姆算法的唯一关键区别在于更新权重的规则。在普里姆算法中,我们只比较和更新跨越“已标记”和“未标记”节点集合的边的权重。而在迪杰斯特拉算法中,我们更新的是从源节点到该邻居节点的总路径成本

具体来说,当我们从节点 u 查看其邻居 v 时,我们计算的是:
新距离 = u.distance + weight(u, v)
只有当这个新距离小于 v 当前存储的距离时,我们才会更新 v.distancev.previous

逐步演示例

让我们用一个例子来具体说明。假设我们从节点A开始。

  1. 初始化:A的距离为0,其他所有节点(B, C, D, E, F, G)的距离为无穷大。
  2. 处理A:将A加入已标记集合。更新A的邻居:
    • B的距离更新为 0 + 10 = 10
    • F的距离更新为 0 + 7 = 7
  3. 处理F(当前最小距离为7):将F加入已标记集合。更新F的邻居:
    • E的新距离为 7 + 5 = 12(小于无穷大,更新)
    • G的新距离为 7 + 4 = 11(小于无穷大,更新)
  4. 处理B(当前最小距离为10):将B加入已标记集合。更新B的邻居:
    • C的新距离为 10 + 7 = 17
    • D的新距离为 10 + 5 = 15
  5. 处理G(当前最小距离为11):将G加入已标记集合。更新G的邻居:
    • E的新距离为 11 + 2 = 13,但E当前距离为12,13 > 12,因此不更新
  6. 处理E(当前最小距离为12):将E加入已标记集合。更新E的邻居:
    • C的新距离为 12 + 6 = 18,但C当前距离为17,18 > 17,因此不更新
  7. 处理D(当前最小距离为15):将D加入已标记集合。更新D的邻居(此例中无新更新)。
  8. 处理C(当前最小距离为17):将C加入已标记集合。算法结束。

最终,我们得到从A到所有节点的最短距离:

  • A: 0
  • B: 10
  • C: 17
  • D: 15
  • E: 12
  • F: 7
  • G: 11

算法结果与应用

运行完迪杰斯特拉算法后,我们得到了一张记录最短路径信息的表。通过每个节点的 previous 指针,我们可以回溯出从源节点A到任意目标节点的具体路径。

例如,要找到A到E的路径:

  1. E的前驱是F。
  2. F的前驱是A。
  3. 因此路径是 A -> F -> E,总距离为12。

正因为迪杰斯特拉算法能够从一个指定的源节点出发,计算出到图中所有其他节点的最短路径,所以它被称为单源最短路径算法。你必须给定一个源节点,算法总是从该节点开始计算。它无法直接告诉你从B到D的最短路径,除非你将B设为新的源节点重新运行算法。

总结

本节课中,我们一起学习了迪杰斯特拉算法。我们了解到:

  • 迪杰斯特拉算法是一种用于在带权有向图中寻找单源最短路径的算法。
  • 其核心思想是贪心算法,通过不断将距离源点最近的未处理节点加入“已确定集合”,并松弛其邻边来逐步求得全局最短路径。
  • 与普里姆算法的关键区别在于,迪杰斯特拉算法在更新节点距离时,计算的是从源节点到该节点的累计路径总成本,而不仅仅是单条边的权重。
  • 算法最终输出每个节点到源节点的最短距离和前驱节点,利用前驱节点可以重构出完整的最短路径。

在接下来的课程中,我们将进一步探讨如何应用迪杰斯特拉算法以及它能解决的其他问题。

048:迪杰斯特拉算法边界情况

在本节课中,我们将学习迪杰斯特拉算法在不同边界情况下的表现。我们将探讨算法如何处理多条轻量路径与单条重量路径的对比、负权边的影响以及无向图的情况。

上一节我们介绍了迪杰斯特拉算法,它与普里姆算法非常相似,只需稍作修改即可用于寻找最短路径。迪杰斯特拉算法的工作原理非常出色,因为它能处理许多边界情况,但在另一些情况下也会失效。接下来,我们将快速在几种不同的图上运行该算法,观察其表现。

多条轻量路径 vs. 单条重量路径

首先,我们来看迪杰斯特拉算法如何处理多条轻量路径与单条重量路径的对比。假设我们希望找到从节点 A 到节点 B 的最短路径。

图中存在一条直接从 A 到 B 的路径,其代价为 10。同时,也存在另一条经过多个其他节点的路径,其总代价小于 10。

思考迪杰斯特拉算法的执行过程:算法首先将所有节点的初始距离标记为无穷大(图中未标出),然后从 A 开始向外标记。因此,C 的权重更新为 1,B 的权重更新为 10。算法会优先选择 C。

然后,C 将 D 的权重更新为 2。继续执行,2 小于 10,E 更新为 3,F 更新为 4,G 更新为 5,H 更新为 6。

现在,计算 H 到 B 的路径,权重为 7。由于 7 小于 10,因此 B 的节点权重更新为 7。最终,算法会选择路径 A -> ... -> H -> B 来到达 B。

在这个例子中,迪杰斯特拉算法成功地找到了由多条短路径组成、总距离更小的路线,尽管存在从 A 到 B 的直接路径。这表明,迪杰斯特拉算法能够克服路径节点数可能更多的情况,因为它最小化的是总距离,并且在本例中成功地做到了这一点。

单条重量路径 vs. 多条轻量路径(反转情况)

现在,让我们稍微修改这个例子,使得较长的路径(由多个短边组成)反而是最优的,并确保算法即使在有许多更短的边可选时,也能选择这条较长的路径。

这里,从 A 到 B 的直接路径权重为 5,而另一条经过多个节点的路径总权重更高。让我们看看迪杰斯特拉算法如何处理。

同样,A 将 B 的权重更新为 5,将 C 更新为 1。算法继续从 A 到 C,然后 C 到 D,D 到 E,E 到 F。在 F 点,我们将 G 的权重更新为 5。算法可以继续选择这条边。然后,G 被加入,并将 H 的权重更新为 6。

此时,迪杰斯特拉算法需要根据优先队列做决定:5 小于 6。因此,算法将选择边 A -> B,确定从 A 到 B 的最短路径就是这条直接路径 A -> B。最后,算法完成 G 到 H 的处理。

在这个例子中,即使存在一条由更短边组成的更长路径,迪杰斯特拉算法依然找到了真正的最短路径。

负权边的影响

在前两个例子中,我们看到了迪杰斯特拉算法总能找到图上最短路径的强大能力。接下来,我们看看当图中存在负权边时会发生什么。在许多应用中,某些边可能会减少总成本,这通过负的边权重表示。

迪杰斯特拉算法在这种情况下是否仍然表现良好?让我们来看一个处理负权重循环的例子。

从节点 A 开始,更新 B 的权重为 10,F 的权重为 7。算法选择 F 作为下一个节点,E 更新为 12,G 更新为 11。然后处理 B(17? 此处视频语音似乎有口误,按算法逻辑应为处理当前距离最小的未访问节点),接着处理 G(权重 11)。然后处理 E(权重 12)。从 E 到 C 的权重是 12 + (-6) = 6。

算法发现 6 是一个更小的权重,于是选择它。6 + 4 = 10。观察路径,我们发现最短路径其实很有趣。例如,路径 A -> F -> E -> C 的权重是 6。

但是,如果我们走之前发现的路径 A -> F -> E -> C(权重 6),然后再走 H -> G -> E -> C 这条路径呢?增加 H -> G -> E -> C 这段路径(权重计算:H->G? 原叙述稍显混乱,核心在于存在负权边可能导致环路)后,我们发现,每次绕行一个包含负权边的循环,路径总成本就会减少。这意味着,理论上可以无限次地绕行该循环,使得到达 C 的路径成本无限降低(6, 1, -4, -9...)。

这里发生的情况是,迪杰斯特拉算法无法找到真正的最短路径。因为只要存在负权重循环,最短路径就会不断“变短”。事实上,到达 C 的最短路径涉及无限次地绕行该循环,每次绕行使路径减少 5 个单位。因此,第一次到达是 6,第二次是 1,第三次是 -4,依此类推。

迪杰斯特拉算法无法处理这种情况。事实上,对于所有图,只要存在负权重循环,就不存在一个确定的最短路径(因为可以无限降低)。因此,在存在负权重循环的图上,绝对不能运行任何寻找最短路径的算法。

迪杰斯特拉算法在任何包含负权边的图上都无法保证正确工作。 这不仅指负权重循环,甚至单个非循环的负权边也会导致算法失效,因为迪杰斯特拉算法假设从源点出发的距离是单调递增的。因此,引入负权边是迪杰斯特拉算法最大的弱点之一。

当然,存在更高级的算法(如贝尔曼-福特算法)可以帮助我们解决包含负权边(但无负权重循环)的图上的最短路径问题。然而,一旦存在负权重循环,从数学上讲,最短路径将是负无穷,因此永远找不到。

无向图的情况

最后,让我们看看迪杰斯特拉算法在无向图这个边界情况下的表现。在无向图中,边是双向连通的。

在这个无向图中,我们看到迪杰斯特拉算法没有问题。从 A 开始,A 到 C 更新权重为 1,A 到 B 更新为 3。然后选择 C,C 到 D 更新为 2。接着选择 D,D 到 E 更新为 3。之后可能选择 B 或 E,B 到 H 更新为 4,E 到 F 更新为 4。最后,H 到 G 更新为 5。拥有无向边完全不会影响迪杰斯特拉算法。

总结

本节课中,我们一起学习了迪杰斯特拉算法在不同边界情况下的表现。

我们知道,迪杰斯特拉算法在任何结构的图上都表现得非常出色,前提是每条边的权重都必须为正数。只要图中所有边权为正,无论是有向图还是无向图,它都能可靠地找到最短路径。

然而,一旦图中引入负权边,我们就不能再使用迪杰斯特拉算法。对于不存在负权边的常见图,迪杰斯特拉算法是寻找最短路径的绝佳算法。

在下一讲中,我们将深入分析迪杰斯特拉算法的工作原理。

049:图论-迪杰斯特拉算法运行时间分析 🚀

在本节课中,我们将要学习迪杰斯特拉算法的运行时间分析。我们已经掌握了如何使用迪杰斯特拉算法及其所有运行细节,现在快速讨论一下该算法的时间复杂度。

算法运行时间概述

迪杰斯特拉算法的总运行时间与其基础算法非常相似。让我们做一点分析,看看它与普里姆算法有何不同。

与普里姆算法类似,迪杰斯特拉算法需要构建一个包含图中所有顶点的数据结构。我们将构建一个顶点优先队列,并遍历图中的每一个顶点,这与普里姆算法的过程一致。

核心步骤与时间复杂度

在每一个顶点上,我们都会移除优先队列中距离最小的顶点,然后遍历它的所有边。当我们找到一条更短的路径时,就会更新该边的权重。

这里的关键在于,更新边的代价只在我们需要“减小”某个顶点的距离值时才会发生。通过使用一种称为斐波那契堆的特殊堆结构,我们可以对此进行优化。

经过优化的迪杰斯特拉算法能达到一个非常出色的运行时间,其优化方式与普里姆算法的最佳优化版本完全相同。

因此,算法的总运行时间为 O(M + N log N)

  • M 代表图中的边数。
  • N 代表图中的节点数。
  • log N 是操作优先队列(斐波那契堆)带来的对数因子。

算法效率评价

这个运行时间是所有最短路径算法中能达到的最佳时间复杂度之一。虽然它比简单的图遍历(O(M+N))多了一个 log N 的因子,但这个额外的代价使得我们能够在遍历所有边和节点的同时,精确地找到单源最短路径。

这是一个非常优秀的运行时间,是寻找最短路径的一个极佳方法,也是现有算法中最优的选择之一。

总结与过渡

上一节我们详细介绍了迪杰斯特拉算法的运行时间,现在你已经对它有了完整的理解。

接下来,让我们看看计算机科学中一个我最喜欢的问题。这将是一个有趣的方式,来展示如何为现实生活中的问题选择合适的算法。

我期待向你展示这一点,我们下一个视频再见。😊

050:图论-地标路径问题 🧭

在本节课中,我们将要学习一个非常有趣的图论问题——地标路径问题。我们将看到,当图中所有边的权重都相等时,我们可以用一种更高效的方法来寻找必须经过特定地标的最短路径。


问题引入

在所有计算机科学谜题中,地标路径问题是我最喜欢的之一。这个问题非常有趣,因为直觉上的答案并不总是能真正解决问题。通过和我一起解决这个问题,你将看到一种思考经典图论问题的新方式。

在地标路径问题中,我们有一个包含一系列节点的图。我们想要找到图中特定节点之间的路径。图中的每一条边都具有完全相同的权重,它们的权重都是3。我们需要找出从不同位置出发的最短路径。我们将从简单的情况开始,然后逐步为这个图增加额外的约束。


从A到G的最短路径

首先,我们来看第一个问题。观察这个图,我们需要找出从节点A到节点G的最短路径。

为了找到从A到G的最短路径,我们可以简单地选择从A向下到F,然后移动到G。这条路径的总距离是6,只需要经过两条边。这就是从A到G的绝对最短路径。


算法运行时间分析

接下来,我们可以问,解决这个问题的运行时间是多少?寻找最短路径的最快算法是什么?

因为我们刚刚学习了迪杰斯特拉算法,似乎迪杰斯特拉算法可以用来寻找最短路径。但是请注意,图中所有的边权重都是相等的。因为所有权重都相等,这意味着我们只需要知道需要经过的最少边数,而不一定是传统意义上的“最短路径”。在这种情况下,最少边数就是最短路径。

因此,我们不需要使用迪杰斯特拉算法(其时间复杂度为 O((V+E) log V)),我们可以直接运行一个最小生成树算法,比如广度优先搜索。广度优先搜索将通过访问所有邻居节点,利用交叉边进行完整的遍历,从而为我们找到最短路径。这只需要一次遍历的成本,时间复杂度为 O(V+E)

让我们运行广度优先搜索,看看它是否有效。

运行广度优先搜索时,我们从A开始。首先访问所有邻居节点:B、D、F。这里会有一条从D到B的交叉边,然后从子节点出发会有新的发现边。我们可以继续这个过程,添加更多的发现节点和交叉边。通过运行BFS,我们看到这里有一条路径,它是生成树的一部分,距离仍然是6。我们找到了完全相同的结果,但使用了一个更快的算法。我们使用了生成树算法,而不是传统的最短路径算法。

所以,尽管迪杰斯特拉算法绝对能找到最短路径,但根据图的结构,有时有更快的方法来解决它。


引入地标约束

但这不仅仅是“最短路径问题”,它被称为“地标路径问题”。现在,让我们为这个问题引入另一个约束。

在这里,我不只是想从A到G。我想从A到G,但必须经过地标L。这意味着在从A到G的路上,我必须访问L。我想知道从A到G并经过L的最短路径。


解决方案思路

解决这个问题,我们可以先从A运行一次BFS到所有节点,就像之前做的那样。从之前的图中我们可以看到,从A到L的最短路径沿着图的底部。所以我知道从A到L的最短路径在这里。然后,我可以从L运行另一个最短路径算法,从L向外扩展,构建那棵树,我们就能看到从L到G的最短路径。

因此,通过两次应用广度优先搜索,我们可以看到它创建的最小生成树将为我们找到一个极佳的解决方案。

但我认为我们还没有结束。关于这个图,我们还可以问更多问题。


优化:从地标开始

在总运行时间方面,找到这条完整路径的绝对最快时间是多少?实际上,我们应该从哪个顶点开始?我们想从A开始,因为那是起点吗?

但如果我们稍微思考一下这个问题,我们会发现,当我们实际上从L开始时,我们不仅仅得到了从L到目的地的最短路径。事实上,从L开始的最小生成树将得到从L到图上每一点的最短路径。

我要论证的是,因为我们有一个双向图,其中所有边的权重都相等,并且我们正在创建生成树。我们知道从L到A的路径也是从A到L的最短路径。因此,只要我们找到从L到A的最短路径,我们就知道它可以用于从A到L。

所以,通过只运行一次BFS,并从L开始,我们得到一个生成树,它实际上向我们展示了一条路径——不仅仅是任意路径,而是从地标到A的最短路径,以及从地标到终点G的最短路径。结合这两点,我们可以反向使用L到A的路径来得到从A到L的路径,然后使用L到终点的路径来得到整个必须首先访问L的路径。


核心结论

因此,解决这个我最喜欢的问题之一的方案是:我们完全不需要使用迪杰斯特拉算法来寻找地标路径问题图中的最短路径。我们必须从地标L开始,只运行一次BFS。这将给我们最短路径。我们只需对整个图进行一次遍历就能解决这个问题。

这是一个非常出色的运行时间和结果。作为一名计算机科学家,你将会思考这类问题:在给定一组约束条件和我所知的全部算法财富的情况下,如何应用我所知的算法来解决一个非常非常有趣的问题?


总结

本节课中,我们一起学习了地标路径问题。我们了解到,当图中所有边权重相等时,广度优先搜索是比迪杰斯特拉算法更高效的最短路径查找工具。对于必须经过特定地标的问题,最优策略是从地标节点开始执行一次BFS,利用生成树的性质同时得到地标到起点和终点的最短路径,从而组合出最终答案。这结束了对迪杰斯特拉算法的讨论,也为我们关于图的学习画上了一个圆满的句号。

在过去的几周里,我们讨论了图的各种应用,现在你已经掌握了大量关于如何将图算法应用于现实问题的知识。我期待看到你运用这些图算法所创造的作品。我们很快会再见面。😊

posted @ 2026-03-29 09:32  布客飞龙II  阅读(13)  评论(0)    收藏  举报