Сайт переезжает. Большинство статей уже перенесено на новую версию.
Скоро добавим автоматические переходы, но пока обновленную версию этой статьи можно найти там.

Сортировки — квадратичные и сортировка слиянием

Мы считаем, что вы уже знаете и умеете следующие вещи: * основы языков Python, C++ или Java (примеры в конспектах будут только на C++) * оператор if * циклы for и while * создание функций * концепцию рекурсии * поиск минимума в массиве

Квадратичные сортировки

Задача сортировки массива заключается в том, чтобы расставить его элементы в определённом порядке (чаще всего - по неубыванию. Это означает, что каждый элемент должен быть больше или равен всех предыдущих).

А именно мы хотим написать такую функцию sort_array, которая принимает массив в качестве аргумента и сортирует его элементы:

def sort_array(array):
    # надо придумать, что написать здесь

array = [1, -3, 7, 88, 7]
sort_array(array)
print(array)
[1, -3, 7, 88, 7]

Задание

Какие вы знаете алгоритмы сортировки?

Если вы не знаете ни одного алгоритма, то придумайте какой-нибудь разумный алгоритм сами.

Сортировка пузырьком

Это самый популярный алгоритм сортировки, хоть он и не самый очевидный.

Пусть \(N\) - длина массива. Сортировка пузырьком заключается в том, что мы просто \(N\) раз пройдемся по массиву и будем менять два соседних элемента, если первый больше второго.

Ссылка на красивую визуализацию: https://visualgo.net/nl/sorting

Заметьте, как каждую итерацию максимальный элемент “всплывает как пузырек” к концу массива.

def bubble_sort(array):
    n = len(array) # длина массива
    for i in range(n): # n раз выполняем цикл
        for j in range(n - 1): # проходимся по всем элементам кроме последнего
            if array[j] > array[j + 1]: # сравниваем элемент по следующим
                a[j], a[j + 1] = a[j + 1], a[j] # меняем местами, если следующий меньше

a = [1, -3, 7, 88, 7]
bubble_sort(a)
print(a)
[-3, 1, 7, 7, 88]

Заметьте, что после \(i\) шагов алгоритма сортировки пузырьком последние \(i + 1\) чисел всегда отсортированы. Именно поэтому алгоритм и работает.

И именно поэтому его можно немного ускорить.

Задание

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

Сортировка выбором

Более понятным и придумываемым способом является сортировка выбором минимума (или максимума).

Чтобы отсортировать массив, нужно просто \(N\) раз выбрать минимум среди еще неотсортированных чисел. То есть на \(i\)-ом шаге ищется минимум на отрезке \([i, n - 1]\), и этот минимум меняется с \(i\)-ым элементом, теперь отрезок \([0, i]\) отсортирован.

Красивая визуализация (вкладка SEL).

Сортировка вставками

Также существует сортировка вставками.

Префиксом длины \(i\) будем называть первые \(i\) элементов массива.

Тогда пусть на \(i\)-ом шаге у нас уже будет отсортирован префикс до \(i\)-го элемента. Чтобы этот префикс увеличить, нужно взять элемент, идущий после него, и менять с левым соседом, пока этот элемент наконец не окажется больше своего левого соседа. Если в конце он больше левого соседа, но меньше правого - это значит, что мы правильно вставили этот элемент в отсортированную часть массива.

Красивая визуализация (вкладка INS).

Задание

Сдайте 4 первые задачи в контесте:

  1. Напишите сортировку пузырьком.
  2. Напишите сортировку выбором максимума.
  3. Напишите сортировку вставками.
  4. Выберите любой алгоритм и примените его для сортировки пар.

При сдаче задач нельзя пользоваться встроенной сортировкой!

Сортировка подсчетом

Предыдущие три алгоритма работали с массивами, в которых лежат абсолютно любые объекты, которые можно сравнивать. Любые числа, строки, пары, другие массивы, почти все что угодно.

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

Пусть, например, нам гарантируется, что все числа натуральные и лежат в промежутке от 1 до 100.

Тогда есть такой простой алгоритм: создадим массив размера \(100\), в котором будем хранить на \(i\)-ом месте, сколько раз число \(i\) встретилось в этом массиве. Пройдемся по всем числам, и увеличим соответствующее значение массива на \(1\). Таким образом мы подсчитали, сколько раз какое число встретилось. Теперь можно просто пройтись по этому массиву и вывести \(1\) столько раз, сколько раз встретилась \(1\), вывести \(2\) столько раз, сколько встретилась \(2\), и так далее.

Красивая визуализация (вкладка COU).

Задание

Сдайте 5, 6 и 7 задачи в контесте:

  1. Напишите сортировку подсчетом.
  2. Придумайте, как преобразовать сортировку подсчетом, чтобы она работала быстро.
  3. Придумайте, как использовать идею сортировки подсчетом в этой задаче.

При сдаче задач нельзя пользоваться встроенной сортировкой!

О-нотация

Очень часто требуется оценить, сколько времени работают эти алгоритмы. Но тут возникают проблемы:

Зачастую основной задачей программиста становится оптимизировать алгоритм, выполнение которого займёт тысячи лет, до какого-нибудь адекватного времени работы. Поэтому хотелось бы уметь предсказывать, сколько времени займёт выполнение алгоритма ещё до того, как мы его запустим.

Для этого давайте для начала попробуем оценить число операций в алгоритме.

Возникает вопрос: какие именно операции считать. Можно считать любые элементарные операции: * арифметические операции с числами: +, -, *, / * сравнение чисел: <, >, <=, >=, ==, != * присваивание: a[0] = 3

При этом надо учитывать, как реализованы некоторые отдельные вещи в самом языке. Например, в питоне срезы массива (array[3:10]) копируют этот массив, то есть этот срез работает за 7 элементарных действий. А swap, например, может работать за 3 присваивания.

Задание

Попробуйте посчитать точное число сравнений в сортировках пузырьком, выбором, вставками и подсчетом в худшем случае (это должна быть какая формула, зависящая от \(N\) - длины массива). Для сортировки подсчетом давайте считать, что все числа целые и лежат в промежутке от 1 до \(M\)

И также посчитайте точное число присваиваний в сортировках пузырьком, выбором, вставками и подсчетом в худшем случае. Давайте считать, что swap — это 3 присваивания.

Ниже будут ответы

.

.

.

.

.

.

.

.

.

.

.

.

.

.

.

В худшем случае алгоритмы работают за столько сравнений:

И столько присваиваний: * Сортировка пузырьком (без улучшения): \(3\frac{N(N-1)}{2}\) * Сортировка пузырьком (с улучшением): \(3\frac{N(N-1)}{2}\) * Сортировка выбором: \(3(N-1) + \frac{N(N-1)}{2}\) * Сортировка вставками: \(3\frac{N(N-1)}{2}\) * Сортировка подсчетом: \(N + M\) (\(M\) - это создание массива)

Но чтобы учесть все элементарные операции, ещё надо посчитать, например, сколько раз прибавилась единичка внутри цикла for. А ещё, например, строчка n = len(array) - это тоже действие.

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

Для этого придумали \(O\)-нотацию - асимптотическое время работы вместо точного (часто его ещё называют асимптотикой).

Пусть \(f(N)\) - это какая-то функция. Говорят, что алгоритм работает за \(O(f(N))\), если существует число \(C\), такое что алгоритм работает не более чем за \(C \cdot f(N)\) операций.

В таких обозначениях можно сказать, что * Сортировка пузырьком работает за \(O(N^2)\) * Сортировка выбором работает за \(O(N^2)\) * Сортировка вставками работает за \(O(N^2)\) * Сортировка подсчетом работает за \(O(N + M)\)

Это обозначение удобно тем, что оно короткое и понятное, а также оно не зависит от умножения на константу или прибавления константы. Если алгоритм работает за \(O(N^2)\), то это может значить, что он работает за \(N^2\), за \(N^2 + 3\), за \(\frac{N(N-1)}{2}\) или даже за \(1000 \cdot N^2 + 1\) действие. Главное, что функция ведет себя как \(N^2\), то есть при увеличении \(N\) (в данном случае это длина массива) он увеличивается как некоторая квадратичная функция. Например, если увеличить \(N\) в 10 раз, время работы программы увеличится приблизительно в 100 раз.

Поэтому все эти рассуждения про то, сколько операций в swap или считать ли отдельно присваивания, сравнения и циклы - отпадают. Как бы вы ни ответили на эти вопросы, они меняют ответ на константу, а значит асимптотическое время работы алгоритма никак не меняется.

Первые три сортировки именно поэтому называют квадратичными - они работают за \(O(N^2)\). Сортировка подсчетом работает намного быстрее - она работает за \(O(N + M)\) действий, то есть если в задаче \(M < N\), то это вообще линейная функция \(O(N)\). Линейная функция растет гораздо медленнее, чем квадратичная, так что эта сортировка гораздо лучше, чем любая другая квадратичная. У нее есть лишь один недостаток - ее можно применять только если множество значений конечное и состоит из чисел от \(1\) до \(M\).

Задание

Найдите асимптотику данных функций. Максимально упростите ответ (например, до \(O(N)\), \(O(N^2)\) и т. д.).

Задание

Найдите асимптотическое время работы данных функций:

def f(n):
    s = 0
    for i in range(n):
        for j in range(n):
            s += i * j
    return s

f(10)
2025
def g(n):
    s = 0
    for i in range(n):
        s += i
    for i in range(n):
        s += i * i
    return s

g(10)
330
def h(n):
    if n == 0:
        return 1
    return h(n - 1) * n

h(10)
3628800

Задание

Найдите лучшее время работы алгоритмов, решающих данные задачи: * Написать числа от \(1\) до \(N\) * Написать все тройки чисел от \(1\) до \(N\) * Найти разницу между максимумом и минимумом в массиве * Найти число единиц в бинарной записи числа \(N\)

Сортировка слиянием* (для тех, кто всё успевает)

Возникает вопрос: а бывают ли сортировки, которые быстрее, чем квадратиные, и работают всегда?

Ответ — да. Есть несколько известных сортировок, работающих за \(O(N \log{N})\), и доказано, что асимптотически быстрее сортировок не бывает.

Давайте подробнее рассмотрим сортировку слиянием, она же MergeSort.

Ссылка на красивую визуализацию: https://visualgo.net/nl/sorting (вкладка MER)

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

def merge(a, b):
    # надо придумать, что написать здесь

merge([1, 3, 7, 10, 100], [2, 7, 7, 7, 11, 13, 18])
[1, 2, 3, 7, 7, 7, 7, 10, 11, 13, 18, 100]

В сущности слить два массива просто - это делается с помощью двух указателей \(i\) и \(j\). Изначально они равны \(0\) (то есть указывают на нулевые элементы массивов \(a\) и \(b\)). После этого достаточно смотреть на элементы \(a[i]\) и \(b[j]\) и минимальный из них класть в результирующий массив, после чего соответствующий указатель надо двигать дальше. Дальше нужно повторять этот процесс заново, пока мы не дошли до конца обоих массивов.

Когда функция merge уже написана, сам merge_sort писать уже легко - надо воспользоваться рекурсией. Чтобы отсортировать массив, достаточно отдельно отсортировать его левую и правую половины рекурсивно, и после этого эти половины слить.

Для удобства написания кода фунции можно сделать вот такими:

def merge(array, a, b, c):
    # функция сливает элементы массива array [a, b) и [b, c)
    # надо придумать, что написать здесь

arr = [1, 1, 7, 10, 100, 2, 7, 40, 78, 6, 13, 100]
merge(arr, 6, 9, 12)
print(arr)
merge(arr, 0, 6, 12)
print(arr)
[1, 1, 7, 10, 100, 2, 6, 7, 13, 40, 78, 100]
[1, 1, 2, 6, 7, 7, 10, 13, 40, 78, 100, 100]
def mergesort(array, a, c):
    # функция сортирует элементы массива array [a, c)
    # надо придумать, что написать здесь

arr = [1, 1, 7, 10, 100, 2, 7, 40, 78, 6, 13, 100]
mergesort(arr, 6, 12)
print(arr)

arr = [1, 1, 7, 10, 100, 2, 7, 40, 78, 6, 13, 100]
mergesort(arr, 0, 12)
print(arr)

Задание

Разобраться, реализовать сортировку слиянием и сдать последнюю задачу в этом контесте:

https://informatics.msk.ru/mod/statements/view3.php?id=33164

Чтобы сдать это задание, нужно писать красивый код - будет проведено ревью.

Применение сортировки для решения задач

Также сортировка очень часто применяется как часть решения олимпиадных задач. В таких случаях обычно используют встроенную сортировку sort. Она на разных языках может быть реализована по-разному, но везде она работает за \(O(N log N)\), и, обычно, неплохо оптимизирована.

# На питоне она пишется так
a = [1, 5, 10, 5, -4]
a.sort()
print(a)
[-4, 1, 5, 5, 10]
// на C++11 она пишется так

#include <vector>
#include <algorithm>
#include <iostream>

using namespace std;

int main() {
    vector<int> v = {1, 5, 10, 5, -4};
    sort(v.begin(), v.end());
    for (auto x : v) {
        cout << x << " ";
    }
}

Задание

Решить как можно больше задач из этого контеста:

https://informatics.msk.ru/mod/statements/view.php?id=33855

В этом контесте можно и нужно использовать встроенную сортировку sort.