线性表的顺序存储结构
顺序存储结构是把所有元素按次序的存放在存储器中一块连续的空间中,线性表的顺序存储结构简称顺序表(sequential list)。
用数组data[M]
可以很自然的表示顺序表,其中M
表示顺序表的最大长度(也叫容量capacity
)。数组的每一个元素也恰好对应线性表中的每一个元素。C/C++的数组只是提供了一个首地址,因此还需要一个额外的字段_length
保存数组的长度。
整个线性表需要占用sizeof(ElementType)*n
字节的空间。
因此下面得到顺序表的C++类定义。
顺序表的类定义
#define MAXSIZE 64
/**
* 顺序表类
*/
template<typename _Ty, int capacity = MAXSIZE>
class SeqList
{
private:
_Ty _data[capacity];//元素数组,假定capacity已定义
int _length; //线性表的实际长度
public:
/**
* 构造函数,创建一个线性表,并用指定的数据填充线性表。
* 如果n大于容量c,则截断为前c个元素进入线性表
* @param _Ty init_data[] 初始输入数据数组
* @param int n 初始数据数组长度
*/
SeqList(_Ty init_data[], int n);
/**
* 默认构造函数,创建一个空的线性表
*/
SeqList();
/**
* 析构函数,销毁线性表
*/
~SeqList(){};
/**
* 判断当前线性表是否为空
* @return bool 表空返回true,否则false
*/
bool empty();
/**
* 返回当前线性表的长度
* @return int 线性表的实际长度
*/
int length();
/**
* 返回线性表中指定位置的元素
* @param int i 序号
* @return _Ty 返回元素的值
*/
_Ty & at(int i);
_Ty & operator[](int i);
/**
* 查找线性表中指定值的元素的位置
* @param _Ty value 需要查找的元素的值
* @return int 返回该元素的位置,0为未找到
*/
int find(_Ty value);
/**
* 将指定元素插入指定位置
* @param int i 待插入元素的位置
* @param _Ty value 待插入元素的值
* @return bool 操作成功返回true,否则false
*/
bool insert(int i, _Ty value);
/**
* @param int i 需要删除的元素的位置
* @return bool 操作成功返回true,否则false
*/
bool remove(int i);
};
顺序表的具体操作实现
构造函数
这里的构造函数实际上也就是建立顺序表的过程。分两种,一种是根据已有的数据建表,另一种是是建立一个空表。
根据已有数据建立表,应该考虑到已有数据的个数可能超过了表的容量,因此这里使用了截断的方法,最多只能取前
c
个元素(
复制数据一般想到的是memcpy
函数,但是实际上,_Ty
可能是一个类,如果这个类使用了动态内存分配的情况,直接使用memcpy
是不行的,但是只要他重载了operator=
,使用=
进行赋值是没有问题的。除此之外,直接使用循环也能比较容易的展示本质而不被函数调用的表象所迷惑。
template<typename _Ty, int capacity>
SeqList<_Ty, capacity>::SeqList(_Ty init_data[], int n){
_length = std::min(n, capacity);
//需要#include<algorithm>使用std::min
for(int i = 0; i < _length; i++)
_data[i] = init_data[i];
}
建立空表的构造函数较为简单,只需要将_length
赋值为0就可以了。_data
并不需要理会,因为我们只关心有效数据。
template<typename _Ty, int capacity>
SeqList<_Ty, capacity>::SeqList(){
_length = 0;
}
析构函数
因为这里的_data
是静态分配的(类的成员),因此此处没有任何动态内存分配的问题,因此析构函数不需要写什么东西,在类的声明用空函数体表示。教材上的使用了结构体的指针表示顺序表,存在着动态内存分配的情形,因此使用free
释放就可以了。
判断顺序表是否为空
判空只需要判断_length
是否为0就可以了。
template<typename _Ty, int capacity>
bool SeqList<_Ty, capacity>::empty(){
return _length == 0;
}
得到线性表的长度
直接返回_length
即可。
template<typename _Ty, int capacity>
int SeqList<_Ty, capacity>::length(){
return _length;
}
对于顺序存储结构的表示,需要显式定义_length
以指出数据的长度;然而对于其他的存储结构(比如链式的链表),事实上维护这样一个属性只需要额外的4个字节的开销,却能够简化得到元素个数的操作,且只需要
O(1)
的时间而不是遍历所有元素的
O(n)
时间。
得到顺序表指定位置的元素值
因为顺序表是用数组作为底层存储,因此也很容易实现根据位置取值。需要注意的是,要将顺序表的逻辑序号(
1..n
)转化为C++的数组下标(
0..n−1
)。除此之外,还需要判断参数的合法性,对于C/C++,这里使用了assert
宏来实现,在实际使用中效果要好于返回值(C++也并不推荐使用异常,在Sedgewick的Algorithm 4th Edition书中,Java描述对于不合法的参数使用了异常来处理。)。C++支持引用,因此使用引用作为返回值,这种表达式就可以当作左值(l-value),即对于SeqList<int> s
来说s[1] = 5
、s.at(1) = 5
这种表达式是合法的。
template<typename _Ty, int capacity>
_Ty & SeqList<_Ty, capacity>::at(int i){
assert(!(i > _length || i <= 0));
//需要#include<cassert>
return _data[i - 1];
}
如果支持运算符重载或者索引器,可以使取值这个过程看起来更清晰
template<typename _Ty, int capacity>
_Ty & SeqList<_Ty, capacity>::operator[](int i){
return this->at(i);
}
查找顺序表的指定元素
查找需要假定数据元素类型ElementType
具有operator==
。对于顺序线性表的顺序查找,只需要依次向后查找,找到了就返回第一个找到的元素的下标(依然需要处理线性表和数组下标之间的关系),否则扫描完所有元素仍然没有找到时,返回0。
template<typename _Ty, int capacity>
int SeqList<_Ty, capacity>::find(_Ty value){
for(int i = 0; i < _length; i++)
if(_data[i] == value)
return i + 1;
return 0;
}
如果要查找的元素在线性表中有多个,在类中就需要额外维护一个指针,表示当前查找到的位置。
插入数据元素
插入数据,首先需要检查参数的合法性,以及是否会出现容量不够的情况。检查参数合法性依然使用了assert
宏,而检查是否已满使用的是if
判断语句。
然后判断插入的位置,如果插入是末尾(逻辑序号
i∈[1,n]
,因此条件为i == _length + 1
,即i - 1 == _length
),直接追加到顺序表末尾即可;如果不是末尾,需要从第
i
个元素开始将每个元素后移一位。注意这里的写法:从后往前倒着复制。在C库函数中有memmove
就是这样的(区别于memcpy
,memmove
适用于src
和dest
有重叠)。
template<typename _Ty, int capacity>
bool SeqList<_Ty, capacity>::insert(int i, _Ty value){
assert(!(i > _length + 1 || i <= 0));
//需要#include<cassert>
if((_length + 1) > capacity)
return false;
i--;
if(i != _length)
for(int j = length; j > i; j--)
_data[j] = _data[j - 1];
_data[i] = value;
_length++;
return true;
}
删除元素
删除元素也需要检查数据的合法性,将_length
减去1,然后从第_length
是先减的,因此下面必须要j < _length
才能保证j + 1 = _length(原来的)
,从而后面所有元素都复制到。
template<typename _Ty, int capacity>
bool SeqList<_Ty, capacity>::remove(int i){
assert(!(i > _length || i <= 0));
//需要#include<cassert>
_length--;
for(int j = i - 1; j < _length; j++)
_data[j] = _data[j + 1];
return true;
}
测试代码
下面是一段测试代码:
int main(){
SeqList<int, 4> s;
printf(s.empty() ? "SeqList Is empty.\n" : "SeqList Not Empty.\n");
int i;
for(i = 1; i <= 4; i++)
s.insert(1, i);
printf(s.empty() ? "SeqList Is empty.\n" : "SeqList Not Empty.\n");
for(i = 1; i <= s.length(); i++)
printf("%d ", s.at(i));
printf("\n");
s.remove(1);
for(i = 1; i <= s.length(); i++)
printf("%d ", s.at(i));
printf("\n");
s.remove(3);
for(i = 1; i <= s.length(); i++)
printf("%d ", s.at(i));
s.insert(3, 5);
s.insert(3, 6);
printf("\nLength => %d\n", s.length());
for(i = 1; i <= s.length(); i++)
printf("%d ", s.at(i));
printf("SeqList[2] => %d\n", s[2]);
printf("5 is Found At => %d\n", s.find(5));
getchar();
return 0;
}
输出:
SeqList Is empty.
4 3 2 1
SeqList Not empty.
3 2 1
3 2
Length => 4
3 2 6 5 SeqList[2] => 2
5 is Found At => 4
一些顺序表的例子
因为顺序表在物理存储上是使用的数组,因此下面直接使用数组进行讨论,以避免模板类作为参数容易导致二进制不兼容的问题,故对下标的操作直接按从0开始。
删除重复元素
要求:设计一个时间复杂度为
O(n)
,空间复杂度为
O(1)
的算法remove_all_by_value(A, n, v)
,删除数组中的重复元素,并返回新的数组的长度。
比如
5 5 4 4 5 3 6 8 3 9
长度为 10
删除所有5
后得到
4 4 3 6 8 3 9
长度为 7
一般情况得出的算法是,从头到尾逐次检查数组A
中的所有元素,只要检查到A[i] == v
,则将所有元素前移一位,并将数组长度n--
.
但是该算法不能保证为
O(n)
时间。
其实关键在于,每次删除一个元素,都会导致后面的元素整体向前移动。删除
k
个重复元素,就需要移动cnt
,记录出现的次数。可以得到如下算法:
/**
* 将数组中所有指定元素的值删除
* @param int [] a 源数组
* @param int n 源数组长度
* @param int value 需要删除的元素的值
* @return 返回新的数组长度
*/
int remove_all_by_value(int a[], int n, int value){
int cnt = 0;
for(int i = 0; i < n; i++)
if(a[i] == value)
cnt++;
else
a[i - cnt] = a[i];
return n - cnt;
}
将数组进行划分
要求:设计一个高效算法partition(A, n, r)
,将数组A
划分为两块,其中第一块的所有元素都小于A[r]
,第二块的元素都大于A[r]
。
比如1 5 3 4 6 7 9 8 2
,r = 2
应该得到1 3 4 2 5 6 7 9 8
(其他合理结果均可)
通常是左边的子块是较小的块,右边的子块是较大的块,因此,可以从左右同时扫描(循环并列而非嵌套),分别在子块内寻找不符合条件的元素,将其交换就可以了。
/**
* 将数组按照大小进行划分
* @param int [] A 源数组
* @param int n 源数组长度
* @param int r 枢轴元素位置
* @return int 划分后的枢轴元素的位置
*/
int partition(int A[], int n, int r){
int i = 0, j = n - 1;
int pivot = A[r];
while(i < j){
while(j > i && A[j] > pivot)
j--;
A[i] = A[j];
while(i < j && A[i] <= pivot)
i++;
A[j] = A[i];
}
A[i] = pivot;
return i;
}
下面是《算法导论》书中快速排序一节(p.95 机械工业出版社 中译版)给出的一种划分算法:
/**
* 将数组按照大小进行划分
* @param int [] A 源数组
* @param int n 源数组长度
* @param int r 枢轴元素位置
* @return int 划分后的枢轴元素的位置
*/
int partition(int A[], int n, int r){
int i, j = 0;
std::swap(A[r], A[n - 1]);
for(i = 0; i < n - 1; j++){
if(A[i] < A[n - 1]){
std::swap(A[i], A[j]);
j++;
}
}
std::swap(A[j], A[n - 1]);
return j;
}
这个算法其实是非常精妙的,正如上面一种算法讨论的,我们只要求两块分别满足都小于一个值、都大于一个值这一个要求,对于这两块内部的元素,不一定需要满足按照原来的次序放置。因此,可以先将A[r]
与最后一个元素A[n - 1]
进行交换(此时A[n - 1]
是中轴数pivot),然后从左到右扫描A
(令i
从0
到 n - 2
),我们假定左侧的子数组是小的,因此在整个循环过程中,数组A
被分为了四个部分
[Alower][Ahigher][Arest][pivot]
其中Alower
表示左侧的子数组,Ahigher
表示右侧的子数组,Arest
表示没有扫描到的元素构成的子数组,pivot
表示枢轴元素。
我们在划分完成后需要得到一个由三个部分构成的数组:
[Alower][pivot][Ahigher]
对比这两个构成,我们需要保存一个指针
j
,来指示最终的枢轴位置。在
事实上对于线性表的划分问题,推广到一般,就是将一个线性表分成两块,其分别满足不同的性质。比如,将一列整数按照奇偶性划分也是一样的。
快速排序算法的重要内容之一就是对数组的划分。
Summary
理解顺序存储结构的优点和缺点
使用顺序存储结构存储线性表,优点在于能够随机存储和很方便的查找;缺点在于插入和删除元素较慢(除非位于末尾。)。
了解基于顺序存储结构(数组)的一些算法设计的经典例子。
第一个例子展示了在删除多个元素时,后续元素可以一次性移动多位来提高效率;第二个例子演示了如何依据元素的某些性质将数组高效的划分成为两个子数组。
第一个例子的提示是,需要做好算法的分析工作,以免出现一些不满足要求的情况。
尤其需要注意的是第二个例子的设计思想:设法构造循环不变式,因为证明算法的正确,必然需要满足循环不变式。因此可以假定某个初始情况是满足目标性质的,然后根据违背性质的地方,设法进行相关操作,以维护这些性质。
除此之外对于数组的某些操作,有时候也可以使用 O(n) 的额外空间(辅助数组)来换取时间,这需要视具体情况而定。了解如何正确选用标准库、第三方库、框架中的容器实现算法
大多数编程语言的标准库、扩展、框架都有相应的容器类,例如,C++的
STL
中就含有vector
向量容器,其内部实现为顺序存储结构的数组(变长的向量使用动态扩容,如果可以预知长度可以事先使用reserve
方法分配空间)。
使用这些内部实现为顺序存储结构的容器类应该同样考虑到插入和删除效率低下(平均时间 O(n) )这些问题。
在某些语言中,数组的实现并非基于顺序存储的线性表(例如PHP
的array
对象是基于哈希表实现的),其使用方法与顺序结构的线性表有所不同,这时候未必满足了顺序存储结构的一些特点。