数据结构与算法第二章 线性表、栈、队列、数组、字符串、树、二叉树、哈希表的增删查

564 篇文章 141 订阅

03 增删查:掌握数据处理的基本操作,以不变应万变

通过前面课时的学习,相信你已经建立了利用数据结构去完成时空转移的思想。接下来,你需要在理论思想的指导下灵活使用。其实,要想灵活使用数据结构,你需要先弄清楚数据在代码中被处理、加工的最小单位动作,也就是数据结构的基本操作,有了这些动作之后,你就可以基于此去选择更合适的数据结构了。本课时我们就先来学习数据处理的基本操作。

代码对数据的处理

我们重温一下上一课时的例子。在一个数组中找出出现次数最多的那个元素的数值。例如,输入数组 a = [1,2,3,4,5,5,6] 中,只有 5 出现了两次,其余都是 1 次。显然 5 出现的次数最多,则输出 5。为了降低时间复杂度,我们引入了 k-v 的字典的数据结构。那么问题来了,究竟是什么原因,促使我们想到了使用字典的数据结构呢?如果不使用字典,改为使用数组行不行呢?

为了回答这些问题,我们先看一下究竟此处代码需要对数据进行哪些操作。我们提到过,这段代码处理数据的核心思路是:

  • 第一步,根据原始数组计算每个元素出现的次数;

  • 第二步,根据第一步的结果,找到出现次数最多的元素。

首先,我们来分析第一步统计出现次数的处理。此时,你还不知道应该采用什么数据结构。

对于每一次的循环,你得到了输入数组中的某个元素 a[ i ] 。接着,你需要判断这个元素在未知的数据结构中是否出现过:

  • 如果出现了,就需要对出现的次数加 1。

  • 如果没有出现过,则把这个元素新增到未知数据结构中,并且把次数赋值为 1。

4SfjILfGIwwUQxq2.gif

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/a8fd535020571e5b04d709ca0239229f.jpeg#pic_center)

这里的数据操作包括以下 3 个。

  • 查找: 看能否在数据结构中查找到这个元素,也就是判断元素是否出现过。

  • 新增: 针对没有出现过的情况,新增这个元素。

  • 改动: 针对出现过的情况,需要对这个元素出现的次数加 1。

接下来,我们一起分析第二步。访问数据结构中的每个元素,找到次数最多的元素。这里涉及的数据操作很简单,只有查找

因此,这段代码需要高频使用查找的功能。此时,第一步的查找动作嵌套在 for 循环中,如果你的代码不能在 O(1) 的时间复杂度内完成,则代码整体的时间复杂度并没有下降。而能在 O(1) 的时间复杂度内完成查找动作的数据结构,只有字典类型。这样,外层 for 循环是 O(n) 的时间复杂度,内部嵌套的查找操作是 O(1) 的时间复杂度。整体计算下来,就仍然是 O(n) 的时间复杂度。字典的查找是通过键值对的匹配完成的,它可以在 O(1) 时间复杂度内,实现对数值条件查找。关于字典的内容,我们在后续的课程中会详细解答。

现在,我们换个解决方案。假设采用两个数组,分别按照对应顺序记录元素及其对应的出现次数。数组对于元素的查找只能逐一访问,时间复杂度是 O(n)。也就是说,在 O(n) 复杂度的 for 循环中,又嵌套了 O(n) 复杂度的查找动作,所以时间复杂度是 O(n²)。因此,这里的数据结构,只能采用字典类型。

数据处理的基本操作

不管是数组还是字典,都需要额外开辟空间,对数据进行存储。而且数据存储的数量,与输入的数据量一致。因此,消耗的空间复杂度相同,都是 O(n)。由前面的分析可见,同样采用复杂的数据结构,消耗了 O(n) 的空间复杂度,其对时间复杂度降低的贡献有可能不一样。因此,我们必须要设计合理的数据结构,以达到降低时间损耗的目的。

而设计合理的数据结构,又要从问题本身出发,我们可以采用这样的思考顺序:

  • 首先我们分析这段代码到底对数据先后进行了哪些操作。

  • 然后再根据分析出来的数据操作,找到合理的数据结构。

这样我们就把数据处理的基本操作梳理了出来。今后,即使你遇到更复杂的问题,无非就是这些基本操作的叠加和组合。只要按照上述的逻辑进行思考,就可以轻松设计出合理的数据结构,

其实,代码对数据处理的操作类型非常少。代码对数据的处理就是代码对输入数据进行计算,得到结果并输出的过程。数据处理的操作就是找到需要处理的数据,计算结果,再把结果保存下来。这个过程总结为以下操作:

  • 找到要处理的数据。这就是按照某些条件进行查找

  • 把结果存到一个新的内存空间中。这就是在现有数据上进行新增

  • 把结果存到一个已使用的内存空间中。这需要先删除内存空间中的已有数据,再新增新的数据。

经过对代码的拆解,你会发现即便是很复杂的代码,它对数据的处理也只有这 3 个基本操作,增、删、查。只要你围绕这 3 个数据处理的操作进行分析,就能得出解决问题的最优方案。常用的分析方法可以参考下面的 3 个步骤:

  • 首先,这段代码对数据进行了哪些操作?

  • 其次,这些操作中,哪个操作最影响效率,对时间复杂度的损耗最大?

  • 最后,哪种数据结构最能帮助你提高数据操作的使用效率?

这 3 个步骤构成了设计合理数据结构的方法论。围绕第一步和第二步的数据处理的操作,我再补充一些例子帮助你理解。而第三个方面就需要你拥有足够扎实的数据结构基础知识了,我会在后面的课程中详细讨论。

数据操作与数据结构的案例

我们先来看一个关于查找的例子。查找,就是从复杂的数据结构中,找到满足某个条件的元素。通常可从以下两个方面来对数据进行查找操作:

  • 根据元素的位置或索引来查找。

  • 根据元素的数值特征来查找。

针对上述两种情况,我们分别给出例子进行详细介绍。

例 1,我们来看第二个例子,对于一个数组,找到数组中的第二个元素并输出。

这个问题的处理很简单。由于数组本身具有索引 index ,因此直接通过索引就能查找到其第二个元素。别忘了,数组的索引值是从 0 开始的,因此第二个元素的索引值是 1 。不难发现,因为有了 index 的索引,所以我们就可以直接进行查找操作来,这里的时间复杂度为 O(1)。

例 2,我们来看第二个例子,如果是链表,如何找到这个链表中的第二个元素并输出呢?

链表和数组一样,都是 O(n) 空间复杂度的复杂数据结构。但其区别之一就是,数组有 index 的索引,而链表没有。链表是通过指针,让元素按某个自定义的顺序“手拉手”连接在一起的。

既然是这样,要查找其第二个元素,就必须要先知道第一个元素在哪里。以此类推,链表中某个位置的元素的查找,只能通过从前往后的顺序逐一去查找。不难发现,链表因为没有索引,只能“一个接一个”地按照位置条件查找,在这种情况下时间复杂度就是 O (n)。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/cb47b5c10c738128ac194fc84a9182d6.jpeg#pic_center)

例 3,我们再来看第三个例子,关于数值条件的查找。

我们要查找出,数据结构中数值等于 5 的元素是否存在。这次的查找,无论是数组还是链表都束手无策了。唯一的方法,也只有按照顺序一个接一个地去判断元素数值是否满足等于 5 的条件。很显然,这样的查找方法时间复杂度是 O(n)。那么有没有时间复杂度更低的方式呢?答案当然是:有。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/39ed6c132a3b37c61077c184eb827d53.jpeg#pic_center)

在前面的课时中,我们遇到过要查找出数组中出现次数最多的元素的情况。我们采用的方法是,把数组转变为字典,以保存元素及其出现次数的 k-v 映射关系。而在每次的循环中,都需要对当前遍历的元素,去查找它是否在字典中出现过。这里就是很实际的按照元素数值查找的例子。如果借助字典的数据类型,这个例子的查找问题,就可以在 O(1) 的时间复杂度内完成了。

例 4,我们再来看第四个例子,关于复杂数据结构中新增数据,这里有两个可能.

  • 第一个是在这个复杂数据结构的最后,新增一条数据。

  • 第二个是在这个复杂数据结构的中间某个位置,新增一条数据。

这两个可能性的区别在于,新增数据之后,是否会导致原有数据结构中数据的位置顺序改变。接下来,我们分别来举例说明。

在复杂数据结构中,新增一条数据。假设是在数据结构的最后新增数据。此时新增一条数据后,对原数据没有产生任何影响。因此,执行的步骤是:

  • 首先,通过查找操作找到数据结构中最后一个数据的位置;

  • 接着,在这个位置之后,通过新增操作,赋值或者插入一条新的数据即可。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/7400a5606f00fb5f8bb3ab0ffc07208a.jpeg#pic_center)

如果是在数据结构中间的某个位置新增数据,则会对插入元素的位置之后的元素产生影响,导致数据的位置依次加 1 。例如,对于某个长度为 4 的数组,在第二个元素之后插入一个元素。则修改后的数组中,原来的第一、第二个元素的位置不发生变化,第三个元素是新插入的元素,第四、第五个元素则是原来的第三、第四个元素。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/642bb2e6b46473e389858063e2a67483.jpeg#pic_center)

我们再来看看删除。在复杂数据结构中删除数据有两个可能:

  • 第一个是在这个复杂数据结构的最后,删除一条数据。

  • 第二个是在这个复杂数据结构的中间某个位置,删除一条数据。

这两个可能性的区别在于,删除了数据之后,是否会导致原有数据结构中数据的位置顺序改变。由于删除操作和新增操作高度类似,我们就不再举详细阐述了。

通过上述例子的学习之后,你就可以对它们进行组合,去玩转更复杂的数据操作了,我们再来看一个例子。

例 5,在某个复杂数据结构中,在第二个元素之后新增一条数据。随后再删除第 1 个满足数值大于 6 的元素。我们来试着分析这个任务的数据操作过程。这里有两个步骤的操作:

  • 第一步,在第二个元素之后新增一条数据。这里包含了查找和新增两个操作,即查找第二个元素的位置,并在数据结构中间新增一条数据。

  • 第二步,删除第 1 个满足数值大于 6 的元素。这里包含查找和删除两个操作,即查找出第 1 个数值大于 6 的元素的位置,并删除这个位置的元素。

因此,总共需要完成的操作包括,按照位置的查找、新增和按照数据数值的查找、删除。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/f2877be61f45e29468ceb674750ad947.jpeg#pic_center)

总结

好的,这节课的内容就到这里了。这一节的内容在很多数据结构的课程中都是没有的,这是因为大部分课程设计时,都普遍默认你已经掌握了这些知识。但是,这些知识恰恰又是你学习数据结构的根基。只有在充分了解问题、明确数据操作的方法之后,才能设计出更加高效的数据结构类型。

经过我们的分析,数据处理的基本操作只有 3 个,分别是增、删、查。其中,增和删又可以细分为在数据结构中间的增和删,以及在数据结构最后的增和删。区别就在于原数据的位置是否发生改变。查找又可以细分为按照位置条件的查找和按照数据数值特征的查找。几乎所有的数据处理,都是这些基本操作的组合和叠加。

练习题

下面我们给出一道练习题。对于一个包含 5 个元素的数组,如果要把这个数组元素的顺序翻转过来。你可以试着分析该过程需要对数据进行哪些操作?

在实际的工作中,如果你不知道该用什么数据结构的时候,就一定要回归问题本源。从数据需要被处理的动作出发。只有明确了会有什么动作,才能找到最合适的解决方法。如果你在拆解数据处理的操作过程中遇到什么问题或者关于拆解有新的想法,欢迎在留言区和我分享。

04 如何完成线性表结构下的增删查?

通过前面课时的学习,我们了解到数据在代码中被处理和加工的最小单位动作是增、删、查。它们是深入学习数据结构的根基,通过“增删查”的操作,我们可以选择更合适的数据结构来解决实际工作中遇到的问题。例如,几个客户端分别向服务端发送请求,服务端要采用先到先得的处理方式,应该如何设计数据结构呢?接下来,从本课时开始,我们将正式开始系统性的学习数据结构的内容。

什么是数据结构?

首先,我们简单探讨一下什么是数据结构。数据结构,从名字上来看是数据的结构,也就是数据的组织方式。在数据结构适用的场合中,需要有一定量的数据。如果数据都没有,也就不用讨论数据如何组织了。当我们有了一定数量的数据时,就需要考虑以什么样的方式去对这些数据进行组织了。

接下来,我将通过一个实际案例来帮助你更好地理解数据结构。假设你是一所幼儿园的园长,现在你们正在组织一场运动会,所有的小朋友需要在操场上接受检阅。那么,如何组织小朋友有序站队并完成检阅呢?

几个可能的方式是,让所有的小朋友站成一横排,或者让小朋友站成方阵,又或者让所有的小朋友手拉手,围成一个大圆圈等等。很显然,这里有无数种可行的组织方式。具体选择哪个组织方式,取决于哪一种能更好地展示出小朋友们的风采。

试想一下,当计算机要处理大量数据时,同样需要考虑如何去组织这些数据,这就是数据结构。类似于小朋友的站队方式有无数种情况,数据组织的方式也是有无数种可能性。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/376ccaac265d7098096350fbb4934426.jpeg#pic_center)

然而,在实际开发中,经过工程师验证并且能有效解决问题的高效率数据结构就比较有限了。事实上,只要我们把这些能真正解决问题的数据结构学会,就足以成为一名合格的软件工程师了。

什么是线性表

好了,铺垫完数据结构的基本概念后,我们就正式进入到这个课程中的第一个数据结构的学习,线性表。

线性表是 n 个数据元素的有限序列,最常用的是链式表达,通常也叫作线性链表或者链表。在链表中存储的数据元素也叫作结点,一个结点存储的就是一条数据记录。每个结点的结构包括两个部分:

  • 第一是具体的数据值;

  • 第二是指向下一个结点的指针。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/351377bc898eab3d55b9f7e0a74fb6c3.png#pic_center)

在链表的最前面,通常会有个头指针用来指向第一个结点。对于链表的最后一个结点,由于在它之后没有下一个结点,因此它的指针是个空指针。链表结构,和小朋友手拉手站成一排的场景是非常相似的。

例如,你需要处理的数据集是 10 个同学考试的得分。如果用链表进行存储,就会得到如下的数据:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/48cc6eac3babd41932bc1d2a4e0ce9cb.png#pic_center)

仔细观察上图,你会发现这个链表只能通过上一个结点的指针找到下一个结点,反过来则是行不通的。因此,这样的链表也被称作单向链表。

有时候为了弥补单向链表的不足,我们可以对结点的结构进行改造:

  • 对于一个单向链表,让最后一个元素的指针指向第一个元素,就得到了循环链表;

  • 或者把结点的结构进行改造,除了有指向下一个结点的指针以外,再增加一个指向上一个结点的指针。这样就得到了双向链表。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/776b3a79aefb6a4504d8e6711715c49a.png#pic_center)

在这里插入图片描述

同样的,还可以对双向链表和循环链表进行融合,就得到了双向循环链表,如下图所示:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/0951196d10d0d51421617f6e32c8c2dc.png#pic_center)

这些种类的链表,都是以单向链表为基础进行的变种。在某些场景下能提高线性表的效率。

线性表对于数据的增删查处理

学会了线性表原理之后,我们就来围绕数据的增删查操作,来看看线性表的表现。在这里我们主要介绍单向链表的增删查操作,其他类型的链表与此雷同,我们就不再重复介绍了。

首先看一下增加操作。如下有一个链表,它存储了 10 个同学的考试成绩。现在发现这样的问题,在这个链表中,有一个同学的成绩忘了被存储进去。假设我们要把这个成绩在红色的结点之后插入,那么该如何进行呢?

其实,链表在执行数据新增的时候非常容易,只需要把待插入结点的指针指向原指针的目标,把原来的指针指向待插入的结点,就可以了。如下图所示:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/7aa408afbd5195780bd0ffc7120d7dda.png#pic_center)

代码如下:

s.next = p.next;
p.next = s;

接下来我们看一下删除操作。还是这个存储了同学们考试成绩的链表,假设里面有一个成绩的样本是被误操作放进来的,我们需要把这个样本删除。链表的删除操作跟新增操作一样,都是非常简单的。如果待删除的结点为 b,那么只需要把指向 b 的指针 (p.next),指向 b 的指针指向的结点(p.next.next)。如下图所示:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/3123e98d2fa58e5c294784cb9db9618f.png#pic_center)

代码如下:

p.next = p.next.next;

最后,我们再来看看查找操作。我们在前面的课时中提到过,查找操作有两种情况:

  • 第一种情况是按照位置序号来查找。

它和数组中的 index 是非常类似的。假设一个链表中,按照学号存储了 10 个同学的考试成绩。现在要查找出学号等于 5 的同学,他的考试成绩是多少,该怎么办呢?

其实,链表的查找功能是比较弱的,对于这个查找问题,唯一的办法就是一个一个地遍历去查找。也就是,从头开始,先找到学号为 1 的同学,再经过他跳转到学号为 2 的同学。直到经过多次跳转,找到了学号为 5 的同学,才能取出这个同学的成绩。如下图所示:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/f0c071dac444426ebba36502d15f26fe.jpeg#pic_center)
  • 第二种情况是按照具体的成绩来查找。

同样,假设在一个链表中,存储了 10 个同学的考试成绩。现在要查找出是否有人得分为 95 分。链表的价值在于用指针按照顺序连接了数据结点,但对于每个结点的数值则没有任何整合。当需要按照数值的条件进行查找时,除了按照先后顺序进行遍历,别无他法。

因此,解决方案是,判断第一个结点的值是否等于 95:

  • 如果是,则返回有人得分为 95 分;

  • 如果不是,则需要通过指针去判断下一个结点的值是否等于 95。以此类推,直到把所有结点都访问完。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/57b08aa15c1bb993048430e9ef5b9fbc.jpeg#pic_center)

在这里插入图片描述

根据这里的分析不难发现,链表在新增、删除数据都比较容易,可以在 O(1) 的时间复杂度内完成。但对于查找,不管是按照位置的查找还是按照数值条件的查找,都需要对全部数据进行遍历。这显然就是 O(n) 的时间复杂度。

虽然链表在新增和删除数据上有优势,但仔细思考就会发现,这个优势并不实用。这主要是因为,在新增数据时,通常会伴随一个查找的动作。例如,在第五个结点后,新增一个新的数据结点,那么执行的操作就包含两个步骤:

  • 第一步,查找第五个结点;

  • 第二步,再新增一个数据结点。整体的复杂度就是 O(n) + O(1)。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/9068edd4a93928829529e82ffdebcdef.jpeg#pic_center)

根据我们前面所学的复杂度计算方法,这也等同于 O(n) 的时间复杂度。线性表真正的价值在于,它对数据的存储方式是按照顺序的存储。如果数据的元素个数不确定,且需要经常进行数据的新增和删除时,那么链表会比较合适。如果数据元素大小确定,删除插入的操作并不多,那么数组可能更适合些。

关于数组的知识,我们在后续的课程中会详细展开。

线性表案例

关于线性表,最高频的问题都会围绕数据顺序的处理。我们在这里给出一些例子来帮助你更好地理解。

例 1,链表的翻转。给定一个链表,输出翻转后的链表。例如,输入1 ->2 -> 3 -> 4 ->5,输出 5 -> 4 -> 3 -> 2 -> 1。

我们来仔细看一下这个问题的难点在哪里,这里有两种情况:

  • 如果是数组的翻转,这会非常容易。原因在于,数组在连续的空间进行存储,可以直接求解出数组的长度。而且,数组可以通过索引值去查找元素,然后对相应的数据进行交换操作而完成翻转。

  • 但对于某个单向链表,它的指针结构造成了它的数据通路有去无回,一旦修改了某个指针,后面的数据就会造成失联的状态。为了解决这个问题,我们需要构造三个指针 prev、curr 和 next,对当前结点、以及它之前和之后的结点进行缓存,再完成翻转动作。具体如下图所示:

while(curr){
    next = curr.next;
    curr.next = prev;
    prev = curr;
    curr = next;
}

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/b3f5cd4b1c1227bc34dab17395d88277.jpeg#pic_center)

例 2,给定一个奇数个元素的链表,查找出这个链表中间位置的结点的数值。

这个问题也是利用了链表的长度无法直接获取的不足做文章,解决办法如下:

  • 一个暴力的办法是,先通过一次遍历去计算链表的长度,这样我们就知道了链表中间位置是第几个。接着再通过一次遍历去查找这个位置的数值。

  • 除此之外,还有一个巧妙的办法,就是利用快慢指针进行处理。其中快指针每次循环向后跳转两次,而慢指针每次向后跳转一次。如下图所示。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/019fa924ec79b0ce457712aaaad04d5f.jpeg#pic_center)
while(fast && fast.next && fast.next.next){
    fast = fast.next.next;
    slow = slow.next;
}

例 3,判断链表是否有环。如下图所示,这就是一个有环的链表。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/50a8a2333570b8460cdab55a25bacc29.png#pic_center)

链表的快慢指针方法,在很多链表操作的场景下都非常适用,对于这个问题也是一样。

假设链表有环,这个环里面就像是一个跑步赛道的操场一样。经过多次循环之后,快指针和慢指针都会进入到这个赛道中,就好像两个跑步选手在比赛。快指针每次走两格,而慢指针每次走一格,相对而言,快指针每次循环会多走一步。这就意味着:

  • 如果链表存在环,快指针和慢指针一定会在环内相遇,即 fast == slow 的情况一定会发生。

  • 反之,则最终会完成循环,二者从未相遇。

根据这个性质我们就能对链表是否有环进行准确地判断了。如下图所示:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/2f2f1c9806eeb09c1d83cf4a4898f3a6.jpeg#pic_center)

总结

好的,这节课的内容就到这里了。这一节的内容主要围绕线性表的原理、线性表对于数据的增删查操作展开。线性链表结构的每个结点,由数据的数值和指向下一个元素的指针构成。根据结构组合方式的不同,除了单向链表以外,还有双向链表、循环链表以及双向循环链表等变形。

经过我们的分析,链表在增、删方面比较容易实现,可以在 O(1) 的时间复杂度内完成。但对于查找,不管是按照位置的查找还是按照数值条件的查找,都需要对全部数据进行遍历。

线性表的价值在于,它对数据的存储方式是按照顺序的存储。当数据的元素个数不确定,且需要经常进行数据的新增和删除时,那么链表会比较合适。链表的翻转、快慢指针的方法,是你必须掌握的内容。

练习题

最后我们留一道课后练习题。给定一个含有 n 个元素的链表,现在要求每 k 个节点一组进行翻转,打印翻转后的链表结果。其中,k 是一个正整数,且可被 n 整除。

例如,链表为 1 -> 2 -> 3 -> 4 -> 5 -> 6,k = 3,则打印 321654。我们给出一些提示,这个问题需要使用到链表翻转的算法。

如果你在链表的使用方面遇到困难,欢迎在留言区和我交流。

05 栈:后进先出的线性表,如何实现增删查?

通过前面课时的学习,相信你已经掌握了线性表的基本原理,以及如何完成线性表结构下的增删查操作。

线性表是使用非常广泛的一类数据结构,它对数据的顺序非常敏感,而且它对数据的增删操作非常灵活。在有序排列的数据中,可以灵活的执行增删操作,就好像是为排好队的数据增加了插队的入口。这既是灵活性也是缺陷,原因在于它的灵活性在某种程度上破坏了数据的原始顺序。在某些需要严格遵守数据处理顺序的场景下,我们就需要对线性表予以限制了。经过限制后的线性表,它们通常会被赋予一些新的名字。这一课时,我们就来学习其中一个限制后的线性表--栈。

栈是什么

你需要牢记一点,栈是一种特殊的线性表。栈与线性表的不同,体现在增和删的操作。具体而言,栈的数据结点必须后进先出。后进的意思是,栈的数据新增操作只能在末端进行,不允许在栈的中间某个结点后新增数据。先出的意思是,栈的数据删除操作也只能在末端进行,不允许在栈的中间某个结点后删除数据。

也就是说,栈的数据新增和删除操作只能在这个线性表的表尾进行,即在线性表的基础上加了限制。如下图所示:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/37e49a3223874e79e48649bb7c771581.png#pic_center)

因此,栈是一种后进先出的线性表。栈对于数据的处理,就像用砖头盖房子的过程。对于盖房子而言,新的砖头只能放在前一个砖头上面;而对于拆房子而言,我们需要从上往下拆砖头。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/adb112f63040682a71c05fb009da17f6.jpeg#pic_center)

宏观上来看,与数组或链表相比,栈的操作更为受限,那为什么我们要用这种受限的栈呢?其实,单纯从功能上讲,数组或者链表可以替代栈。然而问题是,数组或者链表的操作过于灵活,这意味着,它们过多暴露了可操作的接口。这些没有意义的接口过多,当数据量很大的时候就会出现一些隐藏的风险。一旦发生代码 bug 或者受到攻击,就会给系统带来不可预知的风险。虽然栈限定降低了操作的灵活性,但这也使得栈在处理只涉及一端新增和删除数据的问题时效率更高。

举个实际的例子,浏览器都有页面前进和后退功能,这就是个很典型的后进先出的场景。假设你先后访问了五个页面,分别标记为 1、2、3、4、5。当前你在页面 5,如果执行两次后退,则退回到了页面 3,如果再执行一次前进,则到了页面 4。处理这里的页面链接存储问题,栈就应该是我们首选的数据结构。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/2714a4a7b2a4e2abfbfc832cd9c3d8ca.jpeg#pic_center)

栈既然是线性表,那么它也包含了表头和表尾。不过在栈结构中,由于其操作的特殊性,会对表头和表尾的名字进行改造。表尾用来输入数据,通常也叫作栈顶(top);相应地,表头就是栈底(bottom)。栈顶和栈底是用来表示这个栈的两个指针。跟线性表一样,栈也有顺序表示和链式表示,分别称作顺序栈和链栈。

栈的基本操作

如何通过栈这个后进先出的线性表,来实现增删查呢?初始时,栈内没有数据,即空栈。此时栈顶就是栈底。当存入数据时,最先放入的数据会进入栈底。接着加入的数据都会放入到栈顶的位置。如果要删除数据,也只能通过访问栈顶的数据并删除。对于栈的新增操作,通常也叫作 push 或压栈。对于栈的删除操作,通常也叫作 pop 或出栈。对于压栈和出栈,我们分别基于顺序栈和链栈进行讨论。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/5e82a92d48df75c1e58b361f5431586e.jpeg#pic_center)
顺序栈

栈的顺序存储可以借助数组来实现。一般来说,会把数组的首元素存在栈底,最后一个元素放在栈顶。然后定义一个 top 指针来指示栈顶元素在数组中的位置。假设栈中只有一个数据元素,则 top = 0。一般以 top 是否为 -1 来判定是否为空栈。当定义了栈的最大容量为 StackSize 时,则栈顶 top 必须小于 StackSize。

当需要新增数据元素,即入栈操作时,就需要将新插入元素放在栈顶,并将栈顶指针增加 1。如下图所示:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/f0df6e5c03f3e7e3f020c4e8ee0136de.png#pic_center)

删除数据元素,即出栈操作,只需要 top - 1 就可以了。

对于查找操作,栈没有额外的改变,跟线性表一样,它也需要遍历整个栈来完成基于某些条件的数值查找。

链栈

关于链式栈,就是用链表的方式对栈的表示。通常,可以把栈顶放在单链表的头部,如下图所示。由于链栈的后进先出,原来的头指针就显得毫无作用了。因此,对于链栈来说,是不需要头指针的。相反,它需要增加指向栈顶的 top 指针,这是压栈和出栈操作的重要支持。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/fb64099937b8cea76d7779ed49d77789.png#pic_center)

对于链栈,新增数据的压栈操作,与链表最后插入的新数据基本相同。需要额外处理的,就是栈的 top 指针。如下图所示,插入新的数据,则需要让新的结点指向原栈顶,即 top 指针指向的对象,再让 top 指针指向新的结点。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/38e203252e61f0b382b45793d0b61196.png#pic_center)

在链式栈中进行删除操作时,只能在栈顶进行操作。因此,将栈顶的 top 指针指向栈顶元素的 next 指针即可完成删除。对于链式栈来说,新增删除数据元素没有任何循环操作,其时间复杂度均为 O(1)。

对于查找操作,相对链表而言,链栈没有额外的改变,它也需要遍历整个栈来完成基于某些条件的数值查找。

通过分析你会发现,不管是顺序栈还是链栈,数据的新增、删除、查找与线性表的操作原理极为相似,时间复杂度完全一样,都依赖当前位置的指针来进行数据对象的操作。区别仅仅在于新增和删除的对象,只能是栈顶的数据结点。

栈的案例

接下来,我们一起来看两个栈的经典案例,从中你可以更深切地体会到栈所发挥出的价值。

例 1,给定一个只包括 '(',')','{','}','[',']' 的字符串,判断字符串是否有效。有效字符串需满足:左括号必须与相同类型的右括号匹配,左括号必须以正确的顺序匹配。例如,{ [ ( ) ( ) ] } 是合法的,而 { ( [ ) ] } 是非法的。

这个问题很显然是栈发挥价值的地方。原因是,在匹配括号是否合法时,左括号是从左到右依次出现,而右括号则需要按照“后进先出”的顺序依次与左括号匹配。因此,实现方案就是通过栈的进出来完成。

具体为,从左到右顺序遍历字符串。当出现左括号时,压栈。当出现右括号时,出栈。并且判断当前右括号,和被出栈的左括号是否是互相匹配的一对。如果不是,则字符串非法。当遍历完成之后,如果栈为空。则合法。如下图所示:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/cdac801cb011e6e2af3b841086bdfaca.jpeg#pic_center)

代码如下:

public static void main(String[] args) {
    String s = "{[()()]}";
    System.out.println(isLegal(s));
}
private static int isLeft(char c) {
    if (c == '{' || c == '(' || c == '[') {
        return 1;
    } else {
        return 2;
    }
}
private static int isPair(char p, char curr) {
    if ((p == '{' && curr == '}') || (p == '[' && curr == ']') || (p == '(' && curr == ')')) {
        return 1;
    } else {
        return 0;
    }
}
private static String isLegal(String s) {
    Stack stack = new Stack();
    for (int i = 0; i < s.length(); i++) {
        char curr = s.charAt(i);
        if (isLeft(curr) == 1) {
            stack.push(curr);
        } else {
            if (stack.empty()) {
                return "非法";
            }
            char p = (char) stack.pop();
            if (isPair(p, curr) == 0) {
                return "非法";
            }
        }
    }
    if (stack.empty()) {
        return "合法";
    } else {
        return "非法";
    }
}

例 2,浏览器的页面访问都包含了后退和前进功能,利用栈如何实现?

我们利用浏览器上网时,都会高频使用后退和前进的功能。比如,你按照顺序先后访问了 5 个页面,分别标记为 1、2、3、4、5。现在你不确定网页 5 是不是你要看的网页,需要回退到网页 3,则需要使用到两次后退的功能。假设回退后,你发现网页 4 有你需要的信息,那么就还需要再执行一次前进的操作。

为了支持前进、后退的功能,利用栈来记录用户历史访问网页的顺序信息是一个不错的选择。此时需要维护两个栈,分别用来支持后退和前进。当用户访问了一个新的页面,则对后退栈进行压栈操作。当用户后退了一个页面,则后退栈进行出栈,同时前进栈执行压栈。当用户前进了一个页面,则前进栈出栈,同时后退栈压栈。我们以用户按照 1、2、3、4、5、4、3、4 的浏览顺序为例,两个栈的数据存储过程,如下图所示:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/e9e756ba9f80b436744c69a9ae2168bd.jpeg#pic_center)

总结

好的,这节课的内容就到这里了。这一节的内容主要围绕栈的原理、栈对于数据的增删查操作展开。

栈继承了线性表的优点与不足,是个限制版的线性表。限制的功能是,只允许数据从栈顶进出,这也就是栈后进先出的性质。不管是顺序栈还是链式栈,它们对于数据的新增操作和删除操作的时间复杂度都是 O(1)。而在查找操作中,栈和线性表一样只能通过全局遍历的方式进行,也就是需要 O(n) 的时间复杂度。

栈具有后进先出的特性,当你面对的问题需要高频使用新增、删除操作,且新增和删除操作的数据执行顺序具备后来居上的相反关系时,栈就是个不错的选择。例如,浏览器的前进和后退,括号匹配等问题。栈在代码的编写中有着很广泛的应用,例如,大多数程序运行环境都有的子程序的调用,函数的递归调用等。这些问题都具有后进先出的特性。关于递归,我们会在后续的课程单独进行分析。

练习题

下面我们给出本课时的练习题。在上一课时中,我们的习题是,给定一个包含 n 个元素的链表,现在要求每 k 个节点一组进行翻转,打印翻转后的链表结果。其中,k 是一个正整数,且 n 可被 k 整除。

例如,链表为 1 -> 2 -> 3 -> 4 -> 5 -> 6,k = 3,则打印 321654。仍然是这道题,我们试试用栈来解决它吧。

如果你在栈的使用方面遇到困难,欢迎在留言区和我交流。

06 队列:先进先出的线性表,如何实现增删查?

通过前面课时的学习,你学会了数据结构中可以灵活增删数据的线性表。在需要严格遵守数据处理顺序的场景下,我们对线性表予以限制,那么就得到了后进先出的数据结构,栈。与之对应的还有一种限制的线性表,它遵循先进先出的性质,这就是队

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/a2553b0f51e0304fc04c494df33d5f26.png#pic_center) 列。这一课时我们就来学习队列的增删查。

队列是什么

与栈相似,队列也是一种特殊的线性表,与线性表的不同之处也是体现在对数据的增和删的操作上。

队列的特点是先进先出:

  • 先进,表示队列的数据新增操作只能在末端进行,不允许在队列的中间某个结点后新增数据;

  • 先出,队列的数据删除操作只能在始端进行,不允许在队列的中间某个结点后删除数据。也就是说队列的增和删的操作只能分别在这个队列的队尾和队头进行,如下图所示:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/691e9c592c153b120f601c3b1705110b.png#pic_center)

与线性表、栈一样,队列也存在这两种存储方式,即顺序队列和链式队列:

  • 顺序队列,依赖数组来实现,其中的数据在内存中也是顺序存储。

  • 而链式队列,则依赖链表来实现,其中的数据依赖每个结点的指针互联,在内存中并不是顺序存储。链式队列,实际上就是只能尾进头出的线性表的单链表。

如下图所示,我们将队头指针指向链队列的头结点,队尾指针指向终端结点。不管是哪种实现方式,一个队列都依赖队头(front)和队尾(rear)两个指针进行唯一确定。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/2a05c6a0328c1e217f3ff946886424b7.png#pic_center)

当队列为空时,front 和 rear 都指向头结点,如下图所示:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/e404f7cd4f5dced89386c9e402fccc67.png#pic_center)

队列对于数据的增删查处理

队列从队头(front)删除元素,从队尾(rear)插入元素。对于一个顺序队列的数组来说,会设置一个 front 指针来指向队头,并设置另一个 rear 指针指向队尾。当我们不断进行插入删除操作时,头尾两个指针都会不断向后移动。

为了实现一个有 k 个元素的顺序存储的队列,我们需要建立一个长度比 k 大的数组,以便把所有的队列元素存储在数组中。队列新增数据的操作,就是利用 rear 指针在队尾新增一个数据元素。这个过程不会影响其他数据,时间复杂度为 O(1),状态如下图所示:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/b0b040f56f564d2f86300a2caf4fbc06.png#pic_center)

队列删除数据的操作与栈不同。队列元素出口在队列头部,即下标为 0 的位置。当利用 front 指针删除一个数据时,队列中剩余的元素都需要向前移动一个位置,以保证队列头部下标为 0 的位置不为空,此时时间复杂度就变成 O(n) 了,状态如下图所示:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/2598258994aae56088f7e105af076195.png#pic_center)

我们看到,front 指针删除数据的操作引发了时间复杂度过高的问题,那么我们该如何解决呢?我们可以通过移动指针的方式来删除数据,这样就不需要移动剩余的数据了。但是,这样的操作,也可能会产生数组越界的问题。接下来,我们来详细讨论一下。

我们一起来看一个利用顺序队列,持续新增数据和删除数据的例子。

初始时,定义了长度为 5 的数组,front 指针和 rear 指针相等,且都指向下标为 0 的位置,队列为空队列。如下图所示:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/da5dfb47f340467dd99cfaca9b2d65d0.png#pic_center)

当 A、B、C、D 四条数据加入队列后,front 依然指向下标为 0 的位置,而 rear 则指向下标为 4 的位置。

当 A 出队列时,front 指针指向下标为 1 的位置,rear 保持不变。其后 E 加入队列,front 保持不变,rear 则移动到了数组以外,如下图所示:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/dfbb052ddc6e265dd49f53a8635dcda0.png#pic_center)

假设这个列队的总个数不超过 5 个,但是目前继续接着入队,因为数组末尾元素已经被占用,再向后加,就会产生我们前面提到的数组越界问题。而实际上,我们列队的下标 0 的地方还是空闲的,这就产生了一种 “假溢出” 的现象。

这种问题在采用顺序存储的队列时,是一定要小心注意的。两个简单粗暴的解决方法就是:

  • 不惜消耗 O(n) 的时间复杂度去移动数据;

  • 或者开辟足够大的内存空间确保数组不会越界。

循环队列的数据操作

很显然上面的两个方法都不太友好。其实,数组越界问题可以通过队列的一个特殊变种来解决,叫作循环队列。

循环队列进行新增数据元素操作时,首先判断队列是否为满。如果不满,则可以将新元素赋值给队尾,然后让 rear 指针向后移动一个位置。如果已经排到队列最后的位置,则 rea r指针重新指向头部。

循环队列进行删除操作时,即出队列操作,需要判断队列是否为空,然后将队头元素赋值给返回值,front 指针向后移一个位置。如果已经排到队列最后的位置,就把 front 指针重新指向到头部。这个过程就好像钟表的指针转到了表盘的尾部 12 点的位置后,又重新回到了表盘头部 1 点钟的位置。这样就能在不开辟大量存储空间的前提下,不采用 O(n) 的操作,也能通过移动数据来完成频繁的新增和删除数据。

我们继续回到前面提到的例子中,如果是循环队列,rear 指针就可以重新指向下标为 0 的位置,如下图所示:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/0fc6b29c38bc0ee0455a094a48814c51.png#pic_center)

如果这时再新增了 F 进入队列,就可以放入在下标为 0 的位置,rear 指针指向下标为 1 的位置。这时的 rear 和 front 指针就会重合,指向下标为 1 的位置,如下图所示:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/83486f8fb749c17e72e2424544fde8de.png#pic_center)

此时,又会产生新的问题,即当队列为空时,有 front 指针和 rear 指针相等。而现在的队列是满的,同样有 front 指针和 rear 指针相等。那么怎样判断队列到底是空还是满呢?常用的方法是,设置一个标志变量 flag 来区别队列是空还是满。

链式队列的数据操作

我们再看一下链式队列的数据操作。链式队列就是一个单链表,同时增加了 front 指针和 rear 指针。链式队列和单链表一样,通常会增加一个头结点,并另 front 指针指向头结点。头结点不存储数据,只是用来辅助标识。

链式队列进行新增数据操作时,将拥有数值 X 的新结点 s 赋值给原队尾结点的后继,即 rear.next。然后把当前的 s 设置为队尾结点,指针 rear 指向 s。如下图所示:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/7fdb72437f251dbd86a2314c66ac3bec.png#pic_center)

当链式队列进行删除数据操作时,实际删除的是头结点的后继结点。这是因为头结点仅仅用来标识队列,并不存储数据。因此,出队列的操作,就需要找到头结点的后继,这就是要删除的结点。接着,让头结点指向要删除结点的后继。

特别值得一提的是,如果这个链表除去头结点外只剩一个元素,那么删除仅剩的一个元素后,rear 指针就变成野指针了。这时候,需要让 rear 指针指向头结点。也许你前面会对头结点存在的意义产生怀疑,似乎没有它也不影响增删的操作。那么为何队列还特被强调要有头结点呢?

这主要是为了防止删除最后一个有效数据结点后, front 指针和 rear 指针变成野指针,导致队列没有意义了。有了头结点后,哪怕队列为空,头结点依然存在,能让 front 指针和 rear 指针依然有意义。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/63c8ca94f8d400cac53792e4391a8963.png#pic_center)

对于队列的查找操作,不管是顺序还是链式,队列都没有额外的改变。跟线性表一样,它也需要遍历整个队列来完成基于某些条件的数值查找。因此时间复杂度也是 O(n)。

队列的案例

我们来看一个关于用队列解决约瑟夫环问题。约瑟夫环是一个数学的应用问题,具体为,已知 n 个人(以编号 1,2,3...n 分别表示)围坐在一张圆桌周围。从编号为 k 的人开始报数,数到 m 的那个人出列;他的下一个人又从 1 开始报数,数到 m 的那个人又出列;依此规律重复下去,直到圆桌周围的人全部出列。这个问题的输入变量就是 n 和 m,即 n 个人和数到 m 的出列的人。输出的结果,就是 n 个人出列的顺序。

这个问题,用队列的方法实现是个不错的选择。它的结果就是出列的顺序,恰好满足队列对处理顺序敏感的前提。因此,求解方式也是基于队列的先进先出原则。解法如下:

  1. 先把所有人都放入循环队列中。注意这个循环队列的长度要大于或者等于 n。

  2. 从第一个人开始依次出队列,出队列一次则计数变量 i 自增。如果 i 比 m 小,则还需要再入队列。

  3. 直到i等于 m 的人出队列时,就不用再让这个人进队列了。而是放入一个用来记录出队列顺序的数组中。

  4. 直到数完 n 个人为止。当队列为空时,则表示队列中的 n 个人都出队列了,这时结束队列循环,输出数组内记录的元素。

至此,我们就通过循环队列解决了约瑟夫环问题。代码如下:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/b7d21b3fac20a5693a90e21c5e6b1158.jpeg#pic_center)
public static void main(String[] args) {
    ring(10, 5);
}
public static void ring(int n, int m) {
    LinkedList<Integer> q = new LinkedList<Integer>();
    for (int i = 1; i <= n; i++) {
        q.add(i);
    }
    int k = 2;
    int element = 0;
    int i = 1;
    for (; i<k; i++) {
        element = q.poll();
        q.add(element);
    }
    i = 1;
    while (q.size() > 0) {
        element = q.poll();
        if (i < m) {
            q.add(element);
            i++;
        } else {
            i = 1;
            System.out.println(element);
        }
    }
}

总结

好的,这一节的内容就到这里了。本节课我们介绍了队列的基本原理和队列对于数据的增删查的操作。可以发现,队列与前一课时我们学习的栈的特性非常相似,队列也继承了线性表的优点与不足,是加了限制的线性表,队列的增和删的操作只能在这个线性表的头和尾进行。

在时间复杂度上,循环队列和链式队列的新增、删除操作都为 O(1)。而在查找操作中,队列和线性表一样只能通过全局遍历的方式进行,也就是需要 O(n) 的时间复杂度。在空间性能方面,循环队列必须有一个固定的长度,因此存在存储元素数量和空间的浪费问题,而链式队列不存在这种问题,所以在空间上,链式队列更为灵活一些。

通常情况下,在可以确定队列长度最大值时,建议使用循环队列。无法确定队列长度时,应考虑使用链式队列。队列具有先进先出的特点,很像现实中人们排队买票的场景。在面对数据处理顺序非常敏感的问题时,队列一定是个不错的技术选型。

如果你在队列的使用方面遇到困难,欢迎在留言区和我交流。

07 数组:如何实现基于索引的查找?

通过前面几个课时的学习,我们了解了线性表、栈、队列的基本概念,至此,相信你已经掌握了这些数据处理的基本操作,能够熟练地完成线性表、栈、队列结构下的增删查操作。

由于栈和队列是特殊的线性表,本质上它们都可以被看作是一类基本结构。而数组则可以看成是线性表的一种推广,它属于另外一种基本的数据结构。这一课时,我们就来学习数组的概念以及如何用数组实现增删查的操作。

数组是什么

数组是数据结构中的最基本结构,几乎所有的程序设计语言都把数组类型设定为固定的基础变量类型。我们可以把数组理解为一种容器,它可以用来存放若干个相同类型的数据元素。

例如:

  • 存放的数据是整数型的数组,称作整型数组;

  • 存放的数据是字符型的数组,则称作字符数组;

  • 另外还有一类数组比较特殊,它是数组的数组,也可以叫作二维数组。

如果用数学的方式来看,我们可以把普通的数组看成是一个向量,那么二维数组就是一个矩阵。不过,二维数组对数据的处理方式并没有太多特别之处。

数组可以把这些具有相同类型的元素,以一种不规则的顺序进行排列,这些排列好的同类数据元素的集合就被称为数组。

数组在内存中是连续存放的,数组内的数据,可以通过索引值直接取出得到。如下图所示,我们建立了一个简单的数组,数组中的每个数据位置都可以放入对应的数据内容。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/d26ce4084f809b7898558588d5827df8.png#pic_center)

数据元素 A、B 分别为数组的第一个元素和第二个元素,根据其对应位置分别放入数组空间的第一个和第二个位置。数组的索引值从 0 开始记录,因此,上图中数据 A 的索引值是 0,B 的索引值是 1。

实际上数组的索引就是对应数组空间,所以我们在进行新增、删除、查询操作的时候,完全可以根据代表数组空间位置的索引值进行。也就是说,只要记录该数组头部的第一个数据位置,然后累加空间位置即可。下面我们来具体讲一下如何通过数组来实现基于索引的新增、删除和查找操作。

数组的基本操作

数组在存储数据时是按顺序存储的,并且存储数据的内存也是连续的,这就造成了它具有增删困难、查找容易的特点。同时,栈和队列是加了限制的线性表,只能在特定的位置进行增删操作。相比之下,数组并没有这些限制,可以在任意位置增删数据,所以数组的增删操作会更为多样。下面我们来具体地介绍一下数组的增删查操作。

数组的新增操作

数组新增数据有两个情况:

  • 第一种情况,在数组的最后增加一个新的元素。此时新增一条数据后,对原数据产生没有任何影响。可以直接通过新增操作,赋值或者插入一条新的数据即可。时间复杂度是 O(1)。

  • 第二种情况,如果是在数组中间的某个位置新增数据,那么情况就完全不一样了。这是因为,新增了数据之后,会对插入元素位置之后的元素产生影响,具体为这些数据的位置需要依次向后挪动 1 个位置。

例如,对于某一个长度为 4 的数组,我们在第 2 个元素之后插入一个元素,那么修改后的数组中就包含了 5 个元素,其中第 1、第 2 个元素不发生变化,第 3 个元素是新来的元素,第 4、第 5 个元素则是原来第 3、第 4 个元素。这一波操作,就需要对一般的数据进行重新搬家。而这个搬家操作,与数组的数据量线性相关,因此时间复杂度是 O(n)。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/6d1bcb9bee7b6381ccbc913784abb376.jpeg#pic_center)
数组的删除操作

数组删除数据也有两种情况:

  • 第一种情况,在这个数组的最后,删除一个数据元素。由于此时删除一条数据后,对原数据没有产生任何影响。我们可以直接删除该数据即可,时间复杂度是 O(1)。

  • 第二种情况,在这个数组的中间某个位置,删除一条数据。同样的,这两种情况的区别在于,删除数据之后,其他数据的位置是否发生改变。由于此时的情况和新增操作高度类似,我们就不再举例子了。

数组的查找操作

相比于复杂度较高的增删操作,数组的查找操作就方便一些了。由于索引的存在,数组基于位置的查找操作比较容易实现。我们可以索引值,直接在 O(1) 时间复杂度内查找到某个位置的元素。

例如,查找数组中第三个位置的元素,通过 a[2] 就可以直接取出来。但对于链表系的数据结构,是没有这个优势的。

不过,另外一种基于数值的查找方法,数组就没有什么优势了。例如,查找数值为 9 的元素是否出现过,以及如果出现过,索引值是多少。这样基于数值属性的查找操作,也是需要整体遍历一遍数组的。和链表一样,都需要 O(n) 的时间复杂度。

上面的操作,在很多高级编程语言都已经封装了响应的函数方法,是不需要自己独立开发的。例如,新增系列的 push(), unshift(), concat() 和 splice(),删除系列的 pop(),shift() 和slice(),查找系列的 indexOf() 和 lastIndexOf() 等等。不过别被迷惑,即使是封装好了的函数,其时间复杂度也不会发生改变。依然是我们上面分析的结果,这些底层原理是需要你理解并掌握的。

数组增删查操作的特点

通过以上内容的学习,我们发现数组增删查的操作相比栈、队列来说,方法更多,操作更为灵活,这都是由它们数据结构的特点决定的。接下来,我们来归纳一下数组增删查的时间复杂度。

  • 增加:若插入数据在最后,则时间复杂度为 O(1);如果中间某处插入数据,则时间复杂度为 O(n)。

  • 删除:对应位置的删除,扫描全数组,时间复杂度为 O(n)。

  • 查找:如果只需根据索引值进行一次查找,时间复杂度是 O(1)。但是要在数组中查找一个数值满足指定条件的数据,则时间复杂度是 O(n)。

实际上数组是一种相当简单的数据结构,其增删查的时间复杂度相对于链表来说整体上是更优的。那么链表存在的价值又是什么呢?

  • 首先,链表的长度是可变的,数组的长度是固定的,在申请数组的长度时就已经在内存中开辟了若干个空间。如果没有引用 ArrayList 时,数组申请的空间永远是我们在估计了数据的大小后才执行,所以在后期维护中也相当麻烦。

  • 其次,链表不会根据有序位置存储,进行插入数据元素时,可以用指针来充分利用内存空间。数组是有序存储的,如果想充分利用内存的空间就只能选择顺序存储,而且需要在不取数据、不删除数据的情况下才能实现。

数组的案例

例题,假设数组存储了 5 个评委对 1 个运动员的打分,且每个评委的打分都不相等。现在需要你:

  1. 用数组按照连续顺序保存,去掉一个最高分和一个最低分后的 3 个打分样本;

  2. 计算这 3 个样本的平均分并打印。

要求是,不允许再开辟 O(n) 空间复杂度的复杂数据结构。

我们先分析一下题目:第一个问题,要输出删除最高分和最低分后的样本,而且要求是不允许再开辟复杂空间。因此,我们只能在原数组中找到最大值和最小值并删除。第二个问题,基于删除后得到的数组,计算平均值。所以解法如下:

  1. 数组一次遍历,过程中记录下最小值和最大值的索引。对应下面代码的第 7 行到第 16 行。时间复杂度是 O(n)。

  2. 执行两次基于索引值的删除操作。除非极端情况,否则时间复杂度仍然是 O(n)。对应于下面代码的第 18 行到第 30 行。

  3. 计算删除数据后的数组元素的平均值。对应于下面代码的第 32 行到第 37 行。时间复杂度是 O(n)。

因此,O(n) + O(n) + O(n) 的结果仍然是 O(n)。

代码如下:

public void getScore() {
    int a[] = { 2, 1, 4, 5, 3 };
    max_inx = -1;
    max_val = -1;
    min_inx= -1;
    min_val = 99;
    for (int i = 0; i < a.length; i++) {
        if (a[i] > max_val) {
        max_val = a[i];
        max_inx = i;
    }
    if (a[i] < min_val) {
        min_val = a[i];
        min_inx = i;
    }
    }
inx1 = max_inx;
inx2 = min_inx;
<span class="hljs-keyword">if</span> (max_inx &lt; min_inx){
    inx1 = min_inx;
    inx2 = max_inx;
}
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">int</span> i = inx1; i &lt; a.length-<span class="hljs-number">1</span>; i++) {
    a[i] = a[i+<span class="hljs-number">1</span>];
}
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">int</span> i = inx2; i &lt; a.length-<span class="hljs-number">1</span>; i++) {
    a[i] = a[i+<span class="hljs-number">1</span>];
}
sumscore = <span class="hljs-number">0</span>;
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">int</span> i = <span class="hljs-number">0</span>; i &lt; a.length-<span class="hljs-number">2</span>; i++) {
    sumscore += a[i];
}
avg = sumscore/<span class="hljs-number">3.0</span>;
System.out.println(avg);

}

总结

本节内容主要讲解了数组的原理和特性,以及数组的增删查的操作方法。由于数组中没有栈和队列那样对于线性表的限制,所以增删查操作变得灵活很多,代码实现的方法也更多样,所以我们要根据实际需求选择适合的方法进行操作。

在实际操作中,我们还要注意根据数组的优缺点合理区分数组和链表的使用。数组定义简单,访问方便,但在数组中所有元素类型必须相同,数组的最大长度必须在定义时给出,数组使用的内存空间必须连续等。

相对而言,数组更适合在数据数量确定,即较少甚至不需要使用新增数据、删除数据操作的场景下使用,这样就有效地规避了数组天然的劣势。在数据对位置敏感的场景下,比如需要高频根据索引位置查找数据时,数组就是个很好的选择了。

练习题

下面,我们给出一道练习题。给定一个排序数组,你需要在原地删除重复出现的元素,使得每个元素只出现一次,返回移除后的数组和新的长度,你不需要考虑数组中超出新长度后面的元素。要求,空间复杂度为 O(1),即不要使用额外的数组空间。

例如,给定数组 nums = [1,1,2],函数应该返回新的长度 2,并且原数组 nums 的前两个元素被修改为 1, 2。 又如,给定 nums = [0,0,1,1,1,2,2,3,3,4],函数应该返回新的长度 5,并且原数组 nums 的前五个元素被修改为 0, 1, 2, 3, 4。

详细分析和答案,请翻阅 18 课时 例题 1。

如果你在数组的使用方面遇到困难,欢迎在留言区和我交流。

08 字符串:如何正确回答面试中高频考察的字符串匹配算法?

这一节我们来讲字符串和它的相关操作。

字符串是什么

字符串(string) 是由 n 个字符组成的一个有序整体( n >= 0 )。例如,s = "BEIJING" ,s 代表这个串的串名,BEIJING 是串的值。这里的双引号不是串的值,作用只是为了将串和其他结构区分开。字符串的逻辑结构和线性表很相似,不同之处在于字符串针对的是字符集,也就是字符串中的元素都是字符,线性表则没有这些限制。

在实际操作中,我们经常会用到一些特殊的字符串:

  • 空串,指含有零个字符的串。例如,s = "",书面中也可以直接用 Ø 表示。

  • 空格串,只包含空格的串。它和空串是不一样的,空格串中是有内容的,只不过包含的是空格,且空格串中可以包含多个空格。例如,s = "   ",就是包含了 3 个空格的字符串。

  • 子串,串中任意连续字符组成的字符串叫作该串的子串。

  • 原串通常也称为主串。例如:a = "BEI",b = "BEIJING",c = "BJINGEI" 。

    • 对于字符串 a 和 b 来说,由于 b 中含有字符串 a ,所以可以称 a 是 b 的子串,b 是 a 的主串;

    • 而对于 c 和 a 而言,虽然 c 中也含有 a 的全部字符,但不是连续的 "BEI" ,所以串 c 和 a 没有任何关系。

当要判断两个串是否相等的时候,就需要定义相等的标准了。只有两个串的串值完全相同,这两个串才相等。根据这个定义可见,即使两个字符串包含的字符完全一致,它们也不一定是相等的。例如 b = "BEIJING",c = "BJINGEI",则 b 和 c 并不相等。

字符串的存储结构与线性表相同,也有顺序存储和链式存储两种。

  • 字符串的顺序存储结构,是用一组地址连续的存储单元来存储串中的字符序列,一般是用定长数组来实现。有些语言会在串值后面加一个不计入串长度的结束标记符,比如 \0 来表示串值的终结。

  • 字符串的链式存储结构,与线性表是相似的,但由于串结构的特殊性(结构中的每个元素数据都是一个字符),如果也简单地将每个链结点存储为一个字符,就会造成很大的空间浪费。因此,一个结点可以考虑存放多个字符,如果最后一个结点未被占满时,可以使用 "#" 或其他非串值字符补全,如下图所示:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/821767d63ca528a5272819caed4bf6e5.png#pic_center)

在链式存储中,每个结点设置字符数量的多少,与串的长度、可以占用的存储空间以及程序实现的功能相关。

  • 如果字符串中包含的数据量很大,但是可用的存储空间有限,那么就需要提高空间利用率,相应地减少结点数量。

  • 而如果程序中需要大量地插入或者删除数据,如果每个节点包含的字符过多,操作字符就会变得很麻烦,为实现功能增加了障碍。

因此,串的链式存储结构除了在连接串与串操作时有一定的方便之外,总的来说,不如顺序存储灵活,在性能方面也不如顺序存储结构好。

字符串的基本操作

字符串和线性表的操作很相似,但由于字符串针对的是字符集,所有元素都是字符,因此字符串的基本操作与线性表有很大差别。线性表更关注的是单个元素的操作,比如增删查一个元素,而字符串中更多关注的是查找子串的位置、替换等操作。接下来我们以顺序存储为例,详细介绍一下字符串对于另一个字符串的增删查操作。

字符串的新增操作

字符串的新增操作和数组非常相似,都牵涉对插入字符串之后字符的挪移操作,所以时间复杂度是 O(n)。

例如,在字符串 s1 = "123456" 的正中间插入 s2 = "abc",则需要让 s1 中的 "456" 向后挪移 3 个字符的位置,再让 s2 的 "abc" 插入进来。很显然,挪移的操作时间复杂度是 O(n)。不过,对于特殊的插入操作时间复杂度也可以降低为 O(1)。这就是在 s1 的最后插入 s2,也叫作字符串的连接,最终得到 "123456abc"。

字符串的删除操作

字符串的删除操作和数组同样非常相似,也可能会牵涉删除字符串后字符的挪移操作,所以时间复杂度是 O(n)。

例如,在字符串 s1 = "123456" 的正中间删除两个字符 "34",则需要删除 "34" 并让 s1 中的 "56" 向前挪移 2 个字符的位置。很显然,挪移的操作时间复杂度是 O(n)。不过,对于特殊的插入操作时间复杂度也可以降低为 O(1)。这就是在 s1 的最后删除若干个字符,不牵涉任何字符的挪移。

字符串的查找操作

字符串的查找操作,是反映工程师对字符串理解深度的高频考点,这里需要你格外注意。

例如,字符串 s = "goodgoogle",判断字符串 t = "google" 在 s 中是否存在。需要注意的是,如果字符串 t 的每个字符都在 s 中出现过,这并不能证明字符串 t 在 s 中出现了。当 t = "dog" 时,那么字符 "d"、"o"、"g" 都在 s 中出现过,但他们并不连在一起。

那么我们如何判断一个子串是否在字符串中出现过呢?这个问题也被称作子串查找或字符串匹配,接下来我们来重点分析。

子串查找(字符串匹配)

首先,我们来定义两个概念,主串和模式串。我们在字符串 A 中查找字符串 B,则 A 就是主串,B 就是模式串。我们把主串的长度记为 n,模式串长度记为 m。由于是在主串中查找模式串,因此,主串的长度肯定比模式串长,n>m。因此,字符串匹配算法的时间复杂度就是 n 和 m 的函数。

假设要从主串 s = "goodgoogle" 中找到 t = "google" 子串。根据我们的思考逻辑,则有:

  • 首先,我们从主串 s 第 1 位开始,判断 s 的第 1 个字符是否与 t 的第 1 个字符相等。

  • 如果不相等,则继续判断主串的第 2 个字符是否与 t 的第1 个字符相等。直到在 s 中找到与 t 第一个字符相等的字符时,然后开始判断它之后的字符是否仍然与 t 的后续字符相等。

  • 如果持续相等直到 t 的最后一个字符,则匹配成功。

  • 如果发现一个不等的字符,则重新回到前面的步骤中,查找 s 中是否有字符与 t 的第一个字符相等。

  • 如下图所示,s 的第1 个字符和 t 的第 1 个字符相等,则开始匹配后续。直到发现前三个字母都匹配成功,但 s 的第 4 个字母匹配失败,则回到主串继续寻找和 t 的第一个字符相等的字符。

  • 如下图所示,这时我们发现主串 s 第 5 位开始相等,并且随后的 6 个字母全匹配成功,则找到结果。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/5a144b690536b41db4589527f0963e15.jpeg#pic_center)

这种匹配算法需要从主串中找到跟模式串的第 1 个字符相等的位置,然后再去匹配后续字符是否与模式串相等。显然,从实现的角度来看,需要两层的循环。第一层循环,去查找第一个字符相等的位置,第二层循环基于此去匹配后续字符是否相等。因此,这种匹配算法的时间复杂度为 O(nm)。其代码如下:

public void s1() {
    String s = "goodgoogle";
    String t = "google";
    int isfind = 0;
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">int</span> i = <span class="hljs-number">0</span>; i &lt; s.length() - t.length() + <span class="hljs-number">1</span>; i++) {
    <span class="hljs-keyword">if</span> (s.charAt(i) == t.charAt(<span class="hljs-number">0</span>)) {
        <span class="hljs-keyword">int</span> jc = <span class="hljs-number">0</span>;
        <span class="hljs-keyword">for</span> (<span class="hljs-keyword">int</span> j = <span class="hljs-number">0</span>; j &lt; t.length(); j++) {
            <span class="hljs-keyword">if</span> (s.charAt(i + j) != t.charAt(j)) {
                <span class="hljs-keyword">break</span>;
            }
            jc = j;
        }
        <span class="hljs-keyword">if</span> (jc == t.length() - <span class="hljs-number">1</span>) {
            isfind = <span class="hljs-number">1</span>;
        }
    }
}
System.out.println(isfind);

}

字符串匹配算法的案例

最后我们给出一道面试中常见的高频题目,这也是对字符串匹配算法进行拓展,从而衍生出的问题,即查找出两个字符串的最大公共字串。

假设有且仅有 1 个最大公共子串。比如,输入 a = "13452439", b = "123456"。由于字符串 "345" 同时在 a 和 b 中出现,且是同时出现在 a 和 b 中的最长子串。因此输出 "345"。

对于这个问题其实可以用动态规划的方法来解决,关于动态规划,我们会在后续的课程会讲到,所以在这里我们沿用前面的匹配算法。

假设字符串 a 的长度为 n,字符串 b 的长度为 m,可见时间复杂度是 n 和 m 的函数。

  • 首先,你需要对于字符串 a 和 b 找到第一个共同出现的字符,这跟前面讲到的匹配算法在主串中查找第一个模式串字符一样。

  • 然后,一旦找到了第一个匹配的字符之后,就可以同时在 a 和 b 中继续匹配它后续的字符是否相等。这样 a 和 b 中每个互相匹配的字串都会被访问一遍。全局还要维护一个最长子串及其长度的变量,就可以完成了。

从代码结构来看,第一步需要两层的循环去查找共同出现的字符,这就是 O(nm)。一旦找到了共同出现的字符之后,还需要再继续查找共同出现的字符串,这也就是又嵌套了一层循环。可见最终的时间复杂度是 O(nmm),即 O(nm²)。代码如下:

public void s2() {
    String a = "123456";
    String b = "13452439";
    String maxSubStr = "";
    int max_len = 0;
<span class="hljs-keyword">for</span> (<span class="hljs-keyword">int</span> i = <span class="hljs-number">0</span>; i &lt; a.length(); i++) {
    <span class="hljs-keyword">for</span> (<span class="hljs-keyword">int</span> j = <span class="hljs-number">0</span>; j &lt; b.length(); j++){
        <span class="hljs-keyword">if</span> (a.charAt(i) == b.charAt(j)){
            <span class="hljs-keyword">for</span> (<span class="hljs-keyword">int</span> m=i, n=j; m&lt;a.length()&amp;&amp;n&lt;b.length(); m++,n++) {
                <span class="hljs-keyword">if</span> (a.charAt(m) != b.charAt(n)){
                <span class="hljs-keyword">break</span>;
                }
                <span class="hljs-keyword">if</span> (max_len &lt; m-i+<span class="hljs-number">1</span>){
                    max_len = m-i+<span class="hljs-number">1</span>;

                maxSubStr = a.substring(i, m+1);
}
}
}
}
}
System.out.println(maxSubStr);
}

总结

这节课我们介绍了字符串匹配算法,它在平时代码编写中都比较常用。

字符串的逻辑结构和线性表极为相似,区别仅在于串的数据对象约束为字符集。但是,字符串的基本操作和线性表有很大差别:

  • 在线性表的基本操作中,大多以“单个元素”作为操作对象;

  • 在字符串的基本操作中,通常以“串的整体”作为操作对象;

  • 字符串的增删操作和数组很像,复杂度也与之一样。但字符串的查找操作就复杂多了,它是参加面试、笔试常常被考察的内容。

练习题

最后我们给出一道练习题。给定一个字符串,逐个翻转字符串中的每个单词。例如,输入: "the sky is blue",输出: "blue is sky the"。

希望你在课后自己去实践一下,如果你在字符串的使用方面遇到困难,欢迎在留言区和我交流。

09 树和二叉树:分支关系与层次结构下,如何有效实现增删查?

前面课时我们学习了线性表、栈、队列和数组。栈、队列都是特殊的线性表,数组可以看成是线性表的一种推广。根据学习,我们知道了这几种数据结构,在对数据的增删查操作上各有千秋。这一课时再来学习另一种从形式上看上去差异比较大的数据结构,树,以及如何用树和二叉树实现对数据的增删查的操作。

树是什么

树是由结点和边组成的,不存在环的一种数据结构。通过下图,我们就可以更直观的认识树的结构。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/337e9fd7426fb72c5159966671ce2e65.png#pic_center)

树满足递归定义的特性。也就是说,如果一个数据结构是树结构,那么剔除掉根结点后,得到的若干个子结构也是树,通常称作子树。

在一棵树中,根据结点之间层次关系的不同,对结点的称呼也有所不同。我们来看下面这棵树,如下图所示:

  • A 结点是 B 结点和 C 结点的上级,则 A 就是 B 和 C 的父结点,B 和 C 是 A 的子结点。

  • B 和 C 同时是 A 的“孩子”,则可以称 B 和 C 互为兄弟结点。

  • A 没有父结点,则可以称 A 为根结点。

  • G、H、I、F 结点都没有子结点,则称 G、H、I、F 为叶子结点。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/8e6717bdf1e2fddba2c5a50ee114f9b0.png#pic_center)

当有了一棵树之后,还需要用深度、层来描述这棵树中结点的位置。结点的层次从根结点算起,根为第一层,根的“孩子”为第二层,根的“孩子”的“孩子”为第三层,依此类推。树中结点的最大层次数,就是这棵树的树深(称为深度,也称为高度)。如下图所示,就是一棵深度为 4 的树。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/00a55497153c9007e33a1a97e836c3f3.png#pic_center)

二叉树是什么

在树的大家族中,有一种被高频使用的特殊树,它就是二叉树。在二叉树中,每个结点最多有两个分支,即每个结点最多有两个子结点,分别称作左子结点和右子结点。

在二叉树中,有下面两个特殊的类型,如下图所示:

  • 满二叉树,定义为只有最后一层无任何子结点,其他所有层上的所有结点都有两个子结点的二叉树。

  • 完全二叉树,定义为除了最后一层以外,其他层的结点个数都达到最大,并且最后一层的叶子结点都靠左排列。

在这里插入图片描述

你可能会困惑,完全二叉树看上去并不完全,但为什么这样称呼它呢?这其实和二叉树的存储有关系。存储二叉树有两种办法,一种是基于指针的链式存储法,另一种是基于数组的顺序存储法。

  • 链式存储法,也就是像链表一样,每个结点有三个字段,一个存储数据,另外两个分别存放指向左右子结点的指针,如下图所示:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/c1c72bab4675807cef9c13cb8ee849c0.png#pic_center)
  • 顺序存储法,就是按照规律把结点存放在数组里,如下图所示,为了方便计算,我们会约定把根结点放在下标为 1 的位置。随后,B 结点存放在下标为 2 的位置,C 结点存放在下标为 3 的位置,依次类推。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/849708a2d2d9ae782d165a4e1c640aeb.png#pic_center)

根据这种存储方法,我们可以发现如果结点 X 的下标为 i,那么 X 的左子结点总是存放在 2 * i 的位置,X 的右子结点总是存放在 2 * i + 1 的位置。

之所以称为完全二叉树,是从存储空间利用效率的视角来看的。对于一棵完全二叉树而言,仅仅浪费了下标为 0 的存储位置。而如果是一棵非完全二叉树,则会浪费大量的存储空间。

我们来看如下图所示的非完全二叉树,它既需要保留出 5 和 6 的位置。同时,还需要保留 5 的两个子结点 10 和 11 的位置,以及 6 的两个子结点 12 和 13 的位置。这样的二叉树,没有完全利用好数组的存储空间。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/c8a9867f1c5c947ddb3bd4df3538f29b.png#pic_center)

树的基本操作

接下来,我们以二叉树为例介绍树的操作,其他类型的树的操作与二叉树基本相似。

可以发现,我们以前学到的数据结构都是“一对一”的关系,即前面的数据只跟下面的一个数据产生了连接关系,例如链表、栈、队列等。而树结构则是“一对多”的关系,即前面的父结点,跟下面若干个子结点产生了连接关系。

在前面的课时中我们提到过,要在数据结构中,查找具有某个数值特性的数据需要遍历每一条数据。这在“一对一”的结构中,直接按顺序访问就好了。可是,树是“一对多”的关系,那么我们该如何进行数据的遍历,才能保证每条数据都会被访问一次且没有遗漏呢?我们只有解决了遍历问题,才能通过树来进行数据的增删查操作。

其实,遍历一棵树,有非常经典的三种方法,分别是前序遍历、中序遍历、后序遍历。这里的序指的是父结点的遍历顺序,前序就是先遍历父结点,中序就是中间遍历父结点,后序就是最后遍历父结点。不管哪种遍历,都是通过递归调用完成的。如下图所示:

  • 前序遍历,对树中的任意结点来说,先打印这个结点,然后前序遍历它的左子树,最后前序遍历它的右子树。

  • 中序遍历,对树中的任意结点来说,先中序遍历它的左子树,然后打印这个结点,最后中序遍历它的右子树。

  • 后序遍历,对树中的任意结点来说,先后序遍历它的左子树,然后后序遍历它的右子树,最后打印它本身。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/3b39e34b3b1b5d3745ca4a30076e0622.png#pic_center)

通过前面的介绍,相信你已经了解了二叉树的三种遍历方式,下面我们再来分析一下代码的实现过程,如下所示:

// 先序遍历
public static void preOrderTraverse(Node node) {
    if (node == null)
        return;
    System.out.print(node.data + " ");
    preOrderTraverse(node.left);
    preOrderTraverse(node.right);
}
// 中序遍历
public static void inOrderTraverse(Node node) {
    if (node == null)
        return;
    inOrderTraverse(node.left);
    System.out.print(node.data + " ");
    inOrderTraverse(node.right);
}
// 后序遍历
public static void postOrderTraverse(Node node) {
    if (node == null)
        return;
    postOrderTraverse(node.left);
    postOrderTraverse(node.right);
    System.out.print(node.data + " ");
}

不难发现,二叉树遍历过程中,每个结点都被访问了一次,其时间复杂度是 O(n)。接着,在找到位置后,执行增加和删除数据的操作时,我们只需要通过指针建立连接关系就可以了。对于没有任何特殊性质的二叉树而言,抛开遍历的时间复杂度以外,真正执行增加和删除操作的时间复杂度是 O(1)。树数据的查找操作和链表一样,都需要遍历每一个数据去判断,所以时间复杂度是 O(n)。

我们上面讲到二叉树的增删查操作很普通,时间复杂度与链表并没有太多差别。但当二叉树具备一些特性的时候,则可以利用这些特性实现时间复杂度的降低。接下来,我们详细介绍二叉查找树的特性。

二叉查找树的特性

二叉查找树(也称作二叉搜索树)具备以下几个的特性:

  • 在二叉查找树中的任意一个结点,其左子树中的每个结点的值,都要小于这个结点的值。

  • 在二叉查找树中的任意一个结点,其右子树中每个结点的值,都要大于这个结点的值。

  • 在二叉查找树中,会尽可能规避两个结点数值相等的情况。

  • 对二叉查找树进行中序遍历,就可以输出一个从小到大的有序数据队列。如下图所示,中序遍历的结果就是 10、13、15、16、20、21、22、26。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/1fcd82e4e851fba5a819f0abf1080f18.png#pic_center)
二叉查找树的查找操作

在利用二叉查找树执行查找操作时,我们可以进行以下判断:

  • 首先判断根结点是否等于要查找的数据,如果是就返回。

  • 如果根结点大于要查找的数据,就在左子树中递归执行查找动作,直到叶子结点。

  • 如果根结点小于要查找的数据,就在右子树中递归执行查找动作,直到叶子结点。

这样的“二分查找”所消耗的时间复杂度就可以降低为 O(logn)。关于二分查找,我们会在后续的分治法一讲中详细讲述。

二叉查找树的插入操作

在二叉查找树执行插入操作也很简单。从根结点开始,如果要插入的数据比根结点的数据大,且根结点的右子结点不为空,则在根结点的右子树中继续尝试执行插入操作。直到找到为空的子结点执行插入动作。

如下图所示,如果要插入数据 X 的值为 14,则需要判断 X 与根结点的大小关系:

  • 由于 14 小于 16,则聚焦在其左子树,继续判断 X 与 13 的关系。

  • 由于 14 大于 13,则聚焦在其右子树,继续判断 X 与15 的关系。

  • 由于 14 小于 15,则聚焦在其左子树。

因为此时左子树为空,则直接通过指针建立 15 结点的左指针指向结点 X 的关系,就完成了插入动作。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/817e21b99a0615253d634d8a39463be2.jpeg#pic_center)

二叉查找树插入数据的时间复杂度是 O(logn)。但这并不意味着它比普通二叉树要复杂。原因在于这里的时间复杂度更多是消耗在了遍历数据去找到查找位置上,真正执行插入动作的时间复杂度仍然是 O(1)。

二叉查找树的删除操作会比较复杂,这是因为删除完某个结点后的树,仍然要满足二叉查找树的性质。我们分为下面三种情况讨论。

  • 情况一,如果要删除的结点是某个叶子结点,则直接删除,将其父结点指针指向 null 即可。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/fa9fd12f35ab95c5c4a1206129ff9826.jpeg#pic_center)
  • 情况二,如果要删除的结点只有一个子结点,只需要将其父结点指向的子结点的指针换成其子结点的指针即可。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/6d535fabef68b5eba117eeedab4215c7.jpeg#pic_center)
  • 情况三,如果要删除的结点有两个子结点,则有两种可行的操作方式。

第一种,找到这个结点的左子树中最大的结点,替换要删除的结点。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/ad8de3beaa0a3112a1f72ef809850dcf.jpeg#pic_center)

第二种,找到这个结点的右子树中最小的结点,替换要删除的结点。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/f254a1efe837c01a501d83a09653a09e.jpeg#pic_center)

树的案例

我们来看一道例题:

输入一个字符串,判断它在已有的字符串集合中是否出现过?(假设集合中没有某个字符串与另一个字符串拥有共同前缀且完全包含的特殊情况,例如 deep 和 dee。)如,已有字符串集合包含 6 个字符串分别为,cat, car, city, dog,door, deep。输入 cat,输出 true;输入 home,输出 false。

我们假设采用最暴力的办法,估算一下时间复杂度。假设字符串集合包含了 n 个字符串,其中的字符串平均长度为 m。那么新来的一个字符串,需要与每个字符串的每个字符进行匹配。则时间复杂度为 O(nm)。

但在 nm 的复杂度中,显然存在很多的无效匹配。例如,输入 home 时,6 个字符串都没有 h 开头的,则不需要进行后续的匹配。因此,如果可以通过对字符前缀进行处理,就可以最大限度地减少无谓的字符串比较,从而提高查询效率。这就是“用空间换时间”的思想,再利用共同前缀来提高查询效率。

其实,这个问题利用树结构也可以完成。我们对字符串建立一个的树结构,如下图所示,它将字符串集合的前缀进行合并,每个根结点到叶子结点的链条就是一个字符串。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/250836fedf22801eb5487e97875af13b.png#pic_center)

这个树结构也称作 Trie 树,或字典树。它具有三个特点:

  • 第一,根结点不包含字符;

  • 第二,除根结点外每一个结点都只包含一个字符;

  • 第三,从根结点到某一叶子结点,路径上经过的字符连接起来,即为集合中的某个字符串。

这个问题的解法可以拆解为以下两个步骤:

  • 第一步,根据候选字符串集合,建立字典树。这需要使用数据插入的动作。

  • 第二步,对于一个输入字符串,判断它能否在这个树结构中走到叶子结点。如果能,则出现过。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/26715315963d9fa2588d608be544ca56.jpeg#pic_center)

总结

本课时的内容围绕着不同种类树的原理、二叉树对于数据的增删查操作展开。要想利用二叉树实现增删查操作,你需要熟练掌握二叉树的三种遍历方式。遍历的时间复杂度是 O(n)。有了遍历方式之后,你可以完成在指定位置的数据增删操作。增删操作的时间复杂度都是 O(1)。

对于查找操作,如果是普通二叉树,则查找的时间复杂度和遍历一样,都是 O(n)。如果是二叉查找树,则可以在 O(logn) 的时间复杂度内完成查找动作。树结构在存在“一对多”的数据关系中,可被高频使用,这也是它区别于链表系列数据结构的关键点。

练习题

关于树结构,我们留一道习题。给定一棵树,按照层次顺序遍历并打印这棵树。例如:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/94d4c687d1579b0a171d8380ce780024.png#pic_center)

则打印 16、13、20、10、15、22、21、26。请注意,这并不是前序遍历。

练习题代码如下:

public static void levelTraverse(Node root) {
    if (root == null) {
        return;
    }
LinkedList&lt;Node&gt; queue = <span class="hljs-keyword">new</span> LinkedList&lt;Node&gt;();
Node current = <span class="hljs-keyword">null</span>;
queue.offer(root); <span class="hljs-comment">// 根节点入队</span>

<span class="hljs-keyword">while</span> (!queue.isEmpty()) { <span class="hljs-comment">// 只要队列中有元素,就可以一直执行,非常巧妙地利用了队列的特性</span>
    current = queue.poll(); <span class="hljs-comment">// 出队队头元素</span>
    System.out.print(<span class="hljs-string">"--&gt;"</span> + current.data);
    <span class="hljs-comment">// 左子树不为空,入队</span>
    <span class="hljs-keyword">if</span> (current.leftChild != <span class="hljs-keyword">null</span>)
        queue.offer(current.leftChild);

    <span class="hljs-comment">// 右子树不为空,入队</span>
    <span class="hljs-keyword">if</span> (current.rightChild != <span class="hljs-keyword">null</span>)
        queue.offer(current.rightChild);
}

}

如果你在树的使用方面遇到困难,欢迎在留言区和我交流。

10 哈希表:如何利用好高效率查找的“利器”?

在前面课时中,我们先后学习了线性表、数组、字符串和树,并着重分析了它们对于数据的增删查操作。

对于数据处理它们彼此之间各有千秋,例如:

  • 线性表中的栈和队列对增删有严格要求,它们会更关注数据的顺序。

  • 数组和字符串需要保持数据类型的统一,并且在基于索引的查找上会更有优势。

  • 树的优势则体现在数据的层次结构上。

但它们普遍都存在这样的缺陷,那就是数据数值条件的查找,都需要对全部数据或者部分数据进行遍历。那么,有没有一种方法可以省去数据比较的过程,从而进一步提升数值条件查找的效率呢?答案当然是:有。这一课时我们就来介绍这样一种高效率的查找神器,哈希表。

什么是哈希表

哈希表名字源于 Hash,也可以叫作散列表。哈希表是一种特殊的数据结构,它与数组、链表以及树等我们之前学过的数据结构相比,有很明显的区别。

哈希表的核心思想

在我们之前学过的数据结构里,数据的存储位置和数据的具体数值之间不存在任何关系。因此,在面对查找问题时,这些数据结构必须采取逐一比较的方法去实现。

而哈希表的设计采用了函数映射的思想,将记录的存储位置与记录的关键字关联起来。这样的设计方式,能够快速定位到想要查找的记录,而且不需要与表中存在的记录的关键字比较后再来进行查找。

我们回顾一下数组的查找操作。数组是通过数据的索引(index)来取出数值的,例如要找出 a 数组中,索引值为 1 的元素。在前面的课时中,我们讲到索引值是数据存储的位置,因此,直接通过 a[1] 就可以取出这个数据。通过这样的方式,数组实现了“地址 = f (index)”的映射关系。

如果用哈希表的逻辑来理解的话,这里的 f () 就是一个哈希函数。它完成了索引值到实际地址的映射,这就让数组可以快速完成基于索引值的查找。然而,数组的局限性在于,它只能基于数据的索引去查找,而不能基于数据的数值去查找。

如果有一种方法,可以实现“地址 = f (关键字)”的映射关系,那么就可以快速完成基于数据的数值的查找了。这就是哈希表的核心思想。 下面我们通过一个例子来体会一下。

假如,我们要对一个手机通讯录进行存储,并要根据姓名找出一个人的手机号码,如下所示:

张一:155555555

张二:166666666

张三:177777777

张四:188888888

一个可行的方法是,定义包含姓名、手机号码的结构体,再通过链表把 4 个联系人的信息存起来。当要判断“张四”是否在链表中,或者想要查找到张四的手机号码时,就需要从链表的头结点开始遍历。依次将每个结点中的姓名字段,同“张四”进行比较。直到查找成功或者全部遍历一次为止。显然,这种做法的时间复杂度为 O(n)。

如果要降低时间复杂度,就需要借助哈希表的思路,构建姓名到地址的映射函数“地址 = f (姓名)”。这样,我们就可以通过这个函数直接计算出”张四“的存储位置,在 O(1) 时间复杂度内就可以完成数据的查找。

通过这个例子,不难看出 Hash 函数设计的好坏会直接影响到对哈希表的操作效率。假如对上面的例子采用的 Hash 函数为,姓名的每个字的拼音开头大写字母的 ASCII 码之和。即:

address (张一) = ASCII (Z) + ASCII (Y) = 90 + 89 = 179;

address (张二) = ASCII (Z) + ASCII (E) = 90 + 69 = 159;

address (张三) = ASCII (Z) + ASCII (S) = 90 + 83 = 173;

address (张四) = ASCII (Z) + ASCII (S) = 90 + 83 = 173;

我们发现这个哈希函数存在一个非常致命的问题,那就是 f ( 张三) 和 f (张四) 都是 173。这种现象称作哈希冲突,是需要在设计哈希函数时进行规避的。

从本质上来看,哈希冲突只能尽可能减少,不能完全避免。这是因为,输入数据的关键字是个开放集合。只要输入的数据量够多、分布够广,就完全有可能发生冲突的情况。因此,哈希表需要设计合理的哈希函数,并且对冲突有一套处理机制。

如何设计哈希函数

我们先看一些常用的设计哈希函数的方法:

  • 第一,直接定制法

哈希函数为关键字到地址的线性函数。如,H (key) = a*key + b。 这里,a 和 b 是设置好的常数。

  • 第二,数字分析法

假设关键字集合中的每个关键字 key 都是由 s 位数字组成(k1,k2,…,Ks),并从中提取分布均匀的若干位组成哈希地址。上面张一、张二、张三、张四的手机号信息存储,就是使用的这种方法。

  • 第三,平方取中法

如果关键字的每一位都有某些数字重复出现,并且频率很高,我们就可以先求关键字的平方值,通过平方扩大差异,然后取中间几位作为最终存储地址。

  • 第四,折叠法

如果关键字的位数很多,可以将关键字分割为几个等长的部分,取它们的叠加和的值(舍去进位)作为哈希地址。

  • 第五,除留余数法

预先设置一个数 p,然后对关键字进行取余运算。即地址为 key mod p。

如何解决哈希冲突

上面这些常用方法都有可能会出现哈希冲突。那么一旦发生冲突,我们该如何解决呢?

常用的方法,有以下两种:

  • 第一,开放定址法

即当一个关键字和另一个关键字发生冲突时,使用某种探测技术在哈希表中形成一个探测序列,然后沿着这个探测序列依次查找下去。当碰到一个空的单元时,则插入其中。

常用的探测方法是线性探测法。 比如有一组关键字 {12,13,25,23},采用的哈希函数为 key mod 11。当插入 12,13,25 时可以直接插入,地址分别为 1、2、3。而当插入 23 时,哈希地址为 23 mod 11 = 1。然而,地址 1 已经被占用,因此沿着地址 1 依次往下探测,直到探测到地址 4,发现为空,则将 23 插入其中。如下图所示:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/9471bbee35eebebc3bcb29f49dcfe382.jpeg#pic_center)
  • 第二,链地址法

将哈希地址相同的记录存储在一张线性链表中。

例如,有一组关键字 {12,13,25,23,38,84,6,91,34},采用的哈希函数为 key mod 11。如下图所示:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/c56f7e72c7503cf0ab5aaeae60a8c454.jpeg#pic_center)

哈希表相对于其他数据结构有很多的优势。它可以提供非常快速的插入-删除-查找操作,无论多少数据,插入和删除值需要接近常量的时间。在查找方面,哈希表的速度比树还要快,基本可以瞬间查找到想要的元素。

哈希表也有一些不足。哈希表中的数据是没有顺序概念的,所以不能以一种固定的方式(比如从小到大)来遍历其中的元素。在数据处理顺序敏感的问题时,选择哈希表并不是个好的处理方法。同时,哈希表中的 key 是不允许重复的,在重复性非常高的数据中,哈希表也不是个好的选择。

哈希表的基本操作

在很多高级语言中,哈希函数、哈希冲突都已经在底层完成了黑盒化处理,是不需要开发者自己设计的。也就是说,哈希表完成了关键字到地址的映射,可以在常数级时间复杂度内通过关键字查找到数据。

至于实现细节,比如用了哪个哈希函数,用了什么冲突处理,甚至某个数据记录的哈希地址是多少,都是不需要开发者关注的。接下来,我们从实际的开发角度,来看一下哈希表对数据的增删查操作。

哈希表中的增加和删除数据操作,不涉及增删后对数据的挪移问题(数组需要考虑),因此处理就可以了。

哈希表查找的细节过程是:对于给定的 key,通过哈希函数计算哈希地址 H (key)。

  • 如果哈希地址对应的值为空,则查找不成功。

  • 反之,则查找成功。

虽然哈希表查找的细节过程还比较麻烦,但因为一些高级语言的黑盒化处理,开发者并不需要实际去开发底层代码,只要调用相关的函数就可以了。

哈希表的案例

下面我们来讲解两个案例,帮助你进一步理解哈希表的操作过程。

例 1,将关键字序列 {7, 8, 30, 11, 18, 9, 14} 存储到哈希表中。哈希函数为: H (key) = (key * 3) % 7,处理冲突采用线性探测法。

接下来,我们分析一下建立哈希表和查找关键字的细节过程。

首先,我们尝试建立哈希表,求出这个哈希地址:

H (7) = (7 * 3) % 7 = 0

H (8) = (8 * 3) % 7 = 3

H (30) = 6

H (11) = 5

H (18) = 5

H (9) = 6

H (14) = 0

按关键字序列顺序依次向哈希表中填入,发生冲突后按照“线性探测”探测到第一个空位置填入。

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/d6a9e08de1ce823cf6781695c482b73b.jpeg#pic_center)

最终的插入结果如下表所示:

![在这里插入图片描述](https://i-blog.csdnimg.cn/blog_migrate/f6d904375134b788ca2f02e1c456d9ec.png#pic_center)

接着,有了这个表之后,我们再来看一下查找的流程:

  • 查找 7。输入 7,计算得到 H (7) = 0,根据哈希表,在 0 的位置,得到结果为 7,跟待匹配的关键字一样,则完成查找。

  • 查找 18。输入 18,计算得到 H (18) = 5,根据哈希表,在 5 的位置,得到结果为 11,跟待匹配的关键字不一样(11 不等于 18)。因此,往后挪移一位,在 6 的位置,得到结果为 30,跟待匹配的关键字不一样(11 不等于 30)。因此,继续往后挪移一位,在 7 的位置,得到结果为 18,跟待匹配的关键字一样,完成查找。

例 2,假设有一个在线系统,可以实时接收用户提交的字符串型关键字,并实时返回给用户累积至今这个关键字被提交的次数。

例如,用户输入"abc",系统返回 1。用户再输入"jk",系统返回 1。用户再输入"xyz",系统返回 1。用户再输入"abc",系统返回 2。用户再输入"abc",系统返回 3。

一种解决方法是,用一个数组保存用户提交过的所有关键字。当接收到一个新的关键字后,插入到数组中,并且统计这个关键字出现的次数。

根据数组的知识可以计算出,插入到最后的动作,时间复杂度是 O(1)。但统计出现次数必须要全部数据遍历一遍,时间复杂度是 O(n)。随着数据越来越多,这个在线系统的处理时间将会越来越长。显然,这不是一个好的方法。

如果采用哈希表,则可以利用哈希表新增、查找的常数级时间复杂度,在 O(1) 时间复杂度内完成响应。预先定义好哈希表后(可以采用 Map < String, Integer > d = new HashMap <> (); )对于关键字(用变量 key_str 保存),判断 d 中是否存在 key_str 的记录。

  • 如果存在,则把它对应的value(用来记录出现的频次)加 1;

  • 如果不存在,则把它添加到 d 中,对应的 value 赋值为 1。最后,打印处 key_str 对应的 value,即累积出现的频次。

代码如下:

if (d.containsKey(key_str) {
    d.put(key_str, d.get(key_str) + 1);
}
else{
    d.put(key_str, 1);
}
System.out.println(d.get(key_str));

总结

哈希表在我们平时的数据处理操作中有着很多独特的优点,不论哈希表中有多少数据,查找、插入、删除只需要接近常量的时间,即 O(1)的时间级。

实际上,这只需要几条机器指令。哈希表运算得非常快,在计算机程序中,如果需要在一秒钟内查找上千条记录通常使用哈希表(例如拼写检查器),哈希表的速度明显比树快,树的操作通常需要 O(n) 的时间级。哈希表不仅速度快,编程实现也相对容易。如果不需要有序遍历数据,并且可以提前预测数据量的大小。那么哈希表在速度和易用性方面是无与伦比的。

练习题

下面,我们给出一道练习题。这个问题是力扣的经典问题,two sums。给定一个整数数组 arr 和一个目标值 target,请你在该数组中找出加和等于目标值的那两个整数,并返回它们的在数组中下标。

你可以假设,原数组中没有重复元素,而且有且只有一组答案。但是,数组中的元素只能使用一次。例如,arr = [1, 2, 3, 4, 5, 6],target = 4。因为,arr[0] + arr[2] = 1 + 3 = 4 = target,则输出 0,2。

这道题目你可以采用暴力解法来完成,也可以使用哈希表提高效率。详细分析和答案,请翻阅 15 课时 例题 1。

最后,如果你在工作中,遇到了与哈希表相关的困难的经验,欢迎你在留言区和我分享。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

办公模板库 素材蛙

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值