voidlearn
 

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 / maxO(n)O(n)O(n)O(log n)O(1) корень-
Обход в порядке сортировкиO(n log n) - надо отсортироватьневозможно, порядок случайныйO(n log n)O(n) in-orderO(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, память переиспользуется.

Кольцевой буфер: буфер cap=8, в нём 5 элементов C D E F G 0 1 2 3 4 5 6 7 head = 2 tail = (head+size) % 8 = 7 После двух Push и трёх Pop: индексы «завернулись» через ноль I F G H head = 5 tail = (5+4) % 8 = 1 конец массива переходит в начало: индекс считается по модулю cap Push: buf[(head+size) % cap] = v; size++. Pop: v := buf[head]; buf[head] = zero; head = (head+1) % cap; size-- При size == cap - выделить массив вдвое больше и переложить элементы в него подряд, начиная с head. Амортизированно O(1).
Кольцевой буфер. Голова и хвост движутся по кругу, поэтому память переиспользуется и очередь не растёт бесконечно, в отличие от наивного 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 почти никогда не нужен

В стандартной библиотеке есть 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.

BST: 1 ключ в узле → высота log2(n) ≈ 30 при n = 10^9 → до 30 чтений страниц 50 25 75 12 37 каждая стрелка = случайный доступ, почти наверняка промах кэша B+tree: узел = страница 8 КБ ≈ сотни ключей → высота 4 при n = 10^9 k1 | k2 | k3 | … | k400 лист: ключи + ссылки лист: ключи + ссылки лист: ключи + ссылки листья связаны в список - отсюда дешёвый range scan и ORDER BY по индексу
BST против B+tree. Асимптотика одинаковая, а число обращений к диску отличается на порядок: у B-tree высокий коэффициент ветвления, потому что узел равен странице.

В trie (префиксное дерево, произносится «трай») путь от корня задаёт префикс ключа. Поиск слова стоит O(L) при длине слова L и не зависит от числа слов в словаре. Это его главное отличие от мапы и дерева поиска: сложность привязана к длине ключа, а не к размеру коллекции. Отсюда классическое применение: поисковые подсказки и автодополнение. Спускаемся по префиксу за O(L), а дальше обходим поддерево и собираем варианты.

root g r o * конец слова: "go" p r h → e → r * "gopher" m * "gorm" u → s → t * "rust" Подсказки по префиксу "go": спуск g → o за O(2), затем DFS по поддереву - выдаёт "go", "gopher", "gorm" в лексикографическом порядке. Сложность поиска - O(L), не зависит от размера словаря. Цена - память: узел на каждый символ, поэтому в проде это radix/patricia-дерево со сжатыми цепочками.
Trie. Общие префиксы хранятся один раз, поэтому автодополнение сводится к спуску по префиксу и обходу поддерева. В Go на сжатом trie построен, например, роутер httprouter.
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), и этот неочевидный факт хорошо звучит на собесе.

Одна и та же min-heap: как дерево и как массив 1 i=0 3 i=1 6 i=2 5 i=3 9 i=4 8 i=5 Родитель никогда не больше ребёнка. Порядок между братьями не определён. 1 3 6 5 9 8 свободно 0 1 2 3 4 5 left(i) = 2i+1 right(i) = 2i+2 parent(i) = (i-1)/2
Куча. Дерево существует только как способ смотреть на массив: указателей нет, переход к ребёнку и родителю считается арифметикой по индексу. Отсюда отличная локальность и O(1) на min.

В стандартной библиотеке 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×Vmap[int][]int или [][]int
ПамятьO(V²) всегдаO(V + E)
Есть ли ребро u→vO(1)O(deg(u))
Перебрать соседей uO(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 и DFS

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). Слева от lo - все меньше target, справа от hi - все не меньше заведомо a[i] < target не знаем - здесь и ищем заведомо a[i] >= target lo hi Шаг: mid = lo + (hi-lo)/2 mid a[mid] < target → lo = mid + 1 (mid точно не ответ, отрезаем его) a[mid] >= target → hi = mid (mid может быть ответом, оставляем) Ровно эта асимметрия (+1 и без +1)
Полуинтервал [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))
Четыре ошибки, которые делают почти все
  1. Переполнение (lo+hi)/2. На 64-битных платформах int в Go 64-битный, и на практике не переполнится, но привычка писать lo + (hi-lo)/2 ничего не стоит, и её ждут.
  2. Путаница < и <=. Для [lo, hi] нужно lo <= hi, для [lo, hi) - lo < hi. Смешал и либо пропустил элемент, либо вышел за границу.
  3. Зацикливание. Если в полуинтервальной версии написать lo = mid вместо lo = mid + 1, то при hi - lo == 1 получится mid == lo и отрезок перестанет сокращаться: цикл повиснет навсегда.
  4. Забыли отсортированность. Бинарный поиск требует монотонного предиката. На собесе обязательно спроси: «слайс отсортирован?»
// В проде руками писать не надо
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 константа решает, и в стандартной библиотеке это учтено порогами», ответ заметно выделяется.

Вопросы

12
Суть: Big O - верхняя оценка скорости роста при n → ∞ с точностью до константы. Вслух это оценивают по схеме: что такое 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⁵, квадрат не пройдёт, значит нужен хеш или два указателя») уже половина успеха, даже если код потом получится не с первого раза.

Суть: доступ O(1), поиск O(n), вставка/удаление в середину O(n), 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) в среднем и O(n) в худшем случае, когда все ключи схлопнулись в один бакет. Плюс скрытая цена: хеш ключа считается по всему ключу, для строк это O(len(key)).

Откуда берётся 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) поиск этого узла и катастрофическая локальность памяти. Слайс выигрывает почти везде, кроме LRU-подобных сценариев.
ОперацияСлайсДвусвязный список
Доступ по индексу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 остаётся одним объектом, и для []int GC вообще не сканирует содержимое, потому что в типе нет указателей.
  • Оверхед. 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)
Дек - тот же буфер плюс PushFront

Единственная тонкость в движении головы назад: head = (head - 1 + cap) % cap. Без + cap получится отрицательный индекс, потому что в Go -1 % 8 == -1 (остаток берёт знак делимого, как в C, а не как в Python). Это ровно тот баг, который на лайвкодинге ищут глазами.

Когда очередь нужна «просто чтобы работало»

В BFS на графе из тысяч узлов q = q[1:] нормален, потому что жизнь очереди коротка и память освободится вместе с ней. Кольцевой буфер нужен для долгоживущей очереди. На собесе так и скажи: «в задаче обойдусь слайсом, в проде для долгоживущей очереди возьму кольцевой буфер или буферизированный канал».

Суть: BST даёт O(h); у сбалансированного 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 чуть медленнее на точечном поиске, но покрывает все эти сценарии одной структурой.

Глубже: LSM-tree как альтернатива

B-tree платит за чтение случайными записями (обновление страницы на месте, WAL, расщепления). LSM-tree (RocksDB, Cassandra, ClickHouse частично) пишет только последовательно, накапливая данные в памяти и периодически сливая отсортированные уровни. Итог: LSM быстрее на записи и сжатии, B-tree выигрывает на чтении и предсказуемости latency. Добавишь это к ответу, и разговор перейдёт на уровень системного дизайна.

Суть: дерево, где путь от корня - это префикс ключа. Поиск O(L) по длине ключа и не зависит от размера словаря. Плата - память: узел на каждый символ.

Устройство

Каждый узел хранит переходы по символам и флаг «здесь заканчивается слово». Слова с общим префиксом делят один и тот же путь: «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
}
Три ловушки container/heap
  1. heap.Push(h, x) ≠ h.Push(x). Твой метод только кладёт в конец; инвариант восстанавливает пакет. Вызвал напрямую - куча сломана и Pop вернёт не минимум.
  2. Pop должен забирать элемент с конца. До вызова твоего Pop пакет уже поменял местами корень с последним элементом. Если вернуть old[0], получишь не то и разрушишь кучу.
  3. Push/Pop объявляй на указателе (*PQ), потому что они меняют длину слайса. Len/Less/Swap хватит и на значении.
Где куча реально нужна

Top-k из большого потока (куча размера k даёт O(n log k) вместо сортировки за O(n log n) и без хранения всего в памяти), слияние k отсортированных потоков, алгоритм Дейкстры, планировщик таймеров (в рантайме Go таймеры лежат в четверичной куче), «ближайший дедлайн».

Суть: матрица - O(V²) памяти и O(1) на проверку ребра; список - O(V+E) памяти и O(deg) на перебор соседей, и на практике почти всегда берут список. BFS и DFS оба O(V+E), но BFS даёт кратчайший путь в невзвешенном графе.

Представление

// Список смежности берут почти всегда
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 на взвешенном графе даёт неверный ответ, и это популярная добивка.
Классическая ошибка в BFS

Пометить вершину посещённой при извлечении из очереди, а не при добавлении. Тогда одна и та же вершина попадёт в очередь столько раз, сколько у неё входящих рёбер, и сложность поедет в сторону O(V·E). Помечать надо ровно в момент queue = append(...).

Суть: сравнением быстрее O(n log n) нельзя. Go с версии 1.19 использует pdqsort (pattern-defeating quicksort), до этого - introsort. 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.19introsort: quicksort + heapsort как fallback + insertion на короткихнетheapsort включался при глубине рекурсии > 2·log n
Go 1.19 и новееpdqsortнетраспознаёт отсортированные, обратные и «много дубликатов» входы, на них даёт O(n)
sort.Stable, sort.SliceStableinsertion на блоках по 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

Где ошибаются

  1. (lo+hi)/2 вместо lo + (hi-lo)/2. На 64-битных платформах int в Go 64-битный, реально не переполнится, но это каноничная ошибка (в JDK жила девять лет), и её проверяют.
  2. lo < hi при замкнутом отрезке [lo, hi] - пропускается последний кандидат, когда lo == hi.
  3. lo = mid вместо lo = mid + 1 в полуинтервальной версии - при hi - lo == 1 получается mid == lo, отрезок не сокращается, бесконечный цикл.
  4. Не спросили, отсортирован ли вход. Бинарный поиск требует монотонности - без неё ответ мусор.
  5. Забыли про дубликаты: «найти любой» и «найти первый» - разные задачи, и именно поэтому 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)» на собесе звучит очень зрело.

Суть: массив бакетов + хеш-функция, разрешение коллизий цепочками или открытой адресацией, рост при превышении load factor. В Go до 1.23 - бакеты по 8 слотов с overflow-цепочками и инкрементальным ростом; с Go 1.24 - Swiss Tables.

Базовая механика

  1. Считаем h = hash(key).
  2. Младшие биты h дают номер бакета: bucket = h & (2^B - 1).
  3. Внутри бакета ищем слот. Чтобы не сравнивать ключи целиком, сначала сверяем короткий «отпечаток» хеша (в старой мапе это 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 или «прочитал, изменил, записал обратно».
Глубже: чем хороши Swiss Tables (Go 1.24)

Таблица разбита на группы по 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).