C++

Власні структури даних: однозв'язаний список

Проєктування та реалізація однозв'язаного списку з нуля: вузли, покажчики, динамічне виділення пам'яті, операції вставки, видалення, пошуку за O(1) або O(n), ітеративний обхід

Власні структури даних: однозв'язаний список

🎯 Мета лекції

  • Опанувати концепцію вузлової структури даних (node-based data structure): об'єкти, з'єднані покажчиками, на відміну від масивів з суцільним блоком пам'яті.
  • Спроєктувати та реалізувати однозв'язаний список (Singly Linked List): лінійна структура, де кожен вузол містить дані та покажчик на наступний вузол.
  • Дослідити асимптотичну складність операцій: вставка на початок — O(1), пошук елемента — O(n), видалення за значенням — O(n).
  • Реалізувати ключові операції: insertFront, insertBack, deleteValue, search, display, reverse.
  • Зрозуміти компроміси між масивами та зв'язаними списками: довільний доступ vs динамічна вставка, локальність кешу vs гнучкість пам'яті.

🔑 Ключові терміни

  • Node (Вузол): базовий будівельний блок зв'язаного списку — структура, що зберігає дані та покажчик(и) на інші вузли.
  • Head (Голова списку): покажчик на перший вузол у списку; якщо список порожній, head == nullptr.
  • Tail (Хвіст списку): покажчик на останній вузол; у однозв'язаному списку часто не зберігається (обчислюється за потреби).
  • Traversal (Обхід): процес послідовного проходження по всіх вузлах списку від голови до кінця.
  • Dynamic Allocation (Динамічне виділення): кожен вузол створюється окремим викликом new, на відміну від масиву з одним суцільним блоком.

Hook: коли масив стає кайданами — проблема вставки

Уявіть додаток для управління списком контактів у телефоні. Користувач зберігає 1000 контактів у масиві, відсортованому за алфавітом. Раптом він додає новий контакт з ім'ям "Андрій" — перше за алфавітом. Що має зробити програма?

У масиві це катастрофічна операція:

  1. Виділити новий масив на 1001 елемент (старий переповнений).
  2. Скопіювати перший елемент на позицію [1].
  3. Скопіювати другий елемент на позицію [2].
  4. ...
  5. Скопіювати 1000-й елемент на позицію [1000].
  6. Вставити "Андрій" на позицію [0].
  7. Звільнити старий масив.

Складність: O(n) копіювань + реалокація. Для 1000 елементів — мінімум 1000 операцій присвоєння. Для 1 мільйона — 1 мільйон. Це неприйнятно для інтерактивних додатків, де вставка має відбуватися миттєво.

Тепер розглянемо альтернативний підхід — однозв'язаний список (Singly Linked List). Контакти зберігаються у вузлах (nodes), кожен з яких містить:

  • Дані: ім'я контакту, телефон.
  • Покажчик next: адреса наступного контакту в ланцюжку.

Щоб вставити "Андрій" на початок:

  1. Створити новий вузол з даними "Андрій".
  2. Встановити його next на поточну голову списку.
  3. Оновити голову списку на новий вузол.

Складність: O(1) — три операції, незалежно від розміру списку. 1000 елементів чи 1 мільйон — різниці немає. Це фундаментальна перевага зв'язаних списків над масивами: вставка та видалення на початку завжди миттєві.

Звичайно, зв'язані списки не є універсальним рішенням — вони мають свої недоліки (про це у розділі "Порівняння з масивами"). Але вони демонструють ключовий принцип проєктування структур даних: немає єдиної найкращої структури — є компроміси (trade-offs), і завдання інженера — обрати структуру, оптимальну для конкретної задачі.

У попередній лекції (стаття 89) ми реалізували стек та чергу на основі масивів — структур з фіксованою (або переалокованою) ємністю. У цій лекції ми опануємо зв'язані списки — структури з динамічною ємністю, де пам'ять виділяється для кожного елемента окремо, а елементи з'єднуються покажчиками. Це відкриє шлях до дерев, графів та інших складних ієрархічних структур.


Концепція вузлової структури даних

Від масивів до покажчиків: зміна парадигми

Масив (array) — це суцільний блок пам'яті, де елементи розташовані послідовно за адресами. Індекс i перетворюється на адресу через арифметику: address = base + i * sizeof(element). Це дає довільний доступ (random access) за O(1): щоб дістатися до 1000-го елемента, не потрібно проходити через перші 999.

int arr[5] = {10, 20, 30, 40, 50};
int value = arr[2];  // O(1): пряме обчислення адреси
Масив у пам'яті
Hex Dump / ASCII
0x00401000
0A000000140000001E0000002800000032000000
Offset: 20 bytes
Big Endian

Зв'язаний список (linked list) — це розпорошена структура: елементи розкидані по купі (heap), кожен елемент зберігає покажчик на місце розташування наступного. Немає індексів — щоб дістатися до 1000-го елемента, потрібно пройти через перші 999 вузлів (traversal). Доступ — O(n).

struct Node
{
    int data;
    Node* next;  // Покажчик на наступний вузол
};

Node* head = new Node{10, nullptr};
head->next = new Node{20, nullptr};
head->next->next = new Node{30, nullptr};
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

rectangle "head" as head #DBEAFE

package "Вузол 1" #DCFCE7 {
    rectangle "data: 10" as d1
    rectangle "next" as n1
}

package "Вузол 2" #DCFCE7 {
    rectangle "data: 20" as d2
    rectangle "next" as n2
}

package "Вузол 3" #DCFCE7 {
    rectangle "data: 30" as d3
    rectangle "next: nullptr" as n3 #F1F5F9
}

head -right-> d1
n1 -right-> d2
n2 -right-> d3

note right of n3 #FEF3C7
  Останній вузол:
  next == nullptr
  (кінець списку)
end note

@enduml

Ключові відмінності:

ВластивістьМасивОднозв'язаний список
Пам'ятьСуцільний блокРозпорошені вузли в купі
Доступ за індексомO(1) — пряме обчисленняO(n) — обхід від голови
Вставка на початокO(n) — зсув всіх елементівO(1) — зміна голови
Вставка в кінецьO(1) якщо є місце, O(n) при реалокаціїO(n) без tail-покажчика, O(1) з tail
Видалення за значеннямO(n) — пошук + зсувO(n) — пошук + зміна next
Використання пам'ятіКомпактне: лише даніOverhead: дані + покажчик(и) на кожен елемент
Локальність кешуВідмінна (послідовні адреси)Погана (випадкові адреси в купі)
Зв'язані списки оптимальні, коли:
  • Часті вставки/видалення на початку чи в середині (зміна покажчиків замість копіювання).
  • Невідомий заздалегідь розмір (не потрібна реалокація, як у std::vector).
  • Порядок елементів критичний, а довільний доступ — рідкісний.
Масиви оптимальні, коли:
  • Читання за індексом — основна операція (ігрове поле, пікселі зображення).
  • Розмір фіксований чи рідко змінюється.
  • Локальність кешу критична (числові обчислення, графіка).

Анатомія вузла: структура Node

Вузол (node) — це мінімальна одиниця зв'язаного списку. У найпростішому варіанті (однозв'язаний список для цілих чисел) він містить два поля:

struct Node
{
    int data;      // Корисне навантаження (payload)
    Node* next;    // Адреса наступного вузла (або nullptr)
};

Чому struct, а не class? У C++ різниця між struct і class — лише доступ за замовчуванням (public vs private, стаття 51). Для простих вузлів без методів та інваріантів прийнято використовувати struct з публічними полями. Для складніших вузлів (з конструкторами, валідацією) використовується class.

Покажчик next: Зберігає адресу наступного вузла в ланцюжку. Якщо вузол останній, next == nullptr — це сигнальне значення (sentinel), що позначає кінець списку.

Стан трьох вузлів у пам'яті
Filter
NameTypeValue
node1Node{...}
dataint42
nextNode*0x00007F8A2C004020
node2Node{...}
dataint17
nextNode*0x00007F8A2C004040
node3Node{...}
dataint99
nextNode*0x0000000000000000 (nullptr)
Running
Process: 12842

Створення вузлів: динамічне виділення пам'яті

Кожен вузол існує у купі (heap) та створюється оператором new (стаття 19). Це відрізняється від масиву, де весь блок виділяється одним викликом new[].

// Створення окремого вузла
Node* firstNode = new Node;
firstNode->data = 10;
firstNode->next = nullptr;

// Або через uniform initialization (C++11)
Node* secondNode = new Node{20, nullptr};

// Або через designated initializers (C++20)
Node* thirdNode = new Node{.data = 30, .next = nullptr};

Важливий момент: Кожен new повертає унікальну адресу в купі. Вузли не розташовані послідовно — між ними можуть бути інші об'єкти. Саме тому потрібен покажчик next — щоб знайти наступний вузол, потрібно прочитати адресу з поточного.

Програміст вручну відповідає за звільнення кожного вузла через delete. Забутий delete — це витік пам'яті (memory leak). У класі LinkedList (який ми реалізуємо далі) деструктор має обійти весь список та видалити кожен вузол. Це фундаментальна відмінність від масиву, де достатньо одного delete[].

Проєктування класу LinkedList: інтерфейс та інваріанти

Інваріанти однозв'язаного списку

Інваріант (invariant) — умова, що завжди має бути істинною для коректного стану об'єкта. Для однозв'язаного списку:

  1. Якщо список порожній: head == nullptr.
  2. Якщо список не порожній: head вказує на перший вузол, а останній вузол має next == nullptr.
  3. Ланцюжок покажчиків неперервний: можна дістатися від head до будь-якого вузла, слідуючи за next.
  4. Немає циклів (для базового списку): жоден вузол не вказує на попередній (інакше обхід зависне).

Оголошення класу LinkedList

// LinkedList.h
#ifndef LINKEDLIST_H
#define LINKEDLIST_H

#include <iostream>

// Структура вузла (вкладена в клас для інкапсуляції)
struct Node
{
    int data;
    Node* next;
    
    // Конструктор для зручності
    Node(int value, Node* nextNode = nullptr)
        : data(value), next(nextNode)
    {
    }
};

class LinkedList
{
private:
    Node* head;  // Покажчик на перший вузол (nullptr для порожнього списку)

public:
    // Конструктор: створює порожній список
    LinkedList();
    
    // Деструктор: видаляє всі вузли (RAII)
    ~LinkedList();
    
    // Заборона копіювання (для простоти)
    LinkedList(const LinkedList&) = delete;
    LinkedList& operator=(const LinkedList&) = delete;
    
    // Перевірити, чи список порожній
    bool isEmpty() const;
    
    // Отримати кількість елементів (O(n) — потребує обходу)
    int size() const;
    
    // Вставити елемент на початок (O(1))
    void insertFront(int value);
    
    // Вставити елемент в кінець (O(n) без tail-покажчика)
    void insertBack(int value);
    
    // Видалити перший вузол (O(1))
    void deleteFront();
    
    // Видалити перше входження значення (O(n))
    void deleteValue(int value);
    
    // Пошук елемента (O(n))
    bool search(int value) const;
    
    // Вивести всі елементи
    void display() const;
    
    // Обернути список (змінити напрямок покажчиків)
    void reverse();
    
    // Очистити список (видалити всі вузли)
    void clear();
};

#endif
Структура Node оголошена всередині файлу (можна навіть зробити її приватною вкладеною структурою в LinkedList), оскільки це деталь реалізації — клієнтський код не повинен безпосередньо створювати вузли. Вся робота з вузлами інкапсульована в методах LinkedList.

Реалізація конструктора, деструктора та допоміжних методів

// LinkedList.cpp
#include "LinkedList.h"

// Конструктор: ініціалізує порожній список
LinkedList::LinkedList()
    : head(nullptr)  // Голова вказує на nullptr — список порожній
{
}

// Деструктор: видаляє всі вузли (ідіома RAII)
LinkedList::~LinkedList()
{
    clear();  // Делегуємо логіку очищення методу clear()
}

// Перевірити, чи список порожній
bool LinkedList::isEmpty() const
{
    return head == nullptr;
}

// Отримати кількість елементів
int LinkedList::size() const
{
    int count = 0;
    Node* current = head;
    
    // Обходимо список, рахуючи вузли
    while (current != nullptr)
    {
        ++count;
        current = current->next;
    }
    
    return count;
}

// Очистити список (видалити всі вузли)
void LinkedList::clear()
{
    Node* current = head;
    
    while (current != nullptr)
    {
        Node* nextNode = current->next;  // Зберігаємо адресу наступного
        delete current;                  // Видаляємо поточний
        current = nextNode;              // Переходимо до наступного
    }
    
    head = nullptr;  // Список тепер порожній
}

Ключовий момент у деструкторі: Не можна просто викликати delete head — це видалить лише перший вузол, решта залишаться у пам'яті (витік). Потрібно обійти весь список, зберігаючи next перед видаленням поточного вузла (інакше після delete current доступ до current->next — Undefined Behavior).


Реалізація операцій вставки: insertFront та insertBack

Операція insertFront: вставка на початок (O(1))

Вставка на початок — найефективніша операція зв'язаного списку. Алгоритм:

  1. Створити новий вузол з даними value.
  2. Встановити новийВузол->next = head (новий вузол тепер вказує на старий перший).
  3. Оновити head = новийВузол (новий вузол стає головою).
void LinkedList::insertFront(int value)
{
    // Створюємо новий вузол, його next вказує на поточну голову
    Node* newNode = new Node(value, head);
    
    // Оновлюємо голову — тепер це новий вузол
    head = newNode;
    
    // Складність: O(1) — три операції, незалежно від розміру списку
}
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

title Вставка 15 на початок списку [10 → 20 → 30]

rectangle "Крок 1: Створення нового вузла" as step1 #F1F5F9

rectangle "head" as head1 #DBEAFE
package "Новий вузол" #FEF3C7 {
    rectangle "data: 15" as new1
    rectangle "next: ???" as nextnew1
}
package "Вузол 10" #DCFCE7 {
    rectangle "data: 10" as d1_1
}

head1 -right-> d1_1
note right of nextnew1 : newNode->next = head

rectangle "Крок 2: Підключення до списку" as step2 #F1F5F9

rectangle "head" as head2 #FEF3C7
package "Новий вузол (15)" #FEF3C7 {
    rectangle "data: 15" as new2
    rectangle "next" as nextnew2
}
package "Вузол 10" #DCFCE7 {
    rectangle "data: 10" as d1_2
    rectangle "next" as n1_2
}
package "Вузол 20" #E2E8F0 {
    rectangle "data: 20" as d2
}

head2 -right-> new2 : head = newNode
nextnew2 -right-> d1_2
n1_2 -right-> d2

note bottom of head2 #DCFCE7
  Результат: [15 → 10 → 20 → 30]
  Складність: O(1)
end note

step1 -[hidden]down-> head1
step2 -[hidden]down-> head2

@enduml

Чому O(1)? Не потрібно обходити список — працюємо лише з головою. Навіть якщо у списку мільйон елементів, операція вимагає однакову кількість дій.

Операція insertBack: вставка в кінець (O(n))

Вставка в кінець без окремого tail-покажчика потребує обходу всього списку для знаходження останнього вузла.

void LinkedList::insertBack(int value)
{
    Node* newNode = new Node(value, nullptr);
    
    // Якщо список порожній — новий вузол стає головою
    if (head == nullptr)
    {
        head = newNode;
        return;
    }
    
    // Інакше — знаходимо останній вузол (той, чий next == nullptr)
    Node* current = head;
    while (current->next != nullptr)
    {
        current = current->next;
    }
    
    // current тепер вказує на останній вузол
    current->next = newNode;
    
    // Складність: O(n) — обхід від голови до кінця
}

Оптимізація через tail-покажчик: Якщо додати до класу поле Node* tail, що завжди вказує на останній вузол, вставка в кінець стає O(1). Але це ускладнює підтримку інваріантів — кожна операція (видалення, очищення) має оновлювати tail. Для простоти базової реалізації ми не використовуємо tail.

Якщо більшість операцій — це вставка в кінець (наприклад, черга на основі зв'язаного списку), додавання tail виправдане. Для загальних випадків, де вставка рівномірно розподілена, overhead підтримки tail може не окупитися.

Реалізація операцій видалення: deleteFront та deleteValue

Операція deleteFront: видалення першого вузла (O(1))

Видалення голови списку — проста операція:

  1. Перевірити, чи список не порожній.
  2. Зберегти адресу другого вузла (head->next).
  3. Видалити перший вузол (delete head).
  4. Оновити голову на другий вузол.
void LinkedList::deleteFront()
{
    // Передумова: список не повинен бути порожнім
    if (isEmpty())
    {
        std::cerr << "Помилка: спроба видалення з порожнього списку\n";
        return;  // Або throw std::out_of_range
    }
    
    // Зберігаємо адресу другого вузла
    Node* temp = head->next;
    
    // Видаляємо перший вузол
    delete head;
    
    // Оновлюємо голову
    head = temp;
    
    // Складність: O(1)
}
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

title Видалення голови зі списку [10 → 20 → 30]

rectangle "Крок 1: Збереження другого вузла" as step1 #F1F5F9

rectangle "head" as head1 #DBEAFE
package "Вузол 10 (видаляється)" #FEE2E2 {
    rectangle "data: 10" as d1_1
    rectangle "next" as n1_1
}
package "Вузол 20" #DCFCE7 {
    rectangle "data: 20" as d2_1
    rectangle "next" as n2_1
}
package "Вузол 30" #E2E8F0 {
    rectangle "data: 30" as d3_1
}

rectangle "temp" as temp #FEF3C7

head1 -right-> d1_1
n1_1 -right-> d2_1
n2_1 -right-> d3_1
temp -down-> d2_1 : temp = head->next

rectangle "Крок 2: Після delete head" as step2 #F1F5F9

rectangle "head" as head2 #FEF3C7
package "Вузол 20" #DCFCE7 {
    rectangle "data: 20" as d2_2
    rectangle "next" as n2_2
}
package "Вузол 30" #E2E8F0 {
    rectangle "data: 30" as d3_2
}

head2 -right-> d2_2 : head = temp
n2_2 -right-> d3_2

note bottom of head2 #DCFCE7
  Результат: [20 → 30]
  Вузол 10 видалено з купи
end note

step1 -[hidden]down-> head1
step2 -[hidden]down-> head2

@enduml

Важливе попередження: Якщо не зберегти head->next перед delete head, доступ до нього після видалення — це використання вже звільненої пам'яті (use-after-free), яке призводить до Undefined Behavior.

Операція deleteValue: видалення за значенням (O(n))

Видалення вузла з конкретним значенням потребує:

  1. Пошук вузла з цим значенням (обхід списку).
  2. Зміна покажчика попереднього вузла, щоб він пропустив видаляємий.
  3. Видалення знайденого вузла.

Особливий випадок: Якщо видаляється голова, алгоритм спрощується (аналогічно deleteFront).

void LinkedList::deleteValue(int value)
{
    // Особливий випадок: видаляється голова
    if (head != nullptr && head->data == value)
    {
        Node* temp = head;
        head = head->next;
        delete temp;
        return;
    }
    
    // Загальний випадок: шукаємо вузол з value, зберігаючи попередній
    Node* current = head;
    Node* previous = nullptr;
    
    while (current != nullptr && current->data != value)
    {
        previous = current;
        current = current->next;
    }
    
    // Якщо current == nullptr — значення не знайдено
    if (current == nullptr)
    {
        std::cerr << "Значення " << value << " не знайдено у списку\n";
        return;
    }
    
    // Знайдено: previous -> current -> current->next
    // Перемикаємо: previous -> current->next
    previous->next = current->next;
    delete current;
    
    // Складність: O(n) — у гіршому випадку обходимо весь список
}
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

title Видалення вузла зі значенням 20 зі списку [10 → 20 → 30]

rectangle "Крок 1: Пошук вузла" as step1 #F1F5F9

package "Вузол 10" #DCFCE7 {
    rectangle "data: 10" as d1
    rectangle "next" as n1
}
package "Вузол 20 (видаляється)" #FEE2E2 {
    rectangle "data: 20" as d2
    rectangle "next" as n2
}
package "Вузол 30" #DCFCE7 {
    rectangle "data: 30" as d3
}

rectangle "previous" as prev #FEF3C7
rectangle "current" as curr #FEF3C7

n1 -right-> d2
n2 -right-> d3
prev -down-> d1
curr -down-> d2

rectangle "Крок 2: Перемикання покажчиків" as step2 #F1F5F9

package "Вузол 10" #DCFCE7 {
    rectangle "data: 10" as d1_2
    rectangle "next" as n1_2
}
package "Вузол 30" #DCFCE7 {
    rectangle "data: 30" as d3_2
}

n1_2 -right-> d3_2 : previous->next = current->next

note bottom of d3_2 #DCFCE7
  Результат: [10 → 30]
  Вузол 20 видалено
end note

step1 -[hidden]down-> d1
step2 -[hidden]down-> d1_2

@enduml

Складність O(n): У гіршому випадку (елемент останній або відсутній) потрібно обійти весь список. Це контрастує з std::vector, де видалення за індексом — O(1) на доступ, але O(n) на зсув елементів після видаленого.


Операція пошуку та відображення списку

Операція search: пошук елемента (O(n))

bool LinkedList::search(int value) const
{
    Node* current = head;
    
    // Обходимо список до знаходження value або кінця
    while (current != nullptr)
    {
        if (current->data == value)
        {
            return true;  // Знайдено
        }
        current = current->next;
    }
    
    return false;  // Не знайдено
}

Лінійний пошук: На відміну від відсортованого масиву (де можливий бінарний пошук за O(log n)), у зв'язаному списку немає довільного доступу — потрібен послідовний обхід. Це фундаментальне обмеження структури.

Операція display: вивід всіх елементів

void LinkedList::display() const
{
    if (isEmpty())
    {
        std::cout << "Список порожній\n";
        return;
    }
    
    Node* current = head;
    std::cout << "Список: ";
    
    while (current != nullptr)
    {
        std::cout << current->data;
        if (current->next != nullptr)
        {
            std::cout << " → ";  // Стрілка для візуалізації зв'язків
        }
        current = current->next;
    }
    
    std::cout << " → nullptr\n";
}

Патерн обходу (traversal pattern): Змінна current починається з голови, на кожній ітерації зміщується на current->next, цикл завершується, коли current == nullptr. Це стандартний ідіоматичний спосіб обходу однозв'язаного списку.


Операція reverse: обернення списку

Обернення списку — це зміна напрямку всіх покажчиків next. Список [10 → 20 → 30 → nullptr] має перетворитися на [30 → 20 → 10 → nullptr].

Алгоритм (ітеративний):

  1. Ініціалізувати три покажчики: previous = nullptr, current = head, next = nullptr.
  2. Обходити список, на кожному кроці:
    • Зберегти next = current->next (щоб не втратити решту списку).
    • Перенаправити current->next = previous (обернути зв'язок).
    • Змістити previous = current та current = next.
  3. Після завершення циклу previous вказує на новий перший вузол (колишній останній).
  4. Оновити head = previous.
void LinkedList::reverse()
{
    Node* previous = nullptr;
    Node* current = head;
    Node* next = nullptr;
    
    while (current != nullptr)
    {
        // Зберігаємо наступний вузол
        next = current->next;
        
        // Обертаємо зв'язок поточного вузла
        current->next = previous;
        
        // Зміщуємо покажчики на наступну ітерацію
        previous = current;
        current = next;
    }
    
    // previous тепер вказує на новий перший вузол (колишній останній)
    head = previous;
    
    // Складність: O(n) — один прохід по списку
}
Loading diagram...
@startuml
skinparam style plain
skinparam backgroundColor #FFFFFF

title Обернення списку [10 → 20 → 30]

rectangle "Початковий стан" as init #F1F5F9

package "Вузол 10" #DCFCE7 {
    rectangle "next" as n1
}
package "Вузол 20" #E2E8F0 {
    rectangle "next" as n2
}
package "Вузол 30" #E2E8F0 {
    rectangle "next" as n3
}

n1 -right-> n2 : →
n2 -right-> n3 : →
n3 -right-> "nullptr" : →

rectangle "Після обернення" as result #F1F5F9

package "Вузол 30" #DCFCE7 {
    rectangle "next" as n3r
}
package "Вузол 20" #E2E8F0 {
    rectangle "next" as n2r
}
package "Вузол 10" #E2E8F0 {
    rectangle "next" as n1r
}

n3r -right-> n2r : ←
n2r -right-> n1r : ←
n1r -right-> "nullptr" : ←

note right of n3r #DCFCE7
  Новий head = 30
  Напрямок покажчиків змінено
end note

init -[hidden]down-> n1
result -[hidden]down-> n3r

@enduml
Існує елегантний рекурсивний алгоритм обернення списку:
Node* reverseRecursive(Node* head)
{
    if (head == nullptr || head->next == nullptr)
    {
        return head;  // Базовий випадок: порожній або з одним вузлом
    }
    
    Node* newHead = reverseRecursive(head->next);
    head->next->next = head;  // Обертаємо зв'язок
    head->next = nullptr;
    return newHead;
}
Однак він має недолік: глибина рекурсії дорівнює довжині списку. Для списку з 10 000 елементів стек викликів може переповнитися (stack overflow). Ітеративний варіант завжди використовує O(1) пам'яті.

Практичне використання: приклад програми

#include "LinkedList.h"
#include <iostream>

int main()
{
    LinkedList list;
    
    std::cout << "=== Демонстрація однозв'язаного списку ===\n\n";
    
    // Вставка елементів
    std::cout << "Вставка елементів: 10, 20, 30 на початок\n";
    list.insertFront(30);
    list.insertFront(20);
    list.insertFront(10);
    list.display();  // Виведе: 10 → 20 → 30 → nullptr
    
    std::cout << "\nВставка 40 в кінець\n";
    list.insertBack(40);
    list.display();  // Виведе: 10 → 20 → 30 → 40 → nullptr
    
    // Пошук
    std::cout << "\nПошук елемента 20: " 
              << (list.search(20) ? "знайдено" : "не знайдено") << '\n';
    std::cout << "Пошук елемента 99: " 
              << (list.search(99) ? "знайдено" : "не знайдено") << '\n';
    
    // Розмір
    std::cout << "\nРозмір списку: " << list.size() << '\n';
    
    // Видалення
    std::cout << "\nВидалення першого елемента\n";
    list.deleteFront();
    list.display();  // Виведе: 20 → 30 → 40 → nullptr
    
    std::cout << "\nВидалення елемента зі значенням 30\n";
    list.deleteValue(30);
    list.display();  // Виведе: 20 → 40 → nullptr
    
    // Обернення
    std::cout << "\nОбернення списку\n";
    list.reverse();
    list.display();  // Виведе: 40 → 20 → nullptr
    
    // Очищення
    std::cout << "\nОчищення списку\n";
    list.clear();
    list.display();  // Виведе: Список порожній
    
    return 0;
}
g++ -std=c++17 main.cpp LinkedList.cpp -o linked_list && ./linked_list
$ ./linked_list
=== Демонстрація однозв'язаного списку ===
Вставка елементів: 10, 20, 30 на початок
Список: 10 → 20 → 30 → nullptr
Вставка 40 в кінець
Список: 10 → 20 → 30 → 40 → nullptr
Пошук елемента 20: знайдено
Пошук елемента 99: не знайдено
Розмір списку: 4
Видалення першого елемента
Список: 20 → 30 → 40 → nullptr
Видалення елемента зі значенням 30
Список: 20 → 40 → nullptr
Обернення списку
Список: 40 → 20 → nullptr
Очищення списку
Список порожній

Порівняння з масивами: компроміси та вибір структури

Масив (Array / std::vector)

Переваги:

  • Довільний доступ O(1): arr[1000] — миттєво.
  • Локальність кешу: послідовні елементи в пам'яті — швидкий доступ CPU.
  • Менше overhead пам'яті: лише дані, без додаткових покажчиків.
  • Простота реалізації: не потрібно керувати окремими вузлами.

Недоліки:

  • Вставка на початок/в середину O(n): потребує зсуву всіх наступних елементів.
  • Реалокація при зростанні: може бути затратною (копіювання всього масиву).
  • Фіксований (або заздалегідь виділений) розмір: втрата пам'яті при резервуванні надлишкової ємності.

Однозв'язаний список (Linked List)

Переваги:

  • Вставка/видалення на початку O(1): миттєва зміна покажчиків.
  • Динамічний розмір: пам'ять виділяється лише для фактичних елементів.
  • Немає реалокації: кожен елемент — окремий блок у купі.
  • Гнучкість структури: легко реорганізувати (обернути, розділити, об'єднати списки).

Недоліки:

  • Доступ за індексом O(n): потрібен обхід від голови.
  • Погана локальність кешу: вузли розкидані по купі — багато cache misses.
  • Overhead пам'яті: кожен елемент потребує додатковий покажчик (4-8 байт).
  • Складніше керування пам'яттю: ручне видалення кожного вузла.
Використовуйте масив (std::vector), якщо:
  • Основна операція — читання за індексом.
  • Розмір відомий заздалегідь або змінюється рідко.
  • Локальність кешу критична (числові обчислення, графіка).
Використовуйте зв'язаний список (std::list), якщо:
  • Основні операції — вставка/видалення на початку або в середині.
  • Розмір непередбачуваний та часто змінюється.
  • Потрібна стабільність ітераторів (ітератор на елемент залишається валідним після вставки інших).
У більшості випадків масив (std::vector) — кращий вибір через локальність кешу та простоту, навіть якщо асимптотична складність гірша.

Обмеження поточної реалізації та шляхи розвитку

Наша базова реалізація LinkedList є навчальною — вона демонструє фундаментальні концепції, але має обмеження порівняно з промисловим std::list (C++ STL, стаття 93).

1. Відсутність шаблонів — лише int

Поточна реалізація: Зберігаємо лише int. Для std::string, double, користувацьких класів потрібна окрема реалізація.

Промислове рішення: std::list<T> — це шаблон класу (стаття 82), що працює з будь-яким типом T.

template<typename T>
struct Node
{
    T data;          // Замість int data
    Node<T>* next;
};

2. Відсутність ітераторів

Поточна реалізація: Щоб обійти список, використовуємо метод display() або ручний обхід через Node* current.

Промислове рішення: STL-контейнери надають ітератори (стаття 87) для універсального обходу:

for (auto it = list.begin(); it != list.end(); ++it)
{
    std::cout << *it << '\n';
}

// Або range-based for (стаття 23)
for (int value : list)
{
    std::cout << value << '\n';
}

3. Обмежений функціонал

Додаткові методи промислового std::list:

  • insertAfter(position, value) — вставка після заданого вузла.
  • sort() — сортування списку (merge sort для зв'язаних списків).
  • merge(otherList) — об'єднання двох відсортованих списків.
  • unique() — видалення дублікатів.
  • splice() — перенесення елементів з одного списку в інший без копіювання.

4. Відсутність двоспрямованого зв'язку

Однозв'язаний список: Можна рухатися лише вперед (від голови до кінця). Обернення списку — O(n).

Двозв'язаний список (стаття 91): Кожен вузол містить два покажчики — next та prev. Це дозволяє ефективно рухатися у обох напрямках та видаляти вузол за O(1), якщо є покажчик на нього.

struct DoublyNode
{
    int data;
    DoublyNode* next;
    DoublyNode* prev;  // Покажчик на попередній вузол
};
Двозв'язаний список (std::list у C++) більш універсальний, але має подвійний overhead пам'яті на покажчики. Однозв'язаний список (std::forward_list) економніший, але обмеженіший у функціоналі.

Типові помилки та підводні камені

1. Втрата покажчика на решту списку

❌ Неправильно:

void deleteFront()
{
    delete head;         // Видалили перший вузол
    head = head->next;   // ❌ UB: доступ до звільненої пам'яті!
}

✅ Правильно:

void deleteFront()
{
    Node* temp = head->next;  // Спершу зберігаємо next
    delete head;
    head = temp;              // Тепер безпечно оновлюємо head
}

2. Забутий nullptr в кінці списку

❌ Неправильно:

Node* newNode = new Node{42};  // next не ініціалізовано!
lastNode->next = newNode;       // Висячий покажчик у newNode->next

✅ Правильно:

Node* newNode = new Node{42, nullptr};  // Явно встановлюємо nullptr

3. Витік пам'яті в деструкторі

❌ Неправильно:

~LinkedList()
{
    delete head;  // Видаляє лише перший вузол, решта — витік!
}

✅ Правильно:

~LinkedList()
{
    Node* current = head;
    while (current != nullptr)
    {
        Node* next = current->next;
        delete current;
        current = next;
    }
}

4. Зміна head всередині циклу обходу

❌ Неправильно:

void display()
{
    while (head != nullptr)
    {
        std::cout << head->data << " ";
        head = head->next;  // ❌ Втрачаємо голову назавжди!
    }
}

✅ Правильно:

void display()
{
    Node* current = head;  // Тимчасова змінна для обходу
    while (current != nullptr)
    {
        std::cout << current->data << " ";
        current = current->next;
    }
}
Голова списку (head) — це єдиний вхідний пункт до структури. Втрата покажчика на голову означає втрату всього списку — жоден вузол більше не досяжний, але всі залишаються в купі (витік пам'яті).

Резюме: від суцільних блоків до ланцюжків вузлів

У цій лекції ми опанували парадигму вузлових структур даних — фундаментальну альтернативу масивам. Однозв'язаний список демонструє потужність динамічного виділення пам'яті та гнучкості покажчиків: замість перерозподілу величезних блоків ми оперуємо окремими вузлами, з'єднаними логічними зв'язками.

Ключові концепції:

  • Node (Вузол): базовий будівельний блок — структура з даними та покажчиком next.
  • Head (Голова): єдина точка входу до списку; втрата голови — втрата всього списку.
  • Traversal (Обхід): послідовний прохід по вузлах через current = current->next.
  • O(1) вставка на початку: фундаментальна перевага над масивами.
  • O(n) доступ за індексом: фундаментальне обмеження через відсутність довільного доступу.

🎓 Що ми опанували

  • Спроєктували вузлову структуру даних з динамічним виділенням пам'яті.
  • Реалізували ключові операції: вставка, видалення, пошук, обернення.
  • Дослідили компроміси між масивами та зв'язаними списками.
  • Навчилися коректно керувати пам'яттю через RAII та clear().
  • Зрозуміли асимптотичну складність операцій та їхній вплив на вибір структури.

🚀 Наступні кроки

Стаття 91 — Двозв'язаний список: Розширимо однозв'язаний список покажчиком prev для ефективного обходу в обох напрямках та O(1) видалення вузла за покажчиком.

Стаття 92 — Бінарне дерево пошуку: Перейдемо від лінійних структур до ієрархічних: дерева з логарифмічною складністю пошуку.

Стаття 93 — Контейнери STL (std::vector, std::list): Вивчимо професійні реалізації динамічних масивів та зв'язаних списків у стандартній бібліотеці C++.


Практичні завдання


Ця стаття завершує знайомство з однозв'язаними списками — першою структурою даних, де ми покинули світ суцільних масивів та вступили у світ динамічно зв'язаних вузлів. У наступній лекції ми розширимо цю концепцію до двозв'язаних списків, а потім перейдемо до ієрархічних структур — дерев та графів.

Copyright © 2026