มีวิธีใดบ้างที่จะหลีกเลี่ยงการโอเวอร์โฟลว์สแต็กในอัลกอริทึมแบบเรียกซ้ำได้


44

คำถาม

มีวิธีใดที่เป็นไปได้ในการแก้ปัญหาการล้นสแต็กที่เกิดจากอัลกอริทึมแบบเรียกซ้ำ

ตัวอย่าง

ฉันกำลังพยายามแก้ปัญหาProject Euler 14และตัดสินใจลองใช้อัลกอริทึมแบบเรียกซ้ำ อย่างไรก็ตามโปรแกรมหยุดทำงานด้วย java.lang.StackOverflowError เข้าใจได้. อัลกอริทึมโอเวอร์โฟลว์สแต็คจริง ๆ เพราะฉันพยายามสร้างลำดับ Collatz สำหรับจำนวนมาก

โซลูชั่น

ดังนั้นฉันจึงสงสัยว่า: วิธีการมาตรฐานแบบใดที่จะแก้ปัญหาสแต็คโอเวอร์โฟลว์โดยสมมติว่าอัลกอริธึมวนซ้ำของคุณเขียนอย่างถูกต้องและจะจบลงด้วยการล้นสแต็กเสมอ แนวคิดสองประการที่อยู่ในใจคือ:

  1. หางเรียกซ้ำ
  2. การย้ำ

แนวคิด (1) และ (2) ถูกต้องหรือไม่ มีตัวเลือกอื่น ๆ อีกไหม?

แก้ไข

มันจะช่วยให้เห็นบางรหัสโดยเฉพาะอย่างยิ่งใน Java, C #, Groovy หรือ Scala

อาจจะไม่ใช้ปัญหา Project Euler ที่กล่าวมาข้างต้นดังนั้นมันจะไม่ทำให้ผู้อื่นเสีย แต่ใช้อัลกอริทึมอื่น ๆ แฟกทอเรียลอาจหรือสิ่งที่คล้ายกัน


3
การย้ำ Memoisation
James

2
เห็นได้ชัดว่า memoization ทำงานเฉพาะเมื่อมีจริงคือการคำนวณซ้ำ
Jörg W Mittag

2
ยังมีมูลค่า noting ว่าไม่ทุกการใช้งานภาษาสามารถทำเพิ่มประสิทธิภาพ recursion หางอยู่แล้ว
JK

2
นี่อาจจะถูกแก้ไขได้ดีกว่าด้วยการสอบสวนมากกว่าการเรียกซ้ำ
Jörg W Mittag

3
หากคุณทำงานจากจำนวนที่น้อยกว่า 1,000,000 และไปที่ 1 คำตอบสำหรับคำถามนี้เกี่ยวข้องกับขั้นตอนที่จะไปถึง 500 ขั้นตอนที่ 1 ถึง 500 ขั้นตอนนี้ไม่ควรทำการเรียกคืนภาษีเนื่องจากกรอบสแต็กขนาดเล็ก --- หากคุณพยายามที่จะแก้ปัญหาเริ่มต้นที่ 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 โทรหางเพราะของการตรวจสอบที่จำเป็นในการกลับมา ( ตัวอย่างซอร์สโค้ดและเอาต์พุตที่คอมไพล์แล้ว )

ในการทำให้การเรียก tail นี้สามารถปรับได้

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

( ตัวอย่างซอร์สโค้ดและเอาต์พุตที่คอมไพล์แล้ว )

หนึ่งสามารถดูในส่วน.L3ที่jneมากกว่าcall(ซึ่งไม่ได้โทร subroutine กับกองกรอบใหม่)

โปรดทราบว่าสิ่งนี้ทำกับ C. Tail call optimization ใน Java นั้นยากและขึ้นอยู่กับการใช้งาน JVM (ที่กล่าวว่าฉันไม่เคยเห็นมันทำเพราะมันยากและความหมายของโมเดลการรักษาความปลอดภัย Java ที่ต้องการเฟรมสแต็ก - ซึ่งเป็นสิ่งที่หลีกเลี่ยง TCO) - tail-recursion + javaและtail-recursion + optimizationเป็นชุดแท็กที่ดีในการเรียกดู คุณอาจพบว่าภาษา JVM อื่น ๆ สามารถปรับการเรียกซ้ำแบบหางให้ดีขึ้นได้ (ลอง clojure (ซึ่งต้องใช้การเพิ่มประสิทธิภาพการเรียกกลับหาง) หรือสกาล่า)

ที่กล่าวว่า

มีความสุขบางอย่างในการรู้ว่าคุณเขียนบางสิ่งถูกต้อง - ในวิธีอุดมคติที่สามารถทำได้
และตอนนี้ฉันจะได้รับสก็อตและวางอิเล็คทรอนิกส์เยอรมันบางส่วน ...


สำหรับคำถามทั่วไปของ "วิธีการเพื่อหลีกเลี่ยงการโอเวอร์โฟลว์สแต็กในอัลกอริทึมแบบเรียกซ้ำ" ...

อีกวิธีคือการรวมตัวนับการเรียกซ้ำ นี่คือการตรวจจับลูปไม่สิ้นสุดที่เกิดจากสถานการณ์ที่อยู่นอกเหนือการควบคุมของคน (และการเข้ารหัสที่ไม่ดี)

ตัวนับการเรียกซ้ำใช้รูปแบบของ

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

ทุกครั้งที่คุณโทรคุณจะเพิ่มเคาน์เตอร์ หากตัวนับใหญ่เกินไปคุณผิดพลาด (ที่นี่เพียงคืน -1 แต่ในภาษาอื่นที่คุณอาจต้องการยกเว้น) ความคิดคือการป้องกันไม่ให้สิ่งเลวร้ายเกิดขึ้น (จากข้อผิดพลาดของหน่วยความจำ) เมื่อทำการเรียกซ้ำที่ลึกกว่าที่คาดไว้มาก

ในทางทฤษฎีคุณไม่ควรต้องการสิ่งนี้ ในทางปฏิบัติฉันได้เห็นโค้ดที่เขียนไม่ดีซึ่งมีผลกระทบนี้เนื่องจากมีข้อผิดพลาดเล็ก ๆ น้อย ๆ และการเขียนโค้ดที่ไม่ดี (ปัญหาการเกิดพร้อมกันหลายเธรดแบบหลายเธรดที่มีบางสิ่งเปลี่ยนแปลงบางอย่าง


ใช้อัลกอริทึมที่เหมาะสมและแก้ปัญหาที่ถูกต้อง โดยเฉพาะสำหรับ Collatz Conjecture ดูเหมือนว่าคุณกำลังพยายามแก้ไขมันด้วยวิธีxkcd :

XKCD # 710

คุณกำลังเริ่มต้นที่ตัวเลขและทำการสำรวจเส้นทางต้นไม้ สิ่งนี้นำไปสู่พื้นที่การค้นหาที่มีขนาดใหญ่มากอย่างรวดเร็ว การวิ่งอย่างรวดเร็วเพื่อคำนวณจำนวนการวนซ้ำสำหรับผลลัพธ์คำตอบที่ถูกต้องในขั้นตอนประมาณ 500 ขั้น นี่ไม่ควรเป็นปัญหาสำหรับการเรียกซ้ำด้วยกรอบสแต็กขนาดเล็ก

ในขณะที่รู้วิธีการแก้ปัญหา recursive ไม่ได้เป็นสิ่งที่ไม่ดีหนึ่งยังควรตระหนักว่าหลายครั้งการแก้ปัญหาซ้ำแล้วซ้ำอีกจะดีกว่า จำนวนของวิธีการของการแสวงหาแปลงขั้นตอนวิธีการเรียกซ้ำไปซ้ำหนึ่งสามารถมองเห็นในกองมากเกินที่ทางที่จะไปจากการเรียกซ้ำไปซ้ำ


1
ฉันเจอการ์ตูน xkcd วันนี้ขณะที่ท่องเว็บ :-) การ์ตูนของ Randall Munroe มีความสุข
Lernkurve

@ Lernkurve ฉันสังเกตเห็นการเพิ่มการแก้ไขรหัสหลังจากที่ฉันเริ่มเขียนสิ่งนี้ (และโพสต์) คุณต้องการตัวอย่างโค้ดอื่น ๆ สำหรับสิ่งนี้หรือไม่?

ไม่เลย. มันสมบูรณ์แบบ. ขอบคุณมัดสำหรับถาม!
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

ฉันประหลาดใจที่ไม่มีใครได้กล่าวถึงtrampoliningเลย แทรมโพลีน (ในแง่นี้) คือลูปที่วนซ้ำฟังก์ชันที่กลับมาซ้ำ (รูปแบบการส่งผ่านต่อเนื่อง) และสามารถใช้ในการเรียกใช้ฟังก์ชั่น tail-recursive ในการเขียนโปรแกรมแบบกองซ้อน

คำถาม StackOverflow นี้มีรายละเอียดเพิ่มเติมเล็กน้อยเกี่ยวกับการใช้งานแบบต่างๆของ trampolining ใน Java: การจัดการ StackOverflow ใน Java สำหรับ Trampoline


ฉันคิดถึงสิ่งนี้ในทันทีเช่นกัน Trampolines เป็นวิธีการในการเพิ่มประสิทธิภาพการโทรหางดังนั้นผู้คนจึงพูดอย่างนั้น +1 สำหรับการอ้างอิงที่เฉพาะเจาะจง
Steven Evers

6

หากคุณใช้ภาษาและคอมไพเลอร์ที่รับรู้ถึงฟังก์ชั่นแบบเรียกซ้ำและจัดการกับมันอย่างถูกต้อง (เช่น "แทนที่ผู้โทรเข้าด้วย callee") ใช่แล้วสแตกไม่ควรเติบโตเกินการควบคุม การปรับให้เหมาะสมนี้จะลดวิธีการวนซ้ำเป็นวิธีการวนซ้ำ ฉันไม่คิดว่า 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++;
        }
    }
}

แม้ว่านี่จะไม่ใช่การบันทึกช่วยจำด้วยวิธีนี้คุณจะถือเป็นโมฆะล้นล้น


แก้ไข


ฉันขอโทษถ้าฉันทำให้คุณผิดหวัง ความตั้งใจเดียวของฉันคือการแสดงวิธีการหลีกเลี่ยงการล้นสแต็ค ฉันอาจจะได้เขียนตัวอย่างรหัสเต็มแทนที่จะเป็นเพียงส่วนเล็ก ๆ ของข้อความที่ตัดตอนมาอย่างรวดเร็วและหยาบ

รหัสต่อไปนี้

  • หลีกเลี่ยงการเรียกซ้ำขณะที่ฉันใช้คำนวณค่าที่ต้องการซ้ำ ๆ
  • รวมถึงการบันทึกความจำเนื่องจากค่าที่คำนวณไปแล้วจะถูกจัดเก็บและเรียกคืนหากคำนวณแล้ว
  • ยังมีนาฬิกาจับเวลาเพื่อให้คุณเห็นว่าการบันทึกช่วยจำทำงานได้อย่างถูกต้อง

... อืมม ... ถ้าคุณเรียกใช้ให้แน่ใจว่าคุณตั้งหน้าต่างเชลล์คำสั่งของคุณให้มีบัฟเฟอร์ 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");
       }
    }

ฉันประกาศ "อินสแตนซ์" ตัวแปรแบบคงที่ * 1 ในคลาสคณะไปยังร้านค้าหนึ่ง ๆ ด้วยวิธีนี้ตราบใดที่โปรแกรมของคุณกำลังทำงานอยู่เมื่อใดก็ตามที่คุณ "GetInstance ()" ของคลาสคุณได้รับอินสแตนซ์ที่เก็บค่าทั้งหมดที่คำนวณไว้แล้ว * 1 SortedList แบบคงที่ซึ่งจะเก็บค่าทั้งหมดที่คำนวณไว้แล้ว

ใน Constructor ฉันยังเพิ่ม 2 ค่าพิเศษของรายการ 1 สำหรับอินพุต 0 และ 1

    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
ฉันคิดว่าคุณเข้าใจผิด memoisation ซึ่งก็คือเมื่อคณะ (100) เรียกว่าครั้งแรกที่มันจะคำนวณผลและเก็บไว้ในกัญชาและกลับมาแล้วเมื่อมันถูกเรียกอีกครั้งผลการจัดเก็บจะถูกส่งกลับ
วงล้อประหลาด

@jk เครดิตของเขาเขาไม่เคยบอกว่านี่เป็นแบบเรียกซ้ำ
Neil

แม้ว่านี่จะไม่ใช่การบันทึกความจำด้วยวิธีนี้คุณจะโมฆะการล้นสแต็ก
Ingo

2

สำหรับ Scala คุณสามารถเพิ่ม@tailrecคำอธิบายประกอบลงในวิธีแบบเรียกซ้ำ วิธีที่คอมไพเลอร์ทำให้มั่นใจว่าการปรับการโทรหางเกิดขึ้นจริง:

ดังนั้นสิ่งนี้จะไม่รวบรวม (แฟคทอเรียล):

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

ข้อความแสดงข้อผิดพลาดคือ:

สกาล่า: ไม่สามารถเพิ่มประสิทธิภาพ @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

ความเป็นไปได้อย่างหนึ่งที่ยังไม่ได้กล่าวถึงคือการเรียกซ้ำ แต่ไม่มีการใช้สแต็กของระบบ แน่นอนคุณสามารถล้นกองของคุณได้เช่นกัน แต่ถ้าอัลกอริทึมของคุณต้องการ backtracking ในรูปแบบเดียวหรืออีกรูปแบบหนึ่ง (ทำไมต้องใช้การเรียกซ้ำด้วยวิธีอื่น) คุณไม่มีทางเลือก

มีการใช้งาน stackless ของบางภาษาเช่นมีStackless Python


0

อีกวิธีคือการจำลองสแต็กของคุณเองและไม่พึ่งพาการใช้งานคอมไพเลอร์ + รันไทม์ นี่ไม่ใช่วิธีแก้ปัญหาที่ง่ายหรือเร็ว แต่ในทางทฤษฎีคุณจะได้รับ StackOverflow เฉพาะเมื่อคุณมีหน่วยความจำไม่เพียงพอ

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.