前言
首先拋出一個問題: 給定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]。比如 abcdefg 的Suffix(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的一個後綴的前綴. 比如pl
是apple
的子串. 那麼它是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]
更多學習筆記見個人博客或關注微信公衆號 <呼延十 >------>呼延十