Tìm hiểu các vectơ liên tục của Clojure, pt. 1 (2013)
Understanding Clojure's Persistent Vectors, pt. 1 (2013)
Bạn có thể đã nghe hoặc chưa nghe về vectơ dai dẳng của Clojure. Nó là một cấu trúc dữ liệu được phát minh bởi Rich Hickey (chịu ảnh hưởng từ bài viết của Phil Bagwell về Cây băm lý tưởng) cho Clojure, mang lại kết quả thực tế là O(1)...
Bạn có thể đã nghe hoặc chưa nghe về vectơ dai dẳng của Clojure. Đó là một dữ liệu cấu trúc do Rich Hickey phát minh (chịu ảnh hưởng từ bài viết của Phil Bagwell về Cây băm lý tưởng) cho Clojure, cung cấp thời gian chạy thực tế là O(1) cho nối thêm, cập nhật, tra cứu và subvec. Vì chúng bền bỉ nên mọi sửa đổi tạo một vectơ mới thay vì thay đổi vectơ cũ.
Vậy chúng hoạt động như thế nào? Tôi sẽ cố gắng giải thích chúng thông qua một loạt bài đăng trên blog, trong mà chúng tôi luôn xem xét các phần có thể quản lý được. Sẽ có lời giải thích chi tiết với tất cả những điều kỳ quặc khác nhau xung quanh việc triển khai.
Hôm nay, chúng ta sẽ xem xét các khái niệm cơ bản và sẽ đề cập đến các bản cập nhật, phần bổ sung và bật lên. Việc triển khai PersistentVector trong Clojure sử dụng điều này làm cốt lõi của nó mà còn thực hiện một số tối ưu hóa tốc độ, chẳng hạn như chuyển tiếp và đuôi. Chúng tôi sẽ xem xét những điều đó trong các bài đăng trên blog sau.
Ý tưởng cơ bản
Các vectơ có thể thay đổi và ArrayList thường chỉ là các mảng tăng lên và thu nhỏ khi cần thiết. Điều này rất hiệu quả khi bạn muốn có khả năng thay đổi, nhưng lại là một vấn đề lớn khi bạn muốn kiên trì. Bạn nhận được các thao tác sửa đổi chậm vì bạn sẽ phải sao chép toàn bộ mảng mọi lúc và nó sẽ sử dụng rất nhiều bộ nhớ. Nó sẽ là lý tưởng để tránh sự dư thừa càng nhiều càng tốt mà không làm mất hiệu suất khi tra cứu giá trị, cùng với các thao tác nhanh. Đó chính xác là vectơ liên tục của Clojure làm gì và nó được thực hiện thông qua cân bằng, có trật tự cây.
Ý tưởng là triển khai cấu trúc tương tự như cây nhị phân. duy nhất điểm khác biệt là các nút bên trong cây có nhiều nhất một tham chiếu đến hai nút con và không chứa bất kỳ phần tử nào. Nút lá chứa nhiều nhất hai phần tử. Các phần tử theo thứ tự, điều đó có nghĩa là phần tử đầu tiên là phần tử đầu tiên ở lá ngoài cùng bên trái và phần tử cuối cùng là phần tử ngoài cùng bên phải trong lá ngoài cùng bên phải. Hiện tại, chúng tôi yêu cầu tất cả các lá các nút có cùng độ sâu1. Ví dụ, hãy nhìn vào cây bên dưới: Nó có các số nguyên từ 0 đến 8, trong đó 0 là phần tử đầu tiên và 8 là phần tử cuối cùng. Số 9 là kích thước vectơ:

Nếu chúng ta muốn thêm một phần tử mới vào cuối vectơ này và chúng ta đang ở trong thế giới có thể thay đổi, chúng ta sẽ chèn 9 vào nút lá ngoài cùng bên phải, như cái này:

Nhưng đây là vấn đề: Chúng ta không thể làm điều đó nếu muốn kiên trì. Và cái này rõ ràng là sẽ không hoạt động nếu chúng tôi muốn cập nhật một phần tử! Chúng tôi sẽ cần phải sao chép toàn bộ cấu trúc hoặc ít nhất một phần của cấu trúc đó.
Để giảm thiểu việc sao chép trong khi vẫn duy trì được sự ổn định hoàn toàn, chúng tôi thực hiện sao chép đường dẫn: Chúng tôi sao chép tất cả các nút trên đường dẫn xuống giá trị mà chúng tôi sắp cập nhật hoặc chèn và thay thế giá trị bằng giá trị mới khi chúng ta ở dưới cùng. Một kết quả của nhiều lần chèn được hiển thị dưới đây. Ở đây, vectơ có 7 phần tử chia sẻ cấu trúc có vectơ có 10 phần tử:

Các nút màu hồng được chia sẻ giữa các vectơ, trong khi các nút màu nâu và màu xanh là riêng biệt. Các vectơ khác không được hiển thị cũng có thể chia sẻ các nút với các vectơ này vectơ.
Cập nhật
Toán tử “sửa đổi” dễ hiểu nhất là cập nhật/thay thế
trong một vectơ, vì vậy trước tiên chúng tôi sẽ giải thích cách cập nhật hoạt động. Ở Clojure,
đó là bản sửa đổi assoc hoặc cập nhật trong/cập nhật.
Để cập nhật một phần tử, chúng ta phải dẫn cây xuống nút lá nơi phần tử được đặt. Trong khi đi xuống, chúng tôi sao chép các nút trên đường dẫn tới đảm bảo tính kiên trì. Khi chúng ta đi xuống nút lá, chúng ta sao chép nó và thay thế giá trị chúng tôi muốn thay thế bằng giá trị mới. Sau đó chúng tôi trả lại vectơ mới với đường dẫn đã sửa đổi.
Ví dụ: giả sử chúng ta thực hiện assoc trên vectơ với
các phần tử từ 0 đến 8, như thế này:
(def nâu [0 1 2 3 4 5 6 7 8])
(def xanh dương (assoc nâu 5 'thịt bò))
Cấu trúc bên trong, trong đó vectơ màu xanh lam có đường dẫn được sao chép, là hiển thị bên dưới:

Vì chúng ta có cách để biết nút nào sẽ bị hỏng nên việc này có vẻ khá dễ dàng. (Chúng ta sẽ tìm hiểu cách tìm đường dẫn đến một chỉ mục cụ thể trong phần tiếp theo của loạt bài này.)
Nối thêm
Phần bổ sung (chèn ở cuối) không khác quá nhiều so với các bản cập nhật, ngoại trừ rằng chúng tôi có một số trường hợp đặc biệt trong đó chúng tôi phải tạo các nút để phù hợp với một giá trị. Về cơ bản, có ba trường hợp:
- Có chỗ cho một giá trị mới ở nút lá ngoài cùng bên phải.
- Có khoảng trống ở nút gốc nhưng không có khoảng trống ở nút lá ngoài cùng bên phải.
- Không có đủ dung lượng trong thư mục gốc hiện tại.
Chúng ta sẽ xem xét tất cả các giải pháp đó để tìm giải pháp không quá khó nắm bắt.
1: Giống như PGS
Bất cứ khi nào có đủ không gian ở nút lá ngoài cùng bên phải, chúng tôi có thể thực hiện những gì mình muốn làm khi chúng tôi thực hiện một PGS: Chúng tôi chỉ sao chép đường dẫn và tại đường dẫn mới được tạo nút lá, chúng ta đặt giá trị vào bên phải của phần tử ngoài cùng bên phải.
Ví dụ: đây là cách chúng tôi sẽ thực hiện (conj [0 1 2 3 4] 5) và nội bộ
cấu trúc từ việc làm như vậy. Màu xanh là mới, màu nâu là cũ:

Vậy thôi. Không có phép thuật nào cả, chỉ cần sao chép và chèn đường dẫn vào nút lá.
2: Tạo nút khi bạn cần chúng
Vậy, chúng ta phải làm gì khi không có đủ dung lượng ở nút lá ngoài cùng bên phải? May mắn thay, chúng ta sẽ không bao giờ rơi vào tình thế phát hiện ra mình đang ở trong tình thế khó khăn. nút lá sai: Chúng tôi sẽ luôn đi theo đường dẫn đúng xuống nút lá.
Thay vào đó, chúng ta sẽ nhận ra rằng nút mà chúng ta đang cố gắng đi xuống vẫn chưa
tồn tại (con trỏ là null). Khi một nút không tồn tại, chúng tôi tạo một nút
và đặt nút đó làm nút “được sao chép”.

Trong hình trên, các nút màu hồng tượng trưng cho các nút được tạo. Các nút màu xanh là các nút chúng tôi đã sao chép.
3: Tràn gốc
Trường hợp cuối cùng là trường hợp tràn gốc. Điều này xảy ra khi không còn nữa khoảng trống trong cây với nút gốc hiện tại.
Không khó để hiểu cách chúng tôi giải quyết vấn đề này: Chúng tôi tạo ra một nút gốc và đặt nút gốc cũ là nút con đầu tiên của nút gốc mới. Từ đó bật, chúng tôi thực hiện việc tạo nút, giống như chúng tôi đã làm trong giải pháp trước đó. các ví dụ bên dưới hiển thị nút gốc mới là nút màu tím và nút được tạo các nút có màu hồng.

Có một điều là giải quyết vấn đề, nhưng việc phát hiện khi nào nó xảy ra cũng là một điều quan trọng. May mắn thay, điều này cũng khá dễ dàng. Khi chúng ta có sự phân nhánh hai chiều vector, điều này xảy ra khi kích thước của vector cũ là lũy thừa của hai. Nói chung nói một cách dễ hiểu, vectơ phân nhánh chiều n sẽ tràn khi kích thước của nó là lũy thừa của n.
Đang xuất hiện
Các giải pháp để bật lên (loại bỏ phần tử cuối cùng) không quá khó nắm bắt được. Popping cũng tương tự như chắp thêm ở chỗ có 3 trường hợp:
- Nút lá ngoài cùng bên phải chứa nhiều phần tử.
- Nút lá ngoài cùng bên phải chứa chính xác một phần tử (không sau khi xuất hiện).
- Nút gốc chứa chính xác một phần tử sau khi xuất hiện.
Về cơ bản, đây đều là những cách hoàn nguyên 1, 2 và 3 ở phần trước phần.
1: Giải cứu để giải cứu
Một lần nữa, chúng ta gặp trường hợp mà chúng ta có thể thực hiện như khi cập nhật cấu trúc: Chúng ta sao chép đường dẫn xuống nút lá ngoài cùng bên phải và loại bỏ nút lá ngoài cùng bên phải phần tử trong nút lá được sao chép. Miễn là còn lại ít nhất một phần tử trong nút lá mới, chúng ta không phải thực hiện bất kỳ phép thuật nào.

Hãy nhớ rằng việc bật lên nhiều lần trên một vectơ sẽ không mang lại kết quả các vectơ giống hệt nhau: Chúng bằng nhau, nhưng chúng không có chung gốc. Ví dụ:
(def nâu [0 1 2 3 4 5])
(def blue (pop nâu))
(def xanh lục (pop nâu))
sẽ tạo ra cấu trúc bên trong sau:

2: Xóa trống Nút
Bất cứ khi nào chúng ta có một nút lá chỉ có một nút duy nhất thì chúng ta sẽ gặp một trường hợp khác.
Chúng tôi muốn tránh các nút trống trong cây của mình bằng mọi giá. Vì vậy, bất cứ khi nào
chúng tôi có một nút trống, thay vì trả về nút đó, chúng tôi trả về null. các
nút cha sau đó sẽ chứa một con trỏ null, thay vì một con trỏ tới một nút trống
nút:

Ở đây, vectơ màu nâu là vectơ gốc, trong khi vectơ màu xanh lam là vectơ hiện ra.
Thật không may, việc loại bỏ các nút lá không dễ dàng như vậy. Bạn thấy đấy, nếu chúng ta trả về một con trỏ null cho một nút mà ban đầu chỉ có một nút con, chúng ta phải chuyển đổi cái đó thành một con trỏ null mà chúng tôi gửi lại: Kết quả của việc làm trống một nút lan truyền lên trên. Điều này hơi khó để làm đúng, nhưng về cơ bản nó hoạt động bằng cách nhìn vào đứa trẻ mới, kiểm tra xem nó có rỗng không và được cho là được đặt ở chỉ mục 0 và trả về giá trị rỗng nếu đúng như vậy.
Nếu điều này được triển khai trong Clojure thì nó có thể trông giống như đệ quy này chức năng:
(defn nút-pop [idx độ sâu cur-nút]
(let [idx phụ (tính-chỉ mục con idx độ sâu)]
(if (nút lá? độ sâu) (if (== idx phụ 0)
không
(sao chép và xóa cur-node sub-idx))
; không phải nút lá
(let [con (nút-pop idx (- độ sâu 1)
(child-at cur-node idx phụ))]
(if (nil? con)
(if (== idx phụ 0)
không
(sao chép và xóa cur-node idx phụ)) (sao chép và thay thế cur-node idx phụ con))))))
Khi chức năng như vậy được triển khai, việc loại bỏ nút đã được thực hiện
hoàn toàn. Ví dụ, xem biểu đồ bên dưới. Ở đây, vectơ bật lên (màu xanh)
đã xóa hai nút: Nút lá chứa c và nút cha của nó.

3: Diệt tận gốc
Chúng tôi hiện đã xử lý tất cả các trường hợp, ngoại trừ một trường hợp. Với cách thực hiện hiện nay, chúng ta sẽ nhận được kết quả sau nếu chúng ta tạo ra một vectơ có chín phần tử:

Đúng vậy, chúng ta có một nút gốc với một con trỏ duy nhất tới nút con. Đẹp vô ích, vì chúng ta sẽ luôn di chuyển xuống phần con khi chúng ta tra cứu hoặc kết hợp các giá trị và việc thêm các giá trị sẽ tạo ra một gốc mới. Những gì chúng tôi muốn làm là loại bỏ nó.
Đây có thể là điều dễ thực hiện nhất trong bài đăng trên blog này: Sau khi chúng tôi đã popping xong, hãy kiểm tra xem nút gốc có chỉ chứa một nút con không (ví dụ: kiểm tra xem con thứ hai có rỗng không). Nếu đúng như vậy, và nút gốc không phải là nút lá, chúng ta chỉ có thể thay thế nút gốc bằng nút con của nó.
Kết quả như mong đợi là một vectơ mới (màu xanh) có vectơ con đầu tiên của nút gốc của vectơ gốc là nút gốc:

O(1) != O(log n)
Một số người ngoài kia có lẽ đang thắc mắc làm sao có thể nói điều này là O(1) tại tất cả. Trên thực tế, chỉ có hai nút con trên mỗi nút, đây là O(log2 n), đó là (tương đối) xa O(1).
Tuy nhiên, chúng tôi không cần chỉ có hai nút con trên mỗi nút (thường được gọi là hệ số phân nhánh). Clojure có 32 mỗi nút, kết quả là biến thành cây rất nông. Trên thực tế, cây sẽ sâu tối đa 6 nút nếu bạn có ít hơn 1 tỷ phần tử trong vectơ. Bạn cần khoảng 35 tỷ phần tử để đạt đến độ sâu 8 nút. Vào thời điểm đó, tôi sẽ tin vào ký ức mức tiêu thụ là một vấn đề nghiêm trọng hơn.
Để thực sự thấy sự khác biệt: Đây là cây phân nhánh 4 chiều với 14 phần tử, chỉ sâu 2 cấp độ. Nếu bạn cuộn lên một chút, bạn sẽ thấy một hình với hai vectơ lần lượt chứa 13 và 12 phần tử. Với sự phân nhánh hai chiều, cái này đã sâu 4 tầng rồi, cao gấp đôi cái này.

Do cây cực kỳ nông nên chúng tôi có xu hướng gọi các sửa đổi và tra cứu các vectơ Clojure là thời gian không đổi “hiệu quả”, mặc dù chúng, trong lý thuyết là O(log32 n). Người có kiến thức cơ bản về ký hiệu O lớn biết rằng điều này hoàn toàn giống với O(log n), nhưng vì lý do tiếp thị, mọi người muốn thêm vào hệ số không đổi.
Tiếp theo
Hy vọng rằng điều này đã giúp bạn hiểu rõ hơn về cách vectơ liên tục của Clojure hoạt động và ý tưởng chung đằng sau việc cập nhật, nối thêm và bật lên, cùng với sự hiểu biết không chính thức về cách họ đạt được tốc độ của mình. Một số tối ưu hóa trên cả hai việc nối thêm và bật lên có thể được thực hiện và tôi sẽ viết blog về điều đó sau khi tôi viết xong về các phần “quan trọng” hơn của vectơ. Đặc biệt là cái đuôi, thế nào quá độ hoạt động và cách hoạt động của subvec trên cấu trúc có liên quan hơn và do đó sẽ xuất hiện trước các chi tiết triển khai nhỏ.
Phần 2 của loạt blog nói về cách một người thực sự phân nhánh, và cách chúng tôi tra cứu chi tiết các phần tử.
-
Bạn có thể coi đây là một cách đơn giản hóa việc chọn tuyến đường, mặc dù nó có một số tác động đến tốc độ JVM do tính năng tối ưu hóa thích ứng của JVM. Chúng ta sẽ có cái nhìn sâu hơn về điều này sau. ↩
Tác giả: mirzap