1. 程式人生 > 實用技巧 >「筆記」字尾陣列

「筆記」字尾陣列

寫在前面

網上部分題解直接對著優化後面目全非的程式碼開講。
*這太野蠻了*
這裡主要參考了 OI-wiki 上的講解。

一些約定

  1. \(\left| \sum \right|\):字符集大小。
  2. \(S[i:j]\):由字串 \(S\)\(S_i\sim S_j\) 構成的子串。
  3. \(S_1<S_2\):字串 \(S_1\) 的字典序 \(<S_2\)
  4. 字尾:從某個位置 \(i\) 開始,到串末尾結束的子串,字尾 \(i\) 等價於子串 \(S[i:n]\)。每一個字尾都與一個 \(1\sim n\) 的整數一一對映。

SA

SA 的定義

字串 \(S\) 的字尾陣列定義為兩個陣列 \(sa,rk\)


\(sa\) 儲存 \(S\) 的所有後綴 按字典序排序後的起始下標,滿足 \(S[sa_{i-1}:n]<S[sa_i:n]\)
\(rk\) 儲存 \(S\) 的所有後綴的排名。

舉例:這裡有一個可愛的字串 \(S=\text{yuyuko}\)
\(\text{k<o<u<y}\),則它的字尾陣列 \(sa = [5,6,4,2,3,1]\)\(rk = [6,4,5,3,1,2]\)。具體地,有:

排名 1 2 3 4 5 6
下標 \(5\) \(6\) \(4\) \(2\) \(3\) \(1\)
字尾 \(\text{ko}\)
\(\text{o}\) \(\text{uko}\) \(\text{uyuko}\) \(\text{yuko}\) \(\text{yuyuko}\)

不同字尾的排名必然不同(長度不等),\(rk\) 中不會有重複值出現。

計數排序 與 基數排序

可以參考:OI-wiki 計數排序 and OI-wiki 基數排序

計數排序是一種與桶排序類似的排序方法。
將長度為 \(n\) 的數列 \(a\) 排序後放入 \(b\) 的程式碼如下, 其中 \(w\) 為值域,即 \(\max\{a_i\}\)

int a[kMaxn], b[kMaxn], cnt[kMaxw];
for (int i = 1; i <= n; ++ i) ++ cnt[a[i]];
for (int i = 1; i <= w; ++ i) cnt[i] += cnt[i - 1];
for (int i = n; i >= 1; -- i) b[cnt[a[i]] --] = a[i];

其中,在對 \(cnt\) 求字首和後, \(cnt_i\) 為小於 \(i\) 的數的數量,即為 \(i\) 的排名。
因此在下一步中,可以根據排名賦值。
複雜度為 \(O(n+w)\),值域與 \(n\) 同階時複雜度比較優秀。


個人認為基數排序只是一種思想,並不算一種獨立的排序方法。
它僅僅是將 \(k\) 個排序關鍵字分開,按優先順序升序依次考慮,從而實現多比較字的排序。實際每次排序還是靠其他排序方法實現。常常與計數排序相結合。

倍增法構造

考慮字串大小的從前向後的比較過程,可以先將所有長度為 \(2^k\) 的子串進行排序,通過相鄰子串合併並比較大小,求出所有長度為 \(2^{k+1}\) 的子串的大小關係。

對於 \(S[i:i+2^k-1]\)\(S[j:j+2^k-1]\),分別將它們裂開,分成兩長度為 \(2^{k-1}\) 的串。設 \(A_i = S[i:i+2^{k-1}-1]\)\(B_i = S[i+2^{k-1}:i+2^k-1]\)
考慮字典序排序的過程,則 \(S[i:i+2^k-1] <S[j:j+2^k-1]\) 的條件為:

\[[A_i<A_j] \operatorname{or}\ [A_i=A_j \operatorname{and} B_i<B_j] \]

考慮每一次倍增時,都使用 sort 按雙關鍵字 \(A_i\)\(B_i\) 進行排序,每次倍增都進行依次排序,時間複雜度為 \(O(n\log^2 n)\)


P3809 【模板】字尾排序
以下是一份簡單易懂的程式碼。

這裡定義了兩個陣列:
\(sa_i\):倍增中 排名為 \(i\) 的長度為 \(2^{k-1}\) 的子串。
\(rk_i\):倍增過程中子串 \(S[i:i+2^k-1]\) 的排名,
顯然它們互為反函式,\(sa_{rk_i}=rk_{sa_i} = i\)

初始化 \(rk_i = S_i\),即 \(S_i\)\(\text{ASCII}\) 值。
雖然這樣不滿足值域在 \([1,n]\) 內,但體現了大小關係,可用於更新。 \(rk\) 的值之後還會更新。 子串長度為 \(1\),直接根據 \(rk_i\) 計數排序 \(sa\) 即可。

//知識點:SA
/*
By:Luckyblock
*/
#include <algorithm>
#include <cctype>
#include <cstdio>
#include <cstring>
#define LL long long
const int kN = 1e6 + 10;
//=============================================================
char s[kN];
int n, m, w, sa[kN], rk[kN << 1], oldrk[kN << 1];
//rk[i]: 倍增過程中子串[i:i+2^k-1]的排名,
//sa[i] 排名為i的子串 [i:i+2^k-1],
//它們互為反函式。
//存在越界風險,如果不寫特判,rk 和 oldrk 要開 2 倍空間。
//=============================================================
inline int read() {
  int f = 1, w = 0;
  char ch = getchar();
  for (; !isdigit(ch); ch = getchar())
    if (ch == '-') f = -1;
  for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
  return f * w;
}
void Chkmax(int &fir_, int sec_) {
  if (sec_ > fir_) fir_ = sec_;
}
void Chkmin(int &fir_, int sec_) {
  if (sec_ < fir_) fir_ = sec_;
}
bool CMP(int fir_, int sec_) {
  if (rk[fir_] != rk[sec_]) return rk[fir_] < rk[sec_];
  return rk[fir_ + w] < rk[sec_ + w];
}
int main() {
  scanf("%s", s + 1);
  n = strlen(s + 1);
  m = std::max(n, 300);
  //初始化 rk 和 sa。
  //觀察下面的程式碼可知,每次倍增時都會根據 rk 來更新 sa,則僅須保證 sa 陣列是一個 1~n 的排列即可。
  for (int i = 1; i <= n; ++ i) sa[i] = i, rk[i] = s[i];
  //倍增過程。w 是已經推出的子串長度,數值上等於上述分析中的 2^{k-1}。
  //注意此處的 sa 陣列存的並不是字尾的排名,存的是倍增過程中指定長度子串的排名。
  for (w = 1; w < n; w <<= 1) {
    std::sort(sa + 1, sa + n + 1, CMP);
    for (int i = 1; i <= n; ++ i) oldrk[i] = rk[i];
    for (int i = 1, p = 0; i <= n; ++ i) {
      if (oldrk[sa[i]] == oldrk[sa[i - 1]] && //判斷兩個子串是否相等。
          oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]) { //越界風險,2倍空間
        rk[sa[i]] = p;
      } else {
        rk[sa[i]] = ++p;
      }
    }
  }
  for (int i = 1; i <= n; ++ i) printf("%d ", sa[i]);
  return 0;
}

優化

請確保完全理解上述樸素實現後再閱讀下文。

發現字尾陣列值域即為 \(n\),又是多關鍵字排序,考慮基數排序。
上面已經給出一個用於比較的式子:

\[[A_i<A_j] \operatorname{or}\ [A_i=A_j \operatorname{and} B_i<B_j] \]

倍增過程中 \(A_i,B_i\) 大小關係已知,先將 \(B_i\) 作為第二關鍵字排序,再將 \(A_i\) 作為第一關鍵字排序,兩次計數排序實現即可。
單次計數排序複雜度 \(O(n + w)\)\(w\) 為值域,顯然最大與 \(n\) 同階),總時間複雜度變為 \(O(n\log n)\)

實現時將所有排序替換為基數排序即可。注意初始化。

//知識點:SA
/*
By:Luckyblock
I love Marisa;
But Marisa has died;
*/
#include <cstdio>
#include <ctype.h>
#include <cstring>
#include <algorithm>
#define ll long long
const int kMaxn = 1e6 + 10;
//=============================================================
char S[kMaxn];
//rk[i]: 倍增過程中子串[i:i+2^k-1]的排名,
//sa[i] 排名為i的子串 [i:i+2^k-1],
//它們互為反函式。
//存在越界風險,如果不寫特判,rk 和 oldrk 要開 2 倍空間。
int n, m, sa[kMaxn], rk[kMaxn << 1], oldrk[kMaxn << 1];
int id[kMaxn], cnt[kMaxn]; //用於計數排序的兩個 temp 陣列
//=============================================================
inline int read() {
  int f = 1, w = 0; char ch = getchar();
  for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1;
  for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
  return f * w;
}
//=============================================================
int main() {
  scanf("%s", S + 1);
  n = strlen(S + 1);
  m = std :: max(n, 300); //值域大小
  
  //初始化 rk 和 sa
  for (int i = 1; i <= n; ++ i) ++ cnt[rk[i] = S[i]];
  for (int i = 1; i <= m; ++ i) cnt[i] += cnt[i - 1];
  for (int i = n; i >= 1; -- i) sa[cnt[rk[i]] --] = i;

  //倍增過程。w 是已經推出的子串長度,數值上等於上述分析中的 2^{k-1}。
  //注意此處的 sa 陣列存的並不是字尾的排名,存的是倍增過程中指定長度子串的排名。
  for (int w = 1; w < n; w <<= 1) {
    //按照後半截 rk[i+w] 作為第二關鍵字排序。
    memset(cnt, 0, sizeof (cnt));
    for (int i = 1; i <= n; ++ i) id[i] = i;
    for (int i = 1; i <= n; ++ i) ++ cnt[rk[id[i] + w]]; //越界風險,2倍空間
    for (int i = 1; i <= m; ++ i) cnt[i] += cnt[i - 1];
    for (int i = n; i >= 1; -- i) sa[cnt[rk[id[i] + w]] --] = id[i];

    //按照前半截 rk[i] 作為第一關鍵字排序。
    memset(cnt, 0, sizeof (cnt));
    for (int i = 1; i <= n; ++ i) id[i] = sa[i];
    for (int i = 1; i <= n; ++ i) ++ cnt[rk[id[i]]];
    for (int i = 1; i <= m; ++ i) cnt[i] += cnt[i - 1];
    for (int i = n; i >= 1; -- i) sa[cnt[rk[id[i]]] --] = id[i];

    //更新 rk 陣列,可以滾動陣列一下,但是可讀性會比較差(
    for (int i = 1; i <= n; ++ i) oldrk[i] = rk[i];
    for (int p = 0, i = 1; i <= n; ++ i) {
      if (oldrk[sa[i]] == oldrk[sa[i - 1]] &&  //判斷兩個子串是否相等。
          oldrk[sa[i] + w] == oldrk[sa[i - 1] + w]) { //越界風險,2倍空間
        rk[sa[i]] = p;
      } else {
        rk[sa[i]] = ++ p;
      }
    }
  }
  for (int i = 1; i <= n; ++ i) printf("%d ", sa[i]);
  return 0;
}

有一點小問題,排後半截時會列舉到 \(id_i+w > n\) 怎麼辦?
考慮實際意義,出現此情況,表示該子串後半截為空。
空串字典序最小,考慮直接把 \(rk\) 開成兩倍空間,則 \(rk_i=0\ (i>n)\) 恆成立。防止了越界,也處理了空串的字典序。

再優化

兩次計數排序太慢啦! 觀察對後半截排序時的特殊性質:

考慮更新前的 \(sa_i\) 的含義:排名為 \(i\) 的長度為 \(2^{k-1}\) 的子串 \(S[sa_i, sa_i + 2^{k-1}]\)
在本次排序中,\(S[sa_i, sa_i + 2^{k-1}]\) 是長度為 \(2^k\) 的子串 \(S[sa_{i}-2^{k-1}:sa_i+2^{k-1}]\) 的後半截,\(sa_i\) 的排名將作為排序的關鍵字。
\(S[sa_i, sa_i + 2^{k-1}]\) 的排名為 \(i\),則第一次計數排序後 \(S[sa_{i}-2^{k-1}:sa_i+2^{k-1}]\) 的排名必為 \(i\)。考慮直接賦值,那麼第一次計數排序就可以寫成這樣:

int p = 0;
for (int i = n; i > n - w; -- i) id[++ p] = i; //後半截為空的串
for (int i = 1; i <= n; ++ i) { //根據後半截,直接推整個串的排名
  if (sa[i] > w) id[++ p] = sa[i] - w;
}

注意後半截為空串的情況,這樣的串排名相同且最小。

以及一些奇怪的常數優化:

  • 減小值域。 值域大小 \(m\) 與排序複雜度有關,其最小值應為 \(rk\) 的最大值,更新 \(rk\) 時更新 \(m\) 即可。
  • 減少陣列巢狀的使用,減少不連續記憶體訪問。 在第二次計數排序時,將 \(rk_{id_i}\) 存下來。
  • 用 cmp 函式判斷兩個子串是否相同。同樣是減少不連續記憶體訪問,詳見程式碼。
//知識點:SA
/*
By:Luckyblock
I love Marisa;
But Marisa has died;
*/
#include <cstdio>
#include <ctype.h>
#include <cstring>
#include <algorithm>
#define ll long long
const int kMaxn = 1e6 + 10;
//=============================================================
char S[kMaxn];
int n, m, sa[kMaxn], rk[kMaxn << 1], oldrk[kMaxn << 1];
int id[kMaxn], cnt[kMaxn], rkid[kMaxn];
//=============================================================
inline int read() {
  int f = 1, w = 0; char ch = getchar();
  for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1;
  for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
  return f * w;
}
bool cmp(int x, int y, int w) { //判斷兩個子串是否相等。
  return oldrk[x] == oldrk[y] && 
         oldrk[x + w] == oldrk[y + w]; 
}
//=============================================================
int main() {
  scanf("%s", S + 1);
  n = strlen(S + 1);
  m = std :: max(n, 300); //值域大小
  
  //初始化 sa陣列
  for (int i = 1; i <= n; ++ i) ++ cnt[rk[i] = S[i]];
  for (int i = 1; i <= m; ++ i) cnt[i] += cnt[i - 1];
  for (int i = n; i >= 1; -- i) sa[cnt[rk[i]] --] = i;

  //倍增過程。 
  //此處 w = 2^{k-1},是已經推出的子串長度。
  //注意此處的 sa 陣列存的並不是字尾的排名,
  //存的是指定長度子串的排名。
  for (int p, w = 1; w < n; w <<= 1) {
    //按照後半截 rk[i+w] 作為第二關鍵字排序。
    p = 0;
    for (int i = n; i > n - w; -- i) id[++ p] = i; //後半截為空的串
    for (int i = 1; i <= n; ++ i) { //根據後半截,直接推整個串的排名
      if (sa[i] > w) id[++ p] = sa[i] - w;
    }

    //按照前半截 rk[i] 作為第一關鍵字排序。
    memset(cnt, 0, sizeof (cnt));
    for (int i = 1; i <= n; ++ i) ++ cnt[rkid[i] = rk[id[i]]];
    for (int i = 1; i <= m; ++ i) cnt[i] += cnt[i - 1];
    for (int i = n; i >= 1; -- i) sa[cnt[rkid[i]] --] = id[i];

    //更新 rk 陣列。
    //這裡可以滾動陣列一下,但是可讀性會比較差(
    //卡常可以寫一下。
    std ::swap(rk, oldrk);
    m = 0; //直接更新值域 m
    for (int i = 1; i <= n; ++ i) {
      rk[sa[i]] = (m += (cmp(sa[i], sa[i - 1], w) ^ 1));
    }
  }
  for (int i = 1; i <= n; ++ i) printf("%d ", sa[i]);
  return 0;
}

LCP 問題

特別鳴謝:論文爺!字尾陣列-許智磊

\(\operatorname{lcp}(S,T)\) 定義為字串 \(S\)\(T\) 的最長公共字首 (Longest common prefix), 即最大的 \(l\le \min\{\left| S\right|,\left| T\right|\}\),滿足 \(S_i=T_i(1\le i\le l)\)
在許多字尾陣列相關問題中,都需要它的幫助。

下文以 \(\operatorname{lcp}(i,j)\) 表示字尾 \(i\)\(j\) 的最長公共字首,並延續上文中一些概念: \(sa_i\):排名為 \(i\) 的字尾,\(rk_i\):字尾 \(i\) 的排名。

一些定義

定義一些新的概念。
\(\operatorname{height}_i\) 表示排名為 \(i-1\)\(i\) 的兩字尾的最長公共字首。

\[\operatorname{height}_i = \operatorname{lcp}(sa_{i-1},sa_i) \]

\(h_i\) 表示字尾 \(i\) 和排名在 \(i\) 之前一位的字尾的最長公共字首。

\[h_i=\operatorname{height}_{rk_i} = \operatorname{lcp}(sa_{rk_i-1}, sa_{rk_i})= \operatorname{lcp}(i, sa_{rk_i -1}) \]

下文中將會用 \(sa_i\) 直接代表排名為 \(i\) 的字尾,即 \(sa_i = S[sa_i:n]\)

引理:LCP Lemma

\[\large \forall 1\le i<j<k\le n, \,\ \operatorname{lcp}(i,k) = \min\{\operatorname{lcp}(i,j), \operatorname{lcp}(j,k)\} \]

此引理是證明其他引理的基礎。
證明,設 \(p = \min\{\operatorname{lcp}(i,j), \operatorname{lcp}(j,k)\}\),則有:

\[\operatorname{lcp}(i,j)\ge p,\, \operatorname{lcp}(j,k)\ge p \]

\(sa_i[1:p] = sa_j[1:p] = sa_k[1:p]\),可得 \(\operatorname{lcp}(i,k)\ge p\)

再考慮反證法,設 \(\operatorname{lcp}(i,k) =q > p\)。則 \(sa_i[1:q]=sa_k[1:q]\),有 \(sa_i[p+1]=sa_k[p+1]\)
\(p\) 的取值分類討論:

  1. \(p=\operatorname{lcp}(i,j) < \operatorname{lcp}(j,k)\):則有 \(sa_i[p+1] < sa_j[p+1] = sa_k[p+1]\)
  2. \(p=\operatorname{lcp}(j,k) < \operatorname{lcp}(i,j)\):則有 \(sa_i[p+1] = sa_j[p+1] < sa_k[p+1]\)
  3. \(p=\operatorname{lcp}(j,k) = \operatorname{lcp}(i,j)\):則有 \(sa_i[p+1] < sa_j[p+1] < sa_k[p+1]\)

\(sa_i[p+1]<sa_k[p+1]\) 恆成立,與已知矛盾,則 \(\operatorname{lcp}(i,k)\le p\)
綜合上述兩個結論,得證引理成立。

引理:LCP Theorem

\[\forall 1\le i < j\le n,\, \operatorname{lcp}(sa_i,sa_j) = \min_{k=i+1}^j\{\operatorname{height_k}\} \]

由 LCP Lemma,可知顯然成立。

根據這個優美的式子,求解任意兩個字尾的 \(\operatorname{lcp}\) 變為求解 \(\operatorname{height}\) 的區間最值問題。
可通過 st 表 實現 \(O(n\log n)\) 預處理,\(O(1)\) 查詢。
問題轉化為如何快速求 \(\operatorname{height}\)


推論:LCP Corollary

\[\operatorname{lcp}(sa_i,sa_j) \ge \operatorname{lcp}(sa_i, sa_k)\, (j>k) \]

排名不相鄰的兩個字尾的 \(\operatorname{lcp}\) 不超過它們之間任何相鄰元素的 \(\operatorname{lcp}\)

證明由引理 LCP Lemma 顯然可得。
但是濤哥欽定我寫一下證明,那我就不勝惶恐地寫了(

類似 LCP Lemma,考慮反證法。
\(\operatorname{lcp}(sa_i,sa_j)< \operatorname{lcp}(sa_i, sa_k)\),則有下圖:

考慮字典序比較的過程。
\(sa_i < sa_j\),則有 \(sa_i[{\operatorname{lcp}(sa_i,sa_j)+1}] <sa_j[{\operatorname{lcp}(sa_i,sa_j) + 1}]\)
即圖中的字元 \(x<y\)

此時考慮比較 \(sa_j\)\(sa_k\) 的字典序。
由圖,\(\operatorname{lcp}(sa_j,sa_k) = \operatorname{lcp}(sa_i,sa_j)\)
\(\operatorname{lcp}(sa_i,sa_k) > \operatorname{lcp}(sa_i,sa_j)\),則 \(sa_k[{\operatorname{lcp}(sa_j,sa_k)+1}] = x\)
\(x<y\),可得 \(sa_k\) 的字典序小於 \(sa_j\)

與已知矛盾,反證原結論成立。


引理

\[\forall 1\le i\le n,\, h_i\ge h_{i-1}-1 \]

\[h_i=\operatorname{height}_{rk_i} = \operatorname{lcp}(sa_{rk_i-1}, sa_{rk_i})= \operatorname{lcp}(i, sa_{rk_i -1}) \]

用來快速計算 \(\operatorname{height}\)
個人喜歡叫它不完全單調性。

證明考慮數學歸納。
\(h_{i-1}\le 1\) 時,結論顯然成立,因為 \(h_i \ge 0\)

\(h_{i-1}>1\) 時:

\(u = i, \, v = sa_{rk_i-1}\),有 \(h_i = \operatorname{lcp}(u,v)\)
\(sa\)\(v\)\(u\) 前一位置。
\(u' = i-1, \, v' = sa_{rk_{i-1}-1}\),有 \(h_{i-1} = \operatorname{lcp}(u',v')\)
\(sa\)\(v'\)\(u'\) 前一位置。

\(h_{i-1} = \operatorname{lcp}(u',v')>1\),則 \(u',v'\) 必有公共字首。
考慮刪去 \(u',v'\) 的第一個字元,設其分別變成 \(x,y\)
顯然 \(\operatorname{lcp}(x,y) = h_{i-1}-1\),且仍滿足字典序 \(y<x\)

\(u' = i-1\),則刪去第一個字元後,\(x\) 等於字尾 \(i\)
\(sa\) 中,有 \(y<x=i=u\)

\(sa\) 中,\(v\)\(u\) 前一位置,則有 \(y<v\)
根據 LCP Corollary,有:

\[h_i = \operatorname{lcp}(u,v)\ge \operatorname{lcp}(u,y) = \operatorname{lcp}(x,y) = h_{i-1}-1 \]

得證。


快速求 height

定義 \(h_i = \operatorname{height}_{sa_i}\),只需快速求出 \(h\),便可 \(O(n)\) 複雜度獲得 \(\operatorname{height}\)

由引理已知 \(\forall 1\le i\le n,\, h_i\ge h_{i-1}-1\)
\(h_i=\operatorname{lcp}(i, sa_{rk_i -1})\) 具有不完全單調性,考慮正序列舉 \(i\) 進行遞推。

\(rk_i=1\) 時, \(sa_{rk_i-1}\) 不存在,特判 \(h_i=0\)
\(i=1\),暴力比較出 \(\operatorname{lcp}(i,sa_{rk_i-1})\),比較次數 \(<n\)

若上述情況均不滿足,由引理知,\(h_i=\operatorname{lcp}(i,sa_{rk_i-1})\ge h_{i-1}-1\),兩字尾前 \(h_{i-1}-1\) 位相同。
可從第 \(h_{i-1}\) 位開始比較兩字尾計算出 \(h_i\),比較次數 \(=h_i-h_{i-1}+2\)

計算出 \(h_i\),可直接得到 \(\operatorname{height}_{sa_i}\)
程式碼中並沒有專門開 \(h\) 陣列,其中\(h_i = k\)

void GetHeight() {
  for (int i = 1, k = 0; i <= n; ++ i) {
    if (rk[i] == 1) k = 0;
    else {
      if (k > 0) k --;
      int j = sa[rk[i] - 1];
      while (i + k <= n && j + k <= n && 
             S[i + k] == S[j + k]) {
        ++ k;
      }
    }
    height[rk[i]] = k;
  }
}

複雜度分析:
\(k\le n\),最多減 \(n\) 次,則最多會在比較中加 \(2n\) 次。
則總複雜度為 \(O(n)\)

寫在最後

參考資料:
OI-wiki SA
字尾陣列詳解 - 自為風月馬前卒
「字尾排序SA」學習筆記 - Rainy7
字尾陣列-許智磊
字尾陣列學習筆記 _ Menci's Blog