Двозв'язаний список: C++, Python (Code приклад)

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

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

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

Двозв’язаний список

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

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

Ось базова структура подвійно зв'язаного списку.

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

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

Кожен зв'язаний список має головний та хвостовий вузли. Головний вузол не має Попередня (попередній покажчик) вузол, а хвостовий вузол не має наступний вузол.

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

  • Попередня: Кожен вузол пов’язаний зі своїм попереднім вузлом. Він використовується як вказівник або посилання.
  • далі: Кожен вузол пов’язаний зі своїм наступним вузлом. Він використовується як вказівник або посилання.
  • дата: Це використовується для зберігання даних у вузлі. Дані можуть містити інші Структури даних всередині нього. Наприклад, рядок, словник, набір, хеш-карта та інші структури можуть зберігатися в полі даних.

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

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

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

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

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

Ось список операцій, які можна реалізувати у двозв'язаному списку:

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

Реалізація та псевдокод для кожної з цих операцій наведено нижче.

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

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

Наприклад, є заданий вузол 15Його потрібно додати як головний вузол.

Під час виконання цієї операції застосовуються дві важливі умови:

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

Ось псевдокод для цієї операції:

function insertAtFront(ListHead, value):
  newNode = Node()
  newNode.value = value
  ListHead.prev = newNode
  newNode.next = ListHead
  newNode.prev = NULL
  return ListHead

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

Введення в передній вузол

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

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

Два способи виконання цієї операції:

  • Метод 1: Почніть переміщення з початку подвійно зв'язаного списку доти, доки наступний стає null. Потім зв'яжіть новий вузол з наступний вказівник.
  • Метод 2: Візьмемо останній вузол подвійно зв'язаного списку. Потім, наступний Вказівник останнього вузла вказує на новий вузол. Новий вузол стає хвостовим вузлом.

Ось псевдокод для вставки у хвостовому вузлі:

function insertAtTail(ListHead, value):
  newNode = Node()
  newNode.value = value
  newNode.next = NULL
  while ListHead.next is not NULL:
    ListHead = ListHead.next
  newNode.prev = ListHead
  ListHead.next = newNode
  return ListHead

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

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

Вставка після вузла

Розглянемо існуючий подвійно зв'язаний список, як показано нижче:

Вставка після вузла

Мета полягає у вставці заданого вузла, який буде пов'язаний після вузла зі значенням 12.

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

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

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

function insertAfter(ListHead, searchItem, value):
  List = ListHead
  newNode = Node()
  newNode.value = value
  while List.value is not equal searchItem:
    List = List.next
  newNode.next = List.next
  newNode.prev = List
  List.next = newNode

Вставка після вузла

Вставка після вузла

Вставка перед вузлом

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

Щоб вставити заданий вузол 15 перед вузлом 12, виконайте такі дії:

Крок 1) Перейдіть по пов’язаному списку від головного вузла до кінцевого вузла.

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

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

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

function insertBefore(ListHead, searchItem, value):
  List = ListHead
  newNode = Node()
  newNode.value = value
  while List.next.value is not equal searchItem:
    List = List.next
  newNode.next = List.next
  newNode.prev = List
  List.next = newNode

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

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

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

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

Ось кроки для видалення головного вузла:

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

Крок 2) Відвідати наступний вузол поточного головного вузла та зробіть Попередня вказівник NULL. Це від'єднує другий вузол від першого вузла.

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

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

function deleteHead(ListHead):
  PrevHead = ListHead
  ListHead = ListHead.next
  ListHead.prev = NULL
  PrevHead.next = NULL
  free memory(PrevHead)
  return ListHead

Видалення головного вузла

Видалення головного вузла

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

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

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

Ця операція також відома як видалення зі спини.

Ось кроки, як це зробити:

Крок 1) Пройдіть до хвостового вузла подвійно зв'язаного списку.

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

Крок 3) Встановіть наступний вказівник на NULL та звільнити пам'ять хвостового вузла.

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

function deleteTail(ListHead):
  head = ListHead
  while ListHead.next is not NULL:
    ListHead = ListHead.next
  Tail = ListHead
  ListHead.prev.next = NULL
  free memory(Tail)
  return head

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

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

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

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

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

Крок 2) Призначте змінну deleteNode до відповідного вузла.

Крок 3) Зв'язати попередній вузол deleteNode до наступного вузла та встановити значення наступного вузла Попередня вказівник на попередній вузол.

Крок 4) Звільніть пам'ять про deleteNode.

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

function searchAndDelete(ListHead, searchItem):
  head = ListHead
  while head.value not equals searchItem:
    head = head.next
  deleteNode = head
  head.prev.next = head.next
  if head.next is not NULL:
    head.next.prev = head.prev
  free memory(deleteNode)
  return ListHead

Пошук і видалення Operaції

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

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

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

Крок 1) Призначте вказівник або змінну поточному головному вузлу.

Крок 2) Переходимо до наступного вузла голови, доки не отримаємо NULL.

Крок 3) Вивести дані вузла в кожній ітерації.

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

Ось псевдокод для обходу двозв'язаного списку спереду:

function traverseFromFront(ListHead):
  head = ListHead
  while head not equals NULL:
    print head.data
    head = head.next
  return ListHead

Повернення не є обов'язковим. Однак повернення головного вузла після операцій є гарною практикою.

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

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

Ось кроки для переходу по двозв'язаному списку з кінця:

Крок 1) Рухайтеся, доки не досягнете хвостового вузла.

Крок 2) Від хвостового вузла перейдіть за допомогою Попередня доки попередній вузол не стане NULL. Попередня Вказівник для головного вузла має значення null.

Крок 3) На кожній ітерації виводити дані вузла.

Ось псевдокод для переходу з зворотного боку:

function traverseFromBack(ListHead):
  head = ListHead
  while head.next is not NULL:
    head = head.next
  tail = head
  while tail is not NULL:
    print tail.value
    tail = tail.prev
  return ListHead

Різниця між одно- та двозв'язним списком

Основна відмінність між однозв'язним списком та двозв'язним списком полягає в кількості посилань, які містить кожен вузол.

Різниця між однозв’язаним і двозв’язаним списком

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

ПолеОднозв'язаний списокДвозв’язаний список
СтруктураОднозв'язаний список має одне поле даних і одне посилання на наступний вузол.Двозв’язаний список має одне поле даних і два посилання. Один для попереднього вузла, інший для наступного вузла.
ОбхідВін може проходити лише від голови до хвоста.Він може рухатися як вперед, так і назад.
пам'ятьЗаймає менше пам'яті.Займає більше пам'яті, ніж однозв'язний список.
ДоступністьОднозв'язані списки менш ефективні, оскільки вони використовують лише одне посилання на наступний вузол. Немає посилання на попередній вузол.Двозв'язані списки є ефективнішими, ніж однозв'язані списки, для двонаправленого доступу.

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

Нижче наведено повний C++ Реалізація двозв'язаного списку з операціями вставки, видалення, пошуку та переміщення.

#include<iostream>
using namespace std;
struct node{
  int data;
  struct node *next;
  struct node *prev;
};
void insertFront(node* &listHead, int value){
  node* newNode = new node();
  newNode->data = value;
  newNode->prev = NULL;
  newNode->next = NULL;
  if(listHead != NULL){
    listHead->prev = newNode;
    newNode->next = listHead;
  }
  listHead = newNode;
  cout<<"Added "<<value<<" at the front"<<endl;
}
void insertEnd(node* &listHead, int value){
  if(listHead == NULL){
    insertFront(listHead, value);
    return;
  }
  node* newNode = new node();
  newNode->data = value;
  newNode->prev = NULL;
  newNode->next = NULL;
  node *head = listHead;
  while(head->next != NULL){
    head = head->next;
  }
  head->next = newNode;
  newNode->prev = head;
  cout<<"Added "<<value<<" at the end"<<endl;
}
void insertAfter(node* &listHead, int searchValue, int value){
  node* newNode = new node();
  newNode->data = value;
  newNode->prev = NULL;
  newNode->next = NULL;
  node *head = listHead;
  while(head->next != NULL && head->data != searchValue){
    head = head->next;
  }
  newNode->next = head->next;
  head->next = newNode;
  newNode->prev = head;
  if(newNode->next != NULL){
    newNode->next->prev = newNode;
  }
  cout<<"Inserted "<<value<<" after node "<<searchValue<<endl;
}
void insertBefore(node* &listHead, int searchValue, int value){
  node* newNode = new node();
  newNode->data = value;
  newNode->prev = NULL;
  newNode->next = NULL;
  node *head = listHead;
  while(head->next != NULL && head->next->data != searchValue){
    head = head->next;
  }
  newNode->next = head->next;
  head->next = newNode;
  newNode->prev = head;
  if(newNode->next != NULL){
    newNode->next->prev = newNode;
  }
  cout<<"Inserted "<<value<<" before node "<<searchValue<<endl;
}
void traverseFromFront(node *listHead){
  node* head = listHead;
  cout<<"Traversal from head:\t";
  while(head != NULL){
    cout<<head->data<<"\t";
    head = head->next;
  }
  cout<<endl;
}
void traverseFromEnd(node *listHead){
  node* head = listHead;
  cout<<"Traversal from tail:\t";
  while(head->next != NULL){
    head = head->next;
  }
  node *tail = head;
  while(tail != NULL){
    cout<<tail->data<<"\t";
    tail = tail->prev;
  }
  cout<<endl;
}
void searchAndDelete(node **listHead, int searchItem){
  node* head = (*listHead);
  while(head != NULL && head->data != searchItem){
    head = head->next;
  }
  if(*listHead == NULL || head == NULL) return;
  if((*listHead)->data == head->data){
    *listHead = head->next;
  }
  if(head->next != NULL){
    head->next->prev = head->prev;
  }
  if(head->prev != NULL){
    head->prev->next = head->next;
  }
  free(head);
  cout<<"Deleted Node\t"<<searchItem<<endl;
}
int main(){
  node *head = NULL;
  insertFront(head, 5);
  insertFront(head, 6);
  insertFront(head, 7);
  insertEnd(head, 9);
  insertEnd(head, 10);
  insertAfter(head, 5, 11);
  insertBefore(head, 5, 20);
  traverseFromFront(head);
  traverseFromEnd(head);
  searchAndDelete(&head, 7);
  traverseFromFront(head);
  traverseFromEnd(head);
}

Вихід

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

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

Нижче наведено повний Python Реалізація двозв'язаного списку з використанням класів для вузлів та самого списку.

class Node:
  def __init__(self, data=None, prev=None, next=None):
    self.data = data
    self.next = next
    self.prev = prev
class DoublyLinkedList:
  def __init__(self):
    self.head = None
  def insertFront(self, val):
    newNode = Node(data=val)
    newNode.next = self.head
    if self.head is not None:
      self.head.prev = newNode
    self.head = newNode
    print("Added {} at the front".format(val))
  def insertEnd(self, val):
    newNode = Node(data=val)
    if self.head is None:
      self.head = newNode
      print("Added {} at the end".format(val))
      return
    temp = self.head
    while temp.next is not None:
      temp = temp.next
    temp.next = newNode
    newNode.prev = temp
    print("Added {} at the end".format(val))
  def traverseFromFront(self):
    temp = self.head
    print("Traversing from head:\t", end="")
    while temp is not None:
      print("{}\t".format(temp.data), end="")
      temp = temp.next
    print()
  def traverseFromEnd(self):
    temp = self.head
    print("Traversing from tail:\t", end="")
    while temp.next is not None:
      temp = temp.next
    tail = temp
    while tail is not None:
      print("{}\t".format(tail.data), end="")
      tail = tail.prev
    print()
  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
    newNode.prev = temp
    if newNode.next is not None:
      newNode.next.prev = newNode
    print("Inserted {} after node {}".format(value, 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
    newNode.prev = temp
    if newNode.next is not None:
      newNode.next.prev = newNode
    print("Inserted {} before node {}".format(value, searchItem))
  def searchAndDelete(self, searchItem):
    temp = self.head
    while temp is not None and temp.data != searchItem:
      temp = temp.next
    if self.head is None or temp is None:
      return
    if self.head.data == temp.data:
      self.head = temp.next
    if temp.next is not None:
      temp.next.prev = temp.prev
    if temp.prev is not None:
      temp.prev.next = temp.next
    print("Deleted Node\t{}".format(searchItem))
doublyLinkedList = DoublyLinkedList()
doublyLinkedList.insertFront(5)
doublyLinkedList.insertFront(6)
doublyLinkedList.insertFront(7)
doublyLinkedList.insertEnd(9)
doublyLinkedList.insertEnd(10)
doublyLinkedList.insertAfter(5, 11)
doublyLinkedList.insertBefore(5, 20)
doublyLinkedList.traverseFromFront()
doublyLinkedList.traverseFromEnd()
doublyLinkedList.searchAndDelete(7)
doublyLinkedList.traverseFromFront()
doublyLinkedList.traverseFromEnd()

Вихід

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

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

Часову складність зазвичай поділяють на три типи: найкращий випадок, середній випадок та найгірший випадок.

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

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

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

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

Найгірша часова складність двозв'язаного списку така ж, як і в середньому випадку.

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

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

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

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

  • Кеш LRU: Кеші Least-Recently-Used використовують подвійно зв'язаний список з хеш-картою для O(1) переміщення на початок та вилучення.
  • Історія браузера: Навігація назад і вперед дозволяє переміщатися по зв'язаному списку в будь-якому напрямку.
  • Скасування та повторення стеків: Редактори та IDE track версій документів з покажчиками на попередній та наступний.
  • Деке: DoubleЧерги з кінцями надсилають та виштовхують дані з обох кінців за час O(1).
  • Музичні плейлисти: Попередній та наступний tracКнопки k залежать від вказівників назад та вперед.

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

Подвійно зв'язані списки повертають кеші LRU, що використовуються в конвеєрах пакетного глибокого навчання та інтерфейсах векторного зберігання, дозволяючи системам штучного інтелекту переміщувати нещодавно отримані тензори в головний блок за час O(1) для швидкого повторного використання.

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

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

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

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

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

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

Так. Круговий двозв'язаний список з'єднує вказівник на наступний елемент (next) з елементом "голова" та вказівник на попередній елемент (prev) з елементом "голова" з елементом "хвоста". Ця структура використовується в циклічному плануванні та буферних кільцях.

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