Привет, Хабр!

Эта статья написана для новичков, которые только начинают осваивать структуры данных на Python. Сегодня мы рассмотрим замечательную и очень полезную структуру — двусвязный список.

Двусвязный список — это структура данных, в которой каждый элемент содержит ссылки как на предыдущий, так и на следующий элементы, что позволяет легко перемещаться в обоих направлениях. В отличие от того же односвязного списка, двусвязный дает более гибкое управление данными.

Начнем с основ: разберемся, как они работают, где их реально стоит применять и как реализовать двусвязный список с нуля (да, на время забудем про библиотеку collections и её deque).

Начнем с основ: создание узлов

Для начала нужно понять, что такое узел в контексте двусвязного списка. Это основная единица, которая хранит данные и ссылки на предыдущий и следующий узлы.

class Node:
    def __init__(self, data):
        self.data = data
        self.prev = None  # Ссылка на предыдущий элемент
        self.next = None  # Ссылка на следующий элемент

Узел в двух строчках кода. Он хранит данные и знает, где его соседние элементы. Конечно, это пока довольно базовая структура.

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

Итак, мы уже реализовали узел, который будет хранить данные и ссылки на соседние элементы, но сам по себе он мало что может. Теперь нужен контейнер, который будет управлять этими узлами — это и есть сам двусвязный список. Список должен уметь добавлять новые элементы, удалять их, обходить все узлы, а главное — обеспечивать возможность легко перемещаться как вперёд, так и назад.

Для начала создадим самую простую заготовку двусвязного списка:

class DoublyLinkedList:
    def __init__(self):
        self.head = None  # Начальный (первый) узел
        self.tail = None  # Конечный (последний) узел

Это базовая структура, и на данный момент наш двусвязный список пуст — он не содержит никаких элементов, а указатели head и tail указывают на None.

Добавляем элементы в конец и начало списка

Добавление в конец списка

Метод append добавляет новый узел в конец двусвязного списка. Создаем новый узел и проверяем, пуст ли список. Если да, то новый элемент станет и головой, и хвостом. Если же список уже содержит элементы, то:

  1. Текущий хвост tail списка должен указать на новый узел.

  2. Новый узел должен узнать о текущем хвосте как о своём предыдущем узле.

  3. Хвост списка должен обновиться на новый узел.

def append(self, data):
    new_node = Node(data)
    if self.head is None:  # Если список пуст
        self.head = self.tail = new_node  # Первый элемент — и голова, и хвост
    else:
        self.tail.next = new_node  # Связываем текущий хвост с новым узлом
        new_node.prev = self.tail  # Связываем новый узел с текущим хвостом
        self.tail = new_node  # Обновляем хвост

Добавление в начало списка

Метод prepend добавляет новый элемент в начало списка. Это практически зеркальная операция по сравнению с append, но здесь обновляем указатели для головы:

  1. Новый узел смотрит на текущую голову как на следующий элемент.

  2. Текущая голова должна знать, что у неё появился новый предшественник.

  3. Обновляем указатель на голову, чтобы он указывал на новый узел.

def prepend(self, data):
    new_node = Node(data)
    if self.head is None:  # Если список пуст
        self.head = self.tail = new_node  # Первый элемент — и голова, и хвост
    else:
        new_node.next = self.head  # Новый узел указывает на текущую голову
        self.head.prev = new_node  # Текущая голова знает, что перед ней новый узел
        self.head = new_node  # Обновляем голову

Ни в append, ни в prepend нет сложных манипуляций с элементами. Мы просто меняем ссылки у нескольких узлов. Это и есть одна из фич двусвязного списка: не нужно беспокоиться о сдвиге всего массива.

Удаление элементов

Теперь рассмотрим, как реализуется удаление узлов из двусвязного списка.

Как работает удаление?

  1. Мы начинаем с поиска элемента, который нужно удалить.

  2. Как только нашли его, нужно обновить указатели у соседних узлов:

    • Если это голова, то нужно обновить ссылку на следующий элемент и убедиться, что у нового первого элемента больше нет ссылки на удаленный узел.

    • Если это хвост, аналогично обновляем ссылку на предыдущий элемент.

    • Если элемент в середине, то его соседи должны перепрыгнуть через него, связавшись друг с другом.

Пример кода для удаления элемента:

def delete(self, data):
    if self.head is None:
        return  # Список пуст

    current = self.head
    while current:
        if current.data == data:
            # Если удаляемый элемент - это голова
            if current == self.head:
                self.head = current.next
                if self.head:
                    self.head.prev = None  # Обнуляем указатель на предыдущий узел у новой головы
            # Если удаляемый элемент - это хвост
            elif current == self.tail:
                self.tail = current.prev
                if self.tail:
                    self.tail.next = None  # Обнуляем указатель на следующий узел у нового хвоста
            # Если удаляемый элемент находится в середине
            else:
                current.prev.next = current.next
                current.next.prev = current.prev
            return
        current = current.next

Здесь проходим по списку, ищем элемент с нужным значением и обновляем указатели.

Обход списка

Для того чтобы убедиться, что список работает корректно, реализуем методы для обхода элементов как в прямом, так и в обратном порядке.

Прямой обход списка (от головы до хвоста):

def traverse(self):
    current = self.head
    while current:
        print(current.data, end=" ")
        current = current.next
    print()

Обратный обход списка (от хвоста к голове):

def reverse_traverse(self):
    current = self.tail
    while current:
        print(current.data, end=" ")
        current = current.prev
    print()

Пограничные случаи

При работе с двусвязными списками всегда нужно учитывать пограничные случаи:

  1. Пустой список: Когда вы пытаетесь удалить элемент из пустого списка, никакие операции не должны выполняться.

  2. Удаление последнего элемента: Если удаляется последний элемент, то как указатель на голову head, так и на хвост tail должны быть сброшены на None.

Эти ситуации можно легко обработать в методах добавления и удаления.

Метод поиска элементов

Можно реализовать метод для поиска элемента по значению, который возвращает сам узел.

def find(self, data):
    current = self.head
    while current:
        if current.data == data:
            return current
        current = current.next
    return None  # Элемент не найден

Этот метод проходит по всему списку и возвращает узел с нужным значением, если такой существует.

Пример использования

Операции undo и redo — это классический пример использования двусвязного списка. Допустим, есть текстовый редактор, где пользователь последовательно вносит изменения и хочет иметь возможность отменять и восстанавливать их. Двусвязный список позволяет легко перемещаться между состояниями "назад"и "вперёд", т.к каждый узел может ссылаться на предыдущие и следующие изменения.

class Node:
    def __init__(self, data):
        self.data = data
        self.prev = None
        self.next = None

class UndoRedo:
    def __init__(self):
        self.current = None  # Текущая версия текста

    def add_change(self, data):
        new_node = Node(data)
        if self.current is None:
            self.current = new_node  # Если это первое изменение
        else:
            self.current.next = new_node
            new_node.prev = self.current
            self.current = new_node  # Обновляем текущее состояние

    def undo(self):
        if self.current and self.current.prev:
            self.current = self.current.prev
        return self.current.data if self.current else None

    def redo(self):
        if self.current and self.current.next:
            self.current = self.current.next
        return self.current.data if self.current else None

# Пример использования:
editor = UndoRedo()
editor.add_change("Текст версии 1")
editor.add_change("Текст версии 2")
editor.add_change("Текст версии 3")

print(editor.undo())  # Текст версии 2
print(editor.undo())  # Текст версии 1
print(editor.redo())  # Текст версии 2

add_change() добавляет новые состояния текста.

undo() и redo() позволяют перемещаться между этими состояниями назад и вперед.


Всем новичкам в Pyrhon рекомендую обратить внимание на открытый урок по управлению зависимостями, на котором будут рассмотрены инструменты Pipenv и Poetry. Если актуально — записывайтесь по ссылке.

Комментарии (2)


  1. IisNuINu
    17.11.2024 20:13

    какие задачи решаются с помощью двусвязного списка? в каких алгоритмах он используется?


  1. alpha_man
    17.11.2024 20:13

    Спасибо за обзор решения сложных задач (нет). Жаль минуса ставить не могу... Уже не знают о чем бы написать лишь бы курсы свои говёные продвинуть