void del(int key){ for(int i=hash(key); table[i].used; i=(i+1)%SIZE){ if(table[i].key==key){ table[i].used=0; return; } } } Объясните, почему такой способ удаления приводит к потере элементов при поиске, как это исправить (описать алгоритм «могильников» или реорганизации) и оцените амортизированную сложность операций
Код, который вы привели, помечает ячейку как свободную used=0used = 0used=0 и уходит. При линейном пробировании это ломает инвариант поиска — из‑за этого некоторые ключи «потеряются».
Почему это происходит примерпримерпример
Пусть SIZE = 5, hk1k1k1 = 1, hk2k2k2 = 1.После вставок: table111 = k1, table222 = k2.Теперь вы удаляете k1 и делаете table111.used = 0.Поиск k2 начинает с hk2k2k2=1: встречает свободную ячейку table111 и решает, что k2 нет в таблице петляforпрерываетсяпетля for прерываетсяпетляforпрерывается, хотя k2 находится в table222. Причина: при поиске мы останавливаемся на первой пустой ячейке, считая, что дальше по цепочке от хэша ничего не могло оказаться. Но если при удалении освободить ячейку, эта гарантия нарушается.
Как исправить
1) Могильники tombstonestombstonestombstones
Вместо полного освобождения ячейки ставим специальный маркер "DELETED" могильникмогильникмогильник. Семантика: Поиск: продолжает пробирование и через могильники т.е.могильниксчитаетсяне‑пустойпосмыслупродолженияпоискат.е. могильник считается не‑пустой по смыслу продолжения поискат.е.могильниксчитаетсяне‑пустойпосмыслупродолженияпоиска.Вставка: при пробировании можно запомнить индекс первой встретившейся ячейки DELETED и в конце вставить туда вместопустойвместо пустойвместопустой, чтобы переиспользовать могильник.Удаление: ставим метку DELETED вместо used = 0.Плюсы: простота, корректность поиска.Минусы: могильники накапливаются, ухудшая эффективность эффективнаяплотностьувеличиваетсяэффективная плотность увеличиваетсяэффективнаяплотностьувеличивается. Нужно отслеживать число могильников и периодически выполнять реорганизацию рехешвсейтаблицывновую,чтобыубратьвсеDELETEDрехеш всей таблицы в новую, чтобы убрать все DELETEDрехешвсейтаблицывновую,чтобыубратьвсеDELETED когда число DELETED слишком велико (например, > alpha_threshold * SIZE).
Псевдокод вставки с могильником:
first_deleted = -1for i = hkeykeykey; tableiii.state != EMPTY; i = i+1i+1i+1%SIZE: if tableiii.state == DELETED and first_deleted == -1: first_deleted = iif tableiii.state == OCCUPIED and tableiii.key == key: обновить/returnif first_deleted != -1: insert в first_deleted else insert в текущую EMPTY
После удаления освобождаем ячейку, но затем проходим дальше по цепочке и "подтягиваем" элементы влево, чтобы восстановить непрерывность кластера: free = удалённая позицияj = free+1free+1free+1 % SIZEwhile tablejjj.state == OCCUPIED: k = tablejjj.key; temp = tablejjjtablejjj.state = EMPTYвставить temp начиная с hkkkилипопробоватьпоставитьименновfree,еслидопустимоили попробовать поставить именно в free, если допустимоилипопробоватьпоставитьименновfree,еслидопустимоfree = новое место, откуда стало EMPTY обычноfree=позиция,гдемывзялиэлементобычно free = позиция, где мы взяли элементобычноfree=позиция,гдемывзялиэлементj = j+1j+1j+1%SIZEостановиться, когда встретили пустую ячейкуПроще и надёжнее вариант: после удаления последовательно извлекаете ключи из следующих занятых ячеек и "вставляете" их заново в таблицу до первой пустой ячейки.Такой подход убирает нужду в могильниках — таблица остаётся чистой.
Примечание по корректной реализации backshift: нужно аккуратно обработать круговую арифметику индексов; алгоритм гарантирует, что элементы останутся достижимы при поиске.
Амортизированная сложность
Для линейного пробирования при случайном равномерномравномерномравномерном хэшировании: Среднее число проверок успешного поиска — O111точнее≈(1+1/(1−α))/2точнее ≈ (1 + 1/(1-α))/2точнее≈(1+1/(1−α))/2.Среднее число проверок неуспешного поиска — O1/(1−α)1/(1-α)1/(1−α).Вставка имеет ту же асимптотику, зависит от коэффициента заполнения α = n / SIZE.С могильниками: Поиск и вставка остаются ожидаемо O111 при ограниченном числе могильников; но по мере их накопления эффективность падает эффективныйαрастётэффективный α растётэффективныйαрастёт.Если вы периодически реорганизуете таблицу rehashrehashrehash при достижении порога, то амортизированная стоимость реорганизаций распределяется по операциям, и средняя стоимость остаётся O111.С реорганизацией backshiftbackshiftbackshift: Одна операция удаления в худшем случае может потребовать сдвинуть/переставить Oclusterlengthcluster_lengthclusterlength элементов — в худшем Onnn.Тем не менее, при последовательности операций амортизированная стоимость удаления остаётся O111 при поддержании α < 1. Интуитивно: каждый элемент перемещается нечасто всегопринебольшомчислеоперацийнаэлементвыполняетсямаленькоечислосдвиговвсего при небольшом числе операций на элемент выполняется маленькое число сдвиговвсегопринебольшомчислеоперацийнаэлементвыполняетсямаленькоечислосдвигов, а общее число перемещений по последовательности операций линейно растёт с числом операций.Вывод: при корректном поддержании коэффициента заполнения напримерα≤0.7например α ≤ 0.7напримерα≤0.7 все операции поиск,вставка,удалениескорректнойсхемойпоиск, вставка, удаление с корректной схемойпоиск,вставка,удалениескорректнойсхемой дают ожидаемую амортизированную сложность O111. Если не очищать могильники, производительность может деградировать до Onnn на операцию.
Резюме / рекомендации
Нельзя просто ставить used = 0 — это ломает поиск.Проще реализовать: использовать могильники + периодический рехеш при накоплении DELETED.Более «чистый» вариант: после удаления реинсерировать/сдвигать элементы кластера backshiftbackshiftbackshift — устраняет могильники, тоже корректен.Оба подхода при разумных порогах дают амортизированную O111 при случайном хэшировании и α < 1.
Код, который вы привели, помечает ячейку как свободную used=0used = 0used=0 и уходит. При линейном пробировании это ломает инвариант поиска — из‑за этого некоторые ключи «потеряются».
Почему это происходит примерпримерпример
Пусть SIZE = 5, hk1k1k1 = 1, hk2k2k2 = 1.После вставок: table111 = k1, table222 = k2.Теперь вы удаляете k1 и делаете table111.used = 0.Поиск k2 начинает с hk2k2k2=1: встречает свободную ячейку table111 и решает, что k2 нет в таблице петляforпрерываетсяпетля for прерываетсяпетляforпрерывается, хотя k2 находится в table222.Причина: при поиске мы останавливаемся на первой пустой ячейке, считая, что дальше по цепочке от хэша ничего не могло оказаться. Но если при удалении освободить ячейку, эта гарантия нарушается.
Как исправить
1) Могильники tombstonestombstonestombstones
Вместо полного освобождения ячейки ставим специальный маркер "DELETED" могильникмогильникмогильник. Семантика:Поиск: продолжает пробирование и через могильники т.е.могильниксчитаетсяне‑пустойпосмыслупродолженияпоискат.е. могильник считается не‑пустой по смыслу продолжения поискат.е.могильниксчитаетсяне‑пустойпосмыслупродолженияпоиска.Вставка: при пробировании можно запомнить индекс первой встретившейся ячейки DELETED и в конце вставить туда вместопустойвместо пустойвместопустой, чтобы переиспользовать могильник.Удаление: ставим метку DELETED вместо used = 0.Плюсы: простота, корректность поиска.Минусы: могильники накапливаются, ухудшая эффективность эффективнаяплотностьувеличиваетсяэффективная плотность увеличиваетсяэффективнаяплотностьувеличивается. Нужно отслеживать число могильников и периодически выполнять реорганизацию рехешвсейтаблицывновую,чтобыубратьвсеDELETEDрехеш всей таблицы в новую, чтобы убрать все DELETEDрехешвсейтаблицывновую,чтобыубратьвсеDELETED когда число DELETED слишком велико (например, > alpha_threshold * SIZE).
Псевдокод вставки с могильником:
first_deleted = -1for i = hkeykeykey; tableiii.state != EMPTY; i = i+1i+1i+1%SIZE:if tableiii.state == DELETED and first_deleted == -1: first_deleted = iif tableiii.state == OCCUPIED and tableiii.key == key: обновить/returnif first_deleted != -1: insert в first_deleted else insert в текущую EMPTY
2) Рееорганизация backshift/реинсерциякластераbackshift / реинсерция кластераbackshift/реинсерциякластера
После удаления освобождаем ячейку, но затем проходим дальше по цепочке и "подтягиваем" элементы влево, чтобы восстановить непрерывность кластера:free = удалённая позицияj = free+1free+1free+1 % SIZEwhile tablejjj.state == OCCUPIED:
k = tablejjj.key; temp = tablejjjtablejjj.state = EMPTYвставить temp начиная с hkkk илипопробоватьпоставитьименновfree,еслидопустимоили попробовать поставить именно в free, если допустимоилипопробоватьпоставитьименновfree,еслидопустимоfree = новое место, откуда стало EMPTY обычноfree=позиция,гдемывзялиэлементобычно free = позиция, где мы взяли элементобычноfree=позиция,гдемывзялиэлементj = j+1j+1j+1%SIZEостановиться, когда встретили пустую ячейкуПроще и надёжнее вариант: после удаления последовательно извлекаете ключи из следующих занятых ячеек и "вставляете" их заново в таблицу до первой пустой ячейки.Такой подход убирает нужду в могильниках — таблица остаётся чистой.
Примечание по корректной реализации backshift: нужно аккуратно обработать круговую арифметику индексов; алгоритм гарантирует, что элементы останутся достижимы при поиске.
Амортизированная сложность
Для линейного пробирования при случайном равномерномравномерномравномерном хэшировании:Среднее число проверок успешного поиска — O111 точнее≈(1+1/(1−α))/2точнее ≈ (1 + 1/(1-α))/2точнее≈(1+1/(1−α))/2.Среднее число проверок неуспешного поиска — O1/(1−α)1/(1-α)1/(1−α).Вставка имеет ту же асимптотику, зависит от коэффициента заполнения α = n / SIZE.С могильниками:
Поиск и вставка остаются ожидаемо O111 при ограниченном числе могильников; но по мере их накопления эффективность падает эффективныйαрастётэффективный α растётэффективныйαрастёт.Если вы периодически реорганизуете таблицу rehashrehashrehash при достижении порога, то амортизированная стоимость реорганизаций распределяется по операциям, и средняя стоимость остаётся O111.С реорганизацией backshiftbackshiftbackshift:
Одна операция удаления в худшем случае может потребовать сдвинуть/переставить Oclusterlengthcluster_lengthclusterl ength элементов — в худшем Onnn.Тем не менее, при последовательности операций амортизированная стоимость удаления остаётся O111 при поддержании α < 1. Интуитивно: каждый элемент перемещается нечасто всегопринебольшомчислеоперацийнаэлементвыполняетсямаленькоечислосдвиговвсего при небольшом числе операций на элемент выполняется маленькое число сдвиговвсегопринебольшомчислеоперацийнаэлементвыполняетсямаленькоечислосдвигов, а общее число перемещений по последовательности операций линейно растёт с числом операций.Вывод: при корректном поддержании коэффициента заполнения напримерα≤0.7например α ≤ 0.7напримерα≤0.7 все операции поиск,вставка,удалениескорректнойсхемойпоиск, вставка, удаление с корректной схемойпоиск,вставка,удалениескорректнойсхемой дают ожидаемую амортизированную сложность O111. Если не очищать могильники, производительность может деградировать до Onnn на операцию.
Резюме / рекомендации
Нельзя просто ставить used = 0 — это ломает поиск.Проще реализовать: использовать могильники + периодический рехеш при накоплении DELETED.Более «чистый» вариант: после удаления реинсерировать/сдвигать элементы кластера backshiftbackshiftbackshift — устраняет могильники, тоже корректен.Оба подхода при разумных порогах дают амортизированную O111 при случайном хэшировании и α < 1.