Зарегистрируйтесь для доступа к 15+ бесплатным курсам по программированию с тренажером

Двусвязный список Основы алгоритмов и структур данных

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

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

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

Расссмотрим такой пример:

«Его» «пример» «другим» «наука» «но» «,» «боже» «мой» «,» «какая» «скука» «с» «больным» «сидеть» «и» «день» «и» «ночь» «,» «не» «отходя» «ни» «шагу» «прочь» «Какое» «низкое» «коварство» «полуживого» «забавлять» «ему» «подушки» «поправлять» «,» «печально» «подносить» «лекарство»

В этом тексте не хватает запятой перед словом «но» и точки перед словом «Какое». Попробуем решить эту задачу с помощью односвязного списка. Нам нужно:

  • Поместить слова в односвязный список

  • Найти слово «но»

  • Попробовать вставить перед ним запятую

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

eyJpZCI6Ijc3NjFkODFlM2UxMjExNGI5NTM2MDFjN2ZkZjI3N2FkLnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=060ebd90368e2758ddfa1abba2ba5e8eb6b5adb6b53ff554b8909002d5c6ec95

Односвязный список устроен так, что для вставки запятой между словами «наука» и «но» нужно модифицировать именно узел со словом «наука». Эти детали делают наш возможный алгоритм сложным и медленным.

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

Двусвязный список

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

eyJpZCI6ImYyMmMzMjJhNGNmY2QzMGQwNjZkYWRjNTg2MTliMWE1LnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=4a1ccd794b40e34abd5290c5d445a4d36149b69bfe858cb81f6613a593087036

Как и в случае с односвязным списком, нам приходится особым образом хранить ссылку на следующий узел для последнего узла. Там мы помещаем значение null — пустую ссылку, которая ни на что не указывает. На рисунке последний узел в списке — это D.

У первого узла не может быть предыдущего узла, поэтому и здесь мы записываем null вместо ссылки. На рисунке первый узел в списке — это A.

За счет изменения структуры, мы получаем две новые возможности:

  • Вставка и удаление в конце списка становятся настолько же быстрыми, как и в начале. Теперь они выполняются за константное время

  • Вставка узла перед заданным узлом становится такой же простой операцией, как и вставка после

Конечно, есть и минусы. Во-первых, сама структура и код становятся сложнее. Во-вторых, структура теперь занимает больше памяти, поскольку в каждом узле хранится две ссылки, а не одна.

Вставка узла в начало списка

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

class DoublyLinkedListNode {
  constructor(value, previous, next) {
    this.value = value;
    this.previous = previous;
    this.next = next;
  }
}

class DoublyLinkedList {
  head = null;
  tail = null;

  insertBegin(value) {
    if (this.head == null) {
      const node = new DoublyLinkedListNode(value, null, null);
      this.head = node;
      this.tail = node;
    } else {
      const node = new DoublyLinkedListNode(value, null, this.head);
      this.head.previous = node;
      this.head = node;
    }
  }
}
Python
class DoublyLinkedListNode:
    def __init__(self, value, previous, next):
        self.value = value
        self.previous = previous
        self.next = next

class DoublyLinkedList:
    head = None
    tail = None

    def insert_begin(self, value):
        if self.head is None:
            node = DoublyLinkedListNode(
                value, None, None
            )
            self.head = node
            self.tail = node
        else:
            node = DoublyLinkedListNode(
                value, None, self.head
            )
            self.head.previous = node
            self.head = node
PHP
<?php

class DoublyLinkedListNode
{
    public function __construct($value, $previous, $next)
    {
        $this->value = $value;
        $this->previous = $previous;
        $this->next = $next;
    }
}

class DoublyLinkedList
{
    public $head = null;
    public $tail = null;

    public function insertBegin($value)
    {
        if ($this->head == null) {
            $node = new DoublyLinkedListNode($value, null, null);
            $this->head = $node;
            $this->tail = $node;
        } else {
            $node = new DoublyLinkedListNode($value, null, $this->head);
            $this->head->previous = $node;
            $this->head = $node;
        }
    }
}
Java
class DoublyLinkedListNode {

    Object value;
    DoublyLinkedListNode previous;
    DoublyLinkedListNode next;

    DoublyLinkedListNode(Object value, DoublyLinkedListNode previous, DoublyLinkedListNode next) {
        this.value = value;
        this.previous = previous;
        this.next = next;
    }
}

class DoublyLinkedList {

    DoublyLinkedListNode head = null;
    DoublyLinkedListNode tail = null;

    public void insertBegin(Object value) {
        if (head == null) {
            var node = new DoublyLinkedListNode(value, null, null);
            head = node;
            tail = node;
        } else {
            var node = new DoublyLinkedListNode(value, null, head);
            head.previous = node;
            head = node;
        }
    }
}

Разберем этот фрагмент кода подробнее.

Двусвязный список, как и односвязный, требует определения двух классов:

Первый класс — DoublyLinkedListNode. Он описывает узел двусвязного списка и состоит из таких компонентов:

  • Значения — value

  • Ссылки на предыдущий узел — previous

  • Ссылки на следующий узел — next

Второй класс — DoublyLinkedList. Он представляет список целиком, вместе с его операциями-алгоритмами. Там находятся:

  • Ссылка на первый узел — head

  • Ссылка на последний узел — tail

  • Различные методы, например:

    • insertBegin(value) — вставка в начало

    • insertEnd(value) — вставка в конец

    • removeBegin() — удаление из начала

Новый список пуст, поэтому поля head и tail содержат значение null:

400

После вставки первого узла head и tail содержат его адрес. При этом поля previous и next у этого узла никуда не указывают, потому что он одновременно является первым и последним в списке — другими словами, у него нет ни предыдущего, ни следующего узла:

eyJpZCI6IjZiOWE3OTk2YTBmNTJlMTRiZWE3ODQ4YTE5M2Q4NGVmLnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=4fd754d96adc3d291cc8f60d87fa26f3d5eeae08a5dfd30319130cfeacd3ec23

Теперь посмотрим на фрагмент кода:

if (this.head == null) {
  const node = new DoublyLinkedListNode(value, null, null);
  this.head = node;
  this.tail = node;
}
Python
if self.head is None:
    node = DoublyLinkedListNode(
        value, None, None
    )
    self.head = node
    self.tail = node
PHP
<?php

if ($this->head == null) {
    $node = new DoublyLinkedListNode($value, null, null);
    $this->head = $node;
    $this->tail = $node;
}
Java
if (head == null) {
    var node = new DoublyLinkedListNode(value, null, null);
    head = node;
    tail = node;
}

Условие this.head == null выполняется для пустого списка. Нам достаточно создать узел с пустыми ссылками на предыдущий и следующий узлы, а затем присвоить его адрес полям this.head и this.tail.

При вставке каждого следующего узла в начало, head всегда будет указывать на новый узел. Значение tail при этом не изменится, потому что хвост списка остается прежним. Поле next новой головы списка будет указывать на прежнюю голову, а в поле previous старой головы вместо null должен появиться адрес новой головы:

eyJpZCI6IjVmYzRhMDhmMmUzNmIxYmQxNTYzZjcxNGU3YzVkZTM4LnBuZyIsInN0b3JhZ2UiOiJjYWNoZSJ9?signature=d447f3d77406fc546a89c78a2b17ece0f6a223fa485c0055d4a65533c8e30297

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

const node = new DoublyLinkedListNode(value, null, this.head);
Python
node = DoublyLinkedListNode(value, None, self.head)
PHP
<?php

$node = new DoublyLinkedListNode($value, null, $this->head);
Java
var node = new DoublyLinkedListNode(value, null, head);

Создавая узел, мы сразу записываем в поле next текущее значение this.head— текущую голову. Поле previous текущей головы должно ссылать на новый узел, за это отвечает такая строка:

this.head.previous = node;
Python
self.head.previous = node
PHP
<?php

$this->head->previous = $node;
Java
head.previous = node;

Наконец, новый узел становится новой головой списка:

this.head = node;
Python
self.head = node
PHP
<?php

$this->head= $node;
Java
head = node;

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

Перейдем к вставке узла в конец списка:

insertEnd(value) {
  if (this.tail == null) {
    const node = new DoublyLinkedListNode(value, null, null);
    this.tail = node;
    this.head = node;
  } else {
    const node = new DoublyLinkedListNode(value, this.tail, null);
    this.tail.next= node;
    this.tail = node;
  }
}
Python
def insert_end(self, value):
    if self.tail is None:
        node = DoublyLinkedListNode(value, None, None)
        self.tail = node
        self.head = node
    else:
        node = DoublyLinkedListNode(value, self.tail, None)
        self.tail.next = node
        self.tail = node
PHP
<?php

public function insertEnd($value)
{
    if ($this->tail == null) {
        $node = new DoublyLinkedListNode($value, null, null);
        $this->tail = $node;
        $this->head = $node;
    } else {
        $node = new DoublyLinkedListNode($value, $this->tail, null);
        $this->tail->next= $node;
        $this->tail = $node;
    }
}
Java
class DoublyLinkedList {
    // ...

    public void insertEnd(Object value) {
        if (tail == null) {
            var node = new DoublyLinkedListNode(value, null, null);
            tail = node;
            head = node;
        } else {
            var node = new DoublyLinkedListNode(value, tail, null);
            tail.next= node;
            tail = node;
        }
    }
}

Такая вставка симметрична вставке в начало. Разница только в том, что здесь мы должны везде менять местами head и tail, а также previous и next.

Удаление узла

Перейдем к операциям удаления:

removeBegin() {
  if (this.head == null) {
    return undefined;
  }

  const result = this.head.value;

  if (this.head == this.tail) {
    this.head = null;
    this.tail = null;
  } else {
    this.head = this.head.next;
    this.head.previous = null;
  }

  return result;
}
Python
def remove_begin(self):
    if self.head is None:
        return None

    result = self.head.value

    if self.head == self.tail:
        self.head - None
        self.tail = None
    else:
      self.head = self.head.next
      self.head.previous = None

    return result
PHP
<?php

public function removeBegin()
{
    if ($this->head == null) {
        return null;
    }

    $result = $this->head->value;

    if ($this->head == $this->tail) {
        $this->head = null;
        $this->tail = null;
    } else {
        $this->head = $this->head->next;
        $this->head->previous = null;
    }

    return $result;
}
Java
class DoublyLinkedList {
  // ...

    public Object removeBegin() {
        if (head == null) {
            return null;
        }

        var result = head.value;

        if (head == tail) {
            head = null;
            tail = null;
        } else {
            head = head.next;
            head.previous = null;
        }

        return result;
    }
}

Метод удаления возвращает значение из удаленного узла. Если список пуст и удалять нечего, метод возвращает undefined. В остальных случаях мы сохраняем значение в переменную result:

if (this.head == null) {
  return undefined;
}

const result = this.head.value;
Python
if self.head is None:
    return None

    result = self.head.value
PHP
<?php

if ($this->head == null) {
    return null;
}

$result = $this->head->value;
Java
if (head == null) {
    return null;
}

var result = head.value;

Если this.head == this.tail, значит, в списке находится один последний узел — он является одновременно и головой, и хвостом. Чтобы его удалить, достаточно обнулить head и tail:

if (this.head == this.tail) {
  this.head = null;
  this.tail = null;
}
Python
if self.head == self.tail:
    self.head - None
    self.tail = None
PHP
<?php

if ($this->head == $this->tail) {
    $this->head = null;
    $this->tail = null;
}
Java
if (head == tail) {
    head = null;
    tail = null;
}

А теперь посмотрим обратный пример — избавимся от первого узла в списке. Сначала записываем в head ссылку на второй узел, а потом обнуляем у нее поле previous:

this.head = this.head.next;
this.head.previous = null;
Python
self.head = self.head.next
self.head.previous = None
PHP
<?php

$this->head = $this->head->next;
$this->head->previous = null;
Java
head = head.next;
head.previous = null;

Перебор значений в прямом порядке

Раньше для работы с массивами, связными и двусвязными списками, программисты писали разный код. Например, если надо было просуммировать элементы массива и элементы связного списка, приходилось писать две похожие функции. Каждая из них складывала элементы коллекций, но доступ к этим элементам у массива и списка был разным.

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

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

Итератор — это объект, который одинаковым образом перебирает элементы коллекции, независимо от структуры данных. Скажем, мы можем написать функцию суммирования элементов любой коллекции и вызвать ее для списка:

const sum = (items) => {
  let result = 0;

  for (item of items) {
    result = result + item;
  }

  return result;
};

console.log(sum([1, 2, 3, 4])); // => 10
Python
def sum(items):
    result = 0
    for item in items:
        result = result + item

    return result

print(sum([1, 2, 3, 4]))  # => 10
PHP
<?php

function sum($items)
{
  $result = 0;

  foreach ($items as $item) {
    $result = $result + $item;
  }

  return $result;
};

print_r(sum([1, 2, 3, 4])); // => 10
Java
class App {
    public static int sum(Iterable<Integer> items) {
        var result = 0;

        for (var item: items) {
            result = result + (int) item;
        }

        return result;
    }
}

System.out.println(App.sum(List.of(1, 2, 3, 4))); // => 10

Эта функция сможет работать и с нашим двусвязным списком, но для этого нам потребуется реализовать собственный итератор.

Однако, здесь есть проблема. Массив и односвязный список имеют естественный порядок перебора — от начала к концу. В двусвязном списке поддерживаются два равноправных порядка:

  • От начала к концу

  • От конца к началу

Поэтому двусвязный список должен иметь два итератора — прямой и обратный. Чтобы так сделать, можно возвращать итераторы из методов класса. Например, метод fore() может создавать и возвращать прямой итератор:

fore() {
  let iterator = {
    current: this.head
  };

  iterator[Symbol.iterator] = function* () {
    while (this.current != null) {
      yield this.current.value;

      this.current = this.current.next;
    }
  };

  return iterator;
}
Python
def fore():
    current = self.head
    while current is not None:
        yield current.value
        current = current.next
PHP
<?php

public functon fore()
{
    $current = $this->head;

    while($current !== null) {
        yield $current->value
        $current = $current->next
    }
}

Использовать итератор можно так:

let list = new DoublyLinkedList();
list.insertBegin(1);
list.insertBegin(2);
list.insertBegin(3);
list.insertBegin(4);

console.log(sum(list.fore())); // => 10
Python
lst = DoublyLinkedList()
lst.insert_begin(1)
lst.insert_begin(2)
lst.insert_begin(3)
lst.insert_begin(4)

print(sum(lst.fore()))
PHP
<?php

$list = new DoublyLinkedList();
$list->insertBegin(1);
$list->insertBegin(2);
$list->insertBegin(3);
$list->insertBegin(4);

print_r(sum($list->fore())); // => 10
Java
class DoubleLinkedList implements Iterable<Object> {
  // ...

    @Override
    public Iterator<Object> iterator() {
        return new DoubleLinkedListIterator();
    }

    private class DoubleLinkedListIterator implements Iterator<Object> {
        DoubleLinkedListNode current = head;

        @Override
        public Object next() {
            if (!this.hasNext()) {
                throw new NoSuchElementException();
            }
            var lastReturnedNode = current;
            current = current.next;
            return lastReturnedNode.value;
        }

        @Override
        public boolean hasNext() {
            return current != null;
        }
    }
}

var list = new DoubleLinkedList();
list.insertBegin(1);
list.insertBegin(2);
list.insertBegin(3);
list.insertBegin(4);

System.out.println(App.sum(list)); // => 10

В JavaScript используется синтаксис function* и yield, который упрощает работу с итераторами. В нашем примере порядок действий такой:

  • Начинаем с первого узла, адрес которого хранится в поле head

  • Пробегаем по всем узлам списка

  • Передаем значения узлов в вызывающую функцию с помощью конструкции yield

Выводы

  • Односвязный список подходит не для всех задач — вставка и удаление в конец списка гораздо медленнее, чем вставка и удаление в начало. Кроме того, в односвязном списке нельзя вставить узел перед другим узлом.

  • Чтобы справиться с этими трудностями, программисты используют такую структуру данных, как двусвязный список.

  • В каждом узле двусвязного списка хранится не только ссылка на следующий узел (как у односвязного), но и на предыдущий.

  • К плюсам двусвязного списка можно отнести возможность «пробегать» по списку как вперед, так и назад, а к минусам — сложный код и больший расход памяти.

  • Итераторы позволяют писать один код для работы с коллекциями разных типов. Мы можем реализовать итераторы для тех структур данных, которые мы разрабатываем.


Аватары экспертов Хекслета

Остались вопросы? Задайте их в разделе «Обсуждение»

Вам ответят команда поддержки Хекслета или другие студенты.

Для полного доступа к курсу нужен базовый план

Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.

Получить доступ
1000
упражнений
2000+
часов теории
3200
тестов

Открыть доступ

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

  • 130 курсов, 2000+ часов теории
  • 1000 практических заданий в браузере
  • 360 000 студентов
Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»

Наши выпускники работают в компаниях:

Логотип компании Альфа Банк
Логотип компании Aviasales
Логотип компании Yandex
Логотип компании Tinkoff

Используйте Хекслет по-максимуму!

  • Задавайте вопросы по уроку
  • Проверяйте знания в квизах
  • Проходите практику прямо в браузере
  • Отслеживайте свой прогресс

Зарегистрируйтесь или войдите в свой аккаунт

Отправляя форму, вы принимаете «Соглашение об обработке персональных данных» и условия «Оферты», а также соглашаетесь с «Условиями использования»