重学算法:字符串匹配

317 阅读4分钟

更新ing...

前置

假设有两个串(其中t(i)、p(j)是字符):

t = t(0)t(1)t(2)...t(n-1)

p = p(0)p(1)p(2)...p(m-1)

字符串匹配就是在t中查找与p相同的子串的操作(或过程)。可将t称为目标串,p称为模式串。通常模式串的长度远小于目标串的长度。

应用

  • 在文本中查找单词或句子,拼写错误的标识符。
  • 垃圾邮件过滤器。
  • 检索表征病毒片段。

具体算法及实现

字符串是最简单的线性序列,其结构也最简单,粗看起来,字符串匹配是一个非常简单的问题。字符串算法设计的关键有两点:

  1. 怎么选择开始比较的字符对;
  2. 发现了不匹配后,下一步怎么做。

对这两点的不同处理策略,就形成了不同的字符串匹配算法。

朴素匹配算法

最简单的朴素匹配算法采用最直观可行的策略:

  1. 从左到右逐个字符匹配;
  2. 发现不匹配时,转去考虑目标串里的下一个位置是否与模式串匹配。
function naiveMatching(t, p) {
  const pLen = p.length;
  const tLen = t.length;
  let i = 0;
  let j = 0;

  while (i < pLen && j < tLen) {
    if (p[i] === t[j]) {
      i += 1;
      j += 1;
    } else {
      j = j - i + 1;
      i = 0;
    }
  }

  if (i === pLen) {
    return j - i;
  }

  return -1;
}

该匹配算法,非常简单,但其效率低下。造成其低效率的主要因素是执行中可能出现回溯:匹配中遇一对字符不同时,模式串p将右移一个字符位置,随后的匹配回到模式串的开始(重置j=0),也回到目标串中前面的下一个位置,从那里再次由p(0)开始比较字符。每次字符比较看作完全独立的操作,完全没有利用字符串本身的特点,也没有尽可能地利用前面已经做过的字符比较中得到的信息。

无回溯串匹配算法(KMP 算法)

KMP 算法是根据三位作者(D.E.Knuth,J.H.Morris 和 V.R.Pratt)的名字来命名的,算法的全称是 Knuth Morris Pratt 算法,简称为 KMP 算法。

KMP算法的基本思想是匹配不回溯,在匹配失败时把模式串前移若干位置,用模式串里匹配失败字符之前的某个字符与目标串中匹配失败的字符比较。

KMP算法设计的关键:在p(i)匹配失败时,所有的p(k)(0 <= k < i)都已经匹配成功(否则就不会考虑p(i)的匹配问题)。这也就是说,在目标串中t(j)之前的i个字符也就是模式串p的前i个字符。这说明,完全可以在实际地与任何目标串匹配之前,通过对模式串本身的分析,解决好匹配失败时应该怎样前移的问题。

先来分析模式串,构造pnext表用于记录匹配失败时,模式串前移的位置。该表遵循以下原则:

  • 模式串移动之后,作为下一个用于匹配的字符的新位置,其前缀子串应该与匹配失败的字符之前同样长度的子串相同。
  • 如果匹配在模式串的位置i失败时,而位置i的前缀子串中满足上述条件的位置不止一处,那么只能做最短的移动,将模式串移到最近的那个满足上述条件的位置,以保证不遗漏可能的匹配。

"前缀"指除了最后一个字符以外,一个字符串的全部头部组合;"后缀"指除了第一个字符以外,一个字符串的全部尾部组合。

字符串 abababzababab 来说:

  • 前缀有 a, ab, aba, abab, ababa, ababab, abababz, ...
  • 后缀有 b, ab, bab, abab, babab, ababab, zababab, ...

PMT(Partial Match Table)中的值是字符串的前缀集合与后缀集合的交集中最长元素的长度。在把PMT进行向右偏移时,第0位的值,我们将其设成了-1,这只是为了编程的方便,并没有其他的意义,这就是pNext。

kmp

function genPNext(p) {
  const pLen = p.length;
  let i = 0;
  let k = -1;
  const pNext = Array.from({ length: pLen }, _ => -1);
  
  // 生成下一个pNext元素值
  while (i < pLen - 1) {
    if (k === -1 || p[i] === p[k]) {
      i += 1;
      k += 1;
      // 设置pNext元素
      pNext[i] = k;
    } else {
      // 退到更短相同前缀
      k = pNext[k];
    }
  }
  return pNext;
}

KMP算法实现:

function matchingKMP(t, p, pNext) {
  let j = 0;
  let i = 0;
  const tLen = t.length;
  const pLen = p.length;

  while (j < tLen && i < pLen) {
    if (i === -1 || t[j] === p[j]) {
      j += 1;
      i += 1;
    } else {
      i = pNext[i];
    }
  }

  if (i === pLen) {
    return j - i;
  }
  return -1;
}