Go: Map Extend
Mục đích của việc sử dụng bảng băm (map) là để tìm kiếm nhanh các key mục tiêu. Tuy nhiên, khi thêm key vào map, khả năng xảy ra va chạm key cũng ngày càng cao. 8 ô (cell) trong mỗi bucket sẽ dần trở nên đầy, hiệu suất tìm kiếm, chèn và xóa key cũng sẽ giảm đi. Trường hợp lý tưởng là mỗi bucket chỉ chứa một key, điều này sẽ đạt được hiệu suất O(1), nhưng đồng thời cũng tiêu tốn quá nhiều không gian.
Go sử dụng 8 key trong mỗi bucket, sau đó lại phải xác định key cụ thể trong bucket đó, điều này lại tốn thời gian để tiết kiệm không gian.
Tuy nhiên, cần có một chỉ số để đánh giá tình trạng đã mô tả ở trên, đó là “tỷ lệ tải” (load factor). Trong mã nguồn Go, “tỷ lệ tải” được định nghĩa như sau:
Ở đây, count là số lượng phần tử trong map, 2^B đại diện cho số lượng bucket.
Tiếp theo là điều kiện để kích hoạt việc mở rộng map: Khi thêm key vào map, điều kiện sau sẽ được kiểm tra, nếu thỏa mãn cả hai điều kiện, việc mở rộng sẽ được kích hoạt:
- Tỷ lệ tải vượt qua ngưỡng, ngưỡng được định nghĩa trong mã nguồn là 6.5.
- Số lượng bucket overflow quá nhiều: Khi B nhỏ hơn 15, tức là số lượng bucket tổng cộng 2^B nhỏ hơn 2^15, nếu số lượng bucket overflow vượt quá 2^B; Khi B >= 15, tức là số lượng bucket tổng cộng 2^B lớn hơn hoặc bằng 2^15, nếu số lượng bucket overflow vượt quá 2^15.
Trong mã nguồn Go, các hàm kiểm tra điều kiện này được viết như sau:
Giải thích:
- Điều kiện 1: Chúng ta biết rằng mỗi bucket có 8 ô trống. Trong trường hợp không có overflow và tất cả các bucket đều đầy, tỷ lệ tải tính toán ra là 8. Do đó, khi tỷ lệ tải vượt quá 6.5, điều này cho thấy nhiều bucket sắp đầy, hiệu suất tìm kiếm và chèn giảm đi. Khi điều kiện này xảy ra, việc mở rộng là cần thiết.
- Điều kiện 2: Đây là bổ sung cho điều kiện 1. Điều này ám chỉ rằng trong trường hợp tỷ lệ tải khá nhỏ, điều này có nghĩa là tổng số phần tử trong map ít, nhưng số lượng bucket lại nhiều (thực tế được phân bổ nhiều bucket, bao gồm nhiều bucket overflow). Hiện tượng này xảy ra khi chúng ta liên tục thêm và xóa các phần tử. Đầu tiên, chúng ta thêm nhiều phần tử, dẫn đến việc tạo ra nhiều bucket, nhưng tỷ lệ tải không đạt ngưỡng của điều kiện 1, không kích hoạt việc mở rộng để giải quyết tình huống này. Sau đó, chúng ta xóa các phần tử để giảm tổng số phần tử, sau đó thêm nhiều phần tử khác, dẫn đến việc tạo ra nhiều bucket overflow, nhưng không vi phạm điều kiện 1. Điều này dẫn đến việc áp dụng điều kiện 2. Đây giống như một thành phố trống, có nhiều nhà nhưng ít người, tất cả đều phân tán, việc tìm kiếm trở nên khó khăn.
Cả hai điều kiện trên đều sẽ kích hoạt việc mở rộng. Tuy nhiên, chiến lược mở rộng sẽ khác nhau, vì hai điều kiện này đối phó với các tình huống khác nhau.
Đối với điều kiện 1, khi có quá nhiều phần tử và quá ít bucket, quá trình mở rộng đơn giản: tăng B lên 1, số lượng bucket tối đa (2^B) tăng gấp đôi số lượng bucket ban đầu (2^B * 2). Lưu ý rằng lúc này, các phần tử vẫn nằm trong bucket cũ và chưa được chuyển sang bucket mới. Bucket mới chỉ có số lượng tối đa là số lượng bucket ban đầu (2^B).
Đối với điều kiện 2, thực tế là không có quá nhiều phần tử, nhưng có quá nhiều bucket overflow, cho thấy nhiều bucket chưa đầy. Giải pháp là tạo ra một bucket mới và di chuyển các phần tử từ bucket cũ sang bucket mới, làm cho các key trong cùng một bucket được sắp xếp gần nhau hơn. Kết quả là tiết kiệm không gian, tăng hiệu suất sử dụng bucket và cải thiện hiệu suất tìm kiếm và chèn của map.
Về cách thực hiện mở rộng, vì việc mở rộng map yêu cầu di chuyển lại các key/value từ địa chỉ bộ nhớ cũ sang địa chỉ bộ nhớ mới, nếu có nhiều key/value cần di chuyển, nó sẽ ảnh hưởng đến hiệu suất. Do đó, quá trình mở rộng của map trong Go được thực hiện theo cách gọi là “mở rộng dần”, nghĩa là các key hiện có không được di chuyển một lần, mà chỉ di chuyển tối đa 2 bucket mỗi lần.
Trước tiên, chúng ta hãy xem công việc được thực hiện trong hàm hashGrow()
, sau đó xem cách di chuyển bucket được thực hiện trong growWork()
.
Chức năng chính là cấp phát không gian mới cho các bucket, xử lý các cờ liên quan: ví dụ, cờ nevacuate được đặt thành 0, đại diện cho tiến trình di chuyển hiện tại là 0.
Cần đề cập đến xử lý của h.flags
:
Đây là toán tử &^
, còn được gọi là toán tử “and not”. Ví dụ:
Nếu bit tương ứng của y là 1, thì bit tương ứng của z sẽ là 0, ngược lại bit tương ứng của z sẽ giống với giá trị bit tương ứng của x.
Vì vậy, đoạn mã trên xử lý flags như sau: trước tiên, xóa các bit tương ứng của iterator và oldIterator trong h.flags, sau đó, nếu bit iterator là 1, thì chuyển nó sang bit oldIterator, làm cho oldIterator trở thành 1. Ý nghĩa ẩn đằng sau là: buckets hiện tại đã được gắn vào oldbuckets, các bit tương ứng cũng được chuyển sang oldIterator.
Một số cờ tương ứng như sau:
Tiếp theo, chúng ta sẽ xem hàm growWork()
thực hiện công việc di chuyển thực sự.
Hàm h.growing()
rất đơn giản:
Nếu oldbuckets
không nil
, điều này cho thấy việc di chuyển vẫn chưa hoàn thành và cần tiếp tục di chuyển.
Dòng code bucket&h.oldbucketmask()
như được giải thích trong chú thích mã nguồn, được sử dụng để xác định bucket cần di chuyển là bucket đang sử dụng. Hàm oldbucketmask()
trả về giá trị mask của map trước khi mở rộng.
Khái niệm bucketmask có tác dụng làm cho giá trị hash của key và bucketmask AND với nhau, kết quả thu được là bucket mà key nên nằm trong. Ví dụ, nếu B = 5, thì bucketmask có 5 bit thấp là 11111
, các bit khác là 0
. Việc AND giữa giá trị hash và bucketmask có nghĩa là chỉ có 5 bit thấp của giá trị hash quyết định key nằm trong bucket nào.
Tiếp theo, chúng ta tập trung vào hàm evacuate, là hàm quan trọng trong quá trình di chuyển. Mã nguồn được đính kèm dưới đây, đừng lo lắng, tôi sẽ thêm chú thích rộng lớn, chú thích này chắc chắn sẽ giúp bạn hiểu được. Sau đó, tôi sẽ giải thích quá trình di chuyển chi tiết.
Dưới đây là mã nguồn:
Hàm evacuate được sử dụng để di chuyển các bucket cũ sang các bucket mới trong quá trình mở rộng của map. Nhìn vào mã nguồn và các chú thích, ta có thể dễ dàng hiểu quá trình di chuyển tổng thể.
Mục đích của quá trình di chuyển là để chuyển các bucket cũ sang các bucket mới. Từ những giải thích trước đó, ta biết rằng để đáp ứng điều kiện 1, số lượng bucket mới sẽ là gấp đôi số lượng bucket cũ, và để đáp ứng điều kiện 2, số lượng bucket mới sẽ giống với số lượng bucket cũ.
Đối với điều kiện 2, việc di chuyển từ bucket cũ sang bucket mới có thể được thực hiện theo thứ tự số hiệu của bucket. Ví dụ, nếu trước đây nằm trong bucket số 0, sau khi di chuyển đến vị trí mới, nó vẫn sẽ nằm trong bucket số 0.
Đối với điều kiện 1, việc di chuyển từ bucket cũ sang bucket mới không đơn giản như vậy. Cần tính toán lại giá trị băm của key để xác định nó thuộc bucket nào. Ví dụ, trước đây B = 5, sau khi tính toán giá trị băm của key, chỉ cần xem 5 bit thấp nhất để xác định key thuộc bucket nào. Sau khi mở rộng, B trở thành 6, vì vậy cần xem thêm 1 bit, bit thứ 6 để xác định key thuộc bucket nào. Điều này được gọi là rehash
.
Do đó, một key có thể có số hiệu bucket trước và sau khi di chuyển có thể giống nhau hoặc tăng lên 2^B (giá trị B ban đầu), phụ thuộc vào bit thứ 6 của giá trị băm.
Do đó, một key có thể có số hiệu bucket trước và sau khi di chuyển có thể giống nhau hoặc tăng lên 2^B (giá trị B ban đầu), phụ thuộc vào bit thứ 6 của giá trị băm.
Vào cuối, chúng ta cần làm rõ một điều: nếu sau khi mở rộng, B tăng lên 1, điều đó có nghĩa là tổng số bucket là gấp đôi số lượng ban đầu, bucket số 1 “phân kỳ” thành hai bucket.
Ví dụ, ban đầu B = 2, trong bucket số 1 có 2 key, giá trị băm thấp nhất của chúng là 010 và 110. Vì B ban đầu là 2, nên 2 bit thấp 10
quyết định chúng thuộc bucket số 2. Bây giờ B đã trở thành 3, vì vậy 010
và 110
lần lượt thuộc bucket số 2 và 6.
Hiểu được điều này, chúng ta sẽ sử dụng nó khi nói về việc lặp lại map.
Tiếp theo, hãy nói về một số điểm quan trọng trong hàm di chuyển:
Hàm evacuate chỉ hoàn thành việc di chuyển của một bucket trong mỗi lần gọi, do đó cần duyệt qua tất cả các cell trong bucket đó và sao chép các cell có giá trị sang vị trí mới. Bucket cũng có thể liên kết với các overflow bucket, chúng cũng cần được di chuyển. Do đó, có 2 vòng lặp, vòng lặp bên ngoài để duyệt qua các bucket và overflow bucket, vòng lặp bên trong để duyệt qua tất cả các cell trong bucket. Các vòng lặp như vậy xuất hiện khắp nơi trong mã nguồn của map, cần hiểu rõ.
Mã nguồn đề cập đến phần X, Y, thực tế là phần X là nửa đầu của bucket, phần Y là nửa sau của bucket. Một key trong một bucket có thể bị chia thành 2 bucket, một nằm trong phần X, một nằm trong phần Y. Vì vậy, trước khi di chuyển một cell, cần biết cell đó thuộc phần nào. Đơn giản, tính lại giá trị băm của key trong cell và xem thêm một bit để xác định nó thuộc phần nào, như đã được giải thích ở trên.
Có một trường hợp đặc biệt là: có một loại key, mỗi lần tính giá trị băm của nó, kết quả sẽ khác nhau. Key này chính là kết quả của math.NaN()
, có ý nghĩa là not a number
, kiểu dữ liệu là float64. Khi nó được sử dụng làm key trong map, trong quá trình di chuyển, sẽ gặp một vấn đề: tính toán lại giá trị băm của nó sẽ cho kết quả khác với giá trị băm ban đầu khi chèn key vào map!
Bạn có thể nghĩ rằng, điều này có nghĩa là key này sẽ không bao giờ được lấy ra bằng cách sử dụng câu lệnh m[math.NaN()]
! Khi tôi sử dụng câu lệnh đó, tôi sẽ không nhận được kết quả. Key này chỉ có cơ hội xuất hiện khi duyệt qua toàn bộ map. Vì vậy, có thể chèn bất kỳ số lượng math.NaN()
nào vào map làm key.
Khi di chuyển gặp key math.NaN()
, chỉ cần sử dụng bit thấp nhất của tophash để xác định nó được phân vào phần X hay phần Y (nếu sau khi mở rộng, số lượng bucket là gấp đôi số lượng bucket ban đầu). Nếu bit thấp nhất của tophash là 0, nó được phân vào phần X; nếu là 1, nó được phân vào phần Y.
Đúng, việc xác định bucket đích dựa trên giá trị tophash và giá trị băm mới được tính toán như sau:
Thực tế, key như vậy có thể di chuyển vào bất kỳ bucket nào, tuy nhiên, vẫn cần di chuyển vào hai bucket được chia sẻ như trong hình split bucket. Nhưng việc này có lợi ích, sẽ được giải thích chi tiết hơn khi nói về việc lặp lại map.
Sau khi xác định bucket đích, quá trình di chuyển trở nên dễ dàng hơn. Chỉ cần sao chép giá trị key/value từ vị trí nguồn sang vị trí đích tương ứng.
Đặt giá trị tophash của key trong bucket cũ thành evacuatedX
hoặc evacuatedY
, cho biết key đã được di chuyển vào phần X hoặc phần Y của map mới. Giá trị tophash của map mới được lấy từ 8 bit cao của giá trị băm của key.
Dưới đây là một cái nhìn tổng quan về sự thay đổi trước và sau khi mở rộng, được biểu diễn bằng các hình ảnh.
Trước khi mở rộng, B = 2, có tổng cộng 4 bucket, lowbits đại diện cho các bit thấp của giá trị băm. Giả sử chúng ta chỉ quan tâm đến bucket số 2. Và giả sử có quá nhiều overflow, gây ra mở rộng cùng kích thước (tương ứng với điều kiện 2 đã đề cập trước đó).
Sau khi mở rộng, các overflow bucket biến mất và các key tập trung vào một bucket, tăng tính hiệu quả của việc tìm kiếm.
Giả sử xảy ra mở rộng gấp đôi, sau khi mở rộng, key trong các bucket cũ được chia thành 2 bucket mới. Một bucket thuộc phần X, một bucket thuộc phần Y. Việc phân chia dựa trên lowbits của giá trị băm. Các bucket từ 0 đến 3 được gọi là phần X, các bucket từ 4 đến 7 được gọi là phần Y.
Lưu ý rằng hai hình ảnh trên đã bỏ qua tình trạng di chuyển của các bucket khác, chỉ đại diện cho tình trạng khi tất cả các bucket đã được di chuyển hoàn toàn. Trong thực tế, chúng ta biết rằng quá trình di chuyển là một quá trình “từ từ”, không thể di chuyển toàn bộ cùng một lúc. Vì vậy, con trỏ oldbuckets vẫn trỏ đến các []bmap
cũ và giá trị tophash của key đã được di chuyển sẽ là giá trị trạng thái, cho biết key đã được di chuyển đến đâu.