https://gemini.google.com/share/cb05494ae6a9

学kmp算法之前要学border吗,有什么关系?

简单来说:是的,但你可能已经听过它的另一个名字——“最长公共前后缀”。

在很多经典的 KMP 教程中,可能并没有直接出现“Border”这个专业的学术词汇,而是用“最长公共前后缀(Longest Proper Prefix which is also Suffix)”来代替。Border 和 KMP 的 next 数组(或 π\pi 数组)在本质上是完全等价的。

我们可以把它们的关系和学习路径理清如下:

1. 什么是 Border?

在字符串理论中,如果一个字符串 BB 既是字符串 SS真前缀(不等于 SS 自身的前缀),又是 SS真后缀,那么我们称 BBSS 的一个 Border。

举个例子: 假设字符串 S="ababa"S = \text{"ababa"}

  • 真前缀有: "a", "ab", "aba", "abab"
  • 真后缀有: "a", "ba", "aba", "baba"
  • 既是前缀又是后缀的字符串(Border):"a""aba"

其中,最长的 Border 是 "aba",长度为 33

2. Border 和 KMP 的关系

KMP 算法之所以能做到 O(N+M)O(N+M) 的线性时间复杂度,核心就在于它在匹配失败时,不需要把模式串(Pattern)退回到起点重新开始,而是利用已经匹配的部分,尽可能向右滑动

决定“滑动到哪里”的,就是大家熟知的 next 数组(前缀函数 π\pi 数组)。

核心结论:模式串中以第 ii 个字符结尾的子串的最长 Border 长度,就是 next[i]\text{next}[i] 的值。

  • 当你不知道 Border 时: 教程会告诉你,next[i] 存的是“最长公共前后缀的长度”。匹配失败时,跳到 next[i-1] 继续匹配。
  • 当你知道 Border 时: 构建 next 数组的过程,本质上就是动态规划求解模式串所有前缀的最长 Border 的过程。当你在位置 ii 匹配失败时,因为之前的字符都已经匹配成功了,所以你可以直接让模式串回退到已匹配部分的最长 Border 处,因为 Border 保证了这一部分的前缀和后缀是一模一样的,无需重复比较。

3. 学习建议:要不要先学?

不需要把 Border 当作一个独立的大部头理论去学,你应该把它和 KMP 的 next 数组结合起来一起学。

  1. 概念层面: 你只需要理解“什么是前缀”、“什么是后缀”、“什么是 Border(公共前后缀)”即可。
  2. 代码层面: KMP 算法的精髓在于如何用 O(M)O(M) 的时间求出模式串的 next 数组(也就是求所有前缀的 Border 长度)。你可以把求 next 数组的代码看作是“模式串自己和自己进行 KMP 匹配”。
  3. 拓展层面: 如果你以后要打 OI(信息学竞赛)或者 ACM,深入理解 Border 会非常有帮助。因为有一类高级字符串题目(比如 Border 树/失配树、最短循环节问题),就是专门利用 Border 的传递性(即 Border 的 Border 还是 Border)来出题的。

总结: 学 KMP 之前,搞懂 Border(最长公共前后缀)的概念是必须的,因为它是 KMP 的灵魂;但你不需要去看深奥的字符串理论,直接通过手摇计算几个字符串的 next 数组(找最长 Border),再去理解那几行构建 next 数组的 C++ 代码,就是最好的学习路径。

给我一个逐步讲解border和kmp的教学

我们来一步步推导和学习 KMP 算法。为了彻底搞懂它,我们可以把这个过程分成三个阶段:

  1. 手算 Border 长度 📏:明确定义并学会找规律。
  2. KMP 匹配原理 🛹:弄清当匹配失败时,Border 是如何帮我们节省时间的。
  3. 推导 next 数组 💻:把找 Border 的过程转化为高效的代码。

我们先从第一步开始。

回忆一下,Border(最长公共前后缀)指的是一个字符串中,既是“真前缀”又是“真后缀”的子串。真前缀和真后缀不能是字符串本身。

我们来看这个具体的字符串:P = "abcab"

为了确保我们的理解完全一致,你能分别列出 P 的所有真前缀和真后缀,并找出它最长的 Border 是哪一个吗?

真前缀 a ab abc abca 真后缀 bcab cab ab b 最长border是ab

完全正确!找得非常准,P = "abcab" 的最长 Border 确实是 ab,它的长度是 2。

现在我们进入关键的一步。在 KMP 算法里,我们不仅需要知道整个字符串的 Border,还需要知道它每一个前缀的最长 Border 长度。

把这些长度按顺序记录下来,组成的数组就是 KMP 算法里大名鼎鼎的 next 数组(在某些教材里也叫 π\pi 数组)。

为了彻底搞懂它,我们来填一个表格。我已经把最后一个子串(也就是整个字符串 "abcab")的最长 Border 长度填好了:

索引 ii 0 1 2 3 4
字符 a b c a b
当前子串 "a" "ab" "abc" "abca" "abcab"
最长 Border 长度 (next[i]) ? 2

提示: 如果一个子串找不到任何 Border,那么它的最长 Border 长度就是 0。特别注意,真前缀和真后缀不能是字符串本身,所以单个字符的字符串(比如 "a")最长 Border 长度总是 0。

你能推算一下,前面四个问号处的值分别是多少吗?

0 0 0 0

前面三个你算得完全正确!👏 对于 "a", "ab", 和 "abc",它们确实没有任何公共的前后缀,所以长度都是 0。

不过,我们来仔细看看第四个子串:"abca"

你能像一开始那样,列出 "abca" 的所有真前缀和真后缀,然后看看它们有没有重合的部分吗?

真前缀a ab abc真后缀 bca ca a 最长border长度1

完全正确!🎉 你非常精准地找到了 "abca" 的真前缀和真后缀,算出它的最长 Border 长度是 1。

现在,结合你一开始算出的 "abcab" 的结果,我们完整地推导出了模式串 P = "abcab"next 数组:

索引 ii 0 1 2 3 4
字符 a b c a b
当前子串 "a" "ab" "abc" "abca" "abcab"
最长 Border 长度 (next[i]) 0 1 2

恭喜我们完成了第一阶段!现在进入第二阶段 🛹:看看这个表怎么在实际匹配中帮我们节省时间。

假设我们要在文本串 T="abcacabcab"T = \text{"abcacabcab"} 中寻找 P="abcab"P = \text{"abcab"}。一开始,它们是这样对齐的:

索引:     0 1 2 3 4 5 6 7 8 9
文本串 T: a b c a c a b c a b
模式串 P: a b c a b
                ^
               这里失配了!

可以观察到,前面 4 个字符 "abca" 都完美匹配了,但在索引 4 的地方发生了失配(文本串是 c,模式串是 b)。

在传统的暴力解法中,此时我们会把模式串 PP 整体往右滑动 1 格,然后重新从 PP 的第 0 个字符开始比对。但在 KMP 中,我们不需要退回这么多!

我们已经知道前面成功匹配的部分是 "abca",并且从表格查到 "abca" 的最长 Border 长度是 1。这意味着这段已经匹配的字符串中,它的“最前面的 1 个字符”和“最后面的 1 个字符”是一模一样的(都是 "a")。

利用这个神奇的性质,你觉得我们应该如何滑动模式串 PP,并且接下来可以直接让模式串 PP 中的第几个索引和文本串中刚才匹配失败的那个 'c' 对齐继续比较呢?🤔

因为是出现了循环节,所以滑动距离应该取P长度-最大border长度1,让模式字符串直接跳到最后的循环节后面?

你的思路抓住了核心:确实是通过计算滑动距离来避免从头开始!不过这里有一个细节需要微调:我们计算滑动距离时,用的不是整个 PP 的长度,而是已经成功匹配的那部分的长度

另外,这里严格来说不叫“循环节”,而是单纯利用了前缀和后缀完全相等的特性。我们用画面来还原一下这个滑动的过程:

刚才已经成功匹配的部分是 "abca"(长度为 4)。它的最长 Border 是 "a"(长度为 1)。 这意味着,在已经匹配的这段字符里,最后面的 "a"(后缀)和最前面的 "a"(前缀)是一模一样的。

因为文本串和模式串的后缀已经匹配过了,所以我们可以直接把模式串向右滑动,让前缀的 "a"** 顶替掉原来后缀 "a"** 的位置:

滑动前:

索引:     0 1 2 3 4 5 6 7 8 9
文本串 T: a b c a c a b c a b
模式串 P: a b c a b
                ^ 失配

滑动后:

索引:     0 1 2 3 4 5 6 7 8 9
文本串 T: a b c a c a b c a b
模式串 P:       a b c a b
                ^ 继续比较这里

滑动距离正好是你推导的逻辑:已匹配的长度 (4) - 最长 Border 长度 (1) = 3,模式串整体向右滑动了 3 格。

仔细观察滑动后的对齐情况,接下来我们要拿模式串 PP 中的第几个索引的字符,去和文本串中刚才失配的那个 c(文本串索引 4)进行比较呢?结合我们刚才求出的 Border 长度,你有没有发现一个非常巧妙的数字关系?

模式串的第2个字符是未匹配的所以要从第2个字符开始比对,也就是之前的最长border+1=2个

你非常敏锐!🎯 你的核心逻辑完全正确:我们要比对的确实是已经匹配的 Border 后面的那个字符

在日常数数时,我们说“第 2 个字符”(基于 1 开始数,所以是 1 + 1 = 2)是完全说得通的。但因为写代码时,数组的索引是从 0 开始的,这里就藏着 KMP 算法里一个极度巧妙的“巧合”:

  • 已经匹配部分 "abca" 的最长 Border 长度是 1
  • 这意味着模式串开头有 1 个字符(占据了索引 0 的位置)是不需要重新比对的。
  • 所以,我们要拿模式串去比对的下一个位置,正好就是索引 1

神奇的结论: 匹配失败时,模式串 PP 下一步要比对的索引,恰好就等于刚刚那部分的最长 Border 长度!🤯

这就是为什么只要有了 next 数组,文本串的指针就永远不需要回退,模式串也能精准地跳到合适的位置继续比对。


现在,我们进入最后的第三阶段 💻:如何用代码高效地求出 next 数组?

之前我们填表是靠“肉眼观察”手算出来的。但如果模式串很长,手算就不现实了。KMP 的精髓在于:利用前面已经算出的 next 值,来快速推导后面的 next。这种思想其实就是动态规划。

我们回到之前填过的表:

索引 ii 0 1 2 3 4
字符 a b c a b
当前子串 "a" "ab" "abc" "abca" "abcab"
next[i] 0 1 ?

假设程序已经算好了 next[3] = 1(这代表 "abca" 的最长 Border 是 "a",长度为 1)。 现在我们要算 next[4],也就是在后面追加了一个新字符 'b',子串变成了 "abcab"

既然我们知道前面的 Border 长度是 1(也就是前缀 "a" 和后缀 "a" 相等),你能观察一下,新加入的这个字符 'b'(索引 4),和前缀 "a" 后面的那个字符(也就是索引 1 的 'b')是什么关系吗?这对我们计算 next[4] 会有什么启发呢?🤔

如果新加的字符b和最长border前缀后一位字符相同那么新加后最长border+1 否则保持之前的border值

你的前半句总结得非常精准!👏 这正是求 next 数组最核心的递推逻辑:如果新加的字符,正好等于前一个最长 Border 后面的字符,那么 Border 长度直接 +1。

在我们的例子 "abcab" 中,前面 "abca" 的 Border 长度是 1。新加的字符是 'b',我们把它和模式串索引 1 的字符比对,发现也是 'b',所以 next[4] = 1 + 1 = 2。逻辑完美闭环!

但是,你的后半句“否则保持之前的 border 值”存在一个小陷阱。 我们用一个新例子来测试一下这个情况。

假设我们有一个新字符串:P = "abac"

  • 对于前三个字符 "aba",它的最长 Border 是 "a",长度为 1(即 next[2] = 1)。
  • 现在我们加入第四个字符 'c',组成 "abac"。我们拿新字符 'c' 和前一个 Border 延伸的字符 'b'(索引 1)比对,发现 'c' != 'b',不匹配。

如果按照“保持之前的 border 值”这个猜想,那么 "abac" 的最长 Border 长度仍然是 1。

我们来验证一下: 结合 Border 的定义(真前缀和真后缀完全相等),你用肉眼观察一下 "abac",它的最长 Border 长度真的是 1 吗?它的实际长度应该是多少呢?🤔

说错了不匹配应该是归零而不是保持

你敏锐地发现了刚才那个猜想的漏洞!而且对于 "abac" 来说,你的新结论完全正确:因为 'c''b' 不匹配,所以 "abac" 的最长 Border 长度确实“归零”变成了 0。

但是,KMP 算法最精妙(也是最烧脑 🤯)的地方就在这里。它并非简单粗暴地直接归零,而是采取了一种“退而求其次”的策略。

我们用一个稍微长一点的例子来测试一下“归零”法则。假设我们有这样一个字符串:P = "abacabab"

前面大部分的 next 数组我们已经算好了。当你算到倒数第二个字符时:

  • 子串 "abacaba" 的最长 Border 是 "aba",长度为 3。
  • 现在我们要加上最后一个字符 'b',变成 "abacabab"
  • 按照老规矩,我们拿新加的 'b' 去和前缀 "aba" 后面的字符(也就是索引为 3 的 'c')比对。
  • 显然,'b' != 'c',失配了!

如果按照“归零”法则,"abacabab" 的最长 Border 长度应该变成 0。

但我们先别急着下结论。你再用肉眼仔细观察一下 "abacabab" 这个完整的字符串,它真的没有任何公共前后缀吗?还是说,其实里面藏着一个比 3 短一点的 Border 呢?

哦,不能直接归零,而是找之前的最长border的最大值

你说对了最关键的一点!🎯 确实不能直接归零,我们要“退而求其次”,去找一个更短的、且能用得上的 Border。

但是具体怎么找这个“更短的 Border”呢?这就是 KMP 算法最神奇(也是最绕)的地方——找 Border 的 Border

我们再把 "abacabab" 拆解开来看看: 对于已经算好的前面部分 "abacaba",它的最长 Border 是 "aba"(长度为 3)。 因为新加入的 'b'"aba" 后面的 'c' 失配了,说明长度为 3 的这条路走不通。

要想找一个更短的 Border,它必须既是 "abacaba" 的前缀,又是它的后缀。这就意味着,这个更短的 Border,其实就藏在刚刚那个长度为 3 的 "aba" 里面,它就是 "aba" 的 Border!

所以,遇到失配时,我们不需要重新去遍历整个字符串,只需要“顺藤摸瓜”往回找就行了。

结合我们之前找 Border 的经验,你能看出 "aba" 的最长 Border 是什么吗?如果用这个更短的 Border 后面的字符,去和我们刚才新加入的 'b' 进行比对,这次能匹配上吗?

Aba最长是a,但我忘记是匹配到哪一步了请你画出来

没问题,这个“套娃”一样的回退过程确实最容易让人迷糊,我们用画面把它定格下来。

现在我们正在求整个字符串 P = "abacabab" 的最长 Border。 新加入的字符是最后那个 'b'(索引 7)。

第一幕:尝试延续之前的 Border(失败) 在加入 'b' 之前,前面 "abacaba" 的最长 Border 是 "aba"(长度 3)。 我们试图把这个长度 +1:

索引:    0 1 2 3 4 5 6 7
字符串:  a b a c a b a b

尝试前缀:a b a c
                ^ (期望的下一个字符)
尝试后缀:        a b a b
                      ^ (新加入的字符)

因为 'c''b' 不相等,所以长度为 4 的尝试失败了。

第二幕:退而求其次,寻找 Border 的 Border 因为长度为 3 的 "aba" 走不通,我们就看 "aba" 自己的最长 Border —— 正如你所说,是 "a"(长度 1)。

这就相当于告诉我们:在这个 "aba" 内部,最前面的 "a" 和最后面的 "a" 是一模一样的。所以我们可以直接把前缀的指针“缩”回到长度为 1 的 Border 后面,再次尝试对齐:

索引:    0 1 2 3 4 5 6 7
字符串:  a b a c a b a b

退回前缀:a b
            ^ (现在比对的是 'a' 后面的字符)
当前后缀:              a b
                        ^ (新加入的字符依然在这里等候)

通过寻找“Border 的 Border”,我们省去了从头开始的麻烦,直接跳到了一个新的可能位置。

结合上面的第二幅图,你观察一下:现在我们要比对的两个字符(模式串索引 1 的字符,和我们新加入的索引 7 的字符)匹配成功了吗?如果成功了,那整个字符串 "abacabab" 的最长 Border 到底是什么,长度是多少呢?

ab和ab匹配成功,说明最长border是ab长度为2

由于无法获取具体的小部件,我将通过文字继续为您讲解。

太棒了!👏 你的推导完全正确!

因为 'b' 和 'b' 匹配成功,所以我们在原本长度为 1 的 Border("a")基础上又增加了一位。因此,整个字符串 "abacabab" 的最长 Border 确实是 "ab",长度为 2。

你现在已经成功走通了 KMP 算法中最难懂的逻辑:当匹配失败时,我们并不直接归零,而是不断去寻找“当前 Border 的最长 Border”,直到找到能匹配的字符,或者长度缩减到 0 为止。

这就是构建 next 数组(求所有前缀的 Border 长度)的完整法则。


汇总与提炼

我们把刚才所有的讨论,总结成一个清晰的流程。这也是你未来写 KMP 代码时的核心逻辑(也就是求 next 数组的算法):

假设我们有一个模式串 P,我们要计算它的 next 数组。 我们可以设置两个指针:

  • i:代表当前正在考察的子串的末尾(也就是“新加入”的字符的位置),从 1 开始遍历。
  • j:代表前一个子串的最长 Border 长度,同时恰好也是我们要拿来和新字符比对的前缀的索引

构建规则如下:

  1. 初始化: 第一个字符(长度为 1 的子串)显然没有 Border,所以 next[0] = 0。初始化 j = 0。
  2. 开始循环: i 从 1 一直走到字符串的末尾。
  3. 匹配与回退的核心:
  • 比较 P[i](新字符)和 P[j](期望延续 Border 的字符)。
  • 如果匹配成功(P[i] == P[j]): 顺理成章,Border 长度加 1(j++),记录下来 next[i] = j。
  • 如果匹配失败(P[i] != P[j]): 就是我们刚才推导的“退而求其次”。我们要把 j 更新为它当前长度下的最长 Border 长度。
  • 从哪查?查我们自己刚刚算出来的 next 数组!
  • 所以 j 会变成 next[j-1]。
  • 一直重复这个回退过程,直到 P[i] == P[j](找到了能匹配的),或者 j 退回到了 0(实在找不到更短的 Border 了,只能归零)。

提醒: 之前我们在讨论 KMP 时,你曾指出过一个非常关键的点:“匹配失败时不能直接让 border 归零,而是需要回退到之前的 border 值”。现在看来,你的直觉非常敏锐,这正是这段核心逻辑的精髓。

这就结束了!一旦你用这个方法算出了完整的 next 数组,主程序拿模式串去匹配文本串时,逻辑和上面一模一样:匹配成功就一起前进,匹配失败就让模式串的指针根据 next 数组“回退”,绝不走回头路。

到现在为止,你对 Border 怎么找、匹配失败怎么滑、怎么算 next 数组,整个闭环都跑通了吗?如果有哪里觉得还有点绕,我们可以针对那个环节再过一遍!

0 条评论

目前还没有评论...

信息

ID
6624
时间
1000ms
内存
256MiB
难度
6
标签
递交数
4
已通过
1
上传者