LoginSignup
48
29

More than 1 year has passed since last update.

高速ゼータ変換について

Last updated at Posted at 2018-12-24

これは データ構造とアルゴリズム Advent Calendar 2018 の 25 日目の記事です。

突然ですが

次の問題を解いてみましょう。

整数 $N$ と $A_0, A_1, A_2, ... A_{2^N-1}$ が与えられます。
$s, t$ に対して $s\ AND\ t=t$ の時、$t \subset s$ と表記することにします。
$0≦i<2^N$ を満たす整数 $i$ について、$j \subset i$ を満たす整数 $j$ についての $A_j$ の総和をそれぞれ求めなさい。
ただし、$a\ AND\ b$ は $a$ と $b$ のbitごとの論理積を求める演算とします。

$1≦N≦10$
$1≦A_i≦10^9\ (0≦i<2^N)$
実行時間制限 2秒

問題文にある $s\ AND\ t=t$ が何を言っているのかちょっと分かりにくいかもしれませんが、これは $t$ で立っている (=1になっている) bitは $s$ でも立っているということです。
例を示します。

$N=2, A=[1, 10, 100, 1000]$ のとき
$0≦i<2^N$ を満たす整数 $i$ は $0, 1, 2, 3$
$i=0$ のとき、$i\ AND\ j=j$ を満たす $j$ は $0$ しかないので、答えは $1$
$i=1$ のとき、$i\ AND\ j=j$ を満たす $j$ は $0,1$ なので、答えは $11$
$i=2$ のとき、$i\ AND\ j=j$ を満たす $j$ は $0,2$ なので、答えは $101$
$i=3$ のとき、$i\ AND\ j=j$ を満たす $j$ は $0,1,2,3$ なので、答えは $1111$

この問題は条件を愚直に試していくことで解くことができます。
$i$ の探索に $O(2^N)$ で、$j$ の探索に $O(2^N)$ の計算量が必要なので、全体の計算量はその積の $O(4^N)$ になります。
$N≦10$ なので $4^N≦1048576$ となり、実行時間制限の2秒には十分に間に合います。

for(int i = 0; i < (1 << N); i++){
    int ans = 0;
    for(int j = 0; j < (1 << N); j++){
        if((i & j) == j){
            ans += A[j];
        }
    }
    cout << ans << endl;
}

ちょっとしたテクニック

※この節は高速ゼータ変換とは関係がないので、この節を飛ばして次の"本題"の節を読んでもらっても大丈夫です。

では、次の問題はどうでしょうか。

整数 $N$ と $A_0, A_1, A_2, ... A_{2^N-1}$ が与えられます。
$0≦i<2^N$ を満たす整数 $i$ について、$j \subset i$ を満たす整数 $j$ についての $A_j$ の総和をそれぞれ求めなさい。

$1≦N≦15$
$1≦A_i≦10^9\ (0≦i<2^N)$
実行時間制限 2秒

前の問題と同じように見えますが、よく見ると制約が少し変わっています。
前の問題では $1≦N≦10$ だったのが $1≦N≦15$ になっています。
前と同じように愚直に解こうとすると $N=15$ のとき $4^N=1073741824$ となり、2秒の実行時間制限に間に合いません。

ここで、テクニックを使うことで高速に解くことができます。
先ほど

for(int j = 0; j < (1 << N); j++){
    if((i & j) == j){
        ans += A[j];
    }
}

としていた部分を

for(int j = i; j >= 0; j--){
    if((i & j) == j){
        ans += A[j];
    }
}

とします。int j = i の部分が気になるかもしれませんが、$j \subset i$ の時は必ず $j≦i$ なので変数 j の初期値を i にしても大丈夫です。
まだこれでは計算量は変わっていません。そして、これを

for(int j = i; j > 0; j = (j - 1) & i){
    ans += A[j];
}
ans += A[0];

とします。$j - 1$ は2進法で考えると、$j$ で立っているbitのうち最下位のものを消し、それ未満のbitを全て立てたものになります。
$j$ を自分で実際に決めて $j$ と $j - 1$ を2進法で表してみるとこれが分かると思います。

そして、(j - 1) & i は $j - 1$ の立っているbitのうち $i$ でも立っているものだけを取り出したものになります。
これも実際にfor文の動きを辿ってみると分かると思います。

ただし、j が 0 の場合、(0 - 1) は -1 になりますが、これはC++では内部的に 111..1 であり、(j - 1) & ii になってしまうので j が 0 の場合はfor文の外で判定をしています。
また、上のコードでは $j \subset i$ を満たす $j$ をもれなくだぶりなく列挙しているので、2個上のコードにあったif文が必要無くなります。

このテクニックは計算量が少し分かりにくいですが、整数 $x$ の立っているbitの個数を $bitcount(x)$ と表記すると、上のコードのfor文のループ回数は $O(2^{bitcount(i)})$ になるので、問題全体の計算量は $O(3^N)$ になります。(二項定理で $(2+1)^N$ を展開すると分かる)
$N≦15$ なので $3^N≦14348907$ となり、実行時間制限に間に合います。

本題

では、今度は次の問題を解いてみましょう。

整数 $N$ と $A_0, A_1, A_2, ... A_{2^N-1}$ が与えられます。
$0≦i<2^N$ を満たす整数 $i$ について、$j \subset i$ を満たす整数 $j$ についての $A_j$ の総和をそれぞれ求めなさい。

$1≦N≦20$
$1≦A_i≦10^9\ (0≦i<2^N)$
実行時間制限 2秒

$N≦20$ なので $3^N$ は最大で $3486784401$ になり、$O(3^N)$ 時間のアルゴリズムでも間に合いません。
やっと本題になりますが、ここで 高速ゼータ変換 というアルゴリズムを使うことで $O(2^NN)$ 時間で解くことができます。

具体的には、次のようなDPをします。

  • dp[i][j] $= k \subset i$ を満たし、かつ $k$ の下から $j$ 番目以上 (0-indexed) のbitの状態が $i$ のそれと同じような整数 $k$ についての $A_k$ の総和

まず、dp[i][0] $= A_i\ (0≦i<2^N)$ になります。
そして、

  • $i$ の下から $j - 1$ 番目 (0-indexed) のbitが立っているなら dp[i][j] = dp[i & ~(1 << (j - 1))][j - 1] + dp[i][j - 1] $(1≦j≦N)$
  • $i$ の下から $j - 1$ 番目 (0-indexed) のbitが立っていないなら dp[i][j] = dp[i][j - 1] $(1≦j≦N)$

と更新できます。
また、求めたい答えは dp[i][N] になっています。
よって、全体のコードはこうなります。

int dp[1 << N][N + 1];
for(int i = 0; i < (1 << N); i++){
    dp[i][0] = A[i];
}
for(int j = 0; j < N; j++){
    for(int i = 0; i < (1 << N); i++){
        if(i & (1 << j)){
            dp[i][j + 1] = dp[i & ~(1 << j)][j] + dp[i][j];
        }else{
            dp[i][j + 1] = dp[i][j];
        }
    }
}
for(int i = 0; i < (1 << N); i++){
    cout << dp[i][N] << endl;
}

次のように、DPテーブルを再利用することで使うメモリを $O(2^N)$ まで減らすことができます。

int dp[1 << N];
for(int i = 0; i < (1 << N); i++){
    dp[i] = A[i];
}
for(int j = 0; j < N; j++){
    for(int i = 0; i < (1 << N); i++){
        if(i & (1 << j)){
            dp[i] += dp[i & ~(1 << j)];
        }
    }
}
for(int i = 0; i < (1 << N); i++){
    cout << dp[i] << endl;
}

DPテーブルに配列 A を使うことで、初期化をしなくてもよくなります。

for(int j = 0; j < N; j++){
    for(int i = 0; i < (1 << N); i++){
        if(i & (1 << j)){
            A[i] += A[i & ~(1 << j)];
        }
    }
}
for(int i = 0; i < (1 << N); i++){
    cout << A[i] << endl;
}

計算量は $O(2^NN)$ になります。
$N≦20$ の時 $2^NN≦20971520$ なので、実行時間制限の2秒に間に合います。

終わりに

今回は総和でしたが、総和でなくても解ける場合があります。(交換法則と結合法則が成り立てば良い)
このアルゴリズムは競プロでは稀にしか出てきませんが、稀に出てくるので覚えておいて損はありません。

最後にこのアルゴリズムを使う問題を挙げておきます。

ネタバレ注意

AtCoder Regular Contest 100 - E Or Plus Max (DPテーブルの要素がそれぞれ2つの整数を持つ場合です)
No.767 配られたジャパリまん - yukicoder (高速ゼータ変換の他に包除原理というテクニックを使います)
日本情報オリンピック 2018年本選 - 5 毒蛇の脱走 (こちらも包除原理を使います、とても難しい)

48
29
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
48
29