Как сохранить и отладить функцию рекурсии в голове?

У меня есть вопрос о рекурсии: как я должен «думать», чтобы обрабатывать, хранить и отлаживать рекурсию в моей голове? Поясню: например, у нас есть функция, вычисляющая число Фибоначчи:

function fib(n) {
    if(n < 3) return 1;
    return fib(n - 1) + fib(n - 2);
}

Выглядит очень просто. Упростим отладку:

function fib(n) {
    if(n < 3) return 1;
    var r1 = fib(n - 1);
    var r2 = fib(n - 2);
    var result = r1 + r2;
    return result;
}

Теперь посмотрим, как будет работать эта функция, если n = 5:

fib(5) //n > 2, we need go deeper r1 = fib(n - 1) -> call fib(4)
    ->fib(4) //n > 2, we need go deeper r1 = fib(n - 1) -> call fib(3)
        ->fib(3) //n > 2, we need go deeper r1 = fib(n - 1) -> call fib(2)
            ->fib(2) n < 3 -> return 1

Теперь позвольте мне написать все еще раз (пожалуйста, читайте снизу вверх, начиная с fib(2)):

fib(5) n = 5; r1 = fib(4) -> 3; r2 = fib(3)//here we go one more time:
                                         r1 = fib(2) return [1]
                                         r2 = fib(1) return [1]
                                         r = r1 + r2 = 1 + 1 = [2]
                                         So fib(3) -> [2];
                                         Only now we can calculate fib(5):
                                            n = 5; r1 = 3; r2 = 2 ->
                                            r = r1 + r2 = 3 + 2 = 5; //Answer

    ->fib(4) n = 4; r1 = fib(3) -> 2; r2 = fib(2) -> 1; r = r1 + r2 = 2 + 1 = [3]
        ->fib(3) n = 3; r1 = fib(2) -> 1; r2 = fib(1) -> 1; r = r1 + r2 = 2 + 1 = [2] 
            ->fib(2) return [1]

А теперь посмотрим нерекурсивную функцию для числа Фибоначчи:

function fn(number) {
    if(number === 0) return 0;
    var fib = [1, 1];

    for(var i = 2; i < number; i++) {
        var temp = fib[i - 1] + fib[i - 2];
        fib.push(temp);
    }
    return fib[fib.length - 1];
}

Эта функция имеет немного больше кода, но она содержит только один цикл, и я могу легко держать все в голове без этого огромного количества рекурсивных уровней.

Рекурсивная функция, которую я показываю в качестве примера, не моя, и я не понимаю, как я должен думать, чтобы реализовать такие функции. С циклом все очень просто:

  1. у нас есть массив с двумя значениями arr = [1, 1].
  2. Мы хотим получить следующее значение?
  3. Нет проблем: просто подсчитайте сумму для arr[n] = arr[n - 1] + arr[n - 2]; вернуть обр[н];

И это все :)

Не поймите меня неправильно, я потратил несколько месяцев, чтобы понять, как «разбирать» рекурсивные функции в голове, но так и не нашел решения. Мне помогает только использование бумаги и многочасовые размышления.


person No Name QA    schedule 06.06.2016    source источник
comment
Вам удобно работать со стеком и древовидной структурой данных? Я спрашиваю, чтобы, если вам удобно, я мог использовать это, чтобы лучше сформировать свой ответ...   -  person Tejash Desai    schedule 06.06.2016
comment
@TejashDesai, да, мне комфортно и с деревьями, и со стеком   -  person No Name QA    schedule 06.06.2016
comment
Чтобы сравнить ваше итеративное решение с рекурсивным, вам также придется выписывать значения для каждой итерации, что даст вам примерно такой же объем вывода, хотя и без отступов.   -  person 500 - Internal Server Error    schedule 06.06.2016


Ответы (5)


Проблема, вероятно, в том, что вы пытаетесь сделать слишком много в своей голове.

Рекурсивные функции декларативны. В случае, который вы предоставили, n-й Фибоначчи представляет собой сумму (n-1) числа Фибоначчи и (n-2) числа Фибоначчи. Вот и все. Это все, что говорит ваша функция, и это также точное решение проблемы: НЕ ТРЕБУЕТСЯ ДУМАТЬ.

ЕДИНСТВЕННАЯ вещь, о которой вам обычно нужно думать, это базовый случай.

Хитрость? Предположим, что ваша функция уже работает идеально. Не думайте о том, как она работает. Просто представьте, что она уже есть и работает именно так, как вам нужно. Вы можете сделать вид, что даже не писали эту функцию — ее написал кто-то другой, и она работает.

Тот же метод применим почти к любой проблеме, решаемой рекурсивно.

Допустим, вы хотите получить минимальное значение в массиве. Ну, это то же самое, что взять первое значение из массива и спросить: «Это значение меньше, чем все остальные значения?»

т. е. В массиве [1,2,3,4,5] первый элемент 1 меньше наименьшего элемента в остальной части массива [2,3,4,5]?

Мы знаем, что можем получить минимальное значение в [2,3,4,5], потому что предполагаем, что наша функция работает.

Осталось одно, какой базовый вариант?

Если массив пуст, то минимальное значение не имеет смысла, и нам может потребоваться либо вернуть значение нулевого типа, либо вызвать исключение.

Если в массиве 1 элемент, то это должно быть минимальное значение, потому что других нет. Отлично. Итак, у нас есть это:

function minimumValue(arr) {
    if (arr.length == 0) {
        // handle this problem
    } else if (array.length == 1) {
        let firstElement = arr[0];
        return firstElement;
    }

    // assume the minimumValue function works
    let firstElement = arr[0];
    let restOfArray = arr.slice(1, arr.length);
    return min(firstElement, minimumValue(restOfArray));
}

Мне не нужно было много думать ни о чем. Я только что перевел точное решение, которое я имел в виду, в код, и оно работает и очень читабельно, IMO.

Если вы вообще занимаетесь математикой, то вы можете думать об этом как о доказательстве по индукции. Предположим, что это работает до N, и вам просто нужно написать случай N+1. И, конечно же, не забывайте о базовом регистре!

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

person Alex Watt    schedule 06.06.2016
comment
Благодарю вас! Ваше объяснение хорошее, но не могли бы вы предоставить полный код этой функции? Я не уверен, что полностью понял идею. - person No Name QA; 06.06.2016
comment
Готово! Если вы все еще боретесь, дайте мне знать, потому что эта функция может быть не самой простой для понимания из-за min() и 2 базовых случаев. Основная идея – это принятие желаемого за действительное. Если вы попытаетесь слишком много думать о рекурсии, это будет очень сложно. - person Alex Watt; 06.06.2016
comment
Спасибо! Это очень помогает! Еще один вопрос о функции min: что она должна делать? - person No Name QA; 06.06.2016
comment
Возвращает минимальное из двух чисел. мин(1,5) == 1. мин(5,3) == 3. - person Alex Watt; 06.06.2016
comment
Спасибо за ваше объяснение - person No Name QA; 07.06.2016

Визуализируйте это!

function fib(n, indent) {
  console.log(indent + "fib(" + n + ")");
  if(n < 3) return 1;
  indent += "  ";
  return fib(n - 1, indent) + fib(n - 2, indent);
}

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

Первоначальный вызов:

 fib(4, "");

Обратите внимание, что в простом рекурсивном случае время выполнения экспоненциально, тогда как итеративная версия линейна (попробуйте 20 или 30, чтобы увидеть разницу - и вам действительно не нужен массив, только последние два значения)

person Stefan Haustein    schedule 06.06.2016
comment
Как вам удалось создать эту функцию? Я пытаюсь сделать такие выходы в течение почти года! Как я должен думать? Извините, я не понимаю, как писать такой код. Не могли бы вы объяснить, плиз? - person No Name QA; 06.06.2016
comment
@NoNameYp Расширили ответ, чтобы лучше объяснить его, и упростили глубину до строки отступа. Не расстраивайтесь, в основном это просто вопрос работы с кодом (я начал около 33 лет назад...) - person Stefan Haustein; 06.06.2016

Вот как вы должны думать о своей рекурсивной функции.

  1. Ответ: Должна ли это быть рекурсивная функция? Есть ли у меня итеративная операция, которую мне нужно вызывать до тех пор, пока не будет выполнено условие завершения?
  2. Ответ: Каково мое прекращение действия?
  3. Ответ: Нужно ли мне проверять условие завершения перед выполнением операции или после?

Оттуда вы можете сформировать более конкретную стратегию на основе вашего варианта использования.

person Glenn Ferrie    schedule 06.06.2016
comment
Условие завершения также известно как базовый случай. - person alex; 06.06.2016

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

Когда вам нужно сделать что-то, что требует рекурсии, вы будете знать. Вы узнаете, потому что вы начнете с цикла for или while и через 2 дня поймете, что не можете делать то, что вам нужно, и вам понадобится какая-то рекурсивная функция.

person IMTheNachoMan    schedule 06.06.2016

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

Определения в математике часто даются рекурсивно. Посмотрите, как определяется последовательность Фибоначи:

f[0] = 0
f[1] = 1
f[n] = f[n-1] + f[n-2]

Попробуйте представить, что n равно 2. Вы получите следующее:

f[2] = f[1] + f[0] = 1 + 0 = 1

Теперь, когда вы знаете, что такое f[2], вам не нужно переосмысливать его при вычислении f[3]. Просто возьмите результаты, которые вы получили на предыдущих шагах.

f[3] = f[2] + f[1] = (1 + 0) + 1 = 1 + 1 = 2
f[4] = f[3] + f[2] = 2 + 1 = 3
f[5] = f[4] + f[3] = 3 + 2 = 5
...
f[n] = f[n-1] + f[n-2]

Теперь давайте конвертируем это в JavaScript:

var fib = function(n){
  if( n === 0 || n === 1 )
    return n;
  return fib(n-1) + fib(n-2);
};

Как видите, функция очень похожа на математическое определение. На декларативном языке, таком как Haskell, это выглядело бы еще хуже:

fib 0 = 0
fib 1 = 1
fib n = fib(n-1) + fib(n-2)

Я искренне рекомендую вам изучить основы Haskell, чтобы получить представление о рекурсии, так как она широко используется. Существует очень интересный учебник learnyouahaskell.

Даже если вы этого не сделаете, прочтите раздел о рекурсии.

Сначала рекурсия может показаться немного запутанной, но после некоторой практики она часто оказывается самым простым и естественным решением. Это особенно верно при работе со структурами, которые являются рекурсивными сами по себе, такими как деревья.

Несколько примеров:

// get the sum of array
var sumArray = function( array ){
  if( array.length === 1 )                 // if only one element, it is the sum
    return array[0];
  return array.shift() + sumArray(array);  // return array[0] + sumArray(array.slice(1))
};
console.log( sumArray( [1,2,3] ) );


// recursive functions don't have to return a value
var repeatFunction = function( action, times ){
  if( times === 0 )
    return;
  action();
  repeatFunction( action, times-1 );
};
repeatFunction( function(){ alert('jah'); }, 3 );


// used in function below
var repeatString = function( string, times ){
  if( times < 1 )
    return '';
  return string + repeatString( string, times-1 );
};
console.log( repeatString( 'jah', 3 ) );


// and finally, a tree traversal
var readDOMStructure = function( element, level ){
  if( typeof level === 'undefined' )
    level = 0;
  else
    level++;
  console.log( repeatString( '  ', level ) + element.nodeName );
  for( var i=0, n=element.children.length; i<n; i++ )
    readDOMStructure( element.children[i], level );
};
readDOMStructure( document.getElementById( 'jah' ) );
person pishpish    schedule 08.06.2016