Bit count는 말 그대로 int 나 long long 등에 저장된 숫자가 2진법으로 1이 몇개가 켜져있는지를 세는 것을 말한다. 흔히 __builtin_popcount로 사용하지만, 직접 구현할 경우를 살펴본다.
__builtin_popcount란 gcc 내장 함수로, 아래와 같이 사용한다. (VS에서는 다른 함수가 있다)
__builtin_popcount(v);// v는 int 형
만약 64bit의 bitcnt가 필요하다면, 뒤에 ll이 붙은 __builtin_popcountll을 사용한다.
Naive
가장 Naive한 방법은 아래와 같다.
intbitcnt(unsignedint v){
int cnt =0;
for(int i =0, mask =0x1; i <32;++i, mask <<=1){
if(v & mask)++cnt;
}
return cnt;
}
Sparse
bit가 sparse하게 있는 경우에는 아래 방법이 유효하다. (켜진 bit수가 적을 것으로 예상되는 경우)
맨 끝 켜진 bit를 없애는 것으로 팬윅 트리의 구현에서도 쓰이는 방법이다.
아래 방법 대신 v & (v-1) 도 가능하다. 성능 차이는 없을 듯..
intbitcnt(unsignedint v){
int cnt =0;
while(v){
v -= v &-v;// or v &= v - 1;
++cnt;
}
return cnt;
}
DP Table
물론 32자리 전체에 대해서 저장은 불가능하다. 아, 불가능하다기보다는 메모리 낭비가 너무 심하다는 점?
보통 이럴때는 16bit 단위로 끊어서 저장하는 테크닉을 많이 사용한다. 16bit 단위는 계산하면 65536(= 1<<16) 만큼의 배열 크기가 요구되는데, 고사양의 컴퓨터를 제외한 일반적으로 쓰이는 컴퓨터의 경우 L1 Cache 가 < 64 kb, L2 Cache가 < 4MB 의 크기를 가진다고 알려져 있으므로, 해당 크기는 충분히 Cache 활용이 가능한 범위라고 할 수 있겠다.
char DP[1<<16];
voidinit(){
// 16bit 가 들어왔을때의 popcount 를 DP 테이블에 저장해둔다.
}
intbitcnt(unsignedint v){
return DP[v>>16]+ DP[v&0xFFFF];
}
Divide & Conquer
bitfield는 마치 병렬 연산처럼 한번에 계산이 가능하다. 이를테면, 0x55555555와 AND 연산을 하게 되면 010101.. 처럼 bit가 남게 되는데, 이 이후에 >>1 한 값과 더해주면 인접한 두 비트의 카운트가 된다. 또한, 이 이후에 0x33333333와 AND 연산을 하게 되면, 인접한 네 비트의 카운트가 된다. 이를 반복한다.
intbitcnt(unsignedint v){
v =(v &0x55555555U)+((v >>1)&0x55555555U);
v =(v &0x33333333U)+((v >>2)&0x33333333U);
v =(v &0x0F0F0F0FU)+((v >>4)&0x0F0F0F0FU);
v =(v &0x00FF00FFU)+((v >>8)&0x00FF00FFU);
v =(v &0x0000FFFFU)+((v >>16)&0x0000FFFFU);
}
아래와 같은 방법으로 Operation을 좀 더 줄일 수 있다.
첫 두번 (인접 4비트 더하는)을 제외하고 나서는 사실 AND 연산을 두번 할 필요가 없다.
맨 처음에 1만큼 right shift 하고 0x55555555U를 씌워서 빼는 방법도 유심히 보자.
인접 2비트가 둘 다 켜져있는 경우에 세워두면 11인데, 여기서 01을 빼면 10, 즉 숫자 2가 되므로, 2개를 카운트 하게 되는 것.
그러나 위 구현도 살짝 복잡해서, 타협점으로 아래처럼 구현하는게 외우기 제일 쉬운듯 하다. (사실 첫줄 제외하면 똑같다)
intbitcnt(unsignedint v){
v =(v &0x55555555U)+((v >>1)&0x55555555U);
v =(v &0x33333333U)+((v >>2)&0x33333333U);
v =(v +(v >>4))&0x0F0F0F0FU;
v =(v +(v >>8))&0x00FF00FFU;
v =(v +(v >>16))&0x0000FFFFU;
}
Built-in이 제공되지 않는 상황에서의 Optimal
64bit까지 포함해서 가장 빠른 방식은 아래 방식이다. 외우기는 조금 빡세다. inline이나 #define을 써서 함수도 없애버리자.
인접 8비트까지 조진다음, 곱셈으로 끝낸다. 결과값을 0x010101.. 로 곱할 경우, 2진 곱셈을 잘 따라가보면, 각 8비트 쪼가리를 더하게 되는 원리이다. 이 합은 자료형 - 8 만큼 right shift 해야 구해진다.
아래 코드를 보면 알겠지만 분할 정복 방식은 64bit 연산을 하게되면 operation이 추가되는데, 이 방식의 경우 operation 수가 늘어나지 않는다. (shift만 32 - 8이었던 것이 64 - 8만큼으로 변하는 것) 그래서 64bit 연산시 제일 유리하다.
#defineBITCNT(n,r){\
n -=(n >>1)&0x5555555555555555;\
n =(n &0x3333333333333333)+((n >>2)&0x3333333333333333);\
r =(((n +(n >>4))&0xF0F0F0F0F0F0F0F)*0x101010101010101)>>56;\
};
성능 측정
-O0 옵션으로 gcc 컴파일 했을 때의 비교이다. (GCC version: 9.4.0)
우선, 32 bit 기준 결과이다.
BUILTINWAY: 298 ms
OPTIMAL WAY: 282 ms
DIVIDE & CONQUER WAY: 284 ms
DP WAY: 296 ms
SPARSE WAY: 322 ms
NAIVE WAY: 489 ms
64 bit 기준으로는 아래와 같이 된다.
BUILTINWAY: 517 ms
OPTIMAL WAY: 521 ms
DIVIDE & CONQUER WAY: 562 ms
DP WAY: 750 ms
SPARSE WAY: 776 ms
NAIVE WAY: 1009 ms
성능 측정 코드는 아래와 같다.
👉 펼치기
32bit Test
#include<stdio.h>
#include<time.h>
#defineBITCNT(n,r){\
n -=(n >>1)&0x5555555555555555;\
n =(n &0x3333333333333333)+((n >>2)&0x3333333333333333);\
r =(((n +(n >>4))&0xF0F0F0F0F0F0F0F)*0x101010101010101)>>24;\
};
intcntbit(unsignedint v){
v =(v &0x55555555U)+((v >>1)&0x55555555U);
v =(v &0x33333333U)+((v >>2)&0x33333333U);
v =(v +(v >>4))&0x0F0F0F0FU;
v =(v +(v >>8))&0x00FF00FFU;
v =(v +(v >>16))&0x0000FFFFU;
}
intcntbit_sparse(unsignedint v){
int cnt =0;
while(v){
v -= v &-v;
++cnt;
}
return cnt;
}
intcntbit_naive(unsignedint v){
int cnt =0;
for(unsignedint mask =0x1, i =0; i <32;++i, mask <<=1){
if(v & mask)++cnt;
}
return cnt;
}
char DP[1<<16];
intcntbit_dp(unsignedint v){
return DP[v &0xFFFF]+ DP[v >>16];
}
intrand(){
staticunsignedint seed =5;
seed =214013* seed +2531011;
return seed >>16;
}
unsignedintranduint(){
unsignedint res =0U;
for(unsignedint mask =0x1, i =0; i <32;++i, mask <<=1){