後綴數組(Suffix Array)在字符串匹配中的應用

前言

首先拋出一個問題: 給定300w字符串A, 之後給定80w字符串B, 需要求出 B中的每一個字符串, 是否是A中某一個字符串的子串. 也就是拿到80w個bool值.

當然, 直觀的看上去, 有一個暴力的解法, 那就是 雙重循環, 再調用字符串德contains方法, 想法很美好, 現實很殘酷. 如果你真的這麼實現了(是的, 我做了.), 就會發現,效率低到無法接受.具體的效率測評在後文給出.

此時我們可以用一個叫Suffix Array的數據結構來輔助我們完成這個任務.

Suffix Array 介紹

在計算機科學裏, 後綴數組(英語:suffix array)是一個通過對字符串的所有後綴經過排序後得到的數組。此數據結構被運用於全文索引、數據壓縮算法、以及生物信息學。

後綴數組被烏迪·曼伯爾(英語:Udi Manber)與尤金·邁爾斯(英語:Eugene Myers)於1990年提出,作爲對後綴樹的一種替代,更簡單以及節省空間。它們也被Gaston Gonnet 於1987年獨立發現,並命名爲“PAT數組”。

在2016年,李志澤,李建和霍紅衛提出了第一個時間複雜度(線性時間)和空間複雜度(常數空間)都是最優的後綴數組構造算法,解決了該領域長達10年的open problem。

讓我們來認識幾個概念:

  • 子串
      字符串S的子串r[i…j],i<=j,表示S串中從i到j-1這一段,就是順次排列r[i],r[i+1],…,r[j-1]形成的子串。 比如 abcdefg的0-3子串就是 abc.
  • 後綴
      後綴是指從某個位置 i 開始到整個串末尾結束的一個特殊子串。字符串r的從第i個字符開始的後綴表示爲Suffix(i),也就是Suffix(i)=S[i…len(S)-1]。比如 abcdefgSuffix(5)fg.
  • 後綴數組(SA[i]存放排名第i大的後綴首字符下標)
      後綴數組 SA 是一個一維數組,它保存1…n 的某個排列SA[1] ,SA[2] ,…,SA[n] ,並且保證Suffix(SA[i])<Suffix(SA[i+1]), 1<=i<n 。也就是將S的n個後綴從小到大進行排序之後把排好序的後綴的開頭位置順次放入SA 中。
  • 名次數組(rank[i]存放suffix(i)的優先級)
    名次數組 Rank[i] 保存的是 Suffix(i) 在所有後綴中從小到大排列的“名次”

看完上面幾個概念是不是有點慌? 不用怕, 我也不會. 我們要牢記自己是工程師, 不去打比賽, 因此不用實現完美的後綴數組. 跟着我的思路, 用簡易版後綴數組來解決前言中的問題.

應用思路

首先, 大概的想明白一個道理. A是B的子串, 那麼A就是B的一個後綴的前綴. 比如plapple的子串. 那麼它是apple的後綴ple的前綴pl.

好的, 正式開始舉栗子.

題目中的A, 有300w字符串.我們用4個代替一下.

apple
orange
pear
banana

題目中的B, 有80w字符串. 我們用一個代替一下.

ear

.

我們的目的是, 找ear是否是A中四個字符串中的某一個的子串. 求出一個TRUE/FALSE.

那麼我們首先求出A中所有的字符串德所有子串.放到一個數組裏.

比如 apple的所有子串爲:

apple
pple
ple
le
e

將A中所有字符串的所有子串放到 同一個 數組中, 之後把這個數組按照字符串序列進行排序.

注: 爲了優化排序的效率, 正統的後綴數組進行了大量的工作, 用比較複雜的算法來進行了優化, 但是我這個項目是一個離線項目, 幾百萬排序也就一分鐘不到, 因此我是直接調用的Arrays.sort.如果有需要, 可以參考網上的其他排序方法進行優化排序.

比如只考慮apple的話, 排完序是這樣子的.

apple
e
le
ple
pple

爲什麼要進行排序呢? 爲了應用二分查找, 二分查找的效率是O(logN),極其優秀.

接下來是使用待查找字符串進行二分查找的過程, 這裏就不贅述了. 可以直接去代碼裏面一探究竟.

代碼實現

package com.huyan.sa;

import java.util.*;

/**
 * Created by pfliu on 2019/12/28.
 */
public class SuffixArray {

    private List<String> array;


    /**
     * 用set構建一個後綴數組.
     */
    public static SuffixArray build(Set<String> stringSet) {
        SuffixArray sa = new SuffixArray();
        sa.array = new ArrayList<>(stringSet.size());
        // 求出每一個string的後綴
        for (String s : stringSet) {
            sa.array.addAll(suffixArray(s));
        }
        sa.array.sort(String::compareTo);
        return sa;
    }

    /**
     * 求單個字符串的所有後綴數組
     */
    private static List<String> suffixArray(String s) {
        List<String> sa = new ArrayList<>(s.length());
        for (int i = 0; i < s.length(); i++) {
            sa.add(s.substring(i));

        }
        return sa;
    }

    /**
     * 判斷當前的後綴數組,是否有以s爲前綴的.
     * 本質上: 判斷s是否是構建時某一個字符串德子串.
     */
    public boolean saContains(String s) {
        int left = 0;
        int right = array.size() - 1;
        while (left <= right) {
            int mid = left + ((right - left) >> 1);
            String suffix = array.get(mid);
            int compareRes = compare1(suffix, s);
            if (compareRes == 0) {
                return true;
            } else if (compareRes < 0) {
                left = mid + 1;
            } else {
                right = mid - 1;
            }
        }
        return false;
    }

    /**
     * 比較兩個字符串,
     * 1. 如果s2是s1的前綴返回0.
     * 2. 其餘情況走string的compare邏輯.
     * 目的: 爲了在string中使用二分查找,以及滿足我們的,相等就結束的策略.
     */
    private static int compare1(String s1, String s2) {
        if (s1.startsWith(s2)) return 0;
        return s1.compareTo(s2);
    }

}

實現的比較簡單,因爲是一個簡易的SA. 主要分爲兩個方法:

  • build(Set): 將傳入的所有字符串構建一個後綴數組.
  • saContains(String): 判斷傳入的字符串是否是某個後綴的前綴(本質上, 判斷傳入的字符串是否是構建時某一個字符串德子串).

評估

我們對性能做一個簡易的評估.

評估使用代碼:

    @Test
    public void perf() throws IOException {
        // use sa
        long i = System.currentTimeMillis();
        List<String> A = Files.readAllLines(Paths.get("/Users/pfliu/data/old_data/A.txt"));
        SuffixArray sa = SuffixArray.build(new HashSet<>(A));

        int right = 0;
        int wrong = 0;
        List<String> B = Files.readAllLines(Paths.get("/Users/pfliu/data/old_data/B.txt"));
        for (String s : B) {
            if (sa.saContains(s)) {
                right++;
            } else {
                wrong++;
            }
        }
        log.info("use sa. all={}, right={}, wrong={}. time={}", B.size(), right, wrong, System.currentTimeMillis() - i);



        // violence
        wrong = 0;
        right = 0;
        //count
        int count = 0;
        long time = System.currentTimeMillis();
        for (String s : B) {
            boolean flag = false;
            for (String s1 : A) {
                if (s1.contains(s)) {
                    flag = true;
                    right++;
                    break;
                }
            }
            if (!flag) wrong++;
            if (++count % 1000 == 0) {
                log.info("use biolence. deal {} word. now right ={}, wrong ={}, time={}", count, right, wrong, System.currentTimeMillis() - time);
                time = System.currentTimeMillis();
            }
        }
    }

這裏是輸出部分日誌(我沒有等待暴力解法跑完):

16:29:35.440 [main] INFO com.huyan.sa.SuffixArrayTest - use sa. all=815971, right=433402, wrong=382569. time=35371
16:29:49.748 [main] INFO com.huyan.sa.SuffixArrayTest - use biolence. deal 1000 word. now right =855, wrong =145, time=14301
16:30:11.807 [main] INFO com.huyan.sa.SuffixArrayTest - use biolence. deal 2000 word. now right =1625, wrong =375, time=22059
16:30:38.272 [main] INFO com.huyan.sa.SuffixArrayTest - use biolence. deal 3000 word. now right =2343, wrong =657, time=26465
16:31:07.080 [main] INFO com.huyan.sa.SuffixArrayTest - use biolence. deal 4000 word. now right =3019, wrong =981, time=28808
16:31:36.550 [main] INFO com.huyan.sa.SuffixArrayTest - use biolence. deal 5000 word. now right =3700, wrong =1300, time=29470
16:32:07.141 [main] INFO com.huyan.sa.SuffixArrayTest - use biolence. deal 6000 word. now right =4365, wrong =1635, time=30590
16:32:39.338 [main] INFO com.huyan.sa.SuffixArrayTest - use biolence. deal 7000 word. now right =5030, wrong =1970, time=32197
16:33:13.781 [main] INFO com.huyan.sa.SuffixArrayTest - use biolence. deal 8000 word. now right =5641, wrong =2359, time=34443
16:33:47.392 [main] INFO com.huyan.sa.SuffixArrayTest - use biolence. deal 9000 word. now right =6269, wrong =2731, time=33611
16:34:21.783 [main] INFO com.huyan.sa.SuffixArrayTest - use biolence. deal 10000 word. now right =6878, wrong =3122, time=34391

我的評估集: A=80w. B=310w.

可以看到, 結果很粗暴.

使用 SA的計算完所有結果,耗時35s.
暴力解法,計算1000個就需要30s. 隨着程序運行, cpu時間更加緊張. 還可能會逐漸變慢.

結論

可以看出, 在這個題目中, SA的效率相比於暴力解法是碾壓性質的.

需要強調的是, 這個"題目"是我在工作中真實碰到的, 使用暴力解法嘗試之後, 由於效率太低, 在大佬指點下使用了SA. 30s解決問題.

因此, 對於一些常用算法, 我們不要抱着 “我是工程師,又不去算法比賽,沒用” 的心態, 是的, 我們不像在算法比賽中那樣分秒必爭, 但是很多算法的思想, 卻能給我們的工作帶來極大的提升.


參考文章

https://blog.csdn.net/u013371163/article/details/60469533

https://zh.wikipedia.org/zh-hans/%E5%90%8E%E7%BC%80%E6%95%B0%E7%BB%84

完。





聯繫我

最後,歡迎關注我的個人公衆號【 呼延十 】,會不定期更新很多後端工程師的學習筆記。
也歡迎直接公衆號私信或者郵箱聯繫我,一定知無不言,言無不盡。


ChangeLog

2019-12-28 完成

以上皆爲個人所思所得,如有錯誤歡迎評論區指正。

歡迎轉載,煩請署名並保留原文鏈接。

聯繫郵箱:[email protected]

更多學習筆記見個人博客或關注微信公衆號 <呼延十 >------>呼延十

發佈了94 篇原創文章 · 獲贊 12 · 訪問量 5萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章