Как рекурсия работает внутри цикла

Я новичок в рекурсии и пытаюсь понять этот фрагмент кода. Я учусь на экзамен, и это "рецензент", который я нашел в Библиотеке образования в Стендфорде (от двоичных деревьев Ником Парланте).

Я понимаю концепцию, но когда мы рекурсируем INSIDE THE LOOP, все это ударит! Пожалуйста, помогите мне. Спасибо.

countTrees() Решение (C/С++)

/*
 For the key values 1...numKeys, how many structurally unique
 binary search trees are possible that store those keys.
 Strategy: consider that each value could be the root.
 Recursively find the size of the left and right subtrees.
*/

int countTrees(int numKeys) {
    if (numKeys <=1) {
        return(1);
    }

    // there will be one value at the root, with whatever remains
    // on the left and right each forming their own subtrees.
    // Iterate through all the values that could be the root...

    int sum = 0;
    int left, right, root;

    for (root=1; root<=numKeys; root++) {
        left = countTrees(root - 1);
        right = countTrees(numKeys - root);
        // number of possible trees with this root == left*right
        sum += left*right;
    }

    return(sum);  
}  

Ответ 1

Представьте себе, что цикл ставится "на паузу", когда вы входите в вызов функции.

Просто потому, что эта функция является рекурсивным вызовом, она работает так же, как любая функция, которую вы вызываете в цикле.

Новый рекурсивный вызов запускает цикл for и снова делает паузу при повторном вызове функций и так далее.

Ответ 2

Посмотрите на это следующим образом: Есть 3 возможных случая для начального вызова:

numKeys = 0
numKeys = 1
numKeys > 1

0 и 1 случаи просты - функция просто возвращает 1, и все готово. Для numkeys 2 вы получите:

sum = 0
loop(root = 1 -> 2)
   root = 1:
      left = countTrees(1 - 1) -> countTrees(0) -> 1
      right = countTrees(2 - 1) -> countTrees(1) -> 1
      sum = sum + 1*1 = 0 + 1 = 1
   root = 2:
      left = countTrees(2 - 1) -> countTrees(1) -> 1
      right = countTrees(2 - 2) -> countTrees(0) -> 1
      sum = sum + 1*1 = 1 + 1 = 2

output: 2

для numKeys = 3:

sum = 0
loop(root = 1 -> 3):
   root = 1:
       left = countTrees(1 - 1) -> countTrees(0) -> 1
       right = countTrees(3 - 1) -> countTrees(2) -> 2
       sum = sum + 1*2 = 0 + 2 = 2
   root = 2:
       left = countTrees(2 - 1) -> countTrees(1) -> 1
       right = countTrees(3 - 2) -> countTrees(1) -> 1
       sum = sum + 1*1 = 2 + 1 = 3
   root = 3:
       left = countTrees(3 - 1) -> countTrees(2) -> 2
       right = countTrees(3 - 3) -> countTrees(0) -> 1
       sum = sum + 2*1 = 3 + 2 = 5

 output 5

и т.д. Эта функция, скорее всего, O (n ^ 2), так как для каждого n-ключей вы используете рекурсивные вызовы 2 * n-1, что означает, что ее время выполнения будет расти очень быстро.

Ответ 3

Просто помните, что все локальные переменные, такие как numKeys, sum, left, right, root находятся в памяти стека. Когда вы переходите к глубине n-th рекурсивной функции, будут n копии этих локальных переменных. Когда он закончит выполнение одной глубины, одна копия этой переменной выскочит из стека.

Таким образом, вы поймете, что глубина следующего уровня НЕ будет влиять на локальные переменные глубины текущего уровня (ЕСЛИ вы используете ссылки, но мы НЕ в этой конкретной проблеме).

Для этой конкретной задачи необходимо внимательно обратить внимание на сложность времени. Вот мои решения:

/* Q: For the key values 1...n, how many structurally unique binary search
      trees (BST) are possible that store those keys.
      Strategy: consider that each value could be the root.  Recursively
      find the size of the left and right subtrees.
      http://stackoverflow.com/questions/4795527/
             how-recursion-works-inside-a-for-loop */
/* A: It seems that it the Catalan numbers:
      http://en.wikipedia.org/wiki/Catalan_number */
#include <iostream>
#include <vector>
using namespace std;

// Time Complexity: ~O(2^n)
int CountBST(int n)
{
    if (n <= 1)
        return 1;

    int c = 0;
    for (int i = 0; i < n; ++i)
    {
        int lc = CountBST(i);
        int rc = CountBST(n-1-i);
        c += lc*rc;
    }

    return c;
}

// Time Complexity: O(n^2)
int CountBST_DP(int n)
{
    vector<int> v(n+1, 0);
    v[0] = 1;

    for (int k = 1; k <= n; ++k)
    {
        for (int i = 0; i < k; ++i)
            v[k] += v[i]*v[k-1-i];
    }

    return v[n];
}

/* Catalan numbers:
            C(n, 2n)
     f(n) = --------
             (n+1)
              2*(2n+1)
     f(n+1) = -------- * f(n)
               (n+2)

   Time Complexity: O(n)
   Space Complexity: O(n) - but can be easily reduced to O(1). */
int CountBST_Math(int n)
{
    vector<int> v(n+1, 0);
    v[0] = 1;

    for (int k = 0; k < n; ++k)
        v[k+1] = v[k]*2*(2*k+1)/(k+2);

    return v[n];
}

int main()
{
    for (int n = 1; n <= 10; ++n)
        cout << CountBST(n) << '\t' << CountBST_DP(n) <<
                               '\t' << CountBST_Math(n) << endl;

    return 0;
}
/* Output:
1       1       1
2       2       2
5       5       5
14      14      14
42      42      42
132     132     132
429     429     429
1430    1430    1430
4862    4862    4862
16796   16796   16796
 */

Ответ 4

Вы можете думать об этом из базового случая, работая вверх.

Итак, для базового варианта у вас есть 1 (или меньше) узлов. Существует только 1 структурно уникальное дерево, которое возможно с 1 node - это сам node. Итак, если numKeys меньше или равно 1, просто верните 1.

Теперь предположим, что у вас более 1 ключа. Ну, тогда один из этих ключей - это корень, некоторые элементы находятся в левой ветке, а некоторые элементы находятся в правой ветке.

Насколько велики эти левые и правые ветки? Ну, это зависит от того, что является корневым элементом. Поскольку вам нужно учитывать общее количество возможных деревьев, мы должны учитывать все конфигурации (все возможные значения корня) - поэтому мы перебираем все возможные значения.

Для каждой итерации я мы знаем, что я находится в корне, i-1 узлов находятся на левой ветки, а узлы numKeys-i находятся на правой ветки. Но, конечно, у нас уже есть функция, которая учитывает общее количество конфигураций деревьев с учетом количества узлов! Это функция, которую мы пишем. Итак, рекурсивный вызов функции, чтобы получить число возможных конфигураций деревьев левого и правого поддеревьев. Общее количество деревьев, доступных с я в корне, является потомством этих двух чисел (для каждой конфигурации левого поддерева возможны все возможные правые поддеревья).

После того, как вы суммируете все это, вы закончили.

Итак, если вы изложили это, то ничего особенного в вызове функции рекурсивно изнутри цикла - это просто инструмент, который нам нужен для нашего алгоритма. Я также рекомендовал бы (как сказал Граммин) запустить это через отладчик и посмотреть, что происходит на каждом шагу.

Ответ 5

Каждый вызов имеет свое собственное переменное пространство, как и следовало ожидать. Сложность исходит из того, что выполнение функции "прерывается", чтобы выполнить -again-ту же функцию. Этот код:

for (root=1; root<=numKeys; root++) {
        left = countTrees(root - 1);
        right = countTrees(numKeys - root);
        // number of possible trees with this root == left*right
        sum += left*right;
    }

Может быть переписан таким образом в Plain C:

 root = 1;
 Loop:
     if ( !( root <= numkeys ) ) {
         goto EndLoop;
     }

     left = countTrees( root -1 );
     right = countTrees ( numkeys - root );
     sum += left * right

     ++root;
     goto Loop;
 EndLoop:
 // more things...

Это фактически переводится компилятором на что-то подобное, но в ассемблере. Как вы видите, цикл управляется парой переменных, numkeys и root, а их значения не изменяются из-за выполнения другого экземпляра той же процедуры. Когда вызываемый абонент возвращается, вызывающий абонент возобновляет выполнение с теми же значениями для всех значений, которые он имел до рекурсивного вызова.

Ответ 6

IMO, ключевым элементом здесь является понимание фреймов вызовов функций, стека вызовов и того, как они работают вместе.

В вашем примере у вас есть куча локальных переменных, которые инициализируются, но не завершаются при первом вызове. Важно наблюдать за этими локальными переменными, чтобы понять всю идею. При каждом вызове локальные переменные обновляются и, наконец, возвращаются в обратном порядке (скорее всего, они сохраняются в регистре до того, как каждый кадр вызова функции извлекается из стека) до тех пор, пока они не добавятся в начальную переменную суммы вызова функции.

Важное различие здесь - куда вернуться. Если вам нужно накопленное значение суммы, как в вашем примере, вы не можете вернуться внутрь функции, которая вызвала бы ранний возврат/выход. Однако, если вы зависите от значения, находящегося в определенном состоянии, вы можете проверить, находится ли это состояние внутри цикла for, и сразу же вернуться, не поднимаясь до конца.

Ответ 7

  • Для рекурсии полезно представить структуру стека вызовов в уме.
  • Если рекурсия находится внутри цикла, структура напоминает (почти) N-арное дерево.
  • Цикл контролирует по горизонтали количество создаваемых веток, а рекурсия определяет высоту дерева.
  • Дерево генерируется вдоль одной конкретной ветки, пока не достигнет листа (базовое условие), затем развернется горизонтально, чтобы получить другие листья и вернуть предыдущую высоту и повторить.

Я нахожу эту перспективу в целом хорошим способом мышления.