ARC 177 / ABC 354 / 寿司打 / Convolution (低速実装)

May 19, 2024

ARC 177

ARC 177 に参加しました。珍しく低 diff の回でした。

A 問題

貪欲です。

B 問題

貪欲です。

C 問題

貪欲です。

ABC 354

ABC 354 に参加しました。

A 問題

沼に落ちました。リストを使うべきでした。

main=interact$show.f[0..]0.read
f(i:r)a k|a>k=i|0<1=f r(a+2^i)k

数学的センスがある人は、 i[0,k1]2i=2k1\sum_{i \in [0, k-1]} 2^i = 2^k-1 による簡潔な解答になるようです。

待って、これは何ですか (exponent) ?? 地球は今日も美しい……

B 問題

ソートして指定番目のデータを抜き出します。

import Data.List
main=interact$(g<*>sort.f<*>(0%).tail).words
s%[]=s
s%(_:b:r)=s+read b%r
f[_]=[]
f(_:n:r)=n:f r
g(n:_)x t=x!!(t`mod`read n)

Applicative style が精一杯です。いずれコードゴルフ記事を書けるほどの実力が……無くてもいいですね。内輪ネタ過ぎますか。

C 問題

平面走査の一種として捉えると、 1 回の走査で答えを出したくなります。

状態を持って mapMaybe がしたかったので、 State モナドを使用しました。 これが模範的な Haskell であるとは決して思いません が、自分の中では王道です。

solve :: StateT BS.ByteString IO ()
solve = do
  !n <- int'
  !acs <- U.replicateM n ints2'

  let !acs' = U.modify (VAI.sortBy (comparing (Down . fst . snd))) $ U.indexed acs
  let !res = (`evalState` (maxBound @Int)) $ (`U.mapMaybeM` acs') $ \(!i, (!_, !c)) -> state $ \acc ->
        let !acc' = min acc c
         in if c <= acc then (Just i, acc') else (Nothing, acc')

  printBSB $ G.length res
  printVec $ U.map (+ 1) $ U.modify VAI.sort res

State 以外で状態を持つ方法としては、たとえば以下があります。どれも厳しい要求で、 Haskell には崖があると思います。

初心を思い出しました。解けて良かった…….

D 問題

Imos 法で解くのが典型です。解けて良かったシリーズに入りました。

入力は x1 y1 x2 y2 形式のようです。問題文からは (A, B)(x, y) なのか (y, x) なのか分からない点が改善の余地ありと思います。

同種の問題で高難度版としては ABC 269 F - Numbered Checker があります。

E 問題

集合 DP で解くのが典型です。遷移先に 1 つでも後手必勝のケースがあれば先手の勝利です。

解けて良かったシリーズでした。

F 問題

Upsolve します。やはり F が解けないとな〜……

Misc

AI で典型問題が解ける点が徐々に物議を醸しています。自分程度の実力でも周囲と相対化される (極端に弱かったりそこそこ強かったりする) のが面白かったわけですが、徐々に元の曖昧な世界へ戻っていくんだなと思いました。

寿司打

寿司打 で 20,700 点を記録しました。元々タイピングが遅かったこともあり、ささやかな栄光でした。

次は 16 キーのキーボードをゲットして、同じ成績を目指してみたいです。誰か氏〜

Convolution (低速実装)

前回の日記で FFT の概要と計算方法を理解しました。今回は NTT (数論変換) と convolution (合成積) の概要を学び、 Haskell で実装します。

FFT が載っている CS の本として、 Introduction to AlgortihmsModern Computer Algebra があるようです。そっちを読めば良かったかも。

※ 今回も自分専用のノートです。

地固め

前回の疑問 (DFT)

離散的フーリエ級数が自然に求まるのに対し、なぜ改めてスケール違いの計算式 (DFT) を再定義するのか疑問に思いました (前回の日記) 。

大した理由は無さそうでした。実際、 DFT の定義式が流派によって異なる ようで、正規化パラメータにはブレがあるようです。今回は以下の式を採用します。

WNk:=ei2πNkXNk:=n[0,N1]ZxnWNnkxk:=1Nn[0,N1]ZXnWNnk\begin{aligned} W_N^k &:= e^{-i\frac{2\pi}{N}k} \\ X_N^k &:= \sum_{n \in [0, N - 1] \cap \mathbb{Z}} x_n W_N^{nk} \\ x_k &:= \frac {1} {N} \sum_{n \in [0, N - 1] \cap \mathbb{Z}} X_n W_N^{-nk} \end{aligned}

TODO : 周期の規格化

WNkW_N^k の定義には周期 TT (または周波数 ω=2πT\omega = \frac {2\pi} {T}) がありませんでした。時間の規格化は係数に吸収されている? 勉強不足です……

FFT と線形代数の関連

DFT は基底変換だと思いました。 FFT を要約すると、『基底変換の際に変換先の基底として 1 の N 乗根を選んだ場合、分割統治によって O(NlogN)O(N \log N) で変換先の成分を計算できる』と言えそうです。 NTT への拡張を予感させます。

DFT から NTT へ

NTT (数論変換)

FFT は精度が悪く 、競技プログラミングでは NTT (数論変換) の方が出題されるようです。

結論

WNW_N11N1^{-\frac 1 N} (1 の N 乗根の逆数) とすれば、 modp\bmod p の世界でも FFT を実施できます。 11N1^{\frac 1 N} は以下の通り求まります。

gg が『原始根』であるとき、Fermet の小定理から 1/2m(modp)1 / 2^m \pmod p が分かります:

gp1=1(modp)(gp1/2m)2m=1(modp)\begin{align} g^{p-1} &= 1 \pmod p \\ (g^{p-1/{2^m}})^{2^m} &= 1 \pmod p \end{align}

gp1/2mg^{p-1/2^m} を 2 乗していくと、 1/2m,..,1/4,1/21/2^m, .., 1/4, 1/2 がすべて求まります。また特に 998244353 に対する原始根 g3 です (WolfarmAlpha にて PrimitiveRoot[988244353] の答えを見るか、計算式を調べて実装します)。

理論 (未習得)

原始根って、何ですか……? 理論習得のために 【NTT(数論変換)入門(2)】NTT(数論変換)編 - Zenn の理解を目標にします。この記事を理解できないのは『巡回群』に馴染みが無いためで、つまり『群論への第一歩』を読んでいないためと思います。

まずは『群論への第一歩』を読もうと思います。しかし、学部レベルの勉強をもっと真っ当にやっていれば、もっと直接的に役立ったのかもしれません。

Convolution (畳み込み、合成積)

いよいよ目的の関数が見えて来ました。 Haskell の文脈では畳み込み = fold のため、ここでは合成積を convlution の訳語とします。

連続関数の合成積

周期 TT の連続関数 f(x)f(x), g(x)g(x) に対し、 合成積 (fg)(x)(f * g)(x) を次式で定義します:

(fg)(x):=T2T2f(τ)g(xτ)dτ(f * g)(x) := \int_{-\frac{T}{2}}^{\frac{T}{2}} f(\tau)g(x-\tau)\mathrm{d}\tau

合成積に対するフーリエ変換は、フーリエ変換の積に分解できます:

((fg)(x),eiωx)=T2T2(fg)(x)eiωnxdx=T2T2(T2T2f(τ)g(xτ)dτ)eiωnxdx=T2T2(T2T2f(τ)g(xτ)dτ)eiωnτeiωn(xτ)dx:=T2T2(T2T2f(τ)g(y)dτ)eiωnτeiωnydy=T2T2f(τ)eiωnτdτT2T2g(y)eiωnydy=F(x)[ω]F(g)(x)\begin{align} ((f * g)(x), e^{i\omega x}) &= \int_{-\frac{T}{2}}^{\frac{T}{2}} (f * g)(x) e^{-i\omega_n x} \mathrm{d}x \\ &= \int_{-\frac{T}{2}}^{\frac{T}{2}} (\int_{-\frac{T}{2}}^{\frac{T}{2}} f(\tau)g(x-\tau)\mathrm{d}\tau) e^{-i\omega_n x} \mathrm{d}x \\ &= \int_{-\frac{T}{2}}^{\frac{T}{2}} (\int_{-\frac{T}{2}}^{\frac{T}{2}} f(\tau)g(x-\tau)\mathrm{d}\tau) e^{-i\omega_n \tau} e^{-i\omega_n (x-\tau)} \mathrm{d}x \\ &:= \int_{-\frac{T}{2}}^{\frac{T}{2}} (\int_{-\frac{T}{2}}^{\frac{T}{2}} f(\tau)g(y)\mathrm{d}\tau) e^{-i\omega_n \tau} e^{-i\omega_n y} \mathrm{d}y \\ &= \int_{-\frac{T}{2}}^{\frac{T}{2}} f(\tau) e^{-i\omega_n \tau} \mathrm{d}\tau \int_{-\frac{T}{2}}^{\frac{T}{2}} g(y) e^{-i\omega_n y} \mathrm{d}y \\ &= \mathcal{F}(x)[\omega] \mathcal{F}(g)(x) \end{align}

積分 2 つにバラしているところが納得行きません。 y=y(τ)y = y(\tau) なので τ\tau の積分から分離できないと思いますが……? 積分の基礎知識が足りないようです。

離散関数の合成積

数列を f(x)=iδ(ix)axf(x) = \sum_i \delta(i - x) a_x のように離散的な関数として見ることで、次式の合成積が得られると解釈しました:

a:=(a0,a1,..,an1)b:=(b0,b1,..,bm1)(ab)n:=t[0,n]Zatbnt\begin{align} a &:= (a_0, a_1, .., a_{n-1}) \\ b &:= (b_0, b_1, .., b_{m-1}) \\ (a * b)_n &:= \sum_{t \in [0, n] \cap \mathcal{Z}} a_t b_{n - t} \end{align}

数列の例としては多項式の係数があります。多項式の積の係数部分は合成積で表されます。この辺りは 高校数学の美しい物語 の図解が良かったです。

a:=iaixib:=ibixi(ab)n=(ab)nxn\begin{align} \mathbb{a} &:= \sum_i a_i x^i \\ \mathbb{b} &:= \sum_i b_i x^i \\ (\mathcal{a} \mathbb{b})_n &= (a * b)_n x^n \end{align}

よって ab=F1(F(a)F(b))\mathbb{a} * \mathbb{b} = \mathcal{F^{-1}}(\mathcal{F}(\mathbb{a}) \mathcal{F}(\mathbb{b})) により、多項式の積が O(NlogN)O(N \log N) で求まります。

まとめ

多項式の積を計算する際に、 1 の N 乗根の基底に基底変換し、分割統治で成分を求めた上で、元の基底に対する成分を計算することにより、 O(NlogN)O(N \log N) で計算できます。

実装

改めて DFT, IDFT の式を眺めます。

F:{xi}i{Xi}iF1:{Xi}i{xi}iXk:=n[0,N1]ZxnWNnkxk:=1Nn[0,N1]ZXnWNnk\begin{aligned} \mathcal{F}&: \{x_i\}_i \mapsto \{X_i\}_i \\ \mathcal{F^{-1}}&: \{X_i\}_i \mapsto \{x_i\}_i \\ X_k &:= \sum_{n \in [0, N - 1] \cap \mathbb{Z}} x_n W_N^{nk} \\ x_k &:= \frac {1} {N} \sum_{n \in [0, N - 1] \cap \mathbb{Z}} X_n W_N^{-nk} \end{aligned}

FFT の図は前回同様で、 IDFT の計算方法もこの図から分かります:

2024-05-12-butterfly-8-4-2-1.webp

以上を元に、各種関数を実装しました:

詳細は cojna/iotaMath.NTT を参考に実装しました。ただし iota においては bit 反転によるソートを実施していない?のか、計算方法に違いがあります。

verify

まだまだ低速ですが、 convolute を使って行きます。

中国剰余定理 (CRT / Garner's algorithm)

convolutin_ll (convolution.hpp)

CRT は理解していませんが、 ACL を写経して AC しました。解けるから今はヨシ……!

今後

僕の convolution は、今より 10 倍速くなりそうです。将来的には 爆速なNTTを実装したい - 競プロ備忘録 などを参考に高速化が必要です。

関連

Misc

SparseGraph から頂点の型パラメータを削除

SparseGraph i w においては頂点の型 i (Index i) を抽象化していましたが、 Int 型に固定しました。 (x, y) のように成分に分けた API が欲しければ、都度グラフをラップした関数を作成します。