数据结构——串

一、串的定义

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 空间并置 NULLlength 置 0
朴素模式匹配 $O(n \times m)$ $n$ 为主串长,$m$ 为模式串长
KMP 匹配 $O(n + m)$ 利用 next 数组避免回溯

总结:串是一种特殊的线性表(数据元素限定为字符),在文本处理、编译系统、生物信息学等领域有广泛应用。串的核心操作是模式匹配,KMP 算法通过 next 数组实现了线性的匹配时间复杂度,是数据结构中的经典算法之一。

posted @ 2026-07-15 13:29  Javenwww  阅读(4)  评论(0)    收藏  举报