深入理解字符串匹配算法:BF算法,KMP算法

欢迎来到我的:世界

希望作者的文章对你有所帮助,有不足的地方还请指正,大家一起学习交流 !


目录

前言

在字符串处理的广阔世界中,字符串匹配是一项基础且极为重要的任务。从文本编辑器中的查找替换功能,到生物信息学中 DNA 序列的比对,字符串匹配无处不在。而 KMP 算法,作为字符串匹配算法家族中的一颗璀璨明星,以其高效性和独特的思想,备受关注。今天,就让我们一同深入探索 KMP 算法的奥秘。


内容

什么是字符串匹配问题

简单来说,字符串匹配问题就是在一个主字符串 (text)中查找是否存在一个特定的模式字符串(pattern)。例如,主字符串 text 为 "ABABDABACDABABCABAB",模式字符串 pattern 为 "ABABCABAB",我们需要判断 pattern 是否是 text 的子串,如果是,返回其在 text 中的起始位置。

BF算法

暴力匹配算法(又叫做BF算法(Brute Froce算法))及其局限性;

在接触 KMP 算法之前,我们先来看看最直观的暴力匹配算法。暴力匹配算法的思路非常简单直接,它从主字符串的第一个字符开始,逐字符与模式串的第一个字符进行比较;如果相等则继续比较后续字符;如果不相等,则主串的比较位往后移动一位,重新从模式串的第一个字符开始比较。这个过程会一直重复,知道模式串的所有字符都匹配成功;

用例介绍:这串主串和匹配串,进行匹配,设置两个索引下标 主串索引i , 匹配串索引j ,此时i=0开始来遍历匹配这两串;

如果i位置的字符等于j位置的字符就代表匹配成功,继续后遍历匹配,

i == 5 ,且 此 时 j==5 明显ac不匹配,说明主串从i=0位置开始,找不到匹配的匹配串,此时再让主串的索引i回退到本次匹配开始的位置的下一个位置(i=1),而模式串索引j回到起始位置;

以此类推,直到找到匹配的子串或者确定模式字符串不是主字符串的子串为止。

暴力匹配算法虽然容易理解和实现,但其时间复杂度较高。

计算时间复杂度 :假设 m 是模式字符串的长度,n 是主字符串的长度,在最坏情况下,每个子串都要对比m个字符,共要对比n-m+1个子串,所以时间复杂度为0((n-m+1) * m),可以看成是0(n*m-m*m+m) ,又因为在很多情况是n>>m(远大于),根据大O渐进表示法,直接抓大头,所以其最坏的时间复杂度是0(n*m)

平均情况下,假设模式串中的字符是随机分布的,并且模式串不在主串中出现。对于主串的每个位置,平均需要比较的字符数是一个常数。因此,平均时间复杂度仍然是 O(m*n)

BF算法C++代码实现

cpp 复制代码
#include <iostream>
#include <string>

// 字符串匹配函数,返回模式串在主串中首次出现的位置,未找到返回0
int Index(const std::string& S, const std::string& T) {
    // 处理空串情况
    if (S.length() == 0 || T.length() == 0) {
        return 0;
    }
    int i = 0, j = 0;
    while (i < static_cast<int>(S.length()) && j < static_cast<int>(T.length())) {
        if (S[i] == T[j]) {
            ++i;
            ++j;  // 继续比较后继字符
        }
        else {
            i = i - j + 1;  // 主串索引回溯到上次匹配开始位置的下一位
            j = 0;  // 模式串索引重置为开头
        }
    }
    if (j == static_cast<int>(T.length())) {
        return i - static_cast<int>(T.length());  // 返回匹配的起始位置
    }
    return 0;  // 未找到匹配返回0
}

    

另一种写法,我觉得既然学了C++就得多用用容器,虽然时间复杂度不会改变,但这可以让你的代码更简洁,明了;

cpp 复制代码
int bruteForceSearch(string& str, string& sub)
{
	size_t lensub = sub.length();
	size_t lenstr = str.length();
	int i = 0;
	// 外层循环,i 从 0 到 m - n
	while (i <= lenstr - lensub)
	{
		// 从主字符串 str 的第 i 个位置开始截取长度为 lensub 的子串
		string tmp = str.substr(i, lensub);
		// 比较截取的子串 tmp 和目标子串 sub 是否相等
		if (tmp == sub) return i;
		else i++;
	}
	return -1;
}

思考:朴素匹配的小优化

使用容器进行小优化

我们这道朴素匹配算法为什么会很慢,就是因为他计算的很多没有必要的比较,这里我想可以使用容器哈希表,来实现快速查找到下一个可以进行匹配的位置,这样可以避免一些不必要的计算;

cpp 复制代码
// 使用容器优化的暴力匹配法
int optimizedBruteForce(const string& text, const string& pattern) {
	int n = text.length();  // 主串长度
	int m = pattern.length();  // 模式串长度

	// 遍历主串的每个可能的起始位置
	for (int i = 0; i <= n - m; ) {
		int j = 0;  // 用于匹配模式串的索引

		// 从当前起始位置开始匹配模式串
		while (j < m && text[i + j] == pattern[j]) {
			++j;  // 如果字符匹配,继续比较下一个字符
		}

		// 如果模式串的所有字符都匹配成功
		if (j == m) {
			return i;  // 返回匹配的起始位置
		}

		// 如果当前起始位置匹配失败,根据模式串的第一个字符在当前窗口中的位置进行跳过
		unordered_set<char> windowSet(text.begin() + i, text.begin() + i + j); // 当前窗口的字符集合
		if (windowSet.find(pattern[0]) != windowSet.end()) {
			// 如果模式串的第一个字符在当前窗口中出现过,跳过该字符的位置
			i += j;
		}
		else {
			// 如果模式串的第一个字符不在当前窗口中,直接跳过整个窗口
			i += j + 1;
		}
	}

	return -1;  // 如果没有找到匹配的子串,返回-1
}

滑动窗口进行小优化

cpp 复制代码
#include <iostream>
#include <string>

// 暴力匹配函数,返回模式串在主串中第一次出现的位置,若未找到则返回 -1
int bruteForceSearch(const std::string& text, const std::string& pattern) {
    int n = text.length();
    int m = pattern.length();
    for (int i = 0; i <= n - m; ++i) {
        int j;
        for (j = 0; j < m; ++j) {
            if (text[i + j] != pattern[j]) {
                break;
            }
        }
        if (j == m) {
            return i;
        }
    }
    return -1;
}

KMP 算

KMP 算法:

KMP 算法的核心思想是:当某次比对失败时,能够利用已经比对过的部分信息,尽可能多地将模式字符串向后移动,减少不必要的重复比对,从而提高匹配效率。

首先要弄清楚前缀,后缀和部分匹配值。

前缀 :是指除最后一个字符外,字符串的所有头子串;

后缀 :是指除第一个字符外,字符串的所有尾部子串;

部分匹配值:是指字符串的最长相等前后缀长度;

比如:先介绍前后缀

匹配串 前缀 后缀
a
ab a b
aba a,ab a,ba
abac a,ab,aba c,ac,bac

而部分匹配值的作用:就是用来设置KMP算法中的next数组(数组中存的是最长相等前后缀长度);就是在某个位置匹配失败时,能够快速将模式串的指针回溯到合适的位置,避免主串指针的回溯。

举个例子:咱们先不管next数组怎么设置的,先假设看到这模式串的next的数组是[-1,0,0,0,1,2],然后看看如何使用KMP算法;

然后开始进行比较,与BF算法类似;设置两个指针i j开始遍历匹配,当i==5j==5的时候,此时不匹配,接下来就是KMP算法中next数组的作用了;此时要j指针回溯到你此时j=next[j]数组中的位置(对于为什么要这样,有什么原理,后面会详细介绍,现在先走下去)

此时再继续进行遍历匹配:但此时i位置和j位置的字符位置不匹配,此时再次进行j指针回溯j=next[j],此时就回溯到了0下标位置,也就是起始位置;

此时再进行遍历匹配:此时就可以全部遍历完了;好了这就是大概KMP算法的算法步骤了,由此就可以了解到KMP算法和BF暴力算法的本质区别就是那个next数组,有了next数组就可以让模式串进行回溯,且主串中的i指针不回退;

在这里解释一下为什么next中的值,就一定可以回溯,且一定正确呢?

理论证明:

看完理论证明,咱们可以带入确定的值看看是否能推出来,不过要先看看下面的next数组的实现;

next数组

接下来就是重点了,开始设置next数组;首先要知道next数组的目的是什么,用通俗易懂的话:是用来保存子串某位置匹配失败后,回头的下标位置。

咱们就根据这句话来填写next数组。

咱们先提示一下:next[j] 记录的是模式串中从第 0 个字符到第 j - 1 个字符所构成的子串的最长公共前后缀的长度。

特别注意:

  • next0设为-1是一种边界处理。当主串与模式串在第一个字符就不匹配时,需要将模式串向后移动一位继续比较,-1可以作为一个特殊标记来表示这种情况。
  • next1设为0,是因为长度为1的子串不存在公共前后缀,所以最长公共前后缀长度为0。

next数组C++代码实现

cpp 复制代码
//设置next数组
	void getnext(string& sub, vector<int>& next)
	{
		//设置初始值
		next[0] = -1, next[1] = 0;
		int k = 0;
		int i = 2;
		while (i < sub.size())
		{
			//找到相同字符
			if (k == -1 || sub[i - 1] == sub[k])
			{
				next[i] = k + 1;
				i++;
				k++;
			}
			else
			{
				k = next[k];
			}
		}
	}

C++代码实现KMP算法

cpp 复制代码
#include <iostream>
#include <string>
#include <vector>

// 计算未优化的 next 数组
void getNext(const std::string& pattern, std::vector<int>& next) {
    int j = 2;
    int k = 0;
    next[0] = -1;
    next[1] = 0;
    while (j < pattern.size()) {
        if (k == -1 || pattern[j-1] == pattern[k]) {
            next[j] = k + 1;
            ++j;
            ++k;
        } else {
            k = next[k];
        }
    }
}

// KMP 搜索算法
int kmpSearch(const std::string& text, const std::string& pattern) {
    int n = text.length();
    int m = pattern.length();
    std::vector<int> next(m);
    getNext(pattern, next);

    int i = 0;
    int j = 0;
    while (i < n) {
        if (j == -1 || text[i] == pattern[j]) {
            ++i;
            ++j;
        }
        if (j == m) {
            return i - j;
        } else if (i < n && text[i] != pattern[j]) {
            j = next[j];
        }
    }
    return -1;
}

int main() {
    std::string text = "ABABDABACDABABCABAB";
    std::string pattern = "ABABCABAB";
    int index = kmpSearch(text, pattern);
    if (index != -1) {
        std::cout << "模式串在文本中的起始索引为: " << index << std::endl;
    } else {
        std::cout << "未找到匹配的模式串。" << std::endl;
    }
    return 0;
}    

优化KMP算法

前面我们已经知道:失配时主串指针 i 不动,只把模式串指针 j 回退到 next[j]。next 数组保证了"回退之后不会漏掉匹配",但它并没有保证每一次回退都是必要的。这一节我们就把这些"没必要的回退"砍掉------这就是 nextval 数组(优化后的 next)。

一、为什么 next 数组还不够用?

先看一个最典型的例子:

  • 主串 S = aaabaaaab
  • 模式串 P = aaaab
  • P 的 next 数组:[-1, 0, 1, 2, 3]

匹配到 i = 3j = 3 时失配(S[3] = 'b'P[3] = 'a'),按 next 数组乖乖回退:

回退 接下来比较 结果
j = next[3] = 2 S3='b' 与 P2='a' 失败,而且是必然失败
j = next[2] = 1 S3='b' 与 P1='a' 失败,而且是必然失败
j = next[1] = 0 S3='b' 与 P0='a' 失败,而且是必然失败
j = next[0] = -1 主串后移一位,模式串从头开始 正常

问题就出在:P[3] = P[2] = P[1] = P[0] = 'a',这四个位置是同一个字符 。既然 S[3] = 'b' 已经和 P[3] = 'a' 比过并失败了,那么它和同样等于 'a'P[2]P[1]P[0] 再比,结果必然还是失败。这三次比较,是完全可以预料结果的重复劳动

一句话总结:如果 Pj == Pnext\[j],那么回退到 nextj 是多余的,应该直接回退到 nextvalnext\[j]。

二、nextval 数组:规则与构造

nextval 的规则只有两句话:

text 复制代码
nextval[0] = -1
对 j >= 1:
    若 P[j] == P[next[j]] ,则 nextval[j] = nextval[next[j]]    // 同字符:这步回退没意义,继续往前跳
    否则                  ,则 nextval[j] = next[j]            // 不同字符:这步回退是"真回退",保留

以 P = aaaab 为例:

j Pj nextj Pj 与 Pnext\[j] nextvalj
0 a -1 --- -1
1 a 0 a = a -1
2 a 1 a = a -1
3 a 2 a = a -1
4 b 3 b ≠ a 3

对照着看:

  • 前四个位置全是 'a',互为"同一种情况",于是 nextval 一路压缩到 -1:一次失配就直接换主串的下一位,中间那几次回退全部省掉;
  • j = 4 保留了 3,因为 P[4] = 'b' ≠ P[3] = 'a',这次回退能真正换到一个不同的字符,是有意义的。

两个小结论(后面会用到):

  1. 因为 next[j] < j 恒成立,所以算 nextval[j]nextval[next[j]] 一定已经算好了,从左往右一遍就能填完;
  2. 恒有 nextval[j] <= next[j],回退只会跳得更靠前,不会倒退------匹配过程中主串指针 i 依然不回退。

三、C++ 代码验证

先把"只看逻辑"的干净版本写出来:

cpp 复制代码
// 求 nextval:把 next 里"注定失败"的回退一次性跳完
void getNextval(const string& p, const vector<int>& next, vector<int>& nextval) {
    int m = (int)p.size();
    nextval.assign(m, 0);
    nextval[0] = -1;
    for (int j = 1; j < m; ++j) {
        if (p[j] == p[next[j]]) nextval[j] = nextval[next[j]];  // 同字符:继续往前跳
        else                    nextval[j] = next[j];           // 不同字符:这次回退有效
    }
}

小细节:next[j] 只有在下标 0 处才是 -1,而循环从 j = 1 开始,所以 p[next[j]] 不会越界。

为了公平对比,把两张表塞进同一个搜索函数:传 next 就是未优化版本,传 nextval 就是优化版本;

cpp 复制代码
long long g_cmp = 0;   // 字符比较次数(比较成功或失败都算一次)

int kmpSearch(const string& s, const string& p, const vector<int>& table) {
    int n = (int)s.size(), m = (int)p.size();
    g_cmp = 0;
    int i = 0, j = 0;
    while (i < n && j < m) {
        if (j == -1) { ++i; j = 0; continue; }   // 模式串退无可退,主串后移一位
        ++g_cmp;
        if (s[i] == p[j]) { ++i; ++j; }
        else j = table[j];                       // 唯一的区别就在这一行
    }
    return (j == m) ? i - m : -1;
}

S = "aaabaaaab"P = "aaaab" 跑一遍,得到:

text 复制代码
next    = -1 0 1 2 3
nextval = -1 -1 -1 -1 3

未优化:匹配下标 4,比较 12 次
优化后:匹配下标 4,比较 9 次
省下 3 次比较

两种写法给出了完全相同的匹配结果(下标 4),但优化版本少做了 3 次注定失败的比较,也就是减少了 25%。

再把主串拉长一点,看看差距会不会消失(文本用 "aaaac" 重复拼接,模式仍是 aaaab,两组结果都是"找不到"):

主串长度 n 未优化 KMP 优化 KMP 减少
50 90 次 60 次 33%
100 180 次 120 次 33%
200 360 次 240 次 33%
400 720 次 480 次 33%

差距稳定在 33% 左右:它会随着主串变长一直存在,但也不会越拉越大------因为它改变的是常数因子。

正确性说明:我用 nextval 版本和暴力匹配做过 1000 组随机串对拍(含大量重复字符),结果完全一致。优化只会跳过"必然失败"的比较,不会跳过任何一次可能成功的匹配。

顺便:nextval 也可以一遍生成(很多教材里就是这种写法):

cpp 复制代码
// 一遍生成 nextval(与上面的两遍写法等价)
void getNextval(const string& p, vector<int>& nextval) {
    int m = (int)p.size();
    nextval.assign(m, 0);
    nextval[0] = -1;
    if (m > 1) nextval[1] = (p[1] == p[0]) ? -1 : 0;   // j = 1 也要压缩

    int j = 2, k = 0;
    while (j < m) {
        if (k == -1 || p[j - 1] == p[k]) {
            ++k;                                          // 此时 k 恰好等于 next[j]
            nextval[j] = (p[j] == p[k]) ? nextval[k] : k;  // 同字符就压缩,不同就保留
            ++j;
        } else {
            k = nextval[k];                               // 用压缩后的表继续回退,跳得更快
        }
    }
}

它和"先求 next、再压缩"的两遍写法完全等价 (我也对拍过 20 万组模式串)。原因很直白:每前进一位时,k 就相当于"还没来得及压缩的 nextj",此时把 p[j]p[k] 比一下------相同就取 nextval[k],不同就取 k,正好就是压缩规则。

四、用图把过程看清楚

图 1:失配之后的"无效回退链"。

P[0] ~ P[3] 全是 'a',所以 S[3] = 'b' 跟它们比是白比;

图 2:nextval 的构造规则。

什么情况下继续往前跳、什么情况下保留 next[j],一张表就说清楚了;

图 3:同一段主串,12 次比较 vs 9 次比较。

红色那三格就是被 nextval 省掉的无效比较,匹配结果完全一样(都是下标 4);

图 4:主串变长,差距依然稳定。

优化前后的比较次数都是线性增长的,nextval 改变的是常数因子,而不是数量级。

五、小结:优化到底改变了什么

对比项 未优化 next 优化 nextval
失配后回退到 next[j] nextval[j](且 nextval[j] <= next[j]
是否会出现"注定失败"的比较 会(P[j] == P[next[j]] 时) 不会
时间复杂度 O(n + m) O(n + m)(不变)
实际比较次数 常数较大 常数更小,模式串前缀重复度高时收益明显
匹配结果 正确 正确,不会漏解

所以 nextval 并不是"另一个更快的算法",而是一张更聪明的回退表 :算法的框架没变,变的只是失配之后跳到哪儿。理解了这一点再回头看 KMP,其实就一句话------主串不回头,模式串尽量少退,能一步跳完的绝不走两步。


附:本节验证用的完整 C++ 程序

把下面的代码存成 .cpp 文件直接编译运行,就能得到文中那些比较次数:

cpp 复制代码
#include <iostream>
#include <string>
#include <vector>
using namespace std;

long long g_cmp = 0;   // 字符比较次数(无论成功还是失败都算 1 次)

// ---------- 1. 原始 next 数组 ----------
void getNext(const string& p, vector<int>& next) {
    int m = (int)p.size();
    next.assign(m, 0);
    next[0] = -1;
    if (m > 1) next[1] = 0;

    int j = 2, k = 0;
    while (j < m) {
        if (k == -1 || p[j - 1] == p[k]) {
            next[j] = k + 1;
            ++j; ++k;
        } else {
            k = next[k];
        }
    }
}

// ---------- 2. nextval 数组(由 next 压缩而来) ----------
void getNextval(const string& p, const vector<int>& next, vector<int>& nextval) {
    int m = (int)p.size();
    nextval.assign(m, 0);
    nextval[0] = -1;
    for (int j = 1; j < m; ++j) {
        if (p[j] == p[next[j]]) nextval[j] = nextval[next[j]];
        else                    nextval[j] = next[j];
    }
}

// ---------- 3. 通用 KMP:table 传 next 就是原版,传 nextval 就是优化版 ----------
int kmpSearch(const string& s, const string& p, const vector<int>& table) {
    int n = (int)s.size(), m = (int)p.size();
    g_cmp = 0;
    int i = 0, j = 0;
    while (i < n && j < m) {
        if (j == -1) { ++i; j = 0; continue; }
        ++g_cmp;
        if (s[i] == p[j]) { ++i; ++j; }
        else j = table[j];
    }
    return (j == m) ? i - m : -1;
}

void printVec(const string& name, const vector<int>& v) {
    cout << name << " = ";
    for (int x : v) cout << x << ' ';
    cout << endl;
}

int main() {
    string s = "aaabaaaab";
    string p = "aaaab";

    vector<int> next, nextval;
    getNext(p, next);
    getNextval(p, next, nextval);
    printVec("next   ", next);
    printVec("nextval", nextval);

    int a = kmpSearch(s, p, next);    long long c1 = g_cmp;
    int b = kmpSearch(s, p, nextval); long long c2 = g_cmp;

    cout << "未优化:匹配下标 " << a << ",比较 " << c1 << " 次" << endl;
    cout << "优化后:匹配下标 " << b << ",比较 " << c2 << " 次" << endl;
    cout << "省下 " << (c1 - c2) << " 次比较" << endl << endl;

    // 把主串拉长,看看差距是否稳定
    for (int k : {10, 20, 40, 80}) {
        string text;
        for (int t = 0; t < k; ++t) text += "aaaac";
        int x = kmpSearch(text, p, next);    long long n1 = g_cmp;
        int y = kmpSearch(text, p, nextval); long long n2 = g_cmp;
        cout << "n = " << text.size() << "  未优化 " << n1 << " 次,优化 " << n2
             << " 次(结果 " << x << " / " << y << ")" << endl;
    }
    return 0;
}

运行结果:

text 复制代码
next    = -1 0 1 2 3
nextval = -1 -1 -1 -1 3
未优化:匹配下标 4,比较 12 次
优化后:匹配下标 4,比较 9 次
省下 3 次比较

n = 50  未优化 90 次,优化 60 次(结果 -1 / -1)
n = 100  未优化 180 次,优化 120 次(结果 -1 / -1)
n = 200  未优化 360 次,优化 240 次(结果 -1 / -1)
n = 400  未优化 720 次,优化 480 次(结果 -1 / -1)

如果这篇 KMP(含 nextval 优化)对你有帮助,欢迎点赞、收藏;有写得不清楚的地方也欢迎在评论区指出,咱们一起把它改得更好 !


到了最后: 感谢支持

------------对过程全力以赴,对结果淡然处之

相关推荐
写后端的胖头鱼1 小时前
【高频面试题】spring事物失效场景(带原理 + 代码示例)
java·后端·spring·事务·事物失效
T1mzhou1 小时前
ARM64 Linux 6.10 内核驱动(14):drivers/base/platform.c:板级设备主战场
linux·c语言·开发语言·arm开发
clz13145211 小时前
设备反欺诈的四大支柱:从设备指纹到关联图谱
开发语言·php
长谷深风1111 小时前
Agent执行系统中的身份与版本设计
java·大数据·人工智能·ai·大模型·task·aiagent
Wang's Blog1 小时前
Java框架快速入门: Spring Security+OAuth2之UserDetails与JDBC认证实践
java·网络·spring
爱从未走散1 小时前
面试八股题
java·面试·职场和发展
jsjzsl21 小时前
独立自由度框架下核聚变的本体论本质与商业化技术新路径
人工智能·python·算法
艺杯羹2 小时前
AI编程时代软件工程怎么学:从底层思维认知到驱动智能体的架构跃迁
java·人工智能·ai·架构·软件工程·ai编程
土司大王2 小时前
LeetCode hot100——51.N 皇后:Java 回溯模板、列与对角线剪枝、O(n!) 复杂度分析
java·leetcode·剪枝