考研学习
知识点讲义数据结构

第 4 章 串

408 数据结构第 4 章 串复习讲义,涵盖核心概念、重点考点与典型题型。

Last updated on

一、先建立整章框架

这一章可以压缩成下面这条主线:

串是什么
   ↓
主串、子串、模式串
   ↓
在主串中找模式串
   ↓
朴素模式匹配
   ↓
发现大量重复比较
   ↓
KMP:主串指针不回退
   ↓
怎么知道模式串应该跳到哪里?
   ↓
前缀 / 后缀 / 部分匹配值 PM
   ↓
next 数组
   ↓
普通 next 仍可能重复比较
   ↓
nextval 优化

所以如果问“第4章最核心的一句话是什么”,就是:

KMP 利用模式串自身的前缀—后缀性质,在失配时让主串指针不回退,只调整模式串指针。

二、4.1 串的定义和实现——理解即可

资料明确指出,4.1 并不是统考大纲的核心,主要是给后面的模式匹配打基础。

1. 什么是串

串就是由零个或多个字符组成的有限序列。

例如:

"abcde"
"408"
"hello world"
""

都可以是串。

设串为:

其中字符数就是串长。

当字符数为 0 时,是空串。

这里特别容易混淆:

""       空串,长度为 0
" "      空格串,长度为 1
"   "    空格串,长度为 3

因为空格本身也是字符。

2. 主串、子串、位置

例如:

S = "China Beijing"
T = "Beijing"

那么 T 是 S 的一个子串。

资料采用从 1 开始的位置编号,因此 "Beijing" 在主串中的位置是 7。

这里以后和模式匹配直接联系起来:

主串 S:被搜索的大字符串
模式串 T:想寻找的小字符串

模式匹配的目标就是:

找出模式串第一次完整出现在主串中的位置。

3. 串和线性表是什么关系

从逻辑结构上看,串很像线性表。

区别在于:

线性表:
一个元素可以是 int、结构体等各种数据。

串:
每一个元素必须是字符。

更重要的区别是操作对象不同。

线性表经常讨论:

插入一个元素
删除一个元素
查找一个元素

串经常讨论:

连接一个子串
删除一个子串
求一个子串
查找一个子串

所以:

串可以看成一种特殊线性结构,但串算法经常以“一段字符”为操作单位。

三、串的基本操作

资料列出了赋值、复制、判空、比较、求长、求子串、连接、定位、清空、销毁等操作。

考试不需要你死背所有函数名,但要理解几个最重要的:

StrLength(S)

求串长。

SubString(&Sub, S, pos, len)

求子串。

Concat(&T, S1, S2)

连接字符串。

Index(S, T)

在 S 中寻找 T。

其中最重要的显然就是:

Index(S, T)

因为整个第4章后半部分其实都在研究:

Index() 怎样实现得更快?

四、串的三种存储结构

这一部分主要理解。

1. 定长顺序存储

本质就是数组:

#define MAXLEN 255

typedef struct {
    char ch[MAXLEN];
    int length;
} SString;

字符连续存储。

例如:

下标:1 2 3 4 5
字符:a b c d e

优点是简单、访问方便。

问题是数组容量固定。

例如 MAXLEN=255,结果串长度变成 300,就装不下。

资料将超出部分被舍弃的情况称为截断。

2. 堆分配存储

还是连续存储,但长度不是提前固定,而是在运行时动态申请:

typedef struct {
    char *ch;
    int length;
} HString;

例如 C 中:

malloc()
free()

所以:

定长顺序存储:数组提前固定
堆分配:运行时动态申请连续空间

注意:

堆分配仍然是顺序存储,不是链式存储。

这一点和动态顺序表类似。

3. 块链存储

使用链表存串。

不过因为一个字符很小,如果每个结点只存一个字符:

[data|next]

指针空间占比就太高。

所以经常一个结点放多个字符:

| a b c d | next |

一个结点叫一个块。

这部分了解即可。

五、4.2 模式匹配——本章正式重点

从这里开始是考试重点。

六、朴素模式匹配算法

又叫简单模式匹配、暴力匹配、BF 算法。

假设:

主串 S = "ababcabcacbab"
模式 T = "abcac"

核心思想非常直接:

从主串某个位置开始,把模式串一个字符一个字符往后比较。

如果全部相同,成功。

如果某个字符失败:

主串的匹配起点右移 1 位,模式串重新从第一个字符开始。

资料中的实现为:

int Index(SString S, SString T) {
    int i = 1, 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;
}

重点不是背代码,而是理解这一句:

i = i - j + 2;

为什么是 i-j+2?

假设这一轮从主串第 start 个字符开始。

经过若干次成功比较后:

i = start + j - 1

因此:

现在失配以后,要从下一个起点:

重新开始。

所以:

这就是那句代码的来源。

七、朴素匹配最大的问题:主串反复回退

例如:

主串:0000000000000000000001
模式:0000001

可以发现每一次:

0 == 0
0 == 0
0 == 0
...
最后才失败

然后:

主串起点只移动 1 位

前面已经比较过的大量字符再次比较。

资料给出的结论是,若主串长度为 n,模式串长度为 m:

最多尝试:

个起点。

每个起点最多比较:

次。

因此最坏时间复杂度:

这就是 KMP 出现的原因。

八、KMP 到底解决了什么问题

KMP 的核心并不是“神奇地预测字符”。

它只是利用一个非常重要的事实:

前面已经匹配成功过的信息,不应该全部扔掉。

还是考虑:

主串:... a b c a X
模式:    a b c a c

前四个:

abca
abca

已经确认相同。

第五个字符失配。

普通算法说:

全部作废!
主串回去重新匹配!

KMP 则问:

已经成功匹配的 abca 中,有没有某个“后缀”,恰好又是模式串的“前缀”?

如果有,我们就可以直接利用这段信息。

这就是 KMP 的灵魂。

九、理解 KMP 必须先搞清:前缀和后缀

假设字符串:

ababa

前缀

必须从开头开始,但不能包含整个串:

a
ab
aba
abab

后缀

必须到结尾结束,但不能包含整个串:

a
ba
aba
baba

注意:

"bab"

不是后缀,因为它没有一直延伸到末尾。

十、部分匹配值 PM

部分匹配值:

最长的“相等真前缀和真后缀”的长度。

对于:

ababa

逐个看。

a

没有非空真前缀/后缀:

PM = 0

ab

前缀:a
后缀:b

没有相同:

PM = 0

aba

前缀:a, ab
后缀:a, ba

最长相同:

a

长度 1:

PM = 1

abab

前缀:a, ab, aba
后缀:b, ab, bab

最长相同:

ab

所以:

PM = 2

ababa

最长相同前后缀:

aba

所以:

PM = 3

最终:

字符串:a b a b a
PM:    0 0 1 2 3

十一、KMP 为什么可以根据 PM 跳跃

假设当前已经成功匹配了 q 个字符。

设这 q 个字符最长公共前后缀长度为 PM。

那么失配以后:

模式串前 PM 个字符

已经和:

主串刚刚匹配部分的后 PM 个字符

完全相等。

因此这 PM 个字符根本不需要再比较。

模式串右移:

即:

这就是 KMP 能跳过重复比较的根本原因。

十二、next 数组是什么——408最重要定义

现在进入最核心部分。

资料采用 从 1 开始编号 的标准形式:

当模式串第 j 个字符发生失配时,next[j] 表示下一次应该拿模式串的第几个字符与当前主串字符继续比较。

即:

失配:
S[i] != T[j]

KMP:
i 不变
j = next[j]

这句话几乎必须刻进脑子里:

KMP 失配:主串指针 i 不回退,模式串指针变为 next[j]。

资料中的题目也直接考了这一点:若

那么下一轮就是模式串第 k 位与主串当前第 i 位对齐,答案就是 k。

十三、next 和 PM 的关系

如果采用资料中的 1 起始编号:

对于:

有:

为什么看 j-1?

因为:

第 j 个字符还没匹配成功,它正是失配的位置。

所以只能利用:

第1 ~ 第j-1个字符

这些已经成功的信息。

一个超级好用的手算办法

先写 PM:

PM:
0 0 1 2 3 ...

然后:

整体向右移动一格 → 所有值加 1 → 第一格写 0 → 最后一项舍去。

例如模式串:

abcac

各前缀 PM:

a       0
ab      0
abc     0
abca    1
abcac   0

即:

PM = 0 0 0 1 0

右移:

_ 0 0 0 1

加 1:

_ 1 1 1 2

第一项置 0:

next = 0 1 1 1 2

所以:

字符: a b c a c
next: 0 1 1 1 2

这就是考试最快的手算方法之一。

十四、重点例题:acaba 的 next

资料模拟题直接考:

模式串:acaba

求 next。

使用 1 起始编号。

第1位

规定:

next[1] = 0

第2位

第2位之前只有:

a

没有可利用的非空前后缀:

next[2] = 1

第3位

看前两个字符:

ac

前后缀不相等:

next[3] = 1

第4位

看:

aca

最长相等前后缀:

a

长度 1。

因此:

第5位

看:

acab

没有非空相等前后缀:

next[5] = 1

最终:

字符: a c a b a
next: 0 1 1 2 1

即:

01121

资料模拟题答案也是 C。

十五、为什么 next[1]=0?

这是人为规定的哨兵值。

如果:

S[i] != T[1]

连模式串第一个字符都不相同,那模式串没有任何已经匹配成功的信息可以利用。

于是:

j = next[1] = 0

随后主循环遇到:

j == 0

执行:

++i;
++j;

于是:

i → i+1
j → 1

也就是说:

放弃当前主串字符,从主串下一个字符重新和模式串首字符比较。

十六、KMP代码应该怎么看,而不是死背

资料中的核心代码:

int Index_KMP(SString S, SString T, int next[]) {
    int i = 1, 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;
}

只需要抓住两条路径。

匹配成功

S[i] == T[j]

那么:

i++;
j++;

两边一起向后走。

匹配失败

S[i] != T[j]

那么:

j = next[j];

注意:

i 不动

所以图像上不是主串回退,而是:

模式串向右滑

十七、KMP 中一个非常高频的认知错误

很多人会想:

j 变小了,是不是模式串向左移动?

不是。

因为模式串只是“逻辑对齐关系”发生变化。

例如原来:

             i
主串: ......X
模式: abcde
             j

失配后:

j → next[j]

同一个 S[i] 改为和模式串更靠前的字符比较:

             i
主串: ......X
模式:    abcde
          j

从整体位置看:

模式串实际上向右移动了。

十八、next 的递推构造——算法题理解重点

资料中的构造代码:

void get_next(SString T, int next[]) {
    int i = 1, j = 0;
    next[1] = 0;

    while (i < T.length) {
        if (j == 0 || T.ch[i] == T.ch[j]) {
            ++i;
            ++j;
            next[i] = j;
        } else {
            j = next[j];
        }
    }
}

这个算法最容易看懵。

可以把它理解为:

“用模式串自己匹配自己。”

假设当前已经知道:

next[i] = j

也就是:

T[1...j-1]

等于:

T[i-j+1...i-1]

现在要判断能不能再扩展一个字符。

比较:

T[i]
T[j]

情况1:相等

如果:

T[i] == T[j]

那么原来的最长公共前后缀可以再增加一个字符。

所以:

i++;
j++;
next[i] = j;

本质就是:

情况2:不相等

如果:

T[i] != T[j]

说明当前长度的前后缀没法继续。

但也不能直接判定为 0。

因为:

当前这个前缀本身可能还有更短的相等前后缀。

于是:

j = next[j];

继续尝试更短的候选前缀。

这和真正的 KMP 匹配过程完全一样。

所以:

get_next() 本质上也是 KMP 思想,只不过是在模式串内部寻找自己的前后缀结构。

十九、KMP 的时间复杂度

设:

主串长度 n
模式串长度 m

计算 next:

真正匹配:

因此总复杂度:

而朴素算法最坏:

不过资料也提醒:普通数据下朴素算法平均性能往往没有最坏情况那么差,因此在实际简单场景中仍然很常见;KMP 最大优势是在字符串存在大量重复前后缀时避免重复比较。

二十、nextval——KMP的进一步优化

这是本章第二个高频难点。

资料标出了 nextval 为考点,并且模拟题专门考过。

先看模式:

aaaab

普通 next:

字符:    a a a a b
next:    0 1 2 3 4

假设现在:

S[i] = b
T[4] = a

失配。

普通 KMP:

j = next[4] = 3

于是继续比较:

S[i] = b
T[3] = a

肯定还是失败。

再:

j = next[3] = 2

比较:

b vs a

还失败。

再:

b vs a

仍失败。

显然都是废操作。

二十一、为什么这些比较一定没意义

已经知道:

S[i] != T[j]

如果同时:

T[j] == T[next[j]]

那么必然:

S[i] != T[next[j]]

因为:

T[j] 和 T[next[j]]

根本就是同一个字符。

所以没必要再比较一次。

因此:

如果回退到的位置上的字符和当前失配字符相同,就继续往更前面跳。

这就是 nextval。

二十二、nextval 的核心规则

普通:

next[j] = k

如果:

T[j] != T[k]

这个 k 是有效的:

nextval[j] = k

如果:

T[j] == T[k]

那么跳到 k 还是必错。

所以:

nextval[j] = nextval[k]

即:

继续跳

直到找到:

字符不同

或者:

到达 0

二十三、例子:aaaab

资料给出的结果:

字符:       a a a a b
next:       0 1 2 3 4
nextval:    0 0 0 0 4

为什么前四个全是 0?

例如第4位:

T[4] = a
next[4] = 3
T[3] = a

相同。

所以不能停在 3。

继续:

next[3] = 2
T[2] = a

仍相同。

继续。

最终一路跳到 0。

因此:

nextval[4] = 0

二十四、nextval 构造代码怎么理解

资料代码:

void get_nextval(SString T, int nextval[]) {
    int i = 1, j = 0;
    nextval[1] = 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];
        }
    }
}

最核心就两句:

if (T.ch[i] != T.ch[j])
    nextval[i] = j;

字符不同,可以放心跳到 j。

否则:

nextval[i] = nextval[j];

字符相同,跳到 j 也必然再次失配,所以进一步跳。

二十五、一个非常重要的问题:为什么你有时看到 next[0]=-1?

这是 KMP 最容易让学生混乱的地方。

资料主体采用:

字符串从 1 开始
next[1] = 0

例如:

acaba
next = 0 1 1 2 1

但另一种常见教材/代码采用:

字符串从 0 开始
next[0] = -1

例如模拟题里就出现:

ababac

next =
[-1, 0, 0, 1, 2, 3]

以及:

nextval =
[-1, 0, -1, 0, -1, 3]

资料中的模拟题就是这种 0 下标表示法。

两者不是两个不同的 KMP。

只是下标体系不同。

大致关系可以理解为:

1-based:
next[1] = 0

0-based:
next[0] = -1

所以考试时第一件事:

先看题目采用 0 下标还是 1 下标。

千万不要把两套 next 定义混着算。

二十六、一道资料中的典型 nextval 题

资料模拟题:

主串:
aabaabcabc

模式串:
aaabc

采用 0 起始的 nextval 表示。

题目告诉你:

i = 2
j = 2

发生失配。

此时模式串前面连续都是:

a a a

如果普通 next 不断回退:

还是拿 b 和 a 比
还是失败

nextval 会直接跳过这些必然失败的位置。

资料给出的:

nextval[2] = -1

失配后:

j = -1

KMP 的特殊规则使:

i++;
j++;

于是下一次真正比较时:

i = 3
j = 0

因此答案为 C。

二十七、408 常考什么?重点练习四类题

结合这里的资料和选择题,这章的题型非常固定。

题型重要程度典型问法
next 数组手算★★★★★给模式串求 next
nextval 手算★★★★★给 next 求 nextval
失配后的 i,j★★★★★失配后下一次比较哪两个字符
比较次数★★★★☆KMP 成功前一共字符比较几次
KMP 原理★★★★☆为什么主串不回退
BF 与 KMP 复杂度★★★☆☆最坏复杂度比较
串的存储结构★★☆☆☆定长/堆/块链特点
串基本操作★☆☆☆☆概念性选择题

二十八、遇到“求 next”题,建议固定使用这个流程

以模式串:

P = p1 p2 ... pm

为例。

考试时不要凭感觉乱跳。

固定这么做:

第一步:确定编号体系。

如果:

next[1]=0

就是 1-based。

如果:

next[0]=-1

就是 0-based。

第二步:求当前位置之前的最长相等真前后缀。

对于第 j 个字符失配,看的不是包含第 j 个字符的全部串,而是:

P[1 ... j-1]

第三步:转换为 next。

1-based 下:

特殊:

这种方法做选择题通常比模拟代码稳定得多。

二十九、遇到“求 nextval”题,固定这样做

先把普通 next 算出来。

然后逐个位置检查:

假设:

next[j] = k

如果:

P[j] != P[k]

那么:

nextval[j] = k

否则:

P[j] == P[k]

就跳过:

nextval[j] = nextval[k]

一句话记忆:

next 找“能去哪”;nextval 再排除“去了也必错”的位置。

三十、遇到“比较次数”题,千万别只看指针移动次数

每执行一次:

S[i] == P[j] ?

或者:

S[i] != P[j] ?

都算一次字符比较。

但:

j = next[j]

本身不是字符比较。

同理:

j == -1

这种哨兵判断,也通常不算字符串字符之间的比较。

最稳妥的方法是做一个表:

当前 i当前 j比较字符成功/失败下一状态
00S[0] vs P[0]×...
...............

每一行有真正字符比较就加 1。

这样不容易漏。

三十一、最容易错的几个点

1. 把“子串”和“子序列”混淆

子串必须连续。

abcde

bcd 是子串。

ace 不是子串。

2. 把空串和空格串混淆

""  长度0
" " 长度1

3. 求 next[j] 时把第 j 个字符也算进去了

第 j 个字符已经失配。

所以应该分析的是:

P[1 ... j-1]

4. 把前缀/后缀算成整个字符串

所谓真前缀、真后缀:

不能等于整个字符串本身。

否则任何字符串和自己都相等,PM 就没有意义了。

5. 认为 KMP 的主串完全不会向前走

准确说:

发生失配时 i 不回退。

成功比较时:

i++

模式首字符都失配时,最终也会:

i++

6. 把 next[i] 当成和主串有关

next 是:

只根据模式串本身计算出来的。

它与主串内容没有关系。

这是资料选择题直接考察的点。

7. 混用两套下标体系

这是最危险的。

1-based:
next[1]=0

0-based:
next[0]=-1

题目给哪个,就按哪个来。

三十二、你应该怎样真正理解 KMP

不要把 KMP 理解成:

“失配以后查一个神秘的 next 表。”

应该把它理解为下面这个逻辑。

假设已经成功匹配:

P[1 ... j-1]

现在:

S[i] != P[j]

于是问:

已经匹配的这一段结尾,有没有一部分恰好和模式串开头一样?

如果有:

      前缀
      abc
P = abc......abcX
              后缀

那么因为这个“后缀”在主串中已经匹配成功:

主串:......abcX
模式:abc......X

根本不需要重新比较 abc。

直接让:

模式前缀 abc

和:

已经成功的主串后缀 abc

对齐即可。

所以真正决定 KMP 跳转位置的是:

最长相等前缀和后缀。

next 只是把这条规律提前计算成了数组。

这才是 KMP 的本质。

三十三、本章考研复习优先级

最后给你压缩成一个复习版本。

第一优先级:必须熟练

KMP 原理

必须会回答:

为什么 i 可以不回退?

因为已匹配串的最长后缀可与模式串最长前缀重新对齐。

next 手算

至少做到看到:

acaba
abaabcaba
ababac

这类串能稳定算出 next。

nextval

知道优化条件:

P[j] == P[next[j]]

时普通 KMP 会发生必然重复失配,应继续跳。

失配后的指针变化

普通 KMP:

j = next[j];

优化 KMP:

j = nextval[j];

主串 i 在普通失配时不回退。

第二优先级:会做题

比较次数、模式串移动距离、匹配成功位置、0/1 下标体系转换。

第三优先级:理解即可

串基本操作、定长顺序存储、堆分配、块链存储。

因为你这份资料自己也明确指出:统考考纲核心是字符串模式匹配,重点是 KMP、next 和 nextval 的推导。

三十四、一页式记忆

可以最后只记住下面这个关系链:

BF:
失配
→ 主串回退
→ 模式串回到开头
→ 重复比较多
→ 最坏 O(nm)

KMP:
失配
→ 主串 i 不回退
→ j = next[j]
→ 利用已匹配信息
→ O(n+m)

为什么能跳?
→ 已匹配序列存在
   “后缀 = 模式串前缀”

PM:
最长相等真前后缀长度

1-based:
next[1] = 0
next[j] = PM[j-1] + 1

nextval:
如果 P[j] == P[next[j]]
→ 跳到 next[j] 仍必然失配
→ 继续跳
→ nextval[j] = nextval[next[j]]

第4章真正做到“会”的标准不是背出 KMP 代码,而是给定任意一个模式串,你能解释为什么某个位置的 next 是那个值;给你一次失配,你能准确说出主串为什么不动、模式串为什么跳到那个位置。做到这一点,nextval 和比较次数题也就会顺很多。

On this page

一、先建立整章框架二、4.1 串的定义和实现——理解即可1. 什么是串2. 主串、子串、位置3. 串和线性表是什么关系三、串的基本操作四、串的三种存储结构1. 定长顺序存储2. 堆分配存储3. 块链存储五、4.2 模式匹配——本章正式重点六、朴素模式匹配算法为什么是 i-j+2?七、朴素匹配最大的问题:主串反复回退八、KMP 到底解决了什么问题九、理解 KMP 必须先搞清:前缀和后缀前缀后缀十、部分匹配值 PMaababaababababa十一、KMP 为什么可以根据 PM 跳跃十二、next 数组是什么——408最重要定义十三、next 和 PM 的关系一个超级好用的手算办法十四、重点例题:acaba 的 next第1位第2位第3位第4位第5位十五、为什么 next[1]=0?十六、KMP代码应该怎么看,而不是死背匹配成功匹配失败十七、KMP 中一个非常高频的认知错误十八、next 的递推构造——算法题理解重点情况1:相等情况2:不相等十九、KMP 的时间复杂度二十、nextval——KMP的进一步优化二十一、为什么这些比较一定没意义二十二、nextval 的核心规则二十三、例子:aaaab二十四、nextval 构造代码怎么理解二十五、一个非常重要的问题:为什么你有时看到 next[0]=-1?二十六、一道资料中的典型 nextval 题二十七、408 常考什么?重点练习四类题二十八、遇到“求 next”题,建议固定使用这个流程二十九、遇到“求 nextval”题,固定这样做三十、遇到“比较次数”题,千万别只看指针移动次数三十一、最容易错的几个点1. 把“子串”和“子序列”混淆2. 把空串和空格串混淆3. 求 next[j] 时把第 j 个字符也算进去了4. 把前缀/后缀算成整个字符串5. 认为 KMP 的主串完全不会向前走6. 把 next[i] 当成和主串有关7. 混用两套下标体系三十二、你应该怎样真正理解 KMP三十三、本章考研复习优先级第一优先级:必须熟练第二优先级:会做题第三优先级:理解即可三十四、一页式记忆