ABC 358 / Z Function / Suffix Array / SKK

Jun 16, 2024

ABC 358

ABC 358 に参加しました。

問題A 問題B 問題C 問題D 問題E 問題F 問題G 問題
予想125009001,8001,6002,400
実際11432733931,3972,0981,737
Table 1: Diff 予想

A 問題

interact の罠がよく分かる問題です。入力ファイルの末尾に改行文字があります。

main=interact b;b"AtCoder Land\n"="Yes";b _="No"

注意点として改行文字 (LF) が 2 バイト (CRLF?) になりがちです。

AtCoderでコードゴルフをするにあたって

submitページから直接提出すると、改行文字は2Byteとカウントされます。

; で区切ったほうが面倒がありません。 Kotatsugame さんは改行文字を 1 バイトにして提出するブラウザ拡張を使っています。

B 問題

scanl の問題です。

main=interact$unwords.map show.f.map read.words;f(n:a:t)=tail$scanl(\x y->max x y+a)0 t

コードゴルフが目的でなければ scanl' を使います。 vector パッケージにおいては postscanl' を使えば tail する必要がありません。

C 問題

Bit 全探索と bitset の問題でした。 Bit mask の和を取るときは、 sum ではなく foldl' (.|.) (0 :: Int) を使わねばなりません……

D 問題

2 つのリストをソートしてマッチさせて行くのが良さそうです (cojna さんの提出) 。

僕は multiset でズルをしてしまいました。

E 問題

DP でした。選んだ文字の長さに注目し、その内訳を忘れ去ることで、状態数を大幅に削減し緩和が効きます。良問ですね。

Upsolve します。

F 問題

hogee

G 問題

hogee

Z Function

文字列に苦手意識があります。特に文字列のアルゴリズムは全容が見えて来ません。困難は実装せよということで、 Algorithms for Competitive Programming を写経しました。

Z-function - Algorithms for Competitive Programming

このサイトは初見に厳し目ですが、読めば分かるように書いてあって高印象……好印象です。以下は自分用メモです。

O(N2)O(N^2) 実装

Z 関数 (配列) の定義を以下とします:

z[i]:=lcp(s[0:],s[i:])z[i] := \mathcal{lcp}(s[0:], s[i:])

愚直に計算します:

-- | \(O(\max(N, M))\) Longest common prefix calculation. \(z[0] := |s|\).
lcpOf :: BS.ByteString -> BS.ByteString -> Int
lcpOf bs1 bs2 = length . takeWhile id $ BS.zipWith (==) bs1 bs2

-- | \(O(N^2)\) Z function calculation. \(z[0] := |s|\).
zOfNaive :: BS.ByteString -> U.Vector Int
zOfNaive bs = U.generate (BS.length bs) z
  where
    -- z 0 = 0
    z i = lcpOf bs (BS.drop i bs)

O(N)O(N) 実装

ss と接尾辞 (s[1:],s[2:],s[1:], s[2:], \dots) のマッチの内、最も右端までマッチした範囲を z-box と呼んで保持します。 z-box 内の ii に対する z[i]z[i] の計算には z[i](i<i)z[i'] (i' < i) の計算結果を利用できます:

2024-06-16-z-function.webp

実装中は z-box を状態に持って constructN したくなりました。しかし constructN が引数に取るのは純粋関数です。やはり cojna さんの実装 と同様に可変配列を手動管理しました。 僕の実装 (ZFunction.hs)

O(N)O(N) になるお気持ち

z-box の右端は単調増加します。 LCP の trivial 解による文字比較の回数は、マッチした場合・マッチしなかった場合がそれぞれ高々 n 回となります。よって O(n)O(n) で計算できています。そんなお気持ちです。

Quickcheck

愚直解と比較しました。

Library Checker

Z Algorithm - Library Checker22 ms でした。さすが O(N)O(N) です。

まだ使い方は知らず、エアプです。

Suffix array

Suffix Array - Algorithms for Competitive Programming

ACL I - Number of Substrings で立ちはだかるデータ構造です。以下は自分用メモです。

O(N2logN)O(N^2 \log N) 実装

Suffix array sa は、文字列 s の全 suffix をソートした後の添字 i' を元の suffix の番号 i に写します。愚直に実装しました:

saOfNaive :: BS.ByteString -> U.Vector Int
saOfNaive bs =
  U.convert
    . V.map fst
    . V.modify (VAI.sortBy (comparing snd))
    $ V.generate n (\i -> (i, BS.drop i bs))
  where
    n = BS.length bs

以降、 ii' の空間の違いを強く意識することが重要です。

O(NlogN)O(N \log N) 実装

Suffix Array - Algorithms for Competitive Programming

写経しました。分割された困難のメモです:

積み重ねが凄くて面白いですね。

QuickCheck

愚直解と比較しました。

Library checker

Suffix Array - Library Checker234 ms でした。 O(N)O(N) 実装は 2 ~ 7 倍速くなります。

O(N)O(N) 実装 (スキップ)

SA-IS (suffix array induced sorting) が O(N)O(N) で強いらしいです。 O(N)O(N) でなければ間に合わない問題もしばしばあるようですが、大変らしいので飛ばします。

LCP 配列 (Kasai's algorithm)

Suffix array と LCP 配列を併用すると、 suffix trie よりも効率が良いと評判のようです。 Suffix trie のことは知らないので、 trie との関連付けは一旦忘れることにします。

LCP 配列とは

多数の lcp(s[sa[i]:],s[sa[j]:])lcp(s[sa[i]:], s[sa[j]:]) クエリへの応答を考えます。 s[sa[i]:],s[sa[j]:]s[sa[i]:], s[sa[j]:] を直接比較して LCP を求めたいところですが、任意の i,ji, j に対して LCP を高速で求める工夫が必要です。

ここで lcp[i]:=lcp(s[sa[i]:],s[sa[i+1]:])\mathcal{lcp}[i] := \mathcal{lcp}(s[sa[i]:], s[sa[i+1]:]) を用いて lcp(i,j)=min{lcp[k])}k[i..j)\mathcal{lcp}(i, j) = \min \{ \mathcal{lcp}[k] ) \}_{k \in [i .. j)} のようです。 lcp\mathcal{lcp} 配列の添字が辞書順ソート後の接尾辞列に対する添字であることを考えると、 s[sa[i]:]s[sa[i]:] から s[sa[j]:]s[sa[j]:] までの間は、徐々に s[sa[j]:]s[sa[j]:] に向かって文字列が編集されていくように見えます。実際 lcp(s[sa[i]:],s[sa[k]:])\mathcal{lcp}(s[sa[i]:], s[sa[k]:])kk が増加するにつれて単調減少します。ここで k[i..j1]k \in [i .. j - 1] の範囲で min 演算子で LCP を畳み込むことで lcp(s[sa[i]:],s[sa[j]:])lcp(s[sa[i]:], s[sa[j]:]) の計算を代替できるようです:

                      LCP      LCP (畳み込み)
1:   a b a b a b
     *-*-*-*          4        4
2:   a b a b c d
     *                1        min 4 1 = 1
3:   a c a b c d
     *-*-*-*-*        5        min 1 5 = 1
4:   a c a b c e
Listing 1: min で LCP を畳み込む

(証明が欲しい)

また重要な事実として suffix array 上で隣接した 2 項の LCP が最も大きく、間隔を広げると LCP は広義単調減少します。このことから次の Kasai's algorithm を導けます。

Kasai's algorithm (O(N)O(N))

LCP 配列生成の方法は O(N)O(N) Kasai's algorihm を採用します。より高速な実装も多数ある (LCP配列の構築アルゴリズムたち) ようですが、 O(N)O(N) の時点で十分高速です。

Kasai's algorithm では最長の suffix から順に LCP 配列の値を確定させます。 (元の添字 → ソート後の添字) を sa1\mathcal{sa}^{-1} として

sa[sa1[i]]:=ilcp[sa1[i]]:=lcp(s[i:],s[sa[sa1[i]+1]:])lcp[sa1[i+1]]lcp[sa1[i]]1\begin{aligned} \mathcal{sa}[\mathcal{sa}^{-1}[i]] &:= i \\ \mathcal{lcp}[\mathcal{sa}^{-1}[i]] &:= \mathcal{lcp}(s[i:], s[\mathcal{sa}[\mathcal{sa}^{-1}[i]+1]:]) \\ \mathcal{lcp}[\mathcal{sa}^{-1}[i+1]] &\ge \mathcal{lcp}[\mathcal{sa}^{-1}[i]] - 1 \\ \end{aligned}

3 行目は s[(i+1):],s[(sa1[i]+1]+1):]s[(i+1):], s[(\mathcal{sa}^{-1}[i]+1]+1):] が存在し sortedIndexOf(s[(i+1):])<sortedIndexOf(s[(sa1[i]+2):])\mathcal{sortedIndexOf}(s[(i+1):]) < \mathcal{sortedIndexOf}(s[(\mathcal{sa}^{-1}[i]+2):]) から前項 (LCP の min 畳み込み) によって証明できます。

ACL / Library Checker

ユニークな部分列の数を数える問題です。ここでも suffix array 上で隣接する 2 項間の LCP 値が最大であることを踏まえて、 sa[i]\mathcal{sa}[i] と結合できる prefix (空でも良い) を重複無く数える式を考えると i(nsa[i]lcp[i])=n2n(n1)2ilcp[i]\sum\limits_i {(n - \mathcal{sa}[i] - \mathcal{lcp}[i])} = n^2 - \frac {n (n - 1)} {2} - \sum\limits_i \mathcal{lcp}[i] が導かれます。

なぜ lcp[i]\mathcal{lcp}[i] を引けば良いのか。それは prefix の suffix + 対象の suffix が他の suffix と一致することを避けるためのようです。この辺も難しい。。

感想

ACL の文字列データ構造 (Z Funciton および Suffix Array) を実装しました。最大流とか遅延セグメント木に比べれば簡単な方ですが、使い方が見えない点が苦痛です。幸いプログラミングにおいては困難は実装せよで理解が進むため、なんとか喰らいつくことができました。

Haskell

醜い Haskell のフォーマット

Z function の愚直実装は美しいフォーマットでした。 gksato さんの提出から学んだことですが、 . を使うとインデントが減ります:

saOfNaive :: BS.ByteString -> U.Vector Int
saOfNaive bs =
  U.convert
    . V.map fst
    . V.modify (VAI.sortBy (comparing snd))
    $ V.generate n (\i -> (i, BS.drop i bs))
  where
    n = BS.length bs

逆に $ を使うと ormolu がインデントを重ねます。 $ を使ってダサくなりましょう:

saOfNaive :: BS.ByteString -> U.Vector Int
saOfNaive bs =
  U.convert $
    V.map fst $
      V.modify (VAI.sortBy (comparing snd)) $
        V.generate n (\i -> (i, BS.drop i bs))
  where
    n = BS.length bs
Listing 2: ダサい Haskell

QuickCheck alternatives?

今回の QuickCheck も、単なるランダムテストで愚直解と高速解を比較しています。ランダムではなく、小さい入力を全点チェックすれば良い (exhaustive test を実施すれば良い) 気もします。

これは QuickCheck 上で exhaustive test を実施する方法を調べたほうが良さそうです。

Misc

nerd-icons.el

Emacs ではアイコンレスなターミナル人生を歩んで来ましたが、 nerd-icons.el により華やかになりました。この 1 週間、何度見ても嬉しいです。

2024-06-16-nerd-icons.webp
Figure 1: my wife

neotree に関しては こちらの PR がマージされれば、ほぼ out-of-the-box でアイコン表示できるようになるはずです。黄金期! Emacs の時代は何度来ても良いですからね。

内なるクソリプの衝動

X で Emacs の画像を送りつけてしまいました。しばらく控えます……

AtCoder-JOI

半年間レーティングが上がらず苦しんでいます。過去のレーティングの上げ方はこんな感じです:

レーティングレーティングを上げた (つもりの) 方法
灰色典型 90 問の ★ 2, ★ 3 を解く
茶色平日に問題を解く
緑色水 diff を 100 問解く

適切な時期に適切な問題を解くのが効く……と思いこんでいます。

現在の僕は JOI の ★ 4 〜 ★ 6 を解くのが良い と助言を頂いたので、素直に取り組んでみます。ありがたい……! AtCoder-JOI をあたります。

oj t -M diff-all

naoya さんの ABC357振り返りoj の side-by-side diff を知りました。 ずるいや……!

Nix 上の環境構築を確認中……

skk-tutorial

日本語入力には、やはり SKK が良いらしいです。 macOS の方で DDSKKskk-tutorial をやっています。 140 問くらいあるんですよね。

Q3-4 左手の小指を SHIFT で酷使したくありません。

これを見ると親指キーを SKK 専用のキーにするのが良いとあります。僕の Keyball には既に Enter キーや IME on, IME off が親指にあり、操作感を崩さずに移行するのが良さそうです。

Misc of misc