基于Monoid与Foldable的Haskell排序性能优化问询
嘿,我来帮你拆解下这个问题——你用Monoid和Foldable实现排序的思路其实挺巧妙的,但性能跟不上的核心问题出在合并操作的调用顺序和Foldable遍历的固有特性上,咱们一步步说:
首先先把你没写完的代码补全(推测你是想把单个元素包装成MergeL,然后用foldMap来合并):
newtype MergeL a = MergeL { getMergeL :: [a] } deriving (Eq, Show) instance Ord a => Monoid (MergeL a) where mempty = MergeL [] mappend l r = MergeL $ merge (getMergeL l) (getMergeL r) -- 单个元素转成MergeL comp :: a -> MergeL a comp a = MergeL [a] -- 你可能是这么用Foldable来排序的 mergeSort :: Ord a => [a] -> [a] mergeSort = getMergeL . foldMap comp
为什么这个实现速度极慢?
核心问题在于**foldMap对列表的遍历是左结合的**:
比如对列表[1,2,3,4],foldMap comp会生成这样的合并链:
((comp 1 `mappend` comp 2) `mappend` comp 3) `mappend` comp 4
每次mappend都是合并两个列表,左结合的情况下,你相当于把一个长度为1的列表反复合并到一个越来越长的列表里——第一次合并2个元素,第二次合并3个,第三次合并4个...总操作次数是1+2+3+...+(n-1) = n(n-1)/2,时间复杂度直接退化到O(n²)!
而标准归并排序是分治式的平衡合并:先把列表拆成两半,各自排序后再合并,每次合并的两个列表长度相近,总操作次数是n log n,这就是为什么不用Monoid的版本速度正常。
怎么优化?
只要把合并方式改成分治式的平衡合并,就能把性能拉回到标准归并排序的水平,这里给你两种实现思路:
1. 手动实现平衡折叠逻辑
不用直接依赖foldMap,而是先把所有元素转成单元素MergeL,然后逐层两两合并:
mergeSort' :: Ord a => [a] -> [a] mergeSort' = getMergeL . mergeAll . map comp where -- 逐层合并,直到只剩一个结果 mergeAll [] = mempty mergeAll [x] = x mergeAll xs = mergeAll (pairMerge xs) -- 两两配对合并 pairMerge [] = [] pairMerge [x] = [x] pairMerge (x:y:xs) = (x `mappend` y) : pairMerge xs
这个版本的合并顺序是平衡的:先合并相邻的单元素列表,得到长度为2的列表;再合并相邻的长度为2的列表,得到长度为4的列表...直到合并成最终的有序列表,时间复杂度回到O(n log n)。
2. 换用更高效的底层数据结构
如果想进一步优化,可以把普通列表换成Data.Sequence.Seq——Seq的合并操作是O(log n)级别的,比列表的O(n)合并更快。你只需要修改MergeL的内部存储类型,然后重新实现merge函数即可。
最后再划个重点
Foldable的foldMap对列表是左折叠逻辑,这种顺序对于需要平衡合并的操作(比如归并排序)非常不友好,直接用它会导致时间复杂度爆炸。只要调整合并的顺序,用分治的思路来组织Monoid的mappend调用,就能解决性能问题啦!
内容的提问来源于stack exchange,提问作者matt

