Когда я увидела в разделе Easy LeetCode задачи со связными списками, сначала было недоумение: что это такое???? Я погуглила, но это не помогло, потому что мне не нужны были ответы вроде:
Почему связный список эффективнее, чем то-то и то-то?
Как хранит данные связный список?
И при чём тут вообще O(n)???
Мне хотелось найти подробное и понятное объяснение вот чего:
Связный список и список — это одно и то же?
Как им пользоваться? (Не супералгоритмы, а просто увидеть, что он хранит внутри.)
Почему в разговорах о связных списках всегда есть упоминание класса, который создаёт этот список?
Как создавать, как выводить и как вообще что-то с ними делать на уровне LeetCode Easy и на собеседованиях?
Поэтому, если вам тоже непонятно — погнали разбираться! ?
Почему разговор про связные списки начинают с классов
В Python нет такой структуры, как linked list, которую можно использовать напрямую, как, например, dict, list или deque (который, кстати, реализован на двусвязном списке).
То есть любая структура данных в Python — это класс. Часть из них уже реализована в стандартной библиотеке (чаще на языке C) и готова к использованию, а другие нужно сначала написать самостоятельно.
Поэтому, когда в Python заходит речь о связных списках, либо даётся готовый класс, который нужно использовать (как, например, в задаче, которую я привожу ниже), либо его требуется написать самостоятельно.
Разница между написанием класса самостоятельно и использованием готового варианта лишь в том, что нам нужно знать имена атрибутов класса, чтобы корректно работать с ним. Особенно это важно при автоматической проверке на платформе, куда мы отправляем задачу: если решение не соответствует ожиданиям, оно просто не запустится.
Теперь давайте посмотрим на пример реализации класса из одной из задач LeetCode (он даётся в прекоде к задаче 21. Merge Two Sorted Lists):
class LinkedList(object):
def __init__(self,val=0, next=None):
self.val = val
self.next = next
Здесь всего два атрибута: val и next. Этого вполне достаточно — в односвязных списках обычно используются именно они. Эти атрибуты могут называться по-разному (value, next_item), но принцип работы от этого не меняется, и в дальнейшем мы это рассмотрим.
Что такое связный список
Из предыдущего параграфа мы выяснили, что класс для связного списка нужен лишь потому, что эта структура не реализована в Python. Она и правда не широко используется в этом языке (почему — разбираться здесь не будем).
Начнём с того, что создадим объект LinkedList, используя вышеописанный класс.
# Ничего не передаем и смотрим что будет у этого объекта
my_list = LinkedList() # создали объект класса
# Смотрим какие атрибуты у этого объекта
print(my_list.val) # ожидаемый вывод 0
print(my_list.next) # ожидаемый вывод None
Разбор работы конструктора
Если при создании объекта ничего не передавать в качестве параметров, то по умолчанию присваиваются значения, указанные в методе __init__ класса LinkedList: def __init__(self,val=0, next=None)
Это означает, что при создании объекта связного списка без параметров мы получим один узел:
val — значение узла (по умолчанию 0).
next — ссылка на следующий узел (по умолчанию None).
Что можно хранить в val и next
В качестве val можно использовать любой объект: int, str, list, dict — что угодно.
Что должно быть в next — станет понятнее дальше.
Однако с этим классом пока ничего нельзя сделать: ни append, ни insert.
Теперь попробуем создать полноценный связный список из массива.
Создание связного списка из массива
Попробуем передать весь массив в качестве аргумента, чтобы убедиться, что это не работает:
|
print(my_list.val)
[1, 2, 3, 4, 5] # ожидаемый вывод
Когда мы вывели print(my_list), то увидели ссылку на объект — никаких ошибок!
Это говорит о том, что элементом в узле связного списка может быть что угодно, в том числе и массив.
Однако то, что в val хранится массив, не делает этот массив связным списком. Мы просто создали один узел, у которого в качестве значения (val) лежит сам массив.
Второй print показывает, что просмотреть содержимое узла можно только через атрибут val.
Теперь корректно создадим связный список вручную, передав в него все элементы по отдельности.
|
Теперь понятно, почему мы начинаем с конца:
чтобы у каждого узла next уже указывал на следующий готовый узел.
Связный список работает только тогда, когда в next передаётся объект класса LinkedList. Если же мы попробуем сделать это интуитивно неправильно, например:
|
И попытаемся получить доступ к последнему узлу, то получим исключение:
❌ Ошибка Возникло исключение:
AttributeError 'int' object has no attribute 'next' |
Почему возникает ошибка?
Мы положили в next число 5. Однако у int нет атрибута next, поэтому при попытке обратиться к нему программа падает с ошибкой.
Главное правило:
? В next всегда должен лежать объект LinkedList — тогда узлы смогут правильно "раскрываться" при вызове next, как матрёшки.
? Как просмотреть содержимое связного списка
Мы уже много говорили про next, и теперь посмотрим, как его использовать на практике.
Если мы просто выведем объект связного списка с print, то увидим, что это просто ссылка на объект, а не его содержимое:
|
? Чем LinkedList отличается от обычного списка? Связный список совсем не похож на обычный list в Python. Например, если мы выводим обычный список, всё просто:
# [1, 2, 3, 4, 5] |
Теперь выводим элементы связного списка
print(my_list.val) # Ожидаемый вывод: 1 |
У нас 5 элементов и логично 5 раз выводить print() чтобы посмотреть все элементы, так?
Наверно ты уже догадался что будет
print(my_list.val) print(my_list.val) print(my_list.val) print(my_list.val) 1 1 1 1 1 |
Мы 5 раз увидим вывод 1-го элемента
Чтобы увидеть все элементы, нужно каждый раз сдвигаться к следующему узлу через next:
print(my_list.val) # ожидаемый вывод 1 print(my_list.next.next.val) # ожидаемый вывод 3 |
Вуаля!
Но конечно это хардкод, и так делать стоит исключительно из любопытства и для демонстрации. Вот тут то и пригождается нам понятие голова списка (но об этом в другой статье)
Сейчас посмотрим как вывести связный список через цикл while
Вывод связного списка через while
Мы всегда начинаем проход связного списка с первого элемента.
Алгоритм:
1️⃣ Получаем значение узла через val.
2️⃣ Переходим к следующему узлу через next.
Если бы нам сразу дали последний узел, то next у него был бы None — значит, дальше двигаться некуда. (Мы разбираем только односвязный список, который умеет двигаться только вперёд.)
❓ Почему используем while, а не for?
В связном списке неизвестно заранее количество элементов.
Поэтому for не подходит, а while идеально вписывается в логику:
? Пока next не стал None, продолжаем движение по списку и выводим значения.
|
|
? Вуаля! Наш связный список распакован в массив
Теперь разберёмся, почему в условии остановки используется while my_list is not None
Как мы помним, в последнем узле next равен None, что означает, что за ним больше нет узлов.
Поскольку на каждой итерации мы обновляем my_list, присваивая ему следующий узел, на последнем шаге my_list становится None.
? Разбираем проход по списку шаг за шагом
1️⃣ Первая итерация
my_list = my_list # 1-й узел (val = 1, next = node2)
element = my_list.val # → 1
my_list = my_list.next # Переходим к следующему узлу (node2)
2️⃣ Вторая итерация
my_list = my_list # 2-й узел (val = 2, next = node3)
element = my_list.val # → 2
my_list = my_list.next # Переходим к следующему узлу (node3)
? Важно! Прошлые узлы уже не хранятся в my_list. После перехода my_list уже не содержит ссылку на node1, и связный список "укорачивается" в процессе прохода.
3️⃣ Третья итерация
my_list = my_list # 3-й узел (val = 3, next = node4)
element = my_list.val # → 3
my_list = my_list.next # Переход к node4
4️⃣ Четвёртая итерация
my_list = my_list # 4-й узел (val = 4, next = node5)
element = my_list.val # → 4
my_list = my_list.next # Переход к node5
5️⃣ Пятая итерация
my_list = my_list # 5-й узел (val = 5, next = None)
element = my_list.val # → 5
my_list = my_list.next # Переход к None
Теперь my_list = None, и мы подошли к концу списка.
6️⃣ Шестая итерация
while my_list is not None: # Условие не выполняется
Цикл останавливается, так как my_list стал None.
? Итог
✔ Мы прошлись по всем узлам, шаг за шагом обрабатывая их.
✔ Как только my_list = None, цикл while завершается.
Теперь стало понятно, как связный список "раскручивается" в процессе прохода! ?
На этой анимации видно как двигается начало связного списка на каждой итерации
Комментарии (7)
nickolaym
02.02.2025 16:44Кстати, "для непрограммистов" - это для кого именно?
Для математиков может жестоко зайти лямбда-исчисление Чёрча.
Пусть список (представленный как рекурсия конс-пар) - это некий загадочный объект x.
Над ним можно и нужно определить функцию проверки на пустоту IsNIL(x). А над непустым списком - функции Head(x) и Tail(x), возвращающие головной элемент и хвостовой список.
А также нам понадобятся два конструктора: константа NIL и конструктор пары Cons(head,tail).
Раз у нас есть булева функция, то нам нужны константы True и False.
А над ними нужна булева алгебра.
И вот вся эта красотища прекрасно упихивается в голое лямбда-исчисление - где ничего, кроме лямбда-функций, вообще нет.
(Пардон, мне лень сейчас пересказывать, как это делается. Погуглите, если сможете. Ну или в википедии: https://ru.wikipedia.org/wiki/Кодирование_Чёрча)
Kelbon
02.02.2025 16:44мне не нужны были ответы вроде:
-
Как хранит данные связный список?
и дальше
Мне хотелось найти подробное и понятное объяснение вот чего:
Как им пользоваться? (Не супералгоритмы, а просто увидеть, что он хранит внутри.)
так хотелось или не хотелось...
AV-BOLT Автор
02.02.2025 16:44Ошибок в синтаксе боюсь больше чем противоречий в моей мотивации)))
Хотелосьсмочь решать задачки на связный список ))
-
VoodooCat
02.02.2025 16:44Было бы лучше взять книжку, например, Н. Вирт "Алгоритмы + структуры данных = программы", прочитать её, реализовать тот же список и оставить свой восторженный отзыв о простой книге и пользе чтения, вместо вот этого вот всего.
AV-BOLT Автор
02.02.2025 16:44Я рада что выглядит так, что я находу сама разбиралась)) Но я уже разобралась и писала для тех кому нужно быстро понять что это такое и как этим пользоваться
Спасибо за книгу, думаю что желающие смогут воспользоваться
nickolaym
Чем писать статью о своём методе тыка на питоне, с ошибками, неудачными дублями и режиссёрскими версиями, - можно было бы просто спросить эти вопросы и получить внятные ответы. Поэтому статья улетела в минуса.
Итак, поехали.
Связный список и список — это одно и то же?
Как им пользоваться? (Не супералгоритмы, а просто увидеть, что он хранит внутри.)
Почему в разговорах о связных списках всегда есть упоминание класса, который создаёт этот список?
Как создавать, как выводить и как вообще что-то с ними делать на уровне LeetCode Easy и на собеседованиях?
Список - это абстракция данных. Некоторое представление цепочки элементов, неважно, как она устроена. Связный список - это реализация такой цепочки.
Питоний список, например, реализован как массив (вектор, по сиплюсплюсному), но позволяет работать с собой как с цепочкой: брать подцепочки, последовательно перебирать элементы, и всё такое. Какие-то из этих операций эффективны, какие-то нет.
Связный список - ещё со времён лиспа - определён как рекурсивная структура данных. Это пара (голова, хвост), где хвост также является списком. В самом конце (в самой глубине) там пустой список, специальное нулевое значение. (x1, (x2, (x3, (x4, nil)))).
Если смотреть на список как на граф узлов, то увидим лесенку: узел - пара (голова, хвост) указывает слева на элемент, а справа - на следующую пару. И в конце нульпоинтер какой-то. По указателям мы можем пробежать все узлы от начала до конца.
Важное свойство таких рассыпных структур данных - в том, что расположение элементов в памяти не зависит от структуры связей между ними. То есть, мы можем добавлять-удалять-менять содержимое и не переписывать и не перемещать все узлы и данные. В отличие от сплошных массивов, где любая вставка-удаление приводит к перемещению всех элементов от точки изменения до конца. O(1) против O(n).
(С другой стороны, хранение данных россыпью адски неэффективно: и большой расход памяти на служебную информацию, и промахи кеша при последовательном доступе).
Почему в разговорах о связных списках всегда есть упоминание класса? Ну, может быть, потому, что это особенности классово-ориентированных языков. (Даже не объектно-ориентированных, а их подмножества). Ещё со времён лиспа доказано, что всё, что нам нужно - это два служебных типа - нульпоинтер (NIL) и пара (CONS). И это позволяет построить абсолютно любые древовидные структуры данных (а список - это вырожденное дерево с одной длиннющей ветвью направо). Классы не нужны. Но с классами - капельку удобнее.
Даже на питоне можно то же самое делать. В качестве конс-пары взять кортеж из двух элементов, в качестве нила - штатный None. И когда я говнокодил на литкоде, то часто ленился объявлять класс.
Как создавать списки. Тут, как говорится, два путя.
Если список неизменяемый, то новый список легче всего создавать, добавляя новую голову: new_head + old_list = (new_head, old_list).
Если можем себе позволить изменять узлы, то
1) получаем указатель на последний узел - пару (последний элемент, NIL)
2) создаём новый узел (совсем последний элемент, NIL)
3) у последнего узла изменяем указатель "хвост" - переставляем его на этот новый узел
4) отдаём указатель на новый узел - чтобы в следующий раз пункт 1 выполнять не за линейное время, а то выйдет алгоритм маляра Шлемюэля.
Заметим, что такие важные свойства списка, как длина и последний элемент, не хранятся на самом верхнем уровне, то есть, в первом узле. А лишь могут быть вычислены путём забега по списку до конца.
Поэтому хорошая практика - это разнести типы "узел списка", образующие собственно цепочку, и "список как единое целое", владеющий узлами и хранящий указатель на головной узел, знающий длину, возможно, знающий последний узел. И не позволяющий никому со стороны влезать в узлы и менять их как попало. Добавил-убрал элемент - изменил длину. И тут появляется ещё один тип - "итератор списка", который знает и про объект списка, и про текущий узел. Это позволяет согласованно вносить изменения.
Но мутабельные списки - это вообще большущая головная боль. Поэтому опустим сейчас завесу жалости над этой сценой.
Как перебирать элементы списка. Элементарно, ватсон. Взяли указатель на головную пару. Взяли левый компонент этой пары и что-то с ним сделали. Переставили указатель на правый компонент этой пары - то есть, на следующую пару. И так далее до NIL. Можно рекурсивно, можно в цикле.
Как узнать длину списка. Пробежать до конца и посчитать.
AV-BOLT Автор
Не буду врать, дизлайки это больно)) Но когда объясняют за что дизлайк - становится попроще))
Теперь к сути:
Благодаря твоему комменту я поняла свою ошибку - плохое позиционирование, нужно было конкретнее описать для кого этот ликбез (а это и был ликбез)
Под непрограммистами я подразумевала аналитиков, которым тоже нужны алгоритмы на собесах, и моя цель была дать подробнейшее объяснение самых элементарных вещей, чтобы этобы было достаточно для того чтобы ответить что такое связные списки и сделать дальнейший разбор чуть проще
Но выглядит так, что реальным программистам стало больно от такой элементарщины)))
При написании я давала на экспертизу людям, кто в этом не разбирается и они мой референс - сделать это понятно им