16. Оптимизация решения шаг за шагом
«Преждевременная оптимизация — корень всех зол. Но когда архитектура уже определена, аккуратная пошаговая оптимизация — признак истинного профессионализма».
— Дональд Кнут
В предыдущей статье мы договорились, что наивное решение (brute-force) — это не досадный черновик, а надежная стартовая площадка. Теперь наступает время главного действия: превращения медленного перебора в высокоскоростной, надежный алгоритм. И делать это нужно не отчаянным прыжком в неизвестность, а последовательностью выверенных, логичных шагов.
Такая техника пошаговой оптимизации (incremental optimization) производит неотразимое впечатление на интервьюера: вы не просто выдаете готовый код из памяти, а наглядно демонстрируете инженерную методологию устранения узких мест. В этой статье мы разберем 4 ступени оптимизации, свяжем их с рантаймом Go и закрепим навык на классических задачах LeetCode.
Модель восхождения по ступеням
Оптимизация подчиняется закону узкого места (bottleneck): в любой момент времени общая скорость алгоритма лимитируется самой «дорогой» операцией. Устраняя один bottleneck, мы передаем пальму первенства следующему, пока не упремся в фундаментальный теоретический предел задачи.
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef aux fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
classDef system fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
classDef data fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef network fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Start["Наивный Brute-force O N³ или O N²"]:::entry --> Step1["Ступень 1: Устранение повторных вычислений<br/>Мемоизация, префиксные суммы, отсечения"]:::process
Step1 --> Step2["Ступень 2: Замена неэффективных структур<br/>Слайс -> Хэш-таблица -> Массив на стеке"]:::data
Step2 --> Step3["Ступень 3: Смена алгоритмической перспективы<br/>Сортировка + 2 указателя, скользящее окно"]:::accent
Step3 --> Step4["Ступень 4: Механическая симпатия Go<br/>make cap, устранение копий, cache locality"]:::success
Step4 --> Done["Итоговый production-код:<br/>оптимальная асимптотика и 0 лишних аллокаций"]:::success
Ступень 1. Устраняем повторные вычисления
Самый банальный источник тормозов — пересчет того, что уже было посчитано на предыдущем шаге.
- Префиксные агрегаты: Вместо того чтобы суммировать срез
nums[left...right]за $O(K)$, мы предподсчитываем префиксные суммыprefix[i] = prefix[i-1] + nums[i]и получаем сумму любого диапазона за $O(1)$ какprefix[right+1] - prefix[left]. - Динамическое программирование: Вместо экспоненциальной рекурсии с ветвлением $O(2^N)$ мы сохраняем промежуточные решения подзадач в плоском срезе
dp.
Ступень 2. Заменяем неэффективные структуры данных
Когда повторные вычисления ликвидированы, узкое место почти всегда перемещается в операции поиска, вставки или удаления в коллекциях:
- Линейный поиск в слайсе ($O(N)$): Заменяется на поиск в
map($O(1)$ в среднем) или бинарный поиск по отсортированному массиву ($O(\log N)$). - Хэш-таблица
map$\to$ Массив прямого доступа: Если ключами являются символы ASCII или небольшие числа, заменаmap[byte]intна[128]intубирает хэширование, pointer chasing и освобождает сборщик мусора. - Стандартный
container/list$\to$ Кастомный список на структурах: Устраняет упаковку примитивов вany(boxing) и сопутствующие аллокации в куче.
Ступень 3. Меняем перспективу (алгоритмический сдвиг)
Иногда структуру данных оптимизировать некуда — нужно изменить сам порядок обхода пространства поиска:
- Предварительная сортировка ($O(N \log N)$): Создает инвариант порядка, открывающий дорогу для сходящихся двух указателей.
- Скользящее окно: Вместо перебора всех пар индексов $(i, j)$ сдвигает границы монотонно, гарантируя строго линейное время $O(N)$.
- Бинарный поиск по ответу: Превращает задачу нахождения оптимума в серию быстрых проверок гипотезы.
Ступень 4. Go-специфичные микрооптимизации
Когда Big O доведен до совершенства, Senior-разработчик демонстрирует уважение к рантайму Go:
- Предварительное выделение емкости (
cap):
Записьmake([]T, 0, expectedSize)предотвращает многократные переаллокации и копирования массива в куче приappend. - Локальные массивы на стеке:
Переменнаяvar count [26]intостается на стеке текущей горутины, если на нее нет внешних ссылок (escape analysis). - Отказ от лишнего копирования в циклах:
Конструкцияfor _, item := range hugeSliceкопирует структуруitemна каждой итерации. Для структур большого размера используйте доступ по индексу:for i := range hugeSliceи&hugeSlice[i]. - Использование
strings.Builder:
Предотвращает квадратичное выделение промежуточных строк при сборке текста.
Сквозной пример 1: 3Sum (LeetCode 15)
Пройдем задачу по всем ступеням эволюции:
Шаг 0: Наивный перебор $O(N^3)$
Три вложенных цикла, перебирающих все возможные комбинации индексов $i < j < k$. При $N = 3000$ это $2.7 \times 10^{10}$ операций $\to$ Time Limit Exceeded.
Шаг 1: Анализ узкого места
Внутренний цикл по $k$ просто ищет значение -(nums[i] + nums[j]). Можно ли найти его быстрее? Если массив отсортирован, мы можем искать пару $(j, k)$ сходящимися указателями с двух концов массива за один проход $O(N)$!
Шаг 2: Итоговый оптимальный код $O(N^2)$
func threeSum(nums []int) [][]int {
n := len(nums)
if n < 3 {
return nil
}
// Сортировка in-place за O(N log N)
sort.Ints(nums)
// Предвыделяем разумную начальную емкость
result := make([][]int, 0, 64)
for i := 0; i < n-2; i++ {
// Пропускаем дубликаты для первого элемента
if i > 0 && nums[i] == nums[i-1] {
continue
}
// Если наименьшее число > 0, сумма трех положительных чисел никогда не будет 0
if nums[i] > 0 {
break
}
left := i + 1
right := n - 1
target := -nums[i]
for left < right {
sum := nums[left] + nums[right]
switch {
case sum == target:
result = append(result, []int{nums[i], nums[left], nums[right]})
// Пропускаем дубликаты слева и справа
for left < right && nums[left] == nums[left+1] {
left++
}
for left < right && nums[right] == nums[right-1] {
right--
}
left++
right--
case sum < target:
left++
default:
right--
}
}
}
return result
}
Сквозной пример 2: Самая длинная подстрока без повторяющихся символов (LeetCode 3)
Шаг 0: Наивный брутфорс $O(N^3)$
Перебираем все возможные подстроки s[left:right] и для каждой из них проверяем уникальность символов. Катастрофически медленно.
Шаг 1: Оптимизация до скользящего окна с хэш-таблицей $O(N)$
Вместо перезапуска проверки сжимаем окно слева при обнаружении дубликата. Работает за $O(N)$, но использует map[byte]int.
Шаг 2: Go-микрооптимизация до массива на стеке
Поскольку символы принадлежат таблице ASCII, заменяем map на плоский массив [128]int, где храним последний замеченный индекс каждого символа.
func lengthOfLongestSubstring(s string) int {
// lastPos[char] хранит последний замеченный индекс символа char
var lastPos [128]int
for i := range lastPos {
lastPos[i] = -1 // -1 означает, что символ еще не встречался
}
left := 0
maxLen := 0
for right := 0; right < len(s); right++ {
ch := s[right]
// Если символ уже встречался внутри текущего окна
if prevIdx := lastPos[ch]; prevIdx >= left {
left = prevIdx + 1 // Мгновенный прыжок левой границы!
}
lastPos[ch] = right
currentLen := right - left + 1
if currentLen > maxLen {
maxLen = currentLen
}
}
return maxLen
}
Инженерный триумф:
- Сложность по времени: строго $O(N)$ за ровно 1 проход по строке.
- Сложность по памяти: $128 \times 8 = 1024$ байта на стеке ($O(1)$).
- Ноль обращений к аллокатору Go, ноль работы для сборщика мусора.
Резюме диалога на собеседовании
- «Я зафиксировал наивное решение со сложностью $O(N^3)$. Главный bottleneck — внутренний цикл перебора».
- «Я предлагаю устранить этот перебор, отсортировав массив и применив два указателя. Это понизит асимптотику до $O(N^2)$».
- «Для оптимизации работы памяти я заменяю хэш-карту на локальный стек-массив [128]int, что полностью исключит аллокации в куче».
- «Финальная сложность: $O(N)$ по времени и $O(1)$ по памяти. Код готов к тестам».
В следующей статье мы разберем диагностику кода: как научиться безошибочно находить узкие места с первого взгляда без профайлера и бенчмарков. 17. Как находить узкие места