Чому я отримую таку кількість ітерацій під час додавання та вилучення із набору під час ітерації над ним?


61

Намагаючись зрозуміти Python for-loop, я подумав, що це дасть результат {1}за одну ітерацію або просто застрягне в нескінченному циклі, залежно від того, чи робить це ітерація, як на C чи інших мовах. Але насправді це не зробило жодне.

>>> s = {0}
>>> for i in s:
...     s.add(i + 1)
...     s.remove(i)
...
>>> print(s)
{16}

Чому це робить 16 ітерацій? Звідки береться результат {16}?

Для цього використовували Python 3.8.2. На піпі це дає очікуваний результат {1}.


17
Залежно від елементів, які ви додаєте, кожен виклик s.add(i+1)(і, можливо, виклик до s.remove(i)) може змінювати порядок ітерації набору, впливаючи на те, що побачить наступний ітератор встановленого циклу. Не мутуйте об'єкт під час активного ітератора.
чепнер

6
Я також помітив, що t = {16}і тоді t.add(15)виходить, що t є множиною {16, 15}. Я думаю, проблема десь є.

19
Це деталь реалізації - 16 має менший хеш, ніж 15 (саме це помітив @Anon), тому додавання 16 до набору типу додало його до "вже баченої" частини ітератора, і таким чином ітератор вичерпався.
Błotosmętek

1
Якщо ви читаєте корисні документи, є примітка, що мутація ітераторів під час циклу може створити деякі помилки. Дивіться: docs.python.org/3.7/reference/…
Марчелло Фабріціо

3
@ Błotosmętek: На CPython 3.8.2 хеш (16) == 16 і хеш (15) == 15. Поведінка не походить від того, що сам хеш є нижчим; елементи не зберігаються безпосередньо в хеш-порядку в наборі.
user2357112 підтримує Моніку

Відповіді:


86

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

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

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


Внутрішня хеш-таблиця набору Python завжди має 2 розміри. Для таблиці розміром 2 ^ n, якщо не виникає зіткнень, елементи зберігаються у позиції хеш-таблиці, що відповідає п’яти найменш значущим бітам їх хешу. Це можна переглянути у set_add_entry:

mask = so->mask;
i = (size_t)hash & mask;

entry = &so->table[i];
if (entry->key == NULL)
    goto found_unused;

Більшість малих інстанцій Python хеш до себе; зокрема, всі вхідні дані в тестовому хеші для себе. Ви можете бачити це реалізованим уlong_hash . Оскільки ваш набір ніколи не містить двох елементів з рівними низькими бітами в хешах, зіткнення не відбувається.


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

while (i <= mask && (entry[i].key == NULL || entry[i].key == dummy))
    i++;
si->si_pos = i+1;
if (i > mask)
    goto fail;
si->len--;
key = entry[i].key;
Py_INCREF(key);
return key;

Набір спочатку починається з хеш-таблиці розміром 8 та вказівника на об’єкт 0int з індексу 0 у хеш-таблиці. Ітератор також розміщується в індексі 0. Коли ви повторюєте, елементи додаються в хеш-таблицю, кожен із наступних індексів, тому що там їх хеш говорить, щоб поставити їх, і це завжди наступний індекс, на який ітератор дивиться. Вилучені елементи мають фіктивний маркер, який зберігається у старому положенні, для вирішення зіткнень. Ви можете бачити, що реалізовано в set_discard_entry:

entry = set_lookkey(so, key, hash);
if (entry == NULL)
    return -1;
if (entry->key == NULL)
    return DISCARD_NOTFOUND;
old_key = entry->key;
entry->key = dummy;
entry->hash = -1;
so->used--;
Py_DECREF(old_key);
return DISCARD_FOUND;

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

if ((size_t)so->fill*5 < mask*3)
    return 0;
return set_table_resize(so, so->used>50000 ? so->used*2 : so->used*4);

so->used- кількість заповнених недумських записів у хеш-таблиці, яка дорівнює 2, тому set_table_resizeотримує 8 як другий аргумент. Виходячи з цього, set_table_resize приймає рішення що новий розмір хеш-таблиці повинен становити 16:

/* Find the smallest table size > minused. */
/* XXX speed-up with intrinsics */
size_t newsize = PySet_MINSIZE;
while (newsize <= (size_t)minused) {
    newsize <<= 1; // The largest possible value is PY_SSIZE_T_MAX + 1.
}

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

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

Шаблон розривається, коли цикл додає 16 як елемент. Немає індексу 16 для розміщення нового елемента. 4 найнижчих біта 16 - 0000, ставлячи 16 в індексі 0. Збережений індекс ітератора в цій точці дорівнює 16, і коли цикл запитує наступний елемент від ітератора, ітератор бачить, що він пройшов минулий кінець хеш-таблиця.

Ітератор завершує цикл у цій точці, залишаючи лише 16у множині.


14

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

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

Моя відповідь ґрунтується на цьому подібному питанні, воно насправді показує цей самий приклад. Я дуже рекомендую прочитати його для більш детальної інформації.


5

З документації python 3:

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

Ітерація над копією

s = {0}
s2 = s.copy()
for i in s2:
     s.add(i + 1)
     s.remove(i)

яка повинна повторюватися лише 1 раз

>>> print(s)
{1}
>>> print(s2)
{0}

Редагувати: Можлива причина такої ітерації полягає в тому, що набір не упорядкований, викликаючи якусь річ простеження стека. Якщо ви робите це зі списком, а не набором, він просто закінчиться, s = [1]тому що списки впорядковані, тому цикл for починається з індексу 0, а потім переходить до наступного індексу, виявляючи, що його немає, і вихід із циклу.


Так. Але моє запитання, чому це робить 16 ітерацій.
переповнення noob

набір не упорядкований. Словники та задає ітерацію у невипадковому порядку, і цей алгоритм ітерації виконується лише у тому випадку, якщо ви нічого не змінюєте. Для списків і кортежів він може просто повторюватися за індексами. Коли я спробував ваш код в 3.7.2, він зробив 8 ітерацій.
Ерік Джин

Порядок ітерації, ймовірно, має відношення до хешування, як уже згадували інші
Ерік Джин

1
Що означає "спричинення якимось чином сортування слідів стека"? Код не робив збоїв чи помилок, тому я не бачив жодного сліду стека. Як увімкнути трасування стека в python?
переповнення noob

1

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

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

Чому це робить 16 ітерацій?

user2357112 supports Monicaвже пояснює головну причину. Ось ще один спосіб мислення.

s = {0}
for i in s:
     s.add(i + 1)
     print(s)
     s.remove(i)
print(s)

Коли ви запускаєте цей код, він дає результат:

{0, 1}                                                                                                                               
{1, 2}                                                                                                                               
{2, 3}                                                                                                                               
{3, 4}                                                                                                                               
{4, 5}                                                                                                                               
{5, 6}                                                                                                                               
{6, 7}                                                                                                                               
{7, 8}
{8, 9}                                                                                                                               
{9, 10}                                                                                                                              
{10, 11}                                                                                                                             
{11, 12}                                                                                                                             
{12, 13}                                                                                                                             
{13, 14}                                                                                                                             
{14, 15}                                                                                                                             
{16, 15}                                                                                                                             
{16}       

Коли ми отримуємо доступ до всіх елементів разом, наприклад, до циклу або друкуємо набір, мусить бути заздалегідь визначений порядок, щоб він пройшов весь набір. Отже, під час останньої ітерації ви побачите, що порядок змінюється як з {i,i+1}у {i+1,i}.

Після останньої ітерації вийшло, що i+1вже пройдений цикл виходу.

Цікавий факт: використання будь-якого значення менше 16, крім 6 та 7, завжди дає результат 16.


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

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