Чому порядок у словниках і множинах довільний?


151

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

Я маю на увазі, це мова програмування, тому все в мові повинно бути визначено на 100%, правильно? Python повинен мати якийсь алгоритм, який визначає, яку частину словника чи набору обрано, 1-ю, другу та інше.

Що я пропускаю?


1
Найновіша збірка PyPy (2.5, для Python 2.7) робить словники впорядкованими за замовчуванням .
Ведрак

Відповіді:


236

Примітка. Ця відповідь була написана перед dictзміною типу, в Python 3.6. Більшість відомостей про реалізацію у цій відповіді все ще застосовуються, але порядок переліку ключів у словниках більше не визначається хеш-значеннями. Реалізація набору залишається незмінною.

Порядок не є довільним, але залежить від історії вставки та видалення словника чи набору, а також від конкретної реалізації Python. У решті цієї відповіді для "словника" ви також можете прочитати "встановити"; Набори реалізуються як словники з просто клавішами і без значень.

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

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

Візьміть ключі 'foo'і 'bar', наприклад, і дозволяє припустити , розмір таблиці 8 слотів. У Python 2.7, hash('foo')є -4177197833195190597, hash('bar')є 327024216814240868. Модуло 8, це означає, що ці два клавіші розміщені в слотах 3 і 4 потім:

>>> hash('foo')
-4177197833195190597
>>> hash('foo') % 8
3
>>> hash('bar')
327024216814240868
>>> hash('bar') % 8
4

Це повідомляє їх порядок лістингу:

>>> {'bar': None, 'foo': None}
{'foo': None, 'bar': None}

Усі слоти, за винятком 3 та 4, порожні, петлюючи над таблицею, спочатку перераховується слот 3, потім слот 4, тому 'foo'зазначено раніше 'bar'.

barі baz, однак, мають хеш-значення, які точно розташовані на відстані 8, і таким чином відображати в точно такому ж слоті 4:

>>> hash('bar')
327024216814240868
>>> hash('baz')
327024216814240876
>>> hash('bar') % 8
4
>>> hash('baz') % 8
4

Їх порядок тепер залежить від того, який ключ був прорізаний першим; другий ключ потрібно буде перенести на наступний слот:

>>> {'baz': None, 'bar': None}
{'bar': None, 'baz': None}
>>> {'bar': None, 'baz': None}
{'baz': None, 'bar': None}

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

Технічна назва базової структури, що використовується CPython (найчастіше використовується реалізація Python), - хеш-таблиця , в якій використовується відкрита адресація. Якщо вам цікаво, і ви добре розумієте C, погляньте на реалізацію C для всіх (добре задокументованих) деталей. Ви також можете подивитися цю презентацію Pycon 2010 Брендона Родоса про те, як dictпрацює CPython , або забрати копію красивого кодексу , що містить розділ про реалізацію, написаний Ендрю Кухлінг.

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

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

CPython 3.6 представляє нову dict реалізацію, яка підтримує порядок вставки та швидше та ефективніше завантажувати пам'ять. Замість того, щоб зберігати велику розріджену таблицю, де кожен рядок посилається на збережене хеш-значення, а також на ключові та цінні об'єкти, нова реалізація додає менший хеш- масив, який посилається лише на індекси в окремій "щільній" таблиці (та, що містить лише стільки рядків оскільки існують фактичні пари ключ-значення), і саме щільна таблиця повинна перераховувати елементи, що містяться в порядку. Дивіться пропозицію до Python-Dev для отримання більш детальної інформації . Зауважте, що в Python 3.6 це вважається деталлю реалізації, Мова Python не вказує, що інші реалізації повинні зберігати порядок. Це змінилося в Python 3.7, де ця деталь була підвищена як мовна специфікація ; щоб будь-яка реалізація була належним чином сумісна з Python 3.7 або новішою, вона повинна скопіювати цю поведінку для збереження замовлень. І щоб бути ясним: ця зміна не стосується наборів, оскільки набори вже мають "малу" хеш-структуру.

Python 2.7 і новіші також надають OrderedDictклас , підклас dictякого додає додаткову структуру даних для запису порядку ключів. Ціною деякої швидкості та додаткової пам’яті цей клас запам'ятовує, в якому порядку ви вставили клавіші; Перерахування ключів, значень або елементів буде зроблено в такому порядку. Він використовує подвійно пов'язаний список, який зберігається в додатковому словнику, щоб ефективно підтримувати замовлення. Дивіться допис Реймонда Хеттінгера з викладом ідеї . OrderedDictоб'єкти мають інші переваги, такі як повторне замовлення .

Якщо ви хотіли замовити набір, можете встановити osetпакет ; він працює на Python 2.5 і вище.


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

1
@delnan: Цікаво, чи можна ще використовувати BTree з хешами та тестами рівності. Я, безумовно, не виключаю цього в будь-якому випадку. :-)
Martijn Pieters

1
Це, безумовно, правильно, і я би радий, що доведено неправильну wrt-можливість, але я не бачу жодного способу, як можна обіграти хеш-таблицю, не вимагаючи більш широкого контракту. BTree не матиме кращої середньої продуктивності та не дає кращого гіршого випадку (хеш-зіткнення все ще означають лінійний пошук). Таким чином, ви тільки отримуєте кращу стійкість до багатьох хешів neomg conguent (mod tableize), і є багато інших чудових способів вирішити це (деякі з яких використовуються в dictobject.c) і в кінцевому рахунку набагато менше порівнянь, ніж BTree потрібно навіть знайти потрібний піддерево.

@delnan: Я повністю згоден; Я більше за все не хотів, щоб його відбивали за те, що не допускаються інші варіанти реалізації.
Martijn Pieters

37

Це більше відповідь на Python 3.41 Набір до його закриття як дублікат.


Інші праві: не покладайтеся на замовлення. Навіть не робіть вигляд, що є такий.

Однак, є одне, на що можна покластися:

list(myset) == list(myset)

Тобто порядок стабільний .


Розуміння того, чому існує сприйнятий порядок, вимагає розуміння кількох речей:

  • Цей Python використовує хеш-набори ,

  • Як хеш набору CPython зберігається в пам'яті та

  • Як числа хешируются

З вершини:

Хеш - набір являє собою спосіб зберігання випадкових даних з дуже швидким пошуком рази.

Він має резервний масив:

# A C array; items may be NULL,
# a pointer to an object, or a
# special dummy object
_ _ 4 _ _ 2 _ _ 6

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

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

Потім ви робите індекс, беручи модуль за довжиною масиву:

hash(4) % len(storage) = index 2

Це робить дійсно швидким доступ до елементів.

Хеш тільки велика частиною історії, так hash(n) % len(storage)і hash(m) % len(storage)можуть привести до того ж номеру. У цьому випадку кілька різних стратегій можуть спробувати вирішити конфлікт. CPython використовує "лінійне зондування" 9 разів, перш ніж робити складні речі, тому він буде шукати ліворуч від слота до 9 місць, перш ніж шукати в іншому місці.

Хешові набори CPython зберігаються так:

  • Хеш-набір може бути не більше 2/3 . Якщо є 20 елементів, а резервний масив - 30 елементів, резервна копія змінить розмір, щоб бути більшим. Це відбувається тому, що ви стикаєтеся частіше з невеликими підкладками, а зіткнення сповільнюють усе.

  • Магазин резервного розміру змінює потужність 4, починаючи з 8, за винятком великих наборів (50k елементів), які змінюють розміри в два: (8, 32, 128, ...).

Отже, коли ви створюєте масив, зберігання резервного копіювання становить довжину 8. Коли він заповнений на 5, і ви додасте елемент, він коротко містить 6 елементів. 6 > ²⁄₃·8таким чином, це запускає розмір, і резервна копія зберігається вчетверо до розміру 32.

Нарешті, hash(n)просто повертається nдля чисел (за винятком -1спеціальних).


Отже, давайте розглянемо перший:

v_set = {88,11,1,33,21,3,7,55,37,8}

len(v_set)- 10, тому після додавання всіх предметів резервного зберігання принаймні 15 (+1) . Відповідна потужність 2 - 32. Отже, підкладка - це:

__ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __

Ми маємо

hash(88) % 32 = 24
hash(11) % 32 = 11
hash(1)  % 32 = 1
hash(33) % 32 = 1
hash(21) % 32 = 21
hash(3)  % 32 = 3
hash(7)  % 32 = 7
hash(55) % 32 = 23
hash(37) % 32 = 5
hash(8)  % 32 = 8

тому вони вставляються як:

__  1 __  3 __ 37 __  7  8 __ __ 11 __ __ __ __ __ __ __ __ __ 21 __ 55 88 __ __ __ __ __ __ __
   33 ← Can't also be where 1 is;
        either 1 or 33 has to move

Тож ми очікували, що таке замовлення

{[1 or 33], 3, 37, 7, 8, 11, 21, 55, 88}

з 1 або 33, що не знаходиться на старті десь в іншому місці. Для цього буде використано лінійне зондування, тож у нас буде або:


__  1 33  3 __ 37 __  7  8 __ __ 11 __ __ __ __ __ __ __ __ __ 21 __ 55 88 __ __ __ __ __ __ __

або


__ 33  1  3 __ 37 __  7  8 __ __ 11 __ __ __ __ __ __ __ __ __ 21 __ 55 88 __ __ __ __ __ __ __

Ви можете очікувати, що 33 буде зміщений, оскільки 1 вже був там, але через зміну розміру, що відбувається під час створення набору, це насправді не так. Кожен раз, коли набір відновлюється, елементи, які вже додаються, фактично упорядковуються.

Тепер ви можете зрозуміти, чому

{7,5,11,1,4,13,55,12,2,3,6,20,9,10}

може бути в порядку. Є 14 елементів, тож накопичувач підкладки становить щонайменше 21 + 1, що означає 32:

__ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __ __

1 - 13 хешу в перших 13 слотах. 20 йде в слот 20.

__  1  2  3  4  5  6  7  8  9 10 11 12 13 __ __ __ __ __ __ 20 __ __ __ __ __ __ __ __ __ __ __

55 є в слоті, hash(55) % 32який становить 23:

__  1  2  3  4  5  6  7  8  9 10 11 12 13 __ __ __ __ __ __ 20 __ __ 55 __ __ __ __ __ __ __ __

Якби ми обрали натомість 50, ми б очікували

__  1  2  3  4  5  6  7  8  9 10 11 12 13 __ __ __ __ 50 __ 20 __ __ __ __ __ __ __ __ __ __ __

І ось ось:

{1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 20, 50}
#>>> {1, 2, 3, 4, 5, 6, 7, 9, 10, 11, 12, 13, 50, 20}

pop реалізується досить просто за зовнішнім виглядом речей: він переходить список і вискакує перший.


Це вся деталь реалізації.


17

"Довільне" - це не те саме, що "невизначене".

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

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


3
Зауважте, що одна частина ітерацій словника чітко визначена: Ітерація ключів, значень або елементів певного словника відбуватиметься в одному порядку, доки в словнику не було внесено змін. Це означає, що d.items()по суті ідентичний zip(d.keys(), d.values()). Якщо до словника додаються якісь предмети, всі ставки знімаються. Порядок може повністю змінитися (якщо потрібно змінити розмір хеш-таблиці), хоча більшу частину часу ви просто знайдете новий елемент, який з’являється в якомусь довільному місці в послідовності.
Blckknght

6

Інші відповіді на це питання чудові та добре написані. В ОП запитують "як", що я трактую як "як вони відводяться" або "чому".

У документації Python сказано, що словники не впорядковані, оскільки словник Python реалізує асоціативний масив абстрактних типів даних . Як кажуть

порядок повернення зв'язків може бути довільним

Іншими словами, студент інформатики не може припустити, що впорядкований асоціативний масив. Те саме стосується наборів з математики

порядок, у якому перелічені елементи набору, не має значення

та інформатики

набір - це абстрактний тип даних, який може зберігати певні значення без будь-якого конкретного порядку

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


1
Ви в принципі праві, але було б трохи ближче (і дати хороший натяк на причину, що це "не упорядкований") сказати, що це реалізація хеш-таблиці, а не асоційований масив.
Двобітний алхімік

5

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

Але щодо індексів елементів у хеш-об'єкті python обчислює індекси на основі наступного коду в межахhashtable.c :

key_hash = ht->hash_func(key);
index = key_hash & (ht->num_buckets - 1);

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

розглянемо наступний приклад із setвикористанням хеш-таблиці:

>>> set([0,1919,2000,3,45,33,333,5])
set([0, 33, 3, 5, 45, 333, 2000, 1919])

За номером у 33нас є:

33 & (ht->num_buckets - 1) = 1

Це насправді це:

'0b100001' & '0b111'= '0b1' # 1 the index of 33

Примітка в цьому випадку (ht->num_buckets - 1)є 8-1=7або 0b111.

І для 1919:

'0b11101111111' & '0b111' = '0b111' # 7 the index of 1919

І для 333:

'0b101001101' & '0b111' = '0b101' # 5 the index of 333

Детальніше про хеш-функцію python корисно прочитати наступні цитати з вихідного коду python :

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

>>> map(hash, (0, 1, 2, 3))
  [0, 1, 2, 3]
>>> map(hash, ("namea", "nameb", "namec", "named"))
  [-1658398457, -1658398460, -1658398459, -1658398462]

Це не обов'язково погано! Навпаки, у таблиці розміром 2 ** i прийняття бітів низького порядку в якості початкового індексу таблиці надзвичайно швидко, і для диктовок, індексованих суміжним діапазоном вступів, взагалі немає зіткнень. Те саме приблизно стосується, коли ключі є "послідовними" рядками. Таким чином, це дає кращу, ніж випадкову поведінку, у звичайних випадках, і це дуже бажано.

ОТОХ, коли виникають зіткнення, тенденція до заповнення суміжних зрізів таблиці хешу робить хорошу стратегію вирішення колізій вирішальним. Вразливий також лише останні i біти хеш-коду: наприклад, розглядайте список [i << 16 for i in range(20000)]як набір ключів. Оскільки ints - це їхні власні хеш-коди, і це вписується у dict розміром 2 ** 15, останні 15 біт кожного хеш-коду - всі 0: всі вони відображаються в одному і тому ж індексі таблиці.

Але харчування в незвичайних випадках не повинно сповільнювати звичні, тому ми все одно беремо останні i шматочки. До решти зіткнення доводиться робити все інше. Якщо ми зазвичай знаходимо ключ, який ми шукаємо з першої спроби (і, виявляється, ми зазвичай робимо - коефіцієнт навантаження таблиці зберігається нижче 2/3, тому шанси твердо на нашу користь), тоді це найкраще має сенс зберегти початковий бруд для обчислення індексу дешево.


* Хеш-функція для класу int:

class int:
    def __hash__(self):
        value = self
        if value == -1:
            value = -2
        return value


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