Mister雑記

競プロやります。

高速ゼータ・メビウス変換

ググれば大量の記事が出てくるが、知見の整理ということで自分でも書いてみる。

ゼータ・メビウス変換とは

「高速」ゼータ・メビウス変換とは名前の通りゼータ・メビウス変換を高速化したものなので、まずはそちらを理解する必要がある。

定義: ゼータ変換、メビウス変換

Xを有限集合、Gを加法が定義された集合1とする。

このとき写像f:P(X)Gから、以下を満たす写像g:P(X)Gを求めることをゼータ変換という2

g(S)=TSf(T)

なお、この式中のTSTSにしたものも同様にゼータ変換と呼ぶことにする3

そしてこの逆変換、すなわちg:P(X)Gから以下を満たすf:P(X)Gを求めることをメビウス変換という4

f(S)=TS(1)|T||S|g(T)

この式中のTSTSにすると、上で包含関係の向きを逆にした場合のゼータ変換の逆変換になる。

この変換がゼータ変換の逆変換であることは全く自明ではないが、この記事ではその証明は省略する5


以降、集合Xとして{1,2,,n}のみを考え、関数fを長さ2nの配列として表現する。これと対応して、以降f({2,4,5})f(11010)のように表現する。

ゼータ変換の高速化

ここからゼータ変換、特に実装しやすいg(S)=TSf(T)の方を高速に行うことを考える。メビウス変換も符号を少し変えるだけでほぼ同様に実現できる。

計算量の評価は、加算が行われる回数C (Complexity)によって行う。

愚直解

まず愚直に求めようとすると、以下のようなコードになるだろう。

for (int s = 0; s < (1 << n); ++s) {
    for (int t = 0; t < (1 << n); ++t) {
        if ((s & t) == t) g[s] += f[t];
    }
}

STXの部分集合全体を回り、ST=T、すなわちTSのときだけg(S)f(T)を加算する。定義通りである。

Xの部分集合は計2n個あることから、このコードの計算量はC=2n2n=4nとなる。

部分集合の列挙

しかしこのコードではTの探索に無駄が多すぎる。できることならXではなくSの部分集合全体だけを回したいが、実はこれは以下のコードで実現できる。

for (int s = 0; s < (1 << n); ++s) {
    for (int t = s; t >= 0; t = ((t - 1) & s)) {
        g[s] += f[t];
    }
}

この手法はビット演算 (bit 演算) の使い方を総特集! 〜 マスクビットから bit DP まで 〜に載っている。

Sの部分集合は2|S|個あり、Xのサイズkの部分集合はnCk個あることから、このコードの計算量はC=k=0nnCk2k=3nとなる6

高速ゼータ変換

さらに高速化して、C=n2n1でゼータ変換を行うのが本記事の主題である高速ゼータ変換と呼ばれる手法である。

方針は「下位要素から順に拡張していく」というもの。 まずdpS,kを「1kSの部分集合、それより上はSと一致しているもの全部の総和」と定義する。言葉では分かりにくいので例を挙げると、

dp110101,3=f(110000)+f(110001)+f(110100)+f(110101)

といった具合である。定義より、dpS,0=f(S)dpS,n=g(S)

これをk=1からk=nまで順に更新していく。このとき、kSか否かで場合分けをする。具体例で考えてみると、

dp10010,3=f(10000)+f(10010)=dp10010,2dp10110,3=f(10000)+f(10010)+f(10100)+f(10110)=dp10010,2+dp10110,2

のように、kSの場合はdpS{k},k1も足す必要がある。

これを実装に落とし込むと以下の通り。

for (int s = 0; s < (1 << n); ++s) {
    dp[s][0] = f[s];
}

for (int k = 1; k <= n; ++k) {
    for (int s = 0; s < (1 << n); ++s) {
        dp[s][k] = dp[s][k - 1];
        if ((s >> (k - 1)) & 1) {
            dp[s][k] += dp[s ^ (1 << (k - 1))][k - 1];
        }
    }
}

for (int s = 0; s < (1 << n); ++s) {
    g[s] = dp[s][n];
}

集合及びDPの添字は1-indexedなのに対してbit演算は0-indexedなので、シフト数を1減らしてやる必要がある。

配列の使い回し

さらに更新について考えてみると、DPテーブルは1次元のものを使い回せることに気づく。すなわち、以下のような実装ができる。

for (int s = 0; s < (1 << n); ++s) {
    dp[s] = f[s];
}

for (int k = 1; k <= n; ++k) {
    // この時点でdp[s]は上の実装のdp[s][k-1]と一致
    for (int s = 0; s < (1 << n); ++s) {
        if ((s >> (k - 1)) & 1) {
            dp[s] += dp[s ^ (1 << (k - 1))];
        }
    }
}

for (int s = 0; s < (1 << n); ++s) {
    g[s] = dp[s];
}

ここで「dpS{k}dpSより先に更新されているが大丈夫なのか」となるが、kS{k}なので、この週ではそもそもdpS{k}は更新されていない。よって問題ない。

さらにkを1ずらしたりDP配列として直にgを使ったりして整理することで、他の記事でもよく見られるような実装になる。

for (int s = 0; s < (1 << n); ++s) {
    g[s] = f[s];
}

for (int k = 0; k < n; ++k) {
    for (int s = 0; s < (1 << n); ++s) {
        if ((s >> k) & 1) {
            g[s] += g[s ^ (1 << k)];
        }
    }
}

長い道のりだったが、ようやく高速ゼータ変換のコードに辿り着いた。

このコードの計算量は、各SXについて|S|回加算更新が行われるため、C=k=0nnCkk=n2n1となる7。加算がO(1)なら、n=18くらいなら安定して間に合うだろう(C2.4×106)。

高速ゼータ・メビウス変換の実装例まとめ

最後にまとめとして、各種変換の実装例を載せておく。 なお配列f,gはコード外で宣言済みとする。

(注※) verifyはしていないので、コピペは自己責任で。実装ミスがあれば報告していただけると助かります。

高速ゼータ変換 上位集合版

g(S)=TSf(T)

for (int s = 0; s < (1 << n); ++s) {
    g[s] = f[s];
}

for (int k = 0; k < n; ++k) {
    for (int s = 0; s < (1 << n); ++s) {
        if (!((s >> k) & 1)) {
            g[s] += g[s ^ (1 << k)];
        }
    }
}

先程とは逆に、kSのときにdpS{k}を加える。この遷移はSが大きい方から小さい方に加えられるので、更新順序的に問題ない。

ゼータ変換 下位集合版

g(S)=TSf(T)

for (int s = 0; s < (1 << n); ++s) {
    g[s] = f[s];
}

for (int k = 0; k < n; ++k) {
    for (int s = 0; s < (1 << n); ++s) {
        if ((s >> k) & 1) {
            g[s] += g[s ^ (1 << k)];
        }
    }
}

先程長々と解説した通り。

メビウス変換 上位集合版

f(S)=TS(1)|T||S|g(T)

for (int s = 0; s < (1 << n); ++s) {
    f[s] = g[s];
}

for (int k = 0; k < n; ++k) {
    for (int s = 0; s < (1 << n); ++s) {
        if (!((s >> k) & 1)) {
            f[s] -= f[s ^ (1 << k)];
        }
    }
}

非自明ではあるが、実はゼータ変換の更新の符号を反転させるだけでいい。 kが抜けるとdpS{k}の各項について|S|が1減るので符号が反転する、みたいなお気持ち。

メビウス変換 下位集合版

f(S)=TS(1)|S||T|g(T)

for (int s = 0; s < (1 << n); ++s) {
    f[s] = g[s];
}

for (int k = 0; k < n; ++k) {
    for (int s = 0; s < (1 << n); ++s) {
        if ((s >> k) & 1) {
            f[s] -= f[s ^ (1 << k)];
        }
    }
}

先と同じく符号を反転させるだけでいい。


  1. ゼータ変換では、を使うためGは可換半群であるべきだと思われる。しかし代数学にはあまり自信がないので注釈に留めておく。

  2. 別な表現として、g=ζfを満たすような写像ζ:GP(X)GP(X)をゼータ変換、(存在すれば)その逆写像ζ1メビウス変換と呼ぶこともできる。が、却って分かりにくくなりそうなので割愛。

  3. この辺は記事によってはメビウス変換になっていたりして曖昧な部分だが、ここでは高速ゼータ変換/高速メビウス変換に従うことにする。

  4. メビウス変換では逆元を必要とするため、Gはより強い性質をもつアーベル群である必要があると思われる。

  5. 指数時間アルゴリズム入門の53ページに式変形が載っている。fgが逆になっているので注意。

  6. 2つ目の等号は、二項定理で(1+2)nを展開すると成り立つことが分かる。

  7. この2つ目の等号を示す方法として、例えば(1+x)nを二項定理を使って展開し、辺々を微分してからx=1を代入するなどがある。