66

In Wikibooks' Haskell, there is the following claim:

Data.List offers a sort function for sorting lists. It does not use quicksort; rather, it uses an efficient implementation of an algorithm called mergesort.

What is the underlying reason in Haskell to use mergesort over quicksort? Quicksort usually has better practical performance, but maybe not in this case. I gather that the in-place benefits of quicksort are hard (impossible?) to do with Haskell lists.

There was a related question on softwareengineering.SE, but it wasn't really about why mergesort is used.

I implemented the two sorts myself for profiling. Mergesort was superior (around twice as fast for a list of 2^20 elements), but I'm not sure that my implementation of quicksort was optimal.

Edit: Here are my implementations of mergesort and 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 first@(l:ls) second@(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..]

Edit 2 3: I was asked to provide timings for my implementations versus the sort in Data.List. Following @Will Ness' suggestions, I compiled this gist with the -O2 flag, changing the supplied sort in main each time, and executed it with +RTS -s. The sorted list was a cheaply-created, pseudorandom [Int] list with 2^20 elements. The results were as follows:

  • Data.List.sort: 0.171s
  • mergesort: 1.092s (~6x slower than Data.List.sort)
  • quicksort: 1.152s (~7x slower than Data.List.sort)
Robert D-B
  • 1,067
  • 1
  • 10
  • 16
  • 6
    How did you implement quicksort on singly-linked lists? – melpomene Sep 08 '18 at 17:29
  • It involved some folding and creation of new lists. (Which I suspect is why it's a bad idea.) I'll edit my question to include the implementation. – Robert D-B Sep 08 '18 at 17:35
  • 7
    merge sort can be optimized by splitting the list at even/odd index, which can be done in a single pass with direct recursion, avoiding the two-pass approach above (`length, splitAt`). Anyway, I guess performance here drove the choice of algorithm. Quicksort is fast because it can be made in-place (with arrays). On lists, it's slower. Some people argue that it should not be called "quicksort" unless it's in-place. Perhaps you can run a few benchmarks yourself. – chi Sep 08 '18 at 17:51
  • 3
    I suggest you benchmark your `quicksort` against `Data.List.sort`. – melpomene Sep 08 '18 at 18:15
  • One place where a QuickSort-like algorithm is rather interesting is `Data.Sequence`. It's possible to write an *incremental* QuickSort for sequences that reveals the first few and last few elements in O(n) time, then takes successively longer times to reveal elements further from the ends. Unfortunately, I never had the time to really optimize it, and getting randomness is a pain. – dfeuer Sep 08 '18 at 22:33
  • 2
    The sort important implementation was not switched from quicksort to mergesort without benchmarking. :) Also, a nice property of the current implementation is that it has O(n) complexity for (nearly) sorted inputs. – augustss Sep 09 '18 at 01:20
  • 2
    The Ghc's implementation of mergesort also uses an algorithm to detect sequences. Making it very efficient sorting almost sorted lists. – JohEker Sep 09 '18 at 14:41
  • How does your algorithms compare to the built in sort? – Thorbjørn Ravn Andersen Sep 09 '18 at 19:01
  • @ThorbjørnRavnAndersen I edited my question to include profiling results. In a word: poorly! – Robert D-B Sep 09 '18 at 19:20
  • Then the fun starts in understanding _why_ :) – Thorbjørn Ravn Andersen Sep 10 '18 at 08:06
  • 1
    Data.List.sort is compiled, and your mergesort isn't, isn't it? for timings, the least thing is to compile with -O2 as a standalone executable, then run it in a shell as `test +RTS -s`. Another thing is CAF memoization. Is your `testList :: [Integer]`, or `Num a => [a]`? In the first case, it is built during the first test, and reused by the subsequent tests, giving them an unfair advantage. – Will Ness Sep 10 '18 at 08:38
  • @WillNess Thanks for your insight. I assumed that ghci was compiling on imports, but I suppose the situation is a little different from that. I updated my question with more accurate profiling. – Robert D-B Sep 10 '18 at 16:00
  • 1
    interesting how your mergesort was 2.5x faster than your quicksort when interpreted, but when compiled it's only 5% faster. you could also try comparing yours with the one-pass three-way code(s) in my answer on the linked question, to see if there's any difference. – Will Ness Sep 10 '18 at 18:20
  • @WillNess Is `Data.List.sort` really Mergesort? To me it seems more like kind of Timsort. Looking at that poetic code i think best case (for already sorted ascending or descending inputs) of `Data.List.sort` is O(n). The `sequences` function is linear and simply `mergeAll [x] = x`. – Redu Nov 11 '18 at 22:29
  • @Redu https://hackage.haskell.org/package/base-4.12.0.0/docs/src/Data.OldList.html#sort is bottom-up merge sort par excellence. sequences are discovered before the repeated merging. – Will Ness Nov 12 '18 at 08:41
  • @WillNess Yes i know the code.. My point was, standard Mergesort doesn't have the notion of discovering sequences AFAIK. And this beauty not only find the sequences (already ordered chunks like you did [here](https://stackoverflow.com/a/52843783/4543207)) but also put them in the required order while generating them, to be merged later. Not exactly, but sort of like Timsort [**run**s](https://dev.to/s_awdesh/timsort-fastest-sorting-algorithm-for-real-world-problems--2jhd). Is it not O(n) for already sorted lists..? – Redu Nov 12 '18 at 09:11
  • 1
    well, the runs technique is attributed to O'Keefe mergesort code from the 1980s, so it's certainly independent of timsort. yes of course it's O(n) for the sorted lists. I could find the paper "Simple and Efficient Mergesort" by Olin Shivers which shows such code, though only discovering the ascending runs. If timsort uses this technique, good for it; but it was known well before it. – Will Ness Nov 12 '18 at 09:35

6 Answers6

75

In imperative languages, Quicksort is performed in-place by mutating an array. As you demonstrate in your code sample, you can adapt Quicksort to a pure functional language like Haskell by building singly-linked lists instead, but this is not as fast.

On the other hand, Mergesort is not an in-place algorithm: a straightforward imperative implementation copies the merged data to a different allocation. This is a better fit for Haskell, which by its nature must copy the data anyway.

Let's step back a bit: Quicksort's performance edge is "lore" -- a reputation built up decades ago on machines much different from the ones we use today. Even if you use the same language, this kind of lore needs rechecking from time to time, as the facts on the ground can change. The last benchmarking paper I read on this topic had Quicksort still on top, but its lead over Mergesort was slim, even in C/C++.

Mergesort has other advantages: it doesn't need to be tweaked to avoid Quicksort's O(n^2) worst case, and it is naturally stable. So, if you lose the narrow performance difference due to other factors, Mergesort is an obvious choice.

comingstorm
  • 25,557
  • 3
  • 43
  • 67
  • 24
    Another note: You can implement mergesort in such a way that `head (sort xs)` is O(n) in a lazy language. – melpomene Sep 08 '18 at 18:32
  • 3
    what do you mean by "naturally" stable? it is very easy to do the initial split wrong, like e.g. "splitting the list at even/odd index". – Will Ness Sep 08 '18 at 19:00
  • 3
    Yes, but if you do the implementation right, you *can* get your stability "for free". With Quicksort (and other unstable sorts like Heapsort), you must explicitly track the original index to stabilize the sort. This dings the performance enough that, if you need the stability, you might as well use Mergesort. – comingstorm Sep 08 '18 at 19:11
  • 2
    Actually, the not-in-place version of Quicksort above *is* (or can be made) stable, unlike the usual in-place Quicksort! I was alerted to this from following the link from K. A. Buhr's answer to the old Haskell implementation, which notes that its `qsort` (similar to the question's `quicksort`) is stable. – comingstorm Sep 08 '18 at 20:25
  • yeah, I wasn't arguing against it. the runs discovery and bottom-up merging is specifically named "natural" in the https://www.cs.tufts.edu/~nr/cs257/archive/olin-shivers/msort.ps paper, even quoting the 1982 O'Keefe implementation mentioned in the docs (which is how I found that paper). for the simple list-based quicksort (which is [a deforested tree sort](https://stackoverflow.com/a/11357450/849891) really), all we have to do is use `<` and `>=` (and not `<=` and `>`) [in the partitioning](https://stackoverflow.com/questions/11355621/pseudo-quicksort-time-complexity). – Will Ness Sep 09 '18 at 01:45
28

I think @comingstorm's answer is pretty much on the nose, but here's some more info on the history of GHC's sort function.

In the source code for Data.OldList, you can find the implementation of sort and verify for yourself that it's a merge sort. Just below the definition in that file is the following comment:

Quicksort replaced by mergesort, 14/5/2002.

From: Ian Lynagh <igloo@earth.li>

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...

So, originally a functional quicksort was used (and the function qsort is still there, but commented out). Ian's benchmarks showed that his mergesort was competitive with quicksort in the "random list" case and massively outperformed it in the case of already sorted data. Later, Ian's version was replaced by another implementation that was about twice as fast, according to additional comments in that file.

The main issue with the original qsort was that it didn't use a random pivot. Instead it pivoted on the first value in the list. This is obviously pretty bad because it implies performance will be worst case (or close) for sorted (or nearly sorted) input. Unfortunately, there are a couple of challenges in switching from "pivot on first" to an alternative (either random, or -- as in your implementation -- somewhere in "the middle"). In a functional language without side effects, managing a pseudorandom input is a bit of a problem, but let's say you solve that (maybe by building a random number generator into your sort function). You still have the problem that, when sorting an immutable linked list, locating an arbitrary pivot and then partitioning based on it will involve multiple list traversals and sublist copies.

I think the only way to realize the supposed benefits of quicksort would be to write the list out to a vector, sort it in place (and sacrifice sort stability), and write it back out to a list. I don't see that that could ever be an overall win. On the other hand, if you already have data in a vector, then an in-place quicksort would definitely be a reasonable option.

K. A. Buhr
  • 45,621
  • 3
  • 45
  • 71
6

On a singly-linked list, mergesort can be done in place. What's more, naive implementations scan over half the list in order to get the start of the second sublist, but the start of the second sublist falls out as a side effect of sorting the first sublist and does not need extra scanning. The one thing quicksort has going over mergesort is cache coherency. Quicksort works with elements close to each other in memory. As soon as an element of indirection enters into it, like when you are sorting pointer arrays instead of the data itself, that advantage becomes less.

Mergesort has hard guarantees for worst-case behavior, and it's easy to do stable sorting with it.

3

Short answer:

Quicksort is advantageous for arrays (in-place, fast, but not worst-case optimal). Mergesort for linked lists (fast, worst-case optimal, stable, simple).

Quicksort is slow for lists, Mergesort is not in-place for arrays.

2

Many arguments on why Quicksort is not used in Haskell seem plausible. However, at least Quicksort is not slower than Mergesort for the random case. Based on the implementation given in Richard Bird's book, Thinking Functionally in Haskell, I made a 3-way Quicksort:

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

I benchmarked a few cases, e.g., lists of size 10^4 containing Int between 0 and 10^3 or 10^4, and so on. The result is the 3-way Quicksort or even Bird's version are better than GHC's Mergesort, something like 1.x~3.x faster than ghc's Mergesort, depending on the type of data (many repetitions? very sparse?). The following stats is generated by criterion:

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)

However, there is another requirement of sort stated in Haskell 98/2010: it needs to be stable. The typical Quicksort implementation using Data.List.partition is stable, but the above one isn't.


Later addition: A stable 3-way Quicksort mentioned in the comment seems as fast as tqsort here.

L.-T. Chen
  • 381
  • 4
  • 4
  • FYI [here](https://stackoverflow.com/a/11373542/849891) (at the bottom of the answer) you can find a *stable* three-way quicksort in Haskell. ---- I assume you meant *`Data.List.partition`* in your last sentence? Also, in your 2nd sentence, in "it is not true", to what is "it" referring? I think there's a missing sentence there. (?) – Will Ness Jan 07 '19 at 12:34
  • Right... thanks for pointing it out. I have changed my comment. – L.-T. Chen Jan 07 '19 at 12:54
  • Regarding your stable 3-way Quicksort, I quickly benchmark the random case (10^4 Ints between 0 and 10^3). Its performance is almost as the same as `tqsort` above. – L.-T. Chen Jan 07 '19 at 12:57
  • @Will Ness, Just let you know ... I change my implementation to adopt accumulator to store the part bigger than the pivot, (or say, to use eliminated join-list structure), so that list append is always linear. The only difference now is the use of difference list to maintain stability which actually costs a bit (about 10-20% slower than mine). – L.-T. Chen Jan 08 '19 at 17:20
  • thanks. it's a first time I hear about "eliminated join-list structure". :) if you have it posted somewhere, maybe you could include a link, or update this answer? – Will Ness Jan 08 '19 at 17:24
  • 1
    On, I was inspired by the JoinList in the Edison package. http://hackage.haskell.org/package/EdisonCore-1.3.2.1/docs/Data-Edison-Seq-JoinList.html An expression of list appends can be considered as a join-list structure , and if you have a function generating this structure and consume it in the end you could probably eliminate this intermediate structure completely. I will perhaps write a proper article about this soon or later, although in the end it is perhaps just difference list .. – L.-T. Chen Jan 08 '19 at 17:36
  • 1
    thanks, will look into it. funny, I once answered with something very similar, if I'm getting it right that is, with `data List a = Nil | Append [a]`. is that it? (will add a link here shortly...) here it is: [Explicit Purely-Functional Data-Structure For Difference Lists](https://stackoverflow.com/a/43945275/849891). it's a bit different than I remembered: `data Dlist a = List [a] | Append (Dlist a) (Dlist a)`. whatever it is, there's bound to be a package already written for it, huh! :) :) – Will Ness Jan 08 '19 at 17:38
0

I am not sure, but looking at the code i don't think Data.List.sort is Mergesort as we know it. It just makes a single pass starting with the sequences function in a beautiful triangular mutual recursive fashion with ascending and descending functions to result in a list of already ascending or descending ordered chunks in the required order. Only then it starts merging.

It's a manifestation of poetry in coding. Unlike Quicksort, its worst case (total random input) has O(nlogn) time complexity, and best case (already sorted ascending or descending) is O(n).

I don't think any other sorting algorithm can beat it.

Will Ness
  • 70,110
  • 9
  • 98
  • 181
Redu
  • 25,060
  • 6
  • 56
  • 76
  • 1
    I'm afraid lots of algorithms _do_ beat it, as far as empirical runtime performance on a real machine is concerned. Not in terms of complexity, but certainly in terms of overhead. – leftaroundabout Nov 13 '18 at 20:18
  • @leftaroundabout You may be right but i wasn't language agnostic. May be I should add *in Haskell only*. – Redu Nov 13 '18 at 20:21