Khi nào nên sử dụng đệ quy?


26

Khi nào một số trường hợp (tương đối) cơ bản (nghĩ rằng sinh viên CS cấp đại học năm thứ nhất) khi một người sẽ sử dụng đệ quy thay vì chỉ một vòng lặp?


2
bạn có thể biến bất kỳ đệ quy nào thành một vòng lặp (với một ngăn xếp).
Kaveh

Câu trả lời:


18

Tôi đã dạy C ++ cho sinh viên đại học trong khoảng hai năm và đệ quy bảo hiểm. Từ kinh nghiệm của tôi, câu hỏi và cảm xúc của bạn là rất phổ biến. Ở một thái cực, một số sinh viên thấy đệ quy là khó hiểu trong khi những người khác muốn sử dụng nó cho khá nhiều thứ.

Tôi nghĩ Dave tổng hợp nó tốt: sử dụng nó ở nơi phù hợp. Đó là, sử dụng nó khi cảm thấy tự nhiên. Khi bạn đối mặt với một vấn đề mà nó phù hợp độc đáo, rất có thể bạn sẽ nhận ra nó: có vẻ như bạn thậm chí không thể đưa ra giải pháp lặp lại. Ngoài ra, sự rõ ràng là một khía cạnh quan trọng của lập trình. Những người khác (và cả bạn nữa!) Sẽ có thể đọc và hiểu mã bạn sản xuất. Tôi nghĩ rằng an toàn khi nói các vòng lặp dễ hiểu hơn từ cái nhìn đầu tiên hơn là đệ quy.

Tôi không biết bạn nói về lập trình hay khoa học máy tính nói chung như thế nào, nhưng tôi cảm thấy mạnh mẽ rằng việc nói về các chức năng ảo, kế thừa hoặc về bất kỳ khái niệm nâng cao nào ở đây là vô nghĩa. Tôi thường bắt đầu với ví dụ cổ điển về tính toán các số Fibonacci. Nó phù hợp ở đây một cách độc đáo, vì các số Fibonacci được định nghĩa đệ quy . Điều này dễ hiểu và không yêu cầu bất kỳ tính năng ưa thích nào của ngôn ngữ. Sau khi các sinh viên đã đạt được một số hiểu biết cơ bản về đệ quy, chúng tôi đã xem xét lại một số chức năng đơn giản mà chúng tôi đã xây dựng trước đó. Đây là một ví dụ:

x

x

bool find(const std::string& s, char x)
{
   for(int i = 0; i < s.size(); ++i)
   {
      if(s[i] == x)
         return true;
   }

   return false;
}

Câu hỏi là sau đó, chúng ta có thể làm nó đệ quy không? Chắc chắn chúng ta có thể, đây là một cách:

bool find(const std::string& s, int idx, char x)
{
   if(idx == s.size())
      return false;

   return s[idx] == x || find(s, ++idx);
}

Câu hỏi tự nhiên tiếp theo là sau đó, chúng ta có nên làm như thế này không? Chắc là không. Tại sao? Nó khó hiểu hơn và khó hơn để đưa ra. Do đó, nó cũng dễ bị lỗi hơn.


2
Đoạn cuối không sai; chỉ muốn đề cập đến điều đó thường xuyên, cùng một lý do ủng hộ đệ quy trên các giải pháp lặp (Quicksort!).
Raphael

1
@Raphael Đồng ý, chính xác. Một số điều tự nhiên hơn để diễn đạt lặp đi lặp lại, những điều khác đệ quy. Đó là điểm mà tôi đã cố gắng thực hiện :)
Juho

Umm, tha thứ cho tôi nếu tôi sai, nhưng sẽ tốt hơn nếu bạn tách dòng trả về thành một điều kiện if trong mã ví dụ, trả về true nếu x được tìm thấy, còn phần nào là đệ quy không? Tôi không biết nếu 'hoặc' tiếp tục thực thi ngay cả khi nó tìm thấy đúng, nhưng nếu vậy, mã này rất kém hiệu quả.
MindlessRanger

@MindlessRanger Có lẽ một ví dụ hoàn hảo rằng phiên bản đệ quy khó hiểu và khó viết hơn? :-)
Juho

Vâng, và nhận xét trước đây của tôi là sai, 'hoặc' hoặc '||' không kiểm tra các điều kiện tiếp theo nếu điều kiện đầu tiên là đúng, do đó không có hiệu quả
MindlessRanger

24

Các giải pháp cho một số vấn đề được thể hiện tự nhiên hơn bằng cách sử dụng đệ quy.

Ví dụ: giả sử rằng bạn có cấu trúc dữ liệu cây với hai loại nút: lá, nơi lưu trữ một giá trị nguyên; và các nhánh, có một cây con trái và phải trong các lĩnh vực của họ. Giả sử rằng các lá được sắp xếp, sao cho giá trị thấp nhất nằm ở lá ngoài cùng bên trái.

Giả sử nhiệm vụ là in ra các giá trị của cây theo thứ tự. Một thuật toán đệ quy để làm điều này là khá tự nhiên:

class Node { abstract void traverse(); }
class Leaf extends Node { 
  int val; 
  void traverse() { print(val); }
} 
class Branch extends Node {
  Node left, right;
  void traverse() { left.traverse(); right.traverse(); }
}

Viết mã tương đương mà không cần đệ quy sẽ khó khăn hơn nhiều. Thử nó!

Tổng quát hơn, đệ quy hoạt động tốt cho các thuật toán trên các cấu trúc dữ liệu đệ quy như cây hoặc cho các vấn đề có thể tự nhiên được chia thành các vấn đề phụ. Kiểm tra, ví dụ, phân chia và chinh phục các thuật toán .

Nếu bạn thực sự muốn thấy đệ quy trong môi trường tự nhiên nhất của nó, thì bạn nên xem một ngôn ngữ lập trình chức năng như Haskell. Trong một ngôn ngữ như vậy, không có cấu trúc lặp, vì vậy mọi thứ được thể hiện bằng cách sử dụng đệ quy (hoặc các hàm bậc cao hơn, nhưng đó là một câu chuyện khác, một điều đáng để biết quá).

Cũng lưu ý rằng các ngôn ngữ lập trình chức năng thực hiện đệ quy đuôi được tối ưu hóa. Điều này có nghĩa là họ không đặt khung stack trừ khi họ không cần --- về cơ bản, đệ quy có thể được chuyển đổi thành một vòng lặp. Từ góc độ thực tế, bạn có thể viết mã theo cách tự nhiên, nhưng có được hiệu suất của mã lặp. Đối với bản ghi, có vẻ như trình biên dịch C ++ cũng tối ưu hóa các cuộc gọi đuôi , do đó không có thêm chi phí sử dụng đệ quy trong C ++.


1
C ++ có đệ quy đuôi không? Có thể đáng để chỉ ra rằng các ngôn ngữ chức năng thường làm.
Louis

3
Cảm ơn Louis. Một số trình biên dịch C ++ tối ưu hóa các cuộc gọi đuôi. (Đệ quy đuôi là thuộc tính của chương trình, không phải ngôn ngữ.) Tôi đã cập nhật câu trả lời của mình.
Dave Clarke

Ít nhất GCC không tối ưu hóa các cuộc gọi đuôi (và thậm chí một số hình thức của các cuộc gọi không đuôi) đi.
vonbrand

11

Từ một người thực tế sống trong đệ quy tôi sẽ thử và làm sáng tỏ chủ đề này.

Khi lần đầu tiên được giới thiệu để đệ quy, bạn biết rằng đó là một hàm tự gọi chính nó và được thể hiện cơ bản bằng các thuật toán như truyền tải cây. Sau này bạn thấy rằng nó được sử dụng rất nhiều trong lập trình chức năng cho các ngôn ngữ như LISP và F #. Với F # tôi viết, hầu hết những gì tôi viết là đệ quy và khớp mẫu.

Nếu bạn tìm hiểu thêm về lập trình chức năng, chẳng hạn như F #, bạn sẽ học Danh sách F # được triển khai dưới dạng danh sách liên kết đơn, có nghĩa là các hoạt động chỉ truy cập vào đầu danh sách là O (1) và truy cập phần tử là O (n). Khi bạn tìm hiểu điều này, bạn có xu hướng duyệt dữ liệu dưới dạng danh sách, xây dựng danh sách mới theo thứ tự ngược lại và sau đó đảo ngược danh sách trước khi quay trở lại từ chức năng rất hiệu quả.

Bây giờ nếu bạn bắt đầu nghĩ về điều này, bạn sẽ sớm nhận ra rằng các hàm đệ quy sẽ đẩy khung ngăn xếp mỗi khi thực hiện lệnh gọi hàm và có thể gây ra lỗi tràn ngăn xếp. Tuy nhiên, nếu bạn xây dựng hàm đệ quy để nó có thể thực hiện cuộc gọi đuôi và trình biên dịch hỗ trợ khả năng tối ưu hóa mã cho cuộc gọi đuôi. tức là .NET OpCodes.Tailcall Field, bạn sẽ không gây ra lỗi tràn ngăn xếp. Tại thời điểm này, bạn bắt đầu viết bất kỳ vòng lặp như là một hàm đệ quy và bất kỳ quyết định nào là khớp; những ngày của ifwhilebây giờ là lịch sử.

Khi bạn chuyển sang AI bằng cách sử dụng quay lui trong các ngôn ngữ như PRITAL, thì mọi thứ đều được đệ quy. Mặc dù điều này đòi hỏi phải suy nghĩ theo một cách hoàn toàn khác với mã bắt buộc, nhưng nếu PRITAL là công cụ phù hợp cho vấn đề, nó sẽ giải phóng bạn khỏi gánh nặng phải viết nhiều dòng mã và có thể giảm đáng kể số lỗi. Xem: Amzi khách hàng eoTek

Để trở lại câu hỏi của bạn khi nào nên sử dụng đệ quy; một cách tôi nhìn vào lập trình là với phần cứng ở một đầu và các khái niệm trừu tượng ở đầu kia. Càng gần với vấn đề phần cứng, tôi càng nghĩ trong các ngôn ngữ bắt buộc ifwhile, vấn đề càng trừu tượng, tôi càng nghĩ trong các ngôn ngữ cấp cao có đệ quy. Tuy nhiên, nếu bạn bắt đầu viết mã hệ thống cấp thấp và như vậy, và bạn muốn xác minh rằng nó hợp lệ, thì bạn sẽ thấy các giải pháp như các định lý định lý có ích, dựa nhiều vào đệ quy.

Nếu bạn nhìn vào Jane Street bạn sẽ thấy họ sử dụng ngôn ngữ chức năng OCaml . Mặc dù tôi chưa thấy bất kỳ mã nào của họ, nhưng khi đọc về những gì họ đề cập về mã của họ, họ chắc chắn đang suy nghĩ đệ quy.

CHỈNH SỬA

Vì bạn đang tìm kiếm một danh sách sử dụng, tôi sẽ cung cấp cho bạn một ý tưởng cơ bản về những gì cần tìm trong mã và một danh sách các cách sử dụng cơ bản chủ yếu dựa trên khái niệm Catamorphism nằm ngoài những điều cơ bản.

Đối với C ++: Nếu bạn định nghĩa một cấu trúc hoặc một lớp có một con trỏ tới cùng một cấu trúc hoặc lớp đó thì nên xem xét đệ quy cho các phương thức truyền tải sử dụng các con trỏ.

Các trường hợp đơn giản là một danh sách liên kết một cách. Bạn sẽ xử lý danh sách bắt đầu từ đầu hoặc đuôi và sau đó duyệt qua danh sách bằng cách sử dụng các con trỏ.

Cây là một trường hợp khác mà đệ quy thường được sử dụng; nhiều đến nỗi nếu bạn nhìn thấy cây đi qua mà không cần đệ quy bạn nên bắt đầu hỏi tại sao? Nó không sai, nhưng một cái gì đó cần được lưu ý trong các ý kiến.

Sử dụng phổ biến của đệ quy là:


2
Nghe có vẻ như là một câu trả lời thực sự tuyệt vời, mặc dù nó cũng cao hơn một chút so với bất cứ điều gì đang được dạy trong các lớp học của tôi bất cứ lúc nào tôi sớm tin.
Taylor Huston

1
@TaylorHuston Hãy nhớ rằng bạn là khách hàng; hỏi giáo viên các khái niệm bạn muốn hiểu Anh ta có thể sẽ không trả lời họ trong lớp, nhưng bắt anh ta trong giờ làm việc và nó có thể trả rất nhiều cổ tức trong tương lai.
Guy Coder

Câu trả lời hay, nhưng có vẻ như quá cao cấp đối với người không biết về lập trình chức năng :).
pad

2
... dẫn người hỏi ngây thơ nghiên cứu lập trình chức năng. Thắng lợi!
JeffE

8

Để cung cấp cho bạn một trường hợp sử dụng ít phức tạp hơn các câu trả lời trong các câu trả lời khác: đệ quy trộn rất tốt với các cấu trúc lớp giống như cây (Hướng đối tượng) xuất phát từ một nguồn chung. Một ví dụ về C ++:

class Expression {
public:
    // The "= 0" means 'I don't implement this, I let my subclasses do that'
    virtual int ComputeValue() = 0;
}

class Plus : public Expression {
private:
    Expression* left
    Expression* right;
public:
    virtual int ComputeValue() { return left->ComputeValue() + right->ComputeValue(); }
}

class Times : public Expression {
private:
    Expression* left
    Expression* right;
public:
    virtual int ComputeValue() { return left->ComputeValue() * right->ComputeValue(); }
}

class Negate : public Expression {
private:
    Expression* expr;
public:
    virtual int ComputeValue() { return -(expr->ComputeValue()); }
}

class Constant : public Expression {
private:
    int value;
public:
    virtual int ComputeValue() { return value; }
}

Ví dụ trên sử dụng đệ quy: ComputeValue được triển khai đệ quy. Để làm cho ví dụ hoạt động, bạn sử dụng các hàm và kế thừa ảo. Bạn không biết chính xác phần bên trái và bên phải của lớp Plus là gì, nhưng bạn không quan tâm: đó là thứ có thể tính toán giá trị của chính nó, đó là tất cả những gì bạn cần biết.

Ưu điểm quan trọng của phương pháp trên là mỗi lớp đều quan tâm đến các tính toán của riêng mình . Bạn tách biệt các cách thực hiện khác nhau của mọi biểu hiện phụ có thể hoàn toàn: chúng không có kiến ​​thức về hoạt động của nhau. Điều này làm cho lý luận về chương trình dễ dàng hơn và do đó làm cho chương trình dễ hiểu hơn, duy trì và mở rộng.


1
Tôi không chắc những ví dụ 'phức tạp' mà bạn đang đề cập đến. Tuy nhiên, thảo luận tốt về sự tích hợp với OO.
Dave Clarke

3

Ví dụ đầu tiên được sử dụng để dạy đệ quy trong lớp lập trình ban đầu của tôi là một hàm liệt kê tất cả các chữ số trong một số theo thứ tự ngược lại.

void listDigits(int x){
     if (x <= 0)
        return;
     print x % 10;
     listDigits(x/10);
}

Hoặc một cái gì đó tương tự (Tôi đang đi từ bộ nhớ ở đây và không kiểm tra). Ngoài ra, khi bạn vào các lớp cấp cao hơn, bạn sẽ sử dụng đệ quy RẤT NHIỀU đặc biệt là trong các thuật toán tìm kiếm, thuật toán sắp xếp, v.v.

Vì vậy, nó có vẻ như là một chức năng vô dụng trong ngôn ngữ bây giờ, nhưng về lâu dài nó rất hữu ích.

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.