Як реалізуються вбудовані словники Python?


294

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


4
Ось глибока розмова про словники Python від 2,7 до 3,6. Посилання
Sören

Відповіді:


494

Тут є все про дикти Python, які мені вдалося скласти (напевно, більше, ніж хто-небудь хотів би знати; але відповідь є вичерпною).

  • Словники Python реалізовані у вигляді хеш-таблиць .
  • Таблиці хешу повинні допускати зіткнення хешу, тобто навіть якщо два різних ключа мають однакове хеш-значення, реалізація таблиці повинна мати стратегію однозначного вставлення та отримання пар ключів та значень.
  • Python dictвикористовує відкриту адресацію для вирішення хеш-колізій (пояснено нижче) (див. Dictobject.c: 296-297 ).
  • Хеш-таблиця Python - це лише суміжний блок пам'яті (на зразок масиву, тому ви можете зробити O(1)пошук за індексом).
  • Кожен слот таблиці може зберігати один і лише один запис. Це важливо.
  • Кожен запис у таблиці насправді є комбінацією трьох значень: <хеш, ключ, значення> . Це реалізовано у вигляді структури С (див. 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 (де я залежить від хеша ключа). Якщо і хеш, і ключ не відповідають запису в слоті, він починається зондуванням, поки не знайде слот з збігом. Якщо всі слоти вичерпані, це повідомляє про помилку.
  • До речі, розмір dictзаповіту буде змінено, якщо він заповнений на дві третини. Це дозволяє уникнути сповільнення пошуку. (див. dictobject.h: 64-65 )

ПРИМІТКА. Я провів дослідження щодо реалізації Python Dict у відповідь на власне запитання про те, як кілька записів у дикті можуть мати однакові хеш-значення. Тут я розмістив трохи відредаговану версію відповіді, оскільки всі дослідження дуже актуальні і для цього питання.


8
Ви сказали, що і хеш, і ключ відповідають, він (вставити оп) здається і рухається далі. Чи не вставляється перезаписувати існуючий запис у цьому випадку?
0xc0de

65

Як реалізуються вбудовані словники Python?

Ось короткий курс:

  • Вони є хеш-таблицями. (Див. Нижче специфіку реалізації Python.)
  • Новий макет і алгоритм, як і в Python 3.6, робить їх
    • впорядковано за допомогою вставки ключа та
    • займають менше місця,
    • практично без витрат на продуктивність.
  • Інша оптимізація економить місце при диктуванні клавіш спільного використання (в особливих випадках).

Впорядкований аспект є неофіційним для Python 3.6 (щоб дати можливість іншим реалізаціям не відставати), але офіційний у Python 3.7 .

Словники Python - це хеш-таблиці

Тривалий час воно працювало саме так. Python попередньо виділить 8 порожніх рядків і використовує хеш, щоб визначити, куди слід вставити пари ключ-значення. Наприклад, якщо хеш для ключа закінчився в 001, він буде вставляти його в 1 (тобто 2-й) індекс (як приклад нижче.)

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

Кожен рядок займає 24 байти в 64-бітній архітектурі, 12 - в 32-бітовій. (Зауважте, що заголовки стовпців - це лише мітки для наших цілей - вони насправді не існують у пам'яті.)

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

Після збереження 5 значень ключ-ключ при додаванні іншої пари ключ-значення ймовірність зіткнення хешу занадто велика, тому словник подвоюється за розміром. У 64-бітовому процесі перед зміною розміру у нас залишається 72 байти, а після цього ми витрачаємо 240 байт через 10 порожніх рядків.

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

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

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

Нові компактні таблиці хешу

Почнемо, замість цього, попередньо розмістивши масив для індексу вставки.

Оскільки наша перша пара ключ-значення йде у другому слоті, ми індексуємо так:

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

І наша таблиця просто заповнюється порядком вставки:

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

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

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

Реймонд Хеттінгер представив це на python-dev у грудні 2012 року. Він нарешті потрапив у CPython в Python 3.6 . Впорядкування шляхом вставки було розглянуто деталі реалізації для 3.6, щоб дати можливість іншим реалізаціям Python отримати нагоду.

Спільні ключі

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

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

Для 64-бітної машини це може заощадити до 16 байт на ключ за додатковий словник.

Спільні ключі для власних об'єктів та альтернатив

Ці дикти зі спільним ключем призначені для використання для користувацьких об'єктів ' __dict__. Щоб отримати таку поведінку, я вважаю, що вам потрібно закінчити заповнення свого, __dict__перш ніж інстанціювати наступний об’єкт ( див. PEP 412 ). Це означає, що ви повинні призначити всі свої атрибути __init__або __new__, інакше ви не зможете отримати економію місця.

Однак якщо ви знаєте всі свої атрибути під час __init__виконання, ви також можете передбачити __slots__свій об’єкт та гарантувати, що він __dict__взагалі не створений (якщо він не доступний у батьків), або навіть дозволити, __dict__але гарантувати, що ваші передбачені атрибути є зберігається в слотах у будь-якому випадку. Більш детальну інформацію про __slots__, см моя відповідь тут .

Дивись також:


1
Ви сказали, що "ми" та "щоб інші можливості реалізації Python мали можливість наздогнати" - це означає, що ви "знаєте речі" і що це може стати постійною ознакою? Чи є якісь недоліки в тому, щоб дикти були замовлені спец?
toonarmycaptain

Недоліком замовлення є те, що якщо очікується, що вони будуть впорядковані, вони не зможуть легко перейти на кращу / швидшу реалізацію, яка не впорядкована. Здається, навряд чи так буде. Я "знаю речі", тому що я спостерігаю багато розмов і читаю багато речей, написаних членами основних та інших людей з кращою репутацією в реальному світі, ніж я, тому навіть якщо у мене немає цитувального джерела, яке можна одразу навести, я зазвичай знаю про що я говорю Але я думаю, що ви можете це зрозуміти з одного з переговорів Реймонда Хеттінгера.
Аарон Холл

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

@Alexey Остання посилання, яку я надаю, дає вам добре анотовану реалізацію диктату - там, де ви можете знайти функцію, яка це робить, на даний момент у рядку 969 під назвою find_empty_slot: github.com/python/cpython/blob/master/Objects/dictobject.c # L969 - і починаючи з рядка 134 є деяка проза, яка описує це.
Аарон Холл

46

Словники Python використовують відкриту адресацію ( посилання на прекрасний код )

NB! Відкрита адресація , також закрита хешшю , як зазначається у Вікіпедії, не повинна плутати з її протилежним відкритим хешированием!

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


5
"не плутати його з протилежним відкритим хешированием! (що ми бачимо у прийнятій відповіді)." - Я не впевнений, яку відповідь було прийнято, коли ви це написали, або що сказала ця відповідь у той час, - але цей коментар у дужках наразі не відповідає дійсності прийнятої відповіді і найкраще буде видалений.
Тоні Делрой
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.