UCD-ECS36c-数据结构与算法笔记-全-

UCD ECS36c 数据结构与算法笔记(全)

001:算法分析 📊

在本节课中,我们将要学习算法分析。算法分析是计算机科学中一个核心主题,它帮助我们理解算法在不同输入规模下的性能表现。我们将探讨为什么需要算法分析,并通过实例来理解其重要性。

为什么需要算法分析?🤔

上一节我们介绍了课程主题,本节中我们来看看为什么算法分析如此重要。我们通过两个例子来理解其必要性。

例子一:课程调度程序

想象一下,加州大学戴维斯分校的注册办公室需要为数千门课程安排教室。他们考虑购买一个自动调度程序。供应商声称,该程序可以在 1.42秒 内为 10间 教室完成调度。

然而,戴维斯分校有 130间 教室。仅凭一个数据点,我们无法判断该程序在处理130间教室时的性能。其表现完全取决于程序内部使用的算法。

以下是几种可能的情况:

  • 对数算法:如果算法高效,可能只需约 3秒
  • 线性算法:如果算法一般,可能需要约 18秒
  • 指数算法:如果算法低效,可能需要约 240秒(4分钟)。

这个例子说明,我们需要算法分析来预测一个算法在输入规模变化时的运行时间。

例子二:搜索算法比较

假设我们有一个已排序的数字列表:[6, 12, 46, 47, 52, 73, 78, 91]。我们需要设计一个算法来搜索列表中的特定数字。

以下是两种不同的搜索方法:

线性搜索

  • 从列表第一个元素开始,逐个检查,直到找到目标数字。
  • 例如,搜索 52 需要 5 步。
  • 最佳情况:目标数字是列表的第一个元素(如 6),只需 1 步。
  • 最坏情况:目标数字是列表的最后一个元素(如 91),需要 n 步(n为列表长度)。

二分搜索

  • 利用列表已排序的特性,每次都检查中间元素,并根据比较结果排除一半的搜索范围。
  • 例如,搜索 52 需要 3 步。
  • 最佳情况:目标数字正好是中间元素,只需 1 步。
  • 最坏情况:需要不断对半分割列表直到找到目标,这被称为对数算法。

通过比较,我们发现二分搜索在平均和最坏情况下通常优于线性搜索,但在某些特定情况(如搜索第一个元素)下可能更慢。这引出了算法分析的第二个目的:我们需要一个系统的方法来比较不同算法的优劣,并定义“更好”的含义(例如,更快的平均运行时间、更少的内存占用等)。

算法分析的目的总结

综上所述,我们需要算法分析主要有三个目的:

  1. 预测性能:预测算法处理特定规模数据所需的时间或空间。
  2. 比较算法:提供客观标准来比较不同算法的效率。
  3. 表征算法:定义算法的最佳情况、平均情况和最坏情况性能。

如何分析算法:实验方法 🔬

上一节我们探讨了为什么需要算法分析,本节中我们来看看如何进行算法分析。首先,我们介绍一种直观的方法:实验分析。

我们将通过一个具体问题——“三数之和”问题来演示。问题描述:给定一个包含 n 个整数的集合,计算其中有多少个三元组(三个不同的数)之和等于 0

例如,对于集合 [30, -40, -20, -10, 40, 0, 10, 20],存在四个这样的三元组:

  • (30, -40, 10)
  • (30, -20, -10)
  • (-40, 40, 0)
  • (-20, 0, 20)

实验步骤

以下是进行实验分析的基本步骤:

  1. 实现算法:首先,我们需要编写程序来实现算法。以下是一个简单的 C++ 实现示例,它通过三重循环检查所有可能的三元组组合:
    int count_threesum(vector<int> &a) {
        int n = a.size();
        int count = 0;
        for (int i = 0; i < n; i++)
            for (int j = i+1; j < n; j++)
                for (int k = j+1; k < n; k++)
                    if (a[i] + a[j] + a[k] == 0)
                        count++;
        return count;
    }
    

  1. 运行实验:在具有不同输入规模(n的值)的数据集上运行该程序,并测量其运行时间。我们可以使用 time 命令来计时。

    • 输入 1000 个整数:运行时间约 1秒
    • 输入 2000 个整数:运行时间约 8秒
    • 输入 4000 个整数:运行时间约 62秒
    • 输入 8000 个整数:运行时间约 500秒
  2. 数据可视化:将输入规模(n)作为x轴,运行时间作为y轴绘制图表。我们得到的是一条向上弯曲的曲线,表明运行时间的增长比输入规模的增长更快。

  3. 建立预测模型:为了更容易地分析趋势,我们使用双对数坐标图重新绘制数据点(即x轴和y轴都采用对数刻度)。神奇的是,曲线变成了一条直线。

    一条直线的方程可以表示为:
    log(T(n)) = m * log(n) + b
    其中 T(n) 是运行时间,n 是输入规模,m 是斜率,b 是截距。

    通过计算,我们得到 m ≈ 3b ≈ -30。将其转换回普通坐标,我们得到运行时间与输入规模的关系方程:
    T(n) ≈ 2^b * n^m = (2^{-30}) * n^3

  1. 进行预测:利用这个方程,我们可以预测未实验过的输入规模的运行时间。
    • 预测 n=8000T(n) ≈ 502秒(实际测量约500秒)。
    • 预测 n=16000T(n) ≈ 4000秒(实际测量约4000秒)。

实验方法的局限性

虽然实验方法直观,但它存在几个主要问题:

  • 实现依赖:必须先完整实现算法才能进行分析。
  • 环境依赖:测量结果受硬件、软件、编译器优化、操作系统等外部因素影响。
  • 数据依赖:运行时间可能高度依赖于输入数据的特性(是否触发最佳或最坏情况)。
  • 成本高昂:需要运行大量实验来获得足够的数据点以建立可靠模型。

因此,我们需要一种更通用、更抽象的分析方法。

总结 📝

本节课中我们一起学习了算法分析的基础。我们首先通过课程调度和搜索算法的例子,理解了为什么需要算法分析——为了预测性能、比较算法和表征算法行为。接着,我们深入探讨了实验分析方法,通过“三数之和”问题,实践了从实现算法、运行实验、可视化数据到建立数学模型的完整流程。虽然实验方法直观,但我们指出了其依赖具体实现和运行环境的局限性。

下一节课,我们将介绍一种更强大、更通用的方法——数学分析方法,它将帮助我们直接通过算法的逻辑结构来推导其性能特征,而不必实际运行程序。

002:算法分析

在本节课中,我们将学习如何分析算法的性能。我们将从实验方法的局限性开始,然后深入探讨一种更数学化的方法,通过计算语句执行频率来估算运行时间。最后,我们将介绍一种简化的概念——增长阶,它帮助我们快速理解和比较不同算法的效率。

实验方法的回顾与局限

上一节我们介绍了分析算法的实验方法。其核心思想是:编写特定算法的代码,运行并收集大量数据点,绘制图表,提取方程,最终预测算法在其他输入上的行为。

然而,这种方法存在几个明显的缺点。首先,我们必须先实现想要分析的算法。其次,测量结果受许多因素干扰,例如编译器优化级别、计算机处理器性能等,这些都会影响结果的质量。因此,实验方法是一种相当复杂的技术。

数学化方法:语句计数

鉴于实验方法的复杂性,我们来看看另一种更数学化的方法。这种方法的核心思想是:查看程序执行的所有操作,并为每个操作关联一个成本(例如,执行该操作所需的纳秒数)。这里的“操作”包括整数加法、乘法、变量声明、赋值、比较、数组访问等。

通过统计程序中每种操作的使用次数,并乘以各自的成本,我们就可以累加得到程序的总成本,从而推导出运行时间。

但这种方法同样复杂。我们需要审视程序的所有操作,而成本本身很难精确确定,因为它高度依赖于编译器、优化级别和处理器性能。

因此,我们将进一步简化。我们不再区分具体操作,而是直接查看代码并计数语句的执行次数,同时考虑输入数据的大小 n

示例分析:One Sum

让我们通过一个简单的例子来理解。这个例子称为 One Sum。假设我们有一个大小为 n 的数组 a,目标是扫描整个数组,统计其中等于零的元素个数。

以下是该算法的伪代码:

count = 0
for (i = 0; i < n; i++) {
    if (a[i] == 0) {
        count++
    }
}
return count

现在,我们来统计每条语句的执行频率:

  • count = 0:这条初始化语句只执行 1 次。
  • return count:返回语句也只执行 1 次。
  • for 循环的初始化 i = 0:只执行 1 次。
  • for 循环的条件 i < n:这个条件在进入循环前会评估一次,然后在每次迭代开始前都会评估。对于从 0n-1n 次迭代,条件总共会被评估 n + 1 次。
  • for 循环的增量 i++:在每次成功的迭代后执行,共执行 n 次。
  • if (a[i] == 0):作为循环体的一部分,执行 n 次。
  • count++:这条语句的执行次数取决于输入数据。如果数组中没有零,则执行 0 次;如果数组中全是零,则执行 n 次。

在最坏情况(数组全为零)下,将所有频率相加,我们得到估算的运行时间表达式为:4n + 4

更复杂的例子:Two Sum

现在让我们看一个更复杂的例子:Two Sum。这是 One Sum 的扩展,目标是找出数组中所有和为 0 的元素对。

以下是 Two Sum 的伪代码:

count = 0
for (i = 0; i < n; i++) {
    for (j = i+1; j < n; j++) {
        if (a[i] + a[j] == 0) {
            count++
        }
    }
}
return count

外层循环的语句计数与 One Sum 类似。关键在于分析内层 j 循环的执行频率。

i = 0 时,j1 运行到 n-1,内层循环体执行 n-1 次。
i = 1 时,j2 运行到 n-1,执行 n-2 次。
以此类推,直到 i = n-2 时,jn-1 运行到 n-1,执行 1 次。
i = n-1 时,内层循环条件 j < n 初始就不满足,执行 0 次。

因此,内层循环体(if 语句)的总执行次数是序列 (n-1) + (n-2) + ... + 1 + 0 的和。这是一个等差数列求和。根据公式,从 m 递减到 1 的序列和为 m(m+1)/2。这里 m = n-1,所以总次数为 (n-1)n/2

展开这个表达式,我们得到 (1/2)n² - (1/2)n。这是算法核心操作(检查数对和)的频率。

在最坏情况下(所有数对和都为零),将所有语句的执行频率相加,我们得到运行时间表达式:2n² + 2n + 4

简化分析:主导项与增长阶

如此详细地计数每条语句非常复杂,而且得到的精确表达式对于理解算法效率的核心并不总是必要的。我们可以进行大幅简化。

首先,观察 Two Sum 的核心频率表达式 (1/2)n² - (1/2)n。它包含两项:(1/2)n²(二次项)和 -(1/2)n(一次项)。

当输入规模 n 变得很大时, 项的增长速度远远超过 n。因此,低阶项 -(1/2)n 的影响变得微乎其微,可以忽略不计。我们只需关注 主导项 (1/2)n²。这种近似方法称为 渐近分析

对于算法分析,我们甚至可以更进一步简化:忽略主导项的常数系数,只关心它的 增长阶。对于 Two Sum,我们只关心它是 阶的。

增长阶为我们提供了一种高层次、直观的方式来描述和比较算法的效率,而不必纠结于精确的常数因子。

常见的增长阶及其代码模式

以下是常见的增长阶分类及其对应的典型代码模式:

  • 常数时间 O(1):算法的运行时间与输入规模 n 无关。通常是一系列简单的顺序语句。
  • 对数时间 O(log n):通常出现在每次迭代都将问题规模减半的算法中,例如二分查找。
  • 线性时间 O(n):算法对输入中的每个元素处理一次。通常是一个简单的 for 循环。
  • 线性对数时间 O(n log n):常见于高效的分治算法,如归并排序。算法递归地将问题分成两半(log n),然后在合并结果时进行线性处理(n)。
  • 平方时间 O(n²):通常由两个嵌套的 for 循环导致。
  • 立方时间 O(n³):通常由三个嵌套的 for 循环导致。
  • 指数时间 O(2^n) 或阶乘时间 O(n!):通常出现在需要尝试所有可能组合的算法中,例如解决旅行商问题的暴力方法。这类算法在输入规模稍大时就变得极不实用。

增长阶的直观感受

不同增长阶之间的性能差异是巨大的。假设我们有一台主频为 1 GHz(每秒执行约 10^9 条指令)的计算机,需要处理一个规模 n = 100,000 的问题:

  • O(log n):几乎瞬间完成(微秒级)。
  • O(n):约 0.1 毫秒。
  • O(n log n):约 1.7 毫秒。
  • O(n²):约 10 秒
  • O(n³):约 11.6 天
  • O(2^n):时间长得无法想象(远超宇宙年龄)。

这个对比清晰地表明,为问题设计一个低增长阶的算法是何等重要。它决定了在可接受的时间内,我们能够处理多大输入规模的问题。

总结

本节课中,我们一起学习了算法分析的核心方法。我们从实验方法的局限性出发,引入了数学化的语句计数法,并通过 One Sum 和 Two Sum 的例子进行了实践。接着,我们了解到过于精确的计数并不必要,进而学习了通过关注主导项增长阶来简化分析。最后,我们认识了常见的增长阶类别及其对应的代码模式,并通过一个生动的对比,深刻理解了设计高效算法(即低增长阶算法)的极端重要性。掌握这些概念,是评估和比较不同算法性能的基础。

003:算法分析

在本节课中,我们将学习算法分析的核心概念,特别是如何使用大O符号来描述算法的运行时间和空间复杂度。我们将从数学定义出发,通过具体代码示例,理解如何分析算法的效率。

回顾:算法分析基础

上一节我们介绍了如何通过分析代码来估算算法的运行时间,而不必实际运行程序。我们通过计算关键语句的执行次数,得到了算法运行时间关于输入规模 n 的函数 T(n)

例如,对于“两数之和”问题,我们分析其核心的 if 语句,得出其执行次数为 (1/2)n^2 - (1/2)n。我们进一步学习了使用“波浪号”表示法(~)和“阶”的概念来简化这个表达式,最终得到其增长阶为

我们还了解了不同的增长阶(如常数、对数、线性、线性对数、平方、指数)及其对算法性能的巨大影响。对于一个规模为100,000的输入,不同阶的算法处理时间可能从不到一秒到数个世纪不等。

深入理解大O符号

本节中,我们来看看如何用更精确的数学工具——大O符号来描述算法的运行时间。

大O符号是一种描述函数增长上界的数学工具。它为我们提供了一个函数,该函数在输入规模足够大时,总是大于或等于我们实际关心的运行时间函数 T(n)

公式化定义如下:
我们说 T(n)O(f(n)),如果存在正常数 cn₀,使得对于所有 n ≥ n₀,都有 T(n) ≤ c * f(n) 成立。

这意味着函数 f(n)(乘以一个常数因子后)是 T(n) 的一个上界

大O符号计算示例

假设我们分析一个算法,得到其运行时间函数为 T(n) = 3n² + 2n + 4。我们想证明它是 O(n²)

根据定义,我们需要找到常数 cn₀,使得 3n² + 2n + 4 ≤ c * n² 对所有足够大的 n 成立。

我们可以尝试设 n₀ = 1。那么不等式变为:
3*1² + 2*1 + 4 ≤ c * 1² => 9 ≤ c
因此,我们可以取 c = 9。可以验证,对于所有 n ≥ 19n² 总是大于等于 3n² + 2n + 4。所以,T(n)O(n²)

其他相关符号:Ω 和 Θ

除了大O,还有两个密切相关的符号用于描述算法的渐近行为。

  • 大Ω符号:描述函数的下界。如果 T(n)Ω(g(n)),意味着 g(n)(乘以一个常数后)在 n 足够大时总是小于等于 T(n)。它表示算法运行时间的最佳情况或至少需要的资源。
  • 大Θ符号:描述函数的紧确界。如果 T(n)Θ(h(n)),意味着 T(n) 同时是 O(h(n))Ω(h(n))。也就是说,T(n) 的增长速度与 h(n) 完全相同(忽略常数因子)。这是我们描述算法效率时最精确的表示。

一个重要提示:大O表示的是上界,因此一个函数可能有多个大O表示。例如,3n² + 2n + 4 不仅是 O(n²),也是 O(n³)O(2ⁿ)。但在实践中,我们总是使用最紧确的上界(即增长最慢的那个)来描述算法,通常是 T(n) 中增长最快的项。

代码复杂度分析实践

现在,让我们将理论应用于实践,分析一些具体代码片段的复杂度。

简单循环分析

以下是几个需要注意的循环结构示例:

示例1:内层循环为常数

for (int i = 0; i < n; i++) {
    for (int j = 0; j < 5; j++) {
        // 执行某些操作
    }
}

外层循环运行 n 次。尽管有嵌套循环,但内层循环固定运行5次,与 n 无关。因此总操作次数约为 5n,复杂度为 O(n),而非 O(n²)。

示例2:受条件限制的内层循环

for (int i = 0; i < n; i++) {
    if (i == SOME_SPECIAL_VALUE) {
        for (int j = 0; j < n; j++) {
            // 执行某些操作
        }
    }
}

外层循环运行 n 次。内层的 for 循环只会在一个特定的 i 值时执行一次。因此,总操作次数是 n(外层循环) + n(单次执行内层循环),复杂度为 O(n),而非 O(n²)。

包含函数调用的复杂度分析

当算法调用其他函数时,必须考虑被调用函数本身的复杂度。

示例3:调用常数时间函数

int sumOfSquares(int count) {
    int sum = 0;
    for (int i = 0; i < count; i++) {
        sum += square(i); // 假设 square() 是常数时间操作
    }
    return sum;
}

外层循环运行 count(即 n)次。如果 square(i) 函数的执行时间是常数(例如,是硬件指令或固定次数的操作),那么整个函数的复杂度就是 O(n)

示例4:调用线性时间函数

bool containsPattern(const string& str, const string& pattern) {
    for (size_t i = 0; i < str.length(); i++) {
        if (str.compare(i, pattern.length(), pattern) == 0) {
            return true;
        }
    }
    return false;
}

str.compare(...) 函数在最坏情况下需要比较整个 pattern 的长度,假设 pattern 长度为 m,则其复杂度为 O(m)。外层循环遍历 str,长度为 n。因此,在最坏情况下(mn 同阶),总复杂度为 O(n * m),可近似为 O(n²)。这是一个低效的字符串匹配算法。

高效算法示例:二分查找

二分查找是一个高效搜索有序数组的算法。

int binarySearch(const vector<int>& arr, int key) {
    int low = 0;
    int high = arr.size() - 1;
    while (low <= high) {
        int mid = low + (high - low) / 2;
        if (key < arr[mid]) {
            high = mid - 1;
        } else if (key > arr[mid]) {
            low = mid + 1;
        } else {
            return mid; // 找到
        }
    }
    return -1; // 未找到
}

其核心思想是每次比较都将搜索范围减半。假设数组长度为 n,最坏情况下需要比较的次数 x 满足 n / 2^x ≈ 1,解得 x ≈ log₂n。因此,二分查找的时间复杂度为 O(log n)

空间复杂度分析

除了运行时间,算法所需的内存空间同样重要。空间复杂度也使用大O符号表示。

我们主要关注两部分:

  1. 输入空间:存储输入数据本身所需的空间。
  2. 辅助空间:算法运行过程中,额外创建的变量、数据结构等所占用的空间。

总空间复杂度通常是两者之和。

空间复杂度示例

以下是两个分析空间复杂度的例子:

示例5:常数辅助空间

int countLetter(const string& str, char letter) {
    int count = 0;
    for (auto c : str) {
        if (c == letter) {
            count++;
        }
    }
    return count;
}
  • 输入空间:字符串 str 占用 O(n) 空间。
  • 辅助空间:只使用了固定大小的变量 countc,占用 O(1) 空间。
  • 总空间复杂度O(n)(由输入主导)。

示例6:线性辅助空间

string toUpperCase(string str) { // 按值传递,创建副本
    string output = str; // 创建输入字符串的完整副本
    for (auto& c : output) { // 使用引用以修改字符
        c = toupper(static_cast<unsigned char>(c));
    }
    return output;
}
  • 输入空间:参数 str 通过值传递,在函数内已占用 O(n) 空间(副本)。
  • 辅助空间:创建了 output 字符串,是 str 的完整副本,又占用 O(n) 空间。循环变量 c 是引用,不占额外空间。
  • 总空间复杂度:O(n) + O(n) = O(n)

总结

本节课中我们一起学习了算法分析的核心工具。我们深入理解了大O、大Ω和大Θ符号的数学定义及其意义,知道大O用于描述最坏情况的上界,是实践中最常用的符号。

我们通过多个代码示例,练习了如何分析循环、条件语句和函数调用对时间复杂度的影响,并特别指出了分析时常见的陷阱。我们还学习了高效算法如二分查找的复杂度分析。

最后,我们引入了空间复杂度的概念,学会了如何分析算法对内存的消耗,区分了输入空间和辅助空间。

掌握这些分析技巧,能够帮助我们在设计或选择算法时,对其性能做出准确的预测和评估,是编写高效程序的基础。下一讲,我们将开始学习具体的数据结构——链表。

004:列表

在本节课中,我们将要学习一种最常见的数据结构——列表。我们将从理解列表作为抽象数据类型(ADT)的概念开始,然后深入探讨其具体实现方式之一:使用固定数组。通过本课,你将掌握列表的基本操作及其在固定数组实现下的工作原理。

概述:什么是列表?

列表是一种抽象数据类型,它代表一个有序的、由特定类型元素组成的集合。与集合不同,列表中元素的顺序是至关重要的。列表支持一系列基本操作,如获取大小、获取元素、查找元素、插入和删除元素。

核心概念回顾

在深入列表之前,我们需要明确几个关键术语。

类型与数据类型

类型 是值的集合。例如,布尔类型包含 truefalse 两个值。整数类型包含 0, 1, 2, 3... 等值。这些类型被称为简单类型基本类型原始类型,因为处理器在硬件层面直接理解它们。

数据类型 不仅包括类型本身,还包括可以对该类型执行的操作。例如,整数数据类型包括整数类型以及加法、减法、乘法、除法等操作。

抽象数据类型与数据结构

抽象数据类型 定义了数据类型的行为规范(类似于一个API),但不指定其具体实现。列表、栈、队列、字典和集合都是ADT的例子。

数据结构 是ADT的具体实现。例如,列表ADT可以通过数组、单链表或双链表等不同的数据结构来实现。

列表ADT:定义与操作

列表是一个有序的元素集合。以下是列表ADT最典型的五种操作:

  1. size(): 返回列表中当前元素的数量。
  2. get(position): 返回指定位置上的元素。
  3. find(item): 在列表中查找给定元素,并返回其位置(如果存在)。
  4. insert(item, position): 在指定位置插入一个新元素。
  5. remove(position): 移除指定位置上的元素。

许多其他有用的操作(如检查列表是否为空、在头部插入、移除尾部元素等)都可以基于这五个基本操作来实现。

列表的实现方式

列表ADT有多种实现方式,主要包括:

  • 使用固定数组
  • 使用动态数组
  • 使用链表(单链表或双链表)
  • 使用(本课程不涉及)

本节课我们将重点讨论第一种:使用固定数组实现列表。

固定数组实现列表

让我们通过一个例子来理解固定数组如何实现列表操作。假设我们有一个最大容量为5的列表。

初始状态

列表初始为空,底层数组为空,当前大小 size 为0。

插入操作

  1. 在位置0插入23:数组在索引0处存入23,size 更新为1。
  2. 在末尾插入42:通过 insert(42, size) 实现。在索引1(当前size值)处存入42,size 更新为2。
  3. 在位置0插入99:这需要将现有元素(23和42)依次向后移动一位,为99腾出位置0。插入后,size 更新为3。

删除操作

删除位置1的元素(23):需要将位置1之后的元素(42)向前移动一位,以填补空缺。删除后,size 更新为2。

核心思想是,列表必须保持元素的连续性,不能有“空洞”。

代码实现分析

以下是使用固定数组实现列表 ListFixedArray 类的关键部分。

数据结构定义

template <typename T>
class ListFixedArray {
private:
    static constexpr int capacity = 3; // 固定容量
    std::array<T, capacity> items;     // 底层固定数组
    int size_;                         // 当前元素数量
public:
    // ... 成员函数
};

这里使用 static constexpr 确保容量在编译期是已知常量。size_ 变量用于追踪列表中当前有多少个有效元素。

构造函数与析构函数

由于没有动态内存分配,构造函数和析构函数可以使用 default 关键字,让编译器生成默认版本。

ListFixedArray() = default;
~ListFixedArray() = default;

基本操作实现与复杂度分析

以下是核心方法的实现思路和算法复杂度分析。

size() 方法

直接返回 size_ 变量。无论列表中有多少元素,此操作都只执行一步。

  • 时间复杂度:O(1)

get(position) 方法

首先检查 position 是否有效(0 <= position < size_),无效则抛出 std::out_of_range 异常。有效则直接通过数组索引 items[position] 返回元素。

  • 时间复杂度:O(1)。数组支持随机访问,访问任何位置耗时相同。

find(item) 方法

遍历数组,将每个元素与目标 item 比较。找到则返回其索引,未找到则返回 -1。

  • 时间复杂度:O(n)。最坏情况下需要遍历整个列表。

remove(position) 方法

  1. 验证 position 有效性。
  2. 如果删除的是最后一个元素(position == size_ - 1),只需将 size_ 减1。
  3. 如果删除中间元素,则需要一个循环,将 position 之后的所有元素向前移动一位,然后 size_ 减1。
  • 时间复杂度:O(n)。在平均和最坏情况下(删除开头或中间元素),可能需要移动大量元素。

insert(item, position) 方法

  1. 验证 position 有效性(0 <= position <= size_)以及列表是否已满(size_ == capacity)。
  2. 如果插入位置在末尾(position == size_),直接在 items[size_] 处放入新元素,然后 size_ 加1。
  3. 如果插入位置在中间,需要先从后向前循环,将 position 及之后的元素都向后移动一位,腾出空间,然后再放入新元素,最后 size_ 加1。
    • 注意:必须从后向前移动,以避免覆盖尚未移动的元素。
  • 时间复杂度:O(n)。在平均和最坏情况下(在开头或中间插入),可能需要移动大量元素。

固定数组实现的优缺点

优点

  • 实现简单:代码逻辑直观,易于编写和理解。
  • 内存预知:如果事先能准确知道元素数量上限,可以精确分配内存,避免浪费。

缺点

  • 容量固定:列表最大尺寸在创建时即确定,无法动态增长。插入元素可能因数组已满而失败。
  • 可能浪费内存:如果预设容量远大于实际所需,会造成内存浪费。
  • 插入/删除成本高:在非末尾位置进行插入或删除操作,需要移动元素,平均时间复杂度为 O(n)。

总结

本节课我们一起学习了列表这一重要的抽象数据类型。我们明确了列表是有序集合,并定义了其核心操作:size, get, find, insert, remove。我们重点探讨了使用固定数组来实现列表的具体方法,分析了每种操作背后的逻辑、代码实现及其算法复杂度(getsize 为 O(1),find, insert, remove 为 O(n))。最后,我们讨论了这种实现方式的优缺点,其最大的局限性在于容量的固定性。在下一讲中,我们将学习如何用动态数组来克服这一限制,实现一个可以灵活扩容的列表。

005:列表的动态数组实现 📚

在本节课中,我们将继续学习列表(List)数据结构,重点探讨如何通过动态数组(Dynamic Array)来实现列表抽象数据类型(ADT)。我们将了解动态数组如何克服固定数组的容量限制,并分析其操作的复杂度。

上一节我们介绍了列表ADT以及使用固定数组的简单实现。本节中,我们来看看如何通过动态调整数组大小来实现一个更灵活、更符合ADT定义的列表。

动态数组的概念

动态数组实现的核心思想是:数组的容量(capacity)不再是固定的。当需要插入的元素数量超过当前数组容量时,我们可以动态地扩展数组以容纳更多元素。反之,当从列表中移除大量元素时,我们也可以收缩数组,以避免内存浪费。

这就引出了一个问题:我们应该在何时扩展(grow)或收缩(shrink)动态数组?

以下是两种可能的策略:

  • 策略一:每次操作都调整大小
    每次插入时,将数组大小增加1;每次删除时,将数组大小减少1。这种方法实现简单,但效率极低。因为每次操作都需要重新分配内存并复制所有现有元素,导致插入N个元素的总时间复杂度为 O(N²),这是不可接受的。

  • 策略二:非频繁调整大小(我们将采用的策略)
    我们只在必要时(例如数组已满或太空时)才调整数组大小,并且每次调整的幅度更大(例如翻倍或减半)。这样,虽然单次调整的代价较高(O(N)),但分摊到多次廉价操作上,平均每次操作的成本(分摊复杂度)可以很低。

动态数组的实现

动态数组实现的大部分代码与固定数组实现相似。我们将重点关注两者的不同之处。

数据结构定义

首先,类的私有成员变量发生了变化:

  • capacity 不再是常量,而是一个普通的变量,其值可以改变。
  • items 是一个指向数组的智能指针std::unique_ptr<T[]>),它管理动态分配的内存。
  • curr_size 保持不变,用于记录列表中当前元素的数量。

使用C++11的std::unique_ptr智能指针的主要优势在于自动内存管理。当List_DynamicArray对象被销毁时,智能指针会自动释放其指向的数组内存,有效防止了内存泄漏。

template <typename T>
class List_DynamicArray {
private:
    int capacity;                 // 当前数组容量(可变)
    std::unique_ptr<T[]> items;   // 指向动态数组的智能指针
    int curr_size;                // 列表中当前元素数量
    // ... 其他成员函数
};

调整大小的核心函数:resize

我们需要一个内部函数来调整底层数组的容量。

void resize(int new_capacity) {
    // 1. 检查新容量是否合法
    assert(new_capacity > 0);
    assert(new_capacity >= curr_size);

    // 2. 分配一个具有新容量大小的新数组
    std::unique_ptr<T[]> new_items = std::make_unique<T[]>(new_capacity);

    // 3. 将旧数组中的所有元素“移动”到新数组中
    std::move(items.get(), items.get() + curr_size, new_items.get());

    // 4. 交换新旧数组指针,使`items`指向新数组
    items.swap(new_items);

    // 5. 更新容量变量
    capacity = new_capacity;
    // 函数结束,局部变量`new_items`被销毁,其指向的旧数组内存也随之自动释放
}

插入操作与数组扩展

在插入元素时,如果发现当前元素数量已经等于数组容量(即数组已满),我们需要先扩展数组。

void insert(const T& item, int position) {
    // 检查插入位置是否有效...
    if (curr_size == capacity) {
        // 数组已满,需要扩展。一个常见的策略是将容量翻倍。
        resize(capacity * 2);
    }
    // 后续的移位和插入操作与固定数组实现相同...
    // ...
}

复杂度分析:分摊分析

考虑连续向列表末尾插入N个元素的情景:

  • 大多数插入操作(当数组未满时)是廉价的,时间复杂度为 O(1)
  • 只有当数组被填满,需要调用resize时,才会发生一次昂贵的 O(N) 操作(分配新数组并复制元素)。

如果我们计算这N次操作的总成本,然后分摊到每一次操作上,那么平均每次插入操作的分摊时间复杂度是 O(1)。这种分析方法称为分摊分析(Amortized Analysis)

删除操作与数组收缩

与插入类似,删除元素后,如果数组变得“太空”,我们可以考虑收缩它以节省空间。但收缩策略需要谨慎,以避免在容量边界附近反复插入和删除导致的“抖动”现象。

一个稳健的策略是:当数组中的元素数量降至容量的 25% 时,才将容量减半。这样可以为后续可能的插入操作预留一些空间,避免立即再次调整大小。

void remove(int position) {
    // 执行删除操作...
    // ...

    // 检查是否需要收缩数组
    // 避免容量为0,并且当元素数量减少到容量的1/4时,将容量减半
    if (curr_size > 0 && curr_size == capacity / 4) {
        resize(capacity / 2);
    }
}

动态数组实现的优缺点总结

本节课中我们一起学习了列表的动态数组实现。以下是其优缺点总结:

优点:

  • 完整实现了列表ADT:可以动态处理任意数量的插入和删除操作。
  • 高效的随机访问:通过索引(get操作)访问任何元素的时间复杂度是严格的 O(1)
  • 高效的尾部操作:在列表末尾进行插入和删除操作的分摊时间复杂度是 O(1)

缺点:

  • 实现更复杂:需要处理内存分配、释放和大小调整逻辑。
  • 存在空间浪费:数组容量通常大于实际元素数量,空间利用率在25%到100%之间。
  • 低效的中间操作:在列表开头或中间进行插入或删除操作时,需要移动大量元素,时间复杂度为 O(N)

动态数组实现(例如C++中的std::vector)在随机访问和尾部操作频繁的场景下表现优异。在下一讲中,我们将探讨另一种实现方式——链表(Linked List),它将在中间插入和删除操作上提供更好的性能。

006:列表ADT的实现与比较

在本节课中,我们将学习列表抽象数据类型(ADT)的最后一种实现方式——基于链表的实现。我们将探讨单链表和双链表的工作原理,分析其优缺点,并了解C++标准库中列表容器的实现。最后,我们将通过一个实际场景比较不同实现的性能。

链表实现概述

上一节我们介绍了基于动态数组的列表实现。本节中,我们来看看基于链表的实现。链表通过节点之间的指针链接来存储数据,提供了动态插入和删除的能力。

单链表示例

以下是单链表实现的基本操作示例。

首先,声明一个空列表。内部有一个头指针head,初始指向空。

List L; // 空列表
head -> nullptr

插入第一个元素(例如23)到位置0:

  1. 创建一个新节点存储值23。
  2. 将头指针head指向这个新节点。
  3. 新节点的next指针指向空。
head -> [23|next] -> nullptr

在列表末尾插入元素(例如42):

  1. 创建一个新节点存储值42。
  2. 找到最后一个节点(当前是包含23的节点),将其next指针指向新节点。
  3. 新节点的next指针指向空。
head -> [23|next] -> [42|next] -> nullptr

在列表前端插入元素(例如99)到位置0:

  1. 创建一个新节点存储值99。
  2. 将新节点的next指针指向当前的头节点。
  3. 将头指针head指向新节点。
head -> [99|next] -> [23|next] -> [42|next] -> nullptr

从列表中移除位置1的元素(值为23):

  1. 找到位置0的节点(值为99)和位置1的节点(值为23)。
  2. 将位置0节点的next指针指向位置1节点的下一个节点(值为42的节点)。
  3. 释放位置1节点占用的内存。
head -> [99|next] -> [42|next] -> nullptr
// 节点 [23|next] 被移除

单链表代码实现

现在,我们来看看单链表的具体代码实现。代码使用C++模板以支持任意数据类型,并利用智能指针管理内存。

template <typename T>
class LinkedList {
private:
    struct Node {
        T item;
        std::unique_ptr<Node> next;
        Node(const T& value) : item(value), next(nullptr) {}
    };

    std::unique_ptr<Node> head;
    size_t size;

    Node* getNode(size_t pos) {
        assert(pos < size);
        Node* current = head.get();
        for (size_t i = 0; i < pos; ++i) {
            current = current->next.get();
        }
        return current;
    }

public:
    LinkedList() : head(nullptr), size(0) {}

    size_t getSize() const { return size; }

    T get(size_t pos) {
        if (pos >= size) throw std::out_of_range("Position out of range");
        Node* node = getNode(pos);
        return node->item;
    }

    int find(const T& target) {
        Node* current = head.get();
        for (int i = 0; current != nullptr; ++i) {
            if (current->item == target) return i;
            current = current->next.get();
        }
        return -1;
    }

    void remove(size_t pos) {
        if (pos >= size) throw std::out_of_range("Position out of range");
        if (pos == 0) {
            auto oldHead = std::move(head);
            head = std::move(oldHead->next);
        } else {
            Node* prev = getNode(pos - 1);
            auto nodeToRemove = std::move(prev->next);
            prev->next = std::move(nodeToRemove->next);
        }
        --size;
    }

    void insert(const T& value, size_t pos) {
        if (pos > size) throw std::out_of_range("Position out of range");
        auto newNode = std::make_unique<Node>(value);
        if (pos == 0) {
            newNode->next = std::move(head);
            head = std::move(newNode);
        } else {
            Node* prev = getNode(pos - 1);
            newNode->next = std::move(prev->next);
            prev->next = std::move(newNode);
        }
        ++size;
    }
};

智能指针与所有权

代码中使用std::unique_ptr管理节点内存。head指针拥有第一个节点,每个节点的next指针拥有下一个节点。当链表对象被销毁时,head指针被销毁,触发链式反应,所有节点被自动释放,避免了内存泄漏。

getNode函数返回一个原始指针Node*,用于遍历链表而不获取所有权。

双链表与优化

单链表只能单向遍历。为了提高效率,可以引入双链表和尾指针。

双链表的节点结构包含指向前一个和后一个节点的指针。

struct Node {
    T item;
    std::unique_ptr<Node> next;
    Node* prev; // 指向前一个节点的原始指针,通常不拥有所有权
};

添加尾指针tail可以直接访问链表末端,使得在末尾插入和删除操作的时间复杂度为O(1)

双链表支持反向遍历,但代码实现更复杂,因为需要维护额外的指针。

不同实现方式的比较

我们已经学习了固定数组、动态数组和链表三种列表ADT的实现方式。以下是它们的优缺点比较。

操作 固定数组 动态数组 单链表 双链表(带尾指针)
随机访问 O(1) O(1) O(n) O(n)
前端插入/删除 不支持 O(n) O(1) O(1)
末端插入/删除 不支持 O(1) 摊销 O(n) O(1)
中间插入/删除 不支持 O(n) O(n) O(n)
空间浪费 O(n) O(n) O(n)
反向遍历 支持 支持 不支持 支持
  • 动态数组:随机访问极快,末端操作高效,但中间或前端操作需要移动元素,且存在一定空间开销。
  • 链表:前端操作极快,中间操作在找到位置后也很快,但随机访问需要遍历,且指针本身带来空间开销。

C++标准库中的序列容器

C++标准库提供了几种序列容器,它们都是列表ADT的实现。

  • std::vector:基于动态数组,是默认推荐的序列容器。支持快速随机访问。
  • std::list:基于双链表。当需要频繁在任意位置插入删除,或需要反向遍历时使用。
  • std::forward_list:基于单链表。更节省内存,适用于许多短列表或空列表的场景(例如按字母分类学生)。
  • std::deque(双端队列):支持在两端高效增长,实现巧妙。

使用示例

以下是std::vectorstd::list的使用示例。

// 使用 std::vector
#include <vector>
#include <iostream>

std::vector<int> vec = {1, 2, 3};
vec.push_back(4); // 末端插入
vec.insert(vec.begin(), 0); // 前端插入
std::cout << vec[2]; // 随机访问,输出 2

for (auto& num : vec) { // 遍历
    std::cout << num << " ";
}

// 使用 std::list
#include <list>

std::list<int> lst = {1, 2, 3};
lst.push_front(0); // 前端插入
lst.push_back(4);  // 末端插入
// lst[2] // 错误!list不支持随机访问

auto it = std::find(lst.begin(), lst.end(), 2); // 查找
if (it != lst.end()) {
    lst.insert(it, 99); // 在找到的位置前插入
}

for (auto& num : lst) { // 遍历
    std::cout << num << " ";
}

理论分析与实际性能

考虑一个场景:需要持续向列表中插入随机整数,并始终保持列表有序。

  • 理论分析

    • 查找插入位置:数组和链表都是O(n)
    • 执行插入:数组需要移动元素,为O(n);链表只需修改指针,为O(1)
    • 理论上链表应更优。
  • 实际测试

    • 使用std::vector(动态数组)的性能远优于std::list(链表)。
    • 原因:现代计算机硬件具有缓存系统。数组在内存中是连续存储的,CPU访问一个元素时,会预加载附近元素到高速缓存,使得顺序访问速度极快。链表的节点在内存中是分散存储的,每次访问都可能引发缓存未命中,导致实际速度很慢。

结论:算法复杂度分析是重要的理论工具,但实际性能还受硬件(如缓存)影响。对于大多数需要顺序访问的场景,std::vector通常是更好的选择,除非有特定原因(如频繁在任意位置插入删除)需要使用链表。

总结

本节课中我们一起学习了列表ADT的链表实现。我们探讨了单链表和双链表的工作原理、代码实现及其复杂度分析。通过比较动态数组和链表,我们了解到它们各有优劣:数组胜在随机访问和缓存友好性,链表胜在任意位置的高效插入删除。最后,我们介绍了C++标准库中的相关容器(vector, list, forward_list, deque),并通过一个实例认识到理论复杂度与实际性能的差异,强调了在实践中根据具体场景选择合适数据结构的重要性。下一讲,我们将开始学习新的主题:栈和队列。

007:栈和队列

在本节课中,我们将要学习两种重要的数据结构:栈和队列。它们是列表抽象数据类型(ADT)的两种特定变体,操作上有所限制,但应用非常广泛。我们将从概念、操作、实现以及实际应用几个方面来详细探讨它们。

栈(Stack)的概念 🥞

上一节我们介绍了列表ADT的灵活性。本节中,我们来看看栈,它是一种操作受限的列表。

栈是一种数据项的序列,只允许在序列的一端进行添加和删除操作。这个操作端通常被称为“栈顶”。栈的核心思想是“后进先出”(LIFO, Last In First Out),就像一叠盘子,你总是把新盘子放在最上面,也总是从最上面取走盘子。

栈支持两个基本操作:

  • 入栈(Push):将一个数据项添加到栈顶。
  • 出栈(Pop):移除并返回栈顶的数据项。

此外,通常还会提供一些辅助操作,例如查看栈顶元素(Top)和获取栈的大小(Size)。

栈的API与实现 🔧

理解了栈的概念后,我们来看看如何用代码来描述和实现它。

一个典型的栈API(应用程序编程接口)可能包含以下方法:

  • Size(): 返回栈中元素的数量。
  • Top(): 返回栈顶元素的引用(但不移除它)。
  • Pop(): 移除栈顶元素。
  • Push(const T& item): 将一个新元素压入栈顶。

以下是两种常见的栈实现方式:

1. 基于数组/向量的实现
使用动态数组(如C++的std::vector)实现栈时,为了获得O(1)的均摊时间复杂度,我们通常在数组的末尾进行PushPop操作。以下是核心思路的伪代码表示:

class Stack {
private:
    std::vector<T> items;
public:
    void Push(const T& item) { items.push_back(item); }
    void Pop() {
        if (items.empty()) throw error;
        items.pop_back();
    }
    T& Top() {
        if (items.empty()) throw error;
        return items.back();
    }
    size_t Size() const { return items.size(); }
};

2. 基于链表的实现
使用单向链表(如C++的std::forward_list)实现栈时,为了操作高效,我们通常在链表的头部进行PushPop操作。因为链表在头部插入和删除都是O(1)操作。实现时需要注意手动维护栈的大小信息。

class Stack {
private:
    std::forward_list<T> items;
    size_t cur_size;
public:
    void Push(const T& item) {
        items.push_front(item);
        ++cur_size;
    }
    void Pop() {
        if (items.empty()) throw error;
        items.pop_front();
        --cur_size;
    }
    T& Top() {
        if (items.empty()) throw error;
        return items.front();
    }
    size_t Size() const { return cur_size; }
};

栈的应用实例 💡

我们已经了解了栈的运作原理和实现,现在来看看它在实际编程中的强大用途。

1. 符号平衡检查
编译器在解析代码时需要检查括号(圆括号、方括号、花括号)是否匹配。栈是完成此任务的理想工具。
算法简述:

  1. 创建一个空栈。
  2. 遍历代码中的每个符号(token)。
  3. 如果遇到开符号(如(, [, {),将其压入栈。
  4. 如果遇到闭符号(如), ], }):
    • 若栈为空,则报错(缺少开符号)。
    • 否则,弹出栈顶元素,检查其是否与当前闭符号匹配。若不匹配,则报错。
  5. 遍历结束后,若栈不为空,则报错(缺少闭符号)。

2. 表达式求值(后缀表达式)
我们日常使用的数学表达式是“中缀表达式”(如 4 + 5),它有时存在歧义(如运算优先级问题)。后缀表达式(或逆波兰表达式,如 4 5 +)则没有歧义,且可以方便地用栈来求值。
算法简述(用于计算后缀表达式):

  1. 创建一个空栈。
  2. 从左到右遍历表达式中的每个元素。
  3. 如果元素是操作数,将其压入栈。
  4. 如果元素是运算符,则:
    • 从栈中弹出两个操作数(注意顺序)。
    • 用该运算符对这两个操作数进行计算。
    • 将计算结果压回栈中。
  5. 遍历结束后,栈中剩下的唯一元素就是表达式的最终结果。

3. 其他常见应用

  • 函数调用栈:程序执行时,函数调用、局部变量和返回地址的管理都依赖于栈。
  • 撤销(Undo)操作:文本编辑器或图形软件中的撤销功能通常用栈来保存历史状态。
  • 浏览器历史记录:浏览器的“后退”按钮可以看作一个栈,将访问的页面依次压栈,后退时出栈。

队列(Queue)的概念 🚶‍♂️🚶‍♀️🚶

看完了栈,我们再来看看它的“近亲”——队列。它与栈相似,但遵循不同的存取规则。

队列也是一种数据项的序列,但它允许在一端(称为队尾)添加元素,在另一端(称为队首)移除元素。队列的核心思想是“先进先出”(FIFO, First In First Out),这就像现实生活中的排队,先来的人先接受服务。

队列的两个基本操作是:

  • 入队(Enqueue):在队尾添加一个元素。
  • 出队(Dequeue):从队首移除一个元素。

同样,队列API通常也包含Size()Front()(查看队首元素)等方法。

队列的实现 🛠️

与栈类似,队列也可以用不同的底层数据结构来实现。

1. 基于链表的实现
使用双向链表(如C++的std::list)可以轻松地在O(1)时间内完成队尾入队和队首出队操作。

class Queue {
private:
    std::list<T> items;
public:
    void Push(const T& item) { items.push_back(item); } // 入队
    void Pop() { // 出队
        if (items.empty()) throw error;
        items.pop_front();
    }
    T& Front() {
        if (items.empty()) throw error;
        return items.front();
    }
    size_t Size() const { return items.size(); }
};

2. 基于循环数组的实现
使用一个固定大小的数组,并维护两个索引:front(队首)和tail(队尾,指向下一个可插入位置)。当tail到达数组末尾时,可以绕回到数组开头,形成一个逻辑上的“循环”数组,从而高效利用空间。

C++标准库中的栈与队列 📚

C++标准模板库(STL)直接提供了std::stackstd::queue。它们被称为容器适配器,因为它们不是独立的底层容器,而是在现有容器(如std::deque, std::list)之上,通过限制操作接口来实现栈或队列的行为。

例如,声明一个栈:

std::stack<int> myStack; // 默认使用std::deque作为底层容器

你也可以指定底层容器:

std::stack<int, std::list<int>> myStack; // 使用std::list作为底层容器

std::queue的用法类似。

总结 🎯

本节课中我们一起学习了栈和队列这两种基础且重要的数据结构。

  • 栈(LIFO):仅允许在栈顶进行插入(Push)和删除(Pop)操作。我们探讨了其基于数组和链表的实现,并介绍了它在符号匹配、表达式求值、函数调用等场景的应用。
  • 队列(FIFO):允许在队尾插入(Enqueue),在队首删除(Dequeue)。我们了解了其基于链表和循环数组的实现思路,以及它在任务调度、消息传递等领域的用途。
  • 容器适配器:C++ STL中的std::stackstd::queue是容器适配器的典型例子,它们基于已有的序列容器,通过封装提供特定的数据访问语义。

栈和队列是构建更复杂算法和系统的基石,理解它们对于学习数据结构与算法至关重要。

008:树

在本节课中,我们将要学习一种新的数据结构——树。我们将了解树的基本概念、术语、用途以及两种常见的实现方式。树是理解后续更复杂数据结构(如二叉搜索树)的基础。

概述:为什么需要树?

在深入探讨树的细节之前,我们首先需要理解为什么树这种数据结构是有用的。为此,我们需要讨论数据搜索。

数据搜索,顾名思义,非常直观。你有一个项目的集合,每个项目通常由一个键和一个值来描述,你希望在该集合中搜索特定的项目。

一个例子是DNS查询。当你在浏览器地址栏输入类似 ucdavis.edu 的域名并按下回车时,这个域名本身在互联网上没有直接意义。它首先被发送到一个特定的服务器,即DNS服务器。该服务器拥有所有域名及其对应IP地址的完整列表。DNS服务器会返回该域名对应的IP地址(例如 23.185.0.4),然后你的浏览器才会向这个IP地址发送网络请求以获取网页。

在这个例子中,键是域名,需要返回的值是IP地址。在计算机科学和编程中,数据搜索是一项至关重要的操作。此外,还有一些相关的操作,例如:

  • 确定最大值/最小值:例如,在成绩列表中找出GPA最高的学生。
  • Floor函数:给定一个键,寻找集合中小于或等于该键的最大键。
  • Ceiling函数:给定一个键,寻找集合中大于或等于该键的最小键。
  • 排名:给定一个键,确定它在集合中的排名。

问题在于,我们如何使搜索和这些相关操作尽可能高效?随着集合规模增大,我们需要找到复杂度良好的算法。一个子问题是:我们可以使用什么样的数据结构来帮助改进搜索效率?

回顾已知的数据结构与算法

上一节我们介绍了数据搜索的重要性,本节中我们来看看我们已经学过的数据结构和算法如何实现搜索。

我们从最明显的搜索方式开始:线性搜索。以下是线性搜索在不同数据结构上的表现:

  • 线性搜索(顺序搜索):在一个无序集合中,从第一个项目开始,依次检查每个项目,直到找到目标键。
    • 数据结构:无序数组、无序链表、有序链表。
    • 搜索复杂度O(n)
    • 插入复杂度:无序数组/链表为 O(1);有序链表为 O(n)
    • 删除复杂度:对于链表,找到节点后移除为 O(1);对于无序数组,可以通过将最后一个元素移到被删除位置来实现 O(1) 的删除。
    • 查找最大/最小值复杂度:无序集合为 O(n);有序链表为 O(1)

如果我们知道集合已经排序,我们可以使用二分搜索。以下是二分搜索的特点:

  • 二分搜索:在有序集合中,每次比较中间元素,根据结果丢弃一半的剩余数组,直到找到目标。
    • 数据结构:仅适用于有序数组。
    • 搜索复杂度O(log n)
    • 插入复杂度O(n)(需要找到正确位置并可能移动元素)。
    • 删除复杂度O(n)(需要移动元素)。
    • 查找最大/最小值复杂度O(1)

性能总结与理想目标

上一节我们回顾了线性和二分搜索,现在我们来总结一下它们的性能,并看看能否找到更优的方案。

以下是四种数据结构在不同操作下的复杂度总结:

操作 无序数组 无序链表 有序链表 有序数组
搜索 O(n) O(n) O(n) O(log n)
插入 O(1) O(1) O(n) O(n)
删除 O(n) O(n) O(n) O(n)
查找最大/最小值 O(n) O(n) O(1) O(1)

我们可以看到,没有一种数据结构在所有操作上都是最优的。它们各自在某些操作上表现良好,而在其他操作上则效率较低。

理想情况下,我们希望找到一种数据结构,使得搜索、插入、删除、查找最大/最小值等所有操作都能在 O(log n) 的复杂度内完成。事实上,这种数据结构是存在的,它被称为二叉搜索树。我们将在未来的课程中详细讨论它。但在学习二叉搜索树之前,我们需要先理解树的一般概念、术语和基本属性。

树的基本概念与术语

上一节我们指出了现有数据结构的局限性,并引出了树的概念。本节中,我们将正式学习树是什么以及描述它的专业术语。

树是由节点通过连接而成的数据结构。它有一个位于顶部的根节点,边将根节点连接到其他节点,以此类推。定义树的一个关键约束是:一个节点只能被连接到另一个节点,并且连接是单向的(不能形成环)。树是一种高效的数据结构,可用于实现列表、映射、集合、优先队列等多种抽象数据类型。

以下是描述树结构所需的核心术语:

  • :位于树顶部的节点,没有父节点。
  • :连接两个节点的链接。一个有 n 个节点的树,必然有 n-1 条边。
  • 父节点:一个节点的直接祖先(通过一条边相连)。例如,E 是 I 和 J 的父节点。
  • 子节点:拥有父节点的节点。例如,D 是 B 的子节点。每个节点(除了根)都是另一个节点的子节点。
  • 祖先与后代:比父/子关系更广泛。如果两个节点之间存在一条路径,那么上方的节点是祖先,下方的节点是后代。例如,B 是 J 的祖先,J 是 B 的后代。
  • 兄弟节点:拥有相同父节点的节点。例如,B 和 C 是兄弟,D、E 和 F 是兄弟。
  • 叶节点(外部节点):没有子节点的节点,位于树的“外围”。
  • 空节点:在一些文献中,为了明确表示没有子节点,可能会用特殊的“空”节点来表示。在编程中,这通常对应着 nullNone 指针。
  • 内部节点:至少有一个子节点的节点(即非叶节点)。
  • 节点的度:一个节点拥有的子节点数量。例如,节点 A 的度为 2,节点 B 的度为 3。叶节点的度总是 0。
  • 树的度:树中所有节点的度的最大值。
  • :根节点位于第 1 层,其子节点位于第 2 层,以此类推。同一层的节点可以看作是“同辈”。
  • 路径:连接两个节点的节点和边的序列。例如,A 到 E 的路径是 A -> B -> E。
  • 路径长度:路径上边的数量。例如,A 到 E 的路径长度为 2。
  • 节点的深度:从根节点到该节点的路径长度。根的深度为 0。
  • 树的高度/深度:从根节点到最远叶节点的路径长度。一个单独节点的树高度为 0。
  • 子树:树中的任意节点及其所有后代构成一棵子树,它本身也具有树的所有性质。

理解这些术语至关重要,因为它们将在后续讨论树的算法时被频繁使用。

树的实现方式

上一节我们学习了树的术语,本节中我们来看看如何在程序中实现树结构。

第一种可能最直观的实现方式是使用数组表示子节点

在这种实现中,我们定义一个 Tree 类,它包含一个指向根节点的指针(类似于链表的头指针)。每个 Node 包含数据项和一个用于存储其所有子节点的数组(例如,C++中的 vector<Node*>)。这样,节点 A 的子节点数组包含 B 和 C,节点 B 的子节点数组包含 D、E 和 F。

然而,这种方法的缺点是可能浪费大量空间。即使一个叶节点没有子节点,它仍然分配了一个(可能初始为空但已分配内存的)数组来存储子节点,这对于有许多叶节点的大树来说是一种内存开销。

另一种更节省空间的实现方式是使用链表,具体称为孩子兄弟表示法

在这种实现中,每个 Node 包含:

  1. 数据项。
  2. 一个指向其第一个子节点的指针。
  3. 一个指向其下一个兄弟节点的指针。

通过这种方式,整个树结构被组织成两层链表:纵向是父子关系(通过“第一个子节点”指针),横向是兄弟关系(通过“下一个兄弟节点”指针)。例如,根节点 A 的“第一个子节点”指向 B,B 的“下一个兄弟”指向 C。B 的“第一个子节点”指向 D,D 的“下一个兄弟”指向 E,E 的“下一个兄弟”指向 F,以此类推。

这种表示的优点是内存使用非常高效,只为实际存在的节点关系分配指针,并且可以轻松处理任意度的树。

总结

本节课中,我们一起学习了树这种重要的数据结构。我们从数据搜索的需求出发,分析了现有线性结构的局限性,从而引出了对更高效数据结构——树的探索。我们详细介绍了树的基本组成部分和各种术语(如根、节点、边、父节点、子节点、深度、高度等),这些是理解更复杂树形结构的基础。最后,我们探讨了树的两种实现方式:使用子节点数组和孩子兄弟链表表示法,并分析了它们各自的优缺点。在接下来的课程中,我们将深入探讨一种特殊的树——二叉树。

009:树(第二部分)

在本节课中,我们将继续学习树结构。我们将重点介绍一种特殊的树——二叉树,学习其定义、类型、遍历方法以及两种不同的实现方式。

二叉树定义与类型

上一节我们介绍了树的基本术语,如节点、边、根节点、父节点和子节点。本节中我们来看看一种有特定约束的树:二叉树。

二叉树是一种树结构,其核心约束是:每个节点最多只能有两个子节点。我们通常称它们为左子节点和右子节点。如果一个树的节点拥有超过两个子节点,那么它就不是二叉树。

以下是几种不同类型的二叉树:

  • 满二叉树:在满二叉树中,每个节点要么有0个子节点(叶节点),要么有2个子节点。关键点在于,不能有只拥有1个子节点的节点。
  • 完全二叉树:在完全二叉树中,除了最后一层,其他所有层都被完全填满。并且,最后一层的所有节点都尽可能地向左排列。这意味着,如果要插入新节点,必须优先填充最左侧的空缺位置。
  • 完美二叉树:在完美二叉树中,所有内部节点都有两个子节点,并且所有叶节点都在同一层。这是一个非常规整的结构。

需要注意的是,计算机科学中术语有时定义并不完全统一。例如,有些资料可能将“完美二叉树”称为“完全二叉树”。在本课程中,我们采用上述定义。

树的遍历

对于列表或数组,我们可以很自然地按顺序遍历所有元素。但对于树结构,如何系统地访问所有节点呢?这被称为树的遍历。主要有两种遍历策略:

  1. 深度优先遍历:从根节点开始,沿着一条路径尽可能深地探索,直到到达叶节点,然后再回溯探索其他分支。
  2. 广度优先遍历:从根节点开始,逐层访问节点,先访问第一层(根节点),然后是第二层,依此类推。

深度优先遍历本身又包含几种不同的具体算法,它们都基于三个基本操作:访问当前节点(N)、递归遍历左子树(L)、递归遍历右子树(R)。根据这三个操作的执行顺序,产生了不同的遍历方式。

以下是三种主要的深度优先遍历算法:

  • 前序遍历:执行顺序是 NLR。即先访问当前节点,然后递归遍历左子树,最后递归遍历右子树。
    • 对于示例树,遍历结果为:A, B, D, H, I, E, C, F, J, G。
  • 中序遍历:执行顺序是 LNR。即先递归遍历左子树,然后访问当前节点,最后递归遍历右子树。
    • 对于示例树,遍历结果为:H, D, I, B, E, A, J, F, C, G。
  • 后序遍历:执行顺序是 LRN。即先递归遍历左子树,然后递归遍历右子树,最后访问当前节点。
    • 对于示例树,遍历结果为:H, I, D, E, B, J, F, G, C, A。

一个记忆这些遍历顺序的巧妙方法是“帆船法”。想象每个节点是一个岛屿,你驾驶帆船从根节点的左侧出发,沿着海岸线(节点和边的外围)航行一圈,最终回到根节点的右侧。

  • 前序遍历:帆船每次到达一个岛屿的西侧(左侧)时,就访问该岛屿。
  • 中序遍历:帆船每次到达一个岛屿的南侧(下方)时,就访问该岛屿。
  • 后序遍历:帆船每次到达一个岛屿的东侧(右侧)时,就访问该岛屿。

广度优先遍历通常只有一种主要算法:

  • 层序遍历:从根节点开始,逐层访问所有节点。
    • 对于示例树,遍历结果为:A, B, C, D, E, F, G, H, I, J。

需要强调的是,没有一种遍历顺序是“唯一正确”的。不同的遍历顺序适用于不同的应用场景。

二叉树的实现(基于节点)

现在让我们看看如何在C++中实现一个二叉树。一种常见的方式是使用节点结构,这与链表类似,但每个节点有两个指针。

一个基本的二叉树节点类可能如下所示:

template <typename T>
class BinaryTreeNode {
public:
    T item; // 节点存储的数据
    std::unique_ptr<BinaryTreeNode> left; // 指向左子节点的智能指针
    std::unique_ptr<BinaryTreeNode> right; // 指向右子节点的智能指针
    // ... 构造函数等
};

std::unique_ptr 用于自动管理内存,确保当父节点被销毁时,其子节点也会被正确释放,从而避免内存泄漏。整个树由一个指向根节点的 root 指针开始。

基于这种结构,我们可以用递归的方式简洁地实现遍历算法。以下是前序遍历的递归实现示例:

void preOrderTraversal(BinaryTreeNode<T>* node) {
    if (node == nullptr) {
        return; // 基线条件:到达空节点
    }
    process(node); // 访问当前节点 (N)
    preOrderTraversal(node->left.get()); // 递归遍历左子树 (L)
    preOrderTraversal(node->right.get()); // 递归遍历右子树 (R)
}
// 调用方式:preOrderTraversal(root.get());

中序和后序遍历只需调整 process(node) 这行代码的位置即可。递归实现虽然简洁,但需要注意深度过大的树可能导致调用栈溢出。迭代实现(通常借助栈或队列数据结构)可以避免这个问题。

二叉树的实现(基于数组)

除了基于指针的节点实现,二叉树还可以用数组来表示。在这种表示法中,数组的每个索引位置对应树中的一个节点。

假设根节点存储在索引 0 处,那么对于数组中任意索引为 i 的节点,我们可以通过公式找到它的家庭成员:

  • 左子节点索引leftChildIndex = 2 * i + 1
  • 右子节点索引rightChildIndex = 2 * i + 2
  • 父节点索引parentIndex = (i - 1) / 2 (整数除法)

这种表示法有一个很好的特性:如果二叉树是一棵完全二叉树,那么按数组索引顺序遍历,就相当于进行了一次层序遍历。并且,这种实现方式没有指针开销,内存利用率高,访问速度快。它常用于实现“二叉堆”这种数据结构,进而用于实现“优先队列”。

总结

本节课中我们一起学习了二叉树的进阶知识。我们明确了二叉树的定义及其不同类型(满、完全、完美)。我们深入探讨了树的遍历,包括深度优先(前序、中序、后序)和广度优先(层序)策略,并学习了用“帆船法”来形象记忆深度优先遍历的顺序。最后,我们了解了二叉树的两种实现方式:基于节点指针的链式结构和基于数组的顺序结构,并分析了它们各自的特点和适用场景。这些概念是理解和应用更复杂树形结构(如二叉搜索树、堆)的重要基础。

010:二叉搜索树 🌳

在本节课中,我们将要学习一种非常高效的数据结构——二叉搜索树。它是一种特殊的二叉树,专门用于存储可比较的键值,并支持快速的查找、插入和删除操作。我们将从回顾二叉树的基础知识开始,然后深入探讨二叉搜索树的定义、性质及其核心操作的实现。

回顾:二叉树

上一节我们介绍了二叉树,即每个节点最多有两个子节点的树结构。我们讨论了遍历二叉树的几种不同方式:前序遍历、中序遍历、后序遍历(这三种属于深度优先遍历)以及层序遍历(属于广度优先遍历)。我们还提到了两种主要的实现方式:基于节点的实现(使用指针连接子节点)和基于数组的实现(通过索引计算节点关系)。在接下来几周,我们将主要关注基于节点的实现。

二叉搜索树的定义

二叉搜索树是一种特殊的二叉树,它在普通二叉树的基础上增加了一个非常重要的约束条件:对于树中的任意一个节点,其左子树中所有节点的键值都必须小于该节点的键值,而其右子树中所有节点的键值都必须大于该节点的键值。

这个定义是递归的。例如,对于根节点,其左子树的所有键值必须小于根节点的键值,右子树的所有键值必须大于根节点的键值。这个规则同样适用于树中的每一个节点。

二叉搜索树的API

以下是二叉搜索树通常提供的公共方法接口(API),你会发现它与列表ADT的许多方法相似:

  • contains(key): 检查树中是否包含给定的键。返回布尔值。
  • max(): 返回树中的最大键值。
  • min(): 返回树中的最小键值。
  • insert(key): 向树中插入一个新的键值。
  • remove(key): 从树中删除一个键值。
  • print(): 以某种顺序打印树的内容。这里我们选择中序遍历。

需要注意的是,列表ADT中的 get(position) 方法在这里没有意义,因为树结构没有明确的“位置”概念。

内部数据结构

我们采用基于节点的实现方式。BinarySearchTree 类包含一个指向根节点的指针。每个 Node 包含三个部分:一个可比较的键值 key,以及指向其左子节点和右子节点的指针。

此外,我们还会定义一些私有的递归辅助函数,用于实现上述的公共API,例如用于查找最小值的递归函数。

实现核心操作

现在我们已经定义了数据结构和需要实现的方法,让我们开始逐一实现这些核心操作。

查找操作 (contains)

contains 方法用于判断给定的键是否存在于树中。我们采用迭代的方式实现。

算法从根节点开始,使用一个指针 n 遍历树。在每一步,我们将目标键与当前节点 n 的键进行比较:

  • 如果相等,则找到目标,返回 true
  • 如果目标键小于当前节点的键,根据二叉搜索树的性质,目标只可能存在于左子树中,因此我们将指针 n 移动到左子节点。
  • 如果目标键大于当前节点的键,则目标只可能存在于右子树中,因此我们将指针 n 移动到右子节点。
  • 如果指针 n 变为 null,说明已经到达了叶子节点仍未找到目标,则返回 false

这个过程避免了像在列表中那样线性扫描所有元素,效率更高。

查找最大值 (max)

查找树中的最大值非常简单。根据二叉搜索树的性质,最大值一定位于最右侧的路径上。

我们使用迭代方法:从根节点开始,只要当前节点存在右子节点,就不断将指针移动到右子节点。当到达一个没有右子节点的节点时,该节点的键值就是树中的最大值。

在包含9个节点的树中,我们只需访问3个节点就能找到最大值,这比遍历整个列表(需要检查所有n个元素)要高效得多。

查找最小值 (min)

查找最小值的逻辑与查找最大值对称,即沿着最左侧的路径查找。这里我们使用递归方法来实现,一方面是为了展示递归实现,另一方面也为后续的删除操作做准备。

公共的 min() 方法调用一个私有的递归辅助函数 _min(node)。该函数接收一个节点指针:

  • 如果该节点有左子节点,则递归地在左子节点上调用 _min
  • 如果该节点没有左子节点,那么它就是当前子树中的最小节点,将其返回。

最终,公共方法从返回的节点中提取键值并返回。

插入操作 (insert)

插入操作需要维护二叉搜索树的性质。我们采用递归方式实现。

公共的 insert(key) 方法调用私有的递归辅助函数 _insert(node, key)。递归函数处理以下几种情况:

  1. 如果当前节点 nodenull,说明找到了插入位置,在此处创建一个包含 key 的新节点。
  2. 如果 key 小于当前节点的键,则递归地在左子树中插入。
  3. 如果 key 大于当前节点的键,则递归地在右子树中插入。
  4. 如果 key 等于当前节点的键,说明键已存在,不进行任何操作(或根据需求处理重复键)。

关键在于,递归调用时传递的是对子节点指针的引用,这样在找到插入位置(null)时,新创建的节点才能正确地被链接到其父节点上。

打印树 (print)

我们实现一个 print 方法,以中序遍历的顺序输出每个节点的键值及其所在的层级。这有助于可视化树的结构。

公共的 print() 方法调用一个私有的递归辅助函数 _print(node, level),该函数执行中序遍历:

  1. 递归遍历左子树,层级加1。
  2. 打印当前节点的键值和当前层级。
  3. 递归遍历右子树,层级加1。

一个有趣的特点是,结合中序遍历的输出和节点的层级信息,可以唯一地重构出原始的树结构。而仅凭前序或后序遍历的输出则无法做到这一点,通常需要两种遍历顺序的组合才能重构。

删除操作 (remove)

删除操作是二叉搜索树中最复杂的操作,因为它需要在删除节点后仍然保持树的性质。我们将在下一节中详细讨论其实现策略。

总结

本节课我们一起学习了二叉搜索树。我们了解了它的定义:一种左子树键值均小于节点、右子树键值均大于节点的特殊二叉树。我们探讨了其核心操作的实现原理:

  • contains 利用树的性质进行高效查找。
  • maxmin 通过遍历最右或最左路径快速找到极值。
  • insert 通过递归找到合适位置并插入新节点,同时维护树的性质。
  • 我们还看到了一个能输出节点层级的 print 方法。

这些操作的高效性(通常优于线性列表)使得二叉搜索树成为实现“集合”(Set)或“映射”(Map)等抽象数据类型的理想底层结构。在接下来的课程中,我们将完成删除操作的实现,并深入分析二叉搜索树的性能。

011:二叉搜索树(删除操作与复杂度分析)

在本节课中,我们将学习二叉搜索树的最后一个核心操作——删除节点。我们将详细探讨删除节点的三种不同情况,并分析二叉搜索树各种操作的时间复杂度,最后引出对自平衡树的需求。

二叉搜索树回顾

上一节我们定义了二叉搜索树。它是一个二叉树,其中每个节点最多有两个子节点:一个左子节点和一个右子节点。二叉搜索树的关键特性是:对于树中的任意节点,其左子树中的所有键值都小于该节点的键值,而其右子树中的所有键值都大于该节点的键值。这个性质适用于树中的每一个节点。

删除节点策略

删除节点是二叉搜索树操作中较为复杂的一个。我们首先来看一种简单的策略:标记删除法。

标记删除法

一种删除策略并非真正移除节点,而是将其标记为“已停用”。例如,要删除键值93的节点,我们可以在节点内部设置一个布尔标志,表示该节点已失效。节点本身仍保留在树的结构中,用于维持搜索路径。如果后续再次插入键值93,只需重新激活该节点即可。

这种方法实现简单,适用于删除操作极少发生的情况。然而,如果插入和删除操作频繁,树中将积累大量无效节点,浪费内存并使树结构膨胀。因此,我们通常需要真正的删除策略。

真正的删除操作

真正的节点删除分为三种情况。以下是每种情况的说明:

  1. 删除叶节点:要删除的节点没有子节点。这是最简单的情况。
  2. 删除仅有一个子节点的内部节点:要删除的节点有一个子节点。这种情况稍复杂,但处理逻辑清晰。
  3. 删除有两个子节点的内部节点:要删除的节点有两个子节点。这是最复杂的情况,需要特殊的算法。

接下来,我们将逐一详细分析这三种情况。

删除叶节点

假设我们有一个二叉搜索树,想要删除键值为2的节点,并且该节点是一个叶节点。

操作步骤如下

  1. 找到该节点的父节点(例如节点18)。
  2. 将父节点指向该叶节点的指针(左指针或右指针)设置为 nullptr
  3. 释放该叶节点的内存。

这个过程类似于从链表中删除一个节点,非常简单。

删除仅有一个子节点的内部节点

现在,考虑删除键值为18的节点,它只有一个右子节点42。

操作步骤如下

  1. 找到该节点的父节点(例如节点43)。
  2. 将父节点指向该节点的指针(例如左指针),重新指向该节点的唯一子节点(节点42)。
  3. 释放该节点的内存。

这样,节点18就从树中被移除,其子节点42直接连接到了原来的祖父节点43上。

删除有两个子节点的内部节点

这是最具挑战性的情况。例如,要删除根节点51,它同时拥有左子节点43和右子节点93。

核心问题:删除该节点后,需要用哪个节点来替代它的位置,才能保持二叉搜索树的性质?

解决方案:有两种合法的候选值可以替代被删除的节点。

  • 左子树中的最大值:即节点43。
  • 右子树中的最小值:即节点54(它是右子树93中的最小节点)。

通常,我们采用后者,即寻找右子树中的最小节点(也称为后继节点)。

算法步骤如下

  1. 找到要删除节点 D(51)的右子树中的最小节点 M(54)。这个节点 MD 的后继。
  2. 将节点 M 的键值复制到节点 D 中。现在,节点 D 的键值变成了54。
  3. 此时,原来的节点 M(键值54)成为了冗余。我们需要在右子树中递归地删除这个键值为54的节点。
  4. 删除节点 M 时,它本身只可能有两种情况:它是叶节点,或它只有一个子节点(因为它已经是右子树中的最小节点,不可能有左子节点)。在我们的例子中,节点54只有一个右子节点74。
  5. 按照“删除仅有一个子节点的内部节点”的规则,将节点93指向节点54的指针,改为指向节点74,然后删除节点54。

通过这个“复制后继键值并递归删除后继节点”的策略,我们成功删除了拥有两个子节点的根节点。

删除操作代码实现

理解了上述逻辑后,我们来看代码实现。公共的 remove 方法很简单,它调用一个私有的递归函数。

public void remove(Key key) {
    root = remove(root, key);
}

private Node remove(Node x, Key key) {
    if (x == null) return null; // 未找到键值
    int cmp = key.compareTo(x.key);
    if (cmp < 0) {
        x.left = remove(x.left, key); // 在左子树中递归查找并删除
    } else if (cmp > 0) {
        x.right = remove(x.right, key); // 在右子树中递归查找并删除
    } else { // 找到要删除的节点 x
        // 情况1 & 2: 节点有0个或1个子节点
        if (x.right == null) return x.left;
        if (x.left == null) return x.right;

        // 情况3: 节点有2个子节点
        Node t = x; // 临时保存要删除的节点
        x = min(t.right); // 找到右子树中的最小节点作为后继
        x.right = deleteMin(t.right); // 删除右子树中的最小节点,并更新x的右链接
        x.left = t.left; // 保持x的左链接为原节点的左子树
    }
    // 更新子树大小等属性(如果存在)
    // x.size = size(x.left) + size(x.right) + 1;
    return x;
}

private Node deleteMin(Node x) {
    if (x.left == null) return x.right; // 找到最小节点(无左子节点),返回其右子树
    x.left = deleteMin(x.left); // 递归地在左子树中查找并删除最小节点
    // 更新大小
    // x.size = size(x.left) + size(x.right) + 1;
    return x;
}

代码的核心是一个递归函数。它首先定位要删除的节点。如果该节点有两个子节点,则执行上述“寻找后继、复制键值、递归删除后继”的算法。如果该节点只有一个子节点或没有子节点,则直接返回其非空子节点(或 nullptr),从而在递归回溯时完成链接的更新和节点的删除。

复杂度分析

对于二叉搜索树,大多数操作(查找、插入、删除、查找最大/最小值)的性能关键取决于树的深度(高度),而不是节点总数。

  • 理想情况(平衡树):如果树是平衡的(左右子树节点数大致相等),深度 D 与节点数 N 成对数关系,即 D ≈ log₂(N)。此时,上述操作的时间复杂度为 O(log N),效率很高。
  • 最坏情况(退化为链表):如果插入的键值恰好是已排序的(升序或降序),二叉搜索树会退化成一条链表。此时深度 D 等于节点数 N,所有操作的时间复杂度退化为 O(N),与普通链表无异。

以下是对比:

  • 二叉搜索树:操作复杂度为 O(D)D 为深度。在平衡状态下为 O(log N)
  • 链表:查找、插入、删除等操作复杂度为 O(N)

遍历操作(如前序、中序、后序遍历)需要访问所有节点,其时间复杂度始终为 O(N),这是无法优化的。

删除操作对平衡性的影响

我们之前讨论的删除策略(总是用右子树的最小节点替代)存在一个问题:它可能导致树逐渐左倾。因为每次都从右子树取节点放到父节点位置,长期来看右子树节点会减少。

缓解策略

  1. 随机选择后继或前驱:删除时,随机选择用右子树的最小节点(后继)左子树的最大节点(前驱)来替代被删除的节点。这有助于避免树向一侧过度倾斜,但无法提供严格的平衡保证。
  2. 使用自平衡二叉搜索树:这是根本的解决方案。这类数据结构(如AVL树、红黑树)在插入和删除节点时会通过旋转等操作自动调整树的结构,确保树始终保持大致平衡,从而将深度维持在 O(log N) 的水平。

总结

本节课我们一起学习了二叉搜索树最后的难点——删除操作。我们详细分析了删除叶节点、删除单子节点和删除双子节点这三种情况及其处理逻辑,并查看了大致的代码实现框架。接着,我们探讨了二叉搜索树操作的时间复杂度,认识到其性能高度依赖于树的深度,进而引出树平衡的重要性。最后,我们指出了标准删除操作可能破坏平衡性的问题,并为下一课要学习的自平衡二叉搜索树做好了铺垫。自平衡树能够保证在最坏情况下也能提供 O(log N) 的操作效率,是实践中广泛应用的数据结构。

012:平衡树 🌲

在本节课中,我们将要学习平衡树的概念,特别是AVL树。我们将探讨为什么需要平衡树,如何定义平衡,以及如何通过旋转操作来维护树的平衡性,从而保证搜索、插入和删除等操作的高效性。

回顾:树的高度与性能

上一节我们介绍了树、二叉树和二叉搜索树的基本概念。本节中,我们来看看树的一个关键特性——高度,以及它与操作性能的关系。

对于一棵有 n 个节点的二叉树,其高度 h 与节点数量 n 的关系至关重要。在一个完美或接近完美的二叉树中,每一层的节点数大约是上一层的两倍。

以下是各层节点数与深度的关系:

  • 深度为 0 的层(根节点)有 2^0 = 1 个节点。
  • 深度为 1 的层有 2^1 = 2 个节点。
  • 深度为 2 的层有 2^2 = 4 个节点。
  • 以此类推,深度为 h 的层有 2^h 个节点。

因此,一棵完美二叉树的总节点数 n 近似等于 2^(h+1) - 1。由此可以推导出,树的高度 h 与节点数 n 的对数成正比:

公式:h ≈ log₂(n)

在二叉搜索树中,查找、插入、删除等操作通常从根节点开始,沿着一条路径向下进行。如果树的高度是 log(n),那么这些操作的时间复杂度就是 O(log n)。然而,这有一个重要前提:树必须是平衡的。如果树退化成链表,高度就变成了 n,时间复杂度也随之恶化成 O(n)

什么是平衡?⚖️

核心问题在于:如何确保二叉搜索树始终保持平衡,从而保证操作的高效性?让我们先看几种不完善的平衡思路。

以下是几种可能但效果不佳的平衡方案:

  • 方案一:根节点左右子树节点数相等。这比单边链表好,但无法保证子树内部的平衡,深度可能依然很大。
  • 方案二:根节点左右子树高度相等。这避免了单边深度过大,但两边仍可能各自是链表,整体性能不理想。
  • 方案三:每个节点的左右子树高度都相等。这确实能保证完美平衡,但过于严格。插入一个新节点就可能破坏这种“完美性”,缺乏灵活性。

因此,我们需要一个平衡条件。这个条件应该:

  1. 接近完美平衡,但不那么严格。
  2. 易于维护和实现。
  3. 能保证树的高度始终与 log(n) 成正比。

AVL树:一种自平衡二叉搜索树 🌳

接下来,我们介绍第一种,也是最著名的平衡二叉搜索树之一:AVL树。它由 Adelson-Velsky 和 Landis 在 1962 年提出,其核心思想是为每个节点引入一个平衡因子

平衡因子的定义

对于树中的任意节点,我们定义:

  • 高度:从该节点到其最远叶子节点的路径上的边数。叶子节点的高度为 0,空节点(null)的高度定义为 -1。
  • 平衡因子:该节点左子树高度减去其右子树高度所得的差值。

公式:balance_factor(node) = height(node.left) - height(node.right)

在AVL树中,要求每个节点的平衡因子只能是 -1、0 或 1。只要有一个节点的平衡因子超出这个范围,这棵树就不是AVL树。

AVL树示例与验证

让我们通过一个例子来理解。考虑一棵二叉搜索树,我们计算每个节点的高度和平衡因子。

以下是计算过程:

  1. 所有叶子节点(如 51, 74, 99)高度为 0,平衡因子为 0(因为左右子树都是空节点,高度均为 -1, (-1) - (-1) = 0)。
  2. 节点 18:左子树高 0,右子树高 0,平衡因子为 0 - 0 = 0
  3. 节点 54:左子树高 0,右子树高 0,平衡因子为 0 - 0 = 0
  4. 节点 93:左子树(节点54)高 1,右子树(节点99)高 0,平衡因子为 1 - 0 = 1
  5. 根节点 43:左子树(节点18)高 1,右子树(节点93)高 2,平衡因子为 1 - 2 = -1

这棵树所有节点的平衡因子都在 [-1, 0, 1] 范围内,因此它是一棵AVL树。

失衡与重平衡:旋转操作 🔄

查询操作(如查找、求最大最小值)在AVL树中与普通BST完全相同,且由于平衡性保证,其时间复杂度稳定为 O(log n)

可能破坏平衡的操作是插入删除。这些操作可能改变树的结构,导致某些节点的平衡因子变为 +2 或 -2。此时,我们需要通过旋转操作来重新平衡这棵树。

单旋转(左旋与右旋)

存在两种基本的单旋转场景。

场景一:左子树过高(平衡因子为 +2),且左孩子的平衡因子为 +1 或 0(同侧失衡)

  • 操作:对失衡节点 z 及其左孩子 x 进行一次右旋转
  • 效果x 成为新的局部根节点,z 成为 x 的右孩子。x 原来的右子树 U/V 变为 z 的左子树。旋转后,xz 的平衡因子均变为合法值(0 或 ±1)。

场景二:右子树过高(平衡因子为 -2),且右孩子的平衡因子为 -1 或 0(同侧失衡)

  • 操作:对失衡节点 x 及其右孩子 z 进行一次左旋转
  • 效果z 成为新的局部根节点,x 成为 z 的左孩子。z 原来的左子树 U/V 变为 x 的右子树。旋转后,xz 的平衡因子均变为合法值。

双旋转(左右旋与右左旋)

当失衡节点与其较重子节点的平衡因子符号相反时,需要进行双旋转。

场景三:左子树过高(平衡因子为 +2),但左孩子的平衡因子为 -1(异侧失衡)

  • 操作
    1. 先对失衡节点 z 的左孩子 x 及其右孩子 y 进行一次左旋转。这使 x, y, z 三点排成一条直线(y 在中间)。
    2. 再对 z 和新上来的 y 进行一次右旋转
  • 效果y 成为新的局部根节点,xz 分别成为其左右孩子。树恢复平衡。

场景四:右子树过高(平衡因子为 -2),但右孩子的平衡因子为 +1(异侧失衡)

  • 操作
    1. 先对失衡节点 x 的右孩子 z 及其左孩子 y 进行一次右旋转
    2. 再对 x 和新上来的 y 进行一次左旋转
  • 效果y 成为新的局部根节点,xz 分别成为其左右孩子。树恢复平衡。

插入操作的重平衡流程

AVL树的插入操作在递归回溯阶段进行重平衡检查:

  1. 像普通BST一样,递归地找到插入位置并创建新节点。
  2. 递归调用开始返回(回溯)。
  3. 在回溯到每个父节点时:
    • 更新该节点的高度。
    • 计算其平衡因子。
    • 如果平衡因子为 +2 或 -2,根据其子节点的平衡因子判断属于上述四种场景中的哪一种,并执行相应的(单或双)旋转操作。
  4. 回溯到根节点,整棵树恢复AVL平衡性质。

总结

本节课中我们一起学习了平衡树的核心动机和AVL树的具体实现机制。

我们了解到,为了保证二叉搜索树操作的高效性(O(log n)),必须控制树的高度。AVL树通过一个巧妙的平衡条件——每个节点的左右子树高度差不超过1——来实现这一目标。我们深入探讨了平衡因子的概念,以及当插入或删除操作破坏平衡时,如何通过单旋转双旋转来修复树的结构。这些旋转操作在保持二叉搜索树性质的前提下,重新调整节点位置,使树恢复平衡。

AVL树是众多平衡树方案中的经典之作,它奠定了自平衡数据结构的基础。在接下来的课程中,我们将探讨其他类型的平衡树及其实现。

013:平衡树实现详解 🧑‍💻

在本节课中,我们将要学习AVL平衡树的C++实现。我们将从回顾AVL树的基本概念开始,然后深入探讨其核心数据结构的修改、旋转操作的实现,以及插入和删除节点后如何通过回溯(retrace)来维持树的平衡。

概述

上一节我们介绍了AVL树的概念,它是一种通过平衡因子来约束二叉树形状的自平衡二叉搜索树。本节中,我们来看看如何用C++代码来实现它。实现的关键在于修改节点结构、实现旋转操作,并在每次修改树结构后执行回溯以检查并修复平衡。

节点结构与辅助函数

与普通的二叉搜索树相比,AVL树的节点需要额外存储一个信息:节点的高度。这有助于我们计算平衡因子。

以下是节点类的基本结构:

class Node {
public:
    int key;
    std::unique_ptr<Node> left;
    std::unique_ptr<Node> right;
    int height; // 新增字段:节点高度
};

AVL树类本身只维护一个根节点指针,并需要一些内部辅助函数。

我们需要四个核心的内部辅助方法:

  1. height(Node*): 获取节点高度。
  2. rotateRight(std::unique_ptr<Node>&): 在指定节点执行右旋转。
  3. rotateLeft(std::unique_ptr<Node>&): 在指定节点执行左旋转。
  4. retrace(std::unique_ptr<Node>&): 回溯并重新平衡树。

计算高度

height函数非常简单。它接收一个节点指针,如果指针不为空,则返回该节点的height字段值。这里有一个特殊情况:如果指针是nullptr(即一个空节点),我们返回-1。这很重要,因为在计算平衡因子balance = height(left) - height(right)时,即使某侧没有子节点,我们也能得到一个有效的数值参与计算。

int height(const std::unique_ptr<Node>& node) {
    return node ? node->height : -1;
}

右旋转操作

右旋转用于修复“左-左”型不平衡。其逻辑是重新组织三个相关节点(Z, X, T)的指针关系。虽然代码只有几行,但涉及智能指针所有权的转移,需要仔细理解。

假设我们有节点Z,它的左孩子是X,而X的右子树是T。右旋转的目标是让X成为新的局部根节点,Z成为X的右孩子,同时T成为Z的左孩子。

以下是rotateRight函数的实现和分步解释:

void rotateRight(std::unique_ptr<Node>& parent) {
    // 1. 临时取得父节点左孩子的所有权
    auto child = std::move(parent->left);
    // 2. 父节点的左指针接管原左孩子的右子树
    parent->left = std::move(child->right);
    // 3. 更新原父节点Z的高度
    parent->height = 1 + std::max(height(parent->left), height(parent->right));
    // 4. 原左孩子X的右指针接管原父节点Z
    child->right = std::move(parent);
    // 5. 更新新局部根节点X的高度
    child->height = 1 + std::max(height(child->left), height(child->right));
    // 6. 传入的引用参数`parent`现在指向新的根节点X
    parent = std::move(child);
}

理解这段代码的最佳方式是在纸上画出每一步指针和所有权的变化。左旋转rotateLeft的实现与此完全对称,只需交换“左”和“右”即可。

插入、删除与回溯

查询操作(如containsfindMin)在AVL树中与普通二叉搜索树完全一致,无需修改。需要修改的是插入和删除操作,它们在完成基础操作后,需要调用回溯函数来维持平衡。

插入和删除的递归算法框架与二叉搜索树相同。不同之处在于,在递归调用返回的路径上,对每一个访问到的祖先节点,我们都需要调用retrace函数。

void insert(int key, std::unique_ptr<Node>& node) {
    if (!node) {
        node = std::make_unique<Node>(key);
    } else if (key < node->key) {
        insert(key, node->left);
    } else if (key > node->key) {
        insert(key, node->right);
    }
    // 插入完成后,回溯当前节点以检查平衡
    retrace(node);
}

删除操作remove在结构上与此完全相同,也会在最后调用retrace(node)

回溯与再平衡

retrace函数是AVL树的核心。它检查当前节点的平衡因子,并根据四种可能的不平衡情况调用相应的旋转操作。

四种不平衡情况是:

  1. 左-左型 (平衡因子 +2,且左孩子的平衡因子 >= 0): 单次右旋转。
  2. 左-右型 (平衡因子 +2,且左孩子的平衡因子 < 0): 先对左孩子左旋,再对自己右旋。
  3. 右-右型 (平衡因子 -2,且右孩子的平衡因子 <= 0): 单次左旋转。
  4. 右-左型 (平衡因子 -2,且右孩子的平衡因子 > 0): 先对右孩子右旋,再对自己左旋。

以下是retrace函数的实现逻辑:

void retrace(std::unique_ptr<Node>& node) {
    // 更新当前节点高度
    node->height = 1 + std::max(height(node->left), height(node->right));
    // 计算平衡因子
    int balance = height(node->left) - height(node->right);
    // 情况1 & 2: 左子树更高
    if (balance > 1) {
        if (height(node->left->left) >= height(node->left->right)) {
            // 情况1: 左-左型,单次右旋
            rotateRight(node);
        } else {
            // 情况2: 左-右型,先左旋再右旋
            rotateLeft(node->left);
            rotateRight(node);
        }
    }
    // 情况3 & 4: 右子树更高
    else if (balance < -1) {
        if (height(node->right->right) >= height(node->right->left)) {
            // 情况3: 右-右型,单次左旋
            rotateLeft(node);
        } else {
            // 情况4: 右-左型,先右旋再左旋
            rotateRight(node->right);
            rotateLeft(node);
        }
    }
    // 如果平衡,无需操作
}

这个函数确保了在插入或删除后,从被修改节点的父节点开始,一直到根节点,整条路径都能恢复平衡。一次修改可能引发多次旋转。

运行示例

让我们看一个将有序序列插入AVL树的例子,这恰好是普通二叉搜索树的最坏情况。

依次插入: 2, 18, 42, 43, 51, 54...

  • 插入2和18后,树是平衡的。
  • 插入42时,根节点2的平衡因子变为-2,触发一次左旋转,18成为新根。
  • 插入43,无影响。
  • 插入51时,节点42的平衡因子变为-2,触发一次左旋转,43成为局部新根。
  • 插入54时,会导致根节点18的平衡因子变为-2,再次触发旋转调整。

通过动态调整,AVL树始终保持近似平衡的高度,避免了退化成链表的情况。即使在混合插入删除后,树的高度也能维持在O(log n)级别。理论证明,AVL树的高度最多不超过1.44 * log₂(n+2),平衡性非常严格。

总结

本节课中我们一起学习了AVL平衡树的C++实现。我们首先在节点中添加了高度字段,然后实现了左右旋转的核心操作。接着,我们修改了插入和删除算法,使它们在操作完成后对祖先路径进行回溯和再平衡。通过严格的平衡因子约束,AVL树保证了所有查询和修改操作的时间复杂度都在O(log n)以内。这种严格平衡的代价是在插入和删除时需要更多的旋转操作。在下一讲中,我们将学习红黑树,它采用一种更宽松的平衡规则,可能在修改操作上更高效。

014:平衡树(下)🌳

在本节课中,我们将继续学习平衡树。上一节我们介绍了AVL树,它是一种基于高度和平衡因子进行严格平衡的树结构。本节中,我们将学习另一种极为流行且应用广泛的自平衡二叉搜索树——红黑树。

红黑树简介

红黑树也是一种自平衡二叉搜索树。与AVL树一样,它首先是一棵二叉搜索树,这意味着对于树中的任意节点,其左子树中的所有键值都小于该节点的键值,右子树中的所有键值都大于该节点的键值。红黑树与AVL树的区别在于它们维持平衡的策略不同。

红黑树的概念大约在AVL树发明十年后(1972年)被提出,其具体实现则由Robert Sedgewick在1978年完成。Sedgewick教授是普林斯顿大学的教授,也是经典教材《算法》的作者,他的工作在本课程后续关于排序算法的内容中还会被提及。

红黑树的规则 🎨

红黑树通过强制执行四条特定规则来确保树的大致平衡。以下是这些规则:

  1. 节点颜色:树中的每个节点都被标记为红色或黑色。在实现中,我们可以在节点结构体中添加一个颜色字段。
  2. 根节点规则:树的根节点必须是黑色的。
  3. 红色节点规则:如果一个节点是红色的,那么它的两个子节点都必须是黑色的。这意味着红色节点不能连续出现。
  4. 路径规则:从根节点到任意一个“叶子节点”(指没有子节点或只有一个子节点的节点)的路径上,必须包含相同数量的黑色节点。这个规则是保证树平衡的关键。

为了理解这些规则,让我们看一个有效的红黑树例子。假设我们有一棵树,根节点43是黑色的。节点18是红色的,它的两个子节点(2和42)都是黑色的,这遵守了规则三。从根节点到所有叶子节点(如2, 11, 42, 51, 74, 99)的路径上,都恰好经过两个黑色节点(例如路径43->18->2,黑色节点是43和2),这遵守了规则四。

识别无效的红黑树 ❌

理解规则后,我们可以练习识别无效的红黑树。请看下面这棵树,它违反了红黑树的多条规则:

  • 违反根节点规则:根节点是红色的。
  • 违反红色节点规则:一个红色节点(例如值为7的节点)有一个红色的子节点。
  • 违反路径规则:从根节点到不同叶子节点的路径上,黑色节点的数量不同(例如到某个叶子节点路径上有2个黑节点,到另一个叶子节点路径上只有1个黑节点)。

通过这个练习,我们可以更清晰地理解红黑树的约束条件。

红黑树的灵活性 ⚖️

与AVL树严格的平衡要求(左右子树高度差不超过1)不同,红黑树的平衡条件更为宽松。这带来一个重要的特性:在最极端的情况下,红黑树中某条路径的长度最多可以是另一条路径长度的两倍。

例如,考虑一棵树,其左子树全是黑色节点,深度为3;而右子树是红黑节点交替的“之字形”结构,深度为6。这棵树仍然完全遵守红黑树的所有规则。这种灵活性意味着红黑树在插入或删除节点时需要进行的结构调整(如旋转)通常比AVL树更少,从而在实践中可能具有更好的性能,同时仍能保证树的大致平衡,使各项操作的时间复杂度维持在 O(log n) 级别。

红黑树的插入操作 🔧

与AVL树类似,红黑树的查询操作(查找、最小值、最大值)与普通二叉搜索树完全相同。需要特殊处理的是会改变树结构的插入和删除操作。本节我们重点讲解插入操作,删除操作更为复杂,在此不做详述。

红黑树插入新节点的标准流程包含以下四个步骤:

  1. 标准BST插入:从根节点开始,按照二叉搜索树的规则递归地找到新节点的正确位置并插入。
  2. 新节点着色:将新插入的节点初始颜色设置为红色。
  3. 调整以恢复性质:检查插入后是否违反了红黑树规则。如果违反,则通过重新着色旋转来进行调整,直到所有规则再次被满足。这是最核心的步骤。
  4. 根节点着黑色:无论之前的调整如何,最后确保根节点的颜色为黑色。

接下来,我们详细看看第三步中可能遇到的几种情况及其解决方法。

情况一:父节点为黑色

如果新插入的红色节点(N)的父节点(P)是黑色的,那么不会违反任何规则(路径上的黑节点数未变,也没有出现连续的红节点)。此时,不需要任何操作

情况二:父节点为红色,叔节点也为红色

当新节点N和其父节点P都是红色时,违反了规则三。此时,如果父节点P的兄弟节点(即叔节点U)也是红色的,并且祖父节点G是黑色的,我们采用重新着色来解决。

解决方法

  • 将父节点P和叔节点U的颜色改为黑色。
  • 将祖父节点G的颜色改为红色。
  • 此时,以G为根的子树恢复了红黑性质,但G变成了红色,可能会与其父节点产生新的冲突。因此,我们需要将G视为新的“问题节点”,继续向上递归处理。

情况三:父节点为红色,叔节点为黑色(或为空)

当父节点P为红色,但叔节点U为黑色(包括U是空节点NIL的情况,NIL节点视为黑色)时,仅靠重新着色无法解决问题,需要结合旋转操作。根据新节点N是父节点P的左孩子还是右孩子,又分为两种子情况。

情况3A(直线型):N是P的左孩子,P是G的左孩子(或者对称情况:N是P的右孩子,P是G的右孩子)。这形成了一条“直线”。

解决方法

  • 对祖父节点G进行一次右旋转(如果是在右侧的对称情况则进行左旋转)。
  • 旋转后,交换原父节点P和原祖父节点G的颜色(P变为黑色,G变为红色)。

情况3B(之字型):N是P的右孩子,P是G的左孩子(或者对称情况:N是P的左孩子,P是G的右孩子)。这形成了一个“之字形”。

解决方法

  • 首先,对父节点P进行一次左旋转(对称情况则为右旋转),将结构转变为情况3A的直线型。
  • 然后,按照情况3A的方法处理:对祖父节点G进行一次右旋转,并重新着色(新的中心节点N变为黑色,G变为红色)。

插入操作实例演示 📈

让我们通过一个具体的插入序列 [2, 18, 42, 43, 51, 54, 74, 93] 来演示红黑树的插入过程。这个序列是有序的,如果使用普通BST会退化成链表,但红黑树能保持平衡。

  1. 插入2:作为第一个节点成为根。根据规则二,最后将其着为黑色。
  2. 插入18:作为2的右孩子插入,着红色。父节点2是黑色,属于情况一,无需调整。
  3. 插入42:作为18的右孩子插入,着红色。此时父节点18(红)与子节点42(红)冲突,且叔节点(NIL,黑)为黑,祖父节点2为黑。这属于情况3A(直线型)。对节点2和18进行左旋转,并将2着红,18着黑。
  4. 插入43:作为42的右孩子插入,着红色。父节点42(红),祖父节点18(黑),叔节点2(红)。这属于情况二。将父节点42和叔节点2着黑,祖父节点18着红。此时根节点18变为红色,违反规则二,最后将根节点18重新着黑。
  5. 插入51:作为43的右孩子插入,着红色。父节点43(红),祖父节点42(黑),叔节点(NIL,黑)。这属于情况3A。对节点42和43进行左旋转并重新着色。
  6. 插入54:作为51的右孩子插入,着红色。父节点51(红),祖父节点43(黑),叔节点18(红)。这属于情况二,进行重新着色。
  7. 插入74:作为54的右孩子插入,着红色。父节点54(红),祖父节点51(黑),叔节点(NIL,黑)。这属于情况3A,进行左旋转和重新着色。
  8. 插入93:作为74的右孩子插入,着红色。父节点74(红),祖父节点54(黑),叔节点51(红)。这属于情况二,对54、74、51进行重新着色(54变红,74和51变黑)。但这导致54(红)与其父节点43(红)产生新的冲突。此时,将54视为新的问题节点N,其父节点43为红,祖父节点18为黑,叔节点(NIL,黑)。这又属于情况3A,需要对节点18和43进行左旋转来最终解决所有冲突。

通过这一系列操作,我们可以看到,即使插入有序序列,红黑树也能通过一系列重新着色和旋转,动态地将自己调整回一个相对平衡的状态,避免了退化为链表。

总结

本节课中,我们一起学习了红黑树这种重要的自平衡二叉搜索树。我们首先了解了它的四条核心规则,并通过示例理解了如何识别有效的红黑树。接着,我们探讨了红黑树相比AVL树具有更好的灵活性,平衡条件不那么严格。最后,我们详细剖析了红黑树插入操作的完整流程,包括三种主要的冲突情况及其通过重新着色和旋转的解决方法。

红黑树是许多编程语言(如Java的TreeMap、TreeSet)和系统(如Linux内核)中实现有序映射和集合的基础数据结构,理解其原理对深入学习算法和数据结构至关重要。下一节,我们将探讨红黑树的具体代码实现。

015:平衡树

在本节课中,我们将要学习红黑树的实现,并快速了解另一种自平衡树——伸展树。红黑树是一种自平衡二叉搜索树,通过一系列颜色规则确保树的高度大致平衡,从而保证操作的效率。我们将重点介绍一种更易实现的变体——左倾红黑树。

红黑树回顾

上一节我们介绍了红黑树的基本概念,本节中我们来看看它的具体实现。红黑树本质上仍然是一棵二叉搜索树,因此它保留了二叉搜索树的所有性质:每个节点都有一个可比较的键,较小键的节点在左子树,较大键的节点在右子树。

红黑树通过四个附加属性来确保平衡,从而获得优秀的对数级时间复杂度。以下是这四个属性:

  • 颜色规则:每个节点非红即黑。
  • 根规则:根节点总是黑色。
  • 红规则:红色节点的子节点必须是黑色。
  • 路径规则:从根节点到任意一个叶子节点(或只有一个子节点的节点)的路径上,黑色节点的数量必须相同。

所有查询操作(如查找最小值、最大值或判断值是否存在)与普通二叉搜索树完全相同。只有可能改变树结构的操作(插入和删除)需要为红黑树进行修改和适配。

我们之前讨论了插入过程,它分为四个步骤:

  1. 像普通二叉搜索树一样插入新节点。
  2. 将新节点着为红色。
  3. 如果插入破坏了红黑树属性,则通过重新着色结构调整(旋转)来修复,从插入点回溯到根节点。
  4. 最后,确保根节点为黑色。

左倾红黑树

今天我们将学习红黑树的实现,但并非典型的红黑树,而是一种更简单的变体——左倾红黑树。它由红黑树的发明者在约30年后提出,其实现要容易得多。

左倾红黑树增加了一个额外的属性:红色节点必须始终是其父节点的左子节点。这个简单的规则使得实现大大简化。顾名思义,这种树在设计上会略微向左倾斜。

数据结构与辅助方法

与AVL树类似,左倾红黑树的节点结构在二叉搜索树的基础上增加了信息。不同的是,AVL树存储高度,而红黑树存储颜色。

以下是节点数据结构的定义:

enum Color { RED, BLACK };

struct Node {
    int key;
    Node* left;
    Node* right;
    Color color;
};

与AVL树类似,我们需要一些辅助方法来实现插入过程中的重新着色和结构调整。以下是四个核心辅助方法:

1. 颜色判断 (isRed)

这个方法类似于AVL树中获取高度的方法。它判断一个节点是否为红色。空节点(nullptr)被视为黑色。

bool isRed(Node* node) {
    if (node == nullptr) {
        return false; // 空节点是黑色
    }
    return node->color == RED;
}

2. 颜色翻转 (flipColors)

这个方法在常数时间内完成三个简单操作:将给定节点变为红色,并将其两个子节点变为黑色。这通常用于处理一个黑色节点带有两个红色子节点的情况。

void flipColors(Node* node) {
    node->color = RED;
    node->left->color = BLACK;
    node->right->color = BLACK;
}

3. 右旋转 (rotateRight) 与 4. 左旋转 (rotateLeft)

旋转操作的代码与我们在AVL树中学到的几乎完全相同。唯一的区别在于,我们不再需要更新节点的高度,而是需要处理节点的颜色。在旋转过程中,子节点会继承父节点的颜色,而父节点会变为红色。

以下是右旋转的示例代码:

Node* rotateRight(Node* parent) {
    Node* child = parent->left;
    parent->left = child->right;
    child->right = parent;
    // 处理颜色
    child->color = parent->color;
    parent->color = RED;
    return child; // 返回新的局部根节点
}

左旋转 (rotateLeft) 是其对称操作。

插入操作实现

有了这些辅助方法,我们就可以实现红黑树的插入操作了。你会发现,它与AVL树的插入非常相似。

插入函数的主体与普通二叉搜索树的递归插入相同。关键区别在于递归调用返回时(即从插入点回溯到根节点的路径上),我们需要执行一系列条件检查和修复操作,以维持红黑树(特别是左倾)的性质。

以下是插入函数的核心逻辑框架:

Node* insert(Node* node, int key) {
    // 1. 标准BST递归插入
    if (node == nullptr) {
        return new Node(key, RED); // 新节点总是红色
    }

    if (key < node->key) {
        node->left = insert(node->left, key);
    } else if (key > node->key) {
        node->right = insert(node->right, key);
    } // 如果key相等,根据需求处理(例如忽略或更新)

    // 2. 回溯修复(维持左倾红黑树性质)
    if (isRed(node->right) && !isRed(node->left)) {
        node = rotateLeft(node);
    }
    if (isRed(node->left) && isRed(node->left->left)) {
        node = rotateRight(node);
    }
    if (isRed(node->left) && isRed(node->right)) {
        flipColors(node);
    }

    return node;
}

// 公开的插入接口
void insert(int key) {
    root = insert(root, key);
    root->color = BLACK; // 确保根节点为黑色
}

回溯修复的三个条件检查对应了维持左倾和红黑树性质的关键情况:

  • 如果右子节点是红色而左子节点不是,进行左旋。
  • 如果左子节点是红色,且左子节点的左子节点也是红色,进行右旋。
  • 如果左右子节点都是红色,进行颜色翻转。

另一种自平衡树:伸展树

在结束红黑树之前,我们快速了解另一种有趣的自平衡二叉搜索树——伸展树。我们将在下一次讨论中详细研究并实现它。

伸展树的核心思想不是始终保持严格的平衡,而是利用局部性原理:最近被访问的元素很可能再次被访问。每次访问一个节点(无论是查找、插入还是删除),都会通过一系列称为“展开”的旋转操作,将该节点移动到树的根节点。

这样,频繁访问的节点会始终位于根节点附近,访问速度非常快。虽然最坏情况下单次操作可能是 O(n),但平均时间复杂度仍然是 O(log n)。伸展树的优点是无需在节点中存储额外信息(如高度或颜色),缺点是它会改变树的结构,即使在只读操作中也是如此,这使其在多线程环境中使用复杂。

总结

本节课中我们一起学习了红黑树的一种实用实现——左倾红黑树。我们了解了其节点结构、核心的辅助方法(颜色判断、翻转、旋转)以及插入操作的回溯修复过程。左倾红黑树通过强制红色节点左倾的规则,简化了实现。

同时,我们引出了另一种自平衡树——伸展树的概念,它通过将访问节点移至根部的策略来优化频繁访问项目的性能。红黑树因其在灵活性和性能间取得的良好平衡而被广泛应用。

016:平衡树 🎄

在本节课中,我们将学习最后一种自平衡树——B树,并探讨自平衡树在实际应用中的使用场景。我们将从理解B树的设计动机开始,逐步介绍其核心概念、性质,并与其他自平衡树进行对比。

概述

在之前的课程中,我们介绍了AVL树、红黑树和伸展树。本节将介绍B树,这是一种专为磁盘存储等I/O密集型场景设计的多路搜索树,旨在通过减少树的高度来最小化昂贵的磁盘访问次数。

从实际问题出发:DMV数据库案例

假设加州车管所(DMV)需要构建一个数据库来管理约2600万名司机的记录。每个司机的记录(包含姓名、出生日期等)大约为1KB,而驾驶执照号码是用于查找的唯一键。

整个数据库的总大小约为26GB。如此庞大的数据量通常无法完全载入计算机的RAM内存,因此必须存储在磁盘上。

磁盘访问非常缓慢,一次典型的磁盘读取操作大约需要10毫秒。这意味着每秒最多只能进行约100次磁盘访问。如果一个DMV办公室有20名职员同时查询数据库,那么平均每位职员每秒只能进行5次磁盘访问。

如果使用AVL树(最平衡的二叉搜索树之一)来存储这2600万条记录,树的高度大约为 log₂(26,000,000) ≈ 25。这意味着查找一条记录平均需要进行25次磁盘访问。

计算过程:每位职员每秒5次访问,完成一次查找需要25次访问,因此单次查询将耗时 25 / 5 = 5 秒。这个速度对于实际应用来说太慢了。

问题的核心在于需要减少查找过程中的磁盘访问次数。

核心思路:增加树的“分支度”

基于两个关键观察,我们找到了解决方案:

  1. 对于二叉搜索树,其高度下限是 log₂(n),无法进一步降低。
  2. 与缓慢的磁盘访问(10毫秒/次)相比,CPU计算速度极快(10毫秒可执行约1000万条指令)。因此,即使管理数据结构本身的代码变得更复杂、计算量更大,只要能减少磁盘访问次数,总体性能也会得到提升。

解决方案是:放弃二叉树,转而使用多路搜索树。通过允许每个节点拥有多于两个的子节点(即增加树的“分支度”),我们可以在存储相同数量元素的前提下,显著降低树的高度,从而减少查找所需的磁盘访问次数。

例如,一个五路搜索树(每个节点最多5个子节点)仅用3层就能存储31个节点,而二叉树则需要5层。

B树详解

B树正是基于上述思路设计的多路搜索树。它于20世纪70年代被发明,旨在保持二叉搜索树有序特性的同时,通过多路分支来降低树高。

一棵M阶B树需要满足以下三个不变式(Invariants):

节点不变式

此不变式规定了内部节点和叶节点的结构。

  • 内部节点:由一组指针交替组成。一个M阶B树的内部节点最多可包含 M个指针(指向下一层子节点)和 M-1个键。查找时,通过将目标键与节点内的键进行比较,决定跟随哪个指针。
  • 叶节点:包含最终的键-值对映射,并且这些键值对按键排序。在DMV的例子中,叶节点存储着驾驶执照号码(键)和司机记录(值)。

M(树的阶数)和L(叶节点能容纳的键值对数量)可以不同。在实际设计中,我们会根据数据页的大小来智能地选择M和L的值,以确保一个节点(无论是内部节点还是叶节点)能恰好填满一个数据页,从而最大化磁盘I/O效率。

示例:在DMV案例中,假设一个数据页大小为4KB,每个司机记录为1KB,那么 L = 4。同时,一个指针或键(如8字节长的整数)占8字节。在一个4KB的页中,我们可以存储多达 (4096 / 8) / 2 ≈ 256 个指针和键,因此 M 可以设为256。

顺序不变式

此不变式保证了B树的有序性,与二叉搜索树类似。

对于内部节点中的任意一个键 K,其左侧指针所指向的子树中的所有键都小于 K;其右侧指针所指向的子树中的所有键都大于或等于 K。这确保了在整个树中进行有序查找的可能性。

结构不变式

此不变式是B树保持平衡和低矮的关键。

  1. 根节点:如果树中元素数量少于一个叶节点的容量,则根节点直接就是叶节点。只有当数据量超过叶节点容量时,根节点才会变为内部节点。
  2. 节点填充度:除了根节点,每个内部节点至少要有 ceil(M/2) 个子节点(即至少半满),最多有 M 个子节点。每个叶节点至少包含 ceil(L/2) 个键值对,最多包含 L 个键值对。
  3. 平衡维护:当插入或删除操作导致某个节点低于半满时,B树会通过节点合并来重组。反之,当节点过满时,则会进行节点分裂。这个过程类似于整理书架:把书重新整理打包,再均匀地放回书架各层,以保证每层都尽可能放满且有序。

B树的应用与变体

B树及其变体(如B+树、B*树)在以下两类系统中极为流行:

  • 数据库系统:如SQL数据库,用于高效索引大量数据。
  • 文件系统:如NTFS、ext4等,用于映射文件名到磁盘物理块的位置,实现文件的快速随机访问。

C++标准库中的关联容器

在C++标准库中,std::setstd::map 这类关联容器通常使用红黑树(一种自平衡二叉搜索树)实现。

以下是它们的主要特点:

  • 核心特性std::set 仅存储键,而 std::map 存储键值对。它们都保持元素有序(默认按键升序)。
  • 操作复杂度:插入、删除、查找等主要操作的时间复杂度为 O(log n),这与基于数组的 std::vector(查找为O(n))或链表 std::list 相比,在数据量大时优势明显。
  • 有序迭代:对它们进行遍历(如使用范围for循环)会得到按键排序的元素序列。
  • 变体
    • std::multisetstd::multimap:允许重复键。
    • std::unordered_setstd::unordered_map:不保持元素顺序,基于哈希表实现,提供平均O(1)的访问复杂度,我们将在后续课程中讨论。

代码示例:使用 std::map

#include <iostream>
#include <map>
#include <string>

int main() {
    std::map<int, std::string> studentMap;

    // 插入元素
    studentMap[123] = "Alice";
    studentMap.insert(std::make_pair(456, "Bob"));
    studentMap.insert({789, "Charlie"});

    // 遍历(按键排序输出)
    for (const auto& entry : studentMap) {
        std::cout << "ID: " << entry.first
                  << ", Name: " << entry.second << std::endl;
    }
    // 输出:
    // ID: 123, Name: Alice
    // ID: 456, Name: Bob
    // ID: 789, Name: Charlie

    return 0;
}

自平衡树总结

下表总结了我们在本系列课程中探讨的几种主要自平衡树的特点:

树类型 核心思想 最佳适用场景
AVL 树 通过严格的平衡因子(左右子树高度差≤1)和旋转操作,维持高度平衡 查询(搜索)操作非常频繁,而插入/删除相对较少的静态或半静态数据集。它提供了最稳定的O(log n)查询性能。
伸展树 每次访问一个节点后,都通过一系列旋转将其移动到根节点 访问模式具有高度局部性的场景,即少数元素会被反复访问。它能将这些热点元素调整到靠近根的位置,加速后续访问。
红黑树 通过一组关于节点颜色的规则来维持大致平衡,确保没有路径会比其他路径长两倍以上。 通用场景,需要在插入、删除和查找之间取得良好平衡。它是实践中应用最广泛的自平衡二叉搜索树,也是C++ STL中set/map的常见实现基础。
B 树 多路搜索树,通过增加节点分支度来降低树高,专为磁盘/外部存储优化。 数据量极大,无法全部装入内存,必须存储在磁盘上的数据库或文件系统索引。其设计目标是最小化磁盘I/O次数

总结

本节课我们一起学习了自平衡树家族的最后一个重要成员——B树。我们从一个实际的性能问题出发,理解了B树通过增加分支度来降低树高、减少磁盘I/O的设计动机。我们详细分析了B树的三个核心不变式:节点不变式、顺序不变式和结构不变式,它们共同保证了B树的有序性和平衡性。此外,我们还了解了B树在数据库和文件系统中的关键应用,并回顾了C++标准库中基于红黑树实现的关联容器。最后,我们对AVL树、伸展树、红黑树和B树进行了对比总结,明确了它们各自的设计思想和适用场景。

通过本系列课程,你应该已经对主要的数据结构与算法有了系统的理解。接下来的课程将探索新的主题,例如哈希表等。

017:优先级队列 🎯

在本节课中,我们将要学习一种非常有趣的数据结构——优先级队列。它巧妙地将我们之前讨论过的队列和二叉树两个概念结合在一起。

概述:为什么需要优先级队列?

在深入探讨优先级队列的定义和典型实现之前,让我们先通过几个例子来理解为什么这种数据结构如此有用。

示例一:进程调度

想象一下,你在电脑上同时运行多个进程:一个耗时的程序编译、刷新网页、给朋友发送即时消息,以及接收邮件通知。如果操作系统采用“先到先服务”的方式,即必须完整执行完一个进程才能开始下一个,那么系统会显得非常卡顿,用户体验极差。

一种改进方法是采用轮询调度:将每个进程的执行时间分成小片段,然后交替执行这些片段。这可以通过一个标准的队列(FIFO)来实现。虽然所有进程的总执行时间不变,但用户会感觉所有任务都在同时推进,响应速度得到了提升。

然而,我们还可以做得更好。这就是优先级调度。我们可以为每个进程分配一个优先级。例如,那些执行时间非常短的进程(如显示邮件通知)应该被赋予更高的优先级,以便尽快执行,从而进一步提升系统的响应速度。这种调度方式需要一种特殊的“队列”,它不仅能管理一个序列,还能从中取出优先级最高的元素。这正是优先级队列的核心功能。

示例二:离散事件模拟

在硬件设计中,直接制造芯片来测试新功能成本高昂、周期漫长。更好的方法是先编写一个模拟模型。一种模拟方式是连续模拟,即每隔固定时间(如每微秒)检查所有组件状态。但这种方式效率低下,因为很多时间段内组件可能并无动作。

更高效的方法是事件驱动模拟。每个组件会告知模拟器它的下一个动作将在何时发生。模拟器则维护一个按事件发生时间排序的“日历”。这时,一个能按时间(优先级)顺序取出事件的优先级队列就至关重要了。

其他应用场景还包括网络流量管理(优先发送高优先级数据包)和图算法(如GPS寻找最短路径)。这些算法我们很快就会讲到。

定义与核心操作

现在,让我们正式定义优先级队列。它本质上是一种队列,支持 push(入队)和 pop(出队)操作。但与普通队列不同,优先级队列中的每个元素都关联着一个优先级(通常是一个可比较的键值)。

  • push(item):将一个新元素插入集合中。
  • pop()移除并返回当前优先级最高的元素(例如,键值最小的元素)。注意,这与普通队列的“先进先出”规则不同,它只关心优先级,不关心插入顺序。

在C++标准库中,使用 pushpop 作为操作名。但在其他资料中,你可能会看到它们被称为 insertgetMin

可能的实现方式

我们将探讨几种可能的实现方式及其复杂度。

实现一:有序链表 📋

我们可以使用一个始终保持有序的链表来实现优先级队列。

  • top()pop():因为链表有序,最高优先级的元素始终在链表头部。因此这两个操作的时间复杂度都是 O(1)
  • push(item):为了保持链表有序,插入新元素时需要找到正确的位置。在最坏情况下(新元素是当前最大元素),需要遍历整个链表,时间复杂度为 O(n)

另一种思路是让 push 总是将新元素插入链表头部(O(1)),但这样 pop 时就不得不遍历整个链表来寻找最高优先级的元素(O(n))。相比之下,保持链表有序的实现更优,因为 push 的平均情况通常优于 O(n)。

实现二:二叉搜索树 🌳

我们也可以使用(自平衡的)二叉搜索树(BST)来实现。

  • top():即寻找树中的最小节点(最左侧节点)。
  • pop():删除最小节点。
  • push(item):执行标准的BST插入。

在平衡BST中,所有这些操作的时间复杂度都是 O(log n)。这比有序链表的 O(n) push 要好,但 top 操作从 O(1) 变成了 O(log n)。

理想的数据结构

那么,是否存在一种数据结构,能同时实现 O(1)top 操作和 O(log n)push/pop 操作呢?答案是肯定的,那就是二叉堆

二叉堆:优先级队列的最佳搭档

二叉堆是一种特殊的二叉树,它满足两个关键性质:

  1. 堆序性质:对于最小堆,每个节点的键值都小于或等于其子节点的键值。这意味着整个堆的最小元素始终位于根节点。因此,top() 操作可以在 O(1) 时间内完成。
  2. 结构性质:二叉堆是一棵完全二叉树。即除了最后一层,其他层都被完全填满,并且最后一层的节点都尽可能靠左排列。

完全二叉树的性质带来了一个巨大优势:它可以被高效地存储在一个数组中,而无需使用指针。如果我们将根节点放在数组索引 1 的位置(索引 0 闲置),那么对于数组中索引为 i 的节点:

  • 其父节点索引为 i / 2(整数除法)。
  • 其左孩子索引为 2 * i
  • 其右孩子索引为 2 * i + 1

这种表示方法简单且节省空间。

堆的操作与维护

插入 (push) 和删除 (pop) 操作可能会破坏堆的性质,因此需要调整。

push 操作与“上滤”
新元素总是被添加到完全二叉树的最后一个位置(以保持结构性质)。这可能会违反堆序性质(新元素可能比父节点小)。修复方法是进行上滤:不断将新节点与其父节点比较,如果它的优先级更高(在最小堆中即键值更小),就交换它们,直到堆序性质恢复。

pop 操作与“下滤”
移除根节点(最高优先级元素)后,我们取最后一个节点放到根节点位置(以保持结构性质)。这几乎肯定会违反堆序性质。修复方法是进行下滤:将新的根节点与其子节点中优先级更高的那个比较,如果它的优先级更低,就交换它们,并继续这个过程,直到堆序性质恢复。

这两个调整过程都只沿着树的一条路径进行,而完全二叉树的高度是 O(log n),因此 pushpop 操作的时间复杂度都是 O(log n)

复杂度总结

让我们对比一下不同的实现方式:

实现方式 top pop push
有序链表 O(1) O(1) O(n)
二叉搜索树 O(log n) O(log n) O(log n)
二叉堆 O(1) O(log n) O(log n)

可以看出,二叉堆在 top 操作上达到了最优的常数时间复杂度,同时在 pushpop 操作上保持了高效的对数时间复杂度,是实现优先级队列的绝佳选择。

总结

本节课我们一起学习了优先级队列。我们首先通过进程调度和事件模拟等例子了解了它的应用场景和重要性。然后,我们明确了它的定义和核心操作:按优先级而非插入顺序来访问元素。

接着,我们探讨了两种基础的实现方式——有序链表和二叉搜索树,并分析了它们各自的优缺点。最后,我们深入介绍了二叉堆这一专门为实现优先级队列而设计的数据结构,详细讲解了它的堆序性质、结构性质、数组表示法,以及核心的 push(伴随上滤)和 pop(伴随下滤)操作是如何在 O(log n) 时间内完成的,同时保持了 O(1) 的 top 操作。

二叉堆是许多高效算法(如我们即将学习的堆排序)的基础,掌握它对于理解更复杂的数据结构和算法至关重要。

018:优先级队列(第二部分)🎯

在本节课中,我们将继续学习优先级队列,重点探讨如何高效地构建一个二叉堆(堆化),并了解C++标准库中优先级队列的实现方式。

上一节我们介绍了优先级队列的基本概念以及使用二叉堆实现pushpop操作的方法。本节中,我们来看看如何从一个无序集合高效地构建堆,以及标准库中的相关工具。

堆化:从无序集合构建堆

堆化是指从一个初始的项目集合(例如一个向量)直接构建出一个二叉堆的过程。这在某些场景下非常有用,例如堆排序算法。

朴素方法(威廉姆斯方法)

一种直观的方法是从一个空堆开始,遍历集合中的每个元素,并逐个执行push操作。

以下是该方法的伪代码表示:

// 伪代码:朴素堆化方法
for (item in collection) {
    heap.push(item);
}

问题:这种方法的时间复杂度是O(n log n)。原因在于,对于集合中大约一半的元素(位于堆的底层),执行push操作(涉及上滤)的代价最高,约为树的高度O(log n)。

高效方法(弗洛伊德方法)

存在一种更优的线性时间O(n)方法,称为弗洛伊德方法。其核心思想是“自底向上”地修复堆序性质。

该方法分为两个步骤:

  1. 构建完全二叉树结构:简单地将所有元素按顺序放入一个数组中,形成一个结构上完全但无序的二叉树。
  2. 自底向上执行下滤:从最后一个非叶子节点开始,向前遍历到根节点,对每个遍历到的节点执行下滤操作。

以下是该方法的伪代码表示:

// 伪代码:弗洛伊德堆化方法
// 步骤1: 将元素复制到内部数组,形成完全二叉树结构
copy(collection, heap_array);

// 步骤2: 从最后一个非叶子节点开始,向前执行下滤
for (i = heap_size / 2; i >= 1; i--) {
    percolate_down(i);
}

优势分析:与朴素方法相反,弗洛伊德方法中,代价高的下滤操作(O(h))只对少数靠近根部的节点执行。而数量庞大的叶子节点(占一半)根本无需操作。其一层节点(占1/4)只需常数时间O(1)的操作。通过数学推导,总时间复杂度可降至O(n)

C++标准库中的优先级队列

在C++标准库中,std::priority_queue是一个容器适配器,它基于底层序列容器(默认为std::vector)和一系列堆算法来实现优先级队列的功能。

声明与类型

默认情况下,std::priority_queue是一个最大堆(元素值越大,优先级越高)。

声明一个最大堆的代码如下:

std::priority_queue<int> max_heap; // 默认最大堆

若要创建最小堆(元素值越小,优先级越高),需要提供额外的模板参数来指定比较方式。

声明一个最小堆的代码如下:

std::priority_queue<int, std::vector<int>, std::greater<int>> min_heap;

底层堆算法

标准库也提供了直接在序列容器(如vector)上操作的堆算法,这揭示了priority_queue的内部机制。

以下是一些关键函数及其作用:

  • std::make_heap(begin, end):将指定范围内的元素重新排列,使其形成一个堆。
  • std::push_heap(begin, end):假设[begin, end-1)已是一个堆,将*(end-1)(即新尾元素)放入堆中的正确位置。
  • std::pop_heap(begin, end):将堆中的最大元素(位于begin)移动到end-1位置,并将[begin, end-1)重新调整为堆。

使用这些函数的示例流程如下:

std::vector<int> v = {3, 1, 4, 1, 5, 9};
// 1. 堆化
std::make_heap(v.begin(), v.end()); // v变为最大堆
// 2. 添加元素并调整堆
v.push_back(6);
std::push_heap(v.begin(), v.end()); // 将6放入堆中正确位置
// 3. 弹出堆顶元素
std::pop_heap(v.begin(), v.end()); // 最大元素被移至末尾
v.pop_back(); // 移除该元素

总结

本节课中我们一起学习了优先级队列的两个高级主题。首先,我们比较了构建二叉堆的两种方法:时间复杂度为O(n log n)的朴素插入法和更优的、时间复杂度为O(n)的弗洛伊德自底向上堆化法。接着,我们探讨了C++标准库中std::priority_queue的实现,它是一个容器适配器,默认创建最大堆,并通过指定比较器可以创建最小堆。我们还了解了底层堆算法(如make_heappush_heappop_heap)如何直接在序列容器上操作,这有助于我们理解优先级队列的内部工作原理。掌握这些知识对于高效地使用和实现优先级队列这一重要数据结构至关重要。

019:图论入门

在本节课中,我们将开始学习一个新的数据结构——图。我们将从一个有趣的历史问题出发,了解图论的起源,并初步认识图的基本概念和应用。

概述:从七桥问题到图论

上一节我们介绍了树等数据结构,本节中我们来看看图。图论起源于一个著名的数学问题。大约300年前,数学家莱昂哈德·欧拉生活在普鲁士的柯尼斯堡市。这座城市被河流分为四个部分,由七座桥梁连接。欧拉思考了一个问题:能否找到一条路线,恰好只穿过每座桥一次

他尝试了多种路径,但最终证明这个问题无解。在证明过程中,欧拉创造性地将实际问题抽象化,从而开创了图论这一数学分支。

图的抽象表示

为了解决问题,欧拉将城市的不同陆地部分抽象为顶点节点,将连接它们的桥梁抽象为。通过这种抽象,复杂的城市布局就变成了一个由点和线组成的结构,即

以下是图的抽象表示方法:

  • 顶点/节点:代表实体(如陆地)。
  • :代表实体间的连接关系(如桥梁)。

欧拉路径与欧拉回路

基于图的抽象,欧拉定义了两种特殊的路径,并给出了它们存在的条件。

欧拉路径

欧拉路径是指一条经过图中每条边恰好一次的路径。欧拉证明,这样的路径存在,当且仅当图中恰好有0个或2个“奇度”顶点

节点的度是指连接到该节点的边的数量。例如,一个连接了3条边的节点,其度为3(奇数)。

在七桥问题对应的图中,所有四个节点的度都是奇数(三个度为3,一个度为5),不满足“0个或2个奇度顶点”的条件,因此不存在欧拉路径。

欧拉回路

欧拉回路是一种特殊的欧拉路径,它要求路径起点和终点是同一个顶点。欧拉证明,欧拉回路存在,当且仅当图中所有顶点的度均为偶数

其原理直观:要进入并离开一个节点,每次需要消耗两条边。只有当一个节点连接的边数为偶数时,才能保证在遍历所有边后,能恰好回到该节点。

图在计算机科学中的应用

图是一种极其强大的数据表示方法,在计算机科学中应用广泛。以下是几个典型的例子:

  • 交通网络:地铁线路图可以用图来表示,其中站点是节点,轨道连线是边。
  • 社交网络:社交平台(如Facebook)的好友关系可以构成一个巨大的图,用户是节点,好友关系是边。曾有实习生通过可视化全球用户的好友连接,在地图上清晰地显示出了国家边界。
  • 生物信息学:蛋白质之间的相互作用可以用图来建模。
  • 其他领域:图还广泛应用于电路设计、金融交易网络分析等诸多领域。

总结

本节课中我们一起学习了图论的起源和基本概念。我们从欧拉解决柯尼斯堡七桥问题的故事出发,理解了如何将现实问题抽象为由顶点和边组成的图。我们学习了欧拉路径欧拉回路的定义及其存在条件,并了解了图在计算机科学中的广泛应用。下一节,我们将开始学习图在编程中的具体表示方法和相关术语。

020:图论基础与表示方法 📊

在本节课中,我们将学习图的基本定义、术语以及如何在程序中表示图。我们将从数学符号开始,逐步介绍不同类型的图,并深入探讨三种主要的图表示方法。

图的基本定义与类型

上一节我们介绍了课程概述,本节中我们来看看图的基本数学定义。

图通常表示为 G = (V, E),其中 V 是顶点(Vertices)的集合,E 是边(Edges)的集合。

图主要分为两种类型:

  • 无向图:边没有方向,连接是双向的。例如,顶点 A 和 B 相连,意味着 A 可以到达 B,B 也可以到达 A。
    • 表示方法:V = {A, B, C}E = {{A, B}, {A, C}}
  • 有向图:边有方向,连接是单向的。例如,边从 C 指向 A,意味着可以从 C 到 A,但不能从 A 到 C。
    • 表示方法:V = {A, B, C}E = {(C, A), (A, B)}
    • 有向图通常简称为 Digraph

默认情况下,当人们只说“图”时,通常指的是无向图。

图的术语与词汇

了解了图的基本类型后,我们需要掌握描述图结构和属性的关键术语。

以下是关于图的一些核心术语:

  • 顶点的度:连接到一个顶点的边的数量。在无向图中,顶点的度就是与其相连的边数。
  • 入度和出度:仅适用于有向图。
    • 入度:指向该顶点的边的数量。
    • 出度:从该顶点指出的边的数量。
  • 孤立顶点:度为 0 的顶点,即没有任何边与之相连。
  • 图的连通性:图不要求所有顶点都必须相互连接。可能存在多个完全独立的子图。

接下来,我们看看图的几种特殊类型。

以下是三种常见的图类型:

  1. 多重图:允许存在自环(连接顶点自身的边)或平行边(两个顶点间有多条边)的图。
  2. 简单图:最常见的图类型。它是无向的,并且没有自环和平行边。通常所说的“图”即指简单图。
  3. 带权图:图中的每条边都关联一个权重值。权重可以代表距离、成本、速度等,具体取决于应用场景。

现在,让我们探讨图中路径和环的概念。

  • 路径:由一系列顶点和边组成的序列,表示从起点到终点的连通路线。
    • 路径长度:路径中边的数量。
    • 路径权重:在带权图中,路径上所有边权重的总和。
  • :起点和终点是同一个顶点的路径。
  • 无环图:不包含任何环的图。

图的分类与特性

在掌握了基本术语后,我们可以根据图的整体结构对其进行分类。

图可以根据其连通性分为两大类:

  • 连通图:图中任意两个顶点之间都存在路径(直接或间接)相连。
  • 非连通图:图中存在至少两个顶点子集,它们之间没有任何路径相连。

对于有向图,连通性有更细致的划分:

  • 弱连通图:如果将所有的有向边替换为无向边后,图是连通的,则原图是弱连通的。
  • 强连通图:对于图中任意两个顶点 uv,既存在从 uv 的路径,也存在从 vu 的路径。

此外,还有两种描述图边密度的概念:

  • 稠密图:边的数量 E 远大于顶点的数量 V,通常接近 的数量级。在完全图(每对顶点之间都有边相连)中,边的数量达到最大。
    • 无向完全图边数公式:E = V * (V - 1) / 2
    • 有向完全图边数公式:E = V * (V - 1)
  • 稀疏图:边的数量 E 与顶点的数量 V 大致成正比,即 E ≈ V

最后,一个重要的特例是树。

  • 是一种特殊的图,它满足以下条件:
    1. 无环
    2. 连通
    3. 具有根节点
  • 森林:由多棵互不相连的树组成的图。

图的程序表示:API 设计

在深入具体的实现细节之前,我们先定义一下图类应该提供哪些基本操作。

我们希望一个 Graph 类至少包含以下功能(API):

class Graph {
public:
    // 构造函数1:创建一个包含 V 个顶点但没有边的图
    Graph(int V);

    // 构造函数2:从输入流(如文件)读取图数据并构建图
    Graph(std::istream& in);

    // 添加一条连接顶点 v 和 w 的边
    void addEdge(int v, int w);

    // 获取与顶点 v 相邻的所有顶点
    std::vector<int> adj(int v);

    // 返回图的顶点总数
    int V() const;

    // 返回图的边总数
    int E() const;

private:
    // 内部表示方法
};

图的三种表示方法

有了清晰的 API 设计,现在我们可以探讨如何在程序中实现图的存储。主要有三种表示方法。

方法一:边列表

边列表是最直观的表示方法。它直接存储图中所有的边。

核心思想:使用一个列表(如向量)来存储所有的边,每条边用一个顶点对 (v, w) 表示。

C++ 实现示例

class Graph {
private:
    int numVertices;
    std::vector<std::pair<int, int>> edgeList;
public:
    Graph(int V) : numVertices(V) {}
    void addEdge(int v, int w) {
        edgeList.emplace_back(v, w); // 添加边 (v, w)
    }
    std::vector<int> adj(int v) {
        std::vector<int> neighbors;
        for (auto& edge : edgeList) {
            if (edge.first == v) neighbors.push_back(edge.second);
            else if (edge.second == v) neighbors.push_back(edge.first);
        }
        return neighbors;
    }
    int V() const { return numVertices; }
    int E() const { return edgeList.size(); }
};

复杂度分析

  • 空间:O(E),与边数成正比。
  • adj(v) 操作:O(E),需要遍历所有边来查找与 v 相连的顶点。
  • edgeExists(v, w) 操作:O(E),需要遍历所有边来检查是否存在边 (v, w)

边列表简单,但在查找相邻顶点或检查边是否存在时效率较低,适合边数极少的场景。

方法二:邻接矩阵

邻接矩阵使用一个二维布尔数组来表示顶点间的连接关系。

核心思想:创建一个大小为 V x V 的矩阵 matrix。如果顶点 ij 之间有边,则 matrix[i][j]matrix[j][i](对于无向图)设为 true,否则为 false

C++ 实现示例

class Graph {
private:
    int numVertices;
    std::vector<std::vector<bool>> adjMatrix;
public:
    Graph(int V) : numVertices(V), adjMatrix(V, std::vector<bool>(V, false)) {}
    void addEdge(int v, int w) {
        adjMatrix[v][w] = true;
        adjMatrix[w][v] = true; // 对于无向图
    }
    std::vector<int> adj(int v) {
        std::vector<int> neighbors;
        for (int i = 0; i < numVertices; ++i) {
            if (adjMatrix[v][i]) neighbors.push_back(i);
        }
        return neighbors;
    }
    int V() const { return numVertices; }
    // E() 需要额外维护一个边计数器
};

复杂度分析

  • 空间:O(V²),即使对于稀疏图也会浪费大量空间存储 false
  • adj(v) 操作:O(V),需要扫描一整行。
  • edgeExists(v, w) 操作:O(1),直接访问 matrix[v][w] 即可。

邻接矩阵在检查边是否存在时速度极快,但空间消耗大,仅适用于稠密图。

方法三:邻接表

邻接表结合了边列表和邻接矩阵的优点,是最常用且高效的表示方法,尤其适合稀疏图。

核心思想:使用一个大小为 V 的数组(或向量),其中每个元素 adjList[v] 是一个列表(如向量),存储所有与顶点 v 直接相邻的顶点。

C++ 实现示例

class Graph {
private:
    int numVertices;
    std::vector<std::vector<int>> adjList;
public:
    Graph(int V) : numVertices(V), adjList(V) {}
    void addEdge(int v, int w) {
        adjList[v].push_back(w);
        adjList[w].push_back(v); // 对于无向图
    }
    std::vector<int> adj(int v) {
        return adjList[v]; // 注意:这里返回的是副本
    }
    int V() const { return numVertices; }
    int E() const {
        int count = 0;
        for (auto& list : adjList) count += list.size();
        return count / 2; // 无向图中每条边被记录了两次
    }
};

复杂度分析

  • 空间:O(V + E),非常高效。
  • adj(v) 操作:O(deg(v)),其中 deg(v) 是顶点 v 的度。通常这比 O(V) 或 O(E) 快得多。
  • edgeExists(v, w) 操作:O(deg(v)),需要在 v 的邻接列表中查找 w

总结与比较

本节课中我们一起学习了图的基础知识、术语以及三种主要的程序表示方法。

以下是三种表示方法的总结与比较:

操作/复杂度 边列表 邻接矩阵 邻接表
空间占用 O(E) O(V²) O(V + E)
添加边 addEdge(v, w) O(1) O(1) O(1)
检查边 edgeExists(v, w) O(E) O(1) O(deg(v))
获取邻接顶点 adj(v) O(E) O(V) O(deg(v))

选择建议

  • 邻接表 是大多数情况下的首选,特别是对于稀疏图,它在空间和时间效率上取得了很好的平衡。
  • 邻接矩阵 仅当图非常稠密,且需要频繁、快速地检查任意两点间是否存在边时才考虑使用。
  • 边列表 通常用于特定算法或作为初始简单的表示,但在通用图操作中效率不高。

下一节课,我们将开始学习应用于图的各种算法。

021:图算法

在本节课中,我们将要学习图数据结构上的核心算法。上一节我们介绍了图的三种表示方法(边列表、邻接矩阵、邻接表),本节中我们来看看如何遍历图,并探索几种最常用的图算法,包括最短路径和最小生成树。

图的遍历

既然我们已经可以用程序表示图,下一步就是如何系统地访问图中的所有节点。对于列表或序列这样的数据结构,遍历很简单,只需从第一个元素开始,按顺序访问即可。对于树,我们有两种主要策略:深度优先和广度优先。图本质上也是一种数据结构,其遍历策略与树非常相似,区别在于图没有明确的根节点,我们可以从任意节点开始探索。

以下是两种主要的图遍历策略:

  • 深度优先遍历:从一个起始节点开始,沿着一条路径尽可能深入地探索,直到无法继续,然后回溯并尝试另一条路径。这类似于在迷宫中“一条路走到黑”。
  • 广度优先遍历:从一个起始节点开始,先访问所有直接相邻的节点,然后再访问这些相邻节点的相邻节点,以此类推,逐层向外扩展。这类似于水波从中心点向外扩散。

深度优先搜索

深度优先搜索的核心思想是递归地探索图的深处。以下是其递归实现的伪代码:

DFS(vertex v) {
    mark v as visited;
    for (each vertex w connected to v) {
        if (w is not visited) {
            DFS(w);
        }
    }
}

从起始节点 v 开始,我们将其标记为已访问,然后对于 v 的每一个未访问的邻居 w,递归地调用 DFS(w)。这个过程会沿着一条路径不断深入,直到遇到死胡同再回溯。

广度优先搜索

广度优先搜索使用队列来实现迭代式的逐层探索。以下是其算法步骤:

  1. 将起始节点 s 放入队列并标记为已访问。
  2. 当队列不为空时:
    • 从队列中取出一个节点 v
    • 对于 v 的每一个未访问的邻居 w
      • w 标记为已访问。
      • w 放入队列。

这个过程确保了节点是按照它们距离起始节点的“跳数”顺序被访问的。

图算法的实现模式

在编写图算法时,一个良好的设计模式是将图的数据表示(如邻接表)与图上的算法处理分离开。我们可以创建一个独立的“处理器”类,它接收一个图对象作为输入,运行特定算法(如DFS或BFS),并存储计算结果(如路径信息),然后提供查询接口。

例如,一个深度优先路径查找器的API可能如下:

class DepthFirstPaths {
public:
    DepthFirstPaths(Graph G, int s); // 构造函数,从节点s开始对图G进行DFS
    bool hasPathTo(int v);           // 是否存在从s到v的路径?
    vector<int> pathTo(int v);       // 返回从s到v的路径(如果存在)
private:
    vector<bool> marked; // 标记节点是否已访问
    vector<int> prev;    // 记录到达每个节点的前驱节点
    void dfs(Graph G, int v); // 递归DFS函数
};

这种设计使得图数据结构保持纯净和只读,而算法逻辑和状态管理由独立的处理器类负责,提高了代码的模块化和可重用性。

最短路径算法

寻找图中两点间的最短路径是一个极其常见的问题,应用广泛,如GPS导航、网络路由等。根据图是否带权,我们需要不同的算法。

无权图的最短路径

无权图中,每条边的“代价”相同(通常视为1)。此时,广度优先搜索本身就是一种最短路径算法。因为BFS按距离起始点的边数逐层访问节点,当它第一次访问到目标节点时,所经过的路径必然是最短路径(边数最少)。

带权图的最短路径(Dijkstra算法)

带权图中,每条边都有一个权重(或成本)。此时,BFS不再适用,因为经过边数最少的路径不一定是总权重最小的路径。解决此问题最著名的算法是Dijkstra算法

Dijkstra算法也使用类似BFS的扩展思想,但每次从“待处理集合”中选出的是当前已知距离起始点最近的节点。它需要用到优先队列(通常是最小堆)来高效地获取这个节点。

算法核心步骤如下:

  1. 初始化:起始点距离为0,其他所有点距离为无穷大。所有节点加入优先队列。
  2. 当优先队列非空时:
    • 从队列中取出距离最小的节点 u
    • u 的每条出边 (u, v),检查能否通过 u 缩短到 v 的距离。即,如果 dist[u] + weight(u, v) < dist[v],则更新 dist[v] 并记录前驱节点。
  3. 算法结束时,dist 数组存储了从起点到所有节点的最短距离,通过前驱节点数组可以回溯出完整路径。

注意:经典的Dijkstra算法要求图中所有边的权重非负。对于包含负权边的图,需要使用Bellman-Ford等算法。

最小生成树

另一个重要的图算法问题是寻找最小生成树。生成树是连接图中所有顶点的一个无环子图。最小生成树是所有生成树中边的总权重最小的那一个。这个问题在网络设计(如用最低成本铺设连接所有城市的电缆)等领域有直接应用。

最常用的算法是Prim算法,其思想与Dijkstra算法有些相似:

  1. 从任意一个顶点开始,将其加入生成树集合。
  2. 在所有连接“生成树集合内顶点”和“集合外顶点”的边中,选择权重最小的一条。
  3. 将这条边以及它连接的那个集合外顶点加入生成树集合。
  4. 重复步骤2和3,直到所有顶点都加入生成树集合。

通过每次都选择当前可用的最小边,Prim算法能够逐步构建出总权重最小的生成树。

其他图算法简介

图论领域算法丰富,除了上述几种,还有:

  • 拓扑排序:针对有向无环图,给出一个顶点序列,使得对于每条有向边 (u, v)u 在序列中都出现在 v 之前。常用于任务调度、课程安排。
  • 强连通分量:找出有向图中最大的子图,使得其中任意两点都互相可达。用于分析社交网络、网页链接等结构的内部紧密程度。
  • 最大流:在带容量的网络中,计算从源点到汇点能通过的最大数据流。是网络流理论的基础,可用于交通规划、资源分配。

总结

本节课中我们一起学习了图的核心算法。我们从图的两种基本遍历方法(深度优先搜索和广度优先搜索)开始,理解了它们的工作原理和实现方式。接着,我们探讨了最短路径问题,区分了无权图(使用BFS)和带权图(使用Dijkstra算法)的不同解决方案。然后,我们介绍了最小生成树的概念以及Prim算法的基本思想。最后,我们简要列举了拓扑排序、强连通分量和最大流等其他重要的图算法。图作为一种强大的抽象工具,其相关算法是解决许多实际复杂问题的关键。

022:排序入门 🎯

在本节课中,我们将要学习计算机科学中的一个核心主题:排序。排序是组织数据的关键操作,它能让数据变得有序,从而极大地简化搜索、去重、统计频率和选择特定元素等其他操作。我们将从排序的基本概念讲起,然后介绍几种经典的排序算法,并分析它们的性能、内存使用和稳定性。

什么是排序?📚

排序是将一个集合中的元素按照某种特定顺序进行排列的过程。这个顺序并非一成不变,它取决于我们选择的排序标准。例如,我们可以根据学生的年龄、姓氏或名字进行排序,顺序可以是升序或降序。因此,排序总是依据某种顺序进行的。

为了实现排序,我们需要能够比较两个元素,即根据特定标准判断哪个元素更小或更大。这是排序算法的基本要求。

排序算法种类繁多,其中许多算法早在几十年前就已经被发明出来。虽然排序问题在今天看来基本已经解决,但了解不同算法的特性对于选择适合特定场景的算法至关重要。

排序算法的分类 📊

我们可以从三个主要方面对排序算法进行分类。

1. 按时间复杂度分类

这是最常见的分类方式,主要根据算法处理 n 个元素所需的步骤来划分。

  • 朴素算法:这类算法通常实现简单,但效率不高,平均时间复杂度为 O(n²)。它们并非一无是处,对于小型数据集,使用简单的算法往往更合适。
  • 高效算法:这类算法是我们学习的重点,如堆排序、归并排序和快速排序。它们的平均时间复杂度约为 O(n log n),是处理大规模数据的默认选择。
  • 特殊算法:这类算法不基于元素间的直接比较,其复杂度有时取决于键值的大小而非元素数量,在某些情况下可以达到极佳的性能。

2. 按空间复杂度(内存占用)分类

这指的是算法执行时是否需要额外的存储空间。

  • 原地算法:这类算法可以直接在接收到的数组或集合内部进行排序,不需要额外的存储空间
  • 非原地算法:这类算法无法直接在原数组上完成排序,需要创建额外的空间(通常是 O(n) 或 O(log n))来辅助完成排序过程。

3. 按稳定性分类

稳定性是指当排序算法遇到两个或多个被视为“相等”的元素时,能否保持它们在原始集合中的相对顺序。

  • 稳定算法:保证相等元素的原始顺序在排序后保持不变
  • 不稳定算法不保证相等元素的原始顺序在排序后保持不变。

为了更直观地理解稳定性,请看以下示例。假设我们有一副扑克牌,需要按点数排序,并且有两张点数相同的7。

  • 初始顺序:7♥, 7♠, ...
  • 稳定排序结果:7♥, 7♠, ... (红心7仍在黑桃7之前)
  • 不稳定排序结果:7♠, 7♥, ... (顺序可能被交换)

上一节我们介绍了排序的基本概念和分类方式,本节中我们来看看第一种具体的排序算法:选择排序。

选择排序算法 🔍

选择排序是一种直观的朴素排序算法。假设我们有一个包含 n 个元素的数组 A,算法需要进行 n 轮操作。

在第 i 轮操作中(i 从 0 开始),算法会在尚未排序的部分(即从索引 in-1)中寻找最小元素。找到之后,将这个最小元素与当前索引 i 位置的元素进行交换。

这样,经过 i 轮后,数组前 i 个位置(A[0]A[i-1])就已经是有序的了。

算法示例

假设我们要对数组 ["U", "C", "D", "A", "V", "I", "S"] 进行排序。

以下是选择排序的伪代码实现:

template <typename T>
void selectionSort(std::vector<T>& arr) {
    int n = arr.size();
    for (int i = 0; i < n - 1; i++) {
        // 寻找 [i, n) 区间里的最小值的索引
        int minIndex = i;
        for (int j = i + 1; j < n; j++) {
            if (arr[j] < arr[minIndex]) {
                minIndex = j;
            }
        }
        // 将找到的最小元素与第 i 个元素交换
        std::swap(arr[i], arr[minIndex]);
    }
}

算法分析

  • 时间复杂度:算法包含一个嵌套循环。外层循环运行 n 次,内层循环平均运行 n/2 次。因此,总的时间复杂度是 Θ(n²)。这意味着无论输入数组是否已经有序,算法都需要进行大约 次比较。
  • 空间复杂度:选择排序是原地排序算法。除了几个临时变量,它不需要额外的存储空间。
  • 稳定性:选择排序是不稳定的排序算法。考虑数组 [B1, B2, A],其中 B1B2 相等。第一轮会将 AB1 交换,导致 B2 跑到了 B1 前面,破坏了原始顺序。

选择排序虽然效率不高,但实现极其简单,对于小规模数据或作为教学示例非常合适。接下来,我们看看另一种朴素但很有特点的算法:插入排序。

插入排序算法 📥

插入排序是另一种经典的朴素排序算法。它同样对包含 n 个元素的数组进行 n 轮操作,但策略不同。

在第 i 轮操作中,算法关注的是位于索引 i 的元素。它会尝试将这个元素向数组的左侧“插入”到正确的位置。具体做法是:将 A[i] 与其左边的元素 A[i-1] 比较,如果 A[i] 更小,则交换它们。然后继续将 A[i-1](即原来的 A[i])与 A[i-2] 比较,重复此过程,直到该元素不小于其左侧的元素,或者已经到达数组开头。

你可以想象整理手中的扑克牌:左手持有一部分已排序的牌,右手从牌堆拿起一张新牌,然后从右向左扫描左手中的牌,找到合适的位置插入。

算法示例

同样对数组 ["U", "C", "D", "A", "V", "I", "S"] 进行排序。

以下是插入排序的伪代码实现:

template <typename T>
void insertionSort(std::vector<T>& arr) {
    int n = arr.size();
    for (int i = 1; i < n; i++) {
        // 将 arr[i] 插入到 arr[0...i-1] 中的正确位置
        T key = arr[i];
        int j = i - 1;
        // 向左移动所有大于 key 的元素
        while (j >= 0 && arr[j] > key) {
            arr[j + 1] = arr[j];
            j--;
        }
        arr[j + 1] = key; // 插入 key
    }
}

算法分析

  • 时间复杂度
    • 最佳情况:如果数组已经有序,内层 while 循环每次立即终止,时间复杂度为 O(n)
    • 最坏情况:如果数组完全逆序,每个元素都需要移动到最左边,时间复杂度为 O(n²)
    • 平均情况也是 O(n²),但通常比选择排序快,因为它可以提前终止内层循环。
  • 空间复杂度:插入排序是原地排序算法。
  • 稳定性:插入排序是稳定的排序算法。因为它是依次交换相邻元素,相等的元素不会跨越彼此。
  • 在线排序:插入排序有一个独特的优点——在线排序。这意味着它不需要一开始就拥有全部数据。数据可以逐个到达,每到达一个,就将其插入到已排序部分的正确位置。这对于处理数据流非常有用。

我们已经学习了两种朴素的排序算法,接下来我们将探讨更高效的算法。首先介绍的是基于我们之前学过的数据结构——堆——的堆排序。

堆排序算法 ⛰️

堆排序是一种高效且有趣的排序算法,它利用了二叉堆(特别是最大堆)的性质。其核心思想分为两步:

  1. 建堆:将待排序的数组原地构建成一个最大堆。最大堆的根节点是数组中的最大元素。
  2. 排序:重复将堆的根节点(当前最大值)与堆的最后一个元素交换,然后减小堆的大小,并对新的根节点进行“下沉”操作以恢复堆的性质。这样,最大值就被依次放置到了数组的末尾。

算法步骤与巧妙之处

  1. 将无序数组 A[0...n-1] 构建成最大堆。
  2. 此时 A[0] 是最大元素。将其与 A[n-1] 交换。现在,最大元素位于数组末尾的正确位置。
  3. 堆的大小减 1(忽略最后一个元素),对新的 A[0] 进行下沉操作,使其重新成为最大堆。
  4. 重复步骤 2 和 3,直到堆的大小为 1。此时数组已经完全有序。

这个算法的巧妙之处在于它是原地排序的。整个排序过程都在原数组上进行,通过交换和堆调整,最终得到一个有序数组,而不需要像朴素的堆排序实现那样额外复制一个数组。

算法复杂度分析

  • 建堆:使用 Floyd 算法,时间复杂度为 O(n)
  • 排序:需要进行 n-1 次“移除堆顶”操作,每次操作(交换后下沉)的时间复杂度为 O(log n)
  • 总时间复杂度O(n log n)。并且,堆排序的复杂度非常稳定,最好、最坏和平均情况都是 O(n log n)。
  • 空间复杂度:原地实现,空间复杂度为 O(1)
  • 稳定性:堆排序是不稳定的排序算法。在构建堆和下沉的过程中,相等的元素可能会改变相对顺序。

由于其稳定的 O(n log n) 性能,堆排序被用于一些对性能一致性要求高的场景,例如 Linux 内核中的排序函数。

总结 📝

本节课中我们一起学习了排序的基础知识和几种重要的排序算法。

  • 我们首先明确了排序的定义和重要性,它依据特定标准排列元素,是许多其他算法的基础。
  • 我们从时间复杂度空间复杂度稳定性三个维度对排序算法进行了分类。
  • 我们详细研究了两种朴素算法
    • 选择排序:简单直观,每次选择未排序部分的最小值,时间复杂度恒为 Θ(n²),原地但不稳定。
    • 插入排序:通过将元素插入已排序序列来工作,最佳情况可达 O(n),具有稳定性和在线排序的优点。
  • 最后,我们介绍了一种高效算法——堆排序。它利用最大堆在 O(n log n) 时间内完成原地排序,性能稳定但非稳定。

在接下来的课程中,我们将继续学习其他高效的排序算法,如归并排序和快速排序,并深入比较它们的优劣。理解这些算法的原理和特性,将帮助你在实际编程中做出最合适的选择。

023:排序算法(下)🎯

在本节课中,我们将继续学习高效的排序算法,包括归并排序、快速排序,以及两种非比较排序算法:桶排序和基数排序。我们还将探讨现代混合排序算法及其应用。

上一节我们介绍了选择排序、插入排序和堆排序等基础算法。本节中,我们将深入探讨基于“分治”策略的更高效算法。

分治策略概述

归并排序和快速排序采用了“分治”策略。这类算法通常包含三个步骤:

  1. :将复杂问题分解为更小的子问题。
  2. :递归地解决这些子问题。
  3. :将子问题的解合并,得到原问题的解。

归并排序 (Merge Sort) 🔄

归并排序是分治策略的典型应用。其核心思想是将数组递归地分成两半,分别排序,然后将两个已排序的子数组合并成一个完整的有序数组。

以下是合并两个已排序子数组的核心过程:

void merge(vector<int>& A, vector<int>& aux, int low, int mid, int high) {
    // 将 A[low..high] 复制到辅助数组 aux
    for (int k = low; k <= high; k++) {
        aux[k] = A[k];
    }

    int i = low;      // 左半部分的起始索引
    int j = mid + 1;  // 右半部分的起始索引

    // 合并回原数组 A
    for (int k = low; k <= high; k++) {
        if (i > mid) {
            // 左半部分已耗尽
            A[k] = aux[j++];
        } else if (j > high) {
            // 右半部分已耗尽
            A[k] = aux[i++];
        } else if (aux[j] < aux[i]) {
            // 右半部分的当前元素更小
            A[k] = aux[j++];
        } else {
            // 左半部分的当前元素更小或相等(保持稳定性)
            A[k] = aux[i++];
        }
    }
}

归并排序的递归函数如下:

void mergeSortRecursive(vector<int>& A, vector<int>& aux, int low, int high) {
    if (high <= low) return; // 递归基:子数组为空或只有一个元素
    int mid = low + (high - low) / 2;
    mergeSortRecursive(A, aux, low, mid);    // 排序左半部分
    mergeSortRecursive(A, aux, mid + 1, high); // 排序右半部分
    merge(A, aux, low, mid, high);           // 合并已排序的两部分
}

用户调用的顶层函数:

void mergeSort(vector<int>& A) {
    vector<int> aux(A.size()); // 创建辅助数组
    mergeSortRecursive(A, aux, 0, A.size() - 1);
}

归并排序特性总结:

  • 时间复杂度O(n log n)
  • 空间复杂度:需要 O(n) 的额外辅助空间。
  • 稳定性稳定。当元素相等时,会优先取左边子数组的元素,保持了原始相对顺序。
  • 适用性:特别适合链表排序,因为其合并过程是顺序访问的。

快速排序 (Quick Sort) ⚡

快速排序是另一种分治算法,但其核心是“分区”操作。它选择一个“基准”元素,将数组重新排列,使得所有小于基准的元素都在其左侧,所有大于基准的元素都在其右侧。然后递归地对左右两个子数组进行相同操作。

以下是分区过程的代码示例:

int partition(vector<int>& A, int low, int high) {
    int pivot = A[low]; // 选择第一个元素作为基准(实践中可能有更好选择)
    int i = low + 1;
    int j = high;

    while (true) {
        // 从左向右找到第一个大于等于基准的元素
        while (i <= high && A[i] < pivot) i++;
        // 从右向左找到第一个小于等于基准的元素
        while (j >= low + 1 && A[j] > pivot) j--;

        if (i >= j) break; // 指针相遇或交叉,分区结束

        // 交换这两个元素
        swap(A[i], A[j]);
        i++;
        j--;
    }
    // 将基准元素放到正确位置(即 j 所指位置)
    swap(A[low], A[j]);
    return j; // 返回基准元素的最终位置
}

快速排序的递归函数:

void quickSortRecursive(vector<int>& A, int low, int high) {
    if (high <= low) return; // 递归基
    int p = partition(A, low, high); // 分区,获取基准位置
    quickSortRecursive(A, low, p - 1); // 排序左子数组
    quickSortRecursive(A, p + 1, high); // 排序右子数组
}

void quickSort(vector<int>& A) {
    quickSortRecursive(A, 0, A.size() - 1);
}

快速排序特性总结:

  • 平均时间复杂度O(n log n)
  • 最坏时间复杂度O(n²)(例如当数组已有序且总是选择最边缘元素作为基准时)。
  • 空间复杂度原地排序,但递归调用栈需要 O(log n) 的空间。
  • 稳定性不稳定
  • 流行度:因其在平均情况下的极高效率,是许多编程语言标准库(如C的qsort,Java对基本类型的排序)的默认或常用排序算法。

非比较排序算法

以下算法不直接通过比较元素大小来排序,因此在特定条件下可以达到线性时间复杂度。

桶排序 (Bucket Sort) 🪣

桶排序假设输入数据均匀分布在一定范围内。它将数据分到有限数量的“桶”中,然后对每个桶单独排序(可使用其他简单排序算法),最后按顺序连接所有桶。

以下是桶排序的简化思路:

void bucketSort(vector<int>& A) {
    // 假设 A 中的元素在 [0, 99] 范围内
    int numBuckets = 10;
    vector<vector<int>> buckets(numBuckets);

    // 1. 将元素放入对应的桶中
    for (int num : A) {
        int bucketIdx = num / 10; // 根据十位数决定桶
        buckets[bucketIdx].push_back(num);
    }

    // 2. 对每个桶内部进行排序(例如使用插入排序)
    for (auto& bucket : buckets) {
        sort(bucket.begin(), bucket.end()); // 实际可用任何排序
    }

    // 3. 按顺序从桶中取出元素
    int idx = 0;
    for (int i = 0; i < numBuckets; ++i) {
        for (int num : buckets[i]) {
            A[idx++] = num;
        }
    }
}

桶排序特性:

  • 在数据分布均匀且范围不大时,性能接近 O(n)
  • 最坏情况是所有数据落入同一个桶,性能取决于桶内排序算法。

基数排序 (Radix Sort) 🔢

基数排序是一种非比较整数排序算法。它根据键值的每位数字来分配和收集元素。可以从最低位(个位)开始(LSD),也可以从最高位开始(MSD)。

算法步骤(LSD为例):

  1. 按照个位数,将每个元素分配到0-9号桶中。
  2. 按桶顺序(0->9)收集元素,形成新的序列。
  3. 按照十位数,将新序列中的每个元素再次分配到0-9号桶中。
  4. 再次收集。
  5. 重复此过程,直到处理完最高位。完成后,序列即有序。

基数排序特性:

  • 时间复杂度O(k * n),其中 k 是最大数字的位数。当 k 远小于 n 时,效率很高。
  • 需要稳定的子排序过程(如计数排序)作为桶内收集的基础。
  • 适用于整数、字符串等有固定“位”或“字符”结构的数据。

现代混合排序与总结 🏁

在实际应用中,标准库的排序函数往往是混合策略,以在各种场景下达到最佳性能。

  • 内省排序 (Introsort):结合了快速排序、堆排序和插入排序的优点。通常先使用快速排序,当递归深度过大时切换到堆排序避免最坏情况,对小数组则使用简单的插入排序。
  • Timsort:Python 和 Java 等语言采用的算法。它利用了现实中数据常部分有序的特性,是归并排序和插入排序的优化混合体。
  • 库排序 (Library Sort):受图书馆书架预留空间启发,在插入排序基础上预留“空隙”,便于后续插入新元素而无需完全重排。

总结:
本节课我们一起深入学习了多种排序算法。我们从归并排序快速排序的分治思想入手,理解了它们的高效原理与实现细节。接着,我们探索了不基于比较的桶排序基数排序,它们在特定条件下能达到线性时间复杂度。最后,我们了解到现代工业级排序(如内省排序、Timsort)是如何融合多种算法优势以应对真实世界的数据。排序是计算机科学的核心操作,大多数编程语言都提供了高度优化的内置排序函数,理解其背后的原理将帮助我们做出更好的技术选则和性能分析。

024:哈希表基础 🗂️

在本节课中,我们将要学习一个非常重要的数据结构——哈希表。我们将从理解它的基本概念开始,探讨为什么需要它,以及它是如何工作的。

概述

在计算机科学中,有一种基础且至关重要的数据结构称为符号表。它也被称为映射、字典等。符号表的核心功能是通过键来索引和存储值。我们将重点学习符号表的三个核心操作:插入、删除和搜索。本节课的目标是理解如何实现这些操作在常数时间复杂度 O(1) 内完成,并引入实现这一目标的关键技术——哈希

什么是符号表?

符号表是一种可以按键索引并存储值的数据结构。它允许我们将一个值与一个键关联起来。

以下是符号表的三个核心操作:

  • 插入:将一个新的键值对放入符号表。
  • 删除:从符号表中移除一个键值对。
  • 搜索:检查一个键是否存在于表中,并可能检索其对应的值。

除了这些核心操作,根据应用场景,符号表还可能支持其他操作,例如查找最小键、最大键或对键进行排序等。但本节课的重点将放在插入、删除和搜索上。

现有数据结构的局限性

在引入哈希表之前,我们先回顾一下已学过的数据结构在实现符号表时的表现。

无序数组/向量

如果使用无序数组来存储键值对,其操作复杂度如下:

  • 搜索:需要扫描整个数组,复杂度为 O(n)
  • 插入:如果键不存在,可以直接添加到数组末尾,复杂度为 O(1)
  • 删除:需要先找到键的位置(O(n)),然后可以通过将最后一个元素移到该位置并缩小数组来实现删除,这部分为 O(1)。总体复杂度仍为 O(n)

有序数组与二分查找

如果保持数组有序以使用二分查找,其操作复杂度如下:

  • 搜索:使用二分查找,复杂度为 O(log n)
  • 插入:为了保持数组有序,需要在正确位置插入元素,这可能需要移动大量元素,复杂度为 O(n)
  • 删除:同样需要移动元素以保持有序,复杂度为 O(n)

二叉搜索树

使用二叉搜索树来组织键,其操作复杂度如下:

  • 在普通二叉搜索树中,搜索、插入和删除的复杂度取决于树的高度。平均情况下为 O(log n),但在最坏情况(树退化成链表)下为 O(n)
  • 通过使用自平衡二叉搜索树(如AVL树、红黑树),可以保证树的高度始终在 O(log n) 范围内。因此,所有核心操作都能保证在 O(log n) 复杂度内完成。

O(log n) 已经是很好的性能,但我们能否做得更好?能否实现所有核心操作都在 O(1) 的常数时间内完成?这就是哈希表要解决的问题。

哈希表的直观想法

为了理解如何实现 O(1) 的操作,让我们看一个简单的例子。假设我们要统计一段文本中每个字母出现的频率。

我们可以直接使用字母的 ASCII 码作为数组的索引。例如,字母 ‘A’ 的 ASCII 码是 97,我们就在数组的第 97 个位置存储 ‘A’ 出现的次数。

// 伪代码示例
int frequency[128] = {0}; // ASCII 码范围是 0-127
for (char c in text) {
    int index = (int)c; // 将字符转换为 ASCII 码(整数)
    frequency[index]++; // 直接通过索引访问并增加计数
}

这种方法实现了:

  • 搜索/访问frequency[‘A’] 直接通过索引 97 访问,复杂度为 O(1)
  • 插入/更新:同上,为 O(1)
  • 删除:将对应位置清零,复杂度为 O(1)

然而,这种方法有局限性:

  1. 它不高效支持查找最小键、排序等其他操作。
  2. 可能浪费空间。例如,ASCII 码中有许多控制字符不会在文本中出现,但数组仍需为它们预留位置。
  3. 最关键的是,它要求键本身必须是或能直接转换为一个小范围的整数。如果我们的键是单词(字符串),就无法直接用字符串作为数组索引。

这引出了哈希的核心思想:设计一个函数,能够将任意类型的键(如字符串)转换(或“映射”)为一个整数索引,然后用这个索引来访问数组。

哈希函数

这个将键转换为数组索引的函数称为哈希函数

哈希函数需要满足几个要求:

  1. 计算速度快,因为我们希望所有操作都接近 O(1)。
  2. 确定性:相同的键必须始终产生相同的哈希值(索引)。
  3. 均匀分布:哈希函数应尽可能地将不同的键均匀地映射到整个索引范围内,以减少冲突。

哈希过程的两步走

典型的哈希过程包含两个步骤:

  1. 哈希码计算:将键(可能不是整数,如字符串)转换为一个整数,称为哈希码。这个哈希码通常在一个较大的范围(如 0 到 N-1)内。
    • 公式:hash_code = some_function(key)
  2. 压缩函数:将上一步得到的大范围哈希码压缩到哈希表数组的索引范围(0 到 M-1,其中 M 是数组大小)。通常 M 远小于 N。
    • 公式:index = compress(hash_code) % M

糟糕的哈希函数示例

理解什么是不好的哈希函数有助于我们设计好的哈希函数。

示例1:常数函数
一个总是返回相同索引(例如总是 0)的哈希函数。这会导致所有键都发生冲突,哈希表退化为一个单点,完全失效。

示例2:使用键的局部特征
假设键是电话号码(如 (530) 123-4567),哈希表大小为 1000。一个糟糕的想法是直接取电话号码的区号(530)作为索引。如果哈希表中许多号码的区号都是 530(例如 UC Davis 的学生),那么这些键都会冲突到索引 530 上,分布极不均匀。

更好的方法是考虑键的更多部分或全部,并使用一个能均匀混合键中信息的压缩方法。

整数键的哈希与取模运算

对于本身就是正整数的键,最简单的哈希函数是使用取模运算

假设键 k 的范围是 [0, K-1],我们希望将其映射到哈希表索引范围 [0, M-1]。哈希函数可以定义为:

index = k % M

这里,% 是取模运算符。这个函数简单地将键压缩到表大小的范围内。

表大小 M 的影响

表大小 M 的选择对哈希函数的性能,尤其是在键分布不均匀时,有重要影响。

让我们通过一个实验来观察。假设我们有一组键,范围在 0 到 999 之间。

场景一:均匀分布的键
当键是随机均匀生成时,无论 M 是 100 还是 97,冲突的数量大致相同且较低。这是因为每个索引被选中的概率均等。

场景二:非均匀分布的键
现在,假设我们的键不是随机的,而是呈现出某种模式,例如只取 0, 25, 50, 75, 100, 125… 这些值。

  • M = 100 时,由于我们使用 k % 100,这些键(50, 150, 250…)的哈希值都是 50,导致大量冲突。
  • M = 97(一个质数)时,同样的键集合被哈希后,会均匀地分布在 0 到 96 的索引上,在这个例子中甚至可以实现零冲突。

为什么质数有帮助?

取模运算 k % M 的结果只依赖于 k 除以 M 的余数。如果键 k 构成一个等差数列(如 0, 25, 50…),且公差 dM 有公因数,那么哈希值序列也会出现周期性的重复,导致分布不均。选择质数作为 M,因为它与大多数可能的公差 d 都互质,有助于打破这种规律性,使得即使键有固定模式,哈希结果也能更均匀地分布。

总结

本节课我们一起学习了哈希表的基础知识。

  • 我们首先明确了符号表的核心操作(插入、删除、搜索)以及追求 O(1) 时间复杂度目标的意义。
  • 我们通过“用 ASCII 码作索引”的例子,直观理解了哈希的思想:将键映射到数组索引
  • 我们介绍了哈希函数的概念、要求以及其两步过程(计算哈希码和压缩)。
  • 我们探讨了对于整数键,如何使用简单的取模运算作为哈希函数,并发现了选择质数作为哈希表大小可以在键分布不均匀时有效减少冲突。

然而,只要我们将一个大的键空间压缩到一个小的数组索引空间,冲突(两个不同的键哈希到同一个索引)就不可避免。在下一节课中,我们将重点讨论如何处理哈希冲突的各种策略。

025:哈希表(第二部分)🎯

在本节课中,我们将继续学习哈希表。上一节我们介绍了哈希表的基本概念和哈希函数,本节我们将深入探讨如何为字符串等复杂类型设计哈希函数,并学习处理哈希冲突的第一种方法——分离链接法。


哈希函数进阶

上一节我们介绍了如何为简单的正整数设计哈希函数。本节中我们来看看如何为字符串等更复杂的键设计哈希函数。

字符串的哈希函数

一个简单但效果不佳的方法是:将字符串中每个字符的ASCII码值相加,然后对哈希表大小取模。

代码示例:

size_t hashStringNaive(const std::string& key, size_t M) {
    size_t hash = 0;
    for (char c : key) {
        hash += static_cast<size_t>(c); // 累加ASCII码
    }
    return hash % M; // 取模得到索引
}

这种方法存在一个问题:英语单词的平均长度约为4.5个字符,每个ASCII码值在0-127之间。因此,大多数单词的哈希码会集中在较小的数值范围内(例如,低于500),导致哈希表前半部分非常拥挤,而后半部分几乎空置,造成严重的空间浪费和碰撞。

改进方法:霍纳方法

为了获得更好的分布,我们可以使用霍纳方法。该方法在累加过程中,将当前的哈希值乘以一个常数(通常是一个小质数,如31),然后再加上下一个字符的值。

公式:
hash = (hash * 31 + c) % M (在实际计算哈希码时通常先不取模,最后一步再取模)

代码示例:

size_t hashStringImproved(const std::string& key, size_t M) {
    size_t hash = 0;
    for (char c : key) {
        hash = hash * 31 + static_cast<size_t>(c);
    }
    return hash % M;
}

选择31作为乘数有几个原因:

  1. 它是一个质数,有助于产生良好的分布。
  2. 在早期或资源受限的处理器上,乘以31 可以通过 乘以32(即左移5位)再减去自身 来高效实现。
  3. 实践证明,31能为许多数据集提供良好的分布特性。

使用改进的方法后,不同字符串的哈希值分布更加均匀,能更好地利用整个哈希表空间。

通用数据结构的哈希

对于包含多个字段的结构(如电话号码、地址),可以采用类似的思路:将每个字段视为一个“部分”,并应用霍纳方法的原则进行组合。

核心思想:
hash = (((field1 * 31) + field2) * 31 + field3) ... ) % M

设计哈希函数时需注意:

  • 高效性:哈希函数应尽可能快。
  • 关键信息:对于复杂对象,应选取最能区分不同对象的部分进行哈希,忽略共性部分(例如,在地址中,“街道”这个词很常见,但街道名本身是关键)。

哈希冲突与生日悖论

即使使用最好的哈希函数,只要键的数量可能超过哈希表的大小,冲突就不可避免。我们可以用“生日悖论”来理解冲突发生的概率。

生日悖论指出:在一个23人的群体中,至少有两人同一天生日的概率超过50%。当群体达到70人时,概率接近100%。

类比到哈希表:

  • 哈希表大小 (M) 相当于一年的天数(例如365)。
  • 插入的键数量 (N) 相当于群体人数。

根据生日悖论推导出的公式,第一次冲突发生的平均插入次数约为:
sqrt(π/2 * M)

这意味着,如果我们希望平均不发生冲突,哈希表的大小需要远大于预计存储的键数量(通常是平方关系),这在实践中往往不现实。因此,我们必须设计机制来处理冲突。


处理冲突:分离链接法

分离链接法是处理哈希冲突最直接的方法之一。其核心思想是:哈希表的每个位置(桶)不再存储单个元素,而是存储一个链表(或其他容器)。所有哈希到同一位置的键值对都放在这个链表中。

工作原理

  1. 当插入一个键时,先通过哈希函数计算其索引。
  2. 找到该索引对应的链表。
  3. 将新键插入到这个链表的末尾(或合适位置,取决于是否允许重复)。
  4. 当查找或删除一个键时,同样先找到对应的链表,然后在链表中进行线性查找。

代码实现

以下是使用C++标准库组件实现分离链接法哈希表的核心框架:

数据结构定义:

template <typename K>
class HashTableSC {
private:
    std::vector<std::list<K>> table; // 哈希表,每个桶是一个链表
    size_t currentSize; // 当前元素数量
    size_t capacity; // 哈希表桶的数量(初始为质数,如11)

    // 内部哈希函数
    size_t hash(const K& key) const {
        static std::hash<K> hasher; // 使用标准库哈希函数对象
        return hasher(key) % capacity;
    }
public:
    HashTableSC() : currentSize(0), capacity(11) {
        table.resize(capacity);
    }
    // ... 成员函数
};

基本操作实现:

以下是核心操作的实现思路:

  • 查找 (Contains)

    1. 计算键的哈希值,得到桶索引。
    2. 在该索引对应的链表中,使用 std::find 查找键。
    3. 返回查找结果(布尔值)。
  • 插入 (Insert)

    1. 先检查键是否已存在(调用Contains)。
    2. 若不存在,计算哈希值得到桶索引。
    3. 将键添加到对应链表的末尾。
    4. 更新元素数量 currentSize
  • 删除 (Remove)

    1. 先检查键是否存在。
    2. 若存在,计算哈希值得到桶索引。
    3. 在对应链表中找到该键的迭代器,并使用链表的 erase 方法删除。
    4. 更新元素数量 currentSize

性能分析与动态扩容

在分离链接法中,操作的性能取决于链表的长度。我们引入 负载因子 α 来衡量哈希表的拥挤程度。

公式:
α = N / M
其中,N 是元素总数,M 是桶的数量。

  • 最坏情况:所有元素都哈希到同一个桶,链表长度为N,操作复杂度为O(N)。
  • 平均情况(使用良好哈希函数):元素均匀分布,每个链表的平均长度为 α。
    • 查找、插入、删除的平均时间复杂度为 O(α)

为了保持哈希表的高效(即O(1)的平均复杂度),我们需要将负载因子 α 控制在一个较小的常数范围内(例如 α < 1)。由于元素数量 N 由用户决定,我们通过调整桶的数量 M 来控制 α。

动态扩容策略:

  1. 当插入新元素后,负载因子 α 超过预设阈值(例如1.0)时,触发扩容。
  2. 创建一个新的、更大的桶数组(通常将容量翻倍,并选择一个接近的质数作为新容量)。
  3. 重哈希:遍历旧哈希表中的每个桶和每个元素,用新的容量 M' 重新计算其哈希值,并插入到新桶数组的相应位置。
  4. 用新数组替换旧数组。

复杂度分析:

  • 单次扩容操作是O(N)的,因为它需要重哈希所有元素。
  • 然而,扩容操作发生的频率很低(容量呈指数增长)。类似于动态数组的扩容,这种成本被 分摊 到了多次插入操作中。
  • 因此,在采用动态扩容的分离链接哈希表中,插入、查找、删除操作的平摊时间复杂度为 O(1)

总结

本节课中我们一起学习了哈希表的核心进阶内容:

  1. 复杂键的哈希:学习了如何为字符串设计有效的哈希函数,重点介绍了霍纳方法,它通过引入乘法使哈希值分布更均匀。
  2. 哈希冲突的必然性:通过生日悖论理解了冲突无法避免的本质,并知道了第一次冲突发生的平均时间。
  3. 分离链接法:深入探讨了处理冲突的第一种方法。其核心是将哈希表的每个桶实现为一个链表,所有冲突元素都放入同一链表。
  4. 实现与性能:我们查看了分离链接法的代码框架,并分析了其性能。关键在于通过动态扩容控制负载因子,从而将各项操作的平摊时间复杂度维持在 O(1)

分离链接法实现简单,是处理冲突的可靠方法。下一节,我们将学习另一种处理冲突的策略——开放寻址法。

026:哈希(下)🔑

在本节课中,我们将继续学习哈希表,重点探讨处理哈希冲突的另一种核心方法——开放寻址法。我们将了解其工作原理、具体实现(包括线性探测、二次探测和双重哈希),并分析其优缺点。最后,我们会介绍C++标准库中基于哈希表的无序容器。

上一节我们介绍了使用分离链接法(Separate Chaining)来解决哈希冲突。本节中,我们来看看另一种截然不同的思路:开放寻址法(Open Addressing)。

开放寻址法概述

在开放寻址法中,哈希表的结构保持不变,即一个固定大小的数组。如果两个键被哈希到同一个数组索引(发生冲突),我们不会使用链表,而是按照一个预定的规则(探测函数)去寻找数组中下一个可用的位置来存放这个键。

其核心思想是:当在索引 H0 处发生冲突时,我们通过一个函数 F(i) 计算一个偏移量,从而尝试新的索引 H1 = H0 + F(1)。如果 H1 仍被占用,则继续尝试 H2 = H0 + F(2),依此类推,直到找到一个空位。

以下是探测过程的伪代码描述:

index = hash(key)
i = 0
while table[(index + F(i)) % table_size] is occupied:
    i = i + 1
insert key at table[(index + F(i)) % table_size]

线性探测

线性探测是最直观的探测方法,其偏移函数为 F(i) = i。这意味着发生冲突时,我们简单地依次检查下一个索引位置。

例如,假设键 U 被哈希到索引0,但该位置已被键 3 占用。使用线性探测:

  1. 尝试索引 0+1 = 1,如果空闲则插入 U
  2. 如果索引1也被占用,则尝试索引 0+2 = 2,以此类推。

线性探测容易导致聚集现象:连续的冲突会形成大块的已占用区域,这会导致后续插入需要多次探测,性能下降。

代码实现

我们来看一个基于线性探测的哈希表实现框架。哈希表的每个位置是一个节点,包含键和一个标记是否被占用的布尔值。

以下是节点和哈希表类的结构定义:

struct Node {
    KeyType key;
    bool taken = false; // 标记此位置是否被占用
};

class HashTable {
private:
    std::vector<Node> table;
    int size = 0; // 当前存储的键数量
    int capacity; // 哈希表总容量
    // ... 哈希函数、调整大小函数等
public:
    bool contains(const KeyType& key);
    void insert(const KeyType& key);
    void remove(const KeyType& key);
};

查找操作

查找操作遵循探测逻辑:从哈希计算出的初始位置开始,如果该位置的键匹配,则查找成功。如果不匹配,则根据探测函数(如线性探测)检查下一个位置,直到找到键或遇到一个空位(表示键不存在)。

以下是查找操作的伪代码流程:

index = hash(key) % capacity
i = 0
while table[(index + i) % capacity] is taken:
    if table[(index + i) % capacity].key == target_key:
        return true
    i = i + 1 // 线性探测:F(i)=i
return false

插入操作

插入前先检查键是否已存在。如果不存在,则从哈希位置开始线性探测,寻找第一个空位进行插入。插入后,需要检查负载因子(已用位置数量 / 总容量)。为了平衡冲突概率和内存使用,我们通常设定一个阈值(例如0.5),当负载因子超过该阈值时,对哈希表进行扩容并重新哈希所有键。

删除操作

删除操作是开放寻址法中的一个难点。不能简单地将找到的键所在位置标记为空。考虑以下场景:键 UV 哈希冲突,U 被插入到其下一个可用位置(比如索引1),V 又被插入到更下一个位置(索引2)。如果直接删除 U 并将索引1置空,那么后续查找 V 时,从 V 的原始哈希位置(索引1)开始探测,发现该位置为空,就会错误地认为 V 不存在。

解决方案是:删除一个键后,需要将其所在聚集块中后续的所有键重新插入(重新哈希),以填补可能产生的“空洞”,确保探测链的连续性。

其他探测方法

除了线性探测,还有其他的冲突解决策略:

  • 二次探测:偏移函数为 F(i) = i²。即第一次冲突尝试偏移1,第二次冲突尝试偏移4,第三次尝试偏移9... 这有助于缓解线性探测带来的聚集问题。
  • 双重哈希:偏移量由第二个哈希函数计算得出,即 F(i, key) = i * hash2(key)。这种方法能产生最好的分布性,极大减少聚集,但每次探测可能需要访问内存中相距较远的位置,可能影响缓存性能。

以下是三种方法的简单对比:

  • 线性探测:实现简单,但容易产生聚集;探测步长短,缓存友好。
  • 二次探测:减轻了聚集,但探测序列可能无法覆盖所有槽位。
  • 双重哈希:分布性最好,聚集最少;但计算稍复杂,可能破坏局部性。

C++标准库中的哈希表

C++标准库提供了基于哈希表的无序关联容器。它们不维护元素的任何特定顺序,但提供了平均情况为常数时间的查找、插入和删除操作。

  • std::unordered_set:仅存储键的集合。
  • std::unordered_map:存储键值对的映射。

这与之前学到的、基于红黑树实现的有序关联容器(std::set, std::map)形成对比。有序容器提供对数级复杂度的操作,并保持元素有序。

以下是一个使用 std::unordered_map 的示例:

#include <unordered_map>
#include <iostream>

int main() {
    std::unordered_map<int, std::string> student_map;

    // 插入键值对
    student_map[101] = "Alice";
    student_map.insert({102, "Bob"});
    student_map.insert(std::make_pair(103, "Charlie"));

    // 遍历(顺序是不确定的)
    for (const auto& entry : student_map) {
        std::cout << entry.first << ": " << entry.second << std::endl;
    }
    // 输出顺序可能是 103: Charlie, 101: Alice, 102: Bob
    return 0;
}

总结

本节课中我们一起学习了哈希冲突的第二种主要解决方案——开放寻址法。我们深入探讨了其核心思想,即通过探测函数在数组内寻找空位。我们详细分析了线性探测的实现,包括查找、插入以及需要特殊处理的删除操作。我们还简要介绍了二次探测双重哈希等其他策略。最后,我们了解了C++标准库中基于哈希表的无序容器(std::unordered_map/set)及其与有序容器的区别。选择开放寻址还是分离链接,取决于具体的应用场景、对内存和性能的权衡。

posted @ 2026-03-29 09:28  布客飞龙II  阅读(8)  评论(0)    收藏  举报