Георгий Вицин пытается освободиться за O(1)
Георгий Вицин пытается освободиться за O(1)

Возможно 11 подписчиков моего блога обратили внимание на тот факт, что все мои статьи касаются языка Wolfram, а несколько последних статей вышли довольно громоздкими. Одна из последних статей была помечена Хабром как требующая в среднем 32 минуты на прочтение. Я посчитал, что это может отпугнуть пользователей (все 11 человек и не факт, что все они до сих пор читают Хабр) и решил попробовать писать более короткие статьи. Плюс я сам перестану теряться в большом количестве текста.

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

Ниже реализация структуры данных LinkedList на Wolfram Language.

О связанном списке

Не думаю, что это требует длинных объяснений, но если совсем коротко, то такая структура данных ценна тем, что:

  • Добавление в конец/начало за O(1)

  • Удаление из начала за O(1)

  • Поиск или получение по индексу за O(n) - это не очень ценно

  • Вставка в середину за O(1), но поиск-то долгий

  • Получение всех элементов за O(n) - как у большинства структур

Эта структура данных используется для реализации стека и очереди, так как она оптимизированно добавляет элементы в начало и конец. В Wolfram Language начиная с версии 12.1 есть такие структуры данных как связанный список и двунаправленный связанный список, а также стек, очередь, кольцевой буфер и некоторые другие. Но нас не интересует встроенная реализация, так как здесь клуб вело-любителей. Но если что, вот так создаются связанные списки в WL:

linkedList = CreateDataStructure["LinkedList"];
Do[linkedList["Append", i], {i, 1000}]
linkedList["Visualization"]
doublyLinkedList = CreateDataStructure["DoublyLinkedList"];
Do[doublyLinkedList["Append", i], {i, 1000}]
doublyLinkedList["Visualization"]
Визуализация связанного списка и двунаправленного связанного списка
Визуализация связанного списка и двунаправленного связанного списка

Узел списка

Что ж, приступим! В первую очередь реализуем не сам список целиком, а только один узел этого списка. Сначала легкая подготовка:

$HistoryLength = 0; 
ClearAll[LinkedListNode]; 
SetAttributes[LinkedListNode, HoldFirst]; 

HoldFirst - позволит держать первый аргумент невыполненным. В WL - это один из способов хранения "ссылки" на выражение, вместо самого выражения, т.е. значения. Теперь создаем "конструктор" для узла списка (на самом деле это просто функция):

LinkedListNode[ref_Symbol, value_, next_] := 
With[{uuid = CreateUUID["LINKEDLISTNODE-"]}, 
	If[Not[AssociationQ[ref]], ref = <|"Nodes" -> <||>|>]; 
	ref["Nodes", uuid] = <|
		"Value" -> value, 
		"Next" -> next
	|>; 
	LinkedListNode[ref, uuid]
]; 
  • ref - та самая "ссылка", в которой хранятся данные списка и узла. Если эта ссылка не является ассоциацией, то ей задается новое значение.

  • uuid - уникальный номер узла

  • value - значение

  • next - следующий узел

Вот пример узла:

ClearAll[ref]; 
node1 = LinkedListNode[ref, 1, Null]
(*LinkedListNode[ref, "LINKEDLISTNODE-ad484b44-803b-436f-966a-b359ac961235"]*)

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

ref
(*<|
  "Nodes" -> <|
    "LINKEDLISTNODE-ad484b44-803b-436f-966a-b359ac961235" -> <|
      "Value" -> 1, "Next" -> Null
    |>
  |>
|>*)

Обратите внимание, что символ ref имеет значение, но внутри выражения с заголовком LinkedListNode благодаря атрибуту оно все еще остается в виде невычисленного символа. Лучше понять смысл "удерживания" можно на простом примере:

x = 1; 

f[x]

SetAttributes[g, HoldFirst]

g[x]
Как работает HoldFirst
Как работает HoldFirst

Теперь добавим узлу методы. Извлечение его свойств:

LinkedListNode[ref_Symbol, uuid_String][key_] := 
ref["Nodes", uuid, key]

Уникального номера:

LinkedListNode[ref_Symbol, uuid_String]["UUID"] := 
uuid; 

И изменение значений свойств:

LinkedListNode /: Set[LinkedListNode[ref_Symbol, uuid_String][key_], value_] := 
ref["Nodes", uuid, key] = value; 

Выше мы по сути переопределяем "оператор присваивания", т.е. "=". Сделано это при помощи так называемого UpSet (obj /: func[obj] := expr[obj]), когда определение связывается не с функцией, а с самим объектом, на котором вызывается функция. Еще добавим метод связывания двух узлов:

LinkedListNode /: link[node_LinkedListNode, next_LinkedListNode] := (
	If[node["Next"] =!= Null, next["Next"] = node["Next"]]; 
	node["Next"] = next
); 

Сделаем так, что этот метод может объединить целый список узлов:

link[list_List] := Fold[link, First[list], Rest[list]]; 

Как это работает:

node2 = LinkedListNode[ref, 2, Null]; 
node3 = LinkedListNode[ref, 3, Null]; 
link[{node1, node2, node3}] (* => node3 *)

(* node1 -> node2 -> node3 *)

Пока что реализованных методов достаточно. Перейдем к реализации самого списка.

Реализация списка

Как обычно подготовка:

ClearAll[LinkedList]; 
SetAttributes[LinkedList, HoldFirst]; 

И конструктор списка. Особенность этой функции в том, что она проверяет "ссылку" и если она неподходящая, то меняет ее значение, а если подходящая то возвращает выражение как есть:

LinkedList[ref: _Symbol?(Not@*AssociationQ): Automatic] := 
Which[
	ref === Automatic, 
		With[{$ref = Unique["LINKEDLIST`$"]}, LinkedList[$ref]], 
	True, 
		Clear[ref]; 
		ref = <|"Nodes" -> <||>, "Size" -> 0, "Head" -> Null, "Tail" -> Null|>; 
		LinkedList[ref]
]; 

ref: _Symbol?(Not@*AssociationQ): Automatic слева направо делает следующее:

  • ref: _Symbol - значит аргумент должен иметь тип Symbol (т.е. ссылка)

  • ?(Not@*AssociationQ) - проверяет аргумент применяя функцию справа от знака вопроса

  • Not@*AssociationQ - композиция двух функций

  • : Automatic - после второго двоеточия значение по умолчанию. Интересно, что оно может не проходить проверку слева

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

LinkedList /: append[LinkedList[ref_Symbol], item_] := 
With[{node = LinkedListNode[ref, item, Null]}, 
	ref["Size"] = ref["Size"] + 1; 
	If[ref["Tail"] === Null, 
		ref["Head"] = node; 
		ref["Tail"] = node; , 
	(*Else*)
		link[ref["Tail"], node]; 
		ref["Tail"] = node; 
	]; 
]; 
LinkedList /: prepend[LinkedList[ref_Symbol], item_] := 
With[{node = LinkedListNode[ref, item, Null]}, 
	ref["Size"] = ref["Size"] + 1; 
	If[ref["Head"] === Null, 
		ref["Head"] = node; 
		ref["Tail"] = node; , 
	(*Else*)
		link[node, ref["Head"]]; 
		ref["Head"] = node; 
	]; 
]; 

Для обычных списков в WL есть функции First и Last, которые получают первый и последний элемент. Но мы для списка определим отдельные "свойства", которые получают голову и хвост:

LinkedList[ref_Symbol]["Head"] := 
If[ref["Size"] > 0, ref["Head"]["Value"]]; 
LinkedList[ref_Symbol]["Tail"] := 
If[ref["Size"] > 0, ref["Tail"]["Value"]]; 

Так же переопределим функцию, которая получает элемент по индексу (тот самый метод, который занимает O(n)):

LinkedList /: Part[LinkedList[ref_Symbol], i_Integer] /; Abs[i] <= ref["Size"]:= 
Block[{$node}, 
	Which[i === 0, LinkedList, 
		i === 1, ref["Head"], 
		i === -1, ref["Tail"], 
		i > 0, 
			$node = ref["Head"]; 
			Do[$node = $node["Next"], {j, 2, i}]; 
			$node, 
		i < 0, 
			$node = ref["Head"]; 
			Do[$node = $node["Next"], {j, -ref["Size"] + 1, i, 1}]; 
			$node
	]["Value"]
]; 

И удаление головы с возвращением узла. Здесь я создам функцию pop :

LinkedList /: pop[LinkedList[ref_Symbol]] := 
With[{head = ref["Head"]}, 
	ref["Head"] = head["Next"]; 
	head["Next"] = Null; 
	head["Value"]
]; 

И последнее, что нам потребуется - функция, которая возвращает все элементы связанного списка в виде обычного списка. Обычно для этих целей используют встроенную функцию Normal:

LinkedList /: Normal[LinkedList[ref_Symbol]] := 
Block[{node = ref["Head"], value}, 
    Table[
        value = node["Value"]; 
        node = node["Next"]; 
        value, 
        {i, 1, ref["Size"]}
    ]
]; 

Примеры

Минимальная реализация связанного списка готова. Создать его можно вот так:

linkedList = LinkedList[]

Затем его можно заполнить:

Do[append[linkedList, i], {i, 10}]
Do[prepend[linkedList, i], {i, 10}]

Удалить часть из начала:

Table[pop[linkedList], {5}]
(* {10, 9, 8, 7, 6} *)

Посмотреть содержимое:

Normal[linkedList]
(* {5, 4, 3, 2, 1, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10} *)

Производительность

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

linkedList = LinkedList[]; 
appendTime = Table[{i, AbsoluteTiming[append[linkedList, i]][[1]]}, {i, 1, 10000}]; 
linkedList = LinkedList[]; 
prependTime = Table[{i, AbsoluteTiming[prepend[linkedList, i]][[1]]}, {i, 1, 10000}]; 
linkedList = LinkedList[]; 
popTime = Table[{i, 
	append[linkedList, i]; 
	prepend[linkedList, i]; 
	AbsoluteTiming[pop[linkedList]][[1]]
}, {i, 1, 10000}]; 

И построим график зависимости времени операции от размера списка:

ListPlot[{appendTime, prependTime, popTime}, 
	ImageSize -> Large, 
	PlotTheme -> "Detailed", 
	PlotLegends -> {"append", "prepend", "pop"}
]
Время операции в зависимости от размера связанного списка
Время операции в зависимости от размера связанного списка

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

linkedList = LinkedList[]; 
partTime = Table[{i, 
	append[linkedList, i]; 
	index = RandomInteger[{1, First[linkedList]["Size"]}];
	AbsoluteTiming[linkedList[[index]]][[1]]
}, {i, 1, 2000}]; 
linkedList = LinkedList[]; 
normalTime = Table[{i, 
	append[linkedList, i]; 
	AbsoluteTiming[Normal[linkedList]][[1]]
}, {i, 1, 2000}]; 

И построим график:

ListPlot[{partTime, normalTime}, 
	ImageSize -> Large, 
	PlotTheme -> "Detailed", 
	PlotLegends -> {"part", "Normal"}
]
Время получения элемента по индексу и получения всех элеме��тов в зависимости от размера списка
Время получения элемента по индексу и получения всех элементов в зависимости от размера списка

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

Красивая плашка

И последняя совершенно необязательная часть. Сделаем так, чтобы в интерфейсе Mathematica наша структура данных красиво отображалась. Т.е. не в виде LinkedList[ref], а так, как отображаются другие сложные объекты. Например разреженный массив:

SparseArray[{{1, 1} -> 2, {100, 100} -> 101, {5, 66} -> 1243}]
Разреженный массив с тремя ненулевыми элементами
Разреженный массив с тремя ненулевыми элементами

Чтобы сделать нечто похожее - надо использовать внутреннюю функцию для SummaryBox :

$linkedListIcon = ImageResize[Import["https://wolfr.am/1jBx5PyAQ"], 1920 / 5]; 


LinkedList /: MakeBoxes[linkedList: LinkedList[ref_Symbol?AssociationQ], 
	form: (StandardForm | TraditionalForm)] := 
Module[{above, below}, 
	above = {
		{BoxForm`SummaryItem[{"Ref: ", Defer[ref]}], SpanFromLeft}, 
		{BoxForm`SummaryItem[{"Size: ", ref["Size"]}], SpanFromLeft}, 
		{BoxForm`SummaryItem[{"Head: ", linkedList["Head"]}], SpanFromLeft}, 
		{BoxForm`SummaryItem[{"Tail: ", linkedList["Tail"]}], SpanFromLeft}
	}; 
	below = {}; 
	
	(*Return*)
	BoxForm`ArrangeSummaryBox[
		LinkedList, 
		linkedList, 
		$linkedListIcon, 
		above, 
		below, 
		form, 
		"Interpretable" -> Automatic
	]
];
  • MakeBoxes - функция, которая переопределяет отображение некоторого выражения в интерфейсе Wolfram Mathematica

  • (StandardForm | TraditionalForm) - два альтернативных формата на которых сработает это отображение. Есть еще например FullForm - он не переопределен и покажет список "как есть"

  • BoxForm`ArrangeSummaryBox и BoxForm`SummaryItem - те самые внутренние функция для создания "плашек"

  • $linkedListIcon - иконка, которую я скачал прямо из интернета по короткой ссылке

Визуальное отображение связанного списка в блокноте
Визуальное отображение связанного списка в блокноте

Заключение

В этой статье я постарался показать каким образом можно самостоятельно реализовать некую структуру данных на Wolrfam Language. Я особенно хочу отметить то, как я использовал удерживающий атрибут HoldFirst, а так же конструкцию With для того чтобы в функции с HoldFirst передавать не ссылку а значение. Надеюсь вам статья показалась интересной! В следующий раз мы реализуем еще какую-нибудь структуру данных с использованием особенностей WL.

Всем спасибо за внимание!