Что, если что-либо, не так с этим алгоритмом перетасовки и как я могу узнать?

Как и в качестве фона, я знаю, что Fisher-Yates отлично перемешивается. Это отличная перетасовка с ее сложностью O (n) и ее гарантированной однородностью, и я был бы дураком, чтобы не использовать ее... в среде, которая позволяет на месте обновления массивов (так что в большинстве, если не все, императивные среды программирования).

К сожалению, мир функционального программирования не дает вам доступ к изменяемому состоянию.

Из-за Fisher-Yates, однако, не так много литературы, которую я могу найти о том, как разработать алгоритм перетасовки. Несколько мест, которые обращаются к нему, делают это кратко, прежде чем сказать, по сути, "так вот, Фишер-Йейтс, который все перетасовывает вам нужно знать". В конце концов, мне пришлось придумать свое решение.

Решение, которое я придумал, похоже на это, чтобы перетасовать любой список данных:

  • Если список пуст, верните пустой набор.
  • Если в списке есть один элемент, верните этот единственный элемент.
  • Если список не пуст, разделите список на генератор случайных чисел и примените алгоритм рекурсивно к каждому разделу, собрав результаты.

В коде Erlang это выглядит примерно так:

shuffle([])  -> [];
shuffle([L]) -> [L];
shuffle(L)   ->
  {Left, Right} = lists:partition(fun(_) -> 
                                    random:uniform() < 0.5 
                                  end, L),
  shuffle(Left) ++ shuffle(Right).

(Если это выглядит как неудобный быстрый вид для вас, ну, что это такое, в основном.)

Итак, вот моя проблема: та же ситуация, которая затрудняет поиск алгоритмов перетасовки, которые не являются Fisher-Yates, затрудняет поиск инструментов анализировать алгоритм перетасовки. Там есть много литературы, которую я могу найти при анализе PRNG для единообразия, периодичности и т.д., Но не так много информации о том, как анализировать перетасовку. (Действительно, некоторая информация, которую я обнаружил при анализе перетасовки, была просто ошибочной - легко обмануть с помощью простых методов.)

Итак, мой вопрос заключается в следующем: как я могу проанализировать мой алгоритм перетасовки (при условии, что вызов random:uniform() доходит до задачи генерации подходящих случайных чисел с хорошими характеристиками)? Какие математические инструменты существуют в моем распоряжении, чтобы судить о том, действительно ли 100 000 пробегов перетасования над списком целых чисел в диапазоне 1..100 дали мне правдоподобные хорошие результаты перетасовки? Я сделал несколько собственных тестов (например, сравнивая приращения с декрементами в случайном порядке), но я хотел бы узнать еще несколько.

И если есть какое-либо понимание этого алгоритма тасования, это тоже было бы оценено.

Ответ 1

Общее замечание

Мой личный подход к правильности алгоритмов с вероятностью: если вы знаете, как доказать это правильно, то это, вероятно, правильно; если вы этого не сделаете, это, безусловно, неправильно.

Говоря иначе, обычно безнадежно пытаться проанализировать каждый алгоритм, который вы могли бы придумать: вам нужно продолжать поиск алгоритма, пока не найдете тот, который вы можете доказать правильно.

Анализ случайного алгоритма путем вычисления распределения

Я знаю об одном способе "автоматически" анализировать случайный выбор (или, скорее, алгоритм случайного использования), который сильнее, чем простой "бросать много тестов и проверять однородность". Вы можете механически вычислить распределение, связанное с каждым входом вашего алгоритма.

Общая идея заключается в том, что алгоритм случайного использования исследует часть мира возможностей. Каждый раз, когда ваш алгоритм запрашивает случайный элемент в наборе ({ true, false} при переворачивании монеты), для вашего алгоритма есть два возможных результата, и один из них выбран. Вы можете изменить свой алгоритм так, чтобы вместо того, чтобы возвращать один из возможных результатов, он исследует все решения параллельно и возвращает все возможные результаты с соответствующими дистрибутивами.

В общем, для этого потребуется переписать ваш алгоритм в глубину. Если ваш язык поддерживает разграниченные продолжения, вам не нужно; вы можете реализовать "исследование всех возможных результатов" внутри функции, запрашивающей случайный элемент (идея состоит в том, что случайный генератор вместо того, чтобы возвращать результат, фиксирует продолжение, связанное с вашей программой, и запускает его со всеми разными результатами). Пример такого подхода см. В разделе oleg HANSEI.

Промежуточное и, возможно, менее загадочное решение состоит в том, чтобы представить этот "мир возможных исходов" в качестве монады и использовать такой язык, как Haskell с возможностями для монадического программирования. Ниже приведен пример реализации варианта 1 вашего алгоритма в Haskell с использованием вероятностной монады пакета probability:

import Numeric.Probability.Distribution

shuffleM :: (Num prob, Fractional prob) => [a] -> T prob [a]
shuffleM [] = return []
shuffleM [x] = return [x]
shuffleM (pivot:li) = do
        (left, right) <- partition li
        sleft <- shuffleM left
        sright <- shuffleM right
        return (sleft ++ [pivot] ++ sright)
  where partition [] = return ([], [])
        partition (x:xs) = do
                  (left, right) <- partition xs
                  uniform [(x:left, right), (left, x:right)]

Вы можете запустить его для заданного ввода и получить распределение выходных данных:

*Main> shuffleM [1,2]
fromFreqs [([1,2],0.5),([2,1],0.5)]
*Main> shuffleM [1,2,3]
fromFreqs
  [([2,1,3],0.25),([3,1,2],0.25),([1,2,3],0.125),
   ([1,3,2],0.125),([2,3,1],0.125),([3,2,1],0.125)]

Вы можете видеть, что этот алгоритм является единым с входами размера 2, но неравномерными на входах размера 3.

Разница с тестовым подходом заключается в том, что мы можем получить абсолютную уверенность в конечном числе шагов: она может быть довольно большой, поскольку она представляет собой исчерпывающее исследование мира возможностей (но обычно меньше 2 ^ N, так как существуют факторизации аналогичных результатов), но если он возвращает неравномерное распределение, мы точно знаем, что алгоритм неверен. Конечно, если он возвращает равномерное распределение для [1..N] и 1 <= N <= 100, вы только знаете, что ваш алгоритм единообразный до списков размером 100; он все еще может быть неправильным.

¹: этот алгоритм является вариантом вашей реализации Erlang из-за конкретной обработки поворота. Если я не использую ни одного стержня, как в вашем случае, размер ввода не уменьшается на каждом шаге больше: в алгоритме также рассматривается случай, когда все входы находятся в левом списке (или в правом списке) и теряются в бесконечном цикле, Это слабость реализации вероятности монады (если алгоритм имеет вероятность 0 без прерывания, расчёт распределения может все еще расходиться), что я еще не знаю, как исправить.

Перетасовка по типу

Вот простой алгоритм, который я уверен, что могу доказать правильность:

  • Выберите случайный ключ для каждого элемента вашей коллекции.
  • Если ключи не все различны, перезапустите с шага 1.
  • Сортировка коллекции по этим случайным клавишам.

Вы можете опустить шаг 2, если знаете вероятность столкновения (два случайных числа выбраны равными) достаточно низки, но без него тасовка не является совершенно однородной.

Если вы выбрали свои ключи в [1..N], где N - длина вашей коллекции, у вас будет много столкновений (проблема с днем ​​рождения). Если вы выбираете свой ключ как 32-битное целое число, вероятность конфликта на практике низкая, но все еще зависит от проблемы с днем ​​рождения.

Если вы используете бесконечные (лениво оцениваемые) битовые строки как ключи, а не ключи с конечной длиной, вероятность столкновения становится равной 0, и проверка отличимости больше не нужна.

Вот реализация shuffle в OCaml, используя ленивые реальные числа как бесконечные битовые строки:

type 'a stream = Cons of 'a * 'a stream lazy_t

let rec real_number () =
  Cons (Random.bool (), lazy (real_number ()))

let rec compare_real a b = match a, b with
| Cons (true, _), Cons (false, _) -> 1
| Cons (false, _), Cons (true, _) -> -1
| Cons (_, lazy a'), Cons (_, lazy b') ->
    compare_real a' b'

let shuffle list =
  List.map snd
    (List.sort (fun (ra, _) (rb, _) -> compare_real ra rb)
       (List.map (fun x -> real_number (), x) list))

Существуют и другие подходы к "чистой перетасовке". Хорошим является apfelmus решение, основанное на слиянии.

Алгоритмические соображения: сложность предыдущего алгоритма зависит от вероятности того, что все ключи различны. Если вы выберете их как 32-битные целые числа, у вас будет одна вероятность ~ 4 миллиарда, что конкретный ключ столкнется с другим ключом. Сортировка по этим клавишам - O (n log n), предполагая, что выбор случайного числа - O (1).

Если вы бесконечные битстроны, вам никогда не придется перезапускать сборку, но сложность тогда связана с "количеством элементов потоков оценивается в среднем". Я предполагаю, что это O (log n) в среднем (следовательно, все равно O (n log n) в целом), но не имеет доказательств.

... и я думаю, что ваш алгоритм работает

После большего отражения, я думаю (например, douplep), что ваша реализация верна. Вот неофициальное объяснение.

Каждый элемент в вашем списке проверяется несколькими тестами random:uniform() < 0.5. К элементу вы можете связать список результатов этих тестов, как список логических или {0, 1}. В начале алгоритма вы не знаете список, связанный с любым из этих чисел. После первого вызова partition вы знаете первый элемент каждого списка и т.д. Когда ваш алгоритм возвращается, список тестов полностью известен и элементы сортируются в соответствии с этими списками (отсортированы в лексикографическом порядке или рассматриваются как двоичные представления действительных чисел).

Итак, ваш алгоритм эквивалентен сортировке с помощью бесконечных клавиш битстрима. Действие разбиения списка, напоминающее секцию quicksort над сводным элементом, на самом деле является способом разделения для данной позиции в битовой строке элементов с оценкой 0 от элементов с оценкой 1.

Сорт является единообразным, потому что битстроны все разные. Действительно, два элемента с вещественными числами, равными до n -th бит, находятся на одной стороне раздела, возникающего во время рекурсивного вызова shuffle глубины n. Алгоритм завершается только тогда, когда все списки, являющиеся результатом разделов, пустыми или одиночными: все элементы разделены хотя бы одним тестом и поэтому имеют один отдельный двоичный десятичный знак.

Вероятностное окончание

Тонкая точка вашего алгоритма (или моего эквивалентного метода сортировки) заключается в том, что условие завершения является вероятностным. Fisher-Yates всегда заканчивается после известного количества шагов (количество элементов в массиве). С вашим алгоритмом окончание зависит от выхода генератора случайных чисел.

Возможны выходы, которые заставили бы ваш алгоритм расходиться, а не заканчиваться. Например, если генератор случайных чисел всегда выводит 0, каждый вызов partition будет возвращать список ввода без изменений, на котором вы рекурсивно вызываете перетасовку: вы будете бесконечно зацикливаться.

Однако это не проблема, если вы уверены, что ваш генератор случайных чисел справедлив: он не обманывает и всегда возвращает независимые равномерно распределенные результаты. В этом случае вероятность того, что тест random:uniform() < 0.5 всегда возвращает true (или false), точно равна 0:

  • вероятность того, что первые N вызовов возвращаются true равна 2 ^ {- N}
  • вероятность того, что все вызовы возвращаются true, - это вероятность бесконечного пересечения для всех N событий, которые первые N вызывает return 0; это нижний предел ¹ 2 ^ {- N}, который равен 0

¹: для математических деталей см. http://en.wikipedia.org/wiki/Measure_(mathematics)#Measures_of_infinite_intersections_of_measurable_sets

В более общем плане алгоритм не заканчивается тогда и только тогда, когда некоторые из элементов связаны с одним и тем же булевым потоком. Это означает, что по крайней мере два элемента имеют один и тот же булев поток. Но вероятность того, что два случайных булева потока равны, снова равна 0: вероятность того, что цифры в позиции K равны, равна 1/2, поэтому вероятность того, что N первых цифр равна 2 ^ {- N}, и то же самое анализ.

Следовательно, вы знаете, что ваш алгоритм заканчивается с вероятностью 1. Это немного более слабая гарантия того, что алгоритм Фишера-Йейса, который всегда заканчивается. В частности, вы уязвимы для атаки злого противника, который будет управлять вашим генератором случайных чисел.

С более вероятной теорией вы также можете вычислить распределение времени работы вашего алгоритма для заданной длины ввода. Это выходит за рамки моих технических возможностей, но я полагаю, что это хорошо: я полагаю, что вам нужно всего лишь взглянуть на первые цифры O (log N) в среднем, чтобы проверить, что все N ленивых потоков различны, и что вероятность намного большего времени работы экспоненциально уменьшаться.

Ответ 2

Ваш алгоритм основан на сортировке, как описано в статье в Википедии.

В общем случае вычислительная сложность тасования на основе сортировки такая же, как и базовый алгоритм сортировки (например, O (n log n) в среднем, O (n²) в худшем случае для случайного перетасования на основе quicksort), а при распределении не является абсолютно однородным, он должен приближаться к единой достаточно близко для практических целей.

Олег Киселев предлагает следующую статью/обсуждение:

который более подробно описывает ограничения сортировки на основе сортировки, а также предлагает две адаптации стратегии Fischer-Yates: наивный O (n²) один и O (n log n) на основе двоичного дерева.

К сожалению, мир функционального программирования не дает вам доступ к изменяемому состоянию.

Это неверно: в то время как чисто функциональное программирование позволяет избежать побочных эффектов, оно поддерживает доступ к изменяемому состоянию с первоклассными эффектами, не требуя побочных эффектов.

В этом случае вы можете использовать изменяемые массивы Haskell для реализации алгоритма Fischer-Yates с мутацией, как описано в этом уроке:

Добавление

Конкретная основа вашего сортировки в случайном порядке на самом деле является бесконечным ключом сортировка radix: по мере того, как указывает Gasche, каждый раздел соответствует цифре группировка.

Основным недостатком этого является то же самое, что и любая другая сортировка сортировки с бесконечным ключом: нет гарантии завершения. Хотя вероятность завершения возрастает по мере продолжения сравнения, никогда не существует верхней границы: наихудшая сложность - O (∞).

Ответ 3

Я делал некоторые вещи, подобные этому некоторое время назад, и, в частности, вас могут интересовать векторы Clojure, которые являются функциональными и неизменными, но все еще с O (1) характеристиками произвольного доступа/обновления. Эти два принципа имеют несколько реализаций "взять N элементов случайным образом из этого списка размеров M"; по крайней мере, один из них превращается в функциональную реализацию Fisher-Yates, если вы допустили N = M.

https://gist.github.com/805546

https://gist.github.com/805747

Ответ 4

Основываясь на Как проверить случайность (случай в точке - Перетасовка), я предлагаю:

Маски в случайном порядке (среднего размера), состоящие из равного количества нулей и единиц. Повторяйте и конкатенируйте до скуки. Используйте их в качестве входных данных для испытаний на жесткой основе. Если у вас хорошая перетасовка, тогда вы должны генерировать случайные последовательности нулей и единиц (с предостережением, что совокупный избыток нулей (или единиц) равен нулю на границах массивов среднего размера, которые вы надеялись бы на обнаружение тестов, но более крупная "среда" тем меньше вероятность того, что они это сделают).

Обратите внимание, что тест может отклонить ваш случайный выбор по трем причинам:

  • неправильный алгоритм тасования,
  • генератор случайных чисел, используемый шаффлером или во время инициализации, является плохим, или
  • тестовая реализация плохая.

Вам нужно будет решить, что происходит, если какой-либо тест отклоняется.

Различные адаптации несгибаемые тесты (для решения определенных чисел я использовал из несгибаемой страницы). Принципиальный механизм адаптации состоит в том, чтобы заставить алгоритм тасования действовать как источник равномерно распределенных случайных битов.

  • Дни рождения: в массиве из n нулей вставьте log n. Перемешать. Повторяйте до скуки. Постройте распределение междоузлий, сравните с экспоненциальным распределением. Вы должны выполнить этот эксперимент с различными стратегиями инициализации - теми, которые находятся спереди, те, которые находятся в конце, те, что находятся в середине, те, которые разбросаны в произвольном порядке. (Последний имеет наибольшую опасность неудачной рандомизации инициализации (относительно случайной случайной выборки), что дает отказ от перетасовки.) Это можно сделать с помощью блоков одинаковых значений, но имеет проблему, заключающуюся в том, что она вводит корреляцию в распределениях ( один и два не могут находиться в одном месте в одном тасовании).
  • Перекрытие перестановок: перетасовать пять значений в несколько раз. Убедитесь, что 120 результатов примерно одинаковы. (Chi-squared test, 119 градусов свободы - твердотельный тест (cdoperm5.c) использует 99 степеней свободы, но это (в основном) артефакт последовательной корреляции, вызванный использованием перекрывающихся подпоследовательностей входной последовательности.)
  • Ранги матриц: от 2 * (6 * 8) ^ 2 = 4608 бит от перетасовки равных чисел нулей и единиц, выберите 6 неперекрывающихся 8-битных подстрок. Рассматривайте их как двоичную матрицу размером 6 на 8 и вычисляйте ее ранг. Повторите для 100 000 матриц. (Объединение в ряды 0-4, а затем - 6, 5 или 0-4.) Ожидаемая доля рангов равна 0.773118, 0.217439, 0.009443. Чи-квадрат сравнивается с наблюдаемыми фракциями с двумя степенями свободы. Тесты 31 на 31 и 32 на 32 аналогичны. Ранги 0-28 и 0-29 объединяются соответственно. Ожидаемые фракции составляют 0,2887880952, 0,5775761902, 0,1283502644, 0,0052854502. Чи-квадрат теста имеет три степени свободы.

и т.д.

Вы также можете использовать dieharder и/или ent, чтобы сделать аналогичные адаптированные тесты.