【问题标题】:What is the proper (efficient) way to write this function?编写此函数的正确(有效)方法是什么?
【发布时间】:2015-03-03 06:49:48
【问题描述】:

以下函数返回从根节点到树最深节点的可能路径列表:

paths :: Tree a -> [[a]]
paths (Node element []) = [[element]]
paths (Node element children) = map (element :) $ concat $ map paths children

这在纸面上看起来非常低效,因为concat 具有可怕的复杂性。是否可以在不使用中间数据结构(如序列)的情况下以降低复杂性的方式重写此函数?

编辑:老实说,我知道可以通过以下方式避免 concat 的 O(n)/循环复杂性:

  1. 在递归过程中构建路径(列表);
  2. 仅当您到达最后一个递归级别时,才将路径附加到全局“结果”列表中。

这是一个说明此算法的 JavaScript 实现:

function paths(tree){
    var result = [];
    (function go(node,path){
        if (node.children.length === 0)
            result.push(path.concat([node.tag]));
        else
            node.children.map(function(child){
                go(child,path.concat([node.tag]));
            });
    })(tree,[]);
    return result;
}
console.log(paths(
    {tag: 1,
    children:[
        {tag: 2, children: [{tag: 20, children: []}, {tag: 200, children: []}]},
        {tag: 3, children: [{tag: 30, children: []}, {tag: 300, children: []}]},
        {tag: 4, children: [{tag: 40, children: []}, {tag: 400, children: []}]}]}));

(实际上不是 O(1)/iteration,因为我使用 Array.concat 而不是列表 consing(JS 没有内置列表),但仅使用它会使其每次迭代的时间恒定.)

【问题讨论】:

  • DList 应该模拟您描述的全局解决方案的性能 - 它的功能等同于改变列表 cons(只要它只被使用一次)
  • ...我不知道怎么做?我很想看到答案! :) 还有 - 如果你只是像这样使用列表单子:“do { x

标签: haskell recursion data-structures tree complexity-theory


【解决方案1】:

让我们进行基准测试:

{-# LANGUAGE BangPatterns #-}

import Control.DeepSeq
import Criterion.Main
import Data.Sequence ((|>), Seq)
import Data.Tree
import GHC.DataSize
import qualified Data.DList as DL
import qualified Data.Sequence as S

-- original version
pathsList :: Tree a -> [[a]]
pathsList = go where
  go (Node element []) = [[element]]
  go (Node element children) = map (element:) (concatMap go children)

-- with reversed lists, enabling sharing of path prefixes
pathsRevList :: Tree a -> [[a]]
pathsRevList = go [] where
  go acc (Node a []) = [a:acc]
  go acc (Node a xs) = concatMap (go (a:acc)) xs

-- dfeuer's version
pathsSeqDL :: Tree a -> [Seq a]
pathsSeqDL = DL.toList . go S.empty
  where
    go s (Node a []) = DL.singleton (s |> a)
    go s (Node a xs) = let sa = s |> a
                       in sa `seq` DL.concat . map (go sa) $ xs

-- same as previous but without DLists. 
pathsSeq :: Tree a -> [Seq a]
pathsSeq = go S.empty where
  go acc (Node a []) = [acc |> a]
  go acc (Node a xs) = let acc' = acc |> a
                       in acc' `seq` concatMap (go acc') xs

genTree :: Int -> Int -> Tree Int
genTree branch depth = go 0 depth where
  go n 0 = Node n []
  go n d = Node n [go n' (d - 1) | n' <- [n .. n + branch - 1]]

memSizes = do
  let !tree = force $ genTree 4 4      
  putStrLn "sizes in memory"
  putStrLn . ("list: "++) . show =<< (recursiveSize $!! pathsList tree)
  putStrLn . ("listRev: "++) . show =<< (recursiveSize $!! pathsRevList tree)
  putStrLn . ("seq: "++) . show =<< (recursiveSize $!! pathsSeq tree)
  putStrLn . ("tree itself: "++) . show =<< (recursiveSize $!! tree)

benchPaths !tree = do
  defaultMain [
    bench "pathsList" $ nf pathsList tree,
    bench "pathsRevList" $ nf pathsRevList tree,
    bench "pathsSeqDL" $ nf pathsSeqDL tree,
    bench "pathsSeq" $ nf pathsSeq tree
    ]  

main = do
  memSizes
  putStrLn ""
  putStrLn "normal tree"
  putStrLn "-----------------------"
  benchPaths (force $ genTree 6 8)
  putStrLn "\ndeep tree"
  putStrLn "-----------------------"  
  benchPaths (force $ genTree 2 20)
  putStrLn "\nwide tree"
  putStrLn "-----------------------"  
  benchPaths (force $ genTree 35 4)  

一些注意事项:

  • 我在 GHC 7.8.4 上使用 -O2 和 -fllvm 进行基准测试。
  • 我用一些Int-s 填充genTree 中的树,以防止GHC 优化导致子树被共享。
  • memSizes 中,树必须非常小,因为recursiveSize 具有二次复杂度。

我的 Core i7 3770 上的结果:

sizes in memory
list: 37096
listRev: 14560
seq: 26928
tree itself: 16576

normal tree
-----------------------
pathsList               372.9 ms   
pathsRevList            213.6 ms   
pathsSeqDL              962.2 ms   
pathsSeq                308.8 ms   

deep tree
-----------------------
pathsList               554.1 ms   
pathsRevList            266.7 ms   
pathsSeqDL              919.8 ms   
pathsSeq                438.4 ms   

wide tree
-----------------------
pathsList               191.6 ms   
pathsRevList            129.1 ms   
pathsSeqDL              448.2 ms   
pathsSeq                157.3 ms  

评论:

  • 我完全不感到惊讶。带有列表的原始版本对于这项工作是渐近最优的。此外,仅当我们将有低效的列表追加时才使用DList 是有意义的,但这里不是这种情况。
  • 请注意,反向路径列表占用的空间比树本身要少。
  • 不同形状的树的性能模式是一致的。在“深度树”的情况下,Seq 的表现相对较差,大概是因为Seq snoc 比 list cons 更昂贵。
  • 我认为 Clojure 风格的持久向量(Int 索引浅尝试)在这里会很好,因为它们可以非常快,可能比普通列表具有更少的空间开销,并支持高效的 snoc 和随机读/写。相比之下,Seq 的重量更重,但它支持更广泛的高效操作。

【讨论】:

  • 完全正确。 foldl (++) 不好; foldr (++)(所以,concat)非常好。
  • 我认为你的树太小了,无法反映这些算法之间的一些差异。使用更少的更深/更茂密的树的基准可能会很有趣。使用DList(或其他东西)添加revList 版本也很好。我说“或某事”是因为我也在尝试找到一种很好的方法来转换 DLists,同时使用相同的基本算法。
  • @dfeuer:我的 6 分支 8 深度树有 6^8 ~ 160 万个节点,这肯定不会太小!无论如何,我也添加了宽树和深树的结果,整体情况保持不变。
  • 有趣。我仍在试图理解为什么DLists 会损害反向列表的性能。我需要考虑更多。
【解决方案2】:

说到算法优化,不是代码优化:根据定义,树从根到任何节点只有一条路径,不需要首先返回列表。仅当您想要返回所有最深节点的路径时才有意义,前提是它们中的许多节点位于同一深度。

【讨论】:

  • 实际上,您的代码返回的路径不是最深节点,而是所有叶节点,根本不考虑深度。
  • ...是的,这就是我的意思。我的意思是终端节点。我不是以英语为母语的人,但我看到了你的困惑。
【解决方案3】:

concat 没有可怕的复杂性;它是O(n),其中n 是每个列表中除最后一个之外的元素总数。在这种情况下,我认为无论有没有中间结构,都无法做得更好,除非您更改结果的类型。在这种情况下,列表列表绝对没有共享的潜力,因此您别无选择,只能分配每个列表的每个“缺点”。 concatMap 只会增加一个常数因子开销,如果你能找到显着减少它的方法,我会感到惊讶。

如果你想使用一些共享(以结构惰性为代价),你确实可以切换到不同的数据结构。这只在树有点“浓密”时才有意义。任何支持snoc 的序列类型都可以。在最简单的情况下,您甚至可以反向使用列表,这样您就可以得到从叶子到根的路径,而不是相反。或者你可以使用更灵活的东西,比如Data.Sequence.Seq:

import qualified Data.Sequence as S
import Data.Sequence ((|>), Seq)
import qualified Data.DList as DL
import Data.Tree

paths :: Tree a -> [Seq a]
paths = DL.toList . go S.empty
  where
    go s (Node a []) = DL.singleton (s |> a)
    go s (Node a xs) = let sa = s |> a
                       in sa `seq` DL.concat . map (go sa) $ xs

编辑

正如 Viclib 和 delnan 指出的那样,我原来的答案存在问题,因为底层被多次遍历。

【讨论】:

  • 一次调用 concat 需要 O(n) 时间,但由于它在每个递归级别都使用,paths 的复杂性更糟(我懒得弄清楚什么确切地)。此外,“可怕”是相对的,但肯定存在具有更好(对数或摊销常数,用于连接两个列表)复杂性的持久顺序容器。
  • @delnan,concat 在每一层都应用于下一层的元素,因此 concats 中所有步骤的总和遵循树中元素的总数。
  • 感谢您的回答!但我确实认为它可以在每次迭代的恒定时间内完成——也许使用 ST——因为你可以用另一种语言。我已经根据我的想法编辑了主要帖子。再次感谢!
  • @Viclib,我不确定您所说的“每次迭代”是什么意思。我给出的解决方案是O(n),其中n 是树中元素的总数。在任何语言中,没有比这更好的方法了。
  • @dfeuer 不是连接元素,paths element 的结果,其中包括所述元素的许多副本(叶子除外)。实际上,一棵深度为d的满二叉树的根,有2个子元素但是concat $ map paths elements有2^(d-1)个元素,给取或取几个,其中大部分已经被前面复制了几次concat 来电。
猜你喜欢
  • 2017-04-20
  • 2010-11-22
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2023-04-02
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多