Понимание стуктур данных помогает во многих аспектах программирования. Не нужно использовать какую-то определенную реализацию, в котором бы вы использовали дерево или графы. Скажу больше, не думаю, что правильно реализовывал структуры данных за мои 8 лет опыта программирования, однако понимание их работы помогало мне в некоторых кейсах.
Понимание работы структур данных, их поведении можно использовать и в во многих областях. К примеру, нам нужно описать архитектуру на основанную на микросервисах, как ее описать? Теперь посмотрим на следующее изображение: Надеюсь, ты понимаешь к чему я клоню мармеладка :D Даже взвешенный граф можно рассматривать как представление архитектуры. При этом учитывать вес ребер (к примеру, как время обращения одного сервиса к другому). То же самое можно сказать и о других структурах.
Примечание: некоторые из этих структур могут быть легко представлены массивами, но вам нужно посмотреть немного дальше, чтобы понять всю их мощь. Структуры данных - это не только способ структурирования данных, но и связанная с ней логика. То, как вы вставляете данные, что с ними происходит внутри, и даже то, как вы извлекаете данные из своей структуры. В этом заключается настоящая магия структур данных и весь смысл их существования. В противном случае мы бы не парились и использовали массивы.
Предположим, вам нужно обработать список задач в порядке их внесения, для решения поставленной проблемы подойдут очереди. Очереди - это простая структура, в которую вы можете вносить данные и извлекать их в соответствии с подходом FIFO: First In First Out. Другими словами, это структура данных похожа на очередь в KFC. Если человек пришел раньше, соответственно обслужат его быстрее.
Как видно из диаграммы выше, сама структура и реализация просты, особенно если вы используете массивы в качестве базовой структуры данных.
class Queue {
data = []
maxSize
constructor(initialData, maxSize = -1) {
this.data = Array.isArray(initialData) ? initialData : (typeof initialData == "undefined" ? [] : [initialData])
this.maxSize = maxSize
}
isFull() {
return this.maxSize != -1 ? (this.data.length == this.maxSize) : false
}
isEmpty() {
return this.data.length == 0
}
enqueue(item) {
if(this.isFull()) {
return false
}
this.data.push(item)
}
*generator() {
while(!this.isEmpty()) {
yield this.data.shift()
}
}
dequeue() {
const { value, done } = this.generator().next()
if(done) return false
return value
}
}
Два основных метода из этой реализации - это методы enqueue и dequeue. С помощью первого вы можете добавлять элементы в очередь, а с помощью последнего - удалять.
Как видите, я выбрал массив для базовой структуры данных, потому что он значительно упрощает оба метода.
Постановка в очередь - это то же самое, что вставка элемента в массив, а исключение из очереди решается простым вызовом shift, который удаляет первый элемент и также возвращает его.
Функция генератора была добавлена в качестве дополнительной вишенки, позволяющей выполнять операции, подобные показанной ниже:
let q = new Queue(3, 2)
// Без генератора
q.enqueue(1)
q.enqueue(2) //ignored...
// С генератором
let x = 0
while(x = q.dequeue()) {
console.log(x)
}
Помимо простой очереди, которую я вам здесь показываю, вы также можете найти:
Я пиши о стэке после очереди, потому что они похожи, настолько, что иногда их путают друг с другом.
Вы можете думать об этой структуре данных как о стопке книг, если вы начнете класть одну поверх другой, а когда вы захотите получить одну, вам придется удалить все книги с нее. Этот способ обработки данных известен как FILO или First In Last Out.
Стеки полезны для изменение порядка в списке, так как вы можете перемещаться по списку от первого до последнего и сохранять каждый элемент в стек, затем вы начнете их извлекать (вынимая их по шаблону FILO) и последний из них будет первым элементом исходного списка.
Еще один интересный вариант использования, который мы видели бесчисленное количество раз: возможность отменить действие. Если вы хотите что-то отменить, вы либо пытаетесь отменить последнее действие, либо пытаетесь отменить несколько действий.
class Operation {
constructor(val) {
this.value = val
}
}
class Add extends Operation {
apply(value) {
return value + this.value
}
undo(value) {
return value - this.value
}
}
class Times extends Operation {
apply(value) {
return value * this.value
}
undo(value) {
return value / this.value
}
}
class Stack {
constructor() {
this.value = 0
this.operations = new Stack()
}
add(op) {
this.value = op.apply(this.value)
this.operations.add(op)
}
undo() {
if(this.operations.isEmpty()) {
return false
}
this.value = (this.operations.pop()).undo(this.value)
}
}
Вы можете увидеть, как мы используем собственный стек. Операции представлены в виде классов, каждый из которых имеет два метода:
В нашем настраиваемом стеке также есть метод отмены, который использует структуру стека, в которой хранятся все операции, и может отменять их эффекты одну за другой.
И, как вы догадались, реализация стека почти идентична классу очереди из предыдущего:
class Stack {
data = []
maxSize
constructor(initialData, maxSize = -1) {
this.data = Array.isArray(initialData) ? initialData : (typeof initialData == "undefined" ? [] : [initialData])
this.maxSize = maxSize
}
isFull() {
return this.maxSize != -1 ? (this.data.length == this.maxSize) : false
}
isEmpty() {
return this.data.length == 0
}
add(item) {
if(this.isFull()) {
return false
}
this.data.push(item)
}
*generator() {
while(!this.isEmpty()) {
yield this.data.pop()
}
}
pop() {
const { value, done } = this.generator().next()
if(done) return false
return value
}
}
Вы можете заметить единственную разницу? Проверьте 27 строку. К счастью для нас, класс Array из JavaScript также предоставляет нам метод pop, который делает то же самое: он удаляет последний добавленный элемент в массив и возвращает его.
Стеки и очереди - это круто, но давайте посмотрим на что-то более сложное, но при этом более мощное и универсальное.
За деревьями стоит много теории... Я не буду вдаваться в подробности, потому что теория есть в интернете, просто загляните в Википедию или одну из многих книг, посвященных дереьям. Вместо этого я дам вам короткую версию.
Дерево - это рекурсивно определенная структура, образованная родительским узлом и несколькими дочерними узлами, связанными с одним родительским. Как я уже сказал, это рекурсивное определение в том смысле, что каждый из этих дочерних узлов может одновременно быть родительскими узлами для других дочерних узлов.
![Tree]/images/2020-11-30-stuctures-3.png)
На приведенной выше диаграмме показано, как растет дерево, есть несколько интересных фактов:
Основные определения используемые в деревьях:
А из диаграммы ниже мы также можем определить еще несколько вещей:
При этом существует несколько различных типов деревьев в зависимости от логики, связанной с вставкой и извлечением данных из структуры:
Посмотрите на диаграмму выше, если вы напечатаете значение каждого узла, когда дойдете до него, вы получите: A-> B-> C-> D-> E. Порядок узлов определяется методом DFS.
Хочу отметить, что направление обхода можно менять (идти справа налево). Этот алгоритм может использоваться и с графами.
Я знаю, это звучит так, будто это очень похоже, но позвольте мне показать вам пример того, как вы реализуете двоичное дерево поиска (один из моих любимых видов деревьев), чтобы увидеть, насколько они не такие сложные и на самом деле очень полезны.
Ниже приведена реализация бинарного дерева
class BinaryTreeNode {
constructor(value) {
this.value = value
this.left_child = null
this.right_child = null
}
compare(v) {
if(this.value > v) return -1
if(this.value == v) return 0
if(this.value < v) return 1
}
}
module.exports = class BST {
constructor() {
this.root_node = null
}
add(elem) {
if(!this.root_node) {
this.root_node = new BinaryTreeNode(elem)
return
}
let inserted = false
let currentNode = this.root_node
do {
let comp = currentNode.compare(elem)
if(comp == -1) {
if(!currentNode.left_child) {
currentNode.left_child = new BinaryTreeNode(elem)
inserted = true
} else {
currentNode = currentNode.left_child
}
}
if(comp != -1) {
if(!currentNode.right_child) {
currentNode.right_child = new BinaryTreeNode(elem)
inserted = true
} else {
currentNode = currentNode.right_child
}
}
} while (!inserted)
}
inorder(parent) {
if(parent) {
this.inorder(parent.left_child)
console.log(parent.value)
this.inorder(parent.right_child)
}
}
print() {
this.inorder(this.root_node)
}
}
Узлы в графе в отличие от деревьев неограничены (узел может быть соеденен с любым другим и в том числе с самим собой).
Графы универсальны! Их можно использовать для представления практически любого сценария, в котором объекты связаны друг с другом. Сценарии использования варьируются от макетов сети до архитектур на основе микросервисов, реальных карт...
Графы настолько практичная структура данных, что существуют даже движки СУБД основанные на концепции графов.
Ниже приведена реализация графов на скорую руку в JavaScript, включая метод обхода с поиском в глубину.
class Node {
constructor(value) {
this.value = value
this.links = []
}
linkTo(node, weight) {
this.links.push(new Link(this, weight, node))
}
}
class Link {
constructor(a, weight, b) {
this.left = a;
this.weight = weight
this.right = b
}
}
class Graph {
constructor(root_node) {
this.root_node = root_node
this.dfs_visited = new Set();
}
dfs(starting_node) {
if(!starting_node) starting_node = this.root_node
let node = starting_node
console.log(node.value);
this.dfs_visited.add(node);
node.links.forEach( neighbour => {
if (!this.dfs_visited.has(neighbour.right)) {
this.dfs(neighbour.right);
}
})
}
}
Из примера выше мы видим, что каждый узел (Node) имеет свой набор ссылок на другие узлы. При этом мы получаем взвешенный граф, то есть помимо ссылки на узел мы еще задаем вес (вес, к примеру, может указывать на задержку между узлами в ЛВС). Метод dfs позабоится об обходе графа, убедившись в том, что мы посетили каждый узел графа только один раз.
Пример использования:
// Объявление узлов
let A = new Node("A")
let B = new Node("B")
let C = new Node("C")
let D = new Node("D")
let E = new Node("E")
let F = new Node("F")
let G = new Node("G")
// Объявление связей узлов
A.linkTo(B, 1)
A.linkTo(C, 2)
B.linkTo(D, 1)
C.linkTo(E, 10)
D.linkTo(E, 10)
D.linkTo(F, 1)
D.linkTo(G, 1)
G.linkTo(G, 1)
let g = new Graph(A)
// Обход графа
g.dfs()
Результат будет следующмим:
A
B
D
E
F
G
C
Другими словами, каждый узел посещался только один раз. С помощью грфаов вы можете делать более интересные вещи, например, реализовать алгоритм Дейкстры, чтобы найти кратчайший путь между двумя узлами, или реализовать нейронную сеть.
Последняя структура данных, о которой я расскажу в этой статье - это Hash Map. Она позволяет вам хранить пары ключ-значение и довольно быстро извлекать их (она имеет порядок сложности O(1) в лучшем случае, что круто!).
Давайте представим, у вас есть 300 пар ключ-значение, которые вам нужно сохранить в памяти, вы можете легко прибегнуть к массиву для этого. Если вам нужно получить конкретный элемент, вам нужно будет обойти весь массив в поисках его (это будет наихудшим случаем, учитывая, что элемент, который вы ищете, является последним). В случае с 300 элементами - это не проблема, но если бы это был вариант использования, когда вам нужно будет искать один элемент в списке из 1 000 000 элементов? Это займет некоторое время, и если базовое число продолжает расти, массивы в этом случае становятся все менее и менее полезными.
Попробуем теперь рассмотреть в качестве альтернативы Hash Map. Hash Map позволяют вам создать структуру, которую вы можете использовать, где ключ может использоваться для быстрого доступа к значению в постоянное время. В JavaScript реализовать Hash Map значительно проще, учитывая, что у нас есть объектные литералы, которые мы можем использовать для добавления случайных свойств (то есть наших ключей). Вот быстрая реализация Hash Map, которая позволяет использовать числовые ключи.
class HashMap {
constructor() {
this.map = {}
}
hash(k) {
return k % 10
}
add(key, value) {
let k = this.hash(key)
if(!this.map[k]) {
this.map[k] = []
}
this.map[k].push(value)
}
get(key) {
let k = this.hash(key)
return this.map[k]
}
}
let h = new HashMap()
h.add(10, "hello")
h.add(100001, "world")
h.add(1, "this is a string")
console.log(h)
Результат выполнения скрипта будет следующий:
HashMap {
map: { '0': [ 'hello' ], '1': [ 'world', 'this is a string' ] }
}
Обратите внимание, как "world" и "this is a string" связаны с одним и тем же ключом, это так называемый хэш-конфликт. В данном случае hash метод вычисляет ключ используя операцию остаточно деления на 10 для гарантирантированного хранения до 10 ключей. Это реализация полезна, если у вас ограниченный объем памяти. Реализуете метода хеширования во многом определяет эффективность hash map'a.
При правильной реализации эта структура настолько эффективна, что широко используется при индексация базы данных (обычно вы устанавливаете поля в качестве индексов, когда вам нужна операция быстрого поиска) и даже при реализации кеширования, позволяя использовать быстрый поиск для извлечения кешированного контента. Как вы догадались это структура достаточно эффективна, если вы хотите иметь быстрый и повторяющийся поиск.
Понимание структур данных, независимо от того, используете ли вы их в повседневных задачах или нет, очень важно. Оно открывает вам глаза на известные шаблоны и дает на них взглянуть под иным углом.
Можете поиграться с приведенными выше реализациями. Попробуйте создать свою собственную реализацию. Какой из них ваш любимый структура данных? Оставьте комментарий внизу и поделитесь им со товарищами!