- Двусвязный список
- Вставка узла в начало списка
- Вставка узла в конец списка
- Удаление узла
- Перебор значений в прямом порядке
- Выводы
Вы уже знакомы с односвязным списком. Эта структура данных позволяет быстро вставлять и удалять элементы. Звучит удобно, но такой подход работает не во всех случаях. В этом уроке вы познакомитесь с двусвязным списком, который лучше подходит для некоторых типичных задач в программировании.
Есть несколько задач, для которых односвязный список подходит не очень хорошо. В частности, он несимметричен — если вставка и удаление в начале списка выполняются за константное время , то в конце — за линейное . Если в списке будет тысяча элементов, вставка в начало может оказаться в тысячу раз быстрее, чем вставка в конец.
Другая задача, которую трудно решить с помощью односвязного списка — вставка перед заданным узлом. Предположим, у нас есть текст на русском языке, где каждый элемент — либо отдельное слово, либо знак препинания. В тексте могут быть ошибки: например, могут отсутствовать запятые и точки. Мы предполагаем, что если слово начинается с большой буквы, перед ним должна быть точка. Если в тексте есть союзы «но» и «а», перед ними должна стоять запятая.
Расссмотрим такой пример:
«Его» «пример» «другим» «наука» «но» «,» «боже» «мой» «,» «какая» «скука» «с» «больным» «сидеть» «и» «день» «и» «ночь» «,» «не» «отходя» «ни» «шагу» «прочь» «Какое» «низкое» «коварство» «полуживого» «забавлять» «ему» «подушки» «поправлять» «,» «печально» «подносить» «лекарство»
В этом тексте не хватает запятой перед словом «но» и точки перед словом «Какое». Попробуем решить эту задачу с помощью односвязного списка. Нам нужно:
-
Поместить слова в односвязный список
-
Найти слово «но»
-
Попробовать вставить перед ним запятую
Здесь мы сталкиваемся с тем, что в узле нет ссылки на предыдущий элемент, только на следующий:
Односвязный список устроен так, что для вставки запятой между словами «наука» и «но» нужно модифицировать именно узел со словом «наука». Эти детали делают наш возможный алгоритм сложным и медленным.
Для подобных задач лучше использовать списки, в котором хранятся обе ссылки.
Двусвязный список
В каждом узле двусвязного списка хранится две ссылки — на следующий и на предыдущий узел. Кроме того, в нем хранятся ссылки и на голову списка (первый элемент), и на его хвост (последний элемент):
Как и в случае с односвязным списком, нам приходится особым образом хранить ссылку на следующий узел для последнего узла. Там мы помещаем значение 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;
}
}
}
https://replit.com/@hexlet/js-double-linked-list#index.js
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
https://replit.com/@hexlet/python-double-linked-list#main.py
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;
}
}
}
https://replit.com/@hexlet/php-double-linked-list#main.php
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;
}
}
}
https://replit.com/@hexlet/java-double-linked-list#src/main/java/DoublyLinkedList.java
Разберем этот фрагмент кода подробнее.
Двусвязный список, как и односвязный, требует определения двух классов:
Первый класс — DoublyLinkedListNode
. Он описывает узел двусвязного списка и состоит из таких компонентов:
-
Значения —
value
-
Ссылки на предыдущий узел —
previous
-
Ссылки на следующий узел —
next
Второй класс — DoublyLinkedList
. Он представляет список целиком, вместе с его операциями-алгоритмами. Там находятся:
-
Ссылка на первый узел —
head
-
Ссылка на последний узел —
tail
-
Различные методы, например:
-
insertBegin(value)
— вставка в начало -
insertEnd(value)
— вставка в конец -
removeBegin()
— удаление из начала
-
Новый список пуст, поэтому поля head
и tail
содержат значение null
:
После вставки первого узла head
и tail
содержат его адрес. При этом поля previous
и next
у этого узла никуда не указывают, потому что он одновременно является первым и последним в списке — другими словами, у него нет ни предыдущего, ни следующего узла:
Теперь посмотрим на фрагмент кода:
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 должен появиться адрес новой головы:
Это довольно сложная логика, которая требует аккуратной реализации и проверки граничных условий. Поэтому код методов двусвязного списка сложнее, чем код методов односвязного. Посмотрите на этот пример:
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 (const 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<Object> 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(self):
current = self.head
while current is not None:
yield current.value
current = current.next
PHP
<?php
public function fore()
{
$current = $this->head;
while($current !== null) {
yield $current->value;
$current = $current->next;
}
}
Использовать итератор можно так:
const 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 DoublyLinkedList();
list.insertBegin(1);
list.insertBegin(2);
list.insertBegin(3);
list.insertBegin(4);
System.out.println(App.sum(list)); // => 10
В JavaScript используется синтаксис function*
и yield
, который упрощает работу с итераторами. В нашем примере порядок действий такой:
-
Начинаем с первого узла, адрес которого хранится в поле head
-
Пробегаем по всем узлам списка
-
Передаем значения узлов в вызывающую функцию с помощью конструкции
yield
Выводы
-
Односвязный список подходит не для всех задач — он предоставляет только последовательный доступ к последующим элементам
-
Чтобы справиться с этими трудностями, программисты используют такую структуру данных, как двусвязный список.
-
В каждом узле двусвязного списка хранится не только ссылка на следующий узел (как у односвязного), но и на предыдущий.
-
К плюсам двусвязного списка можно отнести возможность «пробегать» по списку как вперед, так и назад, а к минусам — сложный код и больший расход памяти.
-
Итераторы позволяют писать один код для работы с коллекциями разных типов. Мы можем реализовать итераторы для тех структур данных, которые мы разрабатываем.
Остались вопросы? Задайте их в разделе «Обсуждение»
Вам ответят команда поддержки Хекслета или другие студенты
Для полного доступа к курсу нужен базовый план
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.