Почему удаление элементов хеш-таблицы с использованием двусвязного списка - это O (1)?

В учебнике CLRS «Введение в алгоритм» есть такой абзац на стр. 258.

Мы можем удалить элемент за время O(1), если списки дважды связаны. (Обратите внимание, что CHAINED-HASH-DELETE принимает в качестве входных данных элемент x, а не его ключ k, поэтому нам не нужно сначала искать x. Если хеш-таблица поддерживает удаление, то ее связанный список должен быть дважды связан, чтобы мы можем быстро удалить элемент. Если бы списки были только односвязными, то для удаления элемента x нам сначала нужно было бы найти x в списке, чтобы мы могли обновить атрибут следующий предшественника x. В односвязных списках и удаление, и поиск будут иметь одинаковое асимптотическое время выполнения).

Что меня озадачивает, так это большие скобки, я не понял их логики. В двусвязном списке все равно нужно найти x, чтобы удалить его, чем это отличается от односвязного списка? Пожалуйста, помогите мне понять это!


person John Yang    schedule 12.11.2011    source источник


Ответы (8)


Проблема, представленная здесь, такова: представьте, что вы просматриваете определенный элемент хеш-таблицы. Насколько затратно его удаление?

Предположим, у вас есть простой связанный список:

v ----> w ----> x ----> y ----> z
                |
            you're here

Теперь, если вы удалите x, вам нужно соединить w с y, чтобы ваш список оставался связанным. Вам нужно получить доступ к w и указать, чтобы он указывал на y (вы хотите иметь w ----> y). Но вы не можете получить доступ к w из x, потому что это просто ссылка! Таким образом, вам нужно пройтись по всему вашему списку, чтобы найти w за O(n) операций, а затем сказать ему связать с y. Плохо.

Затем предположим, что вы дважды связаны:

v <---> w <---> x <---> y <---> z
                |
            you're here

Круто, вы можете получить доступ к w и y отсюда, так что вы можете соединить два (w <---> y) в операции O(1)!

person B. Decoster    schedule 12.11.2011
comment
В своем объяснении вы предполагаете, что знаете указатель на x, а не просто x, но в учебнике этого не сказано! Или это где-то в учебнике подразумевается? - person John Yang; 12.11.2011
comment
Note that CHAINED-HASH-DELETE takes as input an element x and not its key k. Да в учебнике сказано, что ты уже там =). Предполагается, что вы знаете указатель на x. Вот почему я переписал проблему в первой строке своего ответа, потому что думал, что вы упустили из виду этот момент. (Это также означает, что вы в целом правы, если вы не знаете x, это будет стоить вам O (n) операций, чтобы найти x, одно- или двусвязное) - person B. Decoster; 13.11.2011
comment
Если вы не знаете x, для его нахождения потребуется примерно O(1), а не O(n). В конце концов, это хэш-таблица. - person Michael Hays; 13.03.2013
comment
перед этим абзацем в книге есть картинка (11.3). значение ключа в коллекции хэшей на самом деле является указателем в их представлении, поэтому x является указателем - person Horia Toma; 10.04.2014
comment
Хотя я думаю, что этот ответ имеет смысл. Я все еще думаю, что учебник не делает хорошую работу здесь. Это не во всех отношениях понятно и сбивает людей с толку. Подумайте о том, что у нас есть пары ключ-значение x (ключ, значение x) в хеш-таблице. Элементы X могут быть любыми, это не обязательно указатель или содержащий указатель связанный список. В учебнике предполагается, что elements является элементом связанного списка, но нигде об этом не упоминается. Было бы хорошо, если бы учебник действительно определял структуру данных элемента x как структуру, содержащую не только значения, но и указатели. - person Robert Wang; 05.02.2016
comment
Я не уверен, как вы можете получить элемент x без поиска в связанном списке. Контекст здесь таков, что мы пытаемся удалить объект v с ключом k, а хеш-таблица использует цепочку в качестве механизма разрешения коллизий. Если у меня есть элемент x (который оборачивает объект v и указывает на его предыдущий и следующий элементы), то да, это полезно, но на практике у нас есть просто v, поэтому удаление по-прежнему занимает O (n) в худшем случае, потому что вам нужно сначала найти x . Я не знаю, что я пропустил, но я не вижу, чтобы двусвязный список помог. - person Alex; 02.10.2017
comment
Спасибо за объяснение, книга может как-то запутать людей из-за слишком большого количества обозначений и специфической терминологии. Рад найти это обсуждение. - person UniSize; 23.05.2018

Мне кажется, что часть хеш-таблицы — это в основном отвлекающий маневр. Реальный вопрос: «можем ли мы удалить текущий элемент из связанного списка за постоянное время, и если да, то как?»

Ответ таков: это немного сложно, но на самом деле да, мы можем — по крайней мере, обычно. Нам не (обычно) приходится проходить весь связанный список, чтобы найти предыдущий элемент. Вместо этого мы можем поменять местами данные между текущим элементом и следующим элементом, а затем удалить следующий элемент.

Единственным исключением является случай, когда/если нам нужно/хотим удалить последний элемент в списке. В этом случае нет следующего элемента для замены. Если вам действительно нужно это сделать, нет никакого реального способа избежать поиска предыдущего элемента. Однако есть способы, которые обычно работают, чтобы избежать этого — один из них состоит в том, чтобы завершить список часовым указателем вместо нулевого указателя. В этом случае, поскольку мы никогда не удаляем узел со значением дозорного, нам никогда не приходится иметь дело с удалением последнего элемента в списке. Это оставляет нам относительно простой код, что-то вроде этого:

template <class key, class data>
struct node {
    key k;
    data d;
    node *next;
};

void delete_node(node *item) {
    node *temp = item->next;
    swap(item->key, temp->key);
    swap(item->data, temp->data);
    item ->next = temp->next;
    delete temp;
}
person Jerry Coffin    schedule 12.11.2011

В целом вы правы - опубликованный вами алгоритм принимает в качестве входных данных сам элемент, а не только его ключ:

Обратите внимание, что CHAINED-HASH-DELETE принимает в качестве входных данных элемент x, а не его ключ k, так что нам не нужно сначала искать x.

У вас есть элемент x — поскольку это двойной связанный список, у вас есть указатели на предшественника и преемника, поэтому вы можете исправить эти элементы в O (1) — с одним связанным списком будет доступен только преемник, поэтому вам придется поиск предшественника за O(n).

person BrokenGlass    schedule 12.11.2011

предположим, вы хотите удалить элемент x , используя список двойных ссылок, вы можете легко соединить предыдущий элемент x со следующим элементом x. поэтому нет необходимости просматривать весь список, и он будет в O (1).

person saddam hussain    schedule 19.07.2012

Find(x), как правило, O (1) для связанной хэш-таблицы - не имеет значения, используете ли вы односвязные списки или двусвязные списки. Они идентичны по производительности.

Если после запуска Find(x) вы решите, что хотите удалить возвращенный объект, вы обнаружите, что внутри хэш-таблицы, возможно, придется снова искать ваш объект. Это по-прежнему обычно будет O (1) и не имеет большого значения, но вы обнаружите, что удаляете очень много, вы можете сделать немного лучше. Вместо того, чтобы возвращать пользовательский элемент напрямую, верните указатель на базовый хеш-узел. Затем вы можете воспользоваться некоторыми внутренними структурами. Так что, если в этом случае вы выбрали двусвязный список как способ выражения своей цепочки, то во время процесса удаления нет необходимости заново вычислять хэш и искать коллекцию снова — вы можете пропустить этот шаг. У вас достаточно информации, чтобы выполнить удаление прямо с того места, где вы сидите. Необходимо соблюдать дополнительную осторожность, если отправляемый узел является головным узлом, поэтому целое число может использоваться для обозначения местоположения вашего узла в исходном массиве, если он является главой связанного списка.

Компромисс заключается в гарантированном пространстве, занимаемом дополнительным указателем, по сравнению с возможным более быстрым удалением (и немного более сложным кодом). На современных настольных компьютерах место обычно очень дешевое, так что это может быть разумным компромиссом.

person Michael Hays    schedule 13.03.2013

Точка зрения кодирования: для реализации этого можно использовать unordered_map в С++.

unordered_map<value,node*>mp;

Где node* — указатель на структуру, хранящую ключ, левый и правый указатели!

Как использовать:

Если у вас есть значение v и вы хотите удалить этот узел, просто выполните:

  1. Получите доступ к этому значению узлов, например mp[v].

  2. Теперь просто направьте его левый указатель на узел справа.

И вуаля, все готово.

(Напомню, что в C++ unordered_map требуется в среднем O(1) для доступа к определенному сохраненному значению.)

person rahuljain1311    schedule 03.06.2015

Просматривая учебник, я также запутался в той же теме (является ли «x» указателем на элемент или сам элемент), а затем в конце концов остановился на этом вопросе. Но после обсуждения вышеизложенного и повторного обращения к учебнику я думаю, что в книге «x» неявно предполагается как «узел», а его возможные атрибуты - «ключ», «следующий».

Некоторые строки образуют учебник.

1)CHAINED-HASH-INSERT(T,x) вставить x в начало списка T[h(x.key)]

2) Если бы списки были односвязными, то для удаления элемента x нам пришлось бы сначала найти x в списке T[h(x.key)], чтобы мы могли обновить следующий атрибут предшественника x.

Следовательно, мы можем предположить, что задан указатель на элемент, и я думаю, что Фезвес дал хорошее объяснение заданного вопроса.

person Pushpendra    schedule 06.01.2019

Учебник неправильный. Первый член списка не имеет пригодного для использования «предыдущего» указателя, поэтому требуется дополнительный код для поиска и разъединения элемента, если он оказывается первым в цепочке (обычно 30 % элементов являются головными в своей цепочке, если N=M (при отображении N элементов в M слотов; каждый слот имеет отдельную цепочку.))

РЕДАКТИРОВАТЬ:

Лучшим способом использования обратной ссылки является использование указателя на ссылку, которая указывает на нас (обычно это ->следующая ссылка предыдущего узла в списке).

struct node {
   struct node **pppar;
   struct node *nxt;
   ...
   }

тогда удаление становится:

*(p->pppar) = p->nxt;

И приятной особенностью этого метода является то, что он одинаково хорошо работает для первого узла в цепочке (чей указатель pppar указывает на некоторый указатель, который не является частью узла.

ОБНОВЛЕНИЕ 11 ноября 2011 г.

Поскольку люди не понимают моей точки зрения, я попытаюсь проиллюстрировать. В качестве примера есть хеш-таблица table (по сути, массив указателей) и набор узлов one, two, three, один из которых нужно удалить.

    struct node *table[123];
    struct node *one, *two,*three;
    /* Initial situation: the chain {one,two,three}
    ** is located at slot#31 of the array */
    table[31] = one, one->next = two , two-next = three, three->next = NULL;
                one->prev = NULL, two->prev = one, three->prev = two;


    /* How to delete element one :*/
    if (one->prev == NULL) {
            table[31] = one->next;
            }
    else    {
            one->prev->next = one->next
            }
    if (one->next) {
            one->next->prev = one->prev;
            }

Теперь очевидно, что приведенный выше код — это O(1), но есть кое-что неприятное: ему по-прежнему нужны array и индекс 31, так что в большинстве случаев узел является «самостоятельным», и указателя на узел достаточно, чтобы удалить его из цепочки, кроме случаев, когда он является первым узлом в своей цепочке; тогда потребуется дополнительная информация, чтобы найти table и 31.

Далее рассмотрим эквивалентную структуру с указателем на указатель в качестве обратной ссылки.

    struct node {
            struct node *next;
            struct node **ppp;
            char payload[43];
            };

    struct node *table[123];
    struct node *one, *two,*three;
    /* Initial situation: the chain {one,two,three}
    ** is located at slot#31 of the array */
    table[31] = one, one-next = two , two-next = three, three->next = NULL;
                one->ppp = &table[31], two->ppp = &one->next, three->ppp = &two-next;

    /* How to delete element one */
    *(one->ppp) = one->next;
    if (one->next) one->next->ppp = one->ppp;

Примечание: никаких особых случаев, и нет необходимости знать родительскую таблицу. (рассмотрите случай, когда существует более одной хэш-таблицы, но с одинаковыми типами узлов: операция удаления все равно должна знать, из какой таблицы следует удалить узел).

Часто в сценарии {prev,next} особых случаев избегают, добавляя фиктивный узел в начале двусвязного списка; Но это также должно быть выделено и инициализировано.

person wildplasser    schedule 12.11.2011
comment
Я не думаю, что вы думали об этом. Подумайте, сколько усилий требует этот дополнительный код в терминах Big-O. - person BrokenGlass; 12.11.2011
comment
Вам нужен дополнительный код, чтобы назначить head новой головке, но это по-прежнему постоянное время. - person brc; 12.11.2011
comment
(typically 30 % of the elements are the head of their chain, if N=M) Я совершенно не понимаю, что это значит... не могли бы вы объяснить? - person B. Decoster; 12.11.2011
comment
@BrokenGlass: конечно, поиск головы - это O (1), но наличие специального пути кода для этого случая окупается только тогда, когда цепочки длинные. Хранение и обслуживание указателей prev также является важным фактором. - person wildplasser; 12.11.2011
comment
Мы все еще говорим о двусвязном списке? - person BrokenGlass; 12.11.2011
comment
Мы говорим о хеш-таблицах и удобстве использования (двойного) связанного списка, ИМХО. - person wildplasser; 12.11.2011
comment
+1 Я полагаю, что люди упустили вашу мысль - это вопрос о хеш-таблицах, которые используют двусвязные списки для смягчения коллизий. Тем не менее, проблемы, которые вы здесь описываете, легко исправимы. Во-первых, сохраните свой связанный список как круговой связанный список. Во-вторых, сохраняйте целое число с каждым узлом. Если целое число равно -1, дополнительная обработка не требуется. Если он положительный, то он отмечает индекс в массиве, чей указатель заголовка должен быть обновлен до вашего собственного следующего указателя. Наконец, вы установите индекс, хранящийся в вашем следующем указателе, на свой собственный. @brc прав. - person Michael Hays; 13.03.2013
comment
@MichaelHays Люди полностью упускают суть. Конечно, двойной указатель должен обновляться при вставке, но стоимость аналогична стоимости поддержки указателя p->prev (размер хранилища также тот же). НО: в случае указателя p-›prev должен быть отдельный путь кода для случая, когда указатель p-›prev имеет значение NULL (голова списка). Аналогично для указателя p-›next-›prev, который можно обновить, только если (p-›next != NULL) - person wildplasser; 13.03.2013