Занятие 23. k-я статистика и сортировки без сравнений
Финальный семинар сортировочной линии — про пределы и их обходы. Занятие 19 ограничило «соседние» алгоритмы, сегодня — строгая нижняя оценка для всех, кто сравнивает: $\Omega(n \log n)$. И два обходных пути из мелкого шрифта теоремы: спросить меньше (k-я статистика за O(n)) или узнать о ключах больше (counting и radix — вообще без сравнений). Замеры реальные (Apple M-серия, clang++ -O2).
| класс алгоритмов | предел | занятие |
|---|---|---|
| обмены только соседей | Ω(Inv) ⇒ в среднем n² | 19 |
| любые сравнения | Ω(n log n) — сегодня | 23 |
| без сравнений | O(n) при условиях | 23 |
k-я порядковая статистика
k-я статистика — элемент, который стоял бы на позиции $k$ в отсортированном массиве. $k = n/2$ — медиана (занятие 10 не зря мерило медиану латентности: выбросы её не сдвигают); $k = 0.99n$ — 99-й перцентиль, язык SLA. Решение «в лоб» — отсортировать и взять a[k] — покупает весь порядок, а нужен один элемент.
Quickselect: партиция занятия 21, рекурсия в одну сторону
После партиции пивот стоит на окончательном месте — если это k, сортировать остальное незачем; иначе k-й прячется ровно в одной из частей, вторую выбрасываем целиком. Родство с бинпоиском (занятие 12) прямое: каждый раунд сужает зону поиска, только границу ставит пивот, а не середина.
Средний случай: случайный пивот отрезает в среднем константную долю, работа падает геометрически — знакомая сумма занятий 6, 10 и 15:
$$n + \frac{n}{2} + \frac{n}{4} + \dots \le 2n = O(n).$$Худший случай — те же killer-входы занятия 21, $n^2$; случайный пивот делает его астрономически редким. Гарантированный O(n) существует — «медиана медиан» (BFPRT, 1973): красивая теория с большой константой, звёздочка в ДЗ.
Контракт std::nth_element
После вызова a[k] — правильный k-й; всё левее — не больше него, всё правее — не меньше; внутри частей порядка нет — за то и скидка. Топ-10 из миллиона: nth_element на k = 10, затем сортировка первых десяти — O(n).
Нижняя оценка Ω(n log n)
Дерево решений
Что бы ни делал алгоритм, сортирующий сравнениями, вся информация о порядке приходит из ответов «да/нет» на вопросы a[i] < a[j] — его работа описывается двоичным деревом решений.
Каждой входной перестановке обязан достаться свой лист: если два разных входа приводят в один лист, алгоритм выполнил для них одинаковые действия — и хотя бы один отсортирован неверно. Листьев минимум $n!$, значит глубина дерева — худшее число сравнений — не меньше $\log_2(n!)$: более мелкое дерево столько листьев физически не вмещает.
Считаем предел — и меряем зазор
$$\log_2(n!) = n \log_2 n - 1.44\,n + O(\log n) \quad \text{(Стирлинг)}$$Для миллиона элементов меньше 18 488 885 сравнений не обойдётся никто и никогда — а наш скромный merge из занятия 21 работает в одном проценте от вечного предела. «Изобрести сортировку сравнениями сильно лучше merge» — невозможно: Ω(n log n) отныне теорема, а не наблюдение.
Мелкий шрифт теоремы: «худший случай» и «сравнениями». Первое обходит адаптивность (занятие 19: Θ(n + Inv)), второе — сегодняшняя вторая половина.
Counting sort: гистограмма вместо сравнений
Ни одного сравнения: ключ сам говорит, куда встать — O(n + K) для K возможных значений. Теорема не нарушена: мы вышли из класса сравнивающих, прочитав ключ как число. Ограничение честное: K мал (байты, оценки, месяцы); для $K = 2^{32}$ таблица счётчиков заняла бы 16 ГБ.
Стабильная версия: префиксные суммы занятия 15
Простая версия теряет исходные объекты — для записей с полезной нагрузкой нужна стабильная раскладка:
Префиксные суммы гистограммы говорят, где начинается группа каждого ключа; проход слева направо раскладывает записи в группы в порядке появления — сортировка стабильна (занятия 19, 21). Стабильность здесь — несущая конструкция: на ней собирается radix.
Radix sort: четыре стабильных прохода
Инвариант radix (LSD)
После $i$ проходов массив отсортирован по младшим $i$ байтам. Индукция держится на стабильности: элементы, равные по текущему байту, сохраняют порядок, установленный проходами по младшим.
Порядок «от младшего к старшему» не случаен: последний проход — по старшему байту — главный, а стабильность сохраняет всё, что решили предыдущие. Итого $4 \cdot O(n + 256) = O(n)$.
Цена и ниша. Уместен: целые ключи фиксированной ширины (id, ip, таймстампы), большие n, «цифруемые» составные ключи (даты, строки фиксированной длины). Цена: буфер O(n), несколько полных проходов по памяти, и произвольный компаратор из занятия 17 не подключить. При малых n или «почти отсортировано» проигрывает introsort и вставкам (занятие 19). Правило то же, что весь курс: чем больше структуры данных использовано, тем ниже предел.
Задачи семинара
-
Quickselect на бумаге. Найдите медиану {7, 2, 9, 4, 1} трассировкой (пивот — последний элемент). Сколько элементов встало на свои места, а сколько осталось хаосом?
-
Дерево для n = 3. Достройте дерево со слайда: 6 листьев, глубина 3; докажите, что двух сравнений мало.
-
Radix для дат (год, месяц, день): какие проходы и в каком порядке?
Домашнее задание (сдача через Git)
- Quickselect со случайным пивотом + стресс-тест против
std::nth_elementна $10^5$ случайных пар (n, k). - Стабильный counting для записей (ключ 0..255 + полезная нагрузка): проверка стабильности на парах, как в занятии 19.
- Radix для uint32 + таблица замеров против std::sort на своей машине для $n = 10^5, 10^6, 10^7$: где точка перелома?
- Дерево решений: полное дерево для n = 3 на бумаге; вычислите $\lceil \log_2(5!) \rceil$ и выясните, достижимо ли это число сравнений для n = 5.
- * Медиана медиан (BFPRT): реализуйте и сравните с quickselect по времени и числу сравнений на killer-входах занятия 21.
Следующее занятие — шаблоны и исключения: функции- и классы-шаблоны, typename против class, концепты C++20 и обработка ошибок через try/catch.