비트 조작 트릭
비트 조작은 정수를 이진수 비트의 나열로 보고 AND, OR, XOR, NOT, 시프트 같은 연산으로 비트를 직접 다루는 기법입니다. 정수 하나가 비트 수십 개를 담고 있어서, 비트 연산 한 번이 여러 값을 동시에 처리하는 효과를 냅니다. 그래서 짧고 빠른 코드가 나오고, 메모리를 아끼는 데에도 씁니다.
비트 조작의 매력은 몇 가지 관용구(트릭)에 있습니다. n & (n - 1)로 가장 낮은 1을 지우고, XOR로 쌍을 이룬 값을 지우고, 정수 하나로 집합을 표현하는 비트마스크가 대표적입니다. 이 토픽에서는 연산자가 비트에 하는 일, n & (n - 1) 트릭, 비트마스크 집합을 단계별로 봅니다.
핵심 개념
비트 연산자
AND(&)는 둘 다 1일 때, OR(|)은 하나라도 1일 때, XOR(^)은 서로 다를 때 1입니다. NOT(~)은 모든 비트를 뒤집습니다.
핵심 개념
시프트
고정 폭 정수에서 <<는 2의 거듭제곱을 곱한 효과(넘친 비트는 버려짐)를, >>는 2의 거듭제곱으로 내림 나눗셈한 효과를 냅니다.
핵심 개념
n & (n - 1)
n의 가장 낮은 1 하나를 지운 값입니다. n - 1은 가장 낮은 1이 0이 되고 그 아래가 모두 1이 되기 때문에 AND하면 그 부분이 사라집니다.
핵심 개념
2의 거듭제곱 판별
2의 거듭제곱은 1이 하나뿐이라 n & (n - 1)이 0입니다. n이 양수일 때만 이 판별이 맞습니다.
핵심 개념
XOR의 성질
a ^ a = 0이고 a ^ 0 = a이며 교환법칙과 결합법칙이 성립합니다. 그래서 쌍으로 나오는 값은 모두 사라지고 홀로 나온 값만 남습니다.
핵심 개념
비트마스크
정수의 각 비트를 집합의 원소 하나로 대응시킵니다. 추가는 |, 삭제는 & ~, 확인은 &, 토글은 ^로 한 번에 처리합니다.
비트 조작의 배경
컴퓨터는 모든 값을 비트로 저장하므로 비트 연산은 CPU가 명령어로 직접 지원하는 기본 연산입니다. C 언어는 비트 연산자를 언어에 포함했고, 브라이언 커니핸(Brian Kernighan)과 데니스 리치(Dennis Ritchie)의 "The C Programming Language"에는 2의 보수 체계에서 x &= (x - 1)이 x의 가장 오른쪽 1비트를 지우는 이유를 설명하고 이를 이용해 비트 세기를 빠르게 만들라는 연습 문제가 실려 있습니다. 비트 트릭을 모아 놓은 책으로 헨리 워런(Henry S. Warren Jr.)의 "Hacker's Delight"가 널리 알려져 있습니다.
숫자로 확인하기
자주 하는 오해
면접에서 이렇게 물어요
면접 질문
정수가 2의 거듭제곱인지 비트로 판별하려면?
n > 0이고 (n & (n - 1)) == 0이면 2의 거듭제곱입니다. 2의 거듭제곱은 1이 하나뿐이고, n - 1은 그 1을 0으로 바꾸고 아래 비트를 모두 1로 만들기 때문입니다.
면접 질문
한 번만 나타나는 원소를 O(n) 시간 O(1) 공간으로 찾으려면?
나머지가 모두 두 번씩 나온다면 모든 원소를 XOR합니다. 같은 값끼리 XOR하면 0이 되고 0과의 XOR은 그대로이므로 한 번 나온 값만 남습니다.
면접 질문
정수에서 1인 비트의 개수는 어떻게 세나요?
n &= n - 1을 n이 0이 될 때까지 반복하며 횟수를 셉니다. 1의 개수만큼만 반복합니다. 자바에는 Integer.bitCount가 있습니다.
면접 질문
집합의 모든 부분집합을 비트로 어떻게 열거하나요?
원소가 n개이면 0부터 (1 << n) - 1까지의 정수 하나하나가 부분집합 하나이고, 정수의 i번 비트가 1이면 i번째 원소가 들어 있습니다.
실제 시스템에서는
함께 보면 좋은 토픽
참고 자료
Python — 비트 연산자와 대표 트릭
from functools import reduce from operator import xor a, b = 0b01101010, 0b00111100 # 106, 60 print(a & b, a | b, a ^ b, a << 1, a >> 2, ~a) # 40 126 86 212 26 -107 def is_pow2(n): return n > 0 and n & (n - 1) == 0 # 가장 낮은 1을 지우면 0 print([is_pow2(n) for n in (8, 12, 1, 0)]) # [True, False, True, False] def popcount(n): c = 0 while n: n &= n - 1 # 1을 하나씩 지운다 c += 1 return c print(popcount(13), 12 & -12) # 3 4 (13 = 1101, 12의 가장 낮은 1은 4) print(reduce(xor, [4, 1, 2, 1, 2])) # 4 (쌍은 사라지고 하나만 남는다)
AND, OR, XOR, 시프트, NOT은 정수의 각 비트를 한꺼번에 다루는 연산자입니다. 파이썬 정수는 길이 제한이 없고 음수는 무한히 긴 2의 보수로 다루므로 ~106은 -107이 됩니다(~x = -x - 1). n & (n - 1)은 n의 가장 낮은 1 하나를 지운 값입니다. 2의 거듭제곱은 1이 하나뿐이라 지우면 0이 되므로 n이 양수이고 이 값이 0이면 2의 거듭제곱입니다. n > 0을 빠뜨리면 0도 통과하는 점에 주의합니다. 같은 식을 0이 될 때까지 반복하며 횟수를 세면 1의 개수가 나옵니다. 13은 1101이라 세 번에 0이 됩니다. n & -n은 가장 낮은 1만 남기는 식입니다. 음수가 2의 보수로 표현되어 -n이 n의 비트를 반전해 1을 더한 값이기 때문입니다. XOR은 같은 값끼리 0이 되고 0과는 그대로이므로, 한 원소만 한 번 나오고 나머지는 두 번씩 나오는 배열에서 모두 XOR하면 한 번 나온 값이 남습니다. 시각화의 비트 연산자 탭과 n & (n-1) 트릭 탭이 보여 주는 과정과 같습니다.
Python — 비트마스크로 집합 다루기
mask = 0 mask |= 1 << 2 # C 추가 mask |= 1 << 4 # E 추가 mask |= 1 << 6 # G 추가 print(mask, bin(mask), bool(mask & (1 << 4)), bool(mask & (1 << 3))) # 84 0b1010100 True False mask ^= 1 << 4 # E 토글 (있었으므로 빠진다) mask &= ~(1 << 2) # C 삭제 print(mask) # 64 (G만 남음) items = ['A', 'B', 'C'] print([[items[i] for i in range(3) if m >> i & 1] for m in range(1 << 3)]) # [[], ['A'], ['B'], ['A', 'B'], ['C'], ['A', 'C'], ['B', 'C'], ['A', 'B', 'C']]
비트마스크는 정수의 각 비트를 집합의 원소 하나로 보는 방법입니다. 원소를 넣을 때는 1 << i를 OR하고, 확인할 때는 mask & (1 << i)이 0이 아닌지 보고, 토글은 XOR, 삭제는 ~(1 << i)와 AND합니다. 위 흐름은 시각화의 비트마스크 집합 탭과 같고 마지막에 G만 남습니다. 원소가 n개이면 부분집합은 2^n개이고, 0부터 (1 << n) - 1까지의 정수가 모든 부분집합에 하나씩 대응합니다. 그래서 range(1 << n)을 도는 반복문 하나로 모든 부분집합을 열거할 수 있고, 원소가 적은 문제에서 상태를 정수 하나로 줄이는 데 자주 씁니다.
Java — 연산자와 시프트의 차이
int a = 106, b = 60; System.out.println((a & b) + " " + (a | b) + " " + (a ^ b) + " " + (a << 1) + " " + (a >> 2) + " " + (~a)); // 40 126 86 212 26 -107 System.out.println((-8 >> 1) + " " + (-8 >>> 1)); // -4 2147483644 (>> 는 부호 비트로 채우고, >>> 는 0으로 채운다) System.out.println(Integer.bitCount(13) + " " + Integer.toBinaryString(106)); // 3 1101010 int n = 8; System.out.println(n > 0 && (n & (n - 1)) == 0); // true (괄호가 꼭 필요하다)
자바의 비트 연산자도 같은 방식으로 동작하지만 int는 32비트로 고정되어 있습니다. 오른쪽 시프트는 두 가지입니다. >>는 부호 비트를 그대로 채워 음수가 음수로 남고 -8 >> 1은 -4가 되며, >>>는 항상 0으로 채워 -8 >>> 1은 2147483644가 됩니다. 괄호도 중요합니다. 자바와 C에서는 비교 연산자(==)가 비트 AND(&)보다 우선순위가 높아서 n & (n - 1) == 0처럼 쓰면 n & ((n - 1) == 0)으로 묶여 자바에서는 컴파일 오류가 나고 C에서는 의도와 다르게 계산됩니다. 반드시 (n & (n - 1)) == 0으로 괄호를 칩니다. 파이썬은 우선순위가 반대라 괄호가 없어도 되지만 같은 습관을 들이는 것이 안전합니다. 표준 라이브러리의 Integer.bitCount는 1의 개수를 한 번에 세어 줍니다.
Q1
양의 정수 n이 2의 거듭제곱인지 판별하는 비트 연산 조건은?
Q2
배열에서 한 원소만 한 번 나타나고 나머지는 모두 두 번씩 나타날 때, 그 원소를 O(n)에 O(1) 공간으로 찾는 방법은?
Q3
비트마스크 mask에서 i번째 원소가 집합에 포함되어 있는지 확인하는 식은?