要想灵活使用数据结构,需要先弄清楚数据在代码中被处理、加工的最小单位动作,也就是数据结构的基本操作,有了这些动作之后,就可以基于此去选择更合适的数据结构了
增删查:掌握数据处理的基本操作,以不变应万变
代码对数据的处理
例子在一个数组中找出出现次数最多的那个元素的数值。例如,输入数组 a = [1,2,3,4,5,5,6] 中,只有 5 出现了两次,其余都是 1 次。显然 5 出现的次数最多,则输出 5。为了降低时间复杂度,引入了 k-v 的字典的数据结构
为什么使用对象的key-value结构
代码处理数据的核心思路是:
- 第一步,根据原始数组计算每个元素出现的次数;
- 第二步,根据第一步的结果,找到出现次数最多的元素。
首先,我们来分析第一步统计出现次数的处理。此时,你还不知道应该采用什么数据结构。
对于每一次的循环,你得到了输入数组中的某个元素 a[ i ] 。接着,你需要判断这个元素在未知的数据结构中是否出现过:
- 如果出现了,就需要对出现的次数加 1。
- 如果没有出现过,则把这个元素新增到未知数据结构中,并且把次数赋值为 1。
这里的数据操作包括以下 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)。
例 3,我们再来看第三个例子,关于数值条件的查找。
我们要查找出,数据结构中数值等于 5 的元素是否存在。这次的查找,无论是数组还是链表都束手无策了。唯一的方法,也只有按照顺序一个接一个地去判断元素数值是否满足等于 5 的条件。很显然,这样的查找方法时间复杂度是 O(n)。那么有没有时间复杂度更低的方式呢?答案当然是:有。
遇到过要查找出数组中出现次数最多的元素的情况。我们采用的方法是,把数组转变为字典,以保存元素及其出现次数的 k-v 映射关系。而在每次的循环中,都需要对当前遍历的元素,去查找它是否在字典中出现过。这里就是很实际的按照元素数值查找的例子。如果借助字典的数据类型,这个例子的查找问题,就可以在 O(1) 的时间复杂度内完成了。
例 4,我们再来看第四个例子,关于复杂数据结构中新增数据,这里有两个可能.
- 第一个是在这个复杂数据结构的最后,新增一条数据。
- 第二个是在这个复杂数据结构的中间某个位置,新增一条数据。
这两个可能性的区别在于,新增了数据之后,是否会导致原有数据结构中数据的位置顺序改变。接下来,我们分别来举例说明。
在复杂数据结构中,新增一条数据。假设是在数据结构的最后新增数据。此时新增一条数据后,对原数据没有产生任何影响。因此,执行的步骤是:
- 首先,通过查找操作找到数据结构中最后一个数据的位置;
- 接着,在这个位置之后,通过新增操作,赋值或者插入一条新的数据即可。
如果是在数据结构中间的某个位置新增数据,则会对插入元素的位置之后的元素产生影响,导致数据的位置依次加 1 。例如,对于某个长度为 4 的数组,在第二个元素之后插入一个元素。则修改后的数组中,原来的第一、第二个元素的位置不发生变化,第三个元素是新插入的元素,第四、第五个元素则是原来的第三、第四个元素。
我们再来看看删除。在复杂数据结构中删除数据有两个可能:
- 第一个是在这个复杂数据结构的最后,删除一条数据。
- 第二个是在这个复杂数据结构的中间某个位置,删除一条数据。
这两个可能性的区别在于,删除了数据之后,是否会导致原有数据结构中数据的位置顺序改变
例 5,在某个复杂数据结构中,在第二个元素之后新增一条数据。随后再删除第 1 个满足数值大于 6 的元素。我们来试着分析这个任务的数据操作过程。这里有两个步骤的操作:
- 第一步,在第二个元素之后新增一条数据。这里包含了查找和新增两个操作,即查找第二个元素的位置,并在数据结构中间新增一条数据。
- 第二步,删除第 1 个满足数值大于 6 的元素。这里包含查找和删除两个操作,即查找出第 1 个数值大于 6
的元素的位置,并删除这个位置的元素。
因此,总共需要完成的操作包括,按照位置的查找、新增和按照数据数值的查找、删除
总结
数据处理的基本操作只有 3 个,分别是增、删、查。其中,增和删又可以细分为在数据结构中间的增和删,以及在数据结构最后的增和删。区别就在于原数据的位置是否发生改变。查找又可以细分为按照位置条件的查找和按照数据数值特征的查找。几乎所有的数据处理,都是这些基本操作的组合和叠加。
如何完成线性表结构下的增删查?
数据在代码中被处理和加工的最小单位动作是增、删、查。它们是深入学习数据结构的根基,通过“增删查”的操作,我们可以选择更合适的数据结构来解决实际工作中遇到的问题。例如,几个客户端分别向服务端发送请求,服务端要采用先到先得的处理方式,应该如何设计数据结构呢?
什么是数据结构?
什么是数据结构。数据结构,从名字上来看是数据的结构,也就是数据的组织方式。在数据结构适用的场合中,需要有一定量的数据。如果数据都没有,也就不用讨论数据如何组织了。当我们有了一定数量的数据时,就需要考虑以什么样的方式去对这些数据进行组织了。接下来,我将通过一个实际案例来帮助你更好地理解数据结构。假设你是一所幼儿园的园长,现在你们正在组织一场运动会,所有的小朋友需要在操场上接受检阅。那么,如何组织小朋友有序站队并完成检阅呢?
几个可能的方式是,让所有的小朋友站成一横排,或者让小朋友站成方阵,又或者让所有的小朋友手拉手,围成一个大圆圈等等。很显然,这里有无数种可行的组织方式。具体选择哪个组织方式,取决于哪一种能更好地展示出小朋友们的风采。
试想一下,当计算机要处理大量数据时,同样需要考虑如何去组织这些数据,这就是数据结构。类似于小朋友的站队方式有无数种情况,数据组织的方式也是有无数种可能性
什么是线性表
线性表是 n 个数据元素的有限序列,最常用的是链式表达,通常也叫作线性链表或者链表。在链表中存储的数据元素也叫作结点,一个结点存储的就是一条数据记录。每个结点的结构包括两个部分:
- 第一是具体的数据值;
- 第二是指向下一个结点的指针。
在链表的最前面,通常会有个头指针用来指向第一个结点。对于链表的最后一个结点,由于在它之后没有下一个结点,因此它的指针是个空指针。链表结构,和小朋友手拉手站成一排的场景是非常相似的。
例如,你需要处理的数据集是 10 个同学考试的得分。如果用链表进行存储,就会得到如下的数据:
仔细观察上图,你会发现这个链表只能通过上一个结点的指针找到下一个结点,反过来则是行不通的。因此,这样的链表也被称作单向链表。
有时候为了弥补单向链表的不足,我们可以对结点的结构进行改造:
- 对于一个单向链表,让最后一个元素的指针指向第一个元素,就得到了循环链表;
- 或者把结点的结构进行改造,除了有指向下一个结点的指针以外,再增加一个指向上一个结点的指针。这样就得到了双向链表。
同样的,还可以对双向链表和循环链表进行融合,就得到了双向循环链表,如下图所示:
这些种类的链表,都是以单向链表为基础进行的变种。在某些场景下能提高线性表的效率。
线性表对于数据的增删查处理
学会了线性表原理之后,我们就来围绕数据的增删查操作,来看看线性表的表现。在这里我们主要介绍单向链表的增删查操作,其他类型的链表与此雷同,我们就不再重复介绍了
首先看一下增加操作。如下有一个链表,它存储了 10 个同学的考试成绩。现在发现这样的问题,在这个链表中,有一个同学的成绩忘了被存储进去。假设我们要把这个成绩在红色的结点之后插入,那么该如何进行呢?
其实,链表在执行数据新增的时候非常容易,只需要把待插入结点的指针指向原指针的目标,把原来的指针指向待插入的结点,就可以了。如下图所示:
代码如下:
s.next = p.next;
p.next = s;
接下来我们看一下删除操作。还是这个存储了同学们考试成绩的链表,假设里面有一个成绩的样本是被误操作放进来的,我们需要把这个样本删除。链表的删除操作跟新增操作一样,都是非常简单的。如果待删除的结点为 b,那么只需要把指向 b 的指针 (p.next),指向 b 的指针指向的结点(p.next.next)。如下图所示:
p.next = p.next.next;
最后,我们再来看看查找操作。我们在前面的课时中提到过,查找操作有两种情况:
- 第一种情况是按照位置序号来查找。
它和数组中的 index 是非常类似的。假设一个链表中,按照学号存储了 10 个同学的考试成绩。现在要查找出学号等于 5 的同学,他的考试成绩是多少,该怎么办呢?
其实,链表的查找功能是比较弱的,对于这个查找问题,唯一的办法就是一个一个地遍历去查找。也就是,从头开始,先找到学号为 1 的同学,再经过他跳转到学号为 2 的同学。直到经过多次跳转,找到了学号为 5 的同学,才能取出这个同学的成绩。
- 第二种情况是按照具体的成绩来查找。
同样,假设在一个链表中,存储了 10 个同学的考试成绩。现在要查找出是否有人得分为 95 分。链表的价值在于用指针按照顺序连接了数据结点,但对于每个结点的数值则没有任何整合。当需要按照数值的条件进行查找时,除了按照先后顺序进行遍历,别无他法。
因此,解决方案是,判断第一个结点的值是否等于 95:
- 如果是,则返回有人得分为 95 分;
- 如果不是,则需要通过指针去判断下一个结点的值是否等于 95。以此类推,直到把所有结点都访问完。
根据这里的分析不难发现,链表在新增、删除数据都比较容易,可以在 O(1) 的时间复杂度内完成。但对于查找,不管是按照位置的查找还是按照数值条件的查找,都需要对全部数据进行遍历。这显然就是 O(n) 的时间复杂度。
虽然链表在新增和删除数据上有优势,但仔细思考就会发现,这个优势并不实用。这主要是因为,在新增数据时,通常会伴随一个查找的动作。例如,在第五个结点后,新增一个新的数据结点,那么执行的操作就包含两个步骤:
- 第一步,查找第五个结点;
- 第二步,再新增一个数据结点。整体的复杂度就是 O(n) + O(1)。
根据我们前面所学的复杂度计算方法,这也等同于 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;
}
例 2,给定一个奇数个元素的链表,查找出这个链表中间位置的结点的数值。
这个问题也是利用了链表的长度无法直接获取的不足做文章,解决办法如下:
- 一个暴力的办法是,先通过一次遍历去计算链表的长度,这样我们就知道了链表中间位置是第几个。接着再通过一次遍历去查找这个位置的数值。
- 除此之外,还有一个巧妙的办法,就是利用快慢指针进行处理。其中快指针每次循环向后跳转两次,而慢指针每次向后跳转一次。
while(fast && fast.next && fast.next.next){
fast = fast.next.next;
slow = slow.next;
}
例 3,判断链表是否有环。如下图所示,这就是一个有环的链表。
链表的快慢指针方法,在很多链表操作的场景下都非常适用,对于这个问题也是一样。
假设链表有环,这个环里面就像是一个跑步赛道的操场一样。经过多次循环之后,快指针和慢指针都会进入到这个赛道中,就好像两个跑步选手在比赛。快指针每次走两格,而慢指针每次走一格,相对而言,快指针每次循环会多走一步。这就意味着:
- 如果链表存在环,快指针和慢指针一定会在环内相遇,即 fast == slow 的情况一定会发生。
- 反之,则最终会完成循环,二者从未相遇。
根据这个性质我们就能对链表是否有环进行准确地判断了。
总结
内容主要围绕线性表的原理、线性表对于数据的增删查操作展开。线性链表结构的每个结点,由数据的数值和指向下一个元素的指针构成。根据结构组合方式的不同,除了单向链表以外,还有双向链表、循环链表以及双向循环链表等变形。
经过分析,链表在增、删方面比较容易实现,可以在 O(1) 的时间复杂度内完成。但对于查找,不管是按照位置的查找还是按照数值条件的查找,都需要对全部数据进行遍历。
线性表的价值在于,它对数据的存储方式是按照顺序的存储。当数据的元素个数不确定,且需要经常进行数据的新增和删除时,那么链表会比较合适。链表的翻转、快慢指针的方法,是你必须掌握的内容。