꼬리 재귀는 정확히 어떻게 작동합니까?


121

나는 꼬리 재귀가 어떻게 작동하는지, 그리고 그것과 정상적인 재귀의 차이점을 거의 이해합니다. 나는 단지 그 이유를 이해하지 않습니다 그것의 반환 주소를 기억하기 위해 스택을 필요로한다.

// tail recursion
int fac_times (int n, int acc) {
    if (n == 0) return acc;
    else return fac_times(n - 1, acc * n);
}

int factorial (int n) {
    return fac_times (n, 1);
}

// normal recursion
int factorial (int n) {
    if (n == 0) return 1;
    else return n * factorial(n - 1);
}

꼬리 재귀 함수에서 함수 자체를 호출 한 후에는 할 일이 없지만 나에게는 의미가 없습니다.


16
꼬리 재귀 "정상"재귀입니다. 함수의 끝에서 재귀가 발생한다는 의미 일뿐입니다.
Pete Becker

7
... 그러나 일반적인 재귀와는 다른 방식으로 IL 수준에서 구현할 수 있으므로 스택 깊이가 줄어 듭니다.
KeithS 2013 년

2
BTW, gcc는 여기에있는 "일반"예제에서 꼬리 재귀 제거를 수행 할 수 있습니다.
dmckee --- 전 사회자 고양이

1
@Geek-저는 C # 개발자이므로 "어셈블리 언어"는 MSIL 또는 IL입니다. C / C ++의 경우 IL을 ASM으로 바꿉니다.
KeithS 2013 년

1
@ShannonSeverance 나는 gcc가없이 방출 된 어셈블리 코드를 검사하는 간단한 방법으로 수행하고 있음을 발견했습니다 -O3. 이 링크는 매우 유사한 영역을 다루고이 최적화를 구현하는 데 필요한 사항에 대해 논의하는 이전 논의를위한 것입니다.
dmckee --- 전 중재자 새끼 고양이

답변:


169

컴파일러는 간단히 이것을 변환 할 수 있습니다.

int fac_times (int n, int acc) {
    if (n == 0) return acc;
    else return fac_times(n - 1, acc * n);
}

다음과 같이 :

int fac_times (int n, int acc) {
label:
    if (n == 0) return acc;
    acc *= n--;
    goto label;
}

2
@ Mr.32 질문을 이해하지 못합니다. 함수를 동등한 것으로 변환했지만 명시 적 재귀 (즉, 명시 적 함수 호출 없음)가 없습니다. 논리를 동등하지 않은 것으로 변경하면 실제로 일부 또는 모든 경우에 함수 루프를 영원히 만들 수 있습니다.
Alexey Frunze 2013 년

18
꼬리 그래서 재귀 효과 에만 때문에 컴파일러 를 최적화? 그렇지 않으면 스택 메모리 측면에서 일반적인 재귀와 같을까요?
Alan Coromano 2013 년

34
네. 컴파일러가 재귀를 루프로 줄일 수 없으면 재귀에 갇힌 것입니다. 전부 아니면 아무것도.
Alexey Frunze 2013 년

3
@AlanDert : 맞습니다. 꼬리 재귀를 "꼬리 호출 최적화"의 특수한 경우로 고려할 수도 있습니다. 꼬리 호출이 동일한 함수에 대해 발생하기 때문에 특별합니다. 일반적으로 테일 재귀에 적용되는 것과 동일한 요구 사항이 "할 작업이 남아 있지 않음"에 대한 동일한 요구 사항이 있고 테일 호출의 반환 값이 직접 반환되는 경우 컴파일러가 호출 된 함수의 반환 주소를 마무리 호출이 만들어진 주소 대신 마무리 호출을 수행하는 함수의 반환 주소로 설정하는 방법입니다.
Steve Jessop 2013 년

1
@AlanDert in C 이것은 표준에 의해 시행되지 않는 최적화 일 뿐이므로 이식 가능한 코드는 그것에 의존해서는 안됩니다. 그러나 표준에 따라 꼬리 재귀 최적화가 적용되는 언어 (Scheme이 한 가지 예)가 있으므로 일부 환경에서 스택 오버플로가 발생할 것이라고 걱정할 필요가 없습니다.
Jan Wrobel 2013 년

57

"반환 주소를 기억하기 위해 스택이 필요하지 않은"이유를 묻습니다.

나는 이것을 바꾸고 싶다. 그것은 않습니다 반환 주소를 기억하기 위해 스택을 사용합니다. 비결은 꼬리 재귀가 발생하는 함수가 스택에 자체 반환 주소를 가지고 있으며 호출 된 함수로 점프 할 때이를 자체 반환 주소로 취급한다는 것입니다.

구체적으로 꼬리 호출 최적화없이 :

f: ...
   CALL g
   RET
g:
   ...
   RET

이 경우를 g호출하면 스택은 다음과 같습니다.

   SP ->  Return address of "g"
          Return address of "f"

반면에 마무리 호출 최적화를 사용하면 다음을 수행 할 수 있습니다.

f: ...
   JUMP g
g:
   ...
   RET

이 경우를 g호출하면 스택은 다음과 같습니다.

   SP ->  Return address of "f"

분명히 g돌아 오면 f호출 된 위치로 돌아갑니다 .

편집 : 위의 예는 한 함수가 다른 함수를 호출하는 경우를 사용합니다. 함수가 자신을 호출 할 때 메커니즘은 동일합니다.


8
이것은 다른 답변보다 훨씬 더 나은 답변입니다. 컴파일러는 꼬리 재귀 코드를 변환하는 특별한 경우가 없을 가능성이 높습니다. 동일한 기능으로 이동하는 일반적인 마지막 호출 최적화를 수행합니다.
예술

12

꼬리 재귀는 일반적으로 컴파일러에 의해 루프로 변환 될 수 있습니다. 특히 누산기가 사용될 때 더욱 그렇습니다.

// tail recursion
int fac_times (int n, int acc = 1) {
    if (n == 0) return acc;
    else return fac_times(n - 1, acc * n);
}

다음과 같이 컴파일됩니다.

// accumulator
int fac_times (int n) {
    int acc = 1;
    while (n > 0) {
        acc *= n;
        n -= 1;
    }
    return acc;
}

3
Alexey의 구현만큼 영리하지는 않습니다 ... 네, 그것은 칭찬입니다.
Matthieu M.

1
실제로 결과는 더 간단 해 보이지만이 변환을 구현하는 코드는 레이블 / 이동 또는 꼬리 호출 제거보다 훨씬 더 "영리"하다고 생각합니다 (Lidydancer의 답변 참조).
Phob

이것이 모두 꼬리 재귀라면 사람들은 왜 그것에 대해 그렇게 흥분합니까? 나는 while 루프에 대해 흥분하는 사람을 보지 못합니다.
Buh Buh 2013

@BuhBuh : 이것은 stackoverflow가 없으며, 스택 푸시 / 파라미터의 팝을 방지합니다. 이와 같은 타이트한 루프의 경우 차이의 세계를 만들 수 있습니다. 그 외에는 사람들이 흥분해서는 안됩니다.
Mooing Duck 2014

11

재귀 함수에 있어야하는 두 가지 요소가 있습니다.

  1. 재귀 호출
  2. 반환 값의 개수를 유지하는 위치입니다.

"일반"재귀 함수는 스택 프레임에 (2)를 유지합니다.

일반 재귀 함수의 반환 값은 두 가지 유형의 값으로 구성됩니다.

  • 기타 반환 값
  • 자신의 함수 계산 결과

귀하의 예를 살펴 보겠습니다.

int factorial (int n) {
    if (n == 0) return 1;
    else return n * factorial(n - 1);
}

프레임 f (5)는 자신의 계산 결과 (5)와 f (4)의 값을 "저장"합니다. factorial (5)를 호출하면 스택 호출이 붕괴되기 직전에 다음이 있습니다.

 [Stack_f(5): return 5 * [Stack_f(4): 4 * [Stack_f(3): 3 * ... [1[1]]

각 스택은 내가 언급 한 값 외에 함수의 전체 범위를 저장합니다. 따라서 재귀 함수 f의 메모리 사용량은 O (x)입니다. 여기서 x는 내가 만들어야하는 재귀 호출의 수입니다. 따라서 factorial (1) 또는 factorial (2)을 계산하는 데 1kb의 RAM이 필요한 경우 factorial (100)을 계산하려면 ~ 100k가 필요합니다.

Tail Recursive 함수는 인수에 (2)를 넣습니다.

Tail Recursion에서는 매개 변수를 사용하여 각 재귀 프레임의 부분 계산 결과를 다음 프레임으로 전달합니다. 팩토리얼 예제 인 Tail Recursive를 보겠습니다.

int factorial (int n) {int helper (int num, int 누적) {if num == 0 return 누적 else return helper (num-1, 누적 * num)} return helper (n, 1)
}

factorial (4)에서 프레임을 살펴 보겠습니다.

[Stack f(4, 5): Stack f(3, 20): [Stack f(2,60): [Stack f(1, 120): 120]]]]

차이점이 보이십니까? "일반"재귀 호출에서 반환 함수는 최종 값을 재귀 적으로 구성합니다. Tail Recursion에서는 기본 케이스 (마지막으로 평가 된 케이스) 만 참조합니다 . 우리는 accumulator를 이전 값을 추적하는 인수 라고 부릅니다 .

재귀 템플릿

일반 재귀 함수는 다음과 같습니다.

type regular(n)
    base_case
    computation
    return (result of computation) combined with (regular(n towards base case))

Tail 재귀로 변환하려면 다음을 수행합니다.

  • 누산기를 운반하는 도우미 기능을 소개합니다.
  • 누산기가 기본 케이스로 설정된 주 함수 내에서 도우미 함수를 실행합니다.

보기:

type tail(n):
    type helper(n, accumulator):
        if n == base case
            return accumulator
        computation
        accumulator = computation combined with accumulator
        return helper(n towards base case, accumulator)
    helper(n, base case)

차이점이 보이십니까?

테일 콜 최적화

Tail Call 스택의 Non-Border-Cases에 저장된 상태가 없기 때문에 중요하지 않습니다. 일부 언어 / 통역사는 이전 스택을 새 스택으로 대체합니다. 따라서 호출 수를 제한하는 스택 프레임이 없기 때문에 Tail Calls 는 이러한 경우 for 루프처럼 작동합니다 .

최적화하는 것은 컴파일러의 몫입니다.


6

다음은 재귀 함수가 작동하는 방식을 보여주는 간단한 예입니다.

long f (long n)
{

    if (n == 0) // have we reached the bottom of the ocean ?
        return 0;

    // code executed in the descendence

    return f(n-1) + 1; // recurrence

    // code executed in the ascendence

}

Tail 재귀는 함수의 끝에서 반복이 수행되는 간단한 재귀 함수이므로 어센 던스에서 코드가 수행되지 않으므로 대부분의 고급 프로그래밍 언어 컴파일러가 Tail Recursion Optimization으로 알려진 작업을 수행하는 데 도움 이됩니다. Tail recursion modulo로 알려진 더 복잡한 최적화


1

재귀 함수는 자체적으로 호출 하는 함수입니다.

이를 통해 프로그래머는 최소한의 코드를 사용하여 효율적인 프로그램을 작성할 수 있습니다 .

단점은 적절하게 작성하지 않으면 무한 루프 및 기타 예기치 않은 결과 가 발생할있다는 입니다.

Simple Recursive 함수와 Tail Recursive 함수를 모두 설명하겠습니다.

단순 재귀 함수 를 작성하려면

  1. 고려해야 할 첫 번째 사항은 if 루프 인 루프에서 나올 때를 결정할 때입니다.
  2. 두 번째는 우리가 우리 자신의 기능인 경우해야 할 과정입니다.

주어진 예에서 :

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)

  1. n = 4 대체
public static int fact(4){
  if(4 <=1)
     return 1;
  else 
     return 4 * fact(4-1);
}

If루프가 실패하여 else루프 로 이동하여 반환합니다.4 * fact(3)

  1. 스택 메모리에는 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)

  1. 스택 메모리에는 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)

  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);
    }
}
  1. n = 4 대체
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)

  1. 스택 메모리에는 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)

  1. 스택 메모리에는 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)

  1. 스택 메모리에는 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 의 결과

여기에 이미지 설명 입력


0

재귀는 내부 구현과 관련이 있기 때문에 내 대답은 추측에 가깝습니다.

꼬리 재귀에서 재귀 함수는 동일한 함수의 끝에서 호출됩니다. 아마도 컴파일러는 다음과 같이 최적화 할 수 있습니다.

  1. 진행중인 기능이 종료되도록합니다 (예 : 사용 된 스택이 호출 됨).
  2. 함수에 대한 인수로 사용될 변수를 임시 저장소에 저장하십시오.
  3. 그런 다음 임시 저장된 인수로 함수를 다시 호출하십시오.

보시다시피, 동일한 함수의 다음 반복 전에 원래 함수를 마무리하므로 실제로 스택을 "사용"하지 않습니다.

그러나 함수 내부에 호출 할 소멸자가 있으면이 최적화가 적용되지 않을 수 있다고 생각합니다.


0

컴파일러는 테일 재귀를 이해하기에 충분히 지능적입니다. 재귀 호출에서 복귀하는 동안 보류중인 작업이없고 재귀 호출이 마지막 문인 경우 테일 재귀 범주에 속합니다. 컴파일러는 기본적으로 꼬리 재귀 최적화를 수행하여 스택 구현을 제거합니다. 아래 코드를 고려하십시오.

void tail(int i) {
    if(i<=0) return;
    else {
     system.out.print(i+"");
     tail(i-1);
    }
   }

최적화를 수행하면 위의 코드가 아래 코드로 변환됩니다.

void tail(int i) {
    blockToJump:{
    if(i<=0) return;
    else {
     system.out.print(i+"");
     i=i-1;
     continue blockToJump;  //jump to the bolckToJump
    }
    }
   }

이것이 컴파일러가 Tail Recursion Optimization을 수행하는 방법입니다.

당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.