1. 程式人生 > 其它 >資料結構與演算法(十六)

資料結構與演算法(十六)

KMP演算法

應用場景-字串匹配問題

str1 = "矽矽谷 尚矽谷你尚矽 尚矽谷你尚矽谷你尚矽你好"
str2 = "尚矽谷你尚矽你"

求:str2 在 str1 中是否存在,如果存在,返回第一次出現的位置,如果沒有則返回 -1

暴力匹配

假設 str1 匹配到 i 位置,子串 str2 匹配到 j 位置,則:

  1. 如果當前字元匹配成功(str1[i] == str2[j]

    i++j++ 繼續匹配下一個字元

  2. 如果失敗(str1[i] != str2[j])則:

    i = i-(j-1)
    j = 0
    

    相當於每次匹配失敗時, i 回溯,j 被重置為 0

暴力方法解決會有 大量的回溯

,每次只移動一位,若是不匹配,移動到下一位接著判斷匹配,浪費了大量的時間。

程式碼實現

public class ViolenceMatch {
    public static void main(String[] args) {
        String s1 = "矽矽谷 尚矽谷你尚矽 尚矽谷你尚矽谷你尚矽你好";
        String s2 = "尚矽谷你尚矽你";
        int index = violenceMatch(s1,s2);
        System.out.println("index:" + index);
    }
    //str1要查詢匹配的源字串,str2要匹配的字串
    public static int violenceMatch(String str1,String str2){
        //先將字串轉換成陣列
        char[] s1 = str1.toCharArray();
        char[] s2 = str2.toCharArray();

        //求兩個陣列的長度
        int s1Len = s1.length;
        int s2Len = s2.length;

        //定義兩個索引變數
        int i = 0;//用於遍歷s1
        int j = 0;//用於遍歷s2
        while(i < s1Len && j < s2Len){
            if(s1[i] == s2[j]){//匹配成功,繼續匹配下一個
                i++;
                j++;
            }else{
                //匹配不成功,回溯
                i = i - (j-1);
                j = 0;
            }
        }
        if(j == s2Len){
            return i - j;
        }
        return -1;
    }
}

KMP匹配

KMP 是一個解決 模式串在文字串中是否出現過,如果出現過,則最早出現的位置的經典演算法。

Knuth-Morris-Pratt 字串查詢演算法,簡稱 KMP 演算法:常用與在一個文字字串 s 內查詢一個模式串 P 的出現位置

該演算法由 Donald Knuth、Vaughan Pratt、James H. Morris 三人於 1977 年聯合發表,故取這 3 人的姓氏命名此演算法.

KMP 方法利用 之前判斷過的資訊,通過一個 next 陣列,儲存模式串中前後最長公共子序列的長度,每次回溯時,通過 next 陣列找到前面匹配過的位置,省去了大量的計算時間。

KMP 思路分析

Str1 = "BBC ABCDAB ABCDABCDABDE"
Str2 = "ABCDABD"
  1. 都用第 1 個字元進行比較,不符合,關鍵詞(文字串)向後移動一位

  2. 重複第一步,還是不符合,再後移動

  3. 一直重複,直到 str1 有一個字元與 str2 的第一個字元匹配為止

  4. 接著比較字串和搜尋詞的下一個字元,還是符合

  5. 遇到 st1 有一個字元與 str2 對應的字元不符合時

  6. 這時候:想到的是繼續遍歷 st1 的下一個字元(也就是暴力匹配)

這時,就出現一個問題:

此時回溯時,A 還會去和 BCD 進行比較,而在上一步 ABCDAB 與 ABCDABD,前 6 個都相等,其中 BCD 搜尋詞的第一個字元 A 不相等,那麼這個時候還要用 A 去匹配 BCD,這肯定會匹配失敗。

KMP 演算法的想法是:設法利用這個已知資訊,不要把「搜尋位置」移回已經比較過的位置,繼續把它向後移,這樣就提高了效率。

那麼新的問題就來了:你如何知道 A 與 BCD 不相同,並且只有 BCD 不用比較呢?這個就是 KMP 的核心原理了。

  1. KMP 利用 部分匹配表,來省略掉剛剛重複的步驟。

上表是這樣看的:

  1. ABCD 匹配值 0
  2. ABCDA 匹配值 1
  3. ABCDAB 匹配值 2

至於如何產生的這個部分匹配表,下面專門講解,這裡你要知道的是,KMP 利用這個 部分匹配表 可以省略掉重複的步驟

  1. 已知空格與 D 不匹配時,前面 6 個字元 ABCDAB 是匹配的。

查表可知:部分匹配值是 2,因此按照下面的公司計算出後移的位數:移動位數 = 已匹配的字元數 - 對應的部分匹配值

  1. 逐位比較,直到搜尋詞(文字串)的最後一位,發現完全匹配,搜尋完成。

部分匹配表是如何產生的?

看上上述步驟,你現在的疑惑是:這個部分匹配表是如何產生的?下面就來介紹

需要先知道 **字首 ** 和 字尾 是什麼

  • 字首:仔細看,它的字首就是每個字串的組合,逐漸變長,但是不包括最後一個字元

    如果 bread 是字串 bread 的字首,這個不是完全匹配了嗎?

  • 字尾:同理,不包含第一個

部分匹配值 就是 字首字尾最長的共有元素的長度,下面以 ABCDABD 來解說:

字串 字首 字尾 共有元素 共有元素長度
A - - - 0
AB A B - 0
ABC A、AB BC、C - 0
ABCD A、AB、ABC BCD、CD、D - 0
ABCDA A、AB、ABC、ABCD BCDA、CDA、DA、A A 1
ABCDAB A、AB、ABC、ABCD、ABCDA BCDAB、CDAB、DAB、AB、B AB 2
ABCDABD A、AB、ABC、ABCD、ABCDA、ABCDAB BCDABD、CDABD、DABD、ABD、BD、D - 0

部分匹配 的實質是:有時候,字串頭部和尾部會有重複。

比如:ABCDAB 中有兩個 AB ,那麼它的 部分匹配值 就是 2 (AB 的長度),搜尋詞(文字串)移動的時候,第一個移動 4 位(字串長度 - 部分匹配值),就可以來到第二個 AB 的位置,從而跳過了已經匹配過的 BCD。

如果還是想刨根問底,可以去參考下這篇文章:寫得很詳細](https://www.cnblogs.com/zzuuoo666/p/9028287.html),應該需要一些數學知識才能看懂。

KMP 程式碼實現

/**
     * KMP搜尋演算法
     * @param str1 源字串
     * @param str2 匹配字串
     * @param next 部分匹配表
     * @return 找到就返回首字母下標,沒有找到返回-1
     */
    public static int KMPSearch(String str1,String str2,int[] next){
        for(int i = 0,j = 0; i < str1.length();i++){
            //如果不相等就回退
            while(j > 0 && str1.charAt(i) != str2.charAt(j)){
                j = next[j-1];
            }
            if(str1.charAt(i) == str2.charAt(j)){
                j++;
            }

            if(j == str2.length()){
                //全部匹配結束
                return i - j + 1;
            }
        }
        return -1;//沒有匹配到
    }
    //求KMP演算法部分匹配表
    public static int[] kmpNext(String str1){
        int[] next = new int[str1.length()];
        next[0] = 0;//第一個元素的匹配值一定是0
        for(int i = 1,j = 0; i < str1.length();i++){
            while(j > 0 && str1.charAt(i) != str1.charAt(j)){
                j = next[j-1];
            }
            if(str1.charAt(i) == str1.charAt(j)){
                j++;
            }
            next[i] = j;
        }
        return next;
    }

貪心演算法

應用場景-集合覆蓋問題

貪心演算法可以解決很多場景的問題,這裡以集合覆蓋問題為例。

假設存在下面需要付費的廣播臺,以及廣播臺訊號可以覆蓋的地區。如何選擇最少的廣播臺,讓所有的地區都可以接收到訊號?

廣播臺 覆蓋地區
K1 "北京", "上海", "天津"
K2 "廣州", "北京", "深圳"
K3 "成都", "上海", "杭州"
K4 "上海", "天津"
K5 "杭州", "大連"

例如:k4 中有上海、天津,那麼我們選擇 k1,裡面包含了他們,還多了一個地區。

貪心演算法介紹

**貪婪演算法(貪心演算法) **是指在對問題進行求解時,在 每一步選擇中都採取最好或者最優(即最有利)的選擇,從而希望能夠導致結果是最好或者最優的演算法

貪婪演算法所得到的 結果不一定是最優的結果(有時候會是最優解),但是都是相對近似(接近)最優解的結果

思路分析

如何找出覆蓋所有地區的廣播臺的集合呢,最容易想到的是使用窮舉法實現,列出每個可能的廣播臺的集合,這被稱為 冪集。假設總的有 n 個廣播臺,則廣播臺的組合總共有 2ⁿ -1 個,假設每秒可以計算 10 個子集, 如圖:

廣播臺數量n 子集總數2ⁿ 需要的時間
5 32 3.2秒
10 1024 102.4秒
32 4294967296 13.6年
100 1.26*100³º 4x10²³年

由此可見:在進行組合的場景下,使用組合效率是很低的。

那麼貪心演算法的思路如下:

廣播臺 覆蓋地區
K1 "北京", "上海", "天津"
K2 "廣州", "北京", "深圳"
K3 "成都", "上海", "杭州"
K4 "上海", "天津"
K5 "杭州", "大連"

目前並沒有演算法可以快速計算得到準備的值, 使用貪婪演算法,則可以得到非常接近的解,並且效率高。選擇策略上,因為需要覆蓋全部地區的最小集合,思路如下:

  1. 將所有需要覆蓋的地區找出來(allAreas)也就是所有電臺中的覆蓋地區去重後的列表

  2. 遍歷所有的廣播電臺,找到一個 覆蓋了最多未覆蓋的地區 的電臺,此電臺可能包含一些已覆蓋的地區,但是沒有關係。

    比如:k1 中有三個地區,在上面找出來的列表中去判定是否覆蓋其中的地區,找到則 k1 為 覆蓋了最多未覆蓋的地區 的電臺。

  3. 將這個電臺加入到一個集合中(如 ArrayList),並想辦法把該電臺覆蓋的地區在下次比較時去掉。

    比如:前面 k1 為 覆蓋了最多未覆蓋的地區,把 k1 加到該集合中,並從把 k1 已經覆蓋過的地區從 allAreas 中移除

  4. 重複第 2 步,直到覆蓋了全部的地區

圖解

給定的廣播電臺如下

廣播臺 覆蓋地區
K1 "北京", "上海", "天津"
K2 "廣州", "北京", "深圳"
K3 "成都", "上海", "杭州"
K4 "上海", "天津"
K5 "杭州", "大連"
  1. 找出所有需要覆蓋的地區

    allAreas = {"北京", "上海", "天津", "廣州", "深圳", "成都", "杭州", "大連"}
    
  2. 遍歷廣播電臺列表:找出一個覆蓋了最多地區的電臺,重點:如何確定覆蓋了最多的電臺?

    可以這樣做:遍歷廣播臺,計算每個電臺中覆蓋的地區在未覆蓋地區列表中,覆蓋了幾個?

    廣播臺 覆蓋地區 覆蓋數量(未覆蓋地區的數量)
    K1 "北京", "上海", "天津" 3
    K2 "廣州", "北京", "深圳" 3
    K3 "成都", "上海", "杭州" 3
    K4 "上海", "天津" 2
    K5 "杭州", "大連" 2

    上圖覆蓋數量計算,例如:k1 覆蓋地區有三個,這三個地區現在都在 未覆蓋地區(allAreas),所以:k1 的覆蓋數量則是 3

  3. 找到覆蓋數量最大的電臺(每一步的選擇都選擇最優)

    上第 2 步驟中,計算出的覆蓋數量,k1 為最大的(k2 也是 3,但是不大於 k1 的覆蓋數量),計為 maxKey,將它新增到 選擇列表中,表示該電臺已被選擇,同時將 k1 中覆蓋地區,從 allAreas 列表中去掉,那麼現在的情況就如下:

    // 已選電臺
    selects =  {"k1"}
    // 未覆蓋地區
    allAreas = {廣州", "深圳", "成都", "杭州", "大連"}
    
  4. 重新計算未被選擇的電臺的覆蓋數量

    // 已選擇電臺
    selects =  {"k1"}
    // 所有暫時還未覆蓋的地區列表
    allAreas = {廣州", "深圳", "成都", "杭州", "大連"}
    
    廣播臺 覆蓋地區 覆蓋數量(未覆蓋地區的數量)
    K1 "北京", "上海", "天津" 0
    K2 "廣州", "北京", "深圳" 2
    K3 "成都", "上海", "杭州" 2
    K4 "上海", "天津" 0
    K5 "杭州", "大連" 2

    注意:因為 k1,已經被選擇過,可以不重新對它計數,也可以重新計數,對效能影響不太大。

    上圖覆蓋數量計算,例如:

    • k1 覆蓋地區有三個,這三個地區現在在 未覆蓋地區(allAreas)中一個都沒有,所以:k1 的覆蓋數量則是 0
    • k2 覆蓋的確有三個,這三個地區現在在 未覆蓋地區(allAreas)中有 2 個:廣州、深圳,而北京已經被覆蓋掉了(k1),所以:k2 的覆蓋數量則是 2
  5. 找到覆蓋數量最大的電臺,重複上面的過程,直到allAreas為空為止。

程式碼實現

//貪心演算法解決集合覆蓋問題
public class GreedyAlgorithm {
    public static void main(String[] args) {
        //使用HashMap定義所有的廣播
        HashMap<String, HashSet<String>> broadcasts = new HashMap<>();
        HashSet<String> hashSet1 = new HashSet<>();
        hashSet1.add("北京");
        hashSet1.add("上海");
        hashSet1.add("天津");

        HashSet<String> hashSet2 = new HashSet<>();
        hashSet2.add("廣州");
        hashSet2.add("北京");
        hashSet2.add("深圳");

        HashSet<String> hashSet3 = new HashSet<>();
        hashSet3.add("成都");
        hashSet3.add("上海");
        hashSet3.add("杭州");

        HashSet<String> hashSet4 = new HashSet<>();
        hashSet4.add("上海");
        hashSet4.add("天津");

        HashSet<String> hashSet5 = new HashSet<>();
        hashSet5.add("杭州");
        hashSet5.add("大連");

        broadcasts.put("K1",hashSet1);
        broadcasts.put("K2",hashSet2);
        broadcasts.put("K3",hashSet3);
        broadcasts.put("K4",hashSet4);
        broadcasts.put("K5",hashSet5);

        //儲存所有地區
        HashSet<String> allAreas = new HashSet<>();
        allAreas.add("北京");
        allAreas.add("上海");
        allAreas.add("天津");
        allAreas.add("廣州");
        allAreas.add("深圳");
        allAreas.add("成都");
        allAreas.add("杭州");
        allAreas.add("大連");
        //用於儲存選擇的電臺
        ArrayList<String> selects = new ArrayList<>();

        HashSet<String> tempSet = new HashSet<>();//臨時變數
        String keyMax = null;//用於儲存包含最多未包含的地區的key
        while(allAreas.size() > 0){
            keyMax = null;//重置keyMax
            for(String key : broadcasts.keySet()){
                tempSet.clear();//清空已經包含的元素
                HashSet<String> areas = broadcasts.get(key);//獲取當前廣播可以播放的所有地區
                tempSet.addAll(areas);
                tempSet.retainAll(allAreas);//取得可以覆蓋多少未覆蓋的地區,取交集
                if(tempSet.size() > 0 && (keyMax == null || tempSet.size() > broadcasts.get(keyMax).size())){
                    keyMax = key;
                }
            }

            if(keyMax != null){
                selects.add(keyMax);//將電臺加入選擇集合中
                allAreas.removeAll(broadcasts.get(keyMax));//移出所有包含的元素
            }

        }

        System.out.println(selects);

    }
}

貪婪演算法注意事項

貪婪演算法所得到的結果 不一定是最優的結果(有時候會是最優解),但是都是相對近似(接近)最優解的結果

比如上題的演算法選出的是 K1, K2, K3, K5,符合覆蓋了全部的地區,但是我們發現 K2, K3,K4,K5 也可以覆蓋全部地區,如果 K2 的使用成本低於 K1 ,那麼我們上題的 K1, K2, K3, K5 雖然是滿足條件,但是並不是最優的.

但是筆者覺得上述舉例並不是問題:如果加上成本:那麼只要在 maxKey 覆蓋數量相等的情況下,判定採用成本更低的 key,則可解決這個問題。