0Pricing
DSA Interview Prep · 강의

단일 숫자와 XOR의 성질

XOR의 자기 역원 성질을 사용해 다른 모든 원소가 두 번씩 나타나는 리스트에서 한 번만 나타나는 원소를 찾고, 이를 single-number-II와 III으로 확장합니다.

단일 숫자와 XOR의 성질은(는) CoddyKit의 무료 DSA Interview Prep 강의입니다. 이것은 4개 중 2번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 DSA Interview Prep 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. DSA Interview Prep 강의에는 총 4개의 강의가 포함되어 있습니다.

단일 숫자 문제

단일 숫자 문제(LeetCode 136)는 다음과 같습니다. 모든 원소가 정확히 두 번씩 등장하고 하나의 원소만 한 번 등장하는 배열이 주어질 때, 한 번만 등장하는 원소를 찾으십시오. O(n) 시간 및 O(1) 공간 제약 때문에 해시 맵(O(n) 공간)과 정렬(O(n log n) 시간 또는 정렬에 O(n) 공간)은 사용할 수 없습니다.

우아한 해법은 XOR을 사용하는 것입니다. 모든 원소를 차례로 XOR하십시오. 같은 원소끼리는 상쇄되고(a ^ a = 0) XOR은 교환법칙과 결합법칙을 만족하므로, 쌍을 이루는 모든 원소가 사라지고 한 번만 등장한 원소만 남습니다. 이는 모든 경쟁 프로그래밍 문제를 통틀어 가장 만족스러운 O(n)/O(1) 해법 중 하나입니다.

def single_number(nums):
    result = 0
    for n in nums:
        result ^= n
    return result

# All pairs cancel, leaving the lone element
print(single_number([2, 2, 1]))              # 1
print(single_number([4, 1, 2, 1, 2]))        # 4
print(single_number([1]))                    # 1
print(single_number([7, 3, 5, 3, 7]))        # 5

# Even more concise with functools.reduce
from functools import reduce
from operator import xor
print(reduce(xor, [2, 2, 1]))  # 1

XOR이 작동하는 이유: 세 가지 핵심 성질

XOR의 강력함은 다음 세 가지 대수적 성질이 함께 작용하는 데서 나옵니다.

  • 자기 역원성: a ^ a = 0 — 같은 값은 서로 상쇄됩니다
  • 항등원: a ^ 0 = a — 0과 XOR하면 값이 변하지 않습니다
  • 교환법칙과 결합법칙: 순서와 그룹화는 결과에 영향을 주지 않습니다

이 세 가지 성질을 함께 적용하면 다중 집합에서 짝수 번 등장하는 모든 원소는 0으로 소거되고, 홀수 번 등장하는 원소만 남습니다. 단일 숫자 I에서는 정확히 하나의 원소가 한 번(홀수 번) 등장하므로, 그 원소가 XOR 결과입니다.

# Demonstrating the three XOR properties
print('Self-inverse: a ^ a = 0')
for a in [5, 13, 255, 0]:
    print(f'  {a} ^ {a} = {a ^ a}')

print('Identity: a ^ 0 = a')
for a in [5, 13, 0, 1024]:
    print(f'  {a} ^ 0 = {a ^ 0}')

print('Commutativity and Associativity:')
a, b, c = 3, 5, 7
print(f'  a^b^c = {a^b^c}')
print(f'  c^a^b = {c^a^b}')  # same result
print(f'  (a^b)^c = {(a^b)^c}')
print(f'  a^(b^c) = {a^(b^c)}')  # same result

단일 숫자 추적

상쇄가 실제로 어떻게 일어나는지 확인하기 위해 [4, 1, 2, 1, 2]를 단계별로 살펴보겠습니다. 모든 원소를 XOR하면 4 ^ 1 ^ 2 ^ 1 ^ 2입니다. XOR은 교환법칙을 만족하므로 (1 ^ 1) ^ (2 ^ 2) ^ 4 = 0 ^ 0 ^ 4 = 4와 같이 순서를 바꿀 수 있습니다. 쌍을 이루는 원소는 상쇄되고 4만 남습니다.

실제 알고리즘에서는 순서를 바꾸지 않고 왼쪽에서 오른쪽으로 XOR합니다. 그러나 교환법칙과 결합법칙에 따라 순서가 결과에 영향을 주지 않으므로 최종 결과는 같습니다. 쌍을 어디에서든 마음속으로 묶어도 모두 상쇄됩니다.

nums = [4, 1, 2, 1, 2]
result = 0
print(f'Start: result = {result} ({bin(result)})')
for n in nums:
    prev = result
    result ^= n
    print(f'XOR {n:2d}: {bin(prev):8s} ^ {bin(n):6s} = {bin(result):8s} = {result}')
print(f'Final: {result}')  # 4

# Alternative: show pair cancellation
print('\nMath view:')
print('4 ^ 1 ^ 2 ^ 1 ^ 2')
print('= 4 ^ (1^1) ^ (2^2)')
print('= 4 ^  0   ^  0')
print('= 4')

단일 숫자 II: 모든 원소가 세 번씩 등장하는 경우

단일 숫자 II(LeetCode 137)는 다음과 같습니다. 하나의 원소만 한 번 등장하고 나머지 모든 원소가 세 번씩 등장합니다. XOR만으로는 해결할 수 없습니다. 세 번씩 등장할 때는 쌍이 상쇄되지 않기 때문입니다. 대신 모든 숫자에서 각 비트가 몇 번 등장하는지 셉니다. 대상 원소에 해당 비트가 있으면 1이 더해지고, 세 번 등장하는 원소에 해당 비트가 있으면 3이 더해집니다. 각 비트의 개수를 3으로 나눈 나머지를 취하면 대상 원소의 비트만 분리할 수 있습니다.

두 정수 변수 ones와 twos를 사용하여 이 과정을 3을 법으로 하는 비트 단위 카운터로 시뮬레이션할 수 있습니다. 이는 디지털 논리 접근법입니다. ones는 홀수 번 등장한 비트를 2를 법으로 하여 저장하고, twos는 두 번 등장한 비트를 3을 법으로 하여 저장합니다.

def single_number_II(nums):
    ones, twos = 0, 0
    for n in nums:
        ones = (ones ^ n) & ~twos   # bits seen 1 mod 3 times
        twos = (twos ^ n) & ~ones   # bits seen 2 mod 3 times
    return ones  # bits seen exactly once

print(single_number_II([2, 2, 3, 2]))    # 3
print(single_number_II([0, 1, 0, 1, 0, 1, 99]))  # 99

# Simpler but O(32) bit-by-bit approach
def single_number_II_simple(nums):
    result = 0
    for bit in range(32):
        total = sum((n >> bit) & 1 for n in nums)
        if total % 3 == 1:
            result |= (1 << bit)
    return result

print(single_number_II_simple([2, 2, 3, 2]))  # 3

단일 숫자 III: 두 원소가 한 번씩 등장하는 경우

단일 숫자 III(LeetCode 260)는 다음과 같습니다. 두 원소가 각각 한 번씩 등장하고 나머지 모든 원소는 두 번씩 등장합니다. 모든 원소를 XOR하면 두 고유 원소의 XOR인 a ^ b를 얻습니다. a ≠ b이므로 a ^ b에는 1인 비트가 하나 이상 있습니다. diff = xor_all & (-xor_all)을 사용하여 a ^ b의 가장 낮은 1비트를 찾으십시오.

이 비트는 a 또는 b 중 정확히 하나에만 1로 설정되어 있습니다. 해당 비트가 설정되어 있는지에 따라 모든 숫자를 두 그룹으로 나눕니다. 각 그룹을 따로 XOR하면 쌍을 이루는 원소는 상쇄되고, 한 그룹에는 a가 다른 그룹에는 b가 남습니다.

def single_number_III(nums):
    xor_all = 0
    for n in nums:
        xor_all ^= n              # xor_all = a ^ b

    diff = xor_all & (-xor_all)  # isolate lowest differing bit

    a = 0
    for n in nums:
        if n & diff:              # group 1: has the diff bit set
            a ^= n
    b = xor_all ^ a              # a ^ b ^ a = b
    return [a, b]

print(sorted(single_number_III([1, 2, 1, 3, 2, 5])))   # [3, 5]
print(sorted(single_number_III([-1, 0])))               # [-1, 0]
print(sorted(single_number_III([0, 1])))                # [0, 1]

XOR로 누락된 숫자 찾기

누락된 숫자 문제(LeetCode 268)는 다음과 같습니다. 0부터 n까지의 서로 다른 숫자 중 일부가 원소로 주어지는 길이 n의 배열에서 누락된 숫자를 찾으십시오. 배열의 모든 숫자와 0부터 n까지의 모든 숫자를 함께 XOR합니다. 쌍을 이루는 숫자는 상쇄되고 누락된 숫자만 남습니다. 이 방법의 시간 복잡도는 O(n), 공간 복잡도는 O(1)입니다.

또는 산술 합 공식인 expected = n*(n+1)//2를 사용한 다음 실제 합을 빼도 됩니다. 두 방법 모두 O(n)/O(1)입니다. XOR은 고정 너비 정수를 사용하는 언어에서 발생할 수 있는 정수 오버플로를 피하므로 더 견고합니다.

def missing_number_xor(nums):
    n = len(nums)
    result = n              # start with n (the last expected value)
    for i, num in enumerate(nums):
        result ^= i ^ num   # XOR with both index and value
    return result

def missing_number_sum(nums):
    n = len(nums)
    expected = n * (n + 1) // 2
    return expected - sum(nums)

for nums, expected in [([3,0,1], 2), ([0,1], 2), ([9,6,4,2,3,5,7,0,1], 8)]:
    xor_ans = missing_number_xor(nums)
    sum_ans = missing_number_sum(nums)
    print(f'nums={nums}: XOR={xor_ans}, Sum={sum_ans}, expected={expected}')

임시 변수 없이 XOR로 교환하기

XOR을 사용하면 임시 변수 없이 두 변수를 교환할 수 있습니다. 핵심은 a ^ b ^ a = b와 a ^ b ^ b = a입니다. XOR 대입을 세 번 연속으로 적용하십시오. 먼저 a ^= b를 실행하고, 다음으로 b ^= a를 실행한 뒤, 마지막으로 a ^= b를 실행합니다. 세 번의 연산이 끝나면 a에는 원래 b의 값이, b에는 원래 a의 값이 들어 있습니다.

중요한 주의 사항이 있습니다. a와 b가 같은 메모리 위치를 참조하는 경우, 즉 같은 변수인 경우에는 이 방법이 작동하지 않습니다. 이때 a ^= a가 a를 0으로 만들고 값이 손실됩니다. Python에서는 튜플 언패킹(a, b = b, a)이 더 안전하고 명확합니다. XOR 교환은 주로 추가 메모리를 사용할 수 없는 C/임베디드 환경에서 유용합니다.

# XOR swap
a, b = 17, 42
print(f'Before: a={a}, b={b}')
a ^= b   # a = 17 ^ 42
b ^= a   # b = 42 ^ (17 ^ 42) = 17
a ^= b   # a = (17 ^ 42) ^ 17 = 42
print(f'After:  a={a}, b={b}')   # a=42, b=17

# The caveat: same variable/reference => broken
c = 99
# If a and b pointed to same value:
c ^= c   # c = 0  (destroyed!)
print(f'Same-variable XOR swap: c={c}')  # 0, not 99

# Pythonic swap: always prefer this
a, b = 17, 42
a, b = b, a   # safe, clear, handles aliases
print(f'Pythonic: a={a}, b={b}')

해싱과 체크섬에서의 XOR

XOR은 체크섬과 패리티 검사에서 흔히 사용되는 구성 요소입니다. 데이터 블록의 모든 바이트를 XOR하면 1바이트 체크섬이 생성됩니다. 전송 중 단일 비트가 뒤집히면 체크섬이 변경되어 오류를 감지할 수 있습니다. 이는 CRC보다 단순하면서도 모든 단일 비트 오류를 감지합니다.

XOR은 RAID-5 패리티에도 사용됩니다. 드라이브 세 개가 있다면 두 드라이브의 데이터를 XOR한 결과를 세 번째 드라이브에 저장합니다. 드라이브 하나에 장애가 발생하면 나머지 두 드라이브를 XOR하여 손실된 데이터를 복원할 수 있습니다. 이는 단일 숫자 논리를 역으로 적용한 것과 정확히 같습니다. 패리티 드라이브는 세 드라이브를 모두 XOR했을 때 무엇이 상쇄되는지를 인코딩하는 '고유 원소'입니다.

# Simple XOR checksum
def xor_checksum(data):
    result = 0
    for byte in data:
        result ^= byte
    return result

data = [0x48, 0x65, 0x6C, 0x6C, 0x6F]  # 'Hello' in ASCII
checksum = xor_checksum(data)
print(f'Checksum: {hex(checksum)}')

# Detect corruption
corrupted = data[:]
corrupted[2] ^= 0xFF   # flip all bits of 3rd byte
new_checksum = xor_checksum(corrupted)
print(f'Original checksum: {hex(checksum)}')
print(f'Corrupted checksum: {hex(new_checksum)}')
print(f'Error detected: {checksum != new_checksum}')

# RAID-5 parity recovery
d1 = [1, 0, 1, 1]
d2 = [0, 1, 1, 0]
parity = [d1[i] ^ d2[i] for i in range(4)]
recovered = [parity[i] ^ d2[i] for i in range(4)]  # recover d1
print(f'd1={d1}, parity={parity}, recovered={recovered}')

XOR과 부분 집합 문제

모든 부분 집합의 XOR을 계산해야 하는 부분 집합 문제에서 XOR이 등장합니다. 핵심 통찰은 n개의 원소가 있을 때 각 원소가 정확히 2^(n-1)개의 부분 집합에 포함된다는 것입니다. n > 1이면 모든 원소가 짝수 개의 부분 집합에 포함되므로 XOR 기여가 상쇄됩니다. 따라서 n > 1일 때 모든 부분 집합 XOR 결과를 다시 XOR한 값은 0입니다.

n == 1이면 공집합이 아닌 유일한 부분 집합은 원소 자체이므로 모든 부분 집합의 XOR 결과는 그 원소입니다. XOR의 성질과 개수 세기를 활용하는 이러한 사고방식은 고급 비트 조작 문제에서 평가됩니다.

from itertools import combinations
from functools import reduce
from operator import xor

def xor_of_all_subsets(arr):
    n = len(arr)
    total_xor = 0
    for r in range(1, n + 1):
        for subset in combinations(arr, r):
            subset_xor = reduce(xor, subset)
            total_xor ^= subset_xor
    return total_xor

# For n > 1, each element appears 2^(n-1) times (even) => cancels
# Result is always 0 for n > 1
for arr in [[1,2,3], [5,7], [1], [1,2,3,4]]:
    result = xor_of_all_subsets(arr)
    predicted = arr[0] if len(arr) == 1 else 0
    print(f'arr={arr}: XOR of all subsets = {result}, predicted = {predicted}')

면접 패턴: 고유성을 위한 XOR

문제에서 다음과 같이 말한다면 고유성을 위한 XOR 패턴을 인식하십시오. '모든 원소가 k번 등장하고, 하나의 원소만 m번 등장하며 m mod k != 0이다'. k=2, m=1인 경우(단일 숫자 I)에는 모든 원소를 XOR합니다. k=3, m=1인 경우(단일 숫자 II)에는 비트 개수를 3으로 나눈 나머지를 구합니다. 고유 원소가 두 개인 k=2, m=1인 경우(단일 숫자 III)에는 XOR한 다음 가장 낮은 차이 비트를 기준으로 나눕니다.

임의의 k에 대한 일반적인 방법은 각 비트가 등장한 총횟수를 세고 k로 나눈 나머지를 취하는 것입니다. 개수가 0이 아니면 해당 비트는 고유 원소에 속합니다. 이 방법은 어떤 k에 대해서도 O(32n) = O(n) 시간과 O(1) 공간으로 동작합니다.

def single_number_k_times(nums, k):
    '''Find the element that appears m times when all others appear k times.'''
    # Count each bit's occurrence and take mod k
    result = 0
    for bit in range(32):
        total = sum((n >> bit) & 1 for n in nums)
        if total % k != 0:
            result |= (1 << bit)
    # Handle negative 32-bit numbers
    if result >= (1 << 31):
        result -= (1 << 32)
    return result

# k=2, element appears once
print(single_number_k_times([2,2,1], 2))         # 1
# k=3, element appears once
print(single_number_k_times([2,2,3,2], 3))       # 3
# k=4, element appears once
print(single_number_k_times([1,1,1,1,7,2,2,2,2], 4))  # 7

자주 출제되는 XOR 면접 문제

단일 숫자 계열 외에도 XOR은 다음과 같이 자주 출제되는 문제에 등장합니다.

  • 차이 찾기(LC 389): 두 문자열의 모든 문자를 XOR하면 추가된 문자가 남습니다
  • 해밍 거리(LC 461): 두 숫자를 XOR한 뒤 결과에서 1비트의 개수를 셉니다
  • 전체 해밍 거리(LC 477): 모든 쌍에서 각 비트 위치의 0과 1 개수를 셉니다
  • 부분 배열의 XOR 질의(LC 1310): 구간 질의에 접두 XOR 배열을 사용합니다

각 경우에 XOR의 상쇄 성질이 중복을 제거하여 O(n²) 완전 탐색을 O(n)으로 줄입니다.

# Find the difference between two strings
def find_the_difference(s, t):
    result = 0
    for c in s + t:
        result ^= ord(c)
    return chr(result)

print(find_the_difference('abcd', 'abcde'))  # 'e'

# Hamming distance: count differing bits
def hamming_distance(x, y):
    diff = x ^ y
    count = 0
    while diff:
        count += diff & 1
        diff >>= 1
    return count
    # or: bin(x ^ y).count('1')

print(hamming_distance(1, 4))   # 2: 001 vs 100 differ in bits 0 and 2
print(hamming_distance(3, 1))   # 1: 011 vs 001 differ in bit 1

# Prefix XOR for range queries
def xor_queries(arr, queries):
    prefix = [0] * (len(arr) + 1)
    for i, v in enumerate(arr):
        prefix[i+1] = prefix[i] ^ v
    return [prefix[r+1] ^ prefix[l] for l, r in queries]

print(xor_queries([1,3,4,8], [[0,1],[1,2],[0,3],[3,3]]))

빠른 확인

이번 단원에서 배운 자료 구조 및 알고리즘 — 코딩 면접 준비 개념을 이해했는지 확인해 보십시오.

단원 요약

이번 단원에서는 다음을 배웠습니다. XOR의 자기 역원성(a ^ a = 0)으로 인해 모든 숫자를 함께 XOR하면 쌍을 이루는 원소가 상쇄되고 고유 원소만 남습니다. 또한 단일 숫자 II는 비트 개수를 3으로 나눈 나머지를 사용하고, 단일 숫자 III는 가장 낮은 차이 비트를 기준으로 원소를 나눕니다. 그리고 XOR은 누락된 숫자, 차이 찾기, 해밍 거리, 구간 XOR 질의도 해결합니다. 다음으로는 개별 비트를 설정하고, 지우고, 전환하고, 확인하기 위한 비트 마스크를 살펴보겠습니다.

자주 묻는 질문

“단일 숫자와 XOR의 성질” 강의는 무료인가요?

네 — “단일 숫자와 XOR의 성질” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 DSA Interview Prep 강의 전체를 잠금 해제할 수 있습니다. DSA Interview Prep 강의에는 총 4개의 강의가 포함되어 있습니다.

“단일 숫자와 XOR의 성질”에서 뭘 배우나요?

XOR의 자기 역원 성질을 사용해 다른 모든 원소가 두 번씩 나타나는 리스트에서 한 번만 나타나는 원소를 찾고, 이를 single-number-II와 III으로 확장합니다. 브라우저에서 직접 실행하는 실습 코드로 DSA Interview Prep을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

DSA Interview Prep을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 DSA Interview Prep은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 2번째 강의입니다.

“단일 숫자와 XOR의 성질” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 DSA Interview Prep 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 DSA Interview Prep 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. 비트 연산자: AND, OR, XOR, NOT, 시프트
  2. 단일 숫자와 XOR의 성질
  3. 비트 마스크: 설정, 해제, 전환, 확인
  4. 비트 세기, 누락된 숫자와 비트 뒤집기
← DSA Interview Prep(으)로 돌아가기