From 366bdc941dcb17af87906ec4f7bcb9c3294356c0 Mon Sep 17 00:00:00 2001 From: ITCharge Date: Wed, 13 Aug 2025 13:29:08 +0800 Subject: [PATCH 01/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=92=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=AF=AD=E5=8F=A5=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/00_preface/00_01_preface.md | 39 +- .../00_02_data_structures_algorithms.md | 160 ++++--- docs/00_preface/00_03_algorithm_complexity.md | 398 +++++++++++------- docs/00_preface/00_04_leetcode_guide.md | 117 ++--- 4 files changed, 376 insertions(+), 338 deletions(-) diff --git a/docs/00_preface/00_01_preface.md b/docs/00_preface/00_01_preface.md index 274b3ade..9ce4dfe5 100644 --- a/docs/00_preface/00_01_preface.md +++ b/docs/00_preface/00_01_preface.md @@ -2,51 +2,46 @@ ### 1.1 创作起因 -我想写一本通俗易懂的算法书已经很久了,久到大概有 7 年那么久。至今我还记得大学时曾立下的 flag:**我要把我所学到的算法知识总结起来,整理成册,编纂成书,然后大大方方的在封面上写上自己的名字,再将它分享给所有喜欢学习算法的朋友们看。** +写一本通俗易懂的算法书一直是我的心愿,这个想法已经在我心中埋藏了七年之久。至今我仍记得大学时立下的 flag:**要把所学的算法知识系统整理,编写成书,署上自己的名字,并分享给所有热爱算法的朋友们。** -结果是万万没想到,一晃过去,毕业后参加工作都已经 6 年了,每天忙于开发需求、业务逻辑、项目文档,写算法书这件事也跟其他大多数的待办事项一样,被无限制的闲置在一旁,再也不管不顾了。 +然而,毕业后由于工作繁忙,这个计划被一再搁置。直到 2021 年 3 月,在朋友的建议下,我们组建了一个算法学习群,并制定了为期三个月(2021 年 4 月 ~ 6 月)的刷题打卡计划,规定连续两天不刷题就会被移出群。 -直到 2021 年 3 月底的时候,在朋友的怂恿下,我们建了一个算法群,制定了一个为期 3 个月的算法打卡计划(2021 年 4 月 ~ 6 月),这个计划的唯一规则就是:连续两天不刷题就会被踢出群。 +最初有 39 人参与,不过最终坚持下来的只有 13 人。虽然计划未能全部完成,但这三个月的坚持让我重新找回了学习算法的乐趣,也养成了刷题的习惯。工作之余,我总会习惯性地打开 LeetCode 刷题、写题解,收获满满。 -就这样我们一群小伙伴们(39 个人)开启了为期 3 个月的刷题计划。3 个月过后,最终只有 13 个人成功完成了刷题计划,另外三分之二的人都因为打卡失败被踢出群了。**这些失败的人中也包括我自己,我是在计划即将结束的时候,一次周末忘记了打卡,惨遭淘汰。** +后来,我们又重新组建了算法交流群。群里的伙伴越来越多,大家每天刷题、写题解、讨论思路、交流心得,甚至还一起参加周赛、双周赛,赛后还会分享做题心得。 -虽然这次我的刷题计划失败了,但是经过这 3 个月的刷题练习,让我重新拾起了学习算法的乐趣,并且把刷题变成了自己的日常习惯。在工作之余,我总是习惯性地打开 LeetCode,刷上几道题,然后写下题解,这种感觉很充实也很有成就感。 - -在这次刷题计划结束之后,2021 年 7 月份的时候,我们重新建立了算法打卡群,并持续到了今天。 - -就这样,刷题打卡成了我们的日常,群里的小伙伴也越来越多。我们每天刷题,写题解,在群里讨论每日题目的思路,提出问题和回答问题、探讨更好的求解思路。再后来群里的一些小伙伴们开始参加周赛、双周赛,在比赛结束之后我们还会交流赛题思路、做题心得。 ### 1.2 输出是最好的学习方法 -在写刷算法题、写题解的过程中,我又开始写算法和数据结构的基础知识,于是就有了现在这个开源项目。后来我学会了用 hugo 搭建网站,就搭建了一个开源项目的电子书网站,方便大家在线阅读。 +在刷题和撰写题解的过程中,我逐步整理了算法与数据结构的基础知识,最终汇聚成了这个开源项目。随后,我又学习搭建了电子书网站,方便大家随时在线阅读。 -在写算法书的这个过程中,我发现一个秘籍:**只有「输出」才是最好的学习方法**。这也是「费曼学习法」的一个应用。 +在这个过程中,我深刻体会到一个重要秘诀:**「输出」是最有效的学习方式**,这正是费曼学习法的真实写照。 -在写算法内容的时候,如果对一个概念不理解,或者有点模糊,我是不可能把它写清楚,并且也让别人看明白的。只有在参考了大量的算法书籍和大佬的博客,把其中的概念或算法理解透彻了,彻底弄明白了之后,才能够转换为通俗易懂的文字,让大家也看明白。 +只有真正理解了某个概念,才能用简明易懂的语言表达出来,让他人也能明白。如果自己尚未吃透,就很难讲清楚。为此,我大量阅读算法书籍和优质博客,反复思考,直到能够将复杂的内容转化为通俗的文字。 -并且在刷题的过程中,也会有很多朋友和群里小伙伴,跟我一起进行算法知识探讨,帮我指正错误或者提出建议。这些指正和建议,在很大程度上帮助我改进文章内容和提高自己对算法的理解。就好像是学生在写作业,有专业的老师在帮我批改作业,帮助我进步一样。 +在刷题过程中,许多朋友和群友与我交流算法知识,指出不足,提出建议。这些宝贵的反馈如同专业老师批改作业,不仅帮助我完善内容,也加深了对算法的理解。 -就这样,经过一段时间的努力,我从 2021 年 7 月开始,到 2022 年 7 月底,历时整整 1 年,在 LeetCode 上刷了 1000 多题,并且在刷题的过程总结了一些算法知识和数据结构知识,终于写完了这本算法书,也就是 **「算法通关手册」**。 +就这样,从 2021 年 7 月到 2022 年 7 月,经过一年的坚持,我在 LeetCode 上完成了 1000 多道题目,系统总结了算法与数据结构知识,最终完成了这本 **「算法通关手册」**。 ## 2. 为什么要学习算法和数据结构 ### 2.1 算法是程序员的底层能力 -**「算法和数据结构」** 是计算机程序设计的重要理论技术基础,但很多程序员忽略了它的重要性。在日常开发工作中,最多的情况是使用成熟的开发框架,利用已经封装好的接口,进行 CRUD(增删改查)操作,似乎很少会需要自己实现相应的数据结构和算法。 +**算法和数据结构** 是计算机程序设计的核心理论基础,但在实际开发中,许多程序员往往忽视了它们的重要性。日常工作中,我们更多依赖成熟的框架和封装良好的接口来完成 CRUD 操作,极少需要自己从零实现底层的数据结构和算法。 -况且工作中用到的编程语言、开发框架、开发平台,更新速度堪比摩尔定律。以前端为例,React 还没学明白呢,Vue 就火起来了。Vue 2.0 的文档还在研究呢,Vue 3.0 就发布了。很多时候,连新的技术还学不过来呢,哪还有时间去专门研究算法和数据结构呢。 +此外,编程语言和开发框架的迭代速度极快。以前端为例,React 还没完全掌握,Vue 又流行起来了;刚研究完 Vue 2.0,Vue 3.0 又已发布。新技术层出不穷,学习都来不及,很难专门抽出时间去深入研究算法。 -诚然,语言、技术、框架固然重要,但背后的计算机算法和理论更为重要。因为语言、技术、框架的更新日新月异,但万变不离其宗的是背后的算法和理论,例如:**数据结构**、**算法**、**编译原理**、**计算机网络**、**计算机体系结构** 等等。任凭新技术如何变化,只要掌握了这些计算机科学的核心理论,就可以见招拆招,让自己立于不败之地。从此无论是看懂底层系统的设计原理、框架背后的设计思想,还是学习新技术、提升工作实战的效率,都可以做到得心应手。 +不可否认,语言、技术和框架固然重要,但它们背后的计算机算法与理论才是根本。无论技术如何更迭,始终不变的是底层的算法和理论基础,比如:**数据结构**、**算法**、**编译原理**、**计算机网络**、**计算机体系结构** 等。掌握这些核心理论,才能灵活应对各种技术变化,深入理解系统设计原理和框架思想,快速上手新技术,并有效提升工作效率。 -**学习数据结构与算法的关键,在于掌握其中的思想和精髓,学会解决实际问题的方法。** +**学习数据结构与算法的关键,在于领悟其思想和精髓,掌握解决实际问题的方法。** ### 2.2 算法是技术面试的必考内容 -在互联网行业相关的技术面试中,**算法和数据结构知识** 几乎是所有公司的必考内容。众多知名互联网公司喜欢在面试中考察 LeetCode 上的算法题目,通常需要面试者对给定问题进行深入分析并提供解题思路。有时候,面试官还会要求面试者评估相关算法的时间复杂度和空间复杂度。面试官通过检验面试者对常用算法的熟悉程度和实现能力的方式,从而评估面试者解决实际问题的思维能力水平。 +在互联网行业的技术面试中,**算法与数据结构** 几乎是所有公司必考的核心内容。许多知名互联网公司倾向于以 LeetCode 等平台上的算法题作为考察标准,要求面试者不仅要分析问题、阐述解题思路,还需要评估算法的时间复杂度和空间复杂度。通过这些题目的考察,面试官能够有效判断候选人解决实际问题的能力和思维深度。 -LeetCode 等平台上的算法题目已经成为行业标准。很多公司直接从这些平台选取题目或进行改编。通过系统性地练习这些题目,可以提高解决实际问题的能力。在面试中遇到类似问题时,就能更从容地应对。 +LeetCode 等平台的算法题已成为行业通用标准,许多公司会直接选用或稍作改编作为面试题。系统地练习这些题目,不仅能提升解决实际问题的能力,也能让你在面试中遇到类似问题时更加自信、从容。 -学习算法需要循序渐进。从基础的数据结构开始,逐步掌握常见算法思想。每学习一个新概念,都要通过实际题目来巩固理解。这样积累下来,就能建立起完整的算法知识体系。 +学习算法应当循序渐进,从基础数据结构入手,逐步掌握常见的算法思想。每当学习一个新概念,都要通过实际题目加以巩固,长期积累下来,才能建立起完善的算法知识体系。 -这本「算法通关手册」就是为了帮助读者系统学习算法知识而编写的。书中包含基础理论讲解和大量实战题目分析。通过理论学习和实践练习相结合的方式,读者可以真正掌握算法知识,提高解决问题的能力。无论是准备面试还是提升编程能力,这本书都能提供有价值的帮助。 +本书「算法通关手册」旨在帮助读者系统学习算法知识,既包含基础理论讲解,也有大量实战题目分析。通过理论与实践相结合,读者能够真正掌握算法精髓,提升解决问题的能力。无论是备战面试,还是提升编程能力,这本书都将为你带来切实的帮助。 diff --git a/docs/00_preface/00_02_data_structures_algorithms.md b/docs/00_preface/00_02_data_structures_algorithms.md index 3805867f..faac30b9 100644 --- a/docs/00_preface/00_02_data_structures_algorithms.md +++ b/docs/00_preface/00_02_data_structures_algorithms.md @@ -2,207 +2,197 @@ > 数据结构是程序的骨架,而算法则是程序的灵魂。 -**《算法 + 数据结构 = 程序》** 是 Pascal 语言之父 [Niklaus Emil Wirth](https://zh.wikipedia.org/wiki/尼克劳斯·维尔特) 写过的一本非常著名的书。而作为书名的这句话也成为了计算机科学的经典名句。可见,对于程序设计来说,算法和数据结构的关系密不可分。 +**《算法 + 数据结构 = 程序》** 是 Pascal 语言之父 [Niklaus Emil Wirth](https://zh.wikipedia.org/wiki/尼克劳斯·维尔特) 所著的经典著作,其书名也成为计算机科学领域广为流传的名言。这句话深刻揭示了算法与数据结构在程序设计中的密切关系。 -在学习之前,首先我们要弄清楚什么是算法?什么是数据结构?为什么要学习算法和数据结构? +在正式学习之前,我们首先要搞清楚:什么是算法?什么是数据结构?为什么要学习它们? -简单来说,**「算法」就是解决问题的方法或者过程**。如果我们把问题看成是函数,那么算法就是将输入转换为输出的过程。**「数据结构」是数据的计算机表示和相应的一组操作**。**「程序」则是算法和数据结构的具体实现**。 +简而言之,**「算法」是解决问题的方法或步骤**。如果把问题看作一个函数,算法就是将输入转化为输出的过程。**「数据结构」则是数据在计算机中的组织方式及其相关操作**。**「程序」就是算法和数据结构的具体实现**。 -如果我们把「程序设计」比作是做菜的话,那么「数据结构」就是食材和调料,「算法」则是不同的烹饪方式,或者可以看作是菜谱。不同的食材和调料,不同的烹饪方式,有着不同的排列组合。同样的东西,由不同的人做出来,味道自然也是千差万别。 +如果把「程序设计」比作烹饪,「数据结构」就像是食材和调料,「算法」则是不同的烹饪方法或菜谱。不同的食材、调料和烹饪方式可以组合出千变万化的菜肴。同样的原料,不同的人做出来,味道也会大不相同。 -至于为什么要学习算法和数据结构? +为什么要学习算法和数据结构呢? -还是拿做菜举例子。我们做菜,讲究的是「色香味俱全」。**程序设计也是如此,对于待解决的问题,我们追求的是:选择更加合适的「数据结构」,使用花费时间更少、占用空间更小的「算法」。** +还是以做菜为例。做菜讲究「色香味俱全」,而程序设计则追求为问题选择最合适的「数据结构」,并采用更高效、占用资源更少的「算法」。 -我们学习算法和数据结构,是为了学会在编程中从时间复杂度、空间复杂度方面考虑解决方案,训练自己的逻辑思维,从而写出高质量的代码,以此提升自己的编程技能,获取更高的工作回报。 +学习算法和数据结构,能够帮助我们在编程时从时间复杂度和空间复杂度等角度优化解决方案,提升逻辑思维能力,写出高质量的代码,从而增强编程能力,获得更好的职业发展。 -当然,这就像是做菜,掌握了食材和调料,学会了烹饪方式,并不意味着你就会做出一盘很好吃的炒菜。同样,掌握了算法和数据结构并不意味着你就会写程序。这需要不断的琢磨和思考,并持续学习,才能成为一名优秀的 ~~厨师~~(程序员)。 +当然,正如掌握了食材和烹饪方法并不代表就能做出美味佳肴,学会了算法和数据结构也不意味着就能写出优秀的程序。这需要不断实践、思考和持续学习,才能真正成长为一名优秀的 ~~厨师~~(程序员)。 ## 1. 数据结构 -> **数据结构(Data Structure)**:带有结构特性的数据元素的集合。 +> **数据结构(Data Structure)**:具有特定结构特征的数据元素的集合。 -简单而言,**「数据结构」** 指的是:**数据的组织结构,用来组织、存储数据**。 +简而言之,「数据结构」 就是数据的组织结构,用来组织、存储数据。 -展开来讲,数据结构研究的是数据的逻辑结构、物理结构以及它们之间的相互关系,并对这种结构定义相应的运算,设计出相应的算法,并确保经过这些运算以后所得到的新结构仍保持原来的结构类型。 +进一步来说,数据结构关注的是数据的逻辑结构、物理结构及其相互关系,并针对这些结构定义相应的操作和算法,确保经过操作后数据依然保持原有的结构特性。 -数据结构的作用,就是为了提高计算机硬件的利用率。比如说:操作系统想要查找应用程序 「Microsoft Word」 在硬盘中的哪一个位置存储。如果对硬盘全部扫描一遍的话肯定效率很低,但如果使用「B+ 树」作为索引,就能很容易的搜索到 `Microsoft Word` 这个单词,然后很快的定位到 「Microsoft Word」这个应用程序的文件信息,从而从文件信息中找到对应的磁盘位置。 +数据结构的核心作用在于提升计算机资源的利用效率。例如,操作系统想要查找硬盘中「Microsoft Word」的存储位置,如果采用全盘扫描,则效率极低;而通过「B+ 树」索引,可以迅速定位到 `Microsoft Word`,进而快速获取其文件信息和磁盘位置。 -学习数据结构,就是为了帮助我们了解和掌握计算机中的数据是以何种方式进行组织、存储的。 +学习数据结构,能够帮助我们理解和掌握数据在计算机中的组织与存储方式,从而为高效编程打下坚实基础。 --- -对于数据结构,我们可以按照数据的 **「逻辑结构」** 和 **「物理结构」** 来进行分类。 +通常,我们可以从**「逻辑结构」**和**「物理结构」**两个维度对数据结构进行分类。 ### 1.1 数据的逻辑结构 -> **逻辑结构(Logical Structure)**:数据元素之间的相互关系。 +> **逻辑结构(Logical Structure)**:指数据元素之间的相互关系。 -根据元素之间具有的不同关系,通常我们可以将数据的逻辑结构分为以下四种: +根据数据元素之间的关系,数据的逻辑结构通常分为以下四类: -#### 1. 集合结构 +#### 1.1.1 集合结构 -> **集合结构**:数据元素同属于一个集合,除此之外无其他关系。 +> **集合结构**:数据元素属于同一个集合,彼此之间没有其他关系。 -集合结构中的数据元素是无序的,并且每个数据元素都是唯一的,集合中没有相同的数据元素。集合结构很像数学意义上的「集合」。 +集合结构中的数据元素是无序且互不相同的,每个元素在集合中只出现一次。这种结构与数学中的「集合」概念非常相似。 ![集合结构](https://qcdn.itcharge.cn/images/20240509150647.png) -#### 2. 线性结构 +#### 1.1.2 线性结构 -> **线性结构**:数据元素之间是「一对一」关系。 +> **线性结构**:数据元素之间存在严格的「一对一」关系。 -线性结构中的数据元素(除了第一个和最后一个元素),左侧和右侧分别只有一个数据与其相邻。线性结构类型包括:数组、链表,以及由它们衍生出来的栈、队列、哈希表。 +在线性结构中,除第一个元素和最后一个元素外,每个数据元素都仅与前后各一个元素相邻。常见的线性结构有:数组、链表,以及基于它们实现的栈、队列等。此外,哈希表在底层实现时也常依赖数组或链表等线性结构。 ![线性结构](https://qcdn.itcharge.cn/images/20240509150709.png) -#### 3. 树形结构 +#### 1.1.3 树形结构 -> **树形结构**:数据元素之间是「一对多」的层次关系。 +> **树形结构**:数据元素之间存在「一对多」的层次关系。 -最简单的树形结构是二叉树。这种结构可以简单的表示为:根, 左子树, 右子树。 左子树和右子树又有自己的子树。当然除了二叉树,树形结构类型还包括:多叉树、字典树等。 +树形结构最典型的例子是二叉树,其基本形式包括根节点、左子树和右子树,而每个子树又可以递归地包含自己的子树。除了二叉树之外,树形结构还包括多叉树、字典树等多种类型,广泛用于表达具有层级关系的数据。 ![树形结构](https://qcdn.itcharge.cn/images/20240509150724.png) -#### 4. 图形结构 +#### 1.1.4 图形结构 -> **图形结构**:数据元素之间是「多对多」的关系。 +> **图形结构**:数据元素之间存在「多对多」的关系。 -图形结构是一种比树形结构更复杂的非线性结构,用于表示物件与物件之间的关系。一张图由一些小圆点(称为 **「顶点」** 或 **「结点」**)和连结这些圆点的直线或曲线(称为 **「边」**)组成。 +图形结构是一种比树形结构更为复杂的非线性结构,常用于描述对象之间任意的关联关系。在图结构中,数据元素被称为 **「顶点」**(或 **「结点」**),顶点之间通过 **「边」**(可以是直线或曲线)相连,形成复杂的网络。 -在图形结构中,任意两个结点之间都可能相关,即结点之间的邻接关系可以是任意的。图形结构类型包括:无向图、有向图、连通图等。 +与树形结构不同,图形结构允许任意两个顶点之间建立连接,顶点之间的邻接关系没有限制。常见的图结构类型包括:无向图、有向图、连通图等。 ![图形结构](https://qcdn.itcharge.cn/images/20240509150831.png) ### 1.2 数据的物理结构 -> **物理结构(Physical Structure)**:数据的逻辑结构在计算机中的存储方式。 +> **物理结构(Physical Structure)**:指数据的逻辑结构在计算机中的具体存储方式。 -计算机内有多种存储结构,采用最多的是这两种结构:**「顺序存储结构」**、**「链式存储结构」**。 +在计算机中,常见的物理存储结构主要有两种:**顺序存储结构** 和 **链式存储结构**,它们被广泛应用于各类数据结构的实现。 -#### 1. 顺序存储结构 +#### 1.2.1 顺序存储结构 -> **顺序存储结构(Sequential Storage Structure)**:将数据元素存放在一片地址连续的存储单元里,数据元素之间的逻辑关系通过数据元素的存储地址来直接反映。 +> **顺序存储结构(Sequential Storage Structure)**:指将所有数据元素依次存放在一块地址连续的存储空间中,元素之间的逻辑关系通过它们在物理存储上的相对位置来体现。 ![顺序存储结构](https://qcdn.itcharge.cn/images/20240509150846.png) -在顺序存储结构中,逻辑上相邻的数据元素在物理地址上也必然相邻 。 +在顺序存储结构中,逻辑上相邻的数据元素在物理地址上也紧密相邻。 -这种结构的优点是:简单、易理解,且实际占用最少的存储空间。缺点是:需要占用一片地址连续的存储单元;并且存储分配要事先进行;另外对于一些操作的时间效率较低(移动、删除元素等操作)。 +顺序存储结构的优点在于:结构简单、易于理解,并且能够高效利用存储空间。其缺点主要包括:必须预先分配一块连续的存储空间,灵活性较差;在插入、删除等需要移动大量元素的操作时,时间效率较低。 #### 2. 链式存储结构 -> **链式存储结构(Linked Storage Structure)**:将数据元素存放在任意的存储单元里,存储单元可以连续,也可以不连续。 +> **链式存储结构(Linked Storage Structure)**:指将数据元素存放在内存中的任意存储单元,这些单元可以是连续的,也可以是不连续的。 ![链式存储结构](https://qcdn.itcharge.cn/images/20240509150902.png) -链式存储结构中,逻辑上相邻的数据元素在物理地址上可能相邻,可也能不相邻。其在物理地址上的表现是随机的。链式存储结构中,一般将每个数据元素占用的若干单元的组合称为一个链结点。每个链结点不仅要存放一个数据元素的数据信息,还要存放一个指出这个数据元素在逻辑关系的直接后继元素所在链结点的地址,该地址被称为指针。换句话说,数据元素之间的逻辑关系是通过指针来间接反映的。 +在链式存储结构中,逻辑上相邻的数据元素在物理地址上不要求相邻,实际存储位置是随机的。通常,每个数据元素及其相关信息被组合成一个「链结点」。每个链结点除了存放数据本身外,还包含一个「指针(或引用)」,用于指向下一个逻辑上相邻的链结点。也就是说,数据元素之间的逻辑关系是通过指针来连接和体现的。 -这种结构的优点是:存储空间不必事先分配,在需要存储空间的时候可以临时申请,不会造成空间的浪费;一些操作的时间效率远比顺序存储结构高(插入、移动、删除元素)。缺点是:不仅数据元素本身的数据信息要占用存储空间,指针也需要占用存储空间,链式存储结构比顺序存储结构的空间开销大。 +链式存储结构的主要优点在于:无需预先分配一整块连续的存储空间,能够根据需要动态申请和释放内存,避免空间浪费;在插入、删除等操作时,通常只需修改指针,效率较高。其缺点是:每个链结点除了存储数据外,还需额外存储指针信息,因此整体空间开销相较于顺序存储结构更大。 ## 2. 算法 > **算法(Algorithm)**:解决特定问题求解步骤的准确而完整的描述,在计算机中表现为一系列指令的集合,算法代表着用系统的方法描述解决问题的策略机制。 -简单而言,**「算法」** 指的就是解决问题的方法。 +简而言之,**「算法」** 就是解决问题的具体方法和步骤。 -展开来讲,算法是某一系列运算步骤,它表达解决某一类计算问题的一般方法,对这类方法的任何一个输入,它可以按步骤一步一步计算,最终产生一个输出。它不依赖于任何一种语言,可以用 **自然语言、编程语言(Python、C、C++、Java 等)描述**,也可以用 **伪代码、流程图** 来表示。 +进一步来说,算法是一系列有序的运算步骤,能够为某一类计算问题提供通用的解决方案。对于任意合法输入,算法都能按照既定步骤逐步执行,最终得到正确的输出。算法本身与具体的编程语言无关,可以用 **自然语言、编程语言(如 Python、C、C++、Java 等)**,也可以用 **伪代码或流程图** 等多种方式进行描述。 -下面我们举几个例子来说明什么是算法。 +下面通过几个例子来直观理解什么是算法。 - 示例 1: > **问题描述**: > -> - 从上海到北京,应该怎么去? +> - 如何从上海前往北京? > > **解决方法**: > -> 1. 选择坐飞机,坐飞机用的时间最少,但费用最高。 -> 2. 选择坐长途汽车,坐长途汽车费用低,但花费时间长。 -> 3. 选择坐高铁或火车,花费时间不算太长,价格也不算太贵。 +> 1. 选择乘坐飞机,速度最快但费用最高。 +> 2. 选择长途汽车,费用最低但耗时最长。 +> 3. 选择高铁或火车,时间和费用都较为适中。 - 示例 2: > **问题描述**: > -> - 如何计算 $1 + 2 + 3 + … + 100$ 的值? +> - 如何计算 $1 + 2 + 3 + \dots + 100$ 的和? > > **解决方法**: > -> 1. 用计算器从 $1$ 开始,不断向右依次加上 $2$,再加上 $3$,...,依次加到 $100$,得出结果为 $5050$。 -> 2. 根据高斯求和公式:**和 = (首项 + 末项) × 项数 ÷ 2**,直接算出结果为:$\frac{(1+100) \times 100}{2} = 5050$。 +> 1. 依次用计算器从 $1$ 加到 $100$,最终得到 $5050$。 +> 2. 利用高斯求和公式:**和 = (首项 + 末项) × 项数 ÷ 2**,直接计算得 $\frac{(1+100) \times 100}{2} = 5050$。 - 示例 3: > **问题描述**: > -> - 如何对一个 $n$ 个整数构成的数组进行升序排序? +> - 如何将一个包含 $n$ 个整数的数组按升序排列? > > **解决方法**: > -> 1. 使用冒泡排序对 $n$ 个整数构成的数组进行升序排序。 -> 2. 选择插入排序、归并排序、快速排序等等其他排序算法对 $n$ 个整数构成的数组进行升序排序。 +> 1. 使用冒泡排序对数组进行升序排序。 +> 2. 也可以选择插入排序、归并排序、快速排序等其他排序算法实现升序排列。 -以上 $3$ 个示例中的解决方法都可以看做是算法。从上海去北京的解决方法可以看做是算法,对 $1 \sim 100$ 的数进行求和的计算方法也可以看做是算法。对数组进行排序的方法也可以看做是算法。并且从这 $3$ 个示例中可以看出对于一个特定的问题,往往有着不同的算法。 +上述三个示例中的解决方法都属于算法。从上海到北京的出行方案是一种算法,对 $1$ 到 $100$ 求和的方法是一种算法,对数组排序的方法同样是一种算法。可以看出,对于同一个问题,往往存在多种不同的算法可供选择。 ### 2.1 算法的基本特性 -算法其实就是一系列的运算步骤,这些运算步骤可以解决特定的问题。除此之外,**算法** 应必须具备以下特性: +算法本质上是一组有序的运算步骤,用于解决特定的问题。除此之外,**算法** 还必须具备以下五个基本特性: -1. **输入**:对于待解决的问题,都要以某种方式交给对应的算法。在算法开始之前最初赋给算法的参数称为输入。比如示例 $1$ 中的输入就是出发地和目的地的参数(北京,上海),示例 $3$ 中的输入就是 $n$ 个整数构成的数组。一个算法可以有多个输入,也可以没有输入。比如示例 $2$ 是对固定问题的求解,就可以看做没有输入。 -2. **输出**:算法是为了解决问题存在的,最终总需要返回一个结果。所以至少需要一个或多个参数作为算法的输出。比如示例 $1$ 中的输出就是最终选择的交通方式,示例 $2$ 中的输出就是和的结果。示例 $3$ 中的输出就是排好序的数组。 -3. **有穷性**:算法必须在有限的步骤内结束,并且应该在一个可接受的时间内完成。比如示例 $1$,如果我们选择五一从上海到北京去旅游,结果五一纠结了三天也没决定好怎么去北京,那么这个旅游计划也就泡汤了,这个算法自然也是不合理的。 -4. **确定性**:组成算法的每一条指令必须有着清晰明确的含义,不能令读者在理解时产生二义性或者多义性。就是说,算法的每一个步骤都必须准确定义而无歧义。 -5. **可行性**:算法的每一步操作必须具有可执行性,在当前环境条件下可以通过有限次运算实现。也就是说,每一步都能通过执行有限次数完成,并且可以转换为程序在计算机上运行并得到正确的结果。 +1. **输入**:算法需要接收外部提供的信息作为处理对象,这些信息称为输入。一个算法可以有零个、一个或多个输入。例如,示例 $1$ 的输入是出发地和目的地(如上海、北京),示例 $3$ 的输入是由 $n$ 个整数构成的数组,而示例 $2$ 针对的是固定问题,可以视为没有输入。 +2. **输出**:算法的执行结果必须有明确的输出,即至少有一个输出结果。比如,示例 $1$ 的输出是最终选择的交通方式,示例 $2$ 的输出是求和的结果,示例 $3$ 的输出是排好序的数组。 +3. **有穷性**:算法必须在有限的步骤内终止,并且能够在合理的时间内完成。如果算法无法在有限时间内结束,就不能称为有效的算法。例如,若五一假期从上海到北京旅游,三天都没决定交通方式,计划就无法实现,这样的「算法」显然不合理。 +4. **确定性**:算法中的每一步操作都必须有明确、唯一的含义,不能存在歧义。也就是说,任何人在相同输入下执行算法,得到的中间过程和最终结果都应一致。 +5. **可行性**:算法的每一步都必须是可执行的,即在现有条件下能够通过有限次数的操作实现,并且可以被计算机程序实现并运行,最终得到正确的结果。 ### 2.2 算法追求的目标 -研究算法的作用,就是为了使解决问题的方法变得更加高效。对于给定的问题,我们往往会有多种算法来解决。而不同算法的 **成本** 也是不同的。总体而言,一个优秀的算法至少应该追求以下两个目标: +研究算法的核心目的,是让我们以更高效的方式解决问题。对于同一个问题,往往存在多种算法可选,而不同算法的“代价”也各不相同。一般来说,优秀的算法应当重点追求以下两个目标: -1. **所需运行时间更少(时间复杂度更低)**; -2. **占用内存空间更小(空间复杂度更低)**。 +1. **更少的运行时间(更低的时间复杂度)** +2. **更小的内存占用(更低的空间复杂度)** -假设计算机执行一条命令的时间为 $1$ 纳秒(假定值),第一种算法需要执行 $100$ 纳秒,第二种算法则需要执行 $3$ 纳秒。如果不考虑占用内存空间的话,很明显第二种算法比第一种算法要好很多。 +举例来说,假设计算机执行一条指令需要 $1$ 纳秒。若某算法需 $100$ 纳秒,另一算法只需 $3$ 纳秒,在不考虑内存消耗的前提下,显然后者更优。再比如,若某算法只需 $3$ 字节内存,另一算法需 $100$ 字节,在不考虑运行时间的情况下,前者更优。 -假设计算机一个内存单元的大小为一个字节,第一种算法需要占用 $3$ 个字节大小的内存空间,第二种算法则需要占用 $100$ 个字节大小的内存空间,如果不考虑运行时间的话,很明显第一种算法比第二种算法要好很多。 +实际应用中,算法设计往往需要在运行时间和空间占用之间权衡。理想情况下,算法既快又省空间,但现实中常常需要根据具体需求做出取舍。例如,当程序运行速度要求较高时,可以适当增加空间消耗以换取更快的执行速度;反之,如果设备内存有限且对速度要求不高,则可以选择更节省空间的算法,即使牺牲一些运行时间。 -现实中算法,往往是需要同时从运行时间、占用空间两个方面考虑问题。当然,运行时间越少,占用空间越小的算法肯定是越好的,但总是会有各种各样的因素导致了运行时间和占用空间不可兼顾。比如,在程序运行时间过高时,我们可以考虑在空间上做文章,牺牲一定量的空间,来换取更短的运行时间。或者在程序对运行时间要求不是很高,而设备内存又有限的情况下,选择占用空间更小,但需要牺牲一定量的时间的算法。 +除了运行时间和空间占用,优秀的算法还应具备以下基本特性: -当然,除了对运行时间和占用内存空间的追求外,一个好的算法还应该追求以下目标: - -1. **正确性**:正确性是指算法能够满足具体问题的需求,程序运行正常,无语法错误,能够通过典型的软件测试,达到预期的需求。 -2. **可读性**:可读性指的是算法遵循标识符命名规则,简洁易懂,注释语句恰当,方便自己和他人阅读,便于后期修改和调试。 -3. **健壮性**:健壮性指的是算法对非法数据以及操作有较好的反应和处理。 - -这 $3$ 个目标是算法的基本标准,是所有算法所必须满足的。一般我们对好的算法的评判标准就是上边提到的 **所需运行时间更少(时间复杂度更低)**、**占用内存空间更小(空间复杂度更低)**。 +1. **正确性**:算法能准确满足问题需求,程序运行无语法错误,能通过典型测试,达到预期目标。 +2. **可读性**:算法结构清晰,命名规范,注释恰当,便于理解、维护和后续修改。 +3. **健壮性**:算法能合理应对非法输入或异常操作,具备良好的容错能力。 +这三点是算法的基本要求,所有算法都必须满足。而我们评价一个算法是否优秀,通常最看重的还是其运行时间和空间占用两个方面。 ## 3. 总结 ### 3.1 数据结构 -数据结构可以分为 **「逻辑结构」** 和 **「物理结构」**。 - -- 逻辑结构可分为:**集合结构**、**线性结构**、**树形结构**、**图形结构**。 - -- 物理结构可分为:**顺序存储结构**、**链式存储结构**。 +数据结构通常分为 **逻辑结构** 和 **物理结构** 两大类。 -「逻辑结构」指的是数据之间的 **关系**,「物理结构」指的是这种关系 **在计算机中的表现形式**。 +- **逻辑结构**:描述数据元素之间的关系,主要包括:**集合结构**、**线性结构**、**树形结构** 和 **图形结构**。 +- **物理结构**:指数据在计算机中的实际存储方式,主要有:**顺序存储结构** 和 **链式存储结构**。 -例如:线性表中的「栈」,其数据元素之间的关系是一对一的,除头和尾结点之外的每个结点都有唯一的前驱和唯一的后继,这体现的是逻辑结构。而对于栈中的结点来说,可以使用顺序存储(也就是 **顺序栈**)的方式存储在计算机中,其结构在计算机中的表现形式就是一段连续的存储空间,栈中每个结点和它的前驱结点、后继结点在物理上都是相邻的。当然,栈中的结点也可以使用链式存储(也即是 **链式栈**),每个结点和它的前驱结点、后继结点在物理上不一定相邻,每个结点是靠前驱结点的指针域来进行访问的。 +逻辑结构强调数据元素之间的相互关系,而物理结构则关注这些关系在计算机内的具体实现。例如,线性表中的「栈」在逻辑上属于线性结构,元素之间是一对一的关系(除首尾元素外,每个元素有唯一前驱和后继)。在物理实现上,栈可以采用顺序存储(即 **顺序栈**,元素在内存中连续存放),也可以采用链式存储(即 **链式栈**,元素在内存中不一定连续,通过指针连接)。 ### 3.2 算法 -**「算法」** 指的就是解决问题的方法。算法是一系列的运算步骤,这些运算步骤可以解决特定的问题。 +**算法** 是解决特定问题的有序操作步骤的集合。 -算法拥有 5 个基本特性:**输入**、**输出**、**有穷性**、**确定性**、**可行性**。 - -算法追求的目标有 5 个:**正确性**、**可读性**、**健壮性**、**所需运行时间更少(时间复杂度更低)**、**占用内存空间更小(空间复杂度更低)**。 - ---- +一个算法应具备以下五个基本特性:**输入**、**输出**、**有穷性**、**确定性**、**可行性**。 -以上就是本篇的全部内容,我们将在下一篇文章具体讲解算法的「时间复杂度」和「空间复杂度」。 +优秀的算法通常追求以下目标:**正确性**、**可读性**、**健壮性**、**更低的时间复杂度(运行时间更短)** 和 **更低的空间复杂度(占用内存更小)**。 ## 参考资料 diff --git a/docs/00_preface/00_03_algorithm_complexity.md b/docs/00_preface/00_03_algorithm_complexity.md index 2e1e4e94..4fcfd6e8 100644 --- a/docs/00_preface/00_03_algorithm_complexity.md +++ b/docs/00_preface/00_03_algorithm_complexity.md @@ -1,255 +1,337 @@ ## 1. 算法复杂度简介 -> **算法复杂度(Algorithm complexity)**:在问题的输入规模为 $n$ 的条件下,程序的时间使用情况和空间使用情况。 +> **算法复杂度(Algorithm complexity)**:用于衡量算法在输入规模为 $n$ 时所需的时间和空间资源。 -「算法分析」的目的在于改进算法。正如上文中所提到的那样:算法所追求的就是 **所需运行时间更少(时间复杂度更低)**、**占用内存空间更小(空间复杂度更低)**。所以进行「算法分析」,就是从运行时间情况、空间使用情况两方面对算法进行分析。 +这里的 **问题规模 $n$**,指的是算法输入的数据量。不同类型的算法,$n$ 的具体含义也有所不同: -比较两个算法的优劣通常有两种方法: +- 排序算法中,$n$ 表示待排序元素的数量; +- 查找算法中,$n$ 表示查找范围的大小(如数组长度、字符串长度等); +- 图论算法中,$n$ 可以指节点数或边数,具体视问题而定; +- 二进制相关算法中,$n$ 通常指二进制的位数。 -- **事后统计**:将两个算法各编写一个可执行程序,交给计算机执行,记录下各自的运行时间和占用存储空间的实际大小,从中挑选出最好的算法。 -- **预先估算**:在算法设计出来之后,根据算法中包含的步骤,估算出算法的运行时间和占用空间。比较两个算法的估算值,从中挑选出最好的算法。 +一般来说,输入规模越大,算法的计算成本也会随之增加;而当输入规模相近时,计算成本也会比较接近。 -大多数情况下,我们会选择第 $2$ 种方式。因为第 $1$ 种方式的工作量实在太大,得不偿失。另外,即便是同一个算法,用不同的语言实现,在不同的计算机上运行,所需要的运行时间都不尽相同。所以我们一般采用预先估算的方法来衡量算法的好坏。 +「算法分析」的核心目标是优化算法,使其 **运行时间更短**、**内存占用更小**。分析算法时,主要从运行时间和空间使用两个方面入手。常见的分析方法有两种: -采用预先估算的方式下,编译语言、计算机运行速度都不是我们所考虑的对象。我们只关心随着问题规模 $n$ 扩大时,时间开销、空间开销的增长情况。 +- **事后统计**:将不同算法分别实现并运行,通过实际测量运行时间和内存占用来比较优劣。 +- **预先估算**:在算法设计阶段,根据算法的步骤,理论上估算其运行时间和空间消耗,并进行比较。 -这里的 **「问题规模 $n$」** 指的是:算法问题输入的数据量大小。对于不同的算法,定义也不相同。 +实际应用中,我们更倾向于采用预先估算的方法,因为事后统计不仅工作量大,而且同一算法在不同编程语言和硬件环境下的表现差异较大。 -- 排序算法中:$n$ 表示需要排序的元素数量。 -- 查找算法中:$n$ 表示查找范围内的元素总数:比如数组大小、二维矩阵大小、字符串长度、二叉树节点数、图的节点数、图的边界点等。 -- 二进制计算相关算法中:$n$ 表示二进制的展开宽度。 - -一般来说,问题的输入规模越接近,相应的计算成本也越接近。而随着问题输入规模的扩大,计算成本也呈上升趋势。 - -接下来,我们将具体讲解「时间复杂度」和「空间复杂度」。 +采用预先估算时,我们通常不考虑编程语言、计算机运行速度等外部因素,关注的是算法随问题规模增长时的资源消耗趋势。 ## 2. 时间复杂度 ### 2.1 时间复杂度简介 -> **时间复杂度(Time Complexity)**:在问题的输入规模为 $n$ 的条件下,算法运行所需要花费的时间,可以记作为 $T(n)$。 - -我们将 **基本操作次数** 作为时间复杂度的度量标准。换句话说,时间复杂度跟算法中基本操作次数的数量正相关。 +> **时间复杂度(Time Complexity)**:用于衡量算法在输入规模为 $n$ 时的运行时间,通常记作 $T(n)$。 -- **基本操作** :算法执行中的每一条语句。每一次基本操作都可在常数时间内完成。 +时间复杂度的本质,是统计算法中 **基本操作** 的执行次数。也就是说,时间复杂度与算法中基本操作的数量成正比。 -基本操作是一个运行时间不依赖于操作数的操作。 +- **基本操作**:指的是在常数时间内可以完成的语句,其执行时间与操作数的大小无关。 -比如两个整数相加的操作,如果两个数的规模不大,运行时间不依赖于整数的位数,则相加操作就可以看做是基本操作。 +举例来说,两个小整数相加,所需时间不会因为数字位数的不同而变化,因此属于基本操作。但如果操作数非常大,运算时间会随位数增加而增长,这时整体加法就不再是基本操作,应将每一位的加法视为基本操作。 -反之,如果两个数的规模很大,相加操作依赖于两个数的位数,则两个数的相加操作不是一个基本操作,而每一位数的相加操作才是一个基本操作。 - -下面通过一个具体例子来说明一下如何计算时间复杂度。 +下面通过一个具体例子来演示时间复杂度的计算方法。 ```python -def algorithm(n): - fact = 1 # 执行 1 次 - for i in range(1, n + 1): # 执行 n 次 - fact *= i # 执行 n 次 - return fact # 执行 1 次 +def find_max(arr): + max_val = arr[0] # 1 次操作 + for i in range(len(arr)): # n 次循环 + if arr[i] > max_val: # n 次比较 + max_val = arr[i] # 最多 n 次赋值 + return max_val # 1 次操作 ``` -在这个例子中: +在上述例子中,基本操作总共执行了 $1 + n + n + n + 1 = 3 \times n + 2$ 次,因此可以用 $f(n) = 3 \times n + 2$ 表示其操作次数。 -1. `fact = 1` 执行了 1 次。 -2. `for i in range(1, n + 1)` 执行了 n 次。 -3. `fact *= i` 执行了 n 次。 -4. `return fact` 执行了 1 次。 +时间复杂度分析如下: +- 当 $n$ 足够大时,$3n$ 是主要影响项,常数 $2$ 可以忽略不计。 +- 由于我们关注的是随规模增长的趋势,常数系数 $3$ 也可以省略。 +- 因此,该算法的时间复杂度为 $O(n)$。这里的 $O$ 表示渐进符号,强调 $f(n)$ 与 $n$ 成正比。 -总执行次数为:$1 + n + n + 1 = 2 \times n + 2$,可以用一个函数 $f(n)$ 来表达语句的执行次数:$f(n) = 2 \times n + 2$。忽略掉 $f(n)$ 中的常数系数、低阶项、常数项,因此时间复杂度为 $O(n)$。 +所谓「算法执行时间的增长趋势」,实际上就是用类似 $O$ 这样的渐进符号,来简洁地描述算法随输入规模变化时的资源消耗情况。 -这个例子展示了如何从具体的执行次数推导出算法的时间复杂度。虽然实际执行次数是 $2 \times n + 2$,但在复杂度分析中,我们只关注增长最快的项,因此最终的时间复杂度是 $O(n)$。 +### 2.2 渐进符号 -时间复杂度的函数可以表示为:$T(n) = O(f(n))$。它表示的是随着问题规模 $n$ 的增大,算法执行时间的增长趋势跟 $f(n)$ 相同。$O$ 是一种渐进符号,与 $f(n)$ 成正比例关系,$T(n)$ 称作算法的 **渐进时间复杂度(Asymptotic Time Complexity)**,简称为 **时间复杂度**。 +时间复杂度通常记作 $T(n) = O(f(n))$,称为 **渐进时间复杂度(Asymptotic Time Complexity)**,用于描述当问题规模 $n$ 趋近于无穷大时,算法运行时间的增长趋势。我们常用渐进符号(如 $O$、$\Omega$、$\Theta$ 等)来表达这种增长关系。渐进时间复杂度只关注主导项,忽略常数和低阶项,从而简洁地反映算法的本质效率。 -所谓「算法执行时间的增长趋势」是一个模糊的概念,通常我们要借助像上边公式中 $O$ 这样的「渐进符号」来表示时间复杂度。 +> **渐进符号(Asymptotic Symbol)**:一类数学符号,用于描述函数(如算法运行时间或空间)随输入规模增长时的变化速度。在算法分析中,常用的渐进符号有大 $O$(上界)、大 $\Omega$(下界)、大 $\Theta$(紧确界),它们帮助我们以统一的方式比较不同算法的效率。 -### 2.2 渐进符号 +![渐进符号关系图](https://qcdn.itcharge.cn/images/202109092356694.png) -> **渐进符号(Asymptotic Symbol)**:专门用来刻画函数的增长速度的。简单来说,渐进符号只保留了 **最高阶幂**,忽略了一个函数中增长较慢的部分,比如 **低阶幂**、**系数**、**常量**。因为当问题规模变的很大时,这几部分并不能左右增长趋势,所以可以忽略掉。 +#### 2.2.1 渐进上界符号 $O$ -经常用到的渐进符号有三种: $\Theta$ 渐进紧确界符号、$O$ 渐进上界符号、$\Omega$ 渐进下界符号。接下来我们将依次讲解。 +> **渐进上界符号 $O$**:用于描述算法运行时间的上限,通常反映算法在最坏情况下的性能。 -#### 2.2.1 $\Theta$ 渐进紧确界符号 +**数学定义**:设 $T(n)$ 和 $f(n)$ 为两个函数,若存在正常数 $c$ 和 $n_0$,使得对所有 $n \geq n_0$,都有 $T(n) \leq c \cdot f(n)$,则称 $T(n) = O(f(n))$。 -> **$\Theta$ 渐进紧确界符号**:对于函数 $f(n)$ 和 $g(n)$,$f(n) = \Theta(g(n))$。存在正常量 $c_1$、$c_2$ 和 $n_0$,使得对于所有 $n \ge n_0$ 时,有 $0 \le c_1 \cdot g(n) \le f(n) \le c_2 \cdot g(n)$。 +**直观理解**:$T(n) = O(f(n))$ 表示「算法的运行时间至多为 $f(n)$ 的某个常数倍」,即不会比 $f(n)$ 增长得更快。 -也就是说,如果函数 $f(n) = \Theta(g(n))$,那么我们能找到两个正数 $c_1$、$c_2$,使得 $f(n)$ 被 $c_1 \cdot g(n)$ 和 $c_2 \cdot g(n)$ 夹在中间。 +> **示例**: +> - 如果 $T(n) = 3 \times n^2 + 2 \times n + 1$,则 $T(n) = O(n^2)$。 +> - 如果 $T(n) = 2 \times n + 5$,则 $T(n) = O(n)$。 +> - 如果 $T(n) = 100$,则 $T(n) = O(1)$。 -例如:$T(n) = 3n^2 + 4n + 5 = \Theta(n^2)$,可以找到 $c_1 = 1$,$c_2 = 12$,$n_0 = 1$,使得对于所有 $n \ge 1$,都有 $n^2 \le 3n^2 + 4n + 5 \le 12n^2$。 +#### 2.2.2 渐进下界符号 $\Omega$ -#### 2.2.2 $O$ 渐进上界符号 +> **渐进下界符号 $\Omega$**:用于描述算法运行时间的下界,通常反映算法在最优情况下的性能。 -> **$O$ 渐进上界符号**:对于函数 $f(n)$ 和 $g(n)$,$f(n) = O(g(n))$。存在常量 $c$,$n_0$,使得当 $n > n_0$ 时,有 $0 \le f(n) \le c \cdot g(n)$。 +**数学定义**:设 $T(n)$ 和 $f(n)$ 为两个函数,若存在正常数 $c > 0$ 和 $n_0$,使得对所有 $n \geq n_0$,都有 $T(n) \geq c \cdot f(n)$,则称 $T(n) = \Omega(f(n))$。 -$\Theta$ 符号渐进地给出了一个函数的上界和下界,如果我们只知道一个函数的上界,可以使用 $O$ 渐进上界符号。 +**直观理解**:$T(n) = \Omega(f(n))$ 表示「算法的运行时间至少不会低于 $f(n)$ 的某个常数倍」,即增长速度不慢于 $f(n)$。 -#### 2.2.3 $\Omega$ 渐进下界符号 +> **示例**: +> - 如果 $T(n) = 3 \times n^2 + 2 \times n + 1$,则 $T(n) = \Omega(n^2)$。 +> - 如果 $T(n) = 2 \times n + 5$,则 $T(n) = \Omega(n)$。 +> - 如果 $T(n) = n^3$,则 $T(n) = \Omega(n^2)$。 -> **$\Omega$ 渐进下界符号**:对于函数 $f(n)$ 和 $g(n)$,$f(n) = \Omega(g(n))$。存在常量 $c$,$n_0$,使得当 $n > n_0$ 时,有 $0 \le c \cdot g(n) \le f(n)$。 +#### 2.2.3 渐进紧确界符号 $\Theta$ -同样,如果我们只知道函数的下界,可以使用 $\Omega$ 渐进下界符号。 +> **渐进紧确界符号 $\Theta$**:用于描述算法运行时间的精确数量级,即算法在最好和最坏情况下的增长速度都与 $f(n)$ 保持一致。 -![$\Theta$、$O$ 和 $\Omega$ 记号对比](https://qcdn.itcharge.cn/images/202109092356694.png) +**数学定义**:设 $T(n)$ 和 $f(n)$ 为两个函数,若存在正常数 $c_1, c_2 > 0$ 及 $n_0$,使得对所有 $n \geq n_0$,都有 $c_1 \cdot f(n) \leq T(n) \leq c_2 \cdot f(n)$,则称 $T(n) = \Theta(f(n))$。 -### 2.3 时间复杂度计算 +**直观理解**:$T(n) = \Theta(f(n))$ 表示「算法运行时间与 $f(n)$ 同阶」,即上下界都为 $f(n)$ 的常数倍。 -渐进符号可以渐进地描述一个函数的上界、下界,同时也可以描述算法执行时间的增长趋势。 +> **示例**: +> - 如果 $T(n) = 3 \times n^2 + 2 \times n + 1$,则 $T(n) = \Theta(n^2)$。 +> - 如果 $T(n) = 2 \times n + 5$,则 $T(n) = \Theta(n)$。 +> - 如果 $T(n) = n \log n + n$,则 $T(n) = \Theta(n \log n)$。 -在计算时间复杂度的时候,我们经常使用 $O$ 渐进上界符号。因为我们关注的通常是算法用时的上界,而不用关心其用时的下界。 +### 2.3 时间复杂度计算 -那么具体应该如何计算时间复杂度呢? +渐进符号用于描述函数的上界、下界,以及算法执行时间随问题规模增长的趋势。 -求解时间复杂度一般分为以下几个步骤: +在分析时间复杂度时,我们通常使用 $O$ 符号来表示算法的上界,因为实际应用中更关注算法在最坏情况下的表现。 -- **找出算法中的基本操作(基本语句)**:算法中执行次数最多的语句就是基本语句,通常是最内层循环的循环体部分。 -- **计算基本语句执行次数的数量级**:只需要计算基本语句执行次数的数量级,即保证函数中的最高次幂正确即可。像最高次幂的系数和低次幂可以忽略。 -- **用大 O 表示法表示时间复杂度**:将上一步中计算的数量级放入 O 渐进上界符号中。 +那么,如何具体计算时间复杂度呢? -同时,在求解时间复杂度还要注意一些原则: +一般来说,计算时间复杂度可以分为以下几个步骤: -- **加法原则**:总的时间复杂度等于量级最大的基本语句的时间复杂度。 +1. **确定基本操作**:找出算法中执行次数最多的语句,通常是最内层循环的核心操作。 +2. **估算执行次数**:只关注基本操作的最高阶项,忽略常数系数和低阶项。 +3. **用大 O 符号表示**:将上一步得到的数量级用 $O$ 符号表示出来。 -如果 $T_1(n) = O(f_1(n))$,$T_2(n) = O(f_2(n))$,$T(n) = T_1(n) + T_2(n)$,则 $T(n) = O(f(n)) = max(O(f_1(n)), \enspace O(f_2(n))) = O(max(f_1(n), \enspace f_2(n)))$。 +在计算时间复杂度时,还需注意以下两条常用原则: -- **乘法原则**:循环嵌套代码的复杂度等于嵌套内外基本语句的时间复杂度乘积。 +> **加法原则**:多个代码块顺序执行时,总时间复杂度等于其中最大的那一个。 -如果 $T_1 = O(f_1(n))$,$T_2 = O(f_2(n))$,$T(n) = T_1(n) \times T_2(n)$,则 $T(n) = O(f(n)) = O(f_1(n)) \times O(f_2(n)) = O(f_1(n) \times f_2(n))$。 +即如果 $T_1(n) = O(f_1(n))$,$T_2(n) = O(f_2(n))$,$T(n) = T_1(n) + T_2(n)$,则 $T(n) = O(\max(f_1(n), f_2(n)))$。 -下面通过实例来说明如何计算时间复杂度。 +> **乘法原则**:循环嵌套时,总时间复杂度等于各层复杂度的乘积。 -#### 2.3.1 常数 $O(1)$ +即如果 $T_1(n) = O(f_1(n))$,$T_2(n) = O(f_2(n))$,$T(n) = T_1(n) \times T_2(n)$,则 $T(n) = O(f_1(n) \times f_2(n))$。 -一般情况下,只要算法中不存在循环语句、递归语句,其时间复杂度都为 $O(1)$。 +下面通过具体实例来说明各种常见时间复杂度的计算方法。 -$O(1)$ 只是常数阶时间复杂度的一种表示方式,并不是指只执行了一行代码。只要代码的执行时间不随着问题规模 $n$ 的增大而增长,这样的算法时间复杂度都记为 $O(1)$。 +#### 2.3.1 常数时间 $O(1)$ + +没有循环和递归的算法,时间复杂度通常为 $O(1)$。 ```python -def algorithm(n): - a = 1 - b = 2 - res = a * b + n - return res +def get_first_element(arr): + return arr[0] # 直接返回第一个元素 + +def add_two_numbers(a, b): + return a + b # 简单的加法运算 ``` -上述代码虽然有 $4$ 行代码,但时间复杂度也是 $O(1)$,而不是 $O(3)$。 +上述代码中,每个函数都只执行常数次操作,时间复杂度为 $O(1)$。 -#### 2.3.2 线性 $O(n)$ +#### 2.3.2 线性时间 $O(n)$ -一般含有非嵌套循环,且单层循环下的语句执行次数为 $n$ 的算法涉及线性时间复杂度。这类算法随着问题规模 $n$ 的增大,对应计算次数呈线性增长。 +单层循环遍历 $n$ 个元素的算法,时间复杂度为 $O(n)$。 ```python -def algorithm(n): - sum = 0 - for i in range(n): - sum += 1 - return sum +def find_max(arr): + max_val = arr[0] + for num in arr: # 遍历数组中的每个元素 + if num > max_val: + max_val = num + return max_val + +def sum_array(arr): + total = 0 + for num in arr: # 遍历数组中的每个元素 + total += num + return total ``` -上述代码中 `sum += 1` 的执行次数为 $n$ 次,所以这段代码的时间复杂度为 $O(n)$。 +上述代码中,每个函数都只遍历数组一次,时间复杂度为 $O(n)$。 +#### 2.3.3 平方时间 $O(n^2)$ -#### 2.3.3 平方 $O(n^2)$ +两层嵌套循环,每层执行 $n$ 次操作的算法,时间复杂度为 $O(n^2)$。 -一般含有双层嵌套,且每层循环下的语句执行次数为 $n$ 的算法涉及平方时间复杂度。这类算法随着问题规模 $n$ 的增大,对应计算次数呈平方关系增长。 +```python +def bubble_sort(arr): + n = len(arr) + for i in range(n): # 外层循环 + for j in range(n - 1): # 内层循环 + if arr[j] > arr[j + 1]: + arr[j], arr[j + 1] = arr[j + 1], arr[j] + +def find_all_pairs(arr): + pairs = [] + for i in range(len(arr)): # 外层循环 + for j in range(len(arr)): # 内层循环 + pairs.append((arr[i], arr[j])) + return pairs +``` + +上述代码中,每个函数都包含两层嵌套循环,总操作次数为 $n^2$,时间复杂度为 $O(n^2)$。 + +#### 2.3.4 对数时间 $O(\log n)$ + +每次操作将问题规模缩小一半的算法,如「二分查找」和「分治算法」,时间复杂度为 $O(\log n)$。 ```python -def algorithm(n): - res = 0 - for i in range(n): - for j in range(n): - res += 1 - return res +def binary_search(arr, target): + left, right = 0, len(arr) - 1 + + while left <= right: + mid = (left + right) // 2 + if arr[mid] == target: + return mid + elif arr[mid] < target: + left = mid + 1 + else: + right = mid - 1 + return -1 + +def power_of_two(n): + count = 0 + while n > 1: + n = n // 2 # 每次除以2 + count += 1 + return count ``` -上述代码中,`res += 1` 在两重循环中,根据时间复杂度的乘法原理,这段代码的执行次数为 $n^2$ 次,所以其时间复杂度为 $O(n^2)$。 +上述代码中,每次将问题规模缩小一半,循环次数为 $\log_2 n$,时间复杂度为 $O(\log n)$。 -#### 2.3.4 阶乘 $O(n!)$ +#### 2.3.5 线性对数时间 $O(n \log n)$ -阶乘时间复杂度一般出现在与「全排列」、「旅行商问题暴力解法」相关的算法中。这类算法随着问题规模 $n$ 的增大,对应计算次数呈阶乘关系增长。 +线性对数一般出现在排序算法中,例如「快速排序」、「归并排序」、「堆排序」等,时间复杂度为 $O(n \log n)$。 ```python -def permutations(arr, start, end): - if start == end: - print(arr) - return - - for i in range(start, end): - arr[i], arr[start] = arr[start], arr[i] - permutations(arr, start + 1, end) - arr[i], arr[start] = arr[start], arr[i] +def merge_sort(arr): + if len(arr) <= 1: + return arr + + mid = len(arr) // 2 + left = merge_sort(arr[:mid]) # 递归处理左半部分 + right = merge_sort(arr[mid:]) # 递归处理右半部分 + + return merge(left, right) # 合并两个有序数组 + +def merge(left, right): + result = [] + i = j = 0 + + while i < len(left) and j < len(right): + if left[i] <= right[j]: + result.append(left[i]) + i += 1 + else: + result.append(right[j]) + j += 1 + + result.extend(left[i:]) + result.extend(right[j:]) + return result ``` -上述代码中实现「全排列」使用了递归的方法。假设数组 $arr$ 长度为 $n$,第一层 `for` 循环执行了 $n$ 次,第二层 `for` 循环执行了 $n - 1$ 次。以此类推,最后一层 `for` 循环执行了 $1$ 次,将所有层 `for` 循环的执行次数累乘起来为 $n \times (n - 1) \times (n - 2) \times … \times 2 \times 1 = n!$ 次。则整个算法的 `for` 循环中基本语句的执行次数为 $n!$ 次,所以对应时间复杂度为 $O(n!)$。 +上述代码中,`merge_sort` 函数采用了分治思想,每次递归将数组一分为二,递归深度为 $\log_2 n$ 层,每层处理 $n$ 个元素,整体的时间复杂度为 $O(n \log n)$。 -#### 2.3.5 对数 $O(\log n)$ +#### 2.3.6 指数时间 $O(2^n)$ -对数时间复杂度一般出现在「二分查找」、「分治」这种一分为二的算法中。这类算法随着问题规模 $n$ 的增大,对应的计算次数呈对数关系增长。 +指数时间复杂度 $O(2^n)$ 通常出现在每一步都存在两种选择、递归分支成倍增长的算法中,如递归斐波那契、子集枚举等,时间复杂度为 $O(2^n)$。 ```python -def algorithm(n): - cnt = 1 - while cnt < n: - cnt *= 2 - return cnt +def fibonacci_recursive(n): + if n <= 1: + return n + return fibonacci_recursive(n-1) + fibonacci_recursive(n-2) + +def generate_subsets(arr): + def backtrack(start, current): + result.append(current[:]) + for i in range(start, len(arr)): + current.append(arr[i]) + backtrack(i + 1, current) + current.pop() + + result = [] + backtrack(0, []) + return result ``` -上述代码中 `cnt = 1` 的时间复杂度为 $O(1)$ 可以忽略不算。`while` 循环体中 $cnt$ 从 $1$ 开始,每循环一次都乘以 $2$。当大于等于 $n$ 时循环结束。变量 $cnt$ 的取值是一个等比数列:$2^0, 2^1, 2^2, …, 2^x$,根据 $2^x = n$,可以得出这段循环体的执行次数为 $\log_2n$,所以这段代码的时间复杂度为 $O(\log_2n)$。 +上述代码中,`fibonacci_recursive` 函数每次递归都会分裂成两个子问题,递归树的节点总数为 $2^n$,因此时间复杂度为 $O(2^n)$。`generate_subsets` 函数通过回溯法枚举所有子集,每个元素有选或不选两种选择,子集总数为 $2^n$,所以整体时间复杂度也是 $O(2^n)$。 -因为 $\log_2 n = k \times \log_{10} n$,这里 $k \approx 3.322$,是一个常数系数,$\log_2 n$ 与 $\log_{10} n$ 之间差别比较小,可以忽略 $k$。并且 $\log_{10} n$ 也可以简写成 $\log n$,所以为了方便书写,通常我们将对数时间复杂度写作是 $O(\log n)$。 -#### 2.3.6 线性对数 $O(n \times \log n)$ +#### 2.3.7 阶乘时间 $O(n!)$ - 线性对数一般出现在排序算法中,例如「快速排序」、「归并排序」、「堆排序」等。这类算法随着问题规模 $n$ 的增大,对应的计算次数呈线性对数关系增长。 +阶乘时间 $O(n!)$ 通常出现在需要枚举所有排列或组合的算法中,如全排列、旅行商问题暴力解法等。随着输入规模 $n$ 的增加,算法的执行次数以阶乘级别增长,计算量极大,几乎 +无法处理较大的输入规模。 ```python -def algorithm(n): - cnt = 1 - res = 0 - while cnt < n: - cnt *= 2 - for i in range(n): - res += 1 - return res +def generate_permutations(arr): + def backtrack(start): + if start == len(arr): + result.append(arr[:]) + return + + for i in range(start, len(arr)): + arr[start], arr[i] = arr[i], arr[start] # 交换 + backtrack(start + 1) # 递归 + arr[start], arr[i] = arr[i], arr[start] # 恢复 + + result = [] + backtrack(0) + return result ``` -上述代码中外层循环的时间复杂度为 $O(\log n)$,内层循环的时间复杂度为 $O(n)$,且两层循环相互独立,则总体时间复杂度为 $O(n \times \log n)$。 +上述代码中,`generate_permutations` 函数通过回溯法枚举所有排列。每一层递归会将当前位置与后续每个元素交换,递归深度为 $n$ 层。第 1 层有 $n$ 种选择,第 2 层有 $n - 1$ 种选择,依此类推,总共 $n!$ 种排列,因此时间复杂度为 $O(n!)$。 + + +#### 2.3.8 时间复杂度对比 -#### 2.3.7 常见时间复杂度关系 +常见时间复杂度从小到大排序:$O(1)$ < $O(\log n)$ < $O(n)$ < $O(n \log n)$ < $O(n^2)$ < $O(n^3)$ < $O(2^n)$ < $O(n!)$ < $O(n^n)$ -根据从小到大排序,常见的时间复杂度主要有:$O(1)$ < $O(\log n)$ < $O(n)$ < $O(n \times \log n)$ < $O(n^2)$ < $O(n^3)$ < $O(2^n)$ < $O(n!)$ < $O(n^n)$。 +| 时间复杂度 | 输入规模 n=10 | n=100 | n=1000 | 实际应用 | +|------------|---------------|-------|--------|----------| +| $O(1)$ | 1 | 1 | 1 | 数组访问、哈希表查找 | +| $O(\log n)$| 3 | 7 | 10 | 二分查找、平衡树操作 | +| $O(n)$ | 10 | 100 | 1000 | 线性搜索、数组遍历 | +| $O(n \log n)$ | 33 | 664 | 9966 | 快速排序、归并排序 | +| $O(n^2)$ | 100 | 10000 | 1000000| 冒泡排序、选择排序 | +| $O(2^n)$ | 1024 | 1.3×10^30 | 1.1×10^301 | 递归斐波那契 | +| $O(n!)$ | 3628800 | 9.3×10^157 | 4.0×10^2567 | 全排列 | ### 2.4 最佳、最坏、平均时间复杂度 -时间复杂度是一个关于输入问题规模 $n$ 的函数。但是因为输入问题的内容不同,习惯将「时间复杂度」分为「最佳」、「最坏」、「平均」三种情况。这三种情况的具体含义如下: +由于同一算法在不同输入下的表现可能差异很大,我们通常从三个角度分析时间复杂度: -- **最佳时间复杂度**:每个输入规模下用时最短的输入所对应的时间复杂度。 -- **最坏时间复杂度**:每个输入规模下用时最长的输入所对应的时间复杂度。 -- **平均时间复杂度**:每个输入规模下所有可能的输入所对应的平均用时复杂度(随机输入下期望用时的复杂度)。 +- **最佳时间复杂度**:最理想输入下的时间复杂度 +- **最坏时间复杂度**:最差输入下的时间复杂度 +- **平均时间复杂度**:随机输入下的期望时间复杂度 -我们通过一个例子来分析下最佳、最坏、最差时间复杂度。 +**示例**:在数组中查找目标值 ```python def find(nums, val): - pos = -1 - for i in range(n): + for i in range(len(nums)): if nums[i] == val: - pos = i - break - return pos + return i + return -1 ``` -这段代码要实现的功能是:从一个整数数组 $nums$ 中查找值为 $val$ 的变量出现的位置。如果不考虑 `break` 语句,根据「2.3 时间复杂度计算」中讲的分析步骤,这个算法的时间复杂度是 $O(n)$,其中 $n$ 代表数组的长度。 - -但是如果考虑 `break` 语句,那么就需要考虑输入的内容了。如果数组中第 $1$ 个元素值就是 $val$,那么剩下 $n - 1$ 个数据都不要遍历了,那么时间复杂度就是 $O(1)$,即最佳时间复杂度为 $O(1)$。如果数组中不存在值为 $val$ 的变量,那么就需要把整个数组遍历一遍,时间复杂度就变成了 $O(n)$,即最差时间复杂度为 $O(n)$。 - -这样下来,时间复杂度就不唯一了。怎么办? - -我们都知道,最佳时间复杂度和最坏时间复杂度都是极端条件下的时间复杂度,发生的概率其实很小。为了能更好的表示正常情况下的复杂度,所以我们一般采用平均时间复杂度作为时间复杂度的计算方式。 - -还是刚才的例子,在数组 $nums$ 中查找变量值为 $val$ 的位置,总共有 $n + 1$ 种情况:「在数组的的 $0 \sim n - 1$ 个位置上」和「不在数组中」。我们将所有情况下,需要执行的语句次数累加起来,再除以 $n + 1$,就可以得到平均需要执行的语句次数,即:$\frac{1 + 2 + 3 + ... + n + n}{n + 1} = \frac{n(n + 3)}{2(n + 1)}$。将公式简化后,得到的平均时间复杂度就是 $O(n)$。 +- **最佳情况**:目标值在数组开头,时间复杂度 $O(1)$ +- **最坏情况**:目标值不存在,需要遍历整个数组,时间复杂度 $O(n)$ +- **平均情况**:假设目标值等概率出现在任意位置,平均时间复杂度 $O(n)$ -通常只有同一个算法在输入内容不同,不同时间复杂度有量级的差距时,我们才会通过三种时间复杂度表示法来区分。一般情况下,使用其中一种就可以满足需求了。 +**实际应用**:通常使用 **最坏时间复杂度** 作为算法性能的衡量标准,因为它能保证算法在任何输入下的性能上限。只有在不同情况下的时间复杂度存在量级差异时,才需要区分三种情况。 ## 3. 空间复杂度 @@ -257,15 +339,15 @@ def find(nums, val): > **空间复杂度(Space Complexity)**:在问题的输入规模为 $n$ 的条件下,算法所占用的空间大小,可以记作为 $S(n)$。一般将 **算法的辅助空间** 作为衡量空间复杂度的标准。 -除了执行时间的长短,算法所需储存空间的多少也是衡量性能的一个重要方面。而在「2. 时间复杂度」中提到的渐进符号,也同样适用于空间复杂度的度量。空间复杂度的函数可以表示为 $S(n) = O(f(n))$,它表示的是随着问题规模 $n$ 的增大,算法所占空间的增长趋势跟 $f(n)$ 相同。 +空间复杂度的渐进符号表示方法与时间复杂度相同,可以表示为 $S(n) = O(f(n))$,表示算法空间占用随问题规模 $n$ 的增长趋势。 -相比于算法的时间复杂度计算来说,算法的空间复杂度更容易计算,主要包括「局部变量(算法范围内定义的变量)所占用的存储空间」和「系统为实现递归(如果算法是递归的话)所使用的堆栈空间」两个部分。 +相对于算法的时间复杂度计算来说,算法的空间复杂度更容易计算。空间复杂度的计算主要包括局部变量占用的存储空间和递归栈空间两个部分。 -下面通过实例来说明如何计算空间复杂度。 +### 3.2 空间复杂度计算 -### 3.1 空间复杂度计算 +空间复杂度的计算主要考虑算法运行过程中额外占用的空间,包括局部变量和递归栈空间。 -#### 3.1.1 常数 $O(1)$ +#### 3.2.1 常数空间 $O(1)$ ```python def algorithm(n): @@ -275,9 +357,9 @@ def algorithm(n): return res ``` -上述代码中使用 $a$、$b$、$res$ 这 $3$ 个局部变量,其所占空间大小为常数阶,并不会随着问题规模 $n$ 的在增大而增大,所以该算法的空间复杂度为 $O(1)$。 +上述代码中,只使用了固定数量的变量,因此空间复杂度为 $O(1)$。 -#### 3.1.2 线性 $O(n)$ +#### 3.2.2 线性空间 $O(n)$ ```python def algorithm(n): @@ -286,17 +368,17 @@ def algorithm(n): return n * algorithm(n - 1) ``` -上述代码采用了递归调用的方式。每次递归调用都占用了 $1$ 个栈帧空间,总共调用了 $n$ 次,所以该算法的空间复杂度为 $O(n)$。 +上述代码中,递归深度为 $n$,需要 $O(n)$ 的栈空间。 -#### 3.1.3 常见空间复杂度关系 +#### 3.2.3 常见空间复杂度 -根据从小到大排序,常见的算法复杂度主要有:$O(1)$ < $O(\log n)$ < $O(n)$ < $O(n^2)$ < $O(2^n)$ 等。 +常见空间复杂度从小到大排序:$O(1)$ < $O(\log n)$ < $O(n)$ < $O(n^2)$ < $O(2^n)$ ## 4. 总结 -**「算法复杂度」** 包括 **「时间复杂度」** 和 **「空间复杂度」**,用来分析算法执行效率与输入问题规模 $n$ 的增长关系。通常采用 **「渐进符号」** 的形式来表示「算法复杂度」。 +**「算法复杂度」** 包括 **「时间复杂度」** 和 **「空间复杂度」**,用于衡量算法在输入规模 $n$ 增大时的资源消耗情况。通常使用**渐进符号**(如 $O$ 符号)来描述算法复杂度的增长趋势。 -常见的时间复杂度有:$O(1)$、$O(\log n)$、$O(n)$、$O(n \times \log n)$、$O(n^2)$、$O(n^3)$、$O(2^n)$、$O(n!)$。 +常见的时间复杂度有:$O(1)$、$O(\log n)$、$O(n)$、$O(n \log n)$、$O(n^2)$、$O(n^3)$、$O(2^n)$、$O(n!)$。 常见的空间复杂度有:$O(1)$、$O(\log n)$、$O(n)$、$O(n^2)$。 diff --git a/docs/00_preface/00_04_leetcode_guide.md b/docs/00_preface/00_04_leetcode_guide.md index 18407a0a..b1fd1671 100644 --- a/docs/00_preface/00_04_leetcode_guide.md +++ b/docs/00_preface/00_04_leetcode_guide.md @@ -1,12 +1,8 @@ ## 1. LeetCode 是什么 -**「LeetCode」** 是一个代码在线评测平台(Online Judge),包含了 **算法**、**数据库**、**Shell**、**多线程** 等不同分类的题目,其中以算法题目为主。我们可以通过解决 LeetCode 题库中的问题来练习编程技能,以及提高算法能力。 +**「LeetCode」** 是一个在线编程评测平台,主要包含算法、数据库、Shell、多线程等题目,其中以算法题目为主。LeetCode 上有 $3000+$ 道编程问题,支持 $16+$ 种编程语言,还有一个活跃的社区用于技术交流。我们可以通过解决 LeetCode 题库中的问题来练习编程技能,以及提高算法能力。 -LeetCode 上有 $3000+$ 道的编程问题,支持 $16+$ 种编程语言(C、C++、Java、Python 等),还有一个活跃的社区,可以用于分享技术话题、职业经历、题目交流等。 - -并且许多知名互联网公司在面试的时候喜欢考察 LeetCode 题目,通常会以手写代码的形式出现。需要面试者对给定问题进行分析并给出解答,有时还会要求面试者分析算法的时间复杂度和空间复杂度,以及算法思路。面试官通过考察面试者对常用算法的熟悉程度和实现能力来确定面试者解决问题的思维能力水平。 - -所以无论是面试国内还是国外的知名互联网公司,通过 LeetCode 刷题,充分准备好算法,对拿到一个好公司的好 offer 都是有帮助的。 +许多知名互联网公司在面试时会考察 LeetCode 题目,要求面试者分析问题、编写代码,并分析算法的时间复杂度和空间复杂度。通过 LeetCode 刷题,充分准备算法知识,对获得好的工作机会很有帮助。 ## 2. LeetCode 新手入门 @@ -24,43 +20,41 @@ LeetCode 上有 $3000+$ 道的编程问题,支持 $16+$ 种编程语言(C、 ![LeetCode 题库页面](https://qcdn.itcharge.cn/images/20210901155423.png) -#### 1. 题目标签 +#### 2.2.1 题目标签 LeetCode 的题目涉及了许多算法和数据结构。有贪心,搜索,动态规划,链表,二叉树,哈希表等等,可以通过选择对应标签进行专项刷题,同时也可以看到对应专题的完成度情况。 ![LeetCode 题目标签](https://qcdn.itcharge.cn/images/20210901155435.png) -#### 2. 题目列表 +#### 2.2.2 题目列表 LeetCode 提供了题目的搜索过滤功能。可以筛选相关题单、不同难易程度、题目完成状态、不同标签的题目。还可以根据题目编号、题解数目、通过率、难度、出现频率等进行排序。 ![LeetCode 题目列表](https://qcdn.itcharge.cn/images/20210901155450.png) -#### 3. 当前进度 +#### 2.2.3 当前进度 当前进度提供了一个直观的进度展示。在这里可以看到自己的练习概况。进度会自动展现当前的做题情况。也可以点击「[进度设置](https://leetcode.cn/session/)」创建新的进度,在这里还可以修改、删除相关的进度。 ![LeetCode 当前进度](https://qcdn.itcharge.cn/images/20210901155500.png) -#### 4. 题目详情 +#### 2.2.4 题目详情 -从题目大相关题目点击进去,就可以看到这道题目的内容描述和代码编辑器。在这里还可以查看相关的题解和自己的提交记录。 +从题目列表点击进入,就可以看到这道题目的内容描述和代码编辑器。在这里还可以查看相关的题解和自己的提交记录。 ![LeetCode 题目详情](https://qcdn.itcharge.cn/images/20210901155529.png) ### 2.3 LeetCode 刷题语言 -大厂在面试算法的时候考察的是基本功,用什么语言没有什么限制,也不会影响成绩。日常刷题建议使用自己熟悉的语言,或者语法简洁的语言刷题。 +面试时考察的是算法基本功,对于语言的选择没有限制。建议使用熟悉的语言或语法简洁的语言刷题。 -相对于 Java、Python 而言,C、C++ 相关的语法比较复杂,在做题的时候一方面需要思考思路,另一方面还要研究语法。并且复杂的语法也不利于看懂思路,耗费时间较多,不利于刷题效率。在面试的时候往往需要一个小时内尽可能的完成更多的题目,C++ 一旦语法出错很容易慌乱。当然 LeetCode 周赛的大神更偏向于使用 C++ 刷题,这是因为用 C++ 参加算法竞赛已经成为传统了,绝大多数的 OI / ACM 竞赛选手都是 C++ 大神。 - -就我个人经历而言,我大学参加 ACM 竞赛的时候,用的是 C、C++ 和一点点的 Java。现在刷 LeetCode 为了更高的刷题效率,选择了 Python。感觉用 Python 刷题能更加专注于算法与数据结构本身,也能获得更快的刷题效率。 +相对于 Python 而言,C、C++ 语法比较复杂,在做题的时候除了要思考思路,还得考虑语法,不太利于刷题。Python 等语言更简洁,能让你专注于算法思路本身,提高刷题效率。当然,算法竞赛选手通常使用 C++,已经成为传统了。 > 人生苦短,我用 Python。 ### 2.4 LeetCode 刷题流程 -在「2.2 LeetCode 题库 —— 4. 题目详情」中我们介绍了题目的相关情况。 +在「2.2.1 题目标签」中我们介绍了题目的相关情况。 ![LeetCode 题目详情](https://qcdn.itcharge.cn/images/20210901155529.png) @@ -105,7 +99,6 @@ LeetCode 提供了题目的搜索过滤功能。可以筛选相关题单、不 - 示例 1: ```python -示例 1: 输入:num1 = 12, num2 = 5 输出:17 解释:num1 是 12,num2 是 5,它们的和是 12 + 5 = 17,因此返回 17。 @@ -145,22 +138,21 @@ class Solution: ### 3.1 LeetCode 前期准备 -如果你是一个对基础算法和数据结构完全不懂的小白,那么在刷 LeetCode 之前,建议先学习一下基础的 **「数据结构」** 和 **「算法」** 知识,这样在开始刷题的时候才不会那么痛苦。 - -基础的 **「数据结构」** 和 **「算法」** 知识包括: +如果你是算法和数据结构的新手,建议在刷 LeetCode 之前先学习一下基础知识,这样刷题时会更顺利。 -- **常考的数据结构**:**数组**、**字符串**、**链表**、**树(如二叉树)** 等。 -- **常考的算法**:**分治算法**、**贪心算法**、**穷举算法**、**回溯算法**、**动态规划** 等。 +基础知识包括: +- **数据结构**:数组、字符串、链表、树(如二叉树)等。 +- **算法**:分治、贪心、回溯、动态规划等。 这个阶段推荐看一些经典的算法基础书来进行学习。这里推荐一下我看过的感觉不错的算法书: -- 【书籍】「[算法(第 4 版)- 谢路云 译](https://book.douban.com/subject/19952400/)」 -- 【书籍】「[大话数据结构 - 程杰 著](https://book.douban.com/subject/6424904/)」 -- 【书籍】「[趣学算法 - 陈小玉 著](https://book.douban.com/subject/27109832/)」 -- 【书籍】「[算法图解 - 袁国忠 译](https://book.douban.com/subject/26979890/)」 -- 【书籍】「[算法竞赛入门经典(第 2 版) - 刘汝佳 著](https://book.douban.com/subject/25902102/)」 -- 【书籍】「[数据结构与算法分析 - 冯舜玺 译](https://book.douban.com/subject/1139426/)」 -- 【书籍】「[算法导论(原书第 3 版) - 殷建平 / 徐云 / 王刚 / 刘晓光 / 苏明 / 邹恒明 / 王宏志 译](https://book.douban.com/subject/20432061/)」 +- 【书籍】[算法(第 4 版)- 谢路云 译](https://book.douban.com/subject/19952400/) +- 【书籍】[大话数据结构 - 程杰 著](https://book.douban.com/subject/6424904/) +- 【书籍】[趣学算法 - 陈小玉 著](https://book.douban.com/subject/27109832/) +- 【书籍】[算法图解 - 袁国忠 译](https://book.douban.com/subject/26979890/) +- 【书籍】[算法竞赛入门经典(第 2 版) - 刘汝佳 著](https://book.douban.com/subject/25902102/) +- 【书籍】[数据结构与算法分析 - 冯舜玺 译](https://book.douban.com/subject/1139426/) +- 【书籍】[算法导论(原书第 3 版) - 殷建平 / 徐云 / 王刚 / 刘晓光 / 苏明 / 邹恒明 / 王宏志 译](https://book.douban.com/subject/20432061/) 当然,也可以直接看我写的「算法通关手册」,欢迎指正和提出建议,万分感谢。 @@ -169,9 +161,9 @@ class Solution: ### 3.2 LeetCode 刷题顺序 -讲个笑话,从前有个人以为 LeetCode 的题目是按照难易程度排序的,所以他从「[1. 两数之和](https://leetcode.cn/problems/two-sum)」 开始刷题,结果他卡在了 「[4. 寻找两个正序数组的中位数](https://leetcode.cn/problems/median-of-two-sorted-arrays)」这道困难题上。 +讲个笑话,从前有个人以为 LeetCode 的题目是按照难易程度排序的,所以他从 [1. 两数之和](https://leetcode.cn/problems/two-sum) 开始刷题,结果他卡在了 [4. 寻找两个正序数组的中位数](https://leetcode.cn/problems/median-of-two-sorted-arrays) 这道困难题上。 -LeetCode 的题目序号并不是按照难易程度进行排序的,所以除非硬核人士,不建议按照序号顺序刷题。如果是新手刷题的话,推荐先从「简单」难度等级的算法题开始刷题。等简单题上手熟练之后,再开始按照标签类别,刷中等难度的题。中等难度的题刷差不多之后,可以考虑刷面试题或者难题。 +LeetCode 的题目序号并不是按难易程度排序的,不建议按序号顺序刷题。新手建议从「简单」难度开始,熟练后再刷中等难度题目,最后考虑面试题或难题。 其实 LeetCode 官方网站上就有整理好的题目不错的刷题清单。链接为:[https://leetcode.cn/leetbook/](https://leetcode.cn/leetbook/)。可以先刷这里边的题目卡片。我这里也做了一个整理。 @@ -179,7 +171,7 @@ LeetCode 的题目序号并不是按照难易程度进行排序的,所以除 [1. 初级算法](https://leetcode.cn/leetbook/detail/top-interview-questions-easy/)、[2. 数组类算法](https://leetcode.cn/leetbook/detail/all-about-array/)、[3. 数组和字符串](https://leetcode.cn/leetbook/detail/array-and-string/)、[4. 链表类算法](https://leetcode.cn/leetbook/detail/linked-list/)、[5. 哈希表](https://leetcode.cn/leetbook/detail/hash-table/)、[6. 队列 & 栈](https://leetcode.cn/leetbook/detail/queue-stack/)、[7. 递归](https://leetcode.cn/leetbook/detail/recursion/)、[8. 二分查找](https://leetcode.cn/leetbook/detail/binary-search/)、[9. 二叉树](https://leetcode.cn/leetbook/detail/data-structure-binary-tree/)、[10. 中级算法](https://leetcode.cn/leetbook/detail/top-interview-questions-medium/)、[11. 高级算法](https://leetcode.cn/leetbook/detail/top-interview-questions-hard/)、[12. 算法面试题汇总](https://leetcode.cn/leetbook/detail/top-interview-questions/)。 -当然还可以通过官方新推出的「[学习计划 - 力扣](https://leetcode.cn/study-plan/)」按计划每天刷题。 +当然还可以通过官方推出的「[学习计划 - 力扣](https://leetcode.cn/study-plan/)」按计划每天刷题。 或者直接按照我整理的分类刷题列表进行刷题: @@ -189,8 +181,8 @@ LeetCode 的题目序号并不是按照难易程度进行排序的,所以除 > **说明**:「LeetCode 面试最常考 100 题」、「LeetCode 面试最常考 200 题」是笔者根据「[CodeTop 企业题库](https://codetop.cc/home)」按频度从高到低进行筛选,并且去除了一部分 LeetCode 上没有的题目和重复题目后得到的题目清单。 -- 「LeetCode 面试最常考 100 题:[点击打开「LeetCode 面试最常考 100 题」](https://github.com/itcharge/AlgoNote/tree/main/docs/00_preface/00_07_interview_100_list.md) -- 「LeetCode 面试最常考 200 题:[点击打开「LeetCode 面试最常考 200 题」](https://github.com/itcharge/AlgoNote/tree/main/docs/00_preface/00_08_interview_200_list.md) +- [LeetCode 面试最常考 100 题](https://github.com/itcharge/AlgoNote/tree/main/docs/00_preface/00_07_interview_100_list.md) +- [LeetCode 面试最常考 200 题](https://github.com/itcharge/AlgoNote/tree/main/docs/00_preface/00_08_interview_200_list.md) ### 3.3 LeetCode 刷题技巧 @@ -206,78 +198,57 @@ LeetCode 的题目序号并不是按照难易程度进行排序的,所以除 > **五分钟思考法**:如果一道题如果 $5$ 分钟之内有思路,就立即动手写代码解题。如果 $5$ 分钟之后还没有思路,就直接去看题解。然后根据题解的思路,自己去实现代码。如果发现自己看了题解也无法实现代码,就认真阅读题解的代码,并理解代码的逻辑。 -这种刷题方法其实跟英语里边的背单词过程是类似的。 +其实,刷算法题的过程和背英语单词很相似。 -一开始零基础学英语的时候,先学最简单的字母,不用纠结为什么这个字母这么写。然后学习简单的单词,也不用去纠结这个单词为啥就是这个意思,学就完事。在掌握了基本词汇之后,再去学习词组,学习短句子,然后长句子,再然后再看文章。 +刚开始学英语时,先从最基础的字母学起,不必纠结每个字母的由来。接着学习简单的单词,也不用深究单词的含义,先记住再说。掌握了基础词汇后,再逐步学习词组、短句、长句,最后阅读文章。 -而且,在学英语单词的时候,也不是学一遍就会了。而是不断的重复练习、重复记忆加深印象。 +背单词不是看一遍就能记住,而是需要不断重复练习、反复记忆来加深印象。 -算法刷题也是一样,零基础刷题的时候,不要过分纠结怎么自己就想不出来算法的解法,怎么就想不到更加高效的方法。遇到没有思路的题目,老老实实去看题解区的高赞题解,尽可能的让自己快速入门。 +刷算法题也是如此。零基础时,不要纠结为什么自己想不出解法,或者为什么没想到更高效的方法。遇到没有思路的题目时,直接去看题解区的高赞解答,尽快积累经验,帮助自己快速入门。 #### 3.3.2 重复刷题 > **重复刷题**:遇见不会的题,多刷几遍,不断加深理解。 -算法题有时候一遍刷过去,过的时间长了可能就忘了,看到之前做的题不能够立马想到解题思路。这其实还是跟背单词一样,单词也不是看一遍就完全记住了。所以题目刷完一遍并不是结束了,还需要不断的回顾。 +刷算法题经常是做完一遍后,隔一段时间就忘记了,看到之前做过的题目也未必能立刻想起解题思路。所以,刷题并不是做完一遍就结束了,还需要定期回顾和复习。 -而且,一道题目可能有多种解法,还可能有好的算法思路。 +此外,一道题往往有多种解法和不同的优化思路。第一次做时可能只想到一种方法,等到第二遍、第三遍时,可能会发现新的解法或更优的实现。 -最开始做的时候,可能只能想到一种思路,再做第二遍的时候,很有可能会想到了新的解法,新的优化方式等等。 - -所以,算法题在做完一遍之后遇见不会的,还可以多刷几遍,不断加深理解。 +因此,建议对不会的题目多刷几遍,通过反复练习不断加深理解和记忆。 #### 3.3.3 按专题分类刷题 > **按专题分类刷题**:按照不同专题分类刷题,既可以巩固刚学完的算法知识,还可以提高刷题效率。 -在上边「3.2 LeetCode 刷题顺序」我们给出了刷题顺序和目录。这里的刷题顺序其实就是按照不同分类来进行排序的。 - -我们可以在学习相关算法和数据结构知识时,顺便做一下该算法和数据结构知识专题下对应的题目清单。比如在学习完「链表」相关的基础知识时,可以将「链表」相关的基础题目刷完,或者刷官方 LeetBook 清单 [4. 链表类算法](https://leetcode.cn/leetbook/detail/linked-list/) 中的对应题目。 +按专题分类刷题有两个好处: -按照专题分类刷题的第一个好处是:**可以巩固刚学完的算法知识。** 如果是第一次学习对应的算法知识,刚学完可能对里边的相关知识理解的不够透彻,或者说可能会遗漏一些关键知识点,这时候可以通过刷对应题目的方式来帮助我们巩固刚学完的算法知识。 - -按照专题分类刷题的第二个好处是:**可以提高刷题效率。** 因为同一类算法题目所用到的算法知识其实是相同或者相似的,同一种解题思路可以运用到多道题目中。通过不断求解同一类算法专题下的题目,可以大大的提升我们的刷题速度。 +1. **巩固知识**:刚学完某个算法时,可能对里边的相关知识理解的不够透彻,或者说可能会遗漏一些关键知识点,这时候可以通过刷对应题目的方式来帮助我们巩固刚学完的算法知识。 +2. **提高效率**:同类题目所用到的算法知识其实是相同或者相似的,同一种解题思路可以运用到多道题目中。通过不断求解同一类算法专题下的题目,可以大大的提升我们的刷题速度。 #### 3.3.4 写解题报告 ->**写解题报告**:如果能够用简介清晰的语言让别人听懂这道题目的思路,那就说明你真正理解了这道题的解法。 - -刷算法题,有一个十分有用的技巧,就是 **「写解题报告」**。如果你刷完一道题,能把这道题的解题步骤,做题思路用通俗易懂的话写成解题报告,那么这道题就算是掌握了。这其实就相当于「费曼学习法」的思维。 +> **写解题报告**:如果能够用简洁清晰的语言让别人听懂这道题目的思路,那就说明你真正理解了这道题的解法。 -这样,也可以减少刷题的遍数。如果在写题的时候遇到之前刷过的题,但一时之间没有思路的,就可以看看自己之前的解题报告。这样就节省了大量重复刷题的时间。 +写解题报告是很有用的技巧。如果你能用通俗易懂的语言写出解题思路,说明你真正理解了这道题。这相当于「费曼学习法」,也能减少重复刷题的时间。 #### 3.3.5 坚持刷题 > **坚持刷题**:算法刷题没有捷径,只有不断的刷题、总结,再刷题,再总结。 -千万不要相信很多机构宣传的「3 天带你精通数据结构」、「7 天从算法零基础到精通」能让你快速学会算法知识。 +千万不要相信「3天精通数据结构」这类速成学习宣传。学习算法需要不断积累,反复理解算法思想,并通过刷题来应用知识。 -学习算法和数据结构知识,不能靠速成,只能靠不断的积累,一步一步的推敲算法步骤,一遍又一遍的理解算法思想,才能掌握一个又一个的算法知识。而且还要不断的去刷该算法对应专题下的题目,才能将算法知识应用到日常的解题过程中。这样才能算彻底掌握了一个算法或一种解题思路。 - -根据我过去一年多和小伙伴们一起刷题打卡的经验发现:**那些能够坚持每天刷题,并最终学会一整套「基础算法知识」和「基础数据结构知识」的人,总是少数人**。 - -大部分总会因为种种主观和客观原因而放弃了刷题(工作繁忙、学习任务繁重、个人精力有限、时间不足等)。 - -但不管怎么样,如果你当初选择了学习算法知识,选择了通过刷题来通过面试,以便获取更好的工作岗位。那我希望在达成自己的目标之前,可以一直坚持下去,去「刻意练习」。在刷题的过程中收获知识,通过刷题得到满足感,从而把刷题变成兴趣。 - -这些话有些鸡汤了,但都是我的心里话。希望大家能够一起坚持刷题,争取早日实现自己的目标。 +根据我的个人经验,能坚持每天刷题并掌握基础算法知识的人总是少数。但如果你选择了学习算法,希望在达成目标前能坚持下去,通过「刻意练习」把刷题变成兴趣。 ## 4. 总结 -### 4.1 LeetCode - -LeetCode 是一个在线编程练习平台,主要用于提升算法和编程能力。它包含大量题目,涵盖多种编程语言,适合准备技术面试。 - -新手可以从简单题目开始,逐步学习数据结构和算法。注册后,用户可以通过题库选择题目,按标签或难度分类练习。刷题时,建议使用熟悉的编程语言,如 Python,以提高效率。 - -### 4.2 刷题技巧 +LeetCode 是一个在线编程练习平台,主要用于提升算法和编程能力。新手可以从简单题目开始,逐步学习数据结构和算法。 -刷题需要坚持和重复练习。按专题分类刷题效果更好,可以巩固知识点。写解题报告有助于加深理解。 +刷题技巧包括:五分钟思考法、重复刷题、按专题分类刷题、写解题报告、坚持刷题。最重要的是坚持,通过不断练习和总结来掌握算法知识。 ## 练习题目 -- [2235. 两整数相加](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/2200-2299/add-two-integers.md) -- [1929. 数组串联](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/1900-1999/concatenation-of-array.md) +- [2235. 两整数相加](https://github.com/itcharge/AlgoNote/tree/main/docs/solutions/2200-2299/add-two-integers.md) +- [1929. 数组串联](https://github.com/itcharge/AlgoNote/tree/main/docs/solutions/1900-1999/concatenation-of-array.md) ## 参考资料 From 8c9a41e7ef487dcc0ca46b3fb63e91d25f7f58c6 Mon Sep 17 00:00:00 2001 From: ITCharge Date: Mon, 18 Aug 2025 17:58:05 +0800 Subject: [PATCH 02/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=92=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=AF=AD=E5=8F=A5=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/00_preface/00_01_preface.md | 1 - docs/01_array/01_01_array_basic.md | 241 ++++++------- docs/01_array/01_02_array_sort.md | 77 ++-- docs/01_array/01_03_array_bubble_sort.md | 94 ++--- docs/01_array/01_04_array_selection_sort.md | 55 +-- docs/01_array/01_05_array_insertion_sort.md | 58 +-- docs/01_array/01_06_array_shell_sort.md | 76 ++-- docs/01_array/01_07_array_merge_sort.md | 68 ++-- docs/01_array/01_08_array_quick_sort.md | 118 +++--- docs/01_array/01_09_array_heap_sort.md | 336 +++++++++++------- docs/01_array/01_10_array_counting_sort.md | 56 +-- docs/01_array/01_11_array_bucket_sort.md | 48 +-- docs/01_array/01_12_array_radix_sort.md | 48 +-- docs/01_array/01_13_array_binary_search_01.md | 108 +++--- docs/01_array/01_14_array_binary_search_02.md | 206 +++++------ 15 files changed, 854 insertions(+), 736 deletions(-) diff --git a/docs/00_preface/00_01_preface.md b/docs/00_preface/00_01_preface.md index 9ce4dfe5..72d4142c 100644 --- a/docs/00_preface/00_01_preface.md +++ b/docs/00_preface/00_01_preface.md @@ -44,4 +44,3 @@ LeetCode 等平台的算法题已成为行业通用标准,许多公司会直 学习算法应当循序渐进,从基础数据结构入手,逐步掌握常见的算法思想。每当学习一个新概念,都要通过实际题目加以巩固,长期积累下来,才能建立起完善的算法知识体系。 本书「算法通关手册」旨在帮助读者系统学习算法知识,既包含基础理论讲解,也有大量实战题目分析。通过理论与实践相结合,读者能够真正掌握算法精髓,提升解决问题的能力。无论是备战面试,还是提升编程能力,这本书都将为你带来切实的帮助。 - diff --git a/docs/01_array/01_01_array_basic.md b/docs/01_array/01_01_array_basic.md index 2ab3d233..c3bf947d 100644 --- a/docs/01_array/01_01_array_basic.md +++ b/docs/01_array/01_01_array_basic.md @@ -2,64 +2,67 @@ ### 1.1 数组定义 -> **数组(Array)**:一种线性表数据结构。它使用一组连续的内存空间,来存储一组具有相同类型的数据。 +> **数组(Array)**:一种线性表数据结构,利用一段连续的内存空间,存储一组相同类型的数据。 -简单来说,**「数组」** 是实现线性表的顺序结构存储的基础。 +简而言之,**「数组」** 是线性表顺序存储结构的典型代表。 -以整数数组为例,数组的存储方式如下图所示。 +以整数数组为例,其存储方式如下图所示: ![数组](https://qcdn.itcharge.cn/images/202405091955166.png) -如上图所示,假设数据元素的个数为 $n$,则数组中的每一个数据元素都有自己的下标索引,下标索引从 $0$ 开始,到 $n - 1$ 结束。数组中的每一个「下标索引」,都有一个与之相对应的「数据元素」。 +如上图,假设数组包含 $n$ 个元素,每个元素都有唯一的下标索引,范围从 $0$ 到 $n - 1$。每个下标对应一个数据元素。 -从上图还可以看出,数组在计算机中的表示,就是一片连续的存储单元。数组中的每一个数据元素都占有一定的存储单元,每个存储单元都有自己的内存地址,并且元素之间是紧密排列的。 +可以看出,数组在计算机中本质上是一段连续的内存区域。每个元素都占用相同大小的存储单元,这些单元都有自己的内存地址,并且在物理内存中是依次排列的。 -我们还可以从两个方面来解释一下数组的定义。 +我们可以从两个角度理解数组的定义: -> 1. **线性表**:线性表就是所有数据元素排成像一条线一样的结构,线性表上的数据元素都是相同类型,且每个数据元素最多只有前、后两个方向。数组就是一种线性表结构,此外,栈、队列、链表都是线性表结构。 -> 2. **连续的内存空间**:线性表有两种存储结构:「顺序存储结构」和「链式存储结构」。其中,「顺序存储结构」是指占用的内存空间是连续的,相邻数据元素之间,物理内存上的存储位置也相邻。数组也是采用了顺序存储结构,并且存储的数据都是相同类型的。 +> 1. **线性表**:线性表是一种数据元素顺序排列、类型相同的数据结构,每个元素最多只有前驱和后继两个相邻元素。数组正是线性表的一种典型实现,此外,栈、队列、链表等也属于线性表结构。 +> 2. **连续的内存空间**:线性表有「顺序存储」和「链式存储」两种方式。顺序存储结构要求内存空间连续,相邻元素在物理内存中紧挨着。数组采用的正是顺序存储结构,且所有元素类型一致。 -综合这两个角度,数组就可以看做是:使用了「顺序存储结构」的「线性表」的一种实现方式。 +综合这两个角度,数组可以看作是采用「顺序存储结构」实现的「线性表」。 ### 1.2 如何随机访问数据元素 -数组的一个最大特点是:**可以进行随机访问**。即数组可以根据下标,直接定位到某一个元素存放的位置。 +数组最显著的特点是:**支持随机访问**。也就是说,可以通过下标直接定位并访问任意一个元素。 -那么,计算机是如何实现根据下标随机访问数组元素的? +那么,计算机是如何实现通过下标高效访问数组元素的呢? -计算机给一个数组分配了一组连续的存储空间,其中第一个元素开始的地址被称为 **「首地址」**。每个数据元素都有对应的下标索引和内存地址,计算机通过地址来访问数据元素。当计算机需要访问数组的某个元素时,会通过 **「寻址公式」** 计算出对应元素的内存地址,然后访问地址对应的数据元素。 +实际上,数组在内存中被分配为一段连续的空间,第一个元素的地址称为 **「首地址」**。每个元素都有唯一的下标和对应的内存地址。计算机在访问数组元素时,会利用下标通过 **「寻址公式」** 快速计算出目标元素的内存地址,从而实现高效访问。 -寻址公式如下:**下标 $i$ 对应的数据元素地址 = 数据首地址 + $i$ × 单个数据元素所占内存大小**。 +寻址公式为:**下标 $i$ 的元素地址 = 首地址 + $i$ × 单个元素占用的字节数** ### 1.3 多维数组 -上面介绍的数组只有一个维度,称为一维数组,其数据元素也是单下标变量。但是在实际问题中,很多信息是二维或者是多维的,一维数组已经满足不了我们的需求,所以就有了多维数组。 +前面介绍的是只有一个维度的数组,称为一维数组,其每个数据元素都通过单一的下标进行访问。但在实际应用中,许多数据具有二维或多维结构,一维数组已无法满足需求,因此引入了多维数组的概念。 -以二维数组为例,数组的形式如下图所示。 +以二维数组为例,其结构如下图所示: ![二维数组](https://qcdn.itcharge.cn/images/202405091957859.png) -二维数组是一个由 $m$ 行 $n$ 列数据元素构成的特殊结构,其本质上是以数组作为数据元素的数组,即 **「数组的数组」**。二维数组的第一维度表示行,第二维度表示列。 +二维数组由 $m$ 行 $n$ 列的数据元素组成,本质上可以理解为「数组的数组」,即每个元素本身也是一个数组。第一维表示行,第二维表示列。在内存中,二维数组通常采用行优先或列优先的存储方式。 -我们可以将二维数组看做是一个矩阵,并处理矩阵的相关问题,比如转置矩阵、矩阵相加、矩阵相乘等等。 +二维数组常被视为矩阵,可以用于处理如矩阵转置、矩阵加法、矩阵乘法等相关问题。 ### 1.4 不同编程语言中数组的实现 -在具体的编程语言中,数组这个数据结构的实现方式具有一定差别。 +在不同的编程语言中,数组的数据结构实现存在一定差异。 -C / C++ 语言中的数组最接近数组结构定义中的数组,使用的是一块存储相同类型数据的、连续的内存空间。不管是基本类型数据,还是结构体、对象,在数组中都是连续存储的。例如: +C / C++ 语言中的数组实现最贴合数据结构中对数组的定义:它们使用一块连续的内存空间来存储相同类型的数据元素。无论是基本数据类型,还是结构体、对象,在数组中都以连续方式排列。例如: ```C++ int arr[3][4] = {{0, 1, 2, 3}, {4, 5, 6, 7}, {8, 9, 10, 11}}; ``` -Java 中的数组跟数据结构定义中的数组不太一样。Java 中的数组也是存储相同类型数据的,但所使用的内存空间却不一定是连续(多维数组中)。且如果是多维数组,其嵌套数组的长度也可以不同。例如: +Java 中的数组同样用于存储相同类型的数据,并且在底层实现中也是连续存储的。但在多维数组的情况下,Java 允许创建不规则数组(jagged array),即每个嵌套数组的长度可以不同。例如: ```Java -int[][] arr = new int[3][]{ {1,2,3}, {4,5}, {6,7,8,9}}; +int[][] arr = new int[3][]; +arr[0] = new int[]{1, 2, 3}; +arr[1] = new int[]{4, 5}; +arr[2] = new int[]{6, 7, 8, 9}; ``` -原生 Python 中其实没有数组的概念,而是使用了类似 Java 中的 ArrayList 容器类数据结构,叫做列表。通常我们把列表来作为 Python 中的数组使用。Python 中列表存储的数据类型可以不一致,数组长度也可以不一致。例如: +在原生 Python 中,并不存在严格意义上的「数组」这一数据结构,而是提供了一种名为「列表(list)」的容器类型,功能类似于 Java 中的 ArrayList。我们通常将列表作为 Python 中的数组来使用。与传统数组不同,Python 的列表不仅可以存储不同类型的数据元素,长度也可以动态变化,并且支持丰富的内置方法。例如: ```python arr = ['python', 'java', ['asp', 'php'], 'c'] @@ -67,23 +70,29 @@ arr = ['python', 'java', ['asp', 'php'], 'c'] ## 2. 数组的基本操作 -数据结构的操作一般涉及到增、删、改、查共 $4$ 种情况,下面我们一起来看一下数组的这 $4$ 种基本操作。 +数组的基本操作主要包括增、删、改、查四类,下面我们分别介绍数组在这四种操作下的实现方式。 ### 2.1 访问元素 -> **访问数组中第 $i$ 个元素**: +> **访问数组中第 $index$ 个元素**: > -> 1. 只需要检查 $i$ 的范围是否在合法的范围区间,即 $0 \le i \le len(nums) - 1$。超出范围的访问为非法访问。 -> 2. 当位置合法时,由给定下标得到元素的值。 +> 1. 首先检查下标 $index$ 是否在合法范围内,即 $0 \le index \le len(nums) - 1$,超出该范围属于非法访问。 +> 2. 如果下标合法,则可直接通过下标获取对应元素的值。 +> 3. 如果下标不合法,则抛出异常或返回特殊值。 + ```python # 从数组 nums 中读取下标为 i 的数据元素值 -def value(nums, i): - if 0 <= i <= len(nums) - 1: - print(nums[i]) - +def get_element(nums: list[int], index: int): + """获取数组中指定下标的元素值""" + if 0 <= index < len(nums): + return nums[index] + else: + raise IndexError(f"数组下标 {index} 超出范围 [0, {len(nums)-1}]") + +# 示例用法 arr = [0, 5, 2, 3, 7, 1, 6] -value(arr, 3) +print(get_element(arr, 3)) # 输出: 3 ``` 「访问数组元素」的操作不依赖于数组中元素个数,因此,「访问数组元素」的时间复杂度为 $O(1)$。 @@ -92,147 +101,127 @@ value(arr, 3) > **查找数组中元素值为 $val$ 的位置**: > -> 1. 建立一个基于下标的循环,每次将 $val$ 与当前数据元素 $nums[i]$ 进行比较。 -> 2. 在找到元素的时候返回元素下标。 -> 3. 遍历完找不到时可以返回一个特殊值(例如 $-1$)。 +> 1. 遍历数组,将目标值 $val$ 与每个元素进行比较。 +> 2. 找到匹配元素时返回其下标。 +> 3. 遍历完未找到时返回特殊值(如 $-1$)。 ```python -# 从数组 nums 中查找元素值为 val 的数据元素第一次出现的位置 -def find(nums, val): +def find_element(nums: list[int], val: int): + """查找数组中元素值为 val 的位置""" for i in range(len(nums)): if nums[i] == val: return i return -1 +# 示例用法 arr = [0, 5, 2, 3, 7, 1, 6] -print(find(arr, 5)) +print(find_element(arr, 5)) # 输出: 1 +print(find_element(arr, 9)) # 输出: -1 (未找到) ``` -在「查找元素」的操作中,如果数组无序,那么我们只能通过将 $val$ 与数组中的数据元素逐一对比的方式进行查找,也称为线性查找。而线性查找操作依赖于数组中元素个数,因此,「查找元素」的时间复杂度为 $O(n)$。 +当数组无序时,查找元素只能通过将 $val$ 与数组中的每个元素依次比较,这种方式称为线性查找。由于需要遍历整个数组,线性查找的时间复杂度为 $O(n)$。 ### 2.3 插入元素 -插入元素操作分为两种:「在数组尾部插入值为 $val$ 的元素」和「在数组第 $i$ 个位置上插入值为 $val$ 的元素」。 - -> **在数组尾部插入值为 $val$ 的元素**: -> -> 1. 如果数组尾部容量不满,则直接把 $val$ 放在数组尾部的空闲位置,并更新数组的元素计数值。 -> 2. 如果数组容量满了,则插入失败。不过,Python 中的 list 列表做了其他处理,当数组容量满了,则会开辟新的空间进行插入。 - -Python 中的 list 列表直接封装了尾部插入操作,直接调用 `append` 方法即可。 - -![插入元素](https://qcdn.itcharge.cn/images/20210916222517.png) - -```python -arr = [0, 5, 2, 3, 7, 1, 6] -val = 4 -arr.append(val) -print(arr) -``` - -「在数组尾部插入元素」的操作不依赖数组个数,因此,「在数组尾部插入元素」的时间复杂度为 $O(1)$。 - -> **在数组第 $i$ 个位置上插入值为 $val$ 的元素**: +> **在数组第 $index$ 个位置插入值 $val$**: > -> 1. 先检查插入下标 $i$ 是否合法,即 $0 \le i \le len(nums)$。 -> 2. 确定合法位置后,通常情况下第 $i$ 个位置上已经有数据了(除非 $i == len(nums)$),要把第 $i \sim len(nums) - 1$ 位置上的元素依次向后移动。 -> 3. 然后再在第 $i$ 个元素位置赋值为 $val$,并更新数组的元素计数值。 - -Python 中的 list 列表直接封装了中间插入操作,直接调用 `insert` 方法即可。 +> 1. 检查 $index$ 是否在 $0 \le index \le len(nums)$ 范围内。 +> 2. 扩展数组长度,为新元素腾出空间。 +> 3. 将 $index$ 及其后的元素整体向后移动一位。 +> 4. 在 $index$ 位置插入 $val$。 -![插入中间元素](https://qcdn.itcharge.cn/images/20210916224032.png) +![插入元素](https://qcdn.itcharge.cn/images/20210916224032.png) ```python +def insert_element(nums: list[int], index: int, val: int): + """在指定位置插入元素""" + # 检查 index 是否在有效范围内 + if 0 <= index <= len(nums): + # 扩展数组长度,在末尾添加一个占位元素 + nums.append(0) + # 将 index 及其后的元素整体向后移动一位 + for i in range(len(nums) - 1, index, -1): + nums[i] = nums[i - 1] + # 在 index 位置插入 val + nums[index] = val + return True + else: + # 索引不在范围内,返回错误 + return False + +# 示例用法 arr = [0, 5, 2, 3, 7, 1, 6] -i, val = 2, 4 -arr.insert(i, val) -print(arr) +result = insert_element(arr, 2, 4) +print(f"插入结果: {result}") # 输出: 插入结果: True +print(f"插入后数组: {arr}") # 输出: [0, 5, 4, 2, 3, 7, 1, 6] ``` 「在数组中间位置插入元素」的操作中,由于移动元素的操作次数跟元素个数有关,因此,「在数组中间位置插入元素」的最坏和平均时间复杂度都是 $O(n)$。 ### 2.4 改变元素 -> **将数组中第 $i$ 个元素值改为 $val$**: +> **将数组中第 $index$ 个元素值改为 $val$**: > -> 1. 需要先检查 $i$ 的范围是否在合法的范围区间,即 $0 \le i \le len(nums) - 1$。 -> 2. 然后将第 $i$ 个元素值赋值为 $val$。 +> 1. 检查 $index$ 是否在 $0 \le index \le len(nums) - 1$ 范围内。 +> 2. 将第 $index$ 个元素值赋值为 $val$。 ![改变元素](https://qcdn.itcharge.cn/images/20210916224722.png) ```python -def change(nums, i, val): - if 0 <= i <= len(nums) - 1: - nums[i] = val - +def change_element(nums: list[int], index: int, val: int): + """修改数组中指定位置的元素值""" + if 0 <= index < len(nums): + nums[index] = val + return True + else: + return False # 索引超出范围 + +# 示例用法 arr = [0, 5, 2, 3, 7, 1, 6] -i, val = 2, 4 -change(arr, i, val) -print(arr) +result = change_element(arr, 2, 4) +print(f"修改结果: {result}") # 输出: 修改结果: True +print(f"修改后数组: {arr}") # 输出: [0, 5, 4, 3, 7, 1, 6] ``` -「改变元素」的操作跟访问元素操作类似,访问操作不依赖于数组中元素个数,因此,「改变元素」的时间复杂度为 $O(1)$。 +「改变元素」操作与访问元素类似,都是通过下标直接定位,无需遍历数组,操作时间与数组长度无关,因此其时间复杂度为 $O(1)$。 ### 2.5 删除元素 -删除元素分为三种情况:「删除数组尾部元素」、「删除数组第 $i$ 个位置上的元素」、「基于条件删除元素」。 - -> **删除数组尾部元素**: -> -> 1. 只需将元素计数值减一即可。 - -Python 中的 list 列表直接封装了删除数组尾部元素的操作,只需要调用 `pop` 方法即可。 - -![删除尾部元素](https://qcdn.itcharge.cn/images/20210916233914.png) - -```python -arr = [0, 5, 2, 3, 7, 1, 6] -arr.pop() -print(arr) -``` - -「删除数组尾部元素」的操作,不依赖于数组中的元素个数,因此,「删除数组尾部元素」的时间复杂度为 $O(1)$。 - -> **删除数组第 $i$ 个位置上的元素**: +> **删除数组中第 $index$ 个位置的元素**: > -> 1. 先检查下标 $i$ 是否合法,即 $0 \le i \le len(nums) - 1$。 -> 2. 如果下标合法,则将第 $i + 1$ 个位置到第 $len(nums) - 1$ 位置上的元素依次向左移动。 -> 3. 删除后修改数组的元素计数值。 - -Python 中的 list 列表直接封装了删除数组中间元素的操作,只需要以下标作为参数调用 `pop` 方法即可。 - -![删除中间元素](https://qcdn.itcharge.cn/images/20210916234013.png) +> 1. 检查下标 $index$ 是否在合法范围内,即 $0 \le index < len(nums)$。 +> 2. 将 $index + 1$ 位置及其后的元素整体向前移动一位。 +> 3. 删除最后一个元素(或更新数组长度)。 -``` -arr = [0, 5, 2, 3, 7, 1, 6] -i = 3 -arr.pop(i) -print(arr) -``` - -「删除数组中间位置元素」的操作同样涉及移动元素,而移动元素的操作次数跟元素个数有关,因此,「删除数组中间位置元素」的最坏和平均时间复杂度都是 $O(n)$。 - -> **基于条件删除元素**:这种操作一般不给定被删元素的位置,而是给出一个条件要求删除满足这个条件的(一个、多个或所有)元素。这类操作也是通过循环检查元素,查找到元素后将其删除。 +![删除元素](https://qcdn.itcharge.cn/images/20210916234013.png) ```python +def delete_element(nums: list[int], index: int): + """删除数组中指定位置的元素""" + if 0 <= index < len(nums): + # 将 index 后的元素整体向前移动一位 + for i in range(index, len(nums) - 1): + nums[i] = nums[i + 1] + # 删除最后一个元素(或更新数组长度) + nums.pop() + return True + else: + return False # 索引超出范围 + +# 示例用法 arr = [0, 5, 2, 3, 7, 1, 6] -arr.remove(5) -print(arr) +result = delete_element(arr, 2) +print(f"删除结果: {result}") # 输出: 删除结果: True +print(f"删除后数组: {arr}") # 输出: [0, 5, 3, 7, 1, 6] ``` -「基于条件删除元素」的操作同样涉及移动元素,而移动元素的操作次数跟元素个数有关,因此,「基于条件删除元素」的最坏和平均时间复杂度都是 $O(n)$。 - ---- - -到这里,有关数组的基础知识就介绍完了。下面进行一下总结。 +「删除元素」需要移动后续元素,移动次数与数组长度相关,因此时间复杂度为 $O(n)$。 ## 3. 总结 -数组是一种简单的数据结构。它使用连续的内存空间存储相同类型的数据。数组的最大特点的支持随机访问,可以根据下标快速访问元素。 - -访问数组元素、修改元素的时间复杂度是 $O(1)$。在数组尾部插入、删除元素的时间复杂度也是 $O(1)$。在数组中间插入、删除元素的时间复杂度是 $O(n)$。 +数组是一种基础且重要的数据结构,采用连续的内存空间来存储同类型的数据。其最大优势在于支持随机访问,可以通过下标高效地定位和访问任意元素。 -不同编程语言中数组的实现可能不同。C/C++ 的数组严格使用连续内存,Java 和 Python 的数组灵活性更高。 +数组的访问和修改操作时间复杂度为 $O(1)$,而插入和删除操作由于需要移动元素,时间复杂度为 $O(n)$。 ## 4. 练习题目 diff --git a/docs/01_array/01_02_array_sort.md b/docs/01_array/01_02_array_sort.md index e24125b8..f235e715 100644 --- a/docs/01_array/01_02_array_sort.md +++ b/docs/01_array/01_02_array_sort.md @@ -6,49 +6,66 @@ 排序算法可以按照不同的标准进行分类: -1. 按照时间复杂度分类: - - 简单排序算法:时间复杂度为 $O(n^2)$,如冒泡排序、选择排序、插入排序 - - 高级排序算法:时间复杂度为 $O(n \log n)$,如快速排序、归并排序、堆排序 - - 线性排序算法:时间复杂度为 $O(n)$,如计数排序、桶排序、基数排序 +1. 按照时间复杂度分类 + - **简单排序算法**:时间复杂度为 $O(n^2)$,如冒泡排序、选择排序、插入排序 + - **高级排序算法**:时间复杂度为 $O(n \log n)$,如快速排序、归并排序、堆排序 + - **线性排序算法**:时间复杂度为 $O(n)$,如计数排序、桶排序、基数排序 -2. 按照空间复杂度分类: - - 原地排序算法:空间复杂度为 $O(1)$,如冒泡排序、选择排序、插入排序、快速排序、堆排序 - - 非原地排序算法:空间复杂度为 $O(n)$,如归并排序、计数排序、桶排序、基数排序 +2. 按照空间复杂度分类 + - **原地排序算法**:空间复杂度为 $O(1)$,如冒泡排序、选择排序、插入排序、快速排序、堆排序 + - **非原地排序算法**:空间复杂度为 $O(n)$ 或更高,如归并排序、计数排序、桶排序、基数排序 -3. 按照稳定性分类: - - 稳定排序算法:相等元素的相对顺序在排序后保持不变,如冒泡排序、插入排序、归并排序、计数排序、桶排序、基数排序 - - 不稳定排序算法:相等元素的相对顺序在排序后可能改变,如选择排序、快速排序、堆排序 +3. 按照稳定性分类 + - **稳定排序算法**:相等元素的相对顺序在排序后保持不变,如冒泡排序、插入排序、归并排序、计数排序、桶排序、基数排序 + - **不稳定排序算法**:相等元素的相对顺序在排序后可能改变,如选择排序、快速排序、堆排序 ## 2. 排序算法的评价指标 评价一个排序算法的好坏,主要从以下几个方面考虑: -1. 时间复杂度:算法执行所需的时间 -2. 空间复杂度:算法执行所需的额外空间 -3. 稳定性:相等元素的相对顺序是否保持不变 -4. 原地性:是否需要在原数组之外开辟额外空间 -5. 自适应性:算法是否能够利用输入数据的特性来提高效率 +1. **时间复杂度**:算法执行所需的时间,包括最好情况、最坏情况和平均情况 +2. **空间复杂度**:算法执行所需的额外空间(不包括输入数据本身) +3. **稳定性**:相等元素的相对顺序是否保持不变 +4. **原地性**:是否需要在原数组之外开辟额外空间 ## 3. 常见排序算法 常见的排序算法包括: -1. 冒泡排序:通过相邻元素比较和交换,将最大元素逐步"冒泡"到数组末尾 -2. 选择排序:每次从未排序区间选择最小元素,放到已排序区间末尾 -3. 插入排序:将未排序区间的元素插入到已排序区间的合适位置 -4. 快速排序:选择一个基准元素,将数组分为两部分,递归排序 -5. 归并排序:将数组分成两半,分别排序后合并 -6. 堆排序:利用堆这种数据结构进行排序 -7. 计数排序:统计每个元素出现的次数,按顺序输出 -8. 桶排序:将元素分到有限数量的桶中,对每个桶单独排序 -9. 基数排序:按照元素的位数进行排序 +1. **冒泡排序**:通过相邻元素比较和交换,将最大元素逐步「冒泡」到数组末尾 +2. **选择排序**:每次从未排序区间选择最小元素,放到已排序区间末尾 +3. **插入排序**:将未排序区间的元素插入到已排序区间的合适位置 +4. **希尔排序**:先按一定间隔分组进行插入排序,逐步缩小间隔,最后整体进行插入排序 +5. **归并排序**:将数组分成两半,分别排序后合并 +6. **快速排序**:选择一个基准元素,将数组分为两部分,递归排序 +7. **堆排序**:利用堆这种数据结构进行排序 +8. **计数排序**:统计每个元素出现的次数,按顺序输出 +9. **桶排序**:将元素分到有限数量的桶中,对每个桶单独排序 +10. **基数排序**:按照元素的位数进行排序 -## 4. 排序算法的选择 +## 4. 排序算法的选择策略 在实际应用中,选择合适的排序算法需要考虑以下因素: -1. 数据规模:小规模数据可以使用简单排序算法,大规模数据需要使用高级排序算法 -2. 数据特征:如果数据基本有序,插入排序效率较高;如果数据分布均匀,快速排序效率较高 -3. 空间限制:如果空间有限,应该选择原地排序算法 -4. 稳定性要求:如果需要保持相等元素的相对顺序,应该选择稳定排序算法 -5. 硬件环境:不同的硬件环境可能适合不同的排序算法 +1. 数据规模 + - **小规模数据**(n < 50):可以使用简单排序算法,如插入排序 + - **中等规模数据**(50 ≤ n < 1000):推荐使用快速排序或归并排序 + - **大规模数据**(n ≥ 1000):优先考虑快速排序、归并排序或堆排序 +2. 数据特征 + - **基本有序**:插入排序效率较高 + - **数据分布均匀**:快速排序效率较高 + - **数据范围较小**:计数排序或桶排序可能更高效 + - **数据有大量重复**:三路快速排序或计数排序更合适 +3. 环境约束 + - **空间限制**:选择原地排序算法 + - **稳定性要求**:选择稳定排序算法 + - **硬件环境**:考虑缓存友好性和并行化潜力 +4. 实际应用场景 + - **系统排序**:通常使用快速排序或归并排序的混合算法 + - **外部排序**:使用归并排序 + - **实时系统**:优先考虑时间复杂度稳定的算法 + - **内存受限环境**:选择空间复杂度低的算法 + +## 5. 总结 + +选择合适的排序算法需要综合考虑数据特征、环境约束和性能要求。在实际开发中,大多数编程语言的标准库都提供了经过优化的排序函数,这些函数通常结合了多种排序算法的优点,能够适应不同的数据特征。 diff --git a/docs/01_array/01_03_array_bubble_sort.md b/docs/01_array/01_03_array_bubble_sort.md index d1153069..63c6ecb1 100644 --- a/docs/01_array/01_03_array_bubble_sort.md +++ b/docs/01_array/01_03_array_bubble_sort.md @@ -2,17 +2,15 @@ > **冒泡排序(Bubble Sort)基本思想**: > -> 经过多次迭代,通过相邻元素之间的比较与交换,使值较小的元素逐步从后面移到前面,值较大的元素从前面移到后面。 +> 通过相邻元素的比较与交换,将较大的元素逐步「冒泡」到数组末尾,较小的元素自然「下沉」到数组开头。 -这个过程就像水底的气泡一样从底部向上「冒泡」到水面,这也是冒泡排序法名字的由来。 +冒泡排序的名字来源于这个过程:就像水中的气泡从底部向上浮到水面一样,较大的元素会逐步移动到数组的末尾。 -接下来,我们使用「冒泡」的方式来模拟一下这个过程。 - -1. 首先将数组想象是一排「泡泡」,元素值的大小与泡泡的大小成正比。 -2. 然后从左到右依次比较相邻的两个「泡泡」: - 1. 如果左侧泡泡大于右侧泡泡,则交换两个泡泡的位置。 - 2. 如果左侧泡泡小于等于右侧泡泡,则两个泡泡保持不变。 -3. 这 $1$ 趟遍历完成之后,最大的泡泡就会放置到所有泡泡的最右侧,就像是「泡泡」从水底向上浮到了水面。 +**我们使用「冒泡」的方式来模拟一下这个过程**: +1. 将数组元素想象成大小不同的「泡泡」,值越大的元素「泡泡」越大。 +2. 从左到右依次比较相邻的两个元素。 +3. 如果左侧元素大于右侧元素,则交换位置。 +4. 每完成一趟遍历,最大的元素就会「浮」到最右侧。 ::: tabs#bubble @@ -48,64 +46,70 @@ ## 2. 冒泡排序算法步骤 -假设数组的元素个数为 $n$ 个,则冒泡排序的算法步骤如下: +对于长度为 $n$ 的数组,冒泡排序的步骤如下: -1. 第 $1$ 趟「冒泡」:对前 $n$ 个元素执行「冒泡」,从而使第 $1$ 个值最大的元素放置在正确位置上。 - 1. 先将序列中第 $1$ 个元素与第 $2$ 个元素进行比较,如果前者大于后者,则两者交换位置,否则不交换。 - 2. 然后将第 $2$ 个元素与第 $3$ 个元素比较,如果前者大于后者,则两者交换位置,否则不交换。 - 3. 依次类推,直到第 $n - 1$ 个元素与第 $n$ 个元素比较(或交换)为止。 - 4. 经过第 $1$ 趟排序,使得 $n$ 个元素中第 $i$ 个值最大元素被安置在第 $n$ 个位置上。 -2. 第 $2$ 趟「冒泡」:对前 $n - 1$ 个元素执行「冒泡」,从而使第 $2$ 个值最大的元素放置在正确位置上。 - 1. 先将序列中第 $1$ 个元素与第 $2$ 个元素进行比较,若前者大于后者,则两者交换位置,否则不交换。 - 2. 然后将第 $2$ 个元素与第 $3$ 个元素比较,若前者大于后者,则两者交换位置,否则不交换。 - 3. 依次类推,直到对 $n - 2$ 个元素与第 $n - 1$ 个元素比较(或交换)为止。 - 4. 经过第 $2$ 趟排序,使得数组中第 $2$ 个值最大元素被安置在第 $n$ 个位置上。 -3. 依次类推,重复上述「冒泡」过程,直到某一趟排序过程中不出现元素交换位置的动作,则排序结束。 +1. 第 $1$ 趟冒泡:对前 $n$ 个元素依次比较相邻元素,将较大的元素向右交换,最终使最大值移动到数组末尾(第 $n$ 个位置)。 + 1. 比较第 $1$ 个和第 $2$ 个元素,若前者大于后者则交换。 + 2. 比较第 $2$ 个和第 $3$ 个元素,若前者大于后者则交换。 + 3. 以此类推,直到比较第 $n - 1$ 个和第 $n$ 个元素。 + 4. 完成后,最大元素已位于末尾。 +2. 第 $2$ 趟冒泡:对前 $n-1$ 个元素重复上述过程,将次大值移动到倒数第二个位置(第 $n-1$ 个位置)。 + 1. 比较第 $1$ 个和第 $2$ 个元素,若前者大于后者则交换。 + 2. 比较第 $2$ 个和第 $3$ 个元素,若前者大于后者则交换。 + 3. 以此类推,直到比较第 $n-2$ 个和第 $n-1$ 个元素。 + 4. 完成后,次大元素已位于倒数第二位。 +3. 持续进行上述冒泡过程,每一趟比较的元素个数递减,直到某一趟未发生任何交换,说明数组已完全有序,排序结束。 -我们以 $[5, 2, 3, 6, 1, 4]$ 为例,演示一下冒泡排序算法的整个步骤。 +以数组 $[5, 2, 3, 6, 1, 4]$ 为例,演示一下冒泡排序的算法步骤。 -![冒泡排序算法步骤](https://qcdn.itcharge.cn/images/20230816154510.png) +![冒泡排序的算法步骤](https://qcdn.itcharge.cn/images/20230816154510.png) ## 3. 冒泡排序代码实现 ```python class Solution: def bubbleSort(self, nums: [int]) -> [int]: - # 第 i 趟「冒泡」 - for i in range(len(nums) - 1): - flag = False # 是否发生交换的标志位 - # 从数组中前 n - i + 1 个元素的第 1 个元素开始,相邻两个元素进行比较 - for j in range(len(nums) - i - 1): - # 相邻两个元素进行比较,如果前者大于后者,则交换位置 + """冒泡排序算法实现""" + n = len(nums) + # 外层循环控制趟数,每一趟将当前未排序区间的最大值“冒泡”到末尾 + for i in range(n - 1): + swapped = False # 记录本趟是否发生过交换 + # 内层循环负责相邻元素两两比较,将较大值后移 + for j in range(n - i - 1): + # 如果前一个元素大于后一个元素,则交换 if nums[j] > nums[j + 1]: nums[j], nums[j + 1] = nums[j + 1], nums[j] - flag = True - if not flag: # 此趟遍历未交换任何元素,直接跳出 + swapped = True # 发生了交换 + # 如果本趟没有发生任何交换,说明数组已经有序,可以提前结束 + if not swapped: break - - return nums - + return nums # 返回排序后的数组 + def sortArray(self, nums: [int]) -> [int]: + """排序数组的接口,调用冒泡排序""" return self.bubbleSort(nums) ``` ## 4. 冒泡排序算法分析 -- **最佳时间复杂度**:$O(n)$。最好的情况下(初始时序列已经是升序排列),只需经过 $1$ 趟排序,总共经过 $n$ 次元素之间的比较,并且不移动元素,算法就可以结束排序。因此,冒泡排序算法的最佳时间复杂度为 $O(n)$。 -- **最坏时间复杂度**:$O(n^2)$。最差的情况下(初始时序列已经是降序排列,或者最小值元素处在序列的最后),则需要进行 $n$ 趟排序,总共进行 $∑^n_{i=2}(i−1) = \frac{n(n−1)}{2}$ 次元素之间的比较,因此,冒泡排序算法的最坏时间复杂度为 $O(n^2)$。 -- **空间复杂度**:$O(1)$。冒泡排序为原地排序算法,只用到指针变量 $i$、$j$ 以及标志位 $flag$ 等常数项的变量。 -- **冒泡排序适用情况**:冒泡排序方法在排序过程中需要移动较多次数的元素,并且排序时间效率比较低。因此,冒泡排序方法比较适合于参加排序序列的数据量较小的情况,尤其是当序列的初始状态为基本有序的情况。 -- **排序稳定性**:由于元素交换是在相邻元素之间进行的,不会改变相等元素的相对顺序,因此,冒泡排序法是一种 **稳定排序算法**。 +| 指标 | 复杂度 | 说明 | +|------|--------|------| +| **最佳时间复杂度** | $O(n)$ | 数组已有序,只需一趟遍历 | +| **最坏时间复杂度** | $O(n^2)$ | 数组逆序,需要 $n$ 趟遍历 | +| **平均时间复杂度** | $O(n^2)$ | 一般情况下的复杂度 | +| **空间复杂度** | $O(1)$ | 原地排序,只使用常数空间 | +| **稳定性** | ✅ 稳定 | 相等元素相对位置不变 | -## 5. 总结 +**适用场景**: +- 数据量较小($n < 50$) +- 数据基本有序 -冒泡排序是一种简单的排序算法。它通过多次比较相邻元素并交换位置,将较大的元素逐步移动到数组末尾。 - -冒泡排序的时间复杂度取决于数据情况。最好情况下是 $O(n)$,最坏情况下是 $O(n^2)$。它只需要常数级别的额外空间,空间复杂度是 $O(1)$。 +## 5. 总结 -冒泡排序适合数据量小的场景。当数据基本有序时,它的效率较高。由于交换只在相邻元素间进行,冒泡排序是稳定的排序算法。 +冒泡排序是最简单的排序算法之一,通过相邻元素比较交换实现排序。虽然实现简单,但效率较低。 -冒泡排序容易实现,但效率较低。在实际应用中,通常选择更高效的排序算法处理大规模数据。 +**优点**:实现简单,稳定排序,空间复杂度低 +**缺点**:时间复杂度高,交换次数多 ## 练习题目 diff --git a/docs/01_array/01_04_array_selection_sort.md b/docs/01_array/01_04_array_selection_sort.md index b3e48d63..0bc0c771 100644 --- a/docs/01_array/01_04_array_selection_sort.md +++ b/docs/01_array/01_04_array_selection_sort.md @@ -2,26 +2,22 @@ > **选择排序(Selection Sort)基本思想**: > -> 将数组分为两个区间:左侧为已排序区间,右侧为未排序区间。每趟从未排序区间中选择一个值最小的元素,放到已排序区间的末尾,从而将该元素划分到已排序区间。 +> 将数组分为两个区间:左侧为已排序区间,右侧为未排序区间。每趟从未排序区间中选择最小的元素,放到已排序区间的末尾。 -选择排序是一种简单直观的排序算法,其思想简单,代码也相对容易。 +选择排序是一种简单直观的排序算法,实现简单,易于理解。 ## 2. 选择排序算法步骤 -假设数组的元素个数为 $n$ 个,则选择排序的算法步骤如下: +假设数组长度为 $n$,选择排序的算法步骤如下: -1. 初始状态下,无已排序区间,未排序区间为 $[0, n - 1]$。 -2. 第 $1$ 趟选择: - 1. 遍历未排序区间 $[0, n - 1]$,使用变量 $min\underline{\hspace{0.5em}}i$ 记录区间中值最小的元素位置。 - 2. 将 $min\underline{\hspace{0.5em}}i$ 与下标为 $0$ 处的元素交换位置。如果下标为 $0$ 处元素就是值最小的元素位置,则不用交换。 - 3. 此时,$[0, 0]$ 为已排序区间,$[1, n - 1]$(总共 $n - 1$ 个元素)为未排序区间。 -3. 第 $2$ 趟选择: - 1. 遍历未排序区间 $[1, n - 1]$,使用变量 $min\underline{\hspace{0.5em}}i$ 记录区间中值最小的元素位置。 - 2. 将 $min\underline{\hspace{0.5em}}i$ 与下标为 $1$ 处的元素交换位置。如果下标为 $1$ 处元素就是值最小的元素位置,则不用交换。 - 3. 此时,$[0, 1]$ 为已排序区间,$[2, n - 1]$(总共 $n - 2$ 个元素)为未排序区间。 -4. 依次类推,对剩余未排序区间重复上述选择过程,直到所有元素都划分到已排序区间,排序结束。 +1. **初始状态**:已排序区间为空,未排序区间为 $[0, n - 1]$。 +2. **第 $i$ 趟选择**($i$ 从 $0$ 开始): + 1. 在未排序区间 $[i, n - 1]$ 中找到最小元素的位置 $min\underline{\hspace{0.5em}}i$。 + 2. 将位置 $i$ 的元素与位置 $min\_i$ 的元素交换。 + 3. 此时 $[0, i]$ 为已排序区间,$[i + 1, n - 1]$ 为未排序区间。 +3. **重复步骤 2**,直到未排序区间为空,排序完成。 -我们以 $[5, 2, 3, 6, 1, 4]$ 为例,演示一下选择排序的整个步骤。 +以数组 $[5, 2, 3, 6, 1, 4]$ 为例,演示一下选择排序的算法步骤。 ::: tabs#selectionSort @@ -60,13 +56,14 @@ ```python class Solution: def selectionSort(self, nums: [int]) -> [int]: - for i in range(len(nums) - 1): - # 记录未排序区间中最小值的位置 + n = len(nums) + for i in range(n - 1): + # 找到未排序区间中最小元素的位置 min_i = i - for j in range(i + 1, len(nums)): + for j in range(i + 1, n): if nums[j] < nums[min_i]: min_i = j - # 如果找到最小值的位置,将 i 位置上元素与最小值位置上的元素进行交换 + # 交换元素 if i != min_i: nums[i], nums[min_i] = nums[min_i], nums[i] return nums @@ -77,20 +74,24 @@ class Solution: ## 4. 选择排序算法分析 -- **时间复杂度**:$O(n^2)$。排序法所进行的元素之间的比较次数与序列的原始状态无关,时间复杂度总是 $O(n^2)$。 - - 这是因为无论序列中元素的初始排列状态如何,第 $i$ 趟排序要找出值最小元素都需要进行 $n − i$ 次元素之间的比较。因此,整个排序过程需要进行的元素之间的比较次数都相同,为 $∑^n_{i=2}(i - 1) = \frac{n(n−1)}{2}$ 次。 -- **空间复杂度**:$O(1)$。选择排序算法为原地排序算法,只用到指针变量 $i$、$j$ 以及最小值位置 $min\underline{\hspace{0.5em}}i$ 等常数项的变量。 -- **选择排序适用情况**:选择排序方法在排序过程中需要移动较多次数的元素,并且排序时间效率比较低。因此,选择排序方法比较适合于参加排序序列的数据量较小的情况。选择排序的主要优点是仅需要原地操作无需占用其他空间就可以完成排序,因此在空间复杂度要求较高时,可以考虑选择排序。 +| 指标 | 复杂度 | 说明 | +|------|--------|------| +| **最佳时间复杂度** | $O(n^2)$ | 无论数组状态如何,都需要 $\frac{n(n-1)}{2}$ 次比较 | +| **最坏时间复杂度** | $O(n^2)$ | 无论数组状态如何,都需要 $\frac{n(n-1)}{2}$ 次比较 | +| **平均时间复杂度** | $O(n^2)$ | 选择排序的时间复杂度与数据状态无关 | +| **空间复杂度** | $O(1)$ | 原地排序,只使用常数空间 | +| **稳定性** | ❌ 不稳定 | 交换操作可能改变相等元素的相对顺序 | -- **排序稳定性**:由于值最小元素与未排序区间第 $1$ 个元素的交换动作是在不相邻的元素之间进行的,因此很有可能会改变相等元素的相对顺序,因此,选择排序法是一种 **不稳定排序算法**。 +**适用场景**: +- 数据量较小($n < 50$) +- 对空间复杂度要求严格的场景 ## 5. 总结 -选择排序是一种简单的排序算法。它的工作原理是将数组分成已排序和未排序两部分。每次从未排序部分找到最小的元素,放到已排序部分的末尾。这个过程重复进行,直到所有元素排序完成。 +选择排序是一种简单直观的排序算法,通过不断选择未排序区间的最小元素来构建有序序列。 -选择排序的时间复杂度是 $O(n^2)$,因为它需要进行 $ \frac{n(n−1)}{2}$ 次比较。空间复杂度是 $O(1)$,因为它只需要常数级的额外空间。选择排序是不稳定的排序算法,因为在交换过程中可能改变相等元素的相对顺序。 - -选择排序适合小规模数据的排序。它的优点是实现简单,不需要额外空间。缺点是效率较低,不适合大规模数据。 +**优点**:实现简单,空间复杂度低,交换次数少 +**缺点**:时间复杂度高,不适合大规模数据 ## 练习题目 diff --git a/docs/01_array/01_05_array_insertion_sort.md b/docs/01_array/01_05_array_insertion_sort.md index 8ab06c9b..dae0c2b2 100644 --- a/docs/01_array/01_05_array_insertion_sort.md +++ b/docs/01_array/01_05_array_insertion_sort.md @@ -2,31 +2,25 @@ > **插入排序(Insertion Sort)基本思想**: > -> 将数组分为两个区间:左侧为有序区间,右侧为无序区间。每趟从无序区间取出一个元素,然后将其插入到有序区间的适当位置。 -> +> 将数组分为有序区间和无序区间,每次从无序区间取出一个元素插入到有序区间的正确位置。 -插入排序在每次插入一个元素时,该元素会在有序区间找到合适的位置,因此每次插入后,有序区间都会保持有序。 +插入排序通过逐步构建有序序列来实现排序,每次插入后有序区间保持有序。 ## 2. 插入排序算法步骤 -假设数组的元素个数为 $n$ 个,则插入排序的算法步骤如下: +假设数组长度为 $n$,算法步骤如下: + +1. **初始化**:有序区间为 $[0, 0]$,无序区间为 $[1, n - 1]$ +2. **第 $i$ 趟插入**($i$ 从 $1$ 到 $n-1$): + - 取出无序区间第一个元素 $nums[i]$ + - 从右到左遍历有序区间,将大于 $nums[i]$ 的元素右移一位 + - 找到合适位置后插入 $nums[i]$ + - 有序区间扩展为 $[0, i]$,无序区间变为 $[i+1, n-1]$ -1. 初始状态下,有序区间为 $[0, 0]$,无序区间为 $[1, n - 1]$。 -2. 第 $1$ 趟插入: - 1. 取出无序区间 $[1, n - 1]$ 中的第 $1$ 个元素,即 $nums[1]$。 - 2. 从右到左遍历有序区间中的元素,将比 $nums[1]$ 大的元素向后移动 $1$ 位。 - 3. 如果遇到小于或等于 $nums[1]$ 的元素时,说明找到了插入位置,将 $nums[1]$ 插入到该位置。 - 4. 插入元素后有序区间变为 $[0, 1]$,无序区间变为 $[2, n - 1]$。 -3. 第 $2$ 趟插入: - 1. 取出无序区间 $[2, n - 1]$ 中的第 $1$ 个元素,即 $nums[2]$。 - 2. 从右到左遍历有序区间中的元素,将比 $nums[2]$ 大的元素向后移动 $1$ 位。 - 3. 如果遇到小于或等于 $nums[2]$ 的元素时,说明找到了插入位置,将 $nums[2]$ 插入到该位置。 - 4. 插入元素后有序区间变为 $[0, 2]$,无序区间变为 $[3, n - 1]$。 -4. 依次类推,对剩余无序区间中的元素重复上述插入过程,直到所有元素都插入到有序区间中,排序结束。 -我们以 $[5, 2, 3, 6, 1, 4]$ 为例,演示一下插入排序算法的整个步骤。 +以数组 $[5, 2, 3, 6, 1, 4]$ 为例,演示一下插入排序的算法步骤。 -![插入排序算法步骤](http://qcdn.itcharge.cn/images/20230816175619.png) +![插入排序的算法步骤](http://qcdn.itcharge.cn/images/20230816175619.png) ## 3. 插入排序代码实现 @@ -39,12 +33,11 @@ class Solution: j = i # 从右至左遍历有序区间 while j > 0 and nums[j - 1] > temp: - # 将有序区间中插入位置右侧的元素依次右移一位 + # 将大于temp的元素右移 nums[j] = nums[j - 1] j -= 1 - # 将该元素插入到适当位置 + # 插入到正确位置 nums[j] = temp - return nums def sortArray(self, nums: [int]) -> [int]: @@ -53,19 +46,26 @@ class Solution: ## 4. 插入排序算法分析 -- **最佳时间复杂度**:$O(n)$。最好的情况下(初始时区间已经是升序排列),每个元素只进行一次元素之间的比较,因而总的比较次数最少,为 $∑^n_{i = 2}1 = n − 1$,并不需要移动元素(记录),这是最好的情况。 -- **最差时间复杂度**:$O(n^2)$。最差的情况下(初始时区间已经是降序排列),每个元素 $nums[i]$ 都要进行 $i - 1$ 次元素之间的比较,元素之间总的比较次数达到最大值,为 $∑^n_{i=2}(i − 1) = \frac{n(n−1)}{2}$。 -- **平均时间复杂度**:$O(n^2)$。如果区间的初始情况是随机的,即参加排序的区间中元素可能出现的各种排列的概率相同,则可取上述最小值和最大值的平均值作为插入排序时所进行的元素之间的比较次数,约为 $\frac{n^2}{4}$。由此得知,插入排序算法的平均时间复杂度为 $O(n^2)$。 -- **空间复杂度**:$O(1)$。插入排序算法为原地排序算法,只用到指针变量 $i$、$j$ 以及表示无序区间中第 $1$ 个元素的变量等常数项的变量。 -- **排序稳定性**:在插入操作过程中,每次都讲元素插入到相等元素的右侧,并不会改变相等元素的相对顺序。因此,插入排序方法是一种 **稳定排序算法**。 +| 指标 | 复杂度 | 说明 | +|------|--------|------| +| **最佳时间复杂度** | $O(n)$ | 数组已有序,每个元素只需比较一次 | +| **最坏时间复杂度** | $O(n^2)$ | 数组逆序,每个元素需要比较 $i-1$ 次 | +| **平均时间复杂度** | $O(n^2)$ | 一般情况下的复杂度 | +| **空间复杂度** | $O(1)$ | 原地排序,只使用常数空间 | +| **稳定性** | ✅ 稳定 | 相等元素相对位置不变 | + +**适用场景**: +- 数据量较小($n < 50$) +- 数据基本有序 +- 在线排序(数据逐个到达) ## 5. 总结 -插入排序是一种简单直观的排序算法。它的工作原理是将数组分为有序区间和无序区间,每次从无序区间取出一个元素,插入到有序区间的正确位置。这个过程重复进行,直到所有元素都排好序。 +插入排序是一种简单直观的排序算法,通过逐步构建有序序列实现排序。 -插入排序的时间复杂度取决于数据的初始顺序。最好的情况是数组已经有序,时间复杂度为 $O(n)$。最坏的情况是数组完全逆序,时间复杂度为 $O(n^2)$。平均时间复杂度也是 $O(n^2)$。空间复杂度是 $O(1)$,因为排序是在原地进行的。 +**优点**:实现简单,稳定排序,空间复杂度低,对基本有序数据效率高 +**缺点**:时间复杂度高,不适合大规模数据 -插入排序是稳定的排序算法,因为它不会改变相等元素的相对顺序。这个算法适合小规模数据或基本有序的数据排序。对于大规模数据,插入排序的效率不如快速排序等更高级的算法。 ## 练习题目 diff --git a/docs/01_array/01_06_array_shell_sort.md b/docs/01_array/01_06_array_shell_sort.md index 866ba1c4..118988a2 100644 --- a/docs/01_array/01_06_array_shell_sort.md +++ b/docs/01_array/01_06_array_shell_sort.md @@ -2,20 +2,18 @@ > **希尔排序(Shell Sort)基本思想**: > -> 将整个数组切按照一定的间隔取值划分为若干个子数组,每个子数组分别进行插入排序。然后逐渐缩小间隔进行下一轮划分子数组和对子数组进行插入排序。直至最后一轮排序间隔为 $1$,对整个数组进行插入排序。 -> +> 通过设定不同的间隔(gap),将数组分组进行插入排序,然后逐步缩小间隔直至为 $1$,最终完成整个数组的排序。 ## 2. 希尔排序算法步骤 -假设数组的元素个数为 $n$ 个,则希尔排序的算法步骤如下: - -1. 确定一个元素间隔数 $gap$。 -2. 将参加排序的数组按此间隔数从第 $1$ 个元素开始一次分成若干个子数组,即分别将所有位置相隔为 $gap$ 的元素视为一个子数组。 -3. 在各个子数组中采用某种排序算法(例如插入排序算法)进行排序。 -4. 减少间隔数,并重新将整个数组按新的间隔数分成若干个子数组,再分别对各个子数组进行排序。 -5. 依次类推,直到间隔数 $gap$ 值为 $1$,最后进行一次排序,排序结束。 +假设数组长度为 $n$,算法步骤如下: +1. 设定初始间隔 `gap = n / 2`。 +2. 按间隔将数组分组,对每组进行插入排序。 +3. 缩小间隔 `gap = gap / 2`。 +4. 重复步骤 $2 \sim 3$,直到 `gap = 1`。 +5. 最后对整个数组进行一次插入排序。 -我们以 $[7, 2, 6, 8, 0, 4, 1, 5, 9, 3]$ 为例,演示一下希尔排序的整个步骤。 +以数组 $[7, 2, 6, 8, 0, 4, 1, 5, 9, 3]$ 为例,演示一下希尔排序的算法步骤。 ::: tabs#shellSort @@ -55,46 +53,56 @@ class Solution: def shellSort(self, nums: [int]) -> [int]: size = len(nums) - gap = size // 2 - # 按照 gap 分组 + gap = size // 2 # 初始间隔设为数组长度的一半 + + # 不断缩小gap,直到gap为0 while gap > 0: - # 对每组元素进行插入排序 + # 从gap位置开始,对每个元素进行组内插入排序 for i in range(gap, size): - # temp 为每组中无序数组第 1 个元素 - temp = nums[i] + temp = nums[i] # 记录当前待插入的元素 j = i - # 从右至左遍历每组中的有序数组元素 + # 在组内进行插入排序,将比 temp 大的元素向后移动 while j >= gap and nums[j - gap] > temp: - # 将每组有序数组中插入位置右侧的元素依次在组中右移一位 - nums[j] = nums[j - gap] - j -= gap - # 将该元素插入到适当位置 - nums[j] = temp - # 缩小 gap 间隔 - gap = gap // 2 - return nums + nums[j] = nums[j - gap] # 元素后移 + j -= gap # 向前跳 gap 步 + nums[j] = temp # 插入到正确位置 + # 缩小 gap,通常取 gap 的一半 + gap //= 2 + + return nums # 返回排序后的数组 def sortArray(self, nums: [int]) -> [int]: + """排序接口,调用shellSort方法""" return self.shellSort(nums) ``` ## 4. 希尔排序算法分析 -- **时间复杂度**:介于 $O(n \times \log^2 n)$ 与 $O(n^2)$ 之间。 - - 希尔排序方法的速度是一系列间隔数 $gap_i$ 的函数,而比较次数与 $gap_i$ 之间的依赖关系比较复杂,不太容易给出完整的数学分析。 - - 本文采用 $gap_i = \lfloor gap_{i-1}/2 \rfloor$ 的方法缩小间隔数,对于具有 $n$ 个元素的数组,如果 $gap_1 = \lfloor n/2 \rfloor$,则经过 $p = \lfloor \log_2 n \rfloor$ 趟排序后就有 $gap_p = 1$,因此,希尔排序方法的排序总躺数为 $\lfloor \log_2 n \rfloor$。 - - 从算法中也可以看到,外层 `while gap > 0` 的循环次数为 $\log n$ 数量级,内层插入排序算法循环次数为 $n$ 数量级。当子数组分得越多时,子数组内的元素就越少,内层循环的次数也就越少;反之,当所分的子数组个数减少时,子数组内的元素也随之增多,但整个数组也逐步接近有序,而循环次数却不会随之增加。因此,希尔排序算法的时间复杂度在 $O(n \times \log^2 n)$ 与 $O(n^2)$ 之间。 +| 指标 | 复杂度 | 说明 | +|------|--------|------| +| **最佳时间复杂度** | $O(n)$ | 当数组已有序时 | +| **最坏时间复杂度** | $O(n^2)$ | 使用普通间隔序列时 | +| **平均时间复杂度** | $O(n^{1.3})$ ~ $O(n^{1.5})$ | 取决于间隔序列选择,若选取得当接近于 $O(n \log n)$ | +| **空间复杂度** | $O(1)$ | 原地排序,只使用常数空间 | +| **稳定性** | ❌ 不稳定 | 不同组间的相等元素可能改变相对顺序 | -- **空间复杂度**:$O(1)$。希尔排序中用到的插入排序算法为原地排序算法,只用到指针变量 $i$、$j$ 以及表示无序区间中第 $1$ 个元素的变量、间隔数 $gap$ 等常数项的变量。 -- **排序稳定性**:在一次插入排序是稳定的,不会改变相等元素的相对顺序,但是在不同的插入排序中,相等元素可能在各自的插入排序中移动。因此,希尔排序方法是一种 **不稳定排序算法**。 +**补充说明:** +- 希尔排序的时间复杂度高度依赖于间隔序列的选择。 +- 当采用常见的 `gap = gap // 2` 间隔序列时,排序过程大约需要 $\log_2 n$ 趟,每一趟的操作类似于分组插入排序。 +- 每一趟的排序时间复杂度约为 $O(n)$,但随着 gap 的减小,实际操作次数逐步减少。 +- 综合来看,希尔排序的整体时间复杂度通常介于 $O(n \log n)$ 和 $O(n^2)$ 之间,若间隔序列选择得当,性能可接近 $O(n \log n)$。 -## 5. 总结 +**适用场景**: +- 中等规模数据($50 \leq n \leq 1000$) +- 对插入排序的改进需求 +- 对稳定性要求不高的场景 -希尔排序是插入排序的改进版本。它通过将数组分成多个子数组进行排序,逐步缩小间隔,最终对整个数组进行一次插入排序。这种方法减少了数据移动的次数,提高了排序效率。 +## 5. 总结 -希尔排序的时间复杂度取决于间隔序列的选择。使用常见的间隔序列时,时间复杂度在 $O(n \log n)$ 到 $O(n^2)$ 之间。空间复杂度是 $O(1)$,因为排序是在原地进行的。 +希尔排序是插入排序的改进版本,通过分组排序减少数据移动次数,提高排序效率。 -希尔排序是不稳定的排序算法,因为在不同的子数组排序过程中,相等元素的相对顺序可能改变。希尔排序适合中等规模的数据排序,比简单插入排序更快,但比快速排序等高级算法稍慢。 +**优点**:比插入排序更快,空间复杂度低,适合中等规模数据 +**缺点**:时间复杂度不稳定,不稳定排序,间隔序列选择影响性能 ## 练习题目 diff --git a/docs/01_array/01_07_array_merge_sort.md b/docs/01_array/01_07_array_merge_sort.md index 9fce7622..aefeb9e7 100644 --- a/docs/01_array/01_07_array_merge_sort.md +++ b/docs/01_array/01_07_array_merge_sort.md @@ -2,27 +2,27 @@ > **归并排序(Merge Sort)基本思想**: > -> 采用经典的分治策略,先递归地将当前数组平均分成两半,然后将有序数组两两合并,最终合并成一个有序数组。 +> 利用分治法,将数组递归地一分为二,直至每个子数组只包含一个元素。随后,将这些有序子数组两两合并,最终得到一个整体有序的数组。 ## 2. 归并排序算法步骤 假设数组的元素个数为 $n$ 个,则归并排序的算法步骤如下: -1. **分解过程**:先递归地将当前数组平均分成两半,直到子数组长度为 $1$。 - 1. 找到数组中心位置 $mid$,从中心位置将数组分成左右两个子数组 $left\underline{\hspace{0.5em}}nums$、$right\underline{\hspace{0.5em}}nums$。 - 2. 对左右两个子数组 $left\underline{\hspace{0.5em}}nums$、$right\underline{\hspace{0.5em}}nums$ 分别进行递归分解。 - 3. 最终将数组分解为 $n$ 个长度均为 $1$ 的有序子数组。 -2. **归并过程**:从长度为 $1$ 的有序子数组开始,依次将有序数组两两合并,直到合并成一个长度为 $n$ 的有序数组。 - 1. 使用数组变量 $nums$ 存放合并后的有序数组。 - 2. 使用两个指针 $left\underline{\hspace{0.5em}}i$、$right\underline{\hspace{0.5em}}i$ 分别指向两个有序子数组 $left\underline{\hspace{0.5em}}nums$、$right\underline{\hspace{0.5em}}nums$ 的开始位置。 - 3. 比较两个指针指向的元素,将两个有序子数组中较小元素依次存入到结果数组 $nums$ 中,并将指针移动到下一位置。 - 4. 重复步骤 $3$,直到某一指针到达子数组末尾。 - 5. 将另一个子数组中的剩余元素存入到结果数组 $nums$ 中。 +1. **分解过程**:递归地将当前数组平分为两部分,直到每个子数组只包含一个元素为止。 + 1. 找到数组的中间位置 $mid$,将数组划分为左、右两个子数组 $left\underline{\hspace{0.5em}}nums$ 和 $right\underline{\hspace{0.5em}}nums$。 + 2. 分别对 $left\underline{\hspace{0.5em}}nums$ 和 $right\underline{\hspace{0.5em}}nums$ 递归执行分解操作。 + 3. 最终将原数组拆分为 $n$ 个长度为 $1$ 的有序子数组。 +2. **归并过程**:从长度为 $1$ 的有序子数组开始,逐步将相邻的有序子数组两两合并,最终合并为一个长度为 $n$ 的有序数组。 + 1. 新建数组 $nums$ 用于存放合并后的有序结果。 + 2. 设置两个指针 $left\underline{\hspace{0.5em}}i$ 和 $right\underline{\hspace{0.5em}}i$,分别指向 $left\underline{\hspace{0.5em}}nums$ 和 $right\underline{\hspace{0.5em}}nums$ 的起始位置。 + 3. 比较两个指针所指元素,将较小者加入结果数组 $nums$,并将对应指针后移一位。 + 4. 重复上述操作,直到某一指针到达对应子数组末尾。 + 5. 将另一个子数组剩余的所有元素依次加入结果数组 $nums$。 6. 返回合并后的有序数组 $nums$。 -我们以 $[0, 5, 7, 3, 1, 6, 8, 4]$ 为例,演示一下归并排序算法的整个步骤。 +以数组 $[0, 5, 7, 3, 1, 6, 8, 4]$ 为例,演示一下归并排序的算法步骤。 -![归并排序算法步骤](http://qcdn.itcharge.cn/images/20230817103814.png) +![归并排序的算法步骤](http://qcdn.itcharge.cn/images/20230817103814.png) ## 3. 归并排序代码实现 @@ -32,9 +32,10 @@ class Solution: def merge(self, left_nums: [int], right_nums: [int]): nums = [] left_i, right_i = 0, 0 + + # 合并两个有序子数组 while left_i < len(left_nums) and right_i < len(right_nums): - # 将两个有序子数组中较小元素依次插入到结果数组中 - if left_nums[left_i] < right_nums[right_i]: + if left_nums[left_i] <= right_nums[right_i]: nums.append(left_nums[left_i]) left_i += 1 else: @@ -71,25 +72,46 @@ class Solution: ## 4. 归并排序算法分析 -- **时间复杂度**:$O(n \times \log n)$。归并排序算法的时间复杂度等于归并趟数与每一趟归并的时间复杂度乘积。子算法 `merge(left_nums, right_nums):` 的时间复杂度是 $O(n)$,因此,归并排序算法总的时间复杂度为 $O(n \times \log n)$。 -- **空间复杂度**:$O(n)$。归并排序方法需要用到与参加排序的数组同样大小的辅助空间。因此,算法的空间复杂度为 $O(n)$。 -- **排序稳定性**:因为在两个有序子数组的归并过程中,如果两个有序数组中出现相等元素,`merge(left_nums, right_nums):` 算法能够使前一个数组中那个相等元素先被复制,从而确保这两个元素的相对顺序不发生改变。因此,归并排序算法是一种 **稳定排序算法**。 +| 指标 | 复杂度 | 说明 | +|------|--------|------| +| **最佳时间复杂度** | $O(n \log n)$ | 无论数组状态如何,都需要 $\log n$ 次分解和 $n$ 次合并 | +| **最坏时间复杂度** | $O(n \log n)$ | 无论数组状态如何,都需要 $\log n$ 次分解和 $n$ 次合并 | +| **平均时间复杂度** | $O(n \log n)$ | 归并排序的时间复杂度与数据状态无关 | +| **空间复杂度** | $O(n)$ | 需要额外的辅助数组来存储合并结果 | +| **稳定性** | ✅ 稳定 | 合并过程中相等元素的相对顺序保持不变 | + +**补充说明:** +- 归并排序采用分治策略,将数组递归地分成两半,每次分解的时间复杂度为 $O(1)$,分解次数为 $\log n$。 +- 合并过程的时间复杂度为 $O(n)$,因为需要遍历两个子数组的所有元素。 +- 总的时间复杂度为 $O(n \log n)$,这是基于比较的排序算法的理论下界。 + +**适用场景:** +- 大规模数据排序($n > 1000$) +- 对稳定性有要求的场景 +- 外部排序(数据无法全部加载到内存) +- 链表排序 ## 5. 总结 -归并排序采用分治策略,将数组不断拆分为更小的子数组进行排序,再将有序子数组合并成完整的有序数组。这种方法保证了排序的稳定性。 +归并排序是一种高效稳定的排序算法,采用分治策略将数组递归分解后合并排序。 -归并排序的时间复杂度是 $O(n \log n)$,这是因为它需要 $\log n$ 次分解,每次合并需要 $O(n)$ 时间。空间复杂度是 $O(n)$,因为合并过程需要额外的存储空间。 +**优点**: +- 时间复杂度稳定,始终为 $O(n \log n)$ +- 稳定排序,相等元素相对位置不变 +- 适合大规模数据排序 +- 可用于外部排序和链表排序 -归并排序是稳定的排序算法,在合并过程中相等元素的相对顺序不会改变。它适合处理大规模数据,但需要额外的存储空间是其缺点。归并排序常用于外部排序场景。 +**缺点**: +- 空间复杂度较高,需要 $O(n)$ 额外空间 +- 对于小规模数据,常数因子较大 +- 不是原地排序算法 -## 练习题目 +## 练习题目 - [0912. 排序数组](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0900-0999/sort-an-array.md) - [0088. 合并两个有序数组](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0001-0099/merge-sorted-array.md) - [LCR 170. 交易逆序对的总数](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/LCR/shu-zu-zhong-de-ni-xu-dui-lcof.md) - [0315. 计算右侧小于当前元素的个数](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0300-0399/count-of-smaller-numbers-after-self.md) - - [排序算法题目列表](https://github.com/ITCharge/AlgoNote/tree/main/docs/00_preface/00_06_categories_list.md#%E6%8E%92%E5%BA%8F%E7%AE%97%E6%B3%95%E9%A2%98%E7%9B%AE) \ No newline at end of file diff --git a/docs/01_array/01_08_array_quick_sort.md b/docs/01_array/01_08_array_quick_sort.md index a701de70..9d4a2cc2 100644 --- a/docs/01_array/01_08_array_quick_sort.md +++ b/docs/01_array/01_08_array_quick_sort.md @@ -2,61 +2,57 @@ > **快速排序(Quick Sort)基本思想**: > -> 采用经典的分治策略,选择数组中某个元素作为基准数,通过一趟排序将数组分为独立的两个子数组,一个子数组中所有元素值都比基准数小,另一个子数组中所有元素值都比基准数大。然后再按照同样的方式递归的对两个子数组分别进行快速排序,以达到整个数组有序。 -> +> 采用分治策略,选择一个基准元素,将数组分为两部分:小于基准的元素放在左侧,大于基准的元素放在右侧。然后递归地对左右两部分进行排序,最终得到有序数组。 ## 2. 快速排序算法步骤 -假设数组的元素个数为 $n$ 个,则快速排序的算法步骤如下: - -1. **哨兵划分**:选取一个基准数,将数组中比基准数大的元素移动到基准数右侧,比他小的元素移动到基准数左侧。 - 1. 从当前数组中找到一个基准数 $pivot$(这里以当前数组第 $1$ 个元素作为基准数,即 $pivot = nums[low]$)。 - 2. 使用指针 $i$ 指向数组开始位置,指针 $j$ 指向数组末尾位置。 - 3. 从右向左移动指针 $j$,找到第 $1$ 个小于基准值的元素。 - 4. 从左向右移动指针 $i$,找到第 $1$ 个大于基准数的元素。 - 5. 交换指针 $i$、指针 $j$ 指向的两个元素位置。 - 6. 重复第 $3 \sim 5$ 步,直到指针 $i$ 和指针 $j$ 相遇时停止,最后将基准数放到两个子数组交界的位置上。 -2. **递归分解**:完成哨兵划分之后,对划分好的左右子数组分别进行递归排序。 - 1. 按照基准数的位置将数组拆分为左右两个子数组。 - 2. 对每个子数组分别重复「哨兵划分」和「递归分解」,直到各个子数组只有 $1$ 个元素,排序结束。 +快速排序的核心是**分区操作**,具体步骤如下: -我们以 $[4, 7, 5, 2, 6, 1, 3]$ 为例,演示一下快速排序的整个步骤。 +1. **选择基准**:从数组中选择一个元素作为基准值(通常选择第一个元素) +2. **分区操作**: + - 使用双指针法,左指针从数组开始,右指针从数组末尾 + - 右指针向左移动,找到第一个小于基准值的元素 + - 左指针向右移动,找到第一个大于基准值的元素 + - 交换这两个元素 + - 重复上述过程,直到左右指针相遇 + - 将基准值放到正确位置(左右指针相遇处) +3. **递归排序**:对基准值左右的两个子数组分别进行快速排序 -我们先来看一下单次「哨兵划分」的过程。 +以数组 $[4, 7, 5, 2, 6, 1, 3]$ 为例,先来演示一下快速排序的分区操作过程。 ::: tabs#partition @tab <1> -![哨兵划分 1](https://qcdn.itcharge.cn/images/20230818175908.png) +![分区操作 1](https://qcdn.itcharge.cn/images/20230818175908.png) @tab <2> -![哨兵划分 2](https://qcdn.itcharge.cn/images/20230818175922.png) +![分区操作 2](https://qcdn.itcharge.cn/images/20230818175922.png) @tab <3> -![哨兵划分 3](https://qcdn.itcharge.cn/images/20230818175952.png) +![分区操作 3](https://qcdn.itcharge.cn/images/20230818175952.png) @tab <4> -![哨兵划分 4](https://qcdn.itcharge.cn/images/20230818180001.png) +![分区操作 4](https://qcdn.itcharge.cn/images/20230818180001.png) @tab <5> -![哨兵划分 5](https://qcdn.itcharge.cn/images/20230818180009.png) +![分区操作 5](https://qcdn.itcharge.cn/images/20230818180009.png) @tab <6> -![哨兵划分 6](https://qcdn.itcharge.cn/images/20230818180019.png) +![分区操作 6](https://qcdn.itcharge.cn/images/20230818180019.png) @tab <7> -![哨兵划分 7](https://qcdn.itcharge.cn/images/20230818180027.png) +![分区操作 7](https://qcdn.itcharge.cn/images/20230818180027.png) ::: -在经过一次「哨兵划分」过程之后,数组就被划分为左子数组、基准数、右子树组三个独立部分。接下来只要对划分好的左右子数组分别进行递归排序即可完成排序。快速排序算法的整个步骤如下: +完成一次分区后,数组被分为三部分:左子数组、基准值、右子数组。然后递归地对左右子数组进行排序。 ![快速排序算法步骤](https://qcdn.itcharge.cn/images/20230818153642.png) @@ -66,9 +62,8 @@ import random class Solution: - # 随机哨兵划分:从 nums[low: high + 1] 中随机挑选一个基准数,并进行移位排序 def randomPartition(self, nums: [int], low: int, high: int) -> int: - # 随机挑选一个基准数 + # 随机选择基准值,避免最坏情况 i = random.randint(low, high) # 将基准数与最低位互换 nums[i], nums[low] = nums[low], nums[i] @@ -77,33 +72,31 @@ class Solution: # 哨兵划分:以第 1 位元素 nums[low] 为基准数,然后将比基准数小的元素移动到基准数左侧,将比基准数大的元素移动到基准数右侧,最后将基准数放到正确位置上 def partition(self, nums: [int], low: int, high: int) -> int: - # 以第 1 位元素为基准数 - pivot = nums[low] - + pivot = nums[low] # 基准值 i, j = low, high + while i < j: - # 从右向左找到第 1 个小于基准数的元素 + # 从右向左找小于基准值的元素 while i < j and nums[j] >= pivot: j -= 1 - # 从左向右找到第 1 个大于基准数的元素 + # 从左向右找大于基准值的元素 while i < j and nums[i] <= pivot: i += 1 # 交换元素 nums[i], nums[j] = nums[j], nums[i] - # 将基准节点放到正确位置上 + # 将基准值放到正确位置 nums[i], nums[low] = nums[low], nums[i] # 返回基准数的索引 return i def quickSort(self, nums: [int], low: int, high: int) -> [int]: if low < high: - # 按照基准数的位置,将数组划分为左右两个子数组 + # 分区并获取基准值位置 pivot_i = self.randomPartition(nums, low, high) - # 对左右两个子数组分别进行递归快速排序 + # 递归排序左右子数组 self.quickSort(nums, low, pivot_i - 1) self.quickSort(nums, pivot_i + 1, high) - return nums def sortArray(self, nums: [int]) -> [int]: @@ -112,37 +105,42 @@ class Solution: ## 4. 快速排序算法分析 -快速排序算法的时间复杂度主要跟基准数的选择有关。本文中是将当前数组中第 $1$ 个元素作为基准值。 - -在这种选择下,如果参加排序的元素初始时已经有序的情况下,快速排序方法花费的时间最长。也就是会得到最坏时间复杂度。 - -在这种情况下,第 $1$ 趟排序经过 $n - 1$ 次比较以后,将第 $1$ 个元素仍然确定在原来的位置上,并得到 $1$ 个长度为 $n - 1$ 的子数组。第 $2$ 趟排序进过 $n - 2$ 次比较以后,将第 $2$ 个元素确定在它原来的位置上,又得到 $1$ 个长度为 $n - 2$ 的子数组。 - -最终总的比较次数为 $(n − 1) + (n − 2) + … + 1 = \frac{n(n − 1)}{2}$。因此这种情况下的时间复杂度为 $O(n^2)$,也是最坏时间复杂度。 - -我们可以改进一下基准数的选择。如果每次我们选中的基准数恰好能将当前数组平分为两份,也就是刚好取到当前数组的中位数。 - -在这种选择下,每一次都将数组从 $n$ 个元素变为 $\frac{n}{2}$ 个元素。此时的时间复杂度公式为 $T(n) = 2 \times T(\frac{n}{2}) + \Theta(n)$。根据主定理可以得出 $T(n) = O(n \times \log n)$,也是最佳时间复杂度。 - -而在平均情况下,我们可以从当前数组中随机选择一个元素作为基准数。这样,每一次选择的基准数可以看做是等概率随机的。其期望时间复杂度为 $O(n \times \log n)$,也就是平均时间复杂度。 - -下面来总结一下: - -- **最佳时间复杂度**:$O(n \times \log n)$。每一次选择的基准数都是当前数组的中位数,此时算法时间复杂度满足的递推式为 $T(n) = 2 \times T(\frac{n}{2}) + \Theta(n)$,由主定理可得 $T(n) = O(n \times \log n)$。 -- **最坏时间复杂度**:$O(n^2)$。每一次选择的基准数都是数组的最终位置上的值,此时算法时间复杂度满足的递推式为 $T(n) = T(n - 1) + \Theta(n)$,累加可得 $T(n) = O(n^2)$。 -- **平均时间复杂度**:$O(n \times \log n)$。在平均情况下,每一次选择的基准数可以看做是等概率随机的。其期望时间复杂度为 $O(n \times \log n)$。 -- **空间复杂度**:$O(n)$。无论快速排序算法递归与否,排序过程中都需要用到堆栈或其他结构的辅助空间来存放当前待排序数组的首、尾位置。最坏的情况下,空间复杂度为 $O(n)$。如果对算法进行一些改写,在一趟排序之后比较被划分所得到的两个子数组的长度,并且首先对长度较短的子数组进行快速排序,这时候需要的空间复杂度可以达到 $O(log_2 n)$。 -- **排序稳定性**:在进行哨兵划分时,基准数可能会被交换至相等元素的右侧。因此,快速排序是一种 **不稳定排序算法**。 +| 指标 | 复杂度 | 说明 | +|------|--------|------| +| **最佳时间复杂度** | $O(n \log n)$ | 每次都能将数组平均分成两半 | +| **最坏时间复杂度** | $O(n^2)$ | 每次选择的基准值都是极值(如已排序数组) | +| **平均时间复杂度** | $O(n \log n)$ | 随机选择基准值时的期望复杂度 | +| **空间复杂度** | $O(\log n)$ | 递归栈空间,最坏情况下为 $O(n)$ | +| **稳定性** | ❌ 不稳定 | 交换操作可能改变相等元素的相对位置 | + +**适用场景**: +- 大规模数据排序($n \geq 1000$) +- 对平均性能要求高的场景 +- 数据分布相对均匀的情况 + +**优化策略**: +- 随机选择基准值,避免最坏情况 +- 三数取中法选择基准值 +- 小数组使用插入排序 +- 处理重复元素时使用三路快排 ## 5. 总结 -快速排序使用分治策略,通过选取基准数将数组分成两部分,一部分比基准数小,一部分比基准数大。然后对这两部分递归地进行相同操作。 +快速排序是一种高效的排序算法,采用分治策略,通过分区操作将数组分成两部分,然后递归排序。 -快速排序的时间复杂度取决于基准数的选择。最好情况是每次都能平分数组,时间复杂度为 $O(n \log n)$。最坏情况是每次选择的基准数都是极值,时间复杂度为 $O(n^2)$。平均时间复杂度为 $O(n \log n)$。空间复杂度为 $O(n)$,优化后可达 $O(\log n)$。 +**优点**: +- 平均情况下效率高,时间复杂度为 $O(n \log n)$ +- 原地排序,空间复杂度低 +- 缓存友好,局部性良好 +- 实际应用中常数因子较小 -快速排序是不稳定的排序算法,因为在交换过程中可能改变相等元素的相对顺序。它的优势在于平均情况下效率高,适合处理大规模数据。但最坏情况下的性能较差,可以通过随机选择基准数来优化。 +**缺点**: +- 不稳定排序 +- 最坏情况下性能较差,时间复杂度为 $O(n^2)$ +- 对于小数组,其他算法可能更快 +- 递归调用可能导致栈溢出 -快速排序在实际应用中很常见,是许多编程语言内置排序函数的实现基础。它的效率通常比其他 $O(n \log n)$ 算法更高,因为它的常数因子较小。 +快速排序是许多编程语言内置排序函数的实现基础,在实际应用中非常广泛。通过合理的优化策略,可以显著提高其性能和稳定性。 ## 练习题目 diff --git a/docs/01_array/01_09_array_heap_sort.md b/docs/01_array/01_09_array_heap_sort.md index 73530b41..2813dc04 100644 --- a/docs/01_array/01_09_array_heap_sort.md +++ b/docs/01_array/01_09_array_heap_sort.md @@ -1,69 +1,83 @@ + + ## 1. 堆结构 -「堆排序(Heap sort)」是一种基于「堆结构」实现的高效排序算法。在介绍「堆排序」之前,我们先来了解一下什么是「堆结构」。 +「堆排序(Heap sort)」是一种基于「堆结构」实现的高效排序算法。在介绍堆排序之前,我们先来了解什么是堆结构。 ### 1.1 堆的定义 -> **堆(Heap)**:一种满足以下两个条件之一的完全二叉树: +> **堆(Heap)**:一种特殊的完全二叉树,具有以下性质之一: > -> - **大顶堆(Max Heap)**:任意节点值 ≥ 其子节点值。 -> - **小顶堆(Min Heap)**:任意节点值 ≤ 其子节点值。 +> - **大顶堆(Max Heap)**:任意节点值 ≥ 其子节点值 +> - **小顶堆(Min Heap)**:任意节点值 ≤ 其子节点值 ![堆结构](https://qcdn.itcharge.cn/images/20230823133321.png) ### 1.2 堆的存储结构 -堆的逻辑结构就是一颗完全二叉树。如下图所示: +堆的逻辑结构是一棵完全二叉树,如下图所示: ![堆的逻辑结构](https://qcdn.itcharge.cn/images/202405092006120.png) -而我们在「07.树 - 01.二叉树 - 01.树与二叉树的基础知识」章节中学过,对于完全二叉树(尤其是满二叉树)来说,采用顺序存储结构(数组)的形式来表示完全二叉树,能够充分利用存储空间。如下图所示: +在实际编程中,堆通常采用数组进行存储。使用数组表示堆时,节点与数组索引之间的对应关系如下: + +- 若某节点的下标为 $i$,则其左孩子的下标为 $2 \times i + 1$,右孩子的下标为 $2 \times i + 2$; +- 若某节点的下标为 $i$,则其父节点的下标为 $\lfloor \frac{i - 1}{2} \rfloor$。 + +如下图所示,顺序存储结构(数组)可以高效地表示堆: ![使用顺序存储结构(数组)表示堆](https://qcdn.itcharge.cn/images/202405092007823.png) -当我们使用顺序存储结构(即数组)来表示堆时,堆中元素的节点编号与数组的索引关系为: -- 如果某二叉树节点(非叶子节点)的下标为 $i$,那么其左孩子节点下标为 $2 \times i + 1$,右孩子节点下标为 $2 \times i + 2$。 -- 如果某二叉树节点(非根结点)的下标为 $i$,那么其根节点下标为 $\lfloor \frac{i - 1}{2} \rfloor$(向下取整)。 +### 1.3 堆的基本操作 + +#### 1.3.1 创建空堆 + +> **创建空堆**:初始化一个空的堆结构,为后续的堆操作做准备。 + +创建空堆是堆操作的基础,只需要初始化一个空数组即可。在实际应用中,我们通常创建一个类来封装堆的各种操作。 ```python class MaxHeap: def __init__(self): + # 创建空的大顶堆 self.max_heap = [] ``` -### 1.3 访问堆顶元素 +**时间复杂度**:$O(1)$。空堆创建只需要初始化一个空数组,操作非常简单高效。 -> **访问堆顶元素**:指的是从堆结构中获取位于堆顶的元素。 +#### 1.3.2 访问堆顶元素 -在堆中,堆顶元素位于根节点,当我们使用顺序存储结构(即数组)来表示堆时,堆顶元素就是数组的首个元素。 +> **访问堆顶元素**:获取堆中最大(或最小)的元素,即根节点的值。 + +在大顶堆中,堆顶元素就是整个堆中的最大值;在小顶堆中,堆顶元素就是整个堆中的最小值。由于堆顶元素总是存储在数组的第一个位置,因此访问操作非常高效。 ```python -class MaxHeap: - ...... - def peek(self) -> int: - # 大顶堆为空 - if not self.max_heap: - return None - # 返回堆顶元素 - return self.max_heap[0] +def peek(self) -> int: + # 检查堆是否为空 + if not self.max_heap: + return None + # 返回堆顶元素(数组第一个元素) + return self.max_heap[0] ``` -访问堆顶元素不依赖于数组中元素个数,因此时间复杂度为 $O(1)$。 +**时间复杂度**:$O(1)$。由于堆顶元素始终位于数组索引 0 的位置,访问操作只需要一次数组索引操作,不依赖于堆的大小。 -### 1.4 向堆中插入元素 +#### 1.3.3 向堆中插入元素 -> **向堆中插入元素**:指的将一个新的元素添加到堆中,调整堆结构,以保持堆的特性不变。 +> **向堆中插入元素**:将新元素添加到堆中,并通过调整保持堆的性质。 -向堆中插入元素的步骤如下: +向堆中插入元素需要两个步骤: -1. 将新元素添加到堆的末尾,保持完全二叉树的结构。 -2. 从新插入的元素节点开始,将该节点与其父节点进行比较。 - 1. 如果新节点的值大于其父节点的值,则交换它们,以保持最大堆的特性。 - 2. 如果新节点的值小于等于其父节点的值,说明已满足最大堆的特性,此时结束。 -3. 重复上述比较和交换步骤,直到新节点不再大于其父节点,或者达到了堆的根节点。 +1. **添加元素**:将新元素添加到堆的末尾,保持完全二叉树的结构 +2. **上移调整**:从新元素开始向上调整,直到满足堆的性质 -这个过程称为「上移调整(Shift Up)」。因为新插入的元素会逐步向堆的上方移动,直到找到了合适的位置,保持堆的有序性。 +**上移调整(Shift Up)过程**: +- 将新插入的节点与其父节点比较 +- 如果新节点值大于父节点值,则交换它们 +- 重复此过程,直到新节点不再大于其父节点或到达根节点 + +下面我们通过图示步骤来演示一下向堆中插入元素的过程。 ::: tabs#heapPush @@ -98,38 +112,41 @@ class MaxHeap: ::: ```python -class MaxHeap: - ...... - def push(self, val: int): - # 将新元素添加到堆的末尾 - self.max_heap.append(val) - - size = len(self.max_heap) - # 从新插入的元素节点开始,进行上移调整 - self.__shift_up(size - 1) - - def __shift_up(self, i: int): - while (i - 1) // 2 >= 0 and self.max_heap[i] > self.max_heap[(i - 1) // 2]: - self.max_heap[i], self.max_heap[(i - 1) // 2] = self.max_heap[(i - 1) // 2], self.max_heap[i] - i = (i - 1) // 2 +def push(self, val: int): + # 将新元素添加到堆的末尾 + self.max_heap.append(val) + # 从新元素开始进行上移调整 + self.__shift_up(len(self.max_heap) - 1) + +def __shift_up(self, i: int): + # 上移调整:将节点与其父节点比较并交换 + while (i - 1) // 2 >= 0 and self.max_heap[i] > self.max_heap[(i - 1) // 2]: + # 交换当前节点与父节点 + self.max_heap[i], self.max_heap[(i - 1) // 2] = self.max_heap[(i - 1) // 2], self.max_heap[i] + # 继续向上调整 + i = (i - 1) // 2 ``` -在最坏情况下,「向堆中插入元素」的时间复杂度为 $O(\log n)$,其中 $n$ 是堆中元素的数量,这是因为堆的高度是 $\log n$。 +**时间复杂度**:$O(\log n)$。在最坏情况下,新插入的元素需要从堆的底部移动到顶部,移动的距离等于堆的高度 $\log n$。 + +#### 1.3.4 删除堆顶元素 + +> **删除堆顶元素**:移除堆中的最大(或最小)元素,并重新调整堆结构。 -### 1.5 删除堆顶元素 +删除堆顶元素需要三个步骤: -> **删除堆顶元素**:指的是从堆中移除位于堆顶的元素,并重新调整对结果,以保持堆的特性不变。 +1. **交换元素**:将堆顶元素与末尾元素交换 +2. **删除元素**:移除末尾元素(原堆顶元素) +3. **下移调整**:从新的堆顶开始向下调整,直到满足堆的性质 -删除堆顶元素的步骤如下: +**下移调整(Shift Down)过程**: +- 将新的堆顶元素与其较大的子节点比较 +- 如果堆顶元素小于较大子节点,则交换它们 +- 重复此过程,直到堆顶元素不再小于其子节点或到达叶子节点 -1. 将堆顶元素(即根节点)与堆的末尾元素交换。 -2. 移除堆末尾的元素(之前的堆顶),即将其从堆中剔除。 -3. 从新的堆顶元素开始,将其与其较大的子节点进行比较。 - 1. 如果当前节点的值小于其较大的子节点,则将它们交换。这一步是为了将新的堆顶元素「下沉」到适当的位置,以保持最大堆的特性。 - 2. 如果当前节点的值大于等于其较大的子节点,说明已满足最大堆的特性,此时结束。 -4. 重复上述比较和交换步骤,直到新的堆顶元素不再小于其子节点,或者达到了堆的底部。 +这个过程称为「下移调整」,因为新的堆顶元素会逐步向堆的下方移动,直到找到合适的位置。 -这个过程称为「下移调整(Shift Down)」。因为新的堆顶元素会逐步向堆的下方移动,直到找到了合适的位置,保持堆的有序性。 +下面我们通过图示步骤来演示一下删除堆顶元素的过程。 ::: tabs#heapPop @@ -164,75 +181,69 @@ class MaxHeap: ::: ```python -class MaxHeap: - ...... - def pop(self) -> int: - # 堆为空 - if not self.max_heap: - raise IndexError("堆为空") - - size = len(self.max_heap) - self.max_heap[0], self.max_heap[size - 1] = self.max_heap[size - 1], self.max_heap[0] - # 删除堆顶元素 - val = self.max_heap.pop() - # 节点数减 1 - size -= 1 +def pop(self) -> int: + # 检查堆是否为空 + if not self.max_heap: + raise IndexError("堆为空") + + # 交换堆顶元素与末尾元素 + size = len(self.max_heap) + self.max_heap[0], self.max_heap[size - 1] = self.max_heap[size - 1], self.max_heap[0] + + # 删除末尾元素(原堆顶元素) + val = self.max_heap.pop() + + # 如果堆不为空,进行下移调整 + if self.max_heap: + self.__shift_down(0, len(self.max_heap)) + + # 返回被删除的堆顶元素 + return val + +def __shift_down(self, i: int, n: int): + # 下移调整:将节点与其较大的子节点比较并交换 + while 2 * i + 1 < n: + # 计算左右子节点索引 + left, right = 2 * i + 1, 2 * i + 2 - # 下移调整 - self.__shift_down(0, size) + # 找出较大的子节点 + larger = left + if right < n and self.max_heap[right] > self.max_heap[left]: + larger = right - # 返回堆顶元素 - return val - - - def __shift_down(self, i: int, n: int): - while 2 * i + 1 < n: - # 左右子节点编号 - left, right = 2 * i + 1, 2 * i + 2 - - # 找出左右子节点中的较大值节点编号 - if 2 * i + 2 >= n: - # 右子节点编号超出范围(只有左子节点 - larger = left - else: - # 左子节点、右子节点都存在 - if self.max_heap[left] >= self.max_heap[right]: - larger = left - else: - larger = right - - # 将当前节点值与其较大的子节点进行比较 - if self.max_heap[i] < self.max_heap[larger]: - # 如果当前节点值小于其较大的子节点,则将它们交换 - self.max_heap[i], self.max_heap[larger] = self.max_heap[larger], self.max_heap[i] - i = larger - else: - # 如果当前节点值大于等于于其较大的子节点,此时结束 - break + # 如果当前节点小于较大子节点,则交换 + if self.max_heap[i] < self.max_heap[larger]: + self.max_heap[i], self.max_heap[larger] = self.max_heap[larger], self.max_heap[i] + i = larger + else: + break ``` -「删除堆顶元素」的时间复杂度通常为$O(\log n)$,其中 $n$ 是堆中元素的数量,因为堆的高度是 $\log n$。 +**时间复杂度**:$O(\log n)$。在最坏情况下,新的堆顶元素需要从堆的顶部移动到底部,移动的距离等于堆的高度 $\log n$。 ## 2. 堆排序 ### 2.1 堆排序算法思想 > **堆排序(Heap sort)基本思想**: -> -> 借用「堆结构」所设计的排序算法。将数组转化为大顶堆,重复从大顶堆中取出数值最大的节点,并让剩余的堆结构继续维持大顶堆性质。 +> +> 利用堆的特性,将数组构建成大顶堆,然后重复取出堆顶元素(最大值)并调整堆结构,最终得到有序数组。 ### 2.2 堆排序算法步骤 -1. **构建初始大顶堆**: - 1. 定义一个数组实现的堆结构,将原始数组的元素依次存入堆结构的数组中(初始顺序不变)。 - 2. 从数组的中间位置开始,从右至左,依次通过「下移调整」将数组转换为一个大顶堆。 +堆排序分为两个主要阶段: -2. **交换元素,调整堆**: - 1. 交换堆顶元素(第 $1$ 个元素)与末尾(最后 $1$ 个元素)的位置,交换完成后,堆的长度减 $1$。 - 2. 交换元素之后,由于堆顶元素发生了改变,需要从根节点开始,对当前堆进行「下移调整」,使其保持堆的特性。 +**第一阶段:构建初始大顶堆** +1. 将原始数组视为完全二叉树 +2. 从最后一个非叶子节点开始,自底向上进行下移调整 +3. 将数组转换为大顶堆 + +**第二阶段:重复提取最大值** +1. 交换堆顶元素与当前末尾元素 +2. 堆长度减 $1$,末尾元素已排好序 +3. 对新的堆顶元素进行下移调整,恢复堆的性质 +4. 重复步骤 $1 \sim 3$,直到堆的大小为 $1$ -3. **重复交换和调整堆**: - 1. 重复第 $2$ 步,直到堆的大小为 $1$ 时,此时大顶堆的数组已经完全有序。 ::: tabs#heapSortBuildMaxHeap @@ -310,7 +321,7 @@ class MaxHeap: @tab <11> -![https://qcdn.](https://qcdn.itcharge.cn/images/20230831162505.png) +![2. 交换元素,调整堆 11](https://qcdn.itcharge.cn/images/20230831162505.png) @tab <12> @@ -322,61 +333,112 @@ class MaxHeap: ```python class MaxHeap: - ...... + def __init__(self): + self.max_heap = [] + def __buildMaxHeap(self, nums: [int]): + # 将数组元素复制到堆中 + self.max_heap = nums.copy() size = len(nums) - # 先将数组 nums 的元素按顺序添加到 max_heap 中 - for i in range(size): - self.max_heap.append(nums[i]) - # 从最后一个非叶子节点开始,进行下移调整 + # 从最后一个非叶子节点开始,自底向上构建堆 for i in range((size - 2) // 2, -1, -1): self.__shift_down(i, size) def maxHeapSort(self, nums: [int]) -> [int]: - # 根据数组 nums 建立初始堆 + # 第一阶段:构建初始大顶堆 self.__buildMaxHeap(nums) size = len(self.max_heap) + # 第二阶段:重复提取最大值 for i in range(size - 1, -1, -1): - # 交换根节点与当前堆的最后一个节点 + # 交换堆顶元素与当前末尾元素 self.max_heap[0], self.max_heap[i] = self.max_heap[i], self.max_heap[0] - # 从根节点开始,对当前堆进行下移调整 + # 对新的堆顶元素进行下移调整,堆的大小为 i self.__shift_down(0, i) # 返回排序后的数组 return self.max_heap + def __shift_down(self, i: int, n: int): + # 下移调整:将节点与其较大的子节点比较并交换 + while 2 * i + 1 < n: + left, right = 2 * i + 1, 2 * i + 2 + + # 找出较大的子节点 + larger = left + if right < n and self.max_heap[right] > self.max_heap[left]: + larger = right + + # 如果当前节点小于较大子节点,则交换 + if self.max_heap[i] < self.max_heap[larger]: + self.max_heap[i], self.max_heap[larger] = self.max_heap[larger], self.max_heap[i] + i = larger + else: + break + class Solution: - def maxHeapSort(self, nums: [int]) -> [int]: - return MaxHeap().maxHeapSort(nums) - def sortArray(self, nums: [int]) -> [int]: - return self.maxHeapSort(nums) - -print(Solution().sortArray([10, 25, 6, 8, 7, 1, 20, 23, 16, 19, 17, 3, 18, 14])) + return MaxHeap().maxHeapSort(nums) ``` ### 2.4 堆排序算法分析 -- **时间复杂度**:$O(n \times \log n)$。 - - 堆积排序的时间主要花费在两个方面:「建立初始堆」和「下移调整」。 - - 设原始数组所对应的完全二叉树深度为 $d$,算法由两个独立的循环组成: - 1. 在第 $1$ 个循环构造初始堆积时,从 $i = d - 1$ 层开始,到 $i = 1$ 层为止,对每个分支节点都要调用一次调整堆算法,而一次调整堆算法,对于第 $i$ 层一个节点到第 $d$ 层上建立的子堆积,所有节点可能移动的最大距离为该子堆积根节点移动到最后一层(第 $d$ 层) 的距离,即 $d - i$。而第 $i$ 层上节点最多有 $2^{i-1}$ 个,所以每一次调用调整堆算法的最大移动距离为 $2^{i-1} * (d-i)$。因此,堆积排序算法的第 $1$ 个循环所需时间应该是各层上的节点数与该层上节点可移动的最大距离之积的总和,即:$\sum_{i = d - 1}^1 2^{i-1} (d-i) = \sum_{j = 1}^{d-1} 2^{d-j-1} \times j = \sum_{j = 1}^{d-1} 2^{d-1} \times {j \over 2^j} \le n \times \sum_{j = 1}^{d-1} {j \over 2^j} < 2 \times n$。这一部分的时间花费为 $O(n)$。 - 2. 在第 $2$ 个循环中,每次调用调整堆算法一次,节点移动的最大距离为这棵完全二叉树的深度 $d = \lfloor \log_2(n) \rfloor + 1$,一共调用了 $n - 1$ 次调整堆算法,所以,第 $2$ 个循环的时间花费为 $(n-1)(\lfloor \log_2 (n)\rfloor + 1) = O(n \times \log n)$。 - - 因此,堆积排序的时间复杂度为 $O(n \times \log n)$。 -- **空间复杂度**:$O(1)$。由于在堆积排序中只需要一个记录大小的辅助空间,因此,堆积排序的空间复杂度为:$O(1)$。 -- **排序稳定性**:在进行「下移调整」时,相等元素的相对位置可能会发生变化。因此,堆排序是一种 **不稳定排序算法**。 +**时间复杂度分析**: + +堆排序的时间复杂度由两个主要步骤组成: + +1. **构建初始堆**:$O(n)$ + - 从最后一个非叶子节点开始,自底向上进行下移调整 + - 对于第 $i$ 层的节点,最多需要下移 $(\log n - i)$ 层 + - 第 $i$ 层有 $2^i$ 个节点 + - 总调整次数:$\sum_{i=0}^{\log n - 1} 2^i \cdot (\log n - i) = O(n)$ + +2. **重复提取最大值**:$O(n \log n)$ + - 需要进行 $n$ 次提取操作 + - 每次提取后需要下移调整,最坏情况下需要 $O(\log n)$ 时间 + - 总时间复杂度:$n \times O(\log n) = O(n \log n)$ + +**总时间复杂度**:$O(n) + O(n \log n) = O(n \log n)$ + +| 指标 | 复杂度 | 说明 | +|------|--------|------| +| **最佳时间复杂度** | $O(n \log n)$ | 无论数组状态如何,都需要构建堆和提取元素 | +| **最坏时间复杂度** | $O(n \log n)$ | 无论数组状态如何,都需要构建堆和提取元素 | +| **平均时间复杂度** | $O(n \log n)$ | 堆排序的时间复杂度与数据状态无关 | +| **空间复杂度** | $O(1)$ | 原地排序,只使用常数空间 | +| **稳定性** | ❌ 不稳定 | 调整堆的过程中可能改变相等元素的相对顺序 | + +**适用场景**: +- 大规模数据排序 +- 内存受限的环境 +- 需要稳定时间复杂度的场景 +- 需要保证最坏情况下性能的场景 + +## 3. 总结 + +堆排序是一种基于堆数据结构的排序算法,利用堆的特性实现高效排序。 -## 5. 总结 +**核心思想**: +- 将数组构建成大顶堆,堆顶元素始终是最大值 +- 重复取出堆顶元素并调整堆结构,最终得到有序数组 -堆排序利用堆结构进行排序。堆是一种完全二叉树,分为大顶堆和小顶堆。大顶堆中每个节点的值都大于等于其子节点值,小顶堆中每个节点的值都小于等于其子节点值。 +**算法步骤**: +1. **构建初始堆**:将数组转换为大顶堆 +2. **重复提取**:交换堆顶与末尾元素,调整堆结构,逐步得到有序数组 -堆排序分为两个主要步骤:构建初始堆和交换调整堆。首先将数组构建成大顶堆,然后重复取出堆顶元素(最大值)并调整堆结构。每次取出堆顶元素后,将堆末尾元素移到堆顶,再进行下移调整保持堆的性质。 +**优点**: +- 时间复杂度稳定,始终为 $O(n \log n)$ +- 空间复杂度低,为 $O(1)$ +- 适合处理大规模数据 +- 原地排序,不需要额外空间 -堆排序的时间复杂度为 $O(n \log n)$。构建初始堆需要 $O(n)$ 时间,每次调整堆需要 $O(\log n)$ 时间,共进行 $n$ 次调整。空间复杂度为 $O(1)$,因为排序是原地进行的。 +**缺点**: +- 不稳定排序 +- 常数因子较大,实际应用中可能比快速排序稍慢 +- 对缓存不友好,访问模式不够局部化 -堆排序是不稳定的排序算法,因为在调整堆的过程中可能改变相等元素的相对顺序。它的优势在于不需要额外空间,适合处理大规模数据。但相比快速排序,堆排序的常数因子较大,实际应用中可能稍慢。 +堆排序是一种同时具备 $O(n \log n)$ 时间复杂度和 $O(1)$ 空间复杂度的比较排序算法,在内存受限或需要稳定时间复杂度的场景下具有重要价值。 ## 练习题目 diff --git a/docs/01_array/01_10_array_counting_sort.md b/docs/01_array/01_10_array_counting_sort.md index 37e8292d..392ffd27 100644 --- a/docs/01_array/01_10_array_counting_sort.md +++ b/docs/01_array/01_10_array_counting_sort.md @@ -2,52 +2,45 @@ > **计数排序(Counting Sort)基本思想**: > -> 通过统计数组中每个元素在数组中出现的次数,根据这些统计信息将数组元素有序的放置到正确位置,从而达到排序的目的。 +> 统计数组中每个元素出现的次数,然后根据统计信息将元素按顺序放置到正确位置,实现排序。 ## 2. 计数排序算法步骤 -1. **计算排序范围**:遍历数组,找出待排序序列中最大值元素 $nums\underline{\hspace{0.5em}}max$ 和最小值元素 $nums\underline{\hspace{0.5em}}min$,计算出排序范围为 $nums\underline{\hspace{0.5em}}max - nums\underline{\hspace{0.5em}}min + 1$。 -2. **定义计数数组**:定义一个大小为排序范围的计数数组 $counts$,用于统计每个元素的出现次数。其中: - 1. 数组的索引值 $num - nums\underline{\hspace{0.5em}}min$ 表示元素的值为 $num$。 - 2. 数组的值 $counts[num - nums\underline{\hspace{0.5em}}min]$ 表示元素 $num$ 的出现次数。 +1. **确定数值范围**:找出数组中的最大值和最小值,计算数值范围。 +2. **创建计数数组**:创建一个大小为数值范围的数组,用于统计每个元素出现的次数。 +3. **统计元素频次**:遍历原数组,统计每个元素出现的次数。 +4. **计算累积频次**:将计数数组转换为累积频次数组,表示每个元素在排序后数组中的位置。 +5. **逆序填充结果**:逆序遍历原数组,根据累积频次将元素放入正确位置。 -3. **对数组元素进行计数统计**:遍历待排序数组 $nums$,对每个元素在计数数组中进行计数,即将待排序数组中「每个元素值减去最小值」作为索引,将「对计数数组中的值」加 $1$,即令 $counts[num - nums\underline{\hspace{0.5em}}min]$ 加 $1$。 -4. **生成累积计数数组**:从 $counts$ 中的第 $1$ 个元素开始,每一项累家前一项和。此时 $counts[num - nums\underline{\hspace{0.5em}}min]$ 表示值为 $num$ 的元素在排序数组中最后一次出现的位置。 -5. **逆序填充目标数组**:逆序遍历数组 $nums$,将每个元素 $num$ 填入正确位置。 - 1. 将其填充到结果数组 $res$ 的索引 $counts[num - nums\underline{\hspace{0.5em}}min]$ 处。 - 2. 放入后,令累积计数数组中对应索引减 $1$,从而得到下个元素 $num$ 的放置位置。 +以数组 $[3, 0, 4, 2, 5, 1, 3, 1, 4, 5]$ 为例,演示一下计数排序的算法步骤。 -我们以 $[3, 0, 4, 2, 5, 1, 3, 1, 4, 5]$ 为例,演示一下计数排序算法的整个步骤。 - -![计数排序算法步骤](https://qcdn.itcharge.cn/images/20230822135634.png) +![计数排序的算法步骤](https://qcdn.itcharge.cn/images/20230822135634.png) ## 3. 计数排序代码实现 ```python class Solution: def countingSort(self, nums: [int]) -> [int]: - # 计算待排序数组中最大值元素 nums_max 和最小值元素 nums_min + # 确定数值范围 nums_min, nums_max = min(nums), max(nums) - # 定义计数数组 counts,大小为 最大值元素 - 最小值元素 + 1 size = nums_max - nums_min + 1 counts = [0 for _ in range(size)] - # 统计值为 num 的元素出现的次数 + # 统计每个元素出现的次数 for num in nums: counts[num - nums_min] += 1 - # 生成累积计数数组 + # 计算累积频次(每个元素出现的次数) for i in range(1, size): counts[i] += counts[i - 1] - # 反向填充目标数组 + # 逆序填充结果数组 res = [0 for _ in range(len(nums))] for i in range(len(nums) - 1, -1, -1): num = nums[i] # 根据累积计数数组,将 num 放在数组对应位置 res[counts[num - nums_min] - 1] = num - # 将 num 的对应放置位置减 1,从而得到下个元素 num 的放置位置 - counts[nums[i] - nums_min] -= 1 + counts[num - nums_min] -= 1 return res @@ -57,18 +50,25 @@ class Solution: ## 4. 计数排序算法分析 -- **时间复杂度**:$O(n + k)$。其中 $k$ 代表待排序数组的值域。 -- **空间复杂度**:$O(k)$。其中 $k$ 代表待排序序列的值域。由于用于计数的数组 $counts$ 的长度取决于待排序数组中数据的范围(大小等于待排序数组最大值减去最小值再加 $1$)。所以计数排序算法对于数据范围很大的数组,需要大量的内存。 -- **计数排序适用情况**:计数排序一般用于整数排序,不适用于按字母顺序、人名顺序排序。 -- **排序稳定性**:由于向结果数组中填充元素时使用的是逆序遍历,可以避免改变相等元素之间的相对顺序。因此,计数排序是一种 **稳定排序算法**。 +| 指标 | 复杂度 | 说明 | +|------|--------|------| +| **最佳时间复杂度** | $O(n + k)$ | $n$ 为元素个数,$k$ 为数值范围,无论数组状态如何,都需要统计和填充操作 | +| **最坏时间复杂度** | $O(n + k)$ | 无论数组状态如何,都需要统计和填充操作 | +| **平均时间复杂度** | $O(n + k)$ | 计数排序的时间复杂度与数据状态无关 | +| **空间复杂度** | $O(k)$ | 需要额外的计数数组,大小取决于数值范围 | +| **稳定性** | ✅ 稳定 | 逆序填充保持相等元素的相对顺序 | -## 5. 总结 +**适用场景**: +- 整数排序 +- 数值范围较小($k$ 远小于 $n$) +- 对稳定性有要求的场景 -计数排序通过统计元素出现次数来实现排序。它先找出数组中的最大值和最小值,确定排序范围。然后统计每个元素的出现次数,计算累积次数,最后根据统计信息将元素放到正确位置。 +## 5. 总结 -计数排序的时间复杂度是 $O(n + k)$,其中 $n$ 是元素个数,$k$ 是数值范围。空间复杂度是 $O(k)$。当 $k$ 值较小时效率很高,但当数值范围很大时会消耗较多内存。 +计数排序是一种非比较排序算法,通过统计元素频次实现排序。它特别适合数值范围较小的整数排序。 -计数排序适合整数排序,不适合字符串等复杂数据。它是稳定的排序算法,能保持相等元素的原始顺序。在实际应用中,计数排序常用于数据范围不大的整数排序场景。 +**优点**:时间复杂度稳定,稳定排序,适合小范围整数排序 +**缺点**:空间复杂度与数值范围相关,不适合大范围数值 ## 练习题目 diff --git a/docs/01_array/01_11_array_bucket_sort.md b/docs/01_array/01_11_array_bucket_sort.md index ed8d3d08..a260d245 100644 --- a/docs/01_array/01_11_array_bucket_sort.md +++ b/docs/01_array/01_11_array_bucket_sort.md @@ -1,19 +1,19 @@ ## 1. 桶排序算法思想 -> **桶排序(Bucket Sort)基本思想**: -> -> 将待排序数组中的元素分散到若干个「桶」中,然后对每个桶中的元素再进行单独排序。 +> **桶排序(Bucket Sort)**: +> +> 将待排序元素分散到多个桶中,对每个桶单独排序后合并。 ## 2. 桶排序算法步骤 -1. **确定桶的数量**:根据待排序数组的值域范围,将数组划分为 $k$ 个桶,每个桶可以看做是一个范围区间。 -2. **分配元素**:遍历待排序数组元素,将每个元素根据大小分配到对应的桶中。 -3. **对每个桶进行排序**:对每个非空桶内的元素单独排序(使用插入排序、归并排序、快排排序等算法)。 -4. **合并桶内元素**:将排好序的各个桶中的元素按照区间顺序依次合并起来,形成一个完整的有序数组。 +1. **确定桶的数量**:根据待排序数组的数值范围,将其划分为 $k$ 个桶,每个桶对应一个特定的区间。 +2. **元素分配**:遍历数组,将每个元素根据其数值映射到所属的桶中。 +3. **桶内排序**:对每个非空桶分别进行排序(可选用插入排序、归并排序、快速排序等算法)。 +4. **合并结果**:按桶的顺序依次合并所有已排序的桶,得到最终有序数组。 -我们以 $[39, 49, 8, 13, 22, 15, 10, 30, 5, 44]$ 为例,演示一下桶排序算法的整个步骤。 +以数组 $[39, 49, 8, 13, 22, 15, 10, 30, 5, 44]$ 为例,演示一下桶排序的算法步骤。 -![桶排序算法步骤](https://qcdn.itcharge.cn/images/20230822153701.png) +![桶排序的算法步骤](https://qcdn.itcharge.cn/images/20230822153701.png) ## 3. 桶排序代码实现 @@ -31,13 +31,11 @@ class Solution: j -= 1 # 将该元素插入到适当位置 nums[j] = temp - return nums - def bucketSort(self, nums: [int], bucket_size=5) -> [int]: - # 计算待排序序列中最大值元素 nums_max、最小值元素 nums_min + def bucketSort(self, nums: [int], bucket_size=5) -> [int]: + # 计算数据范围 nums_min, nums_max = min(nums), max(nums) - # 定义桶的个数为 (最大值元素 - 最小值元素) // 每个桶的大小 + 1 bucket_count = (nums_max - nums_min) // bucket_size + 1 # 定义桶数组 buckets buckets = [[] for _ in range(bucket_count)] @@ -46,7 +44,7 @@ class Solution: for num in nums: buckets[(num - nums_min) // bucket_size].append(num) - # 对每个非空桶内的元素单独排序,排序之后,按照区间顺序依次合并到 res 数组中 + # 排序并合并 res = [] for bucket in buckets: self.insertionSort(bucket) @@ -61,17 +59,25 @@ class Solution: ## 4. 桶排序算法分析 -- **时间复杂度**:$O(n)$。当输入元素个数为 $n$,桶的个数是 $m$ 时,每个桶里的数据就是 $k = \frac{n}{m}$ 个。每个桶内排序的时间复杂度为 $O(k \times \log k)$。$m$ 个桶就是 $m \times O(k \times \log k) = m \times O(\frac{n}{m} \times \log \frac{n}{m}) = O(n \times \log \frac{n}{m})$。当桶的个数 $m$ 接近于数据个数 $n$ 时,$\log \frac{n}{m}$ 就是一个较小的常数,所以排序桶排序时间复杂度接近于 $O(n)$。 -- **空间复杂度**:$O(n + m)$。由于桶排序使用了辅助空间,所以桶排序的空间复杂度是 $O(n + m)$。 -- **排序稳定性**:桶排序的稳定性取决于桶内使用的排序算法。如果桶内使用稳定的排序算法(比如插入排序算法),并且在合并桶的过程中保持相等元素的相对顺序不变,则桶排序是一种 **稳定排序算法**。反之,则桶排序是一种 **不稳定排序算法**。 +| 指标 | 复杂度 | 说明 | +|------|--------|------| +| **最佳时间复杂度** | $O(n)$ | 数据分布均匀,每个桶内元素数量相近 | +| **最坏时间复杂度** | $O(n^2)$ | 数据集中在少数桶中,桶内排序复杂度高 | +| **平均时间复杂度** | $O(n + k)$ | $k$ 为桶的数量,数据分布较均匀时接近 $O(n)$ | +| **空间复杂度** | $O(n + m)$ | 需要额外空间存储桶,$m$ 为桶的数量 | +| **稳定性** | ✅ 稳定 | 取决于桶内排序算法,通常使用稳定排序 | -## 5. 总结 +**适用场景**: +- 数据分布均匀 +- 外部排序 +- 数据范围已知且有限 -桶排序将元素分散到多个桶中,每个桶单独排序后再合并。它先确定桶的数量和范围,把元素分配到对应桶中,对每个桶排序,最后合并所有桶。 +## 5. 总结 -桶排序的时间复杂度接近 $O(n)$,在数据分布均匀时效率很高。空间复杂度是 $O(n + m)$,需要额外存储桶。排序的稳定性取决于桶内使用的排序算法。 +桶排序是一种分布式排序算法,通过将数据分散到多个桶中,对每个桶单独排序后合并实现排序。 -桶排序适合数据分布均匀的情况,当数据集中在少数桶时会降低效率。实际应用中常用于外部排序和处理大数据量的场景。合理设置桶的数量和范围对性能很重要。 +**优点**:数据分布均匀时效率高,适合外部排序,可并行处理 +**缺点**:需要额外空间,数据分布不均匀时效率下降,对数据范围有要求 ## 练习题目 diff --git a/docs/01_array/01_12_array_radix_sort.md b/docs/01_array/01_12_array_radix_sort.md index f21b2783..3ad10e3d 100644 --- a/docs/01_array/01_12_array_radix_sort.md +++ b/docs/01_array/01_12_array_radix_sort.md @@ -2,7 +2,7 @@ > **基数排序(Radix Sort)基本思想**: > -> 将整数按位数切割成不同的数字,然后从低位开始,依次到高位,逐位进行排序,从而达到排序的目的。 +> 按照数字的每一位进行排序,从最低位到最高位,逐位比较。 ## 2. 基数排序算法步骤 @@ -10,14 +10,13 @@ 下面我们以最低位优先法为例,讲解一下算法步骤。 -1. **确定排序的最大位数**:遍历数组元素,获取数组最大值元素,并取得对应位数。 +1. **确定最大位数**:遍历数组元素,找到数组中最大值的位数。 2. **从最低位(个位)开始,到最高位为止,逐位对每一位进行排序**: - 1. 定义一个长度为 $10$ 的桶数组 $buckets$,每个桶分别代表 $0 \sim 9$ 中的 $1$ 个数字。 - 2. 按照每个元素当前位上的数字,将元素放入对应数字的桶中。 - 3. 清空原始数组,然后按照桶的顺序依次取出对应元素,重新加入到原始数组中。 + 1. 创建 10 个桶(每个桶分别代表 $0 \sim 9$ 中的一个数字)。 + 2. 按照每个元素当前位上的数字,将元素放入对应桶中。 + 3. 清空原始数组,然后按照桶的顺序依次取出对应元素,重新加入到数组中。 - -我们以 $[692, 924, 969, 503, 871, 704, 542, 436]$ 为例,演示一下基数排序算法的整个步骤。 +我们以 $[692, 924, 969, 503, 871, 704, 542, 436]$ 为例,演示一下基数排序的算法步骤。 ![基数排序算法步骤](https://qcdn.itcharge.cn/images/20230822171758.png) @@ -26,19 +25,20 @@ ```python class Solution: def radixSort(self, nums: [int]) -> [int]: - # 桶的大小为所有元素的最大位数 + # 获取最大位数 size = len(str(max(nums))) - # 从最低位(个位)开始,逐位遍历每一位 + # 从个位开始逐位排序 for i in range(size): - # 定义长度为 10 的桶数组 buckets,每个桶分别代表 0 ~ 9 中的 1 个数字。 + # 创建 10 个桶,每个桶分别代表 0 ~ 9 中的 1 个数字 buckets = [[] for _ in range(10)] - # 遍历数组元素,按照每个元素当前位上的数字,将元素放入对应数字的桶中。 + + # 按当前位数字分桶 for num in nums: buckets[num // (10 ** i) % 10].append(num) - # 清空原始数组 + + # 重新收集 nums.clear() - # 按照桶的顺序依次取出对应元素,重新加入到原始数组中。 for bucket in buckets: for num in bucket: nums.append(num) @@ -52,17 +52,25 @@ class Solution: ## 4. 基数排序算法分析 -- **时间复杂度**:$O(n \times k)$。其中 $n$ 是待排序元素的个数,$k$ 是数字位数。$k$ 的大小取决于数字位的选择(十进制位、二进制位)和待排序元素所属数据类型全集的大小。 -- **空间复杂度**:$O(n + k)$。 -- **排序稳定性**:基数排序采用的桶排序是稳定的。基数排序是一种 **稳定排序算法**。 +| 指标 | 复杂度 | 说明 | +|------|--------|------| +| **最佳时间复杂度** | $O(n \times k)$ | 所有数字位数相同,$k$ 为最大位数 | +| **最坏时间复杂度** | $O(n \times k)$ | 所有数字位数相同,$k$ 为最大位数 | +| **平均时间复杂度** | $O(n \times k)$ | 基数排序的时间复杂度与数据状态无关 | +| **空间复杂度** | $O(n + k)$ | 需要 $n$ 个元素的存储空间和 $k$ 个桶 | +| **稳定性** | ✅ 稳定 | 桶排序保证相等元素的相对位置不变 | -## 5. 总结 +**适用场景**: +- 整数排序,位数不多($k$ 较小) +- 数据范围大但位数固定 +- 电话号码、身份证号等固定位数数据 -基数排序按照数字的每一位进行排序。它从最低位开始,逐位比较,直到最高位。每次排序时,将数字分配到 $0 \sim 9$ 的桶中,然后按顺序收集。 +## 5. 总结 -基数排序的时间复杂度是 $O(n \times k)$,$n$ 是元素数量,$k$ 是最大位数。空间复杂度是 $O(n + k)$。它是稳定的排序算法,能保持相同数字的相对顺序。 +基数排序是一种非比较排序算法,通过按位分配和收集实现排序。 -基数排序适合处理位数不多的整数排序。当数字范围很大但位数较少时效率较高。实际应用中常用于电话号码、身份证号等固定位数数据的排序。 +**优点**:时间复杂度与数据范围无关,稳定排序,适合固定位数数据 +**缺点**:空间复杂度较高,只适用于整数排序 ## 练习题目 diff --git a/docs/01_array/01_13_array_binary_search_01.md b/docs/01_array/01_13_array_binary_search_01.md index f77ca0ac..e2c666cc 100644 --- a/docs/01_array/01_13_array_binary_search_01.md +++ b/docs/01_array/01_13_array_binary_search_01.md @@ -1,36 +1,33 @@ ## 1. 二分查找算法介绍 -### 1.1 二分查找算法简介 +### 1.1 算法简介 -> **二分查找算法(Binary Search Algorithm)**:也叫做折半查找算法、对数查找算法,是一种用于在有序数组中查找特定元素的高效搜索算法。 +> **二分查找算法(Binary Search Algorithm)**,又称折半查找或对数查找,是一种在有序数组中高效定位目标元素的方法。其核心思想是每次将查找区间缩小一半,从而快速锁定目标位置。 -二分查找的基本算法思想为:通过确定目标元素所在的区间范围,反复将查找范围减半,直到找到元素或找不到该元素为止。 +### 1.2 算法步骤 -### 1.2 二分查找算法步骤 +1. **初始化**:确定待查找的有序数据集合(如数组或列表),并确保元素已按升序或降序排列。 +2. **设置查找区间**:定义查找的左右边界,初始时 $left$ 指向数组起始位置,$right$ 指向数组末尾位置。 +3. **计算中间下标**:通过 $mid = \lfloor (left + right) / 2 \rfloor$ 计算当前查找区间的中间下标 $mid$。 +4. **比较并缩小区间**:将目标值 $target$ 与 $nums[mid]$ 比较: + 1. 若 $target == nums[mid]$,则找到目标,返回 $mid$。 + 2. 若 $target < nums[mid]$,目标在左半区间,更新右边界 $right = mid - 1$。 + 3. 若 $target > nums[mid]$,目标在右半区间,更新左边界 $left = mid + 1$。 +5. 重复步骤 $3 \sim 4$,直到找到目标元素(返回 $mid$),或查找区间为空($left > right$),此时返回 $-1$,表示目标不存在。 -以下是二分查找算法的基本步骤: +我们以在有序数组 $[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]$ 中查找目标元素 $6$ 为例,二分查找的具体过程如下: -1. **初始化**:首先,确定要查找的有序数据集合。可以是一个数组或列表,确保其中的元素按照升序或者降序排列。 -2. **确定查找范围**:将整个有序数组集合的查找范围确定为整个数组范围区间,即左边界 $left$ 和右边界 $right$。 -3. **计算中间元素**:根据 $mid = \lfloor (left + right) / 2 \rfloor$ 计算出中间元素下标位置 $mid$。 -4. **比较中间元素**:将目标元素 $target$ 与中间元素 $nums[mid]$ 进行比较: - 1. 如果 $target == nums[mid]$,说明找到 $target$,因此返回中间元素的下标位置 $mid$。 - 2. 如果 $target < nums[mid]$,说明目标元素在左半部分($[left, mid - 1]$),更新右边界为中间元素的前一个位置,即 $right = mid - 1$。 - 3. 如果 $target > nums[mid]$,说明目标元素在右半部分($[mid + 1, right]$),更新左边界为中间元素的后一个位置,即 $left = mid + 1$。 +1. **设置查找区间**:左边界 $left = 0$(数组起始位置),右边界 $right = 10$(数组末尾位置),查找区间为 $[0, 10]$。 +2. **第一次取中间元素**:$mid = (0 + 10) \div 2 = 5$,$nums[5] = 5$。 +3. **比较目标值与中间元素**:$6 > 5$,目标值在右半区间,更新左边界 $left = 6$,查找区间变为 $[6, 10]$。 +4. **第二次取中间元素**:$mid = (6 + 10) \div 2 = 8$,$nums[8] = 8$。 +5. **再次比较**:$6 < 8$,目标值在左半区间,更新右边界 $right = 7$,查找区间变为 $[6, 7]$。 +6. **第三次取中间元素**:$mid = (6 + 7) \div 2 = 6$,$nums[6] = 6$。 +7. **找到目标值**:$6 == 6$,查找成功,返回下标 $6$,算法结束。 -5. 重复步骤 $3 \sim 4$,直到找到目标元素时返回中间元素下标位置,或者查找范围缩小为空(左边界大于右边界),表示目标元素不存在,此时返回 $-1$。 +可以看到,对于一个长度为 $10$ 的有序数组,使用二分查找仅需 $3$ 次比较就能定位目标元素;而若采用顺序遍历,最坏情况下则需要 $10$ 次比较才能找到目标。这充分体现了二分查找在有序数据中的高效性。 -举个例子来说,以在有序数组 $[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]$ 中查找目标元素 $6$ 来说,使用二分查找算法的步骤如下: - -1. **确定查找范围**:初始时左边界 $left = 0$(数组的起始位置),$right = 10$(数组的末尾位置)。此时查找范围为 $[0, 10]$。 -2. **计算中间元素**:中间元素下标位置 $mid = (0 + 10) \div 2 = 5$,对应元素为 $nums[5] == 5$。 -3. **比较中间元素**:因为 $6 > nums[5]$,所以目标元素可能在右半部分,更新左边界为中间元素的后一个位置,即 $left = 6$。此时查找范围为 $[6, 10]$。 -4. **计算中间元素**:中间元素下标位置 $mid = (6 + 10) \div 2 = 8$,对应元素为 $nums[8] == 8$。 -5. **比较中间元素**:因为 $6 < nums[8]$,所以目标元素可能在左半部分,更新右边界为中间元素的前一个位置,即 $right = 7$。此时查找范围为 $[6, 7]$。 -6. **计算中间元素**:中间元素下标位置 $mid = (6 + 7) \div 2 = 6$(向下取整),对应元素为 $nums[6] == 6$。 -7. **比较中间元素**:因为 $6 == nums[6]$,正好是我们正在查找的目标元素,此时返回中间元素的下标位置,算法结束。 - -于是我们发现,对于一个长度为 $10$ 的有序数组,我们只进行了 $3$ 次查找就找到了目标元素。而如果是按照顺序依次遍历数组,则在最坏情况下,我们可能需要查找 $10$ 次才能找到目标元素。 +下面展示了二分查找算法在有序数组中查找目标元素 $6$ 的完整过程,详细说明了每一步如何更新查找区间、选择中间元素,并最终定位到目标元素的位置。 ::: tabs#BinarySearch @@ -68,13 +65,13 @@ ::: -### 1.2 二分查找算法思想 +### 1.3 二分查找算法思想 -二分查找算法是经典的 **「减而治之」** 的思想。 +二分查找算法体现了经典的 **「减而治之」** 思想。 -这里的 **「减」** 是减少问题规模的意思,**「治」** 是解决问题的意思。**「减」** 和 **「治」** 结合起来的意思就是 **「排除法解决问题」**。即:**每一次查找,排除掉一定不存在目标元素的区间,在剩下可能存在目标元素的区间中继续查找。** +所谓 **「减」**,就是每一步都通过条件判断,排除掉一部分一定不包含目标元素的区间,从而缩小问题规模;**「治」**,则是在缩小后的区间内继续解决剩下的子问题。也就是说,二分查找的核心在于:**每次查找都排除掉不可能存在目标的区间,仅在可能存在目标的区间内继续查找**。 -每一次通过一些条件判断,将待搜索的区间逐渐缩小,以达到「减少问题规模」的目的。而于问题的规模是有限的,经过有限次的查找,最终会查找到目标元素或者查找失败。 +通过不断缩小查找区间,问题规模逐步减小。由于区间有限,经过有限次迭代,最终要么找到目标元素,要么确定目标不存在于数组中。 ## 2. 简单二分查找 @@ -110,12 +107,12 @@ #### 思路 1:二分查找 -1. 设定左右边界为数组两端,即 $left = 0$,$right = len(nums) - 1$,代表待查找区间为 $[left, right]$(左闭右闭区间)。 -2. 取两个节点中心位置 $mid$,先比较中心位置值 $nums[mid]$ 与目标值 $target$ 的大小。 - 1. 如果 $target == nums[mid]$,则返回中心位置。 - 2. 如果 $target > nums[mid]$,则将左节点设置为 $mid + 1$,然后继续在右区间 $[mid + 1, right]$ 搜索。 - 3. 如果 $target < nums[mid]$,则将右节点设置为 $mid - 1$,然后继续在左区间 $[left, mid - 1]$ 搜索。 -3. 如果左边界大于右边界,查找范围缩小为空,说明目标元素不存在,此时返回 $-1$。 +1. 初始化左右边界,令 $left = 0$,$right = len(nums) - 1$,即查找区间为 $[left, right]$(左闭右闭)。 +2. 在每轮循环中,计算中间位置 $mid$,比较 $nums[mid]$ 与目标值 $target$: + 1. 如果 $nums[mid] == target$,直接返回 $mid$。 + 2. 如果 $nums[mid] < target$,则目标值只可能在右半区间,将 $left$ 更新为 $mid + 1$。 + 3. 如果 $nums[mid] > target$,则目标值只可能在左半区间,将 $right$ 更新为 $mid - 1$。 +3. 当 $left > right$ 时,说明查找区间已为空,目标值不存在,返回 $-1$。 #### 思路 1:代码 @@ -123,21 +120,21 @@ class Solution: def search(self, nums: List[int], target: int) -> int: left, right = 0, len(nums) - 1 - - # 在区间 [left, right] 内查找 target + + # 循环查找区间为 [left, right],直到区间为空 while left <= right: - # 取区间中间节点 + # 计算中间位置,防止溢出可写为 left + (right - left) // 2 mid = (left + right) // 2 - # 如果找到目标值,则直接返回中心位置 + # 命中目标,直接返回下标 if nums[mid] == target: return mid - # 如果 nums[mid] 小于目标值,则在 [mid + 1, right] 中继续搜索 + # 目标在右半区间,收缩左边界 elif nums[mid] < target: left = mid + 1 - # 如果 nums[mid] 大于目标值,则在 [left, mid - 1] 中继续搜索 + # 目标在左半区间,收缩右边界 else: right = mid - 1 - # 未搜索到元素,返回 -1 + # 查找失败,返回 -1 return -1 ``` @@ -148,13 +145,34 @@ class Solution: ## 3. 总结 -二分查找是一种高效的搜索算法,适用于有序数组。它的工作原理是通过不断将搜索范围减半来快速定位目标元素。 +### 3.1 核心要点 + +**二分查找**是一种在**有序数组**中高效查找目标元素的算法,其核心思想是**每次将查找区间缩小一半**,从而快速定位目标位置。 + +### 3.2 算法特点 + +- **时间复杂度**:$O(\log n)$,比线性查找 $O(n)$ 更高效。 +- **空间复杂度**:$O(1)$,只需要常数级别的额外空间。 +- **适用条件**:数据必须是有序的(升序或降序)。 +- **核心思想**:减而治之,每次排除一半不可能的区域。 + +### 3.3 实现要点 + +1. **区间定义**:使用左闭右闭区间 `[left, right]`。 +2. **中间计算**:`mid = (left + right) // 2` 或 `mid = left + (right - left) // 2`(防溢出)。 +3. **边界更新**: + - 目标在右半区间:`left = mid + 1` + - 目标在左半区间:`right = mid - 1` +4. **终止条件**:`left > right` 时查找失败。 -二分查找的步骤包括初始化查找范围、计算中间元素、比较中间元素与目标值,并根据比较结果调整查找范围。如果中间元素等于目标值,返回其位置。如果中间元素小于目标值,搜索右半部分。如果中间元素大于目标值,搜索左半部分。重复这个过程直到找到目标值或确定目标值不存在。 +### 3.4 应用场景 -二分查找的时间复杂度是 $O(\log n)$,比顺序查找的 $O(n)$ 更快。它的空间复杂度是 $O(1)$,因为它不需要额外的存储空间。 +- 在有序数组中查找特定元素 +- 查找插入位置 +- 寻找边界值 +- 数值范围查询 -二分查找的思想是减而治之,通过排除不可能的区域来缩小搜索范围。这种方法在有序数据集中非常有效,可以显著提高搜索效率。 +二分查找是算法学习中的基础算法,掌握其思想和实现对于解决更复杂的查找问题具有重要意义。 ## 练习题目 diff --git a/docs/01_array/01_14_array_binary_search_02.md b/docs/01_array/01_14_array_binary_search_02.md index 2f2cbf79..8bd9338a 100644 --- a/docs/01_array/01_14_array_binary_search_02.md +++ b/docs/01_array/01_14_array_binary_search_02.md @@ -1,39 +1,33 @@ -## 3. 二分查找细节 +## 1. 二分查找细节 -从上篇文章的例子中我们了解了二分查找的思路和具体代码。但是真正在解决二分查找题目的时候还需要考虑更多细节。比如说以下几个问题: +在上一节中,我们已经掌握了二分查找的基本思路和实现代码。然而,在实际解题过程中,二分查找还涉及许多关键细节,常见的有以下几个方面: -1. **区间的开闭问题**:区间应该是左闭右闭区间 $[left, right]$,还是左闭右开区间 $[left, right)$? -2. **$mid$ 的取值问题**:$mid = \lfloor \frac{left + right}{2} \rfloor$,还是 $mid = \lfloor \frac{left + right + 1}{2} \rfloor$? -3. **出界条件的判断**:$left \le right$,还是 $left < right$? -4. **搜索区间范围的选择**:$left = mid + 1$、$right = mid - 1$、 $left = mid$、$right = mid$ 应该怎么写? +1. **区间的开闭选择**:查找区间应采用左闭右闭 $[left, right]$,还是左闭右开 $[left, right)$? +2. **$mid$ 的计算方式**:是 $mid = \lfloor \frac{left + right}{2} \rfloor$,还是 $mid = \lfloor \frac{left + right + 1}{2} \rfloor$? +3. **循环终止条件**:应使用 $left \le right$ 还是 $left < right$? +4. **区间收缩方式**:如 $left = mid + 1$、$right = mid - 1$、$left = mid$、$right = mid$ 等,应该如何选择? -下面依次进行讲解。 +接下来将针对这些细节逐一分析说明。 -### 3.1 区间的开闭问题 +## 2. 区间的开闭问题 -左闭右闭区间、左闭右开区间指的是初始待查找区间的范围。 +在二分查找中,区间的开闭方式决定了查找范围的边界取值。常见的有两种: -- **左闭右闭区间**:初始化时,$left = 0$,$right = len(nums) - 1$。 - - $left$ 为数组第一个元素位置,$right$ 为数组最后一个元素位置。 - - 区间 $[left, right]$ 左右边界上的点都能取到。 +- **左闭右闭区间 $[left, right]$**:初始化时,$left = 0$,$right = len(nums) - 1$。此时 $left$ 和 $right$ 都指向有效元素,区间两端的元素都包含在查找范围内。 +- **左闭右开区间 $[left, right)$**:初始化时,$left = 0$,$right = len(nums)$。$left$ 指向第一个元素,$right$ 指向最后一个元素的下一个位置,查找范围包含左端点但不包含右端点。 -- **左闭右开区间**:初始化时,$left = 0$,$right = len(nums)$。 - - $left$ 为数组第一个元素位置,$right$ 为数组最后一个元素的下一个位置。 - - 区间 $[left, right)$ 左边界点能取到,而右边界上的点不能取到。 +虽然两种区间写法都可以实现二分查找,但在实际编码和边界处理时,左闭右开区间往往更容易出错,需要额外关注边界条件,逻辑也更复杂。因此,**强烈推荐统一采用「左闭右闭区间」的写法**,这样更易于理解和维护,出错概率更低。 +## 3. $mid$ 的取值问题 -关于二分查找算法的左闭右闭区间、左闭右开区间,其实在网上都有对应的代码。但是相对来说,左闭右开区间这种写法在解决问题的过程中,会使得问题变得复杂,需要考虑的情况更多,所以不建议使用左闭右开区间这种写法,而是建议:**全部使用「左闭右闭区间」这种写法**。 +在实际应用二分查找时,$mid$ 的取值通常有两种常见写法: -### 3.2 $mid$ 的取值问题 +1. `mid = (left + right) // 2` +2. `mid = (left + right + 1) // 2` -在二分查找的实际问题中,最常见的 $mid$ 取值公式有两个: +这里的 `//` 表示向下取整。若当前查找区间元素个数为奇数,这两种写法都会得到区间正中间的下标。 -1. `mid = (left + right) // 2`。 -2. `mid = (left + right + 1) // 2 `。 - -式子中 `//` 所代表的含义是「中间数向下取整」。当待查找区间中的元素个数为奇数个,使用这两种取值公式都能取到中间元素的下标位置。 - -而当待查找区间中的元素个数为偶数时,使用 `mid = (left + right) // 2` 式子我们能取到中间靠左边元素的下标位置,使用 `mid = (left + right + 1) // 2` 式子我们能取到中间靠右边元素的下标位置。 +当区间元素个数为偶数时,`mid = (left + right) // 2` 会取到中间偏左的下标,而 `mid = (left + right + 1) // 2` 则会取到中间偏右的下标。 ::: tabs#mid @@ -47,88 +41,81 @@ ::: -把这两个公式分别代入到 [704. 二分查找](https://leetcode.cn/problems/binary-search/) 的代码中试一试,发现都能通过题目评测。这是为什么呢? - -因为二分查找算法的思路是:根据每次选择中间位置上的数值来决定下一次在哪个区间查找元素。每一次选择的元素位置可以是中间位置,但并不是一定非得是区间中间位置元素,靠左一些、靠右一些、甚至区间三分之一、五分之一处等等,都是可以的。比如说 `mid = (left + right) * 1 // 5` 也是可以的。 +将这两个公式分别应用到 [704. 二分查找](https://leetcode.cn/problems/binary-search/) 的代码中,会发现它们都能通过题目测试。这是为什么? -但一般来说,取区间中间位置在平均意义下所达到的效果最好。同时这样写最简单。而对于这两个取值公式,大多数时候是选择第一个公式。不过,有些情况下,是需要考虑第二个公式的,我们会在「4.2 排除法」中进行讲解。 +原因在于,二分查找的核心思想是:每次根据中间元素的值,决定下一步在哪个区间继续查找。实际上,中间元素的位置不必严格取区间正中,偏左、偏右,甚至取区间的三分之一、五分之一等位置都可以,例如 `mid = (left + right) * 1 // 5` 也是可行的。 -除了上面提到的这两种写法,我们还经常能看到下面两个公式: +不过,通常取区间中点能在平均意义下获得最优效率,且实现最为简洁。因此,实际编码时大多数情况下会选择第一个公式。但在某些特定场景下,需要用到第二个公式,具体会在「5.2 排除法」部分详细说明。 -1. `mid = left + (right - left) // 2`。 -2. `mid = left + (right - left + 1) // 2`。 +除了上述两种写法,我们还常见如下两种等价公式: -这两个公式其实分别等同于之前两个公式,可以看做是之前两个公式的另一种写法。这种写法能够防止整型溢出问题(Python 语言中整型不会溢出,其他语言可能会有整型溢出问题)。 +1. `mid = left + (right - left) // 2` +2. `mid = left + (right - left + 1) // 2` -在 $left + right$ 的数据量不会超过整型变量最大值时,这两种写法都没有问题。在 $left + right$ 的数据量可能会超过整型变量最大值时,最好使用第二种写法。所以,为了统一和简化二分查找算法的写法,建议统一写成第二种写法: +这两种写法本质上与前面的公式等价,只是通过减法避免了整型溢出的问题(虽然 Python 不会溢出,但其他语言可能会)。当 $left + right$ 不会超过整型最大值时,哪种写法都可以;但如果有溢出风险,推荐使用后一种写法。 -1. `mid = left + (right - left) // 2`。 -2. `mid = left + (right - left + 1) // 2`。 +因此,为了统一和简化二分查找的实现,建议采用如下写法: -### 3.3 出界条件的判断 +1. `mid = left + (right - left) // 2` +2. `mid = left + (right - left + 1) // 2` -二分查找算法的写法中,`while` 语句出界判断条件通常有两种: +## 4. 出界条件的判断 -1. `left <= right`。 -2. `left < right`。 +在二分查找的实现中,`while` 循环的边界判断主要有两种常见写法: -我们究竟应该使用哪一种写法呢? +1. `left <= right` +2. `left < right` -我们先来判断一下导致 `while` 语句出界的条件是什么。 +那么,实际编码时应如何选择呢?我们可以从循环终止的条件来分析: -1. 如果判断语句为 `left <= right`,并且查找的元素不在有序数组中,则 `while` 语句的出界条件是 `left > right`,也就是 `left == right + 1`,写成区间形式就是 $[right + 1, right]$,此时待查找区间为空,待查找区间中没有元素存在,此时终止循环时,可以直接返回 $-1$。 - - 比如说区间 $[3, 2]$, 此时左边界大于右边界,直接终止循环,返回 $-1$ 即可。 -2. 如果判断语句为`left < right`,并且查找的元素不在有序数组中,则 `while` 语句出界条件是 `left == right`,写成区间形式就是 $[right, right]$。此时区间不为空,待查找区间还有一个元素存在,我们并不能确定查找的元素不在这个区间中,此时终止循环时,如果直接返回 $-1$ 就是错误的。 - - 比如说区间 $[2, 2]$,如果元素 $nums[2]$ 刚好就是目标元素 $target$,此时终止循环,返回 $-1$ 就漏掉了这个元素。 +- 当使用 `left <= right` 作为循环条件时,如果目标元素不存在,循环会在 `left > right` 时终止,即 $[right + 1, right]$,此时查找区间已为空,无需再判断,直接返回 $-1$ 即可。例如区间 $[3, 2]$,左边界大于右边界,查找结束。 +- 当使用 `left < right` 作为循环条件时,若目标元素不存在,循环会在 `left == right` 时终止,即 $[right, right]$,此时区间内还剩下一个元素。此时不能直接返回 $-1$,因为最后一个元素可能就是目标值。例如区间 $[2, 2]$,$nums[2]$ 可能等于 $target$,直接返回 $-1$ 会遗漏正确答案。 -但是如果我们还是想要使用 `left < right` 的话,怎么办? - -可以在出界之后增加一层判断,判断 $left$ 所指向位置是否等于目标元素,如果是的话就返回 $left$,如果不是的话返回 $-1$。即: +如果选择 `left < right`,则需要在循环结束后额外判断 $nums[left]$ 是否等于目标值: ```python -# ... + # ... while left < right: # ... return left if nums[left] == target else -1 ``` -此外,`while` 判断语句用 `left < right` 有一个好处,就是在跳出循环的时候,一定是 `left == right`,我们就不用判断此时应该返回 $left$ 还是 $right$ 了。 +另外,采用 `while left < right` 作为循环条件的一个优点是,循环结束时必然有 `left == right`,此时只需判断一个位置,无需区分返回 $left$ 还是 $right$,简化了后续处理。 -### 3.4 搜索区间范围的选择 -在进行区间范围选择的时候,通常有三种写法: +## 5. 搜索区间范围的选择 -1. `left = mid + 1`,`right = mid - 1`。 -2. `left = mid + 1 `,`right = mid`。 -3. `left = mid`,`right = mid - 1`。 +在选择二分查找的区间更新方式时,常见有三种写法: -我们到底应该如何确定搜索区间范围呢? +1. `left = mid + 1`,`right = mid - 1` +2. `left = mid + 1`,`right = mid` +3. `left = mid`,`right = mid - 1` -这是二分查找的一个难点,写错了很容易造成死循环,或者得不到正确结果。 +那么,究竟该如何确定具体的区间更新方式呢? -这其实跟二分查找算法的两种不同思路和三种写法有关。 +这正是二分查找中最容易出错的地方,区间更新不当容易导致死循环或结果错误。 -- 思路 1:「直接法」—— 在循环体中找到元素后直接返回结果。 -- 思路 2:「排除法」—— 在循环体中排除目标元素一定不存在区间。 +本质上,这与二分查找的两种核心思路和三种区间写法密切相关: -接下来我们具体讲解下这两种思路。 +- 思路一:「直接法」—— 在循环体内一旦找到目标元素立即返回。 +- 思路二:「排除法」—— 每次循环排除目标元素一定不存在的区间。 -## 4. 二分查找两种思路 +下面我们将详细介绍这两种思路的具体实现和适用场景。 -### 4.1 直接法 +## 5.1 直接法思路 -> **直接法思想**:一旦我们在循环体中找到元素就直接返回结果。 +> **直接法思想**:在循环过程中,一旦找到目标元素,立即返回其下标。 -这种思路比较简单,其实我们在上篇 「2. 简单二分查找 - [704. 二分查找](https://leetcode.cn/problems/binary-search/)」 中就已经用过了。这里再看一下思路和代码: +这种方法实现简单,实际上我们在前文「1.13 二分查找(一)- 2. 简单二分查找」中已经用过。下面简要回顾其核心思路与代码: #### 思路 1:直接法 -1. 设定左右边界为数组两端,即 $left = 0$,$right = len(nums) - 1$,代表待查找区间为 $[left, right]$(左闭右闭区间)。 -2. 取两个节点中心位置 $mid$,先比较中心位置值 $nums[mid]$ 与目标值 $target$ 的大小。 - 1. 如果 $target == nums[mid]$,则返回中心位置。 - 2. 如果 $target > nums[mid]$,则将左节点设置为 $mid + 1$,然后继续在右区间 $[mid + 1, right]$ 搜索。 - 3. 如果 $target < nums[mid]$,则将右节点设置为 $mid - 1$,然后继续在左区间 $[left, mid - 1]$ 搜索。 -3. 如果左边界大于右边界,查找范围缩小为空,说明目标元素不存在,此时返回 $-1$。 +1. 初始化左右边界,令 $left = 0$,$right = len(nums) - 1$,即查找区间为 $[left, right]$(左闭右闭)。 +2. 在每轮循环中,计算中间位置 $mid$,比较 $nums[mid]$ 与目标值 $target$: + 1. 如果 $nums[mid] == target$,直接返回 $mid$。 + 2. 如果 $nums[mid] < target$,则目标值只可能在右半区间,将 $left$ 更新为 $mid + 1$。 + 3. 如果 $nums[mid] > target$,则目标值只可能在左半区间,将 $right$ 更新为 $mid - 1$。 +3. 当 $left > right$ 时,说明查找区间已为空,目标值不存在,返回 $-1$。 #### 思路 1:代码 @@ -136,21 +123,21 @@ class Solution: def search(self, nums: List[int], target: int) -> int: left, right = 0, len(nums) - 1 - - # 在区间 [left, right] 内查找 target + + # 循环查找区间为 [left, right],直到区间为空 while left <= right: - # 取区间中间节点 - mid = left + (right - left) // 2 - # 如果找到目标值,则直接范围中心位置 + # 计算中间位置,防止溢出可写为 left + (right - left) // 2 + mid = (left + right) // 2 + # 命中目标,直接返回下标 if nums[mid] == target: return mid - # 如果 nums[mid] 小于目标值,则在 [mid + 1, right] 中继续搜索 + # 目标在右半区间,收缩左边界 elif nums[mid] < target: left = mid + 1 - # 如果 nums[mid] 大于目标值,则在 [left, mid - 1] 中继续搜索 + # 目标在左半区间,收缩右边界 else: right = mid - 1 - # 未搜索到元素,返回 -1 + # 查找失败,返回 -1 return -1 ``` @@ -160,18 +147,18 @@ class Solution: - 循环可以继续的条件是 `left <= right`。 - 如果一旦退出循环,则说明这个区间内一定不存在目标元素。 -### 4.2 排除法 +### 5.2 排除法 思路 -> **排除法思想**:在循环体中排除目标元素一定不存在区间。 +> **排除法思想**:每轮循环都优先排除掉一定不包含目标元素的区间,仅在可能存在目标的区间内继续查找。 #### 思路 2:排除法 -1. 设定左右边界为数组两端,即 $left = 0$,$right = len(nums) - 1$,代表待查找区间为 $[left, right]$(左闭右闭区间)。 -2. 取两个节点中心位置 $mid$,比较目标元素和中间元素的大小,先将目标元素一定不存在的区间排除。 -3. 然后在剩余区间继续查找元素,继续根据条件排除目标元素一定不存在的区间。 -4. 直到区间中只剩下最后一个元素,然后再判断这个元素是否是目标元素。 +1. 初始化左右边界 $left = 0$,$right = len(nums) - 1$,查找区间为 $[left, right]$(左闭右闭)。 +2. 每次计算中间位置 $mid$,比较 $nums[mid]$ 与 $target$,优先排除掉目标元素一定不存在的区间。 +3. 在剩余的区间内继续查找,重复上述过程。 +4. 当区间收缩到只剩一个元素时,判断该元素是否为目标值。 -根据排除法的思路,我们可以写出来两种代码。 +基于排除法,可以实现两种常见写法: #### 思路 2:代码 1 @@ -179,18 +166,18 @@ class Solution: class Solution: def search(self, nums: List[int], target: int) -> int: left, right = 0, len(nums) - 1 - - # 在区间 [left, right] 内查找 target + + # 在闭区间 [left, right] 内查找 target while left < right: - # 取区间中间节点 + # 计算中间位置,向下取整 mid = left + (right - left) // 2 - # nums[mid] 小于目标值,排除掉不可能区间 [left, mid],在 [mid + 1, right] 中继续搜索 + # 若 nums[mid] 小于目标值,排除 [left, mid] 区间,继续在 [mid + 1, right] 查找 if nums[mid] < target: - left = mid + 1 - # nums[mid] 大于等于目标值,目标元素可能在 [left, mid] 中,在 [left, mid] 中继续搜索 + left = mid + 1 + # 否则目标值可能在 [left, mid] 区间,收缩右边界 else: right = mid - # 判断区间剩余元素是否为目标元素,不是则返回 -1 + # 循环结束后,left == right,判断该位置是否为目标值 return left if nums[left] == target else -1 ``` @@ -200,34 +187,33 @@ class Solution: class Solution: def search(self, nums: List[int], target: int) -> int: left, right = 0, len(nums) - 1 - - # 在区间 [left, right] 内查找 target + + # 在闭区间 [left, right] 内查找 target while left < right: - # 取区间中间节点 + # 计算中间位置,向上取整,防止死循环 mid = left + (right - left + 1) // 2 - # nums[mid] 大于目标值,排除掉不可能区间 [mid, right],在 [left, mid - 1] 中继续搜索 + # 如果 nums[mid] > target,说明目标只可能在 [left, mid - 1] 区间 if nums[mid] > target: - right = mid - 1 - # nums[mid] 小于等于目标值,目标元素可能在 [mid, right] 中,在 [mid, right] 中继续搜索 + right = mid - 1 + # 否则,目标在 [mid, right] 区间(包括 mid) else: left = mid - # 判断区间剩余元素是否为目标元素,不是则返回 -1 + # 循环结束后,left == right,判断该位置是否为目标值 return left if nums[left] == target else -1 ``` #### 思路 2:细节 -- 判断语句是 `left < right`。这样在退出循环时,一定有`left == right` 成立,就不用判断应该返回 $left$ 还是 $right$ 了。此时只需要判断 $nums[left]$ 是否为目标元素即可。 -- 在循环体中,比较目标元素和中间元素的大小之后,优先将目标元素一定不存在的区间排除,然后再从剩余区间中确定下一次查找区间的范围。 -- 在将目标元素一定不存在的区间排除之后,它的对立面(即 `else` 部分)一般就不需要再考虑区间范围了,直接取上一个区间的相反区间。如果上一个区间是 $[mid + 1, right]$,那么相反区间就是 $[left, mid]$。如果上一个区间是 $[left, mid - 1]$,那么相反区间就是 $[mid, right]$。 -- 为了避免陷入死循环,当区分被划分为 $[left, mid - 1]$ 与 $[mid, right]$ 两部分时,**$mid$ 取值要向上取整**。即 `mid = left + (right - left + 1) // 2`。因为如果当区间中只剩下两个元素时(此时 `right = left + 1`),一旦进入 `left = mid` 分支,区间就不会再缩小了,下一次循环的查找区间还是 $[left, right]$,就陷入了死循环。 - - 比如左边界 $left = 5$,右边界 $right = 6$,此时查找区间为 $[5, 6]$,$mid = 5 + (6 - 5) // 2 = 5$,如果进入 $left = mid$ 分支,那么下次查找区间仍为 $[5, 6]$,区间不再缩小,陷入死循环。 - - 这种情况下,$mid$ 应该向上取整,$mid = 5 + (6 - 5 + 1) // 2 = 6$,如果进入 $left = mid$ 分支,则下次查找区间为 $[6, 6]$。 - +- 循环条件采用 `left < right`,这样循环结束时必然有 `left == right`,无需再区分返回 $left$ 还是 $right$,只需判断 $nums[left]$ 是否为目标值即可。 +- 在循环体内,先比较目标值与中间元素的大小,优先排除目标值不可能存在的区间,然后在剩余区间继续查找。 +- 排除目标值不可能存在的区间后,`else` 分支通常直接取剩余的另一半区间,无需额外判断。例如,若排除 $[left, mid]$,则剩余区间为 $[mid + 1, right]$;若排除 $[mid, right]$,则剩余区间为 $[left, mid-1]$。 +- 为避免死循环,当区间被划分为 $[left, mid-1]$ 和 $[mid, right]$ 时,**$mid$ 需要向上取整**,即 `mid = left + (right - left + 1) // 2`。因为当区间只剩两个元素($right = left + 1$)时,若 $mid$ 向下取整,`left = mid` 会导致区间不变,陷入死循环。 + - 例如 $left = 5$,$right = 6$,若 $mid = 5$,执行 $left = mid$ 后区间仍为 $[5, 6]$,无法收缩,导致死循环。 + - 若 $mid$ 向上取整,$mid = 6$,执行 $left = mid$ 后区间变为 $[6, 6]$,循环得以终止。 -- 关于边界设置可以记忆为:只要看到 `left = mid` 就向上取整。或者记为: - - `left = mid + 1`、`right = mid` 和 `mid = left + (right - left) // 2` 一定是配对出现的。 - - `right = mid - 1`、`left = mid` 和 `mid = left + (right - left + 1) // 2` 一定是配对出现的。 +- 边界设置可记忆为:只要出现 `left = mid`,就要让 $mid$ 向上取整。具体配对如下: + - `left = mid + 1`、`right = mid` 搭配 `mid = left + (right - left) // 2`。 + - `right = mid - 1`、`left = mid` 搭配 `mid = left + (right - left + 1) // 2`。 ### 4.3 两种思路适用范围 From ebf52e932b9c38fcdbc4c8435cc7151c0ec6d525 Mon Sep 17 00:00:00 2001 From: ITCharge Date: Tue, 19 Aug 2025 16:43:18 +0800 Subject: [PATCH 03/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=92=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=AF=AD=E5=8F=A5=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/01_array/01_02_array_sort.md | 3 ++- docs/01_array/01_13_array_binary_search_01.md | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/01_array/01_02_array_sort.md b/docs/01_array/01_02_array_sort.md index f235e715..ffbf6d1b 100644 --- a/docs/01_array/01_02_array_sort.md +++ b/docs/01_array/01_02_array_sort.md @@ -68,4 +68,5 @@ ## 5. 总结 -选择合适的排序算法需要综合考虑数据特征、环境约束和性能要求。在实际开发中,大多数编程语言的标准库都提供了经过优化的排序函数,这些函数通常结合了多种排序算法的优点,能够适应不同的数据特征。 +排序算法的核心目标是将数据按指定顺序排列。常见排序算法各有优缺点,选择时需结合数据规模、数据特性和实际需求。一般来说,小规模数据可用插入、冒泡等简单算法;大规模或高性能场景优先考虑快速排序、归并排序等高效算法。若有稳定性或空间限制等特殊要求,应优先选择满足条件的算法。理解各种排序的原理和适用场景,有助于在实际开发中做出最优选择。 + diff --git a/docs/01_array/01_13_array_binary_search_01.md b/docs/01_array/01_13_array_binary_search_01.md index e2c666cc..bd3f14e2 100644 --- a/docs/01_array/01_13_array_binary_search_01.md +++ b/docs/01_array/01_13_array_binary_search_01.md @@ -147,7 +147,7 @@ class Solution: ### 3.1 核心要点 -**二分查找**是一种在**有序数组**中高效查找目标元素的算法,其核心思想是**每次将查找区间缩小一半**,从而快速定位目标位置。 +**二分查找** 是一种在 **有序数组** 中高效查找目标元素的算法,其核心思想是 **每次将查找区间缩小一半**,从而快速定位目标位置。 ### 3.2 算法特点 From 3af4e7500ffed7e205a8f7c7815fab0e684c15c6 Mon Sep 17 00:00:00 2001 From: ITCharge Date: Thu, 21 Aug 2025 16:48:15 +0800 Subject: [PATCH 04/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=92=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=AF=AD=E5=8F=A5=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/01_array/01_04_array_selection_sort.md | 8 ++++---- docs/01_array/01_05_array_insertion_sort.md | 4 ++-- docs/01_array/01_08_array_quick_sort.md | 2 +- docs/01_array/01_12_array_radix_sort.md | 2 +- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/01_array/01_04_array_selection_sort.md b/docs/01_array/01_04_array_selection_sort.md index 0bc0c771..f4e93e42 100644 --- a/docs/01_array/01_04_array_selection_sort.md +++ b/docs/01_array/01_04_array_selection_sort.md @@ -11,10 +11,10 @@ 假设数组长度为 $n$,选择排序的算法步骤如下: 1. **初始状态**:已排序区间为空,未排序区间为 $[0, n - 1]$。 -2. **第 $i$ 趟选择**($i$ 从 $0$ 开始): - 1. 在未排序区间 $[i, n - 1]$ 中找到最小元素的位置 $min\underline{\hspace{0.5em}}i$。 - 2. 将位置 $i$ 的元素与位置 $min\_i$ 的元素交换。 - 3. 此时 $[0, i]$ 为已排序区间,$[i + 1, n - 1]$ 为未排序区间。 +2. **第 $i$ 趟选择**($i$ 从 $1$ 开始): + 1. 在未排序区间 $[i - 1, n - 1]$ 中找到最小元素的位置 $min\underline{\hspace{0.5em}}i$。 + 2. 将位置 $i - 1$ 的元素与位置 $min\_i$ 的元素交换。 + 3. 此时 $[0, i - 1]$ 为已排序区间,$[i, n - 1]$ 为未排序区间。 3. **重复步骤 2**,直到未排序区间为空,排序完成。 以数组 $[5, 2, 3, 6, 1, 4]$ 为例,演示一下选择排序的算法步骤。 diff --git a/docs/01_array/01_05_array_insertion_sort.md b/docs/01_array/01_05_array_insertion_sort.md index dae0c2b2..7df065c4 100644 --- a/docs/01_array/01_05_array_insertion_sort.md +++ b/docs/01_array/01_05_array_insertion_sort.md @@ -11,11 +11,11 @@ 假设数组长度为 $n$,算法步骤如下: 1. **初始化**:有序区间为 $[0, 0]$,无序区间为 $[1, n - 1]$ -2. **第 $i$ 趟插入**($i$ 从 $1$ 到 $n-1$): +2. **第 $i$ 趟插入**($i$ 从 $1$ 到 $n - 1$): - 取出无序区间第一个元素 $nums[i]$ - 从右到左遍历有序区间,将大于 $nums[i]$ 的元素右移一位 - 找到合适位置后插入 $nums[i]$ - - 有序区间扩展为 $[0, i]$,无序区间变为 $[i+1, n-1]$ + - 有序区间扩展为 $[0, i]$,无序区间变为 $[i + 1, n - 1]$ 以数组 $[5, 2, 3, 6, 1, 4]$ 为例,演示一下插入排序的算法步骤。 diff --git a/docs/01_array/01_08_array_quick_sort.md b/docs/01_array/01_08_array_quick_sort.md index 9d4a2cc2..5aca5f6d 100644 --- a/docs/01_array/01_08_array_quick_sort.md +++ b/docs/01_array/01_08_array_quick_sort.md @@ -6,7 +6,7 @@ ## 2. 快速排序算法步骤 -快速排序的核心是**分区操作**,具体步骤如下: +快速排序的核心是 **分区操作**,具体步骤如下: 1. **选择基准**:从数组中选择一个元素作为基准值(通常选择第一个元素) 2. **分区操作**: diff --git a/docs/01_array/01_12_array_radix_sort.md b/docs/01_array/01_12_array_radix_sort.md index 3ad10e3d..a003ab51 100644 --- a/docs/01_array/01_12_array_radix_sort.md +++ b/docs/01_array/01_12_array_radix_sort.md @@ -18,7 +18,7 @@ 我们以 $[692, 924, 969, 503, 871, 704, 542, 436]$ 为例,演示一下基数排序的算法步骤。 -![基数排序算法步骤](https://qcdn.itcharge.cn/images/20230822171758.png) +![基数排序的算法步骤](https://qcdn.itcharge.cn/images/20230822171758.png) ## 3. 基数排序代码实现 From 77c511a6a0db830e8a712d0404de57e14191dda0 Mon Sep 17 00:00:00 2001 From: ITCharge Date: Thu, 21 Aug 2025 17:42:15 +0800 Subject: [PATCH 05/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=92=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=AF=AD=E5=8F=A5=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/01_array/01_15_array_two_pointers.md | 338 ++++++--------- docs/01_array/01_16_array_sliding_window.md | 190 ++++----- .../02_linked_list/02_01_linked_list_basic.md | 391 ++++++++---------- docs/02_linked_list/02_02_linked_list_sort.md | 56 +-- .../02_03_linked_list_bubble_sort.md | 84 ++-- .../02_04_linked_list_selection_sort.md | 71 +++- .../02_05_linked_list_insertion_sort.md | 88 +++- .../02_06_linked_list_merge_sort.md | 51 ++- .../02_07_linked_list_quick_sort.md | 76 +++- .../02_08_linked_list_counting_sort.md | 111 +++-- .../02_09_linked_list_bucket_sort.md | 142 +++++-- .../02_10_linked_list_radix_sort.md | 58 ++- .../02_11_linked_list_two_pointers.md | 304 ++++++-------- 13 files changed, 1060 insertions(+), 900 deletions(-) diff --git a/docs/01_array/01_15_array_two_pointers.md b/docs/01_array/01_15_array_two_pointers.md index af319093..7e46ab67 100644 --- a/docs/01_array/01_15_array_two_pointers.md +++ b/docs/01_array/01_15_array_two_pointers.md @@ -1,47 +1,53 @@ ## 1. 双指针简介 -> **双指针(Two Pointers)**:指的是在遍历元素的过程中,不是使用单个指针进行访问,而是使用两个指针进行访问,从而达到相应的目的。如果两个指针方向相反,则称为「对撞指针」。如果两个指针方向相同,则称为「快慢指针」。如果两个指针分别属于不同的数组 / 链表,则称为「分离双指针」。 +> **双指针(Two Pointers)**:在遍历序列时,同时用两个指针协同访问元素,以高效解决问题。常见类型有三种:同序列相向移动的「对撞指针」、同序列同向移动的「快慢指针」、以及分别指向不同序列的「分离双指针」。 -在数组的区间问题上,暴力算法的时间复杂度往往是 $O(n^2)$。而双指针利用了区间「单调性」的性质,可以将时间复杂度降到 $O(n)$。 +在处理数组区间类问题时,传统的暴力解法时间复杂度通常为 $O(n^2)$,而双指针方法能够利用区间的「单调性」特征,将时间复杂度优化至 $O(n)$。 ## 2. 对撞指针 -> **对撞指针**:指的是两个指针 $left$、$right$ 分别指向序列第一个元素和最后一个元素,然后 $left$ 指针不断递增,$right$ 不断递减,直到两个指针的值相撞(即 $left == right$),或者满足其他要求的特殊条件为止。 +> **对撞指针**:即用两个指针 $left$ 和 $right$,分别指向序列的首尾,$left$ 向右、$right$ 向左移动,直到两指针相遇($left == right$)或满足特定条件。 ![对撞指针](https://qcdn.itcharge.cn/images/202405092155032.png) ### 2.1 对撞指针求解步骤 -1. 使用两个指针 $left$,$right$。$left$ 指向序列第一个元素,即:$left = 0$,$right$ 指向序列最后一个元素,即:$right = len(nums) - 1$。 -2. 在循环体中将左右指针相向移动,当满足一定条件时,将左指针右移,$left += 1$。当满足另外一定条件时,将右指针左移,$right -= 1$。 -3. 直到两指针相撞(即 $left == right$),或者满足其他要求的特殊条件时,跳出循环体。 +1. 初始化两个指针 $left = 0$,$right = len(nums) - 1$。 +2. 循环中根据条件移动指针:若满足某条件,$left$ 右移;若满足另一条件,$right$ 左移。 +3. 循环至两指针相遇或满足终止条件。 -### 2.2 对撞指针伪代码模板 +### 2.2 对撞指针通用模板 ```python +# 初始化左右指针,分别指向数组的首尾 left, right = 0, len(nums) - 1 +# 当左指针小于右指针时循环 while left < right: + # 如果满足题目要求的特殊条件,直接返回结果 if 满足要求的特殊条件: return 符合条件的值 + # 如果满足某一条件,左指针右移,缩小区间 elif 一定条件 1: left += 1 + # 如果满足另一条件,右指针左移,缩小区间 elif 一定条件 2: right -= 1 +# 如果循环结束还未找到,返回未找到或对应值 return 没找到 或 找到对应值 ``` -### 2.3 对撞指针适用范围 +### 2.3 对撞指针适用场景 -对撞指针一般用来解决有序数组或者字符串问题: +对撞指针常用于有序数组或字符串,典型应用包括: -- 查找有序数组中满足某些约束条件的一组元素问题:比如二分查找、数字之和等问题。 -- 字符串反转问题:反转字符串、回文数、颠倒二进制等问题。 +- 查找有序数组中特定元素组合,如二分查找、两数之和等。 +- 字符串或数组反转,如反转字符串、判断回文、颠倒二进制等。 -下面我们根据具体例子来讲解如何使用对撞指针来解决问题。 +下面通过具体例子演示对撞指针的用法。 -### 2.4 两数之和 II - 输入有序数组 +### 2.4 经典例题:两数之和 II - 输入有序数组 #### 2.4.1 题目链接 @@ -81,7 +87,16 @@ return 没找到 或 找到对应值 #### 2.4.3 解题思路 -这道题如果暴力遍历数组,从中找到相加之和等于 $target$ 的两个数,时间复杂度为 $O(n^2)$,可以尝试一下。 +##### 思路 1:暴力枚举 + +可以直接使用两重循环,枚举所有可能的两数组合,判断其和是否等于目标值 $target$。具体做法如下: + +1. 外层循环遍历数组的每一个元素 $i$。 +2. 内层循环遍历 $i$ 之后的每一个元素 $j$。 +3. 判断 $numbers[i] + numbers[j]$ 是否等于 $target$,如果相等则返回 $[i + 1, j + 1]$(题目下标从 1 开始)。 +4. 如果遍历结束仍未找到,返回 $[-1, -1]$。 + +##### 思路 1:代码 ```python class Solution: @@ -94,9 +109,13 @@ class Solution: return [-1, -1] ``` -结果不出意外的超时了。所以我们要想办法减少时间复杂度。 +##### 思路 1:复杂度分析 + +- **时间复杂度**:$O(n^2)$。外层循环 $O(n)$,内层循环最坏情况下 $O(n)$,因此总时间复杂度为 $O(n^2)$。 +- **空间复杂度**:$O(1)$。只使用了常数级别的额外空间。 -##### 思路 1:对撞指针 + +##### 思路 2:对撞指针 可以考虑使用对撞指针来减少时间复杂度。具体做法如下: @@ -108,7 +127,7 @@ class Solution: 3. 直到 $left$ 和 $right$ 移动到相同位置停止检测。 4. 如果最终仍没找到,则返回 $[-1, -1]$。 -##### 思路 1:代码 +##### 思路 2:代码 ```python class Solution: @@ -126,179 +145,48 @@ class Solution: return [-1, -1] ``` -##### 思路 1:复杂度分析 +##### 思路 2:复杂度分析 - **时间复杂度**:$O(n)$。 - **空间复杂度**:$O(1)$。只用到了常数空间存放若干变量。 -### 2.5 验证回文串 - -#### 2.5.1 题目链接 - -- [125. 验证回文串 - 力扣(LeetCode)](https://leetcode.cn/problems/valid-palindrome/) - -#### 2.5.2 题目大意 - -**描述**:给定一个字符串 $s$。 - -**要求**:判断是否为回文串(只考虑字符串中的字母和数字字符,并且忽略字母的大小写)。 - -**说明**: - -- 回文串:正着读和反着读都一样的字符串。 -- $1 \le s.length \le 2 * 10^5$。 -- $s$ 仅由可打印的 ASCII 字符组成。 - -**示例**: - -```python -输入: "A man, a plan, a canal: Panama" -输出:true -解释:"amanaplanacanalpanama" 是回文串。 - - -输入:"race a car" -输出:false -解释:"raceacar" 不是回文串。 -``` - -#### 2.5.3 解题思路 - -##### 思路 1:对撞指针 - -1. 使用两个指针 $left$,$right$。$left$ 指向字符串开始位置,$right$ 指向字符串结束位置。 -2. 判断两个指针对应字符是否是字母或数字。 通过 $left$ 右移、$right$ 左移的方式过滤掉字母和数字以外的字符。 -3. 然后判断 $s[start]$ 是否和 $s[end]$ 相等(注意大小写)。 - 1. 如果相等,则将 $left$ 右移、$right$ 左移,继续进行下一次过滤和判断。 - 2. 如果不相等,则说明不是回文串,直接返回 $False$。 -4. 如果遇到 $left == right$,跳出循环,则说明该字符串是回文串,返回 $True$。 - -##### 思路 1:代码 - -```python -class Solution: - def isPalindrome(self, s: str) -> bool: - left = 0 - right = len(s) - 1 - - while left < right: - if not s[left].isalnum(): - left += 1 - continue - if not s[right].isalnum(): - right -= 1 - continue - - if s[left].lower() == s[right].lower(): - left += 1 - right -= 1 - else: - return False - return True -``` - -##### 思路 1:复杂度分析 - -- **时间复杂度**:$O(len(s))$。 -- **空间复杂度**:$O(len(s))$。 - -### 2.6 盛最多水的容器 - -#### 2.6.1 题目链接 - -- [11. 盛最多水的容器 - 力扣(LeetCode)](https://leetcode.cn/problems/container-with-most-water/) - -#### 2.6.2 题目大意 - -**描述**:给定 $n$ 个非负整数 $a_1,a_2, ...,a_n$,每个数代表坐标中的一个点 $(i, a_i)$。在坐标内画 $n$ 条垂直线,垂直线 $i$ 的两个端点分别为 $(i, a_i)$ 和 $(i, 0)$。 - -**要求**:找出其中的两条线,使得它们与 $x$ 轴共同构成的容器可以容纳最多的水。 - -**说明**: - -- $n == height.length$。 -- $2 \le n \le 10^5$。 -- $0 \le height[i] \le 10^4$。 - -**示例**: - -![](https://aliyun-lc-upload.oss-cn-hangzhou.aliyuncs.com/aliyun-lc-upload/uploads/2018/07/25/question_11.jpg) - -```python -输入:[1,8,6,2,5,4,8,3,7] -输出:49 -解释:图中垂直线代表输入数组 [1,8,6,2,5,4,8,3,7]。在此情况下,容器能够容纳水(表示为蓝色部分)的最大值为 49。 -``` - -#### 2.6.3 解题思路 - -##### 思路 1:对撞指针 - -从示例中可以看出,如果确定好左右两端的直线,容纳的水量是由「左右两端直线中较低直线的高度 * 两端直线之间的距离」所决定的。所以我们应该使得「」,这样才能使盛水面积尽可能的大。 - -可以使用对撞指针求解。移动较低直线所在的指针位置,从而得到不同的高度和面积,最终获取其中最大的面积。具体做法如下: - -1. 使用两个指针 $left$,$right$。$left$ 指向数组开始位置,$right$ 指向数组结束位置。 -2. 计算 $left$ 和 $right$ 所构成的面积值,同时维护更新最大面积值。 -3. 判断 $left$ 和 $right$ 的高度值大小。 - 1. 如果 $left$ 指向的直线高度比较低,则将 $left$ 指针右移。 - 2. 如果 $right$ 指向的直线高度比较低,则将 $right$ 指针左移。 -4. 如果遇到 $left == right$,跳出循环,最后返回最大的面积。 - -##### 思路 1:代码 - -```python -class Solution: - def maxArea(self, height: List[int]) -> int: - left = 0 - right = len(height) - 1 - ans = 0 - while left < right: - area = min(height[left], height[right]) * (right-left) - ans = max(ans, area) - if height[left] < height[right]: - left += 1 - else: - right -= 1 - return ans -``` - -##### 思路 1:复杂度分析 - -- **时间复杂度**:$O(n)$。 -- **空间复杂度**:$O(1)$。 - ## 3. 快慢指针 -> **快慢指针**:指的是两个指针从同一侧开始遍历序列,且移动的步长一个快一个慢。移动快的指针被称为 「快指针(fast)」,移动慢的指针被称为「慢指针(slow)」。两个指针以不同速度、不同策略移动,直到快指针移动到数组尾端,或者两指针相交,或者满足其他特殊条件时为止。 +> **快慢指针**:指两个指针从同一侧出发,步长不同,快指针(fast)移动更快,慢指针(slow)移动较慢。它们以不同速度遍历序列,直到快指针到达末尾、两指针相遇或满足特定条件时停止。 ![快慢指针](https://qcdn.itcharge.cn/images/202405092156465.png) ### 3.1 快慢指针求解步骤 -1. 使用两个指针 $slow$、$fast$。$slow$ 一般指向序列第一个元素,即:$slow = 0$,$fast$ 一般指向序列第二个元素,即:$fast = 1$。 -2. 在循环体中将左右指针向右移动。当满足一定条件时,将慢指针右移,即 $slow += 1$。当满足另外一定条件时(也可能不需要满足条件),将快指针右移,即 $fast += 1$。 -3. 到快指针移动到数组尾端(即 $fast == len(nums) - 1$),或者两指针相交,或者满足其他特殊条件时跳出循环体。 +1. 初始化两个指针 $slow = 0$,$fast = 1$,分别指向第一个和第二个元素。 +2. 循环中根据条件移动指针:满足条件时 $slow += 1$,否则 $fast += 1$。 +3. 当 $fast$ 到达数组末尾、两指针相遇或满足其他条件时结束循环。 -### 3.2 快慢指针伪代码模板 +### 3.2 快慢指针通用模板 ```python +# 初始化慢指针 slow 和快指针 fast slow = 0 fast = 1 -while 没有遍历完: - if 满足要求的特殊条件: - slow += 1 - fast += 1 -return 合适的值 +# 当 fast 没有遍历到数组末尾时循环 +while fast 未遍历到数组末尾: + # 如果满足特定条件(如去重时 nums[fast] != nums[slow]) + if 满足特定条件: + slow += 1 # 慢指针右移一位,准备接收新元素 + # 根据实际需求,可能需要将 fast 指向的元素赋值给 slow 指向的位置 + # 例如:nums[slow] = nums[fast] + fast += 1 # 快指针继续向右遍历 +# 返回最终结果(如新数组长度 slow + 1 或处理后的数组等) +return 最终结果 ``` -### 3.3 快慢指针适用范围 +### 3.3 快慢指针的应用场景 -快慢指针一般用于处理数组中的移动、删除元素问题,或者链表中的判断是否有环、长度问题。关于链表相关的双指针做法我们到链表章节再详细讲解。 +快慢指针主要用于解决数组元素的移动、删除等问题,以及链表中的环检测、长度统计等操作。链表相关的双指针技巧将在后续链表章节详细介绍。 -下面我们根据具体例子来讲解如何使用快慢指针来解决问题。 +接下来,我们通过具体例题,演示快慢指针的实际用法。 -### 3.4 删除有序数组中的重复项 +### 3.4 经典例题:删除有序数组中的重复项 #### 3.4.1 题目链接 @@ -336,34 +224,36 @@ return 合适的值 ##### 思路 1:快慢指针 -因为数组是有序的,那么重复的元素一定会相邻。 - -删除重复元素,实际上就是将不重复的元素移到数组左侧。考虑使用双指针。具体算法如下: +有序数组中,重复元素必然相邻。我们可以用双指针原地去重: -1. 定义两个快慢指针 $slow$,$fast$。其中 $slow$ 指向去除重复元素后的数组的末尾位置。$fast$ 指向当前元素。 -2. 令 $slow$ 在后, $fast$ 在前。令 $slow = 0$,$fast = 1$。 -3. 比较 $slow$ 位置上元素值和 $fast$ 位置上元素值是否相等。 - - 如果不相等,则将 $slow$ 右移一位,将 $fast$ 指向位置的元素复制到 $slow$ 位置上。 -4. 将 $fast$ 右移 $1$ 位。 -5. 重复上述 $3 \sim 4$ 步,直到 $fast$ 等于数组长度。 -6. 返回 $slow + 1$ 即为新数组长度。 +1. 用两个指针 $slow$ 和 $fast$,初始 $slow = 0$,$fast = 1$。 +2. 遍历数组,若 $nums[fast] \neq nums[slow]$,则 $slow$ 右移一位,并将 $nums[fast]$ 赋值到 $nums[slow]$。 +3. 每轮 $fast$ 右移一位,直到遍历结束。 +4. 最终返回 $slow + 1$,即去重后数组长度。 ##### 思路 1:代码 ```python class Solution: def removeDuplicates(self, nums: List[int]) -> int: + # 数组为空或只有一个元素,直接返回长度 if len(nums) <= 1: return len(nums) + # slow 指针指向去重后数组的最后一个元素 + # fast 指针用于遍历整个数组 slow, fast = 0, 1 - while (fast < len(nums)): + while fast < len(nums): + # 如果当前 fast 指向的元素和 slow 指向的元素不同 + # 说明遇到了新的不重复元素 if nums[slow] != nums[fast]: - slow += 1 - nums[slow] = nums[fast] + slow += 1 # slow 前进一位 + nums[slow] = nums[fast] # 将新元素赋值到 slow 位置 + # 无论是否赋值,fast 都要前进一位 fast += 1 - + + # 返回去重后数组的长度(下标从0开始,所以要+1) return slow + 1 ``` @@ -374,41 +264,48 @@ class Solution: ## 4. 分离双指针 -> **分离双指针**:两个指针分别属于不同的数组,两个指针分别在两个数组中移动。 +> **分离双指针**:指的是分别在两个不同数组上各设置一个指针,两个指针独立地在各自数组中移动,以协同完成特定任务。 ![分离双指针](https://qcdn.itcharge.cn/images/202405092157828.png) ### 4.1 分离双指针求解步骤 -1. 使用两个指针 $left\underline{\hspace{0.5em}}1$、$left\underline{\hspace{0.5em}}2$。$left\underline{\hspace{0.5em}}1$ 指向第一个数组的第一个元素,即:$left\underline{\hspace{0.5em}}1 = 0$,$left\underline{\hspace{0.5em}}2$ 指向第二个数组的第一个元素,即:$left\underline{\hspace{0.5em}}2 = 0$。 -2. 当满足一定条件时,两个指针同时右移,即 $left\underline{\hspace{0.5em}}1 += 1$、$left\underline{\hspace{0.5em}}2 += 1$。 -3. 当满足另外一定条件时,将 $left\underline{\hspace{0.5em}}1$ 指针右移,即 $left\underline{\hspace{0.5em}}1 += 1$。 -4. 当满足其他一定条件时,将 $left\underline{\hspace{0.5em}}2$ 指针右移,即 $left\underline{\hspace{0.5em}}2 += 1$。 -5. 当其中一个数组遍历完时或者满足其他特殊条件时跳出循环体。 +1. 定义两个指针 $left\underline{\hspace{0.5em}}1$ 和 $left\underline{\hspace{0.5em}}2$,分别指向两个数组的起始位置(均为 $0$)。 +2. 根据条件,若需要,两个指针同时右移:$left\underline{\hspace{0.5em}}1 += 1$,$left\underline{\hspace{0.5em}}2 += 1$。 +3. 若只需移动第一个数组指针,则 $left\underline{\hspace{0.5em}}1 += 1$。 +4. 若只需移动第二个数组指针,则 $left\underline{\hspace{0.5em}}2 += 1$。 +5. 当任一指针遍历到数组末尾或满足终止条件时,结束循环。 -### 4.2 分离双指针伪代码模板 +### 4.2 分离双指针通用模板 ```python -left_1 = 0 -left_2 = 0 +# 初始化两个指针,分别指向两个数组的起始位置 +left_1, left_2 = 0, 0 +# 当两个指针都未遍历到各自数组末尾时,循环进行比较 while left_1 < len(nums1) and left_2 < len(nums2): - if 一定条件 1: + if 满足条件 1: + # 通常表示两个指针指向的元素相等 + # 此时可以将该元素加入结果集(如交集),并同时移动两个指针 left_1 += 1 left_2 += 1 - elif 一定条件 2: + elif 满足条件 2: + # 通常表示第一个数组当前元素较小 + # 只移动第一个指针,继续比较下一个元素 left_1 += 1 - elif 一定条件 3: + elif 满足条件 3: + # 通常表示第二个数组当前元素较小 + # 只移动第二个指针,继续比较下一个元素 left_2 += 1 ``` -### 4.3 分离双指针使用范围 +### 4.3 分离双指针适用场景 -分离双指针一般用于处理有序数组合并,求交集、并集问题。 +分离双指针主要应用于有序数组的合并、交集、并集等问题,能够高效地同时遍历两个数组,协同完成元素的比较与处理。 -下面我们根据具体例子来讲解如何使用分离双指针来解决问题。 +下面通过具体例子,详细讲解分离双指针的实际用法。 -### 4.4 两个数组的交集 +### 4.4 经典例题:两个数组的交集 #### 4.4.1 题目链接 @@ -447,33 +344,42 @@ while left_1 < len(nums1) and left_2 < len(nums2): ##### 思路 1:分离双指针 -1. 对数组 $nums1$、$nums2$ 先排序。 -2. 使用两个指针 $left\underline{\hspace{0.5em}}1$、$left\underline{\hspace{0.5em}}2$。$left\underline{\hspace{0.5em}}1$ 指向第一个数组的第一个元素,即:$left\underline{\hspace{0.5em}}1 = 0$,$left\underline{\hspace{0.5em}}2$ 指向第二个数组的第一个元素,即:$left\underline{\hspace{0.5em}}2 = 0$。 -3. 如果 $nums1[left\underline{\hspace{0.5em}}1] == nums2[left\underline{\hspace{0.5em}}2]$,则将其加入答案数组(注意去重),并将 $left\underline{\hspace{0.5em}}1$ 和 $left\underline{\hspace{0.5em}}2$ 右移。 -4. 如果 $nums1[left\underline{\hspace{0.5em}}1] < nums2[left\underline{\hspace{0.5em}}2]$,则将 $left\underline{\hspace{0.5em}}1$ 右移。 -5. 如果 $nums1[left\underline{\hspace{0.5em}}1] > nums2[left\underline{\hspace{0.5em}}2]$,则将 $left\underline{\hspace{0.5em}}2$ 右移。 -6. 最后返回答案数组。 +1. 先对 $nums1$ 和 $nums2$ 排序。 +2. 用两个指针 $left\underline{\hspace{0.5em}}1$、$left\underline{\hspace{0. +5em}}2$ 分别从两个数组头部开始遍历。 +3. 若 $nums1[left\underline{\hspace{0.5em}}1] == nums2[left\underline +{\hspace{0.5em}}2]$,将该元素(去重)加入结果,并同时右移 $left\underline{\hspace{0.5em}}1$、$left\underline +{\hspace{0.5em}}2$。 +4. 若 $nums1[left\underline{\hspace{0.5em}}1] < nums2[left\underline +{\hspace{0.5em}}2]$,则 $left\underline{\hspace{0.5em}}1$ 右移。 +5. 若 $nums1[left\underline{\hspace{0.5em}}1] > nums2[left\underline +{\hspace{0.5em}}2]$,则 $left\underline +{\hspace{0.5em}}2$ 右移。 +6. 遍历结束后返回结果数组。 ##### 思路 1:代码 ```python class Solution: def intersection(self, nums1: List[int], nums2: List[int]) -> List[int]: - nums1.sort() - nums2.sort() + nums1.sort() # 对 nums1 进行排序 + nums2.sort() # 对 nums2 进行排序 - left_1 = 0 - left_2 = 0 + left_1 = 0 # 指向 nums1 的指针 + left_2 = 0 # 指向 nums2 的指针 res = [] + + # 优化:由于数组已排序,结果去重只需判断上一个加入的元素即可 while left_1 < len(nums1) and left_2 < len(nums2): if nums1[left_1] == nums2[left_2]: - if nums1[left_1] not in res: + # 只有 res 为空或当前元素与上一个加入的元素不同才加入结果,避免重复 + if not res or nums1[left_1] != res[-1]: res.append(nums1[left_1]) left_1 += 1 left_2 += 1 elif nums1[left_1] < nums2[left_2]: left_1 += 1 - elif nums1[left_1] > nums2[left_2]: + else: # nums1[left_1] > nums2[left_2] left_2 += 1 return res ``` @@ -485,13 +391,13 @@ class Solution: ## 5. 双指针总结 -双指针分为「对撞指针」、「快慢指针」、「分离双指针」。 +双指针主要分为三类:「对撞指针」、「快慢指针」和「分离双指针」。 -- **对撞指针**:两个指针方向相反。适合解决查找有序数组中满足某些约束条件的一组元素问题、字符串反转问题。 -- **快慢指针**:两个指针方向相同。适合解决数组中的移动、删除元素问题,或者链表中的判断是否有环、长度问题。 -- **分离双指针**:两个指针分别属于不同的数组 / 链表。适合解决有序数组合并,求交集、并集问题。 +- **对撞指针**:两个指针分别从序列两端向中间移动,常用于查找有序数组中满足特定条件的元素对、字符串反转等场景。 +- **快慢指针**:两个指针从同一端出发,步长不同,常用于数组元素的移动、删除,或链表中的环检测、长度统计等问题。 +- **分离双指针**:两个指针分别遍历不同的数组或链表,适合处理有序数组的合并、交集、并集等问题。 -双指针算法能有效降低时间复杂度,通常将暴力解法的 $O(n^2)$ 优化为 $O(n)$。关键在于利用数据的有序性或问题的单调性,通过指针移动排除不可能的情况,减少不必要的计算。掌握双指针技巧能高效解决许多数组和链表问题。 +双指针算法能够显著降低时间复杂度,通常可将暴力解法的 $O(n^2)$ 优化为 $O(n)$。其核心在于利用数据的有序性或问题的单调性,通过灵活移动指针,快速排除不符合条件的情况,从而减少无效计算。熟练掌握双指针技巧,可以高效解决大量数组和链表相关的问题。 ## 练习题目 diff --git a/docs/01_array/01_16_array_sliding_window.md b/docs/01_array/01_16_array_sliding_window.md index addc58ed..dc79527e 100644 --- a/docs/01_array/01_16_array_sliding_window.md +++ b/docs/01_array/01_16_array_sliding_window.md @@ -1,70 +1,68 @@ -## 1. 滑动窗口算法介绍 +## 1. 滑动窗口算法简介 在计算机网络中,滑动窗口协议(Sliding Window Protocol)是传输层进行流控的一种措施,接收方通过通告发送方自己的窗口大小,从而控制发送方的发送速度,从而达到防止发送方发送速度过快而导致自己被淹没的目的。我们所要讲解的滑动窗口算法也是利用了同样的特性。 -> **滑动窗口算法(Sliding Window)**:在给定数组 / 字符串上维护一个固定长度或不定长度的窗口。可以对窗口进行滑动操作、缩放操作,以及维护最优解操作。 +> **滑动窗口算法(Sliding Window)**:在数组 / 字符串上维护一个固定或可变长度的窗口,通过滑动和缩放窗口,动态维护区间内的最优解。 -- **滑动操作**:窗口可按照一定方向进行移动。最常见的是向右侧移动。 -- **缩放操作**:对于不定长度的窗口,可以从左侧缩小窗口长度,也可以从右侧增大窗口长度。 +- **滑动**:窗口整体向一个方向移动,通常是向右。 +- **缩放**:窗口长度可变时,可以通过移动左指针缩小窗口,或移动右指针扩大窗口。 -滑动窗口利用了双指针中的快慢指针技巧,我们可以将滑动窗口看做是快慢指针两个指针中间的区间,也可以将滑动窗口看做是快慢指针的一种特殊形式。 +滑动窗口本质上是双指针(快慢指针)的一种应用,可以理解为用两个指针维护一个区间,动态调整区间范围以满足题目要求。 ![滑动窗口](https://qcdn.itcharge.cn/images/202405092203225.png) -## 2. 滑动窗口适用范围 +## 2. 滑动窗口的应用场景 -滑动窗口算法一般用来解决一些查找满足一定条件的连续区间的性质(长度等)的问题。该算法可以将一部分问题中的嵌套循环转变为一个单循环,因此它可以减少时间复杂度。 +滑动窗口常用于查找满足某些条件的连续子区间,能将嵌套循环优化为单循环,大幅降低时间复杂度。常见题型包括: -按照窗口长度的固定情况,我们可以将滑动窗口题目分为以下两种: +- **固定长度窗口**:窗口大小固定,通常用于统计或查找长度为 $k$ 的区间性质。 +- **可变长度窗口**:窗口大小不固定,常用于查找满足条件的最长/最短区间。 -- **固定长度窗口**:窗口大小是固定的。 -- **不定长度窗口**:窗口大小是不固定的。 - - 求解最大的满足条件的窗口。 - - 求解最小的满足条件的窗口。 - - -下面来分别讲解一下这两种类型题目。 +下面分别介绍这两类滑动窗口的应用。 ## 3. 固定长度滑动窗口 -> **固定长度滑动窗口算法(Fixed Length Sliding Window)**:在给定数组 / 字符串上维护一个固定长度的窗口。可以对窗口进行滑动操作、缩放操作,以及维护最优解操作。 +> **固定长度滑动窗口算法(Fixed Length Sliding Window)**:在数组 / 字符串上维护一个长度固定的窗口,通过不断向右滑动窗口,实时更新窗口内的数据,并根据题目要求动态维护最优解。 ![固定长度滑动窗口](https://qcdn.itcharge.cn/images/202405092204712.png) ### 3.1 固定长度滑动窗口算法步骤 -假设窗口的固定大小为 $window\underline{\hspace{0.5em}}size$。 +假设窗口大小为 $window\underline{\hspace{0.5em}}size$,步骤如下: -1. 使用两个指针 $left$、$right$。初始时,$left$、$right$ 都指向序列的第一个元素,即:$left = 0$,$right = 0$,区间 $[left, right]$ 被称为一个「窗口」。 -2. 当窗口未达到 $window\underline{\hspace{0.5em}}size$ 大小时,不断移动 $right$,先将数组前 $window\underline{\hspace{0.5em}}size$ 个元素填入窗口中,即 `window.append(nums[right])`。 -2. 当窗口达到 $window\underline{\hspace{0.5em}}size$ 大小时,即满足 `right - left + 1 >= window_size` 时,判断窗口内的连续元素是否满足题目限定的条件。 - 1. 如果满足,再根据要求更新最优解。 - 2. 然后向右移动 $left$,从而缩小窗口长度,即 `left += 1`,使得窗口大小始终保持为 $window\underline{\hspace{0.5em}}size$。 -3. 向右移动 $right$,将元素填入窗口中,即 `window.append(nums[right])`。 -4. 重复 $2 \sim 4$ 步,直到 $right$ 到达数组末尾。 +1. 定义两个指针 $left$ 和 $right$,初始都指向序列起始位置($left = 0, right = 0$),区间 $[left, right]$ 表示当前窗口。 +2. 不断右移 $right$,将元素加入窗口(如 `window.append(nums[right])`)。 +3. 当窗口长度达到 $window\underline{\hspace{0.5em}}size$(即 `right - left + 1 >= window_size`)时: + - 判断窗口内元素是否满足题目要求,若满足则更新答案。 + - 右移 $left$(`left += 1`),保持窗口长度不变。 +4. 重复上述过程,直到 $right$ 遍历完整个数组。 ### 3.2 固定长度滑动窗口代码模板 ```python -left = 0 -right = 0 +left = 0 # 窗口左边界 +right = 0 # 窗口右边界 while right < len(nums): + # 将当前元素加入窗口 window.append(nums[right]) - # 超过窗口大小时,缩小窗口,维护窗口中始终为 window_size 的长度 + # 判断当前窗口长度是否达到 window_size if right - left + 1 >= window_size: - # ... 维护答案 - window.popleft() - left += 1 + # 在窗口长度达到要求时,进行答案的统计或更新 + # ... 这里根据题目需求维护/更新答案 + + # 移除窗口最左侧元素,窗口向右滑动 + window.popleft() + left += 1 # 左指针右移,缩小窗口长度,保持窗口长度为 window_size - # 向右侧增大窗口 + # 右指针右移,扩大窗口 right += 1 ``` -下面我们根据具体例子来讲解一下如何使用固定窗口大小的滑动窗口来解决问题。 +下面我们通过具体例题,详细说明如何利用固定长度滑动窗口方法高效解决相关问题。 -### 3.3 大小为 K 且平均值大于等于阈值的子数组数目 +### 3.3 经典例题:大小为 K 且平均值大于等于阈值的子数组数目 #### 3.3.1 题目链接 @@ -105,40 +103,40 @@ while right < len(nums): ##### 思路 1:滑动窗口(固定长度) -这道题目是典型的固定窗口大小的滑动窗口题目。窗口大小为 $k$。具体做法如下: +本题是典型的定长滑动窗口问题,窗口大小为 $k$。具体做法如下: -1. $ans$ 用来维护答案数目。$window\underline{\hspace{0.5em}}sum$ 用来维护窗口中元素的和。 -2. $left$ 、$right$ 都指向序列的第一个元素,即:$left = 0$,$right = 0$。 -3. 向右移动 $right$,先将 $k$ 个元素填入窗口中,即 `window_sum += arr[right]`。 -4. 当窗口元素个数为 $k$ 时,即满足 `right - left + 1 >= k` 时,判断窗口内的元素和平均值是否大于等于阈值 $threshold$。 - 1. 如果满足,则答案数目加 $1$。 - 2. 然后向右移动 $left$,从而缩小窗口长度,即 `left += 1`,使得窗口大小始终保持为 $k$。 -5. 重复 $3 \sim 4$ 步,直到 $right$ 到达数组末尾。 -6. 最后输出答案数目。 +1. 用 $window\_sum$ 维护当前窗口内元素和,$ans$ 统计满足条件的子数组个数。 +2. 使用两个指针 $left$、$right$,初始都为 $0$。 +3. 每次将 $arr[right]$ 加入 $window\underline{\hspace{0.5em}}sum$,$right$ 右移。 +4. 当窗口长度达到 $k$(即 `right - left + 1 >= k`)时,判断窗口平均值是否大于等于 $threshold$,满足则 $ans + 1$。 +5. 然后将 $arr[left]$ 移出窗口,$left$ 右移,保证窗口长度始终为 $k$。 +6. 重复上述过程直到遍历完整个数组,最后返回 $ans$。 ##### 思路 1:代码 -```python + class Solution: def numOfSubarrays(self, arr: List[int], k: int, threshold: int) -> int: - left = 0 - right = 0 - window_sum = 0 - ans = 0 + left = 0 # 窗口左边界 + right = 0 # 窗口右边界 + window_sum = 0 # 当前窗口内元素的和 + ans = 0 # 满足条件的子数组个数 while right < len(arr): - window_sum += arr[right] - + window_sum += arr[right] # 将右边界元素加入窗口和 + + # 当窗口长度达到k时,判断是否满足条件 if right - left + 1 >= k: + # 判断当前窗口的平均值是否大于等于 threshold if window_sum >= k * threshold: - ans += 1 - window_sum -= arr[left] - left += 1 + ans += 1 # 满足条件,计数加一 + window_sum -= arr[left] # 移除左边界元素,准备滑动窗口 + left += 1 # 左边界右移 - right += 1 + right += 1 # 右边界右移,扩大窗口 - return ans -``` + return ans # 返回满足条件的子数组个数 + ##### 思路 1:复杂度分析 @@ -147,38 +145,42 @@ class Solution: ## 4. 不定长度滑动窗口 -> **不定长度滑动窗口算法(Sliding Window)**:在给定数组 / 字符串上维护一个不定长度的窗口。可以对窗口进行滑动操作、缩放操作,以及维护最优解操作。 +> **不定长滑动窗口(Sliding Window)**:在数组 / 字符串上用两个指针动态维护一个可变长度的窗口,通过左右移动指针灵活调整窗口范围,实时维护最优解。 ![不定长度滑动窗口](https://qcdn.itcharge.cn/images/202405092206553.png) ### 4.1 不定长度滑动窗口算法步骤 -1. 使用两个指针 $left$、$right$。初始时,$left$、$right$ 都指向序列的第一个元素。即:$left = 0$,$right = 0$,区间 $[left, right]$ 被称为一个「窗口」。 -2. 将区间最右侧元素添加入窗口中,即 `window.add(s[right])`。 -3. 然后向右移动 $right$,从而增大窗口长度,即 `right += 1`。直到窗口中的连续元素满足要求。 -4. 此时,停止增加窗口大小。转向不断将左侧元素移出窗口,即 `window.popleft(s[left])`。 -5. 然后向右移动 $left$,从而缩小窗口长度,即 `left += 1`。直到窗口中的连续元素不再满足要求。 -6. 重复 2 ~ 5 步,直到 $right$ 到达序列末尾。 +1. 定义左右指针 $left$、$right$,初始都为 $0$,区间 $[left, right]$ 表示当前窗口。 +2. 将 $s[right]$ 加入窗口(如 `window.add(s[right])`),然后 $right += 1$,扩大窗口。 +3. 当窗口不满足条件时,不断移除 $s[left]$(如 `window.popleft()`),并 $left += 1$,缩小窗口,直到重新满足条件。 +4. 重复上述过程,直到 $right$ 遍历完整个序列。 ### 4.2 不定长度滑动窗口代码模板 ```python +# 初始化左右指针,均指向数组起始位置 left = 0 right = 0 +# 主循环,右指针遍历整个数组 while right < len(nums): + # 将当前右指针指向的元素加入窗口 window.append(nums[right]) + # 当窗口不满足题目要求时,缩小窗口(移动左指针) while 窗口需要缩小: - # ... 可维护答案 - window.popleft() - left += 1 + # 此处可根据题意维护/更新答案 + window.popleft() # 移除左边界元素 + left += 1 # 左指针右移,缩小窗口 - # 向右侧增大窗口 + # 此处可根据题意维护/更新答案(如记录最大/最小窗口等) + + # 右指针右移,扩大窗口 right += 1 ``` -### 4.3 [无重复字符的最长子串](https://leetcode.cn/problems/longest-substring-without-repeating-characters/) +### 4.3 经典例题:无重复字符的最长子串 #### 4.3.1 题目链接 @@ -217,39 +219,42 @@ while right < len(nums): ##### 思路 1:滑动窗口(不定长度) -用滑动窗口 $window$ 来记录不重复的字符个数,$window$ 为哈希表类型。 +使用滑动窗口(哈希表 $window$ 记录窗口内每个字符出现的次数)来维护一个不含重复字符的子串。 -1. 设定两个指针:$left$、$right$,分别指向滑动窗口的左右边界,保证窗口中没有重复字符。 -2. 一开始,$left$、$right$ 都指向 $0$。 -3. 向右移动 $right$,将最右侧字符 $s[right]$ 加入当前窗口 $window$ 中,记录该字符个数。 -4. 如果该窗口中该字符的个数多于 $1$ 个,即 $window[s[right]] > 1$,则不断右移 $left$,缩小滑动窗口长度,并更新窗口中对应字符的个数,直到 $window[s[right]] \le 1$。 -5. 维护更新无重复字符的最长子串长度。然后继续右移 $right$,直到 $right \ge len(nums)$ 结束。 -6. 输出无重复字符的最长子串长度。 +1. 初始化两个指针 $left$ 和 $right$,分别作为滑动窗口的左右边界,初始都为 $0$。 +2. 右指针 $right$ 向右移动,每次将 $s[right]$ 加入 window,并统计其出现次数。 +3. 若当前字符 $s[right]$ 在窗口中的出现次数大于 $1$(即 $window[s[right]] > 1$),说明出现重复字符。此时不断右移左指针 $left$,并相应减少 $window[s[left]]$ 的计数,直到窗口内 $s[right]$ 只出现一次,保证窗口内无重复字符。 +4. 每次窗口合法(无重复字符)时,更新最长子串长度的答案。 +5. 重复上述过程,直到 $right$ 遍历完整个字符串。 +6. 最终返回无重复字符的最长子串长度。 ##### 思路 1:代码 ```python class Solution: def lengthOfLongestSubstring(self, s: str) -> int: - left = 0 - right = 0 - window = dict() - ans = 0 + left = 0 # 滑动窗口左边界 + right = 0 # 滑动窗口右边界 + window = dict() # 记录窗口内每个字符出现的次数 + ans = 0 # 记录最长无重复子串的长度 while right < len(s): + # 将当前字符加入窗口,统计出现次数 if s[right] not in window: window[s[right]] = 1 else: window[s[right]] += 1 + # 如果当前字符出现次数大于1,说明有重复,需要收缩左边界 while window[s[right]] > 1: - window[s[left]] -= 1 - left += 1 + window[s[left]] -= 1 # 左边界字符出现次数减少 + left += 1 # 左边界右移,缩小窗口 + # 更新最长无重复子串的长度 ans = max(ans, right - left + 1) - right += 1 + right += 1 # 右边界右移,扩大窗口 - return ans + return ans # 返回结果 ``` ##### 思路 1:复杂度分析 @@ -259,21 +264,20 @@ class Solution: ## 5. 总结 -滑动窗口算法用于解决数组或字符串中的连续子区间问题。 - -**固定长度窗口**:窗口大小不变。适用于需要检查固定长度子区间的问题,如计算特定长度子数组的平均值。 +滑动窗口算法高效解决数组或字符串的连续区间问题。 -**不定长度窗口**:窗口大小可变。适用于寻找满足条件的最长或最短子区间,如无重复字符的最长子串。 +- **固定长度窗口**:窗口大小固定,常用于统计定长子区间的和、均值等。 +- **不定长度窗口**:窗口大小可变,适合查找最长/最短满足条件的子区间,如最长无重复子串。 -滑动窗口通过维护窗口的左右边界来减少重复计算,将时间复杂度从 $O(n^2)$ 优化到 $O(n)$。 +滑动窗口通过动态调整左右边界,避免重复遍历,将时间复杂度从 $O(n^2)$ 降至 $O(n)$。 -使用滑动窗口时需要注意: -- 窗口的初始位置 -- 窗口扩展和收缩的条件 -- 如何更新答案 -- 边界情况的处理 +使用时需注意: +- 窗口的起始位置 +- 何时扩展或收缩窗口 +- 如何及时更新答案 +- 边界情况处理 -掌握滑动窗口算法能高效解决许多子区间相关问题。 +熟练掌握滑动窗口,可高效应对各类区间类问题。 ## 练习题目 diff --git a/docs/02_linked_list/02_01_linked_list_basic.md b/docs/02_linked_list/02_01_linked_list_basic.md index c88cf04a..8701aac8 100644 --- a/docs/02_linked_list/02_01_linked_list_basic.md +++ b/docs/02_linked_list/02_01_linked_list_basic.md @@ -2,362 +2,317 @@ ### 1.1 链表定义 -> **链表(Linked List)**:一种线性表数据结构。它使用一组任意的存储单元(可以是连续的,也可以是不连续的),来存储一组具有相同类型的数据。 +> **链表(Linked List)**:一种线性表数据结构,通过一组任意(可连续或不连续)的存储单元,存储同类型数据。 -简单来说,**「链表」** 是实现线性表链式存储结构的基础。 +简而言之,**链表** 是线性表的链式存储实现。 -以单链表为例,链表的存储方式如下图所示。 +以单链表为例,其结构如下图: ![链表](https://qcdn.itcharge.cn/images/202405092229936.png) -如上图所示,链表通过将一组任意的存储单元串联在一起。其中,每个数据元素占用若干存储单元的组合称为一个「链节点」。为了将所有的节点串起来,每个链节点不仅要存放一个数据元素的值,还要存放一个指出这个数据元素在逻辑关系上的直接后继元素所在链节点的地址,该地址被称为「后继指针 $next$」。 +如上图所示,链表通过指针将一组任意的存储单元串联起来。每个数据元素及其所在的存储单元构成一个「链节点」。为了将所有节点连接成链,每个链节点除了存放数据元素本身,还需要额外存储一个指向其直接后继节点的指针,称为「后继指针 $next$」。 -在链表中,数据元素之间的逻辑关系是通过指针来间接反映的。逻辑上相邻的数据元素在物理地址上可能相邻,可也能不相邻。其在物理地址上的表现是随机的。 +在链表结构中,数据元素之间的逻辑顺序由指针维护。虽然逻辑上相邻的数据元素在物理内存中可以相邻,也可以完全不相邻,因此链表在物理存储上的分布是非连续、随机的。 -我们先来简单介绍一下链表结构的优缺点: +链表的优缺点如下: -- **优点**:存储空间不必事先分配,在需要存储空间的时候可以临时申请,不会造成空间的浪费;一些操作的时间效率远比数组高(插入、移动、删除元素等)。 +- **优点**:链表无需预先分配存储空间,按需动态申请,能够有效避免空间浪费;在插入、删除等操作上,链表通常比数组更高效,尤其是在需要频繁修改数据结构时表现突出。 -- **缺点**:不仅数据元素本身的数据信息要占用存储空间,指针也需要占用存储空间,链表结构比数组结构的空间开销大。 +- **缺点**:链表除了存储数据本身外,还需额外存储指针信息,因此整体空间开销大于数组;同时,链表不支持随机访问,查找元素时需要从头遍历,效率较低。 -接下来我们来介绍一下除了单链表之外,链表的其他几种类型。 +下面介绍除单链表外的其他链表类型。 ### 1.2 双向链表 -> **双向链表(Doubly Linked List)**:链表的一种,也叫做双链表。它的每个链节点中有两个指针,分别指向直接后继和直接前驱。 +> **双向链表(Doubly Linked List)**:链表的一种,也称为双链表。每个节点包含两个指针,分别指向其直接前驱和直接后继节点。 -- **双向链表特点**:从双链表的任意一个节点开始,都可以很方便的访问它的前驱节点和后继节点。 +- **双向链表的特点**:可以从任意节点高效地访问其前驱和后继节点,支持双向遍历,插入和删除操作更加灵活。 ![双向链表](https://qcdn.itcharge.cn/images/202405092230869.png) ### 1.3 循环链表 -> **循环链表(Circular linked list)**:链表的一种。它的最后一个链节点指向头节点,形成一个环。 +> **循环链表(Circular Linked List)**:一种特殊的链表结构,其最后一个节点的指针指向头节点,从而使整个链表首尾相连,形成一个闭环。 -- **循环链表特点**:从循环链表的任何一个节点出发都能找到任何其他节点。 +- **循环链表的特点**:无论从哪个节点出发,都可以遍历到链表中的任意节点,实现了节点间的循环访问。 ![循环链表](https://qcdn.itcharge.cn/images/202405092230094.png) -接下来我们以最基本的「单链表」为例,介绍一下链表的基本操作。 +下面我们将以最基础的「单链表」为例,详细讲解链表的基本操作。 ## 2. 链表的基本操作 -数据结构的操作一般涉及到增、删、改、查 4 种情况,链表的操作也基本上是这 4 种情况。我们一起来看一下链表的基本操作。 +在数据结构中,常见的基本操作包括增、删、改、查四类,链表的操作同样主要围绕这四个方面展开。下面我们详细介绍链表的基本操作。 ### 2.1 链表的结构定义 -链表是由链节点通过 $next$ 链接而构成的,我们可以先定义一个简单的「链节点类」,再来定义完整的「链表类」。 +链表由若干链节点通过 $next$ 指针依次连接而成。通常我们会先定义一个简单的「链节点类」,再基于此实现完整的「链表类」。 -- **链节点类(即 ListNode 类)**:使用成员变量 $val$ 表示数据元素的值,使用指针变量 $next$ 表示后继指针。 +- **链节点类(ListNode)**:包含成员变量 $val$(存储数据元素的值)和 $next$(指向下一个节点的指针)。 -- **链表类(即 LinkedList 类)**:使用一个链节点变量 $head$ 来表示链表的头节点。 +- **链表类(LinkedList)**:包含一个链节点变量 $head$,用于表示链表的头节点。 -我们在创建空链表时,只需要把相应的链表头节点变量设置为空链接即可。在 Python 里可以将其设置为 $None$,其他语言也有类似的惯用值,比如 $NULL$、$nil$、$0$ 等。 +创建空链表时,只需将头节点 $head$ 设为「空指针」。在 Python 中可用 $None$ 表示,其他语言中常用 $NULL$、$nil$、$0$ 等。 -**「链节点以及链表结构定义」** 的代码如下: +**链节点与链表结构的代码实现如下:** ```python # 链节点类 class ListNode: def __init__(self, val=0, next=None): - self.val = val - self.next = next + self.val = val # 节点的值 + self.next = next # 指向下一个节点 -# 链表类 class LinkedList: def __init__(self): - self.head = None + self.head = None # 链表头指针,初始为 None ``` -### 2.2 建立一个线性链表 +### 2.2 创建链表 -> **建立一个线性链表**:根据线性表的数据元素动态生成链节点,并依次将其连接到链表中。 -> -> 1. 从所给线性表中取出第 $1$ 个数据元素,建立链表头节点。然后依次获取表中的数据元素。 -> 2. 每获取一个数据元素,就为该数据元素生成一个新节点,将新节点插入到链表的尾部。 -> 3. 插入完毕之后返回第 $1$ 个链节点(即头节点)的地址。 +> **创建链表**:根据给定的线性表数据,依次生成链表节点,并将它们顺序连接起来,构成完整的链表。 + +具体步骤如下: +1. 取出线性表的第 $1$ 个元素,创建链表头节点。 +2. 依次遍历剩余元素,每获取一个数据元素,就新建一个节点,并将其连接到当前链表的尾部。 +3. 所有元素插入完成后,返回头节点。 -**「建立一个线性链表」** 的代码如下: +**创建链表** 的实现代码如下: ```python -# 根据 data 初始化一个新链表 +# 根据 data 列表初始化一个新链表 def create(self, data): if not data: + # 如果输入数据为空,直接返回,不创建链表 return + # 创建头节点,并将 head 指向头节点 self.head = ListNode(data[0]) - cur = self.head + cur = self.head # cur 用于指向当前链表的尾节点 + # 依次遍历 data 中剩余的元素,逐个创建新节点并连接到链表尾部 for i in range(1, len(data)): - node = ListNode(data[i]) - cur.next = node - cur = cur.next + node = ListNode(data[i]) # 创建新节点 + cur.next = node # 将新节点连接到当前尾节点 + cur = cur.next # cur 指向新的尾节点,准备连接下一个节点 ``` -「建立一个线性链表」的操作依赖于线性表的数据元素个数,因此,「建立一个线性链表」的时间复杂度为 $O(n)$,$n$ 为线性表长度。 +「创建链表」的操作需要遍历所有数据元素,时间复杂度为 $O(n)$,其中 $n$ 为线性表的长度。 -### 2.3 求线性链表的长度 +### 2.3 链表长度 -> **求线性链表长度**:使用指针变量 $cur$ 顺着链表 $next$ 指针进行移动,并使用计数器 $count$ 记录元素个数。 -> -> 1. 让指针变量 $cur$ 指向链表的第 $1$ 个链节点。 -> 2. 顺着链节点的 $next$ 指针遍历链表,指针变量 $cur$ 每指向一个链节点,计数器就做一次计数。 -> 3. 等 $cur$ 指向为空时结束遍历,此时计数器的数值就是链表的长度,将其返回即可。 +> **链表长度**:通过一个指针变量 $cur$ 沿着链表的 $next$ 指针逐个遍历节点,并用计数器 $count$ 统计节点数量,最终得到链表长度。 -**「求线性链表长度」** 的代码如下: +具体步骤如下: +1. 令指针 $cur$ 指向链表头节点(第 $1$ 个节点)。 +2. 沿着 $next$ 指针遍历链表,每访问一个节点,计数器 $count$ 加 $1$。 +3. 当 $cur$ 变为 $None$(即遍历到链表末尾)时,遍历结束,此时 $count$ 即为链表长度,返回该值。 + +**「求链表长度」** 的实现代码如下: ```python # 获取线性链表长度 def length(self): - count = 0 - cur = self.head - while cur: - count += 1 - cur = cur.next - return count + count = 0 # 初始化计数器,记录节点个数 + cur = self.head # 从链表头节点开始遍历 + while cur: # 只要当前节点不为 None,就继续遍历 + count += 1 # 每遍历到一个节点,计数器加 1 + cur = cur.next # 指针后移,指向下一个节点 + return count # 返回计数器的值,即链表长度 ``` -「求线性链表长度」的操作依赖于链表的链节点个数,操作的次数为 $n$,因此,「求线性链表长度」的时间复杂度为 $O(n)$,$n$ 为链表长度。 +「求链表长度」的操作需要遍历链表的所有节点,操作次数为 $n$,因此时间复杂度为 $O(n)$,其中 $n$ 为链表长度。 -### 2.4 查找元素 +### 2.4 查找节点 -> **在链表中查找值为 $val$ 的元素**:从头节点 $head$ 开始,沿着链表节点逐一进行查找。如果查找成功,返回被查找节点的地址;否则返回 $None$。 -> -> 1. 让指针变量 $cur$ 指向链表的第 $1$ 个链节点。 -> 2. 顺着链节点的 $next$ 指针遍历链表,如果遇到 $cur.val == val$,则返回当前指针变量 $cur$。 -> 3. 如果 $cur$ 指向为空时也未找到,则该链表中没有值为 $val$ 的元素,则返回 $None$。 +> **链表中查找值为 $val$ 的节点**:从头节点 $head$ 开始,依次遍历链表,查找值等于 $val$ 的节点。如果找到,返回该节点;否则返回 $None$。 -**「在链表中查找值为 $val$ 的元素」** 的代码如下: +具体步骤如下: + +1. 定义指针变量 $cur$,初始指向链表的头节点。 +2. 沿着链表的 $next$ 指针依次遍历每个节点: + - 如果当前节点 $cur$ 的值等于 $val$,则查找成功,返回该节点。 + - 否则,$cur$ 指向下一个节点,继续查找。 +3. 如果遍历完整个链表仍未找到,说明链表中不存在值为 $val$ 的节点,返回 $None$。 + +**「链表中查找值为 $val$ 的节点」** 的实现代码如下: ```python -# 查找元素:在链表中查找值为 val 的元素 +# 链表中查找值为 val 的节点 def find(self, val): - cur = self.head - while cur: - if val == cur.val: - return cur - cur = cur.next + cur = self.head # 从链表头节点开始遍历 + while cur: # 只要当前节点不为 None,就继续遍历 + if val == cur.val: # 如果当前节点的值等于目标值,查找成功 + return cur # 返回当前节点 + cur = cur.next # 指针后移,指向下一个节点 + # 遍历完整个链表都没有找到目标值,返回 None return None ``` -「在链表中查找值为 $val$ 的元素」的操作依赖于链表的链节点个数,因此,「在链表中查找值为 $val$ 的元素」的时间复杂度为 $O(n)$,$n$ 为链表长度。 - -### 2.5 插入元素 - -链表中插入元素操作分为三种: - -- **链表头部插入元素**:在链表第 $1$ 个链节点之前插入值为 $val$ 的链节点。 -- **链表尾部插入元素**:在链表最后 $1$ 个链节点之后插入值为 $val$ 的链节点。 -- **链表中间插入元素**:在链表第 $i$ 个链节点之前插入值为 $val$ 的链节点。 - -接下来我们分别讲解一下。 - -#### 2.5.1 链表头部插入元素 - -> **链表头部插入元素**:在链表第 $1$ 个链节点之前插入值为 $val$ 的链节点。 -> -> 1. 先创建一个值为 $val$ 的链节点 $node$。 -> 2. 然后将 $node$ 的 $next$ 指针指向链表的头节点 $head$。 -> 3. 再将链表的头节点 $head$ 指向 $node$。 - -![链表头部插入元素](https://qcdn.itcharge.cn/images/202405092231514.png) - -**「链表头部插入元素」** 的代码如下: - -```python -# 链表头部插入元素 -def insertFront(self, val): - node = ListNode(val) - node.next = self.head - self.head = node -``` - -「链表头部插入元素」的操作与链表的长度无关,因此,「链表头部插入元素」的时间复杂度为 $O(1)$。 - -#### 2.5.2 链表尾部插入元素 - -> **链表尾部插入元素**:在链表最后 $1$ 个链节点之后插入值为 $val$ 的链节点。 -> -> 1. 先创建一个值为 $val$ 的链节点 $node$。 -> 2. 使用指针 $cur$ 指向链表的头节点 $head$。 -> 3. 通过链节点的 $next$ 指针移动 $cur$ 指针,从而遍历链表,直到 $cur.next$ 为 $None$。 -> 4. 令 $cur.next$ 指向将新的链节点 $node$。 - -![链表尾部插入元素](https://qcdn.itcharge.cn/images/202405092232023.png) +「链表中查找值为 $val$ 的节点」需要遍历链表的所有节点,因此其时间复杂度为 $O(n)$,其中 $n$ 表示链表的长度。 -**「链表尾部插入元素」** 的代码如下: +### 2.5 插入节点 -```python -# 链表尾部插入元素 -def insertRear(self, val): - node = ListNode(val) - cur = self.head - while cur.next: - cur = cur.next - cur.next = node -``` +- **插入节点**:在链表的第 $i$ 个位置前插入一个值为 $val$ 的新节点。 -「链表尾部插入元素」的操作需要将 $cur$ 从链表头部移动到尾部,操作次数是 $n$ 次,因此,「链表尾部插入元素」的时间复杂度是 $O(n)$。 +具体步骤如下: -#### 2.5.3 链表中间插入元素 +1. 定义指针变量 $cur$,初始指向链表头节点,同时定义计数器 $count$,初始值为 $0$。 +2. 沿着链表的 $next$ 指针遍历,$cur$ 每指向一个节点,$count$ 加 $1$。 +3. 当 $count$ 等于 $index - 1$ 时,$cur$ 正好指向第 $index - 1$ 个节点(即新节点的前驱节点),此时停止遍历。 +4. 创建一个新节点 $node$,其值为 $val$。 +5. 将 $node.next$ 指向 $cur.next$,即新节点的后继为原本的第 $index$ 个节点。 +6. 将 $cur.next$ 指向 $node$,完成插入操作。 -> **链表中间插入元素**:在链表第 $i$ 个链节点之前插入值为 $val$ 的链节点。 -> -> 1. 使用指针变量 $cur$ 和一个计数器 $count$。令 $cur$ 指向链表的头节点,$count$ 初始值赋值为 $0$。 -> 2. 沿着链节点的 $next$ 指针遍历链表,指针变量 $cur$ 每指向一个链节点,计数器就做一次计数。 -> 3. 当遍历到第 $index - 1$ 个链节点时停止遍历。 -> 4. 创建一个值为 $val$ 的链节点 $node$。 -> 5. 将 $node.next$ 指向 $cur.next$。 -> 6. 然后令 $cur.next$ 指向 $node$。 +> 注意:如果 $index = 1$,即在头节点前插入,需要特殊处理(如使用虚拟头节点或单独判断)。 -![链表中间插入元素](https://qcdn.itcharge.cn/images/202405092232900.png) +![插入节点](https://qcdn.itcharge.cn/images/202405092232900.png) -**「链表中间插入元素」** 的代码如下: +**「插入节点」** 的实现代码如下: ```python -# 链表中间插入元素 +# 插入节点 def insertInside(self, index, val): + # 头部插入(index == 1) + if index == 1: + node = ListNode(val) + node.next = self.head + self.head = node + return + count = 0 cur = self.head + # 遍历链表,找到第 index - 1 个节点(即新节点的前驱节点) while cur and count < index - 1: - count += 1 cur = cur.next - + count += 1 + + # 如果遍历到链表末尾还没找到前驱节点,说明 index 越界,插入失败 if not cur: return 'Error' - + node = ListNode(val) - node.next = cur.next - cur.next = node + # 尾部插入(index 指向最后一个节点的下一个位置) + if cur.next is None: + cur.next = node + else: + node.next = cur.next + cur.next = node ``` -「链表中间插入元素」的操作需要将 $cur$ 从链表头部移动到第 $i$ 个链节点之前,操作的平均时间复杂度是 $O(n)$,因此,「链表中间插入元素」的时间复杂度是 $O(n)$。 +「插入节点」操作需要将指针 $cur$ 从链表头部遍历到第 $i$ 个节点的前一个位置,平均时间复杂度为 $O(n)$,因此整体的时间复杂度为 $O(n)$。 -### 2.6 改变元素 +### 2.6 改变节点 -> **将链表中第 $i$ 个元素值改为 $val$**:首先要先遍历到第 $i$ 个链节点,然后直接更改第 $i$ 个链节点的元素值。具体做法如下: +> **将链表中第 $i$ 个节点的值修改为 $val$**:只需遍历到第 $i$ 个节点,然后直接修改该节点的值。具体步骤如下: > -> 1. 使用指针变量 $cur$ 和一个计数器 $count$。令 $cur$ 指向链表的头节点,$count$ 初始值赋值为 $0$。 -> 2. 沿着链节点的 $next$ 指针遍历链表,指针变量 $cur$ 每指向一个链节点,计数器就做一次计数。 -> 3. 当遍历到第 $index$ 个链节点时停止遍历。 -> 4. 直接更改 $cur$ 的值 $val$。 +1. 定义指针变量 $cur$ 指向链表头节点,并设置计数器 $count$,初始为 $0$。 +2. 沿着 $next$ 指针遍历链表,每遍历一个节点,$count$ 加 $1$。 +3. 当 $count$ 等于 $index$ 时,$cur$ 正好指向第 $i$ 个节点,停止遍历。 +4. 直接将 $cur$ 的值设为 $val$。 -**「将链表中第 $i$ 个元素值改为 $val$」** 的代码如下: +**「将链表中第 $i$ 个节点的值修改为 $val$」** 的实现代码如下: ```python # 改变元素:将链表中第 i 个元素值改为 val def change(self, index, val): + # 初始化计数器 count 和指针 cur,cur 指向链表头节点 count = 0 cur = self.head + # 遍历链表,直到找到第 index 个节点 while cur and count < index: count += 1 cur = cur.next + # 如果 cur 为空,说明 index 越界,返回错误 if not cur: return 'Error' - + + # 修改第 index 个节点的值为 val cur.val = val ``` -「将链表中第 $i$ 个元素值改为 $val$」需要将 $cur$ 从链表头部移动到第 $i$ 个链节点,操作的平均时间复杂度是 $O(n)$,因此,「将链表中第 $i$ 个元素值改为 $val$」的时间复杂度是 $O(n)$。 +要将链表中第 $i$ 个节点的值修改为 $val$,需要从链表头节点出发,遍历到第 $i$ 个节点,然后进行赋值操作。由于遍历链表的时间复杂度为 $O(n)$,因此该操作的整体时间复杂度为 $O(n)$。 ### 2.7 删除元素 -链表的删除元素操作与链表的查找元素操作一样,同样分为三种情况: - -- **链表头部删除元素**:删除链表的第 $1$ 个链节点。 -- **链表尾部删除元素**:删除链表末尾最后 $1$ 个链节点。 -- **链表中间删除元素**:删除链表第 $i$ 个链节点。 - -接下来我们分别讲解一下。 - -#### 2.7.1 链表头部删除元素 - -> **链表头部删除元素**:删除链表的第 $1$ 个链节点。 -> -> 1. 直接将 $self.head$ 沿着 $next$ 指针向右移动一步即可。 - -![链表头部删除元素](https://qcdn.itcharge.cn/images/202405092231281.png) +> **删除元素**:删除链表中第 $i$ 个节点。 -**「链表头部删除元素」** 的代码如下: +具体步骤如下: -```python -# 链表头部删除元素 -def removeFront(self): - if self.head: - self.head = self.head.next -``` - -「链表头部删除元」只涉及到 $1$ 步移动操作,因此,「链表头部删除元素」的时间复杂度为 $O(1)$。 - -#### 2.7.2 链表尾部删除元素 - -> **链表尾部删除元素**:删除链表末尾最后 $1$ 个链节点。 -> -> 1. 先使用指针变量 $cur$ 沿着 $next$ 指针移动到倒数第 $2$ 个链节点。 -> 2. 然后将此节点的 $next$ 指针指向 $None$ 即可。 +1. 使用指针变量 $cur$ 遍历至第 $i - 1$ 个节点(即待删除节点的前驱)。 +2. 将 $cur$ 的 $next$ 指针指向第 $i$ 个节点的下一个节点,从而跳过并移除第 $i$ 个节点。 -![链表尾部删除元素](https://qcdn.itcharge.cn/images/202405092232050.png) +![删除元素](https://qcdn.itcharge.cn/images/202405092233332.png) -**「链表尾部删除元素」** 的代码如下: +**「删除元素」** 的实现代码如下: ```python -# 链表尾部删除元素 -def removeRear(self): - if not self.head or not self.head.next: - return 'Error' - - cur = self.head - while cur.next.next: - cur = cur.next - cur.next = None -``` - -「链表尾部删除元素」的操作涉及到移动到链表尾部,操作次数为 $n - 2$ 次,因此,「链表尾部删除元素」的时间复杂度为 $O(n)$。 - -#### 2.7.3 链表中间删除元素 - -> **链表中间删除元素**:删除链表第 $i$ 个链节点。 -> -> 1. 先使用指针变量 $cur$ 移动到第 $i - 1$ 个位置的链节点。 -> 2. 然后将 $cur$ 的 $next$ 指针,指向要第 $i$ 个元素的下一个节点即可。 - -![链表中间删除元素](https://qcdn.itcharge.cn/images/202405092233332.png) - -**「链表中间删除元素」** 的代码如下: - -```python -# 链表中间删除元素 +# 链表删除元素 def removeInside(self, index): + # 初始化计数器 count 和指针 cur,cur 指向链表头节点 count = 0 cur = self.head - + + # 遍历链表,cur 移动到第 index - 1 个节点(即待删除节点的前驱) while cur.next and count < index - 1: count += 1 cur = cur.next - + + # 如果 cur 为空,说明 index 越界,返回错误 if not cur: return 'Error' - + + # del_node 指向待删除的节点 del_node = cur.next + # 将 cur 的 next 指针指向 del_node 的下一个节点,实现删除 cur.next = del_node.next ``` -「链表中间删除元素」的操作需要将 $cur$ 从链表头部移动到第 $i$ 个链节点之前,操作的平均时间复杂度是 $O(n)$,因此,「链表中间删除元素」的时间复杂度是 $O(n)$。 +「删除元素」操作需要将指针 $cur$ 从链表头节点遍历至第 $i$ 个节点的前一个节点,因此其时间复杂度为 $O(n)$。 ---- +## 3. 总结 -到这里,有关链表的基础知识就介绍完了。下面进行一下总结。 +### 3.1 链表特点 -## 3. 总结 +链表是一种**链式存储**的线性表数据结构,具有以下核心特征: + +- **存储方式**:通过指针连接任意存储单元,物理存储非连续 +- **节点结构**:每个节点包含数据域和指针域 +- **访问方式**:只能顺序访问,不支持随机访问 + +### 3.2 链表类型 + +| 类型 | 特点 | 适用场景 | +|------|------|----------| +| **单链表** | 每个节点只有一个后继指针 | 基础链表操作 | +| **双向链表** | 每个节点有前驱和后继指针 | 需要双向遍历 | +| **循环链表** | 尾节点指向头节点形成环 | 循环访问场景 | + +### 3.3 基本操作复杂度 + +| 操作 | 时间复杂度 | 空间复杂度 | 说明 | +|------|------------|------------|------| +| **查找** | $O(n)$ | $O(1)$ | 需要遍历到目标位置 | +| **插入** | $O(n)$ | $O(1)$ | 找到插入位置后操作简单 | +| **删除** | $O(n)$ | $O(1)$ | 找到删除位置后操作简单 | +| **修改** | $O(n)$ | $O(1)$ | 需要遍历到目标位置 | + +### 3.4 链表 vs 数组 -链表是最基础、最简单的数据结构。**「链表」** 是实现线性表的链式存储结构的基础。它使用一组任意的存储单元(可以是连续的,也可以是不连续的),来存储一组具有相同类型的数据。 +| 特性 | 链表 | 数组 | +|------|------|------| +| **存储方式** | 链式存储,非连续 | 顺序存储,连续 | +| **随机访问** | 不支持,$O(n)$ | 支持,$O(1)$ | +| **插入删除** | 高效,$O(1)$(已知位置) | 需要移动元素,$O(n)$ | +| **空间开销** | 额外指针开销 | 无额外开销 | +| **内存分配** | 动态分配 | 静态分配 | -链表最大的优点在于可以灵活的添加和删除元素。 +### 3.5 应用场景 -- 链表进行访问元素、改变元素操作的时间复杂度为 $O(n)$。 -- 链表进行头部插入、头部删除元素操作的时间复杂度是 $O(1)$。 -- 链表进行尾部插入、尾部删除操作的时间复杂度是 $O(n)$。 -- 链表在普通情况下进行插入、删除元素操作的时间复杂度为 $O(n)$。 +- **频繁插入删除**:链表在插入删除操作上比数组更高效 +- **动态内存管理**:适合内存大小不确定的场景 +- **实现其他数据结构**:栈、队列、哈希表等的基础 +- **算法优化**:某些算法中链表结构能提供更好的性能 ## 练习题目 diff --git a/docs/02_linked_list/02_02_linked_list_sort.md b/docs/02_linked_list/02_02_linked_list_sort.md index b566bb44..4aae2e4a 100644 --- a/docs/02_linked_list/02_02_linked_list_sort.md +++ b/docs/02_linked_list/02_02_linked_list_sort.md @@ -1,45 +1,51 @@ ## 1. 链表排序简介 -在数组排序中,常见的排序算法有:冒泡排序、选择排序、插入排序、希尔排序、归并排序、快速排序、堆排序、计数排序、桶排序、基数排序等。 +链表排序相比数组排序具有独特的挑战性。由于链表 **不支持随机访问**,只能通过 $next$ 指针顺序遍历,这使得某些排序算法在链表上的实现更加复杂。 -而对于链表排序而言,因为链表不支持随机访问,访问链表后面的节点只能依靠 `next` 指针从头部顺序遍历,所以相对于数组排序问题来说,链表排序问题会更加复杂一点。 +### 1.1 算法适用性分析 -下面先来总结一下适合链表排序与不适合链表排序的算法: +| 适用性 | 排序算法 | 说明 | +|--------|----------|------| +| **完全适合** | 冒泡排序、选择排序、插入排序、归并排序、快速排序 | 天然适合链表结构 | +| **需要额外空间** | 计数排序、桶排序、基数排序 | 需要辅助数组,但算法逻辑适合 | +| **不适合** | 希尔排序 | 依赖随机访问,链表无法高效实现 | +| **不推荐** | 堆排序 | 完全二叉树结构用链表存储效率低 | -- 适合链表的排序算法:**冒泡排序**、**选择排序**、**插入排序**、**归并排序**、**快速排序**、**计数排序**、**桶排序**、**基数排序**。 -- 不适合链表的排序算法:**希尔排序**。 -- 可以用于链表排序但不建议使用的排序算法:**堆排序**。 +### 1.2 关键限制因素 -> 希尔排序为什么不适合链表排序? +**希尔排序的限制**:希尔排序需要访问序列中第 $i + gap$ 个元素,链表无法直接跳转,必须从头遍历,时间复杂度从 $O(1)$ 退化为 $O(n)$。 -**希尔排序**:希尔排序中经常涉及到对序列中第 $i + gap$ 的元素进行操作,其中 $gap$ 是希尔排序中当前的步长。而链表不支持随机访问的特性,导致这种操作不适合链表,因而希尔排序算法不适合进行链表排序。 +**堆排序的限制**:堆排序基于完全二叉树结构,数组可以通过下标 $O(1)$ 访问父子节点,而链表需要遍历查找,效率极低。 -> 为什么不建议使用堆排序? -**堆排序**:堆排序所使用的最大堆 / 最小堆结构本质上是一棵完全二叉树。而完全二叉树适合采用顺序存储结构(数组)。因为数组存储的完全二叉树可以很方便的通过下标序号来确定父亲节点和孩子节点,并且可以极大限度的节省存储空间。 +## 2. 常见链表排序算法 -而链表用在存储完全二叉树的时候,因为不支持随机访问的特性,导致其寻找子节点和父亲节点会比较耗时,如果增加指向父亲节点的变量,又会浪费大量存储空间。所以堆排序算法不适合进行链表排序。 +链表排序算法可分为 **比较排序** 和 **非比较排序** 两大类,每种算法都有其适用场景和性能特点。 -如果一定要对链表进行堆排序,则可以使用额外的数组空间表示堆结构。然后将链表中各个节点的值依次添加入堆结构中,对数组进行堆排序。排序后,再按照堆中元素顺序,依次建立链表节点,构建新的链表并返回新链表头节点。 +### 2.1 比较排序算法 -> 需要用到额外的辅助空间进行排序的算法 +| 算法 | 核心思想 | 时间复杂度 | 空间复杂度 | 特点 | +|------|----------|------------|------------|------| +| **冒泡排序** | 相邻节点比较交换 | $O(n^2)$ | $O(1)$ | 实现简单,适合小规模数据 | +| **选择排序** | 选择最小节点交换 | $O(n^2)$ | $O(1)$ | 交换次数少,适合交换成本高的场景 | +| **插入排序** | 逐个插入到有序序列 | $O(n^2)$ | $O(1)$ | 链表上表现优异,适合部分有序数据 | +| **归并排序** | 分治合并 | $O(n \log n)$ | $O(1)$ | 链表上的最优选择,稳定高效 | +| **快速排序** | 基准分割递归 | $O(n \log n)$ | $O(1)$ | 平均性能好,但最坏情况 $O(n^2)$ | -刚才我们说到如果一定要对链表进行堆排序,则需要使用额外的数组空间。除此之外,计数排序、桶排序、基数排序都需要用到额外的数组空间。 +### 2.2 非比较排序算法 -接下来,我们将对适合链表排序的 8 种算法进行一一讲解。当然,这些排序算法不用完全掌握,重点是掌握 **「链表插入排序」**、**「链表归并排序」** 这两种排序算法。 +| 算法 | 核心思想 | 时间复杂度 | 空间复杂度 | 适用条件 | +|------|----------|------------|------------|----------| +| **计数排序** | 统计频率重建 | $O(n + k)$ | $O(k)$ | 值域范围小,$k$ 为值域大小 | +| **桶排序** | 分桶排序合并 | $O(n)$ | $O(n + m)$ | 数据分布均匀,$m$ 为桶数 | +| **基数排序** | 按位分桶排序 | $O(n \times d)$ | $O(n + k)$ | 数字位数 $d$ 较小 | -## 2. 常见链表排序算法 +### 2.3 算法选择建议 -链表排序常用的方法有冒泡排序、选择排序、插入排序、归并排序、快速排序、计数排序、桶排序、基数排序。 +- **小规模数据**:选择冒泡排序或插入排序 +- **大规模数据**:优先考虑归并排序 +- **特定场景**:根据数据特征选择相应的非比较排序 -- **链表冒泡排序**:每次比较相邻两个节点的值,大的往后移。重复多次,直到链表有序。时间复杂度 $O(n^2)$,空间复杂度 $O(1)$。 -- **链表选择排序**:每次从未排序部分找到最小的节点,和当前节点交换。重复操作,直到链表有序。时间复杂度 $O(n^2)$,空间复杂度 $O(1)$。 -- **链表插入排序**:每次取一个节点,插入到已排序部分的合适位置。不断重复,直到全部节点有序。时间复杂度 $O(n^2)$,空间复杂度 $O(1)$。 -- **链表归并排序**:把链表分成两半,递归排序,再合并。适合链表,时间复杂度 O$(n \log n)$,空间复杂度 $O(1)$。 -- **链表快速排序**:选一个基准值,把小于基准的放左边,大于的放右边。对两边递归排序。时间复杂度 $O(n \log n)$,空间复杂度 $O(1)$。 -- **链表计数排序**:先找最大最小值,用数组统计每个值出现次数,再按顺序重建链表。适合值域不大时用。时间复杂度 $O(n + k)$,空间复杂度 $O(k)$。 -- **链表桶排序**:把节点分到不同的桶里,每个桶内单独排序,再合并所有桶。适合数据分布均匀时用。时间复杂度 $O(n)$,空间复杂度 $O(n + m)$。 -- **链表基数排序**:按个位、十位、百位等分多轮,把节点分到不同的桶里,再合并。适合数字位数不多时用。时间复杂度 $O(n \times k)$,空间复杂度 $O(n + k)$。 ## 参考资料 diff --git a/docs/02_linked_list/02_03_linked_list_bubble_sort.md b/docs/02_linked_list/02_03_linked_list_bubble_sort.md index 4e55fcc8..92721ee7 100644 --- a/docs/02_linked_list/02_03_linked_list_bubble_sort.md +++ b/docs/02_linked_list/02_03_linked_list_bubble_sort.md @@ -1,29 +1,57 @@ -## 1. 链表冒泡排序算法描述 +## 1. 链表冒泡排序算法思想 -1. 使用三个指针 `node_i`、`node_j` 和 `tail`。其中 `node_i` 用于控制外循环次数,循环次数为链节点个数(链表长度)。`node_j` 和 `tail` 用于控制内循环次数和循环结束位置。 -2. 排序开始前,将 `node_i` 、`node_j` 置于头节点位置。`tail` 指向链表末尾,即 `None`。 -3. 比较链表中相邻两个元素 `node_j.val` 与 `node_j.next.val` 的值大小,如果 `node_j.val > node_j.next.val`,则值相互交换。否则不发生交换。然后向右移动 `node_j` 指针,直到 `node_j.next == tail` 时停止。 -4. 一次循环之后,将 `tail` 移动到 `node_j` 所在位置。相当于 `tail` 向左移动了一位。此时 `tail` 节点右侧为链表中最大的链节点。 -5. 然后移动 `node_i` 节点,并将 `node_j` 置于头节点位置。然后重复第 3、4 步操作。 -6. 直到 `node_i` 节点移动到链表末尾停止,排序结束。 -7. 返回链表的头节点 `head`。 +> **链表冒泡排序基本思想**: +> +> **通过相邻节点比较和交换,将最大值逐步「冒泡」到链表末尾**。 -## 2. 链表冒泡排序算法实现代码 +与数组冒泡排序类似,但需要处理链表的指针操作。 + +## 2. 链表冒泡排序算法步骤 + +链表冒泡排序的算法步骤如下: + +1. **外层循环**:控制排序轮数,每轮将当前最大值「冒泡」到末尾 +2. **内层循环**:比较相邻节点,必要时交换值 +3. **尾指针优化**:每轮结束后,末尾已排序部分不再参与比较 + +``` +初始状态:head → 4 → 2 → 1 → 3 → null + ↑ + node_i, node_j + +第1轮内循环: +- 比较 4 和 2:4 > 2,交换 → 2 → 4 → 1 → 3 → null +- 比较 4 和 1:4 > 1,交换 → 2 → 1 → 4 → 3 → null +- 比较 4 和 3:4 > 3,交换 → 2 → 1 → 3 → 4 → null + +第1轮结束:tail 指向 4,4 已排好序 +第2轮内循环:只在 2 → 1 → 3 范围内比较 +... +``` + +## 3. 链表冒泡排序实现代码 ```python class Solution: def bubbleSort(self, head: ListNode): + if not head or not head.next: + return head + + # 外层循环:控制排序轮数 node_i = head - tail = None - # 外层循环次数为 链表节点个数 + tail = None # 尾指针,右侧为已排序部分 + while node_i: - node_j = head + node_j = head # 内层循环指针 + + # 内层循环:比较相邻节点 while node_j and node_j.next != tail: if node_j.val > node_j.next.val: - # 交换两个节点的值 + # 交换相邻节点的值 node_j.val, node_j.next.val = node_j.next.val, node_j.val node_j = node_j.next - # 尾指针向前移动 1 位,此时尾指针右侧为排好序的链表 + + # 更新尾指针,右侧已排好序 tail = node_j node_i = node_i.next @@ -33,21 +61,31 @@ class Solution: return self.bubbleSort(head) ``` -## 3. 链表冒泡排序算法复杂度分析 +## 4. 链表冒泡排序算法分析 -- **时间复杂度**:$O(n^2)$。 -- **空间复杂度**:$O(1)$。 +| 指标 | 复杂度 | 说明 | +|------|--------|------| +| **最佳时间复杂度** | $O(n)$ | 链表已有序,配合“提前终止”优化仅需一趟 | +| **最坏时间复杂度** | $O(n^2)$ | 链表逆序,多轮遍历与相邻节点交换 | +| **平均时间复杂度** | $O(n^2)$ | 一般情况下需要二重循环比较交换 | +| **空间复杂度** | $O(1)$ | 原地排序,仅使用常数个指针变量 | +| **稳定性** | ✅ 稳定 | 相等元素的相对次序保持不变 | -## 4. 总结 -链表冒泡排序使用三个指针进行操作。`node_i` 控制外层循环次数,`node_j` 和 `tail` 控制内层循环。每次比较相邻节点的值,需要交换时就交换。每次内循环结束后,最大的节点会移动到链表末尾。 +**适用场景**: -这个算法的时间复杂度是 $O(n^2)$,因为需要进行两层循环。空间复杂度是 $O(1)$,因为只使用了固定数量的指针变量,没有使用额外空间。 +- **小规模数据**:节点数量 < 100 +- **教学演示**:理解排序算法原理 +- **特殊要求**:需要稳定排序且空间受限 -链表冒泡排序适合小规模数据排序。对于大规模数据,其他排序算法可能更高效。实现时需要注意指针移动和节点交换的操作。 +## 5. 总结 -## 练习题目 +链表中的冒泡排序是最简单的链表排序之一,通过相邻节点比较交换实现排序。虽然实现简单,但效率较低。 + +**优点**:实现简单,稳定排序,空间复杂度低 +**缺点**:时间复杂度高,交换次数多 -- [0148. 排序链表](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0100-0199/sort-list.md)(链表冒泡排序会超时,仅做练习) +## 练习题目 +- [0148. 排序链表](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0100-0199/sort-list.md) (链表冒泡排序会超时,仅做练习) - [链表排序题目列表](https://github.com/ITCharge/AlgoNote/tree/main/docs/00_preface/00_06_categories_list.md#%E9%93%BE%E8%A1%A8%E6%8E%92%E5%BA%8F%E9%A2%98%E7%9B%AE) \ No newline at end of file diff --git a/docs/02_linked_list/02_04_linked_list_selection_sort.md b/docs/02_linked_list/02_04_linked_list_selection_sort.md index 33d53773..52b1031c 100644 --- a/docs/02_linked_list/02_04_linked_list_selection_sort.md +++ b/docs/02_linked_list/02_04_linked_list_selection_sort.md @@ -1,46 +1,81 @@ -## 1. 链表选择排序算法描述 +## 1. 链表选择排序算法思想 -1. 使用两个指针 `node_i`、`node_j`。`node_i` 既可以用于控制外循环次数,又可以作为当前未排序链表的第一个链节点位置。 -2. 使用 `min_node` 记录当前未排序链表中值最小的链节点。 -3. 每一趟排序开始时,先令 `min_node = node_i`(即暂时假设链表中 `node_i` 节点为值最小的节点,经过比较后再确定最小值节点位置)。 -4. 然后依次比较未排序链表中 `node_j.val` 与 `min_node.val` 的值大小。如果 `node_j.val < min_node.val`,则更新 `min_node` 为 `node_j`。 -5. 这一趟排序结束时,未排序链表中最小值节点为 `min_node`,如果 `node_i != min_node`,则将 `node_i` 与 `min_node` 值进行交换。如果 `node_i == min_node`,则不用交换。 -6. 排序结束后,继续向右移动 `node_i`,重复上述步骤,在剩余未排序链表中寻找最小的链节点,并与 `node_i` 进行比较和交换,直到 `node_i == None` 或者 `node_i.next == None` 时,停止排序。 -7. 返回链表的头节点 `head`。 +> **链表选择排序基本思想**: +> +> 在未排序部分中找到最小元素,然后将其放到已排序部分的末尾。 -## 2. 链表选择排序实现代码 +## 2. 链表选择排序算法步骤 + +1. **初始化**:使用两个指针 `node_i` 和 `node_j`。`node_i` 指向当前未排序部分的第一个节点,同时也用于控制外循环。 + +2. **寻找最小值**:在未排序部分中,使用 `min_node` 记录值最小的节点。初始时假设 `node_i` 为最小值节点。 + +3. **比较交换**:遍历未排序部分,比较每个节点的值。如果发现更小的值,则更新 `min_node`。 + +4. **交换操作**:一趟排序结束后,如果 `min_node` 不等于 `node_i`,则交换两个节点的值。 + +5. **移动指针**:将 `node_i` 向右移动一位,继续处理剩余未排序部分。 + +6. **终止条件**:当 `node_i` 为 `None` 或 `node_i.next` 为 `None` 时,排序完成。 + +## 3. 链表选择排序实现代码 ```python class Solution: - def sectionSort(self, head: ListNode): + def selectionSort(self, head: ListNode): node_i = head - # node_i 为当前未排序链表的第一个链节点 + + # 外层循环:遍历每个节点 while node_i and node_i.next: - # min_node 为未排序链表中的值最小节点 + # 假设当前节点为最小值节点 min_node = node_i node_j = node_i.next + + # 内层循环:在未排序部分寻找最小值 while node_j: if node_j.val < min_node.val: min_node = node_j node_j = node_j.next - # 交换值最小节点与未排序链表中第一个节点的值 + + # 如果找到更小的值,则交换 if node_i != min_node: node_i.val, min_node.val = min_node.val, node_i.val + + # 移动到下一个节点 node_i = node_i.next return head def sortList(self, head: Optional[ListNode]) -> Optional[ListNode]: - return self.sectionSort(head) + return self.selectionSort(head) ``` -## 3. 链表选择排序算法复杂度分析 +## 4. 链表选择排序算法复杂度分析 + +| 指标 | 复杂度 | 说明 | +|------|--------|------| +| **最佳时间复杂度** | $O(n^2)$ | 无论初始顺序如何,都需 $O(n^2)$ 次比较 | +| **最坏时间复杂度** | $O(n^2)$ | 无论初始顺序如何,都需 $O(n^2)$ 次比较 | +| **平均时间复杂度** | $O(n^2)$ | 比较次数与数据状态无关 | +| **空间复杂度** | $O(1)$ | 原地排序,仅使用常数额外空间 | +| **稳定性** | ❌ 不稳定 | 可能改变相等节点的相对次序 | + +**适用场景**: + +- **小规模数据**:节点数量较少的场景(如 < 100) +- **对空间复杂度严格**:仅使用常数额外空间的需求 +- **交换代价高的场景**:选择排序交换次数少(最多 \(n-1\) 次) + + +## 5. 总结 + +链表中的选择排序是一种简单直观的链表排序算法,通过在未排序部分中选择最小节点并将其放到已排序部分的末尾来完成排序。虽然实现简单,但效率较低。 -- **时间复杂度**:$O(n^2)$。 -- **空间复杂度**:$O(1)$。 +**优点**:实现简单,空间复杂度低,交换次数少 +**缺点**:时间复杂度高,不稳定,不适合大规模数据 ## 练习题目 -- [0148. 排序链表](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0100-0199/sort-list.md)(链表选择排序会超时,仅做练习) +- [0148. 排序链表](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0100-0199/sort-list.md)(链表选择排序会超时,仅做练习) - [链表排序题目列表](https://github.com/ITCharge/AlgoNote/tree/main/docs/00_preface/00_06_categories_list.md#%E9%93%BE%E8%A1%A8%E6%8E%92%E5%BA%8F%E9%A2%98%E7%9B%AE) \ No newline at end of file diff --git a/docs/02_linked_list/02_05_linked_list_insertion_sort.md b/docs/02_linked_list/02_05_linked_list_insertion_sort.md index 4041a6d0..5e67572e 100644 --- a/docs/02_linked_list/02_05_linked_list_insertion_sort.md +++ b/docs/02_linked_list/02_05_linked_list_insertion_sort.md @@ -1,17 +1,38 @@ -## 1. 链表插入排序算法描述 +## 1. 链表插入排序基本思想 -1. 先使用哑节点 `dummy_head` 构造一个指向 `head` 的指针,使得可以从 `head` 开始遍历。 -2. 维护 `sorted_list` 为链表的已排序部分的最后一个节点,初始时,`sorted_list = head`。 -3. 维护 `prev` 为插入元素位置的前一个节点,维护 `cur` 为待插入元素。初始时,`prev = head`,`cur = head.next`。 -4. 比较 `sorted_list` 和 `cur` 的节点值。 +> **链表插入排序基本思想**: +> +> 将链表分为已排序部分和未排序部分,逐个将未排序部分的节点插入到已排序部分的正确位置**。 - - 如果 `sorted_list.val <= cur.val`,说明 `cur` 应该插入到 `sorted_list` 之后,则将 `sorted_list` 后移一位。 - - 如果 `sorted_list.val > cur.val`,说明 `cur` 应该插入到 `head` 与 `sorted_list` 之间。则使用 `prev` 从 `head` 开始遍历,直到找到插入 `cur` 的位置的前一个节点位置。然后将 `cur` 插入。 +链表插入排序的算法步骤如下: -5. 令 `cur = sorted_list.next`,此时 `cur` 为下一个待插入元素。 -6. 重复 4、5 步骤,直到 `cur` 遍历结束为空。返回 `dummy_head` 的下一个节点。 +1. **初始化**: + - 创建哑节点 `dummy_head`,指向链表头 `head` + - 设置 `sorted_tail` 为已排序部分的尾节点,初始为 `head` + - 设置 `cur` 为当前待插入节点,初始为 `head.next` + +2. **插入过程**: + - 如果 `cur.val >= sorted_tail.val`:说明 `cur` 已经在正确位置,将 `sorted_tail` 后移 + - 如果 `cur.val < sorted_tail.val`:需要将 `cur` 插入到已排序部分的合适位置 + - 初始化 `prev = dummy_head`,用于在已排序部分中寻找插入位置 + - 使用 `while prev.next.val <= cur.val` 循环,让 `prev` 移动到第一个大于 `cur.val` 的节点的前一个位置 + - 执行插入操作: + - `sorted_tail.next = cur.next`:从原位置移除 `cur` + - `cur.next = prev.next`:`cur` 指向 `prev` 的下一个节点 + - `prev.next = cur`:`prev` 指向 `cur`,完成插入 + +3. **更新指针**: + - 更新 `cur` 为下一个待插入节点:`cur = sorted_tail.next` + - 重复步骤2,直到所有节点都处理完毕 + +### 关键理解: +- 已排序部分始终是有序的 +- 每次插入后,已排序部分长度+1,未排序部分长度-1 +- 插入操作需要维护链表的前后连接关系 +- **`prev` 的作用**:在已排序部分中寻找插入位置,它始终指向要插入位置的前一个节点 +- **`prev` 的移动规律**:通过 `prev.next.val <= cur.val` 的条件,`prev` 会移动到第一个大于 `cur.val` 的节点的前一个位置 ## 2. 链表插入排序实现代码 @@ -21,24 +42,33 @@ class Solution: if not head or not head.next: return head + # 创建哑节点,简化边界情况处理 dummy_head = ListNode(-1) dummy_head.next = head - sorted_list = head + + # sorted_tail: 已排序部分的尾节点 + # cur: 当前待插入的节点 + sorted_tail = head cur = head.next while cur: - if sorted_list.val <= cur.val: - # 将 cur 插入到 sorted_list 之后 - sorted_list = sorted_list.next + if sorted_tail.val <= cur.val: + # cur 已经在正确位置,扩展已排序部分 + sorted_tail = sorted_tail.next else: + # 需要插入 cur 到已排序部分的合适位置 prev = dummy_head + # 找到插入位置:第一个大于 cur.val 的节点的前一个位置 while prev.next.val <= cur.val: prev = prev.next - # 将 cur 到链表中间 - sorted_list.next = cur.next - cur.next = prev.next - prev.next = cur - cur = sorted_list.next + + # 执行插入操作 + sorted_tail.next = cur.next # 从原位置移除 cur + cur.next = prev.next # cur 指向下一个节点 + prev.next = cur # 前一个节点指向 cur + + # 移动到下一个待插入节点 + cur = sorted_tail.next return dummy_head.next @@ -48,8 +78,26 @@ class Solution: ## 3. 链表插入排序算法复杂度分析 -- **时间复杂度**:$O(n^2)$。 -- **空间复杂度**:$O(1)$。 +| 指标 | 复杂度 | 说明 | +|------|--------|------| +| **最佳时间复杂度** | $O(n)$ | 链表基本有序,仅线性扫描与少量移动 | +| **最坏时间复杂度** | $O(n^2)$ | 链表逆序,每次插入需线性查找插入位置 | +| **平均时间复杂度** | $O(n^2)$ | 一般情况下多次线性插入 | +| **空间复杂度** | $O(1)$ | 原地排序,仅使用常数个指针变量 | +| **稳定性** | ✅ 稳定 | 相等节点的相对次序保持不变 | + +**适用场景**: +- 链表长度较小(通常 < 1000) +- 链表基本有序的情况 +- 需要稳定排序的场景 +- 作为教学或理解插入排序原理的练习 + +## 4. 总结 + +链表插入排序通过将未排序节点插入到已排序部分的正确位置完成排序。对近乎有序的链表表现较好,但总体效率不高。 + +**优点**:实现简单,稳定排序,空间复杂度低,适合近乎有序数据 +**缺点**:平均/最坏时间复杂度高,不适合大规模数据 ## 练习题目 diff --git a/docs/02_linked_list/02_06_linked_list_merge_sort.md b/docs/02_linked_list/02_06_linked_list_merge_sort.md index 3a3a4edd..643c8f61 100644 --- a/docs/02_linked_list/02_06_linked_list_merge_sort.md +++ b/docs/02_linked_list/02_06_linked_list_merge_sort.md @@ -1,22 +1,29 @@ -## 1. 链表归并排序算法描述 +## 1. 链表归并算法基本思想 -1. **分割环节**:找到链表中心链节点,从中心节点将链表断开,并递归进行分割。 - 1. 使用快慢指针 `fast = head.next`、`slow = head`,让 `fast` 每次移动 `2` 步,`slow` 移动 `1` 步,移动到链表末尾,从而找到链表中心链节点,即 `slow`。 - 2. 从中心位置将链表从中心位置分为左右两个链表 `left_head` 和 `right_head`,并从中心位置将其断开,即 `slow.next = None`。 - 3. 对左右两个链表分别进行递归分割,直到每个链表中只包含一个链节点。 -2. **归并环节**:将递归后的链表进行两两归并,完成一遍后每个子链表长度加倍。重复进行归并操作,直到得到完整的链表。 +> **链表归并排序基本思想**: +> +> **采用分治策略,将链表递归分割为更小的子链表,然后两两归并得到有序链表**。 + +链表归并排序的算法步骤如下: + + +1. **分割阶段**:找到链表的中间节点,将链表从中间断开,并递归进行分割。 + 1. 使用快慢指针法,`fast = head.next`、`slow = head`,让 `fast` 每次移动 2 步,`slow` 移动 1 步,当 `fast` 到达链表末尾时,`slow` 即为链表的中间节点。 + 2. 从中间位置将链表分为左右两个子链表 `left_head` 和 `right_head`,并从中间位置断开,即 `slow.next = None`。 + 3. 对左右两个子链表分别进行递归分割,直到每个子链表中只包含一个节点。 +2. **归并阶段**:将递归分割后的子链表进行两两归并,完成一遍归并后每个子链表长度加倍。重复进行归并操作,直到得到完整的排序链表。 1. 使用哑节点 `dummy_head` 构造一个头节点,并使用 `cur` 指向 `dummy_head` 用于遍历。 - 2. 比较两个链表头节点 `left` 和 `right` 的值大小。将较小的头节点加入到合并后的链表中,并向后移动该链表的头节点指针。 - 3. 然后重复上一步操作,直到两个链表中出现链表为空的情况。 - 4. 将剩余链表插入到合并后的链表中。 - 5. 将哑节点 `dummy_dead` 的下一个链节点 `dummy_head.next` 作为合并后的头节点返回。 + 2. 比较两个子链表头节点 `left` 和 `right` 的值大小。将较小的头节点加入到合并后的链表中,并向后移动该链表的头节点指针。 + 3. 重复上一步操作,直到其中一个子链表为空。 + 4. 将剩余非空的子链表直接连接到合并后的链表末尾。 + 5. 返回哑节点的下一个节点 `dummy_head.next` 作为合并后的头节点。 ## 2. 链表归并排序实现代码 ```python class Solution: def merge(self, left, right): - # 归并环节 + # 归并阶段 dummy_head = ListNode(-1) cur = dummy_head while left and right: @@ -36,17 +43,17 @@ class Solution: return dummy_head.next def mergeSort(self, head: ListNode): - # 分割环节 + # 分割阶段 if not head or not head.next: return head - # 快慢指针找到中心链节点 + # 快慢指针找到中间节点 slow, fast = head, head.next while fast and fast.next: slow = slow.next fast = fast.next.next - # 断开左右链节点 + # 断开左右子链表 left_head, right_head = head, slow.next slow.next = None @@ -59,8 +66,20 @@ class Solution: ## 3. 链表归并排序算法复杂度分析 -- **时间复杂度**:$O(n \times \log_2n)$。 -- **空间复杂度**:$O(1)$。 +| 指标 | 复杂度 | 说明 | +|------|--------|------| +| **最佳时间复杂度** | $O(n \log n)$ | 每次二分出约 $\log n$ 层;每层线性合并 $n$ 个节点 | +| **最坏时间复杂度** | $O(n \log n)$ | 任何输入都经历 $\log n$ 层 × 每层 $O(n)$ 合并 | +| **平均时间复杂度** | $O(n \log n)$ | 期望同上:$\log n$ 层 × 每层 $O(n)$ | +| **空间复杂度** | $O(\log n)$ | 递归需要约 $\log n$ 层调用栈(迭代自底向上可降到 $O(1)$) | +| **稳定性** | ✅ 稳定 | 归并时相等元素优先取左侧,原相对顺序保留 | + +## 4. 总结 + +链表归并排序采用分治策略,将链表递归拆分后再两两归并得到有序链表,整体效率高且稳定。 + +**优点**:时间复杂度 $O(n\log n)$,稳定排序,适合大规模链表;无需随机访问,天然适配链表结构 +**缺点**:递归实现需要 $O(\log n)$ 栈空间,实现相对复杂,常数因子较高 ## 练习题目 diff --git a/docs/02_linked_list/02_07_linked_list_quick_sort.md b/docs/02_linked_list/02_07_linked_list_quick_sort.md index ad7c755f..81b8e5b4 100644 --- a/docs/02_linked_list/02_07_linked_list_quick_sort.md +++ b/docs/02_linked_list/02_07_linked_list_quick_sort.md @@ -1,55 +1,105 @@ -## 1. 链表快速排序算法描述 +## 1. 链表快速排序基本思想 -1. 从链表中找到一个基准值 `pivot`,这里以头节点为基准值。 -2. 然后通过快慢指针 `node_i`、`node_j` 在链表中移动,使得 `node_i` 之前的节点值都小于基准值,`node_i` 之后的节点值都大于基准值。从而把数组拆分为左右两个部分。 -3. 再对左右两个部分分别重复第二步,直到各个部分只有一个节点,则排序结束。 +> **链表快速排序基本思想**: +> +> 通过选择基准值(pivot)将链表分割为两部分,使得左部分所有节点的值都小于基准值,右部分所有节点的值都大于等于基准值,然后递归地对左右两部分进行排序,最终实现整个链表的有序排列。 + +链表快速排序的核心思想是**分治策略**,具体算法步骤如下: + +1. **选择基准值**:从链表中选择一个基准值 `pivot`,通常选择头节点的值作为基准值。 +2. **分割链表**:通过快慢指针(`node_i`、`node_j`)遍历链表,将链表分割为两部分: + - 左部分:所有节点值都小于基准值 + - 右部分:所有节点值都大于等于基准值 +3. **递归排序**:对分割后的左右两部分分别递归执行快速排序 +4. **合并结果**:当子链表长度小于等于1时,递归结束,最终得到有序链表 ## 2. 链表快速排序实现代码 ```python class Solution: def partition(self, left: ListNode, right: ListNode): - # 左闭右开,区间没有元素或者只有一个元素,直接返回第一个节点 + """ + 分割函数:将链表分割为两部分 + left: 左边界节点(包含) + right: 右边界节点(不包含) + 返回:基准值节点的最终位置 + """ + # 边界条件:区间没有元素或者只有一个元素,直接返回第一个节点 if left == right or left.next == right: return left + # 选择头节点为基准节点 pivot = left.val - # 使用 node_i, node_j 双指针,保证 node_i 之前的节点值都小于基准节点值,node_i 与 node_j 之间的节点值都大于等于基准节点值 + + # 使用快慢指针进行分割 + # node_i: 指向小于基准值的最后一个节点 + # node_j: 遍历指针,寻找小于基准值的节点 node_i, node_j = left, left.next while node_j != right: - # 发现一个小与基准值的元素 + # 发现一个小于基准值的元素 if node_j.val < pivot: - # 因为 node_i 之前节点都小于基准值,所以先将 node_i 向右移动一位(此时 node_i 节点值大于等于基准节点值) + # 将 node_i 向右移动一位 node_i = node_i.next - # 将小于基准值的元素 node_j 与当前 node_i 换位,换位后可以保证 node_i 之前的节点都小于基准节点值 + # 交换 node_i 和 node_j 的值,保证 node_i 之前的节点都小于基准值 node_i.val, node_j.val = node_j.val, node_i.val node_j = node_j.next - # 将基准节点放到正确位置上 + + # 将基准节点放到正确位置上(node_i 位置) node_i.val, left.val = left.val, node_i.val return node_i def quickSort(self, left: ListNode, right: ListNode): + """ + 快速排序主函数 + left: 左边界节点(包含) + right: 右边界节点(不包含) + """ + # 递归终止条件:区间长度小于等于 1 if left == right or left.next == right: return left + + # 分割链表,获取基准值位置 pi = self.partition(left, right) + + # 递归排序左半部分 self.quickSort(left, pi) + # 递归排序右半部分 self.quickSort(pi.next, right) + return left def sortList(self, head: Optional[ListNode]) -> Optional[ListNode]: + """ + 链表排序入口函数 + """ + # 边界条件检查 if not head or not head.next: return head + + # 调用快速排序 return self.quickSort(head, None) ``` ## 3. 链表快速排序算法复杂度分析 -- **时间复杂度**:$O(n \times \log_2n)$。 -- **空间复杂度**:$O(1)$。 +| 指标 | 复杂度 | 说明 | +|------|--------|------| +| **最佳时间复杂度** | $O(n \\log n)$ | 每次等分为两半,递归层数约 $\\log n$ | +| **最坏时间复杂度** | $O(n^2)$ | 已有序/逆序或重复值多,划分极端不均 | +| **平均时间复杂度** | $O(n \\log n)$ | 期望情况下划分较均匀 | +| **空间复杂度** | $O(\\log n)$ | 递归调用栈深度(原地就地分区,无额外数组) | +| **稳定性** | ❌ 不稳定 | 相等节点的相对顺序可能改变 | + +## 4. 总结 + +链表快速排序通过分治与就地分区实现排序,平均效率高,但极端情况下退化明显,且不稳定。 + +**优点**:平均时间复杂度 $O(n\\log n)$,就地分区、空间开销小 +**缺点**:最坏时间复杂度 $O(n^2)$,不稳定,对枢轴选择敏感 ## 练习题目 -- [0148. 排序链表](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0100-0199/sort-list.md)(链表快速排序会超时,仅做练习) +- [0148. 排序链表](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0100-0199/sort-list.md)(链表快速排序会超时,仅做练习) - [链表排序题目列表](https://github.com/ITCharge/AlgoNote/tree/main/docs/00_preface/00_06_categories_list.md#%E9%93%BE%E8%A1%A8%E6%8E%92%E5%BA%8F%E9%A2%98%E7%9B%AE) \ No newline at end of file diff --git a/docs/02_linked_list/02_08_linked_list_counting_sort.md b/docs/02_linked_list/02_08_linked_list_counting_sort.md index 146e40b8..79c5b388 100644 --- a/docs/02_linked_list/02_08_linked_list_counting_sort.md +++ b/docs/02_linked_list/02_08_linked_list_counting_sort.md @@ -1,22 +1,51 @@ -## 1. 链表计数排序算法描述 +## 1. 链表计数排序基本思想 -1. 使用 `cur` 指针遍历一遍链表。找出链表中最大值 `list_max` 和最小值 `list_min`。 -2. 使用数组 `counts` 存储节点出现次数。 -3. 再次使用 `cur` 指针遍历一遍链表。将链表中每个值为 `cur.val` 的节点出现次数,存入数组对应第 `cur.val - list_min` 项中。 -4. 反向填充目标链表: - 1. 建立一个哑节点 `dummy_head`,作为链表的头节点。使用 `cur` 指针指向 `dummy_head`。 - 2. 从小到大遍历一遍数组 `counts`。对于每个 `counts[i] != 0` 的元素建立一个链节点,值为 `i + list_min`,将其插入到 `cur.next` 上。并向右移动 `cur`。同时 `counts[i] -= 1`。直到 `counts[i] == 0` 后继续向后遍历数组 `counts`。 -5. 将哑节点 `dummy_dead` 的下一个链节点 `dummy_head.next` 作为新链表的头节点返回。 +> **计数排序(Counting Sort)基本思想**: +> +> 统计每个元素在序列中出现的次数,然后根据统计结果将元素放回正确的位置。 -## 2. 链表计数排序代码实现 +对于链表结构,计数排序的基本思想是: +1. **统计阶段**:遍历链表,统计每个数值出现的次数 +2. **重构阶段**:根据统计结果,重新构建有序链表 + + + +## 2. 链表计数排序算法步骤 + + +1. **初始化阶段** + - 使用 `cur` 指针遍历一遍链表 + - 找出链表中最大值 `list_max` 和最小值 `list_min` + - 计算数值范围:`size = list_max - list_min + 1` + +2. **计数统计阶段** + - 创建大小为 `size` 的计数数组 `counts`,初始化为 0 + - 再次遍历链表,统计每个数值出现的次数 + - 将计数结果存储在 `counts[cur.val - list_min]` 中 + +3. **重构链表阶段** + - 建立哑节点 `dummy_head` 作为新链表的头节点 + - 使用 `cur` 指针指向 `dummy_head` + - 从小到大遍历计数数组 `counts` + - 对于每个非零计数,创建相应数量的节点,值为 `i + list_min` + - 将新节点插入到当前指针位置,并移动指针 + + +## 3. 链表计数排序代码实现 ```python +class ListNode: + def __init__(self, val=0, next=None): + self.val = val + self.next = next + class Solution: - def countingSort(self, head: ListNode): - if not head: + def countingSort(self, head: ListNode) -> ListNode: + # 边界条件检查 + if not head or not head.next: return head - # 找出链表中最大值 list_max 和最小值 list_min + # 步骤1: 找出链表中最大值和最小值 list_min, list_max = float('inf'), float('-inf') cur = head while cur: @@ -25,35 +54,67 @@ class Solution: if cur.val > list_max: list_max = cur.val cur = cur.next - + + # 计算数值范围 size = list_max - list_min + 1 - counts = [0 for _ in range(size)] + # 步骤2: 创建计数数组并统计每个数值的出现次数 + counts = [0 for _ in range(size)] cur = head while cur: - counts[cur.val - list_min] += 1 + # 将数值映射到计数数组的索引 + index = cur.val - list_min + counts[index] += 1 cur = cur.next - - dummy_head = ListNode(-1) + + # 步骤3: 重构有序链表 + dummy_head = ListNode(-1) # 哑节点,简化头节点操作 cur = dummy_head + + # 遍历计数数组,按顺序重构链表 for i in range(size): - while counts[i]: - cur.next = ListNode(i + list_min) - counts[i] -= 1 + # 对于每个非零计数,创建相应数量的节点 + while counts[i] > 0: + # 创建新节点,值为 i + list_min + new_node = ListNode(i + list_min) + cur.next = new_node cur = cur.next + counts[i] -= 1 + return dummy_head.next - def sortList(self, head: Optional[ListNode]) -> Optional[ListNode]: + def sortList(self, head: ListNode) -> ListNode: + """ + 排序链表的主函数 + + Args: + head: 待排序链表的头节点 + + Returns: + 排序后的链表头节点 + """ return self.countingSort(head) ``` -## 3. 链表计数排序算法复杂度分析 +## 4. 链表计数排序算法分析 + +| 指标 | 复杂度 | 说明 | +|------|--------|------| +| **最佳时间复杂度** | $O(n + k)$ | 遍历链表 n 次 + 遍历计数数组 k 次 | +| **最坏时间复杂度** | $O(n + k)$ | 与初始顺序无关,始终为 n + k | +| **平均时间复杂度** | $O(n + k)$ | 其中 n 为链表长度,k 为值域大小(max - min + 1) | +| **空间复杂度** | $O(k)$ | 需要大小为 k 的计数数组 | +| **稳定性** | ✅ 稳定 | 重构按数值顺序写回,保留相对次序 | + +## 5. 总结 + +链表计数排序通过「统计次数 + 重构链表」完成排序,在值域小且为整数的场景下接近线性时间。 -- **时间复杂度**:$O(n + k)$,其中 $k$ 代表待排序链表中所有元素的值域。 -- **空间复杂度**:$O(k)$。 +**优点**:时间近线性(当 k 较小)、实现简单、稳定排序 +**缺点**:额外空间 $O(k)$,对值域敏感,不适合大值域或稀疏分布 ## 练习题目 - [0148. 排序链表](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0100-0199/sort-list.md) -- [链表排序题目列表](https://github.com/ITCharge/AlgoNote/tree/main/docs/00_preface/00_06_categories_list.md#%E9%93%BE%E8%A1%A8%E6%8E%92%E5%BA%8F%E9%A2%98%E7%9B%AE) \ No newline at end of file +- [链表排序题目列表](https://github.com/ITCharge/AlgoNote/tree/main/docs/00_preface/00_06_categories_list.md#%E9%93%BE%E8%A1%A8%E6%8E%92%E5%BA%8F%E9%A2%98%E7%9B%AE) diff --git a/docs/02_linked_list/02_09_linked_list_bucket_sort.md b/docs/02_linked_list/02_09_linked_list_bucket_sort.md index 15f00c61..740f8e04 100644 --- a/docs/02_linked_list/02_09_linked_list_bucket_sort.md +++ b/docs/02_linked_list/02_09_linked_list_bucket_sort.md @@ -1,35 +1,61 @@ -## 1. 链表桶排序算法描述 +## 1. 链表桶排序算法思想 - 1. 使用 `cur` 指针遍历一遍链表。找出链表中最大值 `list_max` 和最小值 `list_min`。 - 2. 通过 `(最大值 - 最小值) / 每个桶的大小` 计算出桶的个数,即 `bucket_count = (list_max - list_min) // bucket_size + 1` 个桶。 - 3. 定义数组 `buckets` 为桶,桶的个数为 `bucket_count` 个。 - 4. 使用 `cur` 指针再次遍历一遍链表,将每个元素装入对应的桶中。 - 5. 对每个桶内的元素单独排序,可以使用链表插入排序、链表归并排序、链表快速排序等算法。 - 6. 最后按照顺序将桶内的元素拼成新的链表,并返回。 +> **桶排序基本思想**: +> +> 将数据分散到若干个有序的桶中,每个桶内再单独排序,最后按顺序合并所有桶。 -## 2. 链表桶排序代码实现 - ```python +## 2. 链表桶排序算法步骤 + +1. **确定数据范围**:遍历链表找出最大值和最小值。 +2. **计算桶数量**:根据数据范围和桶大小确定桶的个数。 +3. **分配元素到桶**:将每个元素放入对应的桶中。 +4. **桶内排序**:对每个桶内的元素进行排序(使用归并排序等)。 +5. **合并结果**:按桶的顺序将所有元素合并成有序链表。 + +## 3. 链表桶排序代码实现 + +```python class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = next class Solution: - # 将链表节点值 val 添加到对应桶 buckets[index] 中 def insertion(self, buckets, index, val): + """ + 将元素插入到指定桶中(头插法) + + Args: + buckets: 桶数组 + index: 桶的索引 + val: 要插入的值 + """ if not buckets[index]: + # 如果桶为空,直接创建新节点 buckets[index] = ListNode(val) return + # 头插法:新节点插入到桶的头部 node = ListNode(val) node.next = buckets[index] buckets[index] = node - # 归并环节 def merge(self, left, right): - dummy_head = ListNode(-1) + """ + 归并两个有序链表 + + Args: + left: 左链表头节点 + right: 右链表头节点 + + Returns: + 合并后的有序链表头节点 + """ + dummy_head = ListNode(-1) # 虚拟头节点 cur = dummy_head + + # 比较两个链表的节点值,选择较小的加入结果链表 while left and right: if left.val <= right.val: cur.next = left @@ -39,6 +65,7 @@ class Solution: right = right.next cur = cur.next + # 处理剩余节点 if left: cur.next = left elif right: @@ -46,69 +73,112 @@ class Solution: return dummy_head.next - def mergeSort(self, head: ListNode): - # 分割环节 + def mergeSort(self, head): + """ + 对链表进行归并排序 + + Args: + head: 链表头节点 + + Returns: + 排序后的链表头节点 + """ + # 递归终止条件:空链表或单节点 if not head or not head.next: return head - # 快慢指针找到中心链节点 + # 快慢指针找到链表中间位置 slow, fast = head, head.next while fast and fast.next: slow = slow.next fast = fast.next.next - # 断开左右链节点 + # 分割链表为左右两部分 left_head, right_head = head, slow.next slow.next = None - # 归并操作 + # 递归排序左右两部分,然后归并 return self.merge(self.mergeSort(left_head), self.mergeSort(right_head)) - def bucketSort(self, head: ListNode, bucket_size=5): + def bucketSort(self, head, bucket_size=5): + """ + 链表桶排序主函数 + + Args: + head: 待排序的链表头节点 + bucket_size: 每个桶的大小,默认5 + + Returns: + 排序后的链表头节点 + """ if not head: return head - # 找出链表中最大值 list_max 和最小值 list_min + # 第一步:找出链表中的最大值和最小值 list_min, list_max = float('inf'), float('-inf') cur = head while cur: - if cur.val < list_min: - list_min = cur.val - if cur.val > list_max: - list_max = cur.val + list_min = min(list_min, cur.val) + list_max = max(list_max, cur.val) cur = cur.next - # 计算桶的个数,并定义桶 + # 第二步:计算桶的数量并初始化桶数组 bucket_count = (list_max - list_min) // bucket_size + 1 buckets = [None for _ in range(bucket_count)] - # 将链表节点值依次添加到对应桶中 + # 第三步:将链表元素分配到对应的桶中 cur = head while cur: + # 计算元素应该放入哪个桶 index = (cur.val - list_min) // bucket_size self.insertion(buckets, index, cur.val) cur = cur.next + # 第四步:对每个桶内的元素排序,然后合并 dummy_head = ListNode(-1) cur = dummy_head - # 将元素依次出桶,并拼接成有序链表 + for bucket_head in buckets: - bucket_cur = self.mergeSort(bucket_head) - while bucket_cur: - cur.next = bucket_cur - cur = cur.next - bucket_cur = bucket_cur.next + if bucket_head: + # 对桶内元素进行归并排序 + sorted_bucket = self.mergeSort(bucket_head) + # 将排序后的桶内元素添加到结果链表 + while sorted_bucket: + cur.next = sorted_bucket + cur = cur.next + sorted_bucket = sorted_bucket.next return dummy_head.next - def sortList(self, head: Optional[ListNode]) -> Optional[ListNode]: + def sortList(self, head): + """ + 排序链表接口函数 + + Args: + head: 待排序的链表头节点 + + Returns: + 排序后的链表头节点 + """ return self.bucketSort(head) - ``` +``` + +## 4. 链表桶排序算法复杂度分析 + +| 指标 | 复杂度 | 说明 | +|------|--------|------| +| **最佳时间复杂度** | $O(n)$ | 元素均匀分布,各桶很少元素,桶内排序代价小 | +| **最坏时间复杂度** | $O(n^2)$ | 大量元素落入同一桶,桶内排序退化 | +| **平均时间复杂度** | $O(n + k)$ | 遍历元素 n 次 + 遍历 k 个桶(含桶内排序) | +| **空间复杂度** | $O(n + k)$ | 需要桶数组与桶内临时节点空间 | +| **稳定性** | ✅ 稳定 | 桶内使用稳定排序并按桶序合并,保留相对次序 | + +## 5. 总结 -## 3. 链表桶排序算法复杂度分析 +链表桶排序将元素按值域分配到多个桶中,分别排序后再合并。适合值域已知且分布较均匀的场景。 -- **时间复杂度**:$O(n)$。 -- **空间复杂度**:$O(n + m)$。$m$ 为桶的个数。 +**优点**:分布均匀且桶参数合理时可接近线性;可用稳定桶内排序;并行友好 +**缺点**:对分布与桶大小/数量敏感;额外空间 $O(n+k)$;最坏情况可能退化 ## 练习题目 diff --git a/docs/02_linked_list/02_10_linked_list_radix_sort.md b/docs/02_linked_list/02_10_linked_list_radix_sort.md index 8ff70d47..d682036d 100644 --- a/docs/02_linked_list/02_10_linked_list_radix_sort.md +++ b/docs/02_linked_list/02_10_linked_list_radix_sort.md @@ -1,37 +1,45 @@ -## 1. 链表基数排序算法描述 +## 1. 链表基数排序算法思想 -1. 使用 `cur` 指针遍历链表,获取节点值位数最长的位数 `size`。 -2. 从个位到高位遍历位数。因为 `0` ~ `9` 共有 `10` 位数字,所以建立 `10` 个桶。 -3. 以每个节点对应位数上的数字为索引,将节点值放入到对应桶中。 -4. 建立一个哑节点 `dummy_head`,作为链表的头节点。使用 `cur` 指针指向 `dummy_head`。 -5. 将桶中元素依次取出,并根据元素值建立链表节点,并插入到新的链表后面。从而生成新的链表。 -6. 之后依次以十位,百位,…,直到最大值元素的最高位处值为索引,放入到对应桶中,并生成新的链表,最终完成排序。 -7. 将哑节点 `dummy_dead` 的下一个链节点 `dummy_head.next` 作为新链表的头节点返回。 +> **基数排序算法思想**: +> +> 从最低位(个位)开始,按照每一位的数字将节点分配到对应的桶中,然后按顺序重新连接 -## 2. 链表基数排序代码实现 + +## 2. 链表基数排序算法步骤 + +1. **确定最大位数**:遍历链表找出最大数字的位数。 +2. **按位排序**:从个位开始,依次处理每一位。 +3. **分配桶**:根据当前位的数字(0-9)将节点分配到 10 个桶中。 +4. **重新连接**:按桶的顺序重新连接链表。 +5. **重复处理**:处理完所有位后完成排序。 + +## 3. 链表基数排序代码实现 ```python class Solution: def radixSort(self, head: ListNode): - # 计算位数最长的位数 + # 1. 计算最大数字的位数 size = 0 cur = head while cur: val_len = len(str(cur.val)) - if val_len > size: - size = val_len + size = max(size, val_len) cur = cur.next - # 从个位到高位遍历位数 + # 2. 从个位到最高位依次排序 for i in range(size): + # 创建 10 个桶(对应数字 0-9) buckets = [[] for _ in range(10)] cur = head + + # 3. 按当前位数字分配到对应桶 while cur: - # 以每个节点对应位数上的数字为索引,将节点值放入到对应桶中 - buckets[cur.val // (10 ** i) % 10].append(cur.val) + # 获取第 i 位数字:先除以 10^i,再对 10 取余 + digit = (cur.val // (10 ** i)) % 10 + buckets[digit].append(cur.val) cur = cur.next - # 生成新的链表 + # 4. 按桶的顺序重新构建链表 dummy_head = ListNode(-1) cur = dummy_head for bucket in buckets: @@ -46,10 +54,22 @@ class Solution: return self.radixSort(head) ``` -## 3. 链表基数排序算法复杂度分析 +## 4. 链表基数排序算法分析 + +| 指标 | 复杂度 | 说明 | +|------|--------|------| +| **最佳时间复杂度** | $O(n \times k)$ | 每一位都需遍历所有节点,总共 $k$ 位,每位操作 $O(n)$,整体 $O(nk)$ | +| **最坏时间复杂度** | $O(n \times k)$ | 同上;与初始顺序无关 | +| **平均时间复杂度** | $O(n \times k)$ | 同上;$n$ 为节点数,$k$ 为最大位数 | +| **空间复杂度** | $O(n + k)$ | 需要 $n$ 个临时节点/数组及 $k$ 个桶 | +| **稳定性** | ✅ 稳定 | 按桶顺序收集,保持相等值相对次序 | + +## 5. 总结 + +链表基数排序按位进行「分桶 + 收集」,在位数较小、整数键的场景下效率稳定。 -- **时间复杂度**:$O(n \times k)$。其中 $n$ 是待排序元素的个数,$k$ 是数字位数。$k$ 的大小取决于数字位的选择(十进制位、二进制位)和待排序元素所属数据类型全集的大小。 -- **空间复杂度**:$O(n + k)$。 +**优点**:时间复杂度与数据有序度无关,稳定排序,适合固定位数整数 +**缺点**:空间开销较高,仅适用于整数或可映射到位的键 ## 练习题目 diff --git a/docs/02_linked_list/02_11_linked_list_two_pointers.md b/docs/02_linked_list/02_11_linked_list_two_pointers.md index 5163347c..61bca606 100644 --- a/docs/02_linked_list/02_11_linked_list_two_pointers.md +++ b/docs/02_linked_list/02_11_linked_list_two_pointers.md @@ -1,40 +1,51 @@ ## 1. 双指针简介 -在数组双指针中我们已经学习过了双指针的概念。这里再来复习一下。 +双指针是链表问题中非常常用且高效的技巧,通过两个指针的配合移动,能够巧妙地解决许多复杂问题。 -> **双指针(Two Pointers)**:指的是在遍历元素的过程中,不是使用单个指针进行访问,而是使用两个指针进行访问,从而达到相应的目的。如果两个指针方向相反,则称为「对撞时针」。如果两个指针方向相同,则称为「快慢指针」。如果两个指针分别属于不同的数组 / 链表,则称为「分离双指针」。 +> **双指针(Two Pointers)**:指在遍历链表时同时使用两个指针,根据移动方式主要分为以下两类: +> - **快慢指针**:两个指针从同一起点出发,移动速度不同,常用于检测环、寻找中点、定位倒数第 n 个节点等。 +> - **起点不一致**:快指针先行若干步,之后快慢指针同步移动,常用于定位倒数第 n 个节点。 +> - **步长不一致**:快指针每次移动两步,慢指针每次移动一步,常用于判断链表是否有环、寻找中点等。 +> - **分离双指针**:两个指针分别在不同链表或不同起点上独立移动,常用于合并两个有序链表、比较链表节点等场景。 -而在单链表中,因为遍历节点只能顺着 `next` 指针方向进行,所以对于链表而言,一般只会用到「快慢指针」和「分离双指针」。其中链表的「快慢指针」又分为「起点不一致的快慢指针」和「步长不一致的快慢指针」。这几种类型的双指针所解决的问题也各不相同,下面我们一一进行讲解。 +双指针法能够有效降低时间复杂度,减少空间消耗,是解决链表相关问题(如查找、删除、合并等)时非常高效且常用的技巧。 -## 2. 起点不一致的快慢指针 +## 2. 快慢指针(起点不一致) ->**起点不一致的快慢指针**:指的是两个指针从同一侧开始遍历链表,但是两个指针的起点不一样。 快指针 `fast` 比慢指针 `slow` 先走 `n` 步,直到快指针移动到链表尾端时为止。 +> **起点不一致的快慢指针**:快指针先走 n 步,然后两个指针同时移动,快指针到达末尾时,慢指针正好在目标位置。 -### 2.1 起点不一致的快慢指针求解步骤 +### 2.1 求解步骤 -1. 使用两个指针 `slow`、`fast`。`slow`、`fast` 都指向链表的头节点,即:`slow = head`,`fast = head`。 -2. 先将快指针向右移动 `n` 步。然后再同时向右移动快、慢指针。 -3. 等到快指针移动到链表尾部(即 `fast == None`)时跳出循环体。 +1. **初始化**:两个指针 $slow$、$fast$ 都指向头节点。 +2. **快指针先行**:快指针先移动 n 步。 +3. **同步移动**:两个指针同时移动,直到快指针到达末尾(`fast == None`)。 +4. **结果**:慢指针正好指向倒数第 n 个节点。 -### 2.2 起点不一致的快慢指针伪代码模板 +### 2.2 代码模板 ```python -slow = head -fast = head - -while n: - fast = fast.next - n -= 1 -while fast: - fast = fast.next - slow = slow.next +def findNthFromEnd(head, n): + slow = fast = head + + # 快指针先走 n 步 + for _ in range(n): + fast = fast.next + + # 两个指针同时移动 + while fast: + slow = slow.next + fast = fast.next + + return slow # 慢指针指向倒数第 n 个节点 ``` -### 2.3 起点不一致的快慢指针适用范围 +### 2.3 适用场景 -起点不一致的快慢指针主要用于找到链表中倒数第 k 个节点、删除链表倒数第 N 个节点等。 +- 找到链表中倒数第 k 个节点 +- 删除链表倒数第 N 个节点 +- 其他需要定位倒数位置的问题 -### 2.4 删除链表的倒数第 N 个结点 +### 2.4 经典例题:删除链表的倒数第 N 个结点 #### 2.4.1 题目链接 @@ -75,57 +86,70 @@ while fast: 如果用一次遍历实现的话,可以使用快慢指针。让快指针先走 `n` 步,然后快慢指针、慢指针再同时走,每次一步,这样等快指针遍历到链表尾部的时候,慢指针就刚好遍历到了倒数第 `n` 个节点位置。将该位置上的节点删除即可。 -需要注意的是要删除的节点可能包含了头节点。我们可以考虑在遍历之前,新建一个头节点,让其指向原来的头节点。这样,最终如果删除的是头节点,则删除原头节点即可。返回结果的时候,可以直接返回新建头节点的下一位节点。 ##### 思路 1:代码 ```python class Solution: def removeNthFromEnd(self, head: ListNode, n: int) -> ListNode: - newHead = ListNode(0, head) + # 创建虚拟头节点,简化边界情况处理 + dummy = ListNode(0, head) + slow = dummy fast = head - slow = newHead - while n: + + # 快指针先走 n 步 + for _ in range(n): fast = fast.next - n -= 1 + + # 两个指针同时移动 while fast: - fast = fast.next slow = slow.next + fast = fast.next + + # 删除目标节点(slow.next 是要删除的节点) slow.next = slow.next.next - return newHead.next + + return dummy.next ``` ##### 思路 1:复杂度分析 -- **时间复杂度**:$O(n)$。 -- **空间复杂度**:$O(1)$。 +- **时间复杂度**:$O(n)$,只遍历一次 +- **空间复杂度**:O(1),只使用常数额外空间 -## 3. 步长不一致的快慢指针 +## 3. 快慢指针(步长不一致) -> **步长不一致的快慢指针**:指的是两个指针从同一侧开始遍历链表,两个指针的起点一样,但是步长不一致。例如,慢指针 `slow` 每次走 `1` 步,快指针 `fast` 每次走两步。直到快指针移动到链表尾端时为止。 +> **步长不一致的快慢指针**:两个指针从同一起点出发,慢指针每次走 1 步,快指针每次走 2 步。 -### 3.1 步长不一致的快慢指针求解步骤 +### 3.1 求解步骤 -1. 使用两个指针 `slow`、`fast`。`slow`、`fast` 都指向链表的头节点。 -2. 在循环体中将快、慢指针同时向右移动,但是快、慢指针的移动步长不一致。比如将慢指针每次移动 `1` 步,即 `slow = slow.next`。快指针每次移动 `2` 步,即 `fast = fast.next.next`。 -3. 等到快指针移动到链表尾部(即 `fast == None`)时跳出循环体。 +1. **初始化**:两个指针都指向头节点。 +2. **不同步长**:慢指针每次移动 1 步,快指针每次移动 2 步。 +3. **终止条件**:快指针到达末尾或无法继续移动。 +4. **应用场景**:找中点、检测环、找交点等。 -### 3.2 步长不一致的快慢指针伪代码模板 +### 3.2 代码模板 ```python -fast = head -slow = head - -while fast and fast.next: - slow = slow.next - fast = fast.next.next +def fastSlowPointer(head): + slow = fast = head + + # 快指针每次走 2 步,慢指针每次走 1 步 + while fast and fast.next: + slow = slow.next # 慢指针移动 1 步 + fast = fast.next.next # 快指针移动 2 步 + + return slow # 慢指针指向中点或环的入口 ``` -### 3.3 步长不一致的快慢指针适用范围 +### 3.3 适用场景 -步长不一致的快慢指针适合寻找链表的中点、判断和检测链表是否有环、找到两个链表的交点等问题。 +- 寻找链表的中点 +- 检测链表是否有环 +- 找到两个链表的交点 +- 其他需要定位中间位置的问题 -### 3.4 链表的中间结点 +### 3.4 经典例题:链表的中间结点 #### 3.4.1 题目链接 @@ -167,16 +191,18 @@ ans.val = 3, ans.next.val = 4, ans.next.next.val = 5, 以及 ans.next.next.next ```python class Solution: def middleNode(self, head: ListNode) -> ListNode: - n = 0 + # 第一次遍历:计算链表长度 + count = 0 curr = head while curr: - n += 1 + count += 1 curr = curr.next - k = 0 + + # 第二次遍历:找到中间位置 curr = head - while k < n // 2: - k += 1 + for _ in range(count // 2): curr = curr.next + return curr ``` @@ -199,11 +225,14 @@ class Solution: ```python class Solution: def middleNode(self, head: ListNode) -> ListNode: - fast = head - slow = head + slow = fast = head + + # 快指针每次走 2 步,慢指针每次走 1 步 + # 当快指针到达末尾时,慢指针正好在中点 while fast and fast.next: - slow = slow.next - fast = fast.next.next + slow = slow.next # 慢指针移动 1 步 + fast = fast.next.next # 快指针移动 2 步 + return slow ``` @@ -212,132 +241,46 @@ class Solution: - **时间复杂度**:$O(n)$。 - **空间复杂度**:$O(1)$。 -### 3.5 判断链表中是否含有环 - -#### 3.5.1 题目链接 - -- [141. 环形链表 - 力扣(LeetCode)](https://leetcode.cn/problems/linked-list-cycle/) - -#### 3.5.2 题目大意 - -**描述**:给定一个链表的头节点 `head`。 - -**要求**:判断链表中是否有环。如果有环则返回 `True`,否则返回 `False`。 - -**说明**: - -- 链表中节点的数目范围是 $[0, 10^4]$。 -- $-10^5 \le Node.val \le 10^5$。 -- `pos` 为 `-1` 或者链表中的一个有效索引。 - -**示例**: - -![](https://assets.leetcode-cn.com/aliyun-lc-upload/uploads/2018/12/07/circularlinkedlist.png) - -```python -输入:head = [3,2,0,-4], pos = 1 -输出:True -解释:链表中有一个环,其尾部连接到第二个节点。 -``` - -![](https://assets.leetcode-cn.com/aliyun-lc-upload/uploads/2018/12/07/circularlinkedlist_test2.png) - -```python -输入:head = [1,2], pos = 0 -输出:True -解释:链表中有一个环,其尾部连接到第一个节点。 -``` - -#### 3.5.3 解题思路 - -##### 思路 1:哈希表 - -最简单的思路是遍历所有节点,每次遍历节点之前,使用哈希表判断该节点是否被访问过。如果访问过就说明存在环,如果没访问过则将该节点添加到哈希表中,继续遍历判断。 - -##### 思路 1:代码 - -```python -class Solution: - def hasCycle(self, head: ListNode) -> bool: - nodeset = set() - - while head: - if head in nodeset: - return True - nodeset.add(head) - head = head.next - return False -``` - -##### 思路 1:复杂度分析 - -- **时间复杂度**:$O(n)$。 -- **空间复杂度**:$O(n)$。 - -##### 思路 2:快慢指针(Floyd 判圈算法) - -这种方法类似于在操场跑道跑步。两个人从同一位置同时出发,如果跑道有环(环形跑道),那么快的一方总能追上慢的一方。 - -基于上边的想法,Floyd 用两个指针,一个慢指针(龟)每次前进一步,快指针(兔)指针每次前进两步(两步或多步效果是等价的)。如果两个指针在链表头节点以外的某一节点相遇(即相等)了,那么说明链表有环,否则,如果(快指针)到达了某个没有后继指针的节点时,那么说明没环。 - -##### 思路 2:代码 - -```python -class Solution: - def hasCycle(self, head: ListNode) -> bool: - if head == None or head.next == None: - return False - - slow = head - fast = head.next - - while slow != fast: - if fast == None or fast.next == None: - return False - slow = slow.next - fast = fast.next.next - - return True -``` - -##### 思路 2:复杂度分析 - -- **时间复杂度**:$O(n)$。 -- **空间复杂度**:$O(1)$。 - ## 4. 分离双指针 -> **分离双指针**:两个指针分别属于不同的链表,两个指针分别在两个链表中移动。 +> **分离双指针**:两个指针分别在不同的链表中移动,常用于合并、比较等操作。 -### 4.1 分离双指针求解步骤 +### 4.1 求解步骤 -1. 使用两个指针 `left_1`、`left_2`。`left_1` 指向第一个链表头节点,即:`left_1 = list1`,`left_2` 指向第二个链表头节点,即:`left_2 = list2`。 -2. 当满足一定条件时,两个指针同时右移,即 `left_1 = left_1.next`、`left_2 = left_2.next`。 -3. 当满足另外一定条件时,将 `left_1` 指针右移,即 `left_1 = left_1.next`。 -4. 当满足其他一定条件时,将 `left_2` 指针右移,即 `left_2 = left_2.next`。 -5. 当其中一个链表遍历完时或者满足其他特殊条件时跳出循环体。 +1. **初始化**:两个指针分别指向两个链表的头节点。 +2. **条件移动**:根据具体问题决定何时移动哪个指针。 +3. **终止条件**:其中一个链表遍历完毕或满足特定条件。 +4. **应用场景**:有序链表合并、链表比较等。 -### 4.2 分离双指针伪代码模板 +### 4.2 代码模板 ```python -left_1 = list1 -left_2 = list2 - -while left_1 and left_2: - if 一定条件 1: - left_1 = left_1.next - left_2 = left_2.next - elif 一定条件 2: - left_1 = left_1.next - elif 一定条件 3: - left_2 = left_2.next +def separateTwoPointers(list1, list2): + p1, p2 = list1, list2 + + while p1 and p2: + if condition1: + # 两个指针同时移动 + p1 = p1.next + p2 = p2.next + elif condition2: + # 只移动第一个指针 + p1 = p1.next + else: + # 只移动第二个指针 + p2 = p2.next + + return result ``` -### 4.3 分离双指针适用范围 +### 4.3 适用场景 -分离双指针一般用于有序链表合并等问题。 +- 合并两个有序链表 +- 比较两个链表 +- 找到两个链表的交点 +- 其他需要同时处理两个链表的问题 -### 4.4 合并两个有序链表 +### 4.4 经典例题:合并两个有序链表 #### 4.4.1 题目链接 @@ -385,21 +328,26 @@ while left_1 and left_2: ```python class Solution: def mergeTwoLists(self, list1: Optional[ListNode], list2: Optional[ListNode]) -> Optional[ListNode]: - dummy_head = ListNode(-1) - - curr = dummy_head + # 创建虚拟头节点,简化操作 + dummy = ListNode(-1) + curr = dummy + + # 分离双指针:分别遍历两个链表 while list1 and list2: if list1.val <= list2.val: + # 选择 list1 的当前节点 curr.next = list1 list1 = list1.next else: + # 选择 list2 的当前节点 curr.next = list2 list2 = list2.next curr = curr.next - - curr.next = list1 if list1 is not None else list2 - - return dummy_head.next + + # 处理剩余节点 + curr.next = list1 if list1 else list2 + + return dummy.next ``` ##### 思路 1:复杂度分析 From 423bcbccc57fb5190bb5dc0ffdd73213d7b05979 Mon Sep 17 00:00:00 2001 From: ITCharge Date: Mon, 1 Sep 2025 17:06:37 +0800 Subject: [PATCH 06/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=92=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=AF=AD=E5=8F=A5=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../03_01_stack_basic.md | 277 ++++++----- .../03_02_monotone_stack.md | 162 +++--- .../03_03_queue_basic.md | 466 ++++++++++-------- .../03_04_priority_queue.md | 390 ++++++--------- .../03_05_bidirectional_queue.md | 393 +++++++++++++++ .../03_06_hash_table.md | 143 +++--- 6 files changed, 1070 insertions(+), 761 deletions(-) diff --git a/docs/03_stack_queue_hash_table/03_01_stack_basic.md b/docs/03_stack_queue_hash_table/03_01_stack_basic.md index 6b081616..0604588e 100644 --- a/docs/03_stack_queue_hash_table/03_01_stack_basic.md +++ b/docs/03_stack_queue_hash_table/03_01_stack_basic.md @@ -1,183 +1,169 @@ ## 1. 栈简介 -> **栈(Stack)**:也叫做「堆栈」,是一种线性表数据结构,是一种只允许在表的一端进行插入和删除操作的线性表。 +> **栈(Stack)**:也叫做「堆栈」,一种线性表数据结构,只允许在表的一端进行插入和删除操作。 -我们把栈中允许插入和删除的一端称为 **「栈顶(top)」**;另一端则称为 **「栈底(bottom)」**。当表中没有任何数据元素时,称之为 **「空栈」**。 +### 1.1 基本概念 -栈有两种基本操作:**「插入操作」** 和 **「删除操作」**。 +我们可以把栈想象成一摞叠放的盘子: -- 栈的插入操作又称为「入栈」或者「进栈」。 -- 栈的删除操作又称为「出栈」或者「退栈」。 +- **栈顶(top)**:可以插入和删除元素的一端,就像盘子堆的最上面。 +- **栈底(bottom)**:固定不动的一端,不能进行操作,就像盘子堆的最下面。 +- **空栈**:栈中没有任何元素时,称为空栈。 -![栈结构](https://qcdn.itcharge.cn/images/202405092243204.png) - -简单来说,栈是一种 **「后进先出(Last In First Out)」** 的线性表,简称为 **「LIFO 结构」**。 - -我们可以从两个方面来解释一下栈的定义: - -- 第一个方面是 **「线性表」**。 - -栈首先是一个线性表,栈中元素具有前驱后继的线性关系。栈中元素按照 $a_1, a_2, ... , a_n$ 的次序依次进栈。栈顶元素为 $a_n$。 - -- 第二个方面是 **「后进先出原则」**。 - -根据栈的定义,每次删除的总是栈中当前的栈顶元素,即最后进入栈的元素。而在进栈时,最先进入栈的元素一定在栈底,最后进入栈的元素一定在栈顶。也就是说,元素进入栈或者退出退栈是按照「后进先出(Last In First Out)」的原则进行的。 - -## 2. 栈的顺序存储与链式存储 - -和线性表类似,栈有两种存储表示方法:**「顺序栈」** 和 **「链式栈」**。 - -- **「顺序栈」**:即栈的顺序存储结构。利用一组地址连续的存储单元依次存放自栈底到栈顶的元素,同时使用指针 $top$ 指示栈顶元素在顺序栈中的位置。 -- **「链式栈」**:即栈的链式存储结构。利用单链表的方式来实现栈。栈中元素按照插入顺序依次插入到链表的第一个节点之前,并使用栈顶指针 $top$ 指示栈顶元素,$top$ 永远指向链表的头节点位置。 - -在描述栈的顺序存储与链式存储具体实现之前,我们先来看看栈具有哪些基本操作。 +### 1.2 核心特性 -### 2.1 栈的基本操作 +栈的操作遵循 **后进先出(LIFO)** 的原则: +- 最后放入栈的元素,最先被取出。 +- 就像叠盘子,最后放上去的盘子,总是最先被拿走。 -栈作为一种线性表来说,理论上应该具备线性表所有的操作特性,但由于「后进先出」的特殊性,所以针对栈的操作进行了一些变化。尤其是插入操作和删除操作,改为了入栈(push)和出栈(pop)。 +### 1.3 基本操作 -栈的基本操作如下: +栈的常见操作有: +- **入栈(Push)**:在栈顶加入一个新元素。 +- **出栈(Pop)**:移除并返回栈顶的元素。 +- **查看栈顶(Peek)**:只查看栈顶元素,但不移除。 -- **初始化空栈**:创建一个空栈,定义栈的大小 $size$,以及栈顶元素指针 $top$。 +下图展示了栈的结构和操作方式: -- **判断栈是否为空**:当栈为空时,返回 $True$。当栈不为空时,返回 $False$。一般只用于栈中删除操作和获取当前栈顶元素操作中。 +![栈结构](https://qcdn.itcharge.cn/images/202405092243204.png) -- **判断栈是否已满**:当栈已满时,返回 $True$,当栈未满时,返回 $False$。一般只用于顺序栈中插入元素和获取当前栈顶元素操作中。 +## 2. 栈的实现方式 -- **插入元素(进栈、入栈)**:相当于在线性表最后元素后面插入一个新的数据元素。并改变栈顶指针 $top$ 的指向位置。 +与线性表类似,栈常见的存储方式有两种:**「顺序栈」** 和 **「链式栈」**。 -- **删除元素(出栈、退栈)**:相当于在线性表最后元素后面删除最后一个数据元素。并改变栈顶指针 $top$ 的指向位置。 -- **获取栈顶元素**:相当于获取线性表中最后一个数据元素。与插入元素、删除元素不同的是,该操作并不改变栈顶指针 $top$ 的指向位置。 +- **顺序栈**:采用一段连续的存储空间(如数组)依次存放从栈底到栈顶的元素,并通过指针 $top$ 标记当前栈顶元素在数组中的位置。 +- **链式栈**:采用单链表实现,每次新元素都插入到链表头部,$top$ 始终指向链表的头节点,即栈顶元素的位置。 -接下来我们来看一下栈的顺序存储与链式存储两种不同的实现方式。 +### 2.1 顺序栈(数组实现) -### 2.2 栈的顺序存储实现 +栈的最常见实现方式是利用数组来构建顺序存储结构。在 Python 中,可以直接使用列表(list)来实现顺序栈。 -栈最简单的实现方式就是借助于一个数组来描述栈的顺序存储结构。在 Python 中我们可以借助列表 $list$ 来实现。这种采用顺序存储结构的栈也被称为 **「顺序栈」**。 +这种基于顺序存储的栈结构,通常被称为 **「顺序栈」**。 -#### 2.2.1 栈的顺序存储基本描述 +#### 2.1.1 顺序栈的基本描述 ![栈的顺序存储](https://qcdn.itcharge.cn/images/202405092243306.png) -我们约定 $self.top$ 指向栈顶元素所在位置。 +我们约定 $self.top$ 指向当前栈顶元素的位置。 -- **初始化空栈**:使用列表创建一个空栈,定义栈的大小 $self.size$,并令栈顶元素指针 $self.top$ 指向 $-1$,即 $self.top = -1$。 -- **判断栈是否为空**:当 $self.top == -1$ 时,说明栈为空,返回 $True$,否则返回 $False$。 -- **判断栈是否已满**:当 $self.top == self.size - 1$,说明栈已满,返回 $True$,否则返回返回 $False$。 -- **插入元素(进栈、入栈)**:先判断栈是否已满,已满直接抛出异常。如果栈未满,则在 $self.stack$ 末尾插入新的数据元素,并令 $self.top$ 向右移动 $1$ 位。 -- **删除元素(出栈、退栈)**:先判断栈是否为空,为空直接抛出异常。如果栈不为空,则删除 $self.stack$ 末尾的数据元素,并令 $self.top$ 向左移动 $1$ 位。 -- **获取栈顶元素**:先判断栈是否为空,为空直接抛出异常。不为空则返回 $self.top$ 指向的栈顶元素,即 $self.stack[self.top]$。 +- **初始化空栈**:用列表创建空栈,设置栈的最大容量 $self.size$,并将栈顶指针 $self.top$ 设为 $-1$,即 $self.top = -1$。 +- **判断栈空**:若 $self.top == -1$,则栈为空,返回 $True$,否则返回 $False$。 +- **判断栈满**:若 $self.top == self.size - 1$,则栈已满,返回 $True$,否则返回 $False$。 +- **入栈(push)**:先判断栈是否已满,若已满则抛出异常。未满时,将新元素添加到 $self.stack$ 末尾,并将 $self.top$ 加 $1$。 +- **出栈(pop)**:先判断栈是否为空,若为空则抛出异常。不为空时,删除 $self.stack$ 末尾元素,并将 $self.top$ 减 $1$。 +- **获取栈顶元素(peek)**:先判断栈是否为空,若为空则抛出异常。不为空时,返回 $self.stack[self.top]$,即栈顶元素。 -#### 2.2.2 栈的顺序存储实现代码 +#### 2.1.2 顺序栈的实现代码 ```python class Stack: # 初始化空栈 def __init__(self, size=100): - self.stack = [] - self.size = size - self.top = -1 - - # 判断栈是否为空 + self.stack = [] # 存储元素的数组 + self.size = size # 栈的最大容量 + self.top = -1 # 栈顶指针,-1表示空栈 + def is_empty(self): + """判断栈是否为空""" return self.top == -1 - # 判断栈是否已满 def is_full(self): + """判断栈是否已满""" return self.top + 1 == self.size - # 入栈操作 def push(self, value): + """入栈操作""" if self.is_full(): - raise Exception('Stack is full') - else: - self.stack.append(value) - self.top += 1 + raise Exception('栈已满') + self.stack.append(value) + self.top += 1 - # 出栈操作 def pop(self): + """出栈操作""" if self.is_empty(): - raise Exception('Stack is empty') - else: - self.stack.pop() - self.top -= 1 + raise Exception('栈为空') + value = self.stack.pop() + self.top -= 1 + return value - # 获取栈顶元素 def peek(self): + """查看栈顶元素""" if self.is_empty(): - raise Exception('Stack is empty') - else: - return self.stack[self.top] + raise Exception('栈为空') + return self.stack[self.top] ``` -### 2.3 栈的链式存储实现 +- **时间复杂度**:入栈、出栈、查看栈顶均为 O(1) -栈的顺序存储结构保留着顺序存储分配空间的固有缺陷,即在栈满或者其他需要重新调整存储空间时需要移动大量元素。为此,栈可以采用链式存储方式来实现。在 Python 中我们通过构造链表节点 $Node$ 的方式来实现。这种采用链式存储结构的栈也被称为 **「链式栈」**。 +### 2.2 链式栈(链表实现) ![栈的链式存储](https://qcdn.itcharge.cn/images/202405092243367.png) -#### 2.3.1 栈的链式存储基本描述 +顺序栈在存储空间上存在一定局限性:当栈满或需要扩容时,往往需要移动大量元素,效率较低。为了解决这一问题,可以采用链式存储结构实现栈。在 Python 中,我们通常通过自定义链表节点 $Node$ 来实现链式栈。采用链式存储结构的栈被称为 **「链式栈」**。 + +#### 2.2.1 链式栈的基本描述 -我们约定 $self.top$ 指向栈顶元素所在位置。 +约定 $self.top$ 始终指向栈顶元素。 -- **初始化空栈**:使用列表创建一个空栈,并令栈顶元素指针 $self.top$ 指向 $None$,即 $self.top = None$。 -- **判断栈是否为空**:当 $self.top == None$ 时,说明栈为空,返回 $True$,否则返回 $False$。 -- **插入元素(进栈、入栈)**:创建值为 $value$ 的链表节点,插入到链表头节点之前,并令栈顶指针 $self.top$ 指向新的头节点。 -- **删除元素(出栈、退栈)**:先判断栈是否为空,为空直接抛出异常。如果栈不为空,则先使用变量 $cur$ 存储当前栈顶指针 $self.top$ 指向的头节点,然后令 $self.top$ 沿着链表移动 $1$ 位,然后再删除之前保存的 $cur$ 节点。 -- **获取栈顶元素**:先判断栈是否为空,为空直接抛出异常。不为空则返回 $self.top$ 指向的栈顶节点的值,即 $self.top.value$。 +- **初始化空栈**:将栈顶指针 $self.top$ 设为 $None$,表示栈为空。 +- **判断栈是否为空**:若 $self.top == None$,则栈为空,返回 $True$,否则返回 $False$。 +- **入栈(push)**:新建一个值为 $value$ 的链表节点,将其插入到链表头部,并更新 $self.top$ 指向该新节点。 +- **出栈(pop)**:先判断栈是否为空,若为空则抛出异常。否则,记录当前栈顶节点,$self.top$ 指向下一个节点,并返回原栈顶节点的值。 +- **获取栈顶元素(peek)**:先判断栈是否为空,若为空则抛出异常。否则,返回 $self.top.value$。 -#### 2.3.2 栈的链式存储实现代码 +#### 2.2.2 链式栈的实现代码 ```python class Node: + """链表节点""" def __init__(self, value): - self.value = value - self.next = None + self.value = value # 节点值 + self.next = None # 指向下一个节点的指针 class Stack: - # 初始化空栈 def __init__(self): - self.top = None + """初始化空栈""" + self.top = None # 栈顶指针,指向链表头节点 - # 判断栈是否为空 def is_empty(self): - return self.top == None + """判断栈是否为空""" + return self.top is None - # 入栈操作 def push(self, value): - cur = Node(value) - cur.next = self.top - self.top = cur + """入栈操作 - 在链表头部插入新节点""" + new_node = Node(value) + new_node.next = self.top + self.top = new_node - # 出栈操作 def pop(self): + """出栈操作 - 删除链表头节点""" if self.is_empty(): - raise Exception('Stack is empty') - else: - cur = self.top - self.top = self.top.next - del cur + raise Exception('栈为空') + value = self.top.value + self.top = self.top.next + return value - # 获取栈顶元素 def peek(self): + """查看栈顶元素""" if self.is_empty(): - raise Exception('Stack is empty') - else: - return self.top.value + raise Exception('栈为空') + return self.top.value ``` -## 3. 栈的应用 +- **时间复杂度**:入栈、出栈、查看栈顶均为 O(1) -栈是算法和程序中最常用的辅助结构,其的应用十分广泛。栈基本应用于两个方面: +### 2.3 两种实现方式对比 -- 使用栈可以很方便的保存和取用信息,因此长被用作算法和程序中的辅助存储结构,临时保存信息,供后面操作中使用。 - - 例如:操作系统中的函数调用栈,浏览器中的前进、后退功能。 -- 栈的后进先出规则,可以保证特定的存取顺序。 - - 例如:翻转一组元素的顺序、铁路列车车辆调度。 +| 特性 | 顺序栈 | 链式栈 | +|------|--------|--------| +| 空间利用率 | 固定大小,可能浪费 | 按需分配,无浪费 | +| 扩容操作 | 需要重新分配空间 | 无需扩容 | +| 内存碎片 | 较少 | 可能产生碎片 | +| 实现复杂度 | 简单 | 相对复杂 | -下面我们来讲解一下栈应用的典型例子。 +## 3. 栈的经典应用 -### 3.1 括号匹配问题 +### 3.1 经典例题:括号匹配问题 #### 3.1.1 题目链接 @@ -210,47 +196,54 @@ class Stack: ##### 思路 1:栈 -括号匹配是「栈」的经典应用。我们可以用栈来解决这道题。具体做法如下: +括号匹配问题是「栈」结构的经典应用场景。我们可以利用栈高效地判断括号是否匹配,具体思路如下: -1. 先判断一下字符串的长度是否为偶数。因为括号是成对出现的,所以字符串的长度应为偶数,可以直接判断长度为奇数的字符串不匹配。如果字符串长度为奇数,则说明字符串 $s$ 中的括号不匹配,直接返回 $False$。 -2. 使用栈 $stack$ 来保存未匹配的左括号。然后依次遍历字符串 $s$ 中的每一个字符。 - 1. 如果遍历到左括号时,将其入栈。 - 2. 如果遍历到右括号时,先看栈顶元素是否是与当前右括号相同类型的左括号。 - 1. 如果是与当前右括号相同类型的左括号,则令其出栈,继续向前遍历。 - 2. 如果不是与当前右括号相同类型的左括号,则说明字符串 $s$ 中的括号不匹配,直接返回 $False$。 -3. 遍历完,还要再判断一下栈是否为空。 - 1. 如果栈为空,则说明字符串 $s$ 中的括号匹配,返回 $True$。 - 2. 如果栈不为空,则说明字符串 $s$ 中的括号不匹配,返回 $False$。 +1. 首先判断字符串长度是否为偶数。由于括号必须成对出现,若长度为奇数,则一定无法完全匹配,直接返回 $False$。 +2. 使用栈 $stack$ 存放尚未匹配的左括号。遍历字符串 $s$ 的每个字符,按如下规则处理: + 1. 如果遇到左括号,则将其压入栈中。 + 2. 如果遇到右括号,检查栈顶元素是否为对应类型的左括号: + 1. 若匹配,则弹出栈顶元素,继续遍历。 + 2. 若不匹配或栈已空,说明括号不合法,直接返回 $False$。 +3. 遍历结束后,检查栈是否为空: + 1. 若栈为空,说明所有括号均已正确配对,返回 $True$。 + 2. 若栈不为空,说明仍有未配对的左括号,返回 $False$。 ##### 思路 1:代码 ```python class Solution: def isValid(self, s: str) -> bool: + # 如果字符串长度为奇数,必然无法完全配对,直接返回 False if len(s) % 2 == 1: return False - stack = list() + stack = list() # 用于存放未配对的左括号 for ch in s: + # 如果是左括号,直接入栈 if ch == '(' or ch == '[' or ch == '{': stack.append(ch) + # 如果是右括号,需要判断栈顶是否为对应的左括号 elif ch == ')': - if len(stack) !=0 and stack[-1] == '(': + # 栈非空且栈顶为对应的左括号,弹出 + if len(stack) != 0 and stack[-1] == '(': stack.pop() else: + # 不匹配或栈空,返回 False return False elif ch == ']': - if len(stack) !=0 and stack[-1] == '[': + if len(stack) != 0 and stack[-1] == '[': stack.pop() else: return False elif ch == '}': - if len(stack) !=0 and stack[-1] == '{': + if len(stack) != 0 and stack[-1] == '{': stack.pop() else: return False + # 遍历结束后,栈为空说明全部配对成功 if len(stack) == 0: return True else: + # 栈不为空,说明有未配对的左括号 return False ``` @@ -259,7 +252,7 @@ class Solution: - **时间复杂度**:$O(n)$。 - **空间复杂度**:$O(1)$。 -### 3.2 表达式求值问题 +### 3.2 经典例题:表达式求值问题 #### 3.2.1 题目链接 @@ -294,20 +287,20 @@ class Solution: ##### 思路 1:栈 -计算表达式中,乘除运算优先于加减运算。我们可以先进行乘除运算,再将进行乘除运算后的整数值放入原表达式中相应位置,再依次计算加减。 +在表达式计算中,乘除运算优先于加减运算。我们可以优先处理乘除,将结果暂存,再统一处理加减。 -可以考虑使用一个栈来保存进行乘除运算后的整数值。正整数直接压入栈中,负整数,则将对应整数取负号,再压入栈中。这样最终计算结果就是栈中所有元素的和。 +具体实现时,可以借助一个栈来保存每一步的中间结果。遇到正数直接入栈,遇到负数则取相反数入栈。这样,最终的计算结果就是栈中所有元素的和。 -具体做法: +详细步骤如下: -1. 遍历字符串 $s$,使用变量 $op$ 来标记数字之前的运算符,默认为 `+`。 -2. 如果遇到数字,继续向后遍历,将数字进行累积,得到完整的整数 num。判断当前 op 的符号。 - 1. 如果 $op$ 为 `+`,则将 $num$ 压入栈中。 - 2. 如果 $op$ 为 `-`,则将 $-num$ 压入栈中。 - 3. 如果 $op$ 为 `*`,则将栈顶元素 $top$ 取出,计算 $top \times num$,并将计算结果压入栈中。 - 4. 如果 $op$ 为 `/`,则将栈顶元素 $top$ 取出,计算 $int(top / num)$,并将计算结果压入栈中。 -3. 如果遇到 `+`、`-`、`*`、`/` 操作符,则更新 $op$。 -4. 最后将栈中整数进行累加,并返回结果。 +1. 遍历字符串 $s$,用变量 $op$ 记录当前数字前的运算符,初始为 `+`。 +2. 当遇到数字时,连续读取完整数字 $num$,根据 $op$ 的类型进行如下处理: + 1. 若 $op$ 为 `+`,将 $num$ 入栈。 + 2. 若 $op$ 为 `-`,将 $-num$ 入栈。 + 3. 若 $op$ 为 `*`,弹出栈顶元素 $top$,计算 $top \times num$,将结果入栈。 + 4. 若 $op$ 为 `/`,弹出栈顶元素 $top$,计算 $int(top / num)$,将结果入栈。 +3. 如果遇到运算符 `+`、`-`、`*`、`/`,则更新 $op$。 +4. 最后,将栈中所有数字求和,返回结果。 ##### 思路 1:代码 @@ -315,31 +308,37 @@ class Solution: class Solution: def calculate(self, s: str) -> int: size = len(s) - stack = [] - op = '+' + stack = [] # 用于存储每一步的中间结果 + op = '+' # 记录上一个运算符,初始为加号 index = 0 while index < size: if s[index] == ' ': + # 跳过空格 index += 1 continue if s[index].isdigit(): + # 解析多位数字 num = ord(s[index]) - ord('0') while index + 1 < size and s[index+1].isdigit(): index += 1 num = 10 * num + ord(s[index]) - ord('0') + # 根据上一个运算符进行处理 if op == '+': - stack.append(num) + stack.append(num) # 加号直接入栈 elif op == '-': - stack.append(-num) + stack.append(-num) # 减号取相反数入栈 elif op == '*': - top = stack.pop() - stack.append(top * num) + top = stack.pop() # 乘法弹出栈顶元素 + stack.append(top * num) # 计算后入栈 elif op == '/': - top = stack.pop() + top = stack.pop() # 除法弹出栈顶元素 + # Python 的 int() 向零取整,符合题意 stack.append(int(top / num)) elif s[index] in "+-*/": + # 更新当前运算符 op = s[index] index += 1 + # 栈中所有元素求和即为最终结果 return sum(stack) ``` diff --git a/docs/03_stack_queue_hash_table/03_02_monotone_stack.md b/docs/03_stack_queue_hash_table/03_02_monotone_stack.md index 4fc4b9be..eac86b1d 100644 --- a/docs/03_stack_queue_hash_table/03_02_monotone_stack.md +++ b/docs/03_stack_queue_hash_table/03_02_monotone_stack.md @@ -1,129 +1,116 @@ ## 1. 单调栈简介 -> **单调栈(Monotone Stack)**:一种特殊的栈。在栈的「先进后出」规则基础上,要求「从 **栈顶** 到 **栈底** 的元素是单调递增(或者单调递减)」。其中满足从栈顶到栈底的元素是单调递增的栈,叫做「单调递增栈」。满足从栈顶到栈底的元素是单调递减的栈,叫做「单调递减栈」。 - -注意:这里定义的顺序是从「栈顶」到「栈底」。有的文章里是反过来的。本文全文以「栈顶」到「栈底」的顺序为基准来描述单调栈。 +> **单调栈(Monotone Stack)**:在栈「先进后出」规则的基础上,要求从 **栈顶** 到 **栈底** 的元素单调递增或单调递减。 +> +> - **单调递增栈**:从栈顶到栈底元素单调递增 +> - **单调递减栈**:从栈顶到栈底元素单调递减 ### 1.1 单调递增栈 -> **单调递增栈**:只有比栈顶元素小的元素才能直接进栈,否则需要先将栈中比当前元素小的元素出栈,再将当前元素入栈。 +> **单调递增栈**:每次新元素进栈时,如果它比栈顶元素小,直接入栈;如果比栈顶元素大或相等,就把栈顶及以上所有小于等于它的元素依次弹出,直到栈顶比它大或栈空,再将新元素入栈。 > -> 这样就保证了:栈中保留的都是比当前入栈元素大的值,并且从栈顶到栈底的元素值是单调递增的。 +> 这样可以保证:栈从栈顶到栈底的元素是递增的,且每个元素左侧第一个比它大的元素都能被快速找到。 -单调递增栈的入栈、出栈过程如下: +单调递增栈的操作流程: -- 假设当前进栈元素为 $x$,如果 $x$ 比栈顶元素小,则直接入栈。 -- 否则从栈顶开始遍历栈中元素,把小于 $x$ 或者等于 $x$ 的元素弹出栈,直到遇到一个大于 $x$ 的元素为止,然后再把 $x$ 压入栈中。 +- 当前元素 $x$,如果 $x$ < 栈顶元素,直接入栈。 +- 否则,不断弹出栈顶小于等于 $x$ 的元素,直到栈顶比 $x$ 大或栈空,然后将 $x$ 入栈。 -下面我们以数组 $[2, 7, 5, 4, 6, 3, 4, 2]$ 为例,模拟一下「单调递增栈」的进栈、出栈过程。具体过程如下: +下面以数组 $[2, 7, 5, 4, 6, 3, 4, 2]$ 为例,演示「单调递增栈」的入栈与出栈过程: -- 数组元素:$[2, 7, 5, 4, 6, 3, 4, 2]$,遍历顺序为从左到右。 +- 数组:$[2, 7, 5, 4, 6, 3, 4, 2]$,从左到右依次遍历。 -| 第 i 步 | 待插入元素 | 操 作 | 结 果(左侧为栈底) | 作 用 | -| :-----: | :--------: | ---------------------- | ------------------- | ------------------------------------- | -| 1 | 2 | 2 入栈 | [2] | 元素 2 的左侧无比 2 大的元素 | -| 2 | 7 | 2 出栈,7 入栈 | [7] | 元素 7 的左侧无比 7 大的元素 | -| 3 | 5 | 5 入栈 | [7, 5] | 元素 5 的左侧第一个比 5 大的元素为:7 | -| 4 | 4 | 4 入栈 | [7, 5, 4] | 元素 4 的左侧第一个比 4 大的元素为:5 | -| 5 | 6 | 4 出栈,5 出栈,6 入栈 | [7, 6] | 元素 6 的左侧第一个比 6 大的元素为:7 | -| 6 | 3 | 3 入栈 | [7, 6, 3] | 元素 3 的左侧第一个比 3 大的元素为:6 | -| 7 | 4 | 3 出栈,4 入栈 | [7, 6, 4] | 元素 4 的左侧第一个比 4 大的元素为:6 | -| 8 | 2 | 2 入栈 | [7, 6, 4, 2] | 元素 2 的左侧第一个比 2 大的元素为:4 | +| 步骤 | 当前元素 | 操作 | 栈状态(左为栈底) | 说明 | +| :--: | :------: | ------------------------- | ------------------- | -------------------------------------- | +| 1 | 2 | 2 入栈 | [2] | 2 左侧无更大元素 | +| 2 | 7 | 2 出栈,7 入栈 | [7] | 7 左侧无更大元素 | +| 3 | 5 | 5 入栈 | [7, 5] | 5 左侧第一个更大元素为 7 | +| 4 | 4 | 4 入栈 | [7, 5, 4] | 4 左侧第一个更大元素为 5 | +| 5 | 6 | 4 出栈,5 出栈,6 入栈 | [7, 6] | 6 左侧第一个更大元素为 7 | +| 6 | 3 | 3 入栈 | [7, 6, 3] | 3 左侧第一个更大元素为 6 | +| 7 | 4 | 3 出栈,4 入栈 | [7, 6, 4] | 4 左侧第一个更大元素为 6 | +| 8 | 2 | 2 入栈 | [7, 6, 4, 2] | 2 左侧第一个更大元素为 4 | -最终栈中元素为 $[7, 6, 4, 2]$。因为从栈顶(右端)到栈底(左侧)元素的顺序为 $2, 4, 6, 7$,满足递增关系,所以这是一个单调递增栈。 +最终,栈中元素为 $[7, 6, 4, 2]$。从栈顶(右)到栈底(左)为 $2, 4, 6, 7$,满足递增,符合单调递增栈的定义。 -我们以上述过程第 5 步为例,所对应的图示过程为: +以第 5 步为例,图示如下: ![](https://qcdn.itcharge.cn/images/20220107101219.png) ### 1.2 单调递减栈 -> **单调递减栈**:只有比栈顶元素大的元素才能直接进栈,否则需要先将栈中比当前元素大的元素出栈,再将当前元素入栈。 +> **单调递减栈**:每次新元素进栈时,只有当它比栈顶元素大时才能直接入栈;如果小于或等于栈顶元素,则需要先将栈中所有大于等于当前元素的元素依次弹出,直到栈顶元素小于当前元素或栈为空,再将新元素入栈。 > -> 这样就保证了:栈中保留的都是比当前入栈元素小的值,并且从栈顶到栈底的元素值是单调递减的。 +> 这样可以保证:栈中始终只保留比当前入栈元素小的值,并且从栈顶到栈底的元素是单调递减的。 -单调递减栈的入栈、出栈过程如下: +单调递减栈的操作流程如下: -- 假设当前进栈元素为 $x$,如果 $x$ 比栈顶元素大,则直接入栈。 -- 否则从栈顶开始遍历栈中元素,把大于 $x$ 或者等于 $x$ 的元素弹出栈,直到遇到一个小于 $x$ 的元素为止,然后再把 $x$ 压入栈中。 +- 假设当前待入栈元素为 $x$,如果 $x$ > 栈顶元素,则直接入栈。 +- 否则,从栈顶开始,依次弹出所有大于等于 $x$ 的元素,直到遇到一个小于 $x$ 的元素或栈为空,然后将 $x$ 入栈。 -下面我们以数组 $[4, 3, 2, 5, 7, 4, 6, 8]$ 为例,模拟一下「单调递减栈」的进栈、出栈过程。具体过程如下: +下面以数组 $[4, 3, 2, 5, 7, 4, 6, 8]$ 为例,演示「单调递减栈」的入栈与出栈过程: -- 数组元素:$[4, 3, 2, 5, 7, 4, 6, 8]$,遍历顺序为从左到右。 +- 数组:$[4, 3, 2, 5, 7, 4, 6, 8]$,从左到右依次遍历。 -| 第 i 步 | 待插入元素 | 操 作 | 结 果(左侧为栈底) | 作用 | -| :-----: | :--------: | ---------------------- | ------------------- | ------------------------------------- | -| 1 | 4 | 4 入栈 | [4] | 元素 4 的左侧无比 4 小的元素 | -| 2 | 3 | 4 出栈,3 入栈 | [3] | 元素 3 的左侧无比 3 小的元素 | -| 3 | 2 | 3 出栈,2 入栈 | [2] | 元素 2 的左侧无比 2 小的元素 | -| 4 | 5 | 5 入栈 | [2, 5] | 元素 5 的左侧第一个比 5 小的元素是:2 | -| 5 | 7 | 7 入栈 | [2, 5, 7] | 元素 7 的左侧第一个比 7 小的元素是:5 | -| 6 | 4 | 7 出栈,5 出栈,4 入栈 | [2, 4] | 元素 4 的左侧第一个比 4 小的元素是:2 | -| 7 | 6 | 6 入栈 | [2, 4, 6] | 元素 6 的左侧第一个比 6 小的元素是:4 | -| 8 | 8 | 8 入栈 | [2, 4, 6, 8] | 元素 8 的左侧第一个比 8 小的元素是:6 | +| 步骤 | 当前元素 | 操作 | 栈状态(左为栈底) | 说明 | +| :--: | :------: | --------------------- | ------------------- | ------------------------------------- | +| 1 | 4 | 4 入栈 | [4] | 4 左侧无更小元素 | +| 2 | 3 | 4 出栈,3 入栈 | [3] | 3 左侧无更小元素 | +| 3 | 2 | 3 出栈,2 入栈 | [2] | 2 左侧无更小元素 | +| 4 | 5 | 5 入栈 | [2, 5] | 5 左侧第一个更小元素为 2 | +| 5 | 7 | 7 入栈 | [2, 5, 7] | 7 左侧第一个更小元素为 5 | +| 6 | 4 | 7 出栈,5 出栈,4 入栈| [2, 4] | 4 左侧第一个更小元素为 2 | +| 7 | 6 | 6 入栈 | [2, 4, 6] | 6 左侧第一个更小元素为 4 | +| 8 | 8 | 8 入栈 | [2, 4, 6, 8] | 8 左侧第一个更小元素为 6 | -最终栈中元素为 $[2, 4, 6, 8]$。因为从栈顶(右端)到栈底(左侧)元素的顺序为 $8, 6, 4, 2$,满足递减关系,所以这是一个单调递减栈。 +最终,栈中元素为 $[2, 4, 6, 8]$。从栈顶(右)到栈底(左)为 $8, 6, 4, 2$,满足递减,符合单调递减栈的定义。 -我们以上述过程第 6 步为例,所对应的图示过程为: +以第 6 步为例,图示如下: ![](https://qcdn.itcharge.cn/images/20220107102446.png) ## 2. 单调栈适用场景 -单调栈可以在时间复杂度为 $O(n)$ 的情况下,求解出某个元素左边或者右边第一个比它大或者小的元素。 - -所以单调栈一般用于解决一下几种问题: - -- 寻找左侧第一个比当前元素大的元素。 -- 寻找左侧第一个比当前元素小的元素。 -- 寻找右侧第一个比当前元素大的元素。 -- 寻找右侧第一个比当前元素小的元素。 - -下面分别说一下这几种问题的求解方法。 - -### 2.1 寻找左侧第一个比当前元素大的元素 - -- 从左到右遍历元素,构造单调递增栈(从栈顶到栈底递增): - - 一个元素左侧第一个比它大的元素就是将其「插入单调递增栈」时的栈顶元素。 - - 如果插入时的栈为空,则说明左侧不存在比当前元素大的元素。 +单调栈常用于 $O(n)$ 时间复杂度内高效解决「最近更大/更小元素」类问题,主要包括以下四种典型场景: +- 查找左侧第一个比当前元素更大 / 更小的元素。 +- 查找右侧第一个比当前元素更大 / 更小的元素。 -### 2.2 寻找左侧第一个比当前元素小的元素 +具体解法如下: -- 从左到右遍历元素,构造单调递减栈(从栈顶到栈底递减): - - 一个元素左侧第一个比它小的元素就是将其「插入单调递减栈」时的栈顶元素。 - - 如果插入时的栈为空,则说明左侧不存在比当前元素小的元素。 +### 2.1 查找左侧第一个比当前元素大的元素 +- 从左到右遍历数组,维护单调递增栈(栈顶到栈底递增): + - 当前元素入栈时,栈顶元素即为其左侧第一个更大元素; + - 若栈为空,则左侧不存在更大元素。 -### 2.3 寻找右侧第一个比当前元素大的元素 +### 2.2 查找左侧第一个比当前元素小的元素 -- 从左到右遍历元素,构造单调递增栈(从栈顶到栈底递增): - - 一个元素右侧第一个比它大的元素就是将其「弹出单调递增栈」时即将插入的元素。 - - 如果该元素没有被弹出栈,则说明右侧不存在比当前元素大的元素。 +- 从左到右遍历数组,维护单调递减栈(栈顶到栈底递减): + - 当前元素入栈时,栈顶元素即为其左侧第一个更小元素; + - 若栈为空,则左侧不存在更小元素。 -- 从右到左遍历元素,构造单调递增栈(从栈顶到栈底递增): - - 一个元素右侧第一个比它大的元素就是将其「插入单调递增栈」时的栈顶元素。 - - 如果插入时的栈为空,则说明右侧不存在比当前元素大的元素。 +### 2.3 查找右侧第一个比当前元素大的元素 +- 从左到右遍历数组,维护单调递增栈: + - 当前元素将栈中比自己小的元素弹出,被弹出的元素的右侧第一个更大元素即为当前元素; + - 若某元素未被弹出,则右侧不存在更大元素。 +- 或者,从右到左遍历,入栈时栈顶即为右侧第一个更大元素。 -### 2.4 寻找右侧第一个比当前元素小的元素 +### 2.4 查找右侧第一个比当前元素小的元素 -- 从左到右遍历元素,构造单调递减栈(从栈顶到栈底递减): - - 一个元素右侧第一个比它小的元素就是将其「弹出单调递减栈」时即将插入的元素。 - - 如果该元素没有被弹出栈,则说明右侧不存在比当前元素小的元素。 +- 从左到右遍历数组,维护单调递减栈: + - 当前元素将栈中比自己大的元素弹出,被弹出的元素的右侧第一个更小元素即为当前元素; + - 若某元素未被弹出,则右侧不存在更小元素。 +- 或者,从右到左遍历,入栈时栈顶即为右侧第一个更小元素。 -- 从右到左遍历元素,构造单调递减栈(从栈顶到栈底递减): - - 一个元素右侧第一个比它小的元素就是将其「插入单调递减栈」时的栈顶元素。 - - 如果插入时的栈为空,则说明右侧不存在比当前元素小的元素。 +上述四类问题可以归纳为以下通用规则: - -上边的分类解法有点绕口,可以简单记为以下条规则: - -- 无论哪种题型,都建议从左到右遍历元素。 - -- 查找 **「比当前元素大的元素」** 就用 **单调递增栈**,查找 **「比当前元素小的元素」** 就用 **单调递减栈**。 -- 从 **「左侧」** 查找就看 **「插入栈」** 时的栈顶元素,从 **「右侧」** 查找就看 **「弹出栈」** 时即将插入的元素。 +- 查「更大」用单调递增栈,查「更小」用单调递减栈; +- 查「左侧」看元素入栈时的栈顶; +- 查「右侧」看元素出栈时触发它的当前元素; +- 遍历方向通常为从左到右(部分场景可从右到左)。 ## 3. 单调栈模板 @@ -134,9 +121,12 @@ ```python def monotoneIncreasingStack(nums): stack = [] - for num in nums: + left + for i, num in enumerate(nums): while stack and num >= stack[-1]: stack.pop() + if stack: + stack.append(num) ``` @@ -151,9 +141,11 @@ def monotoneDecreasingStack(nums): stack.append(num) ``` -## 4. 单调栈的应用 +等号的去留(>= 或 >,<= 或 <)决定是否保留相等元素,按题意调整。 + +## 4. 单调栈的经典应用 -### 4.1 下一个更大元素 I +### 4.1 经典例题:下一个更大元素 I #### 4.1.1 题目链接 diff --git a/docs/03_stack_queue_hash_table/03_03_queue_basic.md b/docs/03_stack_queue_hash_table/03_03_queue_basic.md index ad0f9319..9a509a91 100644 --- a/docs/03_stack_queue_hash_table/03_03_queue_basic.md +++ b/docs/03_stack_queue_hash_table/03_03_queue_basic.md @@ -1,331 +1,367 @@ ## 1. 队列简介 -> **队列(Queue)**:一种线性表数据结构,是一种只允许在表的一端进行插入操作,而在表的另一端进行删除操作的线性表。 +> **队列(Queue)**:一种线性表数据结构,遵循「先进先出(FIFO)」原则,只允许在一端插入元素(队尾),在另一端删除元素(队头)。 -我们把队列中允许插入的一端称为 **「队尾(rear)」**;把允许删除的另一端称为 **「队头(front)」**。当表中没有任何数据元素时,称之为 **「空队」**。 +### 1.1 基本概念 -队列有两种基本操作:**「插入操作」** 和 **「删除操作」**。 +- **队尾(rear)**:允许插入元素的一端 +- **队头(front)**:允许删除元素的一端 +- **空队**:没有任何数据元素的队列 -- 队列的插入操作又称为「入队」。 -- 队列的删除操作又称为「出队」。 +### 1.2 核心特性 -![队列结构](https://qcdn.itcharge.cn/images/202405092254785.png) - -简单来说,队列是一种 **「先进先出(First In First Out)」** 的线性表,简称为 **「FIFO 结构」**。 - -我们可以从两个方面来解释一下队列的定义: - -- 第一个方面是 **「线性表」**。 - -队列首先是一个线性表,队列中元素具有前驱后继的线性关系。队列中元素按照 $a_1, a_2, ... , a_n$ 的次序依次入队。队头元素为 $a_1$,队尾元素为 $a_n$。 - -- 第二个方面是 **「先进先出原则」**。 +队列的操作遵循 **先进先出(FIFO)** 的原则: +- 最先进入队列的元素,最先被取出。 +- 类似于排队买票,先到的人先买票,后到的人只能排在队尾等待。 -根据队列的定义,最先进入队列的元素在队头,最后进入队列的元素在队尾。每次从队列中删除的总是队头元素,即最先进入队列的元素。也就是说,元素进入队列或者退出队列是按照「先进先出(First In First Out)」的原则进行的。 +### 1.3 基本操作 -## 2. 队列的顺序存储与链式存储 +- **入队(enqueue)**:在队尾插入元素 +- **出队(dequeue)**:从队头删除元素 -和线性表类似,队列有两种存储表示方法:**「顺序存储的队列」** 和 **「链式存储的队列」**。 +下图展示了队列结构和操作方式。 -- **「顺序存储的队列」**:利用一组地址连续的存储单元依次存放队列中从队头到队尾的元素,同时使用指针 $front$ 指向队头元素在队列中的位置,使用指针 $rear$ 指示队尾元素在队列中的位置。 -- **「链式存储的队列」**:利用单链表的方式来实现队列。队列中元素按照插入顺序依次插入到链表的第一个节点之后,并使用队头指针 $front$ 指向链表头节点位置,也就是队头元素,$rear$ 指向链表尾部位置,也就是队尾元素。 - -注意:$front$ 和 $rear$ 的指向位置并不完全固定。有时候算法设计上的方便以及代码简洁,也会使 $front$ 指向队头元素所在位置的前一个位置。$rear$ 也可能指向队尾元素在队列位置的下一个位置。具体还是要看算法是如何实现的。 - -在描述队列的顺序存储与链式存储具体实现之前,我们先来看看队列具有哪些基本操作。 - -### 2.1 队列的基本操作 - -- **初始化空队列**:创建一个空队列,定义队列的大小 $size$,以及队头元素指针 $front$,队尾指针 $rear$。 - -- **判断队列是否为空**:当队列为空时,返回 $True$。当队列不为空时,返回 $False$。一般只用于「出队操作」和「获取队头元素操作」中。 +![队列结构](https://qcdn.itcharge.cn/images/202405092254785.png) -- **判断队列是否已满**:当队列已满时,返回 $True$,当队列未满时,返回 $False$。一般只用于顺序队列中插入元素操作中。 +## 2. 队列的实现方式 -- **插入元素(入队)**:相当于在线性表最后一个数据元素后面插入一个新的数据元素。并改变队尾指针 $rear$ 的指向位置。 +与线性表类似,队列常见的存储方式有两种:**顺序存储** 和 **链式存储**。 -- **删除元素(出队)**:相当于在线性表中删除第一个数据元素。并改变队头指针 $front$ 的指向位置。 -- **获取队头元素**:相当于获取线性表中第一个数据元素。与插入元素(入队)、删除元素(出队)不同的是,该操作并不改变队头指针 $front$ 的指向位置。 -- **获取队尾元素**:相当于获取线性表中最后一个数据元素。与插入元素(入队)、删除元素(出队)不同的是,该操作并不改变队尾指针 $rear$ 的指向位置。 +- **顺序存储队列**:使用一段连续的存储空间(如数组),依次存放队列中从队头到队尾的元素。通过指针 $front$ 标记队头元素的位置,$rear$ 标记队尾元素的位置。 +- **链式存储队列**:采用单链表实现,元素按插入顺序依次链接。$front$ 指向链表的头节点(即队头元素),$rear$ 指向链表的尾节点(即队尾元素)。 -接下来我们来看一下队列的顺序存储与链式存储两种不同的实现方式。 +需要注意的是,$front$ 和 $rear$ 的具体指向方式可能因实现细节而异。为简化算法或代码,有时 $front$ 会指向队头元素的前一个位置,$rear$ 也可能指向队尾元素的下一个位置。具体以实际实现为准。 -### 2.2 队列的顺序存储实现 +### 2.1 顺序存储队列 -队列最简单的实现方式就是借助于一个数组来描述队列的顺序存储结构。在 Python 中我们可以借助列表 $list$ 来实现。 +队列最常见的实现方式是利用数组来构建顺序存储结构。在 Python 中,可以直接使用列表(list)来实现顺序存储队列。 -#### 2.2.1 队列的顺序存储基本描述 +#### 2.1.1 顺序存储队列的基本描述 -![队列的顺序存储](https://qcdn.itcharge.cn/images/202405092254909.png) +![顺序存储队列](https://qcdn.itcharge.cn/images/202405092254909.png) -为了算法设计上的方便以及算法本身的简单,我们约定:队头指针 $self.front$ 指向队头元素所在位置的前一个位置,而队尾指针 $self.rear$ 指向队尾元素所在位置。 +为简化实现,我们约定:队头指针 $self.front$ 指向队头元素的前一个位置,队尾指针 $self.rear$ 指向队尾元素所在位置。 -- **初始化空队列**:创建一个空队列 $self.queue$,定义队列大小 $self.size$。令队头指针 $self.front$ 和队尾指针 $self.rear$ 都指向 $-1$。即 $self.front = self.rear = -1$。 -- **判断队列是否为空**:根据 $self.front$ 和 $self.rear$ 的指向位置关系进行判断。如果队头指针 $self.front$ 和队尾指针 $self.rear$ 相等,则说明队列为空。否则,队列不为空。 -- **判断队列是否已满**:如果 $self.rear$ 指向队列最后一个位置,即 $self.rear == self.size - 1$,则说明队列已满。否则,队列未满。 -- **插入元素(入队)**:先判断队列是否已满,已满直接抛出异常。如果队列不满,则将队尾指针 $self.rear$ 向右移动一位,并进行赋值操作。此时 $self.rear$ 指向队尾元素。 -- **删除元素(出队)**:先判断队列是否为空,为空直接抛出异常。如果队列不为空,则将队头指针 $self.front$ 指向元素赋值为 $None$,并将 $self.front$ 向右移动一位。 -- **获取队头元素**:先判断队列是否为空,为空直接抛出异常。如果队列不为空,因为 $self.front$ 指向队头元素所在位置的前一个位置,所以队头元素在 $self.front$ 后面一个位置上,返回 $self.queue[self.front + 1]$。 -- **获取队尾元素**:先判断队列是否为空,为空直接抛出异常。如果不为空,因为 $self.rear$ 指向队尾元素所在位置,所以直接返回 $self.queue[self.rear]$。 +- **初始化空队列**:创建空队列 $self.queue$,设置队列容量 $self.size$,并令 $self.front = self.rear = -1$。 +- **判断队列是否为空**:若 $self.front$ 与 $self.rear$ 相等,则队列为空。 +- **判断队列是否已满**:若 $self.rear == self.size - 1$,则队列已满。 +- **入队操作**:先判断队列是否已满,若未满,则 $self.rear$ 右移一位,将新元素赋值到 $self.queue[self.rear]$。 +- **出队操作**:先判断队列是否为空,若不为空,则 $self.front$ 右移一位,返回 $self.queue[self.front]$。 +- **获取队头元素**:先判断队列是否为空,若不为空,则返回 $self.queue[self.front + 1]$。 +- **获取队尾元素**:先判断队列是否为空,若不为空,则返回 $self.queue[self.rear]$。 -#### 2.2.2 队列的顺序存储实现代码 +#### 2.1.2 顺序存储队列的实现代码 ```python class Queue: - # 初始化空队列 + """ + 顺序存储队列实现(非循环队列) + front 指向队头元素的前一个位置,rear 指向队尾元素所在位置 + """ def __init__(self, size=100): + """ + 初始化空队列 + :param size: 队列最大容量 + """ self.size = size - self.queue = [None for _ in range(size)] - self.front = -1 - self.rear = -1 - - # 判断队列是否为空 + self.queue = [None for _ in range(size)] # 存储队列元素的数组 + self.front = -1 # 队头指针,指向队头元素的前一个位置 + self.rear = -1 # 队尾指针,指向队尾元素所在位置 + def is_empty(self): + """ + 判断队列是否为空 + :return: 若队列为空返回 True,否则返回 False + """ return self.front == self.rear - - # 判断队列是否已满 + def is_full(self): + """ + 判断队列是否已满 + :return: 若队列已满返回 True,否则返回 False + """ return self.rear + 1 == self.size - - # 入队操作 + def enqueue(self, value): + """ + 入队操作:在队尾插入元素 + :param value: 待插入的元素 + :raises Exception: 队列已满时抛出异常 + """ if self.is_full(): raise Exception('Queue is full') - else: - self.rear += 1 - self.queue[self.rear] = value - - # 出队操作 + self.rear += 1 + self.queue[self.rear] = value + def dequeue(self): + """ + 出队操作:从队头删除元素并返回 + :return: 队头元素 + :raises Exception: 队列为空时抛出异常 + """ if self.is_empty(): raise Exception('Queue is empty') - else: - self.front += 1 - return self.queue[self.front] - - # 获取队头元素 + self.front += 1 + return self.queue[self.front] + def front_value(self): + """ + 获取队头元素(不删除) + :return: 队头元素 + :raises Exception: 队列为空时抛出异常 + """ if self.is_empty(): raise Exception('Queue is empty') - else: - return self.queue[self.front + 1] - - # 获取队尾元素 + return self.queue[self.front + 1] + def rear_value(self): + """ + 获取队尾元素(不删除) + :return: 队尾元素 + :raises Exception: 队列为空时抛出异常 + """ if self.is_empty(): raise Exception('Queue is empty') - else: - return self.queue[self.rear] + return self.queue[self.rear] ``` -### 2.3 循环队列的顺序存储实现 - -在「2.2 队列的顺序存储实现」中,如果队列中第 $0$ ~ $size - 1$ 位置均被队列元素占用时,此时队列已满(即 $self.rear == self.size - 1$),再进行入队操作就会抛出队列已满的异常。 +### 2.2 顺序存储循环队列 -而由于出队操作总是删除当前的队头元素,将 $self.front$ 进行右移,而插入操作又总是在队尾进行。经过不断的出队、入队操作,队列的变化就像是使队列整体向右移动。 +在上一节的顺序队列实现中,队列满时($self.rear == self.size - 1$)就无法再插入新元素,即使前面有空位也无法利用,导致「假溢出」问题。 -当队尾指针满足 $self.rear == self.size - 1$ 条件时,此时再进行入队操作就会抛出队列已满的异常。而之前因为出队操作而产生空余位置也没有利用上,这就造成了「假溢出」问题。 +为解决这个问题,常用两种方法: -为了解决「假溢出」问题,有两种做法: +- **方法一:每次出队后整体前移元素** + +这样可以利用前面的空位,但每次出队都要移动所有元素,效率低,时间复杂度 $O(n)$,不推荐。 -- 第一种:每一次删除队头元素之后,就将整个队列往前移动 $1$ 个位置。其代码如下所示: +- **方法二:循环移动** -```python -# 出队操作 -def dequeue(self): - if self.is_empty(): - raise Exception('Queue is empty') - else: - value = self.queue[0] - for i in range(self.rear): - self.queue[i] = self.queue[i + 1] - return value -``` +将队列的首尾视为相连,通过取模运算实现指针的循环移动,从而充分利用存储空间。采用循环队列后,所有基本操作的时间复杂度均为 $O(1)$,高效且无“假溢出”问题。 -这种情况下,队头指针似乎用不到了。因为队头指针总是在队列的第 $0$ 个位置。但是因为删除操作涉及到整个队列元素的移动,所以每次删除操作的时间复杂度就从 $O(1)$ 变为了 $O(n)$。所以这种方式不太可取。 +循环队列的实现要点如下: -- 第二种:将队列想象成为头尾相连的循环表,利用数学中的求模运算,使得空间得以重复利用,这样就解决了问题。 +- 设 $self.size$ 为循环队列的最大容量,队头指针 $self.front$ 指向队头元素前一个位置,队尾指针 $self.rear$ 指向队尾元素。 +- **入队**:$self.rear = (self.rear + 1) \mod self.size$,在新位置插入元素。 +- **出队**:$self.front = (self.front + 1) \mod self.size$,并返回该位置元素。 -在进行插入操作时,如果队列的第 $self.size - 1$ 个位置被占用之后,只要队列前面还有可用空间,新的元素加入队列时就可以从第 $0$ 个位置开始继续插入。 +> **注意**: +> 初始化时 $self.front == self.rear$,表示队列为空。 +> 但队列满时也可能出现 $self.front == self.rear$,因此需要区分队空和队满。 -我们约定:$self.size$ 为循环队列的最大元素个数。队头指针 $self.front$ 指向队头元素所在位置的前一个位置,而队尾指针 $self.rear$ 指向队尾元素所在位置。则: +常见区分队空和队满的方法: -1. **插入元素(入队)时**:队尾指针循环前进 $1$ 个位置,即 $self.rear = (self.rear + 1) \mod self.size$。 -2. **删除元素(出队)时**:队头指针循环前进 $1$ 个位置,即 $self.front = (self.front + 1) \mod self.size$。 +- **方法 1**:增加计数变量 $self.count$,记录队列元素个数。 +- **方法 2**:增加标记变量 $self.tag$,区分最近一次操作是入队还是出队。 +- **方法 3(常用)**:特意空出一个位置,约定「队头指针在队尾指针的下一位置」为队满。即: + - 队满:$(self.rear + 1) \mod self.size == self.front$ + - 队空:$self.front == self.rear$ -> **注意**: -> -> - 循环队列在一开始初始化,队列为空时,满足条件$self.front == self.rear$。 -> - 而当充满队列后,仍满足条件 $self.front == self.rear$。 -> -> 这种情况下就无法判断「队列为空」还是「队列为满」了。 +#### 2.2.1 顺序存储循环队列的基本描述 -为了区分循环队列中「队列为空」还是「队列已满」的情况,有多种处理方式: +以方法 3 为例,循环队列的操作如下: -- **方式 1**:增加表示队列中元素个数的变量 $self.count$,用来以区分队列已满还是队列为空。在入队、出队过程中不断更新元素个数 $self.count$ 的值。 - - 队列已满条件为:队列中元素个数等于队列整体容量,即 $self.count == self.size$。 - - 队空为空条件为:队列中元素个数等于 $0$,即 $self.count == 0$。 -- **方式 2**:增加标记变量 $self.tag$,用来以区分队列已满还是队列为空。 - - 队列已满条件为:$self.tag == 1$ 的情况下,因插入导致 $self.front == self.rear$。 - - 队列为空条件为:在 $self.tag == 0$ 的情况下,因删除导致 $self.front == self.rear$。 -- **方式 3**:特意空出来一个位置用于区分队列已满还是队列为空。入队时少用一个队列单元,即约定以「队头指针在队尾指针的下一位置」作为队满的标志。 - - 队列已满条件为:队头指针在队尾指针的下一位置,即 $(self.rear + 1) \mod self.size == self.front$。 - - 队列为空条件为:队头指针等于队尾指针,即 $self.front == self.rear$。 +- **初始化**:队列大小为 $self.size + 1$,$self.front = self.rear = 0$。 +- **判空**:$self.front == self.rear$ +- **判满**:$(self.rear + 1) \mod self.size == self.front$ +- **入队**:判断队满,未满则 $self.rear$ 循环前进一位,插入元素。 +- **出队**:判断队空,非空则 $self.front$ 循环前进一位,返回该元素。 +- **获取队头元素**:$self.queue[(self.front + 1) \mod self.size]$ +- **获取队尾元素**:$self.queue[self.rear]$ -#### 2.3.1 循环队列的顺序存储基本描述 +![顺序存储循环队列](https://qcdn.itcharge.cn/images/202405092254537.png) -下面我们以「方式 3」中特意空出来一个位置的处理方式为例,对循环队列的顺序存储做一下基本描述。 - -![循环队列的顺序存储](https://qcdn.itcharge.cn/images/202405092254537.png) - -我们约定:$self.size$ 为循环队列的最大元素个数。队头指针 $self.front$ 指向队头元素所在位置的前一个位置,而队尾指针 $self.rear$ 指向队尾元素所在位置。 - -- **初始化空队列**:创建一个空队列,定义队列大小为 $self.size + 1$。令队头指针 $self.front$ 和队尾指针 $self.rear$ 都指向 $0$。即 $self.front = self.rear = 0$。 -- **判断队列是否为空**:根据 $self.front$ 和 $self.rear$ 的指向位置进行判断。根据约定,如果队头指针 $self.front$ 和队尾指针 $self.rear$ 相等,则说明队列为空。否则,队列不为空。 -- **判断队列是否已满**:队头指针在队尾指针的下一位置,即 $(self.rear + 1) \mod self.size == self.front$,则说明队列已满。否则,队列未满。 -- **插入元素(入队)**:先判断队列是否已满,已满直接抛出异常。如果不满,则将队尾指针 $self.rear$ 向右循环移动一位,并进行赋值操作。此时 $self.rear$ 指向队尾元素。 -- **删除元素(出队)**:先判断队列是否为空,为空直接抛出异常。如果不为空,则将队头指针 $self.front$ 指向元素赋值为 $None$,并将 $self.front$ 向右循环移动一位。 -- **获取队头元素**:先判断队列是否为空,为空直接抛出异常。如果不为空,因为 $self.front$ 指向队头元素所在位置的前一个位置,所以队头元素在 $self.front$ 后一个位置上,返回 $self.queue[(self.front + 1) \mod self.size]$。 -- **获取队尾元素**:先判断队列是否为空,为空直接抛出异常。如果不为空,因为 $self.rear$ 指向队尾元素所在位置,所以直接返回 $self.queue[self.rear]$。 - -#### 2.3.2 循环队列的顺序存储实现代码 +#### 2.2.2 顺序存储循环队列的实现代码 ```python class Queue: - # 初始化空队列 + """ + 顺序存储循环队列实现 + front 指向队头元素的前一个位置,rear 指向队尾元素所在位置 + """ def __init__(self, size=100): - self.size = size + 1 - self.queue = [None for _ in range(size + 1)] - self.front = 0 - self.rear = 0 - - # 判断队列是否为空 + """ + 初始化空队列 + :param size: 队列最大容量(实际可用容量为 size) + """ + self.size = size + 1 # 实际分配空间多一个,用于区分队满和队空 + self.queue = [None for _ in range(self.size)] # 存储队列元素 + self.front = 0 # 队头指针,指向队头元素的前一个位置 + self.rear = 0 # 队尾指针,指向队尾元素所在位置 + def is_empty(self): + """ + 判断队列是否为空 + :return: True 表示队列为空,False 表示非空 + """ return self.front == self.rear - - # 判断队列是否已满 + def is_full(self): + """ + 判断队列是否已满 + :return: True 表示队列已满,False 表示未满 + """ return (self.rear + 1) % self.size == self.front - - # 入队操作 + def enqueue(self, value): + """ + 入队操作:在队尾插入元素 + :param value: 要插入的元素 + :raises Exception: 队列已满时抛出异常 + """ if self.is_full(): raise Exception('Queue is full') - else: - self.rear = (self.rear + 1) % self.size - self.queue[self.rear] = value - - # 出队操作 + # rear 指针循环前进一位 + self.rear = (self.rear + 1) % self.size + self.queue[self.rear] = value + def dequeue(self): + """ + 出队操作:从队头删除元素并返回 + :return: 队头元素的值 + :raises Exception: 队列为空时抛出异常 + """ if self.is_empty(): raise Exception('Queue is empty') - else: - self.queue[self.front] = None - self.front = (self.front + 1) % self.size - return self.queue[self.front] - - # 获取队头元素 + # front 指针循环前进一位 + self.front = (self.front + 1) % self.size + value = self.queue[self.front] + self.queue[self.front] = None # 可选:清除引用,便于垃圾回收 + return value + def front_value(self): + """ + 获取队头元素 + :return: 队头元素的值 + :raises Exception: 队列为空时抛出异常 + """ if self.is_empty(): raise Exception('Queue is empty') - else: - value = self.queue[(self.front + 1) % self.size] - return value - - # 获取队尾元素 + return self.queue[(self.front + 1) % self.size] + def rear_value(self): + """ + 获取队尾元素 + :return: 队尾元素的值 + :raises Exception: 队列为空时抛出异常 + """ if self.is_empty(): raise Exception('Queue is empty') - else: - value = self.queue[self.rear] - return value + return self.queue[self.rear] ``` -### 2.3 队列的链式存储实现 +### 2.3 链式存储队列 -对于在使用过程中数据元素变动较大,或者说频繁进行插入和删除操作的数据结构来说,采用链式存储结构比顺序存储结构更加合适。 +当队列需要频繁插入和删除元素时,链式存储结构比顺序存储结构更高效。因此,队列常用链表实现。 -所以我们可以采用链式存储结构来实现队列。 +链式队列的实现思路如下: -1. 我们用一个线性链表来表示队列,队列中的每一个元素对应链表中的一个链节点。 -2. 再把线性链表的第 $1$ 个节点定义为队头指针 $front$,在链表最后的链节点建立指针 $rear$ 作为队尾指针。 -3. 最后限定只能在链表队头进行删除操作,在链表队尾进行插入操作,这样整个线性链表就构成了一个队列。 +1. 用单链表表示队列,每个节点存储一个元素。 +2. 用指针 $front$ 指向队头元素的前一个位置,$rear$ 指向队尾元素。 +3. 只允许在队头删除元素(出队),在队尾插入元素(入队)。 -#### 2.3.1 队列的链式存储基本描述 +#### 2.3.1 链式存储队列的基本描述 -![队列的链式存储](https://qcdn.itcharge.cn/images/202405092255125.png) +![链式存储队列](https://qcdn.itcharge.cn/images/202405092255125.png) -我们约定:队头指针 $self.front$ 指向队头元素所在位置的前一个位置,而队尾指针 $self.rear$ 指向队尾元素所在位置。 +约定:$self.front$ 指向队头元素前一个位置,$self.rear$ 指向队尾元素。 -- **初始化空队列**:建立一个链表头节点 $self.head$,令队头指针 $self.front$ 和队尾指针 $self.rear$ 都指向 $head$。即 $self.front = self.rear = head$。 -- **判断队列是否为空**:根据 $self.front$ 和 $self.rear$ 的指向位置进行判断。根据约定,如果队头指针 $self.front$ 等于队尾指针 $self.rear$,则说明队列为空。否则,队列不为空。 -- **插入元素(入队)**:创建值为 $value$ 的链表节点,插入到链表末尾,并令队尾指针 $self.rear$ 沿着链表移动 $1$ 位到链表末尾。此时 $self.rear$ 指向队尾元素。 -- **删除元素(出队)**:先判断队列是否为空,为空直接抛出异常。如果不为空,则获取队头指针 $self.front$ 下一个位置节点上的值,并将 $self.front$ 沿着链表移动 $1$ 位。如果 $self.front$ 下一个位置是 $self.rear$,则说明队列为空,此时,将 $self.rear$ 赋值为 $self.front$,令其相等。 -- **获取队头元素**:先判断队列是否为空,为空直接抛出异常。如果不为空,因为 $self.front$ 指向队头元素所在位置的前一个位置,所以队头元素在 $self.front$ 后一个位置上,返回 $self.front.next.value$。 -- **获取队尾元素**:先判断队列是否为空,为空直接抛出异常。如果不为空,因为 $self.rear$ 指向队尾元素所在位置,所以直接返回 $self.rear.value$。 +- **初始化空队列**:创建头节点 $self.head$,令 $self.front = self.rear = self.head$。 +- **队列判空**:如果 $self.front == self.rear$,队列为空。 +- **入队**:新建节点,插入链表末尾,$self.rear$ 指向新节点。 +- **出队**:如果队列为空则抛出异常,否则取 $self.front.next$ 的值,$self.front$ 前进一位。如果出队后 $self.front.next$ 为空,$self.rear = self.front$。 +- **获取队头元素**:队列非空时,返回 $self.front.next.value$。 +- **获取队尾元素**:队列非空时,返回 $self.rear.value$。 -#### 2.3.2 队列的链式存储实现代码 +#### 2.3.2 链式存储队列的实现代码 ```python class Node: + """ + 链表节点类 + """ def __init__(self, value): - self.value = value - self.next = None - + self.value = value # 节点存储的值 + self.next = None # 指向下一个节点的指针 + class Queue: - # 初始化空队列 + """ + 链式队列实现 + """ def __init__(self): - head = Node(0) - self.front = head - self.rear = head - - # 判断队列是否为空 + """ + 初始化空队列,创建一个头结点(哨兵节点),front和rear都指向头结点 + """ + head = Node(0) # 哨兵节点,不存储有效数据 + self.front = head # front指向队头元素的前一个节点 + self.rear = head # rear指向队尾节点 + def is_empty(self): + """ + 判断队列是否为空 + :return: 若队列为空返回True,否则返回False + """ return self.front == self.rear - - # 入队操作 + def enqueue(self, value): - node = Node(value) - self.rear.next = node - self.rear = node - - # 出队操作 + """ + 入队操作,在队尾插入新节点 + :param value: 要插入的元素值 + """ + node = Node(value) # 创建新节点 + self.rear.next = node # 当前队尾节点的next指向新节点 + self.rear = node # rear指针后移,指向新节点 + def dequeue(self): + """ + 出队操作,删除队头元素 + :return: 队头元素的值 + :raises Exception: 队列为空时抛出异常 + """ if self.is_empty(): raise Exception('Queue is empty') - else: - node = self.front.next - self.front.next = node.next - if self.rear == node: - self.rear = self.front - value = node.value - del node - return value - # 获取队头元素 + node = self.front.next # 队头节点(第一个有效节点) + self.front.next = node.next # front的next指向下一个节点 + if self.rear == node: # 如果出队后队列为空,rear回退到front + self.rear = self.front + value = node.value # 取出队头元素的值 + del node # 释放节点(可省略,Python自动垃圾回收) + return value + def front_value(self): + """ + 获取队头元素的值 + :return: 队头元素的值 + :raises Exception: 队列为空时抛出异常 + """ if self.is_empty(): raise Exception('Queue is empty') - else: - return self.front.next.value - - # 获取队尾元素 + + return self.front.next.value # front.next为队头节点 + def rear_value(self): + """ + 获取队尾元素的值 + :return: 队尾元素的值 + :raises Exception: 队列为空时抛出异常 + """ if self.is_empty(): raise Exception('Queue is empty') - else: - return self.rear.value + + return self.rear.value # rear为队尾节点 ``` ## 3. 队列的应用 -队列是算法和程序中最常用的辅助结构,其应用十分广泛。比如现实生活中的排队买票、银行办理业务挂号等等。队列在计算机科学领域的应用主要提现在以下两个方面: +队列作为最常用的基础数据结构之一,在算法和实际开发中有着极其广泛的应用。无论是生活中的排队买票、银行业务办理,还是计算机系统内部的任务调度,队列都扮演着不可或缺的角色。其在计算机领域的典型应用主要体现在以下两个方面: + +1. **缓解主机与外部设备之间的速度差异** + - 例如,主机输出数据的速度远快于打印机的打印速度。如果直接将数据传递给打印机,打印机无法及时处理,容易造成数据丢失。为此,通常会设置一个打印缓冲队列,将待打印的数据按顺序写入队列,打印机则按照先进先出的顺序依次取出数据进行打印。这样既保证了数据的有序输出,也提升了主机的工作效率。 -1. 解决计算机的主机与外部设备之间速度不匹配的问题。 - - 比如解决主机与打印机之间速度不匹配问题。主机输出数据给计算机打印,输出数据的速度比打印数据的速度要快很多,如果直接把数据送给打印机进行打印,由于速度不匹配,显然行不通。为此,可以设置一个打印数据缓存队列,将要打印的数据依次写入缓存队列中。然后打印机从缓冲区中按照先进先出的原则依次取出数据并且打印。这样即保证了打印数据的正确,又提高了主机的效率。 -2. 解决由于多用户引起的系统资源竞争的问题。 - - 比如说一个带有多终端的计算机系统,当有多个用户需要各自运行各自的程序时,就分别通过终端向操作系统提出占用 CPU 的请求。操作系统通常按照每个请求在时间上的先后顺序将它们排成一个队列,每次把 CPU 分配给队头请求的用户使用;当相应的程序运行结束或用完规定的时间间隔之后,将其退出队列,再把 CPU 分配给新的队头请求的用户使用。这样既能满足多用户的请求,又能使 CPU 正常运行。 - - 再比如 Linux 中的环形缓存、高性能队列 Disruptor,都用到了循环并发队列。iOS 多线程中的 GCD、NSOperationQueue 都用到了队列结构。 +2. **解决多用户环境下的系统资源竞争** + - 在多终端的计算机系统中,多个用户可能同时请求使用 CPU。操作系统会根据请求到达的先后顺序,将这些请求排成一个队列,每次优先分配 CPU 给队头的用户。当该用户的程序运行结束或时间片用完后,将其移出队列,再将 CPU 分配给新的队头用户。这样既保证了多用户的公平性,也确保了 CPU 的高效利用。 + - 此外,像 Linux 的环形缓冲区、高性能队列 Disruptor,以及 iOS 多线程中的 GCD、NSOperationQueue 等,底层都大量采用了队列结构来实现高效的数据和任务管理。 ## 练习题目 diff --git a/docs/03_stack_queue_hash_table/03_04_priority_queue.md b/docs/03_stack_queue_hash_table/03_04_priority_queue.md index 4a6d48a4..110967b2 100644 --- a/docs/03_stack_queue_hash_table/03_04_priority_queue.md +++ b/docs/03_stack_queue_hash_table/03_04_priority_queue.md @@ -1,198 +1,215 @@ ## 1. 优先队列简介 -> **优先队列(Priority Queue)**:一种特殊的队列。在优先队列中,元素被赋予优先级,当访问队列元素时,具有最高优先级的元素最先删除。 +> **优先队列(Priority Queue)**:是一种为每个元素分配优先级的特殊队列结构。每次访问或移除元素时,总是优先处理优先级最高的元素。 -优先队列与普通队列最大的不同点在于 **出队顺序**。 +优先队列与普通队列的核心区别在于 **出队顺序**: -- 普通队列的出队顺序跟入队顺序相关,符合「先进先出(First in, First out)」的规则。 -- 优先队列的出队顺序跟入队顺序无关,优先队列是按照元素的优先级来决定出队顺序的。优先级高的元素优先出队,优先级低的元素后出队。优先队列符合 **「最高级先出(First in, Largest out)」** 的规则。 +- 普通队列按照「先进先出(First In, First Out)」原则,元素按入队顺序依次出队。 +- 优先队列则根据元素的优先级决定出队顺序,优先级高的元素先出队,优先级低的元素后出队,遵循 **「优先级高者先出」** 的规则,与入队顺序无关。 -优先队列的示例图如下所示。 +下图展示了优先队列的结构示意: ![优先队列](https://qcdn.itcharge.cn/images/202405092258900.png) -## 2. 优先队列的适用场景 +优先队列在实际开发和算法设计中有着广泛的应用,常见场景包括: -优先队列的应用场景非常多,比如: +- **数据压缩**:如赫夫曼编码算法中,频率最低的节点优先合并。 +- **最短路径搜索**:如 Dijkstra 算法,优先扩展当前距离最小的节点。 +- **最小生成树构建**:如 Prim 算法,优先选择权值最小的边。 +- **任务调度**:根据任务优先级动态分配执行顺序。 +- **事件驱动仿真**:如排队系统,优先处理最早到达或优先级最高的事件。 +- **Top-K 问题**:如查找第 k 大(小)元素、实时维护前 K 个高频元素等。 -- **数据压缩**:赫夫曼编码算法; -- **最短路径算法**:Dijkstra 算法; -- **最小生成树算法**:Prim 算法; -- **任务调度器**:根据优先级执行系统任务; -- **事件驱动仿真**:顾客排队算法; -- **排序问题**:查找第 k 个最小元素。 +主流编程语言均内置了优先队列相关的数据结构。例如 Java 的 `PriorityQueue`,C++ 的 `priority_queue`,Python 可通过 `heapq` 模块实现优先队列。接下来将详细介绍优先队列的实现方式。 -很多语言都提供了优先级队列的实现。比如,Java 的 `PriorityQueue`,C++ 的 `priority_queue` 等。Python 中也可以通过 `heapq` 来实现优先队列。下面我们来讲解一下优先队列的实现。 +## 2. 优先队列的实现方式 -## 3. 优先队列的实现方式 +优先队列的基本操作与普通队列类似,主要包括 **「入队」** 和 **「出队」**,但在出队时会优先移除优先级最高的元素。 -优先队列所涉及的基本操作跟普通队列差不多,主要是 **「入队操作」** 和 **「出队操作」**。 +优先队列的实现方式主要有三种:**数组(顺序存储)**、**链表(链式存储)** 和 **二叉堆结构**。其中,最常用且高效的是基于二叉堆的实现。下面简要对比三种方案: -而优先队列的实现方式也有很多种,除了使用「数组(顺序存储)实现」与「链表(链式存储)实现」之外,我们最常用的是使用 **「二叉堆结构实现」**优先队列。以下是三种方案的介绍和总结。 +- **数组(顺序存储)**:入队时直接将元素插入数组末尾,时间复杂度为 $O(1)$;出队时需遍历整个数组以找到优先级最高的元素并删除,时间复杂度为 $O(n)$。 +- **链表(链式存储)**:链表内元素按优先级有序排列,入队时需找到合适插入位置,时间复杂度为 $O(n)$;出队时直接移除链表头节点,时间复杂度为 $O(1)$。 +- **二叉堆结构**:通过二叉堆维护优先级顺序,入队操作(插入新元素)和出队操作(弹出优先级最高元素)均为 $O(\log n)$,效率较高。 -- **数组(顺序存储)实现优先队列**:入队操作直接插入到数组队尾,时间复杂度为 $O(1)$。出队操作需要遍历整个数组,找到优先级最高的元素,返回并删除该元素,时间复杂度为 $O(n)$。 -- **链表(链式存储)实现优先队列**:链表中的元素按照优先级排序,入队操作需要为待插入元素创建节点,并在链表中找到合适的插入位置,时间复杂度为 $O(n)$。出队操作直接返回链表队头元素,并删除队头元素,时间复杂度为 $O(1)$。 -- **二叉堆结构实现优先队列**:构建一个二叉堆结构,二叉堆按照优先级进行排序。入队操作就是将元素插入到二叉堆中合适位置,时间复杂度为 $O(\log_2n)$。出队操作则返回二叉堆中优先级最大节点并删除,时间复杂度也是 $O(\log n)$。 +三种实现方式的时间复杂度对比如下: -下面是三种结构实现的优先队列入队操作和出队操作的时间复杂度总结。 +| 实现方式 | 入队操作 | 出队操作(取优先级最高元素) | +|----------|----------|------------------------------| +| 二叉堆 | $O(\log n)$ | $O(\log n)$ | +| 数组 | $O(1)$ | $O(n)$ | +| 链表 | $O(n)$ | $O(1)$ | -| | 入队操作时间复杂度 | 出队操作(取出优先级最高的元素)时间复杂度 | -| ---- | ------------------ | ------------------------------------------ | -| 堆 | $O(\log n)$ | $O(\log n)$ | -| 数组 | $O(1)$ | $O(n)$ | -| 链表 | $O(n)$ | $O(1)$ | +综上,二叉堆是实现优先队列的主流高效方案。接下来将详细介绍基于二叉堆的优先队列实现。 -从上面的表格可以看出,使用「二叉堆」这种数据结构来实现优先队列是比较高效的。下面我们来讲解一下二叉堆实现的优先队列。 +## 3. 二叉堆实现的优先队列 -## 4. 二叉堆实现的优先队列 +### 3.1 二叉堆的定义 -我们曾经在「01. 数组 - 02. 数组排序 - 07. 堆排序」中介绍过二叉堆,这里再简单介绍一下。 +二叉堆是一种完全二叉树,分为两类: -### 4.1 二叉堆的定义 +- **大顶堆**:每个节点值 ≥ 子节点值 +- **小顶堆**:每个节点值 ≤ 子节点值 -二叉堆:符合以下两个条件之一的完全二叉树: +### 3.2 二叉堆的基本操作 -- 大顶堆:根节点值 ≥ 子节点值。 -- 小顶堆:根节点值 ≤ 子节点值。 +二叉堆的核心操作有两个: -### 4.2 二叉堆的基本操作 +- **堆调整(heapAdjust)**:从某个节点出发,自上而下比较并交换,使以该节点为根的子树满足堆性质(如大顶堆则父节点 ≥ 子节点),直到整个堆有序。 +- **建堆(heapify)**:从最后一个非叶子节点开始,依次向前对每个节点执行堆调整,最终将数组整体调整为二叉堆。 -二叉堆主要涉及两个基本操作:「堆调整方法」和「将数组构建为二叉堆方法」。 +### 3.3 优先队列的基本操作 -- **堆调整方法 `heapAdjust`**:把移走了最大值元素以后的剩余元素组成的序列再构造为一个新的堆积。具体步骤如下: - - 从根节点开始,自上而下地调整节点的位置,使其成为堆积。即把序号为 $i$ 的节点与其左子树节点(序号为 $2 \times i$)、右子树节点(序号为 $2 \times i + 1$)中值最大的节点交换位置。 - - - 因为交换了位置,使得当前节点的左右子树原有的堆积特性被破坏。于是,从当前节点的左右子树节点开始,自上而下继续进行类似的调整。 - - - 如此下去直到整棵完全二叉树成为一个大顶堆。 - -- **将数组构建为二叉堆方法(初始堆建立方法) `heapify`**: -- 如果原始序列对应的完全二叉树(不一定是堆)的深度为 $d$,则从 $d - 1$ 层最右侧分支节点(序号为 $\lfloor \frac{n}{2} \rfloor$)开始,初始时令 $i = \lfloor \frac{n}{2} \rfloor$,调用堆调整算法。 - -- 每调用一次堆调整算法,执行一次 $i = i - 1$,直到 $i == 1$ 时,再调用一次,就把原始数组构建为了一个二叉堆。 +优先队列主要有两种操作: -### 4.3 优先队列的基本操作 +- **入队(heappush)**:将新元素加到数组末尾,然后从下往上调整,恢复堆结构。 +- **出队(heappop)**:将堆顶元素与末尾元素交换,弹出末尾元素,再对新堆顶自上而下调整,恢复堆结构。 -在「3. 优先队列的实现方式」中我们已经提到过,优先队列所涉及的基本操作主要是 **「入队操作」** 和 **「出队操作」**。 +### 3.4 手写二叉堆实现优先队列 -- **入队操作 `heappush`**: - - 先将待插入元素 $value$ 插入到数组 $nums$ 末尾。 - - 如果完全二叉树的深度为 $d$,则从 $d - 1$ 层开始最右侧分支节点(序号为 $\lfloor \frac{n}{2} \rfloor$)开始,初始时令 $i = \lfloor \frac{n}{2} \rfloor$,从下向上依次查找插入位置。 - - 遇到 $value$ 小于当前根节点时,将其插入到当前位置。否则继续向上寻找插入位置。 - - 如果找到插入位置或者到达根位置,将 $value$ 插入该位置。 -- **出队操作 `heappop`**: - - 交换数组 $nums$ 首尾元素,此时 $nums$ 尾部就是值最大(优先级最高)的元素,将其从 $nums$ 中弹出,并保存起来。 - - 弹出后,对 $nums$ 剩余元素调用堆调整算法,将其调整为大顶堆。 +手写二叉堆实现优先队列,常用方法包括: -### 4.4 手写二叉堆实现优先队列 - -通过手写二叉堆的方式实现优先队列。主要实现了以下五种方法: - -- `heapAdjust`:将完全二叉树调整为二叉堆。 -- `heapify`: 将数组构建为二叉堆方法(初始堆建立方法)。 -- `heappush`:向堆中添加元素,也是优先队列的入队操作。 -- `heappop`:删除堆顶元素,也是优先队列的出队操作,弹出优先队列中优先级最高的元素。 -- `heapSort`:堆排序。 +- `heapAdjust`:调整堆结构 +- `heapify`:建堆 +- `heappush`:入队 +- `heappop`:出队 +- `heapSort`:堆排序 ```python class Heapq: - # 堆调整方法:调整为大顶堆 - def heapAdjust(self, nums: [int], index: int, end: int): - left = index * 2 + 1 - right = left + 1 + # 堆调整方法:将以 index 为根的子树调整为大顶堆 + def heapAdjust(self, nums: list, index: int, end: int): + """ + nums: 堆数组 + index: 当前需要调整的根节点下标 + end: 堆的最后一个元素下标 + """ + left = index * 2 + 1 # 左子节点下标 + right = left + 1 # 右子节点下标 while left <= end: - # 当前节点为非叶子结点 - max_index = index + max_index = index # 假设当前根节点最大 + # 比较左子节点 if nums[left] > nums[max_index]: max_index = left + # 比较右子节点(注意要先判断是否越界) if right <= end and nums[right] > nums[max_index]: max_index = right if index == max_index: - # 如果不用交换,则说明已经交换结束 + # 如果根节点就是最大值,调整结束 break + # 交换根节点与最大子节点 nums[index], nums[max_index] = nums[max_index], nums[index] - # 继续调整子树 + # 继续调整被交换下去的子树 index = max_index left = index * 2 + 1 right = left + 1 - - # 将数组构建为二叉堆 - def heapify(self, nums: [int]): + + # 建堆:将数组整体调整为大顶堆 + def heapify(self, nums: list): size = len(nums) - # (size - 2) // 2 是最后一个非叶节点,叶节点不用调整 + # 从最后一个非叶子节点开始,依次向前调整 for i in range((size - 2) // 2, -1, -1): - # 调用调整堆函数 self.heapAdjust(nums, i, size - 1) - - # 入队操作 + + # 入队操作:插入新元素到堆中 def heappush(self, nums: list, value): - nums.append(value) - size = len(nums) - i = size - 1 - # 寻找插入位置 - while (i - 1) // 2 >= 0: - cur_root = (i - 1) // 2 - # value 小于当前根节点,则插入到当前位置 - if nums[cur_root] > value: + """ + nums: 堆数组 + value: 待插入的新元素 + """ + nums.append(value) # 先将新元素加到末尾 + i = len(nums) - 1 # 新元素下标 + # 自下向上调整,恢复堆结构 + while i > 0: + parent = (i - 1) // 2 # 父节点下标 + if nums[parent] >= value: + # 父节点比新元素大,插入到当前位置 break - # 继续向上查找 - nums[i] = nums[cur_root] - i = cur_root - # 找到插入位置或者到达根位置,将其插入 - nums[i] = value - - # 出队操作 + # 父节点下移 + nums[i] = nums[parent] + i = parent + nums[i] = value # 插入到最终位置 + + # 出队操作:弹出堆顶元素(最大值) def heappop(self, nums: list) -> int: + """ + nums: 堆数组 + return: 堆顶元素 + """ size = len(nums) + if size == 0: + raise IndexError("heappop from empty heap") + # 交换堆顶和末尾元素 nums[0], nums[-1] = nums[-1], nums[0] - # 得到最大值(堆顶元素)然后调整堆 - top = nums.pop() - if size > 0: + top = nums.pop() # 弹出最大值 + if size > 1: + # 重新调整堆 self.heapAdjust(nums, 0, size - 2) - return top - - # 升序堆排序 - def heapSort(self, nums: [int]): - self.heapify(nums) + + # 堆排序:原地将数组升序排序 + def heapSort(self, nums: list): + """ + nums: 待排序数组 + return: 升序排序后的数组 + """ + self.heapify(nums) # 先建堆 size = len(nums) - for i in range(size): - nums[0], nums[size - i - 1] = nums[size - i - 1], nums[0] - self.heapAdjust(nums, 0, size - i - 2) + # 依次将堆顶元素(最大值)交换到末尾,缩小堆范围 + for i in range(size - 1, 0, -1): + nums[0], nums[i] = nums[i], nums[0] # 堆顶与末尾交换 + self.heapAdjust(nums, 0, i - 1) # 调整剩余部分为大顶堆 return nums ``` -### 4.5 使用 heapq 模块实现优先队列 +### 3.5 使用 heapq 模块实现优先队列 + +Python 标准库中的 `heapq` 模块实现了高效的最小堆(小顶堆),可用于构建优先队列。其核心操作如下: -Python 中的 `heapq` 模块提供了优先队列算法。函数 `heapq.heappush()` 用于在队列 $queue$ 上插入一个元素。`heapq.heappop()` 用于在队列 $queue$ 上删除一个元素。 +- `heapq.heappush(heap, item)`:将元素 `item` 压入堆 `heap` 中,保持堆结构。 +- `heapq.heappop(heap)`:弹出并返回堆中的最小元素。 -需要注意的是:`heapq.heappop()` 函数总是返回「最小的」的元素。所以我们在使用 `heapq.heappush()` 时,将优先级设置为负数,这样就使得元素可以按照优先级从高到低排序, 这个跟普通的按优先级从低到高排序的堆排序恰巧相反。这样做的目的是为了 `heapq.heappop()` 每次弹出的元素都是优先级最高的元素。 +**注意事项**: + +- `heapq` 默认是小顶堆,即每次弹出的是最小值。 +- 若需实现「大顶堆」(每次弹出最大优先级元素),可将优先级取负数存入堆中。 +- 为保证当优先级相同时元素的入队顺序,通常可额外存储一个自增索引。 + +下面是一个基于 `heapq` 实现的优先队列类,支持自定义优先级,且保证稳定性: ```python import heapq class PriorityQueue: def __init__(self): + # 初始化一个空堆和自增索引 self.queue = [] self.index = 0 def push(self, item, priority): + """ + 入队操作,将元素 item 按照优先级 priority 压入堆中。 + 为实现大顶堆,优先级取负数;index 保证相同优先级时的稳定性。 + """ heapq.heappush(self.queue, (-priority, self.index, item)) self.index += 1 def pop(self): + """ + 出队操作,弹出并返回优先级最高的元素(大顶堆)。 + """ + if not self.queue: + raise IndexError("pop from empty priority queue") return heapq.heappop(self.queue)[-1] ``` -## 5. 优先队列的应用 +## 5. 经典例题:滑动窗口最大值 -### 5.1 滑动窗口最大值 - -#### 5.1.1 题目链接 +### 5.1.1 题目链接 - [239. 滑动窗口最大值 - 力扣(LeetCode)](https://leetcode.cn/problems/sliding-window-maximum/) -#### 5.1.2 题目大意 +### 5.1.2 题目大意 **描述**:给定一个整数数组 $nums$,再给定一个整数 $k$,表示为大小为 $k$ 的滑动窗口从数组的最左侧移动到数组的最右侧。我们只能看到滑动窗口内的 $k$ 个数字,滑动窗口每次只能向右移动一位。 @@ -224,19 +241,19 @@ class PriorityQueue: 输出:[1] ``` -#### 5.1.3 解题思路 +### 5.1.3 解题思路 -暴力求解的话,需要使用二重循环遍历,其时间复杂度为 $O(n \times k)$。根据题目给定的数据范围,肯定会超时。 +如果采用暴力解法,需要用两重循环遍历每个滑动窗口,时间复杂度为 $O(n \times k)$,在本题数据范围下会超时。 -我们可以使用优先队列来做。 +可以利用优先队列(堆)高效求解: ##### 思路 1:优先队列 -1. 初始的时候将前 $k$ 个元素加入优先队列的二叉堆中。存入优先队列的是数组值与索引构成的元组。优先队列将数组值作为优先级。 -2. 然后滑动窗口从第 $k$ 个元素开始遍历,将当前数组值和索引的元组插入到二叉堆中。 -3. 当二叉堆堆顶元素的索引已经不在滑动窗口的范围中时,即 $q[0][1] \le i - k$ 时,不断删除堆顶元素,直到最大值元素的索引在滑动窗口的范围中。 -4. 将最大值加入到答案数组中,继续向右滑动。 -5. 滑动结束时,输出答案数组。 +1. 首先,将前 $k$ 个元素以 (值, 索引) 形式加入优先队列(大顶堆),以值为优先级。 +2. 从第 $k$ 个元素开始,依次将当前元素及其索引压入堆中。 +3. 每次插入后,检查堆顶元素的索引是否已滑出窗口(即 $q[0][1] \le i - k$),若是则不断弹出堆顶,直到堆顶索引在窗口范围内。 +4. 此时堆顶元素即为当前窗口最大值,将其加入结果数组。 +5. 重复上述过程,直到遍历完整个数组,最后返回结果数组。 ##### 思路 1:代码 @@ -261,135 +278,6 @@ class Solution: - **时间复杂度**:$O(n \times \log n)$。 - **空间复杂度**:$O(k)$。 -### 5.2 前 K 个高频元素 - -#### 5.2.1 题目链接 - -- [347. 前 K 个高频元素 - 力扣(LeetCode)](https://leetcode.cn/problems/top-k-frequent-elements/) - -#### 5.2.2 题目大意 - -**描述**:给定一个整数数组 $nums$ 和一个整数 $k$。 - -**要求**:返回出现频率前 $k$ 高的元素。可以按任意顺序返回答案。 - -**说明**: - -- $1 \le nums.length \le 10^5$。 -- $k$ 的取值范围是 $[1, \text{ 数组中不相同的元素的个数}]$。 -- 题目数据保证答案唯一,换句话说,数组中前 $k$ 个高频元素的集合是唯一的。 - -**示例**: - -```python -输入: nums = [1,1,1,2,2,3], k = 2 -输出: [1,2] - - -输入: nums = [1], k = 1 -输出: [1] -``` - -#### 5.2.3 解题思路 - -##### 思路 1:哈希表 + 优先队列 - -1. 使用哈希表记录下数组中各个元素的频数。 -2. 然后将哈希表中的元素去重,转换为新数组。时间复杂度 $O(n)$,空间复杂度 $O(n)$。 -3. 使用二叉堆构建优先队列,优先级为元素频数。此时堆顶元素即为频数最高的元素。时间复杂度 $O(n)$,空间复杂度 $O(n)$。 -4. 将堆顶元素加入到答案数组中,进行出队操作。时间复杂度 $O(\log n)$。 - - 出队操作:交换堆顶元素与末尾元素,将末尾元素已移出堆。继续调整大顶堆。 -5. 不断重复第 4 步,直到 $k$ 次结束。调整 $k$ 次的时间复杂度 $O(n \times \log n)$。 - -##### 思路 1:代码 - -```python -class Heapq: - # 堆调整方法:调整为大顶堆 - def heapAdjust(self, nums: [int], nums_dict, index: int, end: int): - left = index * 2 + 1 - right = left + 1 - while left <= end: - # 当前节点为非叶子结点 - max_index = index - if nums_dict[nums[left]] > nums_dict[nums[max_index]]: - max_index = left - if right <= end and nums_dict[nums[right]] > nums_dict[nums[max_index]]: - max_index = right - if index == max_index: - # 如果不用交换,则说明已经交换结束 - break - nums[index], nums[max_index] = nums[max_index], nums[index] - # 继续调整子树 - index = max_index - left = index * 2 + 1 - right = left + 1 - - # 将数组构建为二叉堆 - def heapify(self, nums: [int], nums_dict): - size = len(nums) - # (size - 2) // 2 是最后一个非叶节点,叶节点不用调整 - for i in range((size - 2) // 2, -1, -1): - # 调用调整堆函数 - self.heapAdjust(nums, nums_dict, i, size - 1) - - # 入队操作 - def heappush(self, nums: list, nums_dict, value): - nums.append(value) - size = len(nums) - i = size - 1 - # 寻找插入位置 - while (i - 1) // 2 >= 0: - cur_root = (i - 1) // 2 - # value 小于当前根节点,则插入到当前位置 - if nums_dict[nums[cur_root]] > nums_dict[value]: - break - # 继续向上查找 - nums[i] = nums[cur_root] - i = cur_root - # 找到插入位置或者到达根位置,将其插入 - nums[i] = value - - # 出队操作 - def heappop(self, nums: list, nums_dict) -> int: - size = len(nums) - nums[0], nums[-1] = nums[-1], nums[0] - # 得到最大值(堆顶元素)然后调整堆 - top = nums.pop() - if size > 0: - self.heapAdjust(nums, nums_dict, 0, size - 2) - - return top - -class Solution: - def topKFrequent(self, nums: List[int], k: int) -> List[int]: - # 统计元素频数 - nums_dict = dict() - for num in nums: - if num in nums_dict: - nums_dict[num] += 1 - else: - nums_dict[num] = 1 - - # 使用 set 方法去重,得到新数组 - new_nums = list(set(nums)) - size = len(new_nums) - - heap = Heapq() - queue = [] - for num in new_nums: - heap.heappush(queue, nums_dict, num) - - res = [] - for i in range(k): - res.append(heap.heappop(queue, nums_dict)) - return res -``` - -##### 思路 1:复杂度分析 - -- **时间复杂度**:$O(n \times \log n)$。 -- **空间复杂度**:$O(n)$。 ## 练习题目 diff --git a/docs/03_stack_queue_hash_table/03_05_bidirectional_queue.md b/docs/03_stack_queue_hash_table/03_05_bidirectional_queue.md index e69de29b..622c0b93 100644 --- a/docs/03_stack_queue_hash_table/03_05_bidirectional_queue.md +++ b/docs/03_stack_queue_hash_table/03_05_bidirectional_queue.md @@ -0,0 +1,393 @@ +## 1. 双向队列简介 + +> **双向队列(Deque,Double-Ended Queue)**:一种线性表数据结构,允许在队列的两端进行插入和删除操作,既可以从队头入队/出队,也可以从队尾入队/出队。 + +### 1.1 基本概念 + +双向队列可以看作是栈和队列的结合体,具有以下特点: + +- **队头(front)**:队列的前端,可以进行插入和删除操作 +- **队尾(rear)**:队列的后端,也可以进行插入和删除操作 +- **空队列**:没有任何数据元素的双向队列 + +### 1.2 核心特性 + +双向队列的操作遵循 **双端操作** 的原则: +- 可以在队列的两端进行插入和删除操作 +- 既具有栈的"后进先出"特性,又具有队列的"先进先出"特性 +- 提供了比普通队列更灵活的操作方式 + +### 1.3 基本操作 + +双向队列支持以下基本操作: + +- **队头入队(push_front)**:在队头插入元素 +- **队头出队(pop_front)**:从队头删除并返回元素 +- **队尾入队(push_back)**:在队尾插入元素 +- **队尾出队(pop_back)**:从队尾删除并返回元素 +- **查看队头元素(peek_front)**:查看队头元素但不删除 +- **查看队尾元素(peek_back)**:查看队尾元素但不删除 + +下图展示了双向队列的结构和操作方式: + +![双向队列结构](https://qcdn.itcharge.cn/images/202405092300123.png) + +## 2. 双向队列的实现方式 + +双向队列可以通过 **顺序存储** 和 **链式存储** 两种方式实现。由于双向队列需要在两端进行操作,链式存储通常更加高效和灵活。 + +### 2.1 链式存储双向队列 + +链式存储是双向队列最常用的实现方式,使用双向链表结构,每个节点都有指向前后节点的指针。 + +#### 2.1.1 链式存储双向队列的基本描述 + +![链式存储双向队列](https://qcdn.itcharge.cn/images/202405092300456.png) + +我们使用双向链表实现双向队列: + +- **节点结构**:每个节点包含数据域和两个指针域(prev 和 next) +- **头尾指针**:维护指向队头节点和队尾节点的指针 +- **哨兵节点**:可以使用哨兵节点简化边界处理 + +#### 2.1.2 链式存储双向队列的实现代码 + +```python +class Node: + """双向链表节点""" + def __init__(self, value): + self.value = value # 节点值 + self.prev = None # 指向前一个节点的指针 + self.next = None # 指向后一个节点的指针 + +class Deque: + """双向队列实现""" + def __init__(self): + """初始化空双向队列""" + # 创建哨兵节点 + self.head = Node(0) # 头哨兵节点 + self.tail = Node(0) # 尾哨兵节点 + self.head.next = self.tail + self.tail.prev = self.head + self.size = 0 # 队列大小 + + def is_empty(self): + """判断队列是否为空""" + return self.size == 0 + + def get_size(self): + """获取队列大小""" + return self.size + + def push_front(self, value): + """队头入队""" + new_node = Node(value) + # 在头哨兵节点后插入新节点 + new_node.next = self.head.next + new_node.prev = self.head + self.head.next.prev = new_node + self.head.next = new_node + self.size += 1 + + def push_back(self, value): + """队尾入队""" + new_node = Node(value) + # 在尾哨兵节点前插入新节点 + new_node.prev = self.tail.prev + new_node.next = self.tail + self.tail.prev.next = new_node + self.tail.prev = new_node + self.size += 1 + + def pop_front(self): + """队头出队""" + if self.is_empty(): + raise Exception('Deque is empty') + + # 删除头哨兵节点后的第一个节点 + node = self.head.next + self.head.next = node.next + node.next.prev = self.head + self.size -= 1 + return node.value + + def pop_back(self): + """队尾出队""" + if self.is_empty(): + raise Exception('Deque is empty') + + # 删除尾哨兵节点前的第一个节点 + node = self.tail.prev + self.tail.prev = node.prev + node.prev.next = self.tail + self.size -= 1 + return node.value + + def peek_front(self): + """查看队头元素""" + if self.is_empty(): + raise Exception('Deque is empty') + return self.head.next.value + + def peek_back(self): + """查看队尾元素""" + if self.is_empty(): + raise Exception('Deque is empty') + return self.tail.prev.value +``` + +- **时间复杂度**:所有操作均为 O(1) + +### 2.2 顺序存储双向队列 + +顺序存储双向队列可以使用数组实现,但需要处理循环队列的问题以避免"假溢出"。 + +#### 2.2.1 顺序存储双向队列的基本描述 + +![顺序存储双向队列](https://qcdn.itcharge.cn/images/202405092301234.png) + +使用循环数组实现双向队列: + +- **数组结构**:使用固定大小的数组存储元素 +- **头尾指针**:维护队头和队尾的位置 +- **循环处理**:通过取模运算实现循环队列 + +#### 2.2.2 顺序存储双向队列的实现代码 + +```python +class Deque: + """顺序存储双向队列实现""" + def __init__(self, capacity=100): + """初始化双向队列""" + self.capacity = capacity + self.queue = [None] * capacity + self.front = 0 # 队头指针 + self.rear = 0 # 队尾指针 + self.size = 0 # 队列大小 + + def is_empty(self): + """判断队列是否为空""" + return self.size == 0 + + def is_full(self): + """判断队列是否已满""" + return self.size == self.capacity + + def get_size(self): + """获取队列大小""" + return self.size + + def push_front(self, value): + """队头入队""" + if self.is_full(): + raise Exception('Deque is full') + + # 队头指针向前移动 + self.front = (self.front - 1) % self.capacity + self.queue[self.front] = value + self.size += 1 + + def push_back(self, value): + """队尾入队""" + if self.is_full(): + raise Exception('Deque is full') + + self.queue[self.rear] = value + # 队尾指针向后移动 + self.rear = (self.rear + 1) % self.capacity + self.size += 1 + + def pop_front(self): + """队头出队""" + if self.is_empty(): + raise Exception('Deque is empty') + + value = self.queue[self.front] + # 队头指针向后移动 + self.front = (self.front + 1) % self.capacity + self.size -= 1 + return value + + def pop_back(self): + """队尾出队""" + if self.is_empty(): + raise Exception('Deque is empty') + + # 队尾指针向前移动 + self.rear = (self.rear - 1) % self.capacity + value = self.queue[self.rear] + self.size -= 1 + return value + + def peek_front(self): + """查看队头元素""" + if self.is_empty(): + raise Exception('Deque is empty') + return self.queue[self.front] + + def peek_back(self): + """查看队尾元素""" + if self.is_empty(): + raise Exception('Deque is empty') + return self.queue[(self.rear - 1) % self.capacity] +``` + +- **时间复杂度**:所有操作均为 O(1) + +### 2.3 两种实现方式对比 + +| 特性 | 链式存储 | 顺序存储 | +|------|----------|----------| +| 空间利用率 | 按需分配,无浪费 | 固定大小,可能浪费 | +| 扩容操作 | 无需扩容 | 需要重新分配空间 | +| 内存碎片 | 可能产生碎片 | 较少 | +| 实现复杂度 | 相对复杂 | 简单 | +| 缓存性能 | 较差 | 较好 | + +## 3. 经典例题:设计循环双端队列 + +### 3.1 题目链接 + +- [641. 设计循环双端队列 - 力扣(LeetCode)](https://leetcode.cn/problems/design-circular-deque/) + +### 3.2 题目大意 + +**描述**:设计实现双端队列。 + +**要求**:实现 `MyCircularDeque` 类: + +- `MyCircularDeque(int k)`:构造函数,双端队列最大为 $k$。 +- `boolean insertFront()`:将一个元素添加到双端队列头部。如果操作成功返回 $true$,否则返回 $false$。 +- `boolean insertLast()`:将一个元素添加到双端队列尾部。如果操作成功返回 $true$,否则返回 $false$。 +- `boolean deleteFront()`:从双端队列头部删除一个元素。如果操作成功返回 $true$,否则返回 $false$。 +- `boolean deleteLast()`:从双端队列尾部删除一个元素。如果操作成功返回 $true$,否则返回 $false$。 +- `int getFront()`:从双端队列头部获得一个元素。如果双端队列为空,返回 $-1$。 +- `int getRear()`:获得双端队列的最后一个元素。如果双端队列为空,返回 $-1$。 +- `boolean isEmpty()`:若双端队列为空,则返回 $true$,否则返回 $false$。 +- `boolean isFull()`:若双端队列满了,则返回 $true$,否则返回 $false$。 + +### 3.3 解题思路 + +##### 思路 1:数组实现 + +使用数组实现循环双端队列,通过头尾指针和取模运算实现循环操作。 + +##### 思路 1:代码 + +```python +class MyCircularDeque: + def __init__(self, k: int): + """初始化双端队列""" + self.capacity = k + self.queue = [0] * k + self.front = 0 # 队头指针 + self.rear = 0 # 队尾指针 + self.size = 0 # 队列大小 + + def insertFront(self, value: int) -> bool: + """在队头插入元素""" + if self.isFull(): + return False + + # 队头指针向前移动 + self.front = (self.front - 1) % self.capacity + self.queue[self.front] = value + self.size += 1 + return True + + def insertLast(self, value: int) -> bool: + """在队尾插入元素""" + if self.isFull(): + return False + + self.queue[self.rear] = value + # 队尾指针向后移动 + self.rear = (self.rear + 1) % self.capacity + self.size += 1 + return True + + def deleteFront(self) -> bool: + """删除队头元素""" + if self.isEmpty(): + return False + + # 队头指针向后移动 + self.front = (self.front + 1) % self.capacity + self.size -= 1 + return True + + def deleteLast(self) -> bool: + """删除队尾元素""" + if self.isEmpty(): + return False + + # 队尾指针向前移动 + self.rear = (self.rear - 1) % self.capacity + self.size -= 1 + return True + + def getFront(self) -> int: + """获取队头元素""" + if self.isEmpty(): + return -1 + return self.queue[self.front] + + def getRear(self) -> int: + """获取队尾元素""" + if self.isEmpty(): + return -1 + return self.queue[(self.rear - 1) % self.capacity] + + def isEmpty(self) -> bool: + """判断队列是否为空""" + return self.size == 0 + + def isFull(self) -> bool: + """判断队列是否已满""" + return self.size == self.capacity +``` + +##### 思路 1:复杂度分析 + +- **时间复杂度**:所有操作均为 O(1) +- **空间复杂度**:O(k) + + +## 4. 总结 + +### 4.1 优点 + +- **操作灵活**:支持在队列两端进行插入和删除操作 +- **效率高**:所有基本操作的时间复杂度均为 O(1) +- **功能强大**:可以模拟栈和队列的行为 +- **应用广泛**:在滑动窗口、单调队列等算法中发挥重要作用 + +### 4.2 缺点 + +- **实现复杂**:相比普通队列,实现逻辑更加复杂 +- **内存开销**:链式实现需要额外的指针空间 +- **缓存性能**:链式实现在缓存性能上不如数组实现 + +### 4.3 适用场景 + +- **滑动窗口问题**:如滑动窗口最大值、最小值等 +- **单调队列**:维护单调递增或递减序列 +- **双端操作**:需要在序列两端频繁操作的场景 +- **算法优化**:某些算法的时间复杂度优化 + +## 练习题目 + +- [0239. 滑动窗口最大值](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0200-0299/sliding-window-maximum.md) +- [0641. 设计循环双端队列](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0600-0699/design-circular-deque.md) +- [0862. 和至少为 K 的最短子数组](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0800-0899/shortest-subarray-with-sum-at-least-k.md) +- [0901. 股票价格跨度](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0900-0999/online-stock-span.md) + +- [双向队列基础题目列表](https://github.com/ITCharge/AlgoNote/tree/main/docs/00_preface/00_06_categories_list.md#%E5%8F%8C%E5%90%91%E9%98%9F%E5%88%97%E5%9F%BA%E7%A1%80%E9%A2%98%E7%9B%AE) + +## 参考资料 + +- 【书籍】数据结构与算法 Python 语言描述 - 裘宗燕 著 +- 【书籍】数据结构教程 第 3 版 - 唐发根 著 +- 【书籍】大话数据结构 程杰 著 +- 【文章】[双端队列 - 数据结构与算法之美 - 极客时间](https://time.geekbang.org/column/article/41330) +- 【文章】[Python collections.deque 详解 - 菜鸟教程](https://www.runoob.com/python3/python3-collections-deque.html) diff --git a/docs/03_stack_queue_hash_table/03_06_hash_table.md b/docs/03_stack_queue_hash_table/03_06_hash_table.md index 8bac1355..b4ad953a 100644 --- a/docs/03_stack_queue_hash_table/03_06_hash_table.md +++ b/docs/03_stack_queue_hash_table/03_06_hash_table.md @@ -1,75 +1,75 @@ ## 1. 哈希表简介 -> **哈希表(Hash Table)**:也叫做散列表。是根据关键码值(Key Value)直接进行访问的数据结构。 +> **哈希表(Hash Table)**,又称散列表,是一种能通过关键码(Key)直接访问数据的结构。 > -> 哈希表通过「键 $key$」和「映射函数 $Hash(key)$」计算出对应的「值 $value$」,把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做「哈希函数(散列函数)」,存放记录的数组叫做「哈希表(散列表)」。 +> 哈希表利用「键 $key$」和「哈希函数 $Hash(key)$」将关键码映射到表中的某个位置,从而实现高效的查找和存储。这个映射过程由「哈希函数(散列函数)」完成,存储数据的数组称为「哈希表(散列表)」。 -哈希表的关键思想是使用哈希函数,将键 $key$ 映射到对应表的某个区块中。我们可以将算法思想分为两个部分: +哈希表的核心思想是:通过哈希函数将键 $key$ 映射到表的某个区块,实现高效的数据插入与查找。其基本操作流程如下: -- **向哈希表中插入一个关键码值**:哈希函数决定该关键字的对应值应该存放到表中的哪个区块,并将对应值存放到该区块中。 -- **在哈希表中搜索一个关键码值**:使用相同的哈希函数从哈希表中查找对应的区块,并在特定的区块搜索该关键字对应的值。 +- **插入关键码**:利用哈希函数计算关键字应存放的区块索引,然后将数据存入该区块。 +- **查找关键码**:用相同的哈希函数定位区块,再在该区块中查找目标数据。 -哈希表的原理示例图如下所示: +如下图所示为哈希表的原理示意: ![哈希表](https://qcdn.itcharge.cn/images/202405092317578.png) -在上图例子中,我们使用 $value = Hash(key) = key // 1000$ 作为哈希函数。$//$ 符号代表整除。我们以这个例子来说明一下哈希表的插入和查找策略。 +以图中为例,假设哈希函数为 $Hash(key) = key // 1000$($//$ 表示整除),则哈希表的插入和查找过程如下: -- **向哈希表中插入一个关键码值**:通过哈希函数解析关键字,并将对应值存放到该区块中。 - - 比如:$0138$ 通过哈希函数 $Hash(key) = 0138 // 1000 = 0$,得出应将 $0138$ 分配到 $0$ 所在的区块中。 -- **在哈希表中搜索一个关键码值**:通过哈希函数解析关键字,并在特定的区块搜索该关键字对应的值。 - - 比如:查找 $2321$,通过哈希函数,得出 $2321$ 应该在 $2$ 所对应的区块中。然后我们从 $2$ 对应的区块中继续搜索,并在 $2$ 对应的区块中成功找到了 $2321$。 - - 比如:查找 $3214$,通过哈希函数,得出 $3214$ 应该在 $3$ 所对应的区块中。然后我们从 $3$ 对应的区块中继续搜索,但并没有找到对应值,则说明 $3214$ 不在哈希表中。 +- **插入**:如 $0138$,通过 $Hash(0138) = 0$,应存入第 $0$ 区块。 +- **查找**: + - 查找 $2321$,$Hash(2321) = 2$,在第 $2$ 区块中找到 $2321$。 + - 查找 $3214$,$Hash(3214) = 3$,在第 $3$ 区块未找到,说明 $3214$ 不在哈希表中。 -哈希表在生活中的应用也很广泛,其中一个常见例子就是「查字典」。 +哈希表在实际生活中也有广泛应用,例如「查字典」: -比如为了查找 **「赞」** 这个字的具体意思,我们在字典中根据这个字的拼音索引 `zan`,查找到对应的页码为 $599$。然后我们就可以翻到字典的第 $599$ 页查看 **「赞」** 字相关的解释了。 +查找 **「赞」** 这个字时,我们根据拼音索引 `zan` 查到页码 $599$,然后翻到第 $599$ 页即可查看解释。 ![查字典](https://qcdn.itcharge.cn/images/20220111174223.png) 在这个例子中: -- 存放所有拼音和对应地址的表可以看做是 **「哈希表」**。 -- **「赞」** 字的拼音索引 `zan` 可以看做是哈希表中的 **「关键字 $key$」**。 -- 根据拼音索引 $zan$ 来确定字对应页码的过程可以看做是哈希表中的 **「哈希函数 $Hash(key)$」**。 -- 查找到的对应页码 $599$ 可以看做是哈希表中的 **「哈希地址 $value$」**。 +- 存放拼音与页码的表,相当于 **哈希表**。 +- **「赞」** 的拼音 `zan`,相当于哈希表的 **关键字 $key$**。 +- 通过拼音查页码的过程,相当于 **哈希函数 $Hash(key)$** 的作用。 +- 查到的页码 $599$,相当于哈希表中的 **哈希地址 $value$**。 ## 2. 哈希函数 -> **哈希函数(Hash Function)**:将哈希表中元素的关键键值映射为元素存储位置的函数。 +> **哈希函数(Hash Function)**:是一种将元素的关键字映射为哈希表存储位置的函数。 -哈希函数是哈希表中最重要的部分。一般来说,哈希函数会满足以下几个条件: +哈希函数是哈希表设计的核心。一个优秀的哈希函数通常应具备以下特点: -- 哈希函数应该易于计算,并且尽量使计算出来的索引值均匀分布。 -- 哈希函数计算得到的哈希值是一个固定长度的输出值。 -- 如果 $Hash(key1) \ne Hash(key2)$,那么 $key1$、$key2$ 一定不相等。 -- 如果 $Hash(key1) == Hash(key2)$,那么 $key1$、$key2$ 可能相等,也可能不相等(会发生哈希碰撞)。 +- 计算简单高效,便于实现; +- 能将关键字均匀分布到哈希表的各个位置,减少冲突; +- 输出哈希值为固定长度; +- 如果 $Hash(key1) \ne Hash(key2)$,则 $key1$ 和 $key2$ 必然不同; +- 如果 $Hash(key1) = Hash(key2)$,则 $key1$ 和 $key2$ 可能相同,也可能不同(即可能发生哈希冲突)。 -在哈希表的实际应用中,关键字的类型除了数字类,还有可能是字符串类型、浮点数类型、大整数类型,甚至还有可能是几种类型的组合。一般我们会将各种类型的关键字先转换为整数类型,再通过哈希函数,将其映射到哈希表中。 +在实际应用中,关键字类型可能为数字、字符串、浮点数、大整数,甚至多种类型的组合。通常会先将各种类型的关键字转换为整数,再通过哈希函数映射到哈希表中。 -而关于整数类型的关键字,通常用到的哈希函数方法有:直接定址法、除留余数法、平方取中法、基数转换法、数字分析法、折叠法、随机数法、乘积法、点积法等。下面我们介绍几个常用的哈希函数方法。 +针对整数类型关键字,常见的哈希函数设计方法包括:直接定址法、除留余数法、平方取中法、基数转换法、数字分析法、折叠法、随机数法、乘积法、点积法等。下面介绍几种常用方法: ### 2.1 直接定址法 -- **直接定址法**:取关键字本身 / 关键字的某个线性函数值作为哈希地址。即:$Hash(key) = key$ 或者 $Hash(key) = a \times key + b$,其中 $a$ 和 $b$ 为常数。 +- **直接定址法**:直接取关键字本身或其线性函数作为哈希地址,即 $Hash(key) = key$ 或 $Hash(key) = a \times key + b$,其中 $a$、$b$ 为常数。 -这种方法计算最简单,且不会产生冲突。适合于关键字分布基本连续的情况,如果关键字分布不连续,空位较多,则会造成存储空间的浪费。 +该方法计算极为简单,且不会产生冲突,适用于关键字分布连续的场景。如果关键字分布稀疏,则会造成空间浪费。 -举一个例子,假设我们有一个记录了从 $1$ 岁到 $100$ 岁的人口数字统计表。其中年龄为关键字,哈希函数取关键字自身,如下表所示。 +例如,统计 $1 \sim 100$ 岁人口,年龄为关键字,哈希函数取关键字本身: | 年龄 | 1 | 2 | 3 | ... | 25 | 26 | 27 | ... | 100 | | :--: | :--: | :--: | :--: | :--: | :--: | :--: | :--: | :--: | :--: | | 人数 | 3000 | 2000 | 5000 | ... | 1050 | ... | ... | ... | ... | -比如我们想要查询 $25$ 岁的人有多少,则只要查询表中第 $25$ 项即可。 +假如想要查询 $25$ 岁人数,直接访问第 $25$ 项即可。 ### 2.2 除留余数法 -- **除留余数法**:假设哈希表的表长为 $m$,取一个不大于 $m$ 但接近或等于 $m$ 的质数 $p$,利用取模运算,将关键字转换为哈希地址。即:$Hash(key) = key \mod p$,其中 $p$ 为不大于 $m$ 的质数。 +- **除留余数法**:设哈希表长度为 $m$,选取不大于 $m$ 的质数 $p$,用 $Hash(key) = key \mod p$ 计算哈希地址。 -这也是一种简单且常用的哈希函数方法。其关键点在于 $p$ 的选择。根据经验而言,一般 $p$ 取素数或者 $m$,这样可以尽可能的减少冲突。 +这是最常用的哈希函数之一。关键在于 $p$ 的选择,通常取质数或与 $m$ 接近的数,以减少冲突。 -比如我们需要将 $7$ 个数 $[432, 5, 128, 193, 92, 111, 88]$ 存储在 $11$ 个区块中(长度为 $11$ 的数组),通过除留余数法将这 $7$ 个数应分别位于如下地址: +例如,将 $[432, 5, 128, 193, 92, 111, 88]$ 存入长度为 $11$ 的哈希表,哈希地址如下: | 索引 | 00 | 01 | 02 | 03 | 04 | 05 | 06 | 07 | 08 | 09 | 10 | | :--: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | @@ -77,81 +77,82 @@ ### 2.3 平方取中法 -- **平方取中法**:先通过求关键字平方值的方式扩大相近数之间的差别,然后根据表长度取关键字平方值的中间几位数为哈希地址。 - - 比如:$Hash(key) = (key \times key) // 100 \mod 1000$,先计算平方,去除末尾的 $2$ 位数,再取中间 $3$ 位数作为哈希地址。 +- **平方取中法**:先对关键字平方,再取中间若干位作为哈希地址。例如 $Hash(key) = (key \times key) // 100 \mod 1000$,即先平方,去掉末尾两位,再取中间三位。 -这种方法因为关键字平方值的中间几位数和原关键字的每一位数都相关,所以产生的哈希地址也比较均匀,有利于减少冲突的发生。 +该方法能有效利用关键字的各位信息,使哈希地址分布更均匀,减少冲突。 ### 2.4 基数转换法 -- **基数转换法**:将关键字看成另一种进制的数再转换成原来进制的数,然后选其中几位作为哈希地址。 - - 比如,将关键字看做是 $13$ 进制的数,再将其转变为 $10$ 进制的数,将其作为哈希地址。 +- **基数转换法**:将关键字视为某一进制数,转换为另一进制后,选取部分位作为哈希地址。 + - 例如,将关键字视为 $13$ 进制,转换为 $10$ 进制后作为哈希地址。 -以 $343246$ 为例,哈希地址计算方式如下: +以 $343246$ 为例,计算如下: $343246_{13} = 3 \times 13^5 + 4 \times 13^4 + 3 \times 13^3 + 2 \times 13^2 + 4 \times 13^1 + 6 \times 13^0 = 1235110_{10}$ ## 3. 哈希冲突 -> **哈希冲突(Hash Collision)**:不同的关键字通过同一个哈希函数可能得到同一哈希地址,即 $key1 \ne key2$,而 $Hash(key1) == Hash(key2)$,这种现象称为哈希冲突。 +> **哈希冲突(Hash Collision)**:指不同的关键字经过同一个哈希函数后,得到相同的哈希地址,即 $key1 \ne key2$,但 $Hash(key1) == Hash(key2)$,这种现象称为哈希冲突。 -理想状态下,我们的哈希函数是完美的一对一映射,即一个关键字($key$)对应一个值($value$),不需要处理冲突。但是一般情况下,不同的关键字 $key$ 可能对应了同一个值 $value$,这就发生了哈希冲突。 +理想情况下,哈希函数能够实现一一映射,每个关键字($key$)都对应唯一的存储位置($value$),无需处理冲突。但在实际应用中,即使哈希函数设计得再好,也难以完全避免不同关键字映射到同一地址的情况,即哈希冲突不可避免。 -设计再好的哈希函数也无法完全避免哈希冲突。所以就需要通过一定的方法来解决哈希冲突问题。常用的哈希冲突解决方法主要是两类:**「开放地址法(Open Addressing)」** 和 **「链地址法(Chaining)」**。 +因此,必须采用一定的策略来解决哈希冲突。常见的哈希冲突解决方法主要有两大类:**开放地址法(Open Addressing)** 和 **链地址法(Chaining)**。 ### 3.1 开放地址法 -> **开放地址法(Open Addressing)**:指的是将哈希表中的「空地址」向处理冲突开放。当哈希表未满时,处理冲突时需要尝试另外的单元,直到找到空的单元为止。 +> **开放地址法(Open Addressing)**:当哈希冲突发生时,通过探查哈希表中的其他「空地址」来存放冲突元素,直到找到空位为止。 -当发生冲突时,开放地址法按照下面的方法求得后继哈希地址:$H(i) = (Hash(key) + F(i)) \mod m$,$i = 1, 2, 3, ..., n (n ≤ m - 1)$。 -- $H(i)$ 是在处理冲突中得到的地址序列。即在第 1 次冲突($i = 1$)时经过处理得到一个新地址 $H(1)$,如果在 $H(1)$ 处仍然发生冲突($i = 2$)时经过处理时得到另一个新地址 $H(2)$ …… 如此下去,直到求得的 $H(n)$ 不再发生冲突。 -- $Hash(key)$ 是哈希函数,$m$ 是哈希表表长,对哈希表长取余的目的是为了使得到的下一个地址一定落在哈希表中。 -- $F(i)$ 是冲突解决方法,取法可以有以下几种: - - 线性探测法:$F(i) = 1, 2, 3, ..., m - 1$。 - - 二次探测法:$F(i) = 1^2, -1^2, 2^2, -2^2, ..., \pm n^2(n \le m / 2)$。 - - 伪随机数序列:$F(i) = \text{伪随机数序列}$。 +具体做法是:当发生冲突时,按照如下公式计算下一个可用地址:$H(i) = (Hash(key) + F(i)) \mod m$,其中 $i = 1, 2, 3, ..., n$,$n \le m-1$。 +- $H(i)$ 表示第 $i$ 次探查得到的地址。每次冲突时,依次尝试新的地址,直到找到空位。 +- $Hash(key)$ 是哈希函数,$m$ 是哈希表长度,取模保证地址在表内。 +- $F(i)$ 是探查增量,常见方式有: + - 线性探查:$F(i) = i$,即每次向后顺序查找。 + - 二次探查:$F(i) = \pm i^2$,即以二次方步长向前后查找。 + - 伪随机探查:$F(i)$ 为伪随机数序列。 +举例说明:假设哈希表长度为 $11$,哈希函数 $Hash(key) = key \mod 11$,已存入 $28$、$49$、$18$,现插入 $38$,其哈希地址为 $5$,发生冲突。分别采用三种方法处理: -举个例子说说明一下如何用以上三种冲突解决方法处理冲突,并得到新地址 $H(i)$。例如,在长度为 $11$ 的哈希表中已经填有关键字分别为 $28$、$49$、$18$ 的记录(哈希函数为 $Hash(key) = key \mod 11$)。现在将插入关键字为 $38$ 的新纪录。根据哈希函数得到的哈希地址为 $5$,产生冲突。接下来分别使用这三种冲突解决方法处理冲突。 +- 线性探查:$H(1) = (5 + 1) \mod 11 = 6$(冲突),$H(2) = (5 + 2) \mod 11 = 7$(冲突),$H(3) = (5 + 3) \mod 11 = 8$(空位),将 $38$ 存入 $8$。 +- 二次探查:$H(1) = (5 + 1^2) \mod 11 = 6$(冲突),$H(2) = (5 - 1^2) \mod 11 = 4$(空位),将 $38$ 存入 $4$。 +- 伪随机探查:假设伪随机数为 $9$,$H(1) = (5+9)\mod 11 = 3$(空位),将 $38$ 存入 $3$。 -- 使用线性探测法:得到下一个地址 $H(1) = (5 + 1) \mod 11 = 6$,仍然冲突;继续求出 $H(2) = (5 + 2) \mod 11 = 7$,仍然冲突;继续求出 $H(3) = (5 + 3) \mod 11 = 8$,$8$ 对应的地址为空,处理冲突过程结束,记录填入哈希表中序号为 $8$ 的位置。 -- 使用二次探测法:得到下一个地址 $H(1) = (5 + 1 \times 1) \mod 11 = 6$,仍然冲突;继续求出 $H(2) = (5 - 1 \times 1) \mod 11 = 4$,$4$ 对应的地址为空,处理冲突过程结束,记录填入哈希表中序号为 $4$ 的位置。 -- 使用伪随机数序列:假设伪随机数为 $9$,则得到下一个地址 $H(1) = (9 + 5) \mod 11 = 3$,$3$ 对应的地址为空,处理冲突过程结束,记录填入哈希表中序号为 $3$ 的位置。 - -使用这三种方法处理冲突的结果如下图所示: +如下图所示: ![开放地址法](https://qcdn.itcharge.cn/images/202405092318809.png) ### 3.2 链地址法 -> **链地址法(Chaining)**:将具有相同哈希地址的元素(或记录)存储在同一个线性链表中。 - -链地址法是一种更加常用的哈希冲突解决方法。相比于开放地址法,链地址法更加简单。 +> **链地址法(Chaining)**:将哈希地址相同的元素以链表的形式存储在同一个槽位中。 -我们假设哈希函数产生的哈希地址区间为 $[0, m - 1]$,哈希表的表长为 $m$。则可以将哈希表定义为一个有 $m$ 个头节点组成的链表指针数组 $T$。 +链地址法是实际应用中更常用的哈希冲突解决方案。与开放地址法相比,链地址法实现更为简洁灵活。 -- 这样在插入关键字的时候,我们只需要通过哈希函数 $Hash(key)$ 计算出对应的哈希地址 $i$,然后将其以链表节点的形式插入到以 $T[i]$ 为头节点的单链表中。在链表中插入位置可以在表头或表尾,也可以在中间。如果每次插入位置为表头,则插入操作的时间复杂度为 $O(1)$。 +假设哈希表长度为 $m$,哈希函数输出范围为 $[0, m - 1]$,则哈希表可以看作是 $m$ 个链表头指针组成的数组 $T$。 -- 而在在查询关键字的时候,我们只需要通过哈希函数 $Hash(key)$ 计算出对应的哈希地址 $i$,然后将对应位置上的链表整个扫描一遍,比较链表中每个链节点的键值与查询的键值是否一致。查询操作的时间复杂度跟链表的长度 $k$ 成正比,也就是 $O(k)$。对于哈希地址比较均匀的哈希函数来说,理论上讲,$k = n // m$,其中 $n$ 为关键字的个数,$m$ 为哈希表的表长。 +- 插入时,先通过 $Hash(key)$ 计算哈希地址 $i$,再将元素以链表节点的形式插入 $T[i]$ 指向的链表中。通常插入到表头,时间复杂度为 $O(1)$。 +- 查询时,同样先计算哈希地址 $i$,然后遍历 $T[i]$ 链表,查找目标关键字。查找复杂度与链表长度 $k$ 成正比,即 $O(k)$。若哈希函数分布均匀,$k \approx n/m$,$n$ 为元素总数。 -举个例子来说明如何使用链地址法处理冲突。假设现在要存入的关键字集合 $keys = [88, 60, 65, 69, 90, 39, 07, 06, 14, 44, 52, 70, 21, 45, 19, 32]$。再假定哈希函数为 $Hash(key) = key \mod 13$,哈希表的表长 $m = 13$,哈希地址范围为 $[0, m - 1]$。将这些关键字使用链地址法处理冲突,并按顺序加入哈希表中(图示为插入链表表尾位置),最终得到的哈希表如下图所示。 +举例:将 $keys = [88, 60, 65, 69, 90, 39, 07, 06, 14, 44, 52, 70, 21, 45, 19, 32]$ 依次插入哈希表,哈希函数 $Hash(key) = key \mod 13$,表长 $m=13$。采用链地址法,插入结果如下图: ![链地址法](https://qcdn.itcharge.cn/images/202405092319327.png) -相对于开放地址法,采用链地址法处理冲突要多占用一些存储空间(主要是链节点占用空间)。但它可以减少在进行插入和查找具有相同哈希地址的关键字的操作过程中的平均查找长度。这是因为在链地址法中,待比较的关键字都是具有相同哈希地址的元素,而在开放地址法中,待比较的关键字不仅包含具有相同哈希地址的元素,而且还包含哈希地址不相同的元素。 +与开放地址法相比,链地址法虽然需要额外的链表节点存储空间,但能有效降低插入和查找时的平均查找长度。因为链地址法只需比较哈希地址相同的元素,而开放地址法可能需要探查多个不同地址的元素。 ## 4. 哈希表总结 -本文讲解了一些比较基础、偏理论的哈希表知识。包含哈希表的定义,哈希函数、哈希冲突以及哈希冲突的解决方法。 +本文系统梳理了哈希表的基本原理与核心技术要点,内容涵盖哈希表的定义、哈希函数的设计、哈希冲突的本质及主流冲突解决策略。 + +- **哈希表(Hash Table)**:一种通过哈希函数将关键字 $key$ 映射到存储位置,实现高效查找、插入和删除的数据结构。 +- **哈希函数(Hash Function)**:用于将关键字转换为哈希表索引的函数,其优劣直接影响哈希表的性能和冲突概率。 +- **哈希冲突(Hash Collision)**:指不同关键字经过哈希函数后映射到同一地址,是哈希表设计中不可避免的问题。 -- **哈希表(Hash Table)**:通过键 $key$ 和一个映射函数 $Hash(key)$ 计算出对应的值 $value$,把关键码值映射到表中一个位置来访问记录,以加快查找的速度。 -- **哈希函数(Hash Function)**:将哈希表中元素的关键键值映射为元素存储位置的函数。 -- **哈希冲突(Hash Collision)**:不同的关键字通过同一个哈希函数可能得到同一哈希地址。 +哈希表的设计与实现主要关注两个核心问题: -哈希表的两个核心问题是:**「哈希函数的构建」** 和 **「哈希冲突的解决方法」**。 +1. **哈希函数的构建**:常见方法包括直接定址法、除留余数法、平方取中法、基数转换法、数字分析法、折叠法、随机数法、乘积法、点积法等。合理选择和设计哈希函数有助于均匀分布关键字,降低冲突概率。 +2. **哈希冲突的解决**:主流方法有两类,开放地址法和链地址法。 + - **开放地址法**:通过探查其他空槽位存放冲突元素,常见探查方式有线性探查、二次探查和伪随机探查等。 + - **链地址法**:将哈希地址相同的元素以链表形式存储在同一槽位,结构灵活,实际应用更为广泛。 -- 常用的哈希函数方法有:直接定址法、除留余数法、平方取中法、基数转换法、数字分析法、折叠法、随机数法、乘积法、点积法等。 -- 常用的哈希冲突的解决方法有两种:开放地址法和链地址法。 +合理的哈希函数设计与高效的冲突解决策略,是哈希表高性能的关键所在。 ## 练习题目 From 73ce8338009b7804d30314a9ff19c3582df84fbf Mon Sep 17 00:00:00 2001 From: ITCharge Date: Mon, 1 Sep 2025 17:12:10 +0800 Subject: [PATCH 07/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=92=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=AF=AD=E5=8F=A5=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/04_string/04_01_string_basic.md | 309 +++++++++---- ...e_force.md => 04_02_string_brute_force.md} | 34 +- .../04_02_string_single_pattern_matching.md | 143 ------ docs/04_string/04_03_string_rabin_karp.md | 134 ++++++ docs/04_string/04_04_string_kmp.md | 191 ++++++++ docs/04_string/04_04_string_rabin_karp.md | 115 ----- docs/04_string/04_05_string_boyer_moore.md | 424 ++++++++++++++++++ docs/04_string/04_05_string_kmp.md | 165 ------- docs/04_string/04_06_string_boyer_moore.md | 346 -------------- docs/04_string/04_06_string_horspool.md | 166 +++++++ docs/04_string/04_07_string_horspool.md | 114 ----- ...tring_sunday.md => 04_07_string_sunday.md} | 0 .../{04_10_trie.md => 04_08_trie.md} | 0 ..._ac_automaton.md => 04_09_ac_automaton.md} | 0 .../04_09_string_multi_pattern_matching.md | 62 --- ..._suffix_array.md => 04_10_suffix_array.md} | 0 docs/04_string/index.md | 20 +- 17 files changed, 1160 insertions(+), 1063 deletions(-) rename docs/04_string/{04_03_string_brute_force.md => 04_02_string_brute_force.md} (54%) delete mode 100644 docs/04_string/04_02_string_single_pattern_matching.md create mode 100644 docs/04_string/04_03_string_rabin_karp.md create mode 100644 docs/04_string/04_04_string_kmp.md delete mode 100644 docs/04_string/04_04_string_rabin_karp.md create mode 100644 docs/04_string/04_05_string_boyer_moore.md delete mode 100644 docs/04_string/04_05_string_kmp.md delete mode 100644 docs/04_string/04_06_string_boyer_moore.md create mode 100644 docs/04_string/04_06_string_horspool.md delete mode 100644 docs/04_string/04_07_string_horspool.md rename docs/04_string/{04_08_string_sunday.md => 04_07_string_sunday.md} (100%) rename docs/04_string/{04_10_trie.md => 04_08_trie.md} (100%) rename docs/04_string/{04_11_ac_automaton.md => 04_09_ac_automaton.md} (100%) delete mode 100644 docs/04_string/04_09_string_multi_pattern_matching.md rename docs/04_string/{04_12_suffix_array.md => 04_10_suffix_array.md} (100%) diff --git a/docs/04_string/04_01_string_basic.md b/docs/04_string/04_01_string_basic.md index 6a50e756..b0bc105b 100644 --- a/docs/04_string/04_01_string_basic.md +++ b/docs/04_string/04_01_string_basic.md @@ -1,16 +1,16 @@ ## 1. 字符串简介 -> **字符串(String)**:简称为串,是由零个或多个字符组成的有限序列。一般记为 $s = a_1a_2…a_n (0 \le n ⪇ \infty)$。 +> **字符串(String)**:简称为串,由零个或多个字符组成的有限序列,常记为 $s = a_1a_2…a_n(0 \le n \lneqq \infty)$。 -下面介绍一下字符串相关的一些重要概念。 +### 1.1 字符串常见概念 -- **字符串名称**:字符串定义中的 $s$ 就是字符串的名称。 -- **字符串的值**:$a_1a_2…a_n$ 组成的字符序列就是字符串的值,一般用双引号括起来。 -- **字符变量**:字符串每一个位置上的元素都是一个字符变量。字符 $a_i$ 可以是字母、数字或者其他字符。$i$ 是该字符在字符串中的位置。 -- **字符串的长度**:字符串中字符的数目 $n$ 称为字符串的长度。 -- **空串**:零个字符构成的串也成为 **「空字符串(Null String)」**,它的长度为 $0$,可以表示为 `""`。 -- **子串**:字符串中任意个连续的字符组成的子序列称为该字符串的 **「子串(Substring)」**。并且有两种特殊子串,起始于位置为 $0$、长度为 $k$ 的子串称为 **「前缀(Prefix)」**。而终止于位置 $n - 1$、长度为 $k$ 的子串称为 **「后缀(Suffix)」**。 -- **主串**:包含子串的字符串相应的称为 **「主串」**。 +- **字符串名称**:如 $s$。 +- **字符串的值**:字符序列 $a_1a_2…a_n$,通常用双引号括起来。 +- **字符变量**:字符串中每个位置的字符($a_i$),可以是字母、数字或其他字符,$i$ 表示其位置。 +- **字符串长度**:字符个数 $n$。 +- **空串**:长度为 $0$ 的字符串,记为 `""`。 +- **子串**:字符串中任意连续字符组成的序列。特殊子串包括 **前缀**(从第 $0$ 位起,长度为 $k$)和 **后缀**(以 $n - 1$ 结尾,长度为 $k$)。 +- **主串**:包含子串的字符串。 举个例子来说明一下: @@ -22,162 +22,281 @@ str = "Hello World" ![字符串](https://qcdn.itcharge.cn/images/20240511114722.png) -可以看出来,字符串和数组有很多相似之处。比如同样使用 **名称[下标]** 的方式来访问一个字符。 +字符串与数组相似,都使用 **名称[下标]** 方式访问元素。 -之所以单独讨论字符串是因为: +### 1.2 字符串的特点 -- 字符串中的数据元素都是字符,结构相对简单,但规模可能比较庞大。 -- 经常需要把字符串作为一个整体来使用和处理。操作对象一般不是某个数据元素,而是一组数据元素(整个字符串或子串)。 -- 经常需要考虑多个字符串之间的操作。比如:字符串之间的连接、比较操作。 +- 数据元素都是字符,结构简单但规模可能很大 +- 常作为整体处理,操作对象是整个字符串或子串 +- 经常需要处理多个字符串间的操作(连接、比较等) + +### 1.3 字符串问题分类 根据字符串的特点,我们可以将字符串问题分为以下几种: -- 字符串匹配问题。 -- 子串相关问题。 -- 前缀 / 后缀相关问题; -- 回文串相关问题。 -- 子序列相关问题。 +- 字符串匹配问题 +- 子串相关问题 +- 前缀 / 后缀相关问题 +- 回文串相关问题 +- 子序列相关问题 ## 2. 字符串的比较 ### 2.1 字符串的比较操作 -两个数字之间很容易比较大小,例如 $1 < 2$。而字符串之间的比较相对来说复杂一点。字符串之间的大小取决于它们按顺序排列字符的前后顺序。 - -比如字符串 `str1 = "abc"` 和 `str2 = "acc"`,它们的第一个字母都是 $a$,而第二个字母,由于字母 $b$ 比字母 $c$ 要靠前,所以 $b < c$,于是我们可以说 `"abc" < "acd" `,也可以说 $str1 < str2$。 +数字之间的大小比较非常直观,例如 $1 < 2$。而字符串的大小比较则稍显复杂,其本质是根据字符在字符串中的排列顺序和字符编码来决定的。 -字符串之间的比较是通过组成字符串的字符之间的「字符编码」来决定的。而字符编码指的是字符在对应字符集中的序号。 +以 `str1 = "abc"` 和 `str2 = "acc"` 为例,二者的首字母都是 $a$,但第二个字母 $b$ 比 $c$ 靠前,因此 $b < c$,所以 `"abc" < "acc"`,即 $str1 < str2$。 -我们先来考虑一下如何判断两个字符串是否相等。 +字符串的比较实际上是逐字符比较其「字符编码」——即每个字符在字符集中的编号。 -如果说两个字符串 $str1$ 和 $str2$ 相等,则必须满足两个条件: +判断两个字符串是否相等,需要满足以下两个条件: -1. 字符串 $str1$ 和字符串 $str2$ 的长度相等。 -2. 字符串 $str1$ 和字符串 $str2$ 对应位置上的各个字符都相同。 +1. 两个字符串的长度相等; +2. 两个字符串对应位置上的每个字符都相同。 -下面我们再来考虑一下如何判断两个字符串的大小。 +如果要比较两个字符串的大小,可以按照如下规则进行: -而对于两个不相等的字符串,我们可以以下面的规则定义两个字符串的大小: +- 从第 $0$ 个字符开始,依次比较对应位置上的字符编码: + - 如果 $str1[i]$ 的字符编码等于 $str2[i]$,则继续比较下一位; + - 如果 $str1[i]$ 的字符编码小于 $str2[i]$,则 $str1 < str2$,如 `"abc" < "acc"`; + - 如果 $str1[i]$ 的字符编码大于 $str2[i]$,则 $str1 > str2$,如 `"bcd" > "bad"`。 +- 如果某一字符串已比较到末尾,另一个字符串还有剩余字符: + - 如果 $len(str1) < len(str2)$,则 $str1 < str2$,如 `"abc" < "abcde"`; + - 如果 $len(str1) > len(str2)$,则 $str1 > str2$,如 `"abcde" > "abc"`。 +- 如果所有字符都相等且长度也相同,则 $str1 == str2$,如 `"abcd" == "abcd"`。 -- 从两个字符串的第 $0$ 个位置开始,依次比较对应位置上的字符编码大小。 - - 如果 $str1[i]$ 对应的字符编码等于 $str2[i]$ 对应的字符编码,则比较下一位字符。 - - 如果 $str1[i]$ 对应的字符编码小于 $str2[i]$ 对应的字符编码,则说明 $str1 < str2$。比如:`"abc" < "acc"`。 - - 如果 $str1[i]$ 对应的字符编码大于 $str2[i]$ 对应的字符编码,则说明 $str1 > str2$。比如:`"bcd" > "bad"`。 -- 如果比较到某一个字符串末尾,另一个字符串仍有剩余: - - 如果字符串 $str1$ 的长度小于字符串 $str2$,即 $len(str1) < len(str2)$。则 $str1 < str2$。比如:`"abc" < "abcde"`。 - - 如果字符串 $str1$ 的长度大于字符串 $str2$,即 $len(str1) > len(str2)$。则 $str1 > str2$。比如:`"abcde" > "abc"`。 -- 如果两个字符串每一个位置上的字符对应的字符编码都相等,且长度相同,则说明 $str1 == str2$,比如:`"abcd" == "abcd"`。 +基于上述规则,可以实现一个 `strcmp` 方法,约定如下返回值: -按照上面的规则,我们可以定义一个 `strcmp` 方法,并且规定: +- $str1 < str2$ 时,返回 $-1$; +- $str1 == str2$ 时,返回 $0$; +- $str1 > str2$ 时,返回 $1$。 -- 当 $str1 < str2$ 时,`strcmp` 方法返回 $-1$。 -- 当 $str1 == str2$ 时,`strcmp` 方法返回 $0$。 -- 当 $str1 > str2$ 时,`strcmp` 方法返回 $1$。 - -`strcmp` 方法对应的具体代码如下: +`strcmp` 方法的实现如下: ```python def strcmp(str1, str2): - index1, index2 = 0, 0 - while index1 < len(str1) and index2 < len(str2): - if ord(str1[index1]) == ord(str2[index2]): - index1 += 1 - index2 += 1 - elif ord(str1[index1]) < ord(str2[index2]): - return -1 - else: - return 1 - + """ + 比较两个字符串的大小。 + 返回值: + -1:str1 < str2 + 0:str1 == str2 + 1:str1 > str2 + """ + # 逐字符比较 + i = 0 + while i < len(str1) and i < len(str2): + c1 = ord(str1[i]) + c2 = ord(str2[i]) + if c1 < c2: + return -1 # str1 当前字符小于 str2 + elif c1 > c2: + return 1 # str1 当前字符大于 str2 + i += 1 + + # 如果前面都相等,比较长度 if len(str1) < len(str2): - return -1 + return -1 # str1较短 elif len(str1) > len(str2): - return 1 + return 1 # str1较长 else: - return 0 + return 0 # 完全相等 ``` -上面关于字符串大小的定义有点复杂,其实字符串比较大小最简单的例子就是「查英语词典」。在英语词典的目录中,前面的单词要比后面的单词小。我们将英语词典中的每个英语单词都当做是一个字符串,那么查找单词的过程,其实就是比较字符串大小的过程。 +其实,判断字符串大小最直观的例子就是「查英语词典」。在词典中,前面的单词总是比后面的单词小。我们可以把词典里的每个单词看作一个字符串,查找单词的过程本质上就是按照字符串大小进行比较和排序。 ### 2.2 字符串的字符编码 -刚才我们提到了字符编码,这里我们也稍微介绍一下字符串中常用的字符编码标准。 +前面我们提到了字符编码,这里简要介绍几种常见的字符串字符编码标准。 + +最早,计算机采用 ASCII 编码来表示字符。ASCII 编码表包含 $127$ 个字符,涵盖了英文字母(大小写)、数字及常用符号。每个字符对应唯一的编码值,例如大写字母 $A$ 的编码是 $65$,小写字母 $a$ 的编码是 $97$。 -以计算机中常用字符使用的 ASCII 编码为例。最早的时候,人们制定了一个包含 $127$ 个字符的编码表 ASCII 到计算机系统中。ASCII 编码表中的字符包含了大小写的英文字母、数字和一些符号。每个字符对应一个编码,比如大写字母 $A$ 的编码是 $65$,小写字母 $a$ 的编码是 $97$。 +然而,ASCII 编码只能满足英文等西方语言的需求,无法表示中文等其他语言字符。为支持中文,我国先后制定了 GB2312、GBK、GB18030 等中文编码标准,将常用汉字纳入编码体系。但全球有上百种语言,各国标准不一,导致编码冲突频发。为解决这一问题,国际上推出了统一的 Unicode 编码标准。 -ASCII 编码可以解决以英语为主的语言,可是无法满足中文编码。为了解决中文编码,我国制定了 GB2312、GBK、GB18030 等中文编码标准,将中文编译进去。但是世界上有上百种语言和文字,各国有各国的标准,就会不可避免的产生冲突,于是就有了 Unicode 编码。Unicode 编码最常用的就是 UTF-8 编码,UTF-8 编码把一个 Unicode 字符根据不同的数字大小编码成 $1 \sim 6$ 个字节,常用的英文字母被编码成 $1$ 个字节,汉字通常是 $3$ 个字节。 +Unicode 能够为世界上所有文字和符号分配唯一编码。实际存储和传输时,Unicode 最常用的实现方式是 UTF-8 编码。UTF-8 会根据字符的不同,将每个 Unicode 字符编码为 $1 \sim 6$ 个字节:常用英文字母通常占 $1$ 个字节,汉字一般占 $3$ 个字节。 ## 3. 字符串的存储结构 -字符串的存储结构跟线性表相同,分为「顺序存储结构」和「链式存储结构」。 +字符串的存储结构与线性表类似,主要分为「顺序存储结构」和「链式存储结构」两类。 ### 3.1 字符串的顺序存储结构 -与线性表的顺序存储结构相似,字符串的顺序存储结构也是使用一组地址连续的存储单元依次存放串中的各个字符。按照预定义的大小,为每个定义的字符串变量分配一个固定长度的存储区域。一般是用定长数组来定义。 +顺序存储结构是指用一组地址连续的存储单元,依次存放字符串中的各个字符。通常为每个字符串变量分配一个固定长度的存储空间,常见实现方式是定长数组。 -字符串的顺序存储结构如下图所示。 +如下图所示: ![字符串的顺序存储](https://qcdn.itcharge.cn/images/20240511114747.png) -如上图所示,字符串的顺序存储中每一个字符元素都有自己的下标索引,下标所以从 $0$ 开始,到 $\text{字符串长度} - 1$ 结束。字符串中每一个「下标索引」,都有一个与之对应的「字符元素」。 +在顺序存储结构中,每个字符都有唯一的下标索引,索引从 $0$ 开始,到 $\text{字符串长度} - 1$ 结束。每个下标对应一个字符元素。 -跟数组类似,字符串也支持随机访问。即字符串可以根据下标,直接定位到某一个字符元素存放的位置。 +顺序存储的字符串支持随机访问,可以通过下标直接定位和访问任意字符,效率高,操作便捷。 ### 3.2 字符串的链式存储结构 -字符串的存储也可以采用链式存储结构,即采用一个线性链表来存储一个字符串。字符串的链节点包含一个用于存放字符的 $data$ 变量,和指向下一个链节点的指针变量 $next$。这样,一个字符串就可以用一个线性链表来表示。 +链式存储结构是用线性链表来存储字符串。每个链节点包含一个用于存放字符的 $data$ 字段,以及指向下一个节点的指针 $next$,从而将所有字符串联起来。 -在字符串的链式存储结构中,每个链节点可以仅存放一个字符,也可以存放多个字符。通常情况下,链节点的字符长度为 $1$ 或者 $4$,这是为了避免浪费空间。当链节点的字符长度为 $4$ 时,由于字符串的长度不一定是 $4$ 的倍数,因此字符串所占用的链节点中最后那个链节点的 $data$ 变量可能没有占满,我们可以用 `#` 或其他不属于字符集的特殊字符将其补全。 +链式存储结构中,每个节点可以存放一个或多个字符。常见的做法是每个节点存放 $1$ 个或 $4$ 个字符,以减少空间浪费。当节点存放 $4$ 个字符时,若字符串长度不是 $4$ 的倍数,最后一个节点未用满的部分可用 `#` 或其他特殊字符补齐。 -字符串的链式存储结构图下图所示。 +如下图所示: ![字符串的链式存储](https://qcdn.itcharge.cn/images/20240511114804.png) -如上图所示,字符串的链式存储将一组任意的存储单元串联在一起。链节点之间的逻辑关系是通过指针来间接反映的。 +链式存储结构通过指针将分散的存储单元连接起来,逻辑上形成一个完整的字符串。其优点是插入、删除操作灵活,但随机访问效率较低。 -### 3.3 不同语言中的字符串 +### 3.3 各语言中的字符串实现 -- C 语言中的字符串是使用空字符 `\0` 结尾的字符数组。`\0` 符号用于标记字符串的结束。C 语言的标准库 `string.h` 头文件中提供了各种操作字符串的函数。 -- C++ 语言中除了提供 C 风格的字符串,还引入了 `string` 类类型。`string` 类处理起字符串来会方便很多,完全可以代替 C 语言中的字符数组或字符串指针。 -- Java 语言的标准库中也提供了 `String` 类作为字符串库。 -- Python 语言中使用 `str` 对象来代表字符串。`str` 对象一种不可变类型对象。即 `str` 类型创建的字符串对象在定义之后,无法更改字符串的长度,也无法改变或删除字符串中的字符。 +- **C 语言**:字符串以字符数组形式存储,并以空字符 `\0` 结尾标识结束。相关操作函数在 `string.h` 头文件中。 +- **C++ 语言**:既支持 C 风格字符串,也提供了功能更强的 `string` 类,极大简化了字符串操作。 +- **Java 语言**:标准库中提供了 `String` 类,专门用于字符串处理。 +- **Python 语言**:字符串由 `str` 类型对象表示,属于不可变类型。即一旦创建,字符串的内容和长度都无法更改或删除。 ## 4. 字符串匹配问题 -> **字符串匹配(String Matching)**:又称模式匹配(Pattern Matching)。可以简单理解为,给定字符串 $T$ 和 $p$,在主串 $T$ 中寻找子串 $p$。主串 $T$ 又被称为文本串,子串 $p$ 又被称为模式串(`Pattern`)。 +> **字符串匹配(String Matching)**,又称模式匹配(Pattern Matching),指的是在一个主串 $T$(文本串)中查找某个子串 $p$(模式串)的位置。 -在字符串问题中,最重要的问题之一就是字符串匹配问题。而按照模式串的个数,我们可以将字符串匹配问题分为:「单模式串匹配问题」和「多模式串匹配问题」。 +字符串匹配是字符串处理领域中最核心的问题之一。根据需要查找的模式串数量,字符串匹配问题可分为「单模式串匹配」和「多模式串匹配」两大类。 ### 4.1 单模式串匹配问题 -> **单模式匹配问题(Single Pattern Matching)**:给定一个文本串 $T = t_1t_2...t_n$,再给定一个特定模式串 $p = p_1p_2...p_n$。要求从文本串 $T$ 找出特定模式串 $p$ 的所有出现位置。 +> **单模式匹配问题(Single Pattern Matching)**:给定一个文本串 $T = t_1t_2...t_n$ 和一个模式串 $p = p_1p_2...p_m$,要求找出 $p$ 在 $T$ 中所有出现的位置。 + +#### 4.1.1 问题描述 + +单模式串匹配问题是字符串匹配的基础问题,其形式化定义如下: + +- **输入**:文本串 $T = T[0...n-1]$,模式串 $p = p[0...m-1]$ +- **输出**:所有满足 $T[i...i+m-1] = p[0...m-1]$ 的位置 $i$ +- **目标**:高效地找到所有匹配位置 + +#### 4.1.2 主要算法介绍 + +针对单模式串匹配,常见的算法可根据其在文本中搜索模式串的方式分为以下几类: + +**朴素算法** +- **Brute Force 算法(暴力匹配算法)** + - **时间复杂度**:$O(n \times m)$ + - **空间复杂度**:$O(1)$ + - **特点**:简单直观,但效率较低 + - **适用场景**:模式串较短或对性能要求不高的场景 + +**基于前缀搜索的方法** +- **KMP 算法(Knuth-Morris-Pratt 算法)** + - **时间复杂度**:$O(n + m)$ + - **空间复杂度**:$O(m)$ + - **特点**:利用失配信息避免重复比较,从前向后逐个读取文本字符 + - **适用场景**:对性能要求较高的场景 + +**基于后缀搜索的方法** +- **Boyer Moore 算法** + - **时间复杂度**:平均 $O(n/m)$,最坏 $O(n \times m)$ + - **空间复杂度**:$O(k)$($k$ 为字符集大小) + - **特点**:从右到左比较,跳跃能力强 + - **适用场景**:模式串较长,字符集较小的场景 + +- **Horspool 算法** + - **时间复杂度**:平均 $O(n)$,最坏 $O(n \times m)$ + - **空间复杂度**:$O(k)$ + - **特点**:BM算法的简化版本,实现简单 + - **适用场景**:需要简单实现的场景 + +- **Sunday 算法** + - **时间复杂度**:平均 $O(n)$,最坏 $O(n \times m)$ + - **空间复杂度**:$O(k)$ + - **特点**:从左到右比较,跳跃能力强 + - **适用场景**:需要从左到右匹配的场景 + +**基于子串搜索的方法** +- **Rabin Karp 算法** + - **时间复杂度**:平均 $O(n + m)$,最坏 $O(n \times m)$ + - **空间复杂度**:$O(1)$ + - **特点**:使用滚动哈希,平均性能较好 + - **适用场景**:需要处理多个模式串或对哈希冲突不敏感的场景 -有很多算法可以解决单模式匹配问题。而根据在文本中搜索模式串方式的不同,我们可以将单模式匹配算法分为以下几种: -- **基于前缀搜索方法**:在搜索窗口内从前向后(沿着文本的正向)逐个读入文本字符,搜索窗口中文本和模式串的最长公共前缀。 - - 著名的「Knuth-Morris-Pratt (KMP) 算法」和更快的「Shift-Or 算法」使用的就是这种方法。 -- **基于后缀搜索方法**:在搜索窗口内从后向前(沿着文本的反向)逐个读入文本字符,搜索窗口中文本和模式串的最长公共后缀。使用这种搜索算法可以跳过一些文本字符,从而具有亚线性的平均时间复杂度。 - - 最著名的「Boyer-Moore 算法」,以及「Horspool 算法」、「Sunday(Boyer-Moore 算法的简化)算法」都使用了这种方法。 -- **基于子串搜索方法**:在搜索窗口内从后向前(沿着文本的反向)逐个读入文本字符,搜索满足「既是窗口中文本的后缀,也是模式串的子串」的最长字符串。与后缀搜索方法一样,使用这种搜索方法也具有亚线性的平均时间复杂度。这种方法的主要缺点在于需要识别模式串的所有子串,这是一个非常复杂的问题。 - - 「Rabin-Karp 算法」、「Backward Dawg Matching(BDM)算法」、「Backward Nondeterministtic Dawg Matching(BNDM)算法」和 「Backward Oracle Matching(BOM)算法」 使用的就是这种思想。其中,「Rabin-Karp 算法」使用了基于散列的子串搜索算法。 + +#### 4.1.3 算法复杂度对比 + +| 算法 | 预处理时间 | 匹配时间 | 空间复杂度 | 特点 | +|------|------------|----------|------------|------| +| Brute Force | $O(1)$ | $O(n \times m)$ | $O(1)$ | 简单直观 | +| Rabin Karp | $O(m)$ | 平均 $O(n)$,最坏 $O(n \times m)$ | $O(1)$ | 滚动哈希 | +| KMP | $O(m)$ | $O(n)$ | $O(m)$ | 失配信息 | +| Boyer Moore | $O(m + k)$ | 平均 $O(n/m)$,最坏 $O(n \times m)$ | $O(k)$ | 启发式跳跃 | +| Horspool | $O(m + k)$ | 平均 $O(n)$,最坏 $O(n \times m)$ | $O(k)$ | BM简化版 | +| Sunday | $O(m + k)$ | 平均 $O(n)$,最坏 $O(n \times m)$ | $O(k)$ | 从左到右 | ### 4.2 多模式串匹配问题 -> **多模式匹配问题(Multi Pattern Matching)**:给定一个文本串 $T = t_1t_2...t_n$,再给定一组模式串 $P = {p^1, p^2, ... ,p^r}$,其中每个模式串 $p^i$ 是定义在有限字母表上的字符串 $p^i = p^i_1p^i_2...p^i_n$。要求从文本串 $T$ 中找到模式串集合 $P$ 中所有模式串 $p^i$ 的所有出现位置。 +> **多模式匹配问题(Multi Pattern Matching)**:给定一个文本串 $T = t_1t_2...t_n$,以及一组模式串 $P = \{p^1, p^2, ..., p^r\}$,其中每个模式串 $p^i$ 是由有限字母表组成的字符串 $p^i = p^i_1p^i_2...p^i_{m_i}$。目标是在文本串 $T$ 中找出集合 $P$ 中所有模式串 $p^i$ 的全部出现位置。 + +#### 4.2.1 问题描述 + +**输入**:一个长度为 $n$ 的文本串 $T$,和若干模式串组成的集合 $P = \{p^{(1)}, p^{(2)}, ..., p^{(r)}\}$,每个模式串长度为 $m_i$。 +**输出**:返回每个模式串 $p^{(i)}$ 在 $T$ 中所有出现的位置下标。 +**目标**:高效地一次性找出所有模式串在文本串中的全部匹配位置。 + +简而言之,多模式串匹配问题要求:给定一个文本串和多个模式串,找出每个模式串在文本串中所有出现的位置。 + +#### 4.2.2 主要算法介绍 + +针对多模式串匹配,最朴素的做法是对每个模式串分别进行 $r$ 次单模式匹配(如 Brute Force、KMP 等),但这种方法在模式串数量较多时效率极低。为此,实际应用中常用更高效的多模式串匹配算法,主要包括以下三类: + +多模式串匹配的高效算法主要可以归纳为三大类:前缀结构类、后缀结构类和哈希类。它们各自利用不同的数据结构和思想,实现对多个模式串的高效匹配。下面对主流方法进行梳理与融合说明: + +**前缀结构类方法** +- **字典树(Trie)** + - **时间复杂度**:构建 $O(k)$,单次查找 $O(m)$ + - **空间复杂度**:$O(k)$ + - **特点**:支持高效的前缀匹配和批量字符串检索,结构直观,易于实现 + - **适用场景**:前缀匹配、字符串集合检索、词频统计等 + +- **AC 自动机** + - **时间复杂度**:构建 $O(k)$,匹配 $O(n + k)$ + - **空间复杂度**:$O(k)$ + - **特点**:在字典树基础上引入失败指针,结合 KMP 失配思想,可一次遍历文本高效匹配所有模式串 + - **适用场景**:多模式串精确匹配,如敏感词过滤、病毒特征检测等 + +**后缀结构类方法** +- **后缀数组** + - **时间复杂度**:构建 $O(n \log n)$,查找 $O(m + \log n)$ + - **空间复杂度**:$O(n)$ + - **特点**:支持后缀的快速排序和二分查找,适合子串定位和重复子串分析,实现相对简单 + - **适用场景**:子串查找、最长重复子串、最长公共子串等 + +- **后缀树** + - **时间复杂度**:构建 $O(n)$(理论),实际实现较复杂 + - **空间复杂度**:$O(n)$,但常数较大 + - **特点**:支持复杂的字符串分析,能高效解决多种字符串问题,但实现难度高、空间消耗大 + - **适用场景**:复杂的字符串分析、需要多种子串关系查询的场景(实际多用后缀数组替代) + +**哈希与子串搜索类方法** +- **Rabin-Karp 算法** + - **时间复杂度**:平均 $O(n + m)$,最坏 $O(n \times m)$ + - **空间复杂度**:$O(1)$ + - **特点**:利用滚动哈希批量比对多个模式串,平均性能较好,但哈希冲突时退化 + - **适用场景**:需要处理多个模式串、对哈希冲突不敏感的场景 + +综上,实际应用中多模式串匹配常用 AC 自动机(高效且适用范围广)、字典树(适合前缀类问题)、后缀数组(适合后缀和子串分析)以及 Rabin-Karp(适合哈希批量比对)等方法。选择哪种算法,需根据具体问题规模、模式串数量、匹配需求等因素综合考虑。 + +#### 4.2.3 算法复杂度对比 -模式串集合 $P$ 中的一些字符串可能是集合中其他字符串的子串、前缀、后缀,或者完全相等。解决多模式串匹配问题最简单的方法是利用「单模式串匹配算法」搜索 $r$ 遍。这将导致预处理阶段的最坏时间复杂度为 $O(|P|)$,搜索阶段的最坏时间复杂度为 $O(r \times n)$。 +| 算法 | 预处理时间 | 匹配时间 | 空间复杂度 | 稳定性 | 适用场景 | +|--------------|--------------------|--------------------|-------------|----------|----------------------------| +| 字典树 | $O(k)$ | $O(m)$ | $O(k)$ | 稳定 | 前缀匹配、字符串集合操作 | +| AC 自动机 | $O(k)$ | $O(n + k)$ | $O(k)$ | 稳定 | 多模式串精确匹配 | +| 后缀数组 | $O(n \log n)$ | $O(m + \log n)$ | $O(n)$ | 稳定 | 子串匹配、后缀相关操作 | -如果使用「单模式串匹配算法」解决多模式匹配问题,那么根据在文本中搜索模式串方式的不同,我们也可以将多模式串匹配算法分为以下三种: +其中: +- $n$ 表示文本串长度 +- $m$ 表示单个模式串长度 +- $k$ 表示所有模式串的总长度 -- **基于前缀搜索方法**:搜索从前向后(沿着文本的正向)进行,逐个读入文本字符,使用在 $P$ 上构建的自动机进行识别。对于每个文本位置,计算既是已读入文本的后缀,同时也是 $P$ 中某个模式串的前缀的最长字符串。 - - 著名的 「Aho-Corasick Automaton(AC 自动机)算法」、「Multiple Shift-And 算法」使用的这种方法。 -- **基于后缀搜索方法**:搜索从后向前(沿着文本的反向)进行,搜索模式串的后缀。根据后缀的下一次出现位置来移动当前文本位置。这种方法可以避免读入所有的文本字符。 - - 「Commentz-Walter(Boyer-Moore 算法的扩展算法)算法」 、「Set Horspool(Commentz-Walter 算法的简化算法)算法」、「Wu-Manber 算法」都使用了这种方法。 -- **基于子串搜索方法**:搜索从后向前(沿着文本的反向)进行,在模式串的长度为 $min(len(p^i))$ 的前缀中搜索子串,以此决定当前文本位置的移动。这种方法也可以避免读入所有的文本字符。 - - 「Multiple BNDM 算法」、「Set Backward Dawg Matching(SBDM)算法」、「Set Backwrad Oracle Matching(SBOM)算法」都使用了这种方法。 +需要特别指出的是,绝大多数高效的多模式串匹配算法都依赖于一种基础数据结构:**字典树(Trie +Tree)**。其中,著名的 **Aho-Corasick 自动机(AC 自动机)算法**,正是将「KMP 算法」与 +「字典树」结构结合的产物,也是目前多模式串匹配中最常用、最有效的算法之一。 -需要注意的是,以上所介绍的多模式串匹配算法大多使用了一种基本的数据结构:**「字典树(Trie Tree)」**。著名的 **「Aho-Corasick Automaton (AC 自动机) 算法」** 就是在「KMP 算法」的基础上,与「字典树」结构相结合而诞生的。而「AC 自动机算法」也是多模式串匹配算法中最有效的算法之一。 +因此,学习多模式匹配算法时,重点应掌握 **字典树** 及 **AC 自动机算法** 的原理与实现。 -所以学习多模式匹配算法,重点是要掌握 **「字典树」** 和 **「AC 自动机算法」** 。 ## 练习题目 diff --git a/docs/04_string/04_03_string_brute_force.md b/docs/04_string/04_02_string_brute_force.md similarity index 54% rename from docs/04_string/04_03_string_brute_force.md rename to docs/04_string/04_02_string_brute_force.md index d90bd989..2da16757 100644 --- a/docs/04_string/04_03_string_brute_force.md +++ b/docs/04_string/04_02_string_brute_force.md @@ -1,18 +1,16 @@ ## 1. Brute Force 算法介绍 -> **Brute Force 算法**:简称为 BF 算法。中文意思是暴力匹配算法,也可以叫做朴素匹配算法。 -> -> - **BF 算法思想**:对于给定文本串 $T$ 与模式串 $p$,从文本串的第一个字符开始与模式串 $p$ 的第一个字符进行比较,如果相等,则继续逐个比较后续字符,否则从文本串 $T$ 的第二个字符起重新和模式串 $p$ 进行比较。依次类推,直到模式串 $p$ 中每个字符依次与文本串 $T$ 的一个连续子串相等,则模式匹配成功。否则模式匹配失败。 +> **Brute Force 算法**:简称为 BF 算法,也可以叫做「朴素匹配算法」。 +> +> - **Brute Force 算法核心思想**:将模式串 $p$ 依次与文本串 $T$ 的每个起点对齐,从左到右逐字符比对;相等则继续,不等则把对齐起点右移一位,直到匹配成功或遍历完文本。 ![朴素匹配算法](https://qcdn.itcharge.cn/images/20240511154456.png) ## 2. Brute Force 算法步骤 -1. 对于给定的文本串 $T$ 与模式串 $p$,求出文本串 $T$ 的长度为 $n$,模式串 $p$ 的长度为 $m$。 -2. 同时遍历文本串 $T$ 和模式串 $p$,先将 $T[0]$ 与 $p[0]$ 进行比较。 - 1. 如果相等,则继续比较 $T[1]$ 和 $p[1]$。以此类推,一直到模式串 $p$ 的末尾 $p[m - 1]$ 为止。 - 2. 如果不相等,则将文本串 $T$ 移动到上次匹配开始位置的下一个字符位置,模式串 $p$ 则回退到开始位置,再依次进行比较。 -3. 当遍历完文本串 $T$ 或者模式串 $p$ 的时候停止搜索。 +1. 设文本串 $T$ 长度为 $n$,模式串 $p$ 长度为 $m$。 +2. 从 $T$ 的每个起点 $0..n - m$ 依次与 $p$ 对齐,逐字符比较:如果相等则继续,不相等则起点右移一位、$p$ 归零。 +3. 如果某次对齐能把 $p$ 的全部字符匹配完,则返回该起点;否则无解。 ## 3. Brute Force 算法代码实现 @@ -37,13 +35,25 @@ def bruteForce(T: str, p: str) -> int: ## 4. Brute Force 算法分析 -BF 算法非常简单,容易理解,但其效率很低。主要是因为在匹配过程中可能会出现回溯:当遇到一对字符不同时,模式串 $p$ 直接回到开始位置,文本串也回到匹配开始位置的下一个位置,再重新开始比较。 +BF 简单直观,但因不匹配时会完全回退、重新对齐,存在大量重复比较,效率较低。 -在回溯之后,文本串和模式串中一些部分的比较是没有必要的。由于这种操作策略,导致 BF 算法的效率很低。最坏情况是每一趟比较都在模式串的最后遇到了字符不匹配的情况,每轮比较需要进行 $m$ 次字符对比,总共需要进行 $n - m + 1$ 轮比较,总的比较次数为 $m \times (n - m + 1) $。所以 BF 算法的最坏时间复杂度为 $O(m \times n)$。 +| 指标 | 复杂度 | 说明 | +| ------------ | -------------- | ------------------------------------ | +| 最好时间复杂度 | $O(m)$ | 首个起点即匹配成功 | +| 最坏时间复杂度 | $O(n \times m)$ | 每次都需回退,全部比较 | +| 平均时间复杂度 | $O(n \times m)$ | 一般情况下的复杂度 | +| 空间复杂度 | $O(1)$ | 原地匹配,无需额外空间 | -在最理想的情况下(第一次匹配直接匹配成功),BF 算法的最佳时间复杂度是 $O(m)$。 +- 大量回溯导致重复比较,是 BF 变慢的根源。 +- 当文本或模式较长时,更应考虑 KMP、BM、Sunday 等改进算法。 + +## 5. 总结 + +Brute Force(BF)算法通过将模式串与文本串每个可能的起点逐字符对齐比较,遇到不匹配时起点右移、模式串重头开始。该算法实现简单,空间复杂度为 $O(1)$,但时间复杂度较高:最好情况下为 $O(m)$(首位即匹配),平均和最坏情况下为 $O(n\times m)$,适合小规模或一次性匹配场景。 + +**优点**:实现简单、无需预处理、适合小规模或一次性匹配。 +**缺点**:回溯多、效率低,不适合长文本/长模式或多次匹配场景。 -在一般情况下,根据等概率原则,平均搜索次数为 $\frac{(n + m)}{2}$,所以 Brute Force 算法的平均时间复杂度为 $O(n \times m)$。 ## 练习题目 diff --git a/docs/04_string/04_02_string_single_pattern_matching.md b/docs/04_string/04_02_string_single_pattern_matching.md deleted file mode 100644 index 050b313b..00000000 --- a/docs/04_string/04_02_string_single_pattern_matching.md +++ /dev/null @@ -1,143 +0,0 @@ -## 1. 单模式串匹配概述 - -> **单模式串匹配**:是指在文本串 $T$ 中查找一个模式串 $p$ 的所有出现位置的问题。这是字符串匹配中最基础、最经典的问题。 -> -> - **问题定义**:给定一个长度为 $n$ 的文本串 $T$ 和一个长度为 $m$ 的模式串 $p$,找出模式串 $p$ 在文本串 $T$ 中所有出现的位置。 - -### 1.1 问题描述 - -单模式串匹配问题是字符串匹配的基础问题,其形式化定义如下: - -- **输入**:文本串 $T = T[0...n-1]$,模式串 $p = p[0...m-1]$ -- **输出**:所有满足 $T[i...i+m-1] = p[0...m-1]$ 的位置 $i$ -- **目标**:高效地找到所有匹配位置 - -### 1.2 应用场景 - -单模式串匹配在计算机科学和实际应用中有广泛的应用: - -- **文本编辑器**:查找和替换功能 -- **生物信息学**:DNA序列匹配 -- **网络安全**:入侵检测系统中的模式识别 -- **数据挖掘**:文本挖掘和信息检索 -- **编译原理**:词法分析中的关键字识别 - -## 2. 单模式串匹配算法分类 - -根据算法的核心思想和复杂度,单模式串匹配算法可以分为以下几类: - -### 2.1 朴素算法 - -**Brute Force 算法(暴力匹配算法)** -- **时间复杂度**:$O(n \times m)$ -- **空间复杂度**:$O(1)$ -- **特点**:简单直观,但效率较低 -- **适用场景**:模式串较短或对性能要求不高的场景 - -### 2.2 基于哈希的算法 - -**Rabin Karp 算法** -- **时间复杂度**:平均 $O(n + m)$,最坏 $O(n \times m)$ -- **空间复杂度**:$O(1)$ -- **特点**:使用滚动哈希,平均性能较好 -- **适用场景**:需要处理多个模式串或对哈希冲突不敏感的场景 - -### 2.3 基于有限状态机的算法 - -**KMP 算法(Knuth-Morris-Pratt 算法)** -- **时间复杂度**:$O(n + m)$ -- **空间复杂度**:$O(m)$ -- **特点**:利用失配信息避免重复比较 -- **适用场景**:对性能要求较高的场景 - -### 2.4 基于启发式规则的算法 - -**Boyer Moore 算法** -- **时间复杂度**:平均 $O(n/m)$,最坏 $O(n \times m)$ -- **空间复杂度**:$O(k)$($k$ 为字符集大小) -- **特点**:从右到左比较,跳跃能力强 -- **适用场景**:模式串较长,字符集较小的场景 - -**Horspool 算法** -- **时间复杂度**:平均 $O(n)$,最坏 $O(n \times m)$ -- **空间复杂度**:$O(k)$ -- **特点**:BM算法的简化版本,实现简单 -- **适用场景**:需要简单实现的场景 - -**Sunday 算法** -- **时间复杂度**:平均 $O(n)$,最坏 $O(n \times m)$ -- **空间复杂度**:$O(k)$ -- **特点**:从左到右比较,跳跃能力强 -- **适用场景**:需要从左到右匹配的场景 - -## 3. 算法复杂度对比 - -| 算法 | 预处理时间 | 匹配时间 | 空间复杂度 | 特点 | -|------|------------|----------|------------|------| -| Brute Force | $O(1)$ | $O(n \times m)$ | $O(1)$ | 简单直观 | -| Rabin Karp | $O(m)$ | 平均 $O(n)$,最坏 $O(n \times m)$ | $O(1)$ | 滚动哈希 | -| KMP | $O(m)$ | $O(n)$ | $O(m)$ | 失配信息 | -| Boyer Moore | $O(m + k)$ | 平均 $O(n/m)$,最坏 $O(n \times m)$ | $O(k)$ | 启发式跳跃 | -| Horspool | $O(m + k)$ | 平均 $O(n)$,最坏 $O(n \times m)$ | $O(k)$ | BM简化版 | -| Sunday | $O(m + k)$ | 平均 $O(n)$,最坏 $O(n \times m)$ | $O(k)$ | 从左到右 | - -## 4. 算法选择策略 - -### 4.1 根据应用场景选择 - -- **简单应用**:选择 Brute Force 算法,代码简单,易于理解和维护 -- **一般应用**:选择 KMP 算法,性能稳定,实现相对简单 -- **高性能应用**:选择 Boyer Moore 算法,平均性能最优 -- **多模式串应用**:选择 Rabin Karp 算法,易于扩展到多模式串 - -### 4.2 根据数据特征选择 - -- **模式串较短($m < 10$)**:Brute Force 算法足够 -- **模式串较长($m > 50$)**:Boyer Moore 算法优势明显 -- **字符集较小**:Boyer Moore、Horspool、Sunday 算法效果好 -- **字符集较大**:KMP 算法更稳定 - -### 4.3 根据实现复杂度选择 - -- **快速原型**:Brute Force 或 Horspool 算法 -- **生产环境**:KMP 或 Boyer Moore 算法 -- **教学演示**:Brute Force 或 KMP 算法 - -## 5. 实际应用中的考虑 - -### 5.1 内存使用 - -- **嵌入式系统**:选择空间复杂度低的算法 -- **大规模文本处理**:考虑算法的缓存友好性 - -### 5.2 预处理开销 - -- **一次性匹配**:预处理开销相对不重要 -- **多次匹配**:预处理开销分摊后影响较小 - -### 5.3 字符集特性 - -- **ASCII 字符**:所有算法都适用 -- **Unicode 字符**:需要考虑字符编码问题 -- **二进制数据**:需要特殊处理 - -## 6. 总结 - -单模式串匹配是字符串算法的基础问题,不同的算法各有优缺点: - -- **Brute Force**:最简单,适合学习和简单应用 -- **Rabin Karp**:适合多模式串和哈希应用 -- **KMP**:理论最优,实际应用广泛 -- **Boyer Moore**:平均性能最优,适合长模式串 -- **Horspool/Sunday**:BM的简化版本,实现简单 - -## 练习题目 - -- [单模式串匹配题目列表](https://github.com/ITCharge/AlgoNote/tree/main/docs/00_preface/00_06_categories_list.md#%E5%8D%95%E6%A8%A1%E5%BC%8F%E4%B8%B2%E5%8C%B9%E9%85%8D%E9%A2%98%E7%9B%AE) - -## 参考资料 - -- 【书籍】算法导论 - Thomas H. Cormen 等著 -- 【书籍】柔性字符串匹配 - 中科院计算所网络信息安全研究组 译 -- 【文章】[字符串匹配基础(上)- 数据结构与算法之美 - 极客时间](https://time.geekbang.org/column/article/71187) -- 【文章】[字符串匹配算法总结 - 阮一峰的网络日志](http://www.ruanyifeng.com/blog/2013/05/boyer-moore_string_search_algorithm.html) diff --git a/docs/04_string/04_03_string_rabin_karp.md b/docs/04_string/04_03_string_rabin_karp.md new file mode 100644 index 00000000..27f6a7a5 --- /dev/null +++ b/docs/04_string/04_03_string_rabin_karp.md @@ -0,0 +1,134 @@ +## 1. Rabin Karp 算法介绍 + +> **Rabin Karp(RK)算法**:由 Michael Oser Rabin 与 Richard Manning Karp 于 1987 年提出,是一种利用哈希快速筛查匹配起点的单模式串匹配算法。 +> +> - **Rabin Karp 算法核心思想**:给定文本串 $T$ 与模式串 $p$,先计算 $p$ 的哈希值,再对 $T$ 的所有长度为 $m=|p|$ 的子串高效计算哈希。借助「滚动哈希」在 $O(1)$ 时间更新相邻子串的哈希,用哈希相等作为快速筛选,仅在相等时再逐字符比对以排除哈希冲突。 + +## 2. Rabin Karp 算法步骤 + +### 2.1 Rabin Karp 算法整体流程 + +1. 设 $n=|T|$、$m=|p|$。 +2. 计算模式串哈希 $H(p)$。 +3. 计算文本首个长度为 $m$ 的子串 $T_{[0,m-1]}$ 的哈希 $H(T_{[0,m-1]})$,并用滚动哈希依次得到其余 $n - m$ 个相邻子串的哈希。 +4. 逐一比较 $H(T_{[i,i+m-1]})$ 与 $H(p)$: + - 如果不相等,跳过; + - 如果相等,逐字符核验:完全相同则返回起点 $i$,否则继续。 +5. 全部位置检查后仍未匹配,返回 $-1$。 + +### 2.2 滚动哈希算法 + +实现 RK 的关键是 **滚动哈希**:使相邻子串哈希的更新从 $O(m)$ 降为 $O(1)$,显著提升效率。 + +滚动哈希采用 **Rabin fingerprint** 思想:把子串视作 $d$ 进制多项式,基于上一个子串的哈希在 $O(1)$ 时间得到下一个子串的哈希。 + +下面我们用一个例子来解释一下这种算法思想。 + +设字符集大小为 $d$,用 $d$ 进制多项式哈希表示子串。 + +举个例子,假如字符串只包含 $a \sim z$ 这 $26$ 个小写字母,那么我们就可以用 $26$ 进制数来表示一个字符串,$a$ 表示为 $0$,$b$ 表示为 $1$,以此类推,$z$ 就用 $25$ 表示。 + +例如 `"cat"` 的哈希可表示为: + +$$\begin{aligned} Hash(cat) &= c \times 26^2 + a \times 26^1 + t \times 26^0 \cr &= 2 \times 26^2 + 0 \times 26^1 + 19 \times 26^0 \cr &= 1371 \end{aligned}$$ + +这种多项式哈希的特点是:相邻子串的哈希可由上一个快速推得。 + +如果 $cat$ 的相邻子串为 `"ate"`,直接计算其哈希: + +$$\begin{aligned} Hash(ate) &= a \times 26^2 + t \times 26^1 + e \times 26^0 \cr &= 0 \times 26^2 + 19 \times 26^1 + 4 \times 26^0 \cr &= 498 \end{aligned}$$ + +如果利用上一个子串 `"cat"` 的哈希滚动更新: + +$$\begin{aligned} Hash(ate) &= (Hash(cat) - c \times 26^2) \times 26 + e \times 26^0 \cr &= (1371 - 2 \times 26^2) \times 26 + 4 \times 26^0 \cr &= 498 \end{aligned}$$ + +可以看出,这两种方式计算出的哈希值是相同的。但是第二种计算方式不需要再遍历子串,只需要进行一位字符的计算即可得出整个子串的哈希值。这样每次计算子串哈希值的时间复杂度就降到了 $O(1)$。然后我们就可以通过滚动哈希算法快速计算出子串的哈希值了。 + +将上述规律形式化如下。 + +给定文本串 $T$ 与模式串 $p$,设 $n=|T|$、$m=|p|$、字符集大小为 $d$,则: + +- 模式串:$H(p)=\sum\limits_{k=0}^{m-1} p_k\, d^{m-1-k}$; +- 文本首子串:$H(T_{[0,m-1]})=\sum\limits_{k=0}^{m-1} T_k\, d^{m-1-k}$; +- 滚动关系:$H(T_{[i+1,i+m]})=\big(H(T_{[i,i+m-1]})-T_i\, d^{m-1}\big)\, d+T_{i+m}$。 + +为避免溢出与降低冲突,计算时通常对大质数 $q$ 取模(模数宜大且为质数)。 + +## 3. Rabin–Karp 代码实现 + +```python +# T: 文本串,p: 模式串,d: 字符集大小(基数),q: 模数(质数) +def rabinKarp(T: str, p: str, d: int, q: int) -> int: + n, m = len(T), len(p) + if m == 0: + return 0 + if n < m: + return -1 + + hash_p, hash_t = 0, 0 + + # 计算 H(p) 与首个子串的哈希 + for i in range(m): + hash_p = (hash_p * d + ord(p[i])) % q + hash_t = (hash_t * d + ord(T[i])) % q + + # 使用 pow 的三参形式避免中间溢出 + power = pow(d, m - 1, q) # d^(m-1) % q,用于移除最高位字符 + + for i in range(n - m + 1): + if hash_p == hash_t: + # 避免冲突:逐字符核验 + match = True + for j in range(m): + if T[i + j] != p[j]: + match = False + break + if match: + return i + if i < n - m: + # 滚动更新到下一个子串 + hash_t = (hash_t - power * ord(T[i])) % q # 去掉最高位字符 + hash_t = (hash_t * d + ord(T[i + m])) % q # 加入新字符 + + return -1 +``` + +## 4. 复杂度与性质 + +| 指标 | 复杂度 | 说明 | +| ------------ | -------------- | ------------------------------------ | +| 最好时间复杂度 | $O(n-m+1)$ | 无哈希冲突时,仅需 $n-m+1$ 次哈希对比,均为 $O(1)$,无需逐字符校验 | +| 最坏时间复杂度 | $O(m(n-m+1))\approx O(nm)$ | 每次哈希均冲突,需 $n-m+1$ 次逐字符全量比对,每次 $O(m)$ | +| 平均时间复杂度 | $O(n-m+1)$ | 期望哈希冲突极少,绝大多数位置仅哈希对比,均摊 $O(1)$ | +| 空间复杂度 | $O(1)$ | 仅需常数变量存储哈希值与辅助参数 | + +说明:与 BF 相比,RK 通过哈希筛选把大多数不匹配位置在 $O(1)$ 内排除;但哈希冲突会触发逐字符校验,致使最坏复杂度退化。 + +## 5. 总结 + +Rabin-Karp(RK)算法通过将模式串和文本子串转化为哈希值,利用「滚动哈希」快速筛查匹配位置,大幅减少无效字符比较。其平均时间复杂度远优于朴素算法,适合大文本和多模式串场景,但哈希冲突时需回退逐字符比对,最坏情况下复杂度与朴素法相同。合理选择哈希参数可有效降低冲突概率,是一种高效且易于扩展的字符串匹配算法。 + +**优点**: + - 滚动哈希使子串哈希更新为 $O(1)$,平均性能优于 BF; + - 易于扩展到多模式串场景(统一维护多哈希)。 +**缺点**: + - 存在哈希冲突,最坏复杂度可退化至 $O(nm)$; + - 需合理选择基数 $d$ 与大质数模 $q$,以降低冲突概率。 + +## 练习题目 + +- [0028. 找出字符串中第一个匹配项的下标](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0001-0099/find-the-index-of-the-first-occurrence-in-a-string.md) +- [0459. 重复的子字符串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0400-0499/repeated-substring-pattern.md) +- [0686. 重复叠加字符串匹配](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0600-0699/repeated-string-match.md) +- [0796. 旋转字符串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0700-0799/rotate-string.md) +- [1408. 数组中的字符串匹配](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/1400-1499/string-matching-in-an-array.md) +- [2156. 查找给定哈希值的子串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/2100-2199/find-substring-with-given-hash-value.md) + +- [单模式串匹配题目列表](https://github.com/ITCharge/AlgoNote/tree/main/docs/00_preface/00_06_categories_list.md#%E5%8D%95%E6%A8%A1%E5%BC%8F%E4%B8%B2%E5%8C%B9%E9%85%8D%E9%A2%98%E7%9B%AE) + +## 参考资料 + +- 【书籍】数据结构与算法 Python 语言描述 - 裘宗燕 著 +- 【文章】[字符串匹配基础(上)- 数据结构与算法之美 - 极客时间](https://time.geekbang.org/column/article/71187) +- 【文章】[字符串匹配算法 - Rabin Karp 算法 - coolcao 的小站](https://coolcao.com/2020/08/20/rabin-karp/) +- 【问答】[string - Python: Rabin-Karp algorithm hashing - Stack Overflow](https://stackoverflow.com/questions/22216948/python-rabin-karp-algorithm-hashing) \ No newline at end of file diff --git a/docs/04_string/04_04_string_kmp.md b/docs/04_string/04_04_string_kmp.md new file mode 100644 index 00000000..be294036 --- /dev/null +++ b/docs/04_string/04_04_string_kmp.md @@ -0,0 +1,191 @@ +## 1. KMP 算法介绍 + +> **KMP 算法**(全称 **Knuth-Morris-Pratt 算法**):由 Donald Knuth、James H. Morris 和 Vaughan Pratt 三位学者于 1977 年联合提出,并以他们的名字命名。 +> +> - **KMP 算法核心思想**:在字符串匹配过程中,当文本串 $T$ 的某个字符与模式串 $p$ 发生不匹配时,充分利用已匹配的前缀信息,通过预处理得到的「部分匹配表」(即 next 数组),避免文本指针的回退,从而高效地减少不必要的比较次数,实现快速匹配。 + +### 1.1 朴素匹配算法的缺陷 + +在朴素匹配算法(Brute Force)中,匹配过程使用指针 $i$ 和 $j$ 分别指向文本串 $T$ 和模式串 $p$ 当前比较的字符。当遇到 $T$ 和 $p$ 的字符不匹配时,$j$ 会回到模式串的起始位置,$i$ 则回退到上一次匹配起点的下一个字符,重新开始新一轮匹配,如下图所示。 + +![朴素匹配算法](https://qcdn.itcharge.cn/images/20240511154456.png) + +也就是说,每当以 $T[i]$ 为起点的匹配失败后,算法会直接尝试从 $T[i + 1]$ 作为新起点继续匹配。实际上,这种做法导致指针 $i$ 可能频繁回退,造成大量重复比较。 + +那么,有没有一种算法能够让 $i$ 始终向右移动,无需回退,从而提升匹配效率呢? + +### 1.2 KMP 算法的改进 + +KMP 算法的核心在于:每次匹配失败时,能够利用已匹配的信息,跳过那些必然无法匹配的位置,从而显著减少无效的比较次数,实现高效匹配。 + +具体来说,每次失配时,我们已经知道:**主串的某一段子串等于模式串的某一前缀**。也就是说,如果在下标 $j$ 处失配,说明 $T[i: i + j] == p[0: j]$,即主串从 $i$ 开始的前 $j$ 个字符和模式串的前 $j$ 个字符完全相同。 + +那么,这一信息如何帮助我们加速匹配呢? + +以图中例子为例,假设在第 $5$ 个字符处失配,即 $T[i: i + 5]$ 与 $p[0: 5]$ 完全相同(如 `"ABCAB" == "ABCAB"`),但第 $6$ 个字符不匹配。进一步观察,模式串的前 $5$ 个字符中,前 $2$ 位前缀和后 $2$ 位后缀相同(即 `"AB" == "AB"`)。 + +因此,我们可以得出:主串子串的后 $2$ 位($T[i + 3: i + 5]$)和模式串的前 $2$ 位($p[0: 2]$)是相同的,这部分已经比较过,无需重复。于是,我们可以直接将主串的 $T[i + 5]$ 与模式串的 $p[2]$ 对齐,继续匹配。这样,主串指针 $i$ 始终向右移动,无需回退,只需调整模式串指针 $j$。 + +![KMP 匹配算法移动过程 1](https://qcdn.itcharge.cn/images/20240511155900.png) + +KMP 算法正是基于这种思想,对模式串 $p$ 进行预处理,构建出一个 **「部分匹配表」**(即 next 数组)。每当失配发生时,主串指针 $i$ 不回退,而是根据 next 数组中 $next[j - 1]$ 的值,直接将模式串指针 $j$ 移动到合适的位置,跳过无效的比较。 + +例如,上述例子中,模式串在 $j = 5$ 处失配,$next[4] = 2$,因此我们将 $j$ 移动到 $2$,让 $T[i + 5]$ 直接对齐 $p[2]$,继续匹配,无需回退主串指针 $i$。 + +### 1.3 next 数组 + +前文提到的「部分匹配表」又称为「前缀表」,在 KMP 算法中用 $next$ 数组来表示。$next[j]$ 的含义是:**记录子串 $p[0: j + 1]$(包含下标 $j$)中,最长的相等前后缀的长度**。 + +换句话说,$next[j]$ 就是:**在 $p[0: j + 1]$ 这个子串中,既是前缀又是后缀的最长子串的长度(但不能包含整个子串本身)**。 + +举例说明,设 $p = "ABCABCD"$,其 $next$ 数组为: + +- $next[0] = 0$,因为 `"A"` 没有相同的前后缀。 +- $next[1] = 0$,因为 `"AB"` 没有相同的前后缀。 +- $next[2] = 0$,因为 `"ABC"` 没有相同的前后缀。 +- $next[3] = 1$,因为 `"ABCA"` 的前后缀 `"A"` 相同,长度为 $1$。 +- $next[4] = 2$,因为 `"ABCAB"` 的前后缀 `"AB"` 相同,长度为 $2$。 +- $next[5] = 3$,因为 `"ABCABC"` 的前后缀 `"ABC"` 相同,长度为 $3$。 +- $next[6] = 0$,因为 `"ABCABCD"` 没有相同的前后缀。 + +同理,`"ABCABDEF"` 的前缀表为 $[0, 0, 0, 1, 2, 0, 0, 0]$,`"AABAAAB"` 的前缀表为 $[0, 1, 0, 1, 2, 2, 3]$,`"ABCDABD"` 的前缀表为 $[0, 0, 0, 0, 1, 2, 0]$。 + +在前面的例子中,当 $p[5]$ 与 $T[i + 5]$ 匹配失败,根据 $next[4] = 2$,我们可以直接将 $T[i + 5]$ 与 $p[2]$ 对齐,继续匹配,如下图所示: + +![KMP 匹配算法移动过程 2](https://qcdn.itcharge.cn/images/20240511161310.png) + +**那么,这样移动的原理是什么?** + +实际上,这正是前缀表的作用。具体来说: + +假设在第 $j$ 个字符处失配,即 $T[i: i + j] == p[0: j]$,但 $T[i + j] \ne p[j]$。此时,如果 $p[0: k] == p[j - k:j]$,且 $k$ 最大,则 $T[i + j - k: i + j]$ 与 $p[0: k]$ 已经相等,无需重复比较。 + +因此,我们可以直接将 $T[i + j]$ 与 $p[k]$ 对齐,继续匹配。这里的 $k$ 就是 $next[j - 1]$ 的值。 + +简而言之,$next$ 数组帮助我们在失配时,快速定位到模式串中下一个可能匹配的位置,从而避免主串指针回退,大幅提升匹配效率。 + +## 2. KMP 算法步骤 + +### 2.1 next 数组的构造 + +$next$ 数组的构建其实很直观:它记录了模式串每个前缀(不包含当前位置)中,最长的“相等前后缀”长度。这样一旦失配,我们就能直接跳到下一个可能的匹配位置,避免重复比较。 + +具体步骤如下: + +- 假设模式串为 $p$,我们用两个指针:$left$ 表示当前已知的最长相等前后缀的长度,$right$ 表示当前正在处理的字符下标。初始时 $left = 0$,$right = 1$。 +- 比较 $p[left]$ 和 $p[right]$: + - 如果 $p[left] == p[right]$,说明前后缀可以继续延长。此时 $left$ 加 $1$,将 $next[right]$ 设为 $left$,然后 $right$ 右移一位。这样,$next[right]$ 就记录了当前最长的相等前后缀长度,方便失配时快速跳转。 + - 如果 $p[left] \ne p[right]$,说明当前前后缀不相等。此时 $left$ 回退到 $next[left - 1]$,即尝试寻找更短的相等前后缀,直到 $left = 0$ 或再次匹配成功为止。$right$ 不动,继续比较。 +- 重复上述过程,直到 $right$ 遍历完整个模式串。 + +最终,$next[j]$ 就表示子串 $p[0: j+1]$ 的最长相等前后缀的长度。这个数组就是 KMP 算法高效跳转的关键。 + +### 2.2 KMP 算法整体流程 + +1. 先根据模式串 $p$ 构建其前缀表(即 $next$ 数组)。 +2. 设置两个指针:$i$ 指向文本串 $T$ 的当前位置,$j$ 指向模式串 $p$ 的当前位置,初始均为 $0$。 +3. 遍历文本串 $T$: + - 如果 $T[i] == p[j]$,则 $i$ 和 $j$ 同时右移一位,继续比较下一个字符。 + - 如果 $T[i] \ne p[j]$ 且 $j > 0$,则将 $j$ 回退到 $next[j - 1]$,即利用前缀表跳过无效匹配,无需回退 $i$。 + - 如果 $T[i] \ne p[j]$ 且 $j == 0$,则 $i$ 右移一位,$j$ 保持为 $0$。 +4. 当 $j$ 等于模式串长度 $m$ 时,说明已找到完整匹配,返回匹配的起始下标 $i - m + 1$。 +5. 如果遍历完整个文本串仍未找到完整匹配,则返回 $-1$。 + +该流程通过 $next$ 数组高效跳转,避免了主串指针的回退,大幅提升了匹配效率。 + +## 3. KMP 算法代码实现 + +```python +# 生成 next 数组 +# next[j] 表示子串 p[0: j+1] 的最长相等前后缀的长度 +def generateNext(p: str): + m = len(p) + next = [0 for _ in range(m)] # 初始化 next 数组,全部为 0 + + left = 0 # left 表示当前已知的最长相等前后缀的长度 + for right in range(1, m): # right 表示当前考察的字符下标 + # 如果前后缀不相等,尝试回退 left 到更短的前后缀 + while left > 0 and p[left] != p[right]: + left = next[left - 1] # 回退到上一个最长相等前后缀 + # 如果前后缀相等,最长相等前后缀长度加一 + if p[left] == p[right]: + left += 1 + next[right] = left # 记录当前最长相等前后缀长度 + return next + +# KMP 匹配算法,T 为文本串,p 为模式串 +def kmp(T: str, p: str) -> int: + """ + 返回模式串 p 在文本串 T 中首次出现的位置(下标),若不存在则返回 -1 + """ + n, m = len(T), len(p) + if m == 0: + return 0 # 空模式串视为匹配在开头 + + next = generateNext(p) # 生成 next 数组 + + j = 0 # j 为模式串当前匹配到的位置 + for i in range(n): # i 为文本串当前匹配到的位置 + # 如果当前字符不匹配,且 j > 0,则回退 j 到 next[j-1] + while j > 0 and T[i] != p[j]: + j = next[j - 1] + # 如果当前字符匹配,j 向右移动 + if T[i] == p[j]: + j += 1 + # 如果模式串全部匹配,返回匹配起始下标 + if j == m: + return i - m + 1 + return -1 # 未找到匹配,返回 -1 + +# 测试用例 +print(kmp("abbcfdddbddcaddebc", "ABCABCD")) # 不存在,返回 -1 +print(kmp("abbcfdddbddcaddebc", "bcf")) # 返回 2 +print(kmp("aaaaa", "bba")) # 不存在,返回 -1 +print(kmp("mississippi", "issi")) # 返回 1 +print(kmp("ababbbbaaabbbaaa", "bbbb")) # 返回 3 +``` + +## 4. KMP 算法分析 + + +| 指标 | 复杂度 | 说明 | +| ------------ | -------------- | ------------------------------------------------------------ | +| 最好时间复杂度 | $O(n + m)$ | 构造前缀表 $O(m)$,匹配阶段无回退 $O(n)$,总计 $O(n + m)$ | +| 最坏时间复杂度 | $O(n + m)$ | 无论文本和模式内容如何,均为 $O(n + m)$ | +| 平均时间复杂度 | $O(n + m)$ | 平均情况下同样为 $O(n + m)$ | +| 空间复杂度 | $O(m)$ | 仅需存储模式串的前缀表(next 数组) | + +- 构造前缀表($next$)阶段的时间复杂度为 $O(m)$,其中 $m$ 是模式串 $p$ 的长度。 +- 匹配阶段根据前缀表调整位置,文本串指针 $i$ 不回退,时间复杂度为 $O(n)$,其中 $n$ 是文本串 $T$ 的长度。 +- 因此整体时间复杂度为 $O(n + m)$,空间复杂度为 $O(m)$。与朴素匹配的 $O(n \times m)$ 相比,有显著提升。 + +## 5. 总结 + +KMP 算法通过预处理模式串的前缀信息,实现文本串指针不回退的高效匹配,是经典的线性时间字符串查找算法。 + +**优点**: + - 匹配阶段线性时间,文本指针不回退,效率稳定。 + - 仅依赖模式串的前缀表,额外空间开销小($O(m)$)。 +**缺点**: + - 实现与理解相对复杂,调试成本高于朴素算法。 + - 仅适用于精确匹配;包含通配符、编辑距离等需求需用其他算法(如 Aho–Corasick、DP、后缀结构等)。 + +## 练习题目 + +- [0028. 找出字符串中第一个匹配项的下标](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0001-0099/find-the-index-of-the-first-occurrence-in-a-string.md) +- [0459. 重复的子字符串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0400-0499/repeated-substring-pattern.md) +- [0686. 重复叠加字符串匹配](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0600-0699/repeated-string-match.md) +- [0796. 旋转字符串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0700-0799/rotate-string.md) +- [1408. 数组中的字符串匹配](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/1400-1499/string-matching-in-an-array.md) +- [2156. 查找给定哈希值的子串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/2100-2199/find-substring-with-given-hash-value.md) + +- [单模式串匹配题目列表](https://github.com/ITCharge/AlgoNote/tree/main/docs/00_preface/00_06_categories_list.md#%E5%8D%95%E6%A8%A1%E5%BC%8F%E4%B8%B2%E5%8C%B9%E9%85%8D%E9%A2%98%E7%9B%AE) + +## 参考资料 + +- 【书籍】柔性字符串匹配 - 中科院计算所网络信息安全研究组 译 +- 【书籍】ACM-ICPC 程序设计系列 - 算法设计与实现 - 陈宇 吴昊 主编 +- 【博文】[从头到尾彻底理解 KMP - 结构之法 算法之道 - CSDN博客](https://blog.csdn.net/v_JULY_v/article/details/7041827?spm=1001.2014.3001.5502) +- 【博文】[字符串匹配的 KMP 算法 - 阮一峰的网络日志](http://www.ruanyifeng.com/blog/2013/05/Knuth–Morris–Pratt_algorithm.html) +- 【题解】[多图预警 - 详解 KMP 算法 - 实现 strStr() - 力扣](https://leetcode.cn/problems/implement-strstr/solution/duo-tu-yu-jing-xiang-jie-kmp-suan-fa-by-w3c9c/) +- 【题解】[「代码随想录」KMP算法详解 - 实现 strStr() - 力扣](https://leetcode.cn/problems/implement-strstr/solution/dai-ma-sui-xiang-lu-kmpsuan-fa-xiang-jie-mfbs/) diff --git a/docs/04_string/04_04_string_rabin_karp.md b/docs/04_string/04_04_string_rabin_karp.md deleted file mode 100644 index f7cbf381..00000000 --- a/docs/04_string/04_04_string_rabin_karp.md +++ /dev/null @@ -1,115 +0,0 @@ -## 1. Rabin Karp 算法介绍 - -> **Rabin Karp 算法**:简称为 RK 算法。是由它的两位发明者 Michael Oser Rabin 和 Richard Manning Karp 的名字来命名的。RK 算法是他们在 1987 年提出的、使用哈希函数以在文本中搜寻单个模式串的字符串搜索算法。 -> -> - **Rabin Karp 算法思想**:对于给定文本串 $T$ 与模式串 $p$,通过滚动哈希算快速筛选出与模式串 $p$ 不匹配的文本位置,然后在其余位置继续检查匹配项。 - -## 2. Rabin Karp 算法步骤 - -### 2.1 Rabin Karp 算法整体步骤 - -1. 对于给定的文本串 $T$ 与模式串 $p$,求出文本串 $T$ 的长度为 $n$,模式串 $p$ 的长度为 $m$。 -2. 通过滚动哈希算法求出模式串 $p$ 的哈希值 $hash\underline{\hspace{0.5em}}p$。 -3. 再通过滚动哈希算法对文本串 $T$ 中 $n - m + 1$ 个子串分别求哈希值 $hash\underline{\hspace{0.5em}}t$。 -4. 然后逐个与模式串的哈希值比较大小。 - 1. 如果当前子串的哈希值 $hash\underline{\hspace{0.5em}}t$ 与模式串的哈希值 $hash\underline{\hspace{0.5em}}p$ 不同,则说明两者不匹配,则继续向后匹配。 - 2. 如果当前子串的哈希值 $hash\underline{\hspace{0.5em}}t$ 与模式串的哈希值 $hash\underline{\hspace{0.5em}}p$ 相等,则验证当前子串和模式串的每个字符是否真的相等(避免哈希冲突)。 - 1. 如果当前子串和模式串的每个字符相等,则说明当前子串和模式串匹配。 - 2. 如果当前子串和模式串的每个字符不相等,则说明两者不匹配,继续向后匹配。 -5. 比较到末尾,如果仍未成功匹配,则说明文本串 $T$ 中不包含模式串 $p$,方法返回 $-1$。 - -### 2.2 滚动哈希算法 - -实现 RK 算法中一个重要步骤是 **「滚动哈希算法」**,通过滚动哈希算法,将每次计算子串哈希值的复杂度从 $O(m)$ 降到了 $O(1)$,从而提升了整个算法效率。 - -RK 算法中的滚动哈希算法主要是利用了 **「Rabin fingerprint 思想」**。这种算法思想利用了子串中每一位字符的哈希值,并且还可以根据上一个子串的哈希值,快速计算相邻子串的哈希值,从而使得每次计算子串哈希值的时间复杂度降为了 $O(1)$。 - -下面我们用一个例子来解释一下这种算法思想。 - -假设给定的字符串的字符集中只包含 $d$ 种字符,那么我们就可以用一个 $d$ 进制数表示子串的哈希值。 - -举个例子,假如字符串只包含 $a \sim z$ 这 $26$ 个小写字母,那么我们就可以用 $26$ 进制数来表示一个字符串,$a$ 表示为 $0$,$b$ 表示为 $1$,以此类推,$z$ 就用 $25$ 表示。 - -比如 `"cat"` 的哈希值就可以表示为: - -$$\begin{aligned} Hash(cat) &= c \times 26^2 + a \times 26^1 + t \times 26^0 \cr &= 2 \times 26^2 + 0 \times 26^1 + 19 \times 26^0 \cr &= 1371 \end{aligned}$$ - -这种按位计算哈希值的哈希函数有一个特点:在计算相邻子串时,可以利用上一个子串的哈希值。 - -比如说 $cat$ 的相邻子串为 `"ate"`。按照刚才哈希函数计算,可以得出 `"ate"` 的哈希值为: - -$$\begin{aligned} Hash(ate) &= a \times 26^2 + t \times 26^1 + e \times 26^0 \cr &= 0 \times 26^2 + 19 \times 26^1 + 4 \times 26^0 \cr &= 498 \end{aligned}$$ - -如果利用上一个子串 `"cat"` 的哈希值计算 `"ate"`,则 `"ate"` 的哈希值为: - -$$\begin{aligned} Hash(ate) &= (Hash(cat) - c \times 26^2) \times 26 + e \times 26^0 \cr &= (1371 - 2 \times 26^2) \times 26 + 4 \times 26^0 \cr &= 498 \end{aligned}$$ - -可以看出,这两种方式计算出的哈希值是相同的。但是第二种计算方式不需要再遍历子串,只需要进行一位字符的计算即可得出整个子串的哈希值。这样每次计算子串哈希值的时间复杂度就降到了 $O(1)$。然后我们就可以通过滚动哈希算法快速计算出子串的哈希值了。 - -我们将上面的规律扩展总结一下。 - -给定的文本串 $T$ 与模式串 $p$,求出文本串 $T$ 的长度为 $n$,模式串 $p$ 的长度为 $m$。字符串字符种类数为 $d$,则: - -- 模式串 $p$ 的哈希值计算方式为:$Hash(p) = p_0 \times d^{m - 1} + p_1 \times d^{m - 2} + … + p_{m-1} \times d^{0}$。 -- 文本串中起始于位置 $0$,长度为 $m$ 的子串 $T_{[0,m-1]}$ 对应哈希值计算方法为:$Hash(T_{[0, m - 1]}) = T_0 \times d^{m - 1} + T_1 \times d^{m - 2} + ... + T_{m - 1} \times d^0$。 -- 已知子串的哈希值 $Hash(T_{[i,i + m - 1]})$,将子串向右移动一位的子串对应哈希值计算方法为:$Hash(T_{[i + 1, i + m]}) = [Hash(T_{[i, i + m - 1]}) - T_i \times d^{m - 1}] \times d + T_{i + m} \times d^{0}$。 - -因为哈希值过大会造成溢出,所以我们在计算过程中还要对结果取模。取模的值应该尽可能大,并且应该是质数,这样才能减少哈希碰撞的概率。 - -## 3. Rabin Karp 算法代码实现 - -```python -# T 为文本串,p 为模式串,d 为字符集的字符种类数,q 为质数 -def rabinKarp(T: str, p: str, d, q) -> int: - n, m = len(T), len(p) - if n < m: - return -1 - - hash_p, hash_t = 0, 0 - - for i in range(m): - hash_p = (hash_p * d + ord(p[i])) % q # 计算模式串 p 的哈希值 - hash_t = (hash_t * d + ord(T[i])) % q # 计算文本串 T 中第一个子串的哈希值 - - power = pow(d, m - 1) % q # power 用于移除字符哈希时 - - for i in range(n - m + 1): - if hash_p == hash_t: # 检查模式串 p 的哈希值和子串的哈希值 - match = True # 如果哈希值相等,验证模式串和子串每个字符是否完全相同(避免哈希冲突) - for j in range(m): - if T[i + j] != p[j]: - match = False # 模式串和子串某个字符不相等,验证失败,跳出循环 - break - if match: # 如果模式串和子串每个字符是否完全相同,返回匹配开始位置 - return i - if i < n - m: # 计算下一个相邻子串的哈希值 - hash_t = (hash_t - power * ord(T[i])) % q # 移除字符 T[i] - hash_t = (hash_t * d + ord(T[i + m])) % q # 增加字符 T[i + m] - hash_t = (hash_t + q) % q # 确保 hash_t >= 0 - - return -1 -``` - -## 4. RK 算法分析 - -RK 算法可以看做是 BF 算法的一种改进。在 BF 算法中,每一个字符都需要进行比较。而在 RK 算法中,判断模式串的哈希值与每个子串的哈希值之间是否相等的时间复杂度为 $O(1)$。总共需要比较 $n - m + 1$ 个子串的哈希值,所以 RK 算法的整体时间复杂度为 $O(n)$。跟 BF 算法相比,RK 算法的效率提高了很多。 - -但是如果存在冲突的情况下,算法的效率会降低。最坏情况是每一次比较模式串的哈希值和子串的哈希值时都相等,但是每一次都会出现冲突,那么每一次都需要验证模式串和子串每个字符是否完全相同,那么总的比较次数就是 $m \times (n - m + 1)$,时间复杂度就会退化为 $O(m \times n)$。 - -## 练习题目 - -- [0028. 找出字符串中第一个匹配项的下标](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0001-0099/find-the-index-of-the-first-occurrence-in-a-string.md) -- [0459. 重复的子字符串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0400-0499/repeated-substring-pattern.md) -- [0686. 重复叠加字符串匹配](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0600-0699/repeated-string-match.md) -- [0796. 旋转字符串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0700-0799/rotate-string.md) -- [1408. 数组中的字符串匹配](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/1400-1499/string-matching-in-an-array.md) -- [2156. 查找给定哈希值的子串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/2100-2199/find-substring-with-given-hash-value.md) - -- [单模式串匹配题目列表](https://github.com/ITCharge/AlgoNote/tree/main/docs/00_preface/00_06_categories_list.md#%E5%8D%95%E6%A8%A1%E5%BC%8F%E4%B8%B2%E5%8C%B9%E9%85%8D%E9%A2%98%E7%9B%AE) - -## 参考资料 - -- 【书籍】数据结构与算法 Python 语言描述 - 裘宗燕 著 -- 【文章】[字符串匹配基础(上)- 数据结构与算法之美 - 极客时间](https://time.geekbang.org/column/article/71187) -- 【文章】[字符串匹配算法 - Rabin Karp 算法 - coolcao 的小站](https://coolcao.com/2020/08/20/rabin-karp/) -- 【问答】[string - Python: Rabin-Karp algorithm hashing - Stack Overflow](https://stackoverflow.com/questions/22216948/python-rabin-karp-algorithm-hashing) \ No newline at end of file diff --git a/docs/04_string/04_05_string_boyer_moore.md b/docs/04_string/04_05_string_boyer_moore.md new file mode 100644 index 00000000..a9c78b43 --- /dev/null +++ b/docs/04_string/04_05_string_boyer_moore.md @@ -0,0 +1,424 @@ +## 1. Boyer Moore 算法介绍 + +> **Boyer Moore 算法(BM 算法)**:由 Robert S. Boyer 和 J Strother Moore 于 1977 年提出,是一种高效的字符串搜索算法,实际应用中通常比 KMP 算法快 3~5 倍。 +> +> - **BM 算法核心思想**:先对模式串 $p$ 预处理,生成辅助表。在匹配过程中,如果文本串 $T$ 某字符与模式串 $p$ 不匹配,通过启发式规则,直接跳过不可能匹配的位置,将模式串整体向后滑动多位。 + +BM 算法的关键在于两种启发式移动规则:**坏字符规则(Bad Character Rule)** 和 **好后缀规则(Good Suffix Rule)**。 + +这两种规则的计算只依赖于模式串 $p$,与文本串 $T$ 无关。预处理时分别生成对应的后移表,匹配时每次取两者中较大的后移位数进行滑动。 + +需要注意,BM 算法滑动模式串时仍是从左到右,但每次字符比较是从右到左(即从后缀开始)。 + +下面将详细介绍 BM 算法的两种启发式规则:「坏字符规则」和「好后缀规则」。 + +## 2. Boyer Moore 算法启发规则 + +### 2.1 坏字符规则 + +> **坏字符规则(Bad Character Rule)**:当文本串 $T$ 和模式串 $p$ 从右往左比较时,若遇到第一个不匹配的字符(称为 **坏字符**),可以利用该字符快速决定模式串的滑动距离。 + +移动位数分两种情况: + +- **情况 1:坏字符在模式串 $p$ 中出现过** + - 将模式串中最后一次出现该坏字符的位置与文本串中的坏字符对齐。 + - **移动位数 = 坏字符在模式串的失配位置 - 坏字符在模式串中最后一次出现的位置** + +![情况 1:坏字符出现在模式串 p 中](https://qcdn.itcharge.cn/images/20240511164026.png) + +- **情况 2:坏字符未在模式串 $p$ 中出现** + - 直接将模式串整体向右移动一位。 + - **移动位数 = 坏字符在模式串的失配位置 + 1** + +![情况 2:坏字符没有出现在模式串 p 中](https://qcdn.itcharge.cn/images/20240511164048.png) + +### 2.2 好后缀规则 + +> **好后缀规则(Good Suffix Rule)**:当从右往左比较时,遇到不匹配,已匹配的部分称为 **好后缀**。此时可以利用好后缀信息,让模式串整体向右跳跃移动,加快匹配。 + +好后缀规则分为三种情况: + +- **情况 1:模式串中存在与好后缀相同的子串** + - 直接将该子串与好后缀对齐(若有多个,选最右侧的)。 + - **移动位数 = 好后缀最后一个字符在模式串中的位置 - 匹配子串最后一个字符的位置** + +![情况 1:模式串中有子串匹配上好后缀](https://qcdn.itcharge.cn/images/20240511164101.png) + +- **情况 2:模式串中没有子串匹配好后缀,但有前缀等于好后缀的后缀** + - 找到最长的前缀与好后缀的后缀相等,将其对齐。 + - **移动位数 = 好后缀后缀最后一个字符在模式串中的位置 - 最长前缀最后一个字符的位置** + +![情况 2:模式串中无子串匹配上好后缀, 但有最长前缀匹配好后缀的后缀](https://qcdn.itcharge.cn/images/20240511164112.png) + +- **情况 3:既无子串匹配好后缀,也无前缀匹配** + - 直接将模式串整体右移一整段。 + - **移动位数 = 模式串长度** + +![情况 3:模式串中无子串匹配上好后缀,也找不到前缀匹配](https://qcdn.itcharge.cn/images/20240511164124.png) + +## 3. Boyer Moore 算法匹配过程示例 + +下面我们以 J Strother Moore 教授的经典例子,详细演示 BM 算法的匹配流程,帮助大家更直观地理解 **「坏字符规则」** 和 **「好后缀规则」** 的实际应用。 + +::: tabs#Boyer-Moore + +@tab <1> + +假设文本串为 `"HERE IS A SIMPLE EXAMPLE"`,模式串为 `"EXAMPLE"`,如下图所示。 + +![Boyer Moore 算法步骤 1](https://qcdn.itcharge.cn/images/20220127164130.png) + +@tab <2> + +首先,将模式串与文本串的起始位置对齐,从模式串的末尾开始逐个字符向前比较。 + +![Boyer Moore 算法步骤 2](https://qcdn.itcharge.cn/images/20220127164140.png) + +此时,`'S'` 与 `'E'` 不匹配。`'S'` 就是「坏字符(Bad Character)」,位于模式串的第 $6$ 位。由于 `'S'` 在模式串 `"EXAMPLE"` 中未出现(即最后一次出现的位置为 $-1$),根据坏字符规则,模式串可以直接向右移动 $6 - (-1) = 7$ 位,使得模式串的首字符与文本串中 `'S'` 的下一位对齐。 + +@tab <3> + +将模式串向右移动 $7$ 位后,再次从模式串尾部开始比较,发现 `'P'` 与 `'E'` 不匹配,此时 `'P'` 是坏字符。 + +![Boyer Moore 算法步骤 3](https://qcdn.itcharge.cn/images/20220127164151.png) + +此时,`'P'` 在模式串中的失配位置为第 $6$ 位,且在模式串中最后一次出现的位置为 $4$(下标从 $0$ 开始)。 + +@tab <4> + +根据坏字符规则,模式串向右移动 $6 - 4 = 2$ 位,使文本串中的 `'P'` 与模式串中的 `'P'` 对齐。 + +![Boyer Moore 算法步骤 4](https://qcdn.itcharge.cn/images/20220127164202.png) + +@tab <5> + +继续从模式串尾部逐位比较。首先比较文本串的 `'E'` 和模式串的 `'E'`,二者匹配,此时 `"E"` 为好后缀,位于模式串的第 $6$ 位。 + +![Boyer Moore 算法步骤 5](https://qcdn.itcharge.cn/images/20220127164212.png) + +@tab <6> + +继续比较前一位,文本串的 `'L'` 与模式串的 `'L'` 匹配,此时 `"LE"` 为好后缀,位于模式串的第 $6$ 位。 + +![Boyer Moore 算法步骤 6](https://qcdn.itcharge.cn/images/20220127164222.png) + +@tab <7> + +继续比较前一位,文本串的 `'P'` 与模式串的 `'P'` 匹配,此时 `"PLE"` 为好后缀,位于模式串的第 $6$ 位。 + +![Boyer Moore 算法步骤 7](https://qcdn.itcharge.cn/images/20220127164232.png) + +@tab <8> + +继续比较前一位,文本串的 `'M'` 与模式串的 `'M'` 匹配,此时 `"MPLE"` 为好后缀,位于模式串的第 $6$ 位。 + +![Boyer Moore 算法步骤 8](https://qcdn.itcharge.cn/images/20220127164241.png) + +@tab <9> + +继续比较前一位,文本串的 `'I'` 与模式串的 `'A'` 不匹配。 + +![Boyer Moore 算法步骤 9-1](https://qcdn.itcharge.cn/images/20220127164251.png) + +此时,若仅用坏字符规则,模式串应向右移动 $2 - (-1) = 3$ 位。但根据好后缀规则,可以获得更优的移动距离。 + +对于好后缀 `"MPLE"`,其后缀 `"PLE"`、`"LE"`、`"E"` 中,只有 `"E"` 与模式串前缀 `"E"` 匹配,属于好后缀规则的第二种情况。好后缀 `"E"` 的最后一个字符在模式串中的位置为 $6$,最长前缀 `"E"` 的最后一个字符在位置 $0$,因此模式串可以直接向右移动 $6 - 0 = 6$ 位。 + +![Boyer Moore 算法步骤 9-2](https://qcdn.itcharge.cn/images/20220127164301.png) + +@tab <10> + +再次从模式串尾部开始逐位比较。 + +此时,`'P'` 与 `'E'` 不匹配,`'P'` 是坏字符。根据坏字符规则,模式串向右移动 $6 - 4 = 2$ 位。 + +![Boyer Moore 算法步骤 10](https://qcdn.itcharge.cn/images/20220127164312.png) + +@tab <11> + +继续从模式串尾部逐位比较,发现模式串全部匹配,搜索结束,返回模式串在文本串中的起始位置。 + +::: + +## 4. Boyer Moore 算法步骤 + +BM 算法的整体流程如下: + +1. 计算文本串 $T$ 的长度 $n$ 和模式串 $p$ 的长度 $m$。 +2. 对模式串 $p$ 进行预处理,分别生成坏字符表 $bc\underline{\hspace{0.5em}}table$ 和好后缀规则后移位数表 $gs\underline{\hspace{0.5em}}table$。 +3. 将模式串 $p$ 的头部与文本串 $T$ 的当前位置 $i$ 对齐,初始 $i = 0$。每次从模式串的末尾($j = m - 1$)开始向前逐位比较: + - 如果 $T[i + j]$ 与 $p[j]$ 相等,则继续向前比较下一个字符。 + - 如果模式串所有字符均匹配,则返回当前匹配的起始位置 $i$。 + - 如果 $T[i + j]$ 与 $p[j]$ 不相等: + - 分别根据坏字符表和好后缀表,计算坏字符移动距离 $bad\underline{\hspace{0.5em}}move$ 和好后缀移动距离 $good\underline{\hspace{0.5em}}move$。 + - 取两者的最大值作为本轮的实际移动距离,即 $i += \max(bad\underline{\hspace{0.5em}}move,\, good\underline{\hspace{0.5em}}move)$,然后继续下一轮匹配。 +4. 如果模式串移动到文本串末尾仍未找到匹配,则返回 $-1$。 + +该流程充分利用了坏字符和好后缀两种规则,实现了高效的字符串匹配。 + +## 5. Boyer Moore 算法代码实现 + +BM 算法的匹配过程本身实现相对简单,真正的难点主要集中在预处理阶段,尤其是「坏字符位置表」和「好后缀规则后移位数表」的构建。其中,「好后缀规则后移位数表」的实现尤为复杂。接下来我们将分别详细讲解这两部分的实现方法。 + +### 5.1 生成坏字符位置表代码实现 + +坏字符位置表的构建非常直观,具体步骤如下: + +- 创建一个哈希表 $bc\underline{\hspace{0.5em}}table$,用于记录每个字符在模式串中最后一次出现的位置,即 $bc\underline{\hspace{0.5em}}table[bad\underline{\hspace{0.5em}}char]$ 表示坏字符 $bad\underline{\hspace{0.5em}}char$ 在模式串中的最右下标。 + +- 遍历模式串 $p$,将每个字符 $p[i]$ 及其下标 $i$ 存入哈希表。若某字符在模式串中多次出现,则后出现的下标会覆盖前面的值,确保记录的是最右侧的位置。 + +在 BM 算法匹配过程中,如果 $bad\underline{\hspace{0.5em}}char$ 不在 $bc\underline{\hspace{0.5em}}table$ 中,则视为其最右位置为 $-1$;如果存在,则直接取 $bc\underline{\hspace{0.5em}}table[bad\underline{\hspace{0.5em}}char]$。据此即可计算模式串本轮应向右移动的距离。 + +坏字符位置表的实现代码如下: + +```python +# 生成坏字符位置表 +# bc_table[bad_char] 表示坏字符 bad_char 在模式串中最后一次出现的位置 +def generateBadCharTable(p: str): + """ + 构建坏字符位置表。 + 输入: + p: 模式串 + 输出: + bc_table: 字典,key 为字符,value 为该字符在模式串中最后一次出现的下标 + """ + bc_table = dict() # 初始化坏字符表 + + # 遍历模式串,将每个字符及其下标记录到表中 + for i, ch in enumerate(p): + bc_table[ch] = i # 若字符多次出现,保留最后一次出现的位置 + + # 返回坏字符表 + return bc_table +``` + +### 5.2 生成好后缀规则后移位数表代码实现 + +为了生成好后缀规则的后移位数表,首先需要构建一个后缀数组 $suffix$。$suffix[i]$ 表示以 $i$ 结尾的子串(即 $p[0:i+1]$)与模式串后缀的最大匹配长度,即最大的 $k$ 使得 $p[i-k+1:i+1] == p[m-k:m]$。 + +下面是 $suffix$ 数组的构建代码: + +```python +# 生成 suffix 数组 +# suffix[i] 表示以 i 结尾的子串(p[0:i+1])与模式串后缀的最大匹配长度 +def generateSuffixArray(p: str): + """ + 构建 suffix 数组。 + 输入: + p: 模式串 + 输出: + suffix: 列表,suffix[i] 表示以 i 结尾的子串与模式串后缀的最大匹配长度 + """ + m = len(p) + suffix = [0 for _ in range(m)] # 初始化为 0,表示尚未匹配 + suffix[m - 1] = m # 最后一个字符的后缀必然和自身完全匹配,长度为 m + + # 从倒数第二个字符开始向前遍历 + for i in range(m - 2, -1, -1): + j = i # j 指向当前子串的起始位置 + # 比较 p[j] 与 p[m-1-(i-j)],即从后缀和子串末尾同时向前比较 + while j >= 0 and p[j] == p[m - 1 - (i - j)]: + j -= 1 + # 以 i 结尾的子串与模式串后缀的最大匹配长度为 i - j + suffix[i] = i - j + + return suffix +``` + +有了 $suffix$ 数组后,我们可以基于它构建好后缀规则的后移位数表 $gs\underline{\hspace{0.5em}}list$。该表用一个数组表示,其中 $gs\underline{\hspace{0.5em}}list[j]$ 表示在模式串第 $j$ 位遇到坏字符时,根据好后缀规则可以向右移动的距离。 + +根据「2.2 好后缀规则」的分析,好后缀的移动分为三种情况: + +- 情况 1:模式串中存在与好后缀完全相同的子串。 +- 情况 2:模式串中不存在匹配好后缀的子串,但存在前缀与好后缀的后缀相等。 +- 情况 3:既无匹配子串,也无匹配前缀。 + +实际上,情况 2 和情况 3 可以合并处理(情况 3 可视为最长前缀长度为 $0$ 的特殊情况)。当某个坏字符同时满足多种情况时,应优先选择移动距离最小的方案,以避免遗漏可能的匹配。例如,若既有匹配子串又有匹配前缀,应优先采用匹配子串的移动方式。 + +具体构建 $gs\underline{\hspace{0.5em}}list$ 的步骤如下: + +- 首先,假设所有位置均为情况 3,即 $gs\underline{\hspace{0.5em}}list[i] = m$。 +- 然后,利用后缀和前缀的匹配关系,更新情况 2 下的移动距离:$gs\underline{\hspace{0.5em}}list[j] = m - 1 - i$,其中 $j$ 是好后缀前的坏字符位置,$i$ 是最长前缀的末尾下标,$m - 1 - i$ 为可移动的距离。 +- 最后,处理情况 1:对于好后缀的左端点($m - 1 - suffix[i]$ 处)遇到坏字符时,更新其可移动距离为 $gs\underline{\hspace{0.5em}}list[m - 1 - suffix[i]] = m - 1 - i$。 + +下面是生成好后缀规则后移位数表 $gs\underline{\hspace{0.5em}}list$ 的代码: + +```python +# 生成好后缀规则后移位数表 +# gs_list[j] 表示在模式串下标 j 处遇到坏字符时,根据好后缀规则可以向右移动的距离 +def generateGoodSuffixList(p: str): + """ + 构建好后缀规则的后移位数表 gs_list。 + 输入: + p: 模式串 + 输出: + gs_list: 列表,gs_list[j] 表示在 j 处遇到坏字符时可向右移动的距离 + """ + m = len(p) + gs_list = [m for _ in range(m)] # 情况3:默认全部初始化为 m,表示完全不匹配时的最大移动 + suffix = generateSuffixArray(p) # 生成后缀数组 + + # 处理情况 2:寻找最长的前缀与好后缀的后缀相等 + # j 表示好后缀前的坏字符位置 + j = 0 + # 从后往前遍历,i 表示前缀的结尾下标 + for i in range(m - 1, -1, -1): + # 如果 suffix[i] == i + 1,说明 p[0: i+1] == p[m-1-i: m],即前缀和后缀相等 + if suffix[i] == i + 1: + # 对于所有 j < m-1-i 的位置,若还未被更新,则设置为 m-1-i + while j < m - 1 - i: + if gs_list[j] == m: + gs_list[j] = m - 1 - i # 更新移动距离 + j += 1 + + # 处理情况 1:模式串中存在与好后缀完全相同的子串 + # i 表示好后缀的右端点 + for i in range(m - 1): + # m-1-suffix[i] 是好后缀的左端点 + # m-1-i 是可移动的距离 + gs_list[m - 1 - suffix[i]] = m - 1 - i # 更新在好后缀左端点遇到坏字符时的移动距离 + + return gs_list +``` + +### 5.3 Boyer Moore 算法整体代码实现 + +```python +# Boyer-Moore 字符串匹配算法实现 +def boyerMoore(T: str, p: str) -> int: + """ + Boyer-Moore 算法主函数,返回模式串 p 在文本串 T 中首次出现的位置,若无则返回 -1。 + """ + n, m = len(T), len(p) + if m == 0: + return 0 if n == 0 else -1 # 约定空模式串匹配空文本串返回 0,否则 -1 + if n < m: + return -1 + + bc_table = generateBadCharTable(p) # 生成坏字符表 + gs_list = generateGoodSuffixList(p) # 生成好后缀表 + + i = 0 + while i <= n - m: + j = m - 1 + # 从模式串末尾向前比较 + while j >= 0 and T[i + j] == p[j]: + j -= 1 + if j < 0: + return i # 匹配成功,返回起始下标 + # 坏字符规则:j - bc_table.get(T[i + j], -1) + bad_move = j - bc_table.get(T[i + j], -1) + # 好后缀规则:gs_list[j] + good_move = gs_list[j] + # 取两者最大值进行滑动 + i += max(bad_move, good_move) + return -1 + +def generateBadCharTable(p: str): + """ + 生成坏字符表:记录每个字符在模式串中最后一次出现的位置。 + """ + bc_table = dict() + for i, ch in enumerate(p): + bc_table[ch] = i # 只保留最后一次出现的位置 + return bc_table + +def generateGoodSuffixList(p: str): + """ + 生成好后缀规则的后移位数表 gs_list。 + gs_list[j] 表示在模式串下标 j 处遇到坏字符时,根据好后缀规则可以向右移动的距离。 + """ + m = len(p) + gs_list = [m for _ in range(m)] # 默认全部为情况 3:最大移动 m + suffix = generateSuffixArray(p) # 生成后缀数组 + + # 处理情况 2:寻找最长的前缀与好后缀的后缀相等 + j = 0 + for i in range(m - 1, -1, -1): + if suffix[i] == i + 1: + while j < m - 1 - i: + if gs_list[j] == m: + gs_list[j] = m - 1 - i # 更新移动距离 + j += 1 + + # 处理情况 1:模式串中存在与好后缀完全相同的子串 + for i in range(m - 1): + # m-1-suffix[i] 是好后缀的左端点 + gs_list[m - 1 - suffix[i]] = m - 1 - i + + return gs_list + +def generateSuffixArray(p: str): + """ + 生成后缀数组 suffix。 + suffix[i] 表示以 i 结尾的子串与模式串后缀的最大匹配长度。 + """ + m = len(p) + suffix = [m for _ in range(m)] # 初始化为 0,表示尚未匹配 + suffix[m - 1] = m # 最后一个字符的后缀长度为 m + for i in range(m - 2, -1, -1): + j = i + # 从 i 向前与模式串后缀比较 + while j >= 0 and p[j] == p[m - 1 - i + j]: + j -= 1 + suffix[i] = i - j + return suffix + +# 测试用例 +print(boyerMoore("abbcfdddbddcaddebc", "aaaaa")) # -1 +print(boyerMoore("", "")) # 0 +print(boyerMoore("HERE IS A SIMPLE EXAMPLE", "EXAMPLE")) # 17 +print(boyerMoore("abcabcabcabc", "abcabc")) # 0 +``` + +## 6. Boyer Moore 算法分析 + +| 指标 | 复杂度 | 说明 | +| ------------ | -------------- | ------------------------------------------------------------ | +| 最好时间复杂度 | $O(n / m)$ | 每次匹配时,模式串 $p$ 中不存在与文本串 $T$ 中第一个匹配的字符,滑动距离最大,比较次数最少。| +| 最坏时间复杂度 | $O(m \times n)$| 文本串 $T$ 中有大量重复字符,且模式串 $p$ 由 $m-1$ 个相同字符和一个不同字符组成,导致每次只能滑动一位。| +| 平均时间复杂度 | 介于 $O(n / m)$ 与 $O(m \times n)$ 之间 | 实际应用中通常远优于最坏情况,接近最好情况。| +| 预处理时间复杂度 | $O(m + \sigma)$ | 生成坏字符表和好后缀表,$\sigma$ 为字符集大小。| +| 空间复杂度 | $O(m + \sigma)$ | 需存储坏字符表($\sigma$)和好后缀表($m$)。| + +- 其中 $n$ 为文本串长度,$m$ 为模式串长度,$\sigma$ 为字符集大小。 +- 当模式串 $p$ 是非周期性的,在最坏情况下,BM 算法最多需要进行 $3n$ 次字符比较操作。 + +## 7. 总结 + +Boyer-Moore(BM)算法通过「坏字符规则」和「好后缀规则」两种启发式策略,实现模式串的高效跳跃移动,是实际应用中性能最优的单模式串匹配算法之一。 + +**优点**: +- **实际性能优异**:在大多数实际应用中,BM 算法通常比 KMP 算法快 3~5 倍 +- **跳跃能力强**:通过坏字符和好后缀规则,能够跳过大量不可能匹配的位置 +- **从右到左比较**:充分利用模式串信息,减少不必要的字符比较 +- **启发式策略**:两种规则互补,最大化跳跃距离 + +**缺点**: +- **实现复杂**:特别是好后缀规则的预处理部分,理解和实现难度较高 +- **最坏情况退化**:在特定输入下可能退化到 $O(m \times n)$ 复杂度 +- **空间开销**:需要存储坏字符表和好后缀表,空间复杂度为 $O(m + \sigma)$ +- **预处理开销**:需要预先构建两个辅助表,不适合单次匹配场景 + +## 练习题目 + +- [0028. 找出字符串中第一个匹配项的下标](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0001-0099/find-the-index-of-the-first-occurrence-in-a-string.md) +- [0459. 重复的子字符串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0400-0499/repeated-substring-pattern.md) +- [0686. 重复叠加字符串匹配](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0600-0699/repeated-string-match.md) +- [0796. 旋转字符串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0700-0799/rotate-string.md) +- [1408. 数组中的字符串匹配](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/1400-1499/string-matching-in-an-array.md) +- [2156. 查找给定哈希值的子串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/2100-2199/find-substring-with-given-hash-value.md) + +- [单模式串匹配题目列表](https://github.com/ITCharge/AlgoNote/tree/main/docs/00_preface/00_06_categories_list.md#%E5%8D%95%E6%A8%A1%E5%BC%8F%E4%B8%B2%E5%8C%B9%E9%85%8D%E9%A2%98%E7%9B%AE) + +## 参考资料 + +- 【书籍】柔性字符串匹配 - 中科院计算所网络信息安全研究组 译 +- 【文章】[不用找了,学习 BM 算法,这篇就够了(思路+详注代码)- BoCong-Deng 的博客](https://blog.csdn.net/DBC_121/article/details/105569440) +- 【文章】[字符串匹配的 Boyer-Moore 算法 - 阮一峰的网络日志](https://www.ruanyifeng.com/blog/2013/05/boyer-moore_string_search_algorithm.html) +- 【文章】[ bm 算法好后缀 java 实现 - 长笛小号的博客 - CSDN博客](https://blog.csdn.net/weixin_29217235/article/details/114488027) +- 【文章】[BM算法详解 - 简单爱_wxg - 博客园](https://www.cnblogs.com/wxgblogs/p/5701101.html) +- 【文章】[grep 之字符串搜索算法 Boyer-Moore 由浅入深 - Alexia(minmin) - 博客园](https://www.cnblogs.com/lanxuezaipiao/p/3452579.html) +- 【文章】[字符串匹配基础(中)- 数据结构与算法之美 - 极客时间](https://time.geekbang.org/column/article/71525) +- 【代码】[BM算法 附有解释 - 实现 strStr() - 力扣](https://leetcode.cn/problems/implement-strstr/solution/bmsuan-fa-fu-you-jie-shi-by-wen-198/) \ No newline at end of file diff --git a/docs/04_string/04_05_string_kmp.md b/docs/04_string/04_05_string_kmp.md deleted file mode 100644 index cfc3f9c9..00000000 --- a/docs/04_string/04_05_string_kmp.md +++ /dev/null @@ -1,165 +0,0 @@ -## 1. KMP 算法介绍 - -> **KMP 算法**:全称叫做 **「Knuth Morris Pratt 算法」**,是由它的三位发明者 Donald Knuth、James H. Morris、 Vaughan Pratt 的名字来命名的。KMP 算法是他们三人在 1977 年联合发表的。 -> -> - **KMP 算法思想**:对于给定文本串 $T$ 与模式串 $p$,当发现文本串 $T$ 的某个字符与模式串 $p$ 不匹配的时候,可以利用匹配失败后的信息,尽量减少模式串与文本串的匹配次数,避免文本串位置的回退,以达到快速匹配的目的。 - -### 1.1 朴素匹配算法的缺陷 - -在朴素匹配算法的匹配过程中,我们分别用指针 $i$ 和指针 $j$ 指示文本串 $T$ 和模式串 $p$ 中当前正在对比的字符。当发现文本串 $T$ 的某个字符与模式串 $p$ 不匹配的时候,$j$ 回退到开始位置,$i$ 回退到之前匹配开始位置的下一个位置上,然后开启新一轮的匹配,如图所示。 - -![朴素匹配算法](https://qcdn.itcharge.cn/images/20240511154456.png) - -这样,在 Brute Force 算法中,如果从文本串 $T[i]$ 开始的这一趟字符串比较失败了,算法会直接开始尝试从 $T[i + 1]$ 开始比较。如果 $i$ 已经比较到了后边位置,则该操作相当于将指针 $i$ 进行了回退操作。 - -那么有没有哪种算法,可以让 $i$ 不发生回退,一直向右移动呢? - -### 1.2 KMP 算法的改进 - -如果我们可以通过每一次的失配而得到一些「信息」,并且这些「信息」可以帮助我们跳过那些不可能匹配成功的位置,那么我们就能大大减少模式串与文本串的匹配次数,从而达到快速匹配的目的。 - -每一次失配所告诉我们的信息是:**主串的某一个子串等于模式串的某一个前缀**。 - -这个信息的意思是:如果文本串 $T[i: i + m]$ 与模式串 $p$ 的失配是下标位置 $j$ 上发生的,那么文本串 $T$ 从下标位置 $i$ 开始连续的 $j - 1$ 个字符,一定与模式串 $p$ 的前 $j - 1$ 个字符一模一样,即:$T[i: i + j] == p[0: j]$。 - -但是知道这个信息有什么用呢? - -以刚才图中的例子来说,文本串的子串 $T[i: i + m]$ 与模式串 $p$ 的失配是在第 $5$ 个位置发生的,那么: - -- 文本串 $T$ 从下标位置 $i$ 开始连续的 $5$ 个字符,一定与模式串 $p$ 的前 $5$ 个字符一模一样,即:`"ABCAB" == "ABCAB"`。 -- 而模式串的前 $5$ 个字符中,前 $2$ 位前缀和后 $2$ 位后缀又是相同的,即 `"AB" == "AB"`。 - -所以根据上面的信息,我们可以推出:文本串子串的后 $2$ 位后缀和模式串子串的前 $2$ 位是相同的,即 $T[i + 3: i + 5] == p[0: 2]$,而这部分(即下图中的蓝色部分)是之前已经比较过的,不需要再比较了,可以直接跳过。 - -那么我们就可以将文本串中的 $T[i + 5]$ 对准模式串中的 $p[2]$,继续进行对比。这样 $i$ 就不再需要回退了,可以一直向右移动匹配下去。在这个过程中,我们只需要将模式串 $j$ 进行回退操作即可。 - -![KMP 匹配算法移动过程 1](https://qcdn.itcharge.cn/images/20240511155900.png) - -KMP 算法就是使用了这样的思路,对模式串 $p$ 进行了预处理,计算出一个 **「部分匹配表」**,用一个数组 $next$ 来记录。然后在每次失配发生时,不回退文本串的指针 $i$,而是根据「部分匹配表」中模式串失配位置 $j$ 的前一个位置的值,即 $next[j - 1]$ 的值来决定模式串可以向右移动的位数。 - -比如上述示例中模式串 $p$ 是在 $j = 5$ 的位置上发生失配的,则说明文本串的子串 $T[i: i + 5]$ 和模式串 $p[0: 5]$ 的字符是一致的,即 `"ABCAB" == "ABCAB"`。而根据「部分匹配表」中 $next[4] == 2$,所以不用回退 $i$,而是将 $j$ 移动到下标为 $2$ 的位置,让 $T[i + 5]$ 直接对准 $p[2]$,然后继续进行比对。 - -### 1.3 next 数组 - -上文提到的「部分匹配表」,也叫做「前缀表」,在 KMP 算法中使用 $next$ 数组存储。$next[j]$ 表示的含义是:**记录下标 j 之前(包括 j)的模式串 $p$ 中,最长相等前后缀的长度。** - -简单而言,就是求:**模式串 $p$ 的子串 $p[0: j + 1]$ 中,使得「前 k 个字符」恰好等于「后 k 个字符」的「最长的 $k$」**。当然子串 $p[0: j + 1]$ 本身不参与比较。 - -举个例子来说明一下,以 `p = "ABCABCD"` 为例。 - -- $next[0] = 0$,因为 `"A"` 中无有相同前缀后缀,最大长度为 $0$。 -- $next[1] = 0$,因为 `"AB"` 中无相同前缀后缀,最大长度为 $0$。 -- $next[2] = 0$,因为 `"ABC"` 中无相同前缀后缀,最大长度为 $0$。 -- $next[3] = 1$,因为 `"ABCA"` 中有相同的前缀后缀 `"A"`,最大长度为 $1$。 -- $next[4] = 2$,因为 `"ABCAB"` 中有相同的前缀后缀 `"AB"`,最大长度为 $2$。 -- $next[5] = 3$,因为 `"ABCABC"` 中有相同的前缀后缀 `"ABC"`,最大长度为 $3$。 -- $next[6] = 0$,因为 `"ABCABCD"` 中无相同前缀后缀,最大长度为 $0$。 - -同理也可以计算出 `"ABCABDEF"` 的前缀表为 $[0, 0, 0, 1, 2, 0, 0, 0]$。`"AABAAAB"` 的前缀表为 $[0, 1, 0, 1, 2, 2, 3]$。`"ABCDABD"` 的前缀表为 $[0, 0, 0, 0, 1, 2, 0]$。 - -在之前的例子中,当 $p[5]$ 和 $T[i + 5]$ 匹配失败后,根据模式串失配位置 $j$ 的前一个位置的值,即 $next[4] = 2$,我们直接让 $T[i + 5]$ 直接对准了 $p[2]$,然后继续进行比对,如下图所示。 - -![KMP 匹配算法移动过程 2](https://qcdn.itcharge.cn/images/20240511161310.png) - -**但是这样移动的原理是什么?** - -其实在上文 **「1.2 KMP 算法的改进」** 中的例子中我们提到过了。现在我们将其延伸总结一下,其实这个过程就是利用了前缀表进行模式串移动的原理,具体推论如下。 - -如果文本串 $T[i: i + m]$ 与模式串 $p$ 的失配是在第 $j$ 个下标位置发生的,那么: - -- 文本串 $T$ 从下标位置 $i$ 开始连续的 $j$ 个字符,一定与模式串 $p$ 的前 $j$ 个字符一模一样,即:$T[i: i + j] == p[0: j]$。 -- 而如果模式串 $p$ 的前 $j$ 个字符中,前 $k$ 位前缀和后 $k$ 位后缀相同,即 $p[0: k] == p[j - k: j]$,并且要保证 $k$ 要尽可能长。 - -可以推出:文本串子串的后 $k$ 位后缀和模式串子串的前 $k$ 位是相同的,即 $T[i + j - k: i + j] == p[0: k]$(这部分是已经比较过的),不需要再比较了,可以直接跳过。 - -那么我们就可以将文本串中的 $T[i + j]$ 对准模式串中的 $p[k]$,继续进行对比。这里的 $k$ 其实就是 $next[j - 1]$。 - -## 2. KMP 算法步骤 - -### 3.1 next 数组的构造 - -我们可以通过递推的方式构造 $next$ 数组。 - -- 我们把模式串 $p$ 拆分成 $left$、$right$ 两部分。$left$ 表示前缀串开始所在的下标位置,$right$ 表示后缀串开始所在的下标位置,起始时 $left = 0$,$right = 1$。 -- 比较一下前缀串和后缀串是否相等。通过比较 $p[left]$ 和 $p[right]$ 来进行判断。 -- 如果 $p[left] != p[right]$,说明当前的前后缀不相同。则让后缀开始位置 $k$ 不动,前缀串开始位置 $left$ 不断回退到 $next[left - 1]$ 位置,直到 $p[left] == p[right]$ 为止。 -- 如果 $p[left] == p[right]$,说明当前的前后缀相同,则可以先让 $left += 1$,此时 $left$ 既是前缀下一次进行比较的下标位置,又是当前最长前后缀的长度。 -- 记录下标 $right$ 之前的模式串 $p$ 中,最长相等前后缀的长度为 $left$,即 $next[right] = left$。 - -### 3.2 KMP 算法整体步骤 - -1. 根据 $next$ 数组的构造步骤生成「前缀表」$next$。 -2. 使用两个指针 $i$、$j$,其中 $i$ 指向文本串中当前匹配的位置,$j$ 指向模式串中当前匹配的位置。初始时,$i = 0$,$j = 0$。 -3. 循环判断模式串前缀是否匹配成功,如果模式串前缀匹配不成功,将模式串进行回退,即 $j = next[j - 1]$,直到 $j == 0$ 时或前缀匹配成功时停止回退。 -4. 如果当前模式串前缀匹配成功,则令模式串向右移动 $1$ 位,即 $j += 1$。 -5. 如果当前模式串 **完全** 匹配成功,则返回模式串 $p$ 在文本串 $T$ 中的开始位置,即 $i - j + 1$。 -6. 如果还未完全匹配成功,则令文本串向右移动 $1$ 位,即 $i += 1$,然后继续匹配。 -7. 如果直到文本串遍历完也未完全匹配成功,则说明匹配失败,返回 $-1$。 - -## 3. KMP 算法代码实现 - -```python -# 生成 next 数组 -# next[j] 表示下标 j 之前的模式串 p 中,最长相等前后缀的长度 -def generateNext(p: str): - m = len(p) - next = [0 for _ in range(m)] # 初始化数组元素全部为 0 - - left = 0 # left 表示前缀串开始所在的下标位置 - for right in range(1, m): # right 表示后缀串开始所在的下标位置 - while left > 0 and p[left] != p[right]: # 匹配不成功, left 进行回退, left == 0 时停止回退 - left = next[left - 1] # left 进行回退操作 - if p[left] == p[right]: # 匹配成功,找到相同的前后缀,先让 left += 1,此时 left 为前缀长度 - left += 1 - next[right] = left # 记录前缀长度,更新 next[right], 结束本次循环, right += 1 - - return next - -# KMP 匹配算法,T 为文本串,p 为模式串 -def kmp(T: str, p: str) -> int: - n, m = len(T), len(p) - - next = generateNext(p) # 生成 next 数组 - - j = 0 # j 为模式串中当前匹配的位置 - for i in range(n): # i 为文本串中当前匹配的位置 - while j > 0 and T[i] != p[j]: # 如果模式串前缀匹配不成功, 将模式串进行回退, j == 0 时停止回退 - j = next[j - 1] - if T[i] == p[j]: # 当前模式串前缀匹配成功,令 j += 1,继续匹配 - j += 1 - if j == m: # 当前模式串完全匹配成功,返回匹配开始位置 - return i - j + 1 - return -1 # 匹配失败,返回 -1 - -print(kmp("abbcfdddbddcaddebc", "ABCABCD")) -print(kmp("abbcfdddbddcaddebc", "bcf")) -print(kmp("aaaaa", "bba")) -print(kmp("mississippi", "issi")) -print(kmp("ababbbbaaabbbaaa", "bbbb")) -``` - -## 4. KMP 算法分析 - -- KMP 算法在构造前缀表阶段的时间复杂度为 $O(m)$,其中 $m$ 是模式串 $p$ 的长度。 -- KMP 算法在匹配阶段,是根据前缀表不断调整匹配的位置,文本串的下标 $i$ 并没有进行回退,可以看出匹配阶段的时间复杂度是 $O(n)$,其中 $n$ 是文本串 $T$ 的长度。 -- 所以 KMP 整个算法的时间复杂度是 $O(n + m)$,相对于朴素匹配算法的 $O(n \times m)$ 的时间复杂度,KMP 算法的效率有了很大的提升。 - -## 练习题目 - -- [0028. 找出字符串中第一个匹配项的下标](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0001-0099/find-the-index-of-the-first-occurrence-in-a-string.md) -- [0459. 重复的子字符串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0400-0499/repeated-substring-pattern.md) -- [0686. 重复叠加字符串匹配](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0600-0699/repeated-string-match.md) -- [0796. 旋转字符串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0700-0799/rotate-string.md) -- [1408. 数组中的字符串匹配](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/1400-1499/string-matching-in-an-array.md) -- [2156. 查找给定哈希值的子串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/2100-2199/find-substring-with-given-hash-value.md) - -- [单模式串匹配题目列表](https://github.com/ITCharge/AlgoNote/tree/main/docs/00_preface/00_06_categories_list.md#%E5%8D%95%E6%A8%A1%E5%BC%8F%E4%B8%B2%E5%8C%B9%E9%85%8D%E9%A2%98%E7%9B%AE) - -## 参考资料 - -- 【书籍】柔性字符串匹配 - 中科院计算所网络信息安全研究组 译 -- 【书籍】ACM-ICPC 程序设计系列 - 算法设计与实现 - 陈宇 吴昊 主编 -- 【博文】[从头到尾彻底理解 KMP - 结构之法 算法之道 - CSDN博客](https://blog.csdn.net/v_JULY_v/article/details/7041827?spm=1001.2014.3001.5502) -- 【博文】[字符串匹配的 KMP 算法 - 阮一峰的网络日志](http://www.ruanyifeng.com/blog/2013/05/Knuth–Morris–Pratt_algorithm.html) -- 【题解】[多图预警 - 详解 KMP 算法 - 实现 strStr() - 力扣](https://leetcode.cn/problems/implement-strstr/solution/duo-tu-yu-jing-xiang-jie-kmp-suan-fa-by-w3c9c/) -- 【题解】[「代码随想录」KMP算法详解 - 实现 strStr() - 力扣](https://leetcode.cn/problems/implement-strstr/solution/dai-ma-sui-xiang-lu-kmpsuan-fa-xiang-jie-mfbs/) diff --git a/docs/04_string/04_06_string_boyer_moore.md b/docs/04_string/04_06_string_boyer_moore.md deleted file mode 100644 index 4588486a..00000000 --- a/docs/04_string/04_06_string_boyer_moore.md +++ /dev/null @@ -1,346 +0,0 @@ -## 1. Boyer Moore 算法介绍 - -> **Boyer Moore 算法**:简称为 BM 算法,是由它的两位发明者 Robert S. Boyer 和 J Strother Moore 的名字来命名的。BM 算法是他们在 1977 年提出的高效字符串搜索算法。在实际应用中,比 KMP 算法要快 3~5 倍。 -> -> - **BM 算法思想**:对于给定文本串 $T$ 与模式串 $p$,先对模式串 $p$ 进行预处理。然后在匹配的过程中,当发现文本串 $T$ 的某个字符与模式串 $p$ 不匹配的时候,根据启发策略,能够直接尽可能地跳过一些无法匹配的情况,将模式串多向后滑动几位。 - -BM 算法的精髓在于使用了两种不同的启发策略来计算后移位数:**「坏字符规则(The Bad Character Rule)」** 和 **「好后缀规则(The Good Suffix Shift Rule)」**。 - -这两种启发策略的计算过程只与模式串 $p$ 相关,而与文本串 $T$ 无关。因此在对模式串 $p$ 进行预处理时,可以预先生成「坏字符规则后移表」和「好后缀规则后移表」,然后在匹配的过程中,只需要比较一下两种策略下最大的后移位数进行后移即可。 - -同时,还需要注意一点。BM 算法在移动模式串的时候和常规匹配算法一样是从左到右进行,但是在进行比较的时候是从右到左,即基于后缀进行比较。 - -下面我们来讲解一下 BF 算法中的两种不同启发策略:「坏字符规则」和「好后缀规则」。 - -## 2. Boyer Moore 算法启发策略 - -### 2.1 坏字符规则 - -> **坏字符规则(The Bad Character Rule)**:当文本串 $T$ 中某个字符跟模式串 $p$ 的某个字符不匹配时,则称文本串 $T$ 中这个失配字符为 **「坏字符」**,此时模式串 $p$ 可以快速向右移动。 - -「坏字符规则」的移动位数分为两种情况: - -- **情况 1:坏字符出现在模式串 $p$ 中**。 - - 这种情况下,可将模式串中最后一次出现的坏字符与文本串中的坏字符对齐,如下图所示。 - - **向右移动位数 = 坏字符在模式串中的失配位置 - 坏字符在模式串中最后一次出现的位置**。 - -![情况 1:坏字符出现在模式串 p 中](https://qcdn.itcharge.cn/images/20240511164026.png) - -- **情况 2:坏字符没有出现在模式串 $p$ 中**。 - - 这种情况下,可将模式串向右移动一位,如下图所示。 - - **向右移动位数 = 坏字符在模式串中的失配位置 + 1**。 - -![情况 2:坏字符没有出现在模式串 p 中](https://qcdn.itcharge.cn/images/20240511164048.png) - -### 2.2 好后缀规则 - -> **好后缀规则(The Good Suffix Shift Rule)**:当文本串 $T$ 中某个字符跟模式串 $p$ 的某个字符不匹配时,则称文本串 $T$ 中已经匹配好的字符串为 **「好后缀」**,此时模式串 $p$ 可以快速向右移动。 - -「好后缀规则」的移动方式分为三种情况: - -- **情况 1:模式串中有子串匹配上好后缀**。 - - 这种情况下,移动模式串,让该子串和好后缀对齐即可。如果超过一个子串匹配上好后缀,则选择最右侧的子串对齐,如下图所示。 - - **向右移动位数 = 好后缀的最后一个字符在模式串中的位置 - 匹配的子串最后一个字符出现的位置**。 - -![情况 1:模式串中有子串匹配上好后缀](https://qcdn.itcharge.cn/images/20240511164101.png) - -- **情况 2:模式串中无子串匹配上好后缀,但有最长前缀匹配好后缀的后缀**。 - - 这种情况下,我们需要在模式串的前缀中寻找一个最长前缀,该前缀等于好后缀的后缀。找到该前缀后,让该前缀和好后缀的后缀对齐。 - - **向右移动位数 = 好后缀的后缀的最后一个字符在模式串中的位置 - 最长前缀的最后一个字符出现的位置**。 - -![情况 2:模式串中无子串匹配上好后缀, 但有最长前缀匹配好后缀的后缀](https://qcdn.itcharge.cn/images/20240511164112.png) - -- **情况 3:模式串中无子串匹配上好后缀,也找不到前缀匹配**。 - - 可将模式串整个右移。 - - **向右移动位数 = 模式串的长度**。 - -![情况 3:模式串中无子串匹配上好后缀,也找不到前缀匹配](https://qcdn.itcharge.cn/images/20240511164124.png) - -## 3. Boyer Moore 算法匹配过程示例 - -下面我们根据 J Strother Moore 教授给出的例子,先来介绍一下 BF 算法的匹配过程,顺便加深对 **「坏字符规则」** 和 **「好后缀规则」** 的理解。 - -::: tabs#Boyer-Moore - -@tab <1> - -假设文本串为 `"HERE IS A SIMPLE EXAMPLE"`,模式串为 `"EXAMPLE"`,如下图所示。 - -![Boyer Moore 算法步骤 1](https://qcdn.itcharge.cn/images/20220127164130.png) - -@tab <2> - -首先,令模式串与文本串的头部对齐,然后从模式串的尾部开始逐位比较,如下图所示。 - -![Boyer Moore 算法步骤 2](https://qcdn.itcharge.cn/images/20220127164140.png) - -可以看出来,`'S'` 与 `'E'` 不匹配。这时候,不匹配的字符 `'S'` 就被称为「坏字符(Bad Character)」,对应着模式串的第 $6$ 位。并且 `'S'` 并不包含在模式串 `"EXAMPLE"` 中(相当于`'S'` 在模式串中最后一次出现的位置是 $-1$)。根据「坏字符规则」,可以把模式串直接向右移动 $6 - (-1) = 7$​ 位,即将文本串中 `'S'` 的后一位上。 - -@tab <3> - -将模式串向右移动 $7$ 位。然后依然从模式串尾部开始比较,发现 `'P'` 和 `'E'` 不匹配,则 `'P'` 是坏字符,如下图所示。 - -![Boyer Moore 算法步骤 3](https://qcdn.itcharge.cn/images/20220127164151.png) - -但是 `'P'` 包含在模式串 `"EXAMPLE"` 中,`'P'` 这个坏字符在模式串中的失配位置是第 $6$ 位,并且在模式串中最后一次出现的位置是 $4$(编号从 $0$​ 开始)。 - -@tab <4> - -根据「坏字符规则」,可以将模式串直接向右移动 $6 - 4 = 2$ 位,将文本串的 `'P'` 和模式串中的 `'P'` 对齐,如下图所示。 - -![Boyer Moore 算法步骤 4](https://qcdn.itcharge.cn/images/20220127164202.png) - -@tab <5> - -我们继续从尾部开始逐位比较。先比较文本串的 `'E'` 和模式串的 `'E'`,如下图所示。可以看出文本串的 `'E'` 和模式串的 `'E'` 匹配,则 `"E"` 为好后缀,`"E"` 在模式串中的位置为 $6$(编号从 $0$ 开始)。 - -![Boyer Moore 算法步骤 5](https://qcdn.itcharge.cn/images/20220127164212.png) - -@tab <6> - -继续比较前面一位,即文本串的 `'L'` 和模式串的 `'L'`,如下图所示。可以看出文本串的 `'L'` 和模式串的 `'L'` 匹配。则 `"LE"` 为好后缀,`"LE"` 在模式串中的位置为 $6$(编号从 $0$ 开始)。 - -![Boyer Moore 算法步骤 6](https://qcdn.itcharge.cn/images/20220127164222.png) - -@tab <7> - -继续比较前面一位,即文本串中的 `'P'` 和模式串中的 `'P'`,如下图所示。可以看出文本串中的 `'P'` 和模式串中的 `'P'` 匹配,则 `"PLE"` 为好后缀,`"PLE"` 在模式串中的位置为 $6$(编号从 $0$ 开始)。 - -![Boyer Moore 算法步骤 7](https://qcdn.itcharge.cn/images/20220127164232.png) - -@tab <8> - -继续比较前面一位,即文本串中的 `'M'` 和模式串中的 `'M'`,如下图所示。可以看出文本串中的 `'M'` 和模式串中的 `'M'` 匹配,则 `"MPLE"` 为好后缀。`"MPLE"` 在模式串中的位置为 $6$(编号从 $0$ 开始)。 - -![Boyer Moore 算法步骤 8](https://qcdn.itcharge.cn/images/20220127164241.png) - -@tab <9> - -继续比较前面一位,即文本串中的 `'I'` 和模式串中的 `'A'`,如下图所示。可以看出文本串中的 `'I'` 和模式串中的 `'A'` 不匹配。 - -![Boyer Moore 算法步骤 9-1](https://qcdn.itcharge.cn/images/20220127164251.png) - -此时,如果按照「坏字符规则」,模式串应该向右移动 $2 - (-1) = 3$ 位。但是根据「好后缀规则」,我们还有更好的移动方法。 - -在好后缀 `"MPLE"` 和好后缀的后缀 `"PLE"`、`"LE"`、`"E"` 中,只有好后缀的后缀 `"E"` 和模式串中的前缀 `"E"` 相匹配,符合好规则的第二种情况。好后缀的后缀 `"E"` 的最后一个字符在模式串中的位置为 $6$,最长前缀 `"E"`的最后一个字符出现的位置为 $0$,则根据「好后缀规则」,可以将模式串直接向右移动 $6 - 0 = 6$ 位。如下图所示。 - -![Boyer Moore 算法步骤 9-2](https://qcdn.itcharge.cn/images/20220127164301.png) - -@tab <10> - -继续从模式串的尾部开始逐位比较,如下图所示。 - -可以看出,`'P'` 与`'E'` 不匹配,`'P'` 是坏字符。根据「坏字符规则」,可以将模式串直接向右移动 $6 - 4 = 2$ 位,如下图所示。 - -![Boyer Moore 算法步骤 10](https://qcdn.itcharge.cn/images/20220127164312.png) - -@tab <11> - -继续从模式串的尾部开始逐位比较,发现模式串全部匹配,于是搜索结束,返回模式串在文本串中的位置。 - -::: - -## 4. Boyer Moore 算法步骤 - -整个 BM 算法步骤描述如下: - -1. 计算出文本串 $T$ 的长度为 $n$,模式串 $p$ 的长度为 $m$。 -2. 先对模式串 $p$ 进行预处理,生成坏字符位置表 $bc\underline{\hspace{0.5em}}table$ 和好后缀规则后移位数表 $gs\underline{\hspace{0.5em}}talbe$。 -3. 将模式串 $p$ 的头部与文本串 $T$ 对齐,将 $i$ 指向文本串开始位置,即 $i = 0$。$j$ 指向模式串末尾位置,即 $j = m - 1$,然后从模式串末尾位置开始进行逐位比较。 - 1. 如果文本串对应位置 $T[i + j]$ 上的字符与 $p[j]$ 相同,则继续比较前一位字符。 - 1. 如果模式串全部匹配完毕,则返回模式串 $p$ 在文本串中的开始位置 $i$。 - 2. 如果文本串对应位置 $T[i + j]$ 上的字符与 $p[j]$ 不相同,则: - 1. 根据坏字符位置表计算出在「坏字符规则」下的移动距离 $bad\underline{\hspace{0.5em}}move$。 - 2. 根据好后缀规则后移位数表计算出在「好后缀规则」下的移动距离 $good\underline{\hspace{0.5em}}mode$。 - 3. 取两种移动距离的最大值,然后对模式串进行移动,即 $i += max(bad\underline{\hspace{0.5em}}move, good\underline{\hspace{0.5em}}move)$。 -4. 如果移动到末尾也没有找到匹配情况,则返回 $-1$。 - -## 5. Boyer Moore 算法代码实现 - -BM 算法的匹配过程实现起来并不是很难,而整个算法实现的难点在于预处理阶段的「生成坏字符位置表」和「生成好后缀规则后移位数表」这两步上。尤其是「生成好后缀规则后移位数表」,实现起来十分复杂。下面我们一一进行讲解。 - -### 5.1 生成坏字符位置表代码实现 - -生成坏字符位置表的代码实现比较简单。具体步骤如下: - -- 使用一个哈希表 $bc\underline{\hspace{0.5em}}table$, $bc\underline{\hspace{0.5em}}table[bad\underline{\hspace{0.5em}}char]$ 表示坏字符 $bad\underline{\hspace{0.5em}}char$ 在模式串中出现的最右位置。 - -- 遍历模式串,以当前字符 $p[i]$ 为键,所在位置下标为值存入字典中。如果出现重复字符,则新的位置下标值会将之前存放的值覆盖掉。这样哈希表中存放的就是该字符在模式串中出现的最右侧位置。 - -这样如果在 BM 算法的匹配过程中,如果 $bad\underline{\hspace{0.5em}}char$ 不在 $bc\underline{\hspace{0.5em}}table$ 中时,可令 $bad\underline{\hspace{0.5em}}char$ 在模式串中出现的最右侧位置为 $-1$。如果 $bad\underline{\hspace{0.5em}}char$ 在 $bc\underline{\hspace{0.5em}}table$ 中时,$bad\underline{\hspace{0.5em}}char$ 在模式串中出现的最右侧位置就是 $bc\underline{\hspace{0.5em}}table[bad\underline{\hspace{0.5em}}char]$。这样就可以根据公式计算出可以向右移动的位数了。 - -生成坏字符位置表的代码如下: - -```python -# 生成坏字符位置表 -# bc_table[bad_char] 表示坏字符在模式串中最后一次出现的位置 -def generateBadCharTable(p: str): - bc_table = dict() - - for i in range(len(p)): - bc_table[p[i]] = i # 更新坏字符在模式串中最后一次出现的位置 - return bc_table -``` - -### 5.2 生成好后缀规则后移位数表代码实现 - -为了生成好后缀规则后移位数表,我们需要先定义一个后缀数组 $suffix$,其中 $suffix[i] = s$ 表示为以下标 $i$ 为结尾的子串与模式串后缀匹配的最大长度为 $s$。即满足 $p[i-s...i] == p[m-1-s, m-1]$ 的最大长度为 $s$。 - -构建 $suffix$ 数组的代码如下: - -```python -# 生成 suffix 数组 -# suffix[i] 表示为以下标 i 为结尾的子串与模式串后缀匹配的最大长度 -def generageSuffixArray(p: str): - m = len(p) - suffix = [m for _ in range(m)] # 初始化时假设匹配的最大长度为 m - for i in range(m - 2, -1, -1): # 子串末尾从 m - 2 开始 - start = i # start 为子串开始位置 - while start >= 0 and p[start] == p[m - 1 - i + start]: - start -= 1 # 进行后缀匹配,start 为匹配到的子串开始位置 - suffix[i] = i - start # 更新以下标 i 为结尾的子串与模式串后缀匹配的最大长度 - return suffix -``` - -有了 $suffix$ 数组,我们就可以在此基础上定义好后缀规则后移位数表 $gs\underline{\hspace{0.5em}}list$。我们使用一个数组来表示好后缀规则后移位数表。其中 $gs\underline{\hspace{0.5em}}list[j]$ 表示在 $j$ 下标处遇到坏字符时,可根据好规则向右移动的距离。 - -由 「2.2 好后缀规则」 中可知,好后缀规则的移动方式可以分为三种情况。 - -- 情况 1:模式串中有子串匹配上好后缀。 -- 情况 2:模式串中无子串匹配上好后缀,但有最长前缀匹配好后缀的后缀。 -- 情况 3:模式串中无子串匹配上好后缀,也找不到前缀匹配。 - -这 3 种情况中,情况 2 和情况 3 可以合并,因为情况 3 可以看做是匹配到的最长前缀长度为 $0$。而如果遇到一个坏字符同时满足多种情况,则我们应该选择满足情况中最小的移动距离才不会漏掉可能匹配的情况,比如说当模式串中既有子串可以匹配上好后缀,又有前缀可以匹配上好后缀的后缀,则应该按照前者的方式移动模式串。 - -- 为了得到精确的 $gs\underline{\hspace{0.5em}}list[j]$​,我们可以先假定所有情况都为情况 3,即 $gs\underline{\hspace{0.5em}}list[i] = m$​。 -- 然后通过后缀和前缀匹配的方法,更新情况 2 下 $gs\underline{\hspace{0.5em}}list$ 中坏字符位置处的值,即 $gs\underline{\hspace{0.5em}}list[j] = m - 1 - i$,其中 $j$ 是好后缀前的坏字符位置,$i$ 是最长前缀的末尾位置,$m - 1 - i$ 是可向右移动的距离。 -- 最后再计算情况 1 下 $gs\underline{\hspace{0.5em}}list$ 中坏字符位置处的值,更新在好后缀的左端点处($m - 1 - suffix[i]$ 处)遇到坏字符可向后移动位数,即 $gs\underline{\hspace{0.5em}}list[m - 1 - suffix[i]] = m - 1 - i$。 - -生成好后缀规则后移位数表 $gs\underline{\hspace{0.5em}}list$ 代码如下: - -```python -# 生成好后缀规则后移位数表 -# gs_list[j] 表示在 j 下标处遇到坏字符时,可根据好规则向右移动的距离 -def generageGoodSuffixList(p: str): - # 好后缀规则后移位数表 - # 情况 1: 模式串中有子串匹配上好后缀 - # 情况 2: 模式串中无子串匹配上好后缀,但有最长前缀匹配好后缀的后缀 - # 情况 3: 模式串中无子串匹配上好后缀,也找不到前缀匹配 - - m = len(p) - gs_list = [m for _ in range(m)] # 情况 3:初始化时假设全部为情况 3 - suffix = generageSuffixArray(p) # 生成 suffix 数组 - - j = 0 # j 为好后缀前的坏字符位置 - for i in range(m - 1, -1, -1): # 情况 2:从最长的前缀开始检索 - if suffix[i] == i + 1: # 匹配到前缀,即 p[0...i] == p[m-1-i...m-1] - while j < m - 1 - i: - if gs_list[j] == m: - gs_list[j] = m - 1 - i # 更新在 j 处遇到坏字符可向后移动位数 - j += 1 - - for i in range(m - 1): # 情况 1:匹配到子串, p[i-s...i] == p[m-1-s, m-1] - gs_list[m - 1 - suffix[i]] = m - 1 - i # 更新在好后缀的左端点处遇到坏字符可向后移动位数 - return gs_list -``` - -### 5.3 Boyer Moore 算法整体代码实现 - -```python -# BM 匹配算法 -def boyerMoore(T: str, p: str) -> int: - n, m = len(T), len(p) - - bc_table = generateBadCharTable(p) # 生成坏字符位置表 - gs_list = generageGoodSuffixList(p) # 生成好后缀规则后移位数表 - - i = 0 - while i <= n - m: - j = m - 1 - while j > -1 and T[i + j] == p[j]: # 进行后缀匹配,跳出循环说明出现坏字符 - j -= 1 - if j < 0: - return i # 匹配完成,返回模式串 p 在文本串 T 中的位置 - bad_move = j - bc_table.get(T[i + j], -1) # 坏字符规则下的后移位数 - good_move = gs_list[j] # 好后缀规则下的后移位数 - i += max(bad_move, good_move) # 取两种规则下后移位数的最大值进行移动 - return -1 - - -# 生成坏字符位置表 -# bc_table[bad_char] 表示坏字符在模式串中最后一次出现的位置 -def generateBadCharTable(p: str): - bc_table = dict() - - for i in range(len(p)): - bc_table[p[i]] = i # 更新坏字符在模式串中最后一次出现的位置 - return bc_table - -# 生成好后缀规则后移位数表 -# gs_list[j] 表示在 j 下标处遇到坏字符时,可根据好规则向右移动的距离 -def generageGoodSuffixList(p: str): - # 好后缀规则后移位数表 - # 情况 1: 模式串中有子串匹配上好后缀 - # 情况 2: 模式串中无子串匹配上好后缀,但有最长前缀匹配好后缀的后缀 - # 情况 3: 模式串中无子串匹配上好后缀,也找不到前缀匹配 - - m = len(p) - gs_list = [m for _ in range(m)] # 情况 3:初始化时假设全部为情况 3 - suffix = generageSuffixArray(p) # 生成 suffix 数组 - - j = 0 # j 为好后缀前的坏字符位置 - for i in range(m - 1, -1, -1): # 情况 2:从最长的前缀开始检索 - if suffix[i] == i + 1: # 匹配到前缀,即 p[0...i] == p[m-1-i...m-1] - while j < m - 1 - i: - if gs_list[j] == m: - gs_list[j] = m - 1 - i # 更新在 j 处遇到坏字符可向后移动位数 - j += 1 - - for i in range(m - 1): # 情况 1:匹配到子串 p[i-s...i] == p[m-1-s, m-1] - gs_list[m - 1 - suffix[i]] = m - 1 - i # 更新在好后缀的左端点处遇到坏字符可向后移动位数 - return gs_list - -# 生成 suffix 数组 -# suffix[i] 表示为以下标 i 为结尾的子串与模式串后缀匹配的最大长度 -def generageSuffixArray(p: str): - m = len(p) - suffix = [m for _ in range(m)] # 初始化时假设匹配的最大长度为 m - for i in range(m - 2, -1, -1): # 子串末尾从 m - 2 开始 - start = i # start 为子串开始位置 - while start >= 0 and p[start] == p[m - 1 - i + start]: - start -= 1 # 进行后缀匹配,start 为匹配到的子串开始位置 - suffix[i] = i - start # 更新以下标 i 为结尾的子串与模式串后缀匹配的最大长度 - return suffix - -print(boyerMoore("abbcfdddbddcaddebc", "aaaaa")) -print(boyerMoore("", "")) -``` - -## 6. Boyer Moore 算法分析 - -- BM 算法在预处理阶段的时间复杂度为 $O(n + \sigma)$,其中 $\sigma$ 是字符集的大小。 -- BM 算法在搜索阶段最好情况是每次匹配时,模式串 $p$ 中不存在与文本串 $T$ 中第一个匹配的字符。这时的时间复杂度为 $O(n / m)$。 -- BM 算法在搜索阶段最差情况是文本串 $T$ 中有多个重复的字符,并且模式串 $p$ 中有 $m - 1$ 个相同字符前加一个不同的字符组成。这时的时间复杂度为 $O(m * n)$。 -- 当模式串 $p$ 是非周期性的,在最坏情况下,BM 算法最多需要进行 $3 * n$ 次字符比较操作。 - -## 练习题目 - -- [0028. 找出字符串中第一个匹配项的下标](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0001-0099/find-the-index-of-the-first-occurrence-in-a-string.md) -- [0459. 重复的子字符串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0400-0499/repeated-substring-pattern.md) -- [0686. 重复叠加字符串匹配](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0600-0699/repeated-string-match.md) -- [0796. 旋转字符串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0700-0799/rotate-string.md) -- [1408. 数组中的字符串匹配](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/1400-1499/string-matching-in-an-array.md) -- [2156. 查找给定哈希值的子串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/2100-2199/find-substring-with-given-hash-value.md) - -- [单模式串匹配题目列表](https://github.com/ITCharge/AlgoNote/tree/main/docs/00_preface/00_06_categories_list.md#%E5%8D%95%E6%A8%A1%E5%BC%8F%E4%B8%B2%E5%8C%B9%E9%85%8D%E9%A2%98%E7%9B%AE) - -## 参考资料 - -- 【书籍】柔性字符串匹配 - 中科院计算所网络信息安全研究组 译 -- 【文章】[不用找了,学习 BM 算法,这篇就够了(思路+详注代码)- BoCong-Deng 的博客](https://blog.csdn.net/DBC_121/article/details/105569440) -- 【文章】[字符串匹配的 Boyer-Moore 算法 - 阮一峰的网络日志](https://www.ruanyifeng.com/blog/2013/05/boyer-moore_string_search_algorithm.html) -- 【文章】[ bm 算法好后缀 java 实现 - 长笛小号的博客 - CSDN博客](https://blog.csdn.net/weixin_29217235/article/details/114488027) -- 【文章】[BM算法详解 - 简单爱_wxg - 博客园](https://www.cnblogs.com/wxgblogs/p/5701101.html) -- 【文章】[grep 之字符串搜索算法 Boyer-Moore 由浅入深 - Alexia(minmin) - 博客园](https://www.cnblogs.com/lanxuezaipiao/p/3452579.html) -- 【文章】[字符串匹配基础(中)- 数据结构与算法之美 - 极客时间](https://time.geekbang.org/column/article/71525) -- 【代码】[BM算法 附有解释 - 实现 strStr() - 力扣](https://leetcode.cn/problems/implement-strstr/solution/bmsuan-fa-fu-you-jie-shi-by-wen-198/) \ No newline at end of file diff --git a/docs/04_string/04_06_string_horspool.md b/docs/04_string/04_06_string_horspool.md new file mode 100644 index 00000000..40aec329 --- /dev/null +++ b/docs/04_string/04_06_string_horspool.md @@ -0,0 +1,166 @@ +## 1. Horspool 算法介绍 + +> **Horspool 算法**:由 Nigel Horspool 教授于 1980 年提出,是对 Boyer Moore 算法的简化版,用于在字符串中查找子串。 +> +> - **Horspool 算法核心思想**:先对模式串 $p$ 预处理,生成移动表。匹配时,从模式串末尾开始比较,遇到不匹配时,根据移动表跳过尽可能多的位置,加快查找速度。 + +Horspool 算法本质上继承了 Boyer-Moore 的思想,但只保留了「坏字符规则」并加以简化。当文本串 $T$ 某字符与模式串 $p$ 不匹配时,模式串可以根据以下两种情况快速右移: + +- **情况 1:$T[i + m - 1]$(文本串当前窗口的最后一个字符)在模式串 $p$ 中出现过** + - 将该字符在模式串中最后一次出现的位置与模式串末尾对齐。 + - **右移位数 = 模式串长度 - 1 - 该字符在模式串中最后一次出现的位置** + +![Horspool 算法情况 1](https://qcdn.itcharge.cn/images/20240511165106.png) + +- **情况 2:$T[i + m - 1]$ 没有在模式串 $p$ 中出现** + - 直接将模式串整体右移一整个长度。 + - **右移位数 = 模式串长度** + +![Horspool 算法情况 2](https://qcdn.itcharge.cn/images/20240511165122.png) + +## 2. Horspool 算法步骤 + +Horspool 算法流程如下: + +1. 设文本串 $T$ 长度为 $n$,模式串 $p$ 长度为 $m$。 +2. 预处理模式串 $p$,生成后移位数表 $bc\underline{\hspace{0.5em}}table$。 +3. 从文本串起始位置 $i = 0$ 开始,将模式串与文本串对齐,比较方式如下: + - 从模式串末尾 $j = m - 1$ 开始,依次向前比较 $T[i + j]$ 与 $p[j]$。 + - 如果全部字符匹配,返回 $i$,即匹配起始位置。 + - 如果遇到不匹配,查找 $T[i + m - 1]$ 在 $bc\underline{\hspace{0.5em}}table$ 中的值,右移相应距离(如果未出现则右移 $m$)。 +4. 如果遍历完文本串仍未找到匹配,返回 $-1$。 + +## 3. Horspool 算法代码实现 + +### 3.1 后移位数表代码实现 + +后移位数表的生成非常简单,类似于 Boyer-Moore 算法的坏字符表: + +- 用一个哈希表 $bc\underline{\hspace{0.5em}}table$,记录每个字符在模式串中可向右移动的距离。 +- 遍历模式串 $p$,对每个字符 $p[i]$,将 $m - 1 - i$ 作为其移动距离存入表中。如果字符重复,保留最右侧的距离。 + +匹配时,如果 $T[i + m - 1]$ 不在表中,则右移 $m$;如果在表中,则右移 $bc\underline{\hspace{0.5em}}table[T[i + m - 1]]$。 + +后移位数表代码如下: + +```python +# 生成后移位数表 +# bc_table[bad_char] 表示遇到坏字符时可以向右移动的距离 +def generateBadCharTable(p: str): + """ + 构建 Horspool 算法的后移位数表。 + 输入: + p: 模式串 + 输出: + bc_table: 字典,key 为字符,value 为遇到该字符时可向右移动的距离 + """ + m = len(p) + bc_table = dict() + # 只处理模式串的前 m - 1 个字符(最后一个字符不需要处理) + for i in range(m - 1): # i 从 0 到 m - 2 + # 对于每个字符 p[i],记录其对应的移动距离 + # 移动距离 = 模式串长度 - 1 - 当前字符下标 + bc_table[p[i]] = m - 1 - i + # 如果字符重复出现,保留最右侧(下标最大)的距离 + return bc_table +``` + +### 3.2 Horspool 算法整体代码实现 + +```python +# Horspool 算法实现,T 为文本串,p 为模式串 +def horspool(T: str, p: str) -> int: + """ + Horspool 字符串匹配算法。 + 返回模式串 p 在文本串 T 中首次出现的位置,若无则返回 -1。 + """ + n, m = len(T), len(p) + if m == 0: + return 0 if n == 0 else -1 # 约定:空模式串匹配空文本串返回 0,否则返回 -1 + if n < m: + return -1 # 模式串比文本串长,必不匹配 + + bc_table = generateBadCharTable(p) # 生成后移位数表 + + i = 0 + while i <= n - m: + j = m - 1 + # 从模式串末尾向前逐位比较 + while j >= 0 and T[i + j] == p[j]: + j -= 1 + if j < 0: + return i # 匹配成功,返回起始下标 + # 取文本串当前窗口最右字符,查表决定滑动距离 + shift_char = T[i + m - 1] + shift = bc_table.get(shift_char, m) # 如果未出现则右移 m 位 + i += shift + return -1 # 匹配失败,未找到 + +# 生成 Horspool 算法的后移位数表 +# bc_table[bad_char] 表示遇到坏字符 bad_char 时可以向右移动的距离 +def generateBadCharTable(p: str): + """ + 构建 Horspool 算法的后移位数表。 + 输入: + p: 模式串 + 输出: + bc_table: 字典,key 为字符,value 为遇到该字符时可向右移动的距离 + """ + m = len(p) + bc_table = dict() + # 只处理模式串的前 m - 1 个字符(最后一个字符不处理) + for i in range(m - 1): # i 从 0 到 m - 2 + # 对于每个字符 p[i],记录其对应的移动距离 + # 移动距离 = 模式串长度 - 1 - 当前字符下标 + bc_table[p[i]] = m - 1 - i + # 如果字符重复出现,保留最右侧(下标最大)的距离 + return bc_table + +# 测试用例 +print(horspool("abbcfdddbddcaddebc", "aaaaa")) # -1,未匹配 +print(horspool("abbcfdddbddcaddebc", "bcf")) # 2,匹配成功 +``` + +## 4. Horspool 算法分析 + +| 指标 | 复杂度 | 说明 | +| ------------ | ---------------- | ------------------------------------------------------------ | +| 最好时间复杂度 | $O(n)$ | 模式串字符分布均匀,坏字符表能实现最大跳跃,比较次数最少。 | +| 最坏时间复杂度 | $O(n \times m)$ | 模式串字符高度重复且与文本不匹配时,每次只能滑动一位。 | +| 平均时间复杂度 | $O(n)$ | 实际应用中通常接近最好情况,比较次数较少。 | +| 空间复杂度 | $O(m + \sigma)$ | 主要用于存储坏字符表,$m$ 为模式串长度,$\sigma$ 为字符集大小。 | + +- $n$ 为文本串长度,$m$ 为模式串长度,$\sigma$ 为字符集大小。 +- Horspool 算法在大多数实际场景下效率较高,但极端情况下可能退化为 $O(n \times m)$。 +- 空间消耗主要体现在坏字符表的构建上。 + +## 4. 总结 + +Horspool 算法是一种基于坏字符规则的高效字符串匹配算法,通过预处理模式串构建坏字符表,实现快速跳跃以提升匹配效率,适用于大多数实际场景。 + +**优点**: +- 实现简单,代码量少,易于理解。 +- 平均性能优良,适合大多数实际应用场景。 +- 只需构建坏字符表,预处理开销小。 + +**缺点**: +- 最坏情况下时间复杂度较高,可能退化为 $O(n \times m)$。 +- 只利用坏字符规则,跳跃能力不如 BM 算法。 +- 不适合极端重复或特殊构造的模式串。 + +## 练习题目 + +- [0028. 找出字符串中第一个匹配项的下标](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0001-0099/find-the-index-of-the-first-occurrence-in-a-string.md) +- [0459. 重复的子字符串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0400-0499/repeated-substring-pattern.md) +- [0686. 重复叠加字符串匹配](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0600-0699/repeated-string-match.md) +- [0796. 旋转字符串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0700-0799/rotate-string.md) +- [1408. 数组中的字符串匹配](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/1400-1499/string-matching-in-an-array.md) +- [2156. 查找给定哈希值的子串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/2100-2199/find-substring-with-given-hash-value.md) + +- [单模式串匹配题目列表](https://github.com/ITCharge/AlgoNote/tree/main/docs/00_preface/00_06_categories_list.md#%E5%8D%95%E6%A8%A1%E5%BC%8F%E4%B8%B2%E5%8C%B9%E9%85%8D%E9%A2%98%E7%9B%AE) + +## 参考资料 + +- 【书籍】柔性字符串匹配 - 中科院计算所网络信息安全研究组 译 +- 【博文】[字符串模式匹配算法:BM、Horspool、Sunday、KMP、KR、AC算法 - schips - 博客园](https://www.cnblogs.com/schips/p/11098041.html) + diff --git a/docs/04_string/04_07_string_horspool.md b/docs/04_string/04_07_string_horspool.md deleted file mode 100644 index 33554449..00000000 --- a/docs/04_string/04_07_string_horspool.md +++ /dev/null @@ -1,114 +0,0 @@ -## 1.1 Horspool 算法介绍 - -> **Horspool 算法**:是一种在字符串中查找子串的算法,它是由 Nigel Horspool 教授于 1980 年出版的,是首个对 Boyer Moore 算法进行简化的算法。 -> -> - **Horspool 算法思想**:对于给定文本串 $T$ 与模式串 $p$,先对模式串 $p$ 进行预处理。然后在匹配的过程中,当发现文本串 $T$ 的某个字符与模式串 $p$ 不匹配的时候,根据启发策略,能够尽可能的跳过一些无法匹配的情况,将模式串多向后滑动几位。 - -可以看出,Horspool 算法思想和 Boyer Moore 算法思想是一致的。Horspool 算法是在 Boyer Moore 算法思想基础上改进了「坏字符规则」。当文本串 $T$ 中某个字符跟模式串 $p$ 的某个字符不匹配时,可以模式串 $p$ 快速向右移动。 - -遇到不匹配字符时,可以根据以下两种情况向右快速进行移动: - -- **情况 1:文本串 $T$ 中与模式串 $p$ 尾部字符 $p[m - 1]$ 对应的字符 $T[i + m - 1]$ 出现在模式串 $p$ 中**。 - - 这种情况下,可将 $T[i + m - 1]$ 与模式串中最后一次出现的该字符对齐,如下图所示。 - - **向右移动位数 = 模式串最后一个字符的位置 - T[i + m - 1] 在模式串中最后一次出现的位置**。 - - 注意:模式串最后一个字符的位置其实就是「模式串长度 - 1」。 - -![Horspool 算法情况 1](https://qcdn.itcharge.cn/images/20240511165106.png) - -- **情况 2:文本串 $T$ 中与模式串 $p$ 尾部字符 $p[m - 1]$ 对应的字符 $T[i + m - 1]$ 没有出现在模式串 $p$ 中**。 - - 这种情况下,可将模式串整个右移,如下图所示。 - - **向右移动位数 = 整个模式串长度**。 - -![Horspool 算法情况 2](https://qcdn.itcharge.cn/images/20240511165122.png) - -## 2. Horspool 算法步骤 - -整个 Horspool 算法步骤描述如下: - -1. 计算出文本串 $T$ 的长度为 $n$,模式串 $p$ 的长度为 $m$。 -2. 先对模式串 $p$ 进行预处理,生成后移位数表 $bc\underline{\hspace{0.5em}}table$。 -3. 将模式串 $p$ 的头部与文本串 $T$ 对齐,将 $i$ 指向文本串开始位置,即 $i = 0$。$j$ 指向模式串末尾位置,即 $j = m - 1$,然后从模式串末尾位置开始比较。 - 1. 如果文本串对应位置的字符 $T[i + j]$ 与模式串对应字符 $p[j]$ 相同,则继续比较前一位字符。 - 1. 如果模式串全部匹配完毕,则返回模式串 $p$ 在文本串中的开始位置 $i$。 - 2. 如果文本串对应位置的字符 $T[i + j]$ 与模式串对应字符 $p[j]$ 不同,则: - 1. 根据后移位数表 $bc\underline{\hspace{0.5em}}table$ 和模式串末尾位置对应的文本串上的字符 $T[i + m - 1]$ ,计算出可移动距离 $bc\underline{\hspace{0.5em}}table[T[i + m - 1]]$,然后将模式串进行后移。 -4. 如果移动到末尾也没有找到匹配情况,则返回 $-1$。 - -## 3. Horspool 算法代码实现 - -### 3.1 后移位数表代码实现 - -生成后移位数表的代码实现比较简单,跟 Boyer Moore 算法中生成坏字符位置表的代码差不多。具体步骤如下: - -- 使用一个哈希表 $bc\underline{\hspace{0.5em}}table$, $bc\underline{\hspace{0.5em}}table[bad\underline{\hspace{0.5em}}char]$ 表示表示遇到坏字符可以向右移动的距离。 -- 遍历模式串,以当前字符 $p[i]$ 为键,可以向右移动的距离($m - 1 - i$)为值存入字典中。如果出现重复字符,则新的位置下标值会将之前存放的值覆盖掉。这样哈希表中存放的就是该字符在模式串中出现最右侧位置上的可向右移动的距离。 - -如果在 Horspool 算法的匹配过程中,如果 $T[i + m - 1]$ 不在 $bc\underline{\hspace{0.5em}}table$ 中时,可令其为 $m$,表示可以将模式串整个右移。如果 $T[i + m - 1]$ 在 $bc\underline{\hspace{0.5em}}table$ 中时,可移动距离就是 $bc\underline{\hspace{0.5em}}table[T[i + m - 1]]$ 。这样就能计算出可以向右移动的位数了。 - -生成后移位数表的代码如下: - -```python -# 生成后移位数表 -# bc_table[bad_char] 表示遇到坏字符可以向右移动的距离 -def generateBadCharTable(p: str): - m = len(p) - bc_table = dict() - - for i in range(m - 1): # 迭代到 m - 2 - bc_table[p[i]] = m - 1 - i # 更新遇到坏字符可向右移动的距离 - return bc_table -``` - -### 3.2 Horspool 算法整体代码实现 - -```python -# horspool 算法,T 为文本串,p 为模式串 -def horspool(T: str, p: str) -> int: - n, m = len(T), len(p) - - bc_table = generateBadCharTable(p) # 生成后移位数表 - - i = 0 - while i <= n - m: - j = m - 1 - while j > -1 and T[i + j] == p[j]: # 进行后缀匹配,跳出循环说明出现坏字符 - j -= 1 - if j < 0: - return i # 匹配完成,返回模式串 p 在文本串 T 中的位置 - i += bc_table.get(T[i + m - 1], m) # 通过后移位数表,向右进行进行快速移动 - return -1 # 匹配失败 - -# 生成后移位数表 -# bc_table[bad_char] 表示遇到坏字符可以向右移动的距离 -def generateBadCharTable(p: str): - m = len(p) - bc_table = dict() - - for i in range(m - 1): # 迭代到 m - 2 - bc_table[p[i]] = m - 1 - i # 更新遇到坏字符可向右移动的距离 - return bc_table - -print(horspool("abbcfdddbddcaddebc", "aaaaa")) -print(horspool("abbcfdddbddcaddebc", "bcf")) -``` - -## 4. Horspool 算法分析 - -- Horspool 算法在平均情况下的时间复杂度为 $O(n)$,但是在最坏情况下时间复杂度会退化为 $O(n * m)$。 - -## 练习题目 - -- [0028. 找出字符串中第一个匹配项的下标](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0001-0099/find-the-index-of-the-first-occurrence-in-a-string.md) -- [0459. 重复的子字符串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0400-0499/repeated-substring-pattern.md) -- [0686. 重复叠加字符串匹配](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0600-0699/repeated-string-match.md) -- [0796. 旋转字符串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0700-0799/rotate-string.md) -- [1408. 数组中的字符串匹配](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/1400-1499/string-matching-in-an-array.md) -- [2156. 查找给定哈希值的子串](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/2100-2199/find-substring-with-given-hash-value.md) - -- [单模式串匹配题目列表](https://github.com/ITCharge/AlgoNote/tree/main/docs/00_preface/00_06_categories_list.md#%E5%8D%95%E6%A8%A1%E5%BC%8F%E4%B8%B2%E5%8C%B9%E9%85%8D%E9%A2%98%E7%9B%AE) - -## 参考资料 - -- 【书籍】柔性字符串匹配 - 中科院计算所网络信息安全研究组 译 -- 【博文】[字符串模式匹配算法:BM、Horspool、Sunday、KMP、KR、AC算法 - schips - 博客园](https://www.cnblogs.com/schips/p/11098041.html) - diff --git a/docs/04_string/04_08_string_sunday.md b/docs/04_string/04_07_string_sunday.md similarity index 100% rename from docs/04_string/04_08_string_sunday.md rename to docs/04_string/04_07_string_sunday.md diff --git a/docs/04_string/04_10_trie.md b/docs/04_string/04_08_trie.md similarity index 100% rename from docs/04_string/04_10_trie.md rename to docs/04_string/04_08_trie.md diff --git a/docs/04_string/04_11_ac_automaton.md b/docs/04_string/04_09_ac_automaton.md similarity index 100% rename from docs/04_string/04_11_ac_automaton.md rename to docs/04_string/04_09_ac_automaton.md diff --git a/docs/04_string/04_09_string_multi_pattern_matching.md b/docs/04_string/04_09_string_multi_pattern_matching.md deleted file mode 100644 index 943a9cf6..00000000 --- a/docs/04_string/04_09_string_multi_pattern_matching.md +++ /dev/null @@ -1,62 +0,0 @@ -# 多模式串匹配 - -## 1. 多模式串匹配的定义 - -多模式串匹配(Multiple Pattern Matching)是指在文本串中同时查找多个模式串的问题。与单模式串匹配(如 KMP 算法)不同,多模式串匹配需要在一个文本串中同时查找多个模式串的所有出现位置。 - -## 2. 主要算法介绍 - -### 2.1 字典树(Trie) - -字典树是一种树形数据结构,用于高效地存储和检索字符串集合。它的主要特点是: - -- 每个节点代表一个字符 -- 从根节点到某个节点的路径上的字符连接起来,就是该节点对应的字符串 -- 每个节点的所有子节点包含的字符都不相同 - -字典树的主要应用: -- 字符串检索 -- 词频统计 -- 字符串排序 -- 前缀匹配 - -### 2.2 AC 自动机(Aho-Corasick) - -AC 自动机是在字典树的基础上,结合了 KMP 算法的思想,用于多模式串匹配的算法。它的主要特点是: - -- 基于字典树构建 -- 添加了失败指针(fail pointer),类似于 KMP 的 next 数组 -- 可以同时匹配多个模式串 -- 时间复杂度为 O(n + k),其中 n 是文本串长度,k 是所有模式串的总长度 - -AC 自动机的主要应用: -- 敏感词过滤 -- 病毒特征码匹配 -- 文本分析 - -### 2.3 后缀数组(Suffix Array) - -后缀数组是一种数据结构,用于高效地处理字符串的后缀。它的主要特点是: - -- 将字符串的所有后缀按字典序排序 -- 可以快速查找子串 -- 支持最长公共前缀(LCP)查询 - -后缀数组的主要应用: -- 字符串匹配 -- 最长重复子串 -- 最长公共子串 -- 字符串压缩 - -## 3. 算法比较 - -| 算法 | 预处理时间复杂度 | 匹配时间复杂度 | 空间复杂度 | 适用场景 | -|------|----------------|--------------|------------|----------| -| 字典树 | O(k) | O(m) | O(k) | 前缀匹配、字符串集合操作 | -| AC 自动机 | O(k) | O(n + k) | O(k) | 多模式串精确匹配 | -| 后缀数组 | O(n log n) | O(m + log n) | O(n) | 子串匹配、后缀操作 | - -其中: -- n 为文本串长度 -- m 为模式串长度 -- k 为所有模式串的总长度 diff --git a/docs/04_string/04_12_suffix_array.md b/docs/04_string/04_10_suffix_array.md similarity index 100% rename from docs/04_string/04_12_suffix_array.md rename to docs/04_string/04_10_suffix_array.md diff --git a/docs/04_string/index.md b/docs/04_string/index.md index 831e5155..cadd9936 100644 --- a/docs/04_string/index.md +++ b/docs/04_string/index.md @@ -1,14 +1,12 @@ ## 本章内容 - [4.1 字符串基础](https://github.com/ITCharge/AlgoNote/tree/main/docs/04_string/04_01_string_basic.md) -- [4.2 单模式串匹配](https://github.com/ITCharge/AlgoNote/tree/main/docs/04_string/04_02_string_single_pattern_matching.md) -- [4.3 Brute Force 算法](https://github.com/ITCharge/AlgoNote/tree/main/docs/04_string/04_03_string_brute_force.md) -- [4.4 Rabin Karp 算法](https://github.com/ITCharge/AlgoNote/tree/main/docs/04_string/04_04_string_rabin_karp.md) -- [4.5 KMP 算法](https://github.com/ITCharge/AlgoNote/tree/main/docs/04_string/04_05_string_kmp.md) -- [4.6 Boyer Moore 算法](https://github.com/ITCharge/AlgoNote/tree/main/docs/04_string/04_06_string_boyer_moore.md) -- [4.7 Horspool 算法](https://github.com/ITCharge/AlgoNote/tree/main/docs/04_string/04_07_string_horspool.md) -- [4.8 Sunday 算法](https://github.com/ITCharge/AlgoNote/tree/main/docs/04_string/04_08_string_sunday.md) -- [4.9 多模式串匹配](https://github.com/ITCharge/AlgoNote/tree/main/docs/04_string/04_09_string_multi_pattern_matching.md) -- [4.10 字典树](https://github.com/ITCharge/AlgoNote/tree/main/docs/04_string/04_10_trie.md) -- [4.11 AC 自动机](https://github.com/ITCharge/AlgoNote/tree/main/docs/04_string/04_11_ac_automaton.md) -- [4.12 后缀数组](https://github.com/ITCharge/AlgoNote/tree/main/docs/04_string/04_12_suffix_array.md) +- [4.2 Brute Force 算法](https://github.com/ITCharge/AlgoNote/tree/main/docs/04_string/04_02_string_brute_force.md) +- [4.3 Rabin Karp 算法](https://github.com/ITCharge/AlgoNote/tree/main/docs/04_string/04_03_string_rabin_karp.md) +- [4.4 KMP 算法](https://github.com/ITCharge/AlgoNote/tree/main/docs/04_string/04_04_string_kmp.md) +- [4.5 Boyer Moore 算法](https://github.com/ITCharge/AlgoNote/tree/main/docs/04_string/04_05_string_boyer_moore.md) +- [4.6 Horspool 算法](https://github.com/ITCharge/AlgoNote/tree/main/docs/04_string/04_06_string_horspool.md) +- [4.7 Sunday 算法](https://github.com/ITCharge/AlgoNote/tree/main/docs/04_string/04_07_string_sunday.md) +- [4.8 字典树](https://github.com/ITCharge/AlgoNote/tree/main/docs/04_string/04_08_trie.md) +- [4.9 AC 自动机](https://github.com/ITCharge/AlgoNote/tree/main/docs/04_string/04_09_ac_automaton.md) +- [4.10 后缀数组](https://github.com/ITCharge/AlgoNote/tree/main/docs/04_string/04_10_suffix_array.md) From f2cee4e903ee0ed77f3c18076df2d3f79bd49441 Mon Sep 17 00:00:00 2001 From: ITCharge Date: Mon, 1 Sep 2025 18:00:04 +0800 Subject: [PATCH 08/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=92=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=AF=AD=E5=8F=A5=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/04_string/04_07_string_sunday.md | 163 ++++++++++++++++++-------- 1 file changed, 113 insertions(+), 50 deletions(-) diff --git a/docs/04_string/04_07_string_sunday.md b/docs/04_string/04_07_string_sunday.md index 0bb79d8d..19c51285 100644 --- a/docs/04_string/04_07_string_sunday.md +++ b/docs/04_string/04_07_string_sunday.md @@ -1,100 +1,163 @@ ## 1. Sunday 算法介绍 -**「Sunday 算法」** 是一种在字符串中查找子串的算法,是 Daniel M.Sunday 于1990年提出的字符串模式匹配算法。 +> **Sunday 算法**:Sunday 算法是一种高效的字符串查找算法,由 Daniel M. Sunday 于 1990 年提出,专门用于在主串中查找子串的位置。 +> +> - **核心思想**:对于给定的文本串 $T$ 和模式串 $p$,Sunday 算法首先对模式串 $p$ 进行预处理,生成一个「后移位数表」。在匹配过程中,每当发现不匹配时,算法会根据文本串中参与本轮匹配的末尾字符的「下一个字符」,决定模式串应向右滑动的距离,从而尽可能跳过无效的比较,加快匹配速度。 -> **Sunday 算法思想**:对于给定文本串 $T$ 与模式串 $p$,先对模式串 $p$ 进行预处理。然后在匹配的过程中,当发现文本串 $T$ 的某个字符与模式串 $p$ 不匹配的时候,根据启发策略,能够尽可能的跳过一些无法匹配的情况,将模式串多向后滑动几位。 +Sunday 算法的思想与 Boyer-Moore 算法类似,但 Sunday 算法始终从左到右进行匹配。当匹配失败时,Sunday 算法关注的是文本串 $T$ 当前匹配窗口末尾的下一个字符 $T[i + m]$,并据此决定模式串的滑动距离,实现快速跳跃。 -Sunday 算法思想跟 Boyer Moore 算法思想类似。不同的是,Sunday 算法匹配顺序是从左向右,并且在模式串 $p$ 匹配失败时关注的是文本串 $T$ 中参加匹配的末尾字符的下一位字符。当文本串 $T$ 中某个字符跟模式串 $p$ 的某个字符不匹配时,可以将模式串 $p$ 快速向右移动。 +具体来说,遇到不匹配时有两种情况: -遇到不匹配字符时,可以根据以下两种情况向右快速进行移动: - -- **情况 1:文本串 $T$ 中与模式串 $p$ 尾部字符 $p[m - 1]$ 对应的字符下一个位置的字符 $T[i + m]$ 出现在模式串 $p$ 中**。 - - 这种情况下,可将$T[i + m]$ 与模式串中最后一次出现的该字符对齐,如下图所示。 - - **向右移动位数 = 文本串 $T$ 中与模式串 $p$ 尾部位置的下一个位置 $T[i + m]$ 在模式串中最后一次出现的位置**。 - - 注意:文本串 $T$ 中与模式串 $p$ 尾部位置的下一个位置其实就是「模式串长度」。 +- **情况 1:$T[i + m]$ 出现在模式串 $p$ 中** + - 此时,将模式串 $p$ 向右移动,使其最后一次出现 $T[i + m]$ 的位置与 $T[i + m]$ 对齐。 + - **向右移动的位数 = 模式串中 $T[i + m]$ 最右侧出现的位置到末尾的距离** + - 说明:$T[i + m]$ 即为当前匹配窗口末尾的下一个字符。 ![Sunday 算法情况 1](https://qcdn.itcharge.cn/images/20240511165526.png) -- **情况 2:文本串 $T$ 中与模式串 $p$ 尾部字符 $p[m - 1]$ 对应的字符下一个位置的字符 $T[i + m]$ 没有出现在模式串 $p$ 中**。 - - 这种情况下,可将模式串整个右移,如下图所示。 - - **向右移动位数 = 整个模式串长度 + 1**。 +- **情况 2:$T[i + m]$ 未出现在模式串 $p$ 中** + - 此时,直接将模式串整体向右移动 $m + 1$ 位。 + - **向右移动的位数 = 模式串长度 $m + 1$** ![Sunday 算法情况 2](https://qcdn.itcharge.cn/images/20240511165540.png) ## 2. Sunday 算法步骤 -整个 Horspool 算法步骤描述如下: +Sunday 算法的具体流程如下: -- 计算出文本串 $T$ 的长度为 $n$,模式串 $p$ 的长度为 $m$。 -- 先对模式串 $p$ 进行预处理,生成后移位数表 $bc\underline{\hspace{0.5em}}table$。 -- 将模式串 $p$ 的头部与文本串 $T$ 对齐,将 $i$ 指向文本串开始位置,即 $i = 0$。$j$ 指向模式串开始,即 $j = 0$,然后从模式串开始位置开始比较。 - - 如果文本串对应位置的字符 $T[i + j]$ 与模式串对应字符 $p[j]$ 相同,则继续比较后一位字符。 - - 如果模式串全部匹配完毕,则返回模式串 $p$ 在文本串中的开始位置 $i$。 - - 如果文本串对应位置的字符 $T[i + j]$ 与模式串对应字符 $p[j]$ 不同,则: - - 根据后移位数表 $bc\underline{\hspace{0.5em}}table$ 和模式串末尾位置对应的文本串上的字符 $T[i + m]$ ,计算出可移动距离 $bc\underline{\hspace{0.5em}}table[T[i + m]]$,然后将模式串进行后移。 -- 如果移动到末尾也没有找到匹配情况,则返回 $-1$。 +- 设文本串 $T$ 长度为 $n$,模式串 $p$ 长度为 $m$。 +- 首先对模式串 $p$ 进行预处理,生成后移位数表 $bc\underline{\hspace{0.5em}}table$。 +- 令 $i = 0$,表示当前模式串 $p$ 的起始位置与文本串 $T$ 的第 $i$ 位对齐。 +- 在每一轮匹配中,从头开始比较 $T[i + j]$ 与 $p[j]$($j$ 从 $0$ 到 $m-1$): + - 如果所有字符均匹配,则返回当前匹配的起始位置 $i$。 + - 如果出现不匹配,或未全部匹配完毕,则: + - 检查 $T[i + m]$(即当前匹配窗口末尾的下一个字符): + - 如果 $T[i + m]$ 存在于后移位数表中,则将 $i$ 增加 $bc\underline{\hspace{0.5em}}table[T[i + m]]$,即将模式串向右滑动相应距离。 + - 如果 $T[i + m]$ 不存在于后移位数表中,则将 $i$ 增加 $m + 1$,即整体右移 $m + 1$ 位。 +- 若遍历完整个文本串仍未找到匹配,则返回 $-1$。 ## 3. Sunday 算法代码实现 ### 3.1 后移位数表代码实现 -生成后移位数表的代码实现比较简单,跟 Horspool 算法中生成后移位数表的代码差不多。具体步骤如下: +后移位数表的实现非常简洁,与 Horspool 算法类似。具体思路如下: -- 使用一个哈希表 $bc\underline{\hspace{0.5em}}table$, $bc\underline{\hspace{0.5em}}table[bad\underline{\hspace{0.5em}}char]$ 表示表示遇到坏字符可以向右移动的距离。 -- 遍历模式串,以当前字符 $p[i]$ 为键,可以向右移动的距离($m - i$)为值存入字典中。如果出现重复字符,则新的位置下标值会将之前存放的值覆盖掉。这样哈希表中存放的就是该字符在模式串中出现最右侧位置上的可向右移动的距离。 +- 使用一个哈希表 $bc\underline{\hspace{0.5em}}table$,其中 $bc\underline{\hspace{0.5em}}table[bad\underline{\hspace{0.5em}}char]$ 表示遇到该字符时,模式串可以向右移动的距离。 +- 遍历模式串 $p$,将每个字符 $p[i]$ 作为键,其对应的移动距离 $m - i$ 作为值存入字典。如果字符重复出现,则以最右侧(下标最大的)位置为准,覆盖之前的值。这样,哈希表中存储的就是每个字符在模式串中最右侧出现时可向右移动的距离。 -如果在 Sunday 算法的匹配过程中,如果 $T[i + m]$ 不在 $bc\underline{\hspace{0.5em}}table$ 中时,可令其为 $m + 1$,表示可以将模式串整个右移到上一次匹配末尾后边两个位置上。如果 $T[i + m]$ 在 $bc\underline{\hspace{0.5em}}table$ 中时,可移动距离就是 $bc\underline{\hspace{0.5em}}table[T[i + m]]$ 。这样就能计算出可以向右移动的位数了。 +在 Sunday 算法匹配过程中,如果 $T[i + m]$ 不在 $bc\underline{\hspace{0.5em}}table$ 中,则默认移动 $m + 1$ 位,即将模式串整体右移到当前匹配窗口末尾的下一个字符之后。如果 $T[i + m]$ 存在于表中,则移动距离为 $bc\underline{\hspace{0.5em}}table[T[i + m]]$。这样即可高效计算每次滑动的步长。 -生成后移位数表的代码如下: +后移位数表的代码如下: ```python -# 生成后移位数表 -# bc_table[bad_char] 表示遇到坏字符可以向右移动的距离 +# 生成 Sunday 算法的后移位数表 +# bc_table[bad_char] 表示遇到坏字符 bad_char 时,模式串可以向右移动的距离 def generateBadCharTable(p: str): + """ + 构建 Sunday 算法的后移位数表。 + 输入: + p: 模式串 + 输出: + bc_table: 字典,key 为字符,value 为遇到该字符时可向右移动的距离 + """ m = len(p) bc_table = dict() - - for i in range(m): # 迭代到最后一个位置 m - 1 - bc_table[p[i]] = m - i # 更新遇到坏字符可向右移动的距离 + # 遍历模式串的每一个字符(包括最后一个字符) + for i in range(m): + # 对于每个字符 p[i],记录其对应的移动距离 + # 移动距离 = 模式串长度 - 当前字符下标 + bc_table[p[i]] = m - i + # 如果字符重复出现,保留最右侧(下标最大)的距离 return bc_table ``` ### 3.2 Sunday 算法整体代码实现 ```python -# sunday 算法,T 为文本串,p 为模式串 +# Sunday 算法实现,T 为文本串,p 为模式串 def sunday(T: str, p: str) -> int: + """ + Sunday 算法主函数,返回模式串 p 在文本串 T 中首次出现的位置,若未匹配则返回 -1。 + 参数: + T: 文本串 + p: 模式串 + 返回: + int: 第一个匹配位置的下标,未匹配返回 -1 + """ n, m = len(T), len(p) - - bc_table = generateBadCharTable(p) # 生成后移位数表 - - i = 0 + if m == 0: + return 0 # 空模式串视为匹配在开头 + + bc_table = generateBadCharTable(p) # 生成后移位数表 + + i = 0 # i 表示当前窗口在文本串中的起始下标 while i <= n - m: + # 逐字符比较当前窗口是否与模式串完全匹配 j = 0 - if T[i: i + m] == p: - return i # 匹配完成,返回模式串 p 在文本串 T 的位置 + while j < m and T[i + j] == p[j]: + j += 1 + if j == m: + return i # 匹配成功,返回起始下标 + # 检查窗口末尾的下一个字符,决定滑动距离 if i + m >= n: - return -1 - i += bc_table.get(T[i + m], m + 1) # 通过后移位数表,向右进行进行快速移动 - return -1 # 匹配失败 - -# 生成后移位数表 -# bc_table[bad_char] 表示遇到坏字符可以向右移动的距离 + return -1 # 已到文本串末尾,未匹配 + next_char = T[i + m] # 当前窗口末尾的下一个字符 + # 若 next_char 在后移位数表中,滑动对应距离,否则滑动 m+1 + shift = bc_table.get(next_char, m + 1) + i += shift + return -1 # 未找到匹配 + +# 生成 Sunday 算法的后移位数表 +# bc_table[bad_char] 表示遇到坏字符 bad_char 时,模式串可以向右移动的距离 def generateBadCharTable(p: str): + """ + 构建 Sunday 算法的后移位数表。 + 参数: + p: 模式串 + 返回: + dict: 字典,key 为字符,value 为遇到该字符时可向右移动的距离 + """ m = len(p) bc_table = dict() - - for i in range(m): # 迭代到最后一个位置 m - 1 - bc_table[p[i]] = m - i # 更新遇到坏字符可向右移动的距离 + # 遍历模式串每个字符(包括最后一个字符) + for i in range(m): + # 记录每个字符在模式串中最右侧出现时可向右移动的距离 + bc_table[p[i]] = m - i return bc_table -print(sunday("abbcfdddbddcaddebc", "aaaaa")) -print(sunday("abbcfdddbddcaddebc", "bcf")) +# 测试用例 +print(sunday("abbcfdddbddcaddebc", "aaaaa")) # 输出: -1,未匹配 +print(sunday("abbcfdddbddcaddebc", "bcf")) # 输出: 2,匹配成功 ``` ## 4. Sunday 算法分析 -- Sunday 算法在平均情况下的时间复杂度为 $O(n)$,但是在最坏情况下时间复杂度会退化为 $O(n * m)$。 +| 指标 | 复杂度 | 说明 | +| ------------ | ---------------- | ------------------------------------------------------------ | +| 最好时间复杂度 | $O(n)$ | 模式串字符分布均匀,后移位数表能实现最大跳跃,比较次数最少。 | +| 最坏时间复杂度 | $O(n \times m)$ | 模式串字符高度重复且与文本不匹配时,每次只能滑动一位。 | +| 平均时间复杂度 | $O(n)$ | 实际应用中通常接近最好情况,比较次数较少。 | +| 空间复杂度 | $O(m + \sigma)$ | 主要用于存储后移位数表,$m$ 为模式串长度,$\sigma$ 为字符集大小。 | + +- $n$ 为文本串长度,$m$ 为模式串长度,$\sigma$ 为字符集大小。 +- Sunday 算法在大多数实际场景下效率较高,但极端情况下可能退化为 $O(n \times m)$。 +- 空间消耗主要体现在后移位数表的构建上。 + +## 5. 总结 + +Sunday 算法是一种高效的字符串匹配算法,通过利用窗口末尾字符的后移位数表,实现大步跳跃式匹配,提升了实际查找效率,适用于大多数文本搜索场景。 + +**优点**: +- 实现简单,易于理解和编码。 +- 平均性能优良,实际应用中匹配效率高。 +- 只需构建一次后移位数表,预处理开销小。 +- 跳跃能力强,适合大多数实际文本搜索场景。 + +**缺点**: +- 最坏情况下时间复杂度较高,可能退化为 $O(n \times m)$。 +- 只利用窗口末尾字符的信息,未充分利用更多启发式规则(如 BM 算法的好后缀规则)。 +- 对极端重复或特殊构造的模式串不够友好,跳跃能力有限。 + ## 参考资料 From 5ffbe8386222082e7eb7783363bacb208b0d8904 Mon Sep 17 00:00:00 2001 From: ITCharge Date: Mon, 1 Sep 2025 22:31:39 +0800 Subject: [PATCH 09/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=92=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=AF=AD=E5=8F=A5=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/04_string/04_09_ac_automaton.md | 259 +++++++++++++++++++-------- 1 file changed, 189 insertions(+), 70 deletions(-) diff --git a/docs/04_string/04_09_ac_automaton.md b/docs/04_string/04_09_ac_automaton.md index fd2d3550..0032d600 100644 --- a/docs/04_string/04_09_ac_automaton.md +++ b/docs/04_string/04_09_ac_automaton.md @@ -1,26 +1,45 @@ ## 1. AC 自动机简介 -> **AC 自动机(Aho-Corasick Automaton)**:该算法在 1975 年产生于贝尔实验室,是最著名的多模式匹配算法之一。简单来说,AC 自动机是以 **字典树(Trie)** 的结构为基础,结合 **KMP 算法思想** 建立的。 +> **AC 自动机(Aho-Corasick Automaton)**:由 Alfred V. Aho 和 Margaret J. Corasick 于 1975 年在贝尔实验室提出,是最著名的多模式匹配算法之一。 +> +> - **AC 自动机核心思想**:以 **字典树(Trie)** 为基础,结合 **KMP 算法的失配指针思想**,构建一个能够同时匹配多个模式串的有限状态自动机。当在文本串中匹配失败时,通过失配指针快速跳转到下一个可能匹配的状态,避免重复比较,实现高效的多模式匹配。 + +### 1.1 多模式匹配的难点 + +在实际应用中,常常需要在文本中一次性查找多个模式串(如敏感词过滤、病毒检测、DNA 序列分析等)。 + +传统的单模式匹配算法(如 KMP、Boyer-Moore)需要对每个模式串分别进行匹配,时间复杂度为 $O(n \times m \times k)$,其中 $n$ 为文本长度,$m$ 为模式串平均长度,$k$ 为模式串数量,整体效率较低。 + +如果只使用字典树(Trie),虽然能够共享前缀,但每次匹配失败都必须回到根节点重新开始,无法实现高效跳转,最坏情况下复杂度也接近 $O(n \times m)$。 + + -AC 自动机的构造有 3 个步骤: -1. 构造一棵字典树(Trie),作为 AC 自动机的搜索数据结构。 -2. 利用 KMP 算法思想,构造失配指针。使得当前字符失配时可以通过失配指针跳转到具有最长公共前后缀的字符位置上继续匹配。 -3. 扫描文本串进行匹配。 +### 1.2 AC 自动机高效匹配原理 + +AC 自动机能够高效解决多模式匹配问题,其核心思想是:将所有模式串构建为一棵字典树(Trie),并为每个节点设置失配指针(fail 指针),结合 KMP 算法的失配机制,实现对文本串的一次扫描即可同时匹配多个模式串。 + +AC 自动机的主要流程如下: + +1. **构建字典树(Trie)**:将所有模式串插入字典树,充分利用公共前缀,节省空间和比较次数。 +2. **构建失配指针(fail 指针)**:借鉴 KMP 算法思想,为字典树中每个节点添加失配指针。失配指针指向当前节点对应字符串的最长可用后缀节点,实现匹配失败时的快速跳转,避免重复比较。 +3. **一次扫描文本串进行匹配**:只需从头到尾扫描一遍文本串,利用字典树和失配指针的协同作用,即可高效找到所有模式串的出现位置。 + +AC 自动机的时间复杂度为 $O(n + m + k)$,其中 $n$ 为文本串长度,$m$ 为所有模式串的总长度,$k$ 为匹配到的模式串数量。相比传统的多模式串逐一匹配方法(如 $O(n \times m \times k)$),AC 自动机大幅提升了匹配效率。 ## 2. AC 自动机原理 -接下来我们以一个例子来说明一下 AC 自动机的原理。 +下面用一个简单例子来直观理解 AC 自动机的原理。 -> 描述:给定 5 个单词,分别是 `say`、`she`、`shr`、`he`、`her`,再给定一个文本串 `yasherhs`。 +> **例子**:给定 5 个模式串:`say`、`she`、`shr`、`he`、`her`,文本串为 `yasherhs`。 > -> 要求:计算出有多少个单词在文本串中出现过。 +> **目标**:找出文本中所有出现的模式串及其位置。 -### 2.1 构造一棵字典树(Trie) +### 2.1 构建字典树(Trie) -首先我们需要建立一棵字典树。字典树是一种树形数据结构,用于高效地存储和检索字符串集合。每个节点代表一个字符,从根节点到某个节点的路径上的字符连接起来,就是该节点对应的字符串。 +我们先把所有模式串插入到一棵字典树中。字典树就像一棵「分叉的路」,每个节点代表一个字符,从根到某节点的路径,就是一个字符串。 -对于给定的 5 个单词,构造的字典树如下: +以这 5 个模式串为例,字典树结构如下: ``` root @@ -34,103 +53,203 @@ AC 自动机的构造有 3 个步骤: ### 2.2 构造失配指针 -失配指针(fail pointer)是 AC 自动机的核心。当在字典树中匹配失败时,失配指针指向另一个节点,该节点对应的字符串是当前节点对应字符串的最长后缀。 +失配指针(fail 指针)是 AC 自动机的关键。它借鉴 KMP 算法的思想,为每个节点指向其「最长可用后缀」在字典树中的节点,实现失配时的快速跳转。 + +#### 2.2.1 失配指针的定义 + +对于字典树中的任意节点,其失配指针指向该节点对应字符串的 **最长真后缀** 在字典树中的节点。 -失配指针的构造过程: -1. 根节点的失配指针指向空 -2. 对于每个节点,其失配指针指向其父节点的失配指针指向的节点的对应子节点 -3. 如果对应子节点不存在,则继续沿着失配指针向上查找 +- **真后缀**:字符串的真后缀是指该字符串的后缀,但不等于字符串本身。 -### 2.3 扫描文本串 +#### 2.2.2 构造规则 -扫描文本串的过程: -1. 从根节点开始,按照文本串的字符顺序在字典树中移动 -2. 如果当前字符匹配成功,继续移动到下一个字符 -3. 如果当前字符匹配失败,通过失配指针跳转到另一个节点继续匹配 -4. 当到达某个单词的结束节点时,说明找到了一个匹配的单词 +失配指针的构造遵循以下规则: -## 3. AC 自动机的应用 +1. **根节点**:失配指针为 `null` +2. **根节点的子节点**:失配指针都指向根节点 +3. **其他节点**:从父节点的失配指针开始查找,如果找到对应字符的子节点,则指向该子节点;否则继续向上查找,直到找到或到达根节点 + +#### 2.2.3 构造示例 + +以模式串 `["say", "she", "shr", "he", "her"]` 为例: + +``` + root + / \ + s h + / \ | + a h e + / / \ \ + y e r r +``` -AC 自动机在以下场景中有着广泛的应用: +**失配指针构造过程**: +- `s` → `root`(根节点子节点指向根节点) +- `h` → `root`(根节点子节点指向根节点) +- `sa` → `root`(根节点没有 `a` 子节点) +- `sh` → `h`(根节点有 `h` 子节点) +- `he` → `e`(根节点有 `e` 子节点) +- `hr` → `root`(根节点没有 `r` 子节点) +- `say` → `root`(根节点没有 `y` 子节点) +- `she` → `he`(`h` 节点有 `e` 子节点) +- `shr` → `root`(`h` 和根节点都没有 `r` 子节点) +- `her` → `root`(`e` 和根节点都没有 `r` 子节点) -1. **多模式字符串匹配**:在文本中查找多个模式串 -2. **敏感词过滤**:检测文本中是否包含敏感词 -3. **DNA序列分析**:在生物信息学中用于DNA序列的模式匹配 -4. **网络入侵检测**:检测网络数据包中的恶意模式 -5. **拼写检查**:检查文本中的拼写错误 +#### 2.2.4 失配指针的作用 -## 4. AC 自动机的实现 +失配指针的主要作用是: +1. **快速跳转**:匹配失败时,不需要回到根节点重新开始 +2. **避免重复比较**:利用已匹配的部分信息,避免重复比较 +3. **保证匹配连续性**:确保跳转后当前匹配的字符串仍是某个模式串的前缀 -### 4.1 时间复杂度 +### 2.3 文本串匹配过程 -- 构建字典树:O(Σ|P|),其中 P 是所有模式串的集合 -- 构建失配指针:O(Σ|P|) -- 文本串匹配:O(n + k),其中 n 是文本串长度,k 是匹配的模式串数量 +有了字典树和失配指针,我们就可以进行高效的文本串匹配了。 -### 4.2 空间复杂度 +#### 2.3.1 匹配算法流程 -- O(Σ|P|),其中 Σ 是字符集大小 +1. **初始化**:从根节点开始 +2. **字符匹配**:对于文本串中的每个字符: + - 如果当前节点有对应字符的子节点,移动到该子节点 + - 否则,沿着失配指针向上查找,直到找到匹配的子节点或到达根节点 +3. **模式串检测**:每到达一个节点,检查该节点是否为某个模式串的结尾 +4. **输出匹配结果**:如果找到匹配的模式串,记录其位置和内容 + +#### 2.3.2 匹配过程示例 + +以文本串 `yasherhs` 为例,演示匹配过程: + +| 字符 | 当前节点 | 操作 | 当前路径 | 匹配结果 | +|------|----------|------|----------|----------| +| `y` | 根节点 | 无匹配,保持根节点 | - | - | +| `a` | 根节点 | 无匹配,保持根节点 | - | - | +| `s` | 根节点 | 移动到 `s` 节点 | `s` | - | +| `h` | `s` 节点 | 移动到 `sh` 节点 | `sh` | - | +| `e` | `sh` 节点 | 移动到 `she` 节点 | `she` | **找到 `she`** | +| `r` | `she` 节点 | 失配,跳转到根节点 | - | - | +| `h` | 根节点 | 移动到 `h` 节点 | `h` | - | +| `s` | `h` 节点 | 失配,跳转到根节点,再移动到 `s` 节点 | `s` | - | + +**最终结果**:在文本串 `yasherhs` 中找到模式串 `she`(位置 2-4)。 + + +## 3. AC 自动机代码实现 -## 5. 代码实现 ```python class TrieNode: def __init__(self): - self.children = {} # 子节点 - self.fail = None # 失配指针 - self.is_end = False # 是否是单词结尾 - self.word = "" # 存储完整的单词 + self.children = {} # 子节点,key 为字符,value 为 TrieNode + self.fail = None # 失配指针,指向当前节点最长可用后缀的节点 + self.is_end = False # 是否为某个模式串的结尾 + self.word = "" # 如果是结尾,存储完整的单词 class AC_Automaton: def __init__(self): - self.root = TrieNode() - + self.root = TrieNode() # 初始化根节点 + def add_word(self, word): + """ + 向Trie树中插入一个模式串 + """ node = self.root for char in word: if char not in node.children: - node.children[char] = TrieNode() + node.children[char] = TrieNode() # 新建子节点 node = node.children[char] - node.is_end = True - node.word = word - + node.is_end = True # 标记单词结尾 + node.word = word # 存储完整单词 + def build_fail_pointers(self): - queue = [] - # 将根节点的子节点的失配指针指向根节点 - for char, node in self.root.children.items(): - node.fail = self.root - queue.append(node) - - # 广度优先搜索构建失配指针 + """ + 构建失配指针(fail指针),采用BFS广度优先遍历 + """ + from collections import deque + queue = deque() + # 1. 根节点的所有子节点的 fail 指针都指向根节点 + for child in self.root.children.values(): + child.fail = self.root + queue.append(child) + + # 2. 广度优先遍历,依次为每个节点建立 fail 指针 while queue: - current = queue.pop(0) + current = queue.popleft() for char, child in current.children.items(): + # 从当前节点的 fail 指针开始,向上寻找有无相同字符的子节点 fail = current.fail while fail and char not in fail.children: fail = fail.fail - child.fail = fail.children[char] if fail else self.root + # 如果找到了,child的fail指针指向该节点,否则指向根节点 + child.fail = fail.children[char] if fail and char in fail.children else self.root queue.append(child) - + def search(self, text): + """ + 在文本text中查找所有模式串出现的位置 + 返回所有匹配到的模式串(可重复) + """ result = [] - current = self.root - - for char in text: - while current is not self.root and char not in current.children: - current = current.fail - if char in current.children: - current = current.children[char] - - # 检查当前节点是否是某个单词的结尾 - temp = current + node = self.root + + for idx, char in enumerate(text): + # 如果当前节点没有该字符的子节点,则沿fail指针向上跳转 + while node is not self.root and char not in node.children: + node = node.fail + # 如果有该字符的子节点,则转移到该子节点 + if char in node.children: + node = node.children[char] + # 否则仍然停留在根节点 + + # 检查当前节点以及沿fail链上的所有节点是否为单词结尾 + temp = node while temp is not self.root: if temp.is_end: - result.append(temp.word) + result.append(temp.word) # 记录匹配到的模式串 temp = temp.fail - + return result ``` + + +## 4. AC 自动机算法分析 + +| 指标 | 复杂度 | 说明 | +| ------------ | ---------------- | ------------------------------------------------------------ | +| 构建字典树 | $O(m)$ | $m$ 为所有模式串的总长度 | +| 构建失配指针 | $O(m)$ | 使用 BFS 遍历所有节点,每个节点最多被访问一次 | +| 文本串匹配 | $O(n + k)$ | $n$ 为文本串长度,$k$ 为匹配到的模式串数量 | +| **总体时间复杂度** | **$O(n + m + k)$** | 线性时间复杂度,非常高效 | +| **空间复杂度** | $O(m)$ | 包含字典树和失配指针的存储,$m$ 为所有模式串的总长度 | + + ## 6. 总结 -AC 自动机是一种高效的多模式匹配算法,它通过结合字典树和 KMP 算法的思想,实现了在文本串中快速查找多个模式串的功能。虽然其实现相对复杂,但在需要多模式匹配的场景下,AC 自动机提供了最优的时间复杂度。 \ No newline at end of file +AC 自动机是一种高效的多模式匹配算法,它巧妙地结合了字典树和 KMP 算法的思想,实现了在文本串中快速查找多个模式串的功能。 + +**核心思想**: +- 使用字典树组织所有模式串,共享公共前缀 +- 借鉴 KMP 算法的失配指针思想,实现快速状态跳转 +- 通过一次扫描文本串,找到所有匹配的模式串 + +虽然 AC 自动机的实现相对复杂,但在需要多模式匹配的场景下,它提供了最优的时间复杂度,是处理多模式匹配问题的首选算法。 + +## 练习题目 + +- [0208. 实现 Trie (前缀树)](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0200-0299/implement-trie-prefix-tree.md) +- [0677. 键值映射](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0600-0699/map-sum-pairs.md) +- [1023. 驼峰式匹配](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/1000-1099/camelcase-matching.md) +- [0211. 添加与搜索单词 - 数据结构设计](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0200-0299/design-add-and-search-words-data-structure.md) +- [0648. 单词替换](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0600-0699/replace-words.md) +- [0676. 实现一个魔法字典](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0600-0699/implement-magic-dictionary.md) + +- [多模式串匹配题目列表](https://github.com/ITCharge/AlgoNote/tree/main/docs/00_preface/00_06_categories_list.md#%E5%A4%9A%E6%A8%A1%E5%BC%8F%E4%B8%B2%E5%8C%B9%E9%85%8D%E9%A2%98%E7%9B%AE) + +## 参考资料 + +- 【书籍】算法训练营 陈小玉 著 +- 【书籍】ACM-ICPC 程序设计系列 算法设计与实现 陈宇 吴昊 主编 +- 【博文】[AC自动机 - OI Wiki](https://oi-wiki.org/string/ac-automaton/) +- 【博文】[AC自动机算法详解 - 数据结构与算法之美 - 极客时间](https://time.geekbang.org/column/article/72810) +- 【博文】[AC自动机算法详解 - 算法竞赛进阶指南](https://www.acwing.com/blog/content/405/) +- 【博文】[AC自动机算法原理与实现 - 算法笔记](https://www.algorithm-notes.org/string/ac-automaton/) \ No newline at end of file From 9dbc13dea27c9330071fba614e1b2dbf90a9bda3 Mon Sep 17 00:00:00 2001 From: ITCharge Date: Mon, 1 Sep 2025 22:33:04 +0800 Subject: [PATCH 10/18] =?UTF-8?q?=E6=9B=B4=E6=96=B0=201032.=20=E5=AD=97?= =?UTF-8?q?=E7=AC=A6=E6=B5=81=20=E9=A2=98=E8=A7=A3=E6=8A=A5=E5=91=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../1000-1099/stream-of-characters.md | 273 ++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 docs/solutions/1000-1099/stream-of-characters.md diff --git a/docs/solutions/1000-1099/stream-of-characters.md b/docs/solutions/1000-1099/stream-of-characters.md new file mode 100644 index 00000000..e762cb61 --- /dev/null +++ b/docs/solutions/1000-1099/stream-of-characters.md @@ -0,0 +1,273 @@ +# [1032. 字符流](https://leetcode.cn/problems/stream-of-characters/) + +- 标签:设计、字典树、数组、字符串、数据流 +- 难度:困难 + +## 题目链接 + +- [1032. 字符流 - 力扣](https://leetcode.cn/problems/stream-of-characters/) + +## 题目大意 + +**描述**:设计一个算法:接收一个字符流,并检查这些字符的后缀是否是字符串数组 $words$ 中的一个字符串。 + +**要求**: + +按下述要求实现 StreamChecker 类: + +- `StreamChecker(String[] words):` 构造函数,用字符串数组 $words$ 初始化数据结构。 +- `boolean query(char letter):` 从字符流中接收一个新字符,如果字符流中的任一非空后缀能匹配 $words$ 中的某一字符串,返回 $True$;否则,返回 $False$。 + +**说明**: + +- $1 \le words.length \le 2000$。 +- $1 <= words[i].length <= 200$。 +- $words[i]$ 由小写英文字母组成。 +- $letter$ 是一个小写英文字母。 +- 最多调用查询 $4 \times 10^4$ 次。 + +**示例**: + +- 示例 1: + +```python +输入: +["StreamChecker", "query", "query", "query", "query", "query", "query", "query", "query", "query", "query", "query", "query"] +[[["cd", "f", "kl"]], ["a"], ["b"], ["c"], ["d"], ["e"], ["f"], ["g"], ["h"], ["i"], ["j"], ["k"], ["l"]] +输出: +[null, false, false, false, true, false, true, false, false, false, false, false, true] + +解释: +StreamChecker streamChecker = new StreamChecker(["cd", "f", "kl"]); +streamChecker.query("a"); // 返回 False +streamChecker.query("b"); // 返回 False +streamChecker.query("c"); // 返回n False +streamChecker.query("d"); // 返回 True ,因为 'cd' 在 words 中 +streamChecker.query("e"); // 返回 False +streamChecker.query("f"); // 返回 True ,因为 'f' 在 words 中 +streamChecker.query("g"); // 返回 False +streamChecker.query("h"); // 返回 False +streamChecker.query("i"); // 返回 False +streamChecker.query("j"); // 返回 False +streamChecker.query("k"); // 返回 False +streamChecker.query("l"); // 返回 True ,因为 'kl' 在 words 中 +``` + +## 解题思路 + +这道题要求设计一个数据结构,能够实时检查字符流中的后缀是否匹配给定的单词集合。由于字符流是动态的,我们需要高效地处理每个新字符的查询。 + +### 思路 1:字典树 + 字符串反转 + +**问题分析**: +- 需要检查字符流中的后缀是否匹配单词集合中的任意单词 +- 字符流是动态添加的,直接存储所有可能的后缀会非常低效 +- 字典树适合前缀匹配,但我们需要后缀匹配 + +**核心思想**: +将后缀匹配问题转化为前缀匹配问题:将所有单词反转后插入字典树,这样检查后缀就变成了检查前缀。 + +**算法步骤**: +1. **初始化**:将所有单词反转后插入字典树中 +2. **查询处理**:每次接收到新字符时,将其添加到字符流的前面 +3. **匹配检查**:在字典树中搜索当前字符流,找到匹配的单词就返回 `True` + +**关键优化**: +- 使用反转的单词构建字典树,将后缀匹配转化为前缀匹配 +- 在搜索过程中,一旦找到匹配的单词就立即返回,避免不必要的继续搜索 + +**示例分析**: +- 单词集合:`["cd", "f", "kl"]` → 插入字典树:`["dc", "f", "lk"]` +- 字符流 `"cd"` → 检查 `"dc"` 是否在字典树中 → 匹配成功,返回 `True` + + +### 思路 1:代码 + +```python +class Node: # 字符节点 + def __init__(self): # 初始化字符节点 + self.children = dict() # 初始化子节点 + self.isEnd = False # isEnd 用于标记单词结束 + + +class Trie: # 字典树 + + # 初始化字典树 + def __init__(self): # 初始化字典树 + self.root = Node() # 初始化根节点(根节点不保存字符) + + # 向字典树中插入一个单词 + def insert(self, word: str) -> None: + cur = self.root + for ch in word: # 遍历单词中的字符 + if ch not in cur.children: # 如果当前节点的子节点中,不存在键为 ch 的节点 + cur.children[ch] = Node() # 建立一个节点,并将其保存到当前节点的子节点 + cur = cur.children[ch] # 令当前节点指向新建立的节点,继续处理下一个字符 + cur.isEnd = True # 单词处理完成时,将当前节点标记为单词结束 + + # 查找字典树中是否存在一个单词 + def search(self, word: str) -> bool: + cur = self.root + for ch in word: # 遍历单词中的字符 + if ch not in cur.children: # 如果当前节点的子节点中,不存在键为 ch 的节点 + return False # 直接返回 False + cur = cur.children[ch] # 令当前节点指向新建立的节点,然后继续查找下一个字符 + if cur.isEnd: + return True + return False + + # 查找字典树中是否存在一个前缀 + def startsWith(self, prefix: str) -> bool: + cur = self.root + for ch in prefix: # 遍历前缀中的字符 + if ch not in cur.children: # 如果当前节点的子节点中,不存在键为 ch 的节点 + return False # 直接返回 False + cur = cur.children[ch] # 令当前节点指向新建立的节点,然后继续查找下一个字符 + return cur is not None # 判断当前节点是否为空,不为空则查找成功 + +class StreamChecker: + + def __init__(self, words: List[str]): + self.trie = Trie() + self.stream = "" + for word in words: + self.trie.insert(word[::-1]) + + def query(self, letter: str) -> bool: + self.stream = letter + self.stream + size = len(letter) + + return self.trie.search(self.stream) + + + +# Your StreamChecker object will be instantiated and called as such: +# obj = StreamChecker(words) +# param_1 = obj.query(letter) +``` + +### 思路 1:复杂度分析 + +- **时间复杂度**: + - 初始化:$O(m \times n)$,其中 $m$ 是单词数量,$n$ 是单词的平均长度。 + - 查询:$O(k)$,其中 $k$ 是当前字符流的长度。最坏情况下,每次查询都需要遍历整个字符流。 +- **空间复杂度**:$O(m \times n)$,字典树的空间复杂度,其中 $m$ 是单词数量,$n$ 是单词的平均长度。 + +### 思路 2:AC 自动机 + +**问题分析**: +- 需要处理多模式串匹配问题,适合使用 AC 自动机 +- 字符流查询频率高,需要优化查询时间复杂度 +- 不需要存储完整的字符流历史,只需要维护当前匹配状态 + +**核心思想**: +使用 AC 自动机(Aho-Corasick Automaton)进行多模式串匹配: +1. 将所有单词构建成AC自动机,利用字典树共享公共前缀 +2. 为每个节点设置失配指针,实现匹配失败时的快速跳转 +3. 维护当前匹配状态,每次接收新字符时更新状态并检查匹配 + +**算法步骤**: +1. **构建AC自动机**:将所有单词插入字典树,并构建失配指针 +2. **维护匹配状态**:使用变量记录当前在AC自动机中的位置 +3. **字符流处理**:每次接收新字符时,沿着AC自动机进行状态转移 +4. **匹配检测**:检查当前状态及其失配链上是否有单词结尾 + +**关键优势**: +- **时间复杂度优秀**:构建 $O(m)$,查询平均 $O(1)$ +- **空间效率高**:共享公共前缀,节省存储空间 +- **适合流式处理**:不需要存储整个字符流历史 + +### 思路 2:代码 + +```python +class TrieNode: + def __init__(self): + self.children = {} # 子节点,key 为字符,value 为 TrieNode + self.fail = None # 失配指针,指向当前节点最长可用后缀的节点 + self.is_end = False # 是否为某个模式串的结尾 + self.word = "" # 如果是结尾,存储完整的单词 + +class AC_Automaton: + def __init__(self): + self.root = TrieNode() # 初始化根节点 + + def add_word(self, word): + """ + 向Trie树中插入一个模式串 + """ + node = self.root + for char in word: + if char not in node.children: + node.children[char] = TrieNode() # 新建子节点 + node = node.children[char] + node.is_end = True # 标记单词结尾 + node.word = word # 存储完整单词 + + def build_fail_pointers(self): + """ + 构建失配指针(fail指针),采用BFS广度优先遍历 + """ + from collections import deque + queue = deque() + # 1. 根节点的所有子节点的 fail 指针都指向根节点 + for child in self.root.children.values(): + child.fail = self.root + queue.append(child) + + # 2. 广度优先遍历,依次为每个节点建立 fail 指针 + while queue: + current = queue.popleft() + for char, child in current.children.items(): + # 从当前节点的 fail 指针开始,向上寻找有无相同字符的子节点 + fail = current.fail + while fail and char not in fail.children: + fail = fail.fail + # 如果找到了,child的fail指针指向该节点,否则指向根节点 + child.fail = fail.children[char] if fail and char in fail.children else self.root + queue.append(child) + +class StreamChecker: + def __init__(self, words): + self.ac = AC_Automaton() + # 将所有单词插入AC自动机 + for word in words: + self.ac.add_word(word) + # 构建失配指针 + self.ac.build_fail_pointers() + # 当前匹配状态 + self.current_node = self.ac.root + + def query(self, letter): + """ + 处理新字符,检查是否匹配到任何单词 + """ + # 如果当前节点没有该字符的子节点,则沿 fail 指针向上跳转 + while self.current_node is not self.ac.root and letter not in self.current_node.children: + self.current_node = self.current_node.fail + + # 如果有该字符的子节点,则转移到该子节点 + if letter in self.current_node.children: + self.current_node = self.current_node.children[letter] + # 否则仍然停留在根节点 + + # 检查当前节点以及沿 fail 链上的所有节点是否为单词结尾 + temp = self.current_node + while temp is not self.ac.root: + if temp.is_end: + return True # 找到匹配的单词 + temp = temp.fail + + return False # 没有找到匹配的单词 + + +# Your StreamChecker object will be instantiated and called as such: +# obj = StreamChecker(words) +# param_1 = obj.query(letter) +``` + +### 思路 2:复杂度分析 + +- **时间复杂度**: + - 初始化:$O(m)$,其中 $m$ 是所有单词的总长度。构建字典树和失配指针都是线性时间。 + - 查询:$O(1)$ 平均情况,$O(k)$ 最坏情况,其中 $k$ 是单词的最大长度。由于失配指针的存在,大部分情况下可以快速跳转。 +- **空间复杂度**:$O(m)$,其中 $m$ 是所有单词的总长度。AC自动机的空间复杂度主要由字典树决定。 From bbbac114f05e1a3acb09242c2fc84a5137f003d6 Mon Sep 17 00:00:00 2001 From: ITCharge Date: Tue, 2 Sep 2025 17:56:35 +0800 Subject: [PATCH 11/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=92=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=AF=AD=E5=8F=A5=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/04_string/04_08_trie.md | 288 ++++++---- docs/05_tree/05_01_tree_basic.md | 229 ++++---- docs/05_tree/05_02_binary_tree_traverse.md | 504 ++++++++++------- docs/05_tree/05_03_binary_tree_reduction.md | 259 ++++++--- docs/05_tree/05_04_binary_search_tree.md | 256 ++++++--- docs/05_tree/05_05_segment_tree_01.md | 582 ++++++++++++-------- docs/05_tree/05_06_segment_tree_02.md | 238 ++++---- docs/05_tree/05_08_union_find.md | 503 +++++++++-------- 8 files changed, 1719 insertions(+), 1140 deletions(-) diff --git a/docs/04_string/04_08_trie.md b/docs/04_string/04_08_trie.md index 9ef8e618..910f04a7 100644 --- a/docs/04_string/04_08_trie.md +++ b/docs/04_string/04_08_trie.md @@ -1,106 +1,119 @@ -## 1. 字典树简介 +## 1. 字典树介绍 -> **字典树(Trie)**:又称为前缀树、单词查找树,是一种树形结构。顾名思义,就是一个像字典一样的树。它是字典的一种存储方式。字典中的每个单词在字典树中表现为一条从根节点出发的路径,路径相连的边上的字母连起来就形成对应的字符串。 +> **字典树(Trie)**,又称前缀树,是一种高效存储和查找字符串集合的树形结构。可以把它想象成一本「分层字典」:每个单词从根节点出发,按字母顺序一层层分支,直到单词结尾。具有相同前缀的单词会在树上共用同一条路径,就像家族树中有共同祖先的亲戚一样,这样能大幅提升查找和前缀匹配的效率。 -例如下图就是一棵字典树,其中包含有 `"a"`、`"abc"`、`"acb"`、`"acc"`、`"ach"`、`"b"`、`"chb"` 这 7 个单词。 +下图展示了一棵字典树,包含 `"a"`、`"abc"`、`"acb"`、`"acc"`、`"ach"`、`"b"`、`"chb"` 这 7 个单词。 ![字典树](https://qcdn.itcharge.cn/images/20240511165918.png) -从图中可以发现,这棵字典树用边来表示字母,从根节点到树上某一节点的路径就代表了一个单词。比如 $1 \rightarrow 2 \rightarrow 6 \rightarrow 10$ 表示的就是单词 `"acc"`。为了清楚地判断某节点路径是否表示一个单词,我们还可以在每个单词对应路径的结束位置增加一个结束标记 $end$(图中红色节点),表示从根节点到这里有一个单词。 +在图中,边表示字符,从根节点到某节点的路径即为一个单词。例如 $1 \rightarrow 2 \rightarrow 6 \rightarrow 10$ 表示 `"acc"`。每个单词的结尾节点通常会有一个结束标记 $end$(红色节点),用于区分完整单词。 -字典树的结构比较简单,其本质上就是一个用于字符串快速检索的多叉树,树上每个节点都包含多字符指针。将从根节点到某一节点路径上经过的字符连接起来,就是该节点对应的字符串。 +字典树的本质是利用字符串的公共前缀,将相同前缀的单词合并存储,从而加快查询速度,减少重复比较,利用空间换时间。 -**字典树设计的核心思想 **:利用空间换时间,利用字符串的公共前缀来降低查询时间的开销,最大限度的减少无谓的字符串比较,以达到提高效率的目的。 - -下面我们来归纳一下 **字典树的基本性质**: - -- 根节点不包含字符,除根节点外,每个节点都只包含一个字符。 -- 从根节点到某一节点,路径航经过的字符串连接起来,就是该节点对应的字符串。 -- 每个节点的所有子节点包含的字符串都不相同。 +**字典树的基本性质:** +- 根节点不存字符,其他每个节点只存一个字符。 +- 从根到某节点的路径组成该节点对应的字符串。 +- 每个节点的所有子节点字符都不相同。 ## 2. 字典树的基本操作 -字典树的基本操作有 **创建**、**插入**、**查找** 和 **删除**。其中删除操作是最不常用,我们这里主要介绍字典树的创建、插入和查找。 +字典树常见的基本操作包括 **创建**、**插入**、**查找** 和 **删除**。其中,删除操作在实际应用中较少用到,因此本节主要聚焦于字典树的创建、插入和查找。 ### 2.1 字典树的结构 -#### 2.1.1 字典树的节点结构 +#### 2.1.1 字典树节点的定义 -首先我们先来定义一下字典树的节点结构。 +我们先明确字典树节点的结构。 -上面说到字典树是一棵多叉树,这个 **「多叉」** 的意思是一个节点可以有多个子节点。而多叉的实现方式可以使用数组实现,也可以使用哈希表实现。接下来我们来介绍一下这两种节点结构。 +字典树本质上是一棵多叉树,即每个节点可以拥有多个子节点。实现多叉结构时,常见的方式有两种:使用数组或哈希表。下面分别介绍这两种实现方式。 -- 如果字符串所涉及的字符集合只包含小写英文字母的话,我们可以使用一个长度为 $26$ 的数组来表示当前节点的多个子节点,如下面代码所示。 +- 当字符串仅包含小写英文字母时,可以用长度为 $26$ 的数组来存储每个节点的所有子节点。例如: ```python class Node: # 字符节点 - def __init__(self): # 初始化字符节点 - self.children = [None for _ in range(26)] # 初始化子节点 - self.isEnd = False # isEnd 用于标记单词结束 + def __init__(self): + # 初始化字符节点 + # children 是长度为 26 的数组,分别对应 'a'~'z' 的子节点 + self.children = [None for _ in range(26)] # 初始化所有子节点为 None + self.isEnd = False # isEnd 用于标记该节点是否为某个单词的结尾 ``` -代码中,$self.children$ 使用数组实现,表示该节点的所有子节点。$isEnd$ 则用于标记单词是否结束。 +上述代码中,$self.children$ 采用数组结构,表示该节点的全部子节点;$isEnd$ 用于标记该节点是否为某个单词的结尾。 -这样,如果我们在插入单词时,需要先将单词中的字符转换为数字,再创建对应的字符节点,并将其映射到长度为 $26$ 数组中。 +在插入单词时,需要将每个字符转换为对应的数字索引,然后在长度为 $26$ 的数组中定位并创建相应的子节点。 -- 如果所涉及的字符集合不仅包含小写字母,还包含大写字母和其他字符,我们可以使用哈希表来表示当前节点的多个子节点,如下面代码所示。 +- 如果字符集不仅包含小写字母,还包括大写字母或其他字符,则可使用哈希表来存储当前节点的所有子节点,具体实现如下: ```python -class Node: # 字符节点 - def __init__(self): # 初始化字符节点 - self.children = dict() # 初始化子节点 - self.isEnd = False # isEnd 用于标记单词结束 +class Node: # 字符节点 + def __init__(self): # 初始化字符节点 + self.children = dict() # 用哈希表存储所有子节点,key 为字符,value 为 Node 实例 + self.isEnd = False # 标记该节点是否为某个单词的结尾 + # 例如:children['a'] 表示以当前节点为父节点,字符为 'a' 的子节点 ``` -代码中,$self.children$ 使用哈希表实现,表示该节点的所有子节点。$isEnd$ 则用于标记单词是否结束。这样,如果我们在插入单词时,直接根据单词中的字符创建对应的字符节点,并将其插入到对应的哈希表中。 +在上述代码中,$self.children$ 采用哈希表结构,用于存储该节点的所有子节点,$isEnd$ 用于标记该节点是否为某个单词的结尾。插入单词时,可以根据单词的每个字符,动态创建对应的字符节点,并将其加入哈希表,便于高效查找和插入。 -下面为了统一代码和编写方便,本文代码全部以哈希表的形式来表示当前节点的多个子节点。 +为统一实现和便于维护,本文后续所有代码均采用哈希表来管理节点的子节点。 #### 2.1.2 字典树的基本结构 -定义完了字典树的字符结构,下面我们定义下字典树的基本结构。在字典树的初始化操作时,定义一个根节点。并且这个根节点不用保存字符。在后续进行插入操作、查找操作都是从字典树的根节点开始的。字典树的基本结构代码如下。 +在明确了字典树节点的结构后,我们进一步定义字典树的整体结构。字典树在初始化时会创建一个根节点,该根节点不存储任何字符。所有的插入和查找操作均从根节点出发。下面是字典树的基本结构代码: ```python -class Trie: # 字典树 - - # 初始化字典树 - def __init__(self): # 初始化字典树 - self.root = Node() # 初始化根节点(根节点不保存字符) +class Trie: # 字典树(前缀树) + def __init__(self): + """ + 初始化字典树,创建一个根节点。 + 根节点不存储任何字符,仅作为所有单词的公共起点。 + """ + self.root = Node() # 初始化根节点(根节点不保存字符) ``` -### 2.2 字典树的创建和插入操作 +### 2.2 字典树的创建与插入操作 -字典树的创建指的是将字符串数组中的所有字符串都插⼊字典树中。而插⼊操作指的是将⼀个字符串插⼊字典树中。 +字典树的「创建」是指将字符串数组中的所有字符串依次插入到字典树中;而「插入」操作则是将单个字符串加入到字典树的过程。 #### 2.2.1 字典树的插入操作 -在讲解字典树的创建之前,我们先来看一下如何在字典树中插入一个单词。具体步骤如下: +在介绍字典树的批量创建前,先说明单个单词的插入流程: -- 依次遍历单词中的字符 $ch$,并从字典树的根节点的子节点位置开始进行插入操作(根节点不包含字符)。 -- 如果当前节点的子节点中,不存在键为 $ch$ 的节点,则建立一个节点,并将其保存到当前节点的子节点中,即 `cur.children[ch] = Node()`,然后令当前节点指向新建立的节点,然后继续处理下一个字符。 -- 如果当前节点的子节点中,存在键为 $ch$ 的节点,则直接令当前节点指向键为 $ch$ 的节点,继续处理下一个字符。 -- 在单词处理完成时,将当前节点标记为单词结束。 +- 从根节点出发,依次遍历单词的每个字符 $ch$(根节点本身不存储字符)。 +- 如果当前节点的子节点中不存在字符 $ch$,则新建一个节点 `cur.children[ch] = Node()`,并将当前指针移动到新节点。 +- 如果当前节点的子节点中已存在字符 $ch$,则直接将当前指针移动到该子节点。 +- 当所有字符遍历完毕后,将当前节点标记为单词结尾(即 `isEnd = True`)。 ```python # 向字典树中插入一个单词 def insert(self, word: str) -> None: - cur = self.root - for ch in word: # 遍历单词中的字符 - if ch not in cur.children: # 如果当前节点的子节点中,不存在键为 ch 的节点 - cur.children[ch] = Node() # 建立一个节点,并将其保存到当前节点的子节点 - cur = cur.children[ch] # 令当前节点指向新建立的节点,继续处理下一个字符 - cur.isEnd = True # 单词处理完成时,将当前节点标记为单词结束 + """ + 将一个单词插入到字典树中。 + + 参数: + word (str): 需要插入的单词 + """ + cur = self.root # 从根节点开始 + for ch in word: # 遍历单词中的每个字符 + # 如果当前节点的子节点中不存在字符 ch,则新建一个节点 + if ch not in cur.children: + cur.children[ch] = Node() # 创建新节点并加入子节点字典 + # 移动到下一个字符节点,继续插入 + cur = cur.children[ch] + # 单词所有字符插入完成后,将当前节点标记为单词结尾 + cur.isEnd = True ``` #### 2.2.2 字典树的创建操作 -字典树的创建比较简单,具体步骤如下: +字典树的创建过程比较简单,通常包括以下步骤: -- 首先初始化一个字典树,即 `trie = Trie()`。 -- 然后依次遍历字符串中的所有单词,将其一一插入到字典树中。 +- 先实例化一个字典树对象,如 `trie = Trie()`。 +- 遍历单词列表,将每个单词依次插入到字典树中。 ```python +# 创建一个字典树实例 trie = Trie() +# 遍历单词列表,将每个单词插入到字典树中 for word in words: trie.insert(word) ``` @@ -109,91 +122,130 @@ for word in words: #### 2.3.1 字典树的查找单词操作 -在字典树中查找某个单词是否存在,其实和字典树的插入操作差不多。具体操作如下: +在字典树中查找某个单词是否存在的过程与插入操作类似,具体步骤如下: -- 依次遍历单词中的字符,并从字典树的根节点位置开始进行查找操作。 -- 如果当前节点的子节点中,不存在键为 $ch$ 的节点,则说明不存在该单词,直接返回 $False$。 -- 如果当前节点的子节点中,存在键为 $ch$ 的节点,则令当前节点指向新建立的节点,然后继续查找下一个字符。 -- 在单词处理完成时,判断当前节点是否有单词结束标记,如果有,则说明字典树中存在该单词,返回 $True$。否则,则说明字典树中不存在该单词,返回 $False$。 +- 从根节点出发,依次遍历单词的每个字符 $ch$。 +- 如果当前节点的子节点中不存在字符 $ch$,则说明该单词不在字典树中,直接返回 $False$。 +- 如果存在字符 $ch$,则将当前指针移动到对应的子节点,继续查找下一个字符。 +- 当所有字符遍历完毕后,检查当前节点是否被标记为单词结尾(`isEnd = True`)。如果是,则说明字典树中存在该单词,返回 $True$;否则返回 $False$。 ```python # 查找字典树中是否存在一个单词 def search(self, word: str) -> bool: - cur = self.root - for ch in word: # 遍历单词中的字符 - if ch not in cur.children: # 如果当前节点的子节点中,不存在键为 ch 的节点 - return False # 直接返回 False - cur = cur.children[ch] # 令当前节点指向新建立的节点,然后继续查找下一个字符 - - return cur.isEnd # 判断是否有单词结束标记 + """ + 在字典树中查找指定单词是否存在。 + + 参数: + word (str): 需要查找的单词 + + 返回: + bool: 如果单词存在于字典树中,返回 True;否则返回 False + """ + cur = self.root # 从根节点开始 + for ch in word: # 遍历单词中的每个字符 + if ch not in cur.children: # 如果当前节点的子节点中不存在该字符 + return False # 说明单词不存在,直接返回 False + cur = cur.children[ch] # 移动到对应的子节点,继续查找下一个字符 + return cur.isEnd # 所有字符查找完毕,判断当前节点是否为单词结尾标记 ``` #### 2.3.2 字典树的查找前缀操作 -在字典树中查找某个前缀是否存在,和字典树的查找单词操作一样,不同点在于最后不需要判断是否有单词结束标记。 +在字典树中查找某个前缀是否存在,其过程与查找完整单词类似。不同之处在于,查找前缀时只需依次判断每个字符是否存在于相应的子节点中,无需判断最后节点是否为单词结尾标记。只要前缀的所有字符都能顺利匹配,即可认为该前缀存在于字典树中。 ```python # 查找字典树中是否存在一个前缀 def startsWith(self, prefix: str) -> bool: - cur = self.root - for ch in prefix: # 遍历前缀中的字符 - if ch not in cur.children: # 如果当前节点的子节点中,不存在键为 ch 的节点 - return False # 直接返回 False - cur = cur.children[ch] # 令当前节点指向新建立的节点,然后继续查找下一个字符 - return True # 查找成功 + """ + 在字典树中查找指定前缀是否存在。 + + 参数: + prefix (str): 需要查找的前缀字符串 + + 返回: + bool: 如果前缀存在于字典树中,返回 True;否则返回 False + """ + cur = self.root # 从根节点开始 + for ch in prefix: # 遍历前缀中的每个字符 + if ch not in cur.children: # 如果当前节点的子节点中不存在该字符 + return False # 说明前缀不存在,直接返回 False + cur = cur.children[ch] # 移动到对应的子节点,继续查找下一个字符 + return True # 所有字符查找完毕,前缀存在于字典树中 ``` ## 3. 字典树的实现代码 ```python -class Node: # 字符节点 - def __init__(self): # 初始化字符节点 - self.children = dict() # 初始化子节点 - self.isEnd = False # isEnd 用于标记单词结束 - - -class Trie: # 字典树 - - # 初始化字典树 - def __init__(self): # 初始化字典树 - self.root = Node() # 初始化根节点(根节点不保存字符) - - # 向字典树中插入一个单词 +class Node: # 字符节点(Trie 树的节点) + def __init__(self): + self.children = dict() # 子节点字典,key 为字符,value 为 Node 对象 + self.isEnd = False # 是否为单词结尾标记 + + +class Trie: # 字典树(Trie) + + def __init__(self): + """ + 初始化字典树,创建一个空的根节点(根节点不保存字符) + """ + self.root = Node() + def insert(self, word: str) -> None: - cur = self.root - for ch in word: # 遍历单词中的字符 - if ch not in cur.children: # 如果当前节点的子节点中,不存在键为 ch 的节点 - cur.children[ch] = Node() # 建立一个节点,并将其保存到当前节点的子节点 - cur = cur.children[ch] # 令当前节点指向新建立的节点,继续处理下一个字符 - cur.isEnd = True # 单词处理完成时,将当前节点标记为单词结束 - - # 查找字典树中是否存在一个单词 - def search(self, word: str) -> bool: - cur = self.root - for ch in word: # 遍历单词中的字符 - if ch not in cur.children: # 如果当前节点的子节点中,不存在键为 ch 的节点 - return False # 直接返回 False - cur = cur.children[ch] # 令当前节点指向新建立的节点,然后继续查找下一个字符 + """ + 向字典树中插入一个单词 + + 参数: + word (str): 要插入的单词 + """ + cur = self.root # 从根节点开始 + for ch in word: # 遍历单词中的每个字符 + if ch not in cur.children: # 如果当前节点没有ch这个子节点 + cur.children[ch] = Node() # 新建一个子节点 + cur = cur.children[ch] # 移动到子节点,继续处理下一个字符 + cur.isEnd = True # 单词插入完成,标记结尾 - return cur.isEnd # 判断是否有单词结束标记 + def search(self, word: str) -> bool: + """ + 查找字典树中是否存在一个完整单词 + + 参数: + word (str): 要查找的单词 + + 返回: + bool: 存在返回True,否则返回False + """ + cur = self.root # 从根节点开始 + for ch in word: # 遍历单词中的每个字符 + if ch not in cur.children: # 如果没有对应的子节点 + return False # 单词不存在 + cur = cur.children[ch] # 移动到子节点 + return cur.isEnd # 判断是否为单词结尾 - # 查找字典树中是否存在一个前缀 def startsWith(self, prefix: str) -> bool: - cur = self.root - for ch in prefix: # 遍历前缀中的字符 - if ch not in cur.children: # 如果当前节点的子节点中,不存在键为 ch 的节点 - return False # 直接返回 False - cur = cur.children[ch] # 令当前节点指向新建立的节点,然后继续查找下一个字符 - return True # 查找成功 + """ + 查找字典树中是否存在某个前缀 + + 参数: + prefix (str): 要查找的前缀 + + 返回: + bool: 存在返回True,否则返回False + """ + cur = self.root # 从根节点开始 + for ch in prefix: # 遍历前缀中的每个字符 + if ch not in cur.children: # 如果没有对应的子节点 + return False # 前缀不存在 + cur = cur.children[ch] # 移动到子节点 + return True # 前缀存在 ``` ## 4. 字典树的算法分析 -假设单词的长度为 $n$,前缀的长度为 $m$,字符集合的维度为 $d$,则: - -- **插入一个单词**:时间复杂度为 $O(n)$;如果使用数组,则空间复杂度为 $O(d^n)$,如果使用哈希表实现,则空间复杂度为 $O(n)$。 -- **查找一个单词**:时间复杂度为 $O(n)$;空间复杂度为 $O(1)$。 -- **查找一个前缀**:时间复杂度为 $O(m)$;空间复杂度为 $O(1)$。 +| 指标 | 复杂度 | 说明 | +|------------------|-------------------------------|--------------------------------------------------------------| +| 插入一个单词 | 时间:$O(n)$
空间:$O(d^n)$(数组实现)
空间:$O(n)$(哈希表实现) | $n$ 为单词长度,$d$ 为字符集大小。数组实现空间消耗大,哈希表实现更节省空间。 | +| 查找一个单词 | 时间:$O(n)$
空间:$O(1)$ | $n$ 为单词长度,仅遍历单词长度,空间为常数。 | +| 查找一个前缀 | 时间:$O(m)$
空间:$O(1)$ | $m$ 为前缀长度,仅遍历前缀长度,空间为常数。 | ## 5. 字典树的应用 @@ -212,6 +264,22 @@ class Trie: # 字典树 - **最长公共前缀问题**:利用字典树求解多个字符串的最长公共前缀问题。将⼤量字符串都存储到⼀棵字典树上时, 可以快速得到某些字符串的公共前缀。对所有字符串都建⽴字典树,两个串的最长公共前缀的长度就是它们所在节点最近公共祖先的长度,于是转变为最近公共祖先问题。 - **字符串排序**:利⽤字典树进⾏串排序。例如,给定多个互不相同的仅由⼀个单词构成的英⽂名,将它们按字典序从⼩到⼤输出。采⽤数组⽅式创建字典树,字典树中每个节点的所有⼦节点都是按照其字母⼤⼩排序的。然后对字典树进⾏先序遍历,输出的相应字符串就是按字典序排序的结果。 +## 6. 总结 + +字典树(Trie)是一种高效存储和查找字符串集合的树形数据结构,通过利用字符串的公共前缀来减少重复比较,实现快速的前缀匹配和字符串检索。 + +**优点:** +- **查找效率高**:查找单词和前缀的时间复杂度均为 $O(n)$,其中 $n$ 为字符串长度,比暴力匹配快很多 +- **前缀匹配优秀**:能够快速判断一个字符串是否为另一个字符串的前缀,这在搜索引擎自动补全等场景中非常有用 +- **空间共享**:具有相同前缀的单词共享路径,相比单独存储每个单词,能节省大量空间 +- **支持动态操作**:可以动态插入、删除字符串,适合需要频繁更新的字符串集合 + +**缺点:** +- **空间消耗较大**:每个节点都需要存储子节点信息,对于稀疏的字符串集合,空间利用率不高 +- **实现复杂度**:相比简单的哈希表或数组,字典树的实现和维护更加复杂 +- **字符集限制**:使用数组实现时,字符集大小会影响空间复杂度,大字符集会显著增加内存消耗 +- **缓存不友好**:树形结构在内存中的分布可能不够连续,对 CPU 缓存不够友好 + ## 练习题目 - [0208. 实现 Trie (前缀树)](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0200-0299/implement-trie-prefix-tree.md) diff --git a/docs/05_tree/05_01_tree_basic.md b/docs/05_tree/05_01_tree_basic.md index 25235fc8..a32439ed 100644 --- a/docs/05_tree/05_01_tree_basic.md +++ b/docs/05_tree/05_01_tree_basic.md @@ -1,200 +1,235 @@ -## 1. 树简介 +## 1. 树 ### 1.1 树的定义 -> **树(Tree)**:由 $n \ge 0$ 个节点与节点之间的关系组成的有限集合。当 $n = 0$ 时称为空树,当 $n > 0$ 时称为非空树。 +> **树(Tree)**:由 $n \ge 0$ 个节点及其相互之间的关系组成的有限集合。当 $n = 0$ 时称为空树,$n > 0$ 时称为非空树。 -之所以把这种数据结构称为「树」是因为这种数据结构看起来就像是一棵倒挂的树,也就是说数据结构中的「树」是根朝上,而叶朝下的。如下图所示。 +之所以称为「树」,是因为这种数据结构的形态类似于一棵倒挂的树:根节点在上,叶子节点在下。如下图所示: ![树](https://qcdn.itcharge.cn/images/20240511171215.png) -「树」具有以下的特点: +树结构具备以下基本特性: -- 有且仅有一个节点没有前驱节点,该节点被称为树的 **「根节点(Root)」** 。 -- 除了根节点以外,每个节点有且仅有一个直接前驱节点。 -- 包括根节点在内,每个节点可以有多个后继节点。 -- 当 $n > 1$ 时,除了根节点之外的其他节点,可分为 $m(m > 0)$ 个互不相交的有限集合 $T_1, T_2, ..., T_m$,其中每一个集合本身又是一棵树,并且被称为根的 **「子树(SubTree)」**。 +- 仅有一个没有前驱的节点,称为 **根节点(Root)**。 +- 除根节点外,其余每个节点都且仅有一个直接前驱节点。 +- 每个节点(包括根节点)可以有零个或多个后继节点。 +- 当 $n > 1$ 时,除根节点外的其余节点可分为 $m\ (m > 0)$ 个互不相交的有限集合 $T_1, T_2, ..., T_m$,每个集合本身又是一棵树,称为根的 **子树(SubTree)**。 -如下图所示,红色节点 $A$ 是根节点,除了根节点之外,还有 $3$ 棵互不相交的子树 $T_1(B, E, H, I, G)$、$T_2(C)$、$T_3(D, F, G, K)$。 +如下图所示,红色节点 $A$ 是根节点,除根节点外,存在 $3$ 棵互不相交的子树:$T_1(B, E, H, I, G)$、$T_2(C)$、$T_3(D, F, G, K)$。 ![树与子树](https://qcdn.itcharge.cn/images/20240511171233.png) ### 1.2 树的相关术语 -下面我们来介绍一下树结构中的一些基本术语。 +下面介绍树结构中的常用基本术语。 #### 1.2.1 节点分类 -**「树的节点」** 由一个数据元素和若干个指向其子树的树的分支组成。而节点所含有的子树个数称为 **「节点的度」**。度为 $0$ 的节点称为 **「叶子节点」** 或者 **「终端节点」**,度不为 $0$ 的节点称为 **「分支节点」** 或者 **「非终端节点」**。树中各节点的最大度数称为 **「树的度」**。 +- **树的节点**:由一个数据元素和若干指向其子树的分支组成。节点拥有的子树数量称为 **节点的度**。 +- **叶子节点(终端节点)**:度为 $0$ 的节点。例如图中 $C$、$H$、$I$、$G$、$F$、$K$。 +- **分支节点(非终端节点)**:度大于 $0$ 的节点。例如图中 $A$、$B$、$D$、$E$、$G$。 +- **树的度**:树中所有节点的最大度数。例如图中树的度为 $3$。 ![节点分类](https://qcdn.itcharge.cn/images/20240511171300.png) -- **树的节点**:由一个数据元素和若干个指向其子树的树的分支组成。 -- **节点的度**:一个节点所含有的子树个数。 -- **叶子节点(终端节点)**:度为 $0$ 的节点。例如图中叶子节点为 $C$、$H$、$I$、$G$、$F$、$K$。 -- **分支节点(非终端节点)**:度不为 $0$ 的节点。例如图中分支节点为 $A$、$B$、$D$、$E$、$G$。 -- **树的度**:树中节点的最大度数。例如图中树的度为 $3$。 - #### 1.2.2 节点间关系 -一个节点的子树的根节点称为该节点的 **「孩子节点」**,相应的,该节点称为孩子的 **「父亲节点」**。同一个父亲节点的孩子节点之间互称为 **「兄弟节点」**。 +- **孩子节点(子节点)**:某节点的子树的根节点。例如图中 $B$ 是 $A$ 的孩子节点。 +- **父节点**:拥有子节点的节点称为其子节点的父节点。例如图中 $B$ 是 $E$ 的父节点。 +- **兄弟节点**:同一父节点的不同子节点互为兄弟。例如图中 $F$、$G$ 互为兄弟节点。 ![节点间关系](https://qcdn.itcharge.cn/images/20240511171311.png) -- **孩子节点(子节点)**:一个节点含有的子树的根节点称为该节点的子节点。例如图中 $B$ 是 $A$ 的孩子节点。 -- **父亲节点(父节点)**:如果一个节点含有子节点,则这个节点称为其子节点的父节点。例如图中 $B$ 是 $E$ 的父亲节点。 -- **兄弟节点**:具有相同父节点的节点互称为兄弟节点。例如图中 $F$、$G$ 互为兄弟节点。 - -#### 1.2.3 树的其他术语 +#### 1.2.3 其他常用术语 -**「节点的层次」** 是从根节点开始定义,将根节点作为第 1 层,根的孩子节点作为第 2 层,以此类推,如果某个节点在第 $i$ 层,则其孩子节点在第 $i + 1$ 层。而父亲节点在同一层的节点互为 **「堂兄弟节点」**。树中所有节点最大的层数称为 **「树的深度」** 或 **「树的高度」**。树中,两个节点之间所经过节点序列称为 **「路径」**,两个节点之间路径上经过的边数称为 **「路径长度」**。 +- **节点的层次**:从根节点开始,根为第 $1$ 层,根的子节点为第 $2$ 层,依此类推。 +- **树的深度(高度)**:树中节点的最大层数。例如图中树的深度为 $4$。 +- **堂兄弟节点**:父节点在同一层的节点互为堂兄弟。例如图中 $J$、$K$ 互为堂兄弟节点。 +- **路径**:树中两个节点之间经过的节点序列。例如 $E$ 到 $G$ 的路径为 $E - B - A - D - G$。 +- **路径长度**:路径上经过的边数。例如 $E$ 到 $G$ 的路径长度为 $4$。 +- **节点的祖先**:从该节点到根节点路径上所有节点。例如 $H$ 的祖先为 $E$、$B$、$A$。 +- **节点的子孙**:以该节点为根的子树中所有节点。例如 $D$ 的子孙为 $F$、$G$、$K$。 ![树的其他术语](https://qcdn.itcharge.cn/images/20240511171325.png) -- **节点的层次**:从根节点开始定义,根为第 $1$ 层,根的子节点为第 $2$ 层,以此类推。 -- **树的深度(高度)**:所有节点中最大的层数。例如图中树的深度为 $4$。 -- **堂兄弟节点**:父节点在同一层的节点互为堂兄弟。例如图中 $J$、$K$ 互为堂兄弟节点。 -- **路径**:树中两个节点之间所经过的节点序列。例如图中 $E$ 到 $G$ 的路径为 $E - B - A - D - G$。 -- **路径长度**:两个节点之间路径上经过的边数。例如图中 $E$ 到 $G$ 的路径长度为 $4$。 -- **节点的祖先**:从该节点到根节点所经过的所有节点,被称为该节点的祖先。例如图中 $H$ 的祖先为 $E$、$B$、$A$。 -- **节点的子孙**:节点的子树中所有节点被称为该节点的子孙。例如图中 $D$ 的子孙为 $F$、$G$、$K$。 - ### 1.3 树的分类 -根据节点的子树是否可以互换位置,我们可以将树分为两种类型:**「有序树」** 和 **「无序树」**。 +树按照节点子树之间是否可以交换位置,可以分为两大类:**有序树** 和 **无序树**。 -如果将树中节点的各个子树看做是从左到右是依次有序的(即不能互换),则称该树为 **「有序树」**。反之,如果节点的各个子树可以互换位置,则成该树为 **「无序树」**。 +- **有序树**:每个节点的子树有严格的左右次序,子树之间的位置不可随意交换。例如二叉树就是典型的有序树。 +- **无序树**:每个节点的子树之间没有顺序要求,子树可以任意交换位置。 -- **有序树**:节点的各个⼦树从左⾄右有序, 不能互换位置。 -- **无序树**:节点的各个⼦树可互换位置。 +简而言之,有序树强调子树的排列顺序,结构唯一;无序树则只关注连接关系,不关心子树的排列顺序。 -## 2. 二叉树简介 +## 2. 二叉树 ### 2.1 二叉树的定义 -> **二叉树(Binary Tree)**:树中各个节点的度不大于 $2$ 个的有序树,称为二叉树。通常树中的分支节点被称为 **「左子树」** 或 **「右子树」**。二叉树的分支具有左右次序,不能随意互换位置。 +> **二叉树(Binary Tree)**:是一种有序树,其中每个节点的度最多为 $2$。每个节点的两个分支分别称为 **左子树** 和 **右子树**,且左右子树的顺序不可交换。 -下图就是一棵二叉树。 +如下图所示是一棵典型的二叉树: ![二叉树](https://qcdn.itcharge.cn/images/20240511171342.png) -二叉树也可以使用递归方式来定义,即二叉树满足以下两个要求之一: +二叉树还可以递归地定义为: -- **空树**:二叉树是一棵空树。 -- **非空树**:二叉树是由一个根节点和两棵互不相交的子树 $T_1$、$T_2$,分别称为根节点的左子树、右子树组成的非空树;并且 $T_1$、$T_2$ 本身都是二叉树。 +- **空树**:即不包含任何节点的树。 +- **非空树**:由一个根节点和两棵互不相交的子树 $T_1$、$T_2$ 组成,$T_1$ 称为左子树,$T_2$ 称为右子树,且 $T_1$、$T_2$ 本身也都是二叉树。 -⼆叉树是种特殊的树,它最多有两个⼦树,分别为左⼦树和右⼦树,并且两个子树是有序的,不可以互换。也就是说,在⼆叉树中不存在度⼤于 $2$ 的节点。 +简而言之,二叉树是一种每个节点最多有两个子树(左子树和右子树)的有序树,且左右子树的位置不可交换。换句话说,二叉树中每个节点的度都不超过 2。 -二叉树在逻辑上可以分为 $5$ 种基本形态,如下图所示。 +二叉树在结构上可以分为以下 $5$ 种基本形态,如下图所示: ![二叉树的形态](https://qcdn.itcharge.cn/images/20220218164839.png) -### 2.2 特殊的二叉树 +### 2.2 二叉树的基本性质 + +二叉树作为最常用的树形结构之一,具有以下基本性质: + +1. **第 $i$ 层的最大节点数**:在二叉树中,第 $i$ 层最多有 $2^{i-1}$ 个节点($i \geq 1$)。 +2. **深度为 $k$ 的二叉树的最大节点数**:$2^k - 1$。 +3. **任意一棵非空二叉树的第 $k$ 层至多有 $2^{k-1}$ 个节点**。 +4. **节点数为 $n$ 的二叉树的最小深度**:$\lceil \log_2(n+1) \rceil$。 +5. **叶子节点数与度为 $2$ 的节点数关系**:二叉树中叶子节点数 $n_0$ 恰好比度为 $2$ 的节点数 $n_2$ 多 $1$,即 $n_0 = n_2 + 1$。 +6. **二叉树的边数**:$n - 1$,其中 $n$ 为节点数。 + +这些性质对于分析二叉树的结构、空间复杂度和算法效率具有重要意义。 + +### 2.3 特殊的二叉树 -下面我们来介绍一些特殊的二叉树。 +下面介绍几类常见的特殊二叉树。 -#### 2.2.1 满二叉树 +#### 2.3.1 满二叉树 -> **满二叉树(Full Binary Tree)**:如果所有分支节点都存在左子树和右子树,并且所有叶子节点都在同一层上,则称该二叉树为满二叉树。 +> **满二叉树(Full Binary Tree)**:指所有非叶子节点均有左右两个子节点,且所有叶子节点都集中在同一层的二叉树。 -满二叉树满足以下特点: +满二叉树具有以下特征: -- 叶子节点只出现在最下面一层。 -- 非叶子节点的度一定为 $2$。 -- 在同等深度的二叉树中,满二叉树的节点个数最多,叶子节点个数最多。 +- 叶子节点全部位于最底层。 +- 所有非叶子节点的度均为 $2$。 +- 在相同深度的二叉树中,满二叉树的节点数和叶子节点数均为最大。 -如果我们对满二叉树的节点进行编号,根节点编号为 $1$,然后按照层次依次向下,每一层从左至右的顺序进行编号。则深度为 $k$ 的满二叉树最后一个节点的编号为 $2^k - 1$。 +如果对满二叉树的节点自上而下、从左到右依次编号,根节点编号为 $1$,则深度为 $k$ 的满二叉树最后一个节点的编号为 $2^k - 1$。 -我们可以来看几个例子。 +如下图所示,展示了满二叉树与非满二叉树的示例: ![满二叉树与非满二叉树](https://qcdn.itcharge.cn/images/20220218173007.png) -#### 2.2.2 完全二叉树 +#### 2.3.2 完全二叉树 -> **完全二叉树(Complete Binary Tree)**:如果叶子节点只能出现在最下面两层,并且最下层的叶子节点都依次排列在该层最左边的位置上,具有这种特点的二叉树称为完全二叉树。 +> **完全二叉树(Complete Binary Tree)**:一种特殊的二叉树,要求除了最后一层外,每一层的节点数都达到最大,且最后一层的所有节点都连续排列在最左侧。 -完全二叉树满足以下特点: +完全二叉树的主要特征如下: -- 叶子节点只能出现在最下面两层。 -- 最下层的叶子节点一定集中在该层最左边的位置上。 -- 倒数第二层如果有叶子节点,则该层的叶子节点一定集中在右边的位置上。 -- 如果节点的度为 $1$,则该节点只有左孩子节点,即不存在只有右孩子节点的情况。 -- 同等节点数的二叉树中,完全二叉树的深度最小。 +- 叶子节点只可能出现在最后两层。 +- 最底层的叶子节点必须依次排列在最左侧。 +- 倒数第二层如果有叶子节点,则这些节点必须集中在右侧。 +- 如果某节点的度为 $1$,则该节点只能有左孩子,不存在只有右孩子的情况。 +- 在节点数相同的二叉树中,完全二叉树的深度最小。 -完全二叉树也可以使用类似满二叉树的节点编号的方式来定义。即从根节点编号为 $1$ 开始,按照层次从上至下,每一层从左至右进行编号。对于深度为 $i$ 且有 $n$ 个节点的二叉树,当且仅当每一个节点都与深度为 $k$ 的满二叉树中编号从 $1$ 至 $n$ 的节点意义对应时,该二叉树为完全二叉树。 +完全二叉树也可以通过节点编号来定义:从根节点开始,按层次自上而下、从左到右依次编号(根为 $1$)。若一棵深度为 $k$、节点数为 $n$ 的二叉树,其每个节点与深度为 $k$ 的满二叉树中编号 $1$ 到 $n$ 的节点一一对应,则该树为完全二叉树。 -我们可以来看几个例子。 +下面通过示例图进行说明: ![完全二叉树与非完全二叉树](https://qcdn.itcharge.cn/images/20220218174000.png) -#### 2.2.3 二叉搜索树 +#### 2.3.3 二叉搜索树 -> **二叉搜索树(Binary Search Tree)**:也叫做二叉查找树、有序二叉树或者排序二叉树。是指一棵空树或者具有下列性质的二叉树: +> **二叉搜索树(Binary Search Tree, BST)**,又称二叉查找树、有序二叉树或排序二叉树,是一种特殊的二叉树结构。其定义如下: > -> - 如果任意节点的左子树不为空,则左子树上所有节点的值均小于它的根节点的值。 -> - 如果任意节点的右子树不为空,则右子树上所有节点的值均大于它的根节点的值。 -> - 任意节点的左子树、右子树均为二叉搜索树。 +> - 对于任意一个节点,如果其左子树非空,则左子树所有节点的值均小于该节点的值; +> - 对于任意一个节点,如果其右子树非空,则右子树所有节点的值均大于该节点的值; +> - 左右子树本身也都是二叉搜索树; +> - 空树也被视为二叉搜索树。 -如图所示,这 $3$ 棵树都是二叉搜索树。 +二叉搜索树的结构保证了对任意节点的中序遍历结果是递增有序的。下图展示了三棵典型的二叉搜索树: ![二叉搜索树](https://qcdn.itcharge.cn/images/20240511171406.png) -#### 2.2.4 平衡二叉搜索树 +#### 2.3.4 平衡二叉搜索树 -> **平衡二叉搜索树(Balanced Binary Tree)**:一种结构平衡的二叉搜索树。即叶节点高度差的绝对值不超过 $1$,并且左右两个子树都是一棵平衡二叉搜索树。平衡二叉树可以在 $O(logn)$ 内完成插入、查找和删除操作。最早被发明的平衡二叉搜索树为 **「AVL 树(Adelson-Velsky and Landis Tree))」**。 -> -> AVL 树满足以下性质: -> -> - 空二叉树是一棵 AVL 树。 -> - 如果 T 是一棵 AVL 树,那么其左右子树也是 AVL 树,并且 $|h(ls) - h(rs)| \le 1$,$h(ls)$ 是左子树的高度,$h(rs)$ 是右子树的高度。 -> - AVL 树的高度为 $O(log n)$。 +> **平衡二叉搜索树(Balanced Binary Search Tree, BBST)**:是一类结构上保持平衡的二叉搜索树。其核心特性是任意节点的左右子树高度差的绝对值不超过 $1$,且左右子树本身也都是平衡二叉搜索树。通过这种结构,平衡二叉搜索树能够保证插入、查找和删除操作的时间复杂度均为 $O(\log n)$。最早提出的平衡二叉搜索树是 **AVL 树(Adelson-Velsky and Landis Tree)**。 + +AVL 树的主要性质如下: + +- 空树是一棵 AVL 树。 +- 如果二叉树 $T$ 是 AVL 树,则 $T$ 的左右子树也都是 AVL 树,且满足 $|h(\text{left}) - h(\text{right})| \le 1$,其中 $h(\text{left})$ 和 $h(\text{right})$ 分别为左、右子树的高度。 +- AVL 树的高度始终保持在 $O(\log n)$。 -如图所示,前 $2$ 棵树是平衡二叉搜索树,最后一棵树不是平衡二叉搜索树,因为这棵树的左右子树的高度差的绝对值超过了 $1$。 +下图中,前两棵树为平衡二叉搜索树,最后一棵树不是平衡二叉搜索树,因为其某节点的左右子树高度差超过 $1$。 ![平衡二叉树与非平衡二叉树](https://qcdn.itcharge.cn/images/20220221103552.png) -### 2.3 二叉树的存储结构 +### 2.4 二叉树的存储结构 -二叉树的存储结构分为两种:「顺序存储结构」和「链式存储结构」,下面进行一一讲解。 +二叉树常见的存储方式有两种:**「顺序存储结构」**和**「链式存储结构」**。下面分别介绍这两种方式。 -#### 2.3.1 二叉树的顺序存储结构 +#### 2.4.1 二叉树的顺序存储结构 -其实,堆排序、优先队列中的二叉堆结构,采用的就是二叉树的顺序存储结构。 +顺序存储结构通常使用一维数组来保存二叉树的所有节点。节点在数组中的位置按照完全二叉树的层序编号排列:自上而下、从左到右依次存放。如果某个节点不存在,则在数组对应位置填充「空节点」。 -二叉树的顺序存储结构使用一维数组来存储二叉树中的节点,节点存储位置则采用完全二叉树的节点层次编号,按照层次从上至下,每一层从左至右的顺序依次存放二叉树的数据元素。在进行顺序存储时,如果对应的二叉树节点不存在,则设置为「空节点」。 +例如,堆排序和优先队列中的二叉堆结构就是采用顺序存储结构实现的。 + +以节点值为 $[1, 2, 3, 4, 5, 6, 7]$ 的完全二叉树为例,其顺序存储结构如下图所示。 -下图为二叉树的顺序存储结构。 ![二叉树的顺序存储结构](https://qcdn.itcharge.cn/images/20240511171423.png) -从图中我们也可以看出节点之间的逻辑关系。 +通过顺序存储结构,节点之间的关系可以通过下标直接计算得到: -- 如果某二叉树节点(非叶子节点)的下标为 $i$,那么其左孩子节点下标为 $2 * i + 1$,右孩子节点下标为 $2 * i + 2$。 -- 如果某二叉树节点(非根节点)的下标为 $i$,那么其根节点下标为 $(i - 1) // 2$。$//$ 表示整除。 +- 如果某节点(非叶子节点)下标为 $i$,则其左孩子下标为 $2 \times i + 1$,右孩子下标为 $2 \times i + 2$。 +- 如果某节点(非根节点)下标为 $i$,则其父节点下标为 $(i - 1) // 2$($//$ 表示整除)。 -对于完全二叉树(尤其是满二叉树)来说,采用顺序存储结构比较合适,它能充分利用存储空间;而对于一般二叉树,如果需要设置很多的「空节点」,则采用顺序存储结构就会浪费很多存储空间。并且,由于顺序存储结构固有的一些缺陷,会使得二叉树的插入、删除等操作不方便,效率也比较低。对于二叉树来说,当树的形态和大小经常发生动态变化时,更适合采用链式存储结构。 +顺序存储结构非常适合 **完全二叉树**(尤其是满二叉树),因为可以充分利用数组空间,节点排列紧凑。但对于一般二叉树,若存在大量空节点,则会造成空间浪费。此外,顺序存储结构不利于二叉树的插入和删除操作,灵活性较差。当二叉树结构和规模经常变化时,更推荐使用链式存储结构。 -#### 2.3.2 二叉树的链式存储结构 +#### 2.4.2 二叉树的链式存储结构 -二叉树采用链式存储结构时,每个链节点包含一个用于数据域 $val$,存储节点信息;还包含两个指针域 $left$ 和 $right$,分别指向左右两个孩子节点,当左孩子或者右孩子不存在时,相应指针域值为空。二叉链节点结构如下图所示。 +在链式存储结构中,二叉树的每个节点通常包含三个部分:一个数据域 $val$ 用于存放节点的值,两个指针域 $left$ 和 $right$ 分别指向左、右子节点。当某个子节点不存在时,相应的指针为 None(或 null)。这种结构如下图所示: ![二叉链节点](https://qcdn.itcharge.cn/images/20240511171434.png) -二叉链节点结构的对应代码为: +其对应的代码实现如下: ```python class TreeNode: + """ + 二叉树节点定义(链式存储结构) + + 属性: + val: 节点存储的值 + left: 指向左子节点的指针(无左子节点时为 None) + right: 指向右子节点的指针(无右子节点时为 None) + """ def __init__(self, val=0, left=None, right=None): - self.val = val - self.left = left - self.right = right + self.val = val # 节点的值 + self.left = left # 左子节点指针 + self.right = right # 右子节点指针 ``` -下面我们将值为 $[1, 2, 3, 4, 5, 6, 7]$ 的二叉树使用链式存储结构进行存储,即为下图所示。 +以节点值为 $[1, 2, 3, 4, 5, 6, 7]$ 的完全二叉树为例,其链式存储结构如下图所示。 ![二叉树的链式存储结构](https://qcdn.itcharge.cn/images/20240511171446.png) -二叉树的链表存储结构具有灵活、方便的特点。节点的最大数目只受系统最大可存储空间的限制。一般情况下,二叉树的链表存储结构比顺序存储结构更省空间(用于存储指针域的空间开销只是二叉树中节点数的线性函数),而且对于二叉树实施相关操作也很方便,因此,一般我们使用链式存储结构来存储二叉树。 +链式存储结构具有高度的灵活性和便利性。其节点数量仅受限于系统可用内存,且通常比顺序存储结构更节省空间(指针域的空间开销与节点数成线性关系)。此外,链式结构便于进行插入、删除等操作,因此在实际应用中,二叉树大多采用链式存储结构。 + +## 3. 总结 + +树是一种层次化的非线性数据结构,由节点和边组成,具有一个根节点和若干子树。二叉树是树的一种特殊形式,每个节点最多有两个子节点(左子树和右子树)。 + +**二叉树的核心特性:** +- **层次性**:二叉树以根节点为起点,节点自上而下、由左至右分层排列,形成清晰的层级结构。 +- **递归性**:每个节点的左右子树本身也是二叉树,天然适合递归定义与处理。 +- **有序性**:每个节点的左、右子树位置固定,不能随意交换,保证结构的唯一性和有序性。 + +**二叉树常见类型:** +- **满二叉树**:除叶子节点外,每个节点都有两个子节点 +- **完全二叉树**:除最后一层外,其他层都被填满,最后一层从左到右填充 +- **二叉搜索树**:左子树所有节点值小于根节点,右子树所有节点值大于根节点 +- **平衡二叉树**:左右子树高度差不超过1,保证操作效率 + +**二叉树存储方式:** +- **顺序存储**:用数组存储,适合完全二叉树,空间利用率高 +- **链式存储**:用指针连接,灵活性好,便于动态操作 ## 参考链接 diff --git a/docs/05_tree/05_02_binary_tree_traverse.md b/docs/05_tree/05_02_binary_tree_traverse.md index b22704a2..fff972a8 100644 --- a/docs/05_tree/05_02_binary_tree_traverse.md +++ b/docs/05_tree/05_02_binary_tree_traverse.md @@ -1,312 +1,448 @@ ## 1. 二叉树的遍历简介 -> **二叉树的遍历**:指的是从根节点出发,按照某种次序依次访问二叉树中所有节点,使得每个节点被访问一次且仅被访问一次。 +> **二叉树的遍历**:是指从根节点出发,按照特定顺序依次访问二叉树中的所有节点,确保每个节点被且仅被访问一次。 -在二叉树的一些实际问题中,经常需要按照一定顺序对二叉树中每个节点逐个进行访问一次,用以查找具有某一特点的节点或者全部节点,然后对这些满足要求的节点进行处理。这里所说的「访问」就是指对该节点进行某种操作,例如:依次输出节点的数据信息、统计满足某条件的节点总数等等。 +在实际应用中,常常需要按照一定的顺序访问二叉树的每个节点,以便查找特定节点或处理全部节点。例如,可以依次输出节点的值、统计满足某条件的节点数量等。这里的「访问」通常指对节点执行某种操作。 -回顾二叉树的递归定义可以知道,二叉树是由根节点和左子树、右子树构成的。因此,如果能依次遍历这 $3$ 个部分,就可以遍历整个二叉树。 +根据二叉树的递归结构是由根节点、左子树和右子树组成的,只要依次遍历这三部分,就能遍历整棵二叉树。 -如果利用深度优先搜索的方式,并且根据访问顺序次序的不同,我们可以分为 $6$ 种遍历方式,而如果限制先左子树后右子树的遍历顺序,则总共有 $3$ 种遍历方式:分别为 **「二叉树的前序遍历」**、**「二叉树的中序遍历」** 和 **「二叉树的后续遍历」**。 +按照遍历顺序的不同,二叉树的遍历方式主要分为两大类: -而如果使用广度优先搜索的方式,则可以按照层序方式(按照层次从上至下,每一层从左至右)对二叉树进行遍历,这种方式叫做 **「二叉树的层序遍历」**。 +- **深度优先遍历(DFS)**:根据节点访问顺序的不同,理论上有 $6$ 种遍历方式。若约定先遍历左子树再遍历右子树,常用的有 $3$ 种:**前序遍历**、**中序遍历**、**后序遍历**。 +- **广度优先遍历(BFS)**:按照层次自上而下、每层从左到右依次访问所有节点,称为 **层序遍历**。 -## 2. 二叉树的前序遍历 +这些遍历方式为二叉树的各种操作和算法奠定了基础。 -> 二叉树的前序遍历规则为: -> -> - 如果二叉树为空,则返回。 -> - 如果二叉树不为空,则: -> 1. 访问根节点。 -> 2. 以前序遍历的方式遍历根节点的左子树。 -> 3. 以前序遍历的方式遍历根节点的右子树。 +## 2. 二叉树前序遍历 + +> **二叉树前序遍历(Preorder Traversal)**:是指按照「根节点 → 左子树 → 右子树」的顺序依次访问二叉树的所有节点。 + +具体规则如下: -从二叉树的前序遍历规则可以看出:前序遍历过程是一个递归过程。在遍历任何一棵子树时仍然是按照先访问根节点,然后遍历子树根节点的左子树,最后再遍历子树根节点的右子树的顺序进行遍历。 +- 如果二叉树为空,直接返回; +- 如果二叉树非空,则: +> 1. 访问根节点; +> 2. 递归前序遍历左子树; +> 3. 递归前序遍历右子树。 -如下图所示,该二叉树的前序遍历顺序为:$A - B - D - H - I - E - C - F - J - G - K$。 +前序遍历本质上是一个递归过程。无论遍历哪一棵子树,始终遵循「先访问根节点,再遍历左子树,最后遍历右子树」的顺序。 + +如下图所示,该二叉树的前序遍历结果为:$A - B - D - H - I - E - C - F - J - G - K$。 ![二叉树的前序遍历](https://qcdn.itcharge.cn/images/20240511171628.png) -### 2.1 二叉树的前序遍历递归实现 +### 2.1 二叉树前序遍历的递归实现 -二叉树的前序遍历递归实现步骤为: +二叉树前序遍历递归实现的基本步骤: -1. 判断二叉树是否为空,为空则直接返回。 -2. 先访问根节点。 -3. 然后递归遍历左子树。 -4. 最后递归遍历右子树。 +1. 如果当前节点为空,直接返回; +2. 访问当前节点(根节点); +3. 递归遍历左子树; +4. 递归遍历右子树。 -二叉树的前序遍历递归实现代码如下: +前序遍历的递归实现代码如下: ```python class Solution: def preorderTraversal(self, root: TreeNode) -> List[int]: - res = [] - - def preorder(root): - if not root: - return - res.append(root.val) - preorder(root.left) - preorder(root.right) - - preorder(root) + """ + 二叉树的前序遍历(递归实现) + 参数: + root: TreeNode,二叉树的根节点 + 返回: + List[int],前序遍历的节点值列表 + """ + res = [] # 用于存储遍历结果 + + def preorder(node): + if not node: + return # 递归终止条件:节点为空 + res.append(node.val) # 1. 访问根节点 + preorder(node.left) # 2. 递归遍历左子树 + preorder(node.right) # 3. 递归遍历右子树 + + preorder(root) # 从根节点开始递归 return res ``` -### 2.2 二叉树的前序遍历显式栈实现 +### 2.2 二叉树前序遍历的非递归实现 -二叉树的前序遍历递归实现的过程,实际上就是调用系统栈的过程。我们也可以使用一个显式栈 $stack$ 来模拟递归的过程。 +递归实现前序遍历时,实际上是借助系统调用栈来完成的。我们同样可以用一个显式栈 $stack$ 来手动模拟递归过程,实现前序遍历。 -前序遍历的顺序为:根节点 - 左子树 - 右子树,而根据栈的「先入后出」特点,所以入栈的顺序应该为:先放入右子树,再放入左子树。这样可以保证最终遍历顺序为前序遍历顺序。 +前序遍历的访问顺序为:根节点 → 左子树 → 右子树。由于栈具有「后进先出」的特性,为了保证遍历顺序正确,入栈时应先将右子节点压入,再将左子节点压入,这样弹出时会先访问左子树,再访问右子树。 -二叉树的前序遍历显式栈实现步骤如下: +具体实现步骤如下: -1. 判断二叉树是否为空,为空则直接返回。 -2. 初始化维护一个栈,将根节点入栈。 -3. 当栈不为空时: - 1. 弹出栈顶元素 $node$,并访问该元素。 - 2. 如果 $node$ 的右子树不为空,则将 $node$ 的右子树入栈。 - 3. 如果 $node$ 的左子树不为空,则将 $node$ 的左子树入栈。 +1. 如果二叉树为空,直接返回。 +2. 初始化一个栈,将根节点压入栈中。 +3. 当栈不为空时,重复以下操作: + 1. 弹出栈顶节点 $node$,访问该节点。 + 2. 如果 $node$ 的右子节点存在,则将其压入栈中。 + 3. 如果 $node$ 的左子节点存在,则将其压入栈中。 - 二叉树的前序遍历显式栈实现代码如下: +这样即可实现前序遍历的非递归(显式栈)写法。 ```python class Solution: def preorderTraversal(self, root: Optional[TreeNode]) -> List[int]: - if not root: # 二叉树为空直接返回 + """ + 二叉树的前序遍历(非递归/显式栈实现) + 参数: + root: Optional[TreeNode],二叉树的根节点 + 返回: + List[int],前序遍历的节点值列表 + """ + if not root: # 特判:二叉树为空,直接返回空列表 return [] - - res = [] - stack = [root] - - while stack: # 栈不为空 - node = stack.pop() # 弹出根节点 - res.append(node.val) # 访问根节点 - if node.right: - stack.append(node.right) # 右子树入栈 - if node.left: - stack.append(node.left) # 左子树入栈 - return res + res = [] # 用于存储遍历结果 + stack = [root] # 初始化栈,根节点先入栈 + + while stack: # 当栈不为空时循环 + node = stack.pop() # 弹出栈顶节点 + res.append(node.val) # 访问当前节点(根节点) + # 注意:先右后左,保证左子树先被遍历 + if node.right: # 如果右子节点存在,先将其入栈 + stack.append(node.right) + if node.left: # 如果左子节点存在,再将其入栈 + stack.append(node.left) + + return res # 返回前序遍历结果 ``` -## 3. 二叉树的中序遍历 +## 3. 二叉树中序遍历 -> 二叉树的中序遍历规则为: +> **二叉树中序遍历(Inorder Traversal)** 的基本规则如下: > -> - 如果二叉树为空,则返回。 -> - 如果二叉树不为空,则: -> 1. 以中序遍历的方式遍历根节点的左子树。 -> 2. 访问根节点。 -> 3. 以中序遍历的方式遍历根节点的右子树。 +> - 如果二叉树为空,直接返回。 +> - 如果二叉树非空,则依次执行: +> 1. 递归遍历左子树(中序方式); +> 2. 访问当前根节点; +> 3. 递归遍历右子树(中序方式)。 -从二叉树的中序遍历规则可以看出:中序遍历过程也是一个递归过程。在遍历任何一棵子树时仍然是按照先遍历子树根节点的左子树,然后访问根节点,最后再遍历子树根节点的右子树的顺序进行遍历。 +中序遍历本质上是一个递归过程。无论遍历哪一棵子树,始终遵循「先左子树,后根节点,最后右子树」的顺序。每到一个节点,先深入其左子树,左子树遍历完毕后访问该节点本身,最后再遍历其右子树。 -如下图所示,该二叉树的中序遍历顺序为:$H - D - I - B - E - A - F - J - C - K - G$。 +如下图所示,该二叉树的中序遍历结果为:$H - D - I - B - E - A - F - J - C - K - G$。 ![二叉树的中序遍历](https://qcdn.itcharge.cn/images/20240511171643.png) -### 3.1 二叉树的中序遍历递归实现 +### 3.1 二叉树中序遍历的递归实现 -二叉树的中序遍历递归实现步骤为: +二叉树的序遍历递归实现的基本步骤: -1. 判断二叉树是否为空,为空则直接返回。 -2. 先递归遍历左子树。 -3. 然后访问根节点。 -4. 最后递归遍历右子树。 +1. 如果当前节点为空,直接返回。 +2. 递归遍历左子树。 +3. 访问当前节点。 +4. 递归遍历右子树。 -二叉树的中序遍历递归实现代码如下: +对应的递归实现代码如下: ```python class Solution: def inorderTraversal(self, root: TreeNode) -> List[int]: - res = [] - def inorder(root): - if not root: - return - inorder(root.left) - res.append(root.val) - inorder(root.right) - - inorder(root) - return res + """ + 二叉树中序遍历(递归实现) + + 参数: + root: TreeNode,二叉树的根节点 + 返回: + List[int],中序遍历的节点值列表 + """ + res = [] # 用于存储遍历结果 + + def inorder(node): + if not node: + return # 递归终止条件:节点为空 + inorder(node.left) # 递归遍历左子树 + res.append(node.val) # 访问当前节点 + inorder(node.right) # 递归遍历右子树 + + inorder(root) # 从根节点开始递归 + return res # 返回中序遍历结果 ``` -### 3.2 二叉树的中序遍历显式栈实现 - -我们可以使用一个显式栈 $stack$ 来模拟二叉树的中序遍历递归的过程。 +### 3.2 二叉树中序遍历的非递归实现 -与前序遍历不同,访问根节点要放在左子树遍历完之后。因此我们需要保证:**在左子树访问之前,当前节点不能提前出栈**。 +我们可以通过显式维护一个栈 $stack$,来模拟递归实现的中序遍历过程。 -我们应该从根节点开始,循环遍历左子树,不断将当前子树的根节点放入栈中,直到当前节点无左子树时,从栈中弹出该节点并进行处理。 +与前序遍历不同,中序遍历要求在访问根节点前,必须先遍历完其左子树。因此,**只有在左子树全部入栈后,当前节点才能出栈并被访问**。 -然后再访问该元素的右子树,并进行上述循环遍历左子树的操作。这样可以保证最终遍历顺序为中序遍历顺序。 +具体做法是:从根节点出发,不断将当前节点压入栈中,并向左移动,直到没有左子节点为止。此时弹出栈顶节点,访问该节点,然后转向其右子树,重复上述过程。这样可以确保遍历顺序严格按照「左-根-右」进行。 -二叉树的中序遍历显式栈实现步骤如下: +中序遍历的非递归(显式栈)实现步骤如下: -1. 判断二叉树是否为空,为空则直接返回。 -2. 初始化维护一个空栈。 -3. 当根节点或者栈不为空时: - 1. 如果当前节点不为空,则循环遍历左子树,并不断将当前子树的根节点入栈。 - 1. 如果当前节点为空,说明当前节点无左子树,则弹出栈顶元素 $node$,并访问该元素,然后尝试访问该节点的右子树。 +1. 如果二叉树为空,直接返回。 +2. 初始化一个空栈。 +3. 当当前节点不为空或栈不为空时,重复以下操作: + 1. 如果当前节点不为空,不断将其压入栈,并向左移动,直到左子节点为空。 + 2. 如果当前节点为空,说明已到达最左侧,弹出栈顶节点 $node$,访问该节点,然后将当前节点指向 $node$ 的右子节点,继续上述循环。 - 二叉树的中序遍历显式栈实现代码如下: +二叉树中序遍历的非递归实现代码如下: ```python class Solution: def inorderTraversal(self, root: Optional[TreeNode]) -> List[int]: - if not root: # 二叉树为空直接返回 - return [] - - res = [] - stack = [] - - while root or stack: # 根节点或栈不为空 - while root: - stack.append(root) # 将当前树的根节点入栈 - root = root.left # 找到最左侧节点 - - node = stack.pop() # 遍历到最左侧,当前节点无左子树时,将最左侧节点弹出 - res.append(node.val) # 访问该节点 - root = node.right # 尝试访问该节点的右子树 + """ + 二叉树中序遍历(非递归/显式栈实现) + + 参数: + root: Optional[TreeNode],二叉树的根节点 + 返回: + List[int],中序遍历的节点值列表 + """ + res = [] # 用于存储遍历结果 + stack = [] # 显式栈,用于模拟递归过程 + cur = root # 当前遍历的节点指针 + + while cur or stack: # 只要当前节点不为空或栈不为空就继续 + # 不断向左子树深入,将沿途节点全部入栈 + while cur: + stack.append(cur) # 当前节点入栈 + cur = cur.left # 继续遍历左子树 + + # 此时已到达最左侧,弹出栈顶节点 + node = stack.pop() # 弹出最左侧节点 + res.append(node.val) # 访问该节点(中序遍历的“根”) + cur = node.right # 转向右子树,继续上述过程 + return res ``` -## 4. 二叉树的后序遍历 +## 4. 二叉树后序遍历 -> 二叉树的后序遍历规则为: +> **二叉树后序遍历(Postorder Traversal)** 的基本规则如下: > -> - 如果二叉树为空,则返回。 -> - 如果二叉树不为空,则: -> 1. 以后序遍历的方式遍历根节点的左子树。 -> 2. 以后序遍历的方式遍历根节点的右子树。 +> - 如果二叉树为空,直接返回。 +> - 如果二叉树非空,则依次执行: +> 1. 递归遍历左子树(后序方式)。 +> 2. 递归遍历右子树(后序方式)。 > 3. 访问根节点。 -从二叉树的后序遍历规则可以看出:后序遍历过程也是一个递归过程。在遍历任何一棵子树时仍然是按照先遍历子树根节点的左子树,然后遍历子树根节点的右子树,最后再访问根节点的顺序进行遍历。 +后序遍历的本质是递归地先处理左子树,再处理右子树,最后处理根节点。无论遍历到哪一棵子树,始终遵循「左-右-根」的顺序。 -如下图所示,该二叉树的后序遍历顺序为:$H - I - D - E - B - J - F - K - G - C - A$。 +如下图所示,该二叉树的后序遍历结果为:$H - I - D - E - B - J - F - K - G - C - A$。 ![二叉树的后序遍历](https://qcdn.itcharge.cn/images/20240511171658.png) -### 4.1 二叉树的后序遍历递归实现 +### 4.1 二叉树后序遍历的递归实现 -二叉树的后序遍历递归实现步骤为: +后序遍历递归实现的核心思想是:对于每个节点,先处理其左子树,再处理右子树,最后访问节点本身。具体步骤如下: -1. 判断二叉树是否为空,为空则直接返回。 -2. 先递归遍历左子树。 -3. 然后递归遍历右子树。 -4. 最后访问根节点。 +1. 如果当前节点为空,直接返回。 +2. 递归遍历左子树。 +3. 递归遍历右子树。 +4. 访问当前节点(即处理节点值)。 -二叉树的后序遍历递归实现代码如下: +下面是二叉树后序遍历的递归实现代码: ```python class Solution: def postorderTraversal(self, root: TreeNode) -> List[int]: - res = [] - def postorder(root): - if not root: + """ + 二叉树后序遍历(递归实现) + 参数: + root: TreeNode,二叉树的根节点 + 返回: + List[int],后序遍历的节点值列表 + """ + res = [] # 用于存储遍历结果 + + def postorder(node): + if not node: return - postorder(root.left) - postorder(root.right) - res.append(root.val) + # 递归遍历左子树 + postorder(node.left) + # 递归遍历右子树 + postorder(node.right) + # 访问当前节点 + res.append(node.val) postorder(root) return res ``` -### 4.2 二叉树的后序遍历显式栈实现 +### 4.2 二叉树后序遍历的非递归实现 -我们可以使用一个显式栈 $stack$ 来模拟二叉树的后序遍历递归的过程。 +后序遍历可以通过显式栈 $stack$ 来模拟递归过程。与前序和中序遍历不同,后序遍历要求在左右子树都访问完成后,才能访问根节点。因此,必须确保:**当前节点在其左右孩子节点都访问完毕之前不能出栈**。 -与前序、中序遍历不同,在后序遍历中,根节点的访问要放在左右子树访问之后。因此,我们要保证:**在左右孩子节点访问结束之前,当前节点不能提前出栈**。 +后序遍历的非递归实现可以通过如下方式优化理解: -我们应该从根节点开始,先将根节点放入栈中,然后依次遍历左子树,不断将当前子树的根节点放入栈中,直到遍历到左子树最左侧的那个节点,从栈中弹出该元素,并判断该元素的右子树是否已经访问完毕,如果访问完毕,则访问该元素。如果未访问完毕,则访问该元素的右子树。 +- 从根节点出发,将其依次压入栈中,并不断向左深入,直到到达最左侧节点。 +- 每次弹出栈顶节点,判断其右子树是否已被访问: + - 如果已访问,则访问该节点; + - 如果未访问,则将该节点重新压入栈,并转而遍历其右子树。 + +具体步骤如下: -二叉树的后序遍历显式栈实现步骤如下: +1. 如果二叉树为空,直接返回。 +2. 初始化一个空栈 $stack$,并用 $prev$ 记录上一个访问的节点。 +3. 当当前节点不为空或栈不为空时,循环执行: + 1. 不断将当前节点压入栈,并向左移动,直到最左侧节点。 + 2. 弹出栈顶节点 $node$。 + 3. 若 $node$ 没有右子树,或右子树已被访问,则访问 $node$,更新 $prev$,并将当前节点设为空。 + 4. 否则,将 $node$ 重新压回栈,转而遍历其右子树。 -1. 判断二叉树是否为空,为空则直接返回。 -2. 初始化维护一个空栈,使用 $prev$ 保存前一个访问的节点,用于确定当前节点的右子树是否访问完毕。 -3. 当根节点或者栈不为空时,从当前节点开始: - 1. 如果当前节点有左子树,则不断遍历左子树,并将当前根节点压入栈中。 - 2. 如果当前节点无左子树,则弹出栈顶元素 $node$。 - 2. 如果栈顶元素 $node$ 无右子树(即 `not node.right`)或者右子树已经访问完毕(即 `node.right == prev`),则访问该元素,然后记录前一节点,并将当前节点标记为空节点。 - 2. 如果栈顶元素有右子树,则将栈顶元素重新压入栈中,继续访问栈顶元素的右子树。 +这样即可实现二叉树的后序遍历非递归写法。 ```python class Solution: def postorderTraversal(self, root: Optional[TreeNode]) -> List[int]: - res = [] - stack = [] - prev = None # 保存前一个访问的节点,用于确定当前节点的右子树是否访问完毕 - - while root or stack: # 根节点或栈不为空 + """ + 二叉树后序遍历(非递归/显式栈实现) + 参数: + root: Optional[TreeNode],二叉树的根节点 + 返回: + List[int],后序遍历的节点值列表 + """ + res = [] # 用于存储遍历结果 + stack = [] # 显式栈,用于模拟递归过程 + prev = None # 记录上一个访问的节点,用于判断右子树是否已访问 + + while root or stack: # 只要当前节点不为空或栈不为空就继续遍历 + # 一直向左走,将所有左子节点入栈 while root: - stack.append(root) # 将当前树的根节点入栈 - root = root.left # 继续访问左子树,找到最左侧节点 + stack.append(root) # 当前节点入栈 + root = root.left # 继续遍历左子树 - node = stack.pop() # 遍历到最左侧,当前节点无左子树时,将最左侧节点弹出 + node = stack.pop() # 弹出栈顶节点,准备访问或遍历其右子树 - # 如果当前节点无右子树或者右子树访问完毕 + # 判断是否可以访问当前节点 + # 1. 没有右子树 + # 2. 右子树已经访问过(即上一次访问的节点是当前节点的右子节点) if not node.right or node.right == prev: - res.append(node.val)# 访问该节点 - prev = node # 记录前一节点 - root = None # 将当前根节点标记为空 + res.append(node.val) # 访问当前节点 + prev = node # 更新上一次访问的节点 + root = None # 当前节点已访问,重置root,防止重复入栈 else: - stack.append(node) # 右子树尚未访问完毕,将当前节点重新压回栈中 - root = node.right # 继续访问右子树 - + # 右子树还未访问,当前节点重新入栈,转而遍历右子树 + stack.append(node) + root = node.right + return res ``` -## 5. 二叉树的层序遍历 +## 5. 二叉树层序遍历 -> 二叉树的层序遍历规则为: +> **二叉树层序遍历**(Level Order Traversal)的基本规则为:指按照从上到下、从左到右的顺序,逐层依次访问二叉树的所有节点。 > -> - 如果二叉树为空,则返回。 -> - 如果二叉树不为空,则: -> 1. 先依次访问二叉树第 $1$ 层的节点。 -> 2. 然后依次访问二叉树第 $2$ 层的节点。 -> 3. …… -> 4. 依次下去,最后依次访问二叉树最下面一层的节点。 +> - 如果二叉树为空,直接返回。 +> - 如果二叉树非空,则: +> 1. 先访问第 $1$ 层(根节点); +> 2. 再访问第 $2$ 层的所有节点; +> 3. 依次类推,直到访问到最底层的所有节点。 -从二叉树的层序遍历规则可以看出:遍历过程是一个广度优先搜索过程。在遍历的时候是按照第 $1$ 层、第 $2$ 层、…… 最后一层依次遍历的,而同一层节点则是按照从左至右的顺序依次访问的。 +层序遍历本质上是一种广度优先搜索(BFS)过程。遍历时,先访问每一层的所有节点,再进入下一层,并且同一层的节点总是从左到右依次访问。 -如下图所示,该二叉树的后序遍历顺序为:$A - B - C - D - E - F - G - H - I - J - K$。 +如下图所示,该二叉树的层序遍历结果为:$A - B - C - D - E - F - G - H - I - J - K$。 ![二叉树的层序遍历](https://qcdn.itcharge.cn/images/20240511175431.png) -二叉树的层序遍历是通过队列来实现的。具体步骤如下: +层序遍历通常借助队列(Queue)来实现。具体流程如下: -1. 判断二叉树是否为空,为空则直接返回。 -2. 令根节点入队。 -3. 当队列不为空时,求出当前队列长度 $s_i$。 -4. 依次从队列中取出这 $s_i$ 个元素,并对这 $s_i$ 个元素依次进行访问。然后将其左右孩子节点入队,然后继续遍历下一层节点。 -5. 当队列为空时,结束遍历。 +1. 如果二叉树为空,直接返回。 +2. 将根节点加入队列。 +3. 当队列不为空时,重复以下操作: + 1. 记录当前队列长度 $s_i$(即当前层的节点数)。 + 2. 依次从队列中取出这 $s_i$ 个节点,访问它们,并将它们的左右子节点(如存在)加入队列。 +4. 队列为空时,遍历结束。 -二叉树的层序遍历代码实现如下: +二叉树层序遍历的代码实现如下: ```python class Solution: def levelOrder(self, root: TreeNode) -> List[List[int]]: + """ + 二叉树层序遍历(广度优先搜索,BFS) + 返回每一层的节点值组成的二维列表 + """ if not root: - return [] - queue = [root] - order = [] + return [] # 空树直接返回空列表 + + from collections import deque # 推荐使用 deque 提高队列效率 + queue = deque([root]) # 初始化队列,根节点入队 + order = [] # 用于存储最终结果 + while queue: - level = [] - size = len(queue) + level = [] # 存储当前层的节点值 + size = len(queue) # 当前层的节点数量 for _ in range(size): - curr = queue.pop(0) - level.append(curr.val) + curr = queue.popleft() # 弹出队首节点 + level.append(curr.val) # 访问当前节点 if curr.left: - queue.append(curr.left) + queue.append(curr.left) # 左子节点入队 if curr.right: - queue.append(curr.right) + queue.append(curr.right) # 右子节点入队 if level: - order.append(level) + order.append(level) # 当前层结果加入总结果 + return order ``` +## 6. 总结 + +### 6.1 算法特点对比 + +| 遍历方式 | 访问顺序 | 递归实现 | 非递归实现 | 空间复杂度 | 时间复杂度 | +|---------|---------|---------|-----------|-----------|-----------| +| **前序遍历** | 根 → 左 → 右 | 简单直观 | 使用栈,先右后左入栈 | O(h) | O(n) | +| **中序遍历** | 左 → 根 → 右 | 简单直观 | 使用栈,先左后右 | O(h) | O(n) | +| **后序遍历** | 左 → 右 → 根 | 简单直观 | 使用栈,需要标记访问状态 | O(h) | O(n) | +| **层序遍历** | 按层从左到右 | 不适用 | 使用队列,BFS思想 | O(w) | O(n) | + +> 注:h 为树的高度,w 为树的最大宽度,n 为节点总数 + +### 6.2 优缺点分析 + +#### 前序遍历 +- **优点**: + - 递归实现简单直观,易于理解 + - 适合需要先处理根节点再处理子节点的场景 + - 常用于树的复制、序列化等操作 +- **缺点**: + - 非递归实现需要特别注意入栈顺序 + - 对于深度很大的树,递归可能导致栈溢出 + +#### 中序遍历 +- **优点**: + - 对于二叉搜索树,中序遍历得到有序序列 + - 递归实现逻辑清晰 + - 适合需要按顺序处理节点的场景 +- **缺点**: + - 非递归实现相对复杂 + - 需要理解"左-根-右"的访问时机 + +#### 后序遍历 +- **优点**: + - 适合需要先处理子节点再处理父节点的场景 + - 常用于树的删除、后序表达式计算等 + - 递归实现简单 +- **缺点**: + - 非递归实现最复杂,需要额外的访问状态标记 + - 理解难度较高 + +#### 层序遍历 +- **优点**: + - 直观反映树的层次结构 + - 适合需要按层处理节点的场景 + - 非递归实现相对简单 +- **缺点**: + - 不适用于递归实现 + - 空间复杂度可能较高(对于宽树) + +### 6.3 适用场景 + +- **前序遍历**:树的复制、序列化、前缀表达式计算 +- **中序遍历**:二叉搜索树的有序遍历、中缀表达式计算 +- **后序遍历**:树的删除、后缀表达式计算、计算树的高度 +- **层序遍历**:按层打印树、计算树的宽度、BFS相关算法 + +### 6.4 实现建议 + +1. **递归实现**:代码简洁,易于理解,适合面试和教学 +2. **非递归实现**:性能更好,避免栈溢出,适合生产环境 +3. **选择原则**:根据具体需求选择合适的遍历方式,考虑时间复杂度和空间复杂度 +4. **优化技巧**:使用双端队列提高层序遍历效率,合理使用栈和队列数据结构 + ## 练习题目 - [0144. 二叉树的前序遍历](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0100-0199/binary-tree-preorder-traversal.md) diff --git a/docs/05_tree/05_03_binary_tree_reduction.md b/docs/05_tree/05_03_binary_tree_reduction.md index 4803395d..25f4d113 100644 --- a/docs/05_tree/05_03_binary_tree_reduction.md +++ b/docs/05_tree/05_03_binary_tree_reduction.md @@ -1,164 +1,247 @@ ## 1. 二叉树的还原简介 -> **二叉树的还原**:指的是通过二叉树的遍历序列,还原出对应的二叉树。 +> **二叉树的还原**:指通过已知的二叉树遍历序列,重建出原始的二叉树结构。 -从二叉树的遍历过程可以看出,给定一棵非空二叉树,它的前序、中序、后续遍历所得到的遍历序列都是唯一的。那么反过来,如果已知节点的某种遍历序列,能否确定这棵二叉树呢?并且确定的二叉树是否是唯一的呢? +我们知道,对于一棵非空二叉树,其前序、中序、后序遍历序列都是唯一的。但反过来,如果只给出某一种遍历序列,是否能唯一确定这棵二叉树呢?答案是否定的。 -我们先来回顾一下二叉树的前序遍历、中序遍历、后序遍历规则。 +### 1.1 单一遍历序列的还原能力 -- 非空二叉树的前序遍历规则: - 1. 访问根节点。 - 2. 以前序遍历的方式遍历根节点的左子树。 - 3. 以前序遍历的方式遍历根节点的右子树。 -- 非空二叉树的中序遍历规则: - 1. 以中序遍历的方式遍历根节点的左子树。 - 2. 访问根节点。 - 3. 以中序遍历的方式遍历根节点的右子树。 -- 非空二叉树的后序遍历规则: - 1. 以后序遍历的方式遍历根节点的左子树。 - 2. 以后序遍历的方式遍历根节点的右子树。 - 3. 访问根节点。 +- **前序遍历**:第一个节点必为根节点,但无法区分后续节点属于左子树还是右子树,因此仅凭前序序列无法还原二叉树。 +- **中序遍历**:虽然根节点能将中序序列分为左右子树,但无法确定根节点是谁,因此仅凭中序序列也无法还原二叉树。 +- **后序遍历**:最后一个节点必为根节点,但同样无法判断其他节点的归属,仅凭后序序列也无法还原二叉树。 -先来看二叉树的前序遍历,前序遍历过程中首先访问的是根节点,所以通过前序遍历序列,我们可以确定序列的第 $1$ 个节点肯定是根节点。但是从第 $2$ 个节点开始就不确定它是根节点的左子树还是根节点的右子树了。所以单凭前序遍历序列是无法恢复一棵二叉树的。 +### 1.2 两种遍历序列的组合情况 -再来看二叉树的后序遍历,后序遍历也是只能确定序列的最后一个节点为根节点,而无法确定其他节点在二叉树中的位置。所以单凭后序遍历序列也是无法恢复一棵二叉树的。 +- **前序 + 中序**:前序序列确定根节点,中序序列确定左右子树的范围。递归分割子序列,可以唯一还原原二叉树。 +- **中序 + 后序**:后序序列确定根节点,中序序列确定左右子树的范围,方法与前序+中序类似,也能唯一还原二叉树。 +- **中序 + 层序**:通过层序遍历确定每个子树的根节点,再结合中序遍历分割左右子树,也可以唯一还原二叉树。 -最后我们来看二叉树的中序遍历,中序遍历是先遍历根节点的左子树,然后访问根节点,最后遍历根节点的右子树。这样,根节点在中序遍历序列中必然将中序序列分割成前后两个子序列,其中前一个子序列是根节点的左子树的中序遍历序列,后一个子序列是根节点的右子树的中序遍历序列。当然单凭中序遍历序列也是无法恢复一棵二叉树的。 +### 1.3 不能唯一还原的特殊情况 -但是如果我们可以将「前序遍历序列」和「中序遍历序列」相结合,那么我们就可以通过上面中序遍历序列中的两个子序列,在前序遍历序列中找到对应的左子序列和右子序列。在前序遍历序列中,左子序列的第 $1$ 个节点是左子树的根节点,右子序列的第 $1$ 个节点是右子树的根节点。这样,就确定了二叉树的 $3$ 个节点。 +- **前序 + 后序**:仅有前序和后序遍历序列时,无法唯一确定二叉树结构。因为缺少中序信息,无法区分左右子树的分界。例如,如果存在度为 $1$ 的节点,无法判断该节点是左子树还是右子树。 +- **特殊说明**:只有当二叉树中每个节点的度均为 $2$ 或 $0$(即满二叉树)时,前序和后序遍历序列才能唯一确定二叉树。若存在度为 $1$ 的节点,则无法唯一还原。 -同时,左子树和右子树的根节点在中序遍历序列中又可以将左子序列和右子序列分别划分成两个子序列。如此递归下去,当确定了前序遍历序列中的所有节点时,我们就得到了一棵二叉树。 +**结论**: +- 已知「前序+中序」、「中序+后序」、「中序+层序」任意一组遍历序列,可以唯一还原一棵二叉树。 +- 仅有「前序+后序」遍历序列,通常无法唯一还原二叉树,除非二叉树为满二叉树。 -还有一个问题,通过前序序列和中序序列还原的二叉树是唯一的吗? +## 2. 利用前序与中序遍历序列重建二叉树 -这个唯一性可以利用归纳法加以证明。感兴趣的读者可以试试自己证明或者参考有关资料。 +- **描述**:给定一棵二叉树的前序遍历序列和中序遍历序列。 +- **目标**:重建出原始的二叉树结构。 +- **说明**:树中所有节点值均不重复。 -通过上述过程说明:**如果已知一棵二叉树的前序序列和中序序列,可以唯一地确定这棵二叉树。** +### 2.1 实现思路与步骤 -同理,**如果已知一棵二叉树的中序序列和后序序列,也可以唯一地确定这棵二叉树。** 方法和通过二叉树的前序序列和中序序列构造二叉树类似,唯一不同点在于二叉树的根节点是根据后序遍历序列的最后一个元素确定的。 +前序遍历顺序为:根节点 → 左子树 → 右子树; +中序遍历顺序为:左子树 → 根节点 → 右子树。 -类似的,**已知二叉树的「中序遍历序列」和「层序遍历序列」,也可以唯一地确定一棵二叉树。** +基于上述规律,可以通过以下方式递归重建二叉树: -需要注意的是:**如果已知二叉树的「前序遍历序列」和「后序遍历序列」,是不能唯一地确定一棵二叉树的。** 这是因为没有中序遍历序列无法确定左右部分,也就无法进行子序列的分割。 +1. 前序遍历序列的第一个元素即为当前子树的根节点。 +2. 在中序遍历序列中查找该根节点的位置 $inorder[k]$,据此将中序序列分为左、右子树两部分,并确定左右子树的节点数量。 +3. 利用左右子树节点数量,将前序遍历序列切分为左、右子树对应的部分。 +4. 递归构建当前根节点的左、右子树,直到子树为空(序列长度为0)为止。 -只有二叉树中每个节点度为 $2$ 或者 $0$ 的时候,已知前序遍历序列和后序遍历序列,才能唯一地确定一颗二叉树,如果二叉树中存在度为 $1$ 的节点时是无法唯一地确定一棵二叉树的,这是因为我们无法判断该节点是左子树还是右子树。 +简要流程如下: -## 2. 从前序与中序遍历序列构造二叉树 +- 取前序序列首元素作为根节点。 +- 在中序序列中定位根节点,分割出左、右子树的中序区间。 +- 根据左子树节点数,切分前序序列为左、右子树区间。 +- 递归处理左右子树,直至区间为空。 -- **描述**:已知一棵二叉树的前序遍历序列和中序遍历序列。 -- **要求**:构造出该二叉树。 -- **注意**:假设树中没有重复的元素。 - -### 2.1 从前序与中序遍历序列构造二叉树实现过程 - -前序遍历的顺序是:根节点 - 左子树 - 右子树。中序遍历的顺序是:左子树 - 根节点 - 右子树。 - -根据前序遍历的顺序,可以找到根节点位置。然后在中序遍历的结果中可以找到对应的根节点位置,就可以从根节点位置将二叉树分割成左子树、右子树。同时能得到左右子树的节点个数。 - -此时构建当前节点,并递归建立左右子树,在左右子树对应位置继续递归遍历进行上述步骤,直到节点为空,具体操作步骤如下: - -1. 从前序遍历顺序中得到当前根节点的位置在 $postorder[0]$。 -2. 通过在中序遍历中查找上一步根节点对应的位置 $inorder[k]$,从而将二叉树的左右子树分隔开,并得到左右子树节点的个数。 -3. 从上一步得到的左右子树个数将前序遍历结果中的左右子树分开。 -4. 构建当前节点,并递归建立左右子树,在左右子树对应位置继续递归遍历并执行上述三步,直到节点为空。 - -### 2.2 从前序与中序遍历序列构造二叉树实现代码 +### 2.2 代码实现 ```python class Solution: def buildTree(self, preorder: List[int], inorder: List[int]) -> TreeNode: + """ + 根据前序遍历和中序遍历序列重建二叉树 + + 参数: + preorder: List[int],二叉树的前序遍历序列 + inorder: List[int],二叉树的中序遍历序列 + 返回: + TreeNode,重建后的二叉树根节点 + """ def createTree(preorder, inorder, n): + """ + 递归构建二叉树 + + 参数: + preorder: 当前子树的前序遍历序列 + inorder: 当前子树的中序遍历序列 + n: 当前子树的节点数 + 返回: + TreeNode,当前子树的根节点 + """ if n == 0: - return None + return None # 递归终止条件:子树节点数为 0 + # 在中序遍历中查找根节点位置 k = 0 while preorder[0] != inorder[k]: k += 1 + # 创建根节点 node = TreeNode(inorder[k]) + # 递归构建左子树 node.left = createTree(preorder[1: k + 1], inorder[0: k], k) + # 递归构建右子树 node.right = createTree(preorder[k + 1:], inorder[k + 1:], n - k - 1) return node + + # 从整棵树的前序和中序序列开始递归构建 return createTree(preorder, inorder, len(inorder)) ``` -## 3. 从中序与后序遍历序列构造二叉树 +## 3. 利用中序与后序遍历序列重建二叉树 -- **描述**:已知一棵二叉树的中序遍历序列和后序遍历序列。 -- **要求**:构造出该二叉树。 -- **注意**:假设树中没有重复的元素。 +- **描述**:给定一棵二叉树的中序遍历序列和后序遍历序列。 +- **目标**:重建出原始的二叉树结构。 +- **说明**:树中所有节点值均不重复。 -### 3.1 从中序与后序遍历序列构造二叉树实现过程 +### 3.1 实现思路与步骤 -中序遍历的顺序是:左子树 - 根节点 - 右子树。后序遍历的顺序是:左子树 - 右子树 - 根节点。 +- 中序遍历顺序:左子树 → 根节点 → 右子树 +- 后序遍历顺序:左子树 → 右子树 → 根节点 -根据后序遍历的顺序,可以找到根节点位置。然后在中序遍历的结果中可以找到对应的根节点位置,就可以从根节点位置将二叉树分割成左子树、右子树。同时能得到左右子树的节点个数。 +利用后序遍历的最后一个元素可以确定当前子树的根节点。再在中序遍历序列中定位该根节点,从而划分出左、右子树的中序区间,并据此确定左右子树的节点数量。递归地对左右子树重复上述过程,直到区间为空。 -此时构建当前节点,并递归建立左右子树,在左右子树对应位置继续递归遍历进行上述步骤,直到节点为空,具体操作步骤如下: +具体步骤如下: -1. 从后序遍历顺序中当前根节点的位置在 $postorder[n-1]$。 -2. 通过在中序遍历中查找上一步根节点对应的位置 $inorder[k]$,从而将二叉树的左右子树分隔开,并得到左右子树节点的个数。 -3. 从上一步得到的左右子树个数将后序遍历结果中的左右子树分开。 -4. 构建当前节点,并递归建立左右子树,在左右子树对应位置继续递归遍历并执行上述三步,直到节点为空。 +1. 后序遍历序列的最后一个元素 $postorder[n-1]$ 为当前子树的根节点。 +2. 在中序遍历序列中查找该根节点的位置 $inorder[k]$,据此将中序序列分为左、右子树区间,并确定左右子树的节点数。 +3. 利用左右子树的节点数,将后序遍历序列划分为左、右子树对应的区间。 +4. 构建当前根节点,并递归构建其左、右子树,直到区间为空为止。 -### 3.2 从中序与后序遍历序列构造二叉树实现代码 +### 3.2 代码实现 ```python class Solution: def buildTree(self, inorder: List[int], postorder: List[int]) -> TreeNode: + """ + 根据中序遍历和后序遍历序列重建二叉树 + + 参数: + inorder: List[int],二叉树的中序遍历序列 + postorder: List[int],二叉树的后序遍历序列 + 返回: + TreeNode,重建后的二叉树根节点 + """ def createTree(inorder, postorder, n): + """ + 递归构建二叉树 + + 参数: + inorder: 当前子树的中序遍历序列 + postorder: 当前子树的后序遍历序列 + n: 当前子树的节点数 + 返回: + TreeNode,当前子树的根节点 + """ if n == 0: - return None + return None # 递归终止条件:子树节点数为0,返回空节点 + + # 后序遍历的最后一个元素为当前子树的根节点 + root_val = postorder[n - 1] + # 在中序遍历中查找根节点的位置 k = 0 - while postorder[n - 1] != inorder[k]: + while inorder[k] != root_val: k += 1 - node = TreeNode(inorder[k]) - node.right = createTree(inorder[k + 1: n], postorder[k: n - 1], n - k - 1) - node.left = createTree(inorder[0: k], postorder[0: k], k) + + # 创建根节点 + node = TreeNode(root_val) + # 递归构建左子树 + # 左子树的中序区间:inorder[0:k] + # 左子树的后序区间:postorder[0:k] + node.left = createTree(inorder[0:k], postorder[0:k], k) + # 递归构建右子树 + # 右子树的中序区间:inorder[k+1:n] + # 右子树的后序区间:postorder[k:n-1] + node.right = createTree(inorder[k+1:n], postorder[k:n-1], n - k - 1) return node + + # 从整棵树的中序和后序序列开始递归构建 return createTree(inorder, postorder, len(postorder)) ``` -## 4. 从前序与后序遍历序列构造二叉树 - -前边我们说过:**已知二叉树的前序遍历序列和后序遍历序列,是不能唯一地确定一棵二叉树的。** 而如果不要求构造的二叉树是唯一的,只要求构造出一棵二叉树,还是可以进行构造的。 +## 4. 利用前序与后序遍历序列构造二叉树 -- **描述**:已知一棵二叉树的前序遍历序列和后序遍历序列。 +如前所述,**仅通过二叉树的前序和后序遍历序列,无法唯一确定一棵二叉树。** 但如果不要求唯一性,只需构造出任意一棵符合条件的二叉树,是可以实现的。 -- **要求**:重构并返回该二叉树。 +- **描述**:给定一棵二叉树的前序遍历和后序遍历序列。 +- **目标**:重建并返回该二叉树。 +- **说明**:假设树中节点值各不相同。如果存在多个可行答案,返回其中任意一个即可。 -- **注意**:假设树中没有重复的元素。如果存在多个答案,则可以返回其中任意一个。 +### 4.1 实现思路与步骤 -### 4.1 从前序与后序遍历序列构造二叉树实现过程 +我们可以假定前序遍历序列的第二个元素为左子树的根节点,进而递归划分左右子树。具体步骤如下: -我们可以默认指定前序遍历序列的第 $2$ 个值为左子树的根节点,由此递归划分左右子序列。具体操作步骤如下: +1. 前序遍历的第一个元素 $preorder[0]$ 是当前子树的根节点。 +2. 前序遍历的第二个元素 $preorder[1]$ 是左子树的根节点。我们在后序遍历中查找该节点的位置 $postorder[k]$,该位置左侧为左子树,右侧为右子树。 +3. 由 $k$ 可确定左子树的节点数量,从而划分前序和后序序列的左右子树部分。 +4. 递归构建当前节点的左、右子树,直到子树为空。 -1. 从前序遍历序列中可知当前根节点的位置在 $preorder[0]$。 - -2. 前序遍历序列的第 $2$ 个值为左子树的根节点,即 $preorder[1]$。通过在后序遍历中查找上一步根节点对应的位置 $postorder[k]$(该节点右侧为右子树序列),从而将二叉树的左右子树分隔开,并得到左右子树节点的个数。 - -3. 从上一步得到的左右子树个数将后序遍历结果中的左右子树分开。 - -4. 构建当前节点,并递归建立左右子树,在左右子树对应位置继续递归遍历并执行上述三步,直到节点为空。 - -### 4.2 从前序与后序遍历序列构造二叉树实现代码 +### 4.2 代码实现 ```python class Solution: def constructFromPrePost(self, preorder: List[int], postorder: List[int]) -> TreeNode: + """ + 根据前序和后序遍历序列构造二叉树(不唯一) + 参数: + preorder: List[int],二叉树的前序遍历序列 + postorder: List[int],二叉树的后序遍历序列 + 返回: + TreeNode,重建后的二叉树根节点 + """ def createTree(preorder, postorder, n): if n == 0: - return None - node = TreeNode(preorder[0]) + return None # 递归终止条件:子树节点数为0,返回空节点 + # 前序遍历的第一个元素为当前子树的根节点 + root_val = preorder[0] + node = TreeNode(root_val) if n == 1: - return node + return node # 只有一个节点,直接返回 + # 前序遍历的第二个元素为左子树的根节点 + left_root_val = preorder[1] + # 在后序遍历中查找左子树根节点的位置 k = 0 - while postorder[k] != preorder[1]: + while postorder[k] != left_root_val: k += 1 - node.left = createTree(preorder[1: k + 2], postorder[: k + 1], k + 1) - node.right = createTree(preorder[k + 2: ], postorder[k + 1: -1], n - k - 2) + # k 为左子树在 postorder 中的结尾索引,左子树节点数为 k+1 + # 划分左右子树的前序和后序区间 + # 左子树:preorder[1:k+2], postorder[0:k+1] + # 右子树:preorder[k+2:], postorder[k+1:n-1] + node.left = createTree(preorder[1:k+2], postorder[0:k+1], k+1) + node.right = createTree(preorder[k+2:], postorder[k+1:n-1], n-k-1) return node + # 从整棵树的前序和后序序列开始递归构建 return createTree(preorder, postorder, len(preorder)) ``` +## 5. 总结 + +### 5.1 核心要点 + +二叉树的还原是数据结构中的重要问题,其核心在于 **利用遍历序列的特性来重建树结构**。 + +**关键规律**: +- **前序遍历**:根节点 → 左子树 → 右子树 +- **中序遍历**:左子树 → 根节点 → 右子树 +- **后序遍历**:左子树 → 右子树 → 根节点 + +### 5.2 还原能力对比 + +| 遍历序列组合 | 能否唯一还原 | 说明 | +|-------------|-------------|------| +| 前序 + 中序 | ✅ 可以 | 前序确定根,中序确定左右子树范围 | +| 中序 + 后序 | ✅ 可以 | 后序确定根,中序确定左右子树范围 | +| 中序 + 层序 | ✅ 可以 | 层序确定根,中序确定左右子树范围 | +| 前序 + 后序 | ❌ 不能 | 缺少中序信息,无法区分左右子树 | + + +二叉树的还原是理解树结构遍历特性的重要应用,掌握「前序+中序」和「中序+后序」的还原方法,就能解决大部分二叉树构造问题。 + ## 练习题目 - [0105. 从前序与中序遍历序列构造二叉树](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0100-0199/construct-binary-tree-from-preorder-and-inorder-traversal.md) diff --git a/docs/05_tree/05_04_binary_search_tree.md b/docs/05_tree/05_04_binary_search_tree.md index 8e97b8fa..b84f4408 100644 --- a/docs/05_tree/05_04_binary_search_tree.md +++ b/docs/05_tree/05_04_binary_search_tree.md @@ -1,160 +1,207 @@ ## 1. 二叉搜索树简介 -> **二叉搜索树(Binary Search Tree)**:也叫做二叉查找树、有序二叉树或者排序二叉树。是指一棵空树或者具有下列性质的二叉树: +> **二叉搜索树(Binary Search Tree, BST)**,又称二叉查找树、有序二叉树或排序二叉树,是一种特殊的二叉树结构,满足以下性质: > -> - 如果任意节点的左子树不为空,则左子树上所有节点的值均小于它的根节点的值。 -> - 如果任意节点的右子树不为空,则右子树上所有节点的值均大于它的根节点的值。 -> - 任意节点的左子树、右子树均为二叉搜索树。 +> - 对于任意节点,如果其左子树非空,则左子树所有节点的值均 **小于** 该节点的值; +> - 对于任意节点,如果其右子树非空,则右子树所有节点的值均 **大于** 该节点的值; +> - 任意节点的左右子树也都分别是二叉搜索树(递归定义)。 -如图所示,这 $3$ 棵树都是二叉搜索树。 +下图展示了三棵典型的二叉搜索树: ![二叉搜索树](https://qcdn.itcharge.cn/images/20240511171406.png) -二叉树具有一个特性,即:**左子树的节点值 < 根节点值 < 右子树的节点值**。 +二叉搜索树的核心特性是:**左子树所有节点值 < 根节点值 < 右子树所有节点值**。 -根据这个特性,如果我们以中序遍历的方式遍历整个二叉搜索树时,会得到一个递增序列。例如,一棵二叉搜索树的中序遍历序列如下图所示。 +基于这一特性,若对二叉搜索树进行中序遍历,得到的节点值序列一定是递增的。例如,某棵二叉搜索树的中序遍历结果如下图所示。 ## 2. 二叉搜索树的查找 -> **二叉搜索树的查找**:在二叉搜索树中查找值为 $val$ 的节点。 +> **二叉搜索树查找**:即在二叉搜索树中定位值为 $val$ 的节点。 -### 2.1 二叉搜索树的查找算法步骤 +### 2.1 查找算法思路 -按照二叉搜索树的定义,在进行元素查找时,我们只需要根据情况判断需要往左还是往右走。这样,每次根据情况判断都会缩小查找范围,从而提高查找效率。二叉树的查找步骤如下: +基于二叉搜索树的性质,查找过程可以高效地缩小范围。每次比较后,只需决定向左子树还是右子树继续查找,从而大大提升查找效率。具体步骤如下: -1. 如果二叉搜索树为空,则查找失败,结束查找,并返回空指针节点 $None$。 -2. 如果二叉搜索树不为空,则将要查找的值 $val$ 与二叉搜索树根节点的值 $root.val$ 进行比较: - 1. 如果 $val == root.val$,则查找成功,结束查找,返回被查找到的节点。 - 2. 如果 $val < root.val$,则递归查找左子树。 - 3. 如果 $val > root.val$,则递归查找右子树。 +1. 如果当前二叉搜索树为空,查找失败,返回空指针 $None$。 +2. 如果当前节点不为空,将待查找值 $val$ 与当前节点值 $root.val$ 比较: + - 如果 $val == root.val$,查找成功,返回该节点。 + - 如果 $val < root.val$,递归查找左子树。 + - 如果 $val > root.val$,递归查找右子树。 -### 2.2 二叉搜索树的查找代码实现 +### 2.2 查找算法代码实现 ```python class TreeNode: def __init__(self, val=0, left=None, right=None): - self.val = val - self.left = left - self.right = right + self.val = val # 节点值 + self.left = left # 左子节点 + self.right = right # 右子节点 class Solution: def searchBST(self, root: TreeNode, val: int) -> TreeNode: + """ + 在二叉搜索树中查找值为 val 的节点 + + 参数: + root: TreeNode,二叉搜索树的根节点 + val: int,待查找的目标值 + 返回: + TreeNode,值为 val 的节点,若未找到则返回 None + """ if not root: - return None - + return None # 空树或查找失败,返回 None + if val == root.val: - return root + return root # 找到目标节点,返回 elif val < root.val: + # 目标值小于当前节点值,递归查找左子树 return self.searchBST(root.left, val) else: + # 目标值大于当前节点值,递归查找右子树 return self.searchBST(root.right, val) ``` ### 2.3 二叉搜索树的查找算法分析 -- 二叉搜索树的查找时间复杂度和树的形态有关。 -- 在最好情况下,二叉搜索树的形态与二分查找的判定树相似。每次查找都可以所辖一半搜索范围。查找路径最多从根节点到叶子节点,比较次数最多为树的高度 $\log_2 n$。在最好情况下查找的时间复杂度为 $O(\log_2 n)$。 -- 在最坏情况下,二叉搜索树的形态为单支树,即只有左子树或者只有右子树。每次查找的搜索范围都缩小为 $n - 1$,退化为顺序查找,在最坏情况下时间复杂度为 $O(n)$。 -- 在平均情况下,二叉搜索树的平均查找长度为 $ASL = [(n + 1) / n] * /log_2(n+1) - 1$。所以二分搜索树的查找平均时间复杂度为 $O(log_2 n)$。 +| 指标 | 复杂度 | 说明 | +|--------------|------------------|--------------------------------------------------------------| +| 最优时间 | $O(\log_2 n)$ | 树接近完全平衡,高度为 $h = \log_2 n$,每次查找缩小一半范围 | +| 最坏时间 | $O(n)$ | 树退化为单链表,需遍历所有节点 | +| 平均时间 | $O(\log_2 n)$ | 随机插入情况下,平均查找长度约为 $\log_2 n$ | +| 空间复杂度 | $O(1)$ | 递归实现时为 $O(h)$,迭代实现为 $O(1)$,$h$ 为树高 | ## 3. 二叉搜索树的插入 -> **二叉搜索树的插入**:在二叉搜索树中插入一个值为 $val$ 的节点(假设当前二叉搜索树中不存在值为 $val$ 的节点)。 +> **二叉搜索树的插入**:在二叉搜索树中插入一个值为 $val$ 的节点(假设当前树中不存在 $val$)。 -### 3.1 二叉搜索树的插入算法步骤 +### 3.1 插入算法步骤 -二叉搜索树的插入操作与二叉树的查找操作过程类似,具体步骤如下: +二叉搜索树的插入过程与查找类似,具体如下: -1. 如果二叉搜索树为空,则创建一个值为 $val$ 的节点,并将其作为二叉搜索树的根节点。 -2. 如果二叉搜索树不为空,则将待插入的值 $val$ 与二叉搜索树根节点的值 $root.val$ 进行比较: - 1. 如果 $val < root.val$,则递归将值为 $val$ 的节点插入到左子树中。 - 2. 如果 $val > root.val$,则递归将值为 $val$ 的节点插入到右子树中。 +1. 如果当前树为空,直接创建值为 $val$ 的节点,作为根节点返回。 +2. 如果当前树非空,将 $val$ 与当前节点 $root.val$ 比较: + - 如果 $val < root.val$,递归插入到左子树。 + - 如果 $val > root.val$,递归插入到右子树。 -> **注意**:二叉搜索树不允许存在重复节点,否则将违反其定义。因此,如果带插入节点在树中已存在,则不执行插入操作,直接返回。 +> **注意**:二叉搜索树不允许重复节点。如果 $val$ 已存在于树中,则不插入,直接返回原树。 -### 3.2 二叉搜索树的插入代码实现 +### 3.2 插入算法代码实现 ```python class TreeNode: def __init__(self, val=0, left=None, right=None): - self.val = val - self.left = left - self.right = right + self.val = val # 节点值 + self.left = left # 左子节点 + self.right = right # 右子节点 class Solution: def insertIntoBST(self, root: TreeNode, val: int) -> TreeNode: - if root == None: + """ + 在二叉搜索树中插入一个值为 val 的节点 + + 参数: + root: TreeNode,二叉搜索树的根节点 + val: int,待插入的节点值 + 返回: + TreeNode,插入后的二叉搜索树根节点 + """ + if root is None: + # 当前子树为空,直接创建新节点并返回 return TreeNode(val) if val < root.val: + # 待插入值小于当前节点值,递归插入到左子树 root.left = self.insertIntoBST(root.left, val) - if val > root.val: + elif val > root.val: + # 待插入值大于当前节点值,递归插入到右子树 root.right = self.insertIntoBST(root.right, val) + # 如果 val == root.val,不插入(不允许重复),直接返回原树 return root ``` ## 4. 二叉搜索树的创建 -> **二叉搜索树的创建**:根据数组序列中的元素值,建立一棵二叉搜索树。 +> **二叉搜索树的创建**:根据给定数组中的元素,依次插入,构建出一棵二叉搜索树。 -### 4.1 二叉搜索树的创建算法步骤 +### 4.1 创建算法步骤 -二叉搜索树的创建操作是从空树开始,按照给定数组元素的值,依次进行二叉搜索树的插入操作,最终得到一棵二叉搜索树。具体算法步骤如下: +二叉搜索树的创建通常从一棵空树开始,依次将数组中的每个元素插入到树中,最终形成完整的二叉搜索树。具体步骤如下: -1. 初始化二叉搜索树为空树。 -2. 遍历数组元素,将数组元素值 $nums[i]$ 依次插入到二叉搜索树中。 -3. 将数组中全部元素值插入到二叉搜索树中之后,返回二叉搜索树的根节点。 +1. 初始化根节点为空。 +2. 遍历数组,将每个元素 $nums[i]$ 依次插入到当前的二叉搜索树中。 +3. 所有元素插入完成后,返回二叉搜索树的根节点。 -### 4.2 二叉搜索树的创建代码实现 +### 4.2 创建代码实现 ```python class TreeNode: def __init__(self, val=0, left=None, right=None): - self.val = val - self.left = left - self.right = right + self.val = val # 节点值 + self.left = left # 左子节点 + self.right = right # 右子节点 class Solution: def insertIntoBST(self, root: TreeNode, val: int) -> TreeNode: - if root == None: + """ + 在二叉搜索树中插入一个值为 val 的节点 + + 参数: + root: TreeNode,二叉搜索树的根节点 + val: int,待插入的节点值 + 返回: + TreeNode,插入后的二叉搜索树根节点 + """ + if root is None: + # 当前子树为空,直接创建新节点并返回 return TreeNode(val) - if val < root.val: + # 待插入值小于当前节点值,递归插入到左子树 root.left = self.insertIntoBST(root.left, val) - if val > root.val: + elif val > root.val: + # 待插入值大于当前节点值,递归插入到右子树 root.right = self.insertIntoBST(root.right, val) + # 如果 val == root.val,不插入(不允许重复),直接返回原树 return root + def buildBST(self, nums) -> TreeNode: - root = TreeNode(val) + """ + 根据给定数组 nums 创建一棵二叉搜索树 + + 参数: + nums: List[int],待插入的节点值数组 + 返回: + TreeNode,构建好的二叉搜索树根节点 + """ + root = None # 初始化根节点为空 for num in nums: - self.insertIntoBST(root, num) + root = self.insertIntoBST(root, num) # 依次插入每个元素 return root ``` ## 5. 二叉搜索树的删除 -> **二叉搜索树的删除**:在二叉搜索树中删除值为 $val$ 的节点。 +> **二叉搜索树的删除**:即在二叉搜索树中删除值为 $val$ 的节点。 -### 5.1 二叉搜索树的删除算法步骤 +### 5.1 删除操作算法步骤 -在二叉搜索树中删除元素,首先要找到待删除节点,然后执行删除操作。根据待删除节点所在位置的不同,可以分为 $3$ 种情况: +在二叉搜索树中删除节点时,首先需要定位到目标节点,然后根据其子树情况分为三种情形: -1. 被删除节点的左子树为空。则令其右子树代替被删除节点的位置。 -2. 被删除节点的右子树为空。则令其左子树代替被删除节点的位置。 -3. 被删除节点的左右子树均不为空,则根据二叉搜索树的中序遍历有序性,删除该节点时,可以使用其直接前驱(或直接后继)代替被删除节点的位置。 +1. **左子树为空**:用其右子树替代被删除节点的位置。 +2. **右子树为空**:用其左子树替代被删除节点的位置。 +3. **左右子树均不为空**:利用二叉搜索树的有序性,可用「直接前驱」或「直接后继」节点的值替换当前节点,然后递归删除前驱或后继节点。 -- **直接前驱**:在中序遍历中,节点 $p$ 的直接前驱为其左子树的最右侧的叶子节点。 -- **直接后继**:在中序遍历中,节点 $p$ 的直接后继为其右子树的最左侧的叶子节点。 +- **直接前驱**:即左子树中值最大的节点(左子树最右侧节点)。 +- **直接后继**:即右子树中值最小的节点(右子树最左侧节点)。 -二叉搜索树的删除算法步骤如下: +具体删除步骤如下: -1. 如果当前节点为空,则返回当前节点。 -2. 如果当前节点值大于 $val$,则递归去左子树中搜索并删除,此时 $root.left$ 也要跟着递归更新。 -3. 如果当前节点值小于 $val$,则递归去右子树中搜索并删除,此时 $root.right$ 也要跟着递归更新。 -4. 如果当前节点值等于 $val$,则该节点就是待删除节点。 - 1. 如果当前节点的左子树为空,则删除该节点之后,则右子树代替当前节点位置,返回右子树。 - 2. 如果当前节点的右子树为空,则删除该节点之后,则左子树代替当前节点位置,返回左子树。 - 3. 如果当前节点的左右子树都有,则将左子树转移到右子树最左侧的叶子节点位置上,然后右子树代替当前节点位置。 +1. 如果当前节点为空,直接返回。 +2. 如果当前节点值大于 $val$,递归在左子树中查找并删除,更新 $root.left$。 +3. 如果当前节点值小于 $val$,递归在右子树中查找并删除,更新 $root.right$。 +4. 如果当前节点值等于 $val$,即找到目标节点,分三种情况处理: + 1. 如果左子树为空,返回右子树(右子树顶替当前节点)。 + 2. 如果右子树为空,返回左子树(左子树顶替当前节点)。 + 3. 如果左右子树均不为空,将左子树整体接到右子树的最左侧节点下,然后返回右子树作为新的子树根节点。 ### 5.2 二叉搜索树的删除代码实现 @@ -167,28 +214,79 @@ class TreeNode: class Solution: def deleteNode(self, root: TreeNode, val: int) -> TreeNode: + """ + 在二叉搜索树中删除值为 val 的节点,并返回新的根节点 + + 参数: + root: TreeNode,当前子树的根节点 + val: int,待删除的节点值 + 返回: + TreeNode,删除节点后的新根节点 + """ if not root: - return root + # 递归终止条件:未找到目标节点,直接返回 + return None - if root.val > val: + if val < root.val: + # 待删除值小于当前节点,递归去左子树删除 root.left = self.deleteNode(root.left, val) return root - elif root.val < val: + elif val > root.val: + # 待删除值大于当前节点,递归去右子树删除 root.right = self.deleteNode(root.right, val) return root else: + # 找到目标节点,分三种情况处理 if not root.left: + # 情况 1:左子树为空,直接返回右子树 return root.right elif not root.right: + # 情况 2:右子树为空,直接返回左子树 return root.left else: - curr = root.right - while curr.left: - curr = curr.left - curr.left = root.left - return root.right + # 情况 3:左右子树均不为空 + # 找到右子树的最左节点(即后继节点) + successor = root.right + while successor.left: + successor = successor.left + # 用后继节点的值替换当前节点 + root.val = successor.val + # 在右子树中递归删除后继节点 + root.right = self.deleteNode(root.right, successor.val) + return root ``` +## 6. 总结 + +### 6.1 核心特性 + +二叉搜索树(BST)是一种 **有序的二叉树结构**,其核心特性是: +- **左子树所有节点值 < 根节点值 < 右子树所有节点值** +- **中序遍历结果是有序的**(递增序列) +- **每个节点的左右子树也都是二叉搜索树** + +### 6.2 基本操作算法分析 + +| 操作 | 最优时间 | 最坏时间 | 平均时间 | 空间复杂度 | +|------|----------|----------|----------|------------| +| 查找 | O(log n) | O(n) | O(log n) | O(1) | +| 插入 | O(log n) | O(n) | O(log n) | O(1) | +| 删除 | O(log n) | O(n) | O(log n) | O(1) | + +**说明**:最优情况是树接近完全平衡,最坏情况是树退化为单链表。 + +### 6.3 算法特点 + +**优点**: +- 查找、插入、删除效率高(平均 O(log n)) +- 支持范围查询和有序遍历 +- 实现相对简单,易于理解 + +**缺点**: +- 插入顺序影响树的高度和性能 +- 不平衡时可能退化为链表($O(n)$ 复杂度) +- 需要额外的平衡机制(如 AVL 树、红黑树) + ## 练习题目 - [0700. 二叉搜索树中的搜索](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0700-0799/search-in-a-binary-search-tree.md) diff --git a/docs/05_tree/05_05_segment_tree_01.md b/docs/05_tree/05_05_segment_tree_01.md index 09789901..0b639936 100644 --- a/docs/05_tree/05_05_segment_tree_01.md +++ b/docs/05_tree/05_05_segment_tree_01.md @@ -2,51 +2,49 @@ ### 1.1 线段树的定义 -> **线段树(Segment Tree)**:一种基于分治思想的二叉树,用于在区间上进行信息统计。它的每一个节点都对应一个区间 $[left, right]$ ,$left$、$right$ 通常是整数。每一个叶子节点表示了一个单位区间(长度为 $1$),叶子节点对应区间上 $left == right$。每一个非叶子节点 $[left, right]$ 的左子节点表示的区间都为 $[left, (left + right) / 2]$,右子节点表示的的区间都为 $[(left + right) / 2 + 1, right]$。 +> **线段树(Segment Tree)**:一种用于高效处理区间查询和区间修改的二叉树结构。它将一个区间不断二分,每个节点管理一个区间,叶子节点对应单个元素,内部节点则代表其子区间的合并结果。这样可以在 $O(\log n)$ 时间内完成区间相关操作。 -线段树是一棵平衡二叉树,树上的每个节点维护一个区间。根节点维护的是整个区间,每个节点维护的是父亲节点的区间二等分之后的其中一个子区间。当有 $n$ 个元素时,对区间的操作(单点更新、区间更新、区间查询等)可以在 $O(\log_2n)$ 的时间复杂度内完成。 +线段树就像「区间的管家」:每个节点专管一个区间 $[left, right]$,叶子节点只负责一个元素($left = right$),非叶子节点则把区间一分为二,左孩子管 $[left, mid]$,右孩子管 $[mid+1, right]$,其中 $mid = (left + right) // 2$。整棵树自顶向下分工明确,根节点总揽全局。无论是单点修改、区间修改还是区间查询,都能在 $O(\log n)$ 时间内完成,非常适合处理大规模区间数据。 -如下图所示,这是一棵区间为 $[0, 7]$ 的线段树。 +下图展示了区间 $[0, 7]$ 的线段树结构: ![区间 [0, 7] 对应的线段树](https://qcdn.itcharge.cn/images/20240511173328.png) ### 1.2 线段树的特点 -根据上述描述,我们可以总结一下线段树的特点: +线段树的核心特点如下: -1. 线段树的每个节点都代表一个区间。 -2. 线段树具有唯一的根节点,代表的区间是整个统计范围,比如 $[1, n]$。 -3. 线段树的每个叶子节点都代表一个长度为 $1$ 的单位区间 $[x, x]$。 -4. 对于每个内部节点 $[left, right]$,它的左子节点是 $[left, mid]$,右子节点是 $[mid + 1, right]$。其中 $mid = (left + right) / 2$(向下取整)。 +1. 每个节点对应一个区间。 +2. 根节点管理整个区间(如 $[1, n]$)。 +3. 叶子节点对应单个元素区间($[x, x]$)。 +4. 每个内部节点 $[left, right]$ 的左子节点为 $[left, mid]$,右子节点为 $[mid + 1, right]$,其中 $mid = (left + right) // 2$(向下取整)。 ## 2. 线段树的构建 ### 2.1 线段树的存储结构 -之前我们学习过二叉树的两种存储结构,一种是「链式存储结构」,另一种是「顺序存储结构」。线段树也可以使用这两种存储结构来实现。 +在二叉树中,我们常见的存储方式有「链式存储」和「顺序存储」。线段树同样可以采用这两种方式实现,但由于其结构接近完全二叉树,使用「顺序存储结构」(即数组)更加高效和简洁。 -由于线段树近乎是完全二叉树,所以很适合用「顺序存储结构」来实现。 +线段树的数组存储编号规则如下: -我们可以采用与完全二叉树类似的编号方法来对线段树进行编号,方法如下: +- 根节点编号为 $0$。 +- 如果某节点编号为 $i$,则其左孩子编号为 $2 \tiems i + 1$,右孩子编号为 $2 \tiems i + 2$。 +- 如果某节点编号为 $i$(且 $i > 0$),其父节点编号为 $(i - 1) // 2$。 -- 根节点的编号为 $0$。 -- 如果某二叉树节点(非叶子节点)的下标为 $i$,那么其左孩子节点下标为 $2 \times i + 1$,右孩子节点下标为 $2 \times i + 2$。 -- 如果某二叉树节点(非根节点)的下标为 $i$,那么其父节点下标为 $(i - 1) // 2$,$//$ 表示整除。 +这样,我们可以用一个数组来存储整棵线段树。那么数组的大小如何确定呢? -这样我们就能使用一个数组来保存线段树。那么这个数组的大小应该设置为多少才合适? - -- 在理想情况下,$n$ 个单位区间构成的线段树是一棵满二叉树,节点数为 $n + n/2 + n/4 + ... + 2 + 1 = 2 \times n - 1$ 个。 因为 $2 \times n - 1 < 2 \times n$,所以在理想情况下,只需要使用一个大小为 $2 \times n$ 的数组来存储线段树就足够了。 -- 但是在一般情况下,有些区间元素需要开辟新的一层来存储元素。线段树的深度为 $\lceil \log_2n \rceil$,最坏情况下叶子节点(包括无用的节点)的数量为 $2^{\lceil \log_2n \rceil}$ 个,总节点数为 $2^{\lceil \log_2n \rceil + 1} - 1$ 个,可以近似看做是 $4 * n$,所以我们可以使用一个大小为 $4 \times n$ 的数组来存储线段树。 +- 理想情况下,$n$ 个叶子节点构成的线段树是一棵满二叉树,总节点数为 $2 \tiems n - 1$。因此,数组大小取 $2 \tiems n$ 足够。 +- 但实际上,为了适配任意长度的区间,线段树的深度为 $\lceil \log_2 n \rceil$,最坏情况下节点总数约为 $2^{\lceil \log_2 n \rceil + 1} - 1$,可近似为 $4 \tiems n$。因此,通常分配 $4 \tiems n$ 大小的数组即可保证安全。 ### 2.2 线段树的构建方法 ![线段树父子节点下标关系](https://qcdn.itcharge.cn/images/20240511173417.png) -通过上图可知:下标为 $i$ 的节点的孩子节点下标为 $2 \times i + 1$ 和 $2 \times i + 2$。所以线段树十分适合采用递归的方法来创建。具体步骤如下: +如上图所示,编号为 $i$ 的节点,其左右孩子编号分别为 $2 \tiems i + 1$ 和 $2 \tiems i + 2$。因此,线段树的构建非常适合递归实现。具体步骤如下: -1. 如果是叶子节点($left == right$),则节点的值就是对应位置的元素值。 -2. 如果是非叶子节点,则递归创建左子树和右子树。 -3. 节点的区间值(区间和、区间最大值、区间最小值)等于该节点左右子节点元素值的对应计算结果。 +1. 如果当前区间为叶子节点($left == right$),则节点值为对应元素值。 +2. 如果为非叶子节点,递归构建左、右子树。 +3. 当前节点的区间值(如区间和、最大值、最小值等)由左右子节点的值合并得到。 线段树的构建实现代码如下: @@ -54,277 +52,387 @@ # 线段树的节点类 class TreeNode: def __init__(self, val=0): - self.left = -1 # 区间左边界 - self.right = -1 # 区间右边界 - self.val = val # 节点值(区间值) - self.lazy_tag = None # 区间和问题的延迟更新标记 - - + self.left = -1 # 区间左边界 + self.right = -1 # 区间右边界 + self.val = val # 节点值(区间值,如区间和、区间最大值等) + self.lazy_tag = None # 区间延迟更新标记(如区间加法、区间赋值等懒惰标记) + # 线段树类 class SegmentTree: def __init__(self, nums, function): + """ + :param nums: 原始数据数组 + :param function: 区间聚合函数(如 sum, max, min 等) + """ self.size = len(nums) - self.tree = [TreeNode() for _ in range(4 * self.size)] # 维护 TreeNode 数组 - self.nums = nums # 原始数据 - self.function = function # function 是一个函数,左右区间的聚合方法 + # 线段树最多需要 4 * n 个节点,使用数组存储 + self.tree = [TreeNode() for _ in range(4 * self.size)] + self.nums = nums + self.function = function if self.size > 0: self.__build(0, 0, self.size - 1) - - # 构建线段树,节点的存储下标为 index,节点的区间为 [left, right] + def __build(self, index, left, right): + """ + 递归构建线段树 + :param index: 当前节点在数组中的下标 + :param left: 当前节点管理的区间左端点 + :param right: 当前节点管理的区间右端点 + """ self.tree[index].left = left self.tree[index].right = right - if left == right: # 叶子节点,节点值为对应位置的元素值 + if left == right: + # 叶子节点,直接赋值为原数组对应元素 self.tree[index].val = self.nums[left] return - - mid = left + (right - left) // 2 # 左右节点划分点 - left_index = index * 2 + 1 # 左子节点的存储下标 - right_index = index * 2 + 2 # 右子节点的存储下标 - self.__build(left_index, left, mid) # 递归创建左子树 - self.__build(right_index, mid + 1, right) # 递归创建右子树 - self.__pushup(index) # 向上更新节点的区间值 - - # 向上更新下标为 index 的节点区间值,节点的区间值等于该节点左右子节点元素值的聚合计算结果 + + mid = left + (right - left) // 2 + left_index = index * 2 + 1 # 左子节点下标 + right_index = index * 2 + 2 # 右子节点下标 + self.__build(left_index, left, mid) # 构建左子树 + self.__build(right_index, mid + 1, right) # 构建右子树 + self.__pushup(index) # 更新当前节点的区间值 + def __pushup(self, index): - left_index = index * 2 + 1 # 左子节点的存储下标 - right_index = index * 2 + 2 # 右子节点的存储下标 - self.tree[index].val = self.function(self.tree[left_index].val, self.tree[right_index].val) + """ + 向上更新当前节点的区间值 + :param index: 当前节点在数组中的下标 + """ + left_index = index * 2 + 1 # 左子节点下标 + right_index = index * 2 + 2 # 右子节点下标 + # 当前节点的区间值由左右子节点的区间值聚合得到 + self.tree[index].val = self.function( + self.tree[left_index].val, + self.tree[right_index].val + ) ``` -这里的 `function` 指的是线段树区间合并的聚合方法。可以根据题意进行变化,常见的操作有求和、取最大值、取最小值等等。 +这里的 `function` 参数用于指定线段树在区间合并时所采用的聚合函数。根据具体题目需求,可以灵活传入如求和(sum)、取最大值(max)、取最小值(min)等常见操作,实现不同类型的区间查询。 ## 3. 线段树的基本操作 -线段树的基本操作主要涉及到单点更新、区间查询和区间更新操作。下面我们来进行一一讲解。 +线段树的基本操作包括:单点更新、区间查询和区间更新。下面依次介绍。 -### 3.1 线段树的单点更新 +### 3.1 单点更新 -> **线段树的单点更新**:修改一个元素的值,例如将 $nums[i]$ 修改为 $val$。 +> **单点更新**:将 $nums[i]$ 修改为 $val$。 -我们可以采用递归的方式进行单点更新,具体步骤如下: +递归实现思路如下: -1. 如果是叶子节点,满足 $left == right$,则更新该节点的值。 -2. 如果是非叶子节点,则判断应该在左子树中更新,还是应该在右子树中更新。 -3. 在对应的左子树或右子树中更新节点值。 -4. 左右子树更新返回之后,向上更新节点的区间值(区间和、区间最大值、区间最小值等),区间值等于该节点左右子节点元素值的聚合计算结果。 +1. 如果当前节点为叶子节点($left == right$),直接更新其值。 +2. 否则,判断 $i$ 属于左子树还是右子树,递归更新对应子树。 +3. 更新完后,向上合并,重新计算当前节点的区间值。 -线段树的单点更新实现代码如下: +单点更新的代码如下: ```python - # 单点更新,将 nums[i] 更改为 val - def update_point(self, i, val): - self.nums[i] = val - self.__update_point(i, val, 0, 0, self.size - 1) - - # 单点更新,将 nums[i] 更改为 val。节点的存储下标为 index,节点的区间为 [left, right] - def __update_point(self, i, val, index, left, right): - if self.tree[index].left == self.tree[index].right: - self.tree[index].val = val # 叶子节点,节点值修改为 val - return - - mid = left + (right - left) // 2 # 左右节点划分点 - left_index = index * 2 + 1 # 左子节点的存储下标 - right_index = index * 2 + 2 # 右子节点的存储下标 - if i <= mid: # 在左子树中更新节点值 - self.__update_point(i, val, left_index, left, mid) - else: # 在右子树中更新节点值 - self.__update_point(i, val, right_index, mid + 1, right) - self.__pushup(index) # 向上更新节点的区间值 +def update_point(self, i, val): + """ + 单点更新:将原数组 nums[i] 的值修改为 val,并同步更新线段树 + :param i: 需要更新的元素下标 + :param val: 新的值 + """ + self.nums[i] = val # 更新原数组 + self.__update_point(i, val, 0, 0, self.size - 1) # 从根节点递归更新线段树 + +def __update_point(self, i, val, index, left, right): + """ + 递归实现单点更新 + :param i: 需要更新的元素下标 + :param val: 新的值 + :param index: 当前节点在线段树数组中的下标 + :param left: 当前节点管理的区间左端点 + :param right: 当前节点管理的区间右端点 + """ + # 如果到达叶子节点,直接更新节点值 + if self.tree[index].left == self.tree[index].right: + self.tree[index].val = val # 叶子节点,节点值修改为 val + return + + mid = left + (right - left) // 2 # 计算区间中点 + left_index = index * 2 + 1 # 左子节点的下标 + right_index = index * 2 + 2 # 右子节点的下标 + + # 判断 i 属于左子树还是右子树,递归更新 + if i <= mid: + self.__update_point(i, val, left_index, left, mid) # 在左子树中更新 + else: + self.__update_point(i, val, right_index, mid + 1, right) # 在右子树中更新 + + self.__pushup(index) # 向上更新当前节点的区间值 ``` -### 3.2 线段树的区间查询 +### 3.2 区间查询 -> **线段树的区间查询**:查询一个区间为 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 的区间值。 +> **区间查询**:即查询区间 $[q\_left, q\_right]$ 上的区间聚合值(如区间和、区间最值等)。 -我们可以采用递归的方式进行区间查询,具体步骤如下: +区间查询通常采用递归方式实现,具体流程如下: -1. 如果区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 完全覆盖了当前节点所在区间 $[left, right]$ ,即 $left \ge q\underline{\hspace{0.5em}}left$ 并且 $right \le q\underline{\hspace{0.5em}}right$,则返回该节点的区间值。 -2. 如果区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 与当前节点所在区间 $[left, right]$ 毫无关系,即 $right < q\underline{\hspace{0.5em}}left$ 或者 $left > q\underline{\hspace{0.5em}}right$,则返回 $0$。 -3. 如果区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 与当前节点所在区间有交集,则: - 1. 如果区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 与左子节点所在区间 $[left, mid]$ 有交集,即 $q\underline{\hspace{0.5em}}left \le mid$,则在当前节点的左子树中进行查询并保存查询结果 $res\underline{\hspace{0.5em}}left$。 - 2. 如果区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 与右子节点所在区间 $[mid + 1, right]$ 有交集,即 $q\underline{\hspace{0.5em}}right > mid$,则在当前节点的右子树中进行查询并保存查询结果 $res\underline{\hspace{0.5em}}right$。 - 3. 最后返回左右子树元素区间值的聚合计算结果。 +1. 如果查询区间 $[q\_left, q\_right]$ 完全覆盖当前节点区间 $[left, right]$(即 $left \ge q\_left$ 且 $right \le q\_right$),直接返回该节点的区间值。 +2. 如果查询区间 $[q\_left, q\_right]$ 与当前节点区间 $[left, right]$ 无交集(即 $right < q\_left$ 或 $left > q\_right$),返回 $0$(或聚合运算的单位元)。 +3. 如果两区间有交集,则递归查询左右子区间,并将结果合并: + - 如果 $q\_left \le mid$,递归查询左子区间 $[left, mid]$,记为 $res\_left$。 + - 如果 $q\_right > mid$,递归查询右子区间 $[mid+1, right]$,记为 $res\_right$。 + - 最终返回 $res\_left$ 与 $res\_right$ 的聚合结果。 -线段树的区间查询代码如下: +线段树区间查询的代码如下: ```python - # 区间查询,查询区间为 [q_left, q_right] 的区间值 - def query_interval(self, q_left, q_right): - return self.__query_interval(q_left, q_right, 0, 0, self.size - 1) - - # 区间查询,在线段树的 [left, right] 区间范围中搜索区间为 [q_left, q_right] 的区间值 - def __query_interval(self, q_left, q_right, index, left, right): - if left >= q_left and right <= q_right: # 节点所在区间被 [q_left, q_right] 所覆盖 - return self.tree[index].val # 直接返回节点值 - if right < q_left or left > q_right: # 节点所在区间与 [q_left, q_right] 无关 - return 0 - - self.__pushdown(index) - - mid = left + (right - left) // 2 # 左右节点划分点 - left_index = index * 2 + 1 # 左子节点的存储下标 - right_index = index * 2 + 2 # 右子节点的存储下标 - res_left = 0 # 左子树查询结果 - res_right = 0 # 右子树查询结果 - if q_left <= mid: # 在左子树中查询 - res_left = self.__query_interval(q_left, q_right, left_index, left, mid) - if q_right > mid: # 在右子树中查询 - res_right = self.__query_interval(q_left, q_right, right_index, mid + 1, right) - return self.function(res_left, res_right) # 返回左右子树元素值的聚合计算结果 +# 区间查询,查询区间 [q_left, q_right] 的区间聚合值 +def query_interval(self, q_left, q_right): + """ + 查询区间 [q_left, q_right] 的区间聚合值(如区间和、区间最值等) + + :param q_left: 查询区间左端点 + :param q_right: 查询区间右端点 + :return: 区间 [q_left, q_right] 的聚合值 + """ + return self.__query_interval(q_left, q_right, 0, 0, self.size - 1) + +# 区间查询的递归实现 +def __query_interval(self, q_left, q_right, index, left, right): + """ + 递归查询线段树节点 [left, right] 区间与查询区间 [q_left, q_right] 的交集部分的聚合值 + + :param q_left: 查询区间左端点 + :param q_right: 查询区间右端点 + :param index: 当前节点在线段树数组中的下标 + :param left: 当前节点管理的区间左端点 + :param right: 当前节点管理的区间右端点 + :return: 区间 [q_left, q_right] 与 [left, right] 的交集部分的聚合值 + """ + # 情况 1:当前节点区间被查询区间完全覆盖,直接返回节点值 + if left >= q_left and right <= q_right: + return self.tree[index].val + # 情况 2:当前节点区间与查询区间无交集,返回单位元(如区间和为 0,区间最小值为正无穷等) + if right < q_left or left > q_right: + return 0 + + # 情况 3:当前节点区间与查询区间有部分重叠,递归查询左右子区间 + self.__pushdown(index) # 下推懒惰标记,保证子节点信息正确 + + mid = left + (right - left) // 2 # 计算区间中点 + left_index = index * 2 + 1 # 左子节点下标 + right_index = index * 2 + 2 # 右子节点下标 + res_left = 0 # 左子树查询结果初始化 + res_right = 0 # 右子树查询结果初始化 + if q_left <= mid: # 查询区间与左子区间有交集 + res_left = self.__query_interval(q_left, q_right, left_index, left, mid) + if q_right > mid: # 查询区间与右子区间有交集 + res_right = self.__query_interval(q_left, q_right, right_index, mid + 1, right) + return self.function(res_left, res_right) # 合并左右子树结果并返回 ``` -### 3.3 线段树的区间更新 +### 3.3 区间更新 -> **线段树的区间更新**:对 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 区间进行更新,例如将 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 区间内所有元素都更新为 $val$。 +> **区间更新**:即将区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 内所有元素批量修改为 $val$。 -#### 3.3.1 延迟标记 +#### 3.3.1 延迟标记(懒惰标记) -线段树在进行单点更新、区间查询时,区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 在线段树上会被分成 $O(\log_2n)$ 个小区间(节点),从而在 $O(\log_2n)$ 的时间复杂度内完成操作。 +线段树的区间更新如果每次都递归到所有被覆盖的叶子节点,复杂度会退化为 $O(n)$。为避免无用的重复更新,线段树引入了 **延迟标记**(懒惰标记):当某个节点区间 $[left, right]$ 被更新区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 完全覆盖时,只需直接更新该节点的值,并打上延迟标记,表示其子节点尚未被真正更新。只有在后续递归访问到子节点时,才将更新操作「下推」到子节点。 -而在「区间更新」操作中,如果某个节点区间 $[left, right]$ 被修改区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 完全覆盖,则以该节点为根的整棵子树中所有节点的区间值都要发生变化,如果逐一进行更新的话,将使得一次区间更新操作的时间复杂度增加到 $O(n)$。 +这样,区间更新和区间查询的时间复杂度都能保持 $O(\log_2 n)$。 -设想这一种情况:如果我们在一次执行更新操作时,发现当前节点区间 $[left, right]$ 被修改区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 完全覆盖,然后逐一更新了区间 $[left, right]$ 对应子树中的所有节点,但是在后续的区间查询操作中却根本没有用到 $[left, right]$ 作为候选答案,则更新 $[left, right]$ 对应子树的工作就是徒劳的。 +区间更新的主要步骤如下: -如果我们减少更新的次数和时间复杂度,应该怎么办? +1. 如果 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 完全覆盖当前节点区间 $[left, right]$,则直接更新当前节点的值,并设置延迟标记。 +2. 如果 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 与 $[left, right]$ 无交集,直接返回。 +3. 若有部分重叠,先将当前节点的延迟标记下推到子节点(如果有),然后递归更新左右子区间,最后更新当前节点的值。 -我们可以向线段树的节点类中增加一个 **「延迟标记」**,标识为 **「该区间曾经被修改为 $val$,但其子节点区间值尚未更新」**。也就是说除了在进行区间更新时,将区间子节点的更新操作延迟到 **「在后续操作中递归进入子节点时」** 再执行。这样一来,每次区间更新和区间查询的时间复杂度都降低到了 $O(\log_2n)$。 +#### 3.3.2 下推延迟标记 -使用「延迟标记」的区间更新步骤为: +当节点有延迟标记时,需要将该标记下推到左右子节点,具体做法: -1. 如果区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 完全覆盖了当前节点所在区间 $[left, right]$ ,即 $left \ge q\underline{\hspace{0.5em}}left$ 并且 $right \le q\underline{\hspace{0.5em}}right$,则更新当前节点所在区间的值,并将当前节点的延迟标记为区间值。 -2. 如果区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 与当前节点所在区间 $[left, right]$ 毫无关系,即 $right < q\underline{\hspace{0.5em}}left$ 或者 $left > q\underline{\hspace{0.5em}}right$,则直接返回。 -3. 如果区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 与当前节点所在区间有交集,则: - 1. 如果当前节点使用了「延迟标记」,即延迟标记不为 $None$,则将当前区间的更新操作应用到该节点的子节点上(即向下更新)。 - 2. 如果区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 与左子节点所在区间 $[left, mid]$ 有交集,即 $q\underline{\hspace{0.5em}}left \le mid$,则在当前节点的左子树中更新区间值。 - 3. 如果区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 与右子节点所在区间 $[mid + 1, right]$ 有交集,即 $q\underline{\hspace{0.5em}}right > mid$,则在当前节点的右子树中更新区间值。 - 4. 左右子树更新返回之后,向上更新节点的区间值(区间和、区间最大值、区间最小值),区间值等于该节点左右子节点元素值的对应计算结果。 +1. 将左子节点的值和懒惰标记更新为 $val$。 +2. 将右子节点的值和懒惰标记更新为 $val$。 +3. 清除当前节点的懒惰标记。 -#### 3.3.2 向下更新 +这样可以保证每个节点的更新操作只在必要时才真正执行,极大提升效率。 -上面提到了如果当前节点使用了「延迟标记」,即延迟标记不为 $None$,则将当前区间的更新操作应用到该节点的子节点上(即向下更新)。这里描述一下向下更新的具体步骤: +#### 3.3.3 区间赋值操作(延迟标记) -1. 更新左子节点值和左子节点懒惰标记为 $val$。 -2. 更新右子节点值和右子节点懒惰标记为 $val$。 -3. 将当前节点的懒惰标记更新为 $None$。 - -使用「延迟标记」的区间更新实现代码如下: +使用延迟标记实现区间赋值的代码如下: ```python - # 区间更新,将区间为 [q_left, q_right] 上的元素值修改为 val - def update_interval(self, q_left, q_right, val): - self.__update_interval(q_left, q_right, val, 0, 0, self.size - 1) - - # 区间更新 - def __update_interval(self, q_left, q_right, val, index, left, right): - - if left >= q_left and right <= q_right: # 节点所在区间被 [q_left, q_right] 所覆盖 - interval_size = (right - left + 1) # 当前节点所在区间大小 - self.tree[index].val = interval_size * val # 当前节点所在区间每个元素值改为 val - self.tree[index].lazy_tag = val # 将当前节点的延迟标记为区间值 - return - if right < q_left or left > q_right: # 节点所在区间与 [q_left, q_right] 无关 - return 0 - - self.__pushdown(index) - - mid = left + (right - left) // 2 # 左右节点划分点 - left_index = index * 2 + 1 # 左子节点的存储下标 - right_index = index * 2 + 2 # 右子节点的存储下标 - if q_left <= mid: # 在左子树中更新区间值 - self.__update_interval(q_left, q_right, val, left_index, left, mid) - if q_right > mid: # 在右子树中更新区间值 - self.__update_interval(q_left, q_right, val, right_index, mid + 1, right) - - self.__pushup(index) - - # 向下更新下标为 index 的节点所在区间的左右子节点的值和懒惰标记 - def __pushdown(self, index): - lazy_tag = self.tree[index].lazy_tag - if not lazy_tag: - return - - left_index = index * 2 + 1 # 左子节点的存储下标 - right_index = index * 2 + 2 # 右子节点的存储下标 - - self.tree[left_index].lazy_tag = lazy_tag # 更新左子节点懒惰标记 - left_size = (self.tree[left_index].right - self.tree[left_index].left + 1) - self.tree[left_index].val = lazy_tag * left_size # 更新左子节点值 - - self.tree[right_index].lazy_tag = lazy_tag # 更新右子节点懒惰标记 - right_size = (self.tree[right_index].right - self.tree[right_index].left + 1) - self.tree[right_index].val = lazy_tag * right_size # 更新右子节点值 - - self.tree[index].lazy_tag = None # 更新当前节点的懒惰标记 + +def update_interval(self, q_left, q_right, val): + """ + 对区间 [q_left, q_right] 进行区间赋值操作,将该区间内所有元素修改为 val + """ + self.__update_interval(q_left, q_right, val, 0, 0, self.size - 1) + +def __update_interval(self, q_left, q_right, val, index, left, right): + """ + 递归实现区间赋值更新 + 参数说明: + q_left, q_right: 待更新的目标区间 + val: 赋值的目标值 + index: 当前节点在线段树数组中的下标 + left, right: 当前节点所表示的区间范围 + """ + # 情况 1:当前节点区间被 [q_left, q_right] 完全覆盖,直接更新并打懒惰标记 + if left >= q_left and right <= q_right: + interval_size = (right - left + 1) # 当前区间长度 + self.tree[index].val = interval_size * val # 区间所有元素赋值为 val + self.tree[index].lazy_tag = val # 打上懒惰标记 + return + # 情况 2:当前节点区间与 [q_left, q_right] 无交集,直接返回 + if right < q_left or left > q_right: + return + + # 情况 3:部分重叠,先下推懒惰标记,再递归更新左右子区间 + self.__pushdown(index) + + mid = left + (right - left) // 2 # 区间中点 + left_index = index * 2 + 1 # 左子节点下标 + right_index = index * 2 + 2 # 右子节点下标 + if q_left <= mid: # 左子区间有交集 + self.__update_interval(q_left, q_right, val, left_index, left, mid) + if q_right > mid: # 右子区间有交集 + self.__update_interval(q_left, q_right, val, right_index, mid + 1, right) + + self.__pushup(index) # 回溯时更新当前节点的值 + + +def __pushdown(self, index): + """ + 将当前节点的懒惰标记下推到左右子节点,并更新子节点的值 + """ + lazy_tag = self.tree[index].lazy_tag + if lazy_tag is None: + return + + left_index = index * 2 + 1 # 左子节点下标 + right_index = index * 2 + 2 # 右子节点下标 + + # 更新左子节点的懒惰标记和值 + self.tree[left_index].lazy_tag = lazy_tag + left_size = self.tree[left_index].right - self.tree[left_index].left + 1 + self.tree[left_index].val = lazy_tag * left_size + + # 更新右子节点的懒惰标记和值 + self.tree[right_index].lazy_tag = lazy_tag + right_size = self.tree[right_index].right - self.tree[right_index].left + 1 + self.tree[right_index].val = lazy_tag * right_size + + # 清除当前节点的懒惰标记 + self.tree[index].lazy_tag = None ``` -> **注意**:有些题目中不是将 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 区间更新为 $val$,而是将 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 区间中每一个元素值在原值基础增加或减去 $val$。 -> -> 对于这种情况,我们可以更改一下「延迟标记」的定义。改变为: **「该区间曾经变化了 $val$,但其子节点区间值尚未更新」**。并更改对应的代码逻辑。 +### 3.3.4 区间加减操作(延迟标记) + +有些题目要求将区间 $[q\_left, q\_right]$ 内每个元素在原有基础上增加或减少 $val$,而不是直接赋值为 $val$。 + +针对这种情况,我们需要重新定义「延迟标记」的含义:即表示当前区间整体增加了 $val$,但该操作尚未下传到子区间。相应地,代码实现也要相应调整以支持区间加减操作的延迟更新。 -使用「延迟标记」的区间增减更新实现代码如下: +以下是基于延迟标记的区间加减操作代码: ```python - # 区间更新,将区间为 [q_left, q_right] 上的元素值修改为 val - def update_interval(self, q_left, q_right, val): - self.__update_interval(q_left, q_right, val, 0, 0, self.size - 1) - - # 区间更新 - def __update_interval(self, q_left, q_right, val, index, left, right): - - if left >= q_left and right <= q_right: # 节点所在区间被 [q_left, q_right] 所覆盖 -# interval_size = (right - left + 1) # 当前节点所在区间大小 -# self.tree[index].val = interval_size * val # 当前节点所在区间每个元素值改为 val -# self.tree[index].lazy_tag = val # 将当前节点的延迟标记为区间值 - - if self.tree[index].lazy_tag: - self.tree[index].lazy_tag += val # 将当前节点的延迟标记增加 val - else: - self.tree[index].lazy_tag = val # 将当前节点的延迟标记增加 val - interval_size = (right - left + 1) # 当前节点所在区间大小 - self.tree[index].val += val * interval_size # 当前节点所在区间每个元素值增加 val - return - if right < q_left or left > q_right: # 节点所在区间与 [q_left, q_right] 无关 - return 0 - - self.__pushdown(index) - - mid = left + (right - left) // 2 # 左右节点划分点 - left_index = index * 2 + 1 # 左子节点的存储下标 - right_index = index * 2 + 2 # 右子节点的存储下标 - if q_left <= mid: # 在左子树中更新区间值 - self.__update_interval(q_left, q_right, val, left_index, left, mid) - if q_right > mid: # 在右子树中更新区间值 - self.__update_interval(q_left, q_right, val, right_index, mid + 1, right) - - self.__pushup(index) - - # 向下更新下标为 index 的节点所在区间的左右子节点的值和懒惰标记 - def __pushdown(self, index): - lazy_tag = self.tree[index].lazy_tag - if not lazy_tag: - return - - left_index = index * 2 + 1 # 左子节点的存储下标 - right_index = index * 2 + 2 # 右子节点的存储下标 - - if self.tree[left_index].lazy_tag: - self.tree[left_index].lazy_tag += lazy_tag # 更新左子节点懒惰标记 - else: - self.tree[left_index].lazy_tag = lazy_tag - left_size = (self.tree[left_index].right - self.tree[left_index].left + 1) - self.tree[left_index].val += lazy_tag * left_size # 左子节点每个元素值增加 lazy_tag - - if self.tree[right_index].lazy_tag: - self.tree[right_index].lazy_tag += lazy_tag # 更新右子节点懒惰标记 +# 区间更新,将区间 [q_left, q_right] 上的所有元素增加 val +def update_interval(self, q_left, q_right, val): + """ + 对区间 [q_left, q_right] 内的所有元素增加 val + """ + self.__update_interval(q_left, q_right, val, 0, 0, self.size - 1) + +def __update_interval(self, q_left, q_right, val, index, left, right): + """ + 递归实现区间加法更新 + 参数: + q_left, q_right: 待更新的区间范围 + val: 增加的值 + index: 当前节点在线段树数组中的下标 + left, right: 当前节点所表示的区间范围 + """ + # 情况 1:当前节点区间被 [q_left, q_right] 完全覆盖,直接打懒惰标记并更新区间和 + if left >= q_left and right <= q_right: + interval_size = right - left + 1 # 当前节点区间长度 + if self.tree[index].lazy_tag is not None: + self.tree[index].lazy_tag += val # 累加懒惰标记 else: - self.tree[right_index].lazy_tag = lazy_tag - right_size = (self.tree[right_index].right - self.tree[right_index].left + 1) - self.tree[right_index].val += lazy_tag * right_size # 右子节点每个元素值增加 lazy_tag - - self.tree[index].lazy_tag = None # 更新当前节点的懒惰标记 + self.tree[index].lazy_tag = val # 新建懒惰标记 + self.tree[index].val += val * interval_size # 区间和增加 + return + + # 情况2:当前节点区间与 [q_left, q_right] 无交集,直接返回 + if right < q_left or left > q_right: + return + + # 情况3:部分重叠,先下推懒惰标记,再递归更新左右子区间 + self.__pushdown(index) + + mid = left + (right - left) // 2 + left_index = index * 2 + 1 + right_index = index * 2 + 2 + if q_left <= mid: + self.__update_interval(q_left, q_right, val, left_index, left, mid) + if q_right > mid: + self.__update_interval(q_left, q_right, val, right_index, mid + 1, right) + + self.__pushup(index) # 回溯时更新当前节点的区间和 + +def __pushdown(self, index): + """ + 将当前节点的懒惰标记下推到左右子节点,并同步更新子节点的区间和 + """ + lazy_tag = self.tree[index].lazy_tag + if lazy_tag is None: + return + + left_index = index * 2 + 1 + right_index = index * 2 + 2 + + # 处理左子节点 + if self.tree[left_index].lazy_tag is not None: + self.tree[left_index].lazy_tag += lazy_tag + else: + self.tree[left_index].lazy_tag = lazy_tag + left_size = self.tree[left_index].right - self.tree[left_index].left + 1 + self.tree[left_index].val += lazy_tag * left_size + + # 处理右子节点 + if self.tree[right_index].lazy_tag is not None: + self.tree[right_index].lazy_tag += lazy_tag + else: + self.tree[right_index].lazy_tag = lazy_tag + right_size = self.tree[right_index].right - self.tree[right_index].left + 1 + self.tree[right_index].val += lazy_tag * right_size + + # 清除当前节点的懒惰标记 + self.tree[index].lazy_tag = None ``` +## 4. 总结 + +### 4.1 核心要点 + +- 线段树通过对区间反复二分,让每个节点维护一个子区间的聚合信息(如和、最值)。 +- 使用数组顺序存储,容量通常取约 `4 * n`,查询/更新沿树高进行。 +- 引入懒惰标记后,区间更新无需遍历到所有叶子,保持对数级复杂度。 +- 聚合函数可配置(sum/max/min/自定义),需满足可结合性以支持自底向上合并。 + +### 4.2 复杂度分析 + +| 操作 | 最优时间 | 最坏时间 | 平均时间 | 空间复杂度 | 稳定性 | +|------|----------|----------|----------|------------|--------| +| 构建 | O(n) | O(n) | O(n) | O(n) | 不涉及 | +| 单点更新 | O(log n) | O(log n) | O(log n) | O(1)(递归为 O(log n) 栈) | 不涉及 | +| 区间查询 | O(log n) | O(log n) | O(log n) | O(1)(递归为 O(log n) 栈) | 不涉及 | +| 区间更新(含懒标) | O(log n) | O(log n) | O(log n) | O(1)(递归为 O(log n) 栈) | 不涉及 | + +说明:如果不使用懒惰标记,区间更新在最坏情况下会退化为 O(n)。 + +### 4.3 算法特点 + +**优点**: +- 区间查询与区间更新效率高(均为 O(log n))。 +- 适配多种聚合函数,扩展性强。 +- 支持动态数据的在线维护。 + +**缺点**: +- 实现复杂度与常数因子较大,代码易错。 +- 对聚合函数有约束(需可结合),不适合不可结合的运算。 +- 多维线段树实现复杂,内存与常数进一步增大;对于简单前缀和/仅单点更新的场景,树状数组往往更简洁高效。 ## 练习题目 - [线段树题目列表](https://github.com/ITCharge/AlgoNote/tree/main/docs/00_preface/00_06_categories_list.md#%E7%BA%BF%E6%AE%B5%E6%A0%91%E9%A2%98%E7%9B%AE) diff --git a/docs/05_tree/05_06_segment_tree_02.md b/docs/05_tree/05_06_segment_tree_02.md index 7851b63f..7fd84e73 100644 --- a/docs/05_tree/05_06_segment_tree_02.md +++ b/docs/05_tree/05_06_segment_tree_02.md @@ -1,190 +1,222 @@ -## 1. 线段树的常见题型 +## 1. 线段树常见题型 -### 1.1 RMQ 问题 +线段树是一种高效的数据结构,常用于处理区间相关的查询与修改。以下是线段树常见的几类题型及其简要说明: -> **RMQ 问题**:Range Maximum / Minimum Query 的缩写,指的是对于长度为 $n$ 的数组序列 $nums$,回答若干个询问问题 `RMQ(nums, q_left, q_right)`,要求返回数组序列 $nums$ 在区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 中的最大(最小)值。也就是求区间最大(最小)值问题。 +### 1.1 区间最大 / 最小值查询(RMQ) -假设查询次数为 $q$,则使用朴素算法解决 RMQ 问题的时间复杂度为 $O(q \times n)$。而使用线段树解决 RMQ 问题的时间复杂度为 $O(q \times n) \sim Q(q \times \log_2n)$ 之间。 +> **RMQ(Range Maximum / Minimum Query)问题**:给定长度为 $n$ 的数组 $nums$,多次询问区间 $[q_{left}, q_{right}]$ 内的最大值或最小值。 -### 1.2 单点更新,区间查询问题 +假设有 $q$ 次查询,朴素算法每次需遍历区间,整体时间复杂度为 $O(q \times n)$;而采用线段树后,每次查询仅需 $O(\log n)$,总复杂度降为 $O(q \times \log n)$,大大提升了效率。 -> **单点更新,区间查询问题**: +### 1.2 单点更新与区间查询 + +> **单点更新与区间查询问题**: > -> 1. 修改某一个元素的值。 -> 2. 查询区间为 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 的区间值。 +> 1. 支持对数组中某一元素进行修改(单点更新)。 +> 2. 支持查询任意区间 $[q_{left}, q_{right}]$ 的聚合值(如区间和、最大/最小值等)。 -这类问题直接使用「3.1 线段树的单点更新」和「3.2 线段树的区间查询」即可解决。 +这类问题直接使用「5.5 线段树(一)」中的「3.1 单点更新」和「3.2 区间查询」即可解决。 -### 1.3 区间更新,区间查询问题 +### 1.3 区间更新与区间查询 -> **区间更新,区间查询问题**: +> **区间更新与区间查询问题**: > -> 1. 修改某一个区间的值。 -> 2. 查询区间为 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 的区间值。 +> 1. 支持对某一连续区间的所有元素进行批量修改(区间更新)。 +> 2. 支持查询任意区间 $[q_{left}, q_{right}]$ 的聚合值(如区间和、最大/最小值等)。 -这类问题直接使用「3.3 线段树的区间更新」和「3.2 线段树的区间查询」即可解决。 +此类问题直接使用「5.5 线段树(一)」中的「3.3 区间更新」与「3.2 区间查询」即可解决。 -### 1.4 区间合并问题 +### 1.4 区间合并与区间查询 -> **区间合并,区间查询问题**: +> **区间合并与区间查询问题**: > -> 1. 修改某一个区间的值。 -> 2. 查询区间为 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 中满足条件的连续最长区间值。 +> 1. 支持对某一连续区间的所有元素进行批量修改(区间更新)。 +> 2. 支持查询区间 $[q_{left}, q_{right}]$ 内,满足特定条件的连续最长子区间(如最长连续 1、最长连续递增/递减等)。 -这类问题需要在「3.3 线段树的区间更新」和「3.2 线段树的区间查询」的基础上增加变动,在进行向上更新时需要对左右子节点的区间进行合并。 +这类问题在解决时,需在「5.5 线段树(一)」中「3.3 区间更新」和「3.2 区间查询」的基础上,扩展每个节点维护的信息。例如,节点需额外记录区间内的前缀/后缀/最大连续长度等统计量。在向上合并时,需根据左右子节点的这些信息进行合并计算,从而支持高效的区间合并与查询操作。 ### 1.5 扫描线问题 -> **扫描线问题**:虚拟扫描线或扫描面来解决欧几里德空间中的各种问题,一般被用来解决图形面积,周长等问题。 +> **扫描线问题**:通过模拟一条虚拟的扫描线(通常为垂直或水平线)在平面上移动,动态处理与其相交的几何对象,从而高效解决如图形面积、周长等几何统计问题。 > -> 主要思想为:想象一条线(通常是一条垂直线)在平面上扫过或移动,在某些点停止。几何操作仅限于几何对象,无论何时停止,它们都与扫描线相交或紧邻扫描线,并且一旦线穿过所有对象,就可以获得完整的解。 +> 核心思想是:让扫描线从一端出发,依次经过所有关键事件点(如矩形的边界),每到一个事件点时,更新与扫描线相交的区间集合,并据此统计所需信息。随着扫描线的推进,所有对象都被处理,最终得到完整解答。 -这类问题通常坐标跨度很大,需要先对每条扫描线的坐标进行离散化处理,将 $y$ 坐标映射到 $0, 1, 2, ...$ 中。然后将每条竖线的端点作为区间范围,使用线段树存储每条竖线的信息($x$ 坐标、是左竖线还是右竖线等),然后再进行区间合并,并统计相关信息。 +这类问题往往涉及大范围的坐标区间,因此通常需要对坐标进行离散化(如将 $y$ 坐标映射为 $0, 1, 2, \ldots$),以便用线段树等数据结构高效维护区间信息。具体做法是:将每条竖线(或水平线)的端点作为区间边界,利用线段树动态维护区间的覆盖情况(如 $x$ 坐标、左/右边界等),在扫描过程中实时合并区间并统计相关量(如总覆盖长度、重叠次数等)。 ## 2. 线段树的拓展 ### 2.1 动态开点线段树 -在有些情况下,线段树需要维护的区间很大(例如 $[1, 10^9]$),在实际中用到的节点却很少。 +在某些场景下,线段树需要维护的区间范围极大(如 $[1, 10^9]$),但实际被访问和修改的节点却非常有限。 -如果使用之前数组形式实现线段树,则需要 $4 \times n$ 大小的空间,空间消耗有点过大了。 +如果仍采用传统的数组实现方式,则需要分配 $4 \times n$ 的空间,导致空间浪费严重,效率低下。 -这时候我们就可以使用动态开点的思想来构建线段树。 +为了解决这一问题,可以采用 **动态开点** 的线段树实现思路: -动态开点线段树的算法思想如下: +- 初始时仅创建一个根节点,表示整个区间。 +- 只有在访问或修改到某个子区间时,才动态地为该区间分配节点。 -- 开始时只建立一个根节点,代表整个区间。 -- 当需要访问线段树的某棵子树(某个子区间)时,再建立代表这个子区间的节点。 +这种方式极大地节省了空间,仅为实际需要的区间分配内存,适合处理稀疏访问、超大区间的问题。 -动态开点线段树实现代码如下: +动态开点线段树的基本实现如下: ```python -# 线段树的节点类 +# 动态开点线段树节点类 class TreeNode: def __init__(self, left=-1, right=-1, val=0): self.left = left # 区间左边界 self.right = right # 区间右边界 - self.mid = left + (right - left) // 2 - self.leftNode = None # 区间左节点 - self.rightNode = None # 区间右节点 - self.val = val # 节点值(区间值) - self.lazy_tag = None # 区间问题的延迟更新标记 - - -# 线段树类 + self.mid = left + (right - left) // 2 # 区间中点 + self.leftNode = None # 左子节点 + self.rightNode = None # 右子节点 + self.val = val # 区间聚合值 + self.lazy_tag = None # 懒惰标记(延迟更新) + +# 动态开点线段树 class SegmentTree: def __init__(self, function): - self.tree = TreeNode(0, int(1e9)) - self.function = function # function 是一个函数,左右区间的聚合方法 - - # 向上更新 node 节点区间值,节点的区间值等于该节点左右子节点元素值的聚合计算结果 + self.tree = TreeNode(0, int(1e9)) # 根节点,维护区间 [0, 1e9] + self.function = function # 区间聚合函数(如 sum, max, min) + def __pushup(self, node): + """ + 向上更新当前节点的区间值,由左右子节点聚合得到 + """ leftNode = node.leftNode rightNode = node.rightNode if leftNode and rightNode: node.val = self.function(leftNode.val, rightNode.val) - - # 单点更新,将 nums[i] 更改为 val + elif leftNode: + node.val = leftNode.val + elif rightNode: + node.val = rightNode.val + # 如果左右子节点都不存在,val 保持不变 + def update_point(self, i, val): + """ + 单点更新:将下标 i 的元素修改为 val + """ self.__update_point(i, val, self.tree) - - # 单点更新,将 nums[i] 更改为 val。node 节点的区间为 [node.left, node.right] + def __update_point(self, i, val, node): + """ + 递归实现单点更新 + """ if node.left == node.right: - node.val = val # 叶子节点,节点值修改为 val + node.val = val # 叶子节点,直接赋值 + node.lazy_tag = None # 清除懒惰标记 return - - if i <= node.mid: # 在左子树中更新节点值 + + self.__pushdown(node) # 下推懒惰标记,保证更新正确 + + if i <= node.mid: if not node.leftNode: node.leftNode = TreeNode(node.left, node.mid) self.__update_point(i, val, node.leftNode) - else: # 在右子树中更新节点值 + else: if not node.rightNode: node.rightNode = TreeNode(node.mid + 1, node.right) self.__update_point(i, val, node.rightNode) - self.__pushup(node) # 向上更新节点的区间值 - - # 区间查询,查询区间为 [q_left, q_right] 的区间值 + self.__pushup(node) # 向上更新 + def query_interval(self, q_left, q_right): + """ + 区间查询:[q_left, q_right] 区间的聚合值 + """ return self.__query_interval(q_left, q_right, self.tree) - - # 区间查询,在线段树的 [left, right] 区间范围中搜索区间为 [q_left, q_right] 的区间值 + def __query_interval(self, q_left, q_right, node): - if node.left >= q_left and node.right <= q_right: # 节点所在区间被 [q_left, q_right] 所覆盖 - return node.val # 直接返回节点值 - if node.right < q_left or node.left > q_right: # 节点所在区间与 [q_left, q_right] 无关 + """ + 递归实现区间查询 + """ + if node.left > q_right or node.right < q_left: + # 当前节点区间与查询区间无交集 return 0 - - self.__pushdown(node) # 向下更新节点所在区间的左右子节点的值和懒惰标记 - - res_left = 0 # 左子树查询结果 - res_right = 0 # 右子树查询结果 - if q_left <= node.mid: # 在左子树中查询 + if node.left >= q_left and node.right <= q_right: + # 当前节点区间被查询区间完全覆盖 + return node.val + + self.__pushdown(node) # 下推懒惰标记 + + res_left = 0 + res_right = 0 + if q_left <= node.mid: if not node.leftNode: node.leftNode = TreeNode(node.left, node.mid) res_left = self.__query_interval(q_left, q_right, node.leftNode) - if q_right > node.mid: # 在右子树中查询 + if q_right > node.mid: if not node.rightNode: node.rightNode = TreeNode(node.mid + 1, node.right) res_right = self.__query_interval(q_left, q_right, node.rightNode) return self.function(res_left, res_right) # 返回左右子树元素值的聚合计算结果 - - # 区间更新,将区间为 [q_left, q_right] 上的元素值修改为 val + def update_interval(self, q_left, q_right, val): + """ + 区间更新:将 [q_left, q_right] 区间内所有元素增加 val + """ self.__update_interval(q_left, q_right, val, self.tree) - - # 区间更新 + def __update_interval(self, q_left, q_right, val, node): - if node.left >= q_left and node.right <= q_right: # 节点所在区间被 [q_left, q_right] 所覆盖 - if node.lazy_tag: - node.lazy_tag += val # 将当前节点的延迟标记增加 val + """ + 递归实现区间更新(区间加法) + """ + if node.left > q_right or node.right < q_left: + # 当前节点区间与更新区间无交集 + return + + if node.left >= q_left and node.right <= q_right: + # 当前节点区间被更新区间完全覆盖 + interval_size = node.right - node.left + 1 + if node.lazy_tag is not None: + node.lazy_tag += val else: - node.lazy_tag = val # 将当前节点的延迟标记增加 val - interval_size = (node.right - node.left + 1) # 当前节点所在区间大小 - node.val += val * interval_size # 当前节点所在区间每个元素值增加 val + node.lazy_tag = val + node.val += val * interval_size return - if node.right < q_left or node.left > q_right: # 节点所在区间与 [q_left, q_right] 无关 - return 0 - - self.__pushdown(node) # 向下更新节点所在区间的左右子节点的值和懒惰标记 - - if q_left <= node.mid: # 在左子树中更新区间值 + + self.__pushdown(node) # 下推懒惰标记 + + if q_left <= node.mid: if not node.leftNode: node.leftNode = TreeNode(node.left, node.mid) self.__update_interval(q_left, q_right, val, node.leftNode) - if q_right > node.mid: # 在右子树中更新区间值 + if q_right > node.mid: if not node.rightNode: node.rightNode = TreeNode(node.mid + 1, node.right) self.__update_interval(q_left, q_right, val, node.rightNode) - - self.__pushup(node) - - # 向下更新 node 节点所在区间的左右子节点的值和懒惰标记 + + self.__pushup(node) # 向上更新 + def __pushdown(self, node): - lazy_tag = node.lazy_tag - if not node.lazy_tag: + """ + 懒惰标记下推:将当前节点的延迟更新传递给左右子节点 + """ + if node.lazy_tag is None: return - + + # 动态创建左右子节点 if not node.leftNode: node.leftNode = TreeNode(node.left, node.mid) if not node.rightNode: node.rightNode = TreeNode(node.mid + 1, node.right) - - if node.leftNode.lazy_tag: - node.leftNode.lazy_tag += lazy_tag # 更新左子节点懒惰标记 + + # 更新左子节点 + left_size = node.leftNode.right - node.leftNode.left + 1 + if node.leftNode.lazy_tag is not None: + node.leftNode.lazy_tag += node.lazy_tag else: - node.leftNode.lazy_tag = lazy_tag # 更新左子节点懒惰标记 - left_size = (node.leftNode.right - node.leftNode.left + 1) - node.leftNode.val += lazy_tag * left_size # 左子节点每个元素值增加 lazy_tag - - if node.rightNode.lazy_tag: - node.rightNode.lazy_tag += lazy_tag # 更新右子节点懒惰标记 + node.leftNode.lazy_tag = node.lazy_tag + node.leftNode.val += node.lazy_tag * left_size + + # 更新右子节点 + right_size = node.rightNode.right - node.rightNode.left + 1 + if node.rightNode.lazy_tag is not None: + node.rightNode.lazy_tag += node.lazy_tag else: - node.rightNode.lazy_tag = lazy_tag # 更新右子节点懒惰标记 - right_size = (node.rightNode.right - node.rightNode.left + 1) - node.rightNode.val += lazy_tag * right_size # 右子节点每个元素值增加 lazy_tag - - node.lazy_tag = None # 更新当前节点的懒惰标记 + node.rightNode.lazy_tag = node.lazy_tag + node.rightNode.val += node.lazy_tag * right_size + + node.lazy_tag = None # 清除当前节点的懒惰标记 ``` ## 练习题目 diff --git a/docs/05_tree/05_08_union_find.md b/docs/05_tree/05_08_union_find.md index ce0f3a3b..81459e07 100644 --- a/docs/05_tree/05_08_union_find.md +++ b/docs/05_tree/05_08_union_find.md @@ -2,342 +2,438 @@ ### 1.1 并查集的定义 -> **并查集(Union Find)**:一种树型的数据结构,用于处理一些不交集(Disjoint Sets)的合并及查询问题。不交集指的是一系列没有重复元素的集合。 +> **并查集(Union Find)**:一种高效的数据结构,常用于处理若干不相交集合(Disjoint Sets)的合并与查询操作。不相交集合指的是元素互不重叠的集合族。 > -> 并查集主要支持两种操作: +> 并查集主要支持两类核心操作: > -> - **合并(Union)**:将两个集合合并成一个集合。 -> - **查找(Find)**:确定某个元素属于哪个集合。通常是返回集合内的一个「代表元素」。 +> - **合并(Union)**:将两个不同的集合合并为一个集合。 +> - **查找(Find)**:确定某个元素属于哪个集合,通常返回该集合的「代表元素」。 -简单来说,并查集就是用来处理集合的合并和集合的查询。 +简而言之,并查集用于高效地管理集合的合并与成员归属查询。 -- 并查集中的「集」指的就是我们初中所学的集合概念,在这里指的是不相交的集合,即一系列没有重复元素的集合。 -- 并查集中的「并」指的就是集合的并集操作,将两个集合合并之后就变成一个集合。合并操作如下所示: +- 并查集中的「集」指的是不相交的集合,即元素互不重复、互不重叠的若干集合。 +- 并查集中的「并」指的是集合的并集操作,即将两个不同的集合合并为一个更大的集合。合并操作如下: ```python {1, 3, 5, 7} U {2, 4, 6, 8} = {1, 2, 3, 4, 5, 6, 7, 8} ``` -- 并查集中的「查」是对于集合中存放的元素来说的,通常我们需要查询两个元素是否属于同一个集合。 +- 并查集中的「查」操作,主要用于判断两个元素是否属于同一个集合。 -如果我们只是想知道一个元素是否在集合中,可以通过 Python 或其他语言中的 `set` 集合来解决。而如果我们想知道两个元素是否属于同一个集合,则仅用一个 `set` 集合就很难做到了。这就需要用到我们接下来要讲解的「并查集」结构。 +如果只是判断某个元素是否在集合中,直接用 Python 的 `set` 类型即可。但如果要 **高效判断两个元素是否属于同一集合**,`set` 就不适合了,因为它只能判断单个元素是否存在,无法快速判断两个元素是否在同一个集合里,往往需要遍历所有集合,效率很低。此时,就需要用专门的并查集结构,才能高效地支持集合的合并和连通性查询。 -根据上文描述,我们就可以定义一下「并查集」结构所支持的操作接口: +基于上述需求,并查集通常支持以下核心操作接口: -- **合并 `union(x, y)`**:将集合 $x$ 和集合 $y$ 合并成一个集合。 -- **查找 `find(x)`**:查找元素 $x$ 属于哪个集合。 -- **查找 `is_connected(x, y)`**:查询元素 $x$ 和 $y$ 是否在同一个集合中。 +- **合并 `union(x, y)`**:将包含元素 $x$ 和 $y$ 的两个集合合并为一个集合。 +- **查找 `find(x)`**:查找元素 $x$ 所在集合的代表元素(根节点)。 +- **连通性判断 `is_connected(x, y)`**:判断元素 $x$ 和 $y$ 是否属于同一个集合。 ### 1.2 并查集的两种实现思路 -下面我们来讲解一下并查集的两种实现思路:一种是使用「快速查询」思路、基于数组结构实现的并查集;另一种是使用「快速合并」思路、基于森林实现的并查集。 +并查集常见的两种实现方式分别侧重于不同操作的效率:一种是「快速查询」——基于数组结构,另一种是「快速合并」——基于森林结构。 #### 1.2.1 快速查询:基于数组实现 -如果我们希望并查集的查询效率高一些,那么我们就可以侧重于查询操作。 +当我们更关注查询操作的效率时,可以采用基于数组的实现方式。 -在使用「快速查询」思路实现并查集时,我们可以使用一个「数组结构」来表示集合中的元素。数组元素和集合元素是一一对应的,我们可以将数组的索引值作为每个元素的集合编号,称为 $id$。然后可以对数组进行以下操作来实现并查集: +在这种实现中,使用一个数组来表示每个元素所属的集合。数组的下标代表元素本身,数组的值($id$)表示该元素所在集合的编号。具体操作如下: -- **当初始化时**:将数组下标索引值作为每个元素的集合编号。所有元素的 $id$ 都是唯一的,代表着每个元素单独属于一个集合。 -- **合并操作时**:需要将其中一个集合中的所有元素 $id$ 更改为另一个集合中的 $id$,这样能够保证在合并后一个集合中所有元素的 $id$ 均相同。 -- **查找操作时**:如果两个元素的 $id$ 一样,则说明它们属于同一个集合;如果两个元素的 $id$ 不一样,则说明它们不属于同一个集合。 +- **初始化**:将每个元素的集合编号设为其自身的下标,即每个元素自成一个集合。 +- **合并操作**:将一个集合中的所有元素的 $id$ 修改为另一个集合的 $id$,从而实现集合的合并。这样,合并后同一集合内所有元素的 $id$ 都相同。 +- **查找操作**:直接比较两个元素的 $id$ 是否相同,若相同则属于同一集合,否则属于不同集合。 -举个例子来说明一下,我们使用数组来表示一系列集合元素 $\left\{ 0 \right\}, \left\{ 1 \right\}, \left\{ 2 \right\}, \left\{ 3 \right\}, \left\{ 4 \right\}, \left\{ 5 \right\}, \left\{ 6 \right\}, \left\{ 7 \right\}$,初始化时如下图所示。 +举例说明,假设有集合 $\left\{ 0 \right\}, \left\{ 1 \right\}, \left\{ 2 \right\}, \left\{ 3 \right\}, \left\{ 4 \right\}, \left\{ 5 \right\}, \left\{ 6 \right\}, \left\{ 7 \right\}$,初始化如下: ![基于数组实现:初始化操作](https://qcdn.itcharge.cn/images/20240513150949.png) -从上图中可以看出:数组的每个下标索引值对应一个元素的集合编号,代表着每个元素单独属于一个集合。 +如上图所示,数组下标即为元素编号,初始时每个元素单独成集。 -当我们进行一系列的合并操作后,比如合并后变为 $\left\{ 0 \right\}, \left\{ 1, 2, 3 \right\}, \left\{ 4 \right\}, \left\{5, 6\right\}, \left\{ 7 \right\}$,合并操作的结果如下图所示。 +经过若干次合并操作后,例如合并成 $\left\{ 0 \right\}, \left\{ 1, 2, 3 \right\}, \left\{ 4 \right\}, \left\{5, 6\right\}, \left\{ 7 \right\}$,结果如下: ![基于数组实现:合并操作](https://qcdn.itcharge.cn/images/20240513151310.png) -从上图中可以看出,在进行一系列合并操作后,下标为 $1$、$2$、$3$ 的元素集合编号是一致的,说明这 $3$ 个元素同属于一个集合。同理下标为 $5$ 和 $6$ 的元素则同属于另一个集合。 +可以看到,$1$、$2$、$3$ 的 $id$ 相同,说明它们属于同一集合;$5$ 和 $6$ 也同理。 -在快速查询的实现思路中,单次查询操作的时间复杂度是 $O(1)$,而单次合并操作的时间复杂度为 $O(n)$(每次合并操作需要遍历数组)。两者的时间复杂度相差得比较大,完全牺牲了合并操作的性能。因此,这种并查集的实现思路并不常用。 +这种实现方式下,查询操作的时间复杂度为 $O(1)$,但合并操作的时间复杂度为 $O(n)$(每次合并都需遍历整个数组)。因此,虽然查询极快,但合并效率较低,实际应用中较少采用。 -- 使用「快速查询」思路实现并查集代码如下所示: +- 基于「快速查询」思路的并查集代码如下: ```python class UnionFind: - def __init__(self, n): # 初始化:将每个元素的集合编号初始化为数组下标索引 - self.ids = [i for i in range(n)] - - def find(self, x): # 查找元素所属集合编号内部实现方法 + def __init__(self, n): + """ + 初始化并查集,将每个元素的集合编号初始化为其自身下标。 + :param n: 元素总数 + """ + self.ids = [i for i in range(n)] # ids[i] 表示元素 i 所在集合的编号 + + def find(self, x): + """ + 查找元素 x 所在集合的编号。 + :param x: 元素编号 + :return: x 所在集合的编号 + """ return self.ids[x] - def union(self, x, y): # 合并操作:将集合 x 和集合 y 合并成一个集合 + def union(self, x, y): + """ + 合并包含元素 x 和 y 的两个集合。 + :param x: 元素 x + :param y: 元素 y + :return: 如果 x 和 y 原本就在同一集合,返回 False;否则合并并返回 True + """ x_id = self.find(x) y_id = self.find(y) - - if x_id == y_id: # x 和 y 已经同属于一个集合 + + if x_id == y_id: + # x 和 y 已经在同一个集合,无需合并 return False - - for i in range(len(self.ids)): # 将两个集合的集合编号改为一致 + + # 遍历所有元素,将属于 y_id 集合的元素编号改为 x_id,实现合并 + for i in range(len(self.ids)): if self.ids[i] == y_id: self.ids[i] = x_id return True - def is_connected(self, x, y): # 查询操作:判断 x 和 y 是否同属于一个集合 + def is_connected(self, x, y): + """ + 判断元素 x 和 y 是否属于同一个集合。 + :param x: 元素 x + :param y: 元素 y + :return: 如果属于同一集合返回 True,否则返回 False + """ return self.find(x) == self.find(y) ``` #### 1.2.2 快速合并:基于森林实现 -因为快速查询的实现思路中,合并操作的效率比较低。所以我们现在的重点是提高合并操作的效率。 - -在使用「快速合并」思路实现并查集时,我们可以使用「一个森林(若干棵树)」来存储所有集合。每一棵树代表一个集合,树上的每个节点都是一个元素,树根节点为这个集合的代表元素。 +在「快速查询」的实现方式中,合并操作效率较低,因此我们需要优化合并操作的性能。 -> **注意**:与普通的树形结构(父节点指向子节点)不同的是,基于森林实现的并查集中,树中的子节点是指向父节点的。 +为此,可以采用「森林结构」来实现并查集。具体做法是:用若干棵树(即森林)来表示所有集合,每棵树代表一个集合,树中的每个节点对应一个元素,树的根节点即为该集合的代表元素。 -此时,我们仍然可以使用一个数组 $fa$ 来记录这个森林。我们用 $fa[x]$ 来保存 $x$ 的父节点的集合编号,代表着元素节点 $x$ 指向父节点 $fa[x]$。 +> **注意**:与常规树结构(父节点指向子节点)不同,基于森林的并查集中,每个节点都指向其父节点。 -当初始化时,$fa[x]$ 值赋值为下标索引 $x$。在进行合并操作时,只需要将两个元素的树根节点相连接(`fa[root1] = root2`)即可。而在进行查询操作时,只需要查看两个元素的树根节点是否一致,就能知道两个元素是否属于同一个集合。 +我们可以用一个数组 $fa$ 来维护森林结构,其中 $fa[x]$ 表示元素 $x$ 的父节点编号。也就是说,$x$ 通过 $fa[x]$ 指向其父节点。 -总结一下,我们可以对数组 $fa$ 进行以下操作来实现并查集: +- **初始化**:令 $fa[x] = x$,即每个元素自成一个集合,自己是自己的根节点。 +- **合并操作**:将两个集合的根节点相连,例如令 $fa[root1] = root2$,即把 $root1$ 所在集合合并到 $root2$ 所在集合。 +- **查找操作**:从某个元素出发,沿着 $fa$ 数组不断查找其父节点,直到找到根节点。若两个元素的根节点相同,则它们属于同一集合,否则属于不同集合。 -- **当初始化时**:将数组 $fa$​ 的下标索引作为每个元素的集合编号。所有元素的根节点的集合编号都不一样,代表着每个元素单独属于一个集合。 -- **合并操作时**:需要将两个集合的树根节点相连接。即令其中一个集合的树根节点指向另一个集合的树根节点(`fa[root1] = root2`),这样合并后当前集合中的所有元素的树根节点均为同一个。 -- **查找操作时**:分别从两个元素开始,通过数组 $fa$ 存储的值,不断递归访问元素的父节点,直到到达树根节点。如果两个元素的树根节点一样,则说明它们属于同一个集合;如果两个元素的树根节点不一样,则说明它们不属于同一个集合。 - -举个例子来说明一下,我们使用数组来表示一系列集合元素 $\left\{0\right\}, \left\{ 1 \right\}, \left\{ 2 \right\}, \left\{ 3 \right\}, \left\{ 4 \right\}, \left\{ 5 \right\}, \left\{ 6 \right\}, \left\{ 7 \right\}$,初始化时如下图所示。 +举例说明,假设有集合 $\left\{0\right\}, \left\{1\right\}, \left\{2\right\}, \left\{3\right\}, \left\{4\right\}, \left\{5\right\}, \left\{6\right\}, \left\{7\right\}$,初始化时如下图: ![基于森林实现:初始化操作](https://qcdn.itcharge.cn/images/20240513151548.png) 从上图中可以看出:$fa$ 数组的每个下标索引值对应一个元素的集合编号,代表着每个元素属于一个集合。 -当我们进行一系列的合并操作后,比如 `union(4, 5)`、`union(6, 7)`、`union(4, 7)` 操作后变为 $\left\{ 0 \right\}, \left\{ 1 \right\}, \left\{ 2 \right\}, \left\{ 3 \right\}, \left\{ 4, 5, 6, 7 \right\}$​,合并操作的步骤及结果如下图所示。 +接下来,依次执行 `union(4, 5)`、`union(6, 7)`、`union(4, 7)`,最终集合变为 $\left\{0\right\}, \left\{1\right\}, \left\{2\right\}, \left\{3\right\}, \left\{4, 5, 6, 7\right\}$,具体步骤如下: ::: tabs#union @tab <1> -- 合并 $(4, 5)$:令 $4$ 的根节点指向 $5$,即将 $fa[4]$ 更改为 $5$。 +- 合并 $(4, 5)$:将 $4$ 的根节点指向 $5$,即 $fa[4] = 5$。 ![基于森林实现:合并操作 1](https://qcdn.itcharge.cn/images/20240513154015.png) @tab <2> -- 合并 $(6, 7)$:令 $6$ 的根节点指向 $7$,即将 $fa[6]$ 更改为 $7$。 +- 合并 $(6, 7)$:将 $6$ 的根节点指向 $7$,即 $fa[6] = 7$。 ![基于森林实现:合并操作 2](https://qcdn.itcharge.cn/images/20240513154022.png) @tab <3> -- 合并 $(4, 7)$:令 $4$ 的的根节点指向 $7$,即将 $fa[fa[4]]$(也就是 $fa[5]$)更改为 $7$。 +- 合并 $(4, 7)$:将 $4$ 的根节点(即 $fa[4] = 5$)指向 $7$,即 $fa[fa[4]] = fa[5] = 7$。 ![基于森林实现:合并操作 3](https://qcdn.itcharge.cn/images/20240513154030.png) ::: -从上图中可以看出,在进行一系列合并操作后,`fa[fa[4]] == fa[5] == fa[6] == f[7]`,即 $4$、$5$、$6$、$7$ 的元素根节点编号都是 $4$,说明这 $4$ 个元素同属于一个集合。 +可以看到,经过上述合并后,$4$、$5$、$6$、$7$ 的根节点编号都为 $7$,说明它们已经属于同一个集合。 -- 使用「快速合并」思路实现并查集代码如下所示: +- 基于「快速合并」思想的并查集代码如下: ```python class UnionFind: - def __init__(self, n): # 初始化:将每个元素的集合编号初始化为数组 fa 的下标索引 - self.fa = [i for i in range(n)] - - def find(self, x): # 查找元素根节点的集合编号内部实现方法 - while self.fa[x] != x: # 递归查找元素的父节点,直到根节点 + def __init__(self, n): + """ + 初始化并查集,将每个元素的父节点初始化为自身 + :param n: 元素个数 + """ + self.fa = [i for i in range(n)] # fa[x] 表示 x 的父节点,初始时每个节点自成一个集合 + + def find(self, x): + """ + 查找元素 x 所在集合的根节点(代表元) + :param x: 待查找的元素 + :return: x 所在集合的根节点编号 + """ + # 循环查找父节点,直到找到根节点(fa[x] == x) + while self.fa[x] != x: x = self.fa[x] - return x # 返回元素根节点的集合编号 - - def union(self, x, y): # 合并操作:令其中一个集合的树根节点指向另一个集合的树根节点 + return x + + def union(self, x, y): + """ + 合并 x 和 y 所在的两个集合 + :param x: 元素 x + :param y: 元素 y + :return: 如果 x 和 y 原本属于同一集合,返回 False;否则合并并返回 True + """ root_x = self.find(x) root_y = self.find(y) - if root_x == root_y: # x 和 y 的根节点集合编号相同,说明 x 和 y 已经同属于一个集合 + if root_x == root_y: + # x 和 y 已经在同一个集合中,无需合并 return False - self.fa[root_x] = root_y # x 的根节点连接到 y 的根节点上,成为 y 的根节点的子节点 + self.fa[root_x] = root_y # 将 x 的根节点连接到 y 的根节点 return True - def is_connected(self, x, y): # 查询操作:判断 x 和 y 是否同属于一个集合 + def is_connected(self, x, y): + """ + 判断 x 和 y 是否属于同一个集合 + :param x: 元素 x + :param y: 元素 y + :return: 如果属于同一集合返回 True,否则返回 False + """ return self.find(x) == self.find(y) ``` ## 2. 路径压缩 -在集合很大或者树很不平衡时,使用上述「快速合并」思路实现并查集的代码效率很差,最坏情况下,树会退化成一条链,单次查询的时间复杂度高达 $O(n)$。并查集的最坏情况如下图所示。 +当集合规模较大或树结构极度不平衡时,单纯依赖「快速合并」的并查集实现效率较低。在最坏情况下,树会退化为一条链,此时单次查找操作的时间复杂度为 $O(n)$,如下图所示: ![并查集最坏情况](https://qcdn.itcharge.cn/images/20240513154732.png) -为了避免出现最坏情况,一个常见的优化方式是「路径压缩」。 +为了提升效率、避免上述最坏情况,常用的优化手段是「路径压缩」。 -> **路径压缩(Path Compression)**:在从底向上查找根节点过程中,如果此时访问的节点不是根节点,则我们可以把这个节点尽量向上移动一下,从而减少树的层树。这个过程就叫做路径压缩。 +> **路径压缩(Path Compression)**:在查找根节点的过程中,将路径上经过的所有节点尽量直接挂到根节点下,从而显著降低树的高度,提高后续操作的效率。 -路径压缩有两种方式:一种叫做「隔代压缩」;另一种叫做「完全压缩」。 +路径压缩主要有两种常见实现方式:一种是「隔代压缩」,另一种是「完全压缩」。 ### 2.1 隔代压缩 -> **隔代压缩**:在查询时,两步一压缩,一直循环执行「把当前节点指向它的父亲节点的父亲节点」这样的操作,从而减小树的深度。 +> **隔代压缩**:在查找操作时,每次将当前节点直接连接到其父节点的父节点(即跳过一层),通过不断重复这一过程,有效降低树的高度,从而提升并查集的查找效率。 -下面是一个「隔代压缩」的例子。 +如下图所示,展示了隔代压缩的过程: ![路径压缩:隔代压缩](https://qcdn.itcharge.cn/images/20240513154745.png) -- 隔代压缩的查找代码如下: +隔代压缩的查找代码如下: ```python -def find(self, x): # 查找元素根节点的集合编号内部实现方法 - while self.fa[x] != x: # 递归查找元素的父节点,直到根节点 - self.fa[x] = self.fa[self.fa[x]] # 隔代压缩 - x = self.fa[x] - return x # 返回元素根节点的集合编号 +def find(self, x): + """ + 查找元素 x 所在集合的根节点(带隔代路径压缩) + :param x: 待查找的元素 + :return: x 所在集合的根节点编号 + """ + while self.fa[x] != x: + # 将 x 的父节点直接指向其祖父节点,实现隔代压缩 + self.fa[x] = self.fa[self.fa[x]] + x = self.fa[x] # 继续向上查找 + return x # 返回根节点编号 ``` ### 2.2 完全压缩 -> **完全压缩**:在查询时,把被查询的节点到根节点的路径上的所有节点的父节点设置为根节点,从而减小树的深度。也就是说,在向上查询的同时,把在路径上的每个节点都直接连接到根上,以后查询时就能直接查询到根节点。 +> **完全压缩**:在查找操作时,将从当前节点到根节点路径上的所有节点的父节点都直接指向根节点,从而极大地降低树的高度。这样,后续对这些节点的查找都能一步到达根节点,显著提升效率。 -相比较于「隔代压缩」,「完全压缩」压缩的更加彻底。下面是一个「完全压缩」的例子。 +与「隔代压缩」相比,「完全压缩」能够更彻底地扁平化树结构。如下图所示: ![路径压缩:完全压缩](https://qcdn.itcharge.cn/images/20240513154759.png) -- 完全压缩的查找代码如下: +完全压缩的查找代码如下: ```python -def find(self, x): # 查找元素根节点的集合编号内部实现方法 - if self.fa[x] != x: # 递归查找元素的父节点,直到根节点 - self.fa[x] = self.find(self.fa[x]) # 完全压缩优化 - return self.fa[x] +def find(self, x): + """ + 查找元素 x 所在集合的根节点(带完全路径压缩) + :param x: 待查找的元素 + :return: x 所在集合的根节点编号 + """ + if self.fa[x] != x: # 如果 x 不是根节点,递归查找其父节点 + self.fa[x] = self.find(self.fa[x]) # 路径压缩:将 x 直接连接到根节点 + return self.fa[x] # 返回根节点编号 ``` ## 3. 按秩合并 -因为路径压缩只在查询时进行,并且只压缩一棵树上的路径,所以并查集最终的结构仍然可能是比较复杂的。为了避免这种情况,另一个优化方式是「按秩合并」。 +虽然路径压缩能够有效降低树的高度,但它只在查找操作时生效,且仅影响当前查找路径上的节点。因此,若仅依赖路径压缩,整个并查集的结构仍可能出现较高的树。为进一步优化并查集的结构,常用的另一种方法是「按秩合并」。 -> **按秩合并(Union By Rank)**:指的是在每次合并操作时,都把「秩」较小的树根节点指向「秩」较大的树根节点。 +> **按秩合并(Union By Rank)**:在每次合并操作时,总是将「秩」较小的树的根节点连接到「秩」较大的树的根节点下。 -这里的「秩」有两种定义,一种定义指的是树的深度;另一种定义指的是树的大小(即集合节点个数)。无论采用哪种定义,集合的秩都记录在树的根节点上。 +这里的「秩」可以有两种常见定义:一种是树的深度,另一种是集合的大小(即节点个数)。无论采用哪种定义,秩的信息都只需记录在每棵树的根节点上。 -按秩合并也有两种方式:一种叫做「按深度合并」;另一种叫做「按大小合并」。 +按秩合并主要有两种实现方式:一种是「按深度合并」,另一种是「按大小合并」。 ### 3.1 按深度合并 -> **按深度合并(Unoin By Rank)**:在每次合并操作时,都把「深度」较小的树根节点指向「深度」较大的树根节点。 +> **按深度合并(Union By Rank)**:每次合并时,将「深度」较小的树的根节点指向「深度」较大的树的根节点。 -我们用一个数组 $rank$ 记录每个根节点对应的树的深度(如果不是根节点,其 $rank$ 值相当于以它作为根节点的子树的深度)。 +具体做法是,使用一个数组 $rank$ 记录每个根节点对应的树的深度(非根节点的 $rank$ 值无实际意义,仅根节点有效)。 -初始化时,将所有元素的 $rank$ 值设为 $1$。在合并操作时,比较两个根节点,把 $rank$ 值较小的根节点指向 $rank$ 值较大的根节点上合并。 +初始化时,所有元素的 $rank$ 值设为 $1$。合并时,比较两个集合根节点的 $rank$,将 $rank$ 较小的根节点指向 $rank$ 较大的根节点。如果两棵树深度相同,任选一方作为新根,并将其 $rank$ 加 $1$。 -下面是一个「按深度合并」的例子。 +如下图所示为「按深度合并」的示意: ![按秩合并:按深度合并](https://qcdn.itcharge.cn/images/20240513154814.png) -- 按深度合并的实现代码如下: +按深度合并的实现代码如下: ```python class UnionFind: - def __init__(self, n): # 初始化 - self.fa = [i for i in range(n)] # 每个元素的集合编号初始化为数组 fa 的下标索引 - self.rank = [1 for i in range(n)] # 每个元素的深度初始化为 1 - - def find(self, x): # 查找元素根节点的集合编号内部实现方法 - while self.fa[x] != x: # 递归查找元素的父节点,直到根节点 - self.fa[x] = self.fa[self.fa[x]] # 隔代压缩 + def __init__(self, n): + """ + 初始化并查集 + :param n: 元素个数 + """ + self.fa = [i for i in range(n)] # fa[i] 表示元素 i 的父节点,初始时每个元素自成一个集合 + self.rank = [1 for _ in range(n)] # rank[i] 表示以 i 为根的树的深度,初始为 1 + + def find(self, x): + """ + 查找元素 x 所在集合的根节点(带路径压缩,隔代压缩) + :param x: 待查找的元素 + :return: x 所在集合的根节点编号 + """ + while self.fa[x] != x: # 如果 x 不是根节点,继续查找其父节点 + self.fa[x] = self.fa[self.fa[x]]# 路径压缩:将 x 直接连接到祖父节点,实现隔代压缩 x = self.fa[x] - return x # 返回元素根节点的集合编号 - - def union(self, x, y): # 合并操作:令其中一个集合的树根节点指向另一个集合的树根节点 + return x # 返回根节点编号 + + def union(self, x, y): + """ + 合并操作:将 x 和 y 所在的集合合并 + :param x: 元素 x + :param y: 元素 y + :return: 如果合并成功返回 True,若已在同一集合返回 False + """ root_x = self.find(x) root_y = self.find(y) - if root_x == root_y: # x 和 y 的根节点集合编号相同,说明 x 和 y 已经同属于一个集合 + if root_x == root_y: # x 和 y 已经在同一个集合 return False - - if self.rank[root_x] < self.rank[root_y]: # x 的根节点对应的树的深度 小于 y 的根节点对应的树的深度 - self.fa[root_x] = root_y # x 的根节点连接到 y 的根节点上,成为 y 的根节点的子节点 - elif self.rank[root_y] > self.rank[root_y]: # x 的根节点对应的树的深度 大于 y 的根节点对应的树的深度 - self.fa[root_y] = root_x # y 的根节点连接到 x 的根节点上,成为 x 的根节点的子节点 - else: # x 的根节点对应的树的深度 等于 y 的根节点对应的树的深度 - self.fa[root_x] = root_y # 向任意一方合并即可 - self.rank[root_y] += 1 # 因为层数相同,被合并的树必然层数会 +1 + + # 按秩合并:将深度较小的树合并到深度较大的树下 + if self.rank[root_x] < self.rank[root_y]: + self.fa[root_x] = root_y # x 的根节点连接到 y 的根节点 + elif self.rank[root_x] > self.rank[root_y]: + self.fa[root_y] = root_x # y 的根节点连接到 x 的根节点 + else: + self.fa[root_x] = root_y # 深度相同,任选一方作为新根 + self.rank[root_y] += 1 # 新根的深度加 1 return True - def is_connected(self, x, y): # 查询操作:判断 x 和 y 是否同属于一个集合 + def is_connected(self, x, y): + """ + 查询操作:判断 x 和 y 是否属于同一个集合 + :param x: 元素 x + :param y: 元素 y + :return: 如果属于同一集合返回 True,否则返回 False + """ return self.find(x) == self.find(y) ``` ### 3.2 按大小合并 -> **按大小合并(Unoin By Size)**:这里的大小指的是集合节点个数。在每次合并操作时,都把「集合节点个数」较少的树根节点指向「集合节点个数」较大的树根节点。 +> **按大小合并(Union By Size)**:此处的「大小」指的是集合中节点的数量。每次合并时,总是将节点数较少的集合的根节点指向节点数较多的集合的根节点,从而有效控制树的高度。 -我们用一个数组 $size$ 记录每个根节点对应的集合节点个数(如果不是根节点,其 $size$ 值相当于以它作为根节点的子树的集合节点个数)。 +具体做法是,使用一个数组 $size$ 记录每个根节点所代表集合的节点个数(对于非根节点,$size$ 的值无实际意义,仅根节点的 $size$ 有效)。 -初始化时,将所有元素的 $size$ 值设为 $1$。在合并操作时,比较两个根节点,把 $size$ 值较小的根节点指向 $size$ 值较大的根节点上合并。 +初始化时,所有元素各自为一个集合,因此 $size$ 均为 $1$。合并操作时,先分别找到两个元素的根节点,比较它们的 $size$,将较小集合的根节点连接到较大集合的根节点,并更新新根节点的 $size$。 -下面是一个「按大小合并」的例子。 +如下图所示为按大小合并的示意: ![按秩合并:按大小合并](https://qcdn.itcharge.cn/images/20240513154835.png) -- 按大小合并的实现代码如下: +按大小合并的实现代码如下: ```python class UnionFind: - def __init__(self, n): # 初始化 - self.fa = [i for i in range(n)] # 每个元素的集合编号初始化为数组 fa 的下标索引 - self.size = [1 for i in range(n)] # 每个元素的集合个数初始化为 1 - - def find(self, x): # 查找元素根节点的集合编号内部实现方法 - while self.fa[x] != x: # 递归查找元素的父节点,直到根节点 - self.fa[x] = self.fa[self.fa[x]] # 隔代压缩优化 + def __init__(self, n): + """ + 初始化并查集 + :param n: 元素个数 + """ + self.fa = [i for i in range(n)] # fa[i] 表示元素 i 的父节点,初始时每个元素自成一个集合 + self.size = [1 for _ in range(n)] # size[i] 表示以 i 为根的集合的元素个数,初始为 1 + + def find(self, x): + """ + 查找元素 x 所在集合的根节点(带隔代路径压缩) + :param x: 待查找的元素 + :return: x 所在集合的根节点编号 + """ + while self.fa[x] != x: + self.fa[x] = self.fa[self.fa[x]] # 隔代路径压缩,将 x 直接连接到祖父节点 x = self.fa[x] - return x # 返回元素根节点的集合编号 - - def union(self, x, y): # 合并操作:令其中一个集合的树根节点指向另一个集合的树根节点 + return x + + def union(self, x, y): + """ + 合并操作:将 x 和 y 所在的集合合并(按集合大小合并) + :param x: 元素 x + :param y: 元素 y + :return: 如果合并成功返回 True,若已在同一集合返回 False + """ root_x = self.find(x) root_y = self.find(y) - if root_x == root_y: # x 和 y 的根节点集合编号相同,说明 x 和 y 已经同属于一个集合 - return False - - if self.size[root_x] < self.size[root_y]: # x 对应的集合元素个数 小于 y 对应的集合元素个数 - self.fa[root_x] = root_y # x 的根节点连接到 y 的根节点上,成为 y 的根节点的子节点 - self.size[root_y] += self.size[root_x] # y 的根节点对应的集合元素个数 累加上 x 的根节点对应的集合元素个数 - elif self.size[root_x] > self.size[root_y]: # x 对应的集合元素个数 大于 y 对应的集合元素个数 - self.fa[root_y] = root_x # y 的根节点连接到 x 的根节点上,成为 x 的根节点的子节点 - self.size[root_x] += self.size[root_y] # x 的根节点对应的集合元素个数 累加上 y 的根节点对应的集合元素个数 - else: # x 对应的集合元素个数 小于 y 对应的集合元素个数 - self.fa[root_x] = root_y # 向任意一方合并即可 + if root_x == root_y: + return False # x 和 y 已经在同一个集合,无需合并 + + # 按集合大小合并:小集合合并到大集合 + if self.size[root_x] < self.size[root_y]: + self.fa[root_x] = root_y + self.size[root_y] += self.size[root_x] + elif self.size[root_x] > self.size[root_y]: + self.fa[root_y] = root_x + self.size[root_x] += self.size[root_y] + else: + # 集合大小相等,任选一方作为新根 + self.fa[root_x] = root_y self.size[root_y] += self.size[root_x] - + return True - def is_connected(self, x, y): # 查询操作:判断 x 和 y 是否同属于一个集合 + def is_connected(self, x, y): + """ + 查询操作:判断 x 和 y 是否属于同一个集合 + :param x: 元素 x + :param y: 元素 y + :return: 如果属于同一集合返回 True,否则返回 False + """ return self.find(x) == self.find(y) ``` ### 3.3 按秩合并的注意点 -看过「按深度合并」和「按大小合并」的实现代码后,大家可能会产生一个疑问:为什么在路径压缩的过程中不用更新 $rank$ 值或者 $size$ 值呢? - -其实,代码中的 $rank$ 值或者 $size$ 值并不完全是树中真实的深度或者集合元素个数。 +很多同学会疑惑:再路径压缩时,为什么不用更新 $rank$ 或 $size$? -这是因为当我们在代码中引入路径压缩之后,维护真实的深度或者集合元素个数就会变得比较难。此时我们使用的 $rank$ 值或者 $size$ 值更像是用于当前节点排名的一个标志数字,只在合并操作的过程中,用于比较两棵树的权值大小。 +其实,路径压缩后,$rank$ 和 $size$ 已经不再代表真实的树高或集合大小。它们只是合并时用来比较「谁大谁小」的辅助标记,只在合并操作时起作用。 -换句话说,我们完全可以不知道每个节点的具体深度或者集合元素个数,只要能够保证每两个节点之间的深度或者集合元素个数关系可以通过 $rank$ 值或者 $size$ 值正确的表达即可。 +换句话说,我们不需要关心每个节点的真实深度或集合元素个数,只要 $rank$ 或 $size$ 能正确反映两个集合的相对大小即可。 -而根据路径压缩的过程,$rank$ 值或者 $size$ 值只会不断的升高,而不可能降低到比原先深度更小的节点或者集合元素个数更少的节点还要小。所以,$rank$ 值或者 $size$ 值足够用于比较两个节点的权值,进而选择合适的方式进行合并操作。 +此外,路径压缩只会让树变矮,$rank$ 或 $size$ 只会增加,不会减少。因此,它们足以作为合并时的比较依据,无需在路径压缩时维护真实值。 ## 4. 并查集的算法分析 -首先我们来分析一下并查集的空间复杂度。在代码中,我们主要使用了数组 $fa$ 来存储集合中的元素。如果使用了「按秩合并」的优化方式,还会使用数组 $rank$ 或者数组 $size$ 来存放权值。因为空间复杂度取决于元素个数,不难得出空间复杂度为 $O(n)$。 -在同时使用了「路径压缩」和「按秩合并」的情况下,并查集的合并操作和查找操作的时间复杂度可以接近于 $O(1)$。最坏情况下的时间复杂度是 $O(m \times \alpha(n))$。这里的 $m$ 是合并操作和查找操作的次数,$\alpha(n)$ 是 Ackerman 函数的某个反函数,其增长极其缓慢,也就是说其单次操作的平均运行时间可以认为是一个很小的常数。 +- **时间复杂度**:在同时使用「路径压缩」和「按秩合并」优化后,合并(union)和查找(find)操作的均摊时间复杂度非常接近 $O(1)$。更精确地说,$m$ 次操作的总时间复杂度为 $O(m \times \alpha(n))$,其中 $\alpha(n)$ 是阿克曼函数的反函数,增长极其缓慢,实际应用中可视为常数。 +- **空间复杂度**:主要由数组 $fa$(父节点数组)构成,若采用「按秩合并」优化,还需额外的 $rank$ 或 $size$ 数组。整体空间复杂度为 $O(n)$,其中 $n$ 为元素个数。 -总结一下: +## 5. 并查集的推荐实现方式 -- 并查集的空间复杂度:$O(n)$。 -- 并查集的时间复杂度:$O(m \times \alpha(n))$。 +结合实际刷题和主流经验,推荐并查集的实现策略如下:优先采用「隔代压缩」优化,一般情况下无需引入「按秩合并」。 -## 5. 并查集的最终实现代码 +这种做法的优势在于代码简洁、易于实现,同时性能表现也非常优秀。只有在遇到性能瓶颈时,再考虑引入「按秩合并」进一步优化。 -根据我自己的做题经验和网上大佬的经验,我使用并查集的策略(仅供参考)是这样:使用「隔代压缩」,一般不使用「按秩合并」。 +此外,如果题目需要支持查询集合数量或集合内元素个数等功能,可根据具体需求对实现进行适当扩展。 -这样选择的原因是既能保证代码简单易写,又能得到不错的性能。如果这样写的性能还不够好的话,再考虑使用「按秩合并」。 -在有些题目中,还会遇到需要查询集合的个数或者集合中元素个数的情况,可以根据题目具体要求再做相应的更改。 +::: tabs#bubble + +@tab <1> -- 使用「隔代压缩」,不使用「按秩合并」的并查集最终实现代码: +采用「隔代压缩」且不使用「按秩合并」的并查集实现代码: ```python class UnionFind: @@ -363,7 +459,9 @@ class UnionFind: return self.find(x) == self.find(y) ``` -- 使用「隔代压缩」,使用「按秩合并」的并查集最终实现代码: +@tab <2> + +使用「隔代压缩」,使用「按秩合并」的并查集最终实现代码: ```python class UnionFind: @@ -396,6 +494,8 @@ class UnionFind: return self.find(x) == self.find(y) ``` +::: + ## 6. 并查集的应用 并查集通常用来求解不同元素之间的关系问题,比如判断两个人是否是亲戚关系、两个点之间时候存在至少一条路径连接。或者用来求解集合的个数、集合中元素的个数等等。 @@ -430,12 +530,12 @@ class UnionFind: #### 6.1.3 解题思路 -字符串方程只有 `==` 或者 `!=`,可以考虑将相等的遍历划分到相同集合中,然后再遍历所有不等式方程,看方程的两个变量是否在之前划分的相同集合中,如果在则说明不满足。 +由于字符串方程仅包含 `==` 或 `!=` 两种形式,我们可以将所有等式(`==`)的变量归为同一个集合,然后再检查所有不等式(`!=`)的变量是否被错误地划分到了同一集合中。如果出现这种情况,则说明方程组无法同时满足。 -这就需要用到并查集,具体操作如下: +具体步骤如下: -- 遍历所有等式方程,将等式两边的单字母变量顶点进行合并。 -- 遍历所有不等式方程,检查不等式两边的单字母遍历是不是在一个连通分量中,如果在则返回 $False$,否则继续扫描。如果所有不等式检查都没有矛盾,则返回 $True$。 +- 首先遍历所有等式方程,将等式两侧的变量通过并查集合并到同一个集合中。 +- 然后遍历所有不等式方程,判断不等式两侧的变量是否已经在同一个集合中。如果在同一集合,则说明存在矛盾,返回 $False$;如果所有不等式都检查无冲突,则返回 $True$。 #### 6.1.4 代码 @@ -480,87 +580,6 @@ class Solution: return True ``` -### 6.2 省份数量 - -#### 6.2.1 题目链接 - -- [547. 省份数量 - 力扣(LeetCode)](https://leetcode.cn/problems/number-of-provinces/) - -#### 6.2.2 题目大意 - -**描述**:有 $n$ 个城市,其中一些彼此相连,另一些没有相连。如果城市 $a$ 与城市 $b$ 直接相连,且城市 $b$ 与城市 $c$ 直接相连,那么城市 $a$ 与城市 $c$ 间接相连。 - -「省份」是由一组直接或间接链接的城市组成,组内不含有其他没有相连的城市。 - -现在给定一个 $n \times n$ 的矩阵 $isConnected$ 表示城市的链接关系。其中 `isConnected[i][j] = 1` 表示第 $i$ 个城市和第 $j$ 个城市直接相连,`isConnected[i][j] = 0` 表示第 $i$ 个城市和第 $j$ 个城市没有相连。 - -**要求**:根据给定的城市关系,返回「省份」的数量。 - -**说明**: - -- $1 \le n \le 200$。 -- $n == isConnected.length$。 -- $n == isConnected[i].length$。 -- $isConnected[i][j]$ 为 $1$ 或 $0$。 -- $isConnected[i][i] == 1$。 -- $isConnected[i][j] == isConnected[j][i]$。 - -**示例**: - -- 如图所示: - -![](https://assets.leetcode.com/uploads/2020/12/24/graph1.jpg) - -```python -输入 isConnected = [[1,1,0],[1,1,0],[0,0,1]] -输出 2 -``` - -#### 6.2.3 解题思路 - -具体做法如下: -- 遍历矩阵 $isConnected$。如果 `isConnected[i][j] = 1`,将 $i$ 节点和 $j$ 节点相连。 -- 然后判断每个城市节点的根节点,然后统计不重复的根节点有多少个,也就是集合个数,即为「省份」的数量。 - -#### 6.2.4 代码 - -```python -class UnionFind: - def __init__(self, n): # 初始化 - self.fa = [i for i in range(n)] # 每个元素的集合编号初始化为数组 fa 的下标索引 - - def find(self, x): # 查找元素根节点的集合编号内部实现方法 - while self.fa[x] != x: # 递归查找元素的父节点,直到根节点 - self.fa[x] = self.fa[self.fa[x]] # 隔代压缩优化 - x = self.fa[x] - return x # 返回元素根节点的集合编号 - - def union(self, x, y): # 合并操作:令其中一个集合的树根节点指向另一个集合的树根节点 - root_x = self.find(x) - root_y = self.find(y) - if root_x == root_y: # x 和 y 的根节点集合编号相同,说明 x 和 y 已经同属于一个集合 - return False - self.fa[root_x] = root_y # x 的根节点连接到 y 的根节点上,成为 y 的根节点的子节点 - return True - - def is_connected(self, x, y): # 查询操作:判断 x 和 y 是否同属于一个集合 - return self.find(x) == self.find(y) - -class Solution: - def findCircleNum(self, isConnected: List[List[int]]) -> int: - size = len(isConnected) - union_find = UnionFind(size) - for i in range(size): - for j in range(i + 1, size): - if isConnected[i][j] == 1: - union_find.union(i, j) - - res = set() - for i in range(size): - res.add(union_find.find(i)) - return len(res) -``` - ## 练习题目 - [0990. 等式方程的可满足性](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0900-0999/satisfiability-of-equality-equations.md) From 8e453f62f1b6571b36609a39c61b763acda4b8ea Mon Sep 17 00:00:00 2001 From: ITCharge Date: Wed, 3 Sep 2025 10:39:11 +0800 Subject: [PATCH 12/18] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20LaTex=20=E5=85=AC?= =?UTF-8?q?=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/01_array/01_04_array_selection_sort.md | 2 +- docs/01_array/01_07_array_merge_sort.md | 6 ++-- docs/01_array/01_15_array_two_pointers.md | 20 ++++++------- docs/01_array/01_16_array_sliding_window.md | 6 ++-- .../03_02_monotone_stack.md | 6 ++-- docs/04_string/04_05_string_boyer_moore.md | 22 +++++++------- docs/04_string/04_06_string_horspool.md | 8 ++--- docs/04_string/04_07_string_sunday.md | 8 ++--- docs/05_tree/05_05_segment_tree_01.md | 16 +++++----- docs/06_graph/06_02_graph_structure.md | 6 ++-- docs/06_graph/06_04_graph_bfs.md | 10 +++---- docs/07_algorithm/07_05_greedy_algorithm.md | 12 ++++---- .../08_02_memoization_search.md | 26 ++++++++-------- .../08_08_knapsack_problem_04.md | 14 ++++----- docs/08_dynamic_programming/08_11_tree_dp.md | 14 ++++----- .../08_12_state_compression_dp.md | 8 ++--- .../longest-palindromic-substring.md | 2 +- .../remove-duplicates-from-sorted-list-ii.md | 4 +-- docs/solutions/0001-0099/set-matrix-zeroes.md | 4 +-- .../0100-0199/binary-tree-maximum-path-sum.md | 6 ++-- docs/solutions/0100-0199/clone-graph.md | 8 ++--- .../0200-0299/minimum-size-subarray-sum.md | 4 +-- .../count-of-smaller-numbers-after-self.md | 6 ++-- .../0300-0399/intersection-of-two-arrays.md | 8 ++--- .../find-all-anagrams-in-a-string.md | 6 ++-- .../0400-0499/max-consecutive-ones-ii.md | 4 +-- docs/solutions/0400-0499/nth-digit.md | 4 +-- docs/solutions/0400-0499/ones-and-zeroes.md | 4 +-- .../0400-0499/sliding-window-median.md | 28 ++++++++--------- .../solutions/0400-0499/string-compression.md | 6 ++-- docs/solutions/0400-0499/target-sum.md | 30 +++++++++---------- .../0500-0599/beautiful-arrangement.md | 6 ++-- .../0500-0599/permutation-in-string.md | 8 ++--- .../0500-0599/subarray-sum-equals-k.md | 18 +++++------ ...ngest-continuous-increasing-subsequence.md | 4 +-- .../solutions/0600-0699/max-area-of-island.md | 2 +- .../0600-0699/maximum-average-subarray-i.md | 2 +- .../partition-to-k-equal-sum-subsets.md | 4 +-- .../0600-0699/stickers-to-spell-word.md | 6 ++-- .../0700-0799/subarray-product-less-than-k.md | 6 ++-- .../0800-0899/backspace-string-compare.md | 12 ++++---- docs/solutions/0800-0899/binary-gap.md | 4 +-- .../0800-0899/bricks-falling-when-hit.md | 6 ++-- docs/solutions/0800-0899/most-common-word.md | 2 +- .../number-of-lines-to-write-string.md | 2 +- .../shortest-subarray-with-sum-at-least-k.md | 14 ++++----- .../0800-0899/surface-area-of-3d-shapes.md | 8 ++--- .../0900-0999/available-captures-for-rook.md | 2 +- docs/solutions/0900-0999/long-pressed-name.md | 16 +++++----- ...nimum-number-of-k-consecutive-bit-flips.md | 8 ++--- docs/solutions/0900-0999/rle-iterator.md | 6 ++-- docs/solutions/0900-0999/sort-an-array.md | 26 ++++++++-------- .../1000-1099/grumpy-bookstore-owner.md | 4 +-- .../1000-1099/last-stone-weight-ii.md | 4 +-- .../1000-1099/max-consecutive-ones-iii.md | 6 ++-- .../delete-nodes-and-return-forest.md | 8 ++--- .../1100-1199/diet-plan-performance.md | 2 +- .../1100-1199/distribute-candies-to-people.md | 6 ++-- .../maximum-level-sum-of-a-binary-tree.md | 4 +-- .../minimum-swaps-to-group-all-1s-together.md | 8 ++--- ...-elements-in-a-contaminated-binary-tree.md | 2 +- .../get-equal-substrings-within-budget.md | 4 +-- docs/solutions/1200-1299/meeting-scheduler.md | 8 ++--- docs/solutions/1200-1299/tree-diameter.md | 8 ++--- .../1300-1399/maximum-students-taking-exam.md | 10 +++---- ...er-of-steps-to-make-two-strings-anagram.md | 4 +-- .../1300-1399/print-words-vertically.md | 2 +- .../sum-of-mutated-array-closest-to-target.md | 4 +-- ...barray-of-1s-after-deleting-one-element.md | 4 +-- ...f-vowels-in-a-substring-of-given-length.md | 4 +-- ...aximum-points-you-can-obtain-from-cards.md | 12 ++++---- .../1500-1599/count-good-triplets.md | 6 ++-- .../special-positions-in-a-binary-matrix.md | 4 +-- ...nto-the-max-number-of-unique-substrings.md | 4 +-- .../1600-1699/maximum-erasure-value.md | 2 +- ...ns-to-make-character-frequencies-unique.md | 6 ++-- .../minimum-operations-to-reduce-x-to-zero.md | 10 +++---- .../1600-1699/richest-customer-wealth.md | 6 ++-- .../minimum-xor-sum-of-two-arrays.md | 2 +- .../1800-1899/sorting-the-sentence.md | 4 +-- .../find-the-middle-index-in-array.md | 8 ++--- .../maximum-compatibility-score-sum.md | 4 +-- .../1900-1999/the-number-of-good-subsets.md | 4 +-- .../2100-2199/maximum-and-sum-of-array.md | 6 ++-- ...path-with-different-adjacent-characters.md | 8 ++--- ...e-between-maximum-and-minimum-price-sum.md | 16 +++++----- docs/solutions/2700-2799/count-of-integers.md | 14 ++++----- .../LCR/shu-zu-zhong-de-ni-xu-dui-lcof.md | 10 +++---- 88 files changed, 344 insertions(+), 344 deletions(-) diff --git a/docs/01_array/01_04_array_selection_sort.md b/docs/01_array/01_04_array_selection_sort.md index f4e93e42..39f03015 100644 --- a/docs/01_array/01_04_array_selection_sort.md +++ b/docs/01_array/01_04_array_selection_sort.md @@ -12,7 +12,7 @@ 1. **初始状态**:已排序区间为空,未排序区间为 $[0, n - 1]$。 2. **第 $i$ 趟选择**($i$ 从 $1$ 开始): - 1. 在未排序区间 $[i - 1, n - 1]$ 中找到最小元素的位置 $min\underline{\hspace{0.5em}}i$。 + 1. 在未排序区间 $[i - 1, n - 1]$ 中找到最小元素的位置 $min\_i$。 2. 将位置 $i - 1$ 的元素与位置 $min\_i$ 的元素交换。 3. 此时 $[0, i - 1]$ 为已排序区间,$[i, n - 1]$ 为未排序区间。 3. **重复步骤 2**,直到未排序区间为空,排序完成。 diff --git a/docs/01_array/01_07_array_merge_sort.md b/docs/01_array/01_07_array_merge_sort.md index aefeb9e7..456a859e 100644 --- a/docs/01_array/01_07_array_merge_sort.md +++ b/docs/01_array/01_07_array_merge_sort.md @@ -9,12 +9,12 @@ 假设数组的元素个数为 $n$ 个,则归并排序的算法步骤如下: 1. **分解过程**:递归地将当前数组平分为两部分,直到每个子数组只包含一个元素为止。 - 1. 找到数组的中间位置 $mid$,将数组划分为左、右两个子数组 $left\underline{\hspace{0.5em}}nums$ 和 $right\underline{\hspace{0.5em}}nums$。 - 2. 分别对 $left\underline{\hspace{0.5em}}nums$ 和 $right\underline{\hspace{0.5em}}nums$ 递归执行分解操作。 + 1. 找到数组的中间位置 $mid$,将数组划分为左、右两个子数组 $left\_nums$ 和 $right\_nums$。 + 2. 分别对 $left\_nums$ 和 $right\_nums$ 递归执行分解操作。 3. 最终将原数组拆分为 $n$ 个长度为 $1$ 的有序子数组。 2. **归并过程**:从长度为 $1$ 的有序子数组开始,逐步将相邻的有序子数组两两合并,最终合并为一个长度为 $n$ 的有序数组。 1. 新建数组 $nums$ 用于存放合并后的有序结果。 - 2. 设置两个指针 $left\underline{\hspace{0.5em}}i$ 和 $right\underline{\hspace{0.5em}}i$,分别指向 $left\underline{\hspace{0.5em}}nums$ 和 $right\underline{\hspace{0.5em}}nums$ 的起始位置。 + 2. 设置两个指针 $left\_i$ 和 $right\_i$,分别指向 $left\_nums$ 和 $right\_nums$ 的起始位置。 3. 比较两个指针所指元素,将较小者加入结果数组 $nums$,并将对应指针后移一位。 4. 重复上述操作,直到某一指针到达对应子数组末尾。 5. 将另一个子数组剩余的所有元素依次加入结果数组 $nums$。 diff --git a/docs/01_array/01_15_array_two_pointers.md b/docs/01_array/01_15_array_two_pointers.md index 7e46ab67..8c4aafd3 100644 --- a/docs/01_array/01_15_array_two_pointers.md +++ b/docs/01_array/01_15_array_two_pointers.md @@ -270,10 +270,10 @@ class Solution: ### 4.1 分离双指针求解步骤 -1. 定义两个指针 $left\underline{\hspace{0.5em}}1$ 和 $left\underline{\hspace{0.5em}}2$,分别指向两个数组的起始位置(均为 $0$)。 -2. 根据条件,若需要,两个指针同时右移:$left\underline{\hspace{0.5em}}1 += 1$,$left\underline{\hspace{0.5em}}2 += 1$。 -3. 若只需移动第一个数组指针,则 $left\underline{\hspace{0.5em}}1 += 1$。 -4. 若只需移动第二个数组指针,则 $left\underline{\hspace{0.5em}}2 += 1$。 +1. 定义两个指针 $left\_1$ 和 $left\_2$,分别指向两个数组的起始位置(均为 $0$)。 +2. 根据条件,若需要,两个指针同时右移:$left\_1 += 1$,$left\_2 += 1$。 +3. 若只需移动第一个数组指针,则 $left\_1 += 1$。 +4. 若只需移动第二个数组指针,则 $left\_2 += 1$。 5. 当任一指针遍历到数组末尾或满足终止条件时,结束循环。 ### 4.2 分离双指针通用模板 @@ -345,14 +345,14 @@ while left_1 < len(nums1) and left_2 < len(nums2): ##### 思路 1:分离双指针 1. 先对 $nums1$ 和 $nums2$ 排序。 -2. 用两个指针 $left\underline{\hspace{0.5em}}1$、$left\underline{\hspace{0. +2. 用两个指针 $left\_1$、$left\underline{\hspace{0. 5em}}2$ 分别从两个数组头部开始遍历。 -3. 若 $nums1[left\underline{\hspace{0.5em}}1] == nums2[left\underline -{\hspace{0.5em}}2]$,将该元素(去重)加入结果,并同时右移 $left\underline{\hspace{0.5em}}1$、$left\underline +3. 若 $nums1[left\_1] == nums2[left\underline +{\hspace{0.5em}}2]$,将该元素(去重)加入结果,并同时右移 $left\_1$、$left\underline {\hspace{0.5em}}2$。 -4. 若 $nums1[left\underline{\hspace{0.5em}}1] < nums2[left\underline -{\hspace{0.5em}}2]$,则 $left\underline{\hspace{0.5em}}1$ 右移。 -5. 若 $nums1[left\underline{\hspace{0.5em}}1] > nums2[left\underline +4. 若 $nums1[left\_1] < nums2[left\underline +{\hspace{0.5em}}2]$,则 $left\_1$ 右移。 +5. 若 $nums1[left\_1] > nums2[left\underline {\hspace{0.5em}}2]$,则 $left\underline {\hspace{0.5em}}2$ 右移。 6. 遍历结束后返回结果数组。 diff --git a/docs/01_array/01_16_array_sliding_window.md b/docs/01_array/01_16_array_sliding_window.md index dc79527e..e51acf66 100644 --- a/docs/01_array/01_16_array_sliding_window.md +++ b/docs/01_array/01_16_array_sliding_window.md @@ -28,11 +28,11 @@ ### 3.1 固定长度滑动窗口算法步骤 -假设窗口大小为 $window\underline{\hspace{0.5em}}size$,步骤如下: +假设窗口大小为 $window\_size$,步骤如下: 1. 定义两个指针 $left$ 和 $right$,初始都指向序列起始位置($left = 0, right = 0$),区间 $[left, right]$ 表示当前窗口。 2. 不断右移 $right$,将元素加入窗口(如 `window.append(nums[right])`)。 -3. 当窗口长度达到 $window\underline{\hspace{0.5em}}size$(即 `right - left + 1 >= window_size`)时: +3. 当窗口长度达到 $window\_size$(即 `right - left + 1 >= window_size`)时: - 判断窗口内元素是否满足题目要求,若满足则更新答案。 - 右移 $left$(`left += 1`),保持窗口长度不变。 4. 重复上述过程,直到 $right$ 遍历完整个数组。 @@ -107,7 +107,7 @@ while right < len(nums): 1. 用 $window\_sum$ 维护当前窗口内元素和,$ans$ 统计满足条件的子数组个数。 2. 使用两个指针 $left$、$right$,初始都为 $0$。 -3. 每次将 $arr[right]$ 加入 $window\underline{\hspace{0.5em}}sum$,$right$ 右移。 +3. 每次将 $arr[right]$ 加入 $window\_sum$,$right$ 右移。 4. 当窗口长度达到 $k$(即 `right - left + 1 >= k`)时,判断窗口平均值是否大于等于 $threshold$,满足则 $ans + 1$。 5. 然后将 $arr[left]$ 移出窗口,$left$ 右移,保证窗口长度始终为 $k$。 6. 重复上述过程直到遍历完整个数组,最后返回 $ans$。 diff --git a/docs/03_stack_queue_hash_table/03_02_monotone_stack.md b/docs/03_stack_queue_hash_table/03_02_monotone_stack.md index eac86b1d..6aef0064 100644 --- a/docs/03_stack_queue_hash_table/03_02_monotone_stack.md +++ b/docs/03_stack_queue_hash_table/03_02_monotone_stack.md @@ -165,14 +165,14 @@ def monotoneDecreasingStack(nums): 第二种思路是使用单调递增栈。因为 $nums1$ 是 $nums2$ 的子集,所以我们可以先遍历一遍 $nums2$,并构造单调递增栈,求出 $nums2$ 中每个元素右侧下一个更大的元素。然后将其存储到哈希表中。然后再遍历一遍 $nums1$,从哈希表中取出对应结果,存放到答案数组中。这种解法的时间复杂度是 $O(n)$。具体做法如下: -- 使用数组 $res$ 存放答案。使用 $stack$ 表示单调递增栈。使用哈希表 $num\underline{\hspace{0.5em}}map$ 用于存储 $nums2$ 中下一个比当前元素大的数值,映射关系为 **当前元素值:下一个比当前元素大的数值**。 +- 使用数组 $res$ 存放答案。使用 $stack$ 表示单调递增栈。使用哈希表 $num\_map$ 用于存储 $nums2$ 中下一个比当前元素大的数值,映射关系为 **当前元素值:下一个比当前元素大的数值**。 - 遍历数组 $nums2$,对于当前元素: - 如果当前元素值较小,则直接让当前元素值入栈。 - 如果当前元素值较大,则一直出栈,直到当前元素值小于栈顶元素。 - - 出栈时,出栈元素是第一个大于当前元素值的元素。则将其映射到 $num\underline{\hspace{0.5em}}map$ 中。 + - 出栈时,出栈元素是第一个大于当前元素值的元素。则将其映射到 $num\_map$ 中。 - 遍历完数组 $nums2$,建立好所有元素下一个更大元素的映射关系之后,再遍历数组 $nums1$。 -- 从 $num\underline{\hspace{0.5em}}map$ 中取出对应的值,将其加入到答案数组中。 +- 从 $num\_map$ 中取出对应的值,将其加入到答案数组中。 - 最终输出答案数组 $res$。 #### 4.1.4 代码 diff --git a/docs/04_string/04_05_string_boyer_moore.md b/docs/04_string/04_05_string_boyer_moore.md index a9c78b43..d9b415d0 100644 --- a/docs/04_string/04_05_string_boyer_moore.md +++ b/docs/04_string/04_05_string_boyer_moore.md @@ -145,13 +145,13 @@ BM 算法的关键在于两种启发式移动规则:**坏字符规则(Bad Ch BM 算法的整体流程如下: 1. 计算文本串 $T$ 的长度 $n$ 和模式串 $p$ 的长度 $m$。 -2. 对模式串 $p$ 进行预处理,分别生成坏字符表 $bc\underline{\hspace{0.5em}}table$ 和好后缀规则后移位数表 $gs\underline{\hspace{0.5em}}table$。 +2. 对模式串 $p$ 进行预处理,分别生成坏字符表 $bc\_table$ 和好后缀规则后移位数表 $gs\_table$。 3. 将模式串 $p$ 的头部与文本串 $T$ 的当前位置 $i$ 对齐,初始 $i = 0$。每次从模式串的末尾($j = m - 1$)开始向前逐位比较: - 如果 $T[i + j]$ 与 $p[j]$ 相等,则继续向前比较下一个字符。 - 如果模式串所有字符均匹配,则返回当前匹配的起始位置 $i$。 - 如果 $T[i + j]$ 与 $p[j]$ 不相等: - - 分别根据坏字符表和好后缀表,计算坏字符移动距离 $bad\underline{\hspace{0.5em}}move$ 和好后缀移动距离 $good\underline{\hspace{0.5em}}move$。 - - 取两者的最大值作为本轮的实际移动距离,即 $i += \max(bad\underline{\hspace{0.5em}}move,\, good\underline{\hspace{0.5em}}move)$,然后继续下一轮匹配。 + - 分别根据坏字符表和好后缀表,计算坏字符移动距离 $bad\_move$ 和好后缀移动距离 $good\_move$。 + - 取两者的最大值作为本轮的实际移动距离,即 $i += \max(bad\_move,\, good\_move)$,然后继续下一轮匹配。 4. 如果模式串移动到文本串末尾仍未找到匹配,则返回 $-1$。 该流程充分利用了坏字符和好后缀两种规则,实现了高效的字符串匹配。 @@ -164,11 +164,11 @@ BM 算法的匹配过程本身实现相对简单,真正的难点主要集中 坏字符位置表的构建非常直观,具体步骤如下: -- 创建一个哈希表 $bc\underline{\hspace{0.5em}}table$,用于记录每个字符在模式串中最后一次出现的位置,即 $bc\underline{\hspace{0.5em}}table[bad\underline{\hspace{0.5em}}char]$ 表示坏字符 $bad\underline{\hspace{0.5em}}char$ 在模式串中的最右下标。 +- 创建一个哈希表 $bc\_table$,用于记录每个字符在模式串中最后一次出现的位置,即 $bc\_table[bad\_char]$ 表示坏字符 $bad\_char$ 在模式串中的最右下标。 - 遍历模式串 $p$,将每个字符 $p[i]$ 及其下标 $i$ 存入哈希表。若某字符在模式串中多次出现,则后出现的下标会覆盖前面的值,确保记录的是最右侧的位置。 -在 BM 算法匹配过程中,如果 $bad\underline{\hspace{0.5em}}char$ 不在 $bc\underline{\hspace{0.5em}}table$ 中,则视为其最右位置为 $-1$;如果存在,则直接取 $bc\underline{\hspace{0.5em}}table[bad\underline{\hspace{0.5em}}char]$。据此即可计算模式串本轮应向右移动的距离。 +在 BM 算法匹配过程中,如果 $bad\_char$ 不在 $bc\_table$ 中,则视为其最右位置为 $-1$;如果存在,则直接取 $bc\_table[bad\_char]$。据此即可计算模式串本轮应向右移动的距离。 坏字符位置表的实现代码如下: @@ -226,7 +226,7 @@ def generateSuffixArray(p: str): return suffix ``` -有了 $suffix$ 数组后,我们可以基于它构建好后缀规则的后移位数表 $gs\underline{\hspace{0.5em}}list$。该表用一个数组表示,其中 $gs\underline{\hspace{0.5em}}list[j]$ 表示在模式串第 $j$ 位遇到坏字符时,根据好后缀规则可以向右移动的距离。 +有了 $suffix$ 数组后,我们可以基于它构建好后缀规则的后移位数表 $gs\_list$。该表用一个数组表示,其中 $gs\_list[j]$ 表示在模式串第 $j$ 位遇到坏字符时,根据好后缀规则可以向右移动的距离。 根据「2.2 好后缀规则」的分析,好后缀的移动分为三种情况: @@ -236,13 +236,13 @@ def generateSuffixArray(p: str): 实际上,情况 2 和情况 3 可以合并处理(情况 3 可视为最长前缀长度为 $0$ 的特殊情况)。当某个坏字符同时满足多种情况时,应优先选择移动距离最小的方案,以避免遗漏可能的匹配。例如,若既有匹配子串又有匹配前缀,应优先采用匹配子串的移动方式。 -具体构建 $gs\underline{\hspace{0.5em}}list$ 的步骤如下: +具体构建 $gs\_list$ 的步骤如下: -- 首先,假设所有位置均为情况 3,即 $gs\underline{\hspace{0.5em}}list[i] = m$。 -- 然后,利用后缀和前缀的匹配关系,更新情况 2 下的移动距离:$gs\underline{\hspace{0.5em}}list[j] = m - 1 - i$,其中 $j$ 是好后缀前的坏字符位置,$i$ 是最长前缀的末尾下标,$m - 1 - i$ 为可移动的距离。 -- 最后,处理情况 1:对于好后缀的左端点($m - 1 - suffix[i]$ 处)遇到坏字符时,更新其可移动距离为 $gs\underline{\hspace{0.5em}}list[m - 1 - suffix[i]] = m - 1 - i$。 +- 首先,假设所有位置均为情况 3,即 $gs\_list[i] = m$。 +- 然后,利用后缀和前缀的匹配关系,更新情况 2 下的移动距离:$gs\_list[j] = m - 1 - i$,其中 $j$ 是好后缀前的坏字符位置,$i$ 是最长前缀的末尾下标,$m - 1 - i$ 为可移动的距离。 +- 最后,处理情况 1:对于好后缀的左端点($m - 1 - suffix[i]$ 处)遇到坏字符时,更新其可移动距离为 $gs\_list[m - 1 - suffix[i]] = m - 1 - i$。 -下面是生成好后缀规则后移位数表 $gs\underline{\hspace{0.5em}}list$ 的代码: +下面是生成好后缀规则后移位数表 $gs\_list$ 的代码: ```python # 生成好后缀规则后移位数表 diff --git a/docs/04_string/04_06_string_horspool.md b/docs/04_string/04_06_string_horspool.md index 40aec329..688b8f8b 100644 --- a/docs/04_string/04_06_string_horspool.md +++ b/docs/04_string/04_06_string_horspool.md @@ -23,11 +23,11 @@ Horspool 算法本质上继承了 Boyer-Moore 的思想,但只保留了「坏 Horspool 算法流程如下: 1. 设文本串 $T$ 长度为 $n$,模式串 $p$ 长度为 $m$。 -2. 预处理模式串 $p$,生成后移位数表 $bc\underline{\hspace{0.5em}}table$。 +2. 预处理模式串 $p$,生成后移位数表 $bc\_table$。 3. 从文本串起始位置 $i = 0$ 开始,将模式串与文本串对齐,比较方式如下: - 从模式串末尾 $j = m - 1$ 开始,依次向前比较 $T[i + j]$ 与 $p[j]$。 - 如果全部字符匹配,返回 $i$,即匹配起始位置。 - - 如果遇到不匹配,查找 $T[i + m - 1]$ 在 $bc\underline{\hspace{0.5em}}table$ 中的值,右移相应距离(如果未出现则右移 $m$)。 + - 如果遇到不匹配,查找 $T[i + m - 1]$ 在 $bc\_table$ 中的值,右移相应距离(如果未出现则右移 $m$)。 4. 如果遍历完文本串仍未找到匹配,返回 $-1$。 ## 3. Horspool 算法代码实现 @@ -36,10 +36,10 @@ Horspool 算法流程如下: 后移位数表的生成非常简单,类似于 Boyer-Moore 算法的坏字符表: -- 用一个哈希表 $bc\underline{\hspace{0.5em}}table$,记录每个字符在模式串中可向右移动的距离。 +- 用一个哈希表 $bc\_table$,记录每个字符在模式串中可向右移动的距离。 - 遍历模式串 $p$,对每个字符 $p[i]$,将 $m - 1 - i$ 作为其移动距离存入表中。如果字符重复,保留最右侧的距离。 -匹配时,如果 $T[i + m - 1]$ 不在表中,则右移 $m$;如果在表中,则右移 $bc\underline{\hspace{0.5em}}table[T[i + m - 1]]$。 +匹配时,如果 $T[i + m - 1]$ 不在表中,则右移 $m$;如果在表中,则右移 $bc\_table[T[i + m - 1]]$。 后移位数表代码如下: diff --git a/docs/04_string/04_07_string_sunday.md b/docs/04_string/04_07_string_sunday.md index 19c51285..9e442089 100644 --- a/docs/04_string/04_07_string_sunday.md +++ b/docs/04_string/04_07_string_sunday.md @@ -26,13 +26,13 @@ Sunday 算法的思想与 Boyer-Moore 算法类似,但 Sunday 算法始终从 Sunday 算法的具体流程如下: - 设文本串 $T$ 长度为 $n$,模式串 $p$ 长度为 $m$。 -- 首先对模式串 $p$ 进行预处理,生成后移位数表 $bc\underline{\hspace{0.5em}}table$。 +- 首先对模式串 $p$ 进行预处理,生成后移位数表 $bc\_table$。 - 令 $i = 0$,表示当前模式串 $p$ 的起始位置与文本串 $T$ 的第 $i$ 位对齐。 - 在每一轮匹配中,从头开始比较 $T[i + j]$ 与 $p[j]$($j$ 从 $0$ 到 $m-1$): - 如果所有字符均匹配,则返回当前匹配的起始位置 $i$。 - 如果出现不匹配,或未全部匹配完毕,则: - 检查 $T[i + m]$(即当前匹配窗口末尾的下一个字符): - - 如果 $T[i + m]$ 存在于后移位数表中,则将 $i$ 增加 $bc\underline{\hspace{0.5em}}table[T[i + m]]$,即将模式串向右滑动相应距离。 + - 如果 $T[i + m]$ 存在于后移位数表中,则将 $i$ 增加 $bc\_table[T[i + m]]$,即将模式串向右滑动相应距离。 - 如果 $T[i + m]$ 不存在于后移位数表中,则将 $i$ 增加 $m + 1$,即整体右移 $m + 1$ 位。 - 若遍历完整个文本串仍未找到匹配,则返回 $-1$。 @@ -42,10 +42,10 @@ Sunday 算法的具体流程如下: 后移位数表的实现非常简洁,与 Horspool 算法类似。具体思路如下: -- 使用一个哈希表 $bc\underline{\hspace{0.5em}}table$,其中 $bc\underline{\hspace{0.5em}}table[bad\underline{\hspace{0.5em}}char]$ 表示遇到该字符时,模式串可以向右移动的距离。 +- 使用一个哈希表 $bc\_table$,其中 $bc\_table[bad\_char]$ 表示遇到该字符时,模式串可以向右移动的距离。 - 遍历模式串 $p$,将每个字符 $p[i]$ 作为键,其对应的移动距离 $m - i$ 作为值存入字典。如果字符重复出现,则以最右侧(下标最大的)位置为准,覆盖之前的值。这样,哈希表中存储的就是每个字符在模式串中最右侧出现时可向右移动的距离。 -在 Sunday 算法匹配过程中,如果 $T[i + m]$ 不在 $bc\underline{\hspace{0.5em}}table$ 中,则默认移动 $m + 1$ 位,即将模式串整体右移到当前匹配窗口末尾的下一个字符之后。如果 $T[i + m]$ 存在于表中,则移动距离为 $bc\underline{\hspace{0.5em}}table[T[i + m]]$。这样即可高效计算每次滑动的步长。 +在 Sunday 算法匹配过程中,如果 $T[i + m]$ 不在 $bc\_table$ 中,则默认移动 $m + 1$ 位,即将模式串整体右移到当前匹配窗口末尾的下一个字符之后。如果 $T[i + m]$ 存在于表中,则移动距离为 $bc\_table[T[i + m]]$。这样即可高效计算每次滑动的步长。 后移位数表的代码如下: diff --git a/docs/05_tree/05_05_segment_tree_01.md b/docs/05_tree/05_05_segment_tree_01.md index 0b639936..e8a0c9b0 100644 --- a/docs/05_tree/05_05_segment_tree_01.md +++ b/docs/05_tree/05_05_segment_tree_01.md @@ -28,19 +28,19 @@ 线段树的数组存储编号规则如下: - 根节点编号为 $0$。 -- 如果某节点编号为 $i$,则其左孩子编号为 $2 \tiems i + 1$,右孩子编号为 $2 \tiems i + 2$。 +- 如果某节点编号为 $i$,则其左孩子编号为 $2 \times i + 1$,右孩子编号为 $2 \times i + 2$。 - 如果某节点编号为 $i$(且 $i > 0$),其父节点编号为 $(i - 1) // 2$。 这样,我们可以用一个数组来存储整棵线段树。那么数组的大小如何确定呢? -- 理想情况下,$n$ 个叶子节点构成的线段树是一棵满二叉树,总节点数为 $2 \tiems n - 1$。因此,数组大小取 $2 \tiems n$ 足够。 -- 但实际上,为了适配任意长度的区间,线段树的深度为 $\lceil \log_2 n \rceil$,最坏情况下节点总数约为 $2^{\lceil \log_2 n \rceil + 1} - 1$,可近似为 $4 \tiems n$。因此,通常分配 $4 \tiems n$ 大小的数组即可保证安全。 +- 理想情况下,$n$ 个叶子节点构成的线段树是一棵满二叉树,总节点数为 $2 \times n - 1$。因此,数组大小取 $2 \times n$ 足够。 +- 但实际上,为了适配任意长度的区间,线段树的深度为 $\lceil \log_2 n \rceil$,最坏情况下节点总数约为 $2^{\lceil \log_2 n \rceil + 1} - 1$,可近似为 $4 \times n$。因此,通常分配 $4 \times n$ 大小的数组即可保证安全。 ### 2.2 线段树的构建方法 ![线段树父子节点下标关系](https://qcdn.itcharge.cn/images/20240511173417.png) -如上图所示,编号为 $i$ 的节点,其左右孩子编号分别为 $2 \tiems i + 1$ 和 $2 \tiems i + 2$。因此,线段树的构建非常适合递归实现。具体步骤如下: +如上图所示,编号为 $i$ 的节点,其左右孩子编号分别为 $2 \times i + 1$ 和 $2 \times i + 2$。因此,线段树的构建非常适合递归实现。具体步骤如下: 1. 如果当前区间为叶子节点($left == right$),则节点值为对应元素值。 2. 如果为非叶子节点,递归构建左、右子树。 @@ -225,18 +225,18 @@ def __query_interval(self, q_left, q_right, index, left, right): ### 3.3 区间更新 -> **区间更新**:即将区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 内所有元素批量修改为 $val$。 +> **区间更新**:即将区间 $[q\_left, q\_right]$ 内所有元素批量修改为 $val$。 #### 3.3.1 延迟标记(懒惰标记) -线段树的区间更新如果每次都递归到所有被覆盖的叶子节点,复杂度会退化为 $O(n)$。为避免无用的重复更新,线段树引入了 **延迟标记**(懒惰标记):当某个节点区间 $[left, right]$ 被更新区间 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 完全覆盖时,只需直接更新该节点的值,并打上延迟标记,表示其子节点尚未被真正更新。只有在后续递归访问到子节点时,才将更新操作「下推」到子节点。 +线段树的区间更新如果每次都递归到所有被覆盖的叶子节点,复杂度会退化为 $O(n)$。为避免无用的重复更新,线段树引入了 **延迟标记**(懒惰标记):当某个节点区间 $[left, right]$ 被更新区间 $[q\_left, q\_right]$ 完全覆盖时,只需直接更新该节点的值,并打上延迟标记,表示其子节点尚未被真正更新。只有在后续递归访问到子节点时,才将更新操作「下推」到子节点。 这样,区间更新和区间查询的时间复杂度都能保持 $O(\log_2 n)$。 区间更新的主要步骤如下: -1. 如果 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 完全覆盖当前节点区间 $[left, right]$,则直接更新当前节点的值,并设置延迟标记。 -2. 如果 $[q\underline{\hspace{0.5em}}left, q\underline{\hspace{0.5em}}right]$ 与 $[left, right]$ 无交集,直接返回。 +1. 如果 $[q\_left, q\_right]$ 完全覆盖当前节点区间 $[left, right]$,则直接更新当前节点的值,并设置延迟标记。 +2. 如果 $[q\_left, q\_right]$ 与 $[left, right]$ 无交集,直接返回。 3. 若有部分重叠,先将当前节点的延迟标记下推到子节点(如果有),然后递归更新左右子区间,最后更新当前节点的值。 #### 3.3.2 下推延迟标记 diff --git a/docs/06_graph/06_02_graph_structure.md b/docs/06_graph/06_02_graph_structure.md index 7a0512ea..b54879dd 100644 --- a/docs/06_graph/06_02_graph_structure.md +++ b/docs/06_graph/06_02_graph_structure.md @@ -10,10 +10,10 @@ #### 1.1.1 邻接矩阵的原理描述 -> **邻接矩阵(Adjacency Matrix)**:使用一个二维数组 $adj\underline{\hspace{0.5em}}matrix$ 来存储顶点之间的邻接关系。 +> **邻接矩阵(Adjacency Matrix)**:使用一个二维数组 $adj\_matrix$ 来存储顶点之间的邻接关系。 > -> - 对于无权图来说,如果 $adj\underline{\hspace{0.5em}}matrix[i][j]$ 为 $1$,则说明顶点 $v_i$ 到 $v_j$ 存在边,如果 $adj\underline{\hspace{0.5em}}matrix[i][j]$ 为 $0$,则说明顶点 $v_i$ 到 $v_j$ 不存在边。 -> - 对于带权图来说,如果 $adj\underline{\hspace{0.5em}}matrix[i][j]$ 为 $w$,并且 $w \ne \infty$(即 `w != float('inf')`),则说明顶点 $v_i$ 到 $v_j$ 的权值为 $w$。如果 $adj\underline{\hspace{0.5em}}matrix[i][j]$ 为 $\infty$(即 `float('inf')`),则说明顶点 $v_i$ 到 $v_j$ 不存在边。 +> - 对于无权图来说,如果 $adj\_matrix[i][j]$ 为 $1$,则说明顶点 $v_i$ 到 $v_j$ 存在边,如果 $adj\_matrix[i][j]$ 为 $0$,则说明顶点 $v_i$ 到 $v_j$ 不存在边。 +> - 对于带权图来说,如果 $adj\_matrix[i][j]$ 为 $w$,并且 $w \ne \infty$(即 `w != float('inf')`),则说明顶点 $v_i$ 到 $v_j$ 的权值为 $w$。如果 $adj\_matrix[i][j]$ 为 $\infty$(即 `float('inf')`),则说明顶点 $v_i$ 到 $v_j$ 不存在边。 在下面的示意图中,左侧是一个无向图,右侧则是该无向图对应的邻接矩阵结构。 diff --git a/docs/06_graph/06_04_graph_bfs.md b/docs/06_graph/06_04_graph_bfs.md index 7f1e8aff..bc941556 100644 --- a/docs/06_graph/06_04_graph_bfs.md +++ b/docs/06_graph/06_04_graph_bfs.md @@ -146,10 +146,10 @@ Solution().bfs(graph, "0") 1. 使用哈希表 $visited$ 来存储原图中被访问过的节点和克隆图中对应节点,键值对为「原图被访问过的节点:克隆图中对应节点」。使用队列 $queue$ 存放节点。 2. 根据起始节点 $node$,创建一个新的节点,并将其添加到哈希表 $visited$ 中,即 `visited[node] = Node(node.val, [])`。然后将起始节点放入队列中,即 `queue.append(node)`。 -3. 从队列中取出第一个节点 $node\underline{\hspace{0.5em}}u$。访问节点 $node\underline{\hspace{0.5em}}u$。 -4. 遍历节点 $node\underline{\hspace{0.5em}}u$ 的所有未访问邻接节点 $node\underline{\hspace{0.5em}}v$(节点 $node\underline{\hspace{0.5em}}v$ 不在 $visited$ 中)。 -5. 根据节点 $node\underline{\hspace{0.5em}}v$ 创建一个新的节点,并将其添加到哈希表 $visited$ 中,即 `visited[node_v] = Node(node_v.val, [])`。 -6. 然后将节点 $node\underline{\hspace{0.5em}}v$ 放入队列 $queue$ 中,即 `queue.append(node_v)`。 +3. 从队列中取出第一个节点 $node\_u$。访问节点 $node\_u$。 +4. 遍历节点 $node\_u$ 的所有未访问邻接节点 $node\_v$(节点 $node\_v$ 不在 $visited$ 中)。 +5. 根据节点 $node\_v$ 创建一个新的节点,并将其添加到哈希表 $visited$ 中,即 `visited[node_v] = Node(node_v.val, [])`。 +6. 然后将节点 $node\_v$ 放入队列 $queue$ 中,即 `queue.append(node_v)`。 7. 重复步骤 $3 \sim 6$,直到队列 $queue$ 为空。 8. 广度优先搜索结束,返回起始节点的克隆节点(即 $visited[node]$)。 @@ -227,7 +227,7 @@ class Solution: 1. 使用 $ans$ 记录最大岛屿面积。 2. 遍历二维数组的每一个元素,对于每个值为 $1$ 的元素: - 1. 将该元素置为 $0$。并使用队列 $queue$ 存储该节点位置。使用 $temp\underline{\hspace{0.5em}}ans$ 记录当前岛屿面积。 + 1. 将该元素置为 $0$。并使用队列 $queue$ 存储该节点位置。使用 $temp\_ans$ 记录当前岛屿面积。 2. 然后从队列 $queue$ 中取出第一个节点位置 $(i, j)$。遍历该节点位置上、下、左、右四个方向上的相邻节点。并将其置为 $0$(避免重复搜索)。并将其加入到队列中。并累加当前岛屿面积,即 `temp_ans += 1`。 3. 不断重复上一步骤,直到队列 $queue$ 为空。 4. 更新当前最大岛屿面积,即 `ans = max(ans, temp_ans)`。 diff --git a/docs/07_algorithm/07_05_greedy_algorithm.md b/docs/07_algorithm/07_05_greedy_algorithm.md index 9ea54759..0691a88c 100644 --- a/docs/07_algorithm/07_05_greedy_algorithm.md +++ b/docs/07_algorithm/07_05_greedy_algorithm.md @@ -120,10 +120,10 @@ 使用贪心算法的代码解决步骤描述如下: -1. 对数组 $g$、$s$ 进行从小到大排序,使用变量 $index\underline{\hspace{0.5em}}g$ 和 $index\underline{\hspace{0.5em}}s$ 分别指向 $g$、$s$ 初始位置,使用变量 $res$ 保存结果,初始化为 $0$。 -2. 对比每个元素 $g[index\underline{\hspace{0.5em}}g]$ 和 $s[index\underline{\hspace{0.5em}}s]$: - 1. 如果 $g[index\underline{\hspace{0.5em}}g] \le s[index\underline{\hspace{0.5em}}s]$,说明当前饼干满足当前孩子胃口,则答案数量加 $1$,并且向右移动 $index\underline{\hspace{0.5em}}g$ 和 $index\underline{\hspace{0.5em}}s$。 - 2. 如果 $g[index\underline{\hspace{0.5em}}g] > s[index\underline{\hspace{0.5em}}s]$,说明当前饼干无法满足当前孩子胃口,则向右移动 $index_s$,判断下一块饼干是否可以满足当前孩子胃口。 +1. 对数组 $g$、$s$ 进行从小到大排序,使用变量 $index\_g$ 和 $index\_s$ 分别指向 $g$、$s$ 初始位置,使用变量 $res$ 保存结果,初始化为 $0$。 +2. 对比每个元素 $g[index\_g]$ 和 $s[index\_s]$: + 1. 如果 $g[index\_g] \le s[index\_s]$,说明当前饼干满足当前孩子胃口,则答案数量加 $1$,并且向右移动 $index\_g$ 和 $index\_s$。 + 2. 如果 $g[index\_g] > s[index\_s]$,说明当前饼干无法满足当前孩子胃口,则向右移动 $index_s$,判断下一块饼干是否可以满足当前孩子胃口。 3. 遍历完输出答案 $res$。 ##### 思路 1:代码 @@ -203,9 +203,9 @@ class Solution: 使用贪心算法的代码解决步骤描述如下: -1. 将区间集合按照结束坐标升序排列,然后维护两个变量,一个是当前不重叠区间的结束时间 $end\underline{\hspace{0.5em}}pos$,另一个是不重叠区间的个数 $count$。初始情况下,结束坐标 $end\underline{\hspace{0.5em}}pos$ 为第一个区间的结束坐标,$count$ 为 $1$。 +1. 将区间集合按照结束坐标升序排列,然后维护两个变量,一个是当前不重叠区间的结束时间 $end\_pos$,另一个是不重叠区间的个数 $count$。初始情况下,结束坐标 $end\_pos$ 为第一个区间的结束坐标,$count$ 为 $1$。 2. 依次遍历每段区间。对于每段区间:$intervals[i]$: - 1. 如果 $end\underline{\hspace{0.5em}}pos \le intervals[i][0]$,即 $end\underline{\hspace{0.5em}}pos$ 小于等于区间起始位置,则说明出现了不重叠区间,令不重叠区间数 $count$ 加 $1$,$end\underline{\hspace{0.5em}}pos$ 更新为新区间的结束位置。 + 1. 如果 $end\_pos \le intervals[i][0]$,即 $end\_pos$ 小于等于区间起始位置,则说明出现了不重叠区间,令不重叠区间数 $count$ 加 $1$,$end\_pos$ 更新为新区间的结束位置。 3. 最终返回「总区间个数 - 不重叠区间的最多个数」即 $len(intervals) - count$ 作为答案。 ##### 思路 1:代码 diff --git a/docs/08_dynamic_programming/08_02_memoization_search.md b/docs/08_dynamic_programming/08_02_memoization_search.md index b305026f..7f8936a4 100644 --- a/docs/08_dynamic_programming/08_02_memoization_search.md +++ b/docs/08_dynamic_programming/08_02_memoization_search.md @@ -123,11 +123,11 @@ class Solution: 1. 定义从位置 $0$、和为 $0$ 开始,到达数组尾部位置为止,和为 $target$ 的方案数为 `dfs(0, 0)`。 2. 下面从位置 $0$、和为 $0$ 开始,以深度优先搜索遍历每个位置。 3. 如果当前位置 $i$ 到达最后一个位置 $size$: - 1. 如果和 $cur\underline{\hspace{0.5em}}sum$ 等于目标和 $target$,则返回方案数 $1$。 - 2. 如果和 $cur\underline{\hspace{0.5em}}sum$ 不等于目标和 $target$,则返回方案数 $0$。 -4. 递归搜索 $i + 1$ 位置,和为 $cur\underline{\hspace{0.5em}}sum - nums[i]$ 的方案数。 -5. 递归搜索 $i + 1$ 位置,和为 $cur\underline{\hspace{0.5em}}sum + nums[i]$ 的方案数。 -6. 将 4 ~ 5 两个方案数加起来就是当前位置 $i$、和为 $cur\underline{\hspace{0.5em}}sum$ 的方案数,返回该方案数。 + 1. 如果和 $cur\_sum$ 等于目标和 $target$,则返回方案数 $1$。 + 2. 如果和 $cur\_sum$ 不等于目标和 $target$,则返回方案数 $0$。 +4. 递归搜索 $i + 1$ 位置,和为 $cur\_sum - nums[i]$ 的方案数。 +5. 递归搜索 $i + 1$ 位置,和为 $cur\_sum + nums[i]$ 的方案数。 +6. 将 4 ~ 5 两个方案数加起来就是当前位置 $i$、和为 $cur\_sum$ 的方案数,返回该方案数。 7. 最终方案数为 `dfs(0, 0)`,将其作为答案返回即可。 ##### 思路 1:代码 @@ -158,18 +158,18 @@ class Solution: 在思路 1 中我们单独使用深度优先搜索对每位数字进行 `+` 或者 `-` 的方法超时了。所以我们考虑使用记忆化搜索的方式,避免进行重复搜索。 -这里我们使用哈希表 $table$ 记录遍历过的位置 $i$ 及所得到的的当前和$cur\underline{\hspace{0.5em}}sum$ 下的方案数,来避免重复搜索。具体步骤如下: +这里我们使用哈希表 $table$ 记录遍历过的位置 $i$ 及所得到的的当前和$cur\_sum$ 下的方案数,来避免重复搜索。具体步骤如下: 1. 定义从位置 $0$、和为 $0$ 开始,到达数组尾部位置为止,和为 $target$ 的方案数为 `dfs(0, 0)`。 2. 下面从位置 $0$、和为 $0$ 开始,以深度优先搜索遍历每个位置。 3. 如果当前位置 $i$ 遍历完所有位置: - 1. 如果和 $cur\underline{\hspace{0.5em}}sum$ 等于目标和 $target$,则返回方案数 $1$。 - 2. 如果和 $cur\underline{\hspace{0.5em}}sum$ 不等于目标和 $target$,则返回方案数 $0$。 -4. 如果当前位置 $i$、和为 $cur\underline{\hspace{0.5em}}sum$ 之前记录过(即使用 $table$ 记录过对应方案数),则返回该方案数。 -5. 如果当前位置 $i$、和为 $cur\underline{\hspace{0.5em}}sum$ 之前没有记录过,则: - 1. 递归搜索 $i + 1$ 位置,和为 $cur\underline{\hspace{0.5em}}sum - nums[i]$ 的方案数。 - 2. 递归搜索 $i + 1$ 位置,和为 $cur\underline{\hspace{0.5em}}sum + nums[i]$ 的方案数。 - 3. 将上述两个方案数加起来就是当前位置 $i$、和为 $cur\underline{\hspace{0.5em}}sum$ 的方案数,将其记录到哈希表 $table$ 中,并返回该方案数。 + 1. 如果和 $cur\_sum$ 等于目标和 $target$,则返回方案数 $1$。 + 2. 如果和 $cur\_sum$ 不等于目标和 $target$,则返回方案数 $0$。 +4. 如果当前位置 $i$、和为 $cur\_sum$ 之前记录过(即使用 $table$ 记录过对应方案数),则返回该方案数。 +5. 如果当前位置 $i$、和为 $cur\_sum$ 之前没有记录过,则: + 1. 递归搜索 $i + 1$ 位置,和为 $cur\_sum - nums[i]$ 的方案数。 + 2. 递归搜索 $i + 1$ 位置,和为 $cur\_sum + nums[i]$ 的方案数。 + 3. 将上述两个方案数加起来就是当前位置 $i$、和为 $cur\_sum$ 的方案数,将其记录到哈希表 $table$ 中,并返回该方案数。 6. 最终方案数为 `dfs(0, 0)`,将其作为答案返回即可。 ##### 思路 2:代码 diff --git a/docs/08_dynamic_programming/08_08_knapsack_problem_04.md b/docs/08_dynamic_programming/08_08_knapsack_problem_04.md index af9fee9f..fab0a640 100644 --- a/docs/08_dynamic_programming/08_08_knapsack_problem_04.md +++ b/docs/08_dynamic_programming/08_08_knapsack_problem_04.md @@ -80,7 +80,7 @@ class Solution: ## 6. 分组背包问题 -> **分组背包问题**:有 $n$ 组物品和一个最多能装重量为 $W$ 的背包,第 $i$ 组物品的件数为 $group\underline{\hspace{0.5em}}count[i]$,第 $i$ 组的第 $j$ 个物品重量为 $weight[i][j]$,价值为 $value[i][j]$。每组物品中最多只能选择 $1$ 件物品装入背包。请问在总重量不超过背包载重上限的情况下,能装入背包的最大价值是多少? +> **分组背包问题**:有 $n$ 组物品和一个最多能装重量为 $W$ 的背包,第 $i$ 组物品的件数为 $group\_count[i]$,第 $i$ 组的第 $j$ 个物品重量为 $weight[i][j]$,价值为 $value[i][j]$。每组物品中最多只能选择 $1$ 件物品装入背包。请问在总重量不超过背包载重上限的情况下,能装入背包的最大价值是多少? ![分组背包问题](https://qcdn.itcharge.cn/images/20240514111745.png) @@ -100,17 +100,17 @@ class Solution: ###### 3. 状态转移方程 -由于我们可以不选择 $i - 1$ 组物品中的任何物品,也可以从第 $i - 1$ 组物品的第 $0 \sim group\underline{\hspace{0.5em}}count[i - 1] - 1$ 件物品中随意选择 $1$ 件物品,所以状态 $dp[i][w]$ 可能从以下方案中选择最大值: +由于我们可以不选择 $i - 1$ 组物品中的任何物品,也可以从第 $i - 1$ 组物品的第 $0 \sim group\_count[i - 1] - 1$ 件物品中随意选择 $1$ 件物品,所以状态 $dp[i][w]$ 可能从以下方案中选择最大值: 1. 不选择第 $i - 1$ 组中的任何物品:可以获得的最大价值为 $dp[i - 1][w]$。 2. 选择第 $i - 1$ 组物品中第 $0$ 件:可以获得的最大价值为 $dp[i - 1][w - weight[i - 1][0]] + value[i - 1][0]$。 3. 选择第 $i - 1$ 组物品中第 $1$ 件:可以获得的最大价值为 $dp[i - 1][w - weight[i - 1][1]] + value[i - 1][1]$。 4. …… -5. 选择第 $i - 1$ 组物品中最后 $1$ 件:假设 $k = group\underline{\hspace{0.5em}}count[i - 1] - 1$,则可以获得的最大价值为 $dp[i - 1][w - weight[i - 1][k]] + value[i - 1][k]$。 +5. 选择第 $i - 1$ 组物品中最后 $1$ 件:假设 $k = group\_count[i - 1] - 1$,则可以获得的最大价值为 $dp[i - 1][w - weight[i - 1][k]] + value[i - 1][k]$。 则状态转移方程为: -$dp[i][w] = max \lbrace dp[i - 1][w], dp[i - 1][w - weight[i - 1][k]] + value[i - 1][k] \rbrace , \quad 0 \le k \le group\underline{\hspace{0.5em}}count[i - 1]$ +$dp[i][w] = max \lbrace dp[i - 1][w], dp[i - 1][w - weight[i - 1][k]] + value[i - 1][k] \rbrace , \quad 0 \le k \le group\_count[i - 1]$ ###### 4. 初始条件 @@ -144,7 +144,7 @@ class Solution: #### 思路 1:复杂度分析 -- **时间复杂度**:$O(n \times W \times C)$,其中 $n$ 为物品分组数量,$W$ 为背包的载重上限,$C$ 是每组物品的数量。因为 $n \times C = \sum group\underline{\hspace{0.5em}}count[i]$,所以时间复杂度也可以写成 $O(W \times \sum group\underline{\hspace{0.5em}}count[i])$。 +- **时间复杂度**:$O(n \times W \times C)$,其中 $n$ 为物品分组数量,$W$ 为背包的载重上限,$C$ 是每组物品的数量。因为 $n \times C = \sum group\_count[i]$,所以时间复杂度也可以写成 $O(W \times \sum group\_count[i])$。 - **空间复杂度**:$O(n \times W)$。 ### 6.2 分组背包问题滚动数组优化 @@ -161,7 +161,7 @@ class Solution: ###### 3. 状态转移方程 -$dp[w] = max \lbrace dp[w], \quad dp[w - weight[i - 1][k]] + value[i - 1][k] \rbrace , \quad 0 \le k \le group\underline{\hspace{0.5em}}count[i - 1]$ +$dp[w] = max \lbrace dp[w], \quad dp[w - weight[i - 1][k]] + value[i - 1][k] \rbrace , \quad 0 \le k \le group\_count[i - 1]$ ###### 4. 初始条件 @@ -195,7 +195,7 @@ class Solution: #### 思路 2:复杂度分析 -- **时间复杂度**:$O(n \times W \times C)$,其中 $n$ 为物品分组数量,$W$ 为背包的载重上限,$C$ 是每组物品的数量。因为 $n \times C = \sum group\underline{\hspace{0.5em}}count[i]$,所以时间复杂度也可以写成 $O(W \times \sum group\underline{\hspace{0.5em}}count[i])$。 +- **时间复杂度**:$O(n \times W \times C)$,其中 $n$ 为物品分组数量,$W$ 为背包的载重上限,$C$ 是每组物品的数量。因为 $n \times C = \sum group\_count[i]$,所以时间复杂度也可以写成 $O(W \times \sum group\_count[i])$。 - **空间复杂度**:$O(W)$。 ## 7. 二维费用背包问题 diff --git a/docs/08_dynamic_programming/08_11_tree_dp.md b/docs/08_dynamic_programming/08_11_tree_dp.md index 8229820b..d00bd47c 100644 --- a/docs/08_dynamic_programming/08_11_tree_dp.md +++ b/docs/08_dynamic_programming/08_11_tree_dp.md @@ -96,9 +96,9 @@ 在递归时,我们先计算左右子节点的最大贡献值,再更新维护当前最大路径和变量。最终 $ans$ 即为答案。具体步骤如下: 1. 如果根节点 $root$ 为空,则返回 $0$。 -2. 递归计算左子树的最大贡献值为 $left\underline{\hspace{0.5em}}max$。 -3. 递归计算右子树的最大贡献值为 $right\underline{\hspace{0.5em}}max$。 -4. 更新维护最大路径和变量,即 $self.ans = max \lbrace self.ans, \quad left\underline{\hspace{0.5em}}max + right\underline{\hspace{0.5em}}max + node.val \rbrace$。 +2. 递归计算左子树的最大贡献值为 $left\_max$。 +3. 递归计算右子树的最大贡献值为 $right\_max$。 +4. 更新维护最大路径和变量,即 $self.ans = max \lbrace self.ans, \quad left\_max + right\_max + node.val \rbrace$。 5. 返回以当前节点为根节点,并且经过该节点的最大贡献值。即返回 **当前节点值 + 左右子节点提供的最大贡献值中较大的一个**。 6. 最终 $self.ans$ 即为答案。 @@ -195,11 +195,11 @@ class Solution: 即:**最长路径长度 = max(某子树中的最长路径长度 + 另一子树中的最长路径长度 + 1,某个子树中的最长路径长度)**。 -对此,我们可以使用深度优先搜索递归遍历 $u$ 的所有相邻节点 $v$,并在递归遍历的同时,维护一个全局最大路径和变量 $ans$,以及当前节点 $u$ 的最大路径长度变量 $u\underline{\hspace{0.5em}}len$。 +对此,我们可以使用深度优先搜索递归遍历 $u$ 的所有相邻节点 $v$,并在递归遍历的同时,维护一个全局最大路径和变量 $ans$,以及当前节点 $u$ 的最大路径长度变量 $u\_len$。 -1. 先计算出从相邻节点 $v$ 出发的最长路径长度 $v\underline{\hspace{0.5em}}len$。 -2. 更新维护全局最长路径长度为 $self.ans = max(self.ans, \quad u\underline{\hspace{0.5em}}len + v\underline{\hspace{0.5em}}len + 1)$。 -3. 更新维护当前节点 $u$ 的最长路径长度为 $u\underline{\hspace{0.5em}}len = max(u\underline{\hspace{0.5em}}len, \quad v\underline{\hspace{0.5em}}len + 1)$。 +1. 先计算出从相邻节点 $v$ 出发的最长路径长度 $v\_len$。 +2. 更新维护全局最长路径长度为 $self.ans = max(self.ans, \quad u\_len + v\_len + 1)$。 +3. 更新维护当前节点 $u$ 的最长路径长度为 $u\_len = max(u\_len, \quad v\_len + 1)$。 因为题目限定了「相邻节点字符不同」,所以在更新全局最长路径长度和当前节点 $u$ 的最长路径长度时,我们需要判断一下节点 $u$ 与相邻节点 $v$ 的字符是否相同,只有在字符不同的条件下,才能够更新维护。 diff --git a/docs/08_dynamic_programming/08_12_state_compression_dp.md b/docs/08_dynamic_programming/08_12_state_compression_dp.md index 2f54b02c..218f6c67 100644 --- a/docs/08_dynamic_programming/08_12_state_compression_dp.md +++ b/docs/08_dynamic_programming/08_12_state_compression_dp.md @@ -175,7 +175,7 @@ 举个例子 $nums2 = \lbrace 1, 2, 3, 4 \rbrace$,$state = (1001)_2$,表示选择了第 $1$ 个元素和第 $4$ 个元素,也就是 $1$、$4$。那么 $state$ 只能从 $(1000)_2$ 和 $(0001)_2$ 这两个状态转移而来,我们只需要枚举这两种状态,并求出转移过来的异或值之和最小值。 -即状态转移方程为:$dp[state] = min(dp[state], \quad dp[state \oplus (1 \text{ <}\text{< } i)] + (nums1[i] \oplus nums2[one\underline{\hspace{0.5em}}cnt - 1]))$,其中 $state$ 第 $i$ 位一定为 $1$,$one\underline{\hspace{0.5em}}cnt$ 为 $state$ 中 $1$ 的个数。 +即状态转移方程为:$dp[state] = min(dp[state], \quad dp[state \oplus (1 \text{ <}\text{< } i)] + (nums1[i] \oplus nums2[one\_cnt - 1]))$,其中 $state$ 第 $i$ 位一定为 $1$,$one\_cnt$ 为 $state$ 中 $1$ 的个数。 ###### 4. 初始条件 @@ -281,12 +281,12 @@ class Solution: 对于当前状态 $dp[state]$,肯定是从比 $state$ 少选一个元素的状态中递推而来。我们可以枚举少选一个元素的状态,找到可以获得的最大与和,赋值给 $dp[state]$。 -即状态转移方程为:$dp[state] = min(dp[state], dp[state \oplus (1 \text{ <}\text{< } i)] + (i // 2 + 1) \text{ \& } nums[one\underline{\hspace{0.5em}}cnt - 1])$,其中: +即状态转移方程为:$dp[state] = min(dp[state], dp[state \oplus (1 \text{ <}\text{< } i)] + (i // 2 + 1) \text{ \& } nums[one\_cnt - 1])$,其中: 1. $state$ 第 $i$ 位一定为 $1$。 2. $state \oplus (1 \text{ <}\text{< } i)$ 为比 $state$ 少选一个元素的状态。 3. $i // 2 + 1$ 为篮子对应编号 -4. $nums[one\underline{\hspace{0.5em}}cnt - 1]$ 为当前正在考虑的数组元素。 +4. $nums[one\_cnt - 1]$ 为当前正在考虑的数组元素。 ###### 4. 初始条件 @@ -296,7 +296,7 @@ class Solution: 根据我们之前定义的状态,$dp[state]$ 表示为:将前 $count(state)$ 个整数放到篮子里,并且每个篮子中的整数放取情况为 $state$ 时,可以获得的最大与和。所以最终结果为 $max(dp)$。 -> 注意:当 $one\underline{\hspace{0.5em}}cnt > len(nums)$ 时,无法通过递推得到 $dp[state]$,需要跳过。 +> 注意:当 $one\_cnt > len(nums)$ 时,无法通过递推得到 $dp[state]$,需要跳过。 ##### 思路 1:代码 diff --git a/docs/solutions/0001-0099/longest-palindromic-substring.md b/docs/solutions/0001-0099/longest-palindromic-substring.md index ecf59ea1..d367b657 100644 --- a/docs/solutions/0001-0099/longest-palindromic-substring.md +++ b/docs/solutions/0001-0099/longest-palindromic-substring.md @@ -59,7 +59,7 @@ ###### 5. 最终结果 -根据我们之前定义的状态,$dp[i][j]$ 表示为:字符串 $s$ 在区间 $[i, j]$ 范围内是否是一个回文串。当判断完 $s[i: j]$ 是否为回文串时,同时判断并更新最长回文子串的起始位置 $max\underline{\hspace{0.5em}}start$ 和最大长度 $max\underline{\hspace{0.5em}}len$。则最终结果为 $s[max\underline{\hspace{0.5em}}start, max\underline{\hspace{0.5em}}start + max\underline{\hspace{0.5em}}len]$。 +根据我们之前定义的状态,$dp[i][j]$ 表示为:字符串 $s$ 在区间 $[i, j]$ 范围内是否是一个回文串。当判断完 $s[i: j]$ 是否为回文串时,同时判断并更新最长回文子串的起始位置 $max\_start$ 和最大长度 $max\_len$。则最终结果为 $s[max\_start, max\_start + max\_len]$。 ### 思路 1:代码 diff --git a/docs/solutions/0001-0099/remove-duplicates-from-sorted-list-ii.md b/docs/solutions/0001-0099/remove-duplicates-from-sorted-list-ii.md index a0e2b2d1..24b15ed6 100644 --- a/docs/solutions/0001-0099/remove-duplicates-from-sorted-list-ii.md +++ b/docs/solutions/0001-0099/remove-duplicates-from-sorted-list-ii.md @@ -34,12 +34,12 @@ 这道题的题意是需要保留所有不同数字,而重复出现的所有数字都要删除。因为给定的链表是升序排列的,所以我们要删除的重复元素在链表中的位置是连续的。所以我们可以对链表进行一次遍历,然后将连续的重复元素从链表中删除即可。具体步骤如下: -- 先使用哑节点 $dummy\underline{\hspace{0.5em}}head$ 构造一个指向 $head$ 的指针,使得可以防止从 $head$ 开始就是重复元素。 +- 先使用哑节点 $dummy\_head$ 构造一个指向 $head$ 的指针,使得可以防止从 $head$ 开始就是重复元素。 - 然后使用指针 $cur$ 表示链表中当前元素,从 $head$ 开始遍历。 - 当指针 $cur$ 的下一个元素和下下一个元素存在时: - 如果下一个元素值和下下一个元素值相同,则我们使用指针 $temp$ 保存下一个元素,并使用 $temp$ 向后遍历,跳过所有重复元素,然后令 $cur$ 的下一个元素指向 $temp$ 的下一个元素,继续向后遍历。 - 如果下一个元素值和下下一个元素值不同,则令 $cur$ 向右移动一位,继续向后遍历。 -- 当指针 $cur$ 的下一个元素或者下下一个元素不存在时,说明已经遍历完,则返回哑节点 $dummy\underline{\hspace{0.5em}}head$ 的下一个节点作为头节点。 +- 当指针 $cur$ 的下一个元素或者下下一个元素不存在时,说明已经遍历完,则返回哑节点 $dummy\_head$ 的下一个节点作为头节点。 ### 思路 1:代码 diff --git a/docs/solutions/0001-0099/set-matrix-zeroes.md b/docs/solutions/0001-0099/set-matrix-zeroes.md index 4dde4a72..95efa7a3 100644 --- a/docs/solutions/0001-0099/set-matrix-zeroes.md +++ b/docs/solutions/0001-0099/set-matrix-zeroes.md @@ -54,11 +54,11 @@ 考虑使用数组原本的元素进行记录出现 $0$ 的情况。 -1. 设定两个变量 $flag\underline{\hspace{0.5em}}row0$、$flag\underline{\hspace{0.5em}}col0$ 来标记第一行、第一列是否出现了 $0$。 +1. 设定两个变量 $flag\_row0$、$flag\_col0$ 来标记第一行、第一列是否出现了 $0$。 2. 接下来我们使用数组第一行、第一列来标记 $0$ 的情况。 3. 对数组除第一行、第一列之外的每个元素进行遍历,如果某个元素出现 $0$ 了,则使用数组的第一行、第一列对应位置来存储 $0$ 的标记。 4. 再对数组除第一行、第一列之外的每个元素进行遍历,通过对第一行、第一列的标记 $0$ 情况,进行置为 $0$ 的操作。 -5. 最后再根据 $flag\underline{\hspace{0.5em}}row0$、$flag\underline{\hspace{0.5em}}col0$ 的标记情况,对第一行、第一列进行置为 $0$ 的操作。 +5. 最后再根据 $flag\_row0$、$flag\_col0$ 的标记情况,对第一行、第一列进行置为 $0$ 的操作。 ### 思路 1:代码 diff --git a/docs/solutions/0100-0199/binary-tree-maximum-path-sum.md b/docs/solutions/0100-0199/binary-tree-maximum-path-sum.md index aa70d4c5..efe03ced 100644 --- a/docs/solutions/0100-0199/binary-tree-maximum-path-sum.md +++ b/docs/solutions/0100-0199/binary-tree-maximum-path-sum.md @@ -69,9 +69,9 @@ 在递归时,我们先计算左右子节点的最大贡献值,再更新维护当前最大路径和变量。最终 $ans$ 即为答案。具体步骤如下: 1. 如果根节点 $root$ 为空,则返回 $0$。 -2. 递归计算左子树的最大贡献值为 $left\underline{\hspace{0.5em}}max$。 -3. 递归计算右子树的最大贡献值为 $right\underline{\hspace{0.5em}}max$。 -4. 更新维护最大路径和变量,即 $self.ans = max \lbrace self.ans, \quad left\underline{\hspace{0.5em}}max + right\underline{\hspace{0.5em}}max + node.val \rbrace$。 +2. 递归计算左子树的最大贡献值为 $left\_max$。 +3. 递归计算右子树的最大贡献值为 $right\_max$。 +4. 更新维护最大路径和变量,即 $self.ans = max \lbrace self.ans, \quad left\_max + right\_max + node.val \rbrace$。 5. 返回以当前节点为根节点,并且经过该节点的最大贡献值。即返回 **当前节点值 + 左右子节点提供的最大贡献值中较大的一个**。 6. 最终 $self.ans$ 即为答案。 diff --git a/docs/solutions/0100-0199/clone-graph.md b/docs/solutions/0100-0199/clone-graph.md index 7ed17f1e..c5b46836 100644 --- a/docs/solutions/0100-0199/clone-graph.md +++ b/docs/solutions/0100-0199/clone-graph.md @@ -93,10 +93,10 @@ class Solution: 1. 使用哈希表 $visited$ 来存储原图中被访问过的节点和克隆图中对应节点,键值对为「原图被访问过的节点:克隆图中对应节点」。使用队列 $queue$ 存放节点。 2. 根据起始节点 $node$,创建一个新的节点,并将其添加到哈希表 $visited$ 中,即 `visited[node] = Node(node.val, [])`。然后将起始节点放入队列中,即 `queue.append(node)`。 -3. 从队列中取出第一个节点 $node\underline{\hspace{0.5em}}u$。访问节点 $node\underline{\hspace{0.5em}}u$。 -4. 遍历节点 $node\underline{\hspace{0.5em}}u$ 的所有未访问邻接节点 $node\underline{\hspace{0.5em}}v$(节点 $node\underline{\hspace{0.5em}}v$ 不在 $visited$ 中)。 -5. 根据节点 $node\underline{\hspace{0.5em}}v$ 创建一个新的节点,并将其添加到哈希表 $visited$ 中,即 `visited[node_v] = Node(node_v.val, [])`。 -6. 然后将节点 $node\underline{\hspace{0.5em}}v$ 放入队列 $queue$ 中,即 `queue.append(node_v)`。 +3. 从队列中取出第一个节点 $node\_u$。访问节点 $node\_u$。 +4. 遍历节点 $node\_u$ 的所有未访问邻接节点 $node\_v$(节点 $node\_v$ 不在 $visited$ 中)。 +5. 根据节点 $node\_v$ 创建一个新的节点,并将其添加到哈希表 $visited$ 中,即 `visited[node_v] = Node(node_v.val, [])`。 +6. 然后将节点 $node\_v$ 放入队列 $queue$ 中,即 `queue.append(node_v)`。 7. 重复步骤 $3 \sim 6$,直到队列 $queue$ 为空。 8. 广度优先搜索结束,返回起始节点的克隆节点(即 $visited[node]$)。 diff --git a/docs/solutions/0200-0299/minimum-size-subarray-sum.md b/docs/solutions/0200-0299/minimum-size-subarray-sum.md index 9f6e11f9..c8ab0285 100644 --- a/docs/solutions/0200-0299/minimum-size-subarray-sum.md +++ b/docs/solutions/0200-0299/minimum-size-subarray-sum.md @@ -45,8 +45,8 @@ 用滑动窗口来记录连续子数组的和,设定两个指针:$left$、$right$,分别指向滑动窗口的左右边界,保证窗口中的和刚好大于等于 $target$。 1. 一开始,$left$、$right$ 都指向 $0$。 -2. 向右移动 $right$,将最右侧元素加入当前窗口和 $window\underline{\hspace{0.5em}}sum$ 中。 -3. 如果 $window\underline{\hspace{0.5em}}sum \ge target$,则不断右移 $left$,缩小滑动窗口长度,并更新窗口和的最小值,直到 $window\underline{\hspace{0.5em}}sum < target$。 +2. 向右移动 $right$,将最右侧元素加入当前窗口和 $window\_sum$ 中。 +3. 如果 $window\_sum \ge target$,则不断右移 $left$,缩小滑动窗口长度,并更新窗口和的最小值,直到 $window\_sum < target$。 4. 然后继续右移 $right$,直到 $right \ge len(nums)$ 结束。 5. 输出窗口和的最小值作为答案。 diff --git a/docs/solutions/0300-0399/count-of-smaller-numbers-after-self.md b/docs/solutions/0300-0399/count-of-smaller-numbers-after-self.md index 7b759380..62785ea0 100644 --- a/docs/solutions/0300-0399/count-of-smaller-numbers-after-self.md +++ b/docs/solutions/0300-0399/count-of-smaller-numbers-after-self.md @@ -43,12 +43,12 @@ ### 思路 1:归并排序 -在使用归并排序对数组进行排序时,每当遇到 $left\underline{\hspace{0.5em}}nums[left\underline{\hspace{0.5em}}i] \le right\underline{\hspace{0.5em}}nums[right\underline{\hspace{0.5em}}i]$ 时,意味着:在合并前,左子数组当前元素 $left\underline{\hspace{0.5em}}nums[left\underline{\hspace{0.5em}}i]$ 右侧一定有 $left\underline{\hspace{0.5em}}i$ 个元素比 $left\underline{\hspace{0.5em}}nums[left\underline{\hspace{0.5em}}i]$ 小。则我们可以在归并排序的同时,记录 $nums[i]$ 右侧小于 $nums[i]$ 的元素的数量。 +在使用归并排序对数组进行排序时,每当遇到 $left\_nums[left\_i] \le right\_nums[right\_i]$ 时,意味着:在合并前,左子数组当前元素 $left\_nums[left\_i]$ 右侧一定有 $left\_i$ 个元素比 $left\_nums[left\_i]$ 小。则我们可以在归并排序的同时,记录 $nums[i]$ 右侧小于 $nums[i]$ 的元素的数量。 1. 将元素值、对应下标、右侧小于 nums[i] 的元素的数量存入数组中。 2. 对其进行归并排序。 -3. 当遇到 $left\underline{\hspace{0.5em}}nums[left\underline{\hspace{0.5em}}i] \le right\underline{\hspace{0.5em}}nums[right\underline{\hspace{0.5em}}i]$ 时,记录 $left\underline{\hspace{0.5em}}nums[left\underline{\hspace{0.5em}}i]$ 右侧比 $left\underline{\hspace{0.5em}}nums[left\underline{\hspace{0.5em}}i]$ 小的元素数量,即:`left_nums[left_i][2] += right_i`。 -4. 当合并时 $left\underline{\hspace{0.5em}}nums[left\underline{\hspace{0.5em}}i]$ 仍有剩余时,说明 $left\underline{\hspace{0.5em}}nums[left\underline{\hspace{0.5em}}i]$ 右侧有 $right\underline{\hspace{0.5em}}i$ 个小于 $left\underline{\hspace{0.5em}}nums[left\underline{\hspace{0.5em}}i]$ 的元素,记录下来,即:`left_nums[left_i][2] += right_i`。 +3. 当遇到 $left\_nums[left\_i] \le right\_nums[right\_i]$ 时,记录 $left\_nums[left\_i]$ 右侧比 $left\_nums[left\_i]$ 小的元素数量,即:`left_nums[left_i][2] += right_i`。 +4. 当合并时 $left\_nums[left\_i]$ 仍有剩余时,说明 $left\_nums[left\_i]$ 右侧有 $right\_i$ 个小于 $left\_nums[left\_i]$ 的元素,记录下来,即:`left_nums[left_i][2] += right_i`。 5. 根据下标及右侧小于 $nums[i]$ 的元素的数量,组合出答案数组,并返回答案数组。 ### 思路 1:代码 diff --git a/docs/solutions/0300-0399/intersection-of-two-arrays.md b/docs/solutions/0300-0399/intersection-of-two-arrays.md index 557e6034..76b839a5 100644 --- a/docs/solutions/0300-0399/intersection-of-two-arrays.md +++ b/docs/solutions/0300-0399/intersection-of-two-arrays.md @@ -68,10 +68,10 @@ class Solution: ### 思路 2:分离双指针 1. 对数组 $nums1$、$nums2$ 先排序。 -2. 使用两个指针 $left\underline{\hspace{0.5em}}1$、$left\underline{\hspace{0.5em}}2$。$left\underline{\hspace{0.5em}}1$ 指向第一个数组的第一个元素,即:$left\underline{\hspace{0.5em}}1 = 0$,$left\underline{\hspace{0.5em}}2$ 指向第二个数组的第一个元素,即:$left\underline{\hspace{0.5em}}2 = 0$。 -3. 如果 $nums1[left_1]$ 等于 $nums2[left_2]$,则将其加入答案数组(注意去重),并将 $left\underline{\hspace{0.5em}}1$ 和 $left\underline{\hspace{0.5em}}2$ 右移。 -4. 如果 $nums1[left_1]$ 小于 $nums2[left_2]$,则将 $left\underline{\hspace{0.5em}}1$ 右移。 -5. 如果 $nums1[left_1]$ 大于 $nums2[left_2]$,则将 $left\underline{\hspace{0.5em}}2$ 右移。 +2. 使用两个指针 $left\_1$、$left\_2$。$left\_1$ 指向第一个数组的第一个元素,即:$left\_1 = 0$,$left\_2$ 指向第二个数组的第一个元素,即:$left\_2 = 0$。 +3. 如果 $nums1[left_1]$ 等于 $nums2[left_2]$,则将其加入答案数组(注意去重),并将 $left\_1$ 和 $left\_2$ 右移。 +4. 如果 $nums1[left_1]$ 小于 $nums2[left_2]$,则将 $left\_1$ 右移。 +5. 如果 $nums1[left_1]$ 大于 $nums2[left_2]$,则将 $left\_2$ 右移。 6. 最后返回答案数组。 ### 思路 2:代码 diff --git a/docs/solutions/0400-0499/find-all-anagrams-in-a-string.md b/docs/solutions/0400-0499/find-all-anagrams-in-a-string.md index 87fdc2a5..16a1c057 100644 --- a/docs/solutions/0400-0499/find-all-anagrams-in-a-string.md +++ b/docs/solutions/0400-0499/find-all-anagrams-in-a-string.md @@ -48,11 +48,11 @@ 维护一个固定长度为 $len(p)$ 的滑动窗口。于是问题的难点变为了如何判断 $s$ 的子串和 $p$ 是异位词。可以使用两个字典来分别存储 $s$ 的子串中各个字符个数和 $p$ 中各个字符个数。如果两个字典对应的键值全相等,则说明 $s$ 的子串和 $p$ 是异位词。但是这样每一次比较的操作时间复杂度是 $O(n)$,我们可以通过在滑动数组中逐字符比较的方式来减少两个字典之间相互比较的复杂度,并用 $valid$ 记录经过验证的字符个数。整个算法步骤如下: -- 使用哈希表 $need$ 记录 $p$ 中各个字符出现次数。使用字典 $window$ 记录 $s$ 的子串中各个字符出现的次数。使用数组 $res$ 记录答案。使用 $valid$ 记录 $s$ 的子串中经过验证的字符个数。使用 $window\underline{\hspace{0.5em}}size$ 表示窗口大小,值为 $len(p)$。使用两个指针 $left$、$right$。分别指向滑动窗口的左右边界。 +- 使用哈希表 $need$ 记录 $p$ 中各个字符出现次数。使用字典 $window$ 记录 $s$ 的子串中各个字符出现的次数。使用数组 $res$ 记录答案。使用 $valid$ 记录 $s$ 的子串中经过验证的字符个数。使用 $window\_size$ 表示窗口大小,值为 $len(p)$。使用两个指针 $left$、$right$。分别指向滑动窗口的左右边界。 - 一开始,$left$、$right$ 都指向 $0$。 - 如果 $s[right]$ 出现在 $need$ 中,将最右侧字符 $s[right]$ 加入当前窗口 $window$ 中,记录该字符个数。并验证该字符是否和 $need$ 中个对应字符个数相等。如果相等则验证的字符个数加 $1$,即 `valid += 1`。 -- 如果该窗口字符长度大于等于 $window\underline{\hspace{0.5em}}size$ 个,即 $right - left + 1 \ge window\underline{\hspace{0.5em}}size$。则不断右移 $left$,缩小滑动窗口长度。 - - 如果验证字符个数 $valid$ 等于窗口长度 $window\underline{\hspace{0.5em}}size$,则 $s[left, right + 1]$ 为 $p$ 的异位词,所以将 $left$ 加入到答案数组中。 +- 如果该窗口字符长度大于等于 $window\_size$ 个,即 $right - left + 1 \ge window\_size$。则不断右移 $left$,缩小滑动窗口长度。 + - 如果验证字符个数 $valid$ 等于窗口长度 $window\_size$,则 $s[left, right + 1]$ 为 $p$ 的异位词,所以将 $left$ 加入到答案数组中。 - 如果$s[left]$ 在 $need$ 中,则更新窗口中对应字符的个数,同时维护 $valid$ 值。 - 右移 $right$,直到 $right \ge len(nums)$ 结束。 - 输出答案数组 $res$。 diff --git a/docs/solutions/0400-0499/max-consecutive-ones-ii.md b/docs/solutions/0400-0499/max-consecutive-ones-ii.md index a7b648ed..a6ac5179 100644 --- a/docs/solutions/0400-0499/max-consecutive-ones-ii.md +++ b/docs/solutions/0400-0499/max-consecutive-ones-ii.md @@ -43,11 +43,11 @@ 我们可以使用滑动窗口来解决问题。保证滑动窗口内最多有 $1$ 个 $0$。具体做法如下: -设定两个指针:$left$、$right$,分别指向滑动窗口的左右边界,保证滑动窗口内最多有 $1$ 个 $0$。使用 $zero\underline{\hspace{0.5em}}count$ 统计窗口内 $1$ 的个数。使用 $ans$ 记录答案。 +设定两个指针:$left$、$right$,分别指向滑动窗口的左右边界,保证滑动窗口内最多有 $1$ 个 $0$。使用 $zero\_count$ 统计窗口内 $1$ 的个数。使用 $ans$ 记录答案。 - 一开始,$left$、$right$ 都指向 $0$。 - 如果 $nums[right] == 0$,则窗口内 $1$ 的个数加 $1$。 -- 如果该窗口中 $1$ 的个数多于 $1$ 个,即 $zero\underline{\hspace{0.5em}}count > 1$,则不断右移 $left$,缩小滑动窗口长度,并更新窗口中 $1$ 的个数,直到 $zero\underline{\hspace{0.5em}}count \le 1$。 +- 如果该窗口中 $1$ 的个数多于 $1$ 个,即 $zero\_count > 1$,则不断右移 $left$,缩小滑动窗口长度,并更新窗口中 $1$ 的个数,直到 $zero\_count \le 1$。 - 维护更新最大连续 $1$ 的个数。然后右移 $right$,直到 $right \ge len(nums)$ 结束。 - 输出最大连续 $1$ 的个数。 diff --git a/docs/solutions/0400-0499/nth-digit.md b/docs/solutions/0400-0499/nth-digit.md index 914cb2d6..c8bcc4e6 100644 --- a/docs/solutions/0400-0499/nth-digit.md +++ b/docs/solutions/0400-0499/nth-digit.md @@ -53,7 +53,7 @@ 1. 我们可以先找到第 $n$ 位所在整数 $number$ 所对应的位数 $digit$。 2. 同时找到该位数 $digit$ 的起始整数 $start$。 3. 再计算出 $n$ 所在整数 $number$。$number$ 等于从起始数字 $start$ 开始的第 $\lfloor \frac{n - 1}{digit} \rfloor$ 个数字。即 `number = start + (n - 1) // digit`。 -4. 然后确定 $n$ 对应的是数字 $number$ 中的哪一位。即 $digit\underline{\hspace{0.5em}}idx = (n - 1) \mod digit$。 +4. 然后确定 $n$ 对应的是数字 $number$ 中的哪一位。即 $digit\_idx = (n - 1) \mod digit$。 5. 最后返回结果。 ### 思路 1:代码 @@ -90,7 +90,7 @@ class Solution: $n$ 的最大值为 $2^{31} - 1$,约为 $2 \times 10^9$。而 $9$ 位数字有 $9 \times 10^8$ 个,共 $9 \times 9 \times 10^8 = 8.1 \times 10^9 > 2 \times 10 ^ 9$,所以第 $n$ 位所在整数的位数 $digit$ 最多为 $9$ 位,最小为 $1$ 位。即 $digit$ 的取值范围为 $[1, 9]$。 -我们使用二分查找算法得到 $digit$ 之后,还可以计算出不超过 $digit - 1$ 的整数的所有位数和 $pre\underline{\hspace{0.5em}}digits = totalDigits(digit - 1)$,则第 $n$ 位数字所在整数在所有 $digit$ 位数中的下标是 $idx = n - pre\underline{\hspace{0.5em}}digits - 1$。 +我们使用二分查找算法得到 $digit$ 之后,还可以计算出不超过 $digit - 1$ 的整数的所有位数和 $pre\_digits = totalDigits(digit - 1)$,则第 $n$ 位数字所在整数在所有 $digit$ 位数中的下标是 $idx = n - pre\_digits - 1$。 得到下标 $idx$ 后,可以计算出 $n$ 所在整数 $number$。$number$ 等于从起始数字 $10^{digit - 1}$ 开始的第 $\lfloor \frac{idx}{digit} \rfloor$ 个数字。即 `number = 10 ** (digit - 1) + idx // digit`。 diff --git a/docs/solutions/0400-0499/ones-and-zeroes.md b/docs/solutions/0400-0499/ones-and-zeroes.md index e177ff0d..cf93c186 100644 --- a/docs/solutions/0400-0499/ones-and-zeroes.md +++ b/docs/solutions/0400-0499/ones-and-zeroes.md @@ -60,10 +60,10 @@ 填满最多由 $i$ 个 $0$ 和 $j$ 个 $1$ 构成的二维背包的最多物品数为下面两种情况中的最大值: -- 使用之前字符串填满容量为 $i - zero\underline{\hspace{0.5em}}num$、$j - one\underline{\hspace{0.5em}}num$ 的背包的物品数 + 当前字符串价值 +- 使用之前字符串填满容量为 $i - zero\_num$、$j - one\_num$ 的背包的物品数 + 当前字符串价值 - 选择之前字符串填满容量为 $i$、$j$ 的物品数。 -则状态转移方程为:$dp[i][j] = max(dp[i][j], dp[i - zero\underline{\hspace{0.5em}}num][j - one\underline{\hspace{0.5em}}num] + 1)$。 +则状态转移方程为:$dp[i][j] = max(dp[i][j], dp[i - zero\_num][j - one\_num] + 1)$。 ###### 4. 初始条件 diff --git a/docs/solutions/0400-0499/sliding-window-median.md b/docs/solutions/0400-0499/sliding-window-median.md index 245048f6..62a56d97 100644 --- a/docs/solutions/0400-0499/sliding-window-median.md +++ b/docs/solutions/0400-0499/sliding-window-median.md @@ -52,31 +52,31 @@ 初始化问题: -我们将所有大于中位数的元素放到 $heap\underline{\hspace{0.5em}}max$(小顶堆)中,并且元素个数向上取整。然后再将所有小于等于中位数的元素放到 $heap\underline{\hspace{0.5em}}min$(大顶堆)中,并且元素个数向下取整。这样当 $k$ 为奇数时,$heap\underline{\hspace{0.5em}}max$ 比 $heap\underline{\hspace{0.5em}}min$ 多一个元素,中位数就是 $heap\underline{\hspace{0.5em}}max$ 堆顶元素。当 $k$ 为偶数时,$heap\underline{\hspace{0.5em}}max$ 和 $heap\underline{\hspace{0.5em}}min$ 中的元素个数相同,中位数就是 $heap\underline{\hspace{0.5em}}min$ 堆顶元素和 $heap\underline{\hspace{0.5em}}max$ 堆顶元素的平均数。这个过程操作如下: +我们将所有大于中位数的元素放到 $heap\_max$(小顶堆)中,并且元素个数向上取整。然后再将所有小于等于中位数的元素放到 $heap\_min$(大顶堆)中,并且元素个数向下取整。这样当 $k$ 为奇数时,$heap\_max$ 比 $heap\_min$ 多一个元素,中位数就是 $heap\_max$ 堆顶元素。当 $k$ 为偶数时,$heap\_max$ 和 $heap\_min$ 中的元素个数相同,中位数就是 $heap\_min$ 堆顶元素和 $heap\_max$ 堆顶元素的平均数。这个过程操作如下: -- 先将数组中前 $k$ 个元素放到 $heap\underline{\hspace{0.5em}}max$ 中。 -- 再从 $heap\underline{\hspace{0.5em}}max$ 中取出 $k // 2$ 个堆顶元素放到 $heap\underline{\hspace{0.5em}}min$ 中。 +- 先将数组中前 $k$ 个元素放到 $heap\_max$ 中。 +- 再从 $heap\_max$ 中取出 $k // 2$ 个堆顶元素放到 $heap\_min$ 中。 取中位数问题(上边提到过): -- 当 $k$ 为奇数时,中位数就是 $heap\underline{\hspace{0.5em}}max$ 堆顶元素。当 $k$ 为偶数时,中位数就是 $heap\underline{\hspace{0.5em}}max$ 堆顶元素和 $heap\underline{\hspace{0.5em}}min$ 堆顶元素的平均数。 +- 当 $k$ 为奇数时,中位数就是 $heap\_max$ 堆顶元素。当 $k$ 为偶数时,中位数就是 $heap\_max$ 堆顶元素和 $heap\_min$ 堆顶元素的平均数。 窗口滑动过程中元素的添加和删除问题: - 删除:每次滑动将窗口左侧元素删除。由于 `heapq` 没有提供删除中间特定元素相对应的方法。所以我们使用「延迟删除」的方式先把待删除的元素标记上,等到待删除的元素出现在堆顶时,再将其移除。我们使用 $removes$ (哈希表)来记录待删除元素个数。 - 将窗口左侧元素删除的操作为:`removes[nums[left]] += 1`。 -- 添加:每次滑动在窗口右侧添加元素。需要根据上一步删除的结果来判断需要添加到哪一个堆上。我们用 $banlance$ 记录 $heap\underline{\hspace{0.5em}}max$ 和 $heap\underline{\hspace{0.5em}}min$ 元素个数的差值。 - - 如果窗口左边界 $nums[left]$小于等于 $heap\underline{\hspace{0.5em}}max$ 堆顶元素 ,则说明上一步删除的元素在 $heap\underline{\hspace{0.5em}}min$ 上,则让 `banlance -= 1`。 - - 如果窗口左边界 $nums[left]$ 大于 $heap\underline{\hspace{0.5em}}max$ 堆顶元素,则说明上一步删除的元素在 $heap\underline{\hspace{0.5em}}max$ 上,则上 `banlance += 1`。 - - 如果窗口右边界 $nums[right]$ 小于等于 $heap\underline{\hspace{0.5em}}max$ 堆顶元素,则说明待添加元素需要添加到 $heap\underline{\hspace{0.5em}}min$ 上,则让 `banlance += 1`。 - - 如果窗口右边界 $nums[right]$ 大于 $heap\underline{\hspace{0.5em}}max$ 堆顶元素,则说明待添加元素需要添加到 $heap\underline{\hspace{0.5em}}max$ 上,则让 `banlance -= 1`。 +- 添加:每次滑动在窗口右侧添加元素。需要根据上一步删除的结果来判断需要添加到哪一个堆上。我们用 $banlance$ 记录 $heap\_max$ 和 $heap\_min$ 元素个数的差值。 + - 如果窗口左边界 $nums[left]$小于等于 $heap\_max$ 堆顶元素 ,则说明上一步删除的元素在 $heap\_min$ 上,则让 `banlance -= 1`。 + - 如果窗口左边界 $nums[left]$ 大于 $heap\_max$ 堆顶元素,则说明上一步删除的元素在 $heap\_max$ 上,则上 `banlance += 1`。 + - 如果窗口右边界 $nums[right]$ 小于等于 $heap\_max$ 堆顶元素,则说明待添加元素需要添加到 $heap\_min$ 上,则让 `banlance += 1`。 + - 如果窗口右边界 $nums[right]$ 大于 $heap\_max$ 堆顶元素,则说明待添加元素需要添加到 $heap\_max$ 上,则让 `banlance -= 1`。 - 经过上述操作,$banlance$ 的取值为 $0$、$-2$、$2$ 中的一种。需要经过调整使得 $banlance == 0$。 - 如果 $banlance == 0$,已经平衡,不需要再做操作。 - - 如果 $banlance == -2$,则说明 $heap\underline{\hspace{0.5em}}min$ 比 $heap\underline{\hspace{0.5em}}max$ 的元素多了两个。则从 $heap\underline{\hspace{0.5em}}min$ 中取出堆顶元素添加到 $heap\underline{\hspace{0.5em}}max$ 中。 - - 如果 $banlance == 2$,则说明 $heap\underline{\hspace{0.5em}}max$ 比 $heap\underline{\hspace{0.5em}}min$ 的元素多了两个。则从 $heap\underline{\hspace{0.5em}}max$ 中取出堆顶元素添加到 $heap\underline{\hspace{0.5em}}min$ 中。 -- 调整完之后,分别检查 $heap\underline{\hspace{0.5em}}max$ 和 $heap\underline{\hspace{0.5em}}min$ 的堆顶元素。 - - 如果 $heap\underline{\hspace{0.5em}}max$ 堆顶元素恰好为待删除元素,即 $removes[-heap\underline{\hspace{0.5em}}max[0]] > 0$,则弹出 $heap\underline{\hspace{0.5em}}max$ 堆顶元素。 - - 如果 $heap\underline{\hspace{0.5em}}min$ 堆顶元素恰好为待删除元素,即 $removes[heap\underline{\hspace{0.5em}}min[0]] > 0$,则弹出 $heap\underline{\hspace{0.5em}}min$ 堆顶元素。 + - 如果 $banlance == -2$,则说明 $heap\_min$ 比 $heap\_max$ 的元素多了两个。则从 $heap\_min$ 中取出堆顶元素添加到 $heap\_max$ 中。 + - 如果 $banlance == 2$,则说明 $heap\_max$ 比 $heap\_min$ 的元素多了两个。则从 $heap\_max$ 中取出堆顶元素添加到 $heap\_min$ 中。 +- 调整完之后,分别检查 $heap\_max$ 和 $heap\_min$ 的堆顶元素。 + - 如果 $heap\_max$ 堆顶元素恰好为待删除元素,即 $removes[-heap\_max[0]] > 0$,则弹出 $heap\_max$ 堆顶元素。 + - 如果 $heap\_min$ 堆顶元素恰好为待删除元素,即 $removes[heap\_min[0]] > 0$,则弹出 $heap\_min$ 堆顶元素。 - 最后取中位数放入答案数组中,然后继续滑动窗口。 ### 思路 1:代码 diff --git a/docs/solutions/0400-0499/string-compression.md b/docs/solutions/0400-0499/string-compression.md index cdf3a4d9..56d04512 100644 --- a/docs/solutions/0400-0499/string-compression.md +++ b/docs/solutions/0400-0499/string-compression.md @@ -51,11 +51,11 @@ 题目要求原地修改字符串数组。我们可以使用快慢指针来解决原地修改问题,具体解决方法如下: - 定义两个快慢指针 $slow$,$fast$。其中 $slow$ 指向压缩后的当前字符位置,$fast$ 指向压缩前的当前字符位置。 -- 记录下当前待压缩字符的起始位置 $fast\underline{\hspace{0.5em}}start = start$,然后过滤掉连续相同的字符。 -- 将待压缩字符的起始位置的字符存入压缩后的当前字符位置,即 $chars[slow] = chars[fast\underline{\hspace{0.5em}}start]$,并向右移动压缩后的当前字符位置,即 $slow += 1$。 +- 记录下当前待压缩字符的起始位置 $fast\_start = start$,然后过滤掉连续相同的字符。 +- 将待压缩字符的起始位置的字符存入压缩后的当前字符位置,即 $chars[slow] = chars[fast\_start]$,并向右移动压缩后的当前字符位置,即 $slow += 1$。 - 判断一下待压缩字符的数目是否大于 $1$: - 如果数量为 $1$,则不用记录该数量。 - - 如果数量大于 $1$(即 $fast - fast\underline{\hspace{0.5em}}start > 0$),则我们需要将对应数量存入压缩后的当前字符位置。这时候还需要判断一下数量是否大于等于 $10$。 + - 如果数量大于 $1$(即 $fast - fast\_start > 0$),则我们需要将对应数量存入压缩后的当前字符位置。这时候还需要判断一下数量是否大于等于 $10$。 - 如果数量大于等于 $10$,则需要先将数字从个位到高位转为字符,存入压缩后的当前字符位置(此时数字为反,比如原数字是 $321$,则此时存入后为 $123$)。因为数字为反,所以我们需要将对应位置上的子字符串进行反转。 - 如果数量小于 $10$,则直接将数字存入压缩后的当前字符位置,无需取反。 - 判断完之后向右移动压缩前的当前字符位置 $fast$,然后继续压缩字符串,直到全部压缩完,则返回压缩后的当前字符位置 $slow$ 即为答案。 diff --git a/docs/solutions/0400-0499/target-sum.md b/docs/solutions/0400-0499/target-sum.md index 51b3a352..195b3261 100644 --- a/docs/solutions/0400-0499/target-sum.md +++ b/docs/solutions/0400-0499/target-sum.md @@ -51,11 +51,11 @@ 1. 定义从位置 $0$、和为 $0$ 开始,到达数组尾部位置为止,和为 $target$ 的方案数为 `dfs(0, 0)`。 2. 下面从位置 $0$、和为 $0$ 开始,以深度优先搜索遍历每个位置。 3. 如果当前位置 $i$ 到达最后一个位置 $size$: - 1. 如果和 $cur\underline{\hspace{0.5em}}sum$ 等于目标和 $target$,则返回方案数 $1$。 - 2. 如果和 $cur\underline{\hspace{0.5em}}sum$ 不等于目标和 $target$,则返回方案数 $0$。 -4. 递归搜索 $i + 1$ 位置,和为 $cur\underline{\hspace{0.5em}}sum - nums[i]$ 的方案数。 -5. 递归搜索 $i + 1$ 位置,和为 $cur\underline{\hspace{0.5em}}sum + nums[i]$ 的方案数。 -6. 将 4 ~ 5 两个方案数加起来就是当前位置 $i$、和为 $cur\underline{\hspace{0.5em}}sum$ 的方案数,返回该方案数。 + 1. 如果和 $cur\_sum$ 等于目标和 $target$,则返回方案数 $1$。 + 2. 如果和 $cur\_sum$ 不等于目标和 $target$,则返回方案数 $0$。 +4. 递归搜索 $i + 1$ 位置,和为 $cur\_sum - nums[i]$ 的方案数。 +5. 递归搜索 $i + 1$ 位置,和为 $cur\_sum + nums[i]$ 的方案数。 +6. 将 4 ~ 5 两个方案数加起来就是当前位置 $i$、和为 $cur\_sum$ 的方案数,返回该方案数。 7. 最终方案数为 `dfs(0, 0)`,将其作为答案返回即可。 ### 思路 1:代码 @@ -86,18 +86,18 @@ class Solution: 在思路 1 中我们单独使用深度优先搜索对每位数字进行 `+` 或者 `-` 的方法超时了。所以我们考虑使用记忆化搜索的方式,避免进行重复搜索。 -这里我们使用哈希表 $$table$$ 记录遍历过的位置 $i$ 及所得到的的当前和 $cur\underline{\hspace{0.5em}}sum$ 下的方案数,来避免重复搜索。具体步骤如下: +这里我们使用哈希表 $$table$$ 记录遍历过的位置 $i$ 及所得到的的当前和 $cur\_sum$ 下的方案数,来避免重复搜索。具体步骤如下: 1. 定义从位置 $0$、和为 $0$ 开始,到达数组尾部位置为止,和为 $target$ 的方案数为 `dfs(0, 0)`。 2. 下面从位置 $0$、和为 $0$ 开始,以深度优先搜索遍历每个位置。 3. 如果当前位置 $i$ 遍历完所有位置: - 1. 如果和 $cur\underline{\hspace{0.5em}}sum$ 等于目标和 $target$,则返回方案数 $1$。 - 2. 如果和 $cur\underline{\hspace{0.5em}}sum$ 不等于目标和 $target$,则返回方案数 $0$。 -4. 如果当前位置 $i$、和为 $cur\underline{\hspace{0.5em}}sum$ 之前记录过(即使用 $table$ 记录过对应方案数),则返回该方案数。 -5. 如果当前位置 $i$、和为 $cur\underline{\hspace{0.5em}}sum$ 之前没有记录过,则: - 1. 递归搜索 $i + 1$ 位置,和为 $cur\underline{\hspace{0.5em}}sum - nums[i]$ 的方案数。 - 2. 递归搜索 $i + 1$ 位置,和为 $cur\underline{\hspace{0.5em}}sum + nums[i]$ 的方案数。 - 3. 将上述两个方案数加起来就是当前位置 $i$、和为 $cur\underline{\hspace{0.5em}}sum$ 的方案数,将其记录到哈希表 $table$ 中,并返回该方案数。 + 1. 如果和 $cur\_sum$ 等于目标和 $target$,则返回方案数 $1$。 + 2. 如果和 $cur\_sum$ 不等于目标和 $target$,则返回方案数 $0$。 +4. 如果当前位置 $i$、和为 $cur\_sum$ 之前记录过(即使用 $table$ 记录过对应方案数),则返回该方案数。 +5. 如果当前位置 $i$、和为 $cur\_sum$ 之前没有记录过,则: + 1. 递归搜索 $i + 1$ 位置,和为 $cur\_sum - nums[i]$ 的方案数。 + 2. 递归搜索 $i + 1$ 位置,和为 $cur\_sum + nums[i]$ 的方案数。 + 3. 将上述两个方案数加起来就是当前位置 $i$、和为 $cur\_sum$ 的方案数,将其记录到哈希表 $table$ 中,并返回该方案数。 6. 最终方案数为 `dfs(0, 0)`,将其作为答案返回即可。 ### 思路 2:代码 @@ -132,9 +132,9 @@ class Solution: ### 思路 3:动态规划 -假设数组中所有元素和为 $sum$,数组中所有符号为 `+` 的元素为 $sum\underline{\hspace{0.5em}}x$,符号为 `-` 的元素和为 $sum\underline{\hspace{0.5em}}y$。则 $target = sum\underline{\hspace{0.5em}}x - sum\underline{\hspace{0.5em}}y$。 +假设数组中所有元素和为 $sum$,数组中所有符号为 `+` 的元素为 $sum\_x$,符号为 `-` 的元素和为 $sum\_y$。则 $target = sum\_x - sum\_y$。 -而 $sum\underline{\hspace{0.5em}}x + sum\underline{\hspace{0.5em}}y = sum$。根据两个式子可以求出 $2 \times sum\underline{\hspace{0.5em}}x = target + sum$,即 $sum\underline{\hspace{0.5em}}x = (target + sum) / 2$。 +而 $sum\_x + sum\_y = sum$。根据两个式子可以求出 $2 \times sum\_x = target + sum$,即 $sum\_x = (target + sum) / 2$。 那么这道题就变成了,如何在数组中找到一个集合,使集合中元素和为 $(target + sum) / 2$。这就变为了「0-1 背包问题」中求装满背包的方案数问题。 diff --git a/docs/solutions/0500-0599/beautiful-arrangement.md b/docs/solutions/0500-0599/beautiful-arrangement.md index eac572be..d36df4e2 100644 --- a/docs/solutions/0500-0599/beautiful-arrangement.md +++ b/docs/solutions/0500-0599/beautiful-arrangement.md @@ -173,11 +173,11 @@ class Solution: ###### 3. 状态转移方程 -对于状态 $state$,先统计出 $state$ 中选择的数字个数(即统计二进制中 $1$ 的个数)$one\underline{\hspace{0.5em}}num$。 +对于状态 $state$,先统计出 $state$ 中选择的数字个数(即统计二进制中 $1$ 的个数)$one\_num$。 -则 $dp[state]$ 表示选择了前 $one\underline{\hspace{0.5em}}num$ 个数字,且选择情况为 $state$ 时的方案数。 +则 $dp[state]$ 表示选择了前 $one\_num$ 个数字,且选择情况为 $state$ 时的方案数。 -$dp[state]$ 的状态肯定是由前 $one\underline{\hspace{0.5em}}num - 1$ 个数字,且 $state$ 第 $k$ 位为 $0$ 的状态而来对应状态转移而来,即:$dp[state \oplus (1 << (k - 1))]$。 +$dp[state]$ 的状态肯定是由前 $one\_num - 1$ 个数字,且 $state$ 第 $k$ 位为 $0$ 的状态而来对应状态转移而来,即:$dp[state \oplus (1 << (k - 1))]$。 所以状态转移方程为:$dp[state] = \sum_{k = 1}^n dp[state \oplus (1 << (k - 1))]$ diff --git a/docs/solutions/0500-0599/permutation-in-string.md b/docs/solutions/0500-0599/permutation-in-string.md index bf8a5e05..68e41bad 100644 --- a/docs/solutions/0500-0599/permutation-in-string.md +++ b/docs/solutions/0500-0599/permutation-in-string.md @@ -41,15 +41,15 @@ 题目要求判断 $s2$ 是否包含 $s1$ 的排列,则 $s2$ 的子串长度等于 $s1$ 的长度。我们可以维护一个长度为字符串 $s1$ 长度的固定长度的滑动窗口。 -先统计出字符串 $s1$ 中各个字符的数量,我们用 $s1\underline{\hspace{0.5em}}count$ 来表示。这个过程可以用字典、数组来实现,也可以直接用 `collections.Counter()` 实现。再统计 $s2$ 对应窗口内的字符数量 $window\underline{\hspace{0.5em}}count$,然后不断向右滑动,然后进行比较。如果对应字符数量相同,则返回 $True$,否则继续滑动。直到末尾时,返回 $False$。整个解题步骤具体如下: +先统计出字符串 $s1$ 中各个字符的数量,我们用 $s1\_count$ 来表示。这个过程可以用字典、数组来实现,也可以直接用 `collections.Counter()` 实现。再统计 $s2$ 对应窗口内的字符数量 $window\_count$,然后不断向右滑动,然后进行比较。如果对应字符数量相同,则返回 $True$,否则继续滑动。直到末尾时,返回 $False$。整个解题步骤具体如下: -1. $s1\underline{\hspace{0.5em}}count$ 用来统计 $s1$ 中各个字符数量。$window\underline{\hspace{0.5em}}count$ 用来维护窗口中 $s2$ 对应子串的各个字符数量。$window\underline{\hspace{0.5em}}size$ 表示固定窗口的长度,值为 $len(s1)$。 +1. $s1\_count$ 用来统计 $s1$ 中各个字符数量。$window\_count$ 用来维护窗口中 $s2$ 对应子串的各个字符数量。$window\_size$ 表示固定窗口的长度,值为 $len(s1)$。 2. 先统计出 $s1$ 中各个字符数量。 3. $left$ 、$right$ 都指向序列的第一个元素,即:`left = 0`,`right = 0`。 4. 向右移动 $right$,先将 $len(s1)$ 个元素填入窗口中。 -5. 当窗口元素个数为 $window\underline{\hspace{0.5em}}size$ 时,即:$right - left + 1 \ge window\underline{\hspace{0.5em}}size$ 时,判断窗口内各个字符数量 $window\underline{\hspace{0.5em}}count$ 是否等于 $s1 $ 中各个字符数量 $s1\underline{\hspace{0.5em}}count$。 +5. 当窗口元素个数为 $window\_size$ 时,即:$right - left + 1 \ge window\_size$ 时,判断窗口内各个字符数量 $window\_count$ 是否等于 $s1 $ 中各个字符数量 $s1\_count$。 1. 如果等于,直接返回 $True$。 - 2. 如果不等于,则向右移动 $left$,从而缩小窗口长度,即 `left += 1`,使得窗口大小始终保持为 $window\underline{\hspace{0.5em}}size$。 + 2. 如果不等于,则向右移动 $left$,从而缩小窗口长度,即 `left += 1`,使得窗口大小始终保持为 $window\_size$。 6. 重复 $4 \sim 5$ 步,直到 $right$ 到达数组末尾。返回 $False$。 ### 思路 1:代码 diff --git a/docs/solutions/0500-0599/subarray-sum-equals-k.md b/docs/solutions/0500-0599/subarray-sum-equals-k.md index 76b25a8f..42f47ebe 100644 --- a/docs/solutions/0500-0599/subarray-sum-equals-k.md +++ b/docs/solutions/0500-0599/subarray-sum-equals-k.md @@ -76,23 +76,23 @@ class Solution: ### 思路 2:前缀和 + 哈希表 -先用一重循环遍历数组,计算出数组 $nums$ 中前 $j$ 个元素的和(前缀和),保存到一维数组 $pre\underline{\hspace{0.5em}}sum$ 中,那么对于任意 $nums[i]…nums[j]$ 的子数组的和为 $pre\underline{\hspace{0.5em}}sum[j] - pre\underline{\hspace{0.5em}}sum[i - 1]$。这样计算子数组和的时间复杂度降为了 $O(1)$。总体时间复杂度为 $O(n^2)$。 +先用一重循环遍历数组,计算出数组 $nums$ 中前 $j$ 个元素的和(前缀和),保存到一维数组 $pre\_sum$ 中,那么对于任意 $nums[i]…nums[j]$ 的子数组的和为 $pre\_sum[j] - pre\_sum[i - 1]$。这样计算子数组和的时间复杂度降为了 $O(1)$。总体时间复杂度为 $O(n^2)$。 但是还是超时了。。 由于我们只关心和为 $k$ 出现的次数,不关心具体的解,可以使用哈希表来加速运算。 -$pre\underline{\hspace{0.5em}}sum[i]$ 的定义是前 $i$ 个元素和,则 $pre\underline{\hspace{0.5em}}sum[i]$ 可以由 $pre\underline{\hspace{0.5em}}sum[i - 1]$ 递推而来,即:$pre\underline{\hspace{0.5em}}sum[i] = pre\underline{\hspace{0.5em}}sum[i - 1] + num[i]$。 $[i..j]$ 子数组和为 $k$ 可以转换为:$pre\underline{\hspace{0.5em}}sum[j] - pre\underline{\hspace{0.5em}}sum[i - 1] == k$。 +$pre\_sum[i]$ 的定义是前 $i$ 个元素和,则 $pre\_sum[i]$ 可以由 $pre\_sum[i - 1]$ 递推而来,即:$pre\_sum[i] = pre\_sum[i - 1] + num[i]$。 $[i..j]$ 子数组和为 $k$ 可以转换为:$pre\_sum[j] - pre\_sum[i - 1] == k$。 -综合一下,可得:$pre\underline{\hspace{0.5em}}sum[i - 1] == pre\underline{\hspace{0.5em}}sum[j] - k $。 +综合一下,可得:$pre\_sum[i - 1] == pre\_sum[j] - k $。 -所以,当我们考虑以 $j$ 结尾和为 $k$ 的连续子数组个数时,只需要统计有多少个前缀和为 $pre\underline{\hspace{0.5em}}sum[j] - k$ (即 $pre\underline{\hspace{0.5em}}sum[i - 1]$)的个数即可。具体做法如下: +所以,当我们考虑以 $j$ 结尾和为 $k$ 的连续子数组个数时,只需要统计有多少个前缀和为 $pre\_sum[j] - k$ (即 $pre\_sum[i - 1]$)的个数即可。具体做法如下: -- 使用 $pre\underline{\hspace{0.5em}}sum$ 变量记录前缀和(代表 $pre\underline{\hspace{0.5em}}sum[j]$)。 -- 使用哈希表 $pre\underline{\hspace{0.5em}}dic$ 记录 $pre\underline{\hspace{0.5em}}sum[j]$ 出现的次数。键值对为 $pre\underline{\hspace{0.5em}}sum[j] : pre\underline{\hspace{0.5em}}sum\underline{\hspace{0.5em}}count$。 -- 从左到右遍历数组,计算当前前缀和 $pre\underline{\hspace{0.5em}}sum$。 -- 如果 $pre\underline{\hspace{0.5em}}sum - k$ 在哈希表中,则答案个数累加上 $pre\underline{\hspace{0.5em}}dic[pre\underline{\hspace{0.5em}}sum - k]$。 -- 如果 $pre\underline{\hspace{0.5em}}sum$ 在哈希表中,则前缀和个数累加 $1$,即 $pre\underline{\hspace{0.5em}}dic[pre\underline{\hspace{0.5em}}sum] += 1$。 +- 使用 $pre\_sum$ 变量记录前缀和(代表 $pre\_sum[j]$)。 +- 使用哈希表 $pre\_dic$ 记录 $pre\_sum[j]$ 出现的次数。键值对为 $pre\_sum[j] : pre\_sum\_count$。 +- 从左到右遍历数组,计算当前前缀和 $pre\_sum$。 +- 如果 $pre\_sum - k$ 在哈希表中,则答案个数累加上 $pre\_dic[pre\_sum - k]$。 +- 如果 $pre\_sum$ 在哈希表中,则前缀和个数累加 $1$,即 $pre\_dic[pre\_sum] += 1$。 - 最后输出答案个数。 ### 思路 2:代码 diff --git a/docs/solutions/0600-0699/longest-continuous-increasing-subsequence.md b/docs/solutions/0600-0699/longest-continuous-increasing-subsequence.md index 8701a725..04e54e2a 100644 --- a/docs/solutions/0600-0699/longest-continuous-increasing-subsequence.md +++ b/docs/solutions/0600-0699/longest-continuous-increasing-subsequence.md @@ -89,14 +89,14 @@ class Solution: ### 思路 2:滑动窗口(不定长度) -1. 设定两个指针:$left$、$right$,分别指向滑动窗口的左右边界,保证窗口内为连续递增序列。使用 $window\underline{\hspace{0.5em}}len$ 存储当前窗口大小,使用 $max\underline{\hspace{0.5em}}len$ 维护最大窗口长度。 +1. 设定两个指针:$left$、$right$,分别指向滑动窗口的左右边界,保证窗口内为连续递增序列。使用 $window\_len$ 存储当前窗口大小,使用 $max\_len$ 维护最大窗口长度。 2. 一开始,$left$、$right$ 都指向 $0$。 3. 将最右侧元素 $nums[right]$ 加入当前连续递增序列中,即当前窗口长度加 $1$(`window_len += 1`)。 4. 判断当前元素 $nums[right]$ 是否满足连续递增序列。 5. 如果 $right > 0$ 并且 $nums[right - 1] \ge nums[right]$ ,说明不满足连续递增序列,则将 $left$ 移动到窗口最右侧,重置当前窗口长度为 $1$(`window_len = 1`)。 6. 记录当前连续递增序列的长度,并更新最长连续递增序列的长度。 7. 继续右移 $right$,直到 $right \ge len(nums)$ 结束。 -8. 输出最长连续递增序列的长度 $max\underline{\hspace{0.5em}}len$。 +8. 输出最长连续递增序列的长度 $max\_len$。 ### 思路 2:代码 diff --git a/docs/solutions/0600-0699/max-area-of-island.md b/docs/solutions/0600-0699/max-area-of-island.md index 1c67bcaa..fea3e196 100644 --- a/docs/solutions/0600-0699/max-area-of-island.md +++ b/docs/solutions/0600-0699/max-area-of-island.md @@ -85,7 +85,7 @@ class Solution: 1. 使用 $ans$ 记录最大岛屿面积。 2. 遍历二维数组的每一个元素,对于每个值为 $1$ 的元素: - 1. 将该元素置为 $0$。并使用队列 $queue$ 存储该节点位置。使用 $temp\underline{\hspace{0.5em}}ans$ 记录当前岛屿面积。 + 1. 将该元素置为 $0$。并使用队列 $queue$ 存储该节点位置。使用 $temp\_ans$ 记录当前岛屿面积。 2. 然后从队列 $queue$ 中取出第一个节点位置 $(i, j)$。遍历该节点位置上、下、左、右四个方向上的相邻节点。并将其置为 $0$(避免重复搜索)。并将其加入到队列中。并累加当前岛屿面积,即 `temp_ans += 1`。 3. 不断重复上一步骤,直到队列 $queue$ 为空。 4. 更新当前最大岛屿面积,即 `ans = max(ans, temp_ans)`。 diff --git a/docs/solutions/0600-0699/maximum-average-subarray-i.md b/docs/solutions/0600-0699/maximum-average-subarray-i.md index bcae8639..f1b77188 100644 --- a/docs/solutions/0600-0699/maximum-average-subarray-i.md +++ b/docs/solutions/0600-0699/maximum-average-subarray-i.md @@ -43,7 +43,7 @@ 这道题目是典型的固定窗口大小的滑动窗口题目。窗口大小为 $k$。具体做法如下: -1. $ans$ 用来维护子数组最大平均数,初始值为负无穷,即 `float('-inf')`。$window\underline{\hspace{0.5em}}total$ 用来维护窗口中元素的和。 +1. $ans$ 用来维护子数组最大平均数,初始值为负无穷,即 `float('-inf')`。$window\_total$ 用来维护窗口中元素的和。 2. $left$ 、$right$ 都指向序列的第一个元素,即:`left = 0`,`right = 0`。 3. 向右移动 $right$,先将 $k$ 个元素填入窗口中。 4. 当窗口元素个数为 $k$ 时,即:$right - left + 1 >= k$ 时,计算窗口内的元素和平均值,并维护子数组最大平均数。 diff --git a/docs/solutions/0600-0699/partition-to-k-equal-sum-subsets.md b/docs/solutions/0600-0699/partition-to-k-equal-sum-subsets.md index a58e36f0..5c36cb4b 100644 --- a/docs/solutions/0600-0699/partition-to-k-equal-sum-subsets.md +++ b/docs/solutions/0600-0699/partition-to-k-equal-sum-subsets.md @@ -62,10 +62,10 @@ 1. 当数组元素选择情况为 $state$ 时可行,即 $dp[state] == True$; 2. 第 $i$ 位数字没有被使用; -3. 加上第 $i$ 位元素后的状态为 $next\underline{\hspace{0.5em}}state$; +3. 加上第 $i$ 位元素后的状态为 $next\_state$; 4. 加上第 $i$ 位元素后没有超出目标和。 -则:$dp[next\underline{\hspace{0.5em}}state] = True$。 +则:$dp[next\_state] = True$。 ###### 4. 初始条件 diff --git a/docs/solutions/0600-0699/stickers-to-spell-word.md b/docs/solutions/0600-0699/stickers-to-spell-word.md index 3bbd6858..45c38b3e 100644 --- a/docs/solutions/0600-0699/stickers-to-spell-word.md +++ b/docs/solutions/0600-0699/stickers-to-spell-word.md @@ -51,13 +51,13 @@ 然后我们从初始状态 $state = 0$(没有选中 $target$ 中的任何字母)开始进行广度优先搜索遍历。 -在广度优先搜索过程中,对于当前状态 $cur\underline{\hspace{0.5em}}state$,我们遍历所有贴纸的所有字母,如果当前字母可以拼到 $target$ 中的某个位置上,则更新状态 $next\underline{\hspace{0.5em}}state$ 为「选中 $target$ 中对应位置上的字母」。 +在广度优先搜索过程中,对于当前状态 $cur\_state$,我们遍历所有贴纸的所有字母,如果当前字母可以拼到 $target$ 中的某个位置上,则更新状态 $next\_state$ 为「选中 $target$ 中对应位置上的字母」。 为了得到最小最小贴纸数量,我们可以使用动态规划的方法,定义 $dp[state]$ 表示为到达 $state$ 状态需要的最小贴纸数量。 -那么在广度优先搜索中,在更新状态时,同时进行状态转移,即 $dp[next\underline{\hspace{0.5em}}state] = dp[cur\underline{\hspace{0.5em}}state] + 1$。 +那么在广度优先搜索中,在更新状态时,同时进行状态转移,即 $dp[next\_state] = dp[cur\_state] + 1$。 -> 注意:在进行状态转移时,要跳过 $dp[next\underline{\hspace{0.5em}}state]$ 已经有值的情况。 +> 注意:在进行状态转移时,要跳过 $dp[next\_state]$ 已经有值的情况。 这样在到达状态 $1 \text{ <}\text{< } len(target) - 1$ 时,所得到的 $dp[1 \text{ <}\text{< } len(target) - 1]$ 即为答案。 diff --git a/docs/solutions/0700-0799/subarray-product-less-than-k.md b/docs/solutions/0700-0799/subarray-product-less-than-k.md index 459ffe21..b8ee6077 100644 --- a/docs/solutions/0700-0799/subarray-product-less-than-k.md +++ b/docs/solutions/0700-0799/subarray-product-less-than-k.md @@ -40,10 +40,10 @@ ### 思路 1:滑动窗口(不定长度) -1. 设定两个指针:$left$、$right$,分别指向滑动窗口的左右边界,保证窗口内所有数的乘积 $window\underline{\hspace{0.5em}}product$ 都小于 $k$。使用 $window\underline{\hspace{0.5em}}product$ 记录窗口中的乘积值,使用 $count$ 记录符合要求的子数组个数。 +1. 设定两个指针:$left$、$right$,分别指向滑动窗口的左右边界,保证窗口内所有数的乘积 $window\_product$ 都小于 $k$。使用 $window\_product$ 记录窗口中的乘积值,使用 $count$ 记录符合要求的子数组个数。 2. 一开始,$left$、$right$ 都指向 $0$。 -3. 向右移动 $right$,将最右侧元素加入当前子数组乘积 $window\underline{\hspace{0.5em}}product$ 中。 -4. 如果 $window\underline{\hspace{0.5em}}product \ge k$,则不断右移 $left$,缩小滑动窗口长度,并更新当前乘积值 $window\underline{\hspace{0.5em}}product$ 直到 $window\underline{\hspace{0.5em}}product < k$。 +3. 向右移动 $right$,将最右侧元素加入当前子数组乘积 $window\_product$ 中。 +4. 如果 $window\_product \ge k$,则不断右移 $left$,缩小滑动窗口长度,并更新当前乘积值 $window\_product$ 直到 $window\_product < k$。 5. 记录累积答案个数加 $1$,继续右移 $right$,直到 $right \ge len(nums)$ 结束。 6. 输出累积答案个数。 diff --git a/docs/solutions/0800-0899/backspace-string-compare.md b/docs/solutions/0800-0899/backspace-string-compare.md index b1cd1e02..83d3fd41 100644 --- a/docs/solutions/0800-0899/backspace-string-compare.md +++ b/docs/solutions/0800-0899/backspace-string-compare.md @@ -75,19 +75,19 @@ class Solution: 由于 `#` 会消除左侧字符,而不会影响右侧字符,所以我们选择从字符串尾端遍历 $s$、$t$ 字符串。具体做法如下: -- 使用分离双指针 $left\underline{\hspace{0.5em}}1$、$left\underline{\hspace{0.5em}}2$。$left\underline{\hspace{0.5em}}1$ 指向字符串 $s$ 末尾,$left\underline{\hspace{0.5em}}2$ 指向字符串 $t$ 末尾。使用 $sign\underline{\hspace{0.5em}}1$、$sign\underline{\hspace{0.5em}}2$ 标记字符串 $s$、$t$ 中当前退格字符个数。 +- 使用分离双指针 $left\_1$、$left\_2$。$left\_1$ 指向字符串 $s$ 末尾,$left\_2$ 指向字符串 $t$ 末尾。使用 $sign\_1$、$sign\_2$ 标记字符串 $s$、$t$ 中当前退格字符个数。 - 从后到前遍历字符串 $s$、$t$。 - 先来循环处理字符串 $s$ 尾端 `#` 的影响,具体如下: - - 如果当前字符是 `#`,则更新 $s$ 当前退格字符个数,即 `sign_1 += 1`。同时将 $left\underline{\hspace{0.5em}}1$ 左移。 - - 如果 $s$ 当前退格字符个数大于 $0$,则退格数减一,即 `sign_1 -= 1`。同时将 $left\underline{\hspace{0.5em}}1$ 左移。 + - 如果当前字符是 `#`,则更新 $s$ 当前退格字符个数,即 `sign_1 += 1`。同时将 $left\_1$ 左移。 + - 如果 $s$ 当前退格字符个数大于 $0$,则退格数减一,即 `sign_1 -= 1`。同时将 $left\_1$ 左移。 - 如果 $s$ 当前为普通字符,则跳出循环。 - 同理再来处理字符串 $t$ 尾端 `#` 的影响,具体如下: - - 如果当前字符是 `#`,则更新 $t$ 当前退格字符个数,即 `sign_2 += 1`。同时将 $left\underline{\hspace{0.5em}}2$ 左移。 - - 如果 $t$ 当前退格字符个数大于 $0$,则退格数减一,即 `sign_2 -= 1`。同时将 $left\underline{\hspace{0.5em}}2$ 左移。 + - 如果当前字符是 `#`,则更新 $t$ 当前退格字符个数,即 `sign_2 += 1`。同时将 $left\_2$ 左移。 + - 如果 $t$ 当前退格字符个数大于 $0$,则退格数减一,即 `sign_2 -= 1`。同时将 $left\_2$ 左移。 - 如果 $t$ 当前为普通字符,则跳出循环。 - 处理完,如果两个字符串为空,则说明匹配,直接返回 $True$。 - 再先排除长度不匹配的情况,直接返回 $False$。 - - 最后判断 $s[left\underline{\hspace{0.5em}}1]$ 是否等于 $s[left\underline{\hspace{0.5em}}2]$。不等于则直接返回 $False$,等于则令 $left\underline{\hspace{0.5em}}1$、$left\underline{\hspace{0.5em}}2$ 左移,继续遍历。 + - 最后判断 $s[left\_1]$ 是否等于 $s[left\_2]$。不等于则直接返回 $False$,等于则令 $left\_1$、$left\_2$ 左移,继续遍历。 - 遍历完没有出现不匹配的情况,则返回 $True$。 ### 思路 2:代码 diff --git a/docs/solutions/0800-0899/binary-gap.md b/docs/solutions/0800-0899/binary-gap.md index defbc8ba..ddb07244 100644 --- a/docs/solutions/0800-0899/binary-gap.md +++ b/docs/solutions/0800-0899/binary-gap.md @@ -44,9 +44,9 @@ ### 思路 1:遍历 -1. 将正整数 $n$ 转为二进制字符串形式 $bin\underline{\hspace{0.5em}}n$。 +1. 将正整数 $n$ 转为二进制字符串形式 $bin\_n$。 2. 使用变量 $pre$ 记录二进制字符串中上一个 $1$ 的位置,使用变量 $ans$ 存储两个相邻 $1$ 之间的最长距离。 -3. 遍历二进制字符串形式 $bin\underline{\hspace{0.5em}}n$ 的每一位,遇到 $1$ 时判断并更新两个相邻 $1$ 之间的最长距离。 +3. 遍历二进制字符串形式 $bin\_n$ 的每一位,遇到 $1$ 时判断并更新两个相邻 $1$ 之间的最长距离。 4. 遍历完返回两个相邻 $1$ 之间的最长距离,即 $ans$。 ### 思路 1:代码 diff --git a/docs/solutions/0800-0899/bricks-falling-when-hit.md b/docs/solutions/0800-0899/bricks-falling-when-hit.md index 8070f4b6..f8a2b685 100644 --- a/docs/solutions/0800-0899/bricks-falling-when-hit.md +++ b/docs/solutions/0800-0899/bricks-falling-when-hit.md @@ -90,10 +90,10 @@ 整个算法步骤具体如下: -1. 先将二维数组 $grid$ 复制一份到二维数组 $copy\underline{\hspace{0.5em}}gird$ 上。这是因为遍历 $hits$ 元素时需要判断原网格是空白还是被打碎的砖块。 -2. 在 $copy\underline{\hspace{0.5em}}grid$ 中将 $hits$ 中打碎的砖块赋值为 $0$。 +1. 先将二维数组 $grid$ 复制一份到二维数组 $copy\_gird$ 上。这是因为遍历 $hits$ 元素时需要判断原网格是空白还是被打碎的砖块。 +2. 在 $copy\_grid$ 中将 $hits$ 中打碎的砖块赋值为 $0$。 3. 建立并查集,将房顶上的砖块合并到一个集合中。 -4. 逆序遍历 $hits$,将 $hits$ 中的砖块补到 $copy\underline{\hspace{0.5em}}grid$ 中,并计算每一步中有多少个砖块粘到屋顶上(与屋顶砖块在一个集合中),并存入答案数组对应位置。 +4. 逆序遍历 $hits$,将 $hits$ 中的砖块补到 $copy\_grid$ 中,并计算每一步中有多少个砖块粘到屋顶上(与屋顶砖块在一个集合中),并存入答案数组对应位置。 5. 最后输出答案数组。 ### 思路 1:代码 diff --git a/docs/solutions/0800-0899/most-common-word.md b/docs/solutions/0800-0899/most-common-word.md index 0cf6940f..ad90d2a9 100644 --- a/docs/solutions/0800-0899/most-common-word.md +++ b/docs/solutions/0800-0899/most-common-word.md @@ -55,7 +55,7 @@ banned = [] ### 思路 1:哈希表 -1. 将禁用词列表转为集合 $banned\underline{\hspace{0.5em}}set$。 +1. 将禁用词列表转为集合 $banned\_set$。 2. 遍历段落 $paragraph$,获取段落中的所有单词。 3. 判断当前单词是否在禁用词集合中,如果不在禁用词集合中,则使用哈希表对该单词进行计数。 4. 遍历完,找出哈希表中频率最大的单词,将该单词作为答案进行返回。 diff --git a/docs/solutions/0800-0899/number-of-lines-to-write-string.md b/docs/solutions/0800-0899/number-of-lines-to-write-string.md index 13ec0704..d43dbafc 100644 --- a/docs/solutions/0800-0899/number-of-lines-to-write-string.md +++ b/docs/solutions/0800-0899/number-of-lines-to-write-string.md @@ -51,7 +51,7 @@ S = "bbbcccdddaaa" ### 思路 1:模拟 -1. 使用变量 $line\underline{\hspace{0.5em}}cnt$ 记录行数,使用变量 $last\underline{\hspace{0.5em}}cnt$ 记录最后一行使用的单位数。 +1. 使用变量 $line\_cnt$ 记录行数,使用变量 $last\_cnt$ 记录最后一行使用的单位数。 2. 遍历字符串,如果当前最后一行使用的单位数 + 当前字符需要的单位超过了 $100$,则: 1. 另起一行填充字符。(即行数加 $1$,最后一行使用的单位数为当前字符宽度)。 3. 如果当前最后一行使用的单位数 + 当前字符需要的单位没有超过 $100$,则: diff --git a/docs/solutions/0800-0899/shortest-subarray-with-sum-at-least-k.md b/docs/solutions/0800-0899/shortest-subarray-with-sum-at-least-k.md index 300f2347..93a7eb2c 100644 --- a/docs/solutions/0800-0899/shortest-subarray-with-sum-at-least-k.md +++ b/docs/solutions/0800-0899/shortest-subarray-with-sum-at-least-k.md @@ -48,22 +48,22 @@ 首先对于子数组和,我们可以使用「前缀和」的方式,方便快速的得到某个子数组的和。 -对于区间 $[left, right]$,通过 $pre\underline{\hspace{0.5em}}sum[right + 1] - prefix\underline{\hspace{0.5em}}cnts[left]$ 即可快速求解出区间 $[left, right]$ 的子数组和。 +对于区间 $[left, right]$,通过 $pre\_sum[right + 1] - prefix\_cnts[left]$ 即可快速求解出区间 $[left, right]$ 的子数组和。 -此时问题就转变为:是否能找到满足 $i > j$ 且 $pre\underline{\hspace{0.5em}}sum[i] - pre\underline{\hspace{0.5em}}sum[j] \ge k$ 两个条件的子数组 $[j, i)$?如果能找到,则找出 $i - j$ 差值最小的作为答案。 +此时问题就转变为:是否能找到满足 $i > j$ 且 $pre\_sum[i] - pre\_sum[j] \ge k$ 两个条件的子数组 $[j, i)$?如果能找到,则找出 $i - j$ 差值最小的作为答案。 #### 2. 单调队列优化 对于区间 $[j, i)$ 来说,我们应该尽可能的减少不成立的区间枚举。 -1. 对于某个区间 $[j, i)$ 来说,如果 $pre\underline{\hspace{0.5em}}sum[i] - pre\underline{\hspace{0.5em}}sum[j] \ge k$,那么大于 $i$ 的索引值就不用再进行枚举了,不可能比 $i - j$ 的差值更优了。此时我们应该尽可能的向右移动 $j$,从而使得 $i - j$ 更小。 -2. 对于某个区间 $[j, i)$ 来说,如果 $pre\underline{\hspace{0.5em}}sum[j] \ge pre\underline{\hspace{0.5em}}sum[i]$,对于任何大于等于 $i$ 的索引值 $r$ 来说,$pre\underline{\hspace{0.5em}}sum[r] - pre\underline{\hspace{0.5em}}sum[i]$ 一定比 $pre\underline{\hspace{0.5em}}sum[i] - pre\underline{\hspace{0.5em}}sum[j]$ 更小且长度更小,此时 $pre\underline{\hspace{0.5em}}sum[j]$ 可以直接忽略掉。 +1. 对于某个区间 $[j, i)$ 来说,如果 $pre\_sum[i] - pre\_sum[j] \ge k$,那么大于 $i$ 的索引值就不用再进行枚举了,不可能比 $i - j$ 的差值更优了。此时我们应该尽可能的向右移动 $j$,从而使得 $i - j$ 更小。 +2. 对于某个区间 $[j, i)$ 来说,如果 $pre\_sum[j] \ge pre\_sum[i]$,对于任何大于等于 $i$ 的索引值 $r$ 来说,$pre\_sum[r] - pre\_sum[i]$ 一定比 $pre\_sum[i] - pre\_sum[j]$ 更小且长度更小,此时 $pre\_sum[j]$ 可以直接忽略掉。 -因此,我们可以使用单调队列来维护单调递增的前缀数组 $pre\underline{\hspace{0.5em}}sum$。其中存放了下标 $x:x_0, x_1, …$,满足 $pre\underline{\hspace{0.5em}}sum[x_0] < pre\underline{\hspace{0.5em}}sum[x_1] < …$ 单调递增。 +因此,我们可以使用单调队列来维护单调递增的前缀数组 $pre\_sum$。其中存放了下标 $x:x_0, x_1, …$,满足 $pre\_sum[x_0] < pre\_sum[x_1] < …$ 单调递增。 1. 使用一重循环遍历位置 $i$,将当前位置 $i$ 存入倒掉队列中。 -2. 对于每一个位置 $i$,如果单调队列不为空,则可以判断其之前存入在单调队列中的 $pre\underline{\hspace{0.5em}}sum[j]$ 值,如果 $pre\underline{\hspace{0.5em}}sum[i] - pre\underline{\hspace{0.5em}}sum[j] \ge k$,则更新答案,并将 $j$ 从队头位置弹出。直到不再满足 $pre\underline{\hspace{0.5em}}sum[i] - pre\underline{\hspace{0.5em}}sum[j] \ge k$ 时为止(即 $pre\underline{\hspace{0.5em}}sum[i] - pre\underline{\hspace{0.5em}}sum[j] < k$)。 -3. 如果队尾 $pre\underline{\hspace{0.5em}}sum[j] \ge pre\underline{\hspace{0.5em}}sum[i]$,那么说明以后无论如何都不会再考虑 $pre\underline{\hspace{0.5em}}sum[j]$ 了,则将其从队尾弹出。 +2. 对于每一个位置 $i$,如果单调队列不为空,则可以判断其之前存入在单调队列中的 $pre\_sum[j]$ 值,如果 $pre\_sum[i] - pre\_sum[j] \ge k$,则更新答案,并将 $j$ 从队头位置弹出。直到不再满足 $pre\_sum[i] - pre\_sum[j] \ge k$ 时为止(即 $pre\_sum[i] - pre\_sum[j] < k$)。 +3. 如果队尾 $pre\_sum[j] \ge pre\_sum[i]$,那么说明以后无论如何都不会再考虑 $pre\_sum[j]$ 了,则将其从队尾弹出。 4. 最后遍历完返回答案。 ### 思路 1:代码 diff --git a/docs/solutions/0800-0899/surface-area-of-3d-shapes.md b/docs/solutions/0800-0899/surface-area-of-3d-shapes.md index f2b18e2b..6b4179bd 100644 --- a/docs/solutions/0800-0899/surface-area-of-3d-shapes.md +++ b/docs/solutions/0800-0899/surface-area-of-3d-shapes.md @@ -48,10 +48,10 @@ 而每一个正方体所贡献的表面积,可以通过枚举当前正方体前后左右相邻四个方向上的正方体的个数,从而通过判断计算得出。 - 如果当前位置 $(row, col)$ 存在正方体,则正方体在上下位置上起码贡献了 $2$ 的表面积。 -- 如果当前位置 $(row, col)$ 的相邻位置 $(new\underline{\hspace{0.5em}}row, new\underline{\hspace{0.5em}}col)$ 上不存在正方体,说明当前正方体在该方向为最外侧,则 $(row, col)$ 位置所贡献的表面积为当前位置上的正方体个数,即 $grid[row][col]$。 -- 如果当前位置 $(row, col)$ 的相邻位置 $(new\underline{\hspace{0.5em}}row, new\underline{\hspace{0.5em}}col)$ 上存在正方体: - - 如果 $grid[row][col] > grid[new\underline{\hspace{0.5em}}row][new\underline{\hspace{0.5em}}col]$,说明 $grid[row][col]$ 在该方向上底面一部分被 $grid[new\underline{\hspace{0.5em}}row][new\underline{\hspace{0.5em}}col]$ 遮盖了,则 $(row, col)$ 位置所贡献的表面积为 $grid[row][col] - grid[new_row][new_col]$。 - - 如果 $grid[row][col] \le grid[new\underline{\hspace{0.5em}}row][new\underline{\hspace{0.5em}}col]$,说明 $grid[row][col]$ 在该方向上完全被 $grid[new\underline{\hspace{0.5em}}row][new\underline{\hspace{0.5em}}col]$ 遮盖了,则 $(row, col)$ 位置所贡献的表面积为 $0$。 +- 如果当前位置 $(row, col)$ 的相邻位置 $(new\_row, new\_col)$ 上不存在正方体,说明当前正方体在该方向为最外侧,则 $(row, col)$ 位置所贡献的表面积为当前位置上的正方体个数,即 $grid[row][col]$。 +- 如果当前位置 $(row, col)$ 的相邻位置 $(new\_row, new\_col)$ 上存在正方体: + - 如果 $grid[row][col] > grid[new\_row][new\_col]$,说明 $grid[row][col]$ 在该方向上底面一部分被 $grid[new\_row][new\_col]$ 遮盖了,则 $(row, col)$ 位置所贡献的表面积为 $grid[row][col] - grid[new_row][new_col]$。 + - 如果 $grid[row][col] \le grid[new\_row][new\_col]$,说明 $grid[row][col]$ 在该方向上完全被 $grid[new\_row][new\_col]$ 遮盖了,则 $(row, col)$ 位置所贡献的表面积为 $0$。 ### 思路 1:代码 diff --git a/docs/solutions/0900-0999/available-captures-for-rook.md b/docs/solutions/0900-0999/available-captures-for-rook.md index 465dc0ba..691361cc 100644 --- a/docs/solutions/0900-0999/available-captures-for-rook.md +++ b/docs/solutions/0900-0999/available-captures-for-rook.md @@ -51,7 +51,7 @@ ### 思路 1:模拟 -1. 双重循环遍历确定白色车的位置 $(pos\underline{\hspace{0.5em}}i,poss\underline{\hspace{0.5em}}j)$。 +1. 双重循环遍历确定白色车的位置 $(pos\_i,poss\_j)$。 2. 让车向上、下、左、右四个方向进行移动,直到超出边界 / 碰到白色象 / 碰到卒为止。使用计数器 $cnt$ 记录捕获的卒的数量。 3. 返回答案 $cnt$。 diff --git a/docs/solutions/0900-0999/long-pressed-name.md b/docs/solutions/0900-0999/long-pressed-name.md index 27f8eaf0..aadb2f42 100644 --- a/docs/solutions/0900-0999/long-pressed-name.md +++ b/docs/solutions/0900-0999/long-pressed-name.md @@ -44,14 +44,14 @@ 这道题目的意思是在 $typed$ 里边匹配 $name$,同时要考虑字符重复问题,以及不匹配的情况。可以使用分离双指针来做。具体做法如下: -1. 使用两个指针 $left\underline{\hspace{0.5em}}1$、$left\underline{\hspace{0.5em}}2$,$left\underline{\hspace{0.5em}}1$ 指向字符串 $name$ 开始位置,$left\underline{\hspace{0.5em}}2$ 指向字符串 $type$ 开始位置。 -2. 如果 $name[left\underline{\hspace{0.5em}}1] == name[left\underline{\hspace{0.5em}}2]$,则将 $left\underline{\hspace{0.5em}}1$、$left\underline{\hspace{0.5em}}2$ 同时右移。 -3. 如果 $nmae[left\underline{\hspace{0.5em}}1] \ne name[left\underline{\hspace{0.5em}}2]$,则: - 1. 如果 $typed[left\underline{\hspace{0.5em}}2]$ 和前一个位置元素 $typed[left\underline{\hspace{0.5em}}2 - 1]$ 相等,则说明出现了重复元素,将 $left\underline{\hspace{0.5em}}2$ 右移,过滤重复元素。 - 2. 如果 $typed[left\underline{\hspace{0.5em}}2]$ 和前一个位置元素 $typed[left\underline{\hspace{0.5em}}2 - 1]$ 不等,则说明出现了多余元素,不匹配。直接返回 `False` 即可。 - -4. 当 $left\underline{\hspace{0.5em}}1 == len(name)$ 或者 $left\underline{\hspace{0.5em}}2 == len(typed)$ 时跳出循环。然后过滤掉 $typed$ 末尾的重复元素。 -5. 最后判断,如果 $left\underline{\hspace{0.5em}}1 == len(name)$ 并且 $left\underline{\hspace{0.5em}}2 == len(typed)$,则说明匹配,返回 `True`,否则返回 `False`。 +1. 使用两个指针 $left\_1$、$left\_2$,$left\_1$ 指向字符串 $name$ 开始位置,$left\_2$ 指向字符串 $type$ 开始位置。 +2. 如果 $name[left\_1] == name[left\_2]$,则将 $left\_1$、$left\_2$ 同时右移。 +3. 如果 $nmae[left\_1] \ne name[left\_2]$,则: + 1. 如果 $typed[left\_2]$ 和前一个位置元素 $typed[left\_2 - 1]$ 相等,则说明出现了重复元素,将 $left\_2$ 右移,过滤重复元素。 + 2. 如果 $typed[left\_2]$ 和前一个位置元素 $typed[left\_2 - 1]$ 不等,则说明出现了多余元素,不匹配。直接返回 `False` 即可。 + +4. 当 $left\_1 == len(name)$ 或者 $left\_2 == len(typed)$ 时跳出循环。然后过滤掉 $typed$ 末尾的重复元素。 +5. 最后判断,如果 $left\_1 == len(name)$ 并且 $left\_2 == len(typed)$,则说明匹配,返回 `True`,否则返回 `False`。 ### 思路 1:代码 diff --git a/docs/solutions/0900-0999/minimum-number-of-k-consecutive-bit-flips.md b/docs/solutions/0900-0999/minimum-number-of-k-consecutive-bit-flips.md index fa49023c..0c599ef8 100644 --- a/docs/solutions/0900-0999/minimum-number-of-k-consecutive-bit-flips.md +++ b/docs/solutions/0900-0999/minimum-number-of-k-consecutive-bit-flips.md @@ -55,21 +55,21 @@ 同时如果我们知道了前面 $k - 1$ 个元素的翻转次数就可以直接修改 $nums[i]$ 了。 -我们使用 $flip\underline{\hspace{0.5em}}count$ 记录第 $i$ 个元素之前 $k - 1$ 个位置总共被反转了多少次,或者 $flip\underline{\hspace{0.5em}}count$ 是大小为 $k - 1$ 的滑动窗口。 +我们使用 $flip\_count$ 记录第 $i$ 个元素之前 $k - 1$ 个位置总共被反转了多少次,或者 $flip\_count$ 是大小为 $k - 1$ 的滑动窗口。 - 如果前面第 $k - 1$ 个元素翻转了奇数次,则如果 $nums[i] == 1$,则 $nums[i]$ 也被翻转成了 $0$,需要再翻转 $1$ 次。 - 如果前面第 $k - 1$ 个元素翻转了偶数次,则如果 $nums[i] == 0$,则 $nums[i]$ 也被翻转成为了 $0$,需要再翻转 $1$ 次。 这两句写成判断语句可以写为:`if (flip_count + nums[i]) % 2 == 0:`。 -因为 $0 <= nums[i] <= 1$,所以我们可以用 $0$ 和 $1$ 以外的数,比如 $2$ 来标记第 $i$ 个元素发生了翻转,即 `nums[i] = 2`。这样在遍历到第 $i$ 个元素时,如果有 $nums[i - k] == 2$,则说明 $nums[i - k]$ 发生了翻转。同时根据 $flip\underline{\hspace{0.5em}}count$ 和 $nums[i]$ 来判断第 $i$ 位是否需要进行翻转。 +因为 $0 <= nums[i] <= 1$,所以我们可以用 $0$ 和 $1$ 以外的数,比如 $2$ 来标记第 $i$ 个元素发生了翻转,即 `nums[i] = 2`。这样在遍历到第 $i$ 个元素时,如果有 $nums[i - k] == 2$,则说明 $nums[i - k]$ 发生了翻转。同时根据 $flip\_count$ 和 $nums[i]$ 来判断第 $i$ 位是否需要进行翻转。 整个算法的具体步骤如下: -- 使用 $res$ 记录最小翻转次数。使用 $flip\underline{\hspace{0.5em}}count$ 记录窗口内前 $k - 1 $ 位元素的翻转次数。 +- 使用 $res$ 记录最小翻转次数。使用 $flip\_count$ 记录窗口内前 $k - 1 $ 位元素的翻转次数。 - 遍历数组 $nums$,对于第 $i$ 位元素: - 如果 $i - k >= 0$,并且 $nums[i - k] == 2$,需要缩小窗口,将翻转次数减一。(此时窗口范围为 $[i - k + 1, i - 1]$)。 - - 如果 $(flip\underline{\hspace{0.5em}}count + nums[i]) \mod 2 == 0$,则说明 $nums[i]$ 还需要再翻转一次,将 $nums[i]$ 标记为 $2$,同时更新窗口内翻转次数 $flip\underline{\hspace{0.5em}}count$ 和答案最小翻转次数 $ans$。 + - 如果 $(flip\_count + nums[i]) \mod 2 == 0$,则说明 $nums[i]$ 还需要再翻转一次,将 $nums[i]$ 标记为 $2$,同时更新窗口内翻转次数 $flip\_count$ 和答案最小翻转次数 $ans$。 - 遍历完之后,返回 $res$。 ### 思路 1:代码 diff --git a/docs/solutions/0900-0999/rle-iterator.md b/docs/solutions/0900-0999/rle-iterator.md index 5b0aa72f..1f943299 100644 --- a/docs/solutions/0900-0999/rle-iterator.md +++ b/docs/solutions/0900-0999/rle-iterator.md @@ -56,12 +56,12 @@ rLEIterator.next(2); // 耗去序列的 2 个项,返回 -1。 这是由于第 1. 初始化时: 1. 保存数组 $encoding$ 作为成员变量。 2. 保存当前位置 $index$,表示当前迭代器指向元素 $encoding[index + 1]$。初始化赋值为 $0$。 - 3. 保存当前指向元素 $encoding[index + 1]$ 已经被删除的元素个数 $d\underline{\hspace{0.5em}}cnt$。初始化赋值为 $0$。 + 3. 保存当前指向元素 $encoding[index + 1]$ 已经被删除的元素个数 $d\_cnt$。初始化赋值为 $0$。 2. 调用 `next(n)` 时: 1. 对于当前元素,先判断当前位置是否超出 $encoding$ 范围,超过则直接返回 $-1$。 - 2. 如果未超过,再判断当前元素剩余个数 $encoding[index] - d\underline{\hspace{0.5em}}cnt$ 是否小于 $n$ 个。 + 2. 如果未超过,再判断当前元素剩余个数 $encoding[index] - d\_cnt$ 是否小于 $n$ 个。 1. 如果小于 $n$ 个,则删除当前元素剩余所有个数,并指向下一位置继续删除剩余元素。 - 2. 如果等于大于等于 $n$ 个,则令当前指向元素 $encoding[index + 1]$ 已经被删除的元素个数 $d\underline{\hspace{0.5em}}cnt$ 加上 $n$。 + 2. 如果等于大于等于 $n$ 个,则令当前指向元素 $encoding[index + 1]$ 已经被删除的元素个数 $d\_cnt$ 加上 $n$。 ### 思路 1:代码 diff --git a/docs/solutions/0900-0999/sort-an-array.md b/docs/solutions/0900-0999/sort-an-array.md index c8840726..c8ad263b 100644 --- a/docs/solutions/0900-0999/sort-an-array.md +++ b/docs/solutions/0900-0999/sort-an-array.md @@ -97,12 +97,12 @@ class Solution: 1. 初始状态下,无已排序区间,未排序区间为 $[0, n - 1]$。 2. 第 $1$ 趟选择: - 1. 遍历未排序区间 $[0, n - 1]$,使用变量 $min\underline{\hspace{0.5em}}i$ 记录区间中值最小的元素位置。 - 2. 将 $min\underline{\hspace{0.5em}}i$ 与下标为 $0$ 处的元素交换位置。如果下标为 $0$ 处元素就是值最小的元素位置,则不用交换。 + 1. 遍历未排序区间 $[0, n - 1]$,使用变量 $min\_i$ 记录区间中值最小的元素位置。 + 2. 将 $min\_i$ 与下标为 $0$ 处的元素交换位置。如果下标为 $0$ 处元素就是值最小的元素位置,则不用交换。 3. 此时,$[0, 0]$ 为已排序区间,$[1, n - 1]$(总共 $n - 1$ 个元素)为未排序区间。 3. 第 $2$ 趟选择: - 1. 遍历未排序区间 $[1, n - 1]$,使用变量 $min\underline{\hspace{0.5em}}i$ 记录区间中值最小的元素位置。 - 2. 将 $min\underline{\hspace{0.5em}}i$ 与下标为 $1$ 处的元素交换位置。如果下标为 $1$ 处元素就是值最小的元素位置,则不用交换。 + 1. 遍历未排序区间 $[1, n - 1]$,使用变量 $min\_i$ 记录区间中值最小的元素位置。 + 2. 将 $min\_i$ 与下标为 $1$ 处的元素交换位置。如果下标为 $1$ 处元素就是值最小的元素位置,则不用交换。 3. 此时,$[0, 1]$ 为已排序区间,$[2, n - 1]$(总共 $n - 2$ 个元素)为未排序区间。 4. 依次类推,对剩余未排序区间重复上述选择过程,直到所有元素都划分到已排序区间,排序结束。 @@ -231,12 +231,12 @@ class Solution: 假设数组的元素个数为 $n$ 个,则归并排序的算法步骤如下: 1. **分解过程**:先递归地将当前数组平均分成两半,直到子数组长度为 $1$。 - 1. 找到数组中心位置 $mid$,从中心位置将数组分成左右两个子数组 $left\underline{\hspace{0.5em}}nums$、$right\underline{\hspace{0.5em}}nums$。 - 2. 对左右两个子数组 $left\underline{\hspace{0.5em}}nums$、$right\underline{\hspace{0.5em}}nums$ 分别进行递归分解。 + 1. 找到数组中心位置 $mid$,从中心位置将数组分成左右两个子数组 $left\_nums$、$right\_nums$。 + 2. 对左右两个子数组 $left\_nums$、$right\_nums$ 分别进行递归分解。 3. 最终将数组分解为 $n$ 个长度均为 $1$ 的有序子数组。 2. **归并过程**:从长度为 $1$ 的有序子数组开始,依次将有序数组两两合并,直到合并成一个长度为 $n$ 的有序数组。 1. 使用数组变量 $nums$ 存放合并后的有序数组。 - 2. 使用两个指针 $left\underline{\hspace{0.5em}}i$、$right\underline{\hspace{0.5em}}i$ 分别指向两个有序子数组 $left\underline{\hspace{0.5em}}nums$、$right\underline{\hspace{0.5em}}nums$ 的开始位置。 + 2. 使用两个指针 $left\_i$、$right\_i$ 分别指向两个有序子数组 $left\_nums$、$right\_nums$ 的开始位置。 3. 比较两个指针指向的元素,将两个有序子数组中较小元素依次存入到结果数组 $nums$ 中,并将指针移动到下一位置。 4. 重复步骤 $3$,直到某一指针到达子数组末尾。 5. 将另一个子数组中的剩余元素存入到结果数组 $nums$ 中。 @@ -440,15 +440,15 @@ class Solution: 假设数组的元素个数为 $n$ 个,则计数排序的算法步骤如下: -1. **计算排序范围**:遍历数组,找出待排序序列中最大值元素 $nums\underline{\hspace{0.5em}}max$ 和最小值元素 $nums\underline{\hspace{0.5em}}min$,计算出排序范围为 $nums\underline{\hspace{0.5em}}max - nums\underline{\hspace{0.5em}}min + 1$。 +1. **计算排序范围**:遍历数组,找出待排序序列中最大值元素 $nums\_max$ 和最小值元素 $nums\_min$,计算出排序范围为 $nums\_max - nums\_min + 1$。 2. **定义计数数组**:定义一个大小为排序范围的计数数组 $counts$,用于统计每个元素的出现次数。其中: - 1. 数组的索引值 $num - nums\underline{\hspace{0.5em}}min$ 表示元素的值为 $num$。 - 2. 数组的值 $counts[num - nums\underline{\hspace{0.5em}}min]$ 表示元素 $num$ 的出现次数。 + 1. 数组的索引值 $num - nums\_min$ 表示元素的值为 $num$。 + 2. 数组的值 $counts[num - nums\_min]$ 表示元素 $num$ 的出现次数。 -3. **对数组元素进行计数统计**:遍历待排序数组 $nums$,对每个元素在计数数组中进行计数,即将待排序数组中「每个元素值减去最小值」作为索引,将「对计数数组中的值」加 $1$,即令 $counts[num - nums\underline{\hspace{0.5em}}min]$ 加 $1$。 -4. **生成累积计数数组**:从 $counts$ 中的第 $1$ 个元素开始,每一项累家前一项和。此时 $counts[num - nums\underline{\hspace{0.5em}}min]$ 表示值为 $num$ 的元素在排序数组中最后一次出现的位置。 +3. **对数组元素进行计数统计**:遍历待排序数组 $nums$,对每个元素在计数数组中进行计数,即将待排序数组中「每个元素值减去最小值」作为索引,将「对计数数组中的值」加 $1$,即令 $counts[num - nums\_min]$ 加 $1$。 +4. **生成累积计数数组**:从 $counts$ 中的第 $1$ 个元素开始,每一项累家前一项和。此时 $counts[num - nums\_min]$ 表示值为 $num$ 的元素在排序数组中最后一次出现的位置。 5. **逆序填充目标数组**:逆序遍历数组 $nums$,将每个元素 $num$ 填入正确位置。 - 6. 将其填充到结果数组 $res$ 的索引 $counts[num - nums\underline{\hspace{0.5em}}min]$ 处。 + 6. 将其填充到结果数组 $res$ 的索引 $counts[num - nums\_min]$ 处。 7. 放入后,令累积计数数组中对应索引减 $1$,从而得到下个元素 $num$ 的放置位置。 ### 思路 8:代码 diff --git a/docs/solutions/1000-1099/grumpy-bookstore-owner.md b/docs/solutions/1000-1099/grumpy-bookstore-owner.md index 529c7352..b516ec8d 100644 --- a/docs/solutions/1000-1099/grumpy-bookstore-owner.md +++ b/docs/solutions/1000-1099/grumpy-bookstore-owner.md @@ -50,9 +50,9 @@ 固定长度的滑动窗口题目。我们可以维护一个窗口大小为 $minutes$ 的滑动窗口。使用 $window_count$ 记录当前窗口内生气的顾客人数。然后滑动求出窗口中最大顾客数,然后累加上老板未生气时的顾客数,就是答案。具体做法如下: -1. $ans$ 用来维护答案数目。$window\underline{\hspace{0.5em}}count$ 用来维护窗口中生气的顾客人数。 +1. $ans$ 用来维护答案数目。$window\_count$ 用来维护窗口中生气的顾客人数。 2. $left$ 、$right$ 都指向序列的第一个元素,即:`left = 0`,`right = 0`。 -3. 如果书店老板生气,则将这一分钟的顾客数量加入到 $window\underline{\hspace{0.5em}}count$ 中,然后向右移动 $right$。 +3. 如果书店老板生气,则将这一分钟的顾客数量加入到 $window\_count$ 中,然后向右移动 $right$。 4. 当窗口元素个数大于 $minutes$ 时,即:$right - left + 1 > count$ 时,如果最左侧边界老板处于生气状态,则向右移动 $left$,从而缩小窗口长度,即 `left += 1`,使得窗口大小始终保持为小于 $minutes$。 5. 重复 $3 \sim 4$ 步,直到 $right$ 到达数组末尾。 6. 然后累加上老板未生气时的顾客数,最后输出答案。 diff --git a/docs/solutions/1000-1099/last-stone-weight-ii.md b/docs/solutions/1000-1099/last-stone-weight-ii.md index 3eb5250f..721bbfd8 100644 --- a/docs/solutions/1000-1099/last-stone-weight-ii.md +++ b/docs/solutions/1000-1099/last-stone-weight-ii.md @@ -54,8 +54,8 @@ 进一步可以变为:「0-1 背包问题」。 -1. 假设石头总重量和为 $sum$,将一堆石头放进载重上限为 $sum / 2$ 的背包中,获得的最大价值为 $max\underline{\hspace{0.5em}}weight$(即其中一堆石子的重量)。另一堆石子的重量为 $sum - max\underline{\hspace{0.5em}}weight$。 -2. 则两者的差值为 $sum - 2 \times max\underline{\hspace{0.5em}}weight$,即为答案。 +1. 假设石头总重量和为 $sum$,将一堆石头放进载重上限为 $sum / 2$ 的背包中,获得的最大价值为 $max\_weight$(即其中一堆石子的重量)。另一堆石子的重量为 $sum - max\_weight$。 +2. 则两者的差值为 $sum - 2 \times max\_weight$,即为答案。 ###### 1. 划分阶段 diff --git a/docs/solutions/1000-1099/max-consecutive-ones-iii.md b/docs/solutions/1000-1099/max-consecutive-ones-iii.md index 39bb3c93..1f0d4950 100644 --- a/docs/solutions/1000-1099/max-consecutive-ones-iii.md +++ b/docs/solutions/1000-1099/max-consecutive-ones-iii.md @@ -43,10 +43,10 @@ ### 思路 1:滑动窗口(不定长度) -1. 使用两个指针 $left$、$right$ 指向数组开始位置。使用 $max\underline{\hspace{0.5em}}count$ 来维护仅包含 $1$ 的最长连续子数组的长度。 +1. 使用两个指针 $left$、$right$ 指向数组开始位置。使用 $max\_count$ 来维护仅包含 $1$ 的最长连续子数组的长度。 2. 不断右移 $right$ 指针,扩大滑动窗口范围,并统计窗口内 $0$ 元素的个数。 -3. 直到 $0$ 元素的个数超过 $k$ 时将 $left$ 右移,缩小滑动窗口范围,并减小 $0$ 元素的个数,同时维护 $max\underline{\hspace{0.5em}}count$。 -4. 最后输出最长连续子数组的长度 $max\underline{\hspace{0.5em}}count$。 +3. 直到 $0$ 元素的个数超过 $k$ 时将 $left$ 右移,缩小滑动窗口范围,并减小 $0$ 元素的个数,同时维护 $max\_count$。 +4. 最后输出最长连续子数组的长度 $max\_count$。 ### 思路 1:代码 diff --git a/docs/solutions/1100-1199/delete-nodes-and-return-forest.md b/docs/solutions/1100-1199/delete-nodes-and-return-forest.md index af57e3fe..9e77772b 100644 --- a/docs/solutions/1100-1199/delete-nodes-and-return-forest.md +++ b/docs/solutions/1100-1199/delete-nodes-and-return-forest.md @@ -11,7 +11,7 @@ **描述**:给定二叉树的根节点 $root$,树上每个节点都有一个不同的值。 -如果节点值在 $to\underline{\hspace{0.5em}}delete$ 中出现,我们就把该节点从树上删去,最后得到一个森林(一些不相交的树构成的集合)。 +如果节点值在 $to\_delete$ 中出现,我们就把该节点从树上删去,最后得到一个森林(一些不相交的树构成的集合)。 **要求**:返回森林中的每棵树。你可以按任意顺序组织答案。 @@ -19,8 +19,8 @@ - 树中的节点数最大为 $1000$。 - 每个节点都有一个介于 $1$ 到 $1000$ 之间的值,且各不相同。 -- $to\underline{\hspace{0.5em}}delete.length \le 1000$。 -- $to\underline{\hspace{0.5em}}delete$ 包含一些从 $1$ 到 $1000$、各不相同的值。 +- $to\_delete.length \le 1000$。 +- $to\_delete$ 包含一些从 $1$ 到 $1000$、各不相同的值。 **示例**: @@ -44,7 +44,7 @@ ### 思路 1:深度优先搜索 -将待删除节点数组 $to\underline{\hspace{0.5em}}delete$ 转为集合 $deletes$,则每次能以 $O(1)$ 的时间复杂度判断节点值是否在待删除节点数组中。 +将待删除节点数组 $to\_delete$ 转为集合 $deletes$,则每次能以 $O(1)$ 的时间复杂度判断节点值是否在待删除节点数组中。 如果当前节点值在待删除节点数组中,则删除当前节点后,我们还需要判断其左右子节点是否也在待删除节点数组中。 diff --git a/docs/solutions/1100-1199/diet-plan-performance.md b/docs/solutions/1100-1199/diet-plan-performance.md index f6e0a309..c1ee4979 100644 --- a/docs/solutions/1100-1199/diet-plan-performance.md +++ b/docs/solutions/1100-1199/diet-plan-performance.md @@ -49,7 +49,7 @@ 固定长度为 $k$ 的滑动窗口题目。具体做法如下: -1. $score$ 用来维护得分情况,初始值为 $0$。$window\underline{\hspace{0.5em}}sum$ 用来维护窗口中卡路里总量。 +1. $score$ 用来维护得分情况,初始值为 $0$。$window\_sum$ 用来维护窗口中卡路里总量。 2. $left$ 、$right$ 都指向数组的第一个元素,即:`left = 0`,`right = 0`。 3. 向右移动 $right$,先将 $k$ 个元素填入窗口中。 4. 当窗口元素个数为 $k$ 时,即:$right - left + 1 \ge k$ 时,计算窗口内的卡路里总量,并判断和 $upper$、$lower$ 的关系。同时维护得分情况。 diff --git a/docs/solutions/1100-1199/distribute-candies-to-people.md b/docs/solutions/1100-1199/distribute-candies-to-people.md index dd879a20..08b47325 100644 --- a/docs/solutions/1100-1199/distribute-candies-to-people.md +++ b/docs/solutions/1100-1199/distribute-candies-to-people.md @@ -9,7 +9,7 @@ ## 题目大意 -**描述**:给定一个整数 $candies$,代表糖果的数量。再给定一个整数 $num\underline{\hspace{0.5em}}people$,代表小朋友的数量。 +**描述**:给定一个整数 $candies$,代表糖果的数量。再给定一个整数 $num\_people$,代表小朋友的数量。 现在开始分糖果,给第 $1$ 个小朋友分 $1$ 颗糖果,第 $2$ 个小朋友分 $2$ 颗糖果,以此类推,直到最后一个小朋友分 $n$ 颗糖果。 @@ -19,12 +19,12 @@ > 注意:如果我们手中剩下的糖果数不够(小于等于前一次发的糖果数),则将剩下的糖果全部发给当前的小朋友。 -**要求**:返回一个长度为 $num\underline{\hspace{0.5em}}people$、元素之和为 $candies$ 的数组,以表示糖果的最终分发情况(即 $ans[i]$ 表示第 $i$ 个小朋友分到的糖果数)。 +**要求**:返回一个长度为 $num\_people$、元素之和为 $candies$ 的数组,以表示糖果的最终分发情况(即 $ans[i]$ 表示第 $i$ 个小朋友分到的糖果数)。 **说明**: - $1 \le candies \le 10^9$。 -- $1 \le num\underline{\hspace{0.5em}}people \le 1000$。 +- $1 \le num\_people \le 1000$。 **示例**: diff --git a/docs/solutions/1100-1199/maximum-level-sum-of-a-binary-tree.md b/docs/solutions/1100-1199/maximum-level-sum-of-a-binary-tree.md index 1500f6b3..035d03cb 100644 --- a/docs/solutions/1100-1199/maximum-level-sum-of-a-binary-tree.md +++ b/docs/solutions/1100-1199/maximum-level-sum-of-a-binary-tree.md @@ -46,8 +46,8 @@ ### 思路 1:二叉树的层序遍历 1. 利用广度优先搜索,在二叉树的层序遍历的基础上,统计每一层节点和,并存入数组 $levels$ 中。 -2. 遍历 $levels$ 数组,从 $levels$ 数组中找到最大层和 $max\underline{\hspace{0.5em}}sum$。 -3. 再次遍历 $levels$ 数组,找出等于最大层和 $max\underline{\hspace{0.5em}}sum$ 的那一层,并返回该层序号。 +2. 遍历 $levels$ 数组,从 $levels$ 数组中找到最大层和 $max\_sum$。 +3. 再次遍历 $levels$ 数组,找出等于最大层和 $max\_sum$ 的那一层,并返回该层序号。 ### 思路 1:代码 diff --git a/docs/solutions/1100-1199/minimum-swaps-to-group-all-1s-together.md b/docs/solutions/1100-1199/minimum-swaps-to-group-all-1s-together.md index f2016522..b3f51f7a 100644 --- a/docs/solutions/1100-1199/minimum-swaps-to-group-all-1s-together.md +++ b/docs/solutions/1100-1199/minimum-swaps-to-group-all-1s-together.md @@ -50,12 +50,12 @@ 求最少交换次数,也就是求滑动窗口中最少的 $0$ 的个数。具体做法如下: -1. 统计 $1$ 的个数,并设置为窗口长度 $window\underline{\hspace{0.5em}}size$。使用 $window\underline{\hspace{0.5em}}count$ 维护窗口中 $0$ 的个数。使用 $ans$ 维护窗口中最少的 $0$ 的个数,也可以叫做最少交换次数。 -2. 如果 $window\underline{\hspace{0.5em}}size$ 为 $0$,则说明不用交换,直接返回 $0$。 +1. 统计 $1$ 的个数,并设置为窗口长度 $window\_size$。使用 $window\_count$ 维护窗口中 $0$ 的个数。使用 $ans$ 维护窗口中最少的 $0$ 的个数,也可以叫做最少交换次数。 +2. 如果 $window\_size$ 为 $0$,则说明不用交换,直接返回 $0$。 3. 使用两个指针 $left$、$right$。$left$、$right$ 都指向数组的第一个元素,即:`left = 0`,`right = 0`。 4. 如果 $data[right] == 0$,则更新窗口中 $0$ 的个数,即 `window_count += 1`。然后向右移动 $right$。 -5. 当窗口元素个数为 $window\underline{\hspace{0.5em}}size$ 时,即:$right - left + 1 \ge window\underline{\hspace{0.5em}}size$ 时,更新窗口中最少的 $0$ 的个数。 -6. 然后如果左侧 $data[left] == 0$,则更新窗口中 $0$ 的个数,即 `window_count -= 1`。然后向右移动 $left$,从而缩小窗口长度,即 `left += 1`,使得窗口大小始终保持为 $window\underline{\hspace{0.5em}}size$。 +5. 当窗口元素个数为 $window\_size$ 时,即:$right - left + 1 \ge window\_size$ 时,更新窗口中最少的 $0$ 的个数。 +6. 然后如果左侧 $data[left] == 0$,则更新窗口中 $0$ 的个数,即 `window_count -= 1`。然后向右移动 $left$,从而缩小窗口长度,即 `left += 1`,使得窗口大小始终保持为 $window\_size$。 7. 重复 4 ~ 6 步,直到 $right$ 到达数组末尾。返回答案 $ans$。 ### 思路 1:代码 diff --git a/docs/solutions/1200-1299/find-elements-in-a-contaminated-binary-tree.md b/docs/solutions/1200-1299/find-elements-in-a-contaminated-binary-tree.md index 646a109b..1d135e88 100644 --- a/docs/solutions/1200-1299/find-elements-in-a-contaminated-binary-tree.md +++ b/docs/solutions/1200-1299/find-elements-in-a-contaminated-binary-tree.md @@ -71,7 +71,7 @@ findElements.find(5); // return False 1. 从根节点开始进行还原。 2. 然后使用深度优先搜索的方式,依次递归还原左右两个孩子节点。 -3. 递归还原的同时,将还原之后的所有节点值,存入集合 $val\underline{\hspace{0.5em}}set$ 中。 +3. 递归还原的同时,将还原之后的所有节点值,存入集合 $val\_set$ 中。 这样就可以在 $O(1)$ 的时间复杂度内判断目标值 $target$ 是否在还原后的二叉树中了。 diff --git a/docs/solutions/1200-1299/get-equal-substrings-within-budget.md b/docs/solutions/1200-1299/get-equal-substrings-within-budget.md index 89280181..cf10b38c 100644 --- a/docs/solutions/1200-1299/get-equal-substrings-within-budget.md +++ b/docs/solutions/1200-1299/get-equal-substrings-within-budget.md @@ -41,14 +41,14 @@ ### 思路 1:滑动窗口 -维护一个滑动窗口 $window\underline{\hspace{0.5em}}sum$ 用于记录窗口内的开销总和,保证窗口内的开销总和小于等于 $maxCost$。使用 $ans$ 记录可以转化的最大长度。具体做法如下: +维护一个滑动窗口 $window\_sum$ 用于记录窗口内的开销总和,保证窗口内的开销总和小于等于 $maxCost$。使用 $ans$ 记录可以转化的最大长度。具体做法如下: 使用两个指针 $left$、$right$。分别指向滑动窗口的左右边界,保证窗口内所有元素转化开销总和小于等于 $maxCost$。 - 先统计出 $s$ 中第 $i$ 个字符变为 $t$ 的第 $i$ 个字符的开销,用数组 $costs$ 保存。 - 一开始,$left$、$right$ 都指向 $0$。 - 将最右侧字符的转变开销填入窗口中,向右移动 $right$。 -- 直到窗口内开销总和 $window\underline{\hspace{0.5em}}sum$ 大于 $maxCost$。则不断右移 $left$,缩小窗口长度。直到 $window\underline{\hspace{0.5em}}sum \le maxCost$ 时,更新可以转换的最大长度 $ans$。 +- 直到窗口内开销总和 $window\_sum$ 大于 $maxCost$。则不断右移 $left$,缩小窗口长度。直到 $window\_sum \le maxCost$ 时,更新可以转换的最大长度 $ans$。 - 向右移动 $right$,直到 $right \ge len(s)$ 为止。 - 输出答案 $ans$。 diff --git a/docs/solutions/1200-1299/meeting-scheduler.md b/docs/solutions/1200-1299/meeting-scheduler.md index 3ebc139a..cad2b7e7 100644 --- a/docs/solutions/1200-1299/meeting-scheduler.md +++ b/docs/solutions/1200-1299/meeting-scheduler.md @@ -49,12 +49,12 @@ 题目保证了同一个人的空闲时间不会出现交叠。那么可以先直接对两个客户的空间时间表按照开始时间从小到大排序。然后使用分离双指针来遍历两个数组,求出重合部分,并判断重合区间是否大于等于 $duration$。具体做法如下: 1. 先对两个数组排序。 -2. 然后使用两个指针 $left\underline{\hspace{0.5em}}1$、$left\underline{\hspace{0.5em}}2$。$left\underline{\hspace{0.5em}}1$ 指向第一个数组开始位置,$left\underline{\hspace{0.5em}}2$ 指向第二个数组开始位置。 +2. 然后使用两个指针 $left\_1$、$left\_2$。$left\_1$ 指向第一个数组开始位置,$left\_2$ 指向第二个数组开始位置。 3. 遍历两个数组。计算当前两个空闲时间区间的重叠范围。 1. 如果重叠范围大于等于 $duration$,直接返回当前重叠范围开始时间和会议结束时间,即 $[start, start + duration]$,$start$ 为重叠范围开始时间。 - 2. 如果第一个客户的空闲结束时间小于第二个客户的空闲结束时间,则令 $left\underline{\hspace{0.5em}}1$ 右移,即 `left_1 += 1`,继续比较重叠范围。 - 3. 如果第一个客户的空闲结束时间大于等于第二个客户的空闲结束时间,则令 $left\underline{\hspace{0.5em}}2$ 右移,即 `left_2 += 1`,继续比较重叠范围。 -4. 直到 $left\underline{\hspace{0.5em}}1 == len(slots1)$ 或者 $left\underline{\hspace{0.5em}}2 == len(slots2)$ 时跳出循环,返回空数组 $[]$。 + 2. 如果第一个客户的空闲结束时间小于第二个客户的空闲结束时间,则令 $left\_1$ 右移,即 `left_1 += 1`,继续比较重叠范围。 + 3. 如果第一个客户的空闲结束时间大于等于第二个客户的空闲结束时间,则令 $left\_2$ 右移,即 `left_2 += 1`,继续比较重叠范围。 +4. 直到 $left\_1 == len(slots1)$ 或者 $left\_2 == len(slots2)$ 时跳出循环,返回空数组 $[]$。 ### 思路 1:代码 diff --git a/docs/solutions/1200-1299/tree-diameter.md b/docs/solutions/1200-1299/tree-diameter.md index bdf8ded4..67ad255d 100644 --- a/docs/solutions/1200-1299/tree-diameter.md +++ b/docs/solutions/1200-1299/tree-diameter.md @@ -55,11 +55,11 @@ 即:**最长路径长度 = max(某子树中的最长路径长度 + 另一子树中的最长路径长度 + 1,某个子树中的最长路径长度)**。 -对此,我们可以使用深度优先搜索递归遍历 $u$ 的所有相邻节点 $v$,并在递归遍历的同时,维护一个全局最大路径和变量 $ans$,以及当前节点 $u$ 的最大路径长度变量 $u\underline{\hspace{0.5em}}len$。 +对此,我们可以使用深度优先搜索递归遍历 $u$ 的所有相邻节点 $v$,并在递归遍历的同时,维护一个全局最大路径和变量 $ans$,以及当前节点 $u$ 的最大路径长度变量 $u\_len$。 -1. 先计算出从相邻节点 $v$ 出发的最长路径长度 $v\underline{\hspace{0.5em}}len$。 -2. 更新维护全局最长路径长度为 $self.ans = max(self.ans, \quad u\underline{\hspace{0.5em}}len + v\underline{\hspace{0.5em}}len + 1)$。 -3. 更新维护当前节点 $u$ 的最长路径长度为 $u\underline{\hspace{0.5em}}len = max(u\underline{\hspace{0.5em}}len, \quad v\underline{\hspace{0.5em}}len + 1)$。 +1. 先计算出从相邻节点 $v$ 出发的最长路径长度 $v\_len$。 +2. 更新维护全局最长路径长度为 $self.ans = max(self.ans, \quad u\_len + v\_len + 1)$。 +3. 更新维护当前节点 $u$ 的最长路径长度为 $u\_len = max(u\_len, \quad v\_len + 1)$。 > 注意:在遍历邻接节点的过程中,为了避免造成重复遍历,我们在使用深度优先搜索时,应过滤掉父节点。 diff --git a/docs/solutions/1300-1399/maximum-students-taking-exam.md b/docs/solutions/1300-1399/maximum-students-taking-exam.md index c1ee6079..898196d3 100644 --- a/docs/solutions/1300-1399/maximum-students-taking-exam.md +++ b/docs/solutions/1300-1399/maximum-students-taking-exam.md @@ -70,15 +70,15 @@ ###### 3. 状态转移方程 -因为学生可以看到左侧、右侧、左上方、右上方这四个方向上紧邻他的学生答卷,所以对于当前排的某个座位来说,其左侧、右侧、左上方、右上方都不应有人坐。我们可以根据当前排的座位选取状态 $cur\underline{\hspace{0.5em}}state$,并通过枚举的方式,找出符合要求的上一排座位选取状态 $pre\underline{\hspace{0.5em}}state$,并计算出当前排座位选择个数,即 $f(cur\underline{\hspace{0.5em}}state)$,则状态转移方程为: +因为学生可以看到左侧、右侧、左上方、右上方这四个方向上紧邻他的学生答卷,所以对于当前排的某个座位来说,其左侧、右侧、左上方、右上方都不应有人坐。我们可以根据当前排的座位选取状态 $cur\_state$,并通过枚举的方式,找出符合要求的上一排座位选取状态 $pre\_state$,并计算出当前排座位选择个数,即 $f(cur\_state)$,则状态转移方程为: - $dp[i][state] = \max \lbrace dp[i - 1][pre\underline{\hspace{0.5em}}state] \rbrace + f(state)$ + $dp[i][state] = \max \lbrace dp[i - 1][pre\_state] \rbrace + f(state)$ -因为所给座位中还有坏座位(不可用)的情况,我们可以使用一个 $8$ 位的二进制数 $bad\underline{\hspace{0.5em}}seat$ 来表示当前排的坏座位情况,如果 $cur\underline{\hspace{0.5em}}state \text{ \& } bad\underline{\hspace{0.5em}}seat == 1$,则说明当前状态下,选择了坏椅子,则可直接跳过这种状态。 +因为所给座位中还有坏座位(不可用)的情况,我们可以使用一个 $8$ 位的二进制数 $bad\_seat$ 来表示当前排的坏座位情况,如果 $cur\_state \text{ \& } bad\_seat == 1$,则说明当前状态下,选择了坏椅子,则可直接跳过这种状态。 -我们还可以通过 $cur\underline{\hspace{0.5em}}state \text{ \& } (cur\underline{\hspace{0.5em}}state \text{ <}\text{< } 1)$ 和 $cur\underline{\hspace{0.5em}}state \& (cur\underline{\hspace{0.5em}}state \text{ >}\text{> } 1)$ 来判断当前排选择状态下,左右相邻座位上是否有人,如果有人,则可直接跳过这种状态。 +我们还可以通过 $cur\_state \text{ \& } (cur\_state \text{ <}\text{< } 1)$ 和 $cur\_state \& (cur\_state \text{ >}\text{> } 1)$ 来判断当前排选择状态下,左右相邻座位上是否有人,如果有人,则可直接跳过这种状态。 -同理,我们还可以通过 $cur\underline{\hspace{0.5em}}state \text{ \& } (pre\underline{\hspace{0.5em}}state \text{ <}\text{< } 1)$ 和 $cur\underline{\hspace{0.5em}}state \text{ \& } (pre\underline{\hspace{0.5em}}state \text{ >}\text{> } 1)$ 来判断当前排选择状态下,上一行左上、右上相邻座位上是否有人,如果有人,则可直接跳过这种状态。 +同理,我们还可以通过 $cur\_state \text{ \& } (pre\_state \text{ <}\text{< } 1)$ 和 $cur\_state \text{ \& } (pre\_state \text{ >}\text{> } 1)$ 来判断当前排选择状态下,上一行左上、右上相邻座位上是否有人,如果有人,则可直接跳过这种状态。 ###### 4. 初始条件 diff --git a/docs/solutions/1300-1399/minimum-number-of-steps-to-make-two-strings-anagram.md b/docs/solutions/1300-1399/minimum-number-of-steps-to-make-two-strings-anagram.md index b9e51f71..aabfab1a 100644 --- a/docs/solutions/1300-1399/minimum-number-of-steps-to-make-two-strings-anagram.md +++ b/docs/solutions/1300-1399/minimum-number-of-steps-to-make-two-strings-anagram.md @@ -46,9 +46,9 @@ 因为每一次转换都会减少一个字符,并增加另一个字符。 -1. 我们使用两个哈希表 $cnts\underline{\hspace{0.5em}}s$、$cnts\underline{\hspace{0.5em}}t$ 分别对 $t$ 和 $s$ 中的字符进行计数,并求出两者的交集。 +1. 我们使用两个哈希表 $cnts\_s$、$cnts\_t$ 分别对 $t$ 和 $s$ 中的字符进行计数,并求出两者的交集。 2. 遍历交集中的字符种类,以及对应的字符数量。 -3. 对于当前字符 $key$,如果当前字符串 $s$ 中的字符 $key$ 的数量小于字符串 $t$ 中字符 $key$ 的数量,即 $cnts\underline{\hspace{0.5em}}s[key] < cnts\underline{\hspace{0.5em}}t[key]$。则 $s$ 中需要补齐的字符数量就是需要的最小步数,将其累加到答案中。 +3. 对于当前字符 $key$,如果当前字符串 $s$ 中的字符 $key$ 的数量小于字符串 $t$ 中字符 $key$ 的数量,即 $cnts\_s[key] < cnts\_t[key]$。则 $s$ 中需要补齐的字符数量就是需要的最小步数,将其累加到答案中。 4. 遍历完返回答案。 ### 思路 1:代码 diff --git a/docs/solutions/1300-1399/print-words-vertically.md b/docs/solutions/1300-1399/print-words-vertically.md index 6b5526f6..3253946b 100644 --- a/docs/solutions/1300-1399/print-words-vertically.md +++ b/docs/solutions/1300-1399/print-words-vertically.md @@ -50,7 +50,7 @@ ### 思路 1:模拟 1. 将字符串 $s$ 按空格分割为单词数组 $words$。 -2. 计算出单词数组 $words$ 中单词的最大长度 $max\underline{\hspace{0.5em}}len$。 +2. 计算出单词数组 $words$ 中单词的最大长度 $max\_len$。 3. 第一重循环遍历竖直单词的每个单词位置 $i$,第二重循环遍历当前第 $j$ 个单词。 1. 如果当前单词没有第 $i$ 个字符(当前单词的长度超过了单词位置 $i$),则将空格插入到竖直单词中。 2. 如果当前单词有第 $i$ 个字符,泽讲当前单词的第 $i$ 个字符插入到竖直单词中。 diff --git a/docs/solutions/1300-1399/sum-of-mutated-array-closest-to-target.md b/docs/solutions/1300-1399/sum-of-mutated-array-closest-to-target.md index 097a42a2..6f2edfff 100644 --- a/docs/solutions/1300-1399/sum-of-mutated-array-closest-to-target.md +++ b/docs/solutions/1300-1399/sum-of-mutated-array-closest-to-target.md @@ -54,9 +54,9 @@ 整个算法步骤如下: -- 先对数组排序,并计算数组的前缀和 $pre\underline{\hspace{0.5em}}sum$。 +- 先对数组排序,并计算数组的前缀和 $pre\_sum$。 - 通过二分查找在 $[0, arr[-1]]$ 中查找使得转变后数组和刚好大于等于 $target$ 的值 $value$。 -- 计算 $value$ 对应的数组和 $sum\underline{\hspace{0.5em}}1$,以及 $value - 1$ 对应的数组和 $sum\underline{\hspace{0.5em}}2$。并分别计算与 $target$ 的差值 $diff\underline{\hspace{0.5em}}1$、$diff\underline{\hspace{0.5em}}2$。 +- 计算 $value$ 对应的数组和 $sum\_1$,以及 $value - 1$ 对应的数组和 $sum\_2$。并分别计算与 $target$ 的差值 $diff\_1$、$diff\_2$。 - 输出差值小的那个值。 ### 思路 1:代码 diff --git a/docs/solutions/1400-1499/longest-subarray-of-1s-after-deleting-one-element.md b/docs/solutions/1400-1499/longest-subarray-of-1s-after-deleting-one-element.md index 5e9280ca..e86d503b 100644 --- a/docs/solutions/1400-1499/longest-subarray-of-1s-after-deleting-one-element.md +++ b/docs/solutions/1400-1499/longest-subarray-of-1s-after-deleting-one-element.md @@ -42,13 +42,13 @@ 维护一个元素值为 $0$ 的元素数量少于 $1$ 个的滑动窗口。则答案为滑动窗口长度减去窗口内 $0$ 的个数求最大值。具体做法如下: -设定两个指针:$left$、$right$,分别指向滑动窗口的左右边界,保证窗口 $0$ 的个数小于 $1$ 个。使用 $window\underline{\hspace{0.5em}}count$ 记录窗口中 $0$ 的个数,使用 $ans$ 记录删除一个元素后,最长的只包含 $1$ 的非空子数组长度。 +设定两个指针:$left$、$right$,分别指向滑动窗口的左右边界,保证窗口 $0$ 的个数小于 $1$ 个。使用 $window\_count$ 记录窗口中 $0$ 的个数,使用 $ans$ 记录删除一个元素后,最长的只包含 $1$ 的非空子数组长度。 - 一开始,$left$、$right$ 都指向 $0$。 - 如果最右侧元素等于 $0$,则 `window_count += 1` 。 -- 如果 $window\underline{\hspace{0.5em}}count > 1$ ,则不断右移 $left$,缩小滑动窗口长度。并更新当前窗口中 $0$ 的个数,直到 $window\underline{\hspace{0.5em}}count \le 1$。 +- 如果 $window\_count > 1$ ,则不断右移 $left$,缩小滑动窗口长度。并更新当前窗口中 $0$ 的个数,直到 $window\_count \le 1$。 - 更新答案值,然后向右移动 $right$,直到 $right \ge len(nums)$ 结束。 - 输出答案 $ans$。 diff --git a/docs/solutions/1400-1499/maximum-number-of-vowels-in-a-substring-of-given-length.md b/docs/solutions/1400-1499/maximum-number-of-vowels-in-a-substring-of-given-length.md index d6799643..93930059 100644 --- a/docs/solutions/1400-1499/maximum-number-of-vowels-in-a-substring-of-given-length.md +++ b/docs/solutions/1400-1499/maximum-number-of-vowels-in-a-substring-of-given-length.md @@ -44,9 +44,9 @@ 固定长度的滑动窗口题目。维护一个长度为 $k$ 的窗口,并统计滑动窗口中最大元音字母数。具体做法如下: -1. $ans$ 用来维护长度为 $k$ 的单个字符串中最大元音字母数。$window\underline{\hspace{0.5em}}count$ 用来维护窗口中元音字母数。集合 $vowel\underline{\hspace{0.5em}}set$ 用来存储元音字母。 +1. $ans$ 用来维护长度为 $k$ 的单个字符串中最大元音字母数。$window\_count$ 用来维护窗口中元音字母数。集合 $vowel\_set$ 用来存储元音字母。 2. $left$ 、$right$ 都指向字符串 $s$ 的第一个元素,即:$left = 0$,$right = 0$。 -3. 判断 $s[right]$ 是否在元音字母集合中,如果在则用 $window\underline{\hspace{0.5em}}count$ 进行计数。 +3. 判断 $s[right]$ 是否在元音字母集合中,如果在则用 $window\_count$ 进行计数。 4. 当窗口元素个数为 $k$ 时,即:$right - left + 1 \ge k$ 时,更新 $ans$。然后判断 $s[left]$ 是否为元音字母,如果是则 `window_count -= 1`,并向右移动 $left$,从而缩小窗口长度,即 `left += 1`,使得窗口大小始终保持为 $k$。 5. 重复 $3 \sim 4$ 步,直到 $right$ 到达数组末尾。 6. 最后输出 $ans$。 diff --git a/docs/solutions/1400-1499/maximum-points-you-can-obtain-from-cards.md b/docs/solutions/1400-1499/maximum-points-you-can-obtain-from-cards.md index 4eb5510b..d991b1f9 100644 --- a/docs/solutions/1400-1499/maximum-points-you-can-obtain-from-cards.md +++ b/docs/solutions/1400-1499/maximum-points-you-can-obtain-from-cards.md @@ -47,17 +47,17 @@ 可以用固定长度的滑动窗口来做。 -由于只能从开头或末尾位置拿 $k$ 张牌,则最后剩下的肯定是连续的 $len(cardPoints) - k$ 张牌。要求求出 $k$ 张牌可以获得的最大收益,我们可以反向先求出连续 $len(cardPoints) - k$ 张牌的最小点数。则答案为 $sum(cardPoints) - min\underline{\hspace{0.5em}}sum$。维护一个固定长度为 $len(cardPoints) - k$ 的滑动窗口,求最小和。具体做法如下: +由于只能从开头或末尾位置拿 $k$ 张牌,则最后剩下的肯定是连续的 $len(cardPoints) - k$ 张牌。要求求出 $k$ 张牌可以获得的最大收益,我们可以反向先求出连续 $len(cardPoints) - k$ 张牌的最小点数。则答案为 $sum(cardPoints) - min\_sum$。维护一个固定长度为 $len(cardPoints) - k$ 的滑动窗口,求最小和。具体做法如下: -1. $window\underline{\hspace{0.5em}}sum$ 用来维护窗口内的元素和,初始值为 $0$。$min\underline{\hspace{0.5em}}sum$ 用来维护滑动窗口元素的最小和。初始值为 $sum(cardPoints)$。滑动窗口的长度为 $window\underline{\hspace{0.5em}}size$,值为 $len(cardPoints) - k$。 +1. $window\_sum$ 用来维护窗口内的元素和,初始值为 $0$。$min\_sum$ 用来维护滑动窗口元素的最小和。初始值为 $sum(cardPoints)$。滑动窗口的长度为 $window\_size$,值为 $len(cardPoints) - k$。 2. 使用双指针 $left$、$right$。$left$ 、$right$ 都指向序列的第一个元素,即:`left = 0`,`right = 0`。 -3. 向右移动 $right$,先将 $window\underline{\hspace{0.5em}}size$ 个元素填入窗口中。 -4. 当窗口元素个数为 $window\underline{\hspace{0.5em}}size$ 时,即:$right - left + 1 \ge window\underline{\hspace{0.5em}}size$ 时,计算窗口内的元素和,并维护子数组最小和 $min\underline{\hspace{0.5em}}sum$。 +3. 向右移动 $right$,先将 $window\_size$ 个元素填入窗口中。 +4. 当窗口元素个数为 $window\_size$ 时,即:$right - left + 1 \ge window\_size$ 时,计算窗口内的元素和,并维护子数组最小和 $min\_sum$。 5. 然后向右移动 $left$,从而缩小窗口长度,即 `left += 1`,使得窗口大小始终保持为 $k$。 6. 重复 4 ~ 5 步,直到 $right$ 到达数组末尾。 -7. 最后输出 $sum(cardPoints) - min\underline{\hspace{0.5em}}sum$ 即为答案。 +7. 最后输出 $sum(cardPoints) - min\_sum$ 即为答案。 -注意:如果 $window\underline{\hspace{0.5em}}size$ 为 $0$ 时需要特殊判断,此时答案为数组和 $sum(cardPoints)$。 +注意:如果 $window\_size$ 为 $0$ 时需要特殊判断,此时答案为数组和 $sum(cardPoints)$。 ### 思路 1:代码 diff --git a/docs/solutions/1500-1599/count-good-triplets.md b/docs/solutions/1500-1599/count-good-triplets.md index 916079a4..853c4045 100644 --- a/docs/solutions/1500-1599/count-good-triplets.md +++ b/docs/solutions/1500-1599/count-good-triplets.md @@ -94,11 +94,11 @@ class Solution: 现在问题就转变了如何快速获取在值域区间 $[left, right]$ 中,有多少个 $arr[i]$。 -我们可以利用前缀和数组,先计算出 $[0, 1000]$ 范围中,满足 $arr[i] < num$ 的元素个数,即为 $prefix\underline{\hspace{0.5em}}cnts[num]$。 +我们可以利用前缀和数组,先计算出 $[0, 1000]$ 范围中,满足 $arr[i] < num$ 的元素个数,即为 $prefix\_cnts[num]$。 -然后对于区间 $[left, right]$,通过 $prefix\underline{\hspace{0.5em}}cnts[right] - prefix\underline{\hspace{0.5em}}cnts[left - 1]$ 即可快速求解出区间 $[left, right]$ 内 $arr[i]$ 的个数。 +然后对于区间 $[left, right]$,通过 $prefix\_cnts[right] - prefix\_cnts[left - 1]$ 即可快速求解出区间 $[left, right]$ 内 $arr[i]$ 的个数。 -因为 $i < j < k$,所以我们可以在每次 $j$ 向右移动一位的时候,更新 $arr[j]$ 对应的前缀和数组,保证枚举到 $j$ 时,$prefix\underline{\hspace{0.5em}}cnts$ 存储对应元素值的个数足够正确。 +因为 $i < j < k$,所以我们可以在每次 $j$ 向右移动一位的时候,更新 $arr[j]$ 对应的前缀和数组,保证枚举到 $j$ 时,$prefix\_cnts$ 存储对应元素值的个数足够正确。 ### 思路 2:代码 diff --git a/docs/solutions/1500-1599/special-positions-in-a-binary-matrix.md b/docs/solutions/1500-1599/special-positions-in-a-binary-matrix.md index b995292a..542cd3f1 100644 --- a/docs/solutions/1500-1599/special-positions-in-a-binary-matrix.md +++ b/docs/solutions/1500-1599/special-positions-in-a-binary-matrix.md @@ -48,9 +48,9 @@ ### 思路 1:模拟 1. 按照行、列遍历二位数组 $mat$。 -2. 使用数组 $row\underline{\hspace{0.5em}}cnts$、$col\underline{\hspace{0.5em}}cnts$ 分别记录每行和每列所含 $1$ 的个数。 +2. 使用数组 $row\_cnts$、$col\_cnts$ 分别记录每行和每列所含 $1$ 的个数。 3. 再次按照行、列遍历二维数组 $mat$。 -4. 统计满足 $mat[row][col] == 1$ 并且 $row\underline{\hspace{0.5em}}cnts[row] == col\underline{\hspace{0.5em}}cnts[col] == 1$ 的位置个数。 +4. 统计满足 $mat[row][col] == 1$ 并且 $row\_cnts[row] == col\_cnts[col] == 1$ 的位置个数。 5. 返回答案。 ### 思路 1:代码 diff --git a/docs/solutions/1500-1599/split-a-string-into-the-max-number-of-unique-substrings.md b/docs/solutions/1500-1599/split-a-string-into-the-max-number-of-unique-substrings.md index e34c4b63..e671f1fc 100644 --- a/docs/solutions/1500-1599/split-a-string-into-the-max-number-of-unique-substrings.md +++ b/docs/solutions/1500-1599/split-a-string-into-the-max-number-of-unique-substrings.md @@ -41,12 +41,12 @@ ### 思路 1:回溯算法 -维护一个全局变量 $ans$ 用于记录拆分后唯一子字符串的最大数目。并使用集合 $s\underline{\hspace{0.5em}}set$ 记录不重复的子串。 +维护一个全局变量 $ans$ 用于记录拆分后唯一子字符串的最大数目。并使用集合 $s\_set$ 记录不重复的子串。 - 从下标为 $0$ 开头的子串回溯。 - 对于下标为 $index$ 开头的子串,我们可以在 $index + 1$ 开始到 $len(s) - 1$ 的位置上,分别进行子串拆分,将子串拆分为 $s[index: i + 1]$。 -- 如果当前子串不在 $s\underline{\hspace{0.5em}}set$ 中,则将其存入 $s\underline{\hspace{0.5em}}set$ 中,然后记录当前拆分子串个数,并从 $i + 1$ 的位置进行下一层递归拆分。然后在拆分完,对子串进行回退操作。 +- 如果当前子串不在 $s\_set$ 中,则将其存入 $s\_set$ 中,然后记录当前拆分子串个数,并从 $i + 1$ 的位置进行下一层递归拆分。然后在拆分完,对子串进行回退操作。 - 如果拆到字符串 $s$ 的末尾,则记录并更新 $ans$。 - 在开始位置还可以进行以下剪枝:如果剩余字符个数 + 当前子串个数 <= 当前拆分后子字符串的最大数目,则直接返回。 diff --git a/docs/solutions/1600-1699/maximum-erasure-value.md b/docs/solutions/1600-1699/maximum-erasure-value.md index 90ef3bf5..0ebed944 100644 --- a/docs/solutions/1600-1699/maximum-erasure-value.md +++ b/docs/solutions/1600-1699/maximum-erasure-value.md @@ -43,7 +43,7 @@ 题目要求的是含有不同元素的连续子数组最大和,我们可以用滑动窗口来做,维护一个不包含重复元素的滑动窗口,计算最大的窗口和。具体方法如下: -- 用滑动窗口 $window$ 来记录不重复的元素个数,$window$ 为哈希表类型。用 $window\underline{\hspace{0.5em}}sum$ 来记录窗口内子数组元素和,$ans$ 用来维护最大子数组和。设定两个指针:$left$、$right$,分别指向滑动窗口的左右边界,保证窗口中没有重复元素。 +- 用滑动窗口 $window$ 来记录不重复的元素个数,$window$ 为哈希表类型。用 $window\_sum$ 来记录窗口内子数组元素和,$ans$ 用来维护最大子数组和。设定两个指针:$left$、$right$,分别指向滑动窗口的左右边界,保证窗口中没有重复元素。 - 一开始,$left$、$right$ 都指向 $0$。 - 将最右侧数组元素 $nums[right]$ 加入当前窗口 $window$ 中,记录该元素个数。 diff --git a/docs/solutions/1600-1699/minimum-deletions-to-make-character-frequencies-unique.md b/docs/solutions/1600-1699/minimum-deletions-to-make-character-frequencies-unique.md index a83e74c3..9219f137 100644 --- a/docs/solutions/1600-1699/minimum-deletions-to-make-character-frequencies-unique.md +++ b/docs/solutions/1600-1699/minimum-deletions-to-make-character-frequencies-unique.md @@ -44,10 +44,10 @@ ### 思路 1:贪心算法 + 哈希表 1. 使用哈希表 $cnts$ 统计每字符串中每个字符出现次数。 -2. 然后使用集合 $s\underline{\hspace{0.5em}}set$ 保存不同的出现次数。 +2. 然后使用集合 $s\_set$ 保存不同的出现次数。 3. 遍历哈希表中所偶出现次数: - 1. 如果当前出现次数不在集合 $s\underline{\hspace{0.5em}}set$ 中,则将该次数添加到集合 $s\underline{\hspace{0.5em}}set$ 中。 - 2. 如果当前出现次数在集合 $s\underline{\hspace{0.5em}}set$ 中,则不断减少该次数,直到该次数不在集合 $s\underline{\hspace{0.5em}}set$ 中停止,将次数添加到集合 $s\underline{\hspace{0.5em}}set$ 中,同时将减少次数累加到答案 $ans$ 中。 + 1. 如果当前出现次数不在集合 $s\_set$ 中,则将该次数添加到集合 $s\_set$ 中。 + 2. 如果当前出现次数在集合 $s\_set$ 中,则不断减少该次数,直到该次数不在集合 $s\_set$ 中停止,将次数添加到集合 $s\_set$ 中,同时将减少次数累加到答案 $ans$ 中。 4. 遍历完哈希表后返回答案 $ans$。 ### 思路 1:代码 diff --git a/docs/solutions/1600-1699/minimum-operations-to-reduce-x-to-zero.md b/docs/solutions/1600-1699/minimum-operations-to-reduce-x-to-zero.md index 852daf04..6215810e 100644 --- a/docs/solutions/1600-1699/minimum-operations-to-reduce-x-to-zero.md +++ b/docs/solutions/1600-1699/minimum-operations-to-reduce-x-to-zero.md @@ -43,14 +43,14 @@ 将 $x$ 减到 $0$ 的最小操作数可以转换为求和等于 $sum(nums) - x$ 的最长连续子数组长度。我们可以维护一个区间和为 $sum(nums) - x$ 的滑动窗口,求出最长的窗口长度。具体做法如下: -令 `target = sum(nums) - x`,使用 $max\underline{\hspace{0.5em}}len$ 维护和等于 $target$ 的最长连续子数组长度。然后用滑动窗口 $window\underline{\hspace{0.5em}}sum$ 来记录连续子数组的和,设定两个指针:$left$、$right$,分别指向滑动窗口的左右边界,保证窗口中的和刚好等于 $target$。 +令 `target = sum(nums) - x`,使用 $max\_len$ 维护和等于 $target$ 的最长连续子数组长度。然后用滑动窗口 $window\_sum$ 来记录连续子数组的和,设定两个指针:$left$、$right$,分别指向滑动窗口的左右边界,保证窗口中的和刚好等于 $target$。 - 一开始,$left$、$right$ 都指向 $0$。 -- 向右移动 $right$,将最右侧元素加入当前窗口和 $window\underline{\hspace{0.5em}}sum$ 中。 -- 如果 $window\underline{\hspace{0.5em}}sum > target$,则不断右移 $left$,缩小滑动窗口长度,并更新窗口和的最小值,直到 $window\underline{\hspace{0.5em}}sum \le target$。 -- 如果 $window\underline{\hspace{0.5em}}sum == target$,则更新最长连续子数组长度。 +- 向右移动 $right$,将最右侧元素加入当前窗口和 $window\_sum$ 中。 +- 如果 $window\_sum > target$,则不断右移 $left$,缩小滑动窗口长度,并更新窗口和的最小值,直到 $window\_sum \le target$。 +- 如果 $window\_sum == target$,则更新最长连续子数组长度。 - 然后继续右移 $right$,直到 $right \ge len(nums)$ 结束。 -- 输出 $len(nums) - max\underline{\hspace{0.5em}}len$ 作为答案。 +- 输出 $len(nums) - max\_len$ 作为答案。 - 注意判断题目中的特殊情况。 ### 思路 1:代码 diff --git a/docs/solutions/1600-1699/richest-customer-wealth.md b/docs/solutions/1600-1699/richest-customer-wealth.md index 8f27d192..66ea009f 100644 --- a/docs/solutions/1600-1699/richest-customer-wealth.md +++ b/docs/solutions/1600-1699/richest-customer-wealth.md @@ -51,10 +51,10 @@ ### 思路 1:直接模拟 -1. 使用变量 $max\underline{\hspace{0.5em}}ans$ 存储最富有客户所拥有的资产总量。 +1. 使用变量 $max\_ans$ 存储最富有客户所拥有的资产总量。 2. 遍历所有客户,对于当前客户 $accounts[i]$,统计其拥有的资产总量。 -3. 将当前客户的资产总量与 $max\underline{\hspace{0.5em}}ans$ 进行比较,如果大于 $max\underline{\hspace{0.5em}}ans$,则更新 $max\underline{\hspace{0.5em}}ans$ 的值。 -4. 遍历完所有客户,最终返回 $max\underline{\hspace{0.5em}}ans$ 作为结果。 +3. 将当前客户的资产总量与 $max\_ans$ 进行比较,如果大于 $max\_ans$,则更新 $max\_ans$ 的值。 +4. 遍历完所有客户,最终返回 $max\_ans$ 作为结果。 ### 思路 1:代码 diff --git a/docs/solutions/1800-1899/minimum-xor-sum-of-two-arrays.md b/docs/solutions/1800-1899/minimum-xor-sum-of-two-arrays.md index 54650974..735ad042 100644 --- a/docs/solutions/1800-1899/minimum-xor-sum-of-two-arrays.md +++ b/docs/solutions/1800-1899/minimum-xor-sum-of-two-arrays.md @@ -77,7 +77,7 @@ 举个例子 $nums2 = \lbrace 1, 2, 3, 4 \rbrace$,$state = (1001)_2$,表示选择了第 $1$ 个元素和第 $4$ 个元素,也就是 $1$、$4$。那么 $state$ 只能从 $(1000)_2$ 和 $(0001)_2$ 这两个状态转移而来,我们只需要枚举这两种状态,并求出转移过来的异或值之和最小值。 -即状态转移方程为:$dp[state] = min(dp[state], \quad dp[state \oplus (1 \text{ <}\text{< } i)] + (nums1[i] \oplus nums2[one\underline{\hspace{0.5em}}cnt - 1]))$,其中 $state$ 第 $i$ 位一定为 $1$,$one\underline{\hspace{0.5em}}cnt$ 为 $state$ 中 $1$ 的个数。 +即状态转移方程为:$dp[state] = min(dp[state], \quad dp[state \oplus (1 \text{ <}\text{< } i)] + (nums1[i] \oplus nums2[one\_cnt - 1]))$,其中 $state$ 第 $i$ 位一定为 $1$,$one\_cnt$ 为 $state$ 中 $1$ 的个数。 ###### 4. 初始条件 diff --git a/docs/solutions/1800-1899/sorting-the-sentence.md b/docs/solutions/1800-1899/sorting-the-sentence.md index cd09cfb6..f9d53a8c 100644 --- a/docs/solutions/1800-1899/sorting-the-sentence.md +++ b/docs/solutions/1800-1899/sorting-the-sentence.md @@ -46,8 +46,8 @@ ### 思路 1:模拟 -1. 将句子 $s$ 按照空格分隔成数组 $s\underline{\hspace{0.5em}}list$。 -2. 遍历数组 $s\underline{\hspace{0.5em}}list$ 中的单词: +1. 将句子 $s$ 按照空格分隔成数组 $s\_list$。 +2. 遍历数组 $s\_list$ 中的单词: 1. 从单词中分割出对应单词索引 $idx$ 和对应单词 $word$。 2. 将单词 $word$ 存入答案数组 $res$ 对应位置 $idx - 1$ 上,即:$res[int(idx) - 1] = word$。 3. 将答案数组用空格拼接成句子字符串,并返回。 diff --git a/docs/solutions/1900-1999/find-the-middle-index-in-array.md b/docs/solutions/1900-1999/find-the-middle-index-in-array.md index e409e92d..f704b504 100644 --- a/docs/solutions/1900-1999/find-the-middle-index-in-array.md +++ b/docs/solutions/1900-1999/find-the-middle-index-in-array.md @@ -45,10 +45,10 @@ ### 思路 1:前缀和 1. 先遍历一遍数组,求出数组中全部元素和为 $total$。 -2. 再遍历一遍数组,使用变量 $prefix\underline{\hspace{0.5em}}sum$ 为前 $i$ 个元素和。 -3. 当遍历到第 $i$ 个元素时,其数组左侧元素之和为 $prefix\underline{\hspace{0.5em}}sum$,右侧元素和为 $total - prefix\underline{\hspace{0.5em}}sum - nums[i]$。 - 1. 如果左右元素之和相等,即 $prefix\underline{\hspace{0.5em}}sum == total - prefix\underline{\hspace{0.5em}}sum - nums[i]$($2 \times prefix\underline{\hspace{0.5em}}sum + nums[i] == total$) 时,$i$ 为中间位置。此时返回 $i$。 - 2. 如果不满足,则继续累加当前元素到 $prefix\underline{\hspace{0.5em}}sum$ 中,继续向后遍历。 +2. 再遍历一遍数组,使用变量 $prefix\_sum$ 为前 $i$ 个元素和。 +3. 当遍历到第 $i$ 个元素时,其数组左侧元素之和为 $prefix\_sum$,右侧元素和为 $total - prefix\_sum - nums[i]$。 + 1. 如果左右元素之和相等,即 $prefix\_sum == total - prefix\_sum - nums[i]$($2 \times prefix\_sum + nums[i] == total$) 时,$i$ 为中间位置。此时返回 $i$。 + 2. 如果不满足,则继续累加当前元素到 $prefix\_sum$ 中,继续向后遍历。 4. 如果找不到符合要求的中间位置,则返回 $-1$。 ### 思路 1:代码 diff --git a/docs/solutions/1900-1999/maximum-compatibility-score-sum.md b/docs/solutions/1900-1999/maximum-compatibility-score-sum.md index 18955b88..955fb2d1 100644 --- a/docs/solutions/1900-1999/maximum-compatibility-score-sum.md +++ b/docs/solutions/1900-1999/maximum-compatibility-score-sum.md @@ -69,11 +69,11 @@ 对于当前状态 $state$,肯定是从比 $state$ 少选一个老师被分配的状态中递推而来。我们可以枚举少选一个元素的状态,找到可以得到的最大兼容性评分和,赋值给 $dp[state]$。 -即状态转移方程为:$dp[state] = max(dp[state], \quad dp[state \oplus (1 \text{ <}\text{< } i)] + score[i][one\underline{\hspace{0.5em}}cnt - 1])$,其中: +即状态转移方程为:$dp[state] = max(dp[state], \quad dp[state \oplus (1 \text{ <}\text{< } i)] + score[i][one\_cnt - 1])$,其中: 1. $state$ 第 $i$ 位一定为 $1$。 2. $state \oplus (1 \text{ <}\text{< } i)$ 为比 $state$ 少选一个元素的状态。 -3. $scores[i][one\underline{\hspace{0.5em}}cnt - 1]$ 为第 $i$ 名老师分配到第 $one\underline{\hspace{0.5em}}cnt - 1$ 名学生的兼容性评分。 +3. $scores[i][one\_cnt - 1]$ 为第 $i$ 名老师分配到第 $one\_cnt - 1$ 名学生的兼容性评分。 关于每位老师与每位同学之间的兼容性评分,我们可以事先通过一个 $m \times m \times n$ 的三重循环计算得出,并且存入到 $m \times m$ 大小的二维矩阵 $scores$ 中。 diff --git a/docs/solutions/1900-1999/the-number-of-good-subsets.md b/docs/solutions/1900-1999/the-number-of-good-subsets.md index fbca0c07..89b53c74 100644 --- a/docs/solutions/1900-1999/the-number-of-good-subsets.md +++ b/docs/solutions/1900-1999/the-number-of-good-subsets.md @@ -79,9 +79,9 @@ ###### 3. 状态转移方程 -对于 $nums$ 中的每个数 $num$,其对应出现次数为 $cnt$。我们可以通过试除法,将 $num$ 分解为不同的质因数,并使用「状态压缩」的方式,用一个二进制数 $cur\underline{\hspace{0.5em}}state$ 来表示当前数 $num$ 中使用了哪些质因数。然后枚举所有状态,找到与 $cur\underline{\hspace{0.5em}}state$ 不冲突的状态 $state$(也就是除了 $cur\underline{\hspace{0.5em}}state$ 中选择的质因数外,选择的其他质因数情况,比如 $cur\underline{\hspace{0.5em}}state$ 选择了 $2$ 和 $5$,则枚举不选择 $2$ 和 $5$ 的状态)。 +对于 $nums$ 中的每个数 $num$,其对应出现次数为 $cnt$。我们可以通过试除法,将 $num$ 分解为不同的质因数,并使用「状态压缩」的方式,用一个二进制数 $cur\_state$ 来表示当前数 $num$ 中使用了哪些质因数。然后枚举所有状态,找到与 $cur\_state$ 不冲突的状态 $state$(也就是除了 $cur\_state$ 中选择的质因数外,选择的其他质因数情况,比如 $cur\_state$ 选择了 $2$ 和 $5$,则枚举不选择 $2$ 和 $5$ 的状态)。 -此时,状态转移方程为:$dp[state | cur\underline{\hspace{0.5em}}state] = \sum (dp[state] \times cnt) \mod MOD , \quad state \text{ \& } cur\underline{\hspace{0.5em}}state == 0$ +此时,状态转移方程为:$dp[state | cur\_state] = \sum (dp[state] \times cnt) \mod MOD , \quad state \text{ \& } cur\_state == 0$ ###### 4. 初始条件 diff --git a/docs/solutions/2100-2199/maximum-and-sum-of-array.md b/docs/solutions/2100-2199/maximum-and-sum-of-array.md index 3d1bd402..e607999b 100644 --- a/docs/solutions/2100-2199/maximum-and-sum-of-array.md +++ b/docs/solutions/2100-2199/maximum-and-sum-of-array.md @@ -71,12 +71,12 @@ 对于当前状态 $dp[state]$,肯定是从比 $state$ 少选一个元素的状态中递推而来。我们可以枚举少选一个元素的状态,找到可以获得的最大与和,赋值给 $dp[state]$。 -即状态转移方程为:$dp[state] = min(dp[state], dp[state \oplus (1 \text{ <}\text{< } i)] + (i // 2 + 1) \text{ \& } nums[one\underline{\hspace{0.5em}}cnt - 1])$,其中: +即状态转移方程为:$dp[state] = min(dp[state], dp[state \oplus (1 \text{ <}\text{< } i)] + (i // 2 + 1) \text{ \& } nums[one\_cnt - 1])$,其中: 1. $state$ 第 $i$ 位一定为 $1$。 2. $state \oplus (1 \text{ <}\text{< } i)$ 为比 $state$ 少选一个元素的状态。 3. $i // 2 + 1$ 为篮子对应编号 -4. $nums[one\underline{\hspace{0.5em}}cnt - 1]$ 为当前正在考虑的数组元素。 +4. $nums[one\_cnt - 1]$ 为当前正在考虑的数组元素。 ###### 4. 初始条件 @@ -86,7 +86,7 @@ 根据我们之前定义的状态,$dp[state]$ 表示为:将前 $count(state)$ 个整数放到篮子里,并且每个篮子中的整数放取情况为 $state$ 时,可以获得的最大与和。所以最终结果为 $max(dp)$。 -> 注意:当 $one\underline{\hspace{0.5em}}cnt > len(nums)$ 时,无法通过递推得到 $dp[state]$,需要跳过。 +> 注意:当 $one\_cnt > len(nums)$ 时,无法通过递推得到 $dp[state]$,需要跳过。 ### 思路 1:代码 diff --git a/docs/solutions/2200-2299/longest-path-with-different-adjacent-characters.md b/docs/solutions/2200-2299/longest-path-with-different-adjacent-characters.md index 59192358..676718b5 100644 --- a/docs/solutions/2200-2299/longest-path-with-different-adjacent-characters.md +++ b/docs/solutions/2200-2299/longest-path-with-different-adjacent-characters.md @@ -60,11 +60,11 @@ 即:**最长路径长度 = max(某子树中的最长路径长度 + 另一子树中的最长路径长度 + 1,某个子树中的最长路径长度)**。 -对此,我们可以使用深度优先搜索递归遍历 $u$ 的所有相邻节点 $v$,并在递归遍历的同时,维护一个全局最大路径和变量 $ans$,以及当前节点 $u$ 的最大路径长度变量 $u\underline{\hspace{0.5em}}len$。 +对此,我们可以使用深度优先搜索递归遍历 $u$ 的所有相邻节点 $v$,并在递归遍历的同时,维护一个全局最大路径和变量 $ans$,以及当前节点 $u$ 的最大路径长度变量 $u\_len$。 -1. 先计算出从相邻节点 $v$ 出发的最长路径长度 $v\underline{\hspace{0.5em}}len$。 -2. 更新维护全局最长路径长度为 $self.ans = max(self.ans, \quad u\underline{\hspace{0.5em}}len + v\underline{\hspace{0.5em}}len + 1)$。 -3. 更新维护当前节点 $u$ 的最长路径长度为 $u\underline{\hspace{0.5em}}len = max(u\underline{\hspace{0.5em}}len, \quad v\underline{\hspace{0.5em}}len + 1)$。 +1. 先计算出从相邻节点 $v$ 出发的最长路径长度 $v\_len$。 +2. 更新维护全局最长路径长度为 $self.ans = max(self.ans, \quad u\_len + v\_len + 1)$。 +3. 更新维护当前节点 $u$ 的最长路径长度为 $u\_len = max(u\_len, \quad v\_len + 1)$。 因为题目限定了「相邻节点字符不同」,所以在更新全局最长路径长度和当前节点 $u$ 的最长路径长度时,我们需要判断一下节点 $u$ 与相邻节点 $v$ 的字符是否相同,只有在字符不同的条件下,才能够更新维护。 diff --git a/docs/solutions/2500-2599/difference-between-maximum-and-minimum-price-sum.md b/docs/solutions/2500-2599/difference-between-maximum-and-minimum-price-sum.md index 70cb637f..5009a990 100644 --- a/docs/solutions/2500-2599/difference-between-maximum-and-minimum-price-sum.md +++ b/docs/solutions/2500-2599/difference-between-maximum-and-minimum-price-sum.md @@ -66,22 +66,22 @@ 对此我们可以使用深度优先搜索递归遍历二叉树,并在递归遍历的同时,维护一个最大开销变量 $ans$。 -然后定义函数 ` def dfs(self, u, father):` 计算以节点 $u$ 为根节点的子树中,带端点的最大路径和 $max\underline{\hspace{0.5em}}s1$,以及去掉端点的最大路径和 $max\underline{\hspace{0.5em}}s2$,其中 $father$ 表示节点 $u$ 的根节点,用于遍历邻接节点的过程中过滤父节点,避免重复遍历。 +然后定义函数 ` def dfs(self, u, father):` 计算以节点 $u$ 为根节点的子树中,带端点的最大路径和 $max\_s1$,以及去掉端点的最大路径和 $max\_s2$,其中 $father$ 表示节点 $u$ 的根节点,用于遍历邻接节点的过程中过滤父节点,避免重复遍历。 -初始化带端点的最大路径和 $max\underline{\hspace{0.5em}}s1$ 为 $price[u]$,表示当前只有一个节点,初始化去掉端点的最大路径和 $max\underline{\hspace{0.5em}}s2$ 为 $0$,表示当前没有节点。 +初始化带端点的最大路径和 $max\_s1$ 为 $price[u]$,表示当前只有一个节点,初始化去掉端点的最大路径和 $max\_s2$ 为 $0$,表示当前没有节点。 然后在遍历节点 $u$ 的相邻节点 $v$ 时,递归调用 $dfs(v, u)$,获取以节点 $v$ 为根节点的子树中,带端点的最大路径和 $s1$,以及去掉端点的最大路径和 $s2$。此时最大开销变量 $self.ans$ 有两种情况: -1. $u$ 的子树中带端点的最大路径和,加上 $v$ 的子树中不带端点的最大路径和,即:$max\underline{\hspace{0.5em}}s1 + s2$。 -2. $u$ 的子树中去掉端点的最大路径和,加上 $v$ 的子树中带端点的最大路径和,即:$max\underline{\hspace{0.5em}}s2 + s1$。 +1. $u$ 的子树中带端点的最大路径和,加上 $v$ 的子树中不带端点的最大路径和,即:$max\_s1 + s2$。 +2. $u$ 的子树中去掉端点的最大路径和,加上 $v$ 的子树中带端点的最大路径和,即:$max\_s2 + s1$。 -此时我们更新最大开销变量 $self.ans$,即:$self.ans = max(self.ans, \quad max\underline{\hspace{0.5em}}s1 + s2, \quad max\underline{\hspace{0.5em}}s2 + s1)$。 +此时我们更新最大开销变量 $self.ans$,即:$self.ans = max(self.ans, \quad max\_s1 + s2, \quad max\_s2 + s1)$。 -然后更新 $u$ 的子树中带端点的最大路径和 $max\underline{\hspace{0.5em}}s1$,即:$max\underline{\hspace{0.5em}}s1= max(max\underline{\hspace{0.5em}}s1, \quad s1 + price[u])$。 +然后更新 $u$ 的子树中带端点的最大路径和 $max\_s1$,即:$max\_s1= max(max\_s1, \quad s1 + price[u])$。 -再更新 $u$ 的子树中去掉端点的最大路径和 $max\underline{\hspace{0.5em}}s2$,即:$max\underline{\hspace{0.5em}}s2 = max(max\underline{\hspace{0.5em}}s2, \quad s2 + price[u])$。 +再更新 $u$ 的子树中去掉端点的最大路径和 $max\_s2$,即:$max\_s2 = max(max\_s2, \quad s2 + price[u])$。 -最后返回带端点 $u$ 的最大路径和 $max\underline{\hspace{0.5em}}s1$,以及去掉端点 $u$ 的最大路径和 $。 +最后返回带端点 $u$ 的最大路径和 $max\_s1$,以及去掉端点 $u$ 的最大路径和 $。 最终,最大开销变量 $self.ans$ 即为答案。 diff --git a/docs/solutions/2700-2799/count-of-integers.md b/docs/solutions/2700-2799/count-of-integers.md index 30a70a8d..ed61e389 100644 --- a/docs/solutions/2700-2799/count-of-integers.md +++ b/docs/solutions/2700-2799/count-of-integers.md @@ -9,7 +9,7 @@ ## 题目大意 -**描述**:给定两个数字字符串 $num1$ 和 $num2$,以及两个整数 $max\underline{\hspace{0.5em}}sum$ 和 $min\underline{\hspace{0.5em}}sum$。 +**描述**:给定两个数字字符串 $num1$ 和 $num2$,以及两个整数 $max\_sum$ 和 $min\_sum$。 **要求**:返回好整数的数目。答案可能很大,请返回答案对 $10^9 + 7$ 取余后的结果。 @@ -17,11 +17,11 @@ - **好整数**:如果一个整数 $x$ 满足一下条件,我们称它是一个好整数: - $num1 \le x \le num2$。 - - $num\underline{\hspace{0.5em}}sum \le digit\underline{\hspace{0.5em}}sum(x) \le max\underline{\hspace{0.5em}}sum$。 + - $num\_sum \le digit\_sum(x) \le max\_sum$。 -- $digit\underline{\hspace{0.5em}}sum(x)$ 表示 $x$ 各位数字之和。 +- $digit\_sum(x)$ 表示 $x$ 各位数字之和。 - $1 \le num1 \le num2 \le 10^{22}$。 -- $1 \le min\underline{\hspace{0.5em}}sum \le max\underline{\hspace{0.5em}}sum \le 400$。 +- $1 \le min\_sum \le max\_sum \le 400$。 **示例**: @@ -52,9 +52,9 @@ 2. 初始数位和为 $0$。 3. 开始时当前数位最大值受到最高位数位的约束。 4. 开始时当前数位最小值受到最高位数位的约束。 -2. 如果 $total > max\underline{\hspace{0.5em}}sum$,说明当前方案不符合要求,则返回方案数 $0$。 +2. 如果 $total > max\_sum$,说明当前方案不符合要求,则返回方案数 $0$。 3. 如果遇到 $pos == len(s)$,表示到达数位末尾,此时: - 1. 如果 $min\underline{\hspace{0.5em}}sum \le total \le max\underline{\hspace{0.5em}}sum$,说明当前方案符合要求,则返回方案数 $1$。 + 1. 如果 $min\_sum \le total \le max\_sum$,说明当前方案符合要求,则返回方案数 $1$。 2. 如果不满足,则当前方案不符合要求,则返回方案数 $0$。 4. 如果 $pos \ne len(s)$,则定义方案数 $ans$,令其等于 $0$,即:`ans = 0`。 5. 根据 $isMaxLimit$ 和 $isMinLimit$ 来决定填当前位数位所能选择的最小数字($minX$)和所能选择的最大数字($maxX$)。 @@ -106,5 +106,5 @@ class Solution: ### 思路 1:复杂度分析 - **时间复杂度**:$O(n \times 10)$,其中 $n$ 为数组 $nums2$ 的长度。 -- **空间复杂度**:$O(n \times max\underline{\hspace{0.5em}}sum)$。 +- **空间复杂度**:$O(n \times max\_sum)$。 diff --git a/docs/solutions/LCR/shu-zu-zhong-de-ni-xu-dui-lcof.md b/docs/solutions/LCR/shu-zu-zhong-de-ni-xu-dui-lcof.md index 2563d853..cfc975f6 100644 --- a/docs/solutions/LCR/shu-zu-zhong-de-ni-xu-dui-lcof.md +++ b/docs/solutions/LCR/shu-zu-zhong-de-ni-xu-dui-lcof.md @@ -43,15 +43,15 @@ 1. 使用全局变量 $cnt$ 来存储逆序对的个数。然后进行归并排序。 2. **分割过程**:先递归地将当前序列平均分成两半,直到子序列长度为 $1$。 - 1. 找到序列中心位置 $mid$,从中心位置将序列分成左右两个子序列 $left\underline{\hspace{0.5em}}arr$、$right\underline{\hspace{0.5em}}arr$。 - 2. 对左右两个子序列 $left\underline{\hspace{0.5em}}arr$、$right\underline{\hspace{0.5em}}arr$ 分别进行递归分割。 + 1. 找到序列中心位置 $mid$,从中心位置将序列分成左右两个子序列 $left\_arr$、$right\_arr$。 + 2. 对左右两个子序列 $left\_arr$、$right\_arr$ 分别进行递归分割。 3. 最终将数组分割为 $n$ 个长度均为 $1$ 的有序子序列。 3. **归并过程**:从长度为 $1$ 的有序子序列开始,依次进行两两归并,直到合并成一个长度为 $n$ 的有序序列。 1. 使用数组变量 $arr$ 存放归并后的有序数组。 - 2. 使用两个指针 $left\underline{\hspace{0.5em}}i$、$right\underline{\hspace{0.5em}}i$ 分别指向两个有序子序列 $left\underline{\hspace{0.5em}}arr$、$right\underline{\hspace{0.5em}}arr$ 的开始位置。 + 2. 使用两个指针 $left\_i$、$right\_i$ 分别指向两个有序子序列 $left\_arr$、$right\_arr$ 的开始位置。 3. 比较两个指针指向的元素: - 1. 如果 $left\underline{\hspace{0.5em}}arr[left\underline{\hspace{0.5em}}i] \le right\underline{\hspace{0.5em}}arr[right\underline{\hspace{0.5em}}i]$,则将 $left\underline{\hspace{0.5em}}arr[left\underline{\hspace{0.5em}}i]$ 存入到结果数组 $arr$ 中,并将指针移动到下一位置。 - 2. 如果 $left\underline{\hspace{0.5em}}arr[left\underline{\hspace{0.5em}}i] > right\underline{\hspace{0.5em}}arr[right\underline{\hspace{0.5em}}i]$,则 **记录当前左子序列中元素与当前右子序列元素所形成的逆序对的个数,并累加到 $cnt$ 中,即 `self.cnt += len(left_arr) - left_i`**,然后将 $right\underline{\hspace{0.5em}}arr[right\underline{\hspace{0.5em}}i]$ 存入到结果数组 $arr$ 中,并将指针移动到下一位置。 + 1. 如果 $left\_arr[left\_i] \le right\_arr[right\_i]$,则将 $left\_arr[left\_i]$ 存入到结果数组 $arr$ 中,并将指针移动到下一位置。 + 2. 如果 $left\_arr[left\_i] > right\_arr[right\_i]$,则 **记录当前左子序列中元素与当前右子序列元素所形成的逆序对的个数,并累加到 $cnt$ 中,即 `self.cnt += len(left_arr) - left_i`**,然后将 $right\_arr[right\_i]$ 存入到结果数组 $arr$ 中,并将指针移动到下一位置。 4. 重复步骤 $3$,直到某一指针到达子序列末尾。 5. 将另一个子序列中的剩余元素存入到结果数组 $arr$ 中。 6. 返回归并后的有序数组 $arr$。 From 4f16f4f002aa7c5d40cb95d0017f2787adc62c7c Mon Sep 17 00:00:00 2001 From: ITCharge Date: Wed, 3 Sep 2025 10:39:21 +0800 Subject: [PATCH 13/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=92=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=AF=AD=E5=8F=A5=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/05_tree/05_02_binary_tree_traverse.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/docs/05_tree/05_02_binary_tree_traverse.md b/docs/05_tree/05_02_binary_tree_traverse.md index fff972a8..2af9f28b 100644 --- a/docs/05_tree/05_02_binary_tree_traverse.md +++ b/docs/05_tree/05_02_binary_tree_traverse.md @@ -429,20 +429,6 @@ class Solution: - 不适用于递归实现 - 空间复杂度可能较高(对于宽树) -### 6.3 适用场景 - -- **前序遍历**:树的复制、序列化、前缀表达式计算 -- **中序遍历**:二叉搜索树的有序遍历、中缀表达式计算 -- **后序遍历**:树的删除、后缀表达式计算、计算树的高度 -- **层序遍历**:按层打印树、计算树的宽度、BFS相关算法 - -### 6.4 实现建议 - -1. **递归实现**:代码简洁,易于理解,适合面试和教学 -2. **非递归实现**:性能更好,避免栈溢出,适合生产环境 -3. **选择原则**:根据具体需求选择合适的遍历方式,考虑时间复杂度和空间复杂度 -4. **优化技巧**:使用双端队列提高层序遍历效率,合理使用栈和队列数据结构 - ## 练习题目 - [0144. 二叉树的前序遍历](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0100-0199/binary-tree-preorder-traversal.md) From ffc2f5c59f8c5cbb8733d8524d1c370b01753e6e Mon Sep 17 00:00:00 2001 From: ITCharge Date: Sat, 6 Sep 2025 06:11:11 +0800 Subject: [PATCH 14/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BF=9E=E6=8E=A5?= =?UTF-8?q?=E9=94=99=E8=AF=AF=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/08_dynamic_programming/index.md | 30 ++++++++++++++-------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/docs/08_dynamic_programming/index.md b/docs/08_dynamic_programming/index.md index a029dc20..110aee16 100644 --- a/docs/08_dynamic_programming/index.md +++ b/docs/08_dynamic_programming/index.md @@ -1,17 +1,17 @@ ## 本章内容 -- [8.1 动态规划基础](https://github.com/ITCharge/AlgoNote/tree/main/Contents/10.Dynamic-Programming/01.Dynamic-Programming-Basic/01.Dynamic-Programming-Basic.md) -- [8.2 记忆化搜索](https://github.com/ITCharge/AlgoNote/tree/main/Contents/10.Dynamic-Programming/02.Memoization/01.Memoization.md) -- [8.3 线性 DP(一)](https://github.com/ITCharge/AlgoNote/tree/main/Contents/10.Dynamic-Programming/03.Linear-DP/01.Linear-DP-01.md) -- [8.4 线性 DP(二)](https://github.com/ITCharge/AlgoNote/tree/main/Contents/10.Dynamic-Programming/03.Linear-DP/02.Linear-DP-02.md) -- [8.5 背包问题知识(一)](https://github.com/ITCharge/AlgoNote/tree/main/Contents/10.Dynamic-Programming/04.Knapsack-Problem/01.Knapsack-Problem-01.md) -- [8.6 背包问题知识(二)](https://github.com/ITCharge/AlgoNote/tree/main/Contents/10.Dynamic-Programming/04.Knapsack-Problem/02.Knapsack-Problem-02.md) -- [8.7 背包问题知识(三)](https://github.com/ITCharge/AlgoNote/tree/main/Contents/10.Dynamic-Programming/04.Knapsack-Problem/03.Knapsack-Problem-03.md) -- [8.8 背包问题知识(四)](https://github.com/ITCharge/AlgoNote/tree/main/Contents/10.Dynamic-Programming/04.Knapsack-Problem/04.Knapsack-Problem-04.md) -- [8.9 背包问题知识(五)](https://github.com/ITCharge/AlgoNote/tree/main/Contents/10.Dynamic-Programming/04.Knapsack-Problem/05.Knapsack-Problem-05.md) -- [8.10 区间 DP](https://github.com/ITCharge/AlgoNote/tree/main/Contents/10.Dynamic-Programming/05.Interval-DP/01.Interval-DP.md) -- [8.11 树形 DP](https://github.com/ITCharge/AlgoNote/tree/main/Contents/10.Dynamic-Programming/06.Tree-DP/01.Tree-DP.md) -- [8.12 状态压缩 DP](https://github.com/ITCharge/AlgoNote/tree/main/Contents/10.Dynamic-Programming/07.State-DP/01.State-DP.md) -- [8.13 计数 DP](https://github.com/ITCharge/AlgoNote/tree/main/Contents/10.Dynamic-Programming/08.Counting-DP/01.Counting-DP.md) -- [8.14 数位 DP](https://github.com/ITCharge/AlgoNote/tree/main/Contents/10.Dynamic-Programming/09.Digit-DP/01.Digit-DP.md) -- [8.15 概率 DP](https://github.com/ITCharge/AlgoNote/tree/main/Contents/10.Dynamic-Programming/10.Probability-DP/01.Probability-DP.md) +- [8.1 动态规划基础](https://github.com/itcharge/AlgoNote/tree/main/docs/08_dynamic_programming/08_01_dynamic_programming_basic.md) +- [8.2 记忆化搜索](https://github.com/itcharge/AlgoNote/tree/main/docs/08_dynamic_programming/08_02_memoization_search.md) +- [8.3 线性 DP(一)](https://github.com/itcharge/AlgoNote/tree/main/docs/08_dynamic_programming/08_03_linear_dp_01.md) +- [8.4 线性 DP(二)](https://github.com/itcharge/AlgoNote/tree/main/docs/08_dynamic_programming/08_04_linear_dp_02.md) +- [8.5 背包问题知识(一)](https://github.com/itcharge/AlgoNote/tree/main/docs/08_dynamic_programming/08_05_knapsack_problem_01.md) +- [8.6 背包问题知识(二)](https://github.com/itcharge/AlgoNote/tree/main/docs/08_dynamic_programming/08_06_knapsack_problem_02.md) +- [8.7 背包问题知识(三)](https://github.com/itcharge/AlgoNote/tree/main/docs/08_dynamic_programming/08_07_knapsack_problem_03.md) +- [8.8 背包问题知识(四)](https://github.com/itcharge/AlgoNote/tree/main/docs/08_dynamic_programming/08_08_knapsack_problem_04.md) +- [8.9 背包问题知识(五)](https://github.com/itcharge/AlgoNote/tree/main/docs/08_dynamic_programming/08_09_knapsack_problem_05.md) +- [8.10 区间 DP](https://github.com/itcharge/AlgoNote/tree/main/docs/08_dynamic_programming/08_10_interval_dp.md) +- [8.11 树形 DP](https://github.com/itcharge/AlgoNote/tree/main/docs/08_dynamic_programming/08_11_tree_dp.md) +- [8.12 状态压缩 DP](https://github.com/itcharge/AlgoNote/tree/main/docs/08_dynamic_programming/08_12_state_compression_dp.md) +- [8.13 计数 DP](https://github.com/itcharge/AlgoNote/tree/main/docs/08_dynamic_programming/08_13_counting_dp.md) +- [8.14 数位 DP](https://github.com/itcharge/AlgoNote/tree/main/docs/08_dynamic_programming/08_14_digit_dp.md) +- [8.15 概率 DP](https://github.com/itcharge/AlgoNote/tree/main/docs/08_dynamic_programming/08_15_probability_dp.md) From d5e9252bc9fc5fad678337ae6ad4d456ee66595b Mon Sep 17 00:00:00 2001 From: ITCharge Date: Mon, 8 Sep 2025 00:54:49 +0800 Subject: [PATCH 15/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=92=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=AF=AD=E5=8F=A5=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/06_graph/06_01_graph_basic.md | 154 +++++++++++++++-------------- 1 file changed, 81 insertions(+), 73 deletions(-) diff --git a/docs/06_graph/06_01_graph_basic.md b/docs/06_graph/06_01_graph_basic.md index 04c7c98e..ead8e32e 100644 --- a/docs/06_graph/06_01_graph_basic.md +++ b/docs/06_graph/06_01_graph_basic.md @@ -1,146 +1,154 @@ ## 1. 图的定义 -> **图(Graph)**:由顶点集合 $V$ 与边集合 $E$(顶点之间的关系)构成的数据结构。图的形式化定义为 $G = (V, E)$。 +> **图(Graph)**:由顶点集合 $V$ 和边集合 $E$(即顶点之间的连接关系)组成的数据结构,通常记作 $G = (V, E)$。 -- **顶点(Vertex)**:图中的基本元素,通常称为顶点,表示对象或节点。顶点的集合 $V$ 是有限非空集合,包含 $n > 0$ 个顶点。如下面的示意图所示,通常我们使用圆圈来表示顶点。 -- **边(Edge)**:顶点之间的关系或连接。边的形式化定义为:$e = \langle u, v \rangle$,表示从 $u$ 到 $v$ 的一条边,其中 $u$ 称为起始点,$v$ 称为终止点。如下面的示意图所示,通常我们使用连接两个顶点的线段来表示边。 +- **顶点(Vertex)**:图的基本单元,表示对象或节点。顶点集合 $V$ 是有限且非空的,包含 $n > 0$ 个顶点。通常用圆圈表示顶点。 +- **边(Edge)**:连接两个顶点的线段,表示它们之间的关系。边可记为 $e = \langle u, v \rangle$,表示从 $u$ 到 $v$ 的一条边,其中 $u$ 是起点,$v$ 是终点。 ![](https://qcdn.itcharge.cn/images/20220307145142.png) -- **子图(Sub Graph)**:对于图 $G = (V, E)$ 与 $G^{'} = (V^{'}, E^{'})$,如果满足 $V^{'} \subseteq V$,$E^{'} \subseteq E$,则称图 $G^{'}$ 是图 $G$ 的一个子图。直观的说,子图是由原图的一部分顶点和边组成的,同时边的两端顶点必须属于子图的顶点集合 $V^{'}$。特别地,根据定义,图 $G$ 本身也是其一个子图。在下图中,我们展示了一个图 $G$ 及其子图 $G^{'}$。 +- **子图(Sub Graph)**:如果图 $G' = (V', E')$ 满足 $V' \subseteq V$ 且 $E' \subseteq E$,则 $G'$ 是 $G$ 的子图。也就是说,子图由原图部分顶点和边组成,且边的两个端点都属于 $V'$。特别地,$G$ 本身也是它的一个子图。下图展示了图 $G$ 及其子图 $G'$。 ![](https://qcdn.itcharge.cn/images/20220317163120.png) ## 2. 图的分类 -### 2.1 无向图和有向图 +### 2.1 无向图与有向图 -根据边是否具有方向性,图可以分为两种类型:「无向图」和「有向图」。 +按边是否有方向,可以将图分为「无向图」和「有向图」。 -> **无向图(Undirected Graph)**:如果图中每条边都没有方向性,则称为无向图。例如,表示朋友关系或者城市间双向行驶的路线图常用无向图建模。 +- **无向图(Undirected Graph)**:边没有方向,常用于表示朋友关系、城市间双向道路等。无向图的边由两个顶点组成的无序对表示,如下图左侧中的顶点 $(v_1, v_2)$。 -在无向图中,每条边都是由两个顶点组成的无序对。例如下图左侧中的顶点 $v_1$ 和顶点 $v_2$ 之间的边记为 $(v_1, v_2)$ 或 $(v_2, v_1)$。 - -> **有向图(Directed Graph)**:如果图中的每条边都具有方向性,则称为有向图。例如,表示任务流程的流程图或网络请求的依赖图是典型的有向图。 - -在有向图中,有向边(又称弧)是由两个顶点组成的有序对,例如下图右侧中从顶点 $v_1$ 到顶点 $v_2$ 的弧,记为 $\langle v_1, v_2 \rangle$,$v_1$ 被称为弧尾,$v_2$ 被称为弧头,如下图所示。 +- **有向图(Directed Graph)**:边有方向,常用于表示任务流程、依赖关系等。有向图的边(弧)由两个顶点组成的有序对表示,如下图右侧中的顶点 $\langle v_1, v_2 \rangle$,其中 $v_1$ 为弧尾,$v_2$ 为弧头。 ![](https://qcdn.itcharge.cn/images/20220307160017.png) -如果图中有 $n$ 个顶点,则根据图的类型,其边(或弧)的最大数量可以定义如下: +如果图有 $n$ 个顶点,则: -- **无向图中边的最大数量**:在无向图中,任意两个顶点之间最多存在一条边,因此最多可以有 $\frac{n \times (n - 1)}{2}$ 条边。具有 $\frac{n \times (n - 1)}{2}$ 条边的无向图称为 **「完全无向图(Completed Undirected Graph)」**。 +- **无向图最大边数**:$\frac{n \times (n - 1)}{2}$。达到最大边数的无向图称为 **完全无向图**。 +- **有向图最大边数**:$n \times (n - 1)$。达到最大边数的有向图称为 **完全有向图**。 -- **有向图中边的最大数量**:在有向图中,任意两个顶点之间可以存在一对方向相反的弧,因此最多可以有 $n \times (n - 1)$ 条弧。具有 $n \times (n - 1)$ 条弧的有向图称为 **「完全有向图(Completed Directed Graph)」**。 - -下图展示了两个示例:左侧为包含 $4$ 个顶点的完全无向图,右侧为包含 $4$ 个顶点的完全有向图。 +下图左为 $4$ 个顶点的完全无向图,右为 $4$ 个顶点的完全有向图: ![](https://qcdn.itcharge.cn/images/20220308151436.png) -下面介绍一下无向图和有向图中一个重要概念 **「顶点的度」**。 - -> **顶点的度**:与该顶点 $v_i$ 相关联的边的数量,记为 $TD(v_i)$。 +**顶点的度** 是图的一个重要概念: -- **无向图中顶点的度**:在无向图中,顶点的都是与该顶点相连的边的数量。例如,在上图左侧的完全无向图中,顶点 $v_3$ 的度为 $3$,因为有 $3$ 个其他的顶点与 $v_3$ 相连接。 - -- **有向图中顶点的度**:在有向图中,顶点的度可以分为「出度」和「入度」两个部分。 - - **出度(Out Degree)**:以该顶点 $v_i$ 为出发点的边的条数,记为 $OD(v_i)$。 - - **入度(In Degree)**:以该顶点 $v_i$ 为终止点的边的条数,记为 $ID(v_i)$。 - -在有向图中,顶点 $v_i$ 的度是该点出度和入度之和,即:$TD(v_i) = OD(v_i) + ID(v_i)$。 - -例如,在上图右侧的完全有向图中,顶点 $v_3$ 的出度为 $3$,入度为 $3$,因此顶点 $v_3$ 的度为 $3 + 3 = 6$。 +- **无向图中,顶点的度**:与该顶点相连的边的数量,记为 $TD(v_i)$。 + - 如上图左,$v_3$ 的度为 $3$。 +- **有向图中,顶点的度** 分为: + - **出度(Out Degree)**:以该顶点为起点的边数,记为 $OD(v_i)$。 + - **入度(In Degree)**:以该顶点为终点的边数,记为 $ID(v_i)$。 + - 顶点总度:$TD(v_i) = OD(v_i) + ID(v_i)$。 + - 如上图右,$v_3$ 的出度为 $3$,入度为 $3$,总度为 $6$。 ### 2.2 环形图和无环图 -> **路径** :图中的一个重要概念,对于图 $G = (V, E)$,如果存在顶点序列 $v_{i_0}, v_{i_1}, v_{i_2}, …, v_{i_m}$,并且每对相邻的顶点都有图中的边连接,即 $(v_{i_0}, v_{i_1}), (v_{i_1}, v_{i_2}), …, (v_{i_{m-1}}, v_{i_m}) \in E$(对于有向图则是 $\langle v_{i_0}, v_{i_1} \rangle, \langle v_{i_1}, v_{i_2} \rangle, …, \langle v_{i_{m-1}}, v_{i_m} \rangle \in E$),则称该顶点序列为从顶点 $v_{i_0}$ 和顶点 $v_{i_m}$ 之间的一条路径,其中 $v_{i_0}$ 是这条路径的起始点,$v_{i_m}$ 是这条路径的终止点。 - -简而言之,如果顶点 $v_{i_0}$ 可以通过一系列的顶点和边到达顶点 $v_{i_m}$,则称这两个顶点之间有一条路径,其中经过的顶点序列则称为两个顶点之间的路径。 +> **路径**:路径是图论中的核心概念。对于图 $G = (V, E)$,如果存在顶点序列 $v_{i_0}, v_{i_1}, v_{i_2}, \ldots, v_{i_m}$,使得任意相邻顶点对之间都存在边相连(无向图为 $(v_{i_{k-1}}, v_{i_k}) \in E$,有向图为 $\langle v_{i_{k-1}}, v_{i_k} \rangle \in E$,$1 \leq k \leq m$),则称该顶点序列为从 $v_{i_0}$ 到 $v_{i_m}$ 的一条路径。其中,$v_{i_0}$ 为起点,$v_{i_m}$ 为终点。 -- **环(Circle)**:如果一条路径的起始点和终止点相同(即 $v_{i_0} == v_{i_m}$ ),则称这条路径为「回路」或「环」。 +简而言之,如果从顶点 $v_{i_0}$ 出发,经过若干顶点和边能够到达顶点 $v_{i_m}$,则称 $v_{i_0}$ 与 $v_{i_m}$ 之间存在一条路径,所经过的顶点序列即为这条路径。 -- **简单路径**:顶点序列中顶点不重复出现的路径称为「简单路径」。 +- **环(Cycle)**:如果一条路径的起点和终点重合(即 $v_{i_0} = v_{i_m}$),则称该路径为「环」或「回路」。 +- **简单路径**:路径上所有顶点均不重复出现(除非是环的首尾重合),称为「简单路径」。 -根据图中是否有环,我们可以将图分为「环形图」和「无环图」。 +根据图中是否存在环,可以将图分为「环形图」和「无环图」: -- **环形图(Circular Graph)**:如果图中存在至少一条环路,则该图称为「环形图」。 -- **无环图(Acyclic Graph)**:如果图中不存在环路,则该图称为「无环图」。 +- **环形图(Circular Graph)**:如果图中至少存在一条环,则称为环形图。 +- **无环图(Acyclic Graph)**:如果图中不存在任何环,则称为无环图。 -在有向图中,如果不存在环路,则将该图称为「有向无环图(Directed Acyclic Graph, DAG)」。有向无环图因其独特的拓扑结构,广泛应用于诸如动态规划、最短路径问题、数据压缩等算法场景。 +对于有向图,如果不存在环,则称为「有向无环图(Directed Acyclic Graph, DAG)」。DAG 结构在动态规划、最短路径、数据压缩等算法中有着广泛应用。 -下图展示了四种图的类型:无向无环图、无向环形图、有向无环图和有向环形图。在有向环形图中,顶点 $v_1$、$v_2$、$v_3$ 与相连的边构成了一个环。 +下图展示了四类典型图结构:无向无环图、无向环形图、有向无环图和有向环形图。其中有向环形图中,顶点 $v_1$、$v_2$、$v_3$ 及其相连的边构成了一个环。 ![环形图和无环图](https://qcdn.itcharge.cn/images/20220317115641.png) -### 2.3 连通图和非连通图 +### 2.3 连通图与非连通图 #### 2.3.1 连通无向图 -在无向图中,如果存在一条从顶点 $v_i$ 到顶点 $v_j$ 的路径,则称顶点 $v_i$ 和 $v_j$ 是连通的。 +在无向图中,如果从顶点 $v_i$ 能通过一条路径到达顶点 $v_j$,则称 $v_i$ 和 $v_j$ 连通。 -- **连通无向图**:如果无向图中任意两个顶点之间都是连通的(即任意两个顶点之间都有路径连接),则称该图为「连通无向图」。 -- **非连通无向图**:如果无向图中存在至少一对顶点之间没有任何路径连接,则称该图为「非连通无向图」。 +- **连通无向图**:任意两个顶点之间都有路径相连的无向图。 +- **非连通无向图**:存在至少一对顶点之间没有路径相连的无向图。 -下图展示了两种情况: - -- 在左侧图中,顶点 $v_1$ 与所有其他顶点 $v_2$、$v_3$、$v_4$、$v_5$、$v_6$ 都是连通的,因此该图为连通无向图。 -- 在右侧图中,顶点 $v_1$ 与 $v_2$、$v_3$、$v_4$ 是连通的,但与 $v_5$、$v_6$ 没有任何路径连接,因此该图为非连通无向图。 +下图左侧为连通无向图,$v_1$ 能与所有其他顶点连通;右侧为非连通无向图,$v_1$ 只能与 $v_2$、$v_3$、$v_4$ 连通,无法到达 $v_5$、$v_6$。 ![](https://qcdn.itcharge.cn/images/20220317163249.png) #### 2.3.2 无向图的连通分量 -在无向图中,某些图可能不是连通的,但它们的子图可能是连通的。这样的子图称为「连通子图」。对于其中某个连通子图,如果不存在任何包含他的更大连通子图,则该连通子图称为「连通分量」。 +在无向图中,整体可能不是连通的,但其中的某些子图是连通的,这些子图称为「连通子图」。如果一个连通子图无法再被包含于更大的连通子图中,则称其为「连通分量」,即无向图中的极大连通子图。 -- **连通子图**:如果无向图的子图是连通的,则该子图称为连通子图。 -- **连通分量**:无向图中的一个极大连通子图(不存在任何包含它的更大的连通子图)称为该图的连通分量。 -- **极⼤连通⼦图**:无向图中的一个连通子图,且不存在包含它的更大的连通子图。 +- **连通子图**:无向图的一个子图,且该子图是连通的。 +- **极大连通子图**:连通子图中,如果不存在包含它的更大的连通子图,则称为极大连通子图。 +- **连通分量**:无向图中的极大连通子图,即为该图的连通分量。 -例如,上图右侧的非连通无向图中,尽管整体图是非连通的,但顶点 $v_1$、$v_2$、$v_3$、$v_4$ 与其相连的边构成的子图是连通的,并且不存在任何包含它的更大的连通子图,因此该子图是原图的一个连通分量。类似地,顶点 $v_5$、$v_6$ 与其相连的边也构成了原图的另一个连通分量。 +举例来说,上图右侧的非连通无向图中,顶点 $v_1$、$v_2$、$v_3$、$v_4$ 及其相连的边构成了一个连通子图,且无法再扩展为更大的连通子图,因此它是原图的一个连通分量。同理,顶点 $v_5$、$v_6$ 及其相连的边也构成了另一个连通分量。 #### 2.3.3 强连通有向图 -在有向图中,如果从顶点 $v_i$ 到 $v_j$ 存在路径,且从顶点 $v_j$ 到 $v_i$ 也有路径,则称顶点 $v_i$ 与 $v_j$ 是「强连通」的。 - -- **强连通有向图**:如果图中任意两个顶点 $v_i$ 和 $v_j$ 都满足从 $v_i$ 到 $v_j$ 和从 $v_j$ 到 $v_i$ 均有路径,则称该图为「强连通有向图」。 -- **非强连通有向图**:如果图中存在至少一对顶点之间没有路径连接(即无法相互到达),则称该图为「非强连通有向图」。 +在有向图中,如果顶点 $v_i$ 能到达 $v_j$,且 $v_j$ 也能到达 $v_i$,则称 $v_i$ 和 $v_j$ 是「强连通」的。 -下图展示了两种情况: +- **强连通有向图**:任意两个顶点都能互相到达的有向图。 +- **非强连通有向图**:存在至少一对顶点不能互相到达的有向图。 -- 左侧图中,任意两个顶点之间都存在路径,因此该图为强连通有向图。 -- 右侧图中,顶点 $v_7$ 无法通过路径到达其他顶点,因此该图为非强连通有向图。 +下图左为强连通有向图,任意两点可互达;右图中 $v_7$ 无法到达其他顶点,因此不是强连通有向图。 ![](https://qcdn.itcharge.cn/images/20220317133500.png) #### 2.3.4 有向图的强连通分量 -在有向图中,「强联通分量」是指其内部任意两个顶点之间都强连通的极大强连通子图。以下是具体定义: +在有向图中,「强连通分量」指的是:图中某个极大子图,子图内任意两个顶点都可以互相到达(即强连通),并且无法再加入其他顶点使其仍然强连通。 -- **强连通子图**:有向图的一个子图,且该子图中任意两个顶点都是强连通的。 -- **极⼤强连通⼦图**:如果一个强联通子图不能被包含在任何更大的强连通子图中,则称其为极大强连通子图。 -- **强连通分量**:有向图中的一个极⼤强连通⼦图,称为该图的强连通分量。 +简要定义如下: -举个例子来解释一下。 +- **强连通子图**:有向图的一个子图,子图内任意两点互相可达。 +- **极大强连通子图**:不能再加入其他顶点的强连通子图。 +- **强连通分量**:有向图中的极大强连通子图。 -例如,上图右侧的非强连通有向图,其本身不是强连通的(因为顶点 $v_7$ 无法通过路径到达其他顶点)。但顶点 $v_1$、$v_2$、$v_3$、$v_4$、$v_5$、$v_6$ 与它们之间的边构成了一个强连通子图(即上图的左侧图),且不存在包含它的更大的强连通子图,因此这是右侧图的一个强连通分量。类似地,顶点 $v_7$ 构成了一个只有一个顶点的强连通子图,因此它自身也是右侧图的一个强连通分量。 +举例说明:上图右侧的有向图不是强连通图(如 $v_7$ 无法到达其他顶点),但 $v_1$、$v_2$、 +$v_3$、$v_4$、$v_5$、$v_6$ 及其边构成了一个强连通分量(即左侧图)。而 $v_7$ 自身也单独构成一个强连通分量。 ### 2.4 带权图 -有时,图不仅需要表示顶点之间是否存在某种关系,还需要表示这一关系的具体细节。有时候我们需要给边赋予一些数据信息,这些数据信息被称为 **权**。在具体应用中,权值可以具有某种具体意义,比如权值可以代表距离、时间以及价格等不同属性。 +有些图不仅表示顶点之间是否有关系,还需要描述这种关系的「强度」或「代价」,这就是「权」。权值可以表示距离、时间、费用等。 -- **带权图**:如果图的每条边都被赋以⼀个权值,则该图称为带权图。权值通常表示一个非负实数,但在某些场景下也可以是负数。 -- **网络**:带权的连通⽆向图被称为⽹络。 +- **带权图**:每条边都带有权值的图。权值一般为非负数,有时也可以为负数。 +- **网络**:带权且连通的无向图称为网络。 -在下面的示意图中,我们给出了一个带权图的例子。 +下图展示了一个带权图的例子: ![](https://qcdn.itcharge.cn/images/20220317135207.png) -### 2.5 稠密图和稀疏图 +### 2.5 稠密图与稀疏图 + +图按边的多少可分为「稠密图」和「稀疏图」,这一划分没有严格的界限,仅为便于理解。 + +- **稠密图(Dense Graph)**:边数接近完全图的图,即大多数顶点之间都有边相连。 +- **稀疏图(Sparse Graph)**:边数远少于完全图的图(常见如 $e < n \times \log_2 n$),大部分顶点之间没有直接连接。 + +## 3. 总结 + +图是计算机科学中最重要的数据结构之一,由顶点和边组成,用于表示对象间的关系。本文介绍了图的基本概念和分类: + +### 核心概念 +- **顶点(Vertex)**:图的基本单元,表示对象或节点 +- **边(Edge)**:连接顶点的线段,表示对象间的关系 +- **路径**:顶点序列,表示从一个顶点到另一个顶点的遍历过程 + +### 主要分类 +1. **按方向性**:无向图(双向关系)vs 有向图(单向关系) +2. **按连通性**:连通图(任意两点可达)vs 非连通图(存在孤立部分) +3. **按环结构**:环形图(存在回路)vs 无环图(无回路,如DAG) +4. **按权值**:带权图(边有权重)vs 无权图(边无权重) +5. **按密度**:稠密图(边多)vs 稀疏图(边少) -根据图中边的稀疏程度,我们可以将图分为「稠密图」和「稀疏图」。这是一个模糊的概念,目前为止还没有给出一个量化的定义。 +### 重要特性 +- **度**:无向图中顶点的连接数,有向图中分为入度和出度 +- **连通分量**:无向图中的极大连通子图 +- **强连通分量**:有向图中任意两点互相可达的极大子图 -- **稠密图(Dense Graph)**:有很多条边或弧(边的条数 $e$ 接近于完全图的边数)的图称为稠密图。 -- **稀疏图(Sparse Graph)**:有很少条边或弧(边的条数 $e$ 远小于完全图的边数,如 $e < n \times \log_2n$)的图称为稀疏图。 +理解这些基础概念是学习图算法(如 DFS、BFS、最短路径、最小生成树等)的重要前提。 ## 参考资料 From 8630e966b93cf5a03835b0cc57200e03483b9e6f Mon Sep 17 00:00:00 2001 From: ITCharge Date: Mon, 8 Sep 2025 01:20:22 +0800 Subject: [PATCH 16/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=92=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=AF=AD=E5=8F=A5=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../07_01_enumeration_algorithm.md | 210 ++++++------------ 1 file changed, 67 insertions(+), 143 deletions(-) diff --git a/docs/07_algorithm/07_01_enumeration_algorithm.md b/docs/07_algorithm/07_01_enumeration_algorithm.md index bf8b5080..c0ccf69e 100644 --- a/docs/07_algorithm/07_01_enumeration_algorithm.md +++ b/docs/07_algorithm/07_01_enumeration_algorithm.md @@ -1,85 +1,79 @@ ## 1. 枚举算法简介 -> **枚举算法(Enumeration Algorithm)**:也称为穷举算法,指的是按照问题本身的性质,一一列举出该问题所有可能的解,并在逐一列举的过程中,将它们逐一与目标状态进行比较以得出满足问题要求的解。在列举的过程中,既不能遗漏也不能重复。 +> **枚举算法(Enumeration Algorithm)**,又称穷举算法,是指根据问题的特点,逐一列出所有可能的解,并与目标条件进行比较,找出满足要求的答案。枚举时要确保不遗漏、不重复。 -枚举算法的核心思想是:通过列举问题的所有状态,将它们逐一与目标状态进行比较,从而得到满足条件的解。 +枚举算法的核心思想就是:遍历所有可能的状态,逐个判断是否满足条件,找到符合要求的解。 -由于枚举算法要通过列举问题的所有状态来得到满足条件的解,因此,在问题规模变大时,其效率一般是比较低的。但是枚举算法也有自己特有的优点: +由于需要遍历所有状态,枚举算法在问题规模较大时效率较低。但它也有明显优点: -1. 多数情况下容易编程实现,也容易调试。 -2. 建立在考察大量状态、甚至是穷举所有状态的基础上,所以算法的正确性比较容易证明。 +1. 实现简单,易于编程和调试。 +2. 基于穷举所有情况,正确性容易验证。 -所以,枚举算法通常用于求解问题规模比较小的问题,或者作为求解问题的一个子算法出现,通过枚举一些信息并进行保存,而这些消息的有无对主算法效率的高低有着较大影响。 +因此,枚举算法常用于小规模问题,或作为其他算法的辅助工具,通过枚举部分信息来提升主算法的效率。 ## 2. 枚举算法的解题思路 ### 2.1 枚举算法的解题思路 -枚举算法是设计最简单、最基本的搜索算法。是我们在遇到问题时,最应该优先考虑的算法。 +枚举算法是最简单、最基础的搜索方法,通常是遇到问题时的首选方案。 -因为其实现足够简单,所以在遇到问题时,我们往往可以先通过枚举算法尝试解决问题,然后在此基础上,再去考虑其他优化方法和解题思路。 +由于实现简单,我们可以先用枚举算法尝试解决问题,再考虑是否需要优化。 -采用枚举算法解题的一般思路如下: +枚举算法的基本步骤如下: -1. 确定枚举对象、枚举范围和判断条件,并判断条件设立的正确性。 -2. 一一枚举可能的情况,并验证是否是问题的解。 -3. 考虑提高枚举算法的效率。 +1. 明确需要枚举的对象、枚举范围和约束条件。 +2. 逐一枚举所有可能情况,判断是否满足题意。 +3. 思考如何提升枚举效率。 -我们可以从下面几个方面考虑提高算法的效率: +提升效率的常用方法有: -1. 抓住问题状态的本质,尽可能缩小问题状态空间的大小。 -2. 加强约束条件,缩小枚举范围。 -3. 根据某些问题特有的性质,例如对称性等,避免对本质相同的状态重复求解。 +- 抓住问题本质,尽量缩小状态空间。 +- 增加约束条件,减少无效枚举。 +- 利用某些问题特有的性质(例如对称性等),避免重复计算。 ### 2.2 枚举算法的简单应用 -下面举个著名的例子:「百钱买百鸡问题」。这个问题是我国古代数学家张丘在「算经」一书中提出的。该问题叙述如下: +以经典的「百钱买百鸡问题」为例: -> **百钱买百鸡问题**:鸡翁一,值钱五;鸡母一,值钱三;鸡雏三,值钱一;百钱买百鸡,则鸡翁、鸡母、鸡雏各几何? +> **问题**:公鸡 5 元/只,母鸡 3 元/只,小鸡 1 元/3 只。用 100 元买 100 只鸡,问各买多少只? -翻译一下,意思就是:公鸡一只五块钱,母鸡一只三块钱,小鸡三只一块钱。现在我们用 $100$ 块钱买了 $100$ 只鸡,问公鸡、母鸡、小鸡各买了多少只? +**解题步骤**: -下面我们根据算法的一般思路来解决一下这道题。 +1. **确定枚举对象和范围** + - 枚举对象:公鸡数 $x$,母鸡数 $y$,小鸡数 $z$ + - 枚举范围:$0 \le x, y, z \le 100$ + - 约束条件:$5x + 3y + \frac{z}{3} = 100$ 且 $x + y + z = 100$ -1. 确定枚举对象、枚举范围和判断条件,并判断条件设立的正确性。 +2. **暴力枚举** - 1. 确定枚举对象:枚举对象为公鸡、母鸡、小鸡的只数,那么我们可以用变量 $x$、$y$、$z$ 分别来代表公鸡、母鸡、小鸡的只数。 - 2. 确定枚举范围:因为总共买了 $100$ 只鸡,所以 $0 \le x, y, z \le 100$,则 $x$、$y$、$z$ 的枚举范围为 $[0, 100]$。 - 3. 确定判断条件:根据题意,我们可以列出两个方程式:$5 \times x + 3 \times y + \frac{z}{3} = 100$,$x + y + z = 100$。在枚举 $x$、$y$、$z$ 的过程中,我们可以根据这两个方程式来判断是否当前状态是否满足题意。 + ```python + class Solution: + def buyChicken(self): + for x in range(101): + for y in range(101): + for z in range(101): + if z % 3 == 0 and 5 * x + 3 * y + z // 3 == 100 and x + y + z == 100: + print("公鸡 %s 只,母鸡 %s 只,小鸡 %s 只" % (x, y, z)) + ``` -2. 一一枚举可能的情况,并验证是否是问题的解。 +3. **优化枚举效率** + - 利用 $z = 100 - x - y$ 减少一重循环 + - 缩小枚举范围:$x \in [0, 20]$,$y \in [0, 33]$ - 1. 根据枚举对象、枚举范围和判断条件,我们可以顺利写出对应的代码。 - - ```python - class Solution: - def buyChicken(self): - for x in range(101): - for y in range(101): - for z in range(101): - if z % 3 == 0 and 5 * x + 3 * y + z // 3 == 100 and x + y + z == 100: - print("公鸡 %s 只,母鸡 %s 只,小鸡 %s 只" % (x, y, z)) - ``` - -3. 考虑提高枚举算法的效率。 - - 1. 在上面的代码中,我们枚举了 $x$、$y$、$z$,但其实根据方程式 $x + y + z = 100$,得知:$z$ 可以通过 $z = 100 - x - y$ 而得到,这样我们就不用再枚举 $z$ 了。 - 2. 在上面的代码中,对 $x$、$y$ 的枚举范围是 $[0, 100]$,但其实如果所有钱用来买公鸡,最多只能买 $20$ 只,同理,全用来买母鸡,最多只能买 $33$ 只。所以对 $x$ 的枚举范围可改为 $[0, 20]$,$y$ 的枚举范围可改为 $[0, 33]$。 - - ```python - class Solution: - def buyChicken(self): - for x in range(21): - for y in range(34): - z = 100 - x - y - if z % 3 == 0 and 5 * x + 3 * y + z // 3 == 100: - print("公鸡 %s 只,母鸡 %s 只,小鸡 %s 只" % (x, y, z)) - ``` + ```python + class Solution: + def buyChicken(self): + for x in range(21): + for y in range(34): + z = 100 - x - y + if z % 3 == 0 and 5 * x + 3 * y + z // 3 == 100: + print("公鸡 %s 只,母鸡 %s 只,小鸡 %s 只" % (x, y, z)) + ``` ## 3. 枚举算法的应用 -### 3.1 两数之和 +### 3.1 经典例题:两数之和 #### 3.1.1 题目链接 @@ -117,23 +111,24 @@ #### 3.1.3 解题思路 -这里说下枚举算法的解题思路。 - ##### 思路 1:枚举算法 -1. 使用两重循环枚举数组中每一个数 $nums[i]$、$nums[j]$,判断所有的 $nums[i] + nums[j]$ 是否等于 $target$。 -2. 如果出现 $nums[i] + nums[j] == target$,则说明数组中存在和为 $target$ 的两个整数,将两个整数的下标 $i$、$j$ 输出即可。 +1. 通过两重循环,依次枚举数组中所有可能的下标对 $(i, j)$(其中 $i < j$),判断 $nums[i] + nums[j]$ 是否等于 $target$。 +2. 一旦找到满足条件的下标对,即 $nums[i] + nums[j] == target$,立即返回这两个下标 $[i, j]$ 作为答案。 ##### 思路 1:代码 ```python class Solution: def twoSum(self, nums: List[int], target: int) -> List[int]: + # 遍历第一个数的下标 for i in range(len(nums)): + # 遍历第二个数的下标(只需从i+1开始,避免和自身重复) for j in range(i + 1, len(nums)): - if i != j and nums[i] + nums[j] == target: - return [i, j] - return [] + # 判断两数之和是否等于目标值 + if nums[i] + nums[j] == target: + return [i, j] # 返回下标对 + return [] # 如果没有找到,返回空列表 ``` ##### 思路 1:复杂度分析 @@ -141,85 +136,13 @@ class Solution: - **时间复杂度**:$O(n^2)$,其中 $n$ 为数组 $nums$ 的元素数量。 - **空间复杂度**:$O(1)$。 -### 3.2 计数质数 +### 3.2 统计平方和三元组的数目 #### 3.2.1 题目链接 -- [204. 计数质数 - 力扣(LeetCode)](https://leetcode.cn/problems/count-primes/) - -#### 3.2.2 题目大意 - -**描述**:给定 一个非负整数 $n$。 - -**要求**:统计小于 $n$ 的质数数量。 - -**说明**: - -- $0 \le n \le 5 \times 10^6$。 - -**示例**: - -- 示例 1: - -```python -输入 n = 10 -输出 4 -解释 小于 10 的质数一共有 4 个, 它们是 2, 3, 5, 7。 -``` - -- 示例 2: - -```python -输入:n = 1 -输出:0 -``` - -#### 3.2.3 解题思路 - -这里说下枚举算法的解题思路(注意:提交会超时,只是讲解一下枚举算法的思路)。 - -##### 思路 1:枚举算法(超时) - -对于小于 $n$ 的每一个数 $x$,我们可以枚举区间 $[2, x - 1]$ 上的数是否是 $x$ 的因数,即是否存在能被 $x$ 整除的数。如果存在,则该数 $x$ 不是质数。如果不存在,则该数 $x$ 是质数。 - -这样我们就可以通过枚举 $[2, n - 1]$ 上的所有数 $x$,并判断 $x$ 是否为质数。 - -在遍历枚举的同时,我们维护一个用于统计小于 $n$ 的质数数量的变量 $cnt$。如果符合要求,则将计数 $cnt$ 加 $1$。最终返回该数目作为答案。 - -考虑到如果 $i$ 是 $x$ 的因数,则 $\frac{x}{i}$ 也必然是 $x$ 的因数,则我们只需要检验这两个因数中的较小数即可。而较小数一定会落在 $[2, \sqrt x]$ 上。因此我们在检验 $x$ 是否为质数时,只需要枚举 $[2, \sqrt x]$ 中的所有数即可。 - -利用枚举算法单次检查单个数的时间复杂度为 $O(\sqrt{n})$,检查 $n$ 个数的整体时间复杂度为 $O(n \sqrt{n})$。 - -##### 思路 1:代码 - -```python -class Solution: - def isPrime(self, x): - for i in range(2, int(pow(x, 0.5)) + 1): - if x % i == 0: - return False - return True - - def countPrimes(self, n: int) -> int: - cnt = 0 - for x in range(2, n): - if self.isPrime(x): - cnt += 1 - return cnt -``` - -##### 思路 1:复杂度分析 - -- **时间复杂度**:$O(n \times \sqrt{n})$。 -- **空间复杂度**:$O(1)$。 - -### 3.3 统计平方和三元组的数目 - -#### 3.3.1 题目链接 - - [1925. 统计平方和三元组的数目 - 力扣(LeetCode)](https://leetcode.cn/problems/count-square-sum-triples/) -#### 3.3.2 题目大意 +#### 3.2.2 题目大意 **描述**:给你一个整数 $n$。 @@ -248,30 +171,31 @@ class Solution: 解释:平方和三元组为 (3,4,5),(4,3,5),(6,8,10) 和 (8,6,10)。 ``` -#### 3.3.3 解题思路 +#### 3.2.3 解题思路 ##### 思路 1:枚举算法 -我们可以在 $[1, n]$ 区间中枚举整数三元组 $(a, b, c)$ 中的 $a$ 和 $b$。然后判断 $a^2 + b^2$ 是否小于等于 $n$,并且是完全平方数。 +直接枚举 $a$ 和 $b$,计算 $c^2 = a^2 + b^2$,判断 $c$ 是否为整数且 $1 \leq c \leq n$,如果满足条件则计数加一,最后返回总数。 -在遍历枚举的同时,我们维护一个用于统计平方和三元组数目的变量 $cnt$。如果符合要求,则将计数 $cnt$ 加 $1$。最终,我们返回该数目作为答案。 +该方法时间复杂度为 $O(n^2)$。 -利用枚举算法统计平方和三元组数目的时间复杂度为 $O(n^2)$。 -- 注意:在计算中,为了防止浮点数造成的误差,并且两个相邻的完全平方正数之间的距离一定大于 $1$,所以我们可以用 $\sqrt{a^2 + b^2 + 1}$ 来代替 $\sqrt{a^2 + b^2}$。 +- 注意:为避免浮点误差,可以用 $\sqrt{a^2 + b^2 + 1}$ 代替 $\sqrt{a^2 + b^2}$,这样判断 $c$ 是否为整数更安全。 ##### 思路 1:代码 ```python class Solution: def countTriples(self, n: int) -> int: - cnt = 0 - for a in range(1, n + 1): - for b in range(1, n + 1): + cnt = 0 # 统计满足条件的三元组个数 + for a in range(1, n + 1): # 枚举 a + for b in range(1, n + 1): # 枚举 b + # 计算 c,注意加 1 防止浮点误差 c = int(sqrt(a * a + b * b + 1)) + # 判断 c 是否在范围内,且 a^2 + b^2 == c^2 if c <= n and a * a + b * b == c * c: - cnt += 1 - return cnt + cnt += 1 # 满足条件,计数加一 + return cnt # 返回最终统计结果 ``` ##### 思路 1:复杂度分析 From beb0d85eaff113cbe319f8c2cc81f7a63fd8af28 Mon Sep 17 00:00:00 2001 From: ITCharge Date: Mon, 8 Sep 2025 14:56:42 +0800 Subject: [PATCH 17/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=92=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=AF=AD=E5=8F=A5=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../07_01_enumeration_algorithm.md | 8 + .../07_algorithm/07_02_recursive_algorithm.md | 212 ++++----- .../07_03_divide_and_conquer_algorithm.md | 197 ++++---- .../07_04_backtracking_algorithm.md | 434 ++++++++++-------- docs/07_algorithm/07_05_greedy_algorithm.md | 159 ++++--- docs/07_algorithm/07_06_bit_operation.md | 321 ++++++------- 6 files changed, 721 insertions(+), 610 deletions(-) diff --git a/docs/07_algorithm/07_01_enumeration_algorithm.md b/docs/07_algorithm/07_01_enumeration_algorithm.md index c0ccf69e..927f1e43 100644 --- a/docs/07_algorithm/07_01_enumeration_algorithm.md +++ b/docs/07_algorithm/07_01_enumeration_algorithm.md @@ -203,6 +203,14 @@ class Solution: - **时间复杂度**:$O(n^2)$。 - **空间复杂度**:$O(1)$。 +## 4. 总结 + +枚举算法通过遍历所有可能状态来寻找解,优点是实现简单、思路直接、正确性易于验证;缺点是在问题规模增大时时间开销迅速上升,往往无法满足效率要求。 + +它适用于规模较小、可快速验证答案的问题,或作为基线方案、结果校验与对拍工具。实战中应尽量结合剪枝(添加约束、提前判定不可能)、缩小搜索空间(利用对称性、边界与不变量)、降维与变量替换、以及避免重复计算等手段,显著提升效率。 + +实践建议是:先写出「能过的暴力正确解」,再围绕「减分支、减范围、减重算」迭代优化;当复杂度仍难以接受时,考虑切换到更合适的范式,例如哈希加速、双指针与滑动窗口、二分查找、分治、动态规划或图算法等。 + ## 练习题目 - [0001. 两数之和](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0001-0099/two-sum.md) diff --git a/docs/07_algorithm/07_02_recursive_algorithm.md b/docs/07_algorithm/07_02_recursive_algorithm.md index 050e9061..2f18770d 100644 --- a/docs/07_algorithm/07_02_recursive_algorithm.md +++ b/docs/07_algorithm/07_02_recursive_algorithm.md @@ -1,21 +1,23 @@ ## 1. 递归简介 -> **递归(Recursion)**:指的是一种通过重复将原问题分解为同类的子问题而解决的方法。在绝大数编程语言中,可以通过在函数中再次调用函数自身的方式来实现递归。 +> **递归(Recursion)**:是一种将复杂问题分解为与原问题结构相同的子问题,并通过重复求解这些子问题来获得最终解答的方法。在大多数编程语言中,递归通常通过函数自身的调用来实现。 -举个简单的例子来了解一下递归算法。比如阶乘的计算方法在数学上的定义为: +以阶乘为例,数学定义如下: $fact(n) = \begin{cases} 1 & \text{n = 0} \cr n \times fact(n - 1) & \text{n > 0} \end{cases}$ -根据阶乘计算方法的数学定义,我们可以使用调用函数自身的方式来实现阶乘函数 $fact(n)$ ,其实现代码可以写作: +我们可以直接用调用函数自身的方式实现阶乘函数 $fact(n)$,代码如下: ```python def fact(n): + # 递归终止条件:当 n 等于 0 时,返回 1 if n == 0: return 1 + # 递归调用:n 乘以 fact(n - 1),将问题规模缩小 return n * fact(n - 1) ``` -以 $n = 6$ 为例,上述代码中阶乘函数 $fact(6)$ 的计算过程如下: +以 $n = 6$ 为例,阶乘函数 $fact(6)$ 的递归计算步骤如下: ```python fact(6) @@ -34,116 +36,91 @@ fact(6) = 720 ``` -上面的例子也可以用语言描述为: +上述例子可以用如下方式描述递归的执行过程: -1. 函数从 $fact(6)$ 开始,一层层地调用 $fact(5)$、$fact(4)$、…… 一直调用到最底层的 $fact(0)$。 -2. 当 $n == 0$ 时,$fact(0)$ 不再继续调用自身,而是直接向上一层返回结果 $1$。 -3. $fact(1)$ 通过下一层 $fact(0)$ 的计算结果得出 $fact(1) = 1 \times 1 = 1$,从而向上一层返回结果 $1$。 -4. $fact(2)$ 通过下一层 $fact(1)$ 的计算结果得出 $fact(2) = 2 \times 1 = 2 $,从而向上一层返回结果 $2$。 -5. $fact(3)$ 通过下一层 $fact(2)$ 的计算结果得出 $fact(3) = 3 \times 2 = 6 $,从而向上一层返回结果 $6$。 -6. $fact(4)$ 通过下一层 $fact(3)$ 的计算结果得出 $fact(4) = 4 \times 6 = 24$,从而向上一层返回结果 $24$。 -7. $fact(5)$ 通过下一层 $fact(4)$ 的计算结果得出 $fact(5) = 5 \times 24 = 120$,从而向上一层返回结果 $120$。 -8. $fact(6)$ 通过下一层 $fact(5)$ 的计算结果得出 $fact(6) = 6 \times 120 = 720$,从而返回函数的最终结果 $720$。 +1. 从 $fact(6)$ 开始,函数不断递归调用自身,依次进入 $fact(5)$、$fact(4)$、……,直到到达最底层的 $fact(0)$。 +2. 当 $n == 0$ 时,$fact(0)$ 满足终止条件,直接返回 $1$,递归不再继续向下。 +3. 返回阶段,从 $fact(0)$ 开始逐层向上,每一层利用下一层的返回值进行计算: + - $fact(1)$:通过 $fact(0)$ 的结果 $1$,计算 $fact(1) = 1 \times 1 = 1$,返回 $1$。 + - $fact(2)$:通过 $fact(1)$ 的结果 $1$,计算 $fact(2) = 2 \times 1 = 2$,返回 $2$。 + - $fact(3)$:通过 $fact(2)$ 的结果 $2$,计算 $fact(3) = 3 \times 2 = 6$,返回 $6$。 + - $fact(4)$:通过 $fact(3)$ 的结果 $6$,计算 $fact(4) = 4 \times 6 = 24$,返回 $24$。 + - $fact(5)$:通过 $fact(4)$ 的结果 $24$,计算 $fact(5) = 5 \times 24 = 120$,返回 $120$。 + - $fact(6)$:通过 $fact(5)$ 的结果 $120$,计算 $fact(6) = 6 \times 120 = 720$,最终返回 $720$。 -这就是阶乘函数的递归计算过程。 +整个递归过程分为两步: -根据上面的描述,我们可以把阶乘函数的递归计算过程分为两个部分: +1. 向下递推:不断分解问题,直到满足终止条件($n == 0$)。 +2. 向上回归:逐层返回结果,最终得到原问题的解(即返回 $fact(6) == 720$)。 -1. 先逐层向下调用自身,直到达到结束条件(即 $n == 0$)。 -2. 然后再向上逐层返回结果,直到返回原问题的解(即返回 $fact(6) == 720$)。 - -这两个部分也可以叫做「递推过程」和「回归过程」,如下面两幅图所示: +如下图所示: ![递推过程](https://qcdn.itcharge.cn/images/20220407160648.png) ![回归过程](https://qcdn.itcharge.cn/images/20220407160659.png) -如上面所说,我们可以把「递归」分为两个部分:「递推过程」和「回归过程」。 - -- **递推过程**:指的是将原问题一层一层地分解为与原问题形式相同、规模更小的子问题,直到达到结束条件时停止,此时返回最底层子问题的解。 -- **回归过程**:指的是从最底层子问题的解开始,逆向逐一回归,最终达到递推开始时的原问题,返回原问题的解。 - -「递推过程」和「回归过程」是递归算法的精髓。从这个角度来理解递归,递归的基本思想就是: **把规模大的问题不断分解为子问题来解决。** +简而言之,递归包含「递推过程」和「回归过程」: -同时,因为解决原问题和不同规模的小问题往往使用的是相同的方法,所以就产生了函数调用函数自身的情况,这也是递归的定义所在。 +- **递推过程**:将大问题逐步分解为更小的同类子问题,直到终止条件。 +- **回归过程**:从最小子问题开始,逐层返回结果,最终解决原问题。 -## 2. 递归和数学归纳法 +递归的核心思想就是:**把大问题拆解为小问题,逐步解决。** 因为每一层的处理方式相同,所以递归函数会调用自身,这正是递归的本质。 -递归的数学模型其实就是「数学归纳法」。这里简单复习一下数学归纳法的证明步骤: +## 2. 递归与数学归纳法 -1. 证明当 $n = b$ ($b$ 为基本情况,通常为 $0$ 或者 $1$)时,命题成立。 -2. 证明当 $n > b$ 时,假设 $n = k$ 时命题成立,那么可以推导出 $n = k + 1$ 时命题成立。这一步不是直接证明的,而是先假设 $n = k$ 时命题成立,利用这个条件,可以推论出 $n = k + 1$ 时命题成立。 +递归的本质与「数学归纳法」高度契合。我们先简要回顾数学归纳法的基本步骤: -通过以上两步证明,就可以说:当 $n >= b$ 时,命题都成立。 +1. **基础情形**:证明当 $n = b$($b$ 通常为 $0$ 或 $1$)时,命题成立。 +2. **归纳步骤**:假设当 $n = k$ 时命题成立,进一步证明 $n = k + 1$ 时命题也成立。这里的关键是利用 $n = k$ 成立的假设,推导出 $n = k + 1$ 也成立。 -我们可以从「数学归纳法」的角度来解释递归: +完成上述两步后,即可得出:对于所有 $n \ge b$,命题均成立。 -- **递归终止条件**:数学归纳法第一步中的 $n = b$,可以直接得出结果。 -- **递推过程**:数学归纳法第二步中的假设部分(假设 $n = k$ 时命题成立),也就是假设我们当前已经知道了 $n = k$ 时的计算结果。 -- **回归过程**:数学归纳法第二步中的推论部分(根据 $n = k$ 推论出 $n = k + 1$),也就是根据下一层的结果,计算出上一层的结果。 +将递归与数学归纳法对应起来,可以这样理解: -事实上,数学归纳法的思考过程也正是在解决某些数列问题时,可以使用递归算法的原因。比如阶乘、数组前 $n$ 项和、斐波那契数列等等。 +- **递归终止条件**:对应于数学归纳法的基础情形($n = b$),此时直接给出结果。 +- **递推过程**:对应于归纳假设部分(假设 $n = k$ 时成立),即假设我们已经知道了规模更小的问题的解。 +- **回归过程**:对应于归纳推导部分(由 $n = k$ 推出 $n = k + 1$),即利用子问题的解,推导出当前问题的解。 -## 3. 递归三步走 +正因为数学归纳法的推理方式与递归的分解和回归过程一致,所以在解决如阶乘、前 $n$ 项和、斐波那契数列等问题时,递归算法往往是最自然的选择。 -上面我们提到,递归的基本思想就是: **把规模大的问题不断分解为子问题来解决。** 那么,在写递归的时候,我们可以按照这个思想来书写递归,具体步骤如下: +## 3. 递归三步法 -1. **写出递推公式**:找到将原问题分解为子问题的规律,并且根据规律写出递推公式。 -2. **明确终止条件**:推敲出递归的终止条件,以及递归终止时的处理方法。 -3. **将递推公式和终止条件翻译成代码**: - 1. 定义递归函数(明确函数意义、传入参数、返回结果等)。 - 2. 书写递归主体(提取重复的逻辑,缩小问题规模)。 - 3. 明确递归终止条件(给出递归终止条件,以及递归终止时的处理方法)。 +递归的核心思想是:**把大问题拆解为小问题,逐步解决**。写递归时,可以遵循以下三步: -### 3.1 写出递推公式 +1. **写递推公式**:找出原问题与子问题的关系,写出递推公式。 +2. **确定终止条件**:明确递归何时结束,以及结束时的返回值。 +3. **翻译为代码**: + - 定义递归函数(明确参数和返回值含义) + - 编写递归主体(递推公式对应的递归调用) + - 加入终止条件的判断和处理 -写出递推公式的关键在于:**找到将原问题分解为子问题的规律,并将其抽象成递推公式**。 +### 3.1 写递推公式 -我们在思考递归的逻辑时,没有必要在大脑中将整个递推过程和回归过程一层层地想透彻。很可能还没有递推到栈底呢,脑子就已经先绕晕了。 +递归的关键在于:将原问题拆解为更小、结构相同的子问题,并用递推公式加以表达。例如,阶乘问题的递推公式为 $fact(n) = n \times fact(n - 1)$。 -之前讲解的阶乘例子中,一个问题只需要分解为一个子问题,我们很容易能够想清楚「递推过程」和「回归过程」的每一个步骤,所以写起来和理解起来都不难。 +在思考递归时,无需把每一层的递推和回归过程都在脑海中推演到底,否则容易陷入细节而感到困惑。以阶乘为例,它只需分解为一个子问题,因此递归的每一步都容易理解和实现。 -但是当我们面对的是一个问题需要分解为多个子问题的情况时,就没有那么容易想清楚「递推过程」和「回归过程」的每一个步骤了。 +但当一个问题需要分解为多个子问题时,逐层推演每一步的递推和回归过程就会变得复杂且难以理清。 -那么我们应该如何思考「递推过程」和「回归过程」呢,又该如何写出递归中的递推公式呢? +此时,推荐的思考方式是:假设所有子问题(如 $B$、$C$、$D$)都已经解决,我们只需思考如何利用这些子问题的解来解决原问题 $A$。无需再深入每个子问题的内部递归细节,这样可以大大简化思考难度。 -如果一个问题 $A$,可以分解为若干个规模较小、与原问题形式相同的子问题 $B$、$C$、$D$,那么这些子问题就可以用相同的解题思路来解决。我们可以假设 $B$、$C$、$D$ 已经解决了,然后只需要考虑在这个基础上去思考如何解决问题 $A$ 即可。不需要再一层层往下思考子问题与子子问题、子子问题与子子子问题之间的关系。这样理解起来就简单多了。 +实际上,从原问题 $A$ 拆解为子问题 $B$、$C$、$D$ 的过程,就是递归的「递推过程」;而将子问题的解合并为原问题的解,则是「回归过程」。只要明确了如何划分子问题,以及如何通过子问题的解来解决原问题,就能顺利写出递推公式。 -从问题 $A$ 到分解为子问题 $B$、$C$、$D$ 的思考过程其实就是递归的「递推过程」。而从子问题 $B$、$C$、$D$ 的解回到问题 $A$ 的解的思考过程其实就是递归的「回归过程」。想清楚了「如何划分子问题」和「如何通过子问题来解决原问题」这两个过程,也就想清楚了递归的「递推过程」和「回归过程」。 - -然后,我们只需要考虑原问题与子问题之间的关系,就能够在此基础上,写出递推公式了。 +因此,编写递归时,重点关注原问题与子问题之间的关系,并据此写出递推公式即可。 ### 3.2 明确终止条件 -递归的终止条件也叫做递归出口。在写出了递推公式之后,就要考虑递归的终止条件是什么。如果没有递归的终止条件,函数就会无限地递归下去,程序就会失控崩溃了。通常情况下,递归的终止条件是问题的边界值。 - -在找到递归的终止条件时,我们应该直接给出该条件下的处理方法。一般地,在这种情境下,问题的解决方案是直观的、容易的。例如阶乘中 $fact(0) = 1$。斐波那契数列中 $f(1) = 1$,$f(2) = 2$。 - -### 3.3 将递推公式和终止条件翻译成代码 - -在写出递推公式和明确终止条件之后,我们就可以将其翻译成代码了。这一步也可以分为 $3$ 步来做: - -1. **定义递归函数**:明确函数意义、传入参数、返回结果等。 -2. **书写递归主体**:提取重复的逻辑,缩小问题规模。 -3. **明确递归终止条件**:给出递归终止条件,以及递归终止时的处理方法。 - -#### 3.3.1 定义递归函数 - -在定义递归函数时,一定要明确递归函数的意义,也就是要明白这个问题传入的参数是什么,最终返回的结果是要解决的什么问题。 - -比如说阶乘函数 $fact(n)$,这个函数的传入参数是问题的规模 $n$,最终返回的结果是 $n$ 的阶乘值。 +递归必须有终止条件(递归出口),否则会无限递归导致程序崩溃。终止条件通常是问题的边界值,并在此时直接给出答案。例如,$fact(0) = 1$,$f(1) = 1$。 -#### 3.3.2 书写递归主体 +### 3.3 翻译为代码 -在将原问题划分为子问题,并根据原问题和子问题的关系,我们就可以推论出对应的递推公式。然后根据递推公式,就可以将其转换为递归的主体代码。 +将递推公式和终止条件转化为代码,通常分为三步: -#### 3.3.3 明确递归终止条件 +1. **定义递归函数**:明确参数和返回值的含义。 +2. **编写递归主体**:根据递推公式递归调用自身。 +3. **加入终止条件**:用条件语句判断并处理终止情况。 -这一步其实就是将「3.2 明确终止条件」章节中的递归终止条件和终止条件下的处理方法转换为代码中的条件语句和对应的执行语句。 - -#### 3.3.4 递归伪代码 - -根据上述递归书写的步骤,我们就可以写出递归算法的代码了。递归算法的伪代码如下: +综合上述步骤,递归伪代码如下: ```python def recursion(大规模问题): @@ -153,37 +130,37 @@ def recursion(大规模问题): return recursion(小规模问题) ``` -## 4. 递归的注意点 +## 4. 递归的注意事项 ### 4.1 避免栈溢出 -在程序执行中,递归是利用堆栈来实现的。每一次递推都需要一个栈空间来保存调用记录,每当进入一次函数调用,栈空间就会加一层栈帧。每一次回归,栈空间就会减一层栈帧。由于系统中的栈空间大小不是无限的,所以,如果递归调用的次数过多,会导致栈空间溢出。 - -为了避免栈溢出,我们可以在代码中限制递归调用的最大深度来解决问题。当递归调用超过一定深度时(比如 100)之后,不再进行递归,而是直接返回报错。 +递归在程序执行时依赖于调用栈。每递归调用一次,系统会为该调用分配一个新的栈帧;每当递归返回时,栈帧被销毁。由于系统栈空间有限,如果递归层数过深,极易导致栈溢出(Stack Overflow)。 -当然这种做法并不能完全避免栈溢出,也无法完全解决问题,因为系统允许的最大递归深度跟当前剩余的占空间有关,事先无法计算。 +为降低栈溢出的风险,可以在代码中人为设置递归的最大深度(如 100 层),超过后直接返回错误或采取其他处理措施。但这种方式并不能彻底杜绝栈溢出,因为系统允许的最大递归深度受限于当前可用栈空间,且难以精确预估。 -如果使用递归算法实在无法解决问题,我们可以考虑将递归算法变为非递归算法(即递推算法)来解决栈溢出的问题。 +如果递归深度不可控或递归算法难以避免栈溢出,建议将递归改写为非递归(迭代)算法,即用循环和显式栈模拟递归过程,从根本上解决栈空间受限的问题。 ### 4.2 避免重复运算 -在使用递归算法时,还可能会出现重复运算的问题。 +递归算法常常会遇到重复计算的问题,尤其是在分治结构中多个子问题重叠时。例如,斐波那契数列的递归定义如下: -比如斐波那契数列的定义是: +$$ +f(n) = \begin{cases} +0 & n = 0 \\ +1 & n = 1 \\ +f(n-1) + f(n-2) & n > 1 +\end{cases} +$$ -$f(n) = \begin{cases} 0 & n = 0 \cr 1 & n = 1 \cr f(n - 2) + f(n - 1) & n > 1 \end{cases}$ - -其对应的递归过程如下图所示: +如下图所示,计算 $f(5)$ 时,$f(3)$ 会被多次递归计算,$f(2)$、$f(1)$、$f(0)$ 也会被重复计算,导致效率极低。 ![斐波那契数列的递归过程](https://qcdn.itcharge.cn/images/20230307164107.png) -从图中可以看出:想要计算 $f(5)$,需要先计算 $f(3)$ 和 $f(4)$,而在计算 $f(4)$ 时还需要计算 $f(3)$,这样 $f(3)$ 就进行了多次计算。同理 $f(0)$、$f(1)$、$f(2)$ 都进行了多次计算,就导致了重复计算问题。 - -为了避免重复计算,我们可以使用一个缓存(哈希表、集合或数组)来保存已经求解过的 $f(k)$ 的结果,这也是动态规划算法中的做法。当递归调用用到 $f(k)$ 时,先查看一下之前是否已经计算过结果,如果已经计算过,则直接从缓存中取值返回,而不用再递推下去,这样就避免了重复计算问题。 +为避免重复运算,可以引入缓存机制(如哈希表、数组或集合)记录已经计算过的子问题结果。这种做法称为「记忆化递归」或「递归 + 备忘录」,也是动态规划的核心思想之一。每次递归调用前,先检查缓存中是否已有结果,若有则直接返回,无需再次递归,从而显著提升效率。 ## 5. 递归的应用 -### 5.1 斐波那契数 +### 5.1 经典例题:斐波那契数 #### 5.1.1 题目链接 @@ -224,16 +201,17 @@ $f(n) = \begin{cases} 0 & n = 0 \cr 1 & n = 1 \cr f(n - 2) + f(n - 1) & n > 1 \e ##### 思路 1:递归算法 -根据我们的递推三步走策略,写出对应的递归代码。 +按照递归解题的「三步走」策略,可以将斐波那契数列问题的递归实现过程梳理如下: + +1. 写递推公式:$f(n) = f(n - 1) + f(n - 2)$。 +2. 确定终止条件:$f(0) = 0$,$f(1) = 1$。 +3. 翻译为代码: + 1. 定义递归函数 `fib(self, n)`,其中 $n$ 表示问题规模,返回第 $n$ 个斐波那契数。 + 2. 递归主体为:`return self.fib(n - 1) + self.fib(n - 2)`。 + 3. 递归终止条件: + - `if n == 0: return 0` + - `if n == 1: return 1` -1. 写出递推公式:$f(n) = f(n - 1) + f(n - 2)$。 -2. 明确终止条件:$f(0) = 0, f(1) = 1$。 -3. 翻译为递归代码: - 1. 定义递归函数:`fib(self, n)` 表示输入参数为问题的规模 $n$,返回结果为第 $n$ 个斐波那契数。 - 2. 书写递归主体:`return self.fib(n - 1) + self.fib(n - 2)`。 - 3. 明确递归终止条件: - 1. `if n == 0: return 0` - 2. `if n == 1: return 1` ##### 思路 1:代码 @@ -252,7 +230,7 @@ class Solution: - **时间复杂度**:$O((\frac{1 + \sqrt{5}}{2})^n)$。具体证明方法参考 [递归求斐波那契数列的时间复杂度,不要被网上的答案误导了 - 知乎](https://zhuanlan.zhihu.com/p/256344121)。 - **空间复杂度**:$O(n)$。每次递归的空间复杂度是 $O(1)$, 调用栈的深度为 $n$,所以总的空间复杂度就是 $O(n)$。 -### 5.2 二叉树的最大深度 +### 5.2 经典例题:二叉树的最大深度 #### 5.2.1 题目链接 @@ -289,15 +267,15 @@ class Solution: ##### 思路 1: 递归算法 -根据递归三步走策略,写出对应的递归代码。 +按照递归解题的「三步走」策略,整理递归解法如下: -1. 写出递推公式:**当前二叉树的最大深度 = max(当前二叉树左子树的最大深度, 当前二叉树右子树的最大深度) + 1**。 - - 即:先得到左右子树的高度,在计算当前节点的高度。 -2. 明确终止条件:当前二叉树为空。 -3. 翻译为递归代码: - 1. 定义递归函数:`maxDepth(self, root)` 表示输入参数为二叉树的根节点 $root$,返回结果为该二叉树的最大深度。 - 2. 书写递归主体:`return max(self.maxDepth(root.left) + self.maxDepth(root.right))`。 - 3. 明确递归终止条件:`if not root: return 0` +1. 写递推公式:**当前二叉树的最大深度 = max(左子树最大深度, 右子树最大深度) + 1**。 + - 即:递归分别计算左右子树的深度,取较大值后加 1,得到当前节点的深度。 +2. 确定终止条件:当当前节点为空(即 root 为 None)时,返回 0。 +3. 翻译为代码: + 1. 定义递归函数:`maxDepth(self, root)`,参数为二叉树根节点 $root$,返回该树的最大深度。 + 2. 递归主体:`return max(self.maxDepth(root.left), self.maxDepth(root.right)) + 1`。 + 3. 终止条件判断:`if not root: return 0`。 ##### 思路 1:代码 @@ -315,6 +293,16 @@ class Solution: - **时间复杂度**:$O(n)$,其中 $n$ 是二叉树的节点数目。 - **空间复杂度**:$O(n)$。递归函数需要用到栈空间,栈空间取决于递归深度,最坏情况下递归深度为 $n$,所以空间复杂度为 $O(n)$。 +## 6. 总结 + +递归的本质是将大问题拆成同结构的小问题,先「递推」到底,再「回归」向上合并结果。写递归时只需站在当前层思考:如果更小规模的子问题都已解决,我如何用它们的结果得到当前答案。 + +它与数学归纳法一一对应:终止条件对应基础情形,递推与回归对应「假设成立 → 推出更大规模成立」。因此,实践上先写清递推关系,再补充明确的递归出口,最后把思路直接翻译成代码即可。 + +实战中要特别注意两点:其一,递归层数过深可能导致栈溢出,可限制深度或改写为迭代;其二,子问题重叠会造成重复计算,可使用记忆化(缓存)或转为自底向上的动态规划来优化。 + +递归尤其适合链式、树形与分治类问题,如二叉树深度、DFS 等。建议先保证正确性,再视场景用缓存或迭代 / DP手段优化时间与空间开销。 + ## 练习题目 - [0509. 斐波那契数](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0500-0599/fibonacci-number.md) diff --git a/docs/07_algorithm/07_03_divide_and_conquer_algorithm.md b/docs/07_algorithm/07_03_divide_and_conquer_algorithm.md index fb1d8b90..c68c5a4e 100644 --- a/docs/07_algorithm/07_03_divide_and_conquer_algorithm.md +++ b/docs/07_algorithm/07_03_divide_and_conquer_algorithm.md @@ -2,121 +2,148 @@ ### 1.1 分治算法的定义 -> **分治算法(Divide and Conquer)**:字面上的解释是「分而治之」,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。 +> **分治算法(Divide and Conquer)**:即「分而治之」,把一个复杂问题拆分成多个相同或相似的子问题,递归分解,直到子问题足够简单可以直接解决,最后将子问题的解合并得到原问题的解。 -简单来说,分治算法的基本思想就是: **把规模大的问题不断分解为子问题,使得问题规模减小到可以直接求解为止。** +简而言之,分治算法就是:**把大问题不断拆小,直到可以直接求解,再合并结果**。 ![分治算法的基本思想](https://qcdn.itcharge.cn/images/20220413153059.png) -### 1.2 分治算法和递归算法的异同 +### 1.2 分治算法与递归算法的关系 -从定义上来看,分治算法的思想和递归算法的思想是一样的,都是把规模大的问题不断分解为子问题。 +分治和递归都强调「拆分问题」。递归是一种实现方式,分治是一种思想。可以理解为:$\text{递归算法} \subset \text{分治算法}$。 -其实,分治算法和递归算法的关系是包含与被包含的关系,可以看做: $\text{递归算法} \in \text{分治算法}$。 - -分治算法从实现方式上来划分,可以分为两种:「递归算法」和「迭代算法」。 +分治算法常用递归实现,也可以用迭代实现。例如:快速傅里叶变换、二分查找、非递归归并排序等。 ![分治算法的实现方式](https://qcdn.itcharge.cn/images/20240513162133.png) -一般情况下,分治算法比较适合使用递归算法来实现。但除了递归算法之外,分治算法还可以通过迭代算法来实现。比较常见的例子有:快速傅里叶变换算法、二分查找算法、非递归实现的归并排序算法等等。 - -我们先来讲解一下分支算法的适用条件,再来讲解一下基本步骤。 +下面先介绍分治算法的适用条件,再讲基本步骤。 ### 1.3 分治算法的适用条件 -分治算法能够解决的问题,一般需要满足以下 $4$ 个条件: +分治算法适用于满足以下 4 个条件的问题: -1. **可分解**:原问题可以分解为若干个规模较小的相同子问题。 -2. **子问题可独立求解**:分解出来的子问题可以独立求解,即子问题之间不包含公共的子子问题。 -3. **具有分解的终止条件**:当问题的规模足够小时,能够用较简单的方法解决。 -4. **可合并**:子问题的解可以合并为原问题的解,并且合并操作的复杂度不能太高,否则就无法起到减少算法总体复杂度的效果了。 +1. **可分解**:原问题能拆分为若干规模更小、结构相同的子问题。 +2. **子问题独立**:各子问题互不影响,无重叠部分。 +3. **有终止条件**:子问题足够小时可直接解决。 +4. **可合并**:子问题的解能高效合并为原问题的解,且合并过程不能太复杂。 ## 2. 分治算法的基本步骤 -使用分治算法解决问题主要分为 $3$ 个步骤: +分治算法通常包括以下三个核心步骤: -1. **分解**:把要解决的问题分解为成若干个规模较小、相对独立、与原问题形式相同的子问题。 -2. **求解**:递归求解各个子问题。 -3. **合并**:按照原问题的要求,将子问题的解逐层合并构成原问题的解。 +1. **分解**:将原问题拆分为若干个规模更小、结构相同且相互独立的子问题。 +2. **求解**:递归地解决每个子问题。 +3. **合并**:将各子问题的解按照原问题的要求逐层合并,最终得到整体问题的解。 -其中第 $1$ 步中将问题分解为若干个子问题时,最好使子问题的规模大致相同。换句话说,将一个问题分成大小相等的 $k$ 个子问题的处理方法是行之有效的。在许多问题中,可以取 $k = 2$。这种使子问题规模大致相等的做法是出自一种平衡子问题的思想,它几乎总是比子问题规模不等的做法要好。 +在第 $1$ 步分解时,建议将问题划分为规模尽量相等的 $k$ 个子问题,这样可以保持递归树的平衡,提升算法效率。实际应用中,$k = 2$ 是最常见的选择。子问题规模均衡,通常比不均衡的划分方式更优。 -其中第 $2$ 步的「递归求解各个子问题」指的是按照同样的分治策略进行求解,即通过将这些子问题分解为更小的子子问题来进行求解。就这样一直分解下去,直到分解出来的子问题简单到只用常数操作时间即可解决为止。 +第 $2$ 步的递归求解,意味着对子问题继续应用相同的分治策略,直到子问题足够简单,可以直接用常数时间解决为止。 -在完成第 $2$ 步之后,最小子问题的解可用常数时间求得。然后我们再按照递归算法中回归过程的顺序,由底至上地将子问题的解合并起来,逐级上推就构成了原问题的解。 +完成递归求解后,最小子问题的解可直接获得。随后,按照递归回归的顺序,自底向上逐步合并子问题的解,最终得到原问题的答案。 -按照分而治之的策略,在编写分治算法的代码时,也是按照上面的 $3$ 个步骤来编写的,其对应的伪代码如下: +在实际编写分治算法时,代码结构也应严格遵循上述 $3$ 个步骤,便于理解和维护。伪代码如下: ```python -def divide_and_conquer(problems_n): # problems_n 为问题规模 - if problems_n < d: # 当问题规模足够小时,直接解决该问题 - return solove() # 直接求解 - - problems_k = divide(problems_n) # 将问题分解为 k 个相同形式的子问题 - - res = [0 for _ in range(k)] # res 用来保存 k 个子问题的解 - for problem_k in problems_k: - res[i] = divide_and_conquer(problem_k) # 递归的求解 k 个子问题 - - ans = merge(res) # 合并 k 个子问题的解 - return ans # 返回原问题的解 +def divide_and_conquer(problem_n): + """ + 分治算法通用模板 + :param problem_n: 问题规模 + :return: 原问题的解 + """ + # 1. 递归终止条件:当问题规模足够小时,直接解决 + if problem_n < d: # d 为可直接求解的最小规模 + return solve(problem_n) # 直接求解(注意:原代码有拼写错误,应为 solve) + + # 2. 分解:将原问题分解为 k 个子问题 + problems_k = divide(problem_n) # divide 函数返回 k 个子问题的列表 + + # 3. 递归求解每个子问题 + res = [] + for sub_problem in problems_k: + sub_res = divide_and_conquer(sub_problem) # 递归求解子问题 + res.append(sub_res) # 收集每个子问题的解 + + # 4. 合并:将 k 个子问题的解合并为原问题的解 + ans = merge(res) + return ans # 返回原问题的解 ``` -## 3. 分治算法的复杂度分析 - - 分治算法中,在不断递归后,最后的子问题将变得极为简单,可在常数操作时间内予以解决,其带来的时间复杂度在整个分治算法中的比重微乎其微,可以忽略不计。所以,分治算法的时间复杂度实际上是由「分解」和「合并」两个部分构成的。 - -一般来讲,分治算法将一个问题划分为 $a$ 个形式相同的子问题,每个子问题的规模为 $n/b$,则总的时间复杂度的递归表达式可以表示为: +## 3. 分治算法分析 -$T(n) = \begin{cases} \Theta{(1)} & n = 1 \cr a \times T(n/b) + f(n) & n > 1 \end{cases}$ +分治算法的核心在于:将大问题递归拆分为更小的子问题,直到子问题足够简单(通常可直接用常数时间解决),然后合并子问题的解。实际的时间复杂度主要由「分解」和「合并」两个过程决定。 -其中,每次分解时产生的子问题个数是 $a$ ,每个子问题的规模是原问题规模的 $1 / b$,分解和合并 $a$ 个子问题的时间复杂度是 $f(n)$。 +一般情况下,分治算法会把原问题拆成 $a$ 个规模为 $n/b$ 的子问题,递归式为: -这样,求解一个分治算法的时间复杂度,就是求解上述递归表达式。关于递归表达式的求解有多种方法,这里我们介绍一下比较常用的「递推求解法」和「递归树法」。 +$$ +T(n) = +\begin{cases} +\Theta(1) & n = 1 \\ +a \times T(n/b) + f(n) & n > 1 +\end{cases} +$$ -### 3.1 递推求解法 +其中,$a$ 表示子问题个数,$n/b$ 是每个子问题的规模,$f(n)$ 是分解和合并的总耗时。 -根据问题的递归表达式,通过一步步递推分解推导,从而得到最终结果。 +求解分治算法复杂度,常用两种方法:递推法和递归树法。 -以「归并排序算法」为例,接下来我们通过递推求解法计算一下归并排序算法的时间复杂度。 +### 3.1 递推法 -我们得出归并排序算法的递归表达式如下: +以归并排序为例,其递归式为: -$T(n) = \begin{cases} O{(1)} & n = 1 \cr 2 \times T(n/2) + O(n) & n > 1 \end{cases}$ +$$ +T(n) = +\begin{cases} +O(1) & n = 1 \\ +2T(n/2) + O(n) & n > 1 +\end{cases} +$$ -根据归并排序的递归表达式,当 $n > 1$ 时,可以递推求解: +递推展开如下: -$$\begin{aligned} T(n) & = 2 \times T(n/2) + O(n) \cr & = 2 \times (2 \times T(n / 4) + O(n/2)) + O(n) \cr & = 4 \times T(n/4) + 2 \times O(n) \cr & = 8 \times T(n/8) + 3 \times O(n) \cr & = …… \cr & = 2^x \times T(n/2^x) + x \times O(n) \end{aligned}$$ +$$ +\begin{aligned} +T(n) &= 2T(n/2) + O(n) \\ + &= 2[2T(n/4) + O(n/2)] + O(n) \\ + &= 4T(n/4) + 2O(n/2) + O(n) \\ + &= 4T(n/4) + O(n) + O(n) \\ + &= 8T(n/8) + 3O(n) \\ + &\dots \\ + &= 2^x T(n/2^x) + xO(n) +\end{aligned} +$$ -递推最终规模为 $1$,令 $n = 2^x$,则 $x = \log_2n$,则: +当 $n = 2^x$,$x = \log_2 n$,最终: -$$\begin{aligned} T(n) & = n \times T(1) + \log_2n \times O(n) \cr & = n + \log_2n \times O(n) \cr & = O(n \times \log_2n) \end{aligned}$$ +$$ +T(n) = n \cdot T(1) + \log_2 n \cdot O(n) = O(n \log n) +$$ -则归并排序的时间复杂度为 $O(n \times \log_2n)$。 +则归并排序的时间复杂度为 $O(n \log n)$。 ### 3.2 递归树法 -递归树求解方式其实和递推求解一样,只不过递归树能够更清楚直观的显示出来,更能够形象地表达每层分解的节点和每层产生的时间成本。 +递归树法可以直观展示每层的分解和合并成本。以归并排序为例: -使用递归树法计算时间复杂度的公式为: +- 每层分解为 2 个子问题,总共 $\log_2 n$ 层。 +- 每层合并的总耗时为 $O(n)$。 -$\text{时间复杂度} = \text{叶子数} \times T(1) + \text{成本和} = 2^x \times T(1) + x \times O(n)$。 +总复杂度为: -我们还是以「归并排序算法」为例,通过递归树法计算一下归并排序算法的时间复杂度。 +$$ +\begin{aligned} +\text{总耗时} &= \underbrace{n \cdot O(1)}_{\text{叶子节点}} + \underbrace{O(n) \cdot \log_2 n}_{\text{每层合并}} \\ + &= O(n) + O(n \log n) \\ + &= O(n \log n) +\end{aligned} +$$ -归并排序算法的递归表达式如下: - -$T(n) = \begin{cases} O{(1)} & n = 1 \cr 2T(n/2) + O(n) & n > 1 \end{cases}$ - -其对应的递归树如下图所示。 +下图为归并排序的递归树示意: ![归并排序算法的递归树](https://qcdn.itcharge.cn/images/20220414171458.png) -因为 $n = 2^x$,则 $x = \log_2n$,则归并排序算法的时间复杂度为:$2^x \times T(1) + x \times O(n) = n + \log_2n \times O(n) = O(n \times \log_2n)$。 - ## 4. 分治算法的应用 -### 4.1 归并排序 +### 4.1 经典例题:归并排序 #### 4.1.1 题目链接 @@ -142,13 +169,13 @@ $T(n) = \begin{cases} O{(1)} & n = 1 \cr 2T(n/2) + O(n) & n > 1 \end{cases}$ #### 4.1.3 解题思路 -我们使用归并排序算法来解决这道题。 +本题采用归并排序算法求解,其步骤如下: -1. **分解**:将待排序序列中的 $n$ 个元素分解为左右两个各包含 $\frac{n}{2}$ 个元素的子序列。 -2. **求解**:递归将子序列进行分解和排序,直到所有子序列长度为 $1$。 -3. **合并**:把当前序列组中有序子序列逐层向上,进行两两合并。 +1. **分解**:将待排序数组递归地一分为二,分别划分为左右两个子数组,每个子数组大致包含 $\frac{n}{2}$ 个元素。 +2. **递归排序**:对左右两个子数组分别递归进行归并排序,直到子数组长度为 $1$,此时视为有序。 +3. **合并**:将两个有序子数组合并为一个有序数组,逐层向上合并,最终得到整体有序的结果。 -使用归并排序算法对数组排序的过程如下图所示。 +下图展示了归并排序对数组排序的具体过程: ![归并排序算法对数组排序的过程](https://qcdn.itcharge.cn/images/20220414204405.png) @@ -183,7 +210,7 @@ class Solution: return self.mergeSort(nums) ``` -### 4.2 二分查找 +### 4.2 经典例题:二分查找 #### 4.2.1 题目链接 @@ -211,15 +238,17 @@ class Solution: #### 4.2.3 解题思路 -我们使用分治算法来解决这道题。与其他分治题目不一样的地方是二分查找不用进行合并过程,最小子问题的解就是原问题的解。 +本题采用分治思想进行求解。与典型的分治问题不同,二分查找无需对子问题的结果进行合并,最小子问题的解即为原问题的解。 -1. **分解**:将数组的 $n$ 个元素分解为左右两个各包含 $\frac{n}{2}$ 个元素的子序列。 -2. **求解**:取中间元素 $nums[mid]$ 与 $target$ 相比。 - 1. 如果相等,则找到该元素; - 2. 如果 $nums[mid] < target$,则递归在左子序列中进行二分查找。 - 3. 如果 $nums[mid] > target$,则递归在右子序列中进行二分查找。 +具体步骤如下: -二分查找的的分治算法过程如下图所示。 +1. **分解**:将当前数组划分为左右两个子区间,每个子区间大致包含 $\frac{n}{2}$ 个元素。 +2. **处理**:选取中间元素 $nums[mid]$,并与目标值 $target$ 进行比较: + 1. 如果 $nums[mid] == target$,则直接返回该元素下标; + 2. 如果 $nums[mid] < target$,则在右侧子区间递归查找; + 3. 如果 $nums[mid] > target$,则在左侧子区间递归查找。 + +下图展示了二分查找的分治过程。 ![二分查找的的分治算法过程](https://qcdn.itcharge.cn/images/20211223115032.png) @@ -246,6 +275,18 @@ class Solution: return left if nums[left] == target else -1 ``` +## 5. 总结 + +分治是一种「拆分—求解—合并」的通用思维范式:将大问题拆为若干规模更小且相互独立的同构子问题,递归(或迭代)求解到足够小的基例,最后自底向上合并结果。是否适合分治,取决于子问题是否独立、规模能否尽量均衡、以及合并是否足够高效。 + +实践中要关注三个要点: + +1. 明确且正确的递归基与边界,避免无穷递归与越界; +2. 尽量使子问题独立、规模均衡,必要时调整划分策略; +3. 评估合并代价,若合并过重或子问题高度重叠,应考虑动态规划、记忆化或改用其他范式。 + +合理运用这些原则,分治能在排序、查找、几何与数值计算等领域提供简洁而高效的解法。 + ## 练习题目 - [0050. Pow(x, n)](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0001-0099/powx-n.md) diff --git a/docs/07_algorithm/07_04_backtracking_algorithm.md b/docs/07_algorithm/07_04_backtracking_algorithm.md index 0950bf5e..4e490280 100644 --- a/docs/07_algorithm/07_04_backtracking_algorithm.md +++ b/docs/07_algorithm/07_04_backtracking_algorithm.md @@ -1,113 +1,133 @@ ## 1. 回溯算法简介 -> **回溯算法(Backtracking)**:一种能避免不必要搜索的穷举式的搜索算法。采用试错的思想,在搜索尝试过程中寻找问题的解,当探索到某一步时,发现原先的选择并不满足求解条件,或者还需要满足更多求解条件时,就退回一步(回溯)重新选择,这种走不通就退回再走的技术称为「回溯法」,而满足回溯条件的某个状态的点称为「回溯点」。 +> **回溯算法(Backtracking)**:回溯算法是一种系统地搜索所有可能解的算法,通过递归和试错的方式逐步构建解的过程。当发现当前路径无法满足题目要求或无法得到有效解时,算法会撤销上一步的选择(即「回溯」),返回到上一个决策点,尝试其他可能的路径。回溯法的核心思想是「走不通就退回,换条路再试」,而每次需要回退的节点称为「回溯点」。 -简单来说,回溯算法采用了一种 **「走不通就回退」** 的算法思想。 +简而言之,回溯算法就是「遇到死路就回头」。 -回溯算法通常用简单的递归方法来实现,在进行回溯过程中更可能会出现两种情况: +回溯算法常用递归方式实现,过程通常有两种结果: -1. 找到一个可能存在的正确答案; -2. 在尝试了所有可能的分布方法之后宣布该问题没有答案。 +1. 找到一个满足条件的解; +2. 尝试所有可能后,确认无解。 -## 2. 从全排列问题开始理解回溯算法 +## 2. 从全排列问题直观理解回溯算法 -以求解 $[1, 2, 3]$ 的全排列为例,我们来讲解一下回溯算法的过程。 +以 $[1, 2, 3]$ 的全排列为例,回溯算法的核心流程如下: -1. 选择以 $1$ 为开头的全排列。 - 1. 选择以 $2$ 为中间数字的全排列,则最后数字只能选择 $3$。即排列为:$[1, 2, 3]$。 - 2. 撤销选择以 $3$ 为最后数字的全排列,再撤销选择以 $2$ 为中间数字的全排列。然后选择以 $3$ 为中间数字的全排列,则最后数字只能选择 $2$,即排列为:$[1, 3, 2]$。 -2. 撤销选择以 $2$ 为最后数字的全排列,再撤销选择以 $3$ 为中间数字的全排列,再撤销选择以 $1$ 为开头的全排列。然后选择以 $2$ 开头的全排列。 - 1. 选择以 $1$ 为中间数字的全排列,则最后数字只能选择 $3$。即排列为:$[2, 1, 3]$。 - 2. 撤销选择以 $3$ 为最后数字的全排列,再撤销选择以 $1$ 为中间数字的全排列。然后选择以 $3$ 为中间数字的全排列,则最后数字只能选择 $1$,即排列为:$[2, 3, 1]$。 -3. 撤销选择以 $1$ 为最后数字的全排列,再撤销选择以 $3$ 为中间数字的全排列,再撤销选择以 $2$ 为开头的全排列,选择以 $3$ 开头的全排列。 - 1. 选择以 $1$ 为中间数字的全排列,则最后数字只能选择 $2$。即排列为:$[3, 1, 2]$。 - 2. 撤销选择以 $2$ 为最后数字的全排列,再撤销选择以 $1$ 为中间数字的全排列。然后选择以 $2$ 为中间数字的全排列,则最后数字只能选择 $1$,即排列为:$[3, 2, 1]$。 +1. 首先选择第一个数字为 $1$: + - 接下来可选数字为 $2$ 和 $3$。 + - 选择 $2$ 作为第二个数字,剩下只能选 $3$,得到排列 $[1, 2, 3]$。 + - 回退一步,撤销 $3$,撤销 $2$,尝试 $3$ 作为第二个数字,剩下只能选 $2$,得到排列 $[1, 3, 2]$。 -总结一下全排列的回溯过程: +2. 回退到最初,撤销 $1$,尝试以 $2$ 开头: + - 接下来可选数字为 $1$ 和 $3$。 + - 选择 $1$ 作为第二个数字,剩下只能选 $3$,得到排列 $[2, 1, 3]$。 + - 回退一步,撤销 $3$,撤销 $1$,尝试 $3$ 作为第二个数字,剩下只能选 $1$,得到排列 $[2, 3, 1]$。 -- **按顺序枚举每一位上可能出现的数字,之前已经出现的数字在接下来要选择的数字中不能再次出现。** -- 对于每一位,进行如下几步: - 1. **选择元素**:从可选元素列表中选择一个之前没有出现过的元素。 - 2. **递归搜索**:从选择的元素出发,一层层地递归搜索剩下位数,直到遇到边界条件时,不再向下搜索。 - 3. **撤销选择**:一层层地撤销之前选择的元素,转而进行另一个分支的搜索。直到完全遍历完所有可能的路径。 +3. 再回退到最初,撤销 $2$,尝试以 $3$ 开头: + - 接下来可选数字为 $1$ 和 $2$。 + - 选择 $1$ 作为第二个数字,剩下只能选 $2$,得到排列 $[3, 1, 2]$。 + - 回退一步,撤销 $2$,撤销 $1$,尝试 $2$ 作为第二个数字,剩下只能选 $1$,得到排列 $[3, 2, 1]$。 -对于上述决策过程,我们也可以用一棵决策树来表示: +简而言之,每次选择一个数字作为当前位置的元素,递归地选择下一个位置的数字。当所有数字都被选完时,得到一个完整的排列;如果发现当前选择无法继续,则回退到上一步,尝试其他可能性。这样就能系统地枚举出所有全排列。 + +全排列的回溯过程可以简要归纳为: + +- **逐位枚举每个位置可能出现的数字,且每个数字在同一排列中只出现一次。** +- 对于每一位,遵循以下步骤: + 1. **选择元素**:从当前可选的数字中,挑选一个未被使用的数字。 + 2. **递归探索**:将该数字加入当前路径,递归进入下一层,继续选择下一个位置的数字,直到满足终止条件(如路径长度等于数组长度)。 + 3. **撤销选择(回溯)**:递归返回后,移除刚才选择的数字,恢复现场,尝试其他未选过的数字,探索不同的分支,直到所有可能路径都被遍历。 + +上述决策过程可以用一棵决策树形象表示: ![全排列问题的决策树](https://qcdn.itcharge.cn/images/20220425102048.png) -从全排列的决策树中我们可以看出: +从全排列的决策树结构可以看出: -- 每一层中有一个或多个不同的节点,这些节点以及节点所连接的分支代表了「不同的选择」。 -- 每一个节点代表了求解全排列问题的一个「状态」,这些状态是通过「不同的值」来表现的。 -- 每向下递推一层就是在「可选元素列表」中选择一个「元素」加入到「当前状态」。 -- 当一个决策分支探索完成之后,会逐层向上进行回溯。 -- 每向上回溯一层,就是把所选择的「元素」从「当前状态」中移除,回退到没有选择该元素时的状态(或者说重置状态),从而进行其他分支的探索。 +- 每一层代表当前递归的深度,每个节点及其分支对应一次不同的选择。 +- 每个节点表示当前排列的一个「状态」,即已选择的数字序列。 +- 向下递归一层,相当于在可选数字中再选一个数字加入当前状态。 +- 当某条分支探索结束后,递归会逐层回退(回溯),撤销最近的选择,恢复到上一个状态,继续尝试其他分支。 -根据上文的思路和决策树,我们来写一下全排列的回溯算法代码(假设给定数组 $nums$ 中不存在重复元素)。则代码如下所示: +基于上述思路和决策树结构,下面给出全排列问题的回溯算法代码(假设输入数组 $nums$ 无重复元素): ```python class Solution: def permute(self, nums: List[int]) -> List[List[int]]: - res = [] # 存放所有符合条件结果的集合 - path = [] # 存放当前符合条件的结果 - def backtracking(nums): # nums 为选择元素列表 - if len(path) == len(nums): # 说明找到了一组符合条件的结果 - res.append(path[:]) # 将当前符合条件的结果放入集合中 + """ + 回溯法求解全排列问题 + :param nums: 输入的数字列表 + :return: 所有可能的全排列 + """ + res = [] # 用于存放所有符合条件的排列结果 + path = [] # 用于存放当前递归路径下的排列 + + def backtracking(): + # 递归终止条件:当 path 长度等于 nums 长度时,说明找到一个完整排列 + if len(path) == len(nums): + res.append(path[:]) # 注意要拷贝一份 path,否则后续 path 变化会影响结果 return - for i in range(len(nums)): # 枚举可选元素列表 - if nums[i] not in path: # 从当前路径中没有出现的数字中选择 - path.append(nums[i]) # 选择元素 - backtracking(nums) # 递归搜索 - path.pop() # 撤销选择 - - backtracking(nums) + # 遍历所有可选的数字 + for i in range(len(nums)): + if nums[i] in path: + # 如果当前数字已经在 path 中,跳过,保证每个数字只出现一次 + continue + # 做选择:将当前数字加入 path + path.append(nums[i]) + # 递归进入下一层,继续选择下一个数字 + backtracking() + # 撤销选择:回退到上一步,移除最后一个数字,尝试其他分支 + path.pop() + + backtracking() return res ``` -## 3. 回溯算法的通用模板 +## 3. 回溯算法通用模板 -根据上文全排列的回溯算法代码,我们可以提炼出回溯算法的通用模板,回溯算法的通用模板代码如下所示: +结合前文全排列问题的回溯实现,我们可以总结出一套简洁高效的回溯算法通用模板,具体如下: ```python -res = [] # 存放所欲符合条件结果的集合 -path = [] # 存放当前符合条件的结果 -def backtracking(nums): # nums 为选择元素列表 - if 遇到边界条件: # 说明找到了一组符合条件的结果 - res.append(path[:]) # 将当前符合条件的结果放入集合中 +res = [] # 存放所有符合条件结果的集合 +path = [] # 存放当前递归路径下的结果 + +def backtracking(nums): + """ + 回溯算法通用模板 + :param nums: 可选元素列表 + """ + # 递归终止条件:根据具体问题设定(如 path 满足特定条件) + if 满足结束条件: # 例如:len(path) == len(nums) + res.append(path[:]) # 注意要拷贝一份 path,避免后续修改影响结果 return - for i in range(len(nums)): # 枚举可选元素列表 - path.append(nums[i]) # 选择元素 - backtracking(nums) # 递归搜索 - path.pop() # 撤销选择 + # 遍历所有可选的元素 + for i in range(len(nums)): + # 可选:根据具体问题添加剪枝条件,如元素不能重复选取 + # if nums[i] in path: + # continue + + path.append(nums[i]) # 做选择,将当前元素加入 path + backtracking(nums) # 递归,继续选择下一个元素 + path.pop() # 撤销选择,回退到上一步状态 +# 调用回溯函数,开始搜索 backtracking(nums) ``` -## 4. 回溯算法三步走 - -网络上给定的回溯算法解题步骤比较抽象,这里只做一下简单介绍。 +## 4. 回溯算法的基本步骤 -1. **根据所给问题,定义问题的解空间**:要定义合适的解空间,包括解的组织形式和显约束。 - - **解的组织形式**:将解的组织形式都规范为⼀个 $n$ 元组 ${x_1, x_2 …, x_n}$。 - - **显约束**:对解分量的取值范围的限定,可以控制解空间的大小。 -2. **确定解空间的组织结构**:解空间的组织结构通常以解空间树的方式形象地表达,根据解空间树的不同,解空间分为⼦集树、排列树、$m$ 叉树等。 -3. **搜索解空间**:按照深度优先搜索策略,根据隐约束(约束函数和限界函数),在解空间中搜索问题的可⾏解或最优解。当发现当 前节点不满⾜求解条件时,就回溯,尝试其他路径。 - - 如果问题只是求可⾏解,则只需设定约束函数即可,如果要求最优解,则需要设定约束函数和限界函数。 +回溯算法的核心思想是:**通过深度优先搜索,不断尝试所有可能的选择,当发现当前路径不满足条件时就回退(回溯),尝试其他路径,最终找到所有可行解或最优解。** -这种回溯算法的解题步骤太过于抽象,不利于我们在日常做题时进行思考。其实在递归算法知识的相关章节中,我们根据递归的基本思想总结了递归三步走的书写步骤。同样,根据回溯算法的基本思想,我们也来总结一下回溯算法三步走的书写步骤。 +回溯算法的基本步骤如下: -回溯算法的基本思想是:**以深度优先搜索的方式,根据产生子节点的条件约束,搜索问题的解。当发现当前节点已不满足求解条件时,就「回溯」返回,尝试其他的路径。** - -那么,在写回溯算法时,我们可以按照这个思想来书写回溯算法,具体步骤如下: - -1. **明确所有选择**:画出搜索过程的决策树,根据决策树来确定搜索路径。 -2. **明确终止条件**:推敲出递归的终止条件,以及递归终止时的要执行的处理方法。 -3. **将决策树和终止条件翻译成代码**: - 1. 定义回溯函数(明确函数意义、传入参数、返回结果等)。 - 2. 书写回溯函数主体(给出约束条件、选择元素、递归搜索、撤销选择部分)。 - 3. 明确递归终止条件(给出递归终止条件,以及递归终止时的处理方法)。 +1. **明确所有选择**:画出决策树,理清每一步有哪些可选项。每个节点的分支代表一次选择。 +2. **明确终止条件**:终止条件通常是递归到某一深度、遍历完所有元素或满足题目要求。到达终止条件时,处理当前结果(如加入答案集)。 +3. **将决策树和终止条件转化为代码**: + - 定义回溯函数(明确函数意义、传入参数、返回结果等)。 + - 书写回溯函数主体(给出约束条件、选择元素、递归搜索、撤销选择部分)。 + - 明确递归终止条件(给出递归终止条件,以及递归终止时的处理方法)。 ### 4.1 明确所有选择 @@ -115,51 +135,61 @@ backtracking(nums) ### 4.2 明确终止条件 -回溯算法的终止条件也就是决策树的底层,即达到无法再做选择的条件。 - -回溯函数的终止条件一般为给定深度、叶子节点、非叶子节点(包括根节点)、所有节点等。并且还要给出在终止条件下的处理方法,比如输出答案,将当前符合条件的结果放入集合中等等。 +回溯算法的终止条件,通常对应于决策树的叶子节点,即到达无法继续做选择的位置。 -### 4.3 将决策树和终止条件翻译成代码 +常见的终止条件包括:递归达到指定深度、遍历到叶子节点、遍历完所有元素等。此时需要对当前路径进行处理,例如将符合要求的结果加入答案集合,或输出当前解等。 -在明确所有选择和明确终止条件之后,我们就可以将其翻译成代码了。这一步也可以分为 $3$ 步来做: - -1. 定义回溯函数(明确函数意义、传入参数、返回结果等)。 -2. 书写回溯函数主体(给出约束条件、选择元素、递归搜索、撤销选择部分)。 -3. 明确递归终止条件(给出递归终止条件,以及递归终止时的处理方法)。 +### 4.3 将决策树和终止条件转化为代码 #### 4.3.1 定义回溯函数 -在定义回溯函数时,一定要明确递归函数的意义,也就是要明白这个问题的传入参数和全局变量是什么,最终返回的结果是要解决的什么问题。 - -- **传入参数和全局变量**:是由递归搜索阶段时的「当前状态」来决定的。最好是能通过传入参数和全局变量直接记录「当前状态」。 +在定义回溯函数时,首先要清晰地界定递归函数的含义:即该函数的参数、全局变量分别代表什么,以及最终希望通过递归解决什么问题。 -比如全排列中,`backtracking(nums)` 这个函数的传入参数是 $nums$(可选择的元素列表),全局变量是 $res$(存放所有符合条件结果的集合数组)和 $path$(存放当前符合条件的结果)。$nums$ 表示当前可选的元素,$path$ 用于记录递归搜索阶段的「当前状态」。$res$ 则用来保存递归搜索阶段的「所有状态」。 +- **参数与全局变量设计**:参数和全局变量应能完整表达递归过程中的「当前状态」。通常,参数用于传递当前可选的元素、已做出的选择等信息,全局变量则用于收集所有满足条件的解。 -- **返回结果**:返回结果是在遇到递归终止条件时,需要向上一层函数返回的信息。 +以全排列问题为例,`backtracking(nums)` 的参数 $nums$ 表示当前可选的元素列表,全局变量 $path$ 记录当前递归路径(已选择的元素),$res$ 用于存储所有符合条件的结果。这样,$nums$ 反映可选空间,$path$ 反映当前状态,$res$ 汇总所有解。 -一般回溯函数的返回结果都是单个节点或单个数值,告诉上一层函数我们当前的搜索结果是什么即可。 +- **返回值设计**:回溯函数的返回值通常用于在递归终止时向上一层传递结果。大多数情况下,回溯函数只需返回单个节点或数值,表明当前搜索的结果。 -当然,如果使用全局变量来保存「当前状态」的话,也可以不需要向上一层函数返回结果,即返回空结果。比如上文中的全排列。 +如果采用全局变量(如 $res$)来收集所有解,则回溯函数可以不显式返回结果,直接 return 即可。例如全排列问题中,递归终止时将 $path$ 加入 $res$,无需返回值。 #### 4.3.2 书写回溯函数主体 -根据当前可选择的元素列表、给定的约束条件(例如之前已经出现的数字在接下来要选择的数字中不能再次出现)、存放当前状态的变量,我们就可以写出回溯函数的主体部分了。即: +结合当前可选元素、题目约束(如某元素不可重复选择)、以及用于记录当前路径的变量,我们即可编写回溯函数的核心主体部分。即: ```python -for i in range(len(nums)): # 枚举可选元素列表 - if 满足约束条件: # 约束条件 - path.append(nums[i]) # 选择元素 - backtracking(nums) # 递归搜索 - path.pop() # 撤销选择 +for 选择 in 可选列表: + if 满足约束: + 做选择 + backtrack(新参数) + 撤销选择 ``` #### 4.3.3 明确递归终止条件 -这一步其实就是将「4.2 明确终止条件」章节中的递归终止条件和终止条件下的处理方法转换为代码中的条件语句和对应的执行语句。 +这一环节的本质,是将「4.2 明确终止条件」中分析得到的递归终止条件及其对应的处理逻辑,具体实现为代码中的判断语句和相应的操作。例如,判断是否达到递归深度或满足题目要求,并在满足时将当前结果加入答案集等。 + +#### 4.3.4 回溯函数通用模板 + +通过上述三步分析,我们可以归纳出回溯算法的核心流程:**首先枚举所有可选项,然后判断是否满足终止条件,最后递归深入,并在必要时撤销选择进行回溯**。这种结构化的思考方式,使回溯算法能够高效地解决组合、排列、子集等典型问题。接下来,我们将结合具体例题,进一步体会回溯算法在实际问题中的应用与实现。 + +回溯通用模板如下: + +```python +def backtrack(参数): + if 终止条件: + 处理结果 + return + for 选择 in 可选列表: + if 满足约束: + 做选择 + backtrack(新参数) + 撤销选择 +``` ## 5. 回溯算法的应用 -### 5.1 子集 +### 5.1 经典例题:子集 #### 5.1.1 题目链接 @@ -197,62 +227,77 @@ for i in range(len(nums)): # 枚举可选元素列表 ##### 思路 1:回溯算法 -数组的每个元素都有两个选择:选与不选。 +对于数组中的每个元素,都有「选择」或「不选择」两种可能。 -我们可以通过向当前子集数组中添加可选元素来表示选择该元素。也可以在当前递归结束之后,将之前添加的元素从当前子集数组中移除(也就是回溯)来表示不选择该元素。 +我们可以通过将元素加入当前子集(path)来表示「选择」,递归结束后再将其移除(即回溯),从而实现「撤销选择」,表示「不选择」该元素。 -下面我们根据回溯算法三步走,写出对应的回溯算法。 +下面结合回溯算法的三大步骤,梳理子集问题的解题思路: ![子集的决策树](https://qcdn.itcharge.cn/images/20220425210640.png) -1. **明确所有选择**:根据数组中每个位置上的元素选与不选两种选择,画出决策树,如上图所示。 +1. **明确所有选择**:对于数组的每个位置,都可以选择是否将该元素加入当前子集。决策树的每一层对应一个元素的选择与否。 2. **明确终止条件**: - - 当遍历到决策树的叶子节点时,就终止了。即当前路径搜索到末尾时,递归终止。 -3. **将决策树和终止条件翻译成代码**: + - 当递归遍历到数组末尾(即所有元素都被考虑过)时,递归终止。 +3. **将思路转化为代码实现**: 1. 定义回溯函数: - - `backtracking(nums, index):` 函数的传入参数是 $nums$(可选数组列表)和 $index$(代表当前正在考虑元素是 $nums[i]$),全局变量是 $res$(存放所有符合条件结果的集合数组)和 $path$(存放当前符合条件的结果)。 - - `backtracking(nums, index):` 函数代表的含义是:在选择 $nums[index]$ 的情况下,递归选择剩下的元素。 - 2. 书写回溯函数主体(给出选择元素、递归搜索、撤销选择部分)。 - - 从当前正在考虑元素,到数组结束为止,枚举出所有可选的元素。对于每一个可选元素: - - 约束条件:之前选过的元素不再重复选用。每次从 $index$ 位置开始遍历而不是从 $0$ 位置开始遍历就是为了避免重复。集合跟全排列不一样,子集中 ${1, 2}$ 和 ${2, 1}$ 是等价的。为了避免重复,我们之前考虑过的元素,就不再重复考虑了。 - - 选择元素:将其添加到当前子集数组 $path$ 中。 - - 递归搜索:在选择该元素的情况下,继续递归考虑下一个位置上的元素。 - - 撤销选择:将该元素从当前子集数组 $path$ 中移除。 + - `backtracking(nums, index)`,其中 $nums$ 是原始数组,$index$ 表示当前递归到的元素下标。全局变量 $res$ 用于存储所有子集结果,$path$ 用于存储当前子集路径。 + - 该函数的含义是:从 $index$ 开始,依次尝试将后续元素加入子集,递归搜索所有可能的组合。 + 2. 编写回溯主体逻辑(选择、递归、回溯): + - 从 $index$ 开始,依次枚举每个可选元素。对于每个元素: + - **去重约束**:每次递归都从 $index$ 开始,避免重复选择已考虑过的元素,保证子集不重复(如 {1,2} 和 {2,1} 视为同一子集)。 + - **选择**:将当前元素加入 $path$。 + - **递归**:递归进入下一层,继续选择下一个元素。 + - **回溯**:递归返回后,移除刚刚加入的元素,恢复现场,尝试其他分支。 ```python - for i in range(index, len(nums)): # 枚举可选元素列表 - path.append(nums[i]) # 选择元素 - backtracking(nums, i + 1) # 递归搜索 - path.pop() # 撤销选择 + # 从当前下标开始,依次尝试选择每个元素 + for i in range(index, len(nums)): + path.append(nums[i]) # 选择当前元素,加入子集 + backtracking(i + 1) # 递归,继续选择下一个元素 + path.pop() # 撤销选择,回溯到上一步 ``` - 3. 明确递归终止条件(给出递归终止条件,以及递归终止时的处理方法)。 - - 当遍历到决策树的叶子节点时,就终止了。也就是当正在考虑的元素位置到达数组末尾(即 $start \ge len(nums)$)时,递归停止。 - - 从决策树中也可以看出,子集需要存储的答案集合应该包含决策树上所有的节点,应该需要保存递归搜索的所有状态。所以无论是否达到终止条件,我们都应该将当前符合条件的结果放入到集合中。 + 3. 明确递归终止条件及结果处理: + - 每次进入回溯函数时,都将当前 $path$ 加入结果集 $res$,因为子集问题需要收集所有状态(包括中间状态和叶子节点)。 + - 当 $index \ge len(nums)$ 时,递归自然终止,无需额外处理。 + +简而言之,回溯法通过「选择 - 递归 - 回溯」三步,系统地枚举所有子集,并通过合理的约束避免重复。 ##### 思路 1:代码 ```python class Solution: def subsets(self, nums: List[int]) -> List[List[int]]: - res = [] # 存放所有符合条件结果的集合 - path = [] # 存放当前符合条件的结果 - def backtracking(nums, index): # 正在考虑可选元素列表中第 index 个元素 - res.append(path[:]) # 将当前符合条件的结果放入集合中 - if index >= len(nums): # 遇到终止条件(本题) + """ + 回溯法求解子集问题。 + :param nums: 输入数组 + :return: 所有子集的列表 + """ + res = [] # 用于存放所有子集结果 + path = [] # 用于存放当前递归路径上的子集 + + def backtracking(index: int): + """ + 回溯函数,递归枚举所有子集。 + :param index: 当前递归到的元素下标 + """ + # 每次进入回溯函数,都将当前路径(子集)加入结果集 + res.append(path[:]) + # 递归终止条件:index 超过数组长度时返回 + if index >= len(nums): return + # 从当前下标开始,依次尝试选择每个元素 + for i in range(index, len(nums)): + path.append(nums[i]) # 选择当前元素,加入子集 + backtracking(i + 1) # 递归,继续选择下一个元素 + path.pop() # 撤销选择,回溯到上一步 - for i in range(index, len(nums)): # 枚举可选元素列表 - path.append(nums[i]) # 选择元素 - backtracking(nums, i + 1) # 递归搜索 - path.pop() # 撤销选择 - - backtracking(nums, 0) + backtracking(0) # 从下标 0 开始递归 return res ``` ##### 思路 1:复杂度分析 -- **时间复杂度**:$O(n \times 2^n)$,其中 $n$ 指的是数组 $nums$ 的元素个数,$2^n$ 指的是所有状态数。每种状态需要 $O(n)$ 的时间来构造子集。 -- **空间复杂度**:$O(n)$,每种状态下构造子集需要使用 $O(n)$ 的空间。 +- **时间复杂度**:$O(n \times 2^n)$。其中 $n$ 是数组 $nums$ 的元素个数。回溯过程中,每个元素有选与不选两种状态,共 $2^n$ 种子集,每生成一个子集需要 $O(n)$ 的时间(因为要拷贝 path 到结果集)。 +- **空间复杂度**:$O(n)$。递归过程中 path 最深为 $n$,递归栈空间也是 $O(n)$。 ### 5.2 N 皇后 @@ -288,94 +333,91 @@ class Solution: ##### 思路 1:回溯算法 -这道题是经典的回溯问题。我们可以按照行序来放置皇后,也就是先放第一行,再放第二行 …… 一直放到最后一行。 - -对于 $n \times n$ 的棋盘来说,每一行有 $n$ 列,也就有 $n$ 种放法可供选择。我们可以尝试选择其中一列,查看是否与之前放置的皇后有冲突,如果没有冲突,则继续在下一行放置皇后。依次类推,直到放置完所有皇后,并且都不发生冲突时,就得到了一个合理的解。 +本题是回溯算法的经典应用。我们按照「逐行放置皇后」的顺序进行搜索:即先在第 1 行放皇后,再到第 2 行,依次递归,直到最后一行。 -并且在放置完之后,通过回溯的方式尝试其他可能的分支。 +对于 $n \times n$ 的棋盘,每一行有 $n$ 个位置可选。每次尝试将皇后放在当前行的某一列,并判断该位置是否与之前已放置的皇后冲突(即是否在同一列、主对角线、副对角线上)。如果不冲突,则递归进入下一行继续放置;若冲突,则跳过该位置,尝试下一列。所有皇后都成功放置后,即得到一个有效解。回溯算法会自动探索所有可能的分支,确保所有解都被枚举。 -下面我们根据回溯算法三步走,写出对应的回溯算法。 +下面结合回溯算法的“三步走”思想,梳理 N 皇后问题的解题流程: ![](https://qcdn.itcharge.cn/images/20220426095225.png) -1. **明确所有选择**:根据棋盘中当前行的所有列位置上是否选择放置皇后,画出决策树,如上图所示。 +1. **明确所有选择**:对于当前行,依次尝试将皇后放在每一列的不同位置,每个位置都代表一次选择,整个过程可用决策树表示(如上图)。 2. **明确终止条件**: - - 当遍历到决策树的叶子节点时,就终止了。也就是在最后一行放置完皇后时,递归终止。 -3. **将决策树和终止条件翻译成代码:** + - 当所有行都已成功放置皇后(即递归到第 $n$ 行),说明找到一个有效解,此时递归终止。 +3. **将决策树和终止条件转化为代码实现:** 1. 定义回溯函数: - - 首先我们先使用一个 $n \times n$ 大小的二维矩阵 $chessboard$ 来表示当前棋盘,$chessboard$ 中的字符 `Q` 代表皇后,`.` 代表空位,初始都为 `.`。 - - 然后定义回溯函数 `backtrack(chessboard, row): ` 函数的传入参数是 $chessboard$(棋盘数组)和 $row$(代表当前正在考虑放置第 $row$ 行皇后),全局变量是 $res$(存放所有符合条件结果的集合数组)。 - - `backtrack(chessboard, row):` 函数代表的含义是:在放置好第 $row$ 行皇后的情况下,递归放置剩下行的皇后。 - 2. 书写回溯函数主体(给出选择元素、递归搜索、撤销选择部分)。 - - 枚举出当前行所有的列。对于每一列位置: - - 约束条件:定义一个判断方法,先判断一下当前位置是否与之前棋盘上放置的皇后发生冲突,如果不发生冲突则继续放置,否则则继续向后遍历判断。 - - 选择元素:选择 $row, col$ 位置放置皇后,将其棋盘对应位置设置为 `Q`。 - - 递归搜索:在该位置放置皇后的情况下,继续递归考虑下一行。 - - 撤销选择:将棋盘上 $row, col$ 位置设置为 `.`。 - ```python - # 判断当前位置 row, col 是否与之前放置的皇后发生冲突 - def isValid(self, n: int, row: int, col: int, chessboard: List[List[str]]): - for i in range(row): - if chessboard[i][col] == 'Q': - return False - - i, j = row - 1, col - 1 - while i >= 0 and j >= 0: - if chessboard[i][j] == 'Q': - return False - i -= 1 - j -= 1 - i, j = row - 1, col + 1 - while i >= 0 and j < n: - if chessboard[i][j] == 'Q': - return False - i -= 1 - j += 1 - - return True - ``` - + - 使用一个 $n \times n$ 的二维数组 $chessboard$ 表示棋盘,`Q` 表示皇后,`.` 表示空位,初始均为 `.`。 + - 定义回溯函数 `backtrack(chessboard, row)`,其中 $chessboard$ 为当前棋盘状态,$row$ 表示当前正在处理的行,全局变量 $res$ 用于收集所有可行解。 + - `backtrack(chessboard, row)` 的含义是:在前 $row-1$ 行已放置皇后的前提下,递归尝试为第 $row$ 行放置皇后。 + 2. 编写回溯函数主体(包括选择、递归、撤销选择): + - 遍历当前行的每一列,对于每个位置: + - 约束条件:通过辅助函数判断当前位置是否与已放置的皇后冲突,若无冲突则继续,否则跳过。 + - 选择:在 $row, col$ 位置放置皇后(即 $chessboard[row][col] = 'Q'$)。 + - 递归:递归处理下一行($row+1$)。 + - 撤销选择:回溯时将 $chessboard[row][col]$ 恢复为 `.`,以便尝试其他方案。 ```python - for col in range(n): # 枚举可放置皇后的列 - if self.isValid(n, row, col, chessboard): # 如果该位置与之前放置的皇后不发生冲突 - chessboard[row][col] = 'Q' # 选择 row, col 位置放置皇后 - backtrack(row + 1, chessboard) # 递归放置 row + 1 行之后的皇后 - chessboard[row][col] = '.' # 撤销选择 row, col 位置 + # 枚举当前行的每一列,尝试放置皇后 + for col in range(n): + if self.isValid(n, row, col, chessboard): # 检查当前位置是否合法 + chessboard[row][col] = 'Q' # 放置皇后 + self.backtrack(n, row + 1, chessboard) # 递归处理下一行 + chessboard[row][col] = '.' # 撤销选择,回溯 ``` - 3. 明确递归终止条件(给出递归终止条件,以及递归终止时的处理方法)。 - - 当遍历到决策树的叶子节点时,就终止了。也就是在最后一行放置完皇后(即 $row == n$)时,递归停止。 - - 递归停止时,将当前符合条件的棋盘转换为答案需要的形式,然后将其存入答案数组 $res$ 中即可。 + 3. 明确递归终止条件(即何时递归应当结束,以及结束时如何处理结果)。 + - 当递归到第 $n$ 行(即 $row == n$)时,说明所有皇后已成功放置,此时到达决策树的叶子节点,递归终止。 + - 终止时,将当前棋盘状态转换为题目要求的格式,并加入结果集 $res$。 ##### 思路 1:代码 ```python class Solution: - res = [] + res = [] # 用于存储所有可行解 + def backtrack(self, n: int, row: int, chessboard: List[List[str]]): + """ + 回溯主函数:在第 row 行尝试放置皇后 + :param n: 棋盘大小(n x n) + :param row: 当前递归到的行号 + :param chessboard: 当前棋盘状态 + """ if row == n: + # 递归终止条件:所有行都已放置皇后,记录当前棋盘方案 temp_res = [] for temp in chessboard: - temp_str = ''.join(temp) + temp_str = ''.join(temp) # 将每一行转为字符串 temp_res.append(temp_str) self.res.append(temp_res) return + # 枚举当前行的每一列,尝试放置皇后 for col in range(n): - if self.isValid(n, row, col, chessboard): - chessboard[row][col] = 'Q' - self.backtrack(n, row + 1, chessboard) - chessboard[row][col] = '.' + if self.isValid(n, row, col, chessboard): # 检查当前位置是否合法 + chessboard[row][col] = 'Q' # 放置皇后 + self.backtrack(n, row + 1, chessboard) # 递归处理下一行 + chessboard[row][col] = '.' # 撤销选择,回溯 def isValid(self, n: int, row: int, col: int, chessboard: List[List[str]]): + """ + 检查在 (row, col) 位置放置皇后是否合法 + :param n: 棋盘大小 + :param row: 当前行 + :param col: 当前列 + :param chessboard: 当前棋盘状态 + :return: True 表示合法,False 表示冲突 + """ + # 检查同一列是否有皇后 for i in range(row): if chessboard[i][col] == 'Q': return False + # 检查左上对角线是否有皇后 i, j = row - 1, col - 1 while i >= 0 and j >= 0: if chessboard[i][j] == 'Q': return False i -= 1 j -= 1 + + # 检查右上对角线是否有皇后 i, j = row - 1, col + 1 while i >= 0 and j < n: if chessboard[i][j] == 'Q': @@ -383,12 +425,18 @@ class Solution: i -= 1 j += 1 - return True + return True # 没有冲突,可以放置 def solveNQueens(self, n: int) -> List[List[str]]: - self.res.clear() + """ + 主入口函数,返回所有 N 皇后问题的解 + :param n: 棋盘大小 + :return: 所有可行解的列表 + """ + self.res.clear() # 清空历史结果 + # 初始化棋盘,全部填充为 '.' chessboard = [['.' for _ in range(n)] for _ in range(n)] - self.backtrack(n, 0, chessboard) + self.backtrack(n, 0, chessboard) # 从第 0 行开始回溯 return self.res ``` @@ -397,6 +445,16 @@ class Solution: - **时间复杂度**:$O(n!)$,其中 $n$ 是皇后数量。 - **空间复杂度**:$O(n^2)$,其中 $n$ 是皇后数量。递归调用层数不会超过 $n$,每个棋盘的空间复杂度为 $O(n^2)$,所以空间复杂度为 $O(n^2)$。 +## 6. 总结 + +回溯算法是一种通过递归和试错来系统地搜索所有可能解的算法。其核心思想是「走不通就退回,换条路再试」,通过深度优先搜索的方式遍历决策树,当发现当前路径无法满足条件时,会撤销上一步的选择并尝试其他可能性。 + +回溯算法的关键在于「选择 - 递归 - 回溯」:首先做出选择,然后递归进入下一层继续搜索,最后在递归返回时撤销选择,恢复到之前的状态。这种机制使得算法能够穷尽所有可能的解空间,特别适用于需要枚举所有可能解的问题。 + +回溯算法在解决组合、排列、子集等经典问题中表现出色,如全排列、N皇后、子集生成等。其通用模板简洁明了,通过明确所有选择、确定终止条件、转化为代码实现三个步骤,可以高效地解决各种回溯类问题。 + +虽然回溯算法能够保证找到所有解,但其时间复杂度通常较高,特别是在解空间较大时。因此,在实际应用中需要结合剪枝等优化技巧来提高效率,避免不必要的搜索路径。 + ## 练习题目 - [0046. 全排列](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0001-0099/permutations.md) diff --git a/docs/07_algorithm/07_05_greedy_algorithm.md b/docs/07_algorithm/07_05_greedy_algorithm.md index 0691a88c..7ccc9521 100644 --- a/docs/07_algorithm/07_05_greedy_algorithm.md +++ b/docs/07_algorithm/07_05_greedy_algorithm.md @@ -2,77 +2,68 @@ ### 1.1 贪心算法的定义 -> **贪心算法(Greedy Algorithm)**:一种在每次决策时,总是采取在当前状态下的最好选择,从而希望导致结果是最好或最优的算法。 +> **贪心算法(Greedy Algorithm)**:每一步都选择当前最优(看起来最好的)方案,期望通过一系列局部最优,最终获得全局最优解。 -贪心算法是一种改进的「分步解决算法」,其核心思想是:将求解过程分成「若干个步骤」,然后根据题意选择一种「度量标准」,每个步骤都应用「贪心原则」,选取当前状态下「最好 / 最优选择(局部最优解)」,并以此希望最后得出的结果也是「最好 / 最优结果(全局最优解)」。 +贪心算法的核心思想是:将问题分解为若干步骤,每一步都根据当前情况,按照某种标准选择最优解(即「贪心」选择),不回头、不考虑整体,只关注当前最优。这样可以避免穷举所有可能,大大简化求解过程。 -换句话说,贪心算法不从整体最优上加以考虑,而是一步一步进行,每一步只以当前情况为基础,根据某个优化测度做出局部最优选择,从而省去了为找到最优解要穷举所有可能所必须耗费的大量时间。 +简而言之,贪心算法每次只做出当前看来最优的选择,期望通过一系列这样的选择得到整体最优解。 ### 1.2 贪心算法的特征 -对许多问题来说,可以使用贪心算法,通过局部最优解而得到整体最优解或者是整体最优解的近似解。但并不是所有问题,都可以使用贪心算法的。 +贪心算法适用于一类特殊问题:只要每一步都做出当前最优选择,最终就能得到整体最优解或近似最优解。但并非所有问题都适用贪心算法。 -一般来说,这些能够使用贪心算法解决的问题必须满足下面的两个特征: +通常,能用贪心算法解决的问题需同时满足两个条件: -1. **贪⼼选择性质** +1. **贪心选择性质** 2. **最优子结构** #### 1.2.1 贪心选择性质 -> **贪心选择性质**:指的是一个问题的全局最优解可以通过一系列局部最优解(贪心选择)来得到。 +> **贪心选择性质**:全局最优解可以通过一系列局部最优(贪心)选择获得。 -换句话说,当进行选择时,我们直接做出在当前问题中看来最优的选择,而不用去考虑子问题的解。在做出选择之后,才会去求解剩下的子问题,如下图所示。 +也就是说,每次只需关注当前最优选择,无需关心子问题的解。做出选择后,再递归处理剩下的子问题。 ![贪心选择性质](https://qcdn.itcharge.cn/images/20240513163300.png) -贪心算法在进行选择时,可能会依赖之前做出的选择,但不会依赖任何将来的选择或是子问题的解。运用贪心算法解决的问题在程序的运行过程中无回溯过程。 +贪心算法的每一步可能依赖之前的选择,但不会回溯,也不依赖未来的选择或子问题的解。 #### 1.2.2 最优子结构性质 -> **最优子结构性质**:指的是一个问题的最优解包含其子问题的最优解。 +> **最优子结构性质**:问题的最优解包含其子问题的最优解。 -问题的最优子结构性质是该问题能否用贪心算法求解的关键。 - -举个例子,如下图所示,原问题 $S = \lbrace a_1, a_2, a_3, a_4 \rbrace$,在 $a_1$ 步我们通过贪心选择选出一个当前最优解之后,问题就转换为求解子问题 $S_{\text{子问题}} = \lbrace a_2, a_3, a_4 \rbrace$。如果原问题 $S$ 的最优解可以由「第 $a_1$ 步通过贪心选择的局部最优解」和「 $S_{\text{子问题}}$ 的最优解」构成,则说明该问题满足最优子结构性质。 - -也就是说,如果原问题的最优解包含子问题的最优解,则说明该问题满足最优子结构性质。 +这是贪心算法成立的关键。举例来说,假设原问题 $S = \lbrace a_1, a_2, a_3, a_4 \rbrace$,第一步通过贪心选择得到当前最优解,剩下的子问题 $S_{\text{子问题}} = \lbrace a_2, a_3, a_4 \rbrace$。如果原问题的最优解等于「当前贪心选择」加上「子问题的最优解」,则满足最优子结构。 ![最优子结构性质](https://qcdn.itcharge.cn/images/20240513163310.png) -在做了贪心选择后,满足最优子结构性质的原问题可以分解成规模更小的类似子问题来解决,并且可以通过贪心选择和子问题的最优解推导出问题的最优解。 - -反之,如果不能利用子问题的最优解推导出整个问题的最优解,那么这种问题就不具有最优子结构。 +如果原问题的最优解可以由子问题的最优解推导出来,则说明满足最优子结构;反之,则不满足,不能用贪心算法。 -### 1.3 贪心算法正确性的证明 +### 1.3 贪心算法正确性简述 -贪心算法最难的部分不在于问题的求解,而在于是正确性的证明。我们常用的证明方法有「数学归纳法」和「交换论证法」。 +贪心算法的难点在于如何证明其选择策略能得到全局最优解。常见的两种证明方法: -> - **数学归纳法**:先计算出边界情况(例如 $n = 1$)的最优解,然后再证明对于每个 $n$,$F_{n + 1}$ 都可以由 $F_n$ 推导出。 -> -> - **交换论证法**:从最优解出发,在保证全局最优不变的前提下,如果交换方案中任意两个元素 / 相邻的两个元素后,答案不会变得更好,则可以推定目前的解是最优解。 +> - **数学归纳法**:先验证最小规模(如 $n = 1$)时成立,再证明 $n$ 成立时 $n + 1$ 也成立。 +> - **交换论证法**:假设存在更优解,通过交换局部选择,如果不会得到更优结果,则当前贪心解为最优。 -判断一个问题是否通过贪心算法求解,是需要进行严格的数学证明的。但是在日常写题或者算法面试中,不太会要求大家去证明贪心算法的正确性。 +实际刷题或面试时,通常不要求严格证明。判断是否可用贪心算法,可以通过: -所以,当我们想要判断一个问题是否通过贪心算法求解时,我们可以: +1. **直觉尝试**:先用贪心思路做一遍,看看局部最优能否推出全局最优。 +2. **举反例**:尝试构造反例,如果找不到局部最优导致全局最优失败的例子,基本可以用贪心法。 -1. **凭直觉**:如果感觉这道题可以通过「贪心算法」去做,就尝试找到局部最优解,再推导出全局最优解。 -2. **举反例**:尝试一下,举出反例。也就是说找出一个局部最优解推不出全局最优解的例子,或者找出一个替换当前子问题的最优解,可以得到更优解的例子。如果举不出反例,大概率这道题是可以通过贪心算法求解的。 +## 2. 贪心算法三步走 -## 3. 贪心算法三步走 +1. **问题转化**:将原始优化问题转化为可以应用贪心策略的问题,明确每一步都可以做出一个局部最优的选择。 +2. **贪心策略制定**:结合题意,选定合适的度量标准,设计出每一步的贪心选择规则,即在当前状态下选择最优(最有利)的方案,获得局部最优解。 +3. **最优子结构利用**:保证每次贪心选择后,剩余子问题仍满足同样的结构和贪心选择性质,将每一步的局部最优解累积,最终合成原问题的全局最优解。 -1. **转换问题**:将优化问题转换为具有贪心选择性质的问题,即先做出选择,再解决剩下的一个子问题。 -2. **贪心选择性质**:根据题意选择一种度量标准,制定贪心策略,选取当前状态下「最好 / 最优选择」,从而得到局部最优解。 -3. **最优子结构性质**:根据上一步制定的贪心策略,将贪心选择的局部最优解和子问题的最优解合并起来,得到原问题的最优解。 +## 3. 贪心算法的应用 -## 4. 贪心算法的应用 +### 3.1 经典例题:分发饼干 -### 4.1 分发饼干 - -#### 4.1.1 题目链接 +#### 3.1.1 题目链接 - [455. 分发饼干 - 力扣](https://leetcode.cn/problems/assign-cookies/) -#### 4.1.2 题目大意 +#### 3.1.2 题目大意 **描述**:一位很棒的家长为孩子们分发饼干。对于每个孩子 $i$,都有一个胃口值 $g[i]$,即每个小孩希望得到饼干的最小尺寸值。对于每块饼干 $j$,都有一个尺寸值 $s[j]$。只有当 $s[j] > g[i]$ 时,我们才能将饼干 $j$ 分配给孩子 $i$。每个孩子最多只能给一块饼干。 @@ -104,46 +95,49 @@ 解释: 你有两个孩子和三块小饼干,2个孩子的胃口值分别是1, 2。你拥有的饼干数量和尺寸都足以让所有孩子满足。所以你应该输出 2。 ``` -#### 4.1.3 解题思路 +#### 3.1.3 解题思路 ##### 思路 1:贪心算法 -为了尽可能的满⾜更多的⼩孩,而且一块饼干不能掰成两半,所以我们应该尽量让胃口小的孩子吃小块饼干,这样胃口大的孩子才有大块饼干吃。 +为了让尽可能多的孩子得到满足,且每块饼干只能分配给一个孩子,我们应优先用小尺寸的饼干满足胃口较小的孩子,将大尺寸的饼干留给胃口较大的孩子。 -所以,从贪心算法的角度来考虑,我们应该按照孩子的胃口从小到大对数组 $g$ 进行排序,然后按照饼干的尺寸大小从小到大对数组 $s$ 进行排序,并且对于每个孩子,应该选择满足这个孩子的胃口且尺寸最小的饼干。 +基于贪心思想,具体做法如下:先将孩子的胃口数组 $g$ 和饼干尺寸数组 $s$ 分别从小到大排序。然后,依次为每个孩子分配能够满足其胃口的最小尺寸饼干。 -下面我们使用贪心算法三步走的方法解决这道题。 +结合贪心算法的三步走: -1. **转换问题**:将原问题转变为,当胃口最小的孩子选择完满足这个孩子的胃口且尺寸最小的饼干之后,再解决剩下孩子的选择问题(子问题)。 -2. **贪心选择性质**:对于当前孩子,用尺寸尽可能小的饼干满足这个孩子的胃口。 -3. **最优子结构性质**:在上面的贪心策略下,当前孩子的贪心选择 + 剩下孩子的子问题最优解,就是全局最优解。也就是说在贪心选择的方案下,能够使得满足胃口的孩子数量达到最大。 +1. **问题转化**:将原问题转化为:每次优先用最小的饼干满足胃口最小的孩子,剩下的孩子和饼干继续按同样方式处理(即递归到子问题)。 +2. **贪心选择性质**:对于当前孩子,选择能满足其胃口的最小饼干。 +3. **最优子结构**:当前的贪心选择加上剩余子问题的最优解,能够保证全局最优,即最大化被满足的孩子数量。 -使用贪心算法的代码解决步骤描述如下: +具体实现步骤如下: -1. 对数组 $g$、$s$ 进行从小到大排序,使用变量 $index\_g$ 和 $index\_s$ 分别指向 $g$、$s$ 初始位置,使用变量 $res$ 保存结果,初始化为 $0$。 -2. 对比每个元素 $g[index\_g]$ 和 $s[index\_s]$: - 1. 如果 $g[index\_g] \le s[index\_s]$,说明当前饼干满足当前孩子胃口,则答案数量加 $1$,并且向右移动 $index\_g$ 和 $index\_s$。 - 2. 如果 $g[index\_g] > s[index\_s]$,说明当前饼干无法满足当前孩子胃口,则向右移动 $index_s$,判断下一块饼干是否可以满足当前孩子胃口。 -3. 遍历完输出答案 $res$。 +1. 对 $g$ 和 $s$ 升序排序,定义指针 $index\_g$ 和 $index\_s$ 分别指向 $g$ 和 $s$ 的起始位置,结果计数 $res$ 初始化为 $0$。 +2. 遍历两个数组,比较 $g[index\_g]$ 和 $s[index\_s]$: + 1. 如果 $g[index\_g] \le s[index\_s]$,说明当前饼干可以满足当前孩子,$res$ 加 $1$,$index\_g$ 和 $index\_s$ 同时右移。 + 2. 如果 $g[index\_g] > s[index\_s]$,说明当前饼干无法满足当前孩子,$index\_s$ 右移,尝试下一块饼干。 +3. 遍历结束后,输出 $res$ 即为最多能满足的孩子数量。 ##### 思路 1:代码 ```python class Solution: def findContentChildren(self, g: List[int], s: List[int]) -> int: + # 对孩子的胃口值和饼干尺寸进行升序排序 g.sort() s.sort() - index_g, index_s = 0, 0 - res = 0 + index_g, index_s = 0, 0 # index_g 指向当前要分配的孩子,index_s 指向当前可用的饼干 + res = 0 # 记录能满足的孩子数量 + # 遍历两个数组,直到有一个数组遍历完 while index_g < len(g) and index_s < len(s): + # 如果当前饼干可以满足当前孩子 if g[index_g] <= s[index_s]: - res += 1 - index_g += 1 - index_s += 1 + res += 1 # 满足的孩子数加一 + index_g += 1 # 指向下一个孩子 + index_s += 1 # 指向下一个饼干 else: - index_s += 1 + index_s += 1 # 当前饼干太小,尝试下一块饼干 - return res + return res # 返回最多能满足的孩子数量 ``` ##### 思路 1:复杂度分析 @@ -151,13 +145,13 @@ class Solution: - **时间复杂度**:$O(m \times \log m + n \times \log n)$,其中 $m$ 和 $n$ 分别是数组 $g$ 和 $s$ 的长度。 - **空间复杂度**:$O(\log m + \log n)$。 -### 4.2 无重叠区间 +### 3.2 经典例题:无重叠区间 -#### 4.2.1 题目链接 +#### 3.2.1 题目链接 - [435. 无重叠区间 - 力扣](https://leetcode.cn/problems/non-overlapping-intervals/) -#### 4.2.2 题目大意 +#### 3.2.2 题目大意 **描述**:给定一个区间的集合 $intervals$,其中 $intervals[i] = [starti, endi]$。从集合中移除部分区间,使得剩下的区间互不重叠。 @@ -187,42 +181,50 @@ class Solution: 解释: 你需要移除两个 [1,2] 来使剩下的区间没有重叠。 ``` -#### 4.2.3 解题思路 +#### 3.2.3 解题思路 ##### 思路 1:贪心算法 -这道题我们可以转换一下思路。原题要求保证移除区间最少,使得剩下的区间互不重叠。换个角度就是:「如何使得剩下互不重叠区间的数目最多」。那么答案就变为了:「总区间个数 - 不重叠区间的最多个数」。我们的问题也变成了求所有区间中不重叠区间的最多个数。 +本题可以通过转换思路来简化求解。原题要求移除最少数量的区间,使得剩余区间互不重叠。换句话说,就是要让剩下的互不重叠区间数量最多。因此,答案等价于「总区间数 - 最多不重叠区间数」。问题转化为:在所有区间中,最多能选出多少个互不重叠的区间。 -从贪心算法的角度来考虑,我们应该将区间按照结束时间排序。每次选择结束时间最早的区间,然后再在剩下的时间内选出最多的区间。 +采用贪心算法时,核心策略是将区间按结束时间从小到大排序。每次总是选择结束时间最早且不与已选区间重叠的区间,这样可以在后续留出更多空间,选出更多区间。 -我们用贪心三部曲来解决这道题。 +具体贪心解题步骤如下: -1. **转换问题**:将原问题转变为,当选择结束时间最早的区间之后,再在剩下的时间内选出最多的区间(子问题)。 -2. **贪心选择性质**:每次选择时,选择结束时间最早的区间。这样选出来的区间一定是原问题最优解的区间之一。 -3. **最优子结构性质**:在上面的贪心策略下,贪心选择当前时间最早的区间 + 剩下的时间内选出最多区间的子问题最优解,就是全局最优解。也就是说在贪心选择的方案下,能够使所有区间中不重叠区间的个数最多。 +1. **问题转化**:将原问题转化为「选出最多不重叠区间」。 +2. **贪心选择性质**:每次总是选择当前结束时间最早且不与已选区间重叠的区间。 +3. **最优子结构性质**:当前选择加上后续子问题的最优解,能够得到全局最优解。 -使用贪心算法的代码解决步骤描述如下: +实现流程如下: -1. 将区间集合按照结束坐标升序排列,然后维护两个变量,一个是当前不重叠区间的结束时间 $end\_pos$,另一个是不重叠区间的个数 $count$。初始情况下,结束坐标 $end\_pos$ 为第一个区间的结束坐标,$count$ 为 $1$。 -2. 依次遍历每段区间。对于每段区间:$intervals[i]$: - 1. 如果 $end\_pos \le intervals[i][0]$,即 $end\_pos$ 小于等于区间起始位置,则说明出现了不重叠区间,令不重叠区间数 $count$ 加 $1$,$end\_pos$ 更新为新区间的结束位置。 -3. 最终返回「总区间个数 - 不重叠区间的最多个数」即 $len(intervals) - count$ 作为答案。 +1. 先将所有区间按结束坐标升序排序。 +2. 维护两个变量:$end\_pos$ 表示当前已选区间的结束位置,$count$ 表示已选的不重叠区间数量。初始时,$end\_pos$ 取第一个区间的结束位置,$count=1$。 +3. 遍历后续区间,对于每个区间 $intervals[i]$: + - 若 $end\_pos \le intervals[i][0]$,说明该区间与前面已选区间不重叠,则计数 $count+1$,并更新 $end\_pos$ 为当前区间的结束位置。 +4. 最终返回 $len(intervals) - count$,即最少需要移除的区间数。 ##### 思路 1:代码 ```python class Solution: def eraseOverlapIntervals(self, intervals: List[List[int]]) -> int: + # 如果区间列表为空,直接返回 0 if not intervals: return 0 + # 按区间的结束位置从小到大排序 intervals.sort(key=lambda x: x[1]) + # 初始化第一个区间的结束位置 end_pos = intervals[0][1] + # 记录不重叠区间的数量,初始为 1(第一个区间) count = 1 + # 遍历后续区间 for i in range(1, len(intervals)): + # 如果当前区间的起始位置不小于上一个已选区间的结束位置,说明不重叠 if end_pos <= intervals[i][0]: - count += 1 - end_pos = intervals[i][1] + count += 1 # 计数加一 + end_pos = intervals[i][1] # 更新当前已选区间的结束位置 + # 总区间数减去最多不重叠区间数,即为最少需要移除的区间数 return len(intervals) - count ``` @@ -231,6 +233,17 @@ class Solution: - **时间复杂度**:$O(n \times \log n)$,其中 $n$ 是区间的数量。 - **空间复杂度**:$O(\log n)$。 +## 4. 总结 + +贪心算法是一种简单而有效的算法设计策略,其核心思想是在每一步都做出当前看起来最优的选择,期望通过一系列局部最优选择最终获得全局最优解。这种算法特别适用于具有贪心选择性质和最优子结构性质的问题。 + +贪心算法的优势在于其简洁性和高效性。相比动态规划需要存储和计算所有子问题的解,贪心算法只需要关注当前步骤的最优选择,大大降低了时间和空间复杂度。同时,贪心算法的实现通常比较简单直观,容易理解和编码。 + +然而,贪心算法也有其局限性。并非所有问题都适合使用贪心策略,只有同时满足贪心选择性质和最优子结构性质的问题才能保证得到全局最优解。对于不满足这些条件的问题,贪心算法可能只能得到局部最优解或近似解。此外,贪心算法的正确性证明往往比较复杂,需要严格的数学证明。 + +在实际应用中,贪心算法广泛应用于调度问题、图论问题、优化问题等领域。通过合理设计贪心策略,可以在保证解的质量的同时,显著提高算法的执行效率。掌握贪心算法的关键在于理解其适用条件,学会识别问题特征,并能够设计出合适的贪心选择策略。 + + ## 练习题目 - [0455. 分发饼干](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0400-0499/assign-cookies.md) diff --git a/docs/07_algorithm/07_06_bit_operation.md b/docs/07_algorithm/07_06_bit_operation.md index 52bd9791..968b933a 100644 --- a/docs/07_algorithm/07_06_bit_operation.md +++ b/docs/07_algorithm/07_06_bit_operation.md @@ -1,180 +1,180 @@ ## 1. 位运算简介 -### 1.1 位运算与二进制简介 +### 1.1 位运算与二进制基础 -> **位运算(Bit Operation)**:在计算机内部,数是以「二进制(Binary)」的形式来进行存储。位运算就是直接对数的二进制进行计算操作,在程序中使用位运算进行操作,会大大提高程序的性能。 +> **位运算(Bit Operation)**:计算机内部所有数据均以「二进制(Binary)」形式存储。位运算是直接对二进制位进行操作的运算方式,能够极大提升程序的执行效率。 -在学习二进制数的位运算之前,我们先来了解一下什么叫做「二进制数」。 +在正式学习位运算之前,先简单了解「二进制数」的基本概念。 ![二进制数](https://qcdn.itcharge.cn/images/202405132135165.png) -> **二进制数(Binary)**:由 $0$ 和 $1$ 两个数码来表示的数。二进制数中每一个 $0$ 或每一个 $1$ 都称为一个「位(Bit)」。 +> **二进制数(Binary)**:仅由 $0$ 和 $1$ 两个数字组成。二进制数中的每一位($0$ 或 $1$)称为一个「位(Bit)」。 -我们通常使用的十进制数有 $0 \sim 9$ 共 $10$ 个数字,进位规则是「满十进一」。例如: +我们日常使用的十进制数包含 $0 \sim 9$ 共 $10$ 个数字,进位规则为「满十进一」。例如: -1. $7_{(10)} + 2_{(10)} = 9_{(10)}$:$7_{(10)}$ 加上 $2_{(10)}$ 等于 $9_{(10)}$。 -2. $9_{(10)} + 2_{(10)} = 11_{(10)}$:$9_{(10)}$ 加上 $2_{(10)}$ 之后个位大于等于 $10$,符合「满十进一」,结果等于 $11_{(10)}$。 +1. $7_{(10)} + 2_{(10)} = 9_{(10)}$:$7_{(10)}$ 加 $2_{(10)}$ 得 $9_{(10)}$。 +2. $9_{(10)} + 2_{(10)} = 11_{(10)}$:$9_{(10)}$ 加 $2_{(10)}$ 后个位满 $10$ 进一,结果为 $11_{(10)}$。 -而在二进制数中,我们只有 $0$ 和 $1$ 两个数码,它的进位规则是「逢二进一」。例如: +而二进制数仅有 $0$ 和 $1$,进位规则为「逢二进一」。例如: -1. $1_{(2)} + 0_{(2)} = 1_{(2)}$:$1_{(2)}$ 加上 $0_{(2)}$ 等于 $1_{(2)}$。 -2. $1_{(2)} + 1_{(2)} = 10_{(2)}$:$1_{(2)}$ 加上 $1_{(2)}$,大于等于 $2$,符合「逢二进一」,结果等于 $10_{(2)}$。 -3. $10_{(2)} + 1_{(2)} = 11_{(2)}$。 +1. $1_{(2)} + 0_{(2)} = 1_{(2)}$:$1_{(2)}$ 加 $0_{(2)}$ 得 $1_{(2)}$。 +2. $1_{(2)} + 1_{(2)} = 10_{(2)}$:$1_{(2)}$ 加 $1_{(2)}$,满 $2$ 进一,结果为 $10_{(2)}$。 +3. $10_{(2)} + 1_{(2)} = 11_{(2)}$:$10_{(2)}$ 加 $1_{(2)}$ 得 $11_{(2)}$。 -### 1.2 二进制数的转换 +### 1.2 二进制与十进制的相互转换 -#### 1.2.1 二进制转十进制数 +#### 1.2.1 二进制转十进制 -在十进制数中,数字 $2749_{(10)}$ 可以理解为 $2 \times 1000 + 7 \times 100 + 4 \times 10 + 9 * 1$,相当于 $2 \times 10^3 + 7 \times 10^2 + 4 \times 10^1 + 9 \times 10^0$,即 $2000 + 700 + 40 + 9 = 2749_{(10)}$。 +将二进制数转为十进制,就是将每一位上的数字乘以对应的 $2$ 的幂次,然后相加。例如:十进制 $2749_{(10)}$ 展开为 $2 \times 10^3 + 7 \times 10^2 + 4 \times 10^1 + 9 \times 10^0 = 2000 + 700 + 40 + 9 = 2749$。 -同理,在二进制数中,$01101010_{(2)}$ 可以看作为 $(0 \times 2^7) + (1 \times 2^6) + (1 \times 2^5) + (0 \times 2^4) + (1 \times 2^3) + (0 \times 2^2) + (1 \times 2^1) + (0 \times 2^0)$,即 $0 + 64 + 32 + 0 + 8 + 0 + 2 + 0 = 106_{(10)}$。 + +同理,在二进制数中,$01101010_{(2)}$ 展开为 $0 \times 2^7 + 1 \times 2^6 + 1 \times 2^5 + 0 \times 2^4 + 1 \times 2^3 + 0 \times 2^2 + 1 \times 2^1 + 0 \times 2^0 = 0 + 64 + 32 + 0 + 8 + 0 + 2 + 0 = 106_{(10)}$。 ![二进制数转十进制数](https://qcdn.itcharge.cn/images/202405132136456.png) 我们可以通过这样的方式,将一个二进制数转为十进制数。 -#### 1.2.2 十进制转二进制数 +#### 1.2.2 十进制转二进制 + +十进制转二进制常用方法是「除2取余,逆序排列」。 -十进制数转二进制数的方法是:**除二取余,逆序排列法**。 +以 $106_{(10)}$ 为例: -我们以十进制数中的 $106_{(10)}$ 为例。 +1. $106 \div 2 = 53$,余 $0$。 +2. $53 \div 2 = 26$,余 $1$。 +3. $26 \div 2 = 13$,余 $0$。 +4. $13 \div 2 = 6$,余 $1$。 +5. $6 \div 2 = 3$,余 $0$。 +6. $3 \div 2 = 1$,余 $1$。 +7. $1 \div 2 = 0$,余 $1$。 +8. $0 \div 2 = 0$,余 $0$。 -$\begin{aligned} 106 \div 2 = 53 & \text{(余 0)} \cr 53 \div 2 = 26 & \text{(余 1)} \cr 26 \div 2 = 13 & \text{(余 0)} \cr 13 \div 2 = 6 & \text{(余 1)} \cr 6 \div 2 = 3 & \text{(余 0)} \cr 3 \div 2 = 1 & \text{(余 1)} \cr 1 \div 2 = 0 & \text{(余 1)} \cr 0 \div 2 = 0 & \text{(余 0)} \end{aligned}$ +将余数逆序排列,得到 $01101010_{(2)}$。 -我们反向遍历每次计算的余数,依次是 $0$,$1$,$1$,$0$,$1$,$0$,$1$,$0$,即 $01101010_{(2)}$。 +简而言之:**不断除以 2,记录余数,最后将余数逆序排列即可得到二进制表示。** ## 2. 位运算基础操作 -在二进制的基础上,我们可以对二进制数进行相应的位运算。基本的位运算共有 $6$ 种,分别是:「按位与运算」、「按位或运算」、「按位异或运算」、「取反运算」、「左移运算」、「右移运算」。 +基于二进制表示,我们可以对数字进行多种位运算。常见的位运算包括 $6$ 种:「按位与」、「按位或」、「按位异或」、「取反」、「左移」和「右移」。 -这里的「按位与运算」、「按位或运算」、「按位异或运算」、「左移运算」、「右移运算」是双目运算。 +其中,「按位与」、「按位或」、「按位异或」、「左移」、「右移」属于双目运算(需要两个操作数): -- 「按位与运算」、「按位或运算」、「按位异或运算」是将两个整数作为二进制数,对二进制数表示中的每一位(即二进位)逐一进行相应运算,即双目运算。 -- 「左移运算」、「右移运算」是将左侧整数作为二进制数,将右侧整数作为移动位数,然后对左侧二进制数的全部位进行移位运算,每次移动一位,总共移动右侧整数次位,也是双目运算。 +- 「按位与」、「按位或」、「按位异或」:将两个整数转为二进制后,对应位逐一进行运算。 +- 「左移」、「右移」:左侧为待移位的整数,右侧为移动的位数,对左侧二进制的所有位整体移动指定次数。 -而「取反运算」是单目运算,是对一个整数的二进制数进行的位运算。 +「取反」属于单目运算(只需一个操作数),即对一个整数的每一位进行取反操作。 -我们先来看下这 $6$ 种位运算的规则,再来进行详细讲解。 +下面先简要介绍这 $6$ 种位运算的基本规则,后续将逐一详细讲解。 -| 运算符 | 描述 | 规则 | -| ------------------- | -------------- | ----------------------------------------------------------------------------------------- | -| | | 按位或运算符 | 只要对应的两个二进位有一个为 $1$ 时,结果位就为 $1$。 | -| `&` | 按位与运算符 | 只有对应的两个二进位都为 $1$ 时,结果位才为 $1$。 | -| `<<` | 左移运算符 | 将二进制数的各个二进位全部左移若干位。`<<` 右侧数字指定了移动位数,高位丢弃,低位补 $0$。 | -| `>>` | 右移运算符 | 对二进制数的各个二进位全部右移若干位。`>>` 右侧数字指定了移动位数,低位丢弃,高位补 $0$。 | -| `^` | 按位异或运算符 | 对应的两个二进位相异时,结果位为 $1$,二进位相同时则结果位为 $0$。 | -| `~` | 取反运算符 | 对二进制数的每个二进位取反,使数字 $1$ 变为 $0$,$0$ 变为 $1$。 | +| 运算符 | 描述 | 规则说明 | +| --------- | ------------ | --------------------------------------------------------------------------------------------- | +| | | 按位或 | 只要对应的两个二进位中有一个为 $1$,结果位即为 $1$,否则为 $0$。 | +| `&` | 按位与 | 仅当对应的两个二进位都为 $1$ 时,结果位才为 $1$,否则为 $0$。 | +| `^` | 按位异或 | 对应的两个二进位不同则结果位为 $1$,相同则为 $0$。 | +| `~` | 按位取反 | 对操作数的每一位取反,$1$ 变为 $0$,$0$ 变为 $1$。 | +| `<<` | 左移 | 所有二进位整体向左移动指定的位数,高位溢出丢弃,低位补 $0$。 | +| `>>` | 右移 | 所有二进位整体向右移动指定的位数,低位溢出丢弃,高位补 $0$(无符号右移时)。 | ### 2.1 按位与运算 -> **按位与运算(AND)**:按位与运算符为 `&`。其功能是对两个二进制数的每一个二进位进行与运算。 - -- **按位与运算规则**:只有对应的两个二进位都为 $1$ 时,结果位才为 $1$。 +> **按位与运算(AND)**:使用运算符 `&`,对两个二进制数的每一位进行比较,只有当对应位都为 $1$ 时,结果位才为 $1$,否则为 $0$。 +- **按位与运算规则**: - `1 & 1 = 1` - - `1 & 0 = 0` - - `0 & 1 = 0` - - `0 & 0 = 0` - -举个例子,对二进制数 $01111100_{(2)}$ 与 $00111110_{(2)}$ 进行按位与运算,结果为 $00111100_{(2)}$,如图所示: +例如,将 $01111100_{(2)}$ 与 $00111110_{(2)}$ 进行按位与运算,结果为 $00111100_{(2)}$,如下图所示: ![按位与运算](https://qcdn.itcharge.cn/images/202405132137023.png) ### 2.2 按位或运算 -> **按位或运算(OR)**:按位或运算符为 `|`。其功能对两个二进制数的每一个二进位进行或运算。 +> **按位或运算(OR)**:使用运算符 `|`,对两个二进制数的每一位进行「或」操作。只要对应的两个二进位中有一个为 $1$,结果位就是 $1$,只有两个都是 $0$ 时结果才为 $0$。 -- **按位或运算规则**:只要对应的两个二进位有一个为 $1$ 时,结果位就为 $1$。 +- **按位或运算规则**: - `1 | 1 = 1` - `1 | 0 = 1` - `0 | 1 = 1` - `0 | 0 = 0` - -举个例子,对二进制数 $01001010_{(2)}$ 与 $01011011_{(2)}$ 进行按位或运算,结果为 $01011011_{(2)}$,如图所示: +例如,将 $01001010_{(2)}$ 与 $01011011_{(2)}$ 进行按位或运算,结果为 $01011011_{(2)}$,如下图所示: ![按位或运算](https://qcdn.itcharge.cn/images/202405132137593.png) ### 2.3 按位异或运算 -> **按位异或运算(XOR)**:按位异或运算符为 `^`。其功能是对两个二进制数的每一个二进位进行异或运算。 +> **按位异或运算(XOR)**:使用运算符 `^`,对两个二进制数的每一位进行比较。只有当对应的两位不同(即一位为 $1$,一位为 $0$)时,结果位才为 $1$,否则为 $0$。 -- **按位异或运算规则**:对应的两个二进位相异时,结果位为 $1$,二进位相同时则结果位为 $0$。 -- `0 ^ 0 = 0` - -- `1 ^ 0 = 1` - -- `0 ^ 1 = 1` - -- `1 ^ 1 = 0` +- **按位异或运算运算规则**: + - `0 ^ 0 = 0` + - `1 ^ 0 = 1` + - `0 ^ 1 = 1` + - `1 ^ 1 = 0` +简而言之,异或运算的本质是「相同为 $0$,不同为 $1$」。 -举个例子,对二进制数 $01001010_{(2)}$ 与 $01000101_{(2)}$ 进行按位异或运算,结果为 $00001111_{(2)}$,如图所示: +例如,将 $01001010_{(2)}$ 与 $01000101_{(2)}$ 进行按位异或运算,结果为 $00001111_{(2)}$,如下图所示: ![按位异或运算](https://qcdn.itcharge.cn/images/202405132137874.png) ### 2.4 取反运算 ->**取反运算(NOT)**:取反运算符为 `~`。其功能是对一个二进制数的每一个二进位进行取反运算。 +> **取反运算(NOT)**:取反运算符为 `~`,用于将一个二进制数的每一位进行翻转,即 $1$ 变为 $0$,$0$ 变为 $1$。 -- **取反运算规则**:使数字 $1$ 变为 $0$,$0$ 变为 $1$。 +- **取反运算规则**: - `~0 = 1` - `~1 = 0` -举个例子,对二进制数 $01101010_{(2)}$ 进行取反运算,结果如图所示: +例如,对二进制数 $01101010_{(2)}$ 进行取反,结果如下图所示: ![取反运算](https://qcdn.itcharge.cn/images/202405132138853.png) -### 2.5 左移运算和右移运算 +### 2.5 左移运算与右移运算 -> **左移运算(SHL)**: 左移运算符为 `<<`。其功能是对一个二进制数的各个二进位全部左移若干位(高位丢弃,低位补 $0$)。 +> **左移运算(SHL)**:使用运算符 `<<`,将一个二进制数的所有位整体向左移动指定的位数。左移时,高位超出部分被舍弃,低位空缺部分补 $0$。 -举个例子,对二进制数 $01101010_{(2)}$ 进行左移 $1$ 位运算,结果为 $11010100_{(2)}$,如图所示: +例如,将二进制数 $01101010_{(2)}$ 左移 $1$ 位,得到 $11010100_{(2)}$,如下图所示: ![左移运算](https://qcdn.itcharge.cn/images/202405132138841.png) -> **右移运算(SHR)**: 右移运算符为 `>>`。其功能是对一个二进制数的各个二进位全部右移若干位(低位丢弃,高位补 $0$)。 +> **右移运算(SHR)**:使用运算符 `>>`,将一个二进制数的所有位整体向右移动指定的位数。右移时,低位超出部分被舍弃,高位空缺部分补 $0$。 -举个例子,对二进制数 $01101010_{(2)}$ 进行右移 $1$ 位运算,结果为 $00110101_{(2)}$,如图所示: +例如,将二进制数 $01101010_{(2)}$ 右移 $1$ 位,得到 $00110101_{(2)}$,如下图所示: ![右移运算](https://qcdn.itcharge.cn/images/202405132138348.png) ## 3. 位运算的应用 -### 3.1 位运算的常用操作 - -#### 3.1.1 判断整数奇偶 +### 3.1 判断整数奇偶 -一个整数,只要是偶数,其对应二进制数的末尾一定为 $0$;只要是奇数,其对应二进制数的末尾一定为 $1$。所以,我们通过与 $1$ 进行按位与运算,即可判断某个数是奇数还是偶数。 +判断一个整数的奇偶性,可以利用其二进制表示的最低位。偶数的二进制最低位为 $0$,奇数的最低位为 $1$。因此,通过将该数与 $1$ 进行按位与运算即可快速判断: -1. `(x & 1) == 0` 为偶数。 -2. `(x & 1) == 1` 为奇数。 +- 如果 `(x & 1) == 0`,则 $x$ 为偶数; +- 如果 `(x & 1) == 1`,则 $x$ 为奇数。 -#### 3.1.2 二进制数选取指定位 +### 3.2 二进制数选取指定位 -如果我们想要从一个二进制数 $X$ 中取出某几位,使取出位置上的二进位保留原值,其余位置为 $0$,则可以使用另一个二进制数 $Y$,使该二进制数上对应取出位置为 $1$,其余位置为 $0$。然后令两个数进行按位与运算(`X & Y`),即可得到想要的数。 +若需从二进制数 $X$ 中提取指定的若干位(即保留这些位的原值,其余位置为 $0$),可以先构造一个掩码 $Y$,使得需要保留的位置为 $1$,其余为 $0$。随后通过按位与运算(`X & Y`)即可实现目标。 -举个例子,比如我们要取二进制数 $X = 01101010_{(2)}$ 的末尾 $4$ 位,则只需将 $X = 01101010_{(2)}$ 与 $Y = 00001111_{(2)}$ (末尾 $4$ 位为 $1$,其余位为 $0$) 进行按位与运算,即 `01101010 & 00001111 == 00001010`。其结果 $00001010$ 就是我们想要的数(即二进制数 $01101010_{(2)}$ 的末尾 $4$ 位)。 +例如,如果要获取 $X = 01101010_{(2)}$ 的最低 $4$ 位,只需将其与 $Y = 00001111_{(2)}$(最低 $4$ 位为 $1$,其余为 $0$)进行按位与运算:`01101010 & 00001111 = 00001010`。结果 $00001010$ 即为 $X$ 的末尾 $4$ 位。 -#### 3.1.3 将指定位设置为 $1$ +### 3.3 将指定位设置为 $1$ -如果我们想要把一个二进制数 $X$ 中的某几位设置为 $1$,其余位置保留原值,则可以使用另一个二进制数 $Y$,使得该二进制上对应选取位置为 $1$,其余位置为 $0$。然后令两个数进行按位或运算(`X | Y`),即可得到想要的数。 +若需将二进制数 $X$ 的某几位强制设置为 $1$(其余位保持原值),可构造一个掩码 $Y$,使得需要设置为 $1$ 的位为 $1$,其余为 $0$。然后通过按位或运算(`X | Y`)即可实现。 -举个例子,比如我们想要将二进制数 $X = 01101010_{(2)}$ 的末尾 $4$ 位设置为 $1$,其余位置保留原值,则只需将 $X = 01101010_{(2)}$ 与 $Y = 00001111_{(2)}$(末尾 $4$ 位为 $1$,其余位为 $0$)进行按位或运算,即 `01101010 | 00001111 = 01101111`。其结果 $01101111$ 就是我们想要的数(即将二进制数 $01101010_{(2)}$ 的末尾 $4$ 位设置为 $1$,其余位置保留原值)。 +例如,若要将 $X = 01101010_{(2)}$ 的最低 $4$ 位设置为 $1$,其余位不变,只需与 $Y = 00001111_{(2)}$(最低 $4$ 位为 $1$,其余为 $0$)进行按位或运算:`01101010 | 00001111 = 01101111`。结果 $01101111$ 即为所需的新数。 -#### 3.1.4 反转指定位 +### 3.4 反转指定位 -如果我们想要把一个二进制数 $X$ 的某几位进行反转,则可以使用另一个二进制数 $Y$,使得该二进制上对应选取位置为 $1$,其余位置为 $0$。然后令两个数进行按位异或运算(`X ^ Y`),即可得到想要的数。 +若需反转二进制数 $X$ 的某几位,可构造一个掩码 $Y$,使得需要反转的位置为 $1$,其余为 $0$。然后对 $X$ 和 $Y$ 进行按位异或运算(`X ^ Y`),即可实现指定位的反转。 -举个例子,比如想要将二进制数 $X = 01101010_{(2)}$ 的末尾 $4$ 位进行反转,则只需将 $X = 01101010_{(2)}$ 与 $Y = 00001111_{(2)}$(末尾 $4$ 位为 $1$,其余位为 $0$)进行按位异或运算,即 `01101010 ^ 00001111 = 01100101`。其结果 $01100101$ 就是我们想要的数(即将二进制数 $X = 01101010_{(2)}$ 的末尾 $4$ 位进行反转)。 +例如,若要反转 $X = 01101010_{(2)}$ 的最低 $4$ 位,只需将其与 $Y = 00001111_{(2)}$(最低 $4$ 位为 $1$,其余为 $0$)进行异或:`01101010 ^ 00001111 = 01100101`。结果 $01100101$ 即为 $X$ 的最低 $4$ 位被反转后的新值。 -#### 3.1.5 交换两个数 +### 3.5 交换两个数 -通过按位异或运算可以实现交换两个数的目的(只能用于交换两个整数)。 +通过按位异或运算,可以无需临时变量实现两个整数的交换(仅适用于整数类型)。示例代码如下: ```python a, b = 10, 20 @@ -184,17 +184,17 @@ a ^= b print(a, b) ``` -#### 3.1.6 将二进制最右侧为 $1$ 的二进位改为 $0$ +### 3.6 将二进制最右侧为 $1$ 的二进位改为 $0$ -如果我们想要将一个二进制数 $X$ 最右侧为 $1$ 的二进制位改为 $0$,则只需通过 `X & (X - 1)` 的操作即可完成。 +要将二进制数 $X$ 最右侧的 $1$ 置为 $0$,只需执行 `X & (X - 1)` 操作即可。 -比如 $X = 01101100_{(2)}$,$X - 1 = 01101011_{(2)}$,则 `X & (X - 1) == 01101100 & 01101011 == 01101000`,结果为 $01101000_{(2)}$(即将 $X$ 最右侧为 $1$ 的二进制为改为 $0$)。 +例如,$X = 01101100_{(2)}$,$X - 1 = 01101011_{(2)}$,则 `X & (X - 1) = 01101100 & 01101011 = 01101000`,结果为 $01101000_{(2)}$,即成功将 $X$ 最右侧的 $1$ 变为 $0$。 -#### 3.1.7 计算二进制中二进位为 $1$ 的个数 +### 3.7 计算二进制中二进位为 $1$ 的个数 -从 3.1.6 中得知,通过 `X & (X - 1)` 我们可以将二进制 $X$ 最右侧为 $1$ 的二进制位改为 $0$,那么如果我们不断通过 `X & (X - 1)` 操作,最终将二进制 $X$ 变为 $0$,并统计执行次数,则可以得到二进制中二进位为 $1$ 的个数。 +根据 3.6 节的内容,利用 `X & (X - 1)` 操作可以将二进制数 $X$ 的最右侧一个 $1$ 变为 $0$。因此,如果我们不断对 $X$ 执行该操作,直到 $X$ 变为 $0$,并统计操作次数,就能得到 $X$ 的二进制表示中 $1$ 的个数。 -具体代码如下: +实现代码如下: ```python class Solution: @@ -206,110 +206,113 @@ class Solution: return cnt ``` -#### 3.1.8 判断某数是否为 $2$ 的幂次方 +### 3.8 判断某数是否为 $2$ 的幂次方 -通过判断 `X & (X - 1) == 0` 是否成立,即可判断 $X$ 是否为 $2$ 的幂次方。 +判断一个数 $X$ 是否为 $2$ 的幂,可以利用位运算:只需判断 `X & (X - 1) == 0` 是否成立。 -这是因为: +原理如下: -1. 凡是 $2$ 的幂次方,其二进制数的某一高位为 $1$,并且仅此高位为 $1$,其余位都为 $0$。比如:$4_{(10)} = 00000100_{(2)}$、$8_{(10)} = 00001000_{(2)}$。 -2. 不是 $2$ 的幂次方,其二进制数存在多个值为 $1$ 的位。比如:$5_{10} = 00000101_{(2)}$、$6_{10} = 00000110_{(2)}$。 +- 如果 $X$ 是 $2$ 的幂,则其二进制表示只有一位为 $1$,其余全为 $0$,如 $4_{(10)} = 00000100_{(2)}$,$8_{(10)} = 00001000_{(2)}$。 +- 如果 $X$ 不是 $2$ 的幂,则其二进制表示中有多位为 $1$,如 $5_{(10)} = 00000101_{(2)}$,$6_{(10)} = 00000110_{(2)}$。 -接下来我们使用 `X & (X - 1)` 操作,将原数对应二进制数最右侧为 $1$ 的二进位改为 $0$ 之后,得到新值: +当 $X > 0$ 时,`X & (X - 1)` 的作用是将 $X$ 最右侧的 $1$ 变为 $0$,其余位保持不变: -1. 如果原数是 $2$ 的幂次方,则通过 `X & (X - 1)` 操作之后,新值所有位都为 $0$,值为 $0$。 -2. 如果该数不是 $2$ 的幂次方,则通过 `X & (X - 1)` 操作之后,新值仍存在不为 $0$ 的位,值肯定不为 $0$。 +- 如果 $X$ 是 $2$ 的幂,执行 `X & (X - 1)` 后结果为 $0$。 +- 如果 $X$ 不是 $2$ 的幂,执行后结果不为 $0$。 -所以我们可以通过是否为 $0$ 即可判断该数是否为 $2$ 的幂次方。 +因此,只需判断 `X > 0` 且 `X & (X - 1) == 0`,即可确定 $X$ 是否为 $2$ 的幂。 -### 3.2 位运算的常用操作总结 +### 3.9 位运算的常用操作总结 -| 功 能 | 位运算 | 示例 | -| ----------------------------------------- | ------------------------------------------------------- | ------------------------- | -| **从右边开始,把最后一个 $1$ 改写成 $0$** | x & (x - 1) | `100101000 -> 100100000` | -| **去掉右边起第一个 $1$ 的左边** | x & (x ^ (x - 1))x & (-x) | `100101000 -> 1000` | -| **去掉最后一位** | x >> 1 | `101101 -> 10110` | -| **取右数第 $k$ 位** | x >> (k - 1) & 1 | `1101101 -> 1, k = 4` | -| **取末尾 $3$ 位** | x & 7 | `1101101 -> 101` | -| **取末尾 $k$ 位** | x & 15 | `1101101 -> 1101, k = 4` | -| **只保留右边连续的 $1$** | (x ^ (x + 1)) >> 1 | `100101111 -> 1111` | -| **右数第 $k$ 位取反** | x ^ (1 << (k - 1)) | `101001 -> 101101, k = 3` | -| **在最后加一个 $0$** | x << 1 | `101101 -> 1011010` | -| **在最后加一个 $1$** | (x << 1) + 1 | `101101 -> 1011011` | -| **把右数第 $k$ 位变成 $0$** | x & ~(1 << (k - 1)) | `101101 -> 101001, k = 3` | -| **把右数第 $k$ 位变成 $1$** | x | (1 << (k - 1)) | `101001 -> 101101, k = 3` | -| **把右边起第一个 $0$ 变成 $1$** | x | (x + 1) | `100101111 -> 100111111` | -| **把右边连续的 $0$ 变成 $1$** | x | (x - 1) | `11011000 -> 11011111` | -| **把右边连续的 $1$ 变成 $0$** | x & (x + 1) | `100101111 -> 100100000` | -| **把最后一位变成 $0$** | x | 1 - 1 | `101101 -> 101100` | -| **把最后一位变成 $1$** | x | 1 | `101100 -> 101101` | -| **把末尾 $k$ 位变成 $1$** | x | (1 << k - 1) | `101001 -> 101111, k = 4` | -| **最后一位取反** | x ^ 1 | `101101 -> 101100` | -| **末尾 $k$ 位取反** | x ^ (1 << k - 1) | `101001 -> 100110, k = 4` | +| 序号 | 操作描述 | 位运算表达式 | 示例 | +| :--: | :--------------------------------------- | :------------------------------------- | :-------------------------- | +| 1 | 从右边开始,把最后一个 $1$ 改写成 $0$ | x & (x - 1) | `100101000 -> 100100000` | +| 2 | 保留最右侧的 $1$,其余清零 | x & -xx & (x ^ (x - 1)) | `100101000 -> 1000` | +| 3 | 去掉最后一位 | x >> 1 | `101101 -> 10110` | +| 4 | 取右数第 $k$ 位 | (x >> (k - 1)) & 1 | `1101101 -> 1, k = 4` | +| 5 | 取末尾 $k$ 位 | x & ((1 << k) - 1) | `1101101 -> 101, k = 3`;
`1101101 -> 1101, k = 4` | +| 6 | 只保留右边连续的 $1$ | (x ^ (x + 1)) >> 1 | `100101111 -> 1111` | +| 7 | 右数第 $k$ 位取反 | x ^ (1 << (k - 1)) | `101001 -> 101101, k = 3` | +| 8 | 在最后加一个 $0$ | x << 1 | `101101 -> 1011010` | +| 9 | 在最后加一个 $1$ | (x << 1) + 1 | `101101 -> 1011011` | +| 10 | 把右数第 $k$ 位变成 $0$ | x & ~(1 << (k - 1)) | `101101 -> 101001, k = 3` | +| 11 | 把右数第 $k$ 位变成 $1$ | x | (1 << (k - 1)) | `101001 -> 101101, k = 3` | +| 12 | 把右边起第一个 $0$ 变成 $1$ | x | (x + 1) | `100101111 -> 100111111` | +| 13 | 把右边连续的 $0$ 变成 $1$ | x | (x - 1) | `11011000 -> 11011111` | +| 14 | 把右边连续的 $1$ 变成 $0$ | x & (x + 1) | `100101111 -> 100100000` | +| 15 | 把最后一位变成 $0$ | x & ~1 | `101101 -> 101100` | +| 16 | 把最后一位变成 $1$ | x | 1 | `101100 -> 101101` | +| 17 | 把末尾 $k$ 位变成 $1$ | x | ((1 << k) - 1) | `101001 -> 101111, k = 4` | +| 18 | 末尾 $k$ 位取反 | x ^ ((1 << k) - 1) | `101101 -> 101100, k = 1`;
`101001 -> 100110, k = 4` | ### 3.3 二进制枚举子集 -除了上面的这些常见操作,我们经常常使用二进制数第 $1 \sim n$ 位上 $0$ 或 $1$ 的状态来表示一个由 $1 \sim n$ 组成的集合。也就是说通过二进制来枚举子集。 +在位运算中,常常利用二进制的第 $1 \sim n$ 位上的 $0$ 或 $1$ 来表示由 $1 \sim n$ 组成的集合,从而实现对子集的高效枚举。 #### 3.3.1 二进制枚举子集简介 -先来介绍一下「子集」的概念。 +首先,简要介绍一下「子集」的定义: -- **子集**:如果集合 $A$ 的任意一个元素都是集合 $S$ 的元素,则称集合 $A$ 是集合 $S$ 的子集。可以记为 $A \in S$。 +- **子集**:如果集合 $A$ 的所有元素均属于集合 $S$,则称 $A$ 是 $S$ 的子集,记作 $A \subseteq S$。 -有时候我们会遇到这样的问题:给定一个集合 $S$,枚举其所有可能的子集。 +实际问题中,常常需要枚举集合 $S$ 的所有子集。枚举子集的方法有多种,这里介绍一种简洁高效的方式:「二进制枚举子集」。 -枚举子集的方法有很多,这里介绍一种简单有效的枚举方法:「二进制枚举子集算法」。 +对于一个包含 $n$ 个元素的集合 $S$,每个元素都有「选」或「不选」两种状态。我们可以用二进制数的 $n$ 位来表示每个元素的选取情况:$1$ 表示选取该元素,$0$ 表示不选取。 -对于一个元素个数为 $n$ 的集合 $S$ 来说,每一个位置上的元素都有选取和未选取两种状态。我们可以用数字 $1$ 来表示选取该元素,用数字 $0$ 来表示不选取该元素。 +这样,任意一个 $n$ 位二进制数都唯一对应 $S$ 的一个子集。二进制的每一位对应集合中某个元素,$1$ 代表选取,$0$ 代表不选。 -那么我们就可以用一个长度为 $n$ 的二进制数来表示集合 $S$ 或者表示 $S$ 的子集。其中二进制的每一个二进位都对应了集合中某一个元素的选取状态。对于集合中第 $i$ 个元素来说,二进制对应位置上的 $1$ 代表该元素被选取,$0$ 代表该元素未被选取。 +举例说明,设 $S = \lbrace 5, 4, 3, 2, 1 \rbrace$,用 $5$ 位二进制数表示: -举个例子,比如长度为 $5$ 的集合 $S = \lbrace 5, 4, 3, 2, 1 \rbrace$,我们可以用一个长度为 $5$ 的二进制数来表示该集合。 +- $11111_{(2)}$ 表示选取所有元素,即 $S$ 本身: -比如二进制数 $11111_{(2)}$ 就表示选取集合的第 $1$ 位、第 $2$ 位、第 $3$ 位、第 $4$ 位、第 $5$ 位元素,也就是集合 $\lbrace 5, 4, 3, 2, 1 \rbrace$,即集合 $S$ 本身。如下表所示: +| 元素位置 | 5 | 4 | 3 | 2 | 1 | +| :--------------: | :-: | :-: | :-: | :-: | :-: | +| 二进制位 | 1 | 1 | 1 | 1 | 1 | +| 选取状态 | 选取 | 选取 | 选取 | 选取 | 选取 | -| 集合 S 中元素位置 | 5 | 4 | 3 | 2 | 1 | -| :---------------- | :--: | :--: | :--: | :--: | :--: | -| 二进位对应值 | 1 | 1 | 1 | 1 | 1 | -| 对应选取状态 | 选取 | 选取 | 选取 | 选取 | 选取 | +- $10101_{(2)}$ 表示选取第 $1$、$3$、$5$ 位元素,即 $\lbrace 5, 3, 1 \rbrace$: -再比如二进制数 $10101_{(2)}$ 就表示选取集合的第 $1$ 位、第 $3$ 位、第 $5$ 位元素,也就是集合 $\lbrace 5, 3, 1 \rbrace$。如下表所示: +| 元素位置 | 5 | 4 | 3 | 2 | 1 | +| :--------------: | :-: | :-: | :-: | :-: | :-: | +| 二进制位 | 1 | 0 | 1 | 0 | 1 | +| 选取状态 | 选取 | 未选取 | 选取 | 未选取 | 选取 | -| 集合 S 中元素位置 | 5 | 4 | 3 | 2 | 1 | -| :---------------- | :--: | :----: | :--: | :----: | :--: | -| 二进位对应值 | 1 | 0 | 1 | 0 | 1 | -| 对应选取状态 | 选取 | 未选取 | 选取 | 未选取 | 选取 | +- $01001_{(2)}$ 表示选取第 $1$、$4$ 位元素,即 $\lbrace 4, 1 \rbrace$: -再比如二进制数 $01001_{(2)}$ 就表示选取集合的第 $1$ 位、第 $4$ 位元素,也就是集合 $\lbrace 4, 1 \rbrace$。如下标所示: +| 元素位置 | 5 | 4 | 3 | 2 | 1 | +| :--------------: | :-: | :-: | :-: | :-: | :-: | +| 二进制位 | 0 | 1 | 0 | 0 | 1 | +| 选取状态 | 未选取 | 选取 | 未选取 | 未选取 | 选取 | -| 集合 S 中元素位置 | 5 | 4 | 3 | 2 | 1 | -| :---------------- | :----: | :--: | :----: | :----: | :--: | -| 二进位对应值 | 0 | 1 | 0 | 0 | 1 | -| 对应选取状态 | 未选取 | 选取 | 未选取 | 未选取 | 选取 | +综上所述,对于长度为 $n$ 的集合 $S$,只需枚举 $0 \sim 2^n - 1$(共 $2^n$ 种 $n$ 位二进制数),即可高效遍历并生成 $S$ 的所有子集。 -通过上面的例子我们可以得到启发:对于长度为 $5$ 的集合 $S$ 来说,我们只需要从 $00000 \sim 11111$ 枚举一次(对应十进制为 $0 \sim 2^5 - 1$)即可得到长度为 $5$ 的集合 $S$ 的所有子集。 - -我们将上面的例子拓展到长度为 $n$ 的集合 $S$。可以总结为: - -- 对于长度为 $n$ 的集合 $S$ 来说,只需要枚举 $0 \sim 2^n - 1$(共 $2^n$ 种情况),即可得到集合 $S$ 的所有子集。 - -#### 3.3.2 二进制枚举子集代码 +#### 3.3.2 二进制枚举子集的实现代码 ```python class Solution: - def subsets(self, S): # 返回集合 S 的所有子集 - n = len(S) # n 为集合 S 的元素个数 - sub_sets = [] # sub_sets 用于保存所有子集 - for i in range(1 << n): # 枚举 0 ~ 2^n - 1 - sub_set = [] # sub_set 用于保存当前子集 - for j in range(n): # 枚举第 i 位元素 - if i >> j & 1: # 如果第 i 为元素对应二进位删改为 1,则表示选取该元素 - sub_set.append(S[j]) # 将选取的元素加入到子集 sub_set 中 - sub_sets.append(sub_set) # 将子集 sub_set 加入到所有子集数组 sub_sets 中 - return sub_sets # 返回所有子集 + def subsets(self, S): # 返回集合 S 的所有子集 + n = len(S) # n 为集合 S 的元素个数 + sub_sets = [] # sub_sets 用于保存所有子集 + for i in range(1 << n): # 枚举 0 ~ 2^n - 1 的所有可能,每个 i 表示一种选取方案 + sub_set = [] # sub_set 用于保存当前子集 + for j in range(n): # 枚举集合 S 的每一个元素 + # (i >> j) & 1 判断第 j 位是否为 1 + # 如果为 1,说明在当前子集方案 i 中选取了 S[j] + if (i >> j) & 1: # 如果第 j 位为 1,则选取 S[j] + sub_set.append(S[j]) # 将选取的元素 S[j] 加入到当前子集 sub_set 中 + sub_sets.append(sub_set) # 将当前子集 sub_set 加入到所有子集数组 sub_sets 中 + return sub_sets # 返回所有子集 ``` +## 4. 总结 + +位运算是一种直接操作二进制位的高效技巧,能够在底层实现中大幅提升算法的时间和空间效率,广泛应用于状态压缩、集合枚举、掩码处理等场景。 + +位运算基础操作包括按位与、或、异或、取反、左移和右移等,这些操作能够直接高效地处理二进制数据,常用于状态压缩、集合枚举和性能优化等场景。 + +在实际应用中,常通过二进制状态来表示集合的选取情况,从而高效枚举所有子集。其核心思想是:用 $n$ 位二进制数的每一位对应集合中的一个元素,$1$ 表示选中该元素,$0$ 表示未选中。只需遍历 $0$ 到 $2^n-1$ 的所有二进制数,即可快速生成集合的全部子集。 + + ## 练习题目 - [0190. 颠倒二进制位](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0100-0199/reverse-bits.md) From d517b5317089ff760193d3f9cdbb241827104220 Mon Sep 17 00:00:00 2001 From: ITCharge Date: Tue, 9 Sep 2025 18:05:18 +0800 Subject: [PATCH 18/18] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=92=8C=E4=BC=98?= =?UTF-8?q?=E5=8C=96=E8=AF=AD=E5=8F=A5=E8=A1=A8=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/06_graph/06_01_graph_basic.md | 4 +- .../07_algorithm/07_02_recursive_algorithm.md | 2 +- .../08_01_dynamic_programming_basic.md | 254 +++++++----------- .../08_02_memoization_search.md | 143 +++++----- 4 files changed, 171 insertions(+), 232 deletions(-) diff --git a/docs/06_graph/06_01_graph_basic.md b/docs/06_graph/06_01_graph_basic.md index ead8e32e..9266a825 100644 --- a/docs/06_graph/06_01_graph_basic.md +++ b/docs/06_graph/06_01_graph_basic.md @@ -53,8 +53,8 @@ 根据图中是否存在环,可以将图分为「环形图」和「无环图」: -- **环形图(Circular Graph)**:如果图中至少存在一条环,则称为环形图。 -- **无环图(Acyclic Graph)**:如果图中不存在任何环,则称为无环图。 +- **环形图(Cyclic Graph)**:如果图中至少存在一条环(Cycle),则称该图为环形图或含环图。 +- **无环图(Acyclic Graph)**:如果图中不存在任何环,则称该图为无环图。 对于有向图,如果不存在环,则称为「有向无环图(Directed Acyclic Graph, DAG)」。DAG 结构在动态规划、最短路径、数据压缩等算法中有着广泛应用。 diff --git a/docs/07_algorithm/07_02_recursive_algorithm.md b/docs/07_algorithm/07_02_recursive_algorithm.md index 2f18770d..866ef5d1 100644 --- a/docs/07_algorithm/07_02_recursive_algorithm.md +++ b/docs/07_algorithm/07_02_recursive_algorithm.md @@ -4,7 +4,7 @@ 以阶乘为例,数学定义如下: -$fact(n) = \begin{cases} 1 & \text{n = 0} \cr n \times fact(n - 1) & \text{n > 0} \end{cases}$ +$$fact(n) = \begin{cases} 1 & \text{n = 0} \cr n \times fact(n - 1) & \text{n > 0} \end{cases}$$ 我们可以直接用调用函数自身的方式实现阶乘函数 $fact(n)$,代码如下: diff --git a/docs/08_dynamic_programming/08_01_dynamic_programming_basic.md b/docs/08_dynamic_programming/08_01_dynamic_programming_basic.md index 4d4e4402..23f48991 100644 --- a/docs/08_dynamic_programming/08_01_dynamic_programming_basic.md +++ b/docs/08_dynamic_programming/08_01_dynamic_programming_basic.md @@ -2,136 +2,136 @@ ### 1.1 动态规划的定义 -> **动态规划(Dynamic Programming)**:简称 **DP**,是一种求解多阶段决策过程最优化问题的方法。在动态规划中,通过把原问题分解为相对简单的子问题,先求解子问题,再由子问题的解而得到原问题的解。 +> **动态规划(Dynamic Programming,DP)**:是一种通过将复杂问题分解为若干相互重叠的子问题,逐步求解并保存子问题结果,最终获得原问题最优解的方法。动态规划强调阶段性决策和结果复用,避免重复计算,从而高效解决多阶段最优化问题。 -动态规划最早由理查德 · 贝尔曼于 1957 年在其著作「动态规划(Dynamic Programming)」一书中提出。这里的 Programming 并不是编程的意思,而是指一种「表格处理方法」,即将每一步计算的结果存储在表格中,供随后的计算查询使用。 +动态规划由理查德·贝尔曼于 1957 年提出。这里的 Programming 指的是「规划」或「表格法」,即把每一步的计算结果记录下来,避免重复计算。 ### 1.2 动态规划的核心思想 > **动态规划的核心思想**: > -> 1. 把「原问题」分解为「若干个重叠的子问题」,每个子问题的求解过程都构成一个 **「阶段」**。在完成一个阶段的计算之后,动态规划方法才会执行下一个阶段的计算。 -> 2. 在求解子问题的过程中,按照「自顶向下的记忆化搜索方法」或者「自底向上的递推方法」求解出「子问题的解」,把结果存储在表格中,当需要再次求解此子问题时,直接从表格中查询该子问题的解,从而避免了大量的重复计算。 +> 1. 将复杂的原问题拆解为若干个相互重叠的子问题,每个子问题的求解过程可视为一个 **「阶段」**。每完成一个阶段的计算,才进入下一个阶段。 +> 2. 在求解子问题时,采用「自顶向下的记忆化搜索」或「自底向上的递推」方式,将每个子问题的解保存到表格(数组 / 哈希表)中。这样,当后续需要用到某个子问题的解时,可以直接查表,避免重复计算。 -这看起来很像是分治算法,但动态规划与分治算法的不同点在于: +动态规划与分治算法类似,都采用「分而治之」的思想,但两者的区别在于: -1. 适用于动态规划求解的问题,在分解之后得到的子问题往往是相互联系的,会出现若干个重叠子问题。 -2. 使用动态规划方法会将这些重叠子问题的解保存到表格里,供随后的计算查询使用,从而避免大量的重复计算。 +1. 动态规划适用于子问题之间存在重叠的场景,即分解后得到的子问题并非完全独立,而是相互关联、会被多次重复求解。 +2. 动态规划通过将这些重叠子问题的解存储下来,后续直接复用,极大减少了重复计算,提高了效率。 ### 1.3 动态规划的简单例子 -下面我们先来通过一个简单的例子来介绍一下什么是动态规划算法,然后再来讲解动态规划中的各种术语。 +我们先通过一个经典例子直观理解动态规划的思想,再进一步介绍相关术语。 -> **斐波那契数列**:数列由 $f(0) = 1, f(1) = 2$ 开始,后面的每一项数字都是前面两项数字的和。也就是: +> **斐波那契数列**:该数列以 $f(0) = 0, f(1) = 1$ 为起点,每一项均为前两项之和,即: > -> $f(n) = \begin{cases} 0 & n = 0 \cr 1 & n = 1 \cr f(n - 2) + f(n - 1) & n > 1 \end{cases}$ +> $f(n) = \begin{cases} 0 & n = 0 \\ 1 & n = 1 \\ f(n - 2) + f(n - 1) & n > 1 \end{cases}$ -通过公式 $f(n) = f(n - 2) + f(n - 1)$,我们可以将原问题 $f(n)$ 递归地划分为 $f(n - 2)$ 和 $f(n - 1)$ 这两个子问题。其对应的递归过程如下图所示: +根据递推公式 $f(n) = f(n-2) + f(n-1)$,我们可以将原问题 $f(n)$ 递归的拆解为两个子问题 $f(n-2)$ 和 $f(n-1)$。如下图所示: ![斐波那契数列的重复计算项](https://qcdn.itcharge.cn/images/20230307164107.png) -从图中可以看出:如果使用传统递归算法计算 $f(5)$,需要先计算 $f(3)$ 和 $f(4)$,而在计算 $f(4)$ 时还需要计算 $f(3)$,这样 $f(3)$ 就进行了多次计算。同理 $f(0)$、$f(1)$、$f(2)$ 都进行了多次计算,从而导致了重复计算问题。 +从图中可以看到,如果直接递归计算 $f(5)$,会多次重复计算 $f(3)$、$f(2)$ 等子问题,导致效率低下。 -为了避免重复计算,我们可以使用动态规划中的「表格处理方法」来处理。 +为避免重复计算,动态规划采用「表格法」:将每个子问题的结果记录下来,后续直接查表即可。 -这里我们使用「自底向上的递推方法」求解出子问题 $f(n - 2)$ 和 $f(n - 1)$ 的解,然后把结果存储在表格中,供随后的计算查询使用。具体过程如下: +以「自底向上」的递推方式为例,步骤如下: -1. 定义一个数组 $dp$,用于记录斐波那契数列中的值。 +1. 定义数组 $dp$,用于存储斐波那契数列的各项值。 2. 初始化 $dp[0] = 0, dp[1] = 1$。 -3. 根据斐波那契数列的递推公式 $f(n) = f(n - 1) + f(n - 2)$,从 $dp(2)$ 开始递推计算斐波那契数列的每个数,直到计算出 $dp(n)$。 -4. 最后返回 $dp(n)$ 即可得到第 $n$ 项斐波那契数。 +3. 按递推公式 $f(n) = f(n-1) + f(n-2)$,从 $dp[2]$ 开始依次计算,直到 $dp[n]$。 +4. 返回 $dp[n]$,即为第 $n$ 项斐波那契数。 具体代码如下: ```python class Solution: def fib(self, n: int) -> int: + # 边界情况:n 为 0 时,斐波那契数为 0 if n == 0: return 0 + # 边界情况:n 为 1 时,斐波那契数为 1 if n == 1: return 1 + # 初始化dp数组,dp[i] 表示第 i 个斐波那契数 dp = [0 for _ in range(n + 1)] - dp[0] = 0 - dp[1] = 1 + dp[0] = 0 # 第 0 项 + dp[1] = 1 # 第 1 项 + # 自底向上递推,依次计算每一项 for i in range(2, n + 1): + # 状态转移方程:第 i 项等于前两项之和 dp[i] = dp[i - 2] + dp[i - 1] + # 返回第 n 项斐波那契数 return dp[n] ``` -这种使用缓存(哈希表、集合或数组)保存计算结果,从而避免子问题重复计算的方法,就是「动态规划算法」。 +这种通过缓存(如哈希表、集合或数组)存储已计算结果,避免对子问题的重复求解的方法,就是「动态规划」的核心思想。 -## 2. 动态规划的特征 +## 2. 动态规划的三大特征 -究竟什么样的问题才可以使用动态规划算法解决呢? +什么样的问题适合用动态规划解决?本质上,只有同时满足以下三个特征的问题,才适合用动态规划方法高效求解: -首先,能够使用动态规划方法解决的问题必须满足以下三个特征: - -1. **最优子结构性质** -2. **重叠子问题性质** +1. **最优子结构** +2. **重叠子问题** 3. **无后效性** -### 2.1 最优子结构性质 - -> **最优子结构**:指的是一个问题的最优解包含其子问题的最优解。 +### 2.1 最优子结构 -举个例子,如下图所示,原问题 $S = \lbrace a_1, a_2, a_3, a_4 \rbrace$,在 $a_1$ 步我们选出一个当前最优解之后,问题就转换为求解子问题 $S_{\text{子问题}} = \lbrace a_2, a_3, a_4 \rbrace$。如果原问题 $S$ 的最优解可以由「第 $a_1$ 步得到的局部最优解」和「 $S_{\text{子问题}}$ 的最优解」构成,则说明该问题满足最优子结构性质。 +> **最优子结构**:即一个问题的最优解可以由其子问题的最优解推导得到。 -也就是说,如果原问题的最优解包含子问题的最优解,则说明该问题满足最优子结构性质。 +通俗来说,如果我们能把原问题分解为若干子问题,并且原问题的最优解一定包含这些子问题的最优解,那么该问题就具备最优子结构。例如,设原问题 $S = \lbrace a_1, a_2, a_3, a_4 \rbrace$,在第 $a_1$ 步选出当前最优后,剩下的子问题 $S_{\text{子问题}} = \lbrace a_2, a_3, a_4 \rbrace$ 的最优解也必须包含在整体最优解中。 ![最优子结构性质](https://qcdn.itcharge.cn/images/20240513163310.png) -### 2.2 重叠子问题性质 +### 2.2 重叠子问题 -> **重叠子问题性质**:指的是在求解子问题的过程中,有大量的子问题是重复的,一个子问题在下一阶段的决策中可能会被多次用到。如果有大量重复的子问题,那么只需要对其求解一次,然后用表格将结果存储下来,以后使用时可以直接查询,不需要再次求解。 +> **重叠子问题**:即在递归求解过程中,会反复遇到相同的子问题。 -![重叠子问题性质](https://qcdn.itcharge.cn/images/20230307164107.png) +动态规划与分治法的最大区别在于,动态规划适用于子问题会被多次重复计算的场景。比如斐波那契数列,$f(2)$、$f(3)$ 等子问题会被多次递归调用。动态规划通过将子问题的解存储下来(如用数组或哈希表),避免了重复计算,大大提升了效率。 -之前我们提到的「斐波那契数列」例子中,$f(0)$、$f(1)$、$f(2)$、$f(3)$ 都进行了多次重复计算。动态规划算法利用了子问题重叠的性质,在第一次计算 $f(0)$、$f(1)$、$f(2)$、$f(3)$ 时就将其结果存入表格,当再次使用时可以直接查询,无需再次求解,从而提升效率。 +![重叠子问题性质](https://qcdn.itcharge.cn/images/20230307164107.png) ### 2.3 无后效性 -> **无后效性**:指的是子问题的解(状态值)只与之前阶段有关,而与后面阶段无关。当前阶段的若干状态值一旦确定,就不再改变,不会再受到后续阶段决策的影响。 +> **无后效性**:指某一阶段的状态一旦确定,不会被后续阶段的决策所影响。 -也就是说,**一旦某一个子问题的求解结果确定以后,就不会再被修改**。 +也就是说,**子问题的解一旦确定,就不会再被修改**。例如,在有向无环图中求最短路径时,假设已知 $A$ 到 $D$ 的最短路径为 $9$,那么无论后续如何选择路径,都不会影响 $A$ 到 $D$ 的最短路径长度。这种前面的决策不会被后面的决策反悔就是「无后效性」。 -举个例子,下图是一个有向无环带权图,我们在求解从 $A$ 点到 $F$ 点的最短路径问题时,假设当前已知从 $A$ 点到 $D$ 点的最短路径($2 + 7 = 9$)。那么无论之后的路径如何选择,都不会影响之前从 $A$ 点到 $D$ 点的最短路径长度。这就是「无后效性」。 - -而如果一个问题具有「后效性」,则可能需要先将其转化或者逆向求解来消除后效性,然后才可以使用动态规划算法。 +如果问题存在「后效性」,则需要先消除后效性(如逆序建模),否则无法直接用动态规划求解。 ![无后效性](https://qcdn.itcharge.cn/images/20240514110127.png) ## 3. 动态规划的基本思路 -如下图所示,我们在使用动态规划方法解决某些最优化问题时,可以将解决问题的过程按照一定顺序(时间顺序、空间顺序或其他顺序)分解为若干个相互联系的「阶段」。然后按照顺序对每一个阶段做出「决策」,这个决策既决定了本阶段的效益,也决定了下一阶段的初始状态。依次做完每个阶段的决策之后,就得到了一个整个问题的决策序列。 +如下图所示,动态规划在解决最优化问题时,通常会将整个求解过程按照一定的顺序(如时间、空间等)划分为若干相互关联的「阶段」。在每个阶段,我们需要做出一个「决策」,该决策不仅影响当前阶段的收益,还会决定下一阶段的初始状态。依次完成每个阶段的决策后,就形成了从起点到终点的完整决策序列。 -这样就将一个原问题分解为了一系列的子问题,再通过逐步求解从而获得最终结果。 +通过这种方式,原问题被拆解为一系列相互联系的子问题,逐步递推求解,最终获得整体问题的最优解。 ![动态规划方法](https://qcdn.itcharge.cn/images/20240514110154.png) -这种前后关联、具有链状结构的多阶段进行决策的问题也叫做「多阶段决策问题」。 +这种具有前后依赖关系、链式结构的多阶段决策问题,通常被称为「多阶段决策问题」。 + +动态规划的基本步骤如下: -通常我们使用动态规划方法来解决问题的基本思路如下: +1. **划分阶段**:将原问题按照某种顺序(如时间、空间等)分解为若干有序的阶段。每个阶段对应一个子问题,阶段之间存在明确的先后关系。 +2. **定义状态**:用适当的变量(如位置、数量、容量等)描述每个阶段的状态。状态的设计需满足无后效性,即一旦确定不会被后续决策影响。 +3. **状态转移方程**:根据前一阶段的状态和当前可做的决策,推导出当前阶段的状态。即通过状态转移方程描述各阶段状态之间的递推关系。 +4. **初始条件与边界条件**:结合问题描述、状态定义和状态转移方程,明确初始状态和边界情况,为递推提供基础。 +5. **确定最终结果**:根据递推过程和问题目标,提取或计算出最终所需的答案。 -1. **划分阶段**:将原问题按顺序(时间顺序、空间顺序或其他顺序)分解为若干个相互联系的「阶段」。划分后的阶段⼀定是有序或可排序的,否则问题⽆法求解。 - - 这里的「阶段」指的是⼦问题的求解过程。每个⼦问题的求解过程都构成⼀个「阶段」,在完成前⼀阶段的求解后才会进⾏后⼀阶段的求解。 -2. **定义状态**:将和子问题相关的某些变量(位置、数量、体积、空间等等)作为一个「状态」表示出来。状态的选择要满⾜⽆后效性。 - - 一个「状态」对应一个或多个子问题,所谓某个「状态」下的值,指的就是这个「状态」所对应的子问题的解。 -3. **状态转移**:根据「上一阶段的状态」和「该状态下所能做出的决策」,推导出「下一阶段的状态」。或者说根据相邻两个阶段各个状态之间的关系,确定决策,然后推导出状态间的相互转移方式(即「状态转移方程」)。 -4. **初始条件和边界条件**:根据问题描述、状态定义和状态转移方程,确定初始条件和边界条件。 -5. **最终结果**:确定问题的求解目标,然后按照一定顺序求解每一个阶段的问题。最后根据状态转移方程的递推结果,确定最终结果。 +通过上述步骤,可以系统性地将原问题拆解、建模并高效求解。 ## 4. 动态规划的应用 -动态规划相关的问题往往灵活多变,思维难度大,没有特别明显的套路,并且经常会在各类算法竞赛和面试中出现。 +动态规划问题通常灵活多变,思维难度较高,缺乏固定的解题模板,因此在各类算法竞赛和面试中频繁出现。 -动态规划问题的关键点在于「如何状态设计」和「推导状态转移条件」,还有各种各样的「优化方法」。这类问题一定要多练习、多总结,只有接触的题型多了,才能熟练掌握动态规划思想。 +解决动态规划问题的核心在于「状态的设计」与「状态转移方程的推导」,同时还需掌握多种优化技巧。要想真正掌握动态规划,必须通过大量练习和总结,积累不同题型的经验,才能灵活运用动态规划思想。 -下面来介绍几道关于动态规划的基础题目。 +下面将介绍几道动态规划的基础题目,帮助大家理解和掌握基本方法。 -### 4.1 斐波那契数 +### 4.1 经典例题:斐波那契数 #### 4.1.1 题目链接 @@ -172,47 +172,50 @@ class Solution: ###### 1. 划分阶段 -我们可以按照整数顺序进行阶段划分,将其划分为整数 $0 \sim n$。 +将问题按整数顺序分为 $0$ 到 $n$ 共 $n + 1$ 个阶段,每个阶段对应一个斐波那契数的下标。 ###### 2. 定义状态 -定义状态 $dp[i]$ 为:第 $i$ 个斐波那契数。 +令 $dp[i]$ 表示第 $i$ 个斐波那契数的值。 ###### 3. 状态转移方程 -根据题目中所给的斐波那契数列的定义 $f(n) = f(n - 1) + f(n - 2)$,则直接得出状态转移方程为 $dp[i] = dp[i - 1] + dp[i - 2]$。 +依据斐波那契数列的递推关系 $f(n) = f(n - 1) + f(n - 2)$,可得状态转移方程:$dp[i] = dp[i - 1] + dp[i - 2]$。 ###### 4. 初始条件 -根据题目中所给的初始条件 $f(0) = 0, f(1) = 1$ 确定动态规划的初始条件,即 $dp[0] = 0, dp[1] = 1$。 +根据定义,$dp[0] = 0$,$dp[1] = 1$。 ###### 5. 最终结果 -根据状态定义,最终结果为 $dp[n]$,即第 $n$ 个斐波那契数为 $dp[n]$。 +最终答案为 $dp[n]$,即第 $n$ 个斐波那契数。 #### 4.1.4 代码 ```python class Solution: def fib(self, n: int) -> int: + # 边界情况,n 为 0 或 1 时直接返回 n if n <= 1: return n + # 初始化 dp 数组,长度为 n+1,dp[i] 表示第 i 个斐波那契数 dp = [0 for _ in range(n + 1)] - dp[0] = 0 - dp[1] = 1 + dp[0] = 0 # 第 0 个斐波那契数为 0 + dp[1] = 1 # 第 1 个斐波那契数为 1 + # 状态转移,从第 2 项开始递推 for i in range(2, n + 1): - dp[i] = dp[i - 2] + dp[i - 1] + dp[i] = dp[i - 2] + dp[i - 1] # 状态转移方程 - return dp[n] + return dp[n] # 返回第 n 个斐波那契数 ``` #### 4.1.5 复杂度分析 -- **时间复杂度**:$O(n)$。一重循环遍历的时间复杂度为 $O(n)$。 -- **空间复杂度**:$O(n)$。用到了一维数组保存状态,所以总体空间复杂度为 $O(n)$。 +- **时间复杂度**:$O(n)$。仅需一次循环遍历所有状态,整体时间复杂度为 $O(n)$。 +- **空间复杂度**:$O(n)$。由于使用了一维数组 $dp$ 存储每个阶段的状态,因此空间复杂度为 $O(n)$。 -### 4.2 爬楼梯 +### 4.2 经典例题:爬楼梯 #### 4.2.1 题目链接 @@ -255,134 +258,57 @@ class Solution: ###### 1. 划分阶段 -我们按照台阶的阶层划分阶段,将其划分为 $0 \sim n$ 阶。 +将问题按台阶数分阶段,阶段范围为 $0 \sim n$ 阶。 ###### 2. 定义状态 -定义状态 $dp[i]$ 为:爬到第 $i$ 阶台阶的方案数。 +设 $dp[i]$ 表示到达第 $i$ 阶台阶的不同方法数。 ###### 3. 状态转移方程 -根据题目大意,每次只能爬 $1$ 或 $2$ 个台阶。则第 $i$ 阶楼梯只能从第 $i - 1$ 阶向上爬 $1$ 阶上来,或者从第 $i - 2$ 阶向上爬 $2$ 阶上来。所以可以推出状态转移方程为 $dp[i] = dp[i - 1] + dp[i - 2]$。 +每次只能爬 $1$ 或 $2$ 个台阶,因此到达第 $i$ 阶的方法可以从第 $i - 1$ 阶爬 $1$ 阶,或从第 $i - 2$ 阶爬 $2$ 阶而来。状态转移方程为:$dp[i] = dp[i - 1] + dp[i - 2]$。 ###### 4. 初始条件 -- 第 $0$ 层台阶方案数:可以看做 $1$ 种方法(从 $0$ 阶向上爬 $0$ 阶),即 $dp[1] = 1$。 -- 第 $1$ 层台阶方案数:$1$ 种方法(从 $0$ 阶向上爬 $1$ 阶),即 $dp[1] = 1$。 -- 第 $2$ 层台阶方案数:$2$ 中方法(从 $0$ 阶向上爬 $2$ 阶,或者从 $1$ 阶向上爬 $1$ 阶)。 +- $dp[0] = 1$:从地面(第 $0$ 阶)出发,有 $1$ 种方式(不动)。 +- $dp[1] = 1$:只爬 $1$ 阶,有 $1$ 种方式。 +- $dp[2] = 2$:可以一次爬 $2$ 阶,或两次各爬 $1$ 阶,共 $2$ 种方式。 ###### 5. 最终结果 -根据状态定义,最终结果为 $dp[n]$,即爬到第 $n$ 阶台阶(即楼顶)的方案数为 $dp[n]$。 +最终答案为 $dp[n]$,即到达第 $n$ 阶(楼顶)的方案数。 -虽然这道题跟上一道题的状态转移方程都是 $dp[i] = dp[i - 1] + dp[i - 2]$,但是两道题的考察方式并不相同,一定程度上也可以看出来动态规划相关题目的灵活多变。 +虽然本题与斐波那契数列的状态转移方程相同($dp[i] = dp[i - 1] + dp[i - 2]$),但考察的实际场景和建模方式有所不同,体现了动态规划在不同问题中的灵活应用。 #### 4.2.4 代码 ```python class Solution: def climbStairs(self, n: int) -> int: + # 初始化 dp 数组,dp[i] 表示到达第 i 阶的方法数 dp = [0 for _ in range(n + 1)] - dp[0] = 1 - dp[1] = 1 + dp[0] = 1 # 到达第 0 阶只有 1 种方式(不动) + dp[1] = 1 # 到达第 1 阶只有 1 种方式(一步到达) + # 从第 2 阶开始,依次计算每一阶的方案数 for i in range(2, n + 1): + # 状态转移:到达第 i 阶的方法数 = 到达第 i - 1 阶的方法数 + 到达第 i - 2 阶的方法数 dp[i] = dp[i - 1] + dp[i - 2] - return dp[n] + return dp[n] # 返回到达第 n 阶的方法总数 ``` #### 4.2.5 复杂度分析 -- **时间复杂度**:$O(n)$。一重循环遍历的时间复杂度为 $O(n)$。 -- **空间复杂度**:$O(n)$。用到了一维数组保存状态,所以总体空间复杂度为 $O(n)$。因为 $dp[i]$ 的状态只依赖于 $dp[i - 1]$ 和 $dp[i - 2]$,所以可以使用 $3$ 个变量来分别表示 $dp[i]$、$dp[i - 1]$、$dp[i - 2]$,从而将空间复杂度优化到 $O(1)$。 - -### 4.3 不同路径 - -#### 4.3.1 题目链接 - -- [62. 不同路径 - 力扣](https://leetcode.cn/problems/unique-paths/) - -#### 4.3.2 题目大意 - -**描述**:给定两个整数 $m$ 和 $n$,代表大小为 $m \times n$ 的棋盘, 一个机器人位于棋盘左上角的位置,机器人每次只能向右、或者向下移动一步。 +- **时间复杂度**:为 $O(n)$,因为只需一重循环遍历 $n$ 次即可完成计算。 +- **空间复杂度**:为 $O(n)$,由于需要一个长度为 $n + 1$ 的一维数组来保存每一级台阶的状态。不过,由于每次状态转移只依赖于前两项 $dp[i - 1]$ 和 $dp[i - 2]$,因此可以进一步优化,只用三个变量分别记录当前和前两步的状态,将空间复杂度降为 $O(1)$。 -**要求**:计算出机器人从棋盘左上角到达棋盘右下角一共有多少条不同的路径。 +## 5. 总结 -**说明**: - -- $1 \le m, n \le 100$。 -- 题目数据保证答案小于等于 $2 \times 10^9$。 - -**示例**: - -- 示例 1: - -```python -输入:m = 3, n = 7 -输出:28 -``` - -- 示例 2: - -```python -输入:m = 3, n = 2 -输出:3 -解释: -从左上角开始,总共有 3 条路径可以到达右下角。 -1. 向右 -> 向下 -> 向下 -2. 向下 -> 向下 -> 向右 -3. 向下 -> 向右 -> 向下 -``` - -![](https://assets.leetcode.com/uploads/2018/10/22/robot_maze.png) - -#### 4.3.3 解题思路 - -###### 1. 划分阶段 - -按照路径的结尾位置(行位置、列位置组成的二维坐标)进行阶段划分。 - -###### 2. 定义状态 - -定义状态 $dp[i][j]$ 为:从左上角到达 $(i, j)$ 位置的路径数量。 - -###### 3. 状态转移方程 - -因为我们每次只能向右、或者向下移动一步,因此想要走到 $(i, j)$,只能从 $(i - 1, j)$ 向下走一步走过来;或者从 $(i, j - 1)$ 向右走一步走过来。所以可以写出状态转移方程为:$dp[i][j] = dp[i - 1][j] + dp[i][j - 1]$,此时 $i > 0, j > 0$。 - -###### 4. 初始条件 - -- 从左上角走到 $(0, 0)$ 只有一种方法,即 $dp[0][0] = 1$。 -- 第一行元素只有一条路径(即只能通过前一个元素向右走得到),所以 $dp[0][j] = 1$。 -- 同理,第一列元素只有一条路径(即只能通过前一个元素向下走得到),所以 $dp[i][0] = 1$。 - -###### 5. 最终结果 - -根据状态定义,最终结果为 $dp[m - 1][n - 1]$,即从左上角到达右下角 $(m - 1, n - 1)$ 位置的路径数量为 $dp[m - 1][n - 1]$。 - -#### 4.3.4 代码 - -```python -class Solution: - def uniquePaths(self, m: int, n: int) -> int: - dp = [[0 for _ in range(n)] for _ in range(m)] - - for j in range(n): - dp[0][j] = 1 - for i in range(m): - dp[i][0] = 1 - - for i in range(1, m): - for j in range(1, n): - dp[i][j] = dp[i - 1][j] + dp[i][j - 1] - - return dp[m - 1][n - 1] -``` +动态规划通过「阶段 — 状态 — 转移」的框架,将含有重叠子问题的最优化任务转化为可递推求解的表格计算。它适用于同时满足最优子结构、重叠子问题与无后效性的场景,核心在于合理设计状态与状态转移方程,并明确初始与边界条件。 -#### 4.3.5 复杂度分析 +实际解题时,先判断是否符合三大特征,再按步骤完成:划分阶段、定义状态、写出转移、设定初始 / 边界、提取答案。实现可采用自顶向下(记忆化)或自底向上(迭代)两种方式,并视依赖关系进行空间优化。 -- **时间复杂度**:$O(m \times n)$。初始条件赋值的时间复杂度为 $O(m + n)$,两重循环遍历的时间复杂度为 $O(m \times n)$,所以总体时间复杂度为 $O(m \times n)$。 -- **空间复杂度**:$O(m \times n)$。用到了二维数组保存状态,所以总体空间复杂度为 $O(m \times n)$。因为 $dp[i][j]$ 的状态只依赖于上方值 $dp[i - 1][j]$ 和左侧值 $dp[i][j - 1]$,而我们在进行遍历时的顺序刚好是从上至下、从左到右。所以我们可以使用长度为 $n$ 的一维数组来保存状态,从而将空间复杂度优化到 $O(n)$。 +本文以斐波那契与爬楼梯为例,展示了从建模到实现的完整路径。理解这些基本范式后,多练习与归纳是提升动态规划能力的关键,尤其要学会在不同问题背景下复用“状态设计+转移关系”的思维模板。 ## 题目练习 diff --git a/docs/08_dynamic_programming/08_02_memoization_search.md b/docs/08_dynamic_programming/08_02_memoization_search.md index 7f8936a4..b4a2a086 100644 --- a/docs/08_dynamic_programming/08_02_memoization_search.md +++ b/docs/08_dynamic_programming/08_02_memoization_search.md @@ -1,79 +1,79 @@ ## 1. 记忆化搜索简介 ->**记忆化搜索(Memoization Search)**:是一种通过存储已经遍历过的状态信息,从而避免对同一状态重复遍历的搜索算法。 +> **记忆化搜索(Memoization Search)**:是一种通过记录已访问状态的结果,避免对同一状态进行重复计算的高效搜索方法。 -记忆化搜索是动态规划的一种实现方式。在记忆化搜索中,当算法需要计算某个子问题的结果时,它首先检查是否已经计算过该问题。如果已经计算过,则直接返回已经存储的结果;否则,计算该问题,并将结果存储下来以备将来使用。 +记忆化搜索本质上是动态规划的一种实现方式。其核心思想是在递归求解子问题时,先判断该子问题是否已经被计算过——如果有,则直接返回缓存结果;如果没有,则递归计算,并将结果存入缓存,供后续复用。 -举个例子,比如「斐波那契数列」的定义是:$f(0) = 0, f(1) = 1, f(n) = f(n - 1) + f(n - 2)$。如果我们使用递归算法求解第 $n$ 个斐波那契数,则对应的递推过程如下: +以「斐波那契数列」为例,其定义为:$f(0) = 0, f(1) = 1, f(n) = f(n - 1) + f(n - 2)$。如果直接递归求解第 $n$ 项,递归树会出现大量重复计算: ![记忆化搜索](https://qcdn.itcharge.cn/images/20240514110503.png) -从图中可以看出:如果使用普通递归算法,想要计算 $f(5)$,需要先计算 $f(3)$ 和 $f(4)$,而在计算 $f(4)$ 时还需要计算 $f(3)$。这样 $f(3)$ 就进行了多次计算,同理 $f(0)$、$f(1)$、$f(2)$ 都进行了多次计算,从而导致了重复计算问题。 +如上图所示,普通递归在计算 $f(5)$ 时,会多次计算 $f(3)$、$f(2)$ 等子问题,导致效率低下。 -为了避免重复计算,在递归的同时,我们可以使用一个缓存(数组或哈希表)来保存已经求解过的 $f(k)$ 的结果。如上图所示,当递归调用用到 $f(k)$ 时,先查看一下之前是否已经计算过结果,如果已经计算过,则直接从缓存中取值返回,而不用再递推下去,这样就避免了重复计算问题。 +为避免这种重复,记忆化搜索在递归过程中引入缓存(如数组或哈希表),每当递归到 $f(k)$ 时,先检查缓存中是否已有结果——若有则直接返回,无需再次递归。这样即可显著减少重复计算,提高效率。 -使用「记忆化搜索」方法解决斐波那契数列的代码如下: +下面是用记忆化搜索实现斐波那契数列的示例代码: ```python class Solution: def fib(self, n: int) -> int: - # 使用数组保存已经求解过的 f(k) 的结果 + # 初始化备忘录数组,memo[i] 用于存储 f(i) 的计算结果,避免重复递归 memo = [0 for _ in range(n + 1)] return self.my_fib(n, memo) def my_fib(self, n: int, memo: List[int]) -> int: + # 边界情况:n 为 0 时,斐波那契数为 0 if n == 0: return 0 + # 边界情况:n 为 1 时,斐波那契数为 1 if n == 1: return 1 - # 已经计算过结果 + # 如果 f(n) 已经计算过,直接返回缓存结果,避免重复计算 if memo[n] != 0: return memo[n] - # 没有计算过结果 + # 递归计算 f(n - 1) 和 f(n - 2),并将结果存入备忘录 memo[n] = self.my_fib(n - 1, memo) + self.my_fib(n - 2, memo) return memo[n] ``` -## 2. 记忆化搜索与递推区别 +## 2. 记忆化搜索与递推的区别 -「记忆化搜索」与「递推」都是动态规划的实现方式,但是两者之间有一些区别。 +「记忆化搜索」和「递推」都是动态规划的常用实现方式,但两者在思路和实现上存在明显差异: -> **记忆化搜索**:「自顶向下」的解决问题,采用自然的递归方式编写过程,在过程中会保存每个子问题的解(通常保存在一个数组或哈希表中)来避免重复计算。 +> **记忆化搜索**:采用「自顶向下」的递归方式解决问题。每当遇到一个子问题时,先判断其结果是否已被计算并缓存(通常用数组或哈希表存储),如果已缓存则直接返回,否则递归计算并缓存结果,避免重复计算。 > -> - 优点:代码清晰易懂,可以有效的处理一些复杂的状态转移方程。有些状态转移方程是非常复杂的,使用记忆化搜索可以将复杂的状态转移方程拆分成多个子问题,通过递归调用来解决。 -> - 缺点:可能会因为递归深度过大而导致栈溢出问题。 -> -> **递推**:「自底向上」的解决问题,采用循环的方式编写过程,在过程中通过保存每个子问题的解(通常保存在一个数组或哈希表中)来避免重复计算。 -> -> - 优点:避免了深度过大问题,不存在栈溢出问题。计算顺序比较明确,易于实现。 -> - 缺点:无法处理一些复杂的状态转移方程。有些状态转移方程非常复杂,如果使用递推方法来计算,就会导致代码实现变得非常困难。 - -根据记忆化搜索和递推的优缺点,我们可以在不同场景下使用这两种方法。 +> - **优点**:代码结构直观,易于理解,尤其适合处理状态转移方程复杂、难以直接递推出解的问题。通过递归自然地分解子问题,便于实现。 +> - **缺点**:递归层数过深时,可能导致栈溢出,空间消耗较大。 -适合使用「记忆化搜索」的场景: +> **递推**:采用「自底向上」的循环方式解决问题。通过预先确定的顺序,逐步计算并保存每个子问题的解,最终得到原问题的答案。 +> +> - **优点**:避免了递归带来的栈溢出风险,计算顺序清晰,通常空间和时间效率更高。 +> - **缺点**:对于状态转移方程复杂、依赖关系不明确的问题,递推实现较为困难,代码可读性和灵活性较差。 -1. 问题的状态转移方程比较复杂,递推关系不是很明确。 -2. 问题适合转换为递归形式,并且递归深度不会太深。 +**选择建议**: -适合使用「递推」的场景: +- 适合使用记忆化搜索的场景: + 1. 状态转移方程复杂,难以直接递推出解。 + 2. 问题本身适合递归建模,且递归深度可控。 -1. 问题的状态转移方程比较简单,递归关系比较明确。 -2. 问题不太适合转换为递归形式,或者递归深度过大容易导致栈溢出。 +- 适合使用递推的场景: + 1. 状态转移方程简单明了,递推关系清晰。 + 2. 递归深度过大,担心栈溢出,或希望提升空间效率。 -## 3. 记忆化搜索解题步骤 +## 3. 记忆化搜索的解题步骤 -我们在使用记忆化搜索解决问题的时候,其基本步骤如下: +使用记忆化搜索时,通常遵循以下步骤: -1. 写出问题的动态规划「状态」和「状态转移方程」。 -2. 定义一个缓存(数组或哈希表),用于保存子问题的解。 -3. 定义一个递归函数,用于解决问题。在递归函数中,首先检查缓存中是否已经存在需要计算的结果,如果存在则直接返回结果,否则进行计算,并将结果存储到缓存中,再返回结果。 -4. 在主函数中,调用递归函数并返回结果。 +1. 明确问题的动态规划「状态」以及「状态转移方程」。 +2. 定义一个缓存结构(如数组或哈希表),用于存储已计算过的子问题结果,避免重复计算。 +3. 编写递归函数:每次递归时,先判断当前子问题的解是否已在缓存中,若有则直接返回,否则递归计算,并将结果存入缓存后返回。 +4. 在主函数中初始化缓存,并调用递归函数获取最终答案。 ## 4. 记忆化搜索的应用 -### 4.1 目标和 +### 4.1 经典例题:目标和 #### 4.1.1 题目链接 @@ -118,17 +118,20 @@ class Solution: ##### 思路 1:深度优先搜索(超时) -使用深度优先搜索对每位数字进行 `+` 或者 `-`,具体步骤如下: +采用深度优先搜索(DFS)的方法,对每个数字尝试添加 `+` 或 `-`,具体步骤如下: + +1. 定义递归函数 `dfs(i, cur_sum)`,表示从下标 $i$ 开始,当前累加和为 $cur\_sum$ 时,能够得到目标和 $target$ 的方案数。 +2. 初始调用为 `dfs(0, 0)`,即从第一个数字、当前和为 $0$ 开始递归。 +3. 递归终止条件:当 $i$ 等于数组长度 $size$ 时,说明已经处理完所有数字。 + - 如果此时 $cur\_sum$ 等于 $target$,返回 $1$,表示找到一种可行方案。 + - 否则返回 $0$,表示当前路径不可行。 +4. 对于每个位置 $i$,递归地分别尝试加上 $nums[i]$ 和减去 $nums[i]$,即: + - 递归调用 `dfs(i + 1, cur_sum + nums[i])` + - 递归调用 `dfs(i + 1, cur_sum - nums[i])` +5. 将上述两种选择的返回值相加,得到当前位置 $i$、当前和 $cur\_sum$ 下的总方案数,并返回。 +6. 最终答案即为 `dfs(0, 0)` 的返回值。 -1. 定义从位置 $0$、和为 $0$ 开始,到达数组尾部位置为止,和为 $target$ 的方案数为 `dfs(0, 0)`。 -2. 下面从位置 $0$、和为 $0$ 开始,以深度优先搜索遍历每个位置。 -3. 如果当前位置 $i$ 到达最后一个位置 $size$: - 1. 如果和 $cur\_sum$ 等于目标和 $target$,则返回方案数 $1$。 - 2. 如果和 $cur\_sum$ 不等于目标和 $target$,则返回方案数 $0$。 -4. 递归搜索 $i + 1$ 位置,和为 $cur\_sum - nums[i]$ 的方案数。 -5. 递归搜索 $i + 1$ 位置,和为 $cur\_sum + nums[i]$ 的方案数。 -6. 将 4 ~ 5 两个方案数加起来就是当前位置 $i$、和为 $cur\_sum$ 的方案数,返回该方案数。 -7. 最终方案数为 `dfs(0, 0)`,将其作为答案返回即可。 +这种方法会遍历所有可能的加减组合,统计满足条件的方案数。 ##### 思路 1:代码 @@ -156,21 +159,21 @@ class Solution: ##### 思路 2:记忆化搜索 -在思路 1 中我们单独使用深度优先搜索对每位数字进行 `+` 或者 `-` 的方法超时了。所以我们考虑使用记忆化搜索的方式,避免进行重复搜索。 +在思路 1 中,单纯使用深度优先搜索(DFS)对每个数字尝试加号或减号,导致大量重复计算,最终超时。为此,我们引入记忆化搜索,通过缓存中间结果来避免重复递归。 + +具体做法如下: -这里我们使用哈希表 $table$ 记录遍历过的位置 $i$ 及所得到的的当前和$cur\_sum$ 下的方案数,来避免重复搜索。具体步骤如下: +1. 使用哈希表 $table$ 记录每个状态 $(i, cur\_sum)$ 下的方案数,$i$ 表示当前处理到的下标,$cur\_sum$ 表示当前累加和。 +2. 定义递归函数 `dfs(i, cur_sum)`,表示从下标 $i$ 开始,当前累加和为 $cur\_sum$ 时,能够得到目标和 $target$ 的方案数。初始调用为 `dfs(0, 0)`。 +3. 递归终止条件:当 $i$ 等于数组长度时,如果 $cur\_sum = target$,返回 $1$,否则返回 $0$。 +4. 如果状态 $(i, cur\_sum)$ 已经在 $table$ 中,直接返回缓存的结果,避免重复计算。 +5. 否则,递归计算两种选择的方案数: + - 选择 $nums[i]$ 加号:`dfs(i + 1, cur_sum + nums[i])` + - 选择 $nums[i]$ 减号:`dfs(i + 1, cur_sum - nums[i])` + 将两者结果相加,得到当前状态的总方案数,并存入 $table$。 +6. 最终返回 `dfs(0, 0)` 作为答案。 -1. 定义从位置 $0$、和为 $0$ 开始,到达数组尾部位置为止,和为 $target$ 的方案数为 `dfs(0, 0)`。 -2. 下面从位置 $0$、和为 $0$ 开始,以深度优先搜索遍历每个位置。 -3. 如果当前位置 $i$ 遍历完所有位置: - 1. 如果和 $cur\_sum$ 等于目标和 $target$,则返回方案数 $1$。 - 2. 如果和 $cur\_sum$ 不等于目标和 $target$,则返回方案数 $0$。 -4. 如果当前位置 $i$、和为 $cur\_sum$ 之前记录过(即使用 $table$ 记录过对应方案数),则返回该方案数。 -5. 如果当前位置 $i$、和为 $cur\_sum$ 之前没有记录过,则: - 1. 递归搜索 $i + 1$ 位置,和为 $cur\_sum - nums[i]$ 的方案数。 - 2. 递归搜索 $i + 1$ 位置,和为 $cur\_sum + nums[i]$ 的方案数。 - 3. 将上述两个方案数加起来就是当前位置 $i$、和为 $cur\_sum$ 的方案数,将其记录到哈希表 $table$ 中,并返回该方案数。 -6. 最终方案数为 `dfs(0, 0)`,将其作为答案返回即可。 +通过记忆化搜索,大幅减少了重复子问题的计算,提高了效率。 ##### 思路 2:代码 @@ -202,7 +205,7 @@ class Solution: - **时间复杂度**:$O(2^n)$。其中 $n$ 为数组 $nums$ 的长度。 - **空间复杂度**:$O(n)$。递归调用的栈空间深度不超过 $n$。 -### 4.2 第 N 个泰波那契数 +### 4.2 经典例题:第 N 个泰波那契数 #### 4.2.1 题目链接 @@ -243,13 +246,12 @@ T_4 = 1 + 1 + 2 = 4 ##### 思路 1:记忆化搜索 -1. 问题的状态定义为:第 $n$ 个泰波那契数。其状态转移方程为:$T_0 = 0, T_1 = 1, T_2 = 1$,且在 $n >= 0$ 的条件下,$T_{n + 3} = T_{n} + T_{n+1} + T_{n+2}$。 -2. 定义一个长度为 $n + 1$ 数组 $memo$ 用于保存一斤个计算过的泰波那契数。 -3. 定义递归函数 `my_tribonacci(n, memo)`。 - 1. 当 $n = 0$ 或者 $n = 1$,或者 $n = 2$ 时直接返回结果。 - 2. 当 $n > 2$ 时,首先检查是否计算过 $T(n)$,即判断 $memo[n]$ 是否等于 $0$。 - 1. 如果 $memo[n] \ne 0$,说明已经计算过 $T(n)$,直接返回 $memo[n]$。 - 2. 如果 $memo[n] = 0$,说明没有计算过 $T(n)$,则递归调用 `my_tribonacci(n - 3, memo)`、`my_tribonacci(n - 2, memo)`、`my_tribonacci(n - 1, memo)`,并将计算结果存入 $memo[n]$ 中,并返回 $memo[n]$。 +1. 状态定义:用 $T_n$ 表示第 $n$ 个泰波那契数。状态转移方程为:$T_0 = 0, T_1 = 1, T_2 = 1$,当 $n \ge 3$ 时,$T_n = T_{n-1} + T_{n-2} + T_{n-3}$。 +2. 使用一个长度为 $n + 1$ 的数组 $memo$,用于记录已经计算过的泰波那契数,避免重复计算。 +3. 定义递归函数 `my_tribonacci(n, memo)`,实现如下: + 1. 如果 $n = 0$,返回 $0$;若 $n = 1$ 或 $n = 2$,返回 $1$。 + 2. 如果 $memo[n]$ 已经被赋值(即 $memo[n] \ne 0$),直接返回 $memo[n]$。 + 3. 否则递归计算 $my\_tribonacci(n - 1, memo)$、$my\_tribonacci(n - 2, memo)$ 和 $my\_tribonacci(n-3, memo)$,将三者之和赋值给 $memo[n]$,并返回 $memo[n]$。 ##### 思路 1:代码 @@ -277,6 +279,17 @@ class Solution: - **时间复杂度**:$O(n)$。 - **空间复杂度**:$O(n)$。 +## 5. 总结 + +记忆化搜索是动态规划的一种重要实现方式,通过缓存已计算过的子问题结果来避免重复计算,从而显著提高算法效率。其核心思想是在递归求解过程中,先检查当前状态是否已被计算过,如果有则直接返回缓存结果,否则递归计算并将结果存入缓存。 + +记忆化搜索采用自顶向下的递归方式,代码结构直观易懂,特别适合处理状态转移方程复杂、难以直接递推的问题。通过递归自然地分解子问题,使得复杂问题的建模变得相对简单。然而,递归深度过深时可能导致栈溢出,且空间消耗相对较大。 + +在实际应用中,记忆化搜索广泛应用于各种动态规划问题,如斐波那契数列、目标和问题、泰波那契数等。通过合理使用记忆化技术,可以将原本指数级的时间复杂度降低到多项式级别,大大提升算法性能。 + +选择记忆化搜索还是递推方式,需要根据具体问题的特点来决定。对于状态转移方程复杂、递归深度可控的问题,记忆化搜索是很好的选择;而对于状态转移简单、需要优化空间效率的问题,递推方式可能更为合适。 + + ## 题目练习 - [0494. 目标和](https://github.com/ITCharge/AlgoNote/tree/main/docs/solutions/0400-0499/target-sum.md)