Алгоритмы и структуры данных для задач LeetCode: сложность, коллекции и базовые техники

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

1. Сложность алгоритмов: Big-O и анализ времени/памяти

Сложность алгоритмов: Big-O и анализ времени/памяти

В задачах LeetCode важно не только получить правильный ответ, но и уложиться в ограничения по времени и памяти. Эта статья даёт базовый инструментарий для оценки эффективности решения: как читать ограничения, как прикидывать асимптотику, как выбирать подходящую структуру данных.

Зачем нужна оценка сложности

Ограничения на платформе обычно задаются не напрямую, а косвенно:

  • максимальным размером входа (например, )
  • лимитом времени (скрыт, но проявляется через TLE)
  • лимитом памяти (проявляется через MLE)
  • Если вы умеете оценивать сложность, вы заранее понимаете, какие подходы вообще имеют шанс.

    Модель вычислений и что такое

    При анализе сложности мы считаем, что:

  • операция сравнения, присваивания, обращения по индексу массива — выполняется за константное время
  • важнее всего, как растёт число операций при увеличении размера входа
  • Под обычно понимают размер входа, но в задачах он может быть разным:

  • длина строки
  • количество элементов массива
  • количество узлов дерева
  • количество вершин/рёбер графа
  • В одной задаче может быть несколько параметров, например и .

    Big-O нотация

    Big-O описывает верхнюю оценку роста числа операций.

    Формально запись означает:

  • — реальное число операций (или шагов) алгоритма на входе размера
  • — функция, которая задаёт скорость роста
  • утверждение говорит, что начиная с некоторого значение не растёт быстрее, чем константа, умноженная на
  • Интуитивно: Big-O показывает, как ведёт себя алгоритм на больших входах, не зацикливаясь на точных константах.

    Полезные источники:

  • Big O notation (Wikipedia)
  • Упрощения при вычислении Big-O

    При оценке асимптотики обычно:

  • отбрасывают константы: превращается в
  • оставляют доминирующий член: превращается в
  • разные независимые циклы складывают: даёт
  • Это не означает, что константы не важны вообще — но для выбора класса решения на LeetCode чаще важнее порядок роста.

    Типичные классы сложности

    | Сложность | Название | Где встречается на LeetCode | |---|---|---| | | константная | доступ по индексу, хеш-таблица в среднем | | | логарифмическая | бинарный поиск, операции с кучей | | | линейная | один проход по массиву, два указателя | | | линейно-логарифмическая | сортировка, некоторые техники с деревьями/кучей | | | квадратичная | двойные циклы, перебор пар | | | экспоненциальная | полный перебор подмножеств | | | факториальная | перебор перестановок |

    !Сравнение роста распространённых классов сложности

    Как быстро прикинуть сложность по коду

    Циклы

  • один цикл по элементам:
  • два вложенных цикла по :
  • цикл, который каждый раз делит диапазон пополам (например, ):
  • Рекурсия

    Для рекурсивных решений важны:

  • глубина рекурсии
  • число вызовов на каждом уровне
  • Примеры:

  • бинарный поиск: глубина , по одному вызову на уровень
  • полный перебор подмножеств: на каждом элементе выбор “взять/не взять”, итого ветвей
  • Сортировка

    Обычно сортировка сравнениями даёт (например, sort в большинстве языков). В задачах это часто “плата” за удобство последующих шагов.

    Время против памяти

    У задачи обычно два ресурса:

  • время (сколько операций)
  • память (сколько дополнительного хранения)
  • Память: что включать в оценку

    Чаще всего в Big-O по памяти считают дополнительную память (auxiliary space), не включая входные данные.

    Типичные случаи:

  • хеш-таблица для частот:
  • стек рекурсии: , где — высота дерева или глубина рекурсии
  • массив DP: часто или
  • Обмен времени на память

    Частый сюжет на LeetCode:

  • ускоряем поиск, используя дополнительную память (например, хеш-таблица)
  • экономим память, используя более медленный перебор (например, поиск пары без хеша)
  • Важно уметь объяснить этот обмен в решении.

    Худший, средний и лучший случаи

    Сложность может зависеть от входа.

  • худший случай — гарантированная верхняя граница
  • средний случай — ожидаемая сложность при “типичном” распределении входов
  • лучший случай — обычно мало полезен для прохождения тестов
  • На LeetCode чаще ориентируются на худший случай, но есть структуры данных, где принято говорить “в среднем” (например, хеш-таблицы).

    Амортизированная сложность

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

    Классический пример: динамический массив (vector/ArrayList) при добавлении в конец.

  • обычно добавление —
  • иногда происходит расширение и копирование — дорогая операция
  • но на длинной серии добавлений средняя стоимость остаётся амортизированно
  • Как связать сложность с ограничениями

    Практическая эвристика для LeetCode:

  • : часто проходит
  • : иногда проходит, но с осторожностью
  • : обычно нужен или
  • : чаще , и важно качество констант
  • Это не формальные правила: язык, детали реализации и скрытые константы могут изменить картину, но как “первый фильтр” это работает.

    Мини-примеры типичных оценок

    Поиск пары с суммой (Two Sum)

  • перебор всех пар: времени, памяти
  • хеш-таблица значений: времени в среднем, памяти
  • Проверка отсортированности

  • один проход и сравнение соседей: времени, памяти
  • Обход дерева

  • DFS/BFS: времени
  • память: для DFS (стек), или для BFS (очередь), где — максимальная ширина уровня
  • Как эта тема связана со следующим материалом курса

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

  • какая асимптотика по времени для ключевых операций
  • какая дополнительная память используется и почему
  • Этот навык — основа для выбора правильного подхода под ограничения конкретной задачи.

    2. Структуры данных LeetCode: массивы, списки, строки, хеш-таблицы

    Структуры данных LeetCode: массивы, списки, строки, хеш-таблицы

    В прошлой статье мы научились прикидывать сложность решения по времени и памяти через Big-O. Теперь закрепим этот навык на самых частых структурах данных в задачах LeetCode: массивах (динамических массивах/векторах), связных списках, строках и хеш-таблицах.

    Главная практическая цель: быстро отвечать на два вопроса перед написанием кода.

  • Какие операции я буду делать чаще всего (доступ по индексу, поиск, вставка, удаление)?
  • Какая структура данных даст нужную асимптотику и не сломает лимит памяти?
  • Что обычно проверяют задачи LeetCode

    Задачи редко требуют “идеальной” структуры данных. Чаще всего они проверяют, понимаете ли вы стоимость базовых операций.

  • Сколько стоит получить элемент по индексу?
  • Сколько стоит найти элемент по значению?
  • Сколько стоит вставить/удалить элемент?
  • Сколько дополнительной памяти понадобится?
  • Дальше будем говорить о сложности в терминах , , и т.д., где — размер коллекции (число элементов, длина строки).

    Массивы и динамические массивы (vector, ArrayList)

    Идея

    Массив хранит элементы подряд в памяти. Это даёт быстрый доступ по индексу.

    Во многих языках “обычный” используемый массив для задач — это именно динамический массив (вектор): он умеет автоматически увеличиваться при добавлении элементов.

  • В C++: std::vector
  • В Java: ArrayList
  • В Python: list (реализован как динамический массив)
  • Полезные источники:

  • Dynamic array (Wikipedia)
  • Vector (C++)
  • !Наглядное сравнение размещения в памяти и доступа к элементам

    Ключевые операции и сложность

    | Операция | Динамический массив | Почему так | |---|---:|---| | Доступ по индексу a[i] | | адрес вычисляется напрямую | | Изменение a[i] = x | | то же | | Поиск по значению | | нужно просмотреть элементы | | Добавить в конец push_back/append | амортизированно | иногда происходит расширение и копирование | | Вставка/удаление в середине | | нужно сдвигать хвост |

    Амортизированно означает: редкие дорогие операции (расширение массива) “размазываются” по множеству дешёвых добавлений.

    Типичные сценарии на LeetCode

  • Два указателя (например, на отсортированном массиве): обычно времени и памяти.
  • Скользящее окно: поддерживаем окно [l..r], двигаем границы, часто с дополнительной структурой (например, хеш-таблицей).
  • Префиксные суммы: создаём массив pref, где pref[i] хранит сумму первых i элементов; даёт быстрые ответы на запросы суммы отрезка.
  • Частые ошибки

  • Вставлять/удалять элементы в начале/середине в цикле и не замечать, что это превращается в .
  • Путать “в среднем быстро” с “всегда быстро”: линейный поиск по массиву остаётся .
  • Связные списки (Linked List)

    Идея

    Связный список состоит из узлов. Каждый узел хранит значение и ссылку на следующий узел (а в двусвязном — ещё и на предыдущий).

  • Linked list (Wikipedia)
  • Ключевые операции и сложность

    Здесь важно различать “у нас есть ссылка на нужное место” и “нам надо до него дойти”.

    | Операция | Связный список | Почему так | |---|---:|---| | Доступ по индексу i | | нужно пройти по next | | Вставка/удаление в начале (голове) | | меняем 1–2 ссылки | | Вставка/удаление после известного узла | | меняем ссылки локально | | Поиск по значению | | последовательный проход |

    Когда связный список реально полезен на LeetCode

    На практике — реже, чем кажется. Он полезен, когда задача уже задана как связный список (тип ListNode). Тогда от вас ждут конкретные техники.

  • Разворот списка (итеративно или рекурсивно).
  • Поиск цикла (алгоритм “медленный/быстрый указатель”).
  • Слияние двух отсортированных списков.
  • Частые ошибки

  • Пытаться “обращаться по индексу” так же удобно, как в массиве.
  • Забывать, что для удаления узла в односвязном списке обычно нужно иметь доступ к предыдущему узлу.
  • Строки (String)

    Идея

    Строка — это последовательность символов. Важно: в некоторых языках строки неизменяемые (immutable), то есть “изменение строки” на самом деле создаёт новую строку.

  • String (computer science) (Wikipedia))
  • Python str
  • Java String
  • Операции и сложность: что помнить

    Единой таблицы для всех языков нет, но есть правила, которые почти всегда помогают не ошибиться.

  • Доступ к символу по индексу часто (например, в Java, Python), но зависит от внутреннего представления.
  • Проход по строке — .
  • Создание подстроки может быть по длине подстроки , потому что часто требуется копирование.
  • Конкатенация в цикле для неизменяемых строк часто приводит к (каждый раз копируется всё, что уже накопили).
  • Если нужно “строить строку” по частям, обычно используют буфер.

  • В Python: собрать куски в список и затем ''.join(parts)
  • В Java: StringBuilder
  • Типичные техники на LeetCode со строками

  • Частоты символов: массив на 26/128/256 или хеш-таблица.
  • Скользящее окно для подстрок с ограничениями.
  • Два указателя для палиндромов и удаления символов.
  • Хеш-таблицы (Hash Map / Hash Set)

    Идея

    Хеш-таблица хранит пары ключ–значение (map) или только ключи (set) и позволяет быстро проверять наличие ключа.

  • Hash table (Wikipedia)
  • Python dict
  • Java HashMap
  • !Как хеш-таблица раскладывает ключи по бакетам и что такое коллизия

    Ключевые операции и сложность

    | Операция | Хеш-таблица | Важно | |---|---:|---| | get/put/contains | в среднем | зависит от числа коллизий | | Худший случай | | если много ключей попали в один бакет | | Память | обычно | платим памятью за скорость |

    Фраза “ в среднем” означает: при нормальной работе хеш-функции и разумном размере таблицы количество коллизий невелико.

    Самые частые применения на LeetCode

  • Two Sum: запоминаем “что видели” → проверяем дополнение за в среднем.
  • Частотные словари: “сколько раз встретился элемент”.
  • Группировка: например, анаграммы по ключу (отсортированное слово или частотный профиль).
  • Проверка уникальности: set вместо вложенных циклов.
  • Частые ошибки

  • Думать, что хеш-таблица всегда : в теории есть худший случай.
  • Забывать про стоимость построения ключа: например, если ключ — длинная строка, то её хеширование тоже занимает время (обычно пропорционально длине строки).
  • Как выбрать структуру данных под задачу

    Ниже — практическая шпаргалка по выбору.

    | Нужна операция | Частый выбор | Почему | |---|---|---| | Быстрый доступ по индексу | динамический массив | индексирование | | Много вставок/удалений в начале, есть ссылка на узел | связный список | на ссылки | | Быстро проверять “есть ли элемент” | hash set | в среднем | | Считать частоты | hash map или массив частот | скорость и простота | | Работа с подстроками/окнами | строка + два указателя + хеш-таблица | типичный паттерн |

    Связь с анализом сложности и что дальше

    В статье про Big-O мы обсуждали, что при обычно нельзя позволить себе . Теперь видно, откуда это берётся на практике: например, поиск “в лоб” по массиву внутри цикла или дорогие вставки в середину динамического массива.

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

    3. Деревья и графовые основы: представление и обходы

    Деревья и графовые основы: представление и обходы

    В прошлых статьях мы разобрали Big-O и базовые структуры данных (массивы, строки, хеш-таблицы). Деревья и графы продолжают ту же линию: вы выбираете представление (как хранить связи) и обход (как системно посетить элементы), а затем оцениваете время и память.

    На LeetCode деревья встречаются как готовая структура TreeNode, а графы часто спрятаны внутри “обычных” формулировок: зависимости курсов, связи городов, переходы по клеткам, словарь преобразований, социальная сеть.

    Базовые определения

    Дерево — это связная структура без циклов. В задачах чаще всего встречаются:

  • Бинарное дерево: у узла до двух детей (левый и правый) (Binary tree).
  • Бинарное дерево поиска: упорядоченная версия бинарного дерева со свойством “слева меньше, справа больше” (Binary search tree).
  • Граф — множество вершин и рёбер между ними (Graph (abstract data type))). Граф бывает:

  • неориентированный и ориентированный
  • взвешенный и невзвешенный
  • с циклами и без циклов
  • Что такое , и в задачах про графы и деревья

    В этой теме удобно использовать стандартные обозначения:

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

    !Сравнение дерева и графа и обозначения n и m

    Как представляют деревья на LeetCode

    Представление через узлы и ссылки

    Чаще всего вам дают класс/структуру вида:

    Здесь “представление” уже фиксировано: у каждого узла есть ссылки на детей.

    Представление через массив (реже, но полезно знать)

    Иногда дерево “лежит” в массиве (классическая форма для кучи).

  • индекс — текущий узел
  • левый ребёнок — 2*i + 1
  • правый ребёнок — 2*i + 2
  • Это удобно, когда дерево полное или почти полное; для разреженных деревьев появляются null и усложнение.

    Как представляют графы в задачах

    В графах главный выбор — структура, в которой вы храните соседей.

    Список смежности

    Список смежности хранит для каждой вершины список её соседей (Adjacency list).

  • память: обычно
  • перебор всех соседей вершины: быстро и естественно
  • Типичный шаблон для n вершин с рёбрами edges:

    Матрица смежности

    Матрица смежности — таблица , где adj[u][v] показывает наличие ребра (Adjacency matrix).

  • проверка “есть ли ребро” за
  • память: , поэтому при это невозможно
  • Практический вывод для LeetCode:

  • если граф разреженный (обычно так и есть), берите список смежности
  • матрица смежности оправдана при маленьком или когда нужно очень много проверок “есть ли ребро”
  • Обходы: главный инструмент

    Обход — это способ посетить узлы/вершины в системном порядке. Почти все задачи на деревья/графы сводятся к тому, что вы:

  • выбираете DFS или BFS
  • храните visited, чтобы не зациклиться (в графах)
  • в процессе обхода считаете ответ: суммы, глубины, пути, компоненты связности
  • DFS: обход в глубину

    DFS (depth-first search) старается уйти “как можно глубже”, а потом откатывается назад (Depth-first search).

    Есть две основные формы.

    #### Рекурсивный DFS (часто для деревьев)

    Память здесь — это стек вызовов.

  • время: обычно , потому что каждый узел посещаем один раз
  • доп. память: , где — высота дерева
  • Важно: в “плохом” дереве-цепочке , и рекурсия может упереться в лимит глубины стека.

    #### Итеративный DFS через стек (часто для графов)

    Здесь стек вы контролируете сами.

  • время: (вершины + рёбра)
  • память: на visited и в худшем случае на стек
  • BFS: обход в ширину

    BFS (breadth-first search) ходит слоями: сначала все на расстоянии 1, потом на расстоянии 2 и т.д. (Breadth-first search).

    BFS особенно важен, когда задача про:

  • кратчайший путь в невзвешенном графе
  • минимальное число шагов/переходов
  • уровни дерева
  • Оценки:

  • время:
  • память: на visited и очередь
  • Обходы бинарного дерева: preorder, inorder, postorder

    Для бинарного дерева часто требуют конкретный порядок.

  • Preorder: узел, левое поддерево, правое поддерево
  • Inorder: левое поддерево, узел, правое поддерево
  • Postorder: левое поддерево, правое поддерево, узел
  • Мини-шаблоны (рекурсивно):

    Практическая связь с задачами:

  • inorder даёт отсортированный порядок для BST
  • postorder часто используют там, где надо сначала посчитать ответы в детях, а затем собрать в родителе (типичный “снизу вверх”)
  • !Визуальное сравнение трёх классических обходов бинарного дерева

    Почему в графах нужен visited и чем дерево отличается от графа

    В дереве (если оно корректное) циклов нет, поэтому вы не зациклитесь, двигаясь только от родителя к детям.

    В графе циклы — норма. Значит, почти всегда нужно хранить visited.

  • visited — это set/булев массив, который отвечает на вопрос “мы уже были в этой вершине?”
  • без visited DFS/BFS может уйти в бесконечный цикл
  • Отдельный частый случай: ориентированный граф. Там, помимо visited, иногда нужен второй маркер “вершина в текущем стеке рекурсии”, чтобы ловить циклы зависимостей.

    Сводная таблица: что выбрать на практике

    | Ситуация в задаче | Типичный выбор | Почему | |---|---|---| | Бинарное дерево и “пройти все узлы” | DFS рекурсивно | компактно, | | Бинарное дерево и “уровни / глубина” | BFS (очередь) | естественный обход по слоям | | Невзвешенный граф и “минимум шагов” | BFS | кратчайший путь по числу рёбер | | Граф, нужно “просто проверить достижимость” | DFS или BFS | обе дают | | Большой разреженный граф | список смежности | память | | Маленький граф и частые проверки ребра | матрица смежности | проверка за |

    Связь с темой сложности и с тем, что будет дальше

    Связь с Big-O из первой статьи здесь прямолинейна:

  • DFS/BFS почти всегда дают линейную сложность относительно размера структуры: для дерева и для графа
  • основной риск получить TLE — случайно превратить обход в многократный (например, запускать BFS из каждой вершины без необходимости) или сделать дорогие операции внутри посещения
  • Дальше, когда в курсе появятся сортировки и динамическое программирование, деревья и графы останутся базой:

  • BFS по уровням часто превращается в DP по слоям
  • DFS с возвратом значения из детей — типичный каркас для “DP на дереве”
  • выбор представления графа определяет, сможете ли вы вообще уложиться в память
  • 4. Поиск, сортировки и шаблоны работы с коллекциями

    Поиск, сортировки и шаблоны работы с коллекциями

    Эта статья связывает прошлые темы курса в практический набор приёмов для LeetCode.

  • Из статьи про Big-O вы берёте привычку заранее оценивать время и память.
  • Из статьи про структуры данных — понимание, где выгоднее массив, строка или хеш-таблица.
  • Из статьи про деревья и графы — идею обхода как системного посещения элементов.
  • Теперь добавим главный слой “поверх коллекций”: как искать, как сортировать и какие шаблоны почти всегда приводят к рабочему решению.

    Как понять, что от вас ждут “поиск”

    Сигналы в условии, которые обычно означают, что нужен поиск или быстрые проверки:

  • “найти элемент”, “проверить наличие”, “посчитать частоты”
  • “первый/последний индекс”, “минимальный/максимальный подходящий”
  • “пара/тройка с условием”
  • “подотрезок/подстрока с ограничением”
  • Перед кодом задайте себе два вопроса:

  • Где я буду делать много запросов “есть ли X”?
  • Я ищу конкретное значение или границу условия?
  • От ответа зависит выбор: линейный поиск, хеш-таблица, бинарный поиск, два указателя, окно.

    Поиск в коллекциях: базовые варианты

    Линейный поиск

    Идём по коллекции и проверяем каждый элемент.

  • Время: , где — размер коллекции (длина массива/строки).
  • Память: обычно .
  • Когда подходит:

  • небольшое
  • нужно всего один проход
  • данные не отсортированы и “умный” индекс не построить
  • Поиск через хеш-таблицу (set/map)

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

  • Время: в среднем на contains/get/put.
  • Память: обычно .
  • Типовые сюжеты:

  • “есть ли дополнение до суммы” (Two Sum)
  • частоты элементов/символов
  • уникальность, пересечения множеств
  • Важно: “в среднем ” работает, пока ключи нормально распределяются хеш-функцией, а число элементов разумно.

    Бинарный поиск

    Бинарный поиск применим, когда область поиска упорядочена:

  • массив отсортирован
  • ответ монотонен по параметру (про это ниже)
  • Обычный бинарный поиск по отсортированному массиву:

  • Время: , потому что диапазон каждый шаг делится примерно пополам.
  • Память: .
  • !Иллюстрация того, как бинарный поиск сокращает диапазон вдвое

    Мини-шаблон (Python):

    #### Частый подвид: “первый/последний индекс”

    Если в массиве есть дубликаты, задачи часто требуют:

  • первый индекс значения
  • последний индекс значения
  • первый индекс, где выполнено условие
  • Тогда бинарный поиск чуть меняется: при “успехе” вы не возвращаете ответ сразу, а продолжаете искать левее (для первого) или правее (для последнего).

    “Бинарный поиск по ответу” (монотонность)

    На LeetCode очень часто встречается шаблон:

  • вы не знаете ответ напрямую
  • но можете проверить, “подходит ли” некоторое значение
  • причём если подходит , то подходят и все значения больше (или меньше)
  • Это свойство называют монотонностью условия.

    Примеры формулировок:

  • “минимальная скорость/мощность/вместимость, чтобы уложиться в сроки”
  • “минимальное значение, при котором возможно…”
  • “максимальное значение, при котором всё ещё возможно…”
  • Скелет:

    Как оценивать сложность:

  • -фактор от бинарного поиска по диапазону значений
  • умножается на стоимость ok(x)
  • То есть, если ok(x) работает за , итог часто будет , где — размер диапазона поиска по ответу.

    Сортировка: когда она помогает и сколько “стоит”

    Сортировка — один из самых частых “ускорителей”, потому что после неё появляются:

  • два указателя
  • группировка одинаковых значений
  • дедупликация за один проход
  • возможность бинарного поиска
  • Базовая оценка

    Сортировка сравнениями обычно стоит по времени.

  • — число элементов
  • — число уровней деления/слияния в типичных алгоритмах
  • По памяти зависит от реализации языка:

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

    Когда сортировка — плохая идея

  • нужно сохранить исходные индексы, а вы их теряете (решение: сортировать пары (значение, индекс))
  • требуется лучше, чем (иногда возможно через хеши/подсчёты)
  • сортировка не даёт структуры для дальнейшего шага (частая ошибка: “отсортирую на всякий случай”)
  • Техника: сортировка + линейный проход

    После сортировки много задач превращаются в один проход:

  • слить интервалы
  • удалить дубликаты
  • посчитать группы равных
  • найти ближайшие значения
  • Шаблон “два указателя”

    Два указателя — это управление двумя индексами в массиве/строке, чтобы избежать вложенных циклов.

    Основные формы:

  • навстречу друг другу: l слева, r справа
  • быстрый/медленный: один индекс читает, другой пишет (in-place)
  • после сортировки: поиск пары/тройки с условием
  • Навстречу: типовой пример после сортировки

    Сюжет: найти пару с суммой target.

    Почему работает:

  • массив отсортирован
  • если сумма слишком маленькая, увеличиваем левый элемент
  • если слишком большая, уменьшаем правый
  • Оценки:

  • сортировка:
  • проход указателями:
  • Быстрый/медленный: “удалить элементы на месте”

    Если нужно убрать элементы по условию и не важен порядок “за хвостом”, часто делают так:

  • read идёт по всем элементам
  • write указывает, куда записывать “хорошие”
  • Обычно это времени и памяти.

    Шаблон “скользящее окно”

    Скользящее окно — это два указателя l и r, которые задают текущий подотрезок/подстроку. Вы двигаете r, расширяя окно, а когда условие нарушено — двигаете l, сужая.

    Чаще всего встречается в задачах про:

  • “самая длинная подстрока без повторов”
  • “минимальная длина подмассива с суммой хотя бы …”
  • “количество подотрезков, удовлетворяющих условию”
  • !Иллюстрация, что окно двигается, а состояние поддерживается инкрементально

    Ключевой принцип: вы поддерживаете “состояние окна” так, чтобы обновление при сдвиге границы было быстрым.

  • часто состояние — это hash map частот
  • иногда — текущая сумма
  • иногда — число различных элементов
  • Типовой каркас:

    Почему это не :

  • r проходит строку один раз
  • l тоже двигается только вперёд и суммарно делает не больше шагов
  • Итого часто получается времени.

    Префиксные суммы: быстрые запросы суммы отрезка

    Префиксная сумма — это накопленная сумма “с начала” до позиции.

    Практический смысл:

  • сумма на отрезке считается через два значения префикса
  • вы избегаете пересчёта суммы в каждом запросе
  • Типовой шаблон:

    Где это используется:

  • много запросов сумм подотрезков
  • подсчёт количества подотрезков с заданной суммой (обычно вместе с хеш-таблицей)
  • Оценки:

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

    | Формулировка в задаче | Частый шаблон | Типичная структура данных | Оценка по времени | |---|---|---|---| | “проверить наличие”, “частоты” | хеш-таблица | set/map | обычно | | “первый/последний индекс в отсортированном” | бинарный поиск границы | массив | | | “минимальное/максимальное X, чтобы условие выполнялось” | бинарный поиск по ответу | зависит от ok(x) | | | “пара/тройка”, “ближайшие после упорядочивания” | сортировка + два указателя | массив | | | “подстрока/подотрезок”, “самая длинная/минимальная длина” | скользящее окно | массив/строка + map | часто | | “много сумм отрезков”, “подотрезки с суммой k” | префиксные суммы | массив + map | часто |

    Связь с предыдущими темами курса

  • Из Big-O: вы заранее отсекаете подходы, которые дадут при .
  • Из структур данных: вы выбираете hash map для быстрых проверок, массив для индексов, строковый буфер при построении строк.
  • Из деревьев и графов: вы уже видели, что обход — это “один системный проход”; окно, два указателя и префиксные суммы — тот же принцип для линейных структур.
  • В следующих материалах эти шаблоны будут встречаться постоянно: как “внешний каркас”, внутри которого вы реализуете конкретную логику задачи.

    5. Динамическое программирование: постановка, состояния и оптимизации

    Динамическое программирование: постановка, состояния и оптимизации

    Динамическое программирование (DP) на LeetCode встречается так же часто, как хеш-таблицы и BFS. Но в отличие от “готовых” структур данных, DP требует правильно поставить задачу: выбрать состояние, придумать переход и не выйти за ограничения времени/памяти.

    Связь с предыдущими темами курса прямая:

  • Из темы про Big-O вы берёте навык заранее оценивать, пройдёт ли или нужен .
  • Из темы про коллекции вы берёте инструменты хранения состояний (массив, словарь) и цену операций.
  • Из темы про обходы вы берёте идею “посещать состояния системно”: DP почти всегда можно понимать как обход графа состояний.
  • Что такое динамическое программирование

    Динамическое программирование — это подход, где задача разбивается на подзадачи, ответы на подзадачи сохраняются, а затем используются повторно.

    Два ключевых признака задач, где DP часто уместно:

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

  • найти минимум/максимум (стоимость, число операций, прибыль)
  • посчитать количество способов
  • определить существование решения (можно ли набрать сумму, можно ли дойти)
  • Источник для общего определения:

  • Dynamic programming (Wikipedia)
  • Как распознать DP в условии

    Частые “сигнальные слова”:

  • “минимальная/максимальная стоимость”, “оптимальный”
  • “сколько способов”, “количество путей”, “сколько раз”
  • “можно ли” с множеством выборов на каждом шаге
  • “разбить”, “разделить”, “склеить”, “выбрать подмножество”
  • И важная подсказка про ограничения:

  • если до , классическое DP чаще всего не пройдёт
  • если до , иногда проходит
  • если есть два параметра и , DP часто становится и это нужно сопоставить с лимитами
  • Пошаговая постановка DP

    DP почти всегда строится по одному и тому же плану.

    Состояние

    Состояние — это то, что полностью описывает подзадачу.

    Практический вопрос:

  • что я должен знать, чтобы продолжить решение, не вспоминая весь путь?
  • Типичные формы состояния:

  • dp[i] — ответ для префикса длины i
  • dp[i][j] — ответ для позиции (i, j) (сетка), или для двух индексов (подстрока i..j)
  • dp[i][k] — ответ после обработки i элементов при ресурсе k
  • База

    Базовые случаи — минимальные подзадачи, которые считаются напрямую.

    Пример: если dp[i] зависит от dp[i-1], надо определить dp[0] и, возможно, dp[1].

    Переход

    Переход — формула или правило, как собрать dp[state] из меньших состояний.

    Пример перехода словами:

  • “чтобы оказаться в i, я мог прийти из i-1 или i-2
  • Порядок вычисления

    DP бывает двух основных видов по стилю вычисления:

  • сверху вниз (top-down): рекурсия + мемоизация
  • снизу вверх (bottom-up): табличное заполнение
  • Правильный порядок важен, чтобы все зависимости уже были посчитаны.

    Оценка сложности

    После того как вы придумали состояние и переход, сложность обычно читается почти автоматически.

  • время: число состояний умножить на стоимость обработки одного состояния
  • память: сколько состояний храните одновременно
  • Top-down и bottom-up: два способа сделать одно и то же

    Top-down: рекурсия с мемоизацией

    Идея: считаем ответ рекурсивно и сохраняем результаты, чтобы не пересчитывать.

    Плюсы:

  • часто проще придумать
  • вычисляет только те состояния, которые реально нужны
  • Минусы:

  • риск переполнить стек рекурсии
  • иногда медленнее из-за накладных расходов вызовов
  • Пример (Python) для “лестницы” (аналог Climbing Stairs):

    Здесь состояние f(i) означает “сколько способов попасть на ступень i”.

    Bottom-up: табличное заполнение

    Идея: заполняем dp по возрастанию, чтобы зависимости уже были готовы.

    Плюсы:

  • нет рекурсии
  • проще контролировать память
  • Минусы:

  • иногда приходится заполнять “лишние” состояния
  • !Граф состояний помогает увидеть, почему мемоизация убирает повторные вычисления

    Самые частые формы DP на LeetCode

    Одномерное DP по префиксу

    Состояние вида dp[i] — ответ для первых i элементов.

    Примеры задач:

  • “дойти до конца с минимальной стоимостью”
  • “максимальная сумма подмассива с условиями”
  • “количество способов декодирования”
  • Типовая логика:

  • вы на позиции i
  • выбираете один из нескольких переходов из меньших i
  • DP на сетке

    Состояние вида dp[i][j] — ответ в клетке (i, j).

    Обычно переходы идут из соседей:

  • сверху (i-1, j)
  • слева (i, j-1)
  • Классический пример: “сколько путей до клетки при движении вправо/вниз”.

    !Иллюстрация табличного заполнения DP на сетке

    DP “выбор/не выбор”: рюкзак и суммы

    Состояние часто выглядит так:

  • dp[i][k] — можно ли набрать сумму k, используя первые i элементов
  • или dp[i][k] — максимальная ценность при ограничении k
  • Это типичный класс:

  • 0/1 Knapsack
  • Partition Equal Subset Sum
  • Главная опасность: может быть слишком большим, если сумма не ограничена.

    DP по подстрокам и интервалам

    Состояние вида dp[l][r] — ответ для подстроки/подмассива от l до r.

    Примеры:

  • палиндромы (является ли s[l..r] палиндромом)
  • “оптимальное разбиение строки”
  • Ключевой момент: порядок заполнения должен идти от маленьких интервалов к большим.

    DP на подпоследовательностях

    Состояния часто завязаны на индекс:

  • dp[i] — лучший ответ для подпоследовательности, заканчивающейся в i
  • Классический пример: LIS за через переход по всем j < i.

    Этот вид DP важен как базовый, даже если в некоторых задачах есть более быстрые оптимизации.

    Как правильно считать сложность DP

    Удобная “формула здравого смысла”:

    Здесь:

  • — оценка времени
  • “число состояний” — сколько разных dp[...] вы храните
  • “стоимость перехода” — сколько операций нужно, чтобы вычислить одно состояние
  • Примеры:

  • dp[i] с константным числом переходов:
  • dp[i][j] для сетки :
  • dp[i] где переход перебирает все j < i:
  • Для памяти часто достаточно оценить размер таблицы:

  • dp длины n:
  • dp размера n \times m:
  • Оптимизации: как укладываться в память и время

    Оптимизация памяти: “rolling array”

    Если dp[i] зависит только от нескольких предыдущих значений, хранить весь массив не обязательно.

    Пример для лестницы:

  • вместо dp[0..n] можно держать только последние два значения
  • То же самое часто работает для сетки:

  • если dp[i][j] зависит только от строки i-1, можно хранить одну строку dp[j]
  • Оптимизация перехода

    Частая причина TLE в DP — не количество состояний, а дорогой переход.

    Типовые приёмы ускорения:

  • хранить дополнительные “префиксные” значения (минимумы/максимумы/суммы), чтобы переход стал
  • использовать хеш-таблицу для переходов по редким состояниям
  • заранее отсортировать данные, чтобы переходы стали монотонными и считались линейно
  • Практическая мысль: если переход выглядит как “перебрать всех предыдущих”, это кандидат на оптимизацию.

    Оптимизация за счёт смены постановки

    Иногда можно поменять определение состояния и резко упростить переход.

    Пример типичной смены угла:

  • вместо “какой лучший ответ, если я выбрал конкретные элементы
  • перейти к “какой лучший ответ, если я на позиции i и у меня такой-то ресурс/баланс
  • Это особенно часто встречается в задачах со строками и массивами, где состояние можно выразить через индекс и пару параметров.

    Когда DP превращается в BFS/DFS по состояниям

    Если переходы выглядят как “из состояния можно перейти в несколько новых”, а стоимость каждого шага одинаковая, это очень похоже на граф.

    Практический вывод:

  • если нужно “минимум шагов” и все шаги одинаковые, сначала подумайте про BFS
  • если нужно “минимальная стоимость”, подумайте про DP или алгоритмы кратчайших путей
  • Для связи темы с графами полезна базовая статья:

  • Shortest path problem (Wikipedia)
  • Типичные ошибки в DP на LeetCode

  • Состояние не полное: вы потеряли важный параметр, и переход становится неверным.
  • Переход считает одно и то же много раз: забыли мемоизацию или неправильно выбрали порядок.
  • Неправильные базовые случаи: dp[0] и “пустые” ситуации часто ломают ответ.
  • Таблица слишком большая: по памяти не проходит, нужна оптимизация по памяти.
  • Переход слишком дорогой: получилось при .
  • Как эта тема встраивается в курс

    Ранее вы научились:

  • оценивать, какие классы сложности “реально проходят”
  • выбирать структуры данных для быстрых операций
  • обходить деревья и графы за линейное время
  • DP объединяет всё это в один навык: вы проектируете пространство состояний и “обходите” его так, чтобы уложиться в ограничения.

    На практике в большинстве задач LeetCode достаточно уверенно владеть базовыми формами:

  • dp[i] и dp[i][j]
  • memoization и tabulation
  • rolling array и аккуратная оценка