Що, якщо що, не так із цим алгоритмом перетасовки і як я можу це знати?


77

Подібно до передісторії, я знаю про ідеальне перетасовку Fisher-Yates . Це чудова перетасовка зі своєю складністю O (n) та гарантованою однорідністю, і я був би дурнем не використовувати її ... в середовищі, яке дозволяє оновлювати масиви на місці (тому в більшості, якщо не у всіх, імперативні середовища програмування).

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

Однак через Фішер-Йейтса я не маю багато літератури про те, як розробити алгоритм перемішування. Деякі місця, які взагалі звертаються до цього, роблять це коротко, перш ніж сказати, по суті, "так ось Фішер-Йейтс, який є всім перемішуванням, яке вам потрібно знати". Зрештою, мені довелося придумати власне рішення.

Рішення, яке я придумав, працює таким чином, щоб перетасувати будь-який список даних:

  • Якщо список порожній, поверніть порожній набір.
  • Якщо у списку є один елемент, поверніть його.
  • Якщо список не порожній, розділіть список за допомогою генератора випадкових чисел і застосовуйте алгоритм рекурсивно до кожного розділу, збираючи результати.

У коді Erlang це виглядає приблизно так:

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

(Якщо це здається вам невпорядкованим швидким сортуванням, ну, це, власне, і є).

Отже, моя проблема: та сама ситуація, яка ускладнює пошук алгоритмів перетасовки, які не є Фішер-Йейтсом, ускладнює пошук інструментів для аналізу алгоритму перетасовки. Є багато літератури, яку я можу знайти про аналіз PRNG на предмет однорідності, періодичності тощо, але там немає багато інформації про те, як аналізувати перетасовку. (Дійсно, частина інформації, яку я знайшов при аналізі перетасовки, була просто неправильною - її легко обдурити за допомогою простих методів.)

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

І якщо є якесь розуміння самого цього алгоритму перетасовки, це теж буде оцінено.


Відповіді на це запитання можуть допомогти: stackoverflow.com/questions/1685339/ ... Також варто поглянути на аналіз Кнута про Фішер-Йейтса (див. Статтю у Вікіпедії, на яку ви посилаєтесь).
Алекс Мендес да Коста,

4
Рекомендую перенести це на MathOverflow. Індуктивний доказ того, що він працює, як очікувалося, зводиться до обчислення суми рядка. (Але я майже впевнений , що це правильно , хоча і не гарантоване , щоб зупинити в будь-який момент часу).
дубль

doublep> Я також думаю, що цей алгоритм працює. Дивіться мій пост для детального пояснення.
gasche

Я думав, нескінченне уповільнення вважається дуже поганим в алгоритмах сортування? Крім того, не буде lists:split,lists:droplast і lists:appendзробити реалізації стандартного алгоритму тривіальним?

Відповіді:


77

Загальне зауваження

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

Сказано інакше, як правило, безперспективно намагатися проаналізувати кожен із запропонованих вами алгоритмів: вам доведеться продовжувати шукати алгоритм, поки не знайдете той, який ви зможете довести, що він правильний.

Аналіз випадкового алгоритму шляхом обчислення розподілу

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

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

Загалом, для цього потрібно глибоко переписати ваш алгоритм. Якщо ваша мова підтримує продовження з обмеженнями, вам не потрібно; ви можете реалізувати "дослідження всіх можливих результатів" усередині функції з проханням випадкового елемента (ідея полягає в тому, що генератор випадкових випадків, замість повернення результату, захоплює продовження, пов'язане з вашою програмою, і запускає його з усіма різними результатами). Для прикладу такого підходу див. HANSEI Олега .

Посередницьке, і, мабуть, менш загадкове рішення - представити цей "світ можливих результатів" як монаду та використовувати таку мову, як Хаскелл, із засобами для монадичного програмування. Ось приклад реалізації варіанту¹ вашого алгоритму в Haskell, використовуючи монаду ймовірності ймовірності пакету :

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.
  3. Відсортуйте колекцію за цими випадковими клавішами.

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

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

Якщо ви використовуєте нескінченні (ліньо оцінені) бітстринги як ключі, а не ключі кінцевої довжини, ймовірність зіткнення стає 0, і перевірка на відмінність більше не потрібна.

Ось реалізація перетасовки в 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))

Існують інші підходи до "чистого перетасовки". Приємним є рішення на основі апфельмуса на основі злиття .

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

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

... і я думаю, що ваш алгоритм працює

Після більшої рефлексії я думаю (як дуплеп), що ваша реалізація правильна. Ось неофіційне пояснення.

Кожен елемент у вашому списку перевіряється кількома random:uniform() < 0.5тестами. Елементу ви можете пов’язати перелік результатів цих тестів як логічний перелік або { 0, 1}. На початку алгоритму ви не знаєте списку, пов’язаного з будь-яким із цих чисел. Після першого partitionвиклику ви знаєте перший елемент кожного списку і т. Д. Коли ваш алгоритм повертається, список тестів повністю відомий, і елементи сортуються відповідно до цих списків (сортуються в лексикографічному порядку або розглядаються як двійкові подання реальних цифри).

Отже, ваш алгоритм еквівалентний сортуванню за нескінченними клавішами бітстрингу. Дія розділення списку, що нагадує розділ швидкого сортування над елементом зведення, насправді є способом відокремлення для даної позиції в бітстрингу елементів із оцінкою 0від елементів із оцінкою1 .

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

Імовірнісне припинення

Тонкий момент вашого алгоритму (або мого еквівалентного методу, заснованого на сортуванні) полягає в тому, що умова припинення є імовірнісною . Фішер-Йейтс завжди закінчується через відому кількість кроків (кількість елементів у масиві). З вашим алгоритмом припинення залежить від виходу генератора випадкових чисел.

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

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

  • ймовірність повернення перших N викликів trueдорівнює 2 ^ {- N}
  • ймовірність повернення всіх викликів true- це ймовірність нескінченного перетину для всіх N події, що повертаються перші N викликів 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 ледачих потоків різні, і що ймовірність набагато більших тривалостей роботи зменшуються в геометричній прогресії.


3
Моє справжнє запитання тут полягає в тому, які емпіричні тести я можу здійснити на виході мого перетасовки, щоб перевірити, чи він правдоподібно перетасований? Наприклад, той підхід "поєднання випадкової ваги з кожним елементом" був погано перевірений навіть при моїх обмежених можливостях тестування цього матеріалу. (Я неодноразово перевіряв послідовність [1,2] і виявив величезний дисбаланс.)
ТІЛЬКИ МОЯ правильна ДУМКА

[min_int..max_int]недостатньо, щоб наблизити ймовірність конфлікту до 0, через проблему дня народження, яку ви згадали: із 32-бітними ints ви вже досягаєте 0,5 шансу конфлікту зі списком лише ~ 77 000 предметів.
Пі Дельпорт,

Крім того, зауважте, що загалом зробити будь-яке перетасовку на основі сорту абсолютно рівномірним / правильним, напевно, набагато складніше, ніж здається спочатку: щодо деяких проблем див. Запис Олега та коментарі моєї відповіді. Якщо ідеальна перетасовка взагалі важлива, звичайно, набагато простіше і простіше просто використовувати алгоритм Фішера – Йейтса.
Пі Дельпорт,

Я відредагував, щоб згадати ваше застереження щодо [min_int..max_int]: ви маєте рацію, і воно не масштабується до великих послідовностей. Я також включив реалізацію сортування на основі реального числа. Я згоден, що Фішер-Йейтс простіший, але я не впевнений, що пропозиція Олега є такою.
gasche

1
@AJMansfield: Насправді, з 64-розрядними ключами, вам потрібно лише ~ 5 мільярдів виділень, щоб очікувати зіткнення з 50% ймовірністю. Після 10 мільярдів виділень імовірність зіткнення зростає до ~ 93%. Цей протиінтуїтивний результат - проблема з днем ​​народження.
Пі Дельпорт,

23

Ваш алгоритм - це перетасовка на основі сортування, як обговорюється в статті Вікіпедії.

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

Олег Кисельов пропонує таку статтю / дискусію:

який більш докладно охоплює обмеження перетасовки на основі сортування, а також пропонує дві адаптації стратегії Фішера – Йейтса: наївну O ( n ²) та O ( n log n ) на основі двійкового дерева .

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

Це неправда: хоча чисто функціональне програмування уникає побічних ефектів , воно підтримує доступ до змінного стану з першокласними ефектами, не вимагаючи побічних ефектів.

У цьому випадку ви можете використовувати змінні масиви Хаскелла для реалізації мутуючого алгоритму Фішера – Йейтса, як описано в цьому посібнику:

Додаток

Конкретним підґрунтям вашого сортування у випадковому порядку є насправді нескінченне сортування по радіксу: як зазначає Gasche, кожен розділ відповідає групі цифр.

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


Вибачте, я був менш точним. Ефективний доступ до змінного стану. ;)
ТІЛЬКИ МОЙ правильний ДУМКА

4
Що змушує вас думати, що це не ефективно?
Пі Дельпорт,

Можна просто і просто зафіксувати перетасовку на основі сортування, щоб вона була абсолютно рівномірною (за умови, що базовий генератор випадкових чисел також ідеально рівномірний), не переживаючи додаткової складності чистого рішення Олега. Ви втрачаєте однорідність, коли два елементи порівнюються рівними під час сортування: для їх упорядкування потрібно зробити довільний вибір. Ви можете вибрати ваги, які гарантовано ніколи не будуть рівними, наприклад, випадково підібрані реальні числа (плаваючі чи, ще краще, ледачі булеві потоки). Cf haskell-beginners
gasche

Гаше: Це все ще близько до уніформи, не зовсім однорідно. Є чотири проблеми, які потрібно подолати: (1) При будь-якому виділенні з кінцевого простору ключів, дублікатів не можна уникнути, за визначенням. (2) Якщо ви зробите ключовий простір нескінченним, як у випадку лінивих булевих потоків, ваш алгоритм вже не гарантовано припиняється. (3) Якщо ви відкидаєте та повторно відбираєте дублікати, ви вводите зміщення, і ваші ключі більше не однакові. (4) Як зазначає Олег, навіть якби ви могли вирішити попередні три завдання, вам все одно довелося б довести, що конфігураційний простір призначень ключів точно ділиться на N !.
Пі Дельпорт,

Алгоритм з лінивими булевими потоками завершується з імовірністю 1. Це не те саме, що "завжди закінчувати", зокрема, ви вразливі до злого генератора випадкових чисел (який завжди виводить "1", наприклад), але це все ж досить сильна гарантія. Re. Н! : звичайно, якщо ви рівномірно вибираєте N різних ваг, конфігураційний простір їх впорядкування має розмір N !.
gasche

3

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

https://gist.github.com/805546

https://gist.github.com/805747


1

Виходячи з того, як перевірити випадковість (конкретний випадок - перетасовка) , я пропоную:

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

Зверніть увагу, що тест може відхилити ваше перемішування з трьох причин:

  • алгоритм перетасовки поганий,
  • генератор випадкових чисел, який використовується перетворювачем або під час ініціалізації, поганий, або
  • реалізація тесту погана.

Вам доведеться вирішити, що саме так, якщо будь-який тест відхилить.

Різні адаптації твердих випробувань (для вирішення певних цифр я використовував джерело зі сторінки твердотільних випробувань ). Основний механізм адаптації полягає в тому, щоб змусити алгоритм перемішування діяти як джерело рівномірно розподілених випадкових бітів.

  • Інтервали між днями народження: У масив з n нулів вставте журнал n одиниць. Перемішати. Повторювати, поки не набридне. Побудуйте розподіл міжодних відстаней, порівняйте з експоненціальним розподілом. Ви повинні виконати цей експеримент з різними стратегіями ініціалізації - спереду, в кінці, разом у середині, розсіяних навмання. (Остання має найбільший ризик поганої рандомізації ініціалізації (щодо рандомізації перетасовки), що призводить до відхилення перетасовки.) Це насправді може бути зроблено з блоками однакових значень, але проблема полягає в тому, що вона вносить кореляцію в розподіли ( один і два не можуть знаходитись в одному місці за один перетасовки).
  • Перекриваються перестановки: перетасувати п’ять значень купу разів. Переконайтеся, що 120 результатів є приблизно однаковими. (Тест хі-квадрат, 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 і / або технологічне зробити подібні адаптовані тести.

Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.