Пишем самую тупую на свете сортировку

в 2:49, , рубрики: mergesort, quicksort, сортировка

И это не пузырьковая, а нечто гораздо более тупое.

Как-то после обеда, стоя в коридоре с чашечкой кофе, мне пришла в голову мысль. Что ведь для того, чтобы убедиться, что массив отсортирован, надо сделать всего n-1 сравнение. Например, для массива длины 4 таких сравнения будет 3:

if (a0 < a1 < a2 < a3) 

А получив утвердительный ответ на этот вопрос, мы можем сразу вернуть готовый массив:

if (a0 < a1 && a1 < a2 && a2 < a3) return [a0, a1, a2, a3];

Клево! Теперь чтобы превратить это в сортировку 4 чисел осталось просто проверить все остальные комбинации максимально тупым образом:

def sort4(a0, a1, a2, a3) {
  if (a0 < a1 && a1 < a2 && a2 < a3) return [a0, a1, a2, a3];
  if (a0 < a1 && a1 < a2 && a3 < a2) return [a0, a1, a3, a2];
  if (a0 < a2 && a2 < a1 && a1 < a3) return [a0, a2, a1, a3];
  ...
}
Именно так выглядит "максимальная тупость" по мнению Kandinsky 2.2

Именно так выглядит "максимальная тупость" по мнению Kandinsky 2.2
Оговорка

Строго говоря для того чтобы наша сортировка была стабильной, везде надо писать "<=" (меньше или равно), но меня задолбало это это набирать, так что я буду везде писать только "<". Упражнение по превращению этого удовольствия в продакшен-код я оставляю читателю.

Сколько таких комбинаций? Ну это легко. Их ровно 4! = 24. Мы только что придумали алгоритм с вычислительной сложностью O(n*n!). Это гораздо хуже пузырьковой сортировки (с его квадратичной асимптотикой) не только из-за ужасающе долгой работы, но и еще в добавок нам для массива каждой длины надо писать отдельную функцию. Жуть.

А факториал растет очень быстро, значит наши функции будут становиться все длиннее, отнимая не только вычисления, но и память - ее нам тоже потребуется O(n!). Не для хранения данных (мы же их даже никуда не сохраняем), а для самого алгоритма. Для всех "обычных" алгоритмов размер кода фиксирован, то есть O(1), и мы его просто игнорируем при оценке асимптотики по памяти. Для нашей "тупой" сортировки такое не прокатит.

Но давайте немного поразмышляем. Видим, что первые 3 случая повторяются в части нескольких первых сравнений. А что, если...

if a0 < a1:
	if a1 < a2:
		if a2 < a3:
			return [a0, a1, a2, a3]
		else:
			if a1 < a3:
				return [a0, a1, a3, a2]
			else:
              ...
		else:
          ...
....

Хм. Давайте снова посчитаем вычислительную сложность. Кажется мы тут все наши возможные комбинации каждым сравнением делим примерно на 2. То есть вычислительная сложность сразу получает себе суперспособность логарифм: O(log(n!)).

O(log n!)=OBig(sum_n log iBig) approx  O(ncdot log n)

То есть, если мы все правильно поняли, то мы только что придумали алгоритм, который сортированные списки обрабатывает за O(n) (самая первая ветка срабатывает за n-1 сравнение), а остальные случаи мы сортируем не хуже теоретически идеального случая. То есть как Quicksort, только без его "худшего" случая, когда он деградирует до "пузырька".

кажется, вечер перестает быть томным.

кажется, вечер перестает быть томным.

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

def permutations(a: list[int]) -> list[list[int]]:
    if len(a) == 1:
        return [a]
    return [
        [a[i]] + recur
        for i in range(len(a))
        for recur in permutations(a[0:i] + a[i + 1:])
    ]

Потом выкинем ее, вспомнив, что уже есть itertools.permutation() который делает ровно то же самое.

Теперь пишем 2 функции генерации строк:

def gen_comp(indent: int, l: int, r: int, s_then: str, s_else: str):
    ind = "t" * indent
    return ind + f"if a{l} < a{r}:n{s_then}n{ind}else:n{s_else}"


def gen_return(indent: int, indice: list[int]):
    return "t" * indent + f"return [" + ", ".join([f"a{i}" for i in indice]) + "]"

Ну и саму функцию строящую дерево сравнений (осторожно, говнокод):

def gen_sort_n(cases: list[list[int]], known: list[tuple[int, int]], indent):
    c = cases[0]
    if len(cases) == 1:
        return gen_return(indent, c)

    lss, gtr = [], []
    for l, r in list(zip(c, c[1:])):
        if l == r or (l, r) in known or (r, l) in known:
            continue
        for c in cases:
            (lss, gtr)[c.index(l) > c.index(r)].append(c)  # Хитрый трюк с заполнением двух массивов за раз.
        s_then = gen_sort_n(lss, known + [(l, r)], indent + 1)
        s_else = gen_sort_n(gtr, known + [(r, l)], indent + 1)
        return gen_comp(indent, l, r, s_then, s_else)

print(gen_sort_n(list(itertools.permutations(range(4))), [],0))

Она просто берет первую перестановку из переданных и сравнивает первую непроверенную пару элементов в ней. Потом все оставшиеся случаи раскидывает по подветкам true и false рекурсивно. Получаем на выходе код:

Много кода
if a0 < a1:
	if a1 < a2:
		if a2 < a3:
			return [a0, a1, a2, a3]
		else:
			if a1 < a3:
				return [a0, a1, a3, a2]
			else:
				if a0 < a3:
					return [a0, a3, a1, a2]
				else:
					return [a3, a0, a1, a2]
	else:
		if a0 < a2:
			if a1 < a3:
				return [a0, a2, a1, a3]
			else:
				if a2 < a3:
					return [a0, a2, a3, a1]
				else:
					if a0 < a3:
						return [a0, a3, a2, a1]
					else:
						return [a3, a0, a2, a1]
		else:
			if a1 < a3:
				return [a2, a0, a1, a3]
			else:
				if a0 < a3:
					return [a2, a0, a3, a1]
				else:
					if a2 < a3:
						return [a2, a3, a0, a1]
					else:
						return [a3, a2, a0, a1]
else:
	if a0 < a2:
		if a2 < a3:
			return [a1, a0, a2, a3]
		else:
			if a0 < a3:
				return [a1, a0, a3, a2]
			else:
				if a1 < a3:
					return [a1, a3, a0, a2]
				else:
					return [a3, a1, a0, a2]
	else:
		if a1 < a2:
			if a0 < a3:
				return [a1, a2, a0, a3]
			else:
				if a2 < a3:
					return [a1, a2, a3, a0]
				else:
					if a1 < a3:
						return [a1, a3, a2, a0]
					else:
						return [a3, a1, a2, a0]
		else:
			if a0 < a3:
				return [a2, a1, a0, a3]
			else:
				if a1 < a3:
					return [a2, a1, a3, a0]
				else:
					if a2 < a3:
						return [a2, a3, a1, a0]
					else:
						return [a3, a2, a1, a0]

Выглядит вроде неплохо. А давайте посчитаем, какая средняя глубина вложенности if-ов при увеличении n. Для этого для каждого вызова gen_return запомним ident и просто посчитаем среднее. Код я тут опускаю, потому что он скучный, а терпение читателя не резиновое.

n

n!

Avg(indent)

2

2

1.000

3

6

2.667

4

24

4.917

5

120

7.717

6

720

11.050

7

5040

14.907

8

40320

19.282

Давайте посмотрим, как эти числа ложатся на теорию.

Пишем самую тупую на свете сортировку - 4

Что-то тут не так. Минимальная глубина, как и ожидалось, очень приятная = n-1, но вот оранжевая линия (средняя глубина) выше наших ожиданий. Мы же хотели O(n*logn), а тут явно что-то другое. Давайте внимательнее посмотрим на сгенеренный код. Некоторые перестановки (например return [a0, a3, a2, a1]) возвращаются только после 6 сравнений, тогда как теория предсказывает что их должно быть не больше 5 (столько сделает mergesort в худшем случае сортировки массива из 4 элеменов). Видно что это происходит из-за того что наши деревья сравнений не сбалансированы. То есть они не всегда делят количество вариантов пополам. Иногда пропорции оказываются гораздо хуже: 1 к 3 или даже 1 к 7. Так не пойдет. Давайте дерево балансировать.

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

Секретные материалы

Тут был график, который я где-то пролюбил, а заново искать и перезапускать старый код мне лень. Поэтому я прошу читателя мне поверить на слово, что там все было так, как описано ниже. А сам график был прекрасен как никогда так же скучен, как и первый, так что уважаемый читатель ничего не потерял. :-/

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

Давайте тогда перепишем код чтобы он перебрал вообще все возможные последовательности проверок и вернул самую сбалансированную. Надо сказать, что такой перебор ужасно тормозит так как сложность такой генерации растет как квадрат факториала. Ради науки мы потерпим. Но только до n=5.

N

depth

2

[1, 1]

3

[2, 3, 3, 2, 3, 3]

4

[4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5, 5, 5]

5

[7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 6, 7, 7]

Ну вот, совсем другое дело. Теперь взглянем на сгенеренный код для n=4.

if a0 < a1:
	if a1 < a2:
		if a1 < a3:
			if a2 < a3:
				return [a0, a1, a2, a3]
			else:
				return [a0, a1, a3, a2]
		else:
			if a0 < a3:
				return [a0, a3, a1, a2]
			else:
				return [a3, a0, a1, a2]
	else:
      ...

Хм. Наша красивая первая "перестановка" теперь вместо 3 сравнений содержит 4. Причем "лишнее" сравнение (третье по счету) выглядит как-то совсем ненатурально. Однако статистика не врет - этот способ проверки самый лучший для среднего случая.

Но для среднего случая уже есть сортировка слиянием. Она гарантирует всегда близкое к идеальному количество сравнений. Лучше него только алгоритм слиянием-вставками и его вариации. Все они с фиксированным размером кода. Так нафига козе баян?

Дальше хуже

Для n=5 таких "ненужных" сравнений (для тривильного случая упорядоченнго массива) уже 7-4=3:

if a0 < a1:
	if a2 < a3:
		if a0 < a2:
			if a2 < a4:
				if a3 < a4:
					if a1 < a3:
						if a1 < a2:
							return [a0, a1, a2, a3, a4]
						else:
							return [a0, a2, a1, a3, a4]
					else:
						if a1 < a4:
							return [a0, a2, a3, a1, a4]
						else:
							return [a0, a2, a3, a4, a1]
				else:
...

Но можно подобрать параметры для оптимизации чтобы снизить это количество до 2.

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

"Погодите-погодите" - скажет кто-то наивный - "ведь мы по прежнему имеем ужасную асимптотику по памяти и надо писать отдельную функцию для каждого n". На что мы имеем возразить:

  1. Во первых очевидно, что его можно использовать только для небольших n. Вероятно не больше 7-8. После чего сгенеренный код начнет занимать уже непрактично много места. А если мы ограничиваем размер массива, то код становится конечным и наше использование памяти возвращается в приятное O(1).

  2. Для больших n мы можем "сверху" на него надстроить обычную сортировку слиянием. Тем самым сильно ускорив сортировку для наших данных, а не сферических в вакууме.

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

В качестве дальнейшей оптимизации нашего "супер-алгоритма" можно предложить:

  1. Вместо возврата массивов генерить готовые операции in-place обмена элементов массива самым оптимальным образом (сдвигая подмассивы, где возможно, вместо перемещения поштучно). Либо делая идеальные циклические перестановки с единственным слотом: temp = a0; a0 = a3; a3 = a2; a2 = temp для варианта [a3, a1, a0, a2]) и т.п.

  2. Избавлять от ветвлений с помощью трюков branchless programming, переходя на векторные инструкции. Это должно дополнительно ускорять в разы и десятки раз.

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

Автор: barbalion

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js