为什么极简示例的Haskell快速排序不是一个“真正”的快速排序?

125

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函数不是真正的快速排序?它如何无法缩放到更长的列表?

17
不是就地操作,因此速度相当慢?其实这是一个很好的问题! - fuz
4
@FUZxxl说:Haskell的列表是不可变的,因此在使用默认数据类型时没有任何操作会就地进行。至于它的速度-它不一定会更慢; GHC是一个令人印象深刻的编译器技术,使用不可变数据结构的Haskell解决方案往往与其他语言中使用可变数据结构的解决方案具有相同的速度。 - Callum Rogers
1
它实际上不是qsort吗?请记住,qsort的运行时间为O(N^2) - Thomas Eding
2
需要注意的是,上面的例子是Haskell的一个入门示例,并且快速排序对于排序列表来说是一个非常糟糕的选择。Data.List中的sort在2002年改为了mergesort:http://hackage.haskell.org/packages/archive/base/3.0.3.1/doc/html/src/Data-List.html#sort,在那里您还可以看到以前的快速排序实现。当前的实现是一个mergesort,它是在2009年制作的:http://hackage.haskell.org/packages/archive/base/4.4.0.0/doc/html/src/Data-List.html#sort。 - HaskellElephant
所以,可变性...但我认为说这不是“真正的”快速排序有点傻。无论如何,我仍然很困惑为什么这在大型列表上无法“扩展”。我的猜测是速度会线性损失并额外使用O(log(n))的内存。这完全不正确吗? - dividebyzero
显示剩余4条评论
12个回答

81

真正的快速排序有两个美丽的方面:

  1. 分而治之:将问题分解为两个较小的问题。
  2. 原地对元素进行分区。

这个简短的 Haskell 示例展示了(1),但没有展示(2)。如果您不知道该技术,如何执行(2)可能不是很明显!


19
请将以下英文网址翻译成中文:http://www.informit.com/articles/article.aspx?p=1407357&seqNum=3 -- Andrey Alexandrescu - The_Ghost
有关原地分区过程的清晰描述,请参阅http://interactivepython.org/courselib/static/pythonds/SortSearch/TheQuickSort.html。 - pvillela

60

在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 的源代码显示它确实使用了同样的原地交换技术(据我所知)。 - Dan Burton
4
这个解决方案是不正确的。unstablePartitionquicksort中的partition非常相似,但无法保证在第m个位置上的元素恰好是p - nymk

32

这是将“真正”的快速排序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];。这个语句访问可变的变量lh,然后访问可变数组a,最后改变可变数组a。天啊,变异了!在Haskell中,突变和访问可变变量是明确的。这个"假" qsort有吸引力的原因有很多,但其中最重要的一点是它不使用突变;这种自我限制使得它更容易一眼看懂。


3
太棒了,但也有一种让人感到不舒服的感觉。我想知道像这样的代码会生成什么样的 GHC 代码? - Ian Ross
@IanRoss:从不纯的快速排序算法中? GHC 实际上生成的代码相当不错。 - J D
“假的”qsort因为各种原因很有吸引力...但是我担心它没有原地操作的性能(如已经注意到的那样)会很糟糕。而且总是以第一个元素作为枢轴也没有帮助。 - dbaltor

23

在我看来,说它"不是真正的快速排序"有些言过其实。我认为这是快速排序算法的有效实现,只是不是特别高效的一种。


13
有一次我和某人争论:我查阅了指定QuickSort算法的实际论文,它确实是原地排序。 - ivanm
2
@ivanm 超链接或者就没有发生过 :) - Dan Burton
2
我喜欢这篇论文的全部命令式风格,甚至包括了保证对数空间使用(许多人不知道)的技巧,而 ALGOL 中现在流行的递归版本只是一个脚注。看来我现在得去找那篇其他的论文了... :) - hugomg
9
任何算法的“有效”实现应该具有相同的渐进界限,你觉得呢?改编后的Haskell快速排序算法没有保留原始算法中的任何内存复杂度,差别甚远。这就是为什么它比Sedgewick在C语言中实现的真正快速排序慢了1000倍以上。 - J D
Haskell实现确实具有相同的渐近界限,快速排序的渐近界限为O(n2),这对C和简单的Haskell实现都是正确的。问题在于,快速排序之所以被称为快速排序,是因为它在实践中比大多数算法具有更快的墙时,尽管其渐近界限很差。但是,Haskell实现没有,因此即使它在算法上是正确的,它也无法实现算法的目的-它是无用的。 - James Roper
1
经典的qsort在特定情况下具有二次复杂度,而这个Haskell实现(包括不可变性)每次都是二次的。显然非常不同。 - sprajagopal

19

由于 Haskell 采用了惰性求值,一个 Haskell 程序不会(几乎 不能)做它看起来应该做的事情。

考虑一下这个程序:

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

在一种热切的语言中,首先运行quicksort,然后是show,最后是putStrLn。函数的参数在该函数开始运行之前就已经计算出来了。

而在Haskell中,则相反。函数首先开始运行。只有当函数实际使用它们时,参数才会被计算。而复合参数(如列表)会一次计算一个部分,每个部分用到时才计算。

因此,在这个程序中发生的第一件事情putStrLn开始运行。

GHC的putStrLn实现通过将参数字符串的字符复制到输出缓冲区中来工作。但是当进入这个循环时,show还没有运行。因此,当它尝试从字符串中复制第一个字符时,Haskell会评估计算需要该字符showquicksort调用的一部分。然后putStrLn继续处理下一个字符。因此,putStrLnshowquicksort这三个函数的执行是交替进行的。quicksort逐步执行,留下一张未计算的thunks图来记住它停止的位置。

现在,如果您熟悉其他编程语言,那么您可能会对此感到非常不同。很难想象Haskell中的quicksort实际上如何行为,无论是在内存访问还是比较顺序方面。 如果您只能观察其行为而不是源代码,您将无法将其识别为快速排序算法。

例如,快速排序的C版本在第一个递归调用之前分区所有数据。 在Haskell版本中,在完成第一个分区之前就会计算出结果的第一个元素(甚至可能出现在屏幕上)-实际上,在对greater进行任何工作之前都没有完成任何工作。

P.S. 如果Haskell代码与快速排序使用相同数量的比较,则会更像快速排序。因为指定了lessergreater独立计算,所以编写的代码会进行两次线性扫描,从而进行两倍多的比较。当然,原则上编译器可以足够聪明地消除额外的比较;或者可以更改代码以使用Data.List.partition

P.P.S. Haskell算法并不总是像您预期的那样行为的经典例子是用于计算质数的Eratosthenes筛法


2
它正在执行“森林砍伐排序”,有一个旧的Reddit主题与之相关。请参考https://dev59.com/LG7Xa4cB1Zd3GeqPn0Rw以及相关内容。 - Will Ness
1
“看起来”是对程序实际功能的相当准确的描述。 - Jason Orendorff
关于筛法的注释,如果它被写成等价的形式“primes = unfoldr ((p:xs)-> Just (p, filter ((> 0).(rem p)) xs)) [2..]”,它最直接的问题可能会更清晰。而且这还是在我们考虑切换到真正的筛法算法之前。 - Will Ness
我对你所说的代码“看起来像是这样”的定义感到困惑。在我看来,你的代码看起来像是调用了putStrLn,它是一个对quicksort的thunked应用程序和一个列表文字的thunked应用程序的show --- 而这正是它所做的!(在优化之前 --- 但有时将C代码与优化后的汇编代码进行比较!)。也许你的意思是“由于惰性评估,在其他语言中类似的代码所做的事情,Haskell程序不会这样做”? - Jonathan Cast
6
我认为在这方面,C语言和Haskell有实际的区别。在评论中继续愉快的辩论真的很困难,尽管我很想在现实中喝咖啡时解决这个问题。如果你有一个小时空闲,并来到纳什维尔,请告诉我! - Jason Orendorff
这是否意味着 take 1 (quicksort list) 将会与 minimum list 做的一模一样,但是复杂度更高? - Peter

18

我认为这个论点试图阐述的是快速排序广泛被使用的原因是它是原地排序且由于缓存友好。由于 Haskell 列表不具备这些优势,其主要理由已经不存在了,因此你可以使用保证 O(n log n) 的归并排序,而对于快速排序, 你必须使用随机化或复杂的划分方案来避免最坏情况下的 O(n2) 运行时间。


5
归并排序是一种更自然的排序算法,适用于(不可变的)链表,因为它不需要使用辅助数组。 - hugomg

17

我认为大多数人认为漂亮的Haskell Quicksort不是一个“真正”的Quicksort的原因是它不是就地排序-显然,当使用不可变数据类型时,它不能是就地排序。但是还有一些反对意见认为它不够“快”:部分原因是由于昂贵的++操作符,另外一个原因是存在空间泄漏-当对较小元素进行递归调用时,您会在保留输入列表的同时,在某些情况下——例如列表降序排列时,这会导致二次空间使用。 (您可以说,使其在线性空间中运行是使用不可变数据最接近“就地”排序的方法。)有巧妙的解决方案来解决这两个问题,包括使用累积参数、元组和融合技术;请参阅Introduction to Functional Programming Using Haskell的Richard Bird的第7.6.1节。


4

我并不认同在纯函数环境中就可以改变元素的想法。本线程中使用可变数组的替代方法违背了纯度的精神。

至少有两个步骤可以优化快速排序的基本版本(即最具表现力的版本)。

  1. Optimize the concatenation (++), which is a linear operation, by accumulators:

    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. Optimize to ternary quick sort (3-way partition, mentioned by Bentley and Sedgewick), to handle duplicated elements:

    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. Combine 2 and 3, refer to Richard Bird's book:

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

这四种情况都可以使用命令式的三数中值方法处理得很好。
实际上,纯函数环境下最适合的排序算法仍然是归并排序,而不是快速排序。
更多详细信息,请参阅我正在撰写的文章: https://sites.google.com/site/algoxy/dcsort

1
你错过了另一个优化点:使用 partition 而不是 2 个过滤器来生成子列表(或者在类似的内部函数上使用 foldr 来生成 3 个子列表)。 - Jeremy List

3

什么是真正的快速排序并没有明确的定义。

他们认为这不是真正的快速排序,因为它不是就地排序:

C语言中的真正快速排序是就地排序的


好的,我们在谈论一个“O”。 - windmaomao

0

看起来Haskell版本会为它分割的每个子列表分配更多的空间。因此,在大规模情况下可能会耗尽内存。话虽如此,它更加优雅。我想这就是在选择函数式编程与命令式编程时所做的权衡。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接