[algorithm] 꼬리 재귀는 무엇입니까?
lisp를 배우기 시작하면서 tail-recursive 라는 용어를 보았습니다 . 정확히 무엇을 의미합니까?
답변
첫 번째 N 자연수를 더하는 간단한 함수를 고려하십시오. (예 🙂 sum(5) = 1 + 2 + 3 + 4 + 5 = 15
.
재귀를 사용하는 간단한 JavaScript 구현은 다음과 같습니다.
function recsum(x) {
if (x === 1) {
return x;
} else {
return x + recsum(x - 1);
}
}
를 호출 recsum(5)
하면 JavaScript 인터프리터가 평가하는 것입니다.
recsum(5)
5 + recsum(4)
5 + (4 + recsum(3))
5 + (4 + (3 + recsum(2)))
5 + (4 + (3 + (2 + recsum(1))))
5 + (4 + (3 + (2 + 1)))
15
JavaScript 인터프리터가 실제로 합계 계산 작업을 시작하기 전에 모든 재귀 호출이 완료되어야하는 방법에 유의하십시오.
다음은 동일한 함수의 꼬리 재귀 버전입니다.
function tailrecsum(x, running_total = 0) {
if (x === 0) {
return running_total;
} else {
return tailrecsum(x - 1, running_total + x);
}
}
다음은 호출 한 경우에 발생하는 일련의 이벤트입니다 tailrecsum(5)
( tailrecsum(5, 0)
기본 두 번째 인수로 인해 효과적으로 발생 함 ).
tailrecsum(5, 0)
tailrecsum(4, 5)
tailrecsum(3, 9)
tailrecsum(2, 12)
tailrecsum(1, 14)
tailrecsum(0, 15)
15
꼬리 재귀의 경우 재귀 호출의 각 평가와 함께이 running_total
업데이트됩니다.
참고 : 원래 답변은 Python의 예제를 사용했습니다. 파이썬 인터프리터는 tail call 최적화를 지원하지 않기 때문에 JavaScript로 변경되었습니다 . 그러나 테일 콜 최적화는 ECMAScript 2015 사양의 일부 이지만 대부분의 JavaScript 인터프리터 는이를 지원하지 않습니다 .
답변
에서 기존의 재귀 전형적인 모델은 처음 재귀 호출을 수행 한 다음 재귀 호출의 반환 값을하고 결과를 계산하는 것입니다. 이러한 방식으로 모든 재귀 호출에서 돌아올 때까지 계산 결과를 얻지 못합니다.
꼬리 재귀 에서는 먼저 계산을 수행 한 다음 재귀 호출을 실행하여 현재 단계의 결과를 다음 재귀 단계로 전달합니다. 이로 인해 마지막 설명은의 형식입니다 (return (recursive-function params))
. 기본적으로 주어진 재귀 단계의 반환 값은 다음 재귀 호출의 반환 값과 같습니다. .
그 결과 다음 재귀 단계를 수행 할 준비가되면 더 이상 현재 스택 프레임이 필요하지 않습니다. 이것은 약간의 최적화를 허용합니다. 실제로 적절하게 작성된 컴파일러를 사용하면 테일 재귀 호출 로 스택 오버플로 스니커 가 없어야합니다 . 다음 재귀 단계에서 현재 스택 프레임을 재사용하십시오. 나는 Lisp가 이것을 확신합니다.
답변
중요한 점은 꼬리 재귀가 본질적으로 반복과 동일하다는 것입니다. 컴파일러 최적화의 문제 일뿐만 아니라 표현력에 대한 기본 사실입니다. 이것은 두 가지 방법으로 진행됩니다. 양식의 루프를 사용할 수 있습니다
while(E) { S }; return Q
어디에서 E
그리고 Q
표현이며 S
일련의 진술이며 꼬리 재귀 함수로 바꿉니다.
f() = if E then { S; return f() } else { return Q }
물론, E
, S
, 그리고 Q
몇 가지 변수를 통해 몇 가지 흥미로운 값을 계산하기 위해 정의해야합니다. 예를 들어, 루핑 기능
sum(n) {
int i = 1, k = 0;
while( i <= n ) {
k += i;
++i;
}
return k;
}
꼬리 재귀 함수와 동일
sum_aux(n,i,k) {
if( i <= n ) {
return sum_aux(n,i+1,k+i);
} else {
return k;
}
}
sum(n) {
return sum_aux(n,1,0);
}
(매개 변수가 적은 함수를 사용하는 꼬리 재귀 함수의 “래핑”은 일반적인 기능 관용구입니다.)
답변
Lua의 Programming 책에서 발췌 한이 글 은 Lua에서 꼬리 꼬리 재귀를 올바르게 만드는 방법 과 Lisp에도 적용되어야하는 이유를 보여줍니다.
꼬리를 호출 [꼬리 재귀] 전화로 옷을 입고 고토의 일종이다. 테일 호출은 함수가 마지막 동작으로 다른 것을 호출 할 때 발생하므로 다른 작업이 없습니다. 예를 들어 다음 코드에서 호출
g
은 테일 호출입니다.function f (x) return g(x) end
f
호출 후g
할 일이 없습니다. 이러한 상황에서는 호출 된 기능이 종료 될 때 프로그램이 호출 기능으로 돌아갈 필요가 없습니다. 따라서 꼬리 호출 후 프로그램은 호출 함수에 대한 정보를 스택에 보관할 필요가 없습니다. …적절한 테일 콜은 스택 공간을 사용하지 않기 때문에 프로그램이 수행 할 수있는 “중첩 된”테일 콜의 수에는 제한이 없습니다. 예를 들어, 임의의 숫자를 인수로하여 다음 함수를 호출 할 수 있습니다. 스택을 오버플로하지 않습니다.
function foo (n) if n > 0 then return foo(n - 1) end end
… 앞에서 말했듯이 테일 콜은 일종의 고토입니다. 따라서 Lua에서 적절한 테일 콜을 적용하는 것은 상태 머신을 프로그래밍하는 데 유용합니다. 이러한 응용 프로그램은 함수로 각 상태를 나타낼 수 있습니다. 상태를 변경하는 것은 특정 기능으로 이동하거나 호출하는 것입니다. 예를 들어 간단한 미로 게임을 생각해 봅시다. 미로는 북쪽, 남쪽, 동쪽 및 서쪽으로 최대 4 개의 문이있는 방이 여러 개 있습니다. 각 단계에서 사용자는 이동 방향으로 들어갑니다. 해당 방향으로 문이 있으면 사용자는 해당 방으로갑니다. 그렇지 않으면 프로그램이 경고를 인쇄합니다. 목표는 초기 방에서 마지막 방으로가는 것입니다.
이 게임은 현재 상태가 상태 인 일반적인 상태 머신입니다. 우리는 각 방마다 하나의 기능으로 그러한 미로를 구현할 수 있습니다. 우리는 꼬리 호출을 사용하여 한 방에서 다른 방으로 이동합니다. 4 개의 방이있는 작은 미로는 다음과 같습니다.
function room1 () local move = io.read() if move == "south" then return room3() elseif move == "east" then return room2() else print("invalid move") return room1() -- stay in the same room end end function room2 () local move = io.read() if move == "south" then return room4() elseif move == "west" then return room1() else print("invalid move") return room2() end end function room3 () local move = io.read() if move == "north" then return room1() elseif move == "east" then return room4() else print("invalid move") return room3() end end function room4 () print("congratulations!") end
따라서 다음과 같이 재귀 호출을 할 때 알 수 있습니다.
function x(n)
if n==0 then return 0
n= n-2
return x(n) + 1
end
재귀 호출이 수행 된 후에도 해당 함수에서 수행 할 작업 (1을 추가)이 있기 때문에 꼬리 재귀가 아닙니다. 매우 높은 숫자를 입력하면 스택 오버플로가 발생할 수 있습니다.
답변
정기적 인 재귀를 사용하면 각 재귀 호출이 다른 항목을 호출 스택으로 푸시합니다. 재귀가 완료되면 앱은 각 항목을 끝까지 튀어 나와야합니다.
꼬리 재귀를 사용하면 언어에 따라 컴파일러가 스택을 하나의 항목으로 축소 할 수 있으므로 스택 공간을 절약 할 수 있습니다 … 큰 재귀 쿼리는 실제로 스택 오버플로를 일으킬 수 있습니다.
기본적으로 테일 재귀는 반복에 최적화 될 수 있습니다.
답변
전문 용어 파일에는 꼬리 재귀의 정의에 대해 다음과 같이 말합니다.
꼬리 재귀 /n./
아직 아프지 않은 경우 꼬리 재귀를 참조하십시오.
답변
단어로 설명하는 대신 예제가 있습니다. 이것은 계승 함수의 체계 버전입니다.
(define (factorial x)
(if (= x 0) 1
(* x (factorial (- x 1)))))
다음은 꼬리 재귀 버전의 계승입니다.
(define factorial
(letrec ((fact (lambda (x accum)
(if (= x 0) accum
(fact (- x 1) (* accum x))))))
(lambda (x)
(fact x 1))))
첫 번째 버전에서는 사실에 대한 재귀 호출이 곱셈 표현식에 제공되므로 재귀 호출을 수행 할 때 상태가 스택에 저장되어야 함을 알 수 있습니다. 꼬리 재귀 버전에는 재귀 호출의 값을 기다리는 다른 S- 표현식이 없으며 더 이상 할 일이 없으므로 상태를 스택에 저장할 필요가 없습니다. 원칙적으로 Scheme tail-recursive 함수는 일정한 스택 공간을 사용합니다.