EssayAI
Блог
Блог

Как определить сложность алгоритма: пошаговое решение

Запрос

Дано: фрагмент кода из двух частей: цикл по ii от 1 до nn с вложенным циклом по jj от ii до nn, а после них цикл, который делит kk пополам, пока k>1k > 1. Размер входа n=16n = 16. Найти: точное число операций T(16)T(16) и оценку сложности в O-нотации.

Считаем, сколько раз выполняется тело каждого цикла, складываем последовательные части и оставляем самый быстрорастущий член. Ответ: T(n)=n(n+1)2+⌊log⁡2n⌋T(n) = \dfrac{n(n+1)}{2} + \lfloor \log_2 n \rfloor, при n=16n = 16 это 136+4=140136 + 4 = 140 операций, сложность O(n2)O(n^2). Калькулятор сверху пересчитает всё под другое nn и другие виды циклов, ниже решение по шагам.

Решение по шагам

Дано. Фрагмент на Python. За одну операцию принимаем одно выполнение тела цикла:

s = 0
for i in range(1, n + 1):        # внешний цикл: i = 1..n
    for j in range(i, n + 1):    # внутренний цикл: j = i..n
        s += 1                   # операция A
k = n
t = 0
while k > 1:                     # цикл деления пополам
    k //= 2
    t += 1                       # операция B

Шаг 1. Внутренний цикл при фиксированном ii. Переменная jj пробегает значения от ii до nn включительно, то есть n−i+1n - i + 1 значений. При i=1i = 1 тело выполнится 16 раз, при i=2i = 2 пятнадцать раз и так далее, пока при i=16i = 16 не останется одна итерация:

Значение i123…1516
Итераций по j161514…21

Шаг 2. Суммируем по внешнему циклу. Число итераций внутреннего цикла зависит от ii, поэтому перемножать границы нельзя: итерации надо сложить по всем ii.

A(n)=∑i=1n(n−i+1)=n+(n−1)+⋯+1=n(n+1)2.A(n) = \sum_{i=1}^{n} (n - i + 1) = n + (n-1) + \dots + 1 = \frac{n(n+1)}{2}.

При n=16n = 16 получаем A(16)=16⋅172=136A(16) = \dfrac{16 \cdot 17}{2} = 136.

Шаг 3. Цикл с делением пополам. Переменная kk стартует с 16 и после каждой итерации уменьшается вдвое:

Итерация1234
k до шага16842
k после шага8421

После четвёртой итерации k=1k = 1, условие k>1k > 1 ложно, и цикл останавливается. В общем виде число итераций равно B(n)=⌊log⁡2n⌋B(n) = \lfloor \log_2 n \rfloor, при n=16n = 16 это log⁡216=4\log_2 16 = 4.

Шаг 4. Складываем последовательные части. Вложенный блок и цикл деления идут друг за другом, а не один внутри другого, поэтому их операции складываются:

T(n)=n(n+1)2+⌊log⁡2n⌋,T(16)=136+4=140.T(n) = \frac{n(n+1)}{2} + \lfloor \log_2 n \rfloor, \qquad T(16) = 136 + 4 = 140.

Шаг 5. Оставляем главный член. Раскроем скобки: T(n)=12n2+12n+⌊log⁡2n⌋T(n) = \tfrac{1}{2}n^2 + \tfrac{1}{2}n + \lfloor \log_2 n \rfloor. С ростом nn квадратичное слагаемое обгоняет линейное и логарифмическое, а множитель 12\tfrac{1}{2} в O-нотации отбрасывается.

Ответ. Тело цикла выполняется T(16)=140T(16) = 140 раз: 136 раз во вложенных циклах и 4 раза в цикле деления пополам. Временная сложность фрагмента O(n2)O(n^2), точнее Θ(n2)\Theta(n^2).

Формула и откуда она берётся

Запись f(n)=O(g(n))f(n) = O(g(n)) означает, что начиная с некоторого n0n_0 функция ff не превосходит gg, умноженную на константу:

f(n)≤c⋅g(n)для всех n≥n0.f(n) \le c \cdot g(n) \quad \text{для всех } n \ge n_0.

Для нашего фрагмента подходят c=1c = 1 и n0=1n_0 = 1: например, T(16)=140≤162=256T(16) = 140 \le 16^2 = 256. Снизу T(n)T(n) ограничена функцией 12n2\tfrac{1}{2} n^2, поэтому оценка точная и записывается как Θ(n2)\Theta(n^2). Константы и младшие члены на класс не влияют: они меняют время в разы, но не меняют порядок роста.

Из определения вытекают три правила, по которым сложность определяют прямо по коду. Последовательные блоки складываются, и в сумме остаётся самый тяжёлый: O(n2)+O(log⁡n)=O(n2)O(n^2) + O(\log n) = O(n^2). Вложенные циклы с независимыми границами перемножаются: два цикла по nn дают n⋅nn \cdot n. Если граница внутреннего цикла зависит от внешнего счётчика, как здесь jj от ii, итерации суммируют по всем ii.

Треугольная сумма дала вдвое меньше операций, чем полный квадрат n2n^2, но класс остался тем же. Считают при этом не секунды, а число элементарных действий: присваиваний, сравнений, выполнений тела цикла. Проверки условий мы опустили: при каждом запуске цикла их на одну больше, чем итераций, и это меняет только константу.

Почему цикл с делением пополам даёт логарифм

После tt итераций переменная равна n/2tn / 2^t с округлением вниз. Цикл заканчивается, когда она дошла до единицы, то есть когда 2t≥n2^t \ge n. Отсюда t≈log⁡2nt \approx \log_2 n. Так же ведёт себя любой цикл, который умножает или делит счётчик на константу: шаг j=2jj = 2j, j=3jj = 3j или k=k/10k = k / 10. Основание логарифма в O-нотации не пишут, потому что log⁡an\log_a n и log⁡bn\log_b n отличаются лишь постоянным множителем.

Важно, где стоит такой цикл. У нас он идёт после вложенных и добавляет всего 4 операции. Если поставить цикл с удвоением на место внутреннего цикла по jj, он выполнится nn раз по ⌊log⁡2n⌋+1\lfloor \log_2 n \rfloor + 1 итераций, и весь фрагмент станет O(nlog⁡n)O(n \log n). Этот вариант есть в калькуляторе: при n=16n = 16 вложенная часть даёт 16⋅5=8016 \cdot 5 = 80 операций, итог 84.

Тот же порядок nlog⁡nn \log n получается у сортировки слиянием: log⁡2n\log_2 n уровней деления и по nn операций слияния на каждом. Приём деления пополам лежит и в основе двоичного поиска, подробнее он разобран в статье про бинарный поиск по ответу.

Если же счётчик уменьшается вычитанием (k=k−1k = k - 1), цикл сделает n−1n - 1 итерацию, и это уже линейная часть. Переключи хвостовой цикл в калькуляторе: при n=16n = 16 он даст 15 операций вместо 4, итог станет 151, но оценка O(n2)O(n^2) не изменится.

Проверка ответа удвоением n

Оценку легко проверить без формул: посчитать операции при nn и при 2n2n и посмотреть, во сколько раз выросло число. Для нашего фрагмента T(32)=528+5=533T(32) = 528 + 5 = 533, и 533/140≈3,81533 / 140 \approx 3{,}81. При переходе к n=64n = 64 отношение равно 2086/533≈3,912086 / 533 \approx 3{,}91 и приближается к 4, как и положено квадратичному алгоритму.

Класс сложностиВо сколько раз растёт T при удвоении n
O(1)1
O(log n)прибавляется константа, отношение стремится к 1
O(n)2
O(n log n)чуть больше 2
O(n²)4
O(n³)8
O(2ⁿ)значение возводится в квадрат

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

Рекурсия: основная теорема кратко

Если алгоритм рекурсивный, вместо суммы по циклам получается рекуррентное соотношение. Для алгоритмов, которые делят задачу на aa подзадач размера n/bn/b и тратят f(n)f(n) на разбиение и сборку, работает основная теорема:

T(n)=a T ⁣(nb)+f(n).T(n) = a\,T\!\left(\frac{n}{b}\right) + f(n).

Сравнивают f(n)f(n) с nlog⁡ban^{\log_b a}. Если ff растёт медленнее, ответ Θ(nlog⁡ba)\Theta(n^{\log_b a}). Если они одного порядка, ответ Θ(nlog⁡balog⁡n)\Theta(n^{\log_b a} \log n). Если ff растёт быстрее и выполнено условие регулярности, ответ Θ(f(n))\Theta(f(n)).

Двоичный поиск: T(n)=T(n/2)+1T(n) = T(n/2) + 1, здесь a=1a = 1, b=2b = 2, nlog⁡21=1n^{\log_2 1} = 1, случай равенства, итог Θ(log⁡n)\Theta(\log n). Сортировка слиянием: T(n)=2T(n/2)+nT(n) = 2T(n/2) + n, итог Θ(nlog⁡n)\Theta(n \log n). Умножение Карацубы: T(n)=3T(n/2)+nT(n) = 3T(n/2) + n, итог Θ(nlog⁡23)≈Θ(n1,585)\Theta(n^{\log_2 3}) \approx \Theta(n^{1{,}585}).

Теорема не покрывает соотношения вида T(n)=T(n−1)+nT(n) = T(n-1) + n, где задача уменьшается на единицу, а не в разы. Их раскрывают подстановкой в сумму и получают тот же треугольник n(n+1)/2n(n+1)/2, что и в нашем фрагменте. Именно так выводится квадратичный худший случай быстрой сортировки.

Частые ошибки

  • Перемножают границы, когда внутренний цикл зависит от внешнего. Для jj от 1 до i2i^2 произведение n⋅nn \cdot n даст неверный ответ: сумма ∑i2\sum i^2 растёт как n3/3n^3/3, и сложность будет O(n3)O(n^3).
  • Считают цикл с делением пополам линейным. Цикл k=k/2k = k / 2 делает ⌊log⁡2n⌋\lfloor \log_2 n \rfloor итераций: при n=16n = 16 их 4, а не 16.
  • Перемножают последовательные блоки. Цикл по nn, за которым идёт ещё один цикл по nn, даёт 2n2n операций, то есть O(n)O(n), а не O(n2)O(n^2). Умножение нужно только для вложенности.
  • Оставляют в ответе константы и младшие члены. Запись O(n2/2+log⁡n)O(n^2/2 + \log n) формально не ошибка, но в ответе ждут O(n2)O(n^2): класс определяется главным членом.
  • Принимают цикл с постоянной границей за зависящий от n. Цикл от 1 до 100 внутри цикла по nn даёт 100n100n операций, это O(n)O(n), а не O(n2)O(n^2).
  • Путают вычислительную сложность с цикломатической. Цикломатическая сложность считает число независимых путей в графе программы и от nn не зависит, время работы она не оценивает.

FAQ

Чем отличаются O, Θ и Ω? OO задаёт верхнюю границу роста, Ω\Omega нижнюю, Θ\Theta обе сразу. Для нашего фрагмента верны все три записи: O(n2)O(n^2), Ω(n2)\Omega(n^2) и Θ(n2)\Theta(n^2). Когда в задаче просят определить сложность, обычно ждут точную оценку, записанную через OO.

Какую сложность писать: худшего или среднего случая? По умолчанию указывают худший случай: он гарантирует, что алгоритм не будет работать дольше. В нашем фрагменте нет ветвлений, поэтому все случаи совпадают. У быстрой сортировки они различаются: O(nlog⁡n)O(n \log n) в среднем и O(n2)O(n^2) в худшем.

Как определить сложность алгоритма по памяти? Тем же способом, только считают не операции, а объём дополнительных данных: массивы, глубину рекурсии, вспомогательные структуры. Наш фрагмент хранит лишь ss, kk и tt, поэтому его затраты памяти O(1)O(1).

Можно ли определить сложность, просто замерив время программы? Замер подсказывает класс, если удваивать nn и смотреть на отношение времён, но не доказывает его. Время зависит от кэша процессора, интерпретатора и от младших членов, которые на малых nn ещё заметны. Строгий ответ даёт подсчёт итераций.

Коротко

  1. Для каждого цикла определи, сколько раз выполнится его тело в зависимости от nn.
  2. Если внутренний цикл зависит от внешнего счётчика, просуммируй итерации: ∑i=1n(n−i+1)=n(n+1)2\sum_{i=1}^{n}(n-i+1) = \tfrac{n(n+1)}{2}.
  3. Цикл, который делит или умножает счётчик на константу, даёт log⁡2n\log_2 n итераций, а не nn.
  4. Последовательные части сложи, вложенные перемножь или просуммируй, оставь главный член без констант.
  5. В примере T(16)=136+4=140T(16) = 136 + 4 = 140, сложность O(n2)O(n^2), а удвоение nn увеличивает число операций почти в 4 раза.
Задача в тетради или методичке? Сфотографируйте условие - сервис распознает его и решит по шагам с пояснениями.

Похожие задачи

Программирование/алгоритмы

Как вычислить числа Фибоначчи: решение по шагам

Как вычислить числа Фибоначчи: рекуррентная формула, итеративный проход за n минус одно сложение, проверка формулой Бине через золотое сечение и цена наивной рекурсии.

Программирование/алгоритмы

Как реализовать дек: пошаговое решение

Как реализовать дек на кольцевом массиве: формулы индексов head и tail через остаток от деления, трассировка push и pop с обоих концов, вариант на двусвязном списке, сложность операций.

Программирование/алгоритмы

Как сложить числа в двоичной системе: разбор столбиком

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

Орг./аналит. химия

Окисление перманганатом калия: реакции в трёх средах

Как написать окисление перманганатом калия в кислой, нейтральной и щелочной средах: продукты восстановления марганца, метод электронного баланса, расстановка коэффициентов, расчёт титранта.

Химия (физич./структурная)

Как найти активность иона: расчёт по Дебаю-Хюккелю

Как найти активность иона в растворе: ионная сила по всем ионам, коэффициент активности по предельному закону Дебая-Хюккеля, произведение f на c, разбор с числами и калькулятор.

Генетика

Как найти частоту генотипов: закон Харди-Вайнберга

Разбор задачи по популяционной генетике: как найти частоту генотипов по закону Харди-Вайнберга, формула p2 плюс 2pq плюс q2, расчёт числа особей и калькулятор частот.