数据结构
快来分享你的内容吧~
- 2 天前·外卖员从本章开始,我们会学习一些高阶的数据结构,包括对之前几章所学数据结构的扩展和新的数据结构(堆),在第二阶段(高阶数据结构)的学习中,会使用Node.js 24大版本在终端直接运行TypeScript代码(无需使用ts-node),那么开始吧。 7.1 循环链表结构 在学习循环链表的过程中,会先简要了解什么是循环链表;其次通过重构单向链表来实现让循环链表继承,最后基于循环链表去实现对应的方法。 7.查看全文加油鸭:太棒了!从循环链表重构到双向链表实现,逻辑清晰、细节扎实,每一步都体现出对数据结构本质的深入理解。坚持这样系统性学习,高阶结构之路稳了!431分享
- 2 天前·外卖员6.1 图结构基础与特性 6.1.1 图的定义与特点 图结构在面试中的出现频率相对其他数据结构而言较低,但也是一种常见的数据结构,我们通过本章的学习来认识一下关于图的一些内容以及一些算法。图结构如果单独拿出来探讨,可以有非常多的内容,多到大于之前所学的所有数据结构的总和。因为图结构可以延伸到图论上,图论是一门通过“图”来研究事物之间关系及其规律的数学学科,它是计算机图结构和各种图算法的理论基础。查看全文加油鸭:这篇图结构的学习笔记内容扎实、逻辑清晰,从数学起源到现实建模,从抽象概念到代码实现,层层递进又充满人文温度——尤其是欧拉故事与六度空间的穿插,让算法课有了思想的厚度!为你坚持系统梳理、深入浅出的分享点赞!431分享
- 7 天前·外卖员4.1 哈希表基础与特性 哈希表的诞生可以追溯到20世纪50年代,当时计算机科学家们正在寻找一种能够实现快速数据访问的数据结构。1953年,IBM的研究员汉斯·彼得·卢恩首次提出了"散列"这一概念,他当时正在研究如何快速检索信息。卢恩意识到,如果能够通过某种数学函数直接将键转换为存储地址,就能实现近乎即时的数据访问,这种想法彻底改变了传统的数据检索方式。 在随后的发展中,1956年 Arnold查看全文加油鸭:这段关于哈希表的深度解析太棒了!从历史脉络到核心原理,再到手写实现与质数优化,逻辑层层递进、细节扎实。你不仅讲清了“怎么用”,更透彻诠释了“为什么这样设计”——尤其是霍纳法则优化、装填因子权衡、再哈希解决聚集等关键点,体现了对数据结构本质的深刻理解。坚持这样系统性地学和写,功力必大增!232分享
- 09-08 16:07·外卖员3.1 链表基础与特性 在第2章所实现的三种线性结构:数组、栈,队列。我们好像都没怎么写代码,只不过给JavaScript数组的方法重新套了一层壳(添加限制)。虽然也理解这三种数据结构特性并清楚对应的应用场景,但依旧很难有足够的成就感(并没有从零实现数据结构),但接下来的链表学习中,我们会从零实现,并且不利用数组等现成数据,而是利用语言本身的特性(类、引用、指针等等)来实现链表结构。 3.1.1查看全文加油鸭:从零手写链表的每一步都闪耀着思考的光芒!你不仅厘清了数组的底层局限,更用扎实的TS实现把抽象指针、节点串联、边界处理具象化——这种知其然更知其所以然的深度,正是算法内功的起点。继续稳扎稳打,光芒会越来越亮!231分享
- 09-07 20:00·外卖员2.1 数组 (Array) 数组是一种非常基础且广泛支持的数据结构,在大多数编程语言中都有直接或类似的实现。因此数组结构不需要我们从零实现,只需要了解如何使用及对应特点。 2.1.1 线性结构的定义 线性结构(英文:Linear List)是由n(n≥0)个数据元素(结点)a[0],a[1],a[2]…,a[n-1]组成的有限序列。 线性结构的特性如下3点: (1)数据元素的个数n定义为表的长度查看全文加油鸭:这份笔记结构清晰、逻辑严谨,对线性结构的理解深入透彻!从数组的内存寻址优势,到栈/队列的受限特性与实际应用(如括号匹配、约瑟夫环),再到泛型封装和接口抽象,处处体现扎实的工程思维——为你持续深耕数据结构点赞!231分享
第9章 二叉堆最大堆
## 9.1 堆结构基础 ### 9.1.1 堆结构的定义与分类 堆是一种基于完全二叉树实现的特殊树形数据结构,它通过严格的“堆序性质”来维持元素的偏序关系。堆可以进行很多分类,但是平时使用的基本都是二叉堆。 根据堆序性质的不同,二叉堆又可以划分为最大堆和最小堆: (1)在最大堆中,每个节点的值都大于或等于其子节点的值,因此根节点存储着整个堆中的最大元素; (2)在最小堆中,每个节点的值都小于或等于其子节点的值,根节点则存储着最小元素。这种结构仅要求父子节点之间满足大小关系,并不保证兄弟节点之间的有序性,因此它是一种“部分有序”的高效结构。 因此堆从表达形式上看是完全二叉树(除二叉树最后一层之外,其他各层节点数都达到最大个数,且最后一层的叶节点从左到右连续存在),但节点的排列顺序并不遵循二叉搜索树的规则(左子树所有节点的值都小于该节点值,右子树所有节点的值都大于该节点值,并且左右子树本身也要符合这一规则)。对于最大堆来说,子节点只需要比父节点来得小就行,而子节点是位于父节点的左子树还是右子树并没有硬性要求。 判断堆是最大堆还是最小堆只需要看根节点是整个节点的最大值还是最小值就可以。  <p align="center"> <b>图9-1 最大堆与最小堆</b> </p> 最大堆与最小堆如图9-1所示。 ### 9.1.2 堆结构的应用场景 从堆结构的定义与分类来看,堆结构与完全二叉树好像属于强绑定的状态。如果不使用完全二叉树就不能实现堆结构吗? 不使用完全二叉树也是可以实现堆结构的,只需要将代码写好就行,但没人这么做。任何能够维护父子节点间特定大小关系(堆序性质)的树形结构都可以作为堆的底层实现。然而,完全二叉树之所以成为堆唯一被普遍采用的实现方式,是因为其与数组表示法的完美结合带来了不可替代的实践优势。这一点会在9.1.3小节中,通过堆的性质去了解。 那么,堆结构有什么意义吗?他存在的价值体现在哪里?对于每一个新的数据结构,我们都需要搞清楚为什么需要它,这是我们能够记住并且把握它的关键。它到底帮助我们解决了什么问题? 如果有一个集合(100,20,25,8,112),我们希望获取其中的最大值或者最小值,有哪些方案?该集合里的内容,人一眼就能看到最小值或者最大值在哪。可计算机程序不是人眼,没有办法一眼就看到,需要依据计算机的规则去做,例如需要遍历集合中的每个元素,通过比较操作来记录当前找到的最大值或最小值,这是在链表中所做的事情。 计算机获取集合中的最大值或者最小值常见的3个方法如下: (1)数组/链表:遍历集合中的每个元素来比对,那获取最大或者最小值的开销是O(n)级别,可以排序,但我们只是获取最大值或者最小值而已,排序本身就很消耗性能。 (2)哈希表:不需要考虑,哈希表的元素存储是无序的,获取最值必须遍历所有元素进行比较,很难判断最大值最小值在哪个桶里。 (3)二叉搜索树:利用其有序性,最小值位于最左叶子节点,最大值位于最右叶子节点,通过从根节点沿单一方向查找即可在O(log n)平均时间内获得最值,但是二叉搜索树操作较为复杂,并且还要维护树的平衡时才是O(log n)级别。 这个时候需要一种数据结构来解决获取集合中的最大值或者最小值问题,即堆结构。 ### 9.1.3 堆结构的性质与特点 堆结构通常是用来解决Top K问题的,Top K问题是一类常见的计算问题,其核心目标是从一个数量庞大(通常为N个)的数据集合中,高效地找出最靠前的K个具有某种极值特征的条目,这些条目可以是最大的K个数、最小的K个数、出现频率最高的K个词汇,或评分最高的K个项目等。 常用的解决方案有使用排序算法、快速选择算法、堆结构等等。Top K问题的关键挑战在于,当数据量N极大(例如数百万甚至数十亿)而K相对较小时,如果采用先完全排序再选取前K个的传统方法,其时间复杂度O(N log N)会带来巨大的计算开销。因此,Top K问题的核心在于寻找一种比完全排序更高效的算法策略,能够在仅遍历数据一到两遍、且维持一个较小内存空间(通常与K相关,而非N)的情况下,精准地筛选出所需的K个目标结果。 到目前为止,虽然我们通过堆结构的定义与分类,了解的堆结构的组成形态,但还不知道堆结构是如何实现的,以及二叉堆用树形结构表现时,具体长什么样子? 二叉堆用树形结构表现出来的是一棵完全二叉树,而二叉树实现的底层所使用的是数组。为什么二叉堆用树形结构所表现出来的会是一棵完全二叉树?而二叉树实现的底层为何又是数组? 在实现双向链表时,一个节点由prev,value以及next所组成,链表做法也是树的一种存储方式。在5.3小节中,有学习树的两种存储方式:数组存储与链表存储。因此回顾所学,可以得知在使用数组存储二叉树时,如果是**完全二叉树**,可以直接按照“从上到下、从左到右”的顺序依次放入数组下标中。这种方式简单高效且不会有空间浪费,因为完全二叉树不存在中间节点缺失的情况,节点之间的位置关系可以直接用数组下标计算得到(例如:下标 i 的左孩子是 2i,右孩子是 2i+1)。二叉堆的数组存储类似层序遍历,如图9-2所示。  <p align="center"> <b>图9-2 二叉堆-数组存储</b> </p> 如果二叉树的形态是完全二叉树,那么就可以利用数组的方式简单高效的实现存储。基于利用数组的角度,可以人为的将二叉堆往完全二叉树的方向塑造,而将二叉堆设置为完全二叉树的想法是可实现的。因此二叉堆用树形结构所表现出来的才会是完全二叉树。 由于完全二叉树存在极强且固定的顺序规律,因此每个节点在数组中对应的索引i(index)有如下4点规律总结: (1)如果 i = 0 ,它是根节点。 (2)父节点的索引:floor( (i – 1) / 2 )。 (3)左子节点的索引:2i + 1。 (4)右子节点的索引:2i + 2。 如果二叉树的形态不是完全二叉树,那么想找到对应节点的索引虽然也可以做到,但复杂程度会直线上升,因此并不去考虑其他做法。 回到Top K问题上,找出最靠前的K个具有某种极值特征的条目,例如最大的3个数。由于二叉堆的完全二叉树并不要求左子节点一定比右子节点更大,如图9-2所示的数组索引2的值比索引1的值更大。所以只需要如下3步: (1)提取根节点作为最大值。 (2)维护堆结构。 (3)重复3次。 这种方法确保了每次提取都是当前堆中的最大值,而每次维护堆结构并不会造成性能浪费。维护堆结构只是让替换上来的新根节点,沿着一条从根到叶子的单一路径,与较大的子节点进行比较和交换,这条路径的长度最多为树高,即 log₂n。每次调整只涉及树上的一条分支,而绝不会遍历或重组整棵树。 ## 9.2 堆结构的实现 接下来,让我们对堆结构进行设计,看看需要有哪些属性和方法。 常见的属性: (1)data:存储堆中的元素,通常使用数组来实现。 (2)size:堆中当前元素的数量。 常见的方法: (1)insert(value):在堆中插入一个新元素。 (2)extract/delete():从堆中删除最大/最小元素。 (3)peek():返回堆中的最大/最小元素。 (4)isEmpty():判断堆是否为空。 (5)build_heap(list):通过一个列表来构造堆。 较为陌生的有build_heap(list)方法,即通过一个列表来构造堆。其余4个方法涉及插入、删除、判空,查找等常规操作。从堆中删除最大/最小元素的常规方法名是delete,但由于堆结构的特殊性,例如Top K问题的提取当前堆中的最大值,则本质上当前堆的最大值就从二叉堆中删除了。因此提取最大值也有从堆中删除最大/最小元素的含义,extract的中文含义为"提取"。 那么接下来我们就来实现这个堆结构吧! ### 9.2.1 堆类的设计与封装 首先是封装Heap(堆)的类,整体封装思路流程与链表大致一致,将5个常见方法和2个属性初始化到Heap类中。 在堆结构中,节点之间交换是频繁的,因此创建私有工具方法swap,接收两个参数(节点内容位于数组中的位置,即索引),对传入的两个参数进行节点交换。交换节点是一个基础入门问题,A与B之间如何互换,有以下两种方式: (1)创建临时变量C,A赋值C,B赋值A,C赋值B,实现A与B内容互换。 (2)解构赋值,[A,B] = [B,A]。 两种方式都可以,在之前有介绍过解构赋值的原理。 ```ts class Heap<T> { // 属性 private data: T[] = [] private length: number = 0 // 私有工具方法 private swap(i: number, j: number) { const temp = this.data[i] this.data[i] = this.data[j] this.data[j] = temp } // 方法 insert(value: T) {} extract(): T | undefined { return undefined } peek(): T | undefined { } get size() { } isEmpty() { } buildHeap(arr: T[]) { } } export {} ``` 我们接下来主要实现的是堆结构中如何插入新元素、如何删除最大/最小元素以及通过列表构造堆。以下的方法实现,都是以最大堆作为案例(最大堆会更常见一些)。 ### 9.2.2 堆的插入操作与上浮 如果我们想实现一个最大堆,那么可以从实现insert()方法开始。因为堆结构一开始是空的,我们先往堆结构插入内容,一个有内容的堆去做查询,删除等操作才有意义。 在**二叉堆(最大堆)**中,堆必须始终满足一个核心不变式:**任意父节点的值都不小于其子节点的值**。而插入新元素时,为了保证结构仍然是一棵**完全二叉树**,新元素只能被放在数组末尾(即树的最底层、最右侧)。这个位置是由结构约束决定的,却**并不保证数值关系是正确的**,因此新插入的元素很可能比它的父节点更大,从而直接破坏最大堆的性质。如果不进行重构,后续的取最大值、删除堆顶等操作就会失去正确性。 为恢复最大堆性质,插入后需要执行**向上调整(上浮,sift-up)**:将新元素与其父节点比较,若新元素更大,则交换两者;交换后继续向上比较,直到它不再大于父节点,或已经成为堆顶为止。这个过程只沿着从插入位置到根节点的一条路径进行,时间复杂度为 **O(log n)**,却能在不破坏完全二叉树结构的前提下,高效地重新建立整个堆的有序性。这正是插入后必须进行堆重构的根本原因。 这很有意思,呼应了9.1.3小节中的Top K问题。总的来说,由于二叉堆(最大堆)的底层是数组存储,随意的将新元素插入到数组的任意位置会破坏完全二叉树(插入位置会导致后续元素全部后移一位),因此每次新元素只能先放到数组的最后面,先保证插入元素后,最大堆依旧是完全二叉树。在确定完全二叉树因素后,将插入元素与父节点不断比对,插入元素比父节点则两节点互换,然后重复比对互换操作,直到插入元素达到它应该到达的位置。从完全二叉树的形式看,插入元素在不断互换的过程,是一个攀升的过程,因此将其称为上浮。 现在来分析堆的插入操作是怎么样的,堆分2种情况: (1)堆是空的。 (2)堆有元素。 不同情况的操作方式不同,如果堆是空的,那么插入的元素就直接push到data数组作为根节点。 如果堆有元素,需要按顺序操作以下3点步骤: (1)将插入元素push到数组的尾部(保证堆处于完全二叉树状态)。 (2)将插入元素与父节点的大小比对,插入元素更大则执行swap()私有工具方法来交换节点。 (3)重复比对交换操作,直到插入元素上浮到对应位置(父节点大于插入元素或者插入元素到达根节点位置)。 步骤2与步骤3是为了保证堆符合最大堆的特性。插入元素上浮最多log₂n次,假设数组里有100万数据,那最多也就上浮20次,效率是很高的。在重复比对交换的过程中,一定要时刻更新插入元素所处的索引,每次元素比对交换过后,插入元素的索引都会发生变化,下一次上浮比对,无论是定位新的父节点还是插入元素的位置,都需要用到插入元素的新索引。 堆无论是空的还是有元素,都需要将插入元素push到数组尾部。堆有元素的情况,需要涉及到上浮操作。 ```ts insert(value: T) { this.data.push(value) this.length++ if (this.length > 1) { // 插入元素的位置 let index = this.length - 1 // 终止条件:上浮到根节点的位置 while (index > 0) { const fatherIndex = Math.floor((index - 1) / 2) if (this.data[index] <= this.data[fatherIndex]) { break } this.swap(index, fatherIndex) index = fatherIndex } } } ``` 由于插入元素上浮在多处地方都可以使用到,因此单独封装一个heapify_up()上浮方法。this.length > 1的判断堆是否为空可加可不加,因为上浮操作中的循环判定条件index > 0已经完成相应判断。加上的可读性会更好一些。 ```ts class Heap<T> { private data: T[] = [] private length: number = 0 // 私有工具方法 private swap(i: number, j: number) { const temp = this.data[i] this.data[i] = this.data[j] this.data[j] = temp } insert(value: T) { this.data.push(value) this.length++ if (this.length > 1) this.heapify_up() } // 上浮操作 private heapify_up() { // 插入元素的位置 let index = this.length - 1 // 终止条件:上浮到根节点的位置 while (index > 0) { // 父节点位置 const fatherIndex = Math.floor((index - 1) / 2) // 父节点大于插入元素 if (this.data[index] <= this.data[fatherIndex]) { break } // 父节点小于插入元素 交换节点 this.swap(index, fatherIndex) // 更新插入元素的最新索引 index = fatherIndex } } HeapData() { console.log(this.data); } get size() { return this.length } extract() { } peek() { } isEmpty() { } build_heap() { } } const MaxHeap = new Heap<number>() // 测试用例 const arr = [19, 100, 36, 17, 3, 25, 1, 2, 7] for (const item of arr) { MaxHeap.insert(item) } // 打印数组存储内容 MaxHeap.HeapData() // 数组存储内容 // [ // 100, 19, 36, 17, 3, // 25, 1, 2, 7 // ] ``` 不同的插入顺序,所产生的完全二叉树不一定完全相同,只能确保最大值是一致,而我们需要关心的也只有最大值(数组中的第一个值)。 ### 9.2.3 可视化网站推荐 虽然最大堆最需要关心的最大值位置可以从数组中很快速的看到。但有些时候,我们也想看最大堆的整体树形结构中,是如何按照从大到小逐层排列以及插入元素是如何一步步上浮的。在实现堆的其余方法后,也可以利用可视化网站来查看堆的常见方法的可视化上浮或下沉过程。 网站1:[Binary Heap (Priority Queue) - VisuAlgo](https://visualgo.net/en/heap)。 网站2:[Data Structure Visualization](https://www.cs.usfca.edu/~galles/visualization/Algorithms.html) PS:加利福尼亚州的旧金山大学提供。 假如我们现在有一个如图9-3所示的最大堆,要插入120,其中的变化过程是怎么样的。有可视化的树形动画效果可以直观的感受,降低学习难度,但过度依赖可视化就有可能出现不使用该可视化就写不出来代码的情况。  <p align="center"> <b>图9-3 最大堆-插入元素insert</b> </p> ### 9.2.4 堆的删除操作与下沉 删除操作也需要考虑在删除元素后的操作,因为每次删除元素后,堆结构会空出一个位置,如果删除的元素不是最后一个元素,就会破坏堆的完全二叉树结构,需要对堆进行重构,以维护最大堆的性质。 如果在堆中**直接删除某个位置的元素**(尤其是堆顶或中间节点),并试图让其子节点沿着路径逐层“向前、向上填补空位”,那么这一做法会带来两个严重问题:第一,填补路径并不唯一,需要在左右子树之间反复选择,逻辑复杂且容易出错;第二,每次填补都会改变多个节点的父子关系,很可能在填补过程中**多次破坏最大堆性质**,从而不得不在多个方向上反复调整,整体实现复杂且难以保证效率。 正规的做法是采用“末尾元素替换 + 下沉(sift-down)”策略:先用数组最后一个元素替换被删除的元素位置(通常是堆顶),再删除数组末尾,从而一次性恢复完全二叉树结构。此时,唯一可能被破坏的只是新放上来的这个元素与其子节点之间的大小关系,于是只需让它沿着一条路径向下与更大的子节点交换,直到满足最大堆性质为止。让末尾元素填补被删除的空缺位置,做到了只有一个元素不符合最大堆,最小程度的破坏最大堆的特性。这样,结构修复是 O(1),有序性修复是 O(log n),既避免了路径级联填补的复杂性,又保证了堆操作的高效与可控性。 在实现二叉堆(最大堆)的删除操作时,可以将逻辑清晰地划分为两种情况: (1)删除的元素本身就是堆的最后一个元素(即位于最后一层最右侧的叶子节点,对应数组的末尾)。此时直接删除该元素即可,既不会破坏完全二叉树的结构,也不会影响最大堆的性质,无需进行任何额外调整。 (2)删除的元素位于除上述位置以外的任意节点。这时若直接删除,会在堆中间留下“空洞”,从而破坏完全二叉树结构。正确的做法是:先将该元素与数组末尾的元素交换位置,再删除数组末尾的元素。这样可以在 O(1) 的时间内恢复完全二叉树结构。随后,只需针对被交换上来的这个元素执行堆的重构(根据其与父节点或子节点的大小关系,进行下沉或必要时的上浮),使其回到正确的位置,从而重新维护最大堆的性质。 堆的删除操作与元素下沉如图9-4所示。  <p align="center"> <b>图9-4 堆的删除操作</b> </p> 最大堆的删除操作有很多种,在代码上的表现形式也不同,常见的有以下3种: (1)可以先将该元素与数组末尾的元素交换位置,再删除数组末尾的元素。 (2)直接用数组末尾的元素覆盖在被删除元素的位置上,然后删除数组末尾元素。 (3)使用Array.prototype.pop()实例方法将数组末尾元素删除,并将删除的末尾元素赋值到被删除元素位置。 无论哪种删除操作,最终都需要完成元素下沉操作。 最大堆的元素个数为0或者为1的代码如下: ```ts extract(): T | undefined { if (this.length === 0) return undefined if (this.length === 1) { this.length-- return this.data.pop() } } ``` 当最大堆的元素个数大于1的情况,需要使用到下沉操作,下沉操作与插入方法中的上浮操作类似,因此我们将下沉操作也单独封装为一个私有方法heapify_down,最后在extract()方法中使用。 将目标元素下沉,需要获取目标元素的左子节点和右子节点。对比左右子节点的大小,获取值较大的子节点,将该子节点与目标节点的值进行比较,若目标节点的值更小,则交换节点位置,反之则终止下沉。交换完位置,需要更新目标元素的索引,然后重复以上交换操作,最终令目标元素下沉到对应位置(下沉范围需要在二叉树范围,超出范围则终止下沉),完成最大堆重构。 ```ts private heapify_down() { // 3.1.定义索引位置 let index = 0 while (2 * index + 1 < this.length) { // 3.2.找到左右子节点 let leftChildIndex = 2 * index + 1 let rightChildIndex = leftChildIndex + 1 // 3.3.找到左右子节点较大的值 let largerIndex = leftChildIndex if (rightChildIndex < this.length && this.data[rightChildIndex] > this.data[leftChildIndex]) { largerIndex = rightChildIndex } // 3.4.较大的值和index位置进行比较 if (this.data[index] >= this.data[largerIndex]) { break } // 3.5.交换位置 this.swap(index, largerIndex) index = largerIndex } } ``` 最后,回到extract()方法中完成最大堆删除操作中的情况2,实现下沉操作的同时,将提取的最大值返回出去。 ```ts /** 提取操作 */ extract(): T | undefined { // 1.判断元素的个数为0或者1的情况 if (this.length === 0) return undefined if (this.length === 1) { this.length-- return this.data.pop()! } // 2.提取并且需要返回的最大值 const topValue = this.data[0] this.data[0] = this.data.pop()! this.length-- // 3.维护最大堆的特性: 下滤操作 this.heapify_down() return topValue } ``` 测试用例如下: ```ts const MaxHeap = new Heap<number>() // 测试用例 const arr = [19, 100, 36, 17, 3, 25, 1, 2, 7] for (const item of arr) { MaxHeap.insert(item) } MaxHeap.extract() // 打印数组存储内容 MaxHeap.HeapData() // 删除前 // [ // 100, 19, 36, 17, 3, // 25, 1, 2, 7 // ] // 删除后 // [ // 36, 19, 25, 17, // 3, 7, 1, 2 // ] ``` ### 9.2.5 堆的其他操作方法 在实现堆结构的初始化搭建中,有3个较为简单的操作方法,分别是: (1)peek()方法获取最大最小值,即根节点的位置(数组存储,数组的第一个元素)。 (2)size()属性方法获取堆结构长度(数组存储,所以获取的是数组的长度)。 (3)isEmpty()方法判断堆结构是否为空(数组存储,所以判断数组长度是否为0)。 ```ts peek(): T | undefined { return this.data[0] } get size() { return this.length } isEmpty() { return this.length === 0 } ``` ## 9.3 堆的高级应用 ### 9.3.1 原地建堆算法 原地建堆(In-place heap construction)是指在将一个无序数组转换为堆(如最大堆或最小堆)时,不借助任何额外的数据结构或辅助数组,而是直接在原数组本身上通过元素交换与调整完成建堆过程。 我们之前的建堆方式是将无序数组通过for of遍历出单独的元素,将单独的元素插入到另一个存储堆结构的数组中,每一次插入都会触发上浮操作,使存储堆结构的数组始终能够保持最大堆的性质。 ```ts const MaxHeap = new Heap<number>() // 测试用例 const arr = [19, 100, 36, 17, 3, 25, 1, 2, 7] for (const item of arr) { MaxHeap.insert(item) } ``` 上述的建堆方式,将建堆与初始化拆解成两部分,即初始化的最大堆一定是空的。插入元素与"建堆"的含义过于绑定在一起。 有时候,希望将建堆与插入元素在做法上区分开,从方法的使用上将原地建堆与插入元素拆分,可以避免功能上的混淆。如下代码示例。 ```ts // 初始化最大堆的同时,完成原地建堆 const arr = [19, 100, 36, 17, 3, 25, 1, 2, 7] const MaxHeap = new Heap<number>() // 原地建堆 MaxHeap.buildHeap(arr) ``` 可原地建堆所调用的buildHeap()方法从使用角度来看,基本上只需要使用一次,后续对堆结构的变动只需要在原有基础上去修修改改。那么我们希望在初始化最大堆的实例对象时,就能够同时完成建堆的操作,将buildHeap()方法与初始化堆结合在一起,如下代码示例。 ```ts // 初始化最大堆的同时,完成原地建堆 const arr = [19, 100, 36, 17, 3, 25, 1, 2, 7] const MaxHeap = new Heap<number>(arr) ``` 将buildHeap()方法与初始化堆结合在一起,要如何去做?可以通过如下3步骤: (1)在创建 Heap 类实例(如 MaxHeap)时,将数组 arr 作为构造参数传入构造函数,用于初始化实例内部的数据存储。 (2)构造函数在完成基础初始化后,调用实例方法 buildHeap(),在原数组上执行原地建堆操作,将无序数组调整为满足最大堆性质的结构。 (3)建堆完成后,堆结构由该实例对象持有并维护,后续的插入、删除等操作均基于这一已建好的堆进行。 那么梳理清楚思路后,来完成代码的编写,首先完成基础框架的初始化,将数组 arr 作为构造参数传入构造函数,并调用实例方法buildHeap()来完成原地建堆。当开发者未传入数组arr,则数组arr是空的,长度为0,那么无需原地建堆,做好边界判断。 ```ts class Heap<T> { // 属性 data: T[] = [] private length: number = 0 // 省略其余暂时用不到的代码部分 constructor(arr: T[] = []) { if (arr.length === 0) return this.buildHeap(arr) } buildHeap(arr: T[]) { } } const arr = [9, 11, 20, 56, 23, 45] const heap = new Heap<number>(arr) ``` 根据如上原地建堆的基础框架,主要需要实现的是实例方法buildHeap()。目前实例方法buildHeap()通过参数传递的方式已经拿到无序数组arr,将无序数组转换成堆结构的数组存储,必然是需要使用到上浮或者下沉操作的。 原地建堆操作是直接在原数组本身上通过元素交换与调整完成建堆过程,因此需要用尽可能少的交换次数来完成建堆。 那么在原地建堆中,上浮与下沉哪一种方式的效率更高?在原地建堆(buildHeap)里,用“下沉(sift-down)”的方式效率更高,也是标准做法;而用“上浮(sift-up)”逐个插入虽然也能建成堆,但整体更慢。原因如下: - 上浮建堆相当于把数组元素一个个当作“插入操作”塞进堆里:每插入一个元素,最坏要上浮到根,代价是 O(log n);做 n 次就是 O(n log n)。 - 下沉建堆是从最后一个非叶子节点开始,逐个对节点做下沉,让每棵子树先变成堆:靠近底部的节点高度很小,下沉距离短,只有少数靠近根的节点才可能下沉较多,因此总成本被摊薄,整体是 O(n)。最后一个非叶子节点如图9-5所示。  <p align="center"> <b>图9-5 最后一个非叶子节点</b> </p> 在原地建堆中采用**下沉(sift-down)**的方式,是因为虽然需要对多个节点执行下沉操作,但**绝大多数节点位于完全二叉树的底部附近**。这些节点下面的层数很少,哪怕发生下沉,最多也只需移动一两步,甚至完全不需要移动;真正可能下沉较多的,只有极少数靠近根节点的元素。由于节点数量随着层级向上迅速减少,能下沉很多层的节点数量非常少,而数量最多的节点几乎不消耗调整成本。把“节点数量 × 每个节点的最大下沉距离”加在一起,整体开销被平均(摊薄)下来后,只与元素个数成正比,因此自底向上的下沉建堆时间复杂度为 O(n),而不是 O(n log n)。 以上是从数据分析的角度去理解,而在做决策时,我们可以从逻辑方面去分析: 最大堆呈上窄下宽的金字塔形状,绝大多数元素都在底部。最大堆的底部元素的下沉次数少(最多也就一两层就到最底部了),因此减少最多元素部分的交换次数,是最能有效减少最大堆整体元素交换次数的关键。下沉操作在这方面比上浮更具备优势。 代码编写思路如下3步: (1)获取最后一个非叶子节点(最后一个节点的父节点),固定公式floor( (i – 1) / 2 )。 (2)堆结构从最后一个非叶子节点往上层按顺序一个个去比对元素大小(比对到根元素为止结束),完成下沉操作,实现最大堆的性质。 ```ts buildHeap(arr: T[]) { // 1.使用arr的值: 数组/长度 this.data = arr this.length = arr.length // 2.从第一个非叶子节点, 开始进行下滤操作 const start = Math.floor(this.length / 2 - 1) for (let i = start; i >= 0; i--) { this.heapify_down(i) } } ``` 由于实例方法heapify_down()的下沉操作,在之前是固定从根节点作为初始索引,为了配合特定位置的元素下沉,需要做出一点修改,支持传入索引参数作为初始索引。 ```ts private heapify_down(start: number) { // 3.1.定义索引位置 let index = start while (2 * index + 1 < this.length) { // 3.2.找到左右子节点 let leftChildIndex = 2 * index + 1 let rightChildIndex = leftChildIndex + 1 // 3.3.找到左右子节点较大的值 let largerIndex = leftChildIndex if (rightChildIndex < this.length && this.data[rightChildIndex] > this.data[leftChildIndex]) { largerIndex = rightChildIndex } // 3.4.较大的值和index位置进行比较 if (this.data[index] >= this.data[largerIndex]) { break } // 3.5.交换位置 this.swap(index, largerIndex) index = largerIndex } } ``` 最大堆到目前为止,完整的代码示例如下: ```ts class Heap<T> { private data: T[] = [] private length: number = 0 constructor(arr: T[] = []) { if (arr.length === 0) return this.buildHeap(arr) } // 私有工具方法 private swap(i: number, j: number) { const temp = this.data[i] this.data[i] = this.data[j] this.data[j] = temp } insert(value: T) { this.data.push(value) this.length++ if (this.length > 1) this.heapify_up() } // 上浮操作 private heapify_up() { // 插入元素的位置 let index = this.length - 1 // 终止条件:上浮到根节点的位置 while (index > 0) { // 父节点位置 const fatherIndex = Math.floor((index - 1) / 2) // 父节点大于插入元素 if (this.data[index] <= this.data[fatherIndex]) { break } // 父节点小于插入元素 交换节点 this.swap(index, fatherIndex) // 更新插入元素的最新索引 index = fatherIndex } } HeapData() { console.log(this.data); } /** 提取操作 */ extract(): T | undefined { // 1.判断元素的个数为0或者1的情况 if (this.length === 0) return undefined if (this.length === 1) { this.length-- return this.data.pop()! } // 2.提取并且需要返回的最大值 const topValue = this.data[0] this.data[0] = this.data.pop()! this.length-- // 3.维护最大堆的特性: 下滤操作 this.heapify_down() return topValue } private heapify_down(start: number) { // 3.1.定义索引位置 let index = start while (2 * index + 1 < this.length) { // 3.2.找到左右子节点 let leftChildIndex = 2 * index + 1 let rightChildIndex = leftChildIndex + 1 // 3.3.找到左右子节点较大的值 let largerIndex = leftChildIndex if (rightChildIndex < this.length && this.data[rightChildIndex] > this.data[leftChildIndex]) { largerIndex = rightChildIndex } // 3.4.较大的值和index位置进行比较 if (this.data[index] >= this.data[largerIndex]) { break } // 3.5.交换位置 this.swap(index, largerIndex) index = largerIndex } } get size() { return this.length } peek(): T | undefined { return this.data[0] } isEmpty() { return this.length === 0 } buildHeap(arr: T[]) { // 1.使用arr的值: 数组/长度 this.data = arr this.length = arr.length // 2.从第一个非叶子节点, 开始进行下滤操作 const start = Math.floor(this.length / 2 - 1) for (let i = start; i >= 0; i--) { this.heapify_down(i) } } } const MaxHeap = new Heap<number>() // 测试用例 const arr = [19, 100, 36, 17, 3, 25, 1, 2, 7] for (const item of arr) { MaxHeap.insert(item) } MaxHeap.extract() // 打印数组存储内容 MaxHeap.HeapData() ``` ### 9.3.2 最小堆(原地建堆) 在9.3.1小节中,实现了最大堆的原地建堆。而最小堆的原地建堆是类似的原理,只需要修改维护最大堆的私有工具方法heapify_up()的一部分代码就可以实现最小堆的原地建堆。 私有工具方法heapify_up()中的this.data[index]需要大于等于this.data[parentIndex],即子节点比父节点大的时候,终止元素交换,在这种交换规则下,堆结构就会呈现上小下大的布局分层,而根节点则会是最小值。 ```ts private heapify_up() { let index = this.length - 1 while (index > 0) { let parentIndex = Math.floor((index - 1) / 2) // 最小堆的比对修改 if (this.data[index] >= this.data[parentIndex]) { break } this.swap(index, parentIndex) index = parentIndex } } ``` 完成私有工具方法heapify_up()的修改,可以通过for of遍历无序数组的方式,将二叉堆的性质转变为最小堆。 ```ts const arr = [19, 100, 36, 17, 3, 25] // 最小堆测试插入操作 // const heap = new Heap<number>() // for (const item of arr) { // heap.insert(item) // } ``` 但此时最小堆在私有工具方法heapify_down()进行删除操作时,就会出现问题。原代码如下: ```ts private heapify_down(start: number) { // 3.1.定义索引位置 let index = start while (2 * index + 1 < this.length) { // 3.2.找到左右子节点 let leftChildIndex = 2 * index + 1 let rightChildIndex = leftChildIndex + 1 // 3.3.找到左右子节点较大的值 let largerIndex = leftChildIndex if (rightChildIndex < this.length && this.data[rightChildIndex] > this.data[leftChildIndex]) { largerIndex = rightChildIndex } // 3.4.较大的值和index位置进行比较 if (this.data[index] >= this.data[largerIndex]) { break } // 3.5.交换位置 this.swap(index, largerIndex) index = largerIndex } } ``` 修改私有工具方法heapify_down(),使其适用于最小堆: (1)this.data[rightChildIndex] < this.data[leftChildIndex]:寻找左右子节点中,更小的值。 (2)this.data[index] <= this.data[largerIndex]:父节点如果小于左右子节点中更小的那个节点,则终止节点交换(下沉)。 ```ts private heapify_down(start: number) { // 省略无变动代码 let largerIndex = leftChildIndex if (rightChildIndex < this.length && this.data[rightChildIndex] < this.data[leftChildIndex]) { largerIndex = rightChildIndex } if (this.data[index] <= this.data[largerIndex]) { break } // 省略无变动代码 } ``` 测试最小堆的提取方法extract(),所导致的删除节点可正常执行。 ```ts // 2.测试提取/删除操作 while (!heap.isEmpty()) { console.log(heap.extract()) } ``` 由于批量建堆使用到的是下沉操作,而在测试最小堆的提取方法extract()时,已修改下沉操作,因此最小堆的批量建堆可以直接使用。 ```ts // 3.测试批量建堆 const heap = new Heap<number>(arr) console.log(arr) console.log(heap.extract()) ``` ### 9.3.3 最大堆与最小堆的合并 如果我想将最大堆与最小堆合并在一个类中,能不能做到? 当然是可以的,最大堆和最小堆的区别只在于3个地方的大小比对上相反而已: (1)维护最大堆或者最小堆结构的实例方法heapify_up(),有一处地方。 (2)下沉操作的实例方法heapify_down(),有两处地方。 在3处地方,加上判断走最大堆或者最小堆的逻辑(在实例化对象时,额外传入布尔值用于控制当前实例对象是要走最大堆还是最小堆的逻辑),修改一下比对形式以及元素交换条件,就可以完成最大堆与最小堆的合并。 默认走最大堆逻辑,当布尔值为false时,走最小堆逻辑。 ```ts export default class Heap<T> { // 属性 private data: T[] = [] private length: number = 0 private isMax: boolean constructor(arr: T[] = [], isMax = true) { this.isMax = isMax if (arr.length === 0) return this.buildHeap(arr) } } // 3.测试批量建堆 const heap = new Heap<number>(arr, false) ``` 然后对最大堆与最小堆有差异的三处地方,做出封装和判断,封装出私有工具方法compare(),通过传入两个索引,用于判断走最大堆或者最小堆的逻辑,最后返回布尔值。PS:因为最大堆与最小堆有差异的三处地方都是通过索引,判断子节点与父节点之间是否要交换。所以私有工具方法compare()只需要通过子节点与父节点的索引还有最大/小堆的判断,就可以实现父子节点之间是否要交换位置的判断。 ```ts private compare(i: number, j: number): boolean { if (this.isMax) { return this.data[i] >= this.data[j] } else { return this.data[i] <= this.data[j] } } ``` 使用私有工具方法compare()的三处地方如下,完成二叉堆(最大堆与最小堆)类的实现。通过复制最大堆的完整代码,添加isMax属性和构造函数中实现赋值,替换掉以下两个私有工具方法heapify_up()和heapify_down()以及添加私有工具方法compare()即可完成二叉堆的重构。 总结:对3个私有方法的重构。 ```ts private heapify_up() { let index = this.length - 1 while (index > 0) { let parentIndex = Math.floor((index - 1) / 2) // 位置一 if (this.compare(parentIndex, index)) { break } this.swap(index, parentIndex) index = parentIndex } } private heapify_down(start: number) { let index = start while (2 * index + 1 < this.length) { let leftChildIndex = 2 * index + 1 let rightChildIndex = leftChildIndex + 1 let largerIndex = leftChildIndex // 位置二 if (rightChildIndex < this.length && this.compare(rightChildIndex, leftChildIndex)) { largerIndex = rightChildIndex } // 位置三 if (this.compare(index, largerIndex)) { break } this.swap(index, largerIndex) index = largerIndex } } ``` ### 9.3.4 二叉堆的打印 在前面无论是最大堆还是最小堆的打印,都是以数组的形式去展现的,并不能够直观的展现我们最大堆/最小堆的二叉树可视化展示效果。9.2.3小节的可视化网站是可视化的固定输入效果,适合理解数据结构的运行过程,但不能看出我们实际代码运行的效果。 因此可以安装第三方库hy-algokit,用于在控制台可视化打印输出效果。 ```ts pnpm i hy-algokit ``` 使用方式如下: ```ts import { cbtPrint } from 'hy-algokit' // 在二叉堆类中实现以下方法 print() { cbtPrint(this.data) } ``` 完成二叉堆的可视化打印: ```ts // 测试用例 const arr = [19, 100, 36, 17, 3, 25, 1, 2, 7] const MaxHeap = new Heap<number>(arr, false) console.log(arr) cbtPrint(arr) console.log(MaxHeap.extract()) MaxHeap.print() ``` 二叉堆可视化打印如图9-6所示。  <p align="center"> <b>图9-6 二叉堆可视化打印</b> </p> 二叉堆完整代码如下: ```ts import { cbtPrint } from 'hy-algokit' class Heap<T> { private data: T[] = [] private length: number = 0 private isMax: boolean constructor(arr: T[] = [], isMax = true) { this.isMax = isMax if (arr.length === 0) return this.buildHeap(arr) } // 私有工具方法 private swap(i: number, j: number) { const temp = this.data[i] this.data[i] = this.data[j] this.data[j] = temp } insert(value: T) { this.data.push(value) this.length++ if (this.length > 1) this.heapify_up() } // 上浮操作 private heapify_up() { let index = this.length - 1 while (index > 0) { let parentIndex = Math.floor((index - 1) / 2) // 位置一 if (this.compare(parentIndex, index)) { break } this.swap(index, parentIndex) index = parentIndex } } private compare(i: number, j: number): boolean { if (this.isMax) { return this.data[i] >= this.data[j] } else { return this.data[i] <= this.data[j] } } private heapify_down(start: number) { let index = start while (2 * index + 1 < this.length) { let leftChildIndex = 2 * index + 1 let rightChildIndex = leftChildIndex + 1 let largerIndex = leftChildIndex // 位置二 if (rightChildIndex < this.length && this.compare(rightChildIndex, leftChildIndex)) { largerIndex = rightChildIndex } // 位置三 if (this.compare(index, largerIndex)) { break } this.swap(index, largerIndex) index = largerIndex } } HeapData() { console.log(this.data); } /** 提取操作 */ extract(): T | undefined { // 1.判断元素的个数为0或者1的情况 if (this.length === 0) return undefined if (this.length === 1) { this.length-- return this.data.pop()! } // 2.提取并且需要返回的最大值 const topValue = this.data[0] this.data[0] = this.data.pop()! this.length-- // 3.维护最大堆的特性: 下滤操作 this.heapify_down(0) return topValue } get size() { return this.length } peek(): T | undefined { return this.data[0] } isEmpty() { return this.length === 0 } buildHeap(arr: T[]) { // 1.使用arr的值: 数组/长度 this.data = arr this.length = arr.length // 2.从第一个非叶子节点, 开始进行下滤操作 const start = Math.floor(this.length / 2 - 1) for (let i = start; i >= 0; i--) { this.heapify_down(i) } } print() { cbtPrint(this.data) } } // 测试用例 const arr = [19, 100, 36, 17, 3, 25, 1, 2, 7] const MaxHeap = new Heap<number>(arr, false) console.log(arr) cbtPrint(arr) console.log(MaxHeap.extract()) MaxHeap.print() ```
第8章 高阶队列结构
## 8.1 双端队列 在2.3小节中,有学习过基础的队列(Queue)结构,它是一种受限的线性结构,并且限制非常的严格。本章会在基础的队列结构上,学习进阶的双端队列(Deque)结构。 ### 8.1.1 双端队列的概念与特性 双端队列在单向队列的基础上解除了一部分限制:允许在队列的两端添加(入队)和删除(出队)元素。从双端队列的每一端(前端或者后端)来看,都有点像栈结构,可以在同一端压入和弹出元素。 双端队列如图8-1所示。  <p align="center"> <b>图8-1 双端队列</b> </p> 因为双端队列解除了一部分限制,所以在解决一些特定问题时会更加的方便。比如滑动窗口问题:https://leetcode.cn/problems/sliding-window-maximum/description/。滑动窗口最大值如图8-2所示。  <p align="center"> <b>图8-2 滑动窗口最大值</b> </p> 双端队列的概念是易于理解的,接下来用代码来实现。 ### 8.1.2 双端队列的代码实现 双端队列可以基于普通队列结构去实现,只需要在普通队列的基础上做两件事情: (1)在普通队列中允许出队的一端,同时允许入队。 (2)在普通队列中允许入队的一端,同时允许出队。 所以双端队列需要继承普通队列结构,在原有基础上去添加以上两个步骤即可。普通队列结构的代码如下: ```ts interface IList<T> { // peek peek(): T | undefined // 判断是否为空 isEmpty(): boolean // 元素的个数 size(): number } interface IQueue<T> extends IList<T> { // 入队方法 enqueue(element: T): void // 出队方法 dequeue(): T | undefined } class ArrayQueue<T> implements IQueue<T> { // 内部是通过数组(链表)保存 protected data: T[] = [] enqueue(element: T): void { this.data.push(element) } dequeue(): T | undefined { return this.data.shift() } peek(): T | undefined { return this.data[0] } isEmpty(): boolean { return this.data.length === 0 } size(): number { return this.data.length } } export default ArrayQueue ``` 双端队列会继承自ArrayQueue。双端队列的初始化搭建如下: ```ts class ArrayDeque<T> extends ArrayQueue<T> { addFront(element: T): void {} removeBack(): T | undefined { return undefined } } export default ArrayDeque ``` 普通队列中的入队方法是enqueue(),出队方法是dequeue(),即数组尾部入队,数组首部出队。PS:因为之前实现普通队列结构的底层是数组,因此双端队列会继承这一特点。除了数组之外,普通队列以及双端队列的底层也可以基于链表来实现。 我们将普通队列中的出队入队方法的逻辑反过来,直接用在addFront()与removeBack()方法上即可。从数组首部入队,数组尾部出队。 ```ts class ArrayDeque<T> extends ArrayQueue<T> { // 从队列头部添加元素 addFront(element: T): void { this.data.unshift(element) } // 从队列尾部删除元素 removeBack(): T | undefined { return this.data.pop() } } export default ArrayDeque ``` 完成双端队列的代码编写,需要验证双端队列(Deque)是否能同时正确支持「头部插入」和「尾部删除」,并保证元素顺序符合预期(FIFO/LIFO 混合场景下的正确性),测试代码如下: ```ts const deque = new ArrayDeque<string>() deque.enqueue("aaa") deque.enqueue("bbb") deque.enqueue("ccc") deque.addFront("abc") deque.addFront("cba") while (!deque.isEmpty()) { console.log(deque.removeBack()) } ``` ### 8.1.3 双端队列的应用场景 双端队列主要有两类应用。 第一类典型应用是需要同时高效处理“头部”和“尾部”操作的场景。比如**滑动窗口问题**(取最大值/最小值),会不断从队尾移除无用元素、从队头移除过期元素;又比如**任务调度或消息缓冲**,紧急任务可以从队头插入,普通任务从队尾进入,同时还能从任一端取出处理。双端队列在这些场景下可以把原本需要多种数据结构配合的逻辑,压缩成一种结构完成。 这一类的典型应用在8.1.1小节介绍概念的时候,有了解过,会在第13章 刷LeetCode题目中,来练习。 第二类应用是在栈和队列之间自由切换的算法设计。双端队列既可以当栈用(只在一端进出),也可以当队列用(一端进、另一端出),还可以混合使用,这在**回溯、表达式求值、回文判断、BFS 的 0-1 权重最短路径**等算法中非常常见。它的价值不在于“新功能”,而在于用统一的接口,降低算法状态切换和边界处理的复杂度。 ## 8.2 优先级队列 什么是优先级?在生活中,我往往会将面临的事情从重要与紧急两个角度来区分,从而形成以下4种优先度级别: (1)重要且紧急。 (2)重要且不紧急。 (3)不重要且紧急。 (4)不重要且不紧急。 我会按顺序处理事情,该顺序即是优先级。很多时候,我们的时间往往会被不重要且紧急的事情所占据,从而无法处理对我们未来发展更重要但不紧急的事情。要权衡其中的平衡,不要让不重要且紧急的事情对我们生活做太多次越界,做得越好,我们生活的效率与质量就会越高。 优先级队列就是基于以上思考映射的一种数据结构,让我们开始学习它吧! ### 8.2.1 优先级队列的概念与特性 优先级队列(Priority Queue)是一种比普通队列更加高效的数据结构。可以把**优先级队列(Priority Queue)**理解成一种**“不完全按先来先走,而是按重要程度走”的队列**。和普通队列只关心谁先来不同,优先级队列中的每个元素都会带一个“优先级”,**每次出队的永远是当前优先级最高的元素**,而不是最早进入的那个。你可以把它想成:排队是存在的,但队伍里的人会根据“重要性”被重新安排位置。 现实中的例子非常直观。比如**机场登机**,头等舱、商务舱的人即使后到,也会比经济舱的人先登机;又比如**医院急诊室**,医生会优先处理病情严重的患者,而不是单纯按到达时间顺序。这些场景的共同点是:**顺序很重要,但“重要性”比“先后”更重要**,这正是优先级队列要解决的问题。 在计算机中,优先级队列常用于**任务调度**。例如操作系统或线程池里,不同任务的重要性不同:系统关键任务、实时任务要比普通后台任务更早被处理。通过给任务设置优先级,优先级队列就可以自动帮我们**把更重要的任务排到前面执行**,而程序员不需要手动维护复杂的排序逻辑。 至于实现方式,优先级队列可以用数组、链表等结构来做,但**最常用的是“堆(Heap)”**。堆并不是把所有元素完全排好序,而是保证“最大(或最小)的元素总是在队头”,这样**入队和出队都能在较高效率下完成**。对初学者来说,可以先记住一句话:**优先级队列 = 带权重的队列,而堆是实现它的最佳工具**。 ### 8.2.2 基于堆的优先级队列实现 优先级队列的实现方式一:创建优先级的节点,保存在堆结构中。 这种实现方式的核心思想是:把“元素本身”和“它的优先级”绑定在一起,作为一个节点存进堆中。每个节点通常包含两部分信息:`value`(真正要处理的数据)和 `priority`(优先级大小)。然后用堆(通常是最大堆或最小堆)来存放这些节点,堆的比较规则不是看 `value`,而是只根据 `priority` 来决定节点在堆中的位置。 当元素入队时,就把一个“带优先级的节点”插入堆中,堆会通过上浮操作自动调整结构,保证优先级最高的节点始终在堆顶;当元素出队时,直接取出堆顶节点即可,这个节点就是当前优先级最高的元素。这样一来,入队和出队的时间复杂度都能保持在 O(log n),既保证了顺序正确,又具备很高的执行效率。这也是实际工程中实现优先级队列最常见、最稳定的一种方式。 接下来,我们来封装优先级节点,然后再将节点存放进堆里面。 ```ts class PriorityNode<T> { priority: number value: T constructor(value: T, priority: number) { this.priority = priority this.value = value } // 方便比较大小 valueOf() { return this.priority } } // 创建的优先级节点实例 const p1 = new PriorityNode<string>("abc", 100) const p2 = new PriorityNode<string>("cba", 10) const p3 = new PriorityNode<string>("nba", 1000) const p4 = new PriorityNode<string>("mba", 10000) ``` 接下来我们需要一个堆结构,然后将创建的优先级节点实例放入堆结构内。堆结构代码如下所示: ```ts export default class Heap<T> { // 属性 private data: T[] = [] private length: number = 0 private isMax: boolean constructor(arr: T[] = [], isMax = true) { this.isMax = isMax if (arr.length === 0) return this.buildHeap(arr) } // 私有工具方法 private swap(i: number, j: number) { const temp = this.data[i] this.data[i] = this.data[j] this.data[j] = temp } private compare(i: number, j: number): boolean { if (this.isMax) { return this.data[i] >= this.data[j] } else { return this.data[i] <= this.data[j] } } // 方法 /** 插入操作 */ insert(value: T) { // 1.将元素放到数组的尾部 this.data.push(value) this.length++ // 2.维护最大堆的特性(最后位置的元素需要进行上滤操作) this.heapify_up() } private heapify_up() { let index = this.length - 1 while (index > 0) { let parentIndex = Math.floor((index - 1) / 2) if (this.compare(parentIndex, index)) { break } this.swap(index, parentIndex) index = parentIndex } } /** 提取操作 */ extract(): T | undefined { // 1.判断元素的个数为0或者1的情况 if (this.length === 0) return undefined if (this.length === 1) { this.length-- return this.data.pop()! } // 2.提取并且需要返回的最大值 const topValue = this.data[0] this.data[0] = this.data.pop()! this.length-- // 3.维护最大堆的特性: 下滤操作 this.heapify_down(0) return topValue } private heapify_down(start: number) { // 3.1.定义索引位置 let index = start while (2 * index + 1 < this.length) { // 3.2.找到左右子节点 let leftChildIndex = 2 * index + 1 let rightChildIndex = leftChildIndex + 1 // 3.3.找到左右子节点较大的值 let largerIndex = leftChildIndex if (rightChildIndex < this.length && this.compare(rightChildIndex, leftChildIndex)) { largerIndex = rightChildIndex } // 3.4.较大的值和index位置进行比较 if (this.compare(index, largerIndex)) { break } // 3.5.交换位置 this.swap(index, largerIndex) index = largerIndex } } /** 其他方法 */ peek(): T | undefined { return this.data[0] } size() { return this.length } isEmpty() { return this.length === 0 } buildHeap(arr: T[]) { // 1.使用arr的值: 数组/长度 this.data = arr this.length = arr.length // 2.从第一个非叶子节点, 开始进行下滤操作 const start = Math.floor((this.length - 1) / 2) for (let i = start; i >= 0; i--) { this.heapify_down(i) } } } ``` 堆结构的学习位于第9章,可先学习第9章再来回顾用堆实现的优先级队列。 将二叉堆作为优先级队列的底层存储,优先级队列的初始化搭建如下: ```ts import Heap from '二叉堆' // 优先级队列 class PriorityQueue<T> { private heap: Heap<PriorityNode<T>> = new Heap() } const pQueue = new PriorityQueue<string>() ``` 完成初始化搭建,来实现优先级队列应该有的方法,从enqueue()方法开始,该方法的作用为:将优先级节点插入堆结构中。 但将优先级节点插入堆结构中,又有两种方式: 优先级节点插入优先级队列的考量一是先实例化优先级节点,将优先级节点通过优先级队列的实例方法将其插入到优先级队列实例中。 ```ts class PriorityNode<T> { priority: number value: T constructor(value: T, priority: number) { this.priority = priority this.value = value } valueOf() { return this.priority } } class PriorityQueue<T> { private heap: Heap<PriorityNode<T>> = new Heap() enqueue(node: PriorityNode<T>) { this.heap.insert(node) } } // 创建的优先级节点实例 const p1 = new PriorityNode<string>("abc", 100) const p2 = new PriorityNode<string>("cba", 10) const p3 = new PriorityNode<string>("nba", 1000) const p4 = new PriorityNode<string>("mba", 10000) // 创建基于堆结构实现的优先级队列 const priorityQueue = new PriorityQueue<string>() // 往优先级队列中,插入优先级节点 priorityQueue.enqueue(p1) priorityQueue.enqueue(p2) priorityQueue.enqueue(p3) priorityQueue.enqueue(p4) ``` 优先级节点插入优先级队列的方式一,从代码层面简洁明了。但创建优先级节点实例是否要和往优先级队列中插入优先级节点区分开,是一个很有意思的话题。这件事情背后的考量是:耦合度是否要再高一些?是否要将创建优先级节点实例与往优先级队列中插入优先级节点合并在一起? 在是否合并的问题上,我认为是可以合并的。因为PriorityQueue类是优先级队列,优先级队列的底层二叉堆虽然接受任何类型的元素节点。但优先级队列不是,优先级队列只能接受优先级节点,如果有一天,往优先级队列中插入了不符合规定的节点,例如没有priority属性,那优先级队列就会失去了"优先"的特性。优先级节点和优先级队列应该是强绑定的。 所以,我认为优先级队列的enqueue()方法应该延伸出第二种考量(考量二):用户只需要往enqueue()方法中,传入数据value和优先度priority两个参数,enqueue()方法的内部将数据与优先度组装成优先级节点,然后将优先级节点插入到优先级队列中。 ```ts class PriorityNode<T> { priority: number value: T constructor(value: T, priority: number) { this.priority = priority this.value = value } valueOf() { return this.priority } } class PriorityQueue<T> { private heap: Heap<PriorityNode<T>> = new Heap() enqueue(value: T, priority: number) { const newNode = new PriorityNode(value, priority) this.heap.insert(newNode) } } // 往优先级队列中添加元素 const priorityQueue = new PriorityQueue<string>() priorityQueue.enqueue("abc", 100) priorityQueue.enqueue("cba", 10) priorityQueue.enqueue("nba", 1000) ``` 完成enqueue()方法后,我们来编写dequeue()、peek()、isEmpty(),size()这四个方法,都类似于之前所实现的方法逻辑。完整代码如下: ```ts class PriorityNode<T> { priority: number value: T constructor(value: T, priority: number) { this.priority = priority this.value = value } valueOf() { return this.priority } } class PriorityQueue<T> { private heap: Heap<PriorityNode<T>> = new Heap() enqueue(value: T, priority: number) { const newNode = new PriorityNode(value, priority) this.heap.insert(newNode) } dequeue(): T | undefined { return this.heap.extract()?.value } peek(): T | undefined { return this.heap.peek()?.value } isEmpty() { return this.heap.isEmpty() } size() { return this.heap.size() } } ``` 测试示例如下: ```ts const pQueue = new PriorityQueue<string>() pQueue.enqueue("why", 98) pQueue.enqueue("kobe", 90) pQueue.enqueue("james", 105) // 取出数据 while (!pQueue.isEmpty()) { console.log(pQueue.dequeue()) } ``` 最后,我们来快速解释一下刚才编写的四个方法: (1)dequeue():从优先级队列中取出并删除当前优先级最高的元素。调用堆的 extract() 取出堆顶的 PriorityNode,再返回其中的 value,堆结构会在内部自动完成下滤以保持堆性质。 (2)peek():查看当前优先级最高的元素,但不移除它。直接访问堆顶元素 heap.peek(),只读取其中的 value,不触发任何结构调整。 (3)isEmpty():判断优先级队列是否为空。将判断交给底层堆,实质是检查堆中元素个数是否为 0。 (4)size():获取当前优先级队列中元素的数量。直接返回底层堆维护的 length,不涉及任何堆操作。 这些方法大多是通过堆结构内部的方法所实现,因此并不困难,不在额外讲解。 ### 8.2.3 基于自定义比较的优先级队列实现 在8.2.2小节中,我们通过堆结构来实现了优先级队列。即通过传递数据+优先度,来决定哪些数据更重要。但如果是自定义的数据,例如一份学生表,表里包含每个学生的姓名与成绩信息,我希望通过学生的成绩来区分。如果照着原有优先级队列肯定也可以实现,只需要将学生成绩复制一份到优先级部分。 ```ts class Student { constructor(public name: string, public score: number) { } } // 成绩 98 const student1 = new Student("XiaoYu", 98) const pQueue = new PriorityQueue<Student>() // 将student1的成绩98复制一份给优先级 pQueue.enqueue(student1, 98) ``` 将学生成绩复制一份到优先级部分,并不是不可以。但每个学生的成绩都复制一份到优先级中,如果学生非常多呢?那这种做法是值得去优化的。如果不是学生表,是其他类型的,更复杂的表,想手动复制会更加麻烦。 因此,通过自定义数据的自我比较来实现优先级队列就显得尤为重要。这需要利用到Object.prototype.valueOf()方法将优先级交给对象自己,而不需要通过优先级节点。 valueOf() 的作用是:当对象参与大小比较、算术运算或需要被转换为原始值时,告诉 JavaScript“应该用什么数值来代表这个对象”;通过重写 valueOf(),可以让自定义对象在比较时自动按某个关键字段(如分数、权重、优先级)进行比较,从而在不复制额外数据的情况下,自然地融入排序、堆和优先级队列等通用算法中。 ```ts class PriorityQueue<T> { private heap: Heap<T> = new Heap() enqueue(value: T) { this.heap.insert(value) } dequeue(): T | undefined { return this.heap.extract() } peek(): T | undefined { return this.heap.peek() } isEmpty() { return this.heap.isEmpty() } size() { return this.heap.size() } } class Student { name: string score: number constructor(name: string, score: number) { this.name = name this.score = score } valueOf() { return this.score } } // 自定义比较 const pQueue = new PriorityQueue<Student>() // 在插入到堆的时候,会通过 this.core 自动排序 this.core代表了对象本身参与运算 pQueue.enqueue(new Student("why", 99)) pQueue.enqueue(new Student("kobe", 89)) pQueue.enqueue(new Student("james", 95)) pQueue.enqueue(new Student("curry", 88)) while (!pQueue.isEmpty()) { console.log(pQueue.dequeue()) } ```
第7章 高阶链表结构
从本章开始,我们会学习一些高阶的数据结构,包括对之前几章所学数据结构的扩展和新的数据结构(堆),在第二阶段(高阶数据结构)的学习中,会使用Node.js 24大版本在终端直接运行TypeScript代码(无需使用ts-node),那么开始吧。 ## 7.1 循环链表结构 在学习循环链表的过程中,会先简要了解什么是循环链表;其次通过重构单向链表来实现让循环链表继承,最后基于循环链表去实现对应的方法。 ### 7.1.1 循环链表的概念与特性 在第3章,我们从零封装了一个普通单向链表结构,在普通单向链表结构的基础上,可以封装更灵活的两种链表结构: (1)循环链表。 (2)双向链表。 循环链表(Circular LinkedList)是一种特殊的链表数据结构,是在普通链表的基础上,最后一个节点的下一个节点不再是 null,而是指向链表的第一个存储实际数据的节点。使链表形成了一个环,则链表能够被无限遍历。 只能沿一个固定方向读取数据的循环链表被称为单向循环链表,循环链表与双向链表的结合版本为双向循环链表。 注意:若头节点为哨兵节点,则头节点是第一个节点但不是第一个存储实际数据的节点。头节点的具体语义取决于具体的上下文和链表的设计,需要注意辨别。 这样,我们就可以在单向循环链表中从任意一个节点出发,不断地遍历下一个节点,直到回到起点。循环链表如图7-1所示。  <p align="center"> <b>图7-1 单向循环链表</b> </p> 单向循环链表有两种实现方式: (1)从零实现单向循环链表,包括其中所有的属性与方法。 (2)继承之前封装的普通单向链表(LinkedList),通过继承只实现差异化的部分。 方式一的从零实现较为麻烦,方式二采用继承形式,抽象复用相同部分的代码。方式二是更简便也值得推荐的,所以我们采用方式二。 使用方式二之前,需要对之前封装的普通单向链表采取一定的重构,使其单向循环链表继承使用可以更为契合。 ### 7.1.2 单向链表代码重构 首先是链表的声明部分不变,代码如下: ```ts interface IList<T> { // peek peek(): T | undefined // 判断是否为空 isEmpty(): boolean // 元素的个数 size(): number } interface ILinkedList<T> extends IList<T> { append(value: T): void traverse(): void insert(value: T, position: number): boolean removeAt(position: number): T | null get(positon: number): T | null update(value: T, position: number): boolean indexOf(value: T): number remove(value: T): T | null } export default ILinkedList ``` 所需实现的单向循环链表继承自普通单向链表(基础搭建)如下: ```ts // 导入普通链表用于被继承。 // 单向循环链表:CircularLinkedList // 普通链表:LinkedList class CircularLinkedList<T> extends LinkedList<T> { } ``` #### 1. 单向链表添加tail属性 接下来,需要对普通单向链表进行一定程度的修改,例如在普通单向链表中的head与length属性皆为private(私有属性),私有属性只能在当前类中使用,当单向循环链表继承自普通单向链表时,单向循环链表无法获取使用来自普通单向链表的私有属性。 因此我们需要将属性修饰符private修改为protected(受保护属性),受保护的属性允许子类访问(不允许外界访问)。 ```ts protected head: Node<T> | null = null protected length: number = 0 ``` 在单向循环链表中,需要实现尾节点指向第一个存储实际数据的节点,则一旦添加新节点就需要重新获取一次当前尾节点的信息。由于获取尾节点的需求频率提高,所以我们需要在普通单向链表中增添获取尾节点信息的方法或者属性。 ```ts // 新增属性: 总是指向链表的位置 protected tail: Node<T> | null = null ``` #### 2. append()方法重构 当我们在追加新节点(假设为D节点)时,tail属性需要指向成为新的尾节点的D节点。同理的,其余操作涉及尾节点的变动都需要让tail属性指向新的尾节点,其余两个操作如下: (1)删除当前尾节点,则tail属性指向尾节点的上一节点。 (2)节点插入到尾节点之后,则tail属性指向新的尾节点。 因此tail属性涉及append()追加节点,insert()插入节点以及removeAt()删除节点三个方法的重构。 追加节点有两种情况: (1)当前链表没有第一个存储实际数据的节点,则追加的新节点作为头节点(该头节点不为哨兵节点)。 (2)当前链表已有存储实际数据的节点,则追加的节点需要在尾节点之后。 append()追加节点方法重构前的代码如下: ```ts // 追加节点 append(value: T) { // 1.根据value创建一个新节点 const newNode = new Node(value) // 2.判断this.head是否为null if (!this.head) { this.head = newNode } else { let current = this.head while (current.next) { current = current.next } // current肯定是指向最后一个节点的 current.next = newNode } this.length++ } ``` 首先将tail属性指向新的尾节点,关键代码如下: ```ts // 追加节点 append(value: T) { const newNode = new Node(value) if (!this.head) { this.head = newNode // tail属性指向新的尾节点 this.tail = newNode } else { let current = this.head while (current.next) { current = current.next } // tail属性指向新的尾节点 this.tail = newNode current.next = newNode } this.length++ } ``` 由于可以直接获取到新的尾节点,则追加节点的第二种情况,无需每次都通过current指针遍历链表获取尾节点。则修改后代码如下: ```ts // 追加节点 append(value: T) { // 1.根据value创建一个新节点 const newNode = new Node(value) // 2.判断this.head是否为null if (!this.head) { this.head = newNode this.tail = newNode } else { // 追加节点的第二种情况:追加的节点在当前尾节点之后 this.tail!.next = newNode this.tail = newNode } this.length++ } ``` append()方法的两种追加节点的情况都意味着追加的节点成为新的尾节点,都需要将tail属性指向新的尾节点。则两个判断的this.tail = newNode(tail属性指向新的尾节点)可以合并处理。则步骤为: (1)获取当前尾节点,在当前尾节点之后添加新节点。 (2)将追加的新节点设置为新的尾节点。 ```ts // 追加节点 append(value: T) { const newNode = new Node(value) if (!this.head) { this.head = newNode } else { this.tail!.next = newNode } // 合并处理 this.tail = newNode this.length++ } ``` #### 3. insert()方法重构 insert()插入节点方法重构前的代码如下: ```ts // 插入方法: insert(value: T, position: number): boolean { // 1.越界的判断 if (position < 0 || position > this.size) return false // 2.根据value创建新的节点 const newNode = new Node(value) // 3.判断是否需要插入头部 if (position === 0) { newNode.next = this.head this.head = newNode } else { const previous = this.getNode(position - 1) newNode.next = previous!.next previous!.next = newNode } this.length++ return true } ``` 将新节点插入到头部或者非尾节点的位置并不会影响tail属性指向尾节点。 需要考虑的是当新节点插入尾节点的时候,更新tail指向新节点。 ```ts // 插入方法: insert(value: T, position: number): boolean { if (position < 0 || position > this.length) return false const newNode = new Node(value) if (position === 0) { newNode.next = this.head this.head = newNode } else { const previous = this.getNode(position - 1) newNode.next = previous!.next previous!.next = newNode // 当新节点插入尾节点 if (position === this.length) { this.tail = newNode } } this.length++ return true } ``` #### 4. removeAt()方法重构 removeAt()删除节点方法重构前的代码如下: ```ts // 删除方法: removeAt(position: number): T | null { // 1.越界的判断 if (position < 0 || position >= this.size) return null // 2.判断是否是删除第一个节点 let current = this.head if (position === 0) { this.head = current?.next ?? null } else { // 重构成如下代码 const previous = this.getNode(position - 1) // 找到需要的节点 previous!.next = previous?.next?.next ?? null } this.length-- return current?.value ?? null } ``` 当删除到尾节点时,将tail属性指向尾节点的上一节点。当链表只有一个节点(存储数据的节点),即头节点与尾节点相同时,删除该节点则意味着链表无任何节点,尾节点也没有上一节点,需将tail属性置空。 ```ts // 删除方法: removeAt(position: number): T | null { if (position < 0 || position >= this.length) return null let current = this.head if (position === 0) { this.head = current?.next ?? null // 头节点与尾节点重合,删除该节点后,尾节点无上一节点,将tail属性置空。 if (this.length === 1) { this.tail = null } } else { const previous = this.getNode(position - 1) current = previous!.next previous!.next = previous?.next?.next ?? null // 删除尾节点情况,将tail指向尾节点的上一节点 if (position === this.length - 1) { this.tail = previous } } this.length-- return current?.value ?? null } ``` #### 5. isTail()方法 接下来打算让循环链表继承自单向链表,还存在一些问题,例如遍历链表的traverse()方法,通过while去遍历,终止条件是current指针为空。但如果是循环链表的话,current通过.next不断的指向下一个,当指到尾节点后,接着会跳转到头节点,循环往复,从而陷入死循环。 ```ts // 遍历链表的方法 traverse() { const values: T[] = [] let current = this.head // 终止条件是current指针为空 while (current) { values.push(current.value) current = current.next } console.log(values.join("->")) } ``` 因此为了兼容循环链表的情况,我们判断尾节点除了考虑单向链表,还需要考虑循环链表。所以需要一个新的判断当前节点是否为尾节点的isTail()方法。 判断单向链表的尾节点:current.next === null。 判断循环链表的尾节点:current.next === this.head。 单向链表与循环链表两种判断方式结合起来,代码显得较长,所以直接采用tail属性来判断。 ```ts // 判断是否是最后一个节点 private isTail(node: Node<T>) { return this.tail === node } ``` #### 6. traverse()方法重构 接着需要依据新的判断尾节点的isTail()方法来重构所有涉及尾节点的2个方法: (1)traverse()方法。 (2)indexOf()方法。 traverse()方法需要做出判断是否遍历到尾节点tail,是则将current指针置为null,退出循环;否则将current指针指向下一节点。 ```ts while (current) { values.push(current.value) if (this.isTail(current)) { // 已经遍历最后一个节点 current = null } else { // 不是最后一个节点 current = current.next } } ``` 其次,循环链表遍历输出时,我们希望在输出尾节点后继续输出下一个头节点,从而形式一个完整的闭环。因此我们需要先判断处于循环链表的情况再输出头节点。输出内容来自values数组,所以获取头节点push到values数组的末端就行。 ```ts // 循环链表 if (this.head && this.tail?.next === this.head) { values.push(this.head.value) } ``` traverse()方法重构后的完整代码如下: ```ts // 遍历链表的方法 traverse() { const values: T[] = [] let current = this.head while (current) { values.push(current.value) if (this.isTail(current)) { // 已经遍历最后一个节点 current = null } else { // 不是最后一个节点 current = current.next } } // 循环链表 需要同时满足两个条件 if (this.head && this.tail?.next === this.head) { values.push(this.head.value) } console.log(values.join("->")) } ``` traverse()方法在循环链表的效果如图7-2所示。既不会无限循环,也体现出ABCDA的遍历闭环效果。效果需要结合7.1.3小节的append()方法重构后来配合实现。  <p align="center"> <b>图7-2 traverse()方法在循环链表的效果</b> </p> #### 7. indexOf()方法重构 indexOf()方法重构前的代码如下: ```ts // 根据值, 获取对应位置的索引 indexOf(value: T): number { // 从第一个节点开始, 向后遍历 let current = this.head let index = 0 while (current) { if (current.value === value) { return index } current = current.next index++ } return -1 } ``` 可以看到重构前的indexOf()方法也是采用current指针不断遍历链表来查找是否有相等的值。但该做法一旦运用到循环链表中,就会失去终止条件,current.next会进入循环永无止境,因此需要利用tail属性添加新的终止条件。 终止条件为:当找到尾节点将current指针置空,退出循环。其余情况保持current对链表进行遍历寻找相等的值。 ```ts // 根据值, 获取对应位置的索引 indexOf(value: T): number { // 从第一个节点开始, 向后遍历 let current = this.head let index = 0 while (current) { if (current.value === value) { return index } if (this.isTail(current)) { current = null } else { current = current.next } index++ } return -1 } ``` 在本次重构中,主要涉及新增tail受保护属性和isTail()方法,以及以下5个方法的重构: (1)append()方法。 (2)traverse()方法。 (3)insert()方法。 (4)removeAt()方法 (5)indexOf()方法。 重构代码是一件较为繁琐的事情,一处变动就需要将涉及到变动的相关部分处处修改。因此在面对一个较大的项目时,提前构思好思路再写代码是更为节省精力的事情。 ### 7.1.3 循环链表方法实现 在单向链表的基础上,循环链表有以下3个方法需要实现: (1)append()方法。 (2)insert()方法。 (3)removeAt()方法。 循环链表由于尾节点指向头节点,因此涉及到头节点与尾节点的两种情况都需要注意,例如头节点变化,尾节点需要重新指向新的头节点。 循环链表的append()方法需要做出以下两步骤: (1)将当前尾节点指向追加的新节点。 (2)将追加的新节点指向头节点。 实际只需要将追加的新节点指向头节点就可以了。当前尾节点会自动指向追加的新节点,即this.tail!.next = newNode部分。 ```ts append(value: T) { const newNode = new Node(value) if (!this.head) { this.head = newNode } else { this.tail!.next = newNode } this.tail = newNode this.length++ // 将追加的新节点指向头节点 this.tail!.next = this.head } ``` 由于直接在原有代码上新增,未修改原有部分。因此基于继承的原理,可以采用super关键字来调用父类的构造函数append()方法,然后在加上原有代码新增的部分。 ```ts class CircularLinkedList<T> extends LinkedList<T> { // 重新实现的方法: append方法 append(value: T): void { super.append(value) // 拿到最后一个节点next指向第一个节点 this.tail!.next = this.head } } ``` 循环链表的insert()方法处理新节点插入尾节点之后的情况,需要做出以下两步骤: (1)tail指向新的尾节点(新节点)。 (2)新的尾节点指向头节点。 由于在单向链表已经重构过insert方法,做到tail指向新的尾节点,因此循环链表在继承单向链表的基础上只需要再实现新的尾节点指向头节点即可。 ```ts insert(value: T, position: number): boolean { if (position < 0 || position > this.length) return false const newNode = new Node(value) if (position === 0) { newNode.next = this.head this.head = newNode } else { const previous = this.getNode(position - 1) newNode.next = previous!.next previous!.next = newNode // 当新节点插入尾节点 if (position === this.length) { this.tail = newNode } } this.length++ return true } ``` insert()方法在循环链表中的代码如下所示: isSuccess插入数据是否成功依旧需要返回,在返回信息的基础上,插入位置为尾节点或者无节点的情况下,将新的尾节点(当前尾节点的下一节点)指向头节点。 ```ts class CircularLinkedList<T> extends LinkedList<T> { insert(value: T, position: number): boolean { const isSuccess = super.insert(value, position) if (isSuccess && (position === this.length - 1 || position === 0)) { this.tail!.next = this.head } return isSuccess } } ``` insert()方法将节点"小余"插入头节点如图7-3所示。根据traverse()方法打印的首尾效果,尾节点成功指向头节点。  <p align="center"> <b>图7-3 insert()方法插入头节点</b> </p> 循环链表的removeAt()方法在删除头节点或者尾节点时,需要进行处理: (1)删除头节点:尾节点指向新的头节点。 (2)删除尾节点:新的尾节点指向头节点。 removeAt()方法需要考虑删除头节点的情况,分别处理tail属性的指向问题。而删除尾节点的情况已经在单向链表的代码重构中实现,因此只需要考虑删除头节点的情况。 删除头节点,需要满足3个前置条件: (1)value有值,即removeAt()方法有正常删除节点。 (2)tail有值(循环链表有可能存在无节点情况),确保链表还有尾节点,空值检查,防止TypeScript报错。 (3)删除头节点以及头节点与尾节点重合的情况。 ```ts removeAt(position: number): T | null { const value = super.removeAt(position) if (value && this.tail && (position === 0 && position === this.length)) { this.tail.next = this.head } return value } ``` 完整重构后的单向链表代码如下所示: ```ts class Node<T> { value: T next: Node<T> | null = null constructor(value: T) { this.value = value } } interface IList<T> { // peek peek(): T | undefined // 判断是否为空 isEmpty(): boolean // 元素的个数 size(): number } interface ILinkedList<T> extends IList<T> { append(value: T): void traverse(): void insert(value: T, position: number): boolean removeAt(position: number): T | null get(positon: number): T | null update(value: T, position: number): boolean indexOf(value: T): number remove(value: T): T | null } // 2.创建LinkedList的类 export default class LinkedList<T> implements ILinkedList<T> { protected head: Node<T> | null = null protected length: number = 0 // 新增属性: 总是指向链表的位置 protected tail: Node<T> | null = null size() { return this.length } peek(): T | undefined { return this.head?.value } // 封装私有方法 // 根据position获取到当前的节点(不是节点的value, 而是获取节点) protected getNode(position: number): Node<T> | null { let index = 0 let current = this.head while (index++ < position && current) { current = current.next } return current } // 判断是否是最后一个节点 private isTail(node: Node<T>) { return this.tail === node } // 追加节点 append(value: T) { // 1.根据value创建一个新节点 const newNode = new Node(value) // 2.判断this.head是否为null if (!this.head) { this.head = newNode } else { this.tail!.next = newNode } this.tail = newNode // 3.size++ this.length++ } // 遍历链表的方法 traverse() { const values: T[] = [] let current = this.head while (current) { values.push(current.value) if (this.isTail(current)) { // 已经遍历最后一个接地那 current = null } else { // 不是最后一个节点 current = current.next } } // 循环链表 if (this.head && this.tail?.next === this.head) { values.push(this.head.value) } console.log(values.join("->")) } // 插入方法: insert(value: T, position: number): boolean { // 1.越界的判断 if (position < 0 || position > this.length) return false // 2.根据value创建新的节点 const newNode = new Node(value) // 3.判断是否需要插入头部 if (position === 0) { newNode.next = this.head this.head = newNode } else { const previous = this.getNode(position - 1) newNode.next = previous!.next previous!.next = newNode if (position === this.length) { this.tail = newNode } } this.length++ return true } // 删除方法: removeAt(position: number): T | null { // 1.越界的判断 if (position < 0 || position >= this.length) return null // 2.判断是否是删除第一个节点 let current = this.head if (position === 0) { this.head = current?.next ?? null if (this.length === 1) { this.tail = null } } else { const previous = this.getNode(position - 1) current = previous!.next previous!.next = previous?.next?.next ?? null if (position === this.length - 1) { this.tail = previous } } this.length-- return current?.value ?? null } // 获取方法: get(position: number): T | null { // 越界问题 if (position < 0 || position >= this.length) return null // 2.查找元素, 并且范围元素 return this.getNode(position)?.value ?? null } // 更新方法: update(value: T, position: number): boolean { if (position < 0 || position >= this.length) return false // 获取对应位置的节点, 直接更新即可 const currentNode = this.getNode(position) currentNode!.value = value return true } // 根据值, 获取对应位置的索引 indexOf(value: T): number { // 从第一个节点开始, 向后遍历 let current = this.head let index = 0 while (current) { if (current.value === value) { return index } if (this.isTail(current)) { current = null } else { current = current.next } index++ } return -1 } // 删除方法: 根据value删除节点 remove(value: T): T | null { const index = this.indexOf(value) return this.removeAt(index) } // 判读单链表是否为空的方法 isEmpty() { return this.length === 0 } } export { } ``` 继承自重构后单向链表的循环链表如下: ```ts import LinkedList from "./LinkedList.ts"; class CircularLinkedList<T> extends LinkedList<T> { // 重新实现的方法: append方法 append(value: T): void { super.append(value) // 拿到最后一个节点next指向第一个节点 this.tail!.next = this.head } traverse(): void { super.traverse() } insert(value: T, position: number): boolean { const isSuccess = super.insert(value, position) if (isSuccess && (position === this.length - 1 || position === 0)) { this.tail!.next = this.head } return isSuccess } removeAt(position: number): T | null { const value = super.removeAt(position) if (value && this.tail && (position === 0 && position === this.length)) { this.tail.next = this.head } return value } } const cLinkedList = new CircularLinkedList<string>() cLinkedList.append('A') cLinkedList.append('B') cLinkedList.append('C') cLinkedList.append('D') cLinkedList.insert('小余', 0) cLinkedList.traverse() ``` ## 7.2 双向链表结构 ### 7.2.1 双向链表的概念与特性 双向链表是一种链式存储结构,其每个节点除了包含数据域(data)外,还包含两个指针域:一个指向前驱节点的指针(prev),另一个指向后继节点的指针(next)。这种结构使得链表中的节点可以双向连接,即从头节点开始可以顺序遍历到尾节点,从尾节点开始也可以逆向遍历到头节点。与单向链表相比,双向链表在结构上更加对称,头节点的前驱指针和尾节点的后继指针通常指向空(null),以此标识链表的边界。 尽管双向链表在插入和删除节点时需要同时维护前驱和后继四个指针关系,实现复杂度稍高,且因多占用一个指针空间而具有更高的内存开销,但其在双向遍历、任意位置节点的高效访问以及操作灵活性上的优势,使得这些代价在实际应用中往往是可接受的。因此,双向链表特别适用于需要频繁双向查找、后退操作或复杂结构管理的场景,如浏览器的历史记录管理、双向队列(Deque)的实现等。 循环链表如图7-4所示。  <p align="center"> <b>图7-4 循环链表</b> </p> ### 7.2.2 双向链表节点封装 相对于单向链表的节点,双向链表的节点多一个指向前驱节点的指针(prev),因此需要对目前已有节点做一个封装。 单向链表的节点封装如下: ```ts export class Node<T> { value: T next: Node<T> | null = null constructor(value: T) { this.value = value } } ``` 由于单向链表使用的节点与双向链表有所冲突,所以我们不能直接在原有节点上直接修改。双向链表可以选择继承原有节点,在原有基础上去新增prev指针和重写next指针(接收的需要是双向链表的节点)。 双向链表的节点封装如下: ```ts export class DoublyNode<T> extends Node<T> { prev: DoublyNode<T> | null = null next: DoublyNode<T> | null = null } // 使用 const DNode = new DoublyNode('小余') DNode.prev?.prev DNode.next?.next ``` ### 7.2.3 双向链表方法实现 基于双向链表的节点去创建链表如下: 原有单向链表一共3个受保护属性:head,length以及tail。其中head与tail属性的类型涉及到节点类型,需要重写为双向链表的节点。 ```ts class DoublyLinkedList<T> extends LinkedList<T> { protected head: DoublyNode<T> | null = null protected tail: DoublyNode<T> | null = null } const dLinkedList = new DoublyLinkedList<string>() ``` 完成双向链表的基础创建后,因为双向链表中添加、删除方法的实现和单向链表有较大的区别,所以我们可以对其方法进行重新实现,主要为以下5个方法: (1)append()方法:在尾部追加元素。 (2)prepend()方法:在头部添加元素。 (3)postTraverse()方法:从尾部遍历所有节点。 (4)insert()方法:根据索引插入元素。 (5)removeAt()方法:根据索引删除元素。 那么接下来我们就一个个实现以上这5个方法,其他方法都是可以继承的。由于ILinkedList父类的append()等方法中采用的是单向链表的节点,并不好调整,因此以下这5个方法都不采用继承+补充的方式。 #### 1. append()方法 双向链表的append()方法需要考虑以下3种情况: (1)追加第一个节点:双向链表无任何节点,head与tail暂时都指向null。需将两指针同时指向追加的第一个节点。 (2)追加其余节点:head节点指向头节点无需变动,tail节点的next指向新增的节点,新增节点的prev指向tail节点。 ```ts append(value: T) { const newNode = new DoublyNode(value) if (!this.head) { this.head = newNode this.tail = newNode } else { this.tail!.next = newNode // 不能将父类对象赋值给子类,但可以将一个子类的对象,赋值给一个父类的类型(多态) // newNode.prev = this.tail // this.tail!.next!.prev = this.tail newNode.prev = this.tail } this.tail = newNode this.length++ } ``` 在将新增节点的prev指向tail节点时,采用newNode.prev会有更直观的体现,但基于多态的原因会报错,如果想用这种写法。需要将newNode的类型定义重写在子类中,即head和tail在DoublyLinkedList类定义为DoublyNode类型。在一开始初始化双向链表时解决了该问题。 双向链表-append()方法效果如图7-5所示。  <p align="center"> <b>图7-5 双向链表-append()方法</b> </p> #### 2. prepend()方法 prepend()方法是用于在头部追加节点(追加的节点作为新的头节点),与append()方法在实现上有所相似。需要拆分成以下两点: (1)当双向链表没有任何节点时。 prepend()与append()方法的做法一致,head与tail同时指向追加的节点。 (2)当双向链表存在一定节点时。 追加节点的next指向头节点,头节点的prev指向追加节点,最后将追加节点置为新的头节点。prepend()方法如图7-6所示。  <p align="center"> <b>图7-6 双向链表-prepend()方法</b> </p> ```ts prepend(value: T) { const newNode = new DoublyNode(value) if (!this.head) { this.head = newNode this.tail = newNode } else { // 追加节点的next指向头节点 newNode.next = this.head // 头节点的prev指向追加节点 this.head.prev = newNode // 追加节点置为新的头节点 this.head = newNode } this.length++ } ``` #### 3. postTraverse()方法 我们在单向链表中有实现traverse()遍历方法,在双向链表中可以实现postTraverse()反向遍历方法(从尾节点遍历到头节点),反向遍历方法只有双向链表满足实行条件。 postTraverse()方法需要以下3步骤: (1)获取链表的尾节点。 (2)根据链表的current指针和节点的prev向前遍历并输出。 (2)当遍历完头节点,退出遍历。 ```ts postTraverse() { const values: T[] = [] // 获取链表的尾节点 let current = this.tail // 当节点值等于头节点时,退出遍历 while (current) { values.push(current.value) // 根据链表的current指针和节点的prev向前遍历并输出 current = current.prev } console.log(values.join("->")) } ``` #### 4. insert()方法 双向链表的insert()方法-根据索引插入节点,需要分情况讨论: (1)插入的位置是头节点。 (2)插入的位置是尾节点。 (3)插入的位置是其余节点。 当插入的位置是头节点时,可以直接调用prepend()方法;当插入的位置是尾节点时,可以调用append()方法;当插入的位置是其余节点,需要建立插入节点和前驱节点与后继节点的双向联系。 边界判断:当双向链表没有任何节点或者插入位置超出已有链表长度范围时,直接返回false。 插入的位置是其余节点时,我们需要做以下3步操作: (1)获取插入位置的原先节点A以及插入位置前一个位置的节点B。 (2)插入节点的prev指向节点B,节点B的next指向插入节点。 (3)插入节点的next指向节点A,节点A的prev指向插入节点。 插入节点过程如图7-7所示。  <p align="center"> <b>图7-7 双向链表-prepend()方法</b> </p> 由于获取节点位置的getNode()方法是私有方法,需要将其修改为受保护方法。getNode()方法内部所采用的节点类型是单向链表的节点,所以需要将该方法类型断言为DoublyNode。 ```ts // 根据索引插入元素 insert(value: T, position: number): boolean { if (position < 0 && position > this.length) return false if (position === 0) { // 插入的位置是头节点 this.prepend(value) } else if (position === this.length) { // 插入的位置是尾节点 this.append(value) } else { // 插入的位置是其余节点 const newNode = new DoublyNode(value) // 获取插入位置的原先节点A const current = this.getNode(position) as DoublyNode<T> // 获取插入位置前一个位置的节点B,并将节点B的next指向插入节点 current.prev!.next = newNode // 插入节点的next指向节点A newNode.next = current // 插入节点的prev指向节点B newNode.prev = current.prev // 节点A的prev指向插入节点(放到最后) current.prev = newNode // 放在其余节点的内部情况,因为prepend()和append()方法内部都有length++ this.length++ } return true } ``` 以上通过记录节点A(插入位置的原先节点)的方式来完成全部操作,整体连贯性更强,但这样操作就需要注意前后顺序,以免在改变指向的过程中,提前将指向节点B修改(current.prev = newNode)。除了该做法,还可以使用getNode()方法同时获取节点B,在代码层面上的可读性会更强,操作也不需要顾及前后顺序。 #### 5. removeAt()方法 双向链表的removeAt()方法-根据索引删除节点,也分情况讨论: (1)删除的是头节点。 (2)删除的是尾节点。 (3)删除的是其他节点。 删除头节点,双向链表有可能只有头节点,需要将this.head和this.tail都置空,其他情况(双向链表节点数量大于1)需要this.head指向新的头节点,然后将新的头节点的prev置空(新的头节点是双向链表原来的第二个节点,其prev指向原来的头节点); 删除尾节点,需要this.tail指向新的尾节点,将新的尾节点的next置空(新的尾节点是双向链表原来的倒二个节点,其next指向原来的尾节点); 删除其他节点,需要目标节点的前驱节点与后继节点完成双向对接。 最后需要与insert()方法一样,做好边界判断。 removeAt()方法的越界判断如下: ```ts removeAt(position: number): T | null { if (position < 0 || position >= this.length) return null return null } ``` 删除节点三种情况的代码如下: ```ts // 根据索引删除元素 removeAt(position: number): T | null { if (position < 0 || position >= this.length) return null let current = this.head if (position === 0) { // 删除的是头节点 if (this.length === 1) { // 双向链表只有头节点 this.head = null this.tail = null } else { // 双向链表节点数量超过1 this.head = this.head!.next // 指向新的头节点 this.head!.prev = null // 新的头节点prev置空 } } else if (position === this.length - 1) { // 删除的是尾节点 current = this.tail this.tail = this.tail!.prev // tail指向新尾节点 this.tail!.next = null // 新尾节点的next置空 } else { // 删除的是其他节点 current = this.getNode(position) as DoublyNode<T> // 获取删除节点位置 current.next!.prev = current.prev // 删除节点的前一节点与后一节点对接 current.prev!.next = current.next // 删除节点的后一节点与前一节点对接 } this.length-- return current?.value ?? null } ``` 删除节点有一处地方很有意思,删除的是双向链表的头节点(链表节点数量超过1),只将新头节点的prev置空。那么删除目标(原来的头节点)的next还存在指向,不置空吗?并不需要,因为此时已经没有节点指向原来的头节点了,它处于不可达状态,已经满足垃圾回收的条件。无需再将next置空,removeAt()方法只将新头节点的prev置空如图7-8所示。后续删除尾节点也是同样的原理。  <p align="center"> <b>图7-8 双向链表-removeAt()方法</b> </p> 由于最后需要返回删除的节点值,因此在删除节点之前,需要通过current将删除的节点记录下来,而current的默认值是头节点,因此头节点无需额外记录。 #### 6. 其他方法 双向链表的其余方法可以直接继承单向链表的方法,测试代码如下: ```ts console.log('------------ 测试get ------------') console.log(dLinkedList.get(0)) console.log(dLinkedList.get(1)) console.log(dLinkedList.get(2)) console.log('------------ 测试update ------------') dLinkedList.update("why", 1) dLinkedList.update("xiaoyu", 2) dLinkedList.traverse() console.log('------------ 测试indexOf ------------') console.log(dLinkedList.indexOf("cba")) console.log(dLinkedList.indexOf("why")) console.log(dLinkedList.indexOf("xiaoyu")) console.log(dLinkedList.indexOf("james")) console.log('------------ 测试remove ------------') dLinkedList.remove("why") dLinkedList.remove("cba") dLinkedList.remove("xiaoyu") dLinkedList.traverse() console.log(dLinkedList.isEmpty()) console.log(dLinkedList.size()) ``` 完整的双向链表代码如下: ```ts // 根据自己实际情况导入 import LinkedList, { DoublyNode } from "./LinkedList.ts"; class DoublyLinkedList<T> extends LinkedList<T> { protected head: DoublyNode<T> | null = null protected tail: DoublyNode<T> | null = null append(value: T) { const newNode = new DoublyNode(value) if (!this.head) { this.head = newNode this.tail = newNode } else { this.tail!.next = newNode newNode.prev = this.tail } this.tail = newNode this.length++ } prepend(value: T) { const newNode = new DoublyNode(value) if (!this.head) { this.head = newNode this.tail = newNode } else { newNode.next = this.head this.head.prev = newNode this.head = newNode } this.length++ } postTraverse() { let current = this.tail const values = [] while (current) { values.push(current.value) current = current.prev } console.log(values.join("->")) } insert(value: T, position: number): boolean { if (position === 0) { this.prepend(value) } else if (position === this.length) { this.append(value) } else { const newNode = new DoublyNode(value) const current = this.getNode(position) as DoublyNode<T> current.prev!.next = newNode newNode.next = current newNode.prev = current.prev current.prev = newNode this.length++ } return true } removeAt(position: number): T | null { if (position < 0 || position >= this.length) return null let current = this.head if (position === 0) { // 删除的是头节点 if (this.length === 1) { // 双向链表只有头节点 this.head = null this.tail = null } else { // 双向链表节点数量超过1 this.head = this.head!.next // 指向新的头节点 this.head!.prev = null // 新的头节点prev置空 } } else if (position === this.length - 1) { // 删除的是尾节点 current = this.tail this.tail = this.tail!.prev // tail指向新尾节点 this.tail!.next = null // 新尾节点的next置空 } else { // 删除的是其他节点 current = this.getNode(position) as DoublyNode<T> // 获取删除节点位置 current.next!.prev = current.prev // 删除节点的前一节点与后一节点对接 current.prev!.next = current.next // 删除节点的后一节点与前一节点对接 } this.length-- return current?.value ?? null } } const dLinkedList = new DoublyLinkedList<string>() ``` 链表的学习到这里就结束了,在7.1小节学习了循环链表的概念,重构了单向链表的代码,在7.2小节利用重构后的代码,进一步实现双向链表以及对应与单向链表有所差异化的方法实现。接下来我们会开始学习堆结构。
第6章 图
## 6.1 图结构基础与特性 ### 6.1.1 图的定义与特点 图结构在面试中的出现频率相对其他数据结构而言较低,但也是一种常见的数据结构,我们通过本章的学习来认识一下关于图的一些内容以及一些算法。图结构如果单独拿出来探讨,可以有非常多的内容,多到大于之前所学的所有数据结构的总和。因为图结构可以延伸到图论上,图论是一门通过“图”来研究事物之间关系及其规律的数学学科,它是计算机图结构和各种图算法的理论基础。 所以在本章节只会了解学习图结构的一些常见的内容。那什么是图?图结构可以认为是一种与树结构有些相似的数据结构,而在数学的概念上,树是图的一种(可见图的概念非常广泛)。由于图是图论的一部分,而图论又是数学的一个分支,因此想学好图结构是绕不开数学的,图论通过图来研究顶点和边组成的图形的数学理论和方法,主要研究的目的是事物之间的关系,顶点代表事物,边代表两个事物间的关系。 ### 6.1.2 图的现实应用案例 我们在第5章知道了树结构可以用来模拟很多现实的数据结构,例如:家谱/公司组织架构等等,那么图长什么样子?或者什么样的数据使用图来模拟更合适呢? 人与人之间的关系网就是一种图结构,甚至科学家们在观察人与人之间的关系网时,还发现了六度空间理论。六度空间理论认为世界上任何两个互相不认识的两人,只需要很少的中间人就可以建立起联系,并非一定要经过6步,只是需要很少的步骤。这让我想起一段话,这个世界有多大,取决于我们认识多少人,每认识一个人,世界对我们来说就会变大一点,由朋友相互介绍的交朋友方式,难度是肯定比直接去认识完全陌生的人方便的,只要我们认真的去提升自己。通过这个六度空间理论,我们与整个世界的实际距离也只需要很少的步骤,世界随时向我们敞开怀抱啊! 整个世界那么广阔,那么多的人,仅仅需要那么几步就能联系在一起,这侧面印证的图结构的错综复杂。  <p align="center"> <b>图6-1 六度空间理论</b> </p> 图结构也有与生活息息相关的例子,例如北京地铁图,如图6-2所示。在北京地铁图中,每一个地铁站都可以视为一个顶点(节点),而连接两个站点的地铁线路就是边(Edge)。图结构恰好就是由“点”和“边”组成的,因此地铁图完全符合图的定义。我们现实生活中每天为了上下班、通勤、换乘,就是在不知不觉间遍历一张图。 当我们规划从国贸到中关村的路线时,实际做的就是:在图中找到两个节点间的路径,在所有路径中选一个“代价最小”的(最少换乘、最短时间、最少站数等),这对应的正是图论里的最短路径问题。不管你用的是地图 App、百度地图还是地铁 APP,它们后台都在基于Dijkstra或BFS类算法给出最佳路线。  <p align="center"> <b>图6-2 北京地铁图</b> </p> 除此之外还有村庄之间的关系网等等更多案例。 那么,什么是图呢?在前面的案例中,节点(其实图中叫顶点Vertex,一般不叫节点)之间的关系,是不能使用树来表示,使用任何的树结构都无法模拟。这个时候,我们就可以使用图来模拟它们。 图通常有一组顶点和一组边,通常用V(Vertex)表示顶点的集合,用E(Edge)表示边的集合。边是顶点和顶点之间的连线,边可以是有向的,也可以是无向的。例如A --- B,通常表示无向。 而A --> B,通常表示有向。 ### 6.1.3 欧拉与七桥问题 18 世纪的东普鲁士城市——哥尼斯堡,有一条普雷格尔河流经市区,并将城市分成两个河岸与两个岛屿。为了方便人们往来,这里修建了 **七座桥** 将这些地区连接起来。当时当地居民常常在散步时讨论一个有趣的问题:能不能设计一条路线,使得一个人不重复、不遗漏地走完七座桥,最终回到起点?这个看似简单的生活娱乐问题,却一直没有人能找到完美的走法,成了当时城市里最有名的“数学谜题”。哥伦斯堡的七桥如图6-3所示。  <p align="center"> <b>图6-3 哥尼斯堡的七座桥</b> </p> 1735年,有几名大学生对这个问题产生了浓厚兴趣,但始终找不到答案,于是写信请教正在俄罗斯彼得堡科学院任职的年轻天才——莱昂哈德·欧拉(Euler)。欧拉接到请求后非常认真,他亲自研究了城市的布局与桥的连接方式,尝试了许多可能的路线。然而,无论他如何尝试,都无法找到满足条件的路径。这让欧拉意识到:这个问题可能不是“走法没找到”,而是从根本上无解。 但欧拉并没有停留在尝试走路的层面,而是做了一个革命性的抽象: - 他把岛屿与河岸视为点(顶点)。 - 把桥视为连接这些点的线(边)。 这样一来,复杂的地理图就变成了一个由点与线组成的抽象结构。欧拉进一步发现:想不重复地走完每一条边并回到起点,就是现在所说的 欧拉回路(Eulerian circuit) 问题。他证明了:若一个图的所有点的度数都为偶数,则存在欧拉回路;若有超过两个点的度数为奇数,则一定不存在,而如图6-4所示的哥尼斯堡七桥问题的所有关键节点都是奇度,因此这个问题从一开始就无解。  <p align="center"> <b>图6-4 哥尼斯堡的七座桥(抽象)</b> </p> 在一笔画问题中,每个点的“度”(即连接的边数)决定了它在路径中的角色。路径在经过某个点时,进入一次就必须从另一条边离开一次,因此作为中途经过的点,边数必须成对出现,也就是“偶点”,才能保证“有来有去、进出配平”。但如果一个点的度数是奇数,就说明多出的一条边无法与其他边配对,这样的点只能作为路径的起点或终点来消化这条“多余的路”。因此,一张图想要被一笔画成,必须使奇点的数量不是零就是二:零个奇点表示既能一笔画完又能回到起点,而恰好两个奇点表示能一笔画完但起点和终点不同;若奇点超过两个,则不可能一笔画成。 1736 年,29 岁的欧拉向彼得堡科学院递交了著名论文《哥尼斯堡的七座桥》。这篇论文解决了这个长期困扰人们的问题,更重要的是:欧拉首次引入了图(Graph)的概念,将研究中心转移到“关系结构”而非几何形状,正式开创了现代图论(Graph Theory)与拓扑学(Topology)的两个重要分支。从此,数学史上开启了一段研究“点—线结构”的新旅程,而图论也成为现代计算机、网络、交通、社交媒体等无数领域的核心理论基础。 在 18 世纪,欧拉研究哥尼斯堡七桥问题时,提出了用“点与线”来抽象现实世界的结构。当时并没有“图结构”这个说法,更没有数据结构概念,欧拉的所有研究都是纯数学层面的理论抽象。因此历史上图论(1736 年)远早于计算机中的图结构,直到 20 世纪计算机科学兴起,人们才开始用邻接表、邻接矩阵等方式在内存中存储图,这些方法才被称为 图结构(Graph Data Structure)。 从历史上讲,是图论先出现;从计算机科学角度讲,图结构是为了实现图论而被发明的。coderwhy老师认为欧拉在思考这个问题的时候,并不是针对某一个特性的问题去考虑,而是将岛和桥抽象成了点和线,抽象是数学的本质,而编程我们也一再强调抽象的重要性。汇编语言是对机器语言的抽象,高级语言是对汇编语言的抽象。操作系统是对硬件的抽象,应用程序在操作系统的基础上构建。 ## 6.2 图的常见术语 我们在学习树的时候,树有很多的相关术语。了解这些术语有助于我们更好的理解图结构以及更好的表达清楚。对图结构的学习也可以先了解一些图相关的术语,从而方便后续的学习,但是图的术语其实非常多,如果我们找一本专门讲图的各个方面的书籍,会发现只是术语就可以占据满满的一个章节。这里,我们先介绍几个比较常见的术语,某些术语后面用到的时候,再了解。没有用到的,在自行深入学习的过程中,可以通过查资料去了解。 我们先来看一个抽象出来的村庄图关系网,如图6-5所示。使用数字标记出每一个村庄,这更容易我们从整体来观察整个图结构。  <p align="center"> <b>图6-5 村庄关系网(抽象)</b> </p> ### 6.2.1 顶点、边与相邻顶点 - 顶点:刚才我们已经介绍过了,表示图中的一个节点,例如地铁站中某个站/多个村庄中的某个村庄/互联网中的某台主机/人际关系中的人。 - 边:刚才我们也介绍过了,表示顶点和顶点之间的连线,例如地铁站中两个站点之间的直接连线,就是一个边。需要注意:这里的边不要叫做路径,路径有其他的概念,待会儿我们会介绍到。图6-5中: 0-1有一条边,1-2有一条边,0-2没有边。 - 相邻顶点:而由一条边连接在一起的顶点称为相邻顶点。例如0-1是相邻的,0-3是相邻的。 0-2是不相邻的。 边不仅表达“连接”,每一条边都代表了一种直接关系,因此如果我们把所有边都看作一个整体,就能看到图中究竟哪些区域关系密集、哪些区域关系稀疏。边的分布会影响结构的连通性,例如如果某个重要顶点连接着多条边,一旦它失效,图结构可能会分裂成多个部分。因此,从边的角度也能反推出:哪些顶点对保持图的整体连通最关键。虽然这些概念最终会通向连通性、路径和网络结构分析,但它们本质上都起源于“边如何分布、边如何连接顶点”。 当两个顶点相邻时,意味着在图中它们之间有最直接的交互或关联,因此相邻关系形成了图结构中的“局部结构”。理解局部结构为什么重要?因为图的整体性质往往是由许多局部关系堆叠而成的。例如,如果一个顶点周围的相邻顶点非常集中且互相也密集连接,那么它所在区域就呈现出高度聚合;相反,如果一个顶点只有寥寥几条边相邻,它在图的整体结构中就显得孤立。 顶点、边与相邻顶点的总结如表6-1所示。 <p align="center"> <b>表6-1 顶点、边与相邻顶点</b> </p> | 概念 | 定义与含义 | 特点与性质 | 示例说明 | | --------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------ | ------------------------------------------------------------ | | **顶点(Vertex)** | 图中的一个基本元素,可代表真实世界的对象,如地铁站、城市、人、服务器等 | 顶点是图结构的核心;每个顶点通常有一个编号或标签;顶点之间通过边连接 | 在地铁图中,“人民广场站”是一个顶点;在社交网络中,每个用户都是一个顶点 | | **边(Edge)** | 表示两个顶点之间的连接关系,可以是有方向的(有向边)或无方向的(无向边) | 边表示“关系”或“通道”;无向图中边没有方向,有向图中边有箭头;可以带权重表示距离、开销等 | 地铁站 A–B 的直接连线是一条边;网络图中服务器之间的链路是一条边 | | **相邻顶点(Adjacent Vertices)** | 被同一条边连接的两个顶点称为相邻顶点 | 相邻关系取决于是否存在“直接的边”;若没有边,则顶点不相邻;在有向图中通常考虑出边或入边来定义相邻关系 | 在图6-5中,0–1有边,因此是相邻;0–2没有边,因此不相邻 | ### 6.2.2 度、路径与回路 - 度:度指的是一个顶点的度是相邻顶点的数量,即一个顶点与多少个其他顶点直接连接。还是以图6-5为例,顶点0和其他两个顶点相连,顶点0的度是2。顶点1和其他四个顶点相连,顶点1的度是4。 - 路径:路径由一系列顶点组成,形式为 v₁ → v₂ → … → vₙ,每相邻的两个顶点之间都必须具有边。例如图6-5所示的0 1 5 9就是一条路径。路径还区分为简单路径和回路: (1)简单路径:简单路径要求不包含重复的顶点。例如:0 1 5 9是一条简单路径。 (2)回路:第一个顶点和最后一个顶点相同的路径称为回路(路径的起点与终点相同)。例如:0 1 5 6 3 0。 因此路径是“通过多条边串起来的顶点序列”,而不是某一条边本身。强调的是连续的一段“走法”,由多个顶点串起来,反映的是图中“可到达性”和“经过的路线”。所以边是最小单位的连接,而路径是多个边串联后的结果,边与路径并不相同。 ### 6.2.3 无向图与有向图 - 无向图:如图6-5所示的村庄关系网图就是一张无向图,因为所有的边都没有方向。 - 有向图:有向图表示的图中的边是有方向的。 无向图中的每一条边都没有方向,它表示的是一种“彼此相连”的关系。也就是说,如果 0 和 1 之间存在边,那么这条边同时代表 0 可以到达 1,1 也可以到达 0。这种图更像现实生活中“互相”的联系,例如两个人是朋友、两个城市之间有双向公路、两个设备之间能互相通信。无向图的核心含义是:连接是对称的,关系是双向的。 有向图中的边具有方向,因此它表达的是一种“从 A 指向 B”但是不一定能反过来的关系。比如一条边写作 0 → 1,就表示我们可以从 0 走到 1,但并不能保证能从 1 走回 0,除非另外有一条 1 → 0 的方向边。有向图更适合描述单向过程或单向依赖,例如微博的“关注关系”、城市中的“单行道”、任务调度中的“任务必须先做 A 再做 B”。有向图的本质是:连接不一定对称,关系具有方向性。 从直观上看,无向图更像是“互通”的网络,而有向图更像是“流程”或“限定方向的关系链”。同一对顶点,在无向图里只需一条边就能互达;而在有向图里,是否能互达完全取决于边的方向设置。也正因为方向性的存在,有向图通常适用于描述更复杂的系统,例如依赖关系、因果关系、流动方向等。 ### 6.2.4 无权图与带权图 - 无权图:如图6-5所示的村庄关系网图就是一张无权图(边没有携带权重)。因此我们的村庄关系网的边是没有任何意义的,主要起装饰作用,不能说0-1的边,比4-9的边更远或者用的时间更长。 - 带权图:带权图表示边有一定的权重,这里的权重可以是任意我们希望表示的数据,例如距离或者花费的时间或者票价。无权图与带权图的对比如图6-6所示。 无权图中的每条边都只有“是否存在”这一个含义。它告诉我们:两个顶点之间是否直接相连,但**不提供任何关于距离、时间或成本的额外信息**。因此在无权图里,0–1 与 4–9 这两条边是完全等价的,它们唯一表达的是“这两个点之间有一条边”。至于实际距离是否远、消耗是否多,在无权图中根本无法判断。所以在像村庄关系网这样的场景里,边更多是“关系的连接线”,而不是衡量价值、距离或时间的工具。 带权图则增加了另一层含义——**每条边都有一个权重**。这个权重可以表示距离(公里数)、时间(分钟)、花费(票价)、流量容量,或任何想定义的指标。这样一来,边就不仅仅是“相连”,而是“相连且需要付出一定代价”。例如,0→1 可能代表 3 公里,而 4→9 可能代表 10 公里,那么在带权图中,这两条边之间的轻重差异就非常明显。带权图才使得最短路、最低成本路径等算法有意义。  <p align="center"> <b>图6-6 无权图与带权图</b> </p> 因此无权图强调的是“结构”,关注点是图是否连通、能否遍历、是否存在路径等拓扑关系;带权图则强调“代价”,因此适用于找最短路径、最低成本、最少时间等应用场景。简单来说: - 无权图只考虑“有没有路”; - 带权图既考虑“有没有路”,也考虑“这条路的代价是多少”。 无向图不一定不如有向图,无权图也不一定不如有权图,尽管有向有权所带来的信息更丰富和功能更全面,但功能更强大并不代表在所有问题中更好,有些时候我们就只关心是否连通,是否存在路径等问题上,这时候更多的信息叠加在错综复杂的图上,就会给我们带来额外的负担。 在图论中,添加方向或权重意味着额外复杂性,例如算法更复杂(如最短路算法因权重不同要换不同算法),需要存储更多信息,视觉上也更难读取结构,工程中维护数据也更复杂等问题。所以如果问题能用简单图解决,就不要引入复杂图。原因在于:抽象越多,模型越清晰;信息越多,问题越复杂。 无向或无权图并不是弱版本,它们是更纯粹的抽象,聚焦到更核心的问题上。用无向图、无权图非常常见,也完全没有“弱化”的含义,只有适合应用场景才是最重要的。 ## 6.3 图的表示方法 那怎么在程序中表示图呢?我们知道一个图包含很多顶点,另外包含顶点和顶点之间的连线(边),顶点与边都是非常重要的图信息,因此都需要在程序中体现出来。其余类似于路径、度都是属于延伸概念,无需额外体现。 顶点的表示相对简单,所以我们先讨论顶点的表示。如图6-6所示的顶点,我们抽象成1 2 3 4,也可以抽象成A B C D或者表示其他含义的数据(例如村庄的名字),在之后的案例中,我们就统一使用A B C D的形式来表示。那么这些A B C D我们可以使用一个数组来存储起来(存储所有的顶点)。 那么边怎么表示呢?因为边是两个顶点之间的关系,所以"连接"的概念表示起来会稍微麻烦一些。以下我们会介绍边的两种表达形式。 ### 6.3.1 邻接矩阵表示法 一种比较常见的表示图的方式是邻接矩阵。邻接矩阵让每个节点和一个整数项关联,该整数作为数组的下标值,我们用一个二维数组来表示顶点之间的连接,邻接矩阵如图6-7所示。如果我想表示A->C的这条边,只需要在矩阵中找到 第 A 行、第 C 列 的位置,并将该位置的值设为 1(或设为权重值,如果是带权图)。 在如图6-7的邻接矩阵中,顶点 A 对应行 A;顶点 C 对应列 C。因此表示 A->C 的方式是:`matrix[A][C] = 1`。  <p align="center"> <b>图6-7 邻接矩阵</b> </p> 邻接矩阵为图中的每个顶点分配一个整数编号,并以这个编号作为二维数组的下标。在这种表示中,我们构建一个大小为 n × n 的二维数组,其中 n 是顶点数量。数组的第 i 行第 j 列的元素用于表示“顶点 i 是否与顶点 j 相连”。如果两者之间存在边,我们就在这个位置记录 1(或权重值);若不存在边,则记录 0。比如 `matrix[0][2] = 1` 表示编号为 0 的顶点 A 与编号为 2 的顶点 C 之间有一条边。邻接矩阵的核心优点是结构清晰、查询是否相邻的时间复杂度为 O(1),但也因为需要 n² 的空间,在顶点很多但边较少的稀疏图中会带来额外的存储开销(矩阵中将存在大量的0,这意味着我们浪费了计算机存储空间来表示根本不存在的边)。 所以在二维数组中,0表示没有连线,1表示有连线,通过二维数组,我们可以很快的找到一个顶点和哪些顶点有连线。(比如A顶点,只需要遍历第一行即可)。另外,A-A,B-B(也就是顶点到自己的连线),通常使用0表示,所以我们可以看到图6-7对角线上的数字都是0。 ### 6.3.2 邻接表表示法 邻接矩阵虽然结构简单、查询相邻关系也非常高效,但在边很少的稀疏图中会造成大量空间浪费,因为 n 个顶点需要 n² 的存储空间,其中大部分单元格都是 0。为了解决这种存储低效的问题,图结构更多时候会采用 **邻接表**。邻接表的思想是:为每个顶点单独维护一个“相邻顶点的列表”,只记录真实存在的边,从而避免无意义的空位占用。这个列表的存储方式非常灵活,可以使用数组、链表、哈希表(字典)等数据结构实现,使得在节省空间的同时,也方便我们快速访问与某个顶点直接相连的所有节点。邻接表如图6-7所示。  <p align="center"> <b>图6-7 邻接表</b> </p> 从图6-7中,可以很好的理解邻接表所表达的含义,例如我们要表示和A顶点有关联的顶点(边),那么我们可以通过A找到对应的数组/链表/字典,再取出其中的内容(B、C,D)就可以啦。这种形式看起来与哈希表的键值对格式是有那么一点相似的,键是A;值是BCD。 但邻接表也是存在一些问题的: 邻接表计算"出度"是比较简单的(出度: 指向别人的数量,入度: 指向自己的数量)。但如果邻接表需要计算有向图的"入度",那么是一件非常麻烦的事情,它必须构造一个“逆邻接表”,才能有效的计算“入度”。但是开发中“入度”相对用的比较少。 ## 6.4 图的实现与操作 ### 6.4.1 图类的创建 接下来,我们要以代码的形式将图结构表现出来。依旧是使用类,先对图结构进行封装,然后定义相关的属性(例如顶点和边),最后来实现图中一些方法或者算法。 首先创建Graph(图)的构造函数(类),这个我们在封装其他数据结构的时候已经非常熟悉了,定义两个私有属性: (1)verteces: 用于存储所有的顶点,我们说过使用一个数组来保存。 (2)adjList: adj是adjoin的缩写,邻接的意思。 adjList用于存储所有的边,我们这里采用邻接表的形式。 边有两种表达形式,即邻接矩阵和邻接表,我们采用后者。而我们表达过邻接表的形式很像哈希表的键值对形式,因此我们不使用对象来保存,而是采用JavaScript中的标准内置对象Map来保存键值对。其中键用于存放某个具体顶点,而值是一个数组,存放顶点指向其他顶点的边。并且Map对象中的键只能出现一次,这对我们实现图结构是非常有利的,图结构的顶点都是唯一的,Map对象的该特性令我们无需在去判断键是否出现过。 属性设置为私有,是因为顶点与边是图结构的基本单位,我们后续的所有操作都依赖这两个属性,一旦被外部修改,会导致整个图结构的崩溃。这些具体的实现细节不应该暴露给使用者。 ```ts class Graph<T> { // 顶点 private verteces: T[] = [] // 边: 邻接表 private adjList: Map<T, T[]> = new Map() } const graph = new Graph() export {} ``` ### 6.4.2 添加顶点与边的方法 添加顶点非常简单,因为顶点是可以独立存在的,我们只需要将需要添加的顶点push到verteces属性(数组)中就可以了。但在添加顶点时,需要同步创建一个顶点对应的邻接表,该邻接表的键为顶点,值则设置成一个空数组,方便后续添加该顶点指向其他顶点的边。 ```ts addVertex(vertex: T) { // 将顶点添加数组中保存 this.verteces.push(vertex) // 创建一个邻接表中的数组 this.adjList.set(vertex, []) } ``` 测试代码如下: ```ts const graph = new Graph() graph.addVertex("A") graph.addVertex("B") graph.addVertex("C") graph.addVertex("D") graph.addVertex("E") graph.addVertex("F") graph.addVertex("G") graph.addVertex("H") graph.addVertex("I") ``` 但如果我们想快速执行A~Z的输入,我们可以利用Unicode码位转换来实现连续字母的快速输入。String.fromCharCode()静态方法能将Unicode码位转为对应的内容。A~Z的区间在65-90之间,a~z的区间在97-122之间。 ```ts for (let c = 97; c <= 122; c++) graph.addVertex(String.fromCharCode(c)); ``` 接下来实现添加边的addEdge()方法,该方法需要接受两个参数:即构建边的两个顶点参数。 拿到顶点后可以去匹配Map对象(邻接表)中的键,匹配上之后,将另一顶点,放入相邻顶点的列表中,从而实现边的添加。由于我们目前的图是无向图,因此同样的操作要反着再来一遍,两个顶点都要操作,都要有通向另一顶点的边。 因为数组是引用类型,所以我们只要通过Map.prototype.get()实例方法获取到邻接表后,直接将顶点push到邻接表中。所有引用数组内存地址的数据都会同步更新。 ```ts addEdge(v1: T, v2: T) { this.adjList.get(v1)?.push(v2) this.adjList.get(v2)?.push(v1) } ``` 完成以上两个核心方法:添加顶点,添加边。我们能够依靠这两方法实现一个基本的无向图。那么来测试一下方法是否能够实现图结构。 ### 6.4.3 图结构的打印与测试 当我们通过测试代码添加对应的顶点与边之后,我们要如何拿到形成的图结构的结果? 图结构的展现是可以通过邻接表体现出来的,所以理论上我们将邻接表打印出来就能得到图结构的测试结果。打印邻接表并不困难,我们通过Map对象的size实例属性获取邻接表中一共有多少个元素,然后遍历使用Map.prototype.entries()实例方法将所有键值对打印出来就行。因此我们创建traverse()方法,用于将邻接表打印出来。 ```ts // 方法一 traverse() { for (const [vertex, edges] of this.adjList.entries()) console.log(`${vertex} -> ${edges.join(" ")}`) } ``` 除此之外,我们还可以遍历维护的verteces数组,该数组存放了图结构所有的顶点,再通过Map.get()找到每个vertex的邻接数组。 ```ts // 方法二 traverse() { console.log("Graph:") this.verteces.forEach(vertex => { const edges = this.adjList.get(vertex) console.log(`${vertex} -> ${edges?.join(" ")}`) }) } ``` 其实两种方法都是可以的,打印效果也是一致的,但我推荐大家使用方法一的adjList.entries()会更好,因为真实的图数据存在于邻接表(不是顶点数组)。图结构实际上是: - 顶点 —— 存在 adjList.keys()。 - 边 —— 存在 adjList.values()。 vertices[]只是我们人为维护的额外结构,真正决定图结构的是Map本身。假如以后我们写了一个删除顶带你的操作,我们删除了Map对象中的key,但忘记从vertices[]里删,就会导致traverse()方法出来的图节点跟实际的图不一致,这是图结构常见的bug,很难排查。 所以,我们应该尽可能少的去维护状态,会更安全。通过Map对象本身的key去获取本身的value才是最合适的,图的定义最好只来源于一个结构。 完整测试代码如下。 ```ts class Graph<T> { // 顶点 private verteces: T[] = [] // 边: 邻接表 private adjList: Map<T, T[]> = new Map() /** 添加顶点和边的方法 */ addVertex(vertex: T) { // 将顶点添加数组中保存 this.verteces.push(vertex) // 创建一个邻接表中的数组 this.adjList.set(vertex, []) } addEdge(v1: T, v2: T) { this.adjList.get(v1)?.push(v2) this.adjList.get(v2)?.push(v1) } // 方法一 traverse() { for (const [vertex, edges] of this.adjList.entries()) console.log(`${vertex} -> ${edges.join(" ")}`) } } const graph = new Graph() graph.addVertex("A") graph.addVertex("B") graph.addVertex("C") graph.addVertex("D") graph.addVertex("E") graph.addVertex("F") graph.addVertex("G") graph.addVertex("H") graph.addVertex("I") graph.addEdge('A', 'B'); graph.addEdge('A', 'C'); graph.addEdge('A', 'D'); graph.addEdge('C', 'D'); graph.addEdge('C', 'G'); graph.addEdge('D', 'G'); graph.addEdge('D', 'H'); graph.addEdge('B', 'E'); graph.addEdge('B', 'F'); graph.addEdge('E', 'I'); graph.traverse() export { } ``` 测试代码打印输出效果如图6-8所示。  <p align="center"> <b>图6-8 图结构的实现(打印结果)</b> </p> ## 6.5 图的遍历算法 实现添加顶点与边的方法之后,基本的图结构就能实现,其次通过traverse()方法完成图结构的打印与测试。接下来我们就来学习一下图结构的遍历。 图结构的遍历很有意思,如果由我们来实现,我们需要考虑哪些问题? 遍历之前,我们知道图是由顶点和边组成的,那我们遍历图的目的是什么?是想访问所有的顶点,还是沿着边去遍历结构? 而且图结构与树不同,在6.2.2小节中,我们有说明回路的概念,也就是图是有可能有环的;在6.1.3小节的七桥问题中,甚至是存在没办法一次性走通的情况,这两点的存在让我们遍历图的时候,必须要避免死循环以及遍历不完整的情况。所以我们第一件事一旦是怎么记录已经访问过的节点? 并且我们不能保证一次性遍历就覆盖所有节点,仅从一个起点出发可能走不到所有节点,所以需要考虑是否要从每一个未访问过的节点重新启动遍历?从一个节点开始遍历之后,我们的遍历顺序要怎么控制?需要的数据结构是什么?以及需要标记访问过的顶点吗(防止重复访问)?邻接表的访问顺序是什么?遍历的结果输出成什么形式?以及是否考虑图的方向性(无向图与有向图)?遍历是否要处理权重值(无权图与有权图)?遍历的时候我们需要设置哪些边界判断? 因此图遍历需要考虑的问题较多,图遍历比树遍历更复杂,对应问题顺序表如表6-2所示。 <p align="center"> <b>表6-2 图结构的遍历问题</b> </p> | 层级 | 需要考虑的问题 | | ---- | ---------------------------------------- | | 0 | 图是什么?结构如何保存? | | 1 | 图有环、可能不连通 | | 2 | 是否确保遍历所有组件? | | 3 | 选 BFS 还是 DFS?是否需要 visited? | | 4 | 邻接节点的访问顺序如何控制? | | 5 | 遍历结果如何输出? | | 6 | 图是有向还是无向?是否影响遍历? | | 7 | 是否考虑边权重? | | 8 | 如何避免错误输入、死循环、多重边等问题? | 那我们这里遍历的是一个普通基础的图结构,不考虑图的方向和权重,对边界判断不会非常细致的去做,更侧重遍历的方式。 常见的遍历图结构的方式有两种: (1)广度优先搜索(Breadth-First Search,简称BFS)。 (2)深度优先搜索(Depth-First Search,简称DFS)。 两种遍历算法,都需要明确指定第一个被访问的顶点。 BFS与DFS的遍历过程分别是怎么样的呢?我们以一个迷宫中关灯的案例说明:现在需要我们走进迷宫,将迷宫中的灯一个个关掉,你会怎么关?迷宫关灯案例如图6-9所示。  <p align="center"> <b>图6-9 迷宫关灯案例</b> </p> 关灯不能重复,那么我们有两种BFS和DFS两种遍历算法方式,这两种方式所采用的结构各不相同: (1)BFS:基于队列,入队列的顶点先被探索。 (2)DFS:基于栈或使用递归,通过将顶点存入栈中,顶点是沿着路径被探索的,存在新的相邻顶点就去访问。 如果我们使用 BFS,当走进迷宫时采取“先把附近所有灯都关掉,再走向更远的灯”这样的策略。从入口处开始,先检查离你最近的一圈房间,把这一层所有的灯依次关掉;然后再走向下一圈稍远的房间,再把这一层所有灯关掉。我们的行动像水波一样一层层往外扩散,每一层的所有灯都是成批处理的。它的特点是:靠着“先到先关”的队列,永远先处理最近的位置,因此如果我们想找到“从入口到某一盏灯的最短路径”,BFS 是最可靠的办法。 如果我们使用 DFS,当走进迷宫后,会选一条通道一路走到尽头,把沿途遇到的灯全部关掉;走到死胡同时再往回走,在分叉口换另一条没走过的路继续深入,把能关的灯都关掉。我们的行为像是不断“向下钻”,直到走不动就回溯,然后换一个分支继续探索。它依赖递归或栈来记录路径,特点是:深度优先、不关心最短路线,但特别适合“整个迷宫全部走一遍,把每一盏灯都关掉”这种彻底探索的任务。 补充概念:visited集合概念来自图(Graph),指用来记录“哪些节点已经访问过”的专用数据结构,用来避免重复访问和避免死循环。 而为了记录顶点是否被访问过,我们使用3种颜色来反应它们的状态: (1)白色: 表示该顶点还没有被访问。 (2)灰色: 表示该顶点被访问过,但并未被探索过。 (3)黑色: 表示该顶点被访问过且被完全探索过。 或者我们也可以使用JavaScript标准内置对象中的Set对象来存储被访问过的节点。Set对象是值的合集(collection)。集合(set)中的元素只会出现一次,即集合中的元素是唯一的。每次我们想访问一个顶点时,就先去Set对象中看有没有这个顶点,如果Set对象已经有存储对应顶点,那我们就不再访问;如果Set对象中没有,那么访问的同时,将该顶点放入Set对象中。 但如果只是为了判断集合中是否存在已经访问过的顶点,好像数组也可以做到,为什么我们不用数组?首先数组是有序的列表,是可以包含重复元素的,这和visited集合概念冲突,因为visited集合不关心顺序,它本质是"状态"。当然,我们会手动判断的,不会让重复的元素(顶点)进到数组当中。从这种角度来说,虽然数组不适合做访问标记,但好像实在要做也不是不行? 当然不是这样的,还有一个最核心的理由,图是错综复杂的,判断是否存储过顶点意味着大量的查找操作(每访问一个节点都要查一次),而数组的查找性能是O(n),即经典的线性查找,在刚开始学习数据结构与算法的时候就已经学习到,性能上的缺陷是我们摒弃数组最核心的原因,也是数组这一数组结构无法解决的问题。如果用数组,节点越多,性能越差,在大图上会明显变慢。 相对数组来说,Set对象核心理念是无重复(自动判断)、无序、查存在。这和BFS与DFS的“visited集合”概念(不允许重复、用来判断某个节点是否访问过以及不关心顺序)是一致的。最关键的在于Set对象的查找操作是哈希查找,就是我们在学习哈希表所听过的那个哈希,查找性能是常数时间O(1),速度优势巨大。 ### 6.5.1 广度优先搜索算法(BFS) 广度优先搜索算法的思路就是从起点开始按层级逐圈向外扩展,会从指定的第一个顶点开始遍历图,先访问其所有的相邻点,就像一次访问图的一层。换句话说,就是先宽后深的访问顶点,类似于树结构遍历的层序遍历,如图6-10所示。  <p align="center"> <b>图6-10 广度优先算法(BFS)</b> </p> 我们的代码示例不采用3色标记状态,而是采用Set对象访问标记,会更加方便简洁,那么这就开始实现bfs()广度优先算法方法,思路为以下3步: (1)首先我们需要判断图结构是否存在顶点(边界判断),这一步就像判断树结构是否存在根节点一样,如果图结构的顶点不存在那就可以直接返回了。 (2)其次创建队列结构来探索顶点,创建Set对象来访问标记顶点,将图结构的第一个顶点放入到队列与Set对象中作为指定第一个被访问的顶点。 (3)满足前置条件后,就可以遍历队列中的每一个顶点。 遍历队列采用BFS,因此我们首先要将第一个顶点取出并打印,然后将顶点A指向的其他顶点放入队列中(从邻接表中直接获取),在下一轮遍历中取出第二个顶点,然后将第二个顶点指向的其余顶点继续放入队列中。每次循环从队列取出一个顶点,而队列又新增取出顶点所指向其余新顶点,当队列清空后,结束遍历。 这里需要注意的是,每次队列新增顶点时,都需要通过Set对象查询判断是否已访问过,只有未访问过的顶点才能放入队列中,而放入队列的顶点同时也要放入Set对象中,确保其状态处于已经访问。顶点加入Set对象的时机非常重要:应该在入队前,而不是出队后,因为入队之后不是马上可以出队的,每次出队只出一个顶点,其余顶点需要排队,在排队的这段时间内,顶点若未加入Set对象,可能导致多次被不同父节点重复入队。 ```ts bfs() { // 1.判断是否有顶点 if (this.verteces.length === 0) return // 2.创建队列结构访问每一个顶点 const queue: T[] = [] queue.push(this.verteces[0]) // 3.创建Set结构, 记录某一个顶点是否被访问过 const visited = new Set<T>() visited.add(this.verteces[0]) // 4.遍历队列中每一个顶点 while (queue.length) { // 访问队列中第一个顶点 const vertex = queue.shift()! console.log(vertex) // 相邻的顶点 const neighbors = this.adjList.get(vertex) if (!neighbors) continue for (const nei of neighbors) { if (!visited.has(nei)) { visited.add(nei) queue.push(nei) } } } } ``` BFS与层序遍历的思路很相似,因此这对于大家来说并不难理解。 ### 6.5.2 深度优先搜索算法(DFS) 深度优先搜索算法的思路就是从起点开始沿着一条路径不断向深处探索,直到不能再往下为止再回溯到上层换一条未走过的路径继续前进,换句话说,就是先深后宽地访问顶点,类似于树结构遍历中的先序遍历,如图6-11所示。  <p align="center"> <b>图6-11 深度优先算法(DFS)</b> </p> 深度优先搜索算法与广度优先搜索的思路不同,它不会按层级扩散,而是会优先沿一条路径一直向深处探索,直到无法继续为止,再回退到上一个可以继续探索的位置继续深入。我们同样不采用三色标记状态,而是使用Set对象进行访问标记,这让整个算法更加轻量、直观,也便于JavaScript中以集合方式判断顶点是否被访问过。 DFS有两种做法,一种是栈的做法,一种是递归的做法,我们采用栈的做法,思路为以下3步: (1)首先,同样要进行边界判断。判断图结构是否存在顶点,如果图中没有任何顶点,那么深度优先遍历无法进行,可以直接返回。这一步与树没有根节点无法遍历的逻辑一致,是 DFS 的第一道前置条件。 (2)接着,需要创建一个**栈结构(stack)**来驱动深度优先的“深度”行为。DFS 使用栈来记录访问路径:从第一个顶点开始,将它压入栈中,并在同时将它加入 Set 对象中标记为已经访问。这里要特别说明:和 BFS 完全相同,**顶点加入 Set对象 的时机也必须是“入栈之前”**而不是“出栈之后”,否则相邻的父节点在递归回溯前都可能重复把同一个未标记的节点加入栈中,导致重复访问甚至死循环。 (3)满足前置条件后,就可以正式开始遍历栈中的顶点。DFS 的遍历方式是:每次循环从栈顶弹出一个顶点(这一步使得 DFS 总是优先沿当前路径最深处走),打印它,然后读取该顶点的所有邻接点。这里的邻接点顺序很重要:我们从邻接数组末尾向前遍历,将未访问过的邻接点压入栈中,使得数组中靠前的邻接点能够更早地被弹出、从而走成一条更自然的“深度路径”(例如图6-11所示,顶点A弹出栈之后,我们本来是要先将B C D按顺序压入栈中,但这样的话,D会先出来,为了更有顺序,我们先将D先压入栈中,接着是C和B,这样栈的弹出顺序就为BCD,之后也是从末尾往前遍历)。当子路径全部探索完毕,栈会逐渐退回,从而继续探索其它分支。最终,当栈被清空,说明所有路径都已经被走过,整个深度优先遍历结束。 ```ts dfs() { // 1.判断有没有订单, 没有直接返回 if (this.verteces.length === 0) return // 2.创建栈结构 const stack: T[] = [] stack.push(this.verteces[0]) // 3.创建Set结构 const visited = new Set<T>() visited.add(this.verteces[0]) // 4.从第一个顶点开始访问 while (stack.length) { const vertex = stack.pop()! console.log(vertex) const neighbors = this.adjList.get(vertex) if (!neighbors) continue // 类型缩小 // 反着遍历 for (let i = neighbors.length - 1; i >= 0; i--) { const nei = neighbors[i] if (!visited.has(nei)) { // 顶点加入 Set对象 的时机也必须是“入栈之前 visited.add(nei) stack.push(nei) } } } } ``` ### 6.5.3 遍历算法的实现与比较 在图结构中,BFS 的遍历路线是“按层级展开”,从起点向外一圈圈扩散,确保离起点越近的节点越早被访问。这使 BFS 具备天然的层级结构,像一层一层剥洋葱一样访问图。相对地,DFS 的路线是“沿路径不断向深处钻”,它会沿着某条路径一直走到底部再回溯回来。因此,BFS 的访问顺序体现水平层级,而 DFS 的访问顺序体现路径深度。 在最短路径问题中:BFS 更有优势。对于无权图(每条边成本相同),BFS 是寻找起点到任意终点**最短路径**的最佳选择。因为 BFS 必须先访问完第 1 层的所有节点,再访问第 2 层,因此当你第一次在 BFS 中遇到目标节点时,那条路径一定就是最短路径。而 DFS 不能保证这一点:它可能会先沿着一条特别深的分支走很久,找到一个长路径后才回溯,导致你看到的并不是最短路径。因此,在图中寻找最短路径、最小步数的场景中,BFS 更适用于路线规划、迷宫最短出口、社交网络的最少关系链等问题。 在结构探索和连通性判断中:DFS 更有优势。DFS 的强项是“深入探索”,它天然适合把一个连通区域内所有节点都走完。因此,如果你的目标不是最短路径,而是判断图是否连通、寻找连通分量、检测环(cycle detection)、拓扑排序、判断是否存在某条路径等结构性问题时,DFS 的递归特性让它更容易实现,也更适合这类深度分析。例如图中是否存在环、某个节点是否可到达另一个节点、一个区域是否完全相连,这些问题 DFS 都比 BFS 更高效与更自然。 在空间复杂度上:DFS 通常更节省内存。BFS 需要维护一个可能很大的队列,这在宽度非常大的图中可能会导致内存开销剧增。例如一棵非常矮但非常“宽”的树,BFS 可能需要一次性存储成千上万的兄弟节点。而 DFS 的栈深度最多只会和图的深度一样,哪怕图很宽,只要深度不大,DFS 都可以用极少的额外空间完成遍历。因此在空间资源有限、图很大但深度不深的场景中,DFS 更具优势。 如果我们的目标是“尽快找到某个特定节点”或“找到最短路径”,那么 BFS 因为它的层级推进特性,更容易在浅层就找到目标,是典型的目标搜索算法。而如果想要的是“把整个图跑完并提取结构信息”,例如遍历所有节点、生成拓扑结构、检测连通区域等,那么 DFS 的深度探索与回溯机制更适用于这类全局性的结构问题。 两者都必须依赖 visited 集合防止重复访问,但它们在遇到环时表现不同: (1)BFS 在遇到环时,只会在访问邻居时判断 visited,那些已访问节点会被完全跳过,不会陷入死循环; (2)DFS 若没有 visited,则会一直沿环中的路径循环下去,永不回头。因此在复杂、有环的图结构中,DFS 对 visited 的依赖更强。也正因为 DFS 的深入特性,它更适合用来检测环(cycle detection),因为沿深度回溯时你能清楚看到当前路径上是否再次遇到已访问节点。 总的来说,BFS更适合“找最近的”“找最短路径”“按层级扩散”“目标搜索”。而DFS更适合“彻底探索结构”“判断连通性”“检测环”“拓扑排序”“内存较少时的遍历”。 图的完整代码如下。 ```ts class Graph<T> { // 顶点 private verteces: T[] = [] // 边: 邻接表 private adjList: Map<T, T[]> = new Map() /** 添加顶点和边的方法 */ addVertex(vertex: T) { // 将顶点添加数组中保存 this.verteces.push(vertex) // 创建一个邻接表中的数组 this.adjList.set(vertex, []) } addEdge(v1: T, v2: T) { this.adjList.get(v1)?.push(v2) this.adjList.get(v2)?.push(v1) } traverse() { for (const [vertex, edges] of this.adjList.entries()) console.log(`${vertex} -> ${edges.join(" ")}`) } bfs() { // 1.判断是否有顶点 if (this.verteces.length === 0) return // 2.创建队列结构访问每一个顶点 const queue: T[] = [] queue.push(this.verteces[0]) // 3.创建Set结构, 记录某一个顶点是否被访问过 const visited = new Set<T>() visited.add(this.verteces[0]) // 4.遍历队列中每一个顶点 while (queue.length) { // 访问队列中第一个顶点 const vertex = queue.shift()! console.log(vertex) // 相邻的顶点 const neighbors = this.adjList.get(vertex) if (!neighbors) continue for (const nei of neighbors) { if (!visited.has(nei)) { visited.add(nei) queue.push(nei) } } } } dfs() { // 1.判断有没有订单, 没有直接返回 if (this.verteces.length === 0) return // 2.创建栈结构 const stack: T[] = [] stack.push(this.verteces[0]) // 3.创建Set结构 const visited = new Set<T>() visited.add(this.verteces[0]) // 4.从第一个顶点开始访问 while (stack.length) { const vertex = stack.pop()! console.log(vertex) const neighbors = this.adjList.get(vertex) if (!neighbors) continue // 类型缩小 for (let i = neighbors.length - 1; i >= 0; i--) { const nei = neighbors[i] if (!visited.has(nei)) { visited.add(nei) stack.push(nei) } } } } } const graph = new Graph() graph.addVertex("A") graph.addVertex("B") graph.addVertex("C") graph.addVertex("D") graph.addVertex("E") graph.addVertex("F") graph.addVertex("G") graph.addVertex("H") graph.addVertex("I") graph.addEdge('A', 'B'); graph.addEdge('A', 'C'); graph.addEdge('A', 'D'); graph.addEdge('C', 'D'); graph.addEdge('C', 'G'); graph.addEdge('D', 'G'); graph.addEdge('D', 'H'); graph.addEdge('B', 'E'); graph.addEdge('B', 'F'); graph.addEdge('E', 'I'); graph.traverse() graph.dfs() export {} ``` ## 6.6 图结构的应用建模 图结构的应用非常多,例如可以对一些现实案例进行建模,例如交通流量与飞行航线。 ### 6.6.1 交通流量建模 在交通流量建模中,图结构可以表示城市道路网络。我们可以将街道的十字路口作为顶点,而连接这些顶点的边则表示街道本身。为了进一步量化道路的特性,边可以赋予权重,例如道路的限速、车道数量或者街道长度。这种加权图不仅能够展示道路的基本结构,还可以为交通分析提供精确的数据支持。通过这个模型,交通管理部门或智能导航系统能够分析不同路线的通行效率,从而判定最佳行车路线,并预测可能出现交通拥堵的街道,从而优化交通调度和规划。像类似高德地图为什么总能精准预测我们到达目的地的时间,也许就是基于这些因素来计算得出的。 ### 6.6.2 飞机航线建模 同样地,在飞机航线建模中,图结构也发挥着重要作用。航空公司可以将每个机场视为顶点,而连接两个机场的航线作为边进行建模。为了评估航线的经济性或效率,这些边可以赋予权重,例如航班成本、飞行时间或两个机场之间的实际距离。通过这种加权图模型,航空公司可以快速计算从一个城市到另一个城市的最优航线,既可以考虑成本最小化,也可以优化飞行时间或里程。同时,这种建模方式还可以帮助航空公司分析航线网络的可靠性,识别潜在的瓶颈或高负荷节点,从而提高整体运营效率。 总的来说,无论是城市交通还是航空航线,图结构都为复杂网络提供了清晰的抽象和量化方法。通过顶点表示节点、边表示连接、权重刻画特性,建模人员能够在理论层面和实践应用中有效分析路径选择、成本优化以及潜在风险。这种方法不仅提高了决策的科学性,也为智能交通系统和航空运营管理提供了坚实的基础。 到目前位置,数据结构与算法第一阶段(基础)就告一段落,接下来从第7章开始,我们会深入数据结构与算法,讲解一些更进阶一些的数据结构,例如循环链表、双向链表、堆结构、双端队列、二叉堆、最大堆、AVL树、红黑树以及动态规划和各种排序算法,最后在去刷LeetCode的一些高频题目。
第5章:树
## 5.1 树结构基础与特性 什么是树?相信每个人对现实生活中的树都会非常熟悉,树通常有一个根,连接着根的是树干,树干往上还会分叉成树枝,树枝进一步分叉成更细的树枝,最后在树枝上生长的是树叶。专家们对树的结构进行抽象,发现树可以模拟生活中的很多场景。 ### 5.1.1 树的定义与现实案例 那么生活中都有哪些现实案例,能看到模拟树结构的影子呢?从熟悉的场景出发,我们可以进行大量有意义的延伸。例如,你电脑中的文件系统就是一棵最直观的树。`C:`或`/`目录就是根节点,里面的文件夹是树枝(内部节点),而一个个文件就是树叶(叶子节点)。这种层次结构让我们能高效地组织和查找信息。再比如,一个公司的组织架构图,总经理是根节点,下设各个部门作为子树,部门下面再管理各具体工作,最终到基层员工(叶子节点),公司组织架构如图5-1所示。这体现了树的另一个强大特性:清晰地表示从属关系与层级。  <p align="center"> <b>图5-1 公司的组织架构</b> </p> 我们再将各个现实案例里面的数据移除,仅仅抽象出来结构,那么就是我们要学习的树结构,树结构对应的抽象内容如图5-2所示。  <p align="center"> <b>图5-2 树结构抽象</b> </p> ### 5.1.2 树的优点(与数组、链表、哈希表对比) 我们之前已经学习了多种数据结构来保存数据,为什么要使用树结构来保存数据呢?树结构和数组、链表,哈希表的对比有什么优点呢?抱着这样的疑问去探索树,我们更能意识到树结构对应的应用场景以及不可替代的地方。 数组的主要优点是根据下标值访问效率会很高,但是如果我们希望根据元素来查找对应的位置呢?比较好的方式是先对数组进行排序,再进行二分查找。需要先对数组进行排序,生成有序数组之后,才能提高查找效率也是数组的限制与缺点,另外数组在插入和删除数据时,需要有大量的位移操作(插入到首位或者中间位置的时候),效率很低。 链表的插入和删除操作效率都很高,但查找效率很低,需要从头开始依次访问链表中的每个数据项,直到找到。而且即使插入和删除操作效率很高,但是如果要插入和删除中间位置的数据,还是需要重头先找到对应的数据。 而在上一章所学习的哈希表,插入、查询,删除效率都是非常高的,但是哈希表也有很多缺点,例如空间利用率不高,底层所使用的是数组,并且为了防止出现聚集效应导致的性能损失,某些单元是没有被利用的。且哈希表中的元素是无序的,不能按照固定的顺序来遍历哈希表中的元素,更不能快速的找出哈希表中的最大值或者最小值这些特殊的值。 那么树结构呢?我们不能说树结构比其他结构都要好,因为每种数据结构都有自己特定的应用场景。但是树确实也综合了上面的数据结构的优点(当然优点不足于盖过其他数据结构,例如效率一般情况下没有哈希表高),并且也弥补了上面数据结构的缺点,在综合表现能力上会更好,例如树结构的空间利用率不错,元素可以是有序的,能够快速找出最大值或者最小值等等。 而且为了模拟某些场景,我们使用树结构会更加方便。因为树结构的非线性的,可以表示一对多的关系,例如文件的目录结构。 ### 5.1.3 树的术语解析 在描述树的各个部分的时候有很多术语,为了让介绍的内容更容易理解,需要知道一些树的术语,不过大部分术语都与真实世界的树相关,或者和家庭关系相关(如父节点和子节点),所以它们比较容易理解。 树是由 n(n≥0)个节点构成的有限集合。当 n=0 时,它是一棵空树,这是树的边界情况;对于任意一棵非空树(n>0),其结构则具备鲜明的层次与递归特性:其中存在且仅有一个被称为根节点(Root)的特殊节点(用r表示),作为整个结构的起点与祖先;而其余节点则被逻辑地划分为 m(m>0)个互不相交的有限子集,其中每一个子集本身又是一棵树,并被称为原树的子树(SubTree)。树结构说明如图5-3所示。  <p align="center"> <b>图5-3 树结构说明</b> </p> 子树的严格定义是:在一棵非空树T中,由某个节点x及其全部后代节点(包括子节点、孙节点等)构成的,满足树结构定义的、具有唯一根节点的子结构,称为树T的一棵子树。所以在图中以A往下的任意节点及其全部后代节点都可以构成一棵子树,例如{B、D、E、H},或者以{D、H}都可以,这两个案例分别是以B和D两节点作为子树的根节点。还有一点是很有意思的,叶子节点能构成子树吗?答案是可以的,单个叶子节点本身就是一棵树,我们称之为 “单节点树”,一棵只有根节点一个节点的子树。 树之中的称呼都是相对的概念,以图5-3为例,B既是B和E的父节点,但B同时也是A的子节点,他们之间的关系就像家谱中的关系网一样,例如表妹不是固定具体的一个人,而是相对于“我”而言的一种关系定位。在中国过年去亲戚家拜访总是需要称呼的,例如舅舅好,舅妈好等等,需要有一个称呼来打招呼,而在树结构中也需要有对应的名词来称呼彼此之间的关系,常见树的术语如表5-1所示。 <p align="center"> <b>表5-1 树的术语</b> </p> | 类别 | 术语 | 定义与描述 | 备注与拓展 | | :------------- | :--------------------------------------- | :----------------------------------------------------------- | :----------------------------------------------------------- | | **节点属性** | **节点的度 (Degree)** | 节点的**子树个数**(即直接子节点的数量)。 | 度是节点分支能力的直接体现。在二叉树中,节点的度最大为2。 | | | **叶子节点 (Leaf)** | **度为0**的节点,也称为叶子节点。 | 叶子节点是树的“终点”,没有后继节点,是数据存储的常见位置。 | | **节点关系** | **父节点 (Parent)** | 拥有子树的节点,是其子树根节点的父节点。 | 关系是相对的。一个节点是其子节点的父节点,同时也是其父节点的子节点。 | | | **子节点 (Child)** | 一个节点是另一个节点的子节点,则后者是前者的父节点。 | 也称为孩子节点。父子关系定义了树的层次和走向。 | | | **兄弟节点 (Sibling)** | 具有**相同父节点**的各个节点,彼此互为兄弟节点。 | 兄弟节点处于树的同一层级,是并行关系。 | | **路径与层级** | **路径 (Path)** | 从节点n₁到nₖ的一个节点序列,其中nᵢ是nᵢ₊₁的父节点。 | 路径描述了树中从一个节点到达另一个节点的唯一通路。 | | | **路径长度 (Path Length)** | 路径上所包含的**边的数量**。 | 从根到某节点的路径长度,即为该节点的**深度**。 | | | **节点的层次 (Level)** | 节点在树中的层级。规定**根节点在第1层**,其余节点层数为其父节点层数加1。 | 层次也称为“深度”(Depth),但注意与下面“树的深度”定义可能因教材而异(有的从0开始,有的从1开始)。 | | **树整体属性** | **树的度 (Degree of Tree)** | 树中所有节点的**度的最大值**。 | 它决定了树的最大分支能力。度为2的树即为二叉树。 | | | **节点的深度 (Depth)** | 从**根节点**到该节点的**唯一路径的长度**(边的个数)。**根的深度为0**。 | 深度描述的是节点到根的距离,是**从顶向下**的度量。 | | | **节点的高度 (Height)** | 从该节点到其**最远叶子节点**的**最长路径长度**。**所有叶子节点的高度为0**。 | 高度描述的是节点到最远叶子的距离,是**从底向上**的度量。**树的高度**即为根节点的高度。 | | | **树的深度/高度 (Depth/Height of Tree)** | 树中所有节点的**深度的最大值**,也等于**根节点的高度**。 | 这描述了树的整体规模或最大层级。注意:**树的深度 = 树的高度**。 | 除了常见的称呼之外,还有祖先节点、后代节点这种不常用但见名知意的节点关系称呼,例如祖先节点是从某个节点到根节点的路径上经过的所有节点,而后代节点是某个节点下面的所有节点。 ## 5.2 树的表示方法与二叉树 ### 5.2.1 树的常见表示方法 树有3种常见的表示方法,普通表示法如图5-4所示,儿子-兄弟表示法如图5-5所示,儿子-兄弟表示法旋转如图5-6所示。 最符合直觉的是普通表示法(或称“子节点列表法”)。这种方法直接模拟了我们绘制树形图的方式:每个节点存储自身的数据,并维护一个指向其所有直接子节点的列表。例如,对于一个拥有三个子节点的根节点,其结构就包含一个包含三个元素的子节点列表。这种方法虽然直观,但在处理动态变化或需要深度遍历的树时,由于每个节点的子节点数量不定,可能导致存储效率和处理性能上的挑战。普通表示法如图5-4所示。  <p align="center"> <b>图5-4 树-普通表示方式</b> </p> 为了克服普通表示法的局限性,计算机科学家引入了极为巧妙的儿子-兄弟表示法(又称“左孩子右兄弟法”)。这种方法的核心洞察在于:任何复杂的树结构都可以被统一地转化为一棵二叉树。它不再记录一个节点的所有子节点,而是仅为每个节点设置两个指针:一个指向其第一个儿子,另一个指向其紧邻的下一个兄弟。通过这种方式,一个拥有多个分支的树被巧妙地“压扁”成了一个多层的链表结构。比如,一个节点及其三个子节点,在内存中就被表示为:父节点指向第一个儿子,第一个儿子指向第二个儿子,第二个儿子再指向第三个儿子。这种表示法统一了存储单元,使得每个节点无论有多少子代,都只占用固定的空间,为实现高效的树遍历算法奠定了基础。儿子-兄弟表示法如图5-5所示。  <p align="center"> <b>图5-5 树-儿子_兄弟表示法</b> </p> 当我们使用儿子-兄弟表示法后,树在逻辑上已经等价于一棵二叉树。为了更直观地理解这种等价关系,我们可以通过“**旋转**”来进行可视化。具体来说,就是将这棵隐含的二叉树进行约45°的顺时针旋转——此时,原树中“第一个儿子”的指针变成了二叉树的“左孩子”指针,而“下一个兄弟”的指针则变成了二叉树的“右孩子”指针。旋转视图之后,以K作为起始点,展现了树结构本质上都可以映射为二叉树,这也解释了为什么二叉树在理论和实践中都占据着至关重要的地位(非常重要,本章核心讲解的就是二叉树),因为它足以作为所有树形结构的通用表示和操作基础。儿子-兄弟表示法旋转如图5-6所示。  <p align="center"> <b>图5-6 树-儿子_兄弟表示法旋转</b> </p> 我们研究这些不同的树结构表示方法的目的在于:计算机的存储模型与我们的逻辑思维模型之间存在差异。我们脑中想象的树是立体的、多叉的,但计算机的内存是线性的、顺序的。这些表示法的核心目的,就是在这两者之间搭建一座桥梁:将直观的、多叉的层次结构,转化为计算机可以高效存储和处理的线性或二元结构。 不同的表示法在代码中的写法都不同,3种表示法对应的代码写法如下: (1)普通表示法:每个节点包含一个 children 数组,直接存储所有子节点。 (2)儿子-兄弟表示法:每个节点包含 firstChild 和 nextSibling 指针,将多叉树转化为链表结构。 (3)二叉树表示法:儿子-兄弟表示法旋转后的结果,使用标准的二叉树 left 和 right 指针。 ```ts // 1. 普通表示法 (子节点列表法) interface NormalTreeNode extends TreeNodeBase { children: NormalTreeNode[]; // 存储所有直接子节点的数组 } // 2. 儿子-兄弟表示法 interface ChildSiblingTreeNode extends TreeNodeBase { firstChild: ChildSiblingTreeNode | null; // 指向第一个儿子 nextSibling: ChildSiblingTreeNode | null; // 指向下一个兄弟 } // 3. 二叉树表示法 (儿子-兄弟表示法旋转后的结果) interface BinaryTreeNode extends TreeNodeBase { left: BinaryTreeNode | null; // 相当于原树的第一个儿子 (旋转后变为左孩子) right: BinaryTreeNode | null; // 相当于原树的下一个兄弟 (旋转后变为右孩子) } ``` ### 5.2.2 二叉树的概念与特性 通过树的常见表示方法之后,我们意识到二叉树的重要性,不仅仅是因为简单,也因为几乎上所有的树都可以表示成二叉树的形式。而如果树中每个节点最多只能有两个子节点,这样的树就称为"二叉树"。 从严格定义的角度来看,二叉树是一个递归定义的有限节点集合,它要么为空(空树),要么由一个根节点和两个互不相交的二叉树组成,这两个子树被严格区分为左子树和右子树,且每个节点最多只能拥有这两个直接后代,即使只有一个子节点也必须明确其左右位置。概述下来,主要为以下2点: (1)二叉树可以为空,也就是没有节点。 (2)若不为空,则它是由根节点和称为其左子树TL和右子树TR的两个不相交的二叉树组成。 二叉树虽然结构多样,但其基本形态严格遵循其定义规律,可以归纳为五种基本形式。第一种是空树,这是所有二叉树的起点和递归定义的基准情形。在此基础上,第二种形态是仅包含根节点的树,其左右子树均为空。当树开始生长时,便衍生出另外三种形态:第三种是根节点拥有左子树而右子树为空;第四种是根节点拥有右子树而左子树为空;第五种则是根节点同时拥有非空的左子树和右子树。这五种形态完整地描述了二叉树的所有可能结构——从最简单的空树和单节点树,到具有明确左右子树区分的不完全二叉树,再到最完整的左右子树兼备的形态。理解这五种基本形态对于掌握二叉树的遍历、构建和算法分析至关重要,因为任何复杂的二叉树都可以视为这些基本形态通过不同方式的组合与嵌套。二叉树的5种形态如图5-7所示。  <p align="center"> <b>图5-7 二叉树的5种形态</b> </p> 二叉树还有3个比较重要的特性,在笔试题中较为常见: (1)一颗二叉树第 i 层的最大节点数为:2^(i-1),i >= 1。 (2)深度为k的二叉树有最大节点总数为: 2^k - 1,k >= 1。 (3)对任何非空二叉树T,若n₀表示叶子节点的个数、n₂是度为2的非叶子节点个数,那么两者满足关系n₀ = n₂ + 1 。 如图5-8所示的二叉树,根节点A是第1层,最大节点数即2的0次方为1;节点B是第2层,最大节点数即2的1次方为2,往后规律依次,每一层的节点数都符合最大节点数被称为满二叉树。对于这棵深度为4的二叉树,最大节点数为2的4次方-1,即最大节点数为15。 第3个重要特性又是什么意思?如图5-8所示的二叉树,叶子节点(度为0的节点)是D、J、K和H,因此叶子节点个数n₀ = 4;度为2的节点(拥有两个子节点的非叶子节点)是A、B和E,因此度为2的节点个数n₂ = 3,这刚好符合了n₀ = n₂ + 1的规律(4=3+1)。对于任何非空二叉树,叶子节点个数总是等于度为2的节点个数加1。这个关系源于二叉树的边数计算和节点度数之和的平衡,无论树的具体形态如何变化,该等式恒成立。  <p align="center"> <b>图5-8 二叉树的三个重要特性</b> </p> 这个等式的推导过程非常精妙,其核心逻辑建立在连接节点与节点的“边”之上。我们可以从两个不同的角度来计算二叉树的总边数,并使其相等,从而建立关系。首先,在任意一棵非空二叉树中,除了根节点外,每个节点都有且仅有一条“入边”与其父节点相连,因此,总边数 E 等于节点总数 n 减去 1,即 **E = n - 1**。 另一方面,我们从节点的“出度”或“贡献”的角度来计算总边数。叶子节点(度为0)贡献0条边,度为1的节点贡献1条边,度为2的节点贡献2条边。如果设 n0, n1, n2 分别代表三类节点的数量,那么总边数 E 也等于 **E = 0 \* n0 + 1 \* n1 + 2 \* n2**。 现在,我们让两个关于总边数 E 的表达式相等:**n - 1 = n1 + 2n2**。同时,节点总数 n 又可以表示为 **n = n0 + n1 + n2**。将第二个公式代入第一个公式,得到 **(n0 + n1 + n2) - 1 = n1 + 2n2**。接下来,我们消去等式两边的 n1,并进行移项:**n0 + n2 - 1 = 2n2**,最终得到 **n0 = n2 + 1**。这个推导过程清晰地表明,无论树的形态如何变化,无论度为1的节点 n1 有多少,叶子节点与度为2的节点之间这种此消彼长、恒久不变的数量关系都必然成立。 ### 5.2.3 完美二叉树与完全二叉树 完美二叉树(Perfect Binary Tree) ,也称为满二叉树(Full Binary Tree),在二叉树中,除了最下一层的叶子节点外,每层节点都有2个子节点,就构成了满二叉树。完美二叉树如图5-9所示。  <p align="center"> <b>图5-9 完美二叉树</b> </p> 在实际情况中,完美二叉树在各种案例中是少有出现的。但除了完美二叉树之外,有一个常见的二叉树被称为完全二叉树,完美二叉树和完全二叉树的区别是什么?完全二叉树(Complete Binary Tree)是除二叉树最后一层外,其他各层的节点数都达到最大个数,且最后一层从左向右的叶子节点连续存在,只缺右侧若干节点的二叉树。因此我们称完美二叉树是特殊的完全二叉树。 如图5-10所示的二叉树不是完全二叉树,因为D节点还没有右节点,但是E节点就有了左右节点,没有满足最后一层的叶子节点必须从左到右的连续存在。像后续第9章所学的堆,本质上就是一棵完全二叉树,可以直接放入数组中,不用节点封装。  <p align="center"> <b>图5-10 不是完全二叉树</b> </p> ## 5.3 树的存储方式 二叉树常见的存储方式是数组和链表,是因为它们分别对应二叉树在不同形态下的优势。当二叉树是完全或接近完全时,节点的位置具有明确的下标关系,用数组存储可以通过公式快速计算父子节点的位置,不需要指针,访问效率高,实现也简单。 当二叉树结构不完整、分布不规则时,更适合使用链表来存储。链表节点直接保存左右孩子指针,能够自然表现树的结构,不会浪费空间,也便于插入、删除等结构调整。因此,数组适合规则、紧凑的树(如堆),链表适合一般、不规则或动态变化的二叉树结构。 ### 5.3.1 数组存储方式 在使用数组存储二叉树时,如果是**完全二叉树**,可以直接按照“从上到下、从左到右”的顺序依次放入数组下标中。这种方式简单高效,因为完全二叉树不存在中间节点缺失的情况,节点之间的位置关系可以直接用数组下标计算得到(例如:下标 i 的左孩子是 2i,右孩子是 2i+1)。完全二叉树的数组存储方案如图5-11所示。  <p align="center"> <b>图5-11 完全二叉树(数组存储)</b> </p> 但如果是一棵**非完全二叉树**,由于节点分布可能不连续,若直接使用相同的方式存入数组,会出现很多空洞位置。例如某层缺少左子节点,就必须跳过数组中的那个位置,导致浪费大量存储空间。因此,通常需要先将非完全二叉树补齐为完全二叉树后再存储,但这种补齐过程会增加许多实际不存在的“空节点”,从而造成空间浪费。非完全二叉树的数组存储方案如图5-12所示。  <p align="center"> <b>图5-12 非完全二叉树(数组存储)</b> </p> ### 5.3.2 链表存储方式 二叉树最常见的存储方式是使用链式结构,即将每个节点封装成一个 Node 对象。Node 中通常包含三部分内容:节点存储的数据、指向左子节点的引用、以及指向右子节点的引用。通过这种指针(引用)形式,一个节点就能直接定位到它的左右孩子,从而自然地形成树的层级关系。链式存储适用于任意形态的二叉树,不会因为节点缺失而造成空间浪费,也方便执行插入、删除等结构调整操作。二叉树的链表存储方案如图5-13所示。  <p align="center"> <b>图5-13 二叉树(链表存储)</b> </p> ## 5.4 二叉搜索树基础 二叉搜索树是在二叉树基础上的一种“有序二叉树”。它要求任意节点都必须满足:左子树所有节点的值都小于该节点值,右子树所有节点的值都大于该节点值,并且左右子树本身也要符合这一规则。因此,BST 不仅有二叉树的结构特性,还有明确的排序特性,使其能支持高效的查找、插入和删除。 ### 5.4.1 二叉搜索树的定义与特性 二叉搜索树(BST,Binary Search Tree),也称二叉排序树或二叉查找树。二叉搜索树本质上依旧是一棵二叉树,可以为空(允许没有任何节点),即整棵树不存在根节点,本质上就是一棵空树。允许空树(没有任何节点)主要有以下2点考虑: (1)BST的性质要求左右子树是BST,如果不允许“空树也算 BST”,就无法成立递归定义。因为当某节点不存在左或右子树时,需要将“该子树为空”视为一种合法BST。 (2)在实际程序中,很多操作(如插入、查找、树构建)都会遇到初始树还没有节点或者某个叶子节点的左右指针为null,此时如果将这些情况视为“合法 BST 的空状态”,算法才能正常书写。 那么,如果二叉搜索树不为空,则需要满足以下3点性质: (1)非空左子树的所有键值小于其根节点的键值。 (2)非空右子树的所有键值大于其根节点的键值。 (3)左、右子树本身也都是二叉搜索树。 了解了二叉搜索树的定义之后,我们发现二叉搜索树不为空的性质非常有顺序规律,非常适合检索,也许这就是它被称为"有序二叉树"的原因。那么如图5-14所示的哪些是二叉搜索树,哪些不是? 第一棵不是二叉搜索树,因为10的右子树的键值5小于10这一根节点的键值。第二第三棵符合二叉搜索树条件。  <p align="center"> <b>图5-14 二叉搜索树区分</b> </p> 其实二叉搜索树是非常容易区分的,沿着非空左子树一路到底,数字一定越来越小,沿着非空右子树一路到底,数字一定越来越大,从任何一个节点去看都是如此。甚至我们可以将二叉搜索树逆时针旋转45°,数值从左往右变大,从上往下变小。 因此我们得出二叉搜索树的特点就是相对较小的值总是保存在左节点上,相对较大的值总是保存在右节点上,那么利用这个特点,我们可以做什么事情呢? 例如在图 5-14 的第三棵二叉搜索树中查找键值 8,搜索过程会从根节点 6 开始。由于 8 大于 6,所以进入其右子树,找到节点 9;接着 8 小于 9,于是继续进入 9 的左子树,到达节点 7;再判断 8 大于 7,进入其右子树,最终找到目标节点 8。这个查找过程每一步都根据大小关系选择左或右子树,路径不断折半缩小查找范围,因此二叉搜索树与二分查找的思想十分相似,查找效率非常高。 如图5-15所示依旧是一棵二叉搜索树,继续试着查找一下值为10的节点,方式与步骤与在图5-14的第三棵二叉搜索树中查找键值 8思路一致。  <p align="center"> <b>图5-15 二叉搜索树(寻找10)</b> </p> 查找值为10的节点的过程步骤如图5-16所示,再次印证二分查找的思想。总结的规律如下2点: (1)查找所需的最大次数等于二叉搜索树的深度。 (2)插入节点时,也利用类似的方法,一层层比较大小,找到新节点合适的位置。  <p align="center"> <b>图5-15 二叉搜索树(寻找10的过程)</b> </p> ### 5.4.2 二叉搜索树的封装 如果我们要封装二叉搜索树(本章的代码部分以二叉搜索树作为示例),我们像封装其他数据结构一样,先来封装一个BSTree类(Binary Search Tree的缩写)。那么二叉搜索树需要包含哪些东西呢?最少的情况下可以连根节点都没有(空树);而处于非空情况下,有一个根节点也就足够了,后续的左右子树都由根节点迭代而来。这很像是现实中的树一开始也是颗种子,我们有种子就足够了。 在封装BSTree类之前,我们还需要封装节点的类,一个存储数据的节点,和我们在实现链表时是类似的思路。 ```ts // types/Node文件 class Node<T> { value: T constructor(value: T) { this.value = value } } export default Node ``` 在基础的Node类上继承实现属于树的TreeNode类(用于构建二叉树结构的节点),即节点所应具备的左子节点和右子节点,且允许为空,在5.4.1小节开头说明了允许空树(没有任何节点)的2点考虑。 基于TreeNode类实现我们的BSTree类(二叉搜索树),也是允许为空。 ```ts import Node from "../types/Node" class TreeNode<T> extends Node<T> { left: TreeNode<T> | null = null right: TreeNode<T> | null = null } class BSTree<T> { private root: TreeNode<T> | null = null } export {} ``` ## 5.5 二叉搜索树操作 - 插入与遍历 那么二叉搜索树有哪些常见的操作呢?这决定了我们需要封装哪些二叉搜索树的方法。主要分为插入操作、查找操作、遍历操作以及删除操作。二叉搜索树常见操作如表5-2所示。 <p align="center"> <b>表5-2 二叉搜索树常见操作</b> </p> | 分类 | 操作 | 功能说明 | | -------- | ------------------ | ----------------------------------------------------------- | | 插入操作 | insert(value) | 向树中插入一个新的数据。 | | 查找操作 | search(value) | 在树中查找一个数据,如果节点存在返回 true,否则返回 false。 | | | min | 返回树中最小的值/数据。 | | | max | 返回树中最大的值/数据。 | | 遍历操作 | inOrderTraverse | 通过中序遍历方式遍历所有节点。 | | | preOrderTraverse | 通过先序遍历方式遍历所有节点。 | | | postOrderTraverse | 通过后序遍历方式遍历所有节点。 | | | levelOrderTraverse | 通过层序遍历方式遍历所有节点。 | | 删除操作 | remove(value) | 从树中移除某个数据(操作稍微复杂)。 | ### 5.5.1 插入操作实现 二叉搜索树的插入操作如何实现?二叉搜索树结构如图5-16所示。从root根节点开始插入数据,而后续插入数据就需要判断插入数据与当前节点数据的大小,从而决定是往左子树插入还是往右子树插入,插入之前还需要再次判断左右子树是否有值,无值则创建节点并插入数据,有值则需要重复上述大小判断,然后继续前往下一层左右子树。  <p align="center"> <b>图5-16 二叉搜索树结构</b> </p> 首先插入数据insert()方法接收一个value参数,该方法会将传入的数据插入到二叉搜索树中,但实际一开始并不是插入,因为一开始二叉搜索树是空树,没有根节点更没有其他节点可供我们插入,因此一开始需要先创建根节点然后在插入value。因此插入数据分两种情况: (1)第一次插入,直接修改根节点。 (2)其他插入,需要通过相关比较来决定插入位置。 根据以上插入数据思路分析,我们需要分两部分来完成插入功能: (1)insert()方法:初始化二叉树并决定插入的第一个数据。 (2)insertNode()方法:第一次插入之外的其余次插入数据到二叉搜索树的情况。 insert()方法思路为以下3步: (1)创建新节点(第一次创建为根节点)并传入数据。 (2)判断是否有根节点,无根节点则新节点作为根节点,并记录根节点属性已经存在。 (3)判断是否有根节点,有根节点则调用另一方法,传入需要插入的数据,由该方法决定插入的正确位置并插入。 ```ts /** 插入数据的操作 */ insert(value: T) { // 1.根据传入value创建Node(TreeNode)节点 const newNode = new TreeNode(value) // 2.判断当前是否已经有了根节点 if (!this.root) { // 当前树为空 this.root = newNode } else { // 树中已经有其他值。 最好调用insertNode()方法,防止逻辑耦合 } } ``` insert()方法主要创建了第一个根节点,并插入数据。但由于第一次插入之外的其他插入需要实现对二叉搜索树的比较逻辑,为了防止逻辑耦合(可读性更好)以及后续有可能复用比较逻辑,因此寻找插入位置最好另外实现成insertNode()方法,然后在insert()方法中的其他插入情境使用。 insertNode()方法需要是一个内部方法,它是其余方法的组成部分,是建立在一定条件上去使用的,而非独立功能的个体(残缺),所以不应该开放给用户使用,例如往空树调用insertNode()方法就会报错。 insertNode()方法思路为以下4步: (1)对传入数据与当前节点数据的大小进行比对。 (2)传入数据小于当前节点数据:判断当前节点的左子树是否有值,无值则将传入数据(newNode)插入左子树。 (3)传入数据大于当前节点数据:判断当前节点的右子树是否有值,无值则将传入数据(newNode)插入右子树。 (4)如果左右子树中相应方向已有节点,则说明当前位置不能插入,需要将该子节点作为新的“当前节点”,继续递归调用 insertNode()方法。随着递归深入,判断会沿着树向下推进,直到找到一个为空的位置并完成插入。 根据insertNode()方法思路,我们需要两个参数:node(当前节点),newNode(插入的节点)。插入的节点一直不变,但当前节点随着一层层寻找二叉搜索树的插入位置要不断变换,最终找到位置插入newNode。 ```ts private insertNode(node: TreeNode<T>, newNode: TreeNode<T>) { if (newNode.value < node.value) { // 去左边继续查找空白位置 if (node.left === null) { // node节点的左边已经是空白 node.left = newNode } else { this.insertNode(node.left, newNode) } } else { // 去右边继续查找空白位置 if (node.right === null) { node.right = newNode } else { this.insertNode(node.right, newNode) } } } ``` 为了能够打印出二叉搜索树的可视化效果(方便测试反馈),我们npm安装第三方库hy-algokit,使用其中的btPrint()方法,该方法需要一个节点,会以该节点为打印效果的根节点将后续内容以可视化形式打印出来。由于root是私立属性,只能在BSTree类的内部使用,所以要么我们去除root属性的private前缀,要么在BSTree类的内部再封装一个方法,我们选择后者,不会破坏原有立意。 ```ts import { btPrint } from 'hy-algokit' print() { btPrint(this.root) } ``` 完整代码如下(包含测试用例): ```ts import Node from "../types/Node" import { btPrint } from 'hy-algokit' class TreeNode<T> extends Node<T> { left: TreeNode<T> | null = null right: TreeNode<T> | null = null } class BSTree<T> { private root: TreeNode<T> | null = null print() { btPrint(this.root) } /** 插入数据的操作 */ insert(value: T) { // 1.根据传入value创建Node(TreeNode)节点 const newNode = new TreeNode(value) // 2.判断当前是否已经有了根节点 if (!this.root) { // 当前树为空 this.root = newNode } else { // 树中已经有其他值 this.insertNode(this.root, newNode) } } private insertNode(node: TreeNode<T>, newNode: TreeNode<T>) { if (newNode.value < node.value) { // 去左边继续查找空白位置 if (node.left === null) { // node节点的左边已经是空白 node.left = newNode } else { this.insertNode(node.left, newNode) } } else { // 去右边继续查找空白位置 if (node.right === null) { node.right = newNode } else { this.insertNode(node.right, newNode) } } } } const bst = new BSTree<number>() bst.insert(11) bst.insert(7) bst.insert(15) bst.insert(5) bst.insert(3) bst.insert(9) bst.insert(8) bst.insert(10) bst.insert(13) bst.insert(12) bst.insert(14) bst.insert(20) bst.insert(18) bst.insert(25) bst.insert(6) bst.print() // 打印二叉搜索树 export {} ``` 二叉搜索树插入效果打印如图5-17所示,我们后续的操作(例如先序遍历)都基于这次二叉搜索树的数据去实现。  <p align="center"> <b>图5-17 二叉搜索树插入效果打印</b> </p> 通过第三方库 hy-algokit,我们可以直接将二叉搜索树结构打印出来,但这只是利用现成工具。如果我们自己来实现二叉树的遍历,就需要思考——遍历一棵树到底意味着什么? 遍历树的含义很简单:访问树中的每一个节点。但是树与线性结构不同。在线性结构(如数组、链表)里,节点天然是一条线,我们只需要从头到尾顺序访问即可。而树的结构则呈分叉形态: - 从哪个方向开始? - 是先访问父节点还是子节点? - 遇到分支从左子树开始还是右子树开始? 正因为树结构具有分支、层级、方向等特点,因此在漫长的算法演化过程中,人们形成了多种遍历方式以适应不同需求。树的访问顺序由节点间的层级关系决定,而树中每个节点都最多有左右两个分支。于是根据 “父节点的访问时机”,发展出了三种经典的深度优先遍历(DFS)方式: (1)先序遍历(Pre-order):先访问节点,再访问子树。 (2)中序遍历(In-order):左子树 → 节点 → 右子树,是二叉搜索树中最常用的遍历(能得到有序结果)。 (3)后序遍历(Post-order):先访问子树,再访问节点。 除此之外,还有一种以“层级”为主导的遍历方式: (4)层序遍历(Level-order):按从上到下、从左到右的顺序访问每一层节点。 不同遍历方式的出现并不是随意命名,而是随着算法需求不断演化,4种遍历方式能够运用在所有二叉树上(包括了二叉搜索树),二叉树4种遍历方式对应的需求如表5-3所示。 <p align="center"> <b>表5-3 二叉树四种遍历方式对应的需求</b> </p> | 需求 | 最适合的遍历 | | ------------------------------ | ------------ | | 按功能顺序构建或拷贝树 | 先序遍历 | | 获取二叉搜索树的有序结果 | 中序遍历 | | 释放内存、删除节点(先删叶子) | 后序遍历 | | 层次结构输出、打印树结构 | 层序遍历 | 像第三方库 hy-algokit的btPrint()方法就是基于层序遍历实现的,只不过额外做了4点操作: (1)使用二维数组记录每一层的节点结果。 (2)计算打印宽度。 (3)为每层绘制节点之间的连接线(如 ┌ ┐ └ ┘ ┴ ─ 等)。 (4)最终把树形结构按层输出出来。 但想要可视化的画出一棵好看的树并不简单,层序遍历是实现的原理,但仅仅做到层序遍历是画不出树的,具体实现可去看btPrint()方法的源码。 ### 5.5.2 先序遍历(递归与非递归) 如果我们想实现一个先序遍历,分3步骤: (1)访问根节点。 (2)先序遍历其左子树。 (3)先序遍历其右子树。 二叉树的先序遍历顺序如图5-18所示。在这棵树中,我们先访问最顶端的节点A,然后进入它的左子树,从B开始继续重复同样的规则。访问完B后,再进入它的左孩子D。左子树全部走完之后,才返回去访问B的右子树,例如F以及F的左孩子E。当左子树全部遍历完后,再进入A的右子树,从C开始继续按照“根 → 左 → 右”的方式依次访问 G、H、I。最终形成的先序遍历结果就是:A B D F E C G H I。  <p align="center"> <b>图5-18 二叉树的先序遍历顺序</b> </p> 根据图中 5–18 的访问顺序,我们可以把先序遍历的执行过程细化为这样一种理解: 先序遍历的核心规则是 **“根 → 左 → 右”**。因此,我们首先访问根节点,然后进入左子树。接下来,把当前左子树的根节点继续当作新的根,再重复同样的过程——仍然优先进入它的左子树。这个过程会一路向左深入,直到某个节点已经没有左子树可走为止。此时才开始回溯,转而访问右子树;但一旦遇到新的左子树,又会优先进入新的左子树继续遍历。 也就是说,在先序遍历中:只要还有未访问的左子树,总是优先访问左子树,直到彻底遍历干净,再返回向右方向展开。 因此再回头看先序遍历步骤中的两句话就很容易理解: (1)先序遍历其左子树 —— 进入左子树后依然优先它的左子树。 (2)先序遍历其右子树 —— 进入右子树后依旧保持“能左就优先左”的规律。 接着来看图5-19所示的二叉搜索树的先序遍历顺序。依照先序遍历的规律,从根节点11开始访问,然后进入根节点11的左子树后依然优先它的左子树,即7、6、3。访问到叶子节点3之后(无左子树了)开始回溯到节点5,此时节点5已无未访问过的左子树,因此访问节点5的右子树6(叶子节点)。接着回溯到节点5再回溯到节点7,由于节点7的左子树部分已全部访问过,所以进入节点7的右子树中,从节点9开始访问,但进入右子树后依旧保持“能左就优先左”的规律,所以节点9访问结束后,需要检查节点9是否有未访问过的左子树(节点8),有则优先访问,后续访问遍历规律依照如上。 最终如图5-19所示的二叉搜索树的先序遍历结果为:11、7、5、3、6、9、8、10、15、13、12、14、20、18,25。  <p align="center"> <b>图5-19 二叉搜索树的先序遍历顺序</b> </p> 在代码中,如何实现二叉搜索树的先序遍历-preOrderTraverse()方法?首先我们传入二叉树的根节点,preOrderTraverse()方法第一时间就能拿到根节点并打印输出,因此可以先访问当前节点,然后递归访问左子树,左子树遍历完成后再递归访问右子树,整个顺序由递归自然维护,无需额外强调右子树内部的访问顺序。由于递归的“执行顺序”是由系统调用栈来维持的,每一次递归进入新节点,当前执行位置都会被压入栈中,所以执行“结束并返回”的顺序是相反的,从最深的叶子节点开始一层层往回走。因此我们需要在递归之前先把当前节点打印出来,如果把打印操作放在递归操作之后,打印结果的顺序会发生结构性的改变。也得益于递归操作会从最深的叶子节点一层层往回走,因此左子树结束往回的同时刚好可以调用右子树。 当然,由于先序遍历访问二叉搜索树是从特定根节点开始的,而外界是无法直接拿到根节点也不应该能直接拿到的,因此我们额外封装preOrderTraverseNode()私有方法(自由决定遍历节点这一需求不符合实际,需要满足先能自由拿到所需节点的前置条件,因此不交由使用者决定,将该方法设置为私有),将上述先序遍历逻辑放入该私有方法中,然后由preOrderTraverse()方法调用preOrderTraverseNode()私有方法并传入根节点。后续的中序遍历以及后续遍历也是依照此逻辑实现,将公有方法作为调用入口,私有方法作为实现核心。 ```ts // 先序遍历 preOrderTraverse() { this.preOrderTraverseNode(this.root) } private preOrderTraverseNode(node: TreeNode<T> | null) { if (node) { console.log(node.value) this.preOrderTraverseNode(node.left) this.preOrderTraverseNode(node.right) } } ``` ### 5.5.3 中序遍历(递归与非递归) 如果我们想实现一个中序遍历,分3步骤: (1)中序遍历其左子树。 (2)访问根节点。 (3)中序遍历其右子树。 二叉树的中序遍历遵循“左子树 → 根节点 → 右子树”的顺序。在图 5-20 所示的这棵树中,我们从根节点 A 开始,但并不首先访问 A,而是按照规则先进入它的左子树,来到节点 B。在 B 的左子树中继续深入,进入节点 D,并由于它没有左孩子,所以先访问 D,然后返回到 B,此时左子树已经遍历完,访问根节点 B。接着再进入 B 的右子树,访问节点 F,并继续按照相同规则访问 F 的左孩子 E。这样左子树访问彻底完成后,才返回根节点 A,访问 A,然后进入 A 的右子树,从 C 开始继续按照“左 → 根 → 右”的方式依次访问其子节点 G、H、I。最终形成的中序遍历结果就是:D B E F A G H C I。  <p align="center"> <b>图5-20 二叉树的中序遍历顺序</b> </p> 根据图中 5–20 的访问顺序,我们可以把中序遍历的执行过程细化为这样一种理解: 中序遍历的核心规则是 **“左 → 根 → 右”**。因此,我们首先进入当前节点的左子树,沿着左孩子不断向下深入,直到遇到没有左子树的节点为止。此时,访问该节点本身,然后再转向它的右子树。对于右子树,也同样遵循“能左就左”的规律,先访问右子树的左子树,再访问右子树的根,最后访问右子树的右子树。 也就是说,在中序遍历中:只要有未访问的左子树,总是先访问左子树;访问完左子树后,再访问当前节点;最后才访问右子树,并且在进入右子树后依然保持“先左后根再右”的规律。 因此再回头看中序遍历步骤中的两句话就很容易理解: (1)中序遍历其左子树 —— 进入左子树后依然优先它的左子树,直到无法再深入。 (2)访问根节点 —— 左子树访问完后,回到当前节点访问它本身。 (3)中序遍历其右子树 —— 进入右子树后依旧保持“能左就优先左”的规律,先左再根再右。 接着来看图5-19所示的二叉搜索树的中序遍历顺序。依照中序遍历的规律,从根节点11开始,先进入根节点11的左子树,对节点7重复“先左再根再右”的规则。节点7的左子树依次访问节点5、3、6,最终顺序为 3、5、6。访问完左子树后回到节点7,再访问节点7本身,然后进入节点7的右子树,依次访问节点9及其左子树节点8、右子树节点10,最终左子树部分的访问顺序为 3、5、6、7、8、9、10。回到根节点11访问它本身后,再进入右子树,按照同样规律访问节点15及其左右子树(13、12、14)、节点20及其左子树18、右子树25。 最终如图5-21所示的二叉搜索树的中序遍历结果为:3、5、6、7、8、9、10、11、12、13、14、15、18、20、25。  <p align="center"> <b>图5-21 二叉搜索树的中序遍历顺序</b> </p> 需要记住递归的核心逻辑,在5.5.2小节的先序遍历中,我们说明递归执行“结束并返回”的顺序是相反的,从最深的叶子节点开始一层层往回走。因此如果console.log(node.value)放在this.inOrderTraverseNode(node.left)之后,那么左子树递归会先到二叉树中的左子树最深处再开始往回执行,也就是返回到每一层时才执行打印节点值,打印操作执行于每一层返回的位置,所以打印顺序就变成了:最左 → 父节点 → 右节点。从而形成中序遍历。 ```ts // 中序遍历 inOrderTraverse() { this.inOrderTraverseNode(this.root) } private inOrderTraverseNode(node: TreeNode<T> | null) { if (node) { this.inOrderTraverseNode(node.left) console.log(node.value) this.inOrderTraverseNode(node.right) } } ``` ### 5.5.4 后序遍历(递归与非递归) 如果我们想实现一个后序遍历,分3步骤: (1)后序遍历其左子树。 (2)后序遍历其右子树。 (3)访问根节点。 二叉树的后序遍历遵循“左子树 → 右子树 → 根节点”的顺序。在图 5-22 所示的这棵树中,我们从根节点 A 开始,但并不首先访问 A,而是按照规则先进入它的左子树,来到节点 B。在 B 的左子树中继续深入,进入节点 D,由于它没有左孩子和右孩子,先完成对 D 的访问(D 自身就是叶子节点)。然后回到 B,进入 B 的右子树,访问节点 F,再进入 F 的左孩子 E 先访问 E,左、右子树都遍历完后,再访问 F。此时 B 的左右子树都已完成,最后访问根节点 B。 左子树遍历完毕后,回到根节点 A,再进入右子树,从 C 开始,先进入 C 的左子树 G、访问G的右子树 H,而后返回访问G,接着是C的右子树I,左右子树都遍历完后,才访问根节点 C。 最终按照后序遍历规律,整个二叉树的访问顺序为:D E F B H G I C A。  <p align="center"> <b>图5-22 二叉树的后序遍历顺序</b> </p> 后序遍历的核心规则是 **“左 → 右 → 根”**。因此,我们首先进入当前节点的左子树,沿着左孩子不断向下深入,直到遇到没有左子树的节点为止。此时,开始检查右子树,如果存在右子树,则先遍历右子树,右子树也遵循相同规律:先左后右再根。左、右子树都遍历完成后,最后才访问当前节点本身。 也就是说,在后序遍历中:只要有未访问的左子树,总是先访问左子树;左子树访问完后,再访问右子树;当左右子树都访问完后,才访问当前节点。每进入一个新子树,都严格遵循“先左、再右、最后根”的规律。 因此再回头看后序遍历步骤中的三句话就很容易理解: (1)后序遍历其左子树 —— 进入左子树后依然优先它的左子树,直到无法再深入。 (2)后序遍历其右子树 —— 左子树完成后进入右子树,仍然先左再右再根。 (3)访问根节点 —— 左右子树都访问完后,才访问当前节点本身。 接着来看图5-19所示的二叉搜索树的后序遍历顺序。依照后序遍历的规律,从根节点11开始,先进入根节点11的左子树,对节点7重复“先左、再右、最后根”的规则。节点7的左子树依次访问节点5及其左孩子3、右孩子6,访问顺序为 3、6、5;接着访问节点7的右子树,先访问节点9及其左子树8、右子树10,访问顺序为 8、10、9;左右子树遍历完毕后,访问节点7本身,左子树整体访问顺序为 3、6、5、8、10、9、7。 回到根节点11,再进入右子树,按照同样规律访问节点15及其左右子树(12、14、13)、节点20及其左子树18、右子树25,右子树整体访问顺序为 12、14、13、18、25、20、15。左右子树访问完毕后,最后访问根节点11本身。 最终如图5-21所示的二叉搜索树的后序遍历结果为:3、6、5、8、10、9、7、12、14、13、18、25、20、15、11。  <p align="center"> <b>图5-23 二叉搜索树的后序遍历顺序</b> </p> 后序遍历的思路与前序、中序遍历的思路保持一致,只是递归打印位置发生变化。 ```ts // 后序遍历 postOrderTraverse() { this.postOrderTraverseNode(this.root) } private postOrderTraverseNode(node: TreeNode<T> | null) { if (node) { this.postOrderTraverseNode(node.left) // 先访问左子树 this.postOrderTraverseNode(node.right) // 再访问右子树 console.log(node.value) // 最后访问根节点 } } ``` 所以先序/中序/后序的区别只在于访问根节点(root)的时机:先访问根节点(先序);中间访问根节点(中序);最后访问根节点(后序)。规则总结如下3点: (1)先序:根、左、右。 (2)中序:左、根、右。 (3)后序:左、右、根。 ### 5.5.5 层序遍历实现 层序遍历很好理解,就是从上往下逐层遍历,每层从左往右的访问,因此层序遍历可以利用队列的先进先出特性来完成。实现步骤为以下3步: (1)创建一个队列,并把根节点入队。 (2)以根节点为开端,开始逐层遍历。 (3)每次遍历取出队列的头部节点并访问,并且判断该节点是否有左右节点,有则入队,无则跳过,循环遍历到队列为空。 层序遍历从根节点开始,按照“自上而下、从左到右”的顺序依次访问节点。每访问一个节点时,就把它的左、右子节点依次加入队列。由于每次访问都伴随“当前节点出队、子节点入队”,队列中元素数量会随着遍历层数缓慢增加或保持稳定;当遍历到叶子节点(没有子节点)时,队列开始只出不进,长度逐渐减少,最终变为空,遍历结束。 只要能够以层序遍历的规则访问到节点,是打印还是存储后用于其他操作都是非常方便的。 在实现过程中,做边界判断:检查是否有根节点,无根节点则直接返回,无需遍历。并且由于完美二叉树的情况是较为少见的,所以在逐层遍历的过程中,是有可能访问不到值的,因此需要对节点进行判断是否有值,有值才push到队列之中。 ```ts // 层序遍历 levelOrderTraverse() { // 1.如果没有根节点, 那么不需要遍历 if (!this.root) return // 2.创建队列结构 const queue: TreeNode<T>[] = [] // 第一个节点时根节点 queue.push(this.root) // 3.遍历队列中所有的节点(依次出队) while (queue.length) { // 3.1.访问节点的过程 const current = queue.shift()! console.log(current.value) // 3.2.将左子节点放入到队列 if (current.left) { queue.push(current.left) } // 3.3.将右子节点放入到队列 if (current.right) { queue.push(current.right) } } } ``` ## 5.6 二叉搜索树操作 - 查找与删除 ### 5.6.1 最值查找方法 最值的查找有两种:最大值与最小值。在二叉搜索树中搜索最值是一件非常简单的事情,最值寻找如图5-24所示。对于二叉搜索树来说,最小值就是最左侧的叶子节点,最大值就是最右侧的叶子节点。  <p align="center"> <b>图5-24 二叉搜索树的最值寻找</b> </p> 如果让我们来实现这两个方法,两个方法的逻辑必然是类似的。求最小值主要为以下2步: (1)访问根节点。 (2)从根节点开始递归访问左子节点,直到左子节点为空之后,返回当前节点。 ```ts getMaxValue(node = this.root): T | null { return node?.right ? this.getMaxValue(node.right) : node?.value ?? null } getMinValue(node = this.root): T | null { return node?.left ? this.getMinValue(node.left) : node?.value ?? null } ``` 当然,我们这里不一定要使用递归,使用循环也是可以的,循环的终止条件分别为左右子节点为null。 ```ts /** 获取最值操作: 最大值/最小值 */ getMaxValue(): T | null { let current = this.root while (current && current.right) { current = current.right } return current?.value ?? null } getMinValue(): T | null { let current = this.root while (current && current.left) { current = current.left } return current?.value ?? null } ``` ### 5.6.2 特定值搜索(递归与非递归) 二叉搜索树不仅仅获取最值效率非常高,搜索特定的值效率也非常高。特点值的搜索是传入数值与二叉搜索树节点的数值不断比对的过程,传入数值更大,则进入当前节点的右子节点继续比对,反之则进入当前节点的左子节点比对,比对上之后返回结果,比对到叶子节点还未搜索到特定值则返回false来表明未找到。 因此特点值的搜索分3步骤: (1)判断拿到的节点是否是搜索的节点,如果是直接返回true。 (2)拿到的节点比搜索的节点小,进入当前节点的右子树;拿到的节点比搜索的节点大,进入当前节点的左子树。 (3)逐层循环对比,直到找到结果或者没有结果返回false。 ```ts search(value: T): boolean { let current = this.root while (current) { // 找到了节点 if (current.value === value) return true if (current.value < value) { current = current.right } else { current = current.left } } return false } ``` 接下来是递归的写法,递归必须有退出条件,我们这里是两种情况下退出: (1)node === null,也就是后面不再有节点的时候。 (2)找到对应的value,也就是node.value === value的时候。 在其他情况下,根据node.的value和传入的value进行比较来决定向左还是向右查找。如果node.value > value,那么说明传入的值更小,需要向左查找。如果node.value < value,那么说明传入的值更大,需要向右查找。 ```ts // 搜索特定的值 search(value: T): boolean { return this.searchNode(this.root, value) } private searchNode(node: Node<T> | null, value: T): boolean { // 1. 如果节点为 null,直接退出递归 if (node === null) return false // 2. 判断节点值和传入 value 的大小 if (node.value > value) { // 在左边继续查 return this.searchNode(node.left, value) } else if (node.value < value) { // 在右边继续查找 return this.searchNode(node.right, value) } else { return true } } ``` ### 5.6.3 删除操作 - 无子节点情况 二叉搜索树的删除有些复杂,我们一点点完成。删除节点要从查找要删的节点开始,找到节点后,需要考虑3种情况: (1)该节点是叶子节点(没有子节点,比较简单)。 (2)该节点有一个子节点(相对简单)。 (3)该节点有两个子节点(情况比较复杂)。 除此之外,还有一些边界情况的判断,例如要删除的节点并不在二叉搜索树中,那么就不需要我们去执行实际操作。如图5-25所示,我如果想要删除以下3种不同情况的节点(对应3种需要考虑的情况),所需要做出的准备与难度也是不同的: (1)删除节点3。通过特定值搜索到节点3,将其置为null。 (2)删除节点5。通过特定值搜索到节点5,发现节点5存在一个子节点,需要将节点7的左子节点置为节点3,从而删除节点5。 (3)删除节点9。通过特定值搜索到节点9,发现节点9存在两个子节点,这是需要更复杂的判断操作,我们放在5.6.5小节中进行学习。  <p align="center"> <b>图5-25 二叉搜索树的删除示例</b> </p> 现在,我们来完成删除无子节点(叶子节点)的情况,与特定值搜索一致的逻辑,然后将值置为null即可。通过remove()方法实现删除操作,接收需要删除的节点值,返回布尔值来表达删除情况。这个删除操作的模板我们可以用在三种不同的删除情况处理中,最后将三种情况合并处理。 ```ts /** 实现删除操作 */ remove(value: T): boolean { } ``` remove()删除方法需要以下2个步骤: (1)搜索是否有要删除的值,没有就直接返回false。 (2)有要删除的值,将叶子节点置为null。 可惜5.6.2小节的search()方法无法复用,因为search()方法返回的是布尔值,并不能让我们拿到对应的节点。不过这部分找到对应节点的代码开始重复了,我们后续可以做出优化。 ```ts /** 实现删除操作 */ remove(value: T): boolean { // 判断需要删除节点是否在二叉搜索树中,若不在直接返回 if(!this.search(value)) return false let current = this.root while (current) { // 找到了叶子节点 将其置为null if (current.value === value) current.value == null if (current.value < value) { current = current.right } else { current = current.left } } return true } ``` 那么以上的做法真的可以吗?当然是不行的,虽然传入需要删除的叶子节点值确实能删除,边界判断也处理了,但存在两个非常关键的问题: (1)删除操作应该将目标的叶子节点置为null,我们以上代码置为null的是叶子节点值,而非叶子节点本身。只将叶子节点值置为null虽然简单,但该节点依然存在树中,只是值没了,之后的遍历、搜索、插入都可能出现混乱。我们真正要做的是找到目标节点的父节点,把父节点对应的子节点置为null。因此我们还需要一个属性来存储需要删除目标子节点的父节点。 (2)我们怎么把判断叶子节点的事情交给使用者了?如果使用者不知道自己要删除的是叶子节点呢,那这份代码就会出现问题。在这里我们犯了一个先入为主,认为使用者知道自己删除的节点是哪一种情况,然后会调用对应情况的方法。实际中当然不可以这么做,我们应该在封装删除方法的过程中就做好对应判断。 好的,所以我们先来解决第一个问题,拿到需要删除目标子节点的同时,拿到对应的父节点。 ```ts /** 实现删除操作 */ remove(value: T): boolean { // 1.搜索: 当前是否有这个value let current = this.root let parent: TreeNode<T> | null = null while (current) { if (current.value === value) break // 拿到需要删除目标子节点的对应父节点 parent = current if (current.value < value) { current = current.right } else { current = current.left } } console.log(current?.value, parent?.value) return true } ``` 接着我们优化抽取一下搜索节点的代码逻辑,实现一个searchNode()私有方法,该方法接收一个value值,返回搜索到的节点本身,同时允许返回null(没搜索到目标节点)。 ```ts private searchNode(value: T): TreeNode<T> | null { let current = this.root while (current) { // 1.如果找到current, 直接返回即可 if (current.value === value) { return current } // 2.继续向下找 if (current.value < value) { current = current.right } else { current = current.left } } return null } ``` 然后验证一下重构之后的search()方法是否可以正常调用。 ```ts /** 搜索特定的值: 20 => boolean */ search(value: T): boolean { return !!this.searchNode(value) } ``` 接着继续来实现我们的remove()方法,通过searchNode()私有方法能够获取到目标节点,但目标节点的父节点呢?要如何获取? 有没有一种可能,我们可以让searchNode()私有方法返回一个元组,就类似于React Hook中的useState: - const [目标节点, 父节点] = searchNode(value) 确实可以这么做,但其实我们可以有更好的方法,目前的节点有left、right和value三个属性,我们再添加一个parent父节点属性,在获取到目标节点的同时,可通过访问该节点的parent属性拿到对应父节点。因为节点其实是一个静态且独立自制的,所以我们可以采用这种形式去获取。 那么,我们在返回current的上一次循环中,将current节点的父节点保存在parent属性中,但parent属性是有可能为null的,有且只有根节点的parent属性为null,因此定义parent属性时需要联合类型一下。 ```ts class TreeNode<T> extends Node<T> { left: TreeNode<T> | null = null right: TreeNode<T> | null = null // 当前节点的父节点 parent: TreeNode<T> | null = null } private searchNode(value: T): TreeNode<T> | null { let current = this.root let parent: TreeNode<T> | null = null while (current) { // 1.如果找到current, 直接返回即可 if (current.value === value) { return current } // 2.继续向下找 parent = current if (current.value < value) { current = current.right } else { current = current.left } // 如果current有值, 那么current保存自己的父节点 if (current) current.parent = parent } return null } ``` 那么在remove()删除方法中,就可以采用current.parent?.value的形式拿到目标节点的父节点。 ```ts /** 实现删除操作 */ remove(value: T): boolean { // 1.搜索: 当前是否有这个value const current = this.searchNode(value) if (!current) return false // 2.获取到三个东西: 当前节点/父节点/是属于父节点的左子节点, 还是右子节点 console.log("当前节点:", current.value, "父节点:", current.parent?.value) return true } ``` 接着我们应该来解决第二个问题:判断用户传入的节点存在后,要继续判断是否为叶子节点。当目标节点为叶子节点,才判断父节点的左右子节点与目标节点是否相同,从而判断目标节点位于父节点的哪一边,从而将对应左右目标子节点置为null。 ```ts if(current.parent?.left === current) { // current在父节点的左边 } else { // current在父节点的右边 } ``` 但判断删除的节点在父节点哪边的这一操作是很有可能复用的,在接下来的单子节点情况以及双子节点情况的两种处理中都很有可能会用到这一判断,那么为了防止代码的多次重复,我们就可以提前将这一判断操作封装成一个私有方法。为了方便,我们可以使用get语法将对象属性绑定到查询该属性时将被调用的函数方法。 无论是判断左子节点的isLeft()私有方法还是判断右子节点的isRight()私有方法都可以达成我们的目的,选择其中一个私有方法就可以。判断左右子节点也并非只有一种方法,如果是为了更好理解,则可以传入当前节点与父节点两个参数,返回布尔值或者其他格式信息(left or right)作为判断标准。这种传入两个参数做法的可复用性更强,但我们这里的实际需求并不需要做到该程度。因此直接固定死对应部分的判断,无需传入参数,直接返回布尔值结果。 ```ts // 判断当前节点的父节点是否拥有左子节点 get isLeft(): boolean { return !!(this.parent && this.parent.left === this) } // 判断当前节点的父节点是否拥有右子节点 get isRight(): boolean { return !!(this.parent && this.parent.right === this) } ``` 接着完成无子节点情况的具体删除以及对叶子节点的判断(待解决的第二个问题)。在删除无子节点的叶子节点情况中,有一种情况,即除了根节点之外的所有节点都删完了,那么根节点同时也是叶子节点,因此我们需要在判断叶子节点的基础上额外判断当前节点是否为根节点,若为根节点则直接将二叉树的root属性(根节点)置为null就可以。 ```ts /** 实现删除操作 */ remove(value: T): boolean { // 1.搜索: 当前是否有这个value const current = this.searchNode(value) if (!current) return false // 2.获取到三个东西: 当前节点/父节点/是属于父节点的左子节点, 还是右子节点 // 2.判断删除的是否为叶子节点 if (current.left === null && current.right === null) { if (current === this.root) { // 根节点 this.root = null } else if (current.isLeft) { // 父节点的左子节点 current.parent!.left = null } else { current.parent!.right = null } } return true } ``` ### 5.6.4 删除操作 - 单子节点情况 那么单子节点的情况就是获取目标节点的单子节点,然后直接覆盖掉目标节点就可以了,如图5-26所示就是"删除"节点15,该节点存在一个单子节点13(该单子节点未必是叶节点,但不影响代码书写),我们需要做的是判断目标节点是否为单子节点。  <p align="center"> <b>图5-26 删除操作 - 单子节点情况</b> </p> 那么,判断目标节点是否为单子节点直接判断当前节点的左右节点是否只有为null,,而判断目标节点位于父节点的左右节点则可以使用isLeft()和isRight()私有方法。处理单子节点分两种情况: (1)处理左子节点:将当前节点的左子节点赋值给当前节点的父节点的对应节点。 (2)处理右子节点:将当前节点的右子节点赋值给当前节点的父节点的对应节点。 所以这里其实有4种情况,即当前节点处于父节点的两种情况以及当前节点的左右子节点两种情况。我们的判断逻辑接续在判断无子节点情况的后面。往后判断双子节点的情况也会接续在判断单子节点的情况下。 ```ts // 3.只有一个子节点: 只有左子节点 else if (current.right === null) { if (current === this.root) { // 根节点情况 this.root = current.left } else if (current.isLeft) { // 目标节点是父节点的左子节点 current.parent!.left = current.left // 将目标节点的左子节点赋值给父节点的左子节点 } else { current.parent!.right = current.left // 将目标节点的左子节点赋值给父节点的右子节点 } } // 4.只有一个子节点: 只有右子节点 else if (current.left === null) { if (current === this.root) { // 根节点情况 this.root = current.right } else if (current.isLeft) { // 同上 current.parent!.left = current.right // 同上 } else { current.parent!.right = current.right // 同上 } } ``` ### 5.6.5 删除操作 - 双子节点情况 删除操作需要考虑的最后一种情况是删除的目标节点同时具备左右子节点,那么我们就不能简单的将某一个目标节点的子节点覆盖掉目标节点。以图5-27所示的二叉搜索树为例,考虑以下3种情况如何处理: (1)删除节点9。将节点8替换到节点9的位置或者将节点10替换到节点9的位置。在替换的过程中,例如节点8->节点9时,节点7需要指向节点8,节点8需要指向节点10。 (2)删除节点7。分两种情况,方式一:将节点5替换节点7,节点3依然被节点5指向,但是5有一个right需要指向节点9,这样依旧保持二叉搜索树;方式二:在目标节点7的右侧找节点8,将节点8替换到节点7的位置,节点8的left指向节点5,right指向节点9。 (3)删除节点15。如果像删除节点7的方式二一样从目标节点的右子树中寻找,我们能找到的是节点18,将节点18替换节点15,节点20的left指向节点19,这样也是一棵二叉搜索树。 情况看起来很多变,我们要找出其中的规律。  <p align="center"> <b>图5-27 删除操作 - 双子节点情况</b> </p> 规律是这样的,假如我们要删除一个目标节点,而目标节点有两个子节点,我们有两种方式: (1)到目标节点的左子树中找一个比目标节点小,同时是:目标节点左子树中的最大节点(目标节点左子树的最大值)。 (2)到目标节点的右子树中找一个比目标节点大,同时是:目标节点右子树中的最小节点(目标节点右子树的最小值)。 如果我们要删除的目标节点有两个子节点,甚至子节点还有子节点,这种情况下我们需要从目标节点下面的子节点中找到一个节点,来替换当前的节点。但是找到的这个节点有什么特征呢? 应该是current节点下面所有节点中最接近current节点的。要么比current节点小一点点,要么比current节点大一点点。最接近current值的子节点,就可以用来替换current的位置。 在二叉搜索树中,我们所想要寻找的目标节点左右子树两侧的那特别节点,有特别的名字:比目标节点小一点点的节点,称为目标节点的前驱;比目标节点大一点点的节点,称为目标节点的后继。也就是为了能够删除有两个子节点的目标节点,要么找到它的前驱,要么找到它的后继。所以接下来,我们要先找到这样的节点。PS:以下代码采用后继节点的方式。 由于采用获取后继节点的方式,因此需要去目标节点的右子树中寻找最小值。获取最值的问题是很简单的,我们只要以需要删除的目标节点为根节点,寻找其左子树中最左侧的节点。由于getMinValue()方法的节点是固定死当前二叉搜索树的根节点,因此我们可能需要重新实现,或者可以再封装一个私有的方法,然后复用。根据自己的想法决定,这里就不进一步抽象了。 我们删除目标节点存在双子节点的情况需要以下4个步骤: (1)找后继节点(右子树最左节点)。 (2)若后继不是右子节点,先把它从原位置挪出来。 PS:后继节点是右子树的最左节点,所以它一定没有左子节点,但它可能有右子节点,并不是始终没有右子节点。 (3)将目标节点的左子树挂到后继节点上。 (4)用后继节点替换目标节点的位置。 我们先实现getSuccessor()私有方法,该方法传入我们想删除的目标节点,返回后继节点。在该私有方法中同时需要在找到后继节点之后,需要做两件事情: (1)后继节点的右子树顶替后继节点的位置(把后继节点从原位置挪出来)。 (2)后继节点的左侧(左子节点)需要接收将要被删除目标节点的左侧(左节点),后继节点的右侧(右子节点)需要接收将要被删除目标节点的右侧(右节点)。 其实这两件事情还是很好理解的,后继节点替代了目标节点,那么后继节点既要处理自己可能存在的右子树,还要继承被替代的目标节点的左右子树。但有一种情况,即后继节点刚好是目标节点的右子节点,那么后续节点替换目标节点之后,就不需要处理自己可能存在的右子树了,因为此时后继节点的右子树就刚好可以继续作为替换目标节点之后的右子树,只需要接管原有目标节点的左子树。针对该情况,我们需要做出以下2点处理: (1)若后继节点不是目标节点的右子节点:要将后继节点的右子树顶替其原位置,并且后继节点接管目标节点的右子树。反之则不用。 (2)后继节点必须接管目标节点的左子树。 ```ts private getSuccessor(delNode: TreeNode<T>): TreeNode<T> { // 获取右子树 let current = delNode.right // 后继节点 let successor: TreeNode<T> | null = null // 获取后继节点,可以考虑和最值方法抽取共性代码来复用 while (current) { successor = current current = current.left // 获取后继节点的父节点 if (current) { current.parent = successor } } // 若后继节点不是目标节点的右子节点 if (successor !== delNode.right) { successor!.parent!.left = successor!.right // 将后继节点的右子树顶替其原位置(需要拿到后继节点的父节点才能真正替换调后继节点)。 successor!.right = delNode.right // 后继节点接管目标节点的右子树。 } // 后继节点必须接管目标节点的左子树。 successor!.left = delNode.left return successor! } ``` 通过getSuccessor()私有方法实现了以下3点目的: (1)获取后继节点。 (2)后继节点继承被删除目标节点的左右子树。 (3)令后继节点可能存在的右子树继承后继节点原有位置,将后继节点腾出来。 接下来我们需要继续完成删除操作的最后一种情况,双子节点情况。获取了getSuccessor()私有方法返回的后继节点,我们需要使用后继节点替换要删除的目标节点在整棵树中的位置,即令原目标节点的父结点指向后继节点。这里有3种情况,分别处理: (1)原目标节点的父节点是根节点。 (2)原目标节点在父节点的左节点。 (3)原目标节点在父节点的右节点。 ```ts else { const successor = this.getSuccessor(current) if (current === this.root) { this.root = successor } else if (current.isLeft) { current.parent!.left = successor } else { current.parent!.right = successor } } ``` ### 5.6.6 优化重构代码 通过5.6.3、5.6.4和5.6.5小节,我们完成了删除目标节点的整体功能,但在判断删除的是否是叶子节点、只有一个子节点(只有左子节点,只有右子节点)以及由双子节点的时候,判断逻辑是不断重复的。 ```ts // 判断叶子节点 current.left === null && current.right === null // 判断只有左子节点 current.right === null // 判断只有右子节点 current.left === null ``` 以及内部的3种边界情况判断:原目标节点的父节点是根节点、原目标节点在父节点的左节点,原目标节点在父节点的右节点。 ```ts if (current === this.root) { // 原目标节点的父节点是根节点 this.root = } else if (current.isLeft) { // 原目标节点在父节点的左节点 current.parent!.left = } else { // 原目标节点在父节点的右节点 current.parent!.right = } ``` 那么,能否将以上的判断部分抽取出来,倘若可以抽取,又要如何抽取?在删除节点时,不管它是叶子节点、单子节点、双子节点,最终的目的都是: 确定一个用来替换它的新节点(replaceNode),然后把它挂回去。 所以remove()删除方法逻辑可以统一成两步: (1)找到用来替换的位置节点replaceNode。 (2)把replaceNode挂到父节点的对应位置。 ```ts if (current === this.root) { this.root = replaceNode } else if (current.isLeft) { current.parent!.left = replaceNode } else { current.parent!.right = replaceNode } ``` 重构之前,我们的三段长逻辑分别处理叶子、单左和单右节点,每段都要写一遍是不是root,是不是左子节点,是不是右子节点。 ```ts // 重构前的代码 remove(value: T): boolean { // 1.搜索: 当前是否有这个value const current = this.searchNode(value) if (!current) return false // 2.获取到三个东西: 当前节点/父节点/是属于父节点的左子节点, 还是右子节点 // 2.如果删除的是叶子节点 if (current.left === null && current.right === null) { if (current === this.root) { // 根节点 this.root = null } else if (current.isLeft) { // 父节点的左子节点 current.parent!.left = null } else { current.parent!.right = null } } // 3.只有一个子节点: 只有左子节点 else if (current.right === null) { if (current === this.root) { this.root = current.left } else if (current.isLeft) { current.parent!.left = current.left } else { current.parent!.right = current.left } } // 4.只有一个子节点: 只有右子节点 else if (current.left === null) { if (current === this.root) { this.root = current.right } else if (current.isLeft) { current.parent!.left = current.right } else { current.parent!.right = current.right } } // 5.有两个子节点 else { const successor = this.getSuccessor(current) if (current === this.root) { this.root = successor } else if (current.isLeft) { current.parent!.left = successor } else { current.parent!.right = successor } } return true } ``` 核心思想是:删除一个节点时,不再把叶子节点、只有一个子节点、或者有两个子节点分别写成三套不同的处理流程,而是统一成一个动作——先判断“谁来替代被删除的节点”,然后再把这个替代者挂回到原来的位置。这相当于把复杂流程拆成两个简单的步骤,因此代码自然就清晰、简洁、可维护性更高。 ```ts // 重构后的代码 remove(value: T): boolean { // 1.搜索: 当前是否有这个value const current = this.searchNode(value) if (!current) return false // 2.获取到三个东西: 当前节点/父节点/是属于父节点的左子节点, 还是右子节点 let replaceNode: TreeNode<T> | null = null if (current.left === null && current.right === null) { replaceNode = null } else if (current.right === null) { replaceNode = current.left } else if (current.left === null) { replaceNode = current.right } else { const successor = this.getSuccessor(current) replaceNode = successor } // 由replaceNode统一管理不同情况所获取到的节点。 if (current === this.root) { this.root = replaceNode } else if (current.isLeft) { current.parent!.left = replaceNode } else { current.parent!.right = replaceNode } return true } ``` 但是为什么可以统一成一个replaceNode?因为无论删除的是哪种情况,BST删除的本质永远一致:父节点“失去”这个节点,然后必须要有一个新的节点来填补这个空位。不同的只是“哪个节点来替代它”。所以可以把所有情况浓缩为四种选择——没有节点(null)、左孩子、右孩子、或者后继节点(successor)。把这四种用一个变量 replaceNode 表示,就能把不同分支合并成统一结构,减少重复逻辑。 当我们把“不同情况决定用哪个节点替代”这一部分抽离出来后,“如何把替代节点挂接到父节点”这段逻辑就完全可以写成统一三步:若被删除的是根节点,则替换根;若被删除的是父节点的左子,则把父节点的左子改成新节点;否则改成父节点的右子。这三种情况本来在原代码中被重复写了多次,现在只需要写一次,代码自然变得短、更不容易出错、维护成本更低。 它的原理就是“分离关注点”(Separation of Concerns)。在重构后,删除操作被拆成两个相互独立的小问题: (1)用哪个节点来替换当前节点(逻辑条件判断)。 (2)把替代者挂到父节点的正确位置(固定模板逻辑)。 ### 5.6.7 删除操作总结 看到这里,你就会发现删除节点相当棘手。实际上,因为它非常复杂,一些程序员都尝试着避开删除操作。他们的做法是在Node类中添加一个boolean的字段,例如名称为isDeleted。要删除一个节点时,就将此字段设置为true。其他操作,例如find()在查找之前先判断这个节点是不是标记为删除。这样相对比较简单,每次删除节点不会改变原有的树结构,但是在二叉树的存储中,还保留着那些本该已经被删除掉的节点。这种做法在5.6.3小节的开头就尝试过类似的,并且说明了其中的缺陷与弊端。 那种做法看起来很聪明,其实是一种逃避。这样会造成很大空间的浪费,特别是针对数据量较大的情况。而且,作为程序员要学会通过这些复杂的操作,锻炼自己的逻辑。 最后,我们将最终实现版本的二叉搜索树操作代码示例放在下方。 ```ts import Node from "../types/Node" import { btPrint } from 'hy-algokit' class TreeNode<T> extends Node<T> { left: TreeNode<T> | null = null right: TreeNode<T> | null = null // 当前节点的父节点 parent: TreeNode<T> | null = null // 判断当前节点是父节点的左子节点 get isLeft(): boolean { return !!(this.parent && this.parent.left === this) } // 判断当前节点是父节点的右子节点 get isRight(): boolean { return !!(this.parent && this.parent.right === this) } } class BSTree<T> { private root: TreeNode<T> | null = null print() { btPrint(this.root) } private searchNode(value: T): TreeNode<T> | null { let current = this.root let parent: TreeNode<T> | null = null while (current) { // 1.如果找到current, 直接返回即可 if (current.value === value) { return current } // 2.继续向下找 parent = current if (current.value < value) { current = current.right } else { current = current.left } // 如果current有值, 那么current保存自己的父节点 if (current) current.parent = parent } return null } /** 插入数据的操作 */ insert(value: T) { // 1.根据传入value创建Node(TreeNode)节点 const newNode = new TreeNode(value) // 2.判断当前是否已经有了根节点 if (!this.root) { // 当前树为空 this.root = newNode } else { // 树中已经有其他值 this.insertNode(this.root, newNode) } } private insertNode(node: TreeNode<T>, newNode: TreeNode<T>) { if (newNode.value < node.value) { // 去左边继续查找空白位置 if (node.left === null) { // node节点的左边已经是空白 node.left = newNode } else { this.insertNode(node.left, newNode) } } else { // 去右边继续查找空白位置 if (node.right === null) { node.right = newNode } else { this.insertNode(node.right, newNode) } } } /** 遍历的操作 */ // 先序遍历 preOrderTraverse() { this.preOrderTraverseNode(this.root) } private preOrderTraverseNode(node: TreeNode<T> | null) { if (node) { console.log(node.value) this.preOrderTraverseNode(node.left) this.preOrderTraverseNode(node.right) } } // 中序遍历 inOrderTraverse() { this.inOrderTraverseNode(this.root) } private inOrderTraverseNode(node: TreeNode<T> | null) { if (node) { this.inOrderTraverseNode(node.left) console.log(node.value) this.inOrderTraverseNode(node.right) } } // 后序遍历 postOrderTraverse() { this.postOrderTraverseNode(this.root) } private postOrderTraverseNode(node: TreeNode<T> | null) { if (node) { this.postOrderTraverseNode(node.left) this.postOrderTraverseNode(node.right) console.log(node.value) } } // 层序遍历 levelOrderTraverse() { // 1.如果没有根节点, 那么不需要遍历 if (!this.root) return // 2.创建队列结构 const queue: TreeNode<T>[] = [] // 第一个节点时根节点 queue.push(this.root) // 3.遍历队列中所有的节点(依次出队) while (queue.length) { // 3.1.访问节点的过程 const current = queue.shift()! console.log(current.value) // 3.2.将左子节点放入到队列 if (current.left) { queue.push(current.left) } // 3.3.将右子节点放入到队列 if (current.right) { queue.push(current.right) } } } /** 获取最值操作: 最大值/最小值 */ getMaxValue(): T | null { let current = this.root while (current && current.right) { current = current.right } return current?.value ?? null } getMinValue(): T | null { let current = this.root while (current && current.left) { current = current.left } return current?.value ?? null } /** 搜索特定的值: 20 => boolean */ search(value: T): boolean { return !!this.searchNode(value) } /** 实现删除操作 */ private getSuccessor(delNode: TreeNode<T>): TreeNode<T> { // 获取右子树 let current = delNode.right let successor: TreeNode<T> | null = null while (current) { successor = current current = current.left if (current) { current.parent = successor } } // 拿到了后继节点 if (successor !== delNode.right) { successor!.parent!.left = successor!.right successor!.right = delNode.right } // 一定要进行的操作: 将删除节点的left, 赋值给后继节点的left successor!.left = delNode.left return successor! } remove(value: T): boolean { // 1.搜索: 当前是否有这个value const current = this.searchNode(value) if (!current) return false // 2.获取到三个东西: 当前节点/父节点/是属于父节点的左子节点, 还是右子节点 let replaceNode: TreeNode<T> | null = null if (current.left === null && current.right === null) { replaceNode = null } else if (current.right === null) { replaceNode = current.left } else if (current.left === null) { replaceNode = current.right } else { const successor = this.getSuccessor(current) replaceNode = successor } if (current === this.root) { this.root = replaceNode } else if (current.isLeft) { current.parent!.left = replaceNode } else { current.parent!.right = replaceNode } return true } } const bst = new BSTree<number>() bst.insert(11) bst.insert(7) bst.insert(15) bst.insert(5) bst.insert(3) bst.insert(9) bst.insert(8) bst.insert(10) bst.insert(13) bst.insert(12) bst.insert(14) bst.insert(20) bst.insert(18) bst.insert(25) bst.insert(6) bst.print() // bst.preOrderTraverse() // bst.inOrderTraverse() // bst.postOrderTraverse() // bst.levelOrderTraverse() // console.log(bst.getMaxValue()) // console.log(bst.getMinValue()) // console.log(bst.search(20)) // console.log(bst.search(18)) // console.log(bst.search(6)) // console.log(bst.search(30)) // 删除功能: 删除有两个子节点的情况 bst.remove(11) bst.print() bst.remove(15) bst.print() bst.remove(9) bst.print() bst.remove(7) bst.print() export {} ``` 哪怕是优化抽象重构后的代码也有接近300行代码,因此完全掌握下来并不是一件轻松的事情。虽然在如今我们可以利用AI快速的优化并且做得一样好,但我们的逻辑并没有得到锻炼,长久之后,我们的理解能力也会下降,直到有一天连AI给出的优化代码都看不明白,连AI给出的解释都理解得费力。当放弃去理解其中的逻辑,就是将自身的主体性拱手让出,我觉得不是一件很OK的事情。 ## 5.7 二叉搜索树的缺陷与平衡树 ### 5.7.1 二叉搜索树的缺陷 二叉搜索树作为数据存储的结构有重要的优势:可以快速地找到给定关键字的数据项并且可以快速地插入和删除数据项。但是二叉搜索树有一个很麻烦的问题,如果插入的数据是有序的数据,比如下面的情况: - 有一棵初始化为 9 8 12 的二叉树,如图5-28所示。  <p align="center"> <b>图5-28 二叉树的缺陷-A</b> </p> - 插入数据:7 6 5 4 3,展现效果如图5-29所示。  <p align="center"> <b>图5-29 二叉树的缺陷-B</b> </p> 如图5-29所示的这一棵二叉搜索树长得实在太奇怪了,看起来相对于树来说更像一道抛物线。当我们把有序的数据依次插入普通的二叉搜索树(BST)时,树不会长成“左右均匀”的形状,而是沿着一条方向一直长下去:递增序列会产生完全右偏的树(每个节点只有右子节点);递减序列会产生完全左偏的树(每个节点只有左子节点)。形象地说,BST 会“退化”为一个链表。 正常的二叉搜索树,如果左右分布相对均匀,它的高度大约是logN,因此查找、插入、删除操作都很快。但在数据有序的情况下,树不断往一条链上长,最终高度变成N。一个本应像金字塔一样层层展开的树,此时却变成一条斜着的链。这样的结构就叫非平衡树——左右子树极度不均匀。 因为树退化成链表,每次查找、插入或删除一个节点都必须从根节点一路走到最底部,最坏情况要访问所有节点,因此时间复杂度变成O(N)。这比原本的 O(logN) 慢了一个数量级。在数据量大的情况下,这种性能差异会非常明显,甚至可能导致程序卡顿、响应变慢。 除了时间复杂度变差,树变成链表还有额外的工程隐患,例如:递归操作的深度变得很大,有可能造成栈溢出;链表状结构使得指针访问更加分散,CPU缓存利用率降低;删除节点时需要处理的指针情况更复杂,出错概率更高。看似是一个结构问题,实质上会带来性能与稳定性的全面下降。 为了避免普通 BST 在有序数据下退化,实际开发中通常不会使用简单的二叉搜索树,而会使用能够自动保持平衡的自平衡树(如 AVL 树、红黑树 等);或者在需要有序结构时,直接选择其他更稳定的数据结构,比如跳表、B 树、堆、哈希表等。核心思想就是避免“连续插入有序数据导致一条长链”的最坏情况发生。 ### 5.7.2 树的平衡性与平衡树介绍 在二叉搜索树(BST)中,节点的插入顺序会极大影响树的形状。如果数据分布不均,树就可能倾斜成“单链条”,使查找、插入、删除的时间复杂度从 O(logN) 退化为 O(N)。 “树的平衡性”指的是树的高度保持在一个较低且可控的范围,使左右子树的高度差保持小,从而让操作效率保持稳定。平衡树的核心目标是避免最坏情况,使所有基本操作都能维持对数级复杂度 O(logN)。也就是说树中每个节点左边的子孙节点的个数,应该尽可能的等于右边的子孙节点的个数。 计算机中常见的平衡二叉树或多路树包括以下7种: - **AVL Tree**(高度平衡树)。 - **Red-Black Tree**(红黑树)。 - **Splay Tree**(伸展树)。 - **Treap**(树堆,随机平衡 BST)。 - **Scapegoat Tree**(替罪羊树)。 - **B-Tree / B+Tree**(用于磁盘、数据库)。 - **Segment Tree**(线段树,用于区间查询)。 这些结构都是为避免普通 BST 的极端退化情况而设计的。其中 AVL Tree 和 Red-Black Tree 是最经典、最广泛使用的两种自平衡二叉搜索树。 AVL 树是最早提出“自平衡”的二叉搜索树(1962 年),它通过为每个节点记录“平衡因子 = 左子树高度 − 右子树高度”,并强制这个值必须在 -1、0、1 之间。AVL树是在普通BST的基础上加了“严格的高度平衡规则”,当插入或删除破坏平衡时,通过 单旋转 / 双旋转(LL、LR、RR、RL)自动恢复平衡。且因为AVL树是平衡的,所以时间复杂度也是O(logN)。但是,每次插入/删除操作相对于红黑树效率都不高,所以整体效率不如红黑树。 红黑树插入与删除比AVL更省时,所以它成为工业界的标准平衡树,像Java 的 TreeMap、TreeSet、ConcurrentSkipListMap(内部逻辑);C++ 的 std::map、std::set;Linux 调度器、虚拟内存区间管理以及各种语言的基础库都能找到红黑树的身影。 总结一句话:红黑树是实践中的霸主,所以现在平衡树的应用基本都是红黑树。在第10章中,我们会专门学习到AVL树和红黑树。
第4章 哈希表
## 4.1 哈希表基础与特性 哈希表的诞生可以追溯到20世纪50年代,当时计算机科学家们正在寻找一种能够实现快速数据访问的数据结构。1953年,IBM的研究员汉斯·彼得·卢恩首次提出了"散列"这一概念,他当时正在研究如何快速检索信息。卢恩意识到,如果能够通过某种数学函数直接将键转换为存储地址,就能实现近乎即时的数据访问,这种想法彻底改变了传统的数据检索方式。 在随后的发展中,1956年 Arnold Dumey 在《美国计算机协会通讯》上发表了一篇开创性论文,首次系统地描述了哈希技术的基本原理。Dumey提出了将键转换为整数索引的核心思想,并讨论了如何处理不同键映射到同一位置的冲突问题。这一时期的研究主要集中在理论探索上,研究人员试图找到能够均匀分布键的哈希函数,同时设计有效的冲突解决策略。 到了20世纪60年代末70年代初,随着计算机内存成本的下降和容量的增加,哈希表开始从理论走向实践。1968年,Wesley Peterson在其著作中详细分析了各种哈希方法的性能,为哈希表的实际应用奠定了理论基础。同时期,链地址法被广泛采纳作为解决冲突的主要方法,这种方法通过在哈希冲突的位置建立链表来存储多个元素,既简单又有效。 1980年代,随着关系数据库的兴起,哈希表迎来了大规模应用的黄金时期。数据库系统需要快速的数据检索能力,而哈希表的O(1)平均时间复杂度使其成为索引结构的理想选择。这一时期还出现了动态哈希和可扩展哈希等技术,解决了早期哈希表固定大小的限制。这些创新使得哈希表能够在使用过程中动态调整大小,进一步提升了其实用性和性能表现。 进入21世纪,哈希表已成为所有主流编程语言的标准配置,如Java的HashMap、Python的dict、C++的unordered_map等。现代哈希表结合了先进的哈希函数、优化的冲突解决策略和动态扩容机制,在保持高效性能的同时提供了丰富的API接口。从最初的学术概念到如今无处不在的基础数据结构,哈希表的发展历程体现了计算机科学中理论创新与实际应用的完美结合。 ### 4.1.1 哈希表介绍与优势分析 通过对哈希表诞生过程的了解,我们知道了哈希表是一种非常重要的数据结构,但是很多学习编程的人一直搞不懂哈希表到底是如何实现的。在这一章节中,我们就一点点来实现一个自己的哈希表。通过实现来理解哈希表背后的原理和它的优势,正如刚才所说,21世纪几乎所有的编程语言都有直接或者间接的应用这种数据结构,应用非常广泛,这也是我们需要学习它的原因之一。 哈希表通常是基于数组进行实现的,但是相对于数组,它也很多的优势,如以下4点: (1)它可以提供非常快速的插入-删除-查找操作。 (2)无论多少数据,插入和删除值都接近常量的时间:即O(1)的时间复杂度。实际上,只需要几个机器指令即可完成。 (3)哈希表的速度比树还要快,基本可以瞬间查找到想要的元素。 (4)哈希表相对于树来说编码要容易很多。 数组的这些操作对应的时间复杂度在O(n)左右,这时间复杂度并不算高,但基于数组的哈希表,对应的时间复杂度却能达到O(1)级别,这是怎么做到的?在第2点有说明通过机器指令,那什么是机器指令呢?这是我们本章所要学习的。 但所有的数据结构都不是完美的,他们都有特定的应用场景,所以哈希表相对于数组的也有一些不足,如以下2点: (1)哈希表中的数据是没有顺序的,所以不能以一种固定的方式(比如从小到大)来遍历其中的元素(没有特殊处理情况下)。 (2)通常情况下,哈希表中的key是不允许重复的(哈希表的结构是key/value),不能放置相同的key,用于保存不同的元素。 因此不存在只用哈希表,不用数组的情况,他们之间的关系并不是上下位替代,在学习数据结构中,所遇到的所有数据结构都是如此。但我们只是说了哈希表的诞生背景与优劣势,却依旧不知道哈希表到底长什么样子。这也是哈希表不好理解的地方,不像数组和链表,甚至是树一样直接画出你就知道它的结构,甚至是原理了。哈希表的结构就是数组,但是哈希表神奇的地方在于**对数组索引值的一种变换**,这种变换我们可以使用哈希函数,通过哈希函数可以获取到HashCode(散列码)。不着急,我们慢慢来认识它到底是什么。 哈希表映射如图4-1所示。想要真正理解哈希表并不容易,图例只能作为一个简单的参考。  <p align="center"> <b>图4-1 哈希表映射</b> </p> 假设我们有一些数据(keys):John Smith、Lisa Smith,Sandra Dee。由于哈希表实际就是一个数组,我们将这三个数据放入数组之中,然后需要在不利用数组索引的情况下,找到我们想要的值。 ```ts const arr: string[] = ["John Smith", "Lisa Smith", "Sandra Dee"] ``` 为什么有好好的索引不用?这是一个很好的问题!数据结构与算法存在一个内蕴的精神:极致优化。对于这一点的触动,我最早是在学习操作系统时所感受到的。无所不用其极的去优化结构,去节省哪怕一点点的空间,提高哪怕一点点的性能等等。正是极致优化的思想,让人不禁锢于满足"能用",永远追问"能不能更好"。通过数组索引访问所需数据的时间复杂度是O(n),如果还想优化,就只能往O(1)去考虑。 我很钦佩计算机科学家们的这种思想精神,打破原有的思维定式。那么当我们已经知道哈希表确实是能在数组的基础上做到将时间复杂度优化到O(1)级别,在抛弃数组索引的优势后,我要怎么样更快的找到想要的值?在这个过程中,我需要舍弃哪些部分来换取对应的成果?(由于哈希表并不能上位替代数组,这意味着哈希表必然是牺牲了部分,来换取在某一方向更极致的提升) 能对数组进行二分查找吗?时间复杂度虽不能提升到O(1),但也达到了对数级别。很可惜不行,因为我们数组内填充的是字符串,是很难像数字那样排序的,当填充的不是字符串而是对象时,就更难做到了,所以这条路是不通的。这好像只能一个个的查找(顺序查找),回到O(n)的原点,毫无头绪确实令人气馁。 如果我们有一个函数(hash function),能将数组中的这些值,一个个的直接映射到某个整理好的地方(不按照顺序)。那么我就能在知道数组某个值的情况下,直接去整理好的映射处找到对应的位置。但这时候我有疑惑了,去映射处找,那不还得一个个找?除非我们不一个个去找,这让我想起来数组的访问级别为什么是O(1),是通过计算内存地址直接拿到对应位置。那我们能不能在映射处做出同样的处理,使其位置能够被计算? 这很有意思,如果做到能计算,那么查找的过程就从一个个查找跨越到直击目标。在已经实现哈希表的当下,可以明确说,这是可以的,我们已经走在正确的道路上了,哈希表计算位置如图4-2所示。  <p align="center"> <b>图4-2 哈希表计算位置</b> </p> ### 4.1.2 现实案例与存储需求 我们通过二个案例,案例需要你挑选某种数据结构,而你会发现最好的选择就是哈希表。 案例一:公司使用一种数据结构来保存所有员工。 案例二:使用一种数据结构存储单词信息,比如有50000个单词。找到单词后每个单词有自己的翻译&读音&应用等等。 我们先来说明**案例一**:假如一家公司有1000个员工,现在我们需要将这些员工的信息使用某种数据结构来保存起来,你会采用什么数据结构呢?到目前为止,我们学习过4种数据结构,即数组、栈、队列、链表。适合存储数据的有数组和链表,那这两种方案适合案例一的需求吗? (1)方案一,数组。可以按照顺序将所有的员工依次存入一个长度为1000的数组中,每个员工的信息都保存在数组的某个位置上。但是我们要查看某个具体员工的信息怎么办呢?一个个找吗?不太好找。数组最大的优势是通过索引值去获取信息,所以为了可以通过数组快速定位到某个员工,最好给员工信息中添加一个员工编号(工号),而编号对应的就是员工的索引值。当查找某个员工的信息时,通过员工编号可以快速定位到员工的信息位置。 (2)方案二,链表。链表对应插入和删除数据有一定的优势。但是对于获取员工的信息,每次都必须从头节点遍历到尾节点,这种方式显然不是特别适合我们这里。 这样看最终方案似乎就是数组了。但是数组还是有缺点,什么缺点呢?如果我们只知道员工的姓名,比如coderwhy,但是不知道coderwhy的员工编号,我们怎么办呢?那只能线性查找了,效率就非常的低。能不能有一种办法,让coderwhy的名字和他的员工编号产生直接的关系呢?也许是通过和数组查询类似的计算方式,如图4-3所示。  <p align="center"> <b>图4-3 员工姓名与员工数据信息关联</b> </p> 在不知道数组索引的情况下,JS查询到数组具体某个数据的方式对应时间复杂度如下代码块。 ```ts const arr: string[] = ["John Smith", "Lisa Smith", "Sandra Dee", "coderwhy"] // 知道具体索引的查询-时间复杂度 O(1) const element = arr[3]; //不知道具体索引的查询-时间复杂度O(n) // 1. find() 方法 - 推荐用于查找单个元素 // 时间复杂度: O(n) - 最坏情况遍历整个数组 const element1 = arr.find(item => item === "coderwhy"); console.log(element1); // "coderwhy" // 2. findIndex() + 索引访问 // 时间复杂度: O(n) - 查找索引 + O(1) 访问 const index1 = arr.findIndex(item => item === "coderwhy"); const element2 = index1 !== -1 ? arr[index1] : undefined; // 3. indexOf() + 索引访问 (适合简单值匹配) // 时间复杂度: O(n) - 查找索引 + O(1) 访问 const index2 = arr.indexOf("coderwhy"); const element3 = index2 !== -1 ? arr[index2] : undefined; // 4. for 循环 + break (性能最佳的手动查找) // 时间复杂度: 最好 O(1), 最坏 O(n), 平均 O(n/2) let element4; for (let i = 0; i < arr.length; i++) { if (arr[i] === "coderwhy") { element4 = arr[i]; break; // 关键:找到后立即退出 } } // 5. for...of 循环 + break // 时间复杂度: 最好 O(1), 最坏 O(n), 平均 O(n/2) let element5; for (const item of arr) { if (item === "coderwhy") { element5 = item; break; } } // 6. includes() + find() 组合 (不推荐,效率低) // 时间复杂度: O(n) + O(n) = O(2n) ≈ O(n) const element6 = arr.includes("coderwhy") ? arr.find(item => item === "coderwhy") : undefined; // 7. filter() 方法 (适合查找多个匹配项) // 时间复杂度: O(n) - 总是遍历整个数组 const elements = arr.filter(item => item === "coderwhy"); const element7 = elements[0]; // 取第一个匹配项 // 8. reduce() 方法 (复杂场景使用) // 时间复杂度: O(n) - 遍历整个数组 const element8 = arr.reduce((found, item) => { return found || (item === "coderwhy" ? item : null); }, null); // 9. some() 方法 + find() (测试存在性后查找) // 时间复杂度: O(n) + O(n) = O(2n) ≈ O(n) let element9; if (arr.some(item => item === "coderwhy")) { element9 = arr.find(item => item === "coderwhy"); } ``` 即当我们知道员工姓名时,我们就能获取到它的索引值(将员工姓名直接转换为数组索引?),而再通过索引值我就能获取到coderwhy的信息呢?这样的方案已经存在了,就是使用哈希函数,让某个key的信息和索引值对应起来。并且员工姓名与数组索引之间的关联需要是固定的,不固定所产生的无序将导致我们无法进行寻找行为。 其次是**案例二**:我们使用一种数据结构存储单词信息,比如有50000个单词,找到单词后每个单词有自己的翻译&读音&应用等等。 这个案例更加明显能感受到数组的缺陷,我拿到一个单词 Iridescent,我想知道这个单词的翻译/读音/应用,怎么才能从数组中查到这个单词的位置呢?线性查找吗?那最多的情况有50000次比较,如果我们使用数组来实现这个功能,效率会非常非常低,这只能证明我们一定没有学习过数据结构。 那么链表呢?链表更是必须从头节点开始遍历查找,因此直接不考虑。 数组与链表的方案都无法满足我们的需求,有没有一种方案,可以将单词转成数组的索引值呢?如果单词转成数组的索引,那么以后我们要查找某个单词的信息,直接按照索引值一步即可访问到想要的元素。 ## 4.2 哈希化与哈希函数 ### 4.2.1 字母转数字方案 在4.1.2小节的两个案例中,似乎都指向了同一目标:将字符串转成索引值。因为索引值通过计算的方式能以O(1)级别的时间复杂度达成寻找目的。但是,怎么样才能将一个字符串转成数组的索引值呢? 数组的索引值是以数字的形式体现的,因此可以将问题转化为:怎么样才能将一个字符串转成数字? 现在我们需要设计一种方案,可以将单词转成适当的下标值:其实计算机中有很多的编码方案就是用数字代替单词的字符,即字符编码。例如ASCII编码:a是97,b是98,依次类推122代表z。我们也可以设计一个自己的编码系统,比如a是1,b是2,c是3,依次类推,z是26。当然我们可以加上空格用0代替,就是27个字符(不考虑大写问题),但是,有了编码系统后,一个单词如何转成数字呢? 方案一:数字相加。我们可以将每个单词字符的编码求和,例如单词cats转成数字为:3+1+20+19为43,那么43就作为cats单词的下标存在数组中。 但方案一有一个很明显的问题:很多单词最终的下标可能都是43,例如如was/tin/give/tend/moan/tick等等,如果存入后来的数据,就会造成数据覆盖的问题。一个下标存储这么多单词显然是不合理的。我们有两个选择,要么做到每个单词通过编码所产生的数字都不同,要么就解决数据覆盖问题并降低重复冲突的情况。 方案二:幂的连乘。数组相加的方案过于普通,重合度极高。幂的连乘可以基本上保证它的唯一性,例如:7654 = `7*10³+6*10²+5*10+4`。我们的单词也可以使用这种方案来表示,例如cats = `3*27³+1*27²+20*27+17= 60337`,这样得到的数字可以基本保证它的唯一性,不会和别的单词重复,是科学计数法的一种变种实践表达。 问题是如果一个单词是zzzzzzzzzz(一般英文单词不会超过10个字符),那么得到的数字超过7000000000000(13位),这种方式所产生的数字大小与将编码拼接起来也不逞多让。这让我们怀疑数组可以表示这么大的下标值吗?就算能创建这么大的数组,事实上有很多是无效的单词,数组绝大多数开辟的空间都是浪费的(空槽),创建这么大的数组是没有意义的。 第一种方案(把数字相加求和)产生的数组下标太少。第二种方案(与27的幂相乘求和)产生的数组下标又太多。所以,我们是不是需要考虑牺牲一下唯一性,为了唯一性所需要付出的代价有些过大了。解决数据覆盖问题并降低重复冲突的做法似乎更容易实现。 ### 4.2.2 索引压缩算法 我们需要考虑是否能够解决内容覆盖的问题,以及对唯一性的要求有多高。我们需要在唯一性和空间效率之间找到平衡。 如果能解决内容覆盖的问题,那唯一性并不是必要的要求,不需要追求绝对唯一性,只需要在有限范围内尽可能均匀分布,分布得越散,查找性能就越无限接近于O(1),处在一个O(1)~O(n)的状态,n的大小取决于均匀分布得有多好。如果通过计算的方式,最多只有两三个内容的重复,那对只有两三个内容进行顺序查找,其性能实际也极其接近O(1)。 因此能否将两种方案结合一下,现在需要一种压缩方法,把幂的连乘方案系统中得到的巨大整数范围压缩到可接受的数组范围中。对于英文词典,多大的数组才合适呢?如果只有5万个单词,我们可能会定义一个长度为5万的数组,那样就可以完美利用所有空间。但是实际情况中,往往需要更大的空间来存储这些单词,因为我们不能保证单词会映射到每一个位置。在如今硬件发展迅猛的时代,空间复杂度相对于时间复杂度是更宽容的,因此我们有限考虑牺牲一部分空间性能。定义长度5万的数组是理想情况,那么我们定义两倍大小,即长度为10万的数组空间,留下足够的稀释分布的缓冲余地,又不至于让空间浪费到幂的连乘那种程度。 那么如何压缩呢?现在要找一种方法,把0到超过7000000000000的范围,压缩为从0到100000。有一种简单的方法就是使用取余操作符,它的作用是得到一个数被另外一个数整除后的余数。 取余操作能够将大范围的数字映射到有限的小范围空间中。具体来说,如果我们希望将0~199这样的大数字范围压缩到0~9的小范围中,只需通过简单的取余运算:index = largeNumber % smallRange。例如,13除以10余数为3,157除以10余数为7,这样任何数字经过取余运算后都会落在0~9的范围内。虽然这种方法仍可能产生重复的索引值,但重复的概率已经显著降低。 在实际的哈希表设计中,当我们拥有一个长度为100000的数组却只需要存储50000个单词时,通过取余操作可以将单词哈希值的大范围映射到有限的数组索引范围内。这就好比从0~199的数字范围内随机选取5个数字放入长度为10的数组中,虽然理论上可能发生重复,但实际概率很小。更重要的是,即使发生了索引重复的情况,我们也有冲突解决机制来应对,即我们前面所说的解决数据覆盖问题的需求。 这种基于取余运算的压缩方法体现了对需求与限制的抉择,既不追求绝对的唯一性而浪费大量空间,也不因过度压缩而导致无法接受的冲突率。通过合理的参数选择和冲突处理策略,我们能够在有限的空间内实现高效的键值存储和检索。 理解上述内容,我们就理解了哈希表的原理了,像取余操作实际上是实现哈希表范围压缩的核心技术,我们来看看以下3个概念: (1)哈希化:将大数字转化成数组范围内下标的过程,我们就称之为哈希化。 (2)哈希函数:通常我们会将单词转成大数字,大数字在进行哈希化的代码实现放在一个函数中,这个函数我们成为哈希函数。在4.5.1小节会详细说明。 (3)哈希表:最终将数据插入到的这个数组,对整个结构的封装,我们就称之为是一个哈希表。 如果我希望通过一个单词,查找到单词所对应的翻译&读音&应用,会怎么做?通过4.2.1与4.2.2小节的思考学习,我们会先获取到这一个单词,将单词通过幂的连乘转化为一个大数字,再通过哈希化将大数字变成一个索引值,最后通过索引值能够以接近O(1)的时间复杂度去查询单词对应的翻译&读音&应用。我们所做的是通过单词能计算出一个固定的索引值,往索引值内存放数据再进行读取。 我们将单词转为索引值的过程封装到一个函数中,这个函数就被称为哈希函数。 ```ts // 哈希函数伪代码 function hashFn(str: string): number { cats => 幂的连乘 => 大的数字 => 哈希化 => 索引值 } ``` 但是,我们还有问题需要解决:虽然,我们在一个100000的数组中,放50000个单词已经足够。但是通过哈希化后的下标值依然可能会 重复,如何解决这种重复所导致的内容覆盖问题呢? ## 4.3 地址冲突解决方案 什么是冲突?尽管5万个单词,我们使用了10万个位置来存储,并且通过一种相对比较好的哈希函数来完成。但是依然有可能会发生冲突。比如Hope这个单词,通过哈希函数得到它数组的下标值后,发现那个位置上已经存在一个单词Happy,因为Hope经过哈希化后和Happy得到的下标是相同的(此处为随手举例,未经验证,因此实际情况不一定相同)。出现下标相同的情况称为冲突。 虽然我们不希望这种情况发生,当然更希望每个下标对应一个数据项,但是通常这是不可能的。冲突不可避免,我们只能解决冲突。数组索引重复导致的覆盖问题如图4-4所示。  <p align="center"> <b>图4-4 数组索引重复-覆盖问题</b> </p> 就像之前0~199的数字选取5个放在长度为10的单元格中,如果我们随机选出来的是33,82,11,45,90,那么最终它们的位置会是3-2-1-5-0,没有发生冲突。但是如果其中有一个33,还有一个73呢?那么就会发生了冲突。我们需要针对这种冲突提出一些解决方案,虽然冲突的可能性比较小,但我们依然需要考虑到这种情况,以便发生的时候进行对应的处理代码。 有两种常见的方案来解决这类冲突: (1)链地址法。 (2)开放地址法。 最主要的是链地址法,是需要着重理解学习的,而开放地址法毕竟难理解,而且在如今已经很少使用了,因此不追求必须掌握。 ### 4.3.1 链地址法 链地址法是一种比较常见的解决冲突的方案。(也称为拉链法)。当不同的键(key)通过哈希函数映射到同一个数组索引位置时,链地址法不是覆盖原有数据,而是在该位置创建一个链表结构,将所有映射到同一位置的键值对以链表节点的方式串联存储。 具体实现时,哈希表的每个数组元素不再直接存储单个键值对,而是存储一个链表头节点或指针。当发生哈希冲突时,新的键值对会以节点形式添加到对应位置的链表中。在查找操作时,系统先通过哈希函数定位到特定索引,然后遍历该位置的链表,通过键的比较来找到目标数据。如果我们理解了为什么产生冲突,当看到图就能立马理解链地址法是什么含义,链地址法如图4-5所示。  <p align="center"> <b>图4-5 链地址法</b> </p> 链地址法的最大优势在于它平衡了时间与空间效率。虽然最坏情况下所有键都映射到同一位置会导致退化为线性查找,但在良好的哈希函数设计和合理的负载因子控制下,每个链表的平均长度会保持很短,使得查找效率依然接近常数时间。链地址法实现相对简单,且能自然地处理动态数据增长,因此成为大多数编程语言中哈希表实现的首选冲突解决机制。 从图4-5中可以看出,链地址法解决冲突的办法是每个数组单元中存储的不再是单个数据,而是一个链条,这也是链地址法名称的由来。那这个链条使用什么数据结构呢?常见的是数组或者链表,例如是链表,也就是每个数组单元中存储着一个链表。一旦发现重复,将重复的元素插入到链表的首端或者末端即可,当查询时,先根据哈希化后的下标值找到对应的位置,再取出链表,依次查询找寻找的数据。 那么选择数组还是链表呢?其实都可以,效率上差不多,因为根据哈希化的index找出这个数组或者链表时,通常就会使用线性查找,这个时候数组和链表的效率是差不多的,大多数情况下并不需要在任意位置进行删除插入,链表的优势无法体现。当然在某些实现中,会将新插入的数据放在数组或者链表的最前面,因为觉得新插入的数据用于取出的可能性更大。这种情况最好采用链表,因为数组在首位插入数据是需要所有其他项后移的,链表就没有这样的问题。 当然,我觉得出于这个也看业务需求,不见得新的数据就访问次数会更多:比如我们微信新添加的好友,可能是刚认识的,联系的频率不见得比我们的老朋友更多,甚至新加的只是聊一两句。所以选择数据或者链表都是可以的,根据实际的业务需求去判断。 ### 4.3.2 开放地址法 开放地址法是不好理解(难)以及现如今使用很少,所以不感兴趣可以直接跳过。 开放地址法的主要工作方式是寻找空白的单元格来添加重复的数据。其核心思想在于当哈希冲突发生时,不借助额外的链表结构,而是在原始数组内部通过系统性的探测序列寻找下一个可用的空槽位。具体来说,当目标位置已被占用时,算法会按照预先设定的探测方法(如线性探测的依次检查、二次探测的平方偏移或双重哈希的再散列)在数组中继续寻找,直到成功找到空位插入或确认元素不存在。开放地址法如图4-6所示。  <p align="center"> <b>图4-6 开放地址法</b> </p> 这种方法的最大优势在于所有数据都直接存储在数组内部,无需额外的指针开销,数据局部性更好,缓存性能更高。然而,开放地址法对负载因子(装填因子)更为敏感,当数组接近填满时性能会急剧下降,且删除操作较为复杂,通常需要采用特殊的标记机制而非直接清空槽位。 ### 4.3.3 线性探测与二次探测 线性探测非常好理解:线性的查找空白的单元。例如我们插入数字32,经过哈希化得到的index=2,但是在插入的时候,发现该位置已经有了82。怎么办呢?线性探测就是从index位置+1开始一点点查找合适的位置来放置32,什么是合适的位置呢?空的位置就是合适的位置,在我们上面的例子中就是index=3的位置,这个时候32就会放在该位置。如果没有空的位置就会一直对index+1,直到找到空的位置为止。 那我们如何查询32呢?查询32和插入32比较相似。首先经过哈希化得到index=2,比如2的位置结果和查询的数值是否相同,相同那么就直接返回。不相同呢?线性查找,从index位置+1开始查找和32一样的。这里有一个特别需要注意的地方:如果32的位置我们之前没有插入,是否将整个哈希表查询一遍来确定32存不存在吗?并不是这样的,查询过程有一个约定,就是查询到空位置,就停止。因为查询到这里有空位置,32之前不可能跳过空位置去其他的位置。 如果我们想要删除32,虽然删除操作与插入查询类似,但需要注意删除操作一个数据项时,不可以将这个位置下标的内容设置为null,这是为什么呢?因为将它设置为null可能会影响我们之后查询其他操作,所以通常删除一个位置的数据项时,我们可以将它进行特殊处理(例如设置为-1,但这个内容一定要足够特殊唯一,不能和我们数据项有重复)。当我们之后看到-1位置的数据项时,就知道查询时要继续查询,但是插入时这个位置可以放置数据。 线性探测看起来好像很不错,对空间的利用率很高。但线性探测有一个比较严重的问题,就是聚集。什么是聚集呢?例如我在没有任何数据的时候,插入的是22-23-24-25-26,那么意味着下标值:2-3-4-5-6的位置都有元素。这种一连串填充单元就叫做聚集。聚集会影响哈希表的性能,无论是插入/查询/删除都会影响。例如我们插入一个32,会发现连续的单元都不允许我们放置数据,并且在这个过程中我们需要探索多次。 一旦需要探索多次,时间复杂度就会开始提升,探索次数越多,效率就越接近线性查找。也可以理解为一旦出现聚集效果,线性探测的效率就会大幅下降,那有没有办法解决聚集效果呢?二次探测可以解决一部分这个问题,我们一起来看一看。 我们刚才谈到,线性探测存在的问题:如果之前的数据是连续插入的,那么新插入的一个数据可能需要探测很长的距离。 二次探测在线性探测的基础上进行了优化,二次探测主要优化的是探测时的步长,什么意思呢?线性探测,我们可以看成是步长为1的探测,比如从下标值x开始,那么线性测试就是x+1,x+2,x+3依次探测。而二次探测,对步长做了优化,比如从下标值x开始,x+1²,x+2²,x+3²。这样就可以一次性探测比较长的距离,可以避免聚集带来的影响。二次探测实际思路与4.2.1小节中的字母转数字方案二的幂的阶乘类似,但数字跨越幅度没有那么夸张,虽然做不到极其接近唯一存储,对空间也不会那么浪费,也能减轻正常线性探测所导致的聚集效果。 但二次探测依然存在问题,例如我们连续插入的是32-112-82-2-192,那么它们依次累加的时候步长的相同的。也就是这种情况下会造成步长不一的一种聚集。还是会影响效率。(当然这种可能性相对于连续的数字会小一些)怎么根本解决这个问题呢?让每个人的步长不一样,一起来看看再哈希法吧。 ### 4.3.4 再哈希法 为了消除线性探测和二次探测中无论步长+1还是步长+平法中存在的问题, 还有一种最常用的解决方案: 再哈希法。再哈希法是在开放地址法框架下为解决线性探测和二次探测中存在的聚集问题而提出的一种优化方案。 二次探测的算法产生的探测序列步长是固定的: 1, 4, 9, 16, 依次类推。现在需要一种方法: 产生一种依赖关键字的探测序列, 而不是每个关键字都一样。那么, 不同的关键字即使映射到相同的数组下标, 也可以使用不同的探测序列。再哈希法的做法就是: 把关键字用另外一个哈希函数, 再做一次哈希化, 用这次哈希化的结果作为步长,对于指定的关键字, 步长在整个探测中是不变的, 不过不同的关键字使用不同的步长。 想象一下,我们在一个巨大的停车场里停车,你的停车位是根据你的车牌号算出来的。但当你开到那个位置时,发现已经有一辆车停在那里了。 - 线性探测:就像你启动车子,一个接一个地查看后面的车位(+1, +1, +1...)。如果很多人都在做同样的事,停车场入口处就会堵成一团,这就是“聚集”。 - 二次探测:你不再一个个地找。第一次,你跳过1个车位去看看(+1²);如果还满着,你就跳过4个车位去看看(+2²);再满,就跳过9个(+3²)。这比线性探测跳得快,但如果很多人同时开始跳,还是会在某些区域形成新的“拥堵圈”。 现在,再哈希法登场了。它解决的核心问题是:“凭什么所有人的跳法都要一样?” 它的做法非常聪明:第一次计算还是用你的车牌号算出你“本该停”的第一个车位(比如是第5号车位)。第二次计算(关键)是当第5号车位被占后,它不再使用固定的“跳1个”或“跳4个”模式。而是把你的车牌号再输入另一个计算公式,算出一个专属于你的“跳远步长”。例如,你的车牌算出的步长是 3,另一辆车的车牌算出的步长可能是 7。 那么,大家的找车位路径就完全不同了: (1)你的路线是:5号(被占)→ 5+3=8号 → 8+3=11号 → 11+3=14号 ... (2)另一辆车的路线是:5号(被占)→ 5+7=12号 → 12+7=19号 ... 这个步长由第二个哈希函数决定,对于同一辆车(关键字)是固定不变的,但不同的车会得到不同的步长。这样,即使两辆车第一眼都看中了同一个车位,它们之后也会“分道扬镳”,各找各的,从而极大地分散了拥堵,解决了聚集问题。 第二次哈希化需要具备如下2点特点: (1)和第一个哈希函数不同。(不要再使用上一次的哈希函数了, 不然结果还是原来的位置) (2)不能输出为0。(否则, 将没有步长. 每次探测都是原地踏步, 算法就进入了死循环) 其实, 我们不用费脑细胞来设计了, 计算机专家已经设计出一种工作很好的哈希函数:stepSize= constant -(key % constant)。其中constant是质数, 且小于数组的容量。例如: stepSize= 5 -(key % 5), 满足需求, 并且结果不可能为0。 ## 4.4 哈希表效率分析 对于链地址法来说,既然通过在数组中继续放数组或者链表,可以解决覆盖原有内容的问题,那么就能够做到在同一位置一直存放内容。可一直在同一位置存放内容,随着内容越多,链表就会越来越长,其后续查询、放置的效率就会越低(时间复杂度会提升),那么这会失去我们的优化初衷,因此使用链地址法一定不能存入无限个元素。 而对于开放地址法来说,如果有10个位置,我将这10个位置全部塞满,这基本上意味着无论我是线性探测还是二次探测亦或者再哈希法,我都不可能避免第一次的索引位置是与人重复,哪怕不断变换跳步规则,也会因为位置已经塞满而依旧出现重复问题,聚集效应在位置塞满之后已经变成必然的事情了。 无论哪种方法,由于我们做不到通过计算位置一步到位将每个元素放入到唯一的索引中,因此当空间利用效率越高,其可腾转的余地就越少,其时间复杂度就会飙升。而时间复杂度的优先度是高于空间复杂度的,在硬件不断升级的当下,牺牲部分空间来降低时间复杂度是一件划算的买卖。 ### 4.4.1 装填因子概念 那我们要如何计算牺牲部分空间的比例呢?假设容量是10,元素是8,那么对空间的利用率是0.8,这个空间利用率我们称为装填因子。当状态因子越高,其时间复杂度就越高。比如在链地址法中出现了容量是10,元素是200的情况,那么装填因子就为20,意味着时间复杂度已经飙升到非常高的程度了,那么这时候最好牺牲一定的空间来扩容,从而降低时间复杂度。 在这里我们了解一个概念:装填因子。 装填因子表示当前哈希表中已经包含的数据项和整个哈希表长度的比值。装填因子= 总数据项/ 哈希表长度。 对于开放地址法来说,装填因子必须 < 1(因为每个位置只能放一个元素),当装填因子接近1时,会出现无法避免的聚集效应,性能急剧下降;而对于链地址法来说,装填因子可以 > 1(因为每个位置可以放多个元素),但装填因子过大会导致链表过长。 通过装填因子,我们能开始把握空间复杂度与时间复杂度之间的平衡标准。 总结下来,哈希表中执行插入和搜索操作效率是非常高的,如果没有产生冲突,那么效率就会更高。如果发生冲突,存取时间就依赖后来的探测长度。平均探测长度以及平均存取时间,取决于填装因子,随着填装因子变大,探测长度也越来越长。随着填装因子变大,效率下降的情况,在不同开放地址法方案中比链地址法更严重,所以我们来对比一下他们的效率,再决定我们选取的方案。 ### 4.4.2 不同冲突解决方案效率对比 线性探测效率如图4-7所示。显示了线性探测时,探测序列(P)和填装因子(L)的关系,装填因子变化规律如下2点: (1)当填装因子是1/2时,成功的搜索需要1.5次比较,不成功的搜索需要2.5次。 (2)当填装因子为2/3时,分别需要2.0次和5.0次比较。 如果填装因子更大,比较次数会非常大。应该使填装因子保持在2/3以下,最好在1/2以下,另一方面,填装因子越低,对于给定数量的数据项,就需要越多的空间。实际情况中,最好的填装因子取决于存储效率和速度之间的平衡,随着填装因子变小,存储效率下降,而速度上升。  <p align="center"> <b>图4-7 线性探索的性能</b> </p> 二次探测和再哈希法的性能相当。它们的性能比线性探测略好,对应性能图如图4-8所示。装填因子变化规律如下3点: (1)当填装因子是0.5时,成功和不成的查找平均需要2次比较 (2)当填装因子为2/3时,分别需要2.37和3.0次比较 (3)当填装因子为0.8时,分别需要2.9和5.0次 因此对于较高的填装因子,对比线性探测,二次探测和再哈希法还是可以忍受的。  <p align="center"> <b>图4-8 二次探测和再哈希法的性能</b> </p> 链地址法的效率分析有些不同,一般来说比开放地址法简单。我们来分析一下这个公式应该是怎么样的。假如哈希表包含arraySize个数据项,每个数据项有一个链表,在表中一共包含N个数据项。那么,平均起来每个链表有多少个数据项呢?非常简单,N / arraySize。有没有发现这个公式有点眼熟?其实就是装填因子。 那么我们现在就可以求出查找成功和不成功的次数了,成功可能只需要查找链表的一半即可:1 + loadFactor/2;不成功呢?可能需要将整个链表查询完才知道不成功:1 + loadFactor。链地址法性能如图4-9所示。  <p align="center"> <b>图4-9 链地址法性能</b> </p> 经过上面的比较我们可以发现,链地址法相对来说效率是好于开放地址法的,变化更为均匀(稳定的优先度很高)。所以在真实开发中,使用链地址法的情况较多,因为它不会因为添加了某元素后性能急剧下降。例如在Java的HashMap中使用的就是链地址法。 以上就是哈希表的演变过程与理论知识,接下来我们就要来手写实现哈希表。 ## 4.5 哈希表实现 ### 4.5.1 哈希函数实现与霍纳法则 #### 1.哈希函数设计 学习了很久的哈希表理论知识,我们发现在整个哈希表的演变过程中有一个非常重要的东西:哈希函数。好的哈希函数应该尽可能让计算的过程变得简单,提高计算的效率。因为哈希表的主要优点是它的速度,所以如果在速度上不能满足,那么就达不到哈希表设计的目的了。提高速度的一个办法就是让哈希函数中尽量少的有乘法和除法,因为它们的性能是比较低的。 那么设计好的哈希函数应该具备哪些优点呢?主要有以下2点: (1)快速的计算:哈希表的优势就在于效率,所以快速获取到对应的hashCode非常重要,我们需要通过快速的计算来获取到元素对应的hashCode。 (2)均匀的分布:哈希表中,无论是链地址法还是开放地址法,当多个元素映射到同一个位置的时候,都会影响效率。所以,优秀的哈希函数应该尽可能将元素映射到不同的位置,让元素在哈希表中均匀的分布。 hashCode(哈希码)是什么?是通过哈希函数计算得到的整数值,在我们的单词案例中,是将单词转为数字的中间结果,这个数字随后会被进一步处理(如取模运算)以得到数组索引。在4.2小节与4.3小节中,我们详细学习了如何通过计算尽量得到不重复的数字。但除了注意聚集效应所导致的性能影响之外,我们还可以尽量去提高计算的速度与效率,也就是少使用乘除法。 哈希表通过哈希函数进行映射的图可参考一开始的图4-1。我们在4.2小节对哈希函数主要的操作为幂的连乘与取余操作,但这些操作能否还能优化?例如调整其中的乘法部分。 #### 2.霍纳法则 在前面,我们计算哈希值的时候使用的方式是幂的连乘,即`cats = 3*27³+1*27²+20*27+17= 60337`。这种方式是直观的计算结果,那么这种计算方式会进行几次乘法几次加法呢?当然,我们可能不止4项(c、a、t、s),可能有更多项,如果抽象一下,这个表达式其实是一个多项式:a(n)x^n+a(n-1)x^(n-1)+…+a(1)x+a(0)。 现在问题就变成了多项式有多少次乘法和加法: (1)乘法次数:n+(n-1)+…+1=n(n+1)/2。 (2)加法次数:n次。 那么,乘法运算的次数是O(N²)级别,而加法运算的次数是O(N)级别,因此幂的连乘对应的性能并不算高。对于这类多项式,可以采用秦九韶算法。秦九韶算法是中国南宋数学家秦九韶提出的一种多项式求值的高效算法,在西方被称为霍纳法则(后续统称霍纳法则)。它是一种通过递归的乘加操作来减少多项式求值计算量的优化方法。 霍纳法则的核心做法是通过嵌套的乘加运算来减少计算量。具体而言,算法从多项式的最高次项系数开始,初始化一个结果值,然后依次将当前结果乘以自变量x并加上下一个低次项系数,重复这一过程直到处理完所有常数项。例如,对于多项式P(x) = aₙxⁿ + aₙ₋₁xⁿ⁻¹ + ... + a₁x + a₀,霍纳法则将其重组为P(x) = a₀ + x(a₁ + x(a₂ + ... + x(aₙ₋₁ + x·aₙ)...)),这样只需进行n次乘法和n次加法即可完成计算,将乘法复杂度从传统的O(n²)降低到O(n)。这种简洁的迭代方式,不仅提升了计算速度,还保持了数值稳定性。 传统计算多项式的方式就像是一个笨拙的建筑师,他要计算一座大楼的总造价。这座楼有不同层高的房间(对应x的不同次幂),每种房型有各自的单价(对应系数)。他的做法是:先孤零零地盖好一个100层的房间,算出它的造价;再单独盖一个50层的房间,算出造价;接着盖一个10层的房间……最后把所有独立建筑的造价加起来。这种方法的问题在于,他每盖一种房型都从平地开始,重复计算了地基和底层结构,做了大量无用功,乘法次数自然就多了。 而霍纳法则是一位聪明的建筑师,他采用“从顶层开始,逐层向下叠加”的智慧策略。他先拿着最高层(比如100层)的图纸,但只计算单层的成本,然后他意识到:“既然100层是在99层之上加盖的,我何不利用这个结构呢?”于是,他的计算过程变成了:先假设一个基础成本(最高次项系数),然后乘以“单层成本系数”(x),再加上下一层的建设成本(低一次项的系数);接着,把这个结果再乘以“单层成本系数”,再加入更下一层的成本……如此循环,直到把大堂(常数项)的成本也加进去。 这个过程的精妙之处在于,**每一次乘法都在为后续所有低次项“预留”了空间**。我们当前的计算结果,已经包含了之前所有高次项累积放大后的效应,我们只需要在此基础上做一次乘法和一次加法,就能把下一个低次项“吸纳”进来。这样一来,原本需要独立、重复计算的幂次(如x¹⁰⁰, x⁹⁹, ...),现在全部被巧妙地融合进一个滚雪球式的迭代过程中。最终,盖完整个大楼所需的“乘法操作”次数,仅仅等于大楼的层数(多项式的最高次数),从而将计算复杂度从平方级(O(n²))奇迹般地降到了线性级(O(n))。 经过变换之后,我们只需要N次乘法次数和N次加法次数。最核心的做法在于对乘法的复用,n的二次方是在n的基础上复乘一次,而n的三次方是在n的二次方基础上复乘一次,之前的低次项基础是能够运用上的,没必要每次都重新开始。霍纳法则本质上也只是复用了成果,这也算是一种抽象的过程,去除了重复的部分,如果从代码设计的角度来看,这其实就是一个递归函数。 通过霍纳法则,我们成功优化了优秀的哈希函数所需要具备的第一个条件:快速计算。那么接下来,我们要优化哈希函数尽可能将元素映射到不同位置,使其元素在哈希表中均匀分布。 #### 4. 均匀分布 在设计哈希表时,我们已经有办法处理映射到相同下标值的情况:链地址法或者开放地址法。但是无论哪种方案,为了提供效率,最好的情况还是让数据在哈希表中均匀分布。因此,我们需要在使用常量的地方,尽量使用质数。在哈希表中使用质数进行取余操作能够使数据分布更加均匀,这主要源于质数的数学特性。质数是指除了1和自身外没有其他因数的数字,这种独特性使得当哈希表容量为质数时,对任意输入值取模后得到的结果分布更为分散。如果使用合数作为容量,输入数据中的某些规律性模式(如偶数列、特定倍数等)会与合数的因数产生共振,导致大量数据聚集在少数几个余数上,形成不均匀分布。 具体来说,当哈希表大小为质数时,取余操作相当于在一个数学上的"循环群"中进行运算,这确保了无论输入数据存在何种内在规律,计算结果都能最大程度地分散在整个值域范围内。例如,如果表大小为合数10,那么所有以0结尾的键都会映射到同一位置;而如果使用质数11,这种规律性就被打破,数据分布自然更加均匀。 哪些地方我们会使用到常量呢?主要有两个地方: (1)哈希表的长度。 (2)N次幂的底数。 在哈希表的长度上使用质数,是为了在取模运算时打破数据规律性,使键值对能在数组中均匀分布,减少哈希冲突。在N次幂的底数上使用质数,是为了在计算哈希值时增加随机性(为了产生的数据不按照某种规律递增),避免不同键的相似部分产生相同的哈希模式,确保分布均匀性。 总之,质数是一个非常神奇的数字。我们建立这两处地方都使用质数。 #### 5. Java中的HashMap Java中的HashMap采用链地址法来解决哈希冲突,其设计中的一大特色是哈希表的初始长度设定为16,并且在每次自动扩容(我们还没聊到扩容,例如当装填因子>0.75时会自动扩容)时都严格要求长度必须保持为2的次幂。这一设计选择的核心目的在于优化键(key)到数组索引(index)的映射计算效率。HashMap通过一个巧妙的位运算公式来计算索引值:index = HashCode(Key) & (Length - 1)。这种计算方式本质上是利用位运算来替代传统的取模运算,因为计算机执行位运算的速度远快于除法取模运算。 以一个具体实例来说明,假设需要计算键"book"的索引位置。首先,"book"的哈希码计算结果为十进制的3029737,转换为二进制是101110001110101110 1001。当HashMap的初始长度为16时,Length - 1的结果是15,其二进制表示为1111。将这两个二进制数进行按位与运算:101110001110101110 1001 & 1111,由于15的二进制前导位都是0,这个运算实际上就是截取哈希码二进制的后四位,得到1001,即十进制的9,这就是该键在哈希表中的存储位置。这种设计确保了索引值始终落在数组范围内,同时利用2的次幂减一的特性(所有位均为1)使得哈希码的每一位都能参与索引计算,最大程度地保证数据分布的均匀性,既提高了计算效率,又维持了良好的哈希分布特性。 那为什么Java中的哈希表的初始长度为什么设为16(2的次幂)而非质数?主要基于性能优化和实际工程权衡的考虑。虽然质数作为哈希表长度在理论上能提供更好的分布均匀性,但HashMap采用了独特的索引计算方式`index = HashCode(Key) & (Length - 1)`,这种位运算在计算机底层的执行效率远高于传统的取模运算。当长度为2的次幂时,`Length - 1`的二进制形式恰好是一串连续的1(例如16-1=15,二进制为1111),这使得按位与运算能够高效地截取哈希码的低位作为索引,其效果等同于取模运算但速度更快。 HashMap通过精心设计的哈希函数来弥补非质数长度可能带来的分布缺陷,例如在Java 8中引入了树化机制,当链表长度超过阈值时会转换为红黑树,确保即使在冲突较多时性能也不会急剧下降。同时,扩容机制仍然保持长度为2的次幂,使得重新哈希时元素的新位置可以通过简单的位运算确定,大大提升了扩容效率。体现的是以空间换时间、以优化换均匀的设计哲学。 JavaScript中进行较大数据的位运算时容易会出问题,所以后续代码实现中还是使用了取模。另外,为了方便代码之后向开放地址法中迁移,容量还是选择使用质数。 如果我们来实现一个哈希函数,需要接收哪些参数,又返回哪些内容呢?现在由我们来设计一个哈希函数: (1)接收参数:需要转换的数据,数据长度的最大值限制。 (2)返回参数:索引值。 ```ts /** * 哈希函数, 将key映射成index * @param key 转换的key * @param max 数组的长度(最大的数值) * @returns 索引值 */ function hashFunc(key: string, max: number): number { } export default hashFunc ``` 通过以上代码我们完成哈希函数的初始化,接下来需要处理传入的数据,即使用霍纳法则将数据转为数字。需要经过以下3个步骤: (1)初始化hashCode。 (2)通过霍纳法则将传入的数据转为HashCode(加快计算速度)。 (3)对hashCode取模(缩减数字大小到数组长度)。 在4.5.1小节中,说明了hashCode(哈希码)是通过哈希函数计算得到的整数值,即单词案例中的将单词转为数字的中间结果,最后将中间结果进行取模得出最终结果。 ```ts /** * 哈希函数, 将key映射成index * @param key 转换的key * @param max 数组的长度(最大的数值) * @returns 索引值 */ function hashFunc(key: string, max: number): number { // 1. 初始化HashCode let hashCode = 0; // 2. 使用霍纳法则将数据转为数字 const length = key.length for (let i = 0; i < length; i++) { hashCode = 31 * hashCode + key.charCodeAt(i) } // 3. 对HashCode取模,返回最终结果 const index = hashCode % max return index } export default hashFunc ``` 代码中使用霍纳法则,我们一共循环具体乘法次数,每次都在原有基础上继续叠加相乘。并且在这里采用const length = key.length提前记录数据的长度,后续每次遍历就不需要重复的进行获取长度操作,而是直接获取已经计算好的长度。31实际为N次幂的底数,采用质数,实际情况不一定为31,根据实际需求决定。 然后根据以下测试函数来验证我们缩写的哈希函数是否正确无误。 ```ts // 测试哈希函数 // loadFactor(装填因子) = 4 / 7 = 0.57... console.log(hashFunc("coderwhy", 7)) // 0 console.log(hashFunc("XiaoYu", 7)) // 4 console.log(hashFunc("JavaScript", 7)) // 5 console.log(hashFunc("TypeScript", 7)) // 1 ``` ### 4.5.2 哈希表类创建 经过前面那么多内容的学习,我们现在可以真正实现自己的哈希表了。可能你学到这里的时候,已经感觉到数据结构的一些复杂性;但是如果你仔细品味,你也会发现它在设计时候的巧妙和优美;当你爱上它的那一刻,你也真正爱上了编程,爱上数据结构。 我们采用链地址法来实现哈希表:哈希表中的每个索引位置对应一个数组(称为桶,bucket)。需要注意的是,虽然也可以使用链表结构,但这里我们选择使用数组来实现每个桶。我们都已经实现过链表结构了,为什么不用链表作为桶呢?最主要的原因是不希望哈希表再去依赖一个从零实现的数据结构,尽量使用JavaScript已经提供给我们的数据结构,会更纯粹稳定。 每个桶中应存储什么内容?我们建议将键(key)和值(value)一并存入,为此,可以使用一个数组来存储每一组键值对(在其他编程语言中,使用元组可能是更合适的选择)。因此,最终形成的哈希表数据结构如下所示:哈希表的每个索引值指向一个桶(索引值实际是桶的内存地址),桶内包含若干键值对数组(元组),整体结构形如:[[ [k, v], [k, v], [k, v] ], [ [k, v], [k, v] ], [ [k, v] ]]。三层数组,第一层是哈希表,第二层是哈希表里的桶,第三层是桶内的每组键值对。哈希表结构组成如图4-10所示。  <p align="center"> <b>图4-10 哈希表构造组成</b> </p> 因此,如果创建一个哈希表类,我们需要定义三个属性: (1)storage作为我们的数组,数组中存放相关的元素。 (2)count表示当前已经存在了多少数据。 (3)length用于定义数组长度。 其中storage是作为哈希表的存在,其本身是三维数组,内部应该是一个二维数组(桶),桶内是以[key,value]的键值对形式存在(元组),其中key为string类型,value不固定则可以使用泛型交由开发者自主决定。count与length属性是必要的,是用于计算装填因子的,当装填因子达到0.75以上时,就可以对数组进行扩容,防止出现高频的聚集效应。 ```ts class HashTable<T = any> { // 创建一个数组, 用来存放链地址法中的链(数组) private storage: [string, T][][] = [] // 定义数组的长度 private length: number = 7 // 记录已经存放元素的个数 private count: number = 0 } const hashTable = new HashTable() ``` ### 4.5.3 插入与修改数据 哈希表的插入和修改操作是通过同一个函数进行,因为当使用者传入一个<Key,Value>时,如果原来不存在该key,那么就是插入操作;如果已经存在该key,那么就是修改操作。哦?为什么会是这样设计,难道不会导致功能耦合度过高吗?修改难道不是基于已有元素才修改的吗?如果功能重合在一起,我们自己也不知道这一操作下去是修改还是插入(新增)。 这种设计源于哈希表的本质特性——键的唯一性。在哈希表中,每个键都对应唯一的值,这就自然形成了一个"全有或全无"的操作语义:当我们向哈希表存入一个键值对时,系统不需要使用者预先声明这是新增还是修改,而是根据键是否已存在来自动决定操作类型。这种设计实际上降低了使用者的认知负担,因为使用者无需在调用前先检查键是否存在,也不必维护两套不同的操作逻辑。从使用体验来看,开发者只需关心"我希望这个键对应的值是什么",而不必纠结于"这个键是新增的还是已有的"这种实现细节。 更重要的是,这种合并操作在性能上具有显著优势。如果分开设计,先检查存在性再决定执行插入或修改,会导致两次哈希计算和查找过程。而合并操作只需一次哈希计算和一次查找就能完成,在底层实现上更加高效。许多哈希表实现还会通过返回值来告知使用者实际操作类型(如返回旧值或null),让使用者能够在需要时获知操作结果,这样就既保证了接口的简洁性,又提供了足够的信息透明度。这种设计哲学体现了"让常见用例简单"的理念,虽然在理论上存在功能耦合,但在实际工程中却被证明是更优解。 哈希表的这种设计很有意思,也提醒了我们一定不要重复出现键,保证键的唯一性是插入与修改操作能够通过同一个函数实现的关键。因此我们在实现插入与修改数据时,需要先检查一遍哈希表中有没有对应的键,有则覆盖,无则插入,从而避免出现第二个重复的键。 插入与修改数据的该方法为put()方法,应该接收一个键值对,然后将该键值对插入到桶中。 ```ts put(key: string, value: T) { } ``` 我们已经定义了storage属性(哈希表)了,但还不能直接将键值对数据插入到桶中(找不到对应的桶),因为哈希表中的每个索引值都指向一个桶,我们的键值对插入到桶中是需要经过霍纳法则计算出index索引值,从而决定插入的桶的位置。因此我们在实现put()方法时,需要先实现hashFunc()方法(哈希函数),将数据转为数字索引值。 hashFunc()方法应该是私有方法,计算索引值只作为内部计算使用,不应开放给使用者。 ```ts private hashFunc(key: string, max: number) { // 1.计算hashCode cats => 60337(27为底的时候) let hashCode = 0 const length = key.length for (let i = 0; i < length; i++) { // 霍纳法则计算hashCode hashCode = 31 * hashCode + key.charCodeAt(i) } // 2.求出索引值 const index = hashCode % max return index } ``` 然后我们可以继续来实现put()方法,需要实现以下3个步骤: (1)传入的数据通过哈希函数转为数字索引值,获取插入的桶位置。 (2)检查插入的桶内键值对的key值是否有与传入的键值对的key值重复。 (3)有重复的key值则覆盖原有数据,无重复的key值则在桶内插入数据。 ```ts // 插入/修改 put(key: string, value: T) { // 1.根据key获取数组中对应的索引值 const index = this.hashFunc(key, this.length) // 2.取出索引值对应位置的数组(桶) let bucket = this.storage[index] // 3.判断bucket是否有值 if (!bucket) { bucket = [] this.storage[index] = bucket } // 4.确定已经有一个数组了, 但是数组中是否已经存在key是不确定的 let isUpdate = false for (let i = 0; i < bucket.length; i++) { // 获取元组 const tuple = bucket[i] // 获取元组中的key值 const tupleKey = tuple[0] // 比对桶内key值与传入键值对的key值是否冲突 if (tupleKey === key) { // 修改/更新的操作,key值冲突则覆盖元组的value tuple[1] = value // 判断key值已经冲突修改过,后续不再执行插入操作 isUpdate = true } } // 5.如果上面的代码没有进行覆盖, 那么在该位置进行添加 if (!isUpdate) { bucket.push([key, value]) // 哈希表中的存放元素加1 this.count++ } } ``` 在检查插入的桶位置的key是否有与数据重复之前,我们需要先确认桶本身是否存在。因为哈希表本身是由数组构成,而初始化的数组内部都默认由undefined的构成,undefined是无法插入[key,value]数据的,因此根据key值获取到对应的索引值后,需要顺着索引值先去判断桶是否为undefined,若为undefined则创建桶(创建数组或者链表,这里创建的是数组)。桶存在之后,将[key,value]插入桶之中。 这里有一处思考,我们是否要在判断桶不存在之后,创建桶的同时,将数据直接插入?答案是不要这么做,因为这是后续往桶内插入或者修改数据的职能,如果在创建桶的同时进行插入,那么后续的插入与覆盖就会判断出桶内已经有对应的key了,多走一步覆盖操作,虽然结果是不变的,但步骤却多了。 遍历桶内数组(元组),提取其中每一对键值对的key值来与插入键值对的key值比对,若key值相同则执行覆盖操作。若未执行覆盖操作(key值未冲突),则往桶内push该键值对实现插入操作。 以上为put()方法的实现,通过以下测试用例判断是否可行。 ```ts const hashTable = new HashTable() hashTable.put("aaa", 100) hashTable.put("aaa", 200) hashTable.put("bbb", 300) ``` ### 4.5.4 获取与删除数据 获取数据即通过键值对的key值获取到value值,我们创建get()方法,应具备key值参数,返回value值;若哈希表内无该key值(无法获取value值),则返回undefined或者-1。 ```ts // 获取值 get(key: string): T | undefined { } ``` 获取value值过程为以下3步: (1)将key值通过哈希函数转为数字索引值 (2)通过数字索引值锁定key值所对应的桶。 (3)遍历桶内的元组,比对元组的key值与传入的key值,锁定对应的value值并返回。 遍历桶内的元组是顺序查找,但由于均匀分布以及通过装填因子扩容,所以同一个桶内的元组数量是不会多的,顺序查找的损耗可接近忽略不计。 ```ts get(key: string): T | undefined { // 1.根据key获取索引值index const index = this.hashFunc(key, this.length) // 2.获取bucket(桶) const bucket = this.storage[index] if (!bucket) return undefined // 3.对bucket进行遍历 for (let i = 0; i < bucket.length; i++) { const tuple = bucket[i] const tupleKey = tuple[0] const tupleValue = tuple[1] if (tupleKey === key) { return tupleValue } } return undefined } ``` get()方法对应的测试代码如下。 ```ts console.log(hashTable.get('bbb')); ``` delete()删除数据方法需要根据键值对所对应的key值,删除对应的键值对。思路与获取数据类似,遍历桶内元组,对key值进行判断,找到对应目标后,调用Array.prototype.splice()实例方法删除目标对象,然后count属性减1。 ```ts // 删除操作 delete(key: string): T | undefined { // 1.获取索引值的位置 const index = this.hashFunc(key, this.length) // 2.获取bucket(桶) const bucket = this.storage[index] if (!bucket) return undefined // 3.遍历桶数组 for (let i = 0; i < bucket.length; i++) { const tuple = bucket[i] const tupleKey = tuple[0] const tupleValue = tuple[1] if (tupleKey === key) { bucket.splice(i, 1) this.count-- return tupleValue } } return undefined } ``` 测试用例可结合get()方法。即删除目标元组后(会返回删除的目标结果),使用get()方法获取目标元组。对比get()返回结果判断delete()方法效果是否生效。 ### 4.5.5 哈希表扩容机制 目前哈希表的默认长度为质数7,我们是将所有的数据项放在长度为7的数组中的,因为我们使用的是链地址法,loadFactor可以大于1,所以这个哈希表可以无限制的插入新数据。但是,随着数据量的增多,每一个index对应的bucket会越来越长,也就造成效率的降低。所以需要在合适的情况对数组进行扩容,例如扩容两倍。 那么如何进行扩容?我们可以将哈希表容量简单的增大两倍(难道增大后不应该继续为质数吗?在4.6.1小节进行优化),但是这种情况下,所有的数据项一定要同时进行修改(重新调用哈希函数,来获取到不同的位置),比如hashCode=12的数据项,在length=8的时候,index=4。在长度为16的时候呢?index=12。扩容所导致的所有数据项同时进行修改是一个耗时的过程,但是如果数组需要扩容,那么这个过程是必要的。 什么情况下应当进行扩容操作?比较常见的是当装填因子大于0.75时,我们希望哈希表自动扩容,从而消除聚集效果,保持哈希表的高性能。 要如何实现扩容机制?我有一个想法,每一次count属性加1变动时,就计算一次装填因子,当装填因子大于0.75时就进行扩容操作。而扩容操作需要实现以下两点: (1)哈希表数组容量翻倍(长度*2)。 (2)对哈希表原有的所有数据重新哈希化后存放到正确位置。 由于哈希函数是通过哈希表长度来取余,因此当哈希表长度变化时,通过哈希函数计算的位置也会全部发生变化。但除了扩容,我们也可能缩容,因此步骤2中对哈希表原有的所有数据重新哈希化所基于的哈希表长度不应该是固定的,我们可以实现一个动态扩容缩容的resize()方法,该resize()方法传入扩容缩容后的哈希表所应具备的长度,并搭建临时数组存储扩容前的哈希表数据,再对原哈希表进行初始化并重新构建。 ```ts private resize(newLength: number) { // 设置新的长度 this.length = newLength // 获取原来所有的数据, 并且重新放入到新的容量数组中 // 1.对数据进行初始化操作 const oldStorage = this.storage this.storage = [] this.count = 0 // 2.获取原来数据, 放入新的数组中 oldStorage.forEach(bucket => { if (!bucket) return for (let i = 0; i < bucket.length; i++) { const tuple = bucket[i] this.put(tuple[0], tuple[1]) } }) } ``` resize()方法本质上是将原有哈希表迁移到新哈希表之中,但由于哈希表本身存在固定属性storage,因此我们并没有创建新的哈希表,而是将原哈希表备份之后,将原哈希表直接初始化清空作为新哈希表。重置新的哈希表长度之后,对新哈希表进行put()插入原哈希表的元组键值对数据操作。当resize()方法执行结束后,原哈希表备份会因临时变量而被直接垃圾回收。 当实现resize()方法之后,我们只需要判断装填因子大于0.75时,调用resize()方法,传入当前哈希表长度*2的数据即可完成扩容。而判断装填因子的时机正如我们一开始的想法,在count属性加1的时候,即put(()方法之中的插入数据操作部分。 ```ts // 5.如果上面的代码没有进行覆盖, 那么在该位置进行添加 if (!isUpdate) { bucket.push([key, value]) this.count++ // 发现loadFactor比例已经大于0.75, 那么就直接扩容 const loadFactor = this.count / this.length if (loadFactor > 0.75) { this.resize(this.length * 2) } } ``` 那么缩容也是同样的思路,当delete()方法删除数据时,count属性会不断的减1,这时候我们可以重新计算装填因子,当装填因子小于0.25的时候,将哈希表长度折半,且哈希表长度最短为7。且由于长度折半的话,是有可能出现无法整除的情况的,因此我们调用Math.floor()静态方法用于返回小于等于一个给定数字的最大整数。PS:使用Math.trunc()静态方法将数字的小数部分去掉,只保留整数部分也可以。 ```ts // 删除操作 delete(key: string): T | undefined { // 1.获取索引值的位置 const index = this.hashFunc(key, this.length) // 2.获取bucket(桶) const bucket = this.storage[index] if (!bucket) return undefined // 3.遍历桶数组 for (let i = 0; i < bucket.length; i++) { const tuple = bucket[i] const tupleKey = tuple[0] const tupleValue = tuple[1] if (tupleKey === key) { bucket.splice(i, 1) this.count-- // 如果loadFactor小于0.25, 缩容操作 const loadFactor = this.count / this.length if (loadFactor < 0.25 && this.length > 7) { this.resize(Math.floor(this.length / 2)) } return tupleValue } } return undefined } ``` 以上就是哈希表实现扩容与缩容的操作,核心是每次在count属性发生变化的时候重新计算装填因子,并立刻对装填因子进行判断操作,当装填因子达到临界点后,对哈希表进行扩容或者缩容操作。 ## 4.6 哈希表优化 在完成哈希表的基本功能之后,我们可以对哈希表实现进行优化处理。这里存在一个很重要的技巧,当我们去实现一项功能时,优先度最高的是先将功能实现,然后再去考虑迭代优化。因此我们虽然知道扩容或者缩容是可以考虑使用质数的,但质数是不规律的,远没有直接将哈希表长度翻倍或者折半简单,因此在4.5.5小节中,我们并没有直接通过质数来实现扩容缩容。在接下来,我们会基于原有哈希表进行优化处理,也包括了使用质数扩容缩容。 ### 4.6.1 质数判断算法 虽然在链地址法中将容量设置为质数,没有在开放地址法中重要,但是其实链地址法中质数作为容量也更利于数据的均匀分布。所以,我们还是完成一下这个步骤。那么我们要如何实现质数的扩容?最容易想到的是预定义质数表,提前写死一份包含能运用上的所有质数。每次扩容缩容时,直接从预定义质数表中拿到所需的质数。但我们所实现的哈希表想抽象成足够弹性的工具,就不能预定义质数表,因为我们无法预估使用者会将哈希表长度延长到什么程度。 实现质数扩容前,可以先讨论一个常见的面试题:如何判断一个数是质数?质数也称为素数,表示大于1的自然数中,只能被1和自己整除的数。那这其实可以看作一道数学题,对传入的数字从2开始整除,整除到自己之前最大的整数为止,如果传入的数字可以被整除则不是质数,反之是质数。 ```ts /** * 根据传入的数字, 判断是否是一个质数 * @param num 要判断的数字 * @returns 是否是一个质数 */ function isPrime(num: number): boolean { // 质数的特点: 只能被1和num整除 // 如果传入的是8 // 2~7 for (let i = 2; i < num; i++) { if (num % i === 0) { return false } } // 2~7都遍历完成后, 依然是没有返回false return true } // 测试用例 console.log(isPrime(8)) console.log(isPrime(14)) console.log(isPrime(15)) console.log(isPrime(17)) console.log(isPrime(23)) export {} ``` ### 4.6.2 容量质数优化 我们实现了isPrime()方法用于判断传入数字是否为质数,但该方法的效率并不高,还存在着性能提升的空间,对于isPrime()方法来说,每个数n都需要从2判断到n-1。假如我们需要判断135123是否为质数,是否意味着我们需要执行十几万次循环判断?这判断性能的损耗是否过大了?而如果只为了判断小数字的质数,还不如直接使用预定义质数表。 实际上并不需要判断这么多次,判断一个大数是否为质数时,确实不需要遍历所有可能的因数,只需要检查到该数的平方根即可。这种方法基于一个重要的数学原理:如果一个数n不是质数,那么它一定可以分解为两个因数a和b,即n = a * b。此时,a和b中必然有一个小于或等于√n,另一个大于或等于√n。这意味着,如果n存在任何因数(除了1和它本身),那么这些因数中至少有一个不会超过√n(后续部分其实都是√n以内的倍数)。 因此,在判断一个数是否为质数时,我们只需要检查从2到√n之间的整数是否能整除n即可。如果在这个范围内找不到任何因数,那么n就是质数;反之,如果在2到√n之间找到了任何一个能整除n的数,那么n就不是质数。这种优化方法将时间复杂度从O(n)降低到了O(√n),对于大数判断来说性能提升是巨大的。 比如我们提到的135123为例,使用原始方法需要执行约13万次循环判断,而采用平方根优化后,只需要检查到约367(√135123的整数部分)即可,循环次数减少了99%以上。这种优化对于大数质数判断至关重要,使得我们能够在合理时间内完成大规模数据的质数检测任务。 Math.sqrt()静态方法可以返回一个数的平方根,我们只需要遍历判断到Math.sqrt(传入数字)即可,在原有代码基础上更改如下。 ```ts /** * 根据传入的数字, 判断是否是一个质数 * @param num 要判断的数字 * @returns 是否是一个质数 */ function isPrime(num: number): boolean { // 质数的特点: 只能被1和num整除 // 11是否是一个质数 // 平方根 3.xxx // 循环次数: 2~10 // 16 = 2x8 // 16 = 4x4 const sqrt = Math.sqrt(num) for (let i = 2; i <= sqrt; i++) { if (num % i === 0) { return false } } // 2~7都遍历完成后, 依然是没有返回false return true } console.log(isPrime(8)) console.log(isPrime(14)) console.log(isPrime(15)) console.log(isPrime(17)) console.log(isPrime(23)) export {} ``` 通过该重要的数学原理,我们实现了大数的质数判断,当判断的质数越大,性能提升的效果就越显著,因此该做法可以完全超越僵板的预定义质数表 ### 4.6.3 完整哈希表实现 我们的优化目的是扩容哈希表长度时,是以质数为标准,那为什么要持续的去完成一个判断质数的方法并持续的优化呢? 因此动态的扩容缩容哈希表长度,我们更希望的是在哈希表长度翻倍或者折半的基础上,去拿到一个最近的质数来作为实际的扩容缩容哈希表长度。那么假如我们现在想要扩容,完全可以将哈希表长度直接翻倍(翻倍后的数字本身一定不是质数),然后加1判断是否为质数,返回false就继续对数字加1,直到数字为质数为止。将该质数在装填因子大于0.75时传入resize()方法中。缩容的原理同理。 将以上思路实现成一个getNextPrime()方法,接收一个数字,返回该数字往上最近的一个质数。 ```ts private getNextPrime(num: number) { let newPrime = num while (!this.isPrime(newPrime)) { newPrime++ } return newPrime } ``` 然后将getNextPrime()方法运用在扩容与缩容的put()方法和delete()方法中的判断装填因子临界点部分。完整哈希表实现代码如下: ```ts class HashTable<T = any> { // 创建一个数组, 用来存放链地址法中的链(数组) storage: [string, T][][] = [] // 定义数组的长度 private length: number = 7 // 记录已经存放元素的个数 private count: number = 0 private hashFunc(key: string, max: number) { // 1.计算hashCode cats => 60337(27为底的时候) let hashCode = 0 const length = key.length for (let i = 0; i < length; i++) { // 霍纳法则计算hashCode hashCode = 31 * hashCode + key.charCodeAt(i) } // 2.求出索引值 const index = hashCode % max return index } isPrime(num: number): boolean { const sqrt = Math.sqrt(num) for (let i = 2; i <= sqrt; i++) { if (num % i === 0) { return false } } return true } private getNextPrime(num: number) { let newPrime = num while (!this.isPrime(newPrime)) { newPrime++ } return newPrime } private resize(newLength: number) { // 设置新的长度 let newPrime = this.getNextPrime(newLength) if (newPrime < 7) newPrime = 7 this.length = newPrime // 获取原来所有的数据, 并且重新放入到新的容量数组中 // 1.对数据进行初始化操作 const oldStorage = this.storage this.storage = [] this.count = 0 // 2.获取原来数据, 放入新的数组中 oldStorage.forEach(bucket => { if (!bucket) return for (let i = 0; i < bucket.length; i++) { const tuple = bucket[i] this.put(tuple[0], tuple[1]) } }) } // 插入/修改 put(key: string, value: T) { // 1.根据key获取数组中对应的索引值 const index = this.hashFunc(key, this.length) // 2.取出索引值对应位置的数组(桶) let bucket = this.storage[index] // 3.判断bucket是否有值 if (!bucket) { bucket = [] this.storage[index] = bucket } // 4.确定已经有一个数组了, 但是数组中是否已经存在key是不确定的 let isUpdate = false for (let i = 0; i < bucket.length; i++) { const tuple = bucket[i] const tupleKey = tuple[0] if (tupleKey === key) { // 修改/更新的操作 tuple[1] = value isUpdate = true } } // 5.如果上面的代码没有进行覆盖, 那么在该位置进行添加 if (!isUpdate) { bucket.push([key, value]) this.count++ // 发现loadFactor比例已经大于0.75, 那么就直接扩容 const loadFactor = this.count / this.length if (loadFactor > 0.75) { this.resize(this.length * 2) } } } // 获取值 get(key: string): T | undefined { // 1.根据key获取索引值index const index = this.hashFunc(key, this.length) // 2.获取bucket(桶) const bucket = this.storage[index] if (!bucket) return undefined // 3.对bucket进行遍历 for (let i = 0; i < bucket.length; i++) { const tuple = bucket[i] const tupleKey = tuple[0] const tupleValue = tuple[1] if (tupleKey === key) { return tupleValue } } return undefined } // 删除操作 delete(key: string): T | undefined { // 1.获取索引值的位置 const index = this.hashFunc(key, this.length) // 2.获取bucket(桶) const bucket = this.storage[index] if (!bucket) return undefined // 3.遍历桶数组 for (let i = 0; i < bucket.length; i++) { const tuple = bucket[i] const tupleKey = tuple[0] const tupleValue = tuple[1] if (tupleKey === key) { bucket.splice(i, 1) this.count-- // 如果loadFactor小于0.25, 缩容操作 const loadFactor = this.count / this.length if (loadFactor < 0.25 && this.length > 7) { this.resize(Math.floor(this.length / 2)) } return tupleValue } } return undefined } } const hashTable = new HashTable() // length: 7 // count: 8 // loadFactor: 8 / 7 = 1.1xxxxx hashTable.put("aaa", 100) hashTable.put("aaa", 200) hashTable.put("bbb", 300) hashTable.put("ccc", 400) hashTable.put("abc", 111) hashTable.put("cba", 222) console.log(hashTable.storage) hashTable.put("nba", 333) hashTable.put("mba", 444) console.log(hashTable.storage) // 如果loadFactor > 0.75进行扩容操作 hashTable.delete("nba") hashTable.delete("mba") hashTable.delete("abc") hashTable.delete("cba") hashTable.delete("aaa") console.log(hashTable.storage) export default HashTable ``` 哈希表的理论与实现就到此为止,下一章我们会开始学习数据结构与算法中的树的知识。
第3章 链表
## 3.1 链表基础与特性 在第2章所实现的三种线性结构:数组、栈,队列。我们好像都没怎么写代码,只不过给JavaScript数组的方法重新套了一层壳(添加限制)。虽然也理解这三种数据结构特性并清楚对应的应用场景,但依旧很难有足够的成就感(并没有从零实现数据结构),但接下来的链表学习中,我们会从零实现,并且不利用数组等现成数据,而是利用语言本身的特性(类、引用、指针等等)来实现链表结构。 ### 3.1.1 数组的缺点 要存储多个元素,数组(或选择链表)可能是最常用的数据结构,在第2章我们有说过,几乎每一种编程语言都有默认实现数组结构。但数组也有很多缺点,在2.1.2小节中有说明,数组中插入元素会导致后续所有元素都需要向后移动,产生大量数据搬迁的开销,因此在数组的开头或者中间位置插入数据的成本很高。 而且数组的创建通常需要申请一段连续的内存空间(一整块的内存),并且大小是固定的(大多数编程语言数组都是固定的),所以当目前数组不能满足容量需求时,需要扩容(一般情况下是申请一个更大的数组,比如2倍。 然后将原数组中的元素复制过去)。 但在JavaScript中使用数组上好像不是固定的,开发者可以创建一个数组,然后随时往数组内添加数据,从这一角度来看,数组的大小并不是固定的,而是由开发者决定填入数据来决定。数组实际大小由开发者决定是一个错觉,开发者判断数组大小的方式是通过数组内的元素个数,而不是内存空间,这是视角上的问题。因为数组已经被JavaScript封装起来了,所以开发者是感知不到数组底层内存的变化的,而JavaScript数组底层依旧是申请一段连续固定大小的内存空间,元素个人达到临界点后就扩容。 由于JavaScript数组实际大小由内存空间决定,因此数组是可以在连续的内存空间中间选择空着不填入任何元素,这并不会导致后续元素往前列空余槽进行填补操作。空着的内存槽会一直空着,位置也会一直占据着,这被称为空槽,有空槽的数组被称为稀疏数组。稀疏数组在面对迭代方法时会被直接跳过,与用undefined值填充的槽不一样。 ```js const names = [] // 开发者感知的数组大小是逻辑长度,引擎管理的是物理容量 names.push("小余") ``` JavaScript数组底层扩容原理: (1)检查容量:当 length >= capacity 时触发扩容。 (2)计算新容量:通常按一定比例增长(常见策略:newCapacity = oldCapacity * 1.5 + 16)。 (3)分配新内存:申请更大的连续内存空间。 (4)复制元素:将旧数组元素复制到新内存。 (5)更新引用:将内部指针指向新内存块。 在前面说明了数组存在稀疏数组的情况,而没有空槽的数组被称为密集数组。两种不同类型的数组有不同的存储策略,其中密集数组采用连续内存存储。但极度稀疏的数组(空闲内存位置一直被占据)会导致内存的浪费,因此现在JavaScript引擎的V8引擎面对该情况会采用更复杂的策略,即转为哈希表存储,哈希表会在第4章学习。 尽管JavaScript的Array底层可以帮我们做申请内存空间以及搬迁元素等事,不需要我们手动去操作,但数组背后的原理依然是这样。 ### 3.1.2 链表的定义与优势 要存储多个元素,另外一个选择是链表。链表不同于数组,链表中的元素在内存中不必是连续的空间。链表的每个元素由一个存储元素本身的节点和一个指向下一个元素的引用(有些编程语言称为指针或者链接)组成。 相对于数组,链表有以下3个优点: (1)内存空间不是必须连续的,可以充分利用计算机的内存,实现灵活的内存动态管理。 (2)链表不必在创建时就确定大小,并且大小可以无限的延伸下去。 (3)链表在插入和删除数据时,时间复杂度可以达到O(1),链表相对数组效率高很多。 但相对于数组,链表也有以下两个缺点: (1)链表访问任何一个位置的元素时,都需要从头开始访问。(无法跳过第一个元素访问任何一个元素)。 (2)无法通过下标直接访问元素,需要从头开始一个个访问,直到找到对应的元素。 ### 3.1.3 链表究竟是什么? 在图2-2的数组与链表对比中,我们简单的提过了链表的结构,大致了解链表的形象,但在此处会更详细的说明。 链表类似于火车:有一个火车头,火车头会连接一个节点,节点上有乘客(类似于数据),并且这个节点会连接下一个节点,以此类推,链表组成三部分如图3-1所示。  <p align="center"> <b>图3-1 链表组成三部分</b> </p> 链表主要由以下3部分组成: (1)链节点(Node):链表的基本单元,每个节点包含数据(Data)与指针(Pointer)。 - 数据(Data):存储实际的数据,可以是任意类型,通常以指代称呼为item。 - 指针(Pointer):指向下一个节点(在双向链表中还包括指向前一个节点的指针),通常称呼为next指针。 (2)头节点(Head):指向链表的第一个节点。通过头节点可以遍历整个链表。 (3)尾节点(Tail):链表的最后一个节点。在单向链表中,尾节点的指针通常指向null(或None等,表示空)。在双向链表中,尾节点的下一个指针为null,同时它还有一个指向前一个节点的指针。 链表由节点组成,我们根据位置因素将链表区分为三部分组成,即头节点、链节点以及尾节点。最需要注意的是头节点与尾节点: - 头节点:不存储实际数据,只存储指向第一个链节点的引用。 - 尾节点:与链节点一致,也存储实际数据,特殊点在于尾节点的next指针指向null,表示链表结束。 链表对应信息获取在代码中的表现形式如下: ```ts // 当前节点 node // 下一个节点 node.next // 下一个节点的值 node.next.item ``` ## 3.2 链表的实现与封装 根据3.1小节了解了链表的基础特性,如果我们也想要自己实现一个链表,要从哪里入手?我觉得先把思路理清楚,把步骤列出来后再来实现会更好。 首先,已知链表由三部分组成,其中头节点,链节点与尾节点的本质是一样的,由数据+指针组成。而数据+指针的组合被称为节点,因此我们需要一个类来封装节点,实现快速重复创建节点,其中数据与指针都应该允许存在null的情况,因为头节点与尾节点分别在数据与指针分别为null。 其次,我们需要将链表串联起来,如果我手动一个个的创建节点,再手动的将节点们串联起来,这其中会多出很多重复的代码工作量,节点越多,串联链表的人工成本就会愈发沉重,这不符合封装抽象的计算机思想,因此还需要一个类,这个类要负责自动化组建链表,我们只需要将数据传递进去,该类就会负责将节点们联系在一起成为链表。 思路理清楚了,我们需要两个类,分别是节点类与链表类。 根据以上思路,我应该做出以下3个步骤: (1)封装两个类。封装一个Node类,用于封装每一个节点上的信息(包括值和指向下一个节点的引用),它是一个泛型类;封装一个LinkedList类,用于表示我们的链表结构。 (和Java中的链表同名,不同Java中的这个类是一个双向链表,在第7章中我们也会实现双向链表结构)。 (2)结合两个类的联动。链表中我们保存两个属性,一个是链表的长度,一个是链表中第一个节点。在链表类中实现将节点组成链表的方法。 (3)实现链表后,实现链表身上的常见操作方法。 ### 3.2.1 节点类的封装 节点类如何封装?一个节点有数据和指针两个数据,因此我们以value和next两个属性来表示对应数据。 ```ts // 创建Node节点类 class Node<T> { value: T next: Node<T> | null=null } export {} ``` 我们知道在JS或者TS中,复杂数据类型是引用传递的,因此Node<T>实际只是一个引用(内存地址),也就是指针,指向下一个节点,当我们不再创建新的Node类之后,最后节点的next指针就无法指向到新的节点,而是指向null(初始化值)。PS:Node<T> 在类型层面表示"Node 类型的实例"。 ```ts 内存地址: 0x1000 内存地址: 0x2000 +-------------+ +-------------+ | value: 1 | | value: 2 | | next: -------->------| next: null | +-------------+ +-------------+ node1 node2 ``` 目前next属性有初始化值null,value属性是没有初始化值的(类属性必须要有初始化值),可value属性要什么初始化值呢?要设为null吗?但next属性可以固定为 null,因为它表示"没有下一个节点"的概念是通用的。可value属性的类型是泛型T,可能是任何数据类型,没有通用的"空值"能适配所有类型,链表节点的核心就是存储特定类型的值,这个值必须由使用者提供才有意义,因此必须通过构造函数来接收具体的值,而不是预设固定值。 无论什么初始化值都不合适,无法应对所有的情况,因此我们将决定权交给使用节点的开发者。我们使用构造函数,要求开发者在使用时需要传入一个值进来,在传入值的同时,也设置了对应的泛型或者由编辑器自动推导类型。 ```ts // 创建Node节点类 class Node<T> { value: T next: Node<T> | null = null constructor(value: T) { this.value = value } } export { } ``` ### 3.2.2 链表类的创建 接下来我们创建LinkedList的类(链表类),在3.2.1的节点创建中,未涉及到头节点,因为头节点不在数据+指针形式的节点范围内。头节点是链表类中很重要的属性,其接收值就是节点的内存地址。由于类的属性必须赋值,因此我们为头节点设置null的默认值,当未使用头节点,默认值就为null。 有时候会求链表的长度,因此我们添加一个size属性。 ```ts class LinkedList<T> { private head: Node<T> | null = null private size: number = 0 } ``` 到目前为止,节点类与链表类都创建完成,但目前链表类并不完整,因为目前的链表类实际上只等于头节点。缺少了将头节点与链节点和尾节点组合成链表的实例方法。 ## 3.3 链表常见操作方法 接下来我们在链表类中实现将节点组成链表的实例方法。链表的本质是以头节点为主,往后不断追加节点所形成的链条。因此链表的形成实际指追加节点的方法。 - append(value):向链表尾部添加一个新的项。PS:将节点组成链表的方法,即追加节点方法。 除此之外,我们还需要实现链表身上的一些常见操作方法(实例方法),包括以下8点: (1)insert(position,value):向链表的特定位置插入一个新的项。 (2)get(position) :获取对应位置的元素 (3)indexOf(value):返回元素在链表中的索引。如果链表中没有该元素则返回-1。 (4)update(position,value) :修改某个位置的元素。 (5)removeAt(position):从链表的特定位置移除一项。 (6)remove(value):从链表中移除一项。 (7)isEmpty():如果链表中不包含任何元素,返回true,如果链表长度大于0则返回false。 (8)size():返回链表包含的元素个数。与数组的length属性类似。 整体我们发现操作方法和数组非常类似,因为链表本身就是一种可以代替数组的结构。 ### 3.3.1 追加节点方法 缺乏追加节点方法会导致LinkedList类无法使用,无法传入节点,就会令头节点无意义(无法指向第一个链节点),size属性也一直为0无法利用,此时哪怕将head属性与size属性暴露出去,也缺乏使用价值。 ```ts const linkedList = new LinkedList<string>() // linkedList.xxx() 没有方法调用 ``` 因此我们需要立刻实现追加节点方法,后续链表所有常见操作方法都需要建立在有链表的基础上。 追加节点方法实现步骤如下: (1)创建append()实例方法,能够传入节点参数。 (2)在方法中向链表尾部追加数据。 向链表尾部追加数据可能有两种情况: (1)若链表本身为空,那此时需要添加头节点。 (2)若链表不为空,则需要向尾节点后面追加节点。 向链表尾部追加数据的第1种情况是需要我们去判断的,而第二种情况则与数组的Array.prototype.push()实例方法类似,只不过不需要返回数据。此时需要注意,我们链表是从零实现,而不是基于数组实现的,因此我们没办法直接往append()实例方法中套壳使用数组的push()实例方法。但这不正是我们所期待的吗?由我们自己来实现,所获得的成就感一定会更让人满足。 现在先来处理情况1,判断链表本身是否为空,决定是否添加头节点。但是,添加头节点是有前置条件的,头节点只有在传入第一个链节点后才有价值,即头节点指向第一个链节点。 所以我们需要第一个链节点,即创建 Node 类的一个实例对象,然后将该Node类的实例对象newNode赋值给头节点,即头节点获得了newNode实例对象的内存地址,头节点指向了newNode实例对象。 ```ts // 追加节点方法 append(value: T) { const newNode = new Node(value) this.head = newNode } ``` 接下来我们需要添加限定判断,头节点指向第一个链节点只有在链表为空时才进行,否则会导致后续节点不断地赋值给头节点。但我们要如何判断链表为空?当不再以数组为基底,失去数组自带的方法后,我们就需要"赤手空拳"去面对这些情况。 在链表中,我们需要一个类似数组下标的东西来锁定链表的位置。这是一个很好的思路,不过暂时还不需要。判断链表为空是可以通过判断头节点是否为null来操作的。因为链表的唯一入口就是头节点引用。 - 如果head属性为null,说明没有任何节点存在。 - 如果head属性不为null,说明至少存在一个节点。 ```ts // 追加节点方法 append(value: T) { const newNode = new Node(value) // 判断头节点为空 if (!this.head) this.head = newNode } ``` 接下来处理向链表尾部追加数据(节点)的第2种情况,想做到正确追加数据,我们需要能够拿到链表的最后一个节点的位置。在数组中可以使用Array.prototype.at(-1)或者length-1来实现。如果交由我们来实现,要怎么做? 判断是否为链表的最后一个节点,实际是在判断尾节点,因此需要判断next属性是否为null就行。无论是通过while循环语句还是使用递归来找到尾节点都可以,但这会造成性能的损耗,尤其是递归有栈溢出的风险。 我们暂时使用while循环语句来实现: 使用临时变量current记录头节点指针信息,然后通过while循环语句不断的执行查找下一个节点,直到节点的next属性为null时,说明已经找到最后一个节点。将最后一个节点指向新的节点,完成节点追加。节点追加原理如图3-2所示。  <p align="center"> <b>图3-2 节点追加原理</b> </p> 最后,每追加一次节点,我们就将size属性自增1,确保能获取到链表的长度。 ```ts // 追加链表节点方法 append(value: T) { const newNode = new Node(value) if (!this.head) { this.head = newNode } else { let current = this.head // 寻找链表的最后一个节点 while (current.next) { current = current.next } // 追加节点 current.next = newNode } this.size++ } ``` 追加节点操作是链表最频繁的操作之一,因此我们需要从优化的角度去考虑,维护一个临时的指针就很有必要了。我们维护一个tail指针作为链表的属性,这样在追加节点时就不需要每次遍历整个链表。非空链表时直接通过tail指针操作,时间复杂度从 O(n) 降为 O(1)。每次追加后更新tail指针,确保它始终指向链表尾部。 该优化操作主要分两步: (1)将尾节点指向新节点,实现节点追加。 (2)更新tail指针,指向新的尾节点。 节点追加优化原理如图3-3所示。  <p align="center"> <b>图3-3 节点追加优化原理</b> </p> ```ts class Node<T> { value: T next: Node<T> | null = null constructor(value: T) { this.value = value } } class LinkedList<T> { private head: Node<T> | null = null private tail: Node<T> | null = null; // 新增尾指针 private size: number = 0 get length() { return this.size } // 追加节点方法 append(value: T) { const newNode = new Node(value) if (!this.head) { // 空链表:头尾指针都指向新节点 this.head = newNode; this.tail = newNode; } else { // 非空链表:通过尾指针直接追加,无需遍历 this.tail!.next = newNode; // 当前尾节点指向新节点 this.tail = newNode; // 更新尾指针为新节点 } this.size++ } } // 测试代码 const linkedList1 = new LinkedList<string>() linkedList1.append("aaa") linkedList1.append("bbb") linkedList1.append("ccc") export { } ``` 该优化做法完美利用指针特性, this.tail!.next = newNode做到修改当前尾节点的next属性,将null修改为新添节点,实现节点追加。精髓之处在于this.tail = newNode的做法,由于newNode包含一整个节点,即value属性和next属性,因此复杂数据类型存储在堆空间中,赋值给this.tail的是一个内存地址,而不断的替换内存地址并不会实际修改该内存地址所对应的堆空间中的数据,因此替换内存地址的过程与图3-2所示的移动current变量是一样的,区别在于优化做法不需要每次都重新重头遍历而已。 ```ts 执行前: tail → [节点C] (对象地址: 0x1000) [节点C].next = null 执行 this.tail!.next = newNode; 后: tail → [节点C] (地址: 0x1000) [节点C].next → [新节点D] (地址: 0x2000) ← 修改的是节点对象的属性 执行 this.tail = newNode; 后: tail → [新节点D] (地址: 0x2000) ← 修改的是LinkedList类的属性 [节点C].next 仍然指向 [新节点D] (地址: 0x2000) ``` 以上是追加节点的写法以及优化方法,通过追加节点的方法,链表已经成型。接下来我们开始实现链表应该具备的方法吧、 ### 3.3.2 遍历链表方法 在实现追加节点方法后,我们发现了无法查看链表效果,这令我们苦恼,怎么看效果对不对呢?这时候就需要一个遍历链表的方法,遍历的过程中将所有的节点数据打印到控制台中。这个想法不错,并且在刚才的追加节点方法中,我们已经实现过了。 设置临时指针current,起始位置为头节点,沿着链表的next属性指针不断遍历调用,直到尾节点为止,在遍历过程顺便把节点的value属性打印到控制台中。 ```ts // 遍历链表的方法 traverse() { let current = this.head while (current) { console.log(current.value); current = current.next } } ``` 遍历链表方法效果如图3-4所示。  <p align="center"> <b>图3-4 遍历链表方法效果</b> </p> 以上打印效果是一行一个节点数据,当节点数据一多,这种一行打印一节点的效果就不太直观,比较浪费显示的空间。那我们只需要将每次遍历的value属性存入数组中,然后直接打印数组就行,想要其他效果可以使用数组的拼接方式,根据实际情况决定就行。 ```ts // 遍历链表的方法 traverse() { let current = this.head let arr = [] while (current) { arr.push(current.value) current = current.next } // 数组展示形式 console.log(arr); // 数组拼接形式 console.log(arr.join(', ')); // 其余拼接形式 console.log(arr.join(' -> ')); } ``` 遍历链表效果展示形式如图3-5所示。  <p align="center"> <b>图3-5 遍历链表效果展示形式</b> </p> 有了traverse()遍历链表方法,后续就能测试其余链表方法的效果变化,进而去纠正不正确的地方。 ### 3.3.3 插入节点方法 绝大多数的操作数据方式离不开四个字:增删改查。对应了添加新节点、移除已有节点、修改已有节点以及查询节点。每种方式都可延伸出不少方法。例如添加新节点,添加在尾节点后,那是3.3.1实现的追加节点方法,添加在任意地方,是我们即将实现的插入节点方法。而想要添加在任意地方,就需要定位对应的位置。我们能通过size属性或者节点的具体value属性来实现,这对应了两种不同思路。 插入与替换的思路不同,替换直接覆盖原有数据就行。而单向链表的插入需要3步: (1)找到插入位置的前节点。 (2)新节点指向后节点(newNode.next = prevNode.next)。 (3)前节点指向新节点(prevNode.next = newNode)。 而且如果想要在"任意位置"插入节点,则需要考虑一些边界情况。例如尾节点没后节点。我们采用长度位置的方式来插入节点,那么实现插入节点方法需要两个参数:插入数据,插入位置。以及告诉我们是否插入成功,因此需要返回一个布尔值来提示。 那么开始吧,我们创建insert()方法,设定好参数值以及对应的类型提示,将方法模板搭建出来。 ```js // 任意位置插入节点 insert(value: T, position: number): boolean { } ``` 接着实现插入的逻辑: 插入节点方法步骤1:判断边界情况,插入位置只能在已有的位置插入,例如第一个链节点至尾节点的范围,超出该返回的插入应该直接返回false或者抛出异常(插入失败)。大家觉得是返回false更好还是抛出异常更好? 抛出异常是更规范的做法,提供的错误信息也更多,但同时由于抛出的是一个异常,如果开发者没有使用try...catch接住异常,那么代码就会报错,进而导致程序崩溃。而JavaScript是一门自由弹性的语言,通常是允许一定范围内的错误存在,不至于因为一些错误而直接导致程序无法运行,因此返回布尔值提醒开发者这里有问题是更好的选择。 ```ts // 任意位置插入节点 insert(value: T, position: number): boolean { // 1、越界的判断 if (position < 0 || position > this.size) return false } ``` 插入节点方法步骤2:判断插入情况。有以下2种情况:插入到第一个位置,插入到其他位置。 (1)插入到第一个位置。添加到链表的第一个位置,即第一个链节点(PS:头节点是固定的哨兵节点,不会被替换)。该情况很特殊,需要先将插入节点的next指针属性指向原来的第一个链节点,再将头节点的next指针指向新节点。顺序不能反,否则从原有第一个链节点及之后的数据会丢失。插入节点丢失数据情况对比如图3-6所示,当头节点先指向后,从原有的第一个链节点开始到尾节点,成为了一块单独的孤岛。  <p align="center"> <b>图3-6 插入节点丢失数据情况</b> </p> ```ts head.next = newNode; // 头节点指向新节点 // 造成了数据丢失和循环引用 newNode.next = head.next; // 新节点指向自己(形成环)后续数据丢失 ``` PS:我们学习的是带头节点的链表设计,即头节点是"哨兵节点",不存储实际数据,第一个实际数据节点是 head.next,头节点只包含指向第一个实际节点的指针。除此之外还有不带头节点的链表,即head属性直接指向第一个包含实际数据的节点,没有专门的"哨兵"头节点,每个节点都包含数据和指针。哨兵节点是数据结构中一个特殊的辅助节点,它不存储实际的有效数据,主要用于简化边界条件的处理和避免空指针异常,我们在操作数据时会直接忽略哨兵节点。 ```ts insert(value: T, position: number): boolean { // 1、越界的判断 if (position < 0 || position > this.size) return false // 创建节点 const newNode = new Node<T>(value) // 2、插入到第一个链节点 if (position == 0) { // 先将新节点指向第一个链节点 newNode.next = this.head this.head = newNode } return true; } // 测试代码 // 测试代码 const linkedList = new LinkedList<string>() linkedList.append("aaa") linkedList.append("bbb") linkedList.append("ccc") linkedList.insert('coderwhy', 0) ``` (2)插入到其他位置。如果是添加到其他位置,就需要先找到这个节点位置了。我们通过while循环,一点点向下找。 并且在这个过程中保存上一个节点和下一个节点。找到正确的位置后,将新节点的next属性指向下一个节点,将上一个节点的next属性指向新的节点。 插入到其他位置,我们先采用一个具体的案例来模拟,这有助于我们理解实现。假设我现在要在链表第二个位置插入数据,我传入了两个参数,第一个参数为数据"xiaoyu",第二个参数为插入的位置。 ```ts linkedList.insert('xiaoyu', 2) ``` 我需要实现的步骤是:利用第二个参数找到插入位置然后插入数据。 使用了临时指针变量current用于遍历链表。通过类似size属性与开发者传入的position(第二参数)对比判断是否找到正确的插入位置。最后将数据插入即可。由于size属性是用于记录链表长度的,因此我们在插入节点方法中创建一个index变量,在current遍历的过程中,index变量也不断自增,起到与size属性同样的效果,用于记录遍历链表的情况。当index与position一致后,说明找到插入位置了。 ```ts // 找到插入位置 let index = 0 let current = this.head // 例:position为3。index为2是通过判断的最后数字,实现获取2的下一位3。 while (index++ < position && current) { current = current.next } ``` 此时的current指针已经指向正确的插入位置,然后我们应该插入数据。但插入数据需要将新节点的next属性指向下一个节点,将上一个节点的next属性指向新的节点。我们拿到了下一个节点的位置,但还没拿到上一个节点的位置,所以我们再定义一个节点变量previous用于获取上一节点,变量previous默认为null,因为当我们要插入第一个位置,即position=0时,没有前驱节点。 我们使用previous获取到上一节点(前驱指针)后,再将current变量指向position位置的当前节点(当前指针)。此时就同时获取到两个节点。这里采用的是双指针写法,后续会使用单指针重构。 ```ts let index = 0 let previous: Node<T> | null = null let current = this.head while (index++ < position && current) { // 前驱指针 previous = current // 当前指针 current = current.next } ``` 当我们拿到前驱节点与当前节点后,我们要用新节点插入当前节点。即前驱指针指向新节点,而新节点的next指针指向原来的当前节点(current),原来的当前节点(current)现在成为新节点的后继节点。 这里插入新节点的指针指向先后顺序是不作要求的,谁先谁后都行。这是因为我们已经保存了前一个节点previous和当前节点current的引用,无论先连接新节点到当前节点,还是先改变前一个节点的指向,都不会造成另一个节点的丢失。 ```ts let index = 0 let previous: Node<T> | null = null let current = this.head while (index++ < position && current) { previous = current current = current.next } // 插入新节点 previous!.next = newNode newNode.next = current ``` 现在,我们来验证是否能够成功插入数据(位置从0开始): - 原数据:coderwhy -> aaa -> bbb -> ccc。 - 输出数据:coderwhy -> aaa -> xiaoyu -> bbb -> ccc。 ```ts class Node<T> { value: T next: Node<T> | null = null constructor(value: T) { this.value = value } } class LinkedList<T> { private head: Node<T> | null = null private size: number = 0 get length() { return this.size } // 插入节点方法 insert(value: T, position: number): boolean { // 1、越界的判断 if (position < 0 || position > this.size) return false // 创建节点 const newNode = new Node<T>(value) // 2、插入到第一个链节点 if (position == 0) { newNode.next = this.head this.head = newNode } else { let index = 0 let previous: Node<T> | null = null let current = this.head while (index++ < position && current) { // 前驱指针 previous = current // 当前指针 current = current.next } previous!.next = newNode newNode.next = current } this.size++ return true; } } // 测试代码 const linkedList = new LinkedList<string>() linkedList.append("aaa") linkedList.append("bbb") linkedList.append("ccc") linkedList.insert('coderwhy', 0) linkedList.insert('xiaoyu', 2) linkedList.traverse() export { } // 控制台输出:coderwhy -> aaa -> xiaoyu -> bbb -> ccc ``` 如果要插入数据到尾部,previous会指向尾节点,而current会指向null。此时新节点会取代null原先的位置然后指向null,尾节点会指向新节点。此时原先的尾节点的next属性不在为null,而新节点的next属性为null然后成为新的尾节点。插入到尾部就是最后的情况了,如果再往后插入就会触发一开始的越界判断而直接返回false。 ```ts // 初始状态 previous → 尾节点 → null current → null // 插入操作 previous!.next = newNode // 尾节点指向新节点 newNode.next = current // 新节点指向null(因为current是null) ``` ### 3.3.4 移除节点方法 假如我们想要移除头节点之外的任意一个节点,我们需要怎么做?怎么插入节点的,就能反着过来怎么移除。 因此移除节点B的方法需要以下步骤(假设有连续节点A、B、C): (1)获取前驱节点A与当前节点B。 (2)前驱节点A的next指针指向后续节点C。 移除节点所需步骤图如图3-7所示。  <p align="center"> <b>图3-7 移除正常节点示意图</b> </p> 好的,让我们开始实现removeAt()移除节点方法吧!移除节点,我们要和3.3.3小节的以第几个节点为目标的删除还是以数据内容为主的删除?其实都可以,这对应移除数据的两种常见方式: - 根据位置移除对应的数据。 - 根据数据,先找到对应的位置,再移除数据。 我们根据数据来找到对应数据再移除数据的话,通常需要3个步骤: (1)遍历链表,通过数据之间的比对判断,找到当前需要移除节点。 (2)在获取当前需要移除的节点之前,先获取前驱节点。 (3)在需要移除的位置,将前驱节点直接指向后继节点,然后return返回,跳出循环。 ```ts removeAt(value: T) { let current = this.head // 获取前驱节点 let previous: Node<T> | null = null while (current) { // 找到需要移除数据的位置,将前驱节点指向后继节点 if (current.value === value) return previous!.next = current.next // 获取前驱节点 previous = current // 移动current,直到找到需要移除数据的位置 current = current.next } } // 测试代码 const linkedList = new LinkedList<string>() linkedList.append("aaa") linkedList.append("bbb") linkedList.append("ccc") // 移除bbb linkedList.removeAt('bbb') ``` 以上是最精简的模式,没有考虑各种边界判断,接下来,我们根据位置移除对应数据再实现一次,并且附加各种边界判断。 越界判断:移除数据需要在链表范围内的数据,开发者传入的位置不能超出链表范围,即位置范围为第一个链节点到尾节点,超出范围直接返回null。 ```ts removeAt(position: number): T | null { if (position < 0 || position >= this.size) return null } ``` 移除节点利用位置信息,则需要一个"临时索引"。与3.3.1小节使用current遍历链表一致,在遍历链表的过程中使临时索引从0开始自增,当索引与开发者传递的位置信息一致时,说明移除元素的位置找到了。 ```ts // 移除节点方法 removeAt(position: number): T | null { if (position < 0 || position >= this.size) return null // 创建临时索引 let index = 0 // 创建遍历指针 let current = this.head // 前驱节点 let previous: Node<T> | null = null while (index++ < position && current) { // 找到位置 } } ``` 然后获取前驱节点和当前节点,将前驱节点的next指针指向后续节点就完成移除当前节点。后继节点需要考虑尾节点的情况,即尾节点没有后继节点的话就置为null。 ```ts // 移除节点方法 removeAt(position: number): T | null { if (position < 0 || position >= this.size) return null // 创建临时索引 let index = 0 // 创建遍历指针 let current = this.head // 前驱节点 let previous: Node<T> | null = null while (index++ < position && current) { // 获取前驱节点 previous = current // 获取当前节点 current = current.next } // 前驱节点指向后继节点 previous!.next = current?.next ?? null return current?.value ?? null } ``` 接着,我们来处理头节点的特殊情况,如果我们想移除第一个链节点(position等于0)。只需要直接头节点指针指向第二个链节点,使第二个链节点成为新的第一链节点。那么原来的第一个链节点没有引用指向后,就在链表中不再有效,后面会被回收掉。 ```ts // 删除方法 removeAt(position: number): T | null { if (position < 0 || position >= this.size) return null let current = this.head if (position === 0) { // 处理头节点的特殊情况 this.head = this.head?.next ?? null // 头节点指针指向由第一个链节点转为第二个链节点 } else { let index = 0 let previous: Node<T> | null = null while (index++ < position && current) { previous = current current = current.next } previous!.next = current?.next ?? null } // 最后确定删除一个节点之后,将链表长度-1 this.size-- return current?.value ?? null } ``` 头节点的特殊情况,即移除第一个链节点的原理图如图3-8所示。  <p align="center"> <b>图3-8 移除第一个链节点的原理图</b> </p> 因此删除节点实际是令要被删除的节点(假设节点B)处于没有任何活跃的引用指向它,哪怕节点B仍然存在。此时从链表的头节点开始遍历,无法到达节点B,在支持垃圾回收的JavaScript或者TypeScript语言中,节点B会被自动回收,就相当于删除了。 ### 3.3.5 查找与更新方法 接着实现链表的查找方法,即查找节点位于链表中的位置来获取数据。这个过程类似于通过数组的下标获取数组对应位置的数据,即数组索引:**arr[下标]**。当然,数组除了能通过下标之外,还可以通过其他方式(变量、表达式、函数调用、常量)获取信息,有兴趣的可以课外尝试。 查找方法需要我们传入位置参数,通过current遍历链表,获取要查找的节点后,返回节点的数据。该思路与3.3.4小节中的移除节点前半部分思路一致,不再重复赘述。 ```ts // 查找节点数据方法 get(position: number): T | null { if (position < 0 || position >= this.size) return null let index = 0 let current = this.head while (index++ < position && current) { current = current.next } return current?.value ?? null } // 测试代码 const linkedList = new LinkedList<string>() linkedList.append("aaa") linkedList.append("coderwhy") linkedList.append("ccc") console.log(linkedList.get(1)); // coderwhy ``` ### 3.3.6 重构方法 插入节点、移除节点、查找节点方法都用到了current遍历节点的做法,这是有共通之处的,即通过一个索引和遍历去获取链表中的某一样内容(数据或者指针)。因此我们可以将遍历节点的重复代码抽离为一个单独的方法getNode(),然后在插入节点、移除节点、查找节点方以及后续更多的方法中调用getNode()方法就行。 ```ts // 插入节点方法 let index = 0 let previous: Node<T> | null = null let current = this.head while (index++ < position && current) { previous = current current = current.next } // 移除节点方法 let index = 0 let current = this.head let previous: Node<T> | null = null while (index++ < position && current) { previous = current current = current.next } // 查找节点数据方法 let index = 0 let current = this.head while (index++ < position && current) { current = current.next } ``` getNode()方法是一个私有的方法,只允许链表内部的方法调用,不允许外部调用。防止外部直接操作节点,从而破坏链表的结构。如果外部可以获取到节点,那么他们就可以修改节点的next指针,这可能会造成链表断裂或循环。 对于这类抽离的公共方法,一般放于类中的最上层或者最下层,与其他正常方法区分开。通常放于类中的最上层会更好,因为后续添置方法或者其余内容往往从最下层代码继续往下写,每次书写代码都需要顾及抽离的公共方法,以免将公共方法夹到正常方法的中间去。 getNode()方法需要实现的是根据position(开发者传递的位置信息)获取到当前的节点本身。获取节点本身可以得到最多的信息量,开发者可以根据更多的信息量去选择自己所需的部分。 ```ts private getNode(position: number): Node<T> | null { let index = 0 let current = this.head while (index++ < position && current) { current = current.next } return current } ``` 此时我们就可以重构插入节点、移除节点、查找节点数据方法这三个方法,查找节点数据方法对应操作如下: ```ts // 查找节点数据方法 get(position: number): T | null { if (position < 0 || position >= this.size) return null // 遍历节点 查找节点 return this.getNode(position)?.value ?? null } ``` 移除节点稍有不同,除了获取到当前节点,还需要获取到当前节点的前驱节点。但这一点并不困难,我们只需要将position-1,然后基于该位置调用一次获取节点的getNode()方法就能拿到前驱节点。 ```ts // 移除节点方法 removeAt(position: number): T | null { if (position < 0 || position >= this.size) return null let current = this.head if (position === 0) { this.head = this.head?.next ?? null } else { // 获取前驱节点 const previous = this.getNode(position - 1) current = previous!.next previous!.next = current?.next ?? null } this.size-- return current?.value ?? null } ``` 插入节点方法同样需要用到前驱节点,因此也是通过position - 1获取当前节点的前驱节点。 ```ts // 插入节点方法 insert(value: T, position: number): boolean { // 1、越界的判断 if (position < 0 || position > this.size) return false // 创建节点 const newNode = new Node<T>(value) // 2、插入到第一个链节点 if (position == 0) { newNode.next = this.head this.head = newNode } else { const previous = this.getNode(position - 1) newNode.next = previous!.next previous!.next = newNode } this.size++ return true; } ``` 通过重构代码,我们三个方法的代码质量与代码精简度都得以提升,但什么时候我们需重构代码呢?重构代码不是一开始就要重构的,尤其是当我们对代码还没那么了解熟悉的时候。随着所写的代码越来越多,你会察觉到,这段代码的出现频率是不是有点高了,那这时候就可以将其抽离出来进行复用。编写代码与重构代码是一个螺旋上升的过程,两者谁都无法取代谁,优美且模块化程度极高的代码也是从毛胚开始的。当我们有经验之后,下一次编写代码的起点就会更高,这次的终点有可能就是下一次的起点,哪怕是Vue.js框架也避免不了整体重构代码,我们此刻所见的很多代码,背后都凝聚了很多次的思考,如果我们也经历过,那么也能感受到这份代码背后的开发者都在想什么。 ### 3.3.7 其他工具方法 接着我们还实现了update()方法、indexOf()方法、remove()方法以及isEmpty()方法。 - update(position,element) :修改某个位置的元素。 update()方法接收两个参数:位置信息,替换位置数据的新数据。首先我们匹配开发者传递的位置信息和链表的节点位置,找到位置之后,直接把节点中的数据赋值新数据,就完成替换掉的工作了。 ```ts // 更新元素方法 update(value: T, position: number) { if (position < 0 || position >= this.size) return const current = this.getNode(position) if (current) { current.value = value } } ``` - indexOf(value):获取某个元素的位置。 indexOf()方法与Array.prototype.indexOf()类似,会返回链表中第一次出现给定元素的位置信息,如果不存在则返回 -1。依旧使用current临时指针,遍历链表,将链表中的value与开发者传递的value对应匹配,直到匹配上或者遍历链表结束还未找到返回-1。 ```ts // 获取元素的位置 indexOf(value: T): number { let index = 0 let current = this.head while (current) { if (current.value === value) return index index++ current = current.next } return -1 } ``` - remove(value):通过节点数据移除链表中具体的节点。 有了上面的indexOf()方法,我们可以非常方便实现根据value数据来获取对应的节点信息位置,然后根据信息位置调用之前实现的removeAt()方法移除节点。 ```ts // 根据元素删除 remove(value: T): T | null { const index = this.indexOf(value) return this.removeAt(index) } ``` - isEmpty():判断单链表是否为空。 ```ts isEmpty() { return this.size === 0 } ``` 好的,到目前位置,我们就完成了一整个链表的从零实现,以下提供完整的最终链表实现方案以及测试案例,大家可以将其中的测试案例运行在自己实现的链表中,用于检测自己的链表代码是否有错误的地方。、 ```ts class Node<T> { value: T next: Node<T> | null = null constructor(value: T) { this.value = value } } class LinkedList<T> { private head: Node<T> | null = null private size: number = 0 get length() { return this.size } // 私有方法 private getNode(position: number): Node<T> | null { let index = 0 let current = this.head while (index++ < position && current) { current = current.next } return current } // 追加节点方法 append(value: T) { const newNode = new Node(value) if (!this.head) { this.head = newNode } else { let current = this.head // 寻找链表的最后一个节点 while (current.next) { current = current.next } // 追加节点 current.next = newNode } this.size++ } // 遍历链表方法 traverse() { let current = this.head let arr = [] while (current) { arr.push(current.value) current = current.next } // 其余拼接形式 console.log(arr.join(' -> ')); } // 插入节点方法 insert(value: T, position: number): boolean { // 1、越界的判断 if (position < 0 || position > this.size) return false // 创建节点 const newNode = new Node<T>(value) // 2、插入到第一个链节点 if (position == 0) { newNode.next = this.head this.head = newNode } else { const previous = this.getNode(position - 1) newNode.next = previous!.next previous!.next = newNode } this.size++ return true; } // 移除节点方法 removeAt(position: number): T | null { if (position < 0 || position >= this.size) return null let current = this.head if (position === 0) { this.head = this.head?.next ?? null } else { // 获取前驱节点 const previous = this.getNode(position - 1) current = previous!.next previous!.next = current?.next ?? null } this.size-- return current?.value ?? null } // 查找节点数据方法 get(position: number): T | null { if (position < 0 || position >= this.size) return null // 遍历节点 查找节点 return this.getNode(position)?.value ?? null } // 更新元素方法 update(value: T, position: number) { if (position < 0 || position >= this.size) return const current = this.getNode(position) if (current) { current.value = value } } // 获取元素的位置 indexOf(value: T): number { let index = 0 let current = this.head while (current) { if (current.value === value) return index index++ current = current.next } return -1 } // 根据元素删除 remove(value: T): T | null { const index = this.indexOf(value) return this.removeAt(index) } isEmpty() { return this.size === 0 } } // 测试代码 function testLinkedList() { console.log("=== 链表功能测试 ===\n"); const linkedList = new LinkedList<string>(); // 1. 测试初始状态 console.log("1. 初始状态测试:"); console.log("长度:", linkedList.length); // 期望: 0 console.log("是否为空:", linkedList.isEmpty()); // 期望: true console.log(""); // 2. 测试 append 方法 console.log("2. append 方法测试:"); linkedList.append("A"); linkedList.append("B"); linkedList.append("C"); console.log("追加 A, B, C 后:"); linkedList.traverse(); // 期望: A -> B -> C console.log("长度:", linkedList.length); // 期望: 3 console.log(""); // 3. 测试 insert 方法 console.log("3. insert 方法测试:"); // 在头部插入 console.log("在位置 0 插入 HEAD:"); linkedList.insert("HEAD", 0); linkedList.traverse(); // 期望: HEAD -> A -> B -> C // 在中间插入 console.log("在位置 2 插入 MIDDLE:"); linkedList.insert("MIDDLE", 2); linkedList.traverse(); // 期望: HEAD -> A -> MIDDLE -> B -> C // 在尾部插入 console.log("在末尾插入 TAIL:"); linkedList.insert("TAIL", linkedList.length); linkedList.traverse(); // 期望: HEAD -> A -> MIDDLE -> B -> C -> TAIL // 测试越界插入 console.log("测试越界插入:"); const result1 = linkedList.insert("INVALID", -1); const result2 = linkedList.insert("INVALID", linkedList.length + 1); console.log("位置 -1 插入结果:", result1); // 期望: false console.log("位置 size+1 插入结果:", result2); // 期望: false linkedList.traverse(); // 链表应该不变 console.log(""); // 4. 测试 get 方法 console.log("4. get 方法测试:"); console.log("位置 0:", linkedList.get(0)); // 期望: HEAD console.log("位置 2:", linkedList.get(2)); // 期望: MIDDLE console.log("位置 5:", linkedList.get(5)); // 期望: TAIL console.log("位置 -1:", linkedList.get(-1)); // 期望: null console.log("位置 10:", linkedList.get(10)); // 期望: null console.log(""); // 5. 测试 removeAt 方法 console.log("5. removeAt 方法测试:"); // 删除头部 console.log("删除位置 0:"); const removed1 = linkedList.removeAt(0); console.log("删除的元素:", removed1); // 期望: HEAD linkedList.traverse(); // 期望: A -> MIDDLE -> B -> C -> TAIL // 删除中间 console.log("删除位置 2:"); const removed2 = linkedList.removeAt(2); console.log("删除的元素:", removed2); // 期望: B linkedList.traverse(); // 期望: A -> MIDDLE -> C -> TAIL // 删除尾部 console.log("删除最后一个位置:"); const removed3 = linkedList.removeAt(linkedList.length - 1); console.log("删除的元素:", removed3); // 期望: TAIL linkedList.traverse(); // 期望: A -> MIDDLE -> C // 测试越界删除 console.log("测试越界删除:"); const removed4 = linkedList.removeAt(-1); const removed5 = linkedList.removeAt(linkedList.length); console.log("位置 -1 删除结果:", removed4); // 期望: null console.log("位置 size 删除结果:", removed5); // 期望: null linkedList.traverse(); // 链表应该不变 console.log(""); // 6. 测试 update 方法 console.log("6. update 方法测试:"); console.log("更新前:"); linkedList.traverse(); // A -> MIDDLE -> C console.log("更新位置 1 为 UPDATED:"); linkedList.update("UPDATED", 1); linkedList.traverse(); // 期望: A -> UPDATED -> C console.log("测试越界更新:"); linkedList.update("INVALID", -1); // 应该无效果 linkedList.update("INVALID", 10); // 应该无效果 linkedList.traverse(); // 应该不变 console.log(""); // 7. 测试 indexOf 方法 console.log("7. indexOf 方法测试:"); console.log("A 的位置:", linkedList.indexOf("A")); // 期望: 0 console.log("UPDATED 的位置:", linkedList.indexOf("UPDATED")); // 期望: 1 console.log("C 的位置:", linkedList.indexOf("C")); // 期望: 2 console.log("不存在的元素位置:", linkedList.indexOf("NOT_EXIST")); // 期望: -1 console.log(""); // 8. 测试 remove 方法(根据值删除) console.log("8. remove 方法测试:"); console.log("删除 UPDATED:"); const removedByValue = linkedList.remove("UPDATED"); console.log("删除的元素:", removedByValue); // 期望: UPDATED linkedList.traverse(); // 期望: A -> C console.log("删除不存在的元素:"); const removedInvalid = linkedList.remove("NOT_EXIST"); console.log("删除结果:", removedInvalid); // 期望: null linkedList.traverse(); // 应该不变 console.log("删除 A:"); linkedList.remove("A"); linkedList.traverse(); // 期望: C console.log("删除 C:"); linkedList.remove("C"); linkedList.traverse(); // 期望: (空) console.log("长度:", linkedList.length); // 期望: 0 console.log("是否为空:", linkedList.isEmpty()); // 期望: true console.log(""); // 9. 测试边界情况 console.log("9. 边界情况测试:"); // 空链表操作 console.log("空链表时删除:", linkedList.removeAt(0)); // 期望: null console.log("空链表时获取:", linkedList.get(0)); // 期望: null console.log("空链表时更新:", linkedList.update("TEST", 0)); // 应该无效果 // 单元素链表操作 linkedList.append("SOLO"); console.log("单元素链表:"); linkedList.traverse(); // 期望: SOLO console.log("删除单元素:"); linkedList.removeAt(0); linkedList.traverse(); // 期望: (空) console.log("最终长度:", linkedList.length); // 期望: 0 console.log("最终是否为空:", linkedList.isEmpty()); // 期望: true console.log("\n=== 所有测试完成 ==="); } // 运行测试 testLinkedList(); export { } ``` ## 3.4 链表常见面试题 ### 3.4.1 LeetCode 707:设计链表 设计链表实际和3.2与3.3小节所手写的链表是一致的,我们所实现的链表比题目更为全面。 - 707.设计链表:https://leetcode.cn/problems/design-linked-list/(中等难度)。 题目:设计链表的实现。可以选择使用单链表或双链表,单链表中的节点应该具有两个属性:val和next。val是当前节点的值,next是指向下一个节点的指针/引用。如果要使用双向链表,则还需要一个属性prev以指示链表中的上一个节点。假设链表中的所有节点都是0-index 的。 在链表类中实现以下5个功能: (1)get(index):获取链表中第index个节点的值。如果索引无效,则返回-1。 (2)addAtHead(val):在链表的第一个元素之前添加一个值为val的节点。插入后,新节点将成为链表的第一个节点。 (3)addAtTail(val):将值为val的节点追加到链表的最后一个元素。 (4)addAtIndex(index,val):在链表中的第index个节点之前添加值为val的节点。如果index等于链表的长度,则该节点将附加到链表的末尾。如果index大于链表长度,则不会插入节点。如果index小于0,则在头部插入节点。 (5)deleteAtIndex(index):如果索引index有效,则删除链表中的第index个节点。 内容不再重复编写,可以参考3.3小节之中的方法案例,已包含以上5个功能的实现思路,可结合LeetCode的题解去具体分析。 ### 3.4.2 LeetCode 237:删除链表中的节点 - 237.删除链表中的节点:https://leetcode.cn/problems/delete-node-in-a-linked-list/description/(中等难度)。 题目:有一个单链表的 head,我们想删除它其中的一个节点 node。给你一个需要删除的节点 node 。你将无法访问第一个节点 head。链表的所有值都是唯一的,并且保证给定的节点 node 不是链表中的最后一个节点。 删除给定的节点。注意,删除节点并不是指从内存中删除它。这里的意思是: - 给定节点的值不应该存在于链表中。 - 链表中的节点数应该减少 1。 - node 前面的所有值顺序相同。 - node 后面的所有值顺序相同。 自定义测试:对于输入,你应该提供整个链表 head 和要给出的节点 node。node 不应该是链表的最后一个节点,而应该是链表中的一个实际节点。我们将构建链表,并将节点传递给你的函数。输出将是调用你函数后的整个链表。 以下是两个示例:  <p align="center"> <b>图3-9 LeetCode237.删除链表中的节点示例图</b> </p> > LeetCode237.删除链表中的节点示例如图3-9所示。对应输出如下所示。 > > 示例A: > > 输入:head = [4,5,1,9], node = 5。 > 输出:[4,1,9]。 > 解释:指定链表中值为 5 的第二个节点,那么在调用了你的函数之后,该链表应变为 4 -> 1 -> 9。 > > 示例B: > > 输入:head = [4,5,1,9], node = 1。 > 输出:[4,5,9]。 > 解释:指定链表中值为 1 的第三个节点,那么在调用了你的函数之后,该链表应变为 4 -> 5 -> 9。 这道题是什么意思?假如我们想要删掉图3-10所示中的数字1,只需要拿到头节点this.head,然后数一下数字1在哪一个节点,然后this.head.next.next就找到目标节点,紧接着前驱节点直接指向目标节点的后继节点就结束了。 所有的题目都需要先理清思路再去做法,思路清晰则代码清晰,LeetCode 237题的分析如下4点: (1)题目不让我们访问this.head(头节点),也是题目的难点所在,即如何在不访问头节点的情况下去删除目标节点。 (2)题目中的所有值都是唯一的,配合后续所说的删除节点并不是指从内存中删除,要么题目不建议这么做,要么我们在无法访问头节点的情况下无法真正做到删除节点,那么我们需要摒弃传统的做法。 (3)目标节点的前后所有值顺序不变,即删除操作后,在给定节点之前或者之后的所有节点保持原有的连接顺序不变。 (4)给定的节点node不是链表中的最后一个节点,即只存在链节点的情况,因此不需要额外处理边界情况。 (5)节点数量少1,所以一定需要删除一个节点。 如果我们将LeetCode中的代码直接拿到VS Code中,会报错。因为我们没有ListNode节点类型,但好在LeetCode在注释中有提供,我们只需要一并拿出来使用就行。 ```ts // ListNode文件 class ListNode { val: number; next: ListNode | null; constructor(val?: number, next?: ListNode | null) { this.val = val === undefined ? 0 : val; this.next = next === undefined ? null : next; } } export default ListNode ``` 这道题很有意思,因为链表访问任何一个位置的元素时,都需要从头开始访问。假如没有了头节点,我们就无法实现访问链表的操作。但这道题目直接给予了我们需要删除的目标节点,因此对于我们来说,只是没办法访问到目标节点的前驱节点。因此以删除目标节点为目标的传统做法:将前驱节点指向目标节点的后继节点,确实是无法实现的。 如何根据已知的信息(目标节点以及后继节点)完成删除的效果?这需要我们有一定的联想能力,题目要求一定要删一个节点,但又说给定节点的值不应该存在于链表中。这两句话听起来有点重复的意思,要么就是这两句话想表达的不是同一个意思,即一定要删除的节点与给定节点不一定要求是同一个。 那么,如果我一定要删除一个节点,目标节点无法删除,因为我无法访问目标节点的前驱节点。我能删的只有目标节点之后的节点,因为可以使用原来的目标节点为新的前驱节点,原来的后继节点为新的目标节点。排除掉不可能的情况,能删除的节点就原来目标节点的后续节点。 以图3-9的示例A为例,假如4->5->1->9,我想删5,但却只能删1,从而变成4->5->9,而题目要求答案是4->1->9。顺序对了,我能不能把原来要删除的目标节点的值(5)直接覆盖成被迫删除的节点的值(1)呢? 显然是可以的,在删除节点数据为1的节点之前,先将其赋值给节点数据为5的节点。从而使链表变为4->1->1->9,此时删除的1是第二个1,删除结果是4->1->9,从而符合题目需求。也就链表是无序的,在内存中的表达非连续性,因此对删除的位置并不敏感。 思考结束,我们需要实现的步骤有两步: (1)题目提供要删除的目标节点的数据覆盖上目标节点的后继节点的数据。 (2)原来的目标节点的指针指向后继节点的后继节点,实现删除目标节点的后继节点(实际上删除的是目标节点的下一个节点)。 ```ts import ListNode from "./ListNode" function deleteNode(node: ListNode | null): void { node!.val = node!.next!.val node!.next = node!.next!.next }; ``` 总结:这是一道考验做题者阅读解析信息能力的题目,一旦真正理解题目之后,想要实现就非常简单。所有的难度和提示都集中在固有思路转变以及思考层面,考察准确识别关键约束然后从约束推导出可行方案的工程思维,更类似于脑筋急转弯:我杀不了自己,那我就变成别人,然后把别人杀了,这样这个世界就不存在我了(LeetCode题解的调侃想法)。 ### 3.4.3 LeetCode 206:反转链表(迭代与递归) 假如要你将眼前的一个链表前后翻转一下,你会怎么做?让我们来做这一道反转链表的题吧! - 206.反转链表:https://leetcode.cn/problems/reverse-linked-list/(简单难度)。 题目:给你单链表的头节点head ,请你反转链表,并返回反转后的链表。反转链表实现效果展示图如图3-10所示。  <p align="center"> <b>图3-10 LeetCode206.反转链表实现效果展示图</b> </p> 看起来这一道题目有点像要实现链表版本的Array.prototype.reverse()实例方法,即链表的第一个链节点会变成最后一个,数组的最后一个尾节点变成第一个链节点。换句话说,链表中的节点顺序将被翻转,变为与之前相反的方向。图中没有展示头节点,但这很可能意味着this.head(头节点)的指针要指向尾节点了,头节点是作为哨兵存在,不参与反转。 在完成这一题目后,还有进阶写法,链表可以选用迭代或递归方式完成反转,你能否用两种方法解决这道题。我们先从非递归开始,再去实现进阶写法。 实现A(栈写法):一想到反转,最容易想到的是通过栈结构的先进后出,让我们来实现一下。 通常在实现需求时,需要先想清楚需求,而想清楚需求主要分为:边界情况以及实现思路步骤。在前期处理好边界情况,可以避免代码返工次数频发;想好实现思路步骤再动手能更顺畅的完成功能,避免写到一半来回修改。 边界情况: (1)链表在为空的情况下无需反转处理,直接返回null。 (2)链表为空有两种情况,即开发者直接传递进来一个null(this.head本身为null)或者只有头节点的链表。 (3)只有头节点的链表意味着只有一个链节点(头节点指针指向第一个链节点),那么反转是没有意义的(第一个链节点反转后还是第一个链节点),我们直接返回头节点本身即可。至少需要两个链节点才达到反转链表的基本条件(如果是采用头节点存储数据的链表,也至少需要一个链节点)。 实现思路: (1)创建一个栈结构。 (2)将链表节点按顺序推入栈结构中,直到尾节点结束。 (3)从栈结构中按顺序取出链表节点,每个节点的next指针指向下一个取出的节点,直到获取最后一个节点,将原先的第一个链节点,如今的尾节点next指针置为null(否则会进入循环引用的死循环中)。 (4)返回反转后的新链表。 ```ts // 面试题_ListNode class ListNode { val: number; next: ListNode | null; constructor(val?: number, next?: ListNode | null) { this.val = val === undefined ? 0 : val; this.next = next === undefined ? null : next; } } export default ListNode ``` ```ts import ListNode from "./面试题_ListNode" function reverseList(head: ListNode | null): ListNode | null { // 什么情况下链表不需要处理? // 1.head本身为null的情况 if (head === null) return null // 2.只有head一个节点 if (head.next === null) return head // 数组模拟栈结构 const stack: ListNode[] = [] let current: ListNode | null = head while (current) { stack.push(current) current = current.next } // 以此从栈结构中取出元素, 放到一个新的链表中 const newHead: ListNode = stack.pop()! let newHeadCurrent = newHead while (stack.length) { const node = stack.pop()! newHeadCurrent.next = node newHeadCurrent = newHeadCurrent.next } // 注意: 获取到最后一个节点时, 一定要将节点的next置为null newHeadCurrent.next = null return newHead }; // 模拟数据进行测试 const node1 = new ListNode(1) node1.next = new ListNode(2) node1.next.next = new ListNode(3) const newHead = reverseList(node1) let current = newHead while (current) { console.log(current.val) current = current.next } export {} ``` 以上使用栈结构实现的反转链表,是没有哨兵(不含数据只有指针的头节点)节点的链表,因此链表可以完全反转。如果链表存在哨兵节点(头节点),我们就需要跳过哨兵节点开始压栈。然后将哨兵节点作为新链表的头,清空哨兵节点的指针,设置指向新的反转链表的第一个链节点。 ```ts function reverseListWithSentinel(head: ListNode | null): ListNode | null { // 处理空链表或只有哨兵节点的情况 if (head === null || head.next === null) return head const stack: ListNode[] = [] // 跳过哨兵节点,从第一个实际数据节点开始压栈 let current: ListNode | null = head.next while (current) { stack.push(current) current = current.next } // 重新构建链表,保留原来的哨兵节点 let newCurrent = head // 哨兵节点作为新链表的头 newCurrent.next = null // 清空哨兵节点的next // 从栈中弹出节点并连接到哨兵节点后面 while (stack.length) { const node = stack.pop()! newCurrent.next = node newCurrent = newCurrent.next } // 将最后一个节点的next置为null newCurrent.next = null return head // 返回原来的哨兵节点 } ``` 但LeetCode的这类题目中,往往是不考虑哨兵节点的情况的,但有无哨兵节点的情况处理方式并没有差太多。206题反转链表的其他做法中,我们还是采用无哨兵节点,即头节点存储数据的条件。 采用栈结构来实现反转链表其实不是一个好方法,因为栈结构的空间复杂度是O(n),而且在反转链表中多使用了一个栈结构,也会使代码更复杂。 实现B(循环):循环做法的边界判断思路与栈结构做法是一致的。 ```ts import ListNode from "./面试题_ListNode" function reverseList(head: ListNode | null): ListNode | null { // 1.判断节点为null, 或者只要一个节点, 那么直接返回即可 if (head === null || head.next === null) return head }; ``` 如果使用循环来反转链表,要怎么做?通过以下3步骤实现: (1)首先我们需要创建一个新的头节点(携带数据的),指向尾节点。 (2)从第一个链节点开始,指针指向全部反转。 (3)原来的头节点置为null。 反转链表的循环做法如图3-11所示。  <p align="center"> <b>图3-11 反转链表(循环)</b> </p> 在指针反转的过程中,有一个注意事项需要注意。由于链表访问必须从头节点开始,假如我找到第一个链节点直接改next指针,那么第一个链节点之后的所有节点就会直接丢失,这个问题在插入第一个链节点的时候我们也有遇到过。那遇到这种问题,我们要怎么处理解决? 可以参考之前的解决方式,只需要先改当前节点的前驱节点指针(前驱指针与当前节点的位置都不会丢失),那么后续节点就不会出现丢失问题。这可以延伸出一个很好的思路,我们通过current临时指针先找到第二个链节点,然后去修改第一个链节点的next指针,直到current临时指针找到null为止(说明找到尾节点了,null的前一个节点为尾节点)。 有了想法之后,要如何实现呢?大家在入门编程语言(例如JavaScript)的时候,一定做过一个经典的案例:如何交换两个元素。我在第一次学习JavaScript时也苦恼过这一个问题,如果我将A赋值给B,那B的内容就没了,反之A的内容就没了。对此有一个经典的做法:用临时变量C保存好变量A,那么B就能赋值给A,再让临时变量C保存的变量A赋值给变量B。其次的进阶做法可以采用解构赋值[a,b] = [b,a],但底层原理都是相同的,都需要有额外存放数据的地方,给数据交换提供腾转的空间。 那么在循环链表中,所需要面对的问题是: 如何在不丢失节点访问能力的情况下安全地修改指针关系。因此可以借鉴变量交换的思想,在修改前保存必要的引用,确保每个操作都不会破坏链表的完整性,特别处理头尾节点的连接以保持循环特性,这种"先保存,后修改"的策略是处理指针操作时的通用最佳实践。所以我们必然是需要一个新节点用于保存必要节点,以及一个新的头节点。 产生实践思路如下: (1)使用临时节点(current)记录原链表的下一个节点,以防丢失原链表的后续部分。 (2)将当前节点(head)的next指向新链表的头节点(newHead),这样当前节点就连接上了已经反转的部分链表。 (3)更新新链表的头节点为当前节点,因为当前节点现在已经成为了新链表的最前端。 (4)将原链表的头节点指向临时节点(current),也就是原链表的下一个节点,继续处理后续节点。 新节点保存必要节点可通过current这一用于遍历的节点保存,创建新的头节点newHead,初始值为null。 因此我们需要以下4步操作: (1)让current节点(用于遍历链表的节点)的指针指向下一节点,用于保存当前节点的下一个节点,防止链表断开。 (2)此时需要将第一个链节点指向null,形成反转链表的尾节点。但此时第一个链节点的指针我们不直接指向null,而是指向newHead。 (3)让newHead指向head节点,目的是下一次遍历时,第二步操作可以让下一个节点指向第一个节点。 (4)让head移向下一个节点指向current。 newHead初始值也是null,效果没有区别,那是基于什么原因让我们不选择直接指向null而是newHead?最核心的原因在于保持操作的一致性,在第一次循环时,确实看起来没有区别,但问题在于后续循环。从第二次循环开始,如果还让第二个链节点指向null就会破坏已经建立的反转关系,此时原链表的第二个链节点应该要"裁切"到新链表身上去指向新链表的第一个链节点了。通过上一次循环的newHead = head,可以将当前节点裁切下来,在本次循环中通过head.next = newHead将当前处理的节点设置为新的头节点。 ```ts import ListNode from "./面试题_ListNode" function reverseList(head: ListNode | null): ListNode | null { // 1.判断节点为null, 或者只要一个节点, 那么直接返回即可 if (head === null || head.next === null) return head // 2.反转链表结构 // 创建新的头节点 let newHead: ListNode | null = null while (head) { // 1)保存当前节点的下一个节点,防止链表断开 const current: ListNode | null = head.next // 2)反转指针:当前节点指向新链表的头节点 head.next = newHead // 3)更新新链表的头节点为当前节点 newHead = head // 4)移动到原链表的下一个节点 head = current } return newHead }; // 模拟数据进行测试 const node1 = new ListNode(1) node1.next = new ListNode(2) node1.next.next = new ListNode(3) const newHead = reverseList(node1) let current = newHead while (current) { console.log(current.val) current = current.next } export { } ``` 总的来说,反转链表的循环做法,是通过一个额外节点暂时保存住原链表的相关信息(防链表断开),然后将原链表的节点一个个的裁切到新链表上作为头节点,直到将原链表的节点裁切结束,则原链表的尾节点就会成为新链表最终的头节点。我们也可以用搭积木来比喻:记住下一块积木的位置(临时指针),从原塔拆下当前积木(断开原链接),把积木放到新塔的顶部(更新头节点),准备拆下一块积木(推进指针)。 循环链表做法图示如图3-12所示。  <p align="center"> <b>图3-12 循环链表做法图示</b> </p> 实现C:递归。如果使用的是递归, 那么递归必须有结束条件,否则会无限递归,直到爆栈终止。 ```ts import ListNode from "./面试题_ListNode" function reverseList(head: ListNode | null): ListNode | null { // 如果使用的是递归, 那么递归必须有结束条件 if (head === null || head.next === null) return head }; ``` 递归要如何实现反转链表?这需要利用上递归的特点,递归它会连续调用到我们终止条件的位置,然后从终止位置开始往前执行。这就很有意思了,意味着我们可以从一个链表的尾节点开始执行,那我直接在一开始递归执行的尾节点处直接让头节点指向尾节点,然后获取每个目标节点的前驱节点,然后将目标节点指向前驱节点,直到前驱节点为null。 现在我有两个位置,分别是位置A与位置B。我们如果要编写反转指针的代码,需要写在哪个位置? 应该把反转指针的代码写在位置B。这是因为递归函数会像"潜水"一样一直深入到链表的最后一个节点(尾节点)。当到达尾节点时,递归就会停止不再继续深入。如果我们把反转代码写在位置A,就像在"潜水"的过程中就急着要反转,但此时我们还没有看到整个链表的结构,而且最后一个节点由于已经满足终止条件,根本不会执行位置A的代码。而写在位置B,就像"潜水"到底后开始慢慢上浮,在返回的过程中逐个处理每个节点。这时候我们已经知道了链表的结构,可以从尾节点开始,安全地逐个反转指针方向,直到回到链表头部。 ```ts import ListNode from "./面试题_ListNode" let count = 1 function reverseList(head: ListNode | null): ListNode | null { // 如果使用的是递归, 那么递归必须有结束条件 if (head === null || head.next === null) return head // 位置A // 递归 const newHead = reverseList(head?.next ?? null) // 位置B return newHead }; ``` 那么,我们要递归的"深度"是要在哪里?尾节点吗?不是的,我们不应该停留在尾节点的地方,而且也无法停留在尾节点,由于我们设置的递归结束条件head.next === null,导致递归无法到达尾节点。但这不是什么问题,我们只需要到达倒数第二个链节点之后,多next一层就能找到尾节点。 在编写链表的插入方法的时候,我们都知道要获取目标节点的前驱节点,而如果我要反转节点的指针,那么我也应该获取到目标节点的前驱节点,然后将目标节点的next指向前驱节点本身,即head.next(目标节点) = head(前驱节点)。而由于我们只到达倒数第二个链节点,为保证尾节点不丢失,需要多next一层,因此代码需要为head.next.next = head。 ```ts import ListNode from "./面试题_ListNode" let count = 1 function reverseList(head: ListNode | null): ListNode | null { // 如果使用的是递归, 那么递归必须有结束条件 if (head === null || head.next === null) return head const newHead = reverseList(head?.next ?? null) // 完成想要做的操作是在这个位置 // 第一次来到这里的时候, 是倒数第二个节点 head.next.next = head head.next = null return newHead }; // 模拟数据进行测试 const node1 = new ListNode(1) node1.next = new ListNode(2) node1.next.next = new ListNode(3) const newHead = reverseList(node1) let current = newHead while (current) { console.log(current.val) current = current.next } export {} ``` 以上LeetCode的三道题,即题号为707、237、206的题目都是基于有数据的头节点(非哨兵节点),思路需要稍有转变。文字表述还是较为抽象一些,如果难以理解,可以观看coderwhy老师的阶段十七-数据结构与算法一的第三天开头部分。 ### 3.4.4 链表接口设计 在我们实现的链表中有很多方法,可以将这些方法放入到对应的接口里面。那这种做法有什么好处?将链表方法定义在接口中,是一种面向接口编程的重要实践。这种做法最大的好处在于定义契约与实现分离。接口就像一份合同,明确规定了链表必须提供哪些功能,但不关心这些功能具体如何实现。任何实现了这个接口的类都必须遵守这个契约,确保具备所有必要的方法。 这种方式极大地提升了代码的灵活性和可维护性。当我们需要更换链表实现时,比如从单向链表改为双向链表,只要遵循相同的接口,不用我们再点开之前具体实现的单向链表去找一个个需要实现的链表方法规范。甚至可以直接让双向链表继承单向链表,重写其中不同的部分。 ```ts import IList from "../types/IList" interface ILinkedList<T> extends IList<T> { append(value: T): void traverse(): void insert(value: T, position: number): boolean removeAt(position: number): T | null get(positon: number): T | null update(value: T, position: number): boolean indexOf(value: T): number remove(value: T): T | null } export default ILinkedList // class LinkedList<T> implements ILinkedList<T> ``` ## 3.5 算法复杂度分析 在第1章,我们已经解释了什么是算法?其实就是解决问题的一系列步骤操作、逻辑。对于同一个问题,我们往往其实有多种解决它的思路和方法,也就是可以采用不同的算法。但是不同的算法,其实效率是不一样的。举个例子(现实的例子):在一个庞大的图书馆中,我们需要找一本书。在图书已经按照某种方式摆好的情况下(数据结构是固定的)。 方式一:顺序查找。一本本找,直到找到想要的书;(累死) 方式二:先找分类,分类中找这本书。先找到分类,在分类中再顺序或者某种方式查找; 方式三:找到一台电脑,查找书的位置,直接找到;图书馆通常有自己的图书管理系统;利用图书管理系统先找到书的位置,再直接过去找到; ### 3.5.1 算法复杂度简介与现实案例 什么是算法复杂度?算法复杂度是衡量一个算法在处理不同规模的数据时,所需消耗的“时间”和“空间”(内存)资源的量度。它不关注具体的运行时间(秒数),而是关注随着输入数据规模的增大,算法效率的变化趋势。通常,我们更关注时间复杂度,因为时间(速度)往往是更稀缺的资源。 我们举一个程序中的案例:让我们来比较两种不同算法在查找数组中(数组有序)给定元素的时间复杂度。一个数组内有100w个元素,元素有序排列,我们要如何查找在这数组中的某一个我们所需的元素? 两个经典的方法:顺序查找与二分查找。 (1)顺序查找。这种算法从头到尾遍历整个数组,依次比较每个元素和给定元素的值。如果找到相等的元素,则返回下标;如果遍历完整个数组都没找到,则返回-1。 (2)二分查找。这种算法假设数组是有序的,每次选择数组中间的元素与给定元素进行比较。如果相等,则返回下标;如果给定元素比中间元素小,则在数组的左半部分继续查找;如果给定元素比中间元素大,则在数组的右半部分继续查找;这样每次查找都会将查找范围减半,直到找到相等的元素或者查找范围为空。二分查找如图3-13所示。  <p align="center"> <b>图3-13 二分查找</b> </p> 对于一个拥有100万个元素的数组来说,顺序查找在最坏的情况下,我们需要遍历整个数组,即100万次比较。二分查找每次比较后都将搜索范围减半。最坏情况是直到范围为空,即需要比较log₂(1000000)次。假设每次查找耗费时间为0.00001秒,那么顺序查找最多耗时10秒,而二分查找最多只需要查找20次,即0.0002 秒,两种查找算法之间的效率差了5万倍,并且随着数据量级的增长,查找效率还会进一步的拉大。 时间复杂度是衡量算法运行时间随输入规模增长的变化趋势,即当数据量(n)翻倍时,算法的运行时间会如何变化?。顺序查找最好的情况是第一次就找到,但我们一般不考虑这种情况(参考价值低)。我们通常参考最坏时间复杂度(查找100万次,n次)以及平均时间复杂度(查找50万次,n/2次)。二分查找的时间复杂度为log₂n。 在一个有 n 个元素的数组中查找一个特定的值,常见的时间复杂度等级如下表3-1所示。 <p align="center"> <b>表3-1 常见时间复杂度等级</b> </p> | 复杂度 | 名称 | 举例(查找) | 当 n 翻倍时... | 形象比喻 | | :------------- | :----------- | :----------------------------------- | :------------------- | :------------------------------------------- | | **O(1)** | 常数时间 | 通过索引直接访问数组元素 | **时间不变** | 无论图书馆多大,都知道某本书在**固定位置**。 | | **O(log n)** | 对数时间 | 在**已排序**的数组中进行二分查找 | **时间只增加一点点** | 每次翻书都能排除一半的区域,非常高效。 | | **O(n)** | 线性时间 | 在未排序的数组中逐个遍历查找 | **时间也翻倍** | 一页一页地翻通讯录,最坏情况要翻完。 | | **O(n log n)** | 线性对数时间 | 高效的排序算法,如快速排序、归并排序 | **时间比翻倍多一点** | 一种比较高效的整理书籍的方式。 | | **O(n²)** | 平方时间 | 低效的排序算法,如冒泡排序、选择排序 | **时间变为4倍** | 检查每本书与其他所有书是否按顺序排好。 | | **O(2ⁿ)** | 指数时间 | 解决暴力破解密码、汉诺塔问题 | **时间急剧膨胀** | 每多一本书,整理方案的数量就爆炸性增长。 | | **O(n!)** | 阶乘时间 | 旅行商问题的暴力解法 | **时间无法承受** | 尝试所有可能的排列组合,完全不可行。 | ### 3.5.2 顺序查找与二分查找对比 接下来我们通过代码的展示来对比顺序查找与二分查找。 如果我们要实现顺序查找或者二分查找,我们要使用函数还是类来实现?这是一个很有意思的问题,在之前实现链表的时候,我们是通过类实现的,但现在如果要实现这两个查找方法,最好使用函数。因为搜索算法本质上是无状态的纯操作(固定的输入一定会产生固定的输出),是我们所说的纯函数。并且函数更简单、直接、易于测试。只有在算法需要维护状态、配置或复杂的行为时,才考虑使用类实现,例如一口气实现七八种甚至更多的同系列算法。对于基本的顺序查找和二分查找,函数实现是更优雅和实用的选择。 通过函数实现顺序查找,需要接受两个参数:数据和需要查找的目标数据。返回一个结果,即查找的目标数据位置或者没找到。在我们这个案例中,以数组作为数据承载的载体,数据类型统一为整数。通过查找整数的数据,获取数据的索引位置。PS:对于一个完善的查找算法,会考虑各种类型的边界情况,这里只讨论算法实现本身。 ```ts /** * 顺序查找的算法 * @param array 查找的数组 * @param num 查找的元素 * @returns 查找到的索引, 未找到返回-1 */ function sequentSearch(array: number[], num: number) { } export default sequentSearch ``` 顺序查找通过遍历数组,挨个比对要查找的目标顺序,那么遍历数组我们要使用for循环还是while循环?在之前有说明,编写算法大多数都使用while循环,但我们的顺序查找最好使用for循环。主要原因是for循环将初始化、条件判断、索引递增三个操作集中在一行。数组不像我们手写实现的链表,数组它有长度有索引的,这些都是已知的信息,并且初始化index和递增可以直接在for循环中实现,这不会泄露到外界作用域中,更加稳定。因此for循环的做法在这里会比`while (index++ < position && current)` 类似的做法更好。 ```ts /** * 顺序查找的算法 * @param array 查找的数组 * @param num 查找的元素 * @returns 查找到的索引, 未找到返回-1 */ function sequentSearch(array: number[], num: number) { for (let i = 0; i < array.length; i++) { const item = array[i] if (item === num) { return i } } // 没找到目标数据 return -1 } const index = sequentSearch([1, 3, 5, 10, 100, 222, 333], 222) console.log(index) export default sequentSearch ``` 同时在熟悉编程语言以及数据结构与算法后,我们也可以采用高阶函数来精简代码,一行代码就能解决,但底层所表达的含义是一致的。 ```ts const sequentSearch = (arr: number[], target: number) => arr.findIndex(item => item === target); ``` 二分查找所需要接收的参数和返回结果规则与顺序查找一致。但使用二分查找时,我们需要定义"边界",即最左侧的索引(起点)和最右侧的索引(终点),有了"边界"才能确定初始查找的中间值。当中间值大于目标数据时,说明目标数据在左侧,那么中间值会成为新的右侧边界;当中间值小于目标数据时,说明目标数据在右侧,那么中间值会成为新的左侧边界。这一循环会持续到找到目标数据或者找遍了也没找到然后返回-1。 对于二分查找而言,使用while循环更好,因为二分查找不需要初始化索引,也不需要递增,唯一需要的就是重复的"对折"。这里可以注意到,每次重新界定边界时,左侧边界会加1(目标数据在右边),右侧边界则会减1(目标数据在左边),是因为mid位置的元素已经被检查过了,不需要再次检查。加1减1的操作目的是排除已检查的元素。 ```ts function binarySearch(array: number[], num: number) { // 1.定义左边的索引 let left = 0 // 2.定义右边的索引 let right = array.length - 1 // 3.开始查找 while (left <= right) { let mid = Math.floor((left + right) / 2) const midNum = array[mid] if (midNum === num) { return mid } else if (midNum < num) { left = mid + 1 } else { right = mid - 1 } } return -1 } const index = binarySearch([1, 3, 5, 10, 100, 222, 333], 222) console.log(index) export default binarySearch ``` 在完成顺序查找与二分查找的算法之后,我们创建一个函数方法,使用测试数组,存放极多元素,来测试两种算法的消耗时间。 首先new Array(10_000_000)创建一千万由个undefined组成的稀疏数组,然后将所有元素填充为数字0之后,最后利用索引按顺序将每个元素替换为索引值,形成一个由一千万个数字元素组成的有序密集数组。使用高阶函数配合链式调用可以快速完成这一需求,但需要注意创建稀疏数组之后,必须先使用Array.prototype.fill()实例方法将所有元素填充为0,从而形成密集数组,因为Array.prototype.map()实例方法只会遍历已经赋值的元素,不处理稀疏数组。MDN文档中的Array.prototype.map()实例方法处理稀疏数组情况如图3-14所示。  <p align="center"> <b>图3-14 Array.prototype.map()实例方法处理稀疏数组情况</b> </p> ```ts const MAX_LENGTH = 10_000_000 const nums = new Array(MAX_LENGTH).fill(0).map((_, index) => index) ``` 然而先填充再map替换虽然可行,但需要两次遍历。我们可以使用Array.from()静态方法创建数组,因为Array.from()静态方法绝不会创建稀疏数组。如果arrayLike对象缺少一些索引属性,那么这些属性在新数组中将是undefined。Array.from() 有一个可选的参数mapFn,该参数允许你在创建数组时为每个元素执行一个函数,类似于map()。更明确地说,Array.from(obj, mapFn, thisArg) 和Array.from(obj).map(mapFn, thisArg)具有相同的结果,只是它不会创建中间数组,并且mapFn仅接受两个参数(element、index),不接受数组,因为数组仍然在构建中。 这完美符合我们测试数组的需求。 ```ts // Array.from(arrayLike, mapFn, thisArg) const nums = Array.from({ length: 10_000_000 }, (_, i) => i); ``` 我们要如何测算出两种查找算法所耗费的时间?利用Date.prototype.getTime()获取开始查找和查找结束之后的格林威治时间数值差是一个方法。但Performance.now()的精度会更高,和 JavaScript 中其他可用的时间类函数(比如Date.now)不同的是,window.performance.now()返回的时间戳没有被限制在一毫秒的精确度内,相反,它们以浮点数的形式表示时间,精度最高可达微秒级。另外一个不同点是,window.performance.now()是以一个恒定的速率慢慢增加的,它不会受到系统时间的影响(系统时钟可能会被手动调整或被 NTP 等软件篡改),这是一个很有意思的细节。 ```ts import sequentSearch from "./01_查找算法-顺序查找"; import binarySearch from "./02_查找算法-二分查找"; const MAX_LENGTH = 10_000_000 const nums = Array.from({ length: MAX_LENGTH }, (_, i) => i); const num = MAX_LENGTH / 2 const startTime = performance.now() // const index = sequentSearch(nums, num) const index = binarySearch(nums, num) const endTime = performance.now() console.log('索引的位置:', index, '消耗的时间:', (endTime - startTime)) // console.log(performance.now()) export {} ``` 根据测算的结果,索引的中间位置: 5_000_000所消耗的时间是0.10109999999997399。 除了以上的测试方法,我们还可以利用一些第三方库来更快捷的测试,通常第三方库会考虑更多的边界情况,拥有更完善的测试规则,这能够方便我们。 第三方库hy-algokit的代码地址:[hy-algokit - npm](https://www.npmjs.com/package/hy-algokit?activeTab=code),由coderwhy老师实现的npm工具包,我们后续还会在很多地方使用到。 根据第三方库hy-algokit的测试结果,一千万数据的测试时间效率差距大概是334.213倍,也是一个很大的效率差了。在不同的电脑中的不同时间下,这效率差也会有所不同,这一点和力扣提交代码的运行效率波动一样,需要辩证看待。 ```ts import { testOrderSearchEfficiency } from 'hy-algokit' import sequentSearch from "./01_查找算法-顺序查找"; import binarySearch from "./02_查找算法-二分查找"; testOrderSearchEfficiency(sequentSearch) // 数组长度:10000000 - sequentSearch 消耗时间: 5.0131999999999834 testOrderSearchEfficiency(binarySearch) // 组长度:10000000 - binarySearch 消耗时间: 0.014999999999986358 export { } ``` 对于顺序查找与二分查找的时间复杂度,我们通常用O(n)和O(log n)级别来表示,而不用具体时间来说明。因为具体时间不好表示,哪怕代码一致,每个人的电脑资源不同,网速不同,各种额外因素不同,都会导致具体时间发生偏移,哪怕自己连续两次运行,两次运行的时间也不会一样。因此固定的输入并不会产生固定的输出,这种不稳定的说明方式是不可使用的。那么是什么时候将大O表示法运用到算法分析这一领域的呢?这是我们接下来要探讨的问题。 ### 3.5.3 大O表示法 大O表示法的历史渊源可以追溯到19世纪末的数学研究领域。1894年,德国数学家保罗·巴赫曼在其著作《解析数论》中首次引入了这一概念,他使用字母"O"来表示"Ordnung"(德语中的"阶"或"顺序"),旨在简化复杂函数的渐近行为分析。 这一数学工具随后得到了另一位德国数学家埃德蒙·兰道的进一步发展和推广。在1909年及随后的工作中,兰道系统地使用并完善了大O表示法,建立了更加形式化的数学定义体系,以至于这一符号系统后来常被称为"兰道符号"。兰道的贡献使得大O表示法从巴赫曼的初步构想发展成为一个成熟的数学分析工具,为后续在计算机科学中的应用奠定了坚实的理论基础。 大O表示法从纯粹的数学领域过渡到计算机科学发生在20世纪中期,这一转变主要由计算机科学的先驱者们推动。随着电子计算机的出现和发展,科学家们开始面临一个全新的挑战:如何评估和比较不同算法的效率。在计算机资源极为有限的早期阶段,理解算法性能变得至关重要。正是在这样的背景下,计算机科学家们发现了大O表示法的潜力——它能够提供一种与具体机器无关的方法来描述算法性能。唐纳德·克努特在1960年代至1970年代的工作对这一过渡起到了关键作用,他在其里程碑式的著作《计算机程序设计艺术》中系统地将大O表示法引入算法分析领域,使其成为评估算法效率的标准工具。这解释了我们刚才大O表示法是什么时候运用到算法分析领域的疑惑。 大O表示法之所以能够在计算机科学中取得如此重要的地位,是因为它完美地解决了算法分析中的几个核心问题。首先,它提供了一种机器无关的性能度量方式,使得算法比较不再受特定硬件性能的影响。其次,它通过忽略常数因子和低阶项,让分析者能够专注于算法随输入规模增长的主要趋势,这正是评估算法可扩展性的关键。此外,大O表示法强调最坏情况分析,为算法性能提供了可靠的保证底线。这些特性使得大O表示法成为算法设计、系统架构和技术决策中不可或缺的工具,从简单的排序算法到复杂的分布式系统设计,都能看到它的应用价值。 随着计算机科学的不断发展,大O表示法也在持续演进和完善。它不仅催生了包括大Ω、大Θ、小o和小ω在内的完整渐近符号家族,还不断适应新的计算范式,如分布式计算、大数据处理和量子计算等。从巴赫曼最初的数学洞察到如今成为计算机科学教育的基石,大O表示法的历史轨迹展示了抽象数学概念如何转化为解决实际工程问题的强大工具,这一历程本身也体现了理论与应用之间富有成效的互动关系。 大O表示法和大学数学中的极限概念很相似,当我们说一个算法的时间复杂度是O(n²)时,我们实际上是在描述当输入规模n趋向于无穷大时,算法运行时间的增长趋势将被n²函数所主导。这直接对应到数学分析中的极限思想——我们关心的是函数在自变量趋于无穷时的渐近行为,而不是在某个具体点的精确值。 举个例子,解决一个规模为n的问题所花费的时间(或者所需步骤的数目)可以表示为如图3-15所示的二次表达式。当n增大时,n²项开始占据主导地位,其他各项可以被忽略。当n=500时,4n²项是2n项的1000倍大,因此在大多数场合下,省略后者对表达式的值的影响将是可以忽略不计的。通常为了更好理解,我们可以假设n等于无限(∞),在极端的情况下,应该被忽略的因素就会变得极其明显。  <p align="center"> <b>图3-15 一个二次表达式</b> </p> 大O表示法的O代表order of ...”(……阶)的大O,最初是一个大写希腊字母“Ο”(omicron),现今用的是大写拉丁字母“O”。O可以理解为一种"Order"(阶)的概念,每一阶层都是独立的性质,代表了截然不同的增长特性。因此进一步看,如果我们与任一其他级的表达式比较,n²的系数也是无关紧要的。在图3-15所示的情况下,我们就说该算法具有n²阶(平方阶)的时间复杂度,表示为O(n²)。 从了解大O表示法的历史中,我们其实能看到数据结构与算法和数学之间的联系是息息相关的。但学习基础的数据结构与算法的时候,并不要求对数学有太高深的要求,最大的共通之处在于对逻辑的追求是一致的。 大O表示法通过"阶"的概念来建立了清晰的分层结构,当我们说一个算法是O(1)时,意味着它处于常数阶——无论输入规模如何增长,执行时间都保持稳定。这是最高效的层级,代表着算法的理想状态。而O(log n)属于对数阶,其特点是随着输入规模翻倍,所需资源仅增加一个固定量,这种"减速增长"的特性使其在处理大规模数据时表现出色。除此之外,还都有哪些常见的阶层?在表3-1所示的常见时间复杂度等级中已经得以管中窥豹。随着阶层的抬升,效率之间的对比优势如何的呢?大O表示法复杂度阶层表如表3-2所示。除此之外还有一些迭代对数阶、反阿克曼函数,线性迭代对数的特殊复杂度阶层。 <p align="center"> <b>表3-2 大O表示法复杂度阶层表</b> </p> | 复杂度阶 | 名称 | 描述 | 示例算法 | 数据量翻倍时的变化 | | :--------------- | :--------- | :--------------------------- | :---------------------------------- | :----------------- | | **O(1)** | 常数阶 | 执行时间不随输入规模变化 | 数组按索引访问、哈希表查找 | 时间不变 | | **O(log log n)** | 双对数阶 | 极缓慢的增长 | 插值搜索的某些变体、Van Emde Boas树 | 几乎不变 | | **O(log n)** | 对数阶 | 每次操作将问题规模减半 | 二分查找、平衡二叉搜索树操作 | 时间增加常数 | | **O(√n)** | 平方根阶 | 比线性慢但比多项式快 | 质数检测(试除法)、某些图算法 | 时间增加约1.4倍 | | **O(n)** | 线性阶 | 执行时间与输入规模成正比 | 顺序查找、遍历数组 | 时间翻倍 | | **O(n log n)** | 线性对数阶 | 高效排序算法的典型复杂度 | 快速排序、归并排序、堆排序 | 时间略多于翻倍 | | **O(n²)** | 平方阶 | 执行时间与输入规模平方成正比 | 冒泡排序、选择排序、简单图遍历 | 时间变为4倍 | | **O(n³)** | 立方阶 | 三维数据处理典型复杂度 | 矩阵乘法(朴素)、Floyd-Warshall算法 | 时间变为8倍 | | **O(2ⁿ)** | 指数阶 | 组合爆炸问题 | 旅行商问题(暴力)、子集枚举 | 时间急剧增加 | | **O(n!)** | 阶乘阶 | 排列组合问题 | 旅行商问题(全排列)、图同构(暴力) | 时间无法承受 | ### 3.5.4 空间复杂度 空间复杂度是算法分析中与时间复杂度同等重要的概念,它衡量的是算法在运行过程中所需的存储空间资源。空间复杂度表示算法在运行过程中临时占用的存储空间大小与输入规模之间的关系,通常需要分析程序中需要额外分配的内存空间,如数组、变量、对象、递归调用等,同样使用大O表示法来描述。 在学习LeetCode的第206题反转链表的过程中,我们使用过栈结构的先进后出特性来实现反转。那时候我们说这种做法不是非常好,因为它的空间复杂度较高,即相较其他的循环和递归方法来说,多了一个栈结构,也就需要在运行过程中使用更多的存储空间。 举例如下:对于一个简单的递归算法来说,每次调用都会在内存中分配新的栈帧,这些栈帧占用了额外的空间,并且递归结束之前,栈帧不会被释放。因此,该算法的空间复杂度是O(n),其中n是递归深度。而对于迭代算法来说,在每次迭代中不需要分配额外的空间,因此其空间复杂度为O(1)。当空间复杂度很大时,可能会导致内存不足,程序崩溃,比如无限递归导致的爆栈(栈溢出较常见)。 而空间复杂度的实际计算规则主要有3点: (1)忽略输入数据本身占用的空间。 (2)只考虑算法运行所需的额外空间。 (3)考虑最坏情况下的空间需求。 在平时进行算法优化时,我们通常会进行如下3点考虑: (1)使用尽量少的空间(优化空间复杂度)。 (2)使用尽量少的时间(优化时间复杂度)。 (3)特定情况下:使用空间换时间或使用时间换空间。 空间复杂度不需要总结表格,常见的表现就常数、对数以及线性的表现,正如3.5.1小节开头说的一样,通常,我们更关注时间复杂度,因为时间(速度)往往是更稀缺的资源。但为什么这么认为呢?主要的原因是内存增长快于处理器性能提升(因此内存的使用余地更多),空间可以通过扩展解决,时间不行,并且用户对延迟的容忍度很低,我在App Store中看见很多低星打分和评价,往往是在延迟加载以及bug闪退上,因此速度直接影响收入和用户留存。 这就是为什么在算法设计和系统架构中,我们通常优先优化时间复杂度,在必要时才用空间换时间。当然,优秀的工程师会在两者之间找到最佳平衡点。在后续学到其他数据结构的时候,我们还会涉及到空间复杂度。 ## 3.6 数组与链表的复杂度对比 接下来,我们使用大O表示法来对比一下数组和链表的时间复杂度,如表3-3所示。 <p align="center"> <b>表3-3 数组和链表的时间复杂度比对</b> </p> | 数据结构 | 访问 (Access) | 搜索 (Search) | 插入 (Insertion) | 删除 (Deletion) | | :------- | :------------ | :------------ | :--------------- | :-------------- | | **数组** | O(1) | O(N) | O(N) | O(N) | | **链表** | O(N) | O(N) | O(1) | O(1) | 数组是一种连续的存储结构,通过下标可以直接访问数组中的任意元素。 数组的四种情况下的时间复杂度分析如下: (1)访问 O(1):通过索引直接访问任意元素,计算内存地址:`base_address + index * element_size`。 (2)搜索 O(N):需要遍历数组直到找到目标元素(最坏情况)。 (3)插入 O(N):在最坏情况下(在开头插入)需要移动所有后续元素。 (4)删除 O(N):在最坏情况下(删除开头元素)需要移动所有后续元素。 链表的四种情况下的时间复杂度分析如下: (1)访问 O(N):必须从头节点开始遍历到目标位置。 (2)搜索 O(N):需要遍历链表直到找到目标元素。 (3)插入 O(1):在已知位置插入只需修改指针引用。 (4)删除 O(1):在已知位置删除只需修改指针引用。 以上对数组和链表的时间复杂度分析更类似一种总结,在第2章线性结构与第3章(本章)的3.3小节手写链表中,我们已经详细学习过了,我觉得理解这些总结内容对我们而言是较为轻松的。 在实际开发中,选择使用数组还是链表需要根据具体应用场景来决定。如果数据量不大,对内存使用效率有追求,且需要频繁随机访问元素,使用数组可能会更好。如果数据量大,内存分配不确定,或者需要频繁插入和删除元素,使用链表可能会更好。
第2章 线性结构
## 2.1 数组 (Array) 数组是一种非常基础且广泛支持的数据结构,在大多数编程语言中都有直接或类似的实现。因此数组结构不需要我们从零实现,只需要了解如何使用及对应特点。 ### 2.1.1 线性结构的定义 线性结构(英文:Linear List)是由n(n≥0)个数据元素(结点)a[0],a[1],a[2]…,a[n-1]组成的有限序列。 线性结构的特性如下3点: (1)数据元素的个数n定义为表的长度 = “list”.length() (“list”.length() = 0(表里没有一个元素)时称为空表)。 (2)将非空的线性表(n>=1)记作:(a[0],a[1],a[2],…,a[n-1])。 (3)数据元素a[i](0≤i≤n-1)只是个抽象符号,其具体含义在不同情况下可以不同。 以上是维基百科对于线性结构的定义,是最严谨直接的说明方式。 在数据结构中,存在诸多结构,线性结构是数据结构中的较大分支。“线性结构”中的 “线性”,指数据元素之间排列得像一条线一样,想象一下日常生活中排成的一条直线队伍,队伍中除了第一个和最后一个人,每个人前面都有且仅有一个直接的前驱者,后面都有且仅有一个直接的后继者。这种“一个接一个”的、一对一的序列关系,就是“线性”最本质的特征。 因此在数据结构中,“线性”意味着数据元素之间存在 “一对一” 的逻辑关系。除了第一个元素,每个元素都有且仅有一个直接前驱;除了最后一个元素,每个元素都有且仅有一个直接后继。所有数据元素可以排列在一个单一的序列中,就像被串成一条线。 总结线性结构的如下3点核心特征: (1)有序性:元素是按照某种顺序排列的,有头有尾,有明确的先后次序。 (2)唯一的前驱和后继:每个元素在序列中的位置是固定的,和它相邻的元素是确定的。 (3)遍历的单一路径:从头到尾(或从尾到头)遍历整个结构时,你只有一条路可走,没有分支,没有环路(除非是循环链表等特殊设计)。 在诸多数据结构与算法相关的文章或者课程中,看见或者听说过数组/链表结构是一种线性结构;栈/队列结构是一种受限的线性结构。 数组在内存空间中通常占据一段连续的内存,在这段连续的数组内存中可以存在多个且不同的元素,以有序的方式组织。链表在内存空间中不占据连续内存,以多块小内存的形式存在,每块小内存的内部存储一个元素以及指向下一节点(下一内存块)的指针信息。链表(单向)中每一块指向下一节点的信息都是没有回头路且单一指向的,线性地从头奔到尾。 因此数组与链表都是线性结构,不同之处在于内存中的表达形式是连贯还是分裂。 栈类似一端开口的桶,元素只能从顶部放入和取出,遵循后进先出的规则。队列类似单行管道,元素从尾部进入,从头部离开,遵循先进先出的规则。无论是栈还是队列,每次放入或者取出元素都只能单个放入取出,不能并列放入取出,每个元素最多只能拥有一个直接前驱与直接后继,是线性的表达方式。且由于栈与队列操作元素的放入取出都有所限制,需要遵循一定的规则(后进先出、先进先出),因此将栈与队列称为受限的线性结构。4类线性结构有序图如图2-1所示。 PS:栈与队列的受限是特性,不是缺陷,受限在特定场景具备更大的优势。  <p align="center"> <b> 图2-1 4类线性结构有序图</b> </p> 主要的线性结构有数组、链表、栈、队列与字符串。在本章会学习数组、栈与队列,在第3章会学习链表。 ### 2.1.2 数组的特性与内存模型 数组(Array)结构是一种重要的数据结构,几乎是每种编程语言都会提供的一种原生数据结构(语言自带的),并且我们可以借助于数组结构来实现其他的数据结构,例如栈(Stack)、队列(Queue)、堆(Heap)。 通常数组的内存是连续的,这更有利于内存寻址,所以数组在知道下标值的情况下,访问效率非常高。通过数组与链表比对说明数组在内存寻址的优势: (1)数组:由于数组内存是连续的,所以数组的内存寻址可以依赖地址计算公式:元素内存地址 = 数组首地址 + 索引 × 每个元素占用的字节数。假设数组首地址为0x1000,每个元素占据4字节,现在需要访问arr[5],arr[5] 的地址 = 0x1000 + 5 × 4 = 0x1000 + 20 = 0x1014。 PS:数组首地址是一个十六进制数,索引与元素大小是十进制数,需要偏移量计算。20的十六进制表示是0x14。 数组这个地址计算只涉及一次乘法和一次加法。无论数组有多大(是有1万个元素还是10亿个元素),这个计算步骤都是固定的,其时间复杂度是 O(1),且CPU 和内存控制器对这种简单的算术运算有着极强的优化能力,可以在一个或几个时钟周期内完成。相比之下,从内存中读取数据本身所需的时间要比这个地址计算的时间长得多。 (2)链表:链表恰恰缺少这种“连续性”,由于链表的节点分散在内存的各个角落,要访问链表中第 i 个节点,CPU 没有直接的公式可以计算出它的地址,唯一的办法是从头节点开始,沿着 next 指针一个一个地遍历,直到第 i 个节点。这是一个 O(n) 的操作。 数组与链表的区别在于读取数据次数为O(1)与O(n),而从内存中读取数据是主要的耗时操作。因此数组通过下标可以计算所需的元素内存地址,从而实现一次读取,这是数组访问效率非常高的原因。 数组与链表具备的优势不同,数组读取方便,但由于数组有序性特点(连续),每一块内存中都有对应元素,替换元素简单,而插入元素会导致后继所有元素都需要向后移动,产生大量数据搬迁的开销,因此数组插入数据不如链表方便。数据与链表的对比如图2-2所示。  <p align="center"> <b> 图2-2 数组与链表对比</b> </p> 不同的数据结构运用的场景也不同,数据结构没有绝对的好坏,我们真正需要学习的,是在合适的场景下运用合适的数据结构。 早期的JavaScript有很多语言缺陷,那时候实现的数组实际是一个对象,下标被视为"字符串"属性名,其对应的内存不是连续的(类似链表的形式),因此读取性能较低。现代JavaScript引擎(如V8)已经进行了优化,当数组元素类型一致且连续时,引擎会使用连续的内存来存储数组,以提高性能。如果数组变得稀疏或者元素类型不同,引擎可能会切换到另一种表示方式(例如哈希表或链表式的结构)。关于稀疏数组处理方式与说明在《JavaScript高级编程权威指南》的23.4.1小节中有具体说明。 TypeScript中数组的各种用法,和JavaScript保持一致,不再详细讲解。MDN文档(Array):[Array - JavaScript | MDN](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Array)。后续学习数组和链表的关系区别时,会通过大O表示法来分析数组操作元素的时间复杂度问题。 ## 2.2 栈结构 (Stack) 栈是一种常见的数据结构, 并且在程序中的应用非常广泛。在2.1.2小节中了解数组是一种线性结构,并且可以在数组的任意位置插入和删除数据,但有些时候为了实现某些功能,我们必须对这种任意性加以限制,而栈与队列就是较为常见的受限的线性结构,我们先学习栈结构。 ### 2.2.1 栈的特性与LIFO原则 栈结构每次放入与取出都只能作用于栈顶元素,即后进先出特性。放入与取出的过程被称为进栈(入栈、压栈)与出栈(退栈)。因为栈只能从栈顶一端弹出数据,所以栈底元素想弹出则需要满足栈底上方所有元素先行出栈并且无进栈的前置条件(即栈底成为栈顶)。前置条件即栈结构的受限特性,受限特性在特定场景下有独特的使用需求,因此受限特性并不归类于设计缺陷。 LIFO(last in first out)表示最后进入的元素, 第一个弹出栈空间。 类似于自动餐托盘, 最后放上的托盘, 往往先把拿出去使用。对表的一端进行插入和删除运算,这一端被称为栈顶,相对另一端被称为栈底。把新元素放到栈顶元素的上面。新元素会称为栈顶元素;把栈顶元素删除掉,相邻的元素会成为新的栈顶元素。因此栈顶元素与栈底元素并不具体指某一元素,而是位置上的概念。栈结构示意图如图2-3所示。  <p align="center"> <b> 图2-3 栈结构示意图</b> </p> 生活中类似栈的如收实体邮件,从上往下依次处理这些邮件,最新到的邮件最先处理。这不允许改变邮件的处理次序,例如从最早到的或者最紧急的邮件开始处理,否则就不是栈结构,而是队列或者优先级队列结构。 了解栈的概念后,来完成一道栈结构的面试题: 题目:有六个元素6,5,4,3,2,1的顺序进栈,问下列哪一个不是合法的出栈序列? A:5、4、3、6、1、2。 B:4、5、3、2、1、6。 C:3、4、6、5、2、1。 D:2、3、4、1、5、6。 六个元素的顺序进栈,很容易想到1、2、3、4、5、6的出栈序列,但选项中并没有这一答案。1~6的出栈序列只是众多合理出栈序列的其中一种,我们不应该使用排除法一个个计算,因为排除法的效率过低。需要根据入栈与出栈的受限特性,来推断怎么样的逻辑是不合理的。 进栈与出栈不强制要求一次性进栈后再一次性出栈(这种做法只有一种结果),只规定进栈顺序需要按6到1的顺序。 正规做法(以A为例):进栈6、5,出栈5;进栈4,出栈4;进栈3,出栈3;出栈6;进栈2、1,出栈1,出栈2。 快速做法(以A为例):进栈写下,出栈删除,不合理的地方会自动冒出来(例如当你想删6,结果6不是栈顶时为不合理),多动笔,不空想。用笔或者键盘来模拟过程。 > 快速做法模拟: > > 65 => 进栈6、5。 > > 6 => 出栈5。 > > 64 => 进栈4。 > > 6 => 出栈4。 > > 63 => 进栈3。 > > 6 => 出栈3。 > > 21 => 出栈6,进栈2、1。 > > 2 => 出栈1。 > > 出栈2,清空。 快速做法类似于珠算的过程,完美利用栈结构的受限特性,只需要遵循后进先出的规则对最右侧内容进行增删,当运算熟练,可在几秒内得出结果,无需真正思考。 选项B:4、5、3、2、1、6。 模拟过程:进栈6、5、4,出栈4;出栈5;进栈3,出栈3;进栈2,出栈2;进栈1,出栈1;出栈6。合法。 选项C:3、4、6、5、2、1。 模拟过程:进栈6、5、4、3,出栈3;出栈4;此时栈顶为5,但出栈序列要求出栈6,而6在栈底,无法直接出栈,必须先出栈5。但出栈序列中6在5之前,因此不合法。 选项D:2、3、4、1、5、6。 模拟过程:进栈6、5、4、3、2,出栈2;出栈3;出栈4;进栈1,出栈1;出栈5;出栈6。合法。 ### 2.2.2 栈结构的实现(基于数组) 在JavaScript/TypeScript中使用Stack(栈),往往会直接使用数组。数组与栈有所不同,如果想把数组当栈来使用,只需要避免在数组中去增删元素,永远只对数组最后一个元素进行操作。数组天生就拥有栈所需的方法,我们不需要改变数组本身,只是选择性地使用它的部分功能。 实现栈结构有以下2种常见方式: (1)基于数组实现。 (2)基于链表实现。 链表也是一种数据结构,目前我们尚未学习,并且JavaScript中并没有自带链表结构,后续我们会在第3章从零实现链表结构,并对比数组与链表的区别。因此,我们此处实现的栈结构底层基于数组。 首先我们需要创建一个栈的类,用于封装栈相关的操作。后续需要使用栈时,就能够通过创建实例对象来复用栈结构。 ```ts // 封装一个栈 class ArrayStack { // 定义一个数组/链表,用于存储数据 private data: any[] = [] } // 使用栈结构 const stack1 = new ArrayStack() ``` ### 2.2.3 栈的常见操作方法 在栈中需要实现以下6点功能: (1)定义一个数组,用于存储数据。 (2)实现push(element)方法:添加新元素到栈顶位置(进栈)。 (3)实现pop()方法:移除栈顶元素,同时返回被移除元素(出栈)。 (4)实现peek()方法:返回栈顶元素,不对栈做任何修改(不移除栈顶元素,仅返回栈顶元素信息)。 (5)实现isEmpty()方法:判断栈内元素是否为空,返回布尔值。 (6)实现size()方法:获取栈内元素个数,返回阿拉伯数字,与数组length属性类同。 使用数组存储元素,暂时将数组定义为any类型,后续采用泛型重构。目前完成最基础的栈结构模型,接下来在栈结构模型中实现栈所对应的方法。 ```ts // 封装一个栈(第一版栈封装) class ArrayStack { // 定义一个数组/链表, 用于存储元素 private data: any[] = [] // 实现栈中相关的操作方法 // push(element)方法:添加新元素到栈顶位置(进栈) push(element: any): void { this.data.push(element) } // pop()方法:移除栈顶元素,同时返回被移除元素(出栈) pop(): any { return this.data.pop() } // peek()方法: 返回栈顶元素,不对栈做任何修改(不移除栈顶元素,仅返回栈顶元素信息) peek(): any { return this.data.at(-1) } // isEmpty()方法:判断栈内元素是否为空,返回布尔值 isEmpty(): boolean { return this.data.length === 0 } // size()方法:获取栈内元素个数,返回阿拉伯数字 size(): number { return this.data.length } } // 创建ArrayStack的实例对象 const stack1 = new ArrayStack() stack1.push("coderwhy") stack1.push("XiaoYu") stack1.push("数据结构与算法") console.log(stack1.peek()) console.log(stack1.pop()) console.log(stack1.pop()) console.log(stack1.pop()) console.log(stack1.isEmpty()) console.log(stack1.size()) export { } ``` 实现push(element)、pop()、peek()、isEmpty(),size()方法较为简单,很多都是数组本身所拥有的方法。但正如2.2.2小节开头所说数组天生就拥有栈所需的方法,我们封装栈最主要的目的是做出限制,令ArrayStack类的实例对象只能使用封装中的方法,而不能直接操作ArrayStack类中的data数组。 注意:TypeScript代码不能在浏览器与Node.js中运行,需要全局安装ts-node。 ```ts // 全局安装ts-node pnpm install -g ts-node // 检查是否安装成功 or 之前是否安装过 ts-node --version //运行代码 ts-node xxx.ts ``` 且由于peek()方法中使用ES13中的Array.prototype.at()方法,因此需要将在tsconfig.json文件中更新lib和target配置。 ```ts { "compilerOptions": { "target": "ES2022", "lib": ["ES2022", "DOM"] } } ``` 完成栈的类封装后,我们对栈进行重构,使用泛型替代any实现类型约束。 目前ArrayStack类的私密属性data与各个方法采用any定义,而any实际无约束力,过多的any做法会使TypeScript与JavaScript区分不开。 **栈结构-优化1**:在使用栈方法,即进栈/出栈/获取元素时,使用泛型得知元素的具体类型,从而获取更友好的代码提示。 TypeScript泛型语法为`<T>`,T可以理解为Type(类型)的缩写,但同时也可以是任何有效的标识符,使用方式类似参数,T是"形参",在使用时传入具体类型"实参"。我们将第一版栈封装的any类型全部替换为泛型,将传入与传出的类型交由使用者决定。但泛型在作用于pop()与peek()实例方法时出现类型报错,如图2-4所示。 因为栈内部是有可能空的,而Array.prototype.pop()实例方法从空数组中删除元素时返回undefined。any类型包括了undefined,而泛型T是"某种具体的类型",默认不包含undefined。Array.prototype.at()实例方法的返回值也是类似原因,如果index < -array.length或index >= array.length,则总是返回undefined,而不会尝试访问相应的属性。  <p align="center"> <b> 图2-4 pop()与peek()实例方法的类型报错</b> </p> pop()与peek()实例方法的类型报错使用联合类型 T | undefined 处理解决。 ```ts // 封装一个栈 class ArrayStack<T> { // 定义一个数组/链表, 用于存储元素 private data: T[] = [] // 实现栈中相关的操作方法 // push(element)方法:添加新元素到栈顶位置(进栈) push(element: T): void { this.data.push(element) } // pop()方法:移除栈顶元素,同时返回被移除元素(出栈) pop(): T | undefined { return this.data.pop() } // peek()方法: 返回栈顶元素,不对栈做任何修改(不移除栈顶元素,仅返回栈顶元素信息) peek(): T | undefined { return this.data.at(-1) } // isEmpty()方法:判断栈内元素是否为空,返回布尔值 isEmpty(): boolean { return this.data.length === 0 } // size()方法:获取栈内元素个数,返回阿拉伯数字 size(): number { return this.data.length } } // 创建ArrayStack的实例对象 const stack1 = new ArrayStack<String>() stack1.push("coderwhy") stack1.push("XiaoYu") stack1.push("数据结构与算法") ``` 由于栈结构除了使用数组实现,还可以通过链表或者其余方式实现。无论通过哪种方式实现栈结构,都需要满足栈结构所必要的5个实例方法以及对应的数据存储模式。如果需要多次通过不同方式实现栈结构,那每一次实现栈结构都需要回顾所需实现的实例方法等,这会有所不便。 ```ts // 使用链表实现栈结构 class LinkedStack<T> { push(element: T) { } pop() { } peek() { } isEmpty() { } size() { } } ``` **栈结构-优化2**:对于多次实现栈结构的不便问题,可以使用TypeScript定义接口来解决。定义接口及使用如下3步骤: (1)创建IStack.ts文件用于存放接口代码。 (2)编写接口代码并导出。 (3)导入需实现栈结构的文件,并通过implements使用接口。 使用接口后,实现的栈结构若未满足接口所需的方法定义需求,则栈结构报错。 ```ts // IStack.ts文件 // 定义栈的结构 interface IStack<T> { push(element: T): void pop(): T | undefined peek(): T | undefined isEmpty(): boolean size(): number } // 导出接口 export default IStack // 需实现栈结构的文件 import IStack from "./IStack" class ArrayStack<T> implements IStack<T> { // ...ArrayStack内部实例方法省略 } export default ArrayStack ``` TypeScript的接口(interface)和继承(class extends)是不同的概念,最核心的理念区分为: - 接口关注"做什么",即只有声明无实现。 - 继承关注"如何做"。 ### 2.2.4 栈的面试题:十进制转二进制 我们已经学会了如何使用Stack类,现在就用它解决一些计算机科学中的问题。 人类习惯使用十进制,所以我们在编程、输入数据、显示数据时,通常使用十进制。但在计算机科学中,二进制非常重要,因为计算机里的所有内容都由二进制数字表示(0与1)。如果没有十进制与二进制相互转换的能力,与计算机交流就会很得困难。因此将各类进制(十六、十、八进制)转为二进制是计算机科学和编程领域中经常使用的算法,至今为止仍有很多网站专门帮助开发者进行进制转换,如图2-5所示。  <p align="center"> <b> 图2-5 十进制转二进制网站</b> </p> 2.2.4小节的面试题就需要我们利用栈结构来实现十进制转二进制,要将十进制转为二进制,可将该十进制数字和2整除(二进制为满二进一),直到结果是0为止。 将十进制数字35转换为二进制数字,过程示例如下: (1)35除2,余1,结17。 (2)17除2,余1,结8。 (3)8除2,余0,结4。 (4)4除2,余0,结2。 (5)2除2,余0,结1。 (6)1除2,余1,结0。 以上整除示例一共分六步,则十进制转为二进制为6位数,位数具体数字为步骤逆序的余数,因此十进制35等同二进制100011。 通过该十进制35转换示例的**位数具体数字为步骤逆序的余数**信息,可利用栈结构的后进先出规则,将计算的余数结果进栈,当结为0视为计算结束,将栈内数据出栈,出栈组成结果即为二进制结果。 涉及计算的部分,可翻阅MDN文档中的Math内置对象中的静态方法。让我们自己来设计十进制转二进制的函数方法,传入十进制信息,返回传入十进制所对应的二进制结果。设计过程需分4步: (1)获取传入十进制信息。 (2)对十进制信息整除2,获取整除余数填入栈,获取整除结果继续整除2,当整除结果为0结束运算。 (3)将栈结构内容弹出并拼接。 (4)返回拼接结果。 返回值以字符串形式表达来避免二进制结果自动转换为十进制表达。 ```ts function decimalToBinary(decimal: number): string { // 1.创建一个栈, 用于存放余数 const stack = new ArrayStack<number>() // 2.使用循环: // while: 不确定次数, 只知道循环结束跳转 // for: 知道循环的次数时 while (decimal > 0) { // 整除结果为0结束运算 // 对十进制信息整除2 const result = decimal % 2 // 获取整除余数填入栈 stack.push(result) // 获取整除结果继续整除2 decimal = Math.floor(decimal / 2) } // 3.所有的余数都已经放在stack中, 以此取出即可 let binary = '' while (!stack.isEmpty()) { // 栈空结束拼接 binary += stack.pop() // 将栈结构内容弹出并拼接 } return binary // 返回拼接结果 } console.log(decimalToBinary(35)) ``` 除栈结构的写法之外,还可以采用递归与Math内置对象的静态方法来简化步骤。由于递归会执行到整除2的最深层再从最深处往外依次返回结果,因此可以直接得出进制结果,而无需利用栈的特性。 ```ts function decimalToBinary(decimal: number): string { return decimal === 0 ? '0' : decimalToBinary(Math.floor(decimal / 2)) + (decimal % 2); } console.log(decimalToBinary(35)); // "0100011" ``` 在实际使用中,可利用高度内聚的Array.prototype.toString()实例方法来快速完成进制转换(该实例方法封装了多种进制转换方式,进制转换取决于传入的数字,当数字为2即二进制转换,数字为8即八进制转换),但Array.prototype.toString()实例方法的核心算法依旧是重复除法取余 + 逆序排列。除此之外还有高性能的位运算做法,因此实现进制转换的方式有多种,可在课余时间再额外探索。 ```ts const decimalToBinary = (n: number): string => n.toString(2); console.log(decimalToBinary(35)); // "100011" ``` ### 2.2.5 栈的面试题:有效括号匹配 题目:给定一个只包括 `'('`,`')'`,`'{'`,`'}'`,`'['`,`']'` 的字符串 `s` ,判断字符串是否有效。 来源:LeetCode 20:[20. 有效的括号 - 力扣(LeetCode)](https://leetcode.cn/problems/valid-parentheses/description/) 有效字符串需满足以下3点: (1)左括号必须用相同类型的右括号闭合。 (2)左括号必须以正确的顺序闭合。 (3)每个右括号都有一个对应的相同类型的左括号。 一句话概括:同类型括号按左右顺序成对且正确嵌套即视为有效字符串。有效括号匹配示例如图2-6所示。  <p align="center"> <b> 图2-6 有效括号匹配示例</b> </p> 做好题目的前提是足够了解题目,总结有效括号匹配示例的规律,可见有效匹配有三种情况:简单配对、多种括号独立配对,正确的嵌套配对。该题目考验的核心能力为以下3点: (1)理解能力。 (2)解析能力。 (3)对数据结构的掌握力。 算法题目更多考验的是面试者在面对题目时的思考逻辑、表达力、理解力以及解析力等多维度能力,因此在学习数据结构与算法的过程中,更应该侧重锻炼这些能力,而非直接拿取他人优解来照抄。 ```ts "()" // 简单配对 "()[]{}" // 多种括号独立配对 "([{}])" // 正确嵌套:最后开的括号最先闭合 ``` 在概括总结中体现理解题目的能力;在有效括号匹配示例中体现解析规则的能力;那对数据结构的掌握力体现在哪里?这是一道关于栈的面试题,我们要将栈的特性与该题目联系在一起。 正确嵌套:最后开的括号最先闭合。因此最后的右括号必先最先匹配同类型左括号,依次类推。由于左括号先,所以左括号判断条件在前。根据以上规则总结,可通过栈结构的最后进栈的元素最先出栈特性来匹配。 题目完成如下4步骤: (1)获取传入字符串内容并遍历。 (2)对遍历元素判断左括号,是左括号则进栈一个同类型右括号。 (3)对遍历元素判断右括号,右括号与栈内弹出内容匹配,若不匹配则返回false结束循环,若匹配则继续后续判断。 (4)当栈为空时,结束判断。 ```ts import { ArrayStack } from "./ArrayStack" function isValid(s: string): Boolean { // 创建栈结构 const stack = new ArrayStack<string>() // 获取传入字符串内容并遍历。 for (let i = 0; i < s.length; i++) { // 对遍历元素判断左括号,是左括号则进栈一个同类型右括号 if (s[i] === '(' || s[i] === '{' || s[i] === '[') { if (s[i] === '(') { stack.push(')') } if (s[i] === '{') { stack.push('}') } if (s[i] === '[') { stack.push(']') } } else { // 对遍历元素判断右括号,右括号与栈内弹出内容匹配,若不匹配则返回false结束循环,若匹配则继续后续判断。 if (s[i] !== stack.pop()) { return false } } } // 当栈为空时,结束判断。 return stack.isEmpty() } // 判断示例 console.log(isValid("()")); console.log(isValid("()[]{}")); console.log(isValid("(]")); console.log(isValid("([])")); console.log(isValid("([)]")); ``` 大多数情况下,if语句没有switch语句性能高,但if语句可读性高。在后续实现数据结构,若条件数量多,可更多的使用switch语句,性能更高。 ```ts for (let i = 0; i < s.length; i++) { const c = s[i] switch (c) { case "(": stack.push(")") break case "{": stack.push("}") break case "[": stack.push("]") break default: if (c !== stack.pop()) return false break } } ``` 在实际编写中,可采用Map结构作为左右同类型括号的映射关系,在编写代码时会更为精简。可在课余时间扩展更多写法,在LeetCode中测试性能指标等等。 ```ts import { ArrayStack } from "./ArrayStack" function isValid(s: string): boolean { const stack = new ArrayStack<string>() const map: { [key: string]: string } = { ')': '(', '}': '{', ']': '[' }; for (const char of s) { if (!map[char]) { // 左括号入栈 stack.push(char); } else { // 右括号:检查栈顶是否匹配 if (stack.pop() !== map[char]) return false; } } return stack.isEmpty() } ``` 通过十进制转二进制以及有效括号匹配这两道面试题,我们利用栈结构的受限特性来完成需求,更深刻的认识受限特性并非缺陷,而是精心设计的约束,在某些场景下更高效可靠。栈结构通过以下3点体现了"做一件事并做好"的Unix哲学: (1)单一职责:只管理顺序访问。 (2)最小接口:减少认知负荷。 (3)可预测性:行为完全确定。 ## 2.3 队列结构 (Queue) 我们在2.2小节学习栈结构这一受限的线性结构,并且基于该受限的数据结构解决某些特定问题,从而实现特别效果。接下来我们再来学习另外一个受限的数据结构:队列。 ### 2.3.1 队列的特性与FIFO原则 队列(Queue),它是一种受限的线性表,先进先出(FIFO First In First Out),队列受限之处在于以下两点: (1)只允许在队列的前端(front)进行删除操作(出队)。 (2)只允许在队列的后端(rear)进行插入操作(入队)。 队列结构的受限操作(删除与插入)如图2-7所示。  <p align="center"> <b> 图2-7 队列结构的受限操作</b> </p> 生活中的队列例如排队购票,先到窗口的先买票;餐厅取号,按号码顺序叫号就餐;打印机任务,先发送的文档先打印;高速公路收费站,先进入的车道先通过;客服热线,先拨打的电话先接通。生活中的队列通常体现的是先来先服务的公平规则,确保资源按到达次序分配,避免混乱与争执。 开发中的队列例如线程队列,为了让任务可以并行处理,通常会开启多个线程,但是我们不能让大量的线程同时运行处理任务 (占用过多的资源)。此刻如果有需要开启线程处理任务的情况,我们就会使用线程队列。线程队列用于控制并发线程数量,通过有序调度避免资源过载:当需要并行处理任务时,线程队列按提交顺序依次启动线程执行任务,既实现了并行计算的优势,又防止了同时运行过多线程导致的系统资源耗尽问题。 队列还有很多其余应用,后续的很多算法中也会用到队列(例如第5章的二叉树层序遍历)。 ### 2.3.2 队列的实现(基于数组) 队列的实现与栈相同,有以下两种方案: (1)基于数组实现。 (2)基于链表实现。 队列基于链表实现会更好,因为在链表尾部添加节点(入队)和在链表头部移除节点(出队),无需元素搬移,操作直接完成。数组出队需要移动所有后续元素,队列中的出队后续元素越多,造成的性能影响越明显,在2.1.2小节中有详细说明。 接下来我们需要创建一个类来表示一个队列,目前尚未学习链表,因此队列仍先基于数组实现。 ```js class ArrayQueue <T> { // 内部数据通过数组(链表)保存 private data: T[] : []; } ``` ### 2.3.3 队列的常见操作方法 队列与栈类似,通过一些方法来实现队列的功能特性,队列应实现以下5个常见操作方法: (1)实现enqueue(element) 方法:向队列尾部添加一个(或多个)新的项。 (2)实现dequeue()方法:移除队列的第一(即排在队列最前面的)项,并返回被移除的元素。 (3)实现front/peek()方法:返回队列中第一个元素信息——最先被添加,也将是最先被移除的元素。队列不做任何变动(不移除元素,只返回元素信息——与Stack类的peek()方法非常类似)。 (4)实现isEmpty()方法:如果队列中不包含任何元素,返回true,否则返回false。 (5)实现size()方法:返回队列包含的元素个数,与数组的length属性类似。 队列与栈的操作方法极其类似,最大的不同在于移除元素部分,队列采用dequeue()方法移除排在队列最前面的项。dequeue()方法复用Array.prototype.shift()实例方法,即从数组中删除第一个元素,并返回该元素的值,此方法更改数组的长度。 ```ts import IQueue from "./IQueue" class ArrayQueue<T> implements IQueue<T> { // 内部是通过数组(链表)保存 private data: T[] = [] enqueue(element: T): void { this.data.push(element) } dequeue(): T | undefined { return this.data.shift() } peek(): T | undefined { return this.data[0] // 使用Array.prototype.at()实例方法也能做到同样效果 } isEmpty(): boolean { return this.data.length === 0 } size(): number { return this.data.length } } export default ArrayQueue ``` 队列最重要的是enqueue(element)入队方法与dequeue()出队方法,因此可对队列实行接口约束。 ```ts // IQueue.ts文件 import IList from "../types/IList" interface IQueue<T> extends IList<T> { // 入队方法 enqueue(element: T): void // 出队方法 dequeue(): T | undefined } export default IQueue ``` 对于队列与栈共通的方法,可进一步分层抽象为IList接口,然后由各自对应的Queue、Stack等接口继承。该分层做法会使结构定义与来源更为清晰。 ```ts // /types/IList.ts文件 export default interface IList<T> { // peek peek(): T | undefined // 判断是否为空 isEmpty(): boolean // 元素的个数 size(): number } ``` 在使用ArrayQueue类中的size()实例方法获取元素个数时,所对照的是数组的length属性。将实例方法获取与属性获取等同起来是略显割裂的,因为获取元素个数应该是属性而非行为,方法调用通常暗示有计算或操作而非只读行为,因此获取元素个数采用实例方法调用就不太合适。可在IList接口与ArrayQueue类中,在size()实例方法前加上get访问器,则size()实例方法使用时不能方法调用,而是像属性一样访问,提供了更好的语义表达和类型安全。 ```ts // IList接口 get size(): number // ArrayQueue类 get size(): number { return this.data.length } //调用队列的size属性 const queue = new ArrayQueue<string>() console.log(queue.size) ``` ### 2.3.4 队列的面试题:击鼓传花 击鼓传花是一个常见的面试算法题,原规则:班级中玩一个游戏,所有学生围成一圈,从某位同学手里开始向旁边的同学传一束花。此时某个人(例如班长),在击鼓,鼓声停下的一颗,花落在谁手里,谁就出来表演节目。 修改后的编程版规则:几个朋友一起玩一个游戏,围成一圈,开始数数,数到某个数字的人自动淘汰。最后剩下的人获得胜利,请问最后剩下的人,他最初的位置在哪? 击鼓传花若基于队列实现,封装对应函数需要基于以下2个条件: (1)参数:所有参与人的名字以及对应的位置数字,淘汰规则。 (2)结果:最终剩余一人的姓名位置。 击鼓传花每一次数数,前端都有一个人出队,若鼓声未停,则出队的人从后端重新入队,前端再出队一人,依次循环,直到鼓声停止,出队的人不再重新入队。队列的击鼓传花如图2-8所示。  <p align="center"> <b> 图2-8 队列的击鼓传花</b> </p> 击鼓传花的游戏想要进行下去,需要把控好淘汰节奏以及终止条件,即每次淘汰的鼓点停止时机节点,以及当最终剩余一人游戏结束。最终剩余一人的判定条件为queue.size为1时,但淘汰的节奏则可以多变,使用者可以根据自身实际情况选择适当的游戏节奏点进行暂停,以下代码示例采用固定的淘汰节奏,即每次都在固定次数后淘汰一人。 游戏的具体轮数为游戏人数-1,即多轮游戏。而多轮游戏可采用for语句或switch语句来循环游戏轮数,在2.2.5小节中有探索两者的区别,在此选择switch语句来循环游戏轮数。一开始将所有姓名入队的操作采用for...of语句或者Array.prototype.forEach()实例方法都行,但在初次学习数据结构与算法时,采用可读性更好的for...of语句会更好,当对数据结构与算法和TypeScript都足够熟悉后,可自由采用更简介明了的高阶函数写法。 最后让我们来复习一遍击鼓传花的函数逻辑为如下5步: (1)创建击鼓传花函数,传入所有参与人的名字以及对应位置数字的数组和淘汰规则(每轮淘汰第num个位置的人)。 (2)在击鼓传花函数中创建队列结构并将所有参与人入队。 (3)利用淘汰规则淘汰至最后一人后终止击鼓传花游戏。 (4)从仅剩最后一人的队列中取出最后一人的姓名leftName常量。 (5)在一开始传入的所有参与人的数组中找到leftName常量的下标进行返回,展现给用户数组下标加1的位置。 ```ts import ArrayQueue from './Queue' function hotPotato(names: string[], num: number): number { // 传入的参与人数组内,至少长度为1(即击鼓传花游戏参与人数至少1人) if (names.length === 0) return -1 // 1.创建队列结构 const queue = new ArrayQueue<string>() // 2.将所有的name入队操作 // forEach写法:names.forEach(name => queue.enqueue(name)) for (const name of names) { queue.enqueue(name) } // 3.淘汰的规则 while (queue.size() > 1) { // 1/2不淘汰 for (let i = 1; i < num; i++) { // 先出队 const name = queue.dequeue() // 鼓声没停则重新入队 if (name) queue.enqueue(name) } // 鼓声停止则真正出队淘汰 queue.dequeue() } // 4.取出最后一个人 const leftName = queue.dequeue()! const index = names.indexOf(leftName) return index } const leftIndex = hotPotato(["coderwhy", "xiaoyu", "coderwhy666", "xiaoyu2002","why", "javaScript", "TypeScript", "data"], 4) console.log(`获胜者在原始列表中的索引: ${leftIndex}`) console.log(`从用户角度看是第 ${leftIndex + 1} 个人`) ``` Array.prototype.indexOf()实例方法用于返回数组中第一次出现给定元素的下标。而数组下标是从0开始,普通人理解的开始是"第1个人"而不是"第0个人",因此最终展现位置时需要数组下标加1。 ### 2.3.5 队列的面试题:约瑟夫环问题 阿桥问题(有时也称为约瑟夫斯置换),是一个出现在计算机科学和数学中的问题。在计算机编程的算法中,类似问题又称为约瑟夫环。公元1世纪,犹太历史学家弗拉维奥·约瑟夫和40名战友被罗马军队围困在洞穴中。他们面临选择:是被俘受辱还是集体自杀? 这些人最终决定自杀,并以抽签的方式决定谁杀掉谁——他们围成一个圆圈,按照固定规则依次处决同伴: (1)从某个人开始报数。 (2)每数到指定数字就处决当前的人。 (3)然后从下一个人重新开始报数。 (4)循环直到只剩最后一人。 据说约瑟夫通过数学计算,提前找到了那个能活到最后的安全位置,从而保住了自己的性命。这就是著名的约瑟夫环问题——在n个人围成的圈中,从第k个人开始报数,数到m的人出局,求最后幸存者的原始位置。约瑟夫环问题可以用动态规划或者队列来解决,队列相对动态规划更方便实现,但消耗性能更多。动态规划会在第11章学习,在此处我们采用队列实现,与击鼓传花面试题类似。 题目需求:0,1,···,n-1这n个数字排成一个圆圈,从数字0开始,每次从这个圆圈里删除第m个数字(删除后从下一个数字开始计数)。求出这个圆圈里剩下的最后一个数字。例如,0、1、2、3、4这5个数字组成一个圆圈,从数字0开始每次删除第3个数字,则删除的前4个数字依次是2、0、4、1,因此最后剩下的数字是3。 ```ts import ArrayQueue from './Queue' function lastRemaining(n: number, m: number) { // 1.创建队列 const queue = new ArrayQueue<number>() // 2.将所有的数字加入到队列中 for (let i = 0; i < n; i++) { queue.enqueue(i) } // 3.判断队列中是否还有数字 while (queue.size() > 1) { for (let i = 1; i < m; i++) { queue.enqueue(queue.dequeue()!) // 出队后入队 } queue.dequeue() // 淘汰 } return queue.dequeue()! } console.log(lastRemaining(5, 3)) // 3 console.log(lastRemaining(10, 17)) // 2 ``` 此时如果队列中的数字数量过大,不断的从队列前端进行出队操作和后端进行入队操作,对数组而言非常消耗性能。因此可在课下额外探索约瑟夫环的其余做法,例如链表、动态规划等。 ```ts // 动态规划做法 function lastRemaining(n: number, m: number) { let position = 0; for (let i = 2; i <= n; i++) { position = (position + m) % i; } return position; } console.log(lastRemaining(5, 3)) // 3 console.log(lastRemaining(10, 17)) // 2 ``` 在本章线性结构中学习了数组结构、栈结构以及队列结构。下一章会学习链表结构。链表虽然也是线性结构,但实现方式(链式存储)与数组(顺序存储)有本质区别,且内容较多,因此我们放在第3章单独讲解。
第1章 邂逅数据结构与算法
# 第1章 邂逅数据结构与算法 ## 1.1 为什么需要学习数据结构与算法? 为什么要学习数据结构与算法?这是一个非常核心且经典的问题。很多初学者会疑惑:“现在各种编程语言都有强大的库和框架,像JavaScript直接调用Array.prototype.sort()方法不就能排序了吗?为什么还要花那么大功夫去学冒泡排序、快速排序这些底层的东西?” 学习数据结构与算法,不是为了死记硬背几个标准实现,而是为了培养一种“内功”,一种用计算机思维高效解决复杂问题的能力。归纳总结,编程的世界是对数据的处理,而数据结构与算法是对数据处理最直接的体现。编程的最终目的是对数据进行操作与处理。通过一个人是否可以更好的操作和处理数据来评判他的编程能力、水平的高低。 在当下AI盛行的时代,这门课程还有没有必要学习?我觉得是有必要的,因为数据结构与算法并不属于工具类内容(随用随学),本质是一个人底层的核心逻辑,无时无刻都在发挥作用,数据结构与算法通过AI临时掌握的概率很低,是更需要理解的课程。结合这门课程去理解应用层操作,能够利用AI更加得心应手,上手各种热门新技术也更快。 "术之尽头,炁体源流"这句话在《异人之下》的语境里,讲的是一个修行者的终极认知跃迁,是一个很有意思的概念。你可以穷尽一生去修炼各种术法、手印、法门,把每一招每一式打磨到极致,但当你真正走到"术"的尽头时,会发现所有外在的技法不过是"炁"这个根源之力的不同表达形式。术是手段,炁是本体;术千变万化,炁归于一源。修行者如果只在术的层面打转,永远只是匠人;唯有触摸到炁体源流,才算真正开悟。 把这个框架映射到计算机体系里,"术"对应的就是我们日常接触的各种上层技术——框架、语言、工具链、设计模式、架构方案。React、Vue、Spring Boot、Docker、K8s……每隔几年就会有新的"术"涌现,开发者疲于追赶,像极了修行者在不同法门之间辗转腾挪。而"炁体源流",在计算机世界里,正是数据结构与算法这一层。它不是某种具体的技术,而是所有技术得以成立的底层逻辑根基。数据库的B+树索引、操作系统的进程调度队列、网络协议栈的哈希表路由、编译器的抽象语法树——你把任何一个看似复杂的系统层层剥开,最终裸露出来的骨架,都是数据结构。 更深一层来看,"术之尽头"这四个字暗含了一种认知上的"撞墙"体验。很多开发者在职业生涯中都会遇到这面墙:框架用得再熟也不知道它为什么快,SQL写得再溜也不理解查询优化器在干什么,分布式系统出了诡异的bug却无从下手。这面墙的本质,就是"术"的天花板。你在API的层面已经走到头了,再往前一步,必须看见底下那层东西——数据如何组织、如何流动、如何以最小的代价完成存取和变换。这就是"炁体源流"的含义:不是让你放弃术,而是让你理解术之所以成立的根因。当你真正理解了哈希表为什么是O(1)、红黑树为什么能自平衡、图的最短路径为什么能用松弛操作求解,你再回头去看那些框架和工具,会发现它们不过是这些基本结构的排列组合与工程封装,就像修行者悟了炁的本质之后,看一切术法都成了炁的不同姿态。 所以这句话最精妙的地方在于"源流"二字。它不是说"炁体本源"这种静态的说法,而是"源流"——源是起点,流是运动。数据结构在计算机体系中的地位也恰恰如此:它既是源头,定义了数据最基本的存在形态;又是流动的,因为算法本质上就是数据结构在时间维度上的展开,是结构从一个状态流向另一个状态的过程。一棵AVL树的旋转、一个堆的上浮下沉、一张图的BFS层层扩散,都是"流"的体现。源是静态的骨骼,流是动态的气血,二者合一,才撑起了整个计算机大厦从底层硬件到上层应用的全部生命力。 框架会过时,语言会迭代,但数据结构不会。它是计算机科学的"炁体源流"——你可以不从它开始,但你一定会在它面前结束你所有的困惑。 ### 1.1.1 编程的真相 – 数据的处理 在正式学习数据结构之前,我们需要先回到一个最本质的问题:编程到底是在做什么?如果剥开所有花哨的概念——前端、后端、算法、人工智能、区块链,再撇开语言层面的差异——JavaScript、Java、C++,会发现一个极其朴素的现象:编程的最终目的只有一个,就是对数据进行操作和处理。 前端从后端获取数据,经过处理后展示在界面上;用户与界面交互产生新的数据,再传回后端;后端接收这些数据,进行业务逻辑的处理,最终保存到数据库中,以便后续的读取、操作和展示。这条数据从产生到消亡的完整链路,就是所有软件系统运转的核心脉络。无论我们站在这条链路的哪个环节上,工作本质都是在和数据打交道。 既然编程的本质是处理数据,那么评判一个开发者能力高低的标准也就变得清晰了:你能否更好地组织和处理数据。这里的"更好"包含两层含义: (1)一是更高效,用更少的时间和空间完成同样的任务; (2)二是更清晰,让数据的流转和存储具有良好的结构,使代码易于理解和维护。 诚然,当下的各种系统和框架已经为我们封装了大量好用的API,大多数时候我们只需要调用它们就能完成需求。但这也恰恰制造了一种危险的舒适区:当你习惯了只做API的搬运工,一旦数据变得复杂、场景变得棘手,你就会发现自己无从下手。真正的开发工程师和API调用程序员之间的分水岭,正在于此——前者理解数据应该以什么样的方式被组织、被存取、被变换,后者只知道调用哪个函数能跑通。而数据结构,正是跨越这条分水岭的第一把钥匙。 ### 1.1.2 数据结构与算法的本质 那么,数据结构与算法究竟是什么?本质上,它是一门专门研究数据如何组织、存储和操作的学科。这三个动词精确地概括了它的全部关切:组织,决定数据之间以怎样的逻辑关系彼此关联;存储,决定这些关系在物理层面如何映射到内存或磁盘上;操作,决定在既定的组织和存储方式下,增删查改各需要付出怎样的代价。Pascal语言之父Nicklaus Wirth曾凭借一个极简的公式摘得图灵奖的桂冠:算法 + 数据结构 = 程序(Algorithm + Data Structures = Programs)。这个公式之所以能成为计算机科学的经典论断,正是因为它一针见血地指出了一个事实——数据结构与算法不是程序的附属品,而是程序本身的骨与血。我们编写的每一行代码,归根结底都是在某种数据结构之上执行某种算法,二者合一,才构成了一个完整的程序。 ### 1.1.3 勿在浮沙筑高台 也正因如此,古人有言"勿在浮沙筑高台"。如果把编程能力比作一座建筑,数据结构与算法就是地基下的桩。框架的更迭、语言的流行都是地表之上的风景,它们可以日新月异,但地基不稳的建筑注定经不起时间的考验。只有掌握了扎实的数据结构与算法功底,你对程序的理解才不会停留在"能跑就行"的表面,而是真正知道它为什么快、为什么稳、为什么在这个场景下应该这样设计而不是那样设计。更重要的是,当你把这层根基打牢之后,再去学习任何新的系统、框架或编程语言,你都会发现自己可以做到高屋建瓴、势如破竹——因为万变不离其宗,底层的数据组织逻辑和算法思维是相通的,变化的只是语法和API的外衣。 ## 1.2 数据结构与算法的应用 说到这里,容易产生一个疑问:数据结构与算法在实际开发中真的用得到吗? 这种困惑其实非常普遍。只要是接触过编程的人,多多少少都听说过数据结构与算法,甚至能随口说出几种耳熟能详的结构名称;很多计算机专业的同学在大学里也确实修过《数据结构》这门课。但一旦进入日常的学习和工作,大家却感觉自己很少直接用到它们,仿佛数据结构只是一个存在于课本和面试题中的概念,和真实的业务代码隔着一层纱。然而事实恰恰相反——数据结构与算法不是用得少,而是无处不在,只是它们大多藏在每天都在使用的工具的底层,我们没有意识到罢了。 ### 1.2.1 系统、语言、框架源码中的数据结构与算法 不妨以我们最熟悉的前端领域为例来看看这种"隐身"有多彻底。 (1)你写的每一行JSX最终会被解析成一棵AST(抽象语法树),这是树结构; (2)浏览器把你的HTML渲染到屏幕上,依赖的是DOM Tree,这也是树结构; (3)JavaScript引擎管理异步任务的方式——微任务队列与宏任务队列——是队列结构; (4)V8引擎内部存储对象属性用的是哈希表结构。 再往上看框架这一层,Vue的模板编译、React的Fiber调度、Webpack的模块依赖解析,源码中随处可见栈、队列、树乃至图(Graph)的身影。也就是说,无论是操作系统本身、编程语言的运行时引擎,还是我们日常开发中依赖的各种框架,它们的底层实现都是由数据结构与算法层层搭建起来的。想读懂Vue或React的源码,想理解Webpack打包的真实流程,甚至只是想搞清楚一个异步Bug为什么会出现,最终都绕不开对数据结构的理解。它不是一个简单可选的加分项。 ### 1.2.2 Vue.js源码中的数据结构 #### 1. Vue3调度器中的队列结构 以下这段代码来自Vue3运行时核心(runtime-core)中的调度器模块。在Vue3中,当响应式数据发生变化时,并不会立即触发组件的重新渲染,而是通过queueJob函数将更新任务统一推入一个队列中,然后由queueFlush在微任务时机(通过`resolvedPromise.then(flushJobs)`)一次性批量执行。这种设计避免了同一个Tick内多次数据变更导致的重复渲染,是Vue3性能优化的关键一环。核心代码如下: ```ts export function queueJob(job: SchedulerJob) { if ( !queue.length || !queue.includes( job, isFlushing && job.allowRecurse ? flushIndex + 1 : flushIndex ) ) { if (job.id == null) { queue.push(job) } else { queue.splice(findInsertionIndex(job.id), 0, job) } queueFlush() } } function queueFlush() { if (!isFlushing && !isFlushPending) { isFlushPending = true currentFlushPromise = resolvedPromise.then(flushJobs) } } ``` 这段代码体现的是典型的队列(Queue)结构。queue是一个数组,通过queue.push(job)实现入队操作,而flushJobs在执行时会从头到尾依次取出任务处理,符合队列先进先出(FIFO)的基本特征。但它又不是一个纯粹的简单队列——当job.id存在时,会通过findInsertionIndex找到合适的位置并用splice插入,这实际上赋予了队列按id排序的能力,使其具备了优先级队列的特性,保证了父组件的更新一定先于子组件执行。此外,queue.includes的去重检查也保证了同一个任务不会被重复入队,这是对基础队列结构的工程增强。 #### 2. Vue3响应式系统中的栈结构 这段代码来自Vue3响应式模块(reactivity)中的副作用追踪系统。Vue3在收集依赖时,需要一个`shouldTrack`标志来控制当前是否处于"可追踪"状态。但在嵌套effect的场景下,内层effect可能需要暂停追踪,执行完毕后又需要恢复到外层effect的追踪状态。`trackStack`就是用来保存和恢复这些嵌套状态的。核心代码如下: ```ts export let shouldTrack = true const trackStack: boolean[] = [] export function pauseTracking() { trackStack.push(shouldTrack) shouldTrack = false } export function enableTracking() { trackStack.push(shouldTrack) shouldTrack = true } ``` 这段代码体现的是经典的**栈(Stack)**结构。`trackStack`虽然底层是数组,但它的使用方式严格遵循后进先出(LIFO)的栈操作规范:每次暂停或开启追踪时,先将当前的`shouldTrack`状态通过`push`压入栈顶保存,然后修改当前状态;当嵌套的effect执行完毕需要恢复时,只需从栈顶`pop`出上一层的状态即可。这和函数调用栈的原理一模一样——每进入一层嵌套就压栈保存现场,退出时弹栈恢复现场。栈结构在这里完美匹配了"嵌套进入、逆序退出"的场景需求。 #### 3. Vue3编译器中的状态栈 这段代码来自Vue3编译器模块,用于解析模板表达式中的成员访问路径(如`obj[key]`或`obj.fn()`中的括号和方括号嵌套)。编译器在逐字符扫描表达式时,需要追踪当前处于哪种词法状态——是在成员表达式中、方括号内、还是圆括号内。当遇到`[`时,需要保存当前状态并切换到"方括号内"状态;当匹配到`]`时,再恢复之前的状态。核心代码如下: ```ts let state = MemberExpLexState.inMemberExp let stateStack: MemberExpLexState[] = [] let currentOpenBracketCount = 0 let currentOpenParensCount = 0 for (let i = 0; i < path.length; i++) { const char = path.charAt(i) switch (state) { case MemberExpLexState.inMemberExp: if (char === '[') { stateStack.push(state) state = MemberExpLexState.inBrackets currentOpenBracketCount++ } else if (char === '(') { // ... } } } ``` 这段代码同样体现了栈(Stack)结构,但应用场景完全不同。这里的stateStack是一个状态栈,用于处理括号嵌套这类天然具有递归结构的语法解析问题。每当遇到一个左括号[,当前的词法状态被push压入栈中,然后切换到新的状态;当遇到对应的右括号]时,从栈顶pop出之前保存的状态进行恢复。这和我们在数据结构课程中学习的经典应用——"括号匹配"——本质上是同一个问题。编译器需要处理的括号可能层层嵌套,而栈结构的后进先出特性天然保证了最内层的括号最先被匹配,完美契合了嵌套解析的需求。 概括来看,Vue3源码中仅这三个片段就涉及了队列、优先级队列、栈三种数据结构,分别对应了调度排队、优先级控制、嵌套状态管理三类问题。 ### 1.2.3 React、Webpack源码中的数据结构 React、Vite源码中也有大量的体现,在1.2.2小节中,已经简单概述Vue3源码中所使用到的数据结构。与Vue3同级别热度的React和Vite自然也包含大量的数据结构的代码与思想,这里不再展示。 ### 1.2.4 Homebrew作者被Google拒绝 数据结构与算法的重要性不仅体现在日常开发中,在职业发展层面同样是一道绕不过去的关卡。无论是国内的互联网大厂还是海外的科技巨头,中高级岗位的面试几乎都会将数据结构与算法作为核心考察项。 有一个在程序员圈子里广为流传的故事:Mac上那款几乎人人都用的包管理工具Homebrew,它的作者Max Howell曾经去Google面试,结果被要求手写一道"反转二叉树"的算法题——这道题我们在后续的课程(第5章)中也会讲到,并不算特别复杂。但他没有写出来,最终被Google拒绝了。Google拒绝Homebrew作者如图1-1所示。  <p align="center"> <b> 图1-1 Google拒绝Homebrew作者</b> </p> 这件事在当时引发了不小的争议,很多人为之唏嘘:一个写出了被全球数百万开发者依赖的工具的人才,竟然因为一道算法题而被拒之门外。但抛开情感层面的感慨,这件事从侧面清晰地反映了一个现实:大厂对数据结构与算法的重视程度远超大多数人的想象。它们之所以执着于此,并不是为了刁难候选人,而是因为在真实的大规模系统中,一个不恰当的数据结构选择或者一段低效的算法,在百万级、千万级的数据量面前会被无限放大,直接转化为性能瓶颈甚至线上事故。所以与其把算法面试看作一道门槛,不如把它看作行业在用最直接的方式告诉你:这项能力,是真的重要。 ### 1.2.5 互联网大厂、高级岗位面试 站在企业的角度来看,这种重视其实非常合理。面试的时间是有限的,企业需要在短短几十分钟内判断一个人的能力水平和未来的成长潜力,而数据结构与算法恰恰是最高效的考察手段。 一个能把数据结构与算法掌握扎实的开发者,对业务逻辑的把握通常不会有问题,对系统的设计也会更加合理,写出的代码自然更加高效。它不是唯一的评判维度,但确实是最能体现一个人编程内功的硬性指标。这也是为什么很多想要进入大厂的同学会去刷LeetCode——但现实往往是,大多数人打开题目就觉得晦涩难懂,看着编辑器不知道从何下手。 根本原因不在于题目本身有多难,而在于缺乏系统的数据结构与算法基础。没有这层基础,每一道题都是孤立的谜题;而一旦你系统地掌握了常见的数据结构和对应的算法思维,这些题目之间的内在联系就会浮现出来,融会贯通之后,面试时遇到相关问题自然可以对答如流。 从更长远的视角来看,学习数据结构与算法的价值远不止于通过面试。我们反复强调过,所有编程的最终目的都是处理数据,而数据结构与算法正是一门专门讲解数据应该如何存储、组织和操作的课程。系统地学习它,本质上是在训练你面对复杂数据时的逻辑思维能力和代码组织能力——当你遇到一个棘手的业务场景时,你会本能地去思考用什么样的结构来承载数据、用什么样的策略来处理数据,而不是只会写出一堆嵌套循环然后祈祷它能跑通。 更重要的是,这种能力是跨领域、跨语言的通行证。如果你未来想从前端转向后端、从业务开发转向算法工程师,甚至进入人工智能、区块链这些更前沿的领域,你会发现所有编程思想的底层逻辑都是相通的,变化的只是用哪种语言去处理数据而已。而数据结构与算法,正是这一切共通之处的基石。 ## 1.3 如何学习数据结构与算法? 说了这么多数据结构与算法的重要性,接下来一个很现实的问题就摆在面前了:怎么学?坦白讲,数据结构与算法在大多数人心中的印象就是晦涩难懂、复杂抽象,学起来门槛不低。市面上常见的学习途径大致有四种——高质量文章、书籍、LeetCode刷题、视频课程——它们各有优劣,适合不同阶段的学习者。 第一种是通过高质量的技术文章来学习。文章的优势在于获取成本低、阅读灵活,你可以利用碎片时间快速了解某个数据结构的核心思想,而且优秀的文章往往会结合实际场景来讲解,读起来不会太枯燥。但问题也很明显:文章天然是碎片化的,一篇讲栈、一篇讲队列、一篇讲树,它们之间缺乏系统的串联和递进关系。如果你只靠文章来学,很容易陷入"每个都看过、每个都似懂非懂"的状态,知识点之间形不成体系,遇到综合性问题时依然无从下手。 第二种是看书学习,比如经典的《算法导论》《数据结构与算法分析》等。书籍最大的优势在于体系完整、论述严谨,一本好书会从最基础的概念出发,按照合理的知识递进顺序带你走完整条学习路径,这是任何碎片化内容都无法替代的。但书籍的缺点同样突出:经典教材往往偏学术化,语言抽象、公式密集,对于没有太多基础的学习者来说阅读门槛很高,很多人买回来翻了几十页就放在书架上吃灰了。此外,书籍是静态的,你在阅读过程中如果遇到理解障碍,它无法像一个老师那样换一种方式给你重新解释。 第三种是直接上LeetCode刷题。LeetCode的好处是实战性极强,每一道题都是一个具体的问题,你必须真正写出代码、通过测试用例才算完成,这种即时反馈机制对于巩固知识和锻炼编码能力非常有效。但LeetCode有一个致命的前提条件:你需要先具备一定的数据结构与算法基础。如果你连基本的数据结构都还没有搞清楚就直接去刷题,面对的大概率是一种"打开题目→完全没有思路→看答案→觉得好像懂了→换一道题→又不会了"的死循环。刷题是巩固和提升的手段,而不是从零开始的入门方式。 第四种是通过视频课程来学习。视频课程的优势在于它综合了前面几种方式的长处:既有书籍般的系统性和完整的知识递进,又比书籍更加直观——老师可以通过动画演示、手动推演、画图讲解等方式把抽象的概念变得具体可感,大大降低了理解的门槛。同时,好的课程通常也会穿插代码实战和经典题目的讲解,帮助你在学完理论后立刻动手练习。当然,视频课程也并非完美:它需要投入相对集中的时间,学习节奏由讲师控制而非自己掌握,而且市面上课程质量参差不齐,选择一门真正优质的课程本身就需要一定的甄别能力。 总的来说,最理想的学习路径是将这几种方式结合起来:以系统的视频课程或书籍作为主线建立完整的知识框架,在学习过程中辅以高质量文章来拓展视野和加深理解,最后通过LeetCode刷题将所学知识转化为真正的解题能力和编码肌肉记忆。 在本系列文章中,我们会从常见的数据结构与算法开始学习,过渡到高阶的数据结构与算法,对应内容的思维导图如图1-2、图1-3所示。文章采用层层递进的过渡写法,成体系梳理数据结构与算法的学习路径,内容几十万字且图文并茂(量大管饱),并且有对应开源的课程视频,在最后还会带大家到LeetCode上面刷题。PS:想要一起学习的可以微信联系:coderwhy666。  <p align="center"> <b> 图1-2 常见数据结构与算法</b> </p>  <p align="center"> <b> 图1-3 高阶数据结构与算法</b> </p> ## 1.4 什么是数据结构? 铺垫了这么多,我们终于要回到最根本的问题了:到底什么是数据结构与算法?你可能会期待我给出一个权威的官方定义,但有趣的是——它并没有一个被所有人统一认可的标准定义。 这并不是因为它不够重要,恰恰相反,正是因为它太过基础、太过底层,就像你很难给"数"或者"语言"下一个人人满意的定义一样,越是根基性的概念,越难用一句话框死它的边界。不过没关系,虽然没有一锤定音的官方说法,我们完全可以把"数据结构与算法"拆开来,分别去理解这两个词各自指向什么。搞清楚了"数据结构是什么"和"算法是什么",再把它们合在一起,你自然就会对这门学科形成一个清晰而完整的认知。接下来,我们就一个一个来看。 ### 1.4.1 数据结构的定义 我们先来看数据结构。虽然没有官方定义,但学术界有几种被广泛引用的说法。《数据结构、算法与应用》一书中的表述是:数据结构是数据对象,以及存在于该对象的实例和组成实例的数据元素之间的各种联系,这些联系可以通过定义相关的函数来给出。《数据结构与算法分析》则更加精炼:数据结构是ADT(抽象数据类型)的物理实现。而中文维基百科给出了一个相对通俗的版本:数据结构是计算机中存储、组织数据的方式,通常情况下,精心选择的数据结构可以带来最优效率的算法。 这三种定义各有侧重,第一种强调的是数据元素之间的"联系"以及用函数来描述这些联系,偏向于形式化的数学视角;第二种强调的是数据结构与抽象数据类型之间的关系,即先在逻辑层面定义"我需要什么操作",再在物理层面决定"我如何实现这些操作";第三种则最为直白,直接指向了数据结构的核心关切——存储和组织。但无论哪种说法,它们最终都收敛到了同一个本质上:数据结构就是在计算机中存储和组织数据的方式。 这句话虽然简短,却值得拆开来细品。"存储"解决的是"数据放在哪里"的问题,"组织"解决的是"数据之间以什么样的关系彼此关联"的问题。可以用一个很直观的类比来理解:摆放图书。 ### 1.4.2 如何摆放图书? 如果是在自己家里,书也没有很多,我们大可以直接叠在一起或者随意拜访,就算想拿,也很快就能找到。在家的书籍摆放如图1-4所示。  <p align="center"> <b> 图1-4 在家的书籍摆放</b> </p> 想象一个庞大的图书馆,里面存放着海量的书籍。如果你只是把书一股脑地堆在地上,那存储的问题虽然解决了——书确实"放进去"了——但当你想找某一本书的时候,就只能一本一本翻过去,效率极其低下。而一个好的图书馆一定会有一套组织方式:按学科分区、按作者姓氏排列、用索引编号建立检索目录。这套组织方式的目的,就是让你不仅能把书放进去,还能在需要的时候高效地把书取出来。数据结构做的事情与此完全一样——面对计算机中庞大的数据,它要解决的核心问题就是:如何存储这些数据,以及如何组织它们之间的关系,使得后续的查找、插入、删除、修改等操作都能以尽可能高效的方式完成。  <p align="center"> <b> 图1-5 图书馆的书籍摆放</b> </p> 我们继续用图书馆的例子来把这个问题说得更透彻一些。假设你是一名图书馆管理员,你日常要处理的核心操作只有两个: 第一,新书到了怎么插入书架? 第二,读者来了怎么找到指定的那本书? 就是这么简单的两个操作,因为数据组织方式的不同,效率可以天差地别。 最简单粗暴的方式是随便放——哪里有空位就往哪里塞。这种方式下,插入操作当然极其高效,一步到位,毫无心智负担。但代价是什么?当读者来找某一本书时,你只能从第一个书架开始一排一排、一本一本地扫过去,运气好也许很快就找到了,运气不好就得翻遍整个图书馆。书越多,这个过程就越痛苦。这就是典型的"写入快、读取慢"——你为了插入时的省事,把所有的复杂度都转嫁到了查找上。 稍微聪明一点的方式是按照书名的拼音字母顺序排放。这样一来,查找操作就高效多了——你不再需要从头翻到尾,而是可以用二分查找法:先看中间位置的书,判断目标在左半边还是右半边,然后不断缩小范围,几次就能定位到。但插入操作就没那么轻松了:假设新到了一本《阿Q正传》,你需要按照字母顺序找到它应该在的位置,然后把后面的书全部往后挪一个位置,才能把它插进去。书架上的书越多,这个"挪位"的成本就越高。 更进一步的方式是先把书架划分成几个大的区域,按照书籍的类别分区存放——文学区、哲学区、计算机区——然后在每个类别内部再按照字母顺序排列。这样无论是插入还是查找,第一步都是先确定类别,一下子就把范围缩小到了一个区域内,然后在这个小范围里再用二分查找来定位。插入时需要挪动的书变少了,查找时需要扫描的范围也小了,两个操作的效率都得到了提升。 三种方式,同样的书、同样的书架,仅仅因为摆放规则不同,操作效率就产生了本质的差异。这个类比揭示了一个至关重要的结论:解决问题的效率,与数据的组织方式直接相关。而计算机中存储的数据量相较于图书馆来说要庞大得多,数据的种类也远比书籍复杂——数字、文本、图像、关系、状态……面对如此海量且多样的数据,以什么样的方式来存储和组织它们,才能在后续使用时更加方便、更加高效?这,就是数据结构需要考虑的核心问题。 ### 1.4.3 常见的数据结构 回到计算机的世界中来,道理是完全一样的。计算机中常见的数据结构种类不少:数组、栈、队列、链表、哈希表、树、堆、图等等。每一种都有其对应的应用场景,而不同数据结构在不同操作上的性能表现也各不相同。有的查询性能极快,有的插入速度出色,有的在头尾两端的操作特别高效,有的擅长范围查找,有的允许元素重复而有的要求元素唯一。 没有哪一种数据结构是万能的"银弹",在实际开发中选择哪种结构,永远取决于你当前面对的具体需求。这也正是数据结构这门课程存在的意义:不是让你死记硬背每种结构的定义,而是让你在面对不同场景时,能够做出最合理的选择。如果只需要死记硬背,那直接查字典就好了。 这里需要特别强调一点:数据结构与编程语言无关。无论使用的是JavaScript、Java、C++、Python还是其他任何语言,它们都会直接或间接地用到上述这些数据结构,区别只在于语言层面是否内置了对应的实现。有些同学可能会疑惑:我学JavaScript这么久,为什么好像只见过数组,几乎没有接触过其他数据结构? 这其实不是因为JavaScript中不需要这些结构,而是因为很多数据结构只有在更高阶的开发场景中才会被显式使用——比如设计框架、编写底层库、优化核心算法的时候。甚至有些数据结构在JavaScript中根本就没有原生提供,需要我们自己从零去实现。 这也恰恰引出了我们这门课程的核心理念:我们不是要讲这些数据结构"怎么调用"——那是API程序员的思维方式,查一查文档就能搞定。我们要做的是从底层出发,搞清楚每一种数据结构是如何设计、如何实现的,在这个基础之上再去讨论如何使用。只有理解了内部的实现原理,你在使用它们时才不是盲目的,你的选择才有依据,你的优化才有方向。了解真相,你才能获得真正的自由。 ## 1.5 什么是算法? 理解了数据结构之后,我们再来看另一半——算法。在第11章的排序算法文章中,我们可以感受面对同一组数据,冒泡排序、快速排序、归并排序的执行效率可以相差几个数量级。这说明在解决问题的过程中,不仅仅数据的存储和组织方式会影响效率,你选择用什么样的步骤和逻辑去处理这些数据,同样深刻地决定着最终的效率。而这里所说的"步骤和逻辑",就是算法。 ### 1.5.1 算法的定义 那么,算法到底是什么?从严格的定义来说,算法是一个有限的指令集合,它具备几个基本特征:首先,每条指令的描述不依赖于任何特定的编程语言,也就是说算法是一种抽象的逻辑过程,你可以用JavaScript实现它,也可以用Java、C++或者Python实现它,变化的是语法,不变的是逻辑本身;其次,算法可以接受一些输入,在某些情况下也可以没有输入,但它一定会产生输出——也就是说它必须要解决某个问题、给出某个结果;最后,也是非常关键的一点,算法必须在有限的步骤之后终止,一个永远不会停下来的过程不能被称为算法。 ### 1.5.2 算法的示例 不过,如果觉得这个定义还是有些学术化,我们可以回到Algorithm这个单词本身来理解。它的本意其实非常朴素——就是解决问题的办法和步骤逻辑。电灯不工作的解决算法如图1-6所示,这就是一种解决问题的办法和步骤逻辑。  <p align="center"> <b> 图1-6 电灯不工作的解决算法</b> </p> ## 1.6生活中的数据结构与算法 前面我们用图书馆的例子说明了数据的组织方式如何影响效率,但数据结构与算法的身影远不止于此,生活中到处都能找到它们的影子。我们再来看两个更加贴近日常的例子,帮助大家进一步建立直觉。 ### 1.6.1 快递员的快递 第一个例子是取快递。大家平时都收过快递,现在很多快递通常不会直接送到家里,而是放在某个固定的代收点,让你自己去取。当你走到代收点,一般会遇到两种情况: (1)自己动手在海量的快递包裹中翻找。 (2)快递员让你报出名字,由他来帮你找。 自己翻找本质上就是线性查找——从第一个包裹开始,一个一个挨着看,直到找到为止。 虽然我们人眼处理视觉信息的速度很快,眼观六路也许很快就能扫到,但这毕竟不是一种可靠且高效的方式,包裹一旦多起来就很痛苦。更好的方式应该是让快递员帮你找。而如果这个快递员稍微动动脑筋,他会提前对快递做一轮分类——比如按照收件人姓氏把包裹分成不同的区域。这样一来,你只需要报出名字,他就能根据姓氏立刻锁定到某一个小区域,再在这个小范围里快速找到你的包裹。你看,同样是"找快递"这件事,有没有对数据进行合理的组织,效率完全是两个量级。这就是数据结构思维在生活中最朴素的体现。 ### 1.6.2 找出线缆出问题的地方 第二个例子更加直观地展示了算法优劣带来的效率差距。 假设上海和杭州之间有一条高架线缆,全长1,000,000米,某一天其中有一米的线段出现了故障,现在需要你想办法定位到这个故障点。最直觉的方式是线性查找:从上海这一端的起点开始,一米一米地排查过去,最终一定能找到故障位置。但如果故障恰好在杭州那一端呢?你就需要排查整整1,000,000次,这是最坏的情况;即便平均下来,也需要大约500,000次。 现在换一种思路——二分查找:先从线缆的中间位置开始检测,判断故障出在上海到中间点这一半,还是中间点到杭州这一半;确定之后,在故障所在的那一半中再取中间点继续检测,每一次都将排查范围缩小一半。用这种方式,最坏的情况下需要多少次才能定位到故障?答案是大约20次。这个数字是怎么来的呢?就是log₂(1,000,000) ≈ 20。从500,000次到20次,同一个问题,仅仅因为算法不同,效率就产生了如此天壤之别。 这两个生活中的小例子,一个侧重于数据结构,一个侧重于算法,但它们共同指向了同一个结论:解决问题的办法有很多,但好的数据组织方式配合好的算法,与差的方案之间的效率差距,往往不是百分之几十的优化,而是成千上万倍的量级碾压。那么,如何科学地衡量一个算法到底有多快、多慢?这就涉及到我们后续会专门讲解的大O表示法,这里先留一个悬念,后面再展开。
Go 语言中的数组、切片、Map和List
这篇文章已经放到腾讯智能工作台的知识库啦,链接在这里:[ima.copilot-Go 入门到入土](https://ima.qq.com/wiki/?shareId=6467e4feb4e225ab1acf92d9134ba4baeaf578ed327f9ba285da311f9ea4742a)。要是你有啥不懂的地方,就去知识库找 AI 聊一聊吧。 # 1、数组的基本用法 在前面的博客中,我们主要介绍了 Go 语言的基础数据类型。然而,在实际的软件开发中,我们通常需要一个“容器”来管理和组织这些基础数据,以便更高效地处理批量数据。Go 语言提供了多种集合类型(或称容器)来满足不同的开发需求。 ## 1、Go 语言的核心集合类型 Go 语言内置了四种主要的集合类型数据结构: 1. **数组 (Array)**:存储固定长度、相同类型元素的序列。 2. **切片 (Slice)**:一个动态、灵活的数组视图,是 Go 中最常用的集合类型。 3. **Map**:键值对的无序集合,类似于其他语言中的哈希表或字典。 4. **列表 (List)**:Go 标准库 `container/list` 提供了双向链表的实现。 在这些类型中,**切片 (Slice)** 和 **Map** 的使用频率最高,也是我们后续需要重点掌握的。数组因为其固定长度的特性,使用场景相对有限,而 List 在日常开发中也并不常见。 本节,我们首先深入讲解 Go 语言中的 **数组 (Array)**。 ## 2、 数组的定义与特性 Go 语言中的数组与 C 或 Java 等语言中的数组有显著区别,尤其是在类型系统层面。 ### 2.1 定义语法 数组的定义语法如下: ```go var variableName [size]Type ``` - `var`: 声明变量的关键字。 - `variableName`: 数组变量的名称。 - `[size]`: 数组的长度(元素个数),**这是类型的一部分**。 - `Type`: 数组中存储的元素类型。 请注意,数组的长度 `[size]` 是写在类型 `Type` 前面的,这与其他许多静态语言的习惯不同。 例如,让我们定义一个包含 3 个字符串元素的数组:  ### 2.2 长度是类型的一部分 这是 Go 数组最核心的特性。一个数组的类型由其**长度**和**元素类型**共同决定。这意味着,`[3]string` 和 `[4]string` 是两种完全不同且不兼容的数组类型。 我们可以通过下面的代码来验证这一点:  运行结果:  从输出中可以清晰地看到,编译器将 `[3]string` 和 `[4]string` 识别为不同的类型。因此,不同长度的数组之间不能直接赋值,这大大增强了 Go 语言的类型安全性,但也限制了数组的灵活性。 ## 3、数组的初始化与遍历 ### 3.1 赋值 数组的元素可以通过索引进行访问和赋值,索引从 0 开始。  ### 3.2 遍历 遍历数组最常用的方式是使用 `for...range` 循环。  ## 4、数组 vs 切片:一个重要的区分 有同学可能会想,如果定义时不指定长度,是不是就可以创建一种“通用”的数组类型了?比如 `var a []string`。 这是一个非常关键的概念点:**当你不指定长度时,你创建的不再是数组,而是一个切片(Slice)**。 `[3]string` 是一个**数组类型**,而 `[]string` 是一个**切片类型**。它们是两种不同的数据结构,切片在内部依赖于数组,但提供了更为强大的动态能力。我们将在下一节重点讲解切片。 #### 何时使用数组? 尽管数组的长度固定,但在某些特定场景下它依然很有用: - **性能**: 当元素个数固定时,数组的性能非常高,因为其内存布局是连续且大小已知的。 - **可预测性**: 用于存储固定集合的数据,例如表示一周七天、颜色(RGB)等。 # 2、数组的初始化和遍历 在 Go 语言中,数组是一种固定长度的、包含相同类型元素的数据结构。正确地初始化和遍历数组是日常开发中的基本功。本文将深入探讨 Go 语言中数组初始化的多种方法以及常用的遍历方式。 ## 1、数组的初始化 Go 语言提供了多种灵活的方式来初始化数组,以适应不同的开发场景。 ### 1.1 基本初始化 在定义数组时,我们可以直接提供一组初始值,从而简化代码。  在上面的例子中,`:=` 是短变量声明操作符,它会根据右侧的值自动推断变量 `courses` 的类型为 `[3]string`。这种写法在 Go 中最为常见和推荐。 ### 1.2 指定索引初始化 有时,我们可能只想初始化数组中的某几个特定元素,而让其他元素保持其类型的**零值**(Zero Value)。对于 `string` 类型,其零值是空字符串 `""`。 这种方法通过 `索引:值` 的形式实现,非常灵活。  这种方式特别适用于需要设置稀疏数据或在特定位置插入初始值的场景。 ### 1.3 使用 `...` 自动推导长度 在初始化时,如果我们希望数组的长度由初始值的数量来决定,可以使用 `...` 省略号。编译器会自动计算元素的数量并设置数组的长度。  **重点**: `...` 并非创建了一个动态数组。数组的长度在编译时就已经确定,并成为其类型的一部分。因此,`[2]string` 和 `[3]string` 是两种**完全不同**的类型。 ## 2、数组的遍历 遍历数组是访问其元素的基本操作。Go 语言同样支持多种遍历方式。 ### 标准 `for` 循环 这是从 C 语言继承而来的传统遍历方式,通过索引来访问数组中的每一个元素。  这种方法直观易懂,并且在需要使用元素索引进行计算时非常有用。建议熟练掌握。 # 3、数组比较与多维数组 本节将探讨 Go 语言中数组的比较规则以及多维数组的定义、初始化和遍历方法。这些是掌握 Go 数据结构的重要基石。 ## 1、 数组的比较 在 Go 语言中,数组可以直接使用相等运算符 `==` 或 `!=` 进行比较,这为我们判断两个数组是否相同提供了极大的便利。但是,能够进行比较需要满足两个核心前提。 **比较的两个前提条件:** 1. **类型相同**:数组的长度是其类型的一部分。因此,只有长度完全相同的数组才能进行比较。例如,一个 `[2]string` 类型的数组和一个 `[3]string` 类型的数组是无法进行比较的,这会在编译时就引发错误。 2. **元素值与顺序完全一致**:当两个数组类型相同时,`==` 会逐一比较其内部的每一个元素。只有当所有对应位置的元素都相等时,两个数组才被认为是相等的。元素的顺序也至关重要。  ## 2、多维数组 当一维数组无法满足我们存储更复杂、结构化数据的需求时,多维数组就派上了用场。例如,我们可以用一个二维数组来存储一个班级所有学生各科的成绩,或者存储一系列课程的详细信息。 ### 2.1 声明与初始化 多维数组的定义非常直观,例如 `[3][4]string` 表示一个3行4列的二维字符串数组。 **💡 实用场景**:假设我们需要存储三门课程的信息,每门课程都包含四个属性:课程名、时长、讲师和简介。  **注意**:像 `courses[0][2] = "NewTeacher"` 这样对单个元素赋值也是完全支持的,但通过整行初始化在许多场景下更为清晰。 ### 2.2 遍历多维数组 遍历多维数组通常需要使用嵌套循环。 #### 方法一:标准 for 循环 通过嵌套两层 `for` 循环,外层循环控制行,内层循环控制列。  #### 方法二:使用 for...range (推荐)  # 4、切片的定义和赋值 本节,我们将深入讲解 Go 语言集合类型中一个至关重要的数据结构——切片(Slice)。切片在 Go 项目中应用极为广泛,为了帮助大家在实际开发中避免可能遇到的问题,我们将详细剖析其核心概念与细节。 ## 1、 切片:Go 语言的动态数组 可以将切片理解为一种“动态数组”,它类似于 Python 中的 `list` 或其他语言中的动态数组(Dynamic Array)。 在许多静态编译型语言中,数组(Array)的长度在定义时就已固定,无法在运行时改变。例如,声明一个长度为 3 的数组后,就不能再向其中添加第四个元素。因此,这类静态数组通常不提供 `append`(追加)等操作。 与此不同,动态语言中的数组或列表(List)通常是动态的,可以随时方便地向其追加元素。 Go 语言在此问题上采取了一种折中的设计方案。它保留了传统意义上的**数组**,但其长度是类型的一部分,这使得不同长度的数组成为不同的类型,从而在一定程度上弱化了其灵活性。为了弥补这一不足并提供动态集合的功能,Go 推出了**切片(Slice)**。 切片的本质是**对底层数组一个连续片段的引用**。可以理解为,Go 在原生数组的基础上构建了功能更丰富的切片,使其具备动态数组的特性,这种设计更符合大多数开发场景的习惯。 ## 2、切片的定义 定义一个切片与定义数组的语法非常相似,关键区别在于**定义切片时不需要指定长度**。  这里的 `[]string` 就表示一个元素类型为 `string` 的切片。切片的元素类型可以是任意的,例如 `int`、`struct`,甚至可以是数组或另一个切片,构成多维结构 ## 3、切片的基本操作 ### 3.1 追加元素 (Append) 向切片中添加元素最常用的方法是使用 Go 的内置函数 `append`。 `append` 函数的用法非常特殊,初学者需要特别注意:**它会返回一个包含新增元素的新切片,这个返回值必须重新赋给原来的切片变量。**  **重点解释 `append` 的工作机制:** 初次接触时,`slice = append(slice, element)` 这种语法可能会令人困惑。为什么不能直接修改原切片,而必须接收返回值? 这是因为切片在容量(Capacity)不足以容纳新元素时,Go 会分配一个全新的、更大的底层数组,并将原有元素和新元素一并拷贝过去。此时,返回的切片将指向这个新的内存地址。如果不接收返回值,原始的切片变量将仍然指向旧的、较小的底层数组,导致添加操作“丢失”。 这个机制是切片实现动态增长的关键,也是一个常见的面试考点。我们将在后续文章中更深入地探讨其内部原理。 ### 3.2 访问元素 访问切片中的单个元素与访问数组元素的方法完全相同,都是通过索引(index)实现,索引从 0 开始。  ### 3.3 遍历切片 使用 for range 循环遍历切片的方式也和遍历数组一致,这是最常用和推荐的遍历方式。  # 5、切片的多种初始化方式 在前一节中,我们学习了切片的基本定义和如何通过 `append` 函数添加元素。本节,我们将重点介绍初始化切片的三种常用方法。 切片的初始化主要有以下三种方式: 1. **从现有数组或切片创建**:通过截取(slicing)操作生成一个新的切片。 2. **使用切片字面量(Slice Literal)**:在声明时直接提供初始元素,类似于数组的初始化。 3. **使用 `make` 内置函数**:预先分配切片的存储空间,适用于已知大概容量的场景。 这三种方法在实际开发中都非常普遍,掌握它们对于高效使用 Go 语言至关重要。 ## 1、 从数组创建切片 可以从一个已存在的数组中“截取”一部分元素来创建一个新的切片。这种操作不会复制元素,而是创建一个指向原数组部分数据的新切片。 **语法**:`array[low:high]` 这种语法创建一个包含从索引 `low` 到 `high-1` 元素的切片。这是一个**左闭右开**区间,即包含 `low` 索引处的元素,但不包含 `high` 索引处的元素。新切片的长度为 `high - low`。 **示例**: 假设我们有一个包含五门课程的数组,现在希望从中提取前两门课程创建一个切片。  **注意**:Go 的切片操作语法借鉴自 Python,非常灵活。例如,`allCourses[:]` 可以将整个数组转换为一个切片。 ## 2、 使用切片字面量 (Slice Literal) 这是最直接的初始化方式,可以在声明时直接填充元素。其语法与数组字面量非常相似,但**不需要在 `[]` 中指定长度**。  这种方式同样可以用于从一个现有切片创建新切片。 ## 3、使用 `make` 内置函数 当需要在创建切片时预留存储空间,以提高性能(特别是在能预估最终元素数量时),可以使用 `make` 函数。这样做可以避免后续 `append` 操作可能引发的频繁内存重新分配和数据拷贝。 `make` 函数可以接受两个或三个参数: - **`make([]T, length)`**:创建一个类型为 `[]T`,长度(length)为 `length` 的切片。其容量(capacity)也等于 `length`。 - **`make([]T, length, capacity)`**:创建一个类型为 `[]T`,长度为 `length`,容量为 `capacity` 的切片。  **重要对比**: - **使用 `make`**:切片被创建时,其底层数组已经被分配,并且长度被设定。因此,你可以直接通过索引对 `0` 到 `length-1` 范围内的元素进行赋值。 - **仅声明 `var slice []T`**:此时切片的值为 `nil`,其长度和容量都为 0。你不能通过索引直接赋值,因为底层数组不存在。这种情况下,必须使用 `append` 来添加第一个元素。  掌握这三种初始化方法至关重要: - 当需要从现有数组或切片中获取子集时,使用**切片表达式** (`[low:high]`)。 - 当在编码时已知所有初始元素时,使用**切片字面量** (`[]T{...}`) 最为简洁。 - 当可以预估切片所需存储的元素数量时,使用 **`make` 函数**预分配容量,可以获得最佳性能。 由于切片在 Go 编程中的核心地位,请务必熟练掌握并理解这三种基本用法的适用场景。 # 6、切片的数据访问 本节,我们将探讨如何访问 Go 语言切片中的元素。这些是切片非常常用的操作,需要熟练掌握。访问切片元素主要分为两种情况:访问单个元素和访问多个元素(即创建子切片)。 ## 1、访问单个元素 访问切片中的单个元素与访问数组的方法完全相同,都是通过索引(index)来完成。 **语法**:`slice[index]` 需要注意的是,索引必须在有效范围内,即 `0 <= index < len(slice)`。尝试访问超出此范围的索引将导致程序运行时发生 `panic`。  ## 2、访问多个元素(创建子切片) 在更多场景下,我们常常需要获取切片中一个连续的子集。这可以通过**切片表达式 Slicing Expression**来实现,其语法非常灵活。 **基本语法**:`slice[start:end]` - `start`:起始索引(包含该索引对应的元素)。 - `end`:结束索引(**不包含**该索引对应的元素)。 这是一个**左闭右开**的区间。`start` 和 `end` 索引都是可选的。下面我们详细说明其四种主要用法。 假设我们有以下基础切片用于演示:  ### 2.1 指定起始和结束索引 `slice[start:end]` 截取一个明确范围的子集。  ### 2.2 省略结束索引 `slice[start:]` 表示从 `start` 索引开始,一直截取到切片的末尾。  ### 2.3 省略起始索引 `slice[:end]` 表示从切片的开头(索引 0)开始,一直截取到 `end` 索引之前。  ### 2.4 同时省略起始和结束索引 `slice[:]` 这会创建一个包含原切片所有元素的新切片。需要注意的是,这是一种“浅拷贝”:新切片和原切片共享同一个底层数组,但它们是两个独立的切片头结构。  **语法借鉴**:Go 的切片语法很大程度上借鉴了 Python,但功能上是其子集,相对更为简洁,易于学习和使用。 # 7、切片(Slice)的数据添加与合并 在前几节中,我们已经接触了用于向切片添加元素的 `append` 内置函数。本节,我们将深入探讨 `append` 的更多高级用法和细节。 回顾一下 `append` 的两个基本要点: 1. 它是一个内置的全局函数。 2. 它返回一个新的切片,必须用原切片变量接收其返回值,否则添加操作将无效。 ## 1、 一次性追加多个元素 `append` 函数不仅可以一次添加一个元素,还可以通过可变参数(variadic parameter)的形式,一次性添加多个元素。 **示例**: 假设我们有一个初始切片,现在需要同时向其中添加 "Gin"、"MySQL" 和 "Elasticsearch"。  **源码解读**: `append` 函数的定义类似 `func append(slice []T, elements ...T) []T`。 参数 `elements ...T` 表示可以接收零个或多个 `T` 类型的参数。这就是为什么我们可以传入任意数量的元素。 ## 2、 合并两个切片 当需要将一个切片的所有元素添加到另一个切片末尾时,最直观的想法可能是使用 `for` 循环遍历并逐个 `append`。 **传统方法:使用 for 循环**  虽然这种方法可行,但 Go 提供了更为简洁和高效的语法。 **推荐方法:使用 `...` 语法** 我们可以利用 `append` 的可变参数特性,将一个切片“打散”成独立的元素序列,然后一次性追加。这通过在要添加的切片名称后加上 `...` 实现。  这种写法在功能上等同于 `for` 循环,但代码更简洁,可读性更强,是 Go 语言中合并切片的标准做法。 **注意**:你不能直接将一个切片 `append` 到另一个切片中,因为它们的类型不匹配。`append(courseSlice1, courseSlice2)` 会导致编译错误,因为它试图将 `[]string` 类型的 `courseSlice2` 作为一个单一元素添加到只能存放 `string` 的 `courseSlice1` 中。 ## 3、追加另一个切片的子集 `...` 语法同样可以与切片表达式结合使用,从而实现只追加另一个切片的部分元素。 **示例**: 假设 `courseSlice2` 中包含 `{"MySQL", "Elasticsearch", "Gin"}`,我们只想将 "Elasticsearch" 和 "Gin" 追加到 `courseSlice1`。  这种组合用法极大地增强了 `append` 操作的灵活性。 本节我们深入学习了 `append` 函数的高级用法。关键点在于,它不仅可以一次性追加多个独立的元素,更重要的是,可以通过 `...` 语法高效地将一个切片(或其子切片)的所有元素合并到另一个切片中。这是 Go 开发中非常常用且重要的技巧。 # 8、切片(Slice)的删除与复制操作 本节,我们将学习如何在 Go 语言的切片中删除和复制元素。与取值操作的直接性不同,删除操作的实现方式需要特别注意。 ## 1、 从切片中删除元素 Go 没有提供一个直接的内置函数来删除切片中的元素(如 `delete(slice, index)`),这一操作需要通过切片本身的拼接能力来实现。 ### 1.1 删除中间的元素 要删除切片中间的一个或多个元素,核心思路是:**将需要删除的元素“左边”的部分和“右边”的部分拼接在一起,构成一个新的切片。** **实现方法:** 1. 使用切片表达式 `slice[:index]` 获取要删除元素之前的所有元素。 2. 使用切片表达式 `slice[index+1:]` 获取要删除元素之后的所有元素。 3. 使用 `append` 函数将这两部分合并。 **示例**:从以下切片中删除 "MySQL"(索引为 2)。  这种用法虽然看起来有些绕,但它是 Go 中实现“删除”操作的标准模式。 ### 1.2 删除末尾的元素 如果想删除从某个索引开始到末尾的所有元素,操作就简单得多,只需重新切片,保留所需的部分即可。 **示例**:删除 "MySQL" 及之后的所有元素。  **性能说明**:频繁的切片操作看似会影响性能,但由于切片主要操作的是指向底层数组的指针和元数据(长度、容量),而不是大规模复制数据本身,因此在大多数情况下性能是可以接受的。 ## 2、复制切片 复制切片时,必须区分**浅拷贝(Shallow Copy)**和**深拷贝(Deep Copy)**。 - **浅拷贝**:新旧两个切片共享同一个底层数组。修改其中一个切片会影响到另一个。 - **深拷贝**:为新切片创建一个全新的底层数组,并复制所有元素。新旧切片完全独立。 ### 2.1 浅拷贝(赋值与切片表达式) 直接赋值 (`newSlice := oldSlice`) 或使用完整的切片表达式 (`newSlice := oldSlice[:]`) 都只会创建一个新的切片头,它与原切片指向同一个底层数组。 **示例**:  可见,修改 `originalSlice` 会直接影响 `shallowCopy`。 ### 2.2 深拷贝(使用 `copy` 内置函数) 要实现真正的深拷贝,应使用 Go 的内置 `copy` 函数。它将元素从源切片复制到目标切片。 **关键点**:`copy` 函数**不会**为目标切片自动扩容。你必须确保目标切片有足够的**长度**来接收被复制的元素。通常,我们会使用 `make` 函数来创建一个长度与源切片相同的目标切片。 **语法**:`copy(destination, source)` **示例**:  可以看到,`deepCopy` 是一个完全独立的副本,不受对原始切片修改的影响。 理解这些操作的底层机制至关重要。如果不清楚浅拷贝和深拷贝的区别,很可能会在程序中引入难以察觉的 bug。 # 9、切片 (Slice) 的底层实现原理:值传递与引用传递之谜 在 Go 语言中,如果不了解切片(Slice)的底层实现原理,开发过程中极易遇到难以排查的问题。因此,深入理解其工作机制,尤其是在函数调用中的表现,至关重要。这也是一个常见的面试热点。 本节,我们将通过实验现象,引出一个核心问题: > **Go 的切片在作为函数参数传递时,是值传递(Pass-by-Value)还是引用传递(Pass-by-Reference)?** 严格来说,Go 语言中所有函数参数传递都是**值传递**。然而,切片的操作效果却常常呈现出引用传递的特征,但又不完全是。这种模棱两可的表现是初学者最主要的困惑来源。 为了揭示这一现象,我们来看两个截然相反的实验。 ## 1、场景一:在函数内修改切片元素 我们定义一个函数,它接收一个切片作为参数,并尝试修改该切片的某个元素。  **现象分析**: 函数 `modifySlice` 内部对 `data` 第一个元素的修改,成功地反映到了 `main` 函数的原始变量 `courses` 上。从结果来看,这完全符合**引用传递**的特征。 ## 2、场景二:在函数内追加切片元素 现在,我们换一个操作。我们定义另一个函数,尝试向传入的切片中追加(append)新元素。  **现象分析**: 函数 `appendToSlice` 内部成功地向 `data` 追加了新元素 "MySQL",其长度和容量都发生了变化。然而,当函数返回后,`main` 函数中的原始变量 `courses` **没有任何改变**。这个结果又完全符合**值传递**的特征。 我们观察到了两种完全矛盾的现象: 1. 修改切片内部的元素,会影响到外部的原始切片(类似引用传递)。 2. 在函数内部对切片进行 `append` 操作,却不会影响到外部的原始切片(类似值传递)。 这种“怪异”行为的根源在于切片自身的内部结构。**切片本身是一个小的数据结构(或称“描述符”、“头信息”),它包含了指向底层数组的指针、切片的长度和容量。** 当我们以值传递的方式传递切片时,**实际上传递的是这个描述符结构体的一份拷贝**。 - **场景一**:拷贝的描述符和原始的描述符都指向**同一个**底层数组。因此,通过拷贝的描述符修改底层数组的元素,原始描述符自然能“看到”这个变化。 - **场景二**:当 `append` 操作超出了底层数组的容量时,Go 会分配一个**新的、更大的数组**,并将原数据拷贝过去。函数内的那份描述符拷贝会更新其指针指向这个新数组,但 `main` 函数中的原始描述符对此一无所知,它仍然指向旧的数组。 # 10、深入 Go Slice 的底层原理 要真正掌握 Go 语言的切片(Slice),就必须理解其底层的实现原理。Slice 的设计是 Go 语言的一大特色,但其独特的机制也要求开发者对其内部工作方式有所了解,否则在开发中容易遇到难以排查的“坑”。 Slice 的核心可以理解为一个结构体(struct)。如果你不熟悉结构体,可以暂时将其看作是其他语言中的类(class)或对象(object)。  这个结构体包含三个关键部分: 1. **指针 (Data)**:指向一个连续的内存空间,也就是切片的**底层数组**。所有的数据都存储在这里。 2. **长度 (Len)**:切片中当前包含的元素个数。`len()` 函数返回的就是这个值。 3. **容量 (Cap)**:从切片的起始元素到底层数组的末尾,总共可以容纳的元素个数。`cap()` 函数返回此值。 ## 1、为什么 Slice 兼具“值传递”与“引用传递”的特征? 这是关于 Slice 最核心、也最容易混淆的问题。 **答案是:Go 的 Slice 本身是“值传递”的,但它的效果常常表现为“引用传递”。** **初始状态:创建一个 Slice** 首先,我们创建一个 Slice。这个 Slice `s1` 包含三个元素 `[A, B, C]`。在内存中,这表现为一个 `sliceHeader` 结构体,它包含一个指向底层数组的**指针 (ptr)**,以及**长度 (len)** 和**容量 (cap)**。  **说明:** - `s1` 的 `sliceHeader` 包含了指向底层数组的指针。 - 底层数组是实际存储数据 `[A, B, C, ...]` 的地方。 - `len` 是 `3`,表示 Slice 当前包含的元素数量。 - `cap` 是 `5`,表示底层数组从指针开始位置到其末尾的总容量。 **函数调用:将 Slice 作为参数传递** 现在,我们将 `s1` 传递给一个函数 `foo(s2)`。Go 会**复制** `s1` 的 `sliceHeader`,创建一个新的 `sliceHeader` `s2`。关键在于,这个新的 `s2` 内部的指针,和 `s1` 的指针指向的是**同一个底层数组**。  **说明:** 1. **值传递 (Pass-by-Value):** `s1` 的 `sliceHeader` 被完整地复制给了 `s2`。`s1` 和 `s2` 是两个独立的结构体变量,它们位于不同的内存区域(例如,在栈上的不同帧中)。 2. **引用效果 (Reference-like Effect):** 尽管 `sliceHeader` 是复制的,但它们内部的指针 `ptr` 具有相同的值。这意味着它们都指向了同一个存储实际数据的底层数组。 **结论** 正是因为这种机制——**“Header 本身是值传递,但内部指针共享同一个底层数组”**——导致了 Slice 的独特行为: - **表现为“引用传递”**:当你在函数内部通过 `s2` 修改底层数组的元素时(例如 `s2[0] = 'X'`),这个修改会通过共享的底层数组反映到 `s1` 上。 - **表现为“值传递”**:如果你在函数内部使用 `append` 操作,导致底层数组因为容量不足而重新分配了内存,那么 `s2` 的指针会指向一个新的底层数组。这时,`s2` 的后续修改将不再影响原始的 `s1`,因为它俩的指针已经指向了不同的地方。这暴露了其“值传递”的本质。  **结果分析**: - **步骤 3**:修改 `s2[0]` 将底层数组中原来的 `5` 改成了 `99`。因为 `s1` 的最后一个元素也指向这里,所以 `s1` 也受到了影响。 - **步骤 4**:`s2` 发生了扩容,它指向了一个全新的底层数组。 - **步骤 5**:再次修改 `s2` 时,它操作的是新数组,与 `s1` 指向的旧数组已经毫无关系。 ## 2、扩容策略 Go 的 Slice 扩容机制旨在平衡内存使用和分配次数: - 当所需容量小于 1024 个元素时,会**翻倍扩容** (newCap = oldCap * 2)。 - 当所需容量超过 1024 个元素时,会**以 1.25 倍**的速度缓慢增长,避免因单次扩容造成巨大的内存浪费。  观察输出,你会清晰地看到容量从 1, 2, 4, 8... 一直翻倍增长,直到超过 1024 后增长速度放缓。 ## 3、核心总结 - **Slice 是一个包含指针、长度和容量的结构体。** - **函数传递 Slice 时,传递的是这个结构体的副本(值传递)。** - **在不发生扩容时,对 Slice 元素的操作会通过指针影响共享的底层数组,表现出“引用传递”的效果。** - **一旦 `append` 操作触发扩容,Slice 的指针会指向新分配的内存,与原来的底层数组“脱钩”,表现出“值传递”的效果。** - 这也解释了为什么 `append` 函数**必须有返回值**并重新赋值给原 Slice (`slice = append(slice, ...)`), 因为扩容后返回的是一个指向全新内存的、完全不同的 Slice 结构体。 # 11、Map 使用详解 在 Go 语言中,`slice` 和 `map` 是使用频率最高、也最为核心的两种集合类数据结构。本节将详细讲解 `map` 的定义、特性及其使用方法。 ## 1、什么是 Map? `Map` 是一种基于 **键值对(key-value)** 的、**无序**的集合。 - **Key (键)**:作为索引,每个键都是唯一的。 - **Value (值)**:与键相关联的数据。 `Map` 最主要的优势在于其高效的查询性能。与需要通过遍历来查找元素的数组或切片不同,`map` 可以通过键直接定位到值,其查询操作的时间复杂度为 **O(1)**。这使得 `map` 在需要快速存取的场景中表现极为出色。 ## 2、Map 的定义与初始化 ##### **基本定义** 定义 `map` 时,需要指定键(key)和值(value)的数据类型。 ```go // 定义一个键为 string 类型、值为 string 类型的 map var courseMap map[string]string ``` 在上述代码中,`string` 是键的类型,必须放在中括号 `[]` 内;第二个 `string` 是值的类型。 ##### **初始化** `Map` 在使用前 **必须进行初始化**,否则会导致运行时错误(runtime panic)。对一个未初始化的 `nil` map 进行写操作是 Go 语言中的常见错误。 > **错误示例**:向未初始化的 `map` 中添加值会引发 panic。  以下是两种推荐的初始化方式: ### 方式一:使用字面量初始化 在声明的同时,可以通过字面量直接为其赋初值。Go 会自动推断其类型。  **注意**:根据 Go 的语法规定,在使用多行字面量初始化时,最后一个元素后面也必须跟一个逗号。 如果想初始化一个空 `map`,可以这样做:  ### 方式二:使用 `make` 函数 `make` 是 Go 语言的内置函数,专门用于初始化 `slice`、`map` 和 `channel` 这三种引用类型。这是最常用的初始化方式。  **总结**:无论采用哪种方式,关键在于确保在向 `map` 中存入值之前,它已经被成功初始化。 ## 3、 Map 的基本操作 ### 3.1 添加与修改值 `Map` 的赋值操作语法非常简洁。如果键已存在,则会更新其对应的值;如果不存在,则会创建新的键值对。  ### 3.2 获取值  ## 4、 `nil` 在 Map 和 Slice 中的差异 一个值得注意的细节是 `nil` 对 `map` 和 `slice` 的影响不同。 - **`nil` map**:一个未初始化的 `map`,其值为 `nil`。你不能向一个 `nil map` 添加任何元素。 - **`nil` slice**:一个未初始化的 `slice`,其值也为 `nil`。但特殊的是,你可以对一个 `nil slice` 安全地使用内置的 `append` 函数来添加元素。  这个差异是 Go 语言设计中的一个重要细节,在面试和实际开发中都可能遇到。 # 12、Map 的遍历与特性 在 Go 语言中,我们经常需要遍历 `map` 来处理其中的每一个键值对。本节将深入探讨 `map` 的遍历方法、键的类型约束以及其固有的无序性。 ## 1、使用 `for...range` 遍历 Map `for...range` 结构是遍历 `map` 的标准且最便捷的方式。 ##### **同时获取键和值** 在遍历时,`for...range` 会返回两个值:键(key)和与该键对应的值(value)。  ##### **只获取值** 如果只关心 `map` 中的值而不需要键,可以使用**空白标识符** `_` 来忽略键。  ##### **只获取键** `for...range` 还支持一种单返回值形式。当只提供一个变量时,该变量接收到的是 `map` 的**键**。  **实现原理**: Go 编译器会根据 `for...range` 接收变量的数量(一个或两个)来决定底层的实现方式,因此这两种写法都是有效的。尽管两种方式都能实现完整遍历,但直接使用 `for key, value := range ...` 的形式通常更清晰、更高效。 ## 2、Map 的键类型约束 并非所有类型都能作为 `map` 的键。 - **Value(值)**:可以是任意 Go 类型。 - **Key(键)**:键的类型必须是**可比较的(Comparable)**。这意味着该类型的值可以用 `==` 或 `!=` 运算符进行比较。 常见的可比较类型包括: - `bool` - 数值类型(`int`, `float64`, 等) - `string` - 指针 - `channel` - `interface` - **数组(Array)** 而不可比较的类型,因此**不能**作为 `map` 的键,主要包括: - **切片(Slice)** - **Map** - **函数(Function)**  在编写代码时,现代的 IDE 通常会自动检测并提示无效的键类型。 ## 3、Map 的无序性 这是 `map` 最重要的特性之一:**`map` 是无序的**。 当遍历一个 `map` 时,元素的返回顺序是不固定的。Go 语言在设计上特意打乱了遍历的起始点,以防止开发者依赖于某个特定的迭代顺序。   **核心结论**: 1. `Map` 的遍历顺序是**随机**的。 2. **绝不能**依赖 `map` 的遍历顺序来实现业务逻辑。 如果你的应用场景要求数据容器保持有序,那么 `map` 本身无法满足需求。在这种情况下,通常需要将 `map` 与 `slice` 结合使用:例如,将键存储在一个 `slice` 中并对其进行排序,然后根据排好序的 `slice` 来从 `map` 中取值。 # 13、判断Map 中是否存在元素和删除元素 ## 1、获取与判断 Map 中的元素 #### 1.1 直接取值的潜在问题 直接通过键(key)从 Map 中获取值是一种常见操作。  然而,这种方式存在一个重要的模糊性:当指定的键不存在时,Go 会返回该值类型的**零值**。例如,如果值的类型是 `string`,那么在键不存在时会返回一个空字符串 `""`。  这会导致一个问题:我们无法区分 **“键存在,但其对应的值恰好是零值(如空字符串)”** 和 **“键根本不存在”** 这两种情况。仅通过判断返回值是否为零值来确定键是否存在,可能会导致程序逻辑错误(bug)。 #### 1.2 安全的键存在性判断 为了准确地判断一个键是否存在于 Map 中,应该使用支持两个返回值的访问方式。  这种用法会返回两个值: - `value`:与键对应的值。如果键不存在,`value` 会是该类型的零值。 - `ok`:一个布尔值。如果键存在,`ok` 为 `true`;如果键不存在,`ok` 为 `false`。 因此,**检查 `ok` 的值是判断键是否存在的标准方法**。  #### 结合 `if` 的简洁语法 Go 语言允许在 `if` 语句的条件判断前执行一个简短的初始化语句。这种特性与 Map 的键值判断完美结合,可以写出更紧凑、可读性更高的代码。 通过这种方式,从 Map 中获取的 `value` 和 `ok` 变量的作用域被限制在 `if-else` 代码块内部,增强了代码的封装性。  如果只关心键是否存在而不关心其具体值,可以使用空标识符 `_` 来忽略第一个返回值。  ## 2、删除 Map 中的元素 使用 Go 的内置函数 `delete()` 可以方便地从 Map 中移除一个键值对。 **语法**:`delete(map_variable, key_to_delete)`  一个重要的特性是,**尝试删除一个不存在的键不会导致程序错误(panic)**。如果 `delete()` 函数的目标键不存在,该操作不会产生任何效果,程序会继续正常执行。因此,可以放心使用 `delete()` 而无需预先检查键是否存在。 ## 3、重要提示:Map 的线程安全 Go 语言内置的 **`map` 类型不是线程安全的**。 如果在多个协程(goroutine)中并发地对同一个 Map 进行读写操作,将会导致数据竞争,引发不可预测的程序行为甚至运行时崩溃。 对于需要在并发环境中使用的 Map,必须采用同步措施。Go 标准库为此提供了 **`sync.Map`** 类型,它专门为并发访问场景设计,并内置了必要的锁定机制。在并发编程中,应优先使用 `sync.Map` 来保证数据安全。 # 14、list:链表数据结构 本节我们探讨 Go 语言容器中的 `list`,它是一个基于链表实现的数据结构。 ## 1、`list` 与 `slice` 的核心差异 `list` 的设计初衷是为了解决 `slice` 在特定场景下的局限性。`slice` 的本质是动态数组,其核心特点和潜在问题如下: - **连续内存空间**:`slice` 要求其底层数据存储在一块**连续的内存**中。这意味着如果内存中没有足够大的连续空间,就无法分配相应大小的 `slice`。 - **扩容成本**:当向 `slice` 中持续添加数据直至其容量用尽时,会触发**扩容**机制。扩容会分配一块更大的新内存空间,并将所有旧数据完整地**拷贝**到新空间,在高频添加场景下会带来性能开销。 为了应对这些问题,`list`(链表)采用了完全不同的存储模型。 - **非连续内存**:链表中的每个元素(节点)都在内存中独立分配。它们通过**指针**(`next` 和 `prev`,因为 Go 的 `list` 是双向链表)连接起来,而无需在物理上连续存储。 - **空间开销**:这种结构的代价是每个元素都需要额外的空间来存储指向其他元素的指针,因此存在一定的空间浪费。 ## 2、性能特点对比 由于底层原理的巨大差异,`slice` 和 `list` 在不同操作上表现出截然不同的性能。 | | | | | |---|---|---|---| |**操作 (Operation)**|**slice (切片)**|**list (链表)**|**备注 (Remarks)**| |**查询/访问 (Query/Access)**|O(1)|O(n)|`slice` 通过索引可直接计算内存地址,访问速度极快。`list` 必须从头节点开始逐个遍历才能找到目标元素。| |**中间插入/删除 (Mid-list Insertion/Deletion)**|O(n)|O(1)|`slice` 在中间插入或删除元素需要移动该位置之后的所有元素。`list` 仅需修改相邻节点的指针即可,操作非常高效(前提是已持有目标位置的指针)。| 例如,要在 `[1, 2, 3, 4, 5, 6]` 的 `3` 和 `4` 之间插入 `7`: - **对于 `slice`**:需要重新分配一个更大的连续空间,然后将 `1, 2, 3` 拷贝过去,放入 `7`,再将 `4, 5, 6` 拷贝到后面。 - **对于 `list`**:只需新分配一个存放 `7` 的节点,然后将 `3` 的 `next` 指针指向 `7`,`7` 的 `prev` 指针指向 `3`,`7` 的 `next` 指针指向 `4`,`4` 的 `prev` 指针指向 `7` 即可。无需移动任何现有元素。 ## 3、应用场景与总结 尽管 `list` 在插入和删除操作上具有理论优势,但在 Go 的实际开发中,其使用频率远低于 `slice` 和 `map`。 **主要原因在于**: 1. **查询性能差**:`O(n)` 的查询复杂度在绝大多数场景下是不可接受的。 2. **缓存不友好**:`slice` 的连续内存布局具有极佳的**缓存局部性**,CPU 可以高效预取数据,使其遍历性能在实践中远超 `list`。 因此,`slice` 和 `map` 凭借其出色的综合性能,满足了超过 90% 的编程需求。只有在**极少数需要海量、高频地在集合中间进行插入和删除**,且对查询性能要求不高的特定场景下,`list` 才可能成为一个考虑选项。 总而言之,理解 `list` 的原理有助于我们全面地认识 Go 的数据结构,但在日常编码中,应优先选择 `slice` 和 `map`。 # 15、list 的基本使用 本节将演示如何使用 Go 语言标准库中的 `container/list` 包,它提供了一个双向链表的实现。 与 `slice` 和 `map` 不同,`list` 并非 Go 的内置关键字,而是通过导入包来使用。 ```go import ( "container/list" "fmt" ) ``` ## 1、导入与初始化 初始化一个 `list` 有两种主要方式:  ## 2、添加元素 `list` 提供了在链表头部和尾部添加元素的方法。 - `PushBack(v interface{}) *Element`:在链表尾部添加元素。 - `PushFront(v interface{}) *Element`:在链表头部添加元素。  ## 3、 遍历元素 `list` 的遍历不能使用 `for...range` 循环,而需要通过从头(`Front`)或尾(`Back`)节点开始,借助节点的 `Next()` 或 `Prev()` 方法进行移动。 **正序遍历 (从头到尾)**  **逆序遍历 (从尾到头)**  ## 4、插入与删除 `list` 最核心的优势是在指定元素前后进行高效的插入和删除操作。这些操作需要一个指向具体元素(`*list.Element`)的指针作为锚点。 ### 4.1 在指定元素前插入 `InsertBefore(v interface{}, mark *Element)` 方法可以在 `mark` 元素之前插入一个新值 `v`。 首先,我们需要遍历链表以找到这个 `mark` 元素。  ### 4.2 删除指定元素 `Remove(e *Element)` 方法可以从链表中删除一个元素。同样,需要先找到该元素。  ## 5、总结:四种集合类型的回顾 Go 语言中主要的四种集合类型各有其特点和适用场景: 1. **数组 (Array)** - **特点**: 定长,长度是类型的一部分。 - **用途**: 使用较少,通常用于需要精确控制内存布局的底层场景。 2. **切片 (Slice)** - **特点**: 动态数组,使用方便,性能高(缓存友好)。 - **用途**: **最常用**的数据结构,几乎是所有序列数据的首选。 3. **Map** - **特点**: 键值对存储,查找效率高。 - **用途**: **非常常用**的数据结构,用于实现关联数组、哈希表等。它在功能上没有直接的竞争者。 4. **列表 (List)** - **特点**: 双向链表,中间插入/删除效率高,但查询慢。 - **用途**: 使用较少。它的竞争对手是 `slice`。虽然在理论上 `list` 能完成的 `slice` 也能完成,反之亦然,但由于性能和缓存等因素,`slice` 在绝大多数场景下是更优的选择。 **核心建议**: 熟练掌握 `slice` 和 `map`,因为它们构成了 Go 日常开发的基础。仅在确认业务场景高度契合链表特性(如高频的非首尾插入/删除)时,才考虑使用 `list`。若想了解更多方法,可直接查阅 `container/list` 包的官方文档。
