[recursion] 재귀 란 무엇이며 언제 사용해야합니까?

메일 링리스트와 온라인 토론에서 정기적으로 올라 오는 주제 중 하나는 컴퓨터 과학 학위를 취득하는 것의 장점 (또는 부족)입니다. 부정적인 당사자에 대해 몇 번이고 반복되는 것처럼 보이는 주장은 그들이 몇 년 동안 코딩을 해왔고 재귀를 사용한 적이 없다는 것입니다.

그래서 질문은 :

  1. 재귀 란 무엇입니까?
  2. 재귀는 언제 사용합니까?
  3. 사람들이 재귀를 사용하지 않는 이유는 무엇입니까?


답변

이 스레드 에는 재귀 에 대한 여러 가지 좋은 설명 이 있습니다.이 답변은 대부분의 언어에서 재귀 를 사용하지 말아야하는 이유에 대한 것입니다. * 대부분의 주요 명령형 언어 구현 (즉, C, C ++, Basic, Python의 모든 주요 구현)에서 , Ruby, Java 및 C #) 반복 은 재귀보다 훨씬 선호됩니다.

이유를 확인하려면 위의 언어에서 함수를 호출하는 데 사용하는 단계를 살펴보세요.

  1. 함수의 인수와 지역 변수를 위해 스택 에 공간이 조각되어 있습니다 .
  2. 함수의 인수가이 새 공간에 복사됩니다.
  3. 제어 기능으로 이동
  4. 함수의 코드가 실행됩니다.
  5. 함수의 결과는 반환 값으로 복사됩니다.
  6. 스택이 이전 위치로 되감 깁니다.
  7. 컨트롤은 함수가 호출 된 위치로 다시 이동합니다.

이 모든 단계를 수행하는 데는 일반적으로 루프를 반복하는 것보다 약간 더 많은 시간이 걸립니다. 그러나 실제 문제는 1 단계에 있습니다. 많은 프로그램이 시작될 때 스택에 단일 메모리 청크를 할당하고 해당 메모리가 부족하면 (종종 재귀로 인한 것은 아니지만) 스택 오버플 로로 인해 프로그램이 충돌 합니다 .

따라서 이러한 언어에서는 재귀가 느리고 충돌에 취약합니다. 그래도 사용에 대한 몇 가지 주장이 있습니다. 일반적으로 재귀 적으로 작성된 코드는 읽는 방법을 알게되면 더 짧고 우아합니다.

언어 구현자가 일부 클래스의 스택 오버플로를 제거 할 수있는 테일 호출 최적화 라는 기술을 사용할 수 있습니다 . 간결하게 말하면 함수의 반환 표현식이 단순히 함수 호출의 결과 인 경우 스택에 새 수준을 추가 할 필요가없는 경우 호출되는 함수에 대해 현재 수준을 재사용 할 수 있습니다. 안타깝게도 명령형 언어 구현에는 꼬리 호출 최적화가 내장되어 있습니다.

* 나는 재귀를 좋아합니다. 내가 가장 좋아하는 정적 언어 는 루프를 전혀 사용하지 않습니다. 재귀는 무언가를 반복적으로 수행하는 유일한 방법입니다. 나는 재귀가 일반적으로 그것에 맞게 조정되지 않은 언어에서 좋은 생각이라고 생각하지 않습니다.

** 그런데 Mario, ArrangeString 함수의 일반적인 이름은 “join”이며 선택한 언어에 이미 구현되어 있지 않다면 놀랄 것입니다.


답변

재귀의 간단한 영어 예.

A child couldn't sleep, so her mother told her a story about a little frog,
    who couldn't sleep, so the frog's mother told her a story about a little bear,
         who couldn't sleep, so the bear's mother told her a story about a little weasel...
            who fell asleep.
         ...and the little bear fell asleep;
    ...and the little frog fell asleep;
...and the child fell asleep.


답변

가장 기본적인 컴퓨터 과학적 의미에서 재귀는 자신을 호출하는 함수입니다. 연결 목록 구조가 있다고 가정합니다.

struct Node {
    Node* next;
};

그리고 당신은 재귀로 이것을 할 수있는 연결 목록의 길이를 알아 내고 싶습니다 :

int length(const Node* list) {
    if (!list->next) {
        return 1;
    } else {
        return 1 + length(list->next);
    }
}

(물론 for 루프로도 수행 할 수 있지만 개념을 설명하는 데 유용합니다.)


답변

함수가 자신을 호출 할 때마다 루프를 생성하면 재귀입니다. 모든 것과 마찬가지로 재귀에는 좋은 용도와 나쁜 용도가 있습니다.

가장 간단한 예는 함수의 마지막 줄이 자신에 대한 호출 인 꼬리 재귀입니다.

int FloorByTen(int num)
{
    if (num % 10 == 0)
        return num;
    else
        return FloorByTen(num-1);
}

그러나 이것은 좀 더 효율적인 반복으로 쉽게 대체 될 수 있기 때문에 거의 무의미한 예입니다. 결국 재귀는 함수 호출 오버 헤드로 인해 어려움을 겪습니다. 위의 예에서는 함수 자체 내부의 작업에 비해 상당 할 수 있습니다.

따라서 반복보다는 재귀를 수행하는 모든 이유는 호출 스택 을 활용하여 영리한 작업을 수행하는 것입니다. 예를 들어 동일한 루프 내에서 다른 매개 변수를 사용하여 함수를 여러 번 호출하면 분기 를 수행하는 방법 입니다. 고전적인 예는 Sierpinski 삼각형 입니다.

여기에 이미지 설명 입력

호출 스택이 세 방향으로 분기되는 재귀를 사용하여 매우 간단하게 그 중 하나를 그릴 수 있습니다.

private void BuildVertices(double x, double y, double len)
{
    if (len > 0.002)
    {
        mesh.Positions.Add(new Point3D(x, y + len, -len));
        mesh.Positions.Add(new Point3D(x - len, y - len, -len));
        mesh.Positions.Add(new Point3D(x + len, y - len, -len));
        len *= 0.5;
        BuildVertices(x, y + len, len);
        BuildVertices(x - len, y - len, len);
        BuildVertices(x + len, y - len, len);
    }
}

반복으로 동일한 작업을 시도하면 수행하는 데 더 많은 코드가 필요하다는 것을 알게 될 것입니다.

다른 일반적인 사용 사례에는 웹 사이트 크롤러, 디렉토리 비교 등과 같은 계층 구조를 통과하는 것이 포함될 수 있습니다.

결론

실제로 반복적 인 분기가 필요할 때마다 재귀가 가장 적합합니다.


답변

재귀는 분할 정복 사고 방식을 기반으로 문제를 해결하는 방법입니다. 기본 아이디어는 원래 문제를 가져 와서 더 작은 (더 쉽게 해결되는) 인스턴스로 나누고, 이러한 작은 인스턴스를 해결 한 다음 (일반적으로 동일한 알고리즘을 다시 사용하여) 최종 솔루션으로 재 조립하는 것입니다.

표준 예는 n의 계승을 생성하는 루틴입니다. n의 계승은 1과 n 사이의 모든 숫자를 곱하여 계산됩니다. C #의 반복 솔루션은 다음과 같습니다.

public int Fact(int n)
{
  int fact = 1;

  for( int i = 2; i <= n; i++)
  {
    fact = fact * i;
  }

  return fact;
}

반복적 솔루션에 대해 놀라운 것은 없으며 C #에 익숙한 사람이라면 누구나 이해할 수 있습니다.

재귀 적 해는 n 번째 팩토리얼이 n * Fact (n-1)임을 인식하여 구합니다. 또는 다른 방법으로 말하자면, 특정 팩토리얼 숫자가 무엇인지 안다면 다음 숫자를 계산할 수 있습니다. 다음은 C #의 재귀 솔루션입니다.

public int FactRec(int n)
{
  if( n < 2 )
  {
    return 1;
  }

  return n * FactRec( n - 1 );
}

이 함수의 첫 번째 부분은 Base Case (또는 때때로 Guard Clause) 로 알려져 있으며 알고리즘이 영원히 실행되는 것을 방지합니다. 함수가 1 이하의 값으로 호출 될 때마다 값 1을 반환합니다. 두 번째 부분은 더 흥미롭고 재귀 적 단계 로 알려져 있습니다. 여기서 우리는 약간 수정 된 매개 변수를 사용하여 동일한 메서드를 호출 한 다음 (1 씩 감소) 결과에 n의 복사본을 곱합니다.

처음 만났을 때 이것은 다소 혼란 스러울 수 있으므로 실행할 때 어떻게 작동하는지 조사하는 것이 좋습니다. FactRec (5)를 호출한다고 상상해보십시오. 우리는 루틴에 들어가고, 기본 케이스에 의해 선택되지 않으므로 다음과 같이 끝납니다.

// In FactRec(5)
return 5 * FactRec( 5 - 1 );

// which is
return 5 * FactRec(4);

매개 변수 4로 메소드를 다시 입력하면 다시 가드 절에 의해 중지되지 않으므로 다음과 같이됩니다.

// In FactRec(4)
return 4 * FactRec(3);

이 반환 값을 위의 반환 값으로 대체하면

// In FactRec(5)
return 5 * (4 * FactRec(3));

이렇게하면 최종 솔루션에 도달하는 방법에 대한 단서를 얻을 수 있으므로 아래로가는 각 단계를 빠르게 추적하고 보여 드리겠습니다.

return 5 * (4 * FactRec(3));
return 5 * (4 * (3 * FactRec(2)));
return 5 * (4 * (3 * (2 * FactRec(1))));
return 5 * (4 * (3 * (2 * (1))));

최종 대체는 기본 케이스가 트리거 될 때 발생합니다. 이 시점에서 우리는 처음에 팩토리얼의 정의와 직접적으로 동일한 풀기위한 간단한 algrebraic 공식을 가지고 있습니다.

메서드를 호출 할 때마다 기본 사례가 트리거되거나 매개 변수가 기본 사례에 더 가까운 동일한 메서드에 대한 호출 (종종 재귀 호출이라고 함)이 발생한다는 점에 유의하는 것이 좋습니다. 그렇지 않은 경우 메서드는 영원히 실행됩니다.


답변

재귀는 자신을 호출하는 함수로 문제를 해결합니다. 이에 대한 좋은 예는 계승 함수입니다. Factorial은 5의 factorial이 5 * 4 * 3 * 2 * 1 인 수학 문제입니다.이 함수는 C #에서 양의 정수에 대해이 문제를 해결합니다 (테스트되지 않음-버그가있을 수 있음).

public int Factorial(int n)
{
    if (n <= 1)
        return 1;

    return n * Factorial(n - 1);
}


답변

재귀는 문제의 더 작은 버전을 해결 한 다음 그 결과와 다른 계산을 사용하여 원래 문제에 대한 답을 공식화하여 문제를 해결하는 방법을 말합니다. 종종 더 작은 버전을 해결하는 과정에서이 방법은 해결하기 쉬운 “기본 사례”에 도달 할 때까지 더 작은 버전의 문제를 해결합니다.

예를 들어 숫자에 대한 계승을 계산하기 위해 X이를로 나타낼 수 있습니다 X times the factorial of X-1. 따라서이 메서드는 “반복”하여의 계승을 찾은 X-1다음 얻은 값을 곱하여 X최종 답을 제공합니다. 물론의 계승을 찾기 위해 X-1먼저의 계승을 계산하는 식 X-2입니다. 때 기본 케이스는 것 X그것을 반환 할 알고있는 경우에, 0 또는 1 1부터 0! = 1! = 1.