Đệ quy đuôi là gì?


52

Tôi biết khái niệm chung về đệ quy. Tôi đã bắt gặp khái niệm đệ quy đuôi trong khi nghiên cứu thuật toán quicksort. Trong video về thuật toán sắp xếp nhanh từ MIT lúc 18:30 giây, giáo sư nói rằng đây là thuật toán đệ quy đuôi. Tôi không rõ đệ quy đuôi thực sự có nghĩa là gì.

Ai đó có thể giải thích khái niệm này với một ví dụ thích hợp?

Một số câu trả lời được cung cấp bởi cộng đồng SO ở đây .


Hãy cho chúng tôi biết thêm về bối cảnh mà bạn đã gặp thuật ngữ đệ quy đuôi . Liên kết? Trích dẫn?
A.Schulz

@ A.Schulz Tôi đã đặt liên kết đến bối cảnh.
Geek

5
Hãy xem " Đệ quy đuôi là gì? " Trên stackoverflow
Vor

2
@ajmartin Câu hỏi là đường biên trên Stack Overflow nhưng chắc chắn về chủ đề Khoa học Máy tính , vì vậy về nguyên tắc Khoa học Máy tính sẽ tạo ra câu trả lời tốt hơn. Điều đó đã không xảy ra ở đây, nhưng vẫn ổn khi hỏi lại ở đây với hy vọng có câu trả lời tốt hơn. Geek, bạn nên đã đề cập đến câu hỏi trước đó của bạn về SO, để mọi người không lặp lại những gì đã được nói.
Gilles 'SO- ngừng trở nên xấu xa'

1
Ngoài ra, bạn nên nói phần mơ hồ là gì hoặc tại sao bạn không hài lòng với câu trả lời trước đó, tôi nghĩ trên SO mọi người cung cấp câu trả lời tốt nhưng điều gì khiến bạn phải hỏi lại?

Câu trả lời:


52

Đệ quy đuôi là một trường hợp đệ quy đặc biệt trong đó hàm gọi không tính toán nữa sau khi thực hiện cuộc gọi đệ quy. Ví dụ: hàm

int f (int x, int y) {
  if (y == 0) {
    trả lại x;
  }

  trả về f (x * y, y-1);
}

là đệ quy đuôi (vì lệnh cuối cùng là lệnh gọi đệ quy) trong khi chức năng này không phải là đệ quy đuôi:

int g (int x) {
  if (x == 1) {
    trả lại 1;
  }

  int y = g (x-1);

  trả lại x * y;
}

vì nó thực hiện một số tính toán sau khi cuộc gọi đệ quy đã trở lại.

Đệ quy đuôi là quan trọng vì nó có thể được thực hiện hiệu quả hơn đệ quy chung. Khi chúng ta thực hiện một cuộc gọi đệ quy bình thường, chúng ta phải đẩy địa chỉ trả lại vào ngăn xếp cuộc gọi sau đó nhảy đến chức năng được gọi. Điều này có nghĩa là chúng ta cần một ngăn xếp cuộc gọi có kích thước là tuyến tính theo chiều sâu của các cuộc gọi đệ quy. Khi chúng ta có đệ quy đuôi, chúng ta biết rằng ngay khi chúng ta quay trở lại từ cuộc gọi đệ quy, chúng ta cũng sẽ quay lại ngay lập tức, vì vậy chúng ta có thể bỏ qua toàn bộ chuỗi các hàm đệ quy trở lại và trả thẳng về người gọi ban đầu. Điều đó có nghĩa là chúng ta không cần một ngăn xếp cuộc gọi cho tất cả các cuộc gọi đệ quy và có thể thực hiện cuộc gọi cuối cùng như một bước nhảy đơn giản, giúp chúng ta tiết kiệm không gian.


2
bạn đã viết "Điều đó có nghĩa là chúng tôi không cần một ngăn xếp cuộc gọi cho tất cả các cuộc gọi đệ quy". Ngăn xếp cuộc gọi sẽ luôn ở đó, chỉ là địa chỉ trả lại không cần phải được ghi vào ngăn xếp cuộc gọi, phải không?
Geek

2
Nó phụ thuộc vào mô hình tính toán của bạn ở một mức độ nào đó :) Nhưng vâng, trên một máy tính thực, ngăn xếp cuộc gọi vẫn còn đó, chúng tôi chỉ không sử dụng nó.
Matt Lewis

Điều gì xảy ra nếu đó là cuộc gọi cuối cùng nhưng trong vòng lặp for. Vì vậy, bạn thực hiện tất cả các tính toán của mình ở trên nhưng một số trong số chúng trong một vòng lặp fordef recurse(x): if x < 0 return 1; for i in range 100{ (do calculations) recurse(x)}
thed0ctor

13

Nói một cách đơn giản, đệ quy đuôi là một đệ quy trong đó trình biên dịch có thể thay thế cuộc gọi đệ quy bằng lệnh "goto", vì vậy phiên bản đã biên dịch sẽ không phải tăng độ sâu ngăn xếp.

Đôi khi thiết kế một hàm đệ quy đuôi đòi hỏi bạn cần tạo một hàm trợ giúp với các tham số bổ sung.

Ví dụ, đây không phải là một hàm đệ quy đuôi:

int factorial(int x) {
    if (x > 0) {
        return x * factorial(x - 1);
    }
    return 1;
}

Nhưng đây là một hàm đệ quy đuôi:

int factorial(int x) {
    return tailfactorial(x, 1);
}

int tailfactorial(int x, int multiplier) {
    if (x > 0) {
        return tailfactorial(x - 1, x * multiplier);
    }
    return multiplier;
}

bởi vì trình biên dịch có thể viết lại hàm đệ quy thành hàm không đệ quy, sử dụng cái gì đó như thế này (mã giả):

int tailfactorial(int x, int multiplier) {
    start:
    if (x > 0) {
        multiplier = x * multiplier;
        x--;
        goto start;
    }
    return multiplier;
}

Quy tắc cho trình biên dịch rất đơn giản: Khi bạn tìm thấy " return thisfunction(newparameters);", hãy thay thế nó bằng " parameters = newparameters; goto start;". Nhưng điều này chỉ có thể được thực hiện nếu giá trị được trả về bởi lệnh gọi đệ quy được trả về trực tiếp.

Nếu tất cả các cuộc gọi đệ quy trong một hàm có thể được thay thế như thế này, thì đó là một hàm đệ quy đuôi.


13

Câu trả lời của tôi dựa trên lời giải thích được đưa ra trong cuốn sách Cấu trúc và diễn giải các chương trình máy tính . Tôi đánh giá cao cuốn sách này cho các nhà khoa học máy tính.

Cách tiếp cận A: Quá trình đệ quy tuyến tính

(define (factorial n)
 (if (= n 1)
  1
  (* n (factorial (- n 1)))))

Hình dạng của quy trình cho Cách tiếp cận A trông như thế này:

(factorial 5)
(* 5 (factorial 4))
(* 5 (* 4 (factorial 3)))
(* 5 (* 4 (* 3 (factorial 2))))
(* 5 (* 4 (* 3 (* 2 (factorial 1)))))
(* 5 (* 4 (* 3 (* 2 (* 1)))))
(* 5 (* 4 (* 3 (* 2))))
(* 5 (* 4 (* 6)))
(* 5 (* 24))
120

Cách tiếp cận B: Quá trình lặp tuyến tính

(define (factorial n)
 (fact-iter 1 1 n))

(define (fact-iter product counter max-count)
 (if (> counter max-count)
  product
  (fact-iter (* counter product)
             (+ counter 1)
             max-count)))

Hình dạng của quy trình cho Cách tiếp cận B trông như thế này:

(factorial 5)
(fact-iter 1 1 5)
(fact-iter 1 2 5)
(fact-iter 2 3 5)
(fact-iter 6 4 5)
(fact-iter 24 5 5)
(fact-iter 120 6 5)
120

Quy trình lặp tuyến tính (Cách tiếp cận B) chạy trong không gian không đổi mặc dù quy trình là một thủ tục đệ quy. Cũng cần lưu ý rằng trong cách tiếp cận này, một biến được xác định trạng thái của quá trình tại bất kỳ điểm nào. {product, counter, max-count}. Đây cũng là một kỹ thuật theo đó đệ quy đuôi cho phép tối ưu hóa trình biên dịch.

Trong Cách tiếp cận A có nhiều thông tin ẩn hơn mà trình thông dịch duy trì, về cơ bản là chuỗi các hoạt động bị trì hoãn.


5

Đệ quy đuôi là một hình thức đệ quy trong đó các lệnh gọi đệ quy là hướng dẫn cuối cùng trong hàm (đó là phần đuôi xuất phát từ đâu). Ngoài ra, cuộc gọi đệ quy không được bao gồm các tham chiếu đến các ô nhớ lưu trữ các giá trị trước đó (các tham chiếu khác với các tham số của hàm). Theo cách này, chúng tôi không quan tâm đến các giá trị trước đó và một khung ngăn xếp đủ cho tất cả các cuộc gọi đệ quy; đệ quy đuôi là một cách tối ưu hóa các thuật toán đệ quy. Ưu điểm / tối ưu hóa khác là có một cách dễ dàng để chuyển đổi thuật toán đệ quy đuôi thành một thuật toán tương đương sử dụng phép lặp thay vì đệ quy. Vì vậy, có, thuật toán cho quicksort thực sự là đệ quy đuôi.

QUICKSORT(A, p, r)
    if(p < r)
    then
        q = PARTITION(A, p, r)
        QUICKSORT(A, p, q–1)
        QUICKSORT(A, q+1, r)

Đây là phiên bản lặp:

QUICKSORT(A)
    p = 0, r = len(A) - 1
    while(p < r)
        q = PARTITION(A, p, r)
        r = q - 1

    p = 0, r = len(A) - 1
    while(p < r)
        q = PARTITION(A, p, r)
        p = q + 1
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.