Fisher-Yates 셔플 알고리즘: 무작위 정렬에 난수만으로는 부족한 이유

"무작위 정렬" 버튼을 눌렀을 때, 결과는 정말 무작위일까요? 많은 사람들이 난수 함수만 호출하면 공정한 무작위 순열을 얻을 수 있다고 생각합니다. 하지만 실제로는 잘못된 구현 방식이 특정 순열을 더 자주 출현하게 만드는 숨겨진 편향을 일으킵니다. 이 글에서는 올바른 무작위 셔플 알고리즘인 Fisher-Yates Shuffle의 원리를 설명합니다.

1. "공정한 무작위 순열"이란 무엇인가?

3개의 요소 [A, B, C]가 있다면, 가능한 모든 순열은 3! = 6가지입니다:

ABC  ACB  BAC  BCA  CAB  CBA

"공정한 셔플"은 이 6가지 순열이 각각 1/6 ≈ 16.67%의 확률로 나타나는 것을 의미합니다. 어떤 구현에서 ABC가 20%의 확률로 나타나고 나머지가 16%라면, 그것은 "무작위처럼 보이지만" 통계적으로 편향된 셔플입니다.

2. 흔한 함정: sort() + Math.random()

많은 개발자들이 작성해본 코드 (JavaScript):

// 흔하지만 편향이 있는 방법
array.sort(() => Math.random() - 0.5);

합리적으로 보이지만 실제로는 심각한 편향 문제가 있습니다:

  • 정렬 알고리즘은 일관된 비교를 전제로 한다: 퀵소트, 병합 정렬 등은 동일한 두 요소를 여러 번 비교할 수 있으며, 매번 동일한 결과를 기대합니다. 무작위 비교 함수는 매 호출마다 결과가 뒤집힐 수 있어 알고리즘이 예기치 않은 경로를 취하게 됩니다.
  • 수학적으로 균일 분포를 보장할 수 없다: 비교 정렬의 시간 복잡도는 O(n log n)으로, n log n번의 비교만 수행합니다. n개 요소의 순열은 n!가지인데, n!이 비교 횟수로 구분할 수 있는 상태 수보다 훨씬 크면 일부 순열이 "합쳐져서" 더 높은 확률로 출현합니다.
통계적 검증
[1,2,3]에 sort+random을 100만 번 실행하면, 일부 순열이 균등한 16.67%가 아닌 약 20%로 출현하는 것을 확인할 수 있습니다. 편향이 작더라도 공정성이 필요한 추첨에서는 허용할 수 없습니다.

3. Fisher-Yates 알고리즘의 원리

Fisher-Yates Shuffle(현대 버전은 Knuth Shuffle이라고도 함)은 1938년 Ronald Fisher와 Frank Yates가 고안하고, Donald Knuth가 《컴퓨터 프로그래밍의 예술》에서 현대적인 형태로 발전시켰습니다.

알고리즘 단계 (n개 요소 배열):

  1. 마지막 요소(인덱스 n-1)에서 시작하여 인덱스 1까지 역방향으로 순회합니다.
  2. 현재 인덱스 i에 대해 [0, i](양 끝 포함) 범위에서 무작위로 인덱스 j를 선택합니다.
  3. array[i]array[j]를 교환합니다.
  4. i-1로 이동하여 i = 1이 될 때까지 반복합니다.
// Fisher-Yates Shuffle (JavaScript 올바른 구현)
function shuffle(array) {
    const a = [...array]; // 원본 배열 수정 안 함
    for (let i = a.length - 1; i > 0; i--) {
        const j = Math.floor(Math.random() * (i + 1));
        [a[i], a[j]] = [a[j], a[i]]; // 교환
    }
    return a;
}

4. Fisher-Yates가 균일 분포를 보장하는 이유

[A, B, C]를 예로 확률을 단계별로 분석합니다:

단계작업확률
i=2[0,1,2]에서 j 선택 — 3번째 위치의 요소 결정각 1/3
i=1[0,1]에서 j 선택 — 2번째 위치의 요소 결정각 1/2
i=01번째 위치는 남은 요소로 확정1

임의의 특정 순열이 출현할 확률:

P(특정 순열) = 1/3 × 1/2 × 1 = 1/6

수학적으로 완전히 균일합니다. n개 요소에 대해 각 순열의 출현 확률은 정확히 1/n!입니다 (난수 함수 자체가 균일 분포라는 전제 하에).

5. 그룹화 구현: 셔플에서 배분으로

"무작위 그룹화"는 "셔플 + 균등 배분"의 두 단계입니다:

function randomGroup(names, groupCount) {
    const shuffled = shuffle(names);               // 1단계: Fisher-Yates 셔플
    const groups = Array.from({ length: groupCount }, () => []);
    shuffled.forEach((name, i) => {
        groups[i % groupCount].push(name);          // 2단계: 순환 배분
    });
    return groups;
}

순환 배분(i % groupCount)으로 각 그룹의 인원 차이는 최대 1명입니다. 10명을 3그룹으로 나누면 4·3·3명이 되며, 4·4·2 같은 불균등한 결과는 발생하지 않습니다.

6. 흔한 실수 정리

실수문제올바른 방법
array.sort(() => Math.random() - 0.5)통계적 편향Fisher-Yates 사용
각 요소에 난수 키를 할당 후 정렬이론상 맞지만 O(n log n)Fisher-Yates는 O(n)
처음 k개만 무작위로 선택완전한 셔플이 아님Fisher-Yates 처음 k번 = 비복원 추출
같은 위치를 여러 번 무작위 교환교환 확률이 불균일각 위치는 한 번만 교환
응용: 부분 셔플 = 비복원 추출
Fisher-Yates의 처음 k번만 실행하면 n개 요소에서 k개를 비복원으로 무작위 추출할 수 있습니다. 시간 복잡도는 O(k)로, 전체 배열을 셔플할 필요가 없습니다. 추첨이나 무작위 문제 출제에 최적입니다.

7. 요약

셔플은 단순해 보이지만 "무작위처럼 보이는 것"과 "통계적으로 균일한 것"은 다릅니다. Fisher-Yates 알고리즘은 O(n) 시간 복잡도와 수학적 균일성 보장을 겸비하여, 공정성이 중요한 모든 상황에서 표준 선택이 되었습니다.

직접 무작위 그룹화를 체험하고 싶다면 이 사이트의 목록 그룹화 도구를 이용해 보세요. 이름을 입력하면 공정한 셔플과 그룹 분배가 즉시 이루어집니다.