en.javascript.info/1-js/2-first-steps/17-recursion/article.md
2015-01-09 01:39:01 +03:00

14 KiB
Raw Blame History

Рекурсия, стек

В коде функции могут вызывать другие функции для выполнения подзадач. Частный случай подвызова -- когда функция вызывает сама себя. Это называется рекурсией.

В этой главе мы рассмотрим, как рекурсия устроена изнутри, и как её можно использовать. [cut]

Реализация pow(x, n) через рекурсию

Чтобы возвести x в натуральную степень n -- можно умножить его на себя n раз в цикле:

function pow(x, n) {
  var result = x;
  for(var i=1; i<n; i++) {
    result *= x;
  }

  return result;
}

А можно поступить проще.

Ведь xn = x * xn-1, т.е. можно вынести один x из-под степени. Иначе говоря, значение функции pow(x,n) получается из pow(x, n-1) умножением на x.

Этот процесс можно продолжить. Например, вычислим pow(2, 4):

pow(2, 4) = 2 * pow(2, 3) = 2 * 2 * pow(2, 2) = 2 * 2 * 2 * pow(2, 1) = 2 * 2 * 2 * 2;

Процесс перехода от n к n-1 останавливается на n==1, так как очевидно, что pow(x,1) == x.

Код для такого вычисления:

//+ run
function pow(x, n) {
  if (n != 1) { // пока n!=1 сводить вычисление pow(..n) к pow(..n-1)
    return x * pow(x, n-1); 
  } else { 
    return x; 
  }
}

alert( pow(2, 3) ); // 8

Говорят, что "функция pow рекурсивно вызывает сама себя" при n != 1.

Значение, на котором рекурсия заканчивается называют базисом рекурсии. В примере выше базисом является 1.

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

Рекурсию используют, когда вычисление функции можно свести к её более простому вызову, а его -- еще к более простому, и так далее, пока значение не станет очевидно.

Контекст выполнения, стек

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

У каждого вызова функции есть свой "контекст выполнения" (execution context).

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

Например, для вызова:

//+ run
function pow(x, n) {
  if (n != 1) { // пока n!=1 сводить вычисление pow(..n) к pow(..n-1)
    return x * pow(x, n-1); 
  } else { 
    return x; 
  }
}

*!*
alert( pow(2, 3) );  // (*)
*/!*

При запуске функции pow в строке (*) будет создан контекст выполнения, который будет хранить переменные x = 2, n = 3. Мы схематично обозначим его так:

  • Контекст: { x: 2, n: 3 }

Далее функция pow начинает выполняться. Вычисляется выражение n != 1 -- оно равно true, ведь в текущем контексте n=3. Поэтому задействуется первая ветвь if :

  if (n != 1) { // пока n!=1 сводить вычисление pow(..n) к pow(..n-1)
*!*
    return x * pow(x, n-1); 
*/!*
  } else { 
    return x; 
  }

Чтобы вычислить выражение x * pow(x, n-1), требуется произвести запуск pow с новыми аргументами.

При любом вложенном вызове JavaScript запоминает место, где он остановился в текущей функции в специальной внутренней структуре данных -- "стеке контекстов".

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

Так и здесь -- мы запомним, где остановились в этой функции, пойдём выполним вложенный вызов, затем вернёмся и продолжим дорогу.

После того, как текущий контекст выполнения сохранён в стеке контекстов, JavaScript приступает к выполнению вложенного вызова.

В данном случае вызывается та же pow, однако, это абсолютно неважно. Для любых функций процесс одинаков.

Создаётся новый контекст выполнения, и управление переходит в подвызов, а когда он завершён -- старый контекст достаётся из стека и выполнение внешней функции возобновляется.

Разбор примера

Разберём происходящее более подробно, начиная с вызова (*):

//+ run
function pow(x, n) {
  if (n != 1) { // пока n!=1 сводить вычисление pow(..n) к pow(..n-1)
    return x * pow(x, n-1); 
  } else { 
    return x; 
  }
}

*!*
alert( pow(2, 3) );  // (*)
*/!*
`pow(2, 3)`
Запускается функция `pow`, с аргументами `x=2`, `n=3`. Эти переменные хранятся в контексте выполнения, схематично изображённом ниже:
  • Контекст: { x: 2, n: 3 }
Выполнение в этом контексте продолжается, пока не встретит вложенный вызов в строке 3.
`pow(2, 2)`
В строке `3` происходит вложенный вызов `pow` с аргументами `x=2`, `n=2`. Для этой функции создаётся новый текущий контекст (выделен красным), а предыдущий сохраняется в "стеке":
  • Контекст: { x: 2, n: 3 }
  • Контекст: { x: 2, n: 2 }
`pow(2, 1)`
Опять вложенный вызов в строке `3`, на этот раз -- с аргументами `x=2`, `n=1`. Создаётся новый текущий контекст, предыдущий добавляется в стек:
  • Контекст: { x: 2, n: 3 }
  • Контекст: { x: 2, n: 2 }
  • Контекст: { x: 2, n: 1 }
Выход из `pow(2, 1)`.
При выполнении `pow(2, 1)`, в отличие от предыдущих запусков, выражение `n != 1` будет равно `false`, поэтому сработает вторая ветка `if..else`:
function pow(x, n) {
  if (n != 1) { 
    return x * pow(x, n-1); 
  } else { 
*!*
    return x;  // первая степень числа равна самому числу
*/!*
  }
}

Здесь вложенных вызовов нет, так что функция заканчивает свою работу, возвращая 2. Текущий контекст больше не нужен и удаляется из памяти, из стека восстанавливается предыдущий:

  • Контекст: { x: 2, n: 3 }
  • Контекст: { x: 2, n: 2 }
Возобновляется обработка внешнего вызова `pow(2, 2)`.
Выход из `pow(2, 2)`.
...И теперь уже `pow(2, 2)` может закончить свою работу, вернув `4`. Восстанавливается контекст предыдущего вызова:
  • Контекст: { x: 2, n: 3 }
Возобновляется обработка внешнего вызова `pow(2, 3)`.
Выход из `pow(2, 3)`.
Самый внешний вызов заканчивает свою работу, его результат: `pow(2, 3) = 8`.

Глубина рекурсии в данном случае составила: 3.

Как видно из иллюстраций выше, глубина рекурсии равна максимальному числу контекстов, одновременно хранимых в стеке.

[smart] В самом конце, как и в самом начале, выполнение попадает во внешний код, который находится вне любых функций.

Контекст, который соответствует самому внешнему коду, называют "глобальный контекст". Естественно, он является начальной и конечной точкой любых вложенных подвызовов. [/smart]

Обратим внимание на требования к памяти. Рекурсия приводит к хранению всех данных для неоконченных внешних вызовов в стеке, в данном случае это приводит к тому, что возведение в степень n хранит в памяти n различных контекстов.

Реализация степени через цикл гораздо более экономна:

function pow(x, n) {
  var result = x;
  for(var i=1; i<n; i++) {
    result *= x;
  }
  return result;
}

У такой функции pow будет один контекст, в котором будут последовательно меняться значения i и result.

Любая рекурсия может быть переделана в цикл. Как правило, вариант с циклом будет эффективнее.

...Но зачем тогда нужна рекурсия? Да просто затем, что рекурсивный код может быть гораздо проще и понятнее!

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

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

Недостатки и преимущества рекурсии:

[compare] -Требования к памяти. -Ограничена максимальная глубина стека. +Краткость и простота кода. [/compare]

Итого

Рекурсия -- это когда функция вызывает сама себя, с другими аргументами.

Существуют много областей применения рекурсивных вызовов. Здесь мы посмотрели на один из них -- решение задачи путём сведения её к более простой (с меньшими аргументами), но также рекурсия используется для работы с "естественно рекурсивными" структурами данных, такими как HTML-документы, для "глубокого" копирования сложных объектов.

Есть и другие применения, с которыми мы встретимся по мере изучения JavaScript.

Здесь мы постарались рассмотреть происходящее достаточно подробно, однако, если пожелаете, допустимо временно забежать вперёд и открыть главу , с тем чтобы при помощи отладчика построчно пробежаться по коду и посмотреть стек.

[head]

[/head]