Đệ quy hay lặp lại?


226

Có một cú đánh hiệu suất nếu chúng ta sử dụng một vòng lặp thay vì đệ quy hoặc ngược lại trong các thuật toán mà cả hai có thể phục vụ cùng một mục đích? Ví dụ: Kiểm tra xem chuỗi đã cho có phải là một palindrom không. Tôi đã thấy nhiều lập trình viên sử dụng đệ quy như một phương tiện để thể hiện khi một thuật toán lặp đơn giản có thể phù hợp với hóa đơn. Trình biên dịch có vai trò quan trọng trong việc quyết định sử dụng cái gì?


4
@War Warrior Không phải lúc nào. Ví dụ, với các chương trình cờ vua, việc đọc đệ quy sẽ dễ dàng hơn. Một phiên bản "lặp lại" của mã cờ vua sẽ không thực sự giúp tăng tốc và có thể làm cho nó phức tạp hơn.
Mateen Ulhaq

12
Tại sao một cái búa nên được ưa chuộng hơn cưa? Một tuốc nơ vít trên một dùi? Một cái đục trên một cái khoan?
Wayne Conrad

3
Không có mục yêu thích. Tất cả đều chỉ là công cụ, mỗi công cụ đều có mục đích riêng. Tôi sẽ hỏi, "loại vấn đề nào được lặp lại tốt hơn so với đệ quy và ngược lại?"
Wayne Conrad

9
"Điều gì là tốt về đệ quy?" ... Đó là đệ quy đó là gì. ; o)
Keng

9
Tiền đề sai. Đệ quy không tốt; Thực tế nó rất tệ. Bất cứ ai viết phần mềm mạnh mẽ sẽ cố gắng loại bỏ tất cả đệ quy kể từ khi, trừ khi nó có thể được tối ưu hóa cuộc gọi đuôi hoặc số cấp bị ràng buộc logarit hoặc tương tự, đệ quy hầu như luôn dẫn đến chồng tràn loại xấu.
R .. GitHub DỪNG GIÚP ICE

Câu trả lời:


181

Có thể là đệ quy sẽ đắt hơn, tùy thuộc vào chức năng đệ quy là đệ quy đuôi (dòng cuối cùng là cuộc gọi đệ quy). Đuôi đệ quy nên được trình biên dịch nhận ra và tối ưu hóa cho đối tác lặp của nó (trong khi duy trì sự triển khai rõ ràng, ngắn gọn mà bạn có trong mã của mình).

Tôi sẽ viết thuật toán theo cách có ý nghĩa nhất và rõ ràng nhất đối với người hút kém (có thể là chính bạn hoặc người khác) phải duy trì mã trong vài tháng hoặc vài năm. Nếu bạn gặp phải các vấn đề về hiệu năng, thì hãy lập hồ sơ mã của bạn, và sau đó và chỉ sau đó xem xét tối ưu hóa bằng cách chuyển sang triển khai lặp. Bạn có thể muốn xem xét ghi nhớlập trình động .


12
Các thuật toán có tính chính xác có thể được chứng minh bằng quy nạp có xu hướng tự viết ở dạng đệ quy. Cùng với thực tế là đệ quy đuôi được tối ưu hóa bởi các trình biên dịch, cuối cùng bạn sẽ thấy nhiều thuật toán được biểu diễn đệ quy hơn.
Binil Thomas

15
re: tail recursion is optimized by compilersNhưng không phải tất cả các trình biên dịch đều hỗ trợ đệ quy đuôi ..
Kevin Meredith

347

Vòng lặp có thể đạt được hiệu suất đạt được cho chương trình của bạn. Đệ quy có thể đạt được hiệu suất đạt được cho lập trình viên của bạn. Chọn cái nào quan trọng hơn trong tình huống của bạn!


3
@LeighCaldwell: Tôi nghĩ rằng tổng hợp suy nghĩ của tôi chính xác. Đáng tiếc toàn năng đã không upmod. Tôi chắc chắn có. :)
Ande Turner

35
Bạn có biết rằng bạn đã được trích dẫn vào một cuốn sách vì cụm từ câu trả lời của bạn? LOL amazon.com/Grokking-Algorithms-illustrated-programmers-curious/...
Aipi

4
Tôi thích câu trả lời này .. và tôi thích cuốn sách "Thuật toán Grokking")
Tối đa

vì vậy, ít nhất tôi và 341 người đọc cuốn sách Thuật toán Grokking!
zzfima

78

So sánh đệ quy với phép lặp cũng giống như so sánh tuốc nơ vít đầu phillips với tuốc nơ vít đầu phẳng. Đối với hầu hết các phần bạn có thể loại bỏ bất kỳ vít đầu phillips bằng đầu phẳng, nhưng sẽ dễ dàng hơn nếu bạn sử dụng tuốc nơ vít được thiết kế cho vít đó phải không?

Một số thuật toán chỉ cho vay để đệ quy vì cách chúng được thiết kế (các chuỗi Fibonacci, đi qua một cấu trúc giống như cây, v.v.). Đệ quy làm cho thuật toán ngắn gọn và dễ hiểu hơn (do đó có thể chia sẻ và tái sử dụng).

Ngoài ra, một số thuật toán đệ quy sử dụng "Đánh giá lười biếng" giúp chúng hiệu quả hơn so với các anh em lặp đi lặp lại của chúng. Điều này có nghĩa là họ chỉ thực hiện các phép tính đắt tiền tại thời điểm họ cần chứ không phải mỗi lần vòng lặp chạy.

Điều đó là đủ để bạn bắt đầu. Tôi cũng sẽ đào một số bài báo và ví dụ cho bạn.

Liên kết 1: Haskel vs PHP (Recursion vs Iteration)

Dưới đây là một ví dụ trong đó lập trình viên phải xử lý một tập dữ liệu lớn bằng PHP. Anh ta cho thấy việc đối phó với Haskel dễ dàng như thế nào bằng cách sử dụng đệ quy, nhưng vì PHP không có cách nào dễ dàng để thực hiện cùng một phương thức, anh ta buộc phải sử dụng phép lặp để có kết quả.

http://blog.webspecies.co.uk/2011-05-31/lazy-evalu-with-php.html

Liên kết 2: Làm chủ đệ quy

Hầu hết danh tiếng xấu của đệ quy đến từ chi phí cao và kém hiệu quả trong các ngôn ngữ bắt buộc. Tác giả của bài viết này nói về cách tối ưu hóa các thuật toán đệ quy để làm cho chúng nhanh hơn và hiệu quả hơn. Ông cũng tìm hiểu làm thế nào để chuyển đổi một vòng lặp truyền thống thành một hàm đệ quy và lợi ích của việc sử dụng đệ quy đuôi. Những lời kết thúc của anh ấy thực sự tóm tắt một số điểm chính của tôi, tôi nghĩ:

"lập trình đệ quy cung cấp cho lập trình viên một cách tổ chức mã tốt hơn theo cách vừa có thể duy trì vừa phù hợp về mặt logic."

https://developer.ibm.com/articles/l-recurs/

Liên kết 3: Đệ quy có nhanh hơn lặp không? (Câu trả lời)

Đây là một liên kết đến một câu trả lời cho một câu hỏi stackoverflow tương tự như của bạn. Tác giả chỉ ra rằng rất nhiều điểm chuẩn liên quan đến đệ quy hoặc lặp là rất cụ thể về ngôn ngữ. Các ngôn ngữ bắt buộc thường nhanh hơn bằng cách sử dụng vòng lặp và chậm hơn với đệ quy và ngược lại cho các ngôn ngữ chức năng. Tôi đoán điểm chính cần rút ra từ liên kết này là rất khó để trả lời câu hỏi theo nghĩa bất khả tri / tình huống mù ngôn ngữ.

Là đệ quy bao giờ nhanh hơn vòng lặp?


4
Thực sự thích sự tương tự tuốc nơ vít
jh314


16

Đệ quy sẽ tốn kém hơn trong bộ nhớ, vì mỗi cuộc gọi đệ quy thường yêu cầu một địa chỉ bộ nhớ được đẩy lên ngăn xếp - để sau đó chương trình có thể quay lại điểm đó.

Tuy nhiên, có nhiều trường hợp trong đó đệ quy là tự nhiên và dễ đọc hơn rất nhiều so với các vòng lặp - như khi làm việc với cây. Trong những trường hợp này, tôi khuyên bạn nên bám vào đệ quy.


5
Tất nhiên trừ khi trình biên dịch của bạn tối ưu hóa các cuộc gọi đuôi như Scala.
Ben Hardy

11

Thông thường, người ta sẽ mong đợi hình phạt hiệu suất nằm ở hướng khác. Các cuộc gọi đệ quy có thể dẫn đến việc xây dựng các khung ngăn xếp bổ sung; hình phạt cho việc này khác nhau. Ngoài ra, trong một số ngôn ngữ như Python (chính xác hơn, trong một số triển khai của một số ngôn ngữ ...), bạn có thể chạy vào giới hạn ngăn xếp khá dễ dàng cho các tác vụ bạn có thể chỉ định đệ quy, chẳng hạn như tìm giá trị tối đa trong cấu trúc dữ liệu cây. Trong những trường hợp này, bạn thực sự muốn gắn bó với các vòng lặp.

Viết các hàm đệ quy tốt có thể giảm phần nào hiệu suất phạt, giả sử bạn có trình biên dịch tối ưu hóa thu hồi đuôi, v.v. (Cũng kiểm tra kỹ để đảm bảo rằng hàm thực sự là đệ quy đuôi --- đó là một trong những điều mà nhiều người mắc lỗi trên.)

Ngoài các trường hợp "cạnh" (tính toán hiệu năng cao, độ sâu đệ quy rất lớn, v.v.), tốt nhất nên áp dụng phương pháp thể hiện rõ nhất ý định của bạn, được thiết kế tốt và có thể duy trì. Tối ưu hóa chỉ sau khi xác định một nhu cầu.


8

Đệ quy tốt hơn lặp lại cho các vấn đề có thể được chia thành nhiều phần nhỏ hơn.

Ví dụ, để thực hiện thuật toán Fibonnaci đệ quy, bạn chia sợi (n) thành sợi (n-1) và sợi (n-2) và tính cả hai phần. Lặp lại chỉ cho phép bạn lặp đi lặp lại một chức năng duy nhất.

Tuy nhiên, Fibonacci thực sự là một ví dụ bị hỏng và tôi nghĩ rằng việc lặp lại thực sự hiệu quả hơn. Lưu ý rằng sợi (n) = sợi (n-1) + sợi (n-2) và sợi (n-1) = sợi (n-2) + sợi (n-3). sợi (n-1) được tính hai lần!

Một ví dụ tốt hơn là một thuật toán đệ quy cho một cây. Vấn đề phân tích nút cha có thể được chia thành nhiều vấn đề nhỏ hơn để phân tích từng nút con. Không giống như ví dụ về Fibonacci, các vấn đề nhỏ hơn độc lập với nhau.

Vì vậy, yeah - đệ quy tốt hơn lặp đi lặp lại cho các vấn đề có thể được chia thành nhiều vấn đề tương tự, nhỏ hơn, độc lập, tương tự.


1
Việc tính toán hai lần thực sự có thể tránh được thông qua việc ghi nhớ.
Siddhartha

7

Hiệu suất của bạn giảm khi sử dụng đệ quy vì gọi một phương thức, trong bất kỳ ngôn ngữ nào, ngụ ý rất nhiều sự chuẩn bị: mã cuộc gọi gửi địa chỉ trả về, tham số cuộc gọi, một số thông tin ngữ cảnh khác như thanh ghi bộ xử lý có thể được lưu ở đâu đó và tại thời điểm trả về phương thức được gọi gửi một giá trị trả về sau đó được người gọi lấy ra và bất kỳ thông tin ngữ cảnh nào được lưu trước đó sẽ được khôi phục. hiệu suất khác nhau giữa cách tiếp cận lặp và đệ quy nằm ở thời gian các thao tác này thực hiện.

Từ quan điểm thực hiện, bạn thực sự bắt đầu nhận thấy sự khác biệt khi thời gian xử lý bối cảnh cuộc gọi tương đương với thời gian cần thiết để phương thức của bạn thực thi. Nếu phương thức đệ quy của bạn mất nhiều thời gian hơn để thực thi thì phần quản lý ngữ cảnh gọi, hãy đi theo cách đệ quy vì mã thường dễ đọc và dễ hiểu hơn và bạn sẽ không nhận thấy mất hiệu năng. Nếu không đi lặp đi lặp lại vì lý do hiệu quả.


Điều đó không đúng luôn. Đệ quy có thể hiệu quả như lặp lại đối với một số trường hợp có thể thực hiện tối ưu hóa cuộc gọi đuôi. stackoverflow.com/questions/310974/ cường
Sid Kshatriya

6

Tôi tin rằng đệ quy đuôi trong java hiện không được tối ưu hóa. Các chi tiết được rắc trong suốt cuộc thảo luận này về LtU và các liên kết liên quan. Nó có thể là một tính năng trong phiên bản 7 sắp tới, nhưng rõ ràng nó có một số khó khăn nhất định khi kết hợp với Kiểm tra ngăn xếp vì các khung nhất định sẽ bị thiếu. Kiểm tra ngăn xếp đã được sử dụng để triển khai mô hình bảo mật chi tiết của chúng kể từ Java 2.

http://lambda-the-ultimate.org/node/1333


Có JVM cho Java tối ưu hóa đệ quy đuôi. ibm.com/developerworks/java/l Library / j
Liran Orevi

5

Có nhiều trường hợp nó đưa ra một giải pháp tao nhã hơn nhiều so với phương pháp lặp, ví dụ phổ biến là truyền qua cây nhị phân, do đó không nhất thiết phải khó duy trì hơn. Nói chung, các phiên bản lặp thường nhanh hơn một chút (và trong quá trình tối ưu hóa cũng có thể thay thế phiên bản đệ quy), nhưng các phiên bản đệ quy đơn giản hơn để hiểu và thực hiện chính xác.


5

Đệ quy rất hữu ích là một số tình huống. Ví dụ, hãy xem xét mã để tìm giai thừa

int factorial ( int input )
{
  int x, fact = 1;
  for ( x = input; x > 1; x--)
     fact *= x;
  return fact;
}

Bây giờ hãy xem xét nó bằng cách sử dụng hàm đệ quy

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

Bằng cách quan sát hai điều này, chúng ta có thể thấy rằng đệ quy là dễ hiểu. Nhưng nếu nó không được sử dụng cẩn thận thì nó cũng có thể dễ bị lỗi. Giả sử nếu chúng ta bỏ lỡ if (input == 0), thì mã sẽ được thực thi trong một thời gian và kết thúc với thường là tràn ngăn xếp.


6
Tôi thực sự tìm thấy phiên bản lặp dễ hiểu hơn. Đối với mỗi anh ấy, tôi cho rằng.
Tối đa

@Maxpm, một giải pháp đệ quy bậc cao tốt hơn nhiều : foldl (*) 1 [1..n], đó là nó.
SK-logic

5

Trong nhiều trường hợp, đệ quy nhanh hơn vì bộ nhớ đệm, giúp cải thiện hiệu suất. Ví dụ, đây là một phiên bản lặp của sắp xếp hợp nhất bằng cách sử dụng thói quen hợp nhất truyền thống. Nó sẽ chạy chậm hơn so với việc thực hiện đệ quy vì bộ nhớ đệm được cải thiện hiệu suất.

Lặp đi lặp lại

public static void sort(Comparable[] a)
{
    int N = a.length;
    aux = new Comparable[N];
    for (int sz = 1; sz < N; sz = sz+sz)
        for (int lo = 0; lo < N-sz; lo += sz+sz)
            merge(a, lo, lo+sz-1, Math.min(lo+sz+sz-1, N-1));
}

Thực hiện đệ quy

private static void sort(Comparable[] a, Comparable[] aux, int lo, int hi)
{
    if (hi <= lo) return;
    int mid = lo + (hi - lo) / 2;
    sort(a, aux, lo, mid);
    sort(a, aux, mid+1, hi);
    merge(a, aux, lo, mid, hi);
}

Tái bút - đây là những gì đã được Giáo sư Kevin Wayne (Đại học Princeton) nói về khóa học về các thuật toán được trình bày trên Coursera.


4

Sử dụng đệ quy, bạn phải chịu chi phí của một cuộc gọi hàm với mỗi "lần lặp", trong khi với một vòng lặp, điều duy nhất bạn thường trả là tăng / giảm. Vì vậy, nếu mã cho vòng lặp không phức tạp hơn nhiều so với mã cho giải pháp đệ quy, vòng lặp thường sẽ vượt trội hơn so với đệ quy.


1
Trên thực tế, hàm đệ quy Scala được biên dịch rút gọn thành một vòng lặp trong mã byte, nếu bạn quan tâm đến việc xem xét chúng (được khuyến nghị). Không có chức năng gọi qua đầu. Thứ hai, các hàm đệ quy đuôi có ưu điểm là không yêu cầu các biến / tác dụng phụ có thể thay đổi hoặc các vòng lặp rõ ràng, làm cho tính chính xác dễ dàng hơn nhiều để chứng minh.
Ben Hardy

4

Đệ quy và lặp lại phụ thuộc vào logic nghiệp vụ mà bạn muốn thực hiện, mặc dù trong hầu hết các trường hợp, nó có thể được sử dụng thay thế cho nhau. Hầu hết các nhà phát triển đi đệ quy vì nó dễ hiểu hơn.


4

Nó phụ thuộc vào ngôn ngữ. Trong Java, bạn nên sử dụng các vòng lặp. Ngôn ngữ chức năng tối ưu hóa đệ quy.


3

Nếu bạn chỉ lặp đi lặp lại qua một danh sách, thì chắc chắn, lặp đi lặp lại.

Một vài câu trả lời khác đã đề cập đến việc đi ngang qua cây (chiều sâu trước). Nó thực sự là một ví dụ tuyệt vời, bởi vì đó là một điều rất phổ biến đối với cấu trúc dữ liệu rất phổ biến. Đệ quy là cực kỳ trực quan cho vấn đề này.

Kiểm tra các phương pháp "tìm" tại đây: http://penguin.ewu.edu/cscd300/Topic/BSTintro/index.html


3

Đệ quy đơn giản hơn (và do đó - cơ bản hơn) so với bất kỳ định nghĩa có thể có của phép lặp. Bạn có thể định nghĩa một hệ thống hoàn chỉnh Turing chỉ với một cặp tổ hợp (vâng, ngay cả bản thân đệ quy cũng là một khái niệm phái sinh trong một hệ thống như vậy). Tính toán Lambda là một hệ thống cơ bản mạnh mẽ không kém, có các hàm đệ quy. Nhưng nếu bạn muốn xác định một lần lặp đúng, bạn cần bắt đầu nhiều hơn nữa.

Đối với mã - không, mã đệ quy trên thực tế dễ hiểu và dễ bảo trì hơn nhiều so với mã lặp hoàn toàn, vì hầu hết các cấu trúc dữ liệu đều được đệ quy. Tất nhiên, để làm cho đúng, người ta sẽ cần một ngôn ngữ với sự hỗ trợ cho các chức năng và đóng cửa bậc cao, ít nhất - để có được tất cả các tổ hợp và trình lặp chuẩn một cách gọn gàng. Tất nhiên, trong C ++, các giải pháp đệ quy phức tạp có thể trông hơi xấu, trừ khi bạn là người dùng khó tính của FC ++ và như nhau.


Mã đệ quy có thể cực kỳ khó theo dõi, đặc biệt nếu thứ tự của các tham số thay đổi hoặc các loại với mỗi đệ quy. Mã lặp có thể rất đơn giản và mô tả. Điều quan trọng là mã hóa cho khả năng đọc (và do đó độ tin cậy) trước tiên, cho dù lặp hoặc đệ quy, sau đó tối ưu hóa nếu cần thiết.
Marcus Clements

2

Tôi nghĩ rằng đệ quy (không đuôi) sẽ có một cú đánh hiệu năng để phân bổ một ngăn xếp mới, v.v ... mỗi khi hàm được gọi (tất nhiên phụ thuộc vào ngôn ngữ).


2

nó phụ thuộc vào "độ sâu đệ quy". nó phụ thuộc vào mức độ chi phí của hàm gọi sẽ ảnh hưởng đến tổng thời gian thực hiện.

Ví dụ, tính toán giai thừa cổ điển theo cách đệ quy rất không hiệu quả do: - nguy cơ tràn dữ liệu - nguy cơ tràn ngăn xếp - tổng phí gọi hàm chiếm 80% thời gian thực hiện

trong khi phát triển thuật toán tối thiểu để phân tích vị trí trong trò chơi cờ vua sẽ phân tích các động tác N tiếp theo có thể được thực hiện theo đệ quy trên "độ sâu phân tích" (như tôi đang làm ^ _ ^)


hoàn toàn đồng ý với ugasoft ở đây ... nó phụ thuộc vào độ sâu đệ quy .... và độ phức tạp của việc thực hiện lặp lại của nó ... bạn cần so sánh cả hai và xem cái nào hiệu quả hơn ... Không có quy tắc ngón tay cái như vậy. ..
rajya vardhan

2

Đệ quy? Tôi phải bắt đầu từ đâu, wiki sẽ cho bạn biết đó là quá trình lặp lại các mục theo cách tự tương tự "

Ngày trước khi tôi đang làm C, đệ quy C ++ là một vị thần gửi, đại loại như "Đệ quy đuôi". Bạn cũng sẽ tìm thấy nhiều thuật toán sắp xếp sử dụng đệ quy. Ví dụ sắp xếp nhanh: http://alienryderflex.com/quicksort/

Đệ quy giống như bất kỳ thuật toán nào khác hữu ích cho một vấn đề cụ thể. Có lẽ bạn có thể không tìm thấy việc sử dụng ngay lập tức hoặc thường xuyên nhưng sẽ có vấn đề bạn sẽ vui mừng vì nó có sẵn.


Tôi nghĩ rằng bạn đã tối ưu hóa trình biên dịch lạc hậu. Trình biên dịch sẽ tối ưu hóa các hàm đệ quy thành một vòng lặp khi có thể để tránh sự tăng trưởng ngăn xếp.
CoderDennis

Điểm công bằng, nó đã lạc hậu. Tuy nhiên tôi không chắc điều đó vẫn có thể áp dụng cho đệ quy đuôi.
Nickz

2

Trong C ++ nếu hàm đệ quy là một templated, thì trình biên dịch có nhiều cơ hội để tối ưu hóa nó hơn, vì tất cả các kiểu khấu trừ và khởi tạo hàm sẽ xảy ra trong thời gian biên dịch. Trình biên dịch hiện đại cũng có thể nội tuyến chức năng nếu có thể. Vì vậy, nếu người ta sử dụng các cờ tối ưu hóa như -O3hoặc -O2trong g++, thì việc thu hồi có thể có cơ hội nhanh hơn các lần lặp. Trong các mã lặp, trình biên dịch sẽ có ít cơ hội hơn để tối ưu hóa nó, vì nó đã ở trạng thái tối ưu ít nhiều (nếu được viết đủ tốt).

Trong trường hợp của tôi, tôi đã cố gắng thực hiện lũy thừa ma trận bằng cách bình phương bằng cách sử dụng các đối tượng ma trận Armadillo, theo cả cách đệ quy và lặp. Thuật toán có thể được tìm thấy ở đây ... https://en.wikipedia.org/wiki/Exponentiation_by_squared . Các chức năng của tôi đã được tạo khuôn mẫu và tôi đã tính toán 1,000,000 12x12các ma trận được nâng lên thành sức mạnh 10. Tôi đã nhận được kết quả sau:

iterative + optimisation flag -O3 -> 2.79.. sec
recursive + optimisation flag -O3 -> 1.32.. sec

iterative + No-optimisation flag  -> 2.83.. sec
recursive + No-optimisation flag  -> 4.15.. sec

Những kết quả này đã thu được bằng cách sử dụng gcc-4.8 với cờ c ++ 11 ( -std=c++11) và Armadillo 6.1 với Intel mkl. Trình biên dịch Intel cũng cho thấy kết quả tương tự.


1

Mike đúng. Đệ quy đuôi không được tối ưu hóa bởi trình biên dịch Java hoặc JVM. Bạn sẽ luôn nhận được một ngăn xếp tràn với thứ như thế này:

int count(int i) {
  return i >= 100000000 ? i : count(i+1);
}

3
Trừ khi bạn viết nó bằng Scala ;-)
Ben Hardy

1

Bạn phải nhớ rằng sử dụng đệ quy quá sâu, bạn sẽ chạy vào Stack Overflow, tùy thuộc vào kích thước ngăn xếp được phép. Để ngăn chặn điều này, hãy đảm bảo cung cấp một số trường hợp cơ bản kết thúc bạn đệ quy.


1

Đệ quy có một nhược điểm là thuật toán mà bạn viết sử dụng đệ quy có độ phức tạp không gian O (n). Trong khi aproach lặp có độ phức tạp không gian là O (1). Đây là ưu điểm của việc sử dụng phép lặp trên đệ quy. Vậy thì tại sao chúng ta sử dụng đệ quy?

Xem bên dưới.

Đôi khi, việc viết một thuật toán bằng cách sử dụng đệ quy sẽ dễ dàng hơn trong khi viết thuật toán tương tự khó hơn một chút. Trong trường hợp này nếu bạn chọn thực hiện theo phương pháp lặp, bạn sẽ phải tự xử lý stack.


1

Nếu các lần lặp là nguyên tử và các đơn đặt hàng có cường độ đắt hơn so với việc đẩy khung ngăn xếp mới tạo ra một luồng mới bạn có nhiều lõi môi trường thời gian chạy của bạn có thể sử dụng tất cả chúng, thì cách tiếp cận đệ quy có thể tăng hiệu suất rất lớn khi kết hợp với đa luồng Nếu số lần lặp trung bình không thể dự đoán được thì có lẽ nên sử dụng nhóm luồng sẽ kiểm soát phân bổ luồng và ngăn quá trình của bạn tạo quá nhiều luồng và làm hỏng hệ thống.

Ví dụ, trong một số ngôn ngữ, có các triển khai sắp xếp hợp nhất đa luồng đệ quy.

Nhưng một lần nữa, đa luồng có thể được sử dụng với vòng lặp thay vì đệ quy, do đó, sự kết hợp này sẽ hoạt động tốt như thế nào phụ thuộc vào nhiều yếu tố bao gồm HĐH và cơ chế phân bổ luồng của nó.


0

Theo tôi biết, Perl không tối ưu hóa các cuộc gọi đệ quy đuôi, nhưng bạn có thể giả mạo nó.

sub f{
  my($l,$r) = @_;

  if( $l >= $r ){
    return $l;
  } else {

    # return f( $l+1, $r );

    @_ = ( $l+1, $r );
    goto &f;

  }
}

Khi lần đầu tiên được gọi, nó sẽ phân bổ không gian trên ngăn xếp. Sau đó, nó sẽ thay đổi các đối số của nó và khởi động lại chương trình con, mà không cần thêm bất cứ điều gì nữa vào ngăn xếp. Do đó, nó sẽ giả vờ rằng nó không bao giờ tự gọi nó, thay đổi nó thành một quá trình lặp đi lặp lại.

Lưu ý rằng không có " my @_;" hoặc " local @_;", nếu bạn đã làm nó sẽ không còn hoạt động.


0

Chỉ sử dụng Chrome 45.0.2454,85 ​​m, đệ quy dường như là một số tiền nhanh hơn.

Đây là mã:

(function recursionVsForLoop(global) {
    "use strict";

    // Perf test
    function perfTest() {}

    perfTest.prototype.do = function(ns, fn) {
        console.time(ns);
        fn();
        console.timeEnd(ns);
    };

    // Recursion method
    (function recur() {
        var count = 0;
        global.recurFn = function recurFn(fn, cycles) {
            fn();
            count = count + 1;
            if (count !== cycles) recurFn(fn, cycles);
        };
    })();

    // Looped method
    function loopFn(fn, cycles) {
        for (var i = 0; i < cycles; i++) {
            fn();
        }
    }

    // Tests
    var curTest = new perfTest(),
        testsToRun = 100;

    curTest.do('recursion', function() {
        recurFn(function() {
            console.log('a recur run.');
        }, testsToRun);
    });

    curTest.do('loop', function() {
        loopFn(function() {
            console.log('a loop run.');
        }, testsToRun);
    });

})(window);

CÁC KẾT QUẢ

// 100 chạy bằng cách sử dụng tiêu chuẩn cho vòng lặp

100x cho vòng lặp chạy. Thời gian hoàn thành: 7.683ms

// 100 chạy bằng cách sử dụng phương pháp đệ quy chức năng w / đệ quy đuôi

100x đệ quy chạy. Thời gian hoàn thành: 4,841ms

Trong ảnh chụp màn hình bên dưới, đệ quy lại chiến thắng với mức chênh lệch lớn hơn khi chạy ở 300 chu kỳ trên mỗi bài kiểm tra

Đệ quy lại chiến thắng!


Kiểm tra không hợp lệ vì bạn đang gọi hàm bên trong hàm vòng lặp - điều này làm mất hiệu lực một trong những lợi thế hiệu suất nổi bật nhất của vòng lặp đó là thiếu các lệnh nhảy (bao gồm, đối với các lệnh gọi hàm, gán ngăn xếp, popping ngăn xếp, v.v.). Nếu bạn đang thực hiện một nhiệm vụ trong một vòng lặp (không chỉ được gọi là một hàm) so với thực hiện một nhiệm vụ trong một hàm đệ quy, bạn sẽ nhận được các kết quả khác nhau. (Hiệu suất PS là một câu hỏi của thuật toán tác vụ thực tế, trong đó đôi khi các lệnh nhảy rẻ hơn thì tính toán cần thiết để tránh chúng).
Bí ẩn

0

Tôi tìm thấy một sự khác biệt khác giữa những cách tiếp cận đó. Trông có vẻ đơn giản và không quan trọng, nhưng nó có một vai trò rất quan trọng trong khi bạn chuẩn bị cho các cuộc phỏng vấn và chủ đề này phát sinh, vì vậy hãy xem xét kỹ.

Tóm lại: 1) chuyển đổi theo thứ tự lặp lại không dễ dàng - điều đó làm cho DFT phức tạp hơn 2) kiểm tra chu kỳ dễ dàng hơn với đệ quy

Chi tiết:

Trong trường hợp đệ quy, thật dễ dàng để tạo các giao dịch trước và sau:

Hãy tưởng tượng một câu hỏi khá chuẩn: "in tất cả các tác vụ nên được thực thi để thực thi tác vụ 5, khi các tác vụ phụ thuộc vào các tác vụ khác"

Thí dụ:

    //key-task, value-list of tasks the key task depends on
    //"adjacency map":
    Map<Integer, List<Integer>> tasksMap = new HashMap<>();
    tasksMap.put(0, new ArrayList<>());
    tasksMap.put(1, new ArrayList<>());

    List<Integer> t2 = new ArrayList<>();
    t2.add(0);
    t2.add(1);
    tasksMap.put(2, t2);

    List<Integer> t3 = new ArrayList<>();
    t3.add(2);
    t3.add(10);
    tasksMap.put(3, t3);

    List<Integer> t4 = new ArrayList<>();
    t4.add(3);
    tasksMap.put(4, t4);

    List<Integer> t5 = new ArrayList<>();
    t5.add(3);
    tasksMap.put(5, t5);

    tasksMap.put(6, new ArrayList<>());
    tasksMap.put(7, new ArrayList<>());

    List<Integer> t8 = new ArrayList<>();
    t8.add(5);
    tasksMap.put(8, t8);

    List<Integer> t9 = new ArrayList<>();
    t9.add(4);
    tasksMap.put(9, t9);

    tasksMap.put(10, new ArrayList<>());

    //task to analyze:
    int task = 5;


    List<Integer> res11 = getTasksInOrderDftReqPostOrder(tasksMap, task);
    System.out.println(res11);**//note, no reverse required**

    List<Integer> res12 = getTasksInOrderDftReqPreOrder(tasksMap, task);
    Collections.reverse(res12);//note reverse!
    System.out.println(res12);

    private static List<Integer> getTasksInOrderDftReqPreOrder(Map<Integer, List<Integer>> tasksMap, int task) {
         List<Integer> result = new ArrayList<>();
         Set<Integer> visited = new HashSet<>();
         reqPreOrder(tasksMap,task,result, visited);
         return result;
    }

private static void reqPreOrder(Map<Integer, List<Integer>> tasksMap, int task, List<Integer> result, Set<Integer> visited) {

    if(!visited.contains(task)) {
        visited.add(task);
        result.add(task);//pre order!
        List<Integer> children = tasksMap.get(task);
        if (children != null && children.size() > 0) {
            for (Integer child : children) {
                reqPreOrder(tasksMap,child,result, visited);
            }
        }
    }
}

private static List<Integer> getTasksInOrderDftReqPostOrder(Map<Integer, List<Integer>> tasksMap, int task) {
    List<Integer> result = new ArrayList<>();
    Set<Integer> visited = new HashSet<>();
    reqPostOrder(tasksMap,task,result, visited);
    return result;
}

private static void reqPostOrder(Map<Integer, List<Integer>> tasksMap, int task, List<Integer> result, Set<Integer> visited) {
    if(!visited.contains(task)) {
        visited.add(task);
        List<Integer> children = tasksMap.get(task);
        if (children != null && children.size() > 0) {
            for (Integer child : children) {
                reqPostOrder(tasksMap,child,result, visited);
            }
        }
        result.add(task);//post order!
    }
}

Lưu ý rằng đệ quy theo thứ tự đệ quy không yêu cầu đảo ngược kết quả sau đó. Trẻ em in trước và nhiệm vụ của bạn trong câu hỏi được in cuối cùng. Mọi thứ đều ổn. Bạn có thể thực hiện chuyển đổi theo thứ tự đệ quy (cũng được hiển thị ở trên) và người ta sẽ yêu cầu đảo ngược danh sách kết quả.

Không đơn giản với cách tiếp cận lặp đi lặp lại! Trong phương pháp lặp (một ngăn xếp), bạn chỉ có thể thực hiện đặt hàng trước, vì vậy bạn bắt buộc phải đảo ngược mảng kết quả ở cuối:

    List<Integer> res1 = getTasksInOrderDftStack(tasksMap, task);
    Collections.reverse(res1);//note reverse!
    System.out.println(res1);

    private static List<Integer> getTasksInOrderDftStack(Map<Integer, List<Integer>> tasksMap, int task) {
    List<Integer> result = new ArrayList<>();
    Set<Integer> visited = new HashSet<>();
    Stack<Integer> st = new Stack<>();


    st.add(task);
    visited.add(task);

    while(!st.isEmpty()){
        Integer node = st.pop();
        List<Integer> children = tasksMap.get(node);
        result.add(node);
        if(children!=null && children.size() > 0){
            for(Integer child:children){
                if(!visited.contains(child)){
                    st.add(child);
                    visited.add(child);
                }
            }
        }
        //If you put it here - it does not matter - it is anyway a pre-order
        //result.add(node);
    }
    return result;
}

Trông đơn giản phải không?

Nhưng nó là một cái bẫy trong một số cuộc phỏng vấn.

Nó có nghĩa như sau: với cách tiếp cận đệ quy, bạn có thể triển khai Depth First Traversal và sau đó chọn thứ tự bạn cần trước hoặc đăng (đơn giản bằng cách thay đổi vị trí của "bản in", trong trường hợp của chúng tôi là "thêm vào danh sách kết quả" ). Với cách tiếp cận lặp (một ngăn xếp), bạn có thể dễ dàng thực hiện chỉ cần đặt hàng trước và trong trường hợp khi trẻ cần in trước (khá nhiều tình huống khi bạn cần bắt đầu in từ các nút dưới cùng, đi lên trên) - bạn đang ở trong rắc rối. Nếu bạn gặp rắc rối đó, bạn có thể đảo ngược lại sau, nhưng nó sẽ là một bổ sung cho thuật toán của bạn. Và nếu một người phỏng vấn đang nhìn vào đồng hồ của anh ta, nó có thể là một vấn đề cho bạn. Có nhiều cách phức tạp để thực hiện một giao dịch lặp theo thứ tự lặp, chúng tồn tại, nhưng chúng không đơn giản . Thí dụ:https://www.geekforgeek.org/iterative-postorder-traversal-USE-stack/

Do đó, điểm mấu chốt: Tôi sẽ sử dụng đệ quy trong các cuộc phỏng vấn, việc quản lý và giải thích sẽ đơn giản hơn. Bạn có một cách dễ dàng để đi từ trước đến sau khi đặt hàng trong bất kỳ trường hợp khẩn cấp nào. Với phép lặp bạn không linh hoạt.

Tôi sẽ sử dụng đệ quy và sau đó nói: "Ok, nhưng lặp có thể cung cấp cho tôi quyền kiểm soát trực tiếp hơn trên bộ nhớ đã sử dụng, tôi có thể dễ dàng đo kích thước ngăn xếp và không cho phép một số tràn nguy hiểm .."

Một điểm cộng khác của đệ quy - đơn giản hơn là tránh / thông báo các chu kỳ trong biểu đồ.

Ví dụ (preudocode):

dft(n){
    mark(n)
    for(child: n.children){
        if(marked(child)) 
            explode - cycle found!!!
        dft(child)
    }
    unmark(n)
}

0

Nó có thể là niềm vui để viết nó như là đệ quy, hoặc là một thực hành.

Tuy nhiên, nếu mã được sử dụng trong sản xuất, bạn cần xem xét khả năng tràn ngăn xếp.

Tối ưu hóa đệ quy đuôi có thể loại bỏ tràn ngăn xếp, nhưng bạn có muốn trải qua sự cố khi làm như vậy không, và bạn cần biết bạn có thể tin tưởng vào việc nó có tối ưu hóa trong môi trường của bạn không.

Mỗi lần thuật toán đệ quy, kích thước dữ liệu ngiảm đi bao nhiêu?

Nếu bạn đang giảm kích thước dữ liệu hoặc ngiảm một nửa mỗi lần bạn lặp lại, thì nói chung, bạn không cần phải lo lắng về việc tràn ngăn xếp. Giả sử, nếu nó cần sâu 4.000 cấp hoặc sâu 10.000 cấp để chương trình xếp chồng tràn, thì kích thước dữ liệu của bạn cần khoảng 2 4000 để chương trình của bạn xếp chồng tràn. Để đặt điều đó vào viễn cảnh, một thiết bị lưu trữ lớn nhất gần đây có thể chứa 2 61 byte và nếu bạn có 2 61 thiết bị như vậy, bạn chỉ đang xử lý với 2 122 kích thước dữ liệu. Nếu bạn đang xem xét tất cả các nguyên tử trong vũ trụ, ước tính nó có thể nhỏ hơn 2 84. Nếu bạn cần xử lý tất cả dữ liệu trong vũ trụ và trạng thái của chúng trong mỗi mili giây kể từ khi vũ trụ ra đời ước tính là 14 tỷ năm trước, thì đó chỉ có thể là 2 153 . Vì vậy, nếu chương trình của bạn có thể xử lý 2 4000 đơn vị dữ liệu hoặc n, bạn có thể xử lý tất cả dữ liệu trong vũ trụ và chương trình sẽ không chồng tràn. Nếu bạn không cần phải xử lý các số lớn bằng 2 4000 (số nguyên 4000 bit), thì nói chung, bạn không cần phải lo lắng về việc tràn ngăn xếp.

Tuy nhiên, nếu bạn giảm kích thước dữ liệu hoặc nmột lượng không đổi mỗi lần bạn lặp lại, thì bạn có thể chạy vào ngăn xếp tràn khi chương trình của bạn chạy tốt khi n1000nhưng trong một số trường hợp, khi ntrở nên đơn thuần 20000.

Vì vậy, nếu bạn có khả năng tràn stack, hãy thử biến nó thành một giải pháp lặp lại.


-1

Tôi sẽ trả lời câu hỏi của bạn bằng cách thiết kế cấu trúc dữ liệu Haskell bằng "cảm ứng", đây là một loại "kép" để đệ quy. Và sau đó tôi sẽ chỉ ra làm thế nào sự đối ngẫu này dẫn đến những điều tốt đẹp.

Chúng tôi giới thiệu một loại cho một cây đơn giản:

data Tree a = Branch (Tree a) (Tree a)
            | Leaf a
            deriving (Eq)

Chúng ta có thể đọc định nghĩa này khi nói "Cây là một nhánh (chứa hai cây) hoặc là một chiếc lá (chứa giá trị dữ liệu)". Vì vậy, chiếc lá là một loại trường hợp tối thiểu. Nếu một cây không phải là một chiếc lá, thì đó phải là một cây hỗn hợp chứa hai cây. Đây là những trường hợp duy nhất.

Hãy làm một cái cây:

example :: Tree Int
example = Branch (Leaf 1) 
                 (Branch (Leaf 2) 
                         (Leaf 3))

Bây giờ, giả sử chúng ta muốn thêm 1 vào mỗi giá trị trong cây. Chúng tôi có thể làm điều này bằng cách gọi:

addOne :: Tree Int -> Tree Int
addOne (Branch a b) = Branch (addOne a) (addOne b)
addOne (Leaf a)     = Leaf (a + 1)

Đầu tiên, lưu ý rằng trên thực tế đây là một định nghĩa đệ quy. Nó lấy các hàm tạo dữ liệu Branch và Leaf làm trường hợp (và vì Leaf là tối thiểu và đây là những trường hợp có thể duy nhất), chúng tôi chắc chắn rằng hàm sẽ chấm dứt.

Điều gì sẽ cần để viết addOne theo kiểu lặp? Điều gì sẽ lặp vào một số nhánh tùy ý trông như thế nào?

Ngoài ra, loại đệ quy này thường có thể được tính đến, theo thuật ngữ của "functor". Chúng ta có thể biến Cây thành Functor bằng cách định nghĩa:

instance Functor Tree where fmap f (Leaf a)     = Leaf (f a)
                            fmap f (Branch a b) = Branch (fmap f a) (fmap f b)

và xác định:

addOne' = fmap (+1)

Chúng ta có thể xác định các sơ đồ đệ quy khác, chẳng hạn như catamorphism (hoặc gấp) cho một kiểu dữ liệu đại số. Sử dụng một dị giáo, chúng ta có thể viết:

addOne'' = cata go where
           go (Leaf a) = Leaf (a + 1)
           go (Branch a b) = Branch a b

-2

Tràn ngăn xếp sẽ chỉ xảy ra nếu bạn lập trình bằng ngôn ngữ không có trong quản lý bộ nhớ được xây dựng .... Nếu không, hãy đảm bảo bạn có một cái gì đó trong chức năng của mình (hoặc gọi hàm, STDLbs, v.v.). Nếu không có đệ quy, đơn giản là không thể có những thứ như ... Google hoặc SQL, hoặc bất kỳ nơi nào người ta phải sắp xếp một cách hiệu quả thông qua các cấu trúc dữ liệu lớn (các lớp) hoặc cơ sở dữ liệu.

Đệ quy là cách để đi nếu bạn muốn lặp qua các tệp, khá chắc chắn đó là cách 'tìm * | ? grep * 'hoạt động. Loại đệ quy kép, đặc biệt là với đường ống (nhưng đừng tạo ra một loạt các tòa nhà như rất nhiều người muốn làm nếu đó là bất cứ điều gì bạn sẽ đưa ra cho người khác sử dụng).

Các ngôn ngữ cấp cao hơn và thậm chí clang / cpp có thể thực hiện nó giống nhau trong nền.


1
"Tràn ngăn xếp sẽ chỉ xảy ra nếu bạn lập trình bằng ngôn ngữ không có trong quản lý bộ nhớ được xây dựng" - không có ý nghĩa gì. Hầu hết các ngôn ngữ sử dụng ngăn xếp có kích thước hạn chế, do đó đệ quy sẽ dẫn đến thất bại khá sớm.
StaceyGirl
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.