Структура данных 1960-х годов все еще довольно крута.

Мы окружены списками как в реальном мире, так и в нашем коде. Многие языки (особенно функциональные) имеют их встроенные; у остальных будут библиотеки списков.

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

Как мы реализуем списки? В некоторой степени это зависит от схем доступа. Если мы ищем больше, чем изменяем, то было бы неплохо реализовать список в виде дерева, где почти все операции амортизируются до O (log n).

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

Однако для многих целей предпочтительной реализацией является простой связанный список.

Базовый список выглядит так:

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

Добавить узел впереди довольно просто (предупреждение о спойлере: нет, это не так):

new_node = new Node(somePayload)
new_node.next = my_list
my_list = new_node

Удалить первый узел еще проще:

my_list = my_list.next

Но в этом есть большая проблема: если вы когда-нибудь передадите переменную my_list, то будет передан список на момент вызова функции: добавления и удаления не будут отражены.

Есть еще одна проблема с удалением: что будет, если список пуст? Вздох ... я думаю, время для нескольких if заявлений ...

if (my_list != null)
    my_list = new_node

А давайте подумаем о добавлении и удалении в середине списка. В приведенном выше примере удаление узла с полезной нагрузкой 2 означает обновление предыдущего узла, чтобы он указывал на третий узел. Когда мы просматриваем список в поисках удаляемого узла, мы должны отслеживать предыдущий узел. И, угадайте, что у нас есть особый случай, если узел, который мы ищем, является первым узлом в списке.

Две ссылки лучше, чем одна

Мы можем решить проблему добавления и удаления в середине списка, добавив дополнительную ссылку: теперь каждый узел указывает как на узел, следующий за ним, так и на узел, который ему предшествует.

Теперь удаление среднего списка узлов:

nodeToDelete.prev.next = nodeToDelete.next 
nodeToDelete.next.prev = nodeToDelete.prev

С добавлением указателя конца списка его можно легко добавлять и удалять в конце. Но у нас все еще есть особые случаи при манипулировании первым и последним элементами.

Что происходит…

Вот где происходит волшебство. Давайте возьмем наш двусвязный список и превратим его в круг, соединив конец с началом. Но пока мы это делаем, мы собираемся добавить еще один узел. Назовем его ГОЛОВА.

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

Вот код, который удаляет узел перед данным узлом (мы увидим, почему я указываю следующий узел через минуту).

Если мы вызвали его для удаления узла перед полезной нагрузкой 3, мы установили бы следующий указатель полезной нагрузки 2 на полезную нагрузку 4, а предыдущий указатель на 4 - на 2.

Но что это удаляем payload 1? Раньше нам приходилось рассматривать это как частный случай. Но здесь мы просто настраиваем указатели в узле HEAD и узле 2, и список обновляется.

Добавить узел также просто:

На этот раз нам нужно настроить указатели в новом узле, а также следующий указатель в предыдущем узле и предыдущий указатель в следующем узле.

С учетом этих двух функций код для добавления и удаления узлов спереди (shift и unshift) и в конце (push и pop) является тривиальным:

В этом представлении пустой список - это список, содержащий головной узел. В этом случае указатели next и prev указывают на сам заголовок, а это означает, что все написанные нами обходы будут продолжать работать.

Я оставлю выполнение дополнительных функций, таких как list.contains(payload) и list.removeMatching(payload), на ваше усмотрение. Думаю, вы будете приятно удивлены их простотой.

Элегантный?

Впервые я наткнулся на двусвязный круговой список в колледже. Мы программировали симуляции в Simula 67, и встроенный класс SimSet реализует свои очереди таким образом.

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

На мой взгляд, элегантность заключается в добавлении фиктивного узла для представления заголовка списка. Это простое и простое дополнение имеет два важных эффекта:

  1. Это означает, что указатель на список всегда действителен, и
  2. Он удаляет все особые случаи в начале и в конце списка.

Такая экономия вызывает у меня улыбку.

Примечания:

  • Код для этой статьи доступен на GitHub.