16

我有一棵树,其节点存储 -1 或非负整数,即顶点的名称。每个顶点在树中最多出现一次。以下函数是我的代码中的瓶颈:

版本 A:

void node_vertex_members(node *A, vector<int> *vertexList){
   if(A->contents != -1){
      vertexList->push_back(A->contents);
   }
   else{
      for(int i=0;i<A->children.size();i++){
          node_vertex_members(A->children[i],vertexList);
      }
   }
}

版本 B:

void node_vertex_members(node *A, vector<int> *vertexList){
   stack<node*> q;
   q.push(A);
   while(!q.empty()){
      int x = q.top()->contents;
      if(x != -1){
         vertexList->push_back(x);
         q.pop();
      }
      else{
         node *temp = q.top();
         q.pop();
         for(int i=temp->children.size()-1; i>=0; --i){
            q.push(temp->children[i]);
         }
      }
   }
}

出于某种原因,版本 B 的运行时间明显长于版本 A,这是我没有预料到的。编译器可能在做什么比我的代码聪明得多?换句话说,我在做什么这么低效?让我感到困惑的是,如果我在将孩子的内容放入堆栈之前尝试检查版本 B 是否为 -1,它会显着减慢(几乎 3 倍)。作为参考,我在 Cygwin 中使用带有 -O3 选项的 g++。

更新:

我能够使用以下代码(版本 C)匹配递归版本:

node *node_list[65536];

void node_vertex_members(node *A, vector<int> *vertex_list){
   int top = 0;
   node_list[top] = A;
   while(top >= 0){
      int x = node_list[top]->contents;
      if(x != -1){
         vertex_list->push_back(x);
         --top;
      }
      else{
         node* temp = node_list[top];
         --top;
         for(int i=temp->children.size()-1; i>=0; --i){
            ++top;
            node_list[top] = temp->children[i];
         }
      }
   }
}

明显的缺点是代码长度和幻数(以及相关的硬限制)。而且,正如我所说,这仅匹配版本 A 的性能。我当然会坚持使用递归版本,但现在我很满意它基本上是 STL 开销咬我。

4

3 回答 3

13

Version A has one significant advantage: far smaller code size.

Version B has one significant disadvantage: memory allocation for the stack elements. Consider that the stack starts out empty and has elements pushed into it one by one. Every so often, a new allocation will have to be made for the underlying deque. This is an expensive operation, and it may be repeated a few times for each call of your function.

Edit: here's the assembly generated by g++ -O2 -S with GCC 4.7.3 on Mac OS, run through c++filt and annotated by me:

versionA(node*, std::vector<int, std::allocator<int> >*):
LFB609:
        pushq   %r12
LCFI5:
        movq    %rsi, %r12
        pushq   %rbp
LCFI6:
        movq    %rdi, %rbp
        pushq   %rbx
LCFI7:
        movl    (%rdi), %eax
        cmpl    $-1, %eax ; if(A->contents != -1)
        jne     L36 ; vertexList->push_back(A->contents)
        movq    8(%rdi), %rcx
        xorl    %r8d, %r8d
        movl    $1, %ebx
        movq    16(%rdi), %rax
        subq    %rcx, %rax
        sarq    $3, %rax
        testq   %rax, %rax
        jne     L46 ; i < A->children.size()
        jmp     L35
L43: ; for(int i=0;i<A->children.size();i++)
        movq    %rdx, %rbx
L46:
        movq    (%rcx,%r8,8), %rdi
        movq    %r12, %rsi
        call    versionA(node*, std::vector<int, std::allocator<int> >*)
        movq    8(%rbp), %rcx
        leaq    1(%rbx), %rdx
        movq    16(%rbp), %rax
        movq    %rbx, %r8
        subq    %rcx, %rax
        sarq    $3, %rax
        cmpq    %rbx, %rax
        ja      L43 ; continue
L35:
        popq    %rbx
LCFI8:
        popq    %rbp
LCFI9:
        popq    %r12
LCFI10:
        ret

L36: ; vertexList->push_back(A->contents)
LCFI11:
        movq    8(%rsi), %rsi
        cmpq    16(%r12), %rsi ; vector::size == vector::capacity
        je      L39
        testq   %rsi, %rsi
        je      L40
        movl    %eax, (%rsi)
L40:
        popq    %rbx
LCFI12:
        addq    $4, %rsi
        movq    %rsi, 8(%r12)
        popq    %rbp
LCFI13:
        popq    %r12
LCFI14:
        ret
L39: ; slow path for vector to expand capacity
LCFI15:
        movq    %rdi, %rdx
        movq    %r12, %rdi
        call    std::vector<int, std::allocator<int> >::_M_insert_aux(__gnu_cxx::__normal_iterator<int*, std::vector<int, std::allocator<int> > >, int const&)
        jmp     L35

That's fairly succinct and at a glance seems fairly free of "speed bumps." When I compile with -O3 I get an unholy mess, with unrolled loops and other fun stuff. I don't have time to annotate Version B right now, but suffice to say it is more complex due to many deque functions and scribbling on a lot more memory. No surprise it's slower.

于 2013-06-23T05:10:17.490 回答
3

版本 B 中的最大大小q明显大于版本 A 中的最大递归深度。这可能会降低缓存性能的效率。

(版本 A:深度为log(N)/log(b),版本 B:队列长度命中b*log(N)/log(b)

于 2013-06-23T06:03:50.560 回答
1

第二个代码速度较慢,因为除了要返回的集合之外,它还维护第二个动态集数据结构。这涉及更多的内存分配、更多的对象初始化、更多的列表插入和删除。

但是,第二个代码中的算法更灵活:可以对其进行简单修改,以提供广度优先遍历而不是深度优先,而递归仅执行深度优先遍历。(嗯,它可以先深入,但改变并不是那么微不足道;见最后的评论。)

由于工作是遍历所有内容并收集一些节点,假设您不想要深度优先顺序,那么深度优先遍历可能会更好。

但是在您正在搜索满足某些条件的节点的情况下,实现广度优先搜索可能更合适。如果树是无限的(因为它不是数据结构,而是可能性的搜索树,例如游戏中的未来移动或其他),则深度优先可能难以处理,因为没有底部。在某些情况下,希望找到一个靠近根的节点,而不仅仅是任何节点。深度优先搜索可能需要很长时间才能找到靠近树根的节点。如果树很深,但通常在离根不远的地方找到所需的节点,那么深度优先搜索可能会浪费大量时间,即使实现它的递归机制很快。

递归可以通过迭代加深进行广度优先:递归到最大深度 1,然后再次从顶部递归,这次到最大深度 2,依此类推。基于队列的遍历只需更改将节点添加到工作队列的顺序。

于 2013-06-23T07:02:31.440 回答