Avatar ·

Как правильно перемешать (перетасовать) элементы массива в JavaScript?

Всем привет! Я новичок в JavaScript и столкнулся с такой задачей: нужно перемешать элементы массива в случайном порядке. Например, у меня есть массив с картами для игры: ```javascript const cards = ['2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K', 'A']; ``` Я хочу, чтобы после перемешивания порядок элементов стал случайным. Пробовал использовать `Math.random()` вместе с `sort()`, вот так: ```javascript cards.sort(() => Math.random() - 0.5); ``` Вроде бы работает, но я прочитал, что это неправильный подход, потому что распределение не будет равномерным. К тому же, некоторые элементы могут оставаться на своих местах чаще других. Подскажите, как правильно реализовать тасование Фишера-Йетса (или любой другой корректный алгоритм) на чистом 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), используйте версию с мутацией из первого ответа — она быстрее и не создаёт лишних копий.