Как правильно перемешать (перетасовать) элементы массива в JavaScript?
Лучший ответ: алгоритм Фишера-Йетса (Кнута)
Вы абсолютно правы: использование sort(() => Math.random() - 0.5) — это распространённая, но некорректная практика. Такой подход даёт смещённое распределение (не все перестановки равновероятны) и, что ещё хуже, может нарушить спецификацию sort, которая требует, чтобы функция сравнения была детерминированной. Правильный способ — алгоритм Фишера-Йетса (также известный как алгоритм Кнута). Он работает за O(n) и гарантирует равномерное случайное распределение.
Как работает алгоритм
Идея проста: мы проходим по массиву с конца в начало, на каждом шаге выбираем случайный индекс от 0 до текущей позиции и меняем местами элемент на текущей позиции с элементом по случайному индексу.
Реализация на JavaScript
function shuffle(array) {
// Копируем массив, чтобы не мутировать исходный (опционально)
const shuffled = [...array];
for (let i = shuffled.length - 1; i > 0; i--) {
// Случайный индекс от 0 до i включительно
const j = Math.floor(Math.random() * (i + 1));
// Меняем местами элементы
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
}
return shuffled;
}
// Пример использования:
const cards = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A'];
const shuffledCards = shuffle(cards);
console.log(shuffledCards); // ['Q', '3', 'K', ...] — случайный порядок
console.log(cards); // ['2', '3', '4', ...] — исходный массив не изменился
Если нужно мутировать исходный массив (для экономии памяти)
function shuffleInPlace(array) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
}
Почему это работает?
- Равномерность: На каждом шаге мы выбираем случайный элемент из оставшихся (необработанных). Вероятность любой конкретной перестановки — 1/n!, что математически корректно.
- Эффективность: Один проход по массиву, O(n) времени и O(1) дополнительной памяти (при мутации).
- Надёжность: Алгоритм детерминирован в своей случайности — не зависит от реализации
sortв разных движках JS.
Проверка равномерности (для любознательных)
Вы можете протестировать алгоритм, выполнив его много раз и подсчитав частоту каждой перестановки:
const stats = {};
const arr = [1, 2, 3];
for (let i = 0; i < 60000; i++) {
const key = shuffle(arr).join(',');
stats[key] = (stats[key] || 0) + 1;
}
console.log(stats);
// Все 6 перестановок должны встречаться примерно по 10000 раз
Дополнительные советы
- Если используете TypeScript, добавьте дженерик:
function shuffle<T>(array: T[]): T[]. - Для очень больших массивов и высокой производительности используйте
Uint32Arrayс криптостойким генератором случайных чисел (crypto.getRandomValues), но для 99% задачMath.randomдостаточно. - Не используйте
Array.prototype.sortдля перемешивания — это антипаттерн.
Альтернативный ответ: однострочник с spread и reduce (для иммутабельности)
Если вы предпочитаете функциональный стиль и не хотите использовать циклы, вот элегантный однострочник на основе reduce, который возвращает новый массив, не мутируя исходный:
const shuffle = (arr) =>
arr.reduce((acc, _, i) => {
const j = Math.floor(Math.random() * (i + 1));
[acc[i], acc[j]] = [acc[j], acc[i]];
return acc;
}, [...arr]);
Этот код делает то же самое, что и алгоритм Фишера-Йетса, но в декларативном стиле. Он создаёт копию массива через [...arr] как начальное значение аккумулятора, а затем на каждой итерации меняет местами элементы, используя деструктуризацию.
Важное предостережение
Хотя этот однострочник выглядит красиво, он менее производителен, чем версия с циклом for, из-за накладных расходов на вызов функции на каждом элементе. Для массивов длиной до нескольких тысяч элементов разница незаметна, но для больших данных лучше использовать классический цикл.
Также помните: если вам нужно перемешать массив «на месте» (in-place), используйте версию с мутацией из первого ответа — она быстрее и не создаёт лишних копий.