【数据结构】线性表
目录
1.1 线性表的概念
1.1.1 线性表的抽象数据类型
1.1.2 线性表的存储结构
1.1.3 线性表运算分类
1.2 顺序表
1.2.1 顺序表的类定义
1.2.2 顺序表的运算实现
1. 顺序表的检索
2. 顺序表的插入
3. 顺序表的删除
1.3 链表
1.3.1 单链表
1. 链表的检索
2. 链表的插入和删除
1.3.2 双链表
1.3.3 循环链表
1.4 线性表实现方法的比较
本章小结
线性结构是最简单且应用最广泛的一种数据结构,其基本特点是结构中的各元素之间满足线性关系。所谓线性关系是指数据元素之间存在着一对一的关系,即存在唯一的开始元素和唯一的终止元素,除此之外的其他元素均有且仅有一个唯一的直接前驱元素和一个唯一的直接后继元素。按此关系可以把结构中的所有元素排成一个线性序列,故称为线性表。26个英文字母组成的字母表(A,B,C,…,Z)就是一个线性表,表中每个元素是由单个字母字符组成的数据项一副扑克牌的点数(2,3,…,10,J,O,K,A)也是一个线性表,其中数据元素是每张牌的点数。
线性结构具有如下的结构特点:
(1)均匀性:虽然不同线性表的数据元素可以是各种各样的,但在同一线性表中的各数据元素必定具有相同的数据类型和长度。
(2)有序性:各数据元素在线性表中都有自己的位置,且数据元素之间的相对位置是线性的。
根据应用的特点,线性结构在不同的应用中会采取不同的存储结构和实现方法。因此,线性结构在不同场合具有不同的称谓,例如顺序表、链表、串、栈、顺序文件等;结构中的元素也相应地采取不同的称呼,例如表目、结点、或记录等。一般情况下,这些称谓均可作为同义词来互通使用。
根据结构中元素的复杂程度,线性结构可分为简单线性结构和高级线性结构。简单线性结构包括通常使用的顺序表、队列、散列表等,而多维数组、广义表则属于高级线性结构。本章将首先从线性表的基本概念、存储表示、基本运算及其实现等方面讨论线性表。
1.1 线性表的概念
1.1.1 线性表的抽象数据类型
线性表( linear list)是由称为元素(element)的数据项组成的一种有限且有序的序列,这些元素也可称为结点或表目。
若用第二元组B=(K,R)来表示线性表,则有
其中,结点集合K中的元素个数n称为线性表的长度,当n=0时,线性表被称为空表,当n>0时称为非空表。k_0称为开始结点或表首,k_{n-1}称为终止结点或表尾。
线性关系r刻画元素间的前驱或后继关系:k_i称为…的前驱,k 称为k_{i+1}的后继。因此,线性关系也称为前驱关系或后继关系。直观地讲,线性表的所有结点可以按关系r排列成一个线性序列,结点h是结点k的前驱,结点k是结点k,的前驱,……,结点k-是结点k-的前驱等。对于后继而言,也同样成立。
从线性表的定义可知其逻辑特征:结点集合K中的每个结点在关系,上最多只有一个前驱结点和一个后继结点。线性表的关系,具有反对称性和传递性。
空表意味着线性表中不包含任何元素。而一个非空线性表(h,h,…,h.)中的数据元素k(0≤i≤n-1)是一个抽象的符号,在不同情况下具有不同的含义。虽然概念上并不反对线性表具有不同类型的数据元素(例如广义表),但本章讨论的简单线性表约定所有元素都具有相同的数据类型,并需事先给出其类型定义、元素或是简单数据类型或是复合结构类型。
在线性表上可以实施的操作(或运算)依赖于具体的应用,但一般不外乎两大类,一类是对整个表的操作,另一类是对表中元素的操作。前者用于创建或置空一个线性表、合并两个线性表、判断线性表是否为空或为满:后者则用于查找线性表中满足一定条件的元素,在线性表中插人或删除指定的元素等。
作为一个抽象数据类型而言,线性表由其数据元素及实施在其上的运算集两部分构成。根据抽象和封装的原则,对线性表的操作只可通过其提供的运算集合中的运算来进行下面用C++类模板的方法,给出线性表类(名字为list,模板参数为元素类型 T)的一个抽象数据类型说明。其中,每一个运算都用函数接口指出其输人/输出参数及其返回值类型。
【代码1.1】线性表的抽象数据类型定义
template <class T>
class List {void clear(); //置空线性表bool isEmpty(); //线性表为空时,返回truebool append(const T value); //在表尾添加一个元素value,表的长度增1bool insert(const int p, const T value); //在位置p上插人一个元素value,表的长度增1bool delete(const int p); //删除位置p上的元素,表的长度减1bool getValue(const int p, T& value); //把位置p的元素值返回到变量value中bool setValue(const int p, const T value); //用value修改位置p的元素值bool getPos(int &p, const T value); //把值为value的元素所在位置返回到变量p中
}
线性表抽象数据类型并不是唯一的。针对具体应用的不同要求,线性表的抽象数据类型可以适当增删某些运算,并且可由这些基本的运算来构建更加复杂的运算。
在有些应用中,线性表位置(position)的获取非常重要,可以增加以下这些获取位置的运算函数:
bool setPos(int pos); //设置当前下标bool setStart(); //把当前下标移动到表头bool setEnd(); //把当前下标移动到表尾bool prev(); //当前下标向前左移一位bool next(); //当前下标向右右移一位
当前位置的获取有助于对线性表元素依次进行处理,例如下面的代码段中,每个元素依次被传给函数 DoSomething 进行相应的处理。
List <T> MyList;
bool b = MyList.setStart();while(b) {DoSomething(Mylist.getValue());b = MyList.next();
}
线性表运算的具体实现与它在计算机中的物理存储结构密切相关,运算的效率也与存储结构相关。下面介绍顺序和链接这两种常用的线性表存储结构,
1.1.2 线性表的存储结构
线性表的存储结构是指为它所开辟的计算机存储空间以及所采用的程序实现方法,本质上是逻辑结构到存储空间的映射。不仅要为结点集合到存储器单元建立一个映射,同时还要为元素之间的线性关系到相应的存储单元地址间的关系建立映射。
线性表的存储结构主要有两类:
(1)定长的顺序存储结构,简称顺序表。程序中通过创建数组来建立这种存储结构,其主要特点是为线性表分配一块连续的存储空间,元素顺序地存储在这些地址连续的空间中,以“物理位置相邻”来表示线性表中数据元素之间的关系。定长存储结构的不足之处是限制了线性表的长度变化。
(2)变长的线性存储结构,也称链接式存储结构,简称链表。链接式存储结构使用指针来表示元素之间的线性关系,利用线性表的前驱和后继关系将各个元素用指针链接起来。变长的线性存储结构对线性表长度不加限制,在需要加入新元素时,可以方便地申请空间并链接到合适的位置上。
线性表的这两种存储结构及其运算的具体实现将在1.2节和1.3节中详细介绍
1.1.3 线性表运算分类
按照特性,线性表的运算可以划分为以下5类:
(1)创建线性表的一个实例。
(2)线性表的析构函数~list(),消除线性表实例并释放所占空间。
(3)获取有关当前线性表的信息,包括由内容寻找位置、由位置读取元素内容等,不改变线性表的内容。
(4)访问线性表并改变线性表的内容或结构:例如更新指定元素内容、添加元素、删除元素清空线性表等。
(5)辅助管理操作,例如求表的当前长度等。
1.2 顺序表
按顺序方式存储的线性表称为顺序表(array-based list),又称为向量(vector),通过创建数组来建立。顺序表中的每个元素按其顺序有唯一的索引值,又称下标值,可以用来方便地访问元素内容。
1.2.1 顺序表的类定义
顺序表的示意图如下图所示, 其中图(a)为线性表的逻辑结构,图(b)为线性表的顺序存储结构。
假设每个元素占用工个存储单元,并以第一个单元的存储地址代表数据元素位置。设顺序表的开始结点k。的存储位置记为b=loc(k),称为顺序表的首地址或基地址,下标为i的元素;的存储位置则为
由上述公式可知,每个元素的存储位置都与起始位置相差一个与位序成正比的常数。只要确定了基地址,线性表中任一元素的地址都可以方便地计算出来,从而达到随机存取的效果,因此顺序表是一种随机存取的存储结构。顺序表中两个物理位置相邻的元素在逻辑上互为前驱和后继,因此元素间的物理相邻关系表示了它们逻辑上的相邻关系
根据1.1.1节给出的线性表抽象数据类型,算法1.2用C++类描述线性表的一种顺序实现。根据顺序表的特点,函数getValue()的实现非常简单。
【代码1.2】一种顺序表的类定义
template <classT> //假定顺序表的元素类型为T
class arrList : public List<T> { //顺序表,向量
private: //线性表的取值类型和取值空间 T *aList ; //私有变量,存储顺序表的实例int maxSize; //私有变量,顺序表实例的最大长度int curLen; //私有变量,顺序表实例的当前长度int position; //私有变量,当前处理位置
public: //顺序表的运算集arrList(const int size) { //创建一个新的顺序表,参数为表实例的最大长度maxSize = size;aList = new T[maxSize];curLen = position = 0;}~arrList() { //析构函数,用于消除该表实例delete [ ] aList;}void clear() { //将顺序表存储的内容清除,成为空表delete [ ] aList;curLen = position = 0;aList = new T[maxSize];}int length(); //返回此顺序表的当前实际长度bool append(const T value); //在表尾添加一个元素value,表的长度增1bool insert(const int p, const T value); //在位置p上插人一个元素value,表的长度增1bool delete(const int p); //删除位置p上的元素,表的长度减1bool setValue(const int p, constTvalue); //用value 修改位置p的元素值bool getValue(const int p, T& value); //把位置p的元素值返回到变量value中bool getPos(int & p, const T value); //查找值为value的元素,并返回第1次出现的位置
};
1.2.2 顺序表的运算实现
下面介绍基于数组的顺序表检索、插入和删除等运算
1. 顺序表的检索
顺序表上的检索运算可以分为按位置的查找和按内容的查找两类。前者在顺序表中的实现非常简单,根据1.2.1节所示的计算公式可以直接计算其存储地址,可在常数时间内存取该元素。后者是指在顺序表中寻找满足某种条件,诸如值为value的元素的所在位置。该运算的基本算法是将value与顺序表中的元素依次进行比较,如果某次比较相等,则该元素即为所要查找的元素,并返回true,否则继续比较,直到所有元素都与 value 进行过比较但不匹配,表明此时顺序表不存在元素值为value的元素,返回false。
【算法1.3】在顺序表查找值为 value 的元素是位置
//在表中查找值为value的元素,
//成功则返回true,并将该元素所在的下标记录在参数p中,失败则返回false
template <class T> //假定顺序表的元素类型为T
bool arrList<T> :: getPos(int &p, const T value) {int i; //元素下标for(i = 0; i < n; i++) //依次比较if(value == aList[i]) { //下标为i的元素与 value 相等p = i; //将下标由参数p返回return true;}return false; //顺序表没有元素值为value的元素
}
此算法的执行时间主要体现在元素的比较次数中,即for循环上。最好的情况是第1个元素的值即为 value,此时只需比较一次即可;最差的情况则是表中没有值为 value 的元素,这时需要比较n次;一般情况下,假设 value出现在每个位置的概率相同,均为p=1/n,则平均的比较时间为
即等概率情况下,检索算法需要寻找表中一半的元素,平均的时间开销为 0(n)。
2. 顺序表的插入
插入操作将改变顺序表的内容,因此必须满足一定的限制条件:除了涉及被更新的那个元素之外,其他线性关系的相对顺序应该保持不变。为此,需要对顺序表实施一系列的元素移动操作来维护逻辑的和存储的线性关系。
另外,由于顺序表是长度固定的线性结构,因此对其进行插人运算时还需要检查顺序表中实际元素的个数,以免因为插人而发生溢出现象(即超过所允许的最大长度maxSize 的值)。算法1.4在顺序表的位置p上插入一个值为value的元素,下图所示为相应的示意图。
【算法1.4】 在顺序表的某位置上插入元素
//设元素的类型为T,aList是存储顺序表的数组,maxSize是其最大长度;
//p为新元素value的插入位置,插人成功则返回true,否则返回 false
template <classT> //假定顺序表的元素类型为T
bool arrList<T> :: insert(const int p, const T value) {int i;if(curLen >= maxSize) { //检查顺序表是否溢出cout << "The list is overflow" << endl;return false;} if(p < 0 || p > curLen) { //检查插人位置是否合法 cout << "Insertion point is illegal" << endl;return false;}for(i = curLen; i > p; i--)aList[i] = aList[i-1]; //从表尾curLen-1起向右移动直到paList[p] = value; //位置p处插入新元素curLen++; //表的实际长度增1return true;
}
此算法的执行时间主要消耗在元素的移动操作上。最好的情况下,插人位置为当前线性表的最后,此时移动次数为0;最差情况下插入位置为线性表的最前面,此时所有个元素均需移动;一般情况下,插人位置为i时需要移动其后的n-i个元素,假设在各个位置插入的概率相等均为p=1/(n+1),则平均移动元素次数为
即等概率的情况下插人算法平均需要移动顺序表中的一半元素,总时间开销与表中元素个数成正比,为0(n)。
3. 顺序表的删除
删除运算需要检查事先检查顺序表是否为空表,只在非空表时才能进行删除。算法1.5将删除顺序表中位置p上的元素,位置p+1至curLen-1的元素顺序左移,即下图中的元素k,至元素h-逐次顺序左移一位。
【算法1.5】删除顺序表中给定位置上的元素。
//设元素的类型为T;aList是存储顺序表的数组;p为即将删除元素的位置
//删除成功则返回 true,否则返回 false
template <class T> //顺序表的元素类型为T
bool arrList<T> :: delete(const int p) {int i;if(curLen <= 0) { //检查顺序表是否为空cout << "No element to delete \n" << endl;return false;}if(p < 0 || p > curLen-1) { //检查删除位置是否合法cout << "deletion is illegal\n" << endl;return false;}for(i = p; i < curLen-l; i++)aList[i] = aList[i+1]; //从位置p开始每个元素左移直到curLencurLen--; //表的实际长度减1return true;
}
1.3 链表
尽管顺序表是一种非常有用的数据结构,但其至少存在以下两方面的局限:
(1)改变顺序表的大小需要重新创建一个新的顺序表并把原有的数据都复制过去。
(2)因为逻辑关系是通过物理位置的相邻来表示的,增删元素平均需要移动一半的元素
为了克服顺序表无法改变长度的缺点,并满足许多应用经常插人新结点或删除结点的需要,产生了链表(Iinked list)这样的数据结构。
链表可以看成一组既存储数据又存储相互连接信息的结点集合。这样,各结点不必如顺序表那样存放在连续的存储空间中,可以散放在存储空间的各处,而由称为指针的域来按照线性表的后继关系链接结点。链表的特点是可以动态地申请内存空间,根据线性表元素的数目动态地改变其所需要的存储空间。在插人元素时申请新的存储空间,删除元素时释放其占有的存储空间。
链接存储是最常用的存储方法之一,它不仅可以用来表示线性表,也常用于其他非线性的数据结构。例如,随后几章要讨论的树结构和图结构,其中很多应用都使用结点的链接方式。本章主要讨论几种用于线性表的链接存储结构:单链表、双链表、循环链表等,统称为链表。本节在讨论链表的同时,还涉及了动态存储管理的基本概念和方法。注意,链接存储结构中所涉及的指针变量以及它们涉及的申请和释放计算机内存空间的方法。
1.3.1 单链表
在链表结构中,如果每个结点只包含指向其后继的指针,则称为单链表(singlylinked list)。下图所示为一个单链表的存储示例。
由上图可知,单链表是通过指针把它的一串内存结点链接成一个链,因此这些结点在内存空间中地址不必两两相邻。单链表的存储结点由两部分组成:一部分存放对用户而言非常重要的结点数据,称做 data域, 如上图中的10、8、50和16:另一部分作为辅助成分,存放指向后继结点的指针域next,如上图中的结点10中存放其后继结点8的地址。对于没有后继结点的终止结点而言,其 next 域值为空指针 NULL(在图中用“\”表示),如图2.4中结点16的 next 域。
【代码1.6】单链表的结点定义
template <class T> class Link {
public:T data; //用于保存结点元素的内容Link <T> * next; //指向后继结点的指针Link(const T info, const Link <T> * nextValue = NULL) { //具有两个参数的Link构造函数data = info;next = nextValue;}Link(const Link <T> * nextValue) { //具有一个参数的 Link 构造函数next = nextValue;}
};
由于单链表中的结点是一个独立的对象,故将其定义为一个独立的类,以便于复用。代码1.6给出了单链表结点类型 Link的定义。注意,Link是由其自身来定义的,因为其中的next 域指向正在定义的类型本身,这种类型称为自引用型。鉴于第3章的栈和队列也要用到Link类故在此将其数据成员声明为公有的。
用一个指向表首的变量head存放指向单链表开始结点的指针。由于单链表中各个结点的存储地址并不连续,因此访问任何结点都只能从头指针开始沿着结点的next城来进行。例如,在上图中,head ->next->data=8,而 head->next->next->data=50。一个线性表的元素数目越多,则此链长。为了加速对链表尾端的访问,特别是针对尾附函数append()这样在表尾直接添加元素的操作需求,往往会使用另一个表尾变量tai来存放指向单链表尾结点的指针,以方便操作,如下图所示。对于链表的访问只可通过头、尾指针来进行。增加了尾指针后append()即可在常数时间内完成。
代码1.7给出了这种具有头和尾结点的单链表的抽象数据类型。根据链表的实际应用特点,在其中增加了一个返回链表长度的函数length()和一个返回指向给定结点指针的私有函数setPos(const int p)。在某些定义了当前位置的应用中,可以使用函数 setPos()把某个指定位置设置成当前位置。
【代码1.7】
template <class T> class inkList : public List <T> {
private:Link<T> *head, *tail; //单链表的头、尾指针Link<T> *setPos(const int p); //返回线性表指向第P个元素的指针值
public:InkList(int s); //构造函数~lnkList(); //析构函数bool isEmpty(); //判断链表是否为空void clear(); //将链表存储的内容清除,成为空表int length(); //返回此链表的当前实际长度bool append(cosnt T value); //在表尾添加一个元素value,表的长度增1bool insert(cosnt int p, cosnt T value); //在位置p上插人一个元素value,表的长度增1bool delete(cosnt int p); //删除位置p上的元素,表的长度减1bool getValue(cosnt int p, T& value); //返回位置p的元素值bool getPos(int &p, const T value); //查找值为value的元素,并返回第1次出现的位置
}
为了操作便利,在实际组织单链表时,通常会引人一个称为头结点(header node)的特殊结点作为表头,以避免对空表的处理,从而避免对边界情况做特别考虑。作为表中的一个虚结点,这个头结点的值被忽略,不被看做表中的实际元素。图2.6(a)所示为一个带有头结点的空表,图2.6(b)则为一个典型的带有头结点的单链表。
引人头结点有利于对空链表进行操作或在链表头进行操作等特殊情况的处理,尤其是插入和删除操作对边界的处理。否则,在表为空时进行插入操作或者删除表中唯一的元素时,均需要特殊的边界处理。
算法1.8给出了这种带有头结点的单链表的构造函数和析构函数。为了与顺序表的接口保持一致,链表的构造函数引人了一个表示表的最小长度的参数。这个参数对链表是不必要的,因此在实现中被忽略掉。
【算法1.8】带有头结点的单链表构造函数与析构函数
template <class T>
lnkList :: lnkList(int defSize) {head = tail = new Link<T>; //此处采用classLink的构造丽数来初始化head和tail
}
template <class T>
lnkList :: ~lnkList() {Link <T> * tmp;while(head != NULL) {tmp = head;head = head -> next;delete tmp;}
}
下面仍以检索、插入和删除这3个最常用的运算为例来介绍单链表的实现方法。
1. 链表的检索
由于地址空间的不连续,单链表无法像顺序表那样直接通过结点位置来定位其地址,而是需要从头指针 head所指的首结点开始沿next域,逐个结点地进行访问。
根据链表的特点,只要有指向一个结点的指针,便可通过该指针访问此结点。换育之,按位置检索只要返回指向该位置的指针即可。算法1.9给出了在链表中查找第i个结点的代码,返回值为指向找到的结点的指针。其中,链表中第i个结点是按照C/C++的数组下标编号规则从0到n-1,头结点的编号为-1。当单链表实际长度小于给定的i时,返回NULL,当i为-1时返回指向头结点的指针。
【算法1.9】寻找链表的第 i 个结点
Template <class T> //线性表的元素类型为T
Link <T> * lnkList <T> :: setPos(int i) {int count = 0;if(i == -1) //i为-1则定位到头结点return head;Link <T> *p = new Link<T>(head -> next); //循链定位,若i为0则定位到第一个结点while(p != NULL && count < i) {p = p -> next;count ++;};return p; //指向第i结点,i-0,1,…,当链表中结点数小于i时返回NULL
}
由算法1.9可知,在链表上基于位置检索需要从链表头开始循链移动,直到找到第i个位置,所以平均需要 0(n)的时间。
按内容查找的方法与此类似,也是循链比较,只是比较的对象为数据域而已。因此,无论是按内容还是按位置来检索链表,都只能采取顺序访问的方式,无法进行随机存取。
2. 链表的插入和删除
由于单链表结点之间的前驱后继关系由指针来表示,因此在插人或删除结点时,维护结点之间的逻辑关系只需要改变相关结点的next域,而不必像顺序表那样移动一系列存储单元的内容。
在单链表中插人和删除结点还牵涉到存储管理的问题。在单链表中插人一个新结点时,可以使用 new命令为新结点开辟存储空间。与new对应,可使用delete 命令释放从单链表中删除的结点所占用的空间,否则这些被占用的空间会变成存储空间中无法利用的垃圾,影响再利用,new 和 delete是C++程序语言为动态存储管理提供的两个重要命令,C语言标准函数库中提供的malloc()和free()函数具有同样的功能。
向链表中插人一个新元素的操作,具体包括创建一个新结点(并赋值)和修改相关结点的链接信息以维护原有的前驱后继关系。相关结点涉及新插入结点的前驱结点以及新结点自身,需要把新结点的指针域设置为其前驱的指针值,而前驱结点的指针应该指向新结点。由于单链表没有指向前驱的指针,因此在第i个位置插人结点时,必须先获得位置i-1的指针,以便保持插人后链接正确。对于n个结点的线性表,插人点可以有n+1个:i=0表示插人在表头,setPos(i-1)将返回头结点 head,新结点直接链接到 bead后,成为表中第一个结点;i=n表示在表尾插人,与append()功能相同。算法1.10给出了单链表插人运算的一种实现方法,相应的插入过程如图 2.7 所示。
【算法1.10】插入单链表的第 i 个结点
template <class T> //线性表的元素类型为T
bool lnkList<T> :: insert(const int i, const T value) {Link<T> *p, *q;if((p = setPos(i-1)) == NULL) { //p是第i个结点的前驱cout << "非法插人点" << endl;return false;}q = new Link<T>(value, p->next);p -> next = q;if(p == tail) //插人点在链尾,插人结点成为新的链尾tail = q;return true;
}
与插人操作相同,从单链表中删除一个结点也需要修改被删结点前驱的指针域来维护结点间的线性关系,同时要释放被删结点所占用的内存,以免“丢失”。算法1.11给出了删除单链表第i个结点的代码,相应的操作过程如图2.8所示。
【算法1.11】单链表的删除算法
template <classT> //线性表的元素类型为T
bool lnkList<T> :: delete(const int i) {Link<T> *p,* q;if((p = setPos(i-1)) == NULL || p == tail) { //待删结点不存在,即给定的i大于//当前链中元素个数 cout << "非法删除点" < <endl;return false;}q = p -> next; //q是真正待删结点if(q == tail) { //待删结点为尾结点,则修改尾指针tail = p;p -> next = NULL;delete q;}else if(q != NULL) { //删除结点q并修改链指针p -> next = q -> next;delete q;}return true;
}
从算法1.10和算法1.11可以看到,尽管插入(删除)操作本身可在常数时间内完成结点的创建(释放)和链接信息的修改,但在位置i进行插入删除操作时,需要先定位到位置i-1的结点。而定位操作的平均时间代价为0(n),这也是在某些应用中需要在抽象数据类型中增加一个表示当前位置的成员的原因,因为通常插人和删除均在当前位置上进行。对于前面定义线性表抽象数据类型时所讨论的位置运算问题,具体到用单链表方式实现时,通常保持当前位置的前一个指针以方便操作。
1.3.2 双链表
单链表的主要不足之处在于其指针域仅指向其后继结点,因此从一个结点不能有效地找到其前驱,而必须从表首开始顺着next域逐一査找。这对于长链表而言,代价相当可观。为此,引人双链表(double linked list)结构,其基本思路是在每个结点中再增加一个指向前驱的指针,形成如图2.9所示的双链表,其中带有斜线阴影的结点表示头结点。
图2.9中变量 head 用于指向表首结点,变量tai用于指向表尾结点。图2.10给出了相应的结点图示,其中next表示指向后继的指针,prev表示指向前驱的指针。线性表的链式实现究竟采用单链表还是双链表对 List 类的用户而言应该是透明的。下面代码2.12给出了使用双链表的一个 Link 类的实现。
【代码1.12】双链表的结点定义和实现
template <class T> class Link {
public:T data; //用于保存结点元素的内容Link<T> *next; //指向后继结点的指针Link<T> *prev; //指向前驱结点的指针Link(const T info, Link <T> *preValue = NULL, Link<T> * nextValue = NULL) {//构造函数:值和前后指针data = info;next = nextValue;prev = preValue;}Link(Link<T> *preValue = NULL, Link<T> *nextValue = NULL { //给定前后指针的构造函数next = nextValue;prev = preValue;}
}
与单链表上的对应操作相比,双链表要稍微复杂些,因为双链表需要维护两个链。下面以插人和删除两个操作为例进行讨论。
与单链表不同,若要删除双链表中的一个结点,则不仅要修改该结点前驱的nex域,还要修改该结点后继的 prev域。例如,如果要删除指针变量p所指的结点,需要通过下述的操作来维护前驱和后继两条链。
p-> prev -> next = p -> next;
p-> next -> prev =p -> prev;
然后把变量p的前驱和后继置空,再释放p所指空间即可。
p -> next = NULL:
p -> prev = NULL;
delete p;
删除操作的示意图如图 2.11所示。
同样,双链表中要在p所指结点后插人一个新结点q,具体操作步骤如下
(1)执行newq开辟结点空间。
(2)填写新结点的数据域信息。
(3)填写新结点在链表中的链接关系,即
g -> prev =p;
g-> next=p-> next;
(4)修改p所指结点及其后继结点在新结点插入后的链接信息,即把新结点的地址填入原p所指结点的next域以及新结点后继的prev域回指新结点本身,即
p-> next = q;
g -> next -> prev =q;
插人过程的示意图如图2.12 所示,
尽管双链表的空间开销比单链表稍多,但可在0(1)时间内找到给定元素的前驱.
1.3.3 循环链表
某些情况需要把结点组成环形链表。例如,多个进程在一段时间内访问同样的资源,为了保证每一个进程可以公平地分享这个资源,可以把这些进程组织在如图 2.13所示的称为循环链表(circularly linked lis)的结构中。可以通过指针 current访问该结构,current 指向的结点即为将要激活的进程。随着current指针的移动,可以激活每一个进程。
只要对原有的单链表形式稍做改变,即把最后一个结点的指针设置成指向表首的结点,就可以形成一个如图2.14所示的循环链表。不增加额外的存储花销,就能给很多操作带来方便,因为从循环表中任一结点出发都能访问到表中其他任意结点。
实现时,循环链表往往只需要指向表尾的指针 tail,而无须指向头结点的head 指针,因为 tai的后继即为 head 所指结点。这样,头尾结点都可在常数时间内找到。类似地,将双链表的首尾结点链接起来就可以得到如图2.15所示的循环双链表.
1.4 线性表实现方法的比较
顺序表是最简单的数据组织方法,因此大多数程序设计语言都提供了数组这种机制来实现顺序表。除具有易用、空间开销较小等特点之外,顺序表还提供了对任意元素进行随机访问的能力,适合于二分搜索及快速排序等运算,是存储静态数据的理想选择。
除了适用于频繁插入或删除内部元素的线性表之外,链表还适合于管理那些长度经常变化或事先无法确定长度的线性表。
作为基本的数据结构,线性表有着广泛的应用。例如,存储管理本质上就是利用线性表管理可利用空间。而且,线性表还可以作为基本成分构建复杂的数据结构,例如散列方法就是把顺序表和链表结合起来的一种数据结构。在实际应用中,具体采用何种实现方法,取决于应用线性表数据元素的统计特征和操作特点。下面列出几个在取舍时值得注意的因素。
1.不要使用顺序表的场合
线性表中经常要插人/删除内部元素时,不宜使用顺序表,因为顺序表的插人/删除操作平均情况下需要移动表中一半的元素。此外,无法确定线性表长度的最大值时,也不宜采用顺序表。
2.不要使用链表的场合
经常对线性表进行按位置的访问,而且按位读操作比插人/删除操作频繁时,不宜使用链表因为顺链表扫描浏览比按顺序表下标读元素要费时。此外,指针本身的存储开销也需要考虑,如果与结点内容所占空间相比,指针所占的比例较大时(超过1:1),应该慎重选择。
本章小结
本章在介绍线性表基本概念和抽象数据类型的基础上,描述了线性表的两种实现方法
线性结构是最简单且最常用的一种数据结构,其基本特点是结构中的元素之间满足线性关系,按这个关系可以把所有元素排成一个线性序列。线性表是由若干数据元素组成的有限序列是一种比较简单的数据结构。线性表通常有顺序和链式两种存储方式,在两种存储结构上线性表各运算的实现效率各有千秋。
顺序表是组织数据的最简单方法,具有易用、空间开销较小以及对元素随机访问等特点,是存储静态数据的理想选择。而链表不仅可以适用于那些频繁增删结点的应用,还适用于处理事先无法确定长度的线性表。