lisp를 배우기 시작하면서 tail-recursive 라는 용어를 보았습니다 . 정확히 무엇을 의미합니까?
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 인터프리터 는이를 지원하지 않습니다 .
tail recursion
꼬리 호출을 최적화하지 않는 언어로 어떻게 달성 할 수 있는지 혼란 스럽 습니다.
에서 기존의 재귀 전형적인 모델은 처음 재귀 호출을 수행 한 다음 재귀 호출의 반환 값을하고 결과를 계산하는 것입니다. 이러한 방식으로 모든 재귀 호출에서 돌아올 때까지 계산 결과를 얻지 못합니다.
꼬리 재귀 에서는 먼저 계산을 수행 한 다음 재귀 호출을 실행하여 현재 단계의 결과를 다음 재귀 단계로 전달합니다. 이로 인해 마지막 설명은의 형식입니다 (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);
}
(매개 변수가 적은 함수를 사용하는 꼬리 재귀 함수의 "래핑"은 일반적인 기능 관용구입니다.)
else { return k; }
다음으로 변경 될 수 있습니다return k;
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을 추가)이 있기 때문에 꼬리 재귀가 아닙니다. 매우 높은 숫자를 입력하면 스택 오버플로가 발생할 수 있습니다.
정기적 인 재귀를 사용하면 각 재귀 호출이 다른 항목을 호출 스택으로 푸시합니다. 재귀가 완료되면 앱은 각 항목을 끝까지 튀어 나와야합니다.
꼬리 재귀를 사용하면 언어에 따라 컴파일러가 스택을 하나의 항목으로 축소 할 수 있으므로 스택 공간을 절약 할 수 있습니다 ... 큰 재귀 쿼리는 실제로 스택 오버플로를 일으킬 수 있습니다.
기본적으로 테일 재귀는 반복에 최적화 될 수 있습니다.
단어로 설명하는 대신 예제가 있습니다. 이것은 계승 함수의 체계 버전입니다.
(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 함수는 일정한 스택 공간을 사용합니다.
list-reverse
프로시 저는 일정한 스택 공간에서 실행되지만 힙에서 데이터 구조를 작성하고 확장합니다. 트리 탐색은 추가 인수로 시뮬레이션 된 스택을 사용할 수 있습니다. 등
테일 재귀는 재귀 알고리즘의 마지막 논리 명령에서 마지막 재귀 호출을 나타냅니다.
일반적으로 재귀 에는 재귀 호출을 중지하고 호출 스택을 팝하기 시작 하는 기본 사례 가 있습니다. Lisp보다 C-ish이지만 고전적인 예를 사용하기 위해 계승 함수는 꼬리 재귀를 보여줍니다. 기본 사례 상태를 확인한 후 재귀 호출이 발생합니다 .
factorial(x, fac=1) {
if (x == 1)
return fac;
else
return factorial(x-1, x*fac);
}
계승에 대한 초기 호출은 factorial(n)
여기서 fac=1
(기본값)이고 n은 계승을 계산할 숫자입니다.
else
은 "기본 사례"라고 부르는 단계이지만 여러 줄에 걸쳐 있습니다. 내가 당신을 오해하거나 내 가정이 맞습니까? 꼬리 재귀는 하나의 라이너에만 적합합니까?
factorial
예제는 고전적인 간단한 예일뿐입니다.
다음은 두 기능을 비교하는 빠른 코드 스 니펫입니다. 첫 번째는 주어진 숫자의 계승을 찾기위한 전통적인 재귀입니다. 두 번째는 꼬리 재귀를 사용합니다.
이해하기 매우 간단하고 직관적입니다.
재귀 함수가 꼬리 재귀인지 알 수있는 쉬운 방법은 기본 사례에서 구체적인 값을 반환하는 것입니다. 1 또는 true 또는 그와 비슷한 것을 반환하지 않음을 의미합니다. 메소드 매개 변수 중 하나의 변형을 리턴 할 가능성이 높습니다.
또 다른 방법은 재귀 호출에 추가, 산술, 수정 등이 없는지 여부입니다. 순수한 재귀 호출만을 의미합니다.
public static int factorial(int mynumber) {
if (mynumber == 1) {
return 1;
} else {
return mynumber * factorial(--mynumber);
}
}
public static int tail_factorial(int mynumber, int sofar) {
if (mynumber == 1) {
return sofar;
} else {
return tail_factorial(--mynumber, sofar * mynumber);
}
}
내가 이해하는 가장 좋은 방법 tail call recursion
은 마지막 호출 (또는 꼬리 호출)이 함수 자체 인 특별한 재귀 사례입니다 .
파이썬에서 제공되는 예제 비교 :
def recsum(x):
if x == 1:
return x
else:
return x + recsum(x - 1)
^ 회복
def tailrecsum(x, running_total=0):
if x == 0:
return running_total
else:
return tailrecsum(x - 1, running_total + x)
^ TAIL RECURSION
일반적인 재귀 버전에서 볼 수 있듯이 코드 블록의 최종 호출은 x + recsum(x - 1)
입니다. 따라서 recsum
메소드를 호출 한 후에 는 또 다른 연산이 x + ..
있습니다.
그러나 꼬리 재귀 버전에서 코드 블록의 최종 호출 (또는 꼬리 호출) tailrecsum(x - 1, running_total + x)
은 메소드 자체에 대한 마지막 호출이 수행되고 그 이후에는 작업이 수행되지 않음을 의미합니다.
기본 VM이 테일 위치 (함수에서 평가할 마지막 표현식)에서 자신을 호출하는 함수를 볼 때 현재 스택 프레임을 제거하므로 테일 재귀가 메모리를 늘리지 않기 때문에이 점이 중요합니다. 테일 콜 최적화 (TCO)라고합니다.
NB. 위의 예제는 런타임이 TCO를 지원하지 않는 Python으로 작성된 것임을 명심하십시오. 이것은 요점을 설명하는 예일뿐입니다. TCO는 Scheme, Haskell 등과 같은 언어로 지원됩니다
Java에서 피보나치 함수의 꼬리 재귀 구현이 가능합니다.
public int tailRecursive(final int n) {
if (n <= 2)
return 1;
return tailRecursiveAux(n, 1, 1);
}
private int tailRecursiveAux(int n, int iter, int acc) {
if (iter == n)
return acc;
return tailRecursiveAux(n, ++iter, acc + iter);
}
이것을 표준 재귀 구현과 대조하십시오.
public int recursive(final int n) {
if (n <= 2)
return 1;
return recursive(n - 1) + recursive(n - 2);
}
iter
로 acc
할 때를 iter < (n-1)
.
다음은 꼬리 재귀를 사용하여 계승을 수행하는 일반적인 Lisp 예제입니다. 스택리스 특성으로 인해 엄청나게 큰 계승 계산을 수행 할 수 있습니다 ...
(defun ! (n &optional (product 1))
(if (zerop n) product
(! (1- n) (* product n))))
그리고 재미를 위해 당신은 시도 할 수 있습니다 (format nil "~R" (! 25))
간단히 말해서, 꼬리 재귀는 재귀 호출을 함수 의 마지막 문으로 재귀 호출을 기다릴 필요가 없습니다.
따라서 이것은 꼬리 재귀입니다. 즉, N (x-1, p * x)은 컴파일러가 for- 루프 (인수)에 최적화 될 수 있다는 것을 알아 낸 현명한 함수의 마지막 문장입니다. 두 번째 매개 변수 p는 중간 제품 값을 전달합니다.
function N(x, p) {
return x == 1 ? p : N(x - 1, p * x);
}
이것은 위의 계승 함수를 작성하는 비 꼬리 재귀 방식입니다 (어떤 C ++ 컴파일러는 어쨌든 최적화 할 수 있지만).
function N(x) {
return x == 1 ? 1 : x * N(x - 1);
}
그러나 이것은 아닙니다 :
function F(x) {
if (x == 1) return 0;
if (x == 2) return 1;
return F(x - 1) + F(x - 2);
}
" 꼬리 재귀 이해 – Visual Studio C ++ – 어셈블리보기 " 라는 제목의 긴 게시물을 작성했습니다.
이것은 꼬리 재귀에 관한 컴퓨터 프로그램의 구조와 해석 에서 발췌 한 것입니다 .
반복 및 재귀와 대조적으로, 재귀 프로세스의 개념과 재귀 프로 시저의 개념을 혼동하지 않도록주의해야합니다. 프로 시저를 재귀 적이라고 설명 할 때 프로 시저 정의가 프로 시저 자체를 (직접 또는 간접적으로) 참조하는 구문 적 사실을 참조합니다. 그러나 선형 재귀 패턴을 따르는 프로세스를 설명 할 때 프로 시저 작성 방법의 구문이 아니라 프로세스의 진화 방식에 대해 이야기합니다. 반복 프로세스를 생성하는 것과 같은 사실과 같은 재귀 절차를 참조하는 것이 혼란스러워 보일 수 있습니다. 그러나 프로세스는 실제로 반복적입니다. 상태는 세 가지 상태 변수에 의해 완전히 캡처되므로 인터프리터는 프로세스를 실행하기 위해 세 개의 변수 만 추적하면됩니다.
프로세스와 프로 시저의 차이점이 혼동 될 수있는 한 가지 이유는 공통 언어 (Ada, Pascal 및 C 포함)의 대부분의 구현이 모든 재귀 프로 시저의 해석이 설명 된 프로세스가 원칙적으로 반복적 인 경우에도 프로 시저 호출 수 결과적으로, 이들 언어는 do, repeat, until, for 및 while과 같은 특수 목적의 "루핑 구성"에 의지해서 만 반복 프로세스를 설명 할 수 있습니다. 체계의 구현은이 결함을 공유하지 않습니다. 반복 프로세스가 재귀 프로 시저에 의해 설명 되더라도 일정한 공간에서 반복 프로세스를 실행합니다. 이 속성을 사용한 구현을 테일 재귀라고합니다. tail-recursive 구현에서는 일반적인 프로 시저 호출 메커니즘을 사용하여 반복을 표현할 수 있으므로 특수 반복 구성은 구문 설탕으로 만 유용합니다.
재귀 함수는 자체적으로 호출 하는 함수입니다.
프로그래머는 최소한의 코드를 사용하여 효율적인 프로그램을 작성할 수 있습니다 .
단점은 제대로 작성하지 않으면 무한 루프 및 기타 예상치 못한 결과 가 발생할 수 있다는 것 입니다.
Simple Recursive 함수와 Tail Recursive 함수를 모두 설명하겠습니다.
단순 재귀 함수 를 작성하려면
주어진 예에서 :
public static int fact(int n){
if(n <=1)
return 1;
else
return n * fact(n-1);
}
위의 예에서
if(n <=1)
return 1;
루프 종료 시점을 결정하는 요인
else
return n * fact(n-1);
실제 처리를 수행해야합니까
쉽게 이해할 수 있도록 과제를 하나씩 나누겠습니다.
내가 달리면 내부적으로 어떻게되는지 보자 fact(4)
public static int fact(4){
if(4 <=1)
return 1;
else
return 4 * fact(4-1);
}
If
루프가 실패하여 else
루프 로 이동하여4 * fact(3)
스택 메모리에는 4 * fact(3)
n = 3으로 대체
public static int fact(3){
if(3 <=1)
return 1;
else
return 3 * fact(3-1);
}
If
루프가 실패하여 else
루프로 이동
그래서 그것은 반환 3 * fact(2)
우리는``4 * fact (3)``이라고 불렀습니다.
에 대한 출력 fact(3) = 3 * fact(2)
지금까지 스택은 4 * fact(3) = 4 * 3 * fact(2)
스택 메모리에는 4 * 3 * fact(2)
n = 2로 대체
public static int fact(2){
if(2 <=1)
return 1;
else
return 2 * fact(2-1);
}
If
루프가 실패하여 else
루프로 이동
그래서 그것은 반환 2 * fact(1)
우리가 전화했던 기억 4 * 3 * fact(2)
에 대한 출력 fact(2) = 2 * fact(1)
지금까지 스택은 4 * 3 * fact(2) = 4 * 3 * 2 * fact(1)
스택 메모리에는 4 * 3 * 2 * fact(1)
n = 1로 대체
public static int fact(1){
if(1 <=1)
return 1;
else
return 1 * fact(1-1);
}
If
루프는 사실이다
그래서 그것은 반환 1
우리가 전화했던 기억 4 * 3 * 2 * fact(1)
에 대한 출력 fact(1) = 1
지금까지 스택은 4 * 3 * 2 * fact(1) = 4 * 3 * 2 * 1
마지막으로 fact (4) = 4 * 3 * 2 * 1 = 24의 결과
꼬리 재귀는 것
public static int fact(x, running_total=1) {
if (x==1) {
return running_total;
} else {
return fact(x-1, running_total*x);
}
}
public static int fact(4, running_total=1) {
if (x==1) {
return running_total;
} else {
return fact(4-1, running_total*4);
}
}
If
루프가 실패하여 else
루프 로 이동하여fact(3, 4)
스택 메모리에는 fact(3, 4)
n = 3으로 대체
public static int fact(3, running_total=4) {
if (x==1) {
return running_total;
} else {
return fact(3-1, 4*3);
}
}
If
루프가 실패하여 else
루프로 이동
그래서 그것은 반환 fact(2, 12)
스택 메모리에는 fact(2, 12)
n = 2로 대체
public static int fact(2, running_total=12) {
if (x==1) {
return running_total;
} else {
return fact(2-1, 12*2);
}
}
If
루프가 실패하여 else
루프로 이동
그래서 그것은 반환 fact(1, 24)
스택 메모리에는 fact(1, 24)
n = 1로 대체
public static int fact(1, running_total=24) {
if (x==1) {
return running_total;
} else {
return fact(1-1, 24*1);
}
}
If
루프는 사실이다
그래서 그것은 반환 running_total
에 대한 출력 running_total = 24
마지막으로 fact (4,1) = 24 의 결과
꼬리 재귀는 당신이 지금 살고있는 삶입니다. "이전"프레임으로 돌아갈 이유나 수단이 없기 때문에 동일한 스택 프레임을 계속해서 재활용합니다. 과거는 끝났고 끝났으므로 버릴 수 있습니다. 프로세스가 필연적으로 죽을 때까지 한 프레임 씩 미래로 영원히 이동합니다.
일부 프로세스가 추가 프레임을 사용할 수 있지만 스택이 무한히 커지지 않으면 꼬리 재귀로 간주되는 것으로 간주하면 유추가 무너집니다.
테일 재귀는 재귀 호출의 반환 후 계산이 수행되지 않는 함수의 끝 ( "테일")에서 함수가 호출되는 재귀 함수입니다. 많은 컴파일러는 재귀 호출을 테일 재귀 또는 반복 호출로 변경하도록 최적화합니다.
다수의 계승 계산 문제를 고려하십시오.
간단한 접근 방식은 다음과 같습니다.
factorial(n):
if n==0 then 1
else n*factorial(n-1)
factorial (4)을 호출한다고 가정하십시오. 재귀 트리는 다음과 같습니다.
factorial(4)
/ \
4 factorial(3)
/ \
3 factorial(2)
/ \
2 factorial(1)
/ \
1 factorial(0)
\
1
위의 경우 최대 재귀 깊이는 O (n)입니다.
그러나 다음 예제를 고려하십시오.
factAux(m,n):
if n==0 then m;
else factAux(m*n,n-1);
factTail(n):
return factAux(1,n);
factTail (4)의 재귀 트리는 다음과 같습니다.
factTail(4)
|
factAux(1,4)
|
factAux(4,3)
|
factAux(12,2)
|
factAux(24,1)
|
factAux(24,0)
|
24
또한 최대 재귀 수준은 O (n)이지만 호출에 스택에 추가 변수를 추가하는 것은 없습니다. 따라서 컴파일러는 스택을 제거 할 수 있습니다.
꼬리 재귀의 기능은 반환되기 전에 수행하는 마지막 작업은 재귀 함수 호출을 재귀 함수입니다. 즉, 재귀 함수 호출의 반환 값이 즉시 반환됩니다. 예를 들어 코드는 다음과 같습니다.
def recursiveFunction(some_params):
# some code here
return recursiveFunction(some_args)
# no code after the return statement
테일 콜 최적화 또는 테일 콜 제거 를 구현하는 컴파일러 및 인터프리터는 재귀 코드를 최적화하여 스택 오버플로를 방지 할 수 있습니다. 컴파일러 또는 인터프리터가 CPython 인터프리터와 같은 테일 콜 최적화를 구현하지 않으면 코드를 이런 식으로 작성하면 추가 이점이 없습니다.
예를 들어, 이것은 파이썬에서 표준 재귀 요인 함수입니다.
def factorial(number):
if number == 1:
# BASE CASE
return 1
else:
# RECURSIVE CASE
# Note that `number *` happens *after* the recursive call.
# This means that this is *not* tail call recursion.
return number * factorial(number - 1)
그리고 이것은 계승 함수의 꼬리 호출 재귀 버전입니다.
def factorial(number, accumulator=1):
if number == 0:
# BASE CASE
return accumulator
else:
# RECURSIVE CASE
# There's no code after the recursive call.
# This is tail call recursion:
return factorial(number - 1, number * accumulator)
print(factorial(5))
(이 코드는 Python 코드이지만 CPython 인터프리터는 테일 콜 최적화를 수행하지 않으므로 코드를 이와 같이 정렬하면 런타임 이점이 없습니다.)
팩토리얼 예제에 표시된 것처럼 테일 콜 최적화를 사용하려면 코드를 좀 더 읽기 어려워 야 할 수도 있습니다. (예를 들어, 기본 사례는 이제 조금 직관적이지 않으며 accumulator
매개 변수는 일종의 전역 변수로 효과적으로 사용됩니다.)
그러나 테일 콜 최적화의 이점은 스택 오버플로 오류를 방지한다는 것입니다. (재귀 알고리즘 대신 반복 알고리즘을 사용하면 이와 동일한 이점을 얻을 수 있습니다.)
호출 스택에 너무 많은 프레임 객체가 푸시되면 스택 오버플로가 발생합니다. 함수가 호출되면 프레임 객체가 호출 스택으로 푸시되고 함수가 반환되면 호출 스택에서 튀어 나옵니다. 프레임 객체에는 로컬 변수와 같은 정보와 함수가 반환 될 때 반환되는 코드 줄이 포함됩니다.
재귀 함수가 반환하지 않고 너무 많은 재귀 호출을하는 경우 호출 스택은 프레임 객체 제한을 초과 할 수 있습니다. (숫자는 플랫폼마다 다릅니다. Python에서는 기본적으로 1000 개의 프레임 객체입니다.) 이로 인해 스택 오버플로 오류가 발생합니다. (이곳에서이 웹 사이트의 이름이 유래되었습니다!)
그러나 재귀 함수가 수행하는 마지막 작업이 재귀 호출을 수행하고 반환 값을 반환하는 경우 현재 프레임 객체를 호출 스택에 유지해야 할 이유가 없습니다. 결국 재귀 함수 호출 후에 코드가 없으면 현재 프레임 객체의 로컬 변수에 매달릴 이유가 없습니다. 따라서 현재 프레임 객체를 호출 스택에 유지하지 않고 즉시 제거 할 수 있습니다. 최종 결과는 호출 스택의 크기가 커지지 않아 오버플로를 스택 할 수 없다는 것입니다.
컴파일러 또는 인터프리터는 테일 콜 최적화를 적용 할 수있는시기를 인식 할 수 있도록 테일 콜 최적화 기능을 가지고 있어야합니다. 그럼에도 불구하고 꼬리 호출 최적화를 사용하기 위해 재귀 함수에서 코드를 다시 정렬했을 수 있으며, 가독성의 잠재적 감소가 최적화 가치가 있는지 여부는 사용자에게 달려 있습니다.
tail-call 재귀와 non-tail-call 재귀의 핵심 차이점을 이해하기 위해 이러한 기술의 .NET 구현을 살펴볼 수 있습니다.
다음은 C #, F # 및 C ++ \ CLI : C #, F # 및 C ++ \ CLI의 Tail Recursion에 대한 모험 예제가있는 기사입니다 .
C #은 tail-call 재귀에 최적화되지 않지만 F #은 최적화합니다.
원리의 차이점에는 루프 대 람다 미적분이 포함됩니다. C #은 루프를 염두에두고 설계되었으며 F #은 Lambda 미적분학 원리를 기반으로합니다. Lambda 미적분학의 원리에 대한 아주 좋은 (무료) 책은 Abelson, Sussman 및 Sussman의 컴퓨터 프로그램 구조 및 해석을 참조하십시오 .
F #의 꼬리 호출에 대해서는 아주 좋은 소개 기사를 참조하십시오. 는 F #의 테일 호출에 자세한 소개를 . 마지막으로, 비 꼬리 재귀와 꼬리 호출 재귀의 차이점 (F #) : 꼬리 재귀와 비 꼬리 재귀 의 차이점을 다루는 기사가 있습니다 .
C #과 F # 사이의 테일 콜 재귀의 디자인 차이에 대해 읽으려면 C # 및 F #에서 테일 콜 Opcode 생성을 참조하십시오 .
C # 컴파일러가 테일 콜 최적화를 수행하지 못하게하는 조건을 알고 싶을 경우 JIT CLR 테일 콜 조건 기사를 참조하십시오 .
재귀는 자신을 호출하는 함수를 의미합니다. 예를 들면 다음과 같습니다.
(define (un-ended name)
(un-ended 'me)
(print "How can I get here?"))
테일 재귀는 다음과 같은 기능을 수행하는 재귀를 의미합니다.
(define (un-ended name)
(print "hello")
(un-ended 'me))
끝없는 함수 (Scheme jargon의 절차)가 수행하는 마지막 작업은 자신을 호출하는 것입니다. 또 다른 (더 유용한) 예는 다음과 같습니다.
(define (map lst op)
(define (helper done left)
(if (nil? left)
done
(helper (cons (op (car left))
done)
(cdr left))))
(reverse (helper '() lst)))
도우미 절차에서 왼쪽이 0이 아닌 경우 마지막으로 수행하는 작업은 자체 호출하는 것입니다 (AFTER cons and cdr something). 기본적으로 목록을 매핑하는 방법입니다.
tail-recursion은 인터프리터 (또는 언어 및 공급 업체에 따라 컴파일러)가이를 최적화하고 while 루프와 동등한 것으로 변환 할 수 있다는 큰 이점이 있습니다. 사실, Scheme 전통에서, 대부분의 "for"와 "while"루프는 tail-recursion 방식으로 수행됩니다 (내가 아는 한, 현재는없고 없음).
이 질문에는 많은 해답이 있지만 ... "꼬리 재귀"또는 적어도 "적절한 꼬리 재귀"를 정의하는 방법에 대한 대안을 취할 수는 없습니다. 즉, 프로그램에서 특정 표현의 속성으로보아야합니까? 아니면 프로그래밍 언어 구현의 속성으로보아야 합니까?
후자에 대한 자세한 내용은 고전적인 논문이 있습니다. 은 프로그래밍 언어 구현의 속성으로 "적절한 꼬리 재귀"를 정의한 Will Clinger의 "적절한 꼬리 재귀 및 공간 효율"(PLDI 1998) 이 있습니다. 정의는 호출 스택이 실제로 런타임 스택을 통해 표시되는지 또는 힙 할당 링크 된 프레임 목록을 통해 표시되는지 여부와 같은 구현 세부 사항을 무시할 수 있도록 구성됩니다.
이를 달성하기 위해 일반적으로 보는 프로그램 실행 시간이 아니라 프로그램 공간 사용량이 아닌 점근 분석을 사용합니다 . 이런 방식으로, 힙 할당 링크리스트와 런타임 호출 스택의 공간 사용량은 결과적으로 동일합니다. 따라서 프로그래밍 언어 구현 세부 사항 (실제로는 상당히 중요한 세부 사항)을 무시하게되지만 주어진 구현이 "속성 테일 재귀"에 대한 요구 사항을 충족하는지 여부를 판단하려고 할 때 물을 약간 흐릿하게 만들 수 있습니다. )
이 논문은 여러 가지 이유로 신중하게 연구 할 가치가 있습니다.
프로그램의 테일 표현식 및 테일 호출 에 대한 귀납적 정의를 제공합니다 . (이러한 정의와 그러한 호출이 중요한 이유는 여기에 주어진 다른 답변들 대부분의 주제 인 것 같습니다.)
텍스트의 풍미를 제공하기 위해 이러한 정의가 있습니다.
정의 1 핵심 체계로 작성된 프로그램 의 꼬리 표현 은 다음과 같이 귀납적으로 정의됩니다.
- 람다 식의 본문은 꼬리 식입니다
- 경우는
(if E0 E1 E2)
다음 두, 꼬리 표현E1
과E2
꼬리 표현입니다.- 다른 것은 꼬리 표현이 아닙니다.
정의 2 의 꼬리 호출은 프로 시저 호출하는 꼬리 표현이다.
꼬리 재귀 호출 또는 논문에서 "자기 꼬리 호출"은 절차 자체가 호출되는 꼬리 호출의 특수한 경우입니다.
그것은 각 시스템이 동일한 관찰 행동이 핵심 계획, 평가를위한 여섯 가지 "기계"에 대한 공식적인 정의를 제공 제외 에 대한 점근 각각에 있음 공간의 복잡성 클래스를.
예를 들어, 1. 스택 기반 메모리 관리, 2. 가비지 수집, 테일 호출 없음, 3. 가비지 수집 및 테일 호출이있는 머신에 각각 정의를 제공 한 후 본 백서는 다음과 같은 고급 스토리지 관리 전략으로 계속 진행됩니다. 4. 환경 5.에 폐쇄의 환경을 줄이고, 꼬리 호출의 마지막 하위 식 인수의 평가를 통해 보존 할 필요가 없다 "evlis 꼬리 재귀" 바로 그 폐쇄의 자유 변수, 6. Appel과 Shao에 의해 정의 된 소위 "공간 안전"시맨틱 .
기계가 실제로 6 개의 별개의 공간 복잡성 클래스에 속한다는 것을 증명하기 위해, 비교중인 각 기계 쌍에 대한 논문은 한 기계에서는 점근 적 공간 폭발을 노출 시키지만 다른 기계에서는 그렇지 않은 프로그램의 구체적인 예를 제공합니다.
(지금 내 대답을 읽으면서 Clinger 논문 의 중요한 요점을 실제로 파악할 수 있는지 확실하지 않습니다 . 그러나 아쉽게도 지금이 답을 개발하는 데 더 많은 시간을 할애 할 수는 없습니다.)
많은 사람들이 이미 재귀를 설명했습니다. 나는 리카르도 테렐 (Ricardo Terrell)이 쓴“.NET의 동시성, 동시 및 병렬 프로그래밍의 현대 패턴”이라는 책에서 재귀가주는 몇 가지 장점에 대해 몇 가지 생각을하고 싶다.
“기능 재귀는 상태의 돌연변이를 피하기 때문에 FP에서 자연스럽게 반복하는 방법입니다. 반복 할 때마다 새 값이 루프 생성자로 전달되어 대신 업데이트 (돌연변이)됩니다. 또한 재귀 함수를 구성하여 프로그램을보다 모듈화하고 병렬화를 활용할 수있는 기회를 제공 할 수 있습니다. "
꼬리 재귀에 대한 동일한 책의 흥미로운 메모도 있습니다.
테일 콜 재귀는 규칙적인 재귀 함수를 위험과 부작용없이 큰 입력을 처리 할 수있는 최적화 된 버전으로 변환하는 기술입니다.
참고 테일 콜을 최적화로 사용하는 주요 이유는 데이터 로컬 성, 메모리 사용량 및 캐시 사용량을 향상시키기위한 것입니다. 테일 콜을 수행하면 수신자는 발신자와 동일한 스택 공간을 사용합니다. 메모리 압력이 줄어 듭니다. 새로운 캐시 라인을위한 공간을 만들기 위해 오래된 캐시 라인을 제거하지 않고 후속 호출자에 대해 동일한 메모리가 재사용되고 캐시에 머무를 수 있기 때문에 캐시를 약간 향상시킵니다.