- Квадратичные сортировки: пузырьком, выбором, вставками
- Сортировка подсчетом
- О-нотация
- Сортировка слиянием*
- Применение сортировки для решения задач
Мы считаем, что вы уже знаете и умеете следующие вещи:
- основы языков 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]
Какие вы знаете алгоритмы сортировки?
Если вы не знаете ни одного алгоритма, то придумайте какой-нибудь разумный алгоритм сами.
Это самый популярный алгоритм сортировки, хоть он и не самый очевидный.
Пусть
Ссылка на красивую визуализацию: 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]
Заметьте, что после
И именно поэтому его можно немного ускорить.
Подумайте, какие лишние элементы мы перебираем. Как нужно изменить границы в двух циклах for, чтобы не делать никаких бесполезных действий?
Более понятным и придумываемым способом является сортировка выбором минимума (или максимума).
Чтобы отсортировать массив, нужно просто
Красивая визуализация (вкладка SEL).
Также существует сортировка вставками.
Префиксом длины
Тогда пусть на
Красивая визуализация (вкладка INS).
Сдайте 4 первые задачи в контесте:
- Напишите сортировку пузырьком.
- Напишите сортировку выбором максимума.
- Напишите сортировку вставками.
- Выберите любой алгоритм и примените его для сортировки пар.
При сдаче задач нельзя пользоваться встроенной сортировкой!
Предыдущие три алгоритма работали с массивами, в которых лежат абсолютно любые объекты, которые можно сравнивать. Любые числа, строки, пары, другие массивы, почти все что угодно.
Но в особых случаях, когда элементы принадлежат какому-то маленькому множеству, можно использовать другой алгоритм — сортировку подсчетом.
Пусть, например, нам гарантируется, что все числа натуральные и лежат в промежутке от 1 до 100.
Тогда есть такой простой алгоритм: создадим массив размера
Красивая визуализация (вкладка COU).
Сдайте 5, 6 и 7 задачи в контесте:
- Напишите сортировку подсчетом.
- Придумайте, как преобразовать сортировку подсчетом, чтобы она работала быстро.
- Придумайте, как использовать идею сортировки подсчетом в этой задаче.
При сдаче задач нельзя пользоваться встроенной сортировкой!
Очень часто требуется оценить, сколько времени работают эти алгоритмы. Но тут возникают проблемы:
- на разных компьютерах время работы всегда будет слегка отличаться;
- чтобы измерить время, придётся запустить сам алгоритм, но иногда приходится оценивать алгоритмы, требующие часы или даже дни работы.
Зачастую основной задачей программиста становится оптимизировать алгоритм, выполнение которого займёт тысячи лет, до какого-нибудь адекватного времени работы. Поэтому хотелось бы уметь предсказывать, сколько времени займёт выполнение алгоритма ещё до того, как мы его запустим.
Для этого давайте для начала попробуем оценить число операций в алгоритме.
Возникает вопрос: какие именно операции считать. Можно считать любые элементарные операции:
- арифметические операции с числами:
+, -, *, /
- сравнение чисел:
<, >, <=, >=, ==, !=
- присваивание:
a[0] = 3
При этом надо учитывать, как реализованы некоторые отдельные вещи в самом языке. Например, в питоне срезы массива (array[3:10]
) копируют этот массив, то есть этот срез работает за 7 элементарных действий. А swap
, например, может работать за 3 присваивания.
Попробуйте посчитать точное число сравнений в сортировках пузырьком, выбором, вставками и подсчетом в худшем случае (это должна быть какая формула, зависящая от
И также посчитайте точное число присваиваний в сортировках пузырьком, выбором, вставками и подсчетом в худшем случае. Давайте считать, что swap
— это 3 присваивания.
Ниже будут ответы
.
.
.
.
.
.
.
.
.
.
.
.
.
.
.
В худшем случае алгоритмы работают за столько сравнений:
- Сортировка пузырьком (без улучшения):
$N(N-1)$ - Сортировка пузырьком (с улучшением):
$(N-1) + (N-2) + \ldots + 1 = \frac{N(N-1)}{2}$ - Сортировка выбором:
$(N-1) + (N-2) + \ldots + 1 = \frac{N(N-1)}{2}$ - Сортировка вставками:
$1 + 2 + \ldots + (N-1) = \frac{N(N-1)}{2}$ - Сортировка подсчетом: нет сравнений
И столько присваиваний:
- Сортировка пузырьком (без улучшения):
$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(N^2)$ - Сортировка выбором работает за
$O(N^2)$ - Сортировка вставками работает за
$O(N^2)$ - Сортировка подсчетом работает за
$O(N + M)$
Это обозначение удобно тем, что оно короткое и понятное, а также оно не зависит от умножения на константу или прибавления константы. Если алгоритм работает за
Поэтому все эти рассуждения про то, сколько операций в swap
или считать ли отдельно присваивания, сравнения и циклы - отпадают. Как бы вы ни ответили на эти вопросы, они меняют ответ на константу, а значит асимптотическое время работы алгоритма никак не меняется.
Первые три сортировки именно поэтому называют квадратичными - они работают за
Найдите асимптотику данных функций. Максимально упростите ответ (например, до
$\frac{N}{3}$ $\frac{N(N-1)(N-2)}{6}$ $1 + 2 + 3 + \ldots + N$ $1^2 + 2^2 + 3^2 + \ldots + N^2$ $\log{N} + 3$ $7$ $10^{100}$
Найдите асимптотическое время работы данных функций:
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$
Возникает вопрос: а бывают ли сортировки, которые быстрее, чем квадратиные, и работают всегда?
Ответ — да. Есть несколько известных сортировок, работающих за
Давайте подробнее рассмотрим сортировку слиянием, она же MergeSort.
Ссылка на красивую визуализацию: https://visualgo.net/nl/sorting (вкладка MER)
Для начала определим функцию слияния (merge
) двух отсортированных массивов - она возвращает отсортированный массив, состоящий из элементов обоих массивов, и работает при этом за
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]
В сущности слить два массива просто - это делается с помощью двух указателей
Когда функция 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. Она на разных языках может быть реализована по-разному, но везде она работает за
# На питоне она пишется так
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.