# Анализ сложности ![](ru/docs/assets/media/image68.jpeg){width="3.71875656167979in" height="4.8125in"} > 2.1. Оценка эффективности алгоритмов ❖ **35** #### Оценка эффективности алгоритмов > В процессе разработки алгоритмов мы стремимся к достижению следующих целей: 1) **найти решение задачи**: алгоритм должен надежно находить правиль- ное решение задачи в заданных пределах входных данных; 2) **найти оптимальное решение**: для одной и той же задачи может суще- ствовать несколько решений, и мы стремимся найти максимально эф- фективный алгоритм. > Таким образом, при условии возможности решения задачи эффективность алгоритма становится основным критерием его оценки, который включает два аспекта: 1) **временную эффективность**: продолжительность выполнения алго- ритма; 2) **пространственную эффективность**: объем памяти, занимаемой алго- ритмом. > В двух словах, **наша цель -- разработка быстрых и экономных структур данных и алгоритмов**. Эффективная оценка алгоритмов крайне важна, так как только так можно сравнивать различные алгоритмы и управлять процес- сом их разработки и оптимизации. > > Методы оценки эффективности делятся на два типа: практическое тестиро- вание и теоретическую оценку. 1. **Практическое тестирование** > Предположим, у нас есть алгоритмы A и B, которые решают одну и ту же зада- чу, и необходимо сравнить их эффективность. Самый прямой метод -- это за- пустить оба алгоритма на компьютере и зафиксировать время их выполнения и объем используемой памяти. Этот метод отражает реальную ситуацию, но имеет значительные ограничения. > > С одной стороны, **сложно исключить влияние факторов тестовой сре- ды**. Аппаратная конфигурация влияет на производительность алгоритма. Например, если алгоритм обладает высокой степенью параллелизма, он будет лучше работать на многоядерных процессорах. Если алгоритм интен- сивно использует память, его производительность будет выше на высоко- производительной памяти. Это означает, что результаты тестирования на разных машинах могут значительно отличаться, и потребуется тестирова- ние на различных платформах для получения средней эффективности, что крайне затруднительно. > > С другой стороны, **проведение полного тестирования требует значи- тельных ресурсов**. С изменением объема входных данных алгоритмы демон- стрируют разную эффективность. Например, при небольшом объеме данных алгоритм A может работать быстрее, чем алгоритм B, но при большом объеме данных результат может быть противоположным. Следовательно, для полу- чения убедительных выводов необходимо тестировать различные масштабы входных данных, что требует значительных вычислительных ресурсов. ### Теоретическая оценка > Из-за значительных ограничений практического тестирования можно рас- смотреть возможность оценки эффективности алгоритмов только с помощью математических расчетов. Этот метод называется анализом асимптотической сложности или просто анализом сложности. > > Анализ сложности позволяет отразить зависимость между ресурсами вре- мени и пространства, необходимыми для выполнения алгоритма, и размером входных данных. **Он описывает тенденцию роста времени и простран- ства, необходимых для выполнения алгоритма, по мере увеличения раз- мера входных данных**. Это определение может показаться сложным, но его можно разбить на три ключевых момента. 1. Ресурсы времени и пространства соответствуют временной сложности и пространственной сложности. 2. «По мере увеличения размера входных данных» означает, что сложность отражает зависимость эффективности алгоритма от объема входных данных. 3. Тенденция роста времени и пространства указывает, что анализ слож- ности фокусируется не на конкретных значениях времени выполнения или объема занимаемой памяти, а на скорости их роста. > **Анализ сложности преодолевает недостатки метода практического те- стирования**, что выражается в следующих аспектах: 1) он не требует фактического выполнения кода, что делает его более эко- логичным и энергосберегающим; 2) он независим от тестовой среды, а результаты анализа применимы ко всем платформам выполнения; 3) он может продемонстрировать эффективность алгоритма при различ- ных объемах данных, особенно при больших объемах. > Анализ сложности предоставляет нам мерило оценки эффективности ал- горитмов, позволяя измерять время и ресурсы, необходимые для выполне- ния конкретного алгоритма, а также сравнивать эффективность различных алгоритмов. > > Сложность -- это математическое понятие, которое новичкам может по- казаться абстрактным и сложным для изучения. С этой точки зрения анализ сложности не то, с чего стоит начинать изучение алгоритмов. Однако, обсуж- дая особенности той или иной структуры данных или алгоритма, невозможно избежать анализа их скорости выполнения и использования памяти. > > Таким образом, перед погружением в изучение структур данных и алго- ритмов рекомендуется получить базовое представление об анализе слож- ности, чтобы иметь возможность выполнять хотя бы базовую оценку их эф- фективности. #### итерация и рекурсия > В алгоритмах часто требуется повторное выполнение определенной задачи, что тесно связано с анализом сложности. Поэтому, прежде чем перейти к об- суждению временной и пространственной сложности, рассмотрим, как реа- лизовать повторное выполнение задач в программе, а именно две основные структуры управления программой: итерацию и рекурсию. ### Итерации > *Итерация* -- это структура управления, которая позволяет повторно выполнять определенную задачу. В итерации программа повторяет выполнение опреде- ленного участка кода, пока выполняется определенное условие. ##### Цикл for > *Цикл* for -- одна из наиболее распространенных форм итерации, которая под- ходит для использования, когда количество итераций известно заранее. > > Следующая функция реализует суммирование 1 + 2 + \... + *n* с использо- ванием цикла for, результат суммирования сохраняется в переменной res. Следует отметить, что в Python диапазон range(a, b) соответствует лево- му закрытому, правому открытому интервалу, т. е. перебираются значения *a*, *a* + 1, \... , *b* − 1: > > \# === File: iteration.py === def for_loop(n: int) -\> int: > > \"\"\"Цикл for.\"\"\" > > res = 0 > > \# Цикл суммирования 1, 2, \..., n-1, n. for i in range(1, n + 1): > > res += i return res > > Количество операций этой функции суммирования пропорционально раз- меру входных данных *n*, или, другими словами, линейно зависит от него. **На самом деле временная сложность описывает именно эту линейную зависимость**. Соответствующий материал будет подробно рассмотрен в сле- дующем разделе. > > ![](ru/docs/assets/media/image70.png) ##### Цикл while > **Рис. 2.1.** Блок-схема функции суммирования > > Подобно циклу for, цикл while также представляет собой метод реализации итерации. В цикле while программа перед каждой итерацией проверяет ус- ловие: если условие истинно, то выполнение продолжается, иначе цикл за- вершается. > > Ниже приведен пример реализации суммирования 1 + 2 + \... + *n* с использо- ванием цикла while: > > \# === File: iteration.py === def while_loop(n: int) -\> int: > > \"\"\"Цикл while.\"\"\" > > res = 0 > > i = 1 \# Инициализация условной переменной. \# Цикл для суммирования 1, 2, \..., n-1, n. while i \<= n: > > res += i > > i += 1 \# Обновление значения условной переменной. return res > > **Цикл** while **обладает большей степенью свободы по сравнению с ци- клом** for. В цикле while можно свободно управлять инициализацией и обнов- лением условной переменной. > > Например, в следующем коде условная переменная *i* обновляется дваж- ды на каждой итерации, что затруднительно сделать с использованием цикла for: > > \# === File: iteration.py === > > def while_loop_ii(n: int) -\> int: > > \"\"\"Цикл while (двойное обновление).\"\"\" res = 0 > > i = 1 \# Инициализация условной переменной. \# Цикл для суммирования 1, 4, 10, \... while i \<= n: > > res += i > > \# Обновление значения условной переменной. i += 1 > > i \*= 2 return res > > В целом **код с использованием цикла** for **более компактный**, **а цикл** while **более гибкий**. Но они оба могут реализовать итерационную структуру. Выбор между ними определяется требованиями конкретной задачи. ##### Вложенные циклы > Внутрь одной циклической структуры можно вложить другую, например используя два цикла for: > > \# === File: iteration.py === > > def nested_for_loop(n: int) -\> str: \"\"\"Двойной цикл for.\"\"\" > > res = \"\" > > \# Цикл i = 1, 2, \..., n-1, n. > > for i in range(1, n + 1): > > \# Цикл j = 1, 2, \..., n-1, n for j in range(1, n + 1): > > res += f\"({i}, {j}), \" return res > > В этом случае количество выполненных действий пропорционально *n*2, или, другими словами, время выполнения алгоритма и размер входных данных *n* находятся в квадратичной зависимости. > > Можно и дальше добавлять вложенные циклы, тогда каждое вложение будет повышать размерность, увеличивая временную сложность до кубической за- висимости, зависимости четвертой степени и т. д. > > ![](ru/docs/assets/media/image72.png) ### Рекурсия > **Рис. 2.2.** Блок-схема вложенного цикла > > *Рекурсия* -- это стратегия алгоритма, при которой функция вызывает саму себя для решения задачи. Она включает два основных этапа. 1. **Вызов**: программа постоянно вызывает саму себя, обычно передавая меньшие или более упрощенные параметры, пока не будет достигнуто условие завершения. 2. **Возврат**: после срабатывания условия завершения программа начинает возвращаться из самой глубокой рекурсивной функции, объединяя ре- зультаты каждого уровня. > С точки зрения реализации рекурсивный код включает три основных элемента. 1. **Условие завершения**: используется для определения момента перехода от вызова к возврату. 2. **Рекурсивный вызов**: соответствует вызову, функция вызывает саму себя, обычно с меньшими или упрощенными параметрами. 3. **Возврат результата**: соответствует возврату, возвращает результат те- кущего уровня рекурсии на предыдущий уровень. > Рассмотрим следующий код: вызов функции recur(n) позволяет вычислить сумму 1 + 2 + \... + *n*. > > \# === File: recursion.py === > > def recur(n: int) -\> int: \"\"\" Рекурсия.\"\"\" > > \# Условие завершения. if n == 1: > > return 1 > > \# Вызов: рекурсивный вызов. res = recur(n - 1) > > \# Возврат: возврат результата. return n + res ![](ru/docs/assets/media/image74.png) > **Рис. 2.3.** Рекурсивный вызов функции суммирования > > Хотя с точки зрения вычислений итерация и рекурсия могут давать оди- наковый результат, они представляют собой совершенно разные парадигмы мышления и решения задач. - **Итерация**: решение задачи снизу вверх. Начинаем с самых базовых шагов, которые затем повторяются или накапливаются до завершения задачи. - **Рекурсия**: решение задачи сверху вниз. Исходная задача разбивается на более мелкие подзадачи, которые имеют ту же форму, что и исходная за- дача. Далее подзадачи продолжают делиться на еще более мелкие, пока не достигается базовый случай (решение базового случая известно). > Рассмотрим в качестве примера вышеупомянутую функцию суммирования, где решается задача *f*(*n*) = 1 + 2 + \... + *n*. - **Итерация**: моделирование процесса суммирования в цикле проходит от 1 до *n*, выполняя операцию суммирования на каждом шаге, чтобы полу- чить итоговое значение *f*(*n*). - **Рекурсия**: последовательное разбиение задачи на подзадачи вида *f*(*n*) = > *n* + *f*(*n* -- 1) до достижения базового случая *f*(1) = 1. ##### Стек вызовов > Каждый раз, когда рекурсивная функция вызывает саму себя, система выделя- ет память для нового вызова функции, чтобы хранить локальные переменные, адрес вызова и другую информацию. Это поведение имеет два последствия. 1. Контекстные данные функции хранятся в области памяти, называемой про- странством стекового кадра, и освобождаются только после возврата функ- ции. **Поэтому рекурсия обычно требует больше памяти, чем итерация**. 2. Рекурсивный вызов функции создает дополнительные накладные расходы. ###### Поэтому рекурсия обычно менее эффективна по времени, чем цикл. > До срабатывания условия завершения одновременно существует *n* невоз- вращенных рекурсивных функций, как показано на рис. 2.4. Число *n* называет- ся глубиной рекурсии. ![](ru/docs/assets/media/image76.jpeg) > **Рис. 2.4.** Глубина рекурсивного вызова > > На практике глубина рекурсии, разрешенная языком программирования, обычно ограничена, и слишком глубокая рекурсия может привести к ошибке переполнения стека. ##### Хвостовая рекурсия > Интересно, что если рекурсивный вызов происходит на последнем шаге перед возвратом функции, то компилятор или интерпретатор может оптимизиро- вать этот вызов, сделав его по эффективности использования памяти сопоста- вимым с итерацией. Это называется хвостовой рекурсией. - **Обычная рекурсия**: когда функция возвращается на предыдущий уро- вень, необходимо продолжить выполнение кода, поэтому системе нужно сохранить контекст предыдущего вызова. - **Хвостовая рекурсия**: рекурсивный вызов является последней операци- ей перед возвратом функции, что означает, что после возврата на преды- дущий уровень не требуется выполнять другие операции, поэтому систе- ме не нужно сохранять контекст предыдущей функции. > В качестве примера вычисления суммы 1 + 2 + \... + *n* можно установить пере- менную результата res в качестве параметра функции, чтобы реализовать хво- стовую рекурсию: > > \# === File: recursion.py === def tail_recur(n, res): > > \"\"\" Хвостовая рекурсия. \"\"\" > > \# Условие завершения. if n == 0: > > return res > > \# Хвостовой рекурсивный вызов. return tail_recur(n - 1, res + n) > > Процесс выполнения хвостовой рекурсии показан на рис. 2.5. Сравнивая обычную и хвостовую рекурсии, можно заметить, что точка выполнения опе- рации суммирования у них различается. - **Обычная рекурсия**: операция суммирования выполняется в процессе возврата, после каждого возврата необходимо снова выполнить опера- цию суммирования. - **Хвостовая рекурсия**: операция суммирования выполняется в процессе вызова, процесс возврата требует только последовательного возврата. ![](ru/docs/assets/media/image78.jpeg) > **Рис. 2.5.** Процесс выполнения хвостовой рекурсии ##### Дерево рекурсии > При решении задач, связанных с алгоритмами типа «разделяй и властвуй», ре- курсия зачастую оказывается более интуитивной и читабельной, чем итера- ция. Рассмотрим в качестве примера последовательность Фибоначчи. > > Обозначив *n*-й член последовательности Фибоначчи как *f*(*n*), можно сфор- мулировать два утверждения. 1. Первые два числа последовательности: *f*(1) = 0 и *f*(2) = 1. 2. Каждое число последовательности является суммой двух предыдущих чисел, т. е. *f*(*n*) = *f*(*n* − 1) + *f*(*n* − 2). > Используя рекурсивные вызовы в соответствии с рекуррентным соотноше- нием и принимая первые два числа за условия остановки, можно написать ре- курсивный код. Вызов fib(n) позволит получить *n*-й член последовательности Фибоначчи. > > \# === File: recursion.py === def fib(n: int) -\> int: > > \"\"\" Последовательность Фибоначчи: рекурсия. \"\"\" \# Условия остановки f(1) = 0, f(2) = 1 > > if n == 1 or n == 2: return n - 1 > > \# Рекурсивный вызов f(n) = f(n-1) + f(n-2). res = fib(n - 1) + fib(n - 2) > > \# Возврат результата f(n). return res > > Проанализировав приведенный код, можно заметить, что внутри функ- ции осуществляется рекурсивный вызов двух функций, т. е. из одного вызо- ва образуются два ветвления. При последующем выполнении рекурсивных вызовов в итоге образуется рекурсивное дерево глубиной *n*, как показано на рис. 2.6. > > По своей сути рекурсия отражает парадигму мышления «разбиение зада- чи на более мелкие подзадачи», что делает стратегию «разделяй и властвуй» крайне важной. > > С точки зрения **алгоритмов** многие важные алгоритмические стратегии, такие как поиск, сортировка, возврат, «разделяй и властвуй», динамическое программирование, прямо или косвенно используют этот подход. > > С точки зрения **структур данных** рекурсия естественно подходит для реше- ния задач, связанных со списками, деревьями и графами, поскольку они очень хорошо поддаются анализу с использованием идеи «разделяй и властвуй». > > ![](ru/docs/assets/media/image79.jpeg){width="5.095693350831146in" height="2.08in"} > > **Рис. 2.6.** Рекурсивное дерево последовательности Фибоначчи ### Сравнение > Подводя итог, можно сказать, что итерация и рекурсия различаются по реали- зации, производительности и применимости, как показано в табл. 2.1. > > **Таблица 2.1.** Сравнение итерации и рекурсии +-----------------------+---------------------------------+-----------------------------------------+ | | > **Итерация** | > **Рекурсия** | +=======================+=================================+=========================================+ | > Способ реализации | > Циклическая структура | > Функция вызывает саму себя | +-----------------------+---------------------------------+-----------------------------------------+ | > Временная | > Обычно высокая | > Каждый вызов функции создает | +-----------------------+---------------------------------+-----------------------------------------+ | > эффективность | > эффективность, нет | > затраты | +-----------------------+---------------------------------+-----------------------------------------+ | | > затрат на вызов функции | | +-----------------------+---------------------------------+-----------------------------------------+ | > Использование | > Обычно используется | > Накопление вызовов функции может | +-----------------------+---------------------------------+-----------------------------------------+ | > памяти | > фиксированный объем | > использовать значительное количество | +-----------------------+---------------------------------+-----------------------------------------+ | | > памяти | > пространства стека | +-----------------------+---------------------------------+-----------------------------------------+ | > Сфера использования | > Подходит для простых | > Подходит для разбиения на подзадачи; | +-----------------------+---------------------------------+-----------------------------------------+ | | > циклических задач, код | > для структур деревья, графы; алгорит- | +-----------------------+---------------------------------+-----------------------------------------+ | | > интуитивно понятен | > мов «разделяй и властвуй», возврат | +-----------------------+---------------------------------+-----------------------------------------+ | | > [и]{.smallcaps} хорошо читаем | > и т. д.; структура кода проста и ясна | +-----------------------+---------------------------------+-----------------------------------------+ > Какова же внутренняя связь между итерацией и рекурсией? В рассмотрен- ном примере рекурсивной функции операция сложения выполняется на этапе возврата рекурсии. Это означает, что функция, вызванная первой, фактически завершает операцию сложения последней, **что соответствует принципу сте- ка «первым пришел -- последним вышел»**. > > Фактически такие термины рекурсии, как «вызов стека» и «пространство стекового кадра», уже намекают на тесную связь между рекурсией и стеком. 1. **Вызов**: когда вызывается функция, система выделяет новый стековый кадр в вызове стека» для хранения локальных переменных функции, па- раметров, адреса возврата и других данных. 2. **Возврат**: когда функция завершает выполнение и возвращает результат, соответствующий стековый кадр удаляется из вызова стека, восстанав- ливая среду выполнения предыдущей функции. > Таким образом, **можно использовать явный стек для моделирования по- ведения вызова стека**, чтобы преобразовать рекурсию в итеративную форму. > > \# === File: recursion.py === > > def for_loop_recur(n: int) -\> int: > > \"\"\" Использование итерации для моделирования рекурсии. \"\"\" > > \# Использование явного стека для моделирования системного вызова стека. stack = \[\] > > res = 0 > > \# Вызов: рекурсивный вызов. for i in range(n, 0, -1): > > \# Моделирование вызова через операцию добавления в стек. stack.append(i) > > \# Возврат: возвращение результата. while stack: > > \# Моделирование возврата через операцию удаления из стека. res += stack.pop() > > \# res = 1+2+3+\...+n > > return res > > Проанализировав приведенный код, можно заметить, что после преобразо- вания рекурсии в итерацию код становится более сложным. Хотя в большин- стве случаев итерацию и рекурсию можно взаимно преобразовать, это не всег- да оправдано по следующим двум причинам: 1) преобразованный код может стать более трудным для понимания, с худ- шей читаемостью; 2) для некоторых сложных задач моделирование поведения системного вызова стека может быть крайне сложным. > В общем, выбор между итерацией и рекурсией зависит от природы конкрет- ной задачи. В программной практике крайне важно взвешивать преимуще- ства и недостатки обоих подходов и выбирать наиболее подходящий метод в зависимости от ситуации. #### временная сложность > *Время выполнения* является наглядным и точным показателем эффективности алгоритма. Что же нам нужно сделать, чтобы точно оценить время выполне- ния кода? 1. **Определить платформу выполнения**, включая аппаратную конфи- гурацию, язык программирования, системную среду и т. д., так как эти факторы влияют на эффективность выполнения кода. 2. **Оценить время выполнения различных вычислительных опера- ций** − например, операция сложения + требует 1 нс, операция умноже- ния \* требует 10 нс, операция печати print() требует 5 нс и т. д. 3. **Подсчитать все вычислительные операции в коде** и суммировать время выполнения всех операций, чтобы получить общее время выпол- нения. > Например, в следующем коде размер входных данных равен *n*: > > \# На некоторой платформе выполнения. def algorithm(n: int): > > a = 2 \# 1 нс. > > a = a + 1 \# 1 нс. a = a \* 2 \# 10 нс. \# Цикл n итераций. > > for \_ in range(n): \# 1 нс. print(0) \# 5 нс. > > Согласно вышеописанному методу можно определить, что время выполне- ния алгоритма равно (6*n* + 12) нс: > > 1 + 1 + 10 + (1 + 5) × *n* = 6*n* + 12. > > Однако на практике подсчет времени выполнения алгоритма не является ни разумным, ни реалистичным. Во-первых, мы не хотим связывать оценочное время с конкретной платформой выполнения, так как алгоритм должен рабо- тать на различных платформах. Во-вторых, очень трудно узнать время выпол- нения каждой операции, и это значительно усложняет процесс оценки. ### Тенденция роста времени > Анализ временной сложности не оценивает время выполнения алгоритма, **а исследует тенденцию роста времени выполнения алгоритма по мере увеличения объема данных**. > > Понятие «тенденция роста времени» довольно абстрактно, и для его лучше- го понимания рассмотрим пример. Предположим, что размер входных данных равен *n*, и имеется три алгоритма A, B и C: > > \# Временная сложность алгоритма A: константная. def algorithm_A(n: int): > > print(0) > > \# Временная сложность алгоритма B: линейная. def algorithm_B(n: int): > > for \_ in range(n): print(0) > > \# Временная сложность алгоритма C: константная. def algorithm_C(n: int): > > for \_ in range(1000000): print(0) > > На рис. 2.7 изображена схема временной сложности функций этих трех ал- горитмов. - Алгоритм A содержит только одну операцию печати, и время выполне- ния алгоритма не увеличивается с ростом *n*. Временная сложность этого алгоритма называется константной. - В алгоритме B операция печати выполняется в цикле *n* раз, и время вы- полнения алгоритма увеличивается линейно с ростом *n*. Временная сложность этого алгоритма называется линейной. - В алгоритме C операция печати выполняется в цикле 1 000 000 раз, и, хотя время выполнения очень долгое, оно не зависит от размера входных данных *n*. Поэтому временная сложность C такая же, как у A, и остается константной. ![](ru/docs/assets/media/image81.jpeg) > **Рис. 2.7.** Тенденция роста времени для алгоритмов A, B и C > > Какие особенности имеет анализ временной сложности по сравнению с пря- мым подсчетом времени выполнения алгоритма? - **Временная сложность позволяет эффективно оценить эффектив- ность алгоритма**. Например, время выполнения алгоритма B увеличи- вается линейно, и при *n* \> 1 он медленнее алгоритма A, а при *n* \> 1 000 000 медленнее алгоритма C. На самом деле, если размер входных данных *n* достаточно велик, алгоритм с константной сложностью всегда будет луч- ше, чем алгоритм с линейной сложностью, и это и есть суть тенденции роста времени. - **Метод вычисления временной сложности проще**. Очевидно, что платформа выполнения и типы вычислительных операций не связаны с тенденцией роста времени выполнения алгоритма. Поэтому в анали- зе временной сложности можно просто считать, что время выполнения всех вычислительных операций одинаково и равно единичному време- ни, что позволяет упростить статистику времени выполнения вычисли- тельных операций до статистики количества вычислительных операций, значительно снижая сложность оценки. - **Однако временная сложность имеет определенные ограничения**. Например, хотя временная сложность алгоритмов A и C одинакова, их фак- тическое время выполнения значительно отличается. Аналогично, хотя временная сложность алгоритма B выше, чем у C, при малых значениях *n* алгоритм B явно лучше C. В таких случаях часто трудно оценить эффек- тивность алгоритма только по временной сложности. Тем не менее, не- смотря на эти проблемы, анализ сложности остается наиболее эффектив- ным и распространенным методом оценки эффективности алгоритмов. ### Асимптотическая верхняя граница функции > Пусть дан входной размер *n* для функции: > > def algorithm(n: int): a = 1 \# +1. > > a = a + 1 \# +1. > > a = a \* 2 \# +1. > > \# Цикл n итераций. > > for i in range(n): \# +1. print(0) \# +1. > > Пусть *T*(*n*) -- это количество операций алгоритма, являющееся функцией от размера входных данных *n*. Тогда количество операций для вышеуказанной функции равно: > > *T*(*n*) = 3 + 2*n*. > > *T*(*n*) является линейной функцией, что указывает на линейную тенденцию роста времени выполнения, следовательно, у алгоритма временная сложность линейного порядка. > > Линейная временная сложность обозначается как *O*(*n*), этот математиче- ский символ называется «О» большое и представляет асимптотическую верх- нюю границу функции *T*(*n*). > > Анализ временной сложности, по сути, является вычислением асимптоти- ческой верхней границы количества операций *T*(*n*), которая имеет четкое ма- тематическое определение. > > Вычисление асимптотической верхней границы заключается в нахождении функции *f*(*n*) − такой, что при стремлении *n* к бесконечности *T*(*n*) и *f*(*n*) нахо- дятся на одном уровне роста, отличаясь лишь на множитель константы *c*, как показано на рис. 2.8. > > ![](ru/docs/assets/media/image83.jpeg) > > **Рис. 2.8.** Асимптотическая верхняя граница функции ### Методы вычисления > Асимптотическая верхняя граница является абстрактным математическим понятием, и, если вы не полностью понимаете его, не стоит беспокоиться. Можно сначала освоить методику вывода, и в процессе практики постепенно осознать его математическое значение. > > Согласно определению, если найти функцию *f*(*n*), можно получить времен- ную сложность *O*(*f*(*n*)). Как же определить асимптотическую верхнюю грани- цу *f*(*n*)? В общем случае это делается в два этапа: сначала подсчитывается ко- личество операций, затем определяется асимптотическая верхняя граница. ##### Подсчет количества операций > Для кода это делается путем построчного подсчета сверху вниз. Однако, по- скольку константа *c* в выражении *c* × *f*(*n*) может принимать любое значение, **все коэффициенты и константы в** *T*(*n*) **можно игнорировать**. На основе этого принципа можно сформулировать следующие упрощенные приемы подсчета: 1) **игнорирование констант в** *T*(*n*). Они не зависят от *n* и поэтому не вли- яют на временную сложность; 2) **опускание всех коэффициентов**. Например, циклы из 2*n* итераций, 5*n* > \+ 1 итераций и т. д. можно упростить до *n* итераций, поскольку коэффи- циенты перед *n* не влияют на временную сложность; 3) **при вложенных циклах используется умножение**. Общее количество операций равно произведению количества операций внешнего и вну- треннего циклов, при этом для каждого уровня цикла можно применять приемы 1 и 2. > Следующую функцию можно использовать для подсчета количества опера- ций с помощью вышеуказанных приемов: > > def algorithm(n: int): > > a = 1 \# +0 (прием 1). > > a = a + n \# +0 (прием 1). \# +n (прием 2). > > for i in range(5 \* n + 1): print(0) > > \# +n\*n (прием 3). > > for i in range(2 \* n): > > for j in range(n + 1): print(0) > > Следующая формула демонстрирует результаты подсчета до и после примене- ния вышеуказанных приемов, временная сложность в обоих случаях равна *O*(*n*²). > > *T*(*n*) = 2*n*(*n* + 1) + (5*n* + 1) + 2 Полный подсчет (-.-\|\|\|) > > = 2*n*2 + 7*n* + 3 > > *T*(*n*) = *n*2 + *n* Упрощенный подсчет (o.O) ##### Определение асимптотической верхней границы > Временная сложность определяется старшей степенью в *T*(*n*). Это связано с тем, что при стремлении *n* к бесконечности старшая степень будет играть до- минирующую роль, а влиянием других членов можно пренебречь. > > В табл. 2.2 приведены некоторые гипертрофированные примеры, иллю- стрирующие вывод о том, что «коэффициенты не могут изменить поря- док». Когда *n* стремится к бесконечности, эти константы становятся несу- щественными. > > **Таблица 2.2.** Временная сложность для различных количеств операций > > **Количество операций** *T***(***n***) Временная сложность** *O***(***f***(***n***))** 100 000 *O*(1) 3*n* + 2 *O*(*n*) 2*n*2 + 3*n* + 2 *O*(*n*2) *n*3 + 10000*n*2 *O*(*n*3) 2*n* + 10000*n*10000 *O*(2*n*) ### Основные типы > Пусть размер входных данных равен *n*, основные типы временной сложности показаны на рис. 2.9 (в порядке от низшего к высшему). > > *O*(1) \< *O*(log *n*) \< *O*(*n*) \< *O*(*n* log *n*) \< *O*(*n*2) \< *O*(2*n*) \< *O*(*n*!) > > Константная \< Логарифмическая \< Линейная \< Линейно- логарифмическая \< Квадратичная \< Экспоненциальная \< Факториальная > > **От низшего к высшему,** > > **от лучшего к худшему** ##### Константная сложность *O*(1) > Количество операций *константной сложности* не зависит от размера входных данных *n*, т. е. не изменяется по мере роста *n*. > > В следующей функции временная сложность не зависит от *n* и равна *O*(1), несмотря на то что количество операций size может быть очень большим. > > \# === File: time_complexity.py === def constant(n: int) -\> int: > > \"\"\" Константный порядок.\"\"\" > > count = 0 > > size = 100000 > > for \_ in range(size): count += 1 > > return count ##### Линейная сложность *O*(*n*) > Количество операций *линейной сложности* растет линейно относительно раз- мера входных данных *n*. Линейная сложность обычно встречается в однопро- ходных циклах. > > \# === File: time_complexity.py === def linear(n: int) -\> int: > > \"\"\" Линейный порядок.\"\"\" count = 0 > > for \_ in range(n): count += 1 > > return count > > Временная сложность операций, таких как обход массива и обход связного списка, равна *O*(*n*), где *n* -- длина массива или списка. > > \# === File: time_complexity.py === > > def array_traversal(nums: list\[int\]) -\> int: \"\"\" Линейный порядок (обход массива).\"\"\" count = 0 > > \# Количество циклов пропорционально длине массива. for num in nums: > > count += 1 return count > > Следует отметить, что **размер входных данных *n* необходимо опреде- лять в зависимости от типа входных данных**. Например, в первом при- мере переменная *n* обозначает размер входных данных; во втором примере размер данных определяется длиной массива *n*. ##### Квадратичная сложность *O*(*n*2) > Количество операций *квадратичной сложности* растет с квадратом размера входных данных *n*. Квадратичная сложность обычно возникает в случае вложен- ных циклов, когда временная сложность как внешнего, так и внутреннего ци- клов равна *O*(*n*) и, следовательно, общая временная сложность составляет *O*(n2): > > \# === File: time_complexity.py === def quadratic(n: int) -\> int: > > \"\"\" Квадратичная сложность.\"\"\" count = 0 > > \# Количество операций в циклах пропорционально квадрату размера данных n. for i in range(n): > > for j in range(n): count += 1 > > return count > > На рис. 2.10 приведено сравнение трех видов временной сложности: кон- стантной, линейной и квадратичной. ![](ru/docs/assets/media/image87.jpeg) > **Рис. 2.10.** Временная сложность константного, линейного и квадратичного порядка > > В качестве примера рассмотрим пузырьковую сортировку: внешний цикл выполняется *n* -- 1 раз, внутренний цикл выполняется *n* -- 1, *n* -- 2, \..., 2, 1 раз, в среднем *n*/2 раз, поэтому временная сложность составляет O((*n* − 1)*n*/2) = O(*n*2). > > \# === File: time_complexity.py === > > def bubble_sort(nums: list\[int\]) -\> int: > > \"\"\" Квадратичная сложность (пузырьковая сортировка).\"\"\" count = 0 \# Счетчик. > > \# Внешний цикл: неотсортированный диапазон \[0, i\]. for i in range(len(nums) - 1, 0, -1): > > \# Внутренний цикл: перестановка максимального элемента > > \# неотсортированного диапазона \[0, i\] и элемента на правом конце. for j in range(i): > > if nums\[j\] \> nums\[j + 1\]: > > \# Перестановка nums\[j\] и nums\[j + 1\]. tmp: int = nums\[j\] > > nums\[j\] = nums\[j + 1\] nums\[j + 1\] = tmp > > count += 3 \# Перестановка элементов включает 3 элементарные операции return count ##### Экспоненциальная сложность *O*(2*n*) > Деление клеток в биологии является типичным примером *экспоненциально- го роста*: исходное состояние -- 1 клетка, после одного цикла деления их ста- новится 2, после двух циклов -- 4 и т. д. После *n* циклов деления получается 2*n* клеток. > > В следующем коде моделируется процесс деления клеток, временная слож- ность этого алгоритма составляет *O*(2*n*), см. рис. 2.11. Обратите внимание, что входное значение *n* обозначает количество циклов деления, а возвращаемое значение count обозначает общее количество делений. > > \# === File: time_complexity.py === def exponential(n: int) -\> int: > > \"\"\" Экспоненциальная сложность (реализация c циклом).\"\"\" > > count = 0 > > base = 1 > > \# Каждая клетка делится на две в каждом цикле, формируя последовательность 1, 2, 4, 8, \..., 2\^(n-1). > > for \_ in range(n): > > for \_ in range(base): count += 1 > > base \*= 2 > > \# count = 1 + 2 + 4 + 8 + \...+ 2\^(n-1) = 2\^n -- 1. > > return count > > ![](ru/docs/assets/media/image89.jpeg) > > **Рис. 2.11.** Временная сложность экспоненциального порядка > > В реальных алгоритмах экспоненциальная сложность часто встречается в рекурсивных функциях. Например, в следующем коде функция рекурсивно делится на две части и останавливается после *n* циклов деления. > > \# === File: time_complexity.py === def exp_recur(n: int) -\> int: > > \"\"\" Экспоненциальная сложность (реализация с рекурсией).\"\"\" if n == 1: > > return 1 > > return exp_recur(n - 1) + exp_recur(n - 1) + 1 > > Экспоненциальный рост является очень быстрым и часто встречается в ме- тодах полного перебора (грубая сила, возврат и т. д.). Для задач с большим объ- емом данных экспоненциальная сложность неприемлема, обычно требуется использование динамического программирования или жадных алгоритмов. ##### Логарифмическая сложность *O*(log *n*) > В отличие от экспоненциальной, *логарифмическая сложность* отражает прин- цип «каждый цикл сокращается вдвое». Пусть размер входных данных равен *n*, поскольку каждый цикл сокращается вдвое, количество циклов равно log2 *n*, т. е. обратной функции к 2n. > > В следующем коде моделируется принцип «каждый цикл сокращается вдвое», временная сложность которого составляет *O*(log2 *n*), или сокращенно *O*(log *n*), см. рис. 2.12. > > \# === File: time_complexity.py === > > def logarithmic(n: int) -\> int: > > \"\"\" Логарифмическая сложность (реализация с циклом.)\"\"\" count = 0 > > while n \> 1: > > n = n / 2 count += 1 > > return count > > **Длина оставшегося массива** > > **Длина входного массива равна n,** на каждом уровне исключается половина > > **Каждый уровень содержит 1 вычислительную операцию** > > **Общее количество вычислительных операций log2n + 1** > > **Временная сложность Логарифмическая O(log n)** > > ![](ru/docs/assets/media/image91.png)**Рис. 2.12.** Временная сложность логарифмического порядка > > Подобно экспоненциальной, логарифмическая сложность также часто встречается в рекурсивных функциях. Следующий код формирует рекурсив- ное дерево высотой log2 *n*. > > \# === File: time_complexity.py === def log_recur(n: int) -\> int: > > \"\"\" Логарифмическая сложность (реализация с рекурсией).\"\"\" > > if n \<= 1: > > return 0 > > return log_recur(n / 2) + 1 > > Логарифмическая сложность часто встречается в алгоритмах, основанных на стратегии «разделяй и властвуй», отражая идеи разделения на части и упро- щения сложного. Она растет медленно и является идеальной временной слож- ностью после константной. ##### Линейно-логарифмическая сложность *O*(*n* log *n*) > *Линейно-логарифмическая сложность* часто встречается во вложенных циклах, когда временные сложности двух уровней циклов составляют *O*(log *n*) и *O*(*n*) соответственно. Ниже приведен пример кода. > > \# === File: time_complexity.py === def linear_log_recur(n: int) -\> int: > > \"\"\" Линейно-логарифмическая сложность.\"\"\" if n \<= 1: > > return 1 > > \# Разделение на две части, размер подзадачи уменьшается вдвое. count = linear_log_recur(n // 2) + linear_log_recur(n // 2) > > \# Текущая подзадача содержит n операций. for \_ in range(n): > > count += 1 return count > > На рис. 2.13 изображен способ формирования линейно-логарифмической сложности. Общее количество операций на каждом уровне двоичного дерева равно *n*, всего в дереве log2 *n* + 1 уровней, поэтому временная сложность со- ставляет *O*(*n* log *n*). > > Временная сложность основных алгоритмов сортировки обычно составляет *O*(*n* log *n*), например быстрой сортировки, сортировки слиянием, пирамидаль- ной сортировки и т. д. > > ![](ru/docs/assets/media/image93.jpeg) > > **Рис. 2.13.** Временная сложность линейно-логарифмического порядка ##### Факториальная сложность *O*(*n*!) > *Факториальная сложность* соответствует математической задаче «полной пе- рестановки». Для заданных *n* различных элементов необходимо определить все возможные варианты их перестановки, количество которых вычисляется по следующей формуле: > > *n*! = *n* × (*n* -- 1) × (*n* -- 2) × \... × 2 × 1. > > Факториал обычно реализуется с использованием рекурсии. Как показано на рис. 2.14 и в коде ниже, на первом уровне происходит разбиение на *n* ча- стей, на втором -- на *n* -- 1 частей и т. д., пока на *n*-м уровне разбиение не пре- кращается. > > \# === File: time_complexity.py === def factorial_recur(n: int) -\> int: > > \"\"\" Факториал (реализация с рекурсией).\"\"\" > > if n == 0: > > return 1 > > count = 0 > > \# Разбиение 1 части на n частей. for \_ in range(n): > > count += factorial_recur(n - 1) return count > > ![](ru/docs/assets/media/image95.jpeg) > > **Рис. 2.14.** Временная сложность факториала > > Следует отметить, что, поскольку при *n* ≥ 4 всегда выполняется *n*! \> 2*n*, фак- ториал растет быстрее, чем экспоненциальная функция, и при больших *n* он становится неприемлемым для практического использования. ### Худшая, лучшая и средняя временная сложность > **Эффективность алгоритма во времени часто не является фиксирован- ной**, **а зависит от распределения входных данных**. Предположим, что на вход подается массив nums длиной *n*, содержащий числа от 1 до *n*, каждое из которых встречается ровно один раз. Однако порядок элементов перемешан случайным образом, и задача заключается в том, чтобы вернуть индекс эле- мента 1. Можно сформулировать следующие факты: 1) когда nums = \[?, ?, \..., 1\], т. е. когда последний элемент равен 1, необ- ходимо полностью пройти массив, достигая **худшей временной слож- ности** *O*(*n*); 2) когда nums = \[1, ?, ?, \...\], т. е. когда первый элемент равен 1, независи- мо от длины массива не требуется продолжать проход, достигая **лучшей временной сложности** Ω(1). > Худшая временная сложность соответствует асимптотическому верхнему пределу функции и обозначается символом *O*. Соответственно, лучшая вре- менная сложность соответствует асимптотическому нижнему пределу функ- ции и обозначается символом Ω. > > \# === File: worst_best_time_complexity.py === def random_numbers(n: int) -\> list\[int\]: > > \"\"\" Генерация массива с элементами 1, 2, \..., n в случайном порядке.\"\"\" > > \# Генерация массива nums =: 1, 2, 3, \..., n. nums = \[i for i in range(1, n + 1)\] > > \# Случайное перемешивание элементов массива. random.shuffle(nums) > > return nums > > def find_one(nums: list\[int\]) -\> int: > > \"\"\" Поиск индекса числа 1 в массиве nums.\"\"\" for i in range(len(nums)): > > \# Когда элемент 1 в начале массива, достигается лучшая временная слож- ность O(1). > > \# Когда элемент 1 в конце массива, достигается худшая временная слож- ность O(n). > > if nums\[i\] == 1: return i > > return -1 > > Стоит отметить, что на практике лучшая временная сложность используется редко, так как обычно она достигается с малой вероятностью и может вводить в заблуждение. **Худшая временная сложность более полезна**, **так как она предоставляет безопасное значение эффективности**, позволяя уверенно использовать алгоритм. > > Из приведенного примера видно, что худшая и лучшая временные сложно- сти возникают только при особом распределении данных, вероятность появ- ления которых может быть мала, и они не отражают реальной эффективности алгоритма. **Кроме того*,* средняя временная сложность может показать эффективность алгоритма при случайных входных данных**, она обозна- чается символом Θ. > > Для некоторых алгоритмов можно просто вычислить средний показатель при случайном распределении данных. Например, в приведенном примере, поскольку входной массив перемешан случайным образом, вероятность появ- ления элемента 1 на любом индексе одинакова, и среднее количество циклов алгоритма составляет половину длины массива *n*/2, а средняя временная слож- ность равна Θ(*n*/2) = Θ(*n*). > > Однако для более сложных алгоритмов вычисление средней временной сложности часто затруднительно, так как сложно проанализировать общее ма- тематическое ожидание при распределении данных. В таких случаях обычно используется худшая временная сложность в качестве критерия оценки эф- фективности алгоритма. #### пространственная сложность > Пространственная сложность используется для измерения тенденции роста занимаемой алгоритмом памяти по мере увеличения объема данных. Это по- нятие очень похоже на временную сложность, только вместо времени выпол- нения рассматривается занимаемая память. ### Пространство алгоритма > Память, используемая алгоритмом в процессе выполнения, главным образом включает следующие виды. - **Входное пространство**: используется для хранения входных данных алгоритма. - **Временное пространство**: используется для хранения переменных, объектов, контекста функций и других данных в процессе выполнения алгоритма. - **Выходное пространство**: используется для хранения выходных данных алгоритма. > Как правило, пространственная сложность рассчитывается на основе вре- менного пространства и выходного пространства. > > Временное пространство можно, в свою очередь, разделить на три части. - **Временные данные**: используются для сохранения различных кон- стант, переменных, объектов и т. д. в процессе выполнения алгоритма. - **Пространство стека**: используется для сохранения контекстных дан- ных вызываемой функции. При каждом вызове функции система соз- дает фрейм стека на вершине стека, который освобождается после воз- врата функции. - **Пространство инструкций**: используется для хранения скомпилиро- ванных инструкций программы, в реальной статистике обычно игнори- руется. > При анализе пространственной сложности программы обычно учитывают- ся **временные данные**, **пространство стека и выходные данные**, как по- казано на рис. 2.15. ![](ru/docs/assets/media/image97.jpeg) > **Рис. 2.15.** Пространство, используемое алгоритмом > > Ниже приведен пример кода. > > class Node: > > \"\"\" Класс.\"\"\" > > def init (self, x: int): > > self.val: int = x \# Значение узла. > > self.next: Node \| None = None \# Ссылка на следующий узел. > > def function() -\> int: \"\"\" Функция.\"\"\" > > \# Выполнение операций\... return 0 > > def algorithm(n) -\> int: \# Входные данные. > > A = 0 \# Временные данные (константа, обычно обозначается заглавной буквой). b = 0 \# Временные данные (переменная). > > node = Node(0) \# Временные данные (объект). > > c = function() \# Пространство стека (вызов функции). return A + b + c \# Выходные данные. ### Методы вычисления > Методы вычисления пространственной сложности аналогичны методам вы- числения временной сложности, только объект статистики изменяется с коли- чества операций на размер используемого пространства. > > В отличие от временной сложности **обычно учитывается только наихуд- шая пространственная сложность**. Это связано с тем, что память является жестким требованием, и необходимо убедиться, что для всех входных данных будет зарезервировано достаточное количество памяти. > > В следующем примере кода наихудшая пространственная сложность может иметь два значения. 1. **Наихудшие входные данные**: когда *n* \< 10, пространственная слож- ность составляет *O*(1); но когда *n* \> 10, инициализированный массив nums занимает пространство *O*(*n*), поэтому наихудшая пространственная сложность составляет *O*(*n*). 2. **Пиковое использование памяти во время выполнения**: например, до выполнения последней строки программа занимает пространство *O*(1). При инициализации массива nums программа занимает про- странство *O*(*n*), поэтому наихудшая пространственная сложность со- ставляет *O*(*n*). > def algorithm(n: int): a = 0 \# O(1) > > b = \[0\] \* 10000 \# O(1) > > if n \> 10: > > nums = \[0\] \* n \# O(n) > > **В рекурсивных функциях необходимо учитывать статистику про- странства стека.** Рассмотрим следующий код. > > def function() -\> int: > > \# Выполнение некоторых операций. return 0 > > def loop(n: int): > > \"\"\" Пространственная сложность цикла составляет O(1).\"\"\" for \_ in range(n): > > function() > > def recur(n: int): > > \"\"\" Пространственная сложность рекурсии составляет O(n).\"\"\" if n == 1: > > return > > return recur(n - 1) > > Функции loop() и recur() имеют временную сложность *O*(*n*), но различаются по пространственной сложности. - Функция loop() вызывает function() *n* раз в цикле, и каждый раз function() возвращает значение и освобождает пространство стека, поэтому про- странственная сложность остается *O*(1). - Рекурсивная функция recur() во время выполнения одновременно содер- жит *n* невозвращенных вызовов recur(), занимая пространство стека *O*(*n*). ### Основные типы > Пусть размер входных данных равен *n*, на рис. 2.16 показаны основные типы пространственной сложности (в порядке возрастания). > > *O*(1) \< *O*(log *n*) \< *O*(*n*) \< *O*(*n*2) \< *O*(2*n*) > > Константная \< Логарифмическая \< Линейная \< Квадратичная \< Экспоненциальная > > **От низкой к высокой,** > > **от лучшей к худшей** ##### Константная сложность *O*(1) > *Константная сложность* обычно встречается у констант, переменных, объек- тов, количество которых не зависит от размера входных данных *n*. > > Следует отметить, что память, занимаемая инициализацией переменных или вызовом функций в цикле, освобождается при переходе к следующему циклу, поэтому она не накапливается, и пространственная сложность остается *O*(1). > > \# === File: space_complexity.py === def function() -\> int: > > \"\"\" Функция.\"\"\" > > \# Выполнение некоторых операций. return 0 > > def constant(n: int): > > \"\"\" Константная сложность.\"\"\" > > \# Константы, переменные; объекты, занимающие пространство O(1). a = 0 > > nums = \[0\] \* 10000 node = ListNode(0) > > \# Переменные в цикле занимают пространство O(1). for \_ in range(n): > > c = 0 > > \# Функции в цикле занимают пространство O(1). for \_ in range(n): > > function() ##### Линейная сложность *O*(*n*) > *Линейная сложность* часто встречается у массивов, списков, стеков, очередей и других объектов, количество элементов которых пропорционально *n*. > > \# === File: space_complexity.py === def linear(n: int): > > \"\"\" Линейная сложность.\"\"\" > > \# Список длиной n занимает пространство O(n). nums = \[0\] \* n > > \# Хеш-таблица длиной n занимает пространство O(n). hmap = dict\[int, str\]() > > for i in range(n): hmap\[i\] = str(i) > > Глубина рекурсии этой функции равна *n*, т. е. одновременно существует *n* невозвращенных вызовов функции linear_recur(), использующих простран- ство стека *O*(*n*): > > \# === File: space_complexity.py === def linear_recur(n: int): > > \"\"\" Линейная сложность (реализация с рекурсией).\"\"\" > > print(\" Рекурсия n =\", n) if n == 1: > > return linear_recur(n - 1) > > ![](ru/docs/assets/media/image101.jpeg) > > **Рис. 2.17.** Линейная пространственная сложность, создаваемая рекурсивной функцией ##### Квадратичная сложность *O*(*n*2) > *Квадратичная сложность* часто встречается у матриц и графов, количество элементов в которых пропорционально квадрату *n*. > > \# === File: space_complexity.py === def quadratic(n: int): > > \"\"\" Квадратичная сложность.\"\"\" > > \# Двумерный список занимает пространство O(n\^2). num_matrix = \[\[0\] \* n for \_ in range(n)\] > > Глубина рекурсии данной функции равна *n*, в каждом рекурсивном вызове инициализируется массив, длина которого последовательно уменьшается от *n* до 1, средняя длина равна *n*/2, поэтому в целом используется *O*(*n*2) простран- ства, как показано на рис. 2.18. > > \# === File: space_complexity.py === def quadratic_recur(n: int) -\> int: > > \"\"\" Квадратичная сложность (реализация с рекурсией).\"\"\" > > if n \<= 0: > > return 0 > > \# Массив nums длиной n, n-1, \..., 2, 1. nums = \[0\] \* n > > return quadratic_recur(n - 1) > > ![](ru/docs/assets/media/image103.jpeg) > > **Рис. 2.18.** Квадратичная пространственная сложность рекурсивной функции ##### Экспоненциальная сложность *O*(2*n*) > *Экспоненциальная сложность* часто встречается в двоичных деревьях. Количе- ство узлов в полном двоичном дереве с *n* уровнями равно 2*n* − 1, что занимает *O*(2*n*) пространства, как показано на рис. 2.19. > > \# === File: space_complexity.py === > > def build_tree(n: int) -\> TreeNode \| None: > > \"\"\" Экспоненциальная сложность (создание полного двоичного дерева).\"\"\" if n == 0: > > return None root = TreeNode(0) > > ![](ru/docs/assets/media/image105.jpeg)root.left = build_tree(n - 1) root.right = build_tree(n - 1) return root > > **Рис. 2.19.** Экспоненциальная пространственная сложность полного двоичного дерева ##### Логарифмическая сложность *O*(log *n*) > *Логарифмическая сложность* часто встречается в алгоритмах типа «разделяй и властвуй». Например, в сортировке слиянием, где входной массив длиной *n* на каждой итерации рекурсивно делится пополам и формирует рекурсивное дерево высотой log *n*, используется *O*(log *n*) пространства для стека вызовов. > > Или, например, при преобразовании числа в строку если входное значение является положительным целым числом *n*, то количество его цифр равно ⌊log10 *n* ⌋ + 1, что соответствует длине строки, и поэтому пространственная сложность равна *O*(log10 *n* + 1) = *O*(log *n*). ### 2.4.4 Баланс времени и пространства > В идеальных условиях мы стремимся к тому, чтобы временная и простран- ственная сложности алгоритма были оптимальными. Однако на практике од- новременно оптимизировать обе сложности зачастую очень затруднительно. > > **Снижение временной сложности обычно требует увеличения про- странственной сложности, и наоборот**. Будем называть подход, при кото- ром нужно пожертвовать памятью для увеличения скорости выполнения ал- горитма, обменом пространства на время. Обратный подход будем называть обменом времени на пространство. > > Выбор подхода зависит от того, какой аспект для нас более важен. В боль- шинстве случаев время ценнее пространства, поэтому обмен пространства на время является более распространенной стратегией. Конечно, при больших объемах данных контроль пространственной сложности также очень важен. #### резюме ##### Ключевые моменты ###### Оценка эффективности алгоритмов: - временная и пространственная эффективность являются двумя основ- ными критериями для оценки качества алгоритмов; - эффективность алгоритмов можно оценивать с помощью реальных те- стов, однако это сложно из-за влияния тестовой среды и значительных > затрат вычислительных ресурсов; - анализ сложности позволяет устранить недостатки реальных тестов, ре- зультаты анализа применимы ко всем платформам и могут выявить эф- фективность алгоритма при различных объемах данных. ###### Временная сложность: - временная сложность используется для оценки тенденции изменения времени выполнения алгоритма с увеличением объема данных, что по- зволяет оценивать его эффективность. Однако в некоторых случаях она может работать не так хорошо, например когда объем входных данных мал или временная сложность одинакова, что не позволяет точно срав- нить эффективность алгоритмов; - худшая временная сложность обозначается символом *O* и соответствует асимптотической верхней границе, отражая уровень роста количества операций *T*(*n*) при стремлении *n* к бесконечности; - определение временной сложности включает два этапа: сначала подсчи- тывается количество операций, затем определяется асимптотическая верхняя граница; - наиболее распространенные временные сложности от низкой к высо- > кой: *O*(1), *O*(log *n*), *O*(*n*), *O*(*n* log *n*), *O*(*n*2), *O*(2*n*) и *O*(*n*!); - временная сложность некоторых алгоритмов не является фиксирован- ной и зависит от распределения входных данных. Временная сложность делится на худшую, лучшую и среднюю. Лучшая временная сложность почти не используется, так как для достижения лучшего случая входные данные должны соответствовать строгим критериям; - средняя временная сложность отражает эффективность алгоритма при случайных входных данных и наиболее близка к реальной производитель- ности алгоритма. Для расчета средней временной сложности необходимо учитывать распределение входных данных и математическое ожидание. ###### Пространственная сложность: - пространственная сложность аналогична временной сложности и ис- пользуется для оценки тенденции изменения объема памяти, занимае- мой алгоритмом, с увеличением объема данных; - память, используемая в процессе выполнения алгоритма, можно разде- лить на входное пространство, временное пространство и выходное про- странство. Обычно при расчете пространственной сложности входное пространство не учитывается. Временное пространство делится на вре- менные данные, пространство стека и пространство инструкций, при- чем пространство стека обычно влияет на пространственную сложность только в рекурсивных функциях; - обычно рассматривается только худшая пространственная сложность, т. е. пространственная сложность алгоритма при худших входных дан- ных и в худший момент выполнения; - наиболее распространенные пространственные сложности от низкой > к высокой: *O*(1), *O*(log *n*), *O*(*n*), *O*(n2) и *O*(2n). ##### Вопросы и ответы > **Вопрос**. Пространственная сложность хвостовой рекурсии равна *O*(1)? > > **Ответ**. Теоретически пространственную сложность хвостовой рекурсии можно оптимизировать до *O*(1). Однако большинство языков программиро- вания (например, Java, Python, C++, Go, C# и др.) не поддерживают автомати- ческую оптимизацию хвостовой рекурсии, поэтому обычно считается, что ее пространственная сложность равна *O*(*n*). > > **Вопрос**. В чем разница между терминами «функция» и «метод»? > > **Ответ**. Функция может выполняться независимо, все параметры передают- ся явно. Метод связан с объектом и неявно передается вызывающему его объ- екту. Он может оперировать данными, содержащимися в экземпляре класса. > > Ниже приведены примеры из нескольких распространенных языков про- граммирования: - язык C является процедурным языком и не имеет концепции объек- тно ориентированного программирования, поэтому в нем есть только функции. Однако можно создать структуры, чтобы имитировать клас- сы. Функции, связанные со структурами, будут эквивалентны методам в других языках программирования; - Java и C# являются объектно ориентированными языками программи- рования, и блоки кода (методы) обычно являются частью какого-либо класса. Статические методы ведут себя как функции, так как они при- вязаны к классу и не могут обращаться к конкретным свойствам экзем- пляра; - C++ и Python поддерживают как процедурное программирование (функ- ции), так и объектно ориентированное программирование (методы). > **Вопрос**. Отражает ли схема «Типы пространственной сложности» абсолют- ный размер занимаемого пространства? > > **Ответ**. Нет, данная схема демонстрирует пространственную сложность, отра- жающую тенденцию роста, а не абсолютный размер занимаемого пространства. Предположим, для *n* = 8 вы можете заметить, что значения каждой кривой не соответствуют значениям функции. Это происходит потому, что каждая кривая содержит постоянную составляющую, которая используется для сжа- > > тия диапазона значений до визуально комфортного уровня. > > На практике, поскольку обычно постоянная составляющая метода неизвест- на, невозможно выбрать оптимальное решение только на основе сложности при *n* = 8. Однако при *n* = 85 выбор становится очевидным, так как тенденция роста уже доминирует. > > Глава 3