2.1 线性表的逻辑结构
2.1.1 线性表的定义
线性表简称表,是零个或多个具有相同类型的数据元素的有限序列。数据元素的个数定义为线性表的长度。长度等于零时称为空表,一个非空表通常记为:
L
=
(
a
1
,
a
2
,
…
…
,
a
n
)
L=(a_1,a_2,……,a_n)
L=(a1,a2,……,an)。
所以线性表元素的个数n(n>0)定义为线性表的长度,当n=0时,称为空表。
相同类型的含义:
一群同学排队买演唱会门票,每人限购一张,此时排队的人群是不是线性表?
是,对的。此时来了三个同学要插当中一个同学A的队,说同学A之前拿着的三个书;包就是用来占位的,书包也算是在排队。如果你是后面早已来排队的同学,你们愿不:愿意?肯定不愿意,书包怎么能算排队的人呢,如果这也算,我浑身上下的衣服裤子都在排队了。于是不让这三个人进来。
这里用线性表的定义来说,是什么理由?嗯,因为要相同类型的数据,书包根本不算是人,当然排队无效,三个人想不劳而获,自然遭到大家的谴责。看来大家的线性表学得都不错。
序列的含义:
首先它是一个序列。也就是说,元素之间是有顺序的,若元素存在多个,则第一个元素无前驱,最后一个元素无后继,其他每个元素都有且只有一个前驱和后继。如果一个小朋友去拉两个小朋友后面的衣服,那就不可以排成一队了;同样,如果一个小朋友后面的衣服,被两个甚至多个小朋友拉扯,这其实是在打架,而不是有序排然后,线性表强调是有限的,小朋友班级人数是有限的,元素个数当然也是有限的。事实上,在计算机中处理的对象都是有限的,那种无限的数列,只存在于数学的概念中。
如果用数学语言来进行定义。可如下:
若将线性表记为
(
a
1
,
…
,
a
i
−
1
,
a
i
,
a
i
+
1
,
…
,
a
n
)
(a_1,…,a_{i-1},a_i,a_{i+1},…,a_n)
(a1,…,ai−1,ai,ai+1,…,an),则表中
a
i
−
1
a_{i-1}
ai−1领先于
a
i
a_i
ai,
a
i
a_i
ai领先于
a
i
+
1
a_{i+1}
ai+1,称
a
i
−
1
a_{i-1}
ai−1是
a
i
a_i
ai的直接前驱元素,
a
i
+
1
a_{i+1}
ai+1是
a
i
a_i
ai的直接后继元素。当i=1,2,…,n-1时,
a
i
a_i
ai有且仅有一个直接后继,当i=2,3,…,n时,
a
i
a_i
ai有且仅有一个直接前驱。如下图所示:
2.1.2 线性表的抽象数据类型定义
线性表的抽象数据类型定义为:
ADT List
Data
线性表中的数据元素具有相同类型,相邻元素具有前驱和后继关系
Operation
InitList
前置条件:线性表不存在
输入:无
功能:线性表的初始化
输出: 无
后置条件:一个空的线性表
DestroyList
前置条件:线性表已存在
输入:无
功能:销毁线性表
输出:无
后置条件:释放线性表所占用的存储空间
Length
前置条件:线性表已存在
输入:无
功能:求线性表的长度
输出: 线性表中数据元素的个数
后置条件:线性表不变
Get
前置条件:线性表已存在
输入:元素的序号i
功能:在线性表中取序号为i的数据元素
输出:若序号合法,返回序号为i的元素值,否则抛出异常
后置条件:线性表不变
Locate
前置条件:线性表已存在
输入:数据元素x
功能:在线性表中查找值等于x的元素
输出:若查找成功,返回元素x在表中的序号,否则返回0
后置条件:线性表不变
Insert
前置条件:线性表已存在
输入:插入位置i;待插元素x
功能:在线性表的第i个位置处插入一个新元素x
输出:若插入不成功,抛出异常
后置条件:若插入成功,表中增加了一个新元素
Delete
前置条件:线性表已存在
输入:删除位置i
功能:删除线性表中的第i个元素
输出:若删除成功,返回被删元素,否则抛出异常
后置条件:若删除成功,表中减少了一个元素
Empty
前置条件:线性表已存在
输入:无
功能:判断线性表是否为空表
输出:若是空表,返回1,否则返回0
后置条件:线性表不变
PrintList
前置条件:线性表已存在
输入:无
功能:按位置的先后次序依次输出线性表中的元素
输出:线性表的各个数据元素
后置条件:线性表不变
endADT
对于不同的应用,线性表的基本操作是不同的,上述操作是最基本的,对于实际问题中涉及的关于线性表的更复杂操作,完全可以用这些基本操作的组合来实现。比如,要实现两个线性表集合A和B的并集操作。即要使得集合A=AUB。说白了,就是把存在集合B中但并不存在A中的数据元素插入到A中即可。
仔细分析一下这个操作,发现我们只要循环集合B中的每个元素,判断当前元素是否存在A中,若不存在,则插入到A中即可。思路应该是很容易想到的。
2.2 线性表的顺序存储结构及实现
2.2.1 线性表的顺序存储结构——顺序表
线性表的顺序存储结构称为顺序表。
说明:
1.顺序表是用一段地址连续的存储单元依次存储线性表的数据元素。
2.通常用一维数组来实现顺序表,C++中数组的下标从1开始。
3.线性表中第i个元素存储在数组(C++)中下标为i-1的位置。
4.设顺序表的每个元素占用c个存储单元,则第i个元素的存储地址为:
L
O
C
(
a
i
)
=
L
O
C
(
a
1
)
+
(
i
-
1
)
×
c
LOC(a_i)= LOC(a_1)+(i-1)×c
LOC(ai)=LOC(a1)+(i-1)×c
2.2.1 顺序表的实现
将线性表的抽象数据类型定义在顺序表存储结构下用C++的类实现。
const int MaxSize=100; //100只是示例性的数据,可以根据实际问题具体定义
template <class T> //定义模板类SeqList
class SeqList
{
public:
SeqList( ) {length=0;} //无参构造函数
SeqList(T a[ ], int n); //有参构造函数
~SeqList( ) { } //析构函数为空
int Length( ) {return length;} //求线性表的长度
T Get(int i); //按位查找,取线性表的第i个元素
int Locate(T x ); //按值查找,求线性表中值为x的元素序号
void Insert(int i, T x); //在线性表中第i个位置插入值为x的元素
T Delete(int i); //删除线性表的第i个元素
void PrintList( ); //遍历线性表,按序号依次输出各元素
private:
T data[MaxSize]; //存放数据元素的数组
int length; //线性表的长度
};
1.构造函数
顺序表类提供了两个构造函数。
无参构造函数SeqList( ):创建一个空的顺序表,即将顺序表的长度初始化为0;
有参构造函数SeqList(T a[ ], int n):创建一个长度为n的顺序表,其操作过程如图2-3所示。
2. 插入
几点说明:
1.插入后,元素
a
i
−
1
a_{i-1}
ai−1和
a
i
a_i
ai之间的逻辑关系发生了变化并且存储位置要反映这个变化。
2.插入时,若在表尾插入,直接插入即可,否则元素必须是从最后一个元素开始移动,直至将第i个元素后移为止,然后将新元素插入位置i处。(注意元素移动方向)
3.考虑插入时的边界条件:如果表满了,则引发上溢异常;如果元素的插入位置不合理,则引发位置异常。
3.删除
几点说明:
1.删除后元素
a
i
−
1
a_{i-1}
ai−1和
a
i
+
1
a_{i+1}
ai+1之间的逻辑关系发生了变化并且存储位置要反映这个变化。
2.删除时,若在表尾删除,直接删除即可,否则元素必须是从第i+1个元素(下标为i)开始移动,直至将最后一个元素前移为止。(注意元素移动方向)
3.在移动元素之前要取出被删元素。
4.考虑删除时的边界条件:如果表空,则引发下溢异常;如果元素的删除位置不合理,则引发位置异常。
时间性能分析:
先来看最好的情况,如果元素要插入到最后一个位置,或者删除最后一个元素,此时时间复杂度为
O
(
1
)
O(1)
O(1),因为不需要移动元素的,就如同来了一个新人要正常排队,当然是排在最后,如果此时他又不想排了,那么他一个人离开就好了,不影响任何人。
最坏的情况呢,如果元素要插入到第一个位置或者删除第一个元素,此时时间复杂度是多少呢?那就意味着要移动所有的元素向后或者向前,所以这个时间复杂度为
O
(
n
)
O(n)
O(n)。
至于平均的情况,由于元素插入到第i个位置,或删除第i个元素,需要移动n-i个元素。根据概率原理,每个位置插入或删除元素的可能性是相同的,也就说位置靠前,移动元素多,位置靠后,移动元素少。最终平均移动次数和最中间的那个元素的移动次数相等,为
n
−
1
2
\frac{n-1}{2}
2n−1。
我们前面讨论过时间复杂度的推导,可以得出,平均时间复杂度还是
O
(
n
)
O(n)
O(n)。这说明什么?线性表的顺序存储结构,在存、读数据时,不管是哪个位置,时间复杂度都是
O
(
1
)
O(1)
O(1);而插入或删除时,时间复杂度都是
O
(
n
)
O(n)
O(n)。这就说明,它比较适合元素个数不太变化,而更多是存取数据的应用。当然,它的优缺点还不只这些……
4. 查找
⑴ 按位查找
template <class T>
T SeqList::Get(int i)
{
if (i<1 && i>length) throw "查找位置非法";
else return data[i-1];
}
显然,按位查找算法的时间复杂度为O(1)。
⑵ 按值查找
template <class T>
int SeqList::Locate(T x)
{
for (i=0; i<length; i++)
if (data[i]==x) return i+1; //下标为i的元素等于x,返回其序号i+1
return 0; //退出循环,说明查找失败
}
按值查找算法的平均时间性能是O(n)。
顺序表小结:
·顺序表的优点:
⑴ 无需为表示表中元素之间的逻辑关系而增加额外的存储空间;
举例:
⑵ 可以快速地存取表中任一位置的元素。
举例:
·顺序表的缺点:
⑴ 插入和删除操作需移动大量元素。
原因:
⑵ 当线性表长度变化较大时,表的容量难以确定。
原因:
⑶ 造成存储空间的“碎片”。
原因:因为顺序表需要占用一整块连续的存储空间。如果多个不同大小的顺序表按左右顺序排列,释放掉中间的任意一个顺序表,并且它的左右相邻顺序表始终驻留。那么,所释放的那个顺序表所占的存储空间就会如一道缝隙留在原地。此时该缝隙最大只能存储缝隙大小的内容。若以小于这道缝隙大小的内容填充在这里,自然会形成一道更小的缝隙。而这种更小的缝隙是可以在程序运行时逐渐积累数量的,如果一个程序长时间运行,并且以一定频率产生这种缝隙,久而久之,这类缝隙越来越多,且所有缝隙无法被使用。这就是所谓的碎片。
2.3 线性表的链接存储结构及实现
2.3.1 线性表的链接存储结构——单链表
线性表的链接存储结构称为单链表。结点结构为:
data | next |
---|
其中:data:数据域,存储
next:指针域(亦称链域),存储
每个结点通过一个指针域将线性表的数据元素按其逻辑次序链接在一起,称为单链表。
用结构类型来描述单链表的结点:
template <class T>
struct Node
{
T data;
Node<T> *next; //此处<T>也可以省略
};
对于线性表来说,总得有个头有个尾,链表也不例外。我们把链表中第一个结点的存储位置叫做头指针,那么整个链表的存取就必须是从头指针开始进行了。之后的每一个结点,其实就是上一个的后继指针指向的位置。想象一下,最后一个结点,它的指针指向哪里?
最后一个,当然就意味着直接后继不存在了,所以我们规定,线性链表的最后一个结点指针为“空”(通常用NULL或“^”符号表示,如图所示)。
有时,我们为了更加方便地对链表进行操作,会在单链表的第一个结点前附设一个结点,称为头结点。头结点的数据域可以不存储任何信息,谁叫它是第一个呢,有这个特权。也可以存储如线性表的长度等附加信息,头结点的指针域存储指向第一个结点的指针。
头指针:
·头指针是指链表指向第一个结点的指针,若链表有头结点,则是指向头结点的指针
·头指针具有标识作用,所以常用头指针冠以链表的名字
·无论链表是否为空,头指针均不为空。头指针是链表的必要元素
头结点:
·头结点是为了操作的统一和方便而设立的,放在第一元素的结点之前,其数据域一般无意义(也可存放链表的长度)
·有了头结点,对在第一元素结点前插入结点和删除第一结点,其操作与其它结点的操作就统一了
·头结点不一定是链表必须要素
线性表
(
a
1
,
a
2
,
a
3
,
a
4
)
(a_1, a_2 ,a_3, a_4)
(a1,a2,a3,a4)的存储示意图如下:
2.3.2 单链表的实现
将线性表的抽象数据类型定义在单链表存储结构下用C++中的类实现。
template <class T>
class LinkList
{
public:
LinkList( ){first=new Node<T>; first->next=NULL;} //建立只有头结点的空链表
LinkList(T a[ ], int n); //建立有n个元素的单链表
~LinkList( ); //析构函数
int Length( ); //求单链表的长度
T Get(int i); //取单链表中第i个结点的元素值
int Locate(T x); //求单链表中值为x的元素序号
void Insert(int i, T x); //在单链表中第i个位置插入元素值为x的结点
T Delete(int i); //在单链表中删除第i个结点
void PrintList( ); //遍历单链表,按序号依次输出各元素
private:
Node<T> *first; //单链表的头指针
};
补充说明1:
正确区分指针变量、指针、指针所指结点和结点的值概念:
关于“指针”和“指针变量”比较严格的说法是这样的:
系统为每一个内存单元分配一个地址值,C/C++把这个地址值称为“指针”。如有int i=5;,存放变量i的内存单元的编号(地址)&i被称为指针。
“指针变量”则是存放前述“地址值”的变量,也可以表述为,“指针变量”是存放变量所占内存空间“首地址”的变量(因为一个变量通常要占用连续的多个字节空间)。比如在int i=5;后有一句int *p=&i;,就把i的指针&i赋给了int *型指针变量p,也就是说p中存入着&i。所以说指针变量是存放指针的变量。
有一个事实值得注意,那就是有不少资料和教科书并没有如上区分,而是认为“指针是指针变量的简称”,如对int *p=&i;的解释是:声明一个int *型指针p,并用变量i的地址初始化;而严格说应该是声明一个int *型指针变量p才对。所以有时看书要根据上下文理解实质,而不能过于拘泥于文字表述。
补充说明2:
这里要用到->,它和.有什么区别?
唯一的区别是->前面放的是指针,而.前面跟的是结构体变量,如已定义了一个结构体struct student,里面有一个int a;然后有一个结构体变量struct student stu及结构体变量指针struct student *p;且有p=&stu,那么p->a和stu.a表示同一个意思
->是指针引用数据用的,. 是普通的变量引用数据用的;
&p->id和p.id是不一样的,前者是取id的地址,后者是取id的值;
&p->id和&p.id是一样的,都是取id的地址,因为->和.的优先级都比&高,但这里面的p是不一样的,前者是指针变量
1. 按位查找
template <class T>
T LinkList::Get(int i)
{
p=first->next; j=1; //或p=first; j=0;
while (p && j<i)
{
p=p->next; //工作指针p后移
j++;
}
if (!p) throw "位置";
else return p->data;
}
算法的时间复杂度为O(n)。为什么?
说白了,就是从头开始找,直到第i个元素为止。由于这个算法的时间复杂度取决于i的位置,当i=1时,则不需遍历,第一个就取出数据了,而当i=n时则遍历n-1次才可以。因此最坏情况的时间复杂度是0(n)。
在单链表中,即使知道被访问结点的位置i(即序号),也不能像顺序表那样直接按序号访问。
2. 插入
template <class T>
void LinkList::Insert(int i, T x)
{
p=first ; j=0; //工作指针p初始化
while (p && j<i-1)
{
p=p->next; //工作指针p后移
j++;
}
if (!p) throw "位置";
else {
s=new Node<T>; s->data=x; //向内存申请一个结点s,其数据域为x
s->next=p->next; //将结点s插入到结点p之后
p->next=s;
}
}
比较顺序表和单链表实现插入算法。
分析一下刚才我们讲解的单链表插入和删除算法,我们发现,它们其实都是由两部分组成:第一部分就是遍历查找第i个元素;第二部分就是插入和删除元素。
从整个算法来说,我们很容易推导出:它们的时间复杂度都是
O
(
n
)
O(n)
O(n)。如果在我们不知道第i个元素的指针位置,单链表数据结构在插入和删除操作上,与线性表的顺序存储结构是没有太大优势的。但如果,我们希望从第i个位置,插入10个元素,对于顺序存储结构意味着,每一次插入都需要移动n-i个元素,每次都是
O
(
n
)
O(n)
O(n)。而单链表,我们只需要在第一次时,找到第i个位置的指针,此时为
O
(
n
)
O(n)
O(n),接下来只是简单地通过赋值移动指针而已,时间复杂度都是
O
(
1
)
O(1)
O(1)。显然,对于插入或删除数据越频繁的操作,单链表的效率优势就越是明显。
3. 构造函数
有参构造函数LinkList(T a[ ], int n),即生成一个有n个结点的单链表。有两种方法:头插法和尾插法。
⑴ 头插法
头插法是每次将新申请的结点插在头结点的后面,其执行过程如图所示。
⑵ 尾插法
尾插法就是每次将新申请的结点插在终端结点的后面,其执行过程如图所示。
1.比较头插法和尾插法
头插法相对简便,但插入的数据与插入的顺序相反;
尾插法操作相对复杂,但插入的数据与插入顺序相同。
2.分析两种算法的时间复杂度
每个结点插入的时间为O(1),设单链表长为n,则总的时间复杂度为O(n)。
3.它们各自适用范围
头插法比尾插法少了一个步骤,但是是反的,可以用来倒置链表
4.其它
4. 删除
template <class T>
T LinkList::Delete(int i)
{
p=first ; j=0; //工作指针p初始化
while (p && j<i-1) //查找第i-1个结点
{
p=p->next;
j++;
}
if (!p | | !p->next) throw "位置"; //结点p不存在或结点p的后继结点不存在
else {
q=p->next; x=q->data; //暂存被删结点
p->next=q->next; //摘链
delete q;
return x;
}
}
5. 析构函数
单链表类中的结点是用运算符new申请的,在释放单链表类的对象时无法自动释放这些结点的存储空间,所以,析构函数应将单链表中结点的存储空间释放。
2.4 顺序表和单链表的比较
2.4.1时间性能比较
所谓时间性能是指实现基于这种存储结构的基本运算(即算法)的时间复杂度。
存储分配方式:
·顺序存储结构用一段连续的存储单元依次存储线性表的数据元素
·单链表采用链式存储结构,用一组任意的存储单元存放线性表的元素
时间性能
·查找
·顺序存储结构
O
(
1
)
O(1)
O(1)
·单链表
O
(
n
)
O(n)
O(n)
·插入和删除
·顺序存储结构需要平均移动表长一半的元素,时间为
O
(
n
)
O(n)
O(n)
·单链表在线出某位置的指针后,插入和删除时间仅为
O
(
1
)
O(1)
O(1)
2.4.2空间性能比较
所谓空间性能是指某种存储结构所占用的存储空间的大小。
空间性能
·顺序存储结构需要预分配存储空间,分大了,浪费,分小了易发生上溢
·单链表不需要分配存储空间,只要有就可以分配,元素个数也不受限制
2.5 线性表的其它存储方法
2.5.1 循环链表
在单链表中,如果将终端结点的指针域由空指针改为指向头结点,这种头尾相接的单链表称为单循环链表,简称循环链表。为了使空表和非空表的处理一致,通常也附设一个头结点。
用头指针指示的循环链表,找到开始结点的时间是
O
(
1
)
O(1)
O(1),找到终端结点的时间是
O
(
n
)
O(n)
O(n)。若我们将头指针指示的循环链表改用指向终端结点的尾指针来指示循环链表,如图所示,则查找开始结点和终端结点的时间都是
O
(
1
)
O(1)
O(1),实际中多采用尾指针指示的循环链表。
从上图中可以看到,终端结点用尾指针rear指示,则查找终端结点是0(1],而开始结点,其实就是rear->next>next,其时间复杂也为0(1]。
举个程序的例子,要将两个循环链表合并成一个表时,有了尾指针就非常简单了。比如下面的这两个循环链表,它们的尾指针分别是rearA和rearB,如图所示:
要想把它们合并,只需要如下的操作即可,如图所示。
2.5.2 双链表
我们在单链表中,有了next 指针,这就使得我们要查找下一结点的时间复杂度为
O
(
1
)
O(1)
O(1)。可是如果我们要查找的是上一结点的话,那最坏的时间复杂度就是
O
(
n
)
O(n)
O(n)了,因为我们每次都要从头开始遍历查找。
为了克服单向性这一缺点,我们的老科学家们,设计出了双向链表。双向链表(double linked list)是在单链表的每个结点中,再设置一个指向其前驱结点的指针域。所以在双向链表中的结点都有两个指针域,一个指向直接后继,另一个指向直接前驱。
结点结构:
prior | data | next |
---|
其中,data:数据域,存放数据元素;
prior:前驱指针域,存放该结点的前驱结点的地址;
next:后继指针域,存放该结点的后继结点的地址。
可以用C++中的结构类型描述双链表的结点。
template <class T>
struct DulNode
{
T data;
DulNode<T> *prior, *next;
};
由于这是双向链表,那么对于链表中的某一个结点p,它的后继的前驱是谁?当然还是它自己。它的前驱的后继自然也是它自己,设指针p指向双循环链表中的某一结点,则双循环链表具有如下的对称性:
(p->prior)->next = p = (p->next)->prior
1. 插入
在结点p的后面插入一个新结点s,需要修改四个指针:
我们现在假设存储元素e的结点为s,要实现将结点s插入到结点p和p->next之间需要下面几步,如下图所示。
s->prior=p:/把p赋值给s的前驱,如图中①/
s->next=p->next;/把p->next赋值给s的后继,如图中②/
p->next->prior=s;/把s赋值给p->next的前驱,如图中③/
p->next=s;/把s赋值给p的后继,如图中④/
2. 删除
设指针p指向待删除结点,删除操作可通过下述两条语句完成:
① (p->prior)->next=p->next;
② (p->next)->prior=p->prior;
2.5.3 静态链表
后来的面向对象语言,如Java、C#等,虽不使用指针,但因为启用了对象引用机制,从某种角度也间接实现了指针的某些作用。但对于一些语言,如Basic、Fortran等早期的编程高级语言,由于没有指针,链表结构按照前面我们的讲法,它就没法实现了。怎么办呢?
有人就想出来用数组来代替指针,来描述单链表。真是不得不佩服他们的智慧,我们来看看他是怎么做到的。
静态链表是用数组来描述单链表,用数组元素的下标来模拟单链表的指针。静态链表的每个数组元素由两个域构成:data域存放数据元素,next域(也称游标)存放该元素的后继元素所在的数组下标。由于它是利用数组定义的,属于静态存储分配,因此叫做静态链表。
2.5.4 间接寻址
间接寻址是将数组和指针结合起来的一种方法,它将数组中存储数据元素的单元改为存储指向该元素的指针,如图2-24所示。
参考书目:
大话数据结构
数据结构——从概念到C++实现(第2版)
数据结构(C++版)教师用书