mycodeschool-数据结构笔记-全-

mycodeschool 数据结构笔记(全)

001:数据结构导论 🧱

在本节课以及本系列课程中,我们将向你介绍数据结构的概念。数据结构是计算机科学中最基础、最核心的构建模块概念。掌握良好的数据结构知识,是设计和开发高效软件系统的必要条件。

我们时刻都在处理数据,而如何存储、组织和分组数据至关重要。让我们从日常生活中选取一些例子,看看将数据组织成特定结构如何帮助我们。

生活中的数据结构示例

以下是几个例子,说明特定的数据结构如何帮助我们高效地处理信息。

  • 字典:我们能够快速高效地在语言字典中搜索单词,是因为字典中的单词是排序的。如果字典中的单词没有排序,在数百万个单词中搜索一个单词将是不切实际甚至不可能的。因此,字典被组织成一个有序的单词列表
  • 城市地图:像地标位置和道路网络连接这样的数据,是以几何图形的形式组织的。我们将地图数据以这些几何图形的形式展示在二维平面上。地图数据需要这样结构化,以便我们拥有比例尺和方向,从而有效地搜索地标并获取从一个地方到另一个地方的路线。
  • 现金账簿:对于企业每日的现金收支记录(在会计中也称为现金账簿),以表格的形式组织和存储数据最有意义。如果数据被组织在这些表格的列中,汇总数据和提取信息就会非常容易。

由此可见,不同类型的数据需要不同的结构来组织。

计算机中的数据组织

计算机处理各种数据:文本、图像、视频、关系数据、地理空间数据,以及我们在这个星球上拥有的几乎所有类型的数据。我们如何在计算机中存储、组织和分组数据至关重要,因为计算机处理的是极其庞大的数据量。

即使拥有机器的计算能力,如果我们不使用正确的结构、正确的逻辑结构,我们的软件系统也不会高效。

数据结构的正式定义

数据结构的正式定义是:数据结构是一种在计算机中存储和组织数据的方式,以便数据能被高效使用

当我们研究数据结构作为存储和组织数据的方式时,我们从两个角度进行研究。

数据结构的两种视角

上一节我们介绍了数据结构的基本概念,本节中我们来看看研究数据结构的两种主要视角。

  1. 作为数学和逻辑模型(抽象数据类型):当我们将其视为数学和逻辑模型时,我们只关注它们的抽象视图。我们从一个高层次来看,是哪些特性和操作定义了那个特定的数据结构。现实世界中抽象视图的例子可以是:设备“电视机”的抽象视图是,它是一个可以打开和关闭的电子设备,可以接收卫星节目信号并播放节目的音视频。只要我有这样一个设备,我就不关心电路是如何嵌入来创建这个设备的,或者哪家公司制造了这个设备。这就是一个抽象视图。因此,当我们把数据结构作为数学或逻辑模型来研究时,我们只是定义它们的抽象视图,换句话说,我们有一个术语来描述它——我们将其定义为抽象数据类型

    抽象数据类型的一个例子可以是:我想定义一个叫做列表的东西。它应该能够存储一组特定数据类型的元素,并且我们应该能够通过元素在列表中的位置来读取它们,也应该能够修改列表中特定位置的元素。我们是在定义一个模型。然后,我们可以用多种方式在编程语言中实现它。这就是抽象数据类型的定义,我们也称抽象数据类型为 ADT。如果你注意到,所有高级语言都已经以数组的形式提供了这种ADT的具体实现。所以数组是提供了所有这些功能的具体实现的数据类型。

  1. 作为具体实现:谈论数据结构的第二种方式是谈论它们的实现。实现将是一些具体的类型,而不是抽象数据类型。我们可以在同一种语言中以多种方式实现同一个ADT。例如,在C或C++中,我们可以将这个列表ADT实现为一个名为链表的数据结构。如果你还没听说过它,我们将在后续课程中详细讨论链表。

抽象数据类型的正式定义

上一节我们区分了抽象模型和具体实现,本节中我们来正式定义一下抽象数据类型,因为这是我们经常会遇到的一个术语。

抽象数据类型是数据和操作的定义实体,但没有实现细节。它们只说明“是什么”,而不说明“如何做”。

本课程将涵盖的内容

在本课程中,我们将讨论许多数据结构。我们将把它们作为抽象数据类型来讨论,同时也会学习如何实现它们。

以下是一些我们将要讨论的数据结构:

  • 链表
  • 队列
  • 还有更多可以学习的结构

当我们研究这些数据结构时,我们将研究它们的逻辑视图,研究这些数据结构为我们提供了哪些操作,研究这些操作的成本(主要是在时间方面),并且我们肯定会研究它们在编程语言中的实现。

我们将在接下来的课程中学习所有这些数据结构。

总结

本节课中我们一起学习了数据结构的基本概念。我们了解到数据结构是高效存储和组织计算机数据的基础。我们通过日常例子理解了为什么需要数据结构,并给出了数据结构的正式定义。我们区分了研究数据结构的两种视角:作为抽象数据类型和作为具体实现,并正式定义了抽象数据类型。最后,我们预览了本课程将要涵盖的主要数据结构。在接下来的课程中,我们将深入探讨每一种结构。

002:列表作为抽象数据类型 📚

在本节课中,我们将学习一个简单的数据结构——列表。我们将首先从抽象数据类型的角度定义它,然后探讨其可能的实现方式,并分析不同操作的性能。


概述

上一节我们介绍了数据结构的概念,并了解到可以从两个层面讨论数据结构:一是作为数学和逻辑模型,即抽象数据类型;二是作为具体的实现。本节我们将研究一个简单的数据结构——列表。

列表是现实世界中常见的实体,它本质上是相同类型对象的集合。我们可以有单词列表、姓名列表或数字列表。


列表作为抽象数据类型

当我们定义抽象数据类型时,我们只定义其存储的数据和可用的操作,而不涉及实现细节。

首先,让我们定义一个非常基础的列表。我希望这个列表能存储给定数量的、给定数据类型的元素。这将是一个静态列表,列表中的元素数量不会改变,并且在创建列表之前我们就知道元素的数量。

我们应能写入或修改列表中任意位置的元素,当然,也应能读取列表中特定位置的元素。

如果你学过基础的编程课程,你可能会想到:数组提供了所有这些功能。例如,要创建一个整数列表,我们可以声明一个整数数组,并在声明时指定其大小。我们可以像 A[i] 这样访问或修改第 i 个位置的元素。

因此,数组是这种静态列表的一种实现


动态列表的需求

现在,我想要一个功能更丰富的列表,它能处理更多场景。我不想要一个固定大小的静态集合,我想要一个能根据我的需求动态增长的列表。

我的动态列表应具备以下特性:

  • 当列表中没有元素时,列表为空,大小为0。
  • 我可以在列表中的任意位置插入一个元素。
  • 我可以从列表中移除一个元素。
  • 我可以统计列表中的元素数量。
  • 我可以读取或修改列表中特定位置的元素。
  • 我可以在创建列表时指定其数据类型(例如整数、字符串等)。

使用数组实现动态列表

那么,如何实现这样的动态列表呢?实际上,我们可以在数组的基础上,通过编写一些额外的操作来实现它。

为了简化设计,我们假设列表的数据类型是整数。实现思路如下:

  1. 声明一个非常大的数组,并定义一个最大容量 MAX_SIZE
  2. 数组元素按 A[0], A[1], A[2]... 的方式索引。
  3. 定义一个变量(例如 end)来标记列表中最后一个元素在数组中的位置
    • 如果列表为空,我们将 end 设置为 -1(因为最小的有效索引是0)。
    • 在任何时候,数组的一部分存储着列表。

以下是核心操作的逻辑:

插入元素

  • 如果要在列表末尾插入元素(不指定位置),只需将新元素放在 end + 1 的位置,然后更新 end
  • 如果要在特定位置(例如索引 i)插入元素,为了给新元素腾出空间,我们需要将位置 i 及其之后的所有元素向右移动一个单位,然后再插入新元素,并更新 end

移除元素

  • 要移除特定位置(例如索引 i)的元素,我们需要将位置 i 之后的所有元素向左移动一个单位以填补空缺,然后更新 end

其他操作

  • 判断列表是否为空:检查 end 是否等于 -1
  • 统计元素数量:数量等于 end + 1
  • 读取/修改元素:直接通过数组索引 A[i] 访问即可。


处理数组容量不足的问题

上述实现有一个明显的问题:我们预先声明的数组有最大容量。如果列表不断增长,最终会填满整个数组。

我们无法扩展同一个数组。因此,需要设计一个策略来处理数组已满的情况:

  1. 当数组已满时,我们创建一个新的、更大的数组
  2. 将旧数组中的所有元素复制到新数组中。
  3. 释放旧数组占用的内存。

关键问题是:新数组应该比旧数组大多少?

创建新数组和复制元素的操作在时间上成本很高。一个好的设计策略是尽量减少这种高成本操作的频率。一个常见的策略是:每次数组满时,就创建一个容量为旧数组两倍的新数组。选择这个策略的原因(涉及摊还分析)我们暂不深入讨论。


性能分析

研究数据结构不仅关乎操作和实现,也关乎分析这些操作的成本(时间复杂度)。

以下是基于数组实现的动态列表各项操作的时间复杂度分析:

  • 访问元素:通过索引读取或写入任何元素是 常数时间,记为 O(1)。因为数组在内存中是连续存储的,可以通过基地址和索引直接计算出元素地址。
  • 插入元素
    • 列表末尾插入:通常是 O(1)。但如果触发数组扩容(复制操作),在最坏情况下是 O(n)
    • 列表中间或开头插入:需要移动元素,在最坏情况下(在开头插入)需要移动所有 n 个元素,因此是 线性时间,记为 O(n)
  • 移除元素:同样,在最坏情况下(从开头移除)需要移动所有剩余元素,时间复杂度为 O(n)


总结与展望

本节课我们一起学习了列表作为抽象数据类型的定义,并重点探讨了如何使用数组来实现一个动态列表。

这种实现方式的优点是:能够以常数时间 O(1) 随机访问任何位置的元素

但其缺点也很明显:

  1. 在列表中间进行插入和删除操作成本较高(O(n))。
  2. 当列表频繁扩容和缩容时,反复创建新数组和复制元素会带来额外开销。
  3. 内存利用率可能不高,因为数组可能经常有很大一部分空间未被使用。

这促使我们思考:是否存在一种数据结构,既能提供动态列表的功能,又能更高效地利用内存?

答案是肯定的,这种数据结构就是链表。我们将在下一节课中详细学习链表。


感谢观看。

003:链表简介 🔗

在本节课中,我们将向你介绍链表数据结构。

概述

上一节我们尝试使用数组实现动态列表,但遇到了一些问题。本节我们将探讨这些问题的根源,并引入链表数据结构作为解决方案。

数组的局限性

使用数组时,我们面临一些限制。为了更好地理解链表,我们需要先理解这些限制。下面通过一个简单的故事来帮助你理解。

内存管理的故事

假设这是计算机的内存,每个分区代表一个字节。每个字节都有一个地址。

为了便于理解,我们将内存从左到右水平绘制。假设最左边的字节地址是200,向右地址递增,依次为201、202、203,依此类推。

内存是一种关键资源,所有应用程序都需要使用它。计算机将管理内存的任务交给了“内存管理器”。内存管理器负责跟踪哪些内存是空闲的,哪些已被分配。任何需要存储数据的人都需要与内存管理器沟通。

程序员Albert正在构建一个应用程序,他需要在内存中存储一些数据。

存储单个变量

首先,Albert想存储一个整数。他通过声明一个整数变量来向内存管理器请求内存。

内存管理器看到这个声明,知道整数变量在典型架构中占用4个字节。于是,内存管理器在内存中寻找4个字节的连续空闲空间,并将其分配给变量x。假设分配的内存块起始地址是217。

内存管理器将这个地址告知Albert,Albert就可以在这个地址存储他想要的整数值。

存储数组(列表)

现在,Albert需要存储一个整数列表。他认为列表最多包含4个整数,于是他向内存管理器请求一个名为a、大小为4的整数数组。

数组在内存中总是以一个连续的内存块存储。因此,内存管理器需要寻找一个16字节(4个整数 * 4字节/整数)的连续空闲块。假设它分配了起始地址为201、结束地址为216的内存块给数组a

当Albert尝试访问数组中的任何元素时,例如他想写入a[3](第四个元素),他的应用程序知道在哪里写入,因为它知道数组a的基地址(起始地址)。通过基地址和索引(这里是3),可以计算出a[3]的地址(例如203)。因此,访问数组中的任何元素都只需要常数时间,这是数组的一大优点。

Albert用这个数组存储他的列表,假设他存入了值:8, 2, 6, 5。

数组扩展的问题

后来,Albert觉得他需要在列表中添加第五个元素。但他声明的是大小为4的数组。他询问内存管理器是否可以扩展同一个内存块。

内存管理器回复说,当它为数组分配内存时,并不期望你会请求扩展。它通常会将相邻的内存用于其他变量。在这个例子中,变量x就在数组块的旁边,因此无法扩展。

Albert的选项是:告诉内存管理器新的尺寸,内存管理器会在一个新地址重新创建一个新块,然后必须将所有元素从旧块复制到新块。

Albert决定这次申请一个更大的新数组,以防再次填满。新块在地址224被分配。Albert请求内存管理器释放旧块,并承担复制所有元素到新块的成本。现在他可以添加新元素了。

数组方案的缺点

Albert仍然感到困扰,因为如果列表很小,数组的一部分内存就被浪费了。如果列表再次增长,他又必须创建一个新数组,并再次复制所有元素。他迫切需要一个解决方案。

链表:解决方案

这个问题的解决方案是一种名为链表的数据结构。

链表的核心思想

Albert可以这样做:不再向内存管理器请求一个大的连续内存块(数组),而是每次为一个数据单元(一个元素)单独请求内存。

假设Albert想再次存储四个整数(6, 5, 4, 2)。他可以为每个整数单独请求内存:

  • 第一次请求,为数字6分配了起始地址为204的4字节块。
  • 第二次请求,为数字5分配了起始地址为217的4字节块。
  • 第三次请求,为数字4分配了起始地址为232的4字节块。
  • 第四次请求,为数字2分配了起始地址为242的4字节块。

由于是单独的请求,这些内存块很可能不是连续的,而是分散在内存中。

连接分散的块

我们需要存储更多信息来表明哪个块是列表的第一个元素,哪个是第二个,等等。我们需要以某种方式将这些块链接在一起。

对于数组,我们有一个连续的内存块,通过基地址和元素位置可以计算出地址。但对于这些分散的块,我们需要存储“这是第一个块”和“这是第二个块”的信息。

为了链接这些块,我们可以在每个块中存储一些额外信息。我们可以让每个块包含两个部分:

  1. 数据部分:存储元素的值。
  2. 地址部分:存储下一个块的地址。

在我们的例子中:

  • 第一个块(地址204,存储6)的地址部分存储217(下一个块的地址)。
  • 第二个块(地址217,存储5)的地址部分存储232
  • 第三个块(地址232,存储4)的地址部分存储242
  • 第四个块(地址242,存储2)是最后一个块,没有下一个块。其地址部分可以存储0(或NULL),表示列表结束。

链表的节点结构

现在,Albert需要向内存管理器请求一个能存储两个变量的内存块:一个整数变量(存储元素值)和一个指针变量(存储下一个节点的地址)。

在C语言中,他可以定义一个名为Node的类型:

struct Node {
    int data;           // 存储数据
    struct Node* next;  // 存储下一个节点的地址
};

内存管理器会分配一个8字节的块(假设整数和指针各占4字节)。我们称这个块为一个节点

链表的逻辑视图

如果我们像这样在内存中存储列表——由这些非连续的节点通过指针相互连接——这就是链表数据结构。

链表的逻辑视图如下所示:

头节点 -> [数据:6 | 地址:217] -> [数据:5 | 地址:232] -> [数据:4 | 地址:242] -> [数据:2 | 地址:NULL]
  • 数据存储在节点中。
  • 每个节点存储数据以及指向下一个节点的链接(指针)。
  • 第一个节点称为头节点
  • 我们始终保存的唯一信息是头节点的地址,它让我们能够访问整个列表。
  • 最后一个节点的地址部分是NULL,表示它不指向任何其他节点。

遍历链表

要遍历链表,唯一的方法是:从头节点开始,访问第一个节点,然后询问第一个节点下一个节点的地址,接着前往下一个节点,再询问其下一个节点的地址,依此类推。这就像玩寻宝游戏。

链表的操作与复杂度

  • 访问元素:与数组不同,我们无法在常数时间内访问任意元素。我们必须从头开始,逐个节点查找。在最坏情况下(访问最后一个元素),需要遍历所有n个元素。因此,访问操作的时间复杂度是 O(n)
  • 插入节点:我们可以在列表的任何位置插入。首先创建一个新节点,然后调整相关节点的链接即可。
    • 例如,在第三个位置插入值10。我们创建一个新节点(假设地址310),然后将第二个节点的next指针指向新节点(310),新节点的next指针指向原来的第三个节点(存储4的节点)。
    • 但是,为了找到插入位置,我们通常需要遍历链表。因此,插入操作的时间复杂度通常也是 O(n)。不过,插入本身(调整指针)是简单的操作,不像数组插入那样需要移动大量元素。
  • 删除节点:类似地,删除节点也需要遍历找到目标位置,然后调整指针,时间复杂度也是 O(n)

链表的优点

我们可以看到链表的一些优点:

  • 动态大小:我们可以在需要时创建节点,不需要时释放节点。无需像数组那样预先猜测列表的大小。
  • 高效插入/删除:虽然查找位置需要O(n),但实际的插入或删除操作(调整指针)非常高效,不需要移动后续所有元素。
  • 无内存浪费:链表按需分配内存,没有像固定大小数组那样可能产生的未使用空间(虽然每个节点需要额外空间存储指针)。

总结

本节课我们一起学习了链表数据结构。我们首先回顾了使用数组实现动态列表时在内存使用和扩展性方面的局限性。然后,我们通过一个内存管理的故事,引出了链表的核心思想:使用分散的节点存储数据,并通过指针将它们链接起来。我们介绍了链表的节点结构、逻辑视图、遍历方式以及基本操作(访问、插入、删除)的时间复杂度(O(n))。链表的主要优势在于其动态性和插入/删除操作的效率,但代价是失去了数组的随机访问能力。在接下来的课程中,我们将详细讨论链表的各种操作、实现以及与数组的全面比较。

004:数组 vs 链表 🆚

在本节课中,我们将比较数组和链表这两种基础数据结构。我们将基于访问元素、内存使用、插入/删除操作的成本以及易用性等参数,分析它们各自的优缺点,并探讨在不同应用场景下应如何选择。

概述

上一节我们介绍了链表数据结构,并了解了链表如何解决数组存在的一些问题。现在,一个显而易见的问题是:数组和链表,哪一个更好?实际上,没有一种数据结构绝对优于另一种。一种数据结构可能非常适合某种需求,而另一种则可能更适合另一种需求。这完全取决于多种因素,例如你最常执行的操作是什么,或者数据规模有多大。本节课,我们将基于这些操作的“成本”参数来比较这两种数据结构,通过对比它们的优缺点,理解在何种场景下应使用数组,在何种场景下应使用链表。

参数比较

我们将从以下几个关键参数来比较数组和链表。

1. 访问元素的成本

访问元素时,无论数组大小如何,其耗时都是恒定的。这是因为数组在内存中是连续存储的。如果我们知道内存块的起始地址(基地址),就可以通过一个简单公式计算出任何索引位置元素的地址。

数组元素地址计算公式
元素地址 = 基地址 + 索引(i) * 数据类型大小(字节)

例如,一个整型数组的基地址是200,整型大小为4字节。那么索引为6的元素的地址就是 200 + 6 * 4 = 224。这种计算是瞬间完成的,因此数组访问元素的时间复杂度是 O(1)(常数时间)。

对于链表,数据并非连续存储。链表由节点组成,每个节点包含数据域和指向下一个节点的指针域。我们只保存头节点的地址。要访问链表中特定位置的元素,必须从头节点开始,逐个遍历节点,直到到达目标位置。

  • 最坏情况下(访问最后一个元素),需要遍历所有 n 个节点。
  • 平均情况下(访问中间元素),需要遍历大约 n/2 个节点。

因此,链表访问元素的时间复杂度是 O(n)(线性时间)。

结论:在访问元素这个参数上,数组明显优于链表。如果你的应用需要频繁随机访问列表中的元素,数组是更好的选择。

2. 内存需求

使用数组时,我们需要在创建前确定其大小,因为数组是固定大小的连续内存块。通常的做法是创建一个足够大的数组,其中一部分用于存储列表元素,另一部分保持空闲以备添加新元素。这可能导致内存浪费。

对于链表,内存是按需分配的,每添加一个元素才申请一个节点的内存,因此没有预留的闲置空间。但是,链表每个节点都需要额外的内存来存储指针(指向下一个节点的地址)。

以下是一个内存占用的对比示例:

  • 数组:一个包含7个整型元素的数组,假设整型占4字节,总内存为 7 * 4 = 28 字节。
  • 链表:一个包含3个整型元素的链表,每个节点包含数据(4字节)和指针(4字节),总内存为 3 * (4 + 4) = 24 字节。

从这个例子看,链表似乎更省内存。但情况并非总是如此:

  • 如果数据部分本身很大(例如一个复杂结构占16字节),那么链表的优势会更明显(数组:7 * 16 = 112 字节;链表:3 * (16 + 4) = 60 字节)。
  • 如果数据部分很小(如整型),数组的固定大小策略可能导致浪费,而链表的指针开销则不可忽视。

此外,由于数组需要连续的大块内存,在创建非常大的数组时,可能会遇到内存碎片问题,即系统有很多小内存块,但无法提供一整块足够大的连续内存。链表则能更好地利用分散的内存块。

结论:链表在内存使用上更灵活,没有固定大小限制,也无需预留空间,但每个节点有指针开销。数组需要连续内存,可能造成浪费或分配失败,但在数据元素较小时可能更紧凑。

3. 插入元素的成本

插入操作可以分为三种情况:在开头插入、在结尾插入和在中间插入。

以下是不同情况下数组和链表的时间复杂度对比:

操作场景 数组 (作为动态列表) 链表
在开头插入 O(n)。需要将所有元素向后移动。 O(1)。只需创建新节点并调整头指针。
在结尾插入 O(1) (如果数组未满)。若数组已满,需创建新数组并复制全部元素,此时为 O(n) O(n)。需要遍历到链表末尾。
在中间插入 O(n)。平均需要移动约 n/2 个元素。 O(n)。平均需要遍历约 n/2 个节点以找到插入位置。

删除元素的成本与插入类似,三种场景下的时间复杂度与上述表格完全一致。

结论

  • 如果频繁在列表开头进行插入/删除,链表具有绝对优势(O(1))。
  • 如果主要在结尾进行操作且数组空间充足,数组更快(O(1)),但数组扩容成本高。
  • 中间进行插入/删除,两者平均情况都是 O(n),但数组涉及耗时的元素移动,而链表只需遍历和修改指针。

4. 易用性与实现

数组在大多数编程语言中都是内置的基本类型或标准库组件,使用起来非常简单直观。访问和修改元素语法简洁。

链表的实现,尤其是在C/C++这类需要手动管理内存的语言中,更为复杂且容易出错。开发者需要小心处理指针操作,以避免出现段错误(访问非法内存)和内存泄漏(未释放已分配的内存)等问题。

结论:数组更容易使用和实现。链表需要更谨慎的编程技巧。

总结

本节课我们一起学习了数组和链表的对比。我们来总结一下核心要点:

  • 访问速度:数组 (O(1)) 远快于链表 (O(n))。
  • 内存效率:链表按需分配,无闲置内存,但有指针开销;数组需连续内存,可能浪费空间。
  • 插入/删除效率
    • 在开头:链表 (O(1)) 优于数组 (O(n))。
    • 在结尾:数组 (通常O(1)) 优于链表 (O(n)),但数组扩容成本高。
    • 在中间:两者均为 O(n),但链表仅需遍历,数组还需移动元素。
  • 易用性:数组更简单,链表实现更复杂且易出错。

如何选择

  • 如果需要快速随机访问,选择数组
  • 如果频繁在列表开头进行插入/删除,或者数据规模变化很大、无法预知,选择链表
  • 如果内存是紧缺资源,且数据元素本身很大,链表可能更节省内存。
  • 对于简单的、大小固定的列表,数组通常是更直接的选择。

没有一种结构是万能的,最佳选择取决于你的具体需求。在下一节课中,我们将用C/C++语言动手实现一个链表,通过实际代码来深入理解它。本节课就到这里,感谢学习。

005:链表在C/C++中的实现 🧠

在本节课中,我们将学习如何在C和C++语言中实现链表。我们将从链表的基本概念开始,逐步讲解如何创建节点、构建链表,并实现插入和遍历操作。课程内容将尽可能简单直白,确保初学者能够理解。


概述

在前面的课程中,我们描述了链表数据结构,分析了链表中各种操作的成本,并将链表与数组进行了比较。现在,我们将动手实现链表。在C和C++中的实现非常相似,但存在一些细微差别,我们将在课程中讨论。

本课程的先决条件是您需要对C/C++中的指针有实际了解,并且理解动态内存分配的概念。


链表的基本概念

我们知道,在链表中,数据存储在多个非连续的内存块中,每个内存块称为一个节点

每个节点包含两个部分:

  1. 一个用于存储数据。
  2. 另一个用于存储下一个节点的地址(也称为链接)。

例如,一个包含三个整数的链表可能如下所示(逻辑视图):

  • 第一个节点地址为200,数据为2,链接指向地址100(第二个节点)。
  • 第二个节点地址为100,数据为4,链接指向地址300(第三个节点)。
  • 第三个节点地址为300,数据为6,链接指向NULL(表示链表结束)。

NULL是一个宏,代表地址0,表示指针不指向任何有效的内存位置。

分配给每个节点的内存块的地址是随机的,它们之间没有顺序或相邻关系。因此,我们需要这些链接来维护节点间的关系。

我们始终持有的链表标识是第一个节点的地址,也称为头节点。我们使用一个类型为“指向节点的指针”的变量来存储这个地址。这个指针变量(例如命名为 head)也可以被解释为链表的名称,因为它是我们始终持有的唯一标识。


在程序中定义节点

在我们的程序中,节点将是一种具有两个字段的数据类型。

在C语言中,我们可以使用结构体来定义这样的数据类型。以下是一个用于整数链表的节点定义:

struct Node {
    int data;           // 存储数据
    struct Node* next;  // 存储指向下一个节点的指针
};

在C++中,定义方式更简洁:

struct Node {
    int data;
    Node* next;
};

在我们的逻辑视图中:

  • 变量 head 的类型是 Node*(指向节点的指针)。
  • 每个包含两个字段的矩形(节点)的类型是 Node
  • 节点中的第一个字段(data)类型是 int
  • 第二个字段(next)类型是 Node*

在开始实现链表之前,理解逻辑视图中的这些对应关系非常重要。


创建链表与插入节点

现在,让我们通过代码创建之前示例中的整数链表。为此,我们需要实现两个基本操作:

  1. 向链表中插入节点。
  2. 遍历链表。

首先,我们需要声明一个指向头节点的指针。当链表为空时,这个指针应该指向NULL

Node* head = NULL; // 创建一个空链表

插入第一个节点

当链表为空时,插入节点的逻辑相对简单。以下是步骤:

  1. 动态创建一个新节点:在C中使用malloc,在C++中使用new运算符。
  2. 填充新节点的数据
  3. 将新节点的next指针设为NULL(因为它是第一个也是最后一个节点)。
  4. 让头指针head指向这个新节点

以下是C++的实现代码片段:

// 创建新节点
Node* temp = new Node();
// 填充数据
temp->data = 2;
// 作为第一个节点,next指向NULL
temp->next = NULL;
// 头指针指向新节点
head = temp;

代码说明

  • Node* temp = new Node(); 在堆内存中分配一个Node大小的空间,并返回其地址给指针temp
  • temp->datatemp->next 是使用箭头运算符(->)访问指针所指向结构体的成员。它等价于 (*temp).data(*temp).next
  • 最后,head = temp; 将头指针指向新创建的节点。

至此,我们拥有了一个包含一个节点(数据为2)的链表。


在链表末尾插入更多节点

现在,我们想继续在链表末尾插入值为4和6的节点,以构建示例中的三节点链表。

插入逻辑如下:

  1. 创建新节点并填充数据。
  2. 遍历链表找到最后一个节点
  3. 将最后一个节点的next指针指向新节点

以下是实现第二个节点(值为4)的代码逻辑:

// 1. 创建新节点
Node* temp = new Node();
temp->data = 4;
temp->next = NULL;

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/mycodeschool-dast/img/a640654184be69c31222a364361d27d2_86.png)

// 2. 遍历链表找到最后一个节点
Node* temp1 = head; // 从头部开始
while(temp1->next != NULL) {
    temp1 = temp1->next; // 移动到下一个节点
}
// 循环结束后,temp1指向最后一个节点

// 3. 链接新节点
temp1->next = temp;

遍历逻辑详解

  • 我们使用一个临时指针 temp1 来遍历,初始指向 head重要:我们从不直接修改 head 指针来遍历,否则会丢失链表起点。
  • while(temp1->next != NULL) 循环检查当前节点的next字段。只要它不是NULL,就说明当前节点不是最后一个,于是执行 temp1 = temp1->next; 移动到下一个节点。
  • temp1->nextNULL 时,temp1 就指向了最后一个节点,循环结束。

重复上述过程(创建节点、遍历到末尾、链接),即可插入值为6的第三个节点。


完整的插入逻辑整合

在实际编程中,我们需要将两种情况整合到一个函数中:

  • 情况1:链表为空(head == NULL)。此时新节点成为头节点。
  • 情况2:链表非空。需要遍历到末尾再插入。

一个在末尾插入节点的完整函数框架如下:

void InsertAtEnd(Node** headRef, int data) {
    // 创建新节点
    Node* newNode = new Node();
    newNode->data = data;
    newNode->next = NULL;

    // 如果链表为空,新节点成为头节点
    if (*headRef == NULL) {
        *headRef = newNode;
        return;
    }

    // 否则,遍历到链表末尾
    Node* lastNode = *headRef;
    while (lastNode->next != NULL) {
        lastNode = lastNode->next;
    }
    // 将新节点链接到最后
    lastNode->next = newNode;
}

注意:这里使用 Node** headRef(指向指针的指针)是为了能够在函数内部修改外部的 head 指针(当链表为空时)。


总结

本节课中,我们一起学习了链表在C/C++中的核心实现:

  1. 定义节点结构:使用struct来组合数据域和指针域。
  2. 理解指针与内存:使用new(C++)或malloc(C)进行动态内存分配,并通过指针操作节点。
  3. 实现基础操作
    • 创建空链表:将头指针初始化为NULL
    • 在空链表中插入第一个节点。
    • 在非空链表末尾插入节点:关键在于使用临时指针遍历链表,直到找到nextNULL的最后一个节点。
  4. 重要原则:始终保留好头指针(head),遍历时使用临时指针,避免丢失链表起点。

在接下来的课程中,我们将把这些逻辑封装成独立的函数(例如PrintList, InsertAtBeginning, InsertAtPosition),并看到它们在编译器中的运行代码。掌握这些基础是理解更复杂链表操作(如删除、反转)的关键。

006:在链表头部插入节点 🧩

在本节课中,我们将学习如何在C或C++程序中,向一个链表的头部插入一个新节点。我们将从定义链表节点结构开始,逐步实现插入和遍历打印功能,并探讨不同作用域下(全局变量与局部变量)的代码实现差异。


链表节点定义

上一节我们介绍了如何将链表的逻辑视图映射到C/C++程序中。本节中,我们首先需要定义构成链表的基本单元——节点。

在C语言中,节点通常用一个结构体(struct)来定义。它包含两个字段:

  1. 一个用于存储数据(例如整数)。
  2. 一个用于存储指向下一个节点的地址的指针。

以下是节点定义的代码:

struct Node {
    int data;           // 存储数据
    struct Node* next;  // 指向下一个节点的指针
};

核心概念struct Node* next; 这个指针是链表连接各个节点的关键。在C++中,可以简写为 Node* next;


创建链表头指针

定义了节点结构后,我们需要一个指针来跟踪链表的第一个节点,通常称为头指针(head)。

struct Node* head = NULL; // 初始化头指针为空,表示链表为空

此时,head 指向 NULL,表示链表尚未包含任何节点,是一个空链表。


实现插入函数

现在,让我们实现核心的插入函数 Insert。这个函数的目标是在链表的头部添加一个新节点。

插入操作需要考虑两种情况:

  1. 链表为空时(head == NULL)。
  2. 链表不为空时。

以下是 Insert 函数的实现步骤:

void Insert(int x) {
    // 1. 为新节点动态分配内存
    struct Node* temp = (struct Node*)malloc(sizeof(struct Node));

    // 2. 设置新节点的数据域
    temp->data = x;

    // 3. 关键步骤:将新节点的 next 指针指向当前的头节点
    temp->next = head;

    // 4. 更新头指针,使其指向新节点
    head = temp;
}

逻辑解析

  • temp->next = head;:这行代码同时处理了链表为空和不为空的情况。如果链表为空,headNULL,新节点的 next 自然指向 NULL。如果链表不为空,新节点的 next 就指向了原来的第一个节点,从而将新节点链接到链表前端。
  • head = temp;:最后更新头指针,使其指向这个新创建的节点,完成头部插入。

实现遍历打印函数

为了验证插入操作是否正确,我们需要一个能遍历链表并打印所有节点值的函数。

以下是 Print 函数的实现:

void Print() {
    struct Node* temp = head; // 使用临时指针遍历,避免修改头指针
    printf("当前链表: ");
    while(temp != NULL) {
        printf("%d ", temp->data); // 打印当前节点数据
        temp = temp->next;         // 移动到下一个节点
    }
    printf("\n");
}

关键点:我们使用一个临时指针 temp 来遍历链表,而不是直接使用 head。这是因为 head 需要始终指向链表开头,如果用它来遍历,遍历结束后我们就“丢失”了链表的起点。


主函数与程序流程

了解了核心函数后,我们来看主程序如何组织。程序会提示用户输入一系列数字,并将它们依次插入链表头部,每次插入后打印当前链表状态。

int main() {
    head = NULL; // 确保链表初始为空
    int n, x, i;

    printf("要输入几个数字? ");
    scanf("%d", &n);

    for(i = 0; i < n; i++) {
        printf("请输入第 %d 个数字: ", i+1);
        scanf("%d", &x);
        Insert(x); // 调用插入函数
        Print();   // 调用打印函数
    }
    return 0;
}

运行示例
假设依次输入数字 2, 5, 8, 1, 10,程序输出将如下所示,清晰展示了每次在头部插入后链表的变化:

当前链表: 2
当前链表: 5 2
当前链表: 8 5 2
当前链表: 1 8 5 2
当前链表: 10 1 8 5 2


作用域探讨:局部变量与参数传递

在上面的示例中,头指针 head 被声明为全局变量,因此 InsertPrint 函数可以直接访问它。然而,更好的编程实践是将其作为局部变量,并通过函数参数进行传递。这涉及到指针的传递方式。

方法一:通过函数返回值更新头指针

head 声明在 main 函数内,Insert 函数接收当前头指针并返回新的头指针。

// 插入函数,返回新的头指针
struct Node* Insert(struct Node* head, int x) {
    struct Node* temp = (struct Node*)malloc(sizeof(struct Node));
    temp->data = x;
    temp->next = head;
    return temp; // 返回新节点的地址作为新的头指针
}

// 在主函数中调用
int main() {
    struct Node* head = NULL; // 局部变量
    head = Insert(head, 2);   // 接收返回值以更新head
    PrintList(head);
    return 0;
}

方法二:通过指针的指针(双重指针)传递

通过传递头指针的地址(即指向指针的指针),可以在函数内部直接修改 main 函数中的头指针变量。

// 插入函数,参数为指向头指针的指针
void Insert(struct Node** headRef, int x) {
    struct Node* temp = (struct Node*)malloc(sizeof(struct Node));
    temp->data = x;
    temp->next = *headRef; // 解引用获取真正的头指针
    *headRef = temp;       // 解引用并修改头指针的值
}

// 在主函数中调用
int main() {
    struct Node* head = NULL;
    Insert(&head, 2); // 传递头指针的地址
    PrintList(head);
    return 0;
}

核心概念struct Node** headRef 是一个指向指针的指针。使用 *headRef 可以访问或修改 main 函数中 head 指针本身的值。


总结

本节课中我们一起学习了如何在单链表的头部插入新节点。

我们首先定义了链表节点的结构,然后实现了核心的 Insert 函数,其关键逻辑在于将新节点的 next 指向原头节点,再更新头指针。接着,我们实现了 Print 函数来遍历验证链表。最后,我们探讨了更健壮的代码组织方式,即通过函数返回值或双重指针来管理局部头指针,这避免了全局变量的使用,使程序结构更清晰、模块化。

理解头部插入操作是掌握链表其他操作(如在特定位置插入、删除节点)的重要基础。

007:在链表中任意位置插入节点 🧩

在本节课中,我们将学习如何在一个链表的任意给定位置插入一个新节点。我们将从逻辑理解开始,然后逐步编写C++代码来实现这个功能,并深入探讨程序执行时内存的变化。

概述

在上一节中,我们编写了在链表开头插入节点的代码。本节中,我们将扩展这个功能,实现在链表任意位置插入节点。我们将处理创建节点、遍历链表到指定位置、以及重新连接指针等核心操作。

逻辑理解

假设我们有一个整数链表,包含三个节点,其内存地址分别为200、100和250。我们有一个名为 head 的指针变量,它存储链表中第一个节点的地址。

我们使用1为起始的索引来标记节点的位置:第一个节点是位置1,第二个是位置2,第三个是位置3。

我们的目标是编写一个 Insert 函数,它接收两个参数:要插入的数据和希望插入的位置。

需要考虑的场景:

  • 链表可能为空(headnull)。
  • 传入的位置 n 可能无效(例如,在只有3个节点的链表中,位置5是无效的)。为了简化实现,我们暂时假设传入的位置总是有效的。

插入逻辑(以在位置3插入数据8为例):

  1. 创建一个新节点,其数据域为 8
  2. 要插入到第 n 个位置,我们需要先找到第 n-1 个节点(本例中 n=3,所以是第2个节点)。
  3. 将新节点的 next 指针指向第 n-1 个节点当前指向的节点(即原位置 n 的节点)。
    • 新节点.next = 第(n-1)个节点.next
  4. 将第 n-1 个节点的 next 指针指向新节点。
    • 第(n-1)个节点.next = 新节点

对于在链表开头插入(位置1)这种特殊情况,我们需要单独处理。

代码实现

现在,让我们将上述逻辑转化为C++程序。

节点定义与全局变量

首先,我们定义链表节点的结构,并声明一个全局的 head 指针。

struct Node {
    int data;
    Node* next;
};

Node* head; // 全局变量,指向链表头节点

head 声明为全局变量是为了简化初学者的理解,使其在 main 函数和 Insert 函数中都能被访问。在 main 函数开始时,我们将其初始化为 null,表示链表为空。

int main() {
    head = nullptr; // 初始化为空链表
    // ... 其他代码
}

内存布局简介

在深入函数实现前,了解程序运行时的内存布局很有帮助。程序内存通常分为四个部分:

  1. 代码区:存储执行的指令。
  2. 全局数据区:存储全局变量(如我们的 head),其生命周期与程序相同。
  3. :用于函数调用,存储局部变量、参数和返回地址。其大小在编译时固定。
  4. :自由存储区,大小不固定,可以在运行时通过 newmalloc 动态申请内存(我们的节点就创建在这里)。

我们的全局变量 head 位于全局数据区。函数调用和局部变量(如 temp1, temp2)位于栈区。通过 new 创建的节点则位于堆区。

Insert 函数实现

以下是 Insert 函数的完整实现,它处理了在位置1插入的特殊情况。

void Insert(int data, int n) {
    // 1. 创建新节点
    Node* temp1 = new Node();
    temp1->data = data;
    temp1->next = nullptr;

    // 2. 特殊情况:在链表头部插入(位置1)
    if (n == 1) {
        temp1->next = head; // 新节点指向原头节点
        head = temp1;       // 更新头指针指向新节点
        return;             // 插入完成,直接返回
    }

    // 3. 一般情况:找到第 n-1 个节点
    Node* temp2 = head;
    for (int i = 0; i < n-2; i++) { // 循环 n-2 次
        temp2 = temp2->next;        // temp2 最终指向第 n-1 个节点
    }

    // 4. 插入新节点
    temp1->next = temp2->next; // 步骤 A: 新节点指向原第 n 个节点
    temp2->next = temp1;       // 步骤 B: 第 n-1 个节点指向新节点
}

代码解释:

  • 创建节点:使用 new 在堆上分配内存,并初始化数据和指针。
  • 处理头部插入:如果 n==1,直接将新节点的 next 指向当前 head,然后更新 head 指向新节点。即使链表为空headnull),这段代码也能正常工作。
  • 遍历到第 n-1 个节点:我们使用指针 temp2head 开始遍历。循环执行 n-2 次后,temp2 将指向第 n-1 个节点。
  • 执行插入:严格按照逻辑理解中的两步进行指针重定向。

为了验证插入结果,我们还需要一个遍历打印链表的函数。

void Print() {
    Node* temp = head; // 使用临时指针遍历,避免移动head
    while (temp != nullptr) {
        cout << temp->data << " ";
        temp = temp->next;
    }
    cout << endl;
}

main 函数中,我们进行一系列插入操作并打印链表。

int main() {
    head = nullptr; // 空链表

    Insert(2, 1); // 链表:2
    Insert(3, 2); // 链表:2 3
    Insert(4, 1); // 链表:4 2 3
    Insert(5, 2); // 链表:4 5 2 3

    Print(); // 输出:4 5 2 3
    return 0;
}

执行过程与内存模拟

让我们跟踪第一次调用 Insert(2, 1) 时内存的状态:

  1. main 函数启动,栈上分配其栈帧。head(全局变量)被设为 null
  2. 调用 Insert(2, 1),栈上分配新的栈帧,包含参数 data=2, n=1 和局部变量 temp1
  3. new Node() 在堆上分配一块内存(假设地址为150),temp1 保存该地址。
  4. 设置 temp1->data = 2temp1->next = nullptr
  5. 由于 n==1,执行 temp1->next = head(即 null),然后 head = temp1。现在 head 指向地址150。
  6. Insert 函数返回,其栈帧被释放。堆上的节点(地址150)依然存在,并由 head 指向。

后续的 Insert 调用会重复类似过程,在堆上创建新节点,并通过调整 next 指针将它们链接起来,最终形成链表 4 -> 5 -> 2 -> 3

Print 函数使用一个临时指针 temp 从头开始遍历,依次访问每个节点的数据并打印,直到遇到 nullptr

关于 Head 指针作用的思考

head 指针至关重要,它是我们访问整个链表的唯一入口。在 PrintInsert 函数中,我们都使用了临时指针(如 temp, temp2)来进行遍历,以避免修改 head 本身,从而丢失链表的起点。

如果 head 不是全局变量,而是在 main 函数中声明的局部变量,那么为了在 Insert 函数中修改它,我们需要将其以引用传递的方式传入,或者让 Insert 函数返回新的头指针。这是我们未来可以探讨的进阶话题。

总结

本节课中,我们一起学习了如何在单链表的任意位置插入节点。我们首先从逻辑上理解了插入操作需要找到前驱节点并调整指针指向。然后,我们逐步实现了 Insert 函数,特别处理了在链表头插入的特殊情况。通过跟踪代码执行和内存变化,我们加深了对链表动态特性以及栈、堆内存管理的理解。最后,我们编写了 Print 函数来验证插入操作的结果,并强调了 head 指针作为链表入口的重要性。在下一节课中,我们将学习如何从链表中删除指定位置的节点。

008:删除链表指定位置的节点 🗑️

在本节课中,我们将学习如何从链表中删除指定位置的节点。我们将理解删除操作的两个关键步骤:修复链表链接和释放节点内存,并通过C语言代码示例来演示整个过程。

概述

上一节我们介绍了如何在链表中插入节点。本节中,我们来看看如何从链表中删除一个位于任意给定位置的节点。

链表删除操作原理

我们以一个包含四个节点的整数链表为例。节点的地址分别为100、200、150和250。我们使用基于1的索引来标记位置:第一个节点、第二个节点、第三个节点和第四个节点。

删除链表节点需要完成两件事:

  1. 修复链接:使目标节点不再属于链表。
  2. 释放内存:释放被删除节点占用的内存空间。

删除中间节点

假设我们要删除第三个位置的节点(地址150)。我们需要找到其前驱节点(第二个节点,地址200),并将其 next 指针指向目标节点的后继节点(第四个节点,地址250)。这样,节点150就从链表链式结构中脱离。

删除头节点

如果要删除的是第一个节点(头节点),则是一个特殊情况。我们需要将 head 指针直接指向第二个节点,然后释放原头节点的内存。

我们的实现将涵盖所有这些情况。

代码实现

以下是用C语言实现的删除函数。我们假设链表节点结构体 Node 已经定义,包含 int datastruct Node* next 两个字段。head 是一个指向链表头节点的全局指针。

void Delete(int n) {
    struct Node* temp1 = head;

    // 特殊情况:删除头节点
    if (n == 1) {
        head = temp1->next; // head 现在指向第二个节点
        free(temp1);        // 释放原头节点内存
        return;
    }

    // 一般情况:找到第 n-1 个节点
    for (int i = 0; i < n-2; i++) {
        temp1 = temp1->next; // 最终 temp1 指向第 n-1 个节点
    }
    // temp1 现在指向第 n-1 个节点

    struct Node* temp2 = temp1->next; // temp2 指向第 n 个节点(待删除节点)
    temp1->next = temp2->next;        // 将第 n-1 个节点的 next 指向第 n+1 个节点

    free(temp2); // 释放第 n 个节点的内存
}

代码逻辑分步解析

以下是代码执行过程的逻辑视图,以帮助你更清晰地理解。

场景一:删除头节点 (n = 1)

  1. temp1 被赋值为 head,指向第一个节点(地址100)。
  2. 因为 n == 1,执行 head = temp1->nexthead 现在指向第二个节点(地址200),链表链接被修复。
  3. 执行 free(temp1),释放第一个节点(地址100)的内存。
  4. 函数返回,局部变量 temp1 被销毁。全局变量 head 指向新的链表头。

场景二:删除中间节点 (例如 n = 3)

  1. temp1 初始指向头节点(地址100)。
  2. 因为 n != 1,进入 for 循环。循环执行 n-2(即1)次后,temp1 指向第二个节点(地址200),即第 n-1 个节点。
  3. temp2 = temp1->next,使得 temp2 指向第三个节点(地址150),即待删除的第 n 个节点。
  4. temp1->next = temp2->next,将第二个节点的 next 指针从150改为250(指向第四个节点)。至此,第三个节点已从链表链式结构中脱离。
  5. free(temp2) 释放第三个节点(地址150)的内存。
  6. 函数返回,局部变量 temp1temp2 被销毁。

主函数示例

以下是一个简单的 main 函数示例,展示了如何构建链表并调用删除函数。

int main() {
    head = NULL; // 初始为空链表
    Insert(2);   // 链表:2
    Insert(4);   // 链表:2 -> 4
    Insert(6);   // 链表:2 -> 4 -> 6
    Insert(5);   // 链表:2 -> 4 -> 6 -> 5

    Print();     // 输出:List is: 2 4 6 5

    int position;
    printf("Enter a position to delete: ");
    scanf("%d", &position);

    Delete(position);
    Print();     // 输出删除后的链表

    return 0;
}

运行此程序,输入不同的位置(1, 2, 3, 4),可以验证删除功能的正确性。

总结

本节课中我们一起学习了如何从链表中删除指定位置的节点。关键点在于:

  1. 处理两种主要情况:删除头节点和删除中间/尾节点。
  2. 删除操作包含两个不可少的步骤:修复前后节点的链接关系,以及使用 free()(C语言)或 delete(C++)释放被删除节点的动态内存
  3. 通过临时指针变量遍历和操作链表是常见的实现方式。

你可以尝试扩展此功能,例如实现“删除具有特定值的节点”。在接下来的课程中,我们将探讨更多关于链表的习题和应用。

009:反转链表 - 迭代法 🔄

在本节课中,我们将学习如何使用迭代方法反转一个链表。这是面试中最受欢迎的问题之一,也是一个非常有趣的问题。

概述

在之前的课程中,我们已经实现了链表的一些基本操作,例如插入节点和删除节点。本节课,我们将编写代码来反转一个链表。我们首先需要明确问题定义。

问题定义

假设我们被给定一个整数链表,如下图所示。这是我们的输入链表。

这个链表有四个节点,地址分别为100、200、150和250。我们通常将地址写在逻辑视图中,因为这有助于我们可视化内存中的结构。第一个节点(也称为头节点)由一个名为 head 的变量指向。这个变量存储的是头节点的地址,它本身并不是头节点。除了头节点的地址,我们没有链表的其他标识。

给定这样一个链表,如果我们要反转它,这里的“反转”并不意味着移动数据(例如,我们不能将地址100处的数据5移动到地址250处)。我们实际上需要调整节点之间的链接。因此,我们的输出应该如下图所示。

头指针 head 应该指向地址250处的节点,链接顺序变为250 -> 150 -> 200 -> 100,而地址100处的节点的地址部分应为0或NULL。在每个节点中,红色的第一个字段是数据部分,第二个字段是地址部分。这就是反转链表后的结果。

解决此问题有两种方法:一种是迭代法,我们将使用循环遍历链表,并在每一步反转一个链接;另一种是递归法。本节课,我们将重点理解迭代解决方案。

迭代解决方案思路

回到我们的输入链表。迭代解决方案相对更容易理解。我们可以遍历整个链表,当访问每个节点时,调整该节点的链接部分,使其指向前一个节点,而不是下一个节点。

我们将从第一个节点开始。在每一步,我们都希望反转链接,使当前节点指向前一个节点。对于第一个节点,没有前一个节点,因此我们可以认为前一个节点是NULL。我们首先切断指向下一个节点的链接,然后建立指向前一个节点(NULL)的链接。接着,我们移动到链表中的下一个节点。当然,这里会有一个问题:如果我们已经切断了指向下一个节点的链接,我们如何移动到下一个节点?我们将在实现细节中回到这个问题。

假设我们能够遍历链表并访问每个节点。在每一步,我们将所有相关信息存储在一些临时变量中。

现在,在第二个节点(地址200),我们再次反转链接,将其地址部分设置为100。然后我们移动到地址150处的下一个节点,将其地址部分设置为200。接着,我们移动到地址250处的最后一个节点,将其地址部分设置为150。最后,当我们到达最后一个节点后,我们将调整 head 变量,使其指向地址250处的节点。这样,链表就被反转了。

代码实现

现在,让我们在真实的C语言程序中实现这个逻辑。我将重新绘制原始输入链表。

在我的C代码中,我将像下面这样定义一个节点结构体。这与我们之前所有课程中定义节点的方式一致。

struct Node {
    int data;
    struct Node* next;
};

结构体有两个字段:一个用于存储数据(int 类型),另一个用于存储下一个节点的地址(struct Node* 类型),我们将其命名为 next

假设 head 是一个全局变量,它是一个指向 Node 的指针,因此可以被所有函数访问,无需在函数间传递。

现在,我想编写一个 reverse 函数,用于反转由 head 指针指向的链表。

正如我们所说,我们将遍历整个链表,并在每一步修改节点的链接字段,使其指向前一个节点。

遍历与反转的实现细节

我们如何在C代码中遍历链表?通常我们会这样做:

struct Node* temp = head;
while (temp != NULL) {
    // 处理当前节点 temp
    temp = temp->next; // 移动到下一个节点
}

但在我们的问题中,我们不仅需要遍历,还需要在遍历时反转链接。我们需要将特定节点的地址字段设置为前一个节点的地址,而不是下一个节点的地址。在链表中,我们总是知道下一个节点的地址,但通常不知道前一个节点的地址。因此,在遍历时,我们需要用另一个变量来跟踪前一个节点。

我将这样做:声明一个名为 previous 的变量,并初始化为 NULL,因为对于第一个节点(头节点),前一个节点就是 NULL。在我的循环中,我需要更新两个变量:存储当前节点的 current 变量和存储前一个节点地址的 previous 变量。

在循环的每一步,如果 current 是我们的当前节点,我们可以执行 current->next = previous;,将当前节点的链接部分设置为前一个节点的地址。

但在我们设置当前节点的链接指向前一个节点之前,存在一个问题:一旦我们调整了当前节点的链接,我们就失去了下一个节点的地址,无法将 current 移动到下一个节点。

解决方案是:在迭代的每一步,在我们设置当前节点的链接字段指向前一个节点之前,我们应该将下一个节点的地址存储在另一个临时变量中。

因此,完整的逻辑如下:

  1. 初始化 currentheadpreviousNULL
  2. current 不为 NULL 时,执行循环:
    a. 首先,将下一个节点的地址保存到临时变量 next 中:next = current->next;
    b. 然后,反转当前节点的链接:current->next = previous;
    c. 接着,更新 previouscurrent 以向前移动:previous = current; 然后 current = next;
  3. 循环结束后,previous 将指向原链表的最后一个节点,即新链表的头节点。因此,我们需要更新 head 指针:head = previous;

以下是 reverse 函数的代码框架:

void reverse() {
    struct Node *current, *prev, *next;
    current = head;
    prev = NULL;
    while (current != NULL) {
        next = current->next; // 保存下一个节点
        current->next = prev; // 反转当前节点的链接
        prev = current;       // 移动 prev 到当前节点
        current = next;       // 移动 current 到下一个节点
    }
    head = prev; // 更新头指针指向新的头节点
}

请注意,变量 nextreverse 函数中的局部指针变量。当我们写 current->next 时,我们指的是节点结构体中的链接字段;而当我们写 next 时,我们指的是这个局部指针变量。它们是不同的。

边界情况测试

我们需要确保我们的实现在所有测试用例下都能工作,因此必须验证特殊或边界情况。对于反转链表,边界情况包括:

  • 空链表:此时 headNULL
  • 只有一个节点的链表

你可以验证,上述实现对于这两种场景都能正确工作。

完整代码示例与运行

现在,让我们运行包含所有函数(插入、打印)的完整代码。在我的代码中,reverse 函数接受头节点的地址作为参数,并在反转链表后返回修改后的头节点地址。main 函数中声明了 head 作为局部变量。

我使用了几个插入函数调用(例如在链表末尾插入),初始链表为 2 -> 4 -> 6 -> 8。然后调用打印函数,接着调用 reverse 函数,最后再次打印。

假设 insertprint 函数都已正确编写,运行代码后,反转前的输出是 2 4 6 8,反转后的输出是 8 6 4 2

对于只有一个元素(例如 2)的链表,该实现同样有效。

总结

本节课中,我们一起学习了如何使用迭代方法反转一个链表。我们首先定义了问题,然后详细阐述了迭代法的核心思路:在遍历链表的过程中,使用三个指针(currentprevnext)来逐步调整每个节点的指向。我们给出了具体的C语言实现代码,并讨论了边界情况的处理。最后,我们通过示例验证了代码的正确性。

在下一节课中,我们将学习如何使用递归方法反转链表。

010:使用递归正向与反向打印链表元素 🧠

在本节课中,我们将学习如何使用递归来遍历链表,并分别以正向和反向顺序打印链表中的所有元素。我们将编写两个函数:一个用于正向打印,另一个用于反向打印,同时深入理解递归在链表操作中的应用。


概述

到目前为止,在我们的链表系列中,我们已经实现了插入、删除和遍历等基本操作。本节课,我们将编写代码,使用递归来遍历并打印链表中的元素。学习本课的前提是理解递归这一编程概念。链表的递归遍历实际上能帮助我们解决一些有趣的问题,但本节课我们将保持简单,仅使用递归打印链表中的所有元素,并编写一个简单的变体来反向打印元素。请注意,我们不会实际反转链表,只是以反向顺序打印元素。


链表节点结构

我们以一个整数链表为例。链表有四个节点,每个节点是一个矩形,包含两个字段:一个用于存储数据,另一个用于存储下一个节点的地址。假设四个节点的地址分别为100、200、150和250。当然,我们还需要一个变量来存储头节点的地址,在C或C++程序中,我们通常将这个变量命名为 head

在代码中,一个节点可以这样定义:

struct Node {
    int data;
    struct Node* next;
};

目标函数

本节课的目标是编写两个函数:

  1. 一个名为 Print 的函数,它接收一个节点的地址作为参数(我们将传递头节点的地址给它),并使用递归打印链表中的元素。
  2. 一个名为 ReversePrint 的函数,同样接收一个节点的地址,但使用递归以反向顺序打印链表中的元素。

对于示例链表,Print 函数的输出应为 2 4 6 5,而 ReversePrint 函数的输出应为 5 6 4 2


实现正向打印函数

首先,我们来实现 Print 函数。在C代码中,我们这样声明该函数:它接收一个指向节点的指针作为参数。最初,我们将传递头节点的地址。我们可以将这个参数命名为 p

递归是函数调用自身的一种技术。在我们的代码中,我们可以先打印当前节点的值,然后递归调用 Print 函数并传递下一个节点的地址。递归中一个重要的部分是退出条件,我们不能无限地进行递归调用。在这个例子中,当我们通过递归从第一个节点走到最后一个节点之后,p 最终会变为 NULL。此时,我们应该停止递归并退出。

以下是 Print 函数的一个可能实现:

void Print(struct Node* p) {
    if(p == NULL) {
        printf("\n");
        return; // 退出条件:到达链表末尾
    }
    printf("%d ", p->data); // 打印当前节点的数据
    Print(p->next); // 递归调用,处理下一个节点
}

main 函数中,我们声明一个头指针 head 并初始化为 NULL,表示链表为空。然后,我们通过调用插入函数(例如在链表末尾插入节点的函数)来创建链表。插入函数可能需要返回修改后的头指针地址,以便在 main 函数中更新。创建链表后,我们调用 Print(head) 来打印所有元素。

执行上述代码,控制台将输出:2 4 6 5


实现反向打印函数

接下来,我们来实现反向打印。神奇的是,我们只需对 Print 函数做一个小小的改动:交换打印语句和递归调用的顺序

我们将函数重命名为 ReversePrint,并修改其逻辑:先进行递归调用,等递归调用返回后,再打印当前节点的值。这样,打印操作会在递归“返回”的过程中执行,从而实现了反向输出。

修改后的 ReversePrint 函数如下:

void ReversePrint(struct Node* p) {
    if(p == NULL) {
        return; // 退出条件
    }
    ReversePrint(p->next); // 先递归到链表末尾
    printf("%d ", p->data); // 返回时再打印数据
}

main 函数中调用 ReversePrint(head),控制台将输出:5 6 4 2


递归执行过程分析

为了更好地理解,让我们逻辑上分析一下这两个递归函数的执行过程。

对于 Print 函数(正向打印):

  1. main 调用 Print(100)
  2. Print(100) 打印数据 2,然后调用 Print(200)
  3. Print(200) 打印数据 4,然后调用 Print(150)
  4. Print(150) 打印数据 6,然后调用 Print(250)
  5. Print(250) 打印数据 5,然后调用 Print(NULL)
  6. Print(NULL) 遇到退出条件,打印换行并返回。
  7. 然后各层递归函数依次返回,整个过程结束。输出顺序是调用递归之前打印,因此是正向的。

对于 ReversePrint 函数(反向打印):

  1. main 调用 ReversePrint(100)
  2. ReversePrint(100) 首先调用 ReversePrint(200),自身暂停。
  3. 此过程持续,直到 ReversePrint(NULL),它直接返回。
  4. 然后控制返回到 ReversePrint(250),它打印数据 5,然后返回。
  5. 控制返回到 ReversePrint(150),它打印数据 6,然后返回。
  6. 依次类推,最后 ReversePrint(100) 打印数据 2
  7. 输出顺序是递归调用返回之后打印,因此是反向的。

这种结构被称为“递归树”。


内存中的执行情况

在程序运行时,函数调用和局部变量存储在内存的区,而通过 mallocnew 动态分配的节点内存则位于区。

当进行递归调用时,每个函数调用都会在栈上获得自己的“栈帧”,用于存储其参数和局部变量。对于链表递归打印:

  • 初始时,main 函数的栈帧在栈顶。
  • main 调用 Print(head) 时,Print 函数的栈帧被压入栈。
  • 每次递归调用都会将一个新的 Print 栈帧压入栈。
  • 当到达退出条件(p == NULL)时,递归停止增长。
  • 然后,栈顶的函数调用开始依次完成并弹出栈,控制权逐层返回。

对于反向打印,过程类似,只是打印操作发生在栈帧弹出(即函数返回)的过程中。

需要指出的是,对于正向遍历,迭代方法(使用循环)通常比递归更高效,因为迭代只使用一个临时变量,而递归会隐式使用栈内存来存储多次函数调用的信息。然而,对于反向打印操作,由于我们需要一种机制来“记住”节点顺序,使用递归是完全可以接受的。


总结

本节课中,我们一起学习了如何使用递归来遍历链表。

  • 我们实现了 Print 函数,通过先打印后递归的方式正向输出链表元素。
  • 我们实现了 ReversePrint 函数,通过先递归后打印的方式反向输出链表元素。
  • 我们分析了递归的执行逻辑和内存中的栈帧变化。
  • 我们了解到,对于简单遍历,迭代可能更高效;但对于需要反向顺序访问的问题,递归提供了一种简洁的解决方案。

递归是处理链表(以及树、图等递归结构)的强大工具。在接下来的课程中,我们将运用递归解决更多有趣的链表问题。

011:使用递归反转链表 🔄

在本节课中,我们将学习如何使用递归方法反转一个单向链表。我们将从理解递归遍历链表开始,逐步构建反转链表的逻辑,并最终实现完整的递归反转函数。


概述

在上节课中,我们学习了如何使用递归遍历链表,并编写了代码以正向和反向顺序打印链表元素。我们并未实际反转链表,只是以逆序打印了元素。本节课,我们将真正地使用递归来反转链表。这是一个著名的编程面试问题。

假设我们有一个输入链表,包含四个整数节点。每个节点由两个字段组成:一个存储数据,另一个存储下一个节点的地址。我们还有一个名为 head 的变量,用于存储第一个节点的地址。

输入链表结构如下:

head -> [2| ] -> [6| ] -> [5| ] -> [4| ] -> NULL

我们的目标是通过调整节点间的链接(而非移动数据),将其反转为:

head -> [4| ] -> [5| ] -> [6| ] -> [2| ] -> NULL

我们之前已经学习过使用迭代方法反转链表。现在,让我们看看如何使用递归解决这个问题。


递归遍历的回顾

在深入反转逻辑之前,让我们回顾一下上节课中用于反向打印链表的递归函数。这个函数展示了递归如何让我们先正向遍历链表,再反向“回溯”处理节点。

以下是反向打印函数的伪代码:

void ReversePrint(Node* p) {
    if (p == NULL) return; // 退出条件
    ReversePrint(p->next); // 递归调用
    printf("%d ", p->data); // 打印当前节点数据
}

当我们从 main 函数调用 ReversePrint(head) 时,递归会一直深入到最后一个节点(p->nextNULL),然后开始返回。在返回的过程中,每个节点的数据被打印出来,从而实现了逆序打印。

这个模式——先深入到底层,再在返回过程中执行操作——正是我们反转链表所需的关键。


递归反转的逻辑

现在,让我们设计递归反转函数。为了简化,我们暂时假设 head 是一个全局变量。

我们的递归函数 Reverse 将接收一个节点指针 p 作为参数。基本思路如下:

  1. 递归深入:函数不断递归调用自身,传入 p->next,直到到达最后一个节点。
  2. 处理最后一个节点:当到达最后一个节点时(p->next == NULL),我们将全局的 head 指针指向这个节点,使其成为新链表的头节点。
  3. 回溯并反转链接:在递归调用返回的过程中,我们将当前节点的下一个节点的 next 指针指向当前节点,从而反转链接方向,并将当前节点的 next 指针设为 NULL(在后续步骤中会被正确覆盖)。

以下是该逻辑的步骤分解:

  1. 初始调用:从 main 函数调用 Reverse(head)
  2. 递归深入:函数检查当前节点 p 是否为最后一个节点。如果不是,则递归调用 Reverse(p->next)
  3. 到达末尾:当 p 是最后一个节点时(例如存储数据 4 的节点),设置 head = p。此时,新链表的头节点已经确定。
  4. 回溯过程:递归开始返回。假设我们回到了存储数据 5 的节点(此时 p 指向它)。
    • 我们需要让原顺序中 p 后面的节点(即节点 4,我们称其为 qq = p->next)的 next 指针指向 pq->next = p
    • 然后,将 pnext 指针暂时设为 NULLp->next = NULL。这一步在链表中间的反转过程中是必要的,它会被上一层的递归调用正确修改。
  5. 重复回溯:上述过程在每一层递归返回时重复,直到回到最初的 head 节点,完成整个链表的反转。

代码实现

根据上述逻辑,我们可以写出以下递归反转函数(假设 head 为全局变量):

void Reverse(Node* p) {
    if (p->next == NULL) { // 退出条件:到达最后一个节点
        head = p; // 将头指针指向最后一个节点
        return;
    }
    Reverse(p->next); // 递归调用,深入下一个节点
    // 以下代码在递归返回时执行
    Node* q = p->next; // q 是当前节点 p 的下一个节点
    q->next = p; // 反转链接:让 q 指向 p
    p->next = NULL; // 将 p 的 next 暂时置空(会被上一层调用修改)
}

代码解释

  • if (p->next == NULL): 这是递归的基准情况。当 p 是最后一个节点时,我们将全局 head 指向它,并返回。
  • Reverse(p->next): 递归调用,处理链表剩余部分。
  • Node* q = p->next;: 在递归调用返回后,q 指向原顺序中 p 后面的节点(该节点及其之后的链表部分已经被反转)。
  • q->next = p;: 这是关键的反转操作。它将 q(原顺序中的后继节点)的 next 指针指向当前节点 p
  • p->next = NULL;: 将当前节点 pnext 指针断开。注意,对于非头节点的其他节点,这个 NULL 会在上一层递归中被正确的 q->next = p 覆盖。


执行过程模拟

让我们用之前的链表 [2, 6, 5, 4] 来模拟一下 Reverse(head) 的调用栈:

  1. Reverse(节点2) 被调用。p->next (节点6) 不为 NULL,所以调用 Reverse(节点6)
  2. Reverse(节点6) 被调用。p->next (节点5) 不为 NULL,所以调用 Reverse(节点5)
  3. Reverse(节点5) 被调用。p->next (节点4) 不为 NULL,所以调用 Reverse(节点4)
  4. Reverse(节点4) 被调用。p->nextNULL,触发基准情况:head = 节点4,然后返回。
  5. 控制权回到 Reverse(节点5)。此时 p 是节点5,q = p->next 是节点4。
    • 执行 q->next = p:节点4的 next 指向节点5。
    • 执行 p->next = NULL:节点5的 next 置为 NULL
    • Reverse(节点5) 返回。
  6. 控制权回到 Reverse(节点6)。此时 p 是节点6,q = p->next 是节点5。
    • 执行 q->next = p:节点5的 next 指向节点6。
    • 执行 p->next = NULL:节点6的 next 置为 NULL
    • Reverse(节点6) 返回。
  7. 控制权回到 Reverse(节点2)。此时 p 是节点2,q = p->next 是节点6。
    • 执行 q->next = p:节点6的 next 指向节点2。
    • 执行 p->next = NULL:节点2的 next 置为 NULL
    • Reverse(节点2) 返回。

最终,head 指向节点4,链表成功反转为 4 -> 5 -> 6 -> 2 -> NULL


注意事项与扩展

  1. 代码简化:在回溯部分的代码中,两行语句 q->next = p;p->next = NULL; 有时可以被合并为一行更紧凑但可读性稍差的代码:p->next->next = p;。但为了清晰,我们更推荐分开写。
  2. 非全局头指针:在我们的实现中,假设了 head 是全局变量。如果 head 是局部变量,我们的 Reverse 函数需要返回新的头节点地址。修改后的函数签名可能类似于 Node* Reverse(Node* p),并在基准情况返回 p,在其他情况返回递归调用的结果。这留作一个练习。
  3. 递归与内存:递归调用会在内存的栈区保存每一层函数调用的状态。对于长链表,这可能导致栈溢出。迭代方法通常在空间效率上更优。

总结

本节课中,我们一起学习了如何使用递归反转一个单向链表。

我们首先回顾了递归遍历链表的模式,然后基于此模式设计了反转逻辑:递归深入到链表末尾,将最后一个节点设为新头,然后在回溯过程中逐对反转节点间的链接。我们实现了相应的代码,并模拟了其执行过程。

关键点在于理解递归的“深入”和“回溯”两个阶段,并在回溯阶段执行反转链接的核心操作 q->next = p。虽然递归解法非常优雅且是常见的面试题,但在实际应用中需要注意其对栈空间的使用。

012:双向链表简介 📚

在本节课中,我们将要学习一种新的链表结构——双向链表。我们将了解它与之前学过的单向链表的区别,它的结构定义,以及它的优缺点。

概述

到目前为止,在本系列课程中,我们已经详细讨论了链表。我们学习了如何创建链表以及如何对链表执行各种操作。链表是由我们称为“节点”的实体组成的集合。

在之前所有的实现中,我们创建的链表,每个节点包含两个字段:一个用于存储数据,另一个用于存储下一个节点的地址。

从单向链表到双向链表

上一节我们介绍了单向链表的基本结构,本节中我们来看看双向链表。

在单向链表中,每个节点只有一个指向下一个节点的链接。在C/C++中,节点的定义通常如下:

struct Node {
    int data;
    struct Node* next;
};

而双向链表的核心理念非常简单:在双向链表中,每个节点将拥有两个链接,一个指向下一个节点,另一个指向前一个节点。

在C/C++中,双向链表的节点定义如下:

struct Node {
    int data;
    struct Node* prev;
    struct Node* next;
};

在逻辑表示中,一个双向链表的节点可以这样绘制:一个单元格存储数据,一个单元格存储前一个节点的地址,一个单元格存储下一个节点的地址。

双向链表示例

假设我们创建一个包含三个整数的双向链表,节点地址分别为400、600和800。

以下是各节点链接的建立方式:

  • 第一个节点的 next 字段指向地址600(第二个节点),prev 字段为0或NULL(因为没有前一个节点)。
  • 第二个节点的 next 字段指向地址800(第三个节点),prev 字段指向地址400(第一个节点)。
  • 第三个节点的 next 字段为0或NULL,prev 字段指向地址600(第二个节点)。

当然,我们仍然需要一个变量(例如 head)来存储头节点的地址。

双向链表的优缺点

了解结构后,一个显而易见的问题是:为什么我们需要双向链表?它有什么优势和劣势?

以下是双向链表的主要优势:

  • 双向遍历:如果我们有一个指向任何节点的指针,我们可以进行前向和后向查找。通过一个指针,我们可以访问当前节点、下一个节点以及前一个节点。在单向链表中,仅凭一个指针无法查看前一个节点,需要额外的指针来跟踪。
  • 简化操作:在许多场景下,能够查看前一个节点的能力使操作更简单。例如,删除节点时,在单向链表中需要两个指针(一个指向待删除节点,一个指向前驱节点),而在双向链表中,仅需一个指向待删除节点的指针即可完成。

以下是双向链表的主要劣势:

  • 额外内存开销:每个节点需要额外的内存来存储指向前一个节点的指针。例如,在一个整数链表中,假设整数占4字节,指针占4字节。在单向链表中,每个节点占8字节(4数据+4指针)。在双向链表中,每个节点占12字节(4数据+8指针)。
  • 操作更复杂:在插入或删除节点时,我们需要比单向链表重置更多的链接(需要同时维护 prevnext 指针),因此更容易出错。

总结

本节课中我们一起学习了双向链表。我们了解了它的节点结构定义,通过示例看到了链接是如何建立的,并分析了它相对于单向链表的主要优点(双向遍历、简化某些操作)和缺点(内存开销增加、操作更复杂)。在下一课中,我们将在C程序中实现双向链表,并编写遍历、插入和删除等基本操作。

数据结构:13:双向链表在C/C++中的实现 🧠

在本节课中,我们将学习如何在C语言中实现一个双向链表。我们将编写一些基本操作,例如在链表头部和尾部插入节点、正向遍历以及反向遍历。通过本教程,你将理解双向链表节点的结构、内存管理(栈与堆的区别)以及如何通过指针操作来维护节点间的双向链接。


节点结构定义

在上一节中,我们介绍了双向链表的概念。本节中,我们来看看如何在代码中定义其节点结构。

每个节点包含三个字段:一个用于存储数据,一个用于存储下一个节点的地址,另一个用于存储前一个节点的地址。对于一个存储整数的链表,在C或C++程序中,节点可以这样定义:

struct Node {
    int data;
    struct Node* next;
    struct Node* prev;
};

此外,我们还需要一个全局指针变量 head 来指向链表的第一个节点。

struct Node* head = NULL; // 全局头指针,初始为空

全局变量 head 在整个程序运行期间都存在于内存中,可以被所有函数访问。这与局部变量不同,局部变量仅在函数调用期间存在。


创建新节点

在实现插入操作之前,我们需要一个函数来创建新节点。这个函数将接收一个整数值作为参数,在堆内存中分配一个新节点,并初始化其字段。

以下是创建新节点的函数:

struct Node* GetNewNode(int x) {
    struct Node* newNode = (struct Node*)malloc(sizeof(struct Node));
    newNode->data = x;
    newNode->prev = NULL;
    newNode->next = NULL;
    return newNode;
}

关键点

  • 我们使用 malloc 函数在上分配内存。堆上的内存在程序显式释放前会一直存在。
  • 如果我们在函数内部像 struct Node newNode; 这样声明节点,它将被创建在上。当函数调用结束时,栈内存会被自动回收,这不符合我们的需求。
  • 函数返回指向新创建节点的指针。


在链表头部插入节点

现在,我们来编写在链表头部插入节点的函数。这个函数需要考虑链表是否为空。

void InsertAtHead(int x) {
    struct Node* newNode = GetNewNode(x);
    if(head == NULL) {
        head = newNode;
        return;
    }
    head->prev = newNode;
    newNode->next = head;
    head = newNode;
}

操作步骤

  1. 调用 GetNewNode(x) 创建一个新节点。
  2. 检查链表是否为空(即 head == NULL)。
    • 如果为空,直接将 head 指向新节点,然后返回。
  3. 如果链表不为空:
    • 将当前头节点的 prev 指针指向新节点。
    • 将新节点的 next 指针指向当前的头节点。
    • 最后,更新 head 指针,使其指向新节点,新节点成为新的头节点。

这个过程建立了从新节点到旧头节点、以及从旧头节点回溯到新节点的双向链接。


遍历链表

为了验证我们的操作,我们需要编写遍历链表的函数。我们将实现两个函数:一个正向打印,一个反向打印。

正向打印

正向打印函数从 head 开始,沿着 next 指针遍历直到链表末尾。

void Print() {
    struct Node* temp = head;
    printf("Forward: ");
    while(temp != NULL) {
        printf("%d ", temp->data);
        temp = temp->next;
    }
    printf("\n");
}

反向打印

反向打印函数首先需要遍历到链表的最后一个节点,然后沿着 prev 指针反向遍历回第一个节点。这可以验证我们建立的“前驱”指针是否正确。

void ReversePrint() {
    struct Node* temp = head;
    if(temp == NULL) return; // 空链表,直接返回

    // 先走到链表末尾
    while(temp->next != NULL) {
        temp = temp->next;
    }

    // 现在temp指向尾节点,开始反向遍历
    printf("Reverse: ");
    while(temp != NULL) {
        printf("%d ", temp->data);
        temp = temp->prev;
    }
    printf("\n");
}


测试代码

让我们在 main 函数中测试以上实现的功能。

int main() {
    head = NULL; // 开始时链表为空

    InsertAtHead(2);
    Print();
    ReversePrint();

    InsertAtHead(4);
    Print();
    ReversePrint();

    InsertAtHead(6);
    Print();
    ReversePrint();

    return 0;
}

运行这段代码,预期的输出应该是:

Forward: 2
Reverse: 2
Forward: 4 2
Reverse: 2 4
Forward: 6 4 2
Reverse: 2 4 6

练习:在链表尾部插入节点

我们已经实现了在头部插入节点。作为练习,请你尝试实现 InsertAtTail(int x) 函数,该函数在链表的末尾插入一个新节点。

提示

  1. 创建新节点。
  2. 如果链表为空,处理方式与 InsertAtHead 相同。
  3. 如果链表不为空,需要先遍历到当前的尾节点(nextNULL 的节点)。
  4. 将当前尾节点的 next 指向新节点。
  5. 将新节点的 prev 指向当前的尾节点。

总结

本节课中我们一起学习了双向链表的C语言实现。我们涵盖了以下核心内容:

  1. 节点结构:使用包含 datanextprev 指针的结构体来定义双向链表节点。
  2. 内存管理:理解了栈(自动管理)和堆(手动管理)的区别,并使用 malloc 在堆上动态创建节点。
  3. 基本操作
    • 实现了 GetNewNode 函数来创建并初始化新节点。
    • 实现了 InsertAtHead 函数,处理了链表为空和不为空两种情况。
    • 实现了 PrintReversePrint 函数来遍历和验证链表。
  4. 指针操作:通过操作节点的 nextprev 指针来建立和维护节点间的双向链接。

通过本教程,你应该对双向链表的基本原理和代码实现有了清晰的认识。在后续课程中,我们将探讨循环链表以及更多关于链表的趣味问题。

014:栈简介 📚

在本节课中,我们将介绍栈数据结构。

概述

数据结构是计算机中存储和组织数据的方式。在本系列课程中,我们已经讨论了一些数据结构,例如数组和链表。本节课,我们将讨论栈。我们将栈作为一种抽象数据类型来讨论。当我们把数据结构作为抽象数据类型讨论时,我们只讨论该数据结构可用的特性或操作,而不涉及实现细节。本质上,我们只将数据结构定义为一个数学或逻辑模型。我们将在后续课程中讨论栈的实现。本节课,我们只讨论栈的抽象数据类型,因此我们只关注栈的逻辑视图。

栈的现实世界类比

计算机科学中的栈数据结构与现实世界中组织对象的方式没有太大区别。

以下是现实世界中栈的一些例子:

  • 第一张图是一叠餐盘。
  • 第二张图是一个名为“汉诺塔”的数学谜题,其中有三根柱子,以及多个圆盘。游戏的目标是将一叠圆盘从一根柱子移动到另一根,但有一个限制:不能将较大的圆盘放在较小的圆盘之上。
  • 第三张图是一盒网球。

栈本质上是一个具有特定属性的集合:栈中的项目必须从同一端插入或移除,我们称之为栈顶。实际上,这不仅仅是一个属性,而是一个约束或限制。只有栈顶是可访问的,任何项目都必须从栈顶插入或移除。因此,栈也被称为后进先出集合。栈中最近添加的项目必须最先出去。

在第一个例子中,你总是从栈顶拿起餐盘。如果你要把盘子放回栈中,你总是把它放回栈顶。你可以争辩说,我可以从中间抽出一个盘子,而不实际移除顶部的盘子,所以“必须从顶部取出盘子”这个约束并不严格。为了讨论,这没问题。在其他两个例子中,当柱子上有圆盘或只能从一侧打开的盒子里有网球时,你无法从中间取出项目。任何插入或移除都必须从顶部进行。你无法从中间抽出项目。你可以取出一个项目,但为此你必须移除该项目之上的所有项目。

栈的抽象数据类型定义

现在,让我们正式将栈定义为抽象数据类型。

栈是一个列表或集合,其限制是插入和删除只能从一端进行,我们称之为栈顶。让我们明确栈抽象数据类型可用的接口或操作。栈有两个基本操作:

  • 插入操作称为 push 操作。push 操作可以将某个项目 X 插入或推入栈中。
  • 第二个操作称为 pop。pop 操作从栈中移除最近添加的项目。

push 和 pop 是基本操作。通常还有几个其他操作:

  • 一个操作称为 top,它简单地返回栈顶的元素。
  • 可能还有一个操作来检查栈是否为空。如果栈为空,此操作返回 true,否则返回 false。

因此,push 是在栈顶插入一个元素,pop 是从栈顶移除一个元素。我们一次只能 push 或 pop 一个元素。这里列出的所有操作都可以在常数时间内完成,换句话说,时间复杂度是 O(1)。请记住,最后被推入栈的元素最先被弹出。因此,栈被称为后进先出结构。后进先出简称为 LIFO。

栈的逻辑表示与操作示例

从逻辑上讲,栈被表示为一个三面的图形,像一个从一侧打开的容器。这表示一个空栈。让我们将这个栈命名为 S。

假设这个图表示一个整数栈。目前栈是空的。我将执行 push 和 pop 操作来插入和移除栈中的整数。我将首先在这里写下操作,然后展示在逻辑表示中会发生什么。

让我们首先执行一个 push 操作。我想将数字 2 推入栈中。栈目前是空的,所以我们不能弹出任何东西。push 操作后,栈将如下所示。栈中只有一个整数,所以它当然在顶部。

让我们再推入一个整数。这次我想推入数字 10。

现在,假设我们想执行一个 pop 操作。目前顶部的整数是 10。执行 pop 后,它将被从栈中移除。

让我们再做几个 push 操作。我刚刚将 7 和 51 推入了栈中。

在这个阶段,如果我调用 top 操作,它将返回数字 51。is empty 操作将返回 false。

在这个阶段,一个 pop 操作将移除 51。正如你所见,最后进入的元素最先出去。这就是为什么我们称栈为后进先出数据结构。我们可以一直弹出,直到栈变空。

再执行一次 pop,栈将变为空。

栈的应用场景

以上就是栈数据结构的基本内容。现在一个显而易见的问题是:栈在哪些实际场景中对我们有帮助?让我们列出栈的一些应用。

栈数据结构用于程序中函数调用的执行。我们在关于动态内存分配和链表的课程中已经多次讨论过这一点。我们也可以说栈用于递归,因为递归也是一系列函数调用,只是所有调用都是对同一个函数的调用。要了解更多关于此应用的信息,你可以查看本视频描述中指向 mycodeschool 关于动态内存分配课程的链接。

栈的另一个应用是,我们可以用它来实现编辑器中的撤销操作。你可以在任何文本编辑器或图像编辑器中执行撤销操作。现在我按下 Ctrl+Z,正如你所见,我写的一些文本正在被清除。你可以使用栈来实现这个功能。

栈还用于许多重要算法中。例如,编译器使用栈数据结构来验证源代码中的括号是否平衡。对于源代码中的每个开大括号或开括号,必须在适当位置有一个闭括号。如果源代码中的括号放置不当,即不平衡,编译器应抛出错误。这个检查可以使用栈来执行。

我们将在接下来的课程中详细讨论其中一些问题,作为入门介绍已经足够。

总结

在本节课中,我们一起学习了栈数据结构。我们了解了栈是一种后进先出的抽象数据类型,其核心操作是 push 和 pop。我们还通过现实世界的例子和逻辑图示理解了栈的工作原理,并简要探讨了栈在程序执行、撤销功能和语法检查等场景中的应用。

在下一节课中,我们将讨论栈的实现。本节课到此结束。

015:栈的数组实现 📚

在本节课中,我们将学习如何使用数组来实现栈这种数据结构。我们将从栈的抽象定义开始,逐步深入到具体的代码实现,并讨论其时间复杂度和潜在的限制。


概述

上一节我们介绍了栈数据结构。我们将栈作为一种抽象数据类型(ADT)进行了讨论。我们知道,当我们将数据结构定义为抽象数据类型时,我们只定义其可用的特性或操作,而不关心其具体实现。本节中,我们将探讨如何实现栈数据结构。

我们将首先讨论栈的可能实现方式,然后编写一些代码。

栈的实现基础

栈是一种具有特定约束的列表或集合:插入(压栈)和删除(弹栈)操作必须一次处理一个元素,并且只能从称为“栈顶”的一端进行。因此,只要在任何列表实现的基础上,增加“插入和删除只能从一端进行”这一约束,我们就可以得到一个栈。

创建列表有两种流行的方法,我们可以在之前的课程中多次讨论过。我们可以使用其中任何一种来创建栈:

  • 使用数组实现栈。
  • 使用链表实现栈。

这两种实现方式都非常直观。让我们首先讨论基于数组的实现。

数组实现详解 🧱

假设我想创建一个整数栈。我可以先创建一个整数数组。这里我创建了一个包含10个整数的数组,命名为 a。我将使用这个数组来存储栈。我的定义是:在任何时刻,数组从索引0开始,到标记为 top 的索引为止的部分,就是我的栈。

我们可以创建一个名为 top 的变量来存储栈顶的索引。对于一个空栈,top 被设置为 -1

目前在这个图示中,top 指向数组中一个虚构的索引 -1

压栈操作

插入或压栈操作将如下进行。我将编写一个名为 push 的函数,它接受一个整数 X 作为参数。

push 函数中,我们首先递增 top,然后将整数 X 填入 top 索引处。

这里我们假设 atop 可以被 push 函数访问,即使它们没有作为参数传递。在C语言中,我们可以将它们声明为全局变量;在面向对象的实现中,所有这些实体都可以是一个类的成员。我这里只写伪代码来解释实现逻辑。

对于这个示例数组,目前 top 被设置为 -1,所以我的栈是空的。

让我们插入一些元素到栈中。我需要调用 push 函数。假设我想将数字2压入栈中。在调用 push 时,首先 top 会递增,然后作为参数传递的整数会被写入 top 索引处,所以2会被写入索引0。

让我们再压入一个数字。假设我想压入数字10。这次,top 会再次递增,10现在会进入索引1。随着每次压栈,栈会向数组中更高的索引方向扩展。

弹栈操作

要从栈中弹出一个元素,我在这里为弹栈操作写一个函数。我需要做的就是将 top 减一。

假设我在这里调用 pop 函数。top 将简单地递减。在这个图中,标记为黄色的单元格是我的栈的一部分。在弹出之前,我们不需要重置这个值。如果一个单元格不再是栈的一部分,我们不关心那里有什么垃圾数据。下次我们压栈时,无论如何都会修改它。

假设在这次弹栈操作之后,我想执行一次压栈。我想将数字7压入栈中。所以 top 会再次递增,索引2处的值会被覆盖,新值将是7。

时间复杂度分析

我在这里写的这两个函数 pushpop 将花费常数时间。这两个函数中的操作很简单,执行时间不依赖于栈的大小。在定义栈ADT时,我们说过所有操作都必须花费常数时间,换句话说,时间复杂度应该是 O(1)

在我们的实现中,pushpop 操作都是 O(1)

数组实现的限制与处理 🚧

这里有一个重要的问题:我们只能在数组未被耗尽、即数组中还有空间时,才能向栈中压入元素。

我们可能会遇到栈占满整个数组的情况,此时 top 将等于数组中的最高索引。进一步的压栈操作将不可能,因为它会导致溢出。

这是基于数组实现的一个限制。为了避免溢出,我们总是可以创建一个足够大的数组。为此,我们必须合理地确信栈不会增长超过某个限制。在大多数实际情况下,使用大数组是可行的。但无论如何,我们必须在实现中处理溢出情况。

在发生溢出时,我们可以做几件事:

  1. Push 函数可以检查数组是否已耗尽,并在溢出时抛出错误,这样压栈操作就不会成功。但这并不是一个很好的行为。
  2. 我们可以使用动态数组的概念。我们在本系列课程的初始课程中讨论过动态数组。我们可以做的是:在发生溢出时,创建一个新的更大的数组。我们将栈的内容从已满的旧数组复制到新数组中,如果可能的话,删除较小的数组。

复制的成本将是 O(n),或者简单地说,将元素从较小数组复制到较大数组所花费的时间将与栈中元素的数量或较小数组的大小成正比,因为无论如何栈都会占据整个数组。

必须有一些策略来决定较大数组的大小。一个最佳策略是创建一个两倍于较小数组大小的数组。

在压栈操作中,可能有两种情况:

  • 在正常的压栈中,我们将花费常数时间。
  • 在发生溢出的情况下,我们将首先创建一个两倍于较小数组大小的较大数组,花费与较小数组大小成正比的时间复制所有元素,然后花费常数时间插入新元素。

采用这种策略,push 操作的时间复杂度在最好情况下是 O(1),在最坏情况下(发生溢出时)是 O(n)。但平均情况下仍然是 O(1)。如果我们计算 n 次压栈操作所花费的总时间,它将与 n 成正比。记住,n 是栈中元素的数量。O(n) 基本上意味着所花费的时间将非常接近某个常数乘以 n。简单来说,所花费的时间与 n 成正比。如果我们为 n 次压栈花费了 c * n 的时间,为了找出平均值,我们将除以 n。每次压栈的平均时间将是一个常数,因此在平均情况下是 O(1)

我不会深入探讨为什么 n 次压栈是 O(n) 的所有数学细节。要了解这一点,你可以查看本视频描述中的一些资源。

栈的其他基本操作

这基本上是我们实现的核心。我们在栈ADT的定义中还讨论了另外两个操作。

Top 操作简单地返回栈顶的元素。所以 top 函数看起来像这样:我们只需返回 top 索引处的元素。

为了验证栈是否为空,这是我们定义的另一个操作。我们可以简单地检查 top 的值。如果它等于 -1,我们可以说栈是空的,返回 true;否则,返回 false

有时 poptop 操作会结合在一起。在这种情况下,pop 不仅会从栈顶移除一个元素,还会返回该元素。许多编程语言中的库都为我们提供了栈的实现,这些实现中函数的签名可能略有不同。

C语言基础实现示例 💻

现在,我将快速向你展示一个用C语言实现栈的基本示例。在我的C代码中,我将编写一个简单的基于数组的实现,来创建一个整数栈。

我要做的第一件事是创建一个整数数组作为全局变量。这个数组的大小是 MAX_SIZE,其中 MAX_SIZE 被这个宏定义为101。

我将声明另一个名为 top 的全局变量,并最初将其设置为 -1。记住,top 等于 -1 意味着一个空栈。当一个变量没有在任何函数内部声明时,它是一个全局变量,可以在任何地方访问,所以你不需要将它作为参数传递给函数。

现在我将编写所有操作。

这是我的 push 函数。我首先递增 top,然后将 top 处的值设置为 xx 是作为参数传递的要插入的整数)。我可以写一条语句来代替这两条语句,像这样,效果一样。我使用了前递增运算符,所以递增会在赋值之前发生。

我还想处理溢出。当 top 索引等于 MAX_SIZE - 1(数组中可用的最高索引)时,就会发生溢出。在发生溢出的情况下,我只想打印一条错误信息,然后返回。

所以在这个实现中,我没有使用动态数组。在发生溢出时,push 不会成功。

这是我的 pop 函数。我在这里只是递减 top。同样,我们必须处理一个错误条件:如果栈已经是空的,我们就不能弹出。所以我在这里写了这些语句:如果 top 等于 -1,我们不能弹出,我将打印这条错误信息,说明没有元素可以弹出,然后直接返回。

现在让我们写 top 操作。top 操作将简单地返回 top 索引处的整数。

现在我的基本操作都写在这里了。我已经写了 pushpoptop

main 函数中,我将调用一些 pushpop。我想再写一个名为 Print 的函数,这只是为了验证 pushpop 是否正常工作。我将简单地打印栈中的所有元素。在我的 main 函数中,每次 pushpop 操作后,我都会调用 Print

我在这里同一行写了多个函数调用,因为空间有限。记住,Print 函数不是栈的典型操作,我写它只是为了测试我的实现。

这基本上就是我的代码。现在让我们运行这个程序,看看会发生什么。这是我得到的输出:我们压入了三个整数2、5和10,然后我们执行了一次 pop,所以10从栈中被移除,然后我们压入了12。

这是一个用C语言实现栈的基本示例。这不是一个理想的实现。一个理想的实现应该是这样的:我们应该有一个叫做 stack 的数据类型,并且我们应该能够创建它的实例。在面向对象的实现中,我们可以很容易地做到这一点;在C语言中,我们也可以使用结构体来实现。请查看本视频描述中的链接,以获取此实现以及面向对象实现的源代码。

总结

在本节课中,我们一起学习了如何使用数组来实现栈数据结构。我们详细讲解了压栈和弹栈操作的逻辑,分析了其 O(1) 的时间复杂度,并讨论了数组大小限制导致的溢出问题及其解决方案(如使用动态数组)。最后,我们通过一个C语言示例代码演示了具体的实现过程。

在下一课中,我们将讨论栈的链表实现。本节课到此结束,感谢观看。

016:栈的链表实现 📚

在本节课中,我们将学习如何使用链表来实现栈数据结构。我们将回顾栈和链表的基本概念,然后详细讲解如何通过链表实现栈的入栈和出栈操作,并分析其时间复杂度。


概述

上一节我们介绍了如何使用数组来实现栈。本节中,我们来看看如何使用链表来实现栈。链表实现栈的优势在于其动态的内存分配,可以避免数组实现中可能出现的“溢出”问题。

栈与链表回顾

栈是一种后进先出的数据结构。这意味着最后进入栈的元素将最先被移除。栈的操作限制为只能从一端进行插入和删除,这一端被称为栈顶。插入操作称为入栈,删除操作称为出栈。

链表是一种由节点组成的线性数据结构。每个节点包含两个部分:

  • 数据域:存储数据。
  • 指针域:存储下一个节点的地址。

链表的标识是头节点的地址,通常用一个名为 head 的指针变量来存储。

实现策略选择

为了用链表实现栈,我们需要确保插入和删除操作都在同一端进行。我们有两个选择:

  1. 在链表的尾部进行插入和删除。
  2. 在链表的头部进行插入和删除。

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

  • 在尾部操作:插入或删除节点都需要遍历整个链表以找到最后一个或倒数第二个节点。因此,时间复杂度为 O(n)
  • 在头部操作:插入或删除节点都只需要修改头指针的指向。因此,时间复杂度为 O(1)

由于栈要求入栈和出栈操作是常数时间,因此我们选择在链表的头部进行所有操作。这样,链表的头节点就对应栈的栈顶

核心实现

我们将使用一个名为 top 的指针(替代通常的 head)来指向栈顶节点。当 topNULL 时,表示栈为空。

数据结构定义

首先,我们定义链表节点的结构。

struct Node {
    int data;           // 存储数据
    struct Node* next;  // 指向下一个节点的指针
};

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/mycodeschool-dast/img/c438207c84c03a0a066706c1e948823f_44.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/mycodeschool-dast/img/c438207c84c03a0a066706c1e948823f_45.png)

struct Node* top = NULL; // 栈顶指针,初始化为空栈

入栈操作

入栈操作即在链表头部插入一个新节点。

步骤如下:

  1. 为新节点动态分配内存。
  2. 设置新节点的数据域。
  3. 将新节点的 next 指针指向当前的栈顶节点。
  4. 更新 top 指针,使其指向新节点。

void Push(int x) {
    struct Node* temp = (struct Node*)malloc(sizeof(struct Node));
    temp->data = x;
    temp->next = top;
    top = temp;
}

出栈操作

出栈操作即删除链表头部的节点。

步骤如下:

  1. 检查栈是否为空(top == NULL)。若为空,则报错。
  2. 创建一个临时指针 temp,指向当前栈顶节点。
  3. top 指针指向下一个节点(即新的栈顶)。
  4. 释放 temp 所指向节点的内存。

void Pop() {
    struct Node* temp;
    if (top == NULL) return; // 栈空,可在此处添加错误处理
    temp = top;
    top = top->next;
    free(temp);
}

链表实现栈的优势

与基于数组的实现相比,链表实现栈有以下几个优点:

  • 动态大小:无需预先指定大小,只要系统内存足够,就不会发生“溢出”。
  • 内存高效:内存按需分配,元素被弹出后内存立即释放。

当然,每个节点需要额外的空间来存储指针,这是一个小小的开销。

总结

本节课中,我们一起学习了如何使用链表来实现栈数据结构。核心在于将链表的头部作为栈顶,从而使得入栈和出栈操作的时间复杂度均为 O(1)。我们还用C语言编写了 PushPop 函数的核心代码,并讨论了链表实现相对于数组实现的优势。

在接下来的课程中,我们将运用栈来解决一些实际问题。

017:使用栈反转字符串或链表 📚

在本节课中,我们将学习栈数据结构的一个经典应用场景:反转序列。我们将通过两个具体问题——反转字符串和反转链表——来演示如何使用栈实现这一功能,并分析其效率。


概述

上一节我们介绍了栈的两种常见实现方式:基于数组和基于链表。作为一名程序员,我们不仅要掌握数据结构的实现,更要了解其应用场景。本节我们将探讨栈的一个简单而实用的用例:利用栈的“后进先出”特性来反转一个列表或集合。

我们将解决两个问题:字符串反转和链表反转,并均使用栈来实现。


字符串反转 🔤

首先,我们来讨论字符串反转问题。假设我们有一个以字符数组形式存储的字符串,例如 "hello"。在C语言风格中,字符串以空字符 \0 结尾。反转意味着重新排列数组中的字符顺序,如下图所示,空字符仅用于标记字符串结束,不参与反转。

有多种高效方法可以反转字符串。我们先看看如何使用栈来解决,然后再评估其效率。

使用栈的思路

我们可以创建一个字符栈。以下是逻辑步骤:

  1. 从左到右遍历字符串中的每个字符。
  2. 将每个字符依次压入栈中。
  3. 遍历完成后,再次从字符串起始位置开始。
  4. 将栈顶字符弹出,并写入字符串的当前位置。
  5. 重复此过程,直到栈为空。

由于栈的“后进先出”特性,最后压入的字符会最先弹出,从而实现了反转。

代码实现

以下是使用C++标准模板库中的栈来实现的代码:

#include <iostream>
#include <cstring>
#include <stack>
using namespace std;

void reverse(char *C, int n) {
    stack<char> S;
    // 遍历字符串,将字符压入栈
    for(int i = 0; i < n; i++) {
        S.push(C[i]);
    }
    // 遍历字符串,用栈顶字符覆盖原字符
    for(int i = 0; i < n; i++) {
        C[i] = S.top(); // 获取栈顶元素
        S.pop();        // 弹出栈顶元素
    }
}

int main() {
    char C[51];
    cout << "Enter a string: ";
    cin >> C;
    reverse(C, strlen(C));
    cout << "Output = " << C << endl;
}

运行示例:
输入 "hello",输出 "olleh"
输入 "mycodeschool",输出 "loohcse docym"

复杂度分析

  • 时间复杂度:两个循环分别执行n次,每个栈操作(push, top, pop)是常数时间。因此,总时间复杂度为 O(n)
  • 空间复杂度:我们使用了一个额外的栈来存储所有n个字符,因此空间复杂度也为 O(n)

更优的解法

存在不使用额外空间的反转算法,例如使用双指针法:

void reverseInPlace(char *C, int n) {
    int i = 0;
    int j = n - 1;
    while(i < j) {
        swap(C[i], C[j]); // 交换字符
        i++;
        j--;
    }
}

该算法的空间复杂度为 O(1),时间复杂度仍为 O(n)。在空间效率上,它优于栈方法。


链表反转 🔗

现在,我们来看一个更复杂的问题:反转链表。链表由节点组成,每个节点包含数据域和指向下一个节点的指针域。链表的身份由其头节点的地址(通常存储在 head 变量中)标识。

与数组不同,链表节点在内存中非连续存储,无法通过简单计算直接访问任意元素。我们之前学习过两种反转链表的方法:迭代法和递归法。

  • 迭代法:时间复杂度 O(n),空间复杂度 O(1)。
  • 递归法:时间复杂度 O(n),但使用了函数调用栈(隐式栈),空间复杂度 O(n)。

接下来,我们将看到如何使用显式栈来直观地解决这个问题。

使用栈的思路

  1. 遍历链表,使用一个临时指针。
  2. 将每个节点的地址(指针)依次压入栈中。
  3. 遍历完成后,开始弹出栈顶元素(即原链表的尾节点地址)。
  4. 将弹出的节点作为新链表的头节点,并依次建立反向链接。

代码实现

假设链表节点定义和头指针 head 如下:

struct Node {
    int data;
    Node* next;
};

Node* head; // 全局头指针

反转函数实现如下:

#include <stack>
using namespace std;

void reverseLinkedList() {
    if(head == nullptr) return;
    
    stack<Node*> S;
    Node* temp = head;
    
    // 遍历链表,将节点指针压入栈
    while(temp != nullptr) {
        S.push(temp);
        temp = temp->next;
    }
    
    // 设置新的头节点(原尾节点)
    head = S.top();
    S.pop();
    temp = head;
    
    // 弹出栈中元素,构建反向链接
    while(!S.empty()) {
        temp->next = S.top(); // 建立链接
        S.pop();              // 弹出
        temp = temp->next;    // 移动到下一个节点
    }
    // 设置新链表的尾节点next为nullptr
    temp->next = nullptr;
}

过程解析

  1. 初始链表:1 -> 2 -> 3 -> 4
  2. 压栈后:栈顶为节点4,栈底为节点1。
  3. 弹出栈顶(节点4)作为新头。
  4. 循环弹出并链接:4 -> 3 -> 2 -> 1
  5. 最后将节点1的 next 设为 nullptr

使用栈使得反转链表的逻辑变得非常清晰和直观,尤其适合初学者理解反转过程。虽然空间复杂度为 O(n),但在某些场景下,代码的清晰度可能比极致的空间优化更重要。


总结

本节课我们一起学习了栈的两个经典应用:

  1. 反转字符串:通过将字符压栈再弹出,实现了字符串的反转。我们分析了其 O(n) 的时间和空间复杂度,并对比了更优的双指针原地反转法。
  2. 反转链表:通过将节点指针压栈,再依次弹出构建新链表,直观地实现了链表反转。这种方法逻辑清晰,是理解链表反转过程的优秀范例。

栈的“后进先出”特性使其成为处理“反转”或“逆序”类问题的天然工具。理解这些基础应用,将帮助我们未来更灵活地运用栈去解决更复杂的算法问题。

018:使用栈检查括号平衡性 🔍

在本节课中,我们将学习如何使用栈(Stack)这一数据结构来解决一个经典问题:检查表达式中的括号(包括圆括号、花括号和方括号)是否平衡。这是编程面试中的一个常见问题,也是编译器进行语法检查的基础任务之一。

概述

上一节我们介绍了栈的一个简单应用——反转列表或集合。本节中,我们将探讨另一个可以使用栈解决的著名问题:检查括号平衡性。

给定一个由常量、变量、运算符和括号组成的字符串表达式,我们需要编写一个程序来判断其中的括号是否平衡。这里的“括号”包括圆括号 ()、花括号 {} 和方括号 []

一个表达式中的括号是“平衡”的,意味着每一个开括号都必须有一个对应的、顺序正确的闭括号与之匹配。

什么是平衡括号? ⚖️

以下是平衡括号的核心定义:

  • 每个开括号((, {, [)都必须有一个相同类型的闭括号(), }, ])在其右侧与之匹配。
  • 每个闭括号都必须有一个相同类型的开括号在其左侧与之匹配。
  • 括号必须正确嵌套。后打开的左括号必须先闭合,这符合栈的 后进先出(LIFO) 特性。

示例:

  • (A + B) - 平衡
  • { [A + B] * (C - D) } - 平衡
  • (A + B - 不平衡(缺少闭括号)
  • [A + B) * (C - D] - 不平衡(括号类型不匹配)
  • { (A + B) * (C - D) - 不平衡(缺少闭花括号)

编译器在解析我们编写的代码时,就会执行类似的检查以确保语法正确。

为什么简单的计数法行不通? ❌

一个直观的想法是分别统计三种开括号和闭括号的数量,如果各自相等,则括号平衡。但这种方法并不充分。

考虑表达式:[ ( ] )

  • 开括号 [( 各有一个。
  • 闭括号 ]) 也各有一个。
  • 数量相等,但该表达式并不平衡,因为方括号和圆括号的嵌套顺序错误。

因此,除了数量,顺序嵌套关系也至关重要。

解决方案思路:使用栈 🧠

解决问题的关键在于:当我们从左到右扫描表达式时,最后一个未匹配的开括号,必须与接下来遇到的第一个闭括号匹配。这正是栈(后进先出)的用武之地。

算法步骤:

  1. 创建一个空栈。
  2. 从左到右遍历表达式的每个字符。
  3. 如果当前字符是开括号((, {, [),则将其压入(Push)栈中。
  4. 如果当前字符是闭括号(), }, ]),则:
    • 检查栈是否为空。如果为空,说明没有与之匹配的开括号,表达式不平衡。
    • 如果栈非空,则弹出(Pop)栈顶元素。
    • 检查弹出的开括号是否与当前闭括号类型相同。如果类型不同,表达式不平衡。
  5. 遍历结束后,检查栈是否为空。
    • 如果栈为空,说明所有开括号都找到了匹配的闭括号,表达式平衡
    • 如果栈非空,说明还有未匹配的开括号,表达式不平衡

算法伪代码 📝

以下是该算法的伪代码实现:

函数 checkBalancedParentheses(expression):
    n = expression的长度
    创建栈 S

    循环 i 从 0 到 n-1:
        字符 ch = expression[i]

        如果 ch 是开括号 ('(', '{', '['):
            将 ch 压入栈 S
        否则 如果 ch 是闭括号 (')', '}', ']'):
            如果 栈 S 为空:
                返回 false // 不平衡,闭括号没有匹配项
            否则:
                栈顶字符 topChar = 弹出栈 S
                如果 (ch 是 ')' 且 topChar 不是 '(') 或
                   (ch 是 '}' 且 topChar 不是 '{') 或
                   (ch 是 ']' 且 topChar 不是 '['):
                    返回 false // 不平衡,括号类型不匹配

    循环结束

    如果 栈 S 为空:
        返回 true // 平衡
    否则:
        返回 false // 不平衡,还有未匹配的开括号

示例推演 📊

让我们用算法推演一个平衡表达式 { [ ( ) ] } 的过程。

步骤 当前字符 操作 栈内容 (底->顶) 说明
1 { Push { { 开括号入栈
2 [ Push [ { [ 开括号入栈
3 ( Push ( { [ ( 开括号入栈
4 ) Pop, 匹配 ( { [ 闭括号与栈顶匹配,弹出
5 ] Pop, 匹配 [ { 闭括号与栈顶匹配,弹出
6 } Pop, 匹配 { (空) 闭括号与栈顶匹配,弹出
结束 栈为空 (空) 表达式平衡

总结

本节课我们一起学习了如何使用栈来高效地检查表达式中的括号是否平衡。我们首先明确了“平衡括号”的定义,然后分析了简单计数法的缺陷,最后引出了基于栈的解决方案。

核心要点:

  • 问题:检查字符串中 (), {}, [] 的匹配是否正确。
  • 数据结构栈(Stack),利用其后进先出(LIFO) 的特性。
  • 算法核心:遍历字符串,开括号入栈,遇到闭括号时检查栈顶元素是否与之匹配。
  • 判断条件:最终栈为空且匹配过程中无错误,则括号平衡。

你可以尝试在你熟悉的编程语言中实现这个算法,并测试各种边界情况。在接下来的课程中,我们将继续探索栈的更多应用场景。

019:中缀、前缀与后缀表达式

在本节课中,我们将学习计算机科学中一个重要且有趣的主题——算术与逻辑表达式的求值。这是栈数据结构的一个典型应用。我们将探讨表达式的不同书写形式,以及如何利用栈来高效地处理它们。

表达式可以包含常量、变量以及运算符或括号等符号。我们通常书写表达式的方式被称为中缀表示法,即运算符位于两个操作数之间,例如 A + B

然而,对于计算机而言,中缀表达式并不总是最方便处理的形式。因此,我们引入了前缀后缀表示法。在前缀表示法中,运算符位于操作数之前,例如 + A B。在后缀表示法中,运算符位于操作数之后,例如 A B +。这两种表示法完全不需要括号来定义运算顺序,因此也被称为“无括号表示法”。


中缀表示法

上一节我们提到了中缀表示法是我们最熟悉的书写方式。在这种表示法中,运算符的优先级和结合性决定了运算的顺序。例如,在表达式 A + B * C 中,乘法 * 的优先级高于加法 +,因此会先计算 B * C,再与 A 相加。

为了改变默认的运算顺序,我们需要使用括号。例如,(A + B) * C 会强制先计算加法。

中缀表示法对人类阅读友好,但对计算机直接求值来说较为复杂,因为它需要处理运算符优先级和括号嵌套的问题。


前缀表示法

现在,让我们来看看前缀表示法。前缀表示法,也称为波兰表示法,将运算符写在所有操作数之前。

以下是一些中缀表达式及其对应的前缀表达式示例:

  • A + B 转换为 + A B
  • A + B * C 转换为 + A * B C (注意:乘法优先级高,所以 * B C 作为一个整体成为 + 的第二个操作数)
  • (A + B) * C 转换为 * + A B C (括号强制先算加法,所以 + A B 作为一个整体成为 * 的第一个操作数)

从这些例子可以看出,前缀表达式完全消除了对括号的需求。运算顺序由运算符和操作数的位置唯一确定。


后缀表示法

接下来,我们探讨后缀表示法。后缀表示法,也称为逆波兰表示法,将运算符写在所有操作数之后。

以下是一些中缀表达式及其对应的后缀表达式示例:

  • A + B 转换为 A B +
  • A + B * C 转换为 A B C * +
  • (A + B) * C 转换为 A B + C *

与前缀表示法类似,后缀表达式也无需括号来定义优先级。计算机可以非常高效地使用栈数据结构对后缀表达式进行求值。


表达式求值与栈的应用

我们已经了解了三种表达式表示法。本节中,我们来看看栈如何应用于后缀表达式的求值。

求值算法非常直观:

  1. 从左到右扫描表达式。
  2. 如果遇到操作数(数字或变量),将其压入栈中。
  3. 如果遇到运算符,则从栈中弹出所需数量的操作数(对于二元运算符是两个),执行运算,然后将结果压回栈中。
  4. 扫描结束后,栈顶元素就是表达式的最终结果。

例如,求值后缀表达式 A B C * + (对应中缀 A + B * C):

  • 扫描到 A, B, C,依次压入栈。
  • 扫描到 *,弹出 CB,计算 B * C,将结果 R1 压入栈。
  • 扫描到 +,弹出 R1A,计算 A + R1,将最终结果压入栈。

这个过程可以用以下伪代码描述:

for each token in postfix_expression:
    if token is an operand:
        push(token onto stack)
    else if token is an operator:
        operand2 = pop(stack)
        operand1 = pop(stack)
        result = perform operation(token, operand1, operand2)
        push(result onto stack)
final_result = pop(stack)

中缀到后缀的转换

既然后缀表达式易于求值,一个关键问题是如何将我们熟悉的中缀表达式转换为后缀表达式。这同样可以借助栈来完成。

转换算法如下:

  1. 初始化一个空栈用于存放运算符,初始化一个空列表用于输出后缀表达式。
  2. 从左到右扫描中缀表达式。
  3. 如果遇到操作数,直接添加到输出列表。
  4. 如果遇到左括号 (,将其压入栈。
  5. 如果遇到右括号 ),则不断从栈顶弹出运算符并添加到输出列表,直到遇到左括号为止(左括号弹出但不输出)。
  6. 如果遇到运算符 op1
    • 只要栈非空且栈顶运算符的优先级大于或等于 op1,且栈顶不是左括号,就将其弹出并添加到输出列表。
    • 然后将 op1 压入栈。
  7. 扫描结束后,将栈中所有剩余的运算符依次弹出并添加到输出列表。

以下是一个转换示例,将中缀表达式 A + B * C 转换为后缀表达式:

  • 输出 A
  • + 入栈。
  • 输出 B
  • * 优先级高于栈顶的 +,所以 * 入栈。
  • 输出 C
  • 扫描结束,弹出栈中的 *+ 到输出。
  • 最终后缀表达式为 A B C * +


总结

本节课中,我们一起学习了表达式的三种重要表示法:中缀前缀后缀。我们了解到,虽然中缀表示法便于人类阅读,但前缀和后缀(尤其是后缀)表示法因其无括号的特性,更便于计算机处理。我们重点探讨了如何使用这一数据结构来高效地将中缀表达式转换为后缀表达式,以及如何对后缀表达式进行求值。掌握这些概念是理解编译器、计算器等工作原理的基础。

020:使用栈求值前缀与后缀表达式 📚

在本节课中,我们将学习如何使用栈(Stack)这种数据结构来求值前缀表达式(Prefix)和后缀表达式(Postfix)。我们将从后缀表达式开始,因为它更易于理解和实现,然后再讨论前缀表达式的求值。

概述

上一节我们介绍了前缀和后缀表达式的概念。本节中,我们将具体探讨如何对这两种表达式进行求值。核心思想是利用栈的“后进先出”(LIFO)特性,高效地处理运算符和操作数。

后缀表达式求值

后缀表达式,也称为逆波兰表示法,其特点是运算符位于操作数之后。例如,中缀表达式 (A * B) + (C * D) - E 的后缀形式为 A B * C D * + E -

手动求值过程

以下是手动求值后缀表达式的步骤:

  1. 从左到右扫描表达式。
  2. 找到第一个出现的运算符。
  3. 该运算符之前的两个实体必定是其操作数。
  4. 对这两个操作数应用该运算符,得到一个结果。
  5. 用这个结果替换掉表达式中的“操作数1 操作数2 运算符”序列。
  6. 重复步骤1-5,直到表达式中没有运算符为止。

例如,对于表达式 2 3 * 5 4 * + 9 - 和变量值 A=2, B=3, C=5, D=4, E=9,求值过程如下:

初始: 2 3 * 5 4 * + 9 -
步骤1: 计算 2 * 3 = 6 -> 6 5 4 * + 9 -
步骤2: 计算 5 * 4 = 20 -> 6 20 + 9 -
步骤3: 计算 6 + 20 = 26 -> 26 9 -
步骤4: 计算 26 - 9 = 17 -> 17
最终结果: 17

使用栈的算法

我们可以使用栈来高效地实现上述过程。算法伪代码如下:

函数 evaluatePostfix(表达式字符串 exp):
    创建栈 stack

    对于 i 从 0 到 exp.length - 1:
        当前字符 token = exp[i]

        如果 token 是操作数:
            将 token 压入栈 stack
        否则如果 token 是运算符:
            // 弹出栈顶两个元素作为操作数
            操作数2 = stack.pop()
            操作数1 = stack.pop()
            // 执行运算
            结果 = 执行运算(操作数1, token, 操作数2)
            // 将结果压回栈中
            stack.push(结果)

    // 表达式扫描完毕,栈顶元素即为最终结果
    返回 stack.pop()

核心概念:栈的LIFO特性确保了运算符总是作用于最近遇到的两个操作数。

使用栈对同一个表达式 2 3 * 5 4 * + 9 - 的求值流程如下:

扫描 ‘2’: 栈 -> [2]
扫描 ‘3’: 栈 -> [2, 3]
扫描 ‘*’: 弹出 3 和 2,计算 2 * 3 = 6,压入 6。栈 -> [6]
扫描 ‘5’: 栈 -> [6, 5]
扫描 ‘4’: 栈 -> [6, 5, 4]
扫描 ‘*’: 弹出 4 和 5,计算 5 * 4 = 20,压入 20。栈 -> [6, 20]
扫描 ‘+’: 弹出 20 和 6,计算 6 + 20 = 26,压入 26。栈 -> [26]
扫描 ‘9’: 栈 -> [26, 9]
扫描 ‘-’: 弹出 9 和 26,计算 26 - 9 = 17,压入 17。栈 -> [17]
返回栈顶元素 17。

前缀表达式求值

前缀表达式,也称为波兰表示法,其特点是运算符位于操作数之前。例如,中缀表达式 (A * B) + (C * D) - E 的前缀形式为 - + * A B * C D E

使用栈的算法

前缀表达式的求值算法与后缀类似,但扫描方向相反。

  1. 从右到左扫描表达式。
  2. 如果遇到操作数,将其压入栈。
  3. 如果遇到运算符,则从栈中弹出两个元素作为操作数。注意:第一个弹出的元素是运算符的第一个操作数,第二个弹出的是第二个操作数(这与后缀求值顺序相反)。
  4. 对这两个操作数应用运算符,并将结果压回栈中。
  5. 重复步骤1-4,直到扫描完整个表达式。
  6. 栈中最后剩下的元素就是表达式的结果。

算法伪代码如下:

函数 evaluatePrefix(表达式字符串 exp):
    创建栈 stack

    对于 i 从 exp.length - 1 到 0:
        当前字符 token = exp[i]

        如果 token 是操作数:
            将 token 压入栈 stack
        否则如果 token 是运算符:
            // 弹出栈顶两个元素作为操作数
            操作数1 = stack.pop() // 注意顺序
            操作数2 = stack.pop()
            // 执行运算
            结果 = 执行运算(操作数1, token, 操作数2)
            // 将结果压回栈中
            stack.push(结果)

    // 表达式扫描完毕,栈顶元素即为最终结果
    返回 stack.pop()

例如,对前缀表达式 - + * 2 3 * 5 4 9 进行求值:

从右向左扫描:
扫描 ‘9’: 栈 -> [9]
扫描 ‘4’: 栈 -> [9, 4]
扫描 ‘5’: 栈 -> [9, 4, 5]
扫描 ‘*’: 弹出 5 和 4,计算 5 * 4 = 20,压入 20。栈 -> [9, 20]
扫描 ‘3’: 栈 -> [9, 20, 3]
扫描 ‘2’: 栈 -> [9, 20, 3, 2]
扫描 ‘*’: 弹出 2 和 3,计算 2 * 3 = 6,压入 6。栈 -> [9, 20, 6]
扫描 ‘+’: 弹出 6 和 20,计算 20 + 6 = 26,压入 26。栈 -> [9, 26]
扫描 ‘-’: 弹出 26 和 9,计算 26 - 9 = 17,压入 17。栈 -> [17]
返回栈顶元素 17。

总结

本节课中,我们一起学习了如何使用栈来求值前缀和后缀表达式:

  • 后缀表达式求值:从左到右扫描,遇到操作数则入栈,遇到运算符则弹出栈顶两个操作数进行计算,并将结果入栈。
  • 前缀表达式求值:从右到左扫描,遇到操作数则入栈,遇到运算符则弹出栈顶两个操作数进行计算(注意操作数顺序),并将结果入栈。

这两种算法的核心都是利用栈来临时存储中间结果,从而只需一次扫描即可完成求值,时间复杂度为 O(n),其中 n 是表达式的长度。在后续课程中,我们将探讨如何将常见的中缀表达式高效地转换为前缀或后缀形式。

数据结构:P21:使用栈将中缀表达式转换为后缀表达式 🧮

在本节课中,我们将学习一个高效的算法,用于将中缀表达式转换为后缀表达式。我们已经知道一种手动转换的方法,但我们将重点介绍一个只需从左到右扫描一次表达式即可完成转换的算法。这个算法利用栈来管理运算符,逻辑清晰且易于实现。


概述:中缀与后缀表达式

上一节我们学习了如何计算前缀和后缀表达式。本节中,我们来看看如何将中缀表达式转换为后缀表达式。

我们知道一种手动转换的方法,即应用运算符的优先级和结合性规则。例如,对于表达式 a + b * c,我们首先处理高优先级的乘法部分 b * c,将其转换为 b c *,然后再处理加法部分 a + (b c *),最终得到后缀表达式 a b c * +

然而,这种方法在程序中实现起来效率不高且较为复杂。接下来,我们将介绍一个更简单、高效的算法。


核心观察:操作数的顺序不变

在转换过程中,一个重要的观察是:操作数在中缀和后缀表达式中的从左到右顺序保持不变,但运算符的顺序会根据优先级和括号而改变

例如,对于中缀表达式 a + b * c

  • 操作数顺序:a, b, c
  • 后缀表达式:a b c * +
  • 运算符顺序:后缀表达式中,先出现应被执行的乘法运算符 *,然后是加法运算符 +

这个观察是我们算法的基础。


算法思路与逐步推演

我们将从左到右扫描中缀表达式的每个“记号”(token),它可以是操作数或运算符。我们使用一个栈来临时存放还不能确定位置的运算符,并使用一个字符串来构建最终的后缀表达式。

以下是处理不含括号表达式 a + b * c - d * e 的推演过程:

  1. 遇到操作数 a:直接将其附加到结果字符串。
    • 结果:a
    • 栈:
  2. 遇到运算符 +:由于不知道其右操作数,将其压入栈中。
    • 结果:a
    • 栈:+
  3. 遇到操作数 b:直接附加。
    • 结果:a b
    • 栈:+
  4. 遇到运算符 *
    • 规则:当遇到一个运算符时,我们需要检查栈顶。只要栈顶运算符的优先级高于或等于当前运算符,就将其弹出并附加到结果中
    • 栈顶是 +,优先级低于 *,所以不弹出。
    • * 压入栈。
    • 结果:a b
    • 栈:+ *
  5. 遇到操作数 c:直接附加。
    • 结果:a b c
    • 栈:+ *
  6. 遇到运算符 -
    • 检查栈顶 *,优先级高于 -,弹出并附加。
    • 再次检查栈顶 +,优先级高于 -,弹出并附加。
    • 栈空或栈顶优先级低于 -,停止弹出。
    • - 压入栈。
    • 结果:a b c * +
    • 栈:-
  7. 遇到操作数 d:直接附加。
    • 结果:a b c * + d
    • 栈:-
  8. 遇到运算符 *
    • 栈顶 - 优先级低于 *,不弹出。
    • * 压入栈。
    • 结果:a b c * + d
    • 栈:- *
  9. 遇到操作数 e:直接附加。
    • 结果:a b c * + d e
    • 栈:- *
  10. 表达式结束:将栈中所有剩余运算符依次弹出并附加。
    • 弹出 *:结果 a b c * + d e *
    • 弹出 -:结果 a b c * + d e * -
    • 最终后缀表达式:a b c * + d e * -


处理括号的规则

括号会改变运算的默认优先级。算法需要增加对左右括号的处理。

新增规则如下:

  1. 遇到左括号 (:直接压入栈中。它作为一个边界标记。
  2. 遇到右括号 )
    • 不断弹出栈顶运算符并附加到结果,直到遇到左括号
    • 弹出左括号(丢弃,不附加到结果)。
  3. 遇到运算符时(栈顶为左括号的情况)
    • 当检查栈顶以决定是否弹出时,如果栈顶是左括号,则停止弹出。因为括号内的运算符优先级独立于外部。

示例:转换 ( ( a + b ) * ( c - d ) )

  1. ( -> 压栈 [ ( ]
  2. ( -> 压栈 [ ( ( ]
  3. a -> 结果 a
  4. + -> 栈顶是(,不弹出,压栈 [ ( ( + ]
  5. b -> 结果 a b
  6. ) -> 弹出直到(:弹出+,结果 a b +;弹出(
    • 栈变为 [ ( ]
  7. * -> 栈顶是(,不弹出,压栈 [ ( * ]
  8. ( -> 压栈 [ ( * ( ]
  9. c -> 结果 a b + c
  10. - -> 栈顶是(,不弹出,压栈 [ ( * ( - ]
  11. d -> 结果 a b + c d
  12. ) -> 弹出直到(:弹出-,结果 a b + c d -;弹出(
    • 栈变为 [ ( * ]
  13. ) -> 弹出直到(:弹出*,结果 a b + c d - *;弹出(
    • 栈变为
  14. 最终结果:a b + c d - *


算法伪代码

以下是结合了括号处理规则的完整算法伪代码。

函数 infixToPostfix(表达式字符串 exp):
    创建字符栈 S
    初始化结果字符串 postfix = ""

    对于 i 从 0 到 exp.length - 1:
        当前字符 token = exp[i]

        如果 token 是操作数:
            将 token 附加到 postfix

        否则如果 token 是左括号 '(':
            将 token 压入栈 S

        否则如果 token 是右括号 ')':
            当栈 S 非空 且 栈顶不是 '(':
                将栈顶运算符弹出并附加到 postfix
            弹出栈顶的左括号 '(' (并丢弃)

        否则: // token 是一个运算符 (+, -, *, / 等)
            当栈 S 非空 且 栈顶不是 '(' 且 栈顶运算符优先级 >= token 优先级:
                将栈顶运算符弹出并附加到 postfix
            将 token 压入栈 S

    // 表达式扫描完毕,处理栈中剩余运算符
    当栈 S 非空:
        将栈顶运算符弹出并附加到 postfix

    返回 postfix

辅助函数说明:

  • isOperand(c): 判断字符 c 是否为操作数(字母或数字)。
  • precedence(op): 返回运算符 op 的优先级(例如,*, / > +, -)。


总结

本节课中,我们一起学习了使用栈将中缀表达式转换为后缀表达式的高效算法。

  • 核心思想:从左到右扫描表达式,操作数直接输出,运算符利用栈根据优先级和括号规则调整顺序。
  • 关键规则
    1. 操作数直接加入结果。
    2. 运算符与栈顶比较,弹出优先级更高或相等的栈顶运算符,然后自己入栈。
    3. 左括号直接入栈,作为子表达式的开始标记。
    4. 右括号意味着一个子表达式结束,弹出栈内运算符直到遇见左括号。
  • 算法优势:只需一次扫描,时间复杂度为 O(n),逻辑清晰,易于编程实现。

理解这个算法是掌握表达式求值、编译器语法分析等重要主题的基础。建议你使用几个不同的中缀表达式(包含括号和多种运算符)来手动模拟这个算法,以加深理解。

022:队列简介 🚶‍♂️➡️🚶‍♀️

在本节课中,我们将学习一种新的数据结构——队列。我们将从抽象数据类型(ADT)的角度来理解队列,介绍其核心概念、基本操作以及典型应用场景。


队列的逻辑视图

上一节我们介绍了栈,它是一种后进先出的结构。本节中我们来看看队列,它与栈不同,遵循“先进先出”的原则。

队列是一种数据结构,其中最先进入的元素将最先被取出。我们通常称之为 FIFO 结构。

与栈的插入和删除都在同一端(栈顶)进行不同,队列的插入和删除发生在不同的两端:

  • 入队 操作在队列的尾部进行。
  • 出队 操作在队列的头部进行。

我们可以将队列想象成一个两端开口的管道,元素从一端进入,从另一端离开。


队列的抽象数据类型(ADT)与操作

作为抽象数据类型,我们只定义队列支持的操作,而不关心其具体实现细节。以下是队列的核心操作接口:

以下是队列ADT的基本操作:

  1. Enqueue(x):在队列的尾部插入一个元素 x
    • 代码示例:void Enqueue(int x);
  2. Dequeue():从队列的头部移除一个元素,并返回该元素。
    • 代码示例:int Dequeue();
  3. Front():返回队列头部的元素,但不移除它。
    • 代码示例:int Front();
  4. IsEmpty():检查队列是否为空。
    • 代码示例:bool IsEmpty();

注意:不同编程语言或库中,这些操作的名称可能不同。例如,入队操作也可能被称为 Push,出队操作也可能被称为 Pop。但 EnqueueDequeue 是队列上下文中最常用的名称。

所有这些操作的时间复杂度都应该是 O(1),即常数时间。


队列操作示例

让我们通过一个简单的例子来演示队列的工作流程。假设我们有一个初始为空的整数队列。

以下是操作步骤:

  1. 调用 Enqueue(2)。队列状态:[2](头部和尾部都指向2)。
  2. 调用 Enqueue(5)。队列状态:[2, 5](头部是2,尾部是5)。
  3. 调用 Enqueue(7)。队列状态:[2, 5, 7]
  4. 调用 Dequeue()。它移除并返回头部元素 2。队列状态变为:[5, 7]
  5. 此时调用 Front(),将返回头部元素 5,但队列保持不变。
  6. 调用 IsEmpty(),将返回 false


队列的应用场景

队列最常用于处理需要“排队”或“等待”的场景,特别是当存在一个共享资源,而该资源一次只能服务一个请求时。

以下是两个典型例子:

  • 打印机队列:网络中的多台计算机向一台共享打印机发送打印任务。打印机一次只能处理一个任务,后续的请求会被放入队列中等待,先到的请求先被打印。
  • CPU进程调度:计算机的中央处理器(CPU)是一个共享资源。多个需要运行的进程被放入一个就绪队列中,操作系统从队列头部取出进程为其分配CPU时间片。

队列在模拟等待、广度优先搜索(BFS)等算法中也有广泛应用,我们将在后续课程中详细讨论。


总结

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

  1. 队列是一种 FIFO 数据结构。
  2. 队列的插入(Enqueue)在尾部进行,删除(Dequeue)在头部进行。
  3. 我们定义了队列作为抽象数据类型的基本操作接口。
  4. 队列的典型应用是管理共享资源的访问顺序。

在下一节课中,我们将探讨如何用代码实现队列。

023:队列的数组实现 🧑‍💻

在本节课中,我们将要学习如何使用数组来实现队列数据结构。上一节我们介绍了队列作为一种抽象数据类型(ADT),本节中我们来看看如何用具体的代码来实现它。

概述 📋

队列是一种特殊的列表,它遵循“先进先出”(FIFO)的原则。这意味着元素的插入(入队)只能在一端(称为队尾)进行,而元素的删除(出队)只能在另一端(称为队头)进行。我们将讨论如何用数组来实现队列,并处理一些关键的边界情况。

队列的抽象定义

队列作为抽象数据类型,我们定义了以下四个核心操作:

  • Enqueue(x):在队尾插入元素 x
  • Dequeue():从队头移除一个元素。
  • Front():返回队头的元素(不移除)。
  • IsEmpty():检查队列是否为空。

所有这些操作的时间复杂度都必须是 O(1),即执行时间不依赖于队列中元素的数量。

数组实现的初步构想

我们可以使用一个固定大小的数组来存储队列元素。需要两个变量来追踪队列的位置:

  • front:指向队列的第一个元素(队头)。
  • rear:指向队列的最后一个元素(队尾)。

初始时,队列为空,我们将 frontrear 都设置为 -1

以下是入队(Enqueue)和出队(Dequeue)的基本逻辑:

  • 入队:将 rear 向后移动一位,然后在新的 rear 位置放入新元素。
  • 出队:将 front 向后移动一位,原 front 位置的元素即被“移除”。

基本操作的伪代码

以下是队列基本操作的初步伪代码实现。

检查队列是否为空

如果 frontrear 都等于 -1,则队列为空。

function IsEmpty()
    if front == -1 AND rear == -1
        return true
    else
        return false

入队操作 (Enqueue)

入队操作需要考虑队列是否已满以及是否为空等边界情况。

function Enqueue(x)
    // 检查队列是否已满(简单情况,rear 是否在数组末尾)
    if rear == SIZE_OF_ARRAY - 1
        print "Error: Queue is full"
        return

    else if IsEmpty()
        // 队列为空时,第一个元素放在索引0处
        front = 0
        rear = 0
    else
        // 正常情况,rear 向后移动
        rear = rear + 1

    // 将新元素放入 rear 指向的位置
    A[rear] = x

出队操作 (Dequeue)

出队操作需要考虑队列是否为空以及是否只剩下一个元素。

function Dequeue()
    if IsEmpty()
        print "Error: Queue is empty"
        return
    else if front == rear
        // 队列中只有一个元素,出队后队列变为空
        front = -1
        rear = -1
    else
        // 正常情况,front 向后移动
        front = front + 1

线性数组实现的问题 🚧

使用上述简单方法,随着元素不断入队和出队,rear 指针会逐渐移向数组末端。即使数组前端(front 之前)有空闲位置,我们也无法再利用它们,因为 rear 无法“绕回”到数组开头。这造成了空间浪费。

解决方案:循环数组 🔄

为了解决空间浪费问题,我们引入循环数组的概念。在逻辑上,我们将数组视为一个首尾相接的环。

在循环数组中,计算下一个位置的公式是:
next_index = (current_index + 1) % array_size
计算前一个位置的公式是:
previous_index = (current_index + array_size - 1) % array_size

其中 % 是取模运算符。

基于循环数组的完整实现

现在,我们修改伪代码,使用循环数组的逻辑。

修改后的入队操作

function Enqueue(x)
    // 检查队列是否已满:队尾的下一个位置是否是队头
    if (rear + 1) % N == front
        print "Error: Queue is full"
        return
    else if IsEmpty()
        front = 0
        rear = 0
    else
        // 循环移动 rear
        rear = (rear + 1) % N

    A[rear] = x

修改后的出队操作

function Dequeue()
    if IsEmpty()
        print "Error: Queue is empty"
        return
    else if front == rear
        front = -1
        rear = -1
    else
        // 循环移动 front
        front = (front + 1) % N

获取队头元素

function Front()
    if IsEmpty()
        print "Error: Queue is empty"
        return -1 // 或抛出错误
    else
        return A[front]

总结 🎯

本节课中我们一起学习了如何使用数组实现队列。我们首先探讨了基本的线性数组实现及其空间浪费的局限性,然后引入了循环数组的概念来高效利用所有数组空间。关键点在于使用取模运算 % 来实现指针的循环移动,并仔细处理了队列为空、满、以及只有一个元素等各种边界情况。最终,我们实现的所有队列操作(Enqueue, Dequeue, Front, IsEmpty)都达到了 O(1) 的时间复杂度。

024:队列的链表实现 🧩

在本节课中,我们将学习如何使用链表来实现队列数据结构。我们将探讨链表实现相对于数组实现的优势,并详细讲解入队和出队操作的具体步骤与代码实现。


概述

上一节我们介绍了如何使用数组实现队列。本节中,我们来看看如何使用链表来实现队列。队列是一种“先进先出”的数据结构,其插入操作在一端(队尾)进行,删除操作在另一端(队头)进行。

队列的基本操作

以下是队列通常定义的几种核心操作:

  • 入队:在队尾添加一个元素。
  • 出队:从队头移除一个元素。
  • 获取队头元素:返回队头的元素值,但不移除它。
  • 判空:检查队列是否为空。

所有这些操作的时间复杂度都必须是 O(1),即常数时间。

数组实现的局限性

当我们使用数组实现队列时,通常会采用循环数组的概念。但这种方法存在一些限制:

  1. 固定大小:数组的大小是固定的。一旦数组被填满,我们有两种选择:
    • 拒绝新的插入操作,报告“队列已满”。
    • 创建一个更大的新数组,并将旧数组的所有元素复制过去。这个复制操作的时间复杂度是 O(n),代价高昂。
  2. 内存浪费:即使队列中元素很少,预先分配的大数组也会占用大量未使用的内存,造成资源浪费。

链表实现的优势

使用链表实现队列可以避免上述问题。链表由节点组成,每个节点包含数据域和指向下一个节点的指针域。节点在内存中非连续存储。

为了高效实现队列操作,我们需要维护两个指针:

  • front:指向链表的第一个节点(队头)。
  • rear:指向链表的最后一个节点(队尾)。

这样设计的目的是:

  • 出队操作front 端进行,只需修改 front 指针,时间复杂度为 O(1)
  • 入队操作rear 端进行。如果只有 front 指针,在尾部插入需要遍历整个链表,时间复杂度为 O(n)。但有了 rear 指针,我们可以直接访问尾部节点并修改其指针,从而使入队操作的时间复杂度也降低到 O(1)

代码实现详解

以下是用 C 语言实现队列链表结构的关键部分。

节点与队列结构定义

首先,我们定义节点结构,并声明两个全局指针 frontrear

struct Node {
    int data;
    struct Node* next;
};

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/mycodeschool-dast/img/78b545354beac9b8758688e0d4a719bc_53.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/mycodeschool-dast/img/78b545354beac9b8758688e0d4a719bc_54.png)

struct Node* front = NULL;
struct Node* rear = NULL;

入队操作

入队函数接收一个整数,在链表尾部创建一个新节点。

以下是入队操作的逻辑步骤:

  1. 为新节点动态分配内存。
  2. 判断队列是否为空:
    • 如果为空(front == NULL),则新节点既是队头也是队尾。将 frontrear 都指向新节点。
    • 如果不为空,则将当前 rear 节点的 next 指针指向新节点,然后更新 rear 指针,使其指向这个新节点。

出队操作

出队函数移除并返回队头的元素。

以下是出队操作的逻辑步骤:

  1. 首先检查队列是否为空。如果为空,则报错或返回。
  2. 创建一个临时指针 temp 保存当前 front 节点的地址。
  3. front 指针移动到下一个节点(front = front->next)。
  4. 如果出队后队列变为空(即 front 变为 NULL),则需要将 rear 也设置为 NULL
  5. 最后,通过 temp 指针释放原队头节点的内存。

其他操作

  • 获取队头元素:直接返回 front->data,前提是队列非空。
  • 判空操作:检查 front 指针是否为 NULL

总结

本节课中,我们一起学习了如何使用链表来实现队列。我们分析了数组实现的局限性,并展示了链表如何通过维护 frontrear 两个指针,使入队和出队操作都能在 O(1) 常数时间内完成。链表实现动态管理内存,避免了固定大小数组可能带来的空间浪费或扩容开销。理解这种实现方式是掌握基础数据结构的关键一步。

025:树结构入门 🌳

在本节课中,我们将介绍一种有趣的数据结构——树。这种结构在计算机科学中有广泛的应用。到目前为止,本系列课程中我们讨论的都是线性数据结构,例如链表、栈和队列。所有这些结构中的数据都是以顺序方式排列的,有逻辑上的起点和终点,每个元素可以有下一个元素和前一个元素。总而言之,它们都是线性或顺序的排列。

正如我们所理解的,数据结构是在计算机中存储和组织数据的方式。

对于不同类型的数据,我们使用不同的数据结构。我们对数据结构的选择取决于许多因素。

首先,它关乎需要存储什么。某种数据结构可能最适合特定类型的数据。其次,我们可能关心操作的成本。我们经常希望最小化最频繁执行的操作的成本。例如,假设我们有一个简单的列表,并且我们大部分时间都在列表中搜索元素,那么我们可能希望将列表或集合存储为有序数组,以便我们可以执行像二分查找这样非常快速的操作。另一个因素可能是内存消耗。有时我们可能希望最小化内存使用量。最后,我们也可能为了易于实现而选择一种数据结构,尽管这可能不是最佳策略。

树是一种经常用于表示分层数据的数据结构。例如,假设我们想展示一个组织中的员工及其在组织层级中的位置,我们可以像这样展示。假设这是某公司的组织层级。在这家公司里,John 是 CEO,John 有两个直接下属 Steve 和 Rama。然后 Steve 有三个直接下属,Steve 是 Lee、Bob 和 Ella 的经理。

他们可能有某些职位。

Rama 也有两个直接下属。然后 Bob 有两个直接下属,Tom 有一个直接下属。我在这里绘制的这个特定逻辑结构就是一棵树。你需要把这个结构倒过来看,它就会像一棵真正的树。这里的根在顶部,我们向下分支。

树数据结构的逻辑表示总是这样:根在顶部,向下分支。所以,树是存储和组织自然分层数据的一种高效方式。

但这并不是树在计算机科学中的唯一应用。

我们稍后会讨论其他应用以及一些实现细节,比如如何在计算机内存中创建这样的逻辑结构。首先,我想将树定义为一个逻辑模型。

树数据结构可以定义为一个称为节点的实体集合。

这些节点链接在一起以模拟层次结构。树是一种非线性数据结构。它是一种分层结构。树中最顶层的节点称为树的根。

每个节点将包含一些数据,这可以是任何类型的数据。在我右边展示的树中,数据是员工的姓名和职位。所以我们可以有一个包含两个字符串字段的对象,一个用于存储姓名,另一个用于存储职位。

所以每个节点将包含一些数据,并且可能包含指向其他节点的链接或引用,这些节点可以称为它的子节点。

现在我将向你介绍一些用于树数据结构的词汇。我在这里要做的是给左边树中的这些节点编号,这样我就可以用这些数字来指代这些节点。

我给这些节点编号只是为了方便,并不表示任何顺序。好的,回到正题。

正如我所说,每个节点都会有一些数据。我们可以在这些圆圈中填入一些数据。

它可以是任何类型的数据,可以是整数、字符或字符串。

或者我们可以简单地假设这些节点中填充了一些数据,只是我们没有显示出来。

好的,正如我们讨论的,一个节点可能具有指向其他节点的链接或引用,这些节点将被称为它的子节点。

这个结构中的每个箭头都是一个链接。现在,正如你所看到的,根节点(我编号为1,再次强调,这个编号不表示任何顺序,我也可以把根节点称为节点10)链接到这两个节点2和3。所以2和3将被称为1的子节点。

节点1将被称为节点2和3的父节点。

我会写下我正在谈论的所有这些术语。我们提到了根、子节点和父节点。

在树中,父节点1是2和3的父节点,2是1的子节点。现在,4、5和6是2的子节点。

所以节点2是节点1的子节点,但是节点4、5和6的父节点。

同一父节点的子节点称为兄弟节点。

我在这里用相同的颜色显示兄弟节点。

2和3是兄弟节点,然后4、5和6是兄弟节点,然后7、8是兄弟节点。

最后9和10是兄弟节点。

我希望你现在对这些术语清楚了。树中最顶层的节点称为根。根将是唯一没有父节点的节点。然后,如果一个节点有到另一个节点的直接链接。

那么节点之间就存在父子关系。

树中没有子节点的任何节点称为叶节点。

所有这些节点。在这里用黑色标记。

是叶子。所以叶子是另一个术语。

所有其他至少有一个子节点的节点可以称为内部节点。

我们还可以有更多关系,比如父节点的父节点可以称为祖父节点。

所以1是4的祖父,4是1的孙子。一般来说,如果我们能通过链接从节点A走到节点B。

记住,这些链接不是双向的。

我们有一个从1到2的链接,所以我们可以从1走到2,但不能从2走到1。

当我们遍历树时,我们只能朝一个方向走。所以,如果我们能从节点A走到节点B。

那么A可以称为B的祖先,B可以称为A的后代。

让我们选取这个编号为10的节点,1、2和5都是10的祖先。

10是所有这些节点的后代。

我们可以从这些节点中的任何一个走到10。现在,让我问你一些问题,以确保你理解了内容。4和9的共同祖先是什么?

4的祖先是1和2,9的祖先是1、2和5。

所以共同祖先是1和2。

好的,下一个问题,6和7是兄弟吗?兄弟必须有相同的父节点。

6和7没有相同的父节点,他们有相同的祖父节点。

一个是两个节点的祖父。没有相同父节点但有相同祖父节点的节点可以称为堂兄弟。

所以6和7是堂兄弟。这些关系真的很有趣。我们也可以说节点3是节点6的叔叔,因为。

因为它是2的兄弟,而2是6的父亲。

或者我应该说6的父节点。

所以我们在树的词汇中有很多术语。

好的,现在我将讨论树的一些属性。树可以称为递归数据结构。

我们可以递归地将树定义为一个结构,它由一个称为根的特定节点组成。

以及一些子树,其排列方式是树的根包含指向所有子树根的链接。

在这个图中,T1、T2和T3是子树。在我左边绘制的树中,根节点有两个子树。

我用红色显示根节点,棕色显示左子树,黄色显示右子树。

我们可以进一步拆分左子树,将其视为节点2是这个子树的根。

这个以节点2为根的特定树有三个子树。

我用三种不同的颜色显示这三个子树。

递归基本上是以自相似的方式简化事物。

树的这种递归属性将在所有树的实现和使用中无处不在。

我想讨论的下一个属性是:在一棵有n个节点的树中,将恰好有n-1条链接或边。这个图中的每个箭头都可以称为一条链接或一条边。除了根节点之外的所有节点都恰好有一条入边。如果你能看到,我选取这个节点2,这里只有一条入边。这是入边。

这三条是出边。每个父子关系都会有一条链接。所以在一棵有效的树中,如果有n个节点,将恰好有n-1条边。

除了根节点,每个节点都有一条入边。

好的,现在我想讨论这两个属性:深度和高度。

树中某个节点x的深度可以定义为从根节点到节点x的路径长度。

路径中的每条边将为长度贡献一个单位。

所以我们也可以说,从根到x的路径中的边数。

根节点的深度将为0。

让我们选取其他节点。对于这个编号为5的节点,从根节点出发的路径中有两条边。

所以这个节点的深度是2。在这棵树中,节点2和3的深度是1,节点4、5、6、7和8的深度是2,节点9和10的深度是3。

好的,现在树中节点的高度可以定义为从该节点到叶节点的最长路径中的边数。

所以某个节点x的高度将等于从x到叶节点的最长路径中的边数。在这个图中,对于节点3。

从这个节点到任何叶节点的最长路径是2,所以节点3的高度是2。节点8也是一个叶节点。

我将在这里标记所有叶节点。叶节点是子节点数为零的节点。

从节点3到任何叶节点的最长路径是2。

所以节点3的高度是2。叶节点的高度。

将为0。

那么,这棵树中根节点的高度是多少?

我们可以从根节点到达所有叶节点。最长路径中的边数是3。

所以这里根节点的高度是3。

我们也定义树的高度。树的高度定义为根节点的高度。

我这里展示的这棵树的高度是3。高度和深度是不同的属性,一个节点的高度和深度可能相同也可能不同。

我们经常混淆这两者。

根据属性,树被分为各种类别。

有不同种类的树用于不同的场景。

最简单和最常见的树是具有以下属性的树:任何节点最多可以有两个子节点。

在这个图中,节点2有三个子节点。我去掉一些节点,现在这是一棵二叉树。

二叉树是最著名的,在本系列课程中,我们将主要讨论二叉树。实现树最常见的方式是动态创建节点,并使用指针或引用链接起来,就像我们为链表所做的那样。

我们可以这样看待这棵树。在我右边绘制的结构中。

节点有三个字段。其中一个字段用于存储数据,假设中间的单元格用于存储数据。

左边的单元格用于存储左子节点的地址。

右边的单元格用于存储右子节点的地址。

因为这是一棵二叉树,我们不能有两个以上的子节点。

我们可以将其中一个子节点称为左子节点,另一个称为右子节点。

在C或C++中,我们可以像这样将节点定义为一个结构体。我们这里有三个字段。

一个用于存储数据,假设数据类型是整数。我在这些节点中填入了一些数据。所以在每个节点中,我们有三个字段。我们有一个整数变量来存储数据,然后我们有两个指向节点的指针,一个用于存储左子节点的地址(这将是左子树的根),另一个用于存储右子节点的地址。我们只保留了两个指针,因为在二叉树中我们最多只能有两个子节点。节点的这个特定定义只能用于二叉树。对于可以有任意数量子节点的通用树,我们使用其他结构,我将在后面的课程中讨论。事实上,我们将在后面的课程中详细讨论实现。这只是为了给你一个关于实现中事物将如何运作的简要概念。

好的,这很酷,我们理解了什么是树数据结构。

但在一开始我们说过,存储自然分层数据并不是树的唯一应用。

所以让我们快速看一下树在计算机科学中的一些应用。

第一个应用当然是存储自然分层数据,例如。

你磁盘驱动器上的文件系统,文件和文件夹的层次结构是自然分层的数据。

它以树的形式存储。

下一个应用是组织数据,组织集合以进行快速搜索、插入和删除。

例如,我们将在接下来几节课中大量讨论的二叉搜索树,可以为我们提供对数时间复杂度的元素搜索。

一种称为字典树的特殊树用于存储字典。

它非常快速高效,用于动态拼写检查。

树数据结构也用于网络路由算法,这个列表还有很多。

我们将在后面的课程中讨论不同种类的树及其应用。

我现在就停在这里。对于入门介绍来说,这已经足够了。

在接下来的几节课中,我们将讨论二叉搜索树及其实现。

这节课就到这里,感谢观看。

数据结构:2.1:二叉树基础 🌳

在本节课中,我们将深入学习二叉树。我们将从二叉树的一般性质开始,然后讨论一些特殊类型的二叉树,例如二叉搜索树,它是一种用于存储有序数据的高效结构。

上一节我们介绍了树这种数据结构,讨论了其逻辑模型和一些应用。本节中,我们将更详细地探讨二叉树。

正如我们在上一节所见,二叉树是一种每个节点最多只能有两个子节点的树。我们将首先讨论二叉树的一些通用性质,然后探讨一些特殊类型的二叉树。

二叉树的基本性质

在二叉树中,每个节点最多可以有两个子节点。在下图所示的树中,节点要么有零个子节点,要么有两个子节点。当然,一个节点也可以只有一个子节点。

因为二叉树中的每个节点最多可以有两个子节点,所以我们称其中一个为左子节点,另一个为右子节点

对于根节点,这个特定的节点是左子节点,而这个节点是右子节点。

一个节点可以同时拥有左子节点和右子节点,下图中的这四个节点就是如此。或者,一个节点可以只有左子节点或只有右子节点。

这个节点有左子节点但没有右子节点。我再添加一个节点。

现在这个节点有右子节点但没有左子节点。在程序中,我们会将指向左子节点的引用或指针设置为 null

因此,对于这个节点,我们可以说其左子节点为 null

同样,对于这个节点,我们可以说其右子节点为 null

对于所有没有子节点的节点(即叶节点),我们可以说它们的左子节点和右子节点都是 null

二叉树的分类

根据不同的性质,我们将二叉树分为不同的类型。

以下是几种二叉树的结构。即使一棵树只有一个节点,它也是二叉树。这个结构也是二叉树。这个同样是二叉树。请记住,唯一的条件是节点不能拥有超过两个子节点。

如果一个二叉树的每个节点要么有两个子节点,要么没有子节点,则称之为严格二叉树真二叉树

下图所示的树不是严格二叉树,因为它有两个节点只有一个子节点。我移除两个节点后,现在这就是一棵严格二叉树了。

如果一棵二叉树的所有层(除了最后一层)都被完全填满,并且所有节点都尽可能靠左排列,则称之为完全二叉树

除了最后一层,所有层都必须被填满。如果最后一层没有被完全填满,那么节点必须尽可能靠左。目前这棵树不是完全二叉树。

深度、层级与高度

节点的深度定义为从根节点到该节点的路径长度。根节点的深度为0。

在下图中,根节点位于深度0或层级0(L0)。这两个节点位于层级1(L1)。这四个节点位于层级2(L2)。最后,这两个节点位于层级3(L3)。

树中任意节点的最大深度是3。树的最大深度也等于树的高度

每层最大节点数

如果我们对树的所有层级进行编号,如 L0, L1, L2 等,那么在层级 i 上,我们最多可以拥有的节点数为 2^i

在层级0,我们最多有1个节点(2^0 = 1)。
在层级1,我们最多有2个节点。
在层级2,我们最多有4个节点(2^2 = 4)。
因此,一般来说,在任何层级 i,我们最多可以有 2^i 个节点。

这一点应该很清楚,因为每个节点可以有两个子节点。所以,如果我们在某一层有 x 个节点,那么这 x 个节点中的每一个都可以有两个子节点。因此,在下一层,我们最多可以有 2x 个子节点。

在这棵二叉树中,我们在第2层有4个节点,这是第2层的最大值。

现在,这些节点中的每一个都可能有两个子节点。我在这里只画了箭头。所以在第3层,我们最多可以有 2 * 4 = 8 个节点。

完全二叉树与完美二叉树

对于一棵完全二叉树,所有层都必须被完全填满。

我们可以对最后一层(最底层)例外,它不必是满的,但节点必须尽可能靠左。

下图所示的这棵树不是完全二叉树,因为我们在左边有两个空缺的节点位置。我对这个结构稍作修改,现在这就是一棵完全二叉树了。我们可以在第3层有更多节点,但左边不应该有空缺位置。我再添加一个节点,这仍然是一棵完全二叉树。

如果所有层都被完全填满,这样的二叉树也可以称为完美二叉树。在完美二叉树中,所有层都将被完全填满。

如果 h 是完美二叉树的高度(高度定义为从根节点到任意叶节点的最长路径上的边数),那么树的最大深度也等于 h

对于这棵二叉树,高度或最大深度是3。

节点数与高度的关系

一棵高度为 h 的树的最大节点数计算如下:第0层有 2^0 个节点,第1层有 2^1 个节点,依此类推,直到第 h 层有 2^h 个节点。

节点总数是一个等比数列求和:2^(h+1) - 1。这里的 h+1 是层数。

我们也可以说:2^(层数) - 1

在这棵树中,层数是4(L0 到 L3),所以最大节点数是 2^4 - 1 = 15。因此,一棵完美二叉树对于其高度来说拥有最大可能的节点数,因为所有层都被完全填满。

现在,我们可以问:一棵拥有 n 个节点的完美二叉树的高度是多少?

n 为完美二叉树的节点数。要找出高度 h,我们需要解方程:n = 2^(h+1) - 1

解这个方程,结果是:h = log₂(n+1) - 1

在下图所示的完美二叉树中,节点数 n 是15。n+1 是16,所以 h = log₂(16) - 1 = 4 - 1 = 3

一般来说,对于一棵完全二叉树,我们也可以将高度计算为 ⌊log₂(n)⌋,即 log₂(n) 的向下取整。

完美二叉树也是一种完全二叉树。这里 n=15log₂(15) ≈ 3.906,向下取整是3。我们稍后会尝试理解为什么完全二叉树的高度是 log₂(n)

高度对操作成本的影响

这些数学关系在我们分析二叉树各种操作的成本时将非常有帮助。

树上许多操作的时间成本取决于树的高度。

例如,在二叉搜索树(一种特殊的二叉树)中,搜索、插入或删除一个元素的时间成本与树的高度成正比。因此,在这种情况下,我们希望树的高度尽可能小。

如果树是密集的,即接近完美二叉树或完全二叉树,那么树的高度就会更小。

拥有 n 个节点的树的最小高度是 ⌊log₂(n)⌋,此时树是一棵完全二叉树。

如果我们有像下图这样的排列,那么树在拥有 n 个节点时将具有最大高度。

最小可能高度是 ⌊log₂(n)⌋
最大可能高度是 n-1,此时我们得到一棵像链表一样的稀疏树。

现在思考一下,如果我说一个操作所花费的时间与树的高度成正比,或者说一个操作的时间复杂度是 O(h),其中 h 是二叉树的高度。

那么,对于一棵完全或完美二叉树,我的时间复杂度将是 O(log₂ n)

而在最坏情况下,对于这种稀疏树,我的时间复杂度将是 O(n)

O(log n) 的阶数几乎是 n 高达 2^100 时可能的最佳运行时间(log₂(2^100) = 100)。而对于 O(n) 的运行时间,如果 n 是 2^100,即使使用有史以来最强大的机器,我们也无法在数年内完成计算。

因此,关键点在于:我们经常希望保持二叉树的高度尽可能小,或者更常见地说,我们尝试保持二叉树的平衡

平衡二叉树

如果对于每个节点,其左子树和右子树的高度差不超过某个数 k(通常 k 为1),我们称这棵二叉树为平衡二叉树。也就是说,对于每个节点,左子树和右子树的高度差不应超过1。

关于树的高度,我想说明一点。我们之前将高度定义为从根节点到叶节点的最长路径上的边数。只有单个节点(该节点本身就是叶节点)的树高度为0。我们可以将空树(没有节点的树)的高度定义为 -1

因此,空树的高度是 -1

所以,只有一个节点的树高度是0,空树高度是-1。通常人们将高度计算为从根节点到叶节点的最长路径上的节点数。在下图中,我画出了从根节点到叶节点的最长路径之一。这条路径上有3条边,所以高度是3。如果我们计算路径上的节点数,高度将是4。这看起来非常直观,我在很多地方都见过这种高度定义。如果计算节点数,只有一个节点的树的高度将等于1,那么我们可以说空树的高度是0。但这不是正确的定义,我们不会使用这个假设。我们将说空树的高度是-1,只有一个节点的树高度是0。

一个节点的左子树和右子树的高度差可以计算为 |h_left - h_right|(绝对差值)。在这个计算中,子树的高度也可以是-1。

在下图中,对于这个叶节点,其左子树和右子树都是空的。所以 h_left(左子树高度)和 h_right(右子树高度)都是-1,但总体差值为0。

对于完美树中的所有节点,差值都是0。我在这棵树中去掉了一些节点,现在我在每个节点旁边写下了差值。这仍然是一棵平衡二叉树,因为任何节点的最大差值是1。让我们去掉这棵树中的一些节点,现在它不平衡了,因为其中一个节点的差值是2。

对于这个特定节点,左子树高度是1,右子树高度是-1(因为右子树为空)。所以绝对差值是2。

我们尝试保持树的平衡,以确保它密集且高度最小化。如果高度最小化,那些依赖于高度的各种操作的成本也就最小化了。

接下来,我想简要谈谈如何在内存中存储二叉树。

二叉树的存储方式

我们在上一课中看到的一种最常用的方式是:动态创建节点,并使用指针或引用将它们彼此链接起来。

对于存储整数的二叉树,在C/C++中,我们可以这样定义一个节点:

数据类型是整数,所以我们有一个字段来存储数据,并且有两个指针变量:一个存储左子节点的地址,另一个存储右子节点的地址。

当然,这是最常见的方式:节点在内存中随机位置动态创建,通过指针链接在一起。

但在某些特殊情况下,我们也使用数组。数组通常用于完全二叉树。

我在这里画了一棵完美二叉树。假设这是一棵整数树。我们可以做的是:从根节点开始,按层级从左到右为这些节点编号,如 0, 1, 2, 3, 4, 5, 6。

现在,我可以创建一个包含7个整数的数组,这些数字可以用作这些节点的索引。在第0个位置,我填入2;在第1个位置,我填入4;在第2个位置,我填入1;依此类推。

我们已经将所有数据填入数组,但如何存储链接信息呢?我们如何知道根的左子节点值为4,右子节点值为1?

在完全二叉树的情况下,如果我们像这样对节点编号,那么对于索引为 i 的节点:

其左子节点的索引将是 2i + 1,右子节点的索引将是 2i + 2。请记住,这仅适用于完全二叉树。

对于索引0,左子节点是 20 + 1 = 1,右子节点是 20 + 2 = 2。
对于索引1,左子节点在索引3,右子节点在索引4。
对于索引2,左子节点在索引5,右子节点在索引6。

当我们讨论一种称为的特殊二叉树(用于实现优先队列)时,我们将详细讨论其实现。

本节课就到这里。在下一课中,我们将讨论二叉搜索树,它也是一种特殊的二叉树,为我们提供了一种高效的存储结构,可以快速搜索和更新数据。

感谢观看。

总结

在本节课中,我们一起学习了二叉树的基础知识。我们首先回顾了二叉树的定义,即每个节点最多有两个子节点。然后,我们详细探讨了二叉树的性质,包括节点的左/右子节点、叶节点等概念。

接着,我们根据性质对二叉树进行了分类,介绍了严格二叉树完全二叉树完美二叉树。我们学习了如何计算树的深度层级高度,并推导了每层最大节点数(2^i)以及总节点数与高度的关系(对于完美二叉树,n = 2^(h+1)-1)。

我们重点讨论了树的高度对操作效率的影响,指出许多操作(如二叉搜索树中的搜索)的时间复杂度与树的高度成正比(O(h))。因此,保持树的高度最小化至关重要,这引出了平衡二叉树的概念,即每个节点的左右子树高度差不超过1。

最后,我们介绍了二叉树的两种存储方式:最常见的动态节点链接(使用指针)和适用于完全二叉树的数组存储,并给出了数组表示中父子节点索引的计算公式(左子:2i+1,右子:2i+2)。

理解这些基础知识是学习更高级树形结构(如二叉搜索树、堆、AVL树等)的关键。在下一课中,我们将深入探讨二叉搜索树这一高效的数据结构。

027:二叉搜索树 🎯

在本节课中,我们将要学习一种特殊的二叉树——二叉搜索树。它是一种高效的数据结构,能够支持快速的数据搜索和更新操作。我们将从理解其必要性开始,逐步探讨其定义、性质以及核心操作的时间复杂度。

上一节我们介绍了二叉树的基本概念,本节中我们来看看一种应用广泛的特殊二叉树。

为什么需要二叉搜索树?🤔

假设你需要存储一个可修改的数据集合(例如一组整数),并希望高效地执行以下操作:

  • 搜索:快速查找集合中是否存在某个记录。
  • 插入:向集合中添加一个新记录。
  • 删除:从集合中移除一个记录。

你会选择什么数据结构?以下是两种常见选择及其操作的时间复杂度分析。

使用数组

如果使用一个足够大的数组来存储集合,并标记列表的末尾,各操作的时间复杂度如下:

  • 搜索:在最坏情况下,需要遍历整个数组。时间复杂度为 O(n)
  • 插入:如果数组未满,只需在末尾添加元素并更新标记。时间复杂度为 O(1)。但如果数组已满,需要创建新数组并复制数据,此时时间复杂度为 O(n)
  • 删除:删除元素后,需要将其右侧所有元素向左移动一位以保持连续性。在最坏情况下,时间复杂度为 O(n)

使用链表

如果使用链表来存储集合,各操作的时间复杂度如下:

  • 搜索:在最坏情况下,需要遍历整个链表。时间复杂度为 O(n)
  • 插入:在链表头部插入,时间复杂度为 O(1)
  • 删除:需要先找到要删除的节点,在最坏情况下需要遍历整个链表。时间复杂度为 O(n)

现有方案的局限性

对于搜索操作,O(n) 的时间复杂度在处理大规模数据时可能成为瓶颈。例如,如果一次比较耗时 10^-6 秒,在包含1亿条记录的集合中搜索,最坏情况可能需要100秒,这通常是不可接受的。

我们能否做得更好?如果使用有序数组,我们可以利用二分查找算法将搜索的时间复杂度降至 O(log n)。对于1亿条数据,最多只需约27次比较,速度极快。

然而,有序数组在插入和删除时,为了保持有序性,需要进行大量的元素移动,这两个操作的时间复杂度仍然是 O(n)

因此,我们需要一种数据结构,能同时支持快速的搜索、插入和删除操作。这就是二叉搜索树要解决的问题。

什么是二叉搜索树?🌲

二叉搜索树是一种特殊的二叉树,它满足以下性质:
对于树中的任意一个节点

  1. 左子树中所有节点的值都小于或等于该节点的值。
  2. 右子树中所有节点的值都大于该节点的值。
  3. 其左、右子树也分别是二叉搜索树。

这个性质是递归定义的。用伪代码可以描述其核心约束:

对于任意节点 node:
    node.left 子树中的所有值 <= node.value < node.right 子树中的所有值

下图展示了一个有效的二叉搜索树示例:

        15
       /  \
      10   20
     / \   / \
    8  12 17  25
  • 根节点15:左子树(8,10,12)均≤15,右子树(17,20,25)均>15。
  • 节点10:左孩子8≤10,右孩子12>10。
  • 节点20:左孩子17≤20,右孩子25>20。

如果我们将节点12改为16,那么对于根节点15,其左子树中出现了大于15的值(16),这违反了二叉搜索树的性质,因此就不再是有效的二叉搜索树。

二叉搜索树的操作效率 ⚡

在平衡的二叉搜索树中,搜索、插入和删除操作的平均时间复杂度都可以达到 O(log n)。这是如何实现的呢?

搜索操作

搜索过程与有序数组的二分查找思想类似:

  1. 从根节点开始。
  2. 将目标值与当前节点值比较:
    • 如果相等,搜索成功。
    • 如果目标值小于当前节点值,根据BST性质,目标只可能存在于左子树中,于是进入左子树继续搜索。
    • 如果目标值大于当前节点值,则进入右子树继续搜索。
  3. 重复步骤2,直到找到目标或到达空节点(未找到)。

每一次比较,我们都丢弃了当前节点的其中一个子树(将近一半的搜索空间)。在平衡的树中,这导致搜索空间呈指数级缩小(n, n/2, n/4, ...),经过大约 log₂n 步后,搜索空间会缩小到1。因此时间复杂度为 O(log n)

插入操作

插入新节点前,需要先找到正确的插入位置。这个过程与搜索类似:

  1. 从根节点开始,比较待插入值与当前节点值。
  2. 根据比较结果决定向左或向右子树移动,直到到达一个空位置(即某个节点的左孩子或右孩子为空)。
  3. 在该空位置创建新节点并建立链接。

由于查找位置的过程是 O(log n),而创建节点和建立链接是 O(1),因此整体插入操作的平均时间复杂度也是 O(log n)与数组不同,这里不需要移动任何现有元素

删除操作

删除操作稍复杂一些,但基本步骤是:

  1. 搜索要删除的节点(O(log n))。
  2. 根据该节点的子节点情况(无子节点、有一个子节点、有两个子节点)进行不同的链接调整。
  3. 调整链接的操作是 O(1)

因此,删除操作的平均时间复杂度同样是 O(log n)

平衡的重要性 ⚖️

上述 O(log n) 的效率是基于二叉搜索树是平衡的这一前提。一棵树是平衡的,粗略地说,是指其左右子树的高度相差不大。

考虑同样一组数据 {8, 10, 12, 15, 17, 20, 25},可以构造出不同的BST:

  • 平衡的BST(最佳情况):如上文示例,操作效率为 O(log n)
        15
       /  \
      10   20
     / \   / \
    8  12 17  25
    
  • 不平衡的BST(最坏情况):如果数据按顺序插入(如8,10,12,15,...),树会退化成一条链。
        8
         \
          10
           \
            12
             \
              15
               \
                17
                 \
                  20
                   \
                    25
    
    在这种情况下,搜索、插入、删除都退化为链表的遍历操作,时间复杂度变为 O(n)

因此,在实际使用中,我们需要通过算法(如AVL树、红黑树)来维护二叉搜索树的平衡性,从而避免最坏情况,保证操作的高效性。我们将在后续课程中详细讨论这些平衡技术。

总结 📚

本节课我们一起学习了二叉搜索树的核心知识:

  1. 动机:为了解决有序数组插入/删除慢,以及链表/无序数组搜索慢的问题,我们需要一种能同时支持快速搜索、插入和删除的数据结构。
  2. 定义:二叉搜索树是一种二叉树,其中任意节点的左子树所有值 ≤ 节点值 < 右子树所有值。
  3. 操作效率:在平衡的二叉搜索树中,搜索、插入和删除操作的平均时间复杂度均为 O(log n)
  4. 关键思想:利用树的结构和节点值的排序性质,在每一步操作中都能丢弃大约一半的搜索空间,这是其高效的根本原因。
  5. 注意事项:必须维护树的平衡性,否则在最坏情况下(树退化为链表),时间复杂度会恶化到 O(n)

下一节课,我们将深入探讨二叉搜索树的具体代码实现。

028:二叉搜索树 - C/C++ 实现 🧑‍💻

在本节课中,我们将学习如何用 C/C++ 语言实现二叉搜索树。我们将编写代码来创建树、插入节点以及搜索数据。学习本课的前提是,您需要理解 C/C++ 中的指针和动态内存分配概念。如果您已经学习了本系列关于链表的课程,那么实现二叉搜索树(或一般的二叉树)将不会有太大不同。

概述

上一节我们介绍了二叉搜索树是什么。本节中,我们将动手实现它。我们将使用动态创建并通过指针链接的节点来构建这种非线性逻辑结构,这与链表的实现方式非常相似。

二叉搜索树节点结构

在二叉搜索树(或一般的二叉树)中,每个节点最多可以有两个子节点。因此,我们可以将节点定义为一个包含三个字段的对象。

以下是节点的定义方式:

  • 一个字段用于存储数据。
  • 一个字段用于存储指向左子节点的地址(指针)。
  • 一个字段用于存储指向右子节点的地址(指针)。

如果某个节点没有左子节点或右子节点,相应的指针应设置为 NULL

在 C/C++ 中,我们可以这样定义节点结构体:

struct BSTNode {
    int data;           // 存储数据,此处以整型为例
    BSTNode* left;      // 指向左子节点的指针
    BSTNode* right;     // 指向右子节点的指针
};

这个节点定义与双向链表的节点定义非常相似。区别在于,双向链表是线性排列,而二叉树是非线性的。

树的标识:根节点指针

与链表需要记录头节点地址类似,对于树,我们需要始终记录根节点的地址。

因此,我们需要声明一个指向 BSTNode 的指针变量来存储根节点的地址。通常,我们将其命名为 rootrootPtr

BSTNode* rootPtr; // 指向根节点的指针

初始时,树是空的,因此我们将这个根指针设置为 NULL,表示一棵空树。

rootPtr = NULL; // 初始化为空树

创建新节点的辅助函数

在插入节点之前,我们需要一个函数来在堆内存中动态创建新节点。

以下是 getNewNode 函数的实现:

BSTNode* getNewNode(int data) {
    BSTNode* newNode = new BSTNode(); // 在C++中使用new,C中使用malloc
    newNode->data = data;             // 设置节点数据
    newNode->left = NULL;             // 左子节点初始化为空
    newNode->right = NULL;            // 右子节点初始化为空
    return newNode;                   // 返回新节点的地址
}

向树中插入节点

现在,我们来编写核心的插入函数 insert。该函数接收当前(子)树的根节点地址和要插入的数据,并将新节点插入到正确的位置,最后返回更新后的(子)树根节点地址。

插入逻辑需要考虑以下几种情况:

  1. 如果树为空(根节点为 NULL),则直接创建新节点作为根节点。
  2. 如果树不为空,则比较要插入的数据与当前根节点的数据:
    • 如果数据小于或等于当前节点数据,则递归地将其插入到左子树中。
    • 如果数据大于当前节点数据,则递归地将其插入到右子树中。

以下是 insert 函数的实现:

BSTNode* insert(BSTNode* rootPtr, int data) {
    // 情况1:树为空
    if(rootPtr == NULL) {
        rootPtr = getNewNode(data);
    }
    // 情况2:数据小于等于当前节点,插入左子树
    else if(data <= rootPtr->data) {
        rootPtr->left = insert(rootPtr->left, data);
    }
    // 情况3:数据大于当前节点,插入右子树
    else {
        rootPtr->right = insert(rootPtr->right, data);
    }
    return rootPtr; // 返回当前(子)树的根节点指针
}

注意:由于 rootPtr 是函数内的局部指针变量,为了在 main 函数中更新真正的根指针,我们采用了返回新根节点地址的方式。在 main 函数中调用时,需要这样写:

rootPtr = insert(rootPtr, 15); // 插入数据15,并更新根指针

在树中搜索数据

接下来,我们实现一个搜索函数 search。该函数接收树的根节点地址和要查找的数据,如果找到数据则返回 true,否则返回 false

搜索逻辑如下:

  1. 如果到达空节点(NULL),说明未找到,返回 false
  2. 如果当前节点数据等于要查找的数据,说明已找到,返回 true
  3. 否则,根据要查找的数据与当前节点数据的大小关系,递归地在左子树或右子树中继续搜索。

以下是 search 函数的实现:

bool search(BSTNode* rootPtr, int data) {
    if(rootPtr == NULL) {
        return false; // 未找到
    }
    else if(rootPtr->data == data) {
        return true;  // 找到
    }
    else if(data <= rootPtr->data) {
        return search(rootPtr->left, data); // 在左子树中搜索
    }
    else {
        return search(rootPtr->right, data); // 在右子树中搜索
    }
}

主函数示例

最后,我们在 main 函数中整合以上操作,构建一棵树并进行搜索。

#include <iostream>
using namespace std;

// ... (此处放置之前定义的 struct BSTNode, getNewNode, insert, search 函数)

int main() {
    BSTNode* rootPtr = NULL; // 创建一棵空树

    // 插入一系列数据
    rootPtr = insert(rootPtr, 15);
    rootPtr = insert(rootPtr, 10);
    rootPtr = insert(rootPtr, 20);
    rootPtr = insert(rootPtr, 25);
    rootPtr = insert(rootPtr, 8);
    rootPtr = insert(rootPtr, 12);

    // 搜索数据
    int number;
    cout << "Enter number to be searched: ";
    cin >> number;

    if(search(rootPtr, number) == true) {
        cout << "Found\n";
    }
    else {
        cout << "Not Found\n";
    }

    return 0;
}

总结

本节课中,我们一起学习了二叉搜索树在 C/C++ 中的基本实现。我们首先定义了树的节点结构,然后实现了创建新节点、插入节点和搜索数据的函数。实现的关键在于理解递归在树操作中的自然应用,以及如何通过指针在堆内存中动态构建和链接节点结构。在接下来的课程中,我们将更深入地探讨树的其他操作和内存管理细节。

029:BST实现 - 栈与堆中的内存分配

概述

在本节课中,我们将深入探讨二叉搜索树(BST)的实现,并重点关注程序执行时,内存的栈和堆区域是如何工作的。我们将通过代码示例,一步步分析函数调用、递归以及动态内存分配在内存中的具体表现。


回顾与引入

上一节我们编写了二叉搜索树的代码,实现了插入和搜索功能。本节中,我们将更深入地理解当这些函数执行时,应用程序内存的不同部分(特别是栈和堆)是如何运作的。这将帮助你清晰地理解程序的内存管理机制,以及树结构中频繁使用的递归是如何工作的。

需要说明的是,本节课讨论的概念在我们之前的课程中已有涉及,但在实现树结构时重温这些概念将大有裨益。

以下是我们在上一节编写的核心代码:

// 创建新节点的函数
BSTNode* GetNewNode(int data) {
    BSTNode* newNode = new BSTNode();
    newNode->data = data;
    newNode->left = newNode->right = NULL;
    return newNode;
}

// 向BST插入节点的函数
BSTNode* Insert(BSTNode* root, int data) {
    if(root == NULL) {
        root = GetNewNode(data);
    }
    else if(data <= root->data) {
        root->left = Insert(root->left, data);
    }
    else {
        root->right = Insert(root->right, data);
    }
    return root;
}

// 在BST中搜索数据的函数
bool Search(BSTNode* root, int data) {
    if(root == NULL) return false;
    else if(root->data == data) return true;
    else if(data <= root->data) return Search(root->left, data);
    else return Search(root->right, data);
}

// 主函数
int main() {
    BSTNode* root = NULL;
    root = Insert(root, 15);
    root = Insert(root, 10);
    root = Insert(root, 20);
    root = Insert(root, 25);
    // ... 搜索操作
    return 0;
}

(你可以在视频描述中找到完整的源代码链接。)


程序内存布局

一个程序执行时,其内存通常被划分为四个段:

  1. 代码段:存储程序的所有机器语言指令。
  2. 全局数据段:存储所有全局变量(在函数外部声明的变量)。
  3. :作为函数调用的“草稿空间”,所有局部变量(在函数内部声明的变量)都存放在这里。
  4. :也称为自由存储区,是动态内存区域,可以在运行时增长或收缩。

所有其他段的大小在编译时即已固定,唯独堆的大小可以在运行时变化。我们无法在运行时控制其他段的内存分配与释放,但可以控制堆内存的分配与释放(使用 new/deletemalloc/free)。关于动态内存分配的详细讨论,可以参考相关课程。

为了聚焦,我们将重点放在栈和堆这两个区域,并将它们视为两个容器。


内存分配过程详解

让我们一步步跟踪程序执行时,栈和堆中发生的变化。

第一步:主函数调用

程序开始执行时,首先调用 main 函数。每当一个函数被调用时,都会从栈中分配一块内存用于其执行,这块内存称为该函数调用的栈帧。函数的所有局部变量和执行状态都存储在其栈帧中。

main 函数中,我们有一个局部变量 root,它是一个指向 BSTNode 的指针。我们在栈帧中表示它。执行第一行代码 BSTNode* root = NULL; 时,我们将 root 初始化为 NULL(即地址0)。

第二步:首次插入调用

接下来,我们执行 root = Insert(root, 15);。此时会发生以下情况:

  • main 函数的执行在此处暂停。
  • Insert 函数的调用分配一个新的栈帧。
  • main 函数等待这个 Insert 调用完成并返回。

对于这个 Insert 调用,其局部变量 root(参数)接收到的值是 NULL(0),data 接收到的值是 15。由于 rootNULL,程序进入第一个 if 条件。

第三步:创建新节点

if 块内,我们调用 GetNewNode(15)。于是:

  • 当前 Insert 调用的执行暂停。
  • GetNewNode 函数分配一个新的栈帧。

GetNewNode 函数中,我们使用 new 操作符在上创建一个新的 BSTNode。假设这个新节点被分配在地址 200new 操作符返回这个地址,并将其赋值给局部变量 newNode。然后,我们设置该节点的 data 字段为 15leftright 指针为 NULL

GetNewNode 函数返回地址 200 后,其栈帧被回收(释放)。

第四步:完成首次插入

控制权返回到暂停的 Insert 调用。GetNewNode 的返回值 200 被赋给 Insert 的局部变量 root。然后,这个 Insert 调用也返回 root 的值(即 200),其栈帧被回收。

最后,控制权返回到 main 函数。Insert 的返回值 200 被赋给 main 的局部变量 root。至此,树中有了第一个节点(地址 200,数据为 15)。

第五步:插入第二个节点(递归演示)

接着执行 root = Insert(root, 10);。此时 main 中的 root200,因此这个值作为参数传递给新的 Insert 调用。

在这个新的 Insert 调用中:

  • 局部变量 root200(指向堆中值为15的节点),data10
  • 由于 10 <= 15,程序进入 else if 块,执行 root->left = Insert(root->left, data);
  • 这里发生了递归调用root->left 当前是 NULL,所以新的 Insert 调用接收到的 root 参数是 NULLdata10

对于这个递归的 Insert 调用,其过程与首次插入类似:

  1. 因为 rootNULL,它调用 GetNewNode(10)
  2. GetNewNode 在堆上创建一个新节点,假设地址为 150,数据为 10
  3. 递归的 Insert 调用返回地址 150

控制权返回到上一层的 Insert 调用,它将返回值 150 赋值给 root->left(即地址 200 节点的左指针)。这样,值为 10 的节点就成为了值为 15 的节点的左孩子。

第六步:更复杂的递归插入

插入 20 的过程与插入 10 类似,但会进入右子树。插入 25 的过程则更能体现递归的深度:

  1. root(200)开始,25 > 15,进入右子树递归。
  2. 在节点 20(地址300)处,25 > 20,再次进入右子树递归。
  3. 此时右子树为空(NULL),递归调用到达基础情况,创建新节点(假设地址 100,数据 25)。
  4. 递归逐层返回,将新节点的地址依次设置到相应父节点的右指针上。

栈与堆的核心特点

通过以上过程,我们可以总结出栈和堆的几个关键区别:

以下是栈内存的主要特点:

  • 自动管理:栈帧在函数调用时自动分配,在函数返回时自动回收。内存的分配和释放遵循后进先出(LIFO)顺序。
  • 生命周期:局部变量的生命周期与其所在的函数调用周期相同。
  • 大小固定:栈的大小通常有限。

以下是堆内存的主要特点:

  • 手动控制:必须显式地使用 new(C++)或 malloc(C)来分配内存,并使用 deletefree 来释放内存。
  • 生命周期灵活:在堆上分配的内存会一直存在,直到程序结束或显式释放它。这允许我们创建在函数调用结束后依然存在的数据结构(如链表、树)。
  • 大小可变:堆内存池可以在运行时增长,只受系统总内存限制。

非线性结构在内存中的线性存储

虽然树在逻辑上是一个非线性结构,但计算机内存本质上是线性的。我们通过节点中的指针(地址),在物理上线性排列的堆内存中,建立了逻辑上的非线性链接关系。这正是指针和动态内存分配的强大之处。


总结

本节课我们一起深入分析了二叉搜索树实现过程中,内存栈和堆的工作机制。我们了解到:

  1. 用于管理函数调用和局部变量,其分配和回收是自动的、快速的。
  2. 用于动态分配内存,允许我们创建大小可变、生命周期可控的数据节点,这对于实现树、链表等数据结构至关重要。
  3. 递归本质上就是函数调用自身,每次调用都会在栈上获得独立的帧,并通过返回值在调用链中传递信息,从而构建出复杂的数据关系。

理解这些内存管理的基本概念,对于编写正确、高效的数据结构代码和调试程序至关重要。在接下来的课程中,我们将运用这些知识来解决更多关于树的问题。

030:在二叉搜索树中查找最小与最大值 🔍

在本节课中,我们将学习如何在二叉搜索树中查找最小和最大元素。我们将探讨两种不同的实现方法:迭代法和递归法,并通过清晰的步骤和代码示例来理解其背后的逻辑。


概述

在之前的课程中,我们编写了二叉搜索树的一些基础代码。为了巩固概念,我们需要编写更多代码。本节选取了一个简单的问题:给定一个二叉搜索树,找出其中的最小和最大元素。

让我们看看如何解决这个问题。上图展示了一个整数二叉搜索树的逻辑结构。我们知道,在二叉搜索树中,对于所有节点,其左子树中所有节点的值都小于该节点,右子树中所有节点的值都大于该节点。在C/C++中,我们可以用一个包含三个字段的结构体来定义节点:一个存储数据,一个存储左子节点的地址,另一个存储右子节点的地址。在BST的实现中,我们通常持有并传递给函数的树的标识是根节点的地址。

因此,这里我想首先编写一个名为 find_min 的函数,它接收根节点的地址作为参数,并返回树中的最小元素。




同样地,我们可以编写另一个名为 find_max 的函数来返回BST中的最大元素。让我们先看看如何找到最小元素。


查找最小元素:迭代法 🔄

有两种可能的方法:我们可以编写一个使用简单循环的迭代解决方案,或者使用递归。首先看看迭代解法。

如果我们有一个指向根节点的指针,并且想要找到BST中的最小元素,那么我们需要从根节点开始,沿着左链接尽可能地向左移动。因为在BST中,对于所有节点,左子节点的值较小,右子节点的值较大。我们可以用一个临时指针(例如命名为 current)指向根节点开始。

以下是实现步骤:

  1. 检查树是否为空。如果为空,可以返回一个错误值(例如-1)。
  2. 使用一个 while 循环,只要当前节点的左子节点不为空,就将指针移动到其左子节点。
  3. 当无法再向左移动时,当前指针指向的节点就是最小节点,返回其数据。

代码实现如下:

int find_min_iterative(struct BSTNode* root) {
    if (root == NULL) {
        printf("错误:树为空。\n");
        return -1; // 假设树中只有正数,用-1表示错误
    }
    while (root->left != NULL) {
        root = root->left;
    }
    return root->data;
}

在这个例子中,我们从值为15的节点开始。它有左子节点10,所以我们移动到节点10。节点10有左子节点8,所以我们移动到节点8。节点8没有左子节点,循环结束,我们返回节点8的数据,即最小值。







修改函数内的局部变量 root 不会影响主函数或其他调用函数中的根节点指针。


查找最小元素:递归法 🔁

现在,让我们看看如何使用递归来查找最小元素。

如果我们想以递归的、自相似的方式简化这个问题,可以这样思考:

  • 如果左子树不为空,那么问题可以简化为在左子树中查找最小值。
  • 如果左子树为空,那么当前节点就是最小值,因为右子树中的值不可能更小。

递归的基准条件是当左子节点为空时。以下是递归实现的逻辑:

  1. 如果根节点为空(树为空),抛出错误。
  2. 否则,如果根节点的左子节点为空,返回根节点的数据(这就是最小值)。
  3. 否则(左子树非空),递归地在左子树中查找最小值。

代码实现如下:

int find_min_recursive(struct BSTNode* root) {
    if (root == NULL) {
        printf("错误:树为空。\n");
        return -1;
    }
    else if (root->left == NULL) {
        return root->data; // 基准条件:没有左子节点,当前节点最小
    }
    else {
        // 递归条件:在左子树中继续查找
        return find_min_recursive(root->left);
    }
}


如果你理解了之前编写的在BST中插入节点的递归方法,那么这个递归应该不难理解。


查找最大元素

查找最大元素的逻辑与查找最小元素非常相似。

  • 迭代法:不是一直向左移动,而是一直向右移动,直到无法再向右为止。最后访问的节点就是最大值。
  • 递归法:检查右子树。如果右子树为空,则当前节点是最大值;否则,递归地在右子树中查找最大值。

具体的实现留作练习,你可以参考查找最小元素的代码进行修改。





总结

在本节课中,我们一起学习了如何在二叉搜索树中查找最小和最大元素。我们探讨了两种方法:

  1. 迭代法:通过循环沿着左(找最小)或右(找最大)链接遍历树,直到到达叶子节点。
  2. 递归法:将问题分解为更小的子问题(在左子树或右子树中查找),并定义清晰的基准条件来终止递归。

这两种方法都充分利用了二叉搜索树的关键性质:左子树的值 < 根节点的值 < 右子树的值。理解这些基本操作是掌握更复杂树形算法的重要基础。在接下来的课程中,我们将解决更多关于BST的有趣问题。

031:计算二叉树的高度 🌳

在本节课中,我们将学习如何编写代码来计算二叉树的高度,也称为二叉树的最大深度。我们将首先回顾树的高度和深度的基本概念,然后通过递归方法实现一个计算高度的函数,并分析其时间复杂度。

高度与深度的概念回顾

上一节我们介绍了树的基本概念,本节中我们来看看如何具体计算二叉树的高度。首先,我们需要明确树的高度和深度的定义。

高度定义为从根节点到最远叶子节点的路径上的边数。深度则定义为从根节点到该节点的路径上的边数

以下是一个二叉树的示例,节点已编号以便说明:

  • 节点3、4、6、7、8和9是叶子节点(没有子节点的节点)。
  • 从根节点(节点1)到叶子节点8或9的路径包含3条边。因此,这棵树的高度是3。
  • 一个节点的高度定义为从该节点到其最远叶子节点的路径边数。因此:
    • 节点3的高度是1。
    • 节点2的高度是2。
    • 节点1(根节点)的高度是3,这也是整棵树的高度。
  • 叶子节点的高度为0。如果一棵树只有一个节点,那么这棵树的高度就是0。
  • 深度是节点到根节点的距离。例如:
    • 节点2的深度是1,高度是2。
    • 节点9的深度是3,高度是0。
    • 根节点的深度是0,高度是3。

核心关系:树的高度等于树中所有节点的最大深度

计算高度的递归方法

理解了基本概念后,现在我们来探讨如何通过编程计算二叉树的高度。核心思想是使用递归。

对于树中的任何一个节点,其高度可以通过其左右子树的高度来计算。逻辑如下:

  1. 计算左子树的高度。
  2. 计算右子树的高度。
  3. 该节点的高度等于左右子树高度的较大值再加1(加上的1代表连接该节点到其子节点的边)。

公式表示:
height(node) = max(height(node.left), height(node.right)) + 1

对于上图中的根节点(节点1):

  • 左子树(以节点2为根)的高度是2。
  • 右子树(以节点3为根)的高度是1。
  • 因此,根节点的高度 = max(2, 1) + 1 = 3。

递归函数的实现与边界条件

我们将实现一个名为 findHeight 的函数,它接收二叉树的根节点作为参数,并返回树的高度。

以下是实现这个递归逻辑需要考虑的步骤:

首先,我们需要定义递归的基线条件(即递归终止条件),以防止无限递归。当我们递归到一个空子树(即 node == NULL)时,应该返回什么值?

考虑一个叶子节点(例如节点7)。对于它的递归调用,其左右子节点都是 NULL。我们希望叶子节点的高度计算为0。

  • 如果对 NULL 的调用返回0,那么根据公式 max(0, 0) + 1,叶子节点的高度会被计算为1,这与定义(高度为0)不符。
  • 因此,一个常见的约定是:空树的高度定义为 -1。这样,公式 max(-1, -1) + 1 的结果正好是0,符合叶子节点高度为0的定义。

以下是 findHeight 函数的伪代码:

int findHeight(Node* root) {
    if (root == NULL) {
        return -1; // 空树的高度为-1
    }
    int leftHeight = findHeight(root->left);
    int rightHeight = findHeight(root->right);
    return max(leftHeight, rightHeight) + 1;
}

注意:有些定义将高度视为从根到叶子的节点数。如果采用那种定义,叶子节点高度为1,空树高度为0。此时,基线条件应返回0,公式不变。但本教程采用更常见的边数定义。

时间复杂度分析

最后,我们来分析一下这个算法的时间复杂度。

该函数会访问二叉树中的每一个节点恰好一次。每个节点的处理操作(计算左右子树高度、取最大值、加1)都是常数时间 O(1)

因此,如果树中共有 n 个节点,那么 findHeight 函数的总时间复杂度为 O(n)

总结

本节课中我们一起学习了二叉树高度的计算。

  • 我们明确了高度(从节点到最远叶子的边数)和深度(从根到节点的边数)的定义与区别。
  • 我们掌握了计算高度的核心递归思想:节点高度 = max(左子树高度, 右子树高度) + 1
  • 我们实现了 findHeight 函数,并确定了关键的基线条件:空树返回 -1,以确保叶子节点高度为0。
  • 我们分析了该算法的时间复杂度为 O(n),其中 n 是树中的节点数。

通过本课的学习,你已经掌握了使用递归计算二叉树高度的基本方法,这是理解和操作二叉树结构的重要基础。

032:广度优先与深度优先策略 🌳

在本节课中,我们将要学习二叉树的遍历。当我们处理树结构时,经常需要访问树中的所有节点。

树不像数组或链表那样是线性数据结构。在线性数据结构中,存在逻辑上的起点和终点,因此我们可以从一端开始,将指针不断移向另一端。

对于像链表这样的线性数据结构,每个节点或元素只有一个后继元素。但树是非线性数据结构。这里我画了一棵二叉树,节点中填充了字符数据。在树中,当我们指向某个特定节点时,可能会有多个可能的方向和多个可能的后继节点。

例如,在这棵二叉树中,如果我们从根节点 F 开始,有两个可能的方向:向左到 D,或者向右到 J。如果我们选择了一个方向,那么之后我们必须以某种方式返回,再去往另一个方向。

因此,树的遍历并非直截了当。本节课我们将讨论树遍历的算法。树遍历可以正式定义为:以某种顺序访问树中每个节点恰好一次的过程。对我们而言,“访问”一个节点意味着读取或处理该节点中的数据,在本节课中,特指打印节点中的数据。

根据访问节点的顺序,树遍历算法大致可以分为两类:广度优先遍历和深度优先遍历。

广度优先遍历与深度优先遍历 🧭

广度优先遍历和深度优先遍历是遍历或搜索图(Graph)的通用技术。图是一种数据结构,我们目前尚未在本系列中讨论图,将在后续课程中介绍。现在只需知道,树是图的一种特殊形式。本节课我们将在树的背景下讨论这两种遍历策略。

在广度优先方法中,我们会先访问同一深度或层级的所有节点,然后再访问下一层级的节点。

在我展示的这棵二叉树中,值为 F 的根节点位于第 0 层(L0)。节点的深度定义为从根节点到该节点的路径上的边数,根节点的深度为 0。节点 D 和 J 位于深度 1,即第 1 层。这四个节点位于第 2 层。这三个节点位于第 3 层。最后,值为 H 的节点位于第 4 层。

在广度优先方法中,我们可以从第 0 层开始。第 0 层只有根节点,因此我们访问根节点 F。第 0 层完成后,我们进入第 1 层,从左到右访问节点:先访问 D,然后访问 J。第 1 层完成后,我们进入第 2 层,顺序是 B、E、G、K。接着进入第 3 层:A、C、I。最后进入第 4 层:H。

这种针对树的广度优先遍历称为层序遍历。我们稍后会讨论如何用程序实现它。在这种顺序中,我们逐层、从左到右地访问节点。在广度优先方法中,对于任何节点,我们会在访问其任何孙节点之前,先访问其所有子节点。

在这棵树中,我们先访问 F,然后访问 D。访问 D 后,我们不会立即沿着深度方向去访问 D 的子节点(如 B 或 E),而是接着去访问 J。

深度优先遍历的核心思想 🔍

在深度优先方法中,如果我们访问一个子节点,我们会先完成该子节点的整个子树,然后再去访问下一个子节点。

在这棵树中,从根节点 F 开始,如果我们向左走到 D,那么我们应该先访问完 D 的整个左子树(即沿着这条路径的所有孙节点),然后再去访问 F 的右子节点 J。同样地,当我们访问 J 时,也会先访问完 J 的整个右子树。

在深度优先方法中,访问左子树、右子树和根节点的相对顺序可以不同。例如,我们可以先访问右子树,然后根节点,再左子树;或者先访问根节点,然后左子树,再右子树。相对顺序可以变化,但深度优先策略的核心思想是:访问一个子节点意味着访问该路径上的整个子树。

记住,“访问”一个节点就是读取、处理或打印该节点中的数据。

三种深度优先遍历策略 📝

根据左子树、右子树和根节点的相对顺序,有三种流行的深度优先策略。

  1. 先序遍历:首先访问根节点,然后递归地以相同方式访问左子树,最后访问右子树。
    • 公式/代码表示DLR (Data, Left, Right)
  2. 中序遍历:首先递归地访问左子树,然后访问根节点,最后访问右子树。
    • 公式/代码表示LDR (Left, Data, Right)
  3. 后序遍历:首先递归地访问左子树,然后访问右子树,最后访问根节点。
    • 公式/代码表示LRD (Left, Right, Data)

总共有六种可能的左、右、根排列方式,但按照惯例,左子树总是在右子树之前被访问。因此,我们只使用这三种策略,变化的只是根节点的位置:如果在左、右之前,就是先序;如果在中间,就是中序;如果在左、右之后,就是后序。

有一个简单的方法来记住这三种深度优先算法:用字母 D 表示访问节点(读取数据),L 表示进入左子树,R 表示进入右子树。

  • 先序遍历对每个节点执行 DLR:先读取数据,然后向左,左子树完成后向右。
  • 中序遍历对每个节点执行 LDR:先完成左子树,然后读取当前节点数据,然后向右。
  • 后序遍历对每个节点执行 LRD:先向左,左子树完成后向右,然后读取当前节点数据。

先序、中序和后序遍历使用递归实现起来非常简单直观,我们将在后续讨论实现。现在让我们看看对于示例树,这三种遍历的顺序分别是什么。

手动推导遍历顺序 ✍️

上一节我们介绍了三种深度优先遍历的策略,本节我们来看看如何为示例树手动推导出具体的遍历顺序。

先序遍历顺序推导
我们从根节点 F 开始,遵循 DLR 规则。

  1. 访问 F (D)。
  2. 进入 F 的左子树 (L),即以 D 为根的子树。
    • 访问 D (D)。
    • 进入 D 的左子树 (L),即以 B 为根的子树。
      • 访问 B (D)。
      • 进入 B 的左子树 (L),即节点 A。
        • 访问 A (D)。(A 无左右子树,返回)
      • 进入 B 的右子树 (R),即节点 C。
        • 访问 C (D)。(C 无左右子树,返回)
    • 进入 D 的右子树 (R),即节点 E。
      • 访问 E (D)。(E 无左右子树,返回)
  3. 进入 F 的右子树 (R),即以 J 为根的子树。
    • 访问 J (D)。
    • 进入 J 的左子树 (L),即以 G 为根的子树。
      • 访问 G (D)。
      • (G 无左子树)
      • 进入 G 的右子树 (R),即节点 I。
        • 访问 I (D)。
        • 进入 I 的左子树 (L),即节点 H。
          • 访问 H (D)。(H 无左右子树,返回)
        • (I 无右子树)
    • 进入 J 的右子树 (R),即节点 K。
      • 访问 K (D)。(K 无左右子树,返回)

因此,先序遍历顺序为:F, D, B, A, C, E, J, G, I, H, K

中序遍历顺序推导
我们从根节点 F 开始,遵循 LDR 规则。

  1. 进入 F 的左子树 (L),完成后再访问 F。
    • 进入 D 的左子树 (L),完成后再访问 D。
      • 进入 B 的左子树 (L),完成后再访问 B。
        • 进入 A 的左子树 (L)(为空),访问 A (D),进入 A 的右子树 (R)(为空)。A 完成。
      • 访问 B (D)。
      • 进入 B 的右子树 (R)。
        • 进入 C 的左子树 (L)(为空),访问 C (D),进入 C 的右子树 (R)(为空)。C 完成。
    • 访问 D (D)。
    • 进入 D 的右子树 (R)。
      • 进入 E 的左子树 (L)(为空),访问 E (D),进入 E 的右子树 (R)(为空)。E 完成。
  2. 访问 F (D)。
  3. 进入 F 的右子树 (R)。
    • 进入 J 的左子树 (L),完成后再访问 J。
      • 进入 G 的左子树 (L)(为空),访问 G (D)。
      • 进入 G 的右子树 (R)。
        • 进入 I 的左子树 (L)。
          • 进入 H 的左子树 (L)(为空),访问 H (D),进入 H 的右子树 (R)(为空)。H 完成。
        • 访问 I (D)。
        • 进入 I 的右子树 (R)(为空)。
    • 访问 J (D)。
    • 进入 J 的右子树 (R)。
      • 进入 K 的左子树 (L)(为空),访问 K (D),进入 K 的右子树 (R)(为空)。K 完成。

因此,中序遍历顺序为:A, B, C, D, E, F, G, H, I, J, K

后序遍历顺序推导
遵循 LRD 规则,推导过程类似,结果为:A, C, B, E, D, H, I, G, K, J, F

提示:示例树实际上是一棵二叉搜索树(BST),对于每个节点,左子树所有节点的值都小于它,右子树所有节点的值都大于它。因此,中序遍历(左-根-右)二叉搜索树会得到一个有序的列表。你可以验证上面的中序遍历结果 A, B, C, D, E, F, G, H, I, J, K 确实是升序排列的。

总结 📚

本节课中我们一起学习了二叉树的遍历策略。

  • 我们首先理解了树作为非线性数据结构,其遍历比线性结构更复杂。
  • 接着,我们学习了两种主要的遍历分类:广度优先遍历(层序遍历)和深度优先遍历
  • 然后,我们深入探讨了三种深度优先遍历策略:先序遍历 (DLR)、中序遍历 (LDR) 和后序遍历 (LRD),并通过示例树手动推导了它们的访问顺序。
  • 最后,我们了解到中序遍历二叉搜索树会得到一个有序序列。

在下一节课中,我们将讨论如何用代码实现这三种遍历算法。

033:二叉树的层序遍历 🧮

在本节课中,我们将学习如何为二叉树编写层序遍历的代码。层序遍历是一种按树的层级顺序访问所有节点的方法,即先访问同一层的所有节点,再访问下一层的节点。

算法核心思想

上一节我们介绍了层序遍历的概念,本节中我们来看看如何通过编程实现它。核心思想是使用一个队列来辅助遍历。我们无法仅用一个指针在树中移动,因为从父节点到其兄弟节点没有直接的链接。因此,我们需要一个数据结构来暂存待访问的节点地址。

以下是算法的基本步骤:

  1. 将根节点放入队列。
  2. 当队列不为空时,重复以下步骤:
    • 从队列前端取出一个节点(出队)并访问它(例如打印其值)。
    • 如果该节点有左子节点,则将左子节点放入队列(入队)。
    • 如果该节点有右子节点,则将右子节点放入队列(入队)。

通过队列的“先进先出”特性,我们可以确保节点按照层级顺序被访问。

代码实现

现在,让我们用C++代码来实现上述算法。首先,我们定义二叉树节点的结构。

struct Node {
    char data;      // 存储字符数据
    Node* left;     // 指向左子节点的指针
    Node* right;    // 指向右子节点的指针
};

接下来,我们编写层序遍历函数 levelOrder

#include <queue>
#include <iostream>

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/mycodeschool-dast/img/b3c5c72f9afe807aa497eae30e6d7e07_8.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/mycodeschool-dast/img/b3c5c72f9afe807aa497eae30e6d7e07_9.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/mycodeschool-dast/img/b3c5c72f9afe807aa497eae30e6d7e07_10.png)

void levelOrder(Node* root) {
    // 处理空树的情况
    if (root == nullptr) {
        return;
    }

    // 创建一个存储节点指针的队列
    std::queue<Node*> q;

    // 将根节点放入队列
    q.push(root);

    // 当队列中还有待访问的节点时
    while (!q.empty()) {
        // 取出队列前端的节点
        Node* current = q.front();

        // 访问该节点(这里打印其数据)
        std::cout << current->data << " ";

        // 如果左子节点存在,将其放入队列
        if (current->left != nullptr) {
            q.push(current->left);
        }
        // 如果右子节点存在,将其放入队列
        if (current->right != nullptr) {
            q.push(current->right);
        }

        // 将已访问的节点移出队列
        q.pop();
    }
}

复杂度分析

在理解了算法实现后,我们来分析其时间和空间复杂度。

时间复杂度

每个节点恰好被访问一次(出队并打印),每次访问操作(包括检查子节点和入队)的时间是常数级的。因此,对于包含 n 个节点的树,总时间复杂度为 O(n)。无论树的形状如何,这个结论都成立。

空间复杂度

空间复杂度衡量的是算法使用的额外内存随输入规模增长的速度。在本算法中,主要额外内存消耗来自队列。

以下是几种情况的分析:

  • 最佳情况(如斜树):队列中最多只有一个节点,空间复杂度为 O(1)
  • 最坏情况(如完美二叉树):在遍历到最底层时,队列中大约有 n/2 个节点,因此空间复杂度为 O(n)
  • 平均情况:空间复杂度通常也为 O(n)

因此,层序遍历的时间复杂度恒为 O(n),而空间复杂度在最坏和平均情况下为 O(n)

总结

本节课中我们一起学习了二叉树的层序遍历。我们首先理解了为什么需要借助队列来实现按层级访问节点,然后详细分析了算法的每一步。接着,我们用C++代码实现了 levelOrder 函数。最后,我们讨论了算法的时间复杂度(O(n))和空间复杂度(最坏情况下 O(n))。层序遍历是理解树结构的基础,下一节课我们将探讨深度优先遍历的几种方式:先序、中序和后序遍历。

034:二叉树的深度优先遍历 🌳

在本节课中,我们将要学习二叉树的三种深度优先遍历算法:前序遍历、中序遍历和后序遍历。上一节我们介绍了二叉树的层序遍历(广度优先遍历),本节中我们来看看基于递归思想的深度优先策略。

深度优先遍历的核心思想

深度优先遍历的策略是,当我们选择了一个方向(例如向左),就会先访问完该方向上的所有节点(即整个子树),然后才会转向其他方向。

访问一个节点意味着读取或处理该节点的数据。访问一棵子树则意味着以深度优先策略访问该子树中的所有节点。

整个遍历过程可以看作一个递归问题:访问整棵树等价于 访问根节点 + 访问左子树 + 访问右子树

三种遍历方式的定义

在约定左子树总是先于右子树访问的前提下,根节点(R)、左子树(L)、右子树(R)的访问顺序有三种排列,对应三种遍历方式:

以下是三种遍历方式的定义:

  • 前序遍历:顺序为 根节点 -> 左子树 -> 右子树
  • 中序遍历:顺序为 左子树 -> 根节点 -> 右子树
  • 后序遍历:顺序为 左子树 -> 右子树 -> 根节点

对于子树,我们以同样的递归方式进行访问。例如,在前序遍历中,对于左子树,我们同样按照“根-左-右”的顺序进行。

前序遍历的代码实现与解析

这些算法的实现非常直观。让我们先看看前序遍历的代码。

以下是前序遍历函数的C/C++伪代码描述:

void Preorder(struct node* root) {
    if(root == NULL) return; // 基线条件:空树则返回
    printf("%c ", root->data); // 1. 访问根节点
    Preorder(root->left); // 2. 递归访问左子树
    Preorder(root->right); // 3. 递归访问右子树
}

其中,节点结构体定义如下:

struct node {
    char data;
    struct node* left;
    struct node* right;
};

函数首先检查根节点是否为空(基线条件)。若非空,则执行三步操作:打印当前节点数据,然后递归调用自身处理左子树,最后递归处理右子树。

递归过程可视化

为了理解递归如何工作,我们以一个具体的二叉树为例,逐步跟踪 Preorder 函数的执行。

假设我们有一棵二叉树,根节点地址为200,其数据为 ‘F’。调用 Preorder(200)

  1. 根非空,打印 ‘F’。
  2. 递归调用 Preorder(150) 访问左子树。原调用暂停。
  3. 对于 Preorder(150),打印 ‘D’,然后递归调用 Preorder(400)
  4. 此过程持续进行,直到遇到空节点(root == NULL),函数直接返回,上一级调用得以恢复并继续执行。

递归调用会在程序内存的调用栈中分配空间。虽然代码中没有显式使用额外内存,但递归隐式地使用了栈空间。

空间复杂度分析

调用栈的最大深度取决于树的高度。因此,这些遍历算法的空间复杂度为 O(h),其中 h 是树的高度。

  • 在最坏情况下(树退化为链表),h = n-1,空间复杂度为 O(n)
  • 在最好或平均情况下(平衡树),h = log₂n,空间复杂度为 O(log n)

中序遍历与后序遍历的实现

理解了前序遍历后,中序和后序遍历的代码就非常容易编写了,它们仅在于访问根节点的时机不同。

以下是中序遍历函数的代码:

void Inorder(struct node* root) {
    if(root == NULL) return;
    Inorder(root->left); // 1. 递归访问左子树
    printf("%c ", root->data); // 2. 访问根节点
    Inorder(root->right); // 3. 递归访问右子树
}

以下是后序遍历函数的代码:

void Postorder(struct node* root) {
    if(root == NULL) return;
    Postorder(root->left); // 1. 递归访问左子树
    Postorder(root->right); // 2. 递归访问右子树
    printf("%c ", root->data); // 3. 访问根节点
}

对于一个二叉搜索树,中序遍历的输出结果恰好是所有节点值的升序序列

时间复杂度总结

对于所有三种深度优先遍历算法,每个节点都会被访问一次(执行一次打印操作)。因此,它们的时间复杂度都是 O(n),其中 n 是树中的节点总数。

本节课中我们一起学习了二叉树的三种深度优先遍历算法:前序、中序和后序。我们理解了它们基于递归的定义,掌握了其代码实现,并分析了时间与空间复杂度。在接下来的课程中,我们将运用这些遍历方法来解决一些具体的二叉树问题。

035:判断二叉树是否为二叉搜索树 🌳

在本节课中,我们将解决一个关于二叉树的简单问题,这也是一个著名的编程面试题。问题是:给定一棵二叉树,我们需要判断它是否是一棵二叉搜索树。

什么是二叉搜索树? 🤔

我们知道,二叉树是一种每个节点最多可以有两个子节点的树结构。然而,并非所有二叉树都是二叉搜索树。

二叉搜索树是一种特殊的二叉树,其中对于每个节点:

  • 左子树中所有节点的值都小于或等于该节点的值。
  • 右子树中所有节点的值都大于该节点的值。

我们可以将其定义为一个递归结构:不仅根节点需要满足上述条件,其左子树和右子树本身也必须是二叉搜索树。

问题定义与函数签名 📝

我们需要编写一个函数,它以二叉树根节点的指针(或引用)作为参数,并返回一个布尔值:如果该二叉树是BST则返回true,否则返回false

以下是C++中的函数签名和节点定义:

struct Node {
    int data;
    Node* left;
    Node* right;
};

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/mycodeschool-dast/img/7b46e0d76fe8df69ec2db9308aa696df_9.png)

bool isBST(Node* root);

方法一:直观但低效的解法 ⏳

第一种方法直接根据定义进行验证。对于每个节点,我们需要检查:

  1. 其左子树中的所有节点值是否都小于或等于当前节点值。
  2. 其右子树中的所有节点值是否都大于当前节点值。
  3. 其左子树本身是否是BST。
  4. 其右子树本身是否是BST。

以下是实现此逻辑的伪代码框架:

bool isSubtreeLesser(Node* root, int value) {
    // 检查以root为根的树中所有节点值是否都小于value
}

bool isSubtreeGreater(Node* root, int value) {
    // 检查以root为根的树中所有节点值是否都大于value
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/mycodeschool-dast/img/7b46e0d76fe8df69ec2db9308aa696df_17.png)

bool isBST(Node* root) {
    if (root == NULL) return true; // 空树是BST
    if (isSubtreeLesser(root->left, root->data) &&
        isSubtreeGreater(root->right, root->data) &&
        isBST(root->left) &&
        isBST(root->right))
        return true;
    else
        return false;
}

isSubtreeLesserisSubtreeGreater函数需要遍历整个子树来比较所有节点的值。

时间复杂度分析:这种方法效率很低。对于每个节点,我们都需要遍历其左子树和右子树来验证条件。在最坏情况下(例如树退化成链表),总的时间复杂度将达到 O(n²),其中n是节点数。节点值会被多次读取和比较。

方法二:高效的范围限定法 ⚡

上一节我们介绍了一种直观但低效的方法。本节中,我们来看看一种更高效的解决方案。

核心思想是:为树中的每个节点定义一个允许的取值范围。在遍历树的过程中,我们动态地更新并检查每个节点的值是否在其合法范围内。

以下是具体的步骤:

  1. 根节点的初始范围是 (-∞, +∞)
  2. 当遍历到左子节点时,其取值范围的上限更新为其父节点的值,下限不变。
  3. 当遍历到右子节点时,其取值范围的下限更新为其父节点的值,上限不变。
  4. 在访问每个节点时,检查其值是否在当前范围内。

以下是实现此逻辑的代码:

bool isBSTUtil(Node* root, int minValue, int maxValue) {
    if (root == NULL) return true;
    if (root->data > minValue &&
        root->data < maxValue &&
        isBSTUtil(root->left, minValue, root->data) &&
        isBSTUtil(root->right, root->data, maxValue))
        return true;
    else
        return false;
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/mycodeschool-dast/img/7b46e0d76fe8df69ec2db9308aa696df_28.png)

bool isBST(Node* root) {
    return isBSTUtil(root, INT_MIN, INT_MAX);
}

时间复杂度分析:这种方法非常高效。我们只需遍历每个节点一次,并且在每个节点上只进行常数时间的比较操作。因此,总的时间复杂度是 O(n)

关于重复值的处理:上面的代码要求左子树值严格小于,右子树值严格大于。如果要允许左子树中存在等于节点值的重复项,只需将比较条件 root->data > minValue 改为 root->data >= minValue 即可。

方法三:利用中序遍历的特性 🔄

除了范围限定法,还有一种巧妙的解决方案利用二叉搜索树的一个重要特性:对BST进行中序遍历,会得到一个升序排序的序列

因此,我们可以在中序遍历的过程中,实时检查当前访问的节点值是否大于前一个访问的节点值。以下是实现思路:

  1. 初始化一个变量(如prev)来保存前一个访问的节点值,初始值可设为非常小的数。
  2. 进行中序遍历(左-根-右)。
  3. 访问每个节点时,比较当前节点值与prev值。如果当前值小于等于prev,则不是BST。
  4. 更新prev为当前节点值,继续遍历。

这种方法同样只需要一次遍历,时间复杂度也是 O(n),且空间复杂度可以优化为 O(1)(如果不考虑递归调用栈的话)。鼓励你尝试实现这个方案。

总结 📚

本节课中我们一起学习了如何判断一棵二叉树是否为二叉搜索树。

  • 我们首先分析了BST的递归定义。
  • 然后探讨了一种符合直觉但时间复杂度为 O(n²) 的低效方法。
  • 接着,我们深入学习了高效的范围限定法,其核心是为每个节点设定动态的取值范围,将时间复杂度降至 O(n)
  • 最后,我们提到了利用中序遍历特性进行验证的另一种思路。

理解并掌握范围限定法对于解决BST相关问题至关重要。在接下来的课程中,我们将讨论更多关于二叉树的习题。

036:从二叉搜索树中删除节点 🗑️

在本节课中,我们将学习如何从二叉搜索树中删除一个节点。删除操作在大多数数据结构中都比较复杂,对于二叉搜索树而言,它并非直截了当。我们将预见在删除节点时可能遇到的所有复杂情况,并学习如何处理它们。

二叉搜索树回顾 🌳

首先,我们回顾一下二叉搜索树的定义。对于树中的每个节点,其左子树中所有节点的值都小于该节点的值,其右子树中所有节点的值都大于该节点的值。这个性质在删除节点后必须保持不变。

删除节点的三种情况

删除节点时,根据目标节点的子节点数量,可以分为三种情况。我们将逐一分析每种情况的处理逻辑。

情况一:删除叶子节点 🍃

叶子节点是指没有子节点的节点。删除叶子节点是最简单的情况。

以下是删除叶子节点的步骤:

  1. 从其父节点移除对该节点的引用(即断开链接)。
  2. 释放该节点占用的内存。

例如,要删除值为19的叶子节点,只需将其父节点(值为17)的右子指针设为 null,然后删除该节点即可。删除后,二叉搜索树的性质依然成立。

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

当一个节点只有一个子节点时,处理方式也很直接。我们可以将其父节点直接连接到它的唯一子节点上。

以下是删除只有一个子节点的节点的步骤:

  1. 找到目标节点的父节点。
  2. 将父节点指向目标节点的指针,改为指向目标节点的唯一子节点。
  3. 释放目标节点占用的内存。

例如,要删除值为7的节点(它只有右子节点9),我们将其父节点(值为5)的右子指针指向节点9。这样,节点9及其子树(如果存在)就成为了节点5的右子树,树的性质得以保持。

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

这是最复杂的情况。当目标节点有两个子节点时,我们不能简单地将其父节点连接到其中一个子节点,因为这样会丢失另一个子树。

处理此情况的核心思想是:用另一个合适的节点值替换目标节点的值,然后删除那个“合适的节点”。这个“合适的节点”可以是其右子树中的最小值节点,或者其左子树中的最大值节点。这样做可以将情况三转化为情况一或情况二。

我们通常选择右子树中的最小值节点(后继节点)来替换。因为这个节点是右子树中最小的,所以它一定没有左子节点(否则会有更小的值),它最多只有一个右子节点。

以下是删除有两个子节点的节点的步骤:

  1. 在目标节点的右子树中找到值最小的节点(即最左边的节点)。
  2. 用这个最小节点的值替换目标节点的值。
  3. 现在,右子树中出现了两个具有相同值的节点。我们需要删除那个原来的最小节点。由于这个最小节点最多只有一个右子节点,因此删除它属于情况一或情况二,我们可以轻松处理。

例如,要删除值为15的节点。首先,在其右子树中找到最小值节点17。接着,将节点15的值替换为17。最后,删除原来值为17的节点(它只有一个右子节点19,属于情况二)。删除后,树的结构和性质都保持正确。

另一种可行的方法是使用左子树中的最大值节点(前驱节点)进行替换,逻辑是类似的。

代码实现 💻

理解了逻辑之后,我们来看一下如何用代码(以C++为例)实现删除功能。我们将使用递归方法。

首先,定义树节点的结构:

struct Node {
    int data;
    Node* left;
    Node* right;
};

接着,实现删除函数 Delete。该函数接收树的根节点指针和要删除的值,并返回删除操作后新的根节点指针(因为根节点有可能被删除)。

Node* Delete(Node* root, int data) {
    // 情况:树为空或未找到节点
    if (root == nullptr) return root;

    // 递归查找要删除的节点
    if (data < root->data) {
        // 目标值在左子树
        root->left = Delete(root->left, data);
    } else if (data > root->data) {
        // 目标值在右子树
        root->right = Delete(root->right, data);
    } else {
        // 找到目标节点,开始删除
        // 情况1:叶子节点或只有一个子节点
        if (root->left == nullptr) {
            Node* temp = root->right;
            delete root; // C语言中使用 free(root);
            return temp;
        } else if (root->right == nullptr) {
            Node* temp = root->left;
            delete root;
            return temp;
        }
        // 情况3:有两个子节点
        // 找到右子树中的最小节点
        Node* temp = FindMin(root->right);
        // 用最小节点的值替换当前节点的值
        root->data = temp->data;
        // 删除右子树中的那个最小节点(现在它是重复值)
        root->right = Delete(root->right, temp->data);
    }
    return root;
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/mycodeschool-dast/img/312882b4ee745f9691f56cca19c9b10a_90.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/mycodeschool-dast/img/312882b4ee745f9691f56cca19c9b10a_91.png)

// 辅助函数:查找以给定节点为根的子树中的最小节点
Node* FindMin(Node* root) {
    while (root->left != nullptr) root = root->left;
    return root;
}

代码解析:

  • 函数通过递归不断在左子树或右子树中搜索目标值。
  • 当找到目标节点后:
    • 如果它缺少左子节点或右子节点,则用其存在的那个子节点替换自己,然后删除自身。
    • 如果它有两个子节点,则调用 FindMin 找到右子树最小值,用该值覆盖当前节点值,然后递归地在右子树中删除那个最小值节点。
  • 递归调用 root->left = Delete(...)root->right = Delete(...) 确保了在回溯时,父节点的子指针能被正确更新。

总结 📝

本节课我们一起学习了从二叉搜索树中删除节点的完整过程。我们将其分为三种情况:

  1. 删除叶子节点:直接断开父节点链接并释放内存。
  2. 删除只有一个子节点的节点:用其唯一子节点替代自身。
  3. 删除有两个子节点的节点:用其后继(右子树最小值)或前驱(左子树最大值)节点的值替换自身,然后递归删除那个后继或前驱节点。

关键之处在于,无论哪种情况,操作后都必须保持二叉搜索树“左小右大”的核心性质。通过递归的代码实现,我们可以清晰而优雅地处理这些情况。理解并掌握删除操作,对于维护二叉搜索树的动态数据至关重要。

037:二叉搜索树的中序后继节点 🔍

在本节课中,我们将学习如何解决二叉搜索树上的一个有趣问题:给定树中的一个节点,找到它的中序后继节点。中序后继节点指的是,在对二叉搜索树进行中序遍历时,紧跟在给定节点之后被访问的那个节点。

中序遍历回顾 📚

上一节我们介绍了问题的定义,本节中我们来看看理解问题的基础——中序遍历。

中序遍历是一种深度优先的遍历方式。对于二叉树中的任意节点,遍历顺序遵循“左子树 -> 根节点 -> 右子树”的递归规则。这意味着对于每个节点,我们总是先访问其所有左子节点,然后访问该节点本身,最后访问其所有右子节点。

以下是中序遍历的递归函数实现框架:

void inorderTraversal(Node* root) {
    if (root == NULL) return;
    inorderTraversal(root->left);  // 访问左子树
    visit(root);                   // 访问根节点
    inorderTraversal(root->right); // 访问右子树
}

该函数包含两个递归调用,分别用于遍历左子树和右子树。中序遍历的时间复杂度为 O(n),其中 n 是树中的节点总数,因为每个节点恰好被访问一次。

二叉搜索树与中序序列 🔢

二叉搜索树是一种特殊的二叉树,它满足一个关键性质:对于树中任意节点,其左子树中所有节点的值都小于该节点的值,其右子树中所有节点的值都大于该节点的值。这个性质可以用以下公式描述:

对于节点 node

  • 所有 node.left 子树中的节点值 < node.data
  • 所有 node.right 子树中的节点值 > node.data

当我们对一棵二叉搜索树执行中序遍历时,一个重要的特性是:访问到的节点值序列是升序排列的。因此,寻找一个节点的中序后继,本质上就是在升序序列中寻找它的下一个值。

虽然我们可以通过完整的中序遍历来找到后继节点,但这种方法的时间复杂度是 O(n),效率不高。在二叉搜索树中,我们期望像查找、插入这样的操作能在 O(h) 时间内完成(h 为树高)。对于一个平衡的二叉搜索树,h ≈ log₂(n),这使得操作非常高效。因此,我们的目标是设计一个时间复杂度为 O(h) 的算法来寻找中序后继。

寻找中序后继的算法逻辑 🤔

现在,我们来探讨如何高效地找到中序后继。算法需要处理两种情况,这取决于给定节点是否有右子树。

情况一:节点有右子树

如果给定节点 current 拥有右子树,那么它的中序后继一定存在于这个右子树中。更具体地说,后继节点是其右子树中值最小的节点,也就是右子树里的“最左”节点。

原因:根据中序遍历“左-根-右”的顺序,访问完 current 节点后,下一个要访问的就是其右子树。而在右子树中,我们需要先遍历完所有左子节点,才能访问根节点,因此第一个被访问的节点就是右子树里的最左节点。

情况二:节点没有右子树

如果给定节点 current 没有右子树,情况就稍微复杂一些。我们需要向上回溯,找到这样一个祖先节点:current 节点位于该祖先节点的左子树中。这个祖先节点就是 current 的中序后继。

原因:当 current 没有右子树时,意味着以 current 为根的子树已经遍历完毕。程序将回溯到 current 的父节点。如果 current 是其父节点的左孩子(即 current 在父节点的左子树中),那么根据“左-根-右”的顺序,父节点就是下一个被访问的节点,即后继。如果 current 是其父节点的右孩子,则说明父节点已经在访问 current 之前被访问过了,我们需要继续向上回溯,直到找到一个祖先节点,使得 current 位于它的左子树中。

为了向上回溯,我们需要从树的根节点开始,向下搜索到 current 节点,并记录路径。在这个过程中,每当我们需要向左子节点移动时(即 current.data < ancestor.data),当前的 ancestor 节点就可能是后继节点(因为 current 在它的左子树里)。我们持续更新这个可能的 successor,直到找到 current 节点。最后记录的 successor 就是我们要找的答案。

算法实现(C++)💻

基于上述逻辑,我们可以编写 getSuccessor 函数。该函数接收树的根节点指针和需要查找后继的节点数据值,返回后继节点的指针。

以下是核心代码实现:

// 辅助函数:在BST中查找值为data的节点
Node* Find(Node* root, int data) {
    if(root == NULL) return NULL;
    else if(root->data == data) return root;
    else if(root->data < data) return Find(root->right, data);
    else return Find(root->left, data);
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/mycodeschool-dast/img/6a9b92df06a5a2551f62473daa3ab9cb_113.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/mycodeschool-dast/img/6a9b92df06a5a2551f62473daa3ab9cb_115.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/mycodeschool-dast/img/6a9b92df06a5a2551f62473daa3ab9cb_117.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/mycodeschool-dast/img/6a9b92df06a5a2551f62473daa3ab9cb_119.png)

// 辅助函数:找到子树中的最小节点(最左节点)
Node* FindMin(Node* root) {
    if(root == NULL) return NULL;
    while(root->left != NULL)
        root = root->left;
    return root;
}

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/mycodeschool-dast/img/6a9b92df06a5a2551f62473daa3ab9cb_121.png)

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/mycodeschool-dast/img/6a9b92df06a5a2551f62473daa3ab9cb_122.png)

// 主函数:获取中序后继
Node* GetSuccessor(Node* root, int data) {
    // 1. 搜索包含data的当前节点
    Node* current = Find(root, data);
    if(current == NULL) return NULL; // 节点不存在

    // 2. 情况1:节点有右子树
    if(current->right != NULL) {
        return FindMin(current->right); // 返回右子树中的最小节点
    }
    // 3. 情况2:节点没有右子树
    else {
        Node* successor = NULL;
        Node* ancestor = root;
        // 从根开始向下遍历到当前节点,并记录可能的后继
        while(ancestor != current) {
            if(current->data < ancestor->data) {
                successor = ancestor; // 当前祖先可能是后继
                ancestor = ancestor->left; // 向左走
            }
            else {
                ancestor = ancestor->right; // 向右走
            }
        }
        return successor; // 返回找到的后继,可能为NULL(如最大节点)
    }
}

代码解析

  1. 查找节点:首先使用 Find 函数在BST中找到目标节点 current。这是一个标准的BST查找操作,时间复杂度为 O(h)。
  2. 处理有右子树的情况:如果 current->right 不为空,则调用 FindMin 函数在其右子树中寻找最左节点并返回。
  3. 处理无右子树的情况:初始化 successorNULLancestor 为根节点。通过一个 while 循环从根节点向下走到 current 节点。在每一步,如果 current 的数据小于 ancestor 的数据,说明 currentancestor 的左子树中,此时 ancestor 是一个潜在的后继,我们更新 successor 并走向左孩子;否则,走向右孩子。循环结束时,successor 中存储的就是我们需要的祖先节点。

整个 GetSuccessor 函数的时间复杂度为 O(h),其中 h 是树的高度,这符合我们对高效算法的期望。

总结 🎯

本节课我们一起学习了如何在二叉搜索树中寻找给定节点的中序后继。我们首先回顾了中序遍历的性质,并理解了在BST中,中序序列是升序的这一关键点。

核心算法分为两种情况:

  1. 若节点有右子树,则后继是其右子树中的最小节点
  2. 若节点无右子树,则后继是从根节点到该节点的路径上,最后一个将其置于左子树中的祖先节点

我们实现了时间复杂度为 O(h) 的算法,这对于保持平衡的二叉搜索树来说效率非常高。理解这个算法后,尝试编写寻找中序前驱节点的函数将是一个很好的练习。在接下来的课程中,我们将继续探索二叉树和二叉搜索树上的更多有趣问题。

数据结构:第38讲:图论入门 🚀

在本节课中,我们将学习一种新的非线性数据结构——图。图在计算机科学中应用广泛,用于建模和表示各种系统。我们将首先从数学或逻辑模型的角度来理解图,之后再探讨其实现细节。


什么是图? 🧩

上一节我们介绍了线性数据结构,本节中我们来看看图。图与树类似,都是由称为节点顶点的对象集合,以及连接这些节点的的集合组成。

然而,树中的连接方式有特定规则。在具有 n 个节点的树中,必须有且仅有 n-1 条边,每条边代表一个父子关系,并且从根节点到任意节点都有且仅有一条路径。

在图中,节点之间的连接没有固定规则。图只包含一个顶点集合和一个边集合,边可以以任何可能的方式连接节点。因此,树是图的一种特殊形式。

图的数学定义 📐

图在数学中已被广泛研究。在计算机科学中,我们主要学习和实现数学中的图概念。图的研究通常被称为图论。

从纯数学角度,我们可以这样定义图:

一个图 G 是一个有序对,包含一个顶点集合 V 和一个边集合 E

公式G = (V, E)

这里,V 是顶点集合,E 是边集合。有序对意味着 (V, E)(E, V) 是不同的,除非 VE 相等。

顶点与边的表示 🏷️

图中的每个节点都需要一个标识,可以是名称或索引。例如,一个图可以有顶点集合 V = {v1, v2, v3, v4, v5, v6, v7, v8}

边由其两个端点唯一标识。边有两种类型:

  1. 有向边:连接是单向的。它可以表示为一个有序对 (u, v),其中 u 是起点,v 是终点。
  2. 无向边:连接是双向的。它可以表示为一个无序对 {u, v},因为起点和终点不固定。

通常,一个图中的所有边要么都是有向的,要么都是无向的。

  • 有向图:所有边都是有向的。
  • 无向图:所有边都是无向的。

图的现实应用举例 🌍

图可以用来表示任何具有成对关系的对象集合。以下是几个例子:

以下是图的一些典型应用场景:

  • 社交网络(如Facebook):这是一个无向图。用户是节点,好友关系是边。友谊是双向的,因此边是无向的。基于此模型,我们可以解决诸如“向用户推荐好友”的问题,这本质上是一个标准的图论问题(例如,寻找与给定节点最短路径长度为2的所有节点)。

  • 万维网:这是一个有向图。网页是节点,超链接是边。如果网页A链接到网页B,并不意味着B也链接到A,因此边是有向的。搜索引擎的“网络爬虫”程序执行的任务,本质上就是图的遍历——访问图中的所有节点。

  • 加权图:有时,图中的连接(边)具有不同的重要性或成本。例如,城市间的公路网可以建模为一个加权无向图。城市是节点,公路是边,边的权重可以是距离。在这种情况下,寻找从A市到D市的最佳路线,就需要考虑路径上所有边的权重之和,而不仅仅是边的数量。一个未加权的图可以看作是所有权重都为1的加权图。

总结 📝

本节课我们一起学习了图的基本概念。我们了解到图是由顶点和边组成的非线性数据结构,分为有向图和无向图。边还可以被赋予权重,形成加权图,以表示连接的不同成本。图是建模现实世界系统(如社交网络、交通网络和网络链接)的强大工具。在接下来的课程中,我们将继续探讨图的更多属性。

039:图的属性详解 🧮

在本节课中,我们将深入学习图(Graph)这一数据结构。我们将回顾图的基本定义,并详细探讨图的各种属性,包括特殊边、图的密度、路径、连通性以及环。理解这些概念是掌握图论算法的基础。

回顾:图的定义

上一节我们介绍了图,将其定义为一种数学或逻辑模型,并讨论了图的一些属性和应用。本节中,我们将讨论图的更多属性。首先,快速回顾一下之前的内容。

图可以定义为一个有序对,包含一个顶点集合和一个边集合。我们使用正式的数学符号 G = (V, E) 来定义一个图。其中,V 是顶点集合,E 是边集合。有序对是指一对数学对象,其中对象的顺序很重要,即哪个元素是第一,哪个是第二。

集合中元素的数量称为集合的基数,我们使用与模或绝对值相同的符号来表示。因此,我们可以这样表示图中的顶点数和边数:

  • 顶点数:|V|
  • 边数:|E|

在后续所有解释中,我将使用这种表示法。

图的类型与特殊边

之前我们讨论过,图中的边可以是有向的(单向连接),也可以是无向的(双向连接)。只有有向边的图称为有向图,只有无向边的图称为无向图

有时,图中的连接不能被视为等同,因此我们给边加上权重或成本标签。这种与连接关联了成本或权值的图称为加权图。如果边之间没有成本区别,则图是无权图

图中还可能存在一些特殊类型的边,它们会使处理图变得复杂,但我们仍需了解。

以下是两种主要的特殊边:

  • 自环:如果一条边只涉及一个顶点,即边的两个端点相同,则称为自环。自环可以存在于有向图和无向图中。例如,在将互联网网页表示为有向图时,一个网页可能包含指向自身的链接,这就形成了一个自环。
  • 多重边:如果一条边在图中出现多次,则称为多重边。多重边同样可以存在于有向图和无向图中。例如,在表示城市间航班网络的图中,两个城市之间可能有多个不同航班,每个航班可以表示为一条有向边,从而形成多重边。

自环和多重边常常使图的操作复杂化。如果一个图不包含自环和多重边,则称为简单图。在我们的课程中,主要处理简单图。

简单图中的最大边数

现在思考一个简单问题:给定一个简单图(无自环和多重边)的顶点数,其最大可能的边数是多少?

让我们分析一下。假设我们想画一个有4个顶点的有向图。顶点集合 V 的元素数量是4。

一个图可以没有任何边,边集可以为空,节点可以完全断开。因此,图中边的最小可能数量是0。

对于有向图,每个顶点可以有一条指向其他所有顶点的有向边。在有4个顶点的情况下,每个顶点可以指向其他3个顶点,因此最大可能的边数是 4 × 3 = 12

一般来说,如果有 n 个顶点,则有向简单图中的最大边数为 n × (n - 1)。因此,在有向简单图中,边数 |E| 的范围是 0 ≤ |E| ≤ n × (n - 1)

对于无向图,一对节点之间只能有一条双向边,不能有两条方向不同的边。因此,无向简单图中的最大边数是有向图最大边数的一半,即 n × (n - 1) / 2。边数范围是 0 ≤ |E| ≤ n × (n - 1) / 2

请注意,这仅在无自环和多重边时成立。可以看到,图中的边数相对于顶点数可能非常大。例如,一个有10个顶点的有向图,最大边数为90;有100个顶点时,最大边数为9900。最大边数接近顶点数的平方。

稠密图与稀疏图

根据边数与最大可能边数的关系,图可以分为两类:

  • 稠密图:如果图中的边数接近最大可能边数,即边数的数量级是顶点数的平方(O(|V|²)),则称为稠密图。
  • 稀疏图:如果图中的边数相对较少,通常接近顶点数(O(|V|))且不超过此范围,则称为稀疏图。

稠密和稀疏之间没有明确的界限,这取决于具体情境。但在处理图时,这是一个重要的分类。许多决策(例如在计算机内存中如何存储图)都基于图是稠密的还是稀疏的。通常,稠密图使用邻接矩阵存储,而稀疏图使用邻接表存储。我们将在下一课中讨论这两种存储结构。

图中的路径

接下来,我们探讨图中的路径概念。图中的路径是一个顶点序列,其中序列中每一对相邻顶点都由一条边连接。

在示例图中,顶点序列 A, B, F, H 就是一条路径。在无向图中,边是双向的。在有向图中,所有边也必须沿着路径的方向对齐。

如果路径中没有顶点重复,则称为简单路径。由于顶点不重复,边也不会重复。因此,在简单路径中,顶点和边都不重复。之前高亮的路径 A, B, F, H 就是一条简单路径。

但也可能存在顶点或边重复的路径。在图论中,术语“路径”的使用存在一些不一致。大多数时候,当我们说“路径”时,指的是简单路径。如果允许重复,我们使用术语行走。因此,路径本质上是一种顶点和边都不重复的行走。

如果顶点可以重复但边不能重复,这种行走称为

再次强调,行走和路径经常被用作同义词,但通常“路径”指简单路径。在两个不同顶点之间,如果存在一个顶点或边重复的行走,那么也必然存在一条顶点和边不重复的路径(简单路径)。因此,在大多数情况下,我们关注的是顶点之间的简单路径。在本课程中,除非明确说明,否则“路径”均指简单路径。

图的连通性

图的连通性是一个非常重要的属性。如果在一个图中,从任意顶点到任意其他顶点都存在一条路径,则称该图是连通的。

  • 对于无向图,我们直接称之为连通图
  • 对于有向图,我们称之为强连通图

在示例中,最左边和最右边的图是(强)连通的。但中间的图不是强连通的,因为无法从顶点 C 到达顶点 A(虽然可以从 AC)。

如果一个有向图不是强连通的,但通过将所有边视为无向边可以变成连通图,则称该有向图为弱连通图

不过,建议主要记住“连通”和“强连通”这两个术语。例如,城市内的道路网络(有很多单行道)可以表示为有向图。一个合理的城市道路网络应该是强连通的,我们应该能够从任何街道到达任何其他街道。

图中的环

最后,我们讨论图中的环。如果一条行走的起点和终点是同一个顶点,并且行走的长度大于0,则称之为闭行走。行走或路径的长度是指路径中边的数量。

有些人可能称闭行走为环,但通常我们使用术语简单环来指代一种特殊的闭行走:除了起点和终点顶点相同外,其他任何顶点和边都不重复。

一个没有环的图称为无环图

  • 用无向边绘制的树,就是无向无环图的一个例子。在树中,可能存在闭行走,但不存在简单环。
  • 我们也可以有有向无环图,通常简称为 DAG

图中的环在设计算法(如寻找顶点间最短路径)时会导致许多问题。在后续课程学习高级算法时,我们会经常讨论环。

总结

本节课中,我们一起深入学习了图的多种重要属性。我们回顾了图的正式定义 G = (V, E),认识了特殊边(自环和多重边)以及简单图的概念。我们推导了简单图中最大边数的公式:有向图为 n×(n-1),无向图为 n×(n-1)/2,并由此引出了稠密图与稀疏图的分类。接着,我们明确了路径、简单路径、行走和迹的区别,并强调了通常“路径”指简单路径。我们还学习了图的连通性:无向图的连通性、有向图的强连通性与弱连通性。最后,我们定义了环(简单环)和无环图(包括DAG)。理解这些基础属性是后续学习图存储方式和图算法的重要基石。下一节课,我们将探讨如何在计算机内存中创建和存储图。

040:图的表示方法(第一部分)—— 边列表 🗺️

概述

在本节课中,我们将学习如何在计算机内存中表示和存储图数据结构。我们将从最简单的方法——边列表开始,分析其内存使用和时间成本,并理解其优缺点。


图的定义

在之前的课程中,我们介绍了图的概念及其一些基本属性。但到目前为止,我们尚未讨论如何在计算机中实现图,即如何在内存中创建图这样的逻辑结构。

图包含一组顶点和一组边。这是我们在纯数学术语中定义图的方式:一个图 G 被定义为一个有序对,包含顶点集 V 和边集 E

公式: G = (V, E)


边列表表示法

为了在计算机内存中创建和存储图,我们可能做的最简单的事情是创建两个列表:一个用于存储所有顶点,另一个用于存储所有边。对于列表,我们可以使用适当大小的数组,或者使用动态列表的实现(例如 C++ 中的 vector 或 Java 中的 ArrayList)。

顶点由其名称标识。因此,第一个列表(顶点列表)将只是一个名称或字符串的列表。

边由其两个端点标识。我们可以创建一个具有两个字段的边对象。我们可以将边定义为一个结构体或类,包含两个字段:一个用于存储起始顶点,另一个用于存储结束顶点。边列表本质上就是这种 Edge 结构体的数组或列表。

代码示例(C风格):

struct Edge {
    char* startVertex;
    char* endVertex;
    // 对于加权图,可以添加:int weight;
};

代码示例(C++/Java风格,使用索引):

struct Edge {
    int startVertexIndex;
    int endVertexIndex;
    // int weight; // 对于加权图
};

构建示例图的边列表

让我们为一个示例图填充边列表。考虑以下无向图:

顶点列表:[A, B, C, D, E, F, G, H]

对于无向图,边的顺序不重要(例如,(A, B)(B, A) 表示同一条边)。因此,边列表可以如下所示:

起始顶点索引 结束顶点索引 权重(若为加权图)
0 (A) 1 (B) (例如) 4
0 (A) 2 (C) 1
0 (A) 3 (D) 3
1 (B) 4 (E) 2
1 (B) 5 (F) 5
2 (C) 6 (G) 2
3 (D) 7 (H) 1
4 (E) 7 (H) 3
5 (F) 7 (H) 2
6 (G) 7 (H) 4

注意: 在无向图中,每条边只需存储一次。在有向图中,(F, H)(H, F) 代表两条不同的边,需要分别存储。对于加权图,只需在边对象中添加一个权重字段。


空间复杂度分析

现在我们来分析这种表示方法的内存使用情况,即空间复杂度。

  • 顶点列表: 存储空间与顶点数量 V 成正比。假设顶点名称的平均长度是一个常数,则空间复杂度为 O(V)
  • 边列表: 如果使用顶点索引(整数)而非字符串副本,每条边的存储开销是固定的。存储空间与边的数量 E 成正比。因此,空间复杂度为 O(E)

公式: 总空间复杂度 = O(V + E)

这种内存使用率是合理的,因为要存储一个图,我们至少需要存储所有顶点和所有边的信息。


时间复杂度分析与常见操作

上一节我们分析了内存使用,本节我们来看看在这种表示下执行常见操作所需的时间成本。对于任何数据结构,我们都需要关注其最常见操作的时间复杂度。

以下是使用边列表时两个常见操作的分析:

1. 查找给定节点的所有相邻节点
要找到与给定节点直接连接的所有节点,我们必须扫描整个边列表,检查每条边的起始或结束顶点是否是该节点。

  • 操作: 线性搜索整个边列表。
  • 时间复杂度: O(E),其中 E 是边的数量。

2. 判断两个给定节点是否相连
要检查两个节点之间是否存在边,我们同样需要对边列表进行线性搜索,寻找匹配该节点对的边。

  • 操作: 线性搜索整个边列表。
  • 最坏情况时间复杂度: O(E)

边列表的局限性

现在让我们评估一下 O(E) 这个时间复杂度是好是坏。回忆一下之前课程的内容:在一个有 V 个顶点的简单图中,最大边数可以达到 O(V²) 级别(例如,完全图)。

公式(无向图最大边数): E_max = V * (V - 1) / 2 ≈ O(V²)

因此,O(E) 的操作在最坏情况下可能接近 O(V²)。与 O(V) 的操作相比,O(V²) 的成本要高得多,尤其是在稠密图(边数很多)中。

结论: 边列表表示法在内存使用上是高效的,但在执行查找相邻节点或判断连通性等常见操作时,时间复杂度较高(O(E),可能达到 O(V²)),这被认为是低效的。


总结

本节课中,我们一起学习了图的边列表表示法。

  • 我们了解了如何用顶点列表和边列表在内存中表示图。
  • 我们分析了其空间复杂度为 O(V + E),这是存储图所必需的最低限度。
  • 我们重点分析了两种常见操作(查找相邻节点和判断连通性)的时间复杂度均为 O(E)
  • 由于在稠密图中 E 可能达到 O(V²),因此边列表在时间效率上存在显著缺陷。

正因为这些局限性,我们需要寻找更高效的图表示方法。在下一节课中,我们将探讨另一种更优的图存储和表示方式。

041:图的表示方法(二) - 邻接矩阵 📊

在本节课中,我们将学习图的第二种表示方法——邻接矩阵。我们将探讨其工作原理、优缺点,并通过具体例子理解其时间与空间复杂度。


概述

上一节我们介绍了使用边列表(Edge List)来存储图。本节中,我们将探讨一种更高效的表示方法——邻接矩阵(Adjacency Matrix)。我们将学习如何构建邻接矩阵,分析其执行常见操作的时间成本,并讨论其适用场景。

邻接矩阵表示法

邻接矩阵使用一个二维数组(矩阵)来存储图中顶点之间的连接关系。

核心概念

对于一个具有 V 个顶点的图,我们创建一个大小为 V × V 的二维数组 A。数组中的每个元素 A[i][j] 表示顶点 i 和顶点 j 之间是否存在一条边。

对于无权图:

  • 如果存在从顶点 i 到顶点 j 的边,则 A[i][j] = 1(或 true)。
  • 如果不存在边,则 A[i][j] = 0(或 false)。

对于有权图:

  • 如果存在从顶点 i 到顶点 j 的边,则 A[i][j] = 该边的权重
  • 如果不存在边,则 A[i][j] = 一个特殊值(如 INF,表示无穷大)。

构建示例

考虑以下具有8个顶点的无向无权图:

顶点: A(0), B(1), C(2), D(3), E(4), F(5), G(6), H(7)
边: (0-1), (0-2), (0-3), (1-4), (1-5), (2-6), (3-7), (4-7), (5-7), (6-7)

其对应的邻接矩阵 A 如下所示(为简洁起见,仅展示非零部分逻辑):

   0 1 2 3 4 5 6 7
0: 0 1 1 1 0 0 0 0
1: 1 0 0 0 1 1 0 0
2: 1 0 0 0 0 0 1 0
3: 1 0 0 0 0 0 0 1
4: 0 1 0 0 0 0 0 1
5: 0 1 0 0 0 0 0 1
6: 0 0 1 0 0 0 0 1
7: 0 0 0 1 1 1 1 0

注意: 对于无向图,邻接矩阵是对称的,即 A[i][j] = A[j][i]。对于有向图,则不一定对称。


操作的时间复杂度分析

了解了邻接矩阵的结构后,我们来看看基于此结构执行常见操作需要多少时间。

1. 查找某个节点的所有邻接节点

假设我们要查找与节点 F(索引为5)相邻的所有节点。

操作步骤如下:

  1. 获取索引(若给定的是名称): 需要扫描顶点列表以找到 F 对应的索引5。最坏情况下,这需要检查所有 V 个顶点,时间复杂度为 O(V)
  2. 扫描矩阵行: 在邻接矩阵中,找到第5行,并扫描该行的所有 V 个元素,找出值为1的位置。时间复杂度为 O(V)

因此,总时间复杂度为 O(V)。如果配合哈希表(将顶点名称直接映射到索引),第一步可优化至 O(1),但扫描行仍需 O(V)

2. 判断两个节点是否相连

假设我们要判断节点 A(索引0)和节点 F(索引5)是否相连。

操作步骤如下:

  1. 获取索引(若给定的是名称): 同样,可能需要 O(V) 时间(或使用哈希表优化为 O(1))。
  2. 访问矩阵元素: 直接访问 A[0][5] 的值。在二维数组中,这是一个常数时间操作,即 O(1)

因此,如果直接使用索引,此操作的时间复杂度为优秀的 O(1)


空间复杂度与优缺点

邻接矩阵在时间效率上表现不错,但我们需要评估其空间消耗。

空间复杂度

邻接矩阵需要存储一个 V × V 的二维数组。无论图中有多少条边,它都占用固定的 个存储单元。因此,其空间复杂度为 O(V²)

优缺点总结

以下是邻接矩阵的主要优缺点:

优点:

  • 查询速度快: 判断任意两点间是否存在边仅需 O(1) 时间(若已知索引)。
  • 结构简单: 对于稠密图(边数接近 V²)来说,存储效率高。
  • 易于实现: 矩阵操作直观,便于理解和编码。

缺点:

  • 空间消耗大: 需要 O(V²) 的空间,对于稀疏图(边数远小于 V²)会浪费大量空间存储 0
  • 添加/删除顶点开销大: 需要重新调整整个矩阵的大小。

现实世界的考量

大多数现实世界的图都是稀疏的。例如:

  • 社交网络: 一个人不可能与全球数十亿用户都成为好友。假设有 10⁹ 用户,每人平均有1000个朋友,边数约为 5 × 10¹¹,而矩阵需要 10¹⁸ 的存储空间,这是不现实的。
  • 万维网: 一个网页只会链接到少数其他网页,而非所有网页。

对于这类稀疏图,邻接矩阵会消耗巨大且不必要的内存。


总结

本节课我们一起学习了图的邻接矩阵表示法。

  • 我们了解了如何用一个 V × V 的二维数组来表示顶点间的连接。
  • 我们分析了基于邻接矩阵进行“查找邻接节点”和“判断连通性”等操作的时间复杂度,分别为 O(V)O(1)
  • 我们重点讨论了其 O(V²) 的空间复杂度,并认识到这对于顶点数量庞大、连接稀疏的现实世界图(如社交网络、网页链接)来说是一个主要缺点。

虽然邻接矩阵在查询速度上具有优势,但其巨大的空间开销限制了它在稀疏图中的使用。因此,我们需要寻找一种既能保持高效操作,又更节省空间的表示方法。下一节,我们将介绍这样一种方法——邻接表(Adjacency List)。

042:图的表示方法(三) - 邻接表

在本节课中,我们将学习图的另一种存储和表示方法——邻接表。我们将分析其空间和时间效率,并与之前介绍的邻接矩阵进行比较,以理解它们各自的优缺点。

概述

上一节我们介绍了邻接矩阵作为存储和表示图的一种方式。通过分析,我们发现邻接矩阵在操作的时间成本上非常高效,例如判断两个节点是否相连只需常数时间 O(1),查找一个节点的所有邻居需要 O(V) 时间(V为顶点数)。然而,邻接矩阵在空间消耗上效率不高,其空间复杂度为 O(V^2)

本节中,我们将探讨邻接表这种数据结构,它旨在解决邻接矩阵空间消耗过大的问题,特别是在处理稀疏图时。

邻接矩阵的回顾与问题

在邻接矩阵表示法中,我们使用一个大小为 V x V 的二维数组(矩阵)来存储边,其中 V 是图中的顶点数。例如,对于一个有8个顶点的图,我们需要一个 8 x 8 的矩阵,消耗64个单位的空间。

在这个矩阵中,每一行对应一个顶点,存储了该顶点与其他所有顶点的连接信息。行索引代表边的起点,列索引代表边的终点。单元格中的值(0或1)表示是否存在从起点到终点的边。

然而,这种方法存储了大量冗余信息。对于大多数真实世界的图(如社交网络),它们是稀疏的,即实际连接数远小于可能的最大连接数 V^2。这意味着矩阵中会有大量的0(表示“无连接”),而只有少量的1(表示“有连接”)。这些0造成了巨大的内存浪费。

邻接表的基本思想

为了解决空间浪费问题,我们可以只存储“有连接”的信息,而忽略“无连接”的信息,因为后者可以被推断出来。

以下是实现这一想法的基本方法:

对于每个顶点,我们不再使用一个固定大小的数组(其索引代表其他顶点),而是维护一个列表,仅包含与该顶点直接相连的邻居节点的标识符(例如索引或名称)。这个列表可以使用数组、链表甚至平衡二叉搜索树等数据结构来实现。

邻接表的实现方式

程序上,我们可以创建一个指针数组(或引用数组),数组大小为顶点数 V。数组中的每个元素(指针)指向一个动态数据结构,该结构存储了对应顶点的邻居列表。

例如,在C/C++中,可以这样实现:

// 假设使用链表存储邻居
struct ListNode {
    int vertex;
    struct ListNode* next;
};

![](https://github.com/OpenDocCN/cs-notes-pt1-zh/raw/master/docs/mycodeschool-dast/img/b0a32ea9cb234fcca3f6f54f387f20ef_11.png)

struct ListNode* adjacencyList[V]; // 指针数组

adjacencyList[i] 指向一个链表,该链表包含了顶点 i 的所有邻居。

空间复杂度分析

邻接表的主要优势在于空间效率。

  • 对于无向图,每条边会被存储两次(分别在两个端点的邻居列表中),因此总空间消耗与边数 E 成正比,约为 2E
  • 对于有向图,每条边只存储一次,空间消耗约为 E

因此,邻接表的空间复杂度为 O(V + E)。在稀疏图(E 远小于 V^2)中,这比邻接矩阵的 O(V^2) 要高效得多。

时间复杂度分析

现在,我们来比较两种结构下常见操作的时间成本。

以下是关键操作的时间复杂度对比:

操作 邻接矩阵 邻接表(使用链表/无序数组) 邻接表(使用有序数组/平衡BST)
判断两节点是否相连 O(1) O(degree(V))* O(log(degree(V)))*
查找节点的所有邻居 O(V) O(degree(V))* O(degree(V))*
添加一条边 O(1) O(1)(链表头插)或 O(degree(V))(维护有序) O(log(degree(V)))
删除一条边 O(1) O(degree(V)) O(log(degree(V)))

注:degree(V) 表示顶点V的度(邻居数量)。在稀疏图中,degree(V) 远小于 V

虽然邻接矩阵在“判断连接”操作上有绝对的常数时间优势,但在稀疏图的实际场景中,邻接表在“查找所有邻居”和“修改图结构”操作上通常表现更佳,因为其成本取决于顶点的实际邻居数量,而非顶点总数。

邻接表的变体与优化

我们可以根据需求选择不同的数据结构来实现每个顶点的邻居列表:

  • 动态数组:易于实现,但插入/删除可能涉及数组扩容和数据拷贝。
  • 链表:插入和删除(尤其在头部)效率高,O(1),但查找需要线性扫描。
  • 平衡二叉搜索树(如AVL树、红黑树):可以将查找、插入、删除操作的时间复杂度都优化到 O(log(degree(V))),但实现更复杂。

对于加权图,只需在邻居列表的节点结构中增加一个权重字段即可。

总结

本节课我们一起学习了图的邻接表表示法。

  • 我们首先回顾了邻接矩阵空间消耗大的问题,特别是在处理稀疏图时。
  • 接着,引入了邻接表的核心思想:只为每个顶点存储其实际存在的邻居列表,从而消除冗余的“无连接”信息。
  • 我们分析了邻接表的空间复杂度为 O(V + E),在稀疏图中远优于邻接矩阵。
  • 然后,我们对比了两种结构在各种图操作上的时间复杂度。邻接表在稀疏图的大多数操作中更具实际优势,尽管邻接矩阵在特定操作上有理论上的最优时间。
  • 最后,我们探讨了使用链表、平衡二叉搜索树等不同数据结构来实现邻居列表的优缺点。

邻接表是表示稀疏图最常用且高效的数据结构之一,在实际应用(如社交网络、路由算法)中被广泛采用。选择邻接矩阵还是邻接表,需要根据具体图的特点(稠密或稀疏)和需要频繁执行的操作类型来决定。

posted @ 2026-03-29 09:23  绝不原创的飞龙  阅读(1)  评论(0)    收藏  举报