线性表的定义和操作
定义
线性表是具有相同数据类型的$n (n \leq 0 )$ 个元素的有限序列,其中$n$为表长,当 $n = 0$时线性表为一个空表
若用$L$来命名线性表,则其一般表示为 $L=(a_1,a_2,\cdots,a_i,a_(i+1),\cdots,a_n)$ 式中,$ a_1 $是唯一的“第一个”数据元素,即表头元素;$a_n$是唯一的“最后一个”数据元素,即表尾元素
线性表的逻辑特性:每个元素有且仅有一个直接前驱。除去最后一个元素外,每个元素有且仅有一个直接后继,这种逻辑特性即为线性表的名称由来
线性表的特点:
- 表中元素个数有限
- 表中元素具有逻辑上的顺序性,表中元素有其先后次序
- 表中元素都是数据元素,每个元素都是单个元素
- 表中元素的数据类型都相同,意味着每个元素占有的存储空间相同
- 表中元素具有抽象性,仅讨论元素间的逻辑关系,而忽视元素的实际内容
基本操作
- InitList(&L):初始化表。构造一个空的线性表
- Length(L):求表长。返回线性表L的长度,即L中数据元素的个数
- LocateElem(L,e):按值查找操作。在表L中查找具有给定关键字值的元素
- GetElem(L,i):按位查找操作。获取表L中第i个位置的元素的值
- ListInsert(&L,i,&e):插入操作。在表L中第i个位置插入指定元素e
- ListDelete(&L,i,&e):删除操作。删除表L中第i个位置的元素,并用e返回删除元素的值
- PrintList(L):输出操作。按前后顺序输出线性表L的所有元素值
- Empty(L):判空操作。若L为空表,则返回true
- DestroyList:销毁操作。销毁线性表,并释放线性表L所占用的内存空间
线性表的顺序表示(顺序表)
定义
顺序表,即线性表的顺序存储。是用一组地址连续的存储单元依次存储线性表中的数据元素,从而使得逻辑上相邻的两个元素在物理位置上也相邻。
第一个元素存储在线性表的起始位置,第i个元素的存储位置后面紧接着存储的是$i + 1$个元素,称$i$为元素$a_i$在线性表中的位序,因此可见,顺序表的特点是表中元素的逻辑顺序与其物理顺序相同
假设线性表L存储的起始位置为LOC(A),sizeof(ElemType)为每个数据元素所占用存储空间的大小,可得该表对应的顺序存储如下
数组下标 | 顺序表 | 内存地址 |
---|---|---|
0 | $a_1$ | LOC(A) |
1 | $a_2$ | LOC(A) + sizeof(ElemType) |
i-1 | $a_i$ | LOC(A) + (i-1)$\times$sizeof(ElemType) |
n-1 | $a_n$ | LOC(A) + (n-1)$\times$sizeof(ElemType) |
MaxSize - 1 | $\cdots$ | LOC(A) + (MaxSize - 1)$\times$sizeof(ElemType) |
每个数据元素的存储位置和线性表的起始位置相差一个和该数据元素的位序成正比的常数,因此,线性表中的任一数据元素都可以随机存取,所以线性表的顺序存储结构是一种随机存取的存储结构
- Attention : 线性表中元素的位序从1开始,而数组中元素下标是从0开始的
假定线性表的元素类型为ElemType,则线性表的顺序存储类型可描述为:
|
|
一组数组可以是静态分配的,也可以是动态分配的
- 静态分配时,由于数组的大小和空间已经固定,一旦空间占满,再加入新的数据就会产生溢出,进而导致程序崩溃
- 动态分配时,存储数组的空间是在程序执行过程中通过动态存储分配语句分配的,一旦数据空间占满就另外开辟一块更大的存储空间用以替换原来的存储空间,从而扩充存储数组空间的目的,不需要为线性表一次性划分所有空间
动态分配的顺序存储类型定义:
|
|
C的初始动态分配语句为:
|
|
C++的初始动态分配语句为:
|
|
- Attention : 动态分配并非链式存储,同样属于顺序存储结构,物理结构无变化,依然为随机存储方式,只是分配的空间大小可以在运行时动态决定
- 顺序表主要特点是随机访问,即通过首地址和元素序号可以在时间$O(1)$内找到指定元素
- 顺序表存储密度相对更高,结点只存储数据元素
- 顺序表逻辑上相邻的元素物理上也相邻,所以在插入和删除时需要移动大量元素
顺序表基本操作的实现
插入
在顺序表L的第$i(1<=i<=L.length+1)$个位置插入新元素e。若i的输入不合法,则返回false,表示插入失败;否则,将第i个元素及其以后的所有元素依次往后移动一个位置,腾出一个位置插入新元素e,顺序表长度+1,插入成功,返回true
|
|
- 最好情况:在表尾插入(即$i=n+1$),元素后移语句不执行,时间复杂度为$O(1)$
- 最坏情况:在表头插入(即$i=1$),元素后移语句将执行n次,时间复杂度为$O(n)$
- 平均情况:假设$p_i(p_i=1/(n+1))$是在第$i$个位置上插入一个结点的概率,则在长度为$n$的线性表中插入一个结点时,所需移动结点的平均次数为
$$ \sum_{i=1}^{n+1} p_i(n-i+1) = \sum_{i=1}^{n+1} \frac{1}{n+1}(n-i+1)=\frac{1}{n+1}\sum_{i=1}^{n+1}(n-i+1)=\frac{1}{n+1}\frac{n(n+1)}{2}=\frac{n}{2} $$
由此可得,线性表插入算法的平均时间复杂度为$O(n)$
删除
删除顺序表L中第$i$个$(1<=i<=L.length)$个位置的元素,用引用变量e返回。若i的输入不合法,则返回false;否则,将被删元素赋给引用变量e,并将第$i+1$个元素及其后的所有元素依次往前移动一个位置,返回true。
|
|
- 最好情况:删除表尾元素(即$i=n$),无须移动元素,时间复杂度为$O(1)$
- 最坏情况:删除表头元素(即$i=1$),需移动除表头元素外的所有元素,时间复杂度为$O(n)$
- 平均情况:假设$p_i(p_i = 1/n)$是删除第i个位置上结点的概率,则在长度为n的线性表中删除一个结点时,所需移动结点的平均次数为
$$ \sum_{i=1}^{n} p_i(n-i)=\sum_{i=1}^{n} \frac{1}{n}(n-i) = \frac{1}{n}\sum_{i=1}^{n}(n-i) = \frac{1}{n}\frac{n(n-1)}{2} = \frac{n-1}{2} $$
可见线性表删除算法的平均时间复杂度为$O(n)$且顺序表中插入和删除操作的时间主要耗费在移动元素上,而移动元素的个数取决于插入和删除元素的位置。
按值查找(顺序查找)
在顺序表L中查找第一个元素值等于e的元素,并返回其位序
|
|
- 最好情况:查找的元素就在表头,仅需比较一次,时间复杂度为$O(1)$
- 最坏情况:查找的元素在表尾或不存在时,需要比较n次,时间复杂度为$O(n)$
- 平均情况:假设$p_i(p_i=1/n)$是查找的元素在第$i(l<=i<=L.length)$个位置上的概率,则在长度为n的线性表中查找值为e的元素所需比较的平均次数为
$$ \sum_{i=1}^{n}p_i \times i = \sum_{i=1}^{n} \frac{1}{n} \times i = \frac{1}{n} \frac{n(n+1)}{2} = \frac{n+1}{2} $$
因此线性表按值查找算法的平均时间复杂度为$O(n)$
一些练习
- 从顺序表中删除具有最小值的元素(假设唯一)并由函数返回被删元素的值。空出的位置由最后一个元素填补,若顺序表为空,则显示出错信息并退出运行
算法思想:搜索整个顺序表,查找最小值元素并记住其位置,搜索结束后用最后一个元素填补空出的原最小值元素的位置
|
|
- 设计一个高效算法,将顺序表L的所有元素逆置,要求算法的空间复杂度为$O(1)$
算法思想:扫描顺序表L的前半部分元素,对于元素L.datai,将其与后半部分的对应元素L.data[L.length-i-1]进行交换
|
|
- 对长度为n的顺序表L,编写一个时间复杂度为$O(n)$、空间复杂度为$O(1)$的算法,该算法删除线性表中所有值为x的数据元素
解法一:用k记录顺序表L中不等于x的元素个数(即需要保存的元素个数),边扫描L边统计k,并将不等于x的元素向前移动k个位置,最后修改L的长度
|
|
解法二:用k记录顺序表L中等于x的元素个数,边扫描L边统计k,并将不等于x的元素前移k个位置,最后修改L的长度
|
|
- 从有序顺序表中删除其值在给定值s与t之间(包含s和t,要求s<t)的所有元素,若s或t不合理或顺序表为空,则显示出错信息退出运行
算法思想:因为是有序表,所以删除的部分必定是一个整体,先寻找值大于等于s的一个元素(第一个删除的元素),然后寻找值大于t的第一个元素(最后一个删除的元素的下一个元素),要将这段元素删除,只需将后面的元素前移
|
|
- 从顺序表中删除其值在给定值s与t之间(包含s和t,s<t)的所有元素,若s或t不合理或顺序表为空,则显示出错信息并退出运行
算法思想:从前向后扫描顺序表L,用k记录下元素值在s到t之间元素的个数(初始时k=0)。对于当前扫描的元素,若起值不在s到t之间,则前移k个位置,否则执行k++。由于这样每个不在s到t之间的元素仅移动一次,因此算法的效率高
|
|
- 从有序顺序表中删除所有其值重复的元素,使表中所有元素的值均不同
算法思想:有序顺序表,因此值相同的元素一定在连续的位置上,用类似于直接插入排序的思想,初始时将第一个元素视为非重复的有序表,之后依次判断后面的元素是否与前面非重复有序表的最后一个元素相同,若相同,则继续向后判断,若不同,则插入前面的非重复有序表的最后,知道判断到表为为止
|
|
- 将两个有序顺序表合并为一个新的有序顺序表,并由函数返回结果顺序表
算法思想:首先,按顺序不断取下两个顺序表表头较小的结点存入新的顺序表中,然后,看哪个表还有剩余,将剩下的部分加到新的顺序表后面
|
|
- 已知在一位数组A[m+n]中一次存放两个线性表$(a_1,a_2,a_3,\cdots,a_m)$和$(b_1,b_2,b_3,\cdots,b_n)$。试编写一个函数,将数组中两个顺序表的位置互换,即将$(a_1,a_2,a_3,\cdots,\a_m$放在$(b_1,b_2,b_3,\cdots,b_n)$后面
算法思想:先将数组A[m+n]中的全部元素$(a_1,a_2,a_3,\cdots,a_m,b_1,b_2,b_3,\cdots,b_n)$原地逆置为$(b_n,b_{n-1},b_{n-2},\cdots,b_1,a_m,a_{m-1},a_{m-2},\cdots,a_1)$,再对前n个元素和后m个元素分别使用逆置算法,即可得到$(b_1,b_2,b_3,\cdots,b_n,a_1,a_2,a_3,\cdots,a_m)$,从而实现顺序表的位置互换
|
|
- 线性表$(a_1,a_2,a_3,\cdots,a_n)$中的元素递增有序且安顺序存储于计算机内,要求设计一个算法,完成用最少时间在表中查找数值为x的元素,若找到,则将其与后继元素位置相交换,若找不到,则将其插入表中并使表中元素仍递增有序
算法思想:顺序存储的线性表递增有序,可以顺序查找,也可以折半查找,题目要求"用最少的时间在表中查找数值为x的元素",这里应使用折半查找法
|
|
- 设将$n(n>1)$个整数存放到一维数组R中,设计一个在时间和空间两方面都尽可能高效的算法,将R中保存的序列循环左移$p(0<p<n)$个位置,即将R中的数据由$(X_0,X_1,X_2,\cdots,X_{n-1})$变换为$(X_p,X_{p+1},\cdots,X_{n-1},X_0,X_1,\cdots,X_{p-1})$。要求:
1)给出算法设计思想
算法的基本设计思想:可将这个问题视为吧数组ab转换成数组ba(a代表数组的前p个元素,b代表数组中余下的n-p个元素),先将a逆置得到$a^{-1}b$,再将b逆置得到$a^{-1}b^{-1}$,最后将整个$a^{-1}b^{-1}$逆置得到$(a^{-1}b^{-1})^{-1}=ba$。设Reverse函数执行将数组元素逆置的操作,对abcdefgh向左循环移动3个位置的过程如下:
Reverse(0,p-1);得到cbadefgh
Reverse(p,p-1);得到cbahgfed;
Reverse(0,p-1);得到defghabc;
2)采用C或C++或java语言描述算法,关键之处给出注释
|
|
3)说明算法时间空间复杂度
上述三个Reverse函数的时间复杂度分别为$O(p/2)、O((n-p)/2)$和$O(n/2)$故所设计的算法的时间复杂度为$O(n)$,空间复杂度为$O(1)$
- 一个长度为$L(L \leq 1)$的升序序列S,处在第[L/2]个位置的数称为S的中位数。例如序列$S_1$=(11,13,15,17,19),则$S_1$的中位数是15,两个序列的中位数是含它们所有元素的升序序列的中位数。例如$S_2$=(2,4,6,8,20),则$S_1和S_2$的中位数是11.现在有两个等长升序序列A和B,试设计一个在时间和空间都尽可能高效的算法找出两个序列A和B的中位数。要求:
1)给出算法设计思想
算法的基本设计思想:
分别求两个升序序列A,B的中位数,设为a和b,求序列A,B的中位数过程如下:
- 若a=b,则a或b即为所求中位数,算法结束
- 若a<b,则舍弃序列A中较小的一半,同时舍弃序列B中较大的一半,要求两次舍弃的长度相等
- 若a>b,则舍弃序列A中较大的一半,同时舍弃序列B中较小的一半,要求两次舍弃的长度相等。
在保留的两个升序序列中,重复过程1,2,3,直到两个序列中均只含有一个元素时为止,较小者即为所求的中位数
2)采用C或C++或java语言描述算法,关键之处给出注释
|
|
3)说明算法时间空间复杂度
算法的时间复杂度为$O(\log_2 n)$,空间复杂度为$O(1)$
- 已知一个整数序列$A=(a_0,a_1,\cdots,a_{n-1})$,其中$0 \leq a_i < n(0 \leq i < n)$。若存在$a_{p1}=a_{p2}=\cdots=a_{pm}=x$且$m>n/2(0\leq p_k < n,1 \leq k \leq m)$,则称x为A的主元素,例如A=(0,5,5,3,5,7,5,5),则5为主元素;又如A=(0,5,5,3,5,1,5,7),则A中没有主元素。假设A中的n个元素保存在一个一维数组中,请设计一个尽可能高效的算法,找出A的主元素,若存在主元素,则输出该元素,否则输出-1,要求
1)给出算法设计思想
算法的基本设计思想:从前往后扫描数组元素,标记出一个可能成为主元素的元素num,然后重新计数,确认num是否是主元素
- 选取候选的主元素,依次扫描所给数组中的每个整数,将第一个遇到的整数Num保存到c中,记录num的出现次数为1;若遇到的下一个整数仍然等于num,则计数加一,否则计数减一;当计数减到0时,将遇到的下一个整数保存到c中,计数重新记为一,开始新一轮计数,即从当前位置开始重复上述过程,直到扫描完全部数组元素
- 判断c中元素是否是真正的主元素。再次扫描该数组,统计c中元素出现的次数,若等于n/2,则为主元素;否则,序列中不存在主元素
2)采用C或C++或java语言描述算法,关键之处给出注释
|
|
3)说明算法时间空间复杂度
时间复杂度为$O(n)$,空间复杂度为$O(1)$
- 给定一个含$n(n \geq 1)$个整数的数组,请设计一个在时间上尽可能高效的算法,找出数组中未出现的最小正整数,例如,数组{-5,3,2,3}中未出现的最小正整数是1;数组{1,2,3}中未出现的最小正整数是4,要求:
1)给出算法设计思想
思路:采用空间换时间的方法,分配一个用于标记的数组B[n],用于记录A中是否出现了1~n中的正整数,B[0]对应正整数1,B[n-1]对应正整数n,初始化B中全部为0,由于A中含有n个整数,因此可能的返回值是1~n+1,当A中n个数恰好为1~n时返回n+1。当数组A中出现了小于等于0或大于n的值时,会导致1~n中出现空余位置,返回结果必然在1~n中,因此对于A中出现了小于等于0或大于n的值,可以不采取任何操作
算法流程:从A[0]开始遍历A,若0<A[i]<=n,则令B[A[i]-1]=1;否则不做操作。对A遍历结束后,开始遍历数组B,若能查找到第一个满足B[i]==0的下标i,返回i+1即为结果,此时说明A中未出现的最小正整数在1~n之间。若B[i]全部不为0,返回i+1(跳出循环时i=n,i+1等于n+1),此时说明A中未出现的最小正整数是n+1
2)采用C或C++或java语言描述算法,关键之处给出注释
|
|
3)说明算法时间空间复杂度
时间复杂度:A,B各遍历一次,两次循环内操作步骤为$O(1)$量级,因此时间复杂度为$O(n)$
- 定义三元组(a,b,c)(a、b、c均为正数)的距离D=|a-b| + |b-c|+|c-a|。给定3个非空整数集合$S_1、S_2、S_3$,按升序分别存储在3个数组中。请设计一个尽可能高效的算法,计算并输出所有可能的三元组$(a,b,c)(a \in S_1,b \in S_2,c \in S_3)$中的最小距离。例如$S_1={-1,0,9},S_2={-25,-10,10,11},S_3={2,9,17,30,41},则最小距离为2,相应的三元组为(9,10,9)。要求:
1)给出算法设计思想
- 思路:由$D=|a-b|+|b-c|+|c-a| \geq 0$可得:
- a=b=c时,距离最小
- 假设$a \leq b \leq c$可得$L_1=|a-b| L_2=|b-c| L_3=|c-a| D=|a-b|+|b-c|+|c-a|=L_1+L_2+L_3=2L_3$
由此可得a和c的距离决定D的大小,问题简化为为每次固定的c找一个a,使得$L_3=|c-a|最小$
- 算法的设计思想:
- 使用$D_{min}$记录所有已处理的三元组的最小距离,初值为一个足够大的整数。
- 集合$S_1,S_2,S_3$分别保存在数组A,B,C中。数组的下标变量i=j=k=0,当$i<|S_1|,j<|S_2|,k<|S_3|$时,循环执行下面的步骤
- 计算A[i],B[j],C[k]的距离D
- 若$D<D_{min} $,则$D_{min} = D$
- 将A[i],B[j],C[k]中的最小值的下标+1(最小值为a,最大值为c,此处c不变更新a,试图寻找更小的距离D)
- 输出$D_{min}$,结束
2)采用C或C++或java语言描述算法,关键之处给出注释
|
|
3)说明算法时间空间复杂度
设$n=(|S_1|+|S_2|+|S_3|)$,可得时间复杂度为$O(n)$空间复杂度为$O(1)$
线性表的链式表示
单链表定义
线性表的链式存储称为单链表,通过一组任意的存储单元来存储线性表中的数据元素,对于每个链表结点,除存放元素自身元素以外,还需要存放一个指向其后继的指针
data | next |
---|
如上,data为数据域,存放数据元素,next为指针域,存放后继结点的地址
对结点类型的定义如下:
|
|
单链表可解决顺序表需要大量连续存储单元的问题,但其本身附加指针域,会导致浪费存储空间,且单链表的元素离散分布在存储空间中(非随机存取结构),查找某个特定结点时,需要从表头开始遍历
头指针通常用于标识一个单链表,如单链表L,头指针为NULL时表示为一个空表,出于便于操作的目的,也会在单链表的第一个结点之前附加一个结点,称为头结点。头结点的数据域可以不设任何信息,也可以记录表长等信息。头结点的指针指向线性表的第一个元素结点
头结点和头指针的区别:
- 头指针始终指向链表的第一个结点
- 头结点是带头结点的链表中的第一个结点,结点内通常不存储信息
头结点引入的优势:
- 由于第一个元素结点的位置被放在头结点的指针域中,因而在链表第一个位置上的操作与其他位置保持一致
- 无论链表是否为空,头指针都指向头结点的非空指针(空表中头结点的指针域为空),统一了对空表和非空表的处理
单链表的基本操作
采用头插法建立单链表
该方法从一个空表开始,生成新结点,并把读取到的数据存放到新结点的数据域中,然后将新结点插入到头结点之后
算法如下:
|
|
读入数据的顺序与生成的链表中的元素的顺序是相反的,每个结点插入的时间为$O(1)$,设单链表长度为n,则总时间复杂度为$O(n)$
采用尾插法建立单链表
该方法将新结点插入到当前链表的表尾,为此必须增加一个尾指针r,使其始终指向当前链表的尾结点
算法:
|
|
读入数据的顺序与生成的链表中的元素的顺序一致,附设了一个指向表尾结点的指针,故时间复杂度和头插法的相同,都为$O(n)$
按序号查找结点值
在单链表中从第一个结点出发,顺指针next域逐个往下搜索,直到找到第i个结点为止,否则返回最后一个结点指针域NULL
算法:
|
|
时间复杂度为$O(n)$
按值查找表结点
从单链表的第一个结点开始,由前往后依次比较表中各结点数据域的值,若某结点数据域的值等于给定值e,则返回该结点的指针;若整个单链表中没有这样的结点,则返回NULL
算法:
|
|
插入结点操作
插入结点操作将值为x的新结点插入到单链表的第i个位置上。先检查插入位置的合法性,然后找到待插入位置的前驱结点,即第i-1个结点,再在其后插入新结点
算法首先调用按序号查找算法GetElem(L,i-1),查找第i-1个结点。假设返回的第i-1个结点为* p,然后令新结点* s的指针域指向* p的后继结点,再令结点* p的指针域指向新插入的结点* s
实现插入结点的代码片段:
|
|
上述片段中,指针操作顺序不能颠倒,否则,先执行p->next=s后,指向其原后继的指针就不存在,再执行s->next=p->next时,相当于执行了s->next=s,显然是错误的
主要的时间开销在于查找第i-1个元素,时间复杂度为$O(n)$.若在给定的节点后面插入新结点,则时间复杂度为$O(1)$
对某一结点进行前插操作
前插通常为在某结点的前面插入一个新结点,后插则相反,且单链表插入算法中更常用后插操作
上述算法中,找到插入结点的前驱结点后再执行后插操作即可将前插操作转换为后插操作,前提是从单链表头结点开始顺序查找到其前驱结点,时间复杂度为$O(n)$
也可设待插入结点为*S, 将 *S插入到到 *P的前面,此时仍然可以将 *S插入到 *P后,将p->data与s->data交换,此时的时间复杂度为$O(1)$
|
|
删除结点操作
删除结点操作是江单链表的第i个结点删除。需要先检查删除位置的合法性,后查找表中第i-1个结点,即被删结点的前驱结点,再将其删除
假设*p为找到的被删结点的前驱结点,仅需修改 *p的指针域,即将 *p的指针域next指向 *q的下一结点
|
|
求表长操作
即计算单链表中数据结点的个数,需从第一个结点开始顺序依次访问表中的每个结点,设置一个计算器变量,每访问一次结点则加一,直到访问空结点,算法复杂度为$O(n)$
单链表长度往往不包括头结点,对于不带头结点和带头结点的链表在求表长时操作存在不同。对于前者,当表空时需要单独处理
双链表
双链表在单链表的结点中增加了一个指向前驱的prior指针,使得其无需像单链表那样只能从头开始依次顺序地向后遍历
结点类型描述:
|
|
双链表的插入操作
在双链表中p所指的结点之后插入结点*s
|
|
上述代码顺序并非唯一,但也不是任意的,第一二步需保证在第四步之前,当值丢失*p的后继结点的指针
双链表的删除操作
删除双链表中结点*p的后继结点 *q
|
|
循环链表
循环单链表
循环单链表与单链表的区别在表中最后一个结点的指针不是NULL,而是指向头结点,从而整个链表形成一个环
循环单链表中,表尾结点*r的next域指向L,故表中没有指针域为NULL的结点,因此,循环单链表的判空条件为头结点的指针是否等于头指针
循环单链表中插入,删除与单链表一致,不同在于表尾操作时需要让单链表继续保持循环的性质。当然由于循环单链表往往认为是一个环,任何一个位置上的插入和删除操作都是等价,无需判断是否为表尾
相比于单链表,循环单链表能够从表中任意一个结点开始遍历整个链表,有时对单链表常做的操作是在表头和表尾进行,此时对循环单链表不设头指针,仅设尾指针能够有更高的效率,原因在于,若设的是头指针,对表尾进行操作需要$O(n)$的时间复杂度,若设的是尾指针r,r->即为头指针,对表头与表尾进行操作都只要$O(1)$的时间复杂度
循环双链表
在循环双链表中,头结点的prior指针还要指向表尾结点
例如在循环双链表L中,某结点*p为尾结点时,p->next==L;当循环双链表为空表时,其头结点的prior域和next域都等于L
静态链表
静态链表借助数组来描述线性表的链式存储结构,结点也有数据域data和指针域next,与先前的链表中的指针不同在于此处的指针是结点的相对地址(数组下标),又称游标。和顺序表一致,静态链表也需要分配一块连续的内存空间
结构类型描述:
|
|
静态链表以next == -1作为结束的标志。总体而言,静态链表不如单链表使用起来方便,但在一些不支持指针的高级语言中,其为一种巧妙的设计方法
顺序表和链表的比较
存取(读写)方式
顺序表可以顺序存取,也可以随机存取,链表只能从表头顺序存取元素。例如在第i个位置上执行存或取得操作,顺序表仅需一次访问,而链表则需从表头开始依次访问i次
逻辑结构与物理结构
采用顺序存储是,逻辑上相邻的元素,对应的物理存储位置也相邻。采用链式存储时,逻辑上相邻的元素,物理存储位置不一定相邻,对应的逻辑关系是通过指针链接来表示的
查找、插入和删除操作
对于按值查找,顺序表无序时,两者的时间复杂度均为$O(n)$;顺序表有序时,可采用折半查找,此时的时间复杂度为$O(\log_2 n)$
对于按序号查找,顺序表支持随机访问,时间复杂度仅为$O(1)$,而链表的平均时间复杂度为$O(n)$。顺序表的插入、删除操作,平均需要移动半个表长的元素。链表的插入、删除操作,只需修改相关结点的指针域即可。由于链表的每个结点都带有指针域,故而存储密度不够大
空间分配
顺序存储在静态存储分配情形下,一旦存储空间装满就不能扩充,若再加入新元素,则会出现内存溢出,因此需要预先分配足够大的存储空间。预先分配过大,可能会导致顺序表后不大量闲置;预先分配过小则易发生溢出
动态分配情形下,虽然可以扩充存储空间,但需要移动大量元素,导致操作效率降低,若内存中没有更大块的连续存储空间,则会分配失败,链式存储的存储空间则只在需要时申请,只要空间足够就能够申请,操作灵活高效
存储结构的选取考虑
基于存储考虑
难以估计线性表长度或存储规模时,不宜采用顺序表;
链表不用事先估计存储规模,但链表的存储密度低(低于1)
基于运算的考虑
在顺序表中按序号访问$a_j$的时间复杂度为$O(1)$,而链表中按序号访问的时间复杂度为$O(n)$,因此若经常做的运算是按序号访问数据元素,则显然顺序表优于链表
顺序表中插入,删除操作时,平均移动表中一半的元素,当数据元素的信息量较大且表较长时,此开销不可忽视;在链表中进行插入、删除操作时,虽然也要找插入位置,但主要进行的是比较操作,可见后者优于前者
基于环境的考虑
顺序表易于实现,任何高级语言中都有数组类型;链表的操作是基于指针的,相对来讲,前者实现较为简单
两者各有优缺点,通常较稳定的线性表选择顺序存储,而频繁进行插入、删除操作的线性表宜使用链式存储
一些练习
- 设计一个递归算法,删除不带头结点的单链表L中所有值为x的结点
设计f(L,x)的功能是删除以L为收结点指针的单链表中所有值等于x的结点,显然有f(L->next,x)的功能是删除以L->next为首结点指针的单链表中所有值等于x的结点。由此,可以推出递归模型如下。
终止条件: f(L,x) = 不做任何事情; 若L为空表
递归主体: f(L,x) = 删除*L结点;f(L->next,x); 若L->data == x f(L,x) = f(L->next,x); 其他情况
代码如下:
|
|
算法需要调用一个递归工作栈,深度为O(n),时间复杂度为O(n)。由于L为引用,是直接对原链表进行操作,因而不会发生断链
- 试L为带头结点的单链表,编写算法实现从尾到头反向输出每个结点的值
算法思想:每当访问一个结点时,先递归输出它后面的结点,再输出该结点自身,这样链表就反向输出了
代码:
|
|
- 有一个带头结点的单链表L,试设计一个算法使其元素递增有序
算法思想:
采用直接插入排序算法的思想,先构成只含一个数据结点的有序单链表,然后一次扫描单链表中剩下的结点*p(直到p==NULL为止),在有序表中通过比较查找插入 *p的前驱结点 *pre,然后将 *p插入到 *pre之后
代码如下:
|
|
该算法的时间复杂度为$O(n^2)$,为达到最佳的时间性能,可将链表的数据复制到数组中,再采用时间复杂度为$O(n\log_2 n)$的排序算法进行排序,然后将数组元素依次插入链表中,此时的时间复杂度为$O(n\log_2 n)$,显然这是以空间换时间的策略
- 已知一个带有表头结点的单链表,结点结构为
data | link |
---|
假设该链表只给出了头指针list。在不改变链表的前提下,请设计一个尽可能高效的算法,查找链表中倒数第k个位置上的结点(k为整数)。若查找成功,算法输出该结点的data域的值,并返回1;否则,只返回0,要求:
- 描述算法的基本设计思想
问题关键在于设计一个尽可能高效的算法,通过链表的一次遍历,找到倒数第k个结点的位置
算法的基本设计思想:
定义两个指针变量p和q,初始时均指向头结点的下一个结点(链表的第一个结点),p指针沿链表移动,当p指针移动到第k个结点时,q指针开始与p指针同步移动;当p指针移动到最后一个结点时,q指针所指示结点为倒数第k个结点。以上过程对链表仅进行一遍扫描
-
描述算法的详细实现步骤
- count = 0,p和q指向链表表头结点的下一个结点
- 若p为空,转向步骤5
- 若count等于k,则q指向下一个结点,否则,count=count+1.
- p指向下一个结点,转向步骤2
- 若count等于k,则查找成功,输出该结点的data域的值,返回1,否则,说明k值超过了线性表长度,查找失败,返回0
- 算法结束
-
根据设计思想和实现步骤,采用程序设计语言描述算法,关键之处请给出简要注释
算法实现:
|
|
- 设计一个算法完成以下功能:判断一个链表是否有环,如果有,找出环的入口点并返回,否则返回NULL
算法的基本设计思想:
设置快慢两个指针分别为fast和slow,初始时都指向链表头head。slow每次走一步,即slow=slow->next;fast每次走两步,即fast=fast->next->next。由于fast比slow走得快,如果有环,那么fast一定会先进入,而slow后进入环,当两个指针都进入环后,经过若干操作后两个指针定能在环上相遇,从而判断一个链表是否存在环
当slow刚进入环时,fast早已进入环。因为fast每次比slow多走一步,且fast与slow 的距离小于环的长度,所以fast与slow相遇时,slow所走的距离不超过环的长度
设头结点到环的入口点的距离为a,环的入口点沿着环的方向到相遇点的距离为x,环长为r,相遇时fast绕过了n圈,则有2(a+x)=a+n * r+x,即a=n * r-x。显然从头结点到环的入口点的距离等于n被的环长减去环的入口点到相遇点的距离。因此可设置两个指针,一个指向head,一个指向相遇点,两个指针同步移动(一次走一步),相遇点即为环的入口点
代码实现:
|
|
- 设线性表$L=(a_1,a_2,a_3,\cdots,a_{n-2},a_{n-1},a_{n})$采用带头结点的单链表保存,链表中的结点定义如下:
|
|
请设计一个空间复杂度为$O(1)$且时间上尽可能高效的算法,重新排列L中的各结点,得到线性表$L_1=(a_1,a_n,a_2,a_{n-1},a_3,a_{n-2},\cdots)$。要求:
- 描述算法的基本设计思想和详细实现步骤
观察比较$L$和$L_1$可知,后者由前者摘取一个元素,再摘取倒数第一个元素,依次合并而成
为了方便链表后半段取元素,需要先将$L$的后半段原地逆置(题目要求空间复杂度为$O(n)$因而不能借助栈来逆置),否则每取最后一个结点都需要遍历一次链表
1. 先找出链表L的中间结点,为此设置两个指针p和q,指针p每次走一步,指针q每次走两步,当指针q到达链尾时,指针p正好在链表的中间结点
2. 然后将L的后半段结点原地逆置
3. 从单链表前后两段中依次各取一个结点,按要求重排
- 根据设计思想和实现步骤,采用程序设计预言描述算法,关键之处请给出简要注释
|
|
- 计算时间复杂度
第一步中找中间结点的时间复杂度为$O(n)$,第二步逆置的时间复杂度为$O(n)$,第三部合并链表的时间复杂度为$O(n)$,因此该算法的时间复杂度为$O(n)$