2010-03-11 2 views
95

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

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

Редактировать: Должен сказать, меня впечатляет не только число, но и качество ответов. Я могу принять только один, но есть два или три, которые я должен был бы сказать, было бы достойно принять, если бы что-то немного лучше не было. Только пара (особенно тот, который я в конечном итоге принимала) указывала на ситуации, когда связанный список обеспечивал реальное преимущество. Я действительно думаю, что Стив Джессоп заслуживает своего рода почетного упоминания о том, что он придумал не один, а три разных ответа, все из которых я нашел весьма впечатляющими. Конечно, несмотря на то, что он был опубликован только как комментарий, а не ответ, я думаю, что запись в блоге Нила также стоит того, чтобы читать - не только информативное, но и весьма интересное.

+33

Ответ на ваш второй абзац занимает около семестра. –

+2

По моему мнению, см. Http://punchlet.wordpress.com/2009/12/27/letter-the-fourth. И поскольку это похоже на опрос, вероятно, это должен быть CW. – 2010-03-11 22:35:50

+1

@ Нил, милый, хотя я сомневаюсь, что С.С. Льюис одобрит. – Tom

ответ

37

Они могут быть полезны для параллельных структур данных. (Существует в настоящее время, не одновременно в реальном мире пример использования ниже. - что там не будет, если @Neil не упомянул FORTRAN ;-)

Например, ConcurrentDictionary<TKey, TValue> в .NET 4.0 используют RC связанных списков цепи элементы, которые хешируют в том же ковше.

Основная структура данных для ConcurrentStack<T> также является связанным списком.

ConcurrentStack<T> является одной из структур данных, которые служат основой для new Thread Pool (с локальными «очередями», реализованными как стеки, по существу). (Другая главная опорная структура, ConcurrentQueue<T>.)

новый пул потоков, в свою очередь обеспечивает основу для планирования работы нового Task Parallel Library.

Таким образом, они, безусловно, могут быть полезны - связанный список в настоящее время служит одной из основных опорных структур по меньшей мере одной большой новой технологии.

(односвязный список делает убедительный lock-free - но не ждать, бесплатно - выбор в этих случаях, поскольку основные операции могут быть выполнены с одного CAS (+ повторами) В современном GC-й. среда, такая как Java и .NET, может легко избежать ABA problem. Просто оберните элементы, которые вы добавляете только что созданным узлам, и не используйте их повторно, чтобы GC выполнял свою работу. страница по проблеме ABA также обеспечивает реализацию стека безблокировочного - что на самом деле работает в .NET (& Java) с (GC-эд) Узел проведения элементов)

Edit:. @Neil: Фактически, то, о чем вы упоминали о FORTRAN, напомнило мне, что аналогичные связанные списки можно найти в, пожалуй, самой используемой и подверженной злоупотреблениям структуре данных в .NET: простой .NET-общий Dictionary<TKey, TValue>.

Не один, но многие связанные списки хранятся в массиве.

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

По существу, многие связанные списки хранятся в массиве. (по одному для каждого используемого ковша). . Свободный список многоразовых узлов «переплетается» между ними (если были удалены). Массив выделяется при перезапуске и перестановке узлов и узлов цепей. Существует также указатель - индекс в массиве - который следует за удалением. ;-) Так что - верьте или нет - техника FORTRAN продолжает жить. (... и нигде больше, чем в одной из наиболее часто используемых структур данных .NET ;-).

+2

В случае, если вы пропустили, вот комментарий Нейла: «Люди (включая меня, я сожалею) использовали для реализации связанных списков без указателей на языках, таких как FORTRAN IV (у которых не было понятия указателей), как они это делали деревья. Вместо «реальной» памяти вы использовали массивы ». –

+0

Я должен добавить, что подход «привязанных списков в массиве» в случае «Словаря» сохраняет значительно больше в .NET: в противном случае для каждого узла потребуется отдельный объект в куче - и каждый объект, выделенный в куче, имеет некоторые накладные расходы , (http://en.csharp-online.net/Common_Type_System%E2%80%94Object_Layout) –

+0

Также полезно знать, что C++ по умолчанию 'std :: list' небезопасен в многопоточном контексте без блокировок. –

0

Я использовал связанные списки (даже дважды связанные списки) в прошлом в приложении C/C++. Это было до .NET и даже stl.

Я, вероятно, не использовал бы связанный список сейчас на языке .NET, потому что весь код обхода, который вам нужен, предоставляется вам с помощью методов расширения Linq.

3

Они полезны, когда вам нужен высокоскоростной толчок, поп и поворот, и не против индексации O (n).

+0

Вы когда-нибудь сталкивались с C++ связанными списками по сравнению с (скажем) deque? – 2010-03-11 22:38:53

+0

@Neil: Не могу сказать, что у меня есть. –

+0

@Neil: если C++ преднамеренно саботировал свой связанный класс списка, чтобы сделать его медленнее, чем любой другой контейнер (который находится недалеко от правды), что связано с языковым агностическим вопросом? Интрузивный связанный список по-прежнему является связанным списком. –

20

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

+0

Можно ли мотивировать, почему использовать список вообще, а не набор или карту? – patrik

47

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

Сплит и соединение (двунаправленные) списки очень эффективны.

Вы также можете комбинировать связанные списки - например. древовидные структуры могут быть реализованы как «вертикальные» связанные списки (родительские/дочерние отношения), соединяющие горизонтальные связанные списки (братья и сестры).

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

  • Добавление нового элемента означает, что массив должен быть перераспределены (или вы должны выделить больше места, чем необходимо для обеспечения дальнейшего роста и уменьшения количество перераспределении)
  • Удаление элементов оставляет неиспользуемого пространства или требует перераспределения
  • вставки элементов в любом месте, кроме конца включает в себя (возможно, перераспределении и) копирования много данных на одну позицию вверх
+5

Итак, вопрос сводится к тому, когда * do * вам нужно сделать много вложений и абстракций в середине последовательности, но не очень много поисков в списке по порядку? Перемещение связанного списка обычно является более дорогостоящим, чем копирование массива, поэтому все, что вы говорите об удалении и вставке элементов в массивы, столь же плохо для случайного доступа в списках. Кэш LRU - один из примеров, о котором я могу думать, вам нужно удалить в середине много, но вам не нужно ходить по списку. –

+2

Добавление в список включает выделение памяти для каждого добавляемого вами элемента. Это может быть связано с системным вызовом, который будет очень дорогим. Для добавления в массив требуется только такой вызов, если массив должен быть увеличен. Фактически, на большинстве языков (именно по этим причинам) массив является предпочтительной структурой данных, а списки практически не используются. – 2010-03-12 01:29:03

+0

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

4

Односвязного список является хорошим выбором для свободного списка в ячейке распределитель или объект пуле:

  1. Вам нужно всего лишь стек, поэтому односвязный список достаточно.
  2. Все уже разделено на узлы. Для назойливого узла списка нет дополнительных затрат на распределение, если ячейки достаточно большие, чтобы содержать указатель.
  3. Вектор или deque накладывают накладные расходы одного указателя на блок.Это важно, учитывая, что, когда вы сначала создаете кучу, все ячейки бесплатны, поэтому это авансовая стоимость. В худшем случае он удваивает потребность в памяти на ячейку.
+0

Ну, согласился. Но сколько программистов на самом деле создают такие вещи? Большинство из них просто перепрофилируют то, что std :: list и т. Д. Дают вам. И на самом деле «навязчивый» обычно имеет несколько иное значение, чем вы его дали, - что каждый возможный элемент списка содержит указатель, отдельный от данных. – 2010-03-12 00:57:44

+1

Сколько? Более 0, менее миллиона ;-) Был ли вопрос Джерри «хорошо использовать списки» или «хорошо использовать списки, которые каждый программист использует на ежедневной основе» или что-то среднее? Я не знаю другого имени, кроме «навязчивого» для узла списка, который содержится в объекте, который является элементом списка, будь то как часть объединения (в терминах C) или нет. Точка 3 применяется только на языках, которые позволяют вам это делать - C, C++, ассемблер хорош. Java плохой. –

4

двусвязные список является хорошим выбором, чтобы определить порядок в HashMap, которая также определяет порядок на элементах (LinkedHashMap в Java), особенно, когда по заказу последнего доступа:

  1. больше памяти накладные расходы, чем связанный вектор или deque (2 указателя вместо 1), но лучше вставить/удалить производительность.
  2. Накладные расходы на распределение, так как в любом случае вам нужен узел для записи хэша.
  3. Локальность ссылки не является дополнительной проблемой по сравнению с вектором или указателем указателей, так как вам придется вытаскивать каждый объект в память в любом случае.

Уверенный, вы можете спорить о том, является ли кеш LRU хорошей идеей, в первую очередь, по сравнению с чем-то более сложным и настраиваемым, но если у вас его будет, это довольно приличная реализация. Вы не хотите выполнять удаление-от-середины и-add-to-end на векторе или deque при каждом доступе к чтению, но перемещение узла к хвосту обычно хорошо.

14

Массивы - это структуры данных, к которым обычно сравниваются связанные списки.

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

Вот список операций, которые могут выполняться в списках и массивах, по сравнению с относительной стоимости работы (п = список/длина массива):

  • Добавление элемента:
    • в списках вам просто нужно выделить память для нового элемента и перенаправить указатели. O (1)
    • на массивах вам нужно переместить массив. O (п)
  • Удаление элемента
    • в списках вы просто перенаправляют указатели. O (1).
    • на массивах, на которые вы тратите время O (n), чтобы переместить массив, если элемент для удаления не является первым или последним элементом массива; в противном случае вы можете просто переместить указатель на начало массива или уменьшить длину массива
  • Получение элемента в известном положении:
    • в списках вы должны ходить список от первого элемента к элемент в определенном положении. Худший случай: O (n)
    • на массивах вы можете сразу получить доступ к элементу. O (1)

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

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

Знание этих различий важно для разработчиков, чтобы выбирать между списками и массивами в своих реализациях.

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

+0

Это несколько верно для хорошей реализации списков и ужасной реализации массивов. Большинство реализаций массивов намного сложнее, чем вы им доверяете. И я не думаю, что вы понимаете, как дорогостоящее распределение динамической памяти. – 2010-03-12 01:22:14

+0

Этот ответ не должен охватывать программу курса «Структуры данных». Это сравнение, написанное с учетом связанных списков и массивов, которые реализованы так, как вы, я и большинство людей знают. Геометрически расширяющиеся массивы, списки пропусков и т. Д. - это те решения, которые я знаю, я использую и изучаю, но для этого потребуется более глубокое объяснение, и это не соответствует решению stackoverflow. –

+1

«С точки зрения распределения памяти списки лучше, потому что нет необходимости иметь все элементы рядом друг с другом.«Напротив, смежные контейнеры лучше *, потому что * они хранят элементы рядом друг с другом. На современных компьютерах локализация данных - это король. Все, что прыгает в памяти, убивает вашу производительность кеша и приводит к программам, которые вставляют элемент в (эффективно) случайное местоположение выполняется быстрее с динамическим массивом, таким как C++ 'std :: vector', чем со связанным списком, таким как C++' std :: list', просто потому, что перемещение списка настолько дорого. –

3

Односвязных списками являются очевидным осуществлением общего «список» типа данных в функциональных языках программирования:

  1. Добавление к голове быстро, и (append (list x) (L)) и (append (list y) (L)) может поделиться почти все своими данными. Нет необходимости в копировании на запись на языке без записи. Функциональные программисты знают, как воспользоваться этим.
  2. Добавление к хвосту, к сожалению, медленное, но так будет и любая другая реализация.

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

2

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

3

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

Например, при реализации отслеживания памяти на C++ (замена новой/удаленной) вам нужна какая-либо структура данных управления, которая отслеживает, какие указатели были освобождены, что вам нужно полностью реализовать. Альтернативой является комбинирование и добавление связанного списка в начало каждого блока данных.

Поскольку вы всегда точно знаете, где вы находитесь в списке при вызове delete, вы можете легко отказаться от памяти в O (1). Также добавление нового фрагмента, который только что был выделен, находится в O (1). Прогулка по списку очень редко нужна в этом случае, поэтому стоимость O (n) здесь не проблема (ходьба по структуре - O (n)).

0

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

Карты Hash, очевидно, могут вставлять и удалять в O (1), но тогда вы не можете перебирать элементы по порядку.

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

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

1

Учтите, что связанный список может быть очень полезен в реализации стиля стиля Driven Design, который включает в себя части, которые блокируются повторением.

Примером, который приходит на ум, может быть, если вы хотите моделировать подвесную цепь. Если вы хотите узнать, какое напряжение на какой-либо конкретной ссылке было, ваш интерфейс может включать в себя геттер для «видимого» веса. Реализация которого будет включать ссылку, запрашивающую его следующую ссылку для ее видимого веса, а затем добавление собственного веса к результату. Таким образом, вся длина до дна будет оцениваться с помощью одного вызова от клиента цепи.

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

2

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

1

Один из наиболее полезных случаев, который я нахожу для связанных списков, работающих в критических по производительности областях, таких как обработка сетки и изображений, физические движки и трассировка лучей, при использовании связанных списков actua lly улучшает локальность ссылок и уменьшает распределение кучи, а иногда даже уменьшает использование памяти по сравнению с прямыми альтернативами.

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

В результате получим случай, когда мы хотим сделать аналогичный эквивалент хранения последовательности переменной длины, которая содержит миллион вложенных подпоследовательностей переменной длины. Конкретным примером является индексированная сетка, в которой хранится миллион многоугольников (некоторые треугольники, некоторые квадратики, некоторые пятиугольники, некоторые шестиугольники и т. Д.), А иногда полигоны удаляются из любой точки сетки, а иногда многоугольники перестраиваются, чтобы вставить вершину в существующий многоугольник или удалите один. В этом случае, если мы храним миллион крошечных std::vectors, тогда мы оказываемся перед распределением кучи для каждого отдельного вектора, а также потенциально взрывоопасной памяти. Миллион крошечных SmallVectors, возможно, не пострадает от этой проблемы в общих случаях, но тогда их предварительно выделенный буфер, который не выделяется отдельно, может по-прежнему вызывать взрывную память.

Проблема заключается в том, что миллионы экземпляров std::vector будут пытаться хранить миллионы объектов переменной длины.Величины переменной длины, как правило, хотят распределить кучу, поскольку они не могут эффективно эффективно сохраняться смежно и удаляться в постоянное время (по крайней мере, прямолинейно без очень сложного распределителя), если они не сохраняют их содержимое в другом месте в куче.

Если, вместо этого, мы делаем это:

struct FaceVertex 
{ 
    // Points to next vertex in polygon or -1 
    // if we're at the end of the polygon. 
    int next; 
    ... 
}; 

struct Polygon 
{ 
    // Points to first vertex in polygon. 
    int first_vertex; 
    ... 
}; 

struct Mesh 
{ 
    // Stores all the face vertices for all polygons. 
    std::vector<FaceVertex> fvs; 

    // Stores all the polygons. 
    std::vector<Polygon> polys; 
}; 

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

Вот еще один пример:

enter image description here

... где ячейки сетки используются для ускорения столкновения между частицами, скажем, 16 миллионов частиц, движущихся каждый кадр. В этом примере сетки частиц, используя связанные списки, мы можем перемещать частицу из одной ячейки сетки в другую, просто изменив 3 индекса. Стирание от вектора и откат к другому может быть значительно более дорогостоящим и ввести больше распределений кучи. Связанные списки также уменьшают память ячейки до 32 бит. Вектор, в зависимости от реализации, может предварительно распределить свой динамический массив до точки, где он может принимать 32 байта для пустого вектора. Если у нас около миллиона ячеек сетки, то это совсем не так.

... и именно здесь я нахожу связанные списки наиболее полезными в наши дни, и я специально нахожу множество «индексированных связанных списков» полезным, поскольку 32-разрядные индексы уменьшают вдвое потребности в памяти ссылок на 64-битных машинах и они подразумевают, что узлы хранятся смежно в массиве.

Часто я также объединить их с индексируемыми свободными списками, чтобы постоянное время удаления и вставки в любом месте:

enter image description here

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

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

Смежные вопросы