线性表(2)顺序表
定义
顺序表是一种存储结构,指的是线性表中逻辑相邻的元素在物理内存上也相邻,其用一块连续的地址空间存放表中的数据元素。也就是说,对于表\(A(a_1, a_2, a_3, \dots, a_n)\),设表中元素的大小为\(size\),其物理地址如下:
地址 | Loc(A) | Loc(A)+size | Loc(A)+2size | ... | Loc(A)+(n-1)size |
---|---|---|---|---|---|
元素 | \(a_1\) | \(a_2\) | \(a_3\) | ... | \(a_n\) |
顺序表的最大的特点就是支持随机访问,随机访问指的是,只要知道了表中第一个元素的位置以及表中元素的大小,就可以在\(O(1)\)的时间内查找到某一个位置的元素。如果我们想要查找第\(i\)个元素,只需要计算一次公式\(Loc(A)+(i-1)size\),就可以找到相应的的元素。
实现
对于一个数据结构,我们肯定需要存放其数据元素的空间,对于线性表,我们在定义里说了,它是用一块连续的地址空间存放数据元素,这种情况下,我们必须保证有一块连续的空间用来存放我们的所有数据元素,那么如何保证其连续呢?我们知道在C语言里,数组就是这样的连续空间,这样我们就可以静态地分配一块固定大小的地址空间,存放所有的数据元素,而我们知道,数组的大小在其被声明的时候就已经确定,因此无可更改,也就是静态的。
代码如下:
const int MaxSize = 10;
typedef struct {
ElemType data[MaxSize];
int len;
}SqList;
typedef struct
在这里,我们用到了
typedef
这样的语法,相较于直接用struct xxx
这样的定义,在声明这种类型的变量的时候,无需加上struct
,也就是说,用typedef
定义的结构体,申明变量的时候只需要SqList s;
就可以,而不适用typedef
的则需要struct SqList s;
来申明。
静态分配看起来已经够用了,但是考虑到在某些情况下,需要我们精确地控制使用的内存或者我们事先分配的空间不够用,这就需要我们能够动态地分配空间。注意到C语言中的malloc()/free()
函数就能够实现堆上空间的动态分配,只要我们适当使用这两个函数,就可以完成顺序表的动态分配。
代码如下:
typedef struct {
ElemType *data;
int MaxSize;
int len;
}SqList;
我们在结构体里申明了一个指针,用这个指针代替了原来的数组,这样子,我们只需要将指针指向我们动态申请的空间的起始位置,就可以通过这个指针访问所要访问的元素了。而新加入的MaxSize
数据项,则是记录表的最大长度,类比一下静态分配的MaxSize
,我们动态分配改变的其实就是这个MaxSize
。
我们在更新顺序表的长度的时候,先开辟一块更大的空间,然后将原来的数据拷贝到新的空间,再用free()
回收原来的空间,就完成了一个顺序表表长的更新,代码如下:
//将表增长len
void IncreaseSize(SqList &L, int len) {
ElemType *p = L.data;
L.data = (ElemType *)malloc(sizeof(ElemType) * (L.MaxSize + len));
for (int i = 0; i < L.MaxSize; ++i) {
L.data[i] = p[i];
}
L.MaxSize += len;
free(p);
}
这其实就相当于,我们有一个箱子,箱子里装着衣服,随着时间过去,箱子终于有一天放不下新衣服了,这时候我们就需要先买一个更大的箱子,把箱子里的衣服放进新的箱子,再处理掉就的箱子。
另外注意到,一开始我们的表中的指针并不是指向有效的地址的,这就需要一个初始化过程,避免我们解引用的时候访问了奇奇怪怪的地址:
void InitList(SqList &L, int InitSize) {
L.data = (ElemType *)malloc(sizeof(ElemType) * InitSize);
L.len = 0;
L.MaxSize = InitSize;
}
结构体的更新
假设我们要对一个结构体更新,如果这个结构体的数据项很少,当然很难出问题,但是如果结构体的数据项多了,我们很容易忘记更新某些需要更新的数据,所以最好的更新方法就是对着结构体中的数据项逐个检查,看是否需要更新,避免有遗漏。
操作
根据我们在线性表(1)中的讨论,线性表最基本的操作有增删改查。其中的增就代表了插入元素,对于顺序表,要插入一个元素到某个位置,需要分情况讨论。
插入
在插入后大小不超过表的最大长度的情况下:
- 如果是插入到某个已有元素的位置,我们需要移动该位置以及该位置之后的元素一个位置,空出来的位置留给新的元素。
- 如果是插入到表尾,只需要将表尾的位置给新元素就可以了,不需要改动其他元素的位置。
- 如果是插入到表尾之后的位置,这将造成新的表尾和旧的表尾之间有空隙,操作不合法。
其实第一种和第二种情况可以归约为同一种情况,也就是,将插入位置及其后面的元素向后移动,第二种情况下,后面没有元素,也就是移动0个元素。
根据上面的分析,代码如下:
void ListInsert(SqList &L, int i, ElemType e) {
for (int idx = L.len - 1; idx >= i; --idx) {
L.data[idx + 1] = L.data[idx];
}
L.data[i] = e;
}
注意到,这里并没有考虑到操作不合法的情况,对于操作不合法的情况,也分开考虑:
- 插入位置超过表尾
- 插入后大小超过表最大大小
这里只需要用if
语句对传入的参数进行特判就行了,代码如下:
bool ListInsert(SqList &L, int i, ElemType e) {
if (i < 0 || i > L.len)
return false;
if (L.len >= L.MaxSize)
return false;
for (int idx = L.len - 1; idx >= i; --idx) {
L.data[idx + 1] = L.data[idx];
}
L.data[i] = e;
L.len++;
return true;
}
这样我们就得到了完整的插入操作。接下来要考虑的就是删除操作。
关于插入操作的复杂度,分析如下:
- 对于最好的情况,新插入的元素在表尾,不用移动任何元素,其复杂度是\(O(1)\)。
- 对于最坏情况,需要把所有的元素都移动一遍,其复杂度是\(O(n)\)。
- 对于平均情况,假设元素插入的位置是均匀随机分布的,也就是,新元素插入某一个位置的概率是\(p=\frac{1}{n + 1}\),这个情况下,需要移动的平均次数就是\(\sum_{i = 0}^n\frac{i}{n+1}\),是一个简单的等差数列求和,求和的结果是一次的,因此复杂度是\(O(n)\).
删除
删除是插入的逆过程,在代码上的体现也是如此,对于删除后的位置,会造成空隙,需要把删除位置后面的元素提前。而合法的删除操作只需要保证被删除的位置小于表长就行了。代码如下:
bool ListDelete(SqList &L, int i, ElemType &e) {
if (i < 0 || i >= L.len)
return false;
e = L.data[i];
for (int idx = i; i < L.len - 1; ++i) {
L.data[idx] = L.data[idx + 1];
}
L.len--;
return true;
}
这里大体上和插入操作是很相似的,但是也有几处不同:
- 参数
e
用的是引用,这是为了把被删除的元素传给调用者 - 删除操作的元素移动是从前往后遍历
关于删除操作的时间复杂度,分析如下:
- 对于最好的情况,删除的是表尾元素,不需要移动其他元素,复杂度是\(O(1)\)。
- 对于最坏情况,需要移动剩下所有元素,复杂度是\(O(n)\)。
- 对于平均情况,分析过程与插入操作类似,复杂度是\(O(n)\)。
注意每次删除和插入操作以后都要对长度进行更新,我自己写博客的过程也差点忘记写
另外要注意的,按照王道课程上的说法,顺序表的表项从1开始编号,数组下标从0开始编号,我这里按照我的习惯写了,顺序表表项也从0开始编号
查找
查找有按位查找和按值查找两种,我们说过,顺序表的最大特性就是支持随机访问,这是很好的性质,这使得按位查找的实现变得非常简单。
ElemType GetElem(SqList L, int i) {
return L.data[i];
}
在这一段代码中,由于随机访问的特性,我们直接按下标取值返回即可。但是对于按值查找,则复杂一点:
int LocateElem(SqList L, ElemType e) {
for (int i = 0; i < L.len; i++) {
if (L.data[i] == e)
return i;
}
return -1;
}
在上面的代码中,需要遍历表中的元素,知道找到某一个和给定元素相等的元素,返回其下标即可,而如果遍历完了表都没找到相应的元素,则需要返回-1表示查找失败。
关于两种查找方式的时间复杂度:
按位查找只做了一次操作,复杂度是\(O(1)\)的。
对于按值查找有:
- 最好情况下,第一个元素即使所查找的元素,时间复杂度是\(O(1)\)。
- 最坏情况下,所查找的元素在表的最后,时间复杂度是\(O(n)\)。
- 平均情况下,假设所查找的元素所在的位置是等概率的,按照类似于插入操作的分析可得,时间复杂度是\(O(n)\).