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

2.4.4. Циклический (кольцевой) список

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

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

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

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

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

2.5. Реализация списков с выделенным текущим элементом

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

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

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

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

2.5.1. Однонаправленные списки Ссылочная реализация в динамической памяти на основе указателей

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

На рис.2.10. схематически показан процесс вставки нового элемента между первым и вторым элементами списка.

Рис.2.10. Вставка нового элемента в произвольную позицию списка.

Здесь текущим элементом является второй. Из рисунка понятно, что если бы новый элемент вставлялся после текущего (т. е. если бы текущим был первый), то достаточно только указателя на этот текущий элемент, т. к. адрес следующего легко получим из текущего. Однако при определении АТД была специфицирована операция вставки элемента перед текущим (такой способ вставки является общепринятым). Адреса предыдущего элемента в текущем нет. Следовательно, необходим дополнительный указатель на предыдущий элемент.

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

Рис. 2.11. Удаление элементов однонаправленного списка

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

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

Отсюда вывод: для того, чтобы реализовать АТД «Однонаправленный список», необходимо, кроме самого списка, иметь три дополнительных указателя:

  • на начало списка списка — назовем этот указатель head;

  • на текущий элемент — cur;

  • на элемент, предшествующий текущему — predcur.

Текущие значения этих указателей (и только они) определяют состояние списка. Выделим особые состояния:

  • head=predcur=cur=NULL — список пуст, в этом состоянии нельзя удалять элементы, первый добавленный элемент будет одновременно и насалом и концом списка;

  • head ≠ NULL; cur=head; predcur=NULL — «начало списка»; в этом состоянии новый элемент вставляется перед первым;

  • head ≠ NULL; predcur ≠ NULL; cur=NULL;  — «конец списка», в этом состоянии новый элемент вставляется после последнего, а удалить текущий элемент нельзя.

Два последних состояния определены для непустого списка.

Реализация однонаправленного списка на основе указателей приведена в листинге 2.5. Структура list_l1 содержит три указателя и функции, которые реализуют базовый набор операций.

Листинг 2.5. Реализация однонаправленного списка на основе указателей

#include <iostream.h>

typedef int type_of_data; //тип элементов, может быть любым

struct item // структура одного элемента

{ type_of_data data;

item *next;

};

struct list_l1 // структура списка, включающая и функции

{ item *head, *cur, *predcur; //голова, текущий, предыдущий

list_l1() {head=NULL; cur=NULL; predcur=NULL;}//конструктор

bool eolist() { return cur==NULL; } //проверка на конец списка

bool isnull() { return head==NULL; } // проверка на пустоту

type_of_data getdata(); // получить текущий элемент

void first(); // встать в начало

void next(); // перейти к следующему элементу

void ins(type_of_data x);// вставка перед текущим, новый будет текущим

void del(); // удаление текущего, текущим станет предыдущий

void makenull(); // очистка списка

};

// реализация функций

type_of_data list_l1::getdata()

{ if (isnull()) { cerr << "Список пустой\n"; exit(1); }

if (eolist()) { cerr << "Достигнут конец списка\n"; exit(2); }

return cur->data;

}

void list_l1::first()

{ cur=head; predcur=NULL;

}

void list_l1::next()

{ if (isnull()) { cerr << "Список пустой\n"; exit(1); }

if (eolist()) { cerr << "Достигнут конец списка\n"; exit(2); }

predcur=cur; cur=cur->next;

}

void list_l1::ins(type_of_data x)

{ item *temp=new item;

temp->data=x; temp->next=cur;

if (predcur) predcur->next=temp; //Если элемент вставляется не в начало списка

else head=temp; //вставка в начало

cur=temp;

}

void list_l1::del()

{ if (isnull()) { cerr << "Список пустой\n"; exit(1); }

if (eolist()) { cerr << "Достигнут конец списка\n"; exit(2); }

if (predcur) //Если удаляется не начало списка

{ predcur->next=cur->next; delete cur; cur=predcur->next;

}

else // удаляется первый элемент

{ head=head->next; delete cur; cur=head;

}

}

void list_l1::makenull()

{ first();

while (!isnull()) del();

}

Ссылочная реализация на основе массива

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

Тогда каждый элемент списка будет представлять собой запись (в языке С++ — структуру),а память, отводимая для хранения списка, представляется одномерным массивом записей. Поскольку память под массив выделяется заранее, то максимальная длина списка ограничена размером массива. В этом случае говорят об ограниченном Л1–списке и при реализации обязательно отслеживают аварийную ситуацию, связанную с переполнением области памяти.

На рис.2.12 приведен пример размещения списка, состоящего из четырех элементов, имеющих значения abcd в массиве, максимальный размер которого составляет 8 элементов. Очевидно, в этом списке уже выполнялись операции удаления и вставки, поскольку элементы расположены не по порядку. Но это не важно, т. к. индекс следующего элемента явно хранится в каждом элементе. Последний элемент имеет значение поля связи, соответствующее пустой ссылке. В примере используется несуществующее значение индекса -1. Для работы со списком используем три дополнительных указателя как и при реализации в динамической памяти (head, cur, predcur). Для примера текущим выбран третий элемент списка со значением c.

Рис. 2.12. Ссылочная реализация однонаправленного списка на основе массива

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

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

Рис. 2.13. Список занятых элементов массива и список (стек) свободных ячеек.

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

Листинг 2.6. Ссылочная реализация однонаправленного списка на основе массива

#include <iostream.h>

typedef int type_of_data; //тип элементов, может быть любым

struct item // структура для одного элемента списка

{ type_of_data data; // данные

int next; // индекс следующего элемента

};

const int maxlength=100;

struct list_l1 // структура списка, включающая и функции

{ item elements[maxlength]; //массив элементов

int head, cur, predcur; //голова, текущий, предыдущий

int top_free; //указатель на вершину стека пустых ячеек

list_l1(); //конструктор

bool eolist() {return cur==-1;} //проверка на конец списка

bool isnull() { return head==-1; } // проверка на пустоту

type_of_data getdata(); // получить текущий элемент

void first(); // встать в начало

void next(); // перейти к следующему элементу

void ins(type_of_data x);// вставка перед текущим, новый //будет текущим

void del(); // удаление текущего, текущим станет предыдущий

void makenull(); // очистка списка

};

// реализация функций

list_l1::list_l1()

{ head=-1; cur=-1; predcur=-1;// начальные значения индексов

// организация стека для пустых ячеек

elements[maxlength-1].next=-1;//последний элемент-дно //стека

for (int i=maxlength-2; i>=0; i--)

elements[i].next=i+1;

top_free=0; // первый элемент (с нулевым индексом) - //вершина стека

}

type_of_data list_l1::getdata()

{ if (isnull())

{ cerr << "Список пустой\n"; exit(1); }

if (eolist())

{ cerr << "Достигнут конец списка\n"; exit(2); }

return elements[cur].data;

}

void list_l1::first()

{ cur=head; predcur=-1;

}

void list_l1::next()

{ if (isnull())

{ cerr << "Список пустой\n"; exit(1); }

if (eolist())

{ cerr << "Достигнут конец списка\n"; exit(2); }

predcur=cur; cur=elements[cur].next;

}

void list_l1::ins(type_of_data x)

{ if (top_free==-1) { cerr << "Список переполнен\n"; exit(3); }

int k=top_free; top_free=elements[top_free].next;

elements[k].data=x; elements[k].next=cur;

if (predcur!=-1) elements[predcur].next=k; // если элемент вставляется не в начало списка

else head=k; // вставка в начало

cur=k;

}

void list_l1::del()

{ if (isnull())

{ cerr << "Список пустой\n"; exit(1); }

if (eolist())

{ cerr << "Достигнут конец списка\n"; exit(2); }

if (predcur!=-1) // если удаляется не начало списка

{ elements[predcur].next=elements[cur].next;

elements[cur].next=top_free; top_free=cur; // добавление //в стек свободной ячейки

cur=elements[predcur].next;

}

else // удаляется первый элемент

{ head=elements[head].next;

elements[cur].next=top_free; top_free=cur;

cur=head;

}

}

void list_l1::makenull()

{ if (!isnull())

{ while (!eolist()) next(); // встали в конец списка

elements[predcur].next=top_free; top_free=head;

// кладем весь список в стек

head=NULL; predcur=NULL;

// cur==NULL, т.к. достигнут конец списка

}

}

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