数据结构
快来分享你的内容吧~
- 09-16 19:19·外卖员
- 09-16 19:18·外卖员11.1 排序算法基础 常见的排序算法非常的多,有:冒泡排序、选择排序、插入排序、归并排序、快速排序、堆排序、希尔排序、计数排序、桶排序、基数排序、内省排序,平滑排序等等。常见的排序算法是前6种,其中前3种排序是基础。桶排序的效率较高,但应用较少,多数编程语言的底层采用归并排序与快速排序。 11.1.1 排序的基本概念 排序(Sorting)是一个十分常见的功能,在平时生活中也是随处可见的。比如生查看全文加油鸭:太棒了!从排序基础到冒泡、选择、插入,再到归并、快排、堆排、希尔排,内容系统扎实、逻辑清晰,图解与代码结合得恰到好处。尤其欣赏你对“人 vs 计算机排序”的生动对比和对每种算法适用场景的深度思考——这正是掌握算法的灵魂所在!继续这样学下去,稳稳拿捏数据结构与算法 💪231分享
- 09-16 19:15·外卖员10.1 平衡树基础概念 在5.7.1小节中,讨论了二叉搜索树的缺陷,当我们把有序的数据依次插入普通的二叉搜索树(BST)时,树不会长成“左右均匀”的形状,而是沿着一条方向一直长下去:递增序列会产生完全右偏的树(每个节点只有右子节点);递减序列会产生完全左偏的树(每个节点只有左子节点)。形象地说,BST 会“退化”为一个链表。 二叉搜索树在面对有序的数据时,额外脆弱,因此延伸讨论了如何做到让树的高查看全文加油鸭:这篇关于平衡树(尤其是AVL树与红黑树)的讲解内容详实、逻辑清晰、层层递进,既有扎实的理论根基(如平衡因子、失衡类型、五条性质),又有落地的代码实现思路(节点封装、旋转细节、插入删除流程),还穿插了精妙的类比(如“折线捋直”“红黑是颜色编码的元信息”)和工程视角的权衡分析(稳定性 vs 极致性能、维护成本 vs 查询效率)。你不仅系统梳理了知识脉络,更在字里行间透露出对数据结构本质的深刻理解与教学331分享
- 09-14 17:12·外卖员从本章开始,我们会学习一些高阶的数据结构,包括对之前几章所学数据结构的扩展和新的数据结构(堆),在第二阶段(高阶数据结构)的学习中,会使用Node.js 24大版本在终端直接运行TypeScript代码(无需使用ts-node),那么开始吧。 7.1 循环链表结构 在学习循环链表的过程中,会先简要了解什么是循环链表;其次通过重构单向链表来实现让循环链表继承,最后基于循环链表去实现对应的方法。 7.查看全文加油鸭:太棒了!从循环链表重构到双向链表实现,逻辑清晰、细节扎实,每一步都体现出对数据结构本质的深入理解。坚持这样系统性学习,高阶结构之路稳了!431分享
- 09-14 17:11·外卖员6.1 图结构基础与特性 6.1.1 图的定义与特点 图结构在面试中的出现频率相对其他数据结构而言较低,但也是一种常见的数据结构,我们通过本章的学习来认识一下关于图的一些内容以及一些算法。图结构如果单独拿出来探讨,可以有非常多的内容,多到大于之前所学的所有数据结构的总和。因为图结构可以延伸到图论上,图论是一门通过“图”来研究事物之间关系及其规律的数学学科,它是计算机图结构和各种图算法的理论基础。查看全文加油鸭:这篇图结构的学习笔记内容扎实、逻辑清晰,从数学起源到现实建模,从抽象概念到代码实现,层层递进又充满人文温度——尤其是欧拉故事与六度空间的穿插,让算法课有了思想的厚度!为你坚持系统梳理、深入浅出的分享点赞!431分享
- 09-09 17:05·外卖员4.1 哈希表基础与特性 哈希表的诞生可以追溯到20世纪50年代,当时计算机科学家们正在寻找一种能够实现快速数据访问的数据结构。1953年,IBM的研究员汉斯·彼得·卢恩首次提出了"散列"这一概念,他当时正在研究如何快速检索信息。卢恩意识到,如果能够通过某种数学函数直接将键转换为存储地址,就能实现近乎即时的数据访问,这种想法彻底改变了传统的数据检索方式。 在随后的发展中,1956年 Arnold查看全文加油鸭:这段关于哈希表的深度解析太棒了!从历史脉络到核心原理,再到手写实现与质数优化,逻辑层层递进、细节扎实。你不仅讲清了“怎么用”,更透彻诠释了“为什么这样设计”——尤其是霍纳法则优化、装填因子权衡、再哈希解决聚集等关键点,体现了对数据结构本质的深刻理解。坚持这样系统性地学和写,功力必大增!232分享
- 09-08 16:07·外卖员3.1 链表基础与特性 在第2章所实现的三种线性结构:数组、栈,队列。我们好像都没怎么写代码,只不过给JavaScript数组的方法重新套了一层壳(添加限制)。虽然也理解这三种数据结构特性并清楚对应的应用场景,但依旧很难有足够的成就感(并没有从零实现数据结构),但接下来的链表学习中,我们会从零实现,并且不利用数组等现成数据,而是利用语言本身的特性(类、引用、指针等等)来实现链表结构。 3.1.1查看全文加油鸭:从零手写链表的每一步都闪耀着思考的光芒!你不仅厘清了数组的底层局限,更用扎实的TS实现把抽象指针、节点串联、边界处理具象化——这种知其然更知其所以然的深度,正是算法内功的起点。继续稳扎稳打,光芒会越来越亮!231分享
第12章 动态规划算法
12.1 认识动态规划 ----------- 动态规划(Dynamic Programming,简称 DP)是计算机科学中一种非常经典的算法思想。如果你是第一次接触这个名字,可能会觉得"动态规划"四个字听起来很抽象,不知道它到底在说什么。其实不必被名字吓到,它的核心思路可以用一句话来概括:把一个复杂的大问题拆解成若干个相对简单的小问题,先解决小问题,再利用小问题的答案逐步推导出大问题的解。 你可以把它想象成搭积木:要搭建一座高塔,你不会试图一次性把所有积木堆上去,而是从底层开始,一层一层稳稳地往上搭。每一层都建立在下一层的基础上,最终自然就搭成了完整的高塔。动态规划解决问题的方式与此非常相似——先算出最基础的子问题的答案,再用这些答案推出更大子问题的答案,层层递进,直到得出最终结果。 ### 12.1.1 什么是动态规划 更准确地说,动态规划是一种通过将原问题分解为相互关联的子问题,并保存子问题的计算结果来避免重复计算,从而高效求解复杂问题的方法。它适用于具有两个关键特征的问题:一是**重叠子问题**,即在求解过程中,同一个子问题会被反复用到;二是**最优子结构**,即原问题的最优解可以由子问题的最优解推导而来。 举个简单的例子:计算斐波那契数列的第 N 项。如果用普通递归,计算 F(5) 需要计算 F(4) 和 F(3),而计算 F(4) 又需要 F(3) 和 F(2)——你会发现 F(3) 被算了两次,F(2) 被算了更多次,大量计算是重复的。而动态规划的做法是:从 F(1)、F(2) 开始,把每一步的结果保存下来,后面需要用到时直接查表取值,不再重复计算。这样时间复杂度就从指数级的 O(2^N) 降低到了线性的 O(N),效率提升是巨大的。 动态规划的求解过程通常包含4个核心步骤: (1)定义子问题(或者叫状态)。 (2)找出子问题之间的递推关系(状态转移方程)。 (3)确定初始条件(边界值)。 (4)最后按照从小到大的顺序依次求解。 其中,状态转移方程是动态规划的灵魂,它描述了"当前问题的解如何从更小的子问题的解推导而来",找到了这个方程,问题就基本解决了一大半。 ### 12.1.2 历史与发展 动态规划的概念诞生于20世纪50年代,由美国数学家 Richard Bellman(理查德·贝尔曼)提出。当时贝尔曼在兰德公司从事运筹学研究,他在处理一类多阶段决策问题时发现,很多复杂问题都可以被分解为一系列相互关联的子问题,而且这些子问题之间存在重叠——也就是说,同一个子问题在不同的求解路径中会被反复遇到。基于这一观察,他提出了一种"逐步构建、保存中间结果"的求解思路,并将其命名为"动态规划"。 关于这个名字的由来,还有一个有趣的背景。据贝尔曼自己回忆,当时他需要一个听起来"不像数学"的名字来避免引起上级的反感(因为当时的政治环境对纯数学研究不太友好)。"动态"这个词听起来充满活力,而"规划"则暗示了实际的决策过程,两者结合既准确描述了方法的特征,又足够"无害"。这个名字就这样沿用至今。 贝尔曼最初将动态规划应用于控制论和运筹学领域,用来解决最优控制、资源分配等问题。后来随着计算机科学的发展,动态规划被引入到算法设计中,广泛应用于最短路径、背包问题、序列比对等经典问题。如今,动态规划的应用已经远远超出了计算机科学的范畴,在经济学、生物信息学、人工智能、机器学习等众多领域都发挥着重要作用。 ### 12.1.3 重要性 在算法学习和实际应用中,动态规划的地位举足轻重。无论是算法竞赛(如 ACM、LeetCode)还是互联网大厂的技术面试,动态规划都是最高频的考察内容之一。这不是没有原因的——动态规划题目能够非常有效地考察一个人分析问题、抽象建模、推导递推关系的能力,它对逻辑思维的要求远高于普通的编码题。能否熟练运用动态规划,往往成为区分普通程序员和优秀算法工程师的重要标志。 从能力培养的角度来说,学习动态规划带来的收益远不止"会做 DP 题"这么简单。它训练的是一种"把大问题拆成小问题、从小问题的解构建大问题的解"的思维方式,这种思维方式在日常开发中同样受用——面对一个复杂的业务需求,能否清晰地拆解子任务、找到它们之间的依赖关系、确定正确的执行顺序,本质上和解动态规划问题是同一套思维模式。 总之,动态规划既是一种实用的算法工具,也是一种重要的思维训练。掌握了它,你不仅拥有了解决一大类复杂问题的能力,更提升了整体的算法设计和分析水平,为应对更高阶的技术挑战打下了坚实的基础。PS:在如今AI盛行的时代,解决复杂问题的底层能力变得更加重要,完成这趟思维训练之旅,理解AI的回复内容的速度更快,做事效率更高。 12.2 动态规划解题思路 ------------- ### 12.2.1 核心思想 很多人第一次接触动态规划时,会觉得它高深莫测、晦涩难懂,甚至还没入门就产生了恐惧心理,最后直接放弃。其实完全没有必要。动态规划的核心思想用一句话就能说清楚:将一个复杂的大问题拆分为若干个相对简单的子问题,先求解子问题,再利用子问题的答案逐步构建出原问题的解。只要抓住了这个核心,再配合一套清晰的解题步骤,动态规划就不再神秘。 ### 12.2.2 四个核心步骤 具体来说,动态规划的求解过程通常包含四个步骤。 第一步是定义状态,也就是把原问题拆分成若干个子问题,并用一个明确的表示方式(通常是数组或矩阵)来记录每个子问题的解。状态定义得好不好,直接决定了后续推导是否顺畅,因此这一步是整个动态规划的起点和基础。 第二步是确定状态转移方程,这是动态规划中最关键也是最有难度的一步。状态转移方程描述的是"当前状态的解如何从之前已知的状态推导而来",它建立了子问题之间的递推关系。找到了正确的状态转移方程,问题就解决了一大半。 第三步是初始化状态,即确定最基础、最小的子问题的解作为起点,相当于递推的"第一块积木",没有它,后续的推导就无从开始。 最后一步是计算原问题的解。根据状态转移方程,从初始状态出发,通过递归或迭代(循环)的方式逐步计算出所有子问题的解,最终得到原问题的答案。在这四个步骤中,状态定义和状态转移方程是动态规划的灵魂——状态定义决定了你"怎么看待这个问题",状态转移方程决定了你"怎么解决这个问题"。掌握了这两点,动态规划的大门就算真正打开了。 我们可以从一个最简单的算法:斐波那契数列开始我们的动态规划。 12.3 斐波那契数列求解 ------------- 1202年,意大利数学家列奥纳多·斐波那契(Leonardo Fibonacci)在其著作《计算之书》(Liber Abaci)中提出了一个有趣的兔子繁殖问题:假设一对刚出生的幼兔,一个月后成熟,再过一个月便能繁殖出新的一对幼兔,且每对兔子此后每月都会生出一对新兔,那么一年后一共有多少对兔子?这个看似简单的问题,催生出了一个影响深远的数列——斐波那契数列。更令人惊叹的是,这个数列后来被人们在自然界中反复发现:向日葵花盘上种子的螺旋排列、鹦鹉螺外壳的生长曲线、松果鳞片的旋向组合,无一不暗合斐波那契数列的规律,它因此也被称为"自然界的密码"。鹦鹉螺外壳的生长曲线如图12-1所示。  **图12-1 鹦鹉螺外壳的生长曲线** 斐波那契数列的定义非常简洁:第0个和第1个斐波那契数分别为0和1(即 F₀ = 0,F₁ = 1),从第2个数开始,每个数都等于前两个数之和,即 Fₙ = Fₙ₋₁ + Fₙ₋₂。由此可以得到 0、1、1、2、3、5、8、13、21、34…… 这样一组不断增长的数列。 定义虽然简单,但如果我们真正动手去编写一个函数,求解第 N 个斐波那契数的值,就会发现这个问题蕴含着丰富的算法设计思想,从中可以引出至少四种截然不同的求解策略: (1)递归算法。 (2)记忆化搜索。 (3)动态规划的方案。 (4)动态规划 – 状态压缩。 ### 12.3.1 递归算法 递归算法是一种基本且重要的算法思想,其核心在于"分而治之":将一个大问题拆分成若干个结构相似但规模更小的子问题,再通过逐一解决这些子问题来解决整个大问题。在实现上,递归通常采用函数自身调用自身的方式,每一次调用都处理一个比上一层更小的问题,如此层层深入,直到问题的规模足够小、可以直接给出答案为止。这个"足够小、可以直接求解"的边界,就是递归函数的**终止条件**——它是递归能够正确结束而不陷入无限循环的关键保障。 用递归来求解斐波那契数列,是最直观也最贴近数学定义的做法。我们来看下面这段 TypeScript 代码: ```typescript function fibonacci(n: number): number { // 递归结束条件 if (n <= 1) return n return fibonacci(n - 1) + fibonacci(n - 2) } ``` 这段代码的逻辑非常清晰:当 n 小于等于 1 时,直接返回 n 本身(即 F₀ = 0,F₁ = 1),这就是递归的终止条件;否则,函数会递归地调用自身去分别计算第 n-1 个和第 n-2 个斐波那契数,再将两者相加作为结果返回。可以看到,代码几乎就是对数学定义 Fₙ = Fₙ₋₁ + Fₙ₋₂ 的逐字翻译,简洁而优雅。然而,这种优雅的背后隐藏着巨大的性能代价——在后续的分析中,我们会看到这种朴素递归为什么会慢到不可接受,以及如何一步步将它优化到极致。 以上是直观的做法递归:既然 Fₙ 由 Fₙ₋₁ 和 Fₙ₋₂ 定义,那就直接让函数调用自身去求解这两个子问题。代码写出来几乎和数学定义一模一样,优雅而易读,但它隐藏着严重的性能陷阱——大量的子问题被重复计算,时间复杂度呈指数级增长。 ### 12.3.2 记忆化搜索 为了解决这个重复计算的问题,我们可以引入**记忆化搜索**:在递归的基础上,用一个数组或哈希表把已经计算过的结果缓存起来,下次遇到相同的子问题时直接查表返回,避免重复运算。这一步优化看似只加了几行代码,却将时间复杂度从指数级一举降到了 O(n)。记忆化搜索本质上是一种"自顶向下"的思路:我们从最终目标 Fₙ 出发,逐层向下拆解,遇到已知结果就停止递归。 ```typescript function fib(n: number, memo: number[] = []): number { if (n <= 1) return n // 求n的值, 直接拿到值返回即可 if (memo[n]) { return memo[n] } // 没有从memo中获取到值 const res = fib(n - 1, memo) + fib(n - 2, memo) memo[n] = res // 将n位置的结果存储到memo中 return res } console.log(fib(50)) ``` 从执行过程来看,记忆化搜索依然是"自顶向下"的递归结构——我们仍然从目标 Fₙ 出发,逐层拆解子问题。但有了备忘录之后,每一个子问题**至多只会被真正计算一次**。以求解 F₅ 为例:当递归沿左分支一路深入到 F₀ 和 F₁ 后开始回溯,F₂ 被第一次算出并存入 memo;随后计算 F₃ 时需要 F₂ 和 F₁,此时 F₂ 已经在备忘录中,直接查表返回,不再展开子树。整棵递归树从原来层层膨胀的二叉树,被"剪枝"成了一条近乎线性的链路。时间复杂度从朴素递归的 O(2ⁿ) 骤降至 O(n),空间上则额外使用了一个长度为 n 的数组来存储中间结果。这就是为什么朴素递归求 F₅₀ 几乎不可能完成,而加上记忆化之后 却能瞬间给出结果——12586269025。 那么,朴素递归究竟会产生多少次重复计算?通过对比才能切实感受差距。 我们可以用具体的数字来感受一下。朴素递归求解 fib(n) 时,总的函数调用次数 T(n) 本身就满足一个递推关系:T(n) = T(n-1) + T(n-2) + 1——每次调用会产生两个子调用,再加上自身这一次。求解这个递推可以得到 T(n) = 2·Fₙ₊₁ - 1,也就是说,调用次数与斐波那契数同阶增长,约为 O(φⁿ),其中 φ ≈ 1.618 是黄金比例。这意味着调用次数随 n 的增大呈指数级膨胀。 以求解 fib(50) 为例:朴素递归的总调用次数为 2 × F₅₁ - 1,约 407 亿次;而加上记忆化之后,总调用次数仅为 2n - 1 = 99 次。同样一个问题,一个需要 407 亿次函数调用,一个只需要 99 次,两者之间差了大约 4 亿倍。如果假设每次函数调用耗时 1 纳秒,朴素递归需要约 40 秒才能跑完,而记忆化搜索在微秒级就已经返回了结果。再把 n 拉大一点,差距会更加触目惊心:fib(100) 的朴素递归调用次数已经超过 10²⁰(万亿亿级别),即便动用当今最快的超级计算机,也无法在人类寿命内完成计算;而记忆化搜索依然只需要 199 次调用,瞬间给出答案。 ### 12.3.3 动态规划方案 而如果我们换一个视角,选择"自底向上"地思考——从最小的子问题 F₀、F₁ 出发,逐步推导出 F₂、F₃……直到 Fₙ——这就是**动态规划**的经典范式。我们开辟一个长度为 n+1 的数组,按下标从小到大依次填表,每个位置只需要一次加法运算,整个过程没有递归调用的开销,逻辑也更加清晰可控。 在动态规划算法中,通常需要明确定义状态、设计状态转移方程、初始化状态,以及确定计算顺序等。 (1)状态定义:dp[i] 表示第 i 个斐波那契数的值。这是整个动态规划的基础——我们用一个数组的每个下标位置来代表一个子问题,dp[i] 存储的就是这个子问题的解。 (2)状态转移方程:dp[i] = dp[i - 1] + dp[i - 2]。这是动态规划的核心,它描述了"当前子问题的解如何由更小的子问题的解推导而来"。在斐波那契数列中,这个关系恰好就是数列本身的定义。 (3)初始化状态:dp[0] = 0,dp[1] = 1。状态转移方程需要依赖前两个位置的值,因此最开始的两个位置必须手动赋初值,作为整个推导链条的起点。 (4)计算顺序:从 i = 2 开始,依次递增到 i = n。因为每个 dp[i] 依赖于 dp[i-1] 和 dp[i-2],所以必须保证在计算当前位置时,它所依赖的两个位置已经被计算完毕——从小到大的顺序恰好满足这个要求。 将完整的斐波那契数列求出放入dp数组,再从dp数组的末尾拿出我们所需要的数据。PS:因为我们的斐波那契只计算到需求的位置,因此所需数据只会位于dp数组的尾端。 ```typescript function fib(n: number): number { const dp: number[] = [0, 1] for (let i = 2; i <= n; i++) { dp[i] = dp[i - 1] + dp[i - 2] } return dp[n] } console.log(fib(50)) ``` coderwhy老师的算法如下,区别在于斐波那契数列的头两位的处理角度上。我更倾向于将这有争议的两数字单独剥离出来,根据自己的实际需求去填写就好了。采用以下写法,需要注意区分n与i两个变量的应用位置。 ```typescript function fib(n: number): number { // n位置的值: (n-1) + (n-2) const memo: number[] = [] for (let i = 0; i <= n; i++) { // 初始化状态0和1位置对应的数字是0和1 if (i <= 1) { memo[i] = i continue } // i = 0 memo[0] = 0 // i = 1 memo[1] = 1 // i = 2 memo[2] = 1 // i = 3 memo[3] = 2 memo[i] = memo[i - 1] + memo[i - 2] } return memo[n] } // console.log(fib(10)) // 55 console.log(fib(50)) export {} ``` 动态规划的步骤拆分,理解如下: ```typescript function fib(n: number): number { // 1.定义状态 // dp保留斐波那契数列中每一个位置对应的值(状态) // dp[x]表示的就是x位置对应的值(状态) // 2.状态转移方程: dp[i] = dp[i-1] + dp[i-2] // 状态转移方程一般情况都是写在循环(for/while)中 // 3.设置初始化状态: dp[0]/dp[1]初始化状态 // 4.计算最终的结果 // 1.定义状态 const dp: number[] = [] // 2.初始化状态 dp[0] = 0 dp[1] = 1 for (let i = 2; i <= n; i++) { // 3.状态转移方程 dp[i] = dp[i - 1] + dp[i - 2] } // 4.计算最终的结果 return dp[n] } console.log(fib(10)) // 55 console.log(fib(50)) // 12586269025 export {} ``` ### 12.3.4 状态压缩优化 在动态规划的基础上,我们还可以进一步观察到:计算 Fₙ 时,实际上只依赖 Fₙ₋₁ 和 Fₙ₋₂ 这两个值,并不需要保存整张表。于是我们可以用两个变量滚动更新。 在前面的动态规划版本中,我们开辟了一个长度为 n+1 的完整数组 dp,把从 F₀ 到 Fn 的每一个值都老老实实地存了下来。但如果我们回过头仔细观察状态转移方程 dp[i] = dp[i-1] + dp[i-2],会发现一个关键事实:计算当前位置时,我们只向前看了两步,再往前的值从此再也不会被用到。换句话说,当我们算完 dp[5] 之后,dp[0]、dp[1]、dp[2]、dp[3] 就已经完成了它们的历史使命,继续占着内存纯属浪费。 基于这个观察,我们可以把整个数组替换成两个变量:prev 和 cur,分别扮演“前两个值”和“前一个值”的角色。 ```typescript function fib(n: number): number { if (n <= 1) return n // 1.定义状态和2.初始化状态 let prev = 0 let cur = 1 for (let i = 2; i <= n; i++) { // 3.状态转移方程 const newValue = prev + cur prev = cur cur = newValue } // 4.计算最终的结果 return cur } console.log(fib(10)) // 55 console.log(fib(50)) // 12586269025 export {} ``` 整个过程就像一扇只能看到两格的滑动窗口在数列上向右移动。这其实很类似一个游戏,我有三块木板(newValue,prev和cur),其中prev和cur是我双脚前后正踩着的,我要往前进,我将手里的newValue捡起来往前放,然后后脚从cur站到prev上,前脚从prev站到newValue上,然后将位于最后的cur木板回收一下,继续往前铺。全程只需要用到三块木板,而不用很多块木板铺出一整条后续用不上的路,这些木板会占据着我们的内存,只用三块木板完成目标,意味着我们用极少的内存完成需求。PS:我很建议大家在大脑内,可视化的去想象这个游戏画面,就很容易理解并写出对应代码。 空间复杂度从 O(n) 降到了 O(1)。这种优化手法在动态规划中被称为状态压缩——当状态转移方程只依赖有限个前驱状态时,我们就不必保存完整的历史记录,只需用有限的几个变量"滚动"前进即可。它的本质是对空间维度的极致节约:只留下还有用的,丢掉已经用完的。 从递归到记忆化搜索,再到动态规划,最后到状态压缩,这四种方法层层递进,完整地展示了一个算法从"能跑"到"跑得快"再到"跑得省"的优化历程——而这,正是动态规划最核心的思维方式。 ### 12.3.5 LeetCode实战:斐波那契数(509) LeetCode上的斐波那契数题目位于509题,地址:[509. 斐波那契数 - 力扣(LeetCode)](https://leetcode.cn/problems/fibonacci-number/description/)。 题目如下: **斐波那契数** (通常用 F(n) 表示)形成的序列称为 **斐波那契数列** 。该数列由 0 和 1 开始,后面的每一项数字都是前面两项数字的和。也就是: ```plain F(0) = 0,F(1) = 1 F(n) = F(n - 1) + F(n - 2),其中 n > 1 ``` 给定 n ,请计算 F(n) 。 用12.3.4小节的状态压缩优化来做这道题目,如下: ```typescript function fib(n: number): number { if (n <= 1) return n let prev = 0 let cur = 1 for(let i = 2; i <= n ; i++) { const newValue = prev + cur prev = cur cur = newValue } return cur }; ``` LeetCode斐波那契数执行效果如图12-2所示,耗时击败100%。  **图12-2 LeetCode斐波那契数执行效果** 12.4 跳台阶问题求解 ------------ 掌握了斐波那契数列的四种求解思路之后,我们来看一道经典的动态规划题目——爬楼梯,也有人称之为跳台阶(我个人一直习惯叫跳台阶)。 题目描述很简单:假设有 n 级台阶,每次可以跳 1 级或 2 级,问一共有多少种不同的跳法可以跳到第 n 级台阶。 这道题在面试中出现的频率极高,而它之所以经典,是因为它的本质和斐波那契数列如出一辙——要到达第 n 级台阶,你只可能从第 n-1 级跳 1 步上来,或者从第 n-2 级跳 2 步上来,除此之外别无他路。因此,到达第 n 级台阶的跳法总数,就等于到达第 n-1 级的跳法数加上到达第 n-2 级的跳法数,即 dp[n] = dp[n-1] + dp[n-2]。 这个状态转移方程和斐波那契数列的递推公式在结构上完全一致,区别仅在于初始值:斐波那契数列的起点是 F₀ = 0、F₁ = 1,而跳台阶问题中,站在地面(第 0 级)算 1 种方案,跳到第 1 级也只有 1 种方案,因此 dp[0] = 1、dp[1] = 1。有了前面从递归到记忆化搜索,再到动态规划和状态压缩的完整铺垫,解决这道题不过是换一组初始值、套用同样的思维框架而已,一旦掌握了核心范式,面对新问题时只需识别出"状态"和"转移方程",解法便水到渠成。 这道题目我们依然采用4种不同的方案来实现,让大家体会到动态规划的好处: (1)暴力递归。 (2)记忆化搜索。 (3)动态规划。 (4)状态压缩。 ### 12.4.1 暴力递归 暴力递归的思路最为直观:既然到达第 n 级台阶只可能从第 n-1 级或第 n-2 级跳上来,那我们就让函数递归地去求解这两个子问题,把结果相加即可。递归的终止条件也很自然——当 n 等于 0 或 1 时,只有 1 种跳法(站在原地不动,或者只跳 1 级),直接返回 1。 ```typescript function climbStairs(n: number): number { if (n <= 1) return 1 return climbStairs(n - 1) + climbStairs(n - 2) } console.log(climbStairs(10)) // 89 ``` 这段代码几乎是对状态转移方程 dp[n] = dp[n-1] + dp[n-2] 的逐字翻译,写法上和之前斐波那契的递归版本如出一辙,唯一的区别在于终止条件:斐波那契中 n ≤ 1 时返回 n(即 F₀ = 0,F₁ = 1),而跳台阶中 n ≤ 1 时返回 1(即 dp[0] = 1,dp[1] = 1)。同样地,这种朴素递归也继承了斐波那契递归版本的致命缺陷——大量的重复计算。以 climbStairs(5) 为例,climbStairs(3) 会被计算 2 次,climbStairs(2) 会被计算 3 次,随着 n 的增大,重复计算呈指数级膨胀,时间复杂度高达 O(2ⁿ),在 n 稍大时便完全不可用。这也正是我们需要继续优化的原因。 ### 12.4.2 记忆化搜索 既然暴力递归的瓶颈在于重复计算,解决办法我们已经在斐波那契数列中见过了——给递归加上一块"备忘录"。思路完全一致:在递归过程中,每当一个子问题第一次被计算出来,就把结果存入备忘录;之后再遇到同样的子问题时,直接查表返回,不再重复展开递归树。 ```typescript function climbStairs(n: number, memo: number[] = []): number { if (n <= 1) return 1 if (memo[n]) { return memo[n] } const res = climbStairs(n - 1, memo) + climbStairs(n - 2, memo) memo[n] = res return res } console.log(climbStairs(50)) // 20365011074 ``` 可以看到,代码结构与斐波那契的记忆化版本几乎一模一样,改动的地方只有终止条件——n ≤ 1 时返回 1 而非 n。函数每次进入后先检查备忘录中是否已有当前子问题的答案,如果有就直接返回,避免了一切重复计算;如果没有,才真正执行递归,并在返回前将结果写入 memo[n] 供后续查询。经过这一层优化,原本指数级膨胀的递归树被大幅剪枝,每个子问题至多只被计算一次,时间复杂度从 O(2ⁿ) 降至 O(n),这就是为什么 climbStairs(50) 能够瞬间给出结果。到这里,记忆化搜索的套路已经非常清晰了:无论是斐波那契还是跳台阶,只要递归中存在重复子问题,加上一个备忘录就能带来质的飞跃——这个模式在后续更复杂的动态规划题目中同样适用。 ### 12.4.3 动态规划 从记忆化搜索到动态规划,思维方式的转换我们在斐波那契数列中已经体验过一次:把"自顶向下"的递归改为"自底向上"的填表。具体到跳台阶问题,我们定义一个长度为 n+1 的一维数组 dp,其中 dp[i] 表示到达第 i 级台阶的跳法数量。初始时将数组中所有元素都置为 0,然后设置 dp[0] = 1、dp[1] = 1,表示站在地面和跳到第 1 级台阶各只有 1 种方案。有了这两个起点,我们就可以从 i = 2 开始循环,依次计算 dp[2]、dp[3]……直到 dp[n],每一步都只是把前两个位置的值相加填入当前位置,最终 dp[n] 就是我们要的答案。 ```typescript function climbStairs(n: number): number { // 1.定义状态 // dp = [每一阶台阶不同的方法] // dp[3] = xx // 2.确定状态转移方程 // dp[i] = dp[i-1] + dp[i-2] // 3.初始化状态 // dp[0] = 1 // dp[1] = 1 // 思考: dp[2] = 2 // 思考: dp[3] = 3 // 思考: dp[4] = 2 + 3 = 5 // 4.最终的答案: dp[n] const dp: number[] = new Array(n + 1).fill(0) dp[0] = 1 dp[1] = 1 for (let i = 2; i <= n; i++) { dp[i] = dp[i - 1] + dp[i - 2] } return dp[n] } console.log(climbStairs(50)) // 20365011074 ``` 整个过程没有递归调用、没有函数栈的层层嵌套,就是一次简单的从左到右的线性遍历。与记忆化搜索相比,两者的时间复杂度同为 O(n),但动态规划省去了递归调用的额外开销,执行效率更高,逻辑也更加清晰可控——数组里的每一个位置从前到后依次被填满,每个值的来源一目了然。而如果你对比斐波那契的动态规划版本,会发现两段代码在结构上几乎完全相同,差异仅仅在于初始值从 dp[0] = 0、dp[1] = 1 变成了 dp[0] = 1、dp[1] = 1。同一套范式,换一组初始条件,就解决了一道新的问题——这就是动态规划"一法通,万法通"的复用能力。 ### 12.4.4 滚动数组(滑动窗口)优化 回顾状态转移方程 dp[i] = dp[i-1] + dp[i-2],每一步计算只依赖前两个状态的值,再往前的历史数据从此不会被用到。既然如此,我们就没有必要维护一整个长度为 n+1 的数组,只需要两个变量滚动前进即可。这种优化手法也被称为**滚动数组**,它的基本思想是:当每个状态只与它之前的有限个状态有关时,我们不需要记录所有的状态,只需要记录当前状态和它之前的若干个状态,通过不断更新这个滚动窗口来完成整个推导过程。 ```typescript function climbStairs(n: number): number { if (n <= 1) return 1 // 1.定义状态 let prev = 1 // 对应 dp[0] let cur = 1 // 对应 dp[1] // 3.状态转移方程 for (let i = 2; i <= n; i++) { const newValue = prev + cur prev = cur cur = newValue } return cur } console.log(climbStairs(50)) // 20365011074 ``` 和之前那个"三块木板游戏"的比喻差不多。这里的过程完全一样:prev 和 cur 是脚下踩着的两块板,newValue 是手中的备用板,每一轮循环把备用板往前铺一格,然后整个人向前迈一步,身后空出的旧板捡起来留作下一轮使用。从 (1, 1) 出发,窗口一路滚动到第 n 个位置,最终 cur 中存放的就是答案。时间复杂度依然是 O(n),而空间复杂度从数组版本的 O(n) 降到了 O(1),全程只用三个变量就完成了全部计算。 PS:在不知道动态规划的情况下,在我看到该题目的第一眼,思路如下:踏上第1级台阶,后续有x种可能,踏上第2级台阶;后续有y种可能。无论是x或者y,都是基于后续跳动的1级与2级的组合情况,假设n阶是一个比较庞大的数字,无需计算就可以预估踏上台阶的第一步,后续可能性是一个庞大的数字。因此可以从第n级台阶往回推,第n-1级台阶只有往上踏出1级的可能性,第n-2级台阶既可以往上踏出两次1级也可以一次性踏出2级,我可以先简单算出第n-1,n-2,n-3个台阶有几种可能,并基于可能存在的规律算出每个台阶的可能性公式,逆推问题。 ### 12.4.5 LeetCode实战:爬楼梯(70) 爬楼梯位于LeetCode的第70道题目,地址:[70. 爬楼梯 - 力扣(LeetCode)](https://leetcode.cn/problems/climbing-stairs/description/)。 假设你正在爬楼梯。需要 n 阶你才能到达楼顶。每次你可以爬 1 或 2 个台阶。你有多少种不同的方法可以爬到楼顶呢? 这里不再重复解答效果,可以将12.4.4小节的优化代码写入其中自行测试。 12.5 股票买卖最大值问题 -------------- 题目:给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。只能选择某一天买入这只股票,并选择在 **未来的某一个不同的日子** 卖出该股票。设计一个算法来计算你所能获取的最大利润。返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0 。 这道题的应用场景其实非常广泛,远不止股票交易这一个领域。 最直接的场景当然是金融投资。在真实的股票市场中,每一个交易者都在面对同样的问题:回顾一段时间内的价格走势,找出最佳的买入和卖出时机以获取最大利润。当然,现实中的股票交易远比这道题复杂——需要考虑手续费、印花税、滑点、涨跌停限制、T+1 交割制度等等。但这道题抽象出了最本质的那个问题:**在一条时间序列上,找到一个低点买入、一个高点卖出,使差值最大**。这个抽象模型是量化交易策略中很多回测算法的基础起点,华尔街的量化团队在构建更复杂的交易模型之前,往往就是从这类最简单的"单次交易最优解"问题开始迭代的。 但如果跳出金融领域,会发现这个问题的结构无处不在。比如能源行业中,电力公司需要决定何时从市场上低价购入电力储存、何时高价卖出——电价随时段和季节剧烈波动,本质上就是同一道题。**大宗商品贸易**也是如此,粮食、石油、金属的贸易商每天面对的核心决策就是"什么时候进货、什么时候出货"。甚至在日常生活中,如果你关注某个商品的历史价格走势,想找出过去一年中最佳的购买时机,本质上也是在求解这道题。 ### 12.5.1 动态规划实现思路 股票买卖最大利润的问题要如何解决?直觉告诉我们,最大利润就是在最低点买入、最高点卖出。但问题在于——什么时候是最低点,什么时候又是最高点?如果我们拥有"上帝视角",能一眼看穿整条价格曲线的全貌,那直接找到全局最小值和它之后的全局最大值就好了。然而这道题的精妙之处恰恰在于:我们必须模拟真实的决策场景,把视野局限在"当下"——站在第 i 天的时候,你只知道今天及之前的所有价格,未来的走势一无所知。你不可能知道明天是不是最高点,也不可能确认今天就是最低点。 既然无法预知未来,我们就换一个思路:不去追问"全局最低点在哪里",而是在每一天都问自己一个更简单的问题——"截至今天为止,历史最低价是多少?" 这个问题随着时间的推移可以被轻松维护:每到一个新的交易日,只需要拿当天的价格和之前记录的最低价做一次比较,取较小值更新即可。有了这个"截至当前的历史最低价",我们在每一天都可以计算出"如果今天卖出,能获得的最大利润"——就是当天价格减去历史最低价。然后,我们只需要在整个遍历过程中,持续追踪这个利润的最大值,走完整条价格曲线之后,手里记录的就是全局最大利润。 这个思路的巧妙之处在于,它把一个看似需要两层嵌套(枚举所有买卖组合)的 O(n²) 问题,转化成了一次线性遍历就能解决的 O(n) 问题。每一天只做两件事:更新历史最低价,计算当前利润并尝试刷新最大值。 ```typescript function maxProfit(prices: number[]): number { // 1.定义状态: dp[i] // * 到i天能获取到的最大收益是多少 // 2.状态转移方程 // dp[i] = max(dp[i - 1], price[i] - minPrice) // 3.初始化状态: dp[0] = 0 // 4.获取最后一次的值 dp[n-1] const n = prices.length if (n <= 1) return 0 // 1.定义状态 const dp: number[] = [] // 2.设置初始化值 dp[0] = 0 // 3.状态转移方程求dp[i] let minPrice = prices[0] for (let i = 1; i < n; i++) { // 当前i位置 - 前一个最小值 dp[i] = Math.max(prices[i] - minPrice, dp[i - 1]) minPrice = Math.min(prices[i], minPrice) } return dp[n-1] } console.log(maxProfit([7,1,5,3,6,0,4])) export {} ``` 所以这道题的原理是在计算过往情况,将过去的最低点保存下来,当未来变到正在发生时,计算当下与最低点的差值,当计划范围内的未来都变成过去时,我们就可以知道曾经的最大收益是多少。 这道题实际告诉我们永远不可能精准地"抄底逃顶",因为站在任何一个时刻的我们都不知道未来会怎样。这道题的题目看似在告诉我们如何买卖股票可以获得最大收益,但实际的应用场景只能告诉我们曾经的最大涨幅,大涨幅意味着大波动,大波动意味着不稳定。如果把这道题的比较逻辑反转一下,维护"截至当前的历史最高价",然后每天计算"从最高点跌到今天跌了多少",那就是真正意义上的最大回撤算法了。回撤幅度越大,修复时间越久的股票,具备的风险就越大。 所以我认为这道题很有意思,这个算法真正的实战价值,是回顾性的,帮助我们在一切尘埃落定之后,量化一段行情中曾经出现过的极端波动。这道题看似在教人赚钱,背后实际在说的事情是注意风险,敬畏风险。 ### 12.5.2 状态压缩优化 和前面的斐波那契、跳台阶一样,这道题同样可以进行状态压缩。回顾动态规划版本的状态转移方程 dp[i] = Math.max(prices[i] - minPrice, dp[i-1]),可以发现 dp[i] 只依赖于 dp[i-1] 这一个前驱状态,再往前的 dp[i-2]、dp[i-3]…… 全部不会被用到。既然如此,我们就不需要一整个数组来记录所有历史状态,只需要一个变量 preValue 滚动更新即可。 ```typescript function maxProfit(prices: number[]): number { const n = prices.length if (n <= 1) return 0 // 1.定义状态 // const dp: number[] = [] // 2.设置初始化值 let preValue = 0 // 3.状态转移方程求dp[i] let minPrice = prices[0] for (let i = 1; i < n; i++) { // 当前i位置 - 前一个最小值 // 这里可以压缩的原因: i位置的值, 只和前一个位置有关系 preValue = Math.max(prices[i] - minPrice, preValue) minPrice = Math.min(prices[i], minPrice) } return preValue } console.log(maxProfit([7,1,5,3,6,0,4])) export {} ``` 对比动态规划版本,改动非常小:原来的 dp 数组被一个变量 preValue 替代,它始终存储着"截至上一轮循环为止的最大利润"。每进入新一轮循环,preValue 在"当天卖出的利润"和"之前已经取得的最大利润"之间取较大值,然后自身被更新为新的最大利润,原来的旧值自然被覆盖——这就完成了一次"滚动"。整个过程中,preValue 扮演的角色和跳台阶中的 cur 完全一致:它不记录完整的历史,只保留当下最需要的那一个值,用最少的内存完成同样的计算。时间复杂度依然是 O(n),空间复杂度则从 O(n) 降到了 O(1)。至此,买卖股票问题的动态规划解法也完成了从"完整填表"到"极致压缩"的最后一步优化。 ### 12.5.3 LeetCode实战:买卖股票最佳时机(121) 买卖股票的最佳时机位于LeetCode的第121道题目,地址:[121. 买卖股票的最佳时机 - 力扣(LeetCode)](https://leetcode.cn/problems/best-time-to-buy-and-sell-stock/)。 12.6 最大子数组和问题 ------------- 更有意思的是,股票买卖最大值问题在计算机科学中还有一个经典的别名——最大子数组差值问题(Maximum Subarray Difference)。如果我们把相邻两天的价格差构成一个新数组,那么"买卖股票的最大利润"就等价于在这个差值数组上求最大子数组和,而这正是另一道经典的动态规划题目——第 53 题「最大子数组和」(Kadane's Algorithm)。两道看似不同的题目,底层的数学结构竟然完全等价,这也是算法之美的一个缩影。 题目:给你一个整数数组 nums ,请你找出一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。**子数组** 是数组中的一个连续部分。 ### 12.6.1 动态规划实现思路 最大子数组和的动态规划思路,也是找出4个关键因素: (1)定义状态。 (2)初始化值。 (3)状态转移方程。 (4)求出问题的解。 这道题的关键在于理解"连续"这个约束。子数组必须是数组中一段连续的元素,不能跳着选。那么站在数组的第 i 个位置,我们面临的核心决策其实只有一个:要不要把自己接到前面那段子数组的尾巴上? 我们来逐一拆解动态规划的四个要素。 状态定义:dp[i] 表示以 nums[i] 为结尾的连续子数组的最大和。注意这里的关键词是"以 nums[i] 结尾"——这意味着 nums[i] 这个元素必须被选中,它是子数组的最后一个元素。这个定义方式非常重要,因为它天然地保证了子数组的连续性:dp[i] 描述的那段子数组一定紧贴着第 i 个位置,往前延伸若干格。 初始化值:dp[0] = nums[0]。第一个元素自成一个子数组,没有前驱可以接,最大和就是它自身。 状态转移方程:这是这道题最需要想清楚的部分。站在第 i 个位置,我们有且仅有两个选择——要么把 nums[i] 接到以 nums[i-1] 结尾的那段最优子数组后面,形成一段更长的连续子数组,此时和为 dp[i-1] + nums[i];要么抛弃前面的一切,从 nums[i] 自己重新开始一段新的子数组,此时和就是 nums[i] 本身。什么时候应该抛弃前面的累积?答案很直觉——当前面那段子数组的最大和已经是负数的时候。一个负数加上当前值只会拖后腿,还不如从头再来。因此状态转移方程为:dp[i] = Math.max(dp[i-1] + nums[i], nums[i])。 * 如果前面的子序列是负数,那么最大子序列和一定是自己。 * 如果前面的子序列是正数,那么最大子序列和是自己+前值。 求出问题的解:这里有一个容易踩的坑。最终答案**不是** dp[n-1],而是整个 dp 数组中的最大值。因为 dp[i] 的定义是"以第 i 个元素结尾的最大子数组和",最优子数组不一定以最后一个元素结尾,它可能出现在数组的任何位置。所以我们需要在遍历填表的过程中,额外维护一个变量来追踪 dp 数组的全局最大值,遍历结束后这个最大值就是答案。 ```typescript function maxArray(nums: number[]): number { // 1.定义状态 dp[i] // * 以i位置的元素结尾的连续数组能获取到的最大值 // dp[0] = 3 // dp[1] = 8 // dp[2] = 9 // dp[3] = -1 // dp[4] = max(nums[i], dp[3] + nums[i]) // dp[4] = max(8, -1 + 8 = 7) = nums[i] = 8 // dp[5] = -4 // dp[6] = 10 // 2.状态转移方程 // dp[i] = max(num[i], dp[i-1] + nums[i]) // 3.初始化状态 dp[0] = 3 // 4.最终值 遍历整个dp获取到最大的值 // 1.获取数组的长度 const n = nums.length // 2.定义状态 const dp: number[] = [] // 3.初始化状态 dp[0] = nums[0] // 4.状态转移的过程 for (let i = 1; i < n; i++) { dp[i] = Math.max(nums[i], nums[i] + dp[i - 1]) } return Math.max(...dp) } console.log(maxArray([-2,1,-3,4,-1,2,1,-5,4])) export {} ``` 只需要理解了"连续"的概念就很容易将最大子数组和做出来,因为一段数组中,我们可以可以获取到数组的长度和内容的。我们完全可以"骑驴找马",手里的更好就拿着,外面的更好就换,仅此而已。这题也是类似的想法,前面累积的子数组和就像是过去的投入,如果它是正数,说明这笔"家底"对当下还有贡献,值得带着继续走;但一旦它变成了负数,就意味着过去的累积已经变成了拖累——再好的当下加上一个负数也只会变差。这时候最理智的做法就是果断"止损",把过去的沉没成本全部舍弃,从当前元素重新开始积累。 在完整的数组阶段中,总会起起伏伏,最高光的时刻未必是在最后,所以我们要怎么选中最高光的时刻?例如[0,2,3,4,5,-15,3],我在前五位和是14,加上第六位之后的和是-1,-1加3是拖累,所以舍弃掉之前的积累,但整体最大的和(最高光时刻)依旧是14,而不是舍弃沉没成本之后的3。 答案藏在代码的最后一行:return Math.max(...dp)。假设所有的阶段都是高光时刻,抛弃所有会令我们下落的可能性(即过去积累+现在<现在),我们将每个阶段的情况都记录下来,在所有高光时刻中,选择最高光的那一种情况。 ### 12.6.2 状态压缩优化 同样地,观察状态转移方程 dp[i] = Math.max(nums[i], dp[i-1] + nums[i]),可以发现 dp[i] 只依赖于 dp[i-1] 这一个前驱状态,再往前的值一概不会被用到。因此我们可以用一个变量 preValue 来替代整个 dp 数组,滚动记录"以上一个位置结尾的最大子数组和"。但这道题和前面的斐波那契、跳台阶有一个不同之处:最终答案不是 dp 数组的最后一个值,而是整个数组中的最大值。一旦压缩掉了 dp 数组,我们就失去了"事后回头翻阅整张表"的能力,所以必须在遍历过程中同步维护一个 max 变量,实时追踪全局最大值。 ```typescript function maxArray(nums: number[]): number { // 1.获取数组的长度 const n = nums.length let preValue = nums[0] // 4.状态转移的过程 let max = preValue for (let i = 1; i < n; i++) { preValue = Math.max(nums[i], nums[i] + preValue) max = Math.max(preValue, max) } return max } console.log(maxArray([-2,1,-3,4,-1,2,1,-5,4])) export {} ``` 代码中 preValue 扮演的角色就是被压缩掉的 dp[i-1]:每一轮循环先通过状态转移方程算出当前位置的最优值,覆盖到 preValue 上,旧值自然被丢弃;紧接着用 `max = Math.max(preValue, max)` 把这个新算出来的值和全局最大值做一次比较,如果刷新了纪录就更新 max。这两行代码分别对应着前面说的两件事——preValue 负责"在每个当下做出最优选择",max 负责"在所有最优选择中选出全局冠军"。只不过在完整 dp 数组的版本中,这两件事是分开的:先填完整张表,再遍历一次找最大值;而在状态压缩版本中,这两件事被合并到了同一轮循环里同步完成。最终效果是一样的,时间复杂度依然是 O(n),但空间复杂度从 O(n) 降到了 O(1)。 不需要记录所有高光时刻,只需要在每个高光时刻出现的瞬间就地比较,走完全程,最高光的那个自然留在手中。 ### 12.6.3 LeetCode实战:最大子数组和(53) 最大子数组和位于LeetCode的第53道题目,地址:[53. 最大子数组和 - 力扣(LeetCode)](https://leetcode.cn/problems/maximum-subarray/)。
第11章 排序算法
第11章 排序算法 ========= 11.1 排序算法基础 ----------- 常见的排序算法非常的多,有:冒泡排序、选择排序、插入排序、归并排序、快速排序、堆排序、希尔排序、计数排序、桶排序、基数排序、内省排序,平滑排序等等。常见的排序算法是前6种,其中前3种排序是基础。桶排序的效率较高,但应用较少,多数编程语言的底层采用归并排序与快速排序。 ### 11.1.1 排序的基本概念 排序(Sorting)是一个十分常见的功能,在平时生活中也是随处可见的。比如生活中:图书馆的书按编号从小到大摆放,考试成绩单按分数从高到低排列,手机里的照片按时间先后显示——这些都是排序。 在计算机科学中,排序更具体地指:给定一个包含 n 个元素的序列,将其中的元素按照某个关键字(key)的非递减或非递增顺序重新排列,输出一个有序序列。例如把 [5, 2, 8, 1, 9] 变成 [1, 2, 5, 8, 9](升序)或 [9, 8, 5, 2, 1](降序),这个过程就是排序。在实际开发中,直接调用编程语言对应的API就可以实现排序,但这些API也是基于排序算法实现的(多数采用归并与快排),我们所要学习。 所以排序,简单来说,就是将一组数据按照某种特定的顺序(规则)重新排列的过程。我们刚才提到的算法(冒泡、选择、归并、快排等)就是实现这个过程的不同策略,它们在时间复杂度、空间复杂度和稳定性上各有取舍。 ### 11.1.2 人与计算机排序的差异 现在有个需求,需要对一组身高不等的10个人进行排序。我们要如何排序? 如果是由人来排序,事情会非常简单,因为我们只需要扫一眼就能看出来谁最高谁最低,然后让最低或者最高的站在前面,其他人依次后移,按照这样的方法,依次类推就可以了,在我高中排队跑操时,就有这种根据身高排列的需求。 人排序有个特点,可以统筹全局,直接获取到最高或者最低的结果。哪怕人挤人 密度太高,通常情况下也有足够的空间,可以稍微走开点,让人眼可以分清面前的内容,所以不需要考虑空间的问题。但人排序也有缺点,首先是容易出错,当数据量庞大时,就很难排序出来,其次在精度上也不足(相差一厘米不到,很难快速分辨)。 计算机排序与人类排序有着本质的不同。人在面对少量数据时,可以一眼扫过去,迅速判断出顺序;人与人之间也无需固定的空间,互相推推嚷嚷就能腾出位置、调换前后。但计算机做不到这些——它无法通览全部数据,在同一时间只能对两个元素进行比较,必须依赖严密的逻辑和特定的指令,一步一步地解决问题。在人类看来很简单的事情,对计算机而言却需要遵循一套明确的规则才能完成。 然而,计算机也有它独特的优势。它虽然"笨拙",却无比忠实——只要你写出了正确的指令,它就能不知疲倦地重复执行无数次而不出差错,也无需担心数据量的大小。想象一下,让人去排序一万甚至十万条数据,恐怕早已眼花缭乱,而这对计算机来说不过是多花一点时间罢了。 ### 11.1.3 排序算法的分类标准 所以排序算法就是研究如何对一个集合进行高效排序的算法,也是在面试时非常常见的面试题型之一。 维基百科对排序算法的解释是:在计算机科学与数学中,一个排序算法(英语:Sorting algorithm)是一种能将一串资料依照特定排序方式排列的算法。虽然排序算法从名称来看非常容易理解,但是从计算机科学发展以来,在此问题上已经有大量的研究。 由于排序非常重要而且可能非常耗时,所以它已经成为一个计算机科学中广泛研究的课题,而且人们已经研究出一套成熟的方案来实现排序。因此,幸运的是我们并不需要是发明某种排序算法,而是站在巨人的肩膀上即可。 在计算机科学所使用的众多排序算法通常依以下4点标准分类: (1)计算的时间复杂度:使用大O表示法,也可以实际测试消耗的时间; (2)内存使用量(甚至是其他电脑资源):比如外部排序,使用磁盘来存储排序的数据; (3)稳定性:稳定排序算法会让原本有相等键值的纪录维持相对次序; (4)排序的方法:插入、交换、选择、合并等等; 大O表示法在前文有学习过,即通过数量级来测试性能。 内存使用量,指的是排序过程中需要占用多少额外的存储空间。有些算法只需要在原数组上操作,几乎不需要额外内存,这类叫"原地排序"(如快速排序)。但当数据量大到内存放不下时,就必须借助磁盘等外部存储来辅助完成排序,这就是所谓的"外部排序"。所以内存使用量直接决定了一个算法能不能在资源有限的环境下运行。 稳定性,说的是排序后,两个值相同的元素是否还保持着原来的先后顺序。举个例子,有两个学生都考了90分,排序前小明排在小红前面,排序后如果小明仍然在小红前面,这个排序就是稳定的;如果顺序颠倒了,就是不稳定的。这在只看分数时似乎无所谓,但如果你需要先按分数排、再按姓名排这种多条件排序场景,稳定性就很重要了。PS:稳定性不是说偶尔会出错的概率。 排序的方法,指的是算法在排序时所采用的基本策略。比如"交换"是通过不断交换两个元素的位置来实现排序(冒泡排序就是典型);"插入"是把元素逐个插到已排好序的部分中去;"选择"是每次从剩余元素中选出最小(或最大)的放到正确位置;"合并"则是先把数据拆分成小块,分别排好序后再合并起来。 因为排序算法的种类非常多,而没有任何一种排序算法是在所有场景下都最优的。分类的目的,本质上是为了帮助我们在面对不同的实际需求时,能够快速选择最合适的算法。打个比方,就像我们要出行,可以选择步行、骑车、开车或坐飞机。如果只是去楼下便利店,步行就够了;但如果要跨城市出差,大概率会选高铁或飞机。选择的依据是什么?无非就是距离、时间、费用这几个维度。排序算法的分类逻辑也是一样的——我们从时间复杂度、内存占用、稳定性、排序方法这几个维度去衡量,就能清楚每种算法的优势和局限,从而在具体场景中做出合理的选择。 比如,数据量小且几乎有序时,插入排序简单高效;数据量大时,快速排序的平均性能优异;如果要求排序结果稳定(相等元素保持原有顺序),归并排序就是更好的选择;而当内存有限、数据存在磁盘上时,就需要考虑外部排序的方案。 所以分类不是为了分类本身,而是为了建立一套选型的框架,让我们面对具体问题时不必盲目尝试,而能有据可依地做出判断。这是一套经典的方法论框架,用优化好的逻辑来决策。 11.2 简单排序算法 ----------- 在维基百科列出的排序算法有二三十种,但多数排序算法在平时是难以遇到的,包括一些语言的底层也很少会使用到。一般情况下,在刚入门时,会先掌握冒泡排序、选择排序以及插入排序,这三种排序算法相对好理解且更简单一些。接着我们会学习归并排序和快速排序,很多语言的底层都有这两种排序的影子。堆排序则是可以在不使用额外空间的情况下对数据进行高效排序。 在历史上,希尔排序有重要地位,是第一个将排序的时间复杂度拉到到O(n²)以下。那时候的人们认为是无法突破的,直到希尔排序的出现,在该转折点之后,更快的排序算法如春笋般喷涌而出,但希尔排序如今使用较少。排序算法的时间复杂度如图11-1所示。  **图11-1 排序算法的时间复杂度** 因为接下来要学习多种排序算法,所以本章在讲解思路时,采用以下统一学习顺序: (1)介绍某种排序算法:如果该排序算法有一些历史背景或者故事,我们也会一起介绍。 (2)分析某种排序算法的思路步骤。 (3)某种排序算法的图解。 (4)排序算法的代码实现过程(一步步手写实现)。 (5)排序算法的复杂度分析。 (6)排序算法的小结。 我们要学习非常多种类的排序算法,那么我们可以先从一个最简单的排序算法入手:冒泡排序。 ### 11.2.1 冒泡排序 冒泡排序(Bubble Sort)是一种简单直观的排序算法。它的基本思路是:重复地遍历待排序序列,依次比较相邻的两个元素,如果它们的顺序不对就交换位置。每完成一趟遍历,当前未排序部分中的最大值就会像气泡一样"浮"到序列的末尾,随后将其从下一轮比较中排除。如此反复,直到整个序列有序为止——这也正是"冒泡排序"这一名称的由来。 冒泡排序的思路流程如下: (1)从第一个元素开始,逐一比较相邻元素的大小。 (2)如果前一个元素比后一个元素大,则交换位置。 (3)在第一轮比较结束后,最大的元素被移动到了最后一个位置。 (4)在下一轮比较中,不再考虑最后一个位置的元素,重复上述操作。 (5)每轮比较结束后,需要排序的元素数量减一,直到没有需要排序的元素。 (6)排序结束。 以上流程会一直循环,直到所有元素都有序排列为止。每一次循环都是可以知晓需要循环几次的,因此采用for循环而不是while循环。冒泡排序的流程图如图11-2所示。  **图11-2 冒泡排序流程图** 假设有一组数据(5个)需要冒泡排序,每一趟通过相邻比较与交换,将当前未排序部分中的最大值"推"到末尾,从而使有序区从右向左逐步扩大,直到整个序列有序。即每轮比较会筛出一个目标数组中的最大数据到最后,在下一轮筛选中会将上一轮的"最大数据"排除在外,重新筛选出目标数组的最大数据,直到筛选数组中只剩下一个数据为止(即最小的数据)。 这么做,意味着一共需要n-1轮筛选(只剩下最后一个数据无需筛选,因此减1),每一轮参与筛选的数据量逐轮递减(第 i 轮比较 n-i 对相邻元素),因为每轮结束后都会有一个已归位的元素被排除出下次筛选的范围。。PS:n是数组内的数据个数。 **图11-3 冒泡排序-交换图解** 在编写代码前,我们先想清楚两个设计思路: (1)排序算法需要返回排序后的数组吗? (2)排序算法是否要直接对传入的数组改变? 两个设计思路指向的是同一个问题,是否要对原有数据进行改变?这是都可以的,例如在JavaScript的Array系列API方法中,Array.prototype.splice()和Array.prototype.toSpliced()两个实例方法。这两个方法的作用其实是一样的,但确是不同的两种方法理念,在MDN文档里将其称为复制方法和修改方法,有些方法不会修改调用该方法的现有数组,而是返回一个新的数组。它们通过首先构造一个新数组,然后填充元素来实现。复制始终是浅层次的——该方法从不复制一开始创建的数组之外的任何内容。 非修改方法(即复制方法)往往在API命名中会以to作为开头,但这不是一定的,而是后续逐渐形成的规范。 复制方法的通用性会更强,且不改变原数组符合纯函数的思想(不引起副作用),因此我们采用该方式,那么在TypeScript中,就需要对函数的返回形式做出要求,初始化函数格式如下: ```typescript // 冒泡函数初始化 export default function bubbleSort(arr: number[]): number[] { return arr } ``` 测试方式如下: ```typescript // 冒泡函数的常见测试用例 const nums = [20,6,4,2,24] const newNums = bubbleSort(nums) console.log(newNums) ``` 接下来编写冒泡排序的代码逻辑,冒泡排序有两个重点: (1)交换数据:可以采用临时变量C作为中间站来实现变量A与B的交换,亦或者用解构的方式(代码采用该方式)。 (2)筛选规则:共需要两层循环,外层循环次数为n-1,内层循环为n-1-i(i为筛选出去的数据个数),数组下标从0开始,如果循环次数是数组长度,那么会越界,因此要么length-1,要么采用Array.prototype.at()。 筛选规则是冒泡排序的专属标志,但交换不是,因此交换数据可以抽象成一个函数方法。 ```typescript // 交换方法 export function swap(arr: number[], i: number, j: number) { // 交换方式一: 使用临时变量 // const temp = arr[i] // arr[i] = arr[j] // arr[j] = temp // 交换方式二: 使用解构赋值 [arr[i], arr[j]] = [arr[j], arr[i]] } ``` 在获取循环次数时,我们通常不用length表示,而是用变量n来存储。因为n是number的开头缩写,同时也可以代表大O表示法中的n,即数据的数量,会比length更易于理解。因此在业务中,会用length,而在算法中用n。 ```typescript export default function bubbleSort(arr: number[]): number[] { const n = arr.length // 外层for循环: 0~n-1 for (let i = 0; i < n; i++) { // 内层循环是找到最大值 for (let j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { swap(arr, j, j + 1) swapped = true } } } return arr } ``` 如果我们每次写完一个排序算法,就需要针对性的写一份测试用例,那这个过程是有点繁琐的。那么面对这种情况,既可以使用现成的第三方测试工具库,也可以直接让AI给出完整的测试用例,自己封装起来使用。 现在编写一个testSort()函数工具,用于帮助我们测试排序算法。传入排序算法函数,打印排序前的原数组和排序后的新数组。 ```typescript // 编写一个工具, 直接帮助测试排序算法 type SortAlgoFn = (arr: number[]) => number[] export function testSort(sortFn: SortAlgoFn) { // 1.随机一个长度为10的数组(数组中存放多个数字) const nums = Array.from({ length: 10 }, () => { return Math.floor(Math.random() * 200) }) // 2.使用排序对数组进行排序 console.log("排序前的原数组:", nums) const newNums = sortFn(nums) console.log("排序后的新数组:", newNums) } ``` 但排序算法依旧需要人去主动判别是否正确,可以令testSort()函数工具返回布尔值或者以打印的形式输出排序后的算法是否有正确的顺序。封装isSorted()方法,通过判断数组内的数据是否满足由小到大的排列顺序。 ```typescript export function isSorted(arr: number[]): boolean { for (let i = 0; i < arr.length - 1; i++) { if (arr[i] > arr[i + 1]) return false } return true } // 编写一个工具, 直接帮助测试排序算法 type SortAlgoFn = (arr: number[]) => number[] export function testSort(sortFn: SortAlgoFn) { // 1.随机一个长度为10的数组(数组中存放多个数字) const nums = Array.from({ length: 10 }, () => { return Math.floor(Math.random() * 200) }) // 2.使用排序对数组进行排序 console.log("排序前的原数组:", nums) const newNums = sortFn(nums) console.log("排序后的新数组:", newNums) console.log("是否排序后有正确的顺序?", isSorted(newNums)) } ``` 测试用例也可以使用第三方库"hy-algokit"中的compareSort()测试方法,有两个参数: (1)参数1:数组,可同时传入多个需要测试的排序算法。 (2)参数2:数据量,用于测试的具体数量的随机数据。 输出模板:使用xxx算法,排序xxx个元素,消耗时间为xxx毫秒。当完成所有排序算法的编写后,可以一次性运行起来,因此后续小节不一一展示运行效果。我们会在最后总结的时候,将排序算法一次性运行起来比对。 冒泡排序可以做一个优化,假如在一轮循环中,没有发生任何一次实际的交换,则说明后续的排序是已经归类好了,后续的循环没有实际意义(因为已经排序好了)。那么就可以直接跳出循环,结束排序。利用标记变量swapped来判断当前循环是否有发生交换,若有则继续循环,若无则跳出循环结束排序。 ```typescript export default function bubbleSort(arr: number[]): number[] { const n = arr.length // 外层for循环: 0~n-1 for (let i = 0; i < n; i++) { let swapped = false // 内层循环是找到最大值 for (let j = 0; j < n - 1 - i; j++) { if (arr[j] > arr[j + 1]) { swap(arr, j, j + 1) swapped = true } } if (!swapped) break } return arr } ``` 在冒泡排序中,每次比较两个相邻的元素,并交换他们的位置,如果左边的元素比右边的元素大,则交换它们的位置。这样的比较和交换的过程可以用一个循环实现。冒泡排序的时间复杂度有3种情况: (1)最好情况:O(n)。即待排序的序列已经是有序的,此时仅需遍历一遍序列,不需要进行交换操作。 (2)最坏情况:O(n²)。即待排序的序列是逆序的,需要进行n-1轮排序,每一轮中需要进行n-i-1次比较和交换操作。 (3)平均情况:O(n²)。即待排序的序列是随机排列的,每一对元素的比较和交换都有1/2的概率发生,因此需要进行n-1轮排序,每一轮中需要进行n-i-1次比较和交换操作。PS:和最坏情况在一个数量级内,但平均情况的实际交换次数比最坏情况的要少。 由此可见,冒泡排序的时间复杂度主要取决于数据的初始顺序,最坏情况下时间复杂度是O(n²),不适用于大规模数据的排序。所有产生平方甚至立方的时间复杂度,随着数据量的提升会指数型的拉爆负荷。假如数据量是2,那O(n²)是2倍负荷,可如果数据量是100万,那O(n²)是100万倍的负荷,100万的平方是一万亿,这是一个难以承受的数字。 所以冒泡排序适用于数据规模较小的情况,因为它的时间复杂度为O(n²),对于大数据量的排序会变得很慢。同时,它的实现简单,代码实现也容易理解,适用于学习排序算法的初学者。但是,在实际的应用中,冒泡排序并不常用,因为它的效率较低。因此,在实际应用中,冒泡排序通常被更高效的排序算法代替,如快速排序、归并排序等。 ### 11.2.2 选择排序 选择排序(Selection Sort)是一种简单且易于理解的排序算法。它的基本思想是:每一轮从当前未排序的部分中找出最小(或最大)的元素,将其与未排序部分的起始位置进行交换,从而使有序区从左向右逐步扩大,直到所有元素均排序完毕。选择排序的一个显著优点与数据移动有关——如果某个元素已经位于正确的最终位置,它不会被移动;而且每次交换只涉及一对元素,其中至少有一个会被直接移到最终位置上,因此对 n 个元素进行排序总共至多只需 n-1 次交换,时间复杂度属于O(n)。在所有完全依靠交换来移动元素的排序方法中,选择排序的交换次数是非常少的。凭借其实现简单、逻辑清晰的特点,选择排序也是学习排序算法时一个很好的入门选择。 选择排序符合人的直觉做法,例如在菜市场的胡萝卜堆里挑胡萝卜,人会先扫一眼,挑出认为最好的一根,再扫一遍再挑一根。每一遍选择都会挑一个自己认为胡罗卜堆里面最好的放到手里的袋子中,这种挑选的做法就算是一种选择排序。 选择排序的思路可以这样理解:将数组看作两个部分——左边是已排序区,右边是未排序区。初始时,已排序区为空,整个数组都属于未排序区。 每一轮操作做两件事: (1)首先,从未排序区中找出最小值。具体做法是,先把未排序区的第一个元素暂时当作最小值,然后从第二个元素开始逐一与之比较,遇到更小的就更新标记,一轮扫描结束后就能确定真正的最小值。 (2)接着,将这个最小值与未排序区的第一个元素交换位置,交换完成后,该元素就归入了已排序区,已排序区向右扩展一位,未排序区相应缩短一位。 不断重复以上过程,每一轮都会从剩余的未排序区中"选"出一个最小值归位,直到未排序区只剩一个元素时,排序自然完成。选择排序的交换图解如图11-4所示。  **图11-4 选择排序-交换图解** 选择排序的函数方法初始化如下: ```typescript import { testSort } from "hy-algokit" export default function selectionSort(arr: number[]): number[] { return arr } // 测试用例 testSort(selectionSort) ``` 快速排序最多只需要n-1次交换,每次交换前需要获取未排序部分的最大(小)值,这里以最小值为例。有两个步骤需要遍历: (1)循环获取未排序部分的最小值然后交换。选择最小值的前提是完整比对未排序部分才能确定。 (2)一共n-1次交换,因此循环n-1次。 然后与冒泡排序同思路的优化,只有数值不一致才交换。 ```typescript export default function selectionSort(arr: number[]): number[] { const n = arr.length // 外层循环作用: 经过多少轮的找最小值 for (let i = 0; i < n - 1; i++) { let minIndex = i // 内层循环作用: 每次找到最小值 for (let j = 1 + i; j < n; j++) { if (arr[j] < arr[minIndex]) { minIndex = j } } // 只有不相等时, 才需要进行交换操作 if (i !== minIndex) { swap(arr, i, minIndex) } } return arr } ``` 选择排序的时间复杂度是比较容易分析的。时间复杂度有3种情况: (1)最好情况时间复杂度:O(n²)。即待排序的数组本身就是有序的,在这种情况下,比较次数为 n(n-1)/2,交换次数为 0。 (2)最坏情况时间复杂度:O(n²)。即待排序的数组是倒序排列的,在这种情况下,比较次数为 n(n-1)/2,交换次数为 n-1。 (3)平均情况时间复杂度:O(n²)。即待排序的数组是随机排列的,在这种情况下,比较次数仍然是固定的 n(n-1)/2,交换次数小于 n-1(因为部分轮次中最小值已在正确位置,无需交换)。 选择排序虽然比较次数始终是 O(n²),但交换次数最多只有 O(n),而交换操作(涉及数据写入)的开销通常远大于比较操作(仅读取),因此在交换成本较高的场景下,选择排序的实际表现会优于同为 O(n²) 的冒泡排序。 ### 11.2.3 插入排序 插入排序像我们打扑克时,摸到一张新牌,需要插入到手牌中的合适位置一样。我们会将新牌和手牌中已有的牌进行比较,找到一个合适的位置插入新牌。如果新牌比某张牌小,那么我们就把这张牌向右移动一位,为新牌腾出位置。一直比较直到找到一个合适的位置将新牌插入,这样就完成了一次插入操作。使用插入排序来排序手中的扑克牌如图11-5所示。  **图11-5 使用插入排序来排序手中的扑克牌** 与打牌类似,插入排序(Insertion sort)的实现方法是:首先假设第一个数据是已经排好序的,接着取出下一个数据,在已经排好序的数据中从后往前扫描,找到比它小的数的位置,将该位置之后的数整体后移一个单位,然后再将该数插入到该位置。不断重复上述操作,直到所有的数据都插入到已经排好序的数据中,排序完成。 插入排序的思路流程如下: (1)首先,假设数组的第一个元素已经排好序了,因为它只有一个元素,所以可以认为是有序的。 (2)然后,从第二个元素开始,不断与前面的有序数组元素进行比较。 (3)如果当前元素小于前面的有序数组元素,则把当前元素插入到前面的合适位置。 (4)否则,继续与前面的有序数组元素进行比较。 (5)以此类推,直到整个数组都有序。 (6)循环步骤2~5,直到最后一个元素,完成排序。 插入排序图解如图11-6所示。  **图11-6 插入排序图解** 因此插入排序采用了与选择排序类似的思路,将面前的数据区分为已排序区和未排序区,但思路从交换转移到插入,核心操作方向恰好相反,区别如下: (1)选择排序关注的是未排序区。 每一轮从未排序区中"选"出最小值,放到已排序区的末尾。它解决的问题是"下一个该放谁"——先确定元素,位置是固定的。 (2)插入排序关注的是已排序区。 每一轮取出未排序区的第一个元素,在已排序区中找到它应该待的位置,然后"插"进去。它解决的问题是"这个元素该放哪"——元素是确定的,位置需要寻找。 这个区别也带来了一个重要的性能差异:选择排序每一轮都必须完整扫描未排序区才能确定最小值,所以比较次数是固定的 n(n-1)/2,无论数据是否有序;而插入排序在已排序区中从后往前扫描时,一旦找到合适的位置就可以停下来,所以如果数据本身接近有序,比较次数会大幅减少,最好情况下只需 n-1 次比较,时间复杂度降为 O(n)。 插入排序的代码初始化如下: ```typescript import { testSort } from "hy-algokit"; export default function insertionSort(arr: number[]): number[] { return arr } testSort(insertionSort) ``` 插入排序的循环有两层: (1)外层:固定的n-1次,即除了第一张默认有序牌,其余未排序区插入到有序区的次数。 (2)内层:循环次数是不确定的,所谓"不确定"指的是内层循环的比较次数。插入排序在每一轮中,会把当前元素从后往前与已排序区的元素逐个比较,但一旦找到合适的位置就会停下来,不需要把已排序区全部扫完。所以每一轮内层循环执行多少次,完全取决于数据的排列情况。 内层情况举个例子,假设已排序区是 `[2, 5, 8]`,现在要插入 1 和 7: 插入 7 时,从后往前比较:7 < 8?是,继续;7 < 5?不是,停下,插入到 5 后面。只比较了 2 次。 插入 1 时,从后往前比较:1 < 8?是;1 < 5?是;1 < 2?是,已经到头了,插入到最前面。比较了 3 次。 所以准确的说法应该是:外层循环固定 n-1 轮,但每轮内层循环的比较次数不确定,取决于当前元素在已排序区中的插入位置。正因如此,插入排序的总比较次数才会随数据分布变化——最好情况 O(n),最坏情况 O(n²)。因此内层循环在不确定次数的情况下使用while循环。 ```typescript import { testSort } from "hy-algokit"; export default function insertionSort(arr: number[]): number[] { const n = arr.length for (let i = 1; i < n; i++) { // 外层循环从未排序区开始 // 内层循环 const newNum = arr[i] let j = i - 1 while (arr[j] > newNum && j >= 0) { arr[j + 1] = arr[j] j-- } arr[j + 1] = newNum } return arr } testSort(insertionSort) ``` 插入排序的插入并不是真的像打牌那样"挤进去",而是通过一个更巧妙的方式实现的:先把待插入元素取出暂存,然后从右往左逐个把比它大的元素右移一位(相当于逐步腾出一个空位),当遇到不比它大的元素时停下,最后把暂存的值放进那个腾出来的空位。 那么插入排序的时间复杂度的3种情况如下: (1)最好情况: O(n)。如果待排序数组已经排好序,那么每个元素只需要比较一次就可以确定它的位置,因此比较的次数为 n-1,移动的次数为 0。所以最好情况下,插入排序的时间复杂度为线性级别,即 O(n)。 (2)最坏情况: O(n²)。如果待排序数组是倒序排列的,那么每个元素都需要比较和移动 i 次,其中 i 是元素在数组中的位置。因此比较的次数为 n(n-1)/2,移动的次数也为 n(n-1)/2。所以最坏情况下,插入排序的时间复杂度为平方级别,即O(n²)。 (3)平均情况:O(n²)。对于一个随机排列的数组,插入排序的时间复杂度也为平方级别,即O(n²)。 总而言之,如果数组部分有序,插入排序可以比冒泡排序和选择排序更快。但是如果数组完全逆序,则插入排序的时间复杂度比较高,不如快速排序或归并排序。毕竟插入排序的最坏情况下,有较多的元素右移行为,元素的移动比查询更消耗性能。 插入排序是一种简单直观的排序算法,它的基本思想就是将待排序数组分为已排序部分和未排序部分,然后将未排序部分的每个元素插入到已排序部分的合适位置。插入排序的时间复杂度为 O(n²),虽然这个复杂度比较高,但是插入排序的实现非常简单,而且在某些情况下性能表现也很好,比如,如果待排序数组的大部分元素已经排好序,那么插入排序的性能就会比较优秀。总之,插入排序虽然没有快速排序和归并排序等高级排序算法的复杂性和高效性,但是它的实现非常简单,而且在一些特定的场景下表现也很好。 普通插入排序在已排序区中从右往左逐个比较来寻找插入位置,这是可以优化的,我们可以用二分查找来定位,将每一轮的查找次数从 O(n) 降低到 O(log n)。但需要注意一个关键问题:查找变快了,移动并没有变快。找到插入位置之后,仍然需要把该位置之后的所有元素逐个右移一位来腾出空间,这一步依然是 O(n)。所以二分插入排序的总体时间复杂度仍然是 O(n²),只是减少了比较次数,移动次数不变。所以二分查找优化在数据量较大时确实能带来一定的性能提升,但瓶颈在移动操作上,无法突破 O(n²) 的上限。如果想要真正突破这个瓶颈,就需要换用归并排序或快速排序这类 O(n log n) 的算法了。 11.3 高效排序算法 ----------- 在学习11.2.3小节的插入排序的最后,我们对插入排序的性能进行一定程度的思考,意识到插入排序在移动操作上无法突破O(n²)的上限,而这是制约插入排序进入高效排序算法的原因。接下来我们会学习归并排序或者快速排序是如何解决这一问题的。 ### 11.3.1 归并排序 归并排序(Merge Sort)是一种基于"分治法"思想的经典排序算法,最早由现代计算机之父约翰·冯·诺伊曼(John von Neumann)于1945年提出。其基本思路是:先将待排序数组递归地拆分成越来越小的子数组,直到每个子数组只剩一个元素(自然有序),然后再将相邻的子数组两两合并(merge),在合并过程中完成排序,最终得到一个整体有序的数组。简单来说,就是"先拆到不能再拆,再合并的过程中排好序"。归并排序的时间复杂度稳定在 O(n log n),无论最好、最坏还是平均情况都不会退化,因此在实际应用中被广泛使用,许多编程语言的底层排序实现都采用了归并排序或其变体。 归并排序是一个拆分重组的过程,我们以数组[30,10,8,20]为例,首先是拆分过程: (1)[30,10],[8,20]。 (2)[30],[10],[8],[20]。 在拆分到每个子数组只剩一个元素后,完成拆分,进入重组阶段,每一次重组都会比较两个子数组的顺序进行排序: (1)[10,30],[8,20]。 (2)[8,10,20,30]。 但合并的时候,8如果接到10的前面,那20不是需要另外插入到10和30的中间吗?合并操作是怎么做的?这是个很好的问题。合并操作并不是把一个数组的元素"插入"到另一个数组中去,而是创建一个新的空数组,然后用双指针从两个子数组中逐个"挑"元素放进去。 以合并 `[10, 30]` 和 `[8, 20]` 为例,具体过程如下: 准备一个空的结果数组 `[]`,指针 i 指向左数组的第一个元素,指针 j 指向右数组的第一个元素。 第一步:比较 i 指向的 10 和 j 指向的 8,8 更小,取 8 放入结果,j 后移。 结果:`[8]`,左 `[10, 30]` i→10,右 `[_, 20]` j→20 第二步:比较 i 指向的 10 和 j 指向的 20,10 更小,取 10 放入结果,i 后移。 结果:`[8, 10]`,左 `[_, 30]` i→30,右 `[_, 20]` j→20 第三步:比较 i 指向的 30 和 j 指向的 20,20 更小,取 20 放入结果,j 后移。 结果:`[8, 10, 20]`,左 `[_, 30]` i→30,右边已经取完 第四步:右数组已经没有元素了,直接把左数组剩余的 30 放入结果。 结果:`[8, 10, 20, 30]` 所以关键在于:合并不是在原数组上插入或移动,而是两个有序数组像拉链一样交替取元素,按顺序填入一个全新的数组。正因为两个子数组各自已经有序,所以每次只需要比较两个指针头部的元素就够了,整个过程只需扫描一遍,非常高效。这也是为什么归并排序需要额外的 O(n) 空间——就是用来存放这个新数组的。 归并排序的实现是建立在一个已知的条件下:合并两个已经有序的数组是非常容易且高效的。具体来说,合并两个有序数组时,只需要两个指针分别指向两个数组的头部,每次比较两个指针所指的元素,取较小的那个放入结果数组,然后该指针后移。整个过程只需要扫描一遍,时间复杂度是 O(n)。 但问题是,我们一开始拿到的是一个无序数组,怎么才能得到"有序的子数组"呢?归并排序的巧妙之处就在于:把数组一直拆,拆到每个子数组只有一个元素——一个元素天然就是有序的。这样就获得了最初的"有序子数组",然后就可以利用上面那个高效的合并操作,从底向上逐层合并回去。 所以归并排序的精髓可以概括为:把"排序一个无序数组"这个困难问题,转化为"反复合并有序数组"这个简单问题。 拆分本身不做任何排序工作,真正的排序全部发生在合并阶段。而分治递归保证了每次合并时,两个子数组一定已经是有序的,从而让合并操作始终保持高效。 因此,归并排序是一种基于分治思想的排序算法,其基本思路可以分为三个步骤: (1)步骤一:分解(Divide):归并排序使用递归算法来实现分解过程,具体实现中可以分为以下几个步骤: * 如果待排序数组长度为1,认为这个数组已经有序,直接返回。 * 将待排序数组分成两个长度相等的子数组,分别对这两个子数组进行递归排序。 * 将两个排好序的子数组合并成一个有序数组,返回这个有序数组。 (2)步骤二:合并(Merge):合并过程中,需要比较每个子数组的元素并将它们有序地合并成一个新的数组: * 可以使用两个指针 i 和 j 分别指向两个子数组的开头,比较它们的元素大小,并将小的元素插入到新的有序数组中。 * 如果其中一个子数组已经遍历完,就将另一个子数组的剩余部分直接插入到新的有序数组中。 * 最后返回这个有序数组。 (3)步骤三:归并排序的递归终止条件: * 归并排序使用递归算法来实现分解过程,当子数组的长度为1时,认为这个子数组已经有序,递归结束。 总体来看,归并排序的基本思路是分治法,分成子问题分别解决,然后将子问题的解合并成整体的解。归并排序图解如图11-7所示。归并排序的过程分为"拆分"和"合并"两个阶段。每次拆分都把数组一分为二,所以一个长度为 n 的数组需要拆 log₂n 层才能拆到单个元素。比如 8 个元素:第一层拆成 2 组各 4 个,第二层拆成 4 组各 2 个,第三层拆成 8 组各 1 个,一共 log₂8 = 3 层。 而合并阶段也是 log₂n 层,每一层合并时,所有元素都会被比较和移动恰好一次,所以每一层的工作量是 O(n)。 总的时间复杂度 = 层数 × 每层工作量 = O(log n) × O(n) = O(n log n)。 对于归并排序来说,是不需要扫描的,只需要不断的拆分然后合并就行了。  **图11-7 归并排序图解** 当然了,朋友们,这其实很有意思。这让我想起来在小学阶段算数的日子,那时候多个二位数三位数相加相减,我会把所有加的划分到一堆,相减的划分到一堆,然后按大小排列起来,两两相加,再两两相加,最后把算出来相加的减去相减的。 例如:268+84-352+154-691+894+548=? 84+154+268+548+894-(352+691)=238+816+894-1043=238+1710-1043=200+1700+48-43-1000=905 除了通过合并,我还通过从个位数开始相加减的拆分,对应了归并排序"拆到单个元素"的思想,因此这道题的难度对我而言,降低到个位数的运算,属于可以轻松心算出来的程度,当熟练之后的速度也很快。 个位数开始相加减的拆分例如:84+154=80+150+4+4=50+30+150+8=238。这和归并排序是同种思想,都源于数学中的一些技巧。 归并排序的方法初始化如下: ```typescript import { testSort } from "hy-algokit" export default function mergeSort(arr: number[]): number[] { // 1.分解(divide): 对数组进行分解(分解成两个小数组) // 2.合并(merge): 将两个子数组进行合并(双指针) return arr } testSort(mergeSort) ``` 对数组分解,需要获取数组数据的中间位置,将其一分为二。如果直接获取数组长度除2,一旦数组是奇数,除2就会出现小数位,因此需要取整,取整后的数字归左半部分还是右半部分随意,都可以正常工作(推荐向下取整,更便于配合数组索引。 数组索引从0开始,向下取整恰好让左半部分的长度小于或等于右半部分)。类似左侧五个右侧六个,并不会影响合并。 以长度为 5 的数组为例(取整后的数字归右半部分): 向下取整:mid = Math.floor(5 / 2) = 2,左半部分是索引 0~1(2 个元素),右半部分是索引 2~4(3 个元素)。 向上取整:mid = Math.ceil(5 / 2) = 3,左半部分是索引 0~2(3 个元素),右半部分是索引 3~4(2 个元素)。 切割之后,拿着左右两半部分继续去切割寻找中间位置,直到数组长度为1。 ```typescript import { testSort } from "hy-algokit" export default function mergeSort(arr: number[]): number[] { // 递归切割的结束条件:当数组长度为1甚至小于1。 if (arr.length <= 1) return arr // 1.分解(divide): 对数组进行分解(分解成两个小数组) // 8 / 2 = 4 // 1.1. 切割数组 const mid = Math.floor(arr.length / 2) const leftArr = arr.slice(0, mid) const rightArr = arr.slice(mid) // 1.2.递归的切割leftArr和rightArr const newLeftArr = mergeSort(leftArr) const newRightArr = mergeSort(rightArr) // 2.合并(merge): 将两个子数组进行合并(双指针) return arr } testSort(mergeSort) ``` 在1.2的newLeftArr和newRightArr递归调用结束之前,代码不会继续往下走。当拆分到数组内只剩下一个元素,中断递归,返回的arr会从递归切割的位置继续往下走,即合并部分流程,当合并部分结束时,我们需要将两个子数组合并的结果返回,返回的结果会继续从上一个1.2递归的切割开始继续合并,直到递归收束,一层层的释放递归所占用的运行空间,最终合并为一个完整的有序数组完整返回,递归结束。 从堆栈结构来看,分解阶段在不断压栈,直到数组长度为1开始逐层弹栈,每弹一层就执行一次合并,合并的结果作为返回值交给上一层继续合并,最终栈完全释放,得到整个有序数组。 合并阶段需要定义i与j两个指针和一个新的空数组newArr。两个指针指向相邻的两个子数组的初始下标0,开始两个指针指向的数据比较,指针指向数据小的push到newArr数组中,然后发生push行为的子数组的指针移向下一位,继续两个子数组内的数据比较,直到两个子数组的内部数据都push到newArr数组为止,合并结束,合并的结果作为返回值交给上一层继续合并。 指针指向多次移动,在代码层面可以直接视为遍历,而不固定遍历次数则使用while循环。循环结束的限制条件为:两个指针都指向子数组的末端。且由于当两个指针都指向子数组的末端,则最后一个数据元素还未push到newArr数组中,需要检查左右两部分的数组是否还有剩余,将最后的剩余push到newArr数组中。 ```typescript import { testSort } from "hy-algokit" export default function mergeSort(arr: number[]): number[] { if (arr.length <= 1) return arr // 1.分解(divide): 对数组进行分解(分解成两个小数组) // 8 / 2 = 4 // 1.1. 切割数组 const mid = Math.floor(arr.length / 2) const leftArr = arr.slice(0, mid) const rightArr = arr.slice(mid) // 1.2.递归的切割leftArr和rightArr const newLeftArr = mergeSort(leftArr) const newRightArr = mergeSort(rightArr) // 2.合并(merge): 将两个子数组进行合并(双指针) // 2.1.定义双指针 const newArr: number[] = [] let i = 0 let j = 0 while (i < newLeftArr.length && j < newRightArr.length) { if (newLeftArr[i] <= newRightArr[j]) { newArr.push(newLeftArr[i]) i++ } else { newArr.push(newRightArr[j]) j++ } } // 2.2.判断是否某一个数组中还有剩余的元素 // 循环完左边还有剩余 if (i < newLeftArr.length) { newArr.push(...newLeftArr.slice(i)) } // 循环完右边还有剩余 if (j < newRightArr.length) { newArr.push(...newRightArr.slice(j)) } // 3.将合并后的数组返回 return newArr } testSort(mergeSort) ``` 假设数组长度为 n,需要进行 logn 次归并操作,每次归并操作需要 O(n) 的时间复杂度,因此,归并排序的时间复杂度为 O(nlogn)。归并排序的时间复杂度的3种情况如下: (1)最好情况: O(log n)。待排序数组已经是有序的了,那么每个子数组都只需要合并一次,即只需要进行一次归并操作。 (2)最坏情况: O(nlogn)。待排序数组是逆序的,那么每个子数组都需要进行多次合并。 (3)平均情况: O(nlogn)。假设待排序数组中任意两个元素都是等概率出现的。 无论情况如何,归并排序的分解与合并操作都是不可避免的,但性能依旧非常高效。它的核心思想是分治,即将待排序数组分成若干个子数组,分别对这些子数组进行排序,最后将排好序的子数组合并成一个有序数组。归并排序的时间复杂度为 O(nlogn),并且在最好、最坏和平均情况下都可以达到这个时间复杂度。 在编程中,可预测的结果是非常重要的,在保持高效的性能之下,有着极强的稳定性,在任何情况都保持同样的性能效率。因此大多数编程语言的底层都会广泛应用,不会因使用场景的泛用而出现一些极端问题。 ### 11.3.2 快速排序 快速排序(Quicksort),也被称为"划分交换排序"(partition-exchange sort),由计算机科学家 Tony Hoare(东尼·霍尔)于1960年代初期发明,最初出现在一份 ALGOL 60 的手稿中,目的是为了提升稿件的可读性。后来,Tony Hoare 将这一算法正式命名为 Quicksort。由于其思想精巧,快速排序在计算机科学中得到了广泛应用,但"快速"并不意味着它在所有场景下都是最快的排序算法——其实际性能取决于输入数据的分布、数组长度等多种因素。 快速排序在大部分情况下,都是最好最快的,而这距离归并排序的出现已经有15年之久,每一次的超越都是非常不容易的。 快速排序(Quick Sort)是一种基于分治思想的排序算法,其基本思路是通过选择一个基准元素(pivot),将数组划分为左右两部分——左部分的元素都小于或等于基准元素,右部分的元素都大于基准元素——然后对左右两部分递归地进行快速排序,最终使整个数组有序。作为一种原地排序算法,快速排序不需要额外的数组空间,平均时间复杂度为 O(nlogn),虽然最坏情况下会退化为 O(n²),但这种情况出现的概率极低,因此它通常被认为是一种非常高效的排序算法。快速排序的逻辑看似复杂,但只要理解了分治的基本思路,实现起来并不困难。 快速排序与归并排序都是对目标数组递归式的分解成两部分,直到最小单位为止(数组长度为1)。但快速排序在将目标数组分解到最小单位时,就已经有序,这点与归并排序不同。并且快速排序将目标数组分解成两部分并不是等分。 假设我们选择数组最后一个元素作为基准元素(pivot),对数组 [10, 28, 18, 9, 23, 11, 33, 26] 进行快速排序。 首先,选取 26 作为 pivot,然后从左到右遍历数组,将小于或等于 26 的元素放到左边,大于 26 的元素放到右边。遍历结束后,再将 pivot 放到左右两部分的中间位置,数组变为 [10, 18, 9, 23, 11, 26, 33, 28]。此时 26 已经处于最终的正确位置,它左边的元素都比它小,右边的元素都比它大。 接下来,对左半部分 [10, 18, 9, 23, 11] 和右半部分 [33, 28] 分别递归执行同样的操作。以左半部分为例,选取 11 作为 pivot,经过同样的划分过程后,数组变为 [10, 9, 11, 18, 23],11 归位。然后继续对 11 左边的 [10, 9] 和右边的 [18, 23] 递归处理——[10, 9] 选取 9 为 pivot,划分后变为 [9, 10];[18, 23] 选取 23 为 pivot,划分后保持 [18, 23]。右半部分 [33, 28] 的处理也是同理,选取 28 为 pivot,划分后变为 [28, 33]。 每一轮划分都会让一个 pivot 元素落到它最终的正确位置上,同时递归不断缩小待排序的子数组规模。当子数组长度缩减为 1 时,递归终止并开始逐层回溯,所有子数组的排序结果自然合在一起,最终得到完整的有序数组 [9, 10, 11, 18, 23, 26, 28, 33]。 在快速排序中,有3个关键问题需要解决: (1)pivot 的选择。为什么选最后一个元素(例如以上案例选择的26)?选第一个、中间的、或者随机选一个行不行?不同的选择方式对性能有什么影响?什么情况下会导致最坏时间复杂度 O(n²)? (2)划分过程的细节。遍历数组时,元素具体是怎么交换的?指针是怎么移动的?pivot 最后是怎么放到正确位置的? (3)"原地排序"的理解。既然快速排序是原地排序,不需要额外数组,那元素是怎么在同一个数组里完成左右分区的?和归并排序需要新数组有什么本质区别? **关于 pivot 的选择** 选择最后一个元素作为 pivot 只是一种最简单、最直观的实现方式,便于编码和理解,但并不是唯一的选择。实际上,选第一个元素、中间元素、甚至随机选一个元素都是完全可行的。不同的选择方式不会改变快速排序的基本逻辑,但会影响划分的均匀程度,进而影响性能。理想情况下,pivot 恰好是数组的中位数,这样每次划分都能将数组均匀地分成两半,递归深度为 logn,时间复杂度为 O(nlogn)。但如果每次选到的 pivot 都是当前子数组的最大值或最小值——比如对一个已经有序的数组选取第一个或最后一个元素作为 pivot——那么每次划分都会极度不均匀,一边有 n-1 个元素,另一边为空,递归深度退化为 n,时间复杂度就会恶化为 O(n²)。正因如此,实践中常采用"三数取中"(取首、中、尾三个元素的中间值)或随机选择 pivot 的策略,来尽量避免最坏情况的出现。 **关于划分过程的细节** 以 Lomuto 分区方案为例,我们用指针 i 来标记"小于等于 pivot 的区域"的右边界,初始值为数组起始位置的前一位。然后用指针 j 从左到右遍历数组(不包含 pivot 本身),每当 j 指向的元素小于或等于 pivot 时,就将 i 右移一位,然后交换 i 和 j 位置上的元素。这个操作的本质是:把每一个发现的"小元素"依次甩到数组左侧,而大于 pivot 的元素则自然地被留在右侧。当 j 遍历完整个数组后,i 的右边一位就是 pivot 应该存放的最终位置,此时将 pivot 与该位置上的元素交换,pivot 便归位了——它左边的元素全都小于或等于它,右边的元素全都大于它。等到划分到最小单位时,每个数据的左侧全都小于或等于它,右边的元素全都大于它,那这就整体符合有序排列。 **关于"原地排序"的理解** 从划分过程可以看出,快速排序自始至终都是在原数组上通过元素交换来完成分区的,不需要创建新的数组来存放左右两部分的数据。每一层递归只是通过传递左右边界的索引来缩小操作范围,始终操作的是同一块内存空间。这就是"原地排序"的含义。而归并排序的思路则不同:它在合并两个有序子数组时,需要一个额外的临时数组来存放合并结果,因为两个子数组的元素要按顺序交替取出,无法在原数组上直接完成而不覆盖未处理的数据。这就是两者在空间使用上的本质区别——快速排序靠交换就地完成划分,归并排序靠额外空间才能完成合并。 快速排序思路可以分解成以下8个步骤(Hoare 分区方案): (1)选择一个基准元素,通常选择第一个或最后一个元素作为基准元素。 (2)定义两个指针 i 和 j,分别指向数组的左右两端。 (3)从右侧开始,向左移动 j 指针,直到找到一个小于或等于基准元素的值。 (4)从左侧开始,向右移动 i 指针,直到找到一个大于或等于基准元素的值。 (5)如果 i 指针小于或等于 j 指针,交换 i 和 j 指针所指向的元素。 (6)重复步骤 3-5,直到 i 指针大于 j 指针,这时,我们将基准元素与 j 指针所指向的元素交换位置,将基准元素放到中间位置。 (7)将数组分为两部分,左侧部分包含小于或等于基准元素的元素,右侧部分包含大于基准元素的元素。 (8)对左右两部分分别进行递归调用快速排序,直到左右两部分只剩下一个元素。 我们准备在代码编写的思路是Hoare 分区方案,不是 Lomuto。两者的核心区别在于指针的运动方式。Lomuto 方案只有一个指针 j 从左往右单向遍历,另一个指针 i 只是被动地标记左侧小元素区域的边界——也就是我们之前一直在讨论的那种方式。而这里描述的是两个指针从数组两端相向而行:j 从右往左找小元素,i 从左往右找大元素,找到之后交换,直到两个指针相遇。这正是 Hoare 提出的原始分区方案。 两种方案最终的效果是一样的——都能将数组以 pivot 为界分成左小右大两部分——但实现细节不同。Hoare 方案在实际运行中交换次数通常更少,因为两个指针从两端同时逼近,能更快地找到"站错位置"的元素对并直接交换。 快速排序图解如图11-8所示。i指针从20开始往右,j指针从6开始往左。  **图11-8 快速排序图解** 快速排序的代码初始化如下(在原有基础上排序,无需创建新数组): ```typescript // 快速排序函数初始化 export default function quickSort(arr: number[]): number[] { return arr } ``` 对于快速排序来说,最主要的部分在于分割。 由于快速排序一开始需要分割,左半部分需要递归分割,右半部分需要递归分割。已知的部分就有三处分割,而分割操作实际还包含了交换排序,有一定的代码量,因此分割操作最好创建partition()函数,将分割操作封装起来使用。 那么partition()函数要写在quickSort()函数内部还是外部呢? 写在外部,partition 作为一个独立的工具函数,职责清晰,quickSort 负责递归框架,partition 负责分区逻辑,各司其职。这也是大多数教材和实际项目中更常见的写法,因为它更符合"单一职责"的原则,而且如果将来需要切换分区策略(比如从 Hoare 换成 Lomuto),只需要替换 partition 函数即可,不用动 quickSort 的代码。 写在内部,好处是 partition 可以直接访问 quickSort 的参数和局部变量,形成一个自包含的整体,外部不会看到也不需要知道 partition 的存在。对于我们目前的需求来说,无需进一步考虑快速排序的其余实现方式,不需要考虑更高的复用性。因此将partition写在quickSort内部会更方便。 ```typescript import { swap, testSort } from "hy-algokit" export default function quickSort(arr: number[]): number[] { partition(0, arr.length - 1) function partition(left: number, right: number) { if (left >= right) return // 1.找到基准元素(pivot轴心) const pivot = arr[right] // 2.双指针进行交换操作(左边都是比pivot小的数字, 右边都是比pivot大的数字) let i = left let j = right - 1 while (i <= j) { // 找到一个比pivot大的元素 while (arr[i] < pivot) { i++ } // 找到一个比pivot小的元素 while (arr[j] > pivot) { j-- } // 说明我们已经找到了(比pivot大的元素i)和(比pivot小的j的元素) if (i <= j) { swap(arr, i, j) i++ j-- } } // 将pivot放在正确的位置 swap(arr, i, right) // 左右继续划分区域(partition) partition(left, j) // 左边区域划分 partition(i + 1, right) } return arr } testSort(quickSort) ``` partition()函数方法没办法自调用,因此在一开始时,左右指针分别从整个数组的首尾开始。然后以下4步: (1)找到基准元素。 (2)使用Hoare分区方案的双指针交换。 (3)左右递归继续划分区域,并设置好终止条件(子数组长度为1或为空,在快速排序中则是左指针大于等于右指针,意思是一致的)。 (4)递归终止后逐层返回,释放调用栈空间,无需合并,因为数组在分区过程中已经被排好序了。 快速排序的时间复杂度主要取决于基准元素的选择、数组的划分、递归深度等因素。3种情况的时间复杂度如下: (1)最好情况: O(nlogn)。当每次划分后,两部分的大小都相等,即基准元素恰好位于数组的中间位置,此时递归的深度为 O(log n)。每一层需要进行 n 次比较,因此最好情况下的时间复杂度为 O(nlogn)。 (2)最坏情况: O(n²)。当每次划分后,其中一部分为空,即基准元素是数组中的最大或最小值,此时递归的深度为 O(n)。每一层需要进行 n 次比较,因此最坏情况下的时间复杂度为 O(n²)。需要注意的是,采用三数取中法或随机选择基准元素可以有效避免最坏情况的发生。因此最坏情况的发生概率是很低的。 (3)平均情况: O(nlogn)。在平均情况下,每次划分后,两部分的大小大致相等,此时递归的深度为 O(log n)。每一层需要进行大约 n 次比较,因此平均情况下的时间复杂度为 O(nlogn)。 需要注意的是,快速排序是一个原地排序算法,不需要额外的数组空间。快速排序在多数情况下,比归并排序更快,快两倍以上。因为归并排序实际上要先划分再合并,而快速排序只有划分不需要合并,快速排序是直接在本身数组上操作,当递归结束时,快速排序只需要释放因递归操作所占据的堆栈空间。 ### 11.3.3 堆排序 堆排序(Heap Sort)是一种基于比较的排序算法,其核心思想是利用二叉堆这种数据结构来维护有序序列。二叉堆是一种完全二叉树,每个节点都满足父节点比子节点大(或小)的条件;在堆排序中,我们使用最大堆,即保证每个节点都比它的子节点大。 排序过程是这样的:首先将待排序数组构建成一个最大堆,然后将堆顶元素(即最大值)与堆的最后一个元素交换,使最大值归位到数组末尾的正确位置;接着将堆的大小减一,对剩余元素重新调整为最大堆,再取出堆顶,如此反复,直到堆的大小为 1,整个数组便有序了。从本质上看,堆排序是选择排序的一种优化——选择排序每轮通过线性扫描找出最大(或最小)元素,而堆排序借助最大堆的结构特性,能够以 O(logn) 的代价完成每轮的最大值选取(堆的重新调整),从而将整体时间复杂度优化到 O(nlogn),即选n轮,每轮重建堆的效率是logn。需要注意的是,学习堆排序之前最好先理解堆结构,这样会更有利于对整个排序过程的理解。 堆排序有两种思路: (1)原地建堆。即一开始,将数组传给堆结构,对数组本身建堆,再完成后续操作,该做法无需消耗额外空间。 (2)建立新数组,将原数组复制一份给新数组,对新数组建堆。 如果想让空间复杂度更低,选择原地建堆,空间复杂度是O(1),但如果原数组有在多个地方被引用的需求,就最好选择建立新数组,防止数组重复多次建堆对其余引用地方产生影响。 堆排序可以分成两大步骤:构建最大堆和排序。 构建最大堆: * 遍历待排序序列,从最后一个非叶子节点开始,依次对每个节点进行调整。 * 假设当前节点的下标为 i,左子节点的下标为 2i+1,右子节点的下标为 2i+2,父节点的下标为 (i-1)/2。 * 对于每个节点 i,比较它和左右子节点的值,找出其中最大的值,并将其与节点 i 进行交换。 * 重复进行这个过程,直到节点 i 满足最大堆的性质。 * 依次对每个非叶子节点进行上述操作,直到根节点,这样我们就得到了一个最大堆。 排序: * 将堆的根节点(也就是最大值)与堆的最后一个元素交换,这样最大值就被放在了正确的位置上。 * 将堆的大小减小一,并将剩余的元素重新构建成一个最大堆。 * 重复以上两个步骤,直到堆的大小为 1,这样我们就得到了一个有序的序列。 堆排序图解如图11-9所示。堆排序也是将数组分类为已排序和未排序两个区域,当构建最大堆时,位于数组首位的是最大值,将数组首位的最大值与数组末尾交换,并将交换后的数组末尾归类到已排序区域,交换结束后重新构建堆结构。当经历n-1次交换时,数组已完整排序。 **图11-9 堆排序图解** 堆排序的代码初始化如下: ```typescript import { testSort } from "hy-algokit"; export default function heapSort(arr: number[]): number[] { return arr } testSort(heapSort) ``` 堆排序代码编写思路如下3步: (1)原地建堆(构建最大堆)。 (2)将堆的根节点与堆的最后一个元素交换,堆的大小减一。 (3)重复步骤1和步骤2,直到堆的大小为1。 建堆操作在第9章是有详细学习的,可以将已写好的建堆方法运用到堆排序,但就当复习,在这里重新封装一遍。建堆方法需要3个参数: (1)参数1:建堆的数组数据。 (2)参数2:建堆的长度。 (3)参数3:下滤位置。PS:堆结构的第一个非叶子节点,计算公式:n/2 - 1(正常来说n/2就够了,但数组下标从0开始,因此需要额外减一)。 由于堆排序的步骤二需要将堆排序不断的缩减,所以我们需要对传入建堆方法的数组尾部进行裁切。 ```typescript /** * 下滤操作函数 * @param arr 在数组中进行下滤操作 * @param n 下滤操作的范围 * @param index 哪一个位置需要进行下滤操作 */ function heapifyDown(arr: number[], n: number, index: number) { while (2 * index + 1 < n) { // 1.获取左右子节点的索引 const leftChildIndex = 2 * index + 1 const rightChildIndex = 2 * index + 2 // 2.找出左右子节点较大的值 let largerIndex = leftChildIndex if (rightChildIndex < n && arr[rightChildIndex] > arr[leftChildIndex]) { largerIndex = rightChildIndex } // 3.判断index位置的值比更大的子节点, 直接break if (arr[index] >= arr[largerIndex]) { break } // 4.和更大位置的进行交换操作 swap(arr, index, largerIndex) index = largerIndex } } ``` 通过 heapifyDown() 的第二个参数(堆的有效长度 n),可以在排序过程中逐步缩小堆的范围;第三个参数用于指定需要执行下滤的起始位置(通常为根节点)。随着堆大小不断减小,最终当 n = 1 时,数组即完成有序排列。 在堆排序过程中,每一轮只需将堆顶元素(最大值)与当前堆的最后一个叶子节点交换。交换后,仅需对被移动到堆顶的位置执行一次下滤操作,即可重新恢复堆的有序结构,而无需对整个堆重新调整。 ```typescript export default function heapSort(arr: number[]): number[] { // 1.获取数组的长度 const n = arr.length // 2.对arr进行原地建堆 // 2.1. 从第一个非叶子节点开始进行下滤操作 const start = Math.floor((n / 2) - 1) for (let i = start; i >= 0; i--) { // 2.2. 进行下滤操作 heapifyDown(arr, n, i) } // 3.对最大堆进行排序的操作 for (let i = n - 1; i > 0; i--) { swap(arr, 0, i) heapifyDown(arr, i, 0) } return arr } ``` 堆排序的时间复杂度分析较为复杂,因为它既涉及到堆的建立过程,也涉及到排序过程。下面我们分别对这两个步骤的时间复杂度进行分析。 步骤一:堆的建立过程。堆的建立过程包括 n/2 次堆的向下调整操作,每次调整的时间复杂度为logn,因此它的时间复杂度为 O(nlogn)。 步骤二:排序过程。排序过程需要执行 n 次堆的删除最大值操作,每次操作都需要将堆的最后一个元素与堆顶元素交换,然后向下调整堆。每次向下调整操作的时间复杂度为O(log n),因此整个排序过程的时间复杂度为O(n log n)。 综合起来,堆排序的时间复杂度为O(n log n)。需要注意的是,堆排序的空间复杂度为 O(1),因为它只使用了常数个辅助变量来存储堆的信息。 测试堆排序的空间复杂度可以使用第三方库hy-algokit的measureStort方法,用于测试在10万数据甚至更多数据下,排序算法的消耗时间。堆排序具有时间复杂度为 O(n log n) 的优秀性能,并且由于它只使用了常数个辅助变量来存储堆的信息,因此空间复杂度为 O(1)。但是,由于堆排序的过程是不稳定的,即相同元素的相对位置可能会发生变化,因此在某些情况下可能会导致排序结果不符合要求。 PS:“相同元素的相对位置可能会发生变化”指的是:在排序前,如果两个元素的值相同,它们在数组中有先后顺序(谁在前、谁在后);但经过堆排序后,这种先后关系可能被打乱,比如原本在前的元素被排到后面。之所以会这样,是因为堆排序过程中会频繁将堆顶元素与末尾元素直接交换,这种跨位置的交换不会保留原有顺序,只关注大小关系,从而导致相同元素的相对顺序不再保持一致。 总的来说,堆排序是一种高效的、通用的排序算法,它适用于各种类型的数据,并且可以应用于大规模数据的排序。 11.4 高级排序算法 ----------- 所谓高级排序算法,是相对于冒泡、选择、插入这些简单排序而言的。简单排序的共同特点是思路直观、实现容易,但它们都只能做到"相邻元素之间的比较和交换",每次操作只能将一个元素移动一小步,因此时间复杂度都停留在 O(N²)。而高级排序算法的本质突破在于:它们找到了某种方式,让元素能够"跨越式"地移动到更接近最终位置的地方,从而将时间复杂度降低到 O(N²) 以下。归并排序通过分治合并实现这一点,快速排序通过基准值分区实现这一点,堆排序通过堆结构的上浮下沉实现这一点。 希尔排序之所以被视为高级排序的门槛,是因为它是第一个跳出"逐个移动"思维框架的排序算法。在希尔排序之前,所有人都在"相邻比较"的范围内做优化,而希尔排序引入了"间隔"的概念——让相距较远的元素先进行比较和交换,使得一个较小的元素可以一次跨越多个位置到达靠前的区域。这个思想看似简单,却是从 O(N²) 迈向更低复杂度的关键一步。 从理解难度上来说,希尔排序也恰好处在一个过渡地带。它的底层操作依然是我们熟悉的插入排序,只是在外面包了一层间隔递减的逻辑,所以在认知上并不需要引入全新的概念,比如递归、分治或者树形结构。但它又比简单排序多了一层抽象——你需要理解"为什么先用大间隔粗排,再用小间隔精排,整体效率反而更高",这背后涉及对数据局部有序性的利用,已经触及了高级算法的思维方式。 所以说,希尔排序是从简单排序通往高级排序的一座桥梁:它用最朴素的手段(插入排序)实现了最关键的突破(跨越式移动),既回顾了过去,又指向了未来。理解了希尔排序,再去学习归并排序的分治思想、快速排序的分区策略,就会顺畅很多。 ### 11.4.1 希尔排序 在简单排序算法诞生后的很长一段时间里,无论是冒泡排序、选择排序还是插入排序,时间复杂度都停留在 O(N²) 的级别,学术界甚至弥漫着"排序算法不可能突破 O(N²)"的论调,就像人们曾坚信百米短跑不可能跑进10秒一样。直到1959年,Donald Shell 提出了希尔排序,这一局面才被打破。希尔排序的核心思想是先将数据按一定间隔分组,对每组进行插入排序,再逐步缩小间隔、重复这一过程,使数据逐渐趋于有序,最终完成排序。这种"先粗调、后精调"的策略让它的时间复杂度成功突破了 O(N²) 的壁垒,并且可以通过选择不同的步长序列进一步优化性能,为后续更高效的排序算法研究打开了大门。 先回顾一下插入排序的过程:排序进行到中途时,标记符左边的数据已经有序,右边的数据尚未排序。每一步操作是取出标记符指向的数据项存入临时变量,然后从该位置向左逐一比较,将较大的有序数据项依次右移一位,直到找到合适的插入位置,再将临时变量中的数据项放入。这个过程直观且易于理解,但隐藏着一个效率瓶颈。 设想这样一种情况:一个很小的数据项恰好位于数组的最右端——那里本应是较大数据项的位置。为了把它移动到左边的正确位置,所有中间的数据项都必须逐个向右挪动一位。每个步骤平均需要移动约 N/2 次,N 个元素累计下来就是 N²/2 次移动,这就是插入排序时间复杂度为 O(N²) 的根本原因。那么,如果有一种方法能让较小的数据项直接"跳跃"到靠左的位置,而不必逐一搬动所有中间元素,排序效率就能获得质的提升——这正是希尔排序要解决的问题。 以数组 [81, 94, 11, 96, 12, 35, 17, 95, 28, 58, 41, 75, 15] 为例来看希尔排序的具体过程。首先设定间隔为5,把相隔5个位置的元素分为一组进行插入排序,比如 (81, 35)、(94, 17)、(11, 95) 等组内各自排好序。经过这一轮排序,虽然整体还没有完全有序,但每个数字都朝着自己最终的正确位置迈进了一大步。接着将间隔缩小为3,再次分组排序,例如 (35, 28, 75, 58, 95)、(17, 12, 15, 81) 等组内再次调整,数据又离正确位置更近了一步。 当间隔最终缩小为1时,实际上就回到了标准的插入排序。但此时数组已经经过前几轮的"粗排"变得接近有序,每个元素距离自己的最终位置都很近,因此插入排序中需要移动的次数大大减少。这就是希尔排序的精妙之处——通过先大间隔、后小间隔的多轮分组排序,让数据逐步趋于有序,最后一轮插入排序只需做少量的微调即可完成,从而整体效率远高于直接使用插入排序。  **图11-10 希尔排序图解** 完整的希尔排序过程如下: 第一轮:间隔 = 5 按间隔5分组(相隔5个位置的元素为一组): * 组0(下标 0, 5, 10):81, 35, 41 → 排序后:**35, 41, 81** * 组1(下标 1, 6, 11):94, 17, 75 → 排序后:**17, 75, 94** * 组2(下标 2, 7, 12):11, 95, 15 → 排序后:**11, 15, 95** * 组3(下标 3, 8):96, 28 → 排序后:**28, 96** * 组4(下标 4, 9):12, 58 → 排序后:**12, 58** 将排序结果放回原位,得到: [35, 17, 11, 28, 12, 41, 75, 15, 96, 58, 81, 94, 95]。 第二轮:间隔 = 3 按间隔3分组: * 组0(下标 0, 3, 6, 9, 12):35, 28, 75, 58, 95 → 排序后:**28, 35, 58, 75, 95** * 组1(下标 1, 4, 7, 10):17, 12, 15, 81 → 排序后:**12, 15, 17, 81** * 组2(下标 2, 5, 8, 11):11, 41, 96, 94 → 排序后:**11, 41, 94, 96** 将排序结果放回原位,得到: [28, 12, 11, 35, 15, 41, 58, 17, 94, 75, 81, 96, 95]。 第三轮:间隔 = 1(标准插入排序) 此时数组已接近有序,逐步插入如表11-1所示。 **表11-1 希尔排序第三轮插入排序** | 步骤 | 取出元素 | 操作 | 数组状态 | | --- | --- | --- | --- | | 1 | 12 | 12 < 28,插入到28前 | [12, 28, 11, 35, 15, 41, 58, 17, 94, 75, 81, 96, 95] 2 | 11 | 11 < 28, < 12,插入到最前 | [11, 12, 28, 35, 15, 41, 58, 17, 94, 75, 81, 96, 95] 3 | 35 | 35 > 28,无需移动 | [11, 12, 28, 35, 15, 41, 58, 17, 94, 75, 81, 96, 95] 4 | 15 | 插入到12和28之间 | [11, 12, 15, 28, 35, 41, 58, 17, 94, 75, 81, 96, 95] 5 | 41 | 41 > 35,无需移动 | [11, 12, 15, 28, 35, 41, 58, 17, 94, 75, 81, 96, 95] 6 | 58 | 58 > 41,无需移动 | [11, 12, 15, 28, 35, 41, 58, 17, 94, 75, 81, 96, 95] 7 | 17 | 插入到15和28之间 | [11, 12, 15, 17, 28, 35, 41, 58, 94, 75, 81, 96, 95] 8 | 94 | 94 > 58,无需移动 | [11, 12, 15, 17, 28, 35, 41, 58, 94, 75, 81, 96, 95] 9 | 75 | 插入到58和94之间 | [11, 12, 15, 17, 28, 35, 41, 58, 75, 94, 81, 96, 95] 10 | 81 | 插入到75和94之间 | [11, 12, 15, 17, 28, 35, 41, 58, 75, 81, 94, 96, 95] 11 | 96 | 96 > 94,无需移动 | [11, 12, 15, 17, 28, 35, 41, 58, 75, 81, 94, 96, 95] 12 | 95 | 插入到94和96之间 | [11, 12, 15, 17, 28, 35, 41, 58, 75, 81, 94, 95, 96] | * * * 最终结果:[11, 12, 15, 17, 28, 35, 41, 58, 75, 81, 94, 95, 96]。可以看到,最后一轮插入排序中大量元素已经在正确位置附近,很多步骤都"无需移动",移动次数远少于直接对原数组做插入排序的情况。 ### 11.4.2 希尔排序的增量序列 希尔排序的核心思想可以概括为"分组、排序、缩小间隔、再排序"。具体来说,先选定一个增量序列 d1, d2, ..., dk(其中最后一个增量 dk 必须为1),然后以当前增量 d 为间隔,将待排序数组划分为 d 个子序列,对每个子序列分别执行插入排序;接着缩小增量,重新分组并排序,如此反复,直到增量缩小为1,此时整个数组作为一个子序列进行最后一轮插入排序,排序完成。 增量序列的选择直接影响希尔排序的效率,目前常用的有希尔增量、Hibbard增量、Knuth增量等。以最经典的希尔增量为例,其计算方式为 dk = floor(n / 2^k),即每次将增量取为前一次的一半,逐步缩小直到1。例如对于长度为13的数组,增量序列依次为6、3、1。不同的增量序列会带来不同的时间复杂度表现,这也是希尔排序研究中一个持续探讨的话题。 希尔排序的代码初始化如下(采用11.4.1小节所展示的数据): ```typescript import { testSort } from "hy-algokit"; export default function shellSort(arr: number[]): number[] { return arr } const arr = [81, 94, 11, 96, 12, 35, 17, 95, 28, 58, 41, 75, 15] testSort(shellSort) ``` 代码编写4步骤如下: (1)初始化间隔:根据增量序列确定初始步长(如 n/2)。 (2)分组排序:以当前间隔将数组分为多个子组,对每个子组执行插入排序。 (3)步骤1与步骤2反复执行,直到步长间隔为1,数组已接近有序。 (4)缩小间隔并重复:将间隔缩小(如除以2),重复第2步,直到间隔为1时完成最后一轮排序,算法结束。 ```typescript import { testSort } from 'hy-algokit' // 希尔排序 export default function shellSort(arr: number[]): number[] { const n = arr.length // 选择不同的增量(步长/间隔),确认初始步长 let gap = Math.floor(n / 2) // 6 - 3 - 1 // 1.第一层循环: 不断改变步长的过程 while (gap > 0) { // 直到间隔为1时,结束排序 // 获取到不同的gap, 使用gap进行插入排序 // 2.第二层循环: 找到不同的数列集合进行插入排序的操作 for (let i = gap; i < n; i++) { let j = i const num = arr[i] // 第三层循环: while循环, 对数列进行插入排序的过程 // 使用num向前去找到一个比num小的值 while (j > gap - 1 && num < arr[j - gap]) { arr[j] = arr[j - gap] j = j - gap } arr[j] = num } gap = Math.floor(gap / 2) } return arr } // const arr = [81, 94, 11, 96, 12, 35, 17, 95, 28, 58, 41, 75, 15] // console.log(shellSort(arr)) testSort(shellSort) ``` 希尔排序代码实现的麻烦之处在于有三层循环,容易被绕晕: (1)第一层循环(while循环):控制间隔gap的递减过程。gap从 n/2 开始,每轮缩小一半,依次经历如 6→3→1 的变化,直到 gap 为0时退出循环。这一层决定了整个算法要进行多少轮分组排序。 (2)第二层循环(for循环):在当前gap下,从下标 gap 开始逐一遍历数组中的每个元素。它的作用是依次取出每个待插入的元素,相当于把所有子组的插入排序交织在一起执行——而不是先排完一个子组再排下一个,这样写起来更简洁,效果完全一样。 (3)第三层循环(while循环):这是插入排序的核心操作。取出当前元素 num 后,在它所属的子组内向前比较,每次跨越 gap 个位置,将比 num 大的元素后移 gap 位,直到找到一个不大于 num 的位置,把 num 插入进去。这和标准插入排序的逻辑完全一致,只不过步长从1变成了gap。 总的来说,第一层循环决定要进行多少轮分组排序,每轮的步长间隔会不断减半,直到步长间隔为1。第二层循环和第三层循环共同完成对各子数组的插入排序,其中第二层循环依次取出当前待插入的元素,保存到临时变量 num 中;第三层循环则在该元素所属的子数组中向前查找,将比 num 大的元素逐个后移 gap 位,直到找到不大于 num 的位置或到达子数组边界,最后将 num 放入空出的位置,完成一次插入。 希尔排序的效率与增量序列的选择密切相关,但遗憾的是,其复杂度的严格数学证明非常困难,许多增量序列的效率至今仍未被完全证明。以最原始的希尔增量(每次减半)为例,最坏情况下时间复杂度为 O(N²),不过在通常情况下表现都会优于 O(N²)。为了进一步提升效率,研究者们提出了多种改进的增量序列。 其中比较著名的有 Hibbard 增量序列(1, 3, 5, 7, ...,通项为 2k - 1),其最坏复杂度为 O(N3/2),猜想的平均复杂度为 O(N5/4),但尚未被证明;还有 Sedgewick 增量序列(1, 5, 19, 41, 109, ...),其最坏复杂度为 O(N4/3),平均复杂度为 O(N^7/6),同样未被严格证明。尽管理论证明仍有待突破,但在实际使用中,希尔排序在大多数情况下的效率都显著高于冒泡、选择、插入等简单排序算法,是一种实用且高效的排序方案。 总的来说,希尔排序作为一种改进版的插入排序,其历史意义远大于实用价值——它打破了"排序算法不可能突破 O(N²)"的固有认知,为后续更高效的排序算法铺平了道路。虽然它的时间复杂度取决于步长序列的选择,且最优步长序列至今未被证明,仍是一个开放的研究问题,但随着归并排序、快速排序等更优秀的算法相继出现,希尔排序在实际开发中已经很少被使用了。因此,我们更多地是学习和理解它"分组缩小间隔、逐步趋于有序"的核心思想,以及它在排序算法发展史上的突破性贡献。 11.5 排序算法总结 ----------- 在完成多个排序算法之后,我们可以一次性的运行这些算法,从而完成效率的比对。而这也是在11.2小节与11.3小节中,并未贴出排序算法的运行效果的原因。 完成第三方库hy-algokit的引入,然后将之前实现的所有排序算法引入新的ts文件中,复制以下代码运行。 ```typescript import { compareSort } from 'hy-algokit' import bubbleSort from './01_冒泡排序(bubbleSort)' import selectionSort from './02_选择排序(selectionSort)' import insertionSort from './03_插入排序(insertionSort)' import mergeSort from './04_归并排序(mergeSort)' import quickSort from './05_快速排序(quickSort)' import heapSort from './06_堆排序(heapSort)' import shellSort from './07_希尔排序(shellSort了解)' compareSort([ bubbleSort, selectionSort, insertionSort, mergeSort, quickSort, heapSort, shellSort ], 100000) ``` ### 11.5.1 各算法复杂度对比 我们将实现的7个排序算法以10万数据量作为标准进行测试,排序算法比对总结如图11-11所示。 ```plain Sorting 100000 numbers: quickSort :12ms heapSort :18ms shellSort :24ms mergeSort :25ms selectionSort :5860ms insertionSort :11410ms bubbleSort :20967ms ``` **图11-11 排序算法比对总结** ### 11.5.2 排序算法选择策略 从这组10万数据量的测试结果中,可以非常直观地看到排序算法之间的性能鸿沟。冒泡排序耗时近21秒,插入排序约11秒,选择排序接近6秒,三者都处于"秒级"的量级;而希尔排序、归并排序、堆排序、快速排序全部在30毫秒以内完成,快了几百倍。这就是 O(N²) 与 O(N log N) 之间的差距——当数据量从1万增长到10万时,简单排序的耗时会膨胀100倍,而高级排序只会增长十几倍,数据量越大,差距越悬殊。 在四种高级排序算法中,快速排序以12毫秒排在第一,堆排序18毫秒紧随其后,希尔排序和归并排序分别为24毫秒和25毫秒,表现非常接近。快速排序之所以最快,是因为它的常数因子小、缓存命中率高,在大多数随机数据场景下都是首选。但快速排序在最坏情况下(如数据已经有序或接近有序)会退化到 O(N²),因此在对稳定性和最坏情况有要求的场景中,归并排序是更稳妥的选择——它始终保持 O(N log N) 的复杂度,而且是稳定排序。堆排序同样保证 O(N log N) 的最坏复杂度,且不需要额外的内存空间,适合内存受限的场景。 那么简单排序是否就毫无用武之地了呢?其实不然。当数据量很小(比如几十个元素)时,简单排序的常数因子小、代码开销低,反而可能比高级排序更快。事实上,很多语言标准库中的排序实现(如 TimSort)在递归到小规模子数组时,就会切换为插入排序来处理。此外,如果数据本身已经接近有序,插入排序的效率会接近 O(N),这是其他算法难以比拟的优势。 从实用和面试角度来说,11.3小节的归并、快速和堆排序是最需要掌握的,最好能够自己写出来。 所以,排序算法的选择并没有一个放之四海而皆准的答案,需要根据具体场景来权衡。数据量大、随机分布,优先选快速排序;需要稳定排序或要求最坏情况可控,选归并排序;内存紧张且不需要稳定性,选堆排序;数据量小或接近有序,插入排序反而是最佳选择。理解每种算法的特点和适用场景,比死记哪个"最快"要重要得多。 ### 11.5.3 排序算法面试题 排序算法有专门的面试题,例如LeetCode的912题排序数组。题目地址:[912. 排序数组 - 力扣(LeetCode)](https://leetcode.cn/problems/sort-an-array/description/)。 题目如下: 给你一个整数数组 nums,请你将该数组升序排列。 你必须在 **不使用任何内置函数** 的情况下解决问题,时间复杂度为 O(nlog(n)),并且空间复杂度尽可能小。 示例 1: ```plain 输入:nums = [5,2,3,1] 输出:[1,2,3,5] 解释:数组排序后,某些数字的位置没有改变(例如,2 和 3),而其他数字的位置发生了改变(例如,1 和 5)。 ``` 示例 2: ```plain 输入:nums = [5,1,1,2,0,0] 输出:[0,0,1,1,2,5] 解释:请注意,nums 的值不一定唯一。 ``` 提示: * 1 <= nums.length <= 5 * 104 * -5 * 104 <= nums[i] <= 5 * 104 将我们编写的排序算法都提交上去看看结果吧!本章的排序算法到这里结束,在下一章会开始学习动态规划。
第10章 AVL树-红黑树
10.1 平衡树基础概念 ------------ 在5.7.1小节中,讨论了二叉搜索树的缺陷,当我们把有序的数据依次插入普通的二叉搜索树(BST)时,树不会长成“左右均匀”的形状,而是沿着一条方向一直长下去:递增序列会产生完全右偏的树(每个节点只有右子节点);递减序列会产生完全左偏的树(每个节点只有左子节点)。形象地说,BST 会“退化”为一个链表。 二叉搜索树在面对有序的数据时,额外脆弱,因此延伸讨论了如何做到让树的高度保持在一个较低且可控的范围,使左右子树的高度差保持小,从而让操作效率保持稳定。而今天所要学习的平衡树就是相关的解决方案。 ### 10.1.1 平衡树的定义与必要性 平衡树是一类自我约束高度的二叉搜索树。其目的是通过一些特殊的技巧来维护树的高度平衡,从而保证树的搜索、插入、删除等操作的时间复杂度都较低。在刚才讨论二叉搜索树是有可能退化成链状结构,那么搜索、插入、删除等操作的时间复杂度就会达到最坏情况,即O(n),因此不能满足要求,这是我们需要平衡树的原因。 平衡树通过不断调整树的结构,使得树的高度尽量平衡,从而保证搜索、插入、删除等操作的时间复杂度都较低,通常为O(logn)。因此,如果我们需要高效地处理大量的数据(在如今信息爆炸的时代,高效处理信息的需求逐渐提升),那么平衡树就显得非常重要了。 当插入或删除导致树失衡时,平衡树会通过局部旋转(左旋、右旋或组合旋转)来恢复平衡。不同平衡树对“平衡”的定义略有不同:如 **AVL 树**严格限制高度差,**红黑树**通过颜色规则间接控制高度,但核心思想一致——用额外规则换取高度可控,从而换取性能稳定性。 假如我们连续的插入1、2、3、4、5、6的数字,那么二叉搜索树最终形成的结构如图10-1所示。  **图10-1 二叉搜索树的平衡** 而实际中,不只是添加会导致树的不平衡,删除元素也可能会导致树的不平衡。在普通二叉搜索树中,插入和删除操作始终遵循有序性规则,但**树本身并不对高度或形态作任何约束**。因此,任意一系列有序修改操作,都可能使树的结构逐渐偏斜,甚至退化为近似链表。这里的“不平衡”并非规则意义上的失衡,而是指结构上高度增长、左右子树分布不均所带来的性能退化。 正是由于普通二叉搜索树只保证有序性而不约束结构形态,树的高度完全依赖于操作序列本身。一旦插入或删除的数据分布存在偏序,树就可能逐渐向一侧生长,最终退化为近似链表,使查找、插入和删除等操作的时间复杂度从期望的O(log n)退化到最坏的O(n)。这种性能的不稳定性,并不是算法实现上的缺陷,而是 BST 设计本身就这样。那么如何让一棵树更加平衡呢? 为了在保持有序性的同时控制树的高度并稳定操作性能,引入了平衡树这一类数据结构。平衡树在每次结构性修改后,通过额外的平衡约束与局部调整机制,将树限制在“近似完全”的形态范围内,从而保证基本操作在最坏情况下仍能维持在O(log n)的时间复杂度。这正是平衡树相对于普通二叉搜索树最根本的设计动机。 额外的平衡约束与局部调整机制?要如何去做?通常有以下两种方式: (1)方式一:限制插入、删除的节点(比如在树特性的状态下,不允许插入或者删除某些节点,不现实)。 (2)方式二:在随机插入或者删除元素后,通过某种方式观察树是否平衡,如果不平衡通过特定的方式(比如旋转),让树保持平衡。 方式一并不现实,正如我们前面所说,任意一系列有序修改操作,都可能使树的结构逐渐偏斜,甚至退化为近似链表。有序修改删除操作是非常多且不可避免的,如果为了维持结构形态而在树处于某种状态时拒绝执行这些本就合法的操作,那树的实用性和灵活性会大大降低,在实际使用中处处受限,是没人喜欢使用的,而且违背“支持动态有序数据”的设计初衷。 相比之下,方式二采取的是更符合数据结构设计原则的思路:允许任意合法的插入和删除操作始终按照二叉搜索树的有序规则执行,而在操作完成后,通过维护额外的平衡信息来检测结构是否偏离预期形态。一旦发现局部失衡,便通过预先定义好的调整手段(如旋转等局部重构操作)对树的结构进行修复,使其重新回到受控的高度范围内。这种做法既不限制操作的合法性,又能在整体上约束树的形态,从而在保证有序性的同时,稳定树的高度和操作性能。 现在我们来看AVL树在一次插入操作后,由平衡到失衡,再通过旋转恢复平衡的全过程,如图10-2所示。  **图10-2 AVL树的插入操作** 图10-2所述内容可以从“发生了什么”和“为什么这样做”两个层次来理解。最左侧是一棵合法的 AVL 树。此时以 100 为根,左右子树高度相近,所有节点的平衡因子都在允许范围内,说明树的高度被有效控制。接下来向树中插入新节点 12,插入过程严格遵循二叉搜索树的有序性规则,新节点沿着 100 → 50 → 25 的路径,被放入最左侧的位置。 插入完成后,树的有序性没有任何问题,但高度信息发生了变化。新节点的加入使得 25、50 以及 100 的左子树高度依次增加,而右子树高度保持不变,最终导致节点 100 的左右子树高度差超过 1。此时 100 成为第一个不满足 AVL 平衡条件的节点,也就是图中标注的 critical node。由于失衡路径是“左子树的左子树”,这一情形被称为 LL 型失衡。 针对 LL 型失衡,AVL 树采用一次右旋操作来修复结构。右旋并不是重新插入节点,而是一种局部的结构重排:让失衡节点 100 的左孩子 50 上移成为新的根节点,而 100 下沉为其右孩子,同时将 50 原本的右子树调整为 100 的左子树。整个过程中,各节点的相对大小关系没有被破坏,因此二叉搜索树的有序性得以完整保留。 旋转完成后,树的结构重新变得紧凑,所有节点的左右子树高度差再次回到允许范围内,整棵树恢复为一棵合法的 AVL 树。这个过程体现了 AVL 树的核心思想:插入操作可以是任意合法的有序操作,而平衡性通过插入后的局部旋转来修复,从而保证整体性能的稳定性。 通过一次AVL树的一次插入操作,我们见证了预先定义好的调整手段是如何对树的结构进行修复的。 ### 10.1.2 平衡树的分类与应用 计算机发展到如今,常见的平衡二叉搜索树已经根据应用场景,被人发明出来,不需要我们去冥思苦想,直接学习就行。常见的平衡二叉搜索树有以下5种: (1)AVL树:这是一种最早的平衡二叉搜索树,在1962年由G.M. Adelson-Velsky和E.M. Landis发明。PS:AVL树的命名即发明者的首字母拼接。 (2)红黑树:这是一种比较流行的平衡二叉搜索树,由R. Bayer在1972年发明。 (3)Splay树:这是一种动态平衡二叉搜索树,通过旋转操作对树进行平衡。 (4)Treap:这是一种随机化的平衡二叉搜索树,是二叉搜索树和堆的结合。 (5)B-树:这是一种适用于磁盘或其他外存存储设备的多路平衡查找树。 这些平衡二叉搜索树都用于保证搜索树的平衡,从而在插入、删除、查找操作时保证了较低的时间复杂度。如果把这些平衡二叉搜索树放在一起看,其实它们的差异主要体现在“追求什么样的平衡、愿意为此付出多大的维护成本”上。AVL 树是最“较真”的那一类,它对高度控制得非常严格,几乎时刻保持最矮,因此查询性能很稳定,但插入和删除时旋转也最频繁。红黑树则更像现实主义者,它不追求绝对平衡,只要“别太歪”就行,用更宽松的规则换来了更少的调整成本,这也是它在工程中(比如各种语言的 map/set 实现)被广泛采用的原因。 Splay 树的思路就完全不一样了,它并不保证每一刻都平衡,而是相信“常用的节点以后还会常用”,每次访问都会把节点旋转到根附近,用**摊还复杂度**来换取长期平均性能。Treap 则把“平衡”这件事交给概率,通过给节点随机优先级,让树在期望意义上保持平衡,结构简单、实现优雅,但带点随机色彩。至于 B-树,它已经跳出了“纯内存二叉树”的范畴,通过多路分支大幅降低树高,减少磁盘 IO,几乎是为数据库和文件系统量身定做的方案。可以说,这些树并不是互相取代,而是各自在不同使用场景下,给“有序 + 高效”提供了不同的解决路径。 总结下来,红黑树和AVL树因占据了查询、插入,查找等主要高频操作行为,是应用最广泛的平衡二叉搜索树。红黑树被广泛应用于实现诸如操作系统内核、数据库、编译器等软件中的数据结构,其原因在于它在插入、删除、查找操作时都具有较低的时间复杂度。而AVL树被用于实现各种需要高效查询的数据结构,如计算机图形学、数学计算和计算机科学研究中的一些特定算法。 这两者的区别在于红黑树更稳定,而AVL树牺牲一定稳定性,做到在查询性能极致化。AVL树和红黑树在时间复杂度量级上是一样的,都是 O(log n),红黑树被广泛采用,并不是因为“时间复杂度更低”,而是在“最坏情况下旋转次数更少,维护成本更低”,稳定性更强。在现实中,稳定性往往比性能极致更具备应用场景。 10.2 AVL树详解 ----------- 在前面所说,AVL树(Adelson-Velsky and Landis Tree)是由G.M. Adelson-Velsky和E.M. Landis在1962年发明的,它是一种自(Self)平衡二叉搜索树。自平衡是什么意思?指自动平衡,当出现失衡情况时,树会根据既定的平衡规则,自动对局部结构进行调整,使其重新满足平衡条件。自动行为源于算法的主动且持续的维护。 在AVL树身上是指:树在每一次插入或删除节点之后,都会自动检查自身的结构是否仍然满足平衡条件;一旦发现某些节点的左右子树高度差超过允许范围,就会立即通过局部调整(旋转)把树修复回来。所以我们可以说AVL树是二叉搜索树的一个变体,在保证二叉搜索树性质的同时,通过旋转操作保证树的平衡。 ### 10.2.1 AVL树的基本特性 通过对平衡树的概念学习,以及了解AVL树的运作原理。我们清楚的知道AVL树主要特性源于两点操作: (1)每次树结构的改变都会检查自身是否处于平衡状态。 (2)当树结构处于失衡状态,通过旋转操作,使树结构恢复平衡状态。 但AVL树要通过什么方式来检查树结构是否处于平衡状态呢?通过平衡因子,通俗易懂的说:是"权重"。 在 AVL 树中,每个节点都会维护一个与“高度”相关的度量,通常称为**平衡因子**(有时也被口语化地称为权值)。这个平衡因子表示该节点左子树高度与右子树高度之差,一般定义为 `左子树高度 − 右子树高度`。由于 AVL 树对平衡性有严格约束,任意节点的平衡因子只能取 `-1`、`0` 或 `1`,也就是说,任一节点的左右子树高度差最多为 1。正是这种对高度差的强制限制,使 AVL 树在结构上始终保持“接近完全”的形态,因此也常被称为**高度平衡树**。 这种高度上的严格控制,直接带来了性能上的好处。与普通二叉搜索树不同,AVL 树不会因为插入顺序不当而逐渐退化为链表,其整体高度始终维持在 O(log n) 范围内,从而保证查找操作具有稳定且高效的时间复杂度。当插入或删除节点导致某些节点的平衡因子超出允许范围时,AVL 树会立即通过一系列**旋转操作**对局部结构进行调整,在不破坏二叉搜索树有序性的前提下恢复平衡状态。正是这种“修改后即时修复”的机制,使 AVL 树在动态更新场景中依然能够保持良好的查询性能。 AVL树的插入和删除操作与普通的二叉搜索树类似,但是在插入或者删除之后,AVL树需要通过旋转操作来继续保持树的平衡。  **图10-3 AVL树的平衡因子** AVL树的平衡因子如图10-3所示。这棵 AVL 树中,每个节点旁边标注的数字表示的是平衡因子(balance factor),由节点左右子树的高度计算得出,通常定义为:**左子树高度 − 右子树高度**。例如根节点 `100`,其左子树高度为 3,右子树高度为 2,因此平衡因子为 `3 − 2 = 1`;节点 `50` 的左子树高度为 1、右子树高度为 2,于是得到 `1 − 2 = -1`;而像 `75`、`150` 这类左右子树高度相同的节点,其平衡因子自然为 `0`。叶子节点由于没有子树,高度差为 0,平衡因子也为 0。 在插入或删除节点时,AVL 树会从发生变化的位置开始,**自下而上更新各节点的高度和平衡因子**。一旦发现某个节点的平衡因子变为 `2` 或 `-2`,就说明该节点已经失衡,AVL 树会根据失衡方向(LL、LR、RR、RL)选择对应的旋转方式,对局部结构进行调整。旋转完成后,相关节点的高度和权值会被重新计算,使平衡因子重新回到允许范围内。 失衡方向(LL、LR、RR、RL)是用来描述失衡节点是“往哪一侧歪的”,以及“歪的那一侧内部又是怎么歪的”。 (1)LL(Left-Left)型失衡:左子树高,且左子树的左子树也更高。 (2)LR(Left-Right)型失衡:左子树高,但左子树内部是右子树更高。 (3)RR(Right-Right)型失衡:右子树高,且右子树的右子树也更高。 (4)RL(Right-Left)型失衡:右子树高,但右子树内部是左子树更高。 记住4种失衡方向的方式:第一个字母看“哪边高”,第二个字母看“高度来自那边的哪一侧”。所以旋转方式有可能转一次也可能转两次。像LL和RR这种两次旋转都在一个方向的,可以合并成一次旋转;而LR和RL这种两次旋转相反的,就需要转两次。 由于AVL树具有自平衡性,哪怕最坏的情况下,时间复杂度也仅为O(log n),不至于退化成链表。 ### 10.2.2 AVL树的旋转操作 10.2.1小节中,知晓了AVL树通过平衡因子来判断是否处于平衡状态,以及4种类型的失衡状态。当处于这4种失衡状态,知道要通过旋转操作使AVL树保持平衡状态。可旋转操作是怎么做的?AVL树的旋转操作如图10-4所示。  **图10-4 AVL树的旋转操作** 这幅图,我们很快就会用到。AVL树的实现离不开旋转,而在旋转之前,还需要解决什么时候旋转?从哪个节点旋转?转左边还是转右边等问题。所以接下来,会着重关注这些问题,最终在10.2.5小节,来深入AVL树的旋转问题。 ### 10.2.3 AVL树的节点封装 手写实现AVL树本身的过程是相当的复杂的,那么我们要如何规划学习AVL树的路径呢?将一个庞大的目标,拆分成数个小目标,使学习曲线更加平缓是一种非常好用的方式,我们的AVL树学习也采用该方法,将手写AVL树拆分为5大步骤: (1)学习AVL树节点的封装。 (2)学习AVL树的旋转代码情况。 (3)写出不同情况下进行的不同旋转操作。 (4)写出插入操作后,树的再平衡操作。 (5)写出删除操作后,树的再平衡操作。 一步步实现上面的功能,最后将功能组合在一起,实现AVL树的编写,让我们开始吧! 封装AVL树的节点在完全未知的情况是非常难以完善的。假如我们从未学习过AVL树,我们可能会先将二叉搜索树的节点直接拿过来使用,然后在后续编写代码与完善思路中发现矛盾之处,再回过头对AVL树的节点进行完善补充,然后继续往后编写实现AVL树,循环往复。每一次重构代码都是对自身的一次考验,个人的思维逻辑也会在该过程得到极强的锻炼。PS:AVL 节点的设计(高度 / 平衡因子 / 子指针 / 回溯更新)高度依赖整体算法的协同关系,不是“看到就能想到”的字段组合。如果不知道后面要做旋转、回溯、失衡判断,节点设计必然会反复推翻。 不过这种做法是非常痛苦的,而且非常消耗时间。我们完全不必这么做,但在学习现成的AVL树时,我们也可以采用迂回的,更具性价比的做法,让思维模式也能有足够的锻炼。如:在编写AVL树节点时,看着完善的属性设定,多想一想这些属性会用在哪些地方,会如何使用,最后能用自己的话表达出来。每次思考都可以在稍等之后的学习中得到验证,主动的用起头脑理解和被动摄入知识的效率相比,是云泥之别。 接下来,让我们来封装AVL树的节点。 AVL树也是一棵二叉搜索树,只不过多了两点主要特性(10.2.1小节开头说明),所以AVL树的节点可以继承自二叉搜索树的节点,在原有基础上去开发。二叉搜索树节点代码如下: ```typescript class Node<T> { value: T constructor(value: T) { this.value = value } } export 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) } } ``` AVL树的节点继承自二叉搜索树节点, 在这里可以提前说明isLeft()和isRight()方法很重要,但为什么很重要?可以先想一想。 然后重写left,right和parent属性,设置为AVL树本身的节点类型,后续方便取出一些AVL树所特定的属性。 ```typescript class AVLTreeNode<T> extends TreeNode<T> { // 保证获取到的left/right节点的类型是AVLTreeNode left: AVLTreeNode<T> | null = null right: AVLTreeNode<T> | null = null parent: AVLTreeNode<T> | null = null } ``` ### 10.2.4 AVL树的平衡状态 完成AVLTreeNode节点的基础封装后,我们接下来要开始封装AVL树节点的核心部分,在编写的同时,可以思考该核心部分的用途是什么,能在哪一位置使用。学习已有数据结构时,无需多次往返重构,节省时间是我们的优势。 ```typescript class AVLTreeNode<T> extends TreeNode<T> { // 保证获取到的left/right节点的类型是AVLTreeNode left: AVLTreeNode<T> | null = null right: AVLTreeNode<T> | null = null parent: AVLTreeNode<T> | null = null // height: number = 1 /** 获取每个节点的高度 */ private getHeight(): number { const leftHeight = this.left ? this.left.getHeight(): 0 const rightHeight = this.right ? this.right.getHeight(): 0 return Math.max(leftHeight, rightHeight) + 1 } /** 权重: 平衡因子(左边height - 右边height) */ private getBalanceFactor(): number { const leftHeight = this.left ? this.left.getHeight(): 0 const rightHeight = this.right ? this.right.getHeight(): 0 return leftHeight - rightHeight } /** 直接判断当前节点是否平衡 */ get isBalanced(): boolean { const factor = this.getBalanceFactor() return factor >= -1 && factor <= 1 // -1 0 1 } } ``` 我们一共封装了3个方法,分别用于:获取节点高度,给节点设置权重值以及判断当前节点是否平衡。 获取节点的高度有一种方式,新增height属性,用于存储节点所处的高度,默认值为1(即AVL树只有根节点时,根节点同时也是叶子节点,所处的高度为1)。通过不断的计算节点所处的高度,修改height属性来为每一个节点赋值。 但通过新增height属性来获取节点高度的维护比较困难,因为每添加一个子节点,就需要重新计算一次高度,如果高度增加就更新height属性,高度不变就保持。 所以我们采用另一种方式来获取节点高度。已知节点高度是节点自身的左右子节点高度取高的那一个加1。如图10-5所示,节点75的高度取决于左右叶子节点65和85的高度(叶子节点高度为0,所以节点75高度为1),而节点50的高度取决于左右节点25和75中,高的一方(节点75)再加1,所以节点50的高度为2。  **图10-5 AVL树的节点高度** 所以获取高度,我们通过私有工具方法getHeight()来实现(获取获取节点高度主要用于计算节点的权重值,用户不需要使用,因此该方法为私有方法)。使用递归获取左右节点高度,然后读取高度更高的一侧节点加1,即获取目标节点的高度。PS:高度是相对的数值,叶子节点的高度可以为0为1,也可以为其余任何值,节点之间的高度是相对的,不影响计算。 ```typescript /** 获取每个节点的高度 */ private getHeight(): number { // 获取当前节点的左子节点,直到读取到叶子节点为止,并给叶子节点赋值高度0 const leftHeight = this.left ? this.left.getHeight(): 0 const rightHeight = this.right ? this.right.getHeight(): 0 return Math.max(leftHeight, rightHeight) + 1 // 叶子节点高度为1 } ``` 通过递归方式实现的 getHeight() 方法,即一个节点的高度等于其左右子节点高度的最大值再加1。这种做法不需要在节点中额外维护高度状态,节点的高度完全由当前树的结构自然推导而来,因此逻辑非常直观,也更不容易因遗漏更新而引入错误。从理解和学习的角度看,它能够帮助开发者真正把“高度是结构的派生结果”这一概念内化,而不是把高度误认为节点的固有属性。 相比之下,通过在节点中维护 height 属性虽然在性能上更优,但需要在插入、删除和旋转等操作中小心翼翼地同步更新高度,一旦某个步骤遗漏就可能破坏平衡判断。递归 getHeight() 的优势并不体现在运行效率上,而体现在实现的纯粹性和安全性上:高度始终与当前结构保持一致,不依赖历史状态(不引入额外状态,节点只关系左右节点是谁,高度完全由结构决定,不存在忘记更新height属性,更新顺序错误或者旋转后高度不同步等问题)。在学习 AVL 树原理和验证旋转逻辑正确性的阶段,这种方式更有利于建立清晰、可靠的认知基础(不变式更少,Bug 更难产生),随后再引入 height 属性作为性能优化,会更加水到渠成。PS:height属性的做法更侧重性能,是标准AVL树的实现,但理解难度相对更高。 获取节点高度这件事情,需要用来计算节点的平衡因子才有意义。通过 getHeight() 方法可以获取目标节点的左右子节点高度。目标节点的平衡因子计算公式是:左子节点-右子节点,得出的差值为平衡因子。在此代码的编写中,获取leftHeight和rightHeight是可以抽象封装的,等下会进行优化。 ```typescript /** 权重: 平衡因子(左边height - 右边height) */ getBalanceFactor(): number { const leftHeight = this.left ? this.left.getHeight(): 0 const rightHeight = this.right ? this.right.getHeight(): 0 return leftHeight - rightHeight } ``` 生成一棵比较极端的二叉搜索树(一棵明显向右倾斜的二叉搜索树),根节点为 10,其右子节点为 15,而节点 15 又只有一个右子节点 20。在当前高度定义下(空节点高度为 0,节点高度为其左右子树高度的最大值加 1),节点 20 为叶子节点,其高度为 1;节点 15 的右子树高度为 1、左子树为空,因此其高度为 2;根节点 10 的右子树高度为 2、左子树为空,高度为 3。 按照平衡因子的定义(左子树高度减去右子树高度),根节点 10 的平衡因子为 0 − 2 = -2,已经超出 AVL 树允许的取值范围 [-1, 1],因此该节点处于失衡状态。 ```typescript const avlNode1 = new AVLTreeNode(10) avlNode1.right = new AVLTreeNode(15) avlNode1.right.right = new AVLTreeNode(20) // 测试平衡因子(权值) console.log(avlNode1.getBalanceFactor()) // -2 ``` 所以我们可以通过getBalanceFactor()方法来判断当前二叉树是否处于平衡状态。但在这里,我们还需要基于getBalanceFactor()方法再封装一层,因为getBalanceFactor()方法返回的是具体的权值,还需要比对是否在AVL 树允许的取值范围 [-1, 1]内,而我们需要的是该节点是否处于失衡状态的结果(以布尔值作为返回结果)。通过get语法来反映内部变量的状态,无需使用显式方法调用。 ```typescript /** 直接判断当前节点是否平衡 */ get isBalanced(): boolean { const factor = this.getBalanceFactor() // factor在[-1,1]之间才是平衡的,返回true,否则返回false。 return factor >= -1 && factor <= 1 // -1 0 1 } // 直接获取到一个节点目前是否平衡 console.log(avlNode1.isBalanced) console.log(avlNode1.right.isBalanced) ``` 我们可以通过isBalanced来获取一个节点是否处于平衡状态。如果目标节点处于失衡状态,到时候AVL树要旋转时,要以谁为中心?isBalanced本身并不决定“从哪里旋转”,决定从哪旋转的是轴心(枢轴点)。如图10-5所示的AVL树中,不平衡的节点是根节点,平衡因子为2,依靠直觉的判断,旋转要以节点3作为轴心来旋转,从而形式根节点3,左节点2以及右节点5的AVL树,达成平衡状态。  **图10-5 AVL树的旋转枢轴点** 可在计算机中,一切都是必须明确的,不能依靠直觉,我怎么知道要以失衡节点的左子节点还是右子节点为轴心?这是已经有前人总结规律了,找失衡节点的轴心,是去找失衡节点的左右子节点中,高度更高的那一个子节点。 在 AVL 树中,旋转并不是凭经验或直觉进行的,而是由**失衡产生的因果关系**严格决定的。当插入或删除一个节点时,树的高度变化只会沿着一条从操作位置向上回溯的路径传播。因此,当某个节点第一次出现平衡因子绝对值大于 1 时,说明它的左右子树中,**必然有一侧的高度发生了异常增长或缩减**,从而打破了原有的平衡状态。 所谓“轴心(枢轴点)”,正是这次高度变化的**直接来源**。如果失衡节点的左子树高度更高,说明高度变化来自左子树;反之,若右子树高度更高,则变化来自右子树。旋转操作的目的,是压缩这条过长的高度路径,而不是去调整与失衡无关的那一侧。因此,轴心只能选取失衡节点左右子节点中**高度更高的那个节点**,这是由失衡形成的物理原因所决定的,而非人为设定的规则。 这一原则将人类对树结构“向哪边歪了”的直觉判断,转化为计算机可以明确执行的算法逻辑。通过比较失衡节点左右子节点的高度,程序就能确定旋转的轴心,从而围绕导致失衡的那条路径进行局部结构重排。正是这种以“高度变化来源”为核心的旋转策略,保证了 AVL 树能够用有限且确定的旋转操作,稳定地恢复平衡状态。 所以,我们要封装higherChild()方法,用于获取目标节点中,左右子节点高度更高的那一个子节点。先同时获取目标节点的左右子节点高度,然后返回高度更高的那一个子节点。同时添加边界判断,当左右子节点高度相等时,可以直接返回null,一般情况下,是不会来到该边界判断的,因为只有失衡情况,我们才会调用该方法。但AVL树有一个默认规范,假如目标节点是左节点,那我们就返回目标节点的左节点,反之返回右节点。这是一种延续之前变化的做法。 ```typescript /** 获取更高子节点 */ public get higherChild(): AVLTreeNode<T> | null { const leftHeight = this.left ? this.left.getHeight(): 0 const rightHeight = this.right ? this.right.getHeight(): 0 if (leftHeight > rightHeight) return this.left if (leftHeight < rightHeight) return this.right // 判断父节点是否为左节点,是则返回父节点的左节点,反之返回右节点 return this.isLeft ? this.left: this.right } ``` AVL树确认了轴心就可以旋转了,但要往哪边旋转?在 AVL 树中,当失衡节点和轴心(Pivot)已经确定后,旋转方向其实已经隐含在树的结构之中。失衡节点之所以失衡,是因为某一侧子树高度异常增长,而轴心正是这次高度增长的来源节点。旋转的目的,并不是简单地“换个位置”,而是通过结构重排,将这条过长的高度路径向中间压缩,从而恢复左右子树高度的平衡。 具体来说,**旋转方向永远与失衡的方向相反**。如果失衡节点的左子树更高,说明树在结构上向左倾斜,此时必须通过一次“向右的旋转”来抵消这种左倾;反之,如果右子树更高,则通过一次“向左的旋转”来修正右倾。这种“向高的一侧反方向旋转”的原则,确保旋转操作能够直接作用在造成失衡的那条最长路径上,而不是对无关结构做无效调整。 在更复杂的情形下(如 LR 或 RL 型失衡),虽然需要两次旋转,但方向判断的本质并没有变化:**第一次旋转用于修正轴心内部的倾斜方向,第二次旋转用于修正失衡节点整体的倾斜方向**。因此,无论是单旋转还是双旋转,旋转方向始终由高度增长路径决定,而不是人为指定。正是这种由结构关系自动推导出的旋转方向,使 AVL 树的平衡修复过程既确定又可预测。 ### 10.2.5 AVL树的旋转 如果你是从10.2.2小节直接跳到这一小节,那么需要思考是否想清楚那一小节最后的3个问题: (1)什么时候旋转?PS:你是否掌握权衡因子以及平衡状态。 (2)从哪个节点旋转?PS:你是否掌握判断枢轴点。 (3)转左边还是转右边?PS:你是否理解旋转的本质是为了调整失衡的最长路径。 如果你都已经了解,那么可以开始对AVL树的旋转学习。在开始之前,我们重温一些重要的概念:首先,旋转并不是重新插入节点或打乱树的有序性,而是一种局部的结构调整:它只发生在失衡节点及其相邻子节点之间,作用范围极小。无论进行哪种旋转,树的中序遍历顺序始终保持不变,因此旋转前后都仍然是一棵合法的二叉搜索树。 其次,AVL 树的旋转是确定性的,而不是试探性的。一旦通过平衡因子确定了失衡节点、轴心以及失衡类型(LL、LR、RR、RL),旋转方式和方向就已经唯一确定,不存在多种可选方案。旋转的目标也非常明确:压缩导致失衡的那条最长高度路径,使左右子树的高度重新回到允许范围内。只要牢记“旋转围绕失衡节点展开、轴心来自高度增长方向、旋转方向与失衡方向相反”这几个基本原则,后续理解任何具体旋转步骤都会变得顺理成章。 #### 10.2.5.1 左左情况分析 AVL树的左左情况如图10-6所示,左左情况即LL(Left-Left)型失衡,在该情况下,我们需要采用右旋转来让AVL树恢复平衡。  **图10-6 AVL树-左左情况** 在此图片中的A、B、C、D是可能存在的节点,但为什么要画出可能存在的节点,只考虑失衡节点所处路径的已有节点不行吗?这是一个非常关键、而且非常“算法思维”的问题,画出 A、B、C、D 并不是为了当前这棵树,而是为了证明:旋转在“所有可能的子树形态下”都成立且安全。 在讲解 AVL 树旋转时,如果只画出当前已经存在的节点,很容易让人误以为:旋转只在“这种具体形态”下才正确。但在真实的 AVL 树中,失衡节点附近并不只包含图中标出的那几个关键节点,它们的左右两侧随时可能还挂着其他子树。这些子树在旋转时既不能被丢失(例如旋转前的节点3的右子节点B),也不能破坏二叉搜索树的有序性,因此在示意图中必须用 A、B、C、D 把这些“可能存在的子树”明确标出来。 更重要的是,A、B、C、D 的存在,是为了说明旋转并不是“换几个节点位置”这么简单,而是一种保持中序顺序不变的结构重排。无论这些子树是否为空,旋转前后的中序遍历顺序都必须保持一致:`D < 2 < C < 3 < B < 5 < A`。 画出这些子树,正是在强调这一点——旋转过程中,节点的相对大小关系没有被破坏,只是重新分配了父子关系。如果不把 A、B、C、D 画出来,我们很容易忽略这一不变式,从而对“旋转为什么是安全的”产生疑惑。 从算法设计的角度看,AVL 树的旋转是一种通用规则。画出可能存在的节点,说明这些子树是否存在、形态如何,旋转规则都同样适用。这正是算法严谨性的体现:它不依赖当前数据的偶然形态,而对所有合法情况都成立。 如图10-6是如何判断左左情况的?关键不在于节点的数量或外形是否“向左偏”,而在于高度变化的传播路径。图中最上方的失衡节点是根节点 5,可以看到它的左子树高度明显大于右子树,说明失衡首先发生在左侧,这是第一个 “Left”。 继续沿着高度增长的方向向下观察,5 的左子节点 3 同样是其左子树高度更高,真正导致高度继续增加的是 3 的左子节点 2 这一侧,而非右侧子树。也就是说,高度变化路径呈现为 “左 → 左” 的连续方向。因此,该结构满足左子树的左子树导致失衡的特征,属于典型的 LL(Left-Left)型失衡。 在判断4种类型失衡时,最有意思的地方来了,只需要看这两个地方就能决定旋转并恢复二叉搜索树的平衡了?如果这是一个更复杂的二叉搜索树,有更多的节点,不会影响我们的判断吗?因为一次插入或删除,只会让“一条路径”的高度发生变化,失衡类型正是由这条路径在失衡节点处的前两步方向唯一决定的(这里的局部信号决定了全局)。 在 AVL 树中,无论整棵树多么复杂,插入或删除操作对高度的影响都具有一个重要特性:高度变化只会沿着从操作位置到根节点的一条路径向上传播。其他不在这条路径上的子树,其结构和高度在这次操作中完全不会发生改变。因此,当某个节点第一次出现失衡时,真正“参与造成失衡”的信息,只存在于这条高度变化路径上。 对于这个失衡节点而言,判断失衡类型只需要关注两层关系:高度是从哪一侧子树传上来的(左还是右),以及在该子树内部,高度又是来自它的哪一侧(左还是右)。这两次方向选择,已经完整描述了高度增长路径在该节点附近的形态,也就唯一确定了失衡类型是 LL、LR、RR 还是 RL。再往下的节点虽然可能存在,但它们只是在这条路径上继续延伸,并不会改变“先左还是先右”的方向组合。 因此,即便二叉搜索树结构再复杂,节点数量再多,在判断失衡类型时也无需关心更深层的细节。AVL 树的旋转本质上是对最小失衡子树进行修复,只要把这条路径在失衡节点附近“压短”,更高层的结构自然会随之恢复平衡。这也是 AVL 树能够通过局部旋转解决全局高度问题的根本原因。 在了解4种失衡类型的原理后,能得出结论:只需要找到失衡节点,后续所有操作都可以顺理成章的进行下去,所以失衡节点是绝对的关键,要旋转就第一时间找失衡节点(就像理清毛线球,第一时间找线头)。自己推理出结论和直接获取结论的意义是不一样的,会记得更牢固。 #### 10.2.5.2 右旋转的情况分析 现在,我们来分析AVL树-左左情况的具体右旋转操作(以图10-6举例)。在 AVL 树的左左(LL)型失衡中,需要对失衡节点执行一次**右旋转**。以图 10-6 为例,原本的失衡节点为 5,其左子节点 3 作为枢轴点参与旋转。右旋转的核心过程是:将节点 3 上移,成为新的子树根节点;同时,原根节点 5 下移,成为 3 的右子节点。为了保持二叉搜索树的有序性,3 原本的右子树 B 被重新挂接为 5 的左子树。 右旋转最大的变化,是对右子树B的迁移,这一点在逻辑推理与代码中均是重要体现。 旋转完成后,新的结构以节点 3 为根,左侧保持原有的左子树结构,右侧则由节点 5 及其子树组成。整个过程中,中序遍历顺序保持不变,但失衡节点处过长的左侧高度被有效压缩,从而恢复了 AVL 树的平衡状态。 #### 10.2.5.3 右旋转的伪代码逻辑 所以AVL树的右旋转,主要实现步骤为以下3大步,每大步再具体细分: * 处理pivot的位置。 (1)选择当前节点(失衡节点)的左子节点作为旋转轴心(pivot)。 (2)pivot的父节点指向this(root)的父节点。PS:this节点是当前节点(失衡节点)。 * 处理pivot右子节点的位置。PS:AVL树旋转的重要变化。 (3)this(root)当前节点的左子节点,指向pivot的右子节点。PS:右子树B(pivot的右子节点)有可能为空,为空则无需处理。 (4)如果pivot的右子节点有值,那么右子节点的父节点指向this节点。PS:这一步要在(5)之前完成,否则 pivot.right 会被覆盖。 * 处理this节点的位置。 (5)pivot的右子节点指向this节点。 (6)this节点的父节点指向pivot。 (7)判断是否有父节点,父节点的left/rigth指向pivot。PS:失衡节点如果是根节点的话,根节点是没有父节点。这一点需要注意分情况讨论。 AVL 树的右旋转虽然步骤较多,但逻辑是固定且可复用的。总结下来,就是先处理枢轴点,然后处理枢轴点可能存在的右子树,最后处理失衡节点。严谨的说则是:先确定并上移枢轴点(pivot),再妥善安置枢轴点可能存在的右子树,最后将失衡节点下沉并完成父子关系的重新挂接。只要熟悉这一流程,在实际实现中就可以像模板一样直接套用,而不需要每次重新推导结构。 之所以必须采用这种处理顺序,是因为旋转过程中存在严格的依赖关系。枢轴点是新的子树根,必须最先确立其父子关系,否则后续节点将无从挂接;而枢轴点的右子树在旋转后会变成失衡节点的左子树,如果不提前处理,就可能在指针重排时丢失或覆盖这棵子树;最后再处理失衡节点的位置,才能确保整个旋转过程中所有节点始终“有处可去”,不会出现悬空引用或结构断裂。换句话说,这种顺序并非人为约定,而是为了在每一步都保持二叉搜索树的结构和有序性不被破坏,是一种最安全、最自然的旋转执行路径。 #### 10.2.5.4 右旋转代码实现 将右旋转代码分4个部分,分别实现。 ```typescript rightRotation() { // 1. 处理pivot节点 // 2. 处理pivot节点的右子节点right // 3. 处理this // 4. 挂载pivot } ``` 部分1:处理pivot节点,即枢轴点。在右旋转的情况中,枢轴点位于失衡节点的左子节点。且由于我们是通过失衡节点来调用rightRotation()方法的,所以可以利用this获取失衡节点,通过this.left获取失衡节点的左子节点(枢轴点)。PS:一切都是基于右旋转的情况,即失衡节点的左侧不平衡,因此枢轴点一定位于失衡节点左侧并且有具体的值。 获取枢轴点(pivot节点),将枢轴点的父节点指向失衡节点的父节点。 ```typescript rightRotation() { // 1. 处理pivot节点 const pivot = this.left! pivot.parent = this.parent } ``` 部分2:处理pivot的right,即枢轴点的右子树。失衡节点的原左子节点是枢轴点,但由于枢轴点已改变父节点(父节点不再是失衡节点),所以失衡节点的左子节点可以存放新的节点并且不出问题。 右子树并不一定存在(一种可能性),所以需要判断是否存在,存在则将枢轴点的右子树接续到失衡节点的左子节点。并且节点之间是互相指向,即A指向B,B也应该指向A。当右子树确实存在时,右子树的父节点应该直接指向this失衡节点,而非原先的枢轴点。 ```typescript rightRotation() { // 2. 处理pivot节点的右子节点right this.left = pivot.right if (pivot.right) { pivot.right.parent = this } } ``` 部分3:处理this失衡节点(在上述案例中的失衡节点是根节点root,但失衡节点也可能是其他节点)。将失衡节点挂载到枢轴点的右子节点(原先右子树的位置)。这里一共两步操作(双向对应操作),枢轴点的右子节点为失衡节点,失衡节点的父节点为枢轴点。 ```typescript rightRotation() { // 3.处理this pivot.right = this this.parent = pivot } ``` 部分4:最后需要挂载pivot节点(枢轴点),pivot节点一共3种情况(具体取决于失衡节点原本的位置情况): (1)pivot节点最终是作为根节点。PS:即我们这次的案例事件,枢轴点取代了原先的根节点位置(失衡节点)。 (2)pivot节点最终是作为原失衡节点父节点的左子节点。PS:失衡节点也不一定是根节点,所以pivot节点最终取代的也不一定是根节点。 (3)pivot节点最终是作为原失衡节点父节点的右子节点。 此时刻,pivot节点的父节点已经完成替换(指向失衡节点的父节点),我们还需要做一件事情,将失衡节点的父节点的左/右子节点指向pivot节点,从而实现双向指向。但我们要如何获取失衡节点是位于父节点的左子节点还是右子节点? (1)通过pivot节点。 (2)通过失衡节点。 答案是通过失衡节点,因为此时父节点还未指向pivot节点,如果我们查找pivot父节点的左右子节点,只能找到失衡节点。而我们的isLeft和isRight是根据当前节点与父节点的左右子节点进行比较来确认位置的。所以pivot要作为父节点的左子节点还是右子节点,需要通过失衡节点来获取正确位置,在代码中通过this就能访问失衡节点,this.isLeft和this.isRight来访问失衡节点作为父节点的左子节点还是右子节点。 由于this在部分3已经被处理了,因此在部分3之前(获取属性节点之类的,通常在最前列先写)通过常量先将失衡节点的isLeft和isRight这两个判断结果保存下来。 ```typescript rightRotation() { const isLeft = this.isLeft const isRight = this.isRight // 4.挂载pivot if (!pivot.parent) { // pivot直接作为tree的根 return pivot } else if (isLeft) { // pivot作为父节点的左子节点 pivot.parent.left = pivot } else if (isRight) { // pivot作为父节点的右子节点 pivot.parent.right = pivot } } ``` 最终完整代码如下。 ```typescript /** 旋转操作: 右旋转 */ rightRotation() { const isLeft = this.isLeft const isRight = this.isRight // 1.处理pivot节点 const pivot = this.left! pivot.parent = this.parent // 2.处理pivot的right this.left = pivot.right if (pivot.right) { pivot.right.parent = this } // 3.处理this pivot.right = this this.parent = pivot // 4.挂载pivot if (!pivot.parent) { // pivot直接作为tree的根 return pivot } else if (isLeft) { // pivot作为父节点的左子节点 pivot.parent.left = pivot } else if (isRight) { // pivot作为父节点的右子节点 pivot.parent.right = pivot } return pivot } ``` 如果想对右旋转进行测试,是比较麻烦的。因为AVL树的每个节点暂时都还没有设置parent属性,所以没办法拿到失衡节点的父节点(其实是每个节点的父节点都暂时拿不到)。如果想测试,就需要手动的设置parent属性。但实际并不会如此麻烦,因为AVL树是由一棵空树一次次的插入操作逐渐完善的,我们只需要在插入操作中的第一步(根据传入value创建Node(TreeNode)节点)之后,给新加的节点加上parent属性就行了。 通过正规的方式,AVL树的每个节点都可以拥有parent属性,所以是可以完成右旋转操作的。 ```typescript // 内容回顾 /** 插入数据的操作 */ insert(value: T) { // 1.根据传入value创建Node(TreeNode)节点 const newNode = this.createNode(value) // 设置parent属性 // 2.判断当前是否已经有了根节点 if (!this.root) { // 当前树为空 this.root = newNode } else { // 树中已经有其他值 this.insertNode(this.root, newNode) } // 3.检测树是否平衡 this.checkBalance(newNode) } ``` #### 10.2.5.5 左旋转逻辑分析 与左左情况相对应的是右右情况,他们都是存粹的右旋转与左旋转,而非两者兼备。在理清左左情况之后,右右情况是否也是逻辑类似,只不过由于旋转方向不同,所导致的处理顺序和节点不太一致。 根据平衡因子,可得出关键信息如下2点: (1)失衡节点:3。 (2)枢轴点:5。 AVL树的左旋转用于修复右右(RR)型失衡。当失衡节点的右子树高度过高,且高度来自右子节点的右侧时,选择该右子节点作为枢轴点(pivot),将其上移成为新的子树根;同时,枢轴点原本的左子树被重新挂接为失衡节点的右子树;最后,失衡节点下沉为枢轴点的左子节点并完成父子关系的重连。AVL树的左旋转调整如图10-7所示。 **图10-7 AVL树-右右情况** 所以AVL树左旋转的实现步骤与右旋转是一致的。不同之处在于左旋转处理枢轴点的右子树,右旋转处理枢轴点的左子树。 ```typescript leftRotation() { const isLeft = this.isLeft const isRight = this.isRight // 1.处理pivot const pivot = this.right! pivot.parent = this.parent // 2.处理pivot的left this.right = pivot.left if (pivot.left) { pivot.left.parent = this } // 3.处理root(this) pivot.left = this this.parent = pivot // 4.挂载整颗子树pivot if (!pivot.parent) { return pivot } else if (isLeft) { pivot.parent.left = pivot } else if (isRight) { pivot.parent.right = pivot } return pivot } ``` AVL树的AVLTreeNode节点完整代码如下。 ```typescript class AVLTreeNode<T> extends TreeNode<T> { // 保证获取到的left/right节点的类型是AVLTreeNode left: AVLTreeNode<T> | null = null right: AVLTreeNode<T> | null = null parent: AVLTreeNode<T> | null = null /** 获取每个节点的高度 */ private getHeight(): number { // 获取当前节点的左子节点,直到读取到叶子节点为止,并给叶子节点赋值高度0 const leftHeight = this.left ? this.left.getHeight() : 0 const rightHeight = this.right ? this.right.getHeight() : 0 return Math.max(leftHeight, rightHeight) + 1 // 叶子节点高度为1 } /** 权重: 平衡因子(左边height - 右边height) */ getBalanceFactor(): number { // 在该计算中,叶子节点高度为0(不为1),但不影响计算 const leftHeight = this.left ? this.left.getHeight() : 0 const rightHeight = this.right ? this.right.getHeight() : 0 return leftHeight - rightHeight } /** 直接判断当前节点是否平衡 */ get isBalanced(): boolean { const factor = this.getBalanceFactor() return factor >= -1 && factor <= 1 // -1 0 1 // return Math.abs(factor) <= 1 } /** 获取更高子节点 */ public get higherChild(): AVLTreeNode<T> | null { const leftHeight = this.left ? this.left.getHeight() : 0 const rightHeight = this.right ? this.right.getHeight() : 0 if (leftHeight > rightHeight) return this.left if (leftHeight < rightHeight) return this.right return this.isLeft ? this.left : this.right } /** 旋转操作: 右旋转 */ rightRotation() { const isLeft = this.isLeft const isRight = this.isRight // 1.处理pivot节点 const pivot = this.left! pivot.parent = this.parent // 2.处理pivot的right this.left = pivot.right if (pivot.right) { pivot.right.parent = this } // 3.处理this pivot.right = this this.parent = pivot // 4.挂载pivot if (!pivot.parent) { // pivot直接作为tree的根 return pivot } else if (isLeft) { // pivot作为父节点的左子节点 pivot.parent.left = pivot } else if (isRight) { // pivot作为父节点的右子节点 pivot.parent.right = pivot } return pivot } leftRotation() { const isLeft = this.isLeft const isRight = this.isRight // 1.处理pivot const pivot = this.right! pivot.parent = this.parent // 2.处理pivot的left this.right = pivot.left if (pivot.left) { pivot.left.parent = this } // 3.处理root(this) pivot.left = this this.parent = pivot // 4.挂载整颗子树pivot if (!pivot.parent) { return pivot } else if (isLeft) { pivot.parent.left = pivot } else if (isRight) { pivot.parent.right = pivot } return pivot } } ``` ### 10.2.6 4种失衡处理 到目前为止,我们已经实现了两种基础旋转:右旋 rightRotation()(用于 LL 直线型失衡)与左旋 leftRotation()(用于 RR 直线型失衡)。接下来要完成的“步骤三”,并不是再发明新的旋转动作,而是把它们组合起来,形成四种失衡方向对应的四种旋转策略:**LL、LR、RR、RL**。其中 LL 与 RR 只需要一次旋转;LR 与 RL 属于折线型失衡,需要两次旋转(先把折线“拉直”,再做一次整体旋转),因此本质上仍然是对 rightRotation() 和 leftRotation() 的恰当调用与组合。 需要特别澄清的是:旋转虽然看起来像是在“调整整棵 AVL 树”,但在实现上它只作用于某个最小失衡子树。因此我们把 rightRotation() 和 leftRotation() 设计为“节点方法”是合理的——它们负责完成以某个失衡节点为根的局部重构,并返回该局部子树旋转后的新根节点。然而,决定何时旋转、对哪个节点旋转、以及旋转后的新根如何挂回整棵树,这些都是“树级别”的职责,不能靠节点自己完成。换句话说:节点负责“怎么转”,AVL 树负责“什么时候转、转哪里、转完怎么接回去”。 因此,下一步需要封装一个 AVLTree(或 AVLTree)类,持有整棵树的 root,并在其中实现插入方法 insert()。只有实现插入(以及后续的删除),我们才能在每次结构性修改后沿路径回溯,更新高度/平衡因子,找到第一个失衡节点,并依据其失衡方向(LL/LR/RR/RL)调用 rightRotation() 与 leftRotation() 完成修复。也就是说:先有树结构与插入流程,才有“根据四种失衡情况选择旋转”的触发时机与落点,旋转逻辑才能真正跑起来,成为一棵完整可用的 AVL 树。 AVL树直接继承自之前实现的二叉搜索树。 ```typescript import { BSTree } from "./00_二叉搜索树BSTree"; class AVLTree<T> extends BSTree<T> { } const avlTree = new AVLTree<number>() ``` 由于AVL树直接继承自二叉搜索树,所以哪怕AVL树暂时只有一个空框架,也可以正常使用。但为了契合AVL树的特性(自平衡),还是需要做一些调整。例如在10.2.5.5小节中,有说明节点暂时没有parent属性,需要通过插入方法来补充。 这里有一个抉择,是要先完成插入方法来实现一棵AVL树还是先完成4种失衡情况的处理? * 其实都可以,如果我们是自行推演,基本上就需要先完成插入方法。先让插入流程跑通并能回溯定位失衡点,再实现四种失衡选择与组合旋转;否则会在缺少上下文的情况下“写不完整、写了也用不上”。如果先写4种失衡处理,只能写成一堆“给定 root/pivot 的手工旋转演示”,最后仍然要回头补:如何在插入时找到这些节点、如何维护parent、旋转后如何把新的子树根接回去(尤其是处理旋转节点原来在父节点的 left 还是 right、以及旋转发生在根节点时更新 tree.root)。这会导致写的失衡处理代码缺少真实调用场景,容易反复改动。 * 比较合理的推进顺序可以是:先搭好 AVLTree 框架(root、节点带 parent、基础 BST 插入),让插入至少能把节点按 BST 规则挂上并补齐 parent;然后在插入的回溯阶段加入 rebalanceFrom(node) 这样的树级方法:沿父链向上更新高度/平衡因子,遇到第一个失衡节点就根据(LL/LR/RR/RL)调用已经写好的 rightRotation()/leftRotation()(节点方法),并把旋转后的新子树根接回到父节点或更新根。这样写“四种失衡处理”时,每一行代码都有明确的触发时机与输入输出,整体会顺很多,也更不容易返工。 但我们不是开拓者,AVL树的前路都已经被踏平了,我们只需要沿着已经完善的AVL树进行学习,并不会反复改动。因此在这里先完成4种失衡情况的处理。这种想法在学习层面是合理的,尤其当我们的目标是“把AVL树的旋转规则彻底搞明白”,而不是立刻做出一棵可用的 AVLTree。先把 LL/LR/RR/RL 的判定与对应旋转组合写清楚(哪怕暂时是伪代码或只接受 root/pivot 的函数),能减少在实现细节(parent、回溯、挂接)上的干扰,让注意力集中在“为什么这样转、该怎么转”这条主线。 4种失衡情况如图10-4所示。我们需要了解左右情况和右左情况要如何旋转。PS:其实这两者差不多,了解其中一个就行,以左右情况为例。 左右情况-LR(Left-Right)型失衡如图10-8所示,需要先左旋转再右旋转。  **图10-8 左右情况-LR(Left-Right)型失衡** 从图10-8可以看出,失衡节点只有根节点5,则枢轴点是节点3。可为什么在第一次左旋转时,是以节点3作为失衡节点,以节点4作为枢轴点?这正是左右情况所需要理解的概念。 在AVL树中,一次旋转(无论左旋还是右旋)本质上只能解决一种问题:把同一方向上连续增长的高度压短。这也是为什么 LL 和 RR 这类“直线型失衡”可以通过一次旋转恢复平衡——它们的高度增长路径在结构上是一条单向的直线。 重复一遍,一次旋转只能压短一条直线路径。而在 LR(或 RL)型失衡中,高度增长路径是**折线型的**,例如 LR 情况下路径是“左 → 右”。这意味着失衡并不是简单地向某一侧倾斜,而是先向左、再向右发生了转折。如果此时强行对失衡节点做一次单旋转,只能消除其中一个方向的倾斜,另一侧的高度问题仍然存在,甚至可能引入新的失衡节点。 因此,AVL 树采用“先转直、再压缩”的策略。第一次旋转(对失衡节点的子节点进行旋转)并不是为了立即恢复平衡,而是消除折线结构中的转折点,将高度增长路径转换为直线型结构;第二次旋转才是针对失衡节点本身,执行一次标准的单旋转,将过长的高度路径整体压短。这样分两步处理,才能在不破坏二叉搜索树有序性的前提下,确保所有相关节点的高度同时回到允许范围内。 所以如图10-8所示的第一次左旋转中,节点 3 并不是失衡节点,而是作为“折线转折点”被选为第一次旋转的操作节点。本质是为了将AVL树的高度增长路径转换为直线型结构,方便后续的处理。将AVL树结构的左右情况捋直,可以以折线转折点为界限,捋上方或者下方,将AVL树转变为左左情况或者右右情况。通常捋折线下方会更容易理解,因此我们采用该方法,当左右情况转变为左左情况,就很好处理了。 PS:我想你也注意到了,将AVL树抽象成形状,可以发觉旋转是对树结构的一次弯折,这有利于理解旋转的概念。以枢轴点作为转折点,向左边或者右边掰动。  **图10-9 AVL树结构-折线捋直** 在理解左右情况之后,右左情况也是一样的,只需要以高度增长路径中的折线转折点作为第一次旋转的操作节点作为第一次旋转的"失衡节点"(折线的转折点)进行右旋转,转变为右右情况之后处理。PS:建议一定要理解如图10-9的抽象形状,再区分4种失衡情况就非常好理解。 ### 10.2.7 失衡处理-代码实现 现在,先假设已经找到不平衡的节点,要让该节点变得平衡。 ```typescript import { BSTree } from "./00_二叉搜索树BSTree"; class AVLTree<T> extends BSTree<T> { // 如何去找到不平衡的节点??? 先不管 // 假设已经找到不平衡的节点,那么我们如何让这个节点变得平衡 } const avlTree = new AVLTree<number>() ``` 假设已经找到不平衡的节点了,那我们接下来要让整棵AVL树再次平衡。将使AVL树再次平衡的操作封装为rebalance()方法。 ```typescript // 假设已经找到了, 那么我们如何让这个节点变的平衡 /** * 根据不平衡的节点的情况(LL/RR/LR/RL)让子树平衡 * @param grand 找到的不平衡的节点 */ rebalance(grand: AVLTreeNode<T>) { } ``` 让整棵AVL树再次平衡所需要的节点一共三个:失衡节点,枢轴点(Pivot),以及第三个节点“枢轴点的子节点”,也就是失衡节点的孙节点,常被称为旋转子节点 / 中间节点 / pivot 的孩子。它并不一定失衡,但它决定了旋转是“直线型”还是“折线型”,从而决定需要一次旋转还是两次旋转。 如果按照如图10-9的抽象概念理解,第三个节点决定了树结构在面临转折点时,是要继续直行还是弯折,从而将两种旋转情况(左左、右右)扩充到了四种(左右,右左)旋转情况。PS:在理解了如图10-9所示的抽象概念后,AVL树的旋转与失衡处理已经对我没有任何难度了,如果你到此刻感受到难度的提升,我建议你可以稍微回头再温习一遍。 好的,调用rebalance()方法需要传入失衡节点。枢轴点为失衡节点的更高子节点,通过在AVLTreeNode节点类封装好的higherChild属性获取。枢轴点的子节点(第三个节点)也是同样的做法,调用枢轴点的higherChild属性获取。 ```typescript // 让整棵AVL树再次平衡所需要的节点一共三个:失衡节点,枢轴点(Pivot),以及第三个节点“枢轴点的子节点” // 失衡节点:grand rebalance(grand: AVLTreeNode<T>) { // 枢轴点:pivot const pivot = grand.higherChild // 枢轴点的子节点:current const current = pivot?.higherChild } ``` 接着判断4种失衡情况: (1)枢轴点位于失衡节点的哪一侧?左或者右。 (2)枢轴点的更高子节点位于枢轴点的哪一侧?左或者右。 通过以上两个问题,可以得知失衡情况的4种结合走向,从而针对性处理(使用不同的旋转方式将AVL树实现再平衡)。 ```typescript rebalance(grand: AVLTreeNode<T>) { const pivot = grand.higherChild const current = pivot?.higherChild if (pivot?.isLeft) { // 左 if (current?.isLeft) { // 左左情况 } else { // 左右情况 } } else { // 右 if (current?.isLeft) { // 右左情况 } else { // 右右情况 } } } ``` 将4种失衡情况分别处理就可以了。4种失衡情况的处理情况如下: (1)左左情况:以失衡节点为目标节点,右旋转一次。 (2)左右情况:以枢轴点为目标节点,左旋转一次;以失衡节点为目标节点,右旋转一次。 (3)右左情况:以枢轴点为目标节点,右旋转一次;以失衡节点为目标节点,左旋转一次。 (4)右右情况:以失衡节点为目标节点,左旋转一次。 PS:以上4种处理情况的分析,皆是通过图10-9直接理解而来,非死记硬背。 因此,只需要分别以失衡节点与枢轴点为目标节点去调用leftRotation()左旋转方法和rightRotation()右旋转方法,代码如下。 ```typescript rebalance(grand: AVLTreeNode<T>) { const pivot = grand.higherChild const current = pivot?.higherChild if (pivot?.isLeft) { // 左 if (current?.isLeft) { // 左左情况 grand.rightRotation() // 以失衡节点为目标节点,右旋转一次 } else { // 左右情况 pivot?.leftRotation() // 以枢轴点为目标节点,左旋转一次 grand.rightRotation() // 以失衡节点为目标节点,右旋转一次 } } else { // 右 if (current?.isLeft) { // 右左情况 pivot?.rightRotation() // 以枢轴点为目标节点,右旋转一次 grand.leftRotation() // 以失衡节点为目标节点,左旋转一次 } else { // 右右情况 grand.leftRotation() // 以失衡节点为目标节点,左旋转一次 } } } ``` 在编写左右旋转方法的第4步挂载pivot中(10.2.5.4小节与10.2.5.5小节),当旋转结束后,枢轴点替代了原先失衡节点的位置,需要将枢轴点接到原失衡节点的父节点身上。假如原失衡节点无父节点,则直接返回枢轴点。 此时我们需要在rebalance()方法中,进一步处理假如原失衡节点无父节点的情况(失衡节点为根节点)。调用左/右旋转方法后,会返回枢轴点本身,声明一个resultNode节点变量来接收调用左/右旋转方法后的枢轴点。判断resultNode节点变量是否有父节点(通过parent属性),若resultNode节点无父节点,则说明了原失衡节点无父节点的情况,直接将resultNode节点作为AVL树的根节点。 ```typescript rebalance(grand: AVLTreeNode<T>) { const pivot = grand.higherChild const current = pivot?.higherChild let resultNode: AVLTreeNode<T> | null = null if (pivot?.isLeft) { if (current?.isLeft) { resultNode = grand.rightRotation() } else { pivot?.leftRotation() resultNode = grand.rightRotation() } } else { if (current?.isLeft) { pivot?.rightRotation() resultNode = grand.leftRotation() } else { resultNode = grand.leftRotation() } // 判断返回的pivot是否有父节点 if (!resultNode.parent) { this.root = resultNode } } } ``` PS:如果this.root的root报错(无法访问),就将BSTree类种的root属性设置为protected(受保护)而非private(隐私)。 ### 10.2.8 AVL树的插入与再平衡 当AVL树因插入的新节点而导致不平衡(LL失衡),通过旋转操作再平衡如图10-10所示。  **图10-10 LL失衡(左左情况)-再平衡过程** 我们现在需要回头完成AVL树的插入方法(继承自二叉搜索树的插入方法无法满足需求)。继承的插入方法所创建节点的方式为: ```typescript insert(value: T) { // 1.根据传入value创建Node(TreeNode)节点 const newNode = new TreeNode(value) // 省略后续无关操作 } ``` AVL树的节点是AVLTreeNode,如果按插入方法的原有创建节点方式,所产生的节点不符合AVL树的需求。 对于该情况,为了兼容性(二叉搜索树与AVL树可以共用同一方法),我们就需要将创建节点交给外界决定,不直接在插入方法内写死创建节点。思路想好后,完善代码。 创建受保护(protected)的createNode()方法,该方法唯一作用是返回创建的节点。这么做之后,可以在继承自BSTree类的AVLTreeNode类中,重写createNode()方法,返回类型为AVLTreeNode的节点。 ```typescript protected createNode(value: T): TreeNode<T> { return new TreeNode(value) } insert(value: T) { // 1.根据传入value创建Node(TreeNode)节点 const newNode = this.createNode(value) // 省略后续无关操作 } ``` 在AVLTree类中,重写调用的createNode()方法如下所示。通过重写将树结构的创建节点类型交给继承自该树的子类,BSTree类的复用性会更强,但也意味着需要我们多分一份心思去关注节点的情况。PS:在AVLTree类中重写的createNode()方法所返回的是AVLTreeNode,而我们通过TypeScript设置的返回类型是TreeNode,而这是没有问题的。因为 AVLTreeNode 是 TreeNode 的子类型,子类方法可以返回“更具体的类型”,这在类型系统中称为“协变返回类型(covariant return type)”,也可以说是多态。在面向对象中,方法重写时允许返回类型变得更具体,只要这个返回值仍然“是父类所期望的那一类对象”。AVLTreeNode 继承自 TreeNode,因此任何 AVLTreeNode 在类型意义上也都是一个 TreeNode,不会破坏父类 BSTree 对返回值的使用假设。 ```typescript class AVLTree<T> extends BSTree<T> { // 重写调用的createNode方法 protected createNode(value: T): TreeNode<T> { return new AVLTreeNode(value) } // 省略其余内容 } ``` 在完成创建符合情况的节点后,AVL树的插入方法主要做以下3件事情: (1)给节点添加parent属性,将节点插入到AVL树。 (2)检测插入节点后的AVL树是否平衡,若不平衡,需获得失衡节点。 (3)调用rebalance()方法,传入失衡节点,完成AVL树的再平衡。 第一件事:给节点添加parent属性,方便节点可以找到自身的父节点。根节点无父节点,因此不需要处理根节点。 找到insert()插入方法中的insertNode()插入节点方法处,该方法是找到node节点,将新节点插入到node节点的左边或右边。此时node节点即新节点的父节点,让新节点的parent指向node节点(newNode.parent = node)。 ```typescript private insertNode(node: TreeNode<T>, newNode: TreeNode<T>) { if (newNode.value < node.value) { // 去左边继续查找空白位置 if (node.left === null) { // node节点的左边已经是空白 node.left = newNode // 设置parent属性 newNode.parent = node } else { this.insertNode(node.left, newNode) } } else { // 去右边继续查找空白位置 if (node.right === null) { node.right = newNode // 设置parent属性 newNode.parent = node } else { this.insertNode(node.right, newNode) } } } ``` 第二件事:插入节点方法完善后,已经具备构成AVL树的基础。每一次插入都需要检测AVL树是否平衡,这句话需要仔细理解,如果我们每次都把整棵AVL树的节点的平衡因子检查一遍,那样的效率很低。我们需要通过尽可能简便的方式来检测AVL树是否平衡以及失衡节点(不平衡的情况)。 插入造成的高度变化只会沿着插入点向上回溯传播,因此失衡节点一定在新节点的祖先链上。我们无需扫描整棵树,只要从新节点的父节点开始逐层向上更新高度与平衡因子,第一个出现失衡的节点就是旋转的根;pivot 与其更高侧子节点也都能由这个失衡节点在局部结构中确定。如果整条祖先链的平衡因子都没有问题,则说明插入新节点后的AVL树依旧保持着平衡。 我们插入节点是在insert()方法中通过递归的寻找AVL树的空白位置来插入。在插入节点之后,就可以立刻向上回溯祖先链来检测平衡因子。这里有两种做法: (1)在AVLTree类中,重写insert()方法,在insert()方法插入节点之后,检查AVL树是否平衡。 (2)在BSTree类中,调整insert()方法,在AVLTree中去检查AVL树是否平衡。 首先是做法一,检查AVL树是否平衡等同去找到不平衡的节点(找不到就说明AVL树处于平衡),需要封装成一个checkBalance()方法。checkBalance()方法要做的为:通过传入插入AVL树的新节点,通过节点的parent属性向整条祖先链往上攀升,每攀升一次节点就通过isBalanced检查一次平衡因子,如果没有问题就继续递归调用parent属性往上攀升,直到攀升到根节点或者找到了失衡节点,将失衡节点传给rebalance()方法调用旋转操作,实现AVL树的再平衡,通过break结束递归调用(因为已经找到失衡节点并实现AVL树的再平衡,达成目的,无需再向上寻找)。 ```typescript class AVLTree<T> extends BSTree<T> { insert(value: T) { super.insert(value) // 子类需要有实际的类型提示。 // 检查AVL树是否平衡 } // 如何去找到不平衡的节点 checkBalance(node: AVLTreeNode<T>) { let current = node.parent while (current) { if (!current.isBalanced) { this.rebalance(current) break // 实现AVL树的再平衡,结束递归调用 } current = current.parent } } } ``` 但checkBalance()方法需要我们拿到插入的新节点,而insert()方法是没有返回节点信息的,这需要我们回到insert()返回一下插入的节点,并且将返回的节点类型断言为AVLTreeNode ,因为父类方法的返回类型只保证“最小承诺”,子类在需要使用更具体能力时,必须显式收窄类型。更工程化的做法是让 BSTree 节点类型参数化,以避免类型断言。 ```typescript class AVLTree<T> extends BSTree<T> { insert(value: T) { const newNode = super.insert(value) as AVLTreeNode<T> this.checkBalance(newNode) return newNode // 在BSTree类的insert方法最后也需要return返回。 } // 如何去找到不平衡的节点 checkBalance(node: AVLTreeNode<T>) { let current = node.parent while (current) { if (!current.isBalanced) { this.rebalance(current) break } current = current.parent } } } ``` 但第一种做法有一个缺点,我们的insert()方法直接将插入的节点返回出去了,那开发者在使用插入方法时,就可以拿到插入的新节点本身。虽然该做法可以完成功能,但我们不应该暴露给外界非必要的节点信息。 此时进行第二种做法,我们不重写insert()方法了,直接在BSTree类的insert()方法中获取插入节点信息,然后"检测"是否平衡。该做法无需返回节点信息,因此不会暴露给外界不必要的内容。 ```typescript // 请注意,这是是在BSTree类中 protected checkBalance(node: TreeNode<T>, isAdd = true) {} /** 插入数据的操作 */ insert(value: T) { // 1.根据传入value创建Node(TreeNode)节点 const newNode = this.createNode(value) // 2.判断当前是否已经有了根节点 if (!this.root) { // 当前树为空 this.root = newNode } else { // 树中已经有其他值 this.insertNode(this.root, newNode) } // 3.检测树是否平衡 this.checkBalance(newNode) } // 完成以上操作后,无需重写AVLTree类的insert()方法,而是重写checkBalance()方法 ``` 在BSTree类中,我通过checkBalance()方法检测了AVL树是否平衡。可普通的二叉搜索树并不需要这一项功能,因此在BSTree类的checkBalance()方法内部是放空的,无实际内容。该做法是为了在AVLTree类中重写checkBalance()方法,在重写的checkBalance()方法中,可以调用传入的参数(即插入的新节点信息),完成AVL树节点的平衡因子检测。 测试代码如下: ```typescript const avlTree = new AVLTree<number>() for (let i = 0; i < 20; i++) { avlTree.insert(Math.floor(Math.random() * 200)) } avlTree.print() ``` ### 10.2.9 AVL树的删除与再平衡 AVL树的删除案例如图10-11所示,假设将红色节点删除,AVL树会呈现失衡状态(RR失衡),在删除操作下,如何完成AVL树的再平衡?  **图10-11 AVL树的删除** 删除子节点,有可能打破父节点原有的平衡,而对同层级的兄弟节点无影响,需明确问题所在。当删除目标子节点,需从操作位置向上回溯的路径中检测每个节点是否处于失衡状态。若失衡,则对处于失衡状态的节点做出旋转处理;若未失衡,则无需处理。 对于以上思路,需要先获取被删除的子节点,从传入remove()删除方法的值可知所删除的目标节点,可从remove()方法内部定义一个delNode变量用于获取被删除的子节点信息。 ```typescript remove(value: T): boolean { // 省略其余无关内容 const current = this.searchNode(value) let delNode: TreeNode<T> = current } ``` 但还有一个问题,在图 10-11 所示的结构中,如果删除节点 25,不能简单地将该节点及其子节点一并删除。因为在二叉搜索树(包括 AVL 树)中,删除某个节点时,必须保证原有子树结构被正确保留并重新挂接,而不能破坏整棵树的有序性。 在该例中,节点 25 只有一个子节点 12。根据二叉搜索树的删除规则,当被删除节点只有一个子节点时,应当用其子节点“顶替”被删除节点的位置。因此,正确的做法是:将节点 12 提升到原来 25 所在的位置,即让 50 的左子节点改为指向 12,同时将 12 的 parent 指针更新为 50。这样处理后,整棵树仍然满足二叉搜索树的有序性。如果节点 12 还存在自己的子节点,也应当一并保留,其结构无需额外调整,只需确保父子指针关系正确即可。 在BSTree类的的remove()方法中,current变量是remove()方法所删除的目标节点,而replaceNode是current的子节点(唯一)。因此要做到子节点的“顶替”需要确保两个条件: (1)被删除的目标节点current存在子节点。PS:即被删除的目标节点不是叶子节点。 (2)被删除的目标节点current存在父节点。 current的子节点与父节点同时存在,才能将current的子节点"顶替"到current原先的位置上。如图10-11所示,假如节点25是被删除的节点current,则replaceNode是节点12,current.parent是节点50。完成筛选判断后,让节点12指向节点50即可。 PS:在remove()方法判断replaceNode之前,已经做到current.parent!.left = replaceNode,即节点50的左子节点指向节点12。因此后续判断replaceNode判断中只是补齐“反向指针”,使其完善。 ```typescript // 判断replaceNode if (replaceNode && current.parent) { // replaceNode.parent:节点12 // current.parent:节点50 replaceNode.parent = current.parent } ``` 如果被删除的目标节点有前驱节点和后继节点,那需要怎么做?如图10-12所示的删除节点15,存在前驱节点11与后继节点13与20。  **图10-12 二叉搜索树-覆盖前(原始状态)** 之前的做法是,将节点18放置(覆盖)到被删除节点15的位置,然后将节点20的左子节点指向节点19,操作如图10-13所示。该方式比直接将节点15设置一个isDelete布尔值属性(用于控制是否访问该节点)来得更好,因为设置是否访问节点的做法,无法真正删除节点,删除操作无法让二叉搜索树体积缩小。覆盖虽然也不是完美的方式,但效果一致,并且可以简化较多代码(无需考虑父节点指向问题),因此是一个可行的折中方案。  **图10-13 二叉搜索树-覆盖后(节点18顶替节点15,节点19顶替节点18)** 采用以上折中方案,需来到BSTree类的remove()方法,修改以下代码。 ```typescript remove(value: T): boolean { // 省略无关内容 let replaceNode: TreeNode<T> | null = null if (current.left === null && current.right === null) { } else if (current.right === null) { } else if (current.left === null) { } else { // 当被覆盖的节点存在左右子节点 const successor = this.getSuccessor(current) // 继承的节点覆盖被删除的节点 current.value = successor.value } } ``` 同时,需要修改getSuccessor()方法内的部分内容,当我们拿到后继节点18,要对节点18的子节点19处理,替代节点18原有的位置。做出如下4步操作(用图10-13的示例): (1)删除successor!.left = delNode.left。该代码作用是将节点15的左子树接到新的替代节点上,但由于采用覆盖做法,只将节点15的值覆盖为18,节点本身无变化,因此无需将节点15的左子树接到新的节点上。 (2)当找到后继节点 18 后,需要处理它在原位置的“补位”问题。由于后继节点是右子树中最小的节点,它一定没有左子节点,但可能存在右子节点(图 10-13 中就是 19)。因此,我们要做的是:让 18 的父节点原本指向 18 的那条边,改为指向 18.right(也就是 19),从而实现“用 19 顶替 18 的位置”。在代码层面就是把 successor.parent.left = successor.right(当 18 不是 delNode.right 时)这一类操作落实到位。 (3)仅仅让父节点改指向还不够,还必须同步修复“反向指针”。也就是说,如果 18 的右子节点 19 存在,那么 19.parent 原本指向 18,现在应该改为指向 18 的父节点。否则树的结构会出现“父节点已经换了孩子,但孩子还以为自己的父亲是旧节点”的断链问题。对应代码就是:if (successor.right) successor.right.parent = successor.parent。这样才能保证父子双向引用一致,后续回溯(尤其 AVL 的再平衡回溯)才不会出错。 (4)最后,需要区分一种特殊情况:当后继节点 18 恰好就是 delNode.right(即后继节点就是被删节点的直接右孩子)时,它并不是某个节点的左孩子,而是 delNode 的右孩子。这时“补位”的连接点不再是 successor.parent.left,而应该直接更新 delNode.right:让 delNode.right = successor.right,并在 successor.right 存在时同步更新其 parent = delNode。这样仍然实现同一个目标:把 18 从原结构中移除,由 19(或空)接管它的位置,同时保持 BST 的有序性与指针完整性。 ```typescript // 拿到了后继节点 if (successor !== delNode.right) { successor!.parent!.left = successor!.right if (successor?.right) { successor.right.parent = successor.parent } } else { delNode.right = successor!.right if (successor!.right) { successor!.right.parent = delNode } } ``` 当使用remove()方法删除节点后,将后继节点successor赋值到被删除位置的节点就行。并且如果来到最后一种情况(有两个子节点)。 ```typescript remove(value: T): boolean { // 省略无关内容 let replaceNode: TreeNode<T> | null = null if (current.left === null && current.right === null) { } else if (current.right === null) { } else if (current.left === null) { } else { const successor = this.getSuccessor(current) current.value = successor.value // 给delNode赋值 delNode = successor // 确保删除节点之后的 再平衡没有问题后,返回。 this.checkBalance(delNode, false) return true } } ``` 那不应该让下述代码执行。因为最后一种情况的replaceNode为null,或者用以下两种方式来补充: (1)在上述delNode = successor之后补充replaceNode = current。 (2)在上述delNode = successor之后补充this.checkBalance(delNode, false)和返回true。采用此方式。 因为节点18替换到节点15之后,只是单纯替换值,无需再让节点11去指向被替换值的节点15。 ```typescript if (current === this.root) { this.root = replaceNode } else if (current.isLeft) { current.parent!.left = replaceNode } else { current.parent!.right = replaceNode } ``` AVL树的删除与再平衡就结束了。由于调整原先代码过多,导致思路整体较为复杂,需要额外去多理清两遍。整体代码过多,这里不放出,可以联系我获取完整的代码文件。并且直接替换节点值,保留原先的AVL树指向关系,可以避免去重构节点之间的互相指向问题,节约很多功夫。但由于覆盖节点的方式与一开始的思路不一致,所以在BSTree类,修改了很多内容,这明显带来压力,因此在开写代码之前,想清楚整体结果与做法非常重要,值得多分配一些时间。 remove()方法最终所实现的是两点: (1)将节点删除。PS:考虑删除节点时,父节点的指向问题需要设置的太多。因此采用覆盖的方式来平替,效果是一致的。覆盖效果是直接用后续节点的value去替代删除节点的value。 (2)删除节点之后的再平衡。PS:checkBalance(delNode)。 ### 10.2.10 AVL树添加和删除的检查平衡区分 在 AVL 树中,“插入”和“删除”都会沿着修改点向上影响祖先节点的高度,但二者对平衡的影响方式不同,因此**检查与修复平衡的策略也不同**。插入操作会在某条路径上增加高度:从新节点开始向上回溯,某个祖先节点可能首次出现失衡(平衡因子绝对值大于 1)。一旦找到这条回溯链上的第一个失衡节点并完成旋转,局部子树的高度通常会被恢复到插入前的水平或至少不再继续向上增加,从而使更高层祖先节点不再继续恶化。 ```typescript // 如何去找到不平衡的节点 checkBalance(node: AVLTreeNode<T>, isAdd = true) { let current = node.parent while (current) { if (!current.isBalanced) { this.rebalance(current) // 该位置为旋转完成后的操作 } current = current.parent } } ``` 正因如此,插入场景下常见做法是:**第一次旋转修复完成后就可以停止回溯**,也就是以下代码中的 `if (isAdd) break`,避免不必要的继续检查。 ```typescript // 如何去找到不平衡的节点 checkBalance(node: AVLTreeNode<T>, isAdd = true) { let current = node.parent while (current) { if (!current.isBalanced) { this.rebalance(current) // 这个位置时旋转完成后的操作 // break决定不会进一步去查找父节点有没有平衡的情况了 // 添加的情况是不需要进一步向上查找的, 直接break // 删除的情况是需要进一步向上查找的, 不能break if (isAdd) break } current = current.parent } } ``` 删除操作则不同。删除往往会使某条路径上的高度**减少**,而这种高度减少可能逐层向上传导,引发“连锁式”的平衡变化:即便在某个节点处通过一次旋转恢复了平衡,旋转后的子树高度仍可能继续降低,进而导致其父节点在随后步骤中变得失衡。因此,删除场景的再平衡不能像插入那样“修一次就结束”,而必须继续沿祖先链向上检查,必要时在多个节点处重复旋转,直到回溯到根节点或整条路径重新稳定为止。这就是 `checkBalance(node, isAdd=false)` 时不能 break 的根本原因:删除的修复可能需要多次、分层次地进行,才能保证整棵 AVL 树最终恢复平衡。PS:在10.2.9的末尾使用checkBalance的第二参数传入false即是决定是否要在旋转一次后终止继续向上检查。 10.3 红黑树详解 ---------- 红黑树是数据结构中很难的一个知识点,难到什么程度呢?基本你跟别人聊数据结构的时候, 他不会和你聊红黑树, 因为它是数据结构中一个难点中的难点。数据结构的学习本来就比较难了, 红黑树是又将难度上升一个档次的知识点。 面试的时候经常出现这个场景: * 面试官: 你知道红黑树吗? * 面试者: 知道啊。 * 面试官: 知道原理吗? * 面试者: 不知道啊。 * 面试官: 那你让‘不’过来面试我们公司吧,你先回去等通知吧。 那么哪些面试会出现红黑树相关的题目呢?在面试时基本不会让手写红黑树(即使是面试Google、Apple这样的公司,也很少会出现)。通常是这样问题的(比如腾讯的一次面试题):为什么已经有平衡二叉树(比如AVL树)了,还需要红黑树呢? ### 10.3.1 红黑树的五条性质 在所有的二叉查找树中,有一个问题几乎无法回避——树会"长歪"。当数据按顺序插入时,普通的二叉查找树会退化成一条链,查找效率从 O(log n) 跌落至 O(n)。为了解决这个问题,计算机科学家们发明了各种"自平衡"机制,红黑树便是其中最广为人知的一种。它由鲁道夫·贝尔于 1972 年发明,最初被称为"对称二叉 B 树",直到 1978 年 Leo J. Guibas 与罗伯特·塞奇威克在论文中引入红与黑的染色方案,才有了今天这个更具辨识度的名字。 红黑树本质上仍是一棵二叉查找树,左小右大的基本规则一脉相承。它的特别之处在于,每个节点都被赋予一种颜色——红色或黑色。颜色本身没有任何实际含义,它只是一种编码手段,用来携带关于树结构的元信息,从而让树在插入和删除时能够"感知"自己是否还保持平衡。 要理解红黑树为何能保持平衡,需要先理解它对颜色分布施加的5条约束: (1)节点只能红色或者黑色。 (2)根节点必须是黑色的,这为整棵树提供了一个稳定的起点。约束二:节点只能红色或者黑色。 (3)每个叶子节点都是黑色的空节点(NIL节点,空节点)。 (4)每个红色节点的两个子节点都是黑色。(换句话说,红色节点不能相邻出现,从叶子节点到根的所有路径上不允许存在两个连续的红色节点)。 (5)从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。这个数量被称为叶子节点的"黑色高度"。 约束3要求每个叶节点(空节点)是黑色的,这是因为在红黑树中,黑色节点的数量表示从根节点到叶子节点的黑色节点数量。这个数量叫做“黑高(black-height)”。为了让这个规则在所有路径上都成立,就必须保证每条路径的终点(NIL)都是黑色。否则,有的路径以黑色结束,有的路径以红色结束,黑色节点数量就无法统一,从而破坏红黑树的平衡约束。 所以最后的NIL节点,是为了让“黑色高度”这个概念在结构上成立(约束5),而"黑色高度"这个概念是为了限制树的高度,使红黑树保持近似平衡。当然,黑色高度只需要概念的存在就可以帮助到我们(因为黑色高度是一种计数规则,规则不依赖真实存在的节点),因此在代码中可以用 null 代替 NIL节点的表达,但在理论分析中必须把它当作黑色节点看待。 约束4和约束5是两条最关键的约束。 这两条规则叠加在一起,产生了一个精妙的效果:假设树中最短的路径全由黑色节点构成,那么最长的路径也不过是黑色与红色交替出现——而由于红色不能连续,最长路径最多是最短路径的两倍。整棵树的高度因此被控制在 O(log n) 的量级之内,平衡性得到了保证。 表面上看,这五条规则繁琐而抽象,像是凭空堆砌的限制。但它们共同指向同一个目标:让树上任何路径的长度不能相差太悬殊。这些规则会让人一头雾水,完成搞不懂规则叠加起来,怎么让一棵树平衡的。但是它们还是被一些聪明的人发明出来了。  **图10-14 红黑树示例** 红黑树示例如图10-14所示。在红黑树中,所谓“叶子节点”并不是我们通常理解的“没有左右子节点的实际数据节点”,而是指所有的空指针位置。也就是说,每个 null 都被视为一个特殊的节点,称为 **NIL 节点**。这些 NIL 节点是逻辑上存在的,并且统一规定为黑色。因此,一棵红黑树在结构上,其实是“实节点 + 若干黑色 NIL 节点”共同组成的。 红色节点的作用是让我们尽量少去调整这棵树。 ### 10.3.2 红黑树的相对平衡性 通过10.3.1小节的五条性质,确保了红黑树的关键特性:从根到叶子的最长可能路径, 不会超过最短可能路径的两倍长。结果就是这个树基本是平衡的,虽然也没有做到绝对的平衡,但是可以保证在最坏的情况下, 性能依然是高效的。 最长路径不超过最短路径的两倍,意味着红黑树绝对不可能退化至链表或者近似链表的形态,也是保持树平衡的核心特性,但这个特性是怎么做到的?因为性质5决定了最短路径和最长路径必须有相同的黑色节点: * 路径最短的情况:全部是黑色节点 n。 * 路径最长的情况:黑色接电的数量也是n,中间全部是红色节点 n – 1。该情况由以下3特性组成:性质2:根节点是黑节点。性质3:叶子节点都是黑节点。性质4:两个红色节点不能相连。 因此最短路径的边为n-1(节点与节点之间的连线视为边),最长路径为(n+n-1)-1 = 2n-2=2(n-1)。所以最长路径一定不超过最短路径的两倍。 ### 10.3.3 手写红黑树 手写一个 TypeScript 红黑树的详细步骤可以像AVL树一样拆分为如下6大步骤: (1)定义红黑树的节点:定义一个带有键、值、颜色、左子节点、右子节点和父节点的类。 (2)实现左旋操作:将一个节点向左旋转,保持红黑树的性质。 (3)实现右旋操作:将一个节点向右旋转,保持红黑树的性质。 (4)实现插入操作:在红黑树中插入一个新的节点,并保持红黑树的性质。 (5)实现删除操作:从红黑树中删除一个节点,并保持红黑树的性质。 (6)实现修复红黑树性质:在插入或删除操作后,通过旋转和变色来修复红黑树的性质。 其他方法较为简单,可以自行实现。红黑树的完整代码如下(附带注释)。 #### 10.3.3.1 定义红黑树的节点 使用 TypeScript 的泛型编写红黑树的节点。 ```typescript enum Color { RED, BLACK, } class RedBlackNode<T> { value: T; color: Color; parent: RedBlackNode<T> | null; left: RedBlackNode<T> | null; right: RedBlackNode<T> | null; constructor( value: T, color: Color = Color.RED, parent: RedBlackNode<T> | null = null, left: RedBlackNode<T> | null = null, right: RedBlackNode<T> | null = null ) { this.value = value; this.color = color; this.parent = parent; this.left = left; this.right = right; } } ``` #### 10.3.3.2 红黑树的结构封装 红黑树的整体结构: ```typescript class RedBlackTree<T> { root: RedBlackNode<T> | null = null; // 查找某个节点再红黑树中的最小值 minimum(node: RedBlackNode<T> | null = this.root): RedBlackNode<T> | null { let current = node; while (current && current.left) { current = current.left; } return current; } // 查找红黑树中的某个节点 private search(value: T): RedBlackNode<T> | null { let node = this.root; let parent: RedBlackNode<T> | null = null; while (node) { if (node.value === value) { node.parent = parent; return node; } parent = node; if (value < node.value) { node = node.left; } else { node = node.right; } } return null; } } export {}; ``` #### 10.3.3.3 红黑树的旋转操作 实现左旋转和右旋转操作: ```typescript /** * 左旋操作 * * @param node 要进行左旋的结点 */ private leftRotate(node: RedBlackNode<T>) { // 获取 node 的右子节点 let rightChild = node.right!; // 将右子节点的左子节点赋值给 node 的右子节点 node.right = rightChild.left; // 如果右子节点的左子节点不为空,则将右子节点的左子节点的父节点指向 node if (rightChild.left) { rightChild.left.parent = node; } // 将右子节点的父节点指向 node 的父节点 rightChild.parent = node.parent; // 如果 node 的父节点为空,则将右子节点设为根结点 if (!node.parent) { this.root = rightChild; } // 如果 node 是它父节点的左子节点,则将右子节点设为 node 父节点的左子节点 else if (node === node.parent.left) { node.parent.left = rightChild; } // 否则,将右子节点设为 node 父节点的右子节点 else { node.parent.right = rightChild; } // 将 node 的父节点指向 rightChild,并将 rightChild 的左子节点指向 node rightChild.left = node; node.parent = rightChild; } /** * 右旋转 * @param node 旋转节点 */ private rightRotate(node: RedBlackNode<T>) { // 获取旋转节点的左子节点 let leftChild = node.left!; // 将旋转节点的左子节点的右子节点,接到旋转节点的左边 node.left = leftChild.right; // 如果左子节点的右子节点不为空,设置它的父节点为旋转节点 if (leftChild.right) { leftChild.right.parent = node; } // 将左子节点的父节点设为旋转节点的父节点 leftChild.parent = node.parent; // 如果旋转节点的父节点不存在,说明左子节点变成根节点 if (!node.parent) { this.root = leftChild; } else if (node === node.parent.right) { // 如果旋转节点是它父节点的右子节点,将父节点的右子节点设为左子节点 node.parent.right = leftChild; } else { // 如果旋转节点是它父节点的左子节点,将父节点的左子节点设为左子节点 node.parent.left = leftChild; } // 将旋转节点设为左子节点的右子节点 leftChild.right = node; // 将旋转节点的父节点设为左子节点 node.parent = leftChild; } ``` #### 10.3.3.4 红黑树的插入操作 实现插入操作,并且插入后实现红黑树的平衡和保持性质: ```typescript insert(value: T) { // 创建一个新节点 let newNode = new RedBlackNode(value); // 如果红黑树为空,将该节点作为根节点 if (!this.root) { this.root = newNode; // 根节点为黑色 newNode.color = Color.BLACK; return; } // 初始化搜索变量current和parent let current: RedBlackNode<T> | null = this.root; let parent: RedBlackNode<T> | null = null; // 搜索合适的插入位置 while (current) { parent = current; // 如果value小于当前节点,则继续往左子树搜索 if (value < current.value) { current = current.left; // 否则继续往右子树搜索 } else { current = current.right; } } // 将新节点的父节点设置为搜索到的父节点 newNode.parent = parent; // 将新节点插入到合适的位置 if (value < parent!.value) { parent!.left = newNode; } else { parent!.right = newNode; } // 修复插入导致的红黑树性质破坏 this.fixInsertion(newNode); } private fixInsertion(node: RedBlackNode<T>) { // 当父节点存在且颜色为红时 while (node.parent && node.parent.color === Color.RED) { // 获取祖父节点 let grandParent = node.parent.parent!; // 父节点是祖父节点的左子节点 if (node.parent === grandParent.left) { // 获取叔叔节点 let uncle = grandParent.right; // 叔叔节点存在且颜色为红 if (uncle && uncle.color === Color.RED) { // 将父节点颜色改为黑,叔叔节点颜色改为黑,祖父节点颜色改为红,node节点变为祖父节点,继续循环 node.parent.color = Color.BLACK; uncle.color = Color.BLACK; grandParent.color = Color.RED; node = grandParent; } else { // 当前节点是父节点的右子节点 if (node === node.parent.right) { // 将当前节点变为父节点,进行左旋操作 node = node.parent; this.leftRotate(node); } // 将父节点颜色改为黑,祖父节点颜色改为红,进行右旋操作 node.parent!.color = Color.BLACK; grandParent.color = Color.RED; this.rightRotate(grandParent); } } else { // 父节点是祖父节点的右子节点,与上面的同理 let uncle = grandParent.left; // 如果叔叔节点是红色的 if (uncle && uncle.color === Color.RED) { // 父节点设置为黑色 node.parent.color = Color.BLACK; // 叔叔节点设置为黑色 uncle.color = Color.BLACK; // 祖父节点设置为红色 grandParent.color = Color.RED; // 将当前节点设置为祖父节点 node = grandParent; } else { // 如果当前节点是父节点的左节点 if (node === node.parent.left) { // 将当前节点设置为父节点 node = node.parent; // 右旋父节点 this.rightRotate(node); } // 父节点设置为黑色 node.parent!.color = Color.BLACK; // 祖父节点设置为红色 grandParent.color = Color.RED; // 左旋祖父节点 this.leftRotate(grandParent); } } } // 根节点设置为黑色节点 this.root!.color = Color.BLACK; } ``` #### 10.3.3.5 红黑树的删除操作 ```typescript /** * 删除红黑树中的某个节点 * * @param value 要删除的节点的值 */ delete(value: T) { // 先找到要删除的节点 const nodeToDelete = this.search(value); // 如果不存在,就直接退出 if (!nodeToDelete) { return; } // 否则删除节点 this._delete(nodeToDelete); } /** * 删除红黑树中的节点 * @param node 要删除的节点 */ private _delete(node: RedBlackNode<T>) { // 如果该节点同时存在左右节点,则找到右子树的最小节点作为该节点的后继 if (node.left && node.right) { const successor = this.minimum(node.right); node.value = successor!.value; node = successor!; } let child: RedBlackNode<T> | null; // 如果该节点存在左节点,则将该左节点作为它的唯一子节点 if (node.left) { child = node.left; } else if (node.right) { // 如果该节点存在右节点,则将该右节点作为它的唯一子节点 child = node.right; } else { child = null; } // 如果该节点没有子节点,直接删除 if (!child) { // 如果该节点是黑色,则需要特殊处理 if (node.color === Color.BLACK) { this._deleteCase1(node); } this._removeNode(node); } else { // 如果该节点是黑色,则需要特殊处理 if (node.color === Color.BLACK) { // 如果该节点的唯一子节点是红色,则将该唯一子节点设置为黑色 if (child.color === Color.RED) { child.color = Color.BLACK; } else { this._deleteCase1(node); } } // 用该节点的唯一子节点替换该节点 this._replaceNode(node, child); } } private _deleteCase1(node: RedBlackNode<T>) { // 如果有父节点,就进入 Case 2 if (node.parent) { this._deleteCase2(node); } } private _deleteCase2(node: RedBlackNode<T>) { // 找到兄弟节点 const sibling = this._sibling(node); // 如果兄弟节点存在且颜色为红色 if (sibling && sibling.color === Color.RED) { // 父节点颜色变为红色 node.parent!.color = Color.RED; // 兄弟节点颜色变为黑色 sibling.color = Color.BLACK; // 如果删除的节点是左子节点 if (node === node.parent!.left) { // 则向左旋转 this.leftRotate(node.parent!); } else { // 否则向右旋转 this.rightRotate(node.parent!); } } this._deleteCase3(node); } private _deleteCase3(node: RedBlackNode<T>) { const sibling = this._sibling(node); // 当父节点颜色是黑色,兄弟节点颜色是黑色,兄弟节点的左右子节点都是黑色 if ( node.parent!.color === Color.BLACK && sibling && sibling.color === Color.BLACK && (!sibling.left || sibling.left.color === Color.BLACK) && (!sibling.right || sibling.right.color === Color.BLACK) ) { // 将兄弟节点颜色设置为红色 sibling.color = Color.RED; // 递归处理父节点 this._deleteCase1(node.parent!); } else { // 进入下一个情况 this._deleteCase4(node); } } private _deleteCase4(node: RedBlackNode<T>) { const sibling = this._sibling(node); // 当父节点为红色,兄弟节点为黑色,且兄弟节点的左右子树为黑色时 if ( node.parent!.color === Color.RED && sibling && sibling.color === Color.BLACK && (!sibling.left || sibling.left.color === Color.BLACK) && (!sibling.right || sibling.right.color === Color.BLACK) ) { // 将兄弟节点涂红色 sibling.color = Color.RED; // 父节点涂黑色 node.parent!.color = Color.BLACK; } else { // 否则进入下一个删除 case this._deleteCase5(node); } } private _deleteCase5(node: RedBlackNode<T>) { const sibling = this._sibling(node); if (sibling && sibling.color === Color.BLACK) { // 如果当前节点是它父的左节点,并且兄弟节点的右节点存在且为红色 if ( node === node.parent!.left && sibling.right && sibling.right.color === Color.RED ) { // 将兄弟节点的颜色设置为红色 sibling.color = Color.RED; // 兄弟节点的右节点设置为黑色 sibling.right!.color = Color.BLACK; // 对兄弟节点进行左旋 this.leftRotate(sibling); } else if ( node === node.parent!.right && sibling.left && sibling.left.color === Color.RED ) { // 同上 sibling.color = Color.RED; sibling.left!.color = Color.BLACK; this.rightRotate(sibling); } } this._deleteCase6(node); } private _deleteCase6(node: RedBlackNode<T>) { const sibling = this._sibling(node); // 将兄弟节点颜色设置成父节点颜色 sibling!.color = node.parent!.color; // 将父节点颜色设置成黑色 node.parent!.color = Color.BLACK; if (node === node.parent!.left) { // 将兄弟节点的右子节点颜色设置成黑色 sibling!.right!.color = Color.BLACK; // 对父节点左旋 this.leftRotate(node.parent!); } else { // 将兄弟节点的左子节点颜色设置成黑色 sibling!.left!.color = Color.BLACK; // 对父节点右旋 this.rightRotate(node.parent!); } } private _removeNode(node: RedBlackNode<T>) { if (!node.parent) { this.root = null; } else if (node === node.parent.left) { node.parent.left = null; } else { node.parent.right = null; } } private _replaceNode(oldNode: RedBlackNode<T>, newNode: RedBlackNode<T>) { if (!oldNode.parent) { this.root = newNode; } else if (oldNode === oldNode.parent.left) { oldNode.parent.left = newNode; } else { oldNode.parent.right = newNode; } newNode.parent = oldNode.parent; } private _sibling(node: RedBlackNode<T>) { if (!node.parent) { return null; } return node === node.parent.left ? node.parent.right : node.parent.left; } ``` 10.4 AVL树与红黑树对比 --------------- ### 10.4.1 性能对比分析 事实上,红黑树的性能在搜索上是不如AVL树的,为什么呢?  **图10-15 红黑树性能比对** 如图10-15是一棵红黑树,如果我们插入节点30,会被插入到哪里呢?节点27的右边,并且节点30是红色节点时,依然符合红黑树的性质(不存在“红红冲突”,每条路径的黑节点数量没有改变),因此整棵树仍然满足红黑树五条性质,也就是对于红黑树来说,它不需要进行任何操作。PS:插入节点时,默认都是红色节点,因为如果默认插入为黑色,几乎一定会破坏"黑色高度",如果默认插入为红色,最多只会破坏“不能连续红”的规则。 如果默认插入为黑色,黑高(黑色高度)的定义是:从任一节点到其所有叶子的路径上,黑色节点数量必须相同。如果我们插入一个黑色节点:它所在路径的黑节点数 +1,其他路径不变,于是黑高立刻被破坏。这意味着再去调整整条路径,甚至可能影响整棵树,代价很高。 如果默认插入为红色,黑色节点数量不变,并且黑高保持一致。唯一可能被破坏的是:父节点也是红色(产生红红冲突),但“红红冲突”是一个局部问题,可以通过旋转或者变色在局部范围内修复。 并且在10.3.1小节中,我们说明了第5条性质是最重要的(红黑树的平衡核心是“黑高一致”)。所以设计的原则是插入操作尽量不要改变黑节点数量,因为插入黑色,带来的问题是全局的,相对而言,我们应选择代价更小(局部)的情况:插入红色。 如果是AVL树,那必然要对图10-15所示的节点17、25,27进行一系列的左旋转。但红黑树的高度比AVL树更高,如果同样是搜索节点30,那么红黑树需要搜索4次,AVL树只需要3次,所以红黑树相当于牺牲了一点点的搜索性能,来提高插入与删除的性能(在插入与删除时,AVL树大多数情况都需要调整,而红黑树大多数情况不用,因此红黑树的性能更高)。 ### 10.4.2 应用场景选择 AVL树是一种平衡度更高的二叉搜索树,所以在搜索效率上会更高,但是AVL树为了维护这种平衡性,在插入和删除操作时,通常会进行更多的旋转操作,所以效率相对红黑树较低。 红黑树在平衡度上相较于AVL树没有那么严格,所以搜索效率上会低一些,但是红黑树在插入和删除操作时,通常需要更少的旋转操作,所以效率相对AVL树较高,它们的搜索、添加、删除时间复杂度都是O(logn),但是细节上会有一些差异。 那开发中如何进行选择呢?在应用场景上,需要看主要的需求是搜索还是插入与删除: * 如果主要的需求是搜索并且每个节点的高度尽可能地平衡,选择AVL树。 * 如果主要的需求是插入与删除的效率,选择红黑树。 在早期的时候,很多场景会选择AVL树,目前选择红黑树的越来越多(AVL树依然是一种重要的平衡树),比如操作系统内核中的内存管理或者Java的TreeMap、TreeSet底层的源码。
第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小节手写链表中,我们已经详细学习过了,我觉得理解这些总结内容对我们而言是较为轻松的。 在实际开发中,选择使用数组还是链表需要根据具体应用场景来决定。如果数据量不大,对内存使用效率有追求,且需要频繁随机访问元素,使用数组可能会更好。如果数据量大,内存分配不确定,或者需要频繁插入和删除元素,使用链表可能会更好。
