Tại sao Haskell và Scheme sử dụng danh sách liên kết đơn?


11

Một danh sách liên kết đôi có chi phí tối thiểu (chỉ một con trỏ khác trên mỗi ô) và cho phép bạn nối vào cả hai đầu và qua lại và thường có rất nhiều niềm vui.


hàm tạo danh sách có thể chèn vào đầu danh sách liên kết đơn, mà không sửa đổi danh sách gốc. Điều này rất quan trọng đối với lập trình chức năng. Danh sách liên kết đôi khá nhiều liên quan đến sửa đổi, không phải là rất thuần túy.
tp1

3
Hãy suy nghĩ về nó, làm thế nào bạn thậm chí sẽ xây dựng một danh sách bất biến liên kết đôi? Bạn cần phải có nextcon trỏ của phần tử trước trỏ đến phần tử tiếp theo và prevcon trỏ của phần tử tiếp theo trỏ đến phần tử trước. Tuy nhiên, một trong hai yếu tố đó được tạo trước yếu tố kia, điều đó có nghĩa là một trong những yếu tố đó cần phải có một con trỏ trỏ đến một đối tượng chưa tồn tại! Hãy nhớ rằng, trước tiên bạn không thể tạo một yếu tố, sau đó đến yếu tố khác và sau đó đặt con trỏ - chúng là bất biến. (Lưu ý: Tôi biết có một cách, khai thác sự lười biếng, được gọi là "Buộc thắt nút".)
Jörg W Mittag

1
Danh sách liên kết đôi thường không cần thiết trong hầu hết các trường hợp. Nếu bạn cần truy cập ngược lại, đẩy các mục trong danh sách lên một ngăn xếp và bật từng cái một cho thuật toán đảo ngược O (n).
Neil

Câu trả lời:


22

Chà, nếu bạn nhìn sâu hơn một chút, cả hai thực sự cũng bao gồm các mảng trong ngôn ngữ cơ sở:

  • Báo cáo lược đồ sửa đổi lần thứ 5 (R5RS) bao gồm loại vectơ , là các bộ sưu tập được lập chỉ mục số nguyên có kích thước cố định với thời gian tuyến tính tốt hơn để truy cập ngẫu nhiên.
  • Báo cáo Haskell 98 cũng có một kiểu mảng .

Tuy nhiên, hướng dẫn lập trình chức năng từ lâu đã nhấn mạnh các danh sách liên kết đơn trên các mảng hoặc danh sách liên kết đôi. Trên thực tế, có khả năng quá cao. Có một số lý do cho nó, tuy nhiên.

Đầu tiên là danh sách liên kết đơn là một trong những kiểu dữ liệu đệ quy đơn giản nhất nhưng hữu ích nhất. Có thể định nghĩa tương đương do người dùng xác định loại danh sách của Haskell như sau:

data List a           -- A list with element type `a`...
  = Empty             -- is either the empty list...
  | Cell a (List a)   -- or a pair with an `a` and the rest of the list. 

Thực tế là các danh sách là một kiểu dữ liệu đệ quy có nghĩa là các hàm hoạt động trong danh sách thường sử dụng đệ quy cấu trúc . Theo thuật ngữ Haskell: bạn khớp mẫu trên các hàm tạo danh sách và bạn lặp lại trên một phần con của danh sách. Trong hai định nghĩa hàm cơ bản này, tôi sử dụng biến asđể chỉ phần đuôi của danh sách. Vì vậy, lưu ý rằng các cuộc gọi đệ quy "xuống" xuống danh sách:

map :: (a -> b) -> List a -> List b
map f Empty = Empty
map f (Cell a as) = Cell (f a) (map f as)

filter :: (a -> Bool) -> List a -> List a
filter p Empty = Empty
filter p (Cell a as)
    | p a = Cell a (filter p as)
    | otherwise = filter p as

Kỹ thuật này đảm bảo rằng chức năng của bạn sẽ chấm dứt cho tất cả các danh sách hữu hạn, và cũng là một kỹ thuật giải quyết vấn đề tốt, nó có xu hướng tự nhiên phân tách các vấn đề thành các phần con đơn giản hơn, có thể sử dụng hơn.

Vì vậy, danh sách liên kết đơn có lẽ là loại dữ liệu tốt nhất để giới thiệu cho sinh viên về các kỹ thuật này, rất quan trọng trong lập trình chức năng.

Lý do thứ hai ít hơn là lý do "tại sao danh sách liên kết đơn", nhưng lý do "tại sao không phải là danh sách hoặc mảng liên kết kép": những kiểu dữ liệu sau này thường gọi là đột biến (biến có thể sửa đổi), mà lập trình chức năng rất thường xuyên tránh xa Vì vậy, khi nó xảy ra:

  • Trong một ngôn ngữ háo hức như Scheme, bạn không thể tạo một danh sách liên kết đôi mà không sử dụng đột biến.
  • Trong một ngôn ngữ lười biếng như Haskell, bạn có thể tạo một danh sách liên kết đôi mà không cần sử dụng đột biến. Nhưng bất cứ khi nào bạn tạo một danh sách mới dựa trên danh sách đó, bạn buộc phải sao chép hầu hết nếu không phải là tất cả các cấu trúc của bản gốc. Trong khi với các danh sách liên kết đơn, bạn có thể viết các hàm sử dụng danh sách "chia sẻ cấu trúc" có thể sử dụng lại các ô của danh sách cũ khi thích hợp.
  • Theo truyền thống, nếu bạn sử dụng mảng theo cách không thay đổi, điều đó có nghĩa là mỗi lần bạn muốn sửa đổi mảng, bạn phải sao chép toàn bộ. ( vectorTuy nhiên, các thư viện Haskell gần đây đã tìm thấy các kỹ thuật cải thiện đáng kể vấn đề này).

Lý do thứ ba và cuối cùng áp dụng cho các ngôn ngữ lười biếng như Haskell là chủ yếu: các danh sách liên kết đơn lười biếng, trong thực tế, thường giống với các trình lặp hơn là các danh sách trong bộ nhớ phù hợp. Nếu mã của bạn đang tiêu thụ các yếu tố của danh sách một cách tuần tự và ném chúng ra khi bạn đi, mã đối tượng sẽ chỉ cụ thể hóa các ô danh sách và nội dung của nó khi bạn bước qua danh sách.

Điều này có nghĩa là toàn bộ danh sách không cần tồn tại trong bộ nhớ cùng một lúc, chỉ có ô hiện tại. Các ô trước ô hiện tại có thể được thu gom rác (điều này không thể thực hiện được với danh sách liên kết đôi); các ô muộn hơn các ô hiện tại không cần phải tính toán cho đến khi bạn đến đó.

Nó còn đi xa hơn thế. Có kỹ thuật được sử dụng trong một số thư viện Haskell phổ biến, được gọi là fusion , trong đó trình biên dịch phân tích mã xử lý danh sách của bạn và phát hiện các danh sách trung gian đang được tạo và tiêu thụ tuần tự và sau đó "vứt đi". Với kiến ​​thức này, trình biên dịch có thể loại bỏ hoàn toàn việc cấp phát bộ nhớ cho các ô của danh sách đó. Điều này có nghĩa là một danh sách liên kết đơn trong chương trình nguồn Haskell, sau khi biên dịch, thực sự có thể bị biến thành một vòng lặp thay vì cấu trúc dữ liệu.

Fusion cũng là kỹ thuật mà vectorthư viện nói trên sử dụng để tạo mã hiệu quả cho các mảng không thay đổi. Điều tương tự cũng xảy ra với các thư viện cực kỳ phổ biến bytestring(mảng byte) và text(chuỗi Unicode), được xây dựng để thay thế cho Stringkiểu bản địa không quá lớn của Haskell (giống như [Char]danh sách ký tự liên kết đơn). Vì vậy, trong Haskell hiện đại, có một xu hướng mà các kiểu mảng bất biến với sự hỗ trợ nhiệt hạch đang trở nên rất phổ biến.

Danh sách hợp nhất được tạo điều kiện bởi thực tế là trong một danh sách liên kết đơn, bạn có thể đi tiếp nhưng không bao giờ lùi . Điều này mang đến một chủ đề rất quan trọng trong lập trình chức năng: sử dụng "hình dạng" của kiểu dữ liệu để lấy "hình dạng" của một tính toán. Nếu bạn muốn xử lý các phần tử một cách tuần tự, một danh sách liên kết đơn là một kiểu dữ liệu, khi bạn sử dụng nó với đệ quy cấu trúc, sẽ cung cấp cho bạn mẫu truy cập đó rất tự nhiên. Nếu bạn muốn sử dụng chiến lược "phân chia và chinh phục" để tấn công một vấn đề, thì cấu trúc dữ liệu cây có xu hướng hỗ trợ rất tốt.

Rất nhiều người từ bỏ chương trình lập trình chức năng từ rất sớm, vì vậy họ được tiếp xúc với các danh sách liên kết đơn nhưng không tiếp cận với các ý tưởng cơ bản nâng cao hơn.


1
Thật là một câu trả lời tuyệt vời!
Elliot Gorokhovsky

14

Bởi vì họ làm việc tốt với sự bất biến. Giả sử bạn có hai danh sách bất biến, [1, 2, 3][10, 2, 3]. Được biểu thị dưới dạng các danh sách được liên kết đơn trong đó mỗi mục trong danh sách là một nút chứa mục đó và một con trỏ đến phần còn lại của danh sách, chúng sẽ trông như thế này:

node -> node -> node -> empty
 1       2       3

node -> node -> node -> empty
 10       2       3

Xem các [2, 3]phần giống hệt nhau như thế nào? Với cấu trúc dữ liệu có thể thay đổi, chúng là hai danh sách khác nhau vì mã ghi dữ liệu mới cho một trong số chúng cần không ảnh hưởng đến mã bằng cách sử dụng danh sách khác. Tuy nhiên, với dữ liệu không thay đổi , chúng tôi biết rằng nội dung của danh sách sẽ không bao giờ thay đổi và mã không thể ghi dữ liệu mới. Vì vậy, chúng ta có thể sử dụng lại các đuôi và để hai danh sách chia sẻ một phần cấu trúc của chúng:

node -> node -> node -> empty
 1      ^ 2       3
        |
node ---+
 10

Vì mã sử dụng hai danh sách sẽ không bao giờ làm thay đổi chúng, chúng tôi không bao giờ phải lo lắng về việc thay đổi danh sách này ảnh hưởng đến danh sách kia. Điều này cũng có nghĩa là khi thêm một mục vào phía trước danh sách, bạn không phải sao chép và tạo một danh sách hoàn toàn mới.

Tuy nhiên, nếu bạn cố gắng và đại diện [1, 2, 3][10, 2, 3]như danh sách liên kết đôi :

node <-> node <-> node <-> empty
 1       2       3

node <-> node <-> node <-> empty
 10       2       3

Bây giờ đuôi không còn giống nhau nữa. Cái đầu tiên [2, 3]có một con trỏ 1ở đầu, nhưng cái thứ hai có một con trỏ tới 10. Ngoài ra, nếu bạn muốn thêm một mục mới vào đầu danh sách, bạn phải thay đổi phần đầu trước của danh sách để làm cho nó trỏ đến phần đầu mới.

Vấn đề nhiều đầu có khả năng có thể được khắc phục bằng cách mỗi nút lưu trữ một danh sách các đầu đã biết và việc tạo danh sách mới sửa đổi điều đó, nhưng sau đó bạn phải duy trì danh sách đó theo chu kỳ thu gom rác khi các phiên bản của danh sách có các đầu khác nhau có tuổi thọ khác nhau do được sử dụng trong các đoạn mã khác nhau. Nó thêm sự phức tạp và chi phí chung, và hầu hết thời gian nó không đáng.


8
Chia sẻ đuôi không xảy ra như bạn ngụ ý, mặc dù. Nói chung, không ai đi qua tất cả các danh sách trong bộ nhớ và tìm kiếm cơ hội để hợp nhất các hậu tố phổ biến. Việc chia sẻ chỉ xảy ra , nó rơi ra khỏi cách các thuật toán được viết, ví dụ nếu một hàm có tham số xsxây dựng 1:xsở nơi này và nơi 10:xskhác.

0

Câu trả lời của @ sacundim hầu hết là đúng, nhưng cũng có một số hiểu biết quan trọng khác về sự đánh đổi về thiết kế ngôn ngữ và các yêu cầu thực tế.

Đối tượng và tài liệu tham khảo

Các ngôn ngữ này thường bắt buộc (hoặc giả sử) các đối tượng có phạm vi động không liên kết (hoặc theo cách nói của C, trọn đời , mặc dù không giống nhau do sự khác biệt về ý nghĩa của các đối tượng giữa các ngôn ngữ này, xem bên dưới) theo mặc định, tránh các tham chiếu hạng nhất ( ví dụ: con trỏ đối tượng trong C) và hành vi không thể đoán trước trong các quy tắc ngữ nghĩa (ví dụ: hành vi không xác định của ISO C liên quan đến ngữ nghĩa).

Hơn nữa, khái niệm về các đối tượng (hạng nhất) trong các ngôn ngữ như vậy bị hạn chế một cách bảo thủ: không có thuộc tính "định vị" nào được chỉ định và đảm bảo theo mặc định. Điều này hoàn toàn khác nhau trong một số ngôn ngữ giống ALGOL có đối tượng không có phạm vi động không liên kết (ví dụ: trong C và C ++), trong đó các đối tượng về cơ bản có nghĩa là một số loại "lưu trữ được gõ", thường được kết hợp với các vị trí bộ nhớ.

Để mã hóa lưu trữ trong các đối tượng có một số lợi ích bổ sung như có thể đính kèm các hiệu ứng tính toán xác định trong suốt cuộc đời của họ, nhưng đó là một chủ đề khác.

Các vấn đề về mô phỏng cấu trúc dữ liệu

Không có tài liệu tham khảo hạng nhất, danh sách liên kết đơn có thể mô phỏng nhiều cấu trúc dữ liệu truyền thống (háo hức / có thể thay đổi) một cách hiệu quả và hợp lý, do bản chất của việc trình bày các cấu trúc dữ liệu này và các hoạt động nguyên thủy hạn chế trong các ngôn ngữ này. (Ngược lại, trong C, bạn có thể lấy được danh sách được liên kết khá dễ dàng ngay cả trong một chương trình tuân thủ nghiêm ngặt .) Và các cấu trúc dữ liệu thay thế như mảng / vectơ có một số đặc tính vượt trội so với danh sách liên kết đơn trong thực tế. Đó là lý do tại sao R 5 RS giới thiệu các hoạt động nguyên thủy mới.

Nhưng có tồn tại sự khác biệt giữa các loại vectơ / mảng so với các danh sách liên kết đôi. Một mảng thường được giả định với độ phức tạp thời gian truy cập O (1) và chi phí không gian ít hơn, đó là các thuộc tính tuyệt vời không được chia sẻ bởi danh sách. (Mặc dù nói đúng, không được bảo đảm bởi ISO C, nhưng người dùng hầu như luôn mong đợi nó và không có triển khai thực tế nào vi phạm các bảo đảm ngầm này quá rõ ràng.) OTOH, một danh sách liên kết đôi thường làm cho cả hai thuộc tính thậm chí còn tệ hơn cả danh sách liên kết đơn , trong khi phép lặp lùi / tiến cũng được hỗ trợ bởi một mảng hoặc một vectơ (cùng với các chỉ số nguyên) với chi phí thậm chí ít hơn. Do đó, một danh sách liên kết đôi không hoạt động tốt hơn nói chung. Thậm chí tệ hơn nữa, hiệu suất về hiệu quả bộ đệm và độ trễ khi phân bổ bộ nhớ động của danh sách kém hơn so với hiệu suất của mảng / vectơ khi sử dụng bộ cấp phát mặc định được cung cấp bởi môi trường triển khai bên dưới (ví dụ libc). Vì vậy, nếu không có thời gian chạy rất cụ thể và "thông minh" sẽ tối ưu hóa rất nhiều việc tạo đối tượng như vậy, các kiểu mảng / vectơ thường được ưu tiên cho các danh sách được liên kết. (Ví dụ: sử dụng ISO C ++, có một cảnh báostd::vectornên được ưu tiên std::listtheo mặc định.) Vì vậy, để giới thiệu các nguyên thủy mới để hỗ trợ cụ thể (đôi khi-) các danh sách được liên kết chắc chắn không có lợi như hỗ trợ các cấu trúc dữ liệu mảng / vector trong thực tế.

Để công bằng, danh sách vẫn có một số thuộc tính cụ thể tốt hơn mảng / vectơ:

  • Danh sách dựa trên nút. Xóa các phần tử khỏi danh sách không làm mất hiệu lực tham chiếu đến các phần tử khác trong các nút khác. (Điều này cũng đúng với một số cấu trúc dữ liệu cây hoặc đồ thị.) OTOH, mảng / vectơ có thể làm cho các tham chiếu đến vị trí dấu bị vô hiệu (trong một số trường hợp phân bổ lại lớn).
  • Danh sách có thể ghép trong thời gian O (1). Tái thiết các mảng / vectơ mới với các mảng hiện tại tốn kém hơn nhiều.

Tuy nhiên, các thuộc tính này không quá quan trọng đối với một ngôn ngữ có hỗ trợ danh sách liên kết đơn được tích hợp sẵn, vốn đã có khả năng sử dụng như vậy. Mặc dù vẫn còn tồn tại sự khác biệt, nhưng trong các ngôn ngữ có phạm vi đối tượng động bắt buộc (thường có nghĩa là có một trình thu gom rác giữ các tham chiếu lơ lửng), việc vô hiệu hóa cũng có thể ít quan trọng hơn, tùy thuộc vào ý định. Vì vậy, các trường hợp duy nhất mà danh sách liên kết đôi thắng có thể là:

  • Cả hai yêu cầu bảo đảm không tái phân bổ và lặp lại hai chiều là cần thiết. (Nếu hiệu suất truy cập phần tử là quan trọng và tập hợp dữ liệu đủ lớn, tôi sẽ chọn cây tìm kiếm nhị phân hoặc bảng băm thay thế.)
  • Hoạt động mối nối hai chiều hiệu quả là cần thiết. Điều này là rất hiếm. (Tôi chỉ đáp ứng các yêu cầu chỉ khi thực hiện một cái gì đó như bản ghi lịch sử tuyến tính trong trình duyệt.)

Bất biến và răng cưa

Trong một ngôn ngữ thuần túy như Haskell, các đối tượng là bất biến. Đối tượng của sơ đồ thường được sử dụng mà không có đột biến. Thực tế như vậy giúp cải thiện hiệu quả bộ nhớ với việc thực hiện đối tượng - chia sẻ ngầm định nhiều đối tượng có cùng giá trị khi đang di chuyển.

Đây là một chiến lược tối ưu hóa cấp cao tích cực trong thiết kế ngôn ngữ. Tuy nhiên, điều này không liên quan đến vấn đề thực hiện. Nó thực sự giới thiệu các bí danh ngầm cho các ô lưu trữ bên dưới. Nó làm cho phân tích răng cưa khó khăn hơn. Do đó, có thể có ít khả năng loại bỏ chi phí hoạt động của các tài liệu tham khảo không thuộc hạng nhất, thậm chí người dùng không bao giờ chạm vào chúng. Trong các ngôn ngữ như Scheme, một khi đột biến không được loại trừ hoàn toàn, điều này cũng cản trở sự song song. Mặc dù vậy, nó có thể ổn trong một ngôn ngữ lười biếng (dù đã có vấn đề về hiệu suất do thunks gây ra).

Đối với lập trình có mục đích chung, việc lựa chọn thiết kế ngôn ngữ như vậy có thể có vấn đề. Nhưng với một số mẫu mã hóa chức năng phổ biến, các ngôn ngữ dường như vẫn hoạt động tốt.

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.