꼬리 재귀는 무엇입니까?


1692

lisp를 배우기 시작하면서 tail-recursive 라는 용어를 보았습니다 . 정확히 무엇을 의미합니까?


153
궁금한 점은 : 오랜 시간 동안 언어를 사용하는 동안입니다. 고대 영어에서 사용되는 동안; 반면에 중형 영어 개발입니다. 함께 사용하면 의미가 서로 바뀌지 만 표준 미국 영어에서는 살아남지 못했습니다.
Filip Bartuzi

14
어쩌면 늦었지만 꼬리 재귀에 대한 꽤 좋은 기사입니다. programmerinterview.com/index.php/recursion/tail-recursion
Sam003

5
tail-recursive 함수를 식별하면 얻을 수있는 가장 큰 이점 중 하나는 함수를 반복 형식으로 변환하여 알고리즘을 메소드 스택 오버 헤드에서 재현 할 수 있다는 것입니다. 아래 @Kyle 크로닌의 반응과 몇 가지 다른 방문하는 것 같아서
KGhatak

@yesudeep의이 링크는 내가 찾은 최고의 가장 상세한 설명입니다 -lua.org/pil/6.3.html
Jeff Fischer

1
누군가가 말해 줄 수 있습니까? 병합 정렬 및 빠른 정렬은 꼬리 재귀 (TRO)를 사용합니까?
majurageer1

답변:


1718

첫 번째 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 인터프리터 는이를 지원하지 않습니다 .


32
꼬리 재귀로 최종 답변이 메소드의 마지막 호출에 의해 계산된다고 말할 수 있습니까? 꼬리 재귀가 아닌 경우 답을 계산하려면 모든 방법에 대한 모든 결과가 필요합니다.
chrisapotek

2
다음은 Lua의 몇 가지 예를 제공하는 부록입니다. lua.org/pil/6.3.html이 과정을 살펴 보는 것도 도움이 될 수 있습니다! :)
yesudeep 2013

2
누군가 chrisapotek의 질문을 해결할 수 있습니까? tail recursion꼬리 호출을 최적화하지 않는 언어로 어떻게 달성 할 수 있는지 혼란 스럽 습니다.
케빈 메러디스

3
@KevinMeredith "꼬리 재귀"는 함수의 마지막 명령문이 동일한 함수에 대한 재귀 호출임을 의미합니다. 해당 재귀를 최적화하지 않는 언어로이 작업을 수행하는 데 아무런 의미가없는 것이 맞습니다. 그럼에도 불구하고,이 답변은 (거의) 개념을 정확하게 보여줍니다. "else :"를 생략하면 테일 호출이 더 명확했을 것입니다. 행동을 바꾸지는 않지만 테일 콜을 독립적 인 진술로 할 것입니다. 편집으로 제출하겠습니다.
ToolmakerSteve

2
그래서 파이썬에서는 tailrecsum 함수를 호출 할 때마다 새로운 스택 프레임이 만들어지기 때문에 이점이 없습니다.
Quazi Irfan 2016

707

에서 기존의 재귀 전형적인 모델은 처음 재귀 호출을 수행 한 다음 재귀 호출의 반환 값을하고 결과를 계산하는 것입니다. 이러한 방식으로 모든 재귀 호출에서 돌아올 때까지 계산 결과를 얻지 못합니다.

꼬리 재귀 에서는 먼저 계산을 수행 한 다음 재귀 호출을 실행하여 현재 단계의 결과를 다음 재귀 단계로 전달합니다. 이로 인해 마지막 설명은의 형식입니다 (return (recursive-function params)). 기본적으로 주어진 재귀 단계의 반환 값은 다음 재귀 호출의 반환 값과 같습니다. .

그 결과 다음 재귀 단계를 수행 할 준비가되면 더 이상 현재 스택 프레임이 필요하지 않습니다. 이것은 약간의 최적화를 허용합니다. 실제로 적절하게 작성된 컴파일러를 사용하면 테일 재귀 호출 로 스택 오버플로 스니커 가 없어야합니다 . 다음 재귀 단계에서 현재 스택 프레임을 재사용하십시오. 나는 Lisp가 이것을 확신합니다.


17
"Lisp이이 작업을 수행한다고 확신합니다"– Scheme은하지만 Common Lisp가 항상 그런 것은 아닙니다.
Aaron

2
@Daniel "기본적으로, 주어진 재귀 단계의 반환 값은 다음 재귀 호출의 반환 값과 동일합니다."-Lorin Hochstein이 게시 한 코드 스 니펫에 대해서는이 인수가 적용되지 않습니다. 자세히 설명해 주시겠습니까?
Geek

8
@Geek 이것은 매우 늦은 답변이지만 Lorin Hochstein의 사례에서 실제로 사실입니다. 각 단계에 대한 계산은 재귀 호출이 아닌 재귀 호출 전에 수행됩니다. 결과적으로 각 정지 점은 이전 단계에서 직접 값을 반환합니다. 마지막 재귀 호출은 계산을 완료 한 다음 수정되지 않은 최종 결과를 호출 스택으로 되돌려 보냅니다.
reirab

3
스칼라는 수행하지만 그것을 실행하려면 지정된 @tailrec가 필요합니다.
SilentDirge

2
"이러한 방식으로 모든 재귀 호출에서 돌아올 때까지 계산 결과를 얻을 수 없습니다." -나는 이것을 잘못 이해했을 수도 있지만, 전통적인 재귀 가 모든 재귀를 호출하지 않고 실제로 결과를 얻는 유일한 방법 인 게으른 언어에 대해서는 특히 사실이 아닙니다 (예 : &&로 무한한 Bools 목록을 접는 것).
hasufell 2016 년

205

중요한 점은 꼬리 재귀가 본질적으로 반복과 동일하다는 것입니다. 컴파일러 최적화의 문제 일뿐만 아니라 표현력에 대한 기본 사실입니다. 이것은 두 가지 방법으로 진행됩니다. 양식의 루프를 사용할 수 있습니다

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);
}

(매개 변수가 적은 함수를 사용하는 꼬리 재귀 함수의 "래핑"은 일반적인 기능 관용구입니다.)


@LorinHochstein의 답변에서 나는 그의 설명에 따라 재귀 부분이 "반환"을 따르는 꼬리 꼬리 재귀가 이해되었지만 꼬리 재귀는 그렇지 않습니다. 예제가 꼬리 재귀로 올바르게 간주됩니까?
CodyBugstein

1
@Imray 꼬리 재귀 부분은 sum_aux 내부의 "return sum_aux"문입니다.
Chris Conway

1
@lmray : Chris의 코드는 본질적으로 동일합니다. if / then의 순서와 제한 테스트의 스타일 ... if x == 0 대 if (i <= n) ...은 끊어지지 않습니다. 요점은 각 반복이 결과를 다음으로 전달한다는 것입니다.
Taylor

else { return k; }다음으로 변경 될 수 있습니다return k;
c0der

144

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을 추가)이 있기 때문에 꼬리 재귀가 아닙니다. 매우 높은 숫자를 입력하면 스택 오버플로가 발생할 수 있습니다.


9
이것은 스택 크기에 대한 테일 호출의 의미를 설명하기 때문에 훌륭한 답변입니다.
앤드류 스완

@AndrewSwan 실제로, 나는이 질문에 걸려 넘어 질지도 모르는 독자와 때때로 독자가 받아 들인 대답을 더 잘 받아 들일 수 있다고 생각하지만 (실제로 스택이 무엇인지 알지 못하기 때문에) Jira를 사용하는 방법 부채.
호프만

1
스택 크기에 대한 함의를 포함하여 가장 좋아하는 답변입니다.
njk2015

80

정기적 인 재귀를 사용하면 각 재귀 호출이 다른 항목을 호출 스택으로 푸시합니다. 재귀가 완료되면 앱은 각 항목을 끝까지 튀어 나와야합니다.

꼬리 재귀를 사용하면 언어에 따라 컴파일러가 스택을 하나의 항목으로 축소 할 수 있으므로 스택 공간을 절약 할 수 있습니다 ... 큰 재귀 쿼리는 실제로 스택 오버플로를 일으킬 수 있습니다.

기본적으로 테일 재귀는 반복에 최적화 될 수 있습니다.


1
"큰 재귀 쿼리는 실제로 스택 오버플로를 일으킬 수 있습니다." 두 번째 (꼬리 재귀)가 아닌 첫 번째 단락에 있어야합니까? 테일 재귀의 큰 장점은 스택에서 호출을 "누적"하지 않는 방식으로 최적화 할 수 있다는 것입니다 (예 : 구성표).
Olivier Dulac

69

전문 용어 파일에는 꼬리 재귀의 정의에 대해 다음과 같이 말합니다.

꼬리 재귀 /n./

아직 아프지 않은 경우 꼬리 재귀를 참조하십시오.


68

단어로 설명하는 대신 예제가 있습니다. 이것은 계승 함수의 체계 버전입니다.

(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 함수는 일정한 스택 공간을 사용합니다.


4
꼬리 재귀의 가장 중요한 측면을 반복 형태로 변환하여 O (1) 메모리 복잡성 형태로 변환 할 수 있다고 언급 한 경우 +1.
KGhatak

1
@KGhatak 정확하게; 대답은 일반적으로 메모리가 아니라 "일정한 스택 공간"에 대해 올바르게 말합니다. 오해가 없는지 확인하기 위해서 예를 들어 tail-recursive list-tail-mutating list-reverse프로시 저는 일정한 스택 공간에서 실행되지만 힙에서 데이터 구조를 작성하고 확장합니다. 트리 탐색은 추가 인수로 시뮬레이션 된 스택을 사용할 수 있습니다. 등
윌 네스

45

테일 재귀는 재귀 알고리즘의 마지막 논리 명령에서 마지막 재귀 호출을 나타냅니다.

일반적으로 재귀 에는 재귀 호출을 중지하고 호출 스택을 팝하기 시작 하는 기본 사례 가 있습니다. Lisp보다 C-ish이지만 고전적인 예를 사용하기 위해 계승 함수는 꼬리 재귀를 보여줍니다. 기본 사례 상태를 확인한 재귀 호출이 발생합니다 .

factorial(x, fac=1) {
  if (x == 1)
     return fac;
   else
     return factorial(x-1, x*fac);
}

계승에 대한 초기 호출은 factorial(n)여기서 fac=1(기본값)이고 n은 계승을 계산할 숫자입니다.


설명을 이해하는 것이 가장 쉽다는 것을 알았지 만 설명이 필요한 경우 꼬리 재귀는 하나의 문장 기본 사례가있는 함수에만 유용합니다. 이 postimg.cc/5Yg3Cdjn 과 같은 방법을 고려하십시오 . 참고 : 바깥 쪽 else은 "기본 사례"라고 부르는 단계이지만 여러 줄에 걸쳐 있습니다. 내가 당신을 오해하거나 내 가정이 맞습니까? 꼬리 재귀는 하나의 라이너에만 적합합니까?
답변을 원합니다

2
@IWantAnswers-아니요, 함수 본문은 임의로 클 수 있습니다. 테일 호출에 필요한 것은 해당 분기가 마지막으로 수행하는 함수를 호출하고 함수 호출 결과를 반환한다는 것입니다. 이 factorial예제는 고전적인 간단한 예일뿐입니다.
TJ Crowder

28

즉, 스택에서 명령어 포인터를 누르지 않고 재귀 함수의 맨 위로 이동하여 계속 실행할 수 있습니다. 이를 통해 스택 오버플로없이 함수를 무한정 재귀 할 수 있습니다.

주제에 대한 블로그 게시물을 작성했는데 스택 프레임의 모양을 그래픽으로 보여줍니다.


21

다음은 두 기능을 비교하는 빠른 코드 스 니펫입니다. 첫 번째는 주어진 숫자의 계승을 찾기위한 전통적인 재귀입니다. 두 번째는 꼬리 재귀를 사용합니다.

이해하기 매우 간단하고 직관적입니다.

재귀 함수가 꼬리 재귀인지 알 수있는 쉬운 방법은 기본 사례에서 구체적인 값을 반환하는 것입니다. 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);
    }
}

3
0! "mynumber == 1"은 "mynumber == 0"이어야합니다.
polerto

19

내가 이해하는 가장 좋은 방법 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 등과 같은 언어로 지원됩니다


12

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);
}

1
이것은 나에게 잘못된 결과를 반환합니다. 입력 8의 경우 36을 얻습니다 .21이어야합니다. 내가 뭔가를 놓치고 있습니까? Java를 사용하고 있으며 붙여 넣었습니다.
Alberto Zaccagni

1
이것은 [1, n]에서 i에 대한 SUM (i)을 반환합니다. 피보나치와 관련이 없습니다. Fibbo를 들어, substracts 테스트를 필요 iteracc할 때를 iter < (n-1).
Askolein

10

나는 Lisp 프로그래머는 아니지만 이것이 도움 될 것이라고 생각 합니다 .

기본적으로 재귀 호출이 마지막으로 수행되는 프로그래밍 스타일입니다.


10

다음은 꼬리 재귀를 사용하여 계승을 수행하는 일반적인 Lisp 예제입니다. 스택리스 특성으로 인해 엄청나게 큰 계승 계산을 수행 할 수 있습니다 ...

(defun ! (n &optional (product 1))
    (if (zerop n) product
        (! (1- n) (* product n))))

그리고 재미를 위해 당신은 시도 할 수 있습니다 (format nil "~R" (! 25))


9

간단히 말해서, 꼬리 재귀는 재귀 호출을 함수 의 마지막 문으로 재귀 호출을 기다릴 필요가 없습니다.

따라서 이것은 꼬리 재귀입니다. 즉, 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 ++ – 어셈블리보기 " 라는 제목의 긴 게시물을 작성했습니다.

여기에 이미지 설명을 입력하십시오


1
함수 N은 재귀 적입니까?
Fabian Pijcke

N (x-1)은 컴파일러가
for-

내 관심사는 함수 N이 정확히이 주제의 수용 된 답변 (제품이 아닌 합계 임)을 제외하고 함수 수익이며, 수익이 꼬리 재귀가 아니라고합니다.
Fabian Pijcke

8

다음은 tailrecsum앞에서 언급 한 함수 의 Perl 5 버전입니다 .

sub tail_rec_sum($;$){
  my( $x,$running_total ) = (@_,0);

  return $running_total unless $x;

  @_ = ($x-1,$running_total+$x);
  goto &tail_rec_sum; # throw away current stack frame
}

8

이것은 꼬리 재귀에 관한 컴퓨터 프로그램의 구조와 해석 에서 발췌 한 것입니다 .

반복 및 재귀와 대조적으로, 재귀 프로세스의 개념과 재귀 프로 시저의 개념을 혼동하지 않도록주의해야합니다. 프로 시저를 재귀 적이라고 설명 할 때 프로 시저 정의가 프로 시저 자체를 (직접 또는 간접적으로) 참조하는 구문 적 사실을 참조합니다. 그러나 선형 재귀 패턴을 따르는 프로세스를 설명 할 때 프로 시저 작성 방법의 구문이 아니라 프로세스의 진화 방식에 대해 이야기합니다. 반복 프로세스를 생성하는 것과 같은 사실과 같은 재귀 절차를 참조하는 것이 혼란스러워 보일 수 있습니다. 그러나 프로세스는 실제로 반복적입니다. 상태는 세 가지 상태 변수에 의해 완전히 캡처되므로 인터프리터는 프로세스를 실행하기 위해 세 개의 변수 만 추적하면됩니다.

프로세스와 프로 시저의 차이점이 혼동 될 수있는 한 가지 이유는 공통 언어 (Ada, Pascal 및 C 포함)의 대부분의 구현이 모든 재귀 프로 시저의 해석이 설명 된 프로세스가 원칙적으로 반복적 인 경우에도 프로 시저 호출 수 결과적으로, 이들 언어는 do, repeat, until, for 및 while과 같은 특수 목적의 "루핑 구성"에 의지해서 만 반복 프로세스를 설명 할 수 있습니다. 체계의 구현은이 결함을 공유하지 않습니다. 반복 프로세스가 재귀 프로 시저에 의해 설명 되더라도 일정한 공간에서 반복 프로세스를 실행합니다. 이 속성을 사용한 구현을 테일 재귀라고합니다. tail-recursive 구현에서는 일반적인 프로 시저 호출 메커니즘을 사용하여 반복을 표현할 수 있으므로 특수 반복 구성은 구문 설탕으로 만 유용합니다.


1
나는 여기에 모든 대답을 읽었지만 이것은이 개념의 진정한 핵심에 닿는 가장 명확한 설명입니다. 그것은 모든 것을 그렇게 간단하고 명확하게 보이게하는 똑 바른 방법으로 설명합니다. 내 무례 함을 용서하십시오. 어쨌든 다른 대답이 머리에 못을 박지 않는 것처럼 느껴집니다. SICP가 중요한 이유라고 생각합니다.
englealuze

8

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

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

단점은 제대로 작성하지 않으면 무한 루프 및 기타 예상치 못한 결과 가 발생할있다는 입니다.

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

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

  1. 가장 먼저 고려해야 할 사항은 언제 루프에서 나오는지 결정할 때입니다.
  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 의 결과

여기에 이미지 설명을 입력하십시오


7

꼬리 재귀는 당신이 지금 살고있는 삶입니다. "이전"프레임으로 돌아갈 이유나 수단이 없기 때문에 동일한 스택 프레임을 계속해서 재활용합니다. 과거는 끝났고 끝났으므로 버릴 수 있습니다. 프로세스가 필연적으로 죽을 때까지 한 프레임 씩 미래로 영원히 이동합니다.

일부 프로세스가 추가 프레임을 사용할 수 있지만 스택이 무한히 커지지 않으면 꼬리 재귀로 간주되는 것으로 간주하면 유추가 무너집니다.


1
그것은 분리 된 성격 장애 해석으로 깨지지 않습니다 . :) 마음 의 사회 ; 사회로서의 마음. :)
Will Ness

와! 지금은 그것에 대해 생각하는 또 다른 방법 이잖아
sutanu dalui

7

테일 재귀는 재귀 호출의 반환 후 계산이 수행되지 않는 함수의 끝 ( "테일")에서 함수가 호출되는 재귀 함수입니다. 많은 컴파일러는 재귀 호출을 테일 재귀 또는 반복 호출로 변경하도록 최적화합니다.

다수의 계승 계산 문제를 고려하십시오.

간단한 접근 방식은 다음과 같습니다.

  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)이지만 호출에 스택에 추가 변수를 추가하는 것은 없습니다. 따라서 컴파일러는 스택을 제거 할 수 있습니다.


7

꼬리 재귀는 정상적인 재귀에 비해 매우 빠릅니다. 조상 호출의 출력이 트랙을 유지하기 위해 스택으로 작성되지 않기 때문에 빠릅니다. 그러나 정상적인 재귀에서는 모든 조상이 트랙에 기록하기 위해 스택으로 작성된 출력을 호출합니다.


6

꼬리 재귀의 기능은 반환되기 전에 수행하는 마지막 작업은 재귀 함수 호출을 재귀 함수입니다. 즉, 재귀 함수 호출의 반환 값이 즉시 반환됩니다. 예를 들어 코드는 다음과 같습니다.

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 제거 또는 tail-call 최적화는 tail-recursive 함수에 적용 할 수 있지만 같은 것은 아닙니다. 파이썬에서 tail-recursive 함수를 작성할 수 있지만 (표시된대로) Python은 tail-call 최적화를 수행하지 않기 때문에 non-tail-recursive 함수보다 효율적이지 않습니다.
chepner

누군가 웹 사이트를 최적화하고 재귀 호출을 재귀로 렌더링하면 더 이상 StackOverflow 사이트가 없을 것입니까?! 그건 정말 나쁘다.
Nadjib Mami

5

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 테일 콜 조건 기사를 참조하십시오 .


4

재귀에는 두 가지 기본 종류가 있습니다 : 머리 재귀꼬리 재귀.

에서는 헤드 재귀 함수는 재귀 호출하고 어쩌면 예를 들어 재귀 호출의 결과를 이용하여 좀 더 계산을 수행한다.

A의 꼬리 재귀의 기능, 모든 계산이 먼저 발생하고, 재귀 호출이 발생 마지막 일이다.

멋진 게시물 에서 가져 왔습니다 . 읽어보십시오.


4

재귀는 자신을 호출하는 함수를 의미합니다. 예를 들면 다음과 같습니다.

(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 방식으로 수행됩니다 (내가 아는 한, 현재는없고 없음).


3

이 질문에는 많은 해답이 있지만 ... "꼬리 재귀"또는 적어도 "적절한 꼬리 재귀"를 정의하는 방법에 대한 대안을 취할 수는 없습니다. 즉, 프로그램에서 특정 표현의 속성으로보아야합니까? 아니면 프로그래밍 언어 구현의 속성으로보아야 합니까?

후자에 대한 자세한 내용은 고전적인 논문이 있습니다. 은 프로그래밍 언어 구현의 속성으로 "적절한 꼬리 재귀"를 정의한 Will Clinger의 "적절한 꼬리 재귀 및 공간 효율"(PLDI 1998) 이 있습니다. 정의는 호출 스택이 실제로 런타임 스택을 통해 표시되는지 또는 힙 할당 링크 된 프레임 목록을 통해 표시되는지 여부와 같은 구현 세부 사항을 무시할 수 있도록 구성됩니다.

이를 달성하기 위해 일반적으로 보는 프로그램 실행 시간이 아니라 프로그램 공간 사용량이 아닌 점근 분석을 사용합니다 . 이런 방식으로, 힙 할당 링크리스트와 런타임 호출 스택의 공간 사용량은 결과적으로 동일합니다. 따라서 프로그래밍 언어 구현 세부 사항 (실제로는 상당히 중요한 세부 사항)을 무시하게되지만 주어진 구현이 "속성 테일 재귀"에 대한 요구 사항을 충족하는지 여부를 판단하려고 할 때 물을 약간 흐릿하게 만들 수 있습니다. )

이 논문은 여러 가지 이유로 신중하게 연구 할 가치가 있습니다.

  • 프로그램의 테일 표현식테일 호출 에 대한 귀납적 정의를 제공합니다 . (이러한 정의와 그러한 호출이 중요한 이유는 여기에 주어진 다른 답변들 대부분의 주제 인 것 같습니다.)

    텍스트의 풍미를 제공하기 위해 이러한 정의가 있습니다.

    정의 1 핵심 체계로 작성된 프로그램 의 꼬리 표현 은 다음과 같이 귀납적으로 정의됩니다.

    1. 람다 식의 본문은 꼬리 식입니다
    2. 경우는 (if E0 E1 E2)다음 두, 꼬리 표현 E1E2꼬리 표현입니다.
    3. 다른 것은 꼬리 표현이 아닙니다.

    정의 2꼬리 호출은 프로 시저 호출하는 꼬리 표현이다.

꼬리 재귀 호출 또는 논문에서 "자기 꼬리 호출"은 절차 자체가 호출되는 꼬리 호출의 특수한 경우입니다.

  • 그것은 각 시스템이 동일한 관찰 행동이 핵심 계획, 평가를위한 여섯 가지 "기계"에 대한 공식적인 정의를 제공 제외 에 대한 점근 각각에 있음 공간의 복잡성 클래스를.

    예를 들어, 1. 스택 기반 메모리 관리, 2. 가비지 수집, 테일 호출 없음, 3. 가비지 수집 및 테일 호출이있는 머신에 각각 정의를 제공 한 후 본 백서는 다음과 같은 고급 스토리지 관리 전략으로 계속 진행됩니다. 4. 환경 5.에 폐쇄의 환경을 줄이고, 꼬리 호출의 마지막 하위 식 인수의 평가를 통해 보존 할 필요가 없다 "evlis 꼬리 재귀" 바로 그 폐쇄의 자유 변수, 6. Appel과 Shao에 의해 정의 된 소위 "공간 안전"시맨틱 .

  • 기계가 실제로 6 개의 별개의 공간 복잡성 클래스에 속한다는 것을 증명하기 위해, 비교중인 각 기계 쌍에 대한 논문은 한 기계에서는 점근 적 공간 폭발을 노출 시키지만 다른 기계에서는 그렇지 않은 프로그램의 구체적인 예를 제공합니다.


(지금 내 대답을 읽으면서 Clinger 논문 의 중요한 요점을 실제로 파악할 수 있는지 확실하지 않습니다 . 그러나 아쉽게도 지금이 답을 개발하는 데 더 많은 시간을 할애 할 수는 없습니다.)


1

많은 사람들이 이미 재귀를 설명했습니다. 나는 리카르도 테렐 (Ricardo Terrell)이 쓴“.NET의 동시성, 동시 및 병렬 프로그래밍의 현대 패턴”이라는 책에서 재귀가주는 몇 가지 장점에 대해 몇 가지 생각을하고 싶다.

“기능 재귀는 상태의 돌연변이를 피하기 때문에 FP에서 자연스럽게 반복하는 방법입니다. 반복 할 때마다 새 값이 루프 생성자로 전달되어 대신 업데이트 (돌연변이)됩니다. 또한 재귀 함수를 구성하여 프로그램을보다 모듈화하고 병렬화를 활용할 수있는 기회를 제공 할 수 있습니다. "

꼬리 재귀에 대한 동일한 책의 흥미로운 메모도 있습니다.

테일 콜 재귀는 규칙적인 재귀 함수를 위험과 부작용없이 큰 입력을 처리 할 수있는 최적화 된 버전으로 변환하는 기술입니다.

참고 테일 콜을 최적화로 사용하는 주요 이유는 데이터 로컬 성, 메모리 사용량 및 캐시 사용량을 향상시키기위한 것입니다. 테일 콜을 수행하면 수신자는 발신자와 동일한 스택 공간을 사용합니다. 메모리 압력이 줄어 듭니다. 새로운 캐시 라인을위한 공간을 만들기 위해 오래된 캐시 라인을 제거하지 않고 후속 호출자에 대해 동일한 메모리가 재사용되고 캐시에 머무를 수 있기 때문에 캐시를 약간 향상시킵니다.

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