Tại sao các vòng lặp nhanh hơn đệ quy?


17

Trong thực tế tôi hiểu rằng bất kỳ đệ quy nào cũng có thể được viết dưới dạng một vòng lặp (và ngược lại (?)) Và nếu chúng ta đo bằng các máy tính thực tế, chúng ta thấy rằng các vòng lặp nhanh hơn đệ quy cho cùng một vấn đề. Nhưng có lý thuyết nào tạo nên sự khác biệt này hay chủ yếu là dựa trên kinh nghiệm?


9
Trông chỉ nhanh hơn đệ quy trong các ngôn ngữ thực hiện chúng kém. Trong một ngôn ngữ có Đệ quy đuôi thích hợp, các chương trình đệ quy có thể được dịch thành các vòng lặp phía sau hậu trường, trong trường hợp đó sẽ không có sự khác biệt vì chúng giống hệt nhau.
jmite

3
Có, và nếu bạn sử dụng một ngôn ngữ hỗ trợ nó, bạn có thể sử dụng đệ quy (đuôi) mà không có bất kỳ hiệu ứng hiệu suất tiêu cực nào.
jmite

1
@jmite, đệ quy đuôi thực sự có thể được tối ưu hóa thành một vòng lặp là cực kỳ hiếm, hiếm hơn nhiều so với bạn nghĩ. Đặc biệt là trong các ngôn ngữ đã quản lý các loại như các biến đếm tham chiếu.
Johan - phục hồi Monica

1
Vì bạn đã bao gồm độ phức tạp thời gian của thẻ, tôi cảm thấy nên thêm rằng thuật toán có vòng lặp có độ phức tạp thời gian giống như thuật toán có đệ quy, nhưng với sau này, thời gian thực hiện sẽ cao hơn bởi một số yếu tố không đổi, tùy thuộc vào số lượng chi phí cho đệ quy.
Liêuwe Vinkhuijzen

2
Này, vì bạn đã thêm tiền thưởng với rất nhiều câu trả lời tốt gần như làm cạn kiệt tất cả các khả năng, có điều gì bạn cần hơn hoặc cảm thấy như một cái gì đó nên được làm rõ? Tôi không có nhiều để thêm, tôi có thể chỉnh sửa một số câu trả lời hoặc để lại nhận xét, vì vậy đây là câu hỏi chung chung (không phải cá nhân).
Ác

Câu trả lời:


17

Lý do các vòng lặp nhanh hơn đệ quy là dễ dàng.
Một vòng lặp trông như thế này trong lắp ráp.

mov loopcounter,i
dowork:/do work
dec loopcounter
jmp_if_not_zero dowork

Một bước nhảy có điều kiện duy nhất và một số sổ sách kế toán cho bộ đếm vòng lặp.

Đệ quy (khi trình biên dịch không được hoặc không thể tối ưu hóa) trông như thế này:

start_subroutine:
pop parameter1
pop parameter2
dowork://dowork
test something
jmp_if_true done
push parameter1
push parameter2
call start_subroutine
done:ret

Nó phức tạp hơn rất nhiều và bạn nhận được ít nhất 3 lần nhảy (1 bài kiểm tra để xem đã xong chưa, một cuộc gọi và một lần quay lại).
Ngoài ra trong đệ quy các tham số cần phải được thiết lập và tìm nạp.
Không có thứ gì trong số này là cần thiết trong một vòng lặp vì tất cả các tham số đã được thiết lập.

Về mặt lý thuyết, các tham số có thể giữ nguyên vị trí với đệ quy, nhưng không có trình biên dịch nào tôi biết thực sự đi xa đến mức tối ưu hóa chúng.

Sự khác nhau giữa một cuộc gọi và một jmp
Một cặp trả lại cuộc gọi không đắt hơn nhiều so với jmp. Cặp mất 2 chu kỳ và jmp mất 1; hầu như không đáng chú ý.
Trong các quy ước gọi hỗ trợ các tham số đăng ký, chi phí chung cho các tham số ở mức tối thiểu, nhưng ngay cả các tham số ngăn xếp cũng rẻ miễn là bộ đệm của CPU không bị tràn .
Đó là chi phí chung của thiết lập cuộc gọi được quyết định bởi quy ước gọi và xử lý tham số đang sử dụng làm chậm quá trình đệ quy.
Điều này phụ thuộc rất nhiều vào việc thực hiện.

Ví dụ về xử lý đệ quy kém Ví dụ: nếu một tham số được truyền có tính tham chiếu (ví dụ: tham số loại không được quản lý), nó sẽ thêm 100 chu kỳ thực hiện điều chỉnh khóa số tham chiếu, hoàn toàn giết hiệu suất so với vòng lặp.
Trong các ngôn ngữ được điều chỉnh để đệ quy hành vi xấu này không xảy ra.

Tối ưu hóa CPU
Một lý do khác đệ quy chậm hơn là nó hoạt động chống lại các cơ chế tối ưu hóa trong CPU.
Trả về chỉ có thể được dự đoán chính xác nếu không có quá nhiều trong số chúng liên tiếp. CPU có bộ đệm ngăn xếp trả về với một vài (vài) mục nhập. Một khi hết tiền, mỗi lần trả lại sẽ bị dự đoán sai gây ra sự chậm trễ lớn.
Trên bất kỳ CPU nào sử dụng đệ quy dựa trên bộ đệm trả về ngăn xếp vượt quá kích thước bộ đệm là tốt nhất nên tránh.

Giới thiệu về các ví dụ mã tầm thường bằng cách sử dụng đệ quy
Nếu bạn sử dụng một ví dụ tầm thường về đệ quy như tạo số Fibonacci, thì các hiệu ứng này không xảy ra, bởi vì bất kỳ trình biên dịch nào 'biết' về đệ quy sẽ biến nó thành một vòng lặp, giống như bất kỳ lập trình viên nào xứng đáng với muối của anh ta sẽ.
Nếu bạn chạy các ví dụ tầm thường này trong một môi trường không tối ưu hóa đúng cách hơn ngăn xếp cuộc gọi sẽ (không cần thiết) sẽ phát triển ngoài giới hạn.

Giới thiệu về đệ quy đuôi
Lưu ý rằng đôi khi trình biên dịch tối ưu hóa đệ quy đuôi bằng cách thay đổi nó thành một vòng lặp. Tốt nhất là chỉ dựa vào hành vi này trong các ngôn ngữ có hồ sơ theo dõi tốt đã biết về vấn đề này.
Nhiều ngôn ngữ chèn mã dọn dẹp ẩn trước khi trả về cuối cùng ngăn chặn tối ưu hóa đệ quy đuôi.

Nhầm lẫn giữa đệ quy đúng và giả
Nếu môi trường lập trình của bạn biến mã nguồn đệ quy của bạn thành một vòng lặp, thì có thể cho rằng không phải là đệ quy đúng đang được thực thi.
Đệ quy thực sự đòi hỏi một kho bánh mì, để thói quen đệ quy có thể theo dõi lại các bước của nó sau khi thoát.
Chính việc xử lý dấu vết này làm cho đệ quy chậm hơn so với sử dụng vòng lặp. Hiệu ứng này được phóng to bởi các triển khai CPU hiện tại như đã nêu ở trên.

Ảnh hưởng của môi trường lập trình
Nếu ngôn ngữ của bạn được điều chỉnh theo hướng tối ưu hóa đệ quy thì bằng mọi cách hãy tiếp tục và sử dụng đệ quy ở mọi cơ hội. Trong hầu hết các trường hợp, ngôn ngữ sẽ biến đệ quy của bạn thành một loại vòng lặp.
Trong những trường hợp không thể, lập trình viên cũng sẽ rất khó khăn. Nếu ngôn ngữ lập trình của bạn không được điều chỉnh theo đệ quy, thì nên tránh ngôn ngữ đó trừ khi miền phù hợp với đệ quy.
Thật không may, nhiều ngôn ngữ không xử lý đệ quy tốt.

Lạm dụng đệ quy
Không cần phải tính toán chuỗi Fibonacci bằng cách sử dụng đệ quy, thực tế nó là một ví dụ bệnh lý.
Đệ quy được sử dụng tốt nhất trong các ngôn ngữ hỗ trợ rõ ràng cho nó hoặc trong các miền nơi đệ quy tỏa sáng, như việc xử lý dữ liệu được lưu trữ trong cây.

Tôi hiểu bất kỳ đệ quy có thể được viết như một vòng lặp

Có, nếu bạn sẵn sàng đặt xe trước ngựa.
Tất cả các trường hợp đệ quy có thể được viết dưới dạng một vòng lặp, một số trường hợp đó yêu cầu bạn sử dụng một ngăn xếp rõ ràng như lưu trữ.
Nếu bạn cần cuộn ngăn xếp của riêng mình chỉ để biến mã đệ quy thành một vòng lặp, bạn cũng có thể sử dụng đệ quy đơn giản.
Tất nhiên trừ khi bạn có nhu cầu đặc biệt như sử dụng các điều tra viên trong cấu trúc cây và bạn không có sự hỗ trợ ngôn ngữ phù hợp.


16

Những câu trả lời khác có phần sai lệch. Tôi đồng ý rằng họ nêu chi tiết thực hiện có thể giải thích sự chênh lệch này, nhưng họ nói quá về trường hợp này. Như được đề xuất chính xác bởi jmite, chúng được định hướng triển khai theo hướng thực hiện các lệnh gọi / đệ quy hàm bị hỏng . Nhiều ngôn ngữ thực hiện các vòng lặp thông qua đệ quy, vì vậy các vòng lặp rõ ràng sẽ không nhanh hơn trong các ngôn ngữ đó. Đệ quy không có cách nào kém hiệu quả hơn việc lặp (khi cả hai đều có thể áp dụng) trên lý thuyết. Hãy để tôi trích dẫn bản tóm tắt cho bài viết năm 1977 của Guy Steele về việc hoang tưởng "Cuộc gọi thủ tục tốn kém" hoặc, Việc thực hiện thủ tục được coi là có hại hoặc, Lambda: GOTO tối thượng

Văn hóa dân gian nói rằng các tuyên bố GOTO là "rẻ", trong khi các cuộc gọi thủ tục là "đắt tiền". Huyền thoại này phần lớn là kết quả của việc thực hiện ngôn ngữ được thiết kế kém. Sự phát triển lịch sử của huyền thoại này được xem xét. Cả hai ý tưởng lý thuyết và một triển khai hiện có đều được thảo luận để gỡ bỏ huyền thoại này. Nó cho thấy rằng việc sử dụng các cuộc gọi thủ tục không hạn chế cho phép tự do phong cách tuyệt vời. Đặc biệt, bất kỳ sơ đồ nào cũng có thể được viết dưới dạng chương trình "có cấu trúc" mà không cần đưa ra các biến phụ. Khó khăn với câu lệnh GOTO và lệnh gọi thủ tục được đặc trưng là xung đột giữa các khái niệm lập trình trừu tượng và các cấu trúc ngôn ngữ cụ thể.

"Xung đột giữa các khái niệm lập trình trừu tượng và các cấu trúc ngôn ngữ cụ thể" có thể được nhìn thấy từ thực tế là hầu hết các mô hình lý thuyết, ví dụ, phép tính lambda chưa được xử lý , không có một ngăn xếp . Tất nhiên, xung đột này là không cần thiết như bài viết trên minh họa và cũng được thể hiện bởi các ngôn ngữ không có cơ chế lặp ngoài trừ đệ quy như Haskell.

fixfix f x = f (fix f) x(λx.M)NM[N/x][N/x]xMN

Bây giờ cho một ví dụ. Xác định fact

fact = fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 1

Dưới đây là đánh giá về sự fact 3gọn nhẹ, ở đâu, tôi sẽ sử dụng glàm từ đồng nghĩa với fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)), nghĩa là fact = g 1. Điều này không ảnh hưởng đến lập luận của tôi.

fact 3 
~> g 1 3
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 1 3 
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 1 3
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 1 3
~> (λn.if n == 0 then 1 else g (1*n) (n-1)) 3
~> if 3 == 0 then 1 else g (1*3) (3-1)
~> g (1*3) (3-1)
~> g 3 2
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 3 2
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 3 2
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 3 2
~> (λn.if n == 0 then 3 else g (3*n) (n-1)) 2
~> if 2 == 0 then 3 else g (3*2) (2-1)
~> g (3*2) (2-1)
~> g 6 1
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 6 1
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 6 1
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 6 1
~> (λn.if n == 0 then 6 else g (6*n) (n-1)) 1
~> if 1 == 0 then 6 else g (6*1) (1-1)
~> g (6*1) (1-1)
~> g 6 0
~> fix (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) 6 0
~> (λf.λa.λn.if n == 0 then a else f (a*n) (n-1)) g 6 0
~> (λa.λn.if n == 0 then a else g (a*n) (n-1)) 6 0
~> (λn.if n == 0 then 6 else g (6*n) (n-1)) 0
~> if 0 == 0 then 6 else g (6*0) (0-1)
~> 6

Bạn có thể nhìn từ hình dạng mà không cần nhìn vào các chi tiết không có sự tăng trưởng và mỗi lần lặp lại cần cùng một không gian. (Về mặt kỹ thuật, kết quả số tăng lên là điều không thể tránh khỏi và đúng như vậy đối với một whilevòng lặp.) Tôi thách thức bạn chỉ ra "ngăn xếp" tăng trưởng vô biên ở đây.

Có vẻ như ngữ nghĩa nguyên mẫu của phép tính lambda đã thực hiện những gì thường được đặt tên sai là "tối ưu hóa cuộc gọi đuôi". Tất nhiên, không có "tối ưu hóa" đang xảy ra ở đây. Không có quy tắc đặc biệt nào ở đây cho các cuộc gọi "đuôi" trái ngược với các cuộc gọi "thông thường". Vì lý do này, thật khó để đưa ra một đặc tính "trừu tượng" về cái mà đuôi gọi là "tối ưu hóa" đang làm, vì trong nhiều đặc tính trừu tượng của ngữ nghĩa gọi hàm, không có gì để gọi "tối ưu hóa" đuôi!

Rằng định nghĩa tương tự của factnhiều ngôn ngữ "chồng tràn", là những thất bại của những ngôn ngữ đó để thực hiện chính xác ngữ nghĩa của hàm gọi. (Một số ngôn ngữ có một lý do.) Tình huống này gần giống với việc thực hiện ngôn ngữ thực hiện các mảng với các danh sách được liên kết. Lập chỉ mục vào các "mảng" như vậy sau đó sẽ là một hoạt động O (n) không đáp ứng được kỳ vọng của các mảng. Nếu tôi thực hiện một ngôn ngữ riêng biệt, sử dụng các mảng thực thay vì các danh sách được liên kết, bạn sẽ không nói rằng tôi đã triển khai "tối ưu hóa truy cập mảng", bạn sẽ nói rằng tôi đã sửa lỗi triển khai mảng.

Vì vậy, trả lời câu trả lời của Veedrac. Ngăn xếp không phải là "cơ bản" để đệ quy . Trong phạm vi hành vi "giống như ngăn xếp" xảy ra trong quá trình đánh giá, điều này chỉ có thể xảy ra trong trường hợp các vòng lặp (không có cấu trúc dữ liệu phụ trợ) sẽ không được áp dụng ngay từ đầu! Nói cách khác, tôi có thể thực hiện các vòng lặp với đệ quy với các đặc tính hiệu suất chính xác như nhau. Thật vậy, cả Scheme và SML đều chứa các cấu trúc lặp, nhưng cả hai đều xác định chúng theo thuật ngữ đệ quy (và, ít nhất là trong Scheme, dothường được triển khai như một macro mở rộng thành các lệnh gọi đệ quy.) Tương tự, đối với câu trả lời của Johan, không có gì nói trình biên dịch phải phát ra hội nghị mà Johan mô tả cho đệ quy. Thật,chính xác cùng một hội đồng cho dù bạn sử dụng các vòng lặp hoặc đệ quy. Lần duy nhất trình biên dịch sẽ (phần nào) bắt buộc phải phát ra lắp ráp giống như những gì Johan mô tả là khi bạn đang làm một cái gì đó không thể biểu thị bằng một vòng lặp. Như được nêu trong bài báo của Steele và được chứng minh bằng thực tiễn thực tế của các ngôn ngữ như Haskell, Scheme và SML, không phải là "cực kỳ hiếm" mà các cuộc gọi đuôi có thể được "tối ưu hóa", chúng luôn có thểđược "tối ưu hóa". Việc sử dụng đệ quy cụ thể có chạy trong không gian không đổi hay không tùy thuộc vào cách viết, nhưng những hạn chế bạn cần áp dụng để thực hiện điều đó có thể là những hạn chế bạn cần để phù hợp với vấn đề của mình theo hình vòng lặp. (Trên thực tế, chúng ít nghiêm ngặt hơn. Có một số vấn đề, chẳng hạn như máy trạng thái mã hóa, được xử lý sạch sẽ và hiệu quả hơn thông qua các cuộc gọi đuôi thay vì các vòng lặp yêu cầu các biến phụ trợ.) Một lần nữa, đệ quy thời gian duy nhất yêu cầu thực hiện nhiều công việc hơn là khi mã của bạn không phải là một vòng lặp.

Tôi đoán là Johan đang đề cập đến trình biên dịch C có các hạn chế tùy ý khi nào nó sẽ thực hiện cuộc gọi "tối ưu hóa" đuôi. Johan cũng có lẽ đề cập đến các ngôn ngữ như C ++ và Rust khi ông nói về "các ngôn ngữ với các loại được quản lý". Thành ngữ RAII từ C ++ và hiện diện trong Rust cũng làm cho mọi thứ trông bề ngoài giống như các cuộc gọi đuôi, không phải các cuộc gọi đuôi (vì "kẻ hủy diệt" vẫn cần phải được gọi). Đã có đề xuất sử dụng một cú pháp khác nhau để chọn tham gia vào một ngữ nghĩa hơi khác nhau sẽ cho phép đệ quy đuôi (cụ thể là gọi hàm hủy trước đócuộc gọi đuôi cuối cùng và rõ ràng không cho phép truy cập các đối tượng "bị phá hủy". (Bộ sưu tập rác không có vấn đề như vậy và tất cả Haskell, SML và Scheme đều là ngôn ngữ được thu gom rác.) Trong một tĩnh mạch hoàn toàn khác, một số ngôn ngữ, như Smalltalk, phơi bày "ngăn xếp" như một đối tượng hạng nhất, trong các ngôn ngữ này trong trường hợp "ngăn xếp" không còn là chi tiết triển khai, mặc dù điều này không loại trừ việc có các loại cuộc gọi riêng biệt với ngữ nghĩa khác nhau. (Java nói rằng nó không thể do cách nó xử lý một số khía cạnh của bảo mật, nhưng điều này thực sự sai .)

Trong thực tế, sự phổ biến của việc thực hiện các lệnh gọi hàm bị hỏng xuất phát từ ba yếu tố chính. Đầu tiên, nhiều ngôn ngữ kế thừa việc triển khai bị hỏng từ ngôn ngữ thực hiện của chúng (thường là C). Thứ hai, quản lý tài nguyên xác định là tốt và làm cho vấn đề trở nên phức tạp hơn, mặc dù chỉ có một số ít ngôn ngữ cung cấp điều này. Thứ ba, và, theo kinh nghiệm của tôi, lý do hầu hết mọi người quan tâm, là họ muốn theo dõi ngăn xếp khi xảy ra lỗi cho mục đích gỡ lỗi. Chỉ có lý do thứ hai là một lý do có thể có động lực về mặt lý thuyết.


Tôi đã sử dụng "cơ bản" để chỉ lý do cơ bản nhất cho rằng khiếu nại là đúng, không phải là liệu nó có hợp lý theo cách này hay không (vì rõ ràng là không, vì hai chương trình này giống hệt nhau). Nhưng tôi không đồng ý với nhận xét của bạn nói chung. Việc bạn sử dụng tính toán lambda sẽ không loại bỏ ngăn xếp nhiều như che khuất nó.
Veedrac

Yêu cầu của bạn "Lần duy nhất trình biên dịch sẽ (phần nào) bắt buộc phải phát ra lắp ráp giống như những gì Johan mô tả là khi bạn đang làm một cái gì đó không thể biểu thị bằng một vòng lặp." cũng khá lạ; một trình biên dịch (thông thường) có thể tạo ra bất kỳ mã nào tạo ra cùng một đầu ra, vì vậy bình luận của bạn về cơ bản là một tautology. Nhưng trong thực tế các trình biên dịch tạo ra các mã khác nhau cho các chương trình tương đương khác nhau, và câu hỏi là tại sao.
Veedrac

Ôi(1)

Để đưa ra một sự tương tự, trả lời một câu hỏi tại sao việc thêm các chuỗi bất biến vào các vòng lặp lại mất thời gian bậc hai với "nó không phải" sẽ hoàn toàn hợp lý, nhưng tiếp tục tuyên bố rằng việc triển khai đã bị phá vỡ sẽ không xảy ra.
Veedrac

Câu trả lời rất thú vị. Mặc dù nghe có vẻ hơi giống một câu thần chú :-). Nâng cao vì tôi học được một cái gì đó mới.
Johan - phục hồi Monica

2

Về cơ bản, sự khác biệt là đệ quy bao gồm một ngăn xếp, một cấu trúc dữ liệu phụ trợ mà bạn có thể không muốn, trong khi các vòng lặp không tự động làm như vậy. Chỉ trong những trường hợp hiếm hoi, một trình biên dịch điển hình mới có thể suy luận rằng bạn thực sự không cần ngăn xếp.

Nếu bạn so sánh các vòng lặp hoạt động thủ công trên ngăn xếp được phân bổ (ví dụ: thông qua một con trỏ để bộ nhớ heap), thông thường bạn sẽ thấy chúng không nhanh hơn hoặc thậm chí chậm hơn so với sử dụng ngăn xếp phần cứng.

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.