数据结构——串
串
一、串的定义
1. 串的基本概念
串(String / 字符串)是由零个或多个字符组成的有限序列。记为:
$$S = \text{“}a_1a_2a_3\dots a_n\text{"} \quad (n \ge 0)$$
其中:
- $S$ 是串名,双引号括起来的字符序列是串的值
- $a_i$($1 \le i \le n$)是串中的字符,可以是字母、数字或其他字符
- $n$ 是串的长度。当 $n = 0$ 时,称为空串(Empty String),记为
""
空串与空格串的区别:
- 空串
"":长度为零,不含任何字符- 空格串
" ":长度不为零,全部由空格字符组成。不要混淆两者。
串的常用术语:
| 术语 | 含义 | 示例 |
|---|---|---|
| 串长 | 串中字符的个数 | "Hello" 的串长为 5 |
| 空串 | 长度为 0 的串,记为 "" |
|
| 空格串 | 全部由空格组成的串 | " "(3 个空格) |
| 子串 | 串中任意个连续字符组成的子序列 | "ello" 是 "Hello" 的子串 |
| 主串 | 包含子串的串 | "Hello" 是 "ello" 的主串 |
| 位置 | 字符或子串在主串中的序号(通常从 1 开始) | "e" 在 "Hello" 中的位置是 2 |
| 串相等 | 两个串的长度相等且对应位置的字符相同 | "abc" == "abc",但 "abc" != "ABC" |
串的逻辑结构也是线性结构——字符之间是一对一的线性关系。因此串也可以看作是一种操作受限的线性表(数据元素限定为字符)。但串的操作对象通常是"串"整体或"子串",而非单个字符,这是串区别于线性表的重要特点。
2. 串的存储结构
(1)定长顺序存储
用一组地址连续的存储单元(即数组)存储串中的字符序列,类似于顺序表:
#define MAXSIZE 255 // 串的最大长度
typedef struct {
char ch[MAXSIZE]; // 存储串中的字符
int length; // 串的当前实际长度
} SString; // SString 即 Static String
ch[0]可以不存储字符,从ch[1]开始存储(这样字符的位置与数组下标一致)- 也可以从
ch[0]开始存储,length记录实际长度 - 缺点是大小固定,超出
MAXSIZE的字符串无法处理
(2)堆分配存储
用 malloc / free 动态管理内存,串长可动态增长,更加灵活:
typedef struct {
char *ch; // 按串长分配存储区,ch 指向首地址
int length; // 串的当前实际长度
} HString; // HString 即 Heap String
- 需要时用
ch = (char *)malloc(length * sizeof(char))分配空间 - 使用完毕后要用
free(ch)释放 - 相比定长顺序存储,堆分配存储更灵活,没有最大长度限制
(3)链式存储(块链)
用链表来存储串,每个结点可以存储一个或多个字符:
#define CHUNKSIZE 4 // 每个结点存放的字符数
typedef struct Chunk {
char ch[CHUNKSIZE]; // 每个结点存储多个字符(数据块)
struct Chunk *next; // 指向下一个结点的指针
} Chunk;
typedef struct {
Chunk *head, *tail; // 串的头指针和尾指针
int length; // 串的当前实际长度
} LString; // LString 即 Linked String
为什么每个结点要存多个字符? 如果每个结点只存 1 个字符,那么指针域的开销将远大于数据域,存储密度极低(每个字符都要附带一个指针)。将多个字符打包到一个结点中(称为块),可以显著提高存储密度。这种结构称为块链(Block Linked List)。
二、串的主要操作
串的常见基本运算如下:
| 运算 | 含义 |
|---|---|
求串长 StrLength(S) |
返回串 $S$ 的长度 |
串赋值 StrAssign(&T, chars) |
将一个字符串常量赋值给串 $T$ |
串复制 StrCopy(&T, S) |
将串 $S$ 复制到串 $T$ |
判串空 StrEmpty(S) |
判断串 $S$ 是否为空串 |
串比较 StrCompare(S, T) |
比较串 $S$ 和串 $T$ 的大小 |
求子串 SubString(&Sub, S, pos, len) |
返回串 $S$ 中第 $pos$ 个位置起长度为 $len$ 的子串 |
串连接 Concat(&T, S1, S2) |
将串 $S_1$ 和 $S_2$ 连接成新串 $T$ |
子串定位 Index(S, T) |
返回子串 $T$ 在主串 $S$ 中首次出现的位置(没有则返回 0) |
串替换 Replace(&S, T, V) |
将串 $S$ 中所有子串 $T$ 替换为串 $V$ |
串清空 ClearString(&S) |
清空串 $S$ |
以下的主要操作示例以堆分配存储串为例。
1. 求串长
int StrLength(HString S) {
return S.length;
}
2. 串赋值
void StrAssign(HString *T, const char *chars) {
int len = strlen(chars);
if (T->ch) free(T->ch); // 释放原空间
T->ch = (char *)malloc(len * sizeof(char));
for (int i = 0; i < len; i++)
T->ch[i] = chars[i];
T->length = len;
}
3. 串复制
void StrCopy(HString *T, HString S) {
if (T->ch) free(T->ch); // 释放原空间
T->ch = (char *)malloc(S.length * sizeof(char));
for (int i = 0; i < S.length; i++)
T->ch[i] = S.ch[i];
T->length = S.length;
}
4. 判串空
int StrEmpty(HString S) {
return S.length == 0;
}
5. 串比较
比较规则:从第一个字符开始,按字符的 ASCII 码(或 Unicode 编码)值逐一比较,直到出现一对不同的字符或某一串结束:
int StrCompare(HString S, HString T) {
for (int i = 0; i < S.length && i < T.length; i++) {
if (S.ch[i] != T.ch[i])
return S.ch[i] - T.ch[i]; // 返回差值,正负表示大小关系
}
// 字符全部相同,则长度较长的串更大
return S.length - T.length;
}
返回值含义:
== 0:两串相等> 0:串 $S$ 大于串 $T$< 0:串 $S$ 小于串 $T$
6. 求子串
int SubString(HString *Sub, HString S, int pos, int len) {
if (pos < 1 || pos > S.length || len < 0 || pos + len - 1 > S.length)
return 0; // 参数不合法
char *temp = (char *)malloc(len * sizeof(char)); // 先分配
for (int i = 0; i < len; i++)
temp[i] = S.ch[pos - 1 + i]; // 再拷贝数据
if (Sub->ch) free(Sub->ch); // 最后释放原空间
Sub->ch = temp;
Sub->length = len;
return 1;
}
7. 串连接
int Concat(HString *T, HString S1, HString S2) {
char *temp = (char *)malloc((S1.length + S2.length) * sizeof(char)); // 先分配
for (int i = 0; i < S1.length; i++)
temp[i] = S1.ch[i]; // 拷贝 S1
for (int i = 0; i < S2.length; i++)
temp[S1.length + i] = S2.ch[i]; // 拷贝 S2
if (T->ch) free(T->ch); // 最后释放原空间
T->ch = temp;
T->length = S1.length + S2.length;
return 1;
}
8. 子串定位(模式匹配)
子串定位操作又称模式匹配(Pattern Matching),是串中最核心、最经典的运算之一。其功能是:在主串 $S$ 中寻找子串 $T$(称为模式串)首次出现的位置。
(1)朴素模式匹配算法(BF 算法)
BF(Brute-Force)算法的思路很直观:从主串的第一个字符开始,与模式串的每个字符逐一比较;如果匹配失败,主串指针回溯到下一个位置,模式串指针也重置到开头,重新比较。
int Index_BF(HString S, HString T) {
int i = 0, j = 0; // i 指向主串,j 指向模式串
while (i < S.length && j < T.length) {
if (S.ch[i] == T.ch[j]) {
i++; j++; // 当前字符匹配,继续比较下一个
} else {
i = i - j + 1; // 主串指针回溯到下一个位置
j = 0; // 模式串指针重置到开头
}
}
if (j >= T.length)
return i - T.length + 1; // 匹配成功,返回位置(从 1 开始)
return 0; // 匹配失败
}
时间复杂度分析:
- 最坏情况:每次比较都到模式串的最后一位才失败,如 $S = \text{“}0000000001\text{"}$,$T = \text{“}00001\text{"}$。此时时间复杂度为 $O(n \times m)$,其中 $n$ 是主串长度,$m$ 是模式串长度。
- 最好情况:每次第一个字符就匹配失败,时间复杂度为 $O(n)$。
(2)KMP 算法
KMP 算法(Knuth-Morris-Pratt)是对 BF 算法的重大改进。其核心思想是:当匹配失败时,主串指针 $i$ 不回溯,而是利用已经得到的"部分匹配"结果,将模式串向右滑动尽可能远的一段距离后继续比较。
① 为什么 $i$ 可以不回溯?
回顾 BF 算法,当匹配失败时,$i$ 要回退到本次比较的起始位置的下一个字符,再从头比较。但 KMP 的核心是:我们已经知道主串中已匹配的那一段是什么,利用这个信息可以直接跳过那些必然不可能匹配的位置,而无需移动 $i$。
下面用一个具体例子说明。
例:主串 $S = \text{“}ababaababcb\text{"}$,模式串 $T = \text{“}ababc\text{"}$
当匹配到 $i=4,\ j=4$ 时发生失配($S[4]=a \ne T[4]=c$),此时已成功匹配了 "abab":
S: a b a b a a b a b c b
↑ ↑ ↑ ↑
√ √ √ √ ×
T: a b a b c
↑ ↑ ↑ ↑
j=0,1,2,3
已匹配部分 T[0..3] = "abab" 中,前缀 "ab" 和后缀 "ab" 相同:
| 已匹配串 | 前缀集合 | 后缀集合 | 最长相等前后缀 |
|---|---|---|---|
"abab" |
"a"、"ab"、"aba" |
"b"、"ab"、"bab" |
"ab"(长度 2) |
这个信息告诉我们:模式串的前 2 个字符 "ab" 已经和主串中刚比过的后 2 个字符 "ab" 匹配上了,无需重新比较。因此模式串可以直接向右滑动 2 位,让 j 从 2 开始,而 i 保持不动:
S: a b a b a a b a b c b
↑
i 不动(仍指向 S[4] = 'a')
T: a b a b c
↑
j 跳到 2(指向 T[2] = 'a',跳过已对齐的 "ab")
下面用表格对比 BF 和 KMP 在这一失配时刻的不同处理:
| 步骤 | BF 算法 | KMP 算法 |
|---|---|---|
| 失配时状态 | $i=4,\ j=4$ | $i=4,\ j=4$ |
| 已匹配内容 | "abab" |
"abab" |
| 失配后 $i$ | 回溯到 $1$(i - j + 1) |
保持不变($i = 4$) |
| 失配后 $j$ | 重置到 $0$ | 跳到 $2$(最长相等前后缀长度) |
| 下一轮比较 | $S[1]$ 与 $T[0]$ 重新开始 | $S[4]$ 与 $T[2]$ 直接继续 |
| 理由 | 暴力尝试每个起始位置 | "abab" 中前缀 "ab" 已对齐后缀 "ab",无需重比 |
从失配到最终匹配,$i$ 始终没有回退,只有 $j$ 在来回跳转。这正是 KMP 能达到 $O(n+m)$ 时间复杂度的关键——主串指针 $i$ 只进不退,消除了 BF 算法中 $i$ 反复回溯带来的大量重复比较。
② next 数组
KMP 算法的关键在于模式串的 next 数组,它记录了模式串中每个位置之前的子串的最长相等前后缀长度。
前缀:除最后一个字符外,字符串的所有头部子串。例如
"abcab"的前缀有"a"、"ab"、"abc"、"abca"。
后缀:除第一个字符外,字符串的所有尾部子串。例如"abcab"的后缀有"b"、"ab"、"cab"、"bcab"。
next[j] 的含义:当模式串中第 $j$ 个字符匹配失败时,模式串的指针 $j$ 应该回退到的位置。
void GetNext(HString T, int next[]) {
int j = 0, k = -1;
next[0] = -1; // next[0] 固定为 -1
while (j < T.length - 1) {
if (k == -1 || T.ch[j] == T.ch[k]) { // 第一次执行的时候,k == -1必然成立,所以不会执行到后面的判断条件,因此T.ch[k]不会被访问。所以这个顺序一定不能变化
j++; k++;
next[j] = k;
} else {
k = next[k]; // 回溯
}
}
}
③ KMP 匹配过程
int Index_KMP(HString S, HString T, int next[]) {
int i = 0, j = 0;
while (i < S.length && j < T.length) {
if (j == -1 || S.ch[i] == T.ch[j]) {
i++; j++; // 匹配成功或 j 为 -1 时,继续比较下一个
} else {
j = next[j]; // i 不变,j 回退
}
}
if (j >= T.length)
return i - T.length + 1; // 匹配成功
return 0;
}
KMP 算法的时间复杂度:
- 求
next数组:$O(m)$- 匹配过程:$O(n)$
- 总体:$O(n + m)$,远优于 BF 算法的 $O(n \times m)$
注意:KMP 算法的优势只有在存在大量"部分匹配"的情况下才能体现。如果主串和模式串的字符随机分布(每次比较的第一个字符就匹配失败),BF 算法的时间复杂度也是 $O(n)$,两者性能相近。但面对如 $S = \text{"}0000000001\text{"}$,$T = \text{"}00001\text{"}$ 这样的"恶意"输入时,KMP 的优势就非常明显了。
9. 串替换
int Replace(HString *S, HString T, HString V) {
if (T.length == 0) return 0; // 空模式串无法替换
// 第一遍:统计匹配次数
int count = 0, i = 0;
while (i <= S->length - T.length) {
int j;
for (j = 0; j < T.length; j++)
if (S->ch[i + j] != T.ch[j]) break;
if (j == T.length) {
count++;
i += T.length; // 匹配时跳过整个模式串长度
} else {
i++;
}
}
if (count == 0) return 0; // 无匹配,无需替换
// 计算新串长度并分配空间
int new_len = S->length + count * (V.length - T.length);
char *temp = (char *)malloc(new_len * sizeof(char));
// 第二遍:遍历原串,遇到 T 则替换为 V
int k = 0;
i = 0;
while (i < S->length) {
if (i <= S->length - T.length) {
int j;
for (j = 0; j < T.length; j++)
if (S->ch[i + j] != T.ch[j]) break;
if (j == T.length) { // 匹配成功
for (j = 0; j < V.length; j++)
temp[k++] = V.ch[j];
i += T.length; // 跳过模式串
continue;
}
}
temp[k++] = S->ch[i++]; // 不匹配,直接复制
}
free(S->ch);
S->ch = temp;
S->length = new_len;
return 1;
}
说明:采用两遍扫描策略——第一遍统计匹配次数以计算新串长度,第二遍逐字符扫描并替换。由于每次匹配后
i直接跳过整个模式串长度,不会出现替换后的新串被再次匹配的情况,从根本上避免了无限循环。时间复杂度为 $O(n + m imes count)$,其中 $n$ 为主串长,$m$ 为模式串长。
10. 串清空
void ClearString(HString *S) {
if (S->ch) {
free(S->ch);
S->ch = NULL;
}
S->length = 0;
}
三、串的性能分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 求串长 | $O(1)$ | 直接返回 length 字段 |
| 串赋值 | $O(n)$ | 需要复制整个串 |
| 串复制 | $O(n)$ | 需要复制整个串 |
| 判串空 | $O(1)$ | 只需判断 length 是否为 0 |
| 串比较 | $O(n)$ | 最坏情况需比较所有字符 |
| 求子串 | $O(n)$ | 需要复制子串内容 |
| 串连接 | $O(n)$ | 需要复制两个串 |
| 串替换 | $O(n + m \times count)$ | 两遍扫描,第一遍统计匹配次数,第二遍逐字符构建新串 |
| 串清空 | $O(1)$ | 释放 ch 空间并置 NULL,length 置 0 |
| 朴素模式匹配 | $O(n \times m)$ | $n$ 为主串长,$m$ 为模式串长 |
| KMP 匹配 | $O(n + m)$ | 利用 next 数组避免回溯 |
总结:串是一种特殊的线性表(数据元素限定为字符),在文本处理、编译系统、生物信息学等领域有广泛应用。串的核心操作是模式匹配,KMP 算法通过 next 数组实现了线性的匹配时间复杂度,是数据结构中的经典算法之一。

浙公网安备 33010602011771号