lektsii_OP / T15
.pdf
Стеки
Cтеком називають однозв’язний список, елементи якого можна долучати і вилучати лише з одного кінця – його вершини. При цьому вершиною є останній внесений у стек елемент. Такий принцип обробки елементів формулюється як «останнім прийшов - першим пішов» і позначається абревіатурою LIFO (від англ. Last Input First
Output).
Щоб зрозуміти, що таке стек, слід згадати принцип устрою дитячої піраміди: з її стрижня можна зняти тільки верхнє кільце і надіти нове кільце, тільки поверх усіх інших кілець. Для того, щоб зняти, приміром, третє зверху кільце, треба спочатку зняти (тобто вилучити зі стека) два верхніх кільця.
Для роботи зі стеком слід зберігати покажчик на його вершину (наприклад, head). Кожен елемент стеку повинен мати поле-покажчик (наприклад, рrev) на попередній елемент (який був включений у стек перед поточним). Перший елемент, занесений у стек, вважається кінцем стеку (містить у адресному полі порожній покажчик NULL). Структурна організація стека наведена на рис. 8.
Inf |
Inf |
. . . |
|
|
|
NULL |
Prev |
|
|
head |
Inf |
Inf |
Prev |
Prev |
Рис. 8. Структура стека Загальний алгоритм створення стеку можна представити так:
1)ініціалізувати покажчик вершини стеку, як покажчик на порожній стек (значенням NULL);
2)виділити пам'ять для нового елемента;
3)ввести змістовні дані нового елемента;
4)якщо стек порожній, ініціалізувати адресне поле нового елемента ознакою кінця стеку (NULL); якщо стек не порожній, встановити посилання на вершину стеку у вказівну частину нового елемента;
5)зробити новий елемент вершиною стеку;
6)якщо потрібно ввести ще один елемент, перейти на п.2, інакше – кінець.
Наприклад,
TList *head= NULL; char ch;
do
{current = new TList; cin >> current -> data;
if (head == NULL) current ->prev = NULL; else current -> prev = head;
head = current;
cout << "Continue? (Y/N)";
cin>>ch;
}
31
while (ch == 'y'||ch=='Y');
Тут current - покажчик на новий створюваний елемент.
Основними операціями над стеком є характерні для однозв’язних списків операції додавання елемента у стек (часто цю операцію називають push - "заштовхнути елемент" у стек) і вилучення елемента із стеку (часто цю операцію називають pop - "виштовхнути елемент" із стеку). Однак, у випадку стека ці операції мають тільки по одній реалізації: додавання елемента тільки у вершину стека і вилучення елемента тільки із вершини стека.
Вважаємо, що на новий створюваний елемент посилається покажчик current. Тоді алгоритм додавання елемента (push) у стек може бути наступним:
1)виділити пам'ять для нового елемента;
2)ввести змістовні дані нового елемента;
3)вставити новий елемент у вершину стеку (встановити у його адресному полі посилання на існуючу вершину стеку);
4)зробити новий елемент вершиною стеку.
Наприклад,
TList *current = new TList; |
// виділити пам’ять під новий елемент |
cin >> current -> data; |
// ввести змістовні дані нового елемента |
current -> prev = head; |
// зробити новий елемент вершиною стеку |
head = current; |
// перевизначити покажчик на вершини стеку |
Якщо вважати, що current – допоміжний покажчик, то алгоритм видалення елемента (pop) із непорожнього стеку може бути наступним:
1)створити копію покажчика на вершину стеку;
2)зробити вершиною стеку його попередній елемент;
3)звільнити пам’ять із-під колишньої вершини стеку.
Наприклад,
current = head; head = head -> prev; delete current;
//копія покажчика на вершини стеку
//зробити вершиною стеку попередній елемент //видалити попередню вершину стеку
При намаганні "виштовхнути" елемент з вже пустого стеку, відбувається ситуація "незаповнення" стеку (stack underflow).
Зстеком можна виконувати такі основні дії:
зчитування даних із стеку (зчитування чергового вузла із стеку);
перевірка стеку на наявність елементів (чи є вершина у стеці);
знищення стеку (знищення вершини, якщо така є) тощо.
Кожна з цих операцій з стеком виконується за фіксований час O(1) і не залежить від розміру стеку.
Приклад створення стеку і його перегляду.
struct |
TStack |
// елемент стеку |
{ int |
info; |
// інформаційне поле |
32
TStack *next; // покажчик на наступний елемент в стеці
};
TStack *head = NULL; // покажчик на вершину стека (спочатку NULL, оскільки стек порожній)
void Push(int); |
// додавання елемента в стек |
char Pop(int &); |
// читання елемента із стеку |
// ============== головна функція =====================
int main()
{int n= 5;
for (int i=1; i<n; i++) Push(i*2);
int а=0;
for (int i=0; i<n+1; i++)
{if (Pop(a)) cout << а << endl; else cout << "Stack is empty";
}
system("pause");
}
// ============== додавання елемента в стек =========================
void Push(int value) |
// value - значення, яке потрібно помістити в стек |
{ TStack *current = new TStack ; // створення нового елемента стека |
|
current ->info = value; |
// заповнення його інформаційного поля |
current ->next = head; |
// наступним стає елемент, який був у вершині стека |
head = current; |
// як вершина стека встановлюється новий елемент |
}
// ============== читання елемента із стеку =====================
char |
Pop(int &value) |
// в value поміщається прочитане значення |
{ if (head) |
// перевірка, чи не порожній стек |
|
{ value = head ->info; |
// читання інформаційного поля з елементу у вершині стека |
|
|
TStack *temp = head; |
// запам'ятовування покажчика на перший елемент в стеку |
|
head = head -> next; |
// встановлення як вершини стека елемента, який був наступним |
|
delete temp; |
// видалення елемента, який був вершиною стека |
|
return 1; |
// читання успішне |
} |
|
|
else return 0; |
// спроба читання з порожнього стека |
|
} |
|
|
Слід зазначити, що саме на принципі LIFO грунтується механізм рекурсії, коли адреса кожного наступного виклику рекурсивної функції записується у стеці для коректної передачі управління при її завершенні. Також по принципу LIFO організована і сама стекова пам'ять, яка використовується, у тому числі, і для зберігання викликів вкладених функцій.
Двозв’язні лінійні списки
Обробка однозв’язного списку не завжди зручна. Інколи виникає потреба проходити за списком в обох напрямках і “знати” не лише наступний елемент, а й попередній. Таку можливість надає лінійний двозв'язний список (або дек - від англ. double-ended queue, deq), кожен елемент якого містить два покажчики: на наступний і на
33
попередній елементи списку. Такий список можна обробляти у двох напрямках: від першого елемента до останнього і від останнього до першого.
Схематично зобразити цей список можна так:
head
NULL |
Inf |
Next |
Prev |
Prev |
|
. . . |
Inf |
Inf |
Next |
Next |
tail |
Prev |
Inf |
NULL |
Якщо поле Рrev елемента містить порожній покажчик (NULL), то в елемента немає попередника і він є головою списку. Якщо поле Next елемента містить порожній покажчик (NULL), то в нього немає наступника і такий елемент є хвостом списку.
Наявність двох покажчиків в кожному елементі ускладнює список і приводить до додаткових витрат пам'яті, але в той же час забезпечує ефективніше виконання деяких операцій над деком: такими списками легше маніпулювати, тому що вони дозволяють проходження по списку в обох напрямах, що є необхідною умовою функціонування деяких алгоритмів.
Формат оголошення елемента двозв'язного списку у С/С++:
struct тип
{ інформаційне_поле_1;
…
інформаційне_поле_n; адресне_поле_1; адресне_поле_2;
};
Наприклад,
struct TNode |
|
{ int data; |
// інформаційне поле |
TNode *next; |
// поле-покажчик на наступний елемент |
TNode *prev; |
// поле-покажчик на попередній елемент |
}; |
|
З двозв’язними лінійними списками виконуються такі ж дії, як і з однозв’язними: вставка, видалення, пошук, сортування елементів тощо.
Розглянемо варіант реалізації алгоритму створення для наступного двозв’язного списку:
struct TList
{int data; TList *next; TList *prev;
};
Вважаємо, що на новий створюваний елемент посилається покажчик current, а на останній елемент (хвіст) списку посилається покажчик tail:
34
TList *tail, *current;
Тоді реалізація алгоритму створення двозв’язного списку може бути наступною:
struct TList
{int data; TList *next; TList *prev;
}*current, *head, *last; char ch;
*head=NULL; do
{current = new TList; cin >> current ->data; current ->next = NULL; if (head == NULL)
{head = current; current ->prev = NULL;
}
else
{ last ->next = current; current ->prev = last;
}
last = current;
cout << "Continue? (Y/N)"; cin>>ch;
}
while ((ch!='n')&&(ch!='N'));
Кільцеві списки
Різновидом розглянутих видів лінійних зв'язних списків є кільцеві списки, у яких перший та останній елементи зв'язані між собою. Кільцевий список може бути організований на основі як однозв’язного, так і двозв'язного списків.
В кільцевому однозв’язному списку поле Next хвоста списка вказує на голову списка (рис. 11); у кільцевому двозв'язному списку поле Рrev голови списка вказує на хвіст списка, а поле Next хвоста списка вказує на голову списка (рис.12).
head
Data |
|
Data |
|
Next |
Next |
. . .
Data
Next |
Data |
Next |
Рис.11. Структура кільцевого однозв'язного списку
35
|
Prev |
head |
Inf |
|
|
|
Next |
Prev |
Prev |
Prev |
|
. . . |
Inf |
Inf |
Inf |
|
Next |
Next |
Next |
Рис.12. Структура кільцевого двозв'язного списку
З кільцевими списками виконуються такі ж дії, як і з однозв’язними та двозв’язними списками. Алгоритми виконання цих дій схожі на обробку зазначених списків.
Зв'язані списки мають серію переваг порівняно з масивами.
Принциповою перевагою перед масивом є структурна гнучкість списків: порядок елементів зв'язного списку може не збігатися з порядком розташування елементів даних у пам'яті комп'ютера, а порядок обходу списку завжди явно задається його внутрішніми зв'язками. Також в списках набагато ефективніше (за час О(1), тобто незалежно від кількості елементів) виконуються процедури додавання та вилучення елементів.
Натомість, масиви набагато кращі в операціях, які потребують безпосереднього доступу до кожного елементу, що у випадку із зв'язаними списками неможливо та потребує послідовного перебору усіх елементів, які передують даному.
STL-списки
Стандартна бібліотека шаблонів STL, окрім класів-контейнерів для обробки масивів, рядків, множин, містить також класи для роботи з списками. Ці контейнери відносяться до виду послідовних контейнерів (від англ. sequence containers), оскільки оперують послідовностями, які в термінології STL, по суті, є лінійними списками об'єктів.
Для роботи із списками служать такі STL-контейнери:
|
list |
реалізує концепцію лінійного двозв’язного списку |
|
|
stack |
реалізує концепцію стека |
|
|
queue |
реалізує концепцію черги |
|
|
deque |
реалізує концепцію двосторонньої черги, тобто дозволяє |
|
|
|
вставляти і видаляти елементи з обох кінців |
|
|
priority_queue |
реалізує концепцію черги з пріоритетами, тобто черги, |
|
|
|
елементи якої відсортовані за пріоритетом (найбільший |
|
|
|
елемент завжди стоїть на першому місці) |
|
|
forward_list3 |
реалізує концепцію черги з пріоритетом, організованій так, |
|
|
|
що найбільший елемент завжди стоїть на першому місці |
|
|
|
|
|
3 Починаючи з C++11 |
|
|
|
|
|
36 |
|
При цьому слід зазначити, що класи stack (стек), queue (черга) і priority_queue (черга з пріоритетом) не є окремими контейнерами. Вони використовують (тобто адаптують) один з послідовних контейнерів, що містить їх елементи, і тому називаються адаптерами контейнерів. Однак, з точки зору програмістів, адаптери контейнерів виглядають і поводяться подібно іншим контейнерам.
Для використання зазначених контейнерів необхідно підключити однойменний заголовний файл, наприклад, <list> - для лінійних списків.
Створюється будь-який із STL-списків за допомогою одного із перевантажених конструкторів:
–конструктора за замовчуванням (для створення порожнього контейнера),
–конструктора копіювання,
–конструктора з параметром, який задає розмір контейнера;
–конструктора з параметрами, які задають розмір і наповнення контейнера.
Наприклад, лінійний список можна створити так:
list <int> myList; |
//порожній список |
list <int> myList_2(7); |
//цілочисельний список з 7-х елементів |
list <int> myList_3(9, 1); //цілочисельний список з 9-х елементів, ініціалізованих значенням 1
Кожен з контейнерів підтримує набір однотипних операцій, достатній для того, щоб на його основі можна було писати узагальнені алгоритми для роботи з елементами. Наприклад, функція size() видає поточний розмір контейнера (кількість елементів), функція empty() – дозволяє перевірити чи порожній список. Цими функціями можна користуватися однаково незалежно від того, в якому конкретно контейнері знаходяться елементи.
Для списків не підтримується операція "звертання за індексом" ([]). Тому єдиний спосіб переглянути елементи полягає у використанні ітераторів (спеціальних об'єктів, призначених для перебору елементів контейнера). Для обробки усіх видів списків можуть використовуватися прямі ітератори, доступ до яких здійснюється за допомогою методів begin() і end() - отримання прямого ітератора відповідно на перший елемент списку та на його кінець (позицію, наступну за останнім елементом).
Незважаючи на спільність деяких базових дій, кожен контейнер все ж реалізує концепції різних структур даних. Різновиди контейнерів розрізняються між собою і внутрішнім устроєм, і ефективністю різних операцій, і набором додаткових, характерних тільки для них, способів доступу до елементів.
Розглянемо реалізацію основних контейнерів для обробки списків більш детально.
Контейнер <list>. Контейнерний шаблон <list> реалізує концепцію двозв’язного (двонаправленого) лінійного списку. Тому його ще відносять до підвиду оборотних (reversible) контейнерів, які підтримують двонаправлений доступ до своїх елементів. Щоб скористатися даним контейнером, необхідно підключити заголовний файл
<list>.
37
Доступ до елементів list-списку, як різновиду послідовних контейнерів, може бути тільки послідовним, а як різновиду оборотних контейнерів, двонаправленим (з початку або з кінця списку). Для організації доступу до елементів використовуються ітератори, які визначаються для списку <list> наступним чином:
list <int>:: iterator it;
Оскільки даний контейнер підтримує двонаправлений доступ до елементів, то він, окрім прямих ітераторів, може використовувати зворотні ітератори, доступ до яких здійснюється за допомогою методів rbegin() і rend() - отримання зворотнього ітератора відповідно на останній елемент списку та на його початок (позицію, що передує першому елементу).
Переміщення по списку здійснюється за допомогою ітераторів. Наприклад, знаходження суми елементів списку mylist:
list<int> myList(5,3); |
//3 3 3 3 3 |
int sum=0; |
|
for (list<int>::iterator it= mylist.begin(); it!= mylist.end(); it++)
{ |
cout<<*it<<' '; |
|
|
sum += *it; |
|
} |
|
|
cout << sum << endl; |
//15 |
|
Засобами класу <list> можна організувати доступ до першого і до останнього елемента списку. Для цього служать функції front() і back() відповідно. Також можна здійснити переміщення ітератора на задану кількість позицій. Для цього служить функція advance(it, n), яка здійснює переміщення ітератора it на n позицій.
Вставка елемента у список <list> може виконуватися наступними функціями:
push_back() |
додавання елемента в кінець списку |
|
|
push_front() |
додавання елемента на початок списку |
insert() |
вставка елемента у вказану позицію списку |
Наприклад,
list <int> myList; |
// порожній список |
srand(time(NULL)); |
|
for (int i=0; i<15; i++) |
|
myList .push_back(rand()%20); |
// додавання в список згенерованого елемента |
Деякі із функцій є перевантаженими, тобто такими, що виконують схожу дію, але допускають використання різних параметрів. Наприклад, функція insert() має наступні реалізації:
insert(it, val) |
Вставка елемента val перед елементом, специфікованим ітератором it |
insert(it, n, val) |
Вставка n копій елементів val перед елементом, специфікованим |
|
ітератором it |
insert(it, b, e) |
Вставка елементів із діапазону [b, e) перед елементом, специфікованим |
|
ітератором it |
insert(it, іlist)4 |
Вставка елементів із списка ініціалізації іlist перед елементом, |
4 Починаючи з С++11
38
специфікованим ітератором it
Наприклад,
list <int> myList; |
// порожній список |
list<int>::iterator it; |
// ітератор списка |
for (int i=1; i<=5; ++i) |
// 1 2 3 4 5 |
mylist.push_back(i); |
|
it = mylist.begin(); |
// установка ітератора на початок списку |
++it; |
// переміщення ітератора на другий елемент |
mylist.insert (it,2,20); |
// 1 20 20 2 3 4 5 |
Для видалення елементів із списку <list> служать наступні функції:
pop_front() |
Видалення першого елемента списку |
pop_back() |
Видалення останнього елемента списку |
clear() |
Видалення всіх елементів списку |
erase() |
Видалення із списка вказаних елементів |
remove() |
Видалення всіх елементів із вказаним значенням |
remove_if() |
Видалення всіх елементів, що задовольняють заданій умові (предикату) |
unique() |
Видалення послідовно повторюваних елементів |
Наприклад,
list<int> myList;
for (int i=0; i<15; i++) myList.push_back(rand()%10);
cout << "list1: ";
copy(myList.begin(), myList.end(), ostream_iterator<int>(cout," "));
myList.remove(1); // видалення усіх 1 cout << "\nlist2: ";
copy(myList.begin(), myList.end(), ostream_iterator<int>(cout," ")); cout << "\n";
Відеокопія результата:
Частина із цих функцій також є перевантаженими, зокрема, функція erase(). Їх можливі реалізації:
erase(it) |
Видалення елемента, на який вказує ітератор it. Повертає ітератор на елемент, |
|
що знаходиться після видаленого |
erase (b, e) |
Видалення елементів із діапазону [b, e) |
Наприклад,
list<int> myList;
for (int i=0; i<10; i++) // 0 1 2 3 4 5 6 7 8 9 myList .push_back(i);
39
myList .erase(myList .begin()); |
// 1 2 3 4 5 6 7 8 9 |
list<int>:: iterator range_begin = myList .begin(); |
// ітератор початку діапазону |
list<int>:: iterator range_end = myList .begin(); |
// ітератор кінця діапазону |
advance(range_begin, 2); |
// приріст ітератора на 2 |
advance(range_end, 5); |
// приріст ітератора на 5 |
myList .erase(range_begin, range_end); |
// 1 2 6 7 8 9 |
Для класу list також визначені такі специфічні функції:
merge() |
об'єднання двох відсортованих списків (з видаленням елементів із списка- |
|
параметра) |
|
|
sort() |
сортування елементів списку за збільшенням |
splice() |
переміщення елементів із іншого списку |
reverse() |
зміна порядку елементів списку на зворотній |
Наприклад,
srand(time(NULL));
// ----------------- генерація списка1 ---------------------------
list<int> list1;
for (int i=0; i<5; i++) list1.push_back(rand()%10);
cout << "list1: ";
copy(list1.begin(), list1.end(), ostream_iterator<int>(cout," "));
// ----------------- |
генерація списка2 --------------------------- |
list<int> list2; |
|
for (int i=0; i<5; i++) list2.push_back(rand()%10);
cout << "\nlist2: ";
copy(list2.begin(), list2.end(), ostream_iterator<int>(cout," ")); // ----------------- сортування списка1 ---------------------------
list1.sort();
cout << "\nlist1: ";
copy(list1.begin(), list1.end(), ostream_iterator<int>(cout," ")); // ----------------- сортування списка2 ---------------------------
list2.sort();
cout << "\nlist2: ";
copy(list2.begin(), list2.end(), ostream_iterator<int>(cout," ")); // ----------------- об’єднання списків ---------------------------
list1.merge(list2); cout << "\nmerged: ";
copy(list1.begin(), list1.end(), ostream_iterator<int>(cout," "));
Відеокопія результата:
40
