Алгоритмы сортировки
В работе вы почти всегда используете встроенную sorted(). Но устройство сортировок спрашивают на собеседованиях, а их идеи — «разделяй и властвуй», разбиение, слияние — применяются в десятках других задач.
Простые сортировки O(n²)
def bubble_sort(a):
a = a[:]
for end in range(len(a) - 1, 0, -1):
swapped = False
for i in range(end):
if a[i] > a[i + 1]: # «всплываем» большой элемент вправо
a[i], a[i + 1] = a[i + 1], a[i]
swapped = True
if not swapped: # проход без обменов — уже отсортировано
break
return a
def insertion_sort(a):
a = a[:]
for i in range(1, len(a)):
key, j = a[i], i - 1
while j >= 0 and a[j] > key: # сдвигаем большие вправо
a[j + 1] = a[j]
j -= 1
a[j + 1] = key # вставляем на место
return a
def selection_sort(a):
a = a[:]
for i in range(len(a)):
m = min(range(i, len(a)), key=a.__getitem__) # ищем минимум в хвосте
a[i], a[m] = a[m], a[i]
return a
data = [5, 2, 9, 1, 5, 6]
print(bubble_sort(data), insertion_sort(data), selection_sort(data))
| Лучший | Средний | Худший | Устойчивая | Когда хороша | |
|---|---|---|---|---|---|
| Пузырьком | O(n) | O(n²) | O(n²) | да | только для обучения |
| Вставками | O(n) | O(n²) | O(n²) | да | маленькие и почти отсортированные массивы |
| Выбором | O(n²) | O(n²) | O(n²) | нет | минимум обменов |
Пройдите insertion_sort 👣 Пошагово — видно, как элемент «проезжает» влево на своё место.
Сортировка слиянием — O(n log n)
«Разделяй и властвуй»: разделить пополам, отсортировать половины рекурсивно, слить два отсортированных списка за O(n).
def merge(a, b):
result, i, j = [], 0, 0
while i < len(a) and j < len(b):
if a[i] <= b[j]: # <= сохраняет устойчивость
result.append(a[i]); i += 1
else:
result.append(b[j]); j += 1
return result + a[i:] + b[j:]
def merge_sort(a, depth=0):
if len(a) <= 1:
return a
mid = len(a) // 2
left, right = merge_sort(a[:mid], depth + 1), merge_sort(a[mid:], depth + 1)
merged = merge(left, right)
print(" " * depth + f"слияние {left} + {right} → {merged}")
return merged
merge_sort([38, 27, 43, 3, 9, 82, 10])
Уровней деления — log n, на каждом уровне слияния суммарно O(n) → O(n log n) всегда. Минус — O(n) дополнительной памяти. Идея слияния нужна в задачах: слить k отсортированных списков, посчитать инверсии, внешняя сортировка огромных файлов.
Быстрая сортировка — O(n log n) в среднем
Выбрать опорный элемент, разбить массив: меньшие — влево, большие — вправо, рекурсивно отсортировать части.
import random
def quick_sort_simple(a):
if len(a) <= 1:
return a
pivot = random.choice(a)
less = [x for x in a if x < pivot]
equal = [x for x in a if x == pivot]
greater = [x for x in a if x > pivot]
return quick_sort_simple(less) + equal + quick_sort_simple(greater)
print(quick_sort_simple([3, 6, 8, 10, 1, 2, 1]))
def partition_lomuto(a, lo, hi):
pivot = a[hi]
i = lo # граница «меньших»
for j in range(lo, hi):
if a[j] < pivot:
a[i], a[j] = a[j], a[i]
i += 1
a[i], a[hi] = a[hi], a[i] # опорный встаёт на своё итоговое место
return i
arr = [7, 2, 1, 8, 6, 3, 5, 4]
p = partition_lomuto(arr, 0, len(arr) - 1)
print(arr, "опорный на позиции", p)
- Средний случай O(n log n), на месте (O(log n) стека), на практике очень быстрая.
- Худший случай O(n²) — если опорный всегда минимальный/максимальный (например, первый элемент уже отсортированного массива). Защита — случайный опорный или медиана трёх.
- Неустойчивая.
Quickselect — k-й элемент за O(n)
Разбиение позволяет найти k-й по величине элемент без полной сортировки: после partition опорный стоит на своём месте, рекурсия идёт только в одну половину.
import random
def quickselect(a, k):
"""k-й наименьший (k от 0), в среднем O(n)."""
a = a[:]
lo, hi = 0, len(a) - 1
while True:
p = random.randint(lo, hi)
a[p], a[hi] = a[hi], a[p]
pivot, i = a[hi], lo
for j in range(lo, hi):
if a[j] < pivot:
a[i], a[j] = a[j], a[i]
i += 1
a[i], a[hi] = a[hi], a[i]
if i == k:
return a[i]
if i < k:
lo = i + 1
else:
hi = i - 1
data = [3, 2, 1, 5, 6, 4]
print(quickselect(data, len(data) - 2)) # второй по величине
Сортировки без сравнений — O(n + k)
Любая сортировка сравнениями не может быть быстрее O(n log n) в худшем случае (нужно различить n! перестановок, а каждое сравнение даёт 1 бит). Но если ключи — небольшие целые, можно обойтись без сравнений:
def counting_sort(a, max_value):
counts = [0] * (max_value + 1)
for x in a:
counts[x] += 1
result = []
for value, c in enumerate(counts):
result.extend([value] * c)
return result
ages = [25, 31, 18, 25, 40, 18, 22]
print(counting_sort(ages, 120)) # O(n + k), k — диапазон значений
def radix_sort(a):
"""Поразрядная сортировка неотрицательных чисел: устойчиво по каждой цифре."""
exp = 1
while max(a, default=0) // exp > 0:
buckets = [[] for _ in range(10)]
for x in a:
buckets[(x // exp) % 10].append(x)
a = [x for b in buckets for x in b]
exp *= 10
return a
print(radix_sort([170, 45, 75, 90, 802, 24, 2, 66]))
Сортировка в Python: Timsort
sorted() и list.sort() используют Timsort (в 3.11+ — его улучшение Powersort): гибрид слияния и вставок, который находит уже отсортированные участки («пробеги») и сливает их.
- O(n log n) в худшем, O(n) на почти отсортированных данных;
- устойчивая;
list.sort()— на месте,sorted()— новый список.
people = [("Анна", "HR", 150), ("Боря", "IT", 250), ("Вера", "IT", 200), ("Глеб", "HR", 150)]
print(sorted(people, key=lambda p: p[2], reverse=True))
print(sorted(people, key=lambda p: (p[1], -p[2]))) # отдел ↑, зарплата ↓
by_name = sorted(people, key=lambda p: p[0], reverse=True) # устойчивость: сортируем по
by_dept = sorted(by_name, key=lambda p: p[1]) # второстепенному, потом по главному
print(by_dept)
words = ["banana", "apple", "Cherry", "date"]
print(sorted(words), sorted(words, key=str.lower), sorted(words, key=len))
Сортировка по убыванию строкового поля
Минус работает только для чисел. Для строк по убыванию внутри составного ключа используйте два прохода устойчивой сортировки (как выше) или functools.cmp_to_key.
Вопрос на собеседовании
«Расскажите о быстрой сортировке». Опорный элемент, разбиение на меньшие и большие, рекурсия; в среднем O(n log n), в худшем O(n²) — при плохом выборе опорного, защищаются случайным выбором; сортирует на месте, неустойчивая. Сравнить со слиянием: всегда O(n log n), устойчивая, но O(n) памяти.
Итог
- O(n²): пузырьком, выбором, вставками (вставки хороши для маленьких и почти отсортированных).
- Слиянием: всегда O(n log n), устойчивая, O(n) памяти.
- Быстрая: O(n log n) в среднем, O(n²) в худшем, на месте; quickselect — k-й элемент за O(n).
- Без сравнений: подсчётом и поразрядная — O(n + k).
- В Python — Timsort: устойчивая, O(n) на почти отсортированных; сортировка по нескольким ключам через кортеж.
Практика решено 0 из 5
Сортировка слиянием. Реализуйте merge_sort(nums), возвращающую новый отсортированный список.
Без sorted и .sort(). Сначала напишите функцию слияния двух отсортированных списков.
Какие тесты проверяют код (3)
assert merge_sort([5, 2, 9, 1, 5, 6]) == [1, 2, 5, 5, 6, 9] and merge_sort([]) == [] and merge_sort([1]) == [1] data = [3, 1, 2]; result = merge_sort(data); assert data == [3, 1, 2] and result == [1, 2, 3], "Исходный список не должен меняться" import random; r = random.Random(1); data = [r.randint(-1000, 1000) for _ in range(20_000)]; assert merge_sort(data) == sorted(data)
def merge(a, b):
result = []
i = j = 0
while i < len(a) and j < len(b):
if a[i] <= b[j]:
result.append(a[i])
i += 1
else:
result.append(b[j])
j += 1
result.extend(a[i:])
result.extend(b[j:])
return result
def merge_sort(nums):
if len(nums) <= 1:
return list(nums)
mid = len(nums) // 2
return merge(merge_sort(nums[:mid]), merge_sort(nums[mid:]))
Быстрая сортировка на месте. Реализуйте quick_sort(nums), сортирующую список на месте
(функция ничего не возвращает). Используйте разбиение Ломуто и случайный опорный элемент.
Какие тесты проверяют код (4)
a = [5, 2, 9, 1, 5, 6]; assert quick_sort(a) is None and a == [1, 2, 5, 5, 6, 9] a = []; quick_sort(a); b = [7]; quick_sort(b); assert a == [] and b == [7] import random; r = random.Random(3); a = [r.randint(0, 10_000) for _ in range(20_000)]; b = sorted(a); quick_sort(a); assert a == b a = list(range(5_000)); quick_sort(a); assert a == list(range(5_000)), "Уже отсортированный массив — худший случай для фиксированного опорного; выбирайте его случайно"
import random
def partition(nums, lo, hi):
p = random.randint(lo, hi)
nums[p], nums[hi] = nums[hi], nums[p]
pivot = nums[hi]
i = lo
for j in range(lo, hi):
if nums[j] < pivot:
nums[i], nums[j] = nums[j], nums[i]
i += 1
nums[i], nums[hi] = nums[hi], nums[i]
return i
def quick_sort(nums, lo=0, hi=None):
if hi is None:
hi = len(nums) - 1
while lo < hi:
p = partition(nums, lo, hi)
if p - lo < hi - p:
quick_sort(nums, lo, p - 1)
lo = p + 1
else:
quick_sort(nums, p + 1, hi)
hi = p - 1
Сортировка по нескольким полям. Отсортируйте сотрудников (dict с ключами name, dept, salary):
по отделу по алфавиту, внутри отдела — по зарплате по убыванию, при равной зарплате — по имени.
Верните список имён.
Какие тесты проверяют код (1)
staff = [
{"name": "Вера", "dept": "IT", "salary": 200},
{"name": "Анна", "dept": "HR", "salary": 150},
{"name": "Борис", "dept": "IT", "salary": 250},
{"name": "Глеб", "dept": "IT", "salary": 200},
{"name": "Дина", "dept": "HR", "salary": 180},
]
assert sort_staff(staff) == ["Дина", "Анна", "Борис", "Вера", "Глеб"]
def sort_staff(staff):
ordered = sorted(staff, key=lambda p: (p["dept"], -p["salary"], p["name"]))
return [p["name"] for p in ordered]
Свойства сортировок
| O(n log n) в худшем | устойчивая | на месте (O(1) памяти) | |
|---|---|---|---|
| сортировка вставками | |||
| слиянием | |||
| быстрая (quicksort) | |||
| пирамидальная (heapsort) | |||
| Timsort (sorted в Python) |
Что значит «устойчивая сортировка»?
Войдите, чтобы отмечать пройденные уроки.