Почему моя реализация на основе стека этого кода намного медленнее, чем рекурсия?
У меня есть дерево, чьи узлы хранят либо -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 раза). Для справки, я использую g++ в Cygwin с опцией -O3.
Update:
Мне удалось сопоставить рекурсивную версию, используя следующий код (версия 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, кусая меня.
Ответы
Ответ 1
Версия A имеет одно существенное преимущество: гораздо меньший размер кода.
Версия B имеет один существенный недостаток: распределение памяти для элементов стека. Учтите, что стек начинается пустым и содержит элементы, вставляемые в него один за другим. Каждый раз так часто должно быть сделано новое распределение для лежащего ниже уровня. Это дорогостоящая операция, и ее можно повторить несколько раз для каждого вызова вашей функции.
Изменить: здесь сборка, сгенерированная g++ -O2 -S
с GCC 4.7.3 на Mac OS, выполняется через c++filt
и аннотируется мной:
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
Это довольно кратким и с первого взгляда кажется довольно свободным от "ударов по скорости". Когда я компилирую с -O3, я получаю нечестивый беспорядок, с развернутыми петлями и другими забавными вещами. У меня нет времени, чтобы аннотировать версию B прямо сейчас, но достаточно сказать, что это сложнее из-за многих функций deque и набросания на гораздо большую память. Не удивительно, что это медленнее.
Ответ 2
Максимальный размер q
в версии B значительно больше, чем максимальная глубина рекурсии в версии A. Это может сделать вашу производительность кэша довольно немного менее эффективной.
(версия A: глубина log(N)/log(b)
, версия B: длина очереди достигает b*log(N)/log(b)
)
Ответ 3
Второй код медленнее, поскольку он поддерживает вторую динамическую структуру данных в дополнение к возвращаемой коллекции. Это связано с большим объемом выделения памяти, большим количеством инициализаций объектов, добавлением и удалением списка.
Однако алгоритм во втором коде более гибкий: он может быть тривиально изменен, чтобы сначала сначала пройти обход в ширину вместо глубины, тогда как рекурсия выполняет только обход глубины. (Ну, сначала он может идти глубже, но изменение не совсем так тривиально, см. Комментарий в конце.)
Так как задача состоит в том, чтобы пересечь все и собрать некоторые узлы, возможно, лучше всего пройти первый проход, предполагая, что вам не нужен порядок глубины.
Но в ситуациях, когда вы ищете node, который удовлетворяет некоторому условию, может быть более целесообразным реализовать поиск по ширине. Если дерево бесконечно (потому что это не структура данных, а дерево поиска возможностей, например, будущие движения в игре или что-то еще), это может быть неразрешимым делать глубину в первую очередь, потому что нет дна. В некоторых ситуациях желательно найти node, который близок к корню, а не только любому node. Поиск по глубине может занять много времени, чтобы найти node, который близок к корню дерева. Если дерево глубокое, но обычно желаемый node находится недалеко от корня, поиск по глубине может потратить много времени, даже если рекурсивный механизм, который его реализует, быстро.
Рекурсия может сделать ширину - сначала путем итеративного углубления: рекурсия на максимальную глубину 1, затем повторить сверху, на этот раз до макс. глубины 2 и т.д. Обход на основе очереди просто должен изменить порядок, в котором он добавляет узлы в рабочую очередь.