Приветствую вас!
После изучения коллекций, а именно такие реализации 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)
alatushkin
05.04.2018 21:05+61)На первый взгляд заменив связанный список на связанный список массивов вы получили ровно сложность доступа по индексу O(n) что и у списка, выиграли (?) только «коэффициент». Это понятно и без реализации и в целом подтверждается вашими графиками
2)Кроме того вы забыли об операции вставки (insert). Она может быть через увеличение размера конкретной «корзины» (а значит по «сложностям» мы возвращаемся к ArrayList), тогда надо переписать вашу реализацию get(idx) чтобы она учитывала переменный размер массивов в списке. Либо вставку можно реализовать через добавление-в-начале/удаление-в-конце для каждой из корзин после той в которую добавился элемент, что ясное дело сводит на нет всё возможное преимущество.
В сухом остатке: предвижу, что в «реальном софте» в зависимости от профиля нагрузки ваше преимущество будет чуть более чем никакое. Да и в конечном итоге — внутри ArrayList — массив указателей на объекты. Лежат в памяти хорошим таким цельным понятным куском байт. Если еще и хип маленький, то с compressedOOPs — в 2 раза меньше. И вот это вот всё хозяйство нативный arraycopy через DMA и всё такое прочее сделает ну очень быстро. Гораздо быстрее чем ваша «очень умная логика».
Да и + если где-то увеличение ArrayList станет узким местом — наверняка аккуратный выбор начального размера и инкремента по эффективности опять же сможет обойти ваше решение.vladimir_dolzhenko
05.04.2018 22:17-2можно ещё добавить, что копирование имеет сложность не O(n), а скорее где-то близко O(log(N)) из-за векторизации копирования.
Dim0v
05.04.2018 22:27+3Векторизация в лучшем случае поделит время выполнения на константу. На асимпотику это никак не повлияет (если, конечно, не рассматривать какие-то гипотетические вычислительные устройства с неограниченным количеством SIMD потоков).
ARad
05.04.2018 23:36+2Векторизация кода не улучшает оценки алгоритма.
vladimir_dolzhenko
06.04.2018 10:59-1видимо, оговорки надо делать более аккуратно — на типичных масштабах данных — так, чтобы не вывалится за Integer.MAX_VALUE, а скорее OOM — поведение O(n) с маленькой константой очень близко к поведению O(log(N)) на рассматриваемом отрезке.
mayorovp
06.04.2018 11:19+1Все-таки N и log N очень сильно отличаются. Все-таки log (Integer.MAX_VALUE) = 31, что на 8 порядков меньше этого самого Integer.MAX_VALUE.
Векторизация копирования же при этом дает ускорение не более чем в 8 раз в лучшем случае (всего 1 порядок!)
Куда больший эффект дает уменьшение количества операций косвенной адресации, и улучшение локальности при работе с памятью, особенно если памяти мало и используется своп. Но разницу между N и log N даже это не покрывает.
Dim0v
06.04.2018 11:43Во-первых, "на рассматриваемом отрезке" асимптотическая оценка алгоритма не имеет смысла. Если входные данные ограничены, то любой алгоритм формально работает за О(1).
Во-вторых, на любом отрезке поведение O(N) ни разу не близко к поведению O(Log N). В первом случае увеличение инпута в 10 раз в те-же 10 раз увеличит и время выполнения, какой бы маленькой не была константа. Во втором же случае время исполнения увеличится всего лишь на некоторую константу.
Ну и в третьих, Integer.MAX_VALUE — это довольно много. Даже для самого векторизированного линейного алгоритма время обработки такого объема входных данных на современных CPU будет измеряться сотнями миллисекунд, а то и секундами. В то время как самый дохлый и не оптимизированный логарифм отработает мгновенно.
vladimir_dolzhenko
06.04.2018 11:54Не спорю — когда дело дойдёт до того, что копирование будет занимать секунды — более заметные эффекты другого характера будут всплывать — и GC, и deref
Согласен, одно дело асимптотика, а с другой стороны реальное поведение в ограниченном пространстве.
Alek_roebuck
05.04.2018 21:40Вроде как примерно таким образом обычно устроен std::deque в C++. Джавовский ArrayDeque устроен иначе.
ARad
05.04.2018 23:44+1Если нужно добавление и удаление только в начало, конец то да. А при вставке удалении произвольных будет чуток хуже чем ArrayList.
smer44
06.04.2018 06:42Проблема ArrayList — у него есть начальный размер по умолчанию DEFAULT_CAPACITY или заданный размер initialCapacity, при превышении этого размера, создается новый массив большего размера, при этом туда копируются данные из старого массива, что по времени очень затратно и именно это дает в наихудшем случае алгоритмическую сложность O(n)
проблема LinkedList это то что тратится память на обьекты в массив можно примитивных значений иметь.
однажды в качестве теста я для избавления от копирования сделал немного иначе — просто массив из массивов, при наполнении одного создаётся другй без перекопирования, так оно работало хуже ArrayListа даже при ЗАПОЛНЕНИИ почему-то, так что мораль — нечего выдумывать.
Deranged
06.04.2018 16:23Trade off. За плоскую модель памяти приходится платить сложными алгоритмами. Ничего радикально нового тут особо не придумаешь.
tapoton
07.04.2018 08:07Я не из мира Java, но любопытство есть. Так что у меня такой вопрос: в том же методе get используются this.level, this.current. Вопрос потоконебезопасности даже при чтении не смущает в данном случае? В моем мире задача читателей/писателей довольно актуальна, и не иметь возможности корректно читать из нескольких потоков кажется странным.
Blekel
08.04.2018 12:31Такая идея закономерно приходит многим в самом начале изучения Java и коллекций. Но Вы все равно молодец, что прикрыв фиговым листочком random delete вышли на публику. ) ну и по ULL уже написали
Regis
Очень хотелось бы увидеть результаты, полученные с помощью него. Также было бы хорошо увидеть полученный бенчмарк в вашем репозитории, чтобы можно было легко его запустить у себя.