Что такое двусвязный список (Doubly Linked List)?

Ответ

Двусвязный список — это линейная структура данных, состоящая из узлов (nodes), где каждый узел содержит:

  1. Данные (payload).
  2. Указатель на следующий узел (next).
  3. Указатель на предыдущий узел (prev).

Наличие двух ссылок позволяет эффективно обходить список в обоих направлениях (вперёд и назад) и упрощает некоторые операции, такие как удаление произвольного узла, если известен только он сам, а не его предшественник.

Реализация узла и базовых операций на C++:

#include <iostream>

template <typename T>
class DoublyLinkedList {
private:
    struct Node {
        T data;
        Node* prev;
        Node* next;
        Node(const T& value, Node* p = nullptr, Node* n = nullptr)
            : data(value), prev(p), next(n) {}
    };
    Node* head_;
    Node* tail_;
    size_t size_;

public:
    DoublyLinkedList() : head_(nullptr), tail_(nullptr), size_(0) {}

    ~DoublyLinkedList() { clear(); }

    void push_back(const T& value) {
        Node* newNode = new Node(value, tail_, nullptr);
        if (tail_) {
            tail_->next = newNode;
        } else { // Список был пуст
            head_ = newNode;
        }
        tail_ = newNode;
        ++size_;
    }

    void erase(Node* node) {
        if (!node) return;
        // Корректируем связи соседних узлов
        if (node->prev) node->prev->next = node->next;
        if (node->next) node->next->prev = node->prev;
        // Корректируем head и tail, если удаляемый узел был ими
        if (node == head_) head_ = node->next;
        if (node == tail_) tail_ = node->prev;
        delete node;
        --size_;
    }

    void clear() {
        while (head_) {
            Node* toDelete = head_;
            head_ = head_->next;
            delete toDelete;
        }
        tail_ = nullptr;
        size_ = 0;
    }

    void printForward() const {
        for (Node* curr = head_; curr; curr = curr->next) {
            std::cout << curr->data << " ";
        }
        std::cout << std::endl;
    }

    void printBackward() const {
        for (Node* curr = tail_; curr; curr = curr->prev) {
            std::cout << curr->data << " ";
        }
        std::cout << std::endl;
    }
};

int main() {
    DoublyLinkedList<int> list;
    list.push_back(10);
    list.push_back(20);
    list.push_back(30);
    std::cout << "Forward:  "; list.printForward();  // 10 20 30
    std::cout << "Backward: "; list.printBackward(); // 30 20 10
    return 0;
}
Сравнение с односвязным списком: Операция Двусвязный список Односвязный список
Вставка в начало/конец O(1) O(1) (если есть tail) / O(1) для начала
Удаление известного узла O(1) O(n) для поиска предыдущего узла*
Удаление с конца O(1) O(n) для поиска предпоследнего
Обход назад O(1) на шаг Невозможно без дополнительной структуры
Потребление памяти Выше (2 указателя на узел) Ниже (1 указатель на узел)

*Удаление в односвязном списке за O(1) возможно, если скопировать данные из следующего узла в удаляемый и удалить следующий узел, но это не всегда применимо.

Применение в C++: Стандартный контейнер std::list реализован как двусвязный список.

Ответ 18+ 🔞

Давай разберём эту штуку, как будто объясняю за бутылкой пива после работы. Сидишь, слушаешь.

Представь себе поезд, обычный такой. Вагончик за вагончиком. Это односвязный список, ебать мои старые костыли. Ты сел в головной вагон и можешь ехать только вперёд, к хвосту. А назад? Назад нихуя, пешком иди, против движения. Неудобно, правда? Особенно если тебе надо из середины состава выйти, а проводник тебе говорит: «Чтобы из вагона №5 выйти, иди сначала в головной, а потом оттуда шагай, считая, пока до пятого не дойдёшь». Пиздец, да? Это удаление узла в односвязном списке.

А теперь двусвязный список — это поезд будущего, ёпта. В каждом вагоне две двери. Одна в следующий вагон, другая — в предыдущий. Захотел из пятого выйти — вышел нахуй, и всё. Двери в вагонах 4 и 6 сами закрываются, связь не теряется. Красота!

Вот смотри на этот код, тут всё по-честному:

struct Node {
    T data;           // Груз, пассажиры, данные — называй как хочешь.
    Node* prev;       // Дверь назад. Указатель на прошлый вагон.
    Node* next;       // Дверь вперёд. Указатель на следующий вагон.
};

Самое охуенное — операция erase. В односвязном списке, чтобы удалить узел, тебе надо знать его предыдущего, а иначе нихуя не получится, придётся искать с начала. А здесь? Чистая магия, доверия ебать ноль, но работает.

void erase(Node* node) {
    if (!node) return; // Если узла нет, чего суетиться-то?
    // Вот тут вся соль. Берём соседей и говорим им:
    if (node->prev) node->prev->next = node->next; // "Эй, парень сзади, твой следующий теперь — мой следующий, забудь про меня".
    if (node->next) node->next->prev = node->prev; // "Эй, парень спереди, твой предыдущий теперь — мой предыдущий, я сваливаю".
    // А если этот узел был головой или хвостом? Поправляем!
    if (node == head_) head_ = node->next;
    if (node == tail_) tail_ = node->prev;
    delete node; // Вагон — в утиль.
    --size_;
}

Сравнительная таблица, чтобы вообще ни хуя себе:

Что делаем Двусвязный список (наш красавец) Односвязный список (устаревший дед)
Впихнуть в начало/конец O(1) — быстро O(1) — тоже быстро
Удалить ЛЮБОЙ известный узел O(1) — ВЖИК И ГОТОВО! O(n) — иди ищи предка, полупидор
Удалить с конца O(1) — взял и отцепил хвост O(n) — иди до предпоследнего, бздун
Пойти назад O(1) — пожалуйста Вообще невозможно, хитрая жопа
Жрёт память Больше (две ссылки) Меньше (одна ссылка)

Вот и вся разница. Односвязный — как дешёвая машина без задней передачи. Двусвязный — полный привод и парктроники. В C++ std::list — это именно он, наш герой, двусвязный список. Потому что умные дядьки из комитета знают, что удалять элементы должно быть быстро, а не вот это вот всё «иди найди предыдущий».

Короче, если тебе нужно часто лазить туда-сюда и выковыривать элементы из середины — бери двусвязный. Если пишешь для микроконтроллера, где каждый байт на счету, и ходишь только вперёд — может, и односвязного хватит. Но вообще-то, в 99% случаев std::list твой выбор, и не еби себе мозг.