1.1Теория: сложность и структуры данных
Сравнишь временную и пространственную сложность типичных структур и проверишь бинарный поиск на границах. Разберёшь, когда слайс, map, heap или дерево подходят под операции задачи.
Амортизация и два смысла слова «куча»
1. Амортизированная сложность - «в среднем по длинной серии, а не за один вызов»
Амортизированная сложность усредняет стоимость одной операции по длинной серии. Не «средний случай на случайных данных», а именно «суммарная стоимость n операций, делённая на n». Разница принципиальная: средний случай считает вероятность, амортизация даёт гарантию.
Канонический пример даёт append в слайс. Обычно это запись в свободную ячейку,
то есть O(1). Но когда место кончилось, рантайм выделяет новый массив с запасом
(для небольших слайсов примерно вдвое больше, точная формула разобрана ниже) и
копирует туда все n элементов. Такой вызов стоит O(n).
Рост «в разы» означает, что дорогие вызовы случаются всё реже: на тысяче
append к пустому слайсу выходит ровно 10 перевыделений и 1868 скопированных
элементов на 1000 добавленных, меньше двух копирований на элемент за всю серию.
Делим на n, получаем константу. Отсюда формулировка: append
амортизированно O(1), но отдельный вызов может стоить O(n).
Бытовая аналогия: абонемент в спортзал. Заплатил один раз 12 тысяч, ходишь год, и
«амортизированно» это тысяча в месяц, хотя в конкретный день ты выложил всю сумму
сразу. Аналогия честна ровно до одного места: у append «дорогой день»
наступает не по расписанию, и если у тебя жёсткий лимит на задержку одного запроса,
амортизация тебя не спасёт, поэтому в таких местах ёмкость задают заранее через
make([]T, 0, n).
2. «Куча» - это два совершенно разных слова
Самая коварная ловушка терминологии в Go-собеседовании, потому что оба смысла встречаются в одном и том же разговоре и обозначаются одним и тем же словом (в английском тоже: heap и heap). Связи между ними нет никакой, просто исторически совпали имена.
| Куча как структура данных | Куча как область памяти | |
|---|---|---|
| Что это | почти полное бинарное дерево, у которого родитель не больше (или не меньше) своих детей; физически хранится в обычном массиве | область памяти процесса, где живут значения, пережившие функцию, в которой они созданы |
| Зачем нужна | мгновенно достать минимум или максимум: очередь с приоритетом, top-K, таймеры, алгоритм Дейкстры | хранить то, что нельзя положить на стек, потому что оно должно пережить возврат из функции |
| Кто ей управляет | ты сам, руками, через container/heap | рантайм: аллокатор выделяет, сборщик мусора освобождает |
| Где на этом сайте | раздел «Куча и container/heap» ниже в этой главе | тема 03 «Память, GC и производительность», разговор про стек, escape-анализ и аллокации |
| Как отличить в речи | рядом звучат «вставка», «извлечь минимум», O(log n), «приоритет» | рядом звучат «убежала в кучу», «аллокация», «GC», «стек» |
Проверочная фраза: «этот объект убежал в кучу» говорит про память, а «возьмём кучу, чтобы каждый раз доставать самый дешёвый элемент» - про структуру данных. Куча-аллокатор внутри не построена на куче-структуре, и наоборот: куча-структура сама по себе просто слайс, и жить он может хоть на стеке.
Big O: что это и чем это не является
O(f(n)) задаёт верхнюю оценку скорости роста при n → ∞, с точностью до
константы. Формально g(n) = O(f(n)), если найдутся c > 0 и
n₀, что g(n) ≤ c·f(n) для всех n ≥ n₀. Три нюанса, которые
отличают «зубрил» от «понимает»:
- O, Θ, Ω значат разное.
Oчитается «не хуже чем»,Ω- «не лучше чем»,Θ- «ровно такого порядка». На собесе говорят «O», но почти всегда имеют в видуΘ. ФормальноO(n²)для бинарного поиска не ложь, просто бесполезная оценка. - Константы отброшены, но в бою они решают. Проход по слайсу идёт последовательно, и
префетч кэша работает. Проход по связному списку той же длины даёт n промахов кэша, и
разница в 10–50 раз при одинаковой
O(n). - Асимптотика начинается не с нуля. На коротких отрезках вставками сортируют быстрее,
чем quicksort, поэтому в
pdqsortвнутри Go стоит порог: до 12 элементов идёт insertion sort.
Как оценивать сложность вслух
Это отдельный навык, который проверяют явно: «а какая тут сложность?». Ниже схема ответа из пяти шагов. Проговаривай её вслух, интервьюер оценивает именно рассуждение.
| Шаг | Что сказать | Пример формулировки |
|---|---|---|
| 1. Определить n | Что именно растёт. Если входов два - вводи n и m, не сливай в одно | «n - длина слайса, m - длина строки-паттерна» |
| 2. Посчитать проходы | Сколько раз каждый элемент трогается, а не сколько строк в коде | «внешний цикл n, внутри мапа за O(1) → O(n)» |
| 3. Учесть стоимость операций внутри | Библиотечные вызовы не бесплатны | «sort.Slice внутри цикла - это n·(n log n)» |
| 4. Отдельно память | Считать дополнительную (auxiliary) память, не входную. Глубина рекурсии - тоже память | «мапа на n ключей → O(n) памяти; in-place было бы O(1)» |
| 5. Назвать худший случай | Средний и худший - разные числа. Сказать оба | «в среднем O(n), в худшем O(n²) при всех коллизиях» |
s += xв цикле обходится в O(n²): строки в Go неизменяемы, каждая конкатенация копирует всё. Лечитсяstrings.Builder→ O(n).append(s[:i], s[i+1:]...)удаляет из середины за O(n), а не O(1). В цикле по всем элементам получается O(n²).- Доступ к мапе называют «O(1)» без оговорки. Для
map[string]Tэто O(len(key)): хеш надо посчитать по всем байтам ключа, а при коллизии ещё и сравнить строки. strings.Contains,slices.Contains,copyстоят O(n), их часто «не замечают» внутри цикла.- Забывают память под мемоизацию и под рекурсию: наивный DFS по цепочке из n узлов съедает O(n) стека.
- Путают амортизированную и худшую:
appendамортизированно O(1), но конкретный вызов, попавший наgrowslice, стоит O(n). - «Два вложенных цикла - значит O(n²)» работает не всегда: если внутренний указатель монотонно движется и не откатывается (two pointers, sliding window), суммарно это O(n).
Сложность операций: сводная таблица
Эту таблицу надо знать наизусть: на ней держится половина теории.
| Операция | Слайс | Мапа | Связный список | BST (сбаланс.) | Куча | Trie |
|---|---|---|---|---|---|---|
| Доступ по индексу | O(1) | - | O(n) | - | O(1) по индексу массива | - |
| Поиск по ключу/значению | O(n); O(log n) если отсортирован | O(1) средн. / O(n) худш. | O(n) | O(log n) / O(n) вырожд. | O(n) | O(L), L - длина ключа |
| Вставка в конец | O(1) амортизированно | O(1) средн. | O(1) при хвостовом указателе | O(log n) | O(log n) | O(L) |
| Вставка в начало / середину | O(n) | - | O(1), если узел уже на руках | O(log n) | - | - |
| Удаление | O(n) | O(1) средн. | O(1), если узел на руках (двусвязный) | O(log n) | O(log n) для корня | O(L) |
| Min / max | O(n) | O(n) | O(n) | O(log n) | O(1) корень | - |
| Обход в порядке сортировки | O(n log n) - надо отсортировать | невозможно, порядок случайный | O(n log n) | O(n) in-order | O(n log n) | O(n) лексикографически |
| Оверхед памяти | минимальный, данные подряд | бакеты + запас, ~1.3–2× | +1–2 указателя на узел | +2–3 указателя на узел | как у слайса | много узлов и указателей |
Мапа быстрая, но неупорядоченная и без диапазонных запросов. Дерево медленнее на точечном
поиске (O(log n) против O(1)), но умеет ORDER BY и BETWEEN. Это ровно тот
же выбор, который делает СУБД между hash-индексом и B-tree-индексом. Связку очень любят
на собесе: «почему в Postgres по умолчанию B-tree, а не хеш?»
Стек, очередь, дек на слайсе
В Go отдельных типов для них нет, их пишут на слайсе за пять строк, и на лайвкодинге это ждут. Стек тривиален: конец слайса и есть вершина.
type Stack[T any] []T
func (s *Stack[T]) Push(v T) { *s = append(*s, v) }
func (s *Stack[T]) Pop() (T, bool) {
old := *s
var zero T
if len(old) == 0 {
return zero, false
}
v := old[len(old)-1]
old[len(old)-1] = zero // обнуляем: иначе слайс держит ссылку и мешает GC
*s = old[:len(old)-1]
return v, true
}
// var s Stack[string]
// s.Push("a"); s.Push("b"); s.Push("c")
// s.Pop() → "c", true (осталось 2)
// s.Pop() → "b", true (осталось 1)
// s.Pop() → "a", true (осталось 0)
// s.Pop() → "", false - пустой стек отдаёт нулевое значение и false
С очередью хуже. Наивный вариант «голова в начале слайса» выглядит красиво и течёт памятью:
// Плохо: рабочая, но underlying array растёт неограниченно
q = append(q, v) // enqueue
head := q[0] // dequeue
q = q[1:] // указатель поехал вперёд, а массив под ним никто не освободит
q = q[1:] сдвигает только ptr и len. Занятая память
впереди слайса недостижима, но GC её не соберёт: жив весь массив целиком. Для очереди на миллион
элементов это миллион «дырок». Правильный ответ даёт кольцевой буфер: индексы
ходят по кругу через % cap, память переиспользуется.
q = q[1:].// Дек на кольцевом буфере: O(1) на каждую операцию, PopBack пишется зеркально PopFront
type Deque[T any] struct {
buf []T
head int // индекс первого элемента
size int // сколько занято
}
func (d *Deque[T]) Len() int { return d.size }
func (d *Deque[T]) PushBack(v T) {
d.grow()
d.buf[(d.head+d.size)%len(d.buf)] = v
d.size++
}
func (d *Deque[T]) PushFront(v T) {
d.grow()
d.head = (d.head - 1 + len(d.buf)) % len(d.buf) // +len, иначе отрицательный индекс
d.buf[d.head] = v
d.size++
}
func (d *Deque[T]) PopFront() (T, bool) {
var zero T
if d.size == 0 {
return zero, false
}
v := d.buf[d.head]
d.buf[d.head] = zero // не держим ссылку
d.head = (d.head + 1) % len(d.buf)
d.size--
return v, true
}
func (d *Deque[T]) grow() {
if d.size < len(d.buf) {
return
}
n := len(d.buf) * 2
if n == 0 {
n = 8
}
nb := make([]T, n)
for i := 0; i < d.size; i++ { // перекладываем «выпрямляя» кольцо
nb[i] = d.buf[(d.head+i)%len(d.buf)]
}
d.buf, d.head = nb, 0
}
// var d Deque[int]
// d.PushBack(2); d.PushBack(3); d.PushFront(1)
// d.Len() → 3
// PopFront подряд: 1 2 3, затем (0, false)
В стандартной библиотеке есть container/list, двусвязный список на
any. Он проигрывает слайсу на всём, кроме «удалить узел, который уже на руках»:
каждый элемент требует отдельной аллокации, значения упакованы в интерфейс,
а обход собирает сплошные промахи кэша.
Применений ровно два: LRU-кэш (там нужно именно
«переставить известный узел в голову», разбор в 1.2) и учебные задачи. Для очереди бери кольцевой буфер, для стека слайс.
Хеш-таблица: как устроена и когда ломается
Мапа в Go устроена как хеш-таблица. До Go 1.23 включительно это были классические бакеты по 8 слотов с цепочками overflow-бакетов; с Go 1.24 внутренности заменили на Swiss Tables: группы по 8 слотов, метаданные-контрольные байты, поиск группы одной SIMD-операцией. Меняется константа и расход памяти, асимптотика остаётся прежней.
Внутри бакета ключи лежат в слотах, по одной паре ключ-значение в каждом; в мапе Go слотов в бакете восемь. Чем выше load factor, тем плотнее набиты бакеты и тем длиннее линейный поиск внутри них. Когда load factor перерастает порог, таблица растёт вдвое, и всё раскладывается заново.
- Идея.
hash(key) → номер бакета. Внутри бакета идёт линейный поиск среди нескольких слотов. Средняя стоимость O(1), потому что число слотов ограничено load factor. - Коллизии. Две стратегии: метод цепочек (в старой мапе Go это overflow-бакеты) и открытая адресация (в Swiss Tables пробирование по группам).
- Load factor. В старой мапе порог стоял на 6.5 элемента на бакет из 8 слотов; при превышении таблица удваивается и данные раскладываются заново инкрементально, по чуть-чуть на каждой записи и удалении.
- Худший случай O(n). Если все ключи попали в один бакет. В Go это не воспроизвести извне: у каждой мапы свой случайный seed хеша, его выбирают при создании мапы, поэтому HashDoS-атака на мапу не работает. Но на собесе назвать O(n) как худший случай обязательно.
Деревья: BST, почему на практике B-tree, и trie
BST: в левом поддереве ключи меньше, в правом больше. Поиск, вставка
и удаление стоят O(h) при высоте дерева h. У сбалансированного дерева h = log₂ n, у вырожденного
(вставляли уже отсортированные данные) h = n, и дерево превращается в связный список.
Отсюда самобалансирующиеся варианты: AVL (жёстче балансирует, быстрее читает),
красно-чёрное (дешевле вставка; на нём построены map в C++ и TreeMap в Java).
Но в базах данных индексы строят на B-tree / B+tree, и это ровно тот вопрос, который любят задавать на стыке алгоритмов и БД. Причина не в асимптотике (обе дают O(log n)), а в единице чтения: СУБД читает и пишет диск страницами по 8–16 КБ. У BST в узле один-два ключа, значит каждый шаг вниз обходится в отдельное чтение страницы. У B-tree узел равен странице и хранит сотни ключей, поэтому дерево на миллиард строк имеет высоту 3–4 вместо 30.
В trie (префиксное дерево, произносится «трай») путь от корня задаёт
префикс ключа. Поиск слова стоит O(L) при длине слова L и не зависит от числа
слов в словаре. Это его главное отличие от мапы и дерева поиска: сложность привязана к длине
ключа, а не к размеру коллекции. Отсюда классическое применение: поисковые подсказки и
автодополнение. Спускаемся по префиксу за O(L), а дальше обходим поддерево и собираем варианты.
type Trie struct {
kids map[rune]*Trie // для ASCII-словаря быстрее [26]*Trie
end bool // здесь заканчивается слово
}
func NewTrie() *Trie { return &Trie{kids: map[rune]*Trie{}} }
func (t *Trie) Insert(word string) {
cur := t
for _, r := range word { // range по строке даёт руны, не байты
nxt, ok := cur.kids[r]
if !ok {
nxt = NewTrie()
cur.kids[r] = nxt
}
cur = nxt
}
cur.end = true
}
// Suggest возвращает все слова с заданным префиксом.
func (t *Trie) Suggest(prefix string) []string {
cur := t
for _, r := range prefix { // O(L)
nxt, ok := cur.kids[r]
if !ok {
return nil
}
cur = nxt
}
var out []string
var dfs func(node *Trie, acc []rune)
dfs = func(node *Trie, acc []rune) {
if node.end {
out = append(out, prefix+string(acc))
}
for r, k := range node.kids {
dfs(k, append(acc, r)) // Осторожно: см. заметку про append ниже
}
}
dfs(cur, nil)
return out
}
// t := NewTrie(); t.Insert("go"); t.Insert("golang"); t.Insert("gopher"); t.Insert("rust")
// t.Suggest("go") → [go golang gopher] - но порядок плавает: на 30 прогонах вышло
// [go golang gopher] 27 раз и [go gopher golang] 3 раза.
// dfs идёт по kids через range по мапе, а он рандомизирован.
// Опираться нельзя даже на «почти всегда».
// t.Suggest("ru") → [rust]
// t.Suggest("zz") → [] (nil)
В строке dfs(k, append(acc, r)) заложена классическая мина. Если у acc есть запас
cap, соседние ветки DFS будут писать в один и тот же underlying array и
затирать друг друга. Безопасных путей два. Копировать: next := append(append([]rune{}, acc...), r).
Либо держать паттерн «push / рекурсия / pop»: acc = append(acc, r); dfs(k, acc); acc = acc[:len(acc)-1],
тогда в каждый момент времени активна ровно одна ветка. Если интервьюер это заметит, а ты
объяснишь, получится плюс, а не минус.
Куча и container/heap
Дальше речь пойдёт про структуру данных, а не про область памяти (см. разбор двух смыслов слова «куча» в начале главы). Куча-структура сводится к слайсу с одним хитрым инвариантом.
Куча (heap) хранит почти полное бинарное дерево в обычном массиве, без указателей.
Инвариант min-heap: a[parent] ≤ a[child]. Адресация чисто арифметическая:
parent(i) = (i - 1) / 2
left(i) = 2*i + 1
right(i) = 2*i + 2
Вставка: кладём в конец и «всплываем» (sift up), это O(log n). Извлечение минимума: отдаём
a[0], ставим на его место последний элемент и «топим» (sift down), снова O(log n).
Куча из готового массива строится за O(n), а не за O(n log n), и этот неочевидный факт
хорошо звучит на собесе.
В стандартной библиотеке container/heap даёт алгоритмы, а хранилище пишешь ты.
Нужен sort.Interface (Len, Less, Swap)
плюс Push(any) и Pop() any. Главная ловушка: heap.Push и
h.Push делают разные вещи. Твой Push просто добавляет в конец,
а инвариант восстанавливает пакет.
type IntHeap []int
func (h IntHeap) Len() int { return len(h) }
func (h IntHeap) Less(i, j int) bool { return h[i] < h[j] } // "<" = min-heap, ">" = max-heap
func (h IntHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
// Push/Pop на указателе: меняют длину слайса
func (h *IntHeap) Push(x any) { *h = append(*h, x.(int)) }
func (h *IntHeap) Pop() any {
old := *h
n := len(old)
v := old[n-1] // забираем с конца: heap.Pop уже переставил минимум туда
*h = old[:n-1]
return v
}
func main() {
h := &IntHeap{5, 2, 9}
heap.Init(h) // O(n)
heap.Push(h, 1) // O(log n)
fmt.Println((*h)[0]) // 1 - минимум всегда в нулевом элементе, O(1)
fmt.Println(heap.Pop(h)) // 1
}
Граф: представление, BFS и DFS
| Матрица смежности | Список смежности | |
|---|---|---|
| Хранение | [][]bool или [][]int V×V | map[int][]int или [][]int |
| Память | O(V²) всегда | O(V + E) |
| Есть ли ребро u→v | O(1) | O(deg(u)) |
| Перебрать соседей u | O(V) - идём по всей строке | O(deg(u)) |
| Когда брать | плотный граф, V небольшое, много запросов «есть ребро?» | почти всегда: реальные графы разрежены (E ≪ V²) |
type Graph map[int][]int
// BFS: обход по уровням, даёт кратчайший путь в невзвешенном графе. O(V+E)
func BFS(g Graph, start int) []int {
visited := map[int]bool{start: true}
queue := []int{start}
var order []int
for len(queue) > 0 {
u := queue[0]
queue = queue[1:]
order = append(order, u)
for _, v := range g[u] {
if !visited[v] {
visited[v] = true // помечаем при добавлении в очередь, не при извлечении:
queue = append(queue, v) // иначе один узел попадёт в очередь несколько раз
}
}
}
return order
}
// DFS рекурсивно: компоненты связности, топосорт, поиск циклов. O(V+E)
func DFS(g Graph, u int, visited map[int]bool, order *[]int) {
visited[u] = true
*order = append(*order, u)
for _, v := range g[u] {
if !visited[v] {
DFS(g, v, visited, order)
}
}
}
// g := Graph{1: {2, 3}, 2: {4}, 3: {4, 5}, 4: {5}, 5: nil}
// BFS(g, 1) → [1 2 3 4 5] - по уровням
// DFS(g, 1, map[int]bool{}, &order) → [1 2 4 5 3] - вглубь
// Порядок устойчив только потому, что соседи лежат в слайсе.
// Если бы список смежности хранился в мапе, обход поехал бы от запуска к запуску.
BFS берут, когда важна дистанция: кратчайший путь по числу рёбер, «уровни» дерева, «минимум шагов». Памяти он ест O(ширины), у широкого графа это много. DFS берут, когда важна структура: связность, топологическая сортировка, обнаружение циклов, backtracking. Памяти тут O(глубины), зато рекурсия может переполнить стек. В Go стек горутины растёт динамически до 1 ГБ, так что проблема стоит не так остро, как в C или Java.
Сортировки и что делает sort в Go
| Алгоритм | Среднее | Худшее | Память | Стабильна | Чем полезна |
|---|---|---|---|---|---|
| Пузырьком (bubble) | O(n²) | O(n²) | O(1) | да | ничем, кроме собеса |
| Вставками (insertion) | O(n²) | O(n²) | O(1) | да | O(n) на почти отсортированных - поэтому она внутри всех гибридов |
| Выбором (selection) | O(n²) | O(n²) | O(1) | нет | минимум обменов |
| Слиянием (merge) | O(n log n) | O(n log n) | O(n) | да | гарантия худшего случая, внешняя сортировка, легко распараллеливается |
| Быстрая (quick) | O(n log n) | O(n²) | O(log n) стек | нет | лучшая константа, in-place |
| Пирамидальная (heap) | O(n log n) | O(n log n) | O(1) | нет | страховка от худшего случая quicksort |
| Подсчётом / поразрядная | O(n + k) | O(n + k) | O(n + k) | да | целые числа в узком диапазоне - обходит барьер O(n log n) |
Барьер O(n log n) относится к сортировкам сравнением: любая такая сортировка делает не менее
log₂(n!) ≈ n log n сравнений, потому что дерево решений должно различить n! перестановок.
Counting/radix быстрее именно потому, что не сравнивают, а используют структуру ключа.
Что использует Go. До 1.19 стоял introsort: quicksort с медианой из трёх, переход на heapsort при
слишком большой глубине рекурсии и на insertion sort для коротких кусков. С Go 1.19 работает
pdqsort: та же схема плюс распознавание «плохих» паттернов,
то есть уже отсортированных данных, обратного порядка, массы дубликатов. На них pdqsort даёт O(n) вместо
квадратичной деградации. Стабильная сортировка (sort.Stable,
slices.SortStableFunc) собрана из insertion sort на блоках и symmerge: O(n log² n) в
худшем, зато без буфера: дополнительная память - только O(log n) стека рекурсии.
// Современный способ (Go 1.21+): дженерики, без reflect, быстрее sort.Slice
slices.Sort(nums) // упорядоченные типы
slices.SortFunc(users, func(a, b User) int { // cmp-семантика: -1 / 0 / +1
return cmp.Compare(a.Age, b.Age)
})
slices.SortStableFunc(users, func(a, b User) int { ... }) // стабильная
// Старый способ, всё ещё встречается везде
sort.Slice(users, func(i, j int) bool { return users[i].Age < users[j].Age })
sort.SliceStable(users, func(i, j int) bool { ... })
sort.Sort(ByAge(users)) // через sort.Interface
// Сортировка по нескольким ключам без стабильности: сравнивай второй ключ явно
slices.SortFunc(users, func(a, b User) int {
if c := cmp.Compare(a.Age, b.Age); c != 0 {
return c
}
return cmp.Compare(a.Name, b.Name)
})
Сортировка стабильна, если элементы с равными ключами сохраняют исходный относительный
порядок: из [Аня 30, Боря 25, Вера 30] по возрасту стабильная обязана вернуть
Боря, Аня, Вера, а нестабильная вправе поменять Аню и Веру местами. Практический смысл один: многоключевая сортировка
«в два прохода». Сначала по имени, потом стабильно по возрасту, и внутри каждого возраста
имена останутся упорядоченными. С нестабильной так нельзя, надо писать составной компаратор.
sort.Slice и slices.Sort
нестабильны: контракт ничего не обещает про порядок равных элементов. А вот «Go специально
их перемешивает» - миф, и проверка это сразу покажет: pdqsort использует xorshift, засеянный
длиной среза, поэтому один и тот же вход даёт один и тот же порядок от запуска к запуску.
Опираться на это всё равно нельзя, оно может измениться в любой версии, но и объяснять
нестабильность рандомизацией не стоит: собеседник, который проверял, будет прав, а ты нет.
Бинарный поиск без ошибок на границах
Классика, на которой валятся даже сильные кандидаты. Джон Бентли писал, что из ста профессиональных
программистов правильный бинарный поиск с первого раза написали около десяти, а в JDK баг с переполнением
(lo+hi)/2 прожил девять лет. Надёжнее всего писать не «найти элемент», а
«найти границу», работая с полуинтервалом [lo, hi).
lo == hi и есть искомая граница.// Вариант 1. Классический: найти элемент, вернуть индекс или -1
func Search(a []int, target int) int {
lo, hi := 0, len(a)-1 // замкнутый отрезок [lo, hi]
for lo <= hi { // именно <=, иначе пропустим отрезок из одного элемента
mid := lo + (hi-lo)/2 // не (lo+hi)/2, привычка против переполнения
switch {
case a[mid] == target:
return mid
case a[mid] < target:
lo = mid + 1
default:
hi = mid - 1
}
}
return -1
}
// Вариант 2. Границы (lower bound): надёжнее и универсальнее
// Возвращает первую позицию, где a[i] >= target. Если такой нет, вернёт len(a).
func LowerBound(a []int, target int) int {
lo, hi := 0, len(a) // полуинтервал [lo, hi)
for lo < hi {
mid := lo + (hi-lo)/2
if a[mid] < target {
lo = mid + 1
} else {
hi = mid
}
}
return lo // lo == hi
}
// a := []int{1, 3, 3, 5, 8}
// Search(a, 5) → 3 Search(a, 4) → -1
// Search(a, 1) → 0 Search(a, 8) → 4
// LowerBound(a, 3) → 1 ← первая из двух троек
// LowerBound(a, 4) → 3 ← элемента нет, это позиция вставки
// LowerBound(a, 0) → 0 LowerBound(a, 9) → 5 (== len(a))
- Переполнение
(lo+hi)/2. На 64-битных платформахintв Go 64-битный, и на практике не переполнится, но привычка писатьlo + (hi-lo)/2ничего не стоит, и её ждут. - Путаница
<и<=. Для[lo, hi]нужноlo <= hi, для[lo, hi)-lo < hi. Смешал и либо пропустил элемент, либо вышел за границу. - Зацикливание. Если в полуинтервальной версии написать
lo = midвместоlo = mid + 1, то приhi - lo == 1получитсяmid == loи отрезок перестанет сокращаться: цикл повиснет навсегда. - Забыли отсортированность. Бинарный поиск требует монотонного предиката. На собесе обязательно спроси: «слайс отсортирован?»
// В проде руками писать не надо
i := sort.SearchInts(a, x) // lower bound для []int
i = sort.Search(len(a), func(i int) bool { return a[i] >= x }) // общий случай
// Go 1.21+, дженерики и явный признак «нашли»
i, found := slices.BinarySearch(a, x)
i, found = slices.BinarySearchFunc(users, target, func(u, t User) int {
return cmp.Compare(u.ID, t.ID)
})
sort.Search(n, f) и есть lower bound: он ищет минимальное i из
[0, n), для которого f(i) == true, и требует монотонности:
f должна быть false…false,true…true. Если предикат немонотонен, результат окажется
мусором, причём тихо, без паники. Массивами дело не ограничивается: так ищут границу в любом
монотонном пространстве, например минимальную скорость или размер батча, при которых условие выполняется.
На массиве из int32 линейный поиск обгоняет бинарный на коротких массивах,
примерно до десятка элементов:
бинарный делает непредсказуемые переходы и прыгает по памяти, а линейный
идёт по кэш-линиям подряд, и переходы у него предсказуемы. Именно поэтому в pdqsort есть порог 12 элементов
для insertion sort, а в старой мапе Go бакет вмещал 8 ключей и просматривался линейно. Если на
собесе после O-оценки добавить фразу «но на маленьких n константа решает, и в стандартной библиотеке
это учтено порогами», ответ заметно выделяется.
Вопросы
12n → ∞ с
точностью до константы. Вслух это оценивают по схеме: что такое n → сколько проходов →
цена операций внутри → отдельно память → худший случай.
Формально g(n) = O(f(n)), если существуют c > 0 и n₀
такие, что g(n) ≤ c·f(n) при всех n ≥ n₀. То есть O
читается как «не хуже, чем», Ω как «не лучше, чем», Θ как «ровно
такого порядка». На собесе почти всегда говорят «O», подразумевая Θ; проговорить
разницу одной фразой уже плюс.
Пространственная сложность
Считается дополнительная (auxiliary) память, то есть всё, что алгоритм выделяет сверх
входных данных. Про что забывают: глубина рекурсии (каждый кадр стека занимает память), мапы
для мемоизации, промежуточные слайсы от append, копия строки при
[]rune(s). Фраза «решение in-place, O(1) дополнительной памяти» сразу показывает,
что ты понимаешь разницу.
Как это звучит на собесе
func twoSum(nums []int, target int) (int, int) {
seen := make(map[int]int, len(nums))
for i, v := range nums {
if j, ok := seen[target-v]; ok {
return j, i
}
seen[v] = i
}
return -1, -1
}
// twoSum([]int{2, 7, 11, 15}, 9) → 0, 1
// twoSum([]int{1, 2}, 100) → -1, -1
«n здесь длина nums. Один проход, внутри одна проверка мапы и одна запись, обе
амортизированно O(1). Итого O(n) по времени. По памяти мапа, в худшем случае на все n
элементов, то есть O(n). Худший случай по времени формально O(n²), если все ключи дадут
коллизии, но в Go хеш рандомизирован сидом, так что практически это O(n).»
Шкала, которую надо чувствовать
| Сложность | n = 10⁶ - это | Типичный источник |
|---|---|---|
| O(1) | мгновенно | доступ по индексу, мапа, вершина стека |
| O(log n) | ~20 шагов | бинарный поиск, куча, сбалансированное дерево |
| O(n) | ~10⁶ - миллисекунды | один проход, два указателя, sliding window |
| O(n log n) | ~2·10⁷ - десятки мс | сортировка, «n раз по log n» через кучу |
| O(n²) | 10¹² - часы | вложенные циклы, конкатенация строк в цикле |
| O(2ⁿ) | часы при n = 40, невозможно к n = 50–60 | наивная рекурсия без мемоизации, перебор подмножеств |
Если интервьюер называет ограничение, оно подсказывает целевую сложность. Увидел
n ≤ 10⁵ - ждут O(n) или O(n log n). При n ≤ 10³ пройдёт и O(n²).
А n ≤ 20 обычно значит, что решение и есть экспоненциальный перебор. Проговорить
это вслух («раз n до 10⁵, квадрат не пройдёт, значит нужен хеш или два указателя») уже
половина успеха, даже если код потом получится не с первого раза.
append в конец - амортизированно O(1), но конкретный вызов может стоить O(n) из-за
роста массива.
Слайс устроен как заголовок из трёх слов (ptr, len, cap)
над непрерывным массивом. Отсюда всё поведение:
| Операция | Сложность | Почему |
|---|---|---|
s[i] | O(1) | адрес = ptr + i·sizeof(T), плюс bounds check |
| поиск значения | O(n) | линейный перебор; O(log n) только если отсортирован |
append(s, v) | O(1) амортизированно | обычно запись в свободный слот; при len == cap - новый массив и копирование |
| вставка в середину | O(n) | надо сдвинуть хвост |
| удаление по индексу | O(n) | slices.Delete = сдвиг хвоста влево |
| удаление без сохранения порядка | O(1) | s[i] = s[len(s)-1]; s = s[:len(s)-1] |
copy(dst, src) | O(n) | memmove, но с отличной константой |
Что такое амортизация
Амортизированная сложность означает среднюю стоимость операции в длинной серии, а не
среднее по случайным входам. Когда cap исчерпан, growslice выделяет новый
массив (примерно вдвое больше для небольших слайсов, с плавным коэффициентом, стремящимся к
1.25, после 256 элементов) и копирует туда всё, а это O(n). Но такое случается тем реже, чем
больше слайс: суммарное копирование при n вставках складывается в геометрическую прогрессию
n + n/2 + n/4 + … < 2n, то есть O(n) на всю серию, O(1) на операцию.
«Значит, append всегда O(1)?» - Нет. Амортизированно O(1), но конкретный вызов,
попавший на рост, стоит O(n). Для latency-чувствительного кода это разница между p50 и p99,
поэтому вместимость выделяют заранее: make([]T, 0, n). Второй вопрос-добивка:
«а если делать append в цикле по n элементам без preallocate, какая сложность?»
Всё равно O(n) суммарно, но с несколькими лишними аллокациями и копированиями, и с нагрузкой
на GC.
Типичный антипаттерн выглядит так: цикл по слайсу и
s = append(s[:i], s[i+1:]...) для каждого «плохого» элемента. Это O(n²) плюс
сломанная индексация (после удаления следующий элемент оказывается на том же i).
Правильнее фильтровать «на месте» одним проходом за O(n):
out := s[:0]; for _, v := range s { if keep(v) { out = append(out, v) } }; s = out.
В Go 1.21+ то же самое делает slices.DeleteFunc, а с Go 1.22 он ещё и сам зануляет
освободившийся хвост. В ручном варианте, если элементы указатели, обнули хвост сам, иначе он
держит объекты и мешает GC.
Откуда берётся O(1)
Хеш-функция раскидывает ключи по бакетам примерно равномерно, а load factor (в старой мапе 6.5 элементов на бакет из 8 слотов) ограничивает длину линейного поиска внутри бакета константой. При превышении фактора таблица удваивается, и данные переезжают инкрементально, по паре бакетов на каждую запись или удаление, чтобы не было длинной паузы. В Go 1.24 мапу переписали на Swiss Tables: группа из 8 слотов с байтами-метаданными, которые сканируются одним махом: на amd64 это SIMD-инструкция, на остальных - трюк с 64-битным словом. Асимптотика та же, константа и память лучше.
Откуда берётся O(n)
- Патологические коллизии. Если все ключи дают один и тот же бакет, поиск вырождается в перебор. В Go это нельзя вызвать снаружи: каждая мапа при создании получает случайный seed, поэтому одна и та же последовательность ключей на разных запусках раскладывается по-разному, и это защита от HashDoS.
- Плохой хеш у своего типа ключа. Только для языков, где хеш пишет пользователь. В Go хеш для comparable-типов генерирует компилятор, так что испортить его ты не можешь, зато можешь выбрать тяжёлый ключ (длинная строка, большая структура).
Скрытые расходы, о которых стоит сказать
m := map[string]int{}
m[longKey] = 1 // хеш считается по всем байтам ключа: O(len(key))
// при коллизии ещё и побайтовое сравнение строк
// Мапу нельзя обойти в порядке ключей: порядок итерации намеренно рандомизирован
for k := range m { ... } // порядок каждый раз другой
// Отсортированный обход стоит O(n log n):
keys := slices.Sorted(maps.Keys(m)) // Go 1.23: итератор + сортировка
«Амортизированно O(1) в среднем при равномерном хеше и ограниченном load factor; худший случай O(n) при вырожденных коллизиях. Плюс стоимость самого хеширования, которая для строкового ключа линейна по длине ключа, а не константна. И мапа не даёт ни порядка, ни диапазонных запросов, за этим уже к дереву.»
delete помечает слот свободным, но выделенные бакеты остаются. Мапа, в которую
насыпали 10 млн ключей и потом всё удалили, продолжит держать память. Помогает только
пересоздать мапу (m = make(map[K]V)); встроенный clear(m) из Go 1.21
очищает содержимое, но тоже не возвращает бакеты аллокатору.
| Операция | Слайс | Двусвязный список |
|---|---|---|
| Доступ по индексу | O(1) | O(n) |
| Поиск по значению | O(n), но с идеальной локальностью | O(n), с промахом кэша на каждом шаге |
| Вставка в конец | O(1) аморт. | O(1) |
| Вставка в начало | O(n) | O(1) |
| Вставка/удаление по известному узлу | O(n) (сдвиг) | O(1) |
| Память на элемент | sizeof(T) | sizeof(T) + 2 указателя + заголовок аллокации |
| Аллокаций | одна на весь массив | одна на каждый узел |
Почему на практике список проигрывает
- Кэш. Процессор читает память линиями по 64 байта и умеет предсказывать последовательный доступ. Слайс из int даёт 8 значений на линию и работающий префетч. Список прыгает по случайному адресу на каждом шаге, префетч бесполезен. На обходе это 10–50× при одинаковой O(n).
- Аллокации и GC. Миллион узлов даёт миллион объектов, которые GC обязан
просканировать (они содержат указатели). Слайс из миллиона int остаётся одним объектом,
и для
[]intGC вообще не сканирует содержимое, потому что в типе нет указателей. - Оверхед.
container/listхранит значения вany, добавляя боксинг и косвенность.
Когда список всё-таки нужен
- LRU-кэш: мапа даёт узел за O(1), список переставляет его в голову за O(1). Слайсом так не сделать, мешает сдвиг O(n).
- Очередь задач с удалением из середины по хендлу (например, планировщик таймеров).
- Структуры, где нельзя инвалидировать указатели на элементы при росте: у слайса
appendможет переселить массив, и все&s[i]станут указывать на старую память.
«Асимптотика у списка местами лучше, но реальный код почти всегда быстрее на слайсе: та же O(n), но на порядок меньшая константа за счёт локальности и одной аллокации. Список беру, когда нужно O(1) удаление по уже известному узлу, то есть ровно в случае LRU.»
q = q[1:] работает, но не освобождает память впереди - нужен кольцевой буфер
с индексами по модулю cap.Стек
stack := []int{}
stack = append(stack, v) // push
top := stack[len(stack)-1] // peek
stack = stack[:len(stack)-1] // pop
// если T содержит указатели, обнули слот перед укорачиванием:
// stack[len(stack)-1] = nil
Очередь: почему наивный вариант плох
q = q[1:] двигает вперёд ptr и уменьшает len, но массив
под слайсом остаётся живым целиком, пока жив хоть один слайс на него. В цикле
enqueue/dequeue underlying array растёт монотонно: обработали миллион задач - держим память
под миллион, хотя в очереди три элемента. Формально это не «утечка» (память достижима), но
ведёт себя именно так.
Кольцевой буфер
Храним массив фиксированного размера плюс head и size. Хвост
вычисляется как (head + size) % cap, поэтому индексы «заворачиваются» через ноль
и память переиспользуется. Все четыре операции стоят O(1), рост амортизированно O(1).
type Queue[T any] struct {
buf []T
head, size int
}
func (q *Queue[T]) Push(v T) {
if q.size == len(q.buf) {
q.grow()
}
q.buf[(q.head+q.size)%len(q.buf)] = v
q.size++
}
func (q *Queue[T]) Pop() (T, bool) {
var zero T
if q.size == 0 {
return zero, false
}
v := q.buf[q.head]
q.buf[q.head] = zero // иначе буфер держит объект
q.head = (q.head + 1) % len(q.buf)
q.size--
return v, true
}
// var q Queue[string] (grow такой же, как у Deque выше)
// q.Push("a"); q.Push("b"); q.Push("c")
// Pop подряд: "a" "b" "c", затем ("", false)
Единственная тонкость в движении головы назад: head = (head - 1 + cap) % cap.
Без + cap получится отрицательный индекс, потому что в Go
-1 % 8 == -1 (остаток берёт знак делимого, как в C, а не как в Python).
Это ровно тот баг, который на лайвкодинге ищут глазами.
В BFS на графе из тысяч узлов q = q[1:] нормален, потому что жизнь очереди
коротка и память освободится вместе с ней. Кольцевой буфер нужен для долгоживущей очереди.
На собесе так и скажи: «в задаче обойдусь слайсом, в проде для долгоживущей очереди возьму
кольцевой буфер или буферизированный канал».
h = log n, у
вырожденного - n. В БД берут B-tree не из-за асимптотики, а потому что узел = страница диска:
ветвление в сотни раз, высота 4 вместо 30.BST
Инвариант: в левом поддереве ключи меньше корня, в правом больше. Поиск, вставка и удаление стоят O(h). Проблема в том, что h зависит от порядка вставки: если добавлять уже отсортированные данные (тот самый автоинкрементный ID), каждое новое значение уходит вправо, и дерево становится связным списком с O(n) на всё.
Лечат это самобалансирующиеся деревья: AVL (разница высот поддеревьев не больше 1;
строже балансирует, значит быстрее читает, но больше поворотов при записи) и
красно-чёрное (слабее балансирует, зато дешевле вставка; на нём построены
std::map в C++ и TreeMap в Java). В стандартной библиотеке Go
сбалансированного дерева нет, есть мапа и sort.
Почему B-tree в базах
- Читается всегда целая страница. СУБД читает диск и держит в буферном кэше страницы по 8–16 КБ. Читать один узел BST с двумя указателями - значит потратить целое чтение страницы ради пары байт.
- Коэффициент ветвления. В страницу 8 КБ влезают сотни ключей, поэтому у B-tree
ветвление ~100–500, а высота на миллиард строк всего 3–4. У BST на том же объёме
log₂ 10⁹ ≈ 30. Получается 30 обращений против 4. - B+tree и range scan. В B+tree данные лежат только в листьях, а листья связаны в
двусвязный список. Поэтому
WHERE x BETWEEN a AND bсводится к одному спуску и последовательному проходу по листьям, аORDER BYпо индексу вообще не требует сортировки. - Заполнение страниц и fillfactor. B-tree держит узлы заполненными минимум наполовину, что ограничивает фрагментацию и число расщеплений.
Отсюда же ответ на смежный вопрос из секции БД: «почему в Postgres по умолчанию B-tree, а не
hash-индекс?» Хеш даёт O(1) на точное равенство, но не умеет <,
BETWEEN, ORDER BY, префиксный LIKE 'abc%' и не
поддерживает составные ключи с частичным использованием. B-tree чуть медленнее на точечном
поиске, но покрывает все эти сценарии одной структурой.
B-tree платит за чтение случайными записями (обновление страницы на месте, WAL, расщепления). LSM-tree (RocksDB, Cassandra, ClickHouse частично) пишет только последовательно, накапливая данные в памяти и периодически сливая отсортированные уровни. Итог: LSM быстрее на записи и сжатии, B-tree выигрывает на чтении и предсказуемости latency. Добавишь это к ответу, и разговор перейдёт на уровень системного дизайна.
Устройство
Каждый узел хранит переходы по символам и флаг «здесь заканчивается слово». Слова с общим
префиксом делят один и тот же путь: «go», «gopher», «gorm» идут по общей ветке g → o
и расходятся дальше. Ключ не хранится целиком нигде, он «размазан» по рёбрам.
| Операция | Trie | Мапа | Отсортированный слайс |
|---|---|---|---|
| Точный поиск слова | O(L) | O(L) на хеш, но с лучшей константой | O(L·log n) |
| Все слова с префиксом | O(L + k), k - размер ответа | невозможно, только полный перебор O(n·L) | O(L·log n + k) |
| Лексикографический обход | O(n) естественным образом | нет | O(n) |
| Память | худшая: узлы и указатели | средняя | лучшая |
Где применяют
- Поисковые подсказки / автодополнение. Спуск по введённому префиксу за O(L), затем обход поддерева. Часто в узлах ещё хранят топ-k популярных продолжений, чтобы не обходить поддерево целиком.
- Роутинг HTTP.
httprouterиchiдержат сжатый trie (radix tree) и разбирают пути вида/users/:id/ordersза один проход по URL, без регулярок. - IP-маршрутизация (longest prefix match), словари T9, проверка орфографии, поиск запрещённых слов (Aho–Corasick это trie плюс суффиксные ссылки).
Главная проблема и её решение
Наивный trie расходует память дико: на каждое слово почти столько узлов, сколько в нём символов, а в каждом узле сидит мапа или массив на весь алфавит. Отсюда radix tree (он же patricia): цепочки узлов с одним ребёнком склеиваются в один узел с целой подстрокой на ребре. Для URL-роутера или IP-таблицы это сокращает число узлов на порядок.
«А чем trie лучше мапы, если у мапы тоже O(1)?» - Мапа не умеет префиксных запросов. Чтобы найти все ключи, начинающиеся на «go», по мапе придётся перебрать все n ключей. Плюс trie даёт лексикографический порядок бесплатно. Ответ «trie нужен ровно тогда, когда запросы по префиксу, а не по точному ключу» засчитают.
a[parent] ≤ a[child]. Min за O(1), вставка и извлечение за O(log n), построение из
готового массива за O(n).Механика
- Push: положить в конец массива и «всплывать» (sift up), меняясь местами с родителем, пока нарушается инвариант. Максимум log n обменов.
- Pop: минимум лежит в
a[0]. Забираем его, переносим последний элемент в корень, сокращаем длину и «топим» (sift down), меняясь с меньшим из детей. - Init: sift down для всех узлов с
n/2 - 1вниз до 0. Это O(n), а не O(n log n): у большинства узлов маленькая высота, и сумма высот всех узлов линейна. - Fix(i): элемент изменился, восстанавливаем инвариант из позиции i за O(log n). Именно так делают «изменить приоритет» в очереди с приоритетами.
container/heap: пакет даёт алгоритмы, хранилище - твоё
// Очередь с приоритетами по задачам
type Item struct {
Value string
Priority int
index int // позиция в куче, нужна для heap.Fix / heap.Remove
}
type PQ []*Item
func (pq PQ) Len() int { return len(pq) }
// Больший Priority = выше приоритет → max-heap: знак ">"
func (pq PQ) Less(i, j int) bool { return pq[i].Priority > pq[j].Priority }
func (pq PQ) Swap(i, j int) {
pq[i], pq[j] = pq[j], pq[i]
pq[i].index, pq[j].index = i, j // синхронизируем index
}
func (pq *PQ) Push(x any) {
it := x.(*Item)
it.index = len(*pq)
*pq = append(*pq, it)
}
func (pq *PQ) Pop() any {
old := *pq
n := len(old)
it := old[n-1]
old[n-1] = nil // помогаем GC
it.index = -1
*pq = old[:n-1]
return it
}
func main() {
pq := &PQ{}
heap.Init(pq)
heap.Push(pq, &Item{Value: "low", Priority: 1})
heap.Push(pq, &Item{Value: "high", Priority: 9})
top := (*pq)[0] // максимум за O(1), без извлечения
top.Priority = 0
heap.Fix(pq, top.index) // приоритет изменился, чиним за O(log n)
it := heap.Pop(pq).(*Item) // O(log n)
_ = it
}
heap.Push(h, x)≠h.Push(x). Твой метод только кладёт в конец; инвариант восстанавливает пакет. Вызвал напрямую - куча сломана иPopвернёт не минимум.Popдолжен забирать элемент с конца. До вызова твоегоPopпакет уже поменял местами корень с последним элементом. Если вернутьold[0], получишь не то и разрушишь кучу.Push/Popобъявляй на указателе (*PQ), потому что они меняют длину слайса.Len/Less/Swapхватит и на значении.
Top-k из большого потока (куча размера k даёт O(n log k) вместо сортировки за O(n log n) и без хранения всего в памяти), слияние k отсортированных потоков, алгоритм Дейкстры, планировщик таймеров (в рантайме Go таймеры лежат в четверичной куче), «ближайший дедлайн».
Представление
// Список смежности берут почти всегда
type Graph map[int][]int
// или, если вершины плотно занумерованы 0..V-1, быстрее и компактнее:
adj := make([][]int, V)
adj[u] = append(adj[u], v)
// Матрица смежности нужна на плотных графах с частыми проверками "есть ребро?"
m := make([][]bool, V)
for i := range m { m[i] = make([]bool, V) }
m[u][v] = true
Реальные графы (соцсеть, дорожная сеть, зависимости сервисов) разрежены: E ≪ V².
Матрица на 10⁵ вершин займёт 10¹⁰ ячеек, то есть неприменима. Поэтому по умолчанию список.
BFS: очередь, уровни, кратчайший путь
// Кратчайший путь в невзвешенном графе + восстановление маршрута
func ShortestPath(g Graph, from, to int) []int {
if from == to {
return []int{from}
}
prev := map[int]int{from: -1}
queue := []int{from}
for len(queue) > 0 {
u := queue[0]
queue = queue[1:]
for _, v := range g[u] {
if _, seen := prev[v]; seen {
continue
}
prev[v] = u // помечаем при добавлении, иначе дубли в очереди
if v == to {
// разворачиваем путь назад по prev
path := []int{to}
for x := u; x != -1; x = prev[x] {
path = append(path, x)
}
slices.Reverse(path)
return path
}
queue = append(queue, v)
}
}
return nil
}
// g := Graph{1: {2, 3}, 2: {4}, 3: {4, 5}, 4: {5}, 5: nil}
// ShortestPath(g, 1, 5) → [1 3 5] - два ребра, а не три через 2→4→5
// ShortestPath(g, 1, 1) → [1]
// ShortestPath(g, 5, 1) → [] (nil) - граф ориентированный, пути назад нет
Обход «по уровням» (сколько шагов до вершины) получается, если замерить длину очереди в начале
итерации: levelSize := len(queue), и сделать ровно столько извлечений. Тот же
приём, что и в задаче «BFS по уровням дерева».
DFS: рекурсия или явный стек
// Поиск цикла в ориентированном графе: три цвета
const (white = 0; grey = 1; black = 2)
func hasCycle(g Graph, V int) bool {
color := make([]int, V)
var dfs func(u int) bool
dfs = func(u int) bool {
color[u] = grey // вошли, ещё не вышли
for _, v := range g[u] {
if color[v] == grey { // ребро в "серую" вершину = обратное ребро = цикл
return true
}
if color[v] == white && dfs(v) {
return true
}
}
color[u] = black
return false
}
for u := 0; u < V; u++ {
if color[u] == white && dfs(u) {
return true
}
}
return false
}
// hasCycle(Graph{0: {1}, 1: {2}, 2: {0}}, 3) → true - 0→1→2→0
// hasCycle(Graph{0: {1}, 1: {2}}, 3) → false
- BFS: кратчайший путь по числу рёбер, «минимум шагов/ходов», уровни дерева, многоисточниковый BFS (сразу кладём в очередь несколько стартов, так решают «расстояние до ближайшего X»).
- DFS: связные компоненты, топологическая сортировка, поиск циклов, мосты и точки сочленения, backtracking (перебор с возвратом).
- Дейкстра = BFS + куча, когда рёбра взвешены неотрицательно. Просто BFS на взвешенном графе даёт неверный ответ, и это популярная добивка.
Пометить вершину посещённой при извлечении из очереди, а не при добавлении. Тогда одна
и та же вершина попадёт в очередь столько раз, сколько у неё входящих рёбер, и сложность
поедет в сторону O(V·E). Помечать надо ровно в момент queue = append(...).
sort.Slice и
slices.Sort нестабильны, стабильные - sort.Stable и
slices.SortStableFunc.Минимальный набор, который надо назвать
- Вставками работает за O(n²), но на почти отсортированных данных сваливается в O(n) и держит минимальную константу на коротких массивах. Поэтому живёт внутри всех промышленных гибридов.
- Слиянием даёт гарантированные O(n log n), стабильна, требует O(n) памяти. Основа внешней сортировки (когда данные не влезают в память) и легко распараллеливается.
- Быстрая: O(n log n) в среднем, O(n²) в худшем (отсортированный вход при наивном выборе опорного), in-place, лучшая константа.
- Пирамидальная гарантирует O(n log n) и O(1) памяти, нестабильна, константа хуже quicksort. Её держат как страховку от вырождения quicksort.
- Подсчётом / поразрядная обходит барьер O(n log n) за O(n + k), потому что не сравнивает элементы, а использует структуру ключа. Работает для целых в ограниченном диапазоне.
Почему O(n log n) - барьер
Любая сортировка сравнением - это дерево решений: каждое сравнение даёт один бит информации, а
различить нужно n! перестановок. Высота дерева не меньше
log₂(n!) ≈ n log₂ n − 1.44n. Отсюда нижняя оценка. Counting/radix её не нарушают -
они просто не сравнивают.
Что внутри Go
| Версия / функция | Алгоритм | Стабильна | Замечание |
|---|---|---|---|
sort.Sort, sort.Slice до Go 1.19 | introsort: quicksort + heapsort как fallback + insertion на коротких | нет | heapsort включался при глубине рекурсии > 2·log n |
| Go 1.19 и новее | pdqsort | нет | распознаёт отсортированные, обратные и «много дубликатов» входы, на них даёт O(n) |
sort.Stable, sort.SliceStable | insertion на блоках по 20 + symmerge | да | O(n log² n) в худшем, зато без буфера: O(log n) стека |
slices.Sort (Go 1.21+) | pdqsort на дженериках | нет | быстрее sort.Slice: нет reflect-based swap и вызова функции через интерфейс |
Стабильность
Сортировка стабильна, если элементы с равными ключами сохраняют исходный относительный порядок. Практический смысл - сортировка по нескольким полям в несколько проходов: сначала по имени, затем стабильно по отделу - и внутри отдела имена останутся отсортированными. Ещё стабильность важна для воспроизводимости вывода (диффы, отчёты).
// Порядок равных элементов у нестабильной сортировки не определён:
// сегодня он один, а после смены версии Go может стать другим.
// Нужен предсказуемый - бери стабильную:
slices.SortStableFunc(rows, func(a, b Row) int { return cmp.Compare(a.Dept, b.Dept) })
// Либо один компаратор на все ключи - тогда стабильность не нужна:
slices.SortFunc(rows, func(a, b Row) int {
if c := cmp.Compare(a.Dept, b.Dept); c != 0 {
return c
}
return cmp.Compare(a.Name, b.Name)
})
«Стабильность стоит либо O(n) памяти (merge), либо худшей асимптотики (symmerge). Поэтому по умолчанию в стандартных библиотеках нестабильная сортировка, а стабильную надо просить явно. Если сортирую по нескольким ключам - предпочитаю один составной компаратор: он быстрее двух проходов и не зависит от стабильности.»
[lo, hi): for lo < hi, lo = mid+1 или
hi = mid. Ответ - lo. Так не бывает ни зацикливания, ни выхода за границы.// Найти индекс target или -1
func Search(a []int, target int) int {
lo, hi := 0, len(a)-1
for lo <= hi {
mid := lo + (hi-lo)/2
switch {
case a[mid] == target:
return mid
case a[mid] < target:
lo = mid + 1
default:
hi = mid - 1
}
}
return -1
}
// Первая позиция, где a[i] >= target (lower bound). Универсальнее.
func LowerBound(a []int, target int) int {
lo, hi := 0, len(a)
for lo < hi {
mid := lo + (hi-lo)/2
if a[mid] < target {
lo = mid + 1
} else {
hi = mid
}
}
return lo
}
// Первая позиция, где a[i] > target (upper bound) - тот же код со строгим сравнением
// количество вхождений target = UpperBound(a, t) - LowerBound(a, t)
// a := []int{1, 3, 3, 5, 8}
// Search(a, 5) → 3 Search(a, 4) → -1
// LowerBound(a, 3) → 1 LowerBound(a, 4) → 3 LowerBound(a, 9) → 5
Где ошибаются
(lo+hi)/2вместоlo + (hi-lo)/2. На 64-битных платформахintв Go 64-битный, реально не переполнится, но это каноничная ошибка (в JDK жила девять лет), и её проверяют.lo < hiпри замкнутом отрезке[lo, hi]- пропускается последний кандидат, когдаlo == hi.lo = midвместоlo = mid + 1в полуинтервальной версии - приhi - lo == 1получаетсяmid == lo, отрезок не сокращается, бесконечный цикл.- Не спросили, отсортирован ли вход. Бинарный поиск требует монотонности - без неё ответ мусор.
- Забыли про дубликаты: «найти любой» и «найти первый» - разные задачи, и именно поэтому lower bound надёжнее.
Стандартная библиотека
i := sort.SearchInts(a, x) // lower bound; если не нашли - позиция вставки
i = sort.Search(len(a), func(i int) bool { return a[i] >= x })
idx, ok := slices.BinarySearch(a, x) // Go 1.21+
idx, ok = slices.BinarySearchFunc(users, u, cmpByID) // с компаратором
sort.Search(n, f) возвращает наименьшее i из [0, n), для которого f(i) == true
(или n, если такого нет), и требует, чтобы f была монотонной:
false…false,true…true. Немонотонный предикат не вызовет паники - просто вернёт неверный результат.
sort.Search ищет границу в любом монотонном пространстве. Это приём
«бинарный поиск по ответу»: минимальный размер батча, при котором укладываемся в SLA;
минимальное число воркеров, при котором очередь не растёт; максимальный лимит, при котором
сервис не падает. Формулировка «здесь предикат монотонный, значит применим бинпоиск по
ответу за O(log(range) · cost)» на собесе звучит очень зрело.
Базовая механика
- Считаем
h = hash(key). - Младшие биты
hдают номер бакета:bucket = h & (2^B - 1). - Внутри бакета ищем слот. Чтобы не сравнивать ключи целиком, сначала сверяем короткий
«отпечаток» хеша (в старой мапе это
tophash, старшие 8 бит; в Swiss Tables - control byte с 7 младшими битами). Полное сравнение ключей - только при совпадении отпечатка.
Коллизии: две школы
| Цепочки (chaining) | Открытая адресация | |
|---|---|---|
| Где хранится «лишнее» | в связанном overflow-бакете | в соседних слотах того же массива |
| Локальность | хуже: прыжок по указателю | лучше: всё в одной кэш-линии |
| Допустимый load factor | может быть > 1 | обычно до 0.7–0.87 |
| Удаление | простое | нужны tombstone-метки |
| Где в Go | мапа до 1.23 включительно | Swiss Tables с Go 1.24 |
Что специфично для Go
- Рандомизация хеша. Seed свой у каждой мапы и генерируется при её создании, поэтому одна и та же последовательность ключей раскладывается по-разному на разных запусках. Это защита от HashDoS и одновременно причина, по которой порядок итерации нельзя считать стабильным (плюс рантайм ещё и стартует итерацию со случайного бакета).
- Инкрементальный рост. В старой мапе (до 1.24) при превышении load factor выделялся вдвое больший массив, но перенос делался по чуть-чуть на каждой записи и удалении (evacuate), чтобы не было длинной паузы на одной операции. В Swiss Tables мапа разбита на таблицы не больше 1024 слотов, и каждая растёт целиком за раз: пауза есть, но ограничена размером одной таблицы.
- Рост «вширь» без увеличения размера. Если элементов немного, а overflow-бакетов развелось много (последствия массовых удалений), старая мапа делала same-size grow - просто уплотнялась. В Swiss Tables overflow-бакетов нет, и такого роста тоже.
- Мапа не потокобезопасна и специально ловит конкурентную запись:
fatal error: concurrent map writes- это не паника, её нельзя перехватить черезrecover. - Значения мапы неадресуемы:
&m[k]запрещено, аm[k].field = vдля структуры не скомпилируется - потому что при росте бакеты переезжают и адрес был бы недействителен. Обходной путь -map[K]*Vили «прочитал, изменил, записал обратно».
Таблица разбита на группы по 8 слотов, а рядом лежит массив control-байтов - по одному на слот, в каждом 7 бит хеша плюс метка «пусто/удалено». Поиск сравнивает контрольный байт сразу со всеми восемью байтами группы разом - на amd64 SIMD-инструкцией, на прочих арифметикой по 64-битному слову - и получает битовую маску кандидатов. Результат: меньше промахов кэша, меньше памяти на элемент, заметно быстрее поиск на больших мапах. Асимптотика прежняя, но упомянуть эту деталь на собесе в 2025–2026 - сильный ход.
Что стоит запомнить
- Амортизированная стоимость append усредняет дорогие расширения по серии операций.
- Бинарный поиск требует отсортированного входа и аккуратно заданного интервала.
- Структуру выбирают по нужным операциям и их частоте.
Частые ошибки
- O(1) для map - ожидаемая, а не безусловная цена любого сценария.
- Средний случай и амортизированная сложность отвечают на разные вопросы.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Найди позицию lower bound
Что напечатает программа для отсортированного массива и x=5? Какова сложность поиска?
package main
import "fmt"
func lowerBound(a []int, x int) int {
lo, hi := 0, len(a)
for lo < hi {
mid := lo + (hi-lo)/2
if a[mid] < x { lo = mid+1 } else { hi = mid }
}
return lo
}
func main() { fmt.Println(lowerBound([]int{1,3,5,7,9}, 5)) }
Ответ и разбор
Программа напечатает 2: lower bound возвращает первый индекс со значением не меньше x. Интервал полуоткрытый [lo, hi), поэтому поиск безопасен и когда позиция равна длине слайса. Время O(log n), дополнительная память O(1).
Код-ревью
2. Исправь бинарный поиск за последним элементом
Функция заявлена как exact search, но по коду ищет первую позицию не меньше target. При [2, 4, 6] и цели 9 вернёт индекс 2, хотя совпадения нет. Исправь границы для insertion point в диапазоне от 0 до len(a) и отдельно опиши контракт exact search.
func lowerBound(a []int, target int) int {
lo, hi := 0, len(a)-1
for lo < hi {
mid := lo + (hi-lo)/2
if a[mid] < target { lo = mid+1 } else { hi = mid }
}
return lo
}
Ответ и разбор
Для insertion point используй полуоткрытый интервал: lo, hi := 0, len(a), цикл пока lo < hi, при a[mid] < target двигай lo = mid + 1, иначе hi = mid. Тогда результат 3 для target 9, и массив не читается по индексу len(a). Exact search поверх этого результата обязан проверить i < len(a) && a[i] == target; иначе найденной позиции нет.
Применение
3. Выбери очередь с максимумом
Нужно многократно добавлять числа и забирать наибольшее. Сравни отсортированный слайс и max-heap по стоимости вставки и извлечения.
Ответ и разбор
У отсортированного слайса вставка требует O(n) сдвигов, а чтение максимума O(1). Max-heap даёт O(log n) на вставку и извлечение максимума, но O(1) только на просмотр корня. При частых извлечениях heap обычно лучше; при редких вставках и большом числе чтений массива отсортированный слайс может быть проще.
Применение
4. Оцени серию append
Слайс растёт от пустого через 1000 вызовов append. Один append иногда копирует весь backing array. Почему из одного дорогого вызова нельзя заключить, что серия имеет O(n²) времени?
Ответ и разбор
Расширения происходят не при каждом вызове, а при исчерпании capacity. Если ёмкость растёт геометрически, дорогие копирования редки, и суммарный объём копий за серию пропорционален числу добавлений. Поэтому append имеет амортизированную O(1) стоимость, хотя отдельный вызов может быть O(n).