Приветствую вас!
После изучения коллекций, а именно такие реализации List, как ArrayList и LinkedList, возникла идея, а почему бы не объединить эти структуры данных в одну и посмотреть, что из этого получится.


Зачем это нужно?


  • Проблема ArrayList — у него есть начальный размер по умолчанию DEFAULT_CAPACITY или заданный размер initialCapacity, при превышении этого размера, создается новый массив большего размера, при этом туда копируются данные из старого массива, что по времени очень затратно и именно это дает в наихудшем случае алгоритмическую сложность O(n)
  • Проблема LinkedList — здесь наоборот, добавить новый элемент, это всего лишь добавить новую связь (создать еще одну Node и добавить ссылку на неё), но операция получения элемента по индексу очень затратна, т.к. нужно будет пройтись по всему списку от начала, что очень затратно и дает O(n)

Решение


Что если создать такую структуру данных, при которой вставка и получение любого элемента будет за константное время. Буду использовать технологию ArrayList без пересоздания массива, что конечно же проигрывает по памяти, но выигрывает в скорости, т.к. память дешевая и её очень много, выигрыш в производительности считаю приоритетным.
Для того чтобы связать их между собой, буду использовать двусвязный список:


Реализация


Перейдем непосредственно к исходному коду:


public class Node<T> {
    Node prev;
    Node next;
    T[] value;

    public Node(Node prev, Node next, T[] value) {
        this.prev = prev;
        this.next = next;
        this.value = value;
    }
}

Для начало создадим стандартную структуру двусвязного списка, где value — это массив значений.
Далее перейдем к конкретной реализации класса, объявим необходимые переменные:


public static int INIT_CAPACITY = 100;
private Object[] arr = new Object[INIT_CAPACITY];
private int index = 0;
private int level = 0;
private Node<T> first = new Node<>(null, null, (T[]) arr);
private Node last = first;
private Node current = null;
private int size = 0;

Здесь INIT_CAPACITY — начальный размер массива, его можно переопределить в соответствующем конструкторе, arr — собственно сам массив, переменная index — понадобится для расчета индекса, level — для расчета уровня, далее подробно будет рассказано для чего это нужно, first — ссылка на первый элемент списка,
last — ссылка на последний элемент списка, current — ссылка на текущий элемент списка (последней выборки), так можно ускорить выборку подряд идущих элементов или близ — лежащих к ним, size — размер (или количество данных).
Зададим 2 коструктора — по умолчанию и для изменения начального размера:


public MyLinkedList() {
    first.next = last.next;
}
public MyLinkedList(int size) {
    INIT_CAPACITY = size;
    arr = new Object[INIT_CAPACITY];
    first.next = last.next;
}

Добавление элемента:


public void add(T i) {
    if (index == INIT_CAPACITY) {
        arr = new Object[INIT_CAPACITY];
        last.next = new Node<>(last, null, (T[]) arr);
        index = 0;
        last = last.next;
    }
    arr[index] = i;
    index++;
    size++;
}

Здесь проверяем условие, если массив заполнен, то создаем новый и запоминаем ссылку на него.
Получение элемента:


public T get(int i) {
    T value;
    int level = i / INIT_CAPACITY;
    int index = i % INIT_CAPACITY;
    if (this.current == null) {
        this.level = 0;
        this.current = first;
    }
    if(this.level > level)
        for (int j = this.level; j < level; j++) {
            this.level = level;
            current = current.prev;
        }
    else
        for (int j = this.level; j < level; j++) {
            this.level = level;
            current = current.next;
        }
    value = (T) current.value[index];
    return value;
}

Уровни это количество массивов в списке, т.е на 0-м уровне 1 массив, на 1-м уровне 2 массива и т.д.,index — это индекс текущего уровня 0..INIT_CAPACITY, также у нас есть ссылка на текущий элемент списка current, который был получен из предыдущей выборки, т.е. если новый уровень больше предыдущего, то идем вперед от текущего элемента и если наоборот, то назад. Также добавил 2 быстрые операции — получение первого и последнего элемента:


public T getFirst(){
    return first.value[0];
}
public T getLast() {
    return (T) last.value[(size-1) % INIT_CAPACITY];
}

Первый и последний элемент получить также просто и быстро, как в массиве.
Операция удаления последнего элемента — быстрее всего это затирать значение null-ом, если весь массив становится заполненным null-ми, то теряем ссылку на него и garbage collector все почистит:


public void removeLast(){
    if (last.value[0] == null) {
        last = last.prev;
        index=INIT_CAPACITY-1;
    }
    last.value[(size-1) % INIT_CAPACITY]=null;
    size--;
    index--;
}

Получение размера — очевидно. Также был добавлен итератор, т.е. этот класс имплементирует Iterable и реализует метод iterator


private Node<T> first;
private int index = -1;
public MyLinkedListIterator(Node<T> first) {
    this.first = first;
}

@Override
public boolean hasNext() {
   index++;
   return first != null;
}

@Override
public T next() {
    T value;
    int index = this.index % INIT_CAPACITY;
    value= first.value[index];
    if(index==INIT_CAPACITY-1||this.first.value[index+1]==null)
        first=first.next;
    return value;
}

Время работы


Возможно корректность способа замера оставляет желать лучшего, но делал это так:


long start = System.currentTimeMillis();
// операция add и операция get - с начала, конца и середины списка
// N - кол-во элементов
long finish = System.currentTimeMillis();
long time = finish - start;

Делал по 3 запуска и брал среднее, возьмем 100 тысяч элементов:


N=100000 Вставка в конец Получение первого Получение среднего Получение последнего
MyDeque 8 0 4 0
ArrayDeque 10 2 - 2
ArrayList 50 2 4 3
LinkedList 30 4 86214 4

Возьмем миллион элементов:


N=1000000 Вставка в конец Получение первого Получение среднего Получение последнего
MyDeque 203 2 26 7
ArrayDeque 262 11 - 15
ArrayList 340 13 17 12
LinkedList 486 23 >100000 25

И наконец, возьмем 10 миллионов элементов:


N=10000000 Вставка в конец Получение первого Получение среднего Получение последнего
MyDeque 2410 31 26 71
ArrayDeque 4628 322 - 111
ArrayList 4796 115 17 120
LinkedList OutOfMemoryError: Java heap space

Вывод



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


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


Проект можно посмотреть по ссылке

Комментарии (19)


  1. Regis
    05.04.2018 19:53
    +2

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

    Очень хотелось бы увидеть результаты, полученные с помощью него. Также было бы хорошо увидеть полученный бенчмарк в вашем репозитории, чтобы можно было легко его запустить у себя.


  1. aleksandy
    05.04.2018 20:08

    Если я правильно помню, подобным способом реализованы массивы в стандартной библиотеке scala.


    1. Optik
      06.04.2018 09:30

      Массивы в скалке реализованы никак. Юзается джавовый класс. Есть только свой имплиситный класс (читай враппер), дополняющий функциональность.


  1. alatushkin
    05.04.2018 21:05
    +6

    1)На первый взгляд заменив связанный список на связанный список массивов вы получили ровно сложность доступа по индексу O(n) что и у списка, выиграли (?) только «коэффициент». Это понятно и без реализации и в целом подтверждается вашими графиками
    2)Кроме того вы забыли об операции вставки (insert). Она может быть через увеличение размера конкретной «корзины» (а значит по «сложностям» мы возвращаемся к ArrayList), тогда надо переписать вашу реализацию get(idx) чтобы она учитывала переменный размер массивов в списке. Либо вставку можно реализовать через добавление-в-начале/удаление-в-конце для каждой из корзин после той в которую добавился элемент, что ясное дело сводит на нет всё возможное преимущество.

    В сухом остатке: предвижу, что в «реальном софте» в зависимости от профиля нагрузки ваше преимущество будет чуть более чем никакое. Да и в конечном итоге — внутри ArrayList — массив указателей на объекты. Лежат в памяти хорошим таким цельным понятным куском байт. Если еще и хип маленький, то с compressedOOPs — в 2 раза меньше. И вот это вот всё хозяйство нативный arraycopy через DMA и всё такое прочее сделает ну очень быстро. Гораздо быстрее чем ваша «очень умная логика».
    Да и + если где-то увеличение ArrayList станет узким местом — наверняка аккуратный выбор начального размера и инкремента по эффективности опять же сможет обойти ваше решение.


    1. vladimir_dolzhenko
      05.04.2018 22:17
      -2

      можно ещё добавить, что копирование имеет сложность не O(n), а скорее где-то близко O(log(N)) из-за векторизации копирования.


      1. Dim0v
        05.04.2018 22:27
        +3

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


      1. ARad
        05.04.2018 23:36
        +2

        Векторизация кода не улучшает оценки алгоритма.


        1. vladimir_dolzhenko
          06.04.2018 10:59
          -1

          видимо, оговорки надо делать более аккуратно — на типичных масштабах данных — так, чтобы не вывалится за Integer.MAX_VALUE, а скорее OOM — поведение O(n) с маленькой константой очень близко к поведению O(log(N)) на рассматриваемом отрезке.


          1. mayorovp
            06.04.2018 11:19
            +1

            Все-таки N и log N очень сильно отличаются. Все-таки log (Integer.MAX_VALUE) = 31, что на 8 порядков меньше этого самого Integer.MAX_VALUE.

            Векторизация копирования же при этом дает ускорение не более чем в 8 раз в лучшем случае (всего 1 порядок!)

            Куда больший эффект дает уменьшение количества операций косвенной адресации, и улучшение локальности при работе с памятью, особенно если памяти мало и используется своп. Но разницу между N и log N даже это не покрывает.


          1. Dim0v
            06.04.2018 11:43

            Во-первых, "на рассматриваемом отрезке" асимптотическая оценка алгоритма не имеет смысла. Если входные данные ограничены, то любой алгоритм формально работает за О(1).


            Во-вторых, на любом отрезке поведение O(N) ни разу не близко к поведению O(Log N). В первом случае увеличение инпута в 10 раз в те-же 10 раз увеличит и время выполнения, какой бы маленькой не была константа. Во втором же случае время исполнения увеличится всего лишь на некоторую константу.


            Ну и в третьих, Integer.MAX_VALUE — это довольно много. Даже для самого векторизированного линейного алгоритма время обработки такого объема входных данных на современных CPU будет измеряться сотнями миллисекунд, а то и секундами. В то время как самый дохлый и не оптимизированный логарифм отработает мгновенно.


            1. vladimir_dolzhenko
              06.04.2018 11:54

              Не спорю — когда дело дойдёт до того, что копирование будет занимать секунды — более заметные эффекты другого характера будут всплывать — и GC, и deref

              Согласен, одно дело асимптотика, а с другой стороны реальное поведение в ограниченном пространстве.


  1. ArsenAbakarov
    05.04.2018 21:06
    +1

    было бы неплохо получить графики пожирания памяти такой структурой


  1. Alek_roebuck
    05.04.2018 21:40

    Вроде как примерно таким образом обычно устроен std::deque в C++. Джавовский ArrayDeque устроен иначе.


  1. ARad
    05.04.2018 23:44
    +1

    Если нужно добавление и удаление только в начало, конец то да. А при вставке удалении произвольных будет чуток хуже чем ArrayList.


  1. nsinreal
    06.04.2018 01:15
    +1

    Вы переизобрели Unrolled Linked List?


  1. smer44
    06.04.2018 06:42

    Проблема ArrayList — у него есть начальный размер по умолчанию DEFAULT_CAPACITY или заданный размер initialCapacity, при превышении этого размера, создается новый массив большего размера, при этом туда копируются данные из старого массива, что по времени очень затратно и именно это дает в наихудшем случае алгоритмическую сложность O(n)

    проблема LinkedList это то что тратится память на обьекты в массив можно примитивных значений иметь.
    однажды в качестве теста я для избавления от копирования сделал немного иначе — просто массив из массивов, при наполнении одного создаётся другй без перекопирования, так оно работало хуже ArrayListа даже при ЗАПОЛНЕНИИ почему-то, так что мораль — нечего выдумывать.


  1. Deranged
    06.04.2018 16:23

    Trade off. За плоскую модель памяти приходится платить сложными алгоритмами. Ничего радикально нового тут особо не придумаешь.


  1. tapoton
    07.04.2018 08:07

    Я не из мира Java, но любопытство есть. Так что у меня такой вопрос: в том же методе get используются this.level, this.current. Вопрос потоконебезопасности даже при чтении не смущает в данном случае? В моем мире задача читателей/писателей довольно актуальна, и не иметь возможности корректно читать из нескольких потоков кажется странным.


  1. Blekel
    08.04.2018 12:31

    Такая идея закономерно приходит многим в самом начале изучения Java и коллекций. Но Вы все равно молодец, что прикрыв фиговым листочком random delete вышли на публику. ) ну и по ULL уже написали