【问题标题】:Tree traversal in C++ and HaskellC++ 和 Haskell 中的树遍历
【发布时间】:2014-10-07 04:58:02
【问题描述】:

我是 Haskell 的新手。我试图了解haskell 可以如何处理递归函数调用及其惰性求值。我所做的实验是简单地在 C++ 和 Haskell 中构建二叉搜索树,并分别以后序遍历它们。 C++ 实现是带有辅助堆栈的标准实现。 (一旦我访问它,我就将元素打印出来)。

这是我的haskell代码:

module Main (main) where

import System.Environment (getArgs)
import System.IO
import System.Exit
import Control.Monad(when)
import qualified Data.ByteString as S

main = do
     args <- getArgs
     when (length args < 1) $ do
          putStrLn "Missing input files"
          exitFailure

     content <- readFile (args !! 0)
     --preorderV print $ buildTree content
     mapM_ print $ traverse POST $ buildTree content
     putStrLn "end"


data BSTree a = EmptyTree | Node a (BSTree a) (BSTree a) deriving (Show)
data Mode = IN | POST | PRE

singleNode :: a -> BSTree a
singleNode x = Node x EmptyTree EmptyTree

bstInsert :: (Ord a) => a -> BSTree a -> BSTree a
bstInsert x EmptyTree = singleNode x
bstInsert x (Node a left right)
          | x == a = Node a left right
          | x < a  = Node a (bstInsert x left) right
          | x > a  = Node a left (bstInsert x right)

buildTree :: String -> BSTree String
buildTree = foldr bstInsert EmptyTree . words

preorder :: BSTree a -> [a]
preorder EmptyTree = []
preorder (Node x left right) = [x] ++ preorder left ++ preorder right

inorder :: BSTree a -> [a]
inorder EmptyTree = []
inorder (Node x left right) = inorder left ++ [x] ++ inorder right

postorder :: BSTree a -> [a]
postorder EmptyTree = []
postorder (Node x left right) = postorder left ++  postorder right ++[x]

traverse :: Mode -> BSTree a -> [a]
traverse x tree = case x of IN   -> inorder tree
                            POST -> postorder tree
                            PRE  -> preorder tree


preorderV :: (a->IO ()) -> BSTree a -> IO ()
preorderV f EmptyTree = return ()
preorderV f (Node x left right) = do 
                                     f x
                                     preorderV f left
                                     preorderV f right

我的测试结果表明 C++ 明显优于 Haskell:

C++ 性能:(注意 first15000.txt 大约是 first3000.txt 的 5 倍)

time ./speedTestForTraversal first3000.txt > /dev/null 

real    0m0.158s
user    0m0.156s
sys     0m0.000s
time ./speedTestForTraversal first15000.txt > /dev/null 

real    0m0.923s
user    0m0.916s
sys     0m0.004s

具有相同输入文件的 Haskell:

time ./speedTestTreeTraversal first3000.txt > /dev/null 

real    0m0.500s
user    0m0.488s
sys     0m0.008s
time ./speedTestTreeTraversal first15000.txt > /dev/null 

real    0m3.511s
user    0m3.436s
sys     0m0.072s

我期望haskell应该离C++不远。我犯了什么错误吗?有什么方法可以改进我的 haskell 代码吗?

谢谢

编辑: 2014 年 10 月 18 日

经过多次测试,haskell 的遍历仍然明显慢于 C++ 实现。我想对 Cirdec 的回答给予充分肯定,因为他指出我的 haskell 实施效率低下。但是,我最初的问题是比较 C++ 和 haskell 的实现。因此,我想保持这个问题的开放性并发布我的 C++ 代码以鼓励进一步讨论。

#include <iostream>
#include <string>
#include <boost/algorithm/string.hpp>
#include <fstream>
#include <stack>
using namespace std;
using boost::algorithm::trim;
using boost::algorithm::split;


template<typename T>
class Node
{
public:
    Node(): val(0), l(NULL), r(NULL), p(NULL) {};
    Node(const T &v): val(v), l(NULL), r(NULL), p(NULL) {}
    Node* getLeft() {return l;}
    Node* getRight(){return r;}
    Node* getParent() {return p;}
    void  setLeft(Node *n) {l = n;}
    void  setRight(Node *n) {r = n;}
    void  setParent(Node *n) {p = n;}
    T  &getVal() {return val;}
    Node* getSucc() {return NULL;}
    Node* getPred() {return NULL;}
private:
    T val;
    Node *l;
    Node *r;
    Node *p;
};

template<typename T>
void destoryOne(Node<T>* n)
{
    delete n;
    n = NULL;
}

template<typename T>
void printOne(Node<T>* n)
{
    if (n!=NULL)
    std::cout << n->getVal() << std::endl;
}




template<typename T>
class BinarySearchTree
{
public:
    typedef void (*Visit)(Node<T> *);

    BinarySearchTree(): root(NULL) {}
    void delNode(const T &val){};
    void insertNode(const T &val){
    if (root==NULL)
        root = new Node<T>(val);
    else {
        Node<T> *ptr = root;
        Node<T> *ancester = NULL;
        while(ptr && ptr->getVal()!=val) {
        ancester = ptr;
        ptr = (val < ptr->getVal()) ? ptr->getLeft() : ptr->getRight(); 
        }
        if (ptr==NULL) {
        Node<T> *n = new Node<T>(val);
        if (val < ancester->getVal())
            ancester->setLeft(n);
        else
            ancester->setRight(n);
        } // else the node exists already so ignore!
    }
    }
    ~BinarySearchTree() {
    destoryTree(root);
    }
    void destoryTree(Node<T>* rootN) {
    iterativePostorder(&destoryOne);
    }

    void iterativePostorder(Visit fn) {
    std::stack<Node<T>* > internalStack;
    Node<T> *p = root;
    Node<T> *q = root;
    while(p) {
        while (p->getLeft()) {
        internalStack.push(p);
        p = p->getLeft();
        }
        while (p && (p->getRight()==NULL || p->getRight()==q)) {
        fn(p);
        q = p;
        if (internalStack.empty())
            return;
        else {
            p = internalStack.top();
            internalStack.pop();
        }
        }
        internalStack.push(p);
        p = p->getRight();
    }
    }


    Node<T> * getRoot(){ return root;}
private:
    Node<T> *root;
};



int main(int argc, char *argv[])
{
    BinarySearchTree<string> bst;
    if (argc<2) {
    cout << "Missing input file" << endl;
    return 0;
    }
    ifstream inputFile(argv[1]);
    if (inputFile.fail()) {
    cout << "Fail to open file " << argv[1] << endl;
    return 0;
    }
    while (!inputFile.eof()) {
    string word;
    inputFile >> word;
    trim(word);
    if (!word.empty()) {
        bst.insertNode(word);
    }
    }

    bst.iterativePostorder(&printOne);

    return 0;
}

编辑: 2014 年 10 月 20 日 克里斯在下面的回答非常彻底,我可以重复结果。

【问题讨论】:

  • 请注意,在主函数中,我注释掉了一行。我在想直接打印出元素而不是先列出一个列表可能会更好。事实证明,这两种方法具有相似的性能。看起来惰性评估在这里做得很好。
  • 也许你的意思是postorder (Node x left right) = postorder left ++ postorder right ++ [x]

标签: haskell recursion lazy-evaluation


【解决方案1】:

++ 的列表连接很慢,每次出现++ 时,都必须将其第一个参数遍历到末尾才能找到添加第二个参数的位置。你可以从standard prelude++的定义中看到第一个参数是如何一直遍历到[]的:

(++) :: [a] -> [a] -> [a]
[]     ++ ys = ys
(x:xs) ++ ys = x : (xs ++ ys)

当递归使用++ 时,必须为每一级递归重复此遍历,这是低效的。

还有另一种构建列表的方法:如果您在开始构建列表之前就知道列表末尾会出现什么,那么您可以在末尾已经就位的情况下构建它。我们来看看postorder的定义

postorder :: BSTree a -> [a]
postorder EmptyTree = []
postorder (Node x left right) = postorder left ++ postorder right ++ [x]

当我们创建postorder left 时,我们已经知道它后面会发生什么,它将是postorder right ++ [x],因此为树的左侧和右侧的值构建列表是有意义的节点已经到位。同样,当我们创建postorder right 时,我们已经知道它后面应该是什么,即x。我们可以通过创建一个帮助函数来传递列表的rest 的累积值来做到这一点

postorder :: BSTree a -> [a]
postorder tree = go tree []
    where
        go EmptyTree rest = rest
        go (Node x left right) rest = go left (go right (x:rest))

当使用 15k 单词词典作为输入运行时,这在我的机器上大约快两倍。让我们对此进行更多探索,看看我们是否可以获得更深入的理解。如果我们使用函数组合 (.) 和应用程序 ($) 而不是嵌套括号重写我们的 postorder 定义,我们将拥有

postorder :: BSTree a -> [a]
postorder tree = go tree []
    where
        go EmptyTree rest = rest
        go (Node x left right) rest = go left . go right . (x:) $ rest

我们甚至可以删除 rest 参数和函数应用程序 $,并以稍微无点的风格编写它

postorder :: BSTree a -> [a]
postorder tree = go tree []
    where
        go EmptyTree = id
        go (Node x left right) = go left . go right . (x:)

现在我们可以看到我们做了什么。我们已将列表[a] 替换为函数[a] -&gt; [a],该函数将列表添加到现有列表的前面。空列表被替换为不向列表开头添加任何内容的函数,即标识函数id。单例列表[x] 被替换为将x 添加到列表开头的函数(x:)。列表连接 a ++ b 被替换为函数组合 f . g - 首先添加 g 将添加到列表开头的内容,然后添加 f 将添加到列表开头的内容。

【讨论】:

  • 仅供参考,@Cirdec 提出的方法称为“差异列表”,在函数式和逻辑编程中相当标准。
  • "每次出现++,都要遍历它的第一个参数" it's a little bit more nuanced than that,毕竟定义和遍历发生在不同的时间。关键是(++) 构建了一个严格的explicit ++ 结构; go 重新排列 implicit (“未来”)结构,向右“旋转”,因此最终得到一个更右倾的结构(好)。 -- “为树的左侧构建列表,右侧......已经到位”这是一个很好的表达方式。
  • 感谢 Cirdec 的回复。我知道 (++) 比 (:) 贵。感谢您为我指出这个技巧。但是,在我编译您的代码之后。我得到了和以前非常相似的表现。您的测试是在我的代码上运行还是您有不同的代码?我想知道我的程序是否还有其他瓶颈。
  • @Yan Zhu 我用这本字典的前 15k 个条目运行了你的代码:docs.oracle.com/javase/tutorial/collections/interfaces/examples/…。这些是按字母顺序排列的,因此它们会导致bstInsert 的性能最差。我使用命令ghc -O2 使用64 位Windows GHC 7.8.3 编译了代码。我通过在 mapM_ print $ traverse POST $ buildTree content 行前后添加 System.CPUTime 中的 getCPUTime 来计时代码。
  • @Cirdec 完整测试将尝试增加、减少和随机输入(insert 的最坏情况可能是++ 的最佳情况)。另外,任何一个大小点的速度比较都是没有意义的。至少需要两个,最好是更多的大小点才能看到empirical orders of growth 的比较。例如,OP 数据显示 C 为 N^1.1(典型的线性行为),Haskell 为 N^1.2(N (log N)^2 的暗示),相差不大。
【解决方案2】:

我生成了一个文件,其中包含以空格分隔的小写 ASCII 字母 abcdefghijklmnopqrstuvwxyz 上的所有 4 字母字符串;而且我认为我以正确的顺序得到了它,因此您的代码生成的树是完美平衡的。

我选择这个长度是因为它在我的计算机上需要 3.4 秒,就像你的 3.5 秒 Haskell 运行一样。出于显而易见的原因,我将其称为26_4.txt。听起来您的数据集实际上接近 264 个单词,因此长度也相当。

运行时的下限类似于:

import System.IO
main = do
    mylist <- readFile "26_4.txt"
    mapM_ putStrLn (words mylist)

这对于我的数据集减少到 0.4 秒(当然,将标准输出管道传输到 /dev/null)。因此,我们不能期望 Haskell 对这类问题的加速提高 10 倍,看起来像这样。但是,该因素完全在您的问题范围内; C++ 花费的时间是这个超级简单程序的两倍。

但是,不进行处理是不切实际的目标。如果我们使用已经由更了解 Haskell 的专业人士为我们优化的数据结构,我们可以获得更现实的界限:

import System.IO
import qualified Data.Map.Strict as Map

balancedTree = Map.fromList . map (\k -> (k, ()))

serializeTree = map fst . Map.toList

main = do
    mylist <- readFile "26_4.txt"
    mapM_ putStrLn (serializeTree $ balancedTree $ words mylist)

这在我的机器上运行的时间更像是 1.6 秒。它不如你的 C++ 快,但据我所知,你的 C++ 不能平衡树。

我对代码进行了 Cirdec 的修改,它使您的代码缩短到 3.1 秒,因此它只减少了大约 10% 的文件运行时间。

但是,在我的机器上,这个文件甚至不能运行,除非你用 RTSopts 给它更多的内存。这指向了一个非常重要的优化:尾调用优化。你和 Cirdec 的代码在一个特殊的方面是次优:它不是尾递归,这意味着它不能被 GHC 变成一个循环。我们可以通过编写一个显式的“待完成的事情”堆栈来使其成为尾递归:

postorder :: BSTree a -> [a]
postorder t = go [] [t]
    where go xs [] = xs
          go xs (EmptyTree : ts) = go xs ts
          go xs (Node x a b : ts) = go (x : xs) (b : a : ts)

这一变化似乎将其一直降低到 2.1 秒。

C++ 和 Haskell 之间的另一个导致浪费一些时间的区别是 Haskell 版本将允许您懒惰地构建搜索树,而您的 C++ 代码不允许这样做。我们可以让 Haskell 代码严格处理这个问题,提供如下内容:

data BSTree a = EmptyTree
          | Node  !a !(BSTree a) !(BSTree a) deriving (Show)

这一变化与 Cirdec 的结合使我们缩短到 1.1 秒,这意味着我们与您的 C++ 代码相当,至少在我的机器上是这样。您应该在您的机器上进行测试,看看这些是否也是主要问题。我认为任何进一步的优化都不能“从扶手椅上”完成,而必须使用适当的分析器来完成。

记得ghc -O2代码,否则尾调用和其他优化可能不会采取。

【讨论】:

  • IMO,您对后序的实现可以看作是 C++ 中迭代算法的尾递归转换。我相信类似的技巧可以用于订购/预订,虽然我还没有尝试过。我说的对吗?
  • 是的,尾递归只是迭代算法,其中循环的所有状态变量都被提升为参数!我相信preorder 的代码只是将b : a : ts 换成a : b : ts;我认为inorder 需要更多的思考。我们可以将内部堆栈的类型概括为[(BSTree x, [x])],其中该对的第二个元素包含在处理此树后要附加的元素的反向列表,使用app (x:xs) ys = app xs (x : ys)。然后例如go out ((Node x left right, xs) : ts) = go out ((right, x:xs) : (left, []) : ts) 具有 inorder 逻辑。
猜你喜欢
  • 2011-05-24
  • 2011-01-21
  • 1970-01-01
  • 2018-01-21
  • 2014-09-26
  • 2021-11-20
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
相关资源
最近更新 更多