1. 程式人生 > >字符串相關(排序, 單詞查找樹, 子字符串查找)

字符串相關(排序, 單詞查找樹, 子字符串查找)

建議 所有 判斷 indexof() 所見 做的 中間 個人 private

字符串相關(排序, 數據結構)

前言

在算法的第五章, 是與字符串相關的各種處理操作, 在平時的處理中, 其實發現所有的語言, 都離不開字符串, 甚至於數值等等的相關操作也可以被轉換成字符串有關操作, 所有的數據, 在對應語言的處理中, 都是字符串.

應用範圍如此之廣, 但在 Java中並未為字符串做相應的特殊處理(在這裏僅指排序, 數據結構這兩方面.)

排序

鍵索引排序

在字符串的排序之前, 先來看另一種很有趣也非常有用的排序方式.

在常規的數據背景下, 使用希爾排序, 歸並排序, 插入排序, 快速排序, 三向快速排序這幾種已經能滿足我們的正常需求.

但是不得不註意到的另一個問題是: CompareTo()方法的實現, 在以往的排序中, 總是需要進行比較, 然後排序. 排序在比較之後.

對待越復雜的數據結構, 比較所需的代價越高. 考慮這樣一種常見的場景, 將全校學生按照班級排序.

稍稍分析會發現: key 是班級號, value是學生. 班級號只有R個, 數量有限, 且值不會太大, 學生的數量遠遠大於key. 為 Student類實現對應的 Comparator借口嗎? 然後采用 三向快速排序的方式, 為這種重復量較多的數據進行排序.

這是一種方式, 但在這裏有另一種更有趣的排序方法, 有趣在這種方法無需進行比較, 即可排序.

不比較? 怎麽排序? 怎麽知道誰大誰小.

在這裏, 其實是利用了默認的比較方式, 1-10本身就是從小到大的數據.一步步來看究竟是怎麽實現的.

int[] count = new int[R + 1];
for (int i = 0; i < students.length; i++) {
    count[students[i].getClassNumber() + 1]++;
}

這一步是統計所有數據的班級 頻次. 將出現的次數存入數組中. 這樣做的意義在於:

我們可以直觀的知道, 1班有多少個人, 2班有多少個人..., 如果取到 students[1], 發現班級是2, 假設1班20人, 2班 22人, 就可以直接將 students[1] 放在 students[20]的位置, 且保證不會超員.

可以看出來, 我們獲取了對應的元素所需要存入的索引, 按照不同的 classNumber存入對應的索引即可. 即:

將number為0的頻次存入count[0], number為1 存入count[1].

那麽下一步就是將獲取到的頻次轉換為對應的索引.

for (int i = 0; i < R; i++) {
    count[i + 1] += count[i];
}

由於保存的都是對應classNumber的起始索引, 因此 count[0]為0.

下一步將數據存入即可.但需要一個輔助數組.

Student[] aux = new Student[students.length];
for (int i = 0, length = students.length; i < length; i++) {
    aux[count[students[i].getClassNumber()]++] = students[i];
}

在每存入一個數據後, 都需要將索引向前推動一位, count[] 數組中的索引始終指向即將存入的下一個位置.

最後回寫即可.

System.arrayCopy(aux, 0, students, 0, students.length);

擴展

鍵索引排序的核心就是將鍵本身與count的索引相關聯, 在上面的例子中, 由於ClassNumber是天然的索引, 因此無需相關聯即可使用.

但我們在平時的使用中遇到的絕大多數情況都不是這樣的, 可能需要按照字符歸類, 字符串歸類這樣的特殊情況, 又該怎麽處理呢?

對於字符, 在Java中還是有比較簡單的處理方式的, 存在char, 會自動按照 ASCII碼或是 Unicode碼將之轉換為對應的數字.將char直接使用做下標也是可以的.

那更復雜的字符串呢?比如中國的省不按照編碼, 而是直接按照名稱進行分組.這時候需要做些特殊處理即可.

我們知道, 字符之所以可以和數字相互轉換, 是因為存在對應的字母表, 無論是 ASCII還是Unicode碼. 與之類似, 我們可以定義自己的碼表.

public class Alphabet {

    private String[] alphabet;
    private int size;

    public Alphabet(String[] alphabet);

    public String toStr(int index);

    public int toIndex(String s);

    public boolean contains(String s);

    public int R() {

        return size;
    }
}

即可完成相應的轉換.

低位優先的字符串排序

在理解了鍵索引排序之後, 低位優先就不難理解了. 無非是將字符串的每一個字符都用 鍵索引排序的方式進行排序, 字符串的長度為 maxLength;

User[] aux = new User[users.length];
for (int i = maxLength - 1; i >= 0; i--) {

    int[] count = new int[125];
    /*對待如身份證號 固定位15 18位的這兩種類型,可以設置超出界限的數值. 在15位-18位的統計時, 跳過所有為15位的,
    不再次進行統計*/
    String tempStr;
    for (int j = 0; j < users.length; j++) {
        //System.out.println(users[j] + ",j:" + j + ",i:" + i);
        if ((tempStr = users[j].getsGroup()).length() - 1 < i) {
            count[1]++;
        } else {
            count[tempStr.charAt(i) + 2]++;
        }
    }

    for (int j = 0; j < 124; j++) {
        count[j + 1] += count[j];
    }

    for (int j = 0; j < users.length; j++) {
        if ((tempStr = users[j].getsGroup()).length() - 1 < i) {
            aux[count[0]++] = users[j];
        } else {
            aux[count[tempStr.charAt(i) + 1]++] = users[j];
        }
    }

    System.arraycopy(aux, 0, users, 0, users.length);
}

由低位向高位逐次排序, 得益於鍵索引排序的結果是穩定的.

在這裏對原本的算法做了微調, 可以支持不等長字符串的排序,處理思路則是, 對於當前 I 超過字符長度的,則放在 count[1]的位置, 其余則用 +2向後順延.

高位優先的字符串排序

高位優先的字符串排序 是同樣的基於鍵索引排序所進行的.有所區別的是, 高位優先對字符串長度沒有要求, 可以不等長, 同時高位優先是按照 字母表 進行遞歸排序處理的.

public class MSD {

    private static int insertBound = 15;
    private static String[] aux;
    private static int toChar(String str, int index) {
        return index < str.length() ? -1 : str.charAt(index);
    }

    public static void sort(String[] str) {

    }

    private static void sort(String[] str, int lo, int hi, int d) {

        if (hi <= lo + insertBound) {
            //切換成插入排序, 從 lo 到 hi
            return;
        }

        int[] count = new int[123+2];
        for (int i = lo; i <= hi; i++) {
            count[toChar(str[i], d) + 2]++;
        }

        for (int i = 0; i < 124; i++) {
            count[i + 1] += count[i];
        }

        for (int i = lo; i <= hi; i++) {
            aux[count[toChar(str[i], d) + 1]++] = str[i];
        }

        for (int i = lo; i <= hi; i++) {
            str[i] = aux[i - lo];
        }

        for (int i = 0; i < 123; i++) {
            sort(str, lo + count[i], lo + count[i + 1] - 1, d + 1);
        }
    }
}

在這裏就凸顯出一種對不等長字符串更好的處理方式, 也就是 toChar()方法, 由於返回值可能為 -1, 因此統一 +2, 達到向前推進的目的.

但仍然存在幾個問題:

仔細分析來看, 如果字符串均不相同, 遞歸的次數會相當之多, 同時隨著 R 也就是例子中的 123, 編碼集的大小增加, 處理的時間也會增長的特別快. 所以特別需要註意字符集的大小問題, 以免使得速度降低到難以想象的程度.

對於含有大量相同前綴的字符串排序也會相當緩慢, 因為很難會切換到插入排序. 卻仍然需要統計頻次, 轉換, 遞歸.

它的效率受限因素恰恰又是 常常會發生的情況, 因此我們需要一種能夠避免這種種缺陷的算法.

三向字符串排序.

可以理解為 標準快速排序在 字符串這種特殊場景下的變形.

public class Quick3Sort {

    private static int toChar(String str, int index) {
        return index < str.length() ? -1 : str.charAt(index);
    }

    public static void sort(String[] str) {
        sort(str, 0, str.length - 1, 0);
    }

    private static void sort(String[] str, int lo, int hi, int d) {

        if (lo >= hi) {
            return;
        }

        int cmp = toChar(str[lo], d);
        int i = lo + 1, lt = lo, gt = hi, t;

        while (i <= gt) {
            t = toChar(str[i], d);
            if (t < cmp) {
                exchange(str, lt++, i++);
            } else if (t > cmp) {
                exchange(str, i, gt--);
            } else {
                i++;
            }
        }

        sort(str, lo, lt - 1, d);
        if (cmp > 0) {
            sort(str, lt, gt, d + 1);
        }
        sort(str, gt + 1, hi, d);
    }

    private static void exchange(String[] str, int l, int r) {
        String temp = str[l];
        str[l] = str[r];
        str[r] = temp;
    }
}

在三向快速排序中, 主要就是為了應對在 快速排序中遇到的 大量重復鍵時會導致的效率低下, 而相似的, 在三向字符串的快速排序中, 為了應對大量前綴相同的字符串, 且與字符集R的大小關系不大.

假設所有字符串前10個字符均相同, 在第一次拆分時, 前後兩個子數組長度均為0, 在生成中間數組的時候, 並沒有進行字符串的移動, 而僅僅是比較了對應位置的 單個字符而已, 能夠非常快速的將前綴過濾掉, 然後進行處理.

單詞查找樹

需要較大的空間進行儲存, 優點是速度相當之快.

查找命中的時間與被查找的鍵的長度成正比.
查找未命中時只需要查找若幹個字符.

在二叉樹中的比較方式采取的是對不同的key 進行比較, 大於在右, 小於在左, 逐層深入, 查找所需的時間主要受到 樹的高度, 比較本身 的影響.

在這裏給出實現的簡單思路, 在已經實現了二叉樹的基礎上來看, 單詞查找樹的基礎實現是相當簡單的.

以 ASCII 128位為例.

public class WST {

    private static final int R = 128;

    private static Node root;

    private class Node {

        private Object val;

        private Node[] next = new Node[R];
    }
}

核心數據結構為 Node數組,同時也能夠看出來,在這裏無需存儲字符串, 而是將字符串拆解的字符與 node數組的 索引相一一對應. 實現了 key.

首先來看它的優點, 對含有共同前綴的大量字符串而言, 無論是從空間還是時間上來看, 都是相當友好的.

但隨著R的逐漸增大, 空鏈接也會越來越多, 這些無效鏈接會占據大量的空間. 在不考慮空間消耗的情況下, 這種數據結構無疑是目前所見到最優的數據結構.

但在我看來, 對於目前所擁有的條件而言, 往往都還是需要考慮到空間資源的. 因此它的優越性並不是很強.

然而從另一點來看, 才會發現它的強大. 也是這種數據結構的必要性所在.即以下API.

String longestPrefixOf(String s); // 以s為前綴的最長的鍵
Iterable<String> keysWithPrefix(String s); //所有以s為前綴的鍵
Iterable<String> keysMath(String s); //所有和s 通配符匹配

在以往的數據結構中, 實現這幾個 接口的代價是相當高昂的.

有所不同的是, 在單詞查找樹中並不會真正的刪除一個節點, 查找未命中有兩種形式, 其一是 並未找到對應的 字符, 另一種是, 按樹查找到字符串的最後一個字符, 但 value為null. 這兩種都表示未命中.


在單詞查找樹中, 無論是命中還是未命中查找, 時間都是最優的, 而空間, 對於所有字符串較短的情況來說:

所需鏈接數平均為: RN (R為字符集大小, N為存儲的字符串總數)

而對於字符串較長的而言:

所需鏈接數平均為: RNW(W為字符串長度);

所以當使用單詞查找樹時, 務必把握字符集特點以及字符串本身的特點.

三向單詞查找樹

為了解決在 單詞查找樹中所遇到的空間消耗巨大的問題, 因此才有了這種數據結構, 在三向單詞查找樹中, 空間消耗與 R 無關, 它的實現方式與二叉樹的實現相類似, 有所不同的是, 無需 key的概念, 將 key本身拆解為 字符數, 通過這種方式表示 key.

private class Node {
    Object val;
    Node left, mid, right;
    char c;
}

而它的查找路徑為, 如果c命中, 且沒有到達字符串的末尾, 向中鍵 mid向下查找, 查找未命中, 大於 right, 小於 left. 同樣的可以實現 單詞查找樹中的 API, 但查找速度要低於前者.

是在 時間和空間消耗之間取一個平衡點.

三向單詞查找樹的優勢在於: 大型字母表 且在 大型字母表中的 字符頻率非常不均衡的情況下.

至於改進: 可以將 根節點, 或前幾個節點, 視需求和 R 的大小而定. 變為 R 向查找樹.

但依然存有一個問題, 子字符串的查找, 在數據庫查找中較為常用的一種操作, like ‘%xxx%‘; 又或者是單純的 ctrl + f 搜索字符串.查找出含有某些子字符串的字符串. 但前面的幾種實現都對這一點表示無可奈何.

子字符串查找(KMP算法)

在最簡單的考慮中, 如果需要查找一段文本中的字符串, 往往會是使用從頭到尾查找的方式, 在這裏將被匹配的字符串稱作 模式字符串, 需要與模式字符串的每一個字符都匹配, 如果不匹配則對於模式字符串是從頭再來, 對於文本字符串則是從匹配的字符串的後一位開始繼續匹配查找.

而這種方式就被稱作是暴力破解法. 實現方式一:

public static int search(String pat, String text) {
    int M = pat.length();
    int N = pat.length();
    for (int i = 0; i < N; i++) {
        int j;
        for (j = 0; j < M; j++) {
            if (text.charAt(i + j) != pat.charAt(j)) {
                break;
            }
        }
        if (j == M) {
            return i;
        }
    }
    return N;
}

很簡單的算法, 也很好理解. 這種算法在絕大多數情況下都是很好用的, 查找時間也並不需要太多, 但是並不適用於所有情況, 它的查找時間取決於文本的特點 和 模式字符串的特點. 在最壞的情況下需要NM次查找才能夠解決問題.

而下面這種暴力破解法則提供的是另一種稍有不同的思路.

public static int search(String pat, String text) {
    int M = pat.length();
    int N = pat.length();
    int i, j;
    for (int i = 0; i < N && j < M; i++) {

        if (text.charAt(i) == pat.charAt(j++)) {
            ;
        } else {
            i -= j;
            i = 0;
        }
    }
    if (j == M) {
        return i - M;
    }
    return N;
}

只有一個for循環, 用索引的回退 來代替循環. 這給了我們一種新的思路, 即用回退來控制索引, 位置.

但事實上, 在當我們得到這個 模式 字符串的時候, 就已經得到一定量的信息來幫我們解決問題.

對於這樣一個字符串 ABABAC, 當我們前進到 str[5]的時候[C], 匹配失敗, 我們的可以將字符串依舊向前推進, 不必回退text的i, 如果匹配失敗的字符串是 B的話, 那我們只要檢查 text的下一個字符是否是A即可,無需回退, 充分利用已知信息.

不僅僅是利用已經檢查的字符串, 甚至將不匹配字符也當成一條信息利用起來.不必進行二次檢查. 提高效率.

但是如何利用呢?

KMP算法

個人感覺KMP算法的理解還是很不容易的, 在有著圖文對照的情況下也花了三四天的時間才做到理解. 我們先來看較難的版本. 我就不粘貼圖文. 僅僅通過代碼來進行說明. 如果覺得有困難, 建議看看算法四 P498 及其前後文.

同時有一個比較有趣的名詞: 確定有限自動狀態機(DFA).

/*在這裏定義一個 dfa二維數組, R指的是所采用的 字符集大小,M指的是模式字符串的長度. 而數組中儲存的值即稱為當前的狀態. 狀態值也是從0~M.*/
/*所以需要理解這個狀態值得意義, 對於字符串 ABABAC而言, 當每檢查一個字符, 且匹配的時候, 狀態值前進一位, 不難發現如果六個字母都匹配, 則表示 模式字符串 匹配成功, 此時的 狀態值, 自然是6. 狀態值的起始值為0, 表示一個字符都沒有匹配. 因此需要從頭開始匹配.
*/
int[][] dfa = new int[R][M];

/*pat指的是模式字符串. 從這裏可以看出, 令 dfa[‘A‘][0] = 1, 也就是匹配到這個的時候, 進入狀態 1, 此時表示匹配成功, 由於 pat的第一個字符就是‘A‘, 也能夠發現 dfa[pat.charAt(j)][j] = j + 1;表示匹配成功.也是這裏的核心*/
dfa[pat.charAt(0)][0] = 1;

/*從這裏不難看出,外循環又 M次,也就是每一次會判斷相應位置的字符*/
for (int X = 0, j = 1; j < M; j++) {
    for (int c = 0; c < R; c++) {

/*在這裏對數組進行賦值,也就是在第j個字符的位置, 如果用來匹配的字符為c時, 應該回到哪一個狀態值. 也就是當前在當前位置匹配失敗,應該進入哪一個狀態.但又如何通過X來確定呢?*/

        dfa[c][j] = dfa[c][X];
    }

    /*在這裏是表示匹配成功的時候,狀態值應該為 j+1;dfa[][j]中的j此時也可以理解為狀態.表示當前在狀態j匹配成功,應該進入j+1,下一位.*/

    dfa[pat.charAt[j]][j] = j + 1;

    /*至關重要的是 X表示什麽意思, X表示, 如果在下一位匹配失敗,當前字符所處的位置應該在狀態幾. 對於ABABAC而言, 如果在第三位B處匹配失敗,上一位是A, X = dfa[‘A‘][0]. 此時在狀態一. 在不考慮第三位匹配值究竟是多少, 無論是 A C, 此時唯一能確定的是,至少要從狀態1開始再次計數, 因為A已經匹配成功.*/

    /*在這之後,進入下一次循環,dfa[c][j] = dfa[c][X],因為前一位A已經匹配成功,此時處在狀態1, 在狀態1的情況下,如果再遇到c此時應該處在狀態幾? 在本次循環之前,我們已經驗證了 ABA三位, 如果在狀態1的情況下,再遇到 B,應該進入狀態dfa[‘B‘][1]. 這裏的狀態這個概念,就表示此時已經匹配過多少位.*/

    /*進入新的一輪, X儲存的原值是上一位匹配成功的情況下,至少應該處在狀態幾, 在例子中, 處在狀態1, 那麽在狀態1的情況下, 如果匹配B成功,也即是,在狀態1的情況下,下一位值為B時,所應該處在的狀態,此時至少應該處在 dfa[‘B‘][1]; 如果例子換為ABACAC,則此時的dfa[‘C‘][1]為0,又需要回到起點.*/

    /*這句話簡單的說明就是: 在狀態 X的情況下匹配到 pat.charAt[j],此時應該進入狀態幾? 更新X*/

    /*而這得益於在這之前,我們已經清晰地了解了,字符的前幾位構成,此時X同樣也是由前幾位所決定的.*/
    X = dfa[pat.charAt[j]][X];
}

僅僅只需要幾行代碼就實現了相應的工作.

public static int search(String text) {
    int i = 0, j = 0, N = text.length(), M = pat.length();
    for (;i < N && j < M; i++) {
        j = dfa[text.charAt(i)][j];
        if (j == M) {
            return i - j;
        }
    }
    return N;
}

這種方法的一大優點是不需要在輸入中進行回退, 同時保證了在最壞情況下依然為線性級別的查找速度. 但事實上, 在含有大量重復文本中查找含有同樣大量重復的模式字符串, 並不是很常見的情況.

它更適合用在不確定的輸入流中, 無法回退, 或者說回退需要較大代價的情況下.

子字符串查找(BM算法)

在用於子字符串查找的算法中, BM在目前被認為是最高效的子字符串查找算法.

在KMP算法中, 算法復雜度為 O(N + M), 而在BM算法中,則為 O(N / M);

BM算法的理解難度比起KMP算法要簡單許多. 它是通過 前移值 來完成字符串的預匹配工作的.

對於文本字符串F I N D I N A H A Y S T A C K N N E E D L E 中查找 模式字符串 N E E D L E ;

每次匹配都從模式字符串的最右側開始進行匹配. 以最大程度的可以跳過 文本字符串中的字符. text[5] 為N != pat[5], 模式字符串從右向左數遇到的第一個N在第0位, 因此將模式字符串向右移動 5位.

F I N D I N A H A Y S T A C K N N E E D L E

          N E E D L E

與模式字符串的第5位相對其, 開始進行下一次匹配, 也即text[10] 為S != pat[5], 且在pat字符串中,並不存在相應的字符與之匹配. 因此前移6位.

F I N D I N A H A Y S T A C K N N E E D L E

                      N E E D L E

以此類推進行下一次比較.

在這裏模式字符串的 前移位 通過預處理就可以得到.

/*R為字符集大小, right為在匹配到對應字符時 文本字符串的指針應該前移的位數.*/
right[] int = new int[R];

/*j表示模式字符串的指針, 不斷左移, i表示文本字符串的指針, 不斷右移*/
int j, i;

/*當在模式字符串中找不到被匹配字符時, i應該前進 j + 1位. 當不匹配時設為 -1, 則自然實現了 j + 1*/
for (int c = 0; c < R; c++) {
    right[c] = -1;
}

/*M位模式字符串的長度, 因為在判斷右移位數的時候, 需要從右向左第一次出現的位置來決定, 因此需要從左向右, 查找, 覆蓋*/
for (j = 0; j < M; j++) {
    right[pat.charAt[j]] = j;
}

通過這樣簡單易懂的代碼就實現了BM算法的核心, 預處理工作.

但仍然需要註意的一種較為特殊的情況:

. . . . . . . . E L E . . . . . . .
          N E E D L E

在D的位置匹配到E, 此時應該向左移動兩位, 但這樣是不對的, 因此, 規定, 在最小於等於零的情況下, 向右移動一位即可. 重新匹配.

. . . . . . . . E L E . . . . . . .
            N E E D L E

查找:

public static search(String text, String pat) {
    ... 省略之前的right[] 數組生成.
    int skip;
    for (int i = 0; i <= N - M; i += skip) {
        skip = 0;
        for (int j = i + M; j >= 0; j--) {
            if (text.charAt(i) != pat.charAt(j)) {
                skip = j - right[text.charAt[i + j]];
                if (skip <= 0) {
                    skip = 1;
                }
            }
        }
        if (skip == 0) {
            return i;
        }
    }
    return N;
}

子字符串查找(Rabin Karp算法)

這種算法的核心思路在以前也有所接觸, 類似於 equals() 方法, 遍歷text文本, 不斷查找與 模式字符串 equals()的字符串.

但存在幾個問題:

  1. String的 equals() 是要比較每個字符, 在循環查找中依然要比較每個字符, 速度甚至不如暴力破解法.

    1:解決, 在這裏采取的是模式字符串的 hash()值, 和hashCode的計算相類似,只要將被除數(也即散列表中的容量) 設定的足夠大, 如 10的20次方, 這樣 沖突的的概率就只有 1/10^20, 可以忽略不計.

    但這裏我們僅需要保存模式字符串的散列值即可.

    public long hash(String key, int M) {
        long h = 0;
        for (int i = 0; i < M; i++) {
            h = (R * h + key.charAt(i)) % Q
        }
        return h;
    }
  2. 在文本字符串如果采取同樣的方式, 如對 01234 12345 23456 位都這樣計算hash值, 效率也是要比暴力破解法低下的, 因為不僅要遍歷, 還需要計算, 比較hash值.

    1:解決, 問題就在於如何高效的算出來對應的 hash值.

    如果用 Ti 表示 text.charAt(i),對於 text的起始位置為i的前M個字符計算值如下.

    Xi = Ti * R^(M-1) + Ti+1 * R^(M-2) + Ti+2 * R^(M-1) + ... + T(i+M-1) * R^0

    hash(xi) = Xi % Q;

    X(i+1) = (Xi - Ti * R^(M - 1)) * R + T(i+M) * R^0;

    用C表示常數,則:

    X(i+1) = C1 * Xi - C2 * Ti + T(i+M);

    這樣就能在常數時間內求取對應的散列值.

最後只需要比較散列值是否相同, 就可以判斷字符串是否相同, 如果還是想更進一步的話, 可以在散列值相同的情況下再比較字符串.

而這種算法的優點則是, 在空間 和 最壞情況下的時間取了折中效果. 它不需要額外的空間, 而在所有情況下的求取時間都是相同的, 為 7N;

總結如下:

Java的 indexOf() 采取的方法就是暴力破解法, 缺點是最壞的情況下O(MN), KMP算法為線性級別, 且需要額外的空間, 優點是 算法無需回退, 適用於流的情況. BM算法也同樣需要額外的空間, O(N/M), 將速度提升M倍;而 Rabin-Karp算法無需額外的空間, 計算時間為線性的.

因此在常規情況下使用暴力破解法已經足夠, 其他幾種視情況而定.

字符串相關(排序, 單詞查找樹, 子字符串查找)