Списки с пропусками: вероятностная альтернатива сбалансированным деревьям. Еще раз про skiplist… Неравномерное распределение запросов
Интересной разновидностью структуры данных эффективной реализации АТД «упорядоченный словарь» является skip^cnucoK (skip list). Эта структура данных организует объекты в произвольном порядке таким
образом, что поиск и обновление в среднем выполняются за 0(log п) времени, где п - количество объектов словаря. Интересно, что понятие усредненной временной сложности, используемое здесь, не зависит от возможного распределения ключей при вводе. Наоборот, оно продиктовано использованием генераторов случайных чисел в реализации процесса ввода, чтрбы облегчить процесс принятия решения о месте вставки нового объекта. Время выполнения в этом случае будет равно среднему значению времени выполнения ввода любых случайных чисел, используемых в качестве входных объектов.
Методы генерации случайных чисел встраиваются в большинство современных компьютеров, поскольку они широко применяются в компьютерных играх, криптографии и компьютерных симуляторах. Некоторые методы, называемые генераторами псевдослучайных чисел (pseudorandom number generators), генерируют псевдослучайно числа по некоторому закону, начиная с так называемого начального числа (seed). Другие методы используют аппаратные средства для извлечения «действительно» случайных чисел. В любом случае компьютер имеет числа, отвечающие требованиям, предъявляемым к случайным числам в приводимом анализе.
Основным преимуществом использования случайных чисел в струк- > туре данных и при создании алгоритма является простота и надежность результативных структур и методов. Таким образом можно-создать простую рандомизированную структуру данных, называемую skip-список, обеспечивающую логарифмические затраты времени для поиска, аналогичные затратам при использовании бинарного поискового алгоритма. Тем не менее предполагаемые временные затраты при skip-списке будут большими, чем у бинарного поиска в поисковой таблице. С другой стороны, при словарном обновлении skip-список намного быстрее поисковых таблиц.
Пусть skip-список S для словаря D состоит из серии списков {iSo, S\, Si, З/j}. Каждый список S\ хранит набор объектов словаря D по ключам в неубывающей последовательности плюс объекты с двумя специальными ключами, записываемыми в виде «-оо» и «+оо», где «-оо» обозначает меньше, а «+оо» - больше любого возможного ключа, который может быть в D. Кроме того, списки в Sотвечают следующим требованиям:
Список S 0 содержит каждый объект словаря D (плюс специальные объекты с ключами «-оо» и «+оо»);
Для / = 1, …, h – 1 список Si содержит (в дополнение к «-оо» и «+оо») случайно сгенерированный набор объектов из списка S t _ ь
Список S h содержит только «-оо» и «+оо».
Образец такого skip-списка приведен на рис. 8.9, наглядно представляющем списодс S со списком в основании и списками S\, …, S^ над ним. Высоту (height) списка S обозначим как h.
Рис. 8.9. Пример skip-списка
Интуитивно списки организованы таким образом; чтобы?/+/ содержал хотя бы каждый второй объект 5/. Как будет показано при рассмотрении метода ввода, объекты в St+\ выбираются произвольно из объектов в Si с таким расчетом, чтобы каждый выбираемый объект 5/ входил в 5/ + \ с вероятностью 1/2. Образно говоря, помещать или нет объект из в Si + 1, определяем в зависимости от того, какой стороной упадет подкидываемая монетка - орлом или решкой. Таким образом, можно считать, что S\ содержит около я/2 объектов, S2 - я/4, и, соответственно, Sj - около п/2′ объектов. Другими словами, высота h списка S может составлять около logn. Хотя при этом деление пополам числа объектов от одного списка к другому не является обязательным требованием в виде явно выраженного свойства skip-списка. Наоборот, случайный выбор важнее.
Используя позиционную абстракцию применительно к спискам и деревьям, можно рассматривать skip-список как двухмерную коллекцию позиций, организованных горизонтально в уровни (levels) и вертикально в башни (towers). Каждый уровень - это список S^ а каждая башня - набор позиций, хранящих один и тот же объект и расположенных друг над другом в списках. Позиции skip-списка можно проходить с помощью следующих операций:
after(/?): возвращает позицию, следующую за р ца том же уровне; before(/?): возвращает позицию, предшествующую р на том же уровне; below(/?): возвращает позицию, расположенную под р в той же башне; above(/?): возвращает позицию, расположенную над р в той же башне.
Установим, что перечисленные выше операции должны возвращать null, если запрашиваемой позиции не существует. Не вдаваясь в подробности, заметим, что skip-список легко реализуется с помощью связной структуры таким образом, что методы прохода требуют 0(1) времени (при наличии в списке позиции р). Такая связная структура представляет собой коллекцию из h двусвязных списков, объедийёйй^ й^айШ, в свою очередь являющиеся двусвязными списками.
Структура skip-списка позволяет применять простые поисковые алгоритмы для словарей. В действительности все поисковые алгоритмы skip-списка основываются на достаточно элегантном методе SkipSearch, принимающем ключ к и находящим объект в skip-списке S с наибольшим ключом (который может оказаться «-оо»), меньшим или равным к. Допустим, имеется искомый ключ к. Метод SkipSearch устанавливает позицию р в самой верхней левой позиции в skip-списке S. То есть р устанавливается на позиции специального объекта с ключом «-оо» в S^. Затем выполняется следующее:
1) если S.below(/?) равен null, то поиск заканчивается - на уровень ниже обнаружен наибольший объект в Sс ключом, меньшим или равным искомому ключу к. В противном случае опускаемся на один уровень в данной башне, устанавливая р S.be\ow(p);
2) из позиции р продвигаемся вправо (вперед) до тех пор, пока не окажемся в крайней правой позиции текущего уровня, где кеу(/?) < к. Такой шаг называется сканированием вперед (scan forward). Указанная позиция существует всегда, поскольку каждый уровень имеет специальные ключи «+оо» и «-оо». И на самом деле, после шага вправо по текущему уровню р может остаться на исходном месте. В любом случае после этого снова повторяется предыдущее действие.
Рис. 8.10. Пример поиска в skip-списке. Обследованные во время поиска ключа 50 позиции выделены штриховым обозначением
Фрагмент кода 8.4 содержит описание поискового алгоритма skip-списка. Имея такой метод, несложно реализовать операцию findElement(/:). Выполняется операция р^- SkipSearch(A;) и проверяется равенство key{p)~ k. При ^равенстве возвращается /?.element. В противном случае возвращается сигнальное сообщение NO_SUCH_KEY.
Алгоритм SkipSearch(/:):
Input: поисковый кл?оч к.
Output: позиция р в S, объект которой имеет наибольший ключ, меньший или равный к.
Допустим, что р - самая верхняя левая позиция в S (состоящей как минимум из двух уровней), while below (р) * null do
р below(p) {просканировать вниз} while key(after(p)) < к do
Let p after(p) {просканировать вперед} return p
Фрагмент кода 8.4. Алгоритм поиска в skip-списке S
Оказывается, предполагаемое время выполнения алгоритма SkipSearch составляет 0(log п). Прежде чем обосновать это, приведем реализацию методов обновления skip-списка
СКИП – это система контроля исполнения поручений на базе MS SharePoint, позволяющая полностью автоматизировать процесс работы с поручениями. Это продуманное, коробочное решение для организации работы с поручениями. Оно подходит как для работы в крупных и территориально-распределенных компаниях, так и для компаний среднего бизнеса за счет возможности осуществления максимально гибкой настройки всех модулей.
Система СКИП базируется на платформе Microsoft SharePoint, что автоматически означает возможность ее интеграции с продуктами Microsoft, в том числе Microsoft Office.
Функциональные возможности системы
Система СКИП является «коробочным» решением и в этом варианте содержит базовый набор функциональных возможностей, необходимых для автоматизации работы с поручениями:
- Назначение, исполнение, контроль поручения;
- Отслеживание статуса исполнения поручения;
- Возможность создавать вложенные («дочерние») поручения.
Список поручений с цветовой индикацией
При этом представленный функционал реализован таким образом, чтобы предоставить пользователю максимально широкие возможности по работе с системой:
- Каталогизация поручений (одно поручение может располагаться в различных папках);
- Фильтрация списков поручений;
- Экспорт списков поручений в MS Excel;
- Отчеты по исполнительской дисциплине;
- Цветовая индикация поручений в зависимости от срока исполнения и статуса поручения;
- Возможность прикрепления произвольного количества файлов любых форматов к Карточке поручения;
- Интеграция с календарями Outlook;
- Настраиваемые оповещения о назначении и ходе работы с поручением;
- Система замещения сотрудников на период отпуска или командировки;
- Создание периодических поручений (или поручений с расписанием) для мероприятий, которые имеют определенный период (совещания, встречи и т.д.);
- Отображение сроков исполнения поручений на диаграмме Ганта;
- и прочее
Список поручений с диаграммой Ганта
Представьте, что ваш коллега-нытик пришел рассказать о своей непростой задаче - ему нужно не просто упорядочить по возрастанию набор целых чисел, а выдать все элементы упорядоченного набора с L-го по R-й включительно!
Вы заявили, что это элементарная задача и, чтобы написать решение на языке C#, вам нужно десять минут. Ну, или час. Или два. Или шоколадка «Алёнка»
Предполагается, что в наборе допускаются дубликаты, и количество элементов будет не больше, чем 10^6.
К оценке решения есть несколько комментариев:
Ваш код будут оценивать и тестировать три программиста:Решение может быть очень интересным, поэтому я посчитал нужным его описать.
- Билл будет запускать ваше решение на тестах размером не больше 10Кб.
- В тестах Стивена количество запросов будет не больше 10^5, при этом количество запросов на добавление будет не больше 100.
- В тестах Марка количество запросов будет не больше 10^5.
Решение
Пусть у нас есть абстрактное хранилище.Обозначим Add(e) - добавление элемента в хранилище, а Range(l, r) - взятие среза с l по r элемент.
Тривиальный вариант хранилища может быть таким:
- Основой хранилища будет упорядоченный по возрастанию динамический массив.
- При каждой вставке бинарным поиском находится позицию, в которую необходимо вставить элемент.
- При запросе Range(l, r) будем просто брать срез массива из l по r.
C Range(l, r) - взятие среза можно оценить, как O(r - l).
C Add(e) - вставка в худшем случае будет работать за O(n), где n - количество элементов. При n ~ 10^6, вставка - узкое место. Ниже в статье будет предложен улучшенный вариант хранилища.
Пример исходного кода можно посмотреть .
Skiplist
Skiplist - это рандомизированная альтернатива деревьям поиска, в основе которой лежит несколько связных списков. Была изобретена William Pugh в 1989 году. Операции поиска, вставки и удаления выполняются за логарифмически случайное время.Эта структура данных не получила широкой известности (кстати, и на хабре достаточно обзорно о ней написано), хотя у нее хорошие асимптотические оценки. Любопытства ради захотелось ее реализовать, тем более имелась подходящая задача.
Пусть у нас есть отсортированный односвязный список:
В худшем случае поиск выполняется за O(n). Как его можно ускорить?
В одной из видео-лекций, которые я пересматривал, когда занимался задачкой, приводился замечательный пример про экспресс-линии в Нью-Йорке:
- Скоростные линии соединяют некоторые станции
- Обычные линии соединяют все станции
- Есть обычные линии между общими станциями скоростных линий
На примере изображен идеальный SkipList, в реальности всё выглядит подобным образом, но немного не так:)
Поиск
Так происходит поиск. Предположим, что мы ищем 72-й элемент:Вставка
Со вставкой все немного сложнее. Для того чтобы вставить элемент, нам необходимо понять, куда его вставить в самом нижнем списке и при этом протолкнуть его на какое-то количество вышележащих уровней. Но на какое количество уровней нужно проталкивать каждый конкретный элемент?Решать это предлагается так: при вставке мы добавляем новый элемент в самый нижний уровень и начинаем подкидывать монетку, пока она выпадает, мы проталкиваем элемент на следующий вышележащий уровень.
Попробуем вставить элемент - 67. Сначала найдем, куда в нижележащем списке его нужно вставить:
Предположил, что монетка выпала два раза подряд. Значит нужно протолкнуть элемент на два уровня вверх:
Доступ по индексу
Для доступа по индексу предлагается сделать следующее: каждый переход пометить количеством узлов, которое лежит под ним:После того, как мы получаем доступ к i-му элементу (кстати, получаем мы его за O(ln n)), сделать срез не представляется трудным.
Пусть необходимо найти Range(5, 7). Сначала получим элемент по индексу пять:
А теперь Range(5, 7):
О реализации
Кажется естественной реализация, когда узел SkipList выглядит следующим образом:SkipListNode {Собственно, так и сделано .
int Element;
SkipListNode Next;
int Width;
}
Но в C# в массиве хранится еще и его длина, а хотелось сделать это за как можно меньшее количество памяти (как оказалось, в условиях задачи все оценивалось не так строго). При этом хотелось сделать так, чтобы реализация SkipList и расширенного RB Tree занимала примерно одинаковое количество памяти.
Ответ, как уменьшить потребление памяти, неожиданно был найден при ближайшем рассмотрении из пакета java.util.concurrent.
Двумерный skiplist
Пусть в одном измерении будет односвязный список из всех элементов. Во втором же будут лежать «экспресс-линии» для переходов со ссылками на нижний уровень.ListNode {
int Element;
ListNode Next;
}Lane {
Lane Right;
Lane Down;
ListNode Node;
}
Нечестная монетка
Еще для снижения потребления памяти можно воспользоваться «нечестной» монеткой: уменьшить вероятность проталкивания элемента на следующий уровень. В статье William Pugh рассматривался срез из нескольких значений вероятности проталкивания. При рассмотрении значений ½ и ¼ на практике получилось примерно одинаковое время поиска при уменьшении потребления памяти.Немного о генерации случайных чисел
Копаясь в потрохах ConcurrentSkipListMap, заметил, что случайные числа генерируются следующим образом:int randomLevel() {Подробнее о генерации псевдослучайных чисел с помощью XOR можно почитать в этой статьe . Особого увеличения скорости я не заметил, поэтому, не использовал его.
int x = randomSeed;
x ^= x << 13;
x ^= x >>> 17;
randomSeed = x ^= x << 5;
if ((x & 0x80000001) != 0)
return 0;
int level = 1;
while (((x >>>= 1) & 1) != 0) ++level;
return level;
}
Исходник получившегося хранилища можно глянуть .
Все вместе можно забрать с googlecode.com (проект Pagination).
Тесты
Было использовано три типа хранилища:- ArrayBased (динамический массив)
- SkipListBased (SkipList c параметром ¼)
- RBTreeBased (красно-черное дерево: реализация моего знакомого, который выполнял аналогичное задание).
- Упорядоченные по возрастанию элементы
- Упорядоченные по убыванию элементы
- Случайные элементы
Результаты:
Array | RBTree | SkipList | |
---|---|---|---|
Random | 127033 ms | 1020 ms | 1737 ms |
Ordered | 108 ms | 457 ms | 536 ms |
Ordered by descending | 256337 ms | 358 ms | 407 ms |
Также были проведены замеры: сколько каждое хранилище занимает в памяти при вставленных в него 10^6 элементах. Использовался студийный профайлер, для простоты запускался такой код:
var storage = ...Результаты:
for (var i = 0; i < 1000000; ++i)
storage.Add(i);
Array | RBTree | SkipList | |
---|---|---|---|
Total bytes allocated | 8,389,066 bytes | 24,000,060 bytes | 23,985,598 bytes |
Хеппи-энд с «Алёнкой»
Коллега-нытик, по условию задачи, проспорил мне шоколадку. Моё решение зачли с максимальным баллом. Надеюсь, кому-нибудь данная статья будет полезна. Если есть какие-то вопросы - буду рад ответить.P.S.: я был на стажировке в компании СКБ Контур. Это чтобы не отвечать на одинаковые вопросы =)
7 ответов
Поскольку вы упомянули Список, который является как индексируемым (я предполагаю, что вы хотите скорейшего извлечения), так и для того, чтобы разрешать дубликаты, я бы посоветовал вам использовать пользовательский набор с LinkedList или ArrayList.
Вам нужно иметь базовый набор, например, HashSet и продолжать добавлять к нему значения. Если вы сталкиваетесь с дубликатом, значение этого набора должно указывать на список. Итак, у вас будет и быстрый поиск, и, конечно же, вы сохраните свои объекты в виде коллекции psuedo.
Это должно дать вам хорошую эффективность для извлечения. В идеале, если ваши Ключи не дублируются, вы достигнете O (1) в качестве скорости поиска.
Этот ответ задерживается на 3 года, но я надеюсь, что он будет полезен тем, кто хочет получить список пропусков Java с этого момента:)
Это решение позволяет дублировать, как вы просили. Я следую примерно руководству здесь http://igoro.com/archive/skip-lists-are-fascinating , поэтому сложности похожи на то, что удалить стоит O (nlogn) - как я это делал "Не пытайтесь использовать двусвязные узлы, я предполагаю, что это приведет к тому, что удалить до O (logn).
Код содержит: интерфейс, список пропуска, реализующий интерфейс, и класс node. Он также является общим.
Вы можете настроить параметр LEVELS для производительности, но помните компромисс между пространством и временем.
Вы можете использовать приведенное ниже код, чтобы сделать свой собственный базовый скипист :
1) Сделайте start и конец до represent start and end of skip list .
2) Add the nodes и assign pointers до следующего based on
If(node is even) then ,assign a fast lane pointer with next pointer else assign only pointer to next node
Java-код для базового списка пропуска (вы можете добавить дополнительные функции):
Public class MyClass { public static void main(String args) { Skiplist skiplist=new Skiplist(); Node n1=new Node(); Node n2=new Node(); Node n3=new Node(); Node n4=new Node(); Node n5=new Node(); Node n6=new Node(); n1.setData(1); n2.setData(2); n3.setData(3); n4.setData(4); n5.setData(5); n6.setData(6); skiplist.insert(n1); skiplist.insert(n2); skiplist.insert(n3); skiplist.insert(n4); skiplist.insert(n5); skiplist.insert(n6); /*print all nodes*/ skiplist.display(); System.out.println(); /* print only fast lane node*/ skiplist.displayFast(); } } class Node{ private int data; private Node one_next; //contain pointer to next node private Node two_next; //pointer to node after the very next node public int getData() { return data; } public void setData(int data) { this.data = data; } public Node getOne_next() { return one_next; } public void setOne_next(Node one_next) { this.one_next = one_next; } public Node getTwo_next() { return two_next; } public void setTwo_next(Node two_next) { this.two_next = two_next; } } class Skiplist{ Node start; //start pointer to skip list Node head; Node temp_next; //pointer to store last used fast lane node Node end; //end of skip list int length; public Skiplist(){ start=new Node(); end=new Node(); length=0; temp_next=start; } public void insert(Node node){ /*if skip list is empty */ if(length==0){ start.setOne_next(node); node.setOne_next(end); temp_next.setTwo_next(end); head=start; length++; } else{ length++; Node temp=start.getOne_next(); Node prev=start; while(temp != end){ prev=temp; temp=temp.getOne_next(); } /*add a fast lane pointer for even no of nodes*/ if(length%2==0){ prev.setOne_next(node); node.setOne_next(end); temp_next.setTwo_next(node); temp_next=node; node.setTwo_next(end); } /*odd no of node will not contain fast lane pointer*/ else{ prev.setOne_next(node); node.setOne_next(end); } } } public void display(){ System.out.println("--Simple Traversal--"); Node temp=start.getOne_next(); while(temp != end){ System.out.print(temp.getData()+"=>"); temp=temp.getOne_next(); } } public void displayFast(){ System.out.println("--Fast Lane Traversal--"); Node temp=start.getTwo_next(); while(temp !=end){ System.out.print(temp.getData()+"==>"); temp=temp.getTwo_next(); } } }
Вывод:
Простой обход -
1 = > 2 = > 3 = > 4 = > 5 = > 6 = >
Быстрая перемотка дорожки -
2 == > 4 == > 6 == >
Когда вы создаете ConcurrentSkipListSet , вы передаете компаратор в конструктор.
New ConcurrentSkipListSet<>(new ExampleComparator());
public class ExampleComparator implements Comparator
Вы можете создать компаратор, который сделает ваш SkipListSet вести себя как обычный список.
Я не утверждаю, что это моя собственная реализация. Я просто не могу вспомнить, где я его нашел. Если вы знаете, дайте мне знать, и я обновлю. Это работает для меня хорошо:
Public class SkipList
Исправлена ошибка в реализации, предоставляемой @PoweredByRice. Он выбросил NPE для случаев, когда удаленный node был первым node. Другие обновления включают переименованные имена переменных и обратную печать порядка списка пропусков.
Import java.util.Random;
interface SkippableList