導言java
這篇文章主要目的是解析 LeetCode 上面的一道經典動態規劃問題,Regular Expression Matching,但這裏還會講解暴力搜索、記憶化搜索,以及面對字符串類的動態規劃問題該如何更好地切題express
LeetCode 10. Regular Expression Matching數組
這道題實際上是要實現 Regular Expression 裏面的兩個符號,一個是 '.',另外一個是 '*', 前者能夠 match 任意一個字符,後者表示其前面的字符能夠重複零次或者屢次,舉幾個例子,match("aa","a") => false
,match("ab",".*") => true
,match("aab","c*a*b") => true
spa
題目的難點實際上是在於 * 上面,若是沒有這個 *,題目會變得很是簡單,這裏仍是說一下題目的兩個隱含條件,一個就是 * 不會出如今字符串的開頭,另一個是 * 前面不能是 *,好比 "a**b" 就不行,固然你也能夠把這兩個隱含條件看成一個來看,無論如何,咱們的代碼實現必須創建在這個基礎之上,不然,cases 考慮多了,題目將無從下手。code
遞歸方式的暴力深度優先搜索求解方法每每是搜索問題的萬金油,這裏你只須要簡單的考慮兩件事情,一是,這個問題是否能夠劃分爲幾個小的子問題,二是,每一個劃分後的子問題有幾種狀態,也就是在當前考慮的子問題下,一共有多少種不一樣的可能性。知道了這兩點後,對於每一個子問題的每個狀態遞歸求解就行。遞歸
上面說的可能有點抽象,結合這個題目來作例子,這裏的問題是,輸入一個字符串 s,以及匹配字符串 p,要求解這兩個字符串是否匹配。咱們首先考慮這個字符串比較的問題能不能劃分爲一個個的子問題,你發現字符串是能夠劃分紅爲一個個字符的,這樣字符串比較的問題就會變成字符的比較問題,這樣一來,咱們就能夠把問題當作,決定 s[i,...n] 是否可以匹配 p[j,...m] 的條件是子問題 s[i+1,...n] 能不可以匹配 p[j+1,...m],另外還要看 s[i] 和 p[j] 是否匹配,可是這裏的當前要解決的問題是 s[i] 和 p[j] 是否匹配,只有這一點成立,咱們纔會繼續去看 s[i+1,...n] 和 p[j+1,...m] 是否匹配,注意這裏我說的 s[i] p[j] s[s+1,...n] p[j+1,...m], 並不表示說當前就只用考慮這兩個字符之間匹不匹配,它只是用來表示當前問題,和後面須要考慮的問題,這個當前問題也許只須要比較一個字符,也許要比較多個,這就引伸出了前面提到的第二點,咱們還須要考慮當前問題中的狀態。對於字符串 s 來講,沒有特殊字符,當前問題中字符只會是字母,可是對於 p 來講,咱們須要考慮兩個特殊符號,還有字母,這裏列舉全部的可能,若是說當前的子問題是 s[i,...n] 和 p[j...m]:leetcode
這裏我解釋下第四和第五種狀況,以前在題目描述裏說過,p 的起始字符不多是 *,也就是說 * 的前面必須有字母,根據定義,這裏咱們能夠把 * 的前面的元素個數算做是零個或者是一個或多個,若是是零個,這樣咱們就只用看,s[i,...n] 和 p[j+2,...n] 是否匹配,若是算做一個或者多個,那麼咱們就能夠看 s[i+1,...n] 和 p[j,...m] 是否成立,固然算做一個或者多個的前提是 p[j] == s[i] 或者 p[j] == '.', 咱們能夠結合代碼來看看字符串
public boolean isMatch(String s, String p) {
if (s.equals(p)) {
return true;
}
boolean isFirstMatch = false;
if (!s.isEmpty() && !p.isEmpty() && (s.charAt(0) == p.charAt(0) || p.charAt(0) == '.')) {
isFirstMatch = true;
}
if (p.length() >= 2 && p.charAt(1) == '*') {
// 看 s[i,...n] 和 p[j+2,...m] 或者是 s[i+1,...n] 和 p[j,...m]
return isMatch(s, p.substring(2))
|| (isFirstMatch && isMatch(s.substring(1), p));
}
// 看 s[i+1,...n] 和 p[j+1,...m]
return isFirstMatch && isMatch(s.substring(1), p.substring(1));
}
複製代碼
上面的實現之因此被稱爲暴力求解是由於子問題的答案沒有被記錄,也就是說若是當前要用到以前的子問題的答案,咱們還得去計算以前計算過的子問題。get
上面的暴力解法是由於沒有記錄答案,記憶化搜索是在 「傻搜」 的基礎之上添加 「記事本」。這裏提早說明一下待會給出的代碼實現,我把遞歸的方向給改變了,固然這不是必要的,主要想說明,對於這道題來講,從後往前考慮和從前日後考慮都是可行的,可是從後往前考慮重合的 cases 會更多,這樣 「記事本」 所體現的功能就會更大。string
咱們假設當前問題是考慮 s 的第 i 個字母,p 的第 j 個字母,因此這時的子問題是 s[0...i] 和 p[0...j] 是否匹配:
不論是從前日後,仍是從後往前,你能夠看到,考慮的點都是同樣的,只是這裏咱們多加了一個 「記事本」
public boolean isMatch(String s, String p) {
if (s.equals(p)) {
return true;
}
boolean[] memo = new boolean[s.length() + 1];
return helper(s.toCharArray(), p.toCharArray(),
s.length() - 1, p.length() - 1, memo);
}
private boolean helper(char[] s, char[] p, int i, int j, boolean[] memo) {
if (memo[i + 1]) {
return true;
}
if (i == -1 && j == -1) {
memo[i + 1] = true;
return true;
}
boolean isFirstMatching = false;
if (i >= 0 && j >= 0 && (s[i] == p[j] || p[j] == '.'
|| (p[j] == '*' && (p[j - 1] == s[i] || p[j - 1] == '.')))) {
isFirstMatching = true;
}
if (j >= 1 && p[j] == '*') {
// 看 s[0,...i] 和 p[0,...j-2]
boolean zero = helper(s, p, i, j - 2, memo);
// 看 s[0,...i-1] 和 p[0,...j]
boolean match = isFirstMatching && helper(s, p, i - 1, j, memo);
if (zero || match) {
memo[i + 1] = true;
}
return memo[i + 1];
}
// 看 s[0,...i-1] 和 p[0,...j-1]
if (isFirstMatching && helper(s, p, i - 1, j - 1, memo)) {
memo[i + 1] = true;
}
return memo[i + 1];
}
複製代碼
除了記事本,其他並無太大的差異。其實這種方式已經算是動態規劃了。
有了上面兩種方法和解釋做爲鋪墊,我想迭代式的動態規劃應該不難理解。這裏咱們再也不用遞歸,而是使用 for 循環的形式,先上代碼:
public boolean isMatch(String s, String p) {
if (s.equals(p)) {
return true;
}
char[] sArr = s.toCharArray();
char[] pArr = p.toCharArray();
// dp[i][j] => is s[0, i - 1] match p[0, j - 1] ?
boolean[][] dp = new boolean[sArr.length + 1][pArr.length + 1];
dp[0][0] = true;
for (int i = 1; i <= pArr.length; ++i) {
dp[0][i] = pArr[i - 1] == '*' ? dp[0][i - 2] : false;
}
for (int i = 1; i <= sArr.length; ++i) {
for (int j = 1; j <= pArr.length; ++j) {
if (sArr[i - 1] == pArr[j - 1] || pArr[j - 1] == '.') {
// 看 s[0,...i-1] 和 p[0,...j-1]
dp[i][j] = dp[i - 1][j - 1];
}
if (pArr[j - 1] == '*') {
// 看 s[0,...i] 和 p[0,...j-2]
dp[i][j] |= dp[i][j - 2];
if (pArr[j - 2] == sArr[i - 1] || pArr[j - 2] == '.') {
// 看 s[0,...i-1] 和 p[0,...j]
dp[i][j] |= dp[i - 1][j];
}
}
}
}
return dp[sArr.length][pArr.length];
}
複製代碼
這裏我說一下前面的 DP 數組的初始化,由於須要考慮空串的狀況,因此咱們 DP 數組大小多開了 1 格。由於兩個空串是匹配的,因此 dp[0][0] = true
,緊接着下面一行的 for 循環是爲了確保空串和 p 的一部分是匹配,好比 s = "",p = "a*",那麼這裏 dp[0][2]=true
,也就是 s[0,0]和p[0,2] 是匹配的,注意和以前不同的是這裏的 0 表明空串。
通常來講,對於字符串匹配的問題中,題目輸入參數都會有兩個字串,若是肯定了問題是能夠分解成一系列子問題,那麼就能夠考慮使用動態規劃求解,能夠根據區間來定義狀態,通常來講只須要考慮頭區間或者是尾區間,這道題中的動態規劃解法,咱們就是考慮了頭區間,s[0,...i]和p[0,...j] 是否匹配記錄在 dp[i+1][j+1] 中,若是你選擇尾區間的話,那麼遍歷的方式須要從後往前,就和以前講解的記憶化搜索同樣。通常的字符串匹配的動態規劃的 DP 數組都是二維的,固然也有特例。我的以爲肯定了考慮的區間和遍歷方向,至少來講在動態規劃狀態方程的推導上會清晰很多。
接下來就是重點的部分,遞推方程的推導,這裏沒有特別多的技巧,仍是那句話,惟手熟爾,無他,要說重點的話,仍是在肯定當前子問題和前面子問題的聯繫上吧,或者你能夠這樣想 「當前考慮的子問題在什麼狀況下會變成前面求解過的子問題」,仍是拿這道題舉例,上面的 DP 解法咱們從前日後遍歷,在考慮子問題 s[0,...i]和p[0,...j] 是否匹配,若是拿掉 s[i] 和 p[j],這個問題就會變成前面求解過的子問題 s[0,...i-1]和p[0,...j-1],若是隻拿掉 s[i],這個問題就會變成前面求解過的子問題 s[0,...i-1]和p[0,...j],若是拿掉 p[j-1]和p[j],這個問題就會變成前面求解過的子問題 s[0,...i]和p[0,...j-2],至於爲何有些能夠拿掉,有些不能,那這個只能根據題意來分析了,相信經過前面的分析應該不難理解。
結合上面的分析,這裏列了一些字符串匹配類動態規劃的一些注意事項:
以上就是此次的所有內容,但願對於你理解這道題,或者說是理解字符串匹配類動態規劃有所幫助。