8 数据结构
数据结构是程序设计的重要理论和技术基础,它所讨论的内容和技术,对从事软件项目的开发有重要作用。学习数据结构要达到的目标是:学会总问题出发,分析和研究计算机加工的数据结构的特性,以便为应用所涉及的数据选择适当的逻辑结构、存储结构及其相应的操作方法,为提高应用计算解决问题的效率服务。
数据结构是指数据元素的集合(或数据对象)及元素间的相互关系和构造方法。
元素之间的相互关系是数据的逻辑结构,数据元素及元素之间关系的存储形式称为存储结构(或物理结构)。
数据结构按照逻辑关系的不同分为线性结构和非线性结构两大类,其中非线性结构又可分为树结构和图结构。
算法与数据结构紧密相关,数据结构是算法设计的基础,合理设计的数据结构可使算法简单而高效。
8.1 线性结构
线性结构是一种基本的数据结构,主要用于对客观世界中具有单一前驱和后继的数据关系进行描述。
线性结构的特点是数据元素之间呈现一种线性关系,即元素“ 一个接一个地排列 ”。
8.1.1 线性表
线性表是最简单、最基本、也是最常用的一种线性结构。
它有两种存储方法:
顺序存储 和 链式存储,主要的基本操作是插入、删除和查找等。
1、线性表的定义
一个线性表是 n (n≥0)个元素的有限序列,通常表示为(a1,a2,... an)。
非空线性表的特点如下。
(1)存在唯一的一个称作“ 第一个 ”的元素。
(2)存在唯一的一个称作“ 最后一个 ”的元素。
(3)除第一个元素外,序列中的每个元素均只有一个直接前驱。
(4)除最后一个元素外,序列中的每个元素均只有一个直接后继。
2、线性表的存储结构
线性表的存储结构分为顺序存储和链式存储。
1)线性表的顺序存储
线性表的顺序存储是指用一组地址连续的存储单元依次存储线性表中的数据元素,从而使得逻辑上相邻的两个元素在物理位置上也相邻,如图:
![](https://i-blog.csdnimg.cn/blog_migrate/670ca27ddeb890df2c088538deea60da.png)
在这种存储方式下,元素件的逻辑关系无需占用额外的空间来存储。
一般地,以 LOC(ai)表示线性表中第一个元素的存储位置,在顺序存储结构中,第 i 个元素 a i 的存储位置为:
![](https://i-blog.csdnimg.cn/blog_migrate/9f17900a02d4c7ec91d02f2e896b10c8.png)
其中,L 是表中每个数据元素所占空间的大小。根据该计算关系,可随机存取表中的任一个元素。
线性表采用顺序存储结构的优点是可以随机存取表中的元素,缺点是插入和删除操作需要移动元素。
插入元素前要移动元素以挪出空的存储单元,然后再插入元素;删除元素时同样要移动元素,以填充被删除的元素空出来的存储单元。
在表长为 n 的线性表中插入新元素时,共有 n +1 个插入位置,在位置 1,(元素 a1 所在位置)插入新元素,表中原有的 n 个元素都需要移动,在位置 n+1 (元素 a n所在位置之后)插入新元素时不需要移动任何元素,因此,等概率下(即新元素在 n+1 个位置插入的概率相同时)插入一个新元素需要移动元素的个数 E insert 为:
![](https://i-blog.csdnimg.cn/blog_migrate/c24244d3f3a7d7474cc288c0fe509bf5.png)
其中,Pi 表示在表中的位置 i 插入新元素的概率。
在表长为 n 的线性表中删除元素时,共有 n 个可删除的元素,删除元素 a1 时需要移动 n-1 个元素,删除元素 an 是不需要移动元素,因此,等概率下删除元素时需要移动元素的个数 E delete 为:
![](https://i-blog.csdnimg.cn/blog_migrate/e03516bc162a52c1988a42ce2dee27b2.png)
其中,qi表示删除元素 ai的概率。
2)线性表的链式存储
线性表的链式存储是用节点来存储数据元素,基本的节点结构如下所示:
![](https://i-blog.csdnimg.cn/blog_migrate/e0a6dbc4cc60f8fb7cc62e7a871dcded.png)
其中,数据域用于存储数据元素的值,指针域则存储当前元素的直接前驱和直接后继信息,指针域中的信息称为指针(或链)。
存储各数据元素的节点的地址并不要求是连续的,因此存储数据元素的同时必须存储元素之间的逻辑关系。
另外,节点空间只有在需要的时候才申请,无需事先分配。
节点之间通过指针域构成一个链表,若节点中只有一个指针域,则称为线性链表(或单链表),如图所示:
![](https://i-blog.csdnimg.cn/blog_migrate/c4999064c64b5babe03a82b715d7e71d.png)
设线性表中的元素时整型,则单链表节点类型的定义为:
![](https://i-blog.csdnimg.cn/blog_migrate/c1b93e8a8b7d327a017261373955dc16.png)
在链表的存储结构中,只需要一个指针(称为头指针,如上图中的 head)指向第一个节点,就可以顺序访问到表中的任一个元素。
在链式存储结构下进行插入和删除,其实质都是对相关指针的修改。
在单链表中,若在 p 所指节点后插入新元素节点 (s 所指节点,已经生成),如图 8-3(a)所示,其基本步骤如下。
(1)s-> link = p-> link;
(2)p-> link = s;
即先将 p 所指节点的后继节点指针赋给 s 所指节点的指针域,然后将 p 所指节点的指针域修改为指向 s 所指节点。
![](https://i-blog.csdnimg.cn/blog_migrate/7561b3a33fa44c0a9e15c669bc7a171e.png)
![](https://i-blog.csdnimg.cn/blog_migrate/1325ca80d8ec540952380a671a9db254.png)
同理,在单链表中删除 p 所指节点的后继节点时(图(b)),步骤如下:
(1)q = p->link
(2)p->link = p-link->link
(3)free(q)
即先令临时指针 q 指向待删除的节点,然后修改 p 所指节点的指针域为指向 p 所指节点的后继节点,从而将元素 b 所在的节点从链表中摘除,最后释放 q 所指节点的空间。
实际应用中,为了简化对链表的状态的判断和处理,特别引用一个不存储数据元素的节点,称为头节点,将其作为链表的第一个节点并令头指针指向该节点。
下面给出单链表上查找、插入和删除运算的实现过程。
1、【函数】单链表的查找预算。
《Code》
2、【函数】单链表的插入运算。
《Code》
3、【函数】单链表的删除运算。
《Code》
线性表采用链表作为存储结构时,不能对数据元素进行随机访问,但是插入和删除操作不需要移动元素。
根据节点中指针域的设置方式,还有其他几种链表结构。
- 双向链表。每个节点包含两个指针,分别指出当前节点元素的直接前驱和直接后继。
其特点是可从表中任意的元素节点出发,从两个方向遍历链表。
- 循环链表。在单项链表(或双向链表的基础上),令表尾节点的指针指向表中的第一个节点,构成循环链表。
其特点是从表中任意节点开始遍历整个链表。
- 静态链表。借助数组来描述线性表的链式存储结构。
若双向链表中删除节点时指针的变化情况如图8-4(b)所示,其操作过程可表示为:
(1)p->front -> next = p-> next;
(2)p->front -> next = s;,或者表示为 s-> front -> next = s;
(3)s-> next = p;
(4)p-> front = s;
![](https://i-blog.csdnimg.cn/blog_migrate/9b557535191bfcf483b7b186266d12d4.png)
在双向链表中删除节点时指针的变化情况 如图 8-4(b)所示,其操作过程可表示为:
(1)p-> front -> next = p-> next;
(2)p-> next -> front = p-> front;
![](https://i-blog.csdnimg.cn/blog_migrate/dd596d68bdd87207e9a1c2e91a029b78.png)
8.1.2 栈和队列
栈和队列式软件设计中常用的两种数据结构,它们的逻辑结构和线性表相同。其特点在于运算有所限制:栈按“ 后进先出 ”的规则进行操作,队列按“ 先进先出 ”的规则进行操作,故称运算受限的线性表。
1、栈
1)栈的定义及其基本运算
(1)栈的定义
栈是只能通过访问它的一端来实现数据存储和检索的一种线性数据结构。
换句话说,栈的修改时按先进后出的原则进行的。因此,栈又称为后进先出(Last In First Out,LIFO)的线性表。
![](https://i-blog.csdnimg.cn/blog_migrate/306232013c3a68bf1235946a9e41f162.png)
在栈中进行插入和删除操作的一端称为栈顶(top),相应地,另一端称为栈底(bottom)。
不含数据元素的栈称为空栈。
(2)栈的基本运算。
① 初始化栈 InitStack(S):创建一个空栈(S)。
② 判栈空 StackEmpty(S):当栈 S 为空时返回“ 真 ”值,否则返回“ 假 ”值。
③ 入栈 Push(S,x):将元素 x 加入栈顶,并更新栈顶指针。
④ 出战 Pop(S):将栈顶元素从栈中删除,并更新栈顶指针。若需要得到栈顶元素的值,可将 Pop(S)定义为一个返回栈顶元素值的函数。
⑤ 读栈顶元素 Top(S):返回栈顶元素的值,但不修改栈顶指针。
应用中常使用上述 5 中基本运算实现基于栈结构的问题求解。
2)栈的存储结构
(1)顺序存储。
栈的顺序存储是指用一组地址连续的存储单元依次存储自栈顶到栈底的数据元素,同时附设指针 top 指示栈顶元素的位置。
采用顺序存储结构的栈也称为顺序栈。
在该存储方式下,需要预先定义(或申请)栈的存储空间,也就是说,栈空间的容量是有限的。因此,在顺序栈中,当一个元素入栈时,需要判断是否栈满(栈空间中没有空闲单元),若栈满,则元素入栈发生上溢现象。
(2)栈的链式存储。
为了克服顺序存储的栈可能存在上溢的不足,可以用链表存储栈中的元素。用链表作为存储结构的栈也称为链栈。由于栈中元素的插入和删除仅在栈顶一端进行,因此不必设置头节点,链表的头指针就是栈顶指针。
如图 :
![](https://i-blog.csdnimg.cn/blog_migrate/c061c39d413f18bf438a285bee1bfebb.png)
(3)栈的应用。
栈的典型应用包括表达式求值、括号匹配等,在计算机语言的实现以及递归过程转变为非递归过程的处理中,栈有重要作用。
2、队列
1)队列的定义及其基本运算。
(1)队列的定义。
队列式一种先进先出(First In First Out,FIFO)的线性表,它只允许在表的一端插入元素,而在表的另一端删除元素。
在队列中,允许插入元素的一端称为队尾(rear),允许删除元素的一端称为队头(front)。
(2)队列的基本运算。
① 初始化队 InitQueue(Q):创建一个空的队列 Q。
② 判队空 Empty(Q):当队列为空时返回“ 真 ”值,否则返回“ 假 ”值。
③ 入队 EnQueue(Q,x):将元素 x 加入到队列Q的队尾,并更新队尾指针。
④ 出队 DeQueue(Q):将队头元素从队列Q中删除,并更新队头指针。
⑤ 读取队头元素FrontQue(Q):返回队头元素的值,但不更新队头指针。
2)队列的存储结构
(1)队列的顺序存储
队列的顺序存储结构又称为顺序队列,它也是利用一组地址连续的存储单元存放队列中的元素。
由于队列中元素的插入和删除限定在表的两端进行,因此设置队头和队尾指针,分别指示出当前的队首元素和队尾元素。
下面设顺序队列 Q 的容量为 6,其队头指针 为 front,队尾指针为 rear,头、尾指针和队列中的元素之间的关系如图8-6所示。
![](https://i-blog.csdnimg.cn/blog_migrate/c3d58b2692ed27f1f5318fcf6717be3a.png)
在顺序队列中,为了降低运算的复杂度,元素入队时只修改队尾指针,元素出队时只修改队头指针。
由于顺序队列的存储空间是提前设定的,所以队尾指针会有一个上限值,当队尾指针达到该上限时,就不能只通过修改队尾指针来实现新元素的入队操作了。
此时,可通过除取余运算顺序队列假想成一个环状结构,如图 8-7 所示,称之为循环队列。
![](https://i-blog.csdnimg.cn/blog_migrate/bbc67f9ddccd913d56510a86d28bf27b.png)
设置循环队列Q的容量 MAXSIZE,初始时队列为空,且Q.rear 和 Q.front 都等于 0,如图 8-(a)所示。
元素入队时,修改队尾指针 Q.rear = (Q.rear+1)%MAXSIZE,如图 8-8 (b)所示。
元素出队时,修改队头指针 Q.front = (Q.front+1)%MAXSIZE,如图 8-8(c)所示。
在队列空和队列满的情况下,循环队列的队头、队尾指针指向的位置是相同的,此时仅仅根据 Q.rear 和 Q.front之间的关系无法判定队列的状态。
为了区别队空和队满的情况,可采用以下两种处理方式:
其一,是设置一个标志位,以区别头、尾指针的值相同时队列式空还是满;
其二,是牺牲一个存储单元,约定以“ 队列的尾指针所指位置的下一个位置是队头指针 ”表示队列满,如图 8-8(f)所示,而头、尾指针的值相同时表示队列为空。
设队列中的元素类型为整型,则循环队列的类型定义为。
![](https://i-blog.csdnimg.cn/blog_migrate/b0053dc3e6a837c5e5c547b4a1ab6cd9.png)
#define MAXSIZE 100
typedef struct {
int *base; /* 循环队列的存储空间 */
int front; /* 指示队头元素 */
int rear; /* 指示队尾元素 */
} SqQueue;
【函数】创建一个空的循环队列。
《Code》
【函数】元素入循环队列。
《Code》
【函数】
《Code》
(2)队列的链式存储。
队列的链式存储也称为链队列。这里为了便于操作,给链队列添加一个头节点,并令头指针指向头节点。
因此,队列为空的判定条件是:
头指针和尾指针的值相同,且均指向头节点。队列的链式存储结构如图 8-9所示。
![](https://i-blog.csdnimg.cn/blog_migrate/11d06d9add95e8cff0f3ee80dd56ec58.png)
3)队列的应用
队列结构常用于处理需要排队的场合,如操作系统中处理打印任务的打印队列、离散事件的计算机模拟等。
8.1.3 串
串(字符串)是一种特殊的线性表,其数据元素为字符。
计算机中非数值问题处理的对象经常是字符串数据。
例如,在汇编和高级语言的编译程序中,源程序和目标程序都是字符串数据;在事务处理程序中,姓名、地址等一般也是作为字符串处理的。
串具有自身的特性,运算时常常把一个串作为一个整体来处理。这里介绍串的定义、基本运算、存储结构及串的模式匹配算法。
1、串的定义及基本运算
1)串的定义
串是仅由字符构成的有限序列,是取值范围受限的线性表。一般记为 S=‘a1a2...an’,其中 S 是串名,单引号括起来的字符序列是串值。
2)串的几个基本概念
- 空串:长度为零的串,空串不包含任何字符。
- 空格串:由一个或多个空格组成的串。虽然空格是一个空白字符,但它也是一个字符,计算串长度时要将其计算在内。
- 子串:由串中任意长度的连续字符构成的序列称为子串。含有子串的串称为主串。子串在主串中的位置是指子串首次出现时,该子串的第一个字符在主串中的位置。空串是任意串的子串。
- 串相等:指两个串长度相等且对应位置上的字符也相同。
- 串比较:两个串比较大小时以字符的ASCII码值(或其他字符编码集合)作为依据。
实质上,比较操作从两个串的第一个字符开始进行,字符的码值大者所在的串为大;
若其中一个串先结束,则以串长较大者为大。
3)串的基本操作
(1)赋值操作 StrAssign(s,t):将串 s 的值赋给串 t。
(2)联接操作 Concat(s,t):将串 t 接续在 s 的尾部,形成一个新串。
(3)求串长 StrLength(s):返回串 s 的长度。
(4)串比较 StrCompare(s,t):比较两个串的大小。返回值 -1、0 和 1 分别表示 s<t、s=t 和 s>t 三种情况。
(5)求子串 SubString(s,start,len):返回串 s 中从 start 开始的、长度为 len 的字符串序列。
以上 5 种最基本的串操作构成了串的最小操作子集,利用它们可以实现串的其他运算。
2、串的存储结构
(1)串的定长存储结构。
串的静态存储结构就是串的顺序存储结构,用一组地址连续的存储单元来存储串值的字符序列。由于串中的元素为字符,所以可通过程序语言提供的字符数组定义串的存储空间,也可以根据串长的需要动态申请字符串的空间。
(2)串的链式存储。
字符串也可以采用链表作为存储结构,当链表存储串中的字符时,每个节点中可以存储一个字符,也可以存储多个字符,此时要考虑存储密度问题。
在链式存储结构中,节点大小的选择和顺序存储方法中数组空间大小的选择一样重要,它直接影响对串的处理效率。
3、串的模式匹配
子串的定位操作通常称为串的模式匹配,它是各种串处理系统中最重要的运算之一。
子串也称为模式串。
1)朴素的模式匹配算法
该算法也称为布鲁特-福斯算法,其基本思想是从主串的第一个字符起与模式串的第一个字符比较,若相等,则继续逐对字符串后续的比较,否则从主串第二个字符起与模式串的第一个字符重新比较,直至模式串中每个字符依次和主串中一个连续的字符序列相等时为止,此时称为匹配成功。如果不能在主串中找到与模式串相同的子串,则匹配失败。
【函数】以字符数组存储字符串,实现朴素的模式匹配算法。
《Code》
假设主串和模式串的长度分别为 n 和 m,位置序号 从 0 开始计算,下面分析朴素模式匹配算法的时间复杂度。
设从主串的第 i 个位置开始与模式串匹配成功,在前 i 趟匹配中(位置0~i-1),每趟不成功的匹配都是模式串的第一个字符与主串中相应的字符不相同,则在前 i 趟匹配中,字符的比较共进行了 i 次,而第 i+1 趟(从位置 i 开始)成功匹配的字符比较次数为 m,所以总的字符比较次数为i+m(0<i<n-m)。
若在主串的 n-m 个起始位置上匹配成功的概率相同,则在最好情况下,匹配成功时字符间的平均次数为:
因此,在最好情况下匹配算法的时间复杂度为Ο(n+m)。
而在最坏情况下,每一趟不成功的匹配都是模式串的最后一个字符与主串中响应的字符不相等,则主串中新一趟的起始位置为 i-m+2 。
若设从主串的第 i 个字符开始匹配时成功,则前 i 趟不成功的匹配中,每趟都比较了 m 次,总共比较了 i × m次。
因此,最坏情况下的平均比较次数为:
由于 n>>m,所以该算法在最坏情况下的时间复杂度为Ο(n×m)。
2)改进的模式匹配算法
改进的模式匹配算法又称为 KMP 算法,其改进之处在于:每当匹配过程中出现相比较的字符不相等时,不需要回溯主串的字符串位置指针,而是利用已经得到的“ 部分匹配 ”结果,将模式串向右“ 滑动 ”尽可能远的距离,再继续进行比较。
设模式串为“ P0... Pm-1 ”,KMP 匹配算法的思想是:当模式串中的字符 Pj 与主串中相应的字符 Si不相等时,因其前 j个字符(“P0...Pj-1”)已经获得了匹配,所以若模式串中的“P0...Pk-1”与“Pj-k...Pj-1”相同,这时可令Pk与Si进行比较,从而是 i 无需回退。
在 KMP 算法中,依据模式串的 next 函数值实现了子串的滑动。
若令 next[j] = k,则 next[j] 表示当模式串中的Pj 与主串中相应字符不相等时,令模式串的 Pk 与主串的相应字符进行比较。
next 函数的定义如下:
【函数】求模式串的 next 函数。
《Code》
【函数】KMP 模式匹配算法,设模式串第一个字符的下标为 0。