10. Regular Expression Matching
题目:
Implement regular expression matching with support for '.'
and '*'
.
'.' Matches any single character. '*' Matches zero or more of the preceding element. The matching should cover the entire input string (not partial). The function prototype should be: bool isMatch(const char *s, const char *p) Some examples: isMatch("aa","a") → false isMatch("aa","aa") → true isMatch("aaa","aa") → false isMatch("aa", "a*") → true isMatch("aa", ".*") → true isMatch("ab", ".*") → true isMatch("aab", "c*a*b") → true
链接:http://leetcode.com/problems/regular-expression-matching/
题解:
这道题也卡了很久,一开始就在考虑很多边界条件。后来才理解到s是普通字符串,p是含有'.'或者'*'的字符串。有几种方法可以解题。
一开始是recursive的普通解法,分别考虑p的长度为0, 为1,以及大于1的情况。
public class Solution { public boolean isMatch(String s, String p) { if(p == null || s == null) return false; if (p.length() == 0) return s.length() == 0; if (p.length() == 1) return s.length() == 1 && (p.charAt(0) == '.' || p.charAt(0) == s.charAt(0)) ; if (p.charAt(1) == '*') { if (isMatch(s, p.substring(2))) return true; return s.length() > 0 && (p.charAt(0) == '.' || s.charAt(0) == p.charAt(0)) && isMatch(s.substring(1), p); } else { return s.length() > 0 && (p.charAt(0) == '.' || s.charAt(0) == p.charAt(0)) && isMatch(s.substring(1), p.substring(1)); } } }
下面是dp解法,先对s构建一个数组, 从p的尾部向前遍历,当p的字符为'*'时,从s尾部向前遍历。否则从s头部向后遍历。
Time Complexity - O(m * n), Space Complexity - O(n)。
public class Solution { public boolean isMatch(String s, String p) { //dp boolean[] match = new boolean[s.length() + 1]; match[s.length()] = true; for(int i = p.length() - 1; i >= 0; i--) { if(p.charAt(i) == '*') { for(int j = s.length() - 1; j >= 0; j--) match[j] = match[j] || (match[j + 1] && (p.charAt(i - 1) == '.' || s.charAt(j) == p.charAt(i - 1))); i--; } else { for(int j = 0; j < s.length(); j++) match[j] = match[j + 1] && (p.charAt(i) == '.' || p.charAt(i) == s.charAt(j)); match[s.length()] = false; } } return match[0]; } }
更好的方法是使用regular expression的本质,利用NFA和有向图来计算,代码会写很长,先放在reference里。
二刷:
又做到了这一题,又被卡,实在不想背答案。也不想每道类似的题去找一个特别的recursive或者dp解。决定还是好好学习学习自动机Automata。下面是学了Sedgewick关于Regular Expression这一章后的一些想法。编写边学习,主要目的是理顺思路,有错误的话看官也别生气。
Regular expression is a notation to specify a set of strings, 这个set有可能是infinite。基本操作一般有以下,我们先不管操作的优先级, concatenation - 就是ABC match ABC; or - '|', AA | BAAB match AA或者 BAAB;closure - '*',满足0个或者多个之前的字符, 这道题目里面就有'*', 比如 AB*A可以match AA,也可以match ABBBBA;最后就是括号 parentheses '('')'了,比如 (AB)*A可以match A或者 ABABABABA。 还有其他的操作符,比如 wildcard - '.', 可以match任意字符,这个这道题目里面有,我们在wildcard matching里也会遇到;character class - [A-Z] match word Capitailized; at least 1 - A(BC)+DE - matches ABCDE或者 ABCBCDE一类的; exact K [0-9]{5} - match 12345。
自动机里常用的有DFA (Deterministic Finite Automata)和NFA(Non-deterministic Finite Automata),DFA和NFA可以互相转换。主要可以使用在String searching,Pattern Matching之类的。像KMP String searching就可以做一个DFA来完成matching的过程。 DFA: Machine to recognize whether a given string is in a given set。 Kleene's theorem说明对于任何DFA, 我们都可以招待一个RE来描述同样的same set of strings,对于任何RE,我们也可以找到一个DFA来识别same set of strings.
一般构造RE识别有至少三种方法,第一种是把所有的NFA转换为一个DFA,然后用DFA来进行识别,这种建立DFA的时间会较长O(2m),但识别时间为O(n)。第二种是直接模拟NFA过程,这样建立NFA大约是O(m),但识别时间为O(mn)。第三种是用回溯backtracking来识别,但运行时常最坏情况下可能是O(2n)
Ken Thompson用来识别的方法就是模拟NFA,也是我打算主要学习的方法,在塞神的video里,RE matching NFA主要有几个特征:
- RE enclosed in parentheses (我们可以不用做)
- One state per RE character(start = 0, accept = M)
- epsilon-transition (change state, but don't scan text): 这个epsilon-transition是代表在这一步我们有多少种可能转移的状态,比如 A*B,我们可以走回A,可以停留在*,也可以进一步走到B,这里我们有三种可能的下一步,该怎么选择呢? 在此我们就要转化这个小问题成为一个在有向图中单源的reachability的问题,要构建一个有向图,用DFS来计算到底这一步我们能够到达多少个状态,然后把这些状态放入一个临时的结果集里面,再进行下一步计算。 这个转移矩阵就代表NFA和DFA的本质区别, non-determinism,不可确定性。 在DFA里我们每一步都有一个明确的下一步,但在NFA的这一步里,我们不能确定下一步究竟怎么走。
- Match transition (change state and scan to next text char): 这里就是说我们找到了一个match,要进行下一步状态转换,同时扫描输入text的下一个字符, 比如 s = "ABC", p = "ABC",当i = 0时,因为s(0)='A',p(0)也等于'A',所以他们match,我们可以继续转移状态进行下一步来对比'B'和'B''
- After scanning all text characters, accept if any sequence of transitions ends in accept state。在扫描完毕所有text之后,假如结果集合中,有任何一个state = accept state,那么match成功。
NFA representation:
State names: Integers from 0 to M
Match-transitions: Keep regular expression in array re[]
Epsilon-transitions: Store in digraph G
How to efficiently simulate an NFA? : Maintain set of all possible states that NFA could be in after reading in the first i text characters. 在读取了i个字符后,保存下来所有NFA可能到达的state。
如何从step i 扩展到step i+1呢?
- 这里首先保存all states reachable after reading i symbols
- 然后尝试读取 (i + 1)st symbol c, 根据matching transitions来获取当前NFA可能到达的state,这里一般我们可以重新建立一个Bag或者Set来保存新的state。这一步结束后我们已经读取了第i+1个字符
- 这时我们就可以看一看possible null transitions,就是在上一步的基础上,不读取下一个输入text字符的情况下,根据Epsilon-transitions我们能够到达的state,把这些state加入我们的Bag/Set里
- 到这里我们就完成了从step i 扩展到step i+1, 这时Bag/Set里保存的就是我们的NFA可以到达的所有state
Digraph reachability: Find all vertices reachable from a given source or set of vertices: Run DFS from each source without unmarking vertices
上面是谈了如何simulate NFA matching process,那么如何根据RE构建NFA?
Concatenation: Add match-transition edge from state corresponding to characters in the alphabet to next state
Parentheses: Add epsilon-transition edge to next state
Closure: '*', add three eplison transition edges for each * operator, 比如 state 2 = 'A', state 3 = '*', state 4 = 'B',那么我们可以在eplison-transitions edges里面假如 2 -> 3, 3 -> 2以及 3 -> 4。这个操作也对closure expression成立
Or: '|' addd two epsilon transition edges. - G.addEdge(lp, or + 1); G.addEdge(or, i); 这里假设所有的RE都在Parentheses中。对于这种包含Parentheses的RE,我们需要维护一个栈来保存上一个left Parenthese lp的位置。比较复杂,先不考虑。
Java:
为什么Time Complexity是O(mn)呢? 假定输入是长为n的String s, Pattern是长为m的pattern,那么我们构建NFA的时候最多会有3 * M条边 (全部都是3条边的Epsilon transitions),而遍历用dfs计算reachability的时候复杂度是O(V + E)。那么对于s中的每一个字符,我们们都要计算一遍dfs,最后结果就是 n * O(V + E) = n * O(m) = O(mn)。 空间复杂度没仔细算,也就是对n的每一个元素都进行dfs计算,n次dfs,乘以我们每次dfs的开销O(bm),b是branching factor,这里忽略,总的来说也是O(mn)。
Time Complexity - O(mn), Space Complexity - O(mn)
public class Solution { private Digraph graph; // Digraph to record epsilon transitions public boolean isMatch(String s, String p) { buildNFA(p); DirectedDFS dfs = new DirectedDFS(graph, 0); Set<Integer> reachableState = new HashSet<>(); for (int v = 0; v < graph.v(); v++) { if (dfs.marked(v)) { reachableState.add(v); } } for (int i = 0; i < s.length(); i++) { Set<Integer> match = new HashSet<>(); for (int v : reachableState) { // calculate each possible match transition if (v == p.length()) { // accept state continue; } if (p.charAt(v) == s.charAt(i) || p.charAt(v) == '.') { // match transition match.add(v + 1); } } dfs = new DirectedDFS(graph, match); reachableState = new HashSet<>(); for (int v = 0; v < graph.v(); v++) { // from each match transition, expand to epsilon transitions if (dfs.marked(v)) { reachableState.add(v); } } if (reachableState.size() == 0) { return false; } } for (int v : reachableState) { // after scaned all s characters, if any NFA sequence leads to accept state if (v == p.length()) { return true; } } return false; } private void buildNFA(String p) { this.graph = new Digraph(p.length() + 1); // each char a state, plus one accept state for (int i = 0; i < p.length(); i++) { char c = p.charAt(i); if (c == '*'){ if (i > 0) { graph.addEdge(i, i - 1); graph.addEdge(i - 1, i); } graph.addEdge(i, i + 1); } } } private class DirectedDFS { private boolean[] marked; public DirectedDFS(Digraph graph, int start) { // G and start state s marked = new boolean[graph.v()]; dfs(graph, start); } public DirectedDFS(Digraph graph, Iterable<Integer> sources) { marked = new boolean[graph.v()]; for (int v : sources) { if (!marked[v]) { dfs(graph, v); } } } private void dfs(Digraph graph, int v) { marked[v] = true; for (int w : graph.adj[v]) { if (!marked[w]) { dfs(graph, w); } } } public boolean marked(int v) { return marked[v]; } } private class Digraph { private int V; // number of vertices private Set<Integer>[] adj; // adjacency list representation of Digraph public Digraph(int v) { this.V = v; adj = (Set<Integer>[])new Set[v]; //type unsafe for (int i = 0; i < v; i++) { adj[i] = new HashSet<Integer>(); } } public void addEdge(int v, int w) { adj[v].add(w); } public Iterable<Integer> adj(int v) { // verticies pointing from v return adj[v]; } public int v() { return V; } } }
或者
public class Solution { private Digraph G; public boolean isMatch(String s, String p) { buildNFA(p); DirectedDFS dfs = new DirectedDFS(G, 0); Set<Integer> reachableState = new HashSet<>(); for (int v = 0; v < G.V; v++) { if (dfs.marked[v]) { reachableState.add(v); } } for (int i = 0; i < s.length(); i++) { Set<Integer> match = new HashSet<>(); for (int v : reachableState) { if (v == p.length()) { continue; } if (p.charAt(v) == s.charAt(i) || p.charAt(v) == '.') { match.add(v + 1); } } dfs = new DirectedDFS(G, match); reachableState = new HashSet<>(); for (int v = 0; v < G.V; v++) { if (dfs.marked[v]) { reachableState.add(v); } } if (reachableState.size() == 0) { return false; } } for (int v : reachableState) { if (v == p.length()) { return true; } } return false; } private void buildNFA(String p) { // use p build NFA this.G = new Digraph(p.length() + 1); // each char a state, plus one accept state for (int i = 0; i < p.length(); i++) { char c = p.charAt(i); if (c == '*') { if (i > 0) { G.addEdge(i - 1, i); G.addEdge(i, i - 1); } G.addEdge(i, i + 1); } } } private class DirectedDFS { public boolean[] marked; public DirectedDFS(Digraph G, int num) { marked = new boolean[G.V]; dfs(G, 0); } public DirectedDFS(Digraph G, Iterable<Integer> nums) { marked = new boolean[G.V]; for (int v : nums) { if(!marked[v]) { dfs(G, v); } } } private void dfs(Digraph G, int v) { marked[v] = true; for (int w : G.adj.get(v)) { if (!marked[w]) { dfs(G, w); } } } } private class Digraph { public int V; // num of vertices public Map<Integer, Set<Integer>> adj; // adjacency list representation of Digraph public Digraph(int num) { this.V = num; adj = new HashMap<>(); for (int i = 0; i < num; i++) { adj.put(i, new HashSet<>()); } } private void addEdge(int v, int w) { adj.get(v).add(w); } } }
假如不考虑比较复杂的 | 和 ()的话。假设我们有下面两个变型题:
1. *号代表之前字符出现一次或者0次(其实相当于"+"), 那么在建立Epsilon transitions的时候我们就没有G.addEdge(i - 1, i)这条代表0次matching的边, 只有 G.addEdge(i, i - 1)表示一次多次,以及通向下一state的G.addEdge(i, i + 1)
2. LeetCode no.44 Wildcard Matching: 用构建NFA的方法来做的话,在最后一个例子会超时,强行跳过才可以。我其实每想透该如何构建 Epsilon transition以及Recoganize,因为这里既有Epsilon transition也有matching transition。所以我打算先加入一个预处理步骤 - 在那道题目里 ?可以带表任意字符, * 代表 0个或者多个任意字符, 那么其实联系到这道 Regular Expression matching的话,可以把Wildcard Matching里的 '*' 看成是这里的 “.*”两个字符的组合, 意思是用 '.'来代表任意字符,然后用'*'来代表前面的字符出现一次或者多次, 这样就可以同时包括matching transition以及 epsilon transition。所以实现的话,在预处理里面把'*'替换为"?*", 之后就套用这道题目的code, 再跳过最后一个case就可以了。
总结是:
Simulate NFA的方法,对于OJ这两道题目虽然不是最快的,甚至不是较快的解法, 但却是一种通用的解,深度优化的话时间复杂度是O(mn),符合理论,虽然不能达到 DFA的 O(n)速度,但构建比较容易,理解起来也比较简单。我打算再加深对其他RE operator的理解,比如 |, (), +,[-],等等。
使用DP的方法: jianchao.li.fighter和xiaohui7解释得非常清楚。
- 首先建立dp矩阵res[][] = new boolean[m + 1][n + 1]
- res[0][0] = true表示s和p都为""的时候match成功
- 接下来对pattern p的首行'*'号的0 match情况进行初始化,res[0][j] = res[0][j - 2] && p.charAt(j - 1) == '*'
- 之后从1, 1开始对res矩阵进行dp,主要分为两种情况
- p.charAt(i - 1)不等于'*': 这里表示matching transition,假如s和p的上一个字符match, 即res[i - 1][j - 1] == true,同时新的字符s.charAt(i - 1) == p.charAt(j - 1),或者p.charAt(j - 1) == '.', 那么我们可以设定res[i][j] = true,这表示到 s 和 p 到 i - 1和j - 1的位置是match的
- 假如p.charAt(i - 1) == '*': 这里表示epsilon transition,系统可能处于不同的状态,我们要分多种情况进行考虑,只要有一个状态为true,在这个位置的结果就为true,是一个“或”的关系:
- res[i][j - 2] == true,即s.charAt(i - 1) match p.charAt(j - 3),这里'*'号和其之前的字符可以当作"",表示0 matching,这种情况下,我们可以认为状态合理,res[i][j] = true。 例 "C" match "CA*"
- 系统也可能处于另外一个状态,一个或者多个字符的matching。这里我们判断res[i - 1][j],代表s.charAt(i - 2)和 p.charAt(j - 1),假如这个状态成立,我们再继续判断。例“AA” match "A*",我们先反回去看"A"是否match "A*"。假如上面状态成立,我们再看p.charAt(j - 2)是否和s.charAt(i - 1)能match,这里根matching transition一样,需要判断p.charAt(j - 2) == s.charAt(i - 1) || p.charAt(i - 1) == '.'。例子还是"AA" match "A*",当第一个A match了之后,我们判断s中第二个A和p中第一个"A"是否match。
- 最后我们返回res[m][n]
Time Complexity - O(mn), Space Complexity - O(mn)。
public class Solution { public boolean isMatch(String s, String p) { if (s == null || p == null) { return false; } if (p.length() == 0) { return s.length() == 0; } int m = s.length(), n = p.length(); boolean res[][] = new boolean[m + 1][n + 1]; res[0][0] = true; for (int j = 2; j < res[0].length; j += 2) { // 0 matching res[0][j] = res[0][j - 2] && p.charAt(j - 1) == '*'; } for (int i = 1; i < res.length; i++) { for (int j = 1; j < res[0].length; j++) { if (p.charAt(j - 1) == '*') { // epsilon transition if (j > 1 && res[i][j - 2]) { // 0 matching res[i][j] = true; } if (j > 1 && res[i - 1][j] // one or more matching && (p.charAt(j - 2) == '.' || p.charAt(j - 2) == s.charAt(i - 1))) { res[i][j] = true; } } else { // matching transition if (res[i - 1][j - 1] && (p.charAt(j - 1) == s.charAt(i - 1) || p.charAt(j - 1) == '.')) { res[i][j] = true; } } } } return res[m][n]; } }
Python:
class Solution(object): def isMatch(self, s, p): """ :type s: str :type p: str :rtype: bool """ if len(p) == 0: return len(s) == 0 m = len(s) n = len(p) res = [] for i in range(0, m + 1): tmp = [] for j in range(0, n + 1): tmp.append(False) res.append(tmp) res[0][0] = True for j in range(2, n + 1): res[0][j] = res[0][j - 2] and p[j - 1] == '*' for i in range(1, m + 1): for j in range(1, n + 1): if p[j - 1] == '*': if j > 1 and res[i][j - 2]: res[i][j] = True if j > 1 and res[i - 1][j] and (s[i - 1] == p[j - 2] or p[j - 2] == '.'): res[i][j] = True else: if res[i - 1][j - 1] and (s[i - 1] == p[j - 1] or p[j - 1] == '.'): res[i][j] = True return res[m][n]
题外话:
以前思考过怎样学习最快。总是觉得要看书,看经典书,然后动手做,动手练习。今天觉得看书有的时候真不如看视频。可能对于某些特别聪明的人,看书一眼就可以理解意义,然后就可以应用到实践中。但比如这Regular Expression,算法第四版这本书里只有11页纸,只看书对我来说的话可能怎么也参不透。而视频内容超过1小时,有各个关键点的Demo以及为什么要这么做,配合lecture notes看起来就好吸收不少。各种资源整合起来可能更适合我这种普通算法爱好者来进行学习。 是不是我发现得太晚了。之后试了一下用NFA去解Wildcard Matching, 发现并没有什么用...还是不能ac -_____-!! 等深入学习了DP以后再去尝试吧。
三刷:
nfa的方法,代码比较长,面试时也许写不及,代码一长就容易写错。已经理解了dp,这道题我们要注意阅读清楚题意, '.'是可以match任何单字符,'*'必须连同其前面的字符才能表示0个或者1个该字符。所以使用二维dp的话我们可以按照以下步骤来做:
- 找出base状态。我们先去掉一些边界条件,比如s和p为null,p长度为0,以及p开头为'*',这些情况都是不合理的。接下来我们分析得出,当s和p都为空字符串情况下, 两串是match的,应该返回true。
- 初始化。 这时候我们要创建2维DP矩阵。 矩阵里面的 dp[i][j]的意思是, 到串s的第i - 1个字符和串p的第j - 1个字符是否match。我们也要对i = 0时,即s为空串时的dp数组进行初始化。
- 分析转移方程。
- 当 p.charAt(j - 1) = '*'时,这时候我们要考虑系统可能处于的两种状态
- 当dp[i][j - 2] 为true时,这时候代表epsilon transition : '*'之前的字母出现0次这种情况,我们可以设置dp[i][j] = true
- 或者,当dp[i - 1][j]为true时,这时候说明 s.charAt(i - 2)可以match到p.charAt( j - 1),我们要从s串继续向下扩展一个字符,看s.charAt(i - 1)是否能匹配p.charAt(j - 1)。这里我们就可以比较s.charAt(i - 1)是否等于p.charAt(j - 2),即s.charAt(i - 1)是否和'*'之前的字母相等,或者p.charAt(j - 2)等于通配符'.'。这时候代表epislon transition : '*'之前的字母出现1次。我们可以设置dp[i][j] = true。
- 否则p.charAt(j - 1) != '*'。 这时候我们只需要考虑在之前两个字母匹配的情况下,即 dp[i - 1][j - 1] = true时, 现在s中的字符s.charAt(i - 1)是否等于现在p中的字符p.charAt(j - 1),或者现在p的字符为通配符'.'
- 当 p.charAt(j - 1) = '*'时,这时候我们要考虑系统可能处于的两种状态
- 返回结果
Java:
Time Complexity - O(mn), Space Complexity - O(mn)。
public class Solution { public boolean isMatch(String s, String p) { if (s == null || p == null) return s == p; if (p.length() == 0) return s.length() == 0; if (p.charAt(0) == '*') return false; int sLen = s.length(), pLen = p.length(); boolean[][] dp = new boolean[sLen + 1][pLen + 1]; dp[0][0] = true; for (int j = 2; j <= pLen; j++) { if (p.charAt(j - 1) == '*' && dp[0][j - 2]) dp[0][j] = true; } for (int i = 1; i <= sLen; i++) { for (int j = 1; j <= pLen; j++) { if (p.charAt(j - 1) == '*') { if (dp[i][j - 2] || (dp[i - 1][j] && ((s.charAt(i - 1) == p.charAt(j - 2)) || (p.charAt(j - 2) == '.')))) { dp[i][j] = true; } } else { if (dp[i - 1][j - 1] && (s.charAt(i - 1) == p.charAt(j - 1) || p.charAt(j - 1) == '.')) { dp[i][j] = true; } } } } return dp[sLen][pLen]; } }
Reference:
http://algs4.cs.princeton.edu/54regexp/NFA.java.html
https://en.wikipedia.org/wiki/Regular_expression
https://en.wikipedia.org/wiki/Thompson%27s_construction
https://www.cs.ubc.ca/~kevinlb/teaching/cs322%20-%202008-9/Lectures/Search3.pdf
http://blog.csdn.net/linhuanmars/article/details/21145563
http://blog.csdn.net/linhuanmars/article/details/21198049
https://leetcode.com/discuss/18970/concise-recursive-and-dp-solutions-with-full-explanation-in
https://leetcode.com/discuss/9405/the-shortest-ac-code
https://leetcode.com/discuss/32424/clean-java-solution
https://leetcode.com/discuss/43860/9-lines-16ms-c-dp-solutions-with-explanations
https://leetcode.com/discuss/55253/my-dp-approach-in-python-with-comments-and-unittest
https://leetcode.com/discuss/8648/my-ac-dp-solution-for-this-problem-asking-for-improvements
https://leetcode.com/discuss/20470/fast-python-solution-with-backtracking-and-caching-solution
https://leetcode.com/discuss/26809/dp-java-solution-detail-explanation-from-2d-space-to-1d-space
https://leetcode.com/discuss/48436/accepted-solution-based-nondeterministic-finite-automata
https://leetcode.com/discuss/31950/my-dfa-deterministic-finite-automata-java-codes
https://leetcode.com/discuss/57880/solution-based-nfa-without-backtracking-only-4ms-from-russ
https://leetcode.com/discuss/75098/java-4ms-dp-solution-with-o-n-2-time-and-o-n-space-beats-95%25