Возможно 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]
Теперь добавим узлу методы. Извлечение его свойств:
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.
Всем спасибо за внимание!