На миллионе элементов простая сортировка делает триллион операций, это часы. Сортировка слиянием укладывается в O(N·log N), но в классическом виде просит под временный массив столько же памяти, сколько занимают сами данные: на большом массиве это второй такой же. Здесь три алгоритма, которые сортируют так же быстро или почти, но памяти не транжирят: Шелла, быстрая и совсем необычная поразрядная, которая элементы вообще не сравнивает.
Сортировка Шелла
Сортировка Шелла — умная надстройка над сортировкой вставками. Вспомним слабость вставок: маленький элемент из конца придётся тащить к началу по одной позиции, сдвигая всех по пути, — много мелких перемещений.
Идея Шелла: сначала наводить грубый порядок дальними прыжками, а точный — потом. Алгоритм сортирует вставками не соседние элементы, а стоящие на расстоянии h друг от друга («h-сортировка»): такие прыжки быстро подтаскивают далёкие элементы близко к их местам. Затем h уменьшают и повторяют, пока не дойдут до h = 1, то есть до обычных вставок. Но к этому моменту массив уже почти упорядочен, а на таких данных вставки работают почти за O(N).
Последовательность интервалов h подбирают так, чтобы шаги не повторялись зря; популярный вариант (Кнута) — 1, 4, 13, 40, 121, … (каждый следующий h = 3·h + 1).
Почему это вообще важно. Если интервалы кратны друг другу, скажем 8, 4, 2, 1, то проход с h = 4 сравнивает те же элементы, что уже сравнивал проход с h = 8, только внутри своих подмассивов, и часть работы пропадает зря; последовательность Кнута взаимно простыми шагами перемешивает подмассивы так, что каждый проход видит новые пары. От выбора последовательности зависит и сложность: для Кнута оценка около O(N^1.5), для более хитрых последовательностей ниже, а точной общей оценки у сортировки Шелла нет до сих пор, поэтому её сложность в таблицах пишут с оговоркой «зависит от интервалов».
Проход с шагом четыре одним прыжком уводит мелкие числа в начало, и завершающему проходу обычными вставками остаётся мелочь.
живой пример
import java.util.Arrays;
public class ShellDemo {
public static void main(String[] args) {
int[] a = {62, 83, 18, 53, 7, 17, 95, 86, 47, 69};
int moves = 0, h = 1;
while (h <= a.length / 3) h = h * 3 + 1;
while (h > 0) {
for (int i = h; i < a.length; i++) {
int tmp = a[i], j = i;
while (j >= h && a[j - h] > tmp) { a[j] = a[j - h]; j -= h; moves++; }
a[j] = tmp;
}
System.out.println("h = " + h + ": " + Arrays.toString(a));
h = (h - 1) / 3;
}
System.out.println("перемещений всего: " + moves);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
package main
import "fmt"
func main() {
a := []int{62, 83, 18, 53, 7, 17, 95, 86, 47, 69}
moves, h := 0, 1
for h <= len(a)/3 {
h = h*3 + 1
}
for h > 0 {
for i := h; i < len(a); i++ {
tmp, j := a[i], i
for j >= h && a[j-h] > tmp {
a[j] = a[j-h]
j -= h
moves++
}
a[j] = tmp
}
fmt.Printf("h = %d: %v\n", h, a)
h = (h - 1) / 3
}
fmt.Println("перемещений всего:", moves)
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
const a = [62, 83, 18, 53, 7, 17, 95, 86, 47, 69];
let moves = 0;
let h = 1;
while (h <= Math.floor(a.length / 3)) h = h * 3 + 1;
while (h > 0) {
for (let i = h; i < a.length; i++) {
const tmp = a[i];
let j = i;
while (j >= h && a[j - h] > tmp) {
a[j] = a[j - h];
j -= h;
moves++;
}
a[j] = tmp;
}
console.log(`h = ${h}: [${a.join(", ")}]`);
h = Math.floor((h - 1) / 3);
}
console.log("перемещений всего:", moves);
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
a = [62, 83, 18, 53, 7, 17, 95, 86, 47, 69]
moves, h = 0, 1
while h <= len(a) // 3:
h = h * 3 + 1
while h > 0:
for i in range(h, len(a)):
tmp, j = a[i], i
while j >= h and a[j - h] > tmp:
a[j] = a[j - h]
j -= h
moves += 1
a[j] = tmp
print(f"h = {h}: {a}")
h = (h - 1) // 3
print("перемещений всего:", moves)
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Первая строка вывода — состояние после прохода с h = 4: массив ещё не отсортирован, но крупный беспорядок разобран, и проходу с h = 1 остаётся мелочь. Отсюда и выигрыш: на этих же десяти числах обычные вставки делают 21 перемещение, Шелл — 11.
Быстрая сортировка
Быстрая сортировка (quicksort) — самый популярный алгоритм общего назначения. В среднем — O(N·log N), и обычно она обгоняет всех соперников. В основе лежит операция разбиения (partition).
Разбиение выбирает некоторое опорное значение и переставляет массив так, чтобы слева оказались все элементы меньше опорного, а справа — все большие. Сам опорный встаёт ровно на своё окончательное место — границу между группами. Внутри групп порядок пока произвольный.
Рамка идёт слева направо; элементы меньше опорного собираются слева. Опорный встаёт на своё место — граница групп. «Разделяй и властвуй», в среднем O(N·log N).
Дальше — рекурсия в духе «разделяй и властвуй»: тот же приём применяют к левой и правой группам, к их подгруппам и так далее, пока группы не сожмутся до одного элемента. Когда упорядочены все крохотные подмассивы, упорядочен и весь массив.
Посчитаем сравнения на перемешанных и на упорядоченных данных:
живой пример
import java.util.Arrays;
public class QuickDemo {
static int comparisons = 0;
static void quickSort(int[] a, int left, int right) {
if (right <= left) return;
int p = partition(a, left, right);
quickSort(a, left, p - 1);
quickSort(a, p + 1, right);
}
static int partition(int[] a, int left, int right) {
int pivot = a[right], i = left;
for (int j = left; j < right; j++) {
comparisons++;
if (a[j] < pivot) swap(a, i++, j);
}
swap(a, i, right);
return i;
}
static void swap(int[] a, int i, int j) { int t = a[i]; a[i] = a[j]; a[j] = t; }
public static void main(String[] args) {
int[] mixed = {3, 8, 2, 7, 1, 6, 5, 4};
quickSort(mixed, 0, 7);
System.out.println("вперемешку: " + Arrays.toString(mixed) + ", сравнений " + comparisons);
comparisons = 0;
quickSort(new int[]{1, 2, 3, 4, 5, 6, 7, 8}, 0, 7);
System.out.println("уже по возрастанию: сравнений " + comparisons);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
package main
import "fmt"
var comparisons = 0
func quickSort(a []int, left, right int) {
if right <= left {
return
}
p := partition(a, left, right)
quickSort(a, left, p-1)
quickSort(a, p+1, right)
}
func partition(a []int, left, right int) int {
pivot, i := a[right], left
for j := left; j < right; j++ {
comparisons++
if a[j] < pivot {
a[i], a[j] = a[j], a[i]
i++
}
}
a[i], a[right] = a[right], a[i]
return i
}
func main() {
mixed := []int{3, 8, 2, 7, 1, 6, 5, 4}
quickSort(mixed, 0, 7)
fmt.Printf("вперемешку: %v, сравнений %d\n", mixed, comparisons)
comparisons = 0
quickSort([]int{1, 2, 3, 4, 5, 6, 7, 8}, 0, 7)
fmt.Println("уже по возрастанию: сравнений", comparisons)
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
let comparisons = 0;
function quickSort(a, left, right) {
if (right <= left) return;
const p = partition(a, left, right);
quickSort(a, left, p - 1);
quickSort(a, p + 1, right);
}
function partition(a, left, right) {
const pivot = a[right];
let i = left;
for (let j = left; j < right; j++) {
comparisons++;
if (a[j] < pivot) swap(a, i++, j);
}
swap(a, i, right);
return i;
}
function swap(a, i, j) { [a[i], a[j]] = [a[j], a[i]]; }
const mixed = [3, 8, 2, 7, 1, 6, 5, 4];
quickSort(mixed, 0, 7);
console.log(`вперемешку: [${mixed.join(", ")}], сравнений ${comparisons}`);
comparisons = 0;
quickSort([1, 2, 3, 4, 5, 6, 7, 8], 0, 7);
console.log(`уже по возрастанию: сравнений ${comparisons}`);
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
comparisons = 0
def quick_sort(a: list, left: int, right: int) -> None:
if right <= left:
return
p = partition(a, left, right)
quick_sort(a, left, p - 1)
quick_sort(a, p + 1, right)
def partition(a: list, left: int, right: int) -> int:
global comparisons
pivot, i = a[right], left
for j in range(left, right):
comparisons += 1
if a[j] < pivot:
a[i], a[j] = a[j], a[i]
i += 1
a[i], a[right] = a[right], a[i]
return i
mixed = [3, 8, 2, 7, 1, 6, 5, 4]
quick_sort(mixed, 0, 7)
print(f"вперемешку: {mixed}, сравнений {comparisons}")
comparisons = 0
quick_sort([1, 2, 3, 4, 5, 6, 7, 8], 0, 7)
print(f"уже по возрастанию: сравнений {comparisons}")
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Ахиллесова пята. Скорость зависит от того, насколько удачно опорный элемент делит массив пополам. Делит поровну — получаем log N уровней и O(N·log N). Пример показывает и обратный случай. На перемешанных числах 14 сравнений, на упорядоченных 28, ровно N·(N−1)/2. Опорным здесь берётся последний элемент, а на отсортированных данных он каждый раз наибольший: разбиение отсекает по одному элементу, уровней становится N, и алгоритм вырождается в O(N²), медленнее простых сортировок.
Смотрите на число уровней: деление пополам сводит восемь элементов к одиночкам за три шага, а крайний опорный отрезает по одному и растягивает работу на семь.
Второй вход, на котором эта схема разбиения ломается, в жизни встречается даже чаще отсортированного — массив из одинаковых значений. Проверка a[j] < pivot для равных элементов не срабатывает ни разу, граница i не двигается, опорный так и остаётся на краю. Восемь одинаковых чисел дают те же 28 сравнений, что и восемь упорядоченных.
Лекарство — выбирать опорный с умом. Самое простое и самое надёжное: брать опорный случайно. Тогда неудачный вход это не свойство данных, а невезение с вероятностью, которая на большом массиве исчезающе мала, и никакой заранее подобранный вход не заставит алгоритм выродиться. Распространённый приём без случайности — медиана трёх: взять первый, средний и последний элементы и опорным сделать средний из них по величине. Это почти исключает вырождение на упорядоченных данных, хотя против неё существуют специально подобранные входы, а от повторов не спасает: какой элемент ни возьми опорным, если все значения равны, разбиение всё равно отсечёт по одному. Против повторов помогает другая схема — разбиение на три части (меньше опорного, равно, больше), её и берут в библиотеках. Для совсем маленьких подмассивов часто переключаются на сортировку вставками — на десятке элементов она быстрее из-за меньших накладных расходов.
Разбиение на три части в коде отличается от обычного одним дополнительным указателем: слева копятся меньшие, справа большие, а равные опорному остаются в середине и в рекурсию уже не идут.
static void sort3(int[] a, int lo, int hi) {
if (hi <= lo) return;
int lt = lo, gt = hi, i = lo, p = a[lo];
while (i <= gt) {
if (a[i] < p) swap(a, lt++, i++);
else if (a[i] > p) swap(a, i, gt--);
else i++;
}
sort3(a, lo, lt - 1);
sort3(a, gt + 1, hi);
}
func sort3(a []int, lo, hi int) {
if hi <= lo {
return
}
lt, gt, i, p := lo, hi, lo, a[lo]
for i <= gt {
switch {
case a[i] < p:
a[lt], a[i] = a[i], a[lt]
lt++
i++
case a[i] > p:
a[i], a[gt] = a[gt], a[i]
gt--
default:
i++
}
}
sort3(a, lo, lt-1)
sort3(a, gt+1, hi)
}
function sort3(a, lo, hi) {
if (hi <= lo) return;
let lt = lo, gt = hi, i = lo;
const p = a[lo];
while (i <= gt) {
if (a[i] < p) swap(a, lt++, i++);
else if (a[i] > p) swap(a, i, gt--);
else i++;
}
sort3(a, lo, lt - 1);
sort3(a, gt + 1, hi);
}
def sort3(a: list, lo: int, hi: int) -> None:
if hi <= lo:
return
lt, gt, i, p = lo, hi, lo, a[lo]
while i <= gt:
if a[i] < p:
a[lt], a[i] = a[i], a[lt]
lt += 1
i += 1
elif a[i] > p:
a[i], a[gt] = a[gt], a[i]
gt -= 1
else:
i += 1
sort3(a, lo, lt - 1)
sort3(a, gt + 1, hi)
Массив из одинаковых значений это один проход и ноль рекурсивных вызовов, O(N) вместо O(N²). Есть и ещё одна цена быстрой сортировки, о которой забывают, потому что «она сортирует на месте»: стек рекурсии. При удачных разбиениях глубина log N, и это ничтожно, но при вырождении глубина N, и на миллионе элементов это переполнение стека вызовов. Классическая защита: рекурсивно уходить в меньшую из двух частей, а большую обрабатывать циклом в том же вызове; тогда глубина стека не превышает log N даже в худшем случае.
Поразрядная сортировка
Все алгоритмы выше сравнивают элементы между собой. Поразрядная сортировка (radix sort) — принципиально другой подход: она вообще не сравнивает элементы, а раскладывает их по разрядам.
Работает она так (для чисел): смотрим на последнюю цифру каждого числа и раскладываем числа по десяти «корзинам» (0–9) в порядке следования, затем собираем корзины обратно в массив. Повторяем для десятков, потом сотен — и так до самого старшего разряда, после чего массив отсортирован. Держится всё на устойчивости раскладки: числа с одинаковой цифрой сохраняют порядок, наведённый предыдущими разрядами.
Каждый проход раскладывает числа по корзинам одной цифры и собирает обратно, и после старшего разряда массив упорядочен.
Обходясь без сравнений, она обгоняет барьер N·log N. Точная оценка — O(d · N), где d — число разрядов в ключе: столько раз алгоритм пройдёт по массиву.
Когда ширина ключа фиксирована, например все числа помещаются в четыре байта, d константа, и время линейное; отсюда привычное «O(N)». Если ключи длинные и разной длины, множитель d перестаёт быть мелочью.
Вторая плата: она применима лишь к данным, которые раскладываются на разряды, целым числам и строкам фиксированной длины, и требует памяти под корзины. Инструмент специализированный, зато на своих данных непобедимый.
Что выбрать
- Быстрая сортировка — по умолчанию для больших массивов общего вида, с защитой опорного вроде случайного выбора или «медианы трёх». Она неустойчива: разбиение перебрасывает элементы через весь массив и порядок равных не сохраняет, поэтому для объектов с несколькими полями сортировки библиотеки берут слияние.
- Сортировка Шелла — данные среднего размера и важна простота кода; без лишней памяти.
- Сортировка слиянием — нужна гарантия
O(N·log N)без риска вырождения и не жалко памяти. - Поразрядная — большие объёмы целых чисел или коротких строк одинаковой длины.
Как это сделано в стандартной библиотеке
Свою быструю сортировку в рабочем коде почти не пишут: Arrays.sort уже отлажена и учитывает вырожденные случаи лучше самодельной. Знать про неё нужно одно: под этим именем прячутся два разных алгоритма, и выбор идёт по типу данных.
Для примитивов (int[], long[], double[]) работает двухопорная быстрая сортировка (класс в JDK так и называется — DualPivotQuicksort). Это та же схема разбиения, что выше, только опорных элементов два и массив режется не на две части, а на три: меньше первого опорного, между опорными, больше второго. В среднем O(N·log N), лишней памяти почти не просит.
От вырождения в O(N²) есть страховка, появившаяся в Java 14 вместе с переписанным DualPivotQuicksort: если рекурсия уходит подозрительно глубоко — верный признак того, что опорные раз за разом неудачны, — библиотека переключается на пирамидальную сортировку с гарантированной O(N·log N). А самые короткие участки, как обычно, досортировываются вставками.
Для объектов (Arrays.sort(T[], Comparator), Collections.sort, list.sort(...)) работает TimSort: слияние плюс вставки. Он устойчив, ищет в данных уже упорядоченные куски и на почти готовых данных приближается к O(N). Расплата — память: ему нужен временный массив размером до половины исходного — вдвое экономнее классического слияния, с которого статья начиналась.
Разделение не случайное. У примитивов равные значения неразличимы, устойчивость там смысла не имеет — значит, можно взять более быструю и экономную по памяти быструю сортировку. У объектов два элемента с одинаковым ключом различаются всем остальным, и порядок между ними важен — поэтому там слияние, которое даёт устойчивость даром.
Есть ещё Arrays.parallelSort: та же идея, но куски сортируются в нескольких потоках и сливаются. Выигрыш — только на действительно больших массивах: на маленьких раздача работы съедает всё.
Грабля. Arrays.sort(int[]) не принимает компаратор: примитивы сортируются только по возрастанию, и никак иначе. Чтобы получить убывание, приходится заворачивать числа в объекты:
живой пример
import java.util.Arrays;
import java.util.Comparator;
public class ReverseDemo {
public static void main(String[] args) {
Integer[] ids = {5, 2, 9};
Arrays.sort(ids, Comparator.reverseOrder());
System.out.println(Arrays.toString(ids));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
И это уже совсем другая цена: вместо плотного массива чисел — массив ссылок на объекты, вместо быстрой сортировки — TimSort со своим временным массивом. На больших объёмах дешевле отсортировать int[] как есть и развернуть результат одним проходом.
Свою быструю сортировку в рабочем коде почти не пишут: slices.Sort уже отлажена и учитывает вырожденные случаи лучше самодельной. Знать про неё нужно одно: в отличие от Java, под этим именем один алгоритм для всех типов, и он не слияние.
Для всего, что сортируют slices.Sort, slices.SortFunc, sort.Ints и sort.Slice, с Go 1.19 работает pdqsort (pattern-defeating quicksort). Это та же схема разбиения, что выше, с тремя добавками: опорный выбирается медианой из нескольких элементов, уже упорядоченные и обратно упорядоченные куски распознаются и не пересортировываются, поэтому на готовых данных сортировка близка к O(N), а участки короче двенадцати элементов досортировываются вставками. В среднем O(N·log N), лишней памяти почти не просит.
От вырождения в O(N²) есть страховка: если разбиения раз за разом выходят неудачными — верный признак злонамеренного или очень неудачного входа, — библиотека переключается на пирамидальную сортировку с гарантированной O(N·log N). Отсюда и слово «pattern-defeating» в названии: алгоритм побеждает входы, которые ломали классическую быструю сортировку.
Расплата за скорость — неустойчивость: равные по ключу элементы могут поменяться местами. Для объектов, где порядок равных важен, есть slices.SortStableFunc и sort.Stable: внутри вставки блоками и слияние блоков на месте, устойчиво, без временного массива, но ценой лишних обменов: O(N·log N) сравнений и O(N·log² N) обменов. То есть выбор тот же, что в других языках, только делается он не по типу данных, а руками: неустойчиво и быстро по умолчанию, устойчиво по явной просьбе.
Параллельной сортировки в стандартной библиотеке нет: при необходимости срез режут на части по горутинам и сливают, как во внешней сортировке ниже.
Грабля. Срез это окно в общий массив, и slices.Sort переставляет элементы прямо в нём. Отсортировать кусок a[1:4] значит переставить элементы внутри a, а отсортированную копию можно получить только сделав копию: slices.Sorted(slices.Values(a)) с Go 1.23 или slices.Clone перед сортировкой. Убывание при этом дешёвое, никакой упаковки, как в Java, не нужно:
живой пример
package main
import (
"cmp"
"fmt"
"slices"
)
func main() {
a := []int{9, 5, 2, 7, 1}
slices.Sort(a[1:4])
fmt.Println("отсортировали кусок, а поменялся исходный:", a)
ids := []int{5, 2, 9}
slices.SortFunc(ids, func(x, y int) int { return cmp.Compare(y, x) })
fmt.Println("по убыванию:", ids)
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Первая строка показывает, что переставились именно элементы a с первого по третий. Вторая — что для убывания достаточно поменять местами аргументы в cmp.Compare; тот же результат даёт slices.Sort и затем slices.Reverse.
Свою быструю сортировку в рабочем коде почти не пишут: array.sort уже отлажена и учитывает вырожденные случаи лучше самодельной. Знать про неё нужно одно: в отличие от Java, под этим именем один алгоритм для всех данных, и это не быстрая сортировка.
Для обычных массивов в движке V8 с 2018 года работает TimSort: слияние плюс вставки. Он устойчив, и с ES2019 это требование стандарта, ищет в данных уже упорядоченные куски и на почти готовых данных приближается к O(N). Расплата — память: ему нужен временный массив размером до половины исходного. Быстрой сортировки с её экономией памяти для обычных массивов в языке нет, и выбрать её нельзя.
Разделение по типу данных всё же есть, но проходит оно по другой границе: у типизированных массивов (Int32Array, Float64Array) свой sort, который без функции сравнения сортирует числа как числа, а не как строки, и делает это заметно быстрее обычного массива: элементы лежат плотно, сравнивать их дёшево, а устойчивость для одинаковых чисел не наблюдаема. Для больших числовых данных это и есть аналог быстрой сортировки примитивов.
Параллельной сортировки нет: JavaScript однопоточный, а раздать работу воркерам через worker_threads с общим SharedArrayBuffer почти всегда дороже, чем отсортировать на месте.
Грабля. sort не возвращает новый массив, а переставляет элементы в исходном и возвращает его же. Строчка const sorted = items.sort(...) создаёт иллюзию копии, а на деле items уже отсортирован, и код, который полагался на исходный порядок, ломается далеко от места сортировки. Убывание при этом дешёвое, никакой упаковки, как в Java, не нужно:
живой пример
const ids = [5, 2, 9];
const desc = ids.sort((a, b) => b - a);
console.log("по убыванию:", desc, "исходный:", ids, "один и тот же массив:", desc === ids);
console.log("копия через toSorted:", [5, 2, 9].toSorted((a, b) => b - a));
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Первая строка показывает, что desc и ids — один объект. Вторая — toSorted из ES2023 (Node 20), которая возвращает копию и исходный массив не трогает.
Свою быструю сортировку в рабочем коде почти не пишут: sorted уже отлажена и учитывает вырожденные случаи лучше самодельной. Знать про неё нужно одно: в отличие от Java, под этим именем один алгоритм для всех данных, и это не быстрая сортировка.
И sorted, и list.sort — это TimSort: слияние плюс вставки, придуманный для Python и потом перенятый Java и V8. Он устойчив, и это гарантия языка, ищет в данных уже упорядоченные куски и на почти готовых данных приближается к O(N). Расплата — память: ему нужен временный массив размером до половины исходного. Быстрой сортировки с её экономией памяти в стандартной библиотеке нет, и выбрать её нельзя; когда данных много и это числа, берут NumPy, у которого np.sort предлагает и быструю (по умолчанию, точнее introsort), и устойчивую, и поразрядную для целых.
Разделения по типу данных нет: числа и объекты идут через одну и ту же сортировку, разница только в цене сравнения. У чисел она мала, но каждое число всё равно отдельный объект, и это главная причина, почему сортировка миллиона чисел в Python в десятки раз медленнее, чем в Go или Java, и почему для чисел зовут NumPy.
Параллельной сортировки нет: глобальная блокировка интерпретатора не даёт потокам сортировать одновременно, а раздача по процессам окупается только на очень больших данных.
Грабля. У list.sort() и sorted() разные возвращаемые значения: первая переставляет элементы на месте и возвращает None, вторая строит новый список. Строчка ids = ids.sort() тихо оставляет в ids пустоту. Убывание при этом дешёвое: параметр reverse=True, и никакой упаковки, как в Java, не нужно:
живой пример
ids = [5, 2, 9]
print("по убыванию, копия:", sorted(ids, reverse=True))
print("на месте, вернул:", ids.sort(reverse=True))
print("сам список:", ids)
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Первая строка отсортированная копия, исходный список не тронут. Вторая печатает None: это и есть ловушка присваивания. Третья показывает, что ids при этом отсортирован. reverse=True сохраняет устойчивость: равные элементы остаются в исходном порядке, а не переворачиваются.
Глубже: сортировка слиянием: гарантия без вырождения и слияние двух списковрасширенное
Быстрая сортировка выше в среднем лучшая, но «в среднем» значит, что есть вход, на котором она разваливается, а выбор опорного элемента это борьба со случаем. Сортировка слиянием устроена наоборот: она не выбирает ничего и поэтому ведёт себя одинаково на любом входе.
Идея из двух шагов. Разрезать массив пополам, отсортировать каждую половину тем же способом (рекурсия заканчивается на массиве из одного элемента, он уже упорядочен), а потом слить две отсортированные половины в одну: два указателя на началах половин, в результат уходит меньший из двух текущих элементов, и его указатель сдвигается. Слияние проходит оба куска один раз, O(N), а уровней разрезания log N, итого O(N·log N) всегда, что бы ни лежало в массиве. Второе свойство достаётся даром: если при равных элементах брать из левой половины, порядок равных сохраняется, сортировка устойчива. Платят памятью: слиянию нужен буфер на N элементов, тогда как быстрая сортировка переставляет на месте.
живой пример
import java.util.Arrays;
public class MergeSortDemo {
static void mergeSort(int[] a) {
if (a.length < 2) return;
int[] left = Arrays.copyOfRange(a, 0, a.length / 2);
int[] right = Arrays.copyOfRange(a, a.length / 2, a.length);
mergeSort(left);
mergeSort(right);
merge(left, right, a);
}
static void merge(int[] left, int[] right, int[] out) {
int i = 0, j = 0, k = 0;
while (i < left.length && j < right.length) {
out[k++] = left[i] <= right[j] ? left[i++] : right[j++]; // <= держит устойчивость
}
while (i < left.length) out[k++] = left[i++];
while (j < right.length) out[k++] = right[j++];
}
public static void main(String[] args) {
int[] a = {5, 2, 4, 6, 1, 3, 2};
mergeSort(a);
System.out.println(Arrays.toString(a));
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
package main
import "fmt"
func mergeSort(a []int) []int {
if len(a) < 2 {
return a
}
left := mergeSort(a[:len(a)/2])
right := mergeSort(a[len(a)/2:])
return merge(left, right)
}
func merge(left, right []int) []int {
out := make([]int, 0, len(left)+len(right))
i, j := 0, 0
for i < len(left) && j < len(right) {
if left[i] <= right[j] { // <= держит устойчивость
out = append(out, left[i])
i++
} else {
out = append(out, right[j])
j++
}
}
out = append(out, left[i:]...)
return append(out, right[j:]...)
}
func main() {
fmt.Println(mergeSort([]int{5, 2, 4, 6, 1, 3, 2}))
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
function mergeSort(a) {
if (a.length < 2) return a;
const mid = Math.floor(a.length / 2);
return merge(mergeSort(a.slice(0, mid)), mergeSort(a.slice(mid)));
}
function merge(left, right) {
const out = [];
let i = 0, j = 0;
while (i < left.length && j < right.length) {
out.push(left[i] <= right[j] ? left[i++] : right[j++]); // <= держит устойчивость
}
return out.concat(left.slice(i), right.slice(j));
}
console.log(mergeSort([5, 2, 4, 6, 1, 3, 2]));
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
def merge_sort(a):
if len(a) < 2:
return a
mid = len(a) // 2
return merge(merge_sort(a[:mid]), merge_sort(a[mid:]))
def merge(left, right):
out = []
i = j = 0
while i < len(left) and j < len(right):
if left[i] <= right[j]: # <= держит устойчивость
out.append(left[i]); i += 1
else:
out.append(right[j]); j += 1
return out + left[i:] + right[j:]
print(merge_sort([5, 2, 4, 6, 1, 3, 2]))
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Шаг слияния живёт отдельно от сортировки и встречается чаще неё: слить два отсортированных списка заказов с двух складов в один, пересечь два отсортированных набора идентификаторов, собрать K отсортированных кусков (там два указателя превращаются в кучу, следующий раздел). А сама сортировка слиянием это то, что внутри TimSort в Java, Python и V8 для объектов: библиотека ищет в данных уже упорядоченные куски, досортировывает короткие вставками и сливает, получая устойчивость и почти O(N) на почти готовых данных.
Глубже: сортировка файла, который не влезает в памятьрасширенное
Все сортировки выше держат массив целиком в памяти. Файл логов на сто гигабайт при восьми гигабайтах памяти так не отсортировать, и приём для него называется внешней сортировкой. Он двухфазный. Первая фаза: читаем файл кусками, которые в память помещаются, сортируем каждый обычной сортировкой и записываем отсортированный кусок во временный файл. Сто гигабайт при кусках по гигабайту это сто временных файлов. Вторая фаза, слияние: открываем все куски, берём из каждого первую запись, кладём в кучу, забираем минимум, пишем в результат и подтягиваем следующую запись из того же куска. Куча на сто элементов даёт минимум за O(log 100), и всё слияние проходит одним линейным проходом по данным.
// набросок: Run читает свой временный файл, current — текущая запись
PriorityQueue<Run> heads = new PriorityQueue<>(Comparator.comparing(r -> r.current));
for (Run run : runs) if (run.advance()) heads.add(run);
while (!heads.isEmpty()) {
Run r = heads.poll();
out.write(r.current);
if (r.advance()) heads.add(r);
}
// набросок: run читает свой временный файл, current — текущая запись,
// runHeap реализует heap.Interface и сравнивает по current
heads := &runHeap{}
for _, r := range runs {
if r.advance() {
heap.Push(heads, r)
}
}
for heads.Len() > 0 {
r := heap.Pop(heads).(*run)
out.Write(r.current)
if r.advance() {
heap.Push(heads, r)
}
}
// набросок: run читает свой временный файл, current — текущая запись,
// MinHeap — двоичная куча из статьи про кучи с функцией «меньше»
const heads = new MinHeap((a, b) => a.current < b.current);
for (const run of runs) if (run.advance()) heads.push(run);
while (heads.size > 0) {
const r = heads.pop();
out.write(r.current);
if (r.advance()) heads.push(r);
}
В Python этот шаг уже написан: heapq.merge(*runs) лениво сливает отсортированные итераторы через ту же кучу. Набросок показывает, что у него внутри:
# набросок: run читает свой временный файл, current — текущая запись
heads = []
for run in runs:
if run.advance():
heapq.heappush(heads, (run.current, id(run), run))
while heads:
_, _, r = heapq.heappop(heads)
out.write(r.current)
if r.advance():
heapq.heappush(heads, (r.current, id(r), r))
Так работают sort в Unix, ORDER BY в PostgreSQL, когда данные не помещаются в work_mem (в плане это видно как Sort Method: external merge), и сортировка в MapReduce. Ограничитель здесь не процессор, а диск: чем больше куски в первой фазе, тем меньше файлов сливать и меньше случайных чтений во второй. Отсюда практический совет для базы: если EXPLAIN ANALYZE показывает внешнюю сортировку, добавить памяти на сортировку часто дешевле любой оптимизации кода.
Глубже: слияние интервалов: сортировка по событиямрасширенное
Задача про доставки: есть окна времени, часть из них пересекается, нужно склеить пересекающиеся в непрерывные отрезки. Ни два указателя, ни нарастающий итог здесь не подходят напрямую, а решает сортировка. Сортируем интервалы по началу и идём по ним один раз, держа текущий склеенный отрезок: следующий интервал либо начинается внутри текущего, и тогда сдвигаем правую границу, либо позже, и тогда текущий закрыт.
List<int[]> merge(List<int[]> intervals) {
intervals.sort(Comparator.comparingInt(a -> a[0]));
List<int[]> out = new ArrayList<>();
for (int[] cur : intervals) {
int[] last = out.isEmpty() ? null : out.get(out.size() - 1);
if (last != null && cur[0] <= last[1]) last[1] = Math.max(last[1], cur[1]);
else out.add(new int[]{cur[0], cur[1]});
}
return out;
}
func merge(intervals [][2]int) [][2]int {
slices.SortFunc(intervals, func(a, b [2]int) int { return cmp.Compare(a[0], b[0]) })
var out [][2]int
for _, cur := range intervals {
if n := len(out); n > 0 && cur[0] <= out[n-1][1] {
out[n-1][1] = max(out[n-1][1], cur[1])
} else {
out = append(out, cur)
}
}
return out
}
function merge(intervals) {
intervals.sort((a, b) => a[0] - b[0]);
const out = [];
for (const [start, end] of intervals) {
const last = out.at(-1);
if (last && start <= last[1]) last[1] = Math.max(last[1], end);
else out.push([start, end]);
}
return out;
}
def merge(intervals: list[list[int]]) -> list[list[int]]:
intervals.sort(key=lambda iv: iv[0])
out: list[list[int]] = []
for start, end in intervals:
if out and start <= out[-1][1]:
out[-1][1] = max(out[-1][1], end)
else:
out.append([start, end])
return out
O(N log N) на сортировку, дальше линейно. Тот же приём в общем виде называют сортировкой по событиям: каждый интервал превращают в два события, «начало, +1» и «конец, −1», сортируют события по времени и идут по ним со счётчиком. Счётчик в любой момент говорит, сколько интервалов открыто: так находят максимальное число одновременных встреч, нужное количество переговорных, пиковую нагрузку по логам сессий. Когда в задаче есть отрезки на прямой и вопрос про их пересечения, первый ход: отсортировать по началу и пройти один раз.
Коротко
- Сортировка Шелла — вставки с «дальними прыжками»: сортирует элементы на расстоянии
h, постепенно уменьшаяhдо 1. Проста, без лишней памяти, хороша для средних массивов. - Быстрая сортировка — рекурсивное разбиение вокруг опорного элемента; в среднем
O(N·log N), самая быстрая на практике. Вырождается вO(N²), когда опорный раз за разом оказывается крайним (на восьми упорядоченных числах — 28 сравнений вместо 14); спасает «медиана трёх», а от массива из одинаковых значений — разбиение на три части. - Поразрядная — без сравнений, раскладка по разрядам;
O(d · N), то есть линейно при коротком ключе, и только для чисел/строк, с доп. памятью. - Порядок роста
O(N·log N)— практический потолок сравнительных сортировок; быстрее только специализированные методы вроде поразрядной.
- В Java
Arrays.sort— это два алгоритма:DualPivotQuicksortдля примитивов (быстро, без устойчивости) и TimSort для объектов (устойчиво, ценой временного массива).
- В Go
slices.Sort— это pdqsort для всего: быстро, без буфера, но неустойчиво, со страховкой пирамидальной сортировкой; устойчивость даётslices.SortStableFuncценой лишних обменов. Сортировка куска среза переставляет элементы исходного.
- В JavaScript
sort— это TimSort для всего: устойчиво, ценой временного массива; быстрая сортировка недоступна, числовой аналог —sortтипизированных массивов.sortменяет массив на месте, копию даётtoSorted.
- В Python
sortedиlist.sort— это TimSort для всего: устойчиво, ценой временного массива; быстрая сортировка недоступна, для чисел берут NumPy.list.sort()возвращаетNone, убывание даётreverse=True.
- Сортировка слиянием режет пополам и сливает двумя указателями:
O(N·log N)на любом входе и устойчивость ценой буфера на N элементов; шаг слияния сам по себе решает «слить два отсортированных списка». - Файл больше памяти сортируют кусками и сливают кучей за один проход; в PostgreSQL это
Sort Method: external merge. - Пересекающиеся интервалы склеивают после сортировки по началу за один проход; счётчик по событиям «+1/−1» даёт число одновременно открытых.
- Опорный выбирают случайно (никакой вход не вырождает) или медианой трёх; повторы лечит трёхчастное разбиение, а глубину стека держат в log N, уходя рекурсивно только в меньшую часть.
- У Шелла последовательность интервалов важна, потому что кратные шаги повторяют работу; точной общей оценки у неё нет. Быстрая сортировка неустойчива, поэтому для объектов библиотека берёт слияние.
Что почитать дальше
- Двоичные деревья — быстрый поиск упорядоченного массива плюс быстрая вставка списка.
- Пирамиды (кучи) — та самая пирамидальная сортировка, страховка библиотечных сортировок от вырождения.
- Простая сортировка — вставки, из которых выросла сортировка Шелла.
- Рекурсия — «разделяй и властвуй» и сортировка слиянием.