有没有办法避免在插入时复制二叉树的整个搜索路径?

Pil*_*lsy 9 binary-tree haskell functional-programming

我刚刚开始研究Okasaki的Purely Functional Data Structures,但是我一直在用Haskell而不是Standard ML做事.但是,我遇到了一个早期练习(2.5),让我对如何在Haskell中做事情感到有点困惑:

将现有元素插入二叉搜索树会复制整个搜索路径,即使复制的节点与原始节点无法区分.使用异常重写插入以避免此复制.每次插入只建立一个处理程序,而不是每次迭代一个处理程序.

现在,我的理解是,作为一种不纯洁的语言,ML通过传统的异常处理方法得到了解,而不是Java,所以你可以完成这样的事情:

type Tree = E | T of Tree * int * Tree

exception ElementPresent

fun insert (x, t) = 
  let fun go E = T (E, x, E)
      fun go T(l, y, r) = 
             if      x < y then T(go (l), x, r)
             else if y < x then T(l, x, go (r))
             else    raise ElementPresent
  in go t
  end 
  handle ElementPresent => t
Run Code Online (Sandbox Code Playgroud)

我没有ML实现,所以这在语法方面可能不太正确.

我的问题是,我不知道这是如何在Haskell做,在做的一切之外IO单子,这似乎是作弊,即使它不是作弊,将严重限制其真正的功能的用处并不做任何突变.我可以使用Maybemonad:

data Tree a = Empty | Fork (Tree a) a (Tree a)
        deriving (Show)

insert     :: (Ord a) => a -> Tree a -> Tree a
insert x t = maybe t id (go t)
  where go Empty   = return (Fork Empty x Empty)
    go (Fork l y r)
      | x < y     = do l' <- go l; return (Fork l' y r)
      | x > y     = do r' <- go r; return (Fork l y r')
      | otherwise = Nothing
Run Code Online (Sandbox Code Playgroud)

这意味着一切卷起包裹Just在备份时未找到该元素的方式,这需要更多的堆分配和排序的失败的目的.这种分配只是纯度的价格吗?

编辑添加:为什么我想知道Maybe解决方案的适用性的原因很多,所描述的优化似乎只能保存在元素已经存在的情况下所需的所有构造函数调用,这意味着堆分配与搜索路径的长度.该Maybe也避免了这些构造函数调用时元素已经存在,但你得到了一些Just构造函数调用等于搜索路径的长度.我知道一个足够聪明的编译器可以忽略所有的Just分配,但我不知道,例如,当前版本的GHC是否真的那么聪明.

And*_*ács 5

在这种情况下,GHC通常不能避免路径复制.但是,有一种方法可以手动完成,而不会产生任何间接/分配成本Maybe.这里是:

{-# LANGUAGE MagicHash #-}

import GHC.Prim (reallyUnsafePtrEquality#)

data Tree a = Empty | Fork (Tree a) a (Tree a)
        deriving (Show)

insert :: (Ord a) => a -> Tree a -> Tree a
insert x Empty = Fork Empty x Empty
insert x node@(Fork l y r)
    | x < y = let l' = insert x l in 
        case reallyUnsafePtrEquality# l l' of
            1# -> node
            _  -> Fork l' y r
    | x > y = let r' = insert x r in
        case reallyUnsafePtrEquality# r r' of
            1# -> node
            _  -> Fork l y r'
    | otherwise = node
Run Code Online (Sandbox Code Playgroud)

指针等式函数完全符合名称中的内容.这里是安全的,因为即使相等性返回假阴性,我们只进行一些额外的复制,没有更糟糕的事情发生.

它不是最惯用或最漂亮的Haskell,但性能优势可能非常显着.事实上,这个技巧经常被使用unordered-containers.

  • @dfeuer GHC在指针本身上使用构造函数标记(对于64位系统上最多7个构造函数),因此`Nothing`情况几乎是在布尔标志上进行分支.但是,`Just`情况为构造函数执行一次堆分配,然后为每个树节点执行一次解除引用,这是大部分成本所在的位置.使用指针比较可以避免取消引用和分配(或者,我们可以通过返回`(#Int#,a#)`未装箱的元组来模拟`Maybe`,这具有安全和确定性的额外好处). (3认同)
  • @jberryman:关于严格性,我打算将递归情况严格,但我完全忘记了爆炸模式(我现在编辑了答案).Thunks确实会使指针等式为false,因此这对无限树无效.但是二进制搜索树(以及Haskell平台中的几乎所有关联容器)无论如何都是严格的,所以这不是问题.此外,由于GC指针随时复制,指针相等性可以返回错误否定. (2认同)

Jak*_*hur 5

在成本方面,ML版本实际上与您的Haskell版本非常相似.

ML版本中的每个递归调用都会产生堆栈帧.Haskell版本也是如此.这将与您在树中遍历的路径大小成比例.此外,如果实际执行插入,两个版本当然将为整个路径分配新节点.

在您的Haskell版本中,每个递归调用最终也可能导致Just节点的分配.这将进入次要堆,这只是一个带有凹凸指针的内存块.出于所有实际目的,GHC的次要堆与堆栈的成本大致相当.由于这些是短期分配,它们通常不会最终被移动到主堆.