4. Shuffle an Array
«Перемешивание — это не разрушение порядка, а создание всех возможных порядков с абсолютно равной вероятностью».
— Брайан Керниган
Перемешивание массива (Shuffle an Array): алгоритм Фишера-Йетса и аппаратная механика
Задача генерации случайной перестановки массива (Shuffle an Array, LeetCode 384) часто кажется обманчиво простой. Однако именно на ней спотыкаются многие разработчики, допуская тонкие математические ошибки, приводящие к неравномерному распределению (Bias), или создавая ненужные аллокации в куче при каждом вызове.
В реальных бэкенд-системах алгоритмы тасования критически важны:
- Перемешивание пула бэкендов в Round-Robin балансировщиках для предотвращения резонансных скачков сетевого трафика.
- Формирование плейлистов в стриминговых платформах.
- Алгоритмы распределения задач между воркерами в очередях.
- Рандомизация опорного элемента (Pivot) в параллельном QuickSort.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
A["Вызов Shuffle()"]:::entry --> B["Цикл: i идет от n-1 вниз до 1"]:::process
B --> C["Выбор случайного индекса j:<br/>j = rand.IntN(i + 1) в диапазоне [0, i]"]:::process
C --> D{"i == j ?"}:::warning
D -->|"Да"| E["Пропуск обмена<br/>элемент остается на месте"]:::process
D -->|"Нет"| F["Обмен элементов:<br/>arr[i], arr[j] = arr[j], arr[i]"]:::success
E --> G["Декремент i--"]:::process
F --> G
G --> H{"i > 0?"}:::warning
H -->|"Да"| C
H -->|"Нет"| I["Возврат массива за время O(N)"]:::success
1. Алгоритм Фишера-Йетса (Knuth Shuffle): математика честности
Алгоритм тасования, разработанный Рональдом Фишером и Фрэнком Йетсом (и оптимизированный Дональдом Кнутом для ЭВМ), гарантирует, что каждая из $N!$ возможных перестановок массива возникает с абсолютно равной вероятностью $\frac{1}{N!}$.
Доказательство равномерности
- На первом шаге ($i = N - 1$) мы выбираем один из $N$ элементов с вероятностью $\frac{1}{N}$ и ставим его на позицию $N - 1$.
- На втором шаге ($i = N - 2$) мы выбираем один из оставшихся $N - 1$ элементов с вероятностью $\frac{1}{N - 1}$ и ставим его на позицию $N - 2$. Вероятность конкретного элемента оказаться на позиции $N - 2$:
$$P = \left(1 - \frac{1}{N}\right) \times \frac{1}{N - 1} = \frac{N - 1}{N} \times \frac{1}{N - 1} = \frac{1}{N}$$ - По индукции, вероятность любого элемента оказаться на любой позиции $k$ строго равна $\frac{1}{N}$.
- Общее число путей выполнения алгоритма равно $N \times (N - 1) \times \dots \times 1 = N!$. Каждый путь дает уникальную перестановку с вероятностью $\frac{1}{N!}$.
2. Идиоматичная реализация на Go
Реализуем решение с поддержкой метода Reset() без лишних аллокаций памяти:
package shuffle
import (
"math/rand/v2"
)
// Solution инкапсулирует состояние оригинального и рабочего срезов.
type Solution struct {
original []int
current []int
}
// Constructor сохраняет копию исходного среза.
func Constructor(nums []int) Solution {
orig := make([]int, len(nums))
curr := make([]int, len(nums))
copy(orig, nums)
copy(curr, nums)
return Solution{
original: orig,
current: curr,
}
}
// Reset восстанавливает массив в исходное состояние без выделения новой памяти.
func (s *Solution) Reset() []int {
copy(s.current, s.original)
return s.current
}
// Shuffle перемешивает массив на месте (In-Place) за время O(N).
func (s *Solution) Shuffle() []int {
n := len(s.current)
if n <= 1 {
return s.current
}
// Идем с конца к началу
for i := n - 1; i > 0; i-- {
// j выбирается строго в диапазоне [0, i] включительно
j := rand.IntN(i + 1)
if i != j {
s.current[i], s.current[j] = s.current[j], s.current[i]
}
}
return s.current
}
3. Аппаратная механика: кэш-линии CPU и SIMD memmove
Как устроен Reset() под капотом
Вызов встроенной функции copy(s.current, s.original) в Go компилируется в оптимизированный вызов runtime.memmove.
На современных процессорах с архитектурами x86-64 (AVX-512 / AVX2) и ARM64 (NEON) копирование выполняется не побайтово, а векторными блоками по 32 или 64 байта за один такт процессора.
Метод Reset() работает за считанные наносекунды и не создает ни единого байта мусора в куче (0 B/op).
Случайный доступ и промахи кэша (Cache Misses)
В цикле Shuffle() элемент s.current[i] считывается строго последовательно, что идеально для процессора. Однако элемент s.current[j] читается по случайному адресу.
Для небольших массивов (до $10\,000$ чисел) все данные помещаются в кэш L3 процессора (до 32 МБ), поэтому штрафы за доступ минимальны. Для гигантских массивов, не помещающихся в кэш, случайный доступ неизбежно вызывает промахи кэш-линий RAM — это физическая цена истинной рандомизации.
4. Вопросы для собеседования
Резюме
- Алгоритм Фишера-Йетса гарантирует появление любой из $N!$ перестановок с равной вероятностью $\frac{1}{N!}$.
- Индекс $j$ обязан выбираться в полуинтервале $[0, i + 1)$, то есть в диапазоне $[0, i]$ включительно.
- Вызов
copy()дляReset()использует векторные инструкции процессора и не аллоцирует память. - Наивное тасование с выбором индекса из всего массива порождает статистическое смещение (Bias).
На этом мы завершаем кластер рандомизированных алгоритмов. В следующем кластере мы перейдем к потоковым алгоритмам обработки данных: 1. Теория. Потоковые алгоритмы.