【LeetCode】正則表達式匹配(動態規划)


題目描述

給定一個字符串 (s) 和一個字符模式 (p)。實現支持 '.' 和 '*' 的正則表達式匹配。

'.' 匹配任意單個字符。

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

匹配應該覆蓋整個字符串 (s) ,而不是部分字符串。

說明:

s 可能為空,且只包含從 a-z 的小寫字母。
p 可能為空,且只包含從 a-z 的小寫字母,以及字符 . 和 *。
示例 1:

輸入:
s = "aa"
p = "a"
輸出: false
解釋: "a" 無法匹配 "aa" 整個字符串。

示例 2:

輸入:
s = "aa"
p = "a*"
輸出: true
解釋: '*' 代表可匹配零個或多個前面的元素, 即可以匹配 'a' 。因此, 重復 'a' 一次, 字符串可變為 "aa"。

示例 3:

輸入:
s = "ab"
p = ".*"
輸出: true
解釋: ".*" 表示可匹配零個或多個('*')任意字符('.')。

示例 4:

輸入:
s = "aab"
p = "c*a*b"
輸出: true
解釋: 'c' 可以不被重復, 'a' 可以被重復一次。因此可以匹配字符串 "aab"。

示例 5:

輸入:
s = "mississippi"
p = "mis*is*p*."
輸出: false

題目難度:⭐⭐⭐

題目解析

這是一道有點難度的題,如果你看了一遍題目之后,沒有什么好的想法,不用心急,深呼吸,讓我們一起來探索如何解決這道題。

其實題目的要求,就是實現一個最簡單的正則表達式,即.*的匹配,一提到正則表達式,你也許會想到形如 ^[A-Z]:\\{1,2}[^/:\*\?<>\|]+\.(jpg|gif|png|bmp)$ 之類的一大串亂七八糟的代碼,覺得看着都蛋疼,還要讓我來實現???emmmm,不要方,問題不大,不要被正則表達式這個名號給嚇到,要相信,問題總比方法多🤣。何況這里只需要解析兩個特殊字符,豈不是小菜一碟。

明人不說騷話,擼起袖子就開干。

先重新閱讀一遍題目,對題目要求的理解和把握很關鍵,這決定了之后的思考會不會跑偏,后面的幾個示例可以用來驗證自己理解是否正確。

從后面給的栗子里可以看出,題目的意思是要求字符串s與字符模式p能完全匹配才能算是通過,而不是在s中找到一個p能匹配的子字符串。

腦袋一拍,那一個字符一個字符來匹配不就完事了?嗯,先試試看。把題中的栗子拿出來畫成圖,然后進行觀察。

在形成自己的思路后,一定要對這幾個栗子進行驗證,不然代碼寫完以后才發現理解錯了題目的意思就很尷尬了。🌝

對於一個位於字符模式p中的字符c來說,只有三種情況:

  1. c == '.'
  2. c == '*'
  3. c 為其他普通字符

我們先來看第一種情況,當c == '.'的時候,因為可以匹配任意字符,那么,直接跳過即可,對於第三種情況,那么只要s中對應的字符字符c相同即可,你看,很簡單吧,我們已經完成三分之二了。接下來,再來看看最后一種情況。

如果c == *,那么代表可以匹配零個或者多個前面的字符,比如a*可以匹配aaaaaaaaaa也可以匹配空字符,所以它其實是個修飾符,用來修飾它前面的字符,必須要跟其他字符一起使用,所以在我們在一個個遍歷模式串中的字符的時候,還需看看后面跟的字符是不是*,如果是的話,那么就要進行特殊處理了。

*代表匹配0個或多個它前面的字符,所以有兩種情況,一種是0個,一種是多個。

梳理一下思路,每次從p中拿出一個字符來與s中的字符進行匹配,如果該字符后續的字符不是*,那么直接與s中對應字符進行匹配判斷即可,如果匹配上了,那么就將兩個游標都往后移動一位。如果匹配過程中遇到不相等的情況,則直接返回false。如果后續字符是*,那么就如上面所分析的,分成兩種情況,一種是匹配0個,那么只需要跳過p中的這兩個字符,繼續與s中的字符進行比較即可,如果是匹配多個,那么將s中的游標往后移動一個,繼續進行判斷,這兩個條件只要其中一個能滿足即可。

對於上面分析*字符的說明也許還不夠清晰,繼續畫圖:

等等,你有沒有聞到一絲遞歸的味道,既然對於每個在模式串中的字符都可以采用相同的策略進行處理,那不就是暗示這里可以使用遞歸嗎。機智如我😝

遞歸解法

先來寫一下偽代碼來繼續理清思路,畢竟這可是一道復雜度為三星級別的題,萬萬不可輕敵。

boolean isMatch (String s, String p){
    從p中取出字符c1,從s中取出字符d1
    從p中再取一個字符c2
    if (c2 == '*'){
        跳過c1與c2或者將s的游標往后移動一位
        return isMatch(s,p.subString(2)) || (( c1 == '.' || c1 == d1) && isMatch(s.subString(1),p)));
    } else if(c1 == '.'){
        直接跳過
        return isMatch(s.subString(1),p.subString(1);
    } else {
        普通字符直接比較
        return c1 == d1 && isMatch(s.subString(1), p.subString(1));
    }
}

emmm,這個偽代碼好像不太合格,幾乎把代碼寫完了,23333,接下來只需要考慮一下邊界情況,把代碼補全就行了,當然,還可以將代碼美化一下:

public boolean isMatch(String s, String p){
    if (p.length() <= 0) return s.length() <= 0;
    boolean match = (s.length() > 0 && (s.charAt(0) == p.charAt(0) || p.charAt(0) == '.'));
    if (p.length() > 1 && p.charAt(1) == '*'){
        return isMatch(s, p.substring(2)) || (match && isMatch(s.substring(1), p));
    } else {
        return match && isMatch(s.substring(1), p.substring(1));
    }
}

大功告成,提交一下。

emmm,遞歸的效率一般都比較差,只擊敗了28%的用戶。

當然,一般能用遞歸解決的地方,都可以使用非遞歸的方式解決,下面,我們來使用另一種解決方案。

動態規划解法

動態規划簡介

動態規划???emmm,如果你不經常接觸算法的話,也許對這個名詞不太熟悉,所以我先簡單的介紹一下。

動態規划,簡單來說就是,動態的去進行,規划。😂言歸正傳,其實動態規划也是一種分治的思想,將問題分解成一個個子問題,通過解決所有子問題,來求得原問題的解,一般用於求解最優問題。但是跟分治法不同的地方在於,動態規划的子問題往往是相互關聯的,拿最簡單的斐波拉契數列來說,我們使用分治的思想,對於求fib(6),使用的公式是fib(6) = fib(5) + fib(4),於是將原來的問題便轉化為求解fib(5)fib(4),繼續遞歸,fib(5) = fib(4) + fib(3),然后再繼續遞歸fib(4) = fib(3) + fib(2) fib(3) = fib(2) + fib(1) 這里fib(1) = 1fib(2) = 1為初始條件,於是就能求出fib(6),初看起來似乎沒什么毛病,但是仔細想一想,由於每次遞歸都是無狀態的,所以其實做了很多重復的計算,畫個圖來感受一下:

這里將fib(4)重復算了2次,fib(3)算了3次,這還只是算fib(6),如果是fib(66)呢?那將會有大量的重復計算,這是非常浪費時間的。

動態規划就可以很好的解決這個問題,動態規划的思想跟上面是一樣的,但不同的是,動態規划會將每次計算的結果存起來,因此就解決了。簡單一點理解,就是在分治的基礎上加入了一個狀態數組,來存儲中間計算的結果,以減少重復計算的耗時。當然,動態規划又分為兩種,一種是自頂向下,就是剛才所說的方法,另一個種是自底向上,還是拿上面的斐波拉契數列來說,要計算fib(6),因此我們先計算fib(3) = fib(2) + fib(1) ,再計算fib(4) = fib(3) + fib(2) fib(5) = fib(4) + fib(3),這樣,就能算出fib(6) = fib(5) + fib(4)的結果了。

在動態規划中有幾個比較關鍵的概念:子問題,狀態,狀態空間,初始狀態,狀態轉移方程。

子問題:與原問題形式相同或者類似,只不過規模變小了,子問題都解決后,原問題即解決。

狀態:與子問題相關的各個變量的一組取值即為狀態,狀態與子問題是一對一或一對多的關系,代表着子問題的解。上面的栗子,狀態就是fib(n)的值。

狀態空間:由所有狀態構成的集合,上面的栗子比較簡單,狀態空間是一維空間。

狀態初始條件:即狀態的初始狀態,上面的栗子里fib(1) = 1fib(2) = 1就是初始條件。

狀態轉移方程:用來表示狀態之間是如何轉換的方程,即如何從一個或者多個已知的狀態求出另一個狀態,可以使用遞推公式表示。上面栗子的公式為fib(n) = f(n - 1) + f(n -2) (n > 2)

算法過程

關於動態規划的介紹就結束了,接下來我們來看如何在這道題上面使用。

我們先來考慮自頂向下的算法。為方便起見,假定使用符號s[i:]表示字符串s中從第i個字符到最后一個字符組成的子串,p[j:]則表示模式串p中,從第j個字符到最后一個字符組成的子串,使用 match(i,j) 表示s[i:]p[j:]的匹配情況,如果能匹配,則置為true,否則置為false。這就是各個子問題的狀態。

那么對於match(i,j)的值,取決於p[j + 1]是否為'*'。

curMatch = i < s.length() && s[i] == p[j] || p[j] == '.';

  1. p[j + 1] != '*',match(i,j) = curMatch && match(i + 1, j + 1)
  2. p[j + 1] == '*',match(i,j) = match(i, j + 2) || curMatch && match(i + 1, j)

這樣表述一下是不是就清晰了不少。

s = "aab"; p = "c*a*b"為例,先構建一個二維狀態空間來存儲中間計算得出的狀態值。橫向的值代表i,縱向的值代表j,match(0,0)的值即問題的解,用f代表falset代表true

接下來描述一下后續的計算過程:

1. 求match(0,0): i = 0; j = 0; curMatch = false;
2. p[1] == * -> match(0,0) = match(0,2) || false && match(1,0)
3. 轉化為求子問題match(0,2)和match(1,0)
4. 求match(0,2): i = 0; j = 2; curMatch = true;
5. p[1] == * -> match(0,2) = match(0,4) || true && match(1,2)
6. 求match(0,4): i = 0; j = 4; curMatch = false;
7. j + 1 == 5 >= p.length() -> match(0,4) = curMatch = false;
8. match(0,4) = false;
9. 回溯到第五步,求match(1,2): i = 1; j = 2; curMatch = true;
10. p[3] == * -> match(1,2) = match(1,4) || true && match(2,2)
11. 求match(1,4): i = 1; j = 4; curMatch = false;
12. j + 1 == 5 >= p.length() -> match(1,4) = curMatch = false;
13. match(1,4) = false;
14. 回溯到第10步,求match(2,2): i = 2; j = 2; curMatch = false;
15. p[3] == * -> match(2,2) = match(2,4) || false && match(3,2)
16. 求match(2,4): i = 2; j = 4; curMatch = true;
17.  j + 1 == 5 >= p.length() -> match(2,4) = curMatch = true;
18. match(2,4) = true;
19. 回溯到第15步。
20. match(2,2) = true;
21. 回溯到第10步。
22. match(1,2) = true;
23. 回溯到第5步。
24. match(0,2) = true;
25. 回溯到第2步。
26. match(0,0) = true;
27. 問題解決

你看,其實很簡單吧。😅

接下來轉化成代碼:

enum Result {
    TRUE, FALSE
}

class Solution {
    // 狀態空間
    Result[][] memo;

    public boolean isMatch(String text, String pattern) {
        memo = new Result[text.length() + 1][pattern.length() + 1];
        return match(0, 0, text, pattern);
    }

    public boolean match(int i, int j, String text, String pattern) {
        if (memo[i][j] != null) {
            return memo[i][j] == Result.TRUE;
        }
        boolean ans;
        if (j == pattern.length()){
            ans = i == text.length();
        } else{
            boolean curMatch = (i < text.length() &&
                                   (pattern.charAt(j) == text.charAt(i) ||
                                    pattern.charAt(j) == '.'));

            if (j + 1 < pattern.length() && pattern.charAt(j+1) == '*'){
                ans = (match(i, j+2, text, pattern) ||
                       curMatch && match(i+1, j, text, pattern));
            } else {
                ans = curMatch && match(i+1, j+1, text, pattern);
            }
        }
        memo[i][j] = ans ? Result.TRUE : Result.FALSE;
        return ans;
    }
}

來跑一下結果:

擊敗了99.95%,不錯不錯。

已經很晚了,但我還是想把另一種方法也一起寫完。🙄

還有一種方法,叫做自底向上方法,也是動態規划中的一種,這種方法的思路其實很簡單粗暴,即從最后一個字符開始反向匹配,還是以剛才的栗子為例,從i = 3, j = 5 開始依次往左往上循環計算,match(3,5) == true,核心的邏輯並沒有變。因為最邊緣的值的匹配都是可以直接計算出來的,下面推算其中的一部分:

1. match(3,5) = true;
2. 求match(3,4): i = 3; j = 4; curMatch = false;
3. j + 1 == 5 >= p.length() -> match(3,4) = curMatch = false;
4. match(3,4) = false;
5. 求match(3,3): i = 3; j = 3; curMatch = false;
6. p[4] == b -> match(3,3) = curMatch = false;
7. match(3,3) = false;
8. 求match(3,2): i = 3; j = 2; curMatch = false;
9. p[3] == * -> match(3,2) = match(3,4) || false && match(4,2)
10. match(3,2) = false;
11. 求match(3,1): i = 3; j = 1; curMatch = false;
12. p[2] == a -> match(3,1) = curMatch = false;
13. match(3,1) = false;
14. 求match(3,0): i = 3; j = 0; curMatch = false;
15. p[1] == * -> match(3,0) = match(3,2) || false && match(4,0)
16. match(3,0) = false;
17. ....

剩下的部分可以自行推導。代碼如下:

class Solution {
    public boolean isMatch(String text, String pattern) {
        boolean[][] memo = new boolean[text.length() + 1][pattern.length() + 1];
        memo[text.length()][pattern.length()] = true;

        for (int i = text.length(); i >= 0; i--){
            for (int j = pattern.length() - 1; j >= 0; j--){
                boolean curMatch = (i < text.length() &&
                                       (pattern.charAt(j) == text.charAt(i) ||
                                        pattern.charAt(j) == '.'));
                if (j + 1 < pattern.length() && pattern.charAt(j+1) == '*'){
                    memo[i][j] = memo[i][j+2] || curMatch && memo[i+1][j];
                } else {
                    memo[i][j] = curMatch && memo[i+1][j+1];
                }
            }
        }
        return memo[0][0];
    }
}

提交一下:

效率也是相當高的,雖然比自頂向下方法多計算了不少值,但是減少了方法調用次數,省去了多次遞歸調用方法的開銷,而且每次計算的過程相當簡單,所以並不能說它的效率比自頂向下的方法低,要視具體情況而定。

總結

寫到這,今天的題總算是完成的差不多了,長呼一口,來回顧一下今天的收獲吧:

首先我們用分治法,使用遞歸來解決,但是效率偏低。

於是我們用了動態規划的思想來解決這個問題,與分治法最大的不同便在於動態規划會存儲中間的計算狀態,以減少重復計算。

先是用了自頂向下的方法,跟分治法幾乎沒有差異,只是多使用了一個二維數組。

接着用自底向上的方法來解決,從最后的字符開始匹配,將多次遞歸調用轉為在一個循環體中完成。

總結一下動態規划的步驟:

  1. 抽象問題。將問題分解為多個子問題,子問題的解一旦求出就會被保存。
  2. 確定狀態。確認我們要求解的子問題的狀態空間,並設置初始狀態。
  3. 確定狀態轉移方程。這一步是最難也是最重要的一步。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM