【问题标题】:Function to Populate Tree in O(depth)以 O(深度)填充树的函数
【发布时间】:2015-02-26 10:39:09
【问题描述】:

Purely Functional Data Structures 有以下练习:

-- 2.5 Sharing can be useful within a single object, not just between objects.
-- For example, if the two subtress of a given node are identical, then they can 
-- be represented by the same tree.
-- Part a: make a `complete a Int` function that creates a tree of 
-- depth Int, putting a in every leaf of the tree.
complete :: a -> Integer -> Maybe (Tree a)
complete x depth 
 | depth < 0  = Nothing
 | otherwise  = Just $ complete' depth
                        where complete' d 
                                | d == 0    = Empty
                                | otherwise = let copiedTree = complete' (d-1) 
                                              in Node x copiedTree copiedTree

此实现是否在O(d) 时间运行?你能说一下为什么还是为什么不?

【问题讨论】:

  • 你怎么看?为什么?
  • 如果 depth 为 1,则执行以下命令:complete 1 -> complete' 1 -> complete' 0,返回 Empty - 所以我没有把握。我在这里的困惑是了解 O(d) 计数的重要性。
  • 大多数情况下,您应该计算应用构造函数、匹配构造函数以及执行算术。请注意,守卫和if 本质上是Bool 上的模式匹配,因此它们也很重要。
  • 所以调用complete _ 1将执行以下操作:(1)depth &lt; 0(2)d == 0(3)调用complete' (d-1) -- which includes a few operations and (4) construction of Node xcopyTreecopyTree. So mine definitely isn't O(d)@ 987654336@O(6/7/8)`,据我所知。
  • 凯文,我不知道 O(6/7/8) 应该是什么意思。如果您想真正进行此分析,则需要实际写出方程式,然后进行数学运算。如果您将方程式添加到您的问题中,那肯定会让您朝着正确的方向开始。

标签: haskell tree


【解决方案1】:

代码中有趣的部分是complete' 函数:

complete' d 
  | d == 0    = Empty
  | otherwise = let copiedTree = complete' (d-1) 
                in Node x copiedTree copiedTree

正如Cirdec's answer 建议的那样,我们应该仔细分析实现的每个部分,以确保我们的假设是有效的。作为一般规则,我们可以假设以下每个*需要1个单位时间:

  1. 使用数据构造函数构造一个值(例如,使用Empty 制作一棵空树或使用Node 将一个值和两棵树变成一棵树)。

  2. 对一个值进行模式匹配,以查看它是从哪个数据构造函数构建的,以及数据构造函数应用于哪些值。

  3. 守卫和 if/then/else 表达式(在内部使用模式匹配实现)。

  4. Integer 与 0 进行比较。

Cirdec 提到从Integer 中减去 1 的操作是整数大小的对数。正如他们所说,这本质上是Integer 实现方式的产物。可以实现整数,这样只需一步即可将它们与 0 进行比较,也只需一步即可将它们减 1。为了保持通用性,可以安全地假设存在一些函数 c 使得成本递减Integer 是 c(depth)。


现在我们已经完成了这些准备工作,让我们开始工作吧!通常情况下,我们需要建立一个方程组并求解它。令 f(d) 为计算complete' d 所需的步数。那么第一个方程就很简单了:

f(0) = 2

这是因为将 d 与 0 进行比较需要 1 步,而要检查结果是否为 True 需要另外一步。

另一个等式是有趣的部分。想想d &gt; 0:

时会发生什么
  1. 我们计算d == 0
  2. 我们检查是否是True(不是)。
  3. 我们计算d-1(我们称结果为dm1
  4. 我们计算complete' dm1,将结果保存为copiedTree
  5. 我们将Node 构造函数应用于xcopiedTreecopiedTree

第一部分需要 1 步。第二部分迈出了一步。第三部分需要 c(depth) 步,第五步需要 1 步。第四部分呢?嗯,这需要 f(d-1) 个步骤,所以这将是一个递归定义。

f(0) = 2
f(d) = (3+c(depth)) + f(d-1)    when d > 0

好的,现在我们用煤气做饭!让我们计算 f 的前几个值:

f(0) = 2
f(1) = (3+c(depth)) + f(0) = (3+c(depth)) + 2
f(2) = (3+c(depth)) + f(1)
     = (3+c(depth)) + ((3+c(depth)) + 2)
     = 2*(3+c(depth)) + 2
f(3) = (3+c(depth)) + f(2)
     = (3+c(depth)) + (2*(3+c(depth)) + 2)
     = 3*(3+c(depth)) + 2

你现在应该开始看到一个模式了:

f(d) = d*(3+c(depth)) + 2

我们通常使用数学归纳来证明有关递归函数的事情。

基本情况:

该声明适用于 d=0,因为 0*(3+c(depth))+2=0+2=2=f(0)。

假设对于 d=D,该声明成立。那么

f(D+1) = (3+c(depth)) + f(D)
       = (3+c(depth)) + (D*(3+c(depth))+2)
       = (D+1)*(3+c(depth))+2

因此该主张也适用于 D+1。因此,通过归纳,它适用于所有自然数 d。提醒一下,这给出了complete' d 的结论

f(d) = d*(3+c(depth))+2

时间。现在我们如何用大 O 术语来表达它?好吧,大 O 不关心任何项的常数系数,而只关心最高阶项。我们可以安全地假设 c(depth)>=1,所以我们得到

f(d) ∈ O(d*c(depth))

缩小到complete,这看起来像 O(depth*c(depth))

如果你使用 Integer 递减的实际成本,这会给你 O(depth*log(depth))。如果你假装Integer 的减量是 O(1),这会给你 O(depth)。

旁注:随着您继续研究冈崎,您最终将到达第 10.2.1 节,在那里您将看到一种实现自然数的方法,支持 O(1) 减量和 O(1)加法 em>(但不是有效的减法)。

* Haskell 的惰性求值使这不能完全正确,但是如果您假装所有内容都经过严格求值,您将获得真实值的上限,在这种情况下就足够了。如果您想学习如何分析使用惰性来获得良好渐近界的数据结构,您应该继续阅读 Okasaki。

【讨论】:

  • 感谢您的详细回答。但是,为什么不f(0) == 3(1) depth &lt; 0(2) d == 0(3) Empty
  • 另外,我的解决方案是不是 O(d) - 它需要使用您描述的O(1) 技术,我是否正确?
  • @KevinMeredith,这有点取决于你如何看待它。理论上,它不是 O(d) 因为Integer 递减不是。但是(1)你可以使用不同的自然数实现来“修复”这个问题,以及(2)出于所有实际目的,它也可能是 O(d),因为它是 O(d log d),具有一个很小的常数因子。
  • @KevinMeredith,你应该想想是什么让这个常数因子在这种情况下如此微小。
  • 我会赞成这个答案,只是为了在 O 函数类中正确使用 而不是 = 进行遏制。
【解决方案2】:

理论答案

不,它不会在O(d) 时间运行。它的asymptotic performanceInteger 减法d-1 支配,这需要O(log d) 时间。这会重复O(d) 次,给出时间的渐近上限O(d log d)

如果您使用具有渐近最优O(1) 减量的Integer 表示,则此上限可以提高。 In practice we don't,因为渐近最优的 Integer 实现即使对于难以想象的大值也会更慢。

实际上Integer 算术将是程序运行时间的一小部分。对于实际的“大”深度(小于机器字),程序的运行时间将取决于分配和填充内存。对于更大的深度,您将耗尽计算机的资源。

实用答案

询问运行时系统的profiler

为了分析您的代码,我们首先需要确保它已运行。 Haskell 是惰性求值的,因此,除非我们做一些事情来使树被完全求值,否则它可能不会。不幸的是,完全探索这棵树需要O(2^d) 步骤。如果我们跟踪它们的StableNames,我们可以避免强制我们已经访问过的节点。幸运的是,data-reify 包已经提供了遍历结构并通过其内存位置跟踪访问的节点。由于我们将使用它进行分析,我们需要在启用分析的情况下安装它 (-p)。

cabal install -p data-reify

使用Data.Reify 需要TypeFamilies 扩展和Control.Applicative

{-# LANGUAGE TypeFamilies #-}

import Data.Reify
import Control.Applicative

我们复制您的Tree 代码。

data Tree a = Empty | Node a (Tree a) (Tree a)

complete :: a -> Integer -> Maybe (Tree a)
complete x depth 
 | depth < 0  = Nothing
 | otherwise  = Just $ complete' depth
                        where complete' d 
                                | d == 0    = Empty
                                | otherwise = let copiedTree = complete' (d-1) 
                                              in Node x copiedTree copiedTree

使用 data-reify 将数据转换为图形需要我们有一个数据类型的基本函子。基本函子是删除了显式递归的类型的表示。 Tree 的基本函子是 TreeF。增加了一个额外的类型参数来表示该类型的递归出现,并且每次递归出现都被新的参数替换。

data TreeF a x = EmptyF | NodeF a x x
    deriving (Show)

reifyGraph 所需的 MuRef 实例要求我们提供一个 mapDeRef 以使用 Applicative 遍历结构并将其转换为基本函子。提供给mapDeRef(我将其命名为deRef)的第一个参数是我们如何转换结构的递归出现。

instance MuRef (Tree a) where
    type DeRef (Tree a) = TreeF a
    mapDeRef deRef Empty        = pure EmptyF
    mapDeRef deRef (Node a l r) = NodeF a <$> deRef l <*> deRef r

我们可以编写一个小程序来运行来测试complete 函数。当图表很小时,我们将它打印出来看看发生了什么。当图变大时,我们只会打印出它有多少个节点。

main = do
    d <- getLine
    let (Just tree) = complete 0 (read d)
    graph@(Graph nodes _) <- reifyGraph tree
    if length nodes < 30 
    then print graph
    else print (length nodes)

我将此代码放在一个名为profileSymmetricTree.hs 的文件中。要编译它,我们需要使用-prof 启用分析并使用-rtsopts 启用运行时系统。

ghc -fforce-recomp -O2 -prof -fprof-auto -rtsopts profileSymmetricTree.hs

当我们运行它时,我们将使用+RTS 选项-p 启用时间配置文件。我们将在第一次运行时为其提供深度输入 3

profileSymmetricTree +RTS -p
3
let [(1,NodeF 0 2 2),(2,NodeF 0 3 3),(3,NodeF 0 4 4),(4,EmptyF)] in 1

我们已经从图中可以看出,节点在树的左右两侧共享。

分析器创建一个文件,profileSymmetricTree.prof

                                                                                individual     inherited
COST CENTRE                        MODULE                     no.     entries  %time %alloc   %time %alloc

MAIN                               MAIN                        43           0    0.0    0.7   100.0  100.0
 main                              Main                        87           0  100.0   21.6   100.0   32.5
  ...
  main.(...)                       Main                        88           1    0.0    4.8     0.0    5.1
   complete                        Main                        90           1    0.0    0.0     0.0    0.3
    complete.complete'             Main                        92           4    0.0    0.2     0.0    0.3
     complete.complete'.copiedTree Main                        94           3    0.0    0.1     0.0    0.1

它在entries 列中显示complete.complete' 被执行4 次,complete.complete'.copiedTree 被评估3 次。

如果你用不同的深度重复这个实验,并绘制结果,你应该会很好地了解complete 的实际渐近性能是什么。

以下是更深入的分析结果,300000

                                                                                individual     inherited
COST CENTRE                        MODULE                     no.     entries  %time %alloc   %time %alloc

MAIN                               MAIN                        43           0    0.0    0.0   100.0  100.0
 main                              Main                        87           0    2.0    0.0    99.9  100.0
  ...
  main.(...)                       Main                        88           1    0.0    0.0     2.1    5.6
   complete                        Main                        90           1    0.0    0.0     2.1    5.6
    complete.complete'             Main                        92      300001    1.3    4.4     2.1    5.6
     complete.complete'.copiedTree Main                        94      300000    0.8    1.3     0.8    1.3

【讨论】:

  • 好吧,天哪,如果你要将d-1 算作log(d),我们不妨意识到从整数中减去1 已经摊销了常数时间,因为第n 位只需要每 2^n 次触摸一次。
  • @luqui 如果您将Integer 表示为最低有效位在前的数字列表,或者具有可变的Integers,那么这是正确的。 GHC 的defaultInteger 表示is a ByteArray#。减去1 需要复制整个ByteArray#,即O(n),其中n 是数组中的位数。
  • 基于具有 O(1) 算术的抽象机器给出一个理论答案可能会很有用。
  • @dfeuer 对于具有O(1) 算法的机器故意不完整的理论答案,以鼓励读者认为,“O(log d)O(d)O(d log d)O(1)O(d) 是为了???”或探索实际答案。实际的答案是尽可能简单,以避免等到宇宙结束 一千次 来强制使用像 complete 0 300000 这样的相当小的结构。
  • @KevinMeredith 首先,您需要推断出complete'(忽略递归)的一次执行最多花费一些恒定时间c(如果我们忽略整数减量)。接下来的问题是计算complete' 被评估了多少次。从这两个测量中,我们可以看到它被精确地评估了d + 1 次。然后总运行时间为(d+1)*c,即O(d)。我们可以看到complete' 内部的任何内容(如copiedTree)的评估都与complete' 成比例。
猜你喜欢
  • 2017-04-08
  • 1970-01-01
  • 1970-01-01
  • 2015-02-09
  • 2018-12-03
  • 2018-12-12
  • 1970-01-01
  • 1970-01-01
  • 2021-11-28
相关资源
最近更新 更多