Có những phương pháp nào để tránh tràn ngăn xếp trong thuật toán đệ quy?


44

Câu hỏi

Các cách có thể để giải quyết một tràn ngăn xếp gây ra bởi một thuật toán đệ quy là gì?

Thí dụ

Tôi đang cố gắng giải quyết vấn đề Project Euler 14 và quyết định thử nó với thuật toán đệ quy. Tuy nhiên, chương trình dừng lại với java.lang.StackOverflowError. Có thể hiểu được. Thuật toán thực sự đã tràn vào ngăn xếp vì tôi đã cố tạo ra một chuỗi Collatz cho một số lượng rất lớn.

Các giải pháp

Vì vậy, tôi đã tự hỏi: có những cách tiêu chuẩn nào để giải quyết một tràn ngăn xếp giả sử thuật toán đệ quy của bạn được viết đúng và sẽ luôn luôn tràn ra ngăn xếp? Hai khái niệm xuất hiện trong đầu là:

  1. đệ quy đuôi
  2. lặp đi lặp lại

Ý tưởng (1) và (2) có đúng không? Có những lựa chọn khác?

Biên tập

Nó sẽ giúp để xem một số mã, tốt nhất là trong Java, C #, Groovy hoặc Scala.

Có lẽ không sử dụng vấn đề Project Euler được đề cập ở trên để nó không bị hư hỏng cho người khác, nhưng sử dụng một số thuật toán khác. Yếu tố có thể, hoặc một cái gì đó tương tự.


3
Lặp lại. Hồi ức
James

2
Rõ ràng, Ghi nhớ chỉ hoạt động khi thực sự tính toán lặp lại.
Jörg W Mittag

2
cũng đáng lưu ý rằng không phải tất cả các triển khai ngôn ngữ đều có thể thực hiện tối ưu hóa đệ quy đuôi
jk.

2
Điều này có lẽ sẽ được giải quyết tốt hơn với corecursion hơn là đệ quy.
Jörg W Mittag

3
Nếu bạn đang làm việc từ số nhỏ hơn 1.000.000 và chuyển sang 1, câu trả lời cho câu hỏi này bao gồm khoảng 500 bước để đạt 1. Điều này không nên đệ quy thuế cho một khung ngăn xếp nhỏ. --- Nếu bạn đang cố gắng giải quyết bắt đầu từ 1, sau đó theo nó đến 2, 4, 8, 16, {5,32} và đi lên từ đó, bạn đang làm sai.

Câu trả lời:


35

Tối ưu hóa cuộc gọi đuôi có mặt trong nhiều ngôn ngữ và trình biên dịch. Trong tình huống này, trình biên dịch nhận ra một hàm có dạng:

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

Ở đây, ngôn ngữ có thể nhận ra rằng kết quả được trả về là kết quả từ một hàm khác và thay đổi một lệnh gọi hàm với khung ngăn xếp mới thành một bước nhảy.

Nhận ra rằng phương pháp giai thừa cổ điển:

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

không phải là cuộc gọi đuôi tối ưu vì kiểm tra cần thiết trên trở lại. ( Ví dụ mã nguồn và đầu ra được biên dịch )

Để thực hiện cuộc gọi đuôi này tối ưu hóa,

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);
}

Biên dịch mã này với gcc -O2 -S fact.c(-O2 là cần thiết để cho phép tối ưu hóa trong trình biên dịch, nhưng với tối ưu hóa nhiều hơn -O3, con người khó đọc được ...)

_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

( Ví dụ mã nguồn và đầu ra được biên dịch )

Người ta có thể thấy trong phân đoạn .L3, jnethay vì một call(gọi một chương trình con với khung ngăn xếp mới).

Xin lưu ý rằng điều này đã được thực hiện với C. Tối ưu hóa cuộc gọi đuôi trong Java rất khó và phụ thuộc vào việc triển khai JVM (điều đó nói rằng, tôi chưa thấy bất kỳ điều gì làm điều đó, bởi vì nó khó và hàm ý của mô hình bảo mật Java được yêu cầu yêu cầu khung ngăn xếp - đó là những gì TCO tránh) - đệ quy đuôi + javađệ quy đuôi + tối ưu hóa là các bộ thẻ tốt để duyệt. Bạn có thể thấy các ngôn ngữ JVM khác có thể tối ưu hóa đệ quy đuôi tốt hơn (thử clojure (yêu cầu recur để tối ưu hóa cuộc gọi đuôi) hoặc scala).

Mà nói,

Có một niềm vui nhất định khi biết rằng bạn đã viết một cái gì đó đúng - theo cách lý tưởng mà nó có thể được thực hiện.
Và bây giờ, tôi sẽ lấy một ít scotch và đưa vào một số điện tử Đức ...


Đối với câu hỏi chung về "các phương pháp để tránh tràn ngăn xếp trong thuật toán đệ quy" ...

Một cách tiếp cận khác là bao gồm một bộ đếm đệ quy. Đây là nhiều hơn để phát hiện các vòng lặp vô hạn gây ra bởi các tình huống ngoài tầm kiểm soát của một người (và mã hóa kém).

Bộ đếm đệ quy có dạng

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

Mỗi lần bạn thực hiện một cuộc gọi, bạn sẽ tăng bộ đếm. Nếu bộ đếm quá lớn, bạn sẽ báo lỗi (ở đây, chỉ là trả về -1, mặc dù trong các ngôn ngữ khác, bạn có thể muốn ném ngoại lệ). Ý tưởng là để ngăn chặn những điều tồi tệ hơn xảy ra (lỗi bộ nhớ) khi thực hiện đệ quy sâu hơn nhiều so với dự kiến ​​và có khả năng là một vòng lặp vô hạn.

Về lý thuyết, bạn không cần điều này. Trong thực tế, tôi đã thấy mã được viết kém đã gặp phải lỗi này do vô số lỗi nhỏ và thực tiễn mã hóa xấu (các vấn đề đồng thời đa luồng trong đó có gì đó thay đổi một cái gì đó bên ngoài phương thức khiến một luồng khác đi vào một vòng lặp vô hạn của các cuộc gọi đệ quy).


Sử dụng đúng thuật toán và giải quyết đúng vấn đề. Cụ thể cho Giả thuyết Collatz, có vẻ như bạn đang cố gắng giải quyết nó theo cách xkcd :

XKCD # 710

Bạn đang bắt đầu ở một số và thực hiện một giao dịch cây. Điều này nhanh chóng dẫn đến một không gian tìm kiếm rất lớn. Chạy nhanh để tính số lần lặp cho câu trả lời đúng cho kết quả trong khoảng 500 bước. Đây không phải là một vấn đề cho đệ quy với khung ngăn xếp nhỏ.

Mặc dù biết giải pháp đệ quy không phải là một điều xấu, người ta cũng nên nhận ra rằng nhiều lần giải pháp lặp lại là tốt hơn . Có thể thấy một số cách tiếp cận chuyển đổi thuật toán đệ quy sang thuật toán lặp trên Stack Overflow tại Way để đi từ đệ quy sang lặp .


1
Tôi đã bắt gặp phim hoạt hình xkcd hôm nay khi lướt web. :-) Phim hoạt hình của Randall Munroe là một niềm vui.
Lernkurve

@Lernkurve Tôi nhận thấy việc bổ sung chỉnh sửa mã sau khi tôi đã bắt đầu viết bài này (và đã đăng). Bạn có cần mẫu mã khác cho điều này?

Không hoàn toàn không. Thật hoàn hảo. Cảm ơn một bó đã hỏi!
Lernkurve

Tôi có thể đề nghị thêm phim hoạt hình này không: imss.xkcd.com/comics/feftal.png
Ellen Spertus 23/2/2015

@espertus cảm ơn bạn. Tôi đã thêm nó (dọn sạch một số thế hệ nguồn và thêm một chút nữa)

17

Hãy nhớ rằng việc thực hiện ngôn ngữ phải hỗ trợ tối ưu hóa đệ quy đuôi. Tôi không nghĩ rằng các trình biên dịch java chính làm.

Ghi nhớ có nghĩa là bạn nhớ kết quả của một phép tính thay vì tính toán lại mỗi lần, như:

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]

Khi bạn tính toán mỗi chuỗi dưới một triệu, sẽ có rất nhiều sự lặp lại ở cuối chuỗi. Ghi nhớ làm cho nó tìm kiếm bảng băm nhanh chóng cho các giá trị trước đó thay vì phải làm cho ngăn xếp ngày càng sâu hơn.


1
Giải thích rất dễ hiểu về ghi nhớ. Trên hết, cảm ơn bạn đã minh họa nó bằng một đoạn mã. Ngoài ra, "sẽ có rất nhiều sự lặp lại ở cuối chuỗi" làm cho tôi rõ ràng. Cảm ơn bạn.
Lernkurve

10

Tôi ngạc nhiên khi chưa có ai đề cập đến trampolining . Một tấm bạt lò xo (theo nghĩa này) là một vòng lặp lặp đi lặp lại các hàm trả về thunk (kiểu truyền tiếp tục) và có thể được sử dụng để thực hiện các lệnh gọi hàm đệ quy trong ngôn ngữ lập trình hướng ngăn xếp.

Câu hỏi StackOverflow này đi sâu vào chi tiết hơn một chút về các triển khai khác nhau của trampolining trong Java: Xử lý StackOverflow trong Java cho Trampoline


Tôi nghĩ về điều này ngay lập tức là tốt. Trampolines là một phương pháp để thực hiện tối ưu hóa cuộc gọi đuôi, vì vậy mọi người (gần như có thể) nói điều đó. +1 Để tham khảo cụ thể.
Steven Evers

6

Nếu bạn đang sử dụng một ngôn ngữ và trình biên dịch nhận ra các hàm đệ quy đuôi và xử lý chúng đúng cách (nghĩa là "thay thế người gọi tại chỗ bằng callee"), thì ừ, ngăn xếp không nên vượt quá tầm kiểm soát. Tối ưu hóa này về cơ bản làm giảm một phương pháp đệ quy thành một phương pháp lặp. Tôi không nghĩ Java làm điều này, nhưng tôi biết rằng Vợt làm được.

Nếu bạn đi theo cách tiếp cận lặp lại, thay vì cách tiếp cận đệ quy, bạn sẽ loại bỏ phần lớn nhu cầu ghi nhớ các cuộc gọi đến từ đâu và thực tế loại bỏ khả năng tràn ngăn xếp (dù sao cũng là các cuộc gọi đệ quy).

Ghi nhớ là rất tốt và có thể cắt giảm tổng số cuộc gọi phương thức bằng cách tra cứu các kết quả được tính toán trước đó trong bộ đệm, với điều kiện là phép tính tổng thể của bạn sẽ phải chịu nhiều phép tính lặp lại nhỏ hơn. Ý tưởng này rất hay - nó cũng độc lập với việc bạn đang sử dụng phương pháp lặp hay phương pháp đệ quy.


1
+1 để chỉ ra ghi nhớ cũng hữu ích trong các phương pháp lặp.
Karl Bielefeldt

Tất cả các ngôn ngữ lập trình chức năng có tối ưu hóa cuộc gọi đuôi.

3

bạn có thể tạo một Bảng liệt kê sẽ thay thế đệ quy ... đây là một ví dụ để tính toán giảng viên làm điều đó ... (sẽ không làm việc với số lượng lớn vì tôi chỉ sử dụng lâu trong ví dụ :-))

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++;
        }
    }
}

ngay cả khi đây không phải là ghi nhớ, theo cách này bạn sẽ làm mất hiệu lực tràn ngăn xếp


BIÊN TẬP


Tôi xin lỗi nếu tôi làm phiền một số bạn. Ý định duy nhất của tôi là chỉ ra một cách để tránh tràn ngăn xếp. Tôi có lẽ nên viết một ví dụ mã đầy đủ thay vì chỉ là một đoạn nhỏ của đoạn trích mã được viết nhanh và thô.

Đoạn mã sau

  • tránh đệ quy khi tôi sử dụng tính toán các giá trị được yêu cầu lặp lại.
  • bao gồm ghi nhớ khi các Giá trị đã được tính toán được lưu trữ và truy xuất nếu đã được tính toán
  • cũng bao gồm đồng hồ bấm giờ, vì vậy bạn có thể thấy rằng ghi nhớ hoạt động đúng

... Ừm ... nếu bạn chạy, hãy đảm bảo rằng bạn đặt cửa sổ vỏ lệnh của mình có bộ đệm 9999 dòng ... 300 thông thường sẽ không đủ để chạy qua kết quả của chương trình bên dưới ...

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");
       }
    }

Tôi khai báo * 1 "biến" tĩnh trong lớp Khoa để lưu trữ một singleton. Theo cách đó, miễn là chương trình của bạn đang chạy, bất cứ khi nào bạn "GetInstance ()" của lớp bạn nhận được cá thể đã lưu trữ tất cả các giá trị đã được tính toán. * 1 SortedList tĩnh sẽ chứa tất cả các giá trị đã được tính toán

Trong hàm tạo tôi cũng thêm 2 giá trị đặc biệt của danh sách 1 cho đầu vào 0 và 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
về mặt kỹ thuật, đây là sự lặp đi lặp lại khi bạn loại bỏ hoàn toàn bất kỳ đệ quy nào
ratchet freak

đó là :-) và nó ghi nhớ các kết quả trong các biến phương thức giữa mỗi bước tính toán
Ingo

2
Tôi nghĩ rằng bạn hiểu nhầm sự phân biệt, đó là khi các khoa (100) được gọi lần đầu tiên nó tính kết quả và lưu nó trong một hàm băm và trả về, sau đó khi nó được gọi lại, kết quả được lưu trữ được trả về
ratchet freak

@jk. Đối với tín dụng của mình, anh ta không bao giờ thực sự nói rằng đây là đệ quy.
Neil

ngay cả khi đây không phải là ghi nhớ, bằng cách này, bạn sẽ làm mất hiệu lực tràn ngăn xếp
Ingo

2

Đối với Scala, bạn có thể thêm @tailrecchú thích vào phương thức đệ quy. Bằng cách này, trình biên dịch đảm bảo rằng tối ưu hóa cuộc gọi đuôi thực sự đã diễn ra:

Vì vậy, điều này sẽ không được biên dịch (giai thừa):

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

thông báo lỗi là:

scala: không thể tối ưu hóa phương thức chú thích @tailrec fak1: nó chứa một cuộc gọi đệ quy không ở vị trí đuôi

Mặt khác:

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)
}

biên dịch, và tối ưu hóa cuộc gọi đuôi đã diễn ra.


1

Một khả năng chưa được đề cập là có đệ quy, nhưng không sử dụng ngăn xếp hệ thống. Tất nhiên bạn cũng có thể vượt quá heap của mình, nhưng nếu thuật toán của bạn thực sự cần quay lại ở dạng này hay dạng khác (tại sao lại sử dụng đệ quy cả?), Bạn không có lựa chọn nào khác.

Có nhiều triển khai stackless của một số ngôn ngữ, ví dụ Stackless Python .


0

Một giải pháp khác là mô phỏng ngăn xếp của riêng bạn và không dựa vào việc thực hiện trình biên dịch + thời gian chạy. Đây không phải là một giải pháp đơn giản hay nhanh chóng, nhưng về mặt lý thuyết, bạn sẽ chỉ nhận được StackOverflow khi hết bộ nhớ.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.