Чому диктограма Python може мати кілька ключів з однаковим хешем?


91

Я намагаюся зрозуміти функцію Python hashпід капотом. Я створив власний клас, де всі екземпляри повертають однакове хеш-значення.

class C:
    def __hash__(self):
        return 42

Я просто припустив, що dictв будь-який момент може бути лише один екземпляр вищевказаного класу , але насправді a dictможе мати кілька елементів з однаковим хешем.

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

Я трохи більше експериментував і виявив, що якщо я перевизначаю __eq__метод таким чином, що всі екземпляри класу порівнюються рівними, тоді dictєдиний дозволяє один екземпляр.

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

Тому мені цікаво дізнатись, як це зробити dict може мати кілька елементів з однаковим хешем.


3
Як ви самі виявили, набори та дикти можуть містити кілька об’єктів з однаковими хешами, якщо об’єкти самі не рівні. Що ти запитуєш? Як працюють таблиці? Це досить загальне питання з великою кількістю існуючих матеріалів ...

@delnan Я більше думав про це після того, як опублікував запитання; що ця поведінка не може бути обмежена Python. І ти маєш рацію. Думаю, мені слід глибше заглибитися в загальну літературу хеш-таблиць. Дякую.
Praveen Gollakota

Відповіді:


55

Детальний опис того, як працює хешування Python, див. У моїй відповіді Чому раннє повернення повільніше, ніж інше?

В основному він використовує хеш для вибору слота в таблиці. Якщо в слоті є значення, а хеш відповідає, він порівнює елементи, щоб побачити, чи рівні вони.

Якщо хеш не збігається або елементи не рівні, тоді він спробує інший слот. Існує формула для вибору цього (яку я описую у відповіді на посилання), і вона поступово втягує невикористані частини хеш-значення; але як тільки він використає їх усі, він врешті-решт пройде шлях через усі слоти в хеш-таблиці. Це гарантує, що врешті-решт ми або знайдемо відповідний предмет, або порожній слот. Коли пошук знаходить порожній слот, він вставляє значення або здає (залежно від того, додаємо чи отримуємо значення).

Важливо відзначити, що немає списків або сегментів: існує лише хеш-таблиця з певною кількістю слотів, і кожен хеш використовується для створення послідовності слотів-кандидатів.


7
Дякуємо, що вказали мені в правильному напрямку щодо впровадження хеш-таблиці. Я читав набагато більше, ніж коли-небудь хотів про хеш-таблиці, і пояснив свої висновки в окремій відповіді. stackoverflow.com/a/9022664/553995
Praveen Gollakota

112

Ось все про диктовки Python, які мені вдалося скласти (мабуть, більше, ніж хтось хотів би знати; але відповідь вичерпна). Крик Дункану за те, що він вказав, що диктовки Python використовують слоти і ведуть мене вниз по цій кролячій норі.

  • Словники Python реалізовані як хеш-таблиці .
  • Хеш-таблиці повинні допускати колізійні зіткнення тобто навіть якщо два ключі мають однакове хеш-значення, реалізація таблиці повинна мати стратегію однозначної вставки та отримання пар ключів і значень.
  • Python dict використовує відкриту адресацію для вирішення колізійних зіткнень (пояснення нижче) (див. Dictobject.c: 296-297 ).
  • Хеш-таблиця Python - це просто суцільний блок пам’яті (на зразок масиву, так що ви можете це зробити O(1) пошук за індексом).
  • Кожен слот у таблиці може зберігати один і тільки один запис.Це важливо
  • Кожен запис у таблиці насправді є комбінацією трьох значень -. Це реалізовано як структуру C (див. Dictobject.h: 51-56 )
  • Малюнок нижче є логічним поданням хеш-таблиці python. На малюнку нижче, 0, 1, ..., i, ... зліва - індекси слотів у хеш-таблиці (вони служать лише для ілюстративних цілей і не зберігаються разом з таблицею!).

    # Logical model of Python Hash table
    -+-----------------+
    0| <hash|key|value>|
    -+-----------------+
    1|      ...        |
    -+-----------------+
    .|      ...        |
    -+-----------------+
    i|      ...        |
    -+-----------------+
    .|      ...        |
    -+-----------------+
    n|      ...        |
    -+-----------------+
    
  • Коли ініціалізується новий дикт, він починається з 8 слотів . (див. dictobject.h: 49 )

  • Додаючи записи до таблиці, ми починаємо з деякого слота, iякий базується на хеші ключа. CPython використовує початковий i = hash(key) & mask. Де mask = PyDictMINSIZE - 1, але це насправді не важливо). Тільки зверніть увагу, що початковий слот, i, який перевіряється, залежить від хешу ключа.
  • Якщо цей слот порожній, запис додається до слота (під входом, я маю на увазі, <hash|key|value> ). Але що, якщо цей слот зайнятий !? Швидше за все тому, що інший запис має такий самий хеш (колізійне зіткнення!)
  • Якщо слот зайнятий, CPython (і навіть PyPy) порівнює хеш І ключ (під порівнянням я маю на увазі ==порівняння, а не isпорівняння) запису в слоті із ключем поточного запису, який потрібно вставити ( dictobject.c: 337 , 344-345 ). Якщо обидва збігаються, тоді він вважає, що запис вже існує, здається і переходить до наступного запису, який потрібно вставити. Якщо хеш або ключ не збігаються, він починає зондування .
  • Зондування просто означає, що він шукає слоти за слотом, щоб знайти порожній слот. Технічно ми могли б просто йти по одному, i + 1, i + 2, ... і використовувати перший доступний (це лінійне зондування). Але з причин, чудово пояснених у коментарях (див. Dictobject.c: 33-126 ), CPython використовує випадкове зондування . При випадковому зондуванні наступний слот вибирається в псевдовипадковому порядку. Запис додається до першого порожнього слота. Для цього обговорення фактичний алгоритм, який використовується для вибору наступного слоту, насправді не важливий (див. Dictobject.c: 33-126 для алгоритму зондування). Важливо те, що слоти зондуються, поки не буде знайдено перший порожній слот.
  • Те саме відбувається з пошуком, тільки починається з початкового слота i (де i залежить від хешу ключа). Якщо хеш і ключ обидва не відповідають запису в слоті, він починає зондування, поки не знайде слот із відповідністю. Якщо всі слоти вичерпані, він повідомляє про помилку.
  • До речі, розмір дикту буде змінено, якщо він буде заповнений на дві третини. Це дозволяє уникнути уповільнення пошуку. (див. dictobject.h: 64-65 )

Ось так! Реалізація Python dict перевіряє як хеш-рівність двох ключів, так і звичайну рівність ( ==) ключів при вставці елементів. Отже, підсумовуючи, якщо є два ключі, aі bта hash(a)==hash(b), але a!=b, тоді обидва можуть гармонійно існувати в диктові Python. Але якщо hash(a)==hash(b) і a==b , то вони обидва не можуть бути в одному дикті.

Оскільки нам доводиться досліджувати після кожного хеш-зіткнення, одним із побічних ефектів занадто великої кількості хеш-зіткнень є те, що пошук і вставки стануть дуже повільними (як вказує Дункан у коментарях ).

Я думаю, коротка відповідь на моє запитання: "Тому що це так реалізовано у вихідному коді;)"

Хоча це добре знати (для окулярів?), Я не впевнений, як це можна використовувати в реальному житті. Оскільки, якщо ви не намагаєтесь явно щось зламати, чому б два об'єкти, які не рівні, мали однаковий хеш?


8
Це пояснює, як працює заповнення словника. Але що, якщо під час отримання пари key_value відбудеться колізійне зіткнення. Скажімо, у нас є 2 об’єкти A і B, обидва з яких хешуються до 4. Отже, спочатку A присвоюється слот 4, а потім B призначається слот шляхом випадкового зондування. Що трапляється, коли я хочу отримати B. B хеші до 4, тому python спочатку перевіряє слот 4, але ключ не збігається, тому він не може повернути A. Оскільки слот B був призначений випадковим зондуванням, як повертається B знову за O (1) час?
sayantankhan

4
@ Bolt64 випадкове зондування насправді не є випадковим. Для одних і тих самих значень ключів він завжди дотримується однакової послідовності зондів, тому врешті-решт знайде B. Словники не гарантовано мають значення O (1), якщо ви отримуєте багато зіткнень, вони можуть зайняти більше часу. У старих версіях Python легко створити серію ключів, які зіткнуться, і в такому випадку пошук словника стане O (n). Це можливий вектор для DoS-атак, тому новіші версії Python модифікують хешування, щоб ускладнити це навмисно.
Дункан

2
@Duncan, що якщо видалити A, а потім виконати пошук B? Думаю, ви насправді не видаляєте записи, а позначаєте їх як видалені? Це означало б, що дикти не підходять для безперервного вставлення та видалення ....
gen-ys

2
@ gen-ys так видалені та невикористані обробляються по-різному для пошуку. Невикористаний зупиняє пошук відповідності, а видалений ні. На вставці або видалені, або невикористані розглядаються як порожні слоти, якими можна скористатися. Безперервні вставки та видалення - це нормально. Коли кількість невикористаних (не видалених) слотів опускається занадто низько, хеш-таблиця буде перебудована так само, як ніби вона стала занадто великою для поточної таблиці.
Дункан

1
Це не дуже хороша відповідь щодо точки зіткнення, яку Дункан намагався виправити. Це особливо погана відповідь на посилання для реалізації з вашого запитання. Найголовніше для розуміння цього полягає в тому, що при зіткненні Python знову намагається використовувати формулу для обчислення наступного зміщення в хеш-таблиці. Під час отримання, якщо ключ не той, він використовує ту саму формулу для пошуку наступного зміщення. У цьому немає нічого випадкового.
Еван Керролл

20

Редагувати : відповідь нижче - один із можливих способів усунення колізійних зіткнень, однак він є , НЕ як Python робить це. Вікі-посилання на Python, на яке посилається нижче, також є неправильним. Найкращим джерелом, наведеним @Duncan нижче, є сама реалізація: https://github.com/python/cpython/blob/master/Objects/dictobject.c Прошу вибачення за змішання.


Він зберігає список (або сегмент) елементів у хеші, потім перебирає цей список, поки не знайде фактичний ключ у цьому списку. Картина говорить більше тисячі слів:

Хеш-стіл

Ось бачите John Smithі Sandra Deeобидва хеш 152. Відро 152містить їх обох. При пошуку спочатку Sandra Deeвін знаходить список у відрі 152, а потім перебирає цей список, поки не Sandra Deeбуде знайдений і не повернеться 521-6955.

Наступне неправильно, це лише тут для контексту: На вікі Python ви можете знайти (псевдо?) Код, як Python виконує пошук.

Насправді існує кілька можливих рішень цієї проблеми. Перегляньте статтю wikipedia, щоб отримати хороший огляд: http://en.wikipedia.org/wiki/Hash_table#Collision_resolution


Дякуємо за пояснення та особливо за посилання на запис на вікі-версію Python із псевдокодом!
Praveen Gollakota

2
Вибачте, але ця відповідь просто невірна (як і стаття у вікі). Python не зберігає список або сегмент елементів у хеші: він зберігає точно один об'єкт у кожному слоті хеш-таблиці. Якщо слот, який він спочатку намагається використовувати, зайнятий, тоді він вибирає інший слот (втягуючи невикористані частини хешу якомога довше), а потім ще один і інший. Оскільки жодна хеш-таблиця не заповнена більше ніж на одну третину, вона врешті повинна знайти доступний слот.
Дункан

@Duncan, вікі-версія Python каже, що це реалізовано таким чином. Я був би радий знайти в кращому джерелі. Сторінка wikipedia.org точно не помиляється, це лише одне з можливих рішень, як зазначено.
Роб Воутерс,

@Duncan Чи можете ви пояснити ... втягування невикористаних частин хешу якомога довше? Усі хеші в моєму випадку оцінюють до 42. Дякую!
Praveen Gollakota

@PraveenGollakota Перейдіть за посиланням у моїй відповіді, де детально пояснюється, як докладно використовується хеш. Для хешу з 42 і таблиці з 8 слотами спочатку для пошуку слота № 2 використовуються лише найнижчі 3 біти, але якщо цей слот уже використовується, в гру вступають інші біти. Якщо два значення мають абсолютно однаковий хеш, тоді перше потрапляє в перший спробуваний слот, а друге отримує наступний слот. Якщо є 1000 значень з однаковими хешами, ми закінчуємо пробувати 1000 слотів, перш ніж знайдемо значення, і пошук словника стає дуже повільним!
Дункан

4

Хеш-таблиці, загалом, повинні допускати колізійні зіткнення! Вам пощастить, і дві речі врешті-решт змішать одне і те ж. Внизу є набір об’єктів у списку елементів, що має той самий хеш-ключ. Зазвичай у цьому списку є лише одне, але в цьому випадку він буде продовжувати складати їх в один і той же. Єдиний спосіб дізнатися, що вони різні, - це оператор рівності.

Коли це трапляється, ваша продуктивність з часом погіршується, саме тому ви хочете, щоб ваша хеш-функція була якомога «випадковішою».


2

У потоці я не бачив, що саме робить python з екземплярами визначених користувачем класів, коли ми поміщаємо це у словник як ключі. Давайте прочитаємо деяку документацію: вона заявляє, що в якості ключів можуть використовуватися лише об'єкти, що хешуються. Хашабл - це всі незмінні вбудовані класи та всі визначені користувачем класи.

Встановлені користувачем класи за замовчуванням мають методи __cmp __ () та __hash __ (); з ними всі об'єкти порівнюються нерівними (крім них самих), а x .__ хеш __ () повертає результат, отриманий з id (x).

Отже, якщо у вас є постійно __hash__ у вашому класі, але ви не надаєте жодного методу __cmp__ або __eq__, то всі ваші екземпляри нерівні для словника. З іншого боку, якщо ви надаєте будь-який __cmp__ або __eq__ метод, але не надаєте __hash__, ваші екземпляри все ще нерівні з точки зору словника.

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)

Вихідні дані

{<__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}
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.