InkSoul/content/408/《数据结构》线性表.md

1155 lines
48 KiB
Markdown
Raw Normal View History

2023-06-18 23:16:41 +08:00
---
title: "《数据结构》线性表"
date: 2022-11-16T21:53:40+08:00
---
## 线性表的定义和操作
### 定义
线性表是具有相同数据类型的$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++
#define MaxSize 50 //定义线性表最大长度
typedef struct{
ElemType data[MaxSize]; //顺序表的元素
int length; //顺序表的当前长度
}SqList; //顺序表的类型定义
```
一组数组可以是静态分配的,也可以是动态分配的
1. 静态分配时,由于数组的大小和空间已经固定,一旦空间占满,再加入新的数据就会产生溢出,进而导致程序崩溃
2. 动态分配时,存储数组的空间是在程序执行过程中通过动态存储分配语句分配的,一旦数据空间占满就另外开辟一块更大的存储空间用以替换原来的存储空间,从而扩充存储数组空间的目的,不需要为线性表一次性划分所有空间
动态分配的顺序存储类型定义:
```c++
#define InitSize 100 //表长度的初始定义
typedef struct{
ElemType *data; //指示动态分配数组的指针
int MaxSize,length; //数组的最大容量和当前个数
}SeqList;
```
C的初始动态分配语句为
```c
L.data = (ElemType*)malloc(sizeof(ElemType)*InitSize);
```
C++的初始动态分配语句为:
```C++
L.data = new ElemType[InitSize];
```
- Attention : 动态分配并非链式存储,同样属于顺序存储结构,物理结构无变化,依然为随机存储方式,只是分配的空间大小可以在运行时动态决定
- 顺序表主要特点是随机访问,即通过首地址和元素序号可以在时间$O(1)$内找到指定元素
- 顺序表存储密度相对更高,结点只存储数据元素
- 顺序表逻辑上相邻的元素物理上也相邻,所以在插入和删除时需要移动大量元素
### 顺序表基本操作的实现
#### 插入
在顺序表L的第$i(1<=i<=L.length+1)$个位置插入新元素e。若i的输入不合法则返回false表示插入失败;否则将第i个元素及其以后的所有元素依次往后移动一个位置腾出一个位置插入新元素e顺序表长度+1插入成功返回true
```c++
bool ListInsert(SqList &L,int i,ElemType e){
if(i<1||i>L.length+1)
return false;
if(L.length>=MaxSize)
return false;
for(int j=L.length;j>=i;j--)
L.data[j]=L.data[j-1];
L.data[i-1]=e;
L.length++;
return 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。
```c++
bool ListDelete(SqList &L,int i,Elemtype &e){
if(i<1||i>L.length) //判断i的范围是否有效
return false;
e=L.data[i-1]; //将被删除的元素赋值给e
for(int j=i;j<L.length;j++)//i
L.data[j-1]=L.data[j];
L.length--; //线性表长度减一
return 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的元素并返回其位序
```c++
int LocateElem(SqList L,ElemType e){
int i;
for(i=0;i<L.length;i++)
if(L.data[i]==e)
return i+1; //下标为i的元素值等于e返回其位序i+1
return 0; //退出循环,说明查找失败
}
```
- 最好情况:查找的元素就在表头,仅需比较一次,时间复杂度为$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)$
### 一些练习
1. 从顺序表中删除具有最小值的元素(假设唯一)并由函数返回被删元素的值。空出的位置由最后一个元素填补,若顺序表为空,则显示出错信息并退出运行
算法思想:搜索整个顺序表,查找最小值元素并记住其位置,搜索结束后用最后一个元素填补空出的原最小值元素的位置
```c++
bool Del_Min(sqList &L,ElemType &value){
//删除顺序表L中最小值元素结点并通过引用型参数value返回其值
//若删除成功则返回true否则返回false
if(L.length==0)
return false;//表空,中止操作
value=L.data[0];
int pos=0; //假定0号元素数值最小
for(int i=1;i<L.length;i++) //
if(L.data[i]<value){ //value
value=L.data[i];
pos=i;
}
L.data[pos]=L.data[L.length-1]; //让最后一个元素填补空缺的位置
L.length--;
return true;
}
```
2. 设计一个高效算法将顺序表L的所有元素逆置要求算法的空间复杂度为$O(1)$
算法思想扫描顺序表L的前半部分元素对于元素L.data[i](0<=i<L.length/2),L.data[L.length-i-1]
```c++
void Reverse(Sqlist &L){
Elemtype temp; //辅助变量
for(i=0;i<L.length/2;i++){
temp=L.data[i];
L.data[i]=L.data[L.length-i-1];
L.data[L.length-i-1]=temp;
}
}
```
3. 对长度为n的顺序表L编写一个时间复杂度为$O(n)$、空间复杂度为$O(1)$的算法该算法删除线性表中所有值为x的数据元素
解法一用k记录顺序表L中不等于x的元素个数(即需要保存的元素个数)边扫描L边统计k并将不等于x的元素向前移动k个位置最后修改L的长度
```c++
void del_x_1(Sqlist &L,Elemtype x){
int k=0; //记录值不等于x的元素个数
for(i=0;i<L.length;i++)
if(L.data[i]!=x){
L.data[k]=L.data[i];
k++; //不等于x的元素增一
}
L.length=k; //顺序表L的长度等于k
}
```
解法二用k记录顺序表L中等于x的元素个数边扫描L边统计k并将不等于x的元素前移k个位置最后修改L的长度
```c++
void del_x_2(Sqlist &L,Elemtype x){
int k=0,i=0;
while(i<L.length){
if(L.data[i]==x)
k++;
else
L.data[i-k]=L.data[i]; //当前元素前移k个位置
i++;
}
L.length=L.length-k;
}
```
4. 从有序顺序表中删除其值在给定值s与t之间(包含s和t要求s<t)的所有元素,若st不合理或顺序表为空,则显示出错信息退出运行
算法思想因为是有序表所以删除的部分必定是一个整体先寻找值大于等于s的一个元素(第一个删除的元素)然后寻找值大于t的第一个元素(最后一个删除的元素的下一个元素),要将这段元素删除,只需将后面的元素前移
```c++
bool del_s_t2(SqList &L,ElemType s,ElemType t){
int i,j;
if(s>=t||L.length==0)
return false;
for(i=0;i<=L.length&&L.data[i]<s;i++);
if(i>=L.length)
return false;
for(j=i;j<L.length&&L.data[j]<=t;j++);
for(j<L.length;i++;j++)
L.data[i]=L.data[j];
L.length=i;
return true;
}
```
5. 从顺序表中删除其值在给定值s与t之间(包含s和ts<t)的所有元素,若st不合理或顺序表为空,则显示出错信息并退出运行
算法思想从前向后扫描顺序表L用k记录下元素值在s到t之间元素的个数(初始时k=0)。对于当前扫描的元素若起值不在s到t之间则前移k个位置否则执行k++。由于这样每个不在s到t之间的元素仅移动一次因此算法的效率高
```c++
bool Del_s_t(SqList &L,ElemType s,ElemType t){
int i,k=0;
if(L.length==0||s>=t)
return false; //线性表为空或st不合法
for(i=0;i<L.length;i++){
if(L.data[i]>=s&&L.data[i]<=t)
k++;
else
L.data[i-k]=L.data[i]; //当前元素前移k个位置
}
L.length-=k; //长度减小
return true;
}
```
6. 从有序顺序表中删除所有其值重复的元素,使表中所有元素的值均不同
算法思想:有序顺序表,因此值相同的元素一定在连续的位置上,用类似于直接插入排序的思想,初始时将第一个元素视为非重复的有序表,之后依次判断后面的元素是否与前面非重复有序表的最后一个元素相同,若相同,则继续向后判断,若不同,则插入前面的非重复有序表的最后,知道判断到表为为止
```c++
bool Delete_Same(SeqList& L){
if(L.length==0){
return false;
int i,j;
for(i=0,j=1;j<L.length;j++) //ij
if(L.data[i]!=L.data[j]) //查找下一个与上个元素值不同的元素
L.data[++i]=L.data[j];//查找到后,将元素前移
L.length=i+1;
return true;
}
}
```
7. 将两个有序顺序表合并为一个新的有序顺序表,并由函数返回结果顺序表
算法思想:首先,按顺序不断取下两个顺序表表头较小的结点存入新的顺序表中,然后,看哪个表还有剩余,将剩下的部分加到新的顺序表后面
```c++
bool Merge(SqList A,SeqList B,SeqList &C){
if(A.length+B.length>C.maxSize)
return false;
int i=0,j=0,k=0;
while(i<A.length&&B.length){ //
if(A.data[i]<=B.data[j]);
C.data[k++]=A.data[i++];
else
C.data[k++]=B.data[k++];
}
while(i<A.length) //
C.data[k++]=A.data[i++];
while(j<B.length)
C.data[k++]=B.data[j++];
C.length=k;
return true;
}
```
8. 已知在一位数组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)$,从而实现顺序表的位置互换
```c++
typedef int DataType;
void Reverse(DataType A[],int left,int right,int arraySize){
if(left>=right||right>=arraySize)
return;
int mid=(left+right)/2;
for(int i=0;i<=mid-left;i++){
Datatype temp = A[left+i];
A[left+i]=A[right-i];
A[right-i]=temp;
}
}
void Exchange(DataType A[],int m,int n,int arraySize){
Reverse(A,0,m+n-1,arraySize);
Reverse(A,0,n-1,arraySize);
Reverse(A,n,m+n-1,arraySize);
}
```
9. 线性表$(a_1,a_2,a_3,\cdots,a_n)$中的元素递增有序且安顺序存储于计算机内要求设计一个算法完成用最少时间在表中查找数值为x的元素若找到则将其与后继元素位置相交换若找不到则将其插入表中并使表中元素仍递增有序
算法思想:顺序存储的线性表递增有序,可以顺序查找,也可以折半查找,题目要求"用最少的时间在表中查找数值为x的元素",这里应使用折半查找法
```c++
void SearchExchangeInsert(EkemType A[],ElemType x){
int low=0,high=n-1,mid; //low和high指向顺序表的上界和下界的下标
while(low<=high){
mid = (low+high)/2; //找中间位置
if(A[mid]==x) //找到x退出while循环
break;
else if(A[mid]<x) //mid
low=mid+1;
else //到中点mid的左半部去查
high=mid-1;
}
if(A[mid]==x&&mid!=n-1){. //若最后一个元素与x相等则不存在与其后继交换的操作
t=A[mid];
A[mid]=A[mid+1];
A[mid+1]=t;
}
if(low>high){ //查找失败插入数据元素x
for(i=n-1;i>high;i--) //后移元素
A[i+1]=A[i];
A[i+1]=x; //插入x
}
}
```
10. 设将$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语言描述算法关键之处给出注释
```c++
void Reverse(int R[],int from,int to){
int i,temp;
for(i=0;i<(to-from+1)/2;i++){
temp=R[from+i];
R[from+i]=R[to-i];
R[to-i]=temp;
} //Reverse
}
void Converse(int R[],int n,int p){
Reverse(R,0,p-1)
Reverse(R,p,p-1)
Reverse(R,0,p-1)
}
```
3说明算法时间空间复杂度
上述三个Reverse函数的时间复杂度分别为$O(p/2)、O((n-p)/2)$和$O(n/2)$故所设计的算法的时间复杂度为$O(n)$,空间复杂度为$O(1)$
11. 一个长度为$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给出算法设计思想
算法的基本设计思想:
分别求两个升序序列AB的中位数设为a和b求序列A,B的中位数过程如下
1. 若a=b则a或b即为所求中位数算法结束
2. 若a<b,则舍弃序列A中较小的一半,同时舍弃序列B中较大的一半,要求两次舍弃的长度相等
3. 若a>b则舍弃序列A中较大的一半同时舍弃序列B中较小的一半要求两次舍弃的长度相等。
在保留的两个升序序列中重复过程123直到两个序列中均只含有一个元素时为止较小者即为所求的中位数
2采用C或C++或java语言描述算法关键之处给出注释
```c++
int M_Search(int A[],int B[],int n){
int s1=0,d1=n-1,m1,s2=0,d2=n-1,m2;
//分别表示序列A和B的首位数末位数和中位数
while(s1!=d1||s2!=d2){
m1=(s1+d1)/2;
m2=(s2+d2)/2;
if(A[m1]==B[m2])
return A[m1]; //满足过程1
if(A[m1]<B[m2]){ //2
if((s1+d1)%2==0){ //若元素个数为奇数
s1=m1; //舍弃A中间点以前的部分且保留中间点
d2=m2; //舍弃B中间点以后的部分且保留中间点
}
else{ //元素个数为偶数
s1=m1+1; //舍弃A中间点及中间点以前部分
d2=m2; //舍弃B中间点以后部分且保留中间点
}
}
else{. //满足过程3
if((s2+d2)%2==0){ //若元素个数为奇数
d1=m1; //舍弃A中间点以后的部分且保留中间点
s2=m2; //舍弃B中间点以前的部分且保留中间点
}
else{ //元素个数为偶数
d1=m1; //舍弃A中间点以后部分且保留中间点
s2=m2+1;//舍弃B中间点及中间点以前部分
}
}
}
return A[s1]<B[s2] ? A[s1]:B[s2];
}
```
3说明算法时间空间复杂度
算法的时间复杂度为$O(\log_2 n)$,空间复杂度为$O(1)$
12. 已知一个整数序列$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)$,则称xA的主元素,例如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是否是主元素
1. 选取候选的主元素依次扫描所给数组中的每个整数将第一个遇到的整数Num保存到c中记录num的出现次数为1若遇到的下一个整数仍然等于num则计数加一否则计数减一当计数减到0时将遇到的下一个整数保存到c中计数重新记为一开始新一轮计数即从当前位置开始重复上述过程直到扫描完全部数组元素
2. 判断c中元素是否是真正的主元素。再次扫描该数组统计c中元素出现的次数若等于n/2则为主元素否则序列中不存在主元素
2采用C或C++或java语言描述算法关键之处给出注释
```c++
int Majority(int A[],int n){
int i,c,count=1; //c用来保存候选主元素count计数
c=A[0]; //设置A[0]为候选主元素
for(i=1;i<n;i++) //
if(A[i]==c)
count++; //对A中的候选主元素计数
else
if(count>0) //处理不是候选主元素的情况
count--;
else{ //更换候选主元素,重新计数
c=A[i];
count=1;
}
if(count>0)
for(i=count=0;i<n;i++) //
if(A[i]==c)
count++;
if(count>n/2) //确认候选主元素
return c;
else //不存在主元素
return -1;
}
```
3说明算法时间空间复杂度
时间复杂度为$O(n)$,空间复杂度为$O(1)$
13. 给定一个含$n(n \geq 1)$个整数的数组,请设计一个在时间上尽可能高效的算法,找出数组中未出现的最小正整数,例如,数组{-5,3,2,3}中未出现的最小正整数是1数组{1,2,3}中未出现的最小正整数是4要求
1给出算法设计思想
思路采用空间换时间的方法分配一个用于标记的数组B[n],用于记录A中是否出现了1n中的正整数B[0]对应正整数1B[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;ABB[i]==0ii+1A1~nB[i]0i+1(i=ni+1等于n+1)此时说明A中未出现的最小正整数是n+1
2采用C或C++或java语言描述算法关键之处给出注释
```c
int findMissMin(int A[],int n){
int i,*B; //标记数组
B=(int* )malloc(sizeof(int)*n); //分配空间
memset(B,0,siezof(int)*n); //赋初值为0
for(i=0;i<n;i++)
if(A[i]>0&&A[i]<=n) //若A[i]的值介于1~n则标记数组B
B[A[i]-1]=1;
for(i=0;i<n;i++) //B
if(B[i]==0)
break;
return i+1; //返回结果
}
```
3说明算法时间空间复杂度
时间复杂度A,B各遍历一次两次循环内操作步骤为$O(1)$量级,因此时间复杂度为$O(n)$
14. 定义三元组(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|最小$
* 算法的设计思想:
1. 使用$D_{min}$记录所有已处理的三元组的最小距离,初值为一个足够大的整数。
2. 集合$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语言描述算法关键之处给出注释
```c
#define INT_MAX 0x7fffffff
int abs_(int a){
if(a<0)
return -a;
else
return a;
}
bool xls_min(int a,int b,int c){
if(a<=b&&a<=c)
return true;
return false;
}
int findMinofTrip(int A[],int n,int B[],int m,int C[],int p){
int i=0,j=0,k=0,D_min=INT_MAX,D;
while(i<n&&j<m&&k<p&&D_min>0){
D=abs_(A[i]-B[j])+abs_(B[j]-C[k])+abs_(C[k]-A[i]); //计算D
if(D<D_min)
D_min = D; //更新D
if(xls_min(A[i],B[j],C[k]))
i++; //更新a
else if(xls_min(B[j],C[k],A[i]))
j++;
else
k++;
}
return D_min;
}
```
3说明算法时间空间复杂度
设$n=(|S_1|+|S_2|+|S_3|)$,可得时间复杂度为$O(n)$空间复杂度为$O(1)$
## 线性表的链式表示
### 单链表定义
线性表的链式存储称为单链表,通过一组任意的存储单元来存储线性表中的数据元素,对于每个链表结点,除存放元素自身元素以外,还需要存放一个指向其后继的指针
|data|next|
|:---:|:---:|
如上data为数据域存放数据元素next为指针域存放后继结点的地址
对结点类型的定义如下:
```c
typedef struct LNode{
ElemType data; // 数据域
struct LNode *next; //指针域
}LNode,*LinkList;
```
单链表可解决顺序表需要大量连续存储单元的问题,但其本身附加指针域,会导致浪费存储空间,且单链表的元素离散分布在存储空间中(非随机存取结构),查找某个特定结点时,需要从表头开始遍历
头指针通常用于标识一个单链表如单链表L头指针为NULL时表示为一个空表出于便于操作的目的也会在单链表的第一个结点之前附加一个结点称为头结点。头结点的数据域可以不设任何信息也可以记录表长等信息。头结点的指针指向线性表的第一个元素结点
头结点和头指针的区别:
- 头指针始终指向链表的第一个结点
- 头结点是带头结点的链表中的第一个结点,结点内通常不存储信息
头结点引入的优势:
- 由于第一个元素结点的位置被放在头结点的指针域中,因而在链表第一个位置上的操作与其他位置保持一致
- 无论链表是否为空,头指针都指向头结点的非空指针(空表中头结点的指针域为空),统一了对空表和非空表的处理
### 单链表的基本操作
#### 采用头插法建立单链表
该方法从一个空表开始,生成新结点,并把读取到的数据存放到新结点的数据域中,然后将新结点插入到头结点之后
算法如下:
```c
LinkList List_HeadInsert(LinkList &L){
LNode *s; int x;
L=(LinkList)malloc(sizeof(LNode));
L->next = NULL;
scanf("%d",&x);
while(x!=9999){
s=(LNode*)malloc(sizeof(LNode));
s->data=x;
s->next=L->next;
L->next=s;
scanf("%d",&x);
}
return L;
}
```
读入数据的顺序与生成的链表中的元素的顺序是相反的,每个结点插入的时间为$O(1)$,设单链表长度为n则总时间复杂度为$O(n)$
#### 采用尾插法建立单链表
该方法将新结点插入到当前链表的表尾为此必须增加一个尾指针r使其始终指向当前链表的尾结点
算法:
```c
LinkList List_TailInsert(LinkList &L){ //正向建立单链表
int x;
L=(LinkList)malloc(sizeof(LNode));
LNode *s,*r=L; //r为表尾指针
scanf("%d",&x);
while(x!=9999){
s=(LNode *)malloc(sizeof(LNode));
s->data=x;
r->next=s;
r=s; //r指向新的表尾指针
scanf("%d",&x);
}
r->next=NULL; //尾结点指针置空
return L;
}
```
读入数据的顺序与生成的链表中的元素的顺序一致,附设了一个指向表尾结点的指针,故时间复杂度和头插法的相同,都为$O(n)$
#### 按序号查找结点值
在单链表中从第一个结点出发顺指针next域逐个往下搜索直到找到第i个结点为止否则返回最后一个结点指针域NULL
算法:
```c
LNode *GetElem(LinkList L,int i){
int j=1; //计数初始为1
LNode *p=L->next; //头结点指针赋给p
if(i==0)
return L; //若i等于0则返回头结点
if(i<1)
return NULL; //i无效则返回NULL
while(p&&j<i){
p=p->next;
j++;
}
return p; //返回第i个结点的指针若i大于表长则返回NULL
}
```
时间复杂度为$O(n)$
#### 按值查找表结点
从单链表的第一个结点开始由前往后依次比较表中各结点数据域的值若某结点数据域的值等于给定值e则返回该结点的指针若整个单链表中没有这样的结点则返回NULL
算法:
```c
LNode *LocateElem(LinkList L,ElemType e){
LNode *p=L->next;
while(p!=NULL&&p->data!=e) //从第一个结点开始查找data域为e的结点
p=p->next;
return p; //找到后返回该结点指针否则返回NULL
}
```
#### 插入结点操作
插入结点操作将值为x的新结点插入到单链表的第i个位置上。先检查插入位置的合法性然后找到待插入位置的前驱结点即第i-1个结点再在其后插入新结点
算法首先调用按序号查找算法GetElem(L,i-1),查找第i-1个结点。假设返回的第i-1个结点为* p然后令新结点* s的指针域指向* p的后继结点再令结点* p的指针域指向新插入的结点* s
实现插入结点的代码片段:
```c
p=GetElem(L,i-1);
s->next=p->next;
p->next=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)$
```c
//将 *S插入到到 *P的前面
s->next = p->next; //修改指针域
p->next = s;
temp = p->data; //交换数据域
p->data = s->data;
s->data = temp;
```
#### 删除结点操作
删除结点操作是江单链表的第i个结点删除。需要先检查删除位置的合法性后查找表中第i-1个结点即被删结点的前驱结点再将其删除
假设*p为找到的被删结点的前驱结点仅需修改 *p的指针域即将 *p的指针域next指向 *q的下一结点
```c
p=GetElem(L,i-1); //查找删除位置的前驱结点
q=p->next; //和后继结点交换数据域
p->next=q->next; //将*q结点从链中断开
free(q); //释放后继结点的存储空间
```
#### 求表长操作
即计算单链表中数据结点的个数,需从第一个结点开始顺序依次访问表中的每个结点,设置一个计算器变量,每访问一次结点则加一,直到访问空结点,算法复杂度为$O(n)$
单链表长度往往不包括头结点,对于不带头结点和带头结点的链表在求表长时操作存在不同。对于前者,当表空时需要单独处理
### 双链表
双链表在单链表的结点中增加了一个指向前驱的prior指针使得其无需像单链表那样只能从头开始依次顺序地向后遍历
结点类型描述:
```c
typedef struct DNode{ //定义双链表结点类型
ElemType data; // 数据域
struct DNode *prior,*next; //前驱和后驱结点
}DNode,*DLinklist;
```
#### 双链表的插入操作
在双链表中p所指的结点之后插入结点*s
```c
s->next=p->next; //将结点*s插入到结点*p之后
p->next->prior=s;
s->prior=p;
p->next=s;
```
上述代码顺序并非唯一,但也不是任意的,第一二步需保证在第四步之前,当值丢失*p的后继结点的指针
#### 双链表的删除操作
删除双链表中结点*p的后继结点 *q
```c
p->next=q->next;
q->next->prior=p;
free(q);
```
### 循环链表
#### 循环单链表
循环单链表与单链表的区别在表中最后一个结点的指针不是NULL而是指向头结点从而整个链表形成一个环
循环单链表中,表尾结点*r的next域指向L故表中没有指针域为NULL的结点因此循环单链表的判空条件为头结点的指针是否等于头指针
循环单链表中插入,删除与单链表一致,不同在于表尾操作时需要让单链表继续保持循环的性质。当然由于循环单链表往往认为是一个环,任何一个位置上的插入和删除操作都是等价,无需判断是否为表尾
相比于单链表,循环单链表能够从表中任意一个结点开始遍历整个链表,有时对单链表常做的操作是在表头和表尾进行,此时对循环单链表不设头指针,仅设尾指针能够有更高的效率,原因在于,若设的是头指针,对表尾进行操作需要$O(n)$的时间复杂度若设的是尾指针rr->即为头指针,对表头与表尾进行操作都只要$O(1)$的时间复杂度
#### 循环双链表
在循环双链表中头结点的prior指针还要指向表尾结点
例如在循环双链表L中某结点*p为尾结点时p->next==L;当循环双链表为空表时其头结点的prior域和next域都等于L
### 静态链表
静态链表借助数组来描述线性表的链式存储结构结点也有数据域data和指针域next与先前的链表中的指针不同在于此处的指针是结点的相对地址(数组下标),又称游标。和顺序表一致,静态链表也需要分配一块连续的内存空间
结构类型描述:
```c
#define MaxSize 50
typedef struct {
ElemType data;
int next;//下一个元素的数组下标
} SLinkList[MaxSize];
```
静态链表以next == -1作为结束的标志。总体而言静态链表不如单链表使用起来方便但在一些不支持指针的高级语言中其为一种巧妙的设计方法
## 顺序表和链表的比较
### 存取(读写)方式
顺序表可以顺序存取也可以随机存取链表只能从表头顺序存取元素。例如在第i个位置上执行存或取得操作顺序表仅需一次访问而链表则需从表头开始依次访问i次
### 逻辑结构与物理结构
采用顺序存储是,逻辑上相邻的元素,对应的物理存储位置也相邻。采用链式存储时,逻辑上相邻的元素,物理存储位置不一定相邻,对应的逻辑关系是通过指针链接来表示的
### 查找、插入和删除操作
对于按值查找,顺序表无序时,两者的时间复杂度均为$O(n)$;顺序表有序时,可采用折半查找,此时的时间复杂度为$O(\log_2 n)$
对于按序号查找,顺序表支持随机访问,时间复杂度仅为$O(1)$,而链表的平均时间复杂度为$O(n)$。顺序表的插入、删除操作,平均需要移动半个表长的元素。链表的插入、删除操作,只需修改相关结点的指针域即可。由于链表的每个结点都带有指针域,故而存储密度不够大
### 空间分配
顺序存储在静态存储分配情形下,一旦存储空间装满就不能扩充,若再加入新元素,则会出现内存溢出,因此需要预先分配足够大的存储空间。预先分配过大,可能会导致顺序表后不大量闲置;预先分配过小则易发生溢出
动态分配情形下,虽然可以扩充存储空间,但需要移动大量元素,导致操作效率降低,若内存中没有更大块的连续存储空间,则会分配失败,链式存储的存储空间则只在需要时申请,只要空间足够就能够申请,操作灵活高效
### 存储结构的选取考虑
#### 基于存储考虑
难以估计线性表长度或存储规模时,不宜采用顺序表;
链表不用事先估计存储规模,但链表的存储密度低(低于1)
#### 基于运算的考虑
在顺序表中按序号访问$a_j$的时间复杂度为$O(1)$,而链表中按序号访问的时间复杂度为$O(n)$,因此若经常做的运算是按序号访问数据元素,则显然顺序表优于链表
顺序表中插入,删除操作时,平均移动表中一半的元素,当数据元素的信息量较大且表较长时,此开销不可忽视;在链表中进行插入、删除操作时,虽然也要找插入位置,但主要进行的是比较操作,可见后者优于前者
#### 基于环境的考虑
顺序表易于实现,任何高级语言中都有数组类型;链表的操作是基于指针的,相对来讲,前者实现较为简单
两者各有优缺点,通常较稳定的线性表选择顺序存储,而频繁进行插入、删除操作的线性表宜使用链式存储
### 一些练习
1. 设计一个递归算法删除不带头结点的单链表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); 其他情况
代码如下:
```c
void Del_X_3(Linklist &L,ElemType x){
//递归实现在单链表L中删除值为x的结点
LNode *p; //p指向待删除结点
if(L==NULL) //递归出口
return;
if(L->data==x){ //若L所指结点的值为x
p=L; //删除*L,并让L指向下一结点
L=L->next;
free(p);
Del_X_3(L,x); //递归调用
}
else //若L所指结点的值不为x
Del_X_3(L->next,x);//递归调用
}
```
算法需要调用一个递归工作栈深度为O(n),时间复杂度为O(n)。由于L为引用是直接对原链表进行操作因而不会发生断链
2. 试L为带头结点的单链表编写算法实现从尾到头反向输出每个结点的值
算法思想:每当访问一个结点时,先递归输出它后面的结点,再输出该结点自身,这样链表就反向输出了
代码:
```c
void R_Print(LinkList L){
//从尾到头输出单链表L中每个结点的值
if(L->next!=NULL){
R_Print(L->next); //递归
}//if
if(L!=NULL){
print(L->data);
}
}
void R_Ignore_Head(LinkList L){
if(L!=NULL){
R_Print(L->next);
}
}
```
3. 有一个带头结点的单链表L试设计一个算法使其元素递增有序
算法思想:
采用直接插入排序算法的思想,先构成只含一个数据结点的有序单链表,然后一次扫描单链表中剩下的结点*p(直到p==NULL为止),在有序表中通过比较查找插入 *p的前驱结点 *pre,然后将 *p插入到 *pre之后
代码如下:
```c
void Sort(LinkList &L){
//本算法实现将单链表L的结点重排使其递增有序
LNode *p=L->next,*pre;
LNode *r=p->next; //r保持*p后继结点指针保证不断连
p->next = NULL; //构造只含一个数据结点的有序表
p=r;
while(p!=NULL){
r=p->next; //以保存*p的后继结点指针
pre=L;
while(pre->next!=NULL&&pre->next->data<p->data){
pre = pre->next; //在有序表中查找插入*p的前驱结点*pre
}
p->next = pre->next; //将*p插入到*pre之后
pre->next = p;
p=r; //扫描原单链表中剩下的结点
}
}
```
该算法的时间复杂度为$O(n^2)$,为达到最佳的时间性能,可将链表的数据复制到数组中,再采用时间复杂度为$O(n\log_2 n)$的排序算法进行排序,然后将数组元素依次插入链表中,此时的时间复杂度为$O(n\log_2 n)$,显然这是以空间换时间的策略
4. 已知一个带有表头结点的单链表,结点结构为
| data | link |
| :---: | :---: |
假设该链表只给出了头指针list。在不改变链表的前提下请设计一个尽可能高效的算法查找链表中倒数第k个位置上的结点(k为整数)。若查找成功算法输出该结点的data域的值并返回1否则只返回0要求
1) 描述算法的基本设计思想
问题关键在于设计一个尽可能高效的算法通过链表的一次遍历找到倒数第k个结点的位置
算法的基本设计思想:
定义两个指针变量p和q初始时均指向头结点的下一个结点(链表的第一个结点)p指针沿链表移动当p指针移动到第k个结点时q指针开始与p指针同步移动当p指针移动到最后一个结点时q指针所指示结点为倒数第k个结点。以上过程对链表仅进行一遍扫描
2) 描述算法的详细实现步骤
1. count = 0p和q指向链表表头结点的下一个结点
2. 若p为空转向步骤5
3. 若count等于k则q指向下一个结点否则count=count+1.
4. p指向下一个结点转向步骤2
5. 若count等于k则查找成功输出该结点的data域的值返回1否则说明k值超过了线性表长度查找失败返回0
6. 算法结束
3) 根据设计思想和实现步骤,采用程序设计语言描述算法,关键之处请给出简要注释
算法实现:
```c
typedef int ElemType //链表数据的类型定义
typedef struct LNode{
ElemType data; //结点数据
struct LNode *link;//结点链接指针
}LNode,*LinkList; //链表结点的结构定义
int Search_k(LinkList list,int k){
//查找链表list倒数第k个结点并输出该结点data域的值
LNode *p=list->link,*q=list->link;//指针pq指示第一个结点
int count=0;
while(p!=NULL){ // 遍历链表直到最后一个结点
if(count<k){ //count<kp
count++;
}
else{
q=q->link;
}
p=p->link; //之后让pq同步移动
}
if(count<k){
return 0; //查找失败返回0
}
else{
printf("%d",q->data); //查找成功打印并返回1
return 1;
}
}
```
5. 设计一个算法完成以下功能判断一个链表是否有环如果有找出环的入口点并返回否则返回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一个指向相遇点两个指针同步移动(一次走一步),相遇点即为环的入口点
代码实现:
```c
LNode* FindLoopStart(LNode *head){
LNode *fast=head,*slow=head;//设置快慢两个指针
while(slow!=NULL&&fats->next!=NULL){
slow=slow->next; //每次走一步
fast=fast->next-next;//每次走两步
if(slow==fast){ // 相遇
break;
}
}
if(slow==NULL||fast->next==NULL){
return NULL; //没有环返回NULL
}
LNode *p1=head,*p2=slow; //分别指向开始点,相遇点
while(p1!=p2){
p1=p1->next;
p2=p2->next;
}
return p1; //返回入口点
}
```
6. 设线性表$L=(a_1,a_2,a_3,\cdots,a_{n-2},a_{n-1},a_{n})$采用带头结点的单链表保存,链表中的结点定义如下:
```c
typedef struct node
{
int data;
struct node*next;
}NODE;
```
请设计一个空间复杂度为$O(1)$且时间上尽可能高效的算法重新排列L中的各结点得到线性表$L_1=(a_1,a_n,a_2,a_{n-1},a_3,a_{n-2},\cdots)$。要求:
1) 描述算法的基本设计思想和详细实现步骤
观察比较$L$和$L_1$可知,后者由前者摘取一个元素,再摘取倒数第一个元素,依次合并而成
为了方便链表后半段取元素,需要先将$L$的后半段原地逆置(题目要求空间复杂度为$O(n)$因而不能借助栈来逆置),否则每取最后一个结点都需要遍历一次链表
1. 先找出链表L的中间结点为此设置两个指针p和q指针p每次走一步指针q每次走两步当指针q到达链尾时指针p正好在链表的中间结点
2. 然后将L的后半段结点原地逆置
3. 从单链表前后两段中依次各取一个结点,按要求重排
2) 根据设计思想和实现步骤,采用程序设计预言描述算法,关键之处请给出简要注释
```c
void change_list(NODE*h){
NODE *p,*q,*r,*s;
p=q=h;
while(q->next!=NULL){ //寻找中间结点
p=p->next; //p走一步
q=q->next;
if(q->next!=NULL){
q=q->next; //q走两步
}
q=p->next; //p所指结点为中间结点q为后半段链表的首结点
p->next=NULL;
while(q!=NULL){ //逆置链表后半段
r=q->next;
q->next=p->next;
p->next=q;
q=r;
}
}
s=h->next; //s指向前半段的第一个数据结点插入点
q=p->next; //q指向后半段的第一个数据结点
p->next=NULL;
while(q!=NULL){ //将链表后半段的结点插入到指定位置
r=q->next; //r指向后半段的下一个结点
q->next=s->next; //将q所指结点插入到s所指结点之后
s->next=q;
s=q->next; //s指向前半段的额下一个插入点
q=r;
}
}
```
3) 计算时间复杂度
第一步中找中间结点的时间复杂度为$O(n)$,第二步逆置的时间复杂度为$O(n)$,第三部合并链表的时间复杂度为$O(n)$,因此该算法的时间复杂度为$O(n)$