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


118

Haskell的网站引入了非常有吸引力的5行快速排序功能,如下所示。

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

它们还包括“ C中的True quicksort”

// 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?如何无法扩展较长的列表?


您应该将链接添加到您正在谈论的确切页面。
斯塔文,2011年

14
它不就位,因此很慢?实际上是个好问题!
2011年

4
@FUZxxl:Haskell列表是不可变的,因此在使用默认数据类型时不会进行任何操作。至于速度-不一定会慢一些。GHC是令人印象深刻的编译器技术,并且使用不可变数据结构的haskell解决方案通常可以与其他语言中的其他可变对象一起加快速度。
卡勒姆·罗杰斯

1
实际上不是qsort吗?请记住,qsort具有O(N^2)运行时。
Thomas Eding

2
应该注意的是,以上示例是Haskell的入门示例,而quicksort对于列表排序是非常糟糕的选择。Data.List中的排序在2002年更改为mergesort:hackage.haskell.org/packages/archive/base/3.0.3.1/doc/html/src / ...,您还可以看到以前的快速排序实现。当前的实现是2009年进行的mergesorthackage.haskell.org/packages/archive/base/4.4.0.0/doc/html/src/…
HaskellElephant 2011年

Answers:


75

真正的quicksort有两个美丽的方面:

  1. 分而治之:将问题分解为两个较小的问题。
  2. 将元素分区到位。

简短的Haskell示例演示了(1),但没有演示(2)。如果您还不知道该技术,那么如何完成(2)可能并不明显!



有关就地分区过程的清晰描述,请参见Interactivepython.org/courselib/static/pythonds/SortSearch/…
pvillela

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

不稳定分区的来源表明,它确实是相同的就地交换技术(据我所知)。
丹·伯顿

3
此解决方案不正确。unstablePartitionpartitionfor 非常相似quicksort,但是并不能保证m位置上的元素是p
2013年

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];。这个访问可变变量lh,然后访问可变数组a,然后变异的可变数组a。蝙蝠侠,神圣变异!在Haskell中,突变和访问可变变量是明确的。“假” qsort出于各种原因而具有吸引力,但其中最主要的是它不使用突变。这种自我施加的限制使其一目了然更容易理解。


3
太棒了,以一种怪诞的方式。我想知道GHC会从什么样的代码中产生什么样的代码?
伊恩·罗斯

@IanRoss:来自不纯的quicksort?GHC实际上产生了相当不错的代码。
JD

“由于各种原因,“伪” qsort很有魅力……”我担心,如果没有就地操作(如前所述),它的性能将很糟糕。并且始终以第一个元素为枢轴也无济于事。
dbaltor '19

25

我认为,说这不是“一个真正的快速排序”就夸大了此案。我认为这是Quicksort算法的有效实现,但不是特别有效。


9
我曾经和某人争论过一次:我查看了指定QuickSort的实际论文,并且该论文确实就位。
ivanm 2011年

2
@ivanm超链接或它没有发生:)
Dan Burton

1
我喜欢本文的所有内容都势在必行,甚至包括保证对数空间使用(很多人都不知道)的技巧,而ALGOL中的(现在很流行)递归版本只是一个脚注。猜猜我现在必须去寻找其他论文... :)
hugomg

6
任何算法的“有效”实现都应具有相同的渐近边界,您不觉得吗?拙劣的Haskell quicksort不会保留原始算法的任何内存复杂性。差远了。这就是为什么它比Sedgewick的正版Quicksort慢1000倍的原因。–
JD

16

我认为该论据试图提出的理由是,通常使用quicksort的原因是它是就地且因此非常易于缓存。由于Haskell列表没有这些好处,因此它的主要作用已经消失了,您不妨使用合并排序来保证O(n log n),而使用quicksort则必须使用随机或复杂分配方案,以避免在最坏的情况下O(n 2运行时间。


5
Mergesort是一种针对(不可变的)喜欢列表的自然排序算法,它无需使用辅助数组。
hugomg

16

由于懒惰的评估,Haskell程序无法(几乎不能)执行其看起来像的操作。

考虑以下程序:

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

用急切的语言,首先quicksort运行,然后show,然后运行putStrLn。函数的参数是在该函数开始运行之前计算的。

在Haskell中,情况恰恰相反。该功能首先开始运行。仅在函数实际使用参数时才计算参数。像列表这样的复合参数每次使用时都会一次计算。

因此,此程序中发生的第一件事是putStrLn开始运行。

putStrLn通过将参数String的字符复制到输出缓冲区中,GHC的工作实现。但是当它进入这个循环时,show尚未运行。因此,在从字符串中复制第一个字符时,Haskell会评估计算该字符所需的show和的分数。然后移动到下一个字符。所以,这三个职能时的执行,和-进行了交错。逐步执行,留下一个未经评估的重击图,以记住它停在哪里。quicksortputStrLnputStrLnshowquicksortquicksort

现在,这与您曾经熟悉的任何其他编程语言所期望的完全不同。quicksort从内存访问甚至比较顺序的角度来看,要直观地了解Haskell的实际行为并不容易。如果您只能观察行为,而不能观察源代码,那么您将无法识别它作为quicksort所做的事情

例如,C版本的quicksort对第一个递归调用之前的所有数据进行分区。在Haskell版本中,结果的第一个元素将在第一个分区运行完毕之前进行计算(甚至可能会出现在屏幕上),实际上是在进行任何工作之前greater

PS如果Haskell代码进行的比较次数与quicksort相同,则它会更像quicksort。编写的代码进行的比较是原来的两倍,因为lessergreater被指定为独立计算,并且对列表进行了两次线性扫描。当然,原则上编译器可能足够聪明,可以消除多余的比较。或将代码更改为使用Data.List.partition

PPS Haskell算法的经典示例证明其行为不符合您的预期 用于计算素数的Eratosthenes筛


2
lpaste.net/108190。-它正在执行“森林砍伐的树排序”,关于它有一个旧的reddit线程。cf. stackoverflow.com/questions/14786904/…及相关。
尼斯

1
看起来不错,这很好地表征了程序的实际功能。
杰森·奥伦多夫2014年

再筛此言一出,被它写成等效primes = unfoldr (\(p:xs)-> Just (p, filter ((> 0).(`rem` p)) xs)) [2..]其最直接的问题将可能更清晰。那是我们考虑切换到真正的筛分算法之前。
内斯

您对“看起来像什么”的代码的定义感到困惑。您的代码在我看来就像调用putStrLn它一样,是show将一个重分类应用程序quicksort转换为列表文字的重分类应用程序---正是它的作用!(在优化之前---但有时将C代码与优化的汇编程序进行比较!)。也许您的意思是“由于懒惰的评估,Haskell程序无法执行其他语言中类似外观的代码”?
乔纳森·

4
@jcast我确实认为C和Haskell在这方面有实际的区别。真的很难在评论线程中就此类主题进行令人愉快的辩论,就像我很想在现实生活中喝咖啡一样。让我知道您是否在纳什维尔有一个小时的空闲时间!
杰森·奥伦多夫

12

我相信大多数人认为漂亮的Haskell Quicksort不是“真正的” Quicksort的原因在于它不在原地-显然,在使用不可变数据类型时不可能如此。但是,也有人反对它不是“快速”的:部分原因是昂贵的++,还因为存在空间泄漏-您挂在输入列表上,而对较小的元素进行递归调用,以及在某些情况下(例如,列表减少时),这将导致二次空间使用。(您可能会说,使它在线性空间中运行是使用不变数据最接近“就地”的。)使用累积参数,合并和融合,可以很好地解决这两个问题。参见理查德·伯德(Richard Bird)的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. 优化为三元快速排序(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]
  3. 结合2和3,请参阅Richard Bird的书:

    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


您还错过了另一个优化方法:使用分区而不是2个过滤器来生成子列表(或使用类似内部函数上的文件夹来生成3个子列表)。
杰里米·李斯特

3

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

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

真正的C语言快速排序功能


-1

因为从列表中获取第一个元素会导致运行时非常糟糕。使用3的中位数:第一,中间,最后。


2
如果列表是随机的,则采用第一个元素是可以的。
基思·汤普森

2
但是对已排序或​​几乎已排序的列表进行排序很常见。
约书亚,

7
但是qsort IS O(n^2)
Thomas Eding

8
qsort是平均值n log n,最差n ^ 2。
约书亚

3
从技术上讲,除非输入已被排序或几乎已排序,否则它并不比选择随机值差。错误的枢轴是远离中位数的枢轴。如果第一个元素接近最小值或最大值,则它只是一个不好的枢轴。
白金Azure

-1

要求任何人在Haskell中编写quicksort,您将获得基本上相同的程序-显然是quicksort。以下是一些优点和缺点:

优点:通过稳定,改进了“真正的”快速排序,即保留了相等元素之间的顺序。

优点:归纳为三向拆分(<=>)很简单,由于某些值出现O(n)次,避免了二次行为。

优点:更容易阅读-即使必须包含filter的定义。

缺点:它使用更多的内存。

缺点:通过进一步采样来概括枢轴选择是昂贵的,这可以避免在某些低熵排序上出现二次行为。

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.