Развитие языка JavaScript постепенно переносит всю тяжесть вычислений с одного сервера на сеть пользовательских компьютеров. Это супер-хорошо. Программирование на стороне сервера вынуждает очень тщательно оптимизировать код по быстродействию и занимаемой памяти, в это же время разработка клиентской части несколько отстает.
Часто для удобного и быстрого кодирования применяют специализированные структуры данных. Именно так и поступают при разработке на Java:
https://habr.com/ru/post/237043/
А вот для подобной работы с JavaScript оптимизированных инструментов по умолчанию не предоставляется. Реализация Array(), Set() и Map() перекладывается на плечи разработчиков браузерных движков, а все их разработки на сегодняшний день загнались хэш-таблицы с удвоением размера и копированием :
https://habr.com/ru/company/ruvds/blog/518032/
Зададимся вопросом — а что если требуются уже прямо сейчас действительно оптимальные по производительности и памяти структуры данных. Какой минимальный набор таких структур достаточно реализовать и поддерживать? Один из вариантов ответа — это сделать двунаправленный связный список и сбалансированное дерево поиска.
Что это нам даст?
Реализуя связный список LinkedList мы получаем сразу список, двунаправленную очередь и стек. И если это сделать без JavaScript Array(), а лишь используя простые ссылки на объекты, то получаем действительно оптимальную структуру данных.
Если же сделать бинарное сбалансированное дерево поиска TreeMap, например идеально сбалансированное AVL-дерево:
https://habr.com/ru/post/150732/
тогда используя его можно смоделировать следующие структуры данных:
1) Array = {TreeMap.get(i) , TreeMap.set(i, value), TreeMap.keys()} — динамический массив любого размера, правда операция получить список ключей TreeMap.keys() выполняется за O(nlog(n)). Вообще в теоретическом массиве каждое удаление элемента должно сопровождаться сдвигом оставшегося хвоста элементов, но на практике просто оставляют пробелы. Если непрерывность индексации массива вам непринципиальна (некоторые элементы удалены), а важен лишь перебор и порядок - то AVL-TreeMap хорошо итерируется с помощью next(key) и previous(key) или можно обойти дерево рекурсивно в порядке возрастания или убывания ключей inOrder(). Кроме того AVL-дерево может выдать нам отсортированный по ключам массив. Одним словом выходит нечто похожее на PHP-array, только с асимптотикой O(log(n)), хотя O(1) от хэш-таблиц - это очень прикольно:
https://habr.com/ru/post/162685/
2) Set = TreeMap.set(element, counter) — множество уникальных элементов.
3) Map = TreeMap.set(key, value) — стандартная структура типа ключ-значение. Перебор всех элементов в цикле лучше делать через функции next(key) и previous(key) или использовать стандартные обходы деревьев preOrder(), inOrder(), postOrder():
https://habr.com/ru/post/144850/
Глубина рекурсии в AVL-дереве порядка log(n).
4) MinHeap и MaxHeap — минимальная и максимальная кучи, они же очереди с приоритетами. Сложность O(log(n)) - этого достаточно для нормальной работы.
5) Matrix = TreeMap.get("i_j") и Tensor = TreeMap.get("i_j_k"): простые операции преобразования индексов в строку и обратно ([i, j, k] = "i_j_k") дают нам оптимальные по занимаемой памяти многомерные матрицы, и можно забыть о проблеме разреженности матриц.
6) List — теоретически структуру можно получить если использовать в качестве ключей дробные числа, тогда вставка элемента между i и j будет такая: TreeMap.set((i+j)/2, element)
7) SearchTree — ну и собственно дерево поиска, тоже нам пригодится.
Все операции в TreeMap достаточно оптимальны, порядка O(log(n)).
Вот минимально необходимый интерфейс структуры TreeMap:
/* интерфейс */
class TreeMap {
tree;
constructor() {
//интерфейс можно дополнять своими функциями - он отделен от реализации:
this.tree = new AVLTree();
}
get(key) {...}
set(key, value) {...}
delete(key) {...}
getMinKey() {...}
popMinKey() {...}
getMaxKey() {...}
popMaxKey() {...}
next(key) {...} //следующий элемент (с ключом большим чем key)
previous(key) {...} //предыдущий элемент (с ключом меньше чем key)
has(key) {...}
keys() {...}
toArray() {...}
/**
* Универсальный рекурсивный обход дерева: через все комбинации [узел, правое поддерево, левое поддерево]
* @param {function} func - callback функция
* @param {type} params - параметры функции
* @param {string} order - порядок обхода дерева
* @returns {params} - возвращает переданные параметры возможно модифицированные
*/
traverse(func, params, order) {...}
clear() {...}
}
/* реализация */
class AVLTree {...}
Исходные коды доступны по ссылке, лицензия свободная:
git clone https://gitflic.ru/project/neutrino/js-collections.git
Комментарии (13)
Saiv46
26.09.2021 16:47+1В статье нету ответа на один вопрос: Зачем?
Сейчас Array, Set, Map и WeakMap/WeakSet достаточно оптимизированы.
ermouth
26.09.2021 20:06+1Смотря для чего использовать. Это сразу видно из сравнения (консоль Хрома, Макось, 4.2ГГц х86):
for (var l1 = [], i = 0; i<100000; i++) i%2?l1.unshift(i):l1.push(i);
– примерно 160мсfor (var l0 = new LinkedList(), i= 0; i<100000; i++) i%2?l0.addFirst(i):l0.addLast(i);
– примерно 35мс
Причём если увеличить к-во итераций в 10 раз, для списка это займёт в 10 раз больше времени, а вот для массива – уже в 100.JustDont
27.09.2021 10:22+1Так вы что с чем сравниваете? Это и так заведомо известно, что реализация LL (самая примитивнейшая даже) будет быстрее на вставку с краев, чем массив.
Alexandroppolus
27.09.2021 10:53Насчёт вставки в конец массива нельзя сказать наверняка. Если там ещё есть резерв, то массив может и побыстрее быть. Хотя с js-ными "массивами" ничего не понятно.
Saiv46
27.09.2021 11:49Можно почитать v8.dev, там рассказывают какие оптимизации принимают и какой результат даёт. Из постов блога ясно, что
push
будет выполняться очень быстро на массиве:состоящий из однородных элементов;
без "дыр" (среди массива ничего не удаляли);
И желательно в цикле, дабы JIT соизволил оптимизировать данный участок кода.
Alexandroppolus
27.09.2021 12:33Ага, спасибо. Ну собственно, всё как при использовании массива для стека. Сейчас запустил пример @ermouth с массивом, 196 мс. Если поменять unshift на push, то 4 мс (для миллиона - 16мс, или 120мс, если вставлять вновь созданные объекты). Пример с LinkedList не трогал (оно не копипастится без номеров строк), но думаю будет не меньше 40мс на 100 тыщах. Так что конец массива прям намного лучше списка.
anonymous
00.00.0000 00:00anudas
27.09.2021 13:27-2github для опенсорса уже не айс. есть другие места, но и не облако яндекса, конечно же
aleks_raiden
27.09.2021 19:01+1хорошая шутка :)
anudas
27.09.2021 23:07-2Почему шутка? У каждой нормальной оперсорсной группировки есть свой гибхаб
с блэкджеком...на своем серваке и, например, на опенсорсной gitea
Alexandroppolus
26.09.2021 18:49+2LinkedList дает неплохую очередь, потому что на массиве она очевидно хреновая. Впрочем, тут вполне подойдет "гибридный" вариант в виде списка массивов - это более экономно. Ну и лучше кольцевой буфер, если максимальное количество известно. Стек лучше делать на массиве.
Насчет дерева - использовать его как массив дурацкая идея. Да и для приоритетной очереди есть варианты лучше. В остальном годная вещь.
idneutrino24 Автор
27.09.2021 01:39+1Замечания учитываются и это отражается на тексте статьи. Она корректируется для улучшенного понимания предложенного решения.
Carduelis
А не могли бы вы сравнить производительность данного решения с текущими реализациями в
nodejs
,webkit
,gecko
,blink
?Возьмем, например, пример из жизни: таблица с 10к строками, данные в которой нужно отфильтровать на клиенте.
Конечно, на таких малых значениях использование альтернативных структур данных избыточно. Но в каких случаях это оправданно?
Спасибо!