Сергей Деревяго

DersCountsort: три независимых этапа



1. Введение

Ребята, это жесть!

Ну вот представьте Бизнес тазики:

  1. Бывалый работник за час выбьет тазик из жести. Плюс за час второй тазик... Как ни стучи, но на N тазиков потребно N часов!
  2. А инженер создаст пресс-форму: Хлоп! Хлоп! Хлоп! Вам за секунду вылетает новый тазик.
Эх, вот бы так бы сортировку!! Сформировал сорт-форму и Хлоп! Хлоп!..

Да-да, по правде говоря, я снова сделал невозможное возможным:

  1. Формируете сортирующую последовательность. Это максимум, O(C*log(C)) операций.
  2. А после много раз derscountsort()! И всякий час O(N).
С уважением, Сергей Деревяго.

2. perf12: производительность

Чтоб не тянуть резину в долгий ящик, давайте сразу же запустим тест:

perf\perf12\main.cpp
const int N=1000000;
const int M=100;

template<class T>
void stdsort(int size, const T* arr, int* out)
{
    for (int i=0; i<size; i++) out[i]=i;
    sort(out, out+size, [=](int a, int b) { return arr[a]<arr[b]; });
}

int main(int argc, char** argv)
{
    mem_pool mpo, *mp=&mpo;
    fd_file out(mp, fd_out), err(mp, fd_err);

    try {
        // ...

        vector<int> v(N);
        for (int i=0; i<N; i++) v[i]=rand30()%card;

        vector<int> s1(v.size()), s2(v.size());

        derscountbuf dbuf;
        vector<int> cnts(card);
        vector<ushort> ids(v.size());
        derscountids(v.size(), v.data(), card, cnts.data(), ids.data(), [](int a) { return a; });

        auto t1=steady_clock::now();
        for (int j=0; j<M; j++) {
            stdsort(v.size(), v.data(), s1.data());
        }
        auto t2=steady_clock::now();
        for (int j=0; j<M; j++) {
            derscountsort(&dbuf, ids.size(), ids.data(), 0, card, cnts.data(), 0, s2.data(),
                [](ushort a) { return a; });
        }
        auto t3=steady_clock::now();

        for (int i=0; i<N; i++) perm_assert(v[s1[i]]==v[s2[i]]);

        out.ex_write(tx_buf(mp)+"std\t"+card+'\t'+duration_cast<milliseconds>(t2-t1).count()+'\n');
        out.ex_write(tx_buf(mp)+"ders\t"+card+'\t'+duration_cast<milliseconds>(t3-t2).count()+'\n');

        return 0;
    }
    catch (...) {
        err.ex_write(toTextAll(get(recatch(mp, FLLN, false)))->str());
        return 2;
    }
}

Миллион элементов с заданной кардинальностью:

cardstddersstd/ders
2211210719.7
1032149932.5
100510710648.2
1000669619135.1
10000816039620.6
65535955252418.2

И в этом случае derscountsort() от 20 до 50 раз быстрее!!

Но так как случаи бывают разные, имеет смысл упомянуть нюанс:


3. Реализация DersCountsort

А гениальность в том, чтоб разложить на части неделимое.

Уж с давних пор так повелось, что сортирока -- это вызов функции. Нельзя наковырять одну начинку!

Мне удалось разделаться с DersCountsort и разложить на три самодостаточных этапа:

  1. Создание сортирующей последовательности: O(C*log(C)).
  2. Подсчет количества элементов: O(N).
  3. Сортировка: O(N).
Ну и зачем?! Ведь однократная сортировка уникального массива от этого только замедлится.

Да, однократная и уникального. Но оглядитесь и принюхайтесь! Ведь сплошь и рядом мы неоднократно сортируем почти такие же по сути данные в почти таком же, так сказать, порядке... Хлоп! Хлоп! Хлоп!

Спасибо за аплодисменты! Доселе мыслил не дождусь, ну а поди ж ты.

Все дело в том, что небольшие изменения массива можно быстро применить к результатам предыдущих этапов и расторопно отсортировать. Излишне все этапы переделывать!


3.1. Немного теории

Все дети знают, что: На этом фоне удивительно, но факт: derscountsort() полезен и в любом порядке сортирует за O(N)!

Невозможно? Давайте включим свет и разберемся.

Но перво-наперво заметим, что сортируемый массив обычно состоит из двух неодинаковых частей:

  1. Уникальные элементы в количестве C (cardinality, т.е. кардинальность).
  2. Повторяющиеся элементы в количестве N-C.
Так вот! Математическое ограничение сложности распространяется только на уникальные элементы: в общем случае нам придется потратить не менее O(C*log(C)) операций на создание сортирующей последовательности. А сортировка будет стоить токмо O(N)!

derscountsort() так и работает: всегда за O(N), т.к. использует уже готовую последовательность! А что кто-то когда-то потратил O(C*log(C)) уже не относится к делу, т.к. ее потом попользуют неоднократно.

Ну, хорошо. А если элементы уникальны?

Да, в этом случае не будет Чуда! Ибо O(C*log(C)) становится O(N*log(N)) и сотворение последовательности эквивалентно полной сортировке.

Но не забудьте про повторы! Ведь (пере)сортировка почти тех же данных в почти том же порядке уже стоит дешево. И в целом можно утверждать, что практическое использование DersCountsort имеет амортизированную сложность O(N)!


3.2. Идентификаторы

Идентификаторы в DersCountsort играют такую же важную роль, как компараторы в std::sort()! Вы хотите об этом поговорить?

Тогда приступим с кардинальности. И как казано свыше, любой массив длины N всегда содержит С уникальных элементов (1<=С<=N).

Тем самым, каждому из уникальных мы можем присвоить номер-идентификатор (число от 0 до C-1), зело устанавливая взаимно однозначное соответствие.

Короче, idnt() -- это функтор, концептуально, эдакой сигнатуры: ushort idnt(const T&). Т.е. поддерживается кардинальность до 65535, не более.

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

Итак. Мы вызываем функтор idnt() для каждого сплошного элемента, что намекает: его скорость все определяет! Пришло время воззриться на разные случаи:


3.3. Сортирующая последовательность

А вот и Бриллиант Сказания!

Айда заметим, что в процессе сортировки:

Т.е. достаточно лишь раз создать себе последовательность и потом много раз ее задействовать... Шо, Революция?!

Да, Революция! Ну а теперь детали.

Чисто технически, сортирующая последовательность -- это массив всех чисел от 0 до C-1. В произвольном порядке.

Вот здесь подробнее описаны перестановки и последовательности. А так же функция to_sequence().

Ну а я же лишь просто отмечу, что последовательность:

А в общем случае используйте DersOversort -- гарантированные O(C*log(C)) для любой кардинальности.

Так, что еще. Ну да, конечно же dersort_append()!

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

  1. Вызываем to_sequence() для превращения последовательности в перестановку.
  2. Два-три раза dersort_append().
  3. Ну и, конечно же, опять to_sequence() для возвращения к последовательности.
Все операции лишь O(C)! Весьма недурно для больших массивов.

3.4. Функция derscountids()

Боевой Листок должен быть боевым листком, ведь это же Боевой Листок!

Э... Так и DersCountsort обязан что-то каунтить:

derslib\inc\ders\derscountsort.hpp
template<class T, class ID>
void derscountids(int size, const T* arr, ushort card, int* cnts, ushort* ids, ID idnt)
{
    for (ushort i=0; i<card; i++) cnts[i]=0;

    if (ids) {
        for (int i=0; i<size; i++) {
            ushort id=idnt(arr[i]);
            assert(id<card);

            cnts[id]++;
            ids[i]=id;
        }
    }
    else {
        for (int i=0; i<size; i++) {
            ushort id=idnt(arr[i]);
            assert(id<card);
            cnts[id]++;
        }
    }
}

Как грабли просто это чтиво, но есть и важная деталь:

Деталь? Так вот она деталь: когда не идеален функтор-идентификатор, имеет смысл запомнить вычисления id, чтобы потом не пересчитывать.

Не пересчитывать?!

Ну да, для наведения порядка derscountsort()-у хватит ids.

Еще забавная деталь? Так, иногда (почти всегда?) уже имеется готовый cnts, но массив вдруг расширился свежим хвостом... Ага, издоволь просто отработать по хвосту!


3.5. Функция derscountsort()

И вот мы в Финале, судья дал свисток!

derslib\inc\ders\derscountsort.hpp
template<class T, class ID>
void derscountsort(derscountbuf* buf, int size, const T* arr, const int* prev,
    ushort card, const int* cnts, const ushort* seq, int* out, ID idnt)
{
    buf->reserve(card);
    auto pos=buf->pos;

    int sum=0;
    if (seq) {
        for (ushort i=0; i<card; i++) {
            ushort si=seq[i];
            assert(si<card);

            pos[si]=sum;
            sum+=cnts[si];
        }
    }
    else {
        for (ushort i=0; i<card; i++) {
            pos[i]=sum;
            sum+=cnts[i];
        }
    }
    assert(sum==size);

    if (prev) {
        for (int i=0; i<size; i++) {
            int pi=prev[i];
            assert(pi<size);

            ushort id=idnt(arr[pi]);
            assert(id<card);
            out[pos[id]++]=pi;
        }
    }
    else {
        for (int i=0; i<size; i++) {
            ushort id=idnt(arr[i]);
            assert(id<card);
            out[pos[id]++]=i;
        }
    }
}

На удивление, derscountsort() и правда сортирует!

Не удивительно? Зато красиво: всего лишь out[pos[id]++]=i!

OK, все дружно челюсть поднимаем и вникаем:


4. Дополнительные возможности

4.1. Сортировка по нескольким полям

Все дети любят идентификаторы -- это радует!

Но как быть, если сразу два поля, и потребен нам ORDER BY A,B?

В стандартном сортинге мы просто изменяем компаратор:

sort(v.begin(), v.end(), [](const AB& e1, const AB& e2) {
    if (e1.a!=e2.a) return e1.a<e2.a;
    return e1.b<e2.b;
});

С DersCountsort-ом этот фокус не пройдет!

Излишни фокусы, когда устойчив алгоритм. Два раза прозаично сортируем: по B и A -- так победим!

perf\perf12\main.cpp
struct AB {
    int a, b;
};

void test2()
{
    // ...

    vector<int> cntsa(CA), cntsb(CB);
    derscountids(v.size(), v.data(), CA, cntsa.data(), 0, [](const AB& e) { return e.a; });
    derscountids(v.size(), v.data(), CB, cntsb.data(), 0, [](const AB& e) { return e.b; });

    derscountbuf dbuf;
    vector<int> sa(v.size()), sb(v.size());
    derscountsort(&dbuf, v.size(), v.data(), 0, CB, cntsb.data(), 0, sb.data(),
        [](const AB& e) { return e.b; });
    derscountsort(&dbuf, v.size(), v.data(), sb.data(), CA, cntsa.data(), 0, sa.data(),
        [](const AB& e) { return e.a; });

    vector<AB> v2(v);
    sort(v2.begin(), v2.end(), [](const AB& e1, const AB& e2) {
        if (e1.a!=e2.a) return e1.a<e2.a;
        return e1.b<e2.b;
    });
    for (int i=0; i<N; i++) perm_assert(v[sa[i]].a==v2[i].a && v[sa[i]].b==v2[i].b);
}

Читатель пристальный немедленно заметит: во второй вызов мы передаем sb.data(). Тот самый редкий вариант, когда без prev ни в коем случае!

Ну вот и все: O(N) два раза все равно для всех O(N)!


4.2. Непредсказуемые ключи

Долой учебные примеры!

Легко и просто ведь сортировать массивы идеальных идентификаторов. Но в жизни это не бывает!

А вы уверены? Может вспомним внезапно Star schema: Fact tables generally consist of numeric values, and foreign keys to dimensional data... Dimension tables usually have a relatively small number of records.

Все это значит, что таблицы Фактов имеют УЙМИЩЕ колонок с низкой кардинальностью: идеальная пища для DersCountsort!

Но мы опять вернемся к сложным случаям. Таким, как сортировка слов из книги.

В обычной книге среднего объема (около 300 страниц) содержится примерно 70000-100000 слов, но уникальных слов обычно 5000-15000.

Ага! Прекрасно для DersCountsort, вот только как присвоить идентификаторы?

Ответ известен: хеш-таблица! И таким образом:

  1. Создайте мою хеш-таблицу для слов.
  2. Пройдитесь по массиву слов и вставьте уникальные в таблицу. Еще по ходу создадите массив идентификаторов ids и подобьете счетчики cnts.
  3. Создайте сортирующую последовательность. Кто первый встретился -- ведь так себе порядок!
  4. Все! Можно вызывать derscountsort(). И это будет ОЧЕНЬ эффективно!
Да, хорошо! Но вдруг выходит Юбилейное издание, где автор накропал еще одну главу?

Кому глава, а кому просто хвост массива:

  1. Пройдитесь только по хвосту и вставьте новые слова в таблицу. Еще по ходу допишите идентификаторы и досчитайте счетчики.
  2. А если надо, обновите сортирующую последовательность.
  3. Все! Можно снова вызывать derscountsort(). И это честные O(N)!

4.3. Большая кардинальность

Для кардинальности был выбран тип ushort, и это значит больше 65535 уже не будет!

Но жизнь есть жизнь, и так бывает, надо больше:


5. Заключение

-- А что, отец, -- спросил молодой человек, затянувшись, -- Фундаментальные Открытия у вас в статье есть?
-- Кому и Википедия открытие, -- ответил он, охотно ввязываясь в разговор.

Итак, лишь с появлением DersCountsort мы все узнали, что:

  1. Однократная универсальная сортировка ограничена сложностью O(C*log(C)), а не O(N*log(N)).
  2. А многократная универсальная -- амортизированное O(N)!
И это уже серьезно.
Copyright © С. Деревяго, 2026

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