Bitwise Property

  • 비스마스크 연산은 O(1) 구현이 많아서 적절히 사용하면 훨씬 빠르다.
  • 특히 집합 연산의 경우 반복문 없이 곧바로 가능한 경우가 많아서 코드가 짧아진다.
  • 같은 데이터를 더 적은 메모리에 표현 가능하다. 따라서 더 많은 데이터를 미리 계산해둘 수 있고, 캐시 효율도 좋아지며, 더 빨라질 수 있다.
  • XOR 연산은 <> 와 동치. 즉, 두 비트가 다르면 참을 반환한다.
  • 비트마스크 연산자 우선순위는 가장 낮기 때문에, 중첩된 연산 식에 쓰일 때에는 항상 괄호를 쳐주자.
  • C++에서 1은 부호있는 32비트 상수 로 취급되므로, 부호없는 64비트 1을 쓰고 싶다면 항상 =1ull=의 형태로 쓰자.
  • 32비트 또는 64비트 전체 비트를 쓰고 싶다면 항상 부호없는(unsigned) 정수를 쓰자. 음수를 잘못 쉬프트 하면 1이 채워질 수 있다.

응용

2의 제곱수 판별 (1)

2의 보수 표현에서 다음 등식이 성립한다: -x = ~x + 1

예를 들어 x = 7 인 경우를 살펴보면 다음과 같다.

x = 7;
0 0 0 0 0 1 1 1  // x
1 1 1 1 1 0 0 0  // ~x
1 1 1 1 1 0 0 1  // ~x + 1 == -x

직접 x + (~x + 1) 을 계산해보면 전부 0이 된다는 것을 알 수 있다. 즉, 2의 보수 표현에서 -x = ~x + 1 이다.

이제 이 성질을 가지고 다음과 같이 x가장 오른쪽에 있는 1인 비트 의 위치를 알아낼 수 있다.

x = 7;
0 0 0 0 0 1 1 1  // x
1 1 1 1 1 0 0 1  // -x
0 0 0 0 0 0 0 1  // x & -x

x = 6;
0 0 0 0 0 1 1 0  // x
1 1 1 1 1 0 1 0  // -x
0 0 0 0 0 0 1 0  // x & -x

즉, 두 수를 AND 연산으로 묶으면 가장 오른쪽에 있는 1인 비트 하나만 남기고 다 Unset 된다.

x 가 2의 제곱수인 경우에 어떻게 되는지 보자.

x = 16;
0 0 0 1 0 0 0 0  // x
1 1 1 1 0 0 0 0  // -x
0 0 0 1 0 0 0 0  // x & -x

x 가 2의 제곱수인 경우 (즉 x = 2**x 꼴) 위와 같이 x & -x = x 가 됨을 알 수 있다.

2의 제곱수 판별 (2)

첫 번째 방법이 가장 오른쪽에 있는 1인 비트만 남기는 방법이었다면, 이와 반대로 가장 오른쪽에 있는 1인 비트를 딱 하나만 지워버리는 방법도 있다.

먼저 xx - 1 의 비트 표현을 살펴보자.

x = 7;
0 0 0 0 0 1 1 1  // x
0 0 0 0 0 1 1 0  // x - 1
0 0 0 0 0 1 1 0  // x & (x - 1)

x = 16;
0 0 0 1 0 0 0 0  // x
0 0 0 0 1 1 1 1  // x - 1
0 0 0 0 0 0 0 0  // x & (x - 1)

즉, x 가 2의 제곱수라면 x & (x - 1) = 0 이 됨을 알 수 있다.

4의 제곱수 판별

그러면 문제를 조금 비틀어서, 어떤 수가 4의 제곱수, 즉 \( x = 4^n \) 꼴로 표현되는 수는 어떻게 판별할 수 있을까?

일단 4의 제곱수는 2의 제곱수이므로 위의 방법을 써서 올바른 1비트의 위치를 갖는지 확인할 수 있다.

4의 제곱수의 경우 2의 제곱수와 거의 비슷하지만 한 자리씩 건너 뛰게 된다. 즉 b1, b100, b10000 과 같은 형태를 지니게 된다. 만약 문제의 조건에 따라 값의 범위가 32비트 정수 이내라면, 이 모든 가능한 경우를 하나로 합치면 b010101010101...0101 꼴이 되고 이를 16진수로 좀더 쉽게 표현하면 0xaaaaaaaa 이 된다. 따라서 아래와 같이 판별할 수 있다.

bool isPowerOfFour(int n) {
  if (n <= 0) return false;
  unsigned long long x = n;
  return ((x & (x - 1)) == 0 && (x & 0xaaaaaaaa) == 0);
}

집합 표현하기

N개의 비트를 가지고 집합을 표현할 수 있다. i번째 원소가 집합에 속해있는지 여부는 i번째 비트가 0인지 1인지 여부로 표현한다. 정수는 32비트 또는 64비트 크기 이므로 N이 이 범위에 속할 때에만 가능하다.

구체적인 예시를 위해 N = 20이라고 하자. 즉 0부터 19까지 총 20개의 원소가 가능하다.

다 켠 경우

N개의 원소를 다 선택한 경우를 상수로 미리 선언해두자. 일일이 1을 채워넣은 바이너리 수식을 써줘도 되지만 간단히 다음과 같이 1을 N만큼 쉬프트한 다음 1을 빼줘도 된다. 괄호 치는거 꼭 잊지말자.

const int full = 0b0000'0000'0000'1111'1111'1111'1111'1111; // 20 1s
// or
const int full = (1 << 20) - 1;

원소 추가

picked = picked | (1 << i);

1을 왼쪽으로 i만큼 시프트하면 i번째 비트만 1인 정수가 되고 이 결과를 이때까지 선택한 picked 에 OR로 합치면 항상 이 원소가 추가되게 된다.

원소 포함 여부 확인 (membership)

if (picked & (1 << i)) {
  // i-th element is contained ...
}

역시 i번째 비트만 1인 정수를 이때까지 선택한 picked 와 AND 연산을 하면 i번째 원소의 포함 여부를 알 수 있다.

원소 삭제

삭제가 좀 트리키하다. i번째 원소를 삭제한다는 것은 i번째 원소의 비트를 끄고, 나머지 비트 는 다 살려둬야 한다. i번째 원소는 항상 0으로, 나머지 원소는 항상 1로 만드는 것이다.

picked = picked & ~(1 << i);

i번째 비트만 꺼지고 나머지 비트는 전부 켜진 정수를 picked 와 AND 연산하면 우리가 원하는 것을 달성할 수 있다.

원소 토글

i번째 비트가 켜져있다면 끄고, 꺼져있다면 켜는 연산인 토글 연산이 종종 유용하다. XOR 연산이 정확히 이것을 해준다.

picked = picked ^ (1 << i);

집합 연산

int added = a | b;         // 합집합
int intersection = a & b;  // 교집합
int removed = a & ~b;      // a에서 b를 뺀 차집합
int toggled = a ^ b;       // a와 b 중 한 쪽에만 포함된 집합 (여집합)

집합 크기

집합의 크기 (Cardinality)를 쉽게 구하는 방법은 딱히 없다. 그냥 일일이 비트를 확인해서 켜진 개수를 세야한다.

int cardinality(int a) {
  if (a == 0) return 0;
  return (a % 2) + cardinality(a / 2);
}

모듈러 연산과 나누기 연산은 비싸니까 어떻게 비트로 좀 쪼개면 다음과 같다.

int cardinality(int a) {
  if (a == 0) return 0;
  return (a & 1) + cardinality(a >> 1);
}

더 최적화할 수 있는 방법이 많지만 굉장히 어렵다고 한다.

다행히도 컴파일러가 몇몇 비트 연산과 관련된 내장 함수를 제공한다.

int card = __builtin_popcount(picked);  // gcc/g++
int card = __popcnt(picked);            // Visual C++

최소 원소 찾기 (최하위 비트 찾기)

최하위 비트부터 원소 순서가 매겨진다고 할 때, 처음으로 켜진 최하위 비트만 구하려면 다음과 같이 하면 된다.

int firstPick = picked & -picked;

이건 앞의 2의 제곱수 판별 (1) 방법과 같다. 즉, 가장 오른쪽(최하위) 1만 남기고 나머지는 다 삭제하는 연산과 같다.

최소 원소 지우기 (최하위 비트 지우기)

최하위 1인 비트만 없애는 연산이 종종 필요한데, 이는 위의 2의 제곱수 판별 (2)의 방법과 같다.

picked = picked & (picked - 1);

모든 부분 집합 순회하기

모든 원소가 아니라 부분 집합 을 순회하는 방법이다. 예를 들어 picked{1, 2, 4} 라면 {1}, {2}, {4}, {1, 2}, {1, 4}, {2, 4}, {1, 2, 4} 를 순회하는 방법이다.

for (int subset = picked; subset; subset = ((subset - 1) & picked)) {
  ...
}

subset 은 전체 집합 picked 에서 시작해서 아무것도 선택되지 않은 공집합(즉 0)이 될 때까지 반복한다. subset - 1 을 하면 (1) 켜져 있던 최하위 비트는 꺼지고 (2) 꺼진 최하위 비트의 모든 하위 비트는 켜지게 된다. 예를 들어 001001000 에서 1을 빼면 001000111 이 된다. (subset - 1) & picked 는 이 값과 원래 집합의 교집합 을 구하는 것과 동일한 의미이고, 이는 곧 (subset - 1) 을 통해 켜진 비트들 중 picked 에 속하지 않는 원소들은 모두 제거되는 것과 동일하다. 따라서 이 연산을 반복하면 picked 의 모든 부분 집합을 방문하게 된다.

우선순위 큐

특정한 제약 조건이 있는 경우에 O(1)의 우선순위 큐를 만들 수 있다.

  1. 우선순위의 범위가 정해져 있는 경우. (예: 1부터 140 사이의 정수)
  2. 우선순위가 같은 원소에 대해서는 순서가 상관없는 경우.

이런 경우에 우선순위를 비트 연산으로 O(1) 만에 구할 수 있다.

좀더 구체적으로 우선순위가 1부터 140 사이의 정수라고 하자. 각 우선순위마다 원소를 담을 큐 140개(같은 우선순위에 대해서는 순서 상관 없음)를 만들고 , 각 큐에 원소가 있는지 여부를 비트마스크로 표현할 수 있다. 140개의 불린 값을 64비트 정수 세 개에 저장하면, 앞서 살펴본 최하위 비트를 찾는 연산을 통해 모든 큐를 뒤질 필요 없이 가장 우선순위가 높은 원소가 어디 있는지 바로 찾을 수 있다.

참고로 이런 방식의 우선순위 큐가 리눅스 커널의 프로세스 관리를 위해 구현되어 사용된 적이 있다.

Last Update: 2023-04-18 08:12:13 PM