Tại sao một lệnh Python có thể có nhiều khóa với cùng một hàm băm?


90

Tôi đang cố gắng hiểu hashhàm Python dưới mui xe. Tôi đã tạo một lớp tùy chỉnh nơi tất cả các phiên bản trả về cùng một giá trị băm.

class C:
    def __hash__(self):
        return 42

Tôi chỉ giả định rằng chỉ có một thể hiện của lớp trên có thể dictở bất kỳ lúc nào, nhưng trên thực tế, a dictcó thể có nhiều phần tử với cùng một hàm băm.

c, d = C(), C()
x = {c: 'c', d: 'd'}
print(x)
# {<__main__.C object at 0x7f0824087b80>: 'c', <__main__.C object at 0x7f0823ae2d60>: 'd'}
# note that the dict has 2 elements

Tôi đã thử nghiệm nhiều hơn một chút và nhận thấy rằng nếu tôi ghi đè __eq__phương thức sao cho tất cả các trường hợp của lớp so sánh bằng nhau, thì dictchỉ cho phép một trường hợp.

class D:
    def __hash__(self):
        return 42
    def __eq__(self, other):
        return True

p, q = D(), D()
y = {p: 'p', q: 'q'}
print(y)
# {<__main__.D object at 0x7f0823a9af40>: 'q'}
# note that the dict only has 1 element

Vì vậy, tôi tò mò muốn biết làm thế nào một dictcó thể có nhiều phần tử với cùng một hàm băm.


3
Như bạn đã tự khám phá, các tập hợp và phân đoạn có thể chứa nhiều đối tượng với các hàm băm bằng nhau nếu bản thân các đối tượng không bằng nhau. Bạn hỏi gì? Bảng hoạt động như thế nào? Đó là một câu hỏi khá chung chung với rất nhiều tài liệu hiện có ...

@delnan Tôi đã suy nghĩ thêm về điều này sau khi tôi đăng câu hỏi; rằng hành vi này không thể bị hạn chế đối với Python. Và bạn đã đúng. Tôi đoán tôi nên nghiên cứu sâu hơn về tài liệu Hash table nói chung. Cảm ơn.
Praveen Gollakota

Câu trả lời:


55

Để biết mô tả chi tiết về cách hoạt động của hàm băm trong Python, hãy xem câu trả lời của tôi cho Tại sao trả về sớm chậm hơn các phương pháp khác?

Về cơ bản, nó sử dụng hàm băm để chọn một vị trí trong bảng. Nếu có một giá trị trong vị trí và hàm băm khớp với nhau, nó sẽ so sánh các mục để xem chúng có bằng nhau không.

Nếu hàm băm không khớp hoặc các mục không bằng nhau, thì nó sẽ thử một vị trí khác. Có một công thức để chọn điều này (mà tôi mô tả trong câu trả lời được tham chiếu), và nó dần dần kéo các phần không sử dụng của giá trị băm; nhưng một khi nó đã sử dụng hết chúng, cuối cùng nó sẽ hoạt động theo cách của mình qua tất cả các vị trí trong bảng băm. Điều đó đảm bảo cuối cùng chúng tôi sẽ tìm thấy một mục phù hợp hoặc một vị trí trống. Khi tìm kiếm tìm thấy một vị trí trống, nó sẽ chèn giá trị hoặc loại bỏ (tùy thuộc vào việc chúng ta đang thêm hoặc nhận giá trị).

Điều quan trọng cần lưu ý là không có danh sách hoặc nhóm: chỉ có một bảng băm với một số vị trí cụ thể và mỗi hàm băm được sử dụng để tạo ra một chuỗi các vị trí ứng viên.


7
Cảm ơn đã chỉ cho tôi đúng hướng về việc triển khai bảng Hash. Tôi đã đọc nhiều hơn những gì tôi muốn về bảng băm và tôi đã giải thích những phát hiện của mình trong một câu trả lời riêng. stackoverflow.com/a/9022664/553995
Praveen Gollakota

112

Đây là mọi thứ về các phân đoạn Python mà tôi có thể tổng hợp lại (có thể nhiều hơn bất kỳ ai muốn biết; nhưng câu trả lời là toàn diện). Một lời hét lên với Duncan vì đã chỉ ra rằng các trò chơi Python sử dụng các khe cắm và dẫn tôi xuống lỗ thỏ này.

  • Từ điển Python được triển khai dưới dạng bảng băm .
  • Bảng băm phải cho phép xung đột băm tức là ngay cả khi hai khóa 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ặp khóa và giá trị một cách rõ ràng.
  • Python dict sử dụng địa chỉ mở để giải quyết xung đột 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ớ dự phòng (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 vị trí trong bảng có thể lưu trữ một và chỉ một mục nhập. Cái này quan trọng
  • Mỗi mục nhập trong bảng thực sự là sự kết hợp của ba giá trị:. Điều này được triển khai dưới dạng cấu trúc C (xem dictobject.h: 51-56 )
  • Hình bên dưới 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à chỉ số của các vị trí trong bảng băm (chúng chỉ dành cho mục đích minh họa và không được lưu trữ cùng với bảng rõ ràng!).

    # Logical model of Python Hash table
    -+-----------------+
    0| <hash|key|value>|
    -+-----------------+
    1|      ...        |
    -+-----------------+
    .|      ...        |
    -+-----------------+
    i|      ...        |
    -+-----------------+
    .|      ...        |
    -+-----------------+
    n|      ...        |
    -+-----------------+
    
  • Khi một chính tả 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í, iđược dựa trên băm của khóa. CPython sử dụng ban đầu 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 sẽ được thêm vào vị trí đó (ý tôi là, theo mục nhập, <hash|key|value>). Nhưng điều gì sẽ xảy ra nếu vị trí đó bị chiếm dụng !? Nhiều khả năng là do một mục nhập khác có cùng một hàm băm (xung đột băm!)
  • Nếu vị trí bị chiếm, CPython (và thậm chí cả PyPy) sẽ so sánh hàm băm VÀ khóa (bằng cách so sánh, ý tôi là ==so sánh không phải isso sánh) của mục nhập trong vị trí với khóa của mục nhập hiện tại sẽ được chèn ( dictobject.c: 337 , 344-345 ). Nếu cả hai khớp nhau, thì nó cho rằng mục nhập đã tồn tại, từ bỏ và chuyển sang mục nhập tiếp theo sẽ được chèn. Nếu băm hoặc khóa không khớp, nó sẽ bắt đầu thăm dò .
  • Việc dò tìm chỉ có nghĩa là nó tìm kiếm từng khe để tìm ra vị trí trống. Về mặt kỹ thuật, chúng ta có thể đi từng cái một, i + 1, i + 2, ... và sử dụng cái có sẵn đầu tiên (đó là thăm dò tuyến tính). Nhưng vì những lý do được giải thích rõ ràng trong các nhận xét (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ự ngẫu nhiên giả. Mục nhập được thêm vào vị trí 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 khe cắm đượ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 thứ i (nơi tôi phụ thuộc vào hàm băm của khóa). Nếu hàm băm và khóa đều không khớp với mục nhập trong vị trí, nó sẽ bắt đầu thăm dò, cho đến khi tìm thấy một vị trí phù hợp. Nếu tất cả các vị trí đã hết, nó sẽ báo lỗi.
  • BTW, chính tả sẽ được thay đổi kích thước nếu nó đầy 2/3. Điều này tránh làm chậm quá trình tra cứu. (xem dictobject.h: 64-65 )

Của bạn đây! Việc triển khai dict kiểm tra cả bình đẳng băm của hai khóa và bình đẳng thông thường ( ==) của các khóa khi chèn các mục. Vì vậy, trong Tóm lại, nếu có hai chìa khóa, abhash(a)==hash(b), nhưng a!=b, sau đó cả hai có thể tồn tại hài hòa trong một dict Python. Nhưng nếu hash(a)==hash(b) a==b , thì cả hai không thể ở trong cùng một câu lệnh.

Bởi vì chúng tôi phải thăm dò sau mỗi lần va chạm băm, một tác dụng phụ của quá nhiều lần va chạm băm là việc tra cứu và chèn sẽ trở nên rất chậm (như Duncan đã chỉ ra trong các nhận xét ).

Tôi đoán câu trả lời ngắn gọn cho câu hỏi của tôi là, "Bởi vì đó là cách nó được triển khai trong mã nguồn;)"

Mặc dù điều này là tốt để biết (đối với điểm đam mê?), Tôi không chắc nó có thể được sử dụng như thế nào trong cuộc sống thực. Bởi vì trừ khi bạn đang cố gắng phá vỡ điều gì đó một cách rõ ràng, tại sao hai đối tượng không bằng nhau lại có cùng một hàm băm?


8
Điều này giải thích cách điền từ điển hoạt động. Nhưng điều gì sẽ xảy ra nếu có xung đột băm trong quá trình truy xuất cặp key_value. Giả sử chúng ta có 2 đối tượng A và B, cả hai đều băm thành 4. Vì vậy, đầu tiên A được gán vị trí 4 và sau đó B được chỉ định vị trí thông qua thăm dò ngẫu nhiên. Điều gì sẽ xảy ra khi tôi muốn truy xuất B. B băm thành 4, vì vậy python đầu tiên kiểm tra vị trí 4, nhưng khóa không khớp nên nó không thể trả về A. Vì vị trí của B được chỉ định bằng cách thăm dò ngẫu nhiên, làm thế nào để trả lại B? trong O (1) thời gian?
sayantankhan

4
@ Bolt64 việc thăm dò ngẫu nhiên không thực sự ngẫu nhiên. Đối với các giá trị khóa giống nhau, nó luôn tuân theo cùng một chuỗi các thăm dò nên cuối cùng sẽ tìm thấy B. Các từ điển không được đảm bảo là O (1), nếu bạn gặp nhiều va chạm, chúng có thể mất nhiều thời gian hơn. Với các phiên bản cũ hơn của Python, dễ dàng tạo ra một loạt các khóa sẽ xung đột và trong trường hợp đó, tra cứu từ điển trở thành O (n). Đây là một vectơ có thể xảy ra cho các cuộc tấn công DoS vì vậy các phiên bản Python mới hơn sẽ sửa đổi hàm băm để làm cho việc này khó thực hiện hơn.
Duncan

2
@Duncan điều gì sẽ xảy ra nếu A bị xóa và sau đó chúng tôi thực hiện tra cứu trên B? Tôi đoán bạn không thực sự xóa các mục nhập nhưng đánh dấu chúng là đã xóa? Điều đó có nghĩa rằng dicts không phù hợp để chèn liên tục và xóa ....
gen-YS

2
@ gen-ys vâng đã xóa và không sử dụng được xử lý khác nhau để tra cứu. Chưa sử dụng sẽ dừng tìm kiếm đối sánh nhưng đã xóa thì không. Khi chèn hoặc đã xóa hoặc không sử dụng được coi là các khe trống có thể được sử dụng. Việc chèn và xóa liên tục đều ổn. Khi số lượng vị trí không sử dụng (không bị xóa) giảm xuống quá thấp, bảng băm sẽ được xây dựng lại theo cách giống như thể nó đã phát triển quá lớn so với bảng hiện tại.
Duncan

1
Đây không phải là một câu trả lời tốt cho điểm va chạm mà Duncan đã cố gắng khắc phục. Đó là một câu trả lời đặc biệt nghèo nàn để tham khảo cho việc triển khai từ câu hỏi của bạn. Điều quan trọng nhất để hiểu điều này là nếu có va chạm, Python sẽ thử lại bằng cách sử dụng công thức để tính toán bù đắp tiếp theo trong bảng băm. Khi truy xuất, nếu khóa không giống nhau, nó sẽ sử dụng công thức tương tự để tra cứu điểm bù tiếp theo. Không có gì ngẫu nhiên về nó.
Evan Carroll

20

Chỉnh sửa : câu trả lời bên dưới là một trong những cách có thể để đối phó với xung đột băm, tuy nhiên đó không phải là cách Python thực hiện điều đó. Wiki của Python được tham chiếu bên dưới cũng không chính xác. Nguồn tốt nhất được cung cấp bởi @Duncan bên dưới là chính quá trình triển khai: https://github.com/python/cpython/blob/master/Objects/dictobject.c Tôi xin lỗi vì sự nhầm lẫn này.


Nó lưu trữ một danh sách (hoặc nhóm) các phần tử tại hàm băm sau đó lặp qua danh sách đó cho đến khi nó tìm thấy khóa thực sự trong danh sách đó. Một bức tranh nói hơn một nghìn từ:

Bảng băm

Ở đây bạn thấy John SmithSandra Deecả hai băm thành 152. Xô 152chứa cả hai. Khi tìm kiếm Sandra Dee, đầu tiên nó sẽ tìm danh sách trong thùng 152, sau đó lặp qua danh sách đó cho đến khi Sandra Deetìm thấy và trả về 521-6955.

Điều sau là sai, nó chỉ ở đây cho ngữ cảnh: Trên wiki của Python, bạn có thể tìm thấy mã (giả?) Cách Python thực hiện tra cứu.

Thực sự có một số giải pháp khả thi cho vấn đề này, hãy xem bài viết trên wikipedia để có cái nhìn tổng quan tốt đẹp: http://en.wikipedia.org/wiki/Hash_table#Collision_resolution


Cảm ơn vì lời giải thích và đặc biệt là liên kết đến mục nhập wiki Python với mã giả!
Praveen Gollakota

2
Xin lỗi, nhưng câu trả lời này hoàn toàn sai (bài viết trên wiki cũng vậy). Python không lưu trữ danh sách hoặc nhóm các phần tử tại hàm băm: nó lưu trữ chính xác một đối tượng trong mỗi vị trí của bảng băm. Nếu vị trí mà nó cố gắng sử dụng lần đầu tiên bị chiếm dụng thì nó sẽ chọn một vị trí khác (kéo các phần không sử dụng của băm càng lâu càng tốt) rồi đến vị trí khác. Vì không có bảng băm nào đầy hơn một phần ba nên cuối cùng nó phải tìm được một vị trí có sẵn.
Duncan

@Duncan, wiki của Python cho biết nó được triển khai theo cách này. Tôi rất vui khi tìm thấy một nguồn tốt hơn. Trang wikipedia.org chắc chắn không sai, nó chỉ là một trong những giải pháp khả thi như đã nêu.
Rob Wouters

@Duncan Bạn có thể vui lòng giải thích ... kéo các phần không sử dụng của băm càng lâu càng tốt? Tất cả các băm trong trường hợp của tôi đánh giá đến 42. Cảm ơn!
Praveen Gollakota

@PraveenGollakota Thực hiện theo liên kết trong câu trả lời của tôi, giải thích chi tiết về cách sử dụng hàm băm. Đối với băm 42 và bảng có 8 khe ban đầu chỉ có 3 bit thấp nhất được sử dụng để tìm khe số 2 nhưng nếu khe đó đã được sử dụng thì các bit còn lại sẽ phát huy tác dụng. Nếu hai giá trị có cùng một hàm băm thì giá trị đầu tiên được thử ở vị trí đầu tiên và giá trị thứ hai nhận được vị trí tiếp theo. Nếu có 1000 giá trị với các hàm băm giống hệt nhau thì chúng tôi sẽ thử 1000 vị trí trước khi chúng tôi tìm thấy giá trị và việc tra cứu từ điển rất chậm!
Duncan

4

Các bảng băm, nói chung phải cho phép các xung đột băm! Bạn sẽ gặp xui xẻo và hai thứ cuối cùng sẽ băm về cùng một thứ. Bên dưới, có một tập hợp các đối tượng trong danh sách các mục có cùng khóa băm đó. Thông thường, chỉ có một thứ trong danh sách đó, nhưng trong trường hợp này, nó sẽ tiếp tục xếp chúng vào cùng một thứ. Cách duy nhất nó biết chúng khác nhau là thông qua toán tử bằng.

Khi điều này xảy ra, hiệu suất của bạn sẽ giảm dần theo thời gian, đó là lý do tại sao bạn muốn hàm băm của mình càng "ngẫu nhiên càng tốt".


2

Trong luồng, tôi không thấy chính xác python làm gì với các phiên bản của lớp do người dùng xác định khi chúng tôi đặt nó vào từ điển dưới dạng khóa. Hãy đọc một số tài liệu: nó tuyên bố rằng chỉ các đối tượng có thể băm mới có thể được sử dụng làm khóa. Hashable là tất cả các lớp tích hợp sẵn bất biến và tất cả các lớp do người dùng định nghĩa.

Các lớp do người dùng định nghĩa có các phương thức __cmp __ () và __hash __ () theo mặc định; với chúng, tất cả các đối tượng so sánh không bằng nhau (ngoại trừ với chính chúng) và x .__ hash __ () trả về kết quả bắt nguồn từ id (x).

Vì vậy, nếu bạn có một __hash__ liên tục trong lớp của mình, nhưng không cung cấp bất kỳ phương thức __cmp__ hoặc __eq__ nào, thì tất cả các trường hợp của bạn là không bình đẳng đối với từ điển. Mặt khác, nếu bạn cung cấp bất kỳ phương thức __cmp__ hoặc __eq__ nào, nhưng không cung cấp __hash__, các bản sao của bạn vẫn không bình đẳng về mặt từ điển.

class A(object):
    def __hash__(self):
        return 42


class B(object):
    def __eq__(self, other):
        return True


class C(A, B):
    pass


dict_a = {A(): 1, A(): 2, A(): 3}
dict_b = {B(): 1, B(): 2, B(): 3}
dict_c = {C(): 1, C(): 2, C(): 3}

print(dict_a)
print(dict_b)
print(dict_c)

Đầu ra

{<__main__.A object at 0x7f9672f04850>: 1, <__main__.A object at 0x7f9672f04910>: 3, <__main__.A object at 0x7f9672f048d0>: 2}
{<__main__.B object at 0x7f9672f04990>: 2, <__main__.B object at 0x7f9672f04950>: 1, <__main__.B object at 0x7f9672f049d0>: 3}
{<__main__.C object at 0x7f9672f04a10>: 3}
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.