Сортировка: основные принципы и сравнение алгоритмов

Сортировка: основные принципы и сравнение алгоритмов Полезное

Сортировка — это перестановка элементов массива или списка в заданном порядке: по возрастанию, по убыванию или по произвольному ключу. Задача одна, а способов ее решить много, и различаются они не результатом, а тем, как быстро работают и сколько памяти требуют. Ниже разберу пять базовых алгоритмов — пузырьком, вставками, выбором, быструю и слиянием: идею каждого, сложность в O-нотации и устойчивость. Прогоним один из них руками и кодом на Python, а в конце будет таблица сравнения и правило выбора.

Три свойства, по которым сравнивают алгоритмы

Прежде чем сравнивать, разведу три понятия, которые часто путают.

Временная сложность O() — как растет число операций при увеличении размера массива n. O(n^2) означает: удвоили массив — работы стало вчетверо больше. O(n log n) растет намного медленнее. У одного алгоритма сложность может отличаться в лучшем, среднем и худшем случае.

Память (in-place или нет) — сколько дополнительного места нужно сверх самого массива. Алгоритм, сортирующий «на месте», тратит O(1) лишней памяти; сортировка слиянием требует O(n).

Устойчивость (стабильность) — сохраняет ли алгоритм относительный порядок элементов с одинаковым ключом. Если два человека с одинаковым возрастом шли в списке в порядке «Анна, потом Борис», устойчивая сортировка по возрасту оставит их в том же порядке. Свойство важно при сортировке по нескольким полям подряд.

Пузырьковая сортировка: прогон руками

Идея простая: идем по массиву и сравниваем каждую пару соседних элементов. Если левый больше правого — меняем их местами. За один проход самый большой элемент «всплывает» в конец. Повторяем проходы, пока не останется обменов.

Прогоним на массиве [5, 1, 4, 2, 8]. Каждый проход сравнивает соседние пары слева направо.

Проход 1: 5>1 меняем -> [1,5,4,2,8]; 5>4 меняем -> [1,4,5,2,8]; 5>2 меняем -> [1,4,2,5,8]; 5<8 не трогаем. Итог прохода: [1,4,2,5,8], число 8 встало на место.

Проход 2: 1<4 нет; 4>2 меняем -> [1,2,4,5,8]; 4<5 нет. Итог: [1,2,4,5,8].

Проход 3: ни одной перестановки — значит массив уже отсортирован, дальше идти незачем. Ранний выход по флагу «был ли обмен» — типичная оптимизация пузырька.

Теперь тот же алгоритм кодом. Флаг swapped дает ранний выход, а range(n - 1 - i) не проверяет уже отсортированный хвост.

def bubble_sort(a):
    a = a[:]                      # копия, чтобы не портить исходный список
    n = len(a)
    for i in range(n - 1):
        swapped = False
        for j in range(n - 1 - i):        # хвост из i элементов уже на месте
            if a[j] > a[j + 1]:
                a[j], a[j + 1] = a[j + 1], a[j]
                swapped = True
        print(f"после прохода {i + 1}: {a}")
        if not swapped:               # проход без обменов - массив отсортирован
            print("обменов не было - массив отсортирован, выходим")
            break
    return a

data = [5, 1, 4, 2, 8]
print("исходный:", data)
print("результат:", bubble_sort(data))

Фактический вывод совпадает с ручным прогоном:

исходный: [5, 1, 4, 2, 8]
после прохода 1: [1, 4, 2, 5, 8]
после прохода 2: [1, 2, 4, 5, 8]
после прохода 3: [1, 2, 4, 5, 8]
обменов не было - массив отсортирован, выходим
результат: [1, 2, 4, 5, 8]

Пузырек прост и устойчив, но медленный: в среднем и худшем случае O(n^2). На практике его почти не применяют, зато он нагляден и отлично показывает саму механику перестановок.

Вставками и выбором: две другие простые сортировки

Сортировка вставками строит отсортированную часть слева, беря по одному элементу и вставляя его на нужное место среди уже упорядоченных — как раскладывают карты в руке. В худшем и среднем случае это O(n^2), но на почти отсортированных данных близка к O(n), поэтому годится для небольших и «доупорядоченных» массивов. Устойчива, работает на месте.

Сортировка выбором на каждом шаге находит минимум в неотсортированной части и ставит его в начало. Число сравнений одинаково всегда, поэтому сложность O(n^2) во всех случаях — и в лучшем тоже. Перестановок она делает мало (по одной за проход), но в базовой реализации неустойчива: обмен минимума с текущей позицией может «перепрыгнуть» равный элемент.

Все три простые сортировки — пузырьком, вставками, выбором — работают на месте с O(1) дополнительной памяти. Их потолок O(n^2) делает их непригодными для больших массивов, но на десятках элементов разница незаметна, а код короткий.

Быстрая и слиянием: сортировки за O(n log n)

Быстрая сортировка (quicksort) выбирает опорный элемент (pivot) и разбивает массив на две части: меньше опорного и больше. Затем рекурсивно сортирует каждую часть. В среднем это O(n log n) и на практике одна из самых быстрых. Но при неудачном выборе опорного (например, всегда крайний элемент на уже отсортированных данных) сложность вырождается до O(n^2). Базовая реализация неустойчива; дополнительная память — O(log n) на стек рекурсии.

Сортировка слиянием (merge sort) делит массив пополам, рекурсивно сортирует половины и сливает их в один упорядоченный список, каждый раз забирая меньший из двух текущих элементов. Ее сложность O(n log n) гарантированно во всех случаях, и она устойчива. Плата — O(n) дополнительной памяти под слияние.

Вот ядро слияния и сортировки на Python:

def merge_sort(a):
    if len(a) <= 1:                 # база рекурсии: 0 или 1 элемент уже отсортированы
        return a
    mid = len(a) // 2
    left = merge_sort(a[:mid])
    right = merge_sort(a[mid:])
    res = []
    i = j = 0
    while i < len(left) and j < len(right):
        if left[i] <= right[j]:     # <= сохраняет порядок равных -> устойчивость
            res.append(left[i]); i += 1
        else:
            res.append(right[j]); j += 1
    res.extend(left[i:]); res.extend(right[j:])   # добавляем остаток
    return res

print(merge_sort([5, 1, 4, 2, 8]))   # -> [1, 2, 4, 5, 8]

Именно на идее слияния и вставок построен Timsort — гибридный алгоритм, который стоит за встроенными sorted() и list.sort() в Python. Он устойчив и работает за O(n log n), поэтому в реальном коде почти всегда берут встроенную сортировку, а ручные реализации нужны для учебы и нестандартных случаев.

Таблица сравнения

Алгоритм Лучший Средний Худший Память Устойчивость
Пузырьком O(n) O(n^2) O(n^2) O(1) да
Вставками O(n) O(n^2) O(n^2) O(1) да
Выбором O(n^2) O(n^2) O(n^2) O(1) нет
Быстрая O(n log n) O(n log n) O(n^2) O(log n) нет
Слиянием O(n log n) O(n log n) O(n log n) O(n) да

O(n) в лучшем случае у пузырька и вставок достигается только с оптимизацией «ранний выход» на почти отсортированных данных. Устойчивость указана для базовых реализаций — и быструю, и выбор можно сделать устойчивыми ценой усложнения или лишней памяти.

Когда какой выбирать

  • Учеба, наглядность, крошечные массивы — пузырьком или вставками: код в несколько строк, легко проследить механику.
  • Почти отсортированные данные — вставками: на них она близка к O(n).
  • Нужна гарантия O(n log n) и устойчивость (например, сортировка по нескольким полям) — слиянием, если не жалко O(n) памяти.
  • Быстро и в среднем, память ограничена — быстрая: на случайных данных она обычно обгоняет слияние за счет работы на месте.
  • Реальный проект — встроенная sorted()/list.sort() (Timsort): устойчивая, O(n log n), оптимизированная. Свою сортировку писать стоит, только если есть особые требования.

Выводы

  • Алгоритмы сортировки дают одинаковый результат, но различаются по скорости, памяти и устойчивости — по этим трем свойствам их и выбирают.
  • Простые сортировки (пузырьком, вставками, выбором) работают на месте за O(n^2) и подходят для учебы и малых массивов.
  • Быстрая и слиянием дают O(n log n): быстрая экономит память, но в худшем случае деградирует до O(n^2); слияние стабильно быстрое и устойчивое, но требует O(n) памяти.
  • Устойчивость важна при сортировке по нескольким ключам подряд; выбором и базовый quicksort неустойчивы.
  • В рабочем коде почти всегда берут встроенную sorted()/list.sort() (Timsort) — устойчивую и за O(n log n).

Где применяется / связь с практикой

Сортировка — это основа множества практических задач: поиск (бинарный поиск работает только на отсортированных данных), устранение дубликатов, группировка, ранжирование выдачи. Понимание сложности O() и устойчивости помогает не только выбирать готовую функцию, но и оценивать, почему одна операция на больших данных занимает секунды, а другая — минуты.

Освойте тему на практике

Систематически разобрать алгоритмы, их сложность и применение можно на курсе Алгоритмы и структуры данных: там сортировки идут в связке со структурами данных, на которых они работают. Посмотреть формат и темы вживую можно на открытых уроках Otus — это бесплатные занятия с разбором конкретных задач.

Смежные темы: Пузырьковая сортировка на Python и C, Сортировка массива на PHP.

FAQ

Какая сортировка самая быстрая?
Универсального ответа нет: на случайных данных обычно быстрее quicksort, на почти отсортированных — вставки, а гарантированный O(n log n) без деградации дает слияние. В большинстве задач оптимальна встроенная Timsort.

Зачем изучать пузырек, если его не применяют?
Он нагляднее всего показывает механику сравнений и перестановок и служит отправной точкой для понимания более сложных алгоритмов. В продакшене его действительно не используют.

Что значит «сортировка на месте»?
Алгоритм переставляет элементы внутри самого массива и тратит на это O(1) дополнительной памяти. Сортировка слиянием так не умеет — ей нужен отдельный буфер размером O(n).

OTUS Журнал
Скидка 5% 14-20 сентября на курсы (popup)