前缀函数(部分匹配表(longest prefix suffix)/(partial match table))与 KMP 算法

296 阅读5分钟

前缀函数

字符串的前缀和后缀

  • 如果字符串A和B,存在A = BS,其中S是任意的非空字符串,那就称B为A的前缀
  • 同样可以定义后缀A = SB, 其中S是任意的非空字符串,那就称B为A的后缀

要注意的是,字符串本身并不是自己的前后缀

前缀函数定义

给定一个长度为 n 的字符串 s,其前缀函数被定义为一个长度为 n 的数组 lps. 其中 lps[i] 的定义是:

  1. 如果子串 s[0..i] 有一对相等的前缀与后缀:s[0..k-1] 和 s[i - (k - 1)..i],
    那么 lps[i] 就是这个相等的前缀(或者后缀,因为它们相等)的长度,也就是 lps[i]=k;
  2. 如果不止有一对相等的,那么 lps[i] 就是其中最长的那一对的长度;
  3. 如果没有相等的,那么 lps[i]=0。

简单来说, lps[i]就是子串 s[0..i] 最长的相等的前缀与后缀的长度

特别地,lps[0]=0。

计算前缀函数的朴素算法

  • 在一个循环中以 i = 1 -> n - 1 的顺序计算前缀函数 lps[i] 的值(lps[0] 被赋值为 0)。
  • 为了计算当前的前缀函数值 lps[i],我们令变量 j 从最大的前缀长度 i 开始尝试(注意,i 为索引)。
  • 如果当前长度下前缀和后缀相等,则此时长度为 lps[i],否则令 j 自减 1,继续匹配,直到 j=0。
  • 如果 j = 0 并且仍没有任何一次匹配,则置 lps[i] = 0 并移至下一个下标 i + 1。
fn make_lps(s: &str) -> Vec<usize> {
  let mut lps = vec![0; s.len()];
  for i in 1..s.len() {
    for j in (0..=i).rev() {
      let prefix = &s[0..j];
      let suffix = &s[i - j + 1..=i];
      if prefix == suffix {
        lps[i] = j;
        break;
      }
    }
  }
  lps
}

计算前缀函数的优化一:相邻的前缀函数值至多增加 1

当移动到下一个位置时,前缀函数的值要么增加一,要么维持不变,要么减少。

最大前缀长度为 lps[i - 1] + 1。即 lps[i] <= lps[i - 1] + 1

fn make_lps(s: &str) -> Vec<usize> {
  let mut lps = vec![0; s.len()];
  for i in 1..s.len() {
    for j in (0..=lps[i - 1] + 1).rev() {
      let prefix = &s[0..j];
      let suffix = &s[i - j + 1..=i];
      if prefix == suffix {
        lps[i] = j;
        break;
      }
    }
  }
  lps
}

计算前缀函数的优化二

对于如下字符串s:

s[0], s[1], s[2], s[3], .. , s[i - 2], s[i - 1], s[i], s[i + 1]
  • 若 lps[i] = 1,那么如果lps[i + 1]能够增加,即lps[i + 1] = 2,则 s[1] == s[i + 1]
  • 若 lps[i] = 2,那么如果lps[i + 1]能够增加,即lps[i + 1] = 3,则 s[2] == s[i + 1]
  • 若 lps[i] = 3,那么如果lps[i + 1]能够增加,即lps[i + 1] = 4,则 s[3] == s[i + 1]

也就是

if s[lps[i]] == s[i + 1] {
lps[i + 1] = lps[i] + 1
}

那如果 s[lps[i]] != s[i + 1] 怎么办?

  • 一个简单的想法是从 0 开始重新计算 lps[i]。但这显然是低效的!

能不能利用前缀函数的性质呢?

  • 如果能够找到s[0..i]的一个仅次于 lps[i] 的长度 j1,使得 s[0..j1 - 1] == s[i - j1 + 1..i],
    那么仅需要再次比较 s[i + 1] 和 s[j1],如果相等,那么 lps[i\ + 1] = j1 + 1
  • 否则,需要找到子串 s[0..i] 仅次于 j 的第二长度 j2,如此反复。直到 j == 0,如果 s[i + 1] != s[0],lps[i\ + 1] = 0
let s = "abcabcxxabcabca";
s[0], s[1], s[2], s[3], s[4], s[5], s[6], .. , s[i - 6], s[i - 5], s[i - 4], s[i - 3], s[i - 2], s[i - 1], s[i], s[i + 1]
lps = [0, 0, 0, 1, 2, 3, 0, 0, 1, 2, 3, 4, 5, 6];

观察上面的字符串,i == 13, j1 == 3,因为 s[0..lps[i] - 1] == s[i - lps[i] + 1..i],
所以对于 s[0..i] 的第二长度 j1,有 s[0..j1 - 1] == s[i - j1 + 1..i] == s[lps[i] - j1 ..lps[i] - 1]

而 s[lps[i] - j1..lps[i] - 1] 恰好是字串 s[0..lps[i] - 1]的后缀

那么 j1 的最大值就是 lps[lps[i] - 1]

同理,次于 j1 的第二长度 j2 等价于 s[j1 - 1] 的前缀函数值 lps[j1 - 1]

显然我们可以得到一个关于 j 的状态转移方程:

jn = lps[j(n-1) -1], j(n - 1) > 0.

fn make_lps(s: &str) -> Vec<usize> {
  let s = s.as_bytes();
  let mut lps = vec![0; s.len()];
  for i in 1..s.len() {
    let mut j = lps[i - 1];
    while j > 0 && s[j] != s[i] {
      j = lps[j - 1];
    }
    if s[j] == s[i] {
      j += 1;
    }
    lps[i] = j;
  }
  lps
}

复杂度分析

这个算法的复杂度是 O(n),是令我困惑的地方,为什么呢?

当处理到索引 i 时,求解过程可以认为只有两个过程:

  1. 积累过程:经过一次字符比较, 令lps[i + 1] = lps[i]  + 1,这里可以认为进行了一次比较一次赋值共两次 O(1) 操作
  2. 消耗过程:如果当前不匹配的话,会消耗当前的 lps[i],迭代查询满足 s[i + 1] == s[jk] 的 jk。观察迭代式 jn = lps[j(n-1) -1] 可以发现,这里 jk 每一次迭代至少减少 1 ,至多减少 lps[i] (一次全部消耗完),也就是至多迭代 lps[i] 次,至少迭代 1 次,而每次迭代会执行一次赋值一次字符比较共两次 O(1) 的操作,也就是 1 × 2 <= T <=  lps[i] × 2,T 是操作次数

而 lps[i] 是小于等于字符串长度 n 的,尽管当 lps[i] == n 时,是最好的情况,只有积累没有消耗过程,但是如果不满足,为什么在消耗的时候,复杂度却是常数呢?

前缀函数的应用: KMP算法

在字符串中查找子串:Knuth–Morris–Pratt 算法

该算法由 Knuth、Pratt 和 Morris 在 1977 年共同发布

给定一个文本 t 和一个字符串 s,我们尝试找到并展示 s 在 t 中的所有出现(occurrence)

为了简便起见,用 n 表示字符串 s 的长度,用 m 表示文本 t 的长度。

  1. 构造一个字符串 st = s + # + t,其中 # 为一个既不出现在 s 中也不出现在 t 中的分隔符
  2. 计算该字符串的前缀函数

考虑该前缀函数除去最开始 n + 1 个值(即属于字符串 s 和分隔符的函数值)后其余函数值的意义

根据定义,lps[i] 为右端点在 i 且同时为一个前缀的最长子串的长度,具体到这种情况下,其值为与 s 的前缀相同且右端点位于 i 的最长子串的长度。

由于分隔符的存在,该长度不可能超过 n。

而如果等式 lps[i] = n 成立,则意味着 s 完整出现在该位置(即其右端点位于位置 i)。注意该位置的下标是对字符串 s + # + t 而言的。

因此如果在某一位置 i 有 lps[i] = n 成立,则字符串 s 在字符串 st 的 i - (n - 1) 处出现,

字符串 s 在 t 的 i - (n - 1) - (n + 1) = i -2n 处出现

let t = "4sadbutsad";
let s = "sad";
let st = "sad#4sadbutsad";
let lps = [0, 0, 0, 0, 0, 1, 2, 3, 0, 0, 0, 1, 2, 3]
fn kmp(t: String, s: String) -> Vec<usize> {
  let st = s.clone() + "#" + &t;
  let lps = make_lps(&st);
  let mut res = vec![];

  for i in s.len() + 1..st.len() {
    if lps[i] == s.len() {
      res.push(i - s.len() * 2);
    }
  }
  res
}

复杂度分析

KMP 算法的复杂度分析,关键在 make_lps 函数,而它是 O(n) 的,所以 KMP 算法 O(n + m) 的时间以及 空间复杂度。

有知道如何分析 make_lps 的复杂度的欢迎留言!