
标题
Z函数:从原理到实战的深度解析(对话版)
引言
在字符串算法的学习中,Z函数(Z-array/Z-Box算法)是绕不开的核心知识点------它以线性时间复杂度解决字符串前缀匹配问题,是字符串匹配、周期分析、回文判断等场景的高效工具。本文以"新手提问+导师解答"的对话形式,从基础定义到代码实现,再到实战应用,全方位拆解Z函数,让编程新手也能吃透这一经典算法。
一、初识Z函数:它到底是什么?
新手 :导师您好!我最近在学字符串算法,看到"Z函数"这个概念完全懵了------它是C++自带的函数吗?核心作用是什么?
导师 :别急,咱们先厘清最基础的定义。首先要明确:Z函数不是C++语言内置函数 (比如cout、sort这类),而是字符串领域的经典算法,只因C++是算法实现的主流语言,所以常被称作"C++实现Z函数"。
Z函数的核心目标很简单:对一个字符串s,计算出一个与s长度相同的数组z(即Z数组),其中z[i]表示从字符串第i个位置开始的子串,与原字符串的最长公共前缀(LCP)的长度。
举个直观例子,比如字符串s = "ababab":
z[0]:按惯例定义为0(从第0位开始的子串就是原串本身,对比无意义);z[1]:子串是s[1:] = "babab",和原串s = "ababab"的第一个字符bvsa不匹配,所以z[1]=0;z[2]:子串是s[2:] = "abab",和原串前缀"abab"完全匹配,长度4,因此z[2]=4;z[3]:子串是s[3:] = "bab",和原串前缀第一个字符a不匹配,z[3]=0;z[4]:子串是s[4:] = "ab",和原串前缀"ab"匹配,长度2,z[4]=2;z[5]:子串是s[5:] = "b",和原串前缀a不匹配,z[5]=0。
最终这个字符串的Z数组是[0,0,4,0,2,0]。记住这个核心定义,后续所有内容都围绕它展开。
新手 :哦!原来Z函数就是算"每个位置子串和原串前缀的最长匹配长度"。那它有实际用途吗?总不能只用来算个数组吧?
导师:当然有!Z函数是字符串处理的"利器",比暴力匹配高效得多,核心应用场景有这几类:
- 单模式串匹配 :比如找
pattern是否在text中,暴力匹配是O(n*m)(n、m分别为text和pattern长度),Z函数能做到O(n+m); - 字符串周期分析 :比如找
"ababab"的最小周期"ab"(长度2),Z数组能快速定位; - 回文串判断:把字符串反转后结合Z函数,可高效分析回文子串;
- 重复子串验证 :比如判断
"abcabcabc"是否由"abc"重复3次构成。
不过先别急着聊应用,咱们先把Z函数的实现原理搞透------这是所有应用的基础。
二、Z函数的实现:从暴力到线性优化
新手 :好的!那Z函数怎么实现?我听说它是线性时间复杂度,为什么能做到O(n)?
导师:先从暴力算法入手(虽然效率低,但能帮你理解核心逻辑),再讲优化后的线性算法------这是新手理解的必经之路。
(一)暴力计算Z数组(O(n²),仅用于理解)
导师 :暴力法的思路很直白:对每个位置i(从1开始,z[0]=0),逐个比较s[i + k]和s[k](k从0开始),直到不匹配,此时k就是z[i]。
用C++实现暴力版Z函数的代码如下:
cpp
#include <iostream>
#include <string>
#include <vector>
using namespace std;
// 暴力计算Z数组(时间复杂度O(n²))
vector<int> bruteForceZ(const string &s) {
int n = s.size();
vector<int> z(n, 0); // 初始化Z数组,所有元素为0
// 遍历每个位置i(从1开始,z[0]固定为0)
for (int i = 1; i < n; ++i) {
// 逐个比较s[k]和s[i+k],直到超出字符串长度或不匹配
while (i + z[i] < n && s[z[i]] == s[i + z[i]]) {
z[i]++; // 匹配一次,长度+1
}
}
return z;
}
// 测试函数
int main() {
string s = "ababab";
vector<int> z = bruteForceZ(s);
cout << "字符串\"ababab\"的Z数组:";
for (int num : z) {
cout << num << " ";
}
// 输出结果:0 0 4 0 2 0
return 0;
}
新手 :这段代码我能看懂,但暴力法效率太低了吧?比如字符串长度是10000,时间复杂度O(n²)根本没法用。
导师 :没错!暴力法的问题在于"重复比较"------比如计算z[5]时,可能会重复比较z[3]已经比较过的字符。而线性版Z函数的核心,就是通过"记录已匹配的区间",复用之前的计算结果,避免重复操作。
(二)线性时间Z函数(O(n),实战核心)
导师:线性版Z函数需要引入两个关键变量:
l和r:表示当前已找到的"最右匹配区间"[l, r],即r是所有已计算的i + z[i] - 1中最大的位置,l是对应i的起始位置;- 核心逻辑:遍历到位置
i时,先判断i是否在[l, r]内------如果在,就利用z[i - l]的结果初始化z[i],再从r - i + 1开始继续匹配;如果不在,就从0开始暴力匹配,同时更新l和r。
先看完整代码,再逐行拆解:
cpp
#include <iostream>
#include <string>
#include <vector>
#include <algorithm> // 用于max函数
using namespace std;
// 线性时间计算Z数组(O(n))
vector<int> computeZ(const string &s) {
int n = s.size();
vector<int> z(n, 0);
int l = 0, r = 0; // 初始化最右匹配区间
for (int i = 1; i < n; ++i) {
// 情况1:i在[l, r]区间内,复用z[i-l]的结果
if (i <= r) {
// z[i]的初始值取min(z[i-l], r - i + 1),避免超出r的范围
z[i] = min(z[i - l], r - i + 1);
}
// 情况2:继续暴力匹配,扩展z[i]
while (i + z[i] < n && s[z[i]] == s[i + z[i]]) {
z[i]++;
}
// 情况3:如果i+z[i]-1超出r,更新l和r为当前的匹配区间
if (i + z[i] - 1 > r) {
l = i;
r = i + z[i] - 1;
}
}
return z;
}
// 测试:对比暴力版和线性版的结果
int main() {
string s = "ababab";
vector<int> z_brute = bruteForceZ(s);
vector<int> z_linear = computeZ(s);
cout << "暴力版Z数组:";
for (int num : z_brute) cout << num << " ";
cout << endl;
cout << "线性版Z数组:";
for (int num : z_linear) cout << num << " ";
cout << endl;
// 两者输出一致:0 0 4 0 2 0
// 再测试一个复杂案例:"aaaaa"
string s2 = "aaaaa";
vector<int> z2 = computeZ(s2);
cout << "字符串\"aaaaa\"的Z数组:";
for (int num : z2) cout << num << " ";
// 输出:0 4 3 2 1
return 0;
}
新手 :这段代码里z[i] = min(z[i - l], r - i + 1)这一行我没看懂,为什么要取最小值?
导师 :举个例子帮你理解:假设当前l=2,r=5(对应i=2时的匹配区间),现在遍历到i=4(在[2,5]内),那么i-l=2,z[2]=4。但r - i + 1 = 5 - 4 + 1 = 2------这意味着i=4的初始匹配长度最多只能是2(因为超出r=5的部分还没验证过),所以必须取min(4, 2)=2,避免初始值超出已验证的区间。
如果z[i-l] < r - i + 1,说明i位置的匹配长度不会超过z[i-l],无需额外验证;如果z[i-l] >= r - i + 1,说明需要从r - i + 1开始继续匹配,直到不相等为止。
新手 :哦!这样就避免了重复比较,把时间复杂度降到了O(n)。那怎么验证这个算法的正确性?
导师:可以用几个典型案例测试:
- 全相同字符:
"aaaaa"的Z数组是[0,4,3,2,1](每个位置i的匹配长度是5-i); - 无重复字符:
"abcde"的Z数组是[0,0,0,0,0](每个位置的子串和原串前缀都不匹配); - 部分匹配:
"abacaba"的Z数组是[0,0,1,0,3,0,1]。
你可以把这些案例代入代码,验证输出是否符合预期------这是学习算法的重要习惯。
三、Z函数的核心应用:从理论到实战
新手 :现在我理解了Z函数的实现,该聊聊它的应用了吧?最常用的应该是字符串匹配吧?
导师:没错!字符串匹配是Z函数最经典的应用,咱们先讲这个,再拓展其他场景。
(一)单模式串匹配
新手 :怎么用Z函数实现字符串匹配?比如找pattern = "ab"是否在text = "ababab"中?
导师 :核心思路是"拼接字符串":把pattern + '#' + text拼接成一个新字符串(#是不在pattern和text中的分隔符),然后计算这个新字符串的Z数组。如果Z数组中存在某个位置的Z值等于pattern的长度,说明pattern出现在text中。
举个例子:
pattern = "ab",text = "ababab";- 拼接后:
s = "ab#ababab"; - 计算Z数组:
[0,0,0,2,0,2,0,2,0]; - 观察Z数组中"#"之后的部分(对应text的位置),如果有Z值=2(pattern长度),说明匹配成功。
C++实现代码如下:
cpp
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
using namespace std;
// 复用之前的线性Z函数计算函数
vector<int> computeZ(const string &s) {
int n = s.size();
vector<int> z(n, 0);
int l = 0, r = 0;
for (int i = 1; i < n; ++i) {
if (i <= r) {
z[i] = min(z[i - l], r - i + 1);
}
while (i + z[i] < n && s[z[i]] == s[i + z[i]]) {
z[i]++;
}
if (i + z[i] - 1 > r) {
l = i;
r = i + z[i] - 1;
}
}
return z;
}
// Z函数实现字符串匹配:返回pattern在text中所有起始位置
vector<int> zStringMatch(const string &text, const string &pattern) {
vector<int> res;
string s = pattern + '#' + text; // 拼接字符串
int m = pattern.size();
vector<int> z = computeZ(s);
// 遍历text对应的部分(从m+1开始)
for (int i = m + 1; i < s.size(); ++i) {
// 如果Z值等于pattern长度,说明匹配成功
if (z[i] == m) {
res.push_back(i - m - 1); // 转换为text中的起始位置
}
}
return res;
}
int main() {
string text = "ababab";
string pattern = "ab";
vector<int> positions = zStringMatch(text, pattern);
cout << "模式串\"" << pattern << "\"在文本串\"" << text << "\"中的起始位置:";
for (int pos : positions) {
cout << pos << " "; // 输出:0 2 4
}
return 0;
}
新手 :太高效了!相比暴力匹配,这个方法只需要一次线性遍历。那还有其他应用吗?比如找字符串的最小周期。
导师:当然!咱们来看第二个应用:字符串的最小周期。
(二)找字符串的最小周期
新手 :什么是字符串的最小周期?比如"ababab"的最小周期是"ab"(长度2),怎么用Z函数找?
导师 :字符串的周期定义是:存在最小的p,使得s[i] = s[i+p]对所有i < n-p成立(n是字符串长度)。利用Z数组找最小周期的核心结论是:
如果
n % (n - z[n-1]) == 0,则最小周期长度是n - z[n-1];否则最小周期就是字符串本身的长度n。
举个例子:
s = "ababab",n=6,z[5]=0(n-1=5),n - z[5] = 6,6%6=0?不对,换个例子:s = "abababab"(n=8),z[7]=6,n - z[7] = 2,8%2=0,所以最小周期长度是2;s = "abcabcabc",n=9,z[8]=6,9-6=3,9%3=0,最小周期长度3;s = "abacaba",n=7,z[6]=1,7-1=6,7%6≠0,所以最小周期是7。
C++实现代码:
cpp
#include <iostream>
#include <string>
#include <vector>
#include <algorithm>
using namespace std;
vector<int> computeZ(const string &s) {
// 复用之前的线性Z函数实现
int n = s.size();
vector<int> z(n, 0);
int l = 0, r = 0;
for (int i = 1; i < n; ++i) {
if (i <= r) {
z[i] = min(z[i - l], r - i + 1);
}
while (i + z[i] < n && s[z[i]] == s[i + z[i]]) {
z[i]++;
}
if (i + z[i] - 1 > r) {
l = i;
r = i + z[i] - 1;
}
}
return z;
}
// 找字符串的最小周期长度
int findMinPeriod(const string &s) {
int n = s.size();
vector<int> z = computeZ(s);
int period = n; // 默认周期是字符串本身长度
if (n % (n - z[n-1]) == 0) {
period = n - z[n-1];
}
return period;
}
int main() {
vector<string> testCases = {"ababab", "abcabcabc", "abacaba", "aaaaa"};
for (const string &s : testCases) {
int period = findMinPeriod(s);
cout << "字符串\"" << s << "\"的最小周期长度:" << period << endl;
}
// 输出:
// "ababab":2
// "abcabcabc":3
// "abacaba":7
// "aaaaa":1
return 0;
}
新手 :这个结论太实用了!那回文串问题怎么用Z函数解决?
导师 :回文串的核心是"正读和反读一样",结合Z函数的思路是:把字符串s反转得到s_rev,然后拼接成s + '#' + s_rev,计算Z数组------如果某个位置的Z值等于对应长度,说明是回文。不过回文串的解法有很多,Manacher算法更高效,但Z函数是新手容易理解的入门方法,这里就不展开代码了,核心思路你记住即可。
四、Z函数的常见坑点与优化
新手 :学习过程中容易踩哪些坑?有没有优化技巧?
导师:新手最容易踩的3个坑:
- 分隔符选择 :拼接字符串时,分隔符必须是"不在pattern和text中出现的字符",否则会导致匹配错误(比如pattern和text都包含
#,就换$或%); - z[0]的定义:z[0]固定为0,不要试图修改,否则会破坏整个算法的逻辑;
- 区间更新时机 :只有当
i + z[i] - 1 > r时,才更新l和r,否则会导致区间错误。
优化技巧:
- 如果处理超长字符串(比如1e6长度),可以用
reserve预分配Z数组的空间,避免vector动态扩容的开销; - 对于多模式串匹配,Z函数不如AC自动机高效,但单模式串匹配中,Z函数是最优选择之一。
五、总结
核心要点回顾
- Z函数定义 :Z数组
z[i]表示字符串s从i开始的子串与原串前缀的最长公共前缀长度,z[0]=0; - 实现逻辑 :线性版Z函数通过记录
[l, r]最右匹配区间,复用已计算的Z值,将时间复杂度降到O(n); - 核心应用 :单模式串匹配(拼接字符串+判断Z值)、字符串最小周期(
n % (n - z[n-1]) == 0)、回文串分析等。
学习建议
Z函数是字符串算法的基础,掌握它之后,你可以继续学习KMP算法、Manacher算法等------这些算法的核心思想都是"利用已计算的信息减少重复操作"。建议你多写测试案例,手动推导Z数组的计算过程,直到能独立实现线性版Z函数,并灵活应用到实际问题中。
最后提醒:算法学习的关键是"理解原理+动手实现+多练案例",不要只背代码,要搞懂每一行的逻辑------这样才能应对不同场景的变形问题。