Реализовать неизменную деку как сбалансированное бинарное дерево? - PullRequest
29 голосов
/ 17 июля 2010

Я некоторое время думал о том, как реализовать deque (то есть двустороннюю очередь) в качестве неизменяемой структуры данных.

Кажется, есть разные способы сделать это. AFAIK, неизменяемые структуры данных, как правило, имеют иерархическую структуру , поэтому их основные части могут быть использованы повторно после выполнения таких операций, как вставка или удаление элемента.

Эрик Липперт опубликовал две статьи в своем блоге на эту тему вместе с примерами реализации на C #.

Обе его реализации кажутся мне более сложными, чем на самом деле необходимо. Невозможно просто реализовать запросы в виде двоичных деревьев, где элементы могут быть вставлены или удалены только в самом «левом» ( перед ) и в самом «правом» ( зад * 1016) *) дерева?

                               o
                              / \
                             …   …
                            /     \
                           …       …
                          / \     / \
              front -->  L   …   …   R  <-- back

Кроме того, дерево будет сбалансировано с помощью поворотов:

  • правое вращение при вставке спереди или при удалении сзади и
  • левые повороты при удалении спереди или вставке сзади.

Эрик Липперт, по моему мнению, очень умный человек, которого я глубоко уважаю, хотя он, очевидно, не рассматривал этот подход. Таким образом, я задаюсь вопросом, это было по уважительной причине? Мой предложенный способ реализации deques наивен?

Ответы [ 4 ]

46 голосов
/ 18 июля 2010

Как отметил Дэниел, реализация неизменяемых запросов с хорошо известными сбалансированными деревьями поиска, такими как AVL или красно-черные деревья, дает Θ (lg n) сложности в худшем случае.Некоторые реализации, которые обсуждает Липперт, могут показаться сложными на первый взгляд, но есть много неизменяемых запросов с o (lg n) худшей, средней или амортизированной сложностью, которые строятся из сбалансированных деревьев вместе с двумя простыми идеями:

  1. Реверсирование позвоночника

    Для выполнения операций deque в традиционном сбалансированном дереве поиска нам нужен доступ к концам, но у нас есть доступ только к центру.Чтобы добраться до левого конца, мы должны перемещаться по указателям левого ребенка, пока мы, наконец, не достигнем тупика.Было бы предпочтительно иметь указатель на левый и правый концы без всех этих усилий навигации.На самом деле, нам действительно не нужен доступ к корневому узлу очень часто.Давайте сохраним сбалансированное дерево поиска так, чтобы доступ к концам был O (1).

    Вот пример на C того, как вы обычно можете хранить дерево AVL:

    struct AVLTree {
      const char * value;
      int height;
      struct AVLTree * leftChild;
      struct AVLTree * rightChild;
    };
    

    Toнастройте дерево так, чтобы мы могли начинать с краев и двигаться к корню; мы меняем дерево и сохраняем все указатели вдоль путей от корня до левого и самого правого дочерних элементов в reverse .(Эти пути называются левый и правый отдел позвоночника соответственно).Точно так же, как переворачивание односвязного списка, последний элемент становится первым, так что самый левый потомок теперь легко доступен.

    Это немного сложно понять.Чтобы объяснить это, представьте, что вы сделали это только для левого отдела позвоночника:

    struct LeftSpine {
      const char * value;
      int height;
      struct AVLTree * rightChild;
      struct LeftSpine * parent;
    };
    

    В каком-то смысле самый левый потомок теперь является «корнем» дерева.Если бы вы нарисовали дерево таким образом, это выглядело бы очень странно, но если вы просто возьмете свой обычный рисунок дерева и перевернете все стрелки на левом отделе позвоночника, смысл структуры LeftSpine должен стать более понятным.Доступ к левой стороне дерева теперь мгновенный.То же самое можно сделать для правого отдела позвоночника:

    struct RightSpine {
      double value;
      int height;
      struct AVLTree * leftChild;
      struct RightSpine * parent;
    };
    

    Если вы храните как левый, так и правый отдел позвоночника, а также центральный элемент, у вас есть немедленный доступ к обоим концам.Вставка и удаление могут по-прежнему иметь значение Ω (lg n), поскольку для операций перебалансировки может потребоваться пересечь весь левый или правый отдел позвоночника, но просто просмотр левого и правого элементов теперь является O (1).

    Примеромэта стратегия используется для создания чисто функциональных трэпов с реализациями в SML и Java ( больше документации ).Это также является ключевой идеей в нескольких других неизменяемых запросах с производительностью o (lg n).

  2. Связать работу Rabalancing

    Как отмечено вышедля вставки в левый или правый конец дерева AVL может потребоваться время (lg n) для прохождения позвоночника.Вот пример дерева AVL, которое демонстрирует это:

    Полное двоичное дерево определяется индукцией как:

    • Полное двоичное дерево высоты 0 является пустым узлом.
    • Полное двоичное дерево высотой n + 1 имеет корневой узел с полными двоичными деревьями высотой n в качестве дочерних элементов.

    При перемещении элемента слева от полного двоичного дерева обязательноувеличить максимальную высоту дерева.Поскольку вышеупомянутые деревья AVL хранят эту информацию в каждом узле, и поскольку каждое дерево вдоль левого корешка полного двоичного дерева также является полным двоичным деревом, элемент, помещаемый слева от декы AVL, который оказывается полным двоичным деревомпотребует увеличения значений высоты Ω (lg n) вдоль левого отдела позвоночника.

    (Два замечания по этому поводу: (a) Вы можете хранить деревья AVL, не сохраняя высоту в узле; вместо этого вы сохраняете только информацию о балансе (слева выше, справа выше или даже). Это не меняет производительность приведенного выше примера. (b) В деревьях AVL может потребоваться не только обновление информации о балансе или высоте Omega; (lg n), но и операции перебалансировки Omega; (lg n). это, и это может быть только на удалениях, а не вставках.)

    Чтобы выполнить o (lg n) deque операций, нам нужно ограничить эту работу. Неизменяемые запросы, представленные сбалансированными деревьями, обычно используют по крайней мере одну из следующих стратегий:

    • Предвидеть, где потребуется перебалансировка . Если вы используете дерево, которое требует o (lg n) перебалансировки, но вы знаете, где будет необходимо это перебалансирование и , вы можете добраться до него достаточно быстро, вы можете выполнять свои операции deque за o (lg n) время , В запросах, использующих это как стратегию, в деку будут помещены не только два указателя (концы левого и правого отростков, как обсуждалось выше), но и небольшое количество указателей прыжка в более высокие места вдоль отрубей , Операции Deque могут затем получить доступ к корням деревьев, на которые указывают указатели перехода в O (1) времени. Если o (lg n) указатели перехода поддерживаются для всех мест, где потребуется перебалансировка (или изменение информации об узле), операции удаления могут занять время o (lg n).

      (Конечно, это делает дерево фактически дагом, поскольку на деревьях в шипах, на которые указывают указатели прыжков, их дети также указывают на позвоночник. Неизменяемые структуры данных обычно не уживаются с не-деревом графики, так как замена узла, на который указывает более одного узла, требует замены всех других узлов, которые указывают на него. Я видел это исправленным, просто удалив указатели без перехода, превратив даг обратно в дерево. Можно затем сохраните односвязный список с указателями перехода в виде списка списков. Каждый подчиненный список содержит все узлы между заголовком этого списка и указателем перехода. Это требует некоторой осторожности при работе с частично перекрывающимися указателями перехода и полным объяснение, вероятно, не подходит для этого в стороне.)

      Это один из приемов, используемых Цакалидисом в своей статье "Деревья AVL для локализованного поиска" , чтобы разрешить O (1) операции deque на деревьях AVL с ослабленным условием баланса. Это также основная идея, использованная Капланом и Тарьяном в их работе «Чисто функциональные запросы в реальном времени с конкатенацией» и более позднее уточнение этой работы Михаэзау и Тарьяном . Munro et al. "Детерминированные списки пропусков" также заслуживает упоминания здесь, хотя перевод списков пропусков в неизменяемую настройку с использованием деревьев иногда изменяет свойства, которые позволяют такое эффективное изменение вблизи концов. Примеры перевода см. В Messeguer "Пропускать деревья, альтернативную структуру данных пропускающим спискам в параллельном подходе" , Дин и Джонс "Изучение двойственности между пропускающими списками и бинарными деревьями поиска" и Lamoureux and Nickerson "Об эквивалентности B-деревьев и детерминированных списках пропусков" .

    • Делать работу оптом . В приведенном выше примере с полным бинарным деревом перебалансировка не требуется для push, но узлам & Omega; (lg n) необходимо обновить информацию о своей высоте или балансе. Вместо того, чтобы фактически делать приращение, вы могли бы просто отметить позвоночник на концах как нуждающийся в приращении.

      Одним из способов понять этот процесс является аналогия с двоичными числами. (2 ^ n) -1 представлен в двоичном виде строкой из n 1. При добавлении 1 к этому номеру вам нужно изменить все 1 на 0, а затем добавить 1 в конце. Следующий Haskell кодирует двоичные числа как непустые строки битов, сначала наименее значимые.

      data Bit = Zero | One
      
      type Binary = (Bit,[Bit])
      
      incr :: Binary -> Binary
      incr (Zero,x) = (One,x)
      incr (One,[]) = (Zero,[One])
      incr (One,(x:xs)) = 
          let (y,ys) = incr (x,xs)
          in (Zero,y:ys)
      

      incr является рекурсивной функцией, и для чисел вида (One,replicate k One) incr вызывает себя Ω (k) раз.

      Вместо этого мы можем представлять группы равных бит только числом битов вгруппа.Соседние биты или группы битов объединяются в одну группу, если они равны (по значению, а не по числу).Мы можем увеличивать время O (1):

      data Bits = Zeros Int | Ones Int
      
      type SegmentedBinary = (Bits,[Bits])
      
      segIncr :: SegmentedBinary -> SegmentedBinary
      segIncr (Zeros 1,[]) = (Ones 1,[])
      segIncr (Zeros 1,(Ones n:rest)) = (Ones (n+1),rest)
      segIncr (Zeros n,rest) = (Ones 1,Zeros (n-1):rest)
      segIncr (Ones n,[]) = (Zeros n,[Ones 1])
      segIncr (Ones n,(Zeros 1:Ones m:rest)) = (Zeros n,Ones (m+1):rest)
      segIncr (Ones n,(Zeros p:rest)) = (Zeros n,Ones 1:Zeros (p-1):rest)
      

      Поскольку segIncr не является рекурсивным и не вызывает никаких функций, кроме плюса и минуса для Ints, вы можете видеть, что это занимает O (1) время.

      Некоторые из требований, упомянутых в разделе выше, озаглавленном «Предвидеть, где потребуется перебалансировка», фактически используют другую численно вдохновленную технику, называемую «избыточные системы счисления», чтобы ограничить работу перебалансировки O (1) и найтиэто быстро.Избыточные числовые представления увлекательны, но, возможно, слишком далеки от этого обсуждения. Elmasry et al. "Строго регулярная система счисления и структуры данных" - неплохое место для начала чтения по этой теме. Может также пригодиться «Начальная загрузка односторонних гибких массивов» .

      In «Обеспечение постоянства структур данных» , Driscoll et al.опишите ленивый перекрас , который они приписывают Цакалидису.Они применяют его к красно-черным деревьям, которые могут быть перебалансированы после вставки или удаления с помощью поворотов O (1) (но перекрашивания Ω (lg n)) (см. Тарьяна «Обновление сбалансированного дерева в поворота O (1)»).Суть идеи заключается в маркировке большого пути узлов, которые необходимо перекрасить, но не повернуть.Аналогичная идея используется на деревьях AVL в более старых версиях Brown & Tarjan «Алгоритм быстрого слияния» .(Более новые версии той же работы используют 2-3 дерева; я не читал более новые, и я не знаю, используют ли они какие-либо методы, такие как ленивая перекраска.)

    • Перемешайте .Treaps, упомянутые выше, могут быть реализованы в функциональной настройке так, чтобы они выполняли операции deque в среднем за O (1) времени.Поскольку запросам не нужно проверять свои элементы, это среднее значение не подвержено снижению производительности злонамеренного ввода, в отличие от простых (без перебалансировки) деревьев двоичного поиска, которые в среднем работают быстро.Трипы используют независимый источник случайных битов вместо того, чтобы полагаться на случайность данных.

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

      Если это не представляет проблемы, ошибки являютсяпривлекательная простая структура данных.Они очень близки к описанному выше дереву позвоночника AVL.

      Упомянутые выше списки пропусков также могут быть применимы к функциональным реализациям с O (1) операциями блокировки среднего времени.

      ПервыйДва метода ограничения работы по перебалансировке требуют сложных модификаций в структурах данных, обычно обеспечивая простой анализ сложности операций deque.Рандомизация, наряду со следующим методом, имеет более простые структуры данных, но более сложный анализ. Первоначальный анализ Зейделя и Арагона не является тривиальным , и существует некоторый комплексный анализ точных вероятностей с использованием более сложной математики, чем в приведенных выше работах - см. Flajolet et al.«Шаблоны в случайных бинарных деревьях поиска» .

    • амортизировать . Существует несколько сбалансированных деревьев, которые, если смотреть из корней вверх (как описано выше в разделе «Перевернуть шипы»), предлагают O (1) амортизированное время вставки и удаления. Отдельные операции могут занять & Omega; (LG N) время, но они помещают дерево в такое хорошее состояние, что большое количество операций после дорогой операции будет дешевым.

      К сожалению, этот вид анализа не работает, когда старые версии дерева все еще существуют. Пользователь может выполнять операции со старым, почти несбалансированным деревом много раз, не вмешиваясь в дешевые операции.

      Один из способов получения амортизированных границ в постоянных условиях был изобретен Крисом Окасаки . Не просто объяснить, как амортизация переживает способность использовать произвольные старые версии структуры данных, но если я правильно помню, Первая (насколько я знаю) статья Окасаки по теме имеет довольно четкое объяснение. Более подробные объяснения см. В его диссертации или в его книге .

      Насколько я понимаю, есть два основных компонента. Во-первых, вместо того, чтобы просто гарантировать, что определенное количество дешевых операций происходит перед каждой дорогой операцией (обычный подход к амортизации), вы фактически назначаете и настраиваете эту специфическую дорогую операцию до выполнения дешевые операции, которые окупят это. В некоторых случаях операцию планируется начать (и завершить) только после множества дешевых шагов. В других случаях операция запланирована только на O (1) шагов в будущем, но дешевые операции могут сделать часть дорогостоящей операции, а затем перепланировать ее на более позднее время. Если злоумышленник, желающий повторять дорогостоящую операцию снова и снова, фактически каждый раз использует одну и ту же запланированную операцию. Это разделение, где появляется второй ингредиент.

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

      #include <stdlib.h>
      
      struct lazy {
        int (*oper)(const char *);
        char * arg;
        int* ans;
      };
      
      typedef struct lazy * lazyop;
      
      lazyop suspend(int (*oper)(const char *), char * arg) {
        lazyop ans = (lazyop)malloc(sizeof(struct lazy));
        ans->oper = oper;
        ans->arg = arg;
        return ans;
      }
      
      void force(lazyop susp) {
        if (0 == susp) return;
        if (0 != susp->ans) return;
        susp->ans = (int*)malloc(sizeof(int));
        *susp->ans = susp->oper(susp->arg);
      }
      
      int get(lazyop susp) {
        force(susp);
        return *susp->ans;
      }
      

      Конструкции ленивости включены в некоторые ML, а Haskell по умолчанию ленив. Под капотом лень - это мутация, которая заставляет некоторых авторов называть это «побочным эффектом». Это может считаться плохим, если подобный побочный эффект не очень хорошо сочетается с какими бы то ни было причинами выбора неизменной структуры данных, но, с другой стороны, если рассматривать лень как побочный эффект, это позволяет традиционные методы амортизированного анализа для постоянных структур данных, как упомянуто в статье Каплана, Окасаки и Тарьяна, озаглавленной «Простые надежно сохраняемые списки», .

      Снова рассмотрим противника сверху, который пытается многократно форсировать вычисления дорогостоящей операции. После первой силы ленивого значения каждая оставшаяся сила дешева.

      В своей книге Окасаки объясняет, как строить дебеки с O (1) амортизированным временем, необходимым для каждой операции. По сути, это дерево B +, представляющее собой дерево, в котором все элементы хранятся на листьях, узлы могут различаться по количеству дочерних элементов, и каждый лист находится на одной и той же глубине. Окасаки использует рассмотренный выше метод реверса позвоночника и приостанавливает (то есть сохраняет в виде ленивого значения) шипы над элементами листа.

      Структура Хинце и Патерсона, называемая "Деревья пальца: простая структура данных общего назначения" находится на полпути между запросами, разработанными Окасаки, и "Чисто функциональными представлениями каталитических отсортированных списков". Каплана и Тарьяна . Структура Хинзе и Патерсона стала очень популярной.

      В качестве доказательства того, насколько сложно понять амортизированный анализ, пальчики Хинце и Патерсона часто реализованы без лени, что не ограничивает время O (1), но все еще O (LG N). Одна из реализаций, которая, кажется, использует лень, - это в функциональной сети dotnet . Этот проект также включает реализацию отложенных значений в C # , которая может помочь объяснить их, если мое объяснение выше отсутствует.

Могут ли запросы быть реализованы в виде двоичных деревьев? Да, и их сложность в худшем случае при постоянном использовании будет не хуже, чем у Эрика Липперта. Тем не менее, деревья Эрика на самом деле не достаточно сложны , чтобы получить O (1) удаленных операций в постоянном режиме, хотя только с небольшим запасом сложности (делая центр ленивым), если вы готовы принять амортизированную производительность. Другое, но также простое представление о ловушках может получить O (1) ожидаемую производительность в функциональном окружении, если принять противника, который не слишком хитр. Получение O (1) наихудших операций в режиме deque с древовидной структурой в функциональном окружении требует чуть большей сложности, чем реализации Эрика.


Два заключительных замечания (хотя это очень интересная тема, и я оставляю за собой право добавить больше позже): -)

  1. Почти все упомянутые выше требования также являются деревьями поиска пальцев. В функциональной настройке это означает, что они могут быть разбиты по i-му элементу за O (lg (min (i, ni))), а два дерева размером n и m могут быть объединены в O (lg (min (n, m)). )) время.

  2. Я знаю два способа реализации запросов, которые не используют деревья. Окасаки представляет один в своей книге, тезисе и статье, на которую я ссылался выше. Другой использует технику, называемую «глобальное перестроение» и представлен в 1212 *.

5 голосов
/ 18 июля 2010

Остальные ответы все потрясающие. Я добавлю к ним, что я выбрал реализацию дерева пальца для deque, потому что она необычно и интересно использует систему родовых типов. Большинство структур данных являются рекурсивными в их структуре , но этот метод помещает рекурсию также в систему типа , которую я раньше не видел; Я подумал, что это может представлять общий интерес.

4 голосов
/ 17 июля 2010

Если вы используете сбалансированное двоичное дерево, вставки и удаления на обоих концах равны O (lg N) (как в среднем, так и в худшем случае). Подход, использованный в реализациях Эрика Липперта, более эффективен и выполняется в среднем за постоянное время (наихудший случай все еще равен O (lg N)).

Помните, что изменение неизменяемого дерева включает в себя переписывание всех родителей изменяемого вами узла. Поэтому для deque вы не хотите, чтобы дерево было сбалансировано; вместо этого вы хотите, чтобы узлы L и R были как можно ближе к корню, тогда как узлы в середине дерева могут быть еще дальше.

2 голосов
/ 19 июля 2010

Разве нельзя просто реализовать запросы в виде двоичных деревьев, где элементы могут быть вставлены или удалены только в самом «левом» (переднем) и в самом «правом» (заднем) дереве?

Абсолютно.Модифицированную версию сбалансированного по высоте дерева, в частности деревьев AVL, было бы очень легко реализовать.Однако это означает, что заполнение очереди на основе дерева элементами n требует O(n lg n) времени - вам нужно выбрать деку, которая имеет характеристики производительности, аналогичные изменяемому аналогу.

Вы можете создать прямую неизменяемую деку самортизированные операции с постоянным временем для всех основных операций с использованием двух стеков, левого и правого стека.PushLeft и PushRight соответствуют push-значениям в левом и правом стеке соответственно.Вы можете получить Deque.Hd и Deque.Tl из LeftStack.Hd и LeftStack.Tl;если ваш LeftStack пуст, установите LeftStack = RightStack.Reverse и RightStack = пустой стек.

type 'a deque = Node of 'a list * 'a list    // '

let peekFront = function
    | Node([], []) -> failwith "Empty queue"
    | Node(x::xs, ys) -> x
    | Node([], ys) -> ys |> List.rev |> List.head
let peekRear = function
    | Node([], []) -> failwith "Empty queue"
    | Node(xs, y::ys) -> y
    | Node(xs, []) -> xs |> List.rev |> List.head
let pushFront v = function
    | Node(xs, ys) -> Node(v::xs, ys)
let pushRear v = function
    | Node(xs, ys) -> Node(xs, v::ys)
let tl = function
    | Node([], []) -> failwith "Empty queue"
    | Node([], ys) -> Node(ys |> List.rev |> List.tail, [])
    | Node(x::xs, ys) -> Node(xs, ys)

Это очень распространенная реализация, и ее очень легко оптимизировать для повышения производительности.

...