數據結構與算法|第十三章:字符串匹配

數據結構與算法|第十三章:字符串匹配

項目環境

1.字符串是什麼?

本小節相關字符串定義內容取自於《重學數據結構與算法》- 公瑾

1.1 定義

字符串(string) 是由 n 個字符組成的一個有序整體( n >= 0 )。

例如,s = “BEIJING” ,s 代表這個串的串名,BEIJING 是串的值。字符串的邏輯結構和線性表很相似,不同之處在於字符串針對的是字符集,也就是字符串中的元素都是字符,線性表則沒有這些限制。

在實際操作中,我們經常會用到一些特殊的字符串:

  • 空串,指含有零個字符的串。例如,s = “”,書面中也可以直接用 Ø 表示。

  • 空格串,只包含空格的串。它和空串是不一樣的,空格串中是有內容的,只不過包含的是空格,且空格串中可以包含多個空格。例如,s = " ",就是包含了 3 個空格的字符串。

  • 子串,串中任意連續字符組成的字符串叫作該串的子串。

  • 原串通常也稱爲主串。例如:a = “BEI”,b = “BEIJING”,c = “BJINGEI” 。

    • 對於字符串 a 和 b 來說,由於 b 中含有字符串 a ,所以可以稱 a 是 b 的子串,b 是 a 的主串;
    • 而對於 c 和 a 而言,雖然 c 中也含有 a 的全部字符,但不是連續的 “BEI” ,所以串 c 和 a 沒有任何關係。

1.2 字符串相等

當要判斷兩個串是否相等的時候,就需要定義相等的標準了。只有兩個串的串值完全相同,這兩個串才相等。根據這個定義可見,即使兩個字符串包含的字符完全一致,它們也不一定是相等的。例如 b = “BEIJING”,c = “BJINGEI”,則 b 和 c 並不相等。

1.3 字符串的存儲結構

字符串的存儲結構與線性表相同,也有順序存儲和鏈式存儲兩種。

  • 字符串的順序存儲結構,是用一組地址連續的存儲單元來存儲串中的字符序列,一般是用定長數組來實現。有些語言會在串值後面加一個不計入串長度的結束標記符,比如 \0 來表示串值的終結。

    • Java 中 String 的實現使用的就是數組
      • private final char value[];
  • 字符串的鏈式存儲結構,與線性表是相似的,但由於串結構的特殊性(結構中的每個元素數據都是一個字符),如果也簡單地將每個鏈結點存儲爲一個字符,就會造成很大的空間浪費。
    [外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-EPlO7StO-1592882000652)(G:\workspace\csdn\learn-document\data-structure-algorithm\image-20200618104514516.png)]

  • 一個結點可以考慮存放多個字符,如果最後一個結點未被佔滿時,可以使用 “#” 或其他非串值字符補全,如下圖所示:
    [外鏈圖片轉存失敗,源站可能有防盜鏈機制,建議將圖片保存下來直接上傳(img-iKIXhIPO-1592882000654)(G:\workspace\csdn\learn-document\data-structure-algorithm\image-20200618104538965.png)]
    在鏈式存儲中,每個結點設置字符數量的多少,與串的長度、可以佔用的存儲空間以及程序實現的功能相關。

  • 如果字符串中包含的數據量很大,但是可用的存儲空間有限,那麼就需要提高空間利用率,相應地減少結點數量。

  • 而如果程序中需要大量地插入或者刪除數據,如果每個節點包含的字符過多,操作字符就會變得很麻煩,爲實現功能增加了障礙。

因此,串的鏈式存儲結構除了在連接串與串操作時有一定的方便之外,總的來說,不如順序存儲靈活,在性能方面也不如順序存儲結構好。

2.字符串的基本操作

字符串和線性表的操作很相似,但由於字符串針對的是字符集,所有元素都是字符,因此字符串的基本操作與線性表有很大差別。線性表更關注的是單個元素的操作,比如增刪查一個元素,而字符串中更多關注的是查找子串的位置、替換等操作。接下來我們以順序存儲爲例,詳細介紹一下字符串對於另一個字符串的增刪查操作。

2.1 新增操作

字符串的新增操作和數組非常相似,都牽涉對插入字符串之後字符的挪移操作,所以時間複雜度是 O(n)。

例如,在字符串 s1 = “123456” 的正中間插入 s2 = “abc”,則需要讓 s1 中的 “456” 向後挪移 3 個字符的位置,再讓 s2 的 “abc” 插入進來。很顯然,挪移的操作時間複雜度是 O(n)。不過,對於特殊的插入操作時間複雜度也可以降低爲 O(1)。這就是在 s1 的最後插入 s2,也叫作字符串的連接,最終得到 “123456abc”。

2.2 刪除操作

字符串的刪除操作和數組同樣非常相似,也可能會牽涉刪除字符串後字符的挪移操作,所以時間複雜度是 O(n)。

例如,在字符串 s1 = “123456” 的正中間刪除兩個字符 “34”,則需要刪除 “34” 並讓 s1 中的 “56” 向前挪移 2 個字符的位置。很顯然,挪移的操作時間複雜度是 O(n)。不過,對於特殊的插入操作時間複雜度也可以降低爲 O(1)。這就是在 s1 的最後刪除若干個字符,不牽涉任何字符的挪移。

2.3 查找操作

字符串的查找操作,是反映工程師對字符串理解深度的高頻考點,這裏需要你格外注意。

例如,字符串 s = “goodgoogle”,判斷字符串 t = “google” 在 s 中是否存在。需要注意的是,如果字符串 t 的每個字符都在 s 中出現過,這並不能證明字符串 t 在 s 中出現了。當 t = “dog” 時,那麼字符 “d”、“o”、“g” 都在 s 中出現過,但他們並不連在一起。

3. 子串查找(字符串匹配)

首先,我們來定義兩個概念,主串和模式串。我們在字符串 A 中查找字符串 B,則 A 就是主串,B 就是模式串。我們把主串的長度記爲 n,模式串長度記爲 m。由於是在主串中查找模式串,因此,主串的長度肯定比模式串長,n>m。因此,字符串匹配算法的時間複雜度就是 n 和 m 的函數。

3.1 BF算法

BF算法中的 BF 是 Brute Force 的縮寫,中文叫作暴力匹配算法,也叫樸素匹配算法。從名字可以看出,這種算法的字符串匹配方式很“暴力”,當然也就會比較簡單、好懂,但相應的性能也不高。

作爲最簡單、最暴力的字符串匹配算法,BF 算法的思想可以用一句話來概括,那就是,我們在主串中,檢查起始位置分別是012…n-m且長度爲mn-m+1個子串,看有沒有跟模式串匹配的。
在這裏插入圖片描述
從上面的算法思想和例子,我們可以看出,在極端情況下,比如主串是“aaaaa…aaaaaa”(省略號表示有很多重複的字符a),模式串是“aaaaab”。我們每次都比

對 m 個字符,要比對 n-m+1 次,所以,這種算法的最壞情況時間複雜度是 O(nm)O(n*m)

儘管理論上,BF算法的時間複雜度很高,是 O(nm)O(n*m),但在實際的開發中,它卻是一個比較常用的字符串匹配算法。爲什麼這麼說呢?原因有兩點。

  • 第一,實際的軟件開發中,大部分情況下,模式串和主串的長度都不會太長。而且每次模式串與主串中的子串匹配的時候,當中途遇到不能匹配的字符的時候,就可以就停止了,不需要把 m 個字符都比對一下。所以,儘管理論上的最壞情況時間複雜度是 O(nm)O(n*m),但是,統計意義上,大部分情況下,算法執行效率要比這個高很多。

  • 第二,樸素字符串匹配算法思想簡單,代碼實現也非常簡單。簡單意味着不容易出錯,如果有 bug 也容易暴露和修復。在工程中,在滿足性能要求的前提下,簡單 是首選。這也是我們常說的 KISS(Keep it Simple and Stupid)設計原則。

所以,在實際的軟件開發中,絕大部分情況下,樸素的字符串匹配算法就夠用了。

3.2 實現代碼

    /**
     * BF算法
     *
     * @param strA 主串
     * @param strB 模式串
     * @return 模式串B所在的位置
     */
    public static int strMatchForBF(String strA, String strB) {
        char[] charsA = strA.toCharArray();
        char[] charsB = strB.toCharArray();
        int lengthA = charsA.length;
        int lengthB = charsB.length;
        for (int i = 0; i <= lengthA - lengthB; i++) {
            int k = 0;// 用來記錄對比結果
            if (charsA[i] == charsB[0]) {// 如果第一位相等
                for (int j = 1; j < lengthB; j++) {
                    if (charsA[i + j] == charsB[j]) {// 後續的字符是否相等
                        k++;
                    } else {
                        break;
                    }
                }
                if (k == lengthB - 1) {
                    return i;
                }
            }
        }
        return -1;
    }

測試

    public static void main(String[] args) {
        String strA = "baddef";
        String strB = "abc";
        String strC = "ad";
        int index = strMatchForBF(strA, strB);
        int index1 = strMatchForBF(strA, strC);
        System.out.printf("主串:[%s],模式串:[%s],匹配位置:[%d]\n", strA, strB, index);
        System.out.printf("主串:[%s],模式串:[%s],匹配位置:[%d]\n", strA, strC, index1);
    }

執行結果:

主串:[baddef],模式串:[abc],匹配位置:[-1]
主串:[baddef],模式串:[ad],匹配位置:[1]

4.字符串匹配算法題

4.1 查找出兩個字符串的最大公共字串

假設有且僅有 1 個最大公共子串。比如,輸入 a = “badfeifgh”, b = “cadfe”。由於字符串 “adfe” 同時在 a 和 b 中出現,且是同時出現在 a 和 b 中的最長子串。因此輸出 "adfe”。

解題思路

  • 遍歷 a 和 b,如果元素相等,繼續對比後續的元素是否相等
  • 需要注意的是對比後續元素,數據腳標越界問題

代碼如下:

public class LongestSameSubStringSolution {
    public static void main(String[] args) {
        String a = "badfeifgh";
        String b = "cadfe";
        System.out.println(getLongestSameSubString(a, b));
    }

    public static String getLongestSameSubString(String a, String b) {
        Integer aLength = a.length();
        Integer bLength = b.length();
        String res = "";
        for (int i = 0; i < aLength; i++) {
            for (int j = 0; j < bLength; j++) {
                if (a.charAt(i) == b.charAt(j)) {// 如果相等
                    int startIndex = j;
                    for (int k = 0; k < bLength; k++) {// 繼續遍歷 a 和 b 後面的元素
                        if (i + k < aLength && j + k < bLength && a.charAt(i + k) == b.charAt(j + k)) {
                            String str = b.substring(startIndex, j + k + 1);
                            if (str.length() > res.length()) {
                                res = str;
                            }
                        }
                    }
                }
            }
        }
        return res;
    }
}

執行結果:

adfe

時間複雜度:

假設字符串 a 的長度爲 n,字符串 b 的長度爲 m,可見時間複雜度是 n 和 m 的函數。從代碼結構來看,第一步需要兩層的循環去查找共同出現的字符,這就是 O(nm)O(n*m)。一旦找到了共同出現的字符之後,還需要再繼續查找共同出現的字符串,這也就是又嵌套了一層循環。可見最終的時間複雜度是 O(nmm)O(n*m*m),即 O(nm2)O(n*m^2)

4.2 反轉字符串

編寫一個函數,其作用是將輸入的字符串反轉過來。輸入字符串以字符數組 char[] 的形式給出。

不要給另外的數組分配額外的空間,你必須原地修改輸入數組、使用 O(1) 的額外空間解決這一問題。

你可以假設數組中的所有字符都是 ASCII 碼錶中的可打印字符。

示例 1:

輸入:[“h”,“e”,“l”,“l”,“o”]
輸出:[“o”,“l”,“l”,“e”,“h”]
示例 2:

輸入:[“H”,“a”,“n”,“n”,“a”,“h”]
輸出:[“h”,“a”,“n”,“n”,“a”,“H”]

來源:力扣(LeetCode)
鏈接:https://leetcode-cn.com/problems/reverse-string

題解

使用前面章節講到的遞歸進行實現

public class ReverseStringSolution {

    public static void main(String[] args) {
        char[] a = "hello".toCharArray();
        System.out.println("原字符串:" + Arrays.toString(a));
        reverseString(a);
        System.out.println("反轉後:" + Arrays.toString(a));
    }

    public static void reverseString(char[] s) {
        reverseStr(s, 0, s.length - 1);
    }

    private static void reverseStr(char[] s, int left, int right) {
        if (left <= right) {
            char tmp = s[right];
            s[right--] = s[left];
            s[left++] = tmp;
            reverseStr(s, left, right);
        }
    }

}

執行結果:

原字符串:[h, e, l, l, o]
反轉後:[o, l, l, e, h]

5.小結

字符串的邏輯結構和線性表極爲相似,區別僅在於串的數據對象約束爲字符集。但是,字符串的基本操作和線性表有很大差別:

  • 在線性表的基本操作中,大多以“單個元素”作爲操作對象

  • 在字符串的基本操作中,通常以“串的整體”作爲操作對象

  • 字符串的增刪操作和數組很像,複雜度也與之一樣。但字符串的查找操作就複雜多了

6.參考

  • 《重學數據結構與算法》- 公瑾
  • 《數據結構與算法之美》- 王爭
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章