为啥极简主义,例如 Haskell 快速排序不是“真正的”快速排序?

Posted

技术标签:

【中文标题】为啥极简主义,例如 Haskell 快速排序不是“真正的”快速排序?【英文标题】:Why is the minimalist, example Haskell quicksort not a "true" quicksort?为什么极简主义,例如 Haskell 快速排序不是“真正的”快速排序? 【发布时间】:2011-10-10 19:32:18 【问题描述】:

Haskell 的网站介绍了一个非常吸引人的 5 行代码 quicksort function,如下所示。

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

【问题讨论】:

您应该添加指向您正在谈论的确切页面的链接。 它不是就地的,所以很慢?实际上是个好问题! @FUZxxl:Haskell 列表是不可变的,因此在使用默认数据类型时不会进行任何操作。至于它的速度 - 它不一定会更慢; GHC 是一项令人印象深刻的编译器技术,使用不可变数据结构的 haskell 解决方案通常可以与其他语言中的其他可变数据结构保持同步。 真的不是qsort吗?请记住,qsort 有 O(N^2) 运行时。 需要注意的是,上面的例子是Haskell的介绍性例子,快速排序对于列表排序是一个非常糟糕的选择。早在 2002 年,Data.List 中的排序就更改为合并排序: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/…。 【参考方案1】:

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

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

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

【讨论】:

informit.com/articles/article.aspx?p=1407357&seqNum=3 -- 安德烈亚历山德雷斯库 有关就地分区过程的清晰描述,请参阅interactivepython.org/courselib/static/pythonds/SortSearch/…。【参考方案2】:

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 的来源表明它确实是相同的就地交换技术(据我所知)。 此解决方案不正确。 unstablePartitionpartitionquicksort 非常相似,但不能保证mth 位置的元素就是p【参考方案3】:

这里是“真正的”快速排序 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 具有多种吸引力,但其中最主要的是它不使用突变;这种自我强加的限制让人一目了然。

【讨论】:

这太棒了,以一种令人反胃的方式。我想知道 GHC 会从类似的东西中生成什么样的代码? @IanRoss:来自不纯的快速排序? GHC 实际上产生了相当不错的代码。 ““假”的 qsort 由于各种原因很有吸引力……”我担心如果没有就地操作(如前所述)它的性能会很糟糕。并且总是将第一个元素作为枢轴也无济于事。【参考方案4】:

在我看来,说它“不是真正的快速排序”夸大了情况。我认为这是Quicksort algorithm 的有效实现,只是不是特别有效。

【讨论】:

我曾经和某人发生过这样的争论:我查看了指定 QuickSort 的实际论文,并且确实就位。 @ivanm 超链接或者它没有发生:) 我喜欢这篇论文的必要性,甚至包括保证对数空间使用的技巧(很多人不知道),而 ALGOL 中的(现在流行的)递归版本只是一个脚注。我想我现在得去找那篇论文了……:) 任何算法的“有效”实现都应该具有相同的渐近边界,你不觉得吗?混蛋的 Haskell 快速排序不会保留原始算法的任何内存复杂性。差远了。这就是为什么它比 Sedgewick 真正的 C 语言快速排序慢 1000 倍以上。 规范的 qsort 在特定情况下具有二次复杂性,而这种 Haskell 实现(包括不变性)每次都是二次的。显然非常不同。【参考方案5】:

我认为这个论点试图说明的情况是,快速排序之所以常用,是因为它是就地的,因此对缓存非常友好。由于 Haskell 列表没有这些好处,它的主要存在理由已经消失,你不妨使用合并排序,它保证 O(n log n),而使用快速排序你要么必须使用随机化或复杂的分区方案,以避免在最坏的情况下 O(n2) 运行时间。

【讨论】:

Mergesort 是一种更自然的排序算法,用于(不可变)like 列表,无需使用辅助数组。【参考方案6】:

由于惰性求值,Haskell 程序不能(几乎不能)做它看起来做的事情。

考虑这个程序:

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

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

在 Haskell 中,情况正好相反。该函数首先开始运行。只有在函数实际使用它们时才会计算参数。复合参数,如列表,每次计算一个,因为它的每一部分都被使用。

所以这个程序中发生的第一件事putStrLn开始运行。

GHC's implementation of putStrLn 通过将参数 String 的字符复制到输出缓冲区来工作。但是当它进入这个循环时,show 还没有运行。因此,当它从字符串中复制第一个字符时,Haskell 会计算计算 该字符所需的 showquicksort 调用的分数。然后putStrLn 移动到下一个字符。因此,所有三个函数(putStrLnshowquicksort)的执行都是交错的。 quicksort 以增量方式执行,留下一个 unevaluated thunks 的图表,因为它会记住它停止的位置。

现在,如果您熟悉任何其他编程语言,这与您所期望的完全不同。很难想象quicksort 在内存访问甚至比较顺序方面在 Haskell 中的实际行为。如果您只能观察行为,而不是源代码,您将无法识别它作为快速排序所做的工作

例如,C 版本的快速排序将第一次递归调用之前的所有数据分区。在 Haskell 版本中,结果的第一个元素将在 first 分区完成运行之前计算(甚至可能出现在您的屏幕上)——实际上是在 greater 上完成任何工作之前.

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

附言Haskell 算法的经典示例结果不符合您的预期,这是用于计算素数的 sieve of Eratosthenes。

【讨论】:

lpaste.net/108190。 ——它正在做“砍伐森林的树木分类”,有一个关于它的old reddit thread。参看。 ***.com/questions/14786904/… 和相关的。 看起来是的,这很好地描述了程序的实际作用。 re sieve 备注,如果写成等效的primes = unfoldr (\(p:xs)-&gt; Just (p, filter ((&gt; 0).(`rem` p)) xs)) [2..],its most immediate problem 可能会更清楚。那是我们考虑切换到真正的筛分算法之前。 我对您对“看起来像它”的代码的定义感到困惑。在我看来,您的代码“看起来”就像它调用 putStrLn 一样,它是 show 的 thunked 应用程序到 quicksort 到列表文字的 thunked 应用程序 --- 这正是它的作用! (在优化之前 --- 但有时将 C 代码与优化的汇编程序进行比较!)。也许你的意思是“由于惰性求值,Haskell 程序不会像其他语言中看起来相似的代码那样做”? @jcast 我确实认为 C 和 Haskell 在这方面存在实际差异。在评论线程中就这种主题进行愉快的辩论真的很难,就像我很想在现实生活中喝咖啡一样。如果您在纳什维尔有一个小时的空闲时间,请告诉我!【参考方案7】:

我相信大多数人说漂亮的 Haskell 快速排序不是“真正的”快速排序的原因是它不是就地的 - 显然,在使用不可变数据类型时不可能。但也有人反对它不是“快速”:部分原因是昂贵的 ++,也因为存在空间泄漏 - 你在对较小元素进行递归调用时挂在输入列表上,并且在某些情况下 - 例如当列表减少时 - 这会导致二次空间使用。 (您可能会说,让它在线性空间中运行是使用不可变数据最接近“就地”的方法。)这两个问题都有很好的解决方案,使用累积参数、元组和融合;请参阅 Richard Bird 的 Introduction to Functional Programming Using Haskell 的 S7.6.1。

【讨论】:

【参考方案8】:

这不是在纯功能设置中就地改变元素的想法。该线程中使用可变数组的替代方法失去了纯洁精神。

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

    通过累加器优化串联 (++),这是一个线性运算:

    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
    

    优化三元快速排序(Bentley 和 Sedgewick 提到的 3 路分区),以处理重复元素:

    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]
    

    结合 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, 2, 3, 4, ...., n]

    [n, n-1, n-2, ..., 1]

    [m-1, m-2, ...3, 2, 1, m+1, m+2, ..., n]

    [n, 1, n-1, 2, ... ]

所有这 4 种情况都可以通过命令式中位数方法很好地处理。

其实最适合纯函数设置的排序算法还是归并排序,而不是快速排序。

有关详细信息,请访问我正在进行的写作: https://sites.google.com/site/algoxy/dcsort

【讨论】:

您错过了另一个优化:使用分区而不是 2 个过滤器来生成子列表(或使用类似的内部函数上的 foldr 来生成 3 个子列表)。【参考方案9】:

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

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

C 中真正的快速排序就地排序

【讨论】:

嗯,我们说的是O【参考方案10】:

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

【讨论】:

【参考方案11】:

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

【讨论】:

如果列表是随机的,取第一个元素是可以的。 但是对已排序或​​接近排序的列表进行排序是很常见的。 qsort 是平均 n log n,最差 n^2。 从技术上讲,这并不比选择一个随机值更糟糕,除非输入已经排序或接近排序。坏枢轴是远离中位数的枢轴;只有当第一个元素接近最小值或最大值时,它才是一个糟糕的枢轴。 枢轴的选择是一个小细节,根本原因是它没有到​​位。【参考方案12】:

请任何人在 Haskell 中编写快速排序,您将得到基本相同的程序——它显然是快速排序。以下是一些优点和缺点:

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

Pro:推广到三向拆分 () 很简单,这可以避免由于某些值出现 O(n) 次而导致的二次行为。

专业人士:它更容易阅读——即使必须包含过滤器的定义。

缺点:它使用更多内存。

缺点:通过进一步采样来概括枢轴选择的成本很高,这可以避免某些低熵排序上的二次行为。

【讨论】:

以上是关于为啥极简主义,例如 Haskell 快速排序不是“真正的”快速排序?的主要内容,如果未能解决你的问题,请参考以下文章

html 用于排序表的jquery插件,极简主义版

普通数组上的 Haskell 快速排序 - 可能吗?

valgrind 是不是在 Debian Wheezy 上捕获 Qt 4.8 在极简主义应用程序中泄漏内存?

译文追求生产极简主义

译文追求生产极简主义

02.极简主义——健康(笔记)