Отсортировать данные приходится постоянно: имена по алфавиту, товары по цене, события по времени. К тому же сортировка — обязательный шаг перед быстрым двоичным поиском: он работает только на упорядоченных данных. Начнём с трёх простейших алгоритмов — не самых быстрых, зато на них видно, как сортировка устроена вообще. Быстрые методы разберём позже.
Две операции, из которых всё собрано
Человек строит шеренгу по росту «на глаз»: он видит всех сразу. Программа так не умеет — она сравнивает только двух за раз. Поэтому любая сортировка складывается из двух повторяющихся действий:
- сравнить два элемента;
- поменять их местами (или скопировать один).
Три алгоритма ниже различаются только тем, как выбирают, кого с кем сравнивать.
Все три к тому же сортируют на месте: переставляют элементы внутри того же массива и не просят памяти под копию. Это свойство скоро станет критерием выбора: сортировка слиянием быстрее, но держит второй массив, и на большом объёме память решает не меньше времени.
Пузырьковая сортировка
Самый простой (и самый медленный) метод. Идём слева направо и сравниваем соседние пары: если левый больше правого — меняем их местами. К концу прохода самый большой элемент оказывается в правом краю — он всплыл, как пузырёк. Следующий проход идёт уже до предпоследней позиции, и так далее.
Рамка идёт по соседним парам; если левый больше — столбики меняются местами. За один проход наибольший (5) «всплывает» в правый край.
В коде это два вложенных цикла: внешний сдвигает влево границу отсортированного хвоста, внутренний гоняет пары до этой границы. Каждый проход ставит на место один элемент. Наглядно, но медленно: сравнений примерно N², перестановок много.
Одна дешёвая поправка пузырьку всё-таки помогает: если за целый проход не случилось ни одной перестановки, массив уже в порядке и дальше идти незачем. С такой проверкой на готовом массиве пузырёк укладывается в один проход — O(N). В коде ниже её нет, поэтому он честно перебирает все пары независимо от того, что ему подсунули.
Сортировка выбором
Здесь мы уменьшаем число перестановок. Идея: за один проход находим минимальный элемент среди ещё не отсортированных и ставим его на первое свободное место слева. Потом минимум среди оставшихся — на второе место, и так далее.
Сравнений столько же, сколько у пузырька, — около N². Но перестановка одна на проход: нашли минимум — переставили один раз, а не гоняли пары туда-сюда. Всего порядка N вместо N²; если элементы тяжёлые, это заметный выигрыш.
За один проход рамка проходит все неотсортированные столбики и находит минимум (1). Его переставляют в левый край — одна перестановка на проход, поэтому их всего порядка N.
Сортировка вставками
Обычно лучший из трёх простых методов. Так раскладывают карты в руке: слева держим уже упорядоченную часть, берём очередной элемент справа и вставляем на нужное место, сдвигая большие вправо, чтобы освободить щель.
Сравнений в среднем примерно вдвое меньше, чем у пузырька (около N²/4): вставка останавливается, как только нашлось место, а не идёт по всей длине. Обмена местами здесь нет вовсе — элементы не меняются парами, а сдвигаются вправо, освобождая щель; поэтому у вставок считают сдвиги, а не обмены. Но у сортировки вставками есть особый козырь: на почти упорядоченных данных она работает почти за линейное время O(N) — если элемент уже стоит правильно, вставка не двигает ничего. Поэтому её используют внутри более сложных алгоритмов — например, для маленьких кусочков в быстрой сортировке.
Вынимаем очередной элемент (1), сдвигаем большие вправо и вставляем его на место в отсортированную часть слева. На почти готовых данных сдвигать почти нечего — работа близка к O(N).
Устойчивость
Ещё одно свойство, важное на практике, — устойчивость (stability). Сортировка устойчива, если элементы с одинаковым ключом сохраняют исходный взаимный порядок. Это единственное свойство из статьи, которое видно пользователю: таблицу заказов сначала отсортировали по дате, потом кликнули по колонке «город», и внутри одного города заказы либо остались по дате, либо перемешались.
живой пример
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
public class StableDemo {
record Order(String city, int day) {}
public static void main(String[] args) {
List<Order> orders = new ArrayList<>(List.of(
new Order("Казань", 3), new Order("Москва", 1), new Order("Казань", 1), new Order("Москва", 2)));
orders.sort(Comparator.comparingInt(Order::day));
orders.sort(Comparator.comparing(Order::city));
System.out.println(orders);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
package main
import (
"cmp"
"fmt"
"slices"
)
type Order struct {
City string
Day int
}
func main() {
orders := []Order{{"Казань", 3}, {"Москва", 1}, {"Казань", 1}, {"Москва", 2}}
slices.SortStableFunc(orders, func(a, b Order) int { return cmp.Compare(a.Day, b.Day) })
slices.SortStableFunc(orders, func(a, b Order) int { return cmp.Compare(a.City, b.City) })
fmt.Println(orders)
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
const orders = [
{ city: "Казань", day: 3 }, { city: "Москва", day: 1 },
{ city: "Казань", day: 1 }, { city: "Москва", day: 2 },
];
orders.sort((a, b) => a.day - b.day);
orders.sort((a, b) => a.city.localeCompare(b.city));
console.log(orders);
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
from dataclasses import dataclass
@dataclass
class Order:
city: str
day: int
orders = [Order("Казань", 3), Order("Москва", 1), Order("Казань", 1), Order("Москва", 2)]
orders.sort(key=lambda o: o.day)
orders.sort(key=lambda o: o.city)
print(orders)
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Две сортировки подряд, и внутри каждого города заказы идут по дню: вторая сортировка равные по городу элементы не тронула. Так и сортируют по нескольким полям без сложного компаратора: от второстепенного ключа к главному. С неустойчивой сортировкой второй проход перемешал бы дни. Пузырёк и вставки устойчивы: они переставляют только соседей и только при строгом «больше». Выбор в наивном виде нет: дальняя перестановка минимума может перепрыгнуть через равный ему элемент.
У обеих записей одинаковый ключ «Москва», различаются они днём; устойчивая сортировка по городу оставляет прежний порядок, неустойчивая вправе его перемешать.
Все три — O(N²)
Несмотря на различия, у всех трёх методов одинаковый порядок роста — O(N²) (квадратичное время): удвоили объём данных — учетверили работу. Это терпимо на десятках и сотнях элементов, но на миллионе O(N²) — это триллион операций, что уже неприемлемо. Разница между тремя методами — в константах и числе перестановок, а не в порядке роста. Её видно на счётчиках: отсортируем один и тот же массив тремя способами и посчитаем сравнения и перемещения.
живой пример
import java.util.Arrays;
public class SortCost {
static int comparisons, moves;
static boolean greater(int x, int y) {
comparisons++;
return x > y;
}
static void swap(int[] a, int i, int j) {
moves++;
int t = a[i]; a[i] = a[j]; a[j] = t;
}
static void bubble(int[] a) {
for (int out = a.length - 1; out > 0; out--)
for (int in = 0; in < out; in++)
if (greater(a[in], a[in + 1])) swap(a, in, in + 1);
}
static void selection(int[] a) {
for (int out = 0; out < a.length - 1; out++) {
int min = out;
for (int in = out + 1; in < a.length; in++)
if (greater(a[min], a[in])) min = in;
swap(a, out, min);
}
}
static void insertion(int[] a) {
for (int out = 1; out < a.length; out++) {
int temp = a[out], in = out;
while (in > 0 && greater(a[in - 1], temp)) {
a[in] = a[in - 1];
in--;
moves++;
}
a[in] = temp;
}
}
static void print(String name, int[] a) {
System.out.println(name + ": сравнений " + comparisons + ", перемещений " + moves
+ " → " + Arrays.toString(a));
comparisons = moves = 0;
}
public static void main(String[] args) {
int[] data = {7, 3, 9, 1, 8, 2, 6, 4};
int[] a = data.clone(); bubble(a); print("пузырёк", a);
a = data.clone(); selection(a); print("выбор", a);
a = data.clone(); insertion(a); print("вставки", a);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
package main
import (
"fmt"
"slices"
)
var comparisons, moves int
func greater(x, y int) bool {
comparisons++
return x > y
}
func swap(a []int, i, j int) {
moves++
a[i], a[j] = a[j], a[i]
}
func bubble(a []int) {
for out := len(a) - 1; out > 0; out-- {
for in := 0; in < out; in++ {
if greater(a[in], a[in+1]) {
swap(a, in, in+1)
}
}
}
}
func selection(a []int) {
for out := 0; out < len(a)-1; out++ {
least := out
for in := out + 1; in < len(a); in++ {
if greater(a[least], a[in]) {
least = in
}
}
swap(a, out, least)
}
}
func insertion(a []int) {
for out := 1; out < len(a); out++ {
temp, in := a[out], out
for in > 0 && greater(a[in-1], temp) {
a[in] = a[in-1]
in--
moves++
}
a[in] = temp
}
}
func report(name string, a []int) {
fmt.Printf("%s: сравнений %d, перемещений %d → %v\n", name, comparisons, moves, a)
comparisons, moves = 0, 0
}
func main() {
data := []int{7, 3, 9, 1, 8, 2, 6, 4}
a := slices.Clone(data)
bubble(a)
report("пузырёк", a)
a = slices.Clone(data)
selection(a)
report("выбор", a)
a = slices.Clone(data)
insertion(a)
report("вставки", a)
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
let comparisons = 0;
let moves = 0;
function greater(x, y) {
comparisons++;
return x > y;
}
function swap(a, i, j) {
moves++;
[a[i], a[j]] = [a[j], a[i]];
}
function bubble(a) {
for (let out = a.length - 1; out > 0; out--)
for (let inn = 0; inn < out; inn++)
if (greater(a[inn], a[inn + 1])) swap(a, inn, inn + 1);
}
function selection(a) {
for (let out = 0; out < a.length - 1; out++) {
let least = out;
for (let inn = out + 1; inn < a.length; inn++)
if (greater(a[least], a[inn])) least = inn;
swap(a, out, least);
}
}
function insertion(a) {
for (let out = 1; out < a.length; out++) {
const temp = a[out];
let inn = out;
while (inn > 0 && greater(a[inn - 1], temp)) {
a[inn] = a[inn - 1];
inn--;
moves++;
}
a[inn] = temp;
}
}
function report(name, a) {
console.log(`${name}: сравнений ${comparisons}, перемещений ${moves} → [${a.join(", ")}]`);
comparisons = moves = 0;
}
const data = [7, 3, 9, 1, 8, 2, 6, 4];
let a = [...data]; bubble(a); report("пузырёк", a);
a = [...data]; selection(a); report("выбор", a);
a = [...data]; insertion(a); report("вставки", a);
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
живой пример
comparisons = moves = 0
def greater(x: int, y: int) -> bool:
global comparisons
comparisons += 1
return x > y
def swap(a: list, i: int, j: int) -> None:
global moves
moves += 1
a[i], a[j] = a[j], a[i]
def bubble(a: list) -> None:
for out in range(len(a) - 1, 0, -1):
for i in range(out):
if greater(a[i], a[i + 1]):
swap(a, i, i + 1)
def selection(a: list) -> None:
for out in range(len(a) - 1):
least = out
for i in range(out + 1, len(a)):
if greater(a[least], a[i]):
least = i
swap(a, out, least)
def insertion(a: list) -> None:
global moves
for out in range(1, len(a)):
temp, i = a[out], out
while i > 0 and greater(a[i - 1], temp):
a[i] = a[i - 1]
i -= 1
moves += 1
a[i] = temp
def report(name: str, a: list) -> None:
global comparisons, moves
print(f"{name}: сравнений {comparisons}, перемещений {moves} → {a}")
comparisons = moves = 0
data = [7, 3, 9, 1, 8, 2, 6, 4]
a = data.copy(); bubble(a); report("пузырёк", a)
a = data.copy(); selection(a); report("выбор", a)
a = data.copy(); insertion(a); report("вставки", a)
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Пузырёк и выбор дают по 28 сравнений — это все пары восьми элементов, вставки обходятся 21. Перемещений у выбора всего 7, по одному на проход; у пузырька и вставок — по 16. Часть этих семи — холостые: минимум иногда и так стоит на своём месте, а swap в листинге вызывается безусловно. Проверка, что минимум не стоит на своём месте, перед обменом убирает и их. На больших N соотношения те же:
| Метод | Сравнения | Перестановки | Особенность |
|---|---|---|---|
| Пузырёк | ~N² | до ~N² | простейший, но медленный; с проверкой «обменов не было» — O(N) на готовом массиве |
| Выбор | ~N² | ~N | мало перестановок |
| Вставки | ~N²/4 | ~N²/4 сдвигов | быстра на почти упорядоченных данных |
Из простых методов по умолчанию берут вставки; на больших массивах не годится ни один из трёх — нужен уровень O(N·log N). И это не удача конкретных алгоритмов, а потолок: любая сортировка, которая узнаёт порядок только сравнениями, обязана различить все N! возможных расстановок, а каждое сравнение делит их не больше чем пополам, значит сравнений нужно не меньше log₂(N!) ≈ N·log₂ N. Быстрее могут только методы, которые не сравнивают, а раскладывают по разрядам, о них в следующей статье.
Как это сделано в стандартной библиотеке
От того, что внутри у библиотечной сортировки, зависит, сохранится ли порядок равных элементов и как быстро всё отработает на почти упорядоченных данных.
Точек входа две: Arrays.sort для массивов и Collections.sort (то же самое, что list.sort(...)) для списков.
Откуда сортировка знает, что «меньше», а что «больше». У чисел, строк и дат есть естественный порядок: они реализуют интерфейс Comparable, и метод compareTo возвращает отрицательное число, ноль или положительное; Arrays.sort(int[]) и Collections.sort(list) без аргументов пользуются им. Свой класс либо тоже реализует Comparable, если у него один очевидный порядок, либо порядок задают снаружи объектом Comparator, как в примере выше через Comparator.comparing; порядков может быть сколько угодно, по фамилии, по дате, по сумме. Как их собирают из нескольких ключей и что делать с null, разобрано в статье про коллекции, в разделе про сортировку.
Оговорка про «дорогие перемещения», ради которых выбирают сортировку выбором. В Java перемещение объекта это копирование ссылки, четыре байта, и оно дешевле сравнения: сравнение объектов вызывает compareTo через интерфейс и лезет в поля, а строк ещё и идёт по символам. Дорогим перемещение бывает у примитивов больших структур в других языках и у записей на диске, где переставить значит переписать блок. Поэтому в Java на выбор смотрят как на учебный алгоритм, а библиотечная сортировка экономит именно сравнения.
List<Employee> staff = new ArrayList<>(hired);
staff.sort(Comparator.comparing(Employee::lastName));
Когда сортируют объекты, внутри работает TimSort — гибрид сортировки слиянием и вставок. Сначала он проходит по данным и ищет куски, которые уже упорядочены (их называют сериями); короткую серию он достраивает до приличной длины именно вставками, только двоичными — место для очередного элемента ищется двоичным поиском, а не перебором слева. Потом готовые серии сливаются между собой. Отсюда два свойства: TimSort устойчив и на почти упорядоченных данных работает близко к O(N). В худшем случае — O(N·log N).
Вставки остались в библиотеке и на вторых ролях: короткие участки в несколько десятков элементов досортировываются именно ими — на такой длине они обгоняют всё остальное за счёт крошечных накладных расходов.
Одно важное различие: устойчивость есть только у объектной версии. Arrays.sort(int[]) про неё ничего не знает и знать не может — два одинаковых числа неразличимы, «сохранять их порядок» бессмысленно.
Грабля. TimSort верит, что сравнение последовательно: если a меньше b, а b меньше c, то и a меньше c. Классический способ это сломать — сравнивать вычитанием:
Comparator<Item> byPrice = (a, b) -> (int) (a.priceInCents() - b.priceInCents());
Разность двух больших чисел не влезает в int, знак получается случайным, порядок становится противоречивым. На маленьких списках «всё работает», а на большом TimSort замечает нестыковку и роняет сортировку с IllegalArgumentException: Comparison method violates its general contract!. Надёжный вариант — Comparator.comparingLong(Item::priceInCents): он сравнивает, а не вычитает.
От того, что внутри у библиотечной сортировки, зависит, сохранится ли порядок равных элементов и как быстро всё отработает на почти упорядоченных данных.
Точек входа в Go две семьи: старый пакет sort (sort.Ints, sort.Slice, sort.SliceStable, sort.Sort по интерфейсу) и обобщённый пакет slices с Go 1.21 (slices.Sort, slices.SortFunc, slices.SortStableFunc). В новом коде берут slices: он быстрее и не требует писать три метода интерфейса sort.Interface.
Откуда сортировка знает, что «меньше», а что «больше». slices.Sort работает для типов с естественным порядком, чисел и строк, это ограничение cmp.Ordered. Для своего типа порядок задают функцией func(a, b T) int, которая возвращает отрицательное число, ноль или положительное; собирать её из нескольких ключей помогает cmp.Compare: сначала сравнить город, и только при равенстве день. Порядков может быть сколько угодно, по фамилии, по дате, по сумме.
Оговорка про «дорогие перемещения», ради которых выбирают сортировку выбором. В Go срез структур хранит сами значения, и обмен двух элементов копирует их целиком: у структуры в сотню байт перемещение уже сравнимо со сравнением. Поэтому большие структуры часто сортируют через срез указателей или индексов. Но библиотечная сортировка всё равно экономит именно сравнения: функция сравнения вызывается косвенно, а строки сравниваются побайтно.
staff := slices.Clone(hired)
slices.SortFunc(staff, func(a, b Employee) int {
return cmp.Compare(a.LastName, b.LastName)
})
Внутри slices.Sort и slices.SortFunc с Go 1.19 работает pdqsort (pattern-defeating quicksort): быстрая сортировка, которая замечает уже упорядоченные и почти упорядоченные участки и на них работает близко к O(N), а на плохих данных переключается на пирамидальную сортировку, так что худший случай O(N·log N). Короткие участки до двенадцати элементов досортировываются вставками — на такой длине они обгоняют всё остальное за счёт крошечных накладных расходов.
Одно важное различие: pdqsort неустойчив. Два заказа из одного города после slices.SortFunc по городу могут поменяться местами, и пример с двумя проходами по дню и городу на нём сломается. Для устойчивости есть slices.SortStableFunc и sort.Stable: внутри сортировка вставками блоками по двадцать элементов и слияние блоков на месте, устойчиво, но дороже: O(N·log N) сравнений и O(N·log² N) обменов.
Грабля. Сортировка верит, что сравнение последовательно: если a меньше b, а b меньше c, то и a меньше c. Классический способ это сломать в Go — вычитание беззнаковых чисел:
slices.SortFunc(items, func(a, b Item) int { return int(a.Qty - b.Qty) })
Если Qty объявлен как uint32, разность никогда не бывает отрицательной: 3 - 5 даёт огромное положительное число, и функция всегда отвечает «a больше». Паники не будет: Go молча вернёт неверно упорядоченный срез, а на маленьких данных «всё работает». Надёжный вариант — cmp.Compare(a.Qty, b.Qty): он сравнивает, а не вычитает. Вторая ловушка того же рода — float64 с NaN: обычные < и > с ним непоследовательны, а cmp.Compare нарочно ставит NaN раньше всех чисел.
От того, что внутри у библиотечной сортировки, зависит, сохранится ли порядок равных элементов и как быстро всё отработает на почти упорядоченных данных.
Точка входа одна: array.sort(compareFn), и она сортирует на месте, меняя исходный массив. С Node 20 есть toSorted, которая возвращает отсортированную копию и исходный массив не трогает.
Откуда сортировка знает, что «меньше», а что «больше». Здесь главная грабля JavaScript: без функции сравнения sort приводит элементы к строкам и сравнивает их как текст, поэтому [10, 9, 1].sort() даёт [1, 10, 9]. Для чисел функцию сравнения пишут всегда: (a, b) => a - b; для строк с учётом языка a.localeCompare(b); для своих объектов сравнивают поля. Функция возвращает отрицательное число, ноль или положительное, порядков может быть сколько угодно, по фамилии, по дате, по сумме.
Оговорка про «дорогие перемещения», ради которых выбирают сортировку выбором. В JavaScript перемещение объекта это копирование ссылки, и оно дешевле сравнения: сравнение вызывает вашу функцию, а та лезет в поля. Поэтому библиотечная сортировка экономит именно сравнения.
const staff = hired.toSorted((a, b) => a.lastName.localeCompare(b.lastName));
Внутри у V8 с 2018 года работает TimSort — гибрид сортировки слиянием и вставок, тот же алгоритм, что в Java и Python. Сначала он проходит по данным и ищет куски, которые уже упорядочены (их называют сериями); короткую серию достраивает двоичными вставками, потом сливает серии между собой. Отсюда два свойства: сортировка устойчива, и с ES2019 это требование стандарта, а не деталь движка, а на почти упорядоченных данных она работает близко к O(N). В худшем случае — O(N·log N). Вставки остались на вторых ролях: короткие участки досортировываются именно ими.
Устойчивость есть для любых элементов: два равных числа неразличимы, а объекты с равным ключом сохраняют порядок, и два прохода по дню и городу из примера выше работают.
Грабля. TimSort верит, что сравнение последовательно: если a меньше b, а b меньше c, то и a меньше c. В JavaScript её ломают не вычитанием, числа здесь дробные и до 2⁵³ не переполняются, а функцией, которая возвращает true или false:
items.sort((a, b) => a.priceInCents > b.priceInCents);
true превращается в 1, false в 0, и сортировка никогда не слышит ответа «меньше». Результат стандартом не определён: массив может остаться как был или перемешаться, исключения не будет нигде. Надёжный вариант — (a, b) => a.priceInCents - b.priceInCents: он возвращает знак, а не истину.
От того, что внутри у библиотечной сортировки, зависит, сохранится ли порядок равных элементов и как быстро всё отработает на почти упорядоченных данных.
Точек входа две: list.sort() сортирует список на месте и возвращает None, sorted(iterable) строит новый список из чего угодно, хоть из словаря или генератора. Классическая ошибка новичка — a = a.sort(), после которой в a лежит None.
Откуда сортировка знает, что «меньше», а что «больше». У чисел, строк и дат есть естественный порядок через метод __lt__, им пользуются sort и sorted без аргументов. Для своего порядка функцию сравнения не пишут, а передают ключ key=, который из элемента делает то, что уже умеет сравниваться: key=lambda o: o.day, key=attrgetter("city"), кортеж key=lambda o: (o.city, o.day) для нескольких полей. Ключ считается один раз на элемент, а не при каждом сравнении, поэтому это быстрее двухаргументных сравнений; старую функцию сравнения при необходимости заворачивают в functools.cmp_to_key.
Оговорка про «дорогие перемещения», ради которых выбирают сортировку выбором. В Python перемещение объекта это копирование указателя, и оно дешевле сравнения: сравнение вызывает __lt__, а для своих классов ещё и проходит через интерпретатор. Поэтому библиотечная сортировка экономит именно сравнения.
staff = sorted(hired, key=attrgetter("last_name"))
Внутри работает TimSort, и придуман он был именно для Python: гибрид сортировки слиянием и вставок. Сначала он проходит по данным и ищет куски, которые уже упорядочены (их называют сериями); короткую серию достраивает до длины в несколько десятков элементов двоичными вставками, потом сливает серии между собой. Отсюда два свойства: сортировка устойчива, и это гарантия языка, а на почти упорядоченных данных она работает близко к O(N). В худшем случае — O(N·log N). С версии 3.11 порядок слияния серий сменился на схему powersort, но название и свойства прежние.
Устойчивость есть для любых элементов: два прохода по дню и городу из примера выше дают порядок по обоим полям, а ключ-кортеж делает то же за один проход.
Грабля. TimSort верит, что сравнение последовательно, и переполнения чисел в Python нет, зато есть другая ловушка: элементы разных типов. Список из чисел и строк сортироваться откажется:
sorted([3, "10", 7])
Python 3 честно бросает TypeError: '<' not supported between instances of 'str' and 'int', и это лучше молчаливого мусора. Хуже, когда ключ возвращает None для части элементов: сравнение None < 5 падает тем же TypeError посреди сортировки, уже на боевых данных. Надёжный вариант — ключ, который всегда одного типа: key=lambda o: (o.price is None, o.price or 0) ставит пустые цены в конец и не падает.
Коротко
- Любая сортировка — это повторяющиеся сравнение и перестановка двух элементов.
- Пузырёк гоняет соседние пары, самый большой всплывает в конец; просто, но медленно.
- Выбор ищет минимум и ставит в начало; столько же сравнений, но мало перестановок.
- Вставки держат упорядоченную часть слева и вставляют туда следующий элемент; обычно лучший из трёх, особенно на почти отсортированных данных (там близко к
O(N)). - Все три —
O(N²): годятся для малых объёмов, но не для больших. Устойчивость сохраняет порядок равных элементов — важна при сортировке по нескольким полям. - Все три сортируют на месте, без копии массива;
O(N·log N)для сравнительных сортировок это доказанный потолок (log₂ N! сравнений), а не удача. - Устойчивость видна пользователю: два прохода от второстепенного ключа к главному дают порядок по обоим полям; так сортируют таблицы без сложного компаратора.
- Порядок задаёт
Comparable(естественный, черезcompareTo) или внешнийComparator; в Java перемещение объекта это копия ссылки и дешевле сравнения. ВнутриArrays.sortдля объектов TimSort, устойчивый.
- Порядок задаёт
cmp.Orderedу чисел и строк или функцияfunc(a, b T) intу своих типов;slices.Sortэто pdqsort и он неустойчив, устойчивость даётslices.SortStableFunc; в срезе структур обмен копирует значение целиком.
- Порядок задаёт функция сравнения, без неё
sortсравнивает как строки;sortменяет массив на месте,toSortedдаёт копию; TimSort устойчив по стандарту, функция сравнения должна возвращать знак, а неtrue/false.
- Порядок задаёт
key=, а не функция сравнения;list.sort()меняет на месте и возвращаетNone,sorted()даёт новый список; TimSort устойчив по гарантии языка, разнотипные элементы даютTypeError.
Что почитать дальше
- Нетривиальная сортировка: Шелла, быстрая, поразрядная — как уходят от
O(N²)кO(N·log N). - Стеки и очереди — структуры с намеренно ограниченным доступом.
- Массивы, двоичный поиск и O-нотация — зачем сортируют перед поиском.
- Два указателя и скользящее окно — приёмы для упорядоченных данных.