Chính xác thì đệ quy đuôi hoạt động như thế nào?


121

Tôi gần như hiểu cách hoạt động của đệ quy đuôi và sự khác biệt giữa nó và đệ quy thông thường. Tôi chỉ không hiểu tại sao nó không yêu cầu ngăn xếp nhớ địa chỉ trả lại của nó.

// tail recursion
int fac_times (int n, int acc) {
    if (n == 0) return acc;
    else return fac_times(n - 1, acc * n);
}

int factorial (int n) {
    return fac_times (n, 1);
}

// normal recursion
int factorial (int n) {
    if (n == 0) return 1;
    else return n * factorial(n - 1);
}

Không có gì phải làm sau khi gọi một hàm trong một hàm đệ quy đuôi nhưng nó không có ý nghĩa đối với tôi.


16
Đệ quy đuôi đệ quy "bình thường". Nó chỉ có nghĩa là đệ quy xảy ra ở cuối hàm.
Pete Becker vào

7
... Nhưng nó có thể được thực hiện theo một cách khác ở mức IL so với đệ quy bình thường, làm giảm độ sâu ngăn xếp.
KeithS

2
BTW, gcc có thể thực hiện loại bỏ đệ quy đuôi trên ví dụ "bình thường" ở đây.
dmckee --- ex-moderator kitten.

1
@Geek - Tôi là nhà phát triển C #, vì vậy "hợp ngữ" của tôi là MSIL hoặc chỉ IL. Đối với C / C ++, thay IL bằng ASM.
KeithS

1
@ShannonSeverance Tôi thấy rằng gcc đang làm điều đó bằng cách đơn giản kiểm tra mã lắp ráp được phát ra mà không có -O3. Liên kết này dành cho một cuộc thảo luận trước đó bao gồm cơ sở rất giống nhau và thảo luận những gì cần thiết để thực hiện tối ưu hóa này.
dmckee --- cựu điều hành mèo

Câu trả lời:


169

Trình biên dịch chỉ có thể chuyển đổi

int fac_times (int n, int acc) {
    if (n == 0) return acc;
    else return fac_times(n - 1, acc * n);
}

thành một cái gì đó như thế này:

int fac_times (int n, int acc) {
label:
    if (n == 0) return acc;
    acc *= n--;
    goto label;
}

2
@ Mr.32 Tôi không hiểu câu hỏi của bạn. Tôi đã chuyển đổi hàm thành một hàm tương đương nhưng không có đệ quy rõ ràng (nghĩa là không có lệnh gọi hàm rõ ràng). Nếu bạn thay đổi logic thành một cái gì đó không tương đương, bạn thực sự có thể tạo ra vòng lặp hàm mãi mãi trong một số hoặc tất cả các trường hợp.
Alexey Frunze

18
Vì vậy, đệ quy đuôi là hiệu quả chỉtrình biên dịch tối ưu hóa nó? Và nếu không, nó sẽ giống như một đệ quy bình thường về bộ nhớ ngăn xếp khôn ngoan?
Alan Coromano

34
Vâng. Nếu trình biên dịch không thể giảm đệ quy thành một vòng lặp, bạn đang mắc kẹt với đệ quy. Tất cả hoặc không có gì.
Alexey Frunze,

3
@AlanDert: đúng. Bạn cũng có thể coi đệ quy đuôi là một trường hợp đặc biệt của "tối ưu hóa lệnh gọi đuôi", đặc biệt vì lệnh gọi đuôi xảy ra với cùng một hàm. Nói chung, bất kỳ lệnh gọi đuôi nào (với các yêu cầu tương tự về "không còn công việc phải làm" áp dụng cho đệ quy đuôi và trong đó giá trị trả về của lệnh gọi đuôi được trả về trực tiếp) đều có thể được tối ưu hóa nếu trình biên dịch có thể thực hiện lệnh gọi trong một cách thiết lập địa chỉ trả về của hàm được gọi là địa chỉ trả về của hàm thực hiện lệnh gọi đuôi, thay vì địa chỉ mà từ đó lệnh gọi đuôi được thực hiện.
Steve Jessop,

1
@AlanDert trong C đây chỉ là một tối ưu hóa không được thực thi bởi bất kỳ tiêu chuẩn nào, vì vậy mã di động không nên phụ thuộc vào nó. Nhưng có những ngôn ngữ (Đề án là một ví dụ), nơi tối ưu hóa đệ quy đuôi được thực thi theo tiêu chuẩn, vì vậy bạn không cần phải lo lắng rằng nó sẽ ngăn tràn trong một số môi trường.
Jan Wrobel

57

Bạn hỏi tại sao "nó không yêu cầu ngăn xếp nhớ địa chỉ trả về của nó".

Tôi muốn xoay chuyển điều này. Nó sử dụng ngăn xếp để nhớ địa chỉ trở lại. Bí quyết là hàm trong đó đệ quy đuôi xảy ra có địa chỉ trả về của chính nó trên ngăn xếp và khi nó nhảy đến hàm được gọi, nó sẽ coi đây là địa chỉ trả về của chính nó.

Cụ thể, không có tối ưu hóa cuộc gọi đuôi:

f: ...
   CALL g
   RET
g:
   ...
   RET

Trong trường hợp này, khi gđược gọi, ngăn xếp sẽ giống như sau:

   SP ->  Return address of "g"
          Return address of "f"

Mặt khác, với tối ưu hóa cuộc gọi đuôi:

f: ...
   JUMP g
g:
   ...
   RET

Trong trường hợp này, khi gđược gọi, ngăn xếp sẽ giống như sau:

   SP ->  Return address of "f"

Rõ ràng, khi gtrả về, nó sẽ quay trở lại vị trí fđã được gọi từ đó.

CHỈNH SỬA : Ví dụ trên sử dụng trường hợp một hàm gọi một hàm khác. Cơ chế giống hệt nhau khi hàm gọi chính nó.


8
Đây là một câu trả lời tốt hơn nhiều so với các câu trả lời khác. Trình biên dịch rất có thể không có một số trường hợp đặc biệt kỳ diệu để chuyển đổi mã đệ quy đuôi. Nó chỉ thực hiện một tối ưu hóa cuộc gọi cuối cùng bình thường xảy ra để đi đến cùng một chức năng.
Art

12

Đệ quy đuôi thường có thể được trình biên dịch chuyển thành một vòng lặp, đặc biệt là khi sử dụng bộ tích lũy.

// tail recursion
int fac_times (int n, int acc = 1) {
    if (n == 0) return acc;
    else return fac_times(n - 1, acc * n);
}

sẽ biên dịch thành một cái gì đó giống như

// accumulator
int fac_times (int n) {
    int acc = 1;
    while (n > 0) {
        acc *= n;
        n -= 1;
    }
    return acc;
}

3
Không thông minh như cách thực hiện của Alexey ... và vâng, đó là một lời khen.
Matthieu M.

1
Trên thực tế, kết quả trông đơn giản hơn nhưng tôi nghĩ mã để thực hiện chuyển đổi này sẽ FAR "thông minh" hơn nhãn / goto hoặc chỉ loại bỏ cuộc gọi đuôi (xem câu trả lời của Lindydancer).
Phob

Nếu đây là tất cả đệ quy đuôi, thì tại sao mọi người lại hào hứng với nó như vậy? Tôi không thấy ai hứng thú với vòng lặp while.
Buh Buh

@BuhBuh: Điều này không có stackoverflow và tránh việc đẩy / bật các tham số của ngăn xếp. Đối với một vòng lặp chặt chẽ như thế này, nó có thể tạo ra một thế giới khác biệt. Ngoài ra, mọi người không nên vui mừng.
Mooing Duck,

11

Có hai phần tử phải có trong một hàm đệ quy:

  1. Cuộc gọi đệ quy
  2. Nơi lưu giữ số lượng các giá trị trả về.

Một hàm đệ quy "thông thường" giữ (2) trong khung ngăn xếp.

Giá trị trả về trong hàm đệ quy thông thường bao gồm hai loại giá trị:

  • Các giá trị trả lại khác
  • Kết quả của tính toán hàm sở hữu

Hãy xem ví dụ của bạn:

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

Ví dụ, khung f (5) "lưu trữ" kết quả của phép tính riêng của nó (5) và giá trị của f (4). Nếu tôi gọi giai thừa (5), ngay trước khi các lệnh gọi ngăn xếp bắt đầu thu gọn, tôi có:

 [Stack_f(5): return 5 * [Stack_f(4): 4 * [Stack_f(3): 3 * ... [1[1]]

Lưu ý rằng mỗi ngăn xếp lưu trữ, ngoài các giá trị tôi đã đề cập, toàn bộ phạm vi của hàm. Vì vậy, mức sử dụng bộ nhớ cho một hàm đệ quy f là O (x), trong đó x là số lần gọi đệ quy mà tôi phải thực hiện. Vì vậy, nếu tôi cần 1kb RAM để tính giai thừa (1) hoặc giai thừa (2), tôi cần ~ 100k để tính giai thừa (100), v.v.

Một hàm đệ quy đuôi đặt (2) trong các đối số của nó.

Trong Đệ quy đuôi, tôi chuyển kết quả của các phép tính từng phần trong mỗi khung đệ quy cho khung tiếp theo bằng cách sử dụng các tham số. Hãy xem ví dụ giai thừa của chúng ta, Đệ quy đuôi:

int factorial (int n) {int helper (int num, int Tích lũy) {if num == 0 return else return helper (num - 1, Tích lũy * num)} return helper (n, 1)
}

Hãy xem các khung của nó trong giai thừa (4):

[Stack f(4, 5): Stack f(3, 20): [Stack f(2,60): [Stack f(1, 120): 120]]]]

Thấy sự khác biệt? Trong các lệnh gọi đệ quy "thông thường", các hàm trả về soạn một cách đệ quy giá trị cuối cùng. Trong Đệ quy đuôi, chúng chỉ tham chiếu trường hợp cơ sở (đánh giá cuối cùng) . Chúng tôi gọi bộ tích lũy là đối số theo dõi các giá trị cũ hơn.

Mẫu đệ quy

Hàm đệ quy thông thường đi như sau:

type regular(n)
    base_case
    computation
    return (result of computation) combined with (regular(n towards base case))

Để biến đổi nó trong đệ quy Đuôi, chúng ta:

  • Giới thiệu một chức năng trợ giúp mang bộ tích lũy
  • chạy chức năng trợ giúp bên trong chức năng chính, với bộ tích lũy được đặt thành hộp cơ sở.

Nhìn:

type tail(n):
    type helper(n, accumulator):
        if n == base case
            return accumulator
        computation
        accumulator = computation combined with accumulator
        return helper(n towards base case, accumulator)
    helper(n, base case)

Thấy sự khác biệt?

Tối ưu hóa cuộc gọi đuôi

Vì không có trạng thái nào được lưu trữ trên ngăn xếp Cuộc gọi Đuôi không biên giới nên chúng không quá quan trọng. Sau đó, một số ngôn ngữ / thông dịch viên thay thế ngăn xếp cũ bằng ngăn xếp mới. Vì vậy, không có khung ngăn xếp hạn chế số lượng cuộc gọi, Cuộc gọi Đuôi hoạt động giống như một vòng lặp trong những trường hợp này.

Tối ưu hóa nó hay không tùy thuộc vào trình biên dịch của bạn.


6

Đây là một ví dụ đơn giản cho thấy cách hoạt động của các hàm đệ quy:

long f (long n)
{

    if (n == 0) // have we reached the bottom of the ocean ?
        return 0;

    // code executed in the descendence

    return f(n-1) + 1; // recurrence

    // code executed in the ascendence

}

Đệ quy đuôi là một hàm đệ quy đơn giản, trong đó việc lặp lại được thực hiện ở cuối hàm, do đó không có mã nào được thực hiện ở dạng tăng dần, điều này giúp hầu hết các trình biên dịch của các ngôn ngữ lập trình cấp cao thực hiện những gì được gọi là Tối ưu hóa đệ quy đuôi , cũng có tối ưu hóa phức tạp hơn được gọi là mô-đun đệ quy Đuôi


1

Hàm đệ quy là một hàm tự gọi

Nó cho phép các lập trình viên viết các chương trình hiệu quả bằng cách sử dụng một lượng mã tối thiểu .

Mặt hạn chế là chúng có thể gây ra vòng lặp vô hạn và các kết quả không mong muốn khác nếu không được viết đúng cách .

Tôi sẽ giải thích cả hàm đệ quy đơn giản và hàm đệ quy đuôi

Để viết một hàm đệ quy đơn giản

  1. Điểm đầu tiên cần xem xét là khi nào bạn nên quyết định thoát ra khỏi vòng lặp, đó là vòng lặp if
  2. Thứ hai là quy trình phải làm nếu chúng ta là chức năng của chính mình

Từ ví dụ đã cho:

public static int fact(int n){
  if(n <=1)
     return 1;
  else 
     return n * fact(n-1);
}

Từ ví dụ trên

if(n <=1)
     return 1;

Là yếu tố quyết định khi nào thoát khỏi vòng lặp

else 
     return n * fact(n-1);

Quá trình xử lý thực sự được thực hiện

Hãy để tôi chia nhỏ nhiệm vụ từng cái một cho dễ hiểu.

Hãy để chúng tôi xem điều gì xảy ra trong nội bộ nếu tôi chạy fact(4)

  1. Thay n = 4
public static int fact(4){
  if(4 <=1)
     return 1;
  else 
     return 4 * fact(4-1);
}

Ifvòng lặp không thành công vì vậy nó đi đến elsevòng lặp để nó trả về4 * fact(3)

  1. Trong bộ nhớ ngăn xếp, chúng ta có 4 * fact(3)

    Thay n = 3

public static int fact(3){
  if(3 <=1)
     return 1;
  else 
     return 3 * fact(3-1);
}

If vòng lặp không thành công vì vậy nó đi đến elsevòng lặp

vì vậy nó trở lại 3 * fact(2)

Hãy nhớ rằng chúng tôi gọi là `` 4 * fact (3) ''

Đầu ra cho fact(3) = 3 * fact(2)

Cho đến nay ngăn xếp đã 4 * fact(3) = 4 * 3 * fact(2)

  1. Trong bộ nhớ ngăn xếp, chúng ta có 4 * 3 * fact(2)

    Thay n = 2

public static int fact(2){
  if(2 <=1)
     return 1;
  else 
     return 2 * fact(2-1);
}

Ifvòng lặp không thành công nên nó đi đến elsevòng lặp

vì vậy nó trở lại 2 * fact(1)

Hãy nhớ rằng chúng tôi đã gọi 4 * 3 * fact(2)

Đầu ra cho fact(2) = 2 * fact(1)

Cho đến nay ngăn xếp đã 4 * 3 * fact(2) = 4 * 3 * 2 * fact(1)

  1. Trong bộ nhớ ngăn xếp, chúng ta có 4 * 3 * 2 * fact(1)

    Thay n = 1

public static int fact(1){
  if(1 <=1)
     return 1;
  else 
     return 1 * fact(1-1);
}

If vòng lặp là đúng

vì vậy nó trở lại 1

Hãy nhớ rằng chúng tôi đã gọi 4 * 3 * 2 * fact(1)

Đầu ra cho fact(1) = 1

Cho đến nay ngăn xếp đã 4 * 3 * 2 * fact(1) = 4 * 3 * 2 * 1

Cuối cùng, kết quả của fact (4) = 4 * 3 * 2 * 1 = 24

nhập mô tả hình ảnh ở đây

Các Đệ quy Tail sẽ

public static int fact(x, running_total=1) {
    if (x==1) {
        return running_total;
    } else {
        return fact(x-1, running_total*x);
    }
}
  1. Thay n = 4
public static int fact(4, running_total=1) {
    if (x==1) {
        return running_total;
    } else {
        return fact(4-1, running_total*4);
    }
}

Ifvòng lặp không thành công vì vậy nó đi đến elsevòng lặp để nó trả vềfact(3, 4)

  1. Trong bộ nhớ ngăn xếp, chúng ta có fact(3, 4)

    Thay n = 3

public static int fact(3, running_total=4) {
    if (x==1) {
        return running_total;
    } else {
        return fact(3-1, 4*3);
    }
}

Ifvòng lặp không thành công nên nó đi đến elsevòng lặp

vì vậy nó trở lại fact(2, 12)

  1. Trong bộ nhớ ngăn xếp, chúng ta có fact(2, 12)

    Thay n = 2

public static int fact(2, running_total=12) {
    if (x==1) {
        return running_total;
    } else {
        return fact(2-1, 12*2);
    }
}

Ifvòng lặp không thành công nên nó đi đến elsevòng lặp

vì vậy nó trở lại fact(1, 24)

  1. Trong bộ nhớ ngăn xếp, chúng ta có fact(1, 24)

    Thay n = 1

public static int fact(1, running_total=24) {
    if (x==1) {
        return running_total;
    } else {
        return fact(1-1, 24*1);
    }
}

If vòng lặp là đúng

vì vậy nó trở lại running_total

Đầu ra cho running_total = 24

Cuối cùng, kết quả của fact (4,1) = 24

nhập mô tả hình ảnh ở đây


0

Câu trả lời của tôi là phỏng đoán nhiều hơn, bởi vì đệ quy là một cái gì đó liên quan đến triển khai nội bộ.

Trong đệ quy đuôi, hàm đệ quy được gọi ở cuối cùng một hàm. Có lẽ trình biên dịch có thể tối ưu hóa theo cách dưới đây:

  1. Để chức năng đang diễn ra kết thúc (tức là ngăn xếp đã sử dụng được thu hồi)
  2. Lưu trữ các biến sẽ được sử dụng làm đối số cho hàm trong một bộ lưu trữ tạm thời
  3. Sau đó, gọi lại hàm với đối số được lưu trữ tạm thời

Như bạn có thể thấy, chúng tôi đang kết thúc hàm ban đầu trước lần lặp lại tiếp theo của cùng một hàm, vì vậy chúng tôi không thực sự "sử dụng" ngăn xếp.

Nhưng tôi tin rằng nếu có hàm hủy được gọi bên trong hàm thì tối ưu hóa này có thể không áp dụng.


0

Trình biên dịch đủ thông minh để hiểu đệ quy đuôi. Trong trường hợp, trong khi quay lại từ một cuộc gọi đệ quy, không có hoạt động nào đang chờ xử lý và lệnh gọi đệ quy là câu lệnh cuối cùng, thuộc loại đệ quy đuôi. Về cơ bản, trình biên dịch thực hiện tối ưu hóa đệ quy đuôi, loại bỏ việc triển khai ngăn xếp. Hãy xem xét mã bên dưới.

void tail(int i) {
    if(i<=0) return;
    else {
     system.out.print(i+"");
     tail(i-1);
    }
   }

Sau khi thực hiện tối ưu hóa, đoạn mã trên được chuyển đổi thành đoạn mã bên dưới.

void tail(int i) {
    blockToJump:{
    if(i<=0) return;
    else {
     system.out.print(i+"");
     i=i-1;
     continue blockToJump;  //jump to the bolckToJump
    }
    }
   }

Đây là cách trình biên dịch thực hiện Tối ưu hóa đệ quy đuôi.

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.