Part 6.3:Z-Algorithm

引言:字符串的“自我参照”

在字符串算法的世界里,KMP 通过预处理“模式串”实现了高效匹配。但还有一类更底层的算法,它们专注于分析字符串自身的结构特性。Z-Algorithm 就是其中的佼佼者。

Z-Algorithm 解决了一个看似简单但功能强大的问题:对于一个给定的字符串 S,计算 S 与其每一个后缀 S[i:]最长公共前缀 (Longest Common Prefix, LCP) 的长度。这些长度值被存储在一个称为 Z-array 的数组中。

例如,对于字符串 S = "abacaba"

  • 后缀 S[1:] = "bacaba"S 的 LCP 长度为 0。
  • 后缀 S[2:] = "acaba"S 的 LCP 长度为 1 (都是 ‘a’)。
  • 后缀 S[3:] = "caba"S 的 LCP 长度为 0。
  • 后缀 S[4:] = "aba"S 的 LCP 长度为 3 (都是 ‘aba’)。

Z-Algorithm 的惊人之处在于,它能在线性时间 O(n) 内计算出整个 Z-array。一旦拥有了这个数组,我们就可以用它来解决许多问题,其中最直接的应用就是实现一个与 KMP 同样高效的模式匹配算法。

为什么 Z-Algorithm 如此重要?

  • 面试价值:Z-Algorithm 虽然不如 KMP 知名,但它同样是一个深刻体现线性扫描和信息复用思想的算法。在面试中,如果能写出并解释 Z-Algorithm,绝对会让你在众多候选人中脱颖而出,展现出你对字符串算法的深刻理解。
  • 理论与工程价值:Z-Algorithm 是字符串学(Stringology)中的一个基础工具,其思想被应用于更高级的算法中。它在文本压缩、生物信息学等领域也有应用。

本文将带你深入 Z-Algorithm 的内部,理解其线性时间复杂度的奥秘——Z-box 的巧妙利用。

背景知识:Z-array 的定义

对于一个长度为 n 的字符串 S,它的 Z-array 是一个长度为 n 的整数数组 Z,其中:

Z[i] = 字符串 S 与其后缀 S[i:] (从索引 i 开始的后缀) 的最长公共前缀的长度。

约定Z[0] 没有明确的定义,通常设为 0 或 n。在算法实现中,我们通常从 i=1 开始计算。

例子S = "aabcaabxaaaz"

  • Z[1]=1 (S[1:]=”abcaabx…”, S=”aabc…”, LCP=”a”)
  • Z[2]=0
  • Z[3]=2 (S[3:]=”caabx…”, S=”aabc…”, LCP=””) -> Z[3]=0. Let me re-calculate. S[3] is ‘c’, S[0] is ‘a’. Mismatch. So Z[3]=0.
  • Let’s take a better example: S = "abacaba"
    • Z[0] = 0 (by convention)
    • Z[1] = 0 (b vs a)
    • Z[2] = 1 (a vs a)
    • Z[3] = 0 (c vs a)
    • Z[4] = 3 (aba vs aba)
    • Z[5] = 0 (b vs a)
    • Z[6] = 1 (a vs a)
    • Z-array: [0, 0, 1, 0, 3, 0, 1]

详细算法原理

朴素的计算 Z-array 的方法是:对于每个 i,都从头比较 SS[i:],时间复杂度是 O(n²)。Z-Algorithm 的精髓在于如何将这个过程优化到 O(n)

1. 算法的“直觉”:利用已知的“匹配区域”

Z-Algorithm 的核心是维护一个“匹配盒子”——Z-box。这个 Z-box [L, R] 代表的是到目前为止,我们所找到的结束位置最靠右的、与 S 的前缀相匹配的子串。

  • L:Z-box 的左边界。
  • R:Z-box 的右边界。

当我们计算 Z[i] 时,如果 i 恰好落在了当前的 Z-box [L, R] 内部,我们就不需要从头开始比较了!因为我们已经知道 S[L...R] 等于 S[0...R-L]

图示:Z-box

S: [p p p p p p p p p p p p] ...
   |<- Z[L] ->|
   [L         R]

S[L...R] == S[0...R-L]

既然 S[i][L, R] 内,那么 S[i] 对应的在 S 前缀中的字符是 S[i-L]。这意味着,以 S[i] 开头的子串,其行为在一定程度上会和以 S[i-L] 开头的子串相似。

我们已经计算过 Z[i-L] 了!这就是信息复用的关键。

2. 严谨原理:两种情况的分析

我们从 i = 1 开始计算 Z[i],并维护 LR (初始 L=R=0)。

Case 1: i > R (当前位置在 Z-box 外)

如果 i 在 Z-box 的右边,说明我们没有任何已知信息可以利用。只能采用朴素的方法

  1. S[i]S[0] 开始,一个字符一个字符地向后比较。
  2. 直到遇到不匹配的字符或者到达字符串末尾。
  3. 计算出 Z[i] 的值。
  4. 如果这次匹配形成的新的 Z-box [i, i+Z[i]-1] 比当前的 [L, R] 更靠右,就更新 L=i, R=i+Z[i]-1

Case 2: i <= R (当前位置在 Z-box 内)

这是算法的精髓。我们令 k = i - Lki 在 Z-box 内部相对于 L 的偏移量,也是 i 在前缀 S[0...R-L] 中对应的位置。

我们已经知道 Z[k] 的值。现在有两种子情况:

Case 2a: Z[k] < R - i + 1

Z[k] 的长度小于 iR 的剩余长度。这说明以 S[k] 开头的 LCP 完全被包含在 S[0...R-L] 这个前缀内部。由于 S[L...R] 与这个前缀完全相同,我们可以安全地得出结论:

Z[i] = Z[k]

图示:Case 2a

S: [p p p p p p p p p p p p] ...
   |<- Z[k] ->|
   [0 k         R-L]

S: ... [p p p p p p p p p p p p] ...
         |<- Z[k] ->|
         [L i         R]

Case 2b: Z[k] >= R - i + 1

Z[k] 的长度大于或等于 iR 的剩余长度。这意味着以 S[i] 开头的 LCP 至少能延伸到 R。它可能还会更长

我们不能直接断定 Z[i] = Z[k],因为 S[R+1] 之后的信息是未知的。但我们已经有了一个很好的起点。

  1. 我们从 R+1 的位置开始,继续比较 S[R+1]S[R-i+1]S[R+2]S[R-i+2],以此类推。
  2. 直到遇到不匹配或字符串结尾。
  3. 计算出 Z[i] 的最终值。
  4. 因为我们肯定扩展到了 R 的右边,所以必须更新 L=i, R=i+Z[i]-1

通过这种方式,R 指针永远只会向右移动,保证了整个算法的线性时间复杂度。

步骤分解 (Step-by-step)

  1. 初始化:创建 Z-array Zn 为字符串长度。L=0, R=0
  2. 主循环i1n-1
  3. 判断位置
    a. 如果 i > R (Case 1):
    i. L=i, R=i
    ii. while R < n and S[R] == S[R-L]R++
    iii. Z[i] = R - L
    iv. R-- (因为 while 循环多加了一次)。
    b. 如果 i <= R (Case 2):
    i. k = i - L
    ii. 如果 Z[k] < R - i + 1 (Case 2a),则 Z[i] = Z[k]
    iii. 否则 (Case 2b),L=i,然后从 R+1 开始朴素比较,更新 R,计算 Z[i]
  4. 返回 Z 数组。

Python 实现

def compute_z_array(s: str) -> list[int]:
    """计算字符串 s 的 Z-array"""
    n = len(s)
    if n == 0:
        return []
    
    z = [0] * n
    l, r = 0, 0  # Z-box 的左右边界

    for i in range(1, n):
        # Case 1: i 在 Z-box 外
        if i > r:
            l, r = i, i
            while r < n and s[r] == s[r - l]:
                r += 1
            z[i] = r - l
            r -= 1
        # Case 2: i 在 Z-box 内
        else:
            k = i - l
            # Case 2a: Z[k] 不会超出 Z-box
            if z[k] < r - i + 1:
                z[i] = z[k]
            # Case 2b: Z[k] 可能会超出 Z-box
            else:
                l = i
                while r < n and s[r] == s[r - l]:
                    r += 1
                z[i] = r - l
                r -= 1
                
    return z

# --- 案例测试 ---
s = "aabcaabxaaaz"
z_values = compute_z_array(s)
print(f"String: {s}")
print(f"Z-array: {z_values}")
# 预期: [0, 1, 0, 0, 3, 1, 0, 0, 3, 1, 1, 0]

复杂度推导过程

  • 时间复杂度: O(n)
    • 外层 for 循环执行 n-1 次。
    • 内层的 while 循环看起来可能会导致高复杂度,但我们可以通过均摊分析来证明其线性。关键在于 R 指针。R 只会单调向右移动,永不回退。在整个算法中,R 最多从 0 移动到 n-1while 循环中的字符比较次数,等于 R 指针增加的总次数。因此,所有 while 循环的总执行次数加起来是 O(n) 的。
    • 总时间复杂度为 O(n) + O(n) = O(n)
  • 空间复杂度: O(n)
    • 我们需要一个 Z 数组来存储结果,其长度为 n

应用:使用 Z-Algorithm 进行模式匹配

如何用 Z-Algorithm 实现 KMP 的效果?

  1. 构造新字符串:将模式串 P 和文本串 T 用一个不会出现在两者中的特殊字符(如 #)拼接起来,形成新字符串 S = P + "#" + T
  2. 计算 Z-array:计算 S 的 Z-array。
  3. 查找匹配:遍历 Z-array。如果 Z[i] 的值等于模式串 P 的长度 m,那么就意味着从 S 的第 i 个位置开始的后缀,其与 S 的前缀(即 P)的匹配长度为 m。这说明我们在 T 中找到了一个 P 的匹配项。其在原始文本串 T 中的起始位置是 i - m - 1

Python 实现

def z_search(text, pattern):
    m = len(pattern)
    if m == 0:
        return 0
    
    s = pattern + "#" + text
    z_array = compute_z_array(s)
    
    for i in range(m + 1, len(s)):
        if z_array[i] == m:
            return i - m - 1
            
    return -1

# --- 案例测试 ---
text = "ABABDABACDABABCABAB"
pattern = "ABABCABAB"
start_index = z_search(text, pattern)
print(f"在 text 中找到 pattern, 起始索引为: {start_index}") # 预期: 10

总结

Z-Algorithm 是一个强大而优美的线性时间字符串算法。它通过维护一个动态的“Z-box”,巧妙地复用了已有的匹配信息,避免了不必要的字符比较,从而实现了惊人的效率。

本文核心要点

核心概念:Z-array Z[i] 存储字符串 S 与其后缀 S[i:] 的最长公共前缀长度。
核心思想:维护一个结束位置最靠右的 Z-box [L, R],利用 Z-box 内的信息来加速计算。
两种情况i 在 Z-box 外(朴素比较),i 在 Z-box 内(利用已知的 Z[k] 值)。
复杂度:时间 O(n),空间 O(n)
应用:可以非常简洁地实现在 O(n+m) 时间内的模式匹配。

掌握了 Z-Algorithm,你不仅多了一个解决字符串问题的利器,更能深刻体会到算法设计中“信息复用”和“均摊分析”的精髓。在下一篇文章中,我们将学习另一个处理回文串的终极武器——Manacher 算法