【造轮子系列】面试官问:你能手写一个正则引擎吗?

264 阅读2分钟

freysteinn-g-jonsson-s94zCnADcUs-unsplash.jpg

过两天要填一个大坑(一文带你彻底搞懂递归函数),刚好看到一篇文章用很简洁的代码写了一个简单的正则引擎,里面巧妙的使用了递归完成了整个正则的实现,今天跟大家一起分享下,原文参考附在文末(在此由衷感谢作者),方便大家自取。

正则功能介绍

正则主要支持一般的字母匹配,还有特殊的元字符的实现,(*(匹配0个或多个前一个字符),?(匹配一个或0个前一个字符),.(匹配任意字符),^(匹配以后续字符开始), $(匹配以前面字符结束)),以上5种元字符的含义和js中的元字符的含义完全相同。

题引

类似这种比较抽象的需求,乍一看,脑袋是懵的,不知从何入手,有没有和我一样的小伙伴,很多这种诡异的,但是又可以从数量上进行拆解的,拆解后结构又不会发生变化的问题,通常是能够使用递归的思维进行解决的,说到了这里就提一下递归三要素。

  1. 递归出口,也就是递归结束的条件,程序要结束,递归就不可能无休止的进行下去。
  2. 递归基本情况,递归中最简单的情况,可以直接求解,而不需要再次调用递归函数,通常与递归出口相关。
  3. 递归的递推关系,将原问题拆解成若干个子问题的规律或公式。定义了原问题和子问题之间的关系,它是递归函数的核心。

大概了解了递归的概念,我们先看一个用递归求解的小例子:

function sum(arr) {
  // 递归的出口
  if(arr.length === 0) {
    // 递归的基本情况,如果数组长度为0,则直接返回结果0
    return 0
  }
  // 递归的地推关系sum(arr) = arr[0] + sum(剩余)
  return arr.shift() + sum(arr)
}

通过上面这个简单的例子,想必大家对于递归有了初步的认识,接下来跟大家介绍一个递归的核心概念,递归函数的宏观概念,和微观运行,区分这两个概念,然后在递归中不断的从这两个方面去理解,将会有很大的好处,宏观概念就是,对于递归函数有一个清晰的定位,而不去理会实现的细节,如上例,sum的宏观概念就是求数组arr,从0到length-1的和,有了这个宏观概念之后我们再去想如何对他进行递推。微观运行就是每一次的递归运行栈及变量的存储,等等递归执行的细节都需要深入的理解。这个内容我会在后续的文章中再做详细说明。

match基础版实现

从递归的思维来考虑,假设我已经有一个函数match,它的功能就是进行正则的匹配,那么我们可以直接使用这个函数来解决问题

function match(pattern, text) {
  // 递归出口和基本情况
  if (pattern === "") {
    return true;
  } else {
    // 递推关系
    return (matchOne(pattern[0], text[0]) 
           && match(pattern.slice(1), text.slice(1)));
  }
} 

上面是一个简单的引擎的实现,如果pattern为空,则返回true,递推的关系如上,可以将字符串分解第一个字符和正则的第一个字符进行匹配,剩余的再交给match方法进行匹配,这样就行成了递归的关系,我们看下matchOne的实现。

matchOne实现

function matchOne(pattern, text) {
  // pattern为空直接返回true
  if (!pattern) return true;
  // pattern不为空,text为空直接返回false
  if (!text) return false; 
  // 如果pattern等于.,或者两者相等都返回true
  return pattern === text && pattern === ".";
} 

这个也非常好理解,"."元字符就是匹配任意字符。

添加“$”元字符

function match(pattern, text) {
  if (pattern === "") return true;
  // 当patter是“$”时,text也正好匹配完,则返回true
  if (pattern === "$" && text === "") return true;
  else
    return matchOne(pattern[0], text[0])
          && match(pattern.slice(1), text.slice(1))

}

上面代码添加了元字符"$"的判断,该元字符的含义就是以什么结束,也就是说匹配到它的时候,文本正好结束,那么返回true。

添加“^”元字符

function search(pattern, text) {
  if (pattern[0] === "^") {
    // 调用match,匹配^之后的正则与text
    // 匹配search("^abc", "abc")
    return match(pattern.slice(1), text);
  } else {
    // 匹配search("bc", "abcd")
    return text.split("").some((_, index) => {
      return match(pattern, text.slice(index));
    });
  }
}

上面代码添新加了一个方法search,判断第一个字符如果是元字符"^",该元字符的含义就是以什么开始,也就是说它后面的pattern如果能够完整匹配text, 那么它就能匹配,若不是,则判断text中从每个字符开始是否能够匹配pattern,它用的是some,有一个匹配上就返回true。

修改match,添加“?”元字符

function match(pattern, text) {
  if (pattern === "") {
    return true;
  } else if (pattern === "$" && text === "") {
    return true;
  // 如果pattern的第二个字符是"?",则进行特殊的处理,
  // 注意这里没有进行分组的处理,所以只对前一个字母起作用,直接判断pattern[1]
  } else if (pattern[1] === "?") {
    return matchQuestion(pattern, text);
  } else {
    return matchOne(pattern[0], text[0]) 
          && match(pattern.slice(1), text.slice(1));
  }
}

添加matchQuestion方法

function matchQuestion(pattern, text) {
  // ?的第一种含义,前面字符为1个的时候
  if (matchOne(pattern[0], text[0]) && match(pattern.slice(2), text.slice(1))) {
    return true;
  } else {
    // ?的第二种含义,前面字符为0的时候
    return match(pattern.slice(2), text);
  }
}

该函数分别对“?”元字符的两种释义进行了处理,满足两种中的任意一种就返回true,即,?前面的字符有一个或者0个。

修改match,添加“*”元字符

function match(pattern, text) {
  if (pattern === "") {
    return true;
  } else if (pattern === "$" && text === "") {
    return true;
  } else if (pattern[1] === "?") {
    return matchQuestion(pattern, text);
      // 如果pattern的第二个字符是"*",则进行特殊的处理,同样这里没有进行分组的处理,所以只对前一个字母起作用,直接判断pattern[1]
  } else if(pattern[1] === "*") {
      return matchStar(pattern, text);
  } else {
    return matchOne(pattern[0], text[0]) 
          && match(pattern.slice(1), text.slice(1));
  }
}

添加matchStar方法

function matchStar(pattern, text) {
         // 匹配任意次的前面字符
  return ((matchOne(pattern[0], text[0]) && match(pattern, text.slice(1))) 
         // 匹配0次前面的字符
      || match(pattern.slice(2), text));
}

该函数也是分别对“*”元字符的两种释义进行了处理,满足两种中的任意一种就返回true,即,*前面的字符有多个或者0个。注意这里的调用关系,match里面调用了matchStar, 而matchStar里又调用了match,这也是递归。

基本到这里我们所有的元字符都进行了处理,功能也都完成了,最后做一点点优化

修改search方法

function search(pattern, text) {
  if (pattern[0] === "^") {
    return match(pattern.slice(1), text);
  } else {
    //因为我们实现了元字符"."和“*”,所以直接可以通过元字符的组合实现同样的功能
    return match(".*" + pattern, text);
  }
}

至此,我们完成了一个简单的正则引擎,使用递归思想,分解复杂问题,通过递推关系,以及基础情况和递归出口,即可得到原问题的解。

参考链接:nickdrane.com/build-your-…