Сортировка На Месте Radix

Это длинный текст. Пожалуйста, потерпите. Сводились, вопрос: есть ли работоспособный алгоритм сортировки radix на месте?


предварительно

у меня огромное количество малая фиксированная длина строки, которые используют только буквы "A", "C", "G" и " T " (да, вы догадались: ДНК), который я хочу сортировать.

на данный момент я использую std::sort использует introsort во всех распространенных реализации STL. Это работает довольно хорошо. Однако, я убежден, что radix sort идеально подходит для моей проблемы и должен работать много лучше на практике.

подробности

Я проверил это предположение с очень наивной реализацией и для относительно небольших входных данных (порядка 10 000) это было верно (ну, по крайней мере, более чем в два раза быстрее). Однако среда выполнения ужасно ухудшается, когда размер проблемы становится больше (N > 5,000,000).

причина очевидна: сортировка radix требует копирования всех данных (на самом деле, более одного раза в моей наивной реализации). Это означает, что я поместил ~ 4 GiB в свою основную память, что, очевидно, убивает производительность. Даже если это не так, я не могу позволить себе использовать столько памяти, так как размеры проблемы на самом деле становятся еще больше.

Варианты Использования

В идеале этот алгоритм должен работать с любой длиной строки от 2 до 100, для ДНК, а также DNA5 (что позволяет дополнительный подстановочный знак "N"), или даже ДНК с IUPAC неясность коды (в результате 16 различных значений). Однако я понимаю, что все эти случаи не могут быть охвачены, поэтому я доволен любым улучшением скорости, которое я получаю. Код может динамически решать, какой алгоритм отправлять.

исследования

к сожалению,статья Википедии о сортировке radix бесполезно. Раздел о варианте на месте-полная чушь. The раздел NIST-DADS на Сортировке radix рядом с несуществующим. Есть многообещающая статья под названием Эффективная Адаптивная Сортировка На Месте Radix который описывает алгоритм "MSL". К сожалению, этот документ также разочаровывает.

в частности, есть следующие вещи.

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

и последнее, но не менее важное: алгоритм достигает in-place-ness путем замены индексов массива элементами внутри входного массива. Это, очевидно, работает только на числовом матрицы. Мне нужно использовать его на струнах. Конечно, я мог бы просто вкрутить сильную типизацию и идти вперед, предполагая, что память будет терпеть мое хранение индекса там, где он не принадлежит. Но это работает только до тех пор, пока я могу сжать свои строки в 32 бита памяти (предполагая 32-битные целые числа). Это всего лишь 16 символов(давайте проигнорируем на данный момент, что 16 > log (5,000,000)).

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

напомним: есть ли надежда найти рабочую эталонную реализацию или, по крайней мере, хороший псевдокод/описание рабочей сортировки radix на месте, которая работает на строках ДНК?

15 ответов


Ну, вот простая реализация сортировки MSD radix для ДНК. Она написана на языке "Д", потому что это язык, который я использую чаще всего и, следовательно, наименее склонен к глупым ошибкам, но ее можно легко перевести на какой-то другой язык. Это на месте, но требует 2 * seq.length проходит через массив.

void radixSort(string[] seqs, size_t base = 0) {
    if(seqs.length == 0)
        return;

    size_t TPos = seqs.length, APos = 0;
    size_t i = 0;
    while(i < TPos) {
        if(seqs[i][base] == 'A') {
             swap(seqs[i], seqs[APos++]);
             i++;
        }
        else if(seqs[i][base] == 'T') {
            swap(seqs[i], seqs[--TPos]);
        } else i++;
    }

    i = APos;
    size_t CPos = APos;
    while(i < TPos) {
        if(seqs[i][base] == 'C') {
            swap(seqs[i], seqs[CPos++]);
        }
        i++;
    }
    if(base < seqs[0].length - 1) {
        radixSort(seqs[0..APos], base + 1);
        radixSort(seqs[APos..CPos], base + 1);
        radixSort(seqs[CPos..TPos], base + 1);
        radixSort(seqs[TPos..seqs.length], base + 1);
   }
}

очевидно, что это своего рода специфично для ДНК, в отличие от общего, но это должно быть быстро.

Edit:

мне стало любопытно действительно ли этот код работает, поэтому я протестировал / отладил его, ожидая запуска моего собственного кода биоинформатики. Версия выше теперь фактически протестирована и работает. За 10 миллионов последовательностей из 5 баз каждого, это примерно в 3 раза быстрее, чем оптимизированный introsort.


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

причина:

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

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

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

Я понятия не имею, работает ли это на практике.

Btw: если вы имеете дело только со строками ДНК: вы можете сжать символ в два бита и упаковать свои данные довольно много. Это позволит сократить требования к памяти четыре фактора за представление naiive. Адресация становится более сложной, но ALU вашего процессора имеет много времени, чтобы потратить во время всех пропусков кэша в любом случае.


основываясь на коде dsimcha, я реализовал более общую версию, которая хорошо вписывается в рамки, которые мы используем (SeqAn). На самом деле, портирование кода было совершенно простым. Только потом я обнаружил, что там ... --4-- > are собственно публикации, касающиеся именно этой темы. Самое замечательное: они в основном говорят то же самое, что и вы, ребята. Статья Андерссона и Нильссона о Реализация Radixsort определенно стоит прочитать. Если вы знаете немецкий, обязательно также прочитайте Дэвид Уиз дипломная работа где он реализует общий индекс подстроки. Большая часть диссертации посвящена детальному анализу стоимости построения индекса с учетом вторичной памяти и чрезвычайно больших файлов. Результаты его работы фактически были реализованы в Секане, только не в тех частях, где это было необходимо.

просто для удовольствия, вот код, который я написал (Я не думаю, что кто-то, не использующий SeqAn, будет использовать его). Заметьте, что это все еще не считает радиусы больше 4. Я ожидаю, что это окажет огромное влияние на производительность, но, к сожалению, у меня просто нет времени на реализацию этого.

код выполняет более чем в два раза так быстро, как Introsort для коротких строк. Точка безубыточности находится на длине около 12-13. Тип строки (например, имеет ли она 4, 5 или 16 различных значений) сравнительно неважен. Сортировка > 6,000,000 считывает ДНК из хромосомы 2 генома человека чуть более 2 секунд на моем компьютере. Просто для протокола, это быстро! Особенно учитывая, что я не использую SIMD или любое другое аппаратное ускорение. Кроме того, отчет показывает, что основным узким местом является operator new в строке задания. Он вызывается около 65,000,000 раз-десять раз для каждой строки! Это мертвая раздача, что swap может быть оптимизирован для этих строк: вместо создания копий он может просто поменять местами все символы. Я не пробовал, но я убежден. что это будет чертовски важно. И еще раз, на случай, если кто-то не слушает:--4-->размер radix почти не влияет на время выполнения - что означает, что я должен наверняка попробуйте реализовать предложение, сделанное FryGuy, Stephan и EvilTeach.

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

namespace seqan {

template <typename It, typename F, typename T>
inline void prescan(It front, It back, F op, T const& id) {
    using namespace std;
    if (front == back) return;
    typename iterator_traits<It>::value_type accu = *front;
    *front++ = id;
    for (; front != back; ++front) {
        swap(*front, accu);
        accu = op(accu, *front);
    }
}

template <typename TIter, typename TSize, unsigned int RADIX>
inline void radix_permute(TIter front, TIter back, TSize (& bounds)[RADIX], TSize base) {
    for (TIter i = front; i != back; ++i)
        ++bounds[static_cast<unsigned int>((*i)[base])];

    TSize fronts[RADIX];

    std::copy(bounds, bounds + RADIX, fronts);
    prescan(fronts, fronts + RADIX, std::plus<TSize>(), 0);
    std::transform(bounds, bounds + RADIX, fronts, bounds, plus<TSize>());

    TSize active_base = 0;

    for (TIter i = front; i != back; ) {
        if (active_base == RADIX - 1)
            return;
        while (fronts[active_base] >= bounds[active_base])
            if (++active_base == RADIX - 1)
                return;
        TSize current_base = static_cast<unsigned int>((*i)[base]);
        if (current_base <= active_base)
            ++i;
        else
            std::iter_swap(i, front + fronts[current_base]);
        ++fronts[current_base];
    }
}

template <typename TIter, typename TSize>
inline void insertion_sort(TIter front, TIter back, TSize base) {
    typedef typename Value<TIter>::Type T;
    struct {
        TSize base, len;
        bool operator ()(T const& a, T const& b) {
            for (TSize i = base; i < len; ++i)
                if (a[i] < b[i]) return true;
                else if (a[i] > b[i]) return false;
            return false;
        }
    } cmp = { base, length(*front) }; // No closures yet. :-(

    for (TIter i = front + 1; i != back; ++i) {
        T value = *i;
        TIter j = i;
        for ( ; j != front && cmp(value, *(j - 1)); --j)
            *j = *(j - 1);
        if (j != i)
            *j = value;
    }
}

template <typename TIter, typename TSize, unsigned int RADIX>
inline void radix(TIter top, TIter front, TIter back, TSize base, TSize (& parent_bounds)[RADIX], TSize next) {
    if (back - front > 20) {
        TSize bounds[RADIX] = { 0 };
        radix_permute(front, back, bounds, base);

        // Sort current bucket recursively by suffix.
        if (base < length(*front) - 1)
            radix(front, front, front + bounds[0], base + 1, bounds, static_cast<TSize>(0));
    }
    else if (back - front > 1)
        insertion_sort(front, back, base);

    // Sort next buckets on same level recursively.
    if (next == RADIX - 1) return;
    radix(top, top + parent_bounds[next], top + parent_bounds[next + 1], base, parent_bounds, next + 1);
}

template <typename TIter>
inline void radix_sort(TIter front, TIter back) {
    typedef typename Container<TIter>::Type TStringSet;
    typedef typename Value<TStringSet>::Type TString;
    typedef typename Value<TString>::Type TChar;
    typedef typename Size<TStringSet>::Type TSize;

    TSize const RADIX = ValueSize<TChar>::VALUE;
    TSize bounds[RADIX];

    radix(front, front, back, static_cast<TSize>(0), bounds, RADIX - 1);
}

} // namespace seqan

вы можете, конечно, отказаться от требований к памяти, кодируя последовательность в битах. Вы смотрите на перестановки, поэтому для длины 2 с "ACGT" это 16 состояний или 4 бита. Для длины 3, это 64 состояния, которые могут быть закодированы в 6 бит. Таким образом, это выглядит как 2 бита для каждой буквы в последовательности или около 32 бит для 16 символов, как вы сказали.

Если есть способ уменьшить количество допустимых "слов", возможно дальнейшее сжатие.

So для последовательностей из длины 3 можно создать 64 ведра, возможно, размером uint32 или uint64. Инициализируйте их до нуля. Повторите свой очень большой список из 3 последовательностей символов и Закодируйте их, как указано выше. Используйте это в качестве индекса и увеличьте это ведро.
Повторяйте это до тех пор, пока все ваши последовательности не будут обработаны.

далее, восстановите свой список.

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

последовательность из 4, добавляет 2 бита, поэтому будет 256 ведер. Последовательность из 5, добавляет 2 бита, поэтому будет 1024 ведра.

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

Я думаю, что это будет быстрее, чем выполнение сортировки на месте, поскольку ведра, вероятно, поместятся в ваш рабочий набор.

вот хак, который показывает технику

#include <iostream>
#include <iomanip>

#include <math.h>

using namespace std;

const int width = 3;
const int bucketCount = exp(width * log(4)) + 1;
      int *bucket = NULL;

const char charMap[4] = {'A', 'C', 'G', 'T'};

void setup
(
    void
)
{
    bucket = new int[bucketCount];
    memset(bucket, '', bucketCount * sizeof(bucket[0]));
}

void teardown
(
    void
)
{
    delete[] bucket;
}

void show
(
    int encoded
)
{
    int z;
    int y;
    int j;
    for (z = width - 1; z >= 0; z--)
    {
        int n = 1;
        for (y = 0; y < z; y++)
            n *= 4;

        j = encoded % n;
        encoded -= j;
        encoded /= n;
        cout << charMap[encoded];
        encoded = j;
    }

    cout << endl;
}

int main(void)
{
    // Sort this sequence
    const char *testSequence = "CAGCCCAAAGGGTTTAGACTTGGTGCGCAGCAGTTAAGATTGTTT";

    size_t testSequenceLength = strlen(testSequence);

    setup();


    // load the sequences into the buckets
    size_t z;
    for (z = 0; z < testSequenceLength; z += width)
    {
        int encoding = 0;

        size_t y;
        for (y = 0; y < width; y++)
        {
            encoding *= 4;

            switch (*(testSequence + z + y))
            {
                case 'A' : encoding += 0; break;
                case 'C' : encoding += 1; break;
                case 'G' : encoding += 2; break;
                case 'T' : encoding += 3; break;
                default  : abort();
            };
        }

        bucket[encoding]++;
    }

    /* show the sorted sequences */ 
    for (z = 0; z < bucketCount; z++)
    {
        while (bucket[z] > 0)
        {
            show(z);
            bucket[z]--;
        }
    }

    teardown();

    return 0;
}

Если ваш набор данных настолько велик, то я бы подумал, что подход на основе буфера на диске будет лучше всего:

sort(List<string> elements, int prefix)
    if (elements.Count < THRESHOLD)
         return InMemoryRadixSort(elements, prefix)
    else
         return DiskBackedRadixSort(elements, prefix)

DiskBackedRadixSort(elements, prefix)
    DiskBackedBuffer<string>[] buckets
    foreach (element in elements)
        buckets[element.MSB(prefix)].Add(element);

    List<string> ret
    foreach (bucket in buckets)
        ret.Add(sort(bucket, prefix + 1))

    return ret

Я бы также экспериментировал с группировкой в большее количество ведер, например, если бы ваша строка была:

GATTACA

первый вызов MSB вернет ведро для GATT (256 полных ведер), таким образом, вы сделаете меньше ветвей буфера на основе диска. Это может улучшить или не улучшить производительность, поэтому экспериментируйте с ним.


Я собираюсь выйти на конечность и предложить вам переключиться на кучу/heapsort как реализация. Это предложение приходит с некоторыми предположениями:

  1. вы контролируете чтение данных
  2. вы можете сделать что-то значимое с отсортированными данными, как только вы "начнете" сортировку.

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

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

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

Что сказал - Если у вас нет контроля над тем, как данные читаются, и он читается синхронно, и вы не имеете никакого использования для отсортированных данных, пока он не будет полностью выписан - игнорировать все это. :(

посмотреть в Википедии статьи:


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

В настоящее время вы заканчиваете касаясь каждого элемента каждой строки, но вы можете сделать лучше!

в частности, a burst сортировка очень хорошо подходит для этого случая. В качестве бонуса, поскольку burstsort основан на попытках, он работает смехотворно хорошо для небольших размеров алфавита, используемых в ДНК / РНК, поскольку вам не нужно создавать какой-либо троичный узел поиска, хэш или другая схема сжатия узлов trie в реализацию trie. Попытки могут быть полезны для вашей конечной цели, подобной суффиксу-массиву.

достойная реализация общего назначения burstsort доступна на source forge по адресу http://sourceforge.net/projects/burstsort/ - но это не на месте.

для целей сравнения реализация C-burstsort, охватываемая в http://www.cs.mu.oz.au / ~rsinha / papers / SinhaRingZobel-2006.pdf бенчмарки 4-5x быстрее, чем quicksort и radix для некоторых типичных рабочих нагрузок.


вы можете попробовать использовать trie. Сортировка данных - это просто итерация по набору данных и вставка его; структура сортируется естественным образом, и вы можете думать о ней как о подобии B-дерева (за исключением того, что вместо сравнения вы всегда использовать косвенные обращения указатель).

поведение кэширования будет благоприятствовать всем внутренним узлам, поэтому вы, вероятно, не улучшите это; но вы также можете возиться с фактором ветвления вашего trie (убедитесь, что каждый узел помещается в одну строку кэша, выделяет узлы trie, похожие на кучу, как непрерывный массив, представляющий обход уровня). Поскольку попытки также являются цифровыми структурами (O (k) insert/find/delete для элементов длины k), вы должны иметь конкурентоспособную производительность для сортировки radix.


Я burstsort упакованное битовое представление строк. Утверждается, что Burstsort имеет гораздо лучшую локальность, чем сорта radix, сохраняя дополнительное использование пространства с попытками взрыва вместо классических попыток. Оригинальная бумага имеет измерения.


вы захотите взглянуть на крупномасштабная обработка последовательности генома по Касахара доктора и Моришита.

строки, состоящие из четырех нуклеотидных букв A, C, G и T, могут быть специально закодированы в целые числа для много более быстрая обработка. Radix sort является одним из многих алгоритмов, обсуждаемых в книге; вы должны быть в состоянии адаптировать принятый ответ на этот вопрос и увидеть большое улучшение производительности.


" сортировка Radix без дополнительного пространства " это документ, посвященный вашей проблеме.


Radix-Sort не является сознательным кэшем и не является самым быстрым алгоритмом сортировки для больших наборов. Вы можете посмотреть на:

вы также можете использовать сжатие и кодировать каждую букву вашей ДНК в 2 бита перед хранением в массиве сортировки.


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

Я предлагаю очень простой подход:

  1. эмпирически оценить наибольший размер m для которого сортировка radix эффективна.
  2. читать блоки m элементы за раз, radix сортирует их и записывает (в буфер памяти, если у вас достаточно памяти, но в противном случае в файл), пока вы не исчерпаете свой ввод.
  3. Mergesort результирующие отсортированные блоки.

Mergesort-самый удобный для кэша алгоритм сортировки, о котором я знаю: "прочитайте следующий элемент из массива A или B, затем напишите элемент в выходной буфер.- Он эффективно работает на накопители на магнитной ленте. Это требует 2n пространство для сортировки n элементы, но я уверен, что значительно улучшенная локальность кэша, которую вы увидите, сделает это неважно - и если вы использовали не-на-месте радикса, вам все равно нужно было дополнительное пространство.

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


похоже, вы решили проблему, но для записи, похоже, что одна версия работоспособной сортировки на месте radix-это "сортировка американского флага". Это описано здесь: Инженерная Сортировка Radix. Общая идея состоит в том, чтобы сделать 2 прохода по каждому символу - сначала подсчитайте, сколько у вас есть, чтобы вы могли разделить входной массив на ячейки. Затем пройдите снова, заменяя каждый элемент в правильном бункере. Теперь рекурсивно отсортируйте каждый bin на следующем символе позиция.


во-первых, подумайте о кодировании вашу проблему. Избавьтесь от строк, замените их двоичным представлением. Используйте первый байт, чтобы указать length + encoding. Кроме того, используйте представление фиксированной длины на четырехбайтовой границе. Тогда сортировка radix становится намного проще. Для сортировки radix самое главное-не иметь обработки исключений в горячей точке внутреннего цикла.

хорошо, я подумал немного больше о проблеме 4-nary. Вы хотите решение как Джуди дерево для этого. Следующее решение может обрабатывать строки переменной длины; для фиксированной длины просто удалите биты длины, что на самом деле упрощает работу.

выделить блоки из 16 указателей. Наименее значительный бит указателей можно использовать повторно, так как ваши блоки всегда будут выровнены. Вам может понадобиться специальный распределитель хранилища для него (разбиение большого хранилища на более мелкие блоки). Существует несколько различных видов блоков:

  • кодировка с 7 битами длины строк переменной длины. Когда они заполняются, вы заменяете их:
  • позиция кодирует следующие два символа, у вас есть 16 указателей на следующие блоки, заканчивающиеся на:
  • растровая кодировка последних трех символов строки.

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

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

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

вы, вероятно, хотите начать с широкого прямого радиуса 256 для первых четырех письмена. Это обеспечивает достойный компромисс пространства/времени. В данной реализации вы получаете гораздо меньше памяти, чем с помощью простого дерева; это примерно в три раза меньше (я не измерял). O(n) не проблема, если константа достаточно низкая, как вы заметили при сравнении с O (N log n) quicksort.

вы заинтересованы в обработке двойников? С короткими последовательностями,они будут. Адаптация блоков для обработки подсчетов сложна, но может быть очень космос-эффективный.