Вступ

У семінарі №6 розглянуто принципи побудови таких структур даних:

  • Зв'язані списки
  • Хеш-таблиці
  • Префіксні дерева
  • Дерева
  • Бінарні дерева
  • Стек
  • Черга

Зв'язний список

Визначення

Зв'язний список - це структура даних, в якій елементи лінійно впорядковані, але порядок визначається не номерами елементів (як в масивах), а вказівниками, що входять у склад елементів списку та вказують на наступний елемент. Список має "голову" (перший елемент) та "хвіст" (останній елемент).

Переваги і недоліки порівняно з масивами

На відміну від масивів, вставка та вилучення елементів у Зв'язний список потребують сталого часу (О(1)). Також значною перевагою зв'язних списків є можливість легкого розширення: щоб збільшити розмір списку, треба лише додати ще один елемент.

Недоліком зв'язних списків є необхідність проходити весь список, щоб знайти елемент (тобто час доступу до елемента списку - О(n)).

Елемент зв'язаного списку

Елемент зв'язаного списку містить дані та посилання (покажчик) на наступний елемент списку, або NULL, - якщо цей елемент є останнім.

Нижче наведено код структури, що реалізує елемент зв'язаного списку (і власне список). Дані представлені числовим значенням.

typedef struct node
{
  int n;
  struct node* next;
} node;

Для пошуку елемента у списку, необхідно пройти весь список. Це реалізується за допомогою переходу з поточного елемента на наступний за посиланням.

У цьому прикладі для того, щоб знайти елемент "9", необхідно перш за все з голови списку перейти на елемент "2". Потім за посиланням перейти на елемент "3". Лише після цього можна за посиланням знайти елемент "9".

Нижче наведено код функції, яка шукає заданий елемент у списку і повертає true, якщо елемент є у списку, або false, якщо елемента немає у списку.

bool search (node* list, int n)
{
  node* ptr = list;
  while (ptr != NULL)
  {
    if(ptr -> n == n)
    {
      return true;
    }
    ptr = ptr -> next;
  }
  return false;
}

Вставка елемента у список

Є три різні можливі варіанти вставки у список: вставка у початок списку, вставка у кінець списку і вставка у якесь місце всередині списку.

Розглянемо вставку в початок списку (оскільки це найбільш часто використовуваний варіант вставки).

  1. Створимо новий елемент. Він поки що не посилається на інші елементи, і на нього ніякий інший елемент не посилається.

  2. Переназначити покажчик нового елементу так, щоб він вказував на той же елемент, на який вказує head.

  3. Переназначити покажчик head на новий елемент.

Важливо зробити кроки 2 і 3 саме в такому порядку. Якщо спершу встановити head на новий елемент, то ми втратимо частину списку.

Нижче наведено код функції для вставки нового елементу на початок списку.

void insert(int n)
{
  //створити новий елемент
  node* new = malloc(sizeof(node));
  
  //перевірка на NULL
  if(new == NULL)
  {
    exit(1);
  }
  
  //ініціалізувати новий елемент
  new->n = n;
  new->next = NULL;
  
  //вставити новий елемент у голову списку
  new->next = head;
  head = new;
}

Двозв'язний список

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

Елемент двозв'язного списку

Елемент двозв'язного списку містить дані та два поля: prev та next, - покажчики на попередній та наступний елементи списку відповідно.

Код структури елемента двозв'язного списку мовою С:

typedef struct node
{
  int n;
  struct node* next;
  struct node* prev;
} node;

Хеш-таблиці

Поняття хеш-таблиці

Хеш-таблиця - це, масив, у якому місце розташування елемента залежить від значення, яке має сам елемент. Зв'язок між значенням елемента і його позицією у хеш-таблиці задає хеш-функція. Важлива властивість хеш-таблиці: пошук, вставка і видалення елементів з таблиці виконуються за час О(1).

У прикладі вище, позиція кожного слова в хеш-таблиці залежить від порядкового номеру першої букви цього слова в англійському алфавіті.

Поняття хеш-функції

Загалом, хеш-функцією можна вважати таку функцією, що приймає якийсь елемент (який потрібно вставити у хеш-таблицю), а результатом її роботи є позиція заданого елемента у хеш-таблиці.

Приклади: порядковий номер першої букви слова у алфавіті, остача від ділення на 13, тощо.

Нижче наведено код хеш-функції на основі першої літери в рядку:

int hash_function(char* key)
{
  int hash = toupper(key[0]) - 'A';
  return hash % SIZE;
}

Колізії

Ситуація, коли для різних ключів отримується одне й те саме хеш-значення, називається колізією. Наприклад, у зображену раніше таблицю на основі перших символів рядка ми спробуємо додати нове слово - berry.

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

Лінійне зондування

Один з варіантів розв'язання колізій - лінійне зондування, коли просто відбувається пошук першої пустої комірки після тої, на яку вказала хеш-функція.

Метод ланцюжків

При методі ланцюжків, кожна комірка хеш-таблиці - це список значень. При виникненні колізії, нове значення просто додається до списку у ту ж саму комірку таблиці.

Префіксне дерево

Префіксне дерево - це структура даних, в якій шлях від кореня дерева до листка (останнього елемента) дерева визначає рядок.

Таким чином, при проходженні дерева згори донизу формуються слова.

Опис структури префіксного дерева

typedef struct node 
{
  // Маркер кінця слова 
  bool is_word; 
  // Покажчики на інші елементи 
  struct node* children[27]; 
} node;

Приклад роботи із префіксним деревом

Маємо наступного виду префіксне дерево, у якому є слова bat та zoom:

Переходячи за послідовними посиланнями згори донизу доти, доки не зустрінемо маркер кінця слова, отримаємо слово bat:

Для того щоб вставити слово zoo у масив, необхідно пройти по списку і встановити маркер:

А щоб додати у дерево слово bath, треба в останньому елементі слова bat створити посилання, що вказує на букву h:

Дерева

Дерево - це структура даних, де елемент, або вузол, вказує на інші вузли. Приклад дерева:

Корінь дерева - верхній вузол у дереві (1 у прикладі)

Батько - вузол, що вказує на вузол, який ви розглядаєте. Наприклад, батько вузла 3 - вузол 1.

Нащадок - вузол, на який вказують інші вузли (так, у прикладі вузли 2 та 3 є нащадками вузла 1).

Листки дерева - вузли, що знаходяться знизу, або вузли, що не мають нащадків.

Брати і сестри - це вузли, що мають спільного батька (у прикладі, вузли 5, 6, 7).

Бінарні дерева

Бінарне дерево - це дерево, у якому кожен вузол має не більше двох нащадків.

Елемент бінарного дерева

Бінарне дерево, як і однозв'язний або двозв'язний список, оголошуються за допомогою свого елемента. У бінарному дереві, кожен елемент має дані (ціле число у прикладі), і посилання на лівого та правого нащадків:

typedef struct node
{
  int n;
  struct node* left;
  struct node* right;
} node;

У бінарному дереві пошуку все, що справа від батьківського вузла - більше за нього, а все, що зліва - менше.

Таким чином, пошук елемента в бінарному дереві пошуку дуже схожий на пошук елемента в масиві за алгоритмом бінарного пошуку. Єдина різниця - при пошуку в бінарному дереві, замість лівої або правої половини масиву ми щоразу розглядаємо ліве або праве піддерево.

Нижче наведено код функції пошуку елемента у бінарному дереві пошуку.

bool search(node* root, int val)
{
  if(root == NULL)
    return false;
  if(root->n == val)
    return true;
  if(val < root->n)
    return search(root->left, val);
  if(val > root->n)
    return search(root->right, val);
}

Стек

Стек - це структура даних, яка працює за принципом "останнім прийшов - першим пішов" (first in - first out, FIFO). Можна уявити собі стіс розносів у їдальні - той рознос, що поклали у стіс останнім, новий клієнт їдальні забере в першу чергу.

LIFO - це абревіатура, яка означає "last in - first out", тобто "останнім прийшов - першим пішов".

Над стеком можна здійснювати дві операції - push (занесення даних) і pop (вилучення даних).

Приклад реалізації стеку мовою С наведено нижче. У цьому прикладі, стек - це просто масив рядків, що має певну ємність (CAPACITY), і поточний розмір (size):

typedef struct{
  char* strings[CAPACITY];
  int size;
} stack;

Щоб реалізувати операцію push, необхідно зробити перевірку, чи не перевищує поточний розмір ємність стеку, після чого - вставити елемент на позицію size і збільшити size на одиницю.

Для реалізації операції pop, необхідно перевірити, чи не пустий стек, зменшити поточний розмір на одиницю і повернути елемент.

Черга

Черга - це структура даних, що працює за принципом "першим прийшов - першим пішов" (last in - first out, LIFO).

Для черги визначено дві операції - додавання елемента в кінець черги (enqueue) та вилучення елемента з початку черги (dequeue).

У прикладі оголошено чергу, що фактично являє собою масив рядків:

typedef struct{
  int head;
  char* strings[CAPACITY]
  int size;
} queue;

Щоб реалізувати операцію enqueue, необхідно впевнитись, що чергу не переповнено, додати елемент в кінець черги і збільшити поточний розмір на одиницю.

Щоб реалізувати операцію dequeue, треба впевнитись, що черга не пуста, збільшити head на одиницю, зменшити поточний розмір і повернути перший елемент черги.