Однозв’язаний список у структурах даних

⚡ Розумний підсумок

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

  • 🧩 Структура вузла: Кожен вузол містить одне поле даних та одне наступний вказівник на наступний вузол; хвостовий вузол наступний вказівник має значення NULL.
  • 📦 Список проти масиву: Однозв'язані списки є кращими, коли кількість елементів невідома, довільний доступ не потрібен, а вставка в середині списку є поширеною.
  • Вставки: Вузли можна додавати на початку, в кінці, після збігаючогося вузла або перед збігаючимся вузлом за допомогою перезапису наступного вказівника.
  • Видалення: Видалення голови, хвоста або шуканого вузла оновлює вказівники на сусідні вузли та звільняє вивільнену пам'ять, щоб уникнути витоків.
  • 🔁 Обхід: Підтримується лише прямий обхід, оскільки немає попереднього вказівника, тому зворотний обхід однозв'язного списку неможливий.
  • 💻 C++ та Python Code: Повні реалізації показують процедури вставки, видалення, пошуку та переміщення з виконуваним виводом.
  • 📊 Складність: Вставка або видалення заголовка — це O(1); пошук та інші вставки та видалення — O(n); просторова складність — O(n).

Однозв'язаний список

Що таке однозв’язаний список?

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

Ось структура вузлів однозв'язаного списку:

Структура вузла в зв'язаному списку

Структура вузла в зв'язаному списку

Навіщо використовувати зв'язаний список над масивом?

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

  • Невідома кількість елементів: Коли необхідна кількість елементів невідома під час компіляції, зв'язаний список динамічно розподіляє пам'ять під час додавання елементів.
  • Довільний доступ: Коли випадковий індексований доступ не потрібен, зв'язаний список є підходящим вибором.
  • Вставка посередині: Вставка в середину масиву вимагає зміщення елементів. Зв'язаний список дозволяє вставку в будь-яку позицію, перезаписуючи лише кілька вказівників.

Operaції однозв’язаного списку

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

У цій статті обговорюються такі операції:

  • Вставка в голову
  • Вставка в хвіст
  • Вставка після вузла
  • Вставка перед вузлом
  • Видалити головний вузол
  • Видаліть хвостовий вузол
  • Пошук і видалення вузла
  • Перегляд пов’язаного списку

Ось приклад зв'язаного списку з чотирма вузлами.

Приклад однозв’язаного списку

Приклад однозв’язаного списку

Вставка на початок однозв'язного списку

Це проста операція. Вона зазвичай відома як додавання до однозв'язного списку. Новий вузол створюється та розміщується на початку списку.

Для виконання цієї операції необхідно виконати дві важливі умови:

  1. Якщо список порожній, новостворений вузол стає головним вузлом, і його наступний вказівник має значення NULL.
  2. Якщо список не порожній, новий вузол стає головним вузлом, і його наступний вказівник вказує на попередній головний вузол.

Ось псевдокод для вставки вузла на початок зв'язаного списку:

function insertAtHead(head, value):
  newNode = Node(value)
  if head is NULL:
    head = newNode
    return head
  else:
    newNode.next = head
    return newNode

Вставка в голову

Вставка в голову

Вставка в кінець однозв'язного списку

Вставка вузла в кінець зв'язаного списку подібна до вставки на початку. Перейдіть до вузла-хвостя, а потім вкажіть його наступний вказівник на новий вузол. Якщо значення head має значення NULL, новий вузол стає головним.

Крок 1) Пройдіть до наступний вказівник поточного вузла стає NULL.

Крок 2) Створіть новий вузол із вказаним значенням.

Крок 3) Призначте новий вузол як наступний вузол хвостового вузла.

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

function insertAtEnd(head, value):
  newNode = Node(value)
  if head is NULL:
    head = newNode
    return head
  while head.next is not NULL:
    head = head.next
  head.next = newNode
  newNode.next = NULL

Вставка біля хвоста

Вставка біля хвоста

Вставка після вузла в однозв'язному списку

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

Крок 1) Переміщайтеся, доки значення поточного вузла не дорівнюватиме значенню елемента пошуку.

Крок 2) Встановіть новий вузол наступний вказівник на поточний вузол наступний вказівник.

Крок 3) Вкажіть поточний вузол наступний вказівник на новий вузол.

Псевдокод:

function insertAfter(head, value, searchItem):
  newNode = Node(value)
  while head.value != searchItem:
    head = head.next
  newNode.next = head.next
  head.next = newNode

Вставлення вузла після вузла в однозв’язаний список

Вставлення вузла після вузла в однозв’язаному списку

Вставка перед вузлом в однозв'язному списку

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

Крок 1) Переміщайтеся, поки значення наступного вузла не зрівняється з елементом пошуку.

Крок 2) Створіть новий вузол і встановіть його наступний вказівник на поточний вузол наступний.

Крок 3) Вкажіть поточний вузол наступний до нового вузла.

function insertBefore(head, value, searchItem):
  newNode = Node(value)
  while head.next.value != searchItem:
    head = head.next
  newNode.next = head.next
  head.next = newNode

Вставлення вузла перед вузлом у однозв’язаному списку

Вставлення вузла перед вузлом у однозв’язаному списку

Видалити заголовок однозв'язаного списку

Вказівник на головний вузол надається як параметр. Головний вузол видаляється, а наступний вузол стає новим головним. Пам'ять видаленого вузла має бути звільнена, щоб уникнути витоків пам'яті.

Крок 1) Призначте наступний вузол голови як нову голову.

Крок 2) Звільніть виділену пам'ять попереднього головного вузла.

Крок 3) Поверніть новий головний вузол.

function deleteHead(head):
  temp = head
  head = head.next
  free(temp)
  return head

Видалення голови пов’язаного списку

Видалення заголовка пов’язаного списку

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

Видалення хвостового вузла подібне до видалення головного вузла. Різниця полягає в тому, що потрібен обхід до кінця списку. В однозв'язному списку вузол, чий наступний вказівник дорівнює NULL, це хвостовий вузол.

Крок 1) Пройдіть траверсом до вузла безпосередньо перед хвостовим вузлом. Збережіть поточний вузол.

Крок 2) Звільніть пам'ять наступного вузла (хвоста).

Крок 3) Встановити наступний вузол поточного вузла на NULL.

function deleteTail(head):
  while head.next.next is not NULL:
    head = head.next
  free(head.next)
  head.next = NULL

Видалення хвоста однозв’язаного списку

Видалення хвоста однозв’язаного списку

Пошук та видалення вузла з однозв'язного списку

Ця функція виконує два завдання: пошук та видалення. Переміщення до кінця списку. Якщо знайдено відповідний вузол, видалити його та повторно зв'язати попередній вузол. наступний вказівник.

Крок 1) Пройдіться до кінця списку. Перевірте, чи поточний вузол дорівнює пошуковому вузлу.

Крок 2) Якщо знайдено збіг, зберігається вказівник на поточний вузол.

Крок 3) Команда наступний попереднього вузла стає наступним вузлом поточного вузла.

Крок 4) Видалити поточний вузол та звільнити його пам'ять.

function searchAndDelete(head, searchItem):
  while head.next.next is not NULL and head.next.value != searchItem:
    head = head.next
  temp = head.next
  head.next = head.next.next
  free(temp)

Пошук і видалення вузла з однозв’язаного списку

Пошук і видалення вузла з однозв’язаного списку

Перехід по однозв'язному списку

Однозв'язний список підтримує обхід лише від початку до кінця. Вказівник на попередній вузол відсутній, тому зворотний обхід неможливий. Кожен вузол відвідується по черзі, виводячи його значення, доки не буде досягнуто NULL.

Крок 1) Пройдіться через кожен вузол, доки не буде досягнуто NULL.

Крок 2) Вивести значення поточного вузла.

function traverse(head):
  while head is not NULL:
    print head.value
    head = head.next

Приклад однозв’язаного списку в C++

#include<iostream>
using namespace std;
struct Node{
  int data;
  struct Node *next;
};
void insertAtHead(Node* &head, int value){
  Node* newNode = new Node();
  newNode->data = value;
  newNode->next = NULL;
  if(head != NULL){
    newNode->next = head;
  }
  head = newNode;
  cout<<"Added "<<newNode->data<<" at the front"<<endl;
}
void insertEnd(Node* &head, int value){
  if(head == NULL){
    insertAtHead(head, value);
    return;
  }
  Node* newNode = new Node();
  newNode->data = value;
  newNode->next = NULL;
  Node *temp = head;
  while(temp->next != NULL){
    temp = temp->next;
  }
  temp->next = newNode;
  cout<<"Added "<<newNode->data<<" at the end"<<endl;
}
void searchAndDelete(Node **headPtr, int searchItem){
  Node *temp = NULL;
  if((*headPtr)->data == searchItem){
    temp = *headPtr;
    *headPtr = (*headPtr)->next;
    free(temp);
  } else {
    Node *currentNode = *headPtr;
    while(currentNode->next != NULL){
      if(currentNode->next->data == searchItem){
        temp = currentNode->next;
        currentNode->next = currentNode->next->next;
        free(temp);
        break;
      } else {
        currentNode = currentNode->next;
      }
    }
  }
  cout<<"Deleted Node\t"<<searchItem<<endl;
}
void insertAfter(Node* &headPtr, int searchItem, int value){
  Node* newNode = new Node();
  newNode->data = value;
  newNode->next = NULL;
  Node *head = headPtr;
  while(head->next != NULL && head->data != searchItem){
    head = head->next;
  }
  newNode->next = head->next;
  head->next = newNode;
  cout<<"Inserted "<<value<<" after node\t"<<searchItem<<endl;
}
void insertBefore(Node* &headPtr, int searchItem, int value){
  Node* newNode = new Node();
  newNode->data = value;
  newNode->next = NULL;
  Node *head = headPtr;
  while(head->next != NULL && head->next->data != searchItem){
    head = head->next;
  }
  newNode->next = head->next;
  head->next = newNode;
  cout<<"Inserted "<<value<<" before node\t"<<searchItem<<endl;
}
void traverse(Node *headPointer){
  Node* tempNode = headPointer;
  cout<<"Traversal from head:\t";
  while(tempNode != NULL){
    cout<<tempNode->data;
    if(tempNode->next)
      cout<<" --> ";
    tempNode = tempNode->next;
  }
  cout<<endl;
}
int main(){
  Node *head = NULL;
  insertAtHead(head, 5);
  insertAtHead(head, 6);
  insertAtHead(head, 7);
  insertEnd(head, 9);
  traverse(head);
  searchAndDelete(&head, 6);
  traverse(head);
  insertAfter(head, 7, 10);
  insertBefore(head, 9, 11);
  traverse(head);
}

Вихід

Added 5 at the front
Added 6 at the front
Added 7 at the front
Added 9 at the end
Traversal from head:    7 --> 6 --> 5 --> 9
Deleted Node    6
Traversal from head:    7 --> 5 --> 9
Inserted 10 after node  7
Inserted 11 before node 9
Traversal from head:    7 --> 10 --> 5 --> 11 --> 9

Приклад однозв’язаного списку в Python

class Node:
  def __init__(self, data=None, next=None):
    self.data = data
    self.next = next
class SinglyLinkedList:
  def __init__(self):
    self.head = None
  def insertAtHead(self, value):
    newNode = Node(data=value)
    if self.head is not None:
      newNode.next = self.head
    self.head = newNode
    print(f'Added {newNode.data} at the front.')
  def insertAtEnd(self, value):
    if self.head is None:
      self.insertAtHead(value)
      return
    newNode = Node(value)
    temp = self.head
    while temp.next is not None:
      temp = temp.next
    temp.next = newNode
    print(f'Added {newNode.data} at the end.')
  def searchAndDelete(self, searchItem):
    if self.head is None:
      return
    if self.head.data == searchItem:
      self.head = self.head.next
      print(f'Deleted node\t{searchItem}')
      return
    currentNode = self.head
    while currentNode.next is not None:
      if currentNode.next.data == searchItem:
        currentNode.next = currentNode.next.next
        print(f'Deleted node\t{searchItem}')
        return
      currentNode = currentNode.next
  def insertAfter(self, searchItem, value):
    newNode = Node(data=value)
    temp = self.head
    while temp.next is not None and temp.data != searchItem:
      temp = temp.next
    newNode.next = temp.next
    temp.next = newNode
    print(f'Inserted {value} after node\t{searchItem}')
  def insertBefore(self, searchItem, value):
    newNode = Node(data=value)
    temp = self.head
    while temp.next is not None and temp.next.data != searchItem:
      temp = temp.next
    newNode.next = temp.next
    temp.next = newNode
    print(f'Inserted {value} before node\t{searchItem}')
  def traverse(self):
    temp = self.head
    print("Traversing from head:\t", end="")
    while temp:
      print("{}\t".format(temp.data), end="")
      temp = temp.next
    print()
singlyLinkedList = SinglyLinkedList()
singlyLinkedList.insertAtHead(5)
singlyLinkedList.insertAtHead(6)
singlyLinkedList.insertAtHead(7)
singlyLinkedList.insertAtEnd(9)
singlyLinkedList.traverse()
singlyLinkedList.searchAndDelete(6)
singlyLinkedList.traverse()
singlyLinkedList.insertAfter(7, 10)
singlyLinkedList.insertBefore(9, 11)
singlyLinkedList.traverse()

Вихід

Added 5 at the front.
Added 6 at the front.
Added 7 at the front.
Added 9 at the end.
Traversing from head:   7       6       5       9
Deleted node    6
Traversing from head:   7       5       9
Inserted 10 after node  7
Inserted 11 before node 9
Traversing from head:   7       10      5       11      9

Складність однозв’язаного списку

Існує два види складності: часова складність та просторова складність. Часова складність у найгіршому та середньому випадках однакова для однозв'язного списку.

Часова складність у найкращому випадку:

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

Середня складність справи:

  • Вставка всередину зв'язаного списку займає O(n), де n – загальна кількість елементів.
  • Пошук та видалення також можуть зайняти O(n), оскільки цільовий елемент може знаходитися будь-де аж до хвостового вузла.

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

Однозв'язний список динамічно розподіляє пам'ять. Для зберігання n елементи, він розподіляє n одиниць пам'яті. Отже, просторова складність становить O(n).

Застосування однозв'язного списку

Однозв'язані списки зустрічаються в багатьох місцях, де корисним є перехід лише вперед та динамічна пам'ять:

  • Стеки та черги: Базове сховище для стеків LIFO та черг FIFO, побудованих з вузлів.
  • Ланцюжок хеш-таблиць: Колізії вирішуються шляхом об'єднання записів в односвязний список для кожного сегмента.
  • Списки суміжності: Розріджені графи використовують односвязний список сусідів для кожної вершини.
  • Таблиці символів: Компілятори та інтерпретатори об'єднують ідентифікатори в однозв'язаний список для кожної області видимості.
  • Розподільники пам'яті: Розподільники з вільного списку track вільних блоків у вигляді однозв'язного списку.

Поширені запитання

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

Так. GitHub Copilot та GPT можуть створювати повний однозв'язаний список на C, C++, Java, Pythonабо JavaСкрипт, включаючи вставку, видалення, розворот, виявлення циклів та модульні тести.

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

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

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

Зв'язані списки збільшуються та стискаються під час виконання, вставляють або видаляють за O(1), як тільки позиція відома, і ніколи не потребують безперервної пам'яті. Масиви пропонують O(1) випадковий доступ та кращу локальність кешу.

Пройдіться по списку за допомогою трьох покажчиків: prev, curr та next. На кожному кроці збережіть curr.next, вкажіть curr.next на prev та зсуньте prev та curr вперед. Поверніть prev як новий заголовок.

Алгоритм Флойда «черепаха-заєць» використовує два вказівники, що рухаються з різною швидкістю. Якщо вони коли-небудь зустрічаються, список містить цикл. В іншому випадку швидкий вказівник досягає NULL, і циклу не існує.

Підсумуйте цей пост за допомогою: