Мы считаем, что вы уже знаете и умеете следующие вещи: * основы языков 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 до 100.
Тогда есть такой простой алгоритм: создадим массив размера \(100\), в котором будем хранить на \(i\)-ом месте, сколько раз число \(i\) встретилось в этом массиве. Пройдемся по всем числам, и увеличим соответствующее значение массива на \(1\). Таким образом мы подсчитали, сколько раз какое число встретилось. Теперь можно просто пройтись по этому массиву и вывести \(1\) столько раз, сколько раз встретилась \(1\), вывести \(2\) столько раз, сколько встретилась \(2\), и так далее.
Красивая визуализация (вкладка COU).
Сдайте 5, 6 и 7 задачи в контесте:
При сдаче задач нельзя пользоваться встроенной сортировкой!
Очень часто требуется оценить, сколько времени работают эти алгоритмы. Но тут возникают проблемы:
Зачастую основной задачей программиста становится оптимизировать алгоритм, выполнение которого займёт тысячи лет, до какого-нибудь адекватного времени работы. Поэтому хотелось бы уметь предсказывать, сколько времени займёт выполнение алгоритма ещё до того, как мы его запустим.
Для этого давайте для начала попробуем оценить число операций в алгоритме.
Возникает вопрос: какие именно операции считать. Можно считать любые элементарные операции: * арифметические операции с числами: +, -, *, /
* сравнение чисел: <, >, <=, >=, ==, !=
* присваивание: 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.