재귀 알고리즘에서 스택 오버플로를 피하기 위해 어떤 방법이 있습니까?


44

질문

재귀 알고리즘으로 인한 스택 오버플로를 해결하는 가능한 방법은 무엇입니까?

Project Euler 문제 14 를 해결 하려고 시도하고 재귀 알고리즘으로 시도하기로 결정했습니다. 그러나 프로그램은 java.lang.StackOverflowError와 함께 중지됩니다. 알겠습니다 매우 큰 수의 Collatz 시퀀스를 생성하려고 시도했기 때문에 알고리즘이 실제로 스택을 오버플로했습니다.

솔루션

그래서 궁금합니다. 재귀 알고리즘이 올바르게 작성되었다고 가정하고 스택 오버플로가 발생한다고 가정하면 스택 오버플로를 해결하는 표준 방법은 무엇입니까? 마음에 두 가지 개념은 다음과 같습니다.

  1. 꼬리 재귀
  2. 되풀이

아이디어 (1)과 (2)가 맞습니까? 다른 옵션이 있습니까?

편집하다

Java, C #, Groovy 또는 Scala에서 코드를 보는 것이 좋습니다.

아마도 위에서 언급 한 Project Euler 문제를 사용하지 않기 때문에 다른 사람들에게는 손상되지 않지만 다른 알고리즘을 사용하십시오. 계승 또는 비슷한 것.


3
되풀이. 메모
제임스

2
분명히, 메모 화 실제로 반복 계산 있을 때만 작동합니다 .
Jörg W Mittag

2
어쨌든 모든 언어 구현이 테일 재귀 최적화를 수행 할 수있는 것은 아닙니다
jk.

2
이것은 아마도 재귀보다 코어 큐어로 해결하는 것이 좋습니다.
Jörg W Mittag

3
1,000,000 미만의 숫자로 작업하고 1로 이동하는 경우이 질문에 대한 답은 약 500 단계로 1에 도달합니다. 작은 스택 프레임이있는 경우 재귀에 세금을 부과해서는 안됩니다. --- 1에서 시작하여 2, 4, 8, 16, {5,32}에 따라 올라 가려고하면 잘못하고있는 것입니다.

답변:


35

테일 콜 최적화 는 많은 언어와 컴파일러에 있습니다. 이 상황에서 컴파일러는 다음 형식의 기능을 인식합니다.

int foo(n) {
  ...
  return bar(n);
}

여기서 언어는 반환되는 결과가 다른 함수의 결과임을 인식하고 새 스택 프레임이있는 함수 호출을 점프로 변경합니다.

고전적인 계승 방법을 실현하십시오.

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

입니다 하지 때문에 반환에 필요한 검사의 꼬리 호출 optimizatable은. ( 예제 소스 코드 및 컴파일 된 출력 )

이 테일 콜을 최적화하려면

int _fact(int n, int acc) {
    if(n == 1) return acc;
    return _fact(n - 1, acc * n);
}

int factorial(int n) {
    if(n == 0) return 1;
    return _fact(n, 1);
}

이 코드를 컴파일하면 gcc -O2 -S fact.c(컴파일러에서 최적화를 활성화하려면 -O2가 필요하지만, -O3의 최적화가 많으면 사람이 읽기가 어렵습니다 ...)

_fact(int, int):
    cmpl    $1, %edi
    movl    %esi, %eax
    je  .L2
.L3:
    imull   %edi, %eax
    subl    $1, %edi
    cmpl    $1, %edi
    jne .L3
.L2:
    rep ret

( 예제 소스 코드 및 컴파일 된 출력 )

하나는 (새로운 스택 프레임으로 서브 루틴 호출을 수행함) 보다는 segment .L3에서 볼 수 있습니다 .jnecall

Java로 테일 콜 최적화는 어렵고 JVM 구현에 달려 있습니다. - 어떤) TCO는 피할 것입니다 - 꼬리 재귀 + 자바꼬리 재귀 + 최적화 찾아 좋은 태그 집합입니다. 다른 JVM 언어는 꼬리 재귀를 더 잘 최적화 할 수 있습니다 (클로저 시도 ( 꼬리 호출 최적화 를 요구하는 반복 ) 또는 스칼라).

그것은 말했다

당신이 옳은 것을 썼다는 것을 아는 데는 기쁨 이 있습니다.
그리고 지금, 나는 스카치를 가지고 독일 일렉트로니카를 입을 것입니다 ...


"재귀 알고리즘에서 스택 오버플로를 피하는 방법"의 일반적인 질문에 ...

또 다른 방법은 재귀 카운터를 포함하는 것입니다. 이것은 제어 할 수없는 상황 (및 코딩 불량)으로 인한 무한 루프를 감지하기위한 것입니다.

재귀 카운터는

int foo(arg, counter) {
  if(counter > RECURSION_MAX) { return -1; }
  ...
  return foo(arg, counter + 1);
}

전화를 걸 때마다 카운터가 증가합니다. 카운터가 너무 커지면 오류가 발생합니다 (여기서는 -1 만 반환하지만 다른 언어에서는 예외를 throw하는 것이 좋습니다). 생각은 재귀를 수행 할 때 예상보다 훨씬 깊고 무한 루프가 발생할 때 더 나쁜 일 (메모리 오류에서)이 발생하는 것을 방지하는 것입니다.

이론적으로는 필요하지 않습니다. 실제로, 나는 약간의 작은 오류와 나쁜 코딩 관행 (다른 스레드가 무언가를 변경하여 다른 스레드가 무한한 재귀 호출 루프로 들어가는 다중 스레드 동시성 문제)으로 인해 코드에 잘못 작성된 코드를 보았습니다.


올바른 알고리즘을 사용하고 올바른 문제를 해결하십시오. 특히 콜라 츠 추측 위해, 나타납니다 당신이 그것을 해결하기 위해 노력하는 것을 XKCD의 방법 :

XKCD # 710

당신은 숫자로 시작해서 나무 순회를하고 있습니다. 이로 인해 검색 공간이 매우 넓습니다. 정답에 대한 반복 횟수를 계산하는 빠른 실행은 약 500 단계로 이루어집니다. 작은 스택 프레임의 재귀에는 문제가되지 않습니다.

재귀 솔루션을 아는 것은 좋지 않은 일이지만 반복 솔루션 이 더 낫다는 것을 여러 번 알고 있어야합니다 . 재귀 알고리즘을 반복적 인 알고리즘으로 변환하는 방법에는 스택 오버플 로에서 재귀에서 반복으로 이동하는 여러 가지 방법이 있습니다.


1
웹을 서핑하는 동안 나는 오늘 xkcd 만화를 보았습니다. :-) 랜달 먼로의 만화는 기쁨입니다.
Lernkurve

@ Lenkurve 나는 이것을 작성하기 시작하고 게시 한 후에 코드 편집이 추가 된 것을 알았습니다. 이를 위해 다른 코드 샘플이 필요합니까?

아뇨, 전혀 아닙니다. 그것은 완벽. 물어봐 주셔서 감사합니다!
Lernkurve

이 만화를 추가해도 좋습니다 : imgs.xkcd.com/comics/functional.png
Ellen Spertus

@espertus 감사합니다. 나는 그것을 추가했다 (일부 소스를 정리하고 조금 더 추가했다)

17

언어 구현은 테일 재귀 최적화를 지원해야합니다. 주요 Java 컴파일러는 그렇게 생각하지 않습니다.

메모 화는 다음과 같이 매번 다시 계산하지 않고 계산 결과를 기억한다는 것을 의미합니다.

collatz(i):
    if i in memoized:
        return memoized[i]

    if i == 1:
        memoized[i] = 1
    else if odd(i):
        memoized[i] = 1 + collatz(3*i + 1)
    else
        memoized[i] = 1 + collatz(i / 2)

    return memoized[i]

백만 미만의 모든 시퀀스를 계산할 때는 시퀀스가 ​​끝날 때 많은 반복이 발생합니다. 메모 화를 사용하면 스택을 더 깊고 깊게 만들 필요없이 이전 값을 빠르게 해시 테이블 조회 할 수 있습니다.


1
메모에 대한 매우 이해하기 쉬운 설명. 무엇보다도 코드 스 니펫으로 설명해 주셔서 감사합니다. 또한, "시퀀스의 끝에서 많은 반복이있을 것"은 나에게 분명한 것이 었습니다. 감사합니다.
Lernkurve

10

아직 아무도 트램펄린 을 언급하지 않은 것에 놀랐습니다 . 트램펄린 (이 의미에서)은 썽크 리턴 함수 (반복 전달 스타일)를 반복적으로 호출하는 루프이며 스택 지향 프로그래밍 언어에서 꼬리 재귀 함수 호출을 구현하는 데 사용할 수 있습니다.

이 StackOverflow 질문은 Java에서 다양한 trampolining 구현에 대해 상당히 자세하게 설명 합니다. Trampoline을 위해 Java에서 StackOverflow 처리


나는 이것도 바로 생각했다. 트램펄린은 꼬리 호출 최적화를 수행하는 방법이므로 사람들은 (거의-아마도) 말하고 있습니다. +1 특정 참조 용.
Steven Evers

6

꼬리 재귀 함수를 인식 하고 올바르게 처리 하는 언어와 컴파일러를 사용하는 경우 (예 : "발신자 대신에 호출자를 대체"), 그래도 스택은 제어 할 수 없어야합니다. 이 최적화는 재귀 적 방법을 반복적 인 방법으로 줄입니다. Java 가이 작업을 수행한다고 생각하지 않지만 Racket이한다는 것을 알고 있습니다.

재귀 적 접근 방식이 아닌 반복적 접근 방식을 사용하는 경우 호출이 어디에서 왔는지 기억할 필요가 거의 없으며 실제로 재귀 호출에서 스택 오버플로 가능성을 제거 할 수 있습니다.

전체 계산이 더 작고 반복적 인 계산을 많이 수행한다는 점에서 메모는 훌륭하고 이전에 계산 된 결과를 캐시에서 조회하여 전체 메소드 호출 수를 줄일 수 있습니다. 이 아이디어는 훌륭합니다. 또한 반복적 접근 방식을 사용하는지 아니면 재귀 적 접근 방식을 사용하는지 여부와 무관합니다.


1
메모를 지적하기위한 +1은 반복적 인 접근에도 유용합니다.
Karl Bielefeldt

모든 기능 프로그래밍 언어에는 테일 콜 최적화 기능이 있습니다.

3

당신은 재귀를 대체 할 열거 형을 만들 수 있습니다 ... 여기서 교수진을 계산하는 예가 있습니다 ... (이 예제에서 오랫동안 오래 사용했기 때문에 큰 숫자에는 효과가 없습니다 :-))

public class Faculty
{

    public static IEnumerable<long> Faculties(long n)
    {
        long stopat = n;

        long x = 1;
        long result = 1;

        while (x <= n)
        {
            result = result * x;
            yield return result;
            x++;
        }
    }
}

이것이 메모가 아닌 경우에도 이렇게하면 스택 오버플로가 무효화됩니다.


편집하다


화나게해서 미안 해요 내 유일한 의도는 스택 오버플로를 피하는 방법을 보여주는 것이 었습니다. 아마도 작고 빠르게 작성된 거친 코드 발췌 대신에 전체 코드 예제를 작성했을 것입니다.

다음 코드

  • 필요한 값을 반복적으로 계산할 때 재귀를 피합니다.
  • 이미 계산 된 값은 이미 계산 된 경우 저장되고 검색되므로 메모를 포함합니다
  • 스톱워치도 포함되어있어 메모가 제대로 작동하는지 확인할 수 있습니다

... umm ... 실행하면 명령 셸 창에 9999 줄의 버퍼를 갖도록 설정하십시오 ... 일반적인 300은 아래 프로그램의 결과를 실행하기에 충분하지 않습니다 ...

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Numerics;
using System.Text;
using System.Threading.Tasks;
using System.Timers;

namespace ConsoleApplication1
{
    class Program
    {
        static Stopwatch w = new Stopwatch();
        static Faculty f = Faculty.GetInstance();

        static void Main(string[] args)
        {
            Out(5);
            Out(10);
            Out(-5);
            Out(0);
            Out(1);
            Out(4);
            Out(29);
            Out(30);
            Out(20);
            Out(10000);
            Out(20000);
            Out(19999);
            Console.ReadKey();
        }

        static void Out(BigInteger n)
        {
             try
            {
                w.Reset();
                w.Start();
                var x = f.Calculate(n);
                w.Stop();
                var time = w.ElapsedMilliseconds;
                Console.WriteLine(String.Format("{0} ({2}ms): {1}", n, x, time));
            }
            catch (ArgumentException e)
            {
                Console.WriteLine(e.Message);
            }

            Console.WriteLine("\n\n");
       }
    }

Faculty 클래스의 정적 변수 "instance"* 1을 상점에 싱글 톤으로 선언합니다. 프로그램이 실행되는 한 클래스의 "GetInstance ()"는 이미 계산 된 모든 값을 저장 한 인스턴스를 얻습니다. * 이미 계산 된 모든 값을 보유 할 정적 정렬리스트 1 개

생성자에서 입력 0과 1에 대해 목록 1의 2 개의 특수 값을 추가합니다.

    public class Faculty
    {
        private static SortedList<BigInteger, BigInteger> _values; 
        private static Faculty _faculty {get; set;}

        private Faculty ()
        {
            _values = new SortedList<BigInteger, BigInteger>();
            _values.Add(0, 1);
            _values.Add(1, 1);
        }

        public static Faculty GetInstance() {
            _faculty = _faculty ?? new Faculty();
            return _faculty;
        }

        public BigInteger Calculate(BigInteger n) 
        {
            // check if input is smaller 0
            if (n < 0)
                throw new ArgumentException(" !!! Faculty is not defined for values < 0 !!!");

            // if value is not already calculated => do so
            if(!_values.ContainsKey(n))
                Faculties(n);

            // retrieve n! from Sorted List
            return _values[n];
        }

        private static void Faculties(BigInteger n)
        {
            // get the last calculated values and continue calculating if the calculation for a bigger n is required
            BigInteger i = _values.Max(x => x.Key),
                           result = _values[i];

            while (++i <= n)
            {
                CalculateNext(ref result, i);
                // add value to the SortedList if not already done
                if (!_values.ContainsKey(i))
                    _values.Add(i, result);
            }
        }

        private static void CalculateNext(ref BigInteger lastresult, BigInteger i) {

            // put in whatever iterative calculation step you want to do
            lastresult = lastresult * i;

        }
    }
}

5
기술적으로 이것은 당신이 어떤 재귀를 완전히 제거함에 따라 반복입니다
ratchet freak

그것은 :-)이고 각 계산 단계 사이의 방법 변수 내의 결과를 기억합니다
Ingo

2
나는 학부 (100)는 해시에 결과 저장을 계산 처음 전화를 반환 할 때 다시 저장된 결과가 반환됩니다 호출 한 후 때,이다, 당신이 오해 메모이 제이션을 생각
래칫 괴물

@jk. 그의 신용에 따르면, 그는 실제로 이것이 재귀 적이라고 말하지 않습니다.
Neil

이것이 메모 화가 아니더라도, 이렇게하면 스택 오버 플로우가 무효화됩니다
Ingo

2

스칼라의 @tailrec경우 재귀 메서드에 주석을 추가 할 수 있습니다 . 이런 식으로 컴파일러 테일 콜 최적화가 실제로 이루어 지도록 합니다.

따라서 이것은 컴파일되지 않습니다 (요소).

@tailrec
def fak1(n: Int): Int = {
  n match {
    case 0 => 1
    case _ => n * fak1(n - 1)
  }
}

오류 메시지는 다음과 같습니다.

scala : @tailrec 어노테이션이있는 메소드 fak1을 최적화 할 수 없음 : 꼬리 위치에없는 재귀 호출을 포함합니다.

반면에 :

def fak3(n: Int): Int = {
  @tailrec
  def fak3(n: Int, result: Int): Int = {
    n match {
      case 0 => result
      case _ => fak3(n - 1, n * result)
    }
  }

  fak3(n, 1)
}

컴파일 및 테일 콜 최적화가 발생했습니다.


1

아직 언급되지 않은 한 가지 가능성은 재귀를 가지지 만 시스템 스택을 사용하지 않는 것입니다. 물론 힙을 오버플로 할 수도 있지만 알고리즘이 실제로 한 형태 또는 다른 형태의 역 추적이 필요한 경우 (왜 재귀를 사용해야합니까?), 선택의 여지가 없습니다.

Stackless Python 과 같은 일부 언어의 스택리스 구현이 있습니다 .


0

또 다른 해결책은 자신의 스택을 시뮬레이션하고 컴파일러 + 런타임의 구현에 의존하지 않는 것입니다. 이것은 간단한 해결책이나 빠른 해결책은 아니지만 이론적으로 메모리가 부족할 때만 StackOverflow를 얻습니다.

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