深度解析 LC 10. Regular Expression Matching

導言java

這篇文章主要目的是解析 LeetCode 上面的一道經典動態規劃問題,Regular Expression Matching,但這裏還會講解暴力搜索、記憶化搜索,以及面對字符串類的動態規劃問題該如何更好地切題express


題目分析

LeetCode 10. Regular Expression Matching數組

這道題實際上是要實現 Regular Expression 裏面的兩個符號,一個是 '.',另外一個是 '*', 前者能夠 match 任意一個字符,後者表示其前面的字符能夠重複零次或者屢次,舉幾個例子,match("aa","a") => falsematch("ab",".*") => truematch("aab","c*a*b") => truespa

題目的難點實際上是在於 * 上面,若是沒有這個 *,題目會變得很是簡單,這裏仍是說一下題目的兩個隱含條件,一個就是 * 不會出如今字符串的開頭,另一個是 * 前面不能是 *,好比 "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

  • s[i] == p[j],子問題成立與否取決於子問題 s[i+1,...n] 和 p[j+1,...m]
  • p[j] == '.',子問題成立與否取決於子問題 s[i+1,...n] 和 p[j+1,...m]
  • p[j] != '.' && p[j] != '*' && p[j] != s[i],當前子問題不成立
  • p[j+1] == '*',s[i] != p[j],子問題成立與否取決於子問題 s[i,...n] 和 p[j+2,...m]
  • p[j+1] == '*',s[i] == p[j],子問題成立與否取決於子問題 s[i+1,...n] 和 p[j,...m] 或者 s[i,...n] 和 p[j+2,...m]

這裏我解釋下第四和第五種狀況,以前在題目描述裏說過,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] 是否匹配

  • p[j] 是字母,而且 s[i] == p[j],當前子問題成立與否取決於子問題 s[0...i-1] 和 p[0...j-1] 是否成立
  • p[j] 是 '.',當前子問題成立與否取決於子問題 s[0...i-1] 和 p[0...j-1] 是否成立
  • p[j] 是字母,而且 s[i] != p[j],當前子問題不成立
  • p[j] 是 '*',s[i] == p[j - 1],或者 p[j - 1] == '.', 當前子問題成立與否取決於子問題 s[0...i-1] 和 p[0...j] 是否成立
  • p[j] 是 '*',s[i] != p[j - 1],當前子問題正確與否取決於子問題 s[0...i] 是否匹配 p[0,...j-2]

不論是從前日後,仍是從後往前,你能夠看到,考慮的點都是同樣的,只是這裏咱們多加了一個 「記事本」

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],至於爲何有些能夠拿掉,有些不能,那這個只能根據題意來分析了,相信經過前面的分析應該不難理解。

結合上面的分析,這裏列了一些字符串匹配類動態規劃的一些注意事項:

  • 根據題意,思考是否須要考慮空串的狀況,若是是的話,DP 數組須要多開一格
  • 在考慮遞推方程前,肯定子問題的區間和遍歷方向
  • 在思考遞推方程的時候,重點思考當前子問題怎麼變成以前求解過的子問題

以上就是此次的所有內容,但願對於你理解這道題,或者說是理解字符串匹配類動態規劃有所幫助。

相關文章
相關標籤/搜索