算法笔记1:寻找匹配模式的子串

112 阅读1分钟

核心思想来源于:labuladong.github.io/algo/3/26/1…

本文仅为自学笔记,并无新意

题目:

// return the index of the pattern that appears in the str
// return 0 if str or pattern is empty
// return -1 if it does not exist
function search(str, pattern) {
  // ???
}

// example 1
const a = search('hello', 'll');
assert(a).equals(2);

// example 2
const b = search('foo', '');
assert(b).equals(0);

// example 3
const c = search('apple', 'pear');
assert(c).equals(-1);

暴力解法,就是一位一位的去比:

function search(str, pattern) {
  // edge cases
  if (!str || !pattern) {
    return 0;
  }
  if (pattern.length > str.length) {
    return -1;
  }
  
  // loop
  for (let i = 0; i <= str.length - pattern.length; i++) {
    let match = true;
    // inner loop
    for (let j = 0; j < pattern.length; j++) {
      if (str[i + j] !== pattern[j]) {
        match = false;
        break;
      }
    }
    if (match) return i;
  }
  
  // no match
  return -1;
}

这种暴力解法的时间复杂度就和匹配串与模式串的长度有关:O(sLen * pLen),而且比较浪费性能的就是说如果重复字符比较多的话就有点慢。

比如在 aaaaabb 里面搜 abb 的话:

  1. 走到第一个 aaa 发现与 abb 不同,往后移一位。
  2. 走到第二个 aaa 发现与 abb 不同,往后移一位。
  3. ...

可以发现如果当前位置的 aaaabb 不同的话,可以多移动一位的。因为可以预见到仅仅移动一位是肯定不会匹配的。

提升效率的关键在于将对模式串的一位一位匹配视为在不同状态间的转换:

    str:    a   b  ...
pattern:    a   b   b
  state:  0   1   2   3

也就是说如果我们在比较前,先确定我们遇到某个字符的时候应该转换到什么状态的话,我们就可以大幅提升后移位数的效率。例如上面代码块中描述的场景,现在走到了模式串中的 2 号状态,如果匹配串下一位是 b ,那么我们就走到了 3 号状态,并且找到了匹配的子串。

但如果不是 b 的话,情况就稍微复杂了一些。比如匹配串的下一位是 c ,我们就可以直接后移三位了,也就是说匹配状态重置回了 0 ,又要从头开始。单比如下一位是 a ,那可以直接后移两位,匹配状态重新回到 1 。所以这其实是一个类似动态规划的问题,需要通过一个描述这些状态转换数据的缓存来不断指导向后移位的过程。

所以状态转移关系的求解就是关键。

如果字符一致当然是最简单的,因为状态只要向前走一格就好了。问题在于字符与下一位不一致的时候怎么办?直觉上肯定是后退状态。但是后退几格呢?

首先,如果是遇到了在模式串里根本没有出现过的字符,那肯定是从头开始了。然后其他的字符,就需要通过状态演变来计算出来。拉神在他的文章里把这种状态叫做 影子状态 ,我的理解其实就是上一个状态。

这个状态转换生成的时候正着推,和思考其他 DP 问题类似。可以定义 i 指向当前状态,j 指向上一状态。i 状态下所有的字符的回退路径计算好之后,j 如何变化?只要变成 j 状态下第 i 位上字符指向的状态即可。

代码如下:

const genMap = pattern => {
  // a list for later quick reference
  const charSetList = Array.from(new Set(pattern.split('')));
  // get length
  const patternLen = pattern.length;
  // generate map structure
  const map = Array(patternLen).fill(0).reduce(
    (p, c, index) => ({
      ...p, 
      [index]: charSetList.reduce(
        (prev, char) => ({
          ...prev,
          [char]: 0,
        }),
        {}
      )
    }),
    {}
  );
  // base case
  map[0][pattern[0]] = 1;
  
  
  let j = 0;
  for (let i = 1; i < patternLen; i++) {
    const currStateMap = map[i];
    const currChar = pattern[i];
  
    // for every possible char
    charSetList.forEach(char => {
      if (currChar === char) {
        // go foward
        currStateMap[char] = i + 1;
      } else {
        // use prev value
        currStateMap[char] = map[j][char];
      }
    });
    
    j = map[j][currChar];
  }
  
  return map;
}

const search = (str, pattern) => {
  // edge cases
  if (!str || !pattern) {
    return 0;
  }
  if (pattern.length > str.length) {
    return -1;
  }

  const map = genMap(pattern);
  
  let j = 0;
  for(let i = 0; i < str.length; i++) {
    j = map[j][str[i]] || 0;
    if (j === pattern.length) return i - pattern.length + 1;
  }
  
  return -1;
}

动态规划算法不就是利用过去的结果解决现在的问题吗?