leetcode算法第10题正则表达式匹配-回溯-动态规划

题目

简要贴下题目,具体详见这里

给定一个字符串 (s) 和一个字符模式 (p)。实现支持 '.' 和 '*' 的正则表达式匹配。

'.' 匹配任意单个字符。

'*' 匹配零个或多个前面的元素。

匹配应该覆盖整个字符串 (s) ,而不是部分字符串。

说明:

s 可能为空,且只包含从 a-z 的小写字母。

p 可能为空,且只包含从 a-z 的小写字母,以及字符 . 和 *。

示例 1:

输入:

s = "aa"

p = "a"

输出: false

解释: "a" 无法匹配 "aa" 整个字符串。

解题过程

初始想法

首先我在没有背景知识的情况下,素人想法,从字符串 s 第一个字符开始与正则中第一个 pattern 匹配,如果符合,则看第二个字符是否符合第一个 pattern,如果不符合则看下是否符合第二个 pattern。这样相当于有两个游标在字符串和正则上向后游动,不断匹配,当无法匹配上的时候就是不 match。

回溯

当然这是一个很基础的概念。因为题目中涉及 * 这个可以多次匹配的通配符(其实题目已经很简化了),所以可能出现同一个字符字串匹配到多种 pattern 组合的情况。这就不单单是两个游标依次往下走的问题了。所以我决定先查下正则匹配的通用规则。

像上面提到的这个问题就涉及到回溯问题了,举个简单的例子:

字符串:abbbc

正则:/ab{1,3}bbc/

匹配的过程是:


leetcode算法第10题正则表达式匹配-回溯-动态规划图1.png

第6步由于c没法和b{1,3}后面的b匹配上,所以我们把与b{1,3}匹配上的bbb吐出一位(也就是回退一位)拿这个b去匹配正则b{1,3}后面的b。这种由于往前走尝试一种路径(或者匹配规则)走不通,需要尝试另一种路径的方式叫做回溯。在稍微复杂的正则中可能一次匹配的过程中会涉及非常多次的回溯。稍微详尽的例子看这里

代码

按照初步掌握的知识先尝试写写看(会结合注释和伪代码):

// 主函数
function isMatch( s, p ) {

}

我想了下由于在匹配的过程中需要把整个正则拆分成小的子 pattern,那么我先把 p 分解了,省的游标一边向后走,一边还要判断解析正则。思路是把[a-z], ., [a-z], . 分别摘出来。


// 主函数
function isMatch( s, p ) {
  let patterns = toPatterns( p );
  // 开始匹配
}

function toPatterns( p ) {
  let result = [];
  if ( p.length===0 ) {
    return result;
  }

  for( let i = 0; i < p.length; ) {
    let currentP = p[ i ];
    let nextP = p[ i+1 ];

    if ( nextP!=='*' ) {
      // 单个字母
      if (
        currentP !== '.' &&
        currentP !== '*'
      ) {
        result.push( {
          type: 'char',
          keyword: currentP
        } );
        i++;
        continue;
      }
      // 单个.
      if ( currentP==='.' ) {
        result.push( {
          type: '.'
        } );
        i++
        continue;
      }
      // 单个* 
      if ( currentP==='*' ) {
        throw 'invalid pattern';
      }
    } else {
      if ( currentP==='.' ) {
        result.push( {
          type: '.*'
        } );
        i += 2;
        continue;
      } else {
        result.push( {
          type: 'char*',
          keyword: currentP
        } );
        i += 2;
        continue;
      }
    }
  }

  return result;
}

然后开始循环判断:

// 主函数
function isMatch( s, p ) {
  let patterns = toPatterns( p );
  // 开始匹配
  /* 
  先判断边际条件
    s 为空 p 为空
    s 为空
    p 为空
  的情况,具体代码就省略了
  */
  let subPattern = patterns.shift();
  let strIndex = 0;

  // 当 patterns 和 s 都轮询完了才算完结
  while(
    subPattern ||
    strIndex < s.length;
  ) {
    // 用字符和正则的子模式进行比较
    if ( 
      subPattern && 
      subPattern.type==='char' 
      s[strIndex]===subPattern.keyword
    ) {
      // 如果是 [a-z] 的正则,且匹配上了,那么字符串和正则都需要往下走一步:
      subPattern = patterns.shift();
      strIndex++
    } else if (
      // 如果是 . 的正则匹配上了
    ) {
      // 字符串和正则都需要往下走一步:
      subPattern = patterns.shift();
      strIndex++
    } else if (
      // 如果是 [a-z]* 的正则匹配上了
    ) {
      // 字符串下走一步,正则还可以用
      strIndex++
    } else if (
      // 如果是 .* 的正则匹配上了
    ) {
      // 字符串下走一步,正则还可以用
      strIndex++
    } else {
      // 如果没有匹配上,这里就开始考虑!!!回溯!!!
    }
  }


}

function toPatterns( p ) {
  // 省略
}

代码写到这里,我发现了个问题,如果要回溯,我要用非常多的变量记录各种情况,写很多分支条件,没法写啊。参考了别人的代码,发现把循环该成递归,能很好的解决这个问题(针对这道题目只有[a-z], .的情况会产生回溯):

var isMatch = function(s, p) {
  return isMatchImpl( s, toPatterns(p) );
};

function toPatterns( p ) {
  // 省略
}

function isMatchImpl( s, patterns ) {
  // 开始匹配
  /* 
  先判断边际条件
    s 为空 patterns 为空
    s 为空
    patterns 为空
  的情况,具体代码就省略了
  */
  let p = patterns[ 0 ];
  if (
    p.type==='char' &&
    s[ 0 ]===p.keyword
  ) {
    return isMatchImpl( s.substr(1), patterns.slice(1) );
  } else if (
    p.type==='.' &&
    s[ 0 ]
  ) {
    return isMatchImpl( s.substr(1), patterns.slice(1) );
  } else if (
    p.type==='char*'
  ) {
    if ( s[ 0 ]===p.keyword ) {
      // 这里通过逻辑或和递归,把回溯的各个条件依次执行
      return isMatchImpl( s, patterns.slice(1) ) || isMatchImpl( s.substr(1), patterns ) || isMatchImpl( s.substr(1), patterns.slice(1) );
    } else {
      // 这里通过逻辑或和递归,把回溯的各个条件依次执行
      return isMatchImpl( s, patterns.slice(1) )
    }
  } else if (
    p.type==='.*'
  ) {
    if ( s ) {
      // 这里通过逻辑或和递归,把回溯的各个条件依次执行
      return isMatchImpl( s, patterns.slice(1) ) || isMatchImpl( s.substr(1), patterns ) || isMatchImpl(s.substr(1), patterns.slice(1));
    } else {
      // 这里通过逻辑或和递归,把回溯的各个条件依次执行
      return isMatchImpl( s, patterns.slice(1) );
    }
  } else {

    return false;
  }
}

看下代码里面的注释,通过逻辑或的逻辑短路原则,结合递归,就把回溯的各个路径写成一行了。循环和递归真是好基友,各有各的适用场景。代码的功能完成了,通过了官方的测试用例。

动态规划

代码完成了,但是执行效率很有问题。看下上面讲回溯例子时候的图片,当回溯的时候如果 subpattern 没有变,且 strindex 没有变,那么结果是一致的,也就是说如果多次执行可以被记录下来,不用每次都判断 subPatterns[strIndex] 是否匹配。这个思路和优化斐波那契数列是否有点相似,就是对计算过的结果用空间换时间,对于相同的计算条件只需要计算一次。这个思路再加上这道题目,背后的原理其实就是动态规划的概念。

我简单说下什么叫动态规划:

  1. 将待求解的问题分解为若干个子问题。
  2. 按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。
  3. 在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。

这个好像和我们正则匹配的过程不谋而合了:

  1. 匹配的过程是必须每个 subpattern 都匹配成功。
  2. 下一个 subpattern 所要匹配的字符取决于上一个 subpattern 用到哪个字符。
  3. 当一条匹配路径走不通,通过回溯寻找另一条可能的匹配路径。

而动态规划在算法中的应用,其一大优化策略就是充分利用前面保存的子问题的解来减少重复计算。所以改造下代码:

const P_TYPE_CHAR = 1;
const P_TYPE_ANY_CHAR = 2;
const P_TYPE_CHAR_ANY_TIME = 3;
const P_TYPE_ANY_CHAR_ANY_TIME = 4;
const Q_DOT = '.';
const Q_STAR = '*';
/**
 * @param {string} s
 * @param {string} p
 * @return {boolean}
 */
var isMatch = function(s, p) {
  return isMatchImpl( s, 0, toPatterns(p), 0 );
};



function toPatterns( p ) {
  // 省略
}

let Cache = {};

function isMatchImpl( s, sIndex, patterns, pIndex ) {
  if ( sIndex===s.length && pIndex===patterns.length ) {
    return true;
  }
  if ( sIndex < s.length && pIndex===patterns.length ) {
    return false;
  }

  let cacheKey = `${sIndex}-${pIndex}`;
  if ( Cache[cacheKey]!==undefined ) {
    return Cache[cacheKey];
  }

  let p = patterns[ pIndex ];
  
  if (
    p.type===P_TYPE_CHAR &&
    s[ sIndex ]===p.keyword
  ) {
    Cache[ cacheKey ] = true;
    return isMatchImpl( s, ++sIndex, patterns, ++pIndex );
  } else if (
    p.type===P_TYPE_ANY_CHAR &&
    s[ sIndex ]
  ) {
    Cache[ cacheKey ] = true;
    return isMatchImpl( s, ++sIndex, patterns, ++pIndex );
  } else if (
    p.type===P_TYPE_CHAR_ANY_TIME
  ) {
    Cache[ cacheKey ] = true;
    if ( s[ sIndex ]===p.keyword ) {
      return isMatchImpl( s, sIndex, patterns, ++pIndex ) || isMatchImpl( s, ++sIndex, patterns, pIndex ) || isMatchImpl( s, ++sIndex, patterns, ++pIndex );
    } else {
      Cache[ cacheKey ] = false;
      return isMatchImpl( s, sIndex, patterns, ++pIndex )
    }
  } else if (
    p.type===P_TYPE_ANY_CHAR_ANY_TIME
  ) {
    Cache[ cacheKey ] = true;
    if ( s ) {
      return isMatchImpl( s, sIndex, patterns, ++pIndex ) || isMatchImpl( s, ++sIndex, patterns, pIndex ) || isMatchImpl(s, ++sIndex, patterns, ++pIndex );
    } else {
      return isMatchImpl( s, sIndex, patterns, ++pIndex );
    }
  } else {
    Cache[ cacheKey ] = false;
    return false;
  }

}

参考文献

  1. https://zhuanlan.zhihu.com/p/27417442
  2. http://www.cnblogs.com/steven_oyj/archive/2010/05/22/1741374.html
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,098评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,213评论 2 380
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,960评论 0 336
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,519评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,512评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,533评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,914评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,574评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,804评论 1 296
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,563评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,644评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,350评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,933评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,908评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,146评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,847评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,361评论 2 342

推荐阅读更多精彩内容