Почему минималистский, например, Haskell quicksort не "истинный" quicksort?

На веб-сайте Haskell представлена ​​очень привлекательная 5-строчная функция quicksort, как показано ниже.

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

Они также включают "True quicksort in C".

// 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 ссылается на страницу, в которой говорится: "Быстрая сортировка, указанная во Введении, не является" реальной "быстродействующей сортировкой и не масштабируется для более длинных списков, например код c.

Почему вышеупомянутая функция Haskell не является истинным quicksort? Как он не масштабируется для более длинных списков?

Ответ 1

Истинная quicksort имеет 2 красивых аспекта:

  • Разделите и победите; разбить проблему на две более мелкие проблемы.
  • Разделите элементы на месте.

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

Ответ 2

True inplace quicksort в Haskell:

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

Вот транслитерация "истинного" кода быстрой сортировки C в 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, хотя каждая строка часто немного более многословна. Это потому, что C тайно делает много неприятных вещей, которые вы можете считать само собой разумеющимися. Например, a[l] = a[h];. Он обращается к изменяемым переменным l и h, а затем обращается к изменяемому массиву a, а затем мутирует изменчивый массив a. Святая мутация, бэтмен! В Haskell мутация и доступ к изменяемым переменным являются явными. "Поддельный" qsort привлекателен по разным причинам, но главным среди них является то, что он не использует мутацию; это самоналоженное ограничение облегчает понимание с первого взгляда.

Ответ 4

По-моему, говоря, что это "не настоящая быстрая сортировка", преувеличивает случай. Я думаю, что это действительная реализация алгоритма Quicksort, просто не особенно эффективная.

Ответ 5

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

Ответ 6

Благодаря ленивой оценке, программа Haskell не делает (почти не может) делать то, что она выглядит.

Рассмотрим эту программу:

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

В нетерпеливом языке будет выполняться сначала quicksort, затем show, затем putStrLn. Аргументы функции вычисляются до запуска этой функции.

В Haskell это наоборот. Сначала функция запускается. Аргументы вычисляются только тогда, когда функция фактически использует их. И составной аргумент, как и список, вычисляется по одному фрагменту за раз, поскольку каждый его фрагмент используется.

Итак, первое, что происходит в этой программе, - это запуск putStrLn.

Реализация GHC putStrLn работает, копируя символы аргумента String в выходной буфер. Но когда он входит в этот цикл, show еще не запущен. Поэтому, когда он идет для копирования первого символа из строки, Haskell оценивает долю вызовов show и quicksort, необходимых для вычисления этого символа. Затем putStrLn переходит к следующему символу. Таким образом, выполнение всех трех функций: putStrLn, show и quicksort — чередуется. quicksort выполняется поэтапно, оставляя график ununaluated thunks, поскольку он запоминает, где он остановился.

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

Например, версия быстрой сортировки C разделяет все данные перед первым рекурсивным вызовом. В версии Haskell первый элемент результата будет вычислен (и даже может появиться на вашем экране) до того, как первый раздел будет завершен. Действительно, прежде чем любая работа будет выполнена на greater.

P.S. Код Haskell был бы более quicksort-like, если бы он сделал такое же количество сравнений, что и quicksort; написанный код делает в два раза больше сравнений, потому что lesser и greater заданы для вычисления независимо, делая два линейных сканирования по списку. Разумеется, в принципе возможно, чтобы компилятор был достаточно умным, чтобы исключить дополнительные сравнения; или код может быть изменен для использования Data.List.partition.

P.P.S. Классический пример алгоритмов Haskell, не приводящих к тому, как вы ожидали, - это сито

Ответ 7

Я считаю, что причина, по которой большинство людей говорят, что симпатичный Haskell Quicksort не является "истинным" Quicksort, заключается в том, что он не на месте - ясно, что это невозможно при использовании неизменных типов данных. Но есть также возражение, что это не "быстро": отчасти из-за дорогого ++, а также из-за утечки пространства - вы входите в список ввода, делая рекурсивный вызов для меньших элементов, и в некоторых случаях - например, когда список уменьшается - это приводит к квадратичному использованию пространства. (Можно сказать, что заставить его работать в линейном пространстве - это самое близкое к тому, что вы можете получить "на месте" с использованием неизменяемых данных.) Есть четкие решения обеих проблем, используя накопительные параметры, tupling и fusion; см. S7.6.1 Ричард Берд Введение в функциональное программирование с использованием Haskell.

Ответ 8

Нет четкого определения того, что есть и что не является истинным quicksort.

Они называют это не настоящим quicksort, потому что он не сортируется на месте:

Истинная быстрая сортировка в C-типах на месте

Ответ 9

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

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

  • Оптимизируйте конкатенацию (++), которая является линейной операцией, с помощью аккумуляторов:

    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
    
  • Оптимизируйте до тройной быстрой сортировки (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]
    
  • Объедините 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, 2, 3, 4,...., n]

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

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

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

Все эти 4 случая хорошо обрабатываются с помощью подхода, соответствующего медианному принципу.

На самом деле, наиболее подходящий алгоритм сортировки для чисто функциональных настроек по-прежнему является слиянием-сортировкой, но не быстрой сортировкой.

Подробные сведения см. в моем текущем письме: https://sites.google.com/site/algoxy/dcsort

Ответ 10

Попросите кого-нибудь написать quicksort в Haskell, и вы получите практически ту же программу - это, очевидно, quicksort. Вот некоторые преимущества и недостатки:

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

Pro: тривиально обобщать на трехстороннее раскол (< = > ), что позволяет избежать квадратичного поведения из-за некоторого значения, имеющего время O (n).

Pro: Легче читать - даже если нужно включить определение фильтра.

Con: он использует больше памяти.

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

Ответ 11

Поскольку первый элемент из списка приводит к очень плохому времени выполнения. Используйте медиану 3: первый, средний, последний.