Почему минималистичный пример быстрой сортировки Haskell не является «настоящей» быстрой сортировкой?


118

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

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

Они также включают «Истинную быструю сортировку в 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 не является настоящей быстрой сортировкой? Как он не масштабируется для более длинных списков?


Вы должны добавить ссылку на ту страницу, о которой говорите.
Staven

14
Это не на месте, поэтому довольно медленно? На самом деле хороший вопрос!
fuz

4
@FUZxxl: списки Haskell неизменяемы, поэтому при использовании типов данных по умолчанию никакие операции не выполняются. Что касается скорости - она ​​не обязательно будет медленнее; GHC - впечатляющий образец технологии компиляции, и очень часто решения haskell, использующие неизменяемые структуры данных, не уступают другим изменяемым структурам на других языках.
Каллум Роджерс

1
Разве это не qsort? Помните, что qsort имеет O(N^2)время выполнения.
Томас Эдинг

2
Следует отметить, что приведенный выше пример является вводным примером Haskell, и что быстрая сортировка - очень плохой выбор для сортировки списков. Сортировка в 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 06

57

Истинная быстрая сортировка на месте в 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

Источник unstablePartition показывает, что это действительно тот же метод замены на месте (насколько я могу судить).
Дэн Бертон,

3
Это неправильное решение. unstablePartitionочень похож на partitionfor quicksort, но не гарантирует, что элемент в mпозиции th является справедливым p.
nymk

29

Вот транслитерация «настоящего» кода быстрой сортировки на 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 привлекателен по разным причинам, но главная из них - это то, что он не использует мутацию; это добровольное ограничение упрощает понимание с первого взгляда.


3
Это здорово, в некотором роде вызывающе тошнотворное. Интересно, какой код производит GHC из чего-то подобного?
Ян Росс,

@IanRoss: От нечистых сортов? GHC действительно производит довольно приличный код.
JD

"Поддельный" qsort привлекателен по разным причинам ... "Я боюсь, что его производительность без манипуляций на месте (как уже отмечалось) будет ужасной. И всегда использование 1-го элемента в качестве точки опоры тоже не помогает.
dbaltor 08

25

На мой взгляд, утверждение, что это «не настоящая быстрая сортировка», является преувеличением. Я считаю, что это правильная реализация алгоритма быстрой сортировки , но не особо эффективная.


9
Однажды у меня был такой спор с кем-то: я нашел настоящий документ, в котором описан QuickSort, и он действительно находится на месте.
ivanm

2
@ivanm гиперссылки или этого не произошло :)
Дэн Бертон

1
Мне нравится, что эта статья обязательна и даже включает уловку, гарантирующую использование логарифмического пространства (о которой многие люди не знают), в то время как (ныне популярная) рекурсивная версия в ALGOL - это просто сноска. Думаю, теперь мне придется поискать ту другую газету ... :)
hugomg

6
«Правильная» реализация любого алгоритма должна иметь такие же асимптотические границы, вам не кажется? Уродливая быстрая сортировка Haskell не сохраняет сложность памяти исходного алгоритма. Даже не близко. Вот почему он более чем в 1000 раз медленнее, чем подлинная Quicksort Седжвика в C.
JD

16

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


5
А Mergesort - гораздо более естественный алгоритм сортировки (неизменяемых) списков понравившихся, где он избавлен от необходимости работать с вспомогательными массивами.
hugomg

16

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

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

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

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

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

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

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

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

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

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

PPS Классическим примером алгоритмов Haskell, которые ведут себя не так, как вы ожидали, является решето Эратосфена для вычисления простых чисел.


2
lpaste.net/108190 . - он выполняет «сортировку обезлесенных деревьев», об этом есть старая ветка на Reddit . ср stackoverflow.com/questions/14786904/… и связанных.
Will Ness

1
выглядит Да, это довольно хорошая характеристика того, что на самом деле делает программа.
Джейсон Орендорф

Что касается сита, если бы оно было написано как эквивалент primes = unfoldr (\(p:xs)-> Just (p, filter ((> 0).(`rem` p)) xs)) [2..], его ближайшая проблема , возможно, была бы более ясной. И это до того, как мы рассмотрим переход на настоящий алгоритм сита.
Will Ness

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

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

13

Я считаю, что причина, по которой большинство людей говорят, что красивая Haskell Quicksort не является «настоящей» Quicksort, заключается в том, что она не на месте - очевидно, этого не может быть при использовании неизменяемых типов данных. Но есть также возражение, что это не «быстро»: отчасти из-за дорогостоящего ++, а также из-за утечки места - вы цепляетесь за список ввода, выполняя рекурсивный вызов меньших элементов, и в некоторых случаях - например, когда список уменьшается - это приводит к квадратичному использованию пространства. (Вы могли бы сказать, что запуск в линейном пространстве - это самое близкое к "на месте" использование неизменяемых данных.) Есть изящные решения обеих проблем, используя накопление параметров, кортежи и слияние; см. 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. Оптимизируйте тройную быструю сортировку (трехстороннее разделение, упомянутое Бентли и Седжвиком) для обработки повторяющихся элементов:

    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, ...., n]

  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


Есть еще одна оптимизация, которую вы пропустили: используйте раздел вместо двух фильтров для создания подсписок (или foldr для аналогичной внутренней функции для создания 3 подсписок).
Джереми Лист

3

Нет четкого определения того, что является настоящей быстрой сортировкой, а что нет.

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

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


-1

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


2
Принимать первый элемент можно, если список случайный.
Кейт Томпсон

2
Но сортировка отсортированного или почти отсортированного списка - обычное дело.
Джошуа

7
Но qsort IS O(n^2)
Томас Эдинг

8
qsort - среднее n log n, худшее n ^ 2.
Джошуа

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

-1

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

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

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

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

Против: использует больше памяти.

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

Используя наш сайт, вы подтверждаете, что прочитали и поняли нашу Политику в отношении файлов cookie и Политику конфиденциальности.
Licensed under cc by-sa 3.0 with attribution required.