재귀 또는 반복?


226

재귀 대신 루프를 사용하거나 둘 다 동일한 목적을 수행 할 수있는 알고리즘에서 루프를 사용하면 성능이 저하됩니까? 예 : 주어진 문자열이 회문인지 확인하십시오. 간단한 반복 알고리즘이 계산서에 적합 할 때 보여주기위한 수단으로 재귀를 사용하는 많은 프로그래머를 보았습니다. 컴파일러는 무엇을 사용할지 결정하는 데 중요한 역할을합니까?


4
@Warrior 항상 그런 것은 아닙니다. 예를 들어 체스 프로그램을 사용하면 재귀를 쉽게 읽을 수 있습니다. 체스 코드의 "반복적 인"버전은 실제로 속도를 높이는 데 도움이되지 않으며 더 복잡하게 만들 수 있습니다.
Mateen Ulhaq

12
톱보다 망치를 선호해야하는 이유는 무엇입니까? 송곳 위의 드라이버? 오거 위의 끌?
Wayne Conrad

3
즐겨 찾기가 없습니다. 그것들은 모두 각자의 목적을 가진 도구 일뿐입니다. 나는 "어떤 종류의 문제가 재귀보다 반복적으로 더 좋으며, 그 반대의 문제는 무엇입니까?"라고 물을 것입니다.
Wayne Conrad

9
"재귀에 대해 좋은 점은 무엇입니까?"... 재귀 적입니다. ; o)
Keng

9
거짓 전제. 재귀는 좋지 않습니다. 사실 그것은 매우 나쁘다. 강력한 소프트웨어를 작성하는 사람은 테일 콜 최적화 또는 로그 또는 유사 수준으로 제한되는 수준의 수가 아니면 재귀가 거의 항상 잘못된 종류의 스택 오버플 로로 이어지기 때문에 모든 재귀를 제거하려고 시도합니다 .
R .. GitHub 중지 지원 얼음

답변:


181

재귀 함수가 꼬리 재귀인지 (마지막 줄은 재귀 호출)에 따라 재귀가 더 비쌀 수 있습니다 . 꼬리 재귀 해야 컴파일러에 의해 인식되고 반복 대응에 최적화되어야합니다 (코드에서 간결하고 명확한 구현을 유지하면서).

몇 개월 또는 몇 년 안에 코드를 유지해야하는 빈약 한 빨판 (자신 또는 다른 사람)에게 가장 적합한 방식으로 알고리즘을 작성합니다. 성능 문제가 발생하면 코드를 프로파일 링 한 다음 반복 구현으로 이동하여 최적화 만 살펴보십시오. 메모동적 프로그래밍 을 살펴볼 수 있습니다 .


12
귀납법으로 정확성을 입증 할 수있는 알고리즘은 자연스럽게 재귀 형식으로 작성하는 경향이 있습니다. 테일 재귀가 컴파일러에 의해 최적화된다는 사실과 함께, 더 많은 알고리즘이 재귀 적으로 표현됩니다.
Binil Thomas

15
re : tail recursion is optimized by compilers그러나 모든 컴파일러가 꼬리 재귀를 지원하는 것은 아닙니다.
Kevin Meredith

347

루프는 프로그램 성능을 향상시킬 수 있습니다. 재귀는 프로그래머의 성능을 향상시킬 수 있습니다. 귀하의 상황에서 더 중요한 것을 선택하십시오!


3
@LeighCaldwell : 내 생각을 정확히 요약 한 것 같습니다. 안타깝게도 전능하지 않았습니다. 나는 확실히있다. :)
Ande Turner

35
당신은 당신의 대답 문구 때문에 책에 인용되었다는 것을 알고 있습니까? LOL amazon.com/Grokking-Algorithms-llustrated-programmers-curious/…
Aipi

4
나는이 답변을 좋아한다. 그리고 나는 "Grokking Algorithms"책을 좋아한다)
Max

그래서 적어도 저와 341 명의 인간은 그로 킹 알고리즘 책을 읽습니다!
zzfima

78

재귀를 반복과 비교하는 것은 십자 드라이버를 일자 드라이버와 비교하는 것과 같습니다. 대부분 당신 수있는 경우 납작한 머리를 가진 모든 십자 나사를 제거 있지만 해당 나 사용으로 설계된 드라이버를 사용하면 더 쉬울까요?

일부 알고리즘은 설계된 방식 (피보나치 시퀀스, 구조와 같은 트리 탐색 등)으로 인해 재귀에 적합합니다. 재귀는 알고리즘을 간결하고 이해하기 쉽게 만듭니다 (따라서 공유 가능하고 재사용 가능).

또한 일부 재귀 알고리즘은 "지연 평가"를 사용하여 반복 형제보다 더 효율적입니다. 즉, 루프가 실행될 때마다 필요한 시간보다 비싼 계산 만 수행합니다.

시작하기에 충분해야합니다. 나는 당신을 위해 몇 가지 기사와 예제를 파헤칠 것입니다.

링크 1 : Haskel vs PHP (재귀 vs 반복)

다음은 프로그래머가 PHP를 사용하여 큰 데이터 세트를 처리해야하는 예입니다. 그는 재귀를 사용하여 Haskel에서 처리하는 것이 얼마나 쉬운 지 보여 주지만 PHP는 동일한 방법을 쉽게 수행 할 수있는 방법이 없기 때문에 반복을 사용하여 결과를 얻었습니다.

http://blog.webspecies.co.uk/2011-05-31/lazy-evaluation-with-php.html

링크 2 : 마스터 링 재귀

재귀의 나쁜 평판은 대부분 명령형 언어의 높은 비용과 비 효율성에서 비롯됩니다. 이 기사의 저자는 재귀 알고리즘을 더 빠르고 효율적으로 최적화하는 방법에 대해 설명합니다. 또한 전통적인 루프를 재귀 함수로 변환하는 방법과 테일 엔드 재귀 사용의 이점을 설명합니다. 그의 마지막 말은 실제로 내가 생각하는 핵심 요점 중 일부를 요약했습니다.

"재귀 프로그래밍은 프로그래머가 유지 관리 가능하고 논리적으로 일관된 방식으로 코드를 구성하는 더 나은 방법을 제공합니다."

https://developer.ibm.com/articles/l-recurs/

링크 3 : 재귀가 루핑보다 훨씬 빠릅니까? (대답)

다음은 귀하와 유사한 stackoverflow 질문에 대한 답변 링크입니다. 저자는 되풀이 또는 반복과 관련된 많은 벤치 마크가 언어 마다 매우 다르다고 지적합니다 . 명령형 언어는 일반적으로 루프를 사용하여 더 빠르며 재귀를 사용하면 느리게 기능하고 그 반대도 마찬가지입니다. 이 링크에서 취해야 할 요점은 언어에 구애받지 않고 상황 맹목적으로 질문에 대답하기가 매우 어렵다는 것입니다.

반복보다 반복이 더 빠릅니까?


4
정말 스크루 드라이버 비유 좋아
jh314


16

각 재귀 호출은 일반적으로 메모리 주소를 스택으로 푸시해야하므로 나중에 재귀가 메모리에서 비용이 많이 들기 때문에 나중에 프로그램이 해당 지점으로 돌아갈 수 있습니다.

그럼에도 불구하고 재귀가 루프보다 훨씬 자연스럽고 읽기 쉬운 경우가 많이 있습니다. 이 경우 재귀를 고수하는 것이 좋습니다.


5
물론 컴파일러는 스칼라와 같은 테일 호출을 최적화하지 않습니다.
Ben Hardy

11

일반적으로 성능 저하가 다른 방향으로 나타날 것으로 예상합니다. 재귀 호출은 추가 스택 프레임을 구성 할 수 있습니다. 이에 대한 형벌은 다양합니다. 또한 Python과 같은 일부 언어 (보다 정확하게는 일부 언어의 일부 구현에서는 ...)에서 트리 데이터 구조에서 최대 값 찾기와 같이 재귀 적으로 지정할 수있는 작업에 대해 스택 제한을 쉽게 실행할 수 있습니다. 이 경우 루프를 고수하려고합니다.

좋은 재귀 함수를 작성하면 테일 재귀 등을 최적화하는 컴파일러가 있다고 가정 할 때 성능 저하가 다소 줄어들 수 있습니다. 또한 함수가 실제로 테일 재귀인지 확인하기 위해 두 번 확인하십시오. 많은 사람들이 실수하는 것 중 하나입니다 의 위에.)

"가장자리"사례 (고성능 컴퓨팅, 매우 큰 재귀 깊이 등) 외에도 의도를 가장 명확하게 표현하고 설계가 잘되어 있으며 유지 관리가 가능한 접근 방식을 채택하는 것이 좋습니다. 요구를 파악한 후에 만 ​​최적화하십시오.


8

재귀는 여러 개의 작은 조각 으로 나눌 수있는 문제에 대해 반복보다 낫습니다 .

예를 들어 재귀 피보나치 알고리즘을 만들려면 fib (n)을 fib (n-1) 및 fib (n-2)로 나누고 두 부분을 모두 계산합니다. 반복을 통해 단일 기능을 반복해서 반복 할 수 있습니다.

그러나 피보나치는 실제로 깨진 예이며 반복이 실제로 더 효율적이라고 생각합니다. fib (n) = fib (n-1) + fib (n-2) 및 fib (n-1) = fib (n-2) + fib (n-3)에 유의하십시오. fib (n-1)이 두 번 계산됩니다!

더 좋은 예는 트리의 재귀 알고리즘입니다. 부모 노드를 분석하는 문제는 각 자식 노드를 분석하는 여러 가지 작은 문제 로 나눌 수 있습니다 . 피보나치 예제와 달리 작은 문제는 서로 독립적입니다.

예, 재귀는 여러 개의 작고 독립적이며 유사한 문제로 나눌 수있는 문제에 대해 반복보다 낫습니다.


1
메모를 통해 실제로 계산을 두 번 피할 수 있습니다.
Siddhartha

7

어떤 언어로든 메소드를 호출하면 많은 준비가 필요하기 때문에 재귀를 사용할 때 성능이 저하됩니다. 호출 코드는 리턴 주소, 호출 매개 변수, 프로세서 레지스터와 같은 기타 컨텍스트 정보가 어딘가에 저장 될 수 있으며 리턴 시간에 호출 된 메소드는 리턴 값을 게시 한 다음 호출자가 검색하며 이전에 저장된 컨텍스트 정보가 복원됩니다. 반복적 접근과 재귀 적 접근 사이의 성능 차이는 이러한 작업에 걸리는 시간에 있습니다.

구현 관점에서, 호출 컨텍스트를 처리하는 데 걸리는 시간이 메소드를 실행하는 데 걸리는 시간과 비교할 때 차이를 인식하기 시작합니다. 재귀 적 메서드가 호출 컨텍스트 관리 부분을 실행하는 데 시간이 오래 걸리면 코드를 일반적으로 더 읽기 쉽고 이해하기 쉽고 재귀 적 방식으로 수행하므로 성능 손실을 알 수 없습니다. 그렇지 않으면 효율성상의 이유로 반복적으로 진행하십시오.


항상 그런 것은 아닙니다. 꼬리 호출 최적화를 수행 할 수있는 경우에 대해 재귀는 반복만큼 효율적일 수 있습니다. stackoverflow.com/questions/310974/…
Sid Kshatriya

6

Java의 꼬리 재귀가 현재 최적화되어 있지 않다고 생각합니다. 자세한 내용은 LtU 및 관련 링크에 대한 토론 전체에 뿌려 집니다. 그것은 다가오는 버전 7의 기능,하지만 분명히 특정 프레임이 누락 될 수 있기 때문에 스택 검사와 함께 특정 어려움을 선물한다. 스택 검사는 Java 2부터 세밀한 보안 모델을 구현하는 데 사용되었습니다.

http://lambda-the-ultimate.org/node/1333


꼬리 재귀를 최적화하는 Java 용 JVM이 있습니다. ibm.com/developerworks/java/library/j-diag8.html
Liran Orevi

5

이진 트리를 순회하는 일반적인 예인 반복 방법에 비해 훨씬 더 우아한 솔루션을 제공하는 경우가 많으므로 유지 관리가 더 어려울 필요는 없습니다. 일반적으로 반복 버전은 일반적으로 조금 더 빠르며 (최적화 중에 재귀 버전을 대체 할 수 있음) 재귀 버전은 이해하고 올바르게 구현하기가 더 간단합니다.


5

재귀는 일부 상황에서 매우 유용합니다. 예를 들어 계승을 구하는 코드를 고려하십시오.

int factorial ( int input )
{
  int x, fact = 1;
  for ( x = input; x > 1; x--)
     fact *= x;
  return fact;
}

이제 재귀 함수를 사용하여 고려하십시오.

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

이 두 가지를 관찰하면 재귀를 이해하기 쉽다는 것을 알 수 있습니다. 그러나주의해서 사용하지 않으면 오류가 발생하기 쉽습니다. 우리가 놓치면 if (input == 0)코드가 얼마 동안 실행되고 일반적으로 스택 오버플로로 끝납니다.


6
실제로 반복 버전을 이해하기가 더 쉽습니다. 나는 각자 자신에게 생각합니다.
Maxpm

@Maxpm, 고차 재귀 솔루션은 훨씬 낫 foldl (*) 1 [1..n]습니다.
SK-logic

5

대부분의 경우 캐싱으로 인해 재귀가 빨라져 성능이 향상됩니다. 예를 들어, 다음은 전통적인 병합 루틴을 사용하는 반복 정렬 버전입니다. 캐싱 성능 향상으로 인해 재귀 구현보다 느리게 실행됩니다.

반복 구현

public static void sort(Comparable[] a)
{
    int N = a.length;
    aux = new Comparable[N];
    for (int sz = 1; sz < N; sz = sz+sz)
        for (int lo = 0; lo < N-sz; lo += sz+sz)
            merge(a, lo, lo+sz-1, Math.min(lo+sz+sz-1, N-1));
}

재귀 적 구현

private static void sort(Comparable[] a, Comparable[] aux, int lo, int hi)
{
    if (hi <= lo) return;
    int mid = lo + (hi - lo) / 2;
    sort(a, aux, lo, mid);
    sort(a, aux, mid+1, hi);
    merge(a, aux, lo, mid, hi);
}

추신-이것은 코스 라에 제시된 알고리즘에 대한 과정에서 케빈 웨인 교수 (프린스턴 대학교)가 말한 것입니다.


4

재귀를 사용하면 각 "반복"마다 함수 호출 비용이 발생하지만 루프에서는 일반적으로 지불하는 것이 증가 / 감소뿐입니다. 따라서 루프 코드가 재귀 솔루션 코드보다 훨씬 복잡하지 않은 경우 일반적으로 루프가 재귀보다 우수합니다.


1
실제로 컴파일 된 스칼라 테일 재귀 함수는 바이트 코드를 살펴보면 (권장) 루프로 귀결됩니다. 함수 호출 오버 헤드가 없습니다. 둘째, 꼬리 재귀 함수는 가변 변수 / 부작용 또는 명시 적 루프가 필요하지 않으므로 정확성을 훨씬 쉽게 증명할 수 있다는 이점이 있습니다.
Ben Hardy

4

재귀와 반복은 구현하려는 비즈니스 로직에 따라 다르지만 대부분의 경우 상호 교환하여 사용할 수 있습니다. 대부분의 개발자는 이해하기 쉽기 때문에 재귀를 찾습니다.


4

언어에 따라 다릅니다. Java에서는 루프를 사용해야합니다. 기능적 언어는 재귀를 최적화합니다.


3

목록을 반복하는 중이라면 반복하십시오.

다른 몇 가지 대답은 (심도 우선) 나무 통과를 언급했습니다. 매우 일반적인 데이터 구조에 대해 수행하는 것이 매우 일반적이기 때문에 정말 좋은 예입니다. 재귀는이 문제에 대해 매우 직관적입니다.

여기서 "찾기"방법을 확인하십시오. http://penguin.ewu.edu/cscd300/Topic/BSTintro/index.html


3

재귀는 반복의 가능한 정의보다 더 단순하고 따라서 더 근본적입니다. 콤비 네이터 만으로 Turing-complete 시스템을 정의 할 수 있습니다 (예, 재귀 자체도 그러한 시스템의 파생 개념 임). Lambda 미적분학은 재귀 함수를 특징으로하는 강력한 기본 시스템입니다. 그러나 반복을 올바르게 정의하려면 시작하기 위해 훨씬 더 많은 프리미티브가 필요합니다.

코드에 관해서는, 대부분의 데이터 구조는 재귀 적이므로 재귀 코드는 순전히 반복적 인 코드보다 이해하고 유지하기가 훨씬 쉽습니다. 물론, 그것을 올바르게 얻으려면 최소한 모든 표준 결합기와 반복자를 깔끔하게 얻으려면 고차 함수와 클로저를 지원하는 언어가 필요합니다. 물론 C ++에서는 FC ++ 의 하드 코어 사용자가 아니라면 복잡한 재귀 솔루션이 약간 추한 것처럼 보일 수 있습니다 .


재귀 코드는 특히 매개 변수의 순서가 변경되거나 각 재귀에 따라 유형이 변경되는 경우 따르기가 매우 어려울 수 있습니다. 반복 코드는 매우 간단하고 설명적일 수 있습니다. 중요한 것은 반복적이든 재귀 적이든 먼저 가독성 (따라서 신뢰성)을 코딩 한 다음 필요한 경우 최적화하는 것입니다.
마커스 클레멘트

2

나는 (비 꼬리) 재귀에서 함수가 호출 될 때마다 (물론 언어에 따라) 새로운 스택을 할당하면 성능이 저하 될 것이라고 생각합니다.


2

"재귀 깊이"에 따라 다릅니다. 함수 호출 오버 헤드가 총 실행 시간에 얼마나 영향을 미치는지에 달려 있습니다.

예를 들어, 재귀 방식으로 클래식 계승을 계산하는 것은 다음과 같은 이유로 매우 비효율적입니다.-데이터 오버플로 위험-스택 오버플로 위험-함수 호출 오버 헤드가 실행 시간의 80 %를 차지함

후속 N 이동을 분석 할 체스 게임에서 위치 분석을위한 최소-최대 알고리즘을 개발하는 동안 "분석 깊이"에 대한 재귀로 구현할 수 있습니다 (^_^)


여기에서 ugasoft에 완전히 동의합니다 ... 그것은 재귀 깊이에 달려 있습니다 .... 반복 구현의 복잡성 ... 둘을 비교하고 어느 것이 더 효율적인지 확인해야합니다 ... 엄지 손가락 규칙이 없습니다. ..
rajya vardhan

2

재귀? Wiki는 어디서부터 시작합니까?“자기 비슷한 방식으로 항목을 반복하는 과정입니다”

내가 C를하고 있었을 때, C ++ 재귀는 "Tail recursion"과 같은 신이 보낸 것이 었습니다. 또한 많은 정렬 알고리즘에서 재귀를 사용합니다. 빠른 정렬 예 : http://alienryderflex.com/quicksort/

재귀는 특정 문제에 유용한 다른 알고리즘과 같습니다. 아마도 당신은 곧바로 또는 자주 사용하지 않을 수도 있지만 사용 가능한 것이 기쁠 것입니다.


컴파일러 최적화가 거꾸로 된 것 같습니다. 컴파일러는 스택 성장을 피하기 위해 재귀 함수를 반복 루프로 최적화합니다.
CoderDennis

공정한 포인트, 그것은 뒤로했다. 그러나 꼬리 재귀에 여전히 적용되는지 확실하지 않습니다.
Nickz

2

C ++에서 재귀 함수가 템플릿 함수 인 경우 모든 유형 공제 및 함수 인스턴스화가 컴파일 시간에 발생하므로 컴파일러는이를 최적화 할 수 있습니다. 최신 컴파일러는 가능한 경우 함수를 인라인 할 수도 있습니다. 따라서 -O3또는 -O2in 과 같은 최적화 플래그를 사용하면 g++재귀가 반복보다 빠를 수 있습니다. 반복 코드에서는 컴파일러가 이미 최적 상태 (충분히 작성된 경우)에 있으므로 최적화 할 기회가 줄어 듭니다.

필자의 경우, Armadillo 행렬 객체를 사용하여 재귀 및 반복 방식으로 행렬 지수를 구현하려고했습니다. 알고리즘은 https://en.wikipedia.org/wiki/Exponentiation_by_squaring 에서 찾을 수 있습니다 . 내 함수는 템플릿이었고 나는 1,000,000 12x12거듭 제곱 행렬 을 계산했습니다 10. 나는 다음과 같은 결과를 얻었다 :

iterative + optimisation flag -O3 -> 2.79.. sec
recursive + optimisation flag -O3 -> 1.32.. sec

iterative + No-optimisation flag  -> 2.83.. sec
recursive + No-optimisation flag  -> 4.15.. sec

이 결과는 c ++ 11 플래그 ( -std=c++11)가있는 gcc-4.8 및 Intel mkl이있는 Armadillo 6.1을 사용하여 얻었습니다. 인텔 컴파일러도 비슷한 결과를 보여줍니다.


1

마이크가 맞습니다. 테일 재귀는 Java 컴파일러 또는 JVM에 의해 최적화 되지 않습니다 . 항상 다음과 같은 스택 오버플로가 발생합니다.

int count(int i) {
  return i >= 100000000 ? i : count(i+1);
}

3
Scala로 쓰지 않으면 ;-)
Ben Hardy

1

너무 깊은 재귀를 사용하면 허용되는 스택 크기에 따라 스택 오버플로가 발생한다는 점을 명심해야합니다. 이를 방지하려면 재귀를 끝내는 기본 사례를 제공하십시오.


1

재귀는 재귀를 사용하여 작성하는 알고리즘에 O (n) 공간 복잡성이 있다는 단점이 있습니다. 반복적 인 aproach는 O (1)의 공간 복잡성을 갖지만, 이것은 재귀에 대한 반복을 사용하는 이점입니다. 그렇다면 왜 재귀를 사용합니까?

아래를 참조하십시오.

반복을 사용하여 알고리즘을 작성하는 것이 더 쉬운 반면 반복을 사용하여 동일한 알고리즘을 작성하는 것이 약간 더 어려운 경우가 있습니다.


1

반복을 원자이며, 새로운 스택 프레임을 밀어 것보다 수십 배 더 비싼 경우 새로운 쓰레드를 생성 하고 여러 개의 코어를 가지고 런타임 환경이 그들 모두를 사용할 수 있습니다과 결합 될 때, 다음 재귀 방법은 큰 성능 향상을 얻을 수있다 멀티 스레딩. 평균 반복 횟수를 예측할 수없는 경우 스레드 할당을 제어하고 프로세스가 너무 많은 스레드를 작성하고 시스템을 호깅하지 못하게하는 스레드 풀을 사용하는 것이 좋습니다.

예를 들어, 일부 언어에는 재귀 멀티 스레드 병합 정렬 구현이 있습니다.

그러나 다시 멀티 스레딩을 재귀가 아닌 루핑과 함께 사용할 수 있으므로이 조합의 작동 방식은 OS 및 스레드 할당 메커니즘을 비롯한 더 많은 요소에 따라 다릅니다.


0

내가 아는 한 Perl은 꼬리 재귀 호출을 최적화하지는 않지만 가짜로 만들 수 있습니다.

sub f{
  my($l,$r) = @_;

  if( $l >= $r ){
    return $l;
  } else {

    # return f( $l+1, $r );

    @_ = ( $l+1, $r );
    goto &f;

  }
}

처음 호출되면 스택에 공간을 할당합니다. 그런 다음 인수를 변경하고 스택에 더 이상 아무것도 추가하지 않고 서브 루틴을 다시 시작합니다. 그러므로 그것은 결코 자기 자신을 부르지 않은 것처럼 가장하여 반복적 인 과정으로 바꾼다.

" my @_;"또는 " local @_;"가 없으면 더 이상 작동하지 않습니다.


0

Chrome 45.0.2454.85m 만 사용하면 재귀가 훨씬 빠릅니다.

코드는 다음과 같습니다.

(function recursionVsForLoop(global) {
    "use strict";

    // Perf test
    function perfTest() {}

    perfTest.prototype.do = function(ns, fn) {
        console.time(ns);
        fn();
        console.timeEnd(ns);
    };

    // Recursion method
    (function recur() {
        var count = 0;
        global.recurFn = function recurFn(fn, cycles) {
            fn();
            count = count + 1;
            if (count !== cycles) recurFn(fn, cycles);
        };
    })();

    // Looped method
    function loopFn(fn, cycles) {
        for (var i = 0; i < cycles; i++) {
            fn();
        }
    }

    // Tests
    var curTest = new perfTest(),
        testsToRun = 100;

    curTest.do('recursion', function() {
        recurFn(function() {
            console.log('a recur run.');
        }, testsToRun);
    });

    curTest.do('loop', function() {
        loopFn(function() {
            console.log('a loop run.');
        }, testsToRun);
    });

})(window);

결과

// 표준 for 루프를 사용하여 100 회 실행

루프 실행을위한 100x. 완료 시간 : 7.683ms

// 꼬리 재귀와 함께 기능 재귀 접근 방식을 사용하여 100 회 실행

100x 재귀 실행. 완료 시간 : 4.841ms

아래 스크린 샷에서 테스트 당 300 사이클로 실행하면 재귀가 더 큰 마진으로 다시 승리합니다.

재귀가 다시 이깁니다!


루프 함수 내부에서 함수를 호출하기 때문에 테스트가 유효하지 않습니다. 이는 루프가 가장 눈에 띄는 성능 이점 중 하나이며, 이는 명령 점프가 부족합니다 (함수 호출, 스택 할당, 스택 팝핑 등 포함). 루프 내에서 작업을 수행하는 경우 (방금 함수라고 함) 재귀 함수 내에서 작업을 수행하면 다른 결과를 얻을 수 있습니다. (PS 성능은 실제 작업 알고리즘의 문제이며, 때로는 명령 점프가이를 피하기 위해 필요한 계산보다 저렴합니다).
Myst

0

나는 그 접근법들 사이에 또 ​​다른 차이점을 발견했습니다. 간단하고 중요하지 않은 것처럼 보이지만 인터뷰 준비를하는 동안 매우 중요한 역할을하며이 주제가 발생하므로 자세히 살펴보십시오.

간단히 말하면 : 1) 반복적 인 주문 후 순회가 쉽지 않습니다.-DFT를 더 복잡하게 만듭니다. 2) 재귀로 사이클을 쉽게 확인합니다.

세부:

재귀 적 인 경우 사전 및 사후 순회를 쉽게 만들 수 있습니다.

"작업이 다른 작업에 의존 할 때 작업 5를 실행하기 위해 실행해야하는 모든 작업을 인쇄합니다"라는 매우 표준적인 질문을 상상해보십시오.

예:

    //key-task, value-list of tasks the key task depends on
    //"adjacency map":
    Map<Integer, List<Integer>> tasksMap = new HashMap<>();
    tasksMap.put(0, new ArrayList<>());
    tasksMap.put(1, new ArrayList<>());

    List<Integer> t2 = new ArrayList<>();
    t2.add(0);
    t2.add(1);
    tasksMap.put(2, t2);

    List<Integer> t3 = new ArrayList<>();
    t3.add(2);
    t3.add(10);
    tasksMap.put(3, t3);

    List<Integer> t4 = new ArrayList<>();
    t4.add(3);
    tasksMap.put(4, t4);

    List<Integer> t5 = new ArrayList<>();
    t5.add(3);
    tasksMap.put(5, t5);

    tasksMap.put(6, new ArrayList<>());
    tasksMap.put(7, new ArrayList<>());

    List<Integer> t8 = new ArrayList<>();
    t8.add(5);
    tasksMap.put(8, t8);

    List<Integer> t9 = new ArrayList<>();
    t9.add(4);
    tasksMap.put(9, t9);

    tasksMap.put(10, new ArrayList<>());

    //task to analyze:
    int task = 5;


    List<Integer> res11 = getTasksInOrderDftReqPostOrder(tasksMap, task);
    System.out.println(res11);**//note, no reverse required**

    List<Integer> res12 = getTasksInOrderDftReqPreOrder(tasksMap, task);
    Collections.reverse(res12);//note reverse!
    System.out.println(res12);

    private static List<Integer> getTasksInOrderDftReqPreOrder(Map<Integer, List<Integer>> tasksMap, int task) {
         List<Integer> result = new ArrayList<>();
         Set<Integer> visited = new HashSet<>();
         reqPreOrder(tasksMap,task,result, visited);
         return result;
    }

private static void reqPreOrder(Map<Integer, List<Integer>> tasksMap, int task, List<Integer> result, Set<Integer> visited) {

    if(!visited.contains(task)) {
        visited.add(task);
        result.add(task);//pre order!
        List<Integer> children = tasksMap.get(task);
        if (children != null && children.size() > 0) {
            for (Integer child : children) {
                reqPreOrder(tasksMap,child,result, visited);
            }
        }
    }
}

private static List<Integer> getTasksInOrderDftReqPostOrder(Map<Integer, List<Integer>> tasksMap, int task) {
    List<Integer> result = new ArrayList<>();
    Set<Integer> visited = new HashSet<>();
    reqPostOrder(tasksMap,task,result, visited);
    return result;
}

private static void reqPostOrder(Map<Integer, List<Integer>> tasksMap, int task, List<Integer> result, Set<Integer> visited) {
    if(!visited.contains(task)) {
        visited.add(task);
        List<Integer> children = tasksMap.get(task);
        if (children != null && children.size() > 0) {
            for (Integer child : children) {
                reqPostOrder(tasksMap,child,result, visited);
            }
        }
        result.add(task);//post order!
    }
}

재귀적인 주문 후 순회에는 결과의 후속 역 분개가 필요하지 않습니다. 자녀가 먼저 인쇄하고 문제의 과제가 마지막에 인쇄됩니다. 다 괜찮아 재귀 사전 주문 순회 (위에 표시됨)를 수행 할 수 있으며 결과 목록을 취소해야합니다.

반복적 인 접근 방식으로는 그렇게 간단하지 않습니다! 반복적 인 (하나의 스택) 접근 방식에서는 사전 주문 순회 만 할 수 있으므로 마지막에 결과 배열을 되돌려 야합니다.

    List<Integer> res1 = getTasksInOrderDftStack(tasksMap, task);
    Collections.reverse(res1);//note reverse!
    System.out.println(res1);

    private static List<Integer> getTasksInOrderDftStack(Map<Integer, List<Integer>> tasksMap, int task) {
    List<Integer> result = new ArrayList<>();
    Set<Integer> visited = new HashSet<>();
    Stack<Integer> st = new Stack<>();


    st.add(task);
    visited.add(task);

    while(!st.isEmpty()){
        Integer node = st.pop();
        List<Integer> children = tasksMap.get(node);
        result.add(node);
        if(children!=null && children.size() > 0){
            for(Integer child:children){
                if(!visited.contains(child)){
                    st.add(child);
                    visited.add(child);
                }
            }
        }
        //If you put it here - it does not matter - it is anyway a pre-order
        //result.add(node);
    }
    return result;
}

간단 해 보이죠?

그러나 일부 인터뷰에서는 함정입니다.

재귀 접근 방식을 사용하면 Depth First Traversal을 구현 한 다음 사전 또는 사후 필요한 순서를 선택할 수 있습니다 ( "결과 목록에 추가"의 경우 "인쇄"의 위치를 ​​변경하여 간단히). ). 반복 (한 번의 스택) 접근 방식을 사용하면 사전 주문 순회 만 쉽게 수행 할 수 있으므로 어린이를 먼저 인쇄 해야하는 상황 (하단 노드에서 인쇄를 시작 해야하는 모든 상황) 문제. 그 문제가 있으면 나중에 되돌릴 수 있지만 알고리즘에 추가됩니다. 면접관이 자신의 시계를보고 있다면 문제가 될 수 있습니다. 반복적 인 주문 후 순회를 수행하는 복잡한 방법이 있지만 존재하지만 단순하지는 않습니다 . 예:https://www.geeksforgeeks.org/iterative-postorder-traversal-using-stack/

결론 : 인터뷰 중에 재귀를 사용하고 관리하고 설명하는 것이 더 간단합니다. 긴급한 상황에서 사전 주문 후 주문 순회로 쉽게 이동할 수 있습니다. 반복적으로 당신은 그렇게 유연하지 않습니다.

재귀를 사용하고 다음과 같이 말합니다. "좋아요. 그러나 반복적으로 사용 된 메모리를보다 직접적으로 제어 할 수 있습니다. 스택 크기를 쉽게 측정하고 위험한 오버플로를 허용하지 않습니다."

재귀의 또 다른 장점-그래프에서 사이클을 피 / 통지하는 것이 더 간단합니다.

예 (사전 코드) :

dft(n){
    mark(n)
    for(child: n.children){
        if(marked(child)) 
            explode - cycle found!!!
        dft(child)
    }
    unmark(n)
}

0

재귀 또는 연습으로 작성하는 것이 재미있을 수 있습니다.

그러나 프로덕션에서 코드를 사용하려면 스택 오버플로 가능성을 고려해야합니다.

테일 재귀 최적화는 스택 오버플로를 제거 할 수 있지만 그렇게 만드는 데 어려움을 겪고 싶고 환경에서 최적화를 수행 할 수 있다는 것을 알아야합니다.

알고리즘이 되풀이 될 때마다 데이터 크기 또는 n 됩니까?

데이터 크기를 줄이거 나 n되풀이 할 때마다 절반 씩 줄이면 일반적으로 스택 오버플로에 대해 걱정할 필요가 없습니다. 말, 그것은 깊은 4,000 수준이나 스택 오버 플로우 프로그램에 대한 깊은 10,000 수준, 약이 될 다음 데이터 크기 요구해야 할 경우 4000 프로그램에 대한 스택 오버 플로우. 이를 고려할 때 가장 큰 저장 장치는 최근에 2 61 바이트를 보유 할 수 있으며 , 이러한 장치 가 2 61 개이면 2122 개의 데이터 크기 만 처리 합니다. 우주의 모든 원자를보고 있다면, 그것이 2보다 작을 것으로 추정됩니다 (84). 140 억 년 전에 우주가 탄생 한 이후 1 밀리 초마다 우주의 모든 데이터와 해당 상태를 처리해야하는 경우 2,153 만 될 수 있습니다 . 따라서 프로그램이 2 4000 단위의 데이터 또는를 처리 할 수 ​​있으면 n유니버스의 모든 데이터를 처리 할 수 ​​있으며 프로그램에서 오버플로가 발생하지 않습니다. 2 4000 (4000 비트 정수) 만큼 큰 숫자를 처리 할 필요가없는 경우 일반적으로 스택 오버플로에 대해 걱정할 필요가 없습니다.

그러나 데이터 크기를 줄이거 나 n되풀이 할 때마다 일정한 양 을 줄이면 프로그램 n1000제대로 실행되는 경우 가 있지만 어떤 상황에서는 n단순히 일 때 스택 오버플로가 발생할 수 있습니다 20000.

따라서 스택 오버플로가 발생할 가능성이 있으면 반복적 인 솔루션으로 만들어보십시오.


-1

저는 재귀에 일종의 "이중"인 "유도"에 의해 Haskell 데이터 구조를 설계함으로써 귀하의 질문에 대답 할 것입니다. 그런 다음이 이원성이 어떻게 멋진 일을하는지 보여줄 것입니다.

간단한 트리를위한 타입을 소개합니다 :

data Tree a = Branch (Tree a) (Tree a)
            | Leaf a
            deriving (Eq)

우리는이 정의를 "나무는 가지 (두 개의 나무를 포함) 또는 잎 (데이터 값을 포함)"라고 말합니다. 잎은 최소한의 경우입니다. 나무가 잎이 아닌 경우 두 개의 나무가 포함 된 복합 트리 여야합니다. 이것 만이 유일한 경우입니다.

나무를 만들어 봅시다 :

example :: Tree Int
example = Branch (Leaf 1) 
                 (Branch (Leaf 2) 
                         (Leaf 3))

이제 트리의 각 값에 1을 더한다고 가정 해 봅시다. 다음을 호출하여이 작업을 수행 할 수 있습니다.

addOne :: Tree Int -> Tree Int
addOne (Branch a b) = Branch (addOne a) (addOne b)
addOne (Leaf a)     = Leaf (a + 1)

첫째, 이것이 사실 재귀 적 정의임을 주목하십시오. 데이터 생성자 Branch 및 Leaf를 사례로 사용합니다. Leaf가 최소이고 가능한 유일한 사례이므로 함수가 종료 될 것입니다.

addOne을 반복적 인 스타일로 작성하려면 무엇이 필요합니까? 임의의 수의 분기로 반복되는 모양은 무엇입니까?

또한 이런 종류의 재귀는 종종 "펑터 (functor)"라는 관점에서 제외 될 수 있습니다. 다음을 정의하여 나무를 Functors로 만들 수 있습니다.

instance Functor Tree where fmap f (Leaf a)     = Leaf (f a)
                            fmap f (Branch a b) = Branch (fmap f a) (fmap f b)

그리고 정의 :

addOne' = fmap (+1)

대수 데이터 형식에 대한 이변 형 (또는 접기)과 같은 다른 재귀 체계를 제외 할 수 있습니다. 이형 법을 사용하여 다음과 같이 작성할 수 있습니다.

addOne'' = cata go where
           go (Leaf a) = Leaf (a + 1)
           go (Branch a b) = Branch a b

-2

스택 오버플로는 내장 메모리 관리 기능이없는 언어로 프로그래밍하는 경우에만 발생합니다. 그렇지 않으면 함수 (또는 함수 호출, STDLbs 등)에 무언가가 있는지 확인하십시오. 재귀가 없으면 단순히 구글이나 SQL과 같은 것을 가질 수 없으며 또는 대규모 데이터 구조 (클래스) 또는 데이터베이스를 효율적으로 정렬 해야하는 곳은 없습니다.

재귀는 파일을 반복하고 싶을 때가는 방법입니다. 'find * | ? grep * '작동합니다. Kinda 이중 재귀, 특히 파이프를 사용하십시오 (그러나 다른 사람들이 사용할 수있는 것이면 많은 사람들처럼 많은 syscall을 수행하지 마십시오).

고급 언어 및 clang / cpp조차 백그라운드에서 동일하게 구현할 수 있습니다.


1
"스택 오버플로는 내장 메모리 관리 기능이없는 언어로 프로그래밍하는 경우에만 발생합니다." 대부분의 언어는 제한된 크기의 스택을 사용하므로 재귀는 곧 실패로 이어질 것입니다.
StaceyGirl
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.