Fisher-Yates Shuffle - 배열을 랜덤하게 섞기
카드를 섞을 때, 정말로 완벽하게 무작위로 섞이고 있을까요?
저는 처음에 “배열을 랜덤하게 정렬하면 되지 않나?”라고 생각했습니다. 하지만 그 방법은 편향이 있다는 것을 알게 되었습니다!
// ❌ 잘못된 방법 (편향 발생!)
arr.sort(() => Math.random() - 0.5);
// ✅ 올바른 방법 (Fisher-Yates)
fisherYatesShuffle(arr);
Fisher-Yates shuffle은 1938년에 발표되어 80년 이상 사용되고 있는, 검증된 알고리즘입니다.
이 알고리즘은 통계학자 Ronald Fisher와 Frank Yates가 통계 연구를 위해 고안했으며, 1964년 Richard Durstenfeld이 컴퓨터 시대에 맞게 최적화한 현대판 알고리즘이 지금까지 사용되고 있습니다. Donald Knuth가 그의 유명한 저서 “The Art of Computer Programming”에서 “Algorithm P (Shuffling)”로 소개하면서 전 세계 개발자들의 표준이 되었습니다.
핵심 아이디어는 매우 간단합니다: 배열을 뒤에서부터 순회하면서, 각 위치의 원소를 그보다 앞의 임의 위치(자기 자신 포함)와 교환하는 것입니다. 이 간단한 과정만으로 n! 가지 모든 순열이 정확히 1/n! 확률로 생성되는 완벽한 무작위성을 보장합니다.
왜 Fisher-Yates를 배워야 할까요?
1. 진정한 무작위 셔플
// 잘못된 셔플: 특정 순열이 더 자주 나옴
const wrong = () => arr.sort(() => Math.random() - 0.5);
// Fisher-Yates: 모든 순열이 동일한 확률 (1/n!)
const correct = () => fisherYatesShuffle(arr);
// 3개 원소 [1,2,3]의 가능한 순열: 6가지 (3!)
// 잘못된 방법: 각 순열이 16.67%가 아닌 불균등한 확률
// Fisher-Yates: 각 순열이 정확히 16.67% (1/6)
2. 놀라울 정도로 빠름 (O(n))
// 1,000,000개 원소도 단 한 번의 순회로 완료!
const huge = Array.from({length: 1000000}, (_, i) => i);
fisherYatesShuffle(huge); // ~1ms
3. 실무에서 적용해 볼 수 있는 케이스
- 게임 개발 (카드 게임, 보드 게임)
- 데이터 과학 (무작위 샘플링, 교차 검증)
- 광고 시스템 (순서 랜덤화)
- 플레이리스트 셔플
- A/B 테스트 무작위 배정
⚠️ 흔한 실수: 잘못된 셔플 방법
많은 개발자들이 배열을 섞을 때 직관적으로 sort()와 Math.random()을 조합하여 사용합니다. 언뜻 보기에는 합리적인 접근처럼 보이지만, 이는 심각한 편향을 발생시키는 잘못된 방법입니다. 이 섹션에서는 왜 이런 방법들이 문제가 되는지, 그리고 어떻게 피해야 하는지 자세히 설명합니다.
함정 1: sort + random (가장 흔한 실수!)
이 방법은 스택오버플로우나 블로그에서 가장 자주 볼 수 있는 “잘못된” 답변입니다. 많은 초보 개발자들이 이 방법을 사용하다가 나중에 문제를 발견하게 됩니다.
// ❌ 절대 이렇게 하지 마세요!
const arr = [1, 2, 3, 4, 5];
arr.sort(() => Math.random() - 0.5);
// 문제점:
// 1. 편향 발생 - 특정 순열이 다른 것보다 더 자주 나옴
// 2. 불안정 - 브라우저마다 다른 결과
// 3. 느림 - O(n log n)
왜 이 방법이 잘못되었을까요?
sort() 메서드는 비교 함수가 일관된(consistent) 결과를 반환한다고 가정합니다. 즉, 같은 두 원소를 비교할 때 항상 같은 결과를 내야 합니다. 하지만 Math.random() - 0.5는 호출할 때마다 다른 값을 반환하므로 이 가정을 깨뜨립니다.
정렬 알고리즘은 이런 비일관적인 비교 함수를 예상하지 않기 때문에, 일부 원소는 제대로 비교되지 않고, 결과적으로 특정 위치에 머무르거나 특정 순열이 과도하게 생성됩니다.
실제 실험:
// [1, 2, 3]을 10,000번 섞었을 때
const results = {
'[1,2,3]': 1650, // 16.5% (이상적: 16.67%)
'[1,3,2]': 1823, // 18.2% ❌ 편향!
'[2,1,3]': 1542, // 15.4% ❌ 편향!
'[2,3,1]': 1789, // 17.9%
'[3,1,2]': 1612, // 16.1%
'[3,2,1]': 1584 // 15.8% ❌ 편향!
};
함정 2: 범위를 잘못 설정
// ❌ 잘못된 구현
for (let i = 0; i < n; i++) {
const j = Math.floor(Math.random() * n); // 항상 전체 범위!
[arr[i], arr[j]] = [arr[j], arr[i]];
}
// 문제: 매번 전체 배열에서 선택 → 편향 발생
// 올바른 방법: i부터 끝까지만 선택
어떻게 동작하나요?
Fisher-Yates shuffle의 작동 원리는 놀라울 정도로 직관적입니다. 우리가 실제로 카드를 섞는 방식과 매우 유사하기 때문에 이해하기 쉽습니다.
알고리즘의 핵심은 “남은 카드 중에서 무작위로 하나씩 선택”하는 것입니다. 배열을 뒤에서부터 순회하면서, 각 위치에 올 원소를 그보다 앞의 위치(아직 선택되지 않은 원소들) 중에서 무작위로 선택합니다. 이렇게 하면 각 원소가 특정 위치에 올 확률이 정확히 동일해집니다.
카드 덱 비유
52장의 카드를 섞는다고 상상해보세요. 실제로 카드를 섞을 때 우리가 무의식적으로 하는 행동과 매우 비슷합니다:
1단계: 52장 중 무작위로 1장 선택 → 맨 뒤 위치에
2단계: 남은 51장 중 무작위로 1장 선택 → 뒤에서 두 번째에
3단계: 남은 50장 중 무작위로 1장 선택 → 뒤에서 세 번째에
...
52단계: 마지막 남은 1장 → 맨 앞에
완료! 모든 카드가 섞임 ✅
단계별 시각화
const arr = [1, 2, 3, 4, 5];
// 초기 상태
[1, 2, 3, 4, 5]
↑ i=4
// i=4: [0..4] 중 무작위 선택 (예: j=1)
[1, 2, 3, 4, 5]
↑ ↑
j=1 i=4
// 교환 후: [1, 5, 3, 4, 2] (인덱스 1과 4 교환)
// i=3: [0..3] 중 무작위 선택 (예: j=3)
[1, 5, 3, 4 | 2]
↑
j=i=3
// 교환 후: [1, 5, 3, 4 | 2] (자기 자신과 교환 = 변화 없음)
// i=2: [0..2] 중 무작위 선택 (예: j=0)
[1, 5, 3 | 4, 2]
↑ ↑
j=0 i=2
// 교환 후: [3, 5, 1 | 4, 2]
// i=1: [0..1] 중 무작위 선택 (예: j=1)
[3, 5 | 1, 4, 2]
↑
j=i=1
// 교환 후: [3, 5 | 1, 4, 2]
// i=0: [0..0] 중 무작위 선택 (j=0만 가능)
[3 | 5, 1, 4, 2]
↑
i=j=0
// 최종: [3, 5, 1, 4, 2] ✅
핵심 원리:
- 뒤에서부터 앞으로 진행
- 각 위치 i에서 0부터 i까지만 선택 (이미 섞인 부분 제외)
- 선택한 원소를 현재 위치와 교환
코드 구현
JavaScript 기본 구현
/**
* Fisher-Yates shuffle (현대판, Durstenfeld 버전)
* @param {Array} arr - 섞을 배열
* @returns {Array} - 섞인 배열 (원본 변경)
*/
function fisherYatesShuffle(arr) {
// 뒤에서부터 앞으로
for (let i = arr.length - 1; i > 0; i--) {
// 0부터 i까지 중 무작위 인덱스 선택
const j = Math.floor(Math.random() * (i + 1));
// 현재 위치(i)와 선택한 위치(j) 교환
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// 사용 예시
const deck = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
console.log(fisherYatesShuffle(deck));
// [3, 7, 1, 9, 5, 10, 2, 4, 8, 6] (무작위)
TypeScript 제네릭 버전
function fisherYatesShuffle<T>(arr: T[]): T[] {
const result = [...arr]; // 원본 보호
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
// 타입 안전성
const numbers: number[] = [1, 2, 3, 4, 5];
const shuffled: number[] = fisherYatesShuffle(numbers);
const words: string[] = ['apple', 'banana', 'cherry'];
const shuffledWords: string[] = fisherYatesShuffle(words);
암호학적으로 안전한 버전
/**
* 암호학적으로 안전한 Fisher-Yates shuffle
* (예측 불가능한 난수 생성)
*/
function cryptoShuffle(arr) {
const result = [...arr];
for (let i = result.length - 1; i > 0; i--) {
// crypto.getRandomValues()를 사용한 안전한 난수
const randomBuffer = new Uint32Array(1);
crypto.getRandomValues(randomBuffer);
const j = randomBuffer[0] % (i + 1);
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
// 보안이 중요한 경우 사용
const sensitiveData = ['password1', 'password2', 'password3'];
cryptoShuffle(sensitiveData);
디버깅 버전
function fisherYatesDebug(arr) {
console.log('초기 배열:', [...arr]);
const result = [...arr];
for (let i = result.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
console.log(`\n단계 ${result.length - i}:`);
console.log(` i=${i}, j=${j}`);
console.log(` 교환 전: [${result}]`);
console.log(` 교환: arr[${i}](${result[i]}) ↔ arr[${j}](${result[j]})`);
[result[i], result[j]] = [result[j], result[i]];
console.log(` 교환 후: [${result}]`);
console.log(` 확정: [${result.slice(i).join(', ')}]`);
}
console.log('\n최종 결과:', result);
return result;
}
// 실행
fisherYatesDebug([1, 2, 3, 4, 5]);
출력 예시:
초기 배열: [1, 2, 3, 4, 5]
단계 1:
i=4, j=1
교환 전: [1, 2, 3, 4, 5]
교환: arr[4](5) ↔ arr[1](2)
교환 후: [1, 5, 3, 4, 2]
확정: [2]
단계 2:
i=3, j=0
교환 전: [1, 5, 3, 4, 2]
교환: arr[3](4) ↔ arr[0](1)
교환 후: [4, 5, 3, 1, 2]
확정: [1, 2]
...
최종 결과: [3, 5, 4, 1, 2]
시간 복잡도: O(n)
Fisher-Yates shuffle의 가장 큰 장점 중 하나는 선형 시간에 실행된다는 것입니다. 이는 배열을 섞는 알고리즘 중 이론적으로 가능한 최선의 성능입니다.
왜 O(n)일까요?
배열을 단 한 번 순회하며 각 원소를 정확히 한 번씩만 처리합니다. 각 단계에서 수행하는 작업(난수 생성, 배열 접근, 교환)은 모두 O(1) 상수 시간이므로, 전체 알고리즘의 시간 복잡도는 O(n)이 됩니다.
이는 잘못된 sort() + random 방법의 O(n log n)보다 이론적으로 더 빠르며, 실제 실행 시간도 훨씬 짧습니다.
for (let i = arr.length - 1; i > 0; i--) { // n-1번 반복
const j = Math.floor(Math.random() * (i + 1)); // O(1)
[arr[i], arr[j]] = [arr[j], arr[i]]; // O(1)
}
// 총 시간: (n-1) × O(1) = O(n)
실제 성능
function benchmark(size) {
const arr = Array.from({length: size}, (_, i) => i);
console.time(`Fisher-Yates (${size}개)`);
fisherYatesShuffle(arr);
console.timeEnd(`Fisher-Yates (${size}개)`);
}
benchmark(1000); // ~0.01ms
benchmark(10000); // ~0.1ms
benchmark(100000); // ~1ms
benchmark(1000000); // ~10ms
다른 방법과 비교
| 방법 | 시간 복잡도 | 편향 | 실제 시간 (10,000개) |
|---|---|---|---|
| Fisher-Yates | O(n) | 없음 ✅ | ~0.1ms |
| sort() + random | O(n log n) | 있음 ❌ | ~1.5ms |
| 반복 스왑 (잘못된 구현) | O(n) | 있음 ❌ | ~0.1ms |
수학적 증명: 왜 편향이 없을까?
Fisher-Yates shuffle이 완벽한 무작위성을 보장한다는 것은 직관뿐만 아니라 수학적으로 엄밀하게 증명할 수 있습니다. 이 섹션에서는 왜 이 알고리즘이 편향 없는 완벽한 셔플인지 증명합니다.
모든 순열이 동일한 확률
Fisher-Yates는 n! 가지 순열이 각각 1/n! 확률로 생성됨을 보장합니다.
이것이 의미하는 바는 다음과 같습니다: 만약 배열에 5개의 원소가 있다면, 가능한 모든 배치 방법은 5! = 120가지입니다. Fisher-Yates를 사용하면 이 120가지 각각이 정확히 1/120 = 0.833…% 확률로 나타납니다. 단 하나의 배치도 더 자주 또는 덜 자주 나타나지 않습니다.
n=3일 때:
- 가능한 순열: 3! = 6가지
- 각 순열의 확률: 1/6 ≈ 16.67%
단계별 확률:
1단계: 3개 중 1개 선택 → 1/3 확률
2단계: 남은 2개 중 1개 선택 → 1/2 확률
3단계: 남은 1개 → 1/1 확률
전체 확률: 1/3 × 1/2 × 1/1 = 1/6 ✅
증명 (수학적 귀납법)
기저 사례 (n=1):
- 1개 원소는 이미 섞임
- 확률: 1/1! = 1 ✅
귀납 가정:
- n-1개 원소에 대해 Fisher-Yates가 올바름
귀납 단계 (n개):
P(특정 원소가 마지막 위치) = 1/n
남은 n-1개는 귀납 가정에 의해 올바르게 섞임
→ P(특정 순열) = 1/n × 1/(n-1)! = 1/n! ✅
실전 활용 예시
1. 카드 게임
// 카드 덱 생성 및 섞기
function createDeck() {
const suits = ['♠', '♥', '♦', '♣'];
const ranks = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'];
const deck = [];
for (const suit of suits) {
for (const rank of ranks) {
deck.push({suit, rank});
}
}
return fisherYatesShuffle(deck);
}
const deck = createDeck();
console.log(deck[0]); // {suit: '♦', rank: '7'} (무작위)
// 카드 나누기
function dealCards(deck, players, cardsPerPlayer) {
const hands = Array.from({length: players}, () => []);
for (let i = 0; i < cardsPerPlayer; i++) {
for (let p = 0; p < players; p++) {
hands[p].push(deck.pop());
}
}
return hands;
}
const hands = dealCards(deck, 4, 5); // 4명에게 5장씩
2. 플레이리스트 셔플
class MusicPlayer {
constructor(songs) {
this.originalOrder = songs;
this.shuffled = null;
this.currentIndex = 0;
}
shuffle() {
this.shuffled = fisherYatesShuffle([...this.originalOrder]);
this.currentIndex = 0;
return this.shuffled[0];
}
next() {
if (!this.shuffled) {
throw new Error('Please shuffle first');
}
this.currentIndex++;
if (this.currentIndex >= this.shuffled.length) {
// 끝에 도달하면 다시 섞기
this.shuffle();
}
return this.shuffled[this.currentIndex];
}
unshuffle() {
this.shuffled = null;
this.currentIndex = 0;
}
}
// 사용
const player = new MusicPlayer([
'Song A', 'Song B', 'Song C', 'Song D', 'Song E'
]);
player.shuffle(); // 'Song C' (무작위)
player.next(); // 'Song A' (무작위)
player.next(); // 'Song E' (무작위)
3. A/B 테스트 무작위 배정
function assignABTest(users, variants) {
// 사용자 ID를 섞어서 무작위 배정
const shuffled = fisherYatesShuffle([...users]);
const groupSize = Math.ceil(shuffled.length / variants.length);
const assignments = {};
shuffled.forEach((user, index) => {
const variantIndex = Math.floor(index / groupSize);
const variant = variants[Math.min(variantIndex, variants.length - 1)];
assignments[user.id] = variant;
});
return assignments;
}
// 사용
const users = [
{id: 1, name: 'Alice'},
{id: 2, name: 'Bob'},
{id: 3, name: 'Charlie'},
{id: 4, name: 'David'}
];
const assignments = assignABTest(users, ['A', 'B']);
// {1: 'B', 2: 'A', 3: 'A', 4: 'B'} (무작위)
4. 무작위 샘플링
/**
* 배열에서 n개의 무작위 샘플 추출
*/
function randomSample(arr, n) {
if (n > arr.length) {
throw new Error('Sample size cannot exceed array length');
}
const shuffled = fisherYatesShuffle([...arr]);
return shuffled.slice(0, n);
}
// 데이터 과학: 훈련/테스트 분할
const dataset = Array.from({length: 1000}, (_, i) => ({
id: i,
data: Math.random()
}));
const trainSize = Math.floor(dataset.length * 0.8); // 80%
const shuffled = fisherYatesShuffle([...dataset]);
const trainSet = shuffled.slice(0, trainSize);
const testSet = shuffled.slice(trainSize);
console.log(`Train: ${trainSet.length}, Test: ${testSet.length}`);
// Train: 800, Test: 200
함정과 주의사항
함정 1: 범위 설정 실수
// ❌ 잘못된 구현 1: 항상 전체 범위
for (let i = 0; i < n; i++) {
const j = Math.floor(Math.random() * n); // 항상 0~n-1
[arr[i], arr[j]] = [arr[j], arr[i]];
}
// 문제: 이미 섞인 부분도 다시 섞음 → 편향
// ❌ 잘못된 구현 2: 범위 하나 작음
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * i); // 0~i-1 (i 제외!)
[arr[i], arr[j]] = [arr[j], arr[i]];
}
// 문제: 자기 자신과 교환 불가 → 편향
// ✅ 올바른 구현
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1)); // 0~i (i 포함)
[arr[i], arr[j]] = [arr[j], arr[i]];
}
함정 2: sort()와 random 조합
// ❌ 가장 흔한 실수!
arr.sort(() => Math.random() - 0.5);
// 문제점:
// 1. 비교 함수가 일관성이 없음
// 2. 정렬 알고리즘의 가정 위반
// 3. 브라우저마다 다른 결과
// 4. 편향 발생 (특정 순열이 과대/과소 표현)
함정 3: 원본 배열 변경
// ❌ 원본이 변경됨
const original = [1, 2, 3, 4, 5];
fisherYatesShuffle(original);
console.log(original); // [3, 1, 5, 2, 4] - 변경됨!
// ✅ 원본 보호
function shuffleImmutable(arr) {
const copy = [...arr];
return fisherYatesShuffle(copy);
}
const original = [1, 2, 3, 4, 5];
const shuffled = shuffleImmutable(original);
console.log(original); // [1, 2, 3, 4, 5] - 안전!
함정 4: 의사난수 생성기의 한계
// Math.random()의 한계
// - 예측 가능 (보안 용도 부적합)
// - 긴 배열에서 모든 순열 불가능
// n=52 (카드 덱)일 때:
// - 가능한 순열: 52! ≈ 8×10^67
// - Math.random()의 주기: 2^53 ≈ 9×10^15
// 실제로는 극히 일부 순열만 생성 가능!
// ✅ 보안이 중요하면 crypto.getRandomValues() 사용
function cryptoShuffle(arr) {
const result = [...arr];
for (let i = result.length - 1; i > 0; i--) {
const randomBuffer = new Uint32Array(1);
crypto.getRandomValues(randomBuffer);
const j = randomBuffer[0] % (i + 1);
[result[i], result[j]] = [result[j], result[i]];
}
return result;
}
변형: Sattolo’s Algorithm
Fisher-Yates의 변형으로, 단일 사이클 순열만 생성합니다.
/**
* Sattolo's Algorithm
* 모든 원소가 원래 위치에서 이동하는 순열만 생성
*/
function sattoloShuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
// 차이점: i를 제외 (0부터 i-1까지만)
const j = Math.floor(Math.random() * i); // i+1이 아닌 i
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
// 차이점
const original = [1, 2, 3, 4, 5];
// Fisher-Yates: [1, 2, 3, 4, 5]도 가능 (원본과 같을 수 있음)
// Sattolo: [1, 2, 3, 4, 5]는 불가능 (모두 이동해야 함)
활용 사례:
- Secret Santa (자기 자신 제외)
- 라운드 로빈 스케줄링
- 순환 퀴즈 (다음 문제가 현재 문제와 다름)
검증: 편향 테스트
/**
* Fisher-Yates의 무작위성 검증
*/
function testRandomness(shuffleFunc, iterations = 100000) {
const arr = [1, 2, 3];
const permutations = {};
for (let i = 0; i < iterations; i++) {
const shuffled = shuffleFunc([...arr]);
const key = shuffled.join(',');
permutations[key] = (permutations[key] || 0) + 1;
}
console.log('순열 분포:');
const expected = iterations / 6; // 3! = 6
for (const [perm, count] of Object.entries(permutations)) {
const percentage = (count / iterations * 100).toFixed(2);
const deviation = ((count - expected) / expected * 100).toFixed(2);
console.log(`[${perm}]: ${count}회 (${percentage}%, 편차: ${deviation}%)`);
}
}
// Fisher-Yates 테스트
console.log('=== Fisher-Yates ===');
testRandomness(fisherYatesShuffle);
// 잘못된 방법 테스트
console.log('\n=== sort() + random ===');
testRandomness(arr => arr.sort(() => Math.random() - 0.5));
예상 출력 (Fisher-Yates):
=== Fisher-Yates ===
순열 분포:
[1,2,3]: 16652회 (16.65%, 편차: -0.09%)
[1,3,2]: 16701회 (16.70%, 편차: 0.21%)
[2,1,3]: 16615회 (16.61%, 편차: -0.33%)
[2,3,1]: 16689회 (16.69%, 편차: 0.13%)
[3,1,2]: 16664회 (16.66%, 편차: 0.02%)
[3,2,1]: 16679회 (16.68%, 편차: 0.07%)
모든 순열이 약 16.67% (1/6)에 근접 ✅
연습 문제
1. 기본 구현
// Fisher-Yates shuffle을 직접 구현해보세요
function fisherYatesShuffle(arr) {
// 여기에 코드 작성
}
const test = [1, 2, 3, 4, 5];
console.log(fisherYatesShuffle(test));
// [3, 5, 1, 4, 2] (무작위)
2. 부분 셔플
// 배열의 앞 n개만 섞기
function partialShuffle(arr, n) {
// 힌트: Fisher-Yates를 n번만 반복
}
const arr = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
console.log(partialShuffle(arr, 3));
// [7, 2, 9, 4, 5, 6, 1, 8, 3, 10] - 앞 3개만 랜덤
3. 가중치 셔플
// 가중치에 따라 특정 원소가 앞에 올 확률 높이기
function weightedShuffle(arr, weights) {
// 도전 과제!
}
const items = ['A', 'B', 'C', 'D'];
const weights = [10, 5, 3, 1]; // A가 앞에 올 확률 높음
참고 자료
공식 문서 및 표준
- NIST - Fisher-Yates Shuffle - 미국 국립표준기술연구소의 공식 정의
- MDN - Math.random() - JavaScript 난수 생성
- MDN - Crypto.getRandomValues() - 암호학적으로 안전한 난수
- ECMAScript® 2023 - Array Methods - 배열 메서드 명세
원본 논문 및 책
- Fisher, R. A.; Yates, F. (1938) - “Statistical tables for biological, agricultural and medical research” - 원본 알고리즘
- Durstenfeld, Richard (1964) - “Algorithm 235: Random permutation” - 현대판 알고리즘
- The Art of Computer Programming, Vol. 2 - Donald Knuth, Section 3.4.2 “Algorithm P (Shuffling)”
- Wikipedia - Fisher–Yates shuffle - 역사와 수학적 분석
학습 자료
- Mike Bostock - Fisher–Yates Shuffle - 시각적 설명 및 편향 분석
- Fisher-Yates Shuffle Visualizer - 애니메이션으로 보는 알고리즘
- Big-O Cheat Sheet - 시간/공간 복잡도 비교
관련 문서
심화 주제
- Sattolo’s Algorithm - 단일 사이클 순열
- Reservoir Sampling - 스트림에서의 무작위 샘플링
- Cryptographically Secure PRNGs - 보안 용도의 난수 생성
마치며
Fisher-Yates shuffle은 간단하지만 완벽한 알고리즘입니다.
// 이 10줄이면 완벽한 셔플!
function fisherYatesShuffle(arr) {
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[arr[i], arr[j]] = [arr[j], arr[i]];
}
return arr;
}
핵심 포인트:
- 편향 없음 - 모든 순열이 동일한 확률
- 빠름 - O(n) 선형 시간
- 간단함 - 10줄로 구현 가능
- 검증됨 - 80년 이상 사용
절대 잊지 마세요:
- ❌
sort(() => Math.random() - 0.5)- 편향 있음! - ✅ Fisher-Yates - 올바른 방법!
다음 단계
같은 레벨 (초중급):
관련 주제:
실전 응용:
- 몬테카를로 시뮬레이션 - 무작위성 활용
- 확률과 통계 - 수학적 배경
🎲 추천 학습 순서: 버블 정렬 → Fisher-Yates → 이진 탐색 → 재귀 → DFS/BFS
댓글