算法设计 - KMP算法

字符串模式匹配问题

假设有两个字符串S,T,其中S是主串(正文串),T为子串(模式串),

我们需要在S中查找与T相匹配的子串,如果成功找到,则返回匹配的子串第一个字符在主串中的位置。

暴力算法解决

图示

假设S = "aabaabaaf",T = "aabaaf",则暴力解法过程如下图所示:

 上图匹配过程中,分为两个循环:

外层循环,即匹配的轮数控制,或者说是,S串的匹配起始位置控制,比如:

  • 第0轮,T串是从S串的0索引位置开始匹配
  • 第1轮,T串是从S串的1索引位置开始匹配
  • ...
  • 第k轮,T串是从S串的 k 索引位置开始匹配

内层循环,即T串和S串的 k ~ k + t.length 范围进行逐个字符一一匹配,

  • 如果发现存在对应位的字符不一致,则说明当前轮匹配失败,直接进入下一轮
  • 如果所有位置上的字符都相同,则说明匹配成功,即在S中找到了和T相同的子串,且该子串起始位置是k

假设,s.length = n,t.length = m,则暴力解法的时间复杂度为O(n * m)

代码实现

JS算法源码

/**
 * @param {*} s 正文串
 * @param {*} t 模式串
 * @returns 在s中查找与t相匹配的子串,如果成功找到,则返回匹配的子串第一个字符在主串中的位置
 */
function indexOf(s, t) {
  // k指向s的起始匹配位置
  for (let k = 0; k <= s.length - t.length; k++) {
    let i = k;
    let j = 0;

    while (j < t.length && s[i] == t[j]) {
      i++;
      j++;
    }

    if (j == t.length) {
      return k;
    }
  }

  return -1;
}

const s = "aabaabaafaab";
const t = "aabaaf";
console.log(indexOf(s, t));

Java算法源码

public class Main {
  public static void main(String[] args) {
    String s = "aabaabaaf";
    String t = "aabaaf";
    System.out.println(indexOf(s, t));
  }

  /**
   * @param s 正文串
   * @param t 模式串
   * @return 在s中查找与t相匹配的子串,如果成功找到,则返回匹配的子串第一个字符在主串中的位置
   */
  public static int indexOf(String s, String t) {
    // k指向s的起始匹配位置
    for (int k = 0; k <= s.length() - t.length(); k++) {
      int i = k;
      int j = 0;

      while (j < t.length() && s.charAt(i) == t.charAt(j)) {
        i++;
        j++;
      }

      if (j == t.length()) {
        return k;
      }
    }

    return -1;
  }
}

Python算法源码

def indexOf(s, t):
    """
    :param s: 正文串
    :param t: 模式串
    :return: 在s中查找与t相匹配的子串,如果成功找到,则返回匹配的子串第一个字符在主串中的位置
    """

    # k指向s的起始匹配位置
    for k in range(len(s) - len(t) + 1):
        i = k
        j = 0

        while j < len(t) and s[i] == t[j]:
            i += 1
            j += 1

        if j == len(t):
            return k

    return -1


if __name__ == '__main__':
    s = "aabaabaaf"
    t = "aabaaf"

    print(indexOf(s, t))

KMP算法

暴力解法的改进策略

对于字符串模式匹配问题,暴力算法并非最优解决方案,虽然s,t都是随机串,但是这些随机串也会存在一定规律可以利用。

比如前面例子中,s = "aabaabaaf",t = "aabaaf"

在第0轮匹配失败后,第1轮,第2轮是否注定失败了呢?

如下图是第0轮最后一次匹配失败的情况:

我们观察其中匹配成功的部分,即"aabaa"部分,这部分具有一定的对称性,

如果我们将S,T的"aabaa"后面部分抽象化,如下图所示,那么:

  • 第0轮匹配失败是因为“抽象部分”的匹配失败
  • 第1轮,第2轮匹配失败,其实就是"aabaa"部分的匹配失败:

 我们将第1轮,第2轮,第3轮再次简化一下,如下图所示:

那么是不是很显然可以发现,第1轮,第2轮是注定失败的。

我们再举一个例子:

如果上面S,T在第0轮因为抽象部分匹配失败,那么下一轮,其实可以直接跳转到对称位置开始进行匹配,因为非对称位置的匹配肯定是失败的。

这样的话,是不是跳过了两轮匹配,即节省了两轮匹配的时间。

请大家再思考一下,上面直接跳转的对称部分重新匹配真的是只节省两轮匹配的过程吗?

下面图示是,第0轮匹配失败后,直接跳到对称部分开始重新匹配

如果对应到暴力解法过程的话,那么下面画X的部分就都是跳过的过程

我们再观察下这个跳到对称部分的过程中,i,j指针的变化

可以发现,i 指针在S中的位置并没有改变,而 j 指针回退指向到了T的"aabaa"对称字符串的中心位置"b"。

那么上面这个改进算法的时间复杂度是多少呢?

由于上面算法中,保证了 i 指针不会回退,因此时间复杂度只有O(n)。

而这个算法其实就是KMP算法。

前缀表

前面我们已经知道了KMP算法的大致原理,其中最关键的就是在模式串T中找其子串的对称部分,

那么该如何通过代码来实现这个功能呢?

KMP算法的三个创始人K,M,P提出了前缀表的概念。

比如T = "aabaaf",则我们首先需要找到T的所有子串:

  • a
  • aa
  • aab
  • aaba
  • aabaa
  • aabaaf

然后计算这些子串的最长相同的前缀和后缀的长度

假设字符串s长度为n,那么:

  • 前缀就是起始索引必须为0,结束索引<n-1的所有子串
  • 后缀就是结束索引必须为n-1,起始索引必须>0的所有子串

因此

  • 前缀和后缀不能是字符串s本身
  • 字符串s的前缀和后缀是可能存在重叠部分的

我们举一个例子,比如列出T的子串"aabaa"的所有的前缀和后缀

长度 前缀 后缀
1 a a
2 aa aa
3 aab baa
4 aaba abaa

其中最长且相同的前后缀是"aa"。

注意,判断前缀和后缀是否相同,都是从左往右逐一比对,因此上面例子中,长度为3的前缀"aab"和后缀"baa"是不相同的。

还有相同的前缀、后缀是可能存在重叠,

比如下面字符串"ababab",最长相同的前缀和后缀是"abab"

长度 前缀 后缀
1 a b
2 ab ab
3 aba bab
4 abab abab
5 ababa babab

因此T = "aabaaf"所有子串的最长相同的前缀和后缀的长度分别为:

T的子串 最长相同的前后缀 最长相同的前后缀的长度
a 0
aa a 1
aab 0
aaba a 1
aabaa aa 2
aabaaf 0

上面前缀表,我们一般用next数组表示

next = [0, 1, 0, 1, 2, 0]

前缀表的应用

前面我们手算出了前缀表next数组

next = [0, 1, 0, 1, 2, 0]

那么next数组元素的含义是什么呢?

next[j]元素其实就是0~j子串的最长相同前后缀长度,比如:

  • next[0],就是T的0~0子串"a"的最长相同前后缀长度
  • next[1],就是T的0~1子串"aa"的最长相同前后缀长度
  • next[2],就是T的0~2子串"aab"的最长相同前后缀长度
  • next[3],就是T的0~3子串"aaba"的最长相同前后缀长度
  • next[4],就是T的0~4子串"aabaa"的最长相同前后缀长度
  • next[5],就是T的0~5子串"aabaaf"的最长相同前后缀长度

那么如何将next应用到KMP算法中呢?

比如下图中,s[i] != t[j]时,我们前面分析过,需要做如下动作:

  • i 指针保持指向不变
  • j 指针回退到对称部分的中心位置

这样运动的好处是,

  • 避免了 i 指针的回退(增加冗余比较轮次)
  • 避免了 对称部分中心位置之前部分的冗余匹配(因为必然相同,所以是冗余匹配)

但是,这里的对称部分中心位置的表述,其实非常不研究,更严谨一点的表述:应该是最长相同前后缀中“前缀的结束位置的后一个位置”。

而最长相同前后缀的前缀结束位置的后一个位置,其实就是最长相同前后缀的长度

因此,当s[i] != t[j] 时,我们应该让 j = next[ j - 1 ]

另外,如果 j = 0 时就匹配不上,此时next[j-1]就发生越界异常,因此针对这种i情况,我们应该特殊处理,如下图所示,就是一个 j = 0无法匹配的情况:

此时,我们应该让 i++,j 保持不变,继续匹配

这其实和前面KMP算法规定的 i 指针不回退这一条件不冲突。因为上面过程 i 指针没有发生回退。

KMP算法实现(不包含前缀表生成实现)

这里关于前缀表的生成逻辑先不实现,单纯实现KMP算法的逻辑

JS算法源码

/**
 * @param {*} s 正文串
 * @param {*} t 模式串
 * @returns 在s中查找与t相匹配的子串,如果成功找到,则返回匹配的子串第一个字符在主串中的位置
 */
function indexOf(s, t) {
  // 手算的T串"aabaaf"对应的前缀表
  let next = [0, 1, 0, 1, 2, 0];
  // 手算的T串"cabaa"对应的前缀表
  // next = [0, 0, 0, 0, 0];

  let i = 0; // 扫描S串的指针
  let j = 0; // 扫描T串的指针

  // 如果 i 指针扫描到S串结束位置,或者 j 指针扫描到T串的结束位置,都应该结束查找
  while (i < s.length && j < t.length) {
    if (s[i] == t[j]) {
      // 如果 s[i] == t[j],则当前位置匹配成功,继续匹配下一个位置
      i++;
      j++;
    } else {
      // 如果 s[i] != t[j],则说明当前位置匹配失败,
      // 根据KMP算法,我们只需要回退T串的 j 指针到 next[j-1]位置,即最长相同前缀的结束位置后面一个位置,而S串的 i 指针保持不动
      if (j > 0) {
        j = next[j - 1];
      } else {
        // 如果 j = 0,则说明S子串subS和T在第一个字符上就匹配不上, 此时T不匹配字符T[j]前面已经没有前后缀了,因此只能匹配下一个S子串
        i++;
      }
    }
  }

  // 如果最终可以在S串中找到匹配T的子串,则T串的所有字符都应该被j扫描过,即最终 j = t.length
  if (j >= t.length) {
    // 则S串中匹配T的子串的首字符位置应该在 i - t.length位置,因为 i 指针最终会扫描到S串中匹配T的子串的结束位置的后一个位置
    return i - j;
  } else {
    // 否则就是没有在S中找到匹配T的子串
    return -1;
  }
}

const s = "aabaabaafaab";
let t = "aabaaf";
// t = "cabaa"; // 该T串用于测试第一个字符就不匹配的情况
console.log(indexOf(s, t));

Java算法源码

public class Main {
  public static void main(String[] args) {
    String s = "aabaabaaf";
    String t = "aabaaf";
    //    t = "cabaa"; // 该T串用于测试第一个字符就不匹配的情况

    System.out.println(indexOf(s, t));
  }

  /**
   * @param s 正文串
   * @param t 模式串
   * @return 在s中查找与t相匹配的子串,如果成功找到,则返回匹配的子串第一个字符在主串中的位置
   */
  public static int indexOf(String s, String t) {
    // 手算的T串"aabaaf"对应的前缀表
    int[] next = {0, 1, 0, 1, 2, 0};
    // 手算的T串"cabaa"对应的前缀表
    //    next = new int[] {0, 0, 0, 0, 0};

    int i = 0; // 扫描S串的指针
    int j = 0; // 扫描T串的指针

    // 如果 i 指针扫描到S串结束位置,或者 j 指针扫描到T串的结束位置,都应该结束查找
    while (i < s.length() && j < t.length()) {
      // 如果 s[i] == t[j],则当前位置匹配成功,继续匹配下一个位置
      if (s.charAt(i) == t.charAt(j)) {
        i++;
        j++;
      } else {
        // 如果 s[i] != t[j],则说明当前位置匹配失败,
        // 根据KMP算法,我们只需要回退T串的 j 指针到 next[j-1]位置,即最长相同前缀的结束位置后面一个位置,而S串的 i 指针保持不动
        if (j > 0) {
          j = next[j - 1];
        } else {
          // 如果 j = 0,则说明S子串subS和T在第一个字符上就匹配不上, 此时T不匹配字符T[j]前面已经没有前后缀了,因此只能匹配下一个S子串
          i++;
        }
      }
    }

    // 如果最终可以在S串中找到匹配T的子串,则T串的所有字符都应该被j扫描过,即最终 j = t.length
    if (j == t.length()) {
      // 则S串中匹配T的子串的首字符位置应该在 i - t.length位置,因为 i 指针最终会扫描到S串中匹配T的子串的结束位置的后一个位置
      return i - j;
    } else {
      // 否则就是没有在S中找到匹配T的子串
      return -1;
    }
  }
}

Python算法源码

def indexOf(s, t):
    """
    :param s: 正文串
    :param t: 模式串
    :return: 在s中查找与t相匹配的子串,如果成功找到,则返回匹配的子串第一个字符在主串中的位置
    """

    # 手算的T串"aabaaf"对应的前缀表
    next = [0, 1, 0, 1, 2, 0]

    # 手算的T串"cabaa"对应的前缀表
    # next = [0, 0, 0, 0, 0]

    i = 0  # 扫描S串的指针
    j = 0  # 扫描T串的指针

    # 如果 i 指针扫描到S串结束位置,或者 j 指针扫描到T串的结束位置,都应该结束查找
    while i < len(s) and j < len(t):
        # 如果 s[i] == t[j],则当前位置匹配成功,继续匹配下一个位置
        if s[i] == t[j]:
            i += 1
            j += 1
        else:
            # 如果 s[i] != t[j],则说明当前位置匹配失败
            # 根据KMP算法,我们只需要回退T串的 j 指针到 next[j-1]位置,即最长相同前缀的结束位置后面一个位置,而S串的 i 指针保持不动
            if j > 0:
                j = next[j - 1]
            else:
                # 如果 j = 0,则说明S子串subS和T在第一个字符上就匹配不上, 此时T不匹配字符T[j]前面已经没有前后缀了,因此只能匹配下一个S子串
                i += 1

    # 如果最终可以在S串中找到匹配T的子串,则T串的所有字符都应该被j扫描过,即最终 j = t.length
    if j >= len(t):
        # 则S串中匹配T的子串的首字符位置应该在 i - t.length位置,因为 i 指针最终会扫描到S串中匹配T的子串的结束位置的后一个位置
        return i - j
    else:
        # 否则就是没有在S中找到匹配T的子串
        return -1


if __name__ == '__main__':
    s = "aabaabaaf"
    t = "aabaaf"
    # t = "cabaa"  # 该T串用于测试第一个字符就不匹配的情况

    print(indexOf(s, t))

前缀表的生成

前面我们已经手算过了前缀表,但是手算过程是一个暴力枚举的过程,即枚举出所有的前缀、后缀,然后对比相同的长度的前缀、后缀,看对应内容是否也是相同的。

关于前缀表的生成,我们可以利用动态规划求解。

我们现在要求NEXT[J],假设已知 NEXT[J-1] = K,比如下图

如果T[J] == T[K] 的话,那么 

 

那么 NEXT[J]  = K + 1

(PS:如果不能理解的话,可以将上面?替换成"d",然后手算一下NEXT[J])

如果T[J] ! = T[K]的话

那么NEXT[J]该如何求解呢?

其实换个思维,是可以套用前面KMP算法思路,如下图所示,我们可以将T串想象成两个分身串,如下图所示的SS和TT串,

其中SS串是原T串的后缀范围部分,TT串是是原T串的前缀范围部分

现在已经确定  SS[J] ! = TT[K] ,因此我们应该让TT串的K指针回退,即回退到NEXT[K-1]位置

然后继续比较T[J] 和 T[K]:

  • 如果T[J] == T[K],则NEXT[J] = K + 1

这里为啥可以直接认为0~K-1部分一定和J-K ~ J-1部分相同呢?

其实上面0~K-1部分、J-K ~ J-1部分回归到T串中的话,如下图所示

 再往前走一步的话,如下图所示

  • 如果T[J] ! = T[K],则再次 K = NEXT[K-1]

因此,这里前缀表的生成逻辑,其实也是套用了KMP算法,只是这里的前缀表只有一个T串,我们需要抽象为两个虚拟串SS(虚拟主串),TT(虚拟模式串)。

关于前缀表的代码实现,请看下面小节代码实现中getNext方法,可以对比KMP算法逻辑来看二者的相似之处。

KMP算法的实现(包含前缀表生成实现)

Java算法源码

public class Main {
  public static void main(String[] args) {
    String s = "xyz";
    String t = "z";

    System.out.println(indexOf(s, t));
  }

  /**
   * @param s 正文串
   * @param t 模式串
   * @return 在s中查找与t相匹配的子串,如果成功找到,则返回匹配的子串第一个字符在主串中的位置
   */
  public static int indexOf(String s, String t) {
    int[] next = getNext(t);

    int i = 0; // 扫描S串的指针
    int j = 0; // 扫描T串的指针

    // 如果 i 指针扫描到S串结束位置,或者 j 指针扫描到T串的结束位置,都应该结束查找
    while (i < s.length() && j < t.length()) {
      // 如果 s[i] == t[j],则当前位置匹配成功,继续匹配下一个位置
      if (s.charAt(i) == t.charAt(j)) {
        i++;
        j++;
      } else {
        // 如果 s[i] != t[j],则说明当前位置匹配失败,
        // 根据KMP算法,我们只需要回退T串的 j 指针到 next[j-1]位置,即最长相同前缀的结束位置后面一个位置,而S串的 i 指针保持不动
        if (j > 0) {
          j = next[j - 1];
        } else {
          // 如果 j = 0,则说明S子串subS和T在第一个字符上就匹配不上, 此时T不匹配字符T[j]前面已经没有前后缀了,因此只能匹配下一个S子串
          i++;
        }
      }
    }

    // 如果最终可以在S串中找到匹配T的子串,则T串的所有字符都应该被j扫描过,即最终 j = t.length
    if (j == t.length()) {
      // 则S串中匹配T的子串的首字符位置应该在 i - t.length位置,因为 i 指针最终会扫描到S串中匹配T的子串的结束位置的后一个位置
      return i - j;
    } else {
      // 否则就是没有在S中找到匹配T的子串
      return -1;
    }
  }

  public static int[] getNext(String t) {
    int[] next = new int[t.length()];

    int j = 1;
    int k = 0;

    while (j < t.length()) {
      if (t.charAt(j) == t.charAt(k)) {
        next[j] = k + 1;
        j++;
        k++;
      } else {
        if (k > 0) {
          k = next[k - 1];
        } else {
          j++;
        }
      }
    }

    return next;
  }
}

JS算法源码

/**
 * @param {*} s 正文串
 * @param {*} t 模式串
 * @returns 在s中查找与t相匹配的子串,如果成功找到,则返回匹配的子串第一个字符在主串中的位置
 */
function indexOf(s, t) {
  let next = getNext(t);

  let i = 0; // 扫描S串的指针
  let j = 0; // 扫描T串的指针

  // 如果 i 指针扫描到S串结束位置,或者 j 指针扫描到T串的结束位置,都应该结束查找
  while (i < s.length && j < t.length) {
    if (s[i] == t[j]) {
      // 如果 s[i] == t[j],则当前位置匹配成功,继续匹配下一个位置
      i++;
      j++;
    } else {
      // 如果 s[i] != t[j],则说明当前位置匹配失败,
      // 根据KMP算法,我们只需要回退T串的 j 指针到 next[j-1]位置,即最长相同前缀的结束位置后面一个位置,而S串的 i 指针保持不动
      if (j > 0) {
        j = next[j - 1];
      } else {
        // 如果 j = 0,则说明S子串subS和T在第一个字符上就匹配不上, 此时T不匹配字符T[j]前面已经没有前后缀了,因此只能匹配下一个S子串
        i++;
      }
    }
  }

  // 如果最终可以在S串中找到匹配T的子串,则T串的所有字符都应该被j扫描过,即最终 j = t.length
  if (j >= t.length) {
    // 则S串中匹配T的子串的首字符位置应该在 i - t.length位置,因为 i 指针最终会扫描到S串中匹配T的子串的结束位置的后一个位置
    return i - j;
  } else {
    // 否则就是没有在S中找到匹配T的子串
    return -1;
  }
}

function getNext(t) {
  const next = new Array(t.length).fill(0);

  let j = 1;
  let k = 0;

  while (j < t.length) {
    if (t[j] == t[k]) {
      next[j] = k + 1;
      j++;
      k++;
    } else {
      if (k > 0) {
        k = next[k - 1];
      } else {
        j++;
      }
    }
  }

  return next;
}

const s = "aabaabaafaab";
let t = "aabaaf";
console.log(indexOf(s, t));

Python算法源码

def getNext(t):
    next = [0] * len(t)

    j = 1
    k = 0

    while j < len(t):
        if t[j] == t[k]:
            next[j] = k + 1
            j += 1
            k += 1
        else:
            if k > 0:
                k = next[k - 1]
            else:
                j += 1

    return next


def indexOf(s, t):
    """
    :param s: 正文串
    :param t: 模式串
    :return: 在s中查找与t相匹配的子串,如果成功找到,则返回匹配的子串第一个字符在主串中的位置
    """

    next = getNext(t)

    # 手算的T串"cabaa"对应的前缀表
    # next = [0, 0, 0, 0, 0]

    i = 0  # 扫描S串的指针
    j = 0  # 扫描T串的指针

    # 如果 i 指针扫描到S串结束位置,或者 j 指针扫描到T串的结束位置,都应该结束查找
    while i < len(s) and j < len(t):
        # 如果 s[i] == t[j],则当前位置匹配成功,继续匹配下一个位置
        if s[i] == t[j]:
            i += 1
            j += 1
        else:
            # 如果 s[i] != t[j],则说明当前位置匹配失败
            # 根据KMP算法,我们只需要回退T串的 j 指针到 next[j-1]位置,即最长相同前缀的结束位置后面一个位置,而S串的 i 指针保持不动
            if j > 0:
                j = next[j - 1]
            else:
                # 如果 j = 0,则说明S子串subS和T在第一个字符上就匹配不上, 此时T不匹配字符T[j]前面已经没有前后缀了,因此只能匹配下一个S子串
                i += 1

    # 如果最终可以在S串中找到匹配T的子串,则T串的所有字符都应该被j扫描过,即最终 j = t.length
    if j >= len(t):
        # 则S串中匹配T的子串的首字符位置应该在 i - t.length位置,因为 i 指针最终会扫描到S串中匹配T的子串的结束位置的后一个位置
        return i - j
    else:
        # 否则就是没有在S中找到匹配T的子串
        return -1


if __name__ == '__main__':
    s = "aabaabaaf"
    t = "aabaaf"
    # t = "cabaa"  # 该T串用于测试第一个字符就不匹配的情况

    print(indexOf(s, t))

猜你喜欢

转载自blog.csdn.net/qfc_128220/article/details/131311563