BF,BM,KMP看完不懂,我就去剪光頭!

注:若是文中視頻不可播放,你們能夠去查看原文,我以爲即便不看視頻應該也能夠讀懂。php

微信截圖_20201224203858

爲保證代碼嚴謹性,文中全部代碼均在 leetcode 刷題網站 AC ,你們能夠放心食用。java

皇上生辰之際,舉國同慶,袁記菜館做爲天下第一飯店,因此被選爲此次慶典的菜品供應方,此次慶典對於袁記菜館是一項史無前例的挑戰,畢竟是第一次給皇上慶祝生辰,稍有不慎就是掉腦殼的大罪,整個袁記菜館內都在緊張的佈置着。此時忽然有一個店小二慌慌張張跑到袁廚面前彙報,到底發生了什麼事,讓店小二如此慌張呢?git

袁記菜館內程序員

店小二:很差了很差了,掌櫃的,出大事了。github

袁廚:發生什麼事了,慢慢說,如此慌張,成何體統。(開店開久了,架子出來了哈)面試

店小二:皇上按照我們菜單點了 666 道菜,可是我們作西湖醋魚的師傅請假回家結婚了,不知道皇上有沒有點這道菜,若是點了這道菜,我們作不出來,那我們店可就完了啊。算法

(袁廚聽了以後,嚇得一屁股坐地上了,緩了半天說道)數組

袁廚:別說那麼多了,快給我找找皇上點的菜裏面,有沒有這道菜!微信

找了好久,而且覈對了不少遍,最後確認皇上沒有點這道菜。菜館內的人都鬆了一口氣markdown

經過上面的一個例子,讓咱們簡單瞭解了字符串匹配。

字符串匹配:設 S 和 T 是給定的兩個串,在主串 S 中找到模式串 T 的過程稱爲字符串匹配,若是在主串 S 中找到 模式串 T ,則稱匹配成功,函數返回 T 在 S 中首次出現的位置,不然匹配不成功,返回 -1。

例:

字符串匹配

在上圖中,咱們試圖找到模式 T = baab,在主串 S = abcabaabcabac 中第一次出現的位置,即爲紅色陰影部分, T 第一次在 S 中出現的位置下標爲 4 ( 字符串的首位下標是 0 ),因此返回 4。若是模式串 T 沒有在主串 S 中出現,則返回 -1。

解決上面問題的算法咱們稱之爲字符串匹配算法,今天咱們來介紹三種字符串匹配算法,你們記得打卡呀,說不許面試的時候就問到啦。

BF算法(Brute Force)

這個算法很容易理解,就是咱們將模式串和主串進行比較,一致時則繼續比較下一字符,直到比較完整個模式串。不一致時則將模式串後移一位,從新從模式串的首位開始對比,重複剛纔的步驟下面咱們看下這個方法的動圖解析,看完確定一下就能搞懂啦。

視頻詳解

由於不能夠放置視頻,因此想看視頻的同窗,能夠去看公衆號原文,那裏有視頻

經過上面的代碼是否是一下就將這個算法搞懂啦,下面咱們用這個算法來解決下面這個經典題目吧。

leetcdoe 28. 實現 strStr()

題目描述

給定一個 haystack 字符串和一個 needle 字符串,在 haystack 字符串中找出 needle 字符串出現的第一個位置 (從0開始)。若是不存在,則返回 -1。

示例 1:

輸入: haystack = "hello", needle = "ll" 輸出: 2

示例 2:

輸入: haystack = "aaaaa", needle = "bba" 輸出: -1

題目解析

其實這個題目很容易理解,可是咱們須要注意的是一下幾點,好比咱們的模式串爲 0 時,應該返回什麼,咱們的模式串長度大於主串長度時,應該返回什麼,也是咱們須要注意的地方。下面咱們來看一下題目代碼吧。

題目代碼

class Solution {
    public int strStr(String haystack, String needle) {
        int haylen = haystack.length();
        int needlen = needle.length(); 
        //特殊狀況
        if (haylen < needlen) {
            return -1;
        }
        if (needlen == 0) {
            return 0;
        }
        //主串
        for (int i = 0; i < haylen - needlen + 1; ++i) {
            int j;
            //模式串
            for (j = 0; j < needlen; j++) {
                //不符合的狀況,直接跳出,主串指針後移一位
                if (haystack.charAt(i+j) != needle.charAt(j)) {
                    break;
                }
            }
            //匹配成功
            if (j == needlen) {
                return i;
            } 

        }
        return -1;
    }
}
複製代碼

咱們看一下BF算法的另外一種算法(顯示回退),其實原理同樣,就是對代碼進行了一下修改,只要是看完我們的動圖,這個也可以一下就能看懂,你們能夠結合下面代碼中的註釋和動圖進行理解。

class Solution {
    public int strStr(String haystack, String needle) {
        //i表明主串指針,j模式串
        int i,j;
        //主串長度和模式串長度
        int halen = haystack.length();
        int nelen = needle.length();
        //循環條件,這裏只有 i 增加
        for (i = 0 , j = 0; i < halen && j < nelen; ++i) {
            //相同時,則移動 j 指針
            if (haystack.charAt(i) == needle.charAt(j)) {
                ++j;
            } else {
                //不匹配時,將 j 從新指向模式串的頭部,將 i 本次匹配的開始位置的下一字符
                i -= j;
                j = 0;
            }
        }
        //查詢成功時返回索引,查詢失敗時返回 -1;
        int renum = j == nelen ? i - nelen : -1;
        return renum;

    }
}
複製代碼

BM算法(Boyer-Moore)

咱們剛纔說過了 BF 算法,可是 BF 算法是有缺陷的,好比咱們下面這種狀況

BF第一次

如上圖所示,若是咱們利用 BF 算法,遇到不匹配字符時,每次右移一位模式串,再從新從頭進行匹配,咱們觀察一下,咱們的模式串 abcdex 中每一個字符都不同,可是咱們第一次進行字符串匹配時,abcde 都匹配成功,到 x 時失敗,又由於模式串每位都不相同,因此咱們不須要再每次右移一位,再從新比較,咱們能夠直接跳過某些步驟。以下圖

BM2

咱們能夠跳過其中某些步驟,直接到下面這個步驟。那咱們是依據什麼原則呢?

BM3

壞字符規則

咱們以前的 BF 算法是從前日後進行比較 ,BM 算法是從後往前進行比較,咱們來看一下具體過程,咱們仍是利用上面的例子。

BM4

BM 算法是從後往前進行比較,此時咱們發現比較的第一個字符就不匹配,咱們將主串這個字符稱之爲壞字符,也就是 f ,咱們發現壞字符以後,模式串 T 中查找是否含有該字符(f),咱們發現並不存在 f,此時咱們只需將模式串右移到壞字符的後面一位便可。以下圖

BM5

那咱們在模式串中找到壞字符該怎麼辦呢?

含有壞字符

此時咱們的壞字符爲 f ,咱們在模式串中,查找發現含有壞字符 f,咱們則須要移動模式串 T ,將模式串中的 f 和壞字符對齊。見下圖。

壞字符移動

而後咱們繼續從右往左進行比較,發現 d 爲壞字符,則須要將模式串中的 d 和壞字符對齊。

換字符對其2

壞字符原則

那麼咱們在來思考一下這種狀況,那就是模式串中含有多個壞字符怎麼辦呢?

兩個壞字符

那麼咱們爲何要讓最靠右的對應元素與壞字符匹配呢?若是上面的例子咱們沒有按照這條規則看下會產生什麼問題。

壞字符匹配不按規則

若是沒有按照咱們上述規則,則會漏掉咱們的真正匹配。咱們的主串中是含有 babac 的,可是卻沒有匹配成功,因此應該遵照最靠右的對應字符與壞字符相對的規則。

咱們上面一共介紹了三種移動狀況,分別是下方的模式串中沒有發現與壞字符對應的字符,發現一個對應字符,發現兩個。這三種狀況咱們分別移動不一樣的位數,那咱們是根據依據什麼來決定移動位數的呢?下面咱們給圖中的字符加上下標。見下圖

壞字符移動規則

下面咱們來考慮一下這種狀況。

換字符bug

此時這種狀況確定是不行的,不往右移動,甚至還有可能左移,那麼咱們有沒有什麼辦法解決這個問題呢?繼續往下看吧。

好後綴規則

好後綴其實也很容易理解,咱們以前說過 BM 算法是從右往左進行比較,下面咱們來看下面這個例子。

好後綴1

這裏若是咱們按照壞字符進行移動是不合理的,這時咱們可使用好後綴規則,那麼什麼是好後綴呢?

BM 算法是從右往左進行比較,發現壞字符的時候此時 cac 已經匹配成功,在紅色陰影處發現壞字符。此時已經匹配成功的 cac 則爲咱們的好後綴,此時咱們拿它在模式串中查找,若是找到了另外一個和好後綴相匹配的串,那咱們就將另外一個和好後綴相匹配的串 ,滑到和好後綴對齊的位置。

是否是感受有點拗口,不要緊,咱們看下圖,紅色表明壞字符,綠色表明好後綴

上面那種狀況搞懂了,可是咱們思考一下下面這種狀況

比較

上面咱們說到了,若是在模式串的頭部沒有發現好後綴,發現好後綴的子串也能夠。可是爲何要強調這個頭部呢?

咱們下面來看一下這種狀況

不徹底重合

可是當咱們在頭部發現好後綴的子串時,是什麼狀況呢?

好後綴ok

下面咱們經過動圖來看一下某一例子的具體的執行過程

視頻

說到這裏,壞字符和好後綴規則就算說完了,壞字符很容易理解,咱們對好後綴總結一下

1.若是模式串含有好後綴,不管是中間仍是頭部能夠按照規則進行移動。若是好後綴在模式串中出現屢次,則以最右側的好後綴爲基準。

2.若是模式串頭部含有好後綴子串則能夠按照規則進行移動,中間部分含有好後綴子串則不能夠。

3.若是在模式串尾部就出現不匹配的狀況,即不存在好後綴時,則根據壞字符進行移動,這裏有的文章沒有提到,是個須要特別注意的地方,我是在這個論文裏找到答案的,感興趣的同窗能夠看下。

Boyer R S,Moore J S. A fast string searching algorithm[J]. Communications of the ACM,1977,10: 762-772.

以前咱們剛開始說壞字符的時候,是否是有可能會出現負值的狀況,即往左移動的狀況,因此咱們爲了解決這個問題,咱們能夠分別計算好後綴和壞字符日後滑動的位數**(好後綴不爲 0 的狀況)**,而後取兩個數中最大的,做爲模式串日後滑動的位數。

五好後綴

這破圖畫起來是真費勁啊。下面咱們來看一下算法代碼,代碼有點長,我都標上了註釋也在網站上 AC 了,若是各位感興趣能夠看一下,不感興趣理解壞字符和好後綴規則便可。能夠直接跳到 KMP 部分

class Solution {
    public int strStr(String haystack, String needle) {
        char[] hay = haystack.toCharArray();
        char[] need = needle.toCharArray();
        int haylen = haystack.length();
        int needlen = need.length;
        return bm(hay,haylen,need,needlen);
    }
    //用來求壞字符狀況下移動位數
    private static void badChar(char[] b, int m, int[] bc) {
        //初始化
        for (int i = 0; i < 256; ++i) {
            bc[i] = -1;
        }
        //m 表明模式串的長度,若是有兩個 a,則後面那個會覆蓋前面那個
        for (int i = 0; i < m; ++i) {
            int ascii = (int)b[i];
            bc[ascii] = i;//下標
        }
    }
    //用來求好後綴條件下的移動位數
    private static void goodSuffix (char[] b, int m, int[] suffix,boolean[] prefix) {
        //初始化
        for (int i = 0; i < m; ++i) {
            suffix[i] = -1;
            prefix[i] = false;
        }
        for (int i = 0; i < m - 1; ++i) {
            int j = i;
            int k = 0;
            while (j >= 0 && b[j] == b[m-1-k]) {
                --j;
                ++k;
                suffix[k] = j + 1;
            }
            if (j == -1) prefix[k] = true;
        }
    }
    public static int bm (char[] a, int n, char[] b, int m) {

        int[] bc = new int[256];//建立一個數組用來保存最右邊字符的下標
        badChar(b,m,bc);
        //用來保存各類長度好後綴的最右位置的數組
        int[] suffix_index = new int[m];
        //判斷是不是頭部,若是是頭部則true
        boolean[] ispre = new boolean[m];
        goodSuffix(b,m,suffix_index,ispre);
        int i = 0;//第一個匹配字符
        //注意結束條件
        while (i <= n-m) {
            int j;
            //從後往前匹配,匹配失敗,找到壞字符
            for (j = m - 1; j >= 0; --j) {
                if (a[i+j] != b[j]) break;
            }
            //模式串遍歷完畢,匹配成功
            if (j < 0) {
                return i;
            }
            //下面爲匹配失敗時,如何處理
            //求出壞字符規則下移動的位數,就是咱們壞字符下標減最右邊的下標
            int x = j - bc[(int)a[i+j]];
            int y = 0;
            //好後綴狀況,求出好後綴狀況下的移動位數,若是不含有好後綴的話,則按照壞字符來
            if (y < m-1 && m - 1 - j > 0) {
                y = move(j, m, suffix_index,ispre);
            }
            //移動
            i = i + Math.max(x,y);

        }
        return -1;
    }
    // j表明壞字符的下標
    private static int move (int j, int m, int[] suffix_index, boolean[] ispre) {
        //好後綴長度
        int k = m - 1 - j;
        //若是含有長度爲 k 的好後綴,返回移動位數,
        if (suffix_index[k] != -1) return j - suffix_index[k] + 1;
        //找頭部爲好後綴子串的最大長度,從長度最大的子串開始
        for (int r = j + 2; r <= m-1; ++r) {
            //若是是頭部
            if (ispre[m-r] == true) {
                return r;
            }
        }
        //若是沒有發現好後綴匹配的串,或者頭部爲好後綴子串,則移動到 m 位,也就是匹配串的長度
        return m;
    }
}
複製代碼

咱們來理解一下咱們代碼中用到的兩個數組,由於兩個規則的移動位數,只與模式串有關,與主串無關,因此咱們能夠提早求出每種狀況的移動狀況,保存到數組中。

頭綴函數

KMP算法(Knuth-Morris-Pratt)

咱們剛纔講了 BM 算法,雖然不是特別容易理解,可是若是你用心看的話確定能夠看懂的,咱們再來看一個新的算法,這個算法是考研時必考的算法。實際上 BM 和 KMP 算法的本質是同樣的,你理解了 BM 再來理解 KMP 那就是分分鐘的事啦。

咱們先來看一個實例

視頻

爲了讓讀者更容易理解,咱們將指針移動改爲了模式串移動,二者相對與主串的移動是一致的,從新比較時都是從指針位置繼續比較。

經過上面的實例是否是很快就能理解 KMP 算法的思想了,可是 KMP 的難點不是在這裏,不過多思考,認真看理解起來也是很輕鬆的。

在上面的例子中咱們提到了一個名詞,最長公共先後綴,這個是什麼意思呢?下面咱們經過一個較簡單的例子進行描述。

KMP例子

此時咱們在紅色陰影處匹配失敗,綠色爲匹配成功部分,則咱們觀察匹配成功的部分。

咱們來看一下匹配成功部分的全部前綴

公共先後綴

咱們的最長公共先後綴以下圖,則咱們須要這樣移動

原理

好啦,看完上面的圖,KMP的核心原理已經基本搞定了,可是咱們如今的問題是,咱們應該怎麼才能知道他的最長公共先後綴的長度是多少呢?怎麼知道移動多少位呢?

剛纔咱們在 BM 中說到,咱們移動位數跟主串無關,只跟模式串有關,跟咱們的 bc,suffix,prefix 數組的值有關,咱們經過這些數組就能夠知道咱們每次移動多少位啦,其實 KMP 也有一個數組,這個數組叫作 next 數組,那麼這個 next 數組存的是什麼呢?

next 數組存的我們最長公共先後綴中,前綴的結尾字符下標。是否是感受有點彆扭,咱們經過一個例子進行說明。

next數組

咱們知道 next 數組以後,咱們的 KMP 算法實現起來就很容易啦,另外咱們看一下 next 數組究竟是幹什麼用的。

KMP1

kmp2

剩下的就不用說啦,徹底一致啦,我們將上面這個例子,翻譯成和我們開頭對應的動畫你們看一下。

由於不能夠放置視頻,因此想看視頻的同窗,能夠去看公衆號原文,那裏有視頻

下面咱們看一下代碼,標有詳細註釋,你們認真看呀。

注:不少教科書的 next 數組表示方式不一致,理解便可

class Solution {
    public int strStr(String haystack, String needle) {
        //兩種特殊狀況
        if (needle.length() == 0) {
            return 0;
        }
        if (haystack.length() == 0) {
            return -1;
        }
        // char 數組
        char[] hasyarr = haystack.toCharArray();
        char[] nearr = needle.toCharArray();
        //長度
        int halen = hasyarr.length;
        int nelen = nearr.length;
        //返回下標
        return kmp(hasyarr,halen,nearr,nelen);

    }
    public int kmp (char[] hasyarr, int halen, char[] nearr, int nelen) {
        //獲取next 數組
        int[] next = next(nearr,nelen);
        int j = 0;
        for (int i = 0; i < halen; ++i) {
            //發現不匹配的字符,而後根據 next 數組移動指針,移動到最大公共先後綴的,
            //前綴的後一位,和我們移動模式串的含義相同
            while (j > 0 && hasyarr[i] != nearr[j]) {
                j = next[j - 1] + 1;
                //超出長度時,能夠直接返回不存在
                if (nelen - j + i > halen) {
                    return -1;
                }
            }
            //若是相同就將指針同時後移一下,比較下個字符
            if (hasyarr[i] == nearr[j]) {
                ++j;
            }
            //遍歷完整個模式串,返回模式串的起點下標
            if (j == nelen) {
                return i - nelen + 1;
            }
        }
        return -1;
    }
    //這一塊比較難懂,不想看的同窗能夠忽略,瞭解大體含義便可,或者本身調試一下,看看運行狀況
    //我會每一步都寫上註釋
    public  int[] next (char[] needle,int len) {
        //定義 next 數組
        int[] next = new int[len];
        // 初始化
        next[0] = -1;
        int k = -1;
        for (int i = 1; i < len; ++i) {
            //咱們此時知道了 [0,i-1]的最長先後綴,可是k+1的指向的值和i不相同時,咱們則須要回溯
            //由於 next[k]就時用來記錄子串的最長公共先後綴的尾座標(即長度)
            //就要找 k+1前一個元素在next數組裏的值,即next[k+1]
            while (k != -1 && needle[k + 1] != needle[i]) {
                k = next[k];
            }
            // 相同狀況,就是 k的下一位,和 i 相同時,此時咱們已經知道 [0,i-1]的最長先後綴
            //而後 k - 1 又和 i 相同,最長先後綴加1,便可
            if (needle[k+1] == needle[i]) {
                ++k;
            }
            next[i] = k;

        }
        return next;
    }
}
複製代碼

做者:袁廚 原文公衆號:袁廚的算法小屋 我是袁廚,一個酷愛作飯的程序員,一個愛用動圖解算法的年輕人,新人求支持呀。 這篇文章真的寫了好久好久,以爲還不錯的話,就麻煩您點個贊吧,你們也能夠去個人公衆號看個人全部文章,每一個都有動圖解析,公衆號:袁廚的算法小屋

相關文章
相關標籤/搜索