Làm thế nào để bạn biết khi nào sử dụng gấp trái và khi nào sử dụng gấp phải?


99

Tôi biết rằng gập trái tạo ra cây nghiêng trái và gập phải tạo ra cây nghiêng phải, nhưng khi tôi với đến một nếp gấp, đôi khi tôi thấy mình bị sa lầy vào những suy nghĩ đau đầu khi cố gắng xác định loại nếp gấp nào là thích hợp. Tôi thường kết thúc việc giải nén toàn bộ vấn đề và chuyển qua việc triển khai hàm gập vì nó áp dụng cho vấn đề của tôi.

Vì vậy, những gì tôi muốn biết là:

  • Một số quy tắc ngón tay cái để xác định nên gấp trái hay gấp phải?
  • Làm cách nào để tôi có thể nhanh chóng quyết định loại nếp gấp nào sẽ sử dụng với vấn đề tôi đang gặp phải?

Có một ví dụ trong Scala by Example (PDF) về việc sử dụng một nếp gấp để viết một hàm được gọi là flatten để nối danh sách các danh sách phần tử thành một danh sách duy nhất. Trong trường hợp đó, một nếp gấp đúng là lựa chọn thích hợp (dựa trên cách các danh sách được nối với nhau), nhưng tôi đã phải suy nghĩ về nó một chút để đi đến kết luận đó.

Vì gấp là một hành động phổ biến trong lập trình (chức năng), tôi muốn có thể đưa ra những quyết định kiểu này một cách nhanh chóng và tự tin. Vậy ... bất kỳ lời khuyên?



1
Q này nói chung chung hơn là nói riêng về Haskell. Sự lười biếng tạo ra sự khác biệt lớn trong câu trả lời cho câu hỏi.
Chris Conway

Oh. Weird, bằng cách nào đó tôi nghĩ rằng tôi nhìn thấy một thẻ Haskell về câu hỏi này, nhưng tôi đoán không ...
ephemient

Câu trả lời:


105

Bạn có thể chuyển màn hình đầu tiên thành ký hiệu toán tử infix (viết ở giữa):

Ví dụ này gấp lại bằng cách sử dụng chức năng tích lũy x

fold x [A, B, C, D]

như vậy bằng

A x B x C x D

Bây giờ bạn chỉ cần suy luận về tính liên kết của toán tử của bạn (bằng cách đặt dấu ngoặc đơn!).

Nếu bạn có một toán tử kết hợp trái , bạn sẽ đặt các dấu ngoặc đơn như thế này

((A x B) x C) x D

Ở đây, bạn sử dụng một nếp gấp bên trái . Ví dụ (mã giả kiểu haskell)

foldl (-) [1, 2, 3] == (1 - 2) - 3 == 1 - 2 - 3 // - is left-associative

Nếu toán tử của bạn là liên kết phải ( màn hình đầu tiên bên phải ), các dấu ngoặc đơn sẽ được đặt như thế này:

A x (B x (C x D))

Ví dụ: Cons-Operator

foldr (:) [] [1, 2, 3] == 1 : (2 : (3 : [])) == 1 : 2 : 3 : [] == [1, 2, 3]

Nói chung, các toán tử số học (hầu hết các toán tử) là phép kết hợp trái, do đó foldlphổ biến hơn. Nhưng trong các trường hợp khác, ký hiệu infix + dấu ngoặc đơn khá hữu ích.


6
Vâng, những gì bạn mô tả thực sự là foldl1foldr1trong Haskell ( foldlfoldrlấy giá trị ban đầu bên ngoài), và "khuyết điểm" của Haskell được gọi là (:)không (::), nhưng nếu không thì điều này đúng. Bạn có thể muốn thêm rằng Haskell cung cấp thêm a foldl'/ foldl1'là các biến thể nghiêm ngặt của foldl/ foldl1, bởi vì số học lười biếng không phải lúc nào cũng mong muốn.
18/09/09

Xin lỗi, tôi nghĩ rằng tôi đã thấy thẻ "Haskell" trên câu hỏi này, nhưng không phải ở đó. Nhận xét của tôi không thực sự có ý nghĩa nhiều nếu nó không phải là Haskell ...
ephemient

@ephemient Bạn đã thấy nó. Đó là "mã giả kiểu haskell". :)
laughing_man

Câu trả lời hay nhất mà tôi từng thấy liên quan đến sự khác biệt giữa các nếp gấp.
AleXoundOS

60

Olin Shivers đã phân biệt chúng bằng cách nói "foldl là trình lặp danh sách cơ bản" và "foldr là toán tử đệ quy danh sách cơ bản". Nếu bạn nhìn vào cách thức hoạt động của nếp gấp:

((1 + 2) + 3) + 4

bạn có thể thấy bộ tích lũy (như trong phép lặp đệ quy đuôi) đang được xây dựng. Ngược lại, trình gấp thu được:

1 + (2 + (3 + 4))

nơi bạn có thể xem chuyển tiếp đến trường hợp cơ sở 4 và xây dựng kết quả từ đó.

Vì vậy, tôi đặt ra một quy tắc ngón tay cái: nếu nó trông giống như một lần lặp danh sách, một quy tắc sẽ đơn giản để viết ở dạng đệ quy đuôi, thì gấp nếp là cách để thực hiện.

Nhưng thực sự điều này có lẽ sẽ được thể hiện rõ ràng nhất từ ​​sự liên kết của các toán tử mà bạn đang sử dụng. Nếu chúng được liên kết bên trái, hãy sử dụng nếp gấp. Nếu chúng liên kết đúng, hãy sử dụng gấp.


29

Các áp phích khác đã đưa ra câu trả lời tốt và tôi sẽ không lặp lại những gì họ đã nói. Như bạn đã đưa ra một ví dụ Scala trong câu hỏi của mình, tôi sẽ đưa ra một ví dụ cụ thể về Scala. Như Tricks đã nói, foldRightcần phải bảo toàn n-1các khung ngăn xếp, nđộ dài của danh sách của bạn ở đâu và điều này có thể dễ dàng dẫn đến tràn ngăn xếp - và ngay cả đệ quy đuôi cũng không thể cứu bạn khỏi điều này.

A List(1,2,3).foldRight(0)(_ + _)sẽ giảm xuống:

1 + List(2,3).foldRight(0)(_ + _)        // first stack frame
    2 + List(3).foldRight(0)(_ + _)      // second stack frame
        3 + 0                            // third stack frame 
// (I don't remember if the JVM allocates space 
// on the stack for the third frame as well)

trong khi List(1,2,3).foldLeft(0)(_ + _)sẽ giảm xuống:

(((0 + 1) + 2) + 3)

có thể được tính toán lặp đi lặp lại, như được thực hiện trong quá trình triển khaiList .

Trong một ngôn ngữ được đánh giá nghiêm ngặt là Scala, a foldRightcó thể dễ dàng thổi bay chồng lên các danh sách lớn, trong khi a foldLeftthì không.

Thí dụ:

scala> List.range(1, 10000).foldLeft(0)(_ + _)
res1: Int = 49995000

scala> List.range(1, 10000).foldRight(0)(_ + _)
java.lang.StackOverflowError
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRight(List.scala:1081)
        at scala.List.foldRig...

Do đó, quy tắc ngón tay cái của tôi là - đối với các toán tử không có liên kết cụ thể, hãy luôn sử dụng foldLeft, ít nhất là trong Scala. Nếu không, hãy làm theo lời khuyên khác được đưa ra trong câu trả lời;).


13
Điều này đúng trước đó, nhưng trong các phiên bản Scala hiện tại, foldRight đã được thay đổi để áp dụng foldLeft trên một bản sao đảo ngược của danh sách. Ví dụ: trong 2.10.3, github.com/scala/scala/blob/v2.10.3/src/library/scala/… . Có vẻ như thay đổi này đã được thực hiện vào đầu năm 2013 - github.com/scala/scala/commit/… .
Dhruv Kapoor

4

Nó cũng đáng chú ý (và tôi nhận ra điều này đang nói rõ một chút), trong trường hợp toán tử giao hoán thì cả hai tương đương nhau khá nhiều. Trong tình huống này, gập đôi có thể là lựa chọn tốt hơn:

gấp: (((1 + 2) + 3) + 4)có thể tính toán từng hoạt động và chuyển giá trị tích lũy về phía trước

foldr: (1 + (2 + (3 + 4)))cần mở một khung ngăn xếp 1 + ?2 + ?trước khi tính toán 3 + 4, sau đó nó cần quay lại và thực hiện phép tính cho từng khung.

Tôi không đủ chuyên gia về các ngôn ngữ chức năng hoặc tối ưu hóa trình biên dịch để nói liệu điều này có thực sự tạo ra sự khác biệt hay không nhưng chắc chắn có vẻ rõ ràng hơn khi sử dụng một nếp gấp với các toán tử giao hoán.


1
Các khung chồng thêm chắc chắn sẽ tạo ra sự khác biệt cho các danh sách lớn. Nếu các khung xếp chồng của bạn vượt quá kích thước bộ nhớ đệm của bộ xử lý, thì việc bỏ lỡ bộ nhớ cache của bạn sẽ ảnh hưởng đến hiệu suất. Trừ khi danh sách được liên kết kép, nếu không thì rất khó để biến foldr trở thành một hàm đệ quy đuôi, vì vậy bạn nên sử dụng foldl trừ khi có lý do không nên làm.
A. Levy

4
Bản chất lười biếng của Haskell làm xáo trộn phân tích này. Nếu chức năng được gấp lại không nghiêm ngặt trong tham số thứ hai, thì foldrrất có thể hiệu quả hơn foldlvà sẽ không yêu cầu bất kỳ khung ngăn xếp bổ sung nào.
ephemient

2
Xin lỗi, tôi nghĩ rằng tôi đã thấy thẻ "Haskell" trên câu hỏi này, nhưng không phải ở đó. Nhận xét của tôi không thực sự có ý nghĩa nhiều nếu nó không phải là Haskell ...
ephemient
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.