
알고리즘 문제를 풀다보면 boolean집합을 공간적으로 압축하기 위해 비트마스킹을 사용할 때가 종종 있습니다. [True, False, False, True, True, False, True, False]와 같은 배열을 10011010₂와 같이 표현하면 정수 하나로 표현 가능하기 때문이죠.
문제 중에는 정수의 특정 비트가 0인지 1인지 확인해야 하거나 1의 개수를 세어야 할 경우도 있습니다. 전자의 경우, 즉 특정 비트의 ON/OFF를 판단해야 하는 문제라면, ((임의의 정수) & (1 << n)) > 0와 같이 간단히 구할 수 있습니다.

10011010 & (1 << n) 연산 결과
오른쪽에서 n번째 비트가 1인 정수를 생성하고 임의의 정수와 AND 비트 연산을 하면, 다음과 같이 해당 비트가 1이라면 2^n, 아니라면 0을 결과로 산출합니다. 어렵지 않네요.
그렇다면 이진법으로 표현한 임의의 정수에서 1의 개수를 전부 세어야 한다면 어떻게 해야 할까요? 위의 방식을 응용하면 가장 간단한 방법이 생각나네요. 0부터 n까지 돌면서 AND 비트 연산을 한 결과가 0보다 큰 것만 카운트해주면 되겠죠.
for문을 이용해서 0부터 Integer의 범위(2 ^ 32)까지 세는 코드는 다음과 같습니다.
10011010₂를 정수로 변환한 number와 for문으로 생성한 마스크 (1 << n)를 AND 비트 연산해 0을 초과할 때마다 카운트한 뒤 출력하는 코드입니다. 가장 기초적인 코드인데도 불구하고, 정수 범위에서 겨우 32번의 연산이 진행되므로 충분할 수도 있지만 개선의 여지가 있습니다. 이제 이 코드를 조금씩 개선해봅시다. 우선 첫번째 개선점으로, 굳이 매번 마스크 (1 << n)를 만들어서 AND 비트 연산을 해야 할까요?
어떤 수에 2를 곱하거나 2로 나눈다는 것은 CPU 입장에서는 그저 비트들을 왼쪽, 오른쪽으로 이동(shift)시키는 것과 같습니다. 매번 새로운 마스크 (1 << n)를 만들어서 정수와 연산하는 것보다, 정수 자체를 이동시키면서 연산하면 공간적으로 더 효율적이겠죠? 그럼 정수를 2로 나누어가면서 가장 오른쪽에 있는 비트가 1인지 0인지만 세면 되겠네요. 0이 될 때까지요. 코드로 보면 다음과 같습니다.
대상 정수를 나타내는 number 의 나머지를 구하면 가장 오른쪽에 있는 비트가 0인지 1인지 알 수 있습니다. 1이든 0이든 그냥 더해주면 카운트가 되겠죠? 그리고 다시 number 를 2로 나누면 전체 비트가 오른쪽으로 이동하고 가장 마지막에 있던 비트는 사라지게 됩니다. 이를 반복하면 같은 결과를 얻을 수 있습니다.
이제 나누기(/)와 나머지(%)연산도 비트 연산으로 대체할 수 있겠네요. 각각 코드에 반영하면 다음과 같습니다.
다음과 같이 공간적으로 절약할 수 있는 더 깔끔한 코드를 작성할 수 있습니다. 하지만 만약 모든 비트가 1로 set되어 있다면(1111111111111111111111111111111; 32개의 1) 여전히 최악의 경우 32번의 연산을 해야하는 문제는 고쳐지지 않았네요. 그렇다면 더 개선할 수 있을까요?
브라이언 커니헨의 알고리즘은 이진법에서 1의 개수를 세는 아주 효율적인 방법입니다. 브라이언 커니헨은 벨 연구소의 연구원으로써 데니스 리치와 함께 C언어를 공동 개발한 인물입니다.
어쨌든 이 알고리즘은 아주 효율적인 방법으로 1의 개수를 셀 수 있습니다. 바로 Rightmost Set Bit(가장 오른쪽에 있는 1인 비트)를 하나씩 끄는 방법입니다. 위의 Approach 1과 Approach 2는 전체 비트 길이를 다 체크해야 결국 개수를 셀 수 있었지만, Rightmost Set Bit만 하나씩 끄면 그게 곧 개수가 되기 때문에 전체 비트를 다 체크할 필요가 없습니다. 더 효율적이겠죠.
방법은 아주 간단하면서 많이 쓰이는 트릭인 n & (n - 1) 입니다. 예를 들어, n = 1100₂ = 12라면 (n - 1) = 1011₂ = 11입니다. 둘을 AND 비트 연산하면, n & (n - 1) = 1100₂ & 1011₂ = 1000₂ = 8이 됩니다. n의 이진법 표현인 1100₂ 중 가장 오른쪽에 있는 1이 꺼졌죠. 따라서 모든 1이 다 꺼질 때까지 카운트하면 곧 1의 개수가 됩니다.
코드로 보면 다음과 같습니다.
한번의 비트 연산으로 1 하나를 찾아낼 수 있죠? 따라서 1로 set되어 있는 비트의 수가 적다면 엄청 효율적인 방법이 되겠네요. 하지만 여전히 모든 비트가 1로 set되어 있다면(1111111111111111111111111111111; 32개의 1) 여전히 최악의 경우 32번의 연산이 수행될 것입니다. 이제 진짜로 이 부분을 개선해 봅시다.
앞선 방법들은 정수 범위에서 최악의 경우 32번의 연산을 수행해야 한다는 단점이 고쳐지지 않았습니다. 하지만 이 병렬 비트 카운팅 방식은 고정된 5 ~ 6번의 연산만으로 1의 개수를 셀 수 있습니다.
비법은 바로 분할 정복입니다. 32개의 이진수 비트를 분할하고 다시 합칠 때 1의 개수를 더하는 방식으로 최종적으로 전체 1의 개수를 구하는 것입니다. 우선 코드를 먼저 띄워놓고 설명하겠습니다.
마스킹을 위한 16진수 표현 때문에 조금 혼란스러울 수도 있습니다. 하지만 각각의 16진수 표현을 이진수 표현으로 바꿔보면 이렇게 됩니다.
•
0x55555555 = 01010101010101010101010101010101₂
•
0x33333333 = 00110011001100110011001100110011₂
•
0x0F0F0F0F = 00001111000011110000111100001111₂
•
0x00FF00FF = 00000000111111110000000011111111₂
•
0x0000FFFF = 00000000000000001111111111111111₂
어떤 패턴이 보이지 않나요? 0과 1이 2, 4, 8, 16, 32의 길이로 반복이 되고 있습니다. 즉 첫번째 마스킹 0x55555555는 어떤 정수의 홀수 번째 비트만 가져오기 위함입니다. 이제 마스킹에 대한 감을 잡았으니 첫번째 라인부터 한번 살펴봅시다.
첫번째 라인 number = ((number >> 1) & 0x55555555) + (number & 0x55555555); 에서는 어떤 정수 number 를 오른쪽으로 한 비트씩 옮긴 뒤 홀수 번째 비트만 가져오고 ((number >> 1) & 0x55555555), number 그대로 홀수 번째 비트만 가져온 뒤 (number & 0x55555555) 둘을 각각 더합니다. 이 연산을 수행하면 전체를 두 비트씩 묶은 뒤에 그 안에서 각각 1의 개수를 구한 것과 같습니다. 그 다음 라인에서는 전체를 네 비트씩 묶은 뒤에 그 안에서 각각 1의 개수를 구합니다. 이를 마지막까지 반복하면 최종적으로 number 안에는 전체 1의 개수가 남습니다.
사실 위 설명이 끝이지만 이것만으로 이해하셨다면 대단합니다. 하지만, 좀 더 풀어서 설명하겠습니다. 설명에서는 number = 1,975,373,466라는 임의의 숫자를 가정하겠습니다.
number 를 오른쪽으로 옮기고 마스킹하면 사실 짝수 번째 비트의 값들만 남습니다.
그리고 number 를 마스킹하면 홀수 번째 비트의 값들만 남겠죠.
그리고 각각 인접한 두 개의 값들끼리 더하면 다음과 같은 result 가 산출됩니다. 가장 마지막 줄에는 각각 인접한 두 개의 값들을 십진수로 변환한 값입니다. 각 그룹으로 묶인 값들이 어떻게 변화하는지를 주목해주세요.
산출된 result 를 다시 number 에 대입합니다.
다시 이번에는 number 를 오른쪽으로 두번 옮기고 0x33333333로 마스킹합니다. 그럼 각각 4개로 묶인 값 중에서 앞의 2개의 값이 사라지고 뒤의 2개의 값만 남습니다. 즉, 상위 2비트를 가져오는 것이죠.
그리고 number 를 마스킹하면 각각 하위 2비트의 값만 남습니다.
그리고 각각 인접한 4개의 값들끼리 더하면 다음과 같은 result 가 산출됩니다. Step 2의 가장 마지막 줄, 각각의 값을 10진수로 변환한 값을 Step 1의 값과 한 번 비교해 주세요. 3 (1 + 2), 2 (1 + 1), 3 (1 + 2), 3 (2 + 1), 2 (2 + 0), 3 (2 + 1), 2 (1 + 1), 2 (1 + 1). Step 1에서 두 값을 각각 더한 값과 같습니다. 묶는 비트의 수를 늘려가면서 이를 반복해봅시다.
최종적으로 number = 1,975,373,466 = 01110101101111011100111010011010₂ 의 1인 비트 수는 20입니다. 이처럼 2비트 → 4비트 → 8비트 → 16비트 → 32비트 식으로 비트를 합산하는 방식을 병렬 비트 카운팅 혹은 Population Count 줄여서 Popcount 라고 합니다.
다른 스니펫들을 보면 조금 다른 방식으로 표현되어 있는 경우가 있는데, 위의 코드를 최적화한 것이며, 원리는 같습니다.
사실 자바의 Integer.bitCount() 메서드나 c++의 popcount() 메서드도 위의 Population Count로 구현되어 있습니다. 자바의 메서드를 살펴보겠습니다.
위에 언급한 것처럼 구현 방식이 다른 것처럼 보이지만, 찬찬히 뜯어보면 결과는 거의 같습니다.
위의 글에서 Step 3의 코드를 가져오면 다음과 같습니다.
그리고 자바의 bitCount() 메서드의 Step 3에서 수행하는 연산은 다음과 같습니다.
차이는 상위 4비트, 하위 4비트를 각각 마스킹한 다음에 더할 것이냐, 일단 더한 다음에 마스킹할 것이냐입니다.
만약 초기 값에서 임의의 상위 4비트, 하위 4비트가 포함된 비트 그룹이 1로 다 차있었다고 생각해보겠습니다. 가장 상위 그룹이라고 생각한다면 다음과 같습니다.
•
number = 1111 1111 xxxx x…
Step 2를 거쳤다면 0100 0100 xxxx x…가 result겠네요. 다음으로 Step 3로 넘어와서 연산할 때 어떤 방법으로 연산해도 8 = 00001000₂을 넘을 수 없습니다. 하지만 자바 메서드의 방법에서는 마스킹하는 AND 비트 연산을 한번 줄일 수 있기 때문에 CPU사이클이 절약될 수 있겠죠?
마찬가지로 Step 1도 기존의 방식을 개선한 것입니다. 예를 들어 비트가 11 11₂(15)라면 오른쪽으로 한번 이동한 뒤 마스킹하면 01 01₂(5)가 되고 이를 빼주면 10 10₂(10)이므로, 상위 비트와 하위 비트를 각각 마스킹한 뒤 더한 값과 같습니다. 이렇게 AND 비트 연산을 하나 줄여 최적화할 수 있습니다.
•






