【问题标题】:C++ Tree traversal: Why Looping is slower than Recursion in this case?C++ 树遍历:为什么在这种情况下循环比递归慢?
【发布时间】:2021-09-02 14:30:25
【问题描述】:

我需要在我的 C++ 代码中多次遍历一棵树,树的深度可能因一次迭代而异。我也可能有条件地从树遍历中提前中断。在分析我的代码时(使用 Visual Studio 编译器),我注意到树遍历部分是我代码中最大的瓶颈,因此我需要尽可能加快该部分的速度。

下面是我的代码的描述和简化的可运行版本,以展示我目前遇到的问题。

在使用递归时,我注意到我可以通过有条件地从递归中提前中断来加速我的代码。但是,我对早断的实现根本没有提高速度(参见代码)。我认为通过使用循环而不是递归,提前中断会更容易实现,所以我将树遍历转换为循环。令人惊讶的是,循环版本比递归版本慢了一个数量级!此外,early-break 最多只提高了 10% 的速度,这令人惊讶,因为这是深度优先搜索遍历,当中断发生时,树的很大一部分没有被遍历。因此,预计至少有 50-100% 的加速。

我的问题:

  1. 为什么在特定情况下,循环版本的顺序是 幅度变慢?!
  2. 为什么提前中断并不能大大提高速度(循环和递归)
  3. 非常感谢针对以下案例的任何其他性能提示。
#include <iostream>
#include <vector>
#include <stack>
#include <chrono>

using namespace std;
using namespace std::chrono;

class Node {
public:
    int id;
    int left = -1;
    int right = -1;
    int count = 0;

    Node(int _id) { id = _id; }
};
std::vector<Node> nodes;

//1) recursive tree traversal
void recursive(int node) {
    if (nodes[node].left == -1) {
        nodes[node].count++;
    }
    else {
        recursive(nodes[node].right);
        recursive(nodes[node].left);
    }
}

//2) recursive tree traversal with conditional break
void recursive2(int node, bool* stop) {
    if (*stop == false) {
        if (nodes[node].left == -1) {
            nodes[node].count++;
            if (rand() % 2 == 0) { *stop = true; } //conditional break
        }
        else {
            recursive2(nodes[node].right, stop);
            if (*stop == false) {
                recursive2(nodes[node].left, stop);
            }
        }
    }
}

// loop traversal
void loop(int node) {
    stack<int> stack;
    stack.push(node);
    while (stack.size() > 0) {
        node = stack.top();
        stack.pop();
        if (nodes[node].left == -1) {
            nodes[node].count++;
            //if (rand() % 2 == 0) { break; } // conditional break
        }
        else {
            stack.push(nodes[node].right);
            stack.push(nodes[node].left);
        }
    }
}


int main()
{
    for (int i = 0; i < 7; i++) {
        nodes.push_back(Node(i));
    }
    // make a simple tree /node 6 is the root
    nodes[4].left = nodes[0].id;
    nodes[4].right = nodes[1].id;
    nodes[5].left = nodes[2].id;
    nodes[5].right = nodes[3].id;
    nodes[6].left = nodes[4].id;
    nodes[6].right = nodes[5].id;


    /// speed comparison 
    int n = 10000000;
    int root_node = 6;

    auto start = high_resolution_clock::now();
    for (int i = 0; i < n; i++) { recursive(root_node); }
    auto stop = high_resolution_clock::now();
    auto duration = duration_cast<milliseconds>(stop - start);
    cout << "recursion:" << duration.count() << endl;

    start = high_resolution_clock::now();
    for (int i = 0; i < n; i++) {
        bool stop = false;
        recursive2(root_node, &stop);
    }
    stop = high_resolution_clock::now();
    duration = duration_cast<milliseconds>(stop - start);
    cout << "recursion with early-break:" << duration.count() << endl;

    start = high_resolution_clock::now();
    for (int i = 0; i < n; i++) { loop(root_node); }
    stop = high_resolution_clock::now();
    duration = duration_cast<milliseconds>(stop - start);
    cout << "loop:" << duration.count() << endl;
}

【问题讨论】:

  • 与您正在运行的迭代次数相比,您正在遍历的树是如此之小,以至于管理堆栈对象的动态内存会使您从循环中获得的任何收益相形见绌。 (起草完整的答案)
  • 感谢您的评论。在我的代码中,我通常处理比给定示例深 2-3 倍的树。性能特征与发布的相似。这是否意味着除非我有非常深的树,否则递归在我的情况下总是会更好?另外,是不是因为树的深度较浅,我也没有从早期休息中受益?
  • 不一定。这只是意味着您需要更有效地分配动态内存。一个容易实现的结果是:默认情况下std::stack&lt;&gt; 使用std::deque 进行存储,这在某些编译器上的实现速度非常慢。切换到 std::stack(int, std::vector` 可能会改变世界。对于小树,静态缓冲区会更好。
  • 重要的是要记住,关于算法和数据结构的大多数想法都是关于接近无穷大的数据大小。您的数据集基本上为零。此外,这非常不是你做微基准测试的方式。你会得到垃圾结果。使用quickbench.com 和适当的内存修改技术来获得真正的结果。您确定您的循环甚至正在执行任何代码,而不仅仅是因为没有可见的输出而被省略?
  • @user3134575 如果你的真实代码也使用 arena-allocated 节点,那么通过在 arena 本身上循环而不是在树上循环访问每个节点将是尽可能高效。即只做for(auto&amp; node : nodes) {if (node.left == -1) {node.count++;}}

标签: c++ performance recursion tree depth-first-search


【解决方案1】:

与您正在运行的迭代次数相比,您正在遍历的树是如此之小,以至于管理堆栈对象的动态内存会使您认为的任何收益相形见绌(我们稍后会重新讨论)您正在从循环中获得。

让我们尝试消除内存分配开销,看看速度会发生什么变化。

作为参考,这是我在本地 Visual Studio x64-Release 版本中发布的代码。

recursion:60
recursion with early-break:70
loop:2088

首先,std::stack 使用std::deque,这对于小筹码来说不是很好。切换到向量支持的堆栈应该会使事情变得更好。既然是一行更改,就没有理由至少尝试一下:

std::stack<int, std::vector<int>> stack;

recursion:58
recursion with early-break:68
loop:1853

确实如此!这不是惊天动地的,但这是一个好兆头,表明我们正走在正确的轨道上。如果我们担心进行一次大遍历所需的时间,那可能就足够了。但我们关心的是做10000000 微小的遍历,所以我们需要更进一步:完全摆脱内存分配:

// I "could" allocate the data as a local variable, 
// but then it would be on the stack, completely defeating the purpose.
// This would normally be a recyclable heap-based chunk of memory passed to
// the function.
std::array<int, 50> g_stack; // just for the example, don't actually do this.
void loop(int node) {
  auto top = g_stack.begin();
  auto push = [&](int v) {*(top++) = v;};
  auto pop = [&]() {--top;};

  push(node);
  while (top != g_stack.begin()) {
    node = *(top-1);
    pop();
    if (nodes[node].left == -1) {
      nodes[node].count++;
    }
    else {
      push(nodes[node].right);
      push(nodes[node].left);
    }
  }
}

recursion:61
recursion with early-break:68
loop:65

现在我们正在谈论!但它仍然没有击败递归。这是怎么回事?

循环总是比使用递归更快的前提并不普遍。

与递归相比,基于循环的方法的主要优势不是速度,而是它解决了递归的最大问题:堆栈空间不足的可能性。通过使用循环,您可以比递归函数调用更深入地“递归”,因为操作堆栈位于堆上,通常有更多可用空间。

有时编译器使用循环代码比使用递归做得更好,从而导致更快的运行时间,但它从来都不是给定的。

【讨论】:

  • “解决了递归的最大问题:堆栈空间不足。”我不会走那么远。
  • @xaxxon 我认为进入尾调用优化细节不适合这个问题的范围。除此之外,在像 OP 这样的情况下,循环算法完全相同,但是堆栈在手动管理的内存中而不是由系统处理,这确实是唯一的区别。我们在这里讨论的不是递归 fib() 与迭代 fib(),而是以两种不同的方式实现的相同算法。
  • 问题中的代码是否甚至在循环内运行代码?快速浏览一下似乎它可能只是被完全忽略了。
  • @xaxxon 它没有被忽略。那里有明显的副作用。
  • @user3134575 我只是说为此使用全局变量不是一个好主意,因为它使函数不可重入。
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2014-09-13
  • 1970-01-01
相关资源
最近更新 更多