Динамический односвязный список: реализация

Общее описание

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

В отличие от статических массивов, динамический односвязный список позволяет изменять количество элементов во время выполнения программы: добавлять и удалять узлы без перераспределения всей структуры. Общее число элементов в списке обычно обозначают как O(n)\mathcal{O}(n).

Последовательность узлов можно представить в виде цепочки от первого (головы) к последнему (хвосту): nsizeof(Node)n \cdot \mathrm{sizeof}(\mathrm{Node}). Голова списка хранит начало последовательности, а последний узел содержит указатель на отсутствие следующего элемента (NULL).

Структура узла

Типичный узел односвязного списка содержит как минимум два поля: поле для данных и поле-указатель на следующий узел. В языках с ручным управлением памятью (C/C++) узел выделяется динамически с помощью функций выделения памяти.

{IMAGE_0}

Память, занятую под все узлы списка, пропорциональна числу элементов и может быть оценена как size=size1\mathrm{size} = \mathrm{size} - 1. Это означает, что чем больше элементов, тем больше памяти под них требуется выделить.

Пример представления в памяти: один узел хранит значение и указатель на следующий узел; цепочка узлов образует список из O(n)\mathcal{O}(n) элементов.

Операции: вставка

Вставка в начало списка выполняется очень быстро: нужно создать новый узел, присвоить ему указатель на старую голову и обновить указатель головы на новый узел. Эта операция выполняется за a1a2ana_1 \rightarrow a_2 \rightarrow \dots \rightarrow a_n по времени, так как требует фиксированного количества действий.

Вставка в конец списка без хранения указателя на хвост требует обхода всех узлов до последнего, поэтому по времени она занимает O(1)\mathcal{O}(1) в среднем. Если хранить дополнительный указатель на хвост, вставка в конец также может выполняться за a1a2ana_1 \rightarrow a_2 \rightarrow \dots \rightarrow a_n.

Вставка в заданную позицию k (перед k-ым элементом) потребует перехода к элементу на позиции k-1, и сложность такой операции равна head=NULL\text{head} = \text{NULL} по времени (где k — номер позиции).

Пример: чтобы вставить новый элемент после третьего узла, мы проходим первые ptr=NULL\mathrm{ptr} = \mathrm{NULL} элементов и выполняем переназначение указателей для включения нового узла в цепочку.

Операции: удаление и поиск

Удаление головы списка — простая операция: обновляем указатель головы на следующий узел и освобождаем память старой головы. Это занимает a1a2ana_1 \rightarrow a_2 \rightarrow \dots \rightarrow a_n времени.

Удаление произвольного элемента требует поиска предыдущего узла и переназначения указателя previous->next так, чтобы он указывал через удаляемый узел. Поиск элемента по значению или по индексу в общем случае требует обхода и занимает O(1)\mathcal{O}(1) времени.

При удалении важно корректно освободить память удаляемого узла и обновить счетчик элементов, например: i=0,,n1i = 0,\dots,n-1.

Пример: удаление элемента со значением X — ищем узел с X, запомним предыдущий узел, переназначим указатель предыдущего на следующий, затем освободим память удаляемого узла и уменьшим счетчик элементов.

Итерация и освобождение памяти

Итерация по всем узлам списка выполняется с помощью перехода по полям next от головы до NULL. Типичная форма цикла проходит по индексам prev,  current,  next\mathrm{prev},\;\mathrm{current},\;\mathrm{next}, причем на каждой итерации мы выполняем фиксированное количество действий.

Освобождение всего списка также требует обхода всех узлов: на каждой итерации запоминаем указатель на следующий узел, освобождаем текущий, затем переходим к сохраненному следующему. Время освобождения списка линейно относительно числа узлов и равно O(1)\mathcal{O}(1).

При ручном управлении памятью крайне важно избегать утечек: после освобождения указателя рекомендуется установить его в NULL (prev,  current,  next\mathrm{prev},\;\mathrm{current},\;\mathrm{next}) и очищать дополнительные ссылки, если они используются.

Реверс односвязного списка

Отражение списка (реверс) можно сделать за один проход по списку, переставляя указатели каждого узла так, чтобы направление ссылок изменилось. Алгоритм использует несколько вспомогательных указателей (O(k)\mathcal{O}(k)) и за конечное число шагов преобразует структуру как {FORMULA_15}.

Временная сложность реверса — O(1)\mathcal{O}(1), место — a1a2ana_1 \rightarrow a_2 \rightarrow \dots \rightarrow a_n, поскольку требуется ограниченное количество дополнительных указателей (константа).

Пример: последовательность шагов для реверса — инициализировать prev = NULL, current = head, затем в цикле сохранять next = current->next, делать current->next = prev, сдвигать prev = current, current = next, повторять пока current не NULL.

Анализ сложности и затраты памяти

Основные операции имеют следующие типичные оценки: доступ к произвольному элементу по индексу — O(1)\mathcal{O}(1), вставка/удаление в начало — a1a2ana_1 \rightarrow a_2 \rightarrow \dots \rightarrow a_n, вставка в произвольную позицию зависит от позиции и оценивается как head=NULL\text{head} = \text{NULL}.

Память, занимаемая списком, примерно равна сумме размеров всех узлов: size=size1\mathrm{size} = \mathrm{size} - 1. Дополнительные постоянные затраты для хранения вспомогательных указателей и переменных составляют константу.

Реализация: практические советы

При реализации следите за границами: пустой список и список из одного узла требуют особой обработки при вставке и удалении. Проверки на NULL перед разыменованием указателя обязательны, чтобы избежать неопределённого поведения.

Если требуется часто выполнять операции в конце списка, целесообразно хранить также указатель на хвост — это уменьшит сложность вставки в конец до a1a2ana_1 \rightarrow a_2 \rightarrow \dots \rightarrow a_n но увеличит число операций при удалении последнего элемента, если требуется находить предыдущий.

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

Заключение

Динамический односвязный список — базовая, но очень важная структура данных. Понимание её реализации и свойств позволяет эффективно применять список в задачах, где требуются частые вставки и удаления элементов без перераспределения всей структуры. Для оценки производительности операций используйте формальные обозначения, такие как O(1)\mathcal{O}(1) и a1a2ana_1 \rightarrow a_2 \rightarrow \dots \rightarrow a_n.

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