ビット演算テクニック集
競技プログラミングで使えそうなビット演算のテクニックを書いていきます。
思いついたら追記していく予定です。
この記事で用いるコードの言語はC++ですが、言語特有のテクニックを用いることは殆どないです。
慣れていない場合は、論理演算子とビット演算子を混同しないようにだけ注意してください。
Bit Twiddling Hacks というサイトをかなり参考にしています。
ここに書いていないようなビット演算のテクニックが載っているので一度覗いてみると面白いと思います。
バグや間違いを見つけた場合や他の面白い実装方法を思いついた場合など、コメントをしていただけると嬉しいです。
ビット演算の基礎
ここでは、二進数で表した際の一番最初の桁を 0 桁目としています。
コードの bit
に集合 MASK
に集合
説明 | コード | 集合 |
---|---|---|
bit の |
bool((bit >> i) & 1) または bool(bit & (1 << i))
|
|
bit の |
bit | (1 << i) |
|
bit の |
bit & ~(1 << i) |
|
|
(1 << i) - 1 |
|
|
(1 << j) - (1 << i) |
|
bit の MASK 部分のいずれかが 1 かどうか | bool(bit & MASK) |
|
bit の MASK 部分の全てが 1 かどうか |
(bit & MASK) == MASK または (~bit & MASK) == 0
|
|
bit の MASK 部分を 1 にした値 | bit | MASK |
|
bit の MASK 部分を 0 にした値 | bit & ~MASK |
特に説明はしませんが、まったく分からない方でも自分で例を作って試せば十分理解できると思います。
また、ここに書かれている方法は、私が良く見る実装だけを書いています。この他にも実装の仕方は色々ありますので、考えてみると楽しいかもしれないです。
ビット列に関するテクニック
1 になっているビットの個数
10011
のようなビット列であれば 3
となります。
いわゆる popcount です。
bit = (bit & 0x55555555) + ((bit >> 1) & 0x55555555);
bit = (bit & 0x33333333) + ((bit >> 2) & 0x33333333);
bit = (bit & 0x0f0f0f0f) + ((bit >> 4) & 0x0f0f0f0f);
bit = (bit & 0x00ff00ff) + ((bit >> 8) & 0x00ff00ff);
bit = (bit & 0x0000ffff) + ((bit >> 16) & 0x0000ffff);
上記のコードは 32bit の場合です。
例えば 8bit の値 11010001
で動きを確認してみます。
上記のコードでは16進数で書いているのに対して、下記のコードは2進数で書いていることに注意してください。
コード | bit |
---|---|
初期値 | |
bit = (bit & 0b01010101) + ((bit >> 1) & 0b01010101) |
|
bit = (bit & 0b00110011) + ((bit >> 2) & 0b00110011) |
|
bit = (bit & 0b00001111) + ((bit >> 4) & 0b00001111) |
このように、隣り合う
以下のような再帰関数と同じことを、同じ深さの場所に対して並列で行っているのが、上記のアルゴリズムです。
int popcount(int bit, int size = 32) {
if (size == 1) return bit & 1;
size /= 2;
int mask = (1 << size) - 1;
int cntl = popcount((bit >> size) & mask, size);
int cntr = popcount(bit & mask, size);
return cntl + cntr;
}
C++20 からは popcount
という関数があるので、これを用いれば良いと思います。
1 になっているビットの偶奇
先ほどの popcount を 2 で割ったあまりです。
popcount が求められるなら、特に下記のコードを書く理由はないと思います。
bit ^= bit >> 16;
bit ^= bit >> 8;
bit ^= bit >> 4;
bit ^= bit >> 2;
bit ^= bit >> 1;
bit &= 1;
上記のコードは 32bit の場合です。
これは
ビットの並びの反転
10110
のようなビット列であれば 01101
となります。
bit = ((bit & 0x55555555) << 1) | ((bit >> 1) & 0x55555555);
bit = ((bit & 0x33333333) << 2) | ((bit >> 2) & 0x33333333);
bit = ((bit & 0x0f0f0f0f) << 4) | ((bit >> 4) & 0x0f0f0f0f);
bit = ((bit & 0x00ff00ff) << 8) | ((bit >> 8) & 0x00ff00ff);
bit = (bit << 16) | (bit >> 16);
上記のコードは 32bit の場合です。
これは popcount で見たような、分割統治法を並列化したアルゴリズムです。
文字列の並びを反転させる関数
最も右にある 1 のみにした値
10100
のようなビット列であれば 00100
となります。
元の値を割り切る最大の 2 の冪乗の数、と言い換えることもできます。
bit & -bit
2の補数表現において、ある値とその値の符号を反転させた値を二進数の計算で足したときに、溢れたビットを除いて全て 0 になるように負の値を定めていることを考えると、理解できるかと思います。
あるいは -bit
が ~bit + 1
に等しい、と言い換えた方が分かりやすいかもしれません。
最も左にある 1 のみにした値
01011
のようなビット列であれば 01000
となります。
元の値以下の最大の 2 の冪乗の数、と言い換えることもできます。
bit |= bit >> 1;
bit |= bit >> 2;
bit |= bit >> 4;
bit |= bit >> 8;
bit |= bit >> 16;
bit ^= bit >> 1;
上記のコードは 32bit の場合です。
例えば 8bit の値 01000011
で動きを確認してみます。
コード | bit |
---|---|
初期値 | |
bit |= bit >> 1 |
|
bit |= bit >> 2 |
|
bit |= bit >> 4 |
|
bit ^= bit >> 1 |
このように、右側を全て 1 にした値を作ることで、目的の値を簡単に得ることができます。
最も右にある 1 の位置
10100
のようなビット列であれば 2
となります(0
から始まることに注意してください)。
右から連続している 0 の数、と言い換えることもできます。
かなり説明が面倒なので、書く気力が起きたら追記しておきます。
de Bruijn sequence を利用します。
もちろん二分探索でも実装はできますが、条件分岐がない分 de Bruijn sequence を用いる方が高速です。
とりあえず、以下の実装を参考にすればよいと思います。
https://graphics.stanford.edu/~seander/bithacks.html#ZerosOnRightMultLookup
C++20 から countr_zero
という関数があるので、これを用いれば良いと思います。
最も左にある 1 の位置
01011
のようなビット列であれば 3
となります。
上と同様に、書く気力が起きたら追記しておきます。
最も右にある 1 の位置が取得できれば、左も簡単に取得できます。
C++20 から countl_zero
という関数があるので、これとビット数から計算することができます。
集合の走査
部分集合を走査
いわゆる bit 全探索です。
集合
int n = 10;
for (int bit = 0; bit < (1 << n); ++bit) {
// Todo: 集合 bit を使った処理を書く
}
集合 1 << n
が
計算量は
ある部分集合の部分集合を走査
集合
蟻本の 3-2 の COLUMN に載っています。
int n = 10;
int subset = 0b1011010100;
for (int bit = subset; bit >= 0; --bit) {
bit &= subset;
// Todo: 集合 bit を使った処理を書く
}
--bit
と bit &= subset
が肝です。
これらの操作で bit
が狭義単調減少になっていることは明らかなので、この走査に被りがないことが分かります。
bit
から 1 を引くと、最も右の 1 になっているビットが 0 になり、それよりも右のビットが全て 1 になります。これに subset
でマスクをとることで、不要な走査をせず、漏れなく走査することができます。
計算量は
この操作を集合
これは
具体的なコードは以下の通りです。
int n = 10;
for (int subset = 0; subset < (1 << n); ++subset) {
for (int bit = subset; bit >= 0; --bit) {
bit &= subset;
// Todo: 集合 bit を使った処理を書く
}
}
ある部分集合を包含する部分集合を走査
集合
int n = 10;
int subset = 0b1011010100;
for (int bit = subset; bit < (1 << n); bit = (bit + 1) | subset) {
// Todo: 集合 bit を使った処理を書く
}
計算量は
考え方は先ほどのアルゴリズムと殆ど同じです。
k の部分集合を走査
要素数 集合
これも蟻本に載っています。
int x, y;
for (int bit = (1 << k) - 1; bit < (1 << n);
x = bit & -bit, y = bit + x, bit = (((bit & ~y) / x) >> 1) | y) {
// Todo: 集合 bit を使った処理を書く
}
これは、二進数において昇順となるように目的の部分集合を走査するアルゴリズムです。
簡単なアルゴリズムから改善していく形で説明を書こうと思ったのですが、かなり長くなってしまったため、それぞれの操作が何をするのかを簡単に列挙するだけにしておきます。
-
bit = (1 << k) - 1
: popcount が となる最小の値k -
x = bit & -bit
: 最も右にある 1 のみにした値 ( )01101110_{(2)} \rightarrow 00000010_{(2)} -
y = bit + x
:bit
に対する足し算において、繰り上がりが発生する最小の値 ( )01101110_{(2)} \rightarrow 01110000_{(2)} -
bit & ~y
: 最も右にある"連続した" 1 の部分のみを取り出した値 ( )01101110_{(2)} \rightarrow 00001110_{(2)} -
(bit & ~y) / x
: 右の 0 が無くなるように右シフトした値 ( )00001110_{(2)} \rightarrow 00000111_{(2)} -
(((bit & ~y) / x) >> 1)
: 上記からもう一つ右シフトした値 ( )00000111_{(2)} \rightarrow 00000011_{(2)} -
bit = (((bit & ~y) / x) >> 1) | y
: popcount が となるような辞書順で次の値 (k )01101110_{(2)} \rightarrow 01110011_{(2)}
popcount を
計算量は
Discussion