Các từ điển tích hợp của Python được triển khai như thế nào?


294

Có ai biết làm thế nào các loại từ điển xây dựng cho python được thực hiện? Tôi hiểu rằng đó là một loại bảng băm, nhưng tôi không thể tìm thấy bất kỳ loại câu trả lời dứt khoát nào.


4
Đây là một cuộc nói chuyện sâu sắc về từ điển Python từ 2.7 đến 3.6. Liên kết
Sören

Câu trả lời:


494

Dưới đây là tất cả mọi thứ về các câu lệnh Python mà tôi có thể kết hợp lại với nhau (có lẽ nhiều hơn bất kỳ ai muốn biết; nhưng câu trả lời là toàn diện).

  • Từ điển Python được thực hiện dưới dạng bảng băm .
  • Các bảng băm phải cho phép các xung đột băm tức là ngay cả khi hai khóa riêng biệt có cùng giá trị băm, việc triển khai bảng phải có chiến lược để chèn và truy xuất các cặp khóa và giá trị một cách rõ ràng.
  • Python dictsử dụng địa chỉ mở để giải quyết các va chạm băm (giải thích bên dưới) (xem dictobject.c: 296-297 ).
  • Bảng băm Python chỉ là một khối bộ nhớ liền kề (giống như một mảng, vì vậy bạn có thể thực hiện O(1)tra cứu theo chỉ mục).
  • Mỗi khe trong bảng có thể lưu trữ một và chỉ một mục nhập. Điều này quan trọng.
  • Mỗi mục trong bảng thực sự là sự kết hợp của ba giá trị: <băm, khóa, giá trị> . Điều này được thực hiện dưới dạng cấu trúc C (xem dictobject.h: 51-56 ).
  • Hình dưới đây là một biểu diễn logic của bảng băm Python. Trong hình bên dưới, 0, 1, ..., i, ...bên trái là các chỉ số của các vị trí trong bảng băm (chúng chỉ nhằm mục đích minh họa và rõ ràng không được lưu trữ cùng với bảng!).

    # Logical model of Python Hash table
    -+-----------------+
    0| <hash|key|value>|
    -+-----------------+
    1|      ...        |
    -+-----------------+
    .|      ...        |
    -+-----------------+
    i|      ...        |
    -+-----------------+
    .|      ...        |
    -+-----------------+
    n|      ...        |
    -+-----------------+
  • Khi một lệnh mới được khởi tạo, nó bắt đầu với 8 vị trí . (xem dictobject.h: 49 )

  • Khi thêm các mục vào bảng, chúng tôi bắt đầu với một số vị trí, idựa trên hàm băm của khóa. CPython ban đầu sử dụng i = hash(key) & mask(ở đâu mask = PyDictMINSIZE - 1, nhưng điều đó không thực sự quan trọng). Chỉ cần lưu ý rằng vị trí ban đầu, iđược kiểm tra phụ thuộc vào hàm băm của khóa.
  • Nếu vị trí đó trống, mục nhập được thêm vào vị trí (ý tôi là, <hash|key|value>). Nhưng nếu khe đó bị chiếm thì sao!? Rất có thể vì một mục khác có cùng hàm băm (va chạm băm!)
  • Nếu vị trí bị chiếm dụng, CPython (và thậm chí PyPy) sẽ so sánh hàm băm VÀ khóa (bằng cách so sánh tôi có nghĩa là ==so sánh không phải là isso sánh) của mục nhập trong vị trí so với hàm băm và khóa của mục nhập hiện tại sẽ được chèn ( dictobject.c : 337.344-345 ) tương ứng. Nếu cả hai khớp nhau, thì nó nghĩ rằng mục nhập đã tồn tại, từ bỏ và chuyển sang mục tiếp theo sẽ được chèn. Nếu hàm băm hoặc khóa không khớp, nó sẽ bắt đầu thăm dò .
  • Probing chỉ có nghĩa là nó tìm kiếm các vị trí theo vị trí để tìm một vị trí trống. Về mặt kỹ thuật, chúng tôi chỉ có thể đi từng cái một i+1, i+2, ...và sử dụng cái đầu tiên có sẵn (đó là thăm dò tuyến tính). Nhưng vì những lý do được giải thích rất hay trong các bình luận (xem dictobject.c: 33-126 ), CPython sử dụng phương pháp thăm dò ngẫu nhiên . Trong thăm dò ngẫu nhiên, vị trí tiếp theo được chọn theo thứ tự giả ngẫu nhiên. Các mục được thêm vào khe trống đầu tiên. Đối với cuộc thảo luận này, thuật toán thực tế được sử dụng để chọn vị trí tiếp theo không thực sự quan trọng (xem dictobject.c: 33-126 để biết thuật toán thăm dò). Điều quan trọng là các vị trí được thăm dò cho đến khi tìm thấy vị trí trống đầu tiên.
  • Điều tương tự cũng xảy ra đối với việc tra cứu, chỉ bắt đầu với vị trí ban đầu i (trong đó tôi phụ thuộc vào hàm băm của khóa). Nếu cả hàm băm và khóa đều không khớp với mục trong vị trí, nó sẽ bắt đầu thăm dò, cho đến khi tìm thấy vị trí có khớp. Nếu tất cả các vị trí đã hết, nó báo lỗi.
  • BTW, dictsẽ được thay đổi kích thước nếu nó đầy hai phần ba. Điều này tránh làm chậm việc tra cứu. (xem dictobject.h: 64-65 )

LƯU Ý: Tôi đã thực hiện nghiên cứu về triển khai Python Dict để trả lời câu hỏi của riêng tôi về cách nhiều mục trong một dict có thể có cùng giá trị băm. Tôi đã đăng một phiên bản chỉnh sửa một chút của câu trả lời ở đây vì tất cả các nghiên cứu đều rất phù hợp với câu hỏi này.


8
Bạn đã nói, khi cả băm và khóa khớp nhau, nó (insert op) sẽ bỏ cuộc và tiếp tục. Không chèn ghi đè mục hiện có trong trường hợp này?
0xc0de

65

Các từ điển tích hợp của Python được triển khai như thế nào?

Đây là khóa học ngắn hạn:

  • Chúng là bảng băm. (Xem bên dưới để biết chi tiết cụ thể về cách triển khai của Python.)
  • Một bố cục và thuật toán mới, như Python 3.6, làm cho chúng
    • ra lệnh bằng cách chèn khóa và
    • chiếm ít không gian hơn
    • hầu như không có chi phí trong hiệu suất.
  • Một tối ưu hóa khác tiết kiệm không gian khi các phím chia sẻ (trong trường hợp đặc biệt).

Khía cạnh được đặt hàng không chính thức như Python 3.6 (để cho các triển khai khác có cơ hội theo kịp), nhưng chính thức trong Python 3.7 .

Từ điển của Python là Bảng Hash

Trong một thời gian dài, nó hoạt động chính xác như thế này. Python sẽ phân bổ 8 hàng trống và sử dụng hàm băm để xác định vị trí gắn cặp khóa-giá trị. Ví dụ: nếu hàm băm cho khóa kết thúc vào năm 001, nó sẽ gắn nó vào chỉ mục 1 (tức là thứ 2) (như ví dụ dưới đây.)

   <hash>       <key>    <value>
     null        null    null
...010001    ffeb678c    633241c4 # addresses of the keys and values
     null        null    null
      ...         ...    ...

Mỗi hàng chiếm 24 byte trên kiến ​​trúc 64 bit, 12 trên 32 bit. (Lưu ý rằng các tiêu đề cột chỉ là nhãn cho mục đích của chúng tôi ở đây - chúng không thực sự tồn tại trong bộ nhớ.)

Nếu hàm băm kết thúc giống như hàm băm của khóa có sẵn, đây là một xung đột và sau đó nó sẽ gắn cặp khóa-giá trị ở một vị trí khác.

Sau khi 5 giá trị khóa được lưu trữ, khi thêm một cặp khóa-giá trị khác, xác suất va chạm băm là quá lớn, do đó từ điển có kích thước gấp đôi. Trong quy trình 64 bit, trước khi thay đổi kích thước, chúng ta có 72 byte trống và sau đó, chúng ta đang lãng phí 240 byte do 10 hàng trống.

Điều này tốn rất nhiều không gian, nhưng thời gian tra cứu khá không đổi. Thuật toán so sánh khóa là tính toán hàm băm, đi đến vị trí dự kiến, so sánh id của khóa - nếu chúng là cùng một đối tượng, chúng bằng nhau. Nếu không thì so sánh các giá trị băm, nếu chúng không giống nhau, chúng không bằng nhau. Khác, cuối cùng chúng ta so sánh các khóa cho đẳng thức, và nếu chúng bằng nhau, trả về giá trị. So sánh cuối cùng cho sự bình đẳng có thể khá chậm, nhưng các kiểm tra trước đó thường rút ngắn so sánh cuối cùng, làm cho việc tra cứu rất nhanh.

Các va chạm làm chậm mọi thứ và về mặt lý thuyết, kẻ tấn công có thể sử dụng các va chạm băm để thực hiện tấn công từ chối dịch vụ, vì vậy chúng tôi đã ngẫu nhiên khởi tạo hàm băm sao cho nó tính toán các giá trị băm khác nhau cho mỗi quy trình Python mới.

Không gian lãng phí được mô tả ở trên đã khiến chúng tôi sửa đổi việc thực hiện từ điển, với một tính năng mới thú vị mà từ điển hiện được đặt hàng bằng cách chèn.

Bảng Hash nhỏ gọn mới

Thay vào đó, chúng tôi bắt đầu bằng cách sắp xếp một mảng cho chỉ mục của phần chèn.

Vì cặp khóa-giá trị đầu tiên của chúng tôi đi vào vị trí thứ hai, chúng tôi lập chỉ mục như sau:

[null, 0, null, null, null, null, null, null]

Và bảng của chúng tôi chỉ được điền theo thứ tự chèn:

   <hash>       <key>    <value>
...010001    ffeb678c    633241c4 
      ...         ...    ...

Vì vậy, khi chúng tôi tìm kiếm một khóa, chúng tôi sử dụng hàm băm để kiểm tra vị trí mà chúng tôi mong đợi (trong trường hợp này, chúng tôi đi thẳng đến chỉ mục 1 của mảng), sau đó đi đến chỉ mục đó trong bảng băm (ví dụ: chỉ mục 0 ), kiểm tra xem các khóa có bằng nhau không (sử dụng cùng một thuật toán được mô tả trước đó) và nếu vậy, trả về giá trị.

Chúng tôi duy trì thời gian tra cứu liên tục, với một số tổn thất tốc độ nhỏ trong một số trường hợp và tăng trong những trường hợp khác, với những mặt tích cực mà chúng tôi tiết kiệm được khá nhiều không gian so với việc triển khai trước đó và chúng tôi giữ lại thứ tự chèn. Không gian bị lãng phí duy nhất là các byte null trong mảng chỉ mục.

Raymond Hettinger đã giới thiệu điều này trên python-dev vào tháng 12 năm 2012. Cuối cùng, nó đã vào CPython trong Python 3.6 . Đặt hàng bằng cách chèn được coi là một chi tiết triển khai cho 3.6 để cho phép các triển khai khác của Python có cơ hội bắt kịp.

Khóa chia sẻ

Một tối ưu hóa khác để tiết kiệm không gian là một triển khai chia sẻ các khóa. Do đó, thay vì có các từ điển dư thừa chiếm hết không gian đó, chúng tôi có các từ điển sử dụng lại các khóa băm và khóa chung. Bạn có thể nghĩ về nó như thế này:

     hash         key    dict_0    dict_1    dict_2...
...010001    ffeb678c    633241c4  fffad420  ...
      ...         ...    ...       ...       ...

Đối với máy 64 bit, điều này có thể tiết kiệm tới 16 byte cho mỗi khóa cho mỗi từ điển phụ.

Khóa chia sẻ cho các đối tượng và lựa chọn thay thế

Các mã khóa chia sẻ này được dự định sẽ được sử dụng cho các đối tượng tùy chỉnh ' __dict__. Để có được hành vi này, tôi tin rằng bạn cần hoàn thành việc điền vào __dict__trước khi bạn khởi tạo đối tượng tiếp theo ( xem PEP 412 ). Điều này có nghĩa là bạn nên gán tất cả các thuộc tính của mình trong __init__hoặc __new__, nếu không bạn có thể không nhận được khoản tiết kiệm không gian của mình.

Tuy nhiên, nếu bạn biết tất cả các thuộc tính của mình tại thời điểm bạn __init__được thực thi, bạn cũng có thể cung cấp __slots__cho đối tượng của mình và đảm bảo rằng __dict__nó hoàn toàn không được tạo (nếu không có sẵn ở cha mẹ) hoặc thậm chí cho phép __dict__nhưng đảm bảo rằng các thuộc tính dự kiến ​​của bạn là lưu trữ trong các khe dù sao. Để biết thêm về __slots__, xem câu trả lời của tôi ở đây .

Xem thêm:


1
Bạn đã nói "chúng tôi" và "cho phép các triển khai khác của Python có cơ hội bắt kịp" - điều này có nghĩa là bạn "biết điều" và điều đó có thể trở thành một tính năng vĩnh viễn? Có bất kỳ nhược điểm nào cho các dicts được đặt hàng bởi spec?
toonarmycaptain

Nhược điểm của việc được ra lệnh là nếu dự kiến ​​sẽ ra lệnh, họ không thể dễ dàng chuyển sang thực hiện tốt hơn / nhanh hơn mà không được yêu cầu. Có vẻ như đó không phải là trường hợp. Tôi "biết điều" bởi vì tôi xem nhiều cuộc nói chuyện và đọc nhiều điều được viết bởi các thành viên cốt lõi và những người khác có danh tiếng trong thế giới thực tốt hơn tôi, vì vậy ngay cả khi tôi không có nguồn trích dẫn ngay lập tức, tôi thường biết những gì tôi đang nói về. Nhưng tôi nghĩ bạn có thể có được điểm đó từ một trong những cuộc nói chuyện của Raymond Hettinger.
Aaron Hall

1
Bạn đã giải thích một cách mơ hồ về cách thức hoạt động của công cụ chèn ("Nếu hàm băm kết thúc giống như hàm băm của khóa có sẵn, ... thì nó sẽ gắn cặp khóa-giá trị ở một vị trí khác" - bất kỳ?), Nhưng bạn không giải thích làm thế nào tra cứu và kiểm tra thành viên làm việc. Vẫn chưa rõ vị trí được xác định bởi hàm băm, nhưng tôi cho rằng kích thước luôn là lũy thừa bằng 2 và bạn lấy một vài bit cuối cùng của hàm băm ...
Alexey

@Alexey Liên kết cuối cùng tôi cung cấp cho bạn cách triển khai chính tả được chú thích tốt - nơi bạn có thể tìm thấy chức năng này, hiện tại trên dòng 969, được gọi là find_empty_slot: github.com/python/cpython/blob/master/Objects/dictobject.c # L969 - và bắt đầu trên dòng 134 có một số văn xuôi mô tả nó.
Aaron Hall

46

Từ điển Python sử dụng địa chỉ mở ( tham chiếu bên trong Mã đẹp )

Lưu ý! Địa chỉ mở , hay còn gọi là băm đóng , như đã lưu ý trong Wikipedia, không nên nhầm lẫn với băm mở đối diện của nó !

Địa chỉ mở có nghĩa là dict sử dụng các vị trí mảng và khi vị trí chính của đối tượng được lấy trong dict, vị trí của đối tượng được tìm kiếm ở một chỉ mục khác trong cùng một mảng, sử dụng sơ đồ "nhiễu loạn", trong đó giá trị băm của đối tượng đóng vai trò .


5
"không bị nhầm lẫn với băm mở đối diện của nó! (mà chúng ta thấy trong câu trả lời được chấp nhận)." - Tôi không chắc câu trả lời nào được chấp nhận khi bạn viết câu đó hoặc câu trả lời đã nói vào lúc đó - nhưng nhận xét được ngoặc đơn này hiện không đúng với câu trả lời được chấp nhận và tốt nhất nên xóa.
Tony Delroy
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.