数据结构day02

1 线性表的定义和基本操作

1.1 线性表的定义

分析:

1.1.1 问题一:我们为什么探讨线性表的定义和基本操作

在研究数据结构时,需要重点关注三个方面:逻辑结构物理结构以及数据的运算。在本节内容里,我们首先来介绍线性表的定义与基本操作。实际上,这就是要探究这种数据结构的逻辑结构究竟是怎样的,同时还要探讨针对这种数据结构需要实现哪些基本运算,也就是基本操作。在后续小节中,我们还会深入探讨如何运用不同的存储结构来实现线性表。要知道,当采用不同的存储结构时,数据运算的具体实现方式也会有所不同。这一点,后续我们会通过具体代码,帮助大家加深理解。

分析:

1.1.2 问题2:如何用大白话讲明白线性表的定义?

好了,现在我们先来看看什么是线性表。通俗来讲,线性表指的是各个数据元素之间的逻辑关系,其逻辑结构呈一条线的形态,就如同被串在一起,数据元素之间存在明确的前后顺序课本中给出了较为严谨的文字定义 ,

1.1.3 问题3:在官方给的线性表定义中,我们需要留意哪些点?

在这个定义中,有几个要点需要我们留意。

第一点:

首先,线性表中的各个数据元素,其数据类型必须相同。例如,如果某个数据元素是int型,那么其他数据元素也都得是int型。当然,你也可以自行定义某种结构类型,比如定义为struct a ,然后将这个自定义的结构类型作为数据元素的数据类型。所有数据元素数据类型相同,这意味着它们所占的存储空间大小是一致的 。 这个特性有助于计算机迅速定位某一具体数据元素。

第二点:

线性表是一个序列,“序”即次序,各个数据元素之间存在明确的前后次序

第三点

线性表中的数据元素数量是有限。举个反例,若将所有整数按递增次序排列,这样的数据结构虽满足数据元素类型相同、元素之间有次序这两个特性,但由于整数数量是无限的,所以它并非线性表。

1.1.4 问题4:我们在做题当中,在考察线性表的定义时,还需要注意哪些术语?

(1)线性表的长度 N,我们称之为表长。若表长为 0,这样的线性表就是一个空表

(2)另外,在描述线性表中的各个数据元素时,角标从 1 开始,A₁表示线性表中的第一个数据元素,A₂是第二个数据元素,Aᵢ 是第 i 个数据元素。我们用“数据元素在线性表中的位序”这一专业术语来描述“第几个”。

(3)线性表中的第一个元素称为表头元素,最后一个元素称为表尾元素。除表头元素外,线性表中的其他所有元素都有一个直接前驱,即排在它们前面的数据元素,这就是前驱的概念 。 除了最后一个元素外,其他每个元素都能直接找到其直接后继,这就是后继的概念。

(4)这里要再次强调位序这个概念,位序是从 1 开始的,但我们在程序中定义数组时,数组下标是从 0 开始的。

分析:

1.1.5 问题5:为什么这样一种线性结构关系要被称作“表”呢?

不知道大家看到“线性表”这个术语时,会联想到什么。对我来说,看到“表”这个字,首先想到的就是类似这样的东西。我刚开始学习的时候就很疑惑,为什么这样一种线性结构关系要被称作“表”呢?其实从线性表的英文术语中就能找到答案。线性表的英文是“linear list”,“linear”意思是“线形的、直线的、线状的”,它由“line”这个单词演变而来,“line”就是“线”的意思,比如大家爱看的《天线宝宝》英文是“Teletubbies” ,其中“skyline”就有“天际线”之意。“list”有“列表”的意思,比如待办事项是“to - do list”,这种列表由一个个元素组成,这就和我们的线性表模样对应上了。所以我觉得它被翻译为“线性表”,可能就是因为“list”本身有“列表”的含义。

换个角度理解,如果一个数据元素包含多个数据项,那么从形式上看,这样的数据结构所保存的内容不就像这样的一张表吗?这就是为什么这种数据结构叫“线性表”,而不是什么“线性串”之类名称的原因。

 1.2 线性表的基本操作

好啦,在认识了线性表的逻辑结构后,咱们来瞧瞧需要对线性表实现哪些基本操作,或者说基本运算。

 分析:

1.2.1 问题6:我们为什么要先了解线性表的初始化和销毁,其次了解插入删除,查找?

首先,要实现的两个基本操作,是初始化一个线性表以及销毁一个线性表这两个操作实现了线性表从无到有、从有到无的过程,主要工作是分配和释放内存空间

当然,还得更改一些必要信息。

接下来要实现的基本操作是插入和删除。这里的描述是不是很像函数?这部分是函数名,括号里的是函数参数。这里声明了三个参数,第一个参数 L 代表线性表,第二个参数 i 表示要在线性表的第 i 个位置插入元素,第三个参数 e 则是要插入的元素值。删除操作类似,一个函数名,里面有相应参数。

之后要实现的基本操作是按值查找和按位查找。按值查找,就是给定一个元素 e 的值,在线性表 L 中查找是否有数据元素与传入的参数 e 相同。按位查找呢,就是传入一个参数 i,通过它指明要找的是线性表中的第几个元素。

1.2.2 问题7:我们除了上面的操作方式,还有其他的操作方式吗?

最后,我们还能定义一些其他常用操作。比如定义一个函数 length,传入 L,它会返回线性表 L 的长度;也可以定义一个函数 print_list,传入线性表 L,它会打印输出线性表 L 中所有元素的值。 Emply(L)最后是一个判空操作,传入线性表 L。若线性表为空表,该函数将返回true;若线性表非空,则返回 false。这些基本操作的具体实现,后续会通过具体代码详细讲解,此处我们只需有个大致印象。

1.2.3 问题8:对学习数据结构,有什么好的建议?

这里有几个要点需牢记。

(1)其一,学习任何数据结构时,对其操作基本都逃不开创建、销毁、增删改查,即增加、删除、修改或查询数据元素。学习后续数据结构时,大家也可自行思考,针对特定数据结构,这些操作该如何实现。比如线性表的插入和删除操作,不正是增加和删除数据元素吗?而按值查找和按位查找操作,本质就是查询数据元素。虽说此处未定义修改数据元素值的操作,但修改前必然要先找到目标元素,所以“改”操作的第一步也是“查”。后面这些基本操作是为方便编程实现的,核心还是前面那几个操作。

(2)其二,在 C 语言中定义函数时,需声明函数名,之后还要声明参数列表,包括参数名及其具体类型。 然而,在我们上述描述基本操作的地方,并未明确指出具体的参数类型。因此,此处给出的函数接口实际上具有抽象性。例如,这里的“e”代表数据元素,线性表中存储的数据元素既可以是 int 型变量,也可以是某种 T 类型的变量。所以,我们在阐述这些基本操作时,并未明确参数的具体类型。只有在通过代码实现这些基本操作时,我们才需要真正关注参数的具体类型。

(3)接下来,第三个要点是,在实际开发或做题过程中,大家可根据实际需求定义其他基本操作。倘若你觉得更改某一数据元素的操作十分常用,那么完全可以将其定义为一个基本操作。

(4)最后要提醒大家的是,此处给出的函数名和参数名的命名方式,参考了严蔚敏版的数据结构。由于许多高校指定的考研参考教材都是严蔚敏版的,但大家在自行做题时,不必完全拘泥于这种命名方式,无需过于教条。当然,你所命名的函数和变量,其名称应具备可读性。就像“destroy list”,仅从函数名就能知晓它是用于销毁一个表的。倘若你将销毁操作的函数命名为“A”,谁又能明白这个“A”的用途呢?当然,如果大家在答题时采用此处推荐的命名方式,想必会深受改卷老师的青睐。

(5) 

大家会发现,在某些函数的基本操作中,传入的参数是引用类型,这是 C++ 里的写法。那么,何时需要传入引用型参数呢?先给出结论,稍后再详细解释。如果需要将对参数的修改结果带回来,那就必须传入引用型参数。

下面来看一个具体例子,

1.2.4 问题9:理解什么叫“把修改的结果带回来”。大家可以自己动手编写这段程序。

下面具体看看这个程序。在 main 函数里,首先定义一个变量 X,并赋值为 1,接着使用 printf 输出 X 的值。随后调用 test 函数,将变量 X 作为参数传入。在 test 函数中,把 X 的值修改为 1024,并打印此时 X 的值。当 test 函数运行结束返回 main 函数后,再次打印 X 的值。运行结果是:调用 test 函数前,X 的值为 1

在 test 函数内部,X 的值被改为 1024;但当 test 函数执行完毕返回 main 函数时,X 的值又变回了 1。 所以在这个地方,test函数虽然对X的值进行了修改,但修改结果并未带回main函数。这背后的原因是什么呢? 

在main函数中,我们定义了一个初始值为1的变量X。当调用test函数时,test函数里的X实际上是main函数中X的一个复制品。这两个变量虽然都叫X,但在内存中,它们是两份不同的数据。因此,test函数将X的值改为1024,改的只是它自己那份数据。当test函数运行结束回到main函数后,这里打印出的X值依然是1。请仔细体会这句话:对参数的修改结果没有带回来。

好了,接下来我们把参数改成引用类型,即在参数名前面加一个引用符号。看这边的运行结果

 在test函数中把X的值改成了1024,当返回到main函数时,这里打印出的X值同样是1024。

这意味着,如果将参数改成引用类型,test函数中对参数的修改就能被带回到main函数中。简单理解就是,main函数里定义了变量X,这个变量X作为参数传递给了test函数。由于test函数中定义的参数是引用类型,所以test函数操作的参数与main函数里的X是同一份数据。如此一来,test函数中对X的修改,自然也会影响到main函数里X的值。

通过这两个例子,想必大家已经能体会到什么叫“把对参数的修改结果带回来”了。这里要提醒一下,引用类型是 C++ 里的特性,运行这个程序时,需要选择 C++ 编译器,因为 C 语言并不支持这种引用类型。

好了,咱们再回到刚才提到的基本操作。就拿插入操作来说,传入的线性表 L 加了引用符号。这是因为在这个函数里,我们要修改线性表 L 的具体内容,并且希望把修改效果带回去,所以才加了引用符号。大家不妨暂停一下,好好琢磨琢磨,为什么有些地方需要加引用符号,有些地方却不用。从这个角度深入思考,这可是重点内容,只有理解了,才能写出无误的代码。

1.2.5 问题10:在了解了需要实现哪些基本操作之后,我们来探讨一个问题:为什么要实现对数据结构的基本操作呢?

首先,现在的项目大多是大型项目,需要庞大的团队协作编程。如果你是数据结构的定义者,那么你定义的数据结构,得让队友们用起来得心应手。所以,在定义完数据结构后,还得为队友们提供一些方便易用的函数,将这些基本操作以函数的形式封装起来。 第二,将这些基本操作封装成函数后,能避免重复工作。毕竟这些基本操作十分常用,封装成函数后,日后每次使用时,无需重新编写代码,调用一个函数就能解决问题。

大家学习时,一定要时常进行这类深入思考。很多同学学习时,只关注“怎么做”,却不思考“为什么要这么做”。实际上,想明白“为什么”至关重要。只有清楚做一件事的原因和意义,学习时才会更有动力,更加积极主动。这算是与课程内容本身无关的小建议。

总结:

好啦,在这个小节中,我们学习了线性表这种数据结构。它的逻辑结构理解起来并不难,唯一需要留意的是“位序”这个概念。位序指的是一个数据元素在线性表中的位置序号,即位序从 1 开始,而程序中数组的下标是从 0 开始的。所以,若用数组实现线性表,一定要仔细审题。这一点,我们会在后续小节中有更深刻的体会。

在这个小节里,我们还探讨了需要对线性表实现哪些基本操作。基本运算对所有数据结构而言,都是最为重要和核心的部分。基本操作不外乎创建、销毁,以及增、删、改、查,这可以作为大家的思考方向。我们也着重强调了这一点。 有一个极其关键的要点,那就是我们必须清晰理解参数何时需要使用引用类型。此外,还需格外留意,函数和变量的命名务必具备可读性,要让他人一眼就能明白该函数和变量的用途。好了,以上便是本小节的全部内容。

2 顺序表

从逻辑角度而言,线性表的各个元素构成一个有序序列,各数据元素存在先后顺序。这种逻辑结构是我们从人类视角理解所观察到的特性。那么,在计算机中该如何表示这些数据元素之间的逻辑关系呢?

从本节课起,我们将分别介绍如何运用顺序存储和链式存储这两种存储结构来实现线性表。在本小节,我们要学习的顺序表,实际上就是采用顺序存储方式实现的线性表。本小节我们将学习顺序表的定义,了解顺序表的特性,以及掌握如何用代码实现顺序表。下一小节,我们还会探讨基于顺序存储结构,怎样用代码具体实现之前所定义的一些基本操作。

2.1 顺序表的定义

2.1.1问题11:顺序表的定义是什么?如何理解顺序存储?

顺序表是通过顺序存储方式实现的线性表,而顺序存储是指将逻辑上相邻的数据元素存储在物理位置也相邻的存储单元中。这一点在绪论中曾提及,结合这张图,很容易直观理解,数据元素之间的前后关系通过物理内存的连接关系得以体现。

2.1.2问题12.我们如果知道了顺序表中的一个元素的物理存储地址,那下一个元素的存储地址是什么?

我们在之前强调过,线性表中的各个数据元素数据类型相同,即每个数据元素所占内存空间大小一致。所以,若顺序表的第一个数据元素存放地址为某一地址,由于顺序表中各数据元素在物理内存中连续存放,且每个数据元素所占空间大小相等 。 因此,顺序表中第二个数据元素的存放位置,应为顺序表的起始地址加上数据元素的大小;第三个数据元素的存放位置,则是起始地址加上2倍的数据元素大小。

2.1.3问题13:如何得知一个数据元素的大小呢?

C语言提供了一个便捷的关键字“sizeof”,使用时在其后加上小括号,在括号内传入顺序表中存放的数据元素的数据类型即可。

例如,若顺序表中存放的是整数,在“sizeof”括号内填入“int”,就能得到一个int型整数在该系统中所占的内存空间大小。在C语言里,多数情况下,一个int型变量占4个字节。

      2.1.3.1问题13.1 顺序表还能存放更复杂的数据吗?

当然,顺序表还能存放更复杂的数据,比如自定义的结构类型数据。这里定义了一个名为“customer”的结构,其中包含两个整数“nu”和“people”。由于每个整数占4个字节,所以“customer”这种数据类型所占的内存空间大小为8个字节。实际上,无需关心这8个字节是如何计算得出的,只需使用C语言提供的“sizeof”关键字就能轻松获取数据类型的大小。

   2.1.1 静态分配

接下来,我们看看顺序表的第一种实现方式——静态分配

2.1.1.1问题14:什么是静态分配?

所谓静态分配,就是采用大家最为熟悉的数组定义方式来实现顺序表。

2.1.1.2 问题14.1 静态数组的特性?

静态数组,其长度一旦确定便不可更改,这是静态数组的特性。

2.1.1.3 问题15:解释一下上面如图片中顺序表定义的代码?

我们用这样的数据类型来表示顺序表,其中定义了一个长度为“MaxSize”的静态数组,“MaxSize”是通过宏定义的常量。此外,还定义了一个名为“length”的变量,用于表示当前顺序表的实际长度。“MaxSize”的值决定了顺序表最多能存放的数据元素数量,而“length”的值则体现了当前顺序表中已存入的元素个数。

2.1.1.4问题15.1:如何从内存的角度来理解代码?

从内存角度看,当声明一个数组时,实际上是在内存中开辟了一整片连续空间。在我们的代码中,这片连续空间总共可存放10个数据元素。这里数据元素的类型用“element_type”表示,它其实是“element(元素)”的缩写。数据元素的类型可以是“int”型,也可以是用户自定义的更复杂类型,具体取决于顺序表的存储需求。我们用“element_type”表示,是为了让代码更具通用性。 如果大家自行写代码实现,只需将数据元素的具体类型替换“type”即可。我们将顺序表命名为“SqList”,其中“Sq”是“sequence”的缩写。

    

2.1.1.5问题16:上面的代码是如何给顺序表进行初始化的?

接下来看具体代码。这里定义了一个用于存放整数的顺序表,即数据元素的数据类型为“int”。我们定义了一个名为“data”的静态数组,最多可存放10个数据元素。

定义好这样的数据结构后,在“main”函数里,首先声明一个“SqList”,即声明一个顺序表。执行此代码时,计算机将在内存中为该顺序表分配所需空间。首先是用于存放“data”数组的一整片连续空间,这片空间大小为10乘以每个数据元素的大小。由于这里的数据元素是“int”型,每个数据元素大小为4个字节。除“data”外,还需分配一个用于存放变量“lenth”的空间,其大小同样为4个字节,因为它也是“int”型。

接下来,在代码中实现了一个名为“InitList”的函数,用于对该顺序表进行初始化。 其实这个函数,正是我们上一小节提到的基本运算中的第一个。既然“main”函数调用了“InitList”,接下来就会执行该函数里的代码。

         首先是一个“for”循环,它的作用是将“data”数组中所有数据元素的值设为零,也就是给数据元素设置默认初始值。当然,这一步是可以省略的,稍后再作解释。

        除此之外,还需将“list”的值设为零,因为刚开始顺序表中没有存入任何数据元素,此时顺序表的当前长度应为零。这便是对顺序表的初始化工作。

       

2.1.1.6问题16.1:如果不给“data”数组设置默认初始值,会出现什么情况呢?

我们把这部分代码去掉,即在初始化顺序表时,只设置其内部变量的值。然后在“main”函数里添加一个“for”循环,将“data”数组全部打印出来。打印结果如下:可以看到,“data”数组前面的数据元素都是零,这很正常。但最后两个数据元素却是很奇怪的值。如果大家在自己电脑上运行这段代码,打印出的“data”数组中各元素的值,可能与我的不一样。

出现这种奇怪现象的原因是内存中存在遗留的脏数据。也就是说,当我们声明这个顺序表时,尽管系统在背后为我们分配了一大片内存空间,但这片内存空间之前存储的数据,我们并不清楚。所以,如果不给这些数据元素设置默认值,就可能因之前遗留的脏数据,导致我们的数据中出现一些奇怪的值 。

2.1.1.7问题16.2:如何理解脏数据?

这里重点让大家理解脏数据的概念。由于内存中可能存在脏数据,声明 lengths 变量时,将其初始值设为零这一步绝不能省略,因为无法预知这片内存区域之前存放的数据是什么。有些同学可能会说,C 语言会自动为 int 型变量设置默认初始值为零。但需注意,默认初始值的设置由编译器决定,换一个 C 语言编译器,可能就不会进行这种初始化工作。所以,声明顺序表时,将其初始值设为零是必不可少的。

2.1.1.8问题16.3:数据元素设置默认值这一步其实可以省略,为什么?

不过,刚才提到给数据元素设置默认值这一步其实可以省略。这是因为在内核数里打印顺序表中的内容,这种操作实际上是违规的,我们本就不该以这种方式访问顺序表。要知道,顺序表中定义了一个变量“lenth”,它表示顺序表当前的长度。因此,当我们访问顺序表中的数据元素时,不应从第一个元素一直访问到最后一个元素,而应访问到顺序表中当前实际存储的最后一个元素。由于刚开始“L”的值为零,所以若采用更正规一些的写法,那么“for”循环中的语句将不会被执行。这就是为什么说可以省略给各个数据元素设置默认值这一步,因为按正常的访问方式,我们实际上不应该访问大于顺序表实际长度的那些数据元素。当然,其实更好的做法应该是。 使用基本操作来访问数据元素是最佳方式。回顾上一小节,我们应实现一个名为 GET0 的基本操作,该操作能从线性表 L 中取出第二个元素。

通过刚才的代码,相信大家对顺序表的静态分配实现方式有了更深入的理解。这种实现方式的关键在于定义一个静态数组来存储数据元素。

2.1.1.9问题16.4:接下来思考,如果刚开始声明的数组长度不够,存满了该怎么办?

遇到这种情况,建议直接放弃,因为静态数组一旦声明,其容量就无法改变。 也就是说,为顺序表分配的存储空间是固定不变的,属于静态分配。或许有同学会问:“既然如此,一开始就申请一大片连续的存储空间,把数组长度设置得大一些,不就可以了吗?”然而,这种做法存在明显弊端——太过浪费内存。设想一下,若将数组长度设为 10000,可最终实际仅使用了 10 个元素,这无疑是对内存资源的极大浪费,并非明智之举。

由此可见,静态分配这种实现方式存在一定局限性,主要体现在顺序表的大小和容量无法调整更改

2.1.1.10问题16.5:若想让顺序表的大小可变,该如何操作呢?

答案是采用动态分配的实现方式。

2.1.2 动态分配

2.1.2.1 问题17:若采用动态分配来实现顺序表,我们应该如何操作?

若采用动态分配来实现顺序表,我们需要定义一个指针,使其指向顺序表中的首个数据元素。由于在动态分配方式下,顺序表的容量大小能够改变,因此需要新增一个变量“max_size”,用以表示顺序表的最大容量。除了最大容量,还需使用“next”变量来记录顺序表的当前长度,即顺序表中实际已存放的数据元素个数。

2.1.2.2 问题17.1:malloc 函数的原理是什么?

malloc 函数的作用是申请一整片连续的内存空间。这片内存空间必然有一个起始的内存地址。因此,当 malloc 函数执行完毕后,它会返回一个指向这片存储空间起始地址的指针。

由于这片存储空间是用来存放一个个数据元素的,所以在这里,我们需要将 malloc 函数返回的指针,强制转换为所定义的数据元素数据类型对应的指针。例如,如果顺序表是用来存放整数的,即数据元素为 int 类型,那么在使用 malloc 函数时,就需要将类型指定为 int

malloc 函数返回的这个内存起始地址的指针,要赋值给顺序表中的 data 指针变量,也就是说,data 指针指向了这片存储空间的起始地址。

另外一个需要注意的点是,既然 malloc 函数是申请一整片连续的存储空间,那么究竟要申请多大的空间呢?这是由 malloc 函数的参数来确定的。看一下左边的 sizeof(element_type),之前我们讲过,这个式子得出的结果就是一个数据元素所占的存储空间大小。 如果数据元素是 int 类型,其所占空间大小应为四个字节。式子的第二部分是乘以 init_sizeinit_size 指的是顺序表的初始长度,这里我们将其定义为 10。所以整个式子计算得出的结果,就是存放 10 个 int 型变量所需的存储空间大小。这就是 malloc 函数。

学过 C++ 的同学,可用 newdelete 这两个关键字实现类似于 mallocfree 的功能。不过,newdelete 涉及面向对象相关知识点。为照顾更多跨考同学,后续学习中我们会更多使用 mallocfree 这类函数。

接下来,通过一段具体代码看看顺序表动态分配背后的原理

2.1.2.3问题18:顺序表动态分配背后的原理是什么?

接下来,通过一段具体代码看看顺序表动态分配背后的原理。这里定义了一个顺序表,其数据元素类型为 int 型,data 指针指向顺序表中的第一个数据元素。我们实现了一个 init 函数,用于以动态分配方式初始化顺序表,还实现了一个函数用于动态增加顺序表的长度。在 main 函数里调用这些相关操作。稍后我们来分析其背后的原理。需要注意的是,List 这里使用到了 malloc 函数来增加顺序表的长度 。 这个函数中又用到了 mallocfree 这两个函数。mallocfree 包含在相应头文件中,所以如果大家自己写代码需要使用这两个函数,得引入这个文件。

接下来分析一下这段代码的运行过程。首先在函数内部声明一个顺序表,执行完这句代码后,计算机将在内存中开辟一小片空间,这片空间用于存放顺序表中的几个变量。其中,size 表示顺序表的最大容量,n 表示当前顺序表中的数据元素个数,而 data 是一个指针变量。

接下来,开始执行定义的基本操作——初始化顺序表。在该函数的第一句,会调用 malloc 函数,此函数将申请一整片连续的存储空间,其大小要能容纳 10 个 int 类型的数据。之后,malloc 函数会返回一个指针,我们将这个指针的类型转换为与此处统一的指针类型,再把 malloc 返回的指针值赋给 data。前面提到过,malloc 返回的是这一整片连续存储空间的起始地址,所以执行完这段代码后,data 指针应指向该位置。 再次强调,需要将 malloc 返回的指针转换为与我们在此处定义的相同类型的指针。

除了 data 指针外,我们还需将顺序表的当前长度 n 设置为零,并将顺序表的最大容量设置为与初始值一致。

接下来,省略一些代码。假设我们往顺序表中插入数据直至填满,此时 n 的值应为 10,size 的值也应为 10。倘若还想存入更多数据,顺序表的大小显然不够了。因此,我们在此实现了一个函数,用于动态增加数组(即顺序表)的长度。

这里有一个参数,它表示需要拓展的长度。我们传入 5,意味着希望顺序表能够再多存储 5 个数据元素。

首先,定义一个指针 p,将顺序表的 data 指针的值赋给 p,即 p 指针与 data 指向同一位置。接下来调用 malloc 函数,该函数的作用是申请一整片内存空间,这片空间的大小要能容纳当前所有数据元素,并且还能再多存储 5 个新的数据元素。当然,这需要乘以每个数据元素的大小 sizeof(element type),即要使用 sizeof 这个操作符 。 这意味着开辟了一片新空间,这片空间能存储 15 个元素,此前只能存 10 个,现在可多存 5 个。由于 malloc 申请的是另一片内存空间,且这片空间此时尚未存储任何数据。接下来,让 data 指针指向这片新空间,再通过一个循环,将原来内存空间中的数据逐一迁移过来。因为顺序表的最大容量增加了,所以我们要将size的值加5,使其变为15。最后,调用free函数,它会释放T指针所指向的整片存储内存空间,将其归还给系统。

于P变量是函数的局部变量,当函数执行结束后,存储P变量的内存空间会被系统自动回收。这样,通过malloc就实现了动态数据扩展,也就是顺序表的扩展。 由于我们需要将数据复制到新区域,虽然动态分配能让顺序表的大小灵活改变。熟悉 C 语言的同学可能知道一个名为 realloc 的函数,它确实也能实现我们刚才提到的一系列操作和功能。然而,在调用 realloc 函数的过程中,可能会遇到一些意想不到的“坑”。所以,我建议大家最好还是使用 malloc 和 free 这一对函数,因为使用它们能让我们更清晰地理解动态分配背后的运行过程。

好了,我们已经介绍了顺序表的两种实现方式,一是静态分配,二是动态分配。无论采用哪种方式,顺序表都具备以下特性:

2.1.3 问题19:顺序表都具备哪些特性?

其一,随机访问。这意味着在常数级时间复杂度内就能找到指定元素。这是因为顺序表中的数据元素是连续存放的,只要知道第一个数据元素的存储地址,后续数据元素的地址就能迅速算出,所以能在常数时间内找到目标元素。在代码实现中,我们使用数组,通过数组下标就能直接定位到目标元素。当然,系统在背后还进行了计算地址等一系列操作。 其二,存储密度高。顺序表的每个存储节点仅存储数据元素本身。而若采用链式存储,除了存储数据元素,还需耗费一定的存储空间来存放指针等信息,这就是存储密度高的含义。 第三个特点是拓展容量不便。静态分配方式完全无法拓展容量,动态分配方式虽可拓展,但因要将数据复制到新区域,时间复杂度较高。

第四个特点是插入和删除操作不便,需移动大量元素。在下一小节,我们会结合具体代码,让大家对此有更直观的感受。

总结:

好了,本小节介绍了顺序表的定义。顺序表是采用顺序存储方式实现的线性表,这种存储结构决定了逻辑上相邻的数据元素在物理上也相邻。

我们还介绍了顺序表的两种实现方式:静态分配和动态分配。静态分配代码简单,定义一个常见的数组即可。动态分配则需用到 malloc 和 free 这两个函数。malloc 函数可申请一整片内存空间,若当前顺序表容量不足,可用它申请更大的存储空间,将数据元素复制到新区域,再用 free 函数释放原内存区域,归还系统。这两个函数在考研中至关重要,大家务必亲自编写代码,熟悉其用法。

最后,我们介绍了顺序表的几个特点,其中随机访问这一特点尤为重要,它能在 O(1) 的时间复杂度内找到指定元素。 这一美好的特性源于数据元素在内存中连续存放。在下一小节,我们将介绍如何实现顺序表的插入和删除这两个基本操作。届时,大家会更直观地体会到插入和删除数据的不便之处。

好啦,以上就是本小节的全部内容。

2.2 顺序表的插入和删除

现在大家已经能够动手用代码定义一个顺序表,也知道该如何对顺序表进行初始化。当一个顺序表刚建立时,它是一个空表,里面没有存储任何数据元素。接下来这部分,我们将学习顺序表的两个基本操作。我们会分别介绍如何用代码实现这些基本操作,并分析代码的时间复杂度。

2.2.1 如何用代码表示顺序表的插入 

首先要学习的是插入这一基本操作。插入操作旨在向线性表L的第二个位置插入指定元素E,这里的第二个位置指的是位序,即从1开始计数。

假设我们用静态分配方式实现了一个顺序表,该顺序表总共能存储10个元素。在某个时刻,这个数据结构中包含了5个数据元素,这些数据元素在内存中依次存放,占据顺序表的前五个位置。由于存放了5个数据元素,此时该顺序表的长度为5。倘若此时要进行插入操作,在线性表的第三个位置插入数据元素C,从逻辑上看,操作完成后,C将成为B的后继节点,B则是C的前驱节点 。

由于我们采用顺序表实现这个线性表,需借助存储位置的相邻关系,来呈现数据元素间的逻辑关系。所以,若要在第三个位置插入元素C,就必须将后面的三个元素依次向后挪动,之后再把C插入第三个位置。

也就是说,若在顺序表的第三个位置插入一个元素,那么第三个位置及之后的数据元素都得向后移位。接下来看看如何用代码实现这一操作。本小节所写代码均基于静态分配的顺序表。

首先,我们定义一个顺序表,该顺序表中存放的数据元素类型为int,即一个个整数。这里通过函数实现插入操作。在main函数里,我们会声明一个顺序表,并对其进行初始化,还会写入一些代码,向顺序表中存入数据。假设此时存入了1、2、4、5、6这几个数据元素,那么此时顺序表的长度为5。

接下来,调用此处的函数实现插入操作。该函数的实际操作是往第三个位置插入数据元素3。如前文所述,首先要将后续数据元素依次向后挪动。因此,这里使用了一个for循环。初始时,变量的值等于顺序表的长度,即5。只要变量值大于等于2(即大于等于插入位置3的前一位),循环就会持续进行。 每一轮循环结束后,j的值会减1。首先,来看第一次执行循环内语句时,j的值是5。此时,代码会将data[4]的数据移至data[5]的位置,即把数据元素往后挪一位。执行完该操作后,j的值减1,变为4。

接下来的循环中,会将data[3]的数据元素移动到data[4]的位置,也就是将数据元素再往后挪一位。第三个循环同理,会将数据元素4往后挪一位。同学可以自行逐步分析这个循环的执行过程以及最终的停止位置。

循环结束后,便可以在第三个位置插入数据元素3。这里大家务必注意,函数参数i表示线性表的位序,从1开始,但实际对应到数组下标时,要将数据元素放在第三个位置,实际上应放在数组下标为2的地方。在做题写代码时,一定要留意这一点。
 

由于新增了一个数据元素,顺序表的长度应加1。至此,我们便实现了插入这一基本操作。通过这个函数,我们能让队友或其他人方便地使用自定义的数据结构。 不过要提醒大家,如果队友在使用你定义的这个函数时,传入的参数出现问题,代码运行就会出错。

比如,队友调用这个函数,想往第9个位置插入数据元素3。代码运行后,数据元素3会插到第9个位置,中间位置空了,这显然不正确。要知道,顺序表中的数据元素必须一个挨着一个存放。由此可见,这段代码不够健壮。

如何避免这个问题呢?很简单,添加一个条件判断语句,判断参数i的值是否合法。i的合法取值范围是从1到length + 1。倘若有人想往顺序表的第9个位置插入元素,由于i的值超出了合法范围,就不应执行后续操作。

再思考一下,除了i的值不合法,当顺序表已满,有人想插入数据,也应拒绝。所以,当别人调用你定义的这个基本操作时,代码里还应检查顺序表是否已满。若已满,就不能再往里面插入数据。 好,现在大家站在使用者的角度思考一下。当你定义的这个基本操作供他人使用时,若他人想要插入某个数据元素,却因某些条件不满足而无法插入,调用你这个函数的人,是不是应该得到代码的一些反馈呢?至少得让人家知道这次插入操作究竟是成功还是失败,对吧?

所以在实际编写代码时,除了要保证代码逻辑正确,还应有这样一种意识:让代码用起来得心应手。那该怎么做呢?我们可以让插入操作返回一个布尔型变量。首先要对变量i的值进行合法性判断,如果i的值小于1或者大于Length + 1,那就表明此次想要插入的位置不合法。在这种情况下,返回false。如此一来,函数调用者收到false这个反馈值时,就明白这次调用失败了,进而可以根据这个反馈检查自己的代码是否存在问题。

另外,如果此时顺序表已经存满,那么这次插入操作同样应该判定为失败,也需要返回false。只有当这两个条件都不成立时,才可以进行接下来的一系列操作:先将后续元素依次往后移动,最后把想要插入的元素放到相应位置。 在插入成功后,给调用者返回一个数值作为反馈。用这样的方式定义插入操作,使用者用起来岂不是很畅快,代码也具备了健壮性?如此编写的代码,无疑是优质代码。

按照这个代码逻辑,要是顺序表已满,却还有人调用该函数试图插入数据元素,这个操作将会被拒绝。设想一下,倘若你正在参与一个大型项目的团队开发,那么如何让自己的代码让他人用得舒心,且不易出错,这种意识从刚开始学习编程时,就应当引起大家的重视。

2.2.2 插入操作的时间复杂度

好了,接下来我们快速分析一下插入这一基本操作的时间复杂度。在绪论中我们学过,分析时间复杂度要重点关注代码中最深层循环的语句。在插入操作里,只有这个for循环,所以我们来看看这个for循环的次数与问题规模N之间的关系。这里的问题规模N指的是顺序表的表长。

我们之前提到过,时间复杂度分为最好、最坏和平均三种情况。那么在什么情况下时间复杂度最低呢?如果我们把数据元素插入到顺序表表尾的位置,其他数据元素无需移位,也就是说for循环的次数为0次。 这种情况肯定是执行最快的,也就是当i等于N + 1时,意味着此次要将新元素插入到顺序表的最后一个位置。在这种情况下,代码会一直往下执行,for循环无需执行,而其他语句都只执行一次,所以这种情况下只需常数时间就能完成该操作。

最快是最好的情况,那与之相对的极端情况就是最慢。如果此时要把新元素插入到表头位置,就需要将原有的n个元素全部往后移动,即for循环要执行n次。所以,最坏时间复杂度的数量级应该是大O(n)

最后来看平均时间复杂度该如何计算。我们假设,新元素插入到任何一个位置的概率相同,也就是说传入的参数i在合法范围内(取值从1到n+ 1)取任何一个数字的概率都相同。总共有n+ 1个位置可供新元素插入,那么新元素插入到任何一个位置的概率都应为1 / (n+ 1)

假设此次要将新元素插入到第一个位置,根据之前的分析,需要将后面的n个元素全部依次往后移动一位,所以当i = 1时,for循环需要执行n次。而当i = 2时,除了第一个元素,后面的n - 1个元素都要依次往后移动一位,此时就需要进行n - 1次循环。 再往后,以此类推,当i = n+ 1,即要将新元素插入到表尾位置时,循环次数为0次。由于i取每个值的概率相同,这意味着循环n次的概率是P,循环n - 1次的概率也是P,依此类推。我们把P这个因子提出来,P的值为n+ 1,而里面从1加到n,这是一个简单的等差数列求和,最终结果是n/2。也就是说,平均来看,循环次数约为n/2次。相应地,平均时间复杂度就是大O(n/2),化简后为大O(n)。这就是最好、最坏和平均这三种情况。

2.2.4 如何用代码表示顺序表的删除操作

接下来,我们看看顺序表的删除操作该如何实现。若要删除一个元素,就需要把该元素后面的元素依次往前移动,同时将next的值减1。代码实现如下:这个删除操作有三个参数,第一个参数指定要删除元素的顺序表,第二个参数指明要删除该顺序表中的第几个数据元素,第三个参数需注意,它是一个引用型参数,用于返回被删除的数据元素。

下面来看具体过程,假设经过之前的一系列操作,已经建立了一个顺序表,里面总共存储了6个数据元素。 好,倘若此时要使用这个基本操作删除一个数据元素,首先得定义一个与顺序表中存储的数据元素同类型的合理变量。我们顺序表中存储的数据元素类型是固定的,所以定义一个变量 E,并为其设置初始值 -1。声明这个变量 E 后,内存中会开辟一小片空间,用于存放与该变量相关的数据。由于设置了初始值,这片区域存储的数据内容就是 -1。

接下来,使用删除这个基本操作,要删除顺序表 L 中的第 3 个元素,并将此次删除的元素用变量 E 返回。在删除操作中,我们会对 i 的合法值进行判断。毕竟此时可被删除的数据元素,必然是已存在的数据元素中的某一个。所以,如果 i 的值落在合理区间之外,就应返回一个 false,给函数使用者一个反馈,告知其删除操作失败。在这个地方,我们用一个 if 语句接收函数的返回值。若返回 false,那就表明此次调用失败。

因为这里要删除的是第 3 个元素,所以 i 的值是合法的。接下来,依据代码,会将此次要删除的数据元素的值,复制到变量 e对应的内存区域中。 好,接下来执行一个for循环,将后面的数据元素依次往前移动一位,最后将 Length 值减一,也就是从右向左移动。由于删除操作成功,会给函数的调用者返回一个处理结果。此时 if 的条件满足,接下来会执行相应语句,以反映这一结果。

在此,大家需要特别留意两个要点。

首先,我们定义的用于删除操作的变量 e,它属于引用型变量,我们添加了引用符号。正因为添加了这个引用符号,在函数里处理的变量 e,与 main 函数中定义的变量 e,在内存中实际上指向同一份数据。

倘若变量 e 并非引用型,即去掉引用符号,那么在 main 函数中,会声明一个仅在 main 函数内有效的局部变量 e,并且调用删除函数。由于参数并非引用类型,函数中所处理的变量 e,实则是 main 函数里变量 e 的一个复制品。尽管这两个变量都名为 e,但在内存中,它们对应的是不同的两份数据。

所以,若未添加引用符号,在函数里将此次删除的数据元素的值赋给变量 e,实际上是赋给了另一个位置。而 main 函数里的变量 e 的值依旧保持为 -1 不变。由此可见,若去掉参数的引用符号,在此处打印的 e 的值应当还是 -1。

因此,在刚开始学习数据结构时,一定要深入理解这些添加了引用符号的参数,明白它们为何要添加引用符号。当然,前面提到的 L 参数,也就是顺序表,其前面同样添加了引用符号,这一点也需关注。 如果参数 L 不添加引用符号,那么 main 函数中定义的顺序表 L 对应着特定的一份数据。若去掉这个符号,在删除函数里处理的 L,实际上是这份数据的复制品。同理,在这份复制品上执行一系列删除相关逻辑操作后,回到 main 函数,其中定义的顺序表数据依旧不会改变。这一点,跨考的同学尤其要注意。

在进行删除操作时,元素依次往前移动,先移动前面的元素,再移动后面的元素,这是循环里的逻辑。而在插入操作中,需要将元素往后移动时,要先移动后面的元素,再移动前面的元素。大家一定要留意这一点。

2.2.5 删除操作的时间复杂度

接下来分析删除操作的时间复杂度。在这类问题中,所有问题规模指的是线性表或顺序表的表长。实际上,删除操作和插入操作极为相似。如果删除的是最后一个元素,其余元素无需移动位置,即循环次数为 0 次。所以,删除最后一个元素属于最好情况,仅需在常数时间内就能运行结束。 那最坏的情况当属删除表头元素了。在此种情形下,需要将后续的n - 1个元素全部依次往前挪动一格。每挪动一个元素,循环次数就会增加一次。所以,当i= 1,即删除第一个元素(也就是表头元素)时,循环次数为n - 1次。由此可见,最坏情况下的时间复杂度为O(n) 。

最后来分析平均情况。同样假设删除任何一个元素的概率相同。i的合法取值范围是1到n,毕竟总共有n个元素,要删除的必然是这n个元素中的某一个,且删除每个元素的概率均为1/n ,我们用P来表示这个概率。

若i= 1,即删除第一个元素,需要循环n - 1次;若删除第二个元素,就需要将第二个元素之后的n - 2个元素依次往前挪动一位,也就是需要循环n - 2次,依此类推。当i= n,即删除最后一个元素时,循环次数为0次。

也就是说,循环次数为n- 1次、n - 2次,一直到1次、0次,它们出现的概率都是P 。将循环次数与每种循环次数出现的概率P相乘并相加,就能得出平均情况下的循环次数为(n - 1)/2 。(n - 1)/2属于O(n)的数量级,所以删除操作的平均时间复杂度同样是O(n)。
 

好啦,在这个小节里,我们学习了如何实现顺序表的插入与删除这两个基础操作。因为顺序表规定,逻辑上相邻的元素在物理位置上也必须相邻。所以,要是想在某个位置插入一个新元素,那么该位置之后的所有元素都得依次向后挪动;同理,若要删除某个元素,其后面的数据元素就得依次向前移动。大家编写代码时,可千万别忘记修改n的值哦,n代表的是顺序表当前存放的数据元素个数。

在做题或考试时,一定要仔细审题。有些题目可能会明确告知要删除第二个数据元素,而有些题目则可能给出要删除数组下标为i的数据元素。这里要特别注意,顺序表元素序号是从1开始的,而数组下标是从0开始的,可别在这种细节上丢分。

另外,编写代码时要有条理性,需要对一些必要条件进行判断。无论是学习后续的算法,还是在未来实习工作中,都要时刻留意这个问题。代码不仅要足够健壮,还要方便他人使用。跨考的同学在编写移动数据元素的循环代码时,可能会遇到问题。建议平时写代码较少,甚至从未写过代码的同学,在稿纸上亲自实现一下插入和删除操作。最后,大家一定要深入理解,为什么某些参数是这样设置的。 需要添加引用,可为什么有的代码没有添加呢?像代码健壮性以及参数添加引用这类问题,在后续讲解中提及的频率会越来越低。所以同学们,一定要在刚开始接触这些简单代码时,就反复琢磨这些关键问题,将其梳理清晰并真正吸收内化。好了,以上就是本小节的全部内容。 

2.3 顺序表的查找

表的查找操作该如何实现呢?查找分为两种类型,一种是按位查找,另一种是按值查找。我们将分别介绍如何用代码实现这两种查找,并分析代码的时间复杂度。

2.3.1 按位查找

2.3.1.1 代码实现
2.3.1.1.1 采用静态分配方式实现顺序表

首先,来看看按位查找的实现方法。对线性表进行按位查找,就是要从线性表L中获取第i个元素。若线性表采用顺序表的方式实现,且使用静态分配,那么所有的数据元素都存放在data数组中。在这种情况下,获取第i个数据元素十分简单。需要注意的是,由于位序从1开始,而数组下标从0开始,所以第i个元素对应的数组下标应该是i - 1,该元素的返回值与数据元素的类型相同。当然,为了增强代码的健壮性,还可以在此处判断i的值是否合法,这操作并不复杂,就不详细展开了。 

2.3.1.1.2 采用动态分配方式实现顺序表

接下来,若采用动态分配方式实现顺序表,data变量实际上是一个指针,它指向顺序表的第一个数据元素。存储该顺序表所需的内存空间,是通过malloc函数申请的一整片连续空间。尽管data是指针,但同样可以使用数组下标的方式访问相应元素。 这可能是跨考的同学不太清楚的要点。下面我们来剖析一下计算机在背后进行了哪些操作。这里的 data 变量实际上是一个指针,它指向 malloc 函数分配的一整片连续内存空间的起始地址。

假设现在这个指针指向的地址是 2000,在示意图中,我们假定一小格代表一个字节(即 1B)的大小。如果一个数据元素 I 需要占用 6 个字节,那么使用 L.data[0] 这种方式获取的值,实际上是从 data 指针所指向的地址往后 6 个字节的内容。在代码里返回 data[0],就是返回这 6 个字节的内容,而这 6 个字节对应的内容恰好是第一个数据元素。

同理,如果代码里写的是 data[1],那么 data[1] 对应的数据应该是从地址 2006 开始往后的 6 个字节,也就是第二个数据元素。依此类推,后面的情况也是如此,就不再一一列举。

在这里,我们定义的 data 指针所指向的数据类型是 type 类型。所以,当你以数组下标的方式编写代码时,计算机背后会依据这个指针所指向的数据类型占用的空间大小,计算每个数组下标对应的是哪几个字节的数据。

好,若我们再定义一个指针,它指向的地址同样是 2000。不过,我们将这个指针的类型规定为指向 int 型,而一个 int 型变量占用 4 个字节。

当你采用指针加上数组下标的方式来获取数据时,P0 对应的数据是从地址 2000 开始往后的 4 个字节,这 4 个字节的内容即为 P0;往后的 4 个字节是 P1;再往后的 4 个字节是 P2,依此类推。

这是跨考同学需要理解的一个要点:使用某一类型的指针加上数组下标的方式访问数据时,系统在背后每次取数据的字节数与该指针所指向的类型有关。

这也解释了为何在之前的课程中强调,若使用 malloc 函数申请一片连续的内存空间,需要将 malloc 函数返回的指针强制转换为与数据类型相对应的同类型指针。因为尽管指针指向的是同一个地址,但如果指针所指向的数据类型定义错误,在访问数据元素时就会出现问题。

2.3.1.2 时间复杂度分析

既然顺序表按位查找操作仅需一个 return 语句,既无循环也无递归调用,那么按位查找操作的时间复杂度应为 O(1)。 这便是我们之前提及的顺序表随机存取的特性。顺序表能够实现随机存取,是因为其所有数据元素在内存中连续存放,且数据类型相同,即每个数据元素所占内存空间大小一致。所以,只要知道顺序表的起始地址和每个数据元素的大小,就能立刻找到第二个元素的存放位置。

以上是按位查找,接下来看看按值查找。

2.3.2 按值查找

2.3.2.1 代码实现

按值查找操作,就是要在这个线性表 L 中,找出是否存在与传入参数 E 相等的数据元素。若能找到,就返回该数据元素的存放位置。

这个基本操作的实现并不复杂。我们传入参数 E,然后执行一个 for 循环,从顺序表的首个元素开始依次往后检索,逐一判断顺序表中的各个数据元素是否与传入的数据元素 E 相等。若相等,则返回该数据元素的位序。由于这里返回的是位序变量 i(即数组下标),所以返回时需用数组下标加 1。

下面看一个实际例子。

我们定义了一个顺序表,其数据元素的数据类型为 int 类型,且该顺序表已完成初始化,并插入了 6 个整型变量。 由于这个顺序表存放的是 int 型变量,对比两个 int 型变量,使用特定运算符进行比较即可。除了 int 型变量,char、double、float 等基本数据类型,也都能直接用判断相等的运算符进行比较。

现在假设有人调用了这个函数,想查找线性表 L 中是否存在等于 9 的数据元素。首先会执行 for 循环,初始时 i 等于 0,且 L 的长度为 6。第一个数据元素的值与 9 不相等,即 if 条件不满足,于是执行 i++ 操作,i 的值从 0 变为 1,接着进入第二轮循环。第二轮循环扫描的数据元素依然与 9 不相等,i 的值变为 2,随后进入第三轮循环。在第三轮循环中,找到了与参数相等的数据元素,此时会返回该数据元素的位序,也就是 3。

2.3.2.1.1 如何判断两个结构体(即 structure 数据类型)是否相等

接下来思考一个问题:如果顺序表中存放的数据元素类型是更复杂的结构类型,能否用“==”运算符来比较两个结构类型呢?答案是否定的。这里定义了一个名为 customer 的结构类型,还编写了一个简单的函数,声明了 A 和 B 两个 customer 类型的变量,并特意将这两个 customer 变量里的字段值都设为 1。 接着,我们写一个 if 语句,尝试用“==”运算符判断 A 和 B 这两个结构类型的数据是否相等。然而,IDE 会提示我们,该运算符不能用于比较两个 customer 类型的变量。这意味着,若用此运算符比较两个结构类型的变量,代码连编译都无法通过,更别提运行了。

所以,若要对比两个结构体,必须自行编写代码,分别对比结构体里的各个分量是否相等。若所有分量都相等,便可认为这两个结构体相等。当然,也能实现一个基本操作,用于判断所定义的两个结构体是否相等。

总之,在 C 语言里,不能直接用“==”运算符判断两个结构类型是否相等。而在 C++ 中,可以尝试重载该运算符。考虑到部分同学可能不太熟悉,这里就不展开讲解了。

给大家的建议是,在考研初试中,如果报考学校的考试科目为“数据结构”,那么在判断两个数据元素是否相等时,可直接使用“==”运算符,无论数据元素是基本数据类型还是结构类型。因为数据结构这门课更注重考察大家对数据结构及其相关算法的理解,不会过分严格要求代码是否严格遵循某一编程语言的规则。 但如果你报考的学校,考试科目包含 C 语言程序设计,那么该校会比较在意你的 C 语言语法是否严谨。当然,具体情况需具体分析,大家最好查看一下相关历年真题,看题目是否要求 C 语言语法足够标准,这里给大家提个小醒。

2.3.2.1 时间复杂度分析

接下来,我们分析一下按值查找操作的时间复杂度。计算时间复杂度时,我们需关注最深层循环语句的执行次数,即循环次数与问题规模 N 的关系。这里的问题规模 N 指的是线性表的表长。时间复杂度分为最好、最坏和平均三种情况。

最好的情况是,若要查找的值恰好与表头元素的值相同,那么循环只需执行一次,所以最好时间复杂度为 O(1),属于常数阶。由于我们是从头到尾逐个检索数据元素,若要查找的值是最后一个数据元素,就需要循环 n 次,把 n 个数据元素全部扫描一遍才能找到目标,因此最坏时间复杂度为 O(n)。

对于平均时间复杂度,我们先假设要查找的目标元素出现在每个位置的概率相同。因为总共有 N 个元素,所以它出现在任何一个位置的概率都是 1/n。 若目标元素位于第一位,循环只需执行一次;若在第二位,循环两次,依此类推;若在第 n 位,则需循环 n 次。所以,平均所需的循环次数为每次循环次数乘以该情况发生的概率后相加,最终结果为 (n + 1) / 2,因此平均时间复杂度为 O(n)。

本小节内容较为简单,我们学习了按位查找和按值查找。由于顺序表中的元素是连续存放的,若要查找顺序表的第 i 个元素,只需 O(1) 的时间就能立即找到,这表明顺序表具备随机存取的特性。

若进行按值查找,则需从第一个元素开始依次向后检索。若顺序表中的数据元素是按某种顺序(如从大到小或从小到大)排列的,对于这种有序顺序表的查找会有许多更高效的算法,我们将在后续的查找章节中学习这些高效算法。但如果顺序表中的数据元素存放毫无规律,就只能从第一个元素开始逐个往后查找,平均而言,找到目标元素的时间复杂度为 O(N)。

再次提醒跨考的同学,要注意位序和数组下标的关系,同时也要留意如何判断两个结构体(即 structure 数据类型)是否相等。 好啦,以上便是这一小节的全部内容啦。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值