Развитие языка 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)


  1. Carduelis
    26.09.2021 16:42

    А не могли бы вы сравнить производительность данного решения с текущими реализациями в nodejs, webkit, gecko,blink?

    Возьмем, например, пример из жизни: таблица с 10к строками, данные в которой нужно отфильтровать на клиенте.

    Конечно, на таких малых значениях использование альтернативных структур данных избыточно. Но в каких случаях это оправданно?

    Спасибо!


  1. Saiv46
    26.09.2021 16:47
    +1

    В статье нету ответа на один вопрос: Зачем?

    Сейчас Array, Set, Map и WeakMap/WeakSet достаточно оптимизированы.


    1. 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.


      1. JustDont
        27.09.2021 10:22
        +1

        Так вы что с чем сравниваете? Это и так заведомо известно, что реализация LL (самая примитивнейшая даже) будет быстрее на вставку с краев, чем массив.


        1. Alexandroppolus
          27.09.2021 10:53

          Насчёт вставки в конец массива нельзя сказать наверняка. Если там ещё есть резерв, то массив может и побыстрее быть. Хотя с js-ными "массивами" ничего не понятно.


          1. Saiv46
            27.09.2021 11:49

            Можно почитать v8.dev, там рассказывают какие оптимизации принимают и какой результат даёт. Из постов блога ясно, что push будет выполняться очень быстро на массиве:

            • состоящий из однородных элементов;

            • без "дыр" (среди массива ничего не удаляли);

            • И желательно в цикле, дабы JIT соизволил оптимизировать данный участок кода.


            1. Alexandroppolus
              27.09.2021 12:33

              Ага, спасибо. Ну собственно, всё как при использовании массива для стека. Сейчас запустил пример @ermouth с массивом, 196 мс. Если поменять unshift на push, то 4 мс (для миллиона - 16мс, или 120мс, если вставлять вновь созданные объекты). Пример с LinkedList не трогал (оно не копипастится без номеров строк), но думаю будет не меньше 40мс на 100 тыщах. Так что конец массива прям намного лучше списка.


  1. anonymous
    00.00.0000 00:00


    1. anudas
      27.09.2021 13:27
      -2

      github для опенсорса уже не айс. есть другие места, но и не облако яндекса, конечно же


      1. aleks_raiden
        27.09.2021 19:01
        +1

        хорошая шутка :)


        1. anudas
          27.09.2021 23:07
          -2

          Почему шутка? У каждой нормальной оперсорсной группировки есть свой гибхаб с блэкджеком... на своем серваке и, например, на опенсорсной gitea


  1. Alexandroppolus
    26.09.2021 18:49
    +2

    LinkedList дает неплохую очередь, потому что на массиве она очевидно хреновая. Впрочем, тут вполне подойдет "гибридный" вариант в виде списка массивов - это более экономно. Ну и лучше кольцевой буфер, если максимальное количество известно. Стек лучше делать на массиве.

    Насчет дерева - использовать его как массив дурацкая идея. Да и для приоритетной очереди есть варианты лучше. В остальном годная вещь.


  1. idneutrino24 Автор
    27.09.2021 01:39
    +1

    Замечания учитываются и это отражается на тексте статьи. Она корректируется для улучшенного понимания предложенного решения.