数据结构:串和广义表
引言:
计算机上的非数值处理的对象大部分是字符串数据,字符串一般简称为串。串是一种特殊的线性表,其特殊性体现在数据元素是一个字符,串是一种内容受限的线性表。由于如今的计算机硬件结构是面向数值计算的需要而设计的,在处理字符串数据时比处理整数和浮点数要复杂得多。本章第一部分主要讨论串的定义、存储结构和基本操作,重点讨论串的模式匹配算法。
本章后两部分讨论的多维数组和广义表可以看成是线性表的一种扩充。
1. 串的定义
串( string )(或字符串)是由零个或多个字符组成的有限序列,一般记为
s s s = " a 1 a 2 . . . a n a_1 a_2 ... a_n a1a2...an " ( n ≥ 0 n \geq 0 n≥0 )
其中,s 是串的名,用双引号括起来的字符序列是串的值; a i ( 1 ≤ i ≤ n ) a_i (1 \leq i \leq n ) ai(1≤i≤n) 可以是字母、数字或其他字符; n 称为串的长度,零个字符的串称为空串,其长度为 0;
串中任意个连续的字符组成的子序列称为该串的子串。包含子串的串称为主串。
一个或多个空格组成的串称为空格串。(此处不是空串)
2. 串的类型定义、存储结构及其运算
2.1 串的抽象类型定义
ADT String {
数据对象:D = {
a
i
a_i
ai |
a
i
∈
C
h
a
r
a
c
t
e
r
S
e
t
,
i
=
1
,
2
,
.
.
.
,
n
,
n
≥
0
a_i \in CharacterSet,i = 1,2,... ,n,n \geq 0
ai∈CharacterSet,i=1,2,...,n,n≥0 }
数据关系:R = { <
a
a
a
i
−
1
i-1
i−1,
a
i
a_i
ai> |
a
a
a
i
−
1
i-1
i−1,
a
i
∈
D
a_i \in D
ai∈D,
i
=
2
,
.
.
.
,
n
i = 2,...,n
i=2,...,n }
基本操作:
StrAssign (&T,chars)
初始条件:chars 是字符串常量。
操作结果:生成一个其值等于 chars 的串 T。
StrCopy (&T,S)
初始条件:串 S 存在。
操作结果:由串 S 复制得串 T。
StrEmpty (S)
初始条件:串 S 存在。
操作结果:若 S 为空串,则返回 true,否则返回 false。
StrCompare (S,T)
初始条件:串 S 和 T 存在。
操作结果:若 S > T,则返回值 > 0;若 S = T, 则返回值 = 0;若 S < T,则返回值 < 0。
StrLength (S)
初始条件:串 S 存在。
操作结果:返回 S 的元素个数,称为串的长度。
ClearString (&S)
初始条件:串 S 存在。
操作结果:将 S 清为空串。
Concat (&T,S1,S2)
初始条件:串 S1 和 S2 存在。
操作结果:用 T 返回由 S1 和 S2 连接而成的新串。
SubString (&Sub,S,pos,len)
初始条件:串 S 存在,1
≤
\leq
≤ pos
≤
\leq
≤ StrLength (S) 且 0
≤
\leq
≤ len
≤
\leq
≤ StrLength (S) - pos + 1。
操作结果:用 Sub 返回串 S 的第 pos 个字符起长度为 len 的子串。
Index (S,T,pos)
初始条件:串 S 和 T 存在,T 是非空串,1
≤
\leq
≤ pos
≤
\leq
≤ StrLength (S)。
操作结果:若主串 S 中存在和串 T 相同的子串,则返回它在主串 S 中第 pos 个字符之后第一次出现的位置;否则函数值为 0。
Replace (&S,T,V)
初始条件:串 S,T 和 V 存在,T 是非空串。
操作结果:用 V 替换主串 S 中出现的所有与 T 相等的不重叠的子串。
StrInsert (&S,pos,T)
初始条件:串 S 和 T 存在,1
≤
\leq
≤ pos
≤
\leq
≤ StrLength (S) + 1。
操作结果:在串 S 的第 pos 个字符之前插入串 T。
StrDelete (&S,pos,len)
初始条件:串 S 存在,1
≤
\leq
≤ pos
≤
\leq
≤ StrLength (S) - len + 1。
操作结果:从串 S 中删除第 pos 个字符起长度为 len 的子串。
DestoryString (&S)
初始条件:串 S 存在。
操作结果:串 S 被销毁。
} ADT String
2.2 串的存储结构
与线性表类似,串也有两种基本存储结构:顺序存储和链式存储。但考虑到存储效率和算法的方便性,串多采用顺序存储结构。
串的顺序存储:
串的定义方式分为静态与动态两种,其中静态定义采用一维数组的存储方式:
// 串的定长顺序存储结构
#define MAXLEN 255 // 串的最大长度
typedef struct
{
char ch[MAXLEN + 1]; // 存储串的一维数组
int length; // 串的当前长度
}SString;
动态定义采用堆的存储方式:
// 串的堆式顺序存储结构
typedef struct
{
char *ch; // 若是非空串,则按串长分配存储区,否则 ch 为 NULL
int length; // 串的当前长度
}HString;
串的链式存储:
顺序串的插入和删除操作不方便,需要移动大量的字符。因此,可采用单链表方式存储串。不过这种存储方式存在一个 “结点大小” 的问题,即每个结点可以存放一个字符,也可以存放多个字符,此时通常使用 “#” 补全结点。
// 串的链式存储结构
#define CHUNKSIZE 80
typedef struct Chunk // 可由用户定义的块大小
{
char ch[CHUNKSIZE];
struct Chunk *next;
}Chunk;
typedef struct
{
Chunk *head, *tail; // 串的头和尾指针
int length; // 串的当前长度
}LString;
2.3 串的模式匹配算法
子串的定位运算通常称为串的模式匹配或串匹配。此运算的应用非常广泛,比如在搜索引擎、拼写检查、语言翻译、数据压缩等应用中,都需要进行串匹配。
串的模式匹配设有两个字符串 S 和 T,设 S 为主串,也称正文串;设 T 为子串,也称为模式。在主串 S 中查找与模式 T 相匹配的子串,如果匹配成功,确定相匹配的子串中的第一个字符在主串 S 中出现的位置。
著名的模式匹配算法有 BF 算法 和 KMP 算法,下面详细介绍这两种算法。
BF 算法
此为最简单直观的模式匹配算法,模式匹配不一定是从主串的第一个位置开始,可以指定主串中查找的起始位置 pos。如果采用字符串顺序存储结构,可以写出不依赖于其他串操作的算法。
算法1:BF 算法
【算法步骤】
(1) 分别利用计数哨兵 i 和 j 指示主串 S 和模式 T 中当前正待比较的字符位置,i 初值为 pos,j 初值为 1。
(2) 如果两个串均未比较到串尾,即 i 和 j 均分别小于等于 S 和 T 的长度时,则循环执行以下操作:
- S.ch[i] 和 T.ch[j] 比较,若相等,则 i 和 j 分别指示串中下个位置,继续比较后续字符;
- 若不等,指针后退重新开始匹配,从主串的下一个字符( i = i - j + 2 )起再重新和模式的第一个字符( j = 1)比较。
(3) 如果 j > T.length,说明模式 T 中的每个字符依次和主串 S 中的一个连续的字符序列相等,则匹配成功,返回和模式 T 中第一个字符相等的字符在主串 S 中的符号( i - T.length );否则称匹配不成功,返回 0。
【算法描述】
int Index_BF (SString S, SString T, int pos)
{ // 返回模式 T 在主串 S 中第 pos 个字符开始第一次出现的位置。若不存在,则返回值为 0
// 其中,T 非空,1 <= pos <= S.length
i = pos; j = 1; // 初始化
while (i <= S.length && j <= T.length) // 两个串均未比较到串尾
{
if (S.ch[i] == T.ch[j]) { ++i; ++j; } // 继续比较后续字符
else { i = i - j + 2; j = 1; } // 指针后退重新开始匹配
}
if (j > T.length) return i - T.length; // 匹配成功
else return 0; // 匹配失败
}
时间复杂度:O(m x n)
KMP 算法
这种改进算法是由 Knuth、Morris 和 Pratt 同时设计实现的,因此简称 KMP 算法。此算法可以在 O(n + m) 的时间数量级上完成串的模式匹配操作。其改进在于不需回溯 i 哨兵,而是充分利用已经得到的 “部分匹配” 的结果将模式向右 “滑动”。
代码段与 BF 算法唯一不同的地方为将两哨兵回溯的操作改为仅回溯模式的哨兵。模式哨兵的回溯取决于 next 数组。
算法2:KMP 算法
【算法描述】
int Index_KMP (SString S, SString T, int pos)
{ // 利用模式串 T 的 next 函数求 T 在主串 S 中第 pos 个字符之后的位置
// 其中,T 非空,1 <= pos <= S.length
i = pos; j = 1;
while (i <= S.length && j <= T.length) // 两个串均未比较到串尾
{
if (j == 0 || S.ch[i] == T.ch[j]) { ++i; ++j; } // 继续比较后继字符
else j = next[j]; // 模式串向右移动
}
if (j > T.length) return i - T.length; // 匹配成功
else return 0; // 匹配失败
}
时间复杂度:O(n + m)
算法3:计算 next 数组
【算法描述】
void get_next (SString T, int next[])
{ // 求模式串 T 的 next 函数值并存入数组 next
i = 1; next[1] = 0; j = 0;
while (i < T.length)
{
if (j == 0 || T.ch[i] == T.ch[j]) { ++i; ++j; next[i] = j }
else j = next[j];
}
}
算法4:计算 nextval 数组
【算法描述】
void get_nextval (SString T, int nextval[])
{
i = 1; nextval[1] = 0; j = 0;
while (i < T.length)
{
if (j == 0 || T.ch[i] == T.ch[j])
{
++i, ++j;
if (T.ch[i] != T.ch[j]) nextval[i] = j;
else nextval[i] = nextval[j];
}
else j = nextval[j];
}
}
时间复杂度:O(m)
3. 广义表
3.1 广义表的定义
广义表是线性表的推广,也称为列表。广泛地用于人工智能等领域的表处理语言 LISP 语言,把广义表作为基本的数据结构,就连程序也表示为一系列的广义表。
广义表一般记作:LS = ( a1,a2,…,an )
LS 为广义表的名称,n 为其长度。广义表中 a i i i 可以是单个元素,也可以是广义表,分别称为广义表 LS 的原子和子表。习惯以大写字母表示广义表的名称,小写字母表示原子。
因此,广义表是一个递归的定义。
广义表有以下三个重要结论:
(1) 广义表是一个多层次的结构,可以用图形象地表示。
(2) 广义表可为其他广义表所共享。
(3) 广义表可以是一个递归的表。
广义表最重要的两个运算:
(1) 取表头:取出的表头为非空广义表的第一个元素。
(2) 取表尾:去除的表尾为除去表头之外,由其余元素构成的表。
3.2 广义表的存储结构
由于广义表中的数据元素可以有不同的结构,因此难以使用顺序存储结构表示,通常采用链式存储结构。常用的链式存储结构有两种,头尾链表的存储结构和扩展线性链表的存储结构。
头尾链表的存储结构
由于广义表中的数据元素可能为原子或广义表,因此需要两种结构的结点:一种是表结点,用以表示广义表;另一种是原子结点,用以表示原子。而广义表可分解成表头和表尾,因此,一对确定的表头和表尾可唯一确定广义表。一个表结点可由 3 个域组成:标志域、指示表头的指针域和指示表尾的指针域。而原子结点只需两个域:标志与和值域。
其定义如下:
// 广义表的头尾链表存储表示
typedef enum{ATOM, LIST} ElemTag; // ATOM == 0: 原子; LIST == 1: 子表
typedef struct GLNode
{
ElemType tag; // 公共部分,用于区分原子结点和表结点
union // 原子结点和表结点的联合部分
{
AtomType atom; // atom 是原子结点的值域,AtomType 由用户定义
struct{struct GLNode*hp, *tp;}ptr; // ptr 是表结点的指针域,ptr.hp 和 ptr.tp 分别指向表头和表尾
};
}*GList; // 广义表类型
在这种存储结构中有以下几种情况:
(1) 除空表的表头指针为空外,对任何非空广义表,其表头指针均指向一个表结点,且该结点中的 hp 域指示广义表表头,tp 域指向广义表表尾。
(2) 容易分清列表中原子和子表所在层次。
(3) 最高层的表结点个数即为广义表的长度。
扩展线性链表的存储结构
在这种结构中,无论是原子结点还是表结点均由三个域组成。