Список с пропусками

Материал из Викиконспекты
Перейти к: навигация, поиск
Пример списка с пропусками

Список с пропусками (англ. skip list) — вероятностная структура данных, позволяющая в среднем за [math]O(\log(n))[/math] времени выполнять операции добавления, удаления и поиска элементов.

Структура данных основана на многоуровневом связном отсортированном списке. На самом нижнем (первом) уровне располагаются все элементы в отсортированном порядке. Дальше почти половина элементов таким же образом располагаются на втором, почти четверть [math]-[/math] на третьем и так далее, но при этом известно, что если элемент расположен на каком-то уровнем [math]L_i[/math], то он соответственно расположен и на всех уровнях [math]L_j[/math], где [math]j \lt i[/math].

Построение

Список с пропусками строится на основе существующего односвязного отсортированного списка.

SimpleList.png

Добавив дополнительные уровни, каждый из которых представляет предыдущий уровень без нечетных элементов, мы получим возможность осуществлять поиск, вставку и удаление элементов подобно операциям с двоичным деревом поиска. Соответственно, асимптотика этих операций будет составлять [math]O(\log(n))[/math].

SkipList.png

Операции над структурой

Поиск элемента

Допустим, что в нашем списке с пропусками существуют [math]k[/math] уровней, при этом первым уровнем ([math]L_1[/math]) будет исходный список.

В таком случае алгоритм поиска в этой структуре будет представлять из себя следующие операции:

  1. Начинаем поиск элемента в верхнем списке ([math]L_k[/math]), рассмотрим первый элемент
  2. Переходить к следующему элементу списка, пока значение в следующей ячейке меньше или равно ключу
  3. Переместиться на один уровень вниз и перейти к пункту 2. Если рассматриваемый элемент находится на нижнем уровне - выйти из поиска

Пример поиска числа [math]8[/math] в списке из описания:

SkipListSearch.png

В качестве примера рассмотрим список с двумя уровнями [math]L_1[/math] и [math]L_2[/math]. Поскольку элементы по уровням распределяются равномерно, то в среднем количество элементов между двумя соседними элементами уровня [math]L_2[/math] будет составлять [math]\dfrac{n}{\vert L_2 \vert}[/math]. Оценим среднее время доступа к элементу. Мы пройдём не более [math]\vert L_2 \vert[/math] элементов на втором уровне, однако, если на каком-нибудь шаге мы спустимся на нижней уровень, то пройдём в среднем не более [math]\dfrac{n}{\vert L_2 \vert}[/math] элементов (в противном случае мы могли бы пройти ещё один элемент на втором уровне). Минимизируем величину [math]\vert L_2 \vert + \dfrac{n}{\vert L_2 \vert}[/math]. Очевидно, что при [math] \vert L_2 \vert = \sqrt{n}[/math] сумма достигает минимального значения. Таким образом, время поиска элемента составляет в среднем [math]O(\sqrt{n})[/math], отсюда выходит, что удаление и добавления элемента также происходит за [math]O(\sqrt{n})[/math], а для того, чтобы эффективнее использовать такой список, нам нужно генератор равновероятных чисел с вероятностью [math]\dfrac{1}{\sqrt{n}}[/math].

Допустим, что у нас имеется не два, а [math]k[/math] уровней. Тогда оптимально на каждом из [math]k[/math] уровней проходить не более [math]\sqrt[k]{n}[/math] элементов, отсюда выходить оценка для поиска, добавления и удаления элементов равная [math]O(\sqrt[k]{n})[/math].

Вставка элемента

Для вставки элемента в список с пропусками, нам необходимо выполнить следующие шаги:

  1. Найти с помощью алгоритма поиска позицию, куда нам надо вставить этот элемент
  2. Вставить наш элемент в нижний уровень списка с пропусками
  3. «Подбросить монетку» и в зависимости от результата протолкнуть элемент на уровень выше
  4. Повторять предыдущий шаг до тех пор, пока у нас «подброс монетки» дает положительный результат

Таким образом, если использовать честную монету, то математическое ожидание количества элементов на втором уровне равняется [math]\dfrac{n}{2}[/math], на третьем уровне [math]\dfrac{n}{4}[/math] и т.д. На уровне [math]\log{n}[/math] у нас окажется один элемент. Ну и соответственно вероятности попасть элементу на второй уровень — это [math]\dfrac{1}{2}[/math], на третий [math]\dfrac{1}{4}[/math] и т.д. Вероятность попасть на уровень [math]\log{n}[/math] равна [math]\dfrac{1}{n}[/math].

Используя монетку с распределением отличным от [math]\{\dfrac{1}{2}[/math], [math]\dfrac{1}{2}\}[/math], можно влиять на количество элементов на верхних уровнях. Так, например, при использовании монеты с распределением [math]\{p,q\}[/math]}, математическое ожидание количества элементов на уровне [math]l[/math] равно [math]n q^l[/math], каждый уровень будет составлять [math]q[/math] от предыдущего; время поиска будет равно [math]O(\dfrac{k}{q} + nq^k)[/math]. Соответственно при честной монетке и [math]\log(n)[/math] уровней получаем оценку, полученную ранее. Для крайних распределений:

  • [math]\{0, 1\}[/math][math]O(k+n)[/math]
  • [math]\{1, 0\}[/math][math]\infty[/math] (если разрешить добавление новых уровней при проталкивании элемента после броска монетки; иначе [math]O(n)[/math])

Удаление элемента

Алгоритм удаления достаточно тривиален.

  1. Найти удаляемый элемент
  2. Удалить его со всех уровней

Псевдокод

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

В узлах списка хранятся:

  • [math]next[/math] — следующий узел
  • [math]down[/math] — тот же узел на следующем уровне
  • [math]data[/math] — данные типа T
  • [math]key[/math] — ключ типа K

Конструктор

   list skip_list (list l):
       list lvl = build_lvl(l)                 // Здесь происходит построение первого уровня
       while lvl.size() > 2 
           lvl = build_lvl (lvl)               // Добавление следующих уровней; последний содержит два элемента
       return t 
   list build_lvl (list lvl)                   // Отсеивание нечётных элементов
       list next_lvl 
       node i = lvl.head()                     // Перебор всех элементов lvl
       while (i != null) and (i != lvl.tail())
           next_lvl.push_back(node(i.key, i))  // Добавление чётного элемента;
           i = i.next.next                     // он содержит ключ и ссылку на предыдущий уровень
       return next_lvl 

Поиск элемента по ключу:

   T find (list skip_list, K key)
       node res 
       for (res = skip_list.head; res.ref != null; res = res.ref)
                                            // Cпускаемся на шаг вниз, если можем (п. 3)
           while res.key <= key             // Переходим к следующему элементу (п. 2)
               res = res.next() 
       return res.data 

Вставка:

   node insert (node i, K key, T data)
       while i.key <= key                   // Ищем подходящее место
           i = i.next() 
       node tmp = null                      // Для записи в поле down
       if i.ref != null                     // Мы не на нижнем уровне
           tmp = insert (i.ref, key)        // Рекурсивный вызов на более низком уровне
           if tmp == null                   // Проверка броска монетки
               return null 
       i.next = new node (i.next, tmp, data, key)  //Непосредственно вставка
       if random(0,1) > 0.5                 // Бросок монетки
           return i.next                    // Нужно передать новый элемент для вставки выше
       else
           return null 
   void insert (list skip_list, K key, T data) // Обёрточка
       insert(skip_list.head, key, data) 

Удаление:

   void erase (node i, K key)
       if i == null
           return 
       while i.key <= key                   // Ищем элемент
           i = i.next() 
       erase(i.ref, key)                    // Удаляем с нижних уровней
       if i.key == key                      // Если ключ совпадает
           delete(i)                        // удаляем и с этого уровня
   void erase (list skip_list, K key) // Обёрточка
       erase(skip_list.head, key) 

Применение

  • Базы данных
  • Распределённые вычисления и p2p
  • Масштабируемые параллельные приоритетные очереди и словари

В вычислительной геометрии широко применяются структуры на основе списка с пропусками.

См. также

Структуры на основе списка с пропусками:

Источники информации