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)
时间内运行?你能说出原因或原因吗?
答案 0 :(得分:4)
代码的有趣部分是complete'
函数:
complete' d
| d == 0 = Empty
| otherwise = let copiedTree = complete' (d-1)
in Node x copiedTree copiedTree
如Cirdec's answer所示,我们应该小心分析实施的每个部分,以确保我们的假设是有效的。作为一般规则,我们可以假设以下每个*
:
使用数据构造函数构造值(例如,使用Empty
创建空树或使用Node
将值和两棵树转换为树)。
对值进行模式匹配,以查看其构建的数据构造函数以及应用数据构造函数的值。
Guards和if / then / else表达式(使用模式匹配在内部实现)。
将Integer
与0进行比较。
Cirdec提到从Integer
减去1的运算是整数大小的对数。正如他们所说,这实际上是Integer
实现方式的工件。可以实现整数,这样只需要一步就可以将它们与0进行比较,并且只需要一步就可以将它们减1。为了保持一般性,可以安全地假设有一些函数c使得成本递减Integer
的是c(深度)。
现在我们已经完成了预备工作,让我们开始工作吧!通常情况下,我们需要建立一个方程组并解决它。设f(d)是计算complete' d
所需的步数。然后第一个方程非常简单:
f(0) = 2
这是因为将d
与0进行比较需要一步,另一步是检查结果是否为True
。
另一个等式是有趣的部分。想想d > 0
:
d == 0
。True
(不是)。d-1
(让我们调用结果dm1
)complete' dm1
,将结果保存为copiedTree
。Node
构造函数应用于x
,copiedTree
和copiedTree
。第一部分需要1步。第二部分迈出了一步。第三部分采用c(深度)步骤,第五步采用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(深度))+ 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(深度)&gt; = 1,所以我们得到
f(d) ∈ O(d*c(depth))
缩小到complete
,这看起来像O(深度* c(深度))
如果使用Integer
递减的实际成本,则会得到O(深度* log(深度))。如果假装Integer
递减为O(1),则会给出O(深度)。
旁注:当你继续在Okasaki工作时,你最终会到达第10.2.1节,在那里你将看到一种方法来实现支持O(1)减量和O(1)添加的自然数(但不是有效的减法)。
*
Haskell的懒惰评估使得这一点不能完全正确,但是如果你假装所有内容都是严格评估的,那么你将获得真值的上限,这在这种情况下就足够了。如果你想学习如何分析使用懒惰来获得良好渐近边界的数据结构,你应该继续阅读Okasaki。
答案 1 :(得分:3)
不,它不会在O(d)
时间内运行。其asymptotic performance由Integer
减法d-1
支配,花费O(log d)
时间。重复O(d)
次,给出O(d log d)
时间的渐近上限。
如果使用具有渐近最优Integer
递减的O(1)
表示,则此上限可以得到改善。 In practice we don't,因为即使对于难以想象的大值,渐近最优Integer
实现也会变慢。
实际上Integer
算术只占程序运行时间的一小部分。对于实际的“大”深度(小于机器字),程序的运行时间将由分配和填充内存来控制。对于较大的深度,您将耗尽计算机的资源。
询问运行时系统的profiler。
为了分析您的代码,我们首先需要确保它运行。 Haskell是懒惰的评估,因此,除非我们做一些事情以使树被完全评估,否则它可能不会。不幸的是,完全探索树将采取O(2^d)
步骤。如果我们跟踪他们的StableName
,我们可以避免强制我们已经访问过的节点。幸运的是,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