UIUC-CS225-数据结构算法笔记-全-

UIUC CS225 数据结构算法笔记(全)

001:导论

在本节课中,我们将学习课程的基本介绍,包括课程目标、教学团队、课程结构以及C++编程语言的基础概念。

课程与教学团队介绍

大家好,欢迎来到CS 225课程。今天我们将进行课程导论,介绍课程内容与运行方式。课程讲座将通过Twitch平台直播进行,实验课将通过Zoom平台进行,更具协作性。

首先,我们来介绍教学团队。

我是卡尔·埃文斯。我在伊利诺伊大学任教多年,并且是在本地长大的。我的研究方向是计算科学,特别是通过模拟来替代昂贵的实验。例如,我曾研究等离子体耦合燃烧的模拟。此外,我也研究过并行计算中的系统求解器,以及程序流程控制的图形化表示,这与本课程的部分内容相关。

接下来是布拉德·所罗门。我是一名新任的计算机科学教学助理教授。我的背景是计算基因组学,主要研究如何高效地存储、搜索和分析大型基因组数据集。我开发过如序列B树等新颖的数据结构。本学期,我还将负责荣誉课程部分,重点讲授字符串算法和数据结构,并将其与图算法等核心概念结合。

最后是特里。我负责本课程的后勤与行政管理工作,协调课程各部分之间的沟通,处理日程安排、考试缺席申请等事务性问题。如果大家对课程运行方式有任何疑问,可以联系我。

课程沟通与资源

有多种方式可以联系我们并获取帮助。

  • 最佳的联系方式是发送邮件至课程管理邮箱:cs225admin@illinois.edu
  • 我们有一个Discord服务器,非常适合快速提问和交流。虽然我们不会在那里进行私人的代码调试,但会有很多同学乐于互相帮助。
  • 我们使用Piazza平台进行课程讨论。请注意,我们使用的是免费版本,没有付费要求

学生背景与课程起点

了解大家的编程背景对课程安排很重要。本课程的先修要求主要涉及理论和编程两方面。

在理论方面,大部分同学可能来自CS 173或MATH 213课程,学习了算法分析、图、树和证明等概念。

在编程方面,大家的背景更加多样:

  • 许多同学来自CS 125(Java)或CS 126(Java转向C++)。
  • 部分同学来自ECE 220(C语言,后期涉及C++)。
  • 我们还看到有Python、Haskell、Pascal等多种语言背景的同学。

本学期我们首次开设了CS 128课程,它将教授C++,未来可能会影响本课程的先修要求。但目前,本课程的前几周将重点讲解C++,以确保所有同学都能跟上。

课程核心:数据结构是什么

本课程的核心是数据结构。但数据结构究竟是什么?我们可以通过图书馆的比喻来理解。

想象三个不同的图书馆:

  1. 本科图书馆:一个通用图书馆,采用杜威十进制系统按主题分类书籍。这种结构便于按概念进行探索和发现。
  2. 绘本图书馆:书籍全部封面朝外摆放。这种结构针对儿童设计,便于通过视觉发现书籍,解决了“如何让孩子找到想看的书”这个特定问题。
  3. 博德利图书馆:书籍按照入馆时间顺序排列。这种结构便于观察馆藏随时间的演变,解决了“如何展示历史脉络”的问题。

这些图书馆都存储信息并使其可用,但因其服务的目标不同,而采用了截然不同的组织结构。

数据结构也是如此。它是组织信息的一种架构。我们选择如何存储数据,决定了哪些操作会变得简单,哪些会变得困难。数据本身很重要,但我们组织数据的最终目的是为了使用它来完成特定任务。

在本课程中,我们将学习:

  • 如何设计数据架构:为了完成特定任务,应如何组织数据。
  • 实践层面:如何用代码实现这些结构。
  • 理论层面:如何分析这些结构的性能和行为。

这门课巧妙地将计算机科学中抽象的理论与具体的实现细节结合在了一起。

C++编程基础:核心差异

对于来自Java等语言的同学,C++在语法上看起来相似,但有一个根本性差异:C++以具体的方式管理内存

在C++中,每个变量都包含几个要素:

  • 名称(Name):变量的标识符。
  • 类型(Type):如 int, char, double,或用户定义的类(如 Cat)。
  • 内存位置(Memory Location):变量在计算机内存中存储的地址。
  • 值(Value):存储在该内存位置的数据。

对于未初始化的基本类型变量(如 int my_favorite_int;),其值是未定义的(undefined),即该内存位置原先存在的、未知的数据。这是C++编程中常见的问题来源。

对于类对象(如 Cat guinevere;),如果没有指定,它们会通过默认构造函数(default constructor)进行初始化。

关于 NULLnullptr:在C++11及以后的标准中,推荐使用 nullptr 表示空指针。NULL 通常是一个定义为0的宏,在旧代码中常见。

封装与文件组织

封装是将接口与实现分离的思想,这非常重要。

  • 接口(Interface):描述如何使用一个类。例如,数组的接口是通过方括号和索引来访问元素。
  • 实现(Implementation):描述功能如何被完成。例如,一个图形类中画圆功能的具体代码。

不同的实现方式(如使用数组、指针等)不会改变接口,但会严重影响操作的效率。

C++通过多文件结构来实现封装:

  • 头文件(.h 文件):包含类的接口声明。
  • 源文件(.cpp 文件):包含类的具体实现代码。

头文件通常以 #pragma once 指令开头,这是一个头文件保护(header guard),确保该文件在编译时只被包含一次,防止重复定义。

在头文件中,我们定义类。类内部通常分为 public(公开接口)和 private(私有实现)部分。私有成员变量常以下划线结尾(如 length_)作为命名约定。

有时,非常简单的函数实现也会直接写在头文件中。C++功能强大但需要谨慎使用,就像链锯一样,初学者可能容易出错,但熟练掌握后能完成非常高效的工作。

总结

本节课我们一起学习了CS 225课程的概览。我们认识了教学团队,了解了课程沟通渠道。我们探讨了数据结构的本质——它是为特定任务而设计的信息组织架构,并通过图书馆的比喻加深了理解。最后,我们介绍了C++与Java的核心区别,即其对内存的具体管理,并学习了C++中通过头文件和源文件实现封装的基本概念。下节课我们将继续深入C++的细节。

002:内存管理基础 🧠

在本节课中,我们将学习C++中关于内存管理的基础知识,包括构造函数、引用和指针。这些概念是理解C++如何管理内存以及它与Java等语言区别的关键。


构造函数回顾与深入 🔧

上一节我们介绍了构造函数。我们编写了一个自定义构造函数,并发现了一个重要问题。

class Cube {
public:
    Cube(double length) {
        length_ = length;
    }
    double getVolume() {
        return length_ * length_ * length_;
    }
private:
    double length_;
};

当我们尝试创建一个没有参数的Cube对象时,代码无法编译。

Cube c; // 编译错误!

这是因为一旦我们编写了任何自定义构造函数,编译器就不再自动提供默认构造函数。要解决这个问题,有两种方法。

以下是两种解决方案:

  1. 添加一个默认构造函数:我们显式地编写一个不接受任何参数的构造函数。

    class Cube {
    public:
        Cube() { // 默认构造函数
            length_ = 1.0;
        }
        Cube(double length) { // 自定义构造函数
            length_ = length;
        }
        // ... 其他成员
    };
    
  2. 在创建对象时提供参数:直接使用我们已有的自定义构造函数。

    Cube c(2.0); // 使用自定义构造函数
    

这两种方式都是函数重载的例子。函数重载允许我们为同一个函数名(这里是Cube)提供多个版本,只要它们的参数列表不同即可。


命名空间的使用 📛

在代码中,我们使用了using声明来简化代码。

using CS225::Cube;
using std::cout;

这比使用using namespace std;更安全。后者会将整个std命名空间中的所有名称引入全局作用域,可能导致名称冲突和难以理解的错误。明确引入所需的名称是更好的实践。

全局命名空间是所有不属于任何特定命名空间的标识符的容器。命名空间的作用是组织和区分这些标识符。


变量、引用与指针 🎯

本节中我们来看看C++中几种不同的变量类型,这是理解内存操作的基础。

变量

变量是存储数据的基本单元。

Cube s1; // 在内存中创建一个Cube对象,命名为s1

引用

引用是另一个变量的别名。它必须在创建时初始化,并且一旦初始化,就不能再指向其他变量。引用本身不占用额外的存储空间(实现细节可能不同,但逻辑上如此)。

Cube &r1 = s1; // r1是s1的引用(别名)

公式: 如果 rT 类型的引用,则 r 等价于它所绑定的那个 T 类型对象。

指针

指针是一个变量,它存储的是另一个变量的内存地址。

Cube *p1; // p1是一个指向Cube的指针
p1 = &s1; // 将s1的地址赋值给p1

公式: 指针变量 p 存储的是地址值 &x,其中 x 是某个对象。


间接操作符详解 ⚙️

为了操作指针和地址,C++提供了几个关键的运算符。

取地址运算符 (&)

在表达式中使用&可以获取一个变量的内存地址。

Cube *p = &s1; // p保存了s1所在的内存地址

解引用运算符 (*)

使用*可以访问指针所指向地址中存储的值。

double vol = (*p).getVolume(); // 访问p指向的Cube对象,并调用其方法

箭头运算符 (->)

这是访问指针所指向对象的成员的快捷方式,它等价于先解引用再使用点运算符。

double vol = p->getVolume(); // 与 (*p).getVolume() 完全相同

代码对比

// 以下三行代码效果相同
double v1 = s1.getVolume();
double v2 = (*p1).getVolume();
double v3 = p1->getVolume();

指针非常强大,但也容易导致错误(如空指针解引用、内存泄漏),因此需要谨慎使用。


内存布局示例 🗺️

让我们通过一个简单的例子看看变量和指针在内存中是如何安排的。

int a;          // 未初始化的int,通常占用4字节
int b = -3;     // 初始化为-3的int
int c = 12345;  // 初始化为12345的int
int *p = &b;    // 指向b的指针,在64位系统上通常占用8字节

假设栈内存从高地址向低地址增长,而单个变量从低地址向高地址存储,其布局可能如下所示(地址为示意):

高地址
...
[ a (4字节) ]    地址:0xFFFF...F4
[ b (4字节) ]    地址:0xFFFF...F0 (p指向这里)
[ c (4字节) ]    地址:0xFFFF...EC
[ p (8字节) ]    地址:0xFFFF...E4 (存储着值 0xFFFF...F0,即b的地址)
...
低地址

我们可以使用sizeof运算符来查看类型或变量占用的字节数。

std::cout << sizeof(int) << std::endl;  // 输出int的大小,如 4
std::cout << sizeof(p) << std::endl;    // 输出指针的大小,如 8

总结 📚

本节课中我们一起学习了C++内存管理的几个核心概念:

  1. 构造函数:自定义构造函数会抑制默认构造函数的生成,需要根据需求选择添加默认构造函数或调用带参构造函数。
  2. 引用:作为变量的别名,必须初始化且不可重新绑定,用于提供对现有对象的另一个访问名称。
  3. 指针:存储内存地址的变量。通过取地址运算符(&)获取地址,通过解引用运算符(*)或箭头运算符(->)访问指向的数据。
  4. 内存布局:了解了栈的基本概念以及变量、指针在内存中的大致排列方式。

理解变量、引用和指针的区别与联系,是掌握C++手动内存管理能力的第一步。在接下来的课程中,我们将继续深入探讨如何使用这些工具来动态分配和释放内存。

003:堆内存 🧠

在本节课中,我们将要学习C++中两种主要的内存管理方式之一:堆内存。我们将从回顾栈内存开始,然后深入探讨堆内存的工作原理、如何分配与释放,以及它们之间的核心区别。

上一节我们介绍了栈内存,它是一种自动管理、高效但生命周期有限的内存区域。本节中我们来看看另一种内存管理方式——堆内存,它允许我们手动控制内存的分配与释放,从而创建生命周期更长的对象。

栈内存回顾 📚

栈内存用于存储函数调用时的局部变量。每次调用函数时,系统会创建一个栈帧,其中包含该函数的变量。当函数返回时,这个栈帧及其包含的变量会被自动清理。

栈内存的地址空间从高地址向低地址增长。我们不需要手动管理栈内存,它的分配和释放是自动的。

一个栈内存的“陷阱” ⚠️

为了理解为什么需要堆内存,我们先看一个使用栈内存时可能遇到的问题。

以下是一段有问题的代码,它试图返回一个指向栈内存的指针:

Cube* createCube() {
    Cube c(20);
    return &c; // 返回局部变量c的地址
}

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/36947c91fbde9e9ddb3e92988d111608_129.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/36947c91fbde9e9ddb3e92988d111608_131.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/36947c91fbde9e9ddb3e92988d111608_133.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/36947c91fbde9e9ddb3e92988d111608_135.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/36947c91fbde9e9ddb3e92988d111608_137.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/36947c91fbde9e9ddb3e92988d111608_139.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/36947c91fbde9e9ddb3e92988d111608_140.png)

int main() {
    Cube* c = createCube();
    someOtherFunction();
    double vol = c->getVolume(); // 潜在问题!
}

让我们逐步分析这段代码:

  1. main函数调用createCube
  2. createCube函数在其栈帧中创建了一个局部Cube对象c
  3. createCube返回了局部变量c的地址。
  4. 函数返回后,其栈帧被释放,c所占用的内存不再属于该程序。
  5. main函数调用someOtherFunction,该函数可能会使用刚才被释放的内存区域。
  6. main函数试图通过指针c访问getVolume()时,它实际上是在访问一块可能已被覆盖或不再有效的内存。这会导致未定义行为,程序可能崩溃或返回垃圾数据。

这个问题的核心在于:栈内存中的变量在其所属函数返回后,生命周期就结束了。我们无法安全地保留指向它的指针。

引入堆内存 🆕

为了解决上述问题,并允许我们创建生命周期超越单个函数调用的对象,C++提供了堆内存。

堆内存是程序中另一块可用的内存区域。与栈内存从高地址向低地址增长不同,堆内存从低地址向高地址增长。堆内存的分配和释放需要程序员显式地进行管理。

使用 new 分配堆内存 🔧

在C++中,我们使用关键字 new 在堆上分配内存。

new 操作符主要完成三件事:

  1. 分配内存:在堆上分配足够大小的内存块。
  2. 调用构造函数:对于类类型,调用其构造函数来初始化对象;对于基本类型(如int, double),进行值初始化(例如,new int 会初始化为0)。
  3. 返回地址:返回指向新分配内存的指针。

其基本语法如下:

Cube* c = new Cube(20); // 分配一个Cube对象,并用参数20调用其构造函数
int* p = new int;       // 分配一个int,值初始化为0

注意new 的返回值总是一个指针,因此必须用一个相应类型的指针变量来接收它。

使用 delete 释放堆内存 🗑️

new 相对应,我们使用关键字 delete 来释放堆内存。

delete 操作符主要完成两件事:

  1. 调用析构函数:对于类类型,调用其析构函数来执行必要的清理工作(例如关闭文件、释放其他资源)。
  2. 释放内存:将内存归还给系统,以便后续重用。

其基本语法如下:

delete c; // 释放c指向的堆内存
delete p; // 释放p指向的堆内存

重要原则:每一个通过 new 分配的内存,最终都必须通过 delete 来释放,否则会导致内存泄漏。

堆内存与栈内存的对比 📊

以下是堆内存和栈内存的核心区别总结:

特性 栈内存 (Stack) 堆内存 (Heap)
管理方式 自动(由编译器/系统管理) 手动(由程序员管理)
生命周期 与函数调用周期一致 newdelete
分配速度 快(移动栈指针即可) 相对较慢(需寻找合适内存块)
大小限制 通常较小(取决于系统设置) 理论上可达系统可用内存上限
主要用途 局部变量、函数参数、返回地址 生命周期长的对象、大型数据结构、需要在函数间共享的数据
增长方向 从高地址向低地址增长 从低地址向高地址增长

一个堆内存的示例与陷阱 🧪

让我们通过一个例子来理解堆内存的使用和另一个常见陷阱——重复释放。

int main() {
    Cube* c1 = new Cube(); // 在堆上分配Cube1
    Cube* c2 = c1;         // c2指向与c1相同的堆内存

    c2->setLength(10);     // 通过c2修改对象

    delete c2;             // 释放堆内存
    delete c1;             // 错误!重复释放同一块内存
    double vol = c1->getVolume(); // 错误!访问已释放的内存
}

以下是代码分析:

  1. c1 指向堆上新创建的 Cube 对象。
  2. c2 被赋值为 c1,因此 c1c2 指向同一个堆对象。
  3. 通过 c2 修改长度,实际上修改了 c1 也指向的那个对象。
  4. delete c2; 释放了堆上的 Cube 对象。
  5. delete c1; 试图再次释放同一块已经释放的内存,这会导致未定义行为(通常是程序崩溃)。
  6. 后续通过 c1 访问对象也是非法的。

这个例子强调了两个要点:

  • 多个指针可以指向同一块堆内存
  • 同一块堆内存只能被 delete 一次。重复释放或访问已释放的内存是严重错误。

总结 🎯

本节课中我们一起学习了C++中堆内存的核心概念。

我们首先回顾了栈内存的自动管理特性及其局限性,特别是无法安全返回指向局部变量的指针。接着,我们引入了堆内存作为解决方案,它允许我们手动控制内存的生命周期。

我们详细探讨了如何使用 new 操作符在堆上分配内存并初始化对象,以及如何使用 delete 操作符释放内存并调用析构函数。我们比较了堆内存和栈内存的关键区别,包括管理方式、生命周期、性能和用途。

最后,我们通过实例分析了堆内存的使用,并指出了诸如“重复释放”和“访问已释放内存”等常见陷阱。理解并正确管理堆内存是编写健壮、高效C++程序的基础,在后续构建复杂数据结构时至关重要。

004:参数传递与内存管理 🧠

在本节课中,我们将学习C++中参数传递的三种主要方式:按值传递、按指针传递和按引用传递。我们还将探讨动态内存分配(堆内存)的基本概念,包括如何使用newdelete来管理数组。


概述

我们将从回顾堆内存分配开始,特别是动态数组的创建与销毁。接着,我们会深入探讨函数调用时参数传递的三种不同方式,分析它们的工作原理、性能差异以及适用场景。


堆内存与动态数组 🧱

上一节我们介绍了堆内存的基本概念。本节中,我们来看看如何使用newdelete操作符来创建和销毁动态数组。

在C++中,使用new关键字配合方括号可以创建动态数组。例如:

int size = 10;
int* x = new int[size];

这行代码的含义是:在堆上分配足够存储size个整数的内存,并将这块内存的起始地址赋值给指针x。数组的大小在运行时确定,因此它是“动态”的。

动态数组在内存中的存储方式如下图所示。指针x存储的是数组第一个元素的地址。

内存布局示例:
x -> [int0][int1][int2]...[int(size-1)]

当使用完动态数组后,必须使用delete[]来释放内存:

delete[] x;

使用delete[](带方括号)而不是delete至关重要。delete[]会为数组中的每一个元素调用析构函数,然后释放整块内存。如果错误地使用delete(不带方括号),则只会释放内存而不会调用每个元素的析构函数,这可能导致资源泄漏(例如,如果数组元素是持有文件句柄或网络连接的对象)。

以下是关于动态数组的几个关键点:

  • 动态数组的大小在程序运行时决定。
  • 数组变量(如x)本身是一个指针,它存储数组首元素的地址。
  • 使用x[i]访问元素时,编译器会计算从起始地址偏移i个元素后的位置。
  • 每个new[]操作都必须有一个对应的delete[]操作,以正确释放内存。


函数参数传递方式 🔄

理解了内存的基本操作后,我们来看看如何将数据传递给函数。C++提供了三种主要的参数传递方式,它们各有特点。

按值传递 📥

按值传递是默认的方式。当调用函数时,实参的值会被复制一份,传递给函数的形参。函数内部对形参的任何修改都不会影响原始的实参。

考虑一个合并两个立方体(Cube对象)的函数:

Cube joinCubes(Cube c1, Cube c2) {
    int totalVol = c1.getVolume() + c2.getVolume();
    double newLength = cbrt(totalVol);
    Cube result(newLength);
    return result;
}

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/96def365bcdb88c79d8cca26a613f02e_88.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/96def365bcdb88c79d8cca26a613f02e_89.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/96def365bcdb88c79d8cca26a613f02e_91.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/96def365bcdb88c79d8cca26a613f02e_93.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/96def365bcdb88c79d8cca26a613f02e_95.png)

// 调用
Cube* c1 = new Cube(4);
Cube* c2 = new Cube(5);
Cube c3 = joinCubes(*c1, *c2); // 注意:传递的是解引用后的对象

在这个例子中:

  1. *c1*c2(即Cube对象)被完整地复制到函数joinCubes的局部变量c1c2中。
  2. 函数内部创建了一个新的Cube对象result
  3. 函数返回时,result被复制给外部的c3

这个过程可能创建多个临时副本,如果Cube对象很大(例如包含大量数据),复制开销会很高。它的优点是安全,因为函数无法修改原始的c1c2对象。

按指针传递 🎯

按指针传递时,传递的是实参的内存地址。函数接收一个指针(地址的副本),通过这个指针可以访问和修改原始数据。

以下是使用指针的joinCubes版本:

Cube joinCubes(Cube* c1, Cube* c2) {
    int totalVol = c1->getVolume() + c2->getVolume(); // 使用 -> 操作符
    double newLength = cbrt(totalVol);
    Cube result(newLength);
    return result;
}

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/96def365bcdb88c79d8cca26a613f02e_123.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/96def365bcdb88c79d8cca26a613f02e_124.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/96def365bcdb88c79d8cca26a613f02e_126.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/96def365bcdb88c79d8cca26a613f02e_128.png)

// 调用
Cube* c1 = new Cube(4);
Cube* c2 = new Cube(5);
Cube c3 = joinCubes(c1, c2); // 直接传递指针

在这个例子中:

  • 传递给函数的是指针c1c2(即地址),而不是整个Cube对象。复制一个地址的成本很低。
  • 函数内部通过指针(使用->操作符)直接操作堆上的原始Cube对象。
  • 这避免了复制大对象的开销,效率更高。

但是,按指针传递存在风险:函数可以通过指针修改调用者不希望被修改的数据。此外,指针可能为nullptr,需要在函数内进行检查。

按引用传递 🔗

按引用传递在语法上像按值传递一样简洁,但在效果上类似于按指针传递(直接操作原始数据)。引用是变量的一个“别名”。

以下是使用引用的joinCubes版本:

Cube joinCubes(Cube& c1, Cube& c2) { // 参数类型是 Cube&
    int totalVol = c1.getVolume() + c2.getVolume(); // 使用 . 操作符
    double newLength = cbrt(totalVol);
    Cube result(newLength);
    return result;
}

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/96def365bcdb88c79d8cca26a613f02e_153.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/96def365bcdb88c79d8cca26a613f02e_154.png)

// 调用
Cube* c1 = new Cube(4);
Cube* c2 = new Cube(5);
Cube c3 = joinCubes(*c1, *c2); // 传递解引用后的对象,但函数接收的是引用

在这个例子中:

  • 函数签名中的Cube&表示参数是引用。
  • 调用时,c1c2成为外部Cube对象的别名。函数内对c1c2的操作直接作用于原始的堆对象。
  • 它兼具了按指针传递的效率(无对象复制)和按值传递的语法简洁性(使用.操作符)。

引用必须在创建时初始化,且一旦绑定到一个对象就不能再指向其他对象,这比指针更安全一些,但函数仍然能够修改原始数据。


三种传递方式的对比 📊

为了更清晰地理解,我们来总结一下三种参数传递方式的关键区别:

特性 按值传递 按指针传递 按引用传递
传递的内容 对象的完整副本 对象地址的副本 对象的别名(通常由编译器实现为指针)
函数内修改 不影响原始对象 会影响原始对象 会影响原始对象
空值有效性 总是有效(是副本) 可能为nullptr 总是有效(必须绑定到对象)
语法 func(Cube c) func(Cube* c) func(Cube& c)
访问成员 c.getVolume() c->getVolume() c.getVolume()
性能 可能较慢(复制开销) 快(只复制地址) 快(通常等同于指针)
安全性 高(隔离性好) 中低(可能误修改或空指针) 中(可修改原始数据,但无空引用)

如何选择?

  • 当需要保护原始数据不被修改,且对象很小或复制成本可接受时,使用按值传递
  • 当需要高效传递大型对象、数据结构,或者需要能够接收空值(nullptr)时,使用按指针传递
  • 当需要高效传递大型对象、数据结构,且语法简洁性很重要,同时确定实参一定有效时,使用按引用传递

在实际C++编程中,为了同时获得高效性安全性(防止意外修改),常常会使用常量引用const Cube&),这将是下一讲的重要内容。


总结

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

  1. 动态数组管理:使用new[]在堆上分配数组,并使用delete[]正确释放,确保调用所有元素的析构函数。
  2. 参数传递的三种方式
    • 按值传递:安全但可能有复制开销。传递副本。
    • 按指针传递:高效灵活,可直接修改原始数据或处理空指针,但不够安全。传递地址。
    • 按引用传递:高效且语法简洁,直接操作原始数据,但不能为空。传递别名。

理解这些内存管理和参数传递的基础,对于编写高效、正确的C++程序至关重要,尤其是在实现复杂的数据结构与算法时。

005:生命周期 🧬

在本节课中,我们将要学习C++中两个核心概念:const关键字和拷贝构造函数。我们将了解如何使用const来保证代码的安全性,以及如何通过拷贝构造函数来控制对象的复制行为。


const关键字:不可变的承诺

上一节我们介绍了引用和指针,本节中我们来看看如何让它们更安全。const关键字用于声明一个不可变的常量或对象。它告诉编译器,这个值在初始化后不能被修改。

const的基本用法

以下是const的基本用法:

  • const int pi = 3;:声明一个整型常量pi,其值不能改变。
  • pi = 5;:这行代码会导致编译错误。

const与引用

const与引用结合时,可以确保函数不会修改传入的参数。这对于代码的可读性和安全性至关重要。

void printCube(const Cube& c) {
    // 函数承诺不会修改c
    double vol = c.getVolume();
    std::cout << vol << std::endl;
}

如果一个对象被声明为const,那么只能调用该对象中被标记为const的成员函数。因为编译器需要确保这些函数不会修改对象的状态。

标记成员函数为const

在成员函数声明的末尾添加const,表示该函数不会修改其所属对象的任何成员变量。

class Cube {
public:
    double getVolume() const; // 这是一个const成员函数
private:
    double length_;
};

注意const在函数之前之后的含义不同:

  • const double getVolume();:表示返回值是一个const double
  • double getVolume() const;:表示getVolume函数不会修改Cube对象。

const与指针

const与指针结合时,需要仔细区分是指针本身不可变,还是指针指向的内容不可变。

以下是const与指针的几种组合:

  • const Cube* ptr;ptr是一个指针,它指向一个const Cube。你不能通过ptr来修改它指向的Cube对象,但可以让ptr指向另一个Cube
  • Cube* const ptr;ptr是一个const指针,它指向一个Cube。你不能改变ptr本身(即不能让它指向别的地址),但可以通过ptr来修改它当前指向的Cube对象。
  • const Cube* const ptr;ptr是一个const指针,它指向一个const Cube。两者都不可改变。

拷贝构造函数:对象的复制

上一节我们了解了如何保护对象不被修改,本节中我们来看看如何复制一个对象。拷贝构造函数是一种特殊的构造函数,用于创建一个新对象作为现有对象的副本。

何时会调用拷贝构造函数?

在以下情况中,拷贝构造函数会被调用:

  1. 用一个对象初始化另一个对象时:
    Cube c1(5);
    Cube c2 = c1; // 调用拷贝构造函数
    Cube c3(c1);  // 另一种语法,同样调用拷贝构造函数
    
  2. 传值方式将对象传递给函数时。
  3. 函数以传值方式返回对象时。

默认拷贝构造函数

如果你没有为类定义拷贝构造函数,编译器会自动生成一个默认拷贝构造函数。它的行为是:对对象的每一个非静态成员变量,调用其自身的拷贝构造函数进行复制(对于基本类型,则是直接复制值)。

对于简单的类(如我们之前的Cube),默认拷贝构造函数完全够用。

自定义拷贝构造函数

有时,默认的“逐成员复制”行为并不正确,特别是当类中包含指针成员时。这时我们需要编写自己的拷贝构造函数。

拷贝构造函数的签名是固定的:

ClassName(const ClassName& other);

它必须是引用传递。如果使用值传递,为了复制参数other,又需要调用拷贝构造函数,这将导致无限递归。

以下是一个自定义拷贝构造函数的例子:

class Cube {
public:
    // 自定义拷贝构造函数
    Cube(const Cube& other) {
        length_ = other.length_; // 直接复制值
    }
private:
    double length_;
};

初始化列表

在构造函数中,有些成员(如const成员、引用成员)必须在对象创建时初始化,而不能在构造函数体内赋值。这时需要使用成员初始化列表

以下是使用初始化列表的拷贝构造函数:

class Tower {
public:
    Tower(const Tower& other)
        : cube_(other.cube_),        // 调用Cube的拷贝构造函数
          ptr_(new Cube(*other.ptr_)), // 深拷贝:新建一个Cube对象
          ref_(other.ref_)           // 引用只能初始化,不能重新绑定
    {
        // 构造函数体
    }
private:
    Cube cube_;
    Cube* ptr_;
    Cube& ref_;
};

浅拷贝与深拷贝

这是理解自定义拷贝构造函数为何重要的关键:

  • 浅拷贝:仅复制指针的值(即内存地址)。结果是两个指针指向同一块内存。修改其中一个对象会影响另一个。
  • 深拷贝:为新对象分配新的内存,并复制原指针所指向的实际内容。结果是两个完全独立的对象。

默认拷贝构造函数进行的是浅拷贝。当类管理动态分配的内存(拥有指针成员)时,通常需要实现深拷贝,以避免多个对象意外共享同一资源,导致重复释放内存等问题。


本节课中我们一起学习了const关键字和拷贝构造函数。const是提高代码安全性和表达力的重要工具,它能明确表达“不变”的意图。拷贝构造函数则控制了对象复制的行为,对于管理资源的类(尤其是包含指针的类)来说,实现正确的拷贝语义(通常是深拷贝)至关重要,这是避免内存错误的关键一步。

006:析构函数与运算符重载

在本节课中,我们将要学习C++中两个重要的概念:析构函数和运算符重载。析构函数负责在对象生命周期结束时清理资源,而运算符重载则允许我们为自定义类型定义运算符的行为。

析构函数

上一节我们介绍了拷贝构造函数,本节中我们来看看析构函数。析构函数的目的是释放由对象分配的任何资源。这通常是内存,但也可能是网络连接、文件句柄或数据库锁等。

析构函数是一个特殊的成员函数,其名称由波浪号(~)后接类名构成。它没有参数,也没有返回值。

class Cube {
public:
    ~Cube(); // 析构函数声明
};

析构函数在以下两种情况下被自动调用:

  1. 当使用 delete 操作符删除堆上分配的对象时。
  2. 当栈上分配的对象离开其作用域时。

默认的析构函数(即编译器自动生成的)会为每个成员变量调用其自身的析构函数。对于基本类型(如 intchar*),其“析构”操作不执行任何动作。

如果一个类在构造函数中分配了资源(例如使用 new),那么它通常需要自定义析构函数来释放这些资源,否则会导致内存泄漏。

class MyClass {
private:
    int* data;
public:
    MyClass() {
        data = new int[100]; // 在构造函数中分配资源
    }
    ~MyClass() {
        delete[] data; // 在析构函数中释放资源,防止泄漏
    }
};

注意:我们从不直接调用析构函数。它总是由系统在对象销毁时自动调用。

运算符重载

C++允许我们重新定义大多数运算符对于自定义类型的含义,这称为运算符重载。这使得代码更直观、更易读。例如,我们可以为 Cube 类定义 + 运算符,使其表示两个立方体的“连接”。

运算符重载本质上是一个特殊的成员函数或全局函数,函数名由关键字 operator 后接要重载的运算符符号组成。

以下是重载 + 运算符的示例:

class Cube {
public:
    // ... 其他成员 ...
    Cube operator+(const Cube& other) const {
        // 返回一个新的Cube对象,其“长度”是两个立方体长度之和
        return Cube(this->length + other.length);
    }
};

使用方式如下:

Cube c1(5), c2(10);
Cube c3 = c1 + c2; // 等价于 c1.operator+(c2)

关键点

  • 函数后的 const 关键字表示该函数不会修改调用它的对象(即 c1)。
  • 参数 const Cube& other 表示我们通过常量引用接收另一个对象,避免拷贝,且承诺不修改它。
  • 我们通常返回一个新对象,而不是修改原有对象,这符合直觉(就像 3+5 不会改变 35 一样)。

赋值运算符重载与三法则

赋值运算符(=)也可以被重载。编译器会提供一个默认的赋值运算符,它执行成员的浅拷贝。如果类管理着动态内存等资源,浅拷贝会导致问题(如多个对象指向同一块内存),因此我们需要自定义。

自定义赋值运算符通常需要完成两件事:

  1. 释放左侧对象原有的资源(避免内存泄漏)。
  2. 将右侧对象的值拷贝到左侧对象。

其声明形式如下:

class MyClass {
public:
    MyClass& operator=(const MyClass& other);
};

返回引用(MyClass&)是为了支持链式赋值,如 a = b = c

三法则指出:如果一个类需要显式定义析构函数拷贝构造函数拷贝赋值运算符中的任何一个,那么它很可能需要全部定义这三个。这是因为它们通常都涉及同一种资源的管理(如动态内存)。如果只定义其中一个而依赖编译器生成其他两个,很可能导致资源管理错误(如重复释放或内存泄漏)。

零法则是一个更好的实践:设计类时,尽量使用已有的、能妥善管理资源的类(如标准库中的 std::vector, std::string)作为成员。这样,编译器生成的默认析构函数、拷贝构造和拷贝赋值就能正确工作,我们无需自己定义它们。在本课程中,我们将主要学习如何实现那些管理资源的类,因此会频繁应用“三法则”。


本节课中我们一起学习了析构函数的作用与用法,了解了如何通过运算符重载让自定义类型支持直观的运算,并掌握了管理类资源的“三法则”与“零法则”核心思想。理解这些概念对于编写正确、高效的C++程序至关重要。

007:接口与模板

在本节课中,我们将学习C++中的两个核心概念:赋值运算符的重载与类的继承。我们将探讨如何安全地实现赋值操作,以及如何通过继承来构建更复杂的类。


赋值运算符的重载与自赋值问题

上一节我们介绍了类的构造函数,本节中我们来看看如何重载赋值运算符,并处理一个常见但危险的问题:自赋值。

考虑以下代码片段:

Cube x;
x = x; // 自赋值

这段代码是合法的,但按照我们之前讨论的赋值运算符实现方式(先销毁自身,再复制源对象),会导致对象在复制前就被销毁,从而引发未定义行为。

问题的核心在于,当赋值运算符的左右两边是同一个对象时,我们不能先销毁自身。因此,我们需要在赋值操作前检查是否为自赋值。

以下是解决自赋值问题的关键方法:

  • 比较对象的地址:这是最高效的方式,因为地址比较是常数时间操作。
  • 在赋值运算符实现的开头添加检查:如果 this(当前对象的地址)与 &other(传入对象的地址)相同,则直接返回 *this,不做任何操作。

赋值运算符的典型实现结构如下:

Cube& Cube::operator=(const Cube &other) {
    // 1. 检查自赋值
    if (this == &other) {
        return *this;
    }
    // 2. 释放当前对象持有的资源
    // 3. 从 other 对象复制资源
    // 4. 返回 *this 以支持链式赋值 (如 a = b = c)
    return *this;
}

注意this 是C++中的一个关键字,它是一个指针,指向调用当前成员函数的对象。operator= 是重载赋值运算符的函数名。


C++中的继承基础

接下来,我们转向C++的另一个核心特性:继承。继承允许我们基于已有的类(基类)创建新的类(派生类),从而实现代码的复用和层次化设计。

想象一个图形系统,我们有一个基类 Shape(形状),然后派生出 Cube(立方体)和 Circle(圆形)等具体形状。Shape 类可能包含所有形状共有的属性(如位置),而派生类则添加自己特有的属性(如边长、半径)。

在CS225课程中,我们只讨论最简单的公有继承(public inheritance)。

公有继承的语法与含义

以下是一个简单的例子,Square(正方形)类公有继承自 Shape 类:

// Shape 基类
class Shape {
public:
    Shape();
    Shape(double length);
    double getLength() const;
private:
    double length_;
};

// Square 派生类
class Square : public Shape {
public:
    Square();
    double getArea() const;
};

语法class Square : public Shape
含义Square 类公有继承 Shape 类。这意味着:

  1. 在每一个 Square 对象内部,都包含一个完整的 Shape 子对象。
  2. Shape 类中的所有 公有 成员函数(如 getLength)在 Square 类中也是可访问的,就像它们是 Square 自己的成员一样。
  3. Square不能直接访问 Shape 类的私有成员(如 length_),必须通过 Shape 的公有接口(如 getLength 函数)进行访问。

因此,Square 类中 getArea 函数的实现应该是:

double Square::getArea() const {
    // 通过公有成员函数 getLength() 访问基类的边长
    return getLength() * getLength();
}

在成员函数内部,getLength() 等价于 this->getLength(),它调用的是从 Shape 继承而来的函数。

派生类的构造函数与初始化列表

如何为 Square 类设置边长?我们需要在 Square 的构造函数中初始化其内部的 Shape 子对象。

由于 Shape 的私有成员 length_ 只能在 Shape 的构造函数中初始化,我们需要使用 成员初始化列表 来调用 Shape 的带参构造函数:

Square::Square(double width) : Shape(width) {
    // 构造函数体,可以留空或执行其他初始化
}

关键点

  • 派生类对象构造时,先构造基类子对象,再构造派生类部分
  • 成员初始化列表在构造函数体执行之前运行,用于初始化成员变量和基类子对象。
  • 如果基类没有默认构造函数,或者你想使用基类的特定构造函数,必须在派生类构造函数的初始化列表中显式调用。

多态性与虚函数

继承最强大的功能之一是 多态:允许使用基类的指针或引用来操作派生类对象,并根据对象的实际类型调用正确的函数。

然而,默认情况下,通过基类指针/引用调用函数,调用的是基类中定义的版本,而非派生类中可能存在的重写版本。为了实现“根据实际对象类型调用函数”的行为,我们需要使用 虚函数

函数调用规则

首先,了解默认的成员函数调用规则:

  1. 查看调用该函数的变量或表达式的静态类型(声明时的类型)。
  2. 在该类型的类定义中查找要调用的函数。
  3. 如果找到,则调用它。
  4. 如果未找到,则递归地在其基类中查找。

虚函数的作用

在函数声明前加上 virtual 关键字,使其成为虚函数。虚函数引入了新的行为:

  • 当通过基类的指针或引用调用一个虚函数时,程序会检查指针/引用所指向的实际对象的类型(动态类型)。
  • 如果该实际对象的类型(派生类)重写(override)了这个虚函数,则调用派生类中的版本;否则,调用基类中的版本。

示例对比

class Cube {
public:
    void print1() { cout << "Cube"; }
    virtual void print2() { cout << "Cube"; }
};
class RubiksCube : public Cube {
public:
    void print2() override { cout << "RubiksCube"; } // 重写虚函数
};

int main() {
    RubiksCube rc;
    Cube& cubeRef = rc; // 基类引用指向派生类对象

    rc.print1(); // 输出 "Cube" (规则查找,RubiksCube没有print1,找到基类的)
    rc.print2(); // 输出 "RubiksCube" (调用RubiksCube自己的print2)

    cubeRef.print1(); // 输出 "Cube" (静态类型是Cube,Cube::print1不是虚函数)
    cubeRef.print2(); // 输出 "RubiksCube" (print2是虚函数,根据实际对象类型调用)
}

总结虚函数的关键virtual 关键字使得函数调用依赖于对象的实际类型,而非指针或引用的静态类型,这是实现运行时多态的基础。

纯虚函数与抽象类

当一个虚函数在基类中被声明为 = 0 时,它成为 纯虚函数。包含纯虚函数的类称为 抽象类

class Shape {
public:
    virtual double getArea() const = 0; // 纯虚函数
};
  • 抽象类不能被实例化(不能创建 Shape 对象)。
  • 任何从抽象类派生的非抽象类,都必须提供所有纯虚函数的具体实现。
  • 纯虚函数定义了接口规范,强制派生类实现特定功能,这是实现接口设计的重要手段。

本节课中我们一起学习了C++中赋值运算符重载时处理自赋值的安全方法,以及面向对象编程中继承与多态的核心概念。我们了解了公有继承的语法和含义,掌握了通过初始化列表构造派生类对象,并深入探讨了虚函数如何实现运行时多态,以及纯虚函数如何定义接口。这些概念是构建复杂、灵活且易于维护的软件系统的基础。

008:模板与链式内存 🧩

在本节课中,我们将要学习C++中的两个核心概念:方法分派与继承的深入规则,以及模板的初步介绍。我们将从理解虚函数和纯虚函数如何影响程序行为开始,然后探讨如何利用模板编写通用的、类型无关的代码。

方法分派与继承规则回顾 🔍

上一节我们介绍了方法分派的基本规则。本节中,我们来看看这些规则在涉及虚函数和纯虚函数时的具体应用。

当系统需要确定运行哪段代码时,会遵循以下规则:

  1. 查看方法被调用对象的类型
  2. 检查该类型中是否存在此方法。
  3. 如果存在,检查该方法是否被标记为 virtual(虚函数)。
  4. 如果是虚函数,则检查对象的实际运行时类型(可能比声明的类型更具体)。
  5. 使用实际运行时类型中的方法。
  6. 如果未找到方法,则到其基类中查找。

以下是一个代码示例及其输出结果的总结:

// 假设有基类 Cube 和派生类 RubiksCube
Cube c1;
c1.print1(); // 输出: Cube
c1.print2(); // 输出: Cube
c1.print3(); // 输出: Cube
c1.print4(); // 输出: Cube

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/f88f79e49a671bd7e40512bec0dad7c5_34.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/f88f79e49a671bd7e40512bec0dad7c5_36.png)

RubiksCube r;
r.print1(); // 输出: RubiksCube
r.print2(); // 输出: Cube (因为 print2 非虚函数)
r.print3(); // 输出: RubiksCube
r.print4(); // 输出: RubiksCube

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/f88f79e49a671bd7e40512bec0dad7c5_38.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/f88f79e49a671bd7e40512bec0dad7c5_40.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/f88f79e49a671bd7e40512bec0dad7c5_42.png)

Cube& c_ref = r;
c_ref.print1(); // 输出: RubiksCube (虚函数,使用实际类型)
c_ref.print2(); // 输出: Cube (非虚函数,使用声明类型)
c_ref.print3(); // 输出: RubiksCube (虚函数)
c_ref.print4(); // 输出: RubiksCube (虚函数)

核心区别virtual 关键字使得方法调用基于对象的实际类型(运行时多态),而非其声明的类型。

纯虚函数与抽象基类 🚫

理解了虚函数后,我们来看看一种特殊的虚函数:纯虚函数。

纯虚函数的语法是在声明后加上 = 0

virtual void print5() = 0;

这表示该函数必须在派生类中被实现,而在基类中没有定义。

抽象基类(或纯虚类)是包含至少一个纯虚函数的类。其核心规则是:

  • 抽象基类不能实例化。尝试创建其对象会导致编译错误。
  • 任何继承自抽象基类的类,必须实现所有纯虚函数,否则它自身也会成为抽象类。

它的作用类似于Java中的接口,但更强大,因为抽象基类可以包含数据成员和已实现的方法。

class AbstractCube {
public:
    virtual void mustImplement() = 0; // 纯虚函数
    void concreteMethod() { /* 已实现的方法 */ }
    int someData; // 数据成员
};
// AbstractCube a; // 错误!不能实例化抽象类

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/f88f79e49a671bd7e40512bec0dad7c5_73.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/f88f79e49a671bd7e40512bec0dad7c5_75.png)

class ConcreteCube : public AbstractCube {
public:
    void mustImplement() override { /* 必须提供实现 */ }
};
ConcreteCube cc; // 正确

模板简介:编写通用代码的秘诀 🧪

从方法分派转向另一个强大的C++特性:模板。当我们设计如“列表”这样的数据结构时,希望它能存储各种类型的数据(整数、字符串、对象等),但操作逻辑完全相同。

模板是一种让编译器为我们自动生成类型特定代码的“配方”。它允许我们将类型作为参数

以下是一个简单的模板函数示例,用于返回两个值的最大值:

// 模板声明:T 是一个类型参数
template <typename T>
T myMax(T a, T b) {
    return (a > b) ? a : b; // 三元运算符:如果 a>b 返回 a,否则返回 b
}

// 编译器会根据调用自动生成特定版本的代码
int main() {
    int i = myMax<int>(3, 5);      // 生成并调用 myMax<int>
    double d = myMax<double>(3.14, 2.99); // 生成并调用 myMax<double>
    // 通常编译器也能自动推断类型
    int j = myMax(7, 9); // 推断 T 为 int
}

当编译器看到 myMax<int>(3, 5) 时,它会将模板中的 T 替换为 int,生成一个 int 版本的 myMax 函数并编译。这发生在编译期,因此能进行充分的优化。

模板在数据结构中的应用:通用列表 📝

让我们将模板应用于之前讨论的列表抽象数据类型(ADT)。我们希望有一个能存储任何类型元素的 List

以下是模板化列表的典型文件组织方式:

list.h (接口声明)

#pragma once

// 声明 List 类模板
template <typename T>
class List {
public:
    List(); // 构造函数
    bool isEmpty() const;
    void insert(const T& data);
    T remove();
    // ... 其他接口函数
private:
    // 内部实现细节(例如节点指针)
    struct Node {
        T data;
        Node* next;
    };
    Node* head;
};

list.hpp (模板实现)

// 注意:模板实现通常放在 .hpp 或直接在 .h 文件中
#include "list.h"

template <typename T>
List<T>::List() : head(nullptr) {}

template <typename T>
bool List<T>::isEmpty() const {
    return head == nullptr;
}

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/f88f79e49a671bd7e40512bec0dad7c5_79.png)

template <typename T>
void List<T>::insert(const T& data) {
    // 插入逻辑...
}
// ... 其他成员函数的实现

main.cpp (使用列表)

#include "list.h"
#include "list.hpp" // 需要包含实现,因为模板是编译期展开

int main() {
    List<int> intList;       // 一个整数列表
    List<std::string> strList; // 一个字符串列表

    intList.insert(42);
    strList.insert("Hello");
    // ...
}

关键点:模板代码(.hpp)不是被单独编译的,而是作为一种“配方”,在编译器处理 main.cpp 中类似 List<int> 这样的代码时,被包含进来并针对具体类型 int 进行实例化和编译。

总结 🎯

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

  1. 方法分派:深入理解了 virtual 关键字如何实现运行时多态,以及纯虚函数(=0)如何定义抽象基类,强制派生类实现特定接口。
  2. 模板基础:认识了模板作为C++强大元编程工具的基本概念。它通过在编译期将类型作为参数,允许我们编写通用、高效的代码,避免了为不同数据类型重复编写逻辑相同的代码。

模板是C++标准库(如 vector, list)的基石。掌握它,是编写现代、灵活C++程序的关键一步。在接下来的课程中,我们将利用模板来构建更复杂的数据结构。

009:列表实现 📚

在本节课中,我们将学习如何实现列表(List)这一抽象数据类型(ADT)。我们将重点讨论基于链式内存(链表)的实现方式,并编写具体的模板化代码。课程内容包括回顾抽象数据类型、链表的节点结构、以及实现插入等核心操作。


抽象数据类型回顾 📋

上一节我们介绍了模板化代码和列表抽象数据类型的概念。列表抽象数据类型定义了一组核心操作,这些操作描述了列表应有的行为,而不涉及具体实现。

以下是列表ADT到具体函数的一个映射示例:

  • 插入(Insert):向列表前端或后端添加元素。函数原型可能类似于 void insert(const T& data)
  • 删除(Delete):从列表前端或后端移除元素。函数原型可能类似于 void removeFront()void removeBack()
  • 获取数据(Get Data):获取列表前端的元素。函数原型为 T getFront() const,返回数据的副本且不应修改列表。
  • 判空(Is Empty):检查列表是否为空。函数原型为 bool isEmpty() const
  • 创建空列表(Create Empty List):初始化一个空列表。

在本节中,我们将看看如何用链式内存来实现这些操作。


链式内存实现 🔗

目前,我们掌握的工具允许我们用两种基本方式实现列表:链式内存和数组。本节课我们专注于链式内存的实现,即链表。

链表的基本单元是节点。每个节点包含两部分:存储的数据和一个指向下一个节点的指针。

链表节点结构

我们可以使用一个结构体(struct)来定义链表节点。在C++中,structclass 几乎相同,唯一的区别是:在 class 中,成员默认访问权限是 private;而在 struct 中,成员默认访问权限是 public。当我们只需要一个简单的数据容器,并且希望直接访问其成员时,通常使用 struct

以下是模板化的链表节点定义:

template <typename T>
struct ListNode {
    T data;
    ListNode* next;
    // 构造函数,初始化数据和next指针
    ListNode(const T& data) : data(data), next(nullptr) {}
};

代码解释:

  • T data;:存储模板类型 T 的数据。
  • ListNode* next;:指向下一个 ListNode 的指针。
  • ListNode(const T& data) : data(data), next(nullptr) {}:构造函数,用传入的数据初始化 data 成员,并将 next 指针初始化为 nullptr(空指针)。

链表类框架

接下来,我们定义链表类本身。它将节点结构作为内部类型,并管理链表的头指针。

以下是链表类的基本框架:

template <typename T>
class List {
private:
    // 内部节点类型定义
    struct ListNode {
        T data;
        ListNode* next;
        ListNode(const T& data) : data(data), next(nullptr) {}
    };
    ListNode* head_; // 指向链表第一个节点的指针

public:
    List() : head_(nullptr) {} // 构造函数,初始化空链表
    // 接下来将在这里声明成员函数,如 insertFront
};


实现前端插入操作 ➕

现在,让我们实现一个具体的操作:在链表前端插入一个新节点。

在链表前端插入节点的逻辑分为三步:

  1. 创建一个新节点。
  2. 让新节点的 next 指针指向当前的头节点。
  3. 更新链表的 head_ 指针,使其指向新节点。

以下是 insertFront 函数的实现代码:

void insertFront(const T& data) {
    // 1. 在堆上创建新节点,并用 data 初始化
    ListNode* newNode = new ListNode(data);
    // 2. 新节点的 next 指向原头节点
    newNode->next = head_;
    // 3. 更新头指针指向新节点
    head_ = newNode;
}

代码逐步分析:

  1. ListNode* newNode = new ListNode(data);:在堆内存中动态分配一个新节点,并用传入的 data 初始化它。newNode 是一个局部指针变量,指向这个新节点。
  2. newNode->next = head_;:使用箭头操作符 -> 访问新节点的 next 成员,并将其设置为当前 head_ 的值(即原第一个节点的地址)。
  3. head_ = newNode;:将链表的成员变量 head_ 更新为 newNode 的值(即新节点的地址)。

函数结束后,局部指针 newNode 会被销毁,但新节点本身存在于堆上,并且通过 head_ 指针被链表正确引用。


进阶:实现按索引插入操作 🎯

仅仅在头部插入是不够的。我们希望能够向链表的任意位置(索引)插入节点。为此,我们需要一个辅助工具:一个能返回指向链表中第 i 个节点的指针的引用的函数。

索引辅助函数

为什么返回“指针的引用”如此强大?因为它允许我们直接修改链表中的某个指针(例如,某个节点的 next 指针),而不仅仅是获取一个副本。

我们可以用递归或迭代的方式实现这个函数。递归实现有助于我们更深入地理解链表的结构。

以下是递归版本的 index 辅助函数声明和实现:

// 公共接口:传入索引 i,返回指向第 i 个节点的指针的引用
ListNode*& index(unsigned i) {
    // 调用私有递归辅助函数,传入头指针和索引
    return index(head_, i);
}

private:
// 私有递归辅助函数
ListNode*& index(ListNode*& head, unsigned i) {
    if (i == 0) {
        // 基准情况:索引为0,返回当前头指针的引用
        return head;
    }
    // 递归情况:将当前节点的 next 指针视为新链表的头,索引减1
    return index(head->next, i - 1);
}

代码解释:

  • 函数重载:index(unsigned i)index(ListNode*& head, unsigned i) 是两个不同的函数,编译器根据参数类型区分它们。
  • ListNode*&:这是返回类型,表示返回一个对 ListNode 指针的引用。
  • 递归过程:假设我们要找索引 2 的节点。初始调用 index(head_, 2)
    1. i=2,不等于0,递归调用 index(head_->next, 1)
    2. i=1,不等于0,递归调用 index(head_->next->next, 0)
    3. i=0,到达基准情况,返回 head_->next->next 这个指针本身的引用。

利用索引函数实现插入

有了 index 函数,在任意位置插入节点就变得和前端插入一样简单。我们可以把目标位置想象成那个位置的“局部链表”的头部。

以下是 insertAt 函数的实现:

void insertAt(unsigned i, const T& data) {
    // 1. 创建新节点
    ListNode* newNode = new ListNode(data);
    // 2. 获取指向第 i 个节点指针的引用
    ListNode*& targetRef = index(i);
    // 3. 新节点指向 targetRef 当前指向的节点
    newNode->next = targetRef;
    // 4. 让 targetRef 指向新节点
    targetRef = newNode;
}

工作原理:假设要在索引 2 处插入 ‘X’。index(2) 返回一个指向原索引 2 节点(比如 ‘C’)的指针的引用。通过上述四步操作,我们成功将 ‘X’ 插入到 ‘B’ 之后、’C’ 之前的位置。index 函数返回的引用是关键,它允许我们直接修改链表中的连接关系。


总结 📝

本节课中我们一起学习了列表的链式内存实现。

  • 我们首先回顾了列表抽象数据类型(ADT)的核心操作。
  • 接着,我们定义了模板化的链表节点 ListNode,并使用 struct 使其成员可公开访问。
  • 然后,我们实现了最简单的操作——在链表前端插入节点,并详细分析了其三步逻辑。
  • 最后,为了支持在任意位置插入,我们引入了一个强大的工具:返回“指针的引用”的递归辅助函数 index。利用这个函数,我们能够以简洁的代码实现通用的 insertAt 功能。

理解指针操作和引用在链表实现中至关重要。绘制示意图是理解和调试链表代码的极佳方法。在接下来的课程中,我们将继续探索链表的其他操作和更复杂的数据结构。

010:数组列表实现

在本节课中,我们将完成链表的实现,并探讨如何使用数组来实现列表数据结构。我们将重点关注数组列表的核心操作及其性能分析。

链表 remove 方法实现

上一节我们介绍了链表的基本操作,本节中我们来看看如何实现 remove 方法,即从链表中移除指定索引位置的元素。

remove 方法的目标是删除链表中第 i 个节点。为了实现这个操作,我们需要几个关键步骤。

以下是实现 remove 方法的核心步骤:

  1. 使用辅助函数 index 获取一个指向目标节点的指针的引用。
  2. 创建一个临时指针 temp 指向待删除的节点。
  3. 将指向目标节点的指针修改为指向目标节点的下一个节点。
  4. 删除 temp 指向的节点,释放内存。

对应的核心代码如下:

ListNode*& n = index(i); // 步骤1:获取指向第i个节点的指针的引用
ListNode* temp = n;      // 步骤2:临时指针指向待删除节点
n = n->next;             // 步骤3:绕过待删除节点
delete temp;             // 步骤4:删除节点,释放内存

理解指针和引用的区别至关重要。index 函数返回 ListNode*&(指向指针的引用),这允许我们直接修改链表中某个节点的 next 指针。如果缺少步骤3,链表结构虽然逻辑正确,但会导致内存泄漏。如果缺少步骤4,则链表指针将指向已释放的内存,后续访问可能导致程序崩溃。

数组列表的基本结构

现在,让我们从链表转向数组列表的实现。数组列表使用连续的内存块(数组)来存储元素。

为了实现一个动态数组列表,我们需要在私有成员中管理几个关键信息:

  • T* data_: 指向动态分配的数组的指针。
  • size_t size_: 当前列表中存储的元素数量。
  • size_t capacity_: 数组当前分配的总容量(能够容纳的元素数量)。

size_t 是一种无符号整数类型,它被定义为足够大,可以索引当前机器上任何可能数组的大小,这保证了代码的移植性和安全性。

数组列表的操作与性能

基于上述结构,我们可以实现列表的各种操作。让我们分析 push_back(在末尾添加元素)和 insert_front(在开头插入元素)的操作过程。

push_back 操作
当数组已满(size_ == capacity_)时,需要扩容。一个简单的策略是分配一个更大的新数组(例如,capacity_ + 1),将旧数组的所有元素复制到新数组,然后释放旧数组,最后将新元素添加到 size_ 位置并增加 size_

insert_front 操作
在数组开头插入元素,需要将所有现有元素向后移动一位以腾出空间。即使不需要扩容,这个操作也需要移动所有 size_ 个元素。

以下是两种操作的时间复杂度分析:

  • push_back(无需扩容时):O(1)
  • push_back(需要扩容时):O(n),因为需要复制所有元素。
  • insert_frontO(n),因为需要移动所有元素。

相比之下,链表在开头和末尾插入元素的时间复杂度都是 O(1)。为了优化数组列表,可以考虑使用“循环数组”或预留前端空间等技巧,使得 insert_front 也能达到 O(1) 的摊还时间复杂度,这类似于 std::deque 的实现。

数组扩容策略分析

最后,我们探讨数组的扩容策略。如果每次数组满时只增加固定数量(例如 2 个)的容量,会导致性能问题。

假设每次扩容增加 2 个容量。经过推导,插入 n 个元素的总复制次数与 成正比,这意味着平均每次插入操作的时间复杂度是 O(n),这是非常低效的。

本节课中我们一起学习了链表 remove 方法的实现细节,并引入了数组作为列表的另一种实现方式。我们分析了数组列表的基本结构、push_backinsert_front 操作的实现及其时间复杂度,并指出了简单的固定增量扩容策略会导致性能低下。在接下来的课程中,我们将寻找更优的扩容策略(例如加倍扩容),以实现更高效的动态数组。

011:扩容策略与队列/栈实现 🧠

在本节课中,我们将学习数组实现列表时的扩容策略,并深入理解“摊还分析”的概念。随后,我们将探讨两种重要的数据结构——队列和栈,并分析它们如何利用数组或链表高效实现。


摊还分析简介

上一节我们分析了数组列表在满时每次扩容1个单位的策略,其插入操作的代价较高。本节中,我们来看看另一种更高效的策略——加倍扩容,并引入“摊还分析”这一关键概念。

摊还分析的含义是:我们不保证每一步操作都花费确定的时间,而是说在连续的 n 步操作中,总运行时间不会超过 摊还代价 × n。这是一个精确的下界,而非平均值或近似值。它不说明单步操作的具体耗时。

例如,对于每次扩容1个单位的策略,经过分析,n 次插入的总拷贝次数为 (n² + 2n) / 4。那么,每次插入的摊还拷贝次数为:

总拷贝次数 / n = (n² + 2n) / 4n = O(n)

这意味着,在这种策略下,insert 操作的摊还运行时间是 O(n)


加倍扩容策略分析

现在,让我们分析加倍扩容策略。当数组空间不足时,我们将其容量扩大一倍。

以下是扩容过程的轮次分析(从第0轮开始):

  • 第0轮:进行 1 次拷贝(容量从1变为2)。
  • 第1轮:进行 2 次拷贝(容量从2变为4)。
  • 第2轮:进行 4 次拷贝(容量从4变为8)。
  • 第3轮:进行 8 次拷贝(容量从8变为16)。

可以观察到,第 r 轮需要进行 次拷贝。假设我们一共进行了 n 次插入,那么总共经历的轮数 r 满足 2ʳ ≈ n,即 r = log₂ n

所有轮次的总拷贝次数是等比数列求和:

总拷贝次数 = Σ (k=0 到 r) 2ᵏ = 2ʳ⁺¹ - 1 ≈ 2n - 1

因此,n 次插入的总拷贝次数约为 2n - 1

那么,每次插入的摊还拷贝次数为:

(2n - 1) / n ≈ 2 = O(1)

这是一个巨大的改进!采用加倍策略后,insert 操作的摊还运行时间变为 O(1)。虽然单次插入的代价可能很高(例如在扩容时),但平均分摊到每次插入上,代价是常数。


链表与数组列表对比

我们有两种方式实现列表抽象数据类型:单链表和数组列表(如C++中的vector)。以下是它们在不同操作下的时间复杂度对比:

以下是核心操作的时间复杂度对比:

操作 单链表 (Singly Linked List) 数组列表 (Array List / Vector)
在头部插入/删除 O(1) O(1) (摊还)
在给定元素处插入/删除 (已有指针) O(1) O(n) (需要移动后续元素)
在任意位置插入/删除 (按索引查找) O(n) (需要遍历查找) O(n) (查找O(1),但插入/删除需移动元素)
按索引访问数据 O(n) O(1)

如何选择?

  • 数组列表 在内存连续性和随机访问(get)方面有巨大优势,适合以查询为主、数据相对静态的场景。vector 是典型的数组列表实现。
  • 单链表 在动态插入/删除(尤其在已知节点指针时)方面更灵活,且不要求连续内存。这在操作系统等对单次操作时间有严格限制或内存碎片化的底层环境中很关键。

队列的实现 🚶‍♂️🚶‍♀️

队列是一种 先进先出 (FIFO) 的抽象数据类型,就像排队一样。主要操作是:

  • 入队 (enqueue):在队尾添加元素。
  • 出队 (dequeue):从队头移除元素。

使用数组实现队列时,为了达到 O(1) 的入队和出队操作,并高效利用空间,我们采用一种称为循环缓冲区的技巧。

核心思路是维护几个变量:

  • T* items:指向存储元素的数组。
  • size_t capacity:数组的总容量。
  • size_t size:队列中当前的元素数量。
  • size_t front:指向队头元素的索引。

入队位置的计算公式为:

入队索引 = (front + size) % capacity

出队后,只需返回 items[front],并将 front 更新为 (front + 1) % capacity,同时 size 减1。

size == capacity,队列已满,需要进行扩容。扩容时,我们分配一个两倍大的新数组,并将旧数组中的元素按顺序(从 front 开始循环拷贝)复制到新数组的开头,然后将 front 重置为0。这个过程保证了所有操作的摊还时间复杂度为 O(1)


栈的实现 📚

栈是一种 后进先出 (LIFO) 的抽象数据类型,就像一摞书。主要操作是:

  • 压栈 (push):在栈顶添加元素。
  • 弹栈 (pop):从栈顶移除元素。

栈的实现更为直接。无论是用链表(在头部操作)还是数组列表(在尾部操作),pushpop 操作都可以在 O(1) 时间内完成,无需复杂的循环索引逻辑。


总结

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

  1. 摊还分析:一种评估操作序列总成本的平均化方法,重点关注加倍扩容策略如何将数组列表的插入操作优化至摊还 O(1) 时间。
  2. 数据结构对比:深入比较了单链表和数组列表在各项操作上的时间复杂度,理解了它们各自的适用场景。
  3. 队列与栈:实现了两种重要的线性数据结构——队列 (FIFO) 和栈 (LIFO)。我们重点剖析了如何使用数组配合循环缓冲区来实现高效且空间利用率高的队列,其所有核心操作的摊还时间均为常数。

通过掌握这些扩容策略和基础数据结构,你为构建更复杂、高效的算法打下了坚实的基础。

012:迭代器与树的入门

在本节课中,我们将要学习两个核心概念:迭代器。迭代器是C++中一个非常重要的概念,它为我们提供了一种统一的方式来访问和遍历各种数据结构中的元素,而无需关心其内部实现细节。之后,我们将初步了解树这种数据结构。

迭代器是什么?🤔

上一节我们介绍了课程的主要内容。本节中,我们来看看迭代器的基本概念。

迭代器是C++中一个非常重要的概念,它帮助我们访问数据集。假设我们想要遍历数据结构中的每一个元素,例如一个数组、一个链表或一个超立方体。我们如何访问所有这些元素?我们希望逐个地、按某种顺序访问它们。

让我们思考如何实现这一点。迭代器的理念是提供一个统一的接口,让你能够完成这样的操作。这样,你就不需要知道访问不同数据结构的不同方法。否则,例如,在链表中如何定位元素?我们有一个链表节点指针作为数据,我们称它为cur。要访问数据,我们使用cur->data。在数组中,我们使用索引,如data[index]。在超立方体中,访问方式可能更复杂,例如data[x][y][z]

以下是访问不同数据结构“下一个”元素的方法:

  • 链表cur = cur->next;
  • 数组index++;
  • 超立方体:方式复杂且不直观。

迭代器的作用是,无论底层数据结构是什么,都使用相同的接口来遍历其中的所有数据。

迭代器接口的要求 📋

上一节我们了解了迭代器的目的。本节中我们来看看实现迭代器接口的具体要求。

一个提供迭代器接口的类必须提供两个函数。

在类中,如果我编写了Hypercube类并希望允许你使用迭代器访问它,我需要提供两个函数:

  1. iterator begin():返回指向数据结构中第一个元素的迭代器。
  2. iterator end():返回指向数据结构末尾之后一个位置的迭代器。

每个数据结构都必须有自己的迭代器,因为它封装了在该数据结构中移动的方式。

让我们理解“末尾之后一个位置”的含义。对于一个数组,begin()指向起始位置,end()指向最后一个元素之后的位置。对于一个链表,begin()指向头节点,end()则指向nullptrend()是我们知道何时终止循环的方式,类似于标准for循环中的终止条件。

一个典型的基于迭代器的循环如下所示:

for (auto it = data_structure.begin(); it != data_structure.end(); ++it) {
    // 使用 *it 访问当前元素
}

迭代器类需要实现什么?🔧

上一节我们介绍了数据结构需要提供的接口。本节中我们来看看迭代器类本身需要实现哪些操作。

迭代器类需要实现三个核心操作:

  1. 前置递增运算符 (++it):移动到下一个元素,并返回递增后的迭代器。
  2. 不等运算符 (it1 != it2):比较两个迭代器是否不相等。
  3. 解引用运算符 (*it):返回迭代器当前指向的数据。

前置递增意味着先增加再返回值。与之相对的是后置递增 (it++),它先保存原值,递增后返回保存的原值。在迭代器上下文中,通常使用前置递增效率更高。

只有这三个操作是必须实现的。实现了这些,就拥有了一个可用的迭代器接口。

如何实现迭代器?💻

上一节我们列出了迭代器类的必需操作。本节中我们通过一个链表迭代器的例子,看看如何具体实现。

让我们尝试为链表编写迭代器接口。首先,数据结构(如MyLinkedList)需要提供begin()end()函数。

begin()函数可能如下实现:

iterator begin() { return iterator(head); } // 假设head是链表头指针

end()函数可能如下实现:

iterator end() { return iterator(nullptr); }

迭代器类本身需要一个构造函数来初始化其内部状态(例如一个节点指针)。它的构造函数可能像这样:

class iterator {
private:
    ListNode* ptr;
public:
    iterator(ListNode* p) : ptr(p) {} // 构造函数
    // ... 其他必需的操作(++, !=, *)
};

对于数组,begin()可能返回指向索引0的迭代器,end()返回指向索引size(即数组长度)的迭代器。

迭代器的使用示例 🦒

上一节我们探讨了如何实现迭代器。本节中我们来看看如何在代码中使用迭代器。

首先,我们定义一个简单的Animal类,并创建一个包含动物的vector(一种动态数组)。

最详细的使用迭代器的方式如下:

for (std::vector<Animal>::iterator it = zoo.begin(); it != zoo.end(); it++) {
    std::cout << it->name << " eats " << it->food << std::endl;
}

这里,it++是后置递增。使用*it可以访问元素,对于具有成员的结构,也可以使用it->

为了使代码更简洁,可以使用auto关键字:

for (auto it = zoo.begin(); it != zoo.end(); ++it) {
    std::cout << it->name << " eats " << it->food << std::endl;
}

最简洁的方式是使用基于范围的for循环

for (const auto& animal : zoo) {
    std::cout << animal.name << " eats " << animal.food << std::endl;
}

编译器会将这种语法转换为使用迭代器的形式。标准模板库(STL)中的容器(如vector, list, unordered_set)都实现了迭代器接口,因此都可以用这种方式遍历。

总结 📚

本节课中我们一起学习了迭代器的核心概念。迭代器提供了一种统一的机制来遍历各种数据结构,隐藏了底层实现的复杂性。我们了解到,一个支持迭代器的数据结构需要提供begin()end()方法,而迭代器类型本身需要实现前置递增(++it)、不等比较(!=)和解引用(*)操作。通过使用auto关键字和基于范围的for循环,我们可以写出非常简洁和清晰的遍历代码。掌握迭代器是有效使用C++标准库和许多自定义数据结构的关键。

013:树 🌳

在本节课中,我们将要学习计算机科学中最重要的非线性数据结构之一:树。我们将从树的基本概念开始,逐步深入到其形式化定义、不同类型的树(如二叉树、满树、完美树、完全树)以及它们的性质。最后,我们会简要介绍树的抽象数据类型(ADT)和实现思路。


树的基本概念

树是一种无环图。这是理论层面的定义。然而,在本课程中,我们几乎总是在讨论有根树。一个有根树是指有一个特定节点被指定为根的树。任何无根树都可以通过选择任意一个节点作为根,从而转化为有根树。

上图展示了一个有根树的例子,其中标出的节点就是根。箭头方向表示从父节点到子节点的关系,但通常我们也可以省略箭头。


二叉树的形式化定义

上一节我们介绍了有根树的概念,本节中我们来看看一种特殊的树:二叉树。二叉树是每个节点最多有两个子节点的有根树。

我们可以用递归的方式来形式化定义二叉树 T

  • T 要么是一个空树(用 表示)。
  • T 要么是一个根节点 R,加上一个左子树 T_L 和一个右子树 T_R,其中 T_LT_R 本身也是二叉树。

用公式可以表示为:

T = ∅ 或 T = (R, T_L, T_R)

其中 T_LT_R 是二叉树。

将空树包含在定义中对于实现和递归处理非常重要,它为我们提供了清晰的递归基。


树的性质:高度

现在我们来定义树的一个重要性质:高度。树的高度定义为从根节点到最远叶子节点的最长路径上的边数。

高度的计算是递归的。对于一棵树 T,其高度 height(T) 可以定义为:

height(T) = -1,                    如果 T 是空树 (∅)
height(T) = 1 + max(height(T_L), height(T_R)), 如果 T = (R, T_L, T_R)

根据这个定义,空树的高度为 -1,只有一个根节点(无子节点)的树高度为 0。

这个递归定义虽然初看有些奇怪(空树高度为-1),但在后续的证明和算法分析中会非常简洁和一致。


特殊类型的二叉树

了解了基本定义和高度后,我们来看看几种具有特殊性质的二叉树。以下是它们的定义和特点:

满二叉树

一棵满二叉树中,每个节点要么有 0 个子节点(叶子节点),要么有 2 个子节点。
递归定义如下:

F = ∅ 或 F = (R, F_L, F_R),其中 F_L 和 F_R 要么都是空树,要么都不是空树。

完美二叉树

完美二叉树是所有叶子节点都在同一层,并且所有非叶子节点都有两个子节点的树。它拥有给定高度下最多的节点数,第 k 层有 2^k 个节点。
递归定义如下(设 P(h) 为高度 h 的完美二叉树):

P(-1) = ∅
P(h) = (R, P(h-1), P(h-1)), 对于 h >= 0

完全二叉树

完全二叉树是除了最后一层外,所有层都是“满”的(即具有最大节点数),并且最后一层的所有节点都尽可能地向左排列。
这个“向左排列”的性质对于将树高效地存储在数组中非常关键,因为它能保证数组元素的连续性。


不同类型树之间的关系

我们已经定义了满树、完美树和完全树。理解它们之间的关系非常重要:

  • 所有完美树都是满树,也是完全树。
  • 完全树不一定是满树(例如,一个节点只有一个左子节点的树是完全的,但不是满的)。
  • 满树不一定是完全树(例如,一个节点的左子节点有一个子节点,而右子节点为空,这棵树是满的,但不是完全的)。
  • 存在既不是满树、也不是完全树、也不是完美树的树。

这些定义主要是为了我们在描述和实现特定数据结构(如堆)时的方便。


树的抽象数据类型(ADT)与实现思路

最后,让我们思考一下树的抽象数据类型(ADT)应包含哪些基本操作。一棵树(作为数据结构)必须支持以下核心操作:

  • 插入:向树中添加一个节点。
  • 删除:从树中移除一个节点。
  • 遍历:访问或检查树中的所有节点。

在实现层面,二叉树通常使用类似链表的结构。每个树节点包含:

  • 存储的数据。
  • 指向左子节点的指针。
  • 指向右子节点的指针。
    整棵树则由一个指向根节点的指针来管理。

用代码可以表示为:

struct TreeNode {
    DataType data;
    TreeNode* left;
    TreeNode* right;
};

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/1f5a1ed81344141d8b42f1306e93c09c_31.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/1f5a1ed81344141d8b42f1306e93c09c_33.png)

class BinaryTree {
private:
    TreeNode* root;
    // ... 成员函数(插入、删除、遍历等)...
public:
    // ... 公共接口 ...
};

在实际绘图中,我们通常使用简洁的图形化表示,而不是画出每个指针,以方便讨论。


本节课中我们一起学习了树的基本概念,包括有根树和二叉树的定义。我们深入探讨了树的高度这一重要性质,并介绍了满二叉树、完美二叉树和完全二叉树这三种特殊类型及其相互关系。最后,我们概述了树作为抽象数据类型应具备的操作以及其基于节点指针的典型实现思路。树是构建许多高级数据结构(如二叉搜索树、堆、AVL树等)的基础,理解这些核心概念至关重要。

014:证明与遍历 🌳

在本节课中,我们将继续学习树结构。我们将从证明一个关于二叉树空指针数量的定理开始,然后探讨如何遍历树结构,并实现一种称为“前序遍历”的算法。


树与链表的相似性

上一节我们介绍了树的基本概念。树与我们之前学习的链表非常相似,但我们将对树进行更深入的探讨。实际上,我们将在本月的大部分时间里研究树结构。

在硬件层面,树的节点在内存中的实际布局如下图所示。每个节点包含数据以及指向左子树和右子树的指针。

虽然内存布局图看起来有些复杂,但为了便于理解和推理,我们通常采用左侧这种更直观的图形化表示方式。

然而,一个常见的疑问是:与链表相比,树似乎浪费了大量空间。在链表中,我们只有一个空指针(尾指针)。但在二叉树中,我们似乎有很多空指针。例如,在下图所示的树中,有8个数据节点,却有9个空指针。

本节中,我们将通过一个证明来精确计算任意二叉树中空指针的数量。


定理:二叉树空指针数量证明 🔍

我们的定理是:在一个包含 n 个节点的二叉树中,空指针的数量是 n + 1

为了证明这一点,我们首先进行一些直观的验证。观察上面的例子,n = 8,空指针数量为 9,符合 n + 1。我们再检查几个小例子:

  • 包含 0 个节点的树(空树):有 1 个空指针(根指针)。0 + 1 = 1,成立。
  • 包含 1 个节点的树:有 2 个空指针。1 + 1 = 2,成立。
  • 包含 2 个节点的树:所有可能的结构都有 3 个空指针。2 + 1 = 3,成立。
  • 包含 3 个节点的树:所有可能的结构都有 4 个空指针。3 + 1 = 4,成立。

这些基础案例增强了我们的信心。现在,我们使用数学归纳法进行正式证明。

归纳证明步骤

  1. 定义函数:设 nulls(n) 为具有 n 个节点的二叉树中的空指针数量。
  2. 归纳假设:假设对于所有 n < k(其中 k > 0),定理成立,即 nulls(n) = n + 1
  3. 归纳步骤:考虑一个任意具有 k 个节点的二叉树 T
    • 任何二叉树 T 都可以看作由一个根节点、一个左子树和一个右子树组成。
    • 设左子树有 m 个节点(0 ≤ m ≤ k-1),则右子树有 (k - m - 1) 个节点。
    • 根据归纳假设:
      • 左子树的空指针数为 nulls(m) = m + 1
      • 右子树的空指针数为 nulls(k - m - 1) = (k - m - 1) + 1 = k - m
    • 整个树 T 的空指针数等于左、右子树空指针数之和(注意:根节点本身没有空指针,其左右指针的空性已计入子树):
      nulls(k) = nulls(m) + nulls(k - m - 1)
               = (m + 1) + (k - m)
               = k + 1
      
  4. 结论:因此,对于 n = k,定理也成立。结合基础案例,由数学归纳法可知,对于所有 n ≥ 0,定理 nulls(n) = n + 1 成立。

这个证明的关键在于将树递归地分解为根节点和两个更小的子树,这正是处理树和图这类递归数据结构时常用的技巧。这种“分解”思想与递归算法本身的结构是相呼应的。


树的遍历 🚶

在链表中,遍历是直接的:从头节点开始,依次访问下一个节点,直到末尾。但对于树,访问所有节点的方式有多种选择。本节我们来看看其中一种最简单的方式:前序遍历

前序遍历的定义是递归的:

  1. 访问根节点。
  2. 前序遍历左子树。
  3. 前序遍历右子树。

以下是一个简单的二叉树示例,我们手动模拟前序遍历的过程:

遍历顺序为:+ a * b c。算法会递归地访问每个节点,当遇到空指针(null)时,递归调用返回。

前序遍历的C++实现

以下是前序遍历算法的C++递归实现代码:

void preOrderTraversal(Node* cur) {
    if (cur) { // 如果当前节点不为空(非null)
        // 1. 访问根节点(此处操作为打印数据)
        std::cout << cur->data << " ";

        // 2. 前序遍历左子树
        preOrderTraversal(cur->left);

        // 3. 前序遍历右子树
        preOrderTraversal(cur->right);
    }
    // 如果 cur 为 null,则直接返回(递归的基态)
}

代码说明:

  • if (cur)if (cur != nullptr) 的简写,在C/C++中,空指针在布尔上下文中为 false,非空指针为 true
  • 访问节点的操作(std::cout)可以替换为任何需要对节点数据进行的处理。
  • 递归会深入到每个子树,直到遇到空节点后逐层返回。

算法复杂度分析

前序遍历的时间复杂度是 O(n),其中 n 是树中的节点数。因为每个节点恰好被访问一次,并且每个递归调用只产生常数时间的开销(不包括递归调用本身)。同时,这也是一个 Ω(n) 的操作,因为要遍历所有节点,所以其运行时间是严格的线性时间 Θ(n)。

对于树的操作,递归实现通常比迭代实现更简洁易懂,因此在课程中,我们主要使用递归方式。


总结

本节课中我们一起学习了两个核心内容:

  1. 定理证明:我们使用数学归纳法证明了在包含 n 个节点的二叉树中,空指针的数量恰好为 n + 1。这个证明展示了处理递归数据结构时,将其分解为更小子结构的通用证明技巧。
  2. 树遍历:我们介绍了前序遍历算法,它按照“根-左-右”的顺序访问树中的每个节点。我们给出了该算法的递归定义、手动模拟示例以及C++实现代码,并分析了其线性时间复杂度。

理解这些基础概念和技巧,是后续学习更复杂树形结构和算法的重要基石。

015:遍历与字典 🧭

在本节课中,我们将要完成对树遍历的讨论,并开始学习一种新的抽象数据类型(ADT),这种数据类型将在后续课程中被广泛使用。

树的遍历

上一节我们介绍了遍历整棵树的概念。本节中,我们来看看几种不同的遍历方式。

前序遍历

在周一的课程中,我们讨论了前序遍历。其核心思想是:先访问当前节点,然后递归地遍历左子树,最后递归地遍历右子树。

以下是前序遍历的伪代码:

preorder(node):
    if node is not null:
        visit(node.data)
        preorder(node.left)
        preorder(node.right)

后序遍历

除了前序遍历,我们还可以进行后序遍历。后序遍历的顺序是:先递归遍历左子树,然后递归遍历右子树,最后访问当前节点。

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

postorder(node):
    if node is not null:
        postorder(node.left)
        postorder(node.right)
        visit(node.data)

与前序遍历相比,后序遍历中,树的根节点是最后被访问的,所有子树都在它之前被访问。

中序遍历

另一种重要的遍历方式是中序遍历。其顺序是:先递归遍历左子树,然后访问当前节点,最后递归遍历右子树。

以下是中序遍历的伪代码:

inorder(node):
    if node is not null:
        inorder(node.left)
        visit(node.data)
        inorder(node.right)

中序遍历在表达式树中特别有用,它能以正确的运算顺序输出表达式。

层序遍历

以上三种遍历方式本质上都是深度优先的。现在,我们来看看一种广度优先的遍历方式:层序遍历。

层序遍历的算法不采用递归,而是使用一个队列作为工作列表。以下是其步骤:

  1. 创建一个空队列。
  2. 将根节点入队。
  3. 当队列不为空时:
    • 出队一个节点。
    • 访问该节点。
    • 将该节点的左孩子和右孩子(如果存在)依次入队。

这个算法保证了我们先访问距离根节点为0的所有节点,然后是距离为1的节点,依此类推。

有趣的是,如果我们把算法中的队列换成栈,那么我们就得到了前序遍历。这体现了深度优先遍历和广度优先遍历在实现上的紧密联系。

遍历与搜索

遍历和搜索密切相关,但目标不同。遍历必须访问树中的每一个节点。而搜索的目标是在树中找到一个特定的节点。

以下是两种主要的搜索策略:

  • 广度优先搜索(BFS): 按照层序遍历的顺序进行搜索,先检查离根节点近的节点。
  • 深度优先搜索(DFS): 沿着一条路径深入搜索到底,如果没找到再回溯。前序、后序、中序遍历都可以看作是深度优先搜索的变体。

这两种搜索策略各有优劣:

  • 内存占用: 深度优先搜索的内存占用受限于树的深度,而广度优先搜索的内存占用受限于树最宽一层的节点数。
  • 适用场景: 广度优先搜索能保证在无限树中找到有限距离内的目标。深度优先搜索在树很深但较窄时更节省内存。

此外,还有一种结合两者优点的搜索策略,称为迭代加深搜索。它反复进行有深度限制的深度优先搜索,并逐步增加深度限制。虽然会重复访问浅层节点,但其额外的时间开销在可接受范围内,同时保持了类似深度优先搜索的内存效率。

总结

本节课中我们一起学习了树的多种遍历方式,包括前序、后序、中序和层序遍历,并理解了它们与深度优先搜索、广度优先搜索之间的联系。我们还比较了不同搜索策略的特点和适用场景,为后续学习更复杂的数据结构打下了基础。

016:测试与调试 🐛

在本节课中,我们将要学习如何使用Catch测试框架和GDB调试器来辅助C++程序的开发。掌握这些工具能帮助你更早地发现错误,并更高效地定位和修复程序中的问题。

Catch测试框架简介

上一节我们介绍了课程主题,本节中我们来看看Catch测试框架。Catch是一个用于C++的单元测试框架。如果你使用过Java的JUnit,会发现它们非常相似。它允许你测试程序的各个单元或独立函数,而不是整个程序。这使得调试更加容易,因为它能让你尽早发现错误。Catch提供了一系列宏来简化测试,我们将在幻灯片中介绍一些常用的宏。

Catch还可以将测试用例划分为不同的部分,并可以独立运行这些部分。总的来说,开始使用Catch非常简单直接。我们将展示一个Catch测试用例的例子,并演示如何编写一个简单的测试。

以下是Catch中一些重要的宏:

  • REQUIRE:它接受一个表达式并将其求值为布尔值。如果表达式结果为假,则测试失败。例如,如果你知道一个函数的预期输出,可以使用REQUIRE来检查你的函数输出是否与预期输出相同。
  • TEST_CASE:顾名思义,它定义一个测试用例。第一个参数指定测试名称,你还可以根据需要提供额外的标签。
  • SECTION:它是测试用例的一个子集,嵌套在TEST_CASE内部。通常用于组织测试用例,你可以将一个测试用例划分为多个部分,每个部分测试特定的功能。
  • INFO:它的作用是在测试失败时记录一条消息。这相当于添加打印语句,以便在测试过程中获取更多信息。

一个Catch测试示例

现在,我们来看一个具体的Catch测试示例。这个测试用例用于测试向量的sizeresize方法。

TEST_CASE("Testing size and resize for vectors") {
    std::vector<int> vec(5);
    REQUIRE(vec.size() == 5);
    REQUIRE(vec.capacity() >= 5);

    SECTION("Testing resize method") {
        vec.resize(10);
        REQUIRE(vec.size() == 10);
        REQUIRE(vec.capacity() >= 10);
    }
}

在这个测试用例中,我们首先创建了一个包含5个整数的向量。然后,我们有两个断言,要求向量的大小等于5,容量大于等于5。之后,我们有一个专门测试向量resize方法的SECTION。在这个部分中,我们将向量大小调整为10,并对调整后的大小和容量有额外的要求。

运行Catch测试的方法

接下来,我们介绍一些运行Catch测试的有用方法。

  • 默认运行:如果不传递任何参数或标志,Catch将运行整个测试套件。
  • 运行单个测试:你可以通过指定测试名称来运行单个测试用例。
  • 列出所有测试:如果你想查看所有可用的测试列表,可以使用-l标志,它会列出测试文件中的所有测试用例。
  • 显示成功测试的输出:默认情况下,成功的测试输出不会显示。如果你想查看成功运行的测试的输出,可以使用-s标志。
  • 重定向输出到文件:你可以将所有测试用例的输出重定向到一个文件,而不是打印在终端上。使用-o标志并指定文件名即可。

GDB调试器简介

上一节我们介绍了Catch测试,本节中我们来看看GDB调试器。GDB是GNU项目调试器,是一个在Unix系统上调试C/C++程序的流行调试器。它允许你在程序执行时查看内部情况。起初,它复杂的输出可能看起来有点吓人,但实际上它对调试非常有帮助,因为它比Valgrind给你更多的程序执行控制权。

使用Valgrind时,你必须运行整个程序直到它完成或遇到错误崩溃。而使用GDB,你可以通过断点在任意点停止程序的执行。一旦程序停止,你可以检查程序的状态,例如打印出任何变量或内存中的内容,以确保你的函数按预期工作。你还可以逐行步进执行程序。你可以将GDB与打印语句结合使用,以获取程序的更多信息。

常用GDB命令

以下是人们通常使用的一些基本GDB命令:

  • 启动GDBgdb <program_name>
  • 运行程序runr
  • 设置断点break <file>:<line>break <function_name>。例如,break main会在main函数的开头设置一个断点。
  • 删除断点clear
  • 显示代码list 用于显示程序停止位置前后的代码。
  • 步进执行
    • nextn:执行下一行,但不进入函数内部。
    • steps:执行下一行,如果下一行是函数调用,则进入该函数。
    • finish:执行完当前函数并返回到调用它的函数。
    • continuec:继续执行程序直到下一个断点或程序结束。
  • 获取信息
    • info breakpoints:显示所有已设置的断点。
    • info args:显示当前函数的参数。
    • info locals:显示当前函数的局部变量。
  • 打印变量print <variable>p <variable>,打印变量或表达式的值。
  • 查看调用栈backtracebt,显示程序的调用栈。

演示:使用Catch和GDB

现在,我们将通过一个演示来实际应用这些工具。演示代码取自一个练习题,实现一个名为listUnion的函数,该函数接收两个链表,尝试对它们取并集并保持排序。

首先,我们编写一个Catch测试用例来验证listUnion函数。

TEST_CASE("Test Union") {
    // 创建链表 L1 和 L2
    Node* L1 = /* ... 创建链表 ... */;
    Node* L2 = /* ... 创建链表 ... */;

    // 调用待测试函数
    Node* answer = listUnion(L1, L2);

    // 创建预期结果链表
    Node* solution = /* ... 创建预期链表 ... */;

    // 使用INFO打印信息
    INFO("Solution: " + to_string(solution));
    INFO("Answer: " + to_string(answer));

    // 断言结果相等
    REQUIRE(areEqual(answer, solution));
}

运行这个测试,如果一切正常,测试将通过。我们还可以使用-s标志来查看INFO宏打印的信息。

接下来,我们故意在listUnion函数中引入一个错误(例如,注释掉更新链表指针的代码,导致无限循环),然后使用GDB来调试。

  1. 使用gdb ./test启动GDB并加载测试程序。
  2. 使用break listUnionlistUnion函数开头设置断点。
  3. 使用run运行程序,程序会在断点处停止。
  4. 使用nextstep逐行执行。
  5. 使用print命令检查变量(如print L1),观察其值是否如预期变化。
  6. 如果发现程序陷入循环,可以检查循环条件相关的变量,从而定位问题。
  7. 使用continue继续执行,或使用quit退出GDB。

通过结合next(不进入函数)和step(进入函数),你可以细致地跟踪程序流。使用finish可以跳出当前函数。info argsinfo locals可以帮助你查看当前上下文中的变量。

总结

本节课中我们一起学习了两个强大的开发工具:Catch测试框架和GDB调试器。Catch帮助你通过编写单元测试来验证代码的各个部分,确保它们按预期工作,并能及早发现错误。GDB则允许你在程序运行时深入其内部,设置断点、检查变量状态、逐行执行代码,是定位和修复复杂错误的利器。掌握这些工具将极大地提升你开发C++程序的效率和质量。

017:二叉搜索树(BST) 🧠

在本节课中,我们将要学习一种非常重要的数据结构——二叉搜索树。我们将了解它如何作为字典抽象数据类型的一种高效实现,并学习其核心操作:查找、插入和删除。

概述

在之前的课程中,我们讨论了树作为一种抽象概念。今天,我们将转向更具体的树结构。我们将探讨如何在树中进行查找,并引入一个极其有用的抽象数据类型——字典。最后,我们将学习如何使用二叉搜索树来实现字典,以获得比简单链表或数组更好的性能。

从树到字典

首先,让我们思考一个基本问题:如何在一棵普通的二叉树中查找一个元素?

假设我们有一棵二叉树。如果我们想在其中查找一个特定的值,例如“Hian”,任何算法的运行时间是多少?答案是 O(N)。因为对于一个普通的二叉树,我们没有任何关于节点排列顺序的信息,算法可能需要搜索每一个节点。这与在链表中查找元素一样,效率不高。

那么,树结构是否没有优势呢?并非如此。关键在于我们需要对树施加一些额外的约束,使其变得有序。这就引出了我们今天的主角:二叉搜索树

但在深入BST之前,我们需要理解它旨在解决的核心问题:实现字典抽象数据类型。

字典抽象数据类型

字典是一种将数据组织成键-值对的抽象数据类型。它非常有用,以至于许多现代编程语言都内置了这种结构。

  • 核心思想:通过一个唯一的“键”来查找对应的“值”,就像通过单词在词典中查找定义一样。
  • 应用广泛
    • 课程编号映射到课程表。
    • 在图中,将节点映射到与其相连的边。
    • 航空系统中,航班号映射到抵达信息。
    • 整个互联网可以看作一个将URL映射到HTML页面的巨大字典。

字典ADT通常需要支持以下基本操作:

  1. find(key):根据给定的键查找并返回对应的值。这是最基础的操作。
  2. insert(key, value):向字典中插入一个新的键值对。
  3. remove(key):从字典中移除指定键对应的键值对。
  4. 遍历:提供一种方式(例如迭代器)来访问字典中的所有键或键值对。

我们可以用链表来实现字典:insert操作可以是O(1),但findremove操作都需要O(N)的时间来遍历链表以定位元素。我们的目标是找到一种能提供更快查找速度的数据结构。

二叉搜索树介绍 🌲

二叉搜索树是一种特殊的二叉树,它通过维护一种有序结构来高效地支持字典操作。

二叉搜索树的定义
一棵二叉搜索树要么是一棵空树,要么满足以下三个条件:

  1. 其左子树中的所有节点的都小于根节点的键。
  2. 其右子树中的所有节点的都大于根节点的键。
  3. 其左子树和右子树本身也必须是二叉搜索树。

这个性质可以递归地应用到每一个节点上。用公式可以简洁地表示为:对于树中的任意节点 node,有 node.left.key < node.key < node.right.key(如果子节点存在)。

重要说明:在二叉搜索树(作为字典实现)的图示和讨论中,我们通常只画出,因为算法逻辑只依赖于键的比较和顺序。被“放在盒子里”,在需要时进行存取,但不影响树的结构。

BST的核心操作

查找操作

在BST中查找一个键遵循一个简单的算法:

  1. 从根节点开始。
  2. 如果当前节点为空,则查找失败。
  3. 如果目标键等于当前节点的键,则查找成功。
  4. 如果目标键小于当前节点的键,则递归地在左子树中查找。
  5. 如果目标键大于当前节点的键,则递归地在右子树中查找。

这个算法本质上是“二分查找”思想在树形结构上的体现。

代码描述

// 辅助函数,递归查找
TreeNode* findHelper(TreeNode* root, const K& key) {
    if (root == nullptr) {
        return nullptr; // 未找到
    }
    if (key == root->key) {
        return root; // 找到
    } else if (key < root->key) {
        return findHelper(root->left, key); // 在左子树中查找
    } else { // key > root->key
        return findHelper(root->right, key); // 在右子树中查找
    }
}

// 公开的find接口
V find(const K& key) {
    TreeNode* result = findHelper(root, key);
    if (result != nullptr) {
        return result->value; // 返回找到的值
    }
    return V(); // 返回值类型的默认构造对象(表示未找到)
}

时间复杂度:查找操作的时间复杂度为 O(H),其中H是树的高度。在最坏情况下(树退化成一条链),H = N,时间复杂度为O(N)。在理想平衡状态下,H = logN,时间复杂度为O(logN)。

插入操作

插入操作需要先找到键应该被放置的位置,然后创建新节点。位置的特点是:它应该是某个节点的空子指针(nullptr),使得插入后BST的性质仍然保持。

一个巧妙的实现方法是利用一个返回指针引用的查找函数。这个函数不仅用于查找,还能告诉我们新节点应该挂在哪个指针(左孩子指针或右孩子指针)下。

插入步骤

  1. 使用修改后的find函数(返回指针引用)寻找键。如果键已存在,根据设计决定是替换值还是报错。
  2. 如果find返回了一个指向nullptr的引用(即键不存在),就在这个位置new一个新节点并赋值给该引用。

代码描述(简化版,假设键不存在)

// 返回指针引用的辅助查找函数
TreeNode*& findRef(TreeNode*& root, const K& key) {
    if (root == nullptr || root->key == key) {
        return root;
    }
    if (key < root->key) {
        return findRef(root->left, key);
    } else { // key > root->key
        return findRef(root->right, key);
    }
}

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/801cb0b584abceeb8ba8d322e67448c8_33.png)

![](https://github.com/OpenDocCN/cs-notes-pt2-zh/raw/master/docs/uiuc-cs225-dsal/img/801cb0b584abceeb8ba8d322e67448c8_34.png)

// 插入操作(不处理键已存在的情况)
void insert(const K& key, const V& value) {
    TreeNode*& location = findRef(root, key); // 找到应该插入的位置的引用
    location = new TreeNode(key, value); // 在此位置创建新节点
}

注意:上述简化代码在插入已存在的键时会导致内存泄漏(覆盖旧节点)或逻辑错误。在实际实现中,需要先检查location是否为nullptr,仅当是时才创建新节点;或者设计为允许更新已存在键对应的值。

时间复杂度:插入操作同样需要先查找位置,因此时间复杂度也是 O(H)

总结

本节课我们一起学习了二叉搜索树这一重要数据结构。

  • 我们首先回顾了在普通二叉树中查找效率低下的问题,并引入了字典这一抽象数据类型作为学习目标。
  • 接着,我们详细定义了二叉搜索树:一棵每个节点都满足“左小右大”性质的二叉树。
  • 然后,我们深入探讨了BST的两个核心操作:
    • 查找:通过递归地与当前节点比较,沿着左或右子树路径下降,直到找到目标或到达空节点。
    • 插入:利用一个返回指针引用的查找函数,精确定位新节点应被添加的位置(某个空子指针处),并在该处创建新节点。

通过BST,我们实现了字典的findinsert操作,并且其效率取决于树的高度H。这为我们追求O(logN)的操作性能指明了方向:我们需要保持BST的平衡。删除(remove)操作更为复杂,我们将在下一节课中详细探讨。

018:二叉搜索树的删除操作 🌳

在本节课中,我们将要学习二叉搜索树(BST)的删除操作。我们将从最简单的删除情况开始,逐步深入到更复杂的场景,并理解如何高效地维护BST的性质。最后,我们会探讨BST操作的性能,并引出对更优树结构的思考。


课程公告与项目信息 📢

首先,请注意课程网站已发布期末项目的详细信息。目前无需立即开始工作,但需要尽快决定是接受随机分配的团队,还是自行组建一个3-4人的团队。

关于平时作业(MP)的延期政策,本学期没有自动的24小时宽限期。但你有最多两次机会,可以通过向指定邮箱发送申请,获得一次24小时的自动延期。评分会在稍后进行,并保证在“重做”截止日期前完成。

此外,在复习日,你可以选择一次MP进行重做评分。系统将重新评估你在课程最后一天提交的版本,并将你的MP成绩更新为原成绩或新成绩的90%(取较高者)。这项政策不影响额外学分。

对于新发布的MP作业“Mosaics”,今晚将有一场答疑活动(AMA),强烈建议参加。如果无法参加,录像会稍后发布在课程平台上。


回顾插入与引入删除 🔄

上一节我们介绍了二叉搜索树的插入操作,它主要依赖于 find 函数来定位插入点。本节中我们来看看删除操作 remove

我们能否类似地先使用 find 找到目标节点,然后删除它呢?让我们分情况讨论。


情况一:删除叶子节点(零个子节点) 🍃

如果要删除的节点是叶子节点(例如节点40),操作非常简单:

  1. 使用 find 找到指向该节点的指针。
  2. 删除该节点并释放内存。
  3. 将父节点中对应的指针设置为 nullptr

这个过程直观且不会破坏BST的性质。


情况二:删除仅有一个子节点的节点 ➡️

如果要删除的节点只有一个子节点(例如节点25或89),操作也不复杂:

  1. 使用 find 找到指向该节点的指针 r
  2. 保存其唯一子节点的指针 t(可能是左子节点或右子节点)。
  3. 删除节点 r
  4. r 的父节点直接指向保存的子节点 t

以下是该过程的伪代码示意:

// 假设 r 是要删除的节点指针,且已知它只有一个子节点
if (r->left == nullptr) {
    t = r->right;
} else {
    t = r->left;
}
// 执行删除 r 和指针重连的操作

由于BST的性质,将唯一子节点提升到被删除节点的位置,总能保证新树仍然是一棵有效的二叉搜索树。


情况三:删除有两个子节点的节点 🔀

当要删除的节点有两个子节点时(例如节点13),直接删除会留下两个需要处理的子树,情况变得复杂。我们不能简单地将其中一个子节点提升上来。

一个低效的方法是:删除该节点后,将其左右子树的所有节点重新插入一棵新树。但这种方法的时间复杂度在最坏情况下可能达到 O(n²),不可接受。

我们需要一个更聪明的方法。核心思路是:避免直接删除有两个子节点的节点


高效的删除策略:节点交换

我们可以利用BST中序遍历有序的特性。对于一个有两个子节点的节点,它在中序遍历序列中必然存在前驱节点后继节点

  • 中序前驱:左子树中的最大节点。
  • 中序后继:右子树中的最小节点。

关键点在于:这个前驱或后继节点,最多只有一个子节点(零个或一个)。

因此,删除有两个子节点的节点的策略如下:

  1. 找到该节点的中序前驱(或后继)。
  2. 交换这两个节点的位置(或交换它们的键值/数据,如果数据量小的话;但通常交换节点指针更高效)。
  3. 此时,原节点被换到了叶子节点或单子节点位置。
  4. 问题转化为删除一个零个或一个子节点的节点,使用我们已知的方法即可。

例如,要删除节点13,我们找到它的中序前驱12。交换13和12的位置,然后删除位于新位置的(原来的)13节点,此时它已是一个叶子节点。


删除操作的性能分析 ⚡

让我们总结一下BST核心操作的时间复杂度,它们都依赖于树的高度 h

  • find: O(h)
  • insert: O(h) (先 find,再常数时间操作)
  • remove: O(h) (先 find,可能再找一次前驱/后继,仍是 O(h)
  • 各种遍历:O(n) (必须访问每个节点)

目前,树的高度 h 在最坏情况下(树退化成一条链)是 O(n),最好情况下(完全平衡树)是 O(log n)。因此,我们操作的性能在 O(log n)O(n) 之间波动。


平衡的重要性与期望 ⚖️

上述分析揭示了一个关键点:如果树能保持矮胖(高度低),操作就会非常快。我们渴望所有操作都能达到 O(log n) 的时间复杂度。

研究表明,如果数据以随机顺序插入,BST的平均高度确实是 O(log n)。对于有 n 个节点的树,有 n! 种可能的插入顺序,其中只有极少数(如完全升序或降序)会导致退化的链状树(高度为 n),而绝大多数顺序会产生近似平衡的树(高度为 log n)。

如果我们在构建树时就拥有全部数据,我们可以通过精心选择根节点(例如使用快速选择算法找中位数)来构造一棵完美平衡的树。然而,在动态插入和删除的现实场景中,我们无法预知所有操作。


预告与总结 🎯

本节课中我们一起学习了二叉搜索树删除操作的完整逻辑,涵盖了三种情况,并理解了通过交换节点将复杂情况转化为简单情况的巧妙策略。我们也认识到,BST操作的效率高度依赖于树的平衡度。

目前,我们依赖“随机插入”来获得良好的平均性能。但这并不总是可靠的。从下节课开始,我们将探讨如何主动维护树的平衡,确保在最坏情况下也能获得 O(log n) 的性能。这将引出自平衡二叉搜索树,例如AVL树和红黑树。

总结:二叉搜索树的删除操作需要根据节点子节点的数量(0个、1个或2个)采取不同策略。对于有两个子节点的节点,通过与其中序前驱或后继节点进行交换,可以将其转化为更容易删除的情况。所有操作的时间复杂度都与树高 h 相关,而保持树的高度接近 log n 是获得高效操作的关键。

posted @ 2026-03-29 09:31  布客飞龙II  阅读(12)  评论(0)    收藏  举报