为啥极简主义,例如 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 的来源表明它确实是相同的就地交换技术(据我所知)。 此解决方案不正确。unstablePartition
与partition
的quicksort
非常相似,但不能保证m
th 位置的元素就是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];
。这会访问可变变量l
和h
,然后访问可变数组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 会计算计算 该字符所需的 show
和 quicksort
调用的分数。然后putStrLn
移动到下一个字符。因此,所有三个函数(putStrLn
、show
和 quicksort
)的执行都是交错的。 quicksort
以增量方式执行,留下一个 unevaluated thunks 的图表,因为它会记住它停止的位置。
现在,如果您熟悉任何其他编程语言,这与您所期望的完全不同。很难想象quicksort
在内存访问甚至比较顺序方面在 Haskell 中的实际行为。如果您只能观察行为,而不是源代码,您将无法识别它作为快速排序所做的工作。
例如,C 版本的快速排序将第一次递归调用之前的所有数据分区。在 Haskell 版本中,结果的第一个元素将在 first 分区完成运行之前计算(甚至可能出现在您的屏幕上)——实际上是在 greater
上完成任何工作之前.
附:如果 Haskell 代码进行与快速排序相同数量的比较,它会更像快速排序;编写的代码进行了两倍的比较,因为lesser
和greater
被指定为独立计算,对列表进行两次线性扫描。当然,原则上编译器有可能足够聪明以消除额外的比较;或者代码可以改为使用Data.List.partition
。
附言Haskell 算法的经典示例结果不符合您的预期,这是用于计算素数的 sieve of Eratosthenes。
【讨论】:
lpaste.net/108190。 ——它正在做“砍伐森林的树木分类”,有一个关于它的old reddit thread。参看。 ***.com/questions/14786904/… 和相关的。 看起来是的,这很好地描述了程序的实际作用。 re sieve 备注,如果写成等效的primes = unfoldr (\(p:xs)-> Just (p, filter ((> 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 快速排序不是“真正的”快速排序?的主要内容,如果未能解决你的问题,请参考以下文章