實現正則中的通配符匹配及編程感想

很久以前,剛接觸正則表達式的時候,驚訝於它有如此強大的匹配功能;也略微想了一下應該如何實現正則表達式的匹配功能,當時就覺得頭大,連個星號的匹配也沒想清楚,就放棄了。
現在很偶爾地刷刷LeetCode上的題。這兩天剛好碰到一個通配符問題:要求實現問號(?)和星號(*)通配符的功能。於是就仔細思考了一下,用了兩三天的業餘時間,最終把問題解決了。在回顧整個解決過程時,發現整個編程過程由一次基本實現和三次大改進構成。而這三次較大的改進都涉及到編程中一些重要、常用,卻並不複雜的技巧,所以值得在此記錄一下。

題目

首先,把問題再清晰地描述一遍:

給定一個 string (s) 和一個 pattern §, 實現通配符模式匹配,要求支持 ‘?’ 和 ‘*’.

‘?’ 匹配任何單一字符
‘*’ 匹配任何字符序列(包括空字符序列)
匹配要求覆蓋整個輸入字符串(如果僅僅能匹配輸入字符串s的一部分,則算作不匹配)
(注:這裏和一般的正則表達式的匹配規則還不那麼一樣,因爲這裏要求s和p完全匹配)

注意:
s可以爲空,以及包含任意小寫字母a-z
p可以爲空,以及包含任意小寫字母a-z以及通配符?和*

Example 1:

Input:
    s = "aa"
    p = "a"
    
Output: false

解釋: "a" 沒有匹配整個字符串 "aa".

Example 2:

Input:
    s = "aa"
    p = "*"
    
Output: true

解釋: '*' 符合整個序列

Example 3:

Input:
    s = "cb"
    p = "?a"
    
Output: false

Example 4:

Input:
    s = "adceb"
    p = "*a*b"
    
Output: true

解釋: 第一個 '*' 匹配空序列, 第二個 '*' 匹配 "dce".

最初的想法

最初的想法很簡單,就是一個個字符匹配過去。具體來講,分爲3個部分:

  1. 如果 p 的當前字符不是通配符,那麼就比較 p 和 s 的各自當前字符。若不一樣,則返回false;若一樣,則進行下一個字符的比較。

  2. 如果 p 的當前字符是 ‘?’, 那麼就pass當前字符,同時也pass掉 s 的當前字符,進行下一個字符的比較。

  3. 如果 p 的當前字符是 ‘*’, 筆者當時的處理方法有缺陷,就不再此具體列出了。後面再詳細講。
    但是,這裏有一個關鍵點,就是取緊跟在 ‘*’ 後面的字符和從s的當前字符開始的字符進行比較。

以上這三點,構成了整個程序最基礎的架構。任何一個思路清晰的程序員,在這個地方應該不會犯錯的。
但是,邊界條件方面還是要注意一下。邊界條件就是,什麼時候結束?這要分幾種情況了。

  1. p 和 s 同時走完
    這個時候就是匹配了。

  2. p 沒走完,而 s 走完了
    這個時候要看p剩下的部分是不是全都是星號。如果全是星號,則仍是匹配的;否則,是不匹配的。

  3. p 走完了,而 s 沒走完
    這就是沒有匹配了

最初的想法實現之後,在系統上提交了,結果發現對星號的匹配是有問題的。見下面幾個例子:

// 匹配 
s = "abefcdgiescdfimde";
p = "ab*cd?i*de";

// 匹配
s = "mississippi";
p = "m*iss*";

// 不匹配
s = "mississippi";
p = "m??*ss*?i*pi";

第一次改進 - 使用遞歸

在意識到算法本身存在缺陷後(主要是針對星號的部分),筆者想到,如果通過遞歸,可能能夠比較容易地解決這個問題,而且代碼的可讀性也會比較好。

對星號的具體處理是這樣的:
首先,取到星號後面的一個字符:
如果這個字符還是星號,則繼續往後取;
如果這個字符是問號,則把s和p的當前字符都pass掉,繼續往後取(對於p,就是pass掉問號);
如果這個字符不是星號也不是問號,則停住;
然後,取s的當前字符和p的當前字符進行比較:
如果不相等,則說明不匹配,結束;
如果相當,則要遞歸了 - 截取字符串: 對s和p,都是截取當前字符開始到結束;然後用這2個新字符串作爲參數,調用比較函數。
此時,面臨一個問題:
如果遞歸的函數返回爲true了,則說明截取的子串和截取的pattern是匹配的,那整個串也就匹配了;
如果遞歸函數返回false呢?那隻能說明,從s的當前字符開始到結束的子串,並不能匹配pattern的子串。
但是注意,我們pattern子串前面的那個字符是星號,所以s的當前字符就可以算到星號裏面了,那還得繼續往後找子串取匹配。

舉個例子,s 是 acbcd , p 是 a*cd .
第一個字符匹配上了,遞歸匹配 cbcd 和 *cd
然後,星號略過,c也匹配上了,但bcd和d匹配不上了,可是這時候不能算錯;得把b也算到*中,接着往後走。

那麼具體到算法上,應該怎麼理解比較方便呢?
這麼理解:
假設星號後的第一個非通配符字符爲 a , 則在s中找到當前字符(含)之後的所有字符中的a的位置。
假設有n個位置都是a,那麼從這n個位置開始到s的結束,截取字符串。這就可以截取出n個字符串。而這n個字符串中只要有一個符合pattern的星號後子串,則算匹配成功了。

以上,就是對星號的處理了。

所以,顯而易見,所謂遞歸,就是對截取的s子串和p子串進行比較。

看起來算法已經完善了,是不是?
沒錯,算法看起來沒有什麼漏洞了。但是實際執行過程中,出現了performance的問題!對於如下字符串的匹配比較,執行時間太長而沒有通過系統審覈!

// 不匹配
s = "babbbbaabababaabbababaababaabbaabababbaaababbababaaaaaabbabaaaabababbabbababbbaaaababbbabbbbbbbbbbaabbb";
p = "b**bb**a**bba*b**a*bbb**aba***babbb*aa****aabb*bbb***a";

爲什麼performance太差了呢?筆者當時爲了驗證算法,快速進行原型實現,沒有去理會一些編程細節問題。程序是這麼寫的:

bool isMatch(string s, string p) {
    ......
    string new_s(s.sub(si, s.size()));
    string new_p(p.sub(pi, p.size()));
    
    if (isMatch(new_s, new_p)) {
     ...
    }
    ......
}

有什麼問題嗎?下節分解。

第二次改進 - 只用遊標不拷貝

上節末最後的代碼,當然有問題,而且問題很大。在注重效率的C/C++編程中,像這樣直接傳一個string,顯然是把string又構造了一遍,雖然短了一點。而上述的代碼其實是2次,第2次是函數參數傳遞時的拷貝構造。
而這些拷貝又是在遞歸時進行的,而這些遞歸函數又是會被重複多次的(後面再說這個),所以就造成了非常大量的內存拷貝,導致效率極其低下。比如,只是做上述長字符串的匹配,在筆者的機器上就會花3秒左右。這簡直不可思議。

解決方案是什麼呢?當然是用指針或者引用來代替傳值。這裏選擇引用,更加方便。那麼如何解決截取的問題呢?再加2個遊標就可以了。一個代表s的當前字符位置,另一個代表p的當前字符位置。
好了,那麼這樣的話,我們的遞歸函數的外形看起來是這樣的:

bool myMatch(const string& s, const string& p, int si, int pi);

這一改,上述長字符串的performace解決了,系統不再報錯了。由3秒左右降到了幾百毫秒吧,筆者沒有仔細統計。之所以沒有仔細統計,是因爲當遇到一個超長字符串的時候,performace又不過關了。筆者的機器上,這個超長字符串的驗證運行了十幾分鍾也沒有出結果。

s = "abbabaaabbabbaababbabbbbbabbbabbbabaaaaababababbbabababaabbababaabbbbbbaaaabababbbaabbbbaabbbbababababbaabbaababaabbbababababbbbaaabbbbbabaaaabbababbbbaababaabbababbbbbababbbabaaaaaaaabbbbbaabaaababaaaabb";
p = "**aa*****ba*a*bb**aa*ab****a*aaaaaa***a*aaaa**bbabb*b*b**aaaaaaaaa*a********ba*bbb***a*ba*bb*bb**a*b*bb";

是遇到了死循環嗎?並不是。筆者加了一句log. 通過打印出的log來看,是在做大量重複的計算,怎麼回事呢?詳見下節分解。

第三次改進 - 緩存計算結果

面對超長字符串,在調用遞歸函數之前,筆者打印出了遞歸函數的si和pi參數,發現大量的遞歸函數被重複執行,爲什麼呢?

先想一下Fibonacci數列的處理吧,f(n) = f(n-1) + f(n-2), 也是遞歸的處理,當計算 f(10) 的時候,像 f(3) 這種偏底層的函數,會在f(4),f(5),f(6)…f(10)的每一次函數調用中,都被調到一次。
但是需要這樣嗎?除了最精簡的列表法之外,另一種方法是,把計算過的 f(x) 的值緩存下來,這樣可以大大節約時間。

我們面臨的是同樣的問題。
看一個例子, s=“abcbcbcd”, p=“a*bce”
第1層遞歸中的第1次遞歸,進行子串匹配 isMatch(“bcbcbcd”, “*bce”),最後會失敗在 isMatch(“bcd”, “*bce”) 這個函數;
第1層遞歸中的第2次遞歸,進行子串匹配 isMatch(“bcbcd”, “*bce”),最後還是會失敗在 isMatch(“bcd”, “*bce”) 這個函數;
第1層遞歸中的第3次遞歸,進行子串匹配 isMatch(“bcd”, “*bce”),還是最終失敗在 isMatch(“bcd”, “*bce”) 這個函數;

所謂的 isMatch(“bcd”, “*bce”), 其實就是 myMatch(s, p, 5, 4) , 那麼這個函數只要計算一次就夠了,何必計算多次呢?
所以,策略就是,將所有遞歸調用的結果緩存下來。在調用遞歸之前,先檢查要調用的遞歸結果是否已經被緩存起來;如是,則直接取緩存結果即可;如否,調用遞歸函數,然後將結果保存在緩存中。
注意,緩存中所存的永遠是返回爲false的遞歸調用,因爲如果是返回true的話,就會一路返回上去,最後總結果就是匹配的了。
具體到數據結構。遞歸調用時,參數是si和pi,那麼把這2個數字存在 pair<int, int> 這樣的數據結構中。然後把這個pair存到 set<pair<int, int>> 這樣的數據結構中。

至此,經過三次改進,代碼終於可以通過測試系統的驗證了。

完整的代碼

完整的代碼,包括測試代碼,如下:

#include <iostream>
#include <set>
#include <string>
using namespace std;


bool mymatch(const string& s, const string& p, int si, int pi, set<pair<int, int>>& cache) {
    bool result = false;
    
    int s_size = s.size() - si;;
    int p_size = p.size() - pi;
    
    if (s_size == 0 && p_size == 0) {
        return true;
    }
    else if (s_size > 0 && p_size == 0) {
        return false;
    }
    else if (s_size == 0 && p_size > 0) {
        for (int i=pi; i<p.size(); i++) {
            if (p[i] != '*') return false;
        }
        return true;
    }
    
    while (true) {
        if (p[pi] != '*' && p[pi] != '?') {
            if (s[si] != p[pi]) {
                result = false;
                break;
            }
            else {
                pi ++;
                si ++;
            }
        }
        
        else if (p[pi] == '?') {
            pi ++;
            si ++;
        }
        
        else {  // if (p[pi] == '*')
            if (pi == p.size()-1) {
                result = true;
                break;
            }
            else {
                bool match_star = false;
                
                while ( (p[pi] == '*' || p[pi] == '?') && pi < p.size() ) {
                    if (p[pi] == '*') {
                        pi ++;
                    }
                    else if(p[pi] == '?') {
                        pi ++;
                        si ++;
                        if (si == s.size()) {
                            for (int k=pi; k<p.size(); k++) {
                                if (p[k] != '*') return false;
                            }
                            return true;
                        }
                    }
                }
                if (pi == p.size()) return true;
                
                for (int i=si; i<s.size(); i++) {
                    // cout << "i = " << i << ", pi = " << pi << endl;
                    if (s[i] == p[pi]) {
                        // Look up <si, pi> in the cache
                        pair<int, int> tgt_pair(i, pi);
                        if ( cache.find(tgt_pair) != cache.end() ) { 
                            return false;
                        }
                        
                        if ( mymatch(s, p, i, pi, cache) ) {
                            return true;
                        }
                        else {
                            // record the failure pair to set 
                            cache.insert(tgt_pair);
                        }
                    }
                }
                result = false;
                break;
            }
        }
        
        if (pi == p.size() && si == s.size()) {
            result = true; 
            break;
        }
        else if (pi == p.size() && si < s.size()) {
            result = false;
            break;
        }
        else if (pi < p.size() && si == s.size()) {
            for (int k=pi; k<p.size(); k++) {
                if (p[k] != '*') return false;
            }
            result = true;
            break;
        }
        else {
            // do nothing
        }
    }
    
exit:
    return result;
}

bool isMatch(string s, string p) {
    set<pair<int, int>> cache;
    return mymatch(s, p, 0, 0, cache);
}

# define CHECK_IF_SHOULD_MATCH(bShould)     \
    if ( isMatch(s, p) == (bShould) ) {     \
        cout << "Correct: they do " << (bShould ? "":"not ") << "match: \n"; \
        cout << "   s=" << s << endl;       \
        cout << "   p=" << p << endl;       \
    }                           \
    else {                      \
        cout << "Wrong: they should " << (bShould ? "" : "not ")   \
             << "match but " << (bShould ? "not" : "do") << ":\n"; \
        cout << "   s=" << s << endl;       \
        cout << "   p=" << p << endl;       \
    }

int main()
{
    string s, p;
    
    s = "aa";
    p = "a";
    CHECK_IF_SHOULD_MATCH(false)
    
    s = "adceb";
    p = "*a*b";
    CHECK_IF_SHOULD_MATCH(true)
    
    s = "aaaa";
    p = "***a";
    CHECK_IF_SHOULD_MATCH(true)
    
    s = "";
    p = "";
    CHECK_IF_SHOULD_MATCH(true)
    
    s = "";
    p = "*";
    CHECK_IF_SHOULD_MATCH(true)
    
    s = "a";
    p = "a*";
    CHECK_IF_SHOULD_MATCH(true)
    
    s = "c";
    p = "*?*";
    CHECK_IF_SHOULD_MATCH(true)
    
    s = "hi";
    p = "*?";
    CHECK_IF_SHOULD_MATCH(true)
    
    s = "abefcdgiescdfimde";
    p = "ab*cd?i*de";
    CHECK_IF_SHOULD_MATCH(true)
    
    s = "mississippi";
    p = "m*iss*";
    CHECK_IF_SHOULD_MATCH(true)
    
    s = "mississippi";
    p = "m??*ss*?i*pi";
    CHECK_IF_SHOULD_MATCH(false)
    
    s = "babbbbaabababaabbababaababaabbaabababbaaababbababaaaaaabbabaaaabababbabbababbbaaaababbbabbbbbbbbbbaabbb";
    p = "b**bb**a**bba*b**a*bbb**aba***babbb*aa****aabb*bbb***a";
    CHECK_IF_SHOULD_MATCH(false)
    
    s = "babbbbaabababaabbababaababaabbaabababbaaababbababaaaaaabbabaaaabababbabbababbbaaaababbbabbbbbbbbbbaabbb";
    p = "b*bb*a*bba*b*a*bbb*aba*babbb*aa*aabb*bbb*a";
    CHECK_IF_SHOULD_MATCH(false)
    
    
    s = "abbabaaabbabbaababbabbbbbabbbabbbabaaaaababababbbabababaabbababaabbbbbbaaaabababbbaabbbbaabbbbababababbaabbaababaabbbababababbbbaaabbbbbabaaaabbababbbbaababaabbababbbbbababbbabaaaaaaaabbbbbaabaaababaaaabb";
    p = "**aa*****ba*a*bb**aa*ab****a*aaaaaa***a*aaaa**bbabb*b*b**aaaaaaaaa*a********ba*bbb***a*ba*bb*bb**a*b*bb";
    CHECK_IF_SHOULD_MATCH(false)

    return 0;
}

最後的總結

最後寫幾點總結和感想吧:

  1. 寫程序可以先寫基本框架,然後再慢慢完善;
  2. 在程序的調試過程中,gdb是一個很有用的工具。尤其是面對非常複雜的邏輯時,gdb配合思考,比單純的看代碼要更高效一些;
  3. 除了gdb,有的時候打印log來調試,也是一個不錯的選擇;
  4. 有的時候要想想遞歸是否會有助於將大問題化爲小問題來解決;
  5. 函數的參數要特別注意,複雜類型的傳值參數往往都很低效;如果這些函數再被大量調用的話,就更糟糕了;
  6. 以上第5條,從另一個角度去理解,就是程序中的內存拷貝,往往會耗費大量的計算時間;所以,有些情況下,共享內存也是一個可以大量減少運行時間的辦法;
  7. 緩存經常會用到的計算結果,會極大地提升效率;

(完)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章