Чому мінімалізм, наприклад, Haskell quicksort не є "справжнім" quicksort?


118

Веб-сайт Haskell представляє дуже привабливу 5- рядкову функцію швидкодію , як показано нижче.

quicksort [] = []
quicksort (p:xs) = (quicksort lesser) ++ [p] ++ (quicksort greater)
    where
        lesser = filter (< p) xs
        greater = filter (>= p) xs

Вони також включають в себе "Справжній кікспорт в С" .

// To sort array a[] of size n: qsort(a,0,n-1)

void qsort(int a[], int lo, int hi) 
{
  int h, l, p, t;

  if (lo < hi) {
    l = lo;
    h = hi;
    p = a[hi];

    do {
      while ((l < h) && (a[l] <= p)) 
          l = l+1;
      while ((h > l) && (a[h] >= p))
          h = h-1;
      if (l < h) {
          t = a[l];
          a[l] = a[h];
          a[h] = t;
      }
    } while (l < h);

    a[hi] = a[l];
    a[l] = p;

    qsort( a, lo, l-1 );
    qsort( a, l+1, hi );
  }
}

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

Чому вищевказана функція Haskell не є справжньою швидкодією? Як це не вдалося масштабувати для більш довгих списків?


Ви повинні додати посилання на точну сторінку, про яку ви говорите.
Ставен

14
Це не на місці, таким чином, досить повільно? Добре питання насправді!
fuz

4
@FUZxxl: списки Haskell незмінні, тому жодна операція не буде на місці, використовуючи типи даних за замовчуванням. Щодо швидкості - це не обов'язково буде повільніше; GHC - це вражаюча частина компіляторної технології, і дуже часто рішення haskell, що використовують незмінні структури даних, швидко працюють з іншими змінними іншими мовами.
Callum Rogers

1
Це насправді не qsort? Пам'ятайте, що qsort має O(N^2)час виконання.
Томас Едінг

2
Слід зазначити, що вищенаведений приклад є вступним прикладом Хаскелла, і що квакісорт - це дуже поганий вибір для сортування списків. Сорт у Data.List був змінений на об'єднання в 2002 році: hackage.haskell.org/packages/archive/base/3.0.3.1/doc/html/src/… , там ви також можете побачити попередню швидку реалізацію сортування. Поточна реалізація є об'єднанням, яке було здійснено у 2009 році: hackage.haskell.org/packages/archive/base/4.4.0.0/doc/html/src/… .
HaskellElephant

Відповіді:


75

Справжній хитрощі має два прекрасних аспекти:

  1. Розділіть і перемагайте: розбийте проблему на дві менші проблеми.
  2. Розділіть елементи на місці.

Короткий приклад Haskell демонструє (1), але не (2). Як (2) зроблено може бути не очевидним, якщо ви ще не знаєте техніки!


17
informit.com/articles/article.aspx?p=1407357&seqNum=3 - Андрій Олександреску
The_Ghost

Для чіткого опису процесу розбиття на місці див. Interactivepython.org/courselib/static/pythonds/SortSearch/… .
pvillela

57

Справжній замість хитрості в Хаскеллі:

import qualified Data.Vector.Generic as V 
import qualified Data.Vector.Generic.Mutable as M 

qsort :: (V.Vector v a, Ord a) => v a -> v a
qsort = V.modify go where
    go xs | M.length xs < 2 = return ()
          | otherwise = do
            p <- M.read xs (M.length xs `div` 2)
            j <- M.unstablePartition (< p) xs
            let (l, pr) = M.splitAt j xs 
            k <- M.unstablePartition (== p) pr
            go l; go $ M.drop k pr

Джерело для нестабільної частини розкриває, що це дійсно та сама техніка заміни місця (наскільки я можу сказати).
Ден Бертон

3
Це рішення невірно. unstablePartitionдуже схожий на partitionдля quicksort, але це не гарантує, що елемент на тій mпозиції просто p.
німк

29

Ось транслітерація "справжнього" коду швидкості С на С Haskell. Готуйтеся.

import Control.Monad
import Data.Array.IO
import Data.IORef

qsort :: IOUArray Int Int -> Int -> Int -> IO ()
qsort a lo hi = do
  (h,l,p,t) <- liftM4 (,,,) z z z z

  when (lo < hi) $ do
    l .= lo
    h .= hi
    p .=. (a!hi)

    doWhile (get l .< get h) $ do
      while ((get l .< get h) .&& ((a.!l) .<= get p)) $ do
        modifyIORef l succ
      while ((get h .> get l) .&& ((a.!h) .>= get p)) $ do
        modifyIORef h pred
      b <- get l .< get h
      when b $ do
        t .=. (a.!l)
        lVal <- get l
        hVal <- get h
        writeArray a lVal =<< a!hVal
        writeArray a hVal =<< get t

    lVal <- get l
    writeArray a hi =<< a!lVal
    writeArray a lVal =<< get p

    hi' <- fmap pred (get l)
    qsort a lo hi'
    lo' <- fmap succ (get l)
    qsort a lo' hi

Це було весело, чи не так? Я фактично вирізав цю велику letна початку, а також whereв кінці функції, визначаючи всіх помічників, щоб зробити попередній код дещо гарним.

  let z :: IO (IORef Int)
      z = newIORef 0
      (.=) = writeIORef
      ref .=. action = do v <- action; ref .= v
      (!) = readArray
      (.!) a ref = readArray a =<< get ref
      get = readIORef
      (.<) = liftM2 (<)
      (.>) = liftM2 (>)
      (.<=) = liftM2 (<=)
      (.>=) = liftM2 (>=)
      (.&&) = liftM2 (&&)
  -- ...
  where doWhile cond foo = do
          foo
          b <- cond
          when b $ doWhile cond foo
        while cond foo = do
          b <- cond
          when b $ foo >> while cond foo

І ось тупий тест, щоб побачити, чи працює він.

main = do
    a <- (newListArray (0,9) [10,9..1]) :: IO (IOUArray Int Int)
    printArr a
    putStrLn "Sorting..."
    qsort a 0 9
    putStrLn "Sorted."
    printArr a
  where printArr a = mapM_ (\x -> print =<< readArray a x) [0..9]

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

І що?

Ви помітите, що наведений вище код дуже і дуже довгий. Серце у нього приблизно дорівнює коду С, хоча кожен рядок часто трохи більш багатослівний. Це тому, що C таємно робить багато неприємних речей, які ви можете сприйняти як належне. Наприклад, a[l] = a[h];. Це отримує доступ до змінних змінних lі h, а потім отримує доступ до змінного масиву a, а потім мутує змінний масив a. Свята мутація, Бетмен! У Haskell мутація та доступ до змінних змінних явні. "Підроблений" qsort привабливий з різних причин, але головним серед них є те, що він не використовує мутацію; це самонав’язане обмеження набагато простіше зрозуміти з першого погляду.


3
Це приголомшливо, своєрідно складно. Цікаво, який код GHC виробляє з чогось подібного?
Ян Росс

@IanRoss: Від нечистої швидкості? GHC насправді виробляє досить пристойний код.
JD

"" Підроблений "qsort привабливий з різних причин ..." Я боюся, що його виконання без маніпуляцій на місці (як уже зазначалося) було б жахливим. І завжди прийняття 1-го елемента як зведеного також не допомагає.
dbaltor

25

На мою думку, твердження про те, що це "не справжній хитрощі", завищує цю справу. Я думаю, що це дійсна реалізація алгоритму Quicksort , просто не особливо ефективний.


9
Я мав один раз цей аргумент з кимось: я подивився фактичний документ, який вказав QuickSort, і він справді не існує.
ivanm

2
@ivanm гіперпосилання або це не сталося :)
Ден Бертон

1
Мені подобається, що цей документ є обов'язковим і навіть включає хитрість гарантувати використання логарифмічного простору (про що багато людей не знають), тоді як (на сьогодні популярна) рекурсивна версія в ALGOL - лише виноска. Здогадуюсь, мені доведеться зараз шукати цю іншу папір… :)
hugomg

6
"Дійсна" реалізація будь-якого алгоритму повинна мати однакові асимптотичні межі, ви не думаєте? Підкріплений хикессорт Хаскелла не зберігає жодної складності пам'яті оригінального алгоритму. Навіть близько не. Ось чому він на 1.000 разів повільніше, ніж справжній Квіксорт Седжвік у C.
JD

16

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


5
А Mergesort - це набагато природніший алгоритм сортування (незмінних) списків сподобалися, де він звільняється від необхідності працювати з допоміжними масивами.
хугомг

16

Завдяки лінивій оцінці програма Haskell не робить (майже не може ) робити те, що виглядає, як це робиться.

Розглянемо цю програму:

main = putStrLn (show (quicksort [8, 6, 7, 5, 3, 0, 9]))

Школьною мовою спочатку quicksortбігав би, потім show, потімputStrLn . Аргументи функції обчислюються до запуску цієї функції.

У Haskell все навпаки. Функція починає працювати спочатку. Аргументи обчислюються лише тоді, коли функція їх фактично використовує. І складний аргумент, як список, обчислюється по одному фрагменту за часом, як використовується кожен його фрагмент.

Отже, перше , що відбувається в цій програмі, - це те, що він putStrLnпочинає працювати.

РеалізаціяputStrLn робіт GHC шляхом копіювання символів аргументу String у вихідний буфер. Але коли він входить у цю петлю, showще не запустився. Тому, коли йдеться про копіювання першого символу з рядка, Haskell оцінює частку showта quicksortвиклики, необхідні для обчислення цього символу . Потім putStrLnпереходить до наступного символу. Отже виконання всіх трьох функцій putStrLn- show, і quicksort- переплітається. quicksortвиконується поступово, залишаючи графік неоцінених гронів, коли він пам’ятає, де він зупинився.

Тепер це дико відрізняється від того, що ви можете очікувати, якщо ви знайомі з будь-якою іншою мовою програмування. Непросто уявити, як quicksortнасправді поводиться Haskell з точки зору доступу до пам'яті чи навіть порядку порівнянь. Якби ви могли спостерігати лише за поведінкою, а не за вихідним кодом, ви б не визнавали, що це робить як швидкий корт .

Наприклад, версія C на основі швидкості розбиває всі дані до першого рекурсивного виклику. У версії Haskell перший елемент результату буде обчислений (і навіть може з’явитися на вашому екрані) до того, як перший розділ буде закінчений - дійсно до того, як будь-яка робота взагалі буде виконана greater.

PS Код Haskell був би більш подібний до швидкості, якби він робив таку ж кількість порівнянь, що і quicksort; код як написано робить в два рази більше порівнянь , тому що lesserі greaterвизначено бути обчислено незалежно, роблячи два лінійних сканування за списком. Звичайно, компілятор в принципі може бути досить розумним, щоб усунути зайві порівняння; або код можна змінити на використанняData.List.partition .

PPS Класичний приклад алгоритмів Хаскелла, який виявляється не так, як ви очікували, - це сито Ератостена для обчислення простих чисел.


2
lpaste.net/108190 . - це "сортування дерев", там є стара нитка reddit . пор. stackoverflow.com/questions/14786904/… та пов'язані з ними.
Буде Несс

1
виглядає Так, це досить гарна характеристика того, що програма насправді робить.
Джейсон Орендорф

Зауваження про сито, якби воно було написано як рівнозначне primes = unfoldr (\(p:xs)-> Just (p, filter ((> 0).(`rem` p)) xs)) [2..], його найбільш безпосередня проблема була б, можливо, яснішою. І це перш ніж ми розглянемо перехід на справжній алгоритм сита.
Буде Несс

Мене бентежить ваше визначення того, що код "схожий на нього". Ваш код "схожий" на мене так, як він викликає putStrLnприголомшене додаток showдо приголомшеного додатку quicksortдо списку буквально --- і саме це і робиться! (до оптимізації --- але порівняйте код C з оптимізованим асемблером колись!). Можливо, ви маєте на увазі "завдяки ледачому оцінюванню, програма Haskell не робить те, що схожий на іншій мові код"?
Джонатан У ролях

4
@jcast Я думаю, що між C і Haskell є практична різниця в цьому плані. Насправді важко вести приємну дискусію з цього приводу в темі коментарів, наскільки я б хотів випити її над кавою в реальному житті. Дайте мені знати, якщо ви коли-небудь перебували в Нешвіллі, щоб витратити годину!
Джейсон Орендорф

12

Я вважаю, що причина, по якій більшість людей каже, що гарний Haskell Quicksort не є «справжнім» Quicksort, це те, що він не є на місці - очевидно, що це не може бути при використанні незмінних типів даних. Але є також заперечення, що це не "швидко": частково через дорогий ++, а також тому, що є пробіл у просторі - ви тримаєтесь на вхідному списку, роблячи рекурсивний дзвінок на менших елементах, і в деяких випадках - наприклад, коли список зменшується - це призводить до квадратичного використання простору. (Можна сказати, що змусити його працювати в лінійному просторі - це найближче місце, до якого можна дістатися "на місці", використовуючи незмінні дані.) Є акуратні рішення обох проблем, використовуючи накопичувальні параметри, tupling та fusion; див. S7.6.1 Річарда Птаха '


4

Це не ідея мутувати елементи на місці в чисто функціональних умовах. Альтернативні методи в цій нитці із змінними масивами втратили дух чистоти.

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

  1. Оптимізуйте конкатенацію (++), що є лінійною операцією, акумуляторами:

    qsort xs = qsort' xs []
    
    qsort' [] r = r
    qsort' [x] r = x:r
    qsort' (x:xs) r = qpart xs [] [] r where
        qpart [] as bs r = qsort' as (x:qsort' bs r)
        qpart (x':xs') as bs r | x' <= x = qpart xs' (x':as) bs r
                               | x' >  x = qpart xs' as (x':bs) r
  2. Оптимізуйте до потрійного швидкого сортування (3-х сторонній розділ, згаданий Bentley та Sedgewick) для обробки дублюваних елементів:

    tsort :: (Ord a) => [a] -> [a]
    tsort [] = []
    tsort (x:xs) = tsort [a | a<-xs, a<x] ++ x:[b | b<-xs, b==x] ++ tsort [c | c<-xs, c>x]
  3. Поєднайте 2 і 3, зверніться до книги Річарда Берда:

    psort xs = concat $ pass xs []
    
    pass [] xss = xss
    pass (x:xs) xss = step xs [] [x] [] xss where
        step [] as bs cs xss = pass as (bs:pass cs xss)
        step (x':xs') as bs cs xss | x' <  x = step xs' (x':as) bs cs xss
                                   | x' == x = step xs' as (x':bs) cs xss
                                   | x' >  x = step xs' as bs (x':cs) xss

Або, якщо дублювані елементи не є більшістю:

    tqsort xs = tqsort' xs []

    tqsort' []     r = r
    tqsort' (x:xs) r = qpart xs [] [x] [] r where
        qpart [] as bs cs r = tqsort' as (bs ++ tqsort' cs r)
        qpart (x':xs') as bs cs r | x' <  x = qpart xs' (x':as) bs cs r
                                  | x' == x = qpart xs' as (x':bs) cs r
                                  | x' >  x = qpart xs' as bs (x':cs) r

На жаль, медіану з трьох неможливо реалізувати з таким же ефектом, наприклад:

    qsort [] = []
    qsort [x] = [x]
    qsort [x, y] = [min x y, max x y]
    qsort (x:y:z:rest) = qsort (filter (< m) (s:rest)) ++ [m] ++ qsort (filter (>= m) (l:rest)) where
        xs = [x, y, z]
        [s, m, l] = [minimum xs, median xs, maximum xs] 

тому що він все ще погано працює для наступних 4 випадків:

  1. [1, 2, 3, 4, ...., п]

  2. [n, n-1, n-2, ..., 1]

  3. [m-1, m-2, ... 3, 2, 1, m + 1, m + 2, ..., n]

  4. [n, 1, n-1, 2, ...]

Усі ці 4 випадки добре розглядаються за допомогою імперативного підходу середніх трьох.

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

Для детальної інформації відвідайте моє постійне повідомлення за адресою: https://sites.google.com/site/algoxy/dcsort


Є ще одна оптимізація, яку ви пропустили: використовуйте розділ замість 2 фільтрів для створення під-списків (або складання на подібній внутрішній функції для створення 3 підсписів).
Список Джеремі

3

Не існує чіткого визначення того, що є, а що не є справжньою хитрості.

Вони називають це не справжнім хитрим кором, тому що він не сортує на місці:

Справжній кварцовий сорт у С сортує на місці


-1

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


2
Прийняття першого елемента нормально, якщо список випадковий.
Кіт Томпсон

2
Але поширення відсортованого чи майже відсортованого списку є загальним.
Джошуа

7
Але qsort IS O(n^2)
Томас Едінг

8
qsort є середнім n log n, найгіршим n ^ 2.
Джошуа

3
Технічно це не гірше, ніж вибір випадкового значення, якщо вхід вже не відсортований або майже відсортований. Погані стрижні - це опори, що знаходяться вдалині від медіани; перший елемент є лише поганим стрижнем, якщо він близький до мінімального або максимального.
Platinum Azure

-1

Попросіть кого-небудь написати quicksort в Haskell, і ви отримаєте по суті ту ж програму - це, очевидно, quicksort. Ось деякі переваги та недоліки:

Про: Він покращує «справжній» кікспорт, стабільним, тобто зберігає послідовність послідовностей серед рівних елементів.

Про: Тривіально узагальнювати до тристороннього розбиття (<=>), що дозволяє уникнути квадратичної поведінки через деяке значення, що виникає O (n) разів.

Pro: Простіше читати - навіть якщо потрібно було включити визначення фільтра.

Con: Для цього використовується більше пам'яті.

Кон: Дороге узагальнення виборного вибору шляхом подальшого відбору проб, що може уникнути квадратичної поведінки у певних порядках із низькою ентропією.

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