C++Z 函数超详细解析

标题

Z函数:从原理到实战的深度解析(对话版)

引言

在字符串算法的学习中,Z函数(Z-array/Z-Box算法)是绕不开的核心知识点------它以线性时间复杂度解决字符串前缀匹配问题,是字符串匹配、周期分析、回文判断等场景的高效工具。本文以"新手提问+导师解答"的对话形式,从基础定义到代码实现,再到实战应用,全方位拆解Z函数,让编程新手也能吃透这一经典算法。

一、初识Z函数:它到底是什么?

新手 :导师您好!我最近在学字符串算法,看到"Z函数"这个概念完全懵了------它是C++自带的函数吗?核心作用是什么?
导师 :别急,咱们先厘清最基础的定义。首先要明确:Z函数不是C++语言内置函数 (比如coutsort这类),而是字符串领域的经典算法,只因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"的第一个字符b vs a不匹配,所以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函数是字符串处理的"利器",比暴力匹配高效得多,核心应用场景有这几类:

  1. 单模式串匹配 :比如找pattern是否在text中,暴力匹配是O(n*m)(n、m分别为text和pattern长度),Z函数能做到O(n+m);
  2. 字符串周期分析 :比如找"ababab"的最小周期"ab"(长度2),Z数组能快速定位;
  3. 回文串判断:把字符串反转后结合Z函数,可高效分析回文子串;
  4. 重复子串验证 :比如判断"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函数需要引入两个关键变量:

  • lr:表示当前已找到的"最右匹配区间"[l, r],即r是所有已计算的i + z[i] - 1中最大的位置,l是对应i的起始位置;
  • 核心逻辑:遍历到位置i时,先判断i是否在[l, r]内------如果在,就利用z[i - l]的结果初始化z[i],再从r - i + 1开始继续匹配;如果不在,就从0开始暴力匹配,同时更新lr

先看完整代码,再逐行拆解:

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=2r=5(对应i=2时的匹配区间),现在遍历到i=4(在[2,5]内),那么i-l=2z[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)。那怎么验证这个算法的正确性?
导师:可以用几个典型案例测试:

  1. 全相同字符:"aaaaa"的Z数组是[0,4,3,2,1](每个位置i的匹配长度是5-i);
  2. 无重复字符:"abcde"的Z数组是[0,0,0,0,0](每个位置的子串和原串前缀都不匹配);
  3. 部分匹配:"abacaba"的Z数组是[0,0,1,0,3,0,1]

你可以把这些案例代入代码,验证输出是否符合预期------这是学习算法的重要习惯。

三、Z函数的核心应用:从理论到实战

新手 :现在我理解了Z函数的实现,该聊聊它的应用了吧?最常用的应该是字符串匹配吧?
导师:没错!字符串匹配是Z函数最经典的应用,咱们先讲这个,再拓展其他场景。

(一)单模式串匹配

新手 :怎么用Z函数实现字符串匹配?比如找pattern = "ab"是否在text = "ababab"中?
导师 :核心思路是"拼接字符串":把pattern + '#' + text拼接成一个新字符串(#是不在patterntext中的分隔符),然后计算这个新字符串的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个坑:

  1. 分隔符选择 :拼接字符串时,分隔符必须是"不在pattern和text中出现的字符",否则会导致匹配错误(比如pattern和text都包含#,就换$%);
  2. z[0]的定义:z[0]固定为0,不要试图修改,否则会破坏整个算法的逻辑;
  3. 区间更新时机 :只有当i + z[i] - 1 > r时,才更新lr,否则会导致区间错误。

优化技巧:

  • 如果处理超长字符串(比如1e6长度),可以用reserve预分配Z数组的空间,避免vector动态扩容的开销;
  • 对于多模式串匹配,Z函数不如AC自动机高效,但单模式串匹配中,Z函数是最优选择之一。

五、总结

核心要点回顾

  1. Z函数定义 :Z数组z[i]表示字符串si开始的子串与原串前缀的最长公共前缀长度,z[0]=0
  2. 实现逻辑 :线性版Z函数通过记录[l, r]最右匹配区间,复用已计算的Z值,将时间复杂度降到O(n);
  3. 核心应用 :单模式串匹配(拼接字符串+判断Z值)、字符串最小周期(n % (n - z[n-1]) == 0)、回文串分析等。

学习建议

Z函数是字符串算法的基础,掌握它之后,你可以继续学习KMP算法、Manacher算法等------这些算法的核心思想都是"利用已计算的信息减少重复操作"。建议你多写测试案例,手动推导Z数组的计算过程,直到能独立实现线性版Z函数,并灵活应用到实际问题中。

最后提醒:算法学习的关键是"理解原理+动手实现+多练案例",不要只背代码,要搞懂每一行的逻辑------这样才能应对不同场景的变形问题。

相关推荐
青山是哪个青山2 小时前
C++高阶机制与通用技能
c++
白太岁2 小时前
Muduo:(1) 文件描述符及其事件与回调的封装 (Channel)
c++
我命由我123452 小时前
Visual Studio 文件的编码格式不一致问题:错误 C2001 常量中有换行符
c语言·开发语言·c++·ide·学习·学习方法·visual studio
MR_Promethus2 小时前
【C++类型转换】static_cast、dynamic_cast、const_cast、reinterpret_cast
开发语言·c++
近津薪荼2 小时前
dfs专题9——找出所有子集的异或总和再求和
算法·深度优先
52Hz1182 小时前
力扣131.分割回文串、35.搜索插入位置、74.搜索二维矩阵、34.在排序数组中查找...
python·算法·leetcode
Tisfy2 小时前
LeetCode 761.特殊的二进制字符串:分治(左右括号对移动)
算法·leetcode·字符串·递归·分治
Trouvaille ~2 小时前
【Linux】epoll 深度剖析:高性能 IO 多路复用的终极方案
linux·运维·服务器·c++·epoll·多路复用·io模型
小O的算法实验室2 小时前
2025年AEI SCI1区TOP,面向城市区域监视的任务驱动多无人机路径规划三阶段优化策略,深度解析+性能实测
算法·论文复现·智能算法·智能算法改进