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

На практике сортировка «пузырьком» почти не используется. Причина проста: есть существенно более эффективные алгоритмы сортировки с той же асимптотической сложностью и примерно такой же сложностью реализации.

Ниже мы рассмотрим две альтернативы пузырьку. Обе эти альтернативы разбивают массив на две части: до некоторого элемента (до — по номеру) массив отсортирован, после него — ещё нет. Шаг этих алгоритмов удлиняет отсортированный кусок на 1 элемент.

То есть устроены они так (в предположении, что переменная array связана с сортируемым массивом):

N = len(array)
for sorted_length in range(N):
  ## здесь происходят перестановки элементов, приводящие к 
  ## увеличению длины упорядоченного куска на 1

Сортировка поиском минимума

Для удлинения упорядоченной части на 1 мы ищем наименьший элемент неупорядоченной части и меняем его местами с начальным элементом неупорядоченной части:

N = len(array)
for sorted_length in range(N):
  _, min_index = min([(x, i + sorted_length) 
                      for i,x in enumerate(array[sorted_length:])])
  
  array[min_index], array[sorted_length] = (
    array[sorted_length], array[min_index]
  )

Асимптотическую сложность этого алгоритма нетрудно вычислить. Поиск минимального элемента (перебором) на каждом шагу алгоритма занимает не более \(N\) операций вида «посмотреть на элемент и что-нибудь запомнить», но и не менее \(N-sorted_length\) (в том случае, когда минимальный элемент — последний, на который мы посмотрели в процессе перебора).

Поэтому время работы алгоритма \(T(N)\) удовлетворяет неравенствам

\[ N\cdot N \geqslant T(n) \geqslant N + (N-1) + (N-2) + \ldots + 1 = \frac{N(N+1)}{2} \]

Осталось заметить, что \(N(N+1)/2 \geqslant \frac{1}{2} N^2\). Поэтому рассматриваемый нами алгоритм относится к классу квадратичных (т.е. асимптотической сложности \(N^2\)).

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

Для удлинения упорядоченной части на 1 мы берём первый элемент неупорядоченной части и обменами с левым соседом ставим в то место упорядоченной части, где он должен в итоге оказаться:

N = len(array)
for sorted_length in range(N):
  for k in range(sorted_length):
    i = sorted_length - k 
    if array[i] < array[i-1]:
      array[i], array[i-1] = array[i-1], array[i]
    else:
      break

Единственной сложностью реализации этого алгоритма является обеспечения корректности индекса i внутреннего цикла. Он должен быть в пределах от \(1\) (для того, чтобы было определено array[i-1]) до \(N-1\) (для того, чтобы было определено array[i]).

Поскольку k не превышает sorted_length-1, то sorted_length-k не меньше

sorted_length - (sorted_length - 1) = 1

Также, поскольку k не меньше нуля, а sorted_length не превышает \(N-1\), то sorted_length - k тоже не превышает \(N-1\).

Асимптотическая сложность этого алгоритма ровно такая же, как и у сортировки поиском минимума: квадратичная. Доказательство в точности такое же.

Постскриптум

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

Может возникнуть естественный вопрос: «зачем вообще использовать квадратичный алгоритм сортировки, если есть алгоритмы с более низкой асимптотической сложностью?». Ответ весьма простой (хотя и состоит из двух частей):

  • теория асимптотической сложности исследует лишь время работы алгоритма при достаточно большом \(N\)
  • чем больше \(\alpha\), тем при большем количестве натуральных значений \(N\) выполнено неравенство \(\alpha N \log_2 N > N^2\)

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

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