Статьи

Ультимативный гайд по структурам данных и алгоритмам на Python. Часть 4: алгоритмы поиска и сортировки

2025-04-30 16:42 Продвинутый Python
После рекурсии и динамического программирования (Часть 3), возвращаемся к фундаментальным алгоритмам поиска и сортировки. Выбор правильного алгоритма здесь критичен: он может кардинально повлиять на производительность вашего приложения, особенно на больших данных. Понимание их работы и компромиссов — ключ к эффективности.
Весь маршрут нашего гайда:

  • Часть 1: базовый арсенал.
  • Часть 2: стеки, очереди и связанные списки.
  • Часть 3: рекурсия и оптимизация через динамическое программирование.
  • Часть 4 (вы здесь): алгоритмы поиска и сортировки.
  • Часть 5: деревья, BST и heapq.
  • Часть 6: графы — моделируем всё, что связано.

Сегодня на ринге: линейный против бинарного поиска. Затем — мир сортировок: от учебных O(n²) до эффективных O(n log n) алгоритмов. И конечно, разберем Timsort — стандартный алгоритм сортировки в Python.

Линейный vs. бинарный поиск

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

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

Линейный поиск

Это самый базовый алгоритм поиска, который приходит в голову первым. Идея элементарна:
  1. Начать с первого элемента (индекс 0).
  2. Сравнить текущий элемент с `target`.
  3. Если равны — элемент найден, вернуть его индекс.
  4. Если не равны — перейти к следующему элементу и повторить шаг 2.
  5. Если достигнут конец коллекции без совпадения — элемент не найден, вернуть `-1` (или `False`).
def linear_search(data_collection, target):
    """
    Ищет target в data_collection линейным поиском.
    Возвращает индекс первого найденного элемента или -1.
    """
    for index, element in enumerate(data_collection):
        if element == target:
            print(f"Элемент '{target}' найден на позиции {index}.")
            return index
    print(f"Элемент '{target}' не найден в коллекции.")
    return -1

shopping_list = ["молоко", "хлеб", "яйца", "масло", "сыр", "хлеб"]
target_item1 = "яйца"
target_item2 = "кофе"
target_item3 = "хлеб" # Присутствует дважды

print(f"Ищем '{target_item1}' в списке: {shopping_list}")
linear_search(shopping_list, target_item1) # Элемент 'яйца' найден на позиции 2.

print(f"\nИщем '{target_item2}' в списке: {shopping_list}")
linear_search(shopping_list, target_item2) # Элемент 'кофе' не найден в коллекции.

print(f"\nИщем '{target_item3}' в списке: {shopping_list}")
linear_search(shopping_list, target_item3) # Элемент 'хлеб' найден на позиции 1 
Характеристики линейного поиска:
  • Простота. Алгоритм очень легко понять и реализовать.
  • Универсальность. Он работает на любой последовательности (список, кортеж, строка...), независимо от того, отсортирована она или нет. Ему не нужен порядок.
  • Производительность. Это его слабое место. В лучшем случае, если искомый элемент самый первый, сложность O(1). Однако в худшем случае (элемент последний или отсутствует) и в среднем случае (приходится просмотреть около половины элементов) сложность составляет O(n). Это означает, что время поиска растет линейно с увеличением размера коллекции n, что может быть медленно для больших наборов данных.

Когда использовать?
  • Когда коллекция маленькая. Накладные расходы на более сложные алгоритмы могут перевесить выгоду.
  • Когда данные не отсортированы, и вы не планируете их сортировать (например, если сортировка займет больше времени, чем сам поиск, или если порядок важен и его нельзя менять).
  • Когда поиск нужно выполнить всего один раз или очень редко.

Линейный поиск в Python
Встроенные операции Python часто используют линейный поиск под капотом для неупорядоченных последовательностей:
  • Оператор `in` для списков и кортежей (`element in my_list`) выполняет линейный поиск.
  • Метод `list.index(element)` также использует линейный поиск для нахождения индекса первого вхождения элемента (и выбрасывает `ValueError`, если элемента нет).

Итак, линейный поиск — это просто, надежно, но медленно для больших данных. А что если мы можем позволить себе немного подготовки (сортировку) ради гораздо более быстрого поиска? Тут на сцену выходит бинарный поиск!

Бинарный поиск

Если линейный поиск — это перебор всех вариантов подряд, то бинарный поиск (binary search) — это стратегия умного угадывания. Представьте, что вы ищете слово в большом словаре или номер страницы в толстой книге. Вы же не листаете страницу за страницей с самого начала? Скорее всего, вы откроете книгу примерно посередине, посмотрите, какое слово (или номер страницы) там, и решите, в какой половине книги — левой или правой — нужно продолжить поиск. Затем вы повторите этот процесс для выбранной половины, снова разделив её пополам.

Это и есть основная идея бинарного поиска: на каждом шаге мы отбрасываем половину оставшегося диапазона поиска. Но у этого мощного подхода есть одно критически важное требование:

Коллекция, в которой выполняется бинарный поиск, ДОЛЖНА БЫТЬ ОТСОРТИРОВАНА!


Если данные расположены хаотично, мы не можем делать вывод о том, в какой половине искать дальше, просто сравнив элемент в середине с искомым.


Алгоритм бинарного поиска:
  1. Сначала устанавливаем границы поиска: левая граница `left` на индекс первого элемента (0), а правая `right` на индекс последнего (`len(collection) - 1`).
  2. Затем в цикле, который продолжается пока `left <= right` (то есть пока диапазон поиска не пуст), мы выполняем основные действия. На каждом шаге находим середину текущего диапазона, вычисляя индекс `mid = left + (right - left) // 2`.
  3. Далее сравниваем элемент в середине (`collection[mid]`) с искомым `target`. Если они равны — элемент найден, и мы возвращаем `mid`. Если `target` меньше элемента в середине, мы понимаем, что искать нужно только в левой половине, поэтому сдвигаем правую границу: `right = mid - 1`. Если же target больше элемента в середине, искать нужно в правой половине, и мы сдвигаем левую границу: `left = mid + 1`.
  4. Цикл повторяется с новыми границами. Если цикл завершается (то есть `left` становится больше `right`), это означает, что элемент не найден, и мы возвращаем `-1` или другое подходящее значение.
def binary_search_iterative(sorted_data_collection, target):
    """
    Ищет target в ОТСОРТИРОВАННОЙ коллекции итеративным бинарным поиском.
    Возвращает индекс или -1.
    """
    left, right = 0, len(sorted_data_collection) - 1
    while left <= right:
        mid = left + (right - left) // 2
        current_element = sorted_data_collection[mid]

        if current_element == target:
            print(f"Элемент '{target}' найден на позиции {mid}.")
            return mid
        elif target < current_element:
            right = mid - 1
        else:
            left = mid + 1

    print(f"Элемент '{target}' не найден в коллекции.")
    return -1

sorted_employees = ["Андреев", "Борисов", "Волков", "Григорьев", "Дмитриев", "Егоров", "Зайцев", "Иванов", "Кузнецов", "Лебедев"]
sorted_prices = [150, 299, 450, 800, 1150, 1999, 2500, 3800, 5000]

binary_search_iterative(sorted_employees, "Иванов") # Элемент 'Иванов' найден на позиции 7.
binary_search_iterative(sorted_employees, "Петров") # Элемент 'Петров' не найден в коллекции.

binary_search_iterative(sorted_prices, 1999)        # Элемент '1999' найден на позиции 5.
binary_search_iterative(sorted_prices, 1000)        # Элемент '1000' не найден в коллекции.
Характеристики бинарного поиска:
  • Эффективность. Это главное преимущество бинарного поиска. На каждом шаге алгоритм отбрасывает половину оставшегося диапазона данных, что приводит к очень быстрой логарифмической временной сложности O(log n). Для сравнения: в коллекции из миллиона элементов линейный поиск может потребовать до миллиона сравнений, в то время как бинарному поиску понадобится всего около 20 сравнений. Это делает его идеальным для работы с большими объемами данных.
  • Требование сортировки. Если данные не отсортированы, нужна предварительная сортировка, что может перевесить выгоду при редких поисках.
  • Реализация. Алгоритм бинарного поиска немного сложнее в реализации по сравнению с линейным поиском, требует внимательного обращения с индексами границ диапазона.

Когда использовать?
  • Когда коллекция большая, и скорость поиска критична.
  • Когда данные уже отсортированы или могут быть отсортированы один раз, а затем будет выполняться много операций поиска. В этом случае затраты на начальную сортировку окупаются многократно за счет быстрого поиска.
  • Когда вам нужен индекс элемента, а не просто проверка наличия (хотя для проверки наличия он тоже очень быстр).

Бинарный поиск в Python: модуль bisect

Вам редко придется писать свою реализацию бинарного поиска с нуля в Python. Для работы с отсортированными последовательностями существует встроенный модуль bisect. Он предоставляет функции, основанные на алгоритме бинарного поиска, для быстрого нахождения "точки вставки" элемента и для вставки элементов с сохранением порядка сортировки.

Зачем нужен `bisect`, если есть `list.index()`?
Помните, `list.index()` использует медленный линейный поиск O(n). Модуль `bisect` работает за O(log n), что гораздо быстрее для больших списков.

Основные функции `bisect` для поиска:
  • `bisect.bisect_left(a, x)`. Эта функция находит в отсортированном списке `a` индекс `i`, куда можно было бы вставить элемент `x`, чтобы порядок сортировки сохранился, причем если `x` уже есть в списке, то индекс будет указывать на самое левое (первое) из существующих вхождений `x`.
  • `bisect.bisect_right(a, x)` (или просто `bisect.bisect(a, x)`)/ Делает то же самое, но находит индекс для вставки справа от существующих элементов `x`.

Функция `bisect_left` возвращает индекс возможной вставки. Чтобы проверить, действительно ли искомый элемент (`x`) находится в списке по этому индексу, нам нужно сделать дополнительную проверку:

  1. Вызвать `index = bisect.bisect_left(sorted_list, target)`.
  2. Проверить, что полученный `index` находится в пределах списка (`index < len(sorted_list)`).
  3. Проверить, что элемент по этому индексу (`sorted_list[index]`) действительно равен `target`.

Только если оба условия выполнены, мы можем считать, что элемент найден.
import bisect

sorted_employees = ["Андреев", "Борисов", "Волков", "Григорьев", "Дмитриев", "Егоров", "Зайцев", "Иванов", "Кузнецов", "Лебедев"]
sorted_duplicates = [10, 20, 20, 20, 30, 40]

def find_with_bisect(sorted_list, target):
    """Ищет target в отсортированном списке с помощью bisect_left."""
    print(f"Ищем '{target}' с помощью bisect в {sorted_list}")
    index = bisect.bisect_left(sorted_list, target) # O(log n)

    if index < len(sorted_list) and sorted_list[index] == target:
        print(f"Элемент '{target}' найден на позиции {index}.")
        return index
    else:
        print(f"Элемент '{target}' не найден.")
        return -1

find_with_bisect(sorted_employees, "Иванов") # Элемент 'Иванов' найден на позиции 7.
find_with_bisect(sorted_employees, "Петров") # Элемент 'Петров' не найден.
find_with_bisect(sorted_duplicates, 20) # Элемент '20' найден на позиции 1.
Почему `bisect` предпочтительнее?
  • Эффективность. Реализован на C, работает очень быстро (O(log n) для поиска точки вставки).
  • Надежность. Меньше шансов допустить ошибку "на единицу", чем при ручной реализации бинарного поиска.
  • Идиоматичность. Это стандартный и рекомендованный способ работы с отсортированными списками в Python.

Выводы по поиску: простота или скорость?

Выбор между линейным и бинарным поиском — это классический пример компромисса между подготовкой данных и скоростью выполнения операции.


Линейный поиск (O(n))

  • 👍🏻Простота реализации, работает на любых (в том числе неотсортированных) последовательностях.
  • 👎🏻Медленный для больших объемов данных, так как время поиска растет линейно с размером коллекции.
  • Использовать для небольших коллекций, для неотсортированных данных, когда поиск выполняется редко. В Python часто реализуется через `value in list` или `list.index()`.

Бинарный поиск (O(log n))

  • 👍🏻Чрезвычайно быстрый для больших коллекций благодаря логарифмической сложности.
  • 👎🏻Требует, чтобы данные были предварительно отсортированы. Если данные нужно сортировать перед каждым поиском, общая сложность может стать выше, чем у линейного поиска (O(n log n) для сортировки). Немного сложнее в реализации (но есть модуль `bisect`).
  • Использовать для больших коллекций, когда данные уже отсортированы или могут быть отсортированы один раз, а поиск выполняется многократно.

С поиском разобрались! Теперь перейдем к не менее важной задаче — наведению порядка в данных с помощью алгоритмов сортировки.

Сортировка: от пузырьков до Timsort

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

Зачем вообще сортировать?

Может показаться, что сортировка — это самоцель, просто чтобы данные "красиво" лежали. Но на самом деле упорядоченные данные открывают массу возможностей и упрощают множество задач:
  • Ускорение поиска. Как мы только что выяснили, самый быстрый алгоритм поиска — бинарный (O(log n)) — работает только на отсортированных данных. Сортировка — это необходимый шаг для его применения.
  • Легкость обработки данных. В отсортированной коллекции тривиально найти минимальный (первый) и максимальный (последний) элементы. Легче найти медиану. Значительно проще обнаружить и удалить дубликаты (они будут стоять рядом). Легче выполнять операции над диапазонами значений.
  • Представление данных. Очень часто данные нужно представить пользователю или передать в другую систему именно в упорядоченном виде (список имен по алфавиту, товары по цене и т.д.).
  • Основа для других алгоритмов. Многие более сложные алгоритмы (например, в геометрии, при работе с графами, в задачах на расписания) требуют, чтобы входные данные были предварительно отсортированы.

Понимание различных подходов к сортировке и их характеристик поможет вам не только выбрать правильный инструмент, когда это необходимо, но и лучше понять, как работает встроенная сортировка в Python.

Давайте начнем с рассмотрения самых простых (и часто не самых эффективных) алгоритмов сортировки, которые, тем не менее, отлично иллюстрируют базовые идеи.

Простые сортировки

Эти алгоритмы обычно первыми приходят на ум и их легко реализовать. Однако их эффективность оставляет желать лучшего на больших наборах данных из-за квадратичной временной сложности (O(n²)) в большинстве случаев. Это означает, что если вы увеличите размер списка в 10 раз, время сортировки увеличится примерно в 100 раз! Тем не менее, они важны для понимания основ.

Сортировка выбором (selection sort)

Идея: На каждом шаге находим минимальный элемент в оставшейся неотсортированной части списка и меняем его местами с первым элементом этой части.

Алгоритм такой:
  1. Пройтись по списку от первого до предпоследнего элемента (индекс `i`). Этот `i` отмечает начало неотсортированной части.
  2. Внутри этого прохода найти индекс (`min_index`) самого маленького элемента в части списка от `i` до конца.
  3. Поменять местами элемент на позиции `i` и элемент на позиции `min_index`.
  4. Повторить для следующего `i`.
def selection_sort(data_list):
    """Сортирует data_list при помощи selection sort."""
    n = len(data_list)
    for i in range(n):
        min_index = i
        for j in range(i + 1, n):
            if data_list[j] < data_list[min_index]:
                min_index = j
        # Оптимизация: меняем только если нашли меньший элемент
        if min_index != i:
            data_list[i], data_list[min_index] = data_list[min_index], data_list[i]
        # print(f"  После шага {i+1}: {data_list}") # Отладка шагов
    print(f"Отсортированный список: {data_list}") 
    return data_list

items_to_select = [64, 25, 12, 22, 11, 90]
selection_sort(items_to_select) # [11, 12, 22, 25, 64, 90]
Характеристики:
  • Сложность. Всегда O(n²) по времени. Независимо от того, отсортирован список или нет, нам всегда нужно выполнить два вложенных цикла для поиска минимума и перестановок.
  • Память. Требует O(1) дополнительной памяти, так как сортировка происходит "на месте" (in-place), используя только несколько вспомогательных переменных.
  • Итог. Прост для понимания, но неэффективен для большинства практических задач.

Пузырьковая сортировка (bubble sort)

Пожалуй, самый известный алгоритм сортировки, часто используемый как пример "плохой" сортировки из-за своей неэффективности. Но идея проста:
  • Многократно проходим по списку слева направо.
  • В каждом проходе сравниваем соседние элементы (`a[j] и a[j+1]`).
  • Если они стоят в неправильном порядке (например, `a[j] > a[j+1]` для сортировки по возрастанию), меняем их местами.
  • В результате каждого такого прохода самый большой (или самый маленький, в зависимости от порядка) элемент "всплывает" в конец неотсортированной части списка, как пузырек воздуха.
  • Повторяем проходы для все уменьшающейся неотсортированной части списка.

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

items_to_bubble = [64, 34, 25, 12, 22, 11, 90]
bubble_sort(items_to_bubble.copy())  # [11, 12, 22, 25, 34, 64, 90]
Характеристики:
  • Сложность. O(n²) по времени в худшем (обратно отсортированный список) и среднем случаях. В лучшем случае (список уже отсортирован), благодаря оптимизации, сложность O(n), так как мы сделаем только один проход.
  • Память. O(1) дополнительной памяти.
  • Итог. В основном представляет академический интерес. На практике почти не используется из-за низкой эффективности.

Сортировка вставкой (insertion sort)

Идея: алгоритм делит список на две части: отсортированную (в начале) и неотсортированную. Он берет первый элемент из неотсортированной части и "вставляет" его на правильное место в уже отсортированную часть, сдвигая бóльшие элементы вправо.
Реализуется так:
  1. Считаем, что первый элемент списка — это уже отсортированная часть (длиной 1).
  2. Берем следующий элемент (начиная со второго, `i = 1`) — это наш `key`, который нужно вставить в отсортированную часть.
  3. Сравниваем `key` с элементами в отсортированной части (слева от `i`), двигаясь справа налево (`j = i-1`, `j-1`, ...).
  4. Пока элементы слева больше, чем `key`, сдвигаем их на одну позицию вправо, освобождая место.
  5. Когда мы находим элемент, который меньше или равен `key`, или доходим до начала списка (`j < 0`), мы нашли правильное место. Вставляем `key` на эту позицию (`j + 1`).
  6. Повторяем для всех остальных элементов.
def insertion_sort(data_list):
    """Сортирует data_list на месте методом вставки."""
    n = len(data_list)
    print(f"Начальный список: {data_list}")
    for i in range(1, n):
        key = data_list[i] # Элемент для вставки
        j = i - 1       # Последний индекс в отсортированной части

        # Сдвигаем элементы > key вправо
        while j >= 0 and key < data_list[j]:
                data_list[j + 1] = data_list[j]
                j -= 1
        # Вставляем key на правильное место
        data_list[j + 1] = key
        # print(f"  После шага {i} (вставили {key}): {data_list}")
    print(f"Отсортированный список: {data_list}")
    return data_list

items_to_insert = [12, 11, 13, 5, 6]
insertion_sort(items_to_insert.copy()) # [5, 6, 11, 12, 13]
Характеристики:
  • Сложность. O(n²) по времени в худшем (обратно отсортированный список) и среднем случаях. Однако, в лучшем случае (список уже отсортирован), сложность O(n), так как внутренний цикл `while` ни разу не выполнится.
  • Память. O(1) дополнительной памяти (in-place).
  • Преимущество. Очень эффективна для маленьких списков. Также показывает отличную производительность (близкую к O(n)) на данных, которые почти отсортированы. Это свойство делает сортировку вставкой важным компонентом более продвинутых гибридных алгоритмов, таких как Timsort.

Мы рассмотрели три простых алгоритма, которые полезны для понимания базовых идей, но для реальной работы с большими данными нам нужны более быстрые методы. Переходим к эффективным сортировкам O(n log n)!

Эффективные сортировки

Эти алгоритмы используют более хитрые подходы, чтобы избежать квадратичной сложности простых методов. Многие из этих эффективных алгоритмов основаны на мощной парадигме "Разделяй и властвуй" (divide and conquer). Общий принцип такой:

  1. Разделяй. Разбить исходную задачу (сортировку всего списка) на несколько меньших подзадач того же типа (сортировку частей списка).
  2. Властвуй. Решить эти подзадачи рекурсивно. Если подзадача стала достаточно простой (например, сортировка списка из одного элемента), решить её напрямую (это базовый случай рекурсии).
  3. Соединяй. Объединить решения подзадач, чтобы получить решение исходной задачи.

Сортировка слиянием (merge sort)

Этот алгоритм — классический пример "Разделяй и властвуй".
  • Разделяй. Список делится ровно пополам на два подсписка.
  • Властвуй. Каждый подсписок рекурсивно сортируется с помощью `merge_sort`. Базовый случай — список из 0 или 1 элемента, который уже отсортирован
  • Соединяй. Вот здесь ключевая магия — слияние двух уже отсортированных подсписков в один большой отсортированный список. Процесс слияния выглядит так: мы берем два отсортированных списка (скажем, `L` и `R`), заводим указатели на их начала и пустой итоговый список `result`. На каждом шаге сравниваем элементы, на которые указывают указатели в `L` и `R`, выбираем меньший из них, добавляем его в `result` и сдвигаем соответствующий указатель. Повторяем, пока один из списков не закончится. Затем просто дописываем в `result` все оставшиеся элементы из другого списка.
def merge_sort(data_list):
    """Сортирует data_list методом слияния (рекурсивно). Возвращает НОВЫЙ список."""
    n = len(data_list)
    if n <= 1: # Базовый случай
        return data_list

    # 1. Разделяй
    mid = n // 2
    left_half = data_list[:mid]
    right_half = data_list[mid:]

    # 2. Властвуй (рекурсивно)
    sorted_left = merge_sort(left_half)
    sorted_right = merge_sort(right_half)

    # 3. Соединяй (слияние)
    merged_list = []
    i = j = 0
    while i < len(sorted_left) and j < len(sorted_right):
        # Используем <= для стабильности
        if sorted_left[i] <= sorted_right[j]:
            merged_list.append(sorted_left[i])
            i += 1
        else:
            merged_list.append(sorted_right[j])
            j += 1
    # Добавляем остатки
    merged_list.extend(sorted_left[i:])
    merged_list.extend(sorted_right[j:])
    return merged_list

items_to_merge = [12, 11, 13, 5, 6, 7, 5]
sorted_merged = merge_sort(items_to_merge.copy()) # merge_sort возвращает новый список
print(f"Отсортированный список: {sorted_merged}") # [5, 5, 6, 7, 11, 12, 13]
Характеристики:
  • Сложность. Всегда O(n log n) по времени. Производительность не зависит от исходного порядка элементов, она стабильна.
  • Память. Требует O(n) дополнительной памяти. Это происходит на этапе слияния, где нам нужно создать временные списки для хранения отсортированных половин и итогового объединенного списка. Это может быть недостатком для систем с очень ограниченной памятью.
  • Стабильность. Сортировка слиянием является стабильной. Это означает, что если в исходном списке было два одинаковых элемента, то в отсортированном списке они останутся в том же относительном порядке, в каком были изначально. Это важное свойство для некоторых приложений (например, при многоуровневой сортировке).

Быстрая сортировка (quick sort)

Quick Sort также использует "Разделяй и властвуй", но разделение происходит хитрее:
  1. Выбрать опору (pivot). Выбирается один элемент из списка в качестве "опорного". Способы выбора опоры могут быть разными: первый элемент, последний, случайный, медиана трех и т.д. Выбор опоры влияет на производительность.
  2. Разбиение (partition). Перераспределить элементы списка так, чтобы все элементы, которые меньше опоры, оказались слева от нее, а все элементы, которые больше опоры, — справа от нее. Сама опора при этом встает на свою финальную позицию в отсортированном списке. Этот шаг — ключевой и самый сложный. Существуют разные схемы разбиения (например, Ломуто, Хоара).
  3. Властвуй. Рекурсивно применить Quick Sort к двум подспискам — тому, что слева от опоры, и тому, что справа от нее.
def partition(data_list, low, high):
    """Разбиение для Quick Sort (опора - последний элемент)."""
    pivot = data_list[high]
    i = low - 1 # Индекс для меньших элементов
    for j in range(low, high):
        if data_list[j] <= pivot:
            i += 1
            data_list[i], data_list[j] = data_list[j], data_list[i]
    # Ставим опору на место
    final_pivot_index = i + 1
    data_list[final_pivot_index], data_list[high] = data_list[high], data_list[final_pivot_index]
    return final_pivot_index

def quick_sort_recursive(data_list, low, high):
    """Рекурсивная часть Quick Sort."""
    if low < high:
        pi = partition(data_list, low, high) # Индекс опоры
        quick_sort_recursive(data_list, low, pi - 1) # Сортируем левую часть
        quick_sort_recursive(data_list, pi + 1, high)# Сортируем правую часть

def quick_sort(data_list):
    """Запускает Quick Sort для всего списка (сортирует на месте)."""
    n = len(data_list)
    quick_sort_recursive(data_list, 0, n - 1)
    return data_list

items_to_quick = [10, 7, 8, 9, 1, 5]

sorted_list = items_to_quick.copy()
quick_sort(sorted_list)
print(f"Отсортированный список: {sorted_list}") # [1, 5, 7, 8, 9, 10]
Характеристики:
  • Сложность. В среднем O(n log n) по времени. Quick Sort часто является самым быстрым алгоритмом сортировки на практике из-за небольших константных множителей и хорошей работы с кешем процессора. А в худшем случае O(n²) по времени. Это происходит, если опора каждый раз выбирается крайне неудачно (например, минимальный или максимальный элемент), и разбиение получается очень несбалансированным (одна часть пустая, другая `n-1`). Такое может случиться на уже отсортированных (или обратно отсортированных) данных при наивном выборе опоры (первый/последний элемент). Использование случайной опоры или "медианы трех" помогает избежать худшего случая на практике.
  • Память. O(log n) дополнительной памяти в среднем (из-за стека вызовов рекурсии). В худшем случае (несбалансированное разбиение) глубина рекурсии может достигать O(n), требуя O(n) памяти для стека.
  • Стабильность. Quick Sort в классической реализации не является стабильной. Относительный порядок одинаковых элементов может измениться после сортировки.

Король сортировок в Python: Timsort!

Хорошая новость: в 99.9% случаев нам не нужно реализовывать алгоритмы сортировки вручную в Python для реальных задач! Встроенные функции сортировки (`list.sort()` и `sorted()`) используют чрезвычайно эффективный и умный алгоритм под названием Timsort.
Timsort — это гибридный, адаптивный алгоритм сортировки, разработанный Тимом Петерсом в 2002 году специально для использования в Python. Он спроектирован так, чтобы показывать отличную производительность на реальных данных, которые часто содержат частично упорядоченные участки. Timsort сочетает в себе лучшие идеи из cортировки вставкой и cортировки cлиянием.

Как работает Timsort (упрощенно):
  1. Поиск "естественных прогонов" (runs). Алгоритм сначала проходит по входным данным и ищет уже существующие отсортированные (по возрастанию или убыванию) подпоследовательности. Эти подпоследовательности называются "прогонами" (runs). Если находится убывающий прогон, он просто разворачивается.
  2. Расширение коротких прогонов. Если найденные прогоны слишком короткие (меньше определенного порога, `minrun`), они "досортировываются" с помощью сортировки вставкой. Мы помним, что сортировка вставкой очень эффективна на маленьких и почти отсортированных данных!
  3. Слияние прогонов. Полученные отсортированные прогоны складываются в стек. Затем алгоритм начинает сливать эти прогоны между собой, используя модифицированную сортировку слиянием. Процесс слияния в Timsort очень хитрый: он старается сливать прогоны примерно одинакового размера и использует "галопирующий" режим, чтобы быстро пропускать большие блоки данных, если один прогон явно "побеждает" другой на длинном участке. Это минимизирует количество сравнений и перемещений данных. Слияния продолжаются до тех пор, пока все прогоны не будут объединены в один отсортированный список.

Преимущества Timsort:
  • Скорость. Отлично работает на данных с частичной упорядоченностью, что часто встречается на практике. O(n) в лучшем случае (данные отсортированы), O(n log n) в среднем и худшем.
  • Стабильность. Timsort сохраняет исходный относительный порядок элементов с одинаковыми ключами сортировки.
  • Эффективность по памяти. Использует O(n) дополнительной памяти в худшем случае для временных массивов при слиянии. Однако благодаря умным стратегиям слияния и работе с малыми прогонами, на практике часто требует значительно меньше памяти, приближаясь к O(log n) или даже меньше на частично отсортированных данных.

Как использовать Timsort в Python?

Не нужно ничего импортировать, Timsort встроен в ядро языка и используется двумя основными способами:
  1. `list.sort()`. Изменяет оригинал, возвращает `None`.
  2. `sorted()`. Возвращает НОВЫЙ отсортированный список из любого итерируемого объекта, не меняя оригинал.

Обе функции принимают два очень полезных необязательных именованных аргумента:

  • `key=function`. Позволяет указать функцию, которая будет вызвана для каждого элемента коллекции перед тем, как элементы будут сравниваться. Результат этой функции (ключ сортировки) будет использоваться для сравнения. Это хороший инструмент для сортировки сложных объектов или сортировки по нестандартным критериям.
  • `reverse=True`. По умолчанию сортировка идет по возрастанию (`reverse=False`). Установка `reverse=True` заставляет сортировать по убыванию.
numbers = [12, -5, 11, 0, 13, 5, 6, -2]
words = ["banana", "Apple", "Киви", "date", "Банан"]
users = [
    {'name': 'Олег', 'age': 33, 'id': 3},
    {'name': 'Катя', 'age': 32, 'id': 2},
    {'name': 'Кира', 'age': 1, 'id': 1}
]

# 1. Сортировка списка на месте
numbers_copy = numbers.copy()
numbers_copy.sort(reverse=True) # По убыванию
print(f"\nnumbers.sort(reverse=True): {numbers_copy}") # [13, 12, 11, 6, 5, 0, -2, -5]

# 2. Новый отсортированный список слов без учета регистра
sorted_words_ci = sorted(words, key=str.lower)
print(f"sorted(words, key=str.lower): {sorted_words_ci}") # ['Apple', 'banana', 'date', 'Банан', 'Киви']

# 3. Сортировка пользователей по возрасту, затем по ID (стабильность Timsort!)
# Используем кортеж в lambda для многоуровневой сортировки
sorted_users = sorted(users, key=lambda user: (user['age'], user['id']))
print(f"Пользователи по возрасту, затем ID: {sorted_users}") 
# [{'name': 'Кира', 'age': 1, 'id': 1}, {'name': 'Катя', 'age': 32, 'id': 2}, {'name': 'Олег', 'age': 33, 'id': 3}]
Знайте, как работают классические алгоритмы сортировки для общего развития и понимания сложности. Но в реальном коде на Python всегда используйте `list.sort()` или `sorted()`.

Заключение: поиск и сортировка освоены!

В этой части нашего гайда мы сосредоточились на двух фундаментальных задачах, с которыми сталкивается практически каждый разработчик: поиске и сортировке данных. Мы увидели, что за кажущейся простотой этих задач скрываются разные подходы с важными последствиями для производительности.

Вывод

Производительность имеет значение! Выбор между O(n) и O(log n) для поиска или между O(n²) и O(n log n) для сортировки может кардинально повлиять на скорость работы вашего приложения при росте данных. К счастью, Python предоставляет высокооптимизированные встроенные инструменты, которые в большинстве случаев являются лучшим выбором. Понимание того, как они работают, позволяет применять их максимально эффективно.
Знание этих базовых алгоритмов и умение пользоваться встроенными инструментами Python — это еще один важный шаг к написанию эффективного кода.

До встречи в мире деревьев! 🌳