it-swarm.com.ru

Почему Haskell использует сортировку слиянием вместо быстрой сортировки?

В Wikibooks 'Haskell , есть следующая претензия :

Data.List предлагает функцию сортировки списков. Это не использует быструю сортировку; скорее, он использует эффективную реализацию алгоритма под названием mergesort.

Какова основная причина в Haskell использовать сортировку слиянием вместо быстрой сортировки? Быстрая сортировка обычно имеет лучшую практическую производительность, но, возможно, не в этом случае. Я понял, что преимущества быстрой сортировки на месте трудно (невозможно?) Сделать со списками на Haskell.

Был связанный вопрос о softwareengineering.SE , но на самом деле он не был посвящен почему использованию mergesort.

Я реализовал эти два вида для профилирования. Mergesort был лучше (примерно в два раза быстрее для списка из 2 ^ 20 элементов), но я не уверен, что моя реализация быстрой сортировки была оптимальной.

Edit: Вот мои реализации mergesort и quicksort:

mergesort :: Ord a => [a] -> [a]
mergesort [] = []
mergesort [x] = [x]
mergesort l = merge (mergesort left) (mergesort right)
    where size = div (length l) 2
          (left, right) = splitAt size l

merge :: Ord a => [a] -> [a] -> [a]
merge ls [] = ls
merge [] vs = vs
merge [email protected](l:ls) [email protected](v:vs)
    | l < v = l : merge ls second
    | otherwise = v : merge first vs

quicksort :: Ord a => [a] -> [a]
quicksort [] = []
quicksort [x] = [x]
quicksort l = quicksort less ++ pivot:(quicksort greater)
    where pivotIndex = div (length l) 2
          pivot = l !! pivotIndex
          [less, greater] = foldl addElem [[], []] $ enumerate l
          addElem [less, greater] (index, elem)
            | index == pivotIndex = [less, greater]
            | elem < pivot = [elem:less, greater]
            | otherwise = [less, elem:greater]

enumerate :: [a] -> [(Int, a)]
enumerate = Zip [0..]

Правка 2 3: Меня попросили предоставить сроки для моих реализаций по сравнению с сортировкой в ​​Data.List. Следуя советам @Will Ness, я скомпилировал этот Gist с флагом -O2, каждый раз изменяя предоставляемую сортировку в main, и выполнял ее с помощью +RTS -s. Сортированный список представлял собой дешевый, псевдослучайный список [Int] с 2 ^ 20 элементами. Результаты были следующими:

  • Data.List.sort: 0,171 с
  • mergesort: 1.092s (~ в 6 раз медленнее, чем Data.List.sort)
  • quicksort: 1.152s (~ в 7 раз медленнее, чем Data.List.sort)
61
rwbogl

В императивных языках быстрая сортировка выполняется на месте путем изменения массива. Как вы продемонстрировали в своем примере кода, вы можете адаптировать Quicksort к чисто функциональному языку, такому как Haskell, создавая односвязные списки, но это не так быстро.

С другой стороны, Mergesort не является алгоритмом на месте: простая императивная реализация копирует объединенные данные в другое распределение. Это лучше подходит для Haskell, который по своей природе должен в любом случае копировать данные.

Давайте сделаем небольшой шаг назад: производительность Quicksort Edge - это «знания» - репутация, созданная десятилетиями назад на машинах, которые сильно отличаются от тех, которые мы используем сегодня. Даже если вы используете один и тот же язык, такие знания время от времени нуждаются в перепроверке, поскольку факты на местах могут измениться. В последнем тестовом документе, который я читал по этой теме, Quicksort все еще был на вершине, но его преимущество над Mergesort было небольшим, даже в C/C++.

Mergesort имеет и другие преимущества: его не нужно настраивать, чтобы избежать наихудшего случая Quicksort O (n ^ 2), и он, естественно, стабилен. Таким образом, если вы потеряете узкую разницу в производительности из-за других факторов, Mergesort станет очевидным выбором.

69
comingstorm

Я думаю, что ответ @comingstorm в значительной степени на носу, но вот еще немного информации об истории функции сортировки GHC.

В исходном коде для Data.OldList вы можете найти реализацию of sort и убедиться, что это сортировка слиянием. Чуть ниже определения в этом файле следующий комментарий:

Quicksort replaced by mergesort, 14/5/2002.

From: Ian Lynagh <[email protected]>

I am curious as to why the List.sort implementation in GHC is a
quicksort algorithm rather than an algorithm that guarantees n log n
time in the worst case? I have attached a mergesort implementation along
with a few scripts to time it's performance...

Итак, первоначально была использована функциональная быстрая сортировка (и функция qsort все еще существует, но закомментирована). Тесты Яна показали, что его сортировка слиянием была конкурентоспособной с быстрой сортировкой в ​​случае «случайного списка» и значительно превзошла его в случае уже отсортированных данных. Позднее, согласно дополнительным комментариям в этом файле, версия Яна была заменена другой реализацией, которая была примерно в два раза быстрее.

Основная проблема с оригинальной переменной qsort заключалась в том, что она не использовала случайный пивот. Вместо этого он поворачивается на первое значение в списке. Это, очевидно, довольно плохо, поскольку подразумевает, что производительность будет наихудшим (или близким) для отсортированного (или почти отсортированного) ввода. К сожалению, есть пара сложностей при переключении с «поворот на первый» на альтернативу (случайным образом или - как в вашей реализации - где-то в «середине»). На функциональном языке без побочных эффектов управление псевдослучайным вводом представляет собой небольшую проблему, но, допустим, вы решили это (возможно, встроив генератор случайных чисел в функцию сортировки). У вас все еще есть проблема, заключающаяся в том, что при сортировке неизменяемого связанного списка, нахождении произвольного центра и последующем разбиении на его основе потребуется несколько обходов списка и копий подсписка.

Я думаю, что единственный способ реализовать предполагаемые преимущества быстрой сортировки - это записать список в вектор, отсортировать его на месте (и пожертвовать стабильностью сортировки) и записать его обратно в список. Я не понимаю, что это может быть общей победой. С другой стороны, если у вас уже есть данные в векторе, то быстрая сортировка на месте определенно была бы разумным вариантом.

27
K. A. Buhr

В односвязном списке можно выполнить сортировку слиянием. Более того, наивные реализации сканируют более половины списка, чтобы получить начало второго подсписка, но начало второго подсписка выпадает из-за побочного эффекта сортировки первого подсписка и не требует дополнительного сканирования. При сортировке слиянием quicksort имеет дело только с когерентностью кэша. Быстрая сортировка работает с элементами, близкими друг к другу в памяти. Как только в него входит элемент косвенности, например, когда вы сортируете массивы указателей вместо самих данных, это преимущество уменьшается.

Mergesort имеет жесткие гарантии поведения в худшем случае, и с ним легко выполнить стабильную сортировку.

5
user10339366

Короткий ответ:

Быстрая сортировка выгодна для массивов (на месте, быстрая, но не оптимальная в худшем случае). Слияние для связанных списков (быстрый, оптимальный в худшем случае, стабильный, простой).

Быстрая сортировка медленная для списков, Mergesort не для массивов.

3
Yves Daoust

Многие аргументы о том, почему Quicksort не используется в Haskell, кажутся правдоподобными. Однако, по крайней мере, быстрая сортировка не медленнее, чем Mergesort для случайного случая. Основываясь на реализации, приведенной в книге Ричарда Берда, Мышление функционально в Хаскеле, я сделал трехстороннюю быструю сортировку:

tqsort [] = []
tqsort (x:xs) = sortp xs [] [x] [] 
  where
    sortp [] us ws vs     = tqsort us ++ ws ++ tqsort vs
    sortp (y:ys) us ws vs =
      case compare y x of 
        LT -> sortp ys (y:us) ws vs 
        GT -> sortp ys us ws (y:vs)
        _  -> sortp ys us (y:ws) vs

Я протестировал несколько случаев, например, списки размером 10 ^ 4, содержащие Int от 0 до 10 ^ 3 или 10 ^ 4, и так далее. В результате 3-сторонняя версия Quicksort или даже Bird лучше, чем Mergesort GHC, что примерно в 1.x ~ 3.x быстрее, чем Mergesort ghc, в зависимости от типа данных (много повторений? Очень мало?). Следующая статистика генерируется критерием :

benchmarking Data.List.sort/Diverse/10^5
time                 223.0 ms   (217.0 ms .. 228.8 ms)
                     1.000 R²   (1.000 R² .. 1.000 R²)
mean                 226.4 ms   (224.5 ms .. 228.3 ms)
std dev              2.591 ms   (1.824 ms .. 3.354 ms)
variance introduced by outliers: 14% (moderately inflated)

benchmarking 3-way Quicksort/Diverse/10^5
time                 91.45 ms   (86.13 ms .. 98.14 ms)
                     0.996 R²   (0.993 R² .. 0.999 R²)
mean                 96.65 ms   (94.48 ms .. 98.91 ms)
std dev              3.665 ms   (2.775 ms .. 4.554 ms)

Однако есть еще одно требование sort, изложенное в Haskell 98 / 2010 : оно должно быть стабильным. Типичная реализация Quicksort с использованием Data.List.partition - это stable, но вышеприведенная - нет. 


Позднее добавление: стабильная трехсторонняя быстрая сортировка, упомянутая в комментарии, кажется такой же быстрой, как и tqsort здесь. 

1
L.-T. Chen

Я не уверен, но, глядя на код, я не думаю, что Data.List.sort - это Mergesort, как мы его знаем. Он просто делает один проход, начиная с функции sequences в красивой треугольной взаимно-рекурсивной манере с функциями ascending и descending, чтобы получить список уже восходящих или нисходящих упорядоченных фрагментов в требуемом порядке. Только тогда начинается слияние.

Это проявление поэзии в кодировании. В отличие от быстрой сортировки, ее наихудший случай (суммарный случайный ввод) имеет O(nlogn) временную сложность, а лучший случай (уже отсортированный по возрастанию или убыванию) равен O (n).

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

0
Redu