Загальне зауваження
Мій особистий підхід щодо правильності використання алгоритмів використання ймовірностей: якщо ви знаєте, як довести, що це правильно, то це, мабуть, правильно; якщо ви цього не зробите, це, звичайно, неправильно.
Сказано інакше, як правило, безперспективно намагатися проаналізувати кожен із запропонованих вами алгоритмів: вам доведеться продовжувати шукати алгоритм, поки не знайдете той, який ви зможете довести, що він правильний.
Аналіз випадкового алгоритму шляхом обчислення розподілу
Я знаю один із способів "автоматично" проаналізувати перетасовку (або, загальніше, випадковий алгоритм), який є сильнішим за простий "кинути багато тестів і перевірити на однаковість". Ви можете механічно обчислити розподіл, пов'язаний з кожним входом вашого алгоритму.
Загальна ідея полягає в тому, що випадковий алгоритм досліджує частину світу можливостей. Кожного разу, коли ваш алгоритм запитує випадковий елемент у наборі ({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..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 ледачих потоків різні, і що ймовірність набагато більших тривалостей роботи зменшуються в геометричній прогресії.