====== Двусвязный список — Основы алгоритмов и структур данных ====== Вы уже знакомы с односвязным списком. Эта структура данных позволяет быстро вставлять и удалять элементы. Звучит удобно, но такой подход работает не во всех случаях. В этом уроке вы познакомитесь с двусвязным списком, который лучше подходит для некоторых типичных задач в программировании. Есть несколько задач, для которых односвязный список подходит не очень хорошо. В частности, он несимметричен — если вставка и удаление в начале списка выполняются за константное время , то в конце — за линейное . Если в списке будет тысяча элементов, вставка в начало может оказаться в тысячу раз быстрее, чем вставка в конец. Другая задача, которую трудно решить с помощью односвязного списка — вставка перед заданным узлом. Предположим, у нас есть текст на русском языке, где каждый элемент — либо отдельное слово, либо знак препинания. В тексте могут быть ошибки: например, могут отсутствовать запятые и точки. Мы предполагаем, что если слово начинается с большой буквы, перед ним должна быть точка. Если в тексте есть союзы «но» и «а», перед ними должна стоять запятая. Расссмотрим такой пример: «Его» «пример» «другим» «наука» «но» «,» «боже» «мой» «,» «какая» «скука» «с» «больным» «сидеть» «и» «день» «и» «ночь» «,» «не» «отходя» «ни» «шагу» «прочь» «Какое» «низкое» «коварство» «полуживого» «забавлять» «ему» «подушки» «поправлять» «,» «печально» «подносить» «лекарство» В этом тексте не хватает запятой перед словом «но» и точки перед словом «Какое». Попробуем решить эту задачу с помощью односвязного списка. Нам нужно: * Поместить слова в односвязный список * Найти слово «но» * Попробовать вставить перед ним запятую Здесь мы сталкиваемся с тем, что в узле нет ссылки на предыдущий элемент, только на следующий: {{ :basics_of_algorithms:image_processing20231016-36-aaemuz.png?600 |}} Односвязный список устроен так, что для вставки запятой между словами «наука» и «но» нужно модифицировать именно узел со словом «наука». Эти детали делают наш возможный алгоритм сложным и медленным. Для подобных задач лучше использовать списки, в котором хранятся обе ссылки. ===== Двусвязный список ===== В каждом узле двусвязного списка хранится две ссылки — на следующий и на предыдущий узел. Кроме того, в нем хранятся ссылки и на голову списка (первый элемент), и на его хвост (последний элемент): {{ :basics_of_algorithms:image_processing20231016-27-u0327f.png?600 |}} Как и в случае с односвязным списком, нам приходится особым образом хранить ссылку на следующий узел для последнего узла. Там мы помещаем значение null — пустую ссылку, которая ни на что не указывает. На рисунке последний узел в списке — это D. У первого узла не может быть предыдущего узла, поэтому и здесь мы записываем null вместо ссылки. На рисунке первый узел в списке — это A. За счет изменения структуры, мы получаем две новые возможности: * Вставка и удаление в конце списка становятся настолько же быстрыми, как и в начале. Теперь они выполняются за константное время * Вставка узла перед заданным узлом становится такой же простой операцией, как и вставка после O(1) Конечно, есть и минусы. Во-первых, сама структура и код становятся сложнее. Во-вторых, структура теперь занимает больше памяти, поскольку в каждом узле хранится две ссылки, а не одна. ===== Вставка узла в начало списка ===== Посмотрим, как выглядит вставка узла в начало списка: 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 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: {{ :basics_of_algorithms:image_processing20231016-24-v3t3k9.png?300 |}} После вставки первого узла head и tail содержат его адрес. При этом поля previous и next у этого узла никуда не указывают, потому что он одновременно является первым и последним в списке — другими словами, у него нет ни предыдущего, ни следующего узла: {{ :basics_of_algorithms:image_processing20231016-39-n7fcvw.png?600 |}} Теперь посмотрим на фрагмент кода: 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 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 должен появиться адрес новой головы: {{ :basics_of_algorithms:image_processing20231016-39-mkvx9s.png?600 |}} Это довольно сложная логика, которая требует аккуратной реализации и проверки граничных условий. Поэтому код методов двусвязного списка сложнее, чем код методов односвязного. Посмотрите на этот пример: const node = new DoublyLinkedListNode(value, null, this.head);
Python node = DoublyLinkedListNode(value, None, self.head)
PHP head);
Java var node = new DoublyLinkedListNode(value, null, head);
Создавая узел, мы сразу записываем в поле next текущее значение this.head— текущую голову. Поле previous текущей головы должно ссылать на новый узел, за это отвечает такая строка: this.head.previous = node;
Python self.head.previous = node
PHP head->previous = $node;
Java head.previous = node;
Наконец, новый узел становится новой головой списка: this.head = node;
Python self.head = node
PHP 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 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 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 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 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 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 10
Java class App { public static int sum(Iterable 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 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 insertBegin(1); $list->insertBegin(2); $list->insertBegin(3); $list->insertBegin(4); print_r(sum($list->fore())); // => 10
Java class DoubleLinkedList implements Iterable { // ... @Override public Iterator iterator() { return new DoubleLinkedListIterator(); } private class DoubleLinkedListIterator implements Iterator { 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 ===== Выводы ===== * Односвязный список подходит не для всех задач — вставка и удаление в конец списка гораздо медленнее, чем вставка и удаление в начало. Кроме того, в односвязном списке нельзя вставить узел перед другим узлом. * Чтобы справиться с этими трудностями, программисты используют такую структуру данных, как двусвязный список. * В каждом узле двусвязного списка хранится не только ссылка на следующий узел (как у односвязного), но и на предыдущий. * К плюсам двусвязного списка можно отнести возможность «пробегать» по списку как вперед, так и назад, а к минусам — сложный код и больший расход памяти. * Итераторы позволяют писать один код для работы с коллекциями разных типов. Мы можем реализовать итераторы для тех структур данных, которые мы разрабатываем. ===== Примеры ===== **solutions/solution.js** Импортируйте в solution.js класс DoubleLinkedListNode и функцию getDoubleLinkedList() следующим образом: import DoubleLinkedListNode from '../double_linked_list/DoubleLinkedListNode.js'; import getDoubleLinkedList from '../helpers/helper.js'; Реализуйте функцию, которая принимает на вход массив и возвращает также массив. Внутри функция должна создавать двусвязный список, после этого поменять первые два элемента списка местами и вернуть массив всех значений. Для преобразования массива в список используйте функцию getDoubleLinkedList(), а чтобы получить массив из списка воспользуйтесь методом toArray() на объекте списка. Экспортируйте функцию по умолчанию. import solution from './solution.js'; const items = [[10, 20], 0, -1, ['hey']];
**solutions/solution.php** Импортируйте в solution.php код из файлов DoubleLinkedListNode.php и helper.php следующим образом: Реализуйте функцию solution(), которая принимает на вход массив и возвращает также массив. Внутри функция должна создавать двусвязный список, после этого поменять первые два элемента списка местами и вернуть массив всех значений. Для преобразования массива в список используйте функцию getDoubleLinkedList(), а чтобы получить массив из списка воспользуйтесь методом toArray() на объекте списка.
**solutions/solution.py** Импортируйте в solution.py класс DoubleLinkedListNode и функцию getListFromArray() следующим образом: import os import sys sys.path.append(os.path.abspath('/usr/src/app/')) from double_linked_list.DoubleLinkedListNode import DoubleLinkedListNode # noqa: E402 from helpers.helper import get_double_linked_list # noqa: E402 Реализуйте функцию solution(), которая принимает на вход список и возвращает также список. Внутри функция должна создавать двусвязный список, после этого поменять первые два элемента двусвязного списка местами и вернуть список всех значений. Для преобразования списка в двусвязный список используйте функцию get_double_linked_list(), а чтобы получить список из экземпляра двусвязного списка воспользуйтесь методом to_array() на объекте двусвязного списка. import solution from solution items = [[10, 20], 0, -1, ['hey']] solution(items) # [0, [10, 20], -1, ['hey']] solution([]) # []
**solutions/Solution.java** * Создайте в классе пакет solutions и определите в нем публичный класс Solution * Создайте в классе публичный статический метод swap(), который меняет местами первые два элемента двусвязного списка. Метод принимает в качестве параметра двусвязный список DoubleLinkedList, меняет местами первые два элемента и возвращает новый связный список import linkedlist.DoubleLinkedList; DoubleLinkedList list = new DoubleLinkedList(); list.append(1); list.append(2); list.append(3); list.append(4); list.toList(); // [1, 2, 3, 4] DoubleLinkedList swapped = Solution.swap(list); swapped.toList(); // [2, 1, 3, 4] * Добавьте в класс Solution метод run() со следующим кодом: public static java.util.List run(java.util.List coll) { return helpers.Helper.run(coll); }