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

Постановка задачи

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

Зачем нам это может понадобиться?

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

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

Возникает вопрос: возможно ли добиться такой сортировки с минимальным нарушением существующей последовательности?

Идея построения алгоритма

Конечно, концепция дробного индексирования представляет собой простое и интуитивное решение нашей задачи. Представьте себе, как вы записываете какой-то список на бумаге и понимаете, что забыли или пропустили один пункт. Вместо перенумерации всей последовательности вы можете просто вписать его между строк и обозначить дробным индексом, например 1,5.

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

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

Каждый новый индекс мы будем рассчитывать как среднее арифметическое между двумя ближайшими соседями. Например, для вставки строки в пустой список мы используем обе недостижимые границы и первый индекс будет равен(0+1)/2=0,5.
Аналогично, при вставке строки поверх существующей новый индекс вычисляется как середина между недостижимой верхней границей и индексом предыдущей строки(0+0,5)/2=0,25.
Вставка между существующими строками — среднее значение их индексов, что в данном случае дает(0,25+0,5)/2=0,375.

При более внимательном рассмотрении полученных индексов заметим, что префикс «ноль точка» является общим для всех индексов и им можно пренебречь. Более того, если представить хвосты индекса в виде строк или массивов байтов, то можно обнаружить, что они упорядочены лексикографически. Эта особенность позволяет нам выйти за рамки обычных числовых индексов и использовать расширенные наборы символов. Например, такие как base64 или даже произвольные байты, при условии, что наше приложение или база данных поддерживает лексикографическую сортировку таких массивов.

Вставка между двумя индексами

Как рассчитать новое значение индекса между двумя существующими? В качестве примера рассмотрим массивы байтовP_1=[0A\ 58]иP_2=[7B\ CD\ F2]. Наш подход использует концепцию рациональных чисел, где конечные нули не влияют на значение. Например, 0,1 и 0,100 — одно и то же число. Это позволяет нам корректировать длину индексов, добавляя при необходимости нули.

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

P_n = \frac{P_1+P_2}{2} = \frac{P_1}{2} + \frac{P_2}{2} = (P_1 \gg 1) + (P_2 \gg 1)

Как видно из формулы выше, для этого достаточно выполнить две простые операции с массивами байтов: сложение и сдвиг вправо на один бит. Обе операции легко реализовать для произвольного набора байтов. Нужно просто выполнить операцию над парой байт, а остаток перенести на следующую и так далее. Важно отметить, что нет необходимости сохранять полученное число целиком. Как только встречается байт, который отличается отP_1иP_2, все последующие байты становятся незначительными и могут быть отброшены.

Например, использование этого метода в нашем примере дает новый индекс:

P_n = [0A\ 58\ 00] \gg 1 + [7B\ CD\ F2] \gg 1 = [43\ 12\ F9] \approx [43]

Оценка памяти

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

Реализовав алгоритм с байтовыми массивами и проведя 10 000 вставок в начало списка, я обнаружил, что максимальный размер индекса достигает 1250 байт. Таким образом, получается каждая вставка увеличивает длину индекса всего на один бит.

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

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

Вставка в конец списка

Рассмотрим простой случай, в котором последний индекс нашего списка обозначается какP_1=[80\ AB], а мы хотим создать следующий индексP_n. Если использовать предыдущий алгоритм, то мы получим новое значение индекса какP_n=[C0]. Однако, при ближайшем рассмотрении очевидно, что это слишком большой шаг. Вместо этого достаточно просто увеличить первый байт на единицу.

Учитывая, что первый добавляемый индекс находится ровно в середине диапазона, это наблюдение обеспечивает примерно 127 вставок в конец (или начало) списка на первый байт индекса. Что соответствует приблизительному значению 0,06 бита на вставку.

Более того, благодаря свойству рациональных чисел мы можем считать все последующие байты равными нулю, что позволяет выполнить дополнительные 255 вставок на каждый следующий байт индекса. А это соответствует значению примерно 0,03 бита на вставку.

Важно отметить, что при данной модификации алгоритма увеличение индекса на один байт происходит исключительно при достижении пограничных значений байта (FF для вставки в конец или 00 для вставки в начало списка). Иначе говоря, чем медленнее мы упираемся в эти значения, тем реже появляется необходимость добавления нового байта.

Таким образом, мы можем значительно увеличить эффективность за счет использования пар байтов для инкремента. Этот подход позволяет достичь невероятных значений в 0,0001 бит на каждый новый индекс. Самое главное в этом случае — в качестве пары для инкремента использовать первый байт, который меньше FF, и следующий за ним.

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

Для нашего примера с «Блокнотом» это означает, что индексы будут увеличиваться на один бит при вставке в середину текста, а добавление строки в конец или начало будет практически бесплатным.

Другой пример, где этот подход может хорошо себя показать, — это какое-то приложение со списком задач или приоритетов. Представьте себе список из трех задач, которые вы постоянно меняете в случайном порядке. Без оптимизации каждое движение обходится вам в один бит индекса. Но так как в списке всего три задачи, вероятность того, что мы переместим одну из них в самое начало или конец, довольно велика. И этот оптимизированный случай позволит автоматически обрезать индекс до исходного размера в байтах.

Параллельные изменения

Еще одна тема, которую хотелось бы затронуть, — это одновременное редактирование списка несколькими пользователями. Что произойдет, если два независимых клиента попытаются вставить строку в одно и то же место одновременно?

Рассмотрим простейший сценарий со списком из двух строк:P_1=[05\ 12]иP_2=[07\ 0A]. Предположим, два независимых клиента пытаются вставить новую строку междуP_1иP_2.

Согласно нашему алгоритму, при одинаковых входных данных оба клиента получат одинаковые значения вставленных индексов:P_a=P_b=[06]. Тут возникают сразу две важные проблемы. Во-первых, это приводит к неопределенному порядку строк. Поскольку индексы идентичны, становится невозможным определить, какой из них должен быть выше. Во-вторых, что более важно, одинаковые индексы не позволяют нам вставить что-либо между ними.

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

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

Номер бита

0

1

2 ... 22

23 ... 47

Значение

0

1

Случайное значение

Текущее время в секундах

Для примера, в одном из моих проектов я реализовал генерацию уникального суффикса длиной 6 байт по следующим правилам:

  • Первые два бита «ноль-один» постоянны. Это необходимо для устранения вырожденных случаев, когда суффикс состоит только из нулей или единиц. Поскольку мы знаем, что такие суффиксы могут существенно повлиять на длину индекса в начале или конце списка.

  • Следующие 21 бит являются случайными. Этот порядок позволяет нам чуть-чуть уменьшить ожидаемую длину индекса, так как при вставке строки между двумя существующими мы отсекаем хвост как только находим первый отличающийся байт.

  • А последние 25 бит — это урезанный Unix timestamp. По сути, это цикл длиной в один год, но он позволяет существенно снизить вероятность генерации одинаковых суффиксов, поскольку я произвожу расчет один раз при запуске приложения.

Заключение

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

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


  1. SUNsung
    13.06.2024 14:33

    Привязка ко времени верна, а к "случайному числу" нет.

    Даже если у вас изредка бывают максимум две записи в секунду - рандом может сложится. Вы то вложились "в размер" потому колизии маловеоятны (но все еще вероятны)

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

    Из моей практики в строку из 6 символов (a-z0-9) впихнули возможность хранения трилионного диазона, или до 9999 уникальных записей в секунду в рамках кластера (максимальное количество кластеров 1200) с привязкой ко времени.


    1. AndreyMI Автор
      13.06.2024 14:33

      Пример из моего проекта приведен просто для наглядности.

      Я формировал суффикс на старте приложения и не менял его по ходу работы приложения. Коллизии возможны только если 2 клиента сгенерируют одинаковое случайное число и будут запущены в одну и ту же секунду. Для конкретно этого проекта это был приемлемый баланс между вероятностью коллизии и размером суффикса.

      Вариантов как и когда его формировать уйма. Можно генерировать один раз на старте или на каждую запись. Хоть GUID приписывай, все дело в балансе уникальности и размера.


    1. SpiderEkb
      13.06.2024 14:33

      По этому поводу описывал - Standard Time как его видит IBM Там как раз время - 64бита где старшие 52 - количество микросекунд с начала эпохи, младшие 12 - "биты уникальности" (т.е. в рамках одной микросекунды можно сгенерировать 2048 последовательных уникальных значений с привязкой ко времени).

      Реализовано это, естественно, на уровне системы (ниже SLIC - границы отделяющей доступные разработчику системные интерфейсы, не зависящие от конкретной платформы от платформенно-зависимого ядра).


      1. SUNsung
        13.06.2024 14:33
        +1

        я писал на Go, учитывая что в итоге сама блокировка вышла одноточечной - переписать эту часть на асемблер под определенные ядра не проблема))

        Но в целом 4нс на генерацию при асинхронном вызове для 1000 потоков и так неплохо.

        Вообще разработчики недооценивают потребление таких мелких и рутинных операций, особенно если их инициализация не очевидна. И как избыток проверок способен в подобной "простой" функции умножать потребление ресурсов.


        1. SpiderEkb
          13.06.2024 14:33

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


  1. wataru
    13.06.2024 14:33

    Записи сортированные же, т.е. они не в хеш таблице лежат, верно?

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

    При этом надо лишь в каждой вершине хранить одно число - количество элементов в поддереве. Для 10000 вставок это будет 16 бит, а не 1250 байт. При этом еще и никакие ключи у элементов не сравниваются вообще и не хранятся. Т.е. тут еще и экономия может получится по итогу. Основная идея: если в левом поддереве много элементов - i-ый элемент должен быть там, идем туда. Иначе, идем вправо.

    Единственный минус, который я вижу - это редко есть в стандартной библиотеке, и не так просто какой-нибудь встроенный в язык контейнер для этого приспособить. Хотя в том же С++ варианты, оказывается, есть (но об этом мало кто знает и нагуглить это очень тяжело).

    Но вообще, какое-нибудь декартово самобалансирующееся дерево итак весьма просто написать. И по сравнению с перегруженными на все случаи жизни стандартными структами оно даже быстрее работать будет.


    1. AndreyMI Автор
      13.06.2024 14:33

      Таким и схожими подходами пользуются в редакторах для совместной работы в реальном времени (realtime collaborative editor) или в одноранговых (peer-to-peer) редакторах, когда надо учесть все свои и чужие изменения, а единого места для синхронизации нет.

      Например, Figma использует похожий подход.


      1. wataru
        13.06.2024 14:33

        Да, с параллельными изменениями могут быть проблемы, это важное замечание. Если один клиент прочитает что-то и решит вставить элемент после 10-ого, а в это время кто-то другой вставит новый элемент после 3-его, то новая вставка окажется перед тем элементом, который был 10-ым с точки зрения первого клиента.

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


  1. SpiderEkb
    13.06.2024 14:33

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

    Именно так хранятся исходники (и вообще тексты) на платформе IBM i. Там очень специфическая файловая система (там вообще все специфическое) - нет файлов, есть объекты.

    Исходники, текстовые файлы хранятся не как файлы, а как ... таблица БД DB2 (объект типа *FILE с атрибутом pf-src - "физический файл исходных текстов", от "обычных" таблиц отличается только атрибутом - там pf-dta - "физический файл данных").

    В DB2 таблица может содержать несколько форматов записей (партиций) одновременно. В PF-SRC каждый исходник (member - элемент) является отдельным форматом записи, партицией.

    Т.о. если вы делаете "поставку" в которой несколько объектов, то все они будут хранится в одном pf-src в виде набора элементов.

    К чему все это... Каждый элемент суть набор записей-строк в формате

    • SRCSEQ  Numeric(6, 2) - номер строки

    • SRCDAT  Numeric(6, 0) - дата

    • SRCDTA  Char(...) собственно строка

    Но дробных индексов тут нет - не догадались до такого... Хотя идея хорошая. Тут при каждом сохранении идет полная перенумерация :-(

    Хотя сама идея что любой исходник можно прочитать SQL запросом прикольная. Достаточно создать alias на нужный элемент внутри pf-src и потом select по алиасу.