Структуры данных и модели вычислений

Списки

Разбить на страницы
Показывать лекцию целиком

Общие сведения о списках

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

Кортеж — это конечная последовательность, возможно с повторениями, элементов некоторого множества $$E$$. Элементами кортежа могут быть числа, символы некоторого алфавита, точки плоскости и т.д. В более сложных случаях элементами кортежа, в свою очередь, могут быть также кортежи. Элементы, не являющиеся кортежами, называются атомами. Количество элементов в кортеже называется его длиной. Удобно рассматривать кортежи, не содержащие ни одного элемента. Такие кортежи называются пустыми. Длина пустого кортежа считается равной $$0$$.

Элемент кортежа характеризуется своим номером в последовательности (кортежным номером) и содержанием, то есть элементом множества $$E$$. Если длина кортежа равна $$n$$, $$n > 0$$, то кортеж $$S$$ удобно рассматривать как отображение $$s$$ множества $$N = \{1, 2\dts n\}$$ в множество $$E$$. Таким образом, $$s(i)$$ — это $$i$$ -й элемент кортежа $$S$$.

Термин "список" используется как обобщающее название различных структур данных, используемых для представления кортежей в памяти компьютера. При представлении кортежа в памяти появляется еще одна характеристика элемента кортежа — его позиция в памяти. В некоторых случаях номер элемента в кортеже и его позиция в памяти связаны друг с другом арифметическими соотношениями таким образом, что по номеру легко вычисляется позиция и, наоборот, по позиции вычисляется номер. В других случаях связь между номерами и позициями задается "таблично" или осуществляется с помощью алгоритмических процедур. Множество позиций обозначим через $$P$$. Иногда удобно считать, что в множестве $$P$$ имеется специальный элемент $${\rm nil}$$, указывающий на несуществующую область памяти. Таким образом, при рассмотрении того или иного списка мы имеем дело с тремя множествами $$E$$, $$N$$, $$P$$ и с отображениями на этих множествах.

Типичными при работе со списками являются следующие операции:

  • Нахождение позиции элемента в памяти по его номеру в кортеже.
  • Нахождение позиции элемента, следующего в кортеже за элементом из заданной позиции.
  • Нахождение позиции элемента, предшествующего в кортеже элементу из заданной позиции.
  • Удаление элемента, находящегося в заданной позиции.
  • Вставка в кортеж нового элемента перед элементом, расположенным в заданной позиции.
  • Определение длины кортежа.
  • При описании этих и других операций со списками будем использовать следующие отображения и константы, заданные на множествах $$E$$, $$N$$, $$P$$:

  • $$\Info$$: $$P \to E$$, где $$\Info$$ (pos) — элемент списка, находящийся в позиции pos памяти.
  • $$\Next: P \to P$$, где $$\Next$$ (pos) — позиция элемента, следующего за элементом из позиции pos.
  • $$\Precede$$: $$P \to P$$, где $$\Precede$$ (pos) — позиция элемента, находящегося перед элементом из позиции pos.
  • $$\Number$$: $$P \to N$$, где $$\Number$$ (pos) — кортежный номер элемента, находящегося в позиции pos.
  • $$\Position$$: $$N \to P$$, где $$\Position$$ ( $$k$$ ) — позиция элемента, имеющего кортежный номер $$k$$.
  • $$\Length$$ — длина списка.
  • $$\First$$ — позиция первого элемента списка.
  • $$\Last$$ — позиция последнего элемента списка.
  • Иногда для распознавания концевых элементов списка пользуются следующим соглашением: eсли pos является позицией последнего элемента списка, то полагают $$\Next\t{(pos)} = \pos$$, а если началом, то $$\Precede(\pos) = \pos$$. В случаях, когда позицией элемента, следующего за последним или предшествующего первому, является несуществующая позиция $$\rm nil$$, будем считать, что $$\rm nil$$ принадлежит множеству $$P$$. При изменении содержимого списка все введенные множества, отображения и константы могут изменяться.

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

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

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

    При представлении кортежей, для которых планируется выполнение операций вставки $$/$$ удаления элемента из произвольной позиции, используется возможность нахождения программным путем свободного пространства в памяти для размещения вставляемого элемента. При использовании языков программирования высокого уровня эти обязанности обычно берет на себя система программирования (оператор new — в языках PASCAL и C). При вставлении нового элемента в список место, куда он вставляется, указывается с помощью косвенной адресации. Это может быть адрес элемента, перед которым либо после которого вставляется новый элемент, либо и тот, и другой. Такой способ дает возможность лишь последовательного доступа к элементам. Другими словами, при последовательном доступе гарантируется определение за единицу времени позиции очередного элемента лишь по позиции предыдущего или следующего за ним элемента, но не по его номеру в кортеже.

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

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

    При описании операций над списками будем считать, что каждый из них описан с помощью дескриптора, имеющего форму записи, состоящей из нескольких полей. Дескриптор предназначен для того, чтобы явно указать составные части, из которых формируется список. Как правило, в дескриптор будет входить поле $$\first$$, содержащее позицию первого элемента. Если в конструкции списка используется некоторый массив, то в дескрипторе указывается имя этого массива.

    Поля, относящиеся к конкретному списку $$L$$, будем записывать в форме$$\eq*{ L.<\!\t{имя\_поля}\!>. }$$

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

    Так, например, дескриптор списка $$L$$ может иметь форму$$\eq*{ [\first, \length]. }$$

    При таком дескрипторе

  • $$L.\first$$ означает позицию первого элемента списка $$L$$,
  • $$L.\length$$ — его длину.
  • Списки с прямым доступом

    Прямой доступ, как правило, реализуется при представлении списка массивом. Элементы кортежа размещаются в идущих подряд ячейках некоторого массива. Для локализации списка в массиве введем целочисленную переменную $$\first$$ для хранения номера позиции массива, в которой расположен его первый элемент, и целочисленную переменную $$\length$$, означающую длину списка. Равенство $$\length = 0$$ служит признаком того, что массив содержит пустой список. Иногда для переменных, хранящих позицию элемента массива, удобно иметь какое-либо условное значение, выходящее за рамки индексации массива. Будем обозначать его $$\beyond$$.

    Рассмотрим подробнее реализацию списка с прямым доступом, дающую возможность добавлять элементы к списку с любого его конца. Воспользуемся циклической "нумерацией" элементов массива, при которой следующим за последним элементом массива считается его первый элемент, а предыдущим для первого — последний (речь идет об элементах массива, а не об элементах списка). Если элементы массива пронумеровать числами от $$0$$ до $$n-1$$, то переход к следующему (предыдущему) элементу списка осуществляется с помощью прибавления (вычитания) единицы по модулю $$n$$, где $$n$$ — длина массива. Дескриптор такого списка будет иметь форму$$\eq*{ S = [n, \info, \first, \length]. }$$

    Добавление элемента к началу списка осуществляется его записью в позицию $$\newfirst = (\newfirst - 1) \ {\rm mod} \ n,\ (0 \le \newfirst < n)$$ с последующим присваиванием $$\first := \newfirst$$, а добавление в конец (записью элемента в позицию ( $$\first + \length) \mod n$$ с последующим выполнением оператора $$\length := (\length + 1)$$. Заметим, что при таком способе начальный фрагмент кортежа может оказаться в конце массива, а конечный фрагмент — в начале. Заметим также, что добавление нового элемента возможно только при условии $$\length < n$$. Кроме того, нужно иметь в виду, что в системах программирования со статическим распределением памяти под массивы, которое происходит во время компиляции, длину массива следует выбирать достаточной для размещения списков, порождаемых разрабатываемым алгоритмом. Следует помнить, что максимальная длина списка зависит не только от алгоритма, но и от входных данных.

    Основные отображения для списка с прямым доступом, имеющего дескриптор $$S = [n, \info, \first, \length]$$, определяются следующим образом:

    $$\Info(pos) = > S.\info [pos],$$ $$\Next(pos) = if (pos = S.\first +\, \t{S.}\length-1 ) then pos$$ $$else if (S.\length < S.n) then (\pos + 1)\ {\rm mod} S.n else \beyond,$$ $$\Precede (pos) = if (pos = S. \first) then pos$$ $$else if (S.>\length < S.n) then (pos - 1) {\rm mod} S.n else \beyond,$$ $$\Last = (S.\first + \t{S.}\length - 1)\ {\rm mod} S.n,$$ $$\Number(pos) = if (S.\first < pos) then (pos - S.\first + 1)$$ $$else (S.pos - S.\first + \,\t{S.n} - 1).$$

    Приведем несколько процедур для работы со списками с прямым доступом.

    Создать пустой список

    $$\formula{\t{procedure}\ {\rm SetEmpty}\t{(S);}\\ \ \t{begin} \\ \t{\rm S.}\first:=0;\\ \t{ S.}\length:=0\\ \t{end};}$$

    Добавить элемент $$e$$ к концу списка $$S$$

    $$\formula{ \t{procedure AddToEnd (e, S)};\\ \t{begin}\\ \mbox{}\q \t{if}\ {S.}\length < \t{S.n}\\ \mbox{}\q \t{then}\{\t{S.}\info[(\t{S.}\first + \t{S.}\length)\ {\rm mod}\ \t{S.n}]:= e;\ \t{S.}\length := \t{S.}\length + 1\}\\ \mbox{}\q \t{else 'массив переполнен'}\\ \t{end};}$$

    Добавить элемент e к началу списка $$S$$

    $$\formula{ \t{procedure AddToBegin (e, S);}\\ \mbox{}\q \t{begin}\\ \mbox{}\qq \t{if}\ \t{S.}\length < \t{S.n}\\ \mbox{}\qq \t{then}\ \t{\{S.}\first \!:=\! \t{S.}\first - 1;\ \t{S.}\info[\t{S.}\first] \!:=\! \t{e};\ \t{S.}\length \!:=\! \t{S.}\length + 1\}\\ \mbox{}\q \t{else 'массив переполнен'}\\ \t{end};}$$

    Заменить элемент с кортежным номером $$k$$ на элемент $$e$$

    $$\formula{ \t{procedure Set (k, e, S);}\\ \t{begin}\\ \mbox{}\q \t{S.}\info \t{[S.}\t{first} + \t{k} - 1] := \t{e}\\ \t{end};}$$

    Удалить последний элемент списка $$S$$

    $$\formula{ \t{procedure DelLast (S);}\\ \t{begin} \\ \mbox{}\q \t{if}\ \t{S.}\length > 0\ \t{then}\ \t{S.}\length:= \t{S.}\length - 1\ \t{else 'список пуст'}\\ \t{end};}$$

    Удалить первый элемент списка $$S$$

    $$\formula{ \t{procedure DelFirst (S);}\\ \t{begin}\\ \mbox{}\q \t{if}\ \t{S.}\length > 0\\ \mbox{}\q \t{then}\ \{\t{S.}\first := (\t{S.}\first + 1)\ {\rm mod}\ \t{S.n};\ \t{S.}\length := \t{S.}\length - 1\}\\ \mbox{}\q \t{else 'список пуст'}\\ \t end; }$$

    Списки с последовательным доступом

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

    Элементы связного списка, следующие друг за другом, не обязательно размещаются в последовательных ячейках памяти — доступ к следующему и предыдущему элементам осуществляется при помощи специальных ссылок (указателей). Чтобы обеспечить запоминание указателей на следующий и предыдущий элементы, каждый элемент списка "погружается" в узел, для которого в памяти компьютера формируется запись, состоящая из нескольких полей. В простейшем случае эта запись может состоять из двух полей. Одно из них — $$\Info$$ — предназначено для запоминания самого элемента, а другое — $$\Next$$ — для запоминания позиции следующего. Для обозначения такого узла будем использовать следующую форму:$$\eq*{ t: [\Info, \Next], }$$ где $$t$$ — позиция (адрес) узла в памяти. Поскольку у последнего элемента нет следующего, его поле $$\Next$$ заполняют значением $$\rm nil$$. Иногда вместо $$\rm nil$$ используют ссылку на сам этот элемент, что также может являться признаком конца списка. Мы часто будем пользоваться именно этим способом распознавания конца списка. Представление списка с помощью таких узлов обеспечивает сканирование списка от начала к его концу. Доступ к самому списку осуществляется через его голову с помощью переменной $$\first$$, содержащей позицию первого элемента. Такие списки называются односторонними.

    При описании операций со списками через $$t\t{\^{}}$$ будем обозначать узел, расположенный в позиции $$t$$. Для доступа к полям узла $$t\t{\^{}}$$ используем форму $$t\t{\^{}}.\Info$$, $$t\t{\^{}}.\Next$$ и т.д. Оператор для создания нового узла будем записывать в виде$$\eq*{ \Create (t: [\Info, \Next]). }$$

    Для обеспечения сканирования как от начала к концу, так и от конца к началу используют узлы следующего вида:$$\eq*{ t: [\Info, \Next, \Precede]. }$$

    Поле $$t\t{\^{}}.\Precede$$ служит для запоминания позиции элемента, предшествующего элементу, находящемуся в позиции $$t$$. Доступ к такому списку может осуществляться как через его начало с помощью переменной $$\first$$, так и через конец с помощью переменной $${\rm last}$$. Такие списки называются двусторонними.

    На рис. 2.1—2.6 представлено несколько разновидностей списков. Узлы списков изображены прямоугольниками, разделенными на части по числу полей. Стрелки проведены в соответствии со значениями полей $$\Next$$ и $$\Precede$$.

    (рис 2.2) Односторонний список: вход через первый элемент, сканирование от начала к концу, признак конца — Next(pos) = nil(рис 2.1) Односторонний список: вход через первый элемент; сканирование от начала к концу, признак конца — Next(pos) = pos(рис 2.4) Односторонний циклический список: вход через первый элемент; сканирование от начала к концу, признак конца — Next(pos) = first(рис 2.3) Односторонний циклический список: вход через последний элемент с помощью ссылки Last^.next; сканирование от начала к концу, признак конца — pos = last(рис 2.6) Двусторонний список: вход через первый элемент, сканирование от начала к концу и от конца к началу; признак начала — Procede(pos) = nil; признак конца — Next(pos) = nil(рис 2.5) Двусторонний циклический список: вход через первый элемент; сканирование от начала к концу и от конца к началу, признак конца — Next(pos) = first

    Рассмотрим основные отображения и операции на примере двустороннего списка, сформированного из узлов вида $$t$$: $$[\Info, \Next, \Precede]$$.

    Будем считать, что дескриптор списка $$S$$ имеет вид: $$[\first, \last]$$. Ниже условимся считать, что признак конца — $$\Next(\pos) = {\rm nil}$$, а признак начала — $$\Precede(\pos) = {\rm nil}$$. Основные отображения определяются следующим образом:

    $$\Info(\pos) = \pos\t{\^{}}.\info$$,

    $$\Next(\pos) = \pos\t{\^{}}.{\rm next}$$,

    $$\Precede(\pos) = \pos\t{\^{}}.{\rm preced}$$,

    Создать пустой список $$S$$

    $$\formula{ \t{procedure SetEmpty (S)};\\ \t{begin} \first := {\rm nil}\\ \t end }$$

    Удалить из списка $$S$$ элемент, находящийся в позиции pos памяти

    $$\formula{ \t{procedure Del(S, pos)};\\ \t{begin}\\ \mbox{}\q \t{t} := {\rm pos}\t{\^{}}.{\rm precede};\ \t{u}:= {\rm pos}\t{\^{}}.{\rm next};\ \t{t\^{}}.{\rm next} := \t{u};\ \t{u}\t{\^{}}.{\rm precede} := \t{t}\\ \t end;}$$

    Замечание В теле процедуры $$\rm Del$$ отсутствует параметр $$S$$. При ее использовании могут возникнуть проблемы, связанные с некорректным обращением, так как в процедуре не производится проверка, является ли позиция pos позицией какого-либо элемента списка $$S$$. Ответственность за некорректное обращение несет вызывающая программа. Проверка этого условия с помощью сканирования списка могла бы оказаться слишком дорогой и свела бы на нет преимущества использования связных списков. Еще одна проблема, связанная с выполнением этой операции, заключается в том, что узел $${\rm pos}\t{\^{}}$$ может оказаться недоступным при потере значения переменной pos, но память будет оставаться занятой. Если это нежелательно, следует, воспользовавшись системными средствами, освободить занимаемую узлом память. Замечания по поводу некорректного обращения будут справедливы и для некоторых следующих процедур, однако мы не будем каждый раз напоминать об этом.

    Вставить в список $$S$$ элемент $$e$$ после элемента, находящегося в позиции $$\pos$$

    $$\formula{ \t{procedure InsertAfter(S, pos, e)};\\ \t{begin}\\ \mbox{}\q {\rm create}(\t{t}: [\t{e},\ \t{pos}\t{\^{}}.{\rm next}, \t{pos}]);\ \t{pos}\t{\^{}}.{\rm next}\t{\^{}}.{\rm preced} := \t{t};\ \t{pos}\t{\^{}}.{\rm next} := \t{t}\\ \t{end}; }$$

    Следующие две операции рассмотрим на примере одностороннего циклического списка (см. рис. 2.4).

    Добавить элемент $$e$$ к концу списка $$S$$

    $$\formula{ \t{procedure AddToEnd(e, S)};\\ \t{begin} {\rm create} (\t{t}:\,[\t{e},\,\t{S.}{\rm last}\t{\^{}}. {\rm next}]);\ \t{S}.{\rm last} := \t{t}\ \t{end}; }$$

    Добавить элемент $$e$$ к началу списка $$S$$

    $$\formula{ \t{procedure AddToBegin (e, S)};\\ \t{begin}\\ \mbox{}\q {\rm create}(\t{t}:\,[\t{e},\,\t{S}\t{\^{}}.{\rm last}\t{\^{}}.{\rm next}]);\ \t{S}.{\rm last}\t{\^{}}.{\rm next} := \t{t}\\ \t end; }$$

    Следующие три процедуры рассмотрим на примере двустороннего циклического списка (см. рис. 2.6).

    Удалить последний элемент списка $$S$$

    $$\formula{ \t{procedure DelLast (S)};\\ \t{begin} \\ \mbox{} \q \t{t} := \t{S}.{\rm first}\t{\^{}}.{\rm precede}\t{\^{}}. {\rm precede};\ \t{t\^{}}.{\rm next} := \t{S}.{\rm first};\ \t{S}. {\rm first}\t{\^{}}.{\rm precede} := \t{t}\\ \t end; }$$

    Удалить первый элемент списка $$S$$

    $$\formula{ \t{procedure DelFirst(S)};\\ \t{begin}\\ \mbox{}\q \t{t} := \t{S}.{\rm first}\t{\^{}}.{\rm next};\, \t{S}. {\rm first}\t{\^{}}.{\rm precede}\t{\^{}}.{\rm next} := \t{t};\ \t{S}.{\rm first} := \t{t}\\ \t end; }$$

    Удалить из списка $$S$$ элемент, находящийся в позиции $$\pos$$

    $$\formula{ \t{procedure DelPosition (S, pos)};\\ \t{begin} \\ \mbox{}\q \t{if}\ {\rm pos} = \t{S}.{\rm first}\ \t{then} {\rm DelFirst}(\t{S})\ \t{else if} {\rm pos} = \t{S}.{\rm last}\ \t{then}\ {\rm DelLast}(\t{S})\\ \mbox{}\q \t{else} \{\t{t\^{}}.{\rm precede}\t{\^{}}.{\rm next} := \t{t\^{}}.{\rm next};\ \t{t\^{}}.{\rm next}\t{\^{}}.{\rm precede} := \t{t\^{}}.{\rm precede}\}\\ \t end; }$$

    Некоторые дополнительные операции со связными списками

    Конкатенация. Эта операция предназначена для соединения двух списков в один результирующий. Она эффективна в тех случаях, когда обеспечен доступ к последнему элементу списка с трудоемкостью $$O(1)$$. При соединении двух списков $$S1$$ и $$S2$$ первый элемент списка $$S2$$ становится преемником последнего элемента списка $$S1$$. При этом возникают вопросы — должен ли получившийся список иметь какое-то новое имя и должны ли сохраниться как таковые исходные списки $$S1$$ и $$S2$$?

    В рассмотренной ниже процедуре Concat, реализующей операцию "Соединить два списка", принято следующее решение. К списку $$S1$$ присоединяется список $$S2$$, список $$S2$$ сохраняется, а результирующим является список $$S1$$. Следует, однако, понимать, что если в список $$S2$$ будут внесены изменения, они автоматически произойдут в новом списке. Трудоемкость этой операции — $$O(1)$$.

    $$\formula{ \t{begin} \t{S1}.{\rm last}\t{\^{}}.{\rm next} := \t{S2}.{\rm first}\ \t{end}; }$$

    Из списка $$S$$ удалить элементы, удовлетворяющие некоторому условию. Предположим, что требуемое условие на элемент $$e$$ проверяется предикатом condition( $$e$$ ).

    $$\formula{ \t{begin t} := \t{S}.{\rm first};\\ \mbox{}\q \t{while t} \ne {\rm nil}\ \t{do}\{\t{if condition} (\t{t\^{}}.{\rm info})\ \t{then DelPosition}(\t{S},{\rm pos});\ \t{t} := \\ \mbox{}\q \t{t\^{}}.{\rm next}\}\\ \t end; }$$

    Построить список $$S1$$, состоящий из элементов данного списка $$S$$, удовлетворяющих некоторому условию. Предположим, что требуемое условие на элемент $$e$$ проверяется предикатом condition ( $$e$$ ).

    $$\formula{ \t{begin} \t{t} := \t{S}.{\rm first};\ \t{SetEmpty}(\t{S1});\\ \mbox{}\q \t{while} \t{t} \ne {\rm nil} \t{do} \{\t{if} \t{condition}(\t{t\^{}}.{\rm info})\ \t{then}\ {\rm AddToEnd}(\t{e}, \t{S1});\ \t{t} := \t{t\^{}}.\t{next}\}\\ \t end; }$$

    Получить список $$S1$$ реверсированием списка $$S$$

    $$\formula{ \t begin\\ \mbox{}\q {\rm SetEmpty}(\t{S1});\, \t{t} := \t{S}.{\rm first};\\ \mbox{}\q \t{while}\ \t{t} \ne {\rm nil}\ \t{do} \{{\rm AddToBegin}(\t{t\^{}}.{\rm inf}, \t{S1});\ \t{t} := \t{t\^{}}.{\rm next}\}\\ \t end; }$$

    Моделирование списков с последовательным доступом при помощи массивов

    Если использование динамических ссылок невозможно или нежелательно (тому могут быть свои причины), список со связями можно смоделировать при помощи массивов. В массиве $${\rm Inf}$$ хранятся элементы списка, то есть значения соответствующих полей узлов списка со связями. Позицией элемента является значение целочисленного индекса массива. Кроме того, вводится целочисленный массив $$\Next$$, в котором для каждого узла списка указана позиция, где расположен его преемник. В качестве индексного пространства используем отрезок $${[1\ldots n]}$$ целочисленного типа.

    В одних и тех же массивах $${\rm Inf}$$ и $$\Next$$ могут размещаться сразу несколько списков, состоящих из узлов одного типа. С учетом такого возможного сосуществования различных списков их элементы могут размещаться в этих массивах хаотично, подобно тому, как узлы списков, представленных с помощью ссылок, могут произвольно располагаться в памяти компьютера.

    На табл. 2.1 показано возможное заполнение массивов $${\rm Inf}$$ и $${\rm Next}$$ для одностороннего списка, представляющего кортеж $$(a, b, c, d, e)$$ (пустые клетки не имеют отношения к этому списку).

    Моделирование одностороннего списка при помощи массива
    Адрес 1 2 3 4 5 6 7 8 9 10 11 12
    Inf e b c d a
    Next 0 6 9 1 3

    Доступ к списку можно осуществить через его первый элемент, позиция которого в массиве задается значением переменной $${\rm first} = 11$$. Значение $$\Next[1] = 0$$ говорит о том, что в позиции $$1$$ расположен элемент, у которого нет преемника, то есть последний элемент кортежа.

    На табл. 2.2 показано возможное заполнение массивов $${\rm Inf}$$, $$\rm Next$$ и $$\rm Precede$$ для представления кортежа ( $$a, b, c, d, e$$ ) двусторонним списком.

    Моделирование двустороннего списка при помощи массивов
    Адрес 1 2 3 4 5 6 7 8 9 10 11 12
    Inf e b c d a
    Next 0 6 9 1 3
    Precede 9 11 3 6 0

    Основные отображения $${\rm Info}({\rm pos})$$, $${\rm Next}({\rm pos})$$, $${\rm Precede}({\rm pos})$$, $${\rm First}$$, $${\rm Last}$$, $${\rm Length}$$ задаются очевидным образом. Если какие-либо из них не заданы явно, то их можно вычислять через другие сканированием списка.

    Чтобы одни и те же массивы $${\rm Info}$$, $${\rm Next}$$, $${\rm Precede}$$ использовать для одновременного хранения нескольких однотипных списков, позиции этих массивов объединяют в один так называемый свободный список $${\rm Avail}$$. Это можно сделать, например, с помощью операторов

    $$\formula{ \t begin\\ \mbox{} \q {\rm Avail}.{\rm first} :=1;\ {\rm Next}[\t{n}] := 0;\ \t{for}\ i:= 1\ \t{to}\ \t{n} - 1\ \t{do}\ {\rm Next}[\t{i}]:= \t{i} + 1;\\ \mbox{} \q {\rm Preced}[1]:= 0;\ \t{for}\ \t{i}:= 2\ \t{to}\ \t{n}\ \t{do}\ {\rm Preced}[i]:= \t{i} - 1;\\ \t{end}; }$$

    Массив $${\rm Info}$$ при этом не заполняется. При создании новых списков используются элементы массивов $${\rm Info}$$, $${\rm Next}$$, $${\rm Precede}$$, предварительно удаляемые из списка $${\rm Avail}$$. В момент создания нового узла из списка $${\rm Avail}$$ удаляется головной элемент, который и используется для добавления в новый список. С другой стороны, при удалении элемента из какого-либо списка освобождаемая позиция добавляется к свободному списку для последующего использования. Такая техника применялась, когда системы программирования не имели стандартных средств динамического выделения памяти. Однако в условиях ограниченной памяти этот прием можно использовать и сейчас. Дело в том, что при достаточно большом объеме оперативной памяти стандартные системы вынуждены использовать многоразрядную адресацию, в то время как для позиционирования в массивах $${\rm Info}$$, $${\rm Next}$$, $${\rm Precede}$$ можно задействовать малоразрядные представления чисел.

    Деревья и графы

    Деревья находят широкое применение при проектировании алгоритмов и, в частности, структур данных. Отсылая читателя к литературе по теории графов, мы будем пользоваться такими понятиями, как узел, ребро, лист, потомок, сын, левый потомок, правый потомок, предок, отец, корень, ветвь и другие. Регулярным деревом назовем дерево, в котором фиксировано максимально возможное (как правило, небольшое) число потомков для каждого из его узлов. В частности, если число потомков для каждого узла не больше двух, то дерево называется бинарным, если не более трех — тернарным. Если это число может равняться только двум или трем, то дерево называется ( $$2$$ — $$3$$ )-деревом.

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

    Так, узлы бинарного корневого дерева можно представлять записями вида$$\eq*{ [{\rm Element}, {\rm Left}, {\rm Right}], }$$ где $${\rm Element}$$ представляет связанную с узлом прикладную информацию, $${\rm Left}$$ — позицию его левого потомка, а $${\rm Right}$$ — позицию правого потомка. Само дерево в таком случае можно представить позицией его корня. Если в алгоритме необходимо продвижение от узла к предку, то узлы бинарного корневого дерева можно представлять записями вида$$\eq*{ [{\rm Element}, {\rm Left}, {\rm Right}, {\rm Father}], }$$ где $${\rm Father}$$ — позиция предка рассматриваемого узла.

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

    Для регулярных деревьев более экономным по памяти может оказаться представление с помощью массива. Рассмотрим этот прием на примере бинарного дерева. Значения индексов массива отождествляются с узлами дерева, пронумерованными так, что корень получает номер 1, а потомки узла c номером $$i$$ получают номера $$2i$$ и $$2i + 1$$. При таком представлении предок узла с номером $$i$$ будет иметь номер $$i \mathop{\rm div}\nolimits 2$$ (частное от деления $$i$$ на 2). Аналогично можно представить тернарное и другие регулярные деревья.

    Остановимся вкратце на представлении графов общего вида. Обыкновенный граф с $$n$$ вершинами часто представляют матрицей смежности, то есть матрицей размером $$n\x n$$, в которой элемент, расположенный в $$i$$ -й строке и $$j$$ -м столбце, равен $$1$$, если вершины графа с номерами $$i$$, $$j$$ соединены ребром, и равен 0, если такого ребра нет. Если граф не ориентирован, то его матрица смежности симметрична и можно ограничиться хранением ее треугольной части.

    Матричный способ представления может оказаться неэкономным с точки зрения использования памяти, если граф разрежен. Так, например, известно, что число ребер связного планарного графа с $$n$$ вершинами не превосходит величины ( $$3n - 6$$ ) при $$n \ge 3$$, то есть оценивается величиной $$O(n)$$, а не как в общем случае $$O(n^2)$$. Представлять такие графы матрицей смежности, как правило, нецелесообразно.

    Другой способ представления графа — это список или массив пар вершин, соответствующих ребрам. При таком способе, если граф не ориентирован, то из двух возможных пар ( $$i$$, $$j$$ ) и ( $$j$$, $$i$$ ) целесообразно хранить только одну, например ту, у которой первая компонента меньше второй.

    Еще один способ, часто имеющий преимущества перед названными выше, — это представление графа массивом или списком списков (рис. 2.7).

    (рис 2.7) Представление графа комбинацией списков: множество вершин представлено списком узлов, к каждому из которых справа подцеплен список смежных с ним вершин

    А именно, для каждой вершины организуется список смежных с ней вершин. В этом случае легко осуществляется доступ к окрестностям вершин. Примерно такого же эффекта можно достичь, представляя граф с помощью двух массивов: $${\rm inf} [1 \ldots m]$$ и $${\rm adr} [1\ldots n + 1)]$$, где $$m$$ — число ребер графа. Массив $${\rm adr}$$ назовем адресным, а $${\rm inf}$$ — информационным. В информационном массиве вначале перечисляются номера вершин, смежных с первой вершиной, затем — со второй и так далее. В адресном массиве указываются номера позиций информационного массива так, чтобы для каждой вершины $$i$$ по ним можно было находить фрагменты массива $${\rm inf}$$, в которых записаны номера вершин, смежных с этой вершиной. Например, $${\rm adr}[i]$$ может хранить позицию, с которой начинаются в массиве $${\rm inf}$$ вершины, смежные с $$i$$ -й, при этом $${\rm adr}[n + 1]$$ — первая позиция за пределами массива $${\rm inf}$$. В таком случае, если нам требуется с каждой вершиной $$j$$ из окрестности вершины $$i$$ выполнить оператор $$S(j)$$, то можно сделать это с помощью оператора цикла:$$\eq*{ for k := {\rm adr}[i]\ to\ {\rm adr} [i + 1] - 1\ \t{do} S({\rm inf}[k]). }$$

    Заметим, что если граф не ориентирован, то каждое ребро ( $$i$$, $$j$$ ) будет представлено дважды: один раз в последовательности вершин, смежных с вершиной $$i$$, а второй раз — в последовательности вершин, смежных с вершиной $$j$$. Но эта избыточность часто бывает полезна с точки зрения времени выполнения операций над окрестностями вершин графа. Как недостаток такого представления графа, можно отметить неудобство при динамической модификации графа, например, добавление к графу ребра может потребовать большого количества пересылок в массиве $$\rm inf$$. Этого недостатка лишен способ представления графа, показанный на рис. 2.7.

    Страницы:

    Общие сведения о списках

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

    Кортеж — это конечная последовательность, возможно с повторениями, элементов некоторого множества $$E$$. Элементами кортежа могут быть числа, символы некоторого алфавита, точки плоскости и т.д. В более сложных случаях элементами кортежа, в свою очередь, могут быть также кортежи. Элементы, не являющиеся кортежами, называются атомами. Количество элементов в кортеже называется его длиной. Удобно рассматривать кортежи, не содержащие ни одного элемента. Такие кортежи называются пустыми. Длина пустого кортежа считается равной $$0$$.

    Элемент кортежа характеризуется своим номером в последовательности (кортежным номером) и содержанием, то есть элементом множества $$E$$. Если длина кортежа равна $$n$$, $$n > 0$$, то кортеж $$S$$ удобно рассматривать как отображение $$s$$ множества $$N = \{1, 2\dts n\}$$ в множество $$E$$. Таким образом, $$s(i)$$ — это $$i$$ -й элемент кортежа $$S$$.

    Термин "список" используется как обобщающее название различных структур данных, используемых для представления кортежей в памяти компьютера. При представлении кортежа в памяти появляется еще одна характеристика элемента кортежа — его позиция в памяти. В некоторых случаях номер элемента в кортеже и его позиция в памяти связаны друг с другом арифметическими соотношениями таким образом, что по номеру легко вычисляется позиция и, наоборот, по позиции вычисляется номер. В других случаях связь между номерами и позициями задается "таблично" или осуществляется с помощью алгоритмических процедур. Множество позиций обозначим через $$P$$. Иногда удобно считать, что в множестве $$P$$ имеется специальный элемент $${\rm nil}$$, указывающий на несуществующую область памяти. Таким образом, при рассмотрении того или иного списка мы имеем дело с тремя множествами $$E$$, $$N$$, $$P$$ и с отображениями на этих множествах.

    Типичными при работе со списками являются следующие операции:

  • Нахождение позиции элемента в памяти по его номеру в кортеже.
  • Нахождение позиции элемента, следующего в кортеже за элементом из заданной позиции.
  • Нахождение позиции элемента, предшествующего в кортеже элементу из заданной позиции.
  • Удаление элемента, находящегося в заданной позиции.
  • Вставка в кортеж нового элемента перед элементом, расположенным в заданной позиции.
  • Определение длины кортежа.
  • При описании этих и других операций со списками будем использовать следующие отображения и константы, заданные на множествах $$E$$, $$N$$, $$P$$:

  • $$\Info$$: $$P \to E$$, где $$\Info$$ (pos) — элемент списка, находящийся в позиции pos памяти.
  • $$\Next: P \to P$$, где $$\Next$$ (pos) — позиция элемента, следующего за элементом из позиции pos.
  • $$\Precede$$: $$P \to P$$, где $$\Precede$$ (pos) — позиция элемента, находящегося перед элементом из позиции pos.
  • $$\Number$$: $$P \to N$$, где $$\Number$$ (pos) — кортежный номер элемента, находящегося в позиции pos.
  • $$\Position$$: $$N \to P$$, где $$\Position$$ ( $$k$$ ) — позиция элемента, имеющего кортежный номер $$k$$.
  • $$\Length$$ — длина списка.
  • $$\First$$ — позиция первого элемента списка.
  • $$\Last$$ — позиция последнего элемента списка.
  • Иногда для распознавания концевых элементов списка пользуются следующим соглашением: eсли pos является позицией последнего элемента списка, то полагают $$\Next\t{(pos)} = \pos$$, а если началом, то $$\Precede(\pos) = \pos$$. В случаях, когда позицией элемента, следующего за последним или предшествующего первому, является несуществующая позиция $$\rm nil$$, будем считать, что $$\rm nil$$ принадлежит множеству $$P$$. При изменении содержимого списка все введенные множества, отображения и константы могут изменяться.

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

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

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

    При представлении кортежей, для которых планируется выполнение операций вставки $$/$$ удаления элемента из произвольной позиции, используется возможность нахождения программным путем свободного пространства в памяти для размещения вставляемого элемента. При использовании языков программирования высокого уровня эти обязанности обычно берет на себя система программирования (оператор new — в языках PASCAL и C). При вставлении нового элемента в список место, куда он вставляется, указывается с помощью косвенной адресации. Это может быть адрес элемента, перед которым либо после которого вставляется новый элемент, либо и тот, и другой. Такой способ дает возможность лишь последовательного доступа к элементам. Другими словами, при последовательном доступе гарантируется определение за единицу времени позиции очередного элемента лишь по позиции предыдущего или следующего за ним элемента, но не по его номеру в кортеже.

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

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

    При описании операций над списками будем считать, что каждый из них описан с помощью дескриптора, имеющего форму записи, состоящей из нескольких полей. Дескриптор предназначен для того, чтобы явно указать составные части, из которых формируется список. Как правило, в дескриптор будет входить поле $$\first$$, содержащее позицию первого элемента. Если в конструкции списка используется некоторый массив, то в дескрипторе указывается имя этого массива.

    Поля, относящиеся к конкретному списку $$L$$, будем записывать в форме$$\eq*{ L.<\!\t{имя\_поля}\!>. }$$

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

    Так, например, дескриптор списка $$L$$ может иметь форму$$\eq*{ [\first, \length]. }$$

    При таком дескрипторе

  • $$L.\first$$ означает позицию первого элемента списка $$L$$,
  • $$L.\length$$ — его длину.
  • Списки с прямым доступом

    Прямой доступ, как правило, реализуется при представлении списка массивом. Элементы кортежа размещаются в идущих подряд ячейках некоторого массива. Для локализации списка в массиве введем целочисленную переменную $$\first$$ для хранения номера позиции массива, в которой расположен его первый элемент, и целочисленную переменную $$\length$$, означающую длину списка. Равенство $$\length = 0$$ служит признаком того, что массив содержит пустой список. Иногда для переменных, хранящих позицию элемента массива, удобно иметь какое-либо условное значение, выходящее за рамки индексации массива. Будем обозначать его $$\beyond$$.

    Рассмотрим подробнее реализацию списка с прямым доступом, дающую возможность добавлять элементы к списку с любого его конца. Воспользуемся циклической "нумерацией" элементов массива, при которой следующим за последним элементом массива считается его первый элемент, а предыдущим для первого — последний (речь идет об элементах массива, а не об элементах списка). Если элементы массива пронумеровать числами от $$0$$ до $$n-1$$, то переход к следующему (предыдущему) элементу списка осуществляется с помощью прибавления (вычитания) единицы по модулю $$n$$, где $$n$$ — длина массива. Дескриптор такого списка будет иметь форму$$\eq*{ S = [n, \info, \first, \length]. }$$

    Добавление элемента к началу списка осуществляется его записью в позицию $$\newfirst = (\newfirst - 1) \ {\rm mod} \ n,\ (0 \le \newfirst < n)$$ с последующим присваиванием $$\first := \newfirst$$, а добавление в конец (записью элемента в позицию ( $$\first + \length) \mod n$$ с последующим выполнением оператора $$\length := (\length + 1)$$. Заметим, что при таком способе начальный фрагмент кортежа может оказаться в конце массива, а конечный фрагмент — в начале. Заметим также, что добавление нового элемента возможно только при условии $$\length < n$$. Кроме того, нужно иметь в виду, что в системах программирования со статическим распределением памяти под массивы, которое происходит во время компиляции, длину массива следует выбирать достаточной для размещения списков, порождаемых разрабатываемым алгоритмом. Следует помнить, что максимальная длина списка зависит не только от алгоритма, но и от входных данных.

    Основные отображения для списка с прямым доступом, имеющего дескриптор $$S = [n, \info, \first, \length]$$, определяются следующим образом:

    $$\Info(pos) = > S.\info [pos],$$ $$\Next(pos) = if (pos = S.\first +\, \t{S.}\length-1 ) then pos$$ $$else if (S.\length < S.n) then (\pos + 1)\ {\rm mod} S.n else \beyond,$$ $$\Precede (pos) = if (pos = S. \first) then pos$$ $$else if (S.>\length < S.n) then (pos - 1) {\rm mod} S.n else \beyond,$$ $$\Last = (S.\first + \t{S.}\length - 1)\ {\rm mod} S.n,$$ $$\Number(pos) = if (S.\first < pos) then (pos - S.\first + 1)$$ $$else (S.pos - S.\first + \,\t{S.n} - 1).$$

    Приведем несколько процедур для работы со списками с прямым доступом.

    Создать пустой список

    $$\formula{\t{procedure}\ {\rm SetEmpty}\t{(S);}\\ \ \t{begin} \\ \t{\rm S.}\first:=0;\\ \t{ S.}\length:=0\\ \t{end};}$$

    Добавить элемент $$e$$ к концу списка $$S$$

    $$\formula{ \t{procedure AddToEnd (e, S)};\\ \t{begin}\\ \mbox{}\q \t{if}\ {S.}\length < \t{S.n}\\ \mbox{}\q \t{then}\{\t{S.}\info[(\t{S.}\first + \t{S.}\length)\ {\rm mod}\ \t{S.n}]:= e;\ \t{S.}\length := \t{S.}\length + 1\}\\ \mbox{}\q \t{else 'массив переполнен'}\\ \t{end};}$$

    Добавить элемент e к началу списка $$S$$

    $$\formula{ \t{procedure AddToBegin (e, S);}\\ \mbox{}\q \t{begin}\\ \mbox{}\qq \t{if}\ \t{S.}\length < \t{S.n}\\ \mbox{}\qq \t{then}\ \t{\{S.}\first \!:=\! \t{S.}\first - 1;\ \t{S.}\info[\t{S.}\first] \!:=\! \t{e};\ \t{S.}\length \!:=\! \t{S.}\length + 1\}\\ \mbox{}\q \t{else 'массив переполнен'}\\ \t{end};}$$

    Заменить элемент с кортежным номером $$k$$ на элемент $$e$$

    $$\formula{ \t{procedure Set (k, e, S);}\\ \t{begin}\\ \mbox{}\q \t{S.}\info \t{[S.}\t{first} + \t{k} - 1] := \t{e}\\ \t{end};}$$

    Удалить последний элемент списка $$S$$

    $$\formula{ \t{procedure DelLast (S);}\\ \t{begin} \\ \mbox{}\q \t{if}\ \t{S.}\length > 0\ \t{then}\ \t{S.}\length:= \t{S.}\length - 1\ \t{else 'список пуст'}\\ \t{end};}$$

    Удалить первый элемент списка $$S$$

    $$\formula{ \t{procedure DelFirst (S);}\\ \t{begin}\\ \mbox{}\q \t{if}\ \t{S.}\length > 0\\ \mbox{}\q \t{then}\ \{\t{S.}\first := (\t{S.}\first + 1)\ {\rm mod}\ \t{S.n};\ \t{S.}\length := \t{S.}\length - 1\}\\ \mbox{}\q \t{else 'список пуст'}\\ \t end; }$$

    Списки с последовательным доступом

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

    Элементы связного списка, следующие друг за другом, не обязательно размещаются в последовательных ячейках памяти — доступ к следующему и предыдущему элементам осуществляется при помощи специальных ссылок (указателей). Чтобы обеспечить запоминание указателей на следующий и предыдущий элементы, каждый элемент списка "погружается" в узел, для которого в памяти компьютера формируется запись, состоящая из нескольких полей. В простейшем случае эта запись может состоять из двух полей. Одно из них — $$\Info$$ — предназначено для запоминания самого элемента, а другое — $$\Next$$ — для запоминания позиции следующего. Для обозначения такого узла будем использовать следующую форму:$$\eq*{ t: [\Info, \Next], }$$ где $$t$$ — позиция (адрес) узла в памяти. Поскольку у последнего элемента нет следующего, его поле $$\Next$$ заполняют значением $$\rm nil$$. Иногда вместо $$\rm nil$$ используют ссылку на сам этот элемент, что также может являться признаком конца списка. Мы часто будем пользоваться именно этим способом распознавания конца списка. Представление списка с помощью таких узлов обеспечивает сканирование списка от начала к его концу. Доступ к самому списку осуществляется через его голову с помощью переменной $$\first$$, содержащей позицию первого элемента. Такие списки называются односторонними.

    При описании операций со списками через $$t\t{\^{}}$$ будем обозначать узел, расположенный в позиции $$t$$. Для доступа к полям узла $$t\t{\^{}}$$ используем форму $$t\t{\^{}}.\Info$$, $$t\t{\^{}}.\Next$$ и т.д. Оператор для создания нового узла будем записывать в виде$$\eq*{ \Create (t: [\Info, \Next]). }$$

    Для обеспечения сканирования как от начала к концу, так и от конца к началу используют узлы следующего вида:$$\eq*{ t: [\Info, \Next, \Precede]. }$$

    Поле $$t\t{\^{}}.\Precede$$ служит для запоминания позиции элемента, предшествующего элементу, находящемуся в позиции $$t$$. Доступ к такому списку может осуществляться как через его начало с помощью переменной $$\first$$, так и через конец с помощью переменной $${\rm last}$$. Такие списки называются двусторонними.

    На рис. 2.1—2.6 представлено несколько разновидностей списков. Узлы списков изображены прямоугольниками, разделенными на части по числу полей. Стрелки проведены в соответствии со значениями полей $$\Next$$ и $$\Precede$$.

    (рис 2.2) Односторонний список: вход через первый элемент, сканирование от начала к концу, признак конца — Next(pos) = nil(рис 2.1) Односторонний список: вход через первый элемент; сканирование от начала к концу, признак конца — Next(pos) = pos(рис 2.4) Односторонний циклический список: вход через первый элемент; сканирование от начала к концу, признак конца — Next(pos) = first(рис 2.3) Односторонний циклический список: вход через последний элемент с помощью ссылки Last^.next; сканирование от начала к концу, признак конца — pos = last(рис 2.6) Двусторонний список: вход через первый элемент, сканирование от начала к концу и от конца к началу; признак начала — Procede(pos) = nil; признак конца — Next(pos) = nil(рис 2.5) Двусторонний циклический список: вход через первый элемент; сканирование от начала к концу и от конца к началу, признак конца — Next(pos) = first

    Рассмотрим основные отображения и операции на примере двустороннего списка, сформированного из узлов вида $$t$$: $$[\Info, \Next, \Precede]$$.

    Будем считать, что дескриптор списка $$S$$ имеет вид: $$[\first, \last]$$. Ниже условимся считать, что признак конца — $$\Next(\pos) = {\rm nil}$$, а признак начала — $$\Precede(\pos) = {\rm nil}$$. Основные отображения определяются следующим образом:

    $$\Info(\pos) = \pos\t{\^{}}.\info$$,

    $$\Next(\pos) = \pos\t{\^{}}.{\rm next}$$,

    $$\Precede(\pos) = \pos\t{\^{}}.{\rm preced}$$,

    Создать пустой список $$S$$

    $$\formula{ \t{procedure SetEmpty (S)};\\ \t{begin} \first := {\rm nil}\\ \t end }$$

    Удалить из списка $$S$$ элемент, находящийся в позиции pos памяти

    $$\formula{ \t{procedure Del(S, pos)};\\ \t{begin}\\ \mbox{}\q \t{t} := {\rm pos}\t{\^{}}.{\rm precede};\ \t{u}:= {\rm pos}\t{\^{}}.{\rm next};\ \t{t\^{}}.{\rm next} := \t{u};\ \t{u}\t{\^{}}.{\rm precede} := \t{t}\\ \t end;}$$

    Замечание В теле процедуры $$\rm Del$$ отсутствует параметр $$S$$. При ее использовании могут возникнуть проблемы, связанные с некорректным обращением, так как в процедуре не производится проверка, является ли позиция pos позицией какого-либо элемента списка $$S$$. Ответственность за некорректное обращение несет вызывающая программа. Проверка этого условия с помощью сканирования списка могла бы оказаться слишком дорогой и свела бы на нет преимущества использования связных списков. Еще одна проблема, связанная с выполнением этой операции, заключается в том, что узел $${\rm pos}\t{\^{}}$$ может оказаться недоступным при потере значения переменной pos, но память будет оставаться занятой. Если это нежелательно, следует, воспользовавшись системными средствами, освободить занимаемую узлом память. Замечания по поводу некорректного обращения будут справедливы и для некоторых следующих процедур, однако мы не будем каждый раз напоминать об этом.

    Вставить в список $$S$$ элемент $$e$$ после элемента, находящегося в позиции $$\pos$$

    $$\formula{ \t{procedure InsertAfter(S, pos, e)};\\ \t{begin}\\ \mbox{}\q {\rm create}(\t{t}: [\t{e},\ \t{pos}\t{\^{}}.{\rm next}, \t{pos}]);\ \t{pos}\t{\^{}}.{\rm next}\t{\^{}}.{\rm preced} := \t{t};\ \t{pos}\t{\^{}}.{\rm next} := \t{t}\\ \t{end}; }$$

    Следующие две операции рассмотрим на примере одностороннего циклического списка (см. рис. 2.4).

    Добавить элемент $$e$$ к концу списка $$S$$

    $$\formula{ \t{procedure AddToEnd(e, S)};\\ \t{begin} {\rm create} (\t{t}:\,[\t{e},\,\t{S.}{\rm last}\t{\^{}}. {\rm next}]);\ \t{S}.{\rm last} := \t{t}\ \t{end}; }$$

    Добавить элемент $$e$$ к началу списка $$S$$

    $$\formula{ \t{procedure AddToBegin (e, S)};\\ \t{begin}\\ \mbox{}\q {\rm create}(\t{t}:\,[\t{e},\,\t{S}\t{\^{}}.{\rm last}\t{\^{}}.{\rm next}]);\ \t{S}.{\rm last}\t{\^{}}.{\rm next} := \t{t}\\ \t end; }$$

    Следующие три процедуры рассмотрим на примере двустороннего циклического списка (см. рис. 2.6).

    Удалить последний элемент списка $$S$$

    $$\formula{ \t{procedure DelLast (S)};\\ \t{begin} \\ \mbox{} \q \t{t} := \t{S}.{\rm first}\t{\^{}}.{\rm precede}\t{\^{}}. {\rm precede};\ \t{t\^{}}.{\rm next} := \t{S}.{\rm first};\ \t{S}. {\rm first}\t{\^{}}.{\rm precede} := \t{t}\\ \t end; }$$

    Удалить первый элемент списка $$S$$

    $$\formula{ \t{procedure DelFirst(S)};\\ \t{begin}\\ \mbox{}\q \t{t} := \t{S}.{\rm first}\t{\^{}}.{\rm next};\, \t{S}. {\rm first}\t{\^{}}.{\rm precede}\t{\^{}}.{\rm next} := \t{t};\ \t{S}.{\rm first} := \t{t}\\ \t end; }$$

    Удалить из списка $$S$$ элемент, находящийся в позиции $$\pos$$

    $$\formula{ \t{procedure DelPosition (S, pos)};\\ \t{begin} \\ \mbox{}\q \t{if}\ {\rm pos} = \t{S}.{\rm first}\ \t{then} {\rm DelFirst}(\t{S})\ \t{else if} {\rm pos} = \t{S}.{\rm last}\ \t{then}\ {\rm DelLast}(\t{S})\\ \mbox{}\q \t{else} \{\t{t\^{}}.{\rm precede}\t{\^{}}.{\rm next} := \t{t\^{}}.{\rm next};\ \t{t\^{}}.{\rm next}\t{\^{}}.{\rm precede} := \t{t\^{}}.{\rm precede}\}\\ \t end; }$$

    Некоторые дополнительные операции со связными списками

    Конкатенация. Эта операция предназначена для соединения двух списков в один результирующий. Она эффективна в тех случаях, когда обеспечен доступ к последнему элементу списка с трудоемкостью $$O(1)$$. При соединении двух списков $$S1$$ и $$S2$$ первый элемент списка $$S2$$ становится преемником последнего элемента списка $$S1$$. При этом возникают вопросы — должен ли получившийся список иметь какое-то новое имя и должны ли сохраниться как таковые исходные списки $$S1$$ и $$S2$$?

    В рассмотренной ниже процедуре Concat, реализующей операцию "Соединить два списка", принято следующее решение. К списку $$S1$$ присоединяется список $$S2$$, список $$S2$$ сохраняется, а результирующим является список $$S1$$. Следует, однако, понимать, что если в список $$S2$$ будут внесены изменения, они автоматически произойдут в новом списке. Трудоемкость этой операции — $$O(1)$$.

    $$\formula{ \t{begin} \t{S1}.{\rm last}\t{\^{}}.{\rm next} := \t{S2}.{\rm first}\ \t{end}; }$$

    Из списка $$S$$ удалить элементы, удовлетворяющие некоторому условию. Предположим, что требуемое условие на элемент $$e$$ проверяется предикатом condition( $$e$$ ).

    $$\formula{ \t{begin t} := \t{S}.{\rm first};\\ \mbox{}\q \t{while t} \ne {\rm nil}\ \t{do}\{\t{if condition} (\t{t\^{}}.{\rm info})\ \t{then DelPosition}(\t{S},{\rm pos});\ \t{t} := \\ \mbox{}\q \t{t\^{}}.{\rm next}\}\\ \t end; }$$

    Построить список $$S1$$, состоящий из элементов данного списка $$S$$, удовлетворяющих некоторому условию. Предположим, что требуемое условие на элемент $$e$$ проверяется предикатом condition ( $$e$$ ).

    $$\formula{ \t{begin} \t{t} := \t{S}.{\rm first};\ \t{SetEmpty}(\t{S1});\\ \mbox{}\q \t{while} \t{t} \ne {\rm nil} \t{do} \{\t{if} \t{condition}(\t{t\^{}}.{\rm info})\ \t{then}\ {\rm AddToEnd}(\t{e}, \t{S1});\ \t{t} := \t{t\^{}}.\t{next}\}\\ \t end; }$$

    Получить список $$S1$$ реверсированием списка $$S$$

    $$\formula{ \t begin\\ \mbox{}\q {\rm SetEmpty}(\t{S1});\, \t{t} := \t{S}.{\rm first};\\ \mbox{}\q \t{while}\ \t{t} \ne {\rm nil}\ \t{do} \{{\rm AddToBegin}(\t{t\^{}}.{\rm inf}, \t{S1});\ \t{t} := \t{t\^{}}.{\rm next}\}\\ \t end; }$$

    Моделирование списков с последовательным доступом при помощи массивов

    Если использование динамических ссылок невозможно или нежелательно (тому могут быть свои причины), список со связями можно смоделировать при помощи массивов. В массиве $${\rm Inf}$$ хранятся элементы списка, то есть значения соответствующих полей узлов списка со связями. Позицией элемента является значение целочисленного индекса массива. Кроме того, вводится целочисленный массив $$\Next$$, в котором для каждого узла списка указана позиция, где расположен его преемник. В качестве индексного пространства используем отрезок $${[1\ldots n]}$$ целочисленного типа.

    В одних и тех же массивах $${\rm Inf}$$ и $$\Next$$ могут размещаться сразу несколько списков, состоящих из узлов одного типа. С учетом такого возможного сосуществования различных списков их элементы могут размещаться в этих массивах хаотично, подобно тому, как узлы списков, представленных с помощью ссылок, могут произвольно располагаться в памяти компьютера.

    На табл. 2.1 показано возможное заполнение массивов $${\rm Inf}$$ и $${\rm Next}$$ для одностороннего списка, представляющего кортеж $$(a, b, c, d, e)$$ (пустые клетки не имеют отношения к этому списку).

    Моделирование одностороннего списка при помощи массива
    Адрес 1 2 3 4 5 6 7 8 9 10 11 12
    Inf e b c d a
    Next 0 6 9 1 3

    Доступ к списку можно осуществить через его первый элемент, позиция которого в массиве задается значением переменной $${\rm first} = 11$$. Значение $$\Next[1] = 0$$ говорит о том, что в позиции $$1$$ расположен элемент, у которого нет преемника, то есть последний элемент кортежа.

    На табл. 2.2 показано возможное заполнение массивов $${\rm Inf}$$, $$\rm Next$$ и $$\rm Precede$$ для представления кортежа ( $$a, b, c, d, e$$ ) двусторонним списком.

    Моделирование двустороннего списка при помощи массивов
    Адрес 1 2 3 4 5 6 7 8 9 10 11 12
    Inf e b c d a
    Next 0 6 9 1 3
    Precede 9 11 3 6 0

    Основные отображения $${\rm Info}({\rm pos})$$, $${\rm Next}({\rm pos})$$, $${\rm Precede}({\rm pos})$$, $${\rm First}$$, $${\rm Last}$$, $${\rm Length}$$ задаются очевидным образом. Если какие-либо из них не заданы явно, то их можно вычислять через другие сканированием списка.

    Чтобы одни и те же массивы $${\rm Info}$$, $${\rm Next}$$, $${\rm Precede}$$ использовать для одновременного хранения нескольких однотипных списков, позиции этих массивов объединяют в один так называемый свободный список $${\rm Avail}$$. Это можно сделать, например, с помощью операторов

    $$\formula{ \t begin\\ \mbox{} \q {\rm Avail}.{\rm first} :=1;\ {\rm Next}[\t{n}] := 0;\ \t{for}\ i:= 1\ \t{to}\ \t{n} - 1\ \t{do}\ {\rm Next}[\t{i}]:= \t{i} + 1;\\ \mbox{} \q {\rm Preced}[1]:= 0;\ \t{for}\ \t{i}:= 2\ \t{to}\ \t{n}\ \t{do}\ {\rm Preced}[i]:= \t{i} - 1;\\ \t{end}; }$$

    Массив $${\rm Info}$$ при этом не заполняется. При создании новых списков используются элементы массивов $${\rm Info}$$, $${\rm Next}$$, $${\rm Precede}$$, предварительно удаляемые из списка $${\rm Avail}$$. В момент создания нового узла из списка $${\rm Avail}$$ удаляется головной элемент, который и используется для добавления в новый список. С другой стороны, при удалении элемента из какого-либо списка освобождаемая позиция добавляется к свободному списку для последующего использования. Такая техника применялась, когда системы программирования не имели стандартных средств динамического выделения памяти. Однако в условиях ограниченной памяти этот прием можно использовать и сейчас. Дело в том, что при достаточно большом объеме оперативной памяти стандартные системы вынуждены использовать многоразрядную адресацию, в то время как для позиционирования в массивах $${\rm Info}$$, $${\rm Next}$$, $${\rm Precede}$$ можно задействовать малоразрядные представления чисел.

    Деревья и графы

    Деревья находят широкое применение при проектировании алгоритмов и, в частности, структур данных. Отсылая читателя к литературе по теории графов, мы будем пользоваться такими понятиями, как узел, ребро, лист, потомок, сын, левый потомок, правый потомок, предок, отец, корень, ветвь и другие. Регулярным деревом назовем дерево, в котором фиксировано максимально возможное (как правило, небольшое) число потомков для каждого из его узлов. В частности, если число потомков для каждого узла не больше двух, то дерево называется бинарным, если не более трех — тернарным. Если это число может равняться только двум или трем, то дерево называется ( $$2$$ — $$3$$ )-деревом.

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

    Так, узлы бинарного корневого дерева можно представлять записями вида$$\eq*{ [{\rm Element}, {\rm Left}, {\rm Right}], }$$ где $${\rm Element}$$ представляет связанную с узлом прикладную информацию, $${\rm Left}$$ — позицию его левого потомка, а $${\rm Right}$$ — позицию правого потомка. Само дерево в таком случае можно представить позицией его корня. Если в алгоритме необходимо продвижение от узла к предку, то узлы бинарного корневого дерева можно представлять записями вида$$\eq*{ [{\rm Element}, {\rm Left}, {\rm Right}, {\rm Father}], }$$ где $${\rm Father}$$ — позиция предка рассматриваемого узла.

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

    Для регулярных деревьев более экономным по памяти может оказаться представление с помощью массива. Рассмотрим этот прием на примере бинарного дерева. Значения индексов массива отождествляются с узлами дерева, пронумерованными так, что корень получает номер 1, а потомки узла c номером $$i$$ получают номера $$2i$$ и $$2i + 1$$. При таком представлении предок узла с номером $$i$$ будет иметь номер $$i \mathop{\rm div}\nolimits 2$$ (частное от деления $$i$$ на 2). Аналогично можно представить тернарное и другие регулярные деревья.

    Остановимся вкратце на представлении графов общего вида. Обыкновенный граф с $$n$$ вершинами часто представляют матрицей смежности, то есть матрицей размером $$n\x n$$, в которой элемент, расположенный в $$i$$ -й строке и $$j$$ -м столбце, равен $$1$$, если вершины графа с номерами $$i$$, $$j$$ соединены ребром, и равен 0, если такого ребра нет. Если граф не ориентирован, то его матрица смежности симметрична и можно ограничиться хранением ее треугольной части.

    Матричный способ представления может оказаться неэкономным с точки зрения использования памяти, если граф разрежен. Так, например, известно, что число ребер связного планарного графа с $$n$$ вершинами не превосходит величины ( $$3n - 6$$ ) при $$n \ge 3$$, то есть оценивается величиной $$O(n)$$, а не как в общем случае $$O(n^2)$$. Представлять такие графы матрицей смежности, как правило, нецелесообразно.

    Другой способ представления графа — это список или массив пар вершин, соответствующих ребрам. При таком способе, если граф не ориентирован, то из двух возможных пар ( $$i$$, $$j$$ ) и ( $$j$$, $$i$$ ) целесообразно хранить только одну, например ту, у которой первая компонента меньше второй.

    Еще один способ, часто имеющий преимущества перед названными выше, — это представление графа массивом или списком списков (рис. 2.7).

    (рис 2.7) Представление графа комбинацией списков: множество вершин представлено списком узлов, к каждому из которых справа подцеплен список смежных с ним вершин

    А именно, для каждой вершины организуется список смежных с ней вершин. В этом случае легко осуществляется доступ к окрестностям вершин. Примерно такого же эффекта можно достичь, представляя граф с помощью двух массивов: $${\rm inf} [1 \ldots m]$$ и $${\rm adr} [1\ldots n + 1)]$$, где $$m$$ — число ребер графа. Массив $${\rm adr}$$ назовем адресным, а $${\rm inf}$$ — информационным. В информационном массиве вначале перечисляются номера вершин, смежных с первой вершиной, затем — со второй и так далее. В адресном массиве указываются номера позиций информационного массива так, чтобы для каждой вершины $$i$$ по ним можно было находить фрагменты массива $${\rm inf}$$, в которых записаны номера вершин, смежных с этой вершиной. Например, $${\rm adr}[i]$$ может хранить позицию, с которой начинаются в массиве $${\rm inf}$$ вершины, смежные с $$i$$ -й, при этом $${\rm adr}[n + 1]$$ — первая позиция за пределами массива $${\rm inf}$$. В таком случае, если нам требуется с каждой вершиной $$j$$ из окрестности вершины $$i$$ выполнить оператор $$S(j)$$, то можно сделать это с помощью оператора цикла:$$\eq*{ for k := {\rm adr}[i]\ to\ {\rm adr} [i + 1] - 1\ \t{do} S({\rm inf}[k]). }$$

    Заметим, что если граф не ориентирован, то каждое ребро ( $$i$$, $$j$$ ) будет представлено дважды: один раз в последовательности вершин, смежных с вершиной $$i$$, а второй раз — в последовательности вершин, смежных с вершиной $$j$$. Но эта избыточность часто бывает полезна с точки зрения времени выполнения операций над окрестностями вершин графа. Как недостаток такого представления графа, можно отметить неудобство при динамической модификации графа, например, добавление к графу ребра может потребовать большого количества пересылок в массиве $$\rm inf$$. Этого недостатка лишен способ представления графа, показанный на рис. 2.7.

    Вернуться к учебному плану