Добавил:
Upload Опубликованный материал нарушает ваши авторские права? Сообщите нам.
Вуз: Предмет: Файл:
пособие1.doc
Скачиваний:
1
Добавлен:
14.08.2019
Размер:
1.32 Mб
Скачать

Глава 9 Динамические структуры данных

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

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

Var a:array[1..300,1..300] of integer;

Один элемент матрицы - целое число занимает в памяти машины 2 байта. Общее количество элементов составляет 300*300=90 000, откуда следует оценка объема памяти, необходимой для размещения всего массива: 180 Кбайт. Нужно заметить, попутно, что в памяти также приходится размещать и программу пользователя, и компилятор, а также все переменные, используемые программой.

Вся оперативная память, выделяемая для работы программы, написанной на Паскале, делится на сегменты. Сегмент - это непрерывный участок памяти, размер которой не превышает 64 Кбайт.

Один сегмент всегда необходим для размещения главной программы.

Если программа имеет модульную структуру, то дополнительно по одному сегменту выделяется для каждого модуля. Кроме того, память обязательно выделяется под системный модуль System. Все эти сегменты носят название сегментов кода. Еще один сегмент необходим для размещения глобальных переменных и типизированных констант. К ним относятся переменные и константы, объявленные в главной программе и в секциях связей модулей. И, наконец, последним является сегмент стека, служащий в основном для размещения локальных данных подпрограмм и внутренних данных модулей. Сегменты программы располагаются в памяти в определенном порядке:

Динамическая память

Оверлейный буфер

Сегмент стека

Сегмент данных

Сегмент кода модуля System

Сегмент кода 1 модуля программы

. . .

Сегмент кода последнего модуля

Сегмент кода главной программы

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

Вспомним программу, которая создавала базу данных по студентам класса. Там мы определяли сравнительно небольшой массив записей с данными (30 элементов). Предположим теперь, что нам надо создать базу для набора абитуриентов. Придется увеличивать размер массива. Но насколько? Допустим, сегодня будет достаточно 100 записей. А если завтра потребуется 1000, 5000 или того больше? Конечно, было бы верхом расточительности предусматривать массив из 5000 элементов, когда реально их нужно значительно меньше.

9.1. Динамическое распределение памяти

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

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

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

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

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

Указатели важны не сами по себе, а лишь постольку, поскольку с их помощью можно работать с динамическими переменными. Динамическая переменная явно не объявляется. Обращение к динамической переменной формируется из имени переменной-указателя, за которым следует символ ^.

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

Пример:

Program Ex28;

Var b:byte; p1:^byte;

Begin

b:=1;

p1:=@(b);

writeln ('Значение динамической пеpеменной p1^ pавно ',p1^);

end.

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

Type StudRec = record

name:string[20];

age : integer

end;

При этом указатель на записи типа StudRec нужно объявить следующим образом:

Type

.....

StudPtr = ^StudRec;

Var Stud:StudPtr; {Ptr - Pointer - указатель}

В первой строке сообщается, что любой объект типа StudPtr является указателем и что он будет указывать на запись типа StudRec. Символ ^ означает "указатель на...".

Во второй строке говорится, что Stud есть указатель типа StudPtr. "Машинная природа" указателя очень проста: содержащееся в нем значение есть не что иное, как адрес некоторой ячейки памяти. Установкой в указателе определенного (адресного) значения обеспечивается возможность последующего доступа к соответствующей области памяти. Сославшись на нее посредством такого указателя, мы можем с легкостью поместить туда какое-то новое значение, вывести содержимое области на печать и вообще делать с ней все то, что разрешено делать с любой поименованной переменной, объявленной обычным образом. Вместе с тем, мы никогда точно не знаем, где именно размещается наша динамическая переменная, поскольку машина сама заботится о нахождении ячейки памяти, адресуемой указателем.

9.2. Создание динамической переменной. Процедура new. Ссылки

Заметим, что никаких переменных типа StudRec (то есть запись-переменных) мы здесь не объявляем.

Всякий раз, когда нам нужна новая запись типа StudRec, мы должны вызвать стандартную процедуру New, передав ей в качестве параметра указатель Stud:

New(Stud);

Переменная Stud, указанная в обращении к new, имеет тип StudPtr. Результатом выполнения процедуры New будет создание в памяти компьютера переменной типа StudRec с одновременным назначением указателю Stud адреса этой переменной.

Конструкция Stud^ (то есть имя переменной-указателя с добавлением в конце символа ^) рассматривается как своеобразное имя этой записи. Иначе говоря, Stud^ - это ссылка на объект типа StudRec, выраженная через указатель Stud.

Процедура New всегда "знает", какого рода объект ей предлагается создать, поскольку в Паскале указатель должен указывать на переменную одного строго определенного типа (в нашем случае указатели типа StudPtr могут указывать только на переменные типа StudRec). Это очень важное свойство языка, обеспечивающее программе защиту от путаницы с адресацией памяти. Такое ограничение создает уверенность, что форматы данных в области памяти, к которой получен доступ, не противоречат тем операциям, которые вы намереваетесь осуществить над ней.

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

Stud^.Name:='Игорь Иванов';

Stud^.Age:=18;

Writeln(Stud^.Name);

Теперь представим, что мы регистрируем следующего абитуриента. Если мы попытаемся вызвать снова процедуру New, передав ей тот же указатель Stud, то первая запись при этом исчезнет, так как значением переменной-указателя является адрес, и, как во всякой переменной, помещение в нее нового значения автоматически стирает прежнее.

Для достижения цели можно применить массив, например из 100 указателей. Всякий раз, когда надо зарегистрировать очередного абитуриента, достаточно обратиться к New с соответствующим указателем - элементом массива:

Var Stud : array[1..100] of StudPtr;

..........

New(Stud[51]);

Мы начали с того, что из соображений экономии памяти отказались от массива записей, но теперь снова пришли к массиву – на сей раз из указателей. О каком же выигрыше может идти речь? Ответить на этот вопрос однозначно нельзя без точной оценки объема памяти, необходимой для размещения переменных того или иного типа. Массив из 100 указателей содержит ровно 100 объектов. В то время как в массиве из 100 записей их может быть в несколько раз больше (в зависимости от количества полей). Таким образом, чем "крупнее" переменная, адресуемая указателем, тем заметнее эффект экономии памяти от применения указателей.

9.3. Связные списки. Константа nil

Указатель можно заставить работать еще более гибким и эффективным образом. Для этого его нужно включить в состав записи, например, так:

Type StudPtr = ^StudRec;

StudRec = record

name :string[20];

age :integer;

nextStud:StudPtr

end;

Var StudList:StudPtr;

Первые две строки приведенного примера заслуживают особого внимания. Вызывает удивление тот факт, что в определении типа StudPtr фигурирует тип StudRec, определяемый позже. Это исключение из общего правила Паскаля, утверждающего, что нельзя использовать объект прежде, чем он будет объявлен.

Чего мы достигаем, поместив указатель внутрь записи? Теперь нет необходимости в массиве указателей.

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

New(StudList^.NextStud)

Но аналогичный указатель есть и в этой записи; его полное имя - StudList^.NextStud^.NextStud^. Будучи употреблено в качестве параметра обращения к New, оно позволяет получить следующую (третью) запись. Действуя подобным образом, мы постепенно выстраиваем некую цепочку записей, в которой каждая запись указывает на следующую за ней. Такого рода структура данных называется связным списком. При проходе по такому списку элемент, стоящий в самом конце (то есть не имеющий за собой следующего), должен содержать в своем поле NextStud некоторое специальное значение, ни на что не указывающее. В Паскале это значение изображается как стандартная символическая константа nil (ничего).

Правомерно, например, присваивание

Var aPtr: ^integer;

..............

aPtr:=nil;

Стандартная константа nil предназначена для изображения пустого значения указателя, то есть такого, при котором указатель не ссылается ни на какую область памяти.

В программировании очень важным является понятие списка. Список – это последовательность элементов (Е1, Е2, …, Еn), для которых задан порядок следования. Порядок следования может задаваться как явно, так и неявно.

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

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

Рис. 9.1 Типовое задание списка

Такие списки называют линейными или связными.

9.4. Нетипизированные указатели

Все описанные выше указатели связаны с конкретными типами данных. Паскаль - строго типизированный язык; поэтому присваивать указателю значение другого указателя, относящегося к другому типу данных нельзя. Но это ограничивает возможности программиста. Обойти такое ограничение позволяет использование нетипизированных указателей. Для объявления переменной нетипизированным указателем нужно употреблять ключевое слово Pointer. Значением нетипизированного указателя может являться либо адрес, либо Nil.

Нетипизированному указателю может быть присвоено значение любого типизированного указателя, и наоборот. Рассмотрим пример.

Program Ex29;

Const p1:^byte=nil;

p2:^string=nil;

p3:pointer=nil;

Begin

New(p2);

p2^:='ab';

p3:=p2;

p1:=p3;

writeln(p1^)

End.

В результате выполнения этой программы указатели P1 и P2 будут ссылаться на одну и ту же область памяти, первоначально выделенную динамической переменной P2^. Поскольку прямо присвоить значение P1 значение P2 нельзя, пришлось использовать нетипизированный указатель P3. В результате своей работы программа выведет на экран длину символьной строки, которая равна 2.

Память для размещения значений динамических переменных выделяется с помощью двух процедур New и GetMem. У процедуры New только один параметр - типизированный указатель. В отличие от New процедура GetMem имеет два параметра. Первым является нетипизированный указатель, вторым - размер резервируемой области памяти в байтах. За одно обращение к этой процедуре можно резервировать не более 64 Кбайт динамической памяти. Покажем на примере, как можно в Паскале реализовать массив с переменными границами.

Program Ex30;

Type

p=^ArrayReal;

ArrayReal=Array[1..1] of Real;

Var

Pn:pointer;

Pt:P;

i,j:integer;

Begin

Getmem(Pn,128);

Pt:=Pn;

For i:=0 to 25 do Pt^[i]:=0;

i:=11; Pt^[i]:=25;

For i:=0 to 4 do

Begin

For j:=1 to 5 do write(Pt^[i*5+j]:10:4);

writeln;

end;

End.

9.5. Стандартные процедуры для динамического управления памятью

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

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

Рассмотрим на примере, каким образом осуществляется выделение и освобождение динамической памяти.

Program Ex31;

Var Pint1, Pint2:^integer; Preal:^Real;

Begin

New(Pint1); Pint1^:=1;

New(Preal); Preal^:=5.2;

New(Pint2); Pint2^:=0;

Dispose(Pint1); Dispose(Preal);

Writeln(Pint2^);

End.

После выполнения трех операторов New в начале динамической области памяти будет зарезервировано 10 байт (по 2 байта на Integer и 4 - на Real). Идущие затем операторы Dispose освободят первые шесть байт. Два следующих байта останутся занятыми значением переменной Pint2^. Значение этой переменной, равное 0, и будет выведено на экран дисплея.

Процедура dispose освобождает участок памяти точно такой же протяженности, как у предоставленного когда-то процедуре New. Разумеется, впоследствии этот участок может пойти в дело при условии, что программе понадобится новое пространство такого же или меньшего размера. Сама Паскаль-система не предпринимает каких-либо автоматических действий по "сборке мусора", то есть такой организационной процедуре, которая обнаруживала бы все разрозненные кусочки никем не используемой памяти и объединяла их в одной непрерывной области.

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

Текущая граница незанятой динамической памяти хранится в переменной-указателе HeapPtr. Процедура Mark запоминает значение указателя HeapPtr. После этого, используя процедуру Release, можно в любой момент освободить динамическую память, начиная с запомненного процедурой Mark адреса. Рассмотрим пример:

Program Ex32;

Var

Ptr:pointer;{Объявление пеpеменной типа Pointer

для использования в пpоцедуpе Mark}

P1,P2,P3,P4:^Byte;

Begin

New(P1); New(P2);

P2^:=1;

Mark(Ptr);{Запоминание значения HeapPtr}

New(P3);

P3^:=0;

Writeln(P2^); Writeln(P3^);

{Освобождение памяти, начиная с адpеса пеpеменной P3^}

Release(Ptr);

New(P4);

P4^:=2;

Writeln(P2^);

{Обpащение к P3^ пpиведет к получению значения 2}

Writeln(P3^)

End.

В результате выполнения этой программы на экран будет выведено сначала 1 и 0, а затем 1 и 2.

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

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

9.6. Понятие и характеристики информационных структур

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

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

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

Каждая информационная структура характеризуется:

  • взаимосвязью элементов;

  • набором типовых операций над этой структурой.

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

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

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

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

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

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

9.6.1. Очереди. Стеки

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

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

Left Right

Рис. 9.2 Типовое представление очереди

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

По своему существу очередь является сугубо динамическим объектом - с течением времени и длина очереди и набор образующих ее элементов изменяются.

Для простоты предположим, что каждый заказ кодируется некоторым целым числом. В таком случае очередь организуется из чисел, представляющих собой коды заказов. Например, исходная очередь могла бы состоять из чисел 59 и 17. Потом из очереди выбирается первое число 59, и в ней остается единственный элемент 17. Если далее в очередь последовательно поступают числа 31 и 7, то теперь очередь имеет вид: 17, 31, 7.

Над очередью могут выполняться операции двух видов:

1) выборка с одновременным удалением из очереди первого ее элемента;

2) занесение нового элемента в конец очереди.

Опишем реализацию очереди на Паскале.

Пример:

Program Ex33;

Type PQuery = ^Query;

Query = record

Inf : integer;

Next : PQuery;

end;

Var Left, Right, E, Final : PQuery;

I, V : integer;

Begin

Read(v); {v-целое число в инф. части 1-го элемента очереди}

I:=1;

writeln(i,'-й эл-т очереди = ',v);

if v=999 then exit;

new(E);

E^.inf:=v;

E^.next:=nil;

Left:=E; Right:=E;

while true do

begin

Read(v);

inc(i);

writeln(i,' эл-т очереди = ',v);

if v=999 then exit; {999 - признак конца ввода данных}

new(E);

E^.inf:=v;

E^.next:=Left;

Left:=E;

end;

goto m;

end.

E E E

Left Left Left Right

Рис. 9.3 Организация очереди

Добавление нового элемента в очередь запишем в виде процедуры.

Procedure AddEQuery;

Begin

Read(v);

If v=999 then Exit;

New(E);

E^.inf := v;

E^.next := nil;

Right^.next := E;

Right := E;

End;

E E E E

Left Right Right

Рис. 9.4 Добавление элемента в очередь

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

Удаление из очереди первого элемента процедурой:

Procedure DelEQuery;

Begin

Final := Left;

Left := Left^.Next;

Dispose(Final);

end;

E E E

Final Left Left Right

Рис. 9.5 Удаление элемента из очереди

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

Различают два основных вида очередей, отличающихся по дисциплине обслуживания находящихся в них элементов. Дисциплину обслуживания, которую мы только что рассмотрели принято называть FIFO (First In - First Out, то есть первый пришел - первый вышел). Надо отметить, что очереди с такой дисциплиной обслуживания используются в программировании относительно редко. Теперь мы остановимся на очереди с такой дисциплиной обслуживания, при которой на обслуживание первым выбирается тот элемент очереди, который поступил в нее последним. Эту дисциплину обслуживания принято называть LIFO (Last In - First Out, то есть последним пришел - первым вышел). Очередь такого вида в программировании принято называть стеком (механизмом магазиной памяти) - это одна из наиболее употребительных структур данных, которая оказывается весьма удобной при решении различных задач.

Условно стек в памяти можно представить следующим образом:

1 – нижняя граница стека;

3 2 – верхушка стека;

3 – верхняя граница стека.

2 Если в ходе добавления элементов в стек,

  1. у казатель вершины стека достигает верхней границы, то возникает переполнение стека.

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

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

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

1) организация стека;

2) удалением элемента из стека;

3) занесение нового элемента в стек;

4) просмотр или обработка стека.

Опишем реализацию стека на Паскале.

Пример:

Program Ex34;

type PStack = ^Stack;

Stack = record

inf : integer;

next : PStack;

end;

var UpStack, E, NE, Temp : PStack;

v,i : integer;

Begin

Read(v); {v-целое число в инф. части 1-го элемента стека}

I:=1;

writeln(i,'-й эл-т стека = ',v);

if v=999 then exit; {999 - признак конца ввода данных}

UpStack := nil; {верхушка стека пуста}

new(E);

E^.inf := v;

E^.next := UpStack;

UpStack := E;

while true do

begin

Read(v); inc(i);

writeln(i,'-й эл-т стека = ',v);

if v=999 then exit; {999 - признак конца ввода данных}

if i>=999 then

begin writeln('ПЕРЕПОЛНЕНИЕ !!!!!!!!');

HALT(1); end;

new(E); E^.inf := v; E^.next := UpStack;

UpStack := E;

end;

e nd.

E E E

UpStack

UpStack UpStack UpStack

Рис. 9.6 Организация стека

Добавление нового элемента в стек запишем в виде процедуры.

Procedure AddEStack;

Begin

Read(v); inc(i);

writeln(i,'-й эл-т стека = ',v);

if v=999 then exit; {999 - признак конца ввода данных}

if i>=999 then

begin writeln('ПЕРЕПОЛНЕНИЕ !!!!!!!!');

HALT(1); end;

new(NE);

NE^.inf := v;

NE^.next := UpStack;

UpStack := NE;

End;

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

Procedure DelEStack;

Label m1, m2;

Var b : char;

Begin

m2: Writeln(‘Вв. «Д», если хотите продолжить удаление эл-в’);

Writeln(‘или «Н» в противном случае’);

Read(b);

If (b=’Н’) or (b=’н’) then goto m1 else

If (b=’Д’) or (b=’д’) then

While UpStack<>Nil do

Begin

NE := UpStack;

UpStack := E^.Next;

Dispose(NE);

End;

Goto m2;

m1:

End;

Следующий пример иллюстрирует организацию стека, используя статические типы данных.

Пусть массив Т содержит арифметическое выражение, записанное с помощью букв, знаков операций и круглых скобок. Например, выражение x+(y*(y-x)+(a-b)/x-y)/a. разместится в массиве Т так: T[1]='x',T[2]='+', T[3]='(' и так далее. Признаком конца выражения служит точка.

Каждому символу выражения ставится в соответствие его индекс в массиве Т. Задача состоит в том, чтобы найти все пары скобок () и построить таблицу В, в которой для каждой такой пары скобок должна быть строка с указанием координат открывающей скобки и соответствующей ей закрывающей. Для приведенного примера в таблице В должны быть пары: 5,9; 11,15; 2,20.

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

В случае этой задачи каждый элемент стека является одиночным числовым значением. Такой стек легко отобразить на одномерный массив S. Операция занесения в стек ( то есть в вершину стека) значения x будет реализовываться процедурой InStek(S,x); операция извлечения из стека ( то есть из его вершины) хранящегося там значения будет реализовываться процедурой OutStek(S,x), где x - имя переменной, в которую занесется значение, извлекаемое (и одновременно удаляемое) из стека. Массив S будет начинаться с элемента S[0], который служит указателем свободного места в стеке, то есть содержит индекс того элемента массива, который следует за текущей вершиной стека. Самый давний по времени поступления элемент стека хранится в массиве S в элементе S[1], а поступившие позднее элементы стека хранятся в массиве S соответственно в его элементах S[2], S[3],...,S[S[0]-1]. Если стек пуст, то S[0]=1. Процедуры InStek и OutStek описываются на Паскале следующим образом:

Procedure InStek(Var S:Stek; x:integer);

Begin S[S[0]]:=x; S[0]:=S[0]+1 end;

Procedure OutStek (Var S:stek; x:integer);

Begin S[0]:=S[0]-1; x:=S[S[0]] end;

Предполагается, что тип Stek описан следующим образом:

Type Stek = array[0..m] of integer;

Процедура InStek начинает работу с того, что заносит значение x в вершину стека, индекс которой в массиве S указывается значением S[0]. После этого первым свободным становится следующий элемент массива S с индексом S[0]+1. Это новое значение индекса первого свободного элемента заносится в S[0].

Процедура OutStek уменьшает S[0] на 1, то есть передвигает указатель стека на последний занятый элемент стека, а затем выбирает оттуда значение и передает его в x.

Уточним теперь постановку задачи построения таблицы соответствия скобок. Анализируемое выражение записано в массиве Т последовательно, символ за символом. Требуется построить двумерный массив В, в котором каждая строка содержит два числа: индексы элементов массива Т для пар скобок - открывающей и соответствующей ей закрывающей. Признаком конца выражения в Т служит точка.

Приведем сначала неформальное описание алгоритма.

  1. Образовать пустой стек S и подготовиться к формированию первой строки массива В.

  2. Перебирать последовательно от начала все элементы массива Т, пока не встретится признак конца выражения. Если в очередном элементе T[k] встретится код символа '(', то индекс этого элемента занести в стек. Если же в очередном элементе T[k] встретится код символа ')', то сформировать очередную i-ю строку массива В, в которую занести, во-первых, значение из вершины стека (индекс элемента массива Т, содержащего код символа '(', и во-вторых, индекс k. Тем самым получаются координаты очередной пары скобок.

Программа, реализующая данный алгоритм:

Program Ex35;

Const Lb =20;{длина В}

m=10; {длина S}

Lt = 100;{длина T}

Type Stek =array [0..m] of integer;

Var S:stek;

B:array[1..Lb,1..2] of integer;

T:string[lt];

i,j,k:integer;

Procedure InStek(Var S:stek; x:integer);

Begin S[S[0]]:=x; S[0]:=S[0]+1 end;

Procedure OutStek(Var S:stek; x:integer);

Begin S[0]:=S[0]-1; x:=S[S[0]] end;

Begin

Read(t);{ Ввод массива Т}

S[0]:=1; {обpазуется пустой стек S}

i:=1; {нoмеp пеpвой свободной стpоки в массиве В}

k:=1; {номеp пеpвого символа в массиве Т}

Repeat

if T[k]='(' then

begin InStek (S,k); B[i,1]:=k;writeln(b[i,1]) end

else if T[k]=')' then

begin

OutStek(S,k);

B[i,2]:=k;

writeln(b[i,2]);

i:=i+1

end;

k:=k+1

Until T[k]='.';

For j:=1 to i-1 do writeln( B[j,1]:3, B[j,2]:3)

end.

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

program Ex36;

{Пpовеpка баланса скобок}

Type

Te=char;

Sv=^Zs;

Zs=record sled:Sv;

elem:Te

end;

Stek=Sv;

Var sym:char;

s:stek;

b:boolean;

Procedure Instek (Var St:stek; NewElem:Te);

Var q:Sv;

Begin

{Создание нового звена}

New(q); q^.elem:=NewElem;

{Созданное звено сделать веpшиной стека}

q^.sled:=st; st:=q

end { пpоцедуpы InStek};

Procedure OutStek(Var St:stek; Var a:Te);

Begin {a:= значение из веpшины стека}

a:=st^.elem;

{Исключение пеpвого звена из стека}

st:=st^.sled

end {пpоцедуpы OutStek};

Function Sootv:boolean;

Var r:char;

Begin

OutStek(s,r);

If sym=')' then Sootv:=r='(';

end; {функции соответствия}

Begin {фоpмиpование пустого стека}

s:=nil;

{sym:=пеpвая литеpа стpоки; b:=true}

Read(sym); b:=true;

While(sym<>'.') and b do

begin

{печать введенной литеpы}

write (sym);

if sym='(' then InStek(s,sym)

else if sym=')' then

begin

if {стек пуст или скобки не соответствуют}

(s=nil)or(not sootv) then b:=false

end;

read(sym) {ввести очеpедную литеpу}

end; {обpаботки литеp стpок}

writeln;

if{было несоответствие скобок или стек не пуст}

not b or (s<>nil)

then writeln('баланса скобок нет')

else writeln('баланс скобок есть')

end.

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

9.6.2. Списки

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

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

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

9.6.2.1. Представление строки в виде цепочки

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

Каждая такая пара элементов массива называется звеном. Последовательные ссылки друг на друга сцепляют звенья в одну цепочку.

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

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

Пусть например, строка представляется цепочкой, состоящей из трех звеньев. Мы можем разместить первое звено в элементах массива S[2] и S[3], второе звено - в элементах S[4] и S[5] и третье звено - в элементах S[6] и S[7]. Однако можно сделать и по-другому: первое звено разместить в элементах S[75] и S[76], второе – в S[31] и S[32], третье - в S[103] и S[104]. Если рассматривать эту цепочку, что совершенно неважно, чем заполнены остальные элементы массива, и в частности, находящиеся между нашими звеньями. Теперь, чтобы включить или исключить символ, в строке ничего не нужно сдвигать: достаточно поменять некоторые ссылки.

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

9.6.2.2. Однонаправленные, двунаправленные и кольцевые списки

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

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

Заглавное звено состоит из трех значений:

Длина звена

Cсылка на начало первого звена

Ссылка на начало свободного места

Первые два значения образуют справку заглавного звена, а ссылка на свободное место представляет собой тело этого звена. В справке последнего звена ссылка равно нулю.

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

Однонаправленный список можно отобразить на одномерный массив S следующим образом. Заглавная запись будет занимать элементы S[0], S[1] и S[2]. Далее размещаются основные записи.

Если индекс звена, содержащего запись x, равен i, то в элементе S[i] находится длина этого звена, в элементе S[i+1] - индекс звена со следующей записью списка (или нуль, если запись x – последняя). Далее, начиная с элемента S[i+2], содержится сама запись x.

Включение записи в список. Пусть i - индекс звена, после которого нужно включить новую запись. Новое звено помещается в массив S на свободное место, указываемое значением S[2]. Таким образом, справка нового звена занимает в массиве S элементы с индексами S[2] и S[2]+1. В первый из них - элемент S[S[2]] – заносится значение длины нового звена. В следующий элемент - S[S[2]+1] – нужно занести ссылку на звено, которое прежде следовало за звеном с индексом i, т.е. нужно переписать туда S[i+1]:

S[S[2]+1]:=S[i+1].

Далее расположится сама включаемая запись (тело нового звена). Само же звено с индексом i должно быть теперь сцеплено со вновь включенным звеном, и поэтому мы заносим в его справку индекс нового звена, равный S[2]: S[i+1]:=S[2].

Пусть новая запись хранится в массиве Inf и занимает n элементов этого массива. В массиве S новое звено вместе со своей справкой займет (n+2) элемента. Поэтому в начало его справки (в элемент S[S[2]]) мы заносим значение n+2. Теперь свободная часть массива S начинается за новым звеном и нужно заменить значение указателя первого свободного элемента: S[2]:=S[2]+n+2.

Напишем соответствующую процедуру InSpisok. Цикл в теле процедуры служит для перенесения элементов массива Inf в массив S. Для наглядности вводится вспомогательная переменная new, в которую помещается индекс S[2] начала нового звена.

Type Spisok = array [0..m] of integer;

TypeInf= array [0..l] of integer;

Procedure InSpisok (Var S:Spisok; var Inf:TypeInf; n,i:integer);

Var new, j:integer;

Begin new:=S[2]; S[new]:=n+2; S[new+1]:=S[i+1];

For j:=1 to n do S[new+1+j]:=Inf[j];

S[i+1]:=new; S[2]:=S[2]+n+2

End;

Исключение записи из списка. Пусть задано значение i, и нужно исключить из списка запись, которая содержится в звене, следующем за звеном с индексом i. Индекс исключаемого звена равен значению S[i+1]. Исключение сводится к изменению этого значения и аналогичному изменению ссылки при исключении символа из строки. Иначе говоря, значение S[i+1] заменяется на значение, которое хранилось в ссылочной части справки исключаемого звена.

Для наглядности введем переменную sled, значением которой будет индекс звена, следовавшего за звеном с индексом i: sled:=S[i+1]. Тогда исключение записи сводится к выполнению присваивания S[i+1]:=S[sled+1]. (Эта пара операторов эквивалентна одному оператору S[i+1]:=S[S[i+1]+1]).

Соответствующая процедура записывается так:

Procedure OutSpisok (Var S:Spisok; i:integer);

Var sled:integer;

Begin sled:=S[i+1]; S[i+1]:=S[sled+1] end;

Как и в случае исключения символа из строки, убираемое из цепочки звено остается в массиве S.

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

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

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

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

Длина звена

Cсылка на следующее звено

Ссылка на предыдущее звено

В заглавном звене стоит пустая ссылка на предыдущее звено, а в последнем - на следующее звено.

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

Опишем реализацию двунаправленного списка на Паскале.

Пример:

Program Ex37;

uses crt;

Type PDouble = ^Spisok;

Spisok = record

inf : integer;

L, R : PDouble;

end;

var Left, Right, E, NE, Temp : PDouble;

v,i : integer;

begin clrscr;

Read(v); {v-целое число в инф. части 1-го элемента д_списка}

I:=1; writeln(i,'-й эл-т д_списка = ',v);

if v=999 then exit; {999 - признак конца ввода данных}

new(E);

E^.inf := v;

E^.L := Nil; E^.R := Nil;

Left := E; Right := E;

while true do

begin

Read(v); inc(i);

writeln(i,'-й эл-т д_списка = ',v);

if v=999 then goto m2; {999 - признак конца ввода данных}

new(E);

E^.inf := v;

E^.L := E; E^.R := Left;

Left := E;

end;

e nd.

Left Right

Рис. 9.7 Организация двухсвязного списка

Добавление нового элемента в список запишем в виде процедуры.

Procedure AddESpisok;

Begin

Read(v); inc(i);

writeln(i,'-й эл-т стека = ',v);

if v=999 then exit; {999 - признак конца ввода данных}

new(NE);

NE^.inf := v;

NE^.R := Temp^.R;

NE^.L := Temp;

Temp^.R^.L := NE;

Temp^.R := NE;

End;

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

Procedure DelESpisok;

Label m1, m2;

Var b : char;

Begin

m2: Writeln(‘Вв. «Д», если хотите продолжить удаление эл-в’);

Writeln(‘или «Н» в противном случае’);

Read(b);

If (b=’Н’) or (b=’н’) then goto m1 else

If (b=’Д’) or (b=’д’) then

While Right<>Left do

Begin

Temp^.R := Temp^.R^.R;

Temp^.R^.L := Temp;

End;

Goto m2;

m1:

End;

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

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

Напишем процедуру InRing включения записи в кольцевой список. Запись длины n находится в массиве Inf и вставляется в список после звена, начинающегося с элемента S[i].

Схема процедуры:

  1. Заполнить первый элемент справки нового звена (его индекс находится в S[3]) числом n+3;

  2. Заполнить второй элемент справки нового звена ссылкой на следующее за вставляемым звено;

  3. Заполнить третий элемент справки нового звена ссылкой на предшествующее вставляемому звено;

  4. Перенести в массив S запись из массива Inf;

  5. Изменить ссылку на предшествующее звено в справке звена, следующего за вставляемым;

  6. Изменить ссылку на следующее звено в звене, указываемом значением i;

  7. Изменить указатель начала свободного места.

Как и в случае однонаправленного списка, для наглядности используем переменные new и sled.

Procedure InRing (Var S:Spisok; Var Inf:TypeInf; n,i:integer);

Var new, sled, j:integer;

Begin new:=S[3]; sled:=S[i+1];

S[new]:=n+3; S[new+1]:=sled; S[new+2]:=i;

For j:=1 to n do S[new+2+j]:=Inf[j];

S[sled+2]:=new; S[i+1]:=new; S[3]:=S[3]+n+3

End;

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

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

При описании процедуры OutRing для исключения записи воспользуемся переменными pred=S[i+2] и sled=S[i+1], значениями которых являются соответственно индексы звеньев предшествующего и следующего за исключаемым звеном, которое указывается значением его индекса i.

Procedure OutRing (Var S:spisok;i:integer);

Var sled,pred: integer;

Begin

sled:=S[i+1]; pred:=S[i+2];

S[pred+1]:=sled; S[sled+2]:=pred

End;

9.6.2.3. Иерархические и ассоциативные списки

При объединении информационных записей в список образуется группа записей с определенным внутренним порядком. Можно рассматривать эту группу как единый информационный объект и образовывать списки из таких составных объектов. Например, можно организовать список высших учебных заведений, где каждому ВУЗу соответствует список факультетов. Такая иерархия может быть многоступенчатой, информацию о каждом факультете можно представить как список студентов, информацию о курсе – как список классов, а информацию о классе - как список студентов.

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

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

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

Иерархически связанные между собой списки могут быть разных типов.

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

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

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

9.6.3. Нелинейные структуры данных

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

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

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

Рис. 9.8 Реализация произвольной информационной сети

Частным случаем сети является дерево.

Рис. 9.9 Реализация дерева

Дерево имеет одну корневую вершину (A), на которую не ссылается ни один из элементов дерева, а также листья (E, F, G). Листья – узлы, которые ни на что не ссылаются. Все остальные узлы считаются промежуточными (B, C, D). Если промежуточная вершина ссылается на другие вершины, то ее называют родительской, а те, на которые ссылается она, дочерними. Иногда дочерние вершины называют сыновними.

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

Рис. 9.10 Машинное представление дерева

Если каждая вершина дерева ссылается на n дочерних вершин, то дерево называется n – арным деревом. Частным случаем n – арного дерева является бинарное дерево.

Бинарное дерево удобно определять рекурсивно, как дерево содержащее корень и левое бинарное дерево. В простейшем случае бинарное дерево может быть пустым.

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

Любая вершина в левом поддереве (ЛД) считается меньшей корня, а любая вершина в првом поддереве – большей.

Рис. 9.11 Бинарное дерево

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

Над деревом могут выполняться следуюшие операции:

  1. организация дерева;

  2. добавление вершины в дерево;

  3. удаление вершины из дерева;

  4. поиск требуемого элемента в дереве;

  5. просмотр дерева.

Просмотр дерева выполняется несколькими способами: сверху-вниз, слева-направо, снизу-вверх.

Любой просмотр разделяется на три элементарные операции:

  1. просмотр или обработка корня дерева;

  2. просмотр левого поддерева;

  3. просмотр правого поддерева.

Если операции выполняются в порядке 1-2-3, то это просмотр сверху-вниз. Если в порядке 2-1-3, то это просмотр слева-направо. Если в порядке 2-3-1, то это просмотр снизу-вверх.

Опишем реализацию бинарного дерева на Паскале.

Пример:

Program Ex38;

uses crt;

Type PTree = ^Tree;

Tree = record

data : integer;

L, R : PTree;

end;

var Up, Ltree, Rtree, Node : PTree;

v : integer;

procedure Grow (var Node : PTree; v : integer);

begin

if Node =nil then

{если текущий узел пустой, то создаем новый узел}

begin

new(Node); Node^.Data:=v;

Node^.L:=nil; Node^.R:=nil;

end else

{если узел не пуст, то вызываем процедуру Grow с соотв. указателем}

if v < Node^.Data then Grow (Node^.L, v)

else if v > Node^.Data then Grow (Node^.R, v)

end;

Begin clrscr;

while true do

Begin

Readln(v); if v=999 then exit;

Grow(Up, v);

End;

end.

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

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

  1. исходный список элементов упорядочивают;

  2. берут серединный элемент списка и считают его корнем;

  3. в качестве корней левого и правого поддерева берут серединные элементы левого и правого подсписков, и т.д.

Определим операцию добавления элементов в дерево как функцию:

Function AddETree (Up, Node : PTree) : Ptree;

Begin

If Up <> nil then

Begin

New (Up); Up^.Data := Node;

Up^.L := nil; Up^. R := nil;

End;

If Up^.Data > Node then Up^.L := AddETree (Up^.L, Node)

else Up^.R := AddETree (Up^.R, Node);

AddETree := Up;

End;

При удалении узла возможны следующие случаи:

  1. удаляемый узел не имеет поддеревьев;

  2. удаляемый узел имеет лишь одно поддерево;

  3. удаляемый узел имеет оба поддерева.

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

Вопросы для самоконтроля:

  1. Как происходит сегментация оперативной памяти для паскаль-программы? В чем заключается динамическое распределение памяти?

  2. Что представляет собой переменная ссылочного типа?

  3. Понятие информационной структуры. Виды информационных структур.

  4. Как будет выглядеть процедура просмотра и вывода данных из очереди?

  5. По какому принципу организуется стек?

  6. Что представляет собой и как организуется список? Виды списков.

  7. Понятие информационной сети. Особенности бинарных деревьев как подкласса информационных сетей.