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