Тема 01

Алгоритмы и лайвкодинг

В РФ на Go-мидла редко дают hard с LeetCode. Дают easy / easy-medium, но смотрят не на «решил или нет», а на то, как ты думаешь вслух, знаешь ли идиомы Go (руны, слайсы, каналы) и умеешь ли назвать сложность своего решения без подсказки. Отдельный жанр, который встречается почти всегда, — конкурентный лайвкодинг: worker pool, fan-in, семафор, свой кэш.

Что реально проверяют этой секцией

Три вещи. Первое — умеешь ли ты превратить размытую формулировку в контракт: что на входе, что на выходе, что с пустым входом и дубликатами. Второе — базовую алгоритмическую грамотность: хеш-таблица вместо вложенного цикла, два указателя вместо копирования, куча вместо полной сортировки. Третье — Go как таковой: что s[i] в строке даёт байт, что append может испортить чужой массив, что горутину надо кому-то остановить. Идеальный кандидат проговаривает решение до того, как начал печатать, и сам называет O-оценку.

1.1Теория: сложность и структуры данных

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

Сначала — четыре слова, которые дальше идут без предупреждения

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

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. Стабильность сортировки — «равные элементы не перетасовываются»

Сортировка стабильна, если элементы с равными ключами остаются в том же порядке друг относительно друга, в каком были во входных данных. Если сортировать список [Аня 30, Боря 25, Вера 30] по возрасту, стабильная сортировка обязана вернуть Боря, Аня, Вера, потому что Аня была раньше Веры и осталась раньше. Нестабильная имеет право вернуть Боря, Вера, Аня: ключи-то равны, а про порядок внутри равных она ничего не обещала.

На практике это даёт сортировку по нескольким полям в несколько проходов: сначала по имени, потом стабильно по возрасту, и внутри каждого возраста имена останутся упорядоченными. Без стабильности приходится писать один составной компаратор. Платят за неё либо дополнительной памятью, либо более медленным алгоритмом, поэтому по умолчанию сортировки в Go нестабильны.

3. Префиксное дерево (trie) — «по буквам, а не по ключу целиком»

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

4. «Куча» — это два совершенно разных слова

Самая коварная ловушка терминологии в 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 её не соберёт: жив весь массив целиком. Для очереди на миллион элементов это миллион «дырок». Правильный ответ даёт кольцевой буфер (ring buffer): индексы ходят по кругу через % 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. Он проигрывает слайсу на всём, кроме «удалить узел, который уже на руках»: каждый элемент требует отдельной аллокации, значения лежат в интерфейсе (упаковка, boxing: число или структура не хранится напрямую, а кладётся в пару «тип + указатель на значение»), а обход собирает сплошные промахи кэша. Применений ровно два: LRU-кэш (least recently used: кэш, который при переполнении выбрасывает элемент, к которому дольше всего не обращались; там нужно именно «переставить известный узел в голову», разбор в 1.2) и учебные задачи. Для очереди бери кольцевой буфер, для стека слайс.

Хеш-таблица: как устроена и когда ломается

Мапа в Go устроена как хеш-таблица. До Go 1.23 включительно это были классические бакеты по 8 слотов с «расширениями» (overflow buckets); с Go 1.24 внутренности заменили на Swiss Tables: группы по 8 слотов, метаданные-контрольные байты, поиск группы одной SIMD-операцией. Меняется константа и расход памяти, асимптотика остаётся прежней.

Три слова, которые дальше используются как общеизвестные. Бакет (bucket, «корзина») занимает одну ячейку внутреннего массива таблицы; в неё складывают все ключи, у которых совпал «номер корзины». Слот держит одну пару ключ-значение внутри бакета; в мапе Go их в бакете восемь. Load factor («коэффициент заполнения») считает среднее число элементов на бакет: чем он выше, тем плотнее набиты корзины и тем длиннее линейный поиск внутри них. Когда load factor перерастает порог, таблица растёт вдвое, и всё раскладывается заново.

  • Идея. hash(key) → номер бакета. Внутри бакета идёт линейный поиск среди нескольких слотов. Средняя стоимость O(1), потому что число слотов ограничено load factor.
  • Коллизии. Две стратегии: цепочки (chaining, в старой мапе Go это overflow-бакеты) и открытая адресация (open addressing, в Swiss Tables пробирование внутри группы).
  • Load factor. В старой мапе порог стоял на 6.5 элемента на бакет из 8 слотов; при превышении таблица удваивается и данные раскладываются заново инкрементально, по чуть-чуть на каждой операции.
  • Худший случай O(n). Если все ключи попали в один бакет. В Go это не воспроизвести извне: при старте процесса рантайм берёт случайный seed хеша, поэтому HashDoS-атака на мапу не работает. Но на собесе назвать O(n) как худший случай обязательно.

Деревья: BST, почему на практике B-tree, и trie

BST (binary search tree): в левом поддереве ключи меньше, в правом больше. Поиск, вставка и удаление стоят 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 (pattern-defeating quicksort): та же схема плюс распознавание «плохих» паттернов, то есть уже отсортированных данных, обратного порядка, массы дубликатов. На них pdqsort даёт O(n) вместо квадратичной деградации. Стабильная сортировка (sort.Stable, slices.SortStableFunc) собрана из insertion sort на блоках и symmerge: O(n log² n) в худшем, зато O(1) дополнительной памяти.

// Современный способ (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)
})
Стабильность на практике: колонка «Стабильна» в таблице выше

Напомню определение из начала главы: сортировка стабильна, если элементы с равными ключами сохраняют исходный относительный порядок. Практический смысл один: многоключевая сортировка «в два прохода». Сначала по имени, потом стабильно по возрасту, и внутри каждого возраста имена останутся упорядоченными. С нестабильной так нельзя, надо писать составной компаратор. 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. В Go int 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 линейный поиск обгоняет бинарный на коротких массивах, примерно до десятка элементов: бинарный делает непредсказуемые переходы (branch misprediction) и прыгает по памяти, а линейный идёт по кэш-линиям и векторизуется. Именно поэтому в 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. Если элементы указатели, обнули ещё и хвост, иначе он держит объекты и мешает 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(1) доп. памяти
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. В Go int 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. Внутри бакета ищем слот. Чтобы не сравнивать ключи целиком, сначала сверяем короткий «отпечаток» хеша (старшие 8 бит — tophash в старой мапе, control byte в Swiss Tables). Полное сравнение ключей — только при совпадении отпечатка.

Коллизии: две школы

Цепочки (chaining)Открытая адресация
Где хранится «лишнее»в связанном overflow-бакетев соседних слотах того же массива
Локальностьхуже: прыжок по указателюлучше: всё в одной кэш-линии
Допустимый load factorможет быть > 1обычно до 0.7–0.87
Удалениепростоенужны tombstone-метки
Где в Goмапа до 1.23 включительноSwiss Tables с Go 1.24

Что специфично для Go

  • Рандомизация хеша. Seed генерируется при старте процесса, поэтому одна и та же последовательность ключей раскладывается по-разному на разных запусках. Это защита от HashDoS и одновременно причина, по которой порядок итерации нельзя считать стабильным (плюс рантайм ещё и стартует итерацию со случайного бакета).
  • Инкрементальный рост. При превышении load factor выделяется вдвое больший массив, но перенос делается по чуть-чуть на каждой записи (evacuate), чтобы не было длинной паузы на одной операции.
  • Рост «вширь» без увеличения размера. Если элементов немного, а overflow-бакетов развелось много (последствия массовых удалений), мапа делает same-size grow — просто уплотняется.
  • Мапа не потокобезопасна и специально ловит конкурентную запись: 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 — сильный ход.

1.2Типовые задачи easy / easy-medium

Реальный пул задач на мидла в РФ сводится к полутора десяткам сюжетов, которые кочуют из компании в компанию. Их надо не «уметь решить», а уметь написать за 10 минут, вслух назвать сложность и не наступить на грабли Go: руны, общий underlying array, копия в range.

Протокол лайвкодинга: что делать до первой строчки кода

Проваливают эту секцию чаще всего не потому, что не знают алгоритм, а потому, что молча пишут двадцать минут, а потом код не компилируется. Работающая последовательность:

  1. Уточнить контракт. Что на входе (отсортировано? уникальны? может быть пусто?), что на выходе (индексы или значения? порядок важен?), что при отсутствии ответа (вернуть -1, ok bool, ошибку?). Один вопрос про пустой вход и один про дубликаты уже идут в плюс к оценке.
  2. Проговорить наивное решение и его O. «Можно в лоб двумя циклами за O(n²)» никого не отпугнёт: это база, от которой ты отталкиваешься.
  3. Назвать улучшение и чем платишь. «Заменю внутренний цикл мапой: станет O(n) по времени, но появится O(n) памяти». Компромисс время/память надо озвучить самому.
  4. Согласовать сигнатуру и только потом писать тело.
  5. Прогнать руками на маленьком примере, вслух: «n = [2,7,11], target = 9 — на i = 0 в мапе пусто, кладём 2; на i = 1 ищем 2, нашли, возвращаем 0 и 1».
  6. Перечислить краевые случаи: пустой слайс, один элемент, все одинаковые, отрицательные числа, переполнение, юникод вне ASCII.
Правило, которое экономит собес

Пиши без библиотеки, но идиоматично. Никто не ждёт sort.Search наизусть, но make([]int, 0, n) вместо var s []int, strings.Builder вместо конкатенации в цикле и []rune вместо s[i] выдают человека, который на Go пишет каждый день, а не переводит с Python.

Каталог паттернов: как по формулировке узнать решение

Что в условииПаттернВремя / память
«найти пару / есть ли такой элемент / посчитать вхождения»хеш-мапа как индекс или множествоO(n) / O(n)
«вход отсортирован», «пара с суммой», «слить», «убрать дубликаты на месте»два указателяO(n) / O(1)
«подряд идущие», «подмассив / подстрока длины k», «самая длинная подстрока без…»скользящее окноO(n) / O(k)
«скобки», «вложенность», «отменить последнее»стек на слайсеO(n) / O(n)
«интервалы», «встречи», «объединить диапазоны»сортировка по началу + жадный проходO(n log n) / O(n)
«k самых больших / маленьких», «топ-k из потока»куча размера k или quickselectкуча O(n log k) / O(k), quickselect O(n) в среднем
«дерево», «граф», «уровни», «кратчайший путь без весов»DFS (рекурсия/стек) или BFS (очередь)O(V+E)
«сколько способов», «оптимальная сумма», «повторяющиеся подзадачи»мемоизация → динамикаO(состояний)
«найти в отсортированном», «минимальное значение, при котором…»бинарный поиск (в т. ч. по ответу)O(log n) / O(1)
«цикл в списке», «середина списка», «O(1) памяти»быстрый и медленный указателиO(n) / O(1)

Два указателя

Идея: вместо перебора всех пар держим два индекса и на каждом шаге гарантированно сдвигаем хотя бы один. Раз каждый индекс движется только в одну сторону, суммарно шагов не больше 2n, отсюда и O(n). Работает в трёх вариантах: указатели с концов навстречу (пара с суммой, палиндром, разворот), два указателя по двум слайсам (слияние, пересечение) и «медленный / быстрый» по одному слайсу (удаление дубликатов на месте, цикл в списке).

Указатели с концов: найти пару с суммой 16 в отсортированном массиве шаг 1 1 3 4 6 8 11 13 17 L R 1 + 17 = 18 больше 16 — двигаем R шаг 2 1 3 4 6 8 11 13 17 L R 1 + 13 = 14 меньше 16 — двигаем L шаг 3 1 3 4 6 8 11 13 17 L R 3 + 13 = 16 ответ: индексы 1 и 6 Каждый шаг сдвигает ровно один указатель, значит всего шагов меньше n: O(n) времени, O(1) памяти.
Два указателя. Отсортированность даёт монотонность: если сумма велика — правый элемент точно не участвует в ответе, его можно отбросить навсегда. Именно это и превращает O(n²) в O(n).

Скользящее окно

Окно сводится к двум указателям: между left и right лежит «текущий кандидат», и мы поддерживаем какую-то инкрементальную характеристику окна: сумму, счётчик уникальных, мапу частот. Работает это, только если характеристику удаётся обновить за O(1) при сдвиге, а не пересчитывать заново. Два подвида:

  • Фиксированное окно (длина k задана): вошёл один элемент, вышел один. Сумма: sum += a[i] - a[i-k].
  • Переменное окно: правый край двигается всегда, левый сдвигается, пока нарушено условие (например, «в окне есть повтор»). Левый суммарно проходит тот же путь, поэтому всё ещё O(n), несмотря на вложенный for.
Окно длины k = 3: сумма пересчитывается за O(1) на сдвиг шаг 0 2 1 5 1 3 2 4 1 2 sum = 2 + 1 + 5 = 8 шаг 1 2 1 5 1 3 2 4 1 2 вышло вошло sum = 8 - 2 + 1 = 7 — один вычет и одно сложение, без пересчёта макс 2 1 5 1 3 2 4 1 2 лучшее окно [2..4]: sum = 5 + 1 + 3 = 9
Скользящее окно. Наивно каждое окно суммируется за O(k) и весь проход стоит O(nk). Инкрементальное обновление убирает k из формулы: O(n) и O(1) дополнительной памяти.

Устройство LRU-кэша

Самая частая «спроектируй структуру» на мидла. От тебя ждут Get и Put за O(1) в среднем, плюс вытеснение самого давно не используемого элемента. Ни одна структура поодиночке этого не даёт: мапа находит за O(1), но не хранит порядок; список хранит порядок, но ищет за O(n). Поэтому их склеивают: мапа key → *node, а сами узлы лежат в двусвязном списке, упорядоченном по свежести.

LRU = хеш-таблица (поиск за O(1)) + двусвязный список (перестановка за O(1)) map[key]*node C B A headстраж key: Cval: 3 key: Bval: 2 key: Aval: 1 tailстраж next prev MRU — только что использован LRU — кандидат на вытеснение
Структура. Стражами head и tail служат пустые узлы; они нужны только затем, чтобы в коде вставки и удаления не было ни одной проверки на nil. Это половина успеха: без стражей LRU пишется с четырьмя ветвлениями и обязательно с багом.
Операции по шагам, capacity = 3 было head C B A tail Кэш заполнен. Слева — самый свежий, справа перед tail — самый давний. get(B) head B C A tail Узел найден в мапе за O(1), вырезан из середины (prev.next = next, next.prev = prev) и вставлен сразу за head. put(D) head D B C tail A вытеснен Кэш полон: снимаем tail.prev, делаем delete(m, victim.key), затем новый узел вставляем за head.
Get и Put. Обе операции сводятся к трём примитивам: найти в мапе, вырезать узел, вставить за head. Вытеснение остаётся единственным местом, где трогают tail.prev, и единственным, где надо не забыть удалить ключ из мапы.
Три бага, которые на собесе ловят в LRU
  • Забыли delete из мапы при вытеснении. Список короткий, а мапа течёт — кэш «на 1000 элементов» держит миллион указателей.
  • Put существующего ключа не обрабатывается отдельно: узел добавляется второй раз, мапа перезаписывается, старый узел навсегда остаётся в списке.
  • Get не обновляет порядок. Тогда это не LRU, а FIFO-кэш. Интервьюер спросит именно это.

Обход дерева: три порядка — это один обход, разный момент «посещения»

Рекурсивный DFS всегда идёт одинаково: спустился влево, спустился вправо, вернулся. Отличается только когда мы записываем значение узла — до спуска (pre), между спусками (in) или после (post). BFS устроен иначе: не рекурсия, а очередь, и узлы выходят по уровням.

Один спуск — четыре разных последовательности 1 2 3 4 5 6 глубина = 3 (узлов на самом длинном пути от корня до листа) узлов 6, рёбер 5 pre-order: корень, лево, право 1 2 4 5 3 6 копирование дерева, сериализация in-order: лево, корень, право 4 2 5 1 6 3 для BST даёт отсортированный порядок post-order: лево, право, корень 4 5 2 6 3 1 удаление дерева, подсчёт размеров поддеревьев BFS по уровням: очередь [1] [2 3] [4 5 6] кратчайший путь в невзвешенном графе
Обходы. DFS хранит стек высотой с дерево: O(h), а на вырожденном дереве это уже O(n). BFS хранит очередь шириной с самый широкий уровень: O(w).

Цикл в связном списке: черепаха и заяц

Наивно посещённые узлы складывают в map[*Node]bool: O(n) времени, но и O(n) памяти. Алгоритм Флойда даёт O(1) памяти. Медленный указатель идёт на 1 узел, быстрый на 2. Если цикла нет, быстрый упрётся в nil. Если цикл есть, оба рано или поздно окажутся внутри него, и тогда расстояние между ними сокращается ровно на 1 за шаг — значит, встреча неизбежна.

Флойд: черепаха на 1 шаг, заяц на 2 1 2 3 4 5 6 7 8 вход в цикл длина цикла = 4 T — черепаха, +1 H — заяц, +2 шаг 0: T=1 H=1 шаг 1: T=2 H=3 шаг 2: T=3 H=5 шаг 3: T=4 H=7 шаг 4: T=5 H=5 — встретились Внутри цикла разрыв сокращается на 1 за шаг, поэтому встреча наступает не позже длины цикла. Найти вход в цикл: после встречи вернуть один указатель в head и двигать оба по одному — они сойдутся ровно во входе.
Флойд. Память O(1) — этим он и лучше решения через мапу. Тот же приём находит середину списка (когда быстрый дошёл до конца, медленный ровно посередине) и k-й с конца.
Почему второй указатель из head попадает ровно во вход цикла

Пусть до входа в цикл m шагов, длина цикла c, а встреча произошла на расстоянии k от входа. Черепаха прошла m + k, заяц ровно вдвое больше: 2(m + k). Разница укладывается в целое число оборотов: 2(m+k) - (m+k) = m + k = n·c. Значит m = n·c - k: если пустить одного из головы, а второго с места встречи и оба по одному шагу, то через m шагов первый будет во входе, а второй пройдёт n·c - k + k = n·c — то есть целое число оборотов от входа, и тоже окажется во входе.

Что сказать про сложность, чтобы это звучало профессионально

Не «это O(n)», а «время O(n), дополнительная память O(1), потому что мы не заводим структур, зависящих от входа». Отдельно проговаривай amortized там, где он есть: у append амортизированное O(1), а отдельный вызов иногда стоит O(n) на реаллокации. И отдельно про средний случай для мапы: O(1) в среднем, O(n) в худшем.

Вопросы

18
Суть: один проход и мапа «значение → индекс». На каждом элементе ищем не пару, а дополнение target - v среди уже увиденных.

Идея

В лоб получаются два вложенных цикла, O(n²). Ускорение классическое: внутренний цикл «есть ли где-то нужное число» заменяем на хеш-поиск за O(1). Порядок важен: сначала проверяем, потом кладём текущий элемент в мапу. Так один и тот же элемент не будет использован дважды, и не нужен отдельный костыль if i != j.

func twoSum(nums []int, target int) (int, int, bool) {
    seen := make(map[int]int, len(nums)) // значение -> его индекс
    for i, v := range nums {
        if j, ok := seen[target-v]; ok {
            return j, i, true            // j всегда меньше i
        }
        seen[v] = i                      // кладём после проверки
    }
    return 0, 0, false
}
// twoSum([]int{2, 7, 11, 15}, 9) → 0 1 true
// twoSum([]int{3, 2, 4}, 6)      → 1 2 true
// twoSum([]int{3, 3}, 6)         → 0 1 true
// twoSum([]int{1, 2, 5}, 100)    → 0 0 false

Сложность

Время O(n) — один проход, каждая операция с мапой в среднем O(1). Память O(n) на мапу. Предварительная аллокация make(map[int]int, len(nums)) убирает несколько рехеширований, и такую мелочь интервьюер замечает.

Подводные камни

  • Дубликаты. nums = [3,3], target = 6. При «сначала заполнить мапу, потом искать» второй 3 перезатрёт индекс первого, и решение вернёт (1,1). Порядок «проверил → записал» это чинит автоматически.
  • Что возвращать при отсутствии пары. Идиоматично вернуть (int, int, bool) или ошибку, а не (-1,-1). Проговори это вслух, это вопрос про контракт.
  • Переполнение target - v для крайних int64 достаточно упомянуть вслух.

Добивки, которые обычно задают

  • «А если слайс отсортирован?» Тогда два указателя с концов: O(n) времени и O(1) памяти — строго лучше мапы. Именно этого ответа и ждут.
  • «А three sum?» Отсортировать (O(n log n)), зафиксировать первый элемент циклом и на остатке применить два указателя: суммарно O(n²) времени, O(1) доп. памяти. Не забыть пропуск дубликатов, иначе тройки повторятся.
  • «А если надо все пары, а не одну?» map[int][]int и продолжать проход.
Суть: стек. Открывающую кладём, на закрывающей снимаем вершину и сравниваем с ожидаемой парой. В конце стек обязан быть пуст.

Идея

Скобки сводятся к вложенности, а вложенность к LIFO. В Go стек не нужен как тип: слайс уже стек. Роль push играет append, peek читается как st[len(st)-1], pop пишется как st = st[:len(st)-1].

func validParens(s string) bool {
    pairs := map[rune]rune{')': '(', ']': '[', '}': '{'}
    st := make([]rune, 0, len(s)) // ёмкость сразу: больше len(s) не понадобится

    for _, r := range s {
        switch r {
        case '(', '[', '{':
            st = append(st, r)
        case ')', ']', '}':
            if len(st) == 0 || st[len(st)-1] != pairs[r] {
                return false      // либо закрыли пустоту, либо не ту скобку
            }
            st = st[:len(st)-1]
        default:
            // другие символы игнорируем; уточни у интервьюера, так ли надо
        }
    }
    return len(st) == 0            // незакрытые остались -> невалидно
}
// "()[]{}" → true     "([{}])" → true     "a(b)c" → true
// "(]"     → false    "([)]"   → false
// "("      → false    ""       → true  ← пустая строка валидна, это стоит проговорить

Сложность

Время O(n), память O(n) в худшем случае (строка "(((((").

Подводные камни

  • Забыть проверку len(st) == 0 перед взятием вершины — паника index out of range на строке ")". Это ошибка номер один.
  • Забыть финальную проверку пустоты стека — тогда "((" считается валидной.
  • Ветка default. Если по условию строка может содержать буквы, надо спросить: игнорировать или считать невалидной. Выберешь молча, получишь минус.
  • st = st[:len(st)-1] не отдаёт память, но для скобок это неважно; если бы в стеке лежали указатели, стоило бы занулять снятый элемент.
Как усилить ответ

«Стек на слайсе даёт амортизированное O(1) на push за счёт удвоения ёмкости; я сразу аллоцирую cap = len(s), чтобы вообще не было реаллокаций». И вариант с экономией памяти: если скобки только круглые, стек вырождается в счётчик int — O(1) памяти. Интервьюеры это любят.

Суть: мапа частот за один проход по первой строке и вычитание на второй. O(n) вместо O(n log n) через сортировку. И обязательно на рунах, а не на байтах.

Два решения и когда какое

// Через сортировку: O(n log n)
func isAnagramSort(a, b string) bool {
    ra, rb := []rune(a), []rune(b)
    if len(ra) != len(rb) {
        return false
    }
    slices.Sort(ra)
    slices.Sort(rb)
    return slices.Equal(ra, rb)
}
// isAnagramSort("аргон", "орган") → true
// isAnagramSort("go", "og")       → true
// isAnagramSort("abc", "abd")     → false
// Через частоты: O(n), одна мапа
func isAnagram(a, b string) bool {
    ra, rb := []rune(a), []rune(b)
    if len(ra) != len(rb) {
        return false
    }
    cnt := make(map[rune]int, len(ra))
    for _, r := range ra {
        cnt[r]++
    }
    for _, r := range rb {
        cnt[r]--
        if cnt[r] == 0 {
            delete(cnt, r)
        }
    }
    return len(cnt) == 0
}
// isAnagram("аргон", "орган") → true     isAnagram("", "")     → true
// isAnagram("abc", "abd")     → false    isAnagram("aab","abb") → false

Почему len(ra) != len(rb), а не len(a) != len(b)

len(string) возвращает длину в байтах. Строки "ab" и "я" имеют разное число символов, но "я" занимает 2 байта — и такой проверкой можно случайно пропустить или отсечь неверно. На тексте только из ASCII разницы нет, на русском она вылезает. Ранняя проверка длин при этом полезна: она отсекает большинство пар за O(1).

Оптимизация для ASCII

// Если в условии только строчные a..z, хватит массива:
func isAnagramASCII(a, b string) bool {
    if len(a) != len(b) {
        return false
    }
    var cnt [26]int          // на стеке, без аллокаций и хеширования
    for i := 0; i < len(a); i++ {
        cnt[a[i]-'a']++
        cnt[b[i]-'a']--
    }
    for _, c := range cnt {
        if c != 0 {
            return false
        }
    }
    return true
}
// isAnagramASCII("listen", "silent") → true
// isAnagramASCII("abc", "abd")       → false
// isAnagramASCII("aab", "abb")       → false

Массив на 26 позиций быстрее мапы в разы: нет хеширования, нет аллокаций, всё в кэше. Но это решение под конкретное условие — обязательно скажи, что оно ломается на любом не-ASCII вводе. Мидла от джуна отличает как раз умение назвать границы применимости своей оптимизации.

Добивки

  • «Сгруппируй анаграммы в списке слов». Ключом группы служит отсортированная строка или канонический счётчик; map[string][]string, O(n·k log k).
  • «Регистр и пробелы?» Вопрос к контракту: strings.ToLower плюс фильтрация.
  • «Юникод-нормализация?» "é" может быть одной руной U+00E9 или двумя (e + U+0301). Формально нужна NFC-нормализация через golang.org/x/text/unicode/norm. Скажи про неё вслух, это сильный ход.
Суть: два прохода. Первый считает частоты, второй идёт по строке в исходном порядке и возвращает первый символ с частотой 1.

Почему именно два прохода

Мапа не хранит порядок вставки, и в Go порядок итерации по ней намеренно рандомизирован. Поэтому «пройти по мапе и найти единичку» даёт случайный ответ, а не первый. Порядок нам даёт сама строка — по ней и идём второй раз.

// возвращаем руну и её байтовый индекс
func firstUniq(s string) (rune, int, bool) {
    cnt := make(map[rune]int, len(s))
    for _, r := range s {
        cnt[r]++
    }
    for i, r := range s {   // i считает байты, а не символы
        if cnt[r] == 1 {
            return r, i, true
        }
    }
    return 0, 0, false
}
// firstUniq("swiss")  → 'w', 1, true
// firstUniq("привет") → 'п', 0, true   ← индекс байтовый: для "ивет" он был бы 4, а не 2
// firstUniq("aabb")   → 0, 0, false

Вариант в один проход

Хранить в мапе не счётчик, а первую позицию, и помечать повторы «минус единицей»; затем один раз пробежать по мапе и взять минимальную позицию. По строке идём один раз, но добавляется проход по мапе — суммарно та же O(n), зато порядок восстанавливается минимумом, а не итерацией.

func firstUniqOnePass(s string) (rune, bool) {
    type info struct {
        pos int
        cnt int
    }
    seen := make(map[rune]*info, len(s))
    for i, r := range s {
        if in, ok := seen[r]; ok {
            in.cnt++
        } else {
            seen[r] = &info{pos: i, cnt: 1}
        }
    }
    best, res, found := len(s), rune(0), false
    for r, in := range seen {
        if in.cnt == 1 && in.pos < best {
            best, res, found = in.pos, r, true
        }
    }
    return res, found
}
// firstUniqOnePass("swiss")  → 'w', true
// firstUniqOnePass("привет") → 'п', true
// firstUniqOnePass("aabb")   → 0, false

Сложность и грабли

  • Время O(n), память O(k) по числу различных символов (для ASCII это константа 128).
  • Главная ловушка Go: написать for i := 0; i < len(s); i++ { cnt[s[i]]++ }. Это счёт по байтам: русская буква распадётся на два «символа», и функция начнёт врать.
  • Индекс из range байтовый. Если по условию нужен «номер символа», заводи отдельный счётчик или работай с []rune.
Суть: два указателя навстречу по []rune, а не по байтам. O(n) времени, O(1) дополнительной памяти, если строка уже в рунах.

Базовый вариант

func isPalindrome(s string) bool {
    r := []rune(s)
    for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
        if r[i] != r[j] {
            return false
        }
    }
    return true
}
// isPalindrome("шалаш") → true    isPalindrome("топот") → true
// isPalindrome("Шалаш") → false   ← регистр не нормализуется
// isPalindrome("go")    → false

Условие цикла i < j, а не i <= j: при нечётной длине средний символ сравнивать сам с собой бессмысленно. Оба варианта работают, но < выдаёт аккуратность.

Полный вариант: нормализация «на месте»

func isPalindromeClean(s string) bool {
    r := []rune(strings.ToLower(s))

    // фильтруем in-place: переиспользуем тот же массив, без второй аллокации
    keep := r[:0]
    for _, c := range r {
        if unicode.IsLetter(c) || unicode.IsDigit(c) {
            keep = append(keep, c)
        }
    }

    for i, j := 0, len(keep)-1; i < j; i, j = i+1, j-1 {
        if keep[i] != keep[j] {
            return false
        }
    }
    return true
}
// isPalindromeClean("А роза упала на лапу Азора")   → true
// isPalindromeClean("A man, a plan, a canal: Panama") → true
// isPalindromeClean("hello")                        → false

Приём keep := r[:0] фильтрует слайс без выделения нового массива: пишем поверх исходного, потому что пишущий индекс никогда не обгоняет читающий. Это тот самый «Go-специфичный» штрих, который замечают.

Почему нельзя сравнивать байты

Проверка s[i] != s[len(s)-1-i] на строке "шалаш" вернёт false: каждая буква занимает два байта, и байты в паре идут задом наперёд. Работает такой код только на ASCII. Это самый частый способ провалить «простую» задачу именно на Go-собесе.

Добивки

  • «Без аллокации []rune Можно идти utf8.DecodeRuneInString слева и utf8.DecodeLastRuneInString справа, тогда O(1) памяти на любой строке.
  • «Палиндром ли число, без перевода в строку?» Разворачиваем половину числа арифметикой: rev = rev*10 + n%10.
  • «Можно удалить один символ — палиндром?» Те же два указателя: при первом несовпадении пробуем пропустить левый или правый и проверить остаток. По-прежнему O(n).
Суть: строки в Go неизменяемы, поэтому «in-place» возможен только для []rune/[]byte. Разворот байтов ломает UTF-8, разворот рун ломает составные графемы.

Слайс — честный in-place

func reverse[T any](s []T) {          // дженерик с Go 1.18
    for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 {
        s[i], s[j] = s[j], s[i]
    }
}
// в стандартной библиотеке с Go 1.21 это уже есть: slices.Reverse(s)
// a := []int{1, 2, 3, 4};      reverse(a) → a стал [4 3 2 1]
// s := []string{"a","b","c"};  reverse(s) → s стал [c b a]

Строка — только через копию

func reverseString(s string) string {
    r := []rune(s)                    // аллокация: 4 байта на руну
    for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r)                  // ещё одна аллокация
}
// reverseString("привет") → "тевирп"
// reverseString("Go!")    → "!oG"
// reverseString("")       → ""

Две аллокации неизбежны: строка иммутабельна, её данные лежат в read-only секции или разделяются другими строками. Попытка []byte(s)[0] = 'x' компилируется, но меняет копию, а не строку.

Три уровня «правильности»

Что разворачиваемЧто сломаетсяПример
Байты []byteUTF-8: получится битая последовательность"Привет" превратится в мусор
Руны []runeсоставные графемы: буква и её диакритика разъедутся"e+U+0301" → комбинирующий акцент уедет к другой букве
Графемыничего, но нужен внешний пакетx/text или сегментация по UAX #29
Как отвечать

«Разворачиваю по рунам, для подавляющего большинства текста этого хватает. Полностью корректно было бы разворачивать по графемным кластерам: эмодзи с модификатором цвета кожи или флаг из двух regional indicator развалятся и на рунах. В проде для этого берут golang.org/x/text». Такой ответ закрывает вопрос целиком, включая добивку.

Смежная задача: развернуть слова в предложении

func reverseWords(s string) string {
    w := strings.Fields(s)   // Fields сам схлопывает любые пробельные последовательности
    slices.Reverse(w)
    return strings.Join(w, " ")
}
// reverseWords("  привет   мир  из Go ") → "Go из мир привет"
// reverseWords("one")                    → "one"

strings.Fields вместо strings.Split(s, " ") здесь не придирка: Split на двойном пробеле даст пустые элементы.

Суть: два индекса, на каждом шаге берём меньший из голов и двигаем его указатель. Хвост оставшегося слайса дописываем одним append(out, a[i:]...).
func mergeSorted(a, b []int) []int {
    out := make([]int, 0, len(a)+len(b)) // ровно один раз аллоцируем нужный размер
    i, j := 0, 0
    for i < len(a) && j < len(b) {
        if a[i] <= b[j] {               // именно <=, иначе теряется стабильность
            out = append(out, a[i])
            i++
        } else {
            out = append(out, b[j])
            j++
        }
    }
    out = append(out, a[i:]...)          // один из двух хвостов пуст
    out = append(out, b[j:]...)
    return out
}
// mergeSorted([]int{1, 3, 5}, []int{2, 3, 8, 9}) → [1 2 3 3 5 8 9]
// mergeSorted(nil, []int{1, 2})                  → [1 2]
// mergeSorted([]int{1, 2}, nil)                  → [1 2]

Почему <=, а не <

При равных элементах <= берёт элемент из a первым, то есть сохраняет относительный порядок «сначала первый слайс». Для int разницы не видно, а для структур с ключом это ровно стабильность слияния, и на ней держится стабильная merge sort. Скажешь это — станет видно, что понимаешь, а не заучил.

Сложность

Время O(n+m), память O(n+m) под результат. Дополнительной памяти сверх результата нет. Без make с ёмкостью было бы log₂(n+m) реаллокаций с копированием — всё ещё O(n+m) амортизированно, но с константой в несколько раз хуже.

Вариант «слить в первый слайс, места хватает»

Классическая добивка (LeetCode 88): a длиной m+n, первые m элементов значимы. Слева направо идти нельзя — затрём непрочитанное. Идём справа налево, записывая максимумы в конец:

func mergeInPlace(a []int, m int, b []int, n int) {
    i, j, k := m-1, n-1, m+n-1
    for j >= 0 {                       // b кончился, всё уже на месте
        if i >= 0 && a[i] > b[j] {
            a[k] = a[i]
            i--
        } else {
            a[k] = b[j]
            j--
        }
        k--
    }
}
// a := []int{1, 3, 5, 0, 0, 0}; b := []int{2, 4, 6}
// mergeInPlace(a, 3, b, 3) → a стал [1 2 3 4 5 6]

O(n+m) времени и O(1) дополнительной памяти. Условие цикла j >= 0, а не i >= 0 || j >= 0: когда b исчерпан, остаток a уже лежит на своих местах и копировать его незачем.

Добивки

  • «Слить k отсортированных слайсов». Куча из k голов: O(N log k), где N это суммарная длина. Наивно попарно выйдет O(N·k).
  • «А если это потоки/каналы?» Та же логика, но на каналах, и это уже тема 1.3.
Суть: неотсортированные — множество на мапе, O(n+m) времени и O(min) памяти. Отсортированные — два указателя, O(n+m) времени и O(1) памяти.

Общий случай: множество на мапе

func intersect(a, b []int) []int {
    if len(a) > len(b) {
        a, b = b, a          // мапу строим по меньшему слайсу: память O(min(n,m))
    }
    set := make(map[int]struct{}, len(a))
    for _, v := range a {
        set[v] = struct{}{}  // struct{} не занимает места, в отличие от bool
    }
    out := make([]int, 0, len(a))
    for _, v := range b {
        if _, ok := set[v]; ok {
            out = append(out, v)
            delete(set, v)   // чтобы дубликаты b не размножили результат
        }
    }
    return out
}
// intersect([]int{1, 2, 2, 3}, []int{2, 2, 3, 4}) → [2 3]  ← дубль двойки не размножился
// intersect([]int{1, 2}, []int{3, 4})             → []

Два штриха, за которые ставят плюс: строим мапу по короткому слайсу и берём map[T]struct{} как множество. struct{} нулевого размера, поэтому множество из миллиона int весит столько же, сколько ключи, без байта на значение.

Отсортированный случай: два указателя

func intersectSorted(a, b []int) []int {
    var out []int
    i, j := 0, 0
    for i < len(a) && j < len(b) {
        switch {
        case a[i] < b[j]:
            i++
        case a[i] > b[j]:
            j++
        default:
            // пропускаем повтор, если хотим уникальные значения
            if len(out) == 0 || out[len(out)-1] != a[i] {
                out = append(out, a[i])
            }
            i++
            j++
        }
    }
    return out
}
// intersectSorted([]int{1, 2, 2, 3}, []int{2, 2, 3, 4}) → [2 3]
// intersectSorted([]int{1, 2}, []int{3, 4})             → [] (nil)

Сравнение

ПодходВремяДоп. памятьКогда
Вложенные циклыO(n·m)O(1)совсем маленькие слайсы
Мапа-множествоO(n+m)O(min(n,m))вход не отсортирован
Два указателяO(n+m)O(1)вход отсортирован
Сортировка + два указателяO(n log n + m log m)O(1) или O(n) на копиюпамять критична, вход можно портить
Вопрос, который надо задать первым

Что делать с дубликатами? «Пересечение множеств» и «пересечение мультимножеств» это разные задачи. Для [1,1,2] и [1,1,1,3] ответ либо [1], либо [1,1]. Не спросишь и молча выберешь — половина интервьюеров засчитает это как невнимательность к условию.

Суть: медленный указатель — куда пишем, быстрый — что читаем. Пишущий никогда не обгоняет читающего, поэтому портить данные нечем.
func dedupSorted(a []int) []int {
    if len(a) < 2 {
        return a
    }
    k := 1                          // длина уже собранного уникального префикса
    for i := 1; i < len(a); i++ {
        if a[i] != a[k-1] {         // сравниваем с последним записанным, не с a[i-1]
            a[k] = a[i]
            k++
        }
    }
    return a[:k]
}
// a := []int{1, 1, 2, 3, 3, 3, 4}
// dedupSorted(a) → [1 2 3 4]
// но сам a теперь [1 2 3 4 3 3 4] — функция пишет поверх входного слайса.
// Вызывающего предупреди: результат смотрит в тот же массив.

Что здесь важно объяснить

  • Сравниваем с a[k-1], то есть с последним записанным. Вариант с a[i-1] тоже работает для отсортированного входа, но ломается, как только задачу расширяют до «оставить не более двух одинаковых».
  • Функция возвращает a[:k], а не меняет длину у вызывающего: длина живёт в заголовке слайса, а он передан по значению. Про возврат часто забывают.
  • Элементы за k остаются в массиве как мусор. Для int это безобидно, а на указателях или структурах с указателями выходит утечка: GC не соберёт объекты, на которые ссылается хвост. Правильно занулить: clear(a[k:]) (Go 1.21).

Обобщённая фильтрация тем же приёмом

// Общий идиоматичный шаблон «оставить подходящие, без новой аллокации»
out := a[:0]
for _, v := range a {
    if keep(v) {
        out = append(out, v)
    }
}
clear(a[len(out):])  // обнулить хвост, если в слайсе указатели
a = out

Дубликаты в НЕотсортированном слайсе

func dedupAny[T comparable](a []T) []T {
    seen := make(map[T]struct{}, len(a))
    out := a[:0]
    for _, v := range a {
        if _, ok := seen[v]; ok {
            continue
        }
        seen[v] = struct{}{}
        out = append(out, v)
    }
    return out
}
// dedupAny([]string{"b", "a", "b", "c", "a"}) → [b a c]   ← порядок первого вхождения
// dedupAny([]int{3, 1, 3, 1})                 → [3 1]
// Вход, как и в dedupSorted, портится: out указывает на a[:0].

O(n) времени, O(n) памяти, порядок первого появления сохраняется. Для отсортированного входа мапа не нужна: O(1) памяти. Разницу обязательно проговори — на ней и строится вопрос. В стандартной библиотеке с Go 1.21 есть slices.Compact, ровно первый вариант.

Суть: посчитать первую сумму за O(k), дальше на каждом шаге sum += a[i] - a[i-k]. Время O(n), память O(1).
func maxSumK(a []int, k int) (int, bool) {
    if k <= 0 || len(a) < k {
        return 0, false            // невалидный вход: контракт обговори заранее
    }
    sum := 0
    for _, v := range a[:k] {
        sum += v
    }
    best := sum
    for i := k; i < len(a); i++ {
        sum += a[i] - a[i-k]       // вошёл a[i], вышел a[i-k]
        if sum > best {
            best = sum
        }
    }
    return best, true
}
// maxSumK([]int{2, 1, 5, 1, 3, 2}, 3) → 9, true    ← 5+1+3
// maxSumK([]int{-1, -2, -3}, 2)       → -3, true   ← -1-2, «максимум» может быть отрицательным
// maxSumK([]int{1, 2}, 5)             → 0, false   ← k больше длины

Почему нельзя инициализировать best := 0

Если все числа отрицательные, ноль окажется «лучшим» и функция вернёт неправильный ответ. Стартовать надо от первого реального окна. Это классическая ловушка, которую интервьюер проверяет тестом [-5,-2,-9], k = 2 (правильный ответ −7).

Сложность

Наивно: n-k+1 окон по O(k) каждое, итого O(n·k). Инкрементально выходит O(n), потому что каждый элемент ровно один раз входит в окно и ровно один раз выходит. Память O(1): никаких структур, только sum и best.

Родственные задачи, которые часто дают следом

  • Максимальная сумма подмассива произвольной длины решается алгоритмом Кадане, тоже O(n): cur = max(v, cur+v); best = max(best, cur).
  • Минимальная длина подмассива с суммой ≥ target при положительных числах берётся окном переменной длины: расширяем правым, сжимаем левым, пока сумма достаточна.
  • Среднее по окну / скользящий максимум. Для максимума нужна монотонная дека (deque на слайсе), тоже O(n) — красивая добивка, если останется время.
  • Префиксные суммы как альтернатива: pre[i+1] = pre[i] + a[i], тогда сумма любого отрезка считается за O(1), а память O(n). Полезно, когда запросов много.
Суть: окно переменной длины плюс мапа «символ → последняя позиция». Встретили повтор внутри окна — прыгаем левой границей за прошлое вхождение.

Решение

func longestUnique(s string) int {
    rs := []rune(s)                       // работаем в рунах: индексы сравнимы
    last := make(map[rune]int, len(rs))   // руна -> её последний индекс
    best, left := 0, 0

    for i, r := range rs {
        if j, ok := last[r]; ok && j >= left {
            left = j + 1                  // прыжок, а не сдвиг на единицу
        }
        last[r] = i
        if n := i - left + 1; n > best {
            best = n
        }
    }
    return best
}
// longestUnique("abcabcbb") → 3   ("abc")
// longestUnique("bbbbb")    → 1
// longestUnique("pwwkew")   → 3   ("wke")
// longestUnique("абвабв")   → 3   ← руны, а не байты
// longestUnique("")         → 0

Две тонкости, на которых валятся

  • Проверка j >= left. В мапе остаются позиции символов, которые уже вышли из окна. Без этой проверки левая граница может уехать назад, окно «расширится» задним числом и ответ будет завышен. Тест: "abba" — без проверки получится 3 вместо 2.
  • Прыжок вместо сдвига. left = j + 1 сразу выкидывает весь префикс до повторившегося символа. Вариант «сдвигать left по одному, пока повтор не уйдёт» тоже даёт O(n) амортизированно, но требует мапы счётчиков:
// Вариант со счётчиками, обобщается на «не более k повторов»
func longestUniqueCount(s string) int {
    rs := []rune(s)
    cnt := make(map[rune]int, len(rs))
    best, left := 0, 0
    for right, r := range rs {
        cnt[r]++
        for cnt[r] > 1 {          // сжимаем окно, пока нарушено условие
            cnt[rs[left]]--
            left++
        }
        if n := right - left + 1; n > best {
            best = n
        }
    }
    return best
}
// Ответы те же: "abcabcbb" → 3, "bbbbb" → 1, "pwwkew" → 3, "абвабв" → 3, "" → 0

Почему это O(n), хотя циклов два

Внутренний for двигает только left, а left монотонно растёт и не может превысить n. Суммарно оба указателя делают не больше 2n шагов. Этот аргумент про амортизацию надо уметь произнести вслух. «Два вложенных цикла» пугают интервьюера, пока ты не объяснишь.

Память и вариации

Память O(k), где k это размер алфавита в окне. Для ASCII можно заменить мапу на [128]int и получить константную память. Естественные добивки: «верни саму подстроку, а не длину» (string(rs[bestLeft:bestLeft+best])), «не более двух различных символов», «не более k повторов». Всё это тот же каркас окна, меняется только условие сжатия.

Про []rune

Соблазн писать for i, r := range s прямо по строке велик, но тогда i становится байтовым индексом, и арифметика i - left + 1 начнёт считать байты, а не символы. На кириллице ответ будет вдвое больше правильного. Либо конвертируй в []rune, либо веди отдельный счётчик символов.

Суть: отсортировать по началу, затем один жадный проход: если новый интервал начинается не позже конца текущего — расширяем конец, иначе начинаем новый.
type Interval struct {
    Start, End int
}

func mergeIntervals(in []Interval) []Interval {
    if len(in) == 0 {
        return nil
    }
    s := make([]Interval, len(in))
    copy(s, in)                       // не портим вход вызывающего
    slices.SortFunc(s, func(a, b Interval) int {
        return cmp.Compare(a.Start, b.Start)
    })

    out := make([]Interval, 0, len(s))
    out = append(out, s[0])
    for _, cur := range s[1:] {
        last := &out[len(out)-1]      // указатель на последний собранный интервал
        if cur.Start <= last.End {    // пересекаются или касаются
            if cur.End > last.End {
                last.End = cur.End    // расширяем; не присваиваем cur.End вслепую
            }
        } else {
            out = append(out, cur)
        }
    }
    return out
}
// in := []Interval{{1, 3}, {8, 10}, {2, 6}, {15, 18}}
// mergeIntervals(in) → [{1 6} {8 10} {15 18}]
// in после вызова   → [{1 3} {8 10} {2 6} {15 18}] ← вход не тронут, это и была цель copy
// mergeIntervals([]Interval{{1, 4}, {4, 5}}) → [{1 5}]  ← касание тоже склеиваем
// mergeIntervals(nil)                        → [] (nil)

Почему сортировка по началу решает задачу

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

Сложность

Сортировка доминирует, отсюда O(n log n); сам проход O(n). Память O(n) под копию и результат. Если вход портить разрешено, копию можно не делать и получить O(1) дополнительной памяти сверх результата.

Три места, где ошибаются

  • last.End = cur.End без сравнения. Для [1,10] и [2,3] получится [1,3], интервал «схлопнется». Нужно max(last.End, cur.End).
  • < вместо <=. Вопрос к контракту: считать ли [1,2] и [2,3] пересекающимися. Для закрытых интервалов да, для полуинтервалов [start, end) нет. Спроси.
  • Берём &out[len(out)-1], а потом append в том же шаге. В коде выше этого нет (ветки взаимоисключающие), но если перепутать — указатель повиснет на старом массиве после реаллокации, и изменение потеряется. Классическая Go-ловушка.

Добивки

  • «Вставить один интервал в уже объединённый список». Бинарный поиск позиции плюс локальное слияние: O(log n + k).
  • «Сколько переговорок нужно для списка встреч?» Это та же сортировка, но по событиям: +1 на начало, -1 на конец, максимум префиксной суммы. Либо мин-куча концов, тоже O(n log n).
  • «Найти свободные окна». Дополнение к объединённым интервалам, тот же проход.
Суть: наивная рекурсия для Фибоначчи — O(φⁿ) ≈ O(1.618ⁿ), потому что одни и те же подзадачи считаются заново. Мемоизация делает O(n), итерация — O(n) времени и O(1) памяти. Для факториала рекурсия и так O(n), проблема только в стеке.

Три реализации

// 1) Наивная рекурсия: O(fib(n)) вызовов, экспонента
func fibRec(n int) int {
    if n < 2 {
        return n
    }
    return fibRec(n-1) + fibRec(n-2)
}

// 2) Мемоизация (top-down динамика): O(n) времени, O(n) памяти
func fibMemo(n int, memo map[int]int) int {
    if n < 2 {
        return n
    }
    if v, ok := memo[n]; ok {
        return v
    }
    v := fibMemo(n-1, memo) + fibMemo(n-2, memo)
    memo[n] = v
    return v
}

// 3) Итерация (bottom-up): O(n) времени, O(1) памяти — лучший выбор
func fib(n int) int {
    a, b := 0, 1
    for i := 0; i < n; i++ {
        a, b = b, a+b
    }
    return a
}
// fibRec(10) = fibMemo(10, map[int]int{}) = fib(10) = 55
// fib(0..9): 0 1 1 2 3 5 8 13 21 34
// fib(50) = 12586269025
// fibMemo(90, ...) = 2880067194370816120 — а fib(93) уже переполнит int64

Почему наивная рекурсия экспоненциальна

Дерево вызовов fib(5) считает fib(3) дважды, fib(2) трижды. Число листьев дерева равно самому fib(n), а он растёт как φⁿ/√5, где φ ≈ 1.618. Отсюда и O(1.618ⁿ): fib(50) наивно это порядка 2·10⁹ вызовов, секунды работы; fib(90) считался бы годами. Память при этом O(n), это глубина стека.

ВариантВремяПамятьКомментарий
Наивная рекурсияO(φⁿ)O(n) стектолько как иллюстрация проблемы
МемоизацияO(n)O(n) + стекгодится, когда порядок подзадач неочевиден
Итерация двумя переменнымиO(n)O(1)то, что стоит написать на собесе
Быстрое удвоение / матрицаO(log n)O(1)сильная добивка, но нужен big.Int

Факториал: другая история

func factIter(n int) int {
    r := 1
    for i := 2; i <= n; i++ {
        r *= i
    }
    return r
}
// factIter(0) → 1        factIter(1)  → 1
// factIter(5) → 120      factIter(10) → 3628800
// factIter(20) → 2432902008176640000  ← последнее значение, влезающее в int64
// factIter(21) → -4249290049419214848 ← молча переполнилось, без паники и ошибки

Здесь рекурсия не экспоненциальна: каждая подзадача вызывается ровно один раз, всего O(n) вызовов. Разница только в памяти: рекурсия тратит O(n) на стек, итерация обходится O(1). Факториал ломается на другом, на переполнении: 21! уже не влезает в int64, и Go молча вернёт мусор без всякой паники. Правильный ответ на собесе: «до 20 включительно считаю в int64, дальше math/big».

Про хвостовую рекурсию

В Go нет оптимизации хвостовых вызовов (TCO): компилятор её не делает и делать не собирается. Решение осознанное, ради читаемых стектрейсов и корректной работы defer/recover. Поэтому «перепишу рекурсию в хвостовую» в Go ничего не даёт: стек всё равно вырастет. Зато горутинный стек растёт динамически (старт 2 КБ, удвоение с копированием, лимит 1 ГБ по умолчанию), так что рекурсия глубиной в сотни тысяч не падает мгновенно, как в C.

Как это спрашивают на самом деле

Задачу про Фибоначчи почти всегда дают как предлог поговорить про динамическое программирование: «а если нужно посчитать fib(n) mod m?», «а сколькими способами подняться по лестнице, шагая на 1 или 2 ступени?» (это тот же Фибоначчи), «а если шагов может быть до k?» (окно сумм длины k). Отвечать надо не про рекурсию, а про перекрывающиеся подзадачи.

Суть: map[K]*node для поиска за O(1) плюс двусвязный список со стражами для порядка свежести. Get и Put сводятся к «найти — вырезать — вставить за head», вытеснение — снять tail.prev и не забыть delete из мапы.

Почему именно эта пара структур

  • Мапа даёт поиск по ключу за O(1) в среднем, но не хранит порядок обращений.
  • Двусвязный список даёт вставку и удаление произвольного узла за O(1), если у нас уже есть указатель на узел. Односвязного не хватит: чтобы вырезать узел, нужен его предшественник.
  • Мапа как раз хранит указатели на узлы — вот он, тот самый «уже есть указатель». Две структуры покрывают слабости друг друга.

Полная реализация

package lru

type node struct {
    key        string
    val        any
    prev, next *node
}

type Cache struct {
    capacity   int
    items      map[string]*node
    head, tail *node // стражи: сами по себе данных не хранят
}

func New(capacity int) *Cache {
    if capacity <= 0 {
        panic("lru: capacity must be positive")
    }
    h, t := &node{}, &node{}
    h.next, t.prev = t, h            // пустой список: head <-> tail
    return &Cache{
        capacity: capacity,
        items:    make(map[string]*node, capacity),
        head:     h,
        tail:     t,
    }
}

// --- примитивы списка: ровно три строки каждый, ни одной проверки на nil ---

func (c *Cache) unlink(n *node) {
    n.prev.next = n.next
    n.next.prev = n.prev
    n.prev, n.next = nil, nil        // помогаем GC и ловим повторное использование
}

func (c *Cache) pushFront(n *node) {
    n.prev = c.head
    n.next = c.head.next
    c.head.next.prev = n
    c.head.next = n
}

func (c *Cache) moveToFront(n *node) {
    c.unlink(n)
    c.pushFront(n)
}

// --- публичный API ---

func (c *Cache) Get(key string) (any, bool) {
    n, ok := c.items[key]
    if !ok {
        return nil, false
    }
    c.moveToFront(n)                 // Get обязан обновлять свежесть
    return n.val, true
}

func (c *Cache) Put(key string, val any) {
    if n, ok := c.items[key]; ok {   // ключ уже есть: обновить значение и порядок
        n.val = val
        c.moveToFront(n)
        return
    }
    if len(c.items) == c.capacity {  // места нет: выселяем самый давний
        victim := c.tail.prev
        c.unlink(victim)
        delete(c.items, victim.key)  // забыть это = утечка памяти
    }
    n := &node{key: key, val: val}
    c.items[key] = n
    c.pushFront(n)
}

func (c *Cache) Len() int { return len(c.items) }

func (c *Cache) Remove(key string) bool {
    n, ok := c.items[key]
    if !ok {
        return false
    }
    c.unlink(n)
    delete(c.items, key)
    return true
}
// c := New(2)
// c.Put("a", 1); c.Put("b", 2)
// c.Get("a")  → 1, true      ← и этим Get освежает "a"
// c.Put("c", 3)              ← места нет, выселяется "b" (самый давний), а не "a"
// c.Get("b")  → <nil>, false
// c.Get("a")  → 1, true
// c.Get("c")  → 3, true
// c.Len() → 2   c.Remove("a") → true   c.Remove("zzz") → false   c.Len() → 1

Разбор по шагам: что происходит на каждой операции

ОперацияШагиСтоимость
Get(hit)поиск в мапе → unlink → pushFrontO(1) в среднем
Get(miss)поиск в мапеO(1) в среднем
Put(существующий)поиск → обновить val → unlink → pushFrontO(1)
Put(новый, есть место)создать узел → записать в мапу → pushFrontO(1) + возможный рост мапы
Put(новый, полный)взять tail.prev → unlink → delete из мапы → создать → pushFrontO(1)

Память O(capacity): по одному узлу на элемент плюс запись в мапе. Никаких проходов по списку нигде нет, поэтому «O(1) на обе операции» тут не преувеличение.

Зачем стражи и что будет без них

Без пустых head/tail каждая операция обрастает проверками: «список пуст?», «удаляем первый?», «удаляем последний?», «остался один элемент?». Это четыре ветки в unlink и три в pushFront — и почти гарантированный баг на собесе под давлением. Со стражами n.prev и n.next никогда не nil у реального узла, поэтому обе функции пишутся в три строки без единого if. Это ровно тот приём, ради которого задачу и дают.

Вариант на container/list

import "container/list"

type entry struct {
    key string
    val any
}

type Cache struct {
    capacity int
    ll       *list.List               // хранит *entry
    items    map[string]*list.Element
}

func (c *Cache) Get(key string) (any, bool) {
    el, ok := c.items[key]
    if !ok {
        return nil, false
    }
    c.ll.MoveToFront(el)
    return el.Value.(*entry).val, true
}

func (c *Cache) Put(key string, val any) {
    if el, ok := c.items[key]; ok {
        el.Value.(*entry).val = val
        c.ll.MoveToFront(el)
        return
    }
    if c.ll.Len() == c.capacity {
        back := c.ll.Back()
        c.ll.Remove(back)
        delete(c.items, back.Value.(*entry).key)
    }
    c.items[key] = c.ll.PushFront(&entry{key: key, val: val})
}

Короче и меньше шансов ошибиться, но интервьюер обычно просит «руками», чтобы посмотреть, понимаешь ли ты, что container/list делает внутри. Стоит написать свою версию, а про container/list упомянуть как «в проде взял бы её или hashicorp/golang-lru». В мапе при этом лежит *list.Element, а ключ дублируется внутри значения: иначе при вытеснении из Back() неоткуда узнать, что удалять из мапы.

Потокобезопасность

type SafeCache struct {
    mu sync.Mutex   // именно Mutex, не RWMutex
    c  *Cache
}

func (s *SafeCache) Get(key string) (any, bool) {
    s.mu.Lock()
    defer s.mu.Unlock()
    return s.c.Get(key)
}
Почему здесь RWMutex не поможет

Get в LRU на самом деле пишет: он переставляет узел в списке. Возьмёшь RLock, дёрнешь внутри moveToFront и получишь гонку, которую -race поймает сразу. Варианты: (1) честный Mutex; (2) шардировать кэш по хешу ключа: N независимых LRU, каждый со своим мьютексом; (3) не обновлять порядок на каждом чтении, а копить обращения в буфер и применять пачкой — так устроен Ristretto. Этот вопрос и есть любимая добивка после «напиши LRU».

Что ещё спрашивают сверху

  • TTL. Добавить expiresAt time.Time в узел и проверять при Get (ленивое истечение) плюс фоновая чистка. Точное истечение по времени требует ещё и кучи по срокам.
  • Метрики. hits/misses/evictions покажут, что ты думаешь про наблюдаемость.
  • Колбэк на вытеснение onEvict func(key, val), чтобы закрывать ресурсы.
  • «А LFU?» Другая структура: счётчики частот плюс списки по частотам (алгоритм O(1) от Ketan Shah). Или приближение через Count-Min Sketch, как в TinyLFU.
  • «Почему не просто мапа с временем последнего доступа?» Потому что поиск жертвы станет O(n): придётся перебирать все ключи, чтобы найти минимум.
Суть: мин-куча размера k — O(n log k), память O(k), вход не портится, работает на потоке. Quickselect — O(n) в среднем, память O(1), но портит вход и в худшем случае O(n²).

Вариант 1: куча размера k

import "container/heap"

type minHeap []int

func (h minHeap) Len() int            { return len(h) }
func (h minHeap) Less(i, j int) bool  { return h[i] < h[j] }   // мин-куча
func (h minHeap) Swap(i, j int)       { h[i], h[j] = h[j], h[i] }
func (h *minHeap) Push(x any)         { *h = append(*h, x.(int)) }
func (h *minHeap) Pop() any {
    old := *h
    n := len(old)
    v := old[n-1]
    *h = old[:n-1]
    return v
}

func kthLargest(nums []int, k int) (int, bool) {
    if k <= 0 || k > len(nums) {
        return 0, false
    }
    h := &minHeap{}
    heap.Init(h)
    for _, v := range nums {
        heap.Push(h, v)
        if h.Len() > k {
            heap.Pop(h)      // выбрасываем самый маленький из k+1
        }
    }
    return (*h)[0], true     // на вершине мин-кучи лежит k-й по величине
}
// kthLargest([]int{3, 2, 1, 5, 6, 4}, 2)          → 5, true
// kthLargest([]int{3, 2, 3, 1, 2, 4, 5, 5, 6}, 4) → 4, true  ← дубликаты считаются
// kthLargest([]int{1}, 5)                         → 0, false

Тут легко запутаться, и это стоит проговорить вслух: для k-го по величине нужна мин-куча. В ней всегда лежат k самых больших элементов, а на вершине оказывается наименьший из них, то есть ровно k-й по величине. Симметрично: k-й наименьший ищут макс-кучей.

Вариант 2: quickselect

// Портит входной слайс, об этом надо предупредить вызывающего.
func kthLargestQS(a []int, k int) int {
    target := len(a) - k               // индекс в порядке по возрастанию
    lo, hi := 0, len(a)-1
    for {
        p := partition(a, lo, hi)
        switch {
        case p == target:
            return a[p]
        case p < target:
            lo = p + 1                 // рекурсия только в одну половину
        default:
            hi = p - 1
        }
    }
}

func partition(a []int, lo, hi int) int {
    r := lo + rand.IntN(hi-lo+1)       // рандомный пивот спасает от O(n^2)
    a[r], a[hi] = a[hi], a[r]
    pivot := a[hi]
    i := lo
    for j := lo; j < hi; j++ {
        if a[j] < pivot {
            a[i], a[j] = a[j], a[i]
            i++
        }
    }
    a[i], a[hi] = a[hi], a[i]
    return i
}
// a := []int{3, 2, 1, 5, 6, 4}
// kthLargestQS(a, 2) → 5 — ответ детерминирован,
// а вот сам a после вызова каждый раз разный: [2 1 3 4 5 6], [3 2 1 4 5 6], [1 2 3 4 5 6],
// [1 2 4 3 5 6]… — восемь прогонов подряд дали четыре разные перестановки.
// Пивот случайный, поэтому перестановки внутри слайса от запуска к запуску не совпадают.

Почему в среднем O(n): после разбиения мы идём только в одну половину, поэтому работа складывается как n + n/2 + n/4 + … = 2n. Худший случай O(n²) наступает, когда пивот всё время оказывается краем, а рандомизация такой сценарий практически исключает. Гарантированный O(n) даёт «median of medians», но на собесе его писать не просят, достаточно назвать.

Куча размера kQuickselectПолная сортировка
ВремяO(n log k)O(n) в среднем, O(n²) в худшемO(n log n)
Доп. памятьO(k)O(1)O(log n) на стек у slices.Sort
Портит входнетдада
Данные не помещаются в памятьда, работает на потокенетнет
Нужны все k, а не только k-йда, куча их и содержитда, это a[target:]да
Как выбирать вслух

«Если k маленькое относительно n или данные приходят потоком, беру кучу: O(n log k), память O(k), вход не трогаем. Если весь массив в памяти, его можно портить и нужен один элемент, беру quickselect: в среднем O(n). Просто отсортировать за O(n log n) тоже приемлемо, если n небольшое: код в одну строку, меньше шансов ошибиться». Три варианта названы, выбор обоснован, этого хватает.

Добивки

  • «Топ-k из миллиарда записей». Куча размера k на каждом шарде, потом слияние k-элементных куч. Ответ уже звучит архитектурно, и его любят.
  • «Медиана потока». Две кучи: макс-куча меньшей половины и мин-куча большей, балансируем размеры. Вставка O(log n), медиана O(1).
  • «Почему container/heap просит Push/Pop на указателе?» Потому что они меняют длину слайса, а Len/Less/ Swap — нет. Классическая ловушка: определить все пять на значении и получить «куча не растёт».
Суть: три DFS-обхода отличаются одной строкой — где стоит «посетить узел» относительно рекурсивных вызовов. BFS по уровням — это очередь, и уровень отрезается по len(queue), зафиксированному в начале итерации.

Три DFS-обхода: разница в одной строке

type Node struct {
    Val         int
    Left, Right *Node
}

func inorder(n *Node, out *[]int) {
    if n == nil {
        return
    }
    inorder(n.Left, out)
    *out = append(*out, n.Val)   // посетить между детьми
    inorder(n.Right, out)
}

func preorder(n *Node, out *[]int) {
    if n == nil {
        return
    }
    *out = append(*out, n.Val)   // посетить до детей
    preorder(n.Left, out)
    preorder(n.Right, out)
}

func postorder(n *Node, out *[]int) {
    if n == nil {
        return
    }
    postorder(n.Left, out)
    postorder(n.Right, out)
    *out = append(*out, n.Val)   // посетить после детей
}
//        1
//       / \
//      2   3          t := &Node{1, &Node{2, &Node{Val: 4}, &Node{Val: 5}}, &Node{Val: 3}}
//     / \
//    4   5
//
// inorder   → [4 2 5 1 3]
// preorder  → [1 2 4 5 3]
// postorder → [4 5 2 3 1]
Дерево 4 2 6 1 3 7 у 6 нет левого ребёнка pre-order (корень, лево, право) 4 2 1 3 6 7 in-order (лево, корень, право) = отсортированный порядок в BST 1 2 3 4 6 7 post-order (лево, право, корень) = порядок удаления 1 3 2 7 6 4 BFS по уровням (очередь) [4] [2 6] [1 3 7] уровень отрезается по len(queue) на входе в итерацию
Одно дерево, четыре порядка. Позиция строки «посетить узел» относительно двух рекурсивных вызовов полностью определяет порядок; BFS вообще не рекурсия, а очередь.

Зачем каждый из них нужен на практике

  • in-order для BST даёт отсортированную последовательность. Отсюда и лучшая проверка «а точно ли это BST»: обходим in-order и смотрим, что каждый следующий строго больше предыдущего.
  • pre-order годится для сериализации: запишем его с маркерами nil, и дерево восстановится однозначно.
  • post-order освобождает ресурсы и считает всё «снизу вверх»: размер поддерева, высота, суммы. Ребёнок обработан раньше родителя, ровно это и нужно.
  • BFS даёт кратчайший путь в невзвешенном графе и всё, что связано с «уровнями»: вид справа, средние по уровням, ширина дерева.

Глубина и BFS по уровням

// Максимальная глубина устроена как post-order:
// сначала считаем детей, потом себя.
func maxDepth(n *Node) int {
    if n == nil {
        return 0
    }
    l, r := maxDepth(n.Left), maxDepth(n.Right)
    return 1 + max(l, r)          // max появился как builtin в Go 1.21
}

// BFS по уровням: отдельный слайс на каждый уровень.
func levelOrder(root *Node) [][]int {
    if root == nil {
        return nil                 // не забыть про пустое дерево
    }
    var res [][]int
    queue := []*Node{root}
    for len(queue) > 0 {
        n := len(queue)            // фиксируем размер уровня до цикла
        level := make([]int, 0, n)
        for i := 0; i < n; i++ {
            cur := queue[0]
            queue = queue[1:]      // на маленьких деревьях приемлемо
            level = append(level, cur.Val)
            if cur.Left != nil {
                queue = append(queue, cur.Left)
            }
            if cur.Right != nil {
                queue = append(queue, cur.Right)
            }
        }
        res = append(res, level)
    }
    return res
}
// На том же дереве (1; 2,3; 4,5):
// maxDepth(t)    → 3        maxDepth(nil)    → 0
// levelOrder(t)  → [[1] [2 3] [4 5]]
// levelOrder(nil) → [] (nil)
Главная ошибка в BFS по уровням

Написать for i := 0; i < len(queue); i++ и получить кашу — len(queue) растёт прямо внутри цикла, мы добавляем детей и тут же начинаем обрабатывать их как часть того же уровня. Размер уровня надо снять в переменную до внутреннего цикла. На этой строчке задача и заваливается, интервьюер смотрит именно на неё.

Итеративный in-order — на случай «а без рекурсии?»

func inorderIter(root *Node) []int {
    var res []int
    var stack []*Node
    cur := root
    for cur != nil || len(stack) > 0 {
        for cur != nil {           // спускаемся максимально влево
            stack = append(stack, cur)
            cur = cur.Left
        }
        cur = stack[len(stack)-1]  // взяли самый левый непосещённый
        stack = stack[:len(stack)-1]
        res = append(res, cur.Val)
        cur = cur.Right            // и переключились на правое поддерево
    }
    return res
}
// inorderIter(t)   → [4 2 5 1 3]  — ровно то же, что и рекурсия
// inorderIter(nil) → [] (nil)

Смысл ровно тот же, что у рекурсии, только стек вызовов мы держим руками. Это честный ответ на «а если дерево глубиной миллион и стек переполнится». Правда, в Go горутинный стек растёт динамически до maxstacksize (1 ГБ на 64-битных), так что практически рекурсия выдерживает очень глубокие деревья.

ОбходВремяПамятьТипичное применение
pre-orderO(n)O(h) стексериализация, копирование дерева
in-orderO(n)O(h) стексортированный вывод BST, валидация BST
post-orderO(n)O(h) стеквысота, размеры, освобождение
BFSO(n)O(w) очередьуровни, кратчайший путь

Здесь h означает высоту дерева (для сбалансированного O(log n), для вырожденного O(n)), w — максимальную ширину уровня, у полного дерева это примерно n/2. Отсюда вывод, который на собесе звучит неожиданно: BFS на широком дереве съедает больше памяти, чем DFS, хотя интуиция часто говорит обратное.

Добивки

  • «Проверь, что это BST». Критерий «левый ребёнок меньше родителя» тут неверен. Нужен либо in-order с проверкой возрастания, либо рекурсия с передачей диапазона (min, max) вниз.
  • «Найди наименьшего общего предка (LCA)». В BST спускаемся от корня, пока оба значения по одну сторону; в обычном дереве идём post-order и возвращаем «нашёл кого-то из двух».
  • «Сериализуй и десериализуй дерево». pre-order с явными # вместо nil; обратно читаем поток тем же порядком.
Суть: два указателя — медленный на шаг, быстрый на два. Если цикл есть, они обязательно встретятся; время O(n), память O(1). Вход в цикл находится вторым проходом: один указатель из головы, второй с места встречи, оба по шагу.

Базовая версия

type ListNode struct {
    Val  int
    Next *ListNode
}

func hasCycle(head *ListNode) bool {
    slow, fast := head, head
    for fast != nil && fast.Next != nil {   // оба условия обязательны
        slow = slow.Next
        fast = fast.Next.Next
        if slow == fast {
            return true
        }
    }
    return false                            // дошли до nil, цикла нет
}
// 1→2→3→4→2 (хвост замкнут на второй узел):  hasCycle(head) → true
// 1→2→3     (обычный список):                hasCycle(head) → false
// hasCycle(nil)                                              → false
Список формы «ро»: хвост длины m, затем цикл длины c A B C D E F вход в цикл место встречи slow и fast F.Next = C Шаг за шагом (slow на 1, fast на 2) такт 0: slow=A fast=A такт 1: slow=B fast=C такт 2: slow=C fast=E такт 3: slow=D fast=C ... сходятся внутри цикла Почему встреча гарантирована: попав внутрь цикла, fast догоняет slow на 1 узел за такт — разрыв убывает и обязательно становится нулём
Черепаха и заяц. Внутри цикла расстояние между указателями уменьшается ровно на единицу за такт, поэтому они встретятся не позже, чем через c тактов после того, как медленный вошёл в цикл.

Вход в цикл и его длина

// Возвращает узел-вход в цикл и длину цикла; (nil, 0) если цикла нет.
func detectCycle(head *ListNode) (*ListNode, int) {
    slow, fast := head, head
    met := false
    for fast != nil && fast.Next != nil {
        slow, fast = slow.Next, fast.Next.Next
        if slow == fast {
            met = true
            break
        }
    }
    if !met {
        return nil, 0
    }

    // Длина цикла: крутим один указатель до возврата в точку встречи.
    length := 1
    for p := slow.Next; p != slow; p = p.Next {
        length++
    }

    // Вход: один из головы, второй с места встречи, оба по одному шагу.
    p := head
    for p != slow {
        p, slow = p.Next, slow.Next
    }
    return p, length
}
// Список 1→2→3→4→ back to 2:
// detectCycle(head) → узел со значением 2, длина цикла 3
// Список 1→2 без цикла:
// detectCycle(head) → nil, 0
Доказательство второго прохода (спросят «почему это работает?»)

Пусть m будет длиной хвоста до входа в цикл, c — длиной цикла, k — расстоянием от входа до места встречи. Медленный прошёл m + k, быстрый ровно вдвое больше, 2(m + k). Разница пройденных путей кратна длине цикла: 2(m+k) − (m+k) = m + k = n·c. Отсюда m = n·c − k. Значит, если пустить один указатель из головы, а второй с места встречи, оба по шагу, то через m шагов первый окажется во входе, а второй пройдёт k + m = n·c, целое число оборотов от входа, то есть тоже во входе. Это тот случай, когда двух строчек арифметики достаточно, чтобы ответ прозвучал уверенно.

Альтернатива «мапой» и почему она хуже

func hasCycleMap(head *ListNode) bool {
    seen := make(map[*ListNode]struct{})
    for n := head; n != nil; n = n.Next {
        if _, ok := seen[n]; ok {
            return true
        }
        seen[n] = struct{}{}
    }
    return false
}
// 1→2→3→ back to 2:  hasCycleMap(head) → true
// 1 (один узел):     hasCycleMap(head) → false
// hasCycleMap(nil)                     → false

Работает и пишется за 30 секунд, но это O(n) памяти против O(1) у Флойда. На собесе такое решение стоит назвать первым («в лоб можно мапой указателей, O(n) памяти») и сразу предложить улучшение до O(1). Интервьюеру нравится именно эта последовательность: сначала рабочее, потом оптимальное.

Три ошибки, которые ловят в этой задаче
  • Проверять только fast != nil. Если fast стоит на последнем узле, fast.Next.Next даст панику по nil-указателю. Нужны оба условия.
  • Стартовать slow = head, fast = head.Next. Тогда ломается арифметика второго прохода и вход в цикл найдётся неправильно. Для базовой проверки «есть ли цикл» такой старт работает, для поиска входа — нет.
  • Забыть про head == nil. В приведённом коде это покрывается условием цикла, но проговорить надо.

Где это встречается за пределами литкода

Тот же алгоритм (он же «cycle detection») ищет период в псевдослучайных последовательностях, работает в ро-методе Полларда для факторизации и ловит зацикливание, когда разворачиваются символические ссылки или редиректы — везде, где состояние нельзя запомнить целиком, но можно шагать по нему дважды с разной скоростью. Одна фраза про это заметно поднимает впечатление от ответа.

Добивки

  • «Найди середину списка». Тот же приём: когда быстрый дошёл до конца, медленный ровно посередине.
  • «Найди k-й с конца за один проход». Разнести указатели на k и двигать вместе.
  • «Пересекаются ли два списка?» Два указателя, каждый после конца своего списка перепрыгивает на голову чужого; встретятся они в точке пересечения или оба в nil.
Суть: string — это неизменяемый набор байтов в UTF-8. s[i] даёт байт (byte), range s декодирует UTF-8 и даёт пары «байтовый индекс, руна». Для кириллицы один символ — 2 байта, поэтому len(s) и «число символов» — разные числа.

Наглядно: одна и та же строка тремя способами

s := "привет"

fmt.Println(len(s))                    // 12  — это байты, не символы
fmt.Println(utf8.RuneCountInString(s)) // 6   — вот это символы
fmt.Println(len([]rune(s)))            // 6   — то же, но с аллокацией

fmt.Printf("%c %T\n", s[0], s[0])      // Ð uint8 — половинка буквы «п».
                                       // Именно uint8: byte — это псевдоним, %T печатает базовый тип
for i, r := range s {
    fmt.Printf("%d:%c ", i, r)         // 0:п 2:р 4:и 6:в 8:е 10:т
}                                      // индексы прыгают через 2, это байтовые смещения
s := "привет" — 12 байт, 6 рун байты (hex): d0 bf d1 80 d0 b8 d0 b2 d0 b5 d1 82 0 1 2 3 4 5 6 7 8 9 10 11 range s даёт руны: п р и в е т i=0 i=2 i=4 i=6 i=8 i=10 d0 s[0] — это байт 0xd0, а не буква «п» Правило: индексация — по байтам всегда; символы — только через range, []rune или utf8.*
Байты против рун. Кириллица в UTF-8 занимает 2 байта на символ, поэтому байтовые индексы в range идут через один, а s[i] вырезает половину символа.

Классические задачи и правильные решения

// 1) Развернуть строку. Наивное s[len(s)-1-i] сломает UTF-8.
func reverse(s string) string {
    r := []rune(s)                       // одна аллокация, зато корректно
    for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
        r[i], r[j] = r[j], r[i]
    }
    return string(r)
}

// 2) Палиндром без аллокации: два указателя по рунам через DecodeRuneInString.
func isPalindrome(s string) bool {
    for len(s) > 0 {
        r1, size1 := utf8.DecodeRuneInString(s)
        r2, size2 := utf8.DecodeLastRuneInString(s)
        if unicode.ToLower(r1) != unicode.ToLower(r2) {
            return false
        }
        if len(s) == size1 {             // осталась одна руна, центр
            break
        }
        s = s[size1 : len(s)-size2]
    }
    return true
}

// 3) Частоты символов. Ключ обязательно rune, не byte.
func freq(s string) map[rune]int {
    m := make(map[rune]int)
    for _, r := range s {                // range сам декодирует
        m[r]++
    }
    return m
}

// 4) Обрезать строку до N символов, а не байт. Типичная задача «превью».
func truncate(s string, n int) string {
    if utf8.RuneCountInString(s) <= n {
        return s
    }
    cnt := 0
    for i := range s {                   // i — байтовое смещение начала руны
        if cnt == n {
            return s[:i]                 // режем строго по границе руны
        }
        cnt++
    }
    return s
}
// reverse("привет, Go!")   → "!oG ,тевирп"
// isPalindrome("шАлаШ")    → true    ← ToLower внутри, регистр не мешает
// isPalindrome("привет")   → false
// isPalindrome("А роза упала на лапу Азора") → false — пробелы и заглавные эта функция
//                                             не чистит, для них нужен вариант с фильтром
// freq("абба")             → map[1072:2 1073:2]  ← ключи-руны печатаются числами
// truncate("привет, мир", 6) → "привет"
// truncate("go", 6)          → "go"
// truncate("привет", 0)      → ""
Шесть ловушек, на которых валятся
  • len(s) как «число символов». Для ASCII совпадает, для кириллицы даёт вдвое больше, для эмодзи вчетверо.
  • for i := 0; i < len(s); i++ { s[i] } для символьной задачи режет байты. Такой цикл уместен ровно тогда, когда ты намеренно работаешь с байтами (например, парсишь ASCII-протокол).
  • map[byte]int для частот. Для «привет» получишь мусорные ключи 0xd0, 0xd1 с большими счётчиками.
  • strings.ToUpper для сравнения без учёта регистра. Правильно брать strings.EqualFold: он корректен и там, где верхний и нижний регистр не биективны.
  • s[:100] для «первых ста символов». Руна легко разрежется пополам, и в выводе появится U+FFFD.
  • Считать, что руна = «символ, который видит пользователь». Не всегда: «é» бывает одной руной U+00E9 и двумя (e + U+0301), а флаги и эмодзи с модификаторами вообще занимают несколько рун подряд. Это уже графемные кластеры, и в стандартной библиотеке их нет.
Что нужноКакСтоимость
число символовutf8.RuneCountInString(s)O(n), без аллокаций
перебрать символыfor _, r := range sO(n), без аллокаций
случайный доступ к i-му символу[]rune(s) один разO(n) времени и памяти
изменить строку[]byte или []rune, потом обратнокопия, строки неизменяемы
склеить много строкstrings.Builderамортизированно O(суммарной длины)
проверить валидность UTF-8utf8.ValidString(s)O(n)
Как это звучит на собесе

«В Go строка хранит неизменяемую последовательность байт, обычно в UTF-8. Индексация даёт байт, поэтому для символьных задач я беру либо range, либо конвертирую в []rune. range дешевле, он декодирует на лету и ничего не аллоцирует, но даёт байтовые индексы; []rune удобнее, когда нужен случайный доступ или обмен местами, но это O(n) памяти. Если задача про ASCII и производительность критична, я осознанно остаюсь на байтах и говорю об этом вслух». Так закрывается и невысказанная часть вопроса, про производительность.

Добивки

  • «Сколько памяти займёт []rune(s) 4 байта на руну (rune = int32), то есть для ASCII-строки выйдет вчетверо больше исходной.
  • «Что будет при конвертации невалидного UTF-8 в []rune Каждый невалидный байт превратится в U+FFFD (replacement character), паники не будет.
  • «Строка в Go — это что в памяти?» Двухсловный заголовок: {ptr *byte, len int}, 16 байт на 64-битной платформе. Подстрока s[a:b] не копирует байты, а делает новый заголовок на тот же массив, отсюда, кстати, и эффект «маленькая подстрока удерживает в памяти большой буфер».
  • «Почему строки неизменяемы?» Это позволяет свободно копировать заголовок, класть строки в ключи мапы и не бояться гонок при передаче между горутинами.

1.3Конкурентный лайвкодинг на Go

Самая частая секция лайвкодинга на Go-мидла, она же самая предсказуемая. Задач тут реально десяток, они повторяются из компании в компанию, и почти все сводятся к одному каркасу: каналы + WaitGroup + context. Разница между «решил» и «понравился» прячется в деталях, которых в задаче нет: кто закрывает канал, что будет при отмене на середине, где утечёт горутина, если потребитель ушёл раньше.

Что интервьюер оценивает на самом деле

Работающий на happy path код пишут почти все. Отсев идёт по четырём пунктам, и о них почти никогда не говорят вслух — их просто проверяют глазами по твоему коду.

КритерийЧто смотрит интервьюерКак выглядит провал
Корректное завершение без утечек Каждая запущенная горутина гарантированно заканчивается. Каждый канал закрывает ровно один владелец — тот, кто в него пишет. Потребитель дренирует канал до конца. Горутина навсегда висит на results <- r, потому что читатель вышел по таймауту. Классическая утечка, её ищут первым делом.
Обработка ошибок Ошибка воркера не теряется и не «съедает» остальные. Понятно, что возвращаем: первую ошибку, все ошибки, или отменяем всё остальное. _ = err внутри горутины. Или запись в общую переменную err из нескольких горутин без мьютекса — это гонка.
Отмена по context ctx первым аргументом, select с <-ctx.Done() на каждой блокирующей операции, ctx.Err() в результате. ctx принят, но нигде не проверяется. Или проверяется только в начале цикла, а внутри — блокирующая запись в канал без select.
Отсутствие гонок Общее состояние либо под мьютексом, либо вообще не общее (каждая горутина пишет в свой слот слайса). Готовность сказать «я бы прогнал go test -race». results = append(results, r) из нескольких горутин. Выглядит невинно, ломает слайс на первой же реаллокации.
Правило владения каналом, вокруг которого крутится половина добивок

Закрывает канал тот, кто в него пишет, и только если писатель один. Из этого правила выводится всё остальное. Если писателей несколько, закрывают не они, а отдельный «сторож»: go func(){ wg.Wait(); close(ch) }(). Закроет читатель, и писатель гарантированно словит панику «send on closed channel». Хочешь со стороны читателя сказать писателям «хватит», бери не close канала данных, а отдельный done-канал или context. Проговори это правило вслух до того, как начнёшь писать — интервьюер сразу поймёт, что ты это уже делал.

Типовой каркас: 90% задач — это он с вариациями

Выучи этот скелет до автоматизма. Он закрывает worker pool, pipeline, семафор и «N запросов параллельно» — меняются только детали. Цифры из комментариев разобраны ниже.

func process(ctx context.Context, items []Item, workers int) ([]Result, error) {
    tasks := make(chan Item)                 // 1
    results := make(chan Result)             // 2

    var wg sync.WaitGroup                    // 3 — считаем только воркеров
    wg.Add(workers)
    for i := 0; i < workers; i++ {
        go func() {
            defer wg.Done()
            for it := range tasks {          // 4 — выход по закрытию tasks
                r, err := do(ctx, it)
                select {
                case results <- Result{Val: r, Err: err}:   // 5
                case <-ctx.Done():
                    return
                }
            }
        }()
    }

    go func() {                              // 6 — продюсер, единственный владелец tasks
        defer close(tasks)
        for _, it := range items {
            select {
            case tasks <- it:
            case <-ctx.Done():
                return
            }
        }
    }()

    go func() {                              // 7 — сторож закрывает results
        wg.Wait()
        close(results)
    }()

    out := make([]Result, 0, len(items))
    for r := range results {                 // 8 — дренаж до закрытия
        out = append(out, r)
    }
    return out, ctx.Err()
}
  1. Небуферизованный канал задач. Буфер тут не нужен: он не ускоряет, а лишь позволяет продюсеру убежать вперёд. На собесе скажи «буфер оптимизирует под конкретный профиль нагрузки, по умолчанию беру 0, потому что так проще рассуждать о завершении».
  2. Канал результатов отдельный. Совмещать вход и выход в один канал почти всегда ошибка проектирования.
  3. wg.Add(workers) до цикла, а не wg.Add(1) внутри горутины. Внутри горутины получается гонка: wg.Wait() может успеть отработать раньше первого Add.
  4. for range по каналу. Самый лаконичный способ выйти по закрытию. Не нужен ни ok, ни лишний select.
  5. Запись в results под select с ctx.Done(). Это и есть та самая деталь, которую ищут. Без неё воркер повиснет навсегда, если потребитель ушёл по таймауту.
  6. Продюсер в отдельной горутине. Если писать в tasks из основной, то при небуферизованном канале и медленных воркерах основная встанет — а нам ещё читать results. Дедлок.
  7. Сторож. Писателей в results много, поэтому закрывает не воркер, а отдельная горутина после wg.Wait(). Единственная законная причина для этой горутины.
  8. Дренаж. Читаем до закрытия канала. Именно это гарантирует, что ни один воркер не останется висеть на записи.
Worker pool: у каждого канала ровно один владелец, который его закрывает producer close(tasks) tasks chan Item worker 1 worker 2 worker 3 for it := range tasks все читают ОДИН канал — это и есть распределение нагрузки results chan Result range results собирает всё wg.Wait() close(results) воркер вышел → wg.Done() ctx.Done() виден всем троим: продюсеру, воркерам и записи в results — везде через select
Worker pool. Два канала и три роли. Продюсер пишет в tasks один, поэтому закрывает его сам. Писателей в results трое, поэтому закрывает отдельный сторож после wg.Wait(). Результаты собирает основная горутина: конкурентного доступа там нет, значит нет и гонки.
Как проговаривать решение вслух: сценарий на 60 секунд

Не начинай печатать сразу. Три-четыре фразы до кода стоят дороже, чем идеальный код без слов. Работающая последовательность:

  1. Контракт. «На входе слайс задач и число воркеров, на выходе все результаты и ошибка. Уточню: нужно ли остановиться на первой ошибке или собрать все? Порядок результатов важен?» Это два вопроса, которые интервьюер ждёт, и почти никто их не задаёт.
  2. Схема. «Делаю канал задач, N воркеров читают из него, пишут в канал результатов. Продюсер закрывает задачи, отдельная горутина после wg.Wait() закрывает результаты.» Одной фразой ты уже показал, что знаешь про владение каналом.
  3. Опасные места, до того как их найдут. «Запись в результаты обязательно под select с ctx.Done(), иначе при раннем выходе читателя воркеры утекут.»
  4. Пишешь код и комментируешь только неочевидное. Не озвучивай for i := 0; i < n; i++. Скажи лучше, почему wg.Add стоит до цикла.
  5. После кода проверь себя вслух. «Проверю на утечки: продюсер выходит по ctx или по концу слайса, воркеры по закрытию tasks, сторож по wg.Wait(). Прогнал бы go test -race и тест с goleak
Семь способов провалить конкурентную задачу за 30 секунд
  • wg.Add(1) внутри горутины. Гонка между Add и Wait; Wait может вернуться до старта воркеров.
  • Забыть close(tasks). Воркеры навсегда висят в range, wg.Wait() не вернётся, программа зависнет насмерть.
  • close(results) из воркера. Второй воркер тут же получит панику «send on closed channel».
  • wg.Wait() в основной горутине до чтения результатов при небуферизованном канале. Воркеры блокируются на записи, Wait никогда не вернётся — дедлок в чистом виде.
  • Запись в общий слайс через append из воркеров. Гонка. Либо канал, либо мьютекс, либо каждый пишет в out[i] по своему индексу — последнее, кстати, безопасно и без синхронизации, потому что элементы слайса не пересекаются.
  • Захват переменной цикла до Go 1.22. for _, t := range tasks { go func(){ use(t) }() }: до 1.22 все горутины увидят последнюю задачу.
  • Паника в воркере. Без recover она валит весь процесс. Если задача про продакшн-код, скажи, что в воркере стоял бы defer func(){ if r := recover(); r != nil { ... } }().
Глубже, чем спросят: почему errgroup правильный ответ, но не первый

В реальном коде почти всё из этой главы пишут через golang.org/x/sync/errgroup: g, ctx := errgroup.WithContext(ctx), g.SetLimit(n), g.Go(func() error{...}), g.Wait(). Он сам собирает первую ошибку, сам отменяет производный ctx, а SetLimit (появился в v0.1.0 модуля пакета) заменяет семафор. Но на лайвкодинге тебя просят написать механику руками. Предложишь errgroup сразу, попросят «а теперь без него». Стратегия такая: пишешь руками, а в конце добавляешь фразу «в проде я бы взял errgroup с SetLimit, потому что это ровно этот код, но оттестированный». Так закрыты обе стороны: и «умеет руками», и «не изобретает велосипед на работе».

Вопросы

11
Суть: один канал задач, N читающих его горутин, один канал результатов. Продюсер закрывает задачи, сторож после wg.Wait() закрывает результаты. Ошибки — это данные: они едут в том же канале результатов, а не в общей переменной. Стратегий две — «собрать все» и «упасть на первой», и выбирать её должен вызывающий, а не библиотека.

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

type Job struct {
    ID  int
    Arg string
}

type Result struct {
    JobID int
    Val   string
    Err   error
}

// Pool обрабатывает jobs не более чем workers горутинами.
// Возвращает результаты в порядке завершения и объединённую ошибку.
func Pool(
    ctx context.Context,
    jobs []Job,
    workers int,
    do func(context.Context, Job) (string, error),
) ([]Result, error) {

    if workers <= 0 {
        workers = 1
    }
    if workers > len(jobs) {
        workers = len(jobs)          // не плодим горутины без работы
    }
    if len(jobs) == 0 {
        return nil, nil              // ранний выход: без задач и работать не над чем
    }

    tasks := make(chan Job)
    results := make(chan Result)

    var wg sync.WaitGroup
    wg.Add(workers)
    for i := 0; i < workers; i++ {
        go func() {
            defer wg.Done()
            for j := range tasks {
                val, err := do(ctx, j)
                select {
                case results <- Result{JobID: j.ID, Val: val, Err: err}:
                case <-ctx.Done():
                    return           // читателя больше нет — уходим, а не виснем
                }
            }
        }()
    }

    // Продюсер: единственный писатель в tasks, поэтому закрывает его сам.
    go func() {
        defer close(tasks)
        for _, j := range jobs {
            select {
            case tasks <- j:
            case <-ctx.Done():
                return
            }
        }
    }()

    // Сторож: писателей в results много, закрыть должен кто-то один.
    go func() {
        wg.Wait()
        close(results)
    }()

    out := make([]Result, 0, len(jobs))
    var errs []error
    for r := range results {         // дочитываем до конца, иначе утекут горутины
        out = append(out, r)
        if r.Err != nil {
            errs = append(errs, fmt.Errorf("job %d: %w", r.JobID, r.Err))
        }
    }
    if err := ctx.Err(); err != nil {
        errs = append(errs, err)     // отменили на середине, это тоже результат
    }
    return out, errors.Join(errs...) // Join вернёт nil, если errs пуст
}
// jobs = 5 штук, workers = 3, третья задача возвращает ошибку.
// Характерный прогон:
//   {3  сломался}
//   {2 B <nil>}
//   {1 A <nil>}
//   {5 E <nil>}
//   {4 "D" <nil>}
//   err: job 3: сломался
// Порядок строк меняется от запуска к запуску, это результаты в порядке завершения.
// Стабильно только одно: все 5 результатов на месте и err не nil.

Вариант «fail-fast»: первая ошибка отменяет остальное

Меняется ровно одна вещь: заводим производный ctx с cancel и дёргаем его на первой ошибке. Воркеры увидят отмену в своём select и в do(ctx, ...), продюсер перестанет подавать задачи.

func PoolFailFast(ctx context.Context, jobs []Job, workers int,
    do func(context.Context, Job) (string, error)) ([]Result, error) {

    ctx, cancel := context.WithCancel(ctx)
    defer cancel()                   // иначе утечёт сам context

    // ... тот же запуск воркеров и продюсера с новым ctx ...

    var out []Result
    var firstErr error
    for r := range results {
        if r.Err != nil && firstErr == nil {
            firstErr = r.Err
            cancel()                 // сигнал всем остальным: сворачиваемся
            continue                 // но канал дочитываем до конца
        }
        out = append(out, r)
    }
    return out, firstErr
}
Деталь fail-fast, которую почти всегда пропускают

После cancel() нельзя делать break из цикла чтения. Воркеры, которые уже стоят на results <- r, выйдут по ctx.Done() — но только те, у кого select написан правильно. Дочитать канал до закрытия дешевле и надёжнее: гарантия работает независимо от того, как написан воркер. Формулировка на собесе: «я всегда дренирую канал до close, а не выхожу по break, тогда отсутствие утечки следует из структуры, а не из аккуратности».

Если нужен порядок результатов — не сортируй, пиши по индексу

// Результаты строго в порядке входного слайса, без канала результатов вообще.
out := make([]Result, len(jobs))     // длина фиксирована, реаллокаций не будет
var wg sync.WaitGroup
sem := make(chan struct{}, workers)  // ограничитель параллельности

for i, j := range jobs {
    wg.Add(1)
    go func(i int, j Job) {          // до Go 1.22 параметры обязательны
        defer wg.Done()
        sem <- struct{}{}
        defer func() { <-sem }()
        val, err := do(ctx, j)
        out[i] = Result{JobID: j.ID, Val: val, Err: err}  // свой слот, гонки нет
    }(i, j)
}
wg.Wait()
// Те же 5 задач и 3 воркера, но результат теперь детерминирован по порядку:
// [{1 A <nil>} {2 B <nil>} {3  сломался} {4 D <nil>} {5 E <nil>}]
// Сколько раз ни запусти, порядок тот же: каждый пишет в свой out[i].

Запись в out[i] из разных горутин безопасна: элементы слайса не пересекаются в памяти, а сам заголовок слайса никто не меняет (длина зафиксирована при make). Это одно из немногих мест, где «общая переменная без мьютекса» работает корректно, и объяснить это вслух дорогого стоит. И ни одного append: он меняет заголовок и превращает всё в гонку.

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

  • wg.Add(1) внутри горутины. Планировщик может не успеть запустить горутину до wg.Wait(), и Wait вернётся на пустом счётчике. Add всегда зови в вызывающей горутине, до go.
  • Забыт ранний выход при len(jobs) == 0. Тогда workers = 0, wg.Wait() возвращается сразу, канал закрывается, так что тут всё сойдётся. А вот если workers не обрезали и он отрицательный, wg.Add паникует: sync: negative WaitGroup counter. А при нуле воркеров функция молча вернёт пустой результат, и продюсер повиснет навсегда. Границы проверить всё равно придётся.
  • Общая переменная err вместо канала. if err != nil { globalErr = err } из нескольких горутин даёт гонку, которую -race поймает мгновенно. Ошибка должна ехать как поле результата.
  • defer cancel() забыт в fail-fast варианте. context.WithCancel регистрирует дочерний контекст у родителя; без cancel он висит там до завершения родителя. go vet это ловит (lostcancel).
  • Буферизованный results «чтобы не блокировались». Это маскировка проблемы, а не решение. Буфер размера len(jobs) действительно уберёт блокировку, но съест память и спрячет то, что select с ctx не написан. Интервьюер это заметит.

Добивки, которые задаст интервьюер

  • «А если задачи приходят потоком, а не слайсом?» Сигнатура меняется на jobs <-chan Job, продюсер не нужен вообще — воркеры читают внешний канал напрямую. Закрывать его будет вызывающий, а мы просто выйдем из range.
  • «Как ограничить не число воркеров, а RPS?» Это уже rate limiter, а не pool: добавляем <-limiter.C перед вызовом do. Пул ограничивает одновременность, лимитер бьёт по частоте. Оси разные, и путают их постоянно: типичная ошибка проектирования.
  • «Что если воркер паникует?» Без recover падает весь процесс. В воркере ставим defer func(){ if r := recover(); r != nil { results <- Result{Err: fmt.Errorf("panic: %v", r)} } }() — и обязательно тоже под select с ctx, иначе паника превратится в утечку.
  • «Сколько воркеров брать?» Для CPU-bound бери runtime.GOMAXPROCS(0). Для IO-bound сильно больше, ограничение диктует не CPU, а внешний ресурс: пул соединений БД, лимит стороннего API. Ответ «зависит от того, во что упирается задача» лучше любого числа.
  • «Как протестируешь, что горутины не текут?» go.uber.org/goleak в TestMain, плюс go test -race. Ещё можно сравнить runtime.NumGoroutine() до и после с небольшим ожиданием.
  • «Перепиши на errgroup». g, ctx := errgroup.WithContext(ctx), g.SetLimit(workers), в цикле g.Go(...), в конце g.Wait(). Он даст ровно fail-fast семантику и первую ошибку.
Суть: по горутине на каждый вход, все пишут в общий out, отдельный сторож закрывает out после wg.Wait(). Закрыть его из горутины-читателя нельзя — остальные получат панику. Если читатель уходит раньше, спасает только select с ctx.Done() на записи в out.

Каноническая реализация

// Merge сливает произвольное число входных каналов в один.
// Закрывается, когда закрылись все входы. Отмена ctx спасает пишущего,
// но не читающего: range по входу отмену не видит.
func Merge[T any](ctx context.Context, ins ...<-chan T) <-chan T {
    out := make(chan T)

    var wg sync.WaitGroup
    wg.Add(len(ins))

    for _, in := range ins {
        go func(in <-chan T) {       // параметр нужен до Go 1.22
            defer wg.Done()
            for v := range in {      // выходим, когда вход закрыт
                select {
                case out <- v:
                case <-ctx.Done():   // читатель ушёл, не виснем
                    return
                }
            }
        }(in)
    }

    go func() {                      // единственный, кто имеет право на close
        wg.Wait()
        close(out)
    }()

    return out
}
// Merge(ctx, src(1,2,3), src(10,20,30), src(100,200)) — шесть прогонов подряд:
//   1 2 100 10 3 20 200 30
//   1 10 100 2 20 200 3 30
//   10 100 1 20 200 2 30 3
//   1 2 10 100 3 20 200 30
//   100 10 1 200 20 2 30 3
//   1 2 100 3 10 200 20 30
// Гарантируется только состав (все 8 значений) и порядок внутри каждого входа.
// Как входы чередуются между собой, решает планировщик.

Возвращаем <-chan T, а не chan T. Это не косметика: направленный тип на уровне компилятора запрещает вызывающему писать в канал и закрывать его. Половина проблем с «кто закрыл канал» решается именно типом. Скажи это вслух: деталь уровня «писал такое в проде».

Fan-in: по горутине на вход, один общий выход, один сторож на close входы in[0] in[1] in[2] go: range in[0] go: range in[1] go: range in[2] out chan T select { out <- v | <-ctx.Done() } потребитель: range out wg.Wait() close(out) каждая горутина: wg.Done() Порядок элементов в out не определён: он зависит от того, какая горутина первой доехала до записи.
Fan-in. Число писателей в out равно числу входов, поэтому закрывает отдельная горутина: только эта конструкция корректно отвечает на вопрос «кто закрывает канал, когда писателей много».

Вариант без горутины на канал: reflect.Select

Если N известно только в рантайме и горутин жалко, есть reflect.Select — он делает динамический select по слайсу SelectCase. Одна горутина вместо N. На собесе это отличная «добивка сверху», но в проде почти не нужна: горутина стоит ~2–8 КБ стека, а рефлексия обходится примерно вдвое дороже на каждой операции.

func MergeReflect[T any](ins ...<-chan T) <-chan T {
    out := make(chan T)
    go func() {
        defer close(out)
        cases := make([]reflect.SelectCase, len(ins))
        for i, in := range ins {
            cases[i] = reflect.SelectCase{
                Dir:  reflect.SelectRecv,
                Chan: reflect.ValueOf(in),
            }
        }
        for len(cases) > 0 {
            i, v, ok := reflect.Select(cases)
            if !ok {                          // канал закрыт, убираем из набора
                cases = append(cases[:i], cases[i+1:]...)
                continue
            }
            out <- v.Interface().(T)
        }
    }()
    return out
}

Без строки, которая удаляет кейс, закрытый канал будет отдавать нулевое значение с ok == false в бесконечном цикле и займёт ядро на 100%. Это классическая ловушка select по закрытому каналу, и в статическом select она лечится присваиванием ch = nil: nil-канал в select блокируется навсегда и тем самым «выключает» ветку. Приём с ch = nil стоит назвать, даже если тебя не спрашивали: он выдаёт человека, который писал реальные pipeline.

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

  • close(out) внутри горутины-читателя входа. Первая же закрывшаяся ветка закроет общий канал, остальные упадут с паникой на записи.
  • defer close(out) в Merge напрямую. Функция возвращает управление сразу, канал закроется до того, как что-то в него попадёт.
  • Нет select с ctx.Done() на записи. Работает, пока потребитель читает всё. Как только он выходит по первому найденному элементу — N горутин зависают навсегда. Это самая частая утечка в реальных кодовых базах.
  • Захват in без параметра до Go 1.22. Все горутины читают последний канал, остальные не читает никто: данные оттуда пропадают, а -race ещё и показывает гонку на самой переменной цикла.
  • Ожидание порядка. Fan-in ничего не гарантирует про порядок. Нужен порядок, значит это уже не fan-in, а слияние с приоритетом (куча по ключу) или нумерация элементов.

Добивки, которые задаст интервьюер

  • «А если один вход завис навсегда и не закрывается?» Merge никогда не закроет out. И ctx с таймаутом тут не поможет, пока приём не обёрнут в select: горутина стоит в range и отмену не видит. Признать эту зависимость важнее, чем героически её обходить.
  • «Как сделать fan-out обратно?» Fan-out получается, когда N горутин читают из одного канала (ровно наш worker pool). Симметрия «fan-out → работа → fan-in» и есть базовый конкурентный паттерн Go.
  • «Что если входов тысячи?» Тысячу горутин рантайм переварит (несколько мегабайт), но на десятках тысяч уже стоит подумать про reflect.Select или про иерархическое слияние: сливать по 8 каналов в дерево глубины log₈ N.
  • «Сделай так, чтобы out закрывался при отмене ctx». В этом виде не закроется: ctx.Done() стоит только в ветке записи, а горутина висит на range in и отмену не видит. Чтобы закрывался, приём тоже надо развернуть в select с ctx.Done() — вот тогда wg.Wait() вернётся и сторож закроет канал.
  • «Зачем возвращать <-chan, а не chan Компилятор запретит вызывающему писать и закрывать. Владение выражено типом, а не комментарием.
Суть: буферизованный канал ёмкости K — это и есть семафор. Запись в него — Acquire, чтение — Release. Отличие от пула: пул создаёт K горутин и раздаёт им задачи, семафор создаёт горутину на каждую задачу, но пропускает внутрь только K. Пул экономит горутины, семафор проще вписывается в существующий код.

Минимальная версия, которую пишут на доске

sem := make(chan struct{}, K)        // ёмкость = число разрешений

for _, u := range urls {
    wg.Add(1)
    go func(u string) {
        defer wg.Done()
        sem <- struct{}{}            // Acquire: блокируется, когда занято K мест
        defer func() { <-sem }()     // Release строго через defer
        call(u)
    }(u)
}
wg.Wait()

struct{}{}, а не bool: пустая структура занимает ноль байт, и весь буфер канала на K разрешений не стоит ничего. Это мелочь, но её замечают.

Нормальный семафор: с context, TryAcquire и весами

type Semaphore chan struct{}

func NewSemaphore(n int) Semaphore { return make(Semaphore, n) }

// Acquire ждёт свободный слот или отмены контекста.
func (s Semaphore) Acquire(ctx context.Context) error {
    select {
    case s <- struct{}{}:
        return nil
    case <-ctx.Done():
        return ctx.Err()             // слот не занят, Release звать нельзя
    }
}

// TryAcquire не ждёт: либо взял, либо сразу false.
func (s Semaphore) TryAcquire() bool {
    select {
    case s <- struct{}{}:
        return true
    default:                         // default делает select неблокирующим
        return false
    }
}

func (s Semaphore) Release() { <-s }

// Занято прямо сейчас / всего слотов.
func (s Semaphore) InUse() int { return len(s) }
func (s Semaphore) Cap() int   { return cap(s) }

Как это выглядит в реальном клиенте внешнего API

type Client struct {
    http *http.Client
    sem  Semaphore                   // не более K одновременных запросов
}

func (c *Client) Get(ctx context.Context, url string) (*Response, error) {
    if err := c.sem.Acquire(ctx); err != nil {
        return nil, fmt.Errorf("ожидание слота: %w", err)
    }
    defer c.sem.Release()            // defer сразу после успешного Acquire, иначе утечёт слот

    req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
    if err != nil {
        return nil, err
    }
    resp, err := c.http.Do(req)
    ...
}
Правило, из-за которого семафор «протекает»

Release зови только если Acquire вернул nil. Если Acquire вышел по ctx.Done(), слот не занят, и лишний <-sem заберёт чужое разрешение. Через несколько таких ошибок семафор начинает пропускать K+1, K+2… одновременных вызовов — и ловится это мучительно, потому что всё «почти работает». Отсюда шаблон: if err := Acquire(); err != nil { return err }; defer Release(), где defer стоит строго ниже проверки ошибки.

Семафор vs worker pool — в чём реально разница

Worker poolСемафор
Горутин создаётсяровно K, живут всё времяпо одной на задачу, N штук
Стоимость на 1M задачK стеков1M создаваний горутин (дёшево, но не бесплатно)
Порядок задачзадачи разбираются из общего каналакто первый дошёл до Acquire
Встраивание в чужой коднужна перестройка потока управлениядве строки внутрь существующей функции
Backpressure (обратное давление) — тормозит ли медленный обработчик самого производителя задачесть: небуферизованный канал задач тормозит егонет: продюсер плодит горутины, они копятся на Acquire
Когда лучшепоток задач, long-running обработкаограничить конкретный вызов внутри уже готового кода

Последнюю строку спрашивают чаще всего, и она же самая важная. Если задач миллион и ты пишешь go на каждую, семафор ограничит одновременные вызовы API, но не спасёт от миллиона горутин, ждущих в очереди. Память улетит. Тут нужен именно пул: он создаёт обратное давление на продюсера через небуферизованный канал.

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

  • Release не через defer. Любой ранний return или паника — и слот потерян навсегда. Через несколько ошибок пропускная способность падает до нуля, и программа «зависает» без единой явной блокировки.
  • Acquire без context. Тогда при перегрузке внешнего API горутины будут ждать вечно, и отменить их нечем.
  • Release после неуспешного Acquire. Семафор начинает пропускать больше K.
  • len(sem) для проверки «есть ли место» перед записью. Классический check-then-act: между проверкой и записью состояние изменится. Правильно и неблокирующе это делает select с default.
  • Путать семафор с rate limiter. Семафор ограничивает сколько одновременно, лимитер считает, сколько в секунду. Если API говорит «100 rps», семафор на 100 не даст гарантии: при времени ответа 10 мс это будет 10 000 rps.

Добивки, которые задаст интервьюер

  • «Сделай взвешенный семафор» (одна задача занимает n слотов). На канале это неудобно: Acquire(n) по n записей не атомарен и даёт дедлоки при конкуренции. Нужен sync.Cond или готовый golang.org/x/sync/semaphore.Weighted — внутри у него мьютекс и список ожидающих.
  • «Справедлив ли такой семафор?» Нет. Go не гарантирует FIFO среди горутин, заблокированных на канале — на практике планировщик обслуживает их близко к FIFO, но это деталь реализации, а не контракт. Нужна честность, заводи очередь заявок явно.
  • «Как узнать текущую загрузку?» len(sem) покажет занятые слоты, cap(sem) все. Годится для метрики, не годится для решения (гонка).
  • «Можно ли то же самое одной строкой?» errgroup.Group.SetLimit(K), внутри у него такой же буферизованный канал.
  • «Что если K нужно менять на лету?» Ёмкость канала неизменна. Либо пересоздавать семафор под мьютексом, либо переходить на sync.Cond с обычным счётчиком.
Суть: каждая стадия — функция <-chan In → <-chan Out, которая сама создаёт свой выходной канал, запускает горутину и закрывает канал через defer. Завершение идёт каскадом слева направо от закрытия первого канала. Досрочная остановка — только через ctx, который видит каждая стадия.

Полный рабочий pipeline

// Стадия 1: генератор. Единственная стадия без входного канала.
func gen(ctx context.Context, nums ...int) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)             // владелец канала закрывает его сам
        for _, n := range nums {
            select {
            case out <- n:
            case <-ctx.Done():
                return               // defer всё равно закроет out
            }
        }
    }()
    return out
}

// Стадия 2: фильтр. Сигнатура один-в-один как у любой другой стадии.
func filter(ctx context.Context, in <-chan int, pred func(int) bool) <-chan int {
    out := make(chan int)
    go func() {
        defer close(out)
        for v := range in {          // каскад: закрылся вход, вышли
            if !pred(v) {
                continue
            }
            select {
            case out <- v:
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}

// Стадия 3: трансформация.
func mapStage[T, R any](ctx context.Context, in <-chan T, f func(T) R) <-chan R {
    out := make(chan R)
    go func() {
        defer close(out)
        for v := range in {
            select {
            case out <- f(v):
            case <-ctx.Done():
                return
            }
        }
    }()
    return out
}

// Сборка: стадии просто вкладываются друг в друга.
func main() {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()

    nums := gen(ctx, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10)
    even := filter(ctx, nums, func(n int) bool { return n%2 == 0 })
    sq   := mapStage(ctx, even, func(n int) int { return n * n })

    for v := range sq {
        fmt.Println(v)               // 4, потом 16, потом 36; на 36 сработает break
        if v > 30 {
            cancel()                 // уходим раньше: отменяем все стадии
            break
        }
    }
    // После cancel() каждая стадия выйдет по ctx.Done() и закроет свой out.
}

// вывод, ровно три строки:
// 4
// 16
// 36
// Стадии умеют выдать 4 16 36 64 100, но потребитель уходит на первом же значении > 30.
// 64 и 100 не напечатаются никогда, и поэтому нужен ctx: без него
// три горутины навсегда зависли бы на записи в канал, который больше никто не читает.

Три свойства, которые делают это правильным pipeline

  1. Каждая стадия владеет только своим выходным каналом. Она его создаёт, пишет в него и закрывает. Входной канал она только читает и никогда не закрывает. Поэтому завершение идёт само: закрылся вход — вышел range — сработал defer close(out) — закрылся выход следующей стадии, и так до конца.
  2. Однородная сигнатура. func(ctx, <-chan In, ...) <-chan Out. Стадии свободно складываются друг с другом: их можно переставлять, добавлять, оборачивать в Merge, чтобы распараллелить одну стадию.
  3. ctx в каждой стадии. Каскад закрытия работает слева направо, но останавливаться досрочно приходится справа налево — потребитель решил, что хватит. Канала данных для этого нет, нужен отдельный сигнал: ctx (раньше писали done chan struct{}: та же идея, только ctx удобнее).
Что случится без ctx: канонический пример утечки

Убери из кода выше все select с ctx.Done(), оставив просто out <- v. Программа напечатает те же числа и корректно завершится, потому что main дочитывает канал. А теперь верни break после v > 30: main уходит, стадия возведения в квадрат виснет на записи 64, фильтр — на записи 10, а генератор успевает закрыть свой канал и выйти. Две горутины и их каналы живут до конца процесса. Ни паники, ни ошибки, ни единого симптома — кроме медленно растущей памяти в проде. Именно этот сценарий проверяют вопросом «а если потребитель ушёл раньше?».

Параллельная стадия: fan-out + fan-in внутри pipeline

Если одна стадия стала узким местом (например, ходит в сеть), её распараллеливают, не ломая сигнатуру: запускаем M копий стадии на одном входе и сливаем их выходы через Merge из предыдущего вопроса.

func parallel[T, R any](ctx context.Context, in <-chan T, m int, f func(T) R) <-chan R {
    outs := make([]<-chan R, m)
    for i := 0; i < m; i++ {
        outs[i] = mapStage(ctx, in, f)   // все M читают один in: это fan-out
    }
    return Merge(ctx, outs...)           // и сливаются обратно: это fan-in
}

Порядок элементов после такой стадии теряется. Если он важен, либо не распараллеливай стадию, либо тащи порядковый номер внутри элемента и пересобирай на выходе.

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

  • Стадия закрывает входной канал. Предыдущая стадия тут же паникует на записи. Правило: закрываю только то, что создал.
  • close(out) не через defer, а в конце горутины. Любой ранний return (по ctx, по ошибке) оставит канал незакрытым, и следующая стадия зависнет в range навсегда.
  • Общий буферизованный канал «на всякий случай». Буфер сдвигает момент блокировки, но утечку не убирает — она просто проявится на другом объёме данных.
  • cancel() без дренажа. Технически стадии выйдут сами, но если стадия держит ресурс (открытый файл, соединение), освобождать его надо в defer той же горутины, а не надеяться на GC.
  • Ошибки некуда девать. Стадия возвращает один канал значений, а error деть некуда. Решения два: канал struct{ Val T; Err error } (просто, работает) или отдельный errCh (сложнее в завершении). На собесе назови первый.

Добивки, которые задаст интервьюер

  • «Как передать ошибку через pipeline?» Тип элемента становится Result[T]{Val T; Err error}. Стадии пропускают элементы с ошибкой дальше без обработки, и ошибка доезжает до потребителя, не ломая структуру.
  • «Зачем done chan struct{}, если есть context Это исторический вариант из статьи «Go Concurrency Patterns: Pipelines» (2014), написанной до появления context в стандартной библиотеке. Механика идентична: закрытый канал делает <-done мгновенно готовым для всех читателей. context добавляет к этому дедлайны, значения и иерархию отмены.
  • «Сколько буферизовать между стадиями?» По умолчанию ноль. Буфер имеет смысл, если стадии неравномерны по времени и есть всплески: он сглаживает jitter. Но буфер = задержка в очереди и память; размер подбирай замером, а не на глаз.
  • «Что если стадия должна отдавать несколько элементов на один входной?» Ничего не меняется — просто for внутри for, каждая запись под select. Число элементов на входе и выходе стадии никак не связано.
  • «Как это тестировать?» Стадия работает как чистая функция от канала к каналу: подаёшь на вход закрытый канал с известными значениями, читаешь выход в слайс, сравниваешь. Плюс тест на отмену: cancel() сразу, проверяешь, что выходной канал закрылся, и goleak в TestMain.
Суть: начинаем с map под sync.Mutex — это правильный дефолт. RWMutex помогает только при долгих чтениях и подавляющем перевесе Get. sync.Map хорош ровно в двух сценариях (write-once-read-many и раздельные ключи по горутинам). Шардирование — единственное, что масштабируется линейно по ядрам, и именно его ждут в финале разговора.

Шаг 1. Базовый вариант: map + Mutex

type Cache[K comparable, V any] struct {
    mu sync.Mutex
    m  map[K]V
}

func New[K comparable, V any]() *Cache[K, V] {
    return &Cache[K, V]{m: make(map[K]V)}
}

func (c *Cache[K, V]) Get(k K) (V, bool) {
    c.mu.Lock()
    defer c.mu.Unlock()
    v, ok := c.m[k]
    return v, ok
}

func (c *Cache[K, V]) Set(k K, v V) {
    c.mu.Lock()
    defer c.mu.Unlock()
    c.m[k] = v
}

func (c *Cache[K, V]) Delete(k K) {
    c.mu.Lock()
    defer c.mu.Unlock()
    delete(c.m, k)
}

// GetOrSet: атомарно «прочитать, а если нет, записать».
// Без неё кэш неправильный: Get+Set снаружи не атомарны.
func (c *Cache[K, V]) GetOrSet(k K, v V) (V, bool) {
    c.mu.Lock()
    defer c.mu.Unlock()
    if old, ok := c.m[k]; ok {
        return old, true             // true = уже было
    }
    c.m[k] = v
    return v, false
}
// c := New[string, int]()
// c.Get("a")          → 0, false
// c.Set("a", 1)
// c.Get("a")          → 1, true
// c.GetOrSet("a", 99) → 1, true    ← 99 не записалось, вернулось старое
// c.GetOrSet("b", 2)  → 2, false   ← false = «положили новое»
// c.Delete("a"); c.Get("a") → 0, false
Почему GetOrSet обязателен

Кэш с одними Get и Set формально потокобезопасен — гонки в нём нет. Но вызывающий напишет if _, ok := c.Get(k); !ok { c.Set(k, compute()) }, и это уже логическая гонка: между Get и Set вклинится другая горутина. Потокобезопасность отдельных методов не даёт потокобезопасности их комбинации — эту фразу на собесе стоит произнести дословно. Атомарной должна быть та операция, которая имеет смысл для вызывающего.

Шаг 2. RWMutex — и почему он не бесплатный

type RWCache[K comparable, V any] struct {
    mu sync.RWMutex
    m  map[K]V
}

func (c *RWCache[K, V]) Get(k K) (V, bool) {
    c.mu.RLock()                     // много читателей одновременно
    defer c.mu.RUnlock()
    v, ok := c.m[k]
    return v, ok
}

func (c *RWCache[K, V]) Set(k K, v V) {
    c.mu.Lock()                      // писатель один
    defer c.mu.Unlock()
    c.m[k] = v
}

Интуиция «читателей много, значит RWMutex быстрее» верна не всегда. RLock атомарно инкрементит общий счётчик читателей, то есть пишет в одну и ту же кэш-линию из всех ядер. Получается cache line contention: строка постоянно перегоняется между ядрами в состоянии Modified. Если критическая секция короткая (а m[k] укладывается в десятки наносекунд), накладные расходы на этот пинг-понг сопоставимы с самой работой, и RWMutex проигрывает обычному Mutex.

СитуацияЧто быстрееПочему
Короткое чтение (m[k]), 2–4 ядраMutexменьше атомарных операций на вызов
Короткое чтение, 16+ ядеробычно всё ещё Mutex, а лучше — шардыRLock упирается в ту же кэш-линию
Долгое чтение (обход структуры, сериализация)RWMutexпараллельность окупает накладные расходы
Запись чаще ~10% операцийMutexписатель всё равно всех блокирует

Отвечай на собесе не «RWMutex быстрее», а «надо померить бенчмарком с -cpu=1,2,4,8, потому что выигрыш зависит от длины критической секции и числа ядер; для однострочного чтения из мапы я бы по умолчанию оставил обычный Mutex». Это отличает человека, который читал про RWMutex, от человека, который его профилировал.

Шаг 3. sync.Map — ровно два сценария

var m sync.Map                       // нулевое значение готово к работе

m.Store("k", 42)
v, ok := m.Load("k")                 // v имеет тип any, нужен type assertion
actual, loaded := m.LoadOrStore("k", 100)  // атомарный GetOrSet
m.Delete("k")
m.Range(func(k, v any) bool {        // снимок, не консистентный срез
    return true                      // false прерывает обход
})
// Что напечатает такая последовательность:
// m.Load("k")            → 42, true
// m.LoadOrStore("k", 100) → 42, true   ← ключ уже есть, 100 не записалось
// после m.Delete("k"):
// m.Load("k")            → <nil>, false

До Go 1.24 внутри sync.Map лежали две мапы: read (атомарная, только на чтение) и dirty под мьютексом, а промахи по первой копили счётчик, после которого вторая «повышалась» в первую. С Go 1.24 внутри другое — HashTrieMap, дерево из атомарно подменяемых узлов, — но профиль применимости остался прежним:

  • Выигрывает: ключ записали один раз и потом читают миллионы раз (реестры, метаданные, конфиг). Или так: разные горутины работают с непересекающимися наборами ключей — тогда contention неоткуда взяться.
  • Проигрывает: частые записи новых ключей — каждая идёт в dirty под дерева, а не в общий кэш. В одну горутину sync.Map вдвое медленнее обычной мапы под мьютексом — там нет конкуренции, и платить за неё незачем.
  • Минусы по эргономике: ключ и значение имеют тип any, то есть interface{}-боксинг, аллокации и приведение типов на каждом обращении; нет Len(); Range не даёт консистентного снимка.

Готовая формулировка: «sync.Map не "быстрая мапа", а структура под конкретный профиль. В документации так и написано: она оптимизирована под append-only и под disjoint key sets. Во всех остальных случаях я беру обычную мапу под мьютексом, а если упираюсь в блокировку — шардирую.»

Шаг 4. Шардирование — то, ради чего задавался весь вопрос

const shards = 256                   // степень двойки: маска вместо деления

type shard[K comparable, V any] struct {
    mu sync.RWMutex
    m  map[K]V
    _  [32]byte                      // padding до 64 байт: шарды не делят кэш-линию
}

type Sharded[K comparable, V any] struct {
    s    [shards]*shard[K, V]
    hash func(K) uint64
}

func NewSharded[K comparable, V any](hash func(K) uint64) *Sharded[K, V] {
    c := &Sharded[K, V]{hash: hash}
    for i := range c.s {
        c.s[i] = &shard[K, V]{m: make(map[K]V)}
    }
    return c
}

func (c *Sharded[K, V]) shardFor(k K) *shard[K, V] {
    return c.s[c.hash(k)&(shards-1)]  // & вместо %: маска дешевле деления
}

func (c *Sharded[K, V]) Get(k K) (V, bool) {
    sh := c.shardFor(k)
    sh.mu.RLock()
    defer sh.mu.RUnlock()
    v, ok := sh.m[k]
    return v, ok
}

func (c *Sharded[K, V]) Set(k K, v V) {
    sh := c.shardFor(k)
    sh.mu.Lock()
    defer sh.mu.Unlock()
    sh.m[k] = v
}

// Len приходится собирать по всем шардам, и срез не атомарный.
func (c *Sharded[K, V]) Len() int {
    n := 0
    for _, sh := range c.s {
        sh.mu.RLock()
        n += len(sh.m)
        sh.mu.RUnlock()
    }
    return n
}
Один мьютекс на всю мапу vs шардирование: где стоят горутины Один Mutex: 4 горутины, работает одна, три спят G1 работает G2 ждёт G3 ждёт G4 ждёт sync.Mutex → map[K]V пропускная способность не растёт с числом ядер 4 шарда: ключи разошлись по хешу, все четверо работают G1 работает G2 работает G3 работает G4 работает shard[0] shard[1] shard[2] shard[3] каждый шард — свой мьютекс и своя кэш-линия Как ключ попадает в шард key "user:42" hash(key) = 0x…A7 h & (256-1) = 0xA7 = 167 shard[167] Шардов — степень двойки, поэтому индекс берётся маской &. Для беззнакового хеша компилятор и % свёл бы к ней сам. Цена: нет атомарного Len() и нет консистентного обхода — снимок «всего кэша» больше не бесплатен.
Шардирование. Идея одна: заменить одну точку сериализации на N независимых. Пока ключи распределены равномерно, вероятность попасть в один шард равна 1/N, и конкуренция падает во столько же раз. Платим глобальными операциями: Len, полный обход и «атомарный снимок» перестают быть дешёвыми и консистентными.

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

РеализацияСильнаСлабаБрать, когда
map + Mutex простота, предсказуемость, любые составные операции одна точка сериализации на все ядра дефолт; до тех пор, пока профиль не показал contention
map + RWMutex параллельные долгие чтения атомарный счётчик читателей — общая кэш-линия чтение реально долгое и писателей мало
sync.Map read-mostly по существующим ключам, disjoint keys any-боксинг, нет Len, плохо на записи новых ключей реестр/конфиг: записали один раз, читают всегда
шардирование линейный рост с числом ядер, работает и на записи нет глобальных атомарных операций, больше кода и памяти профиль показал, что мьютекс — узкое место

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

  • Нет атомарного GetOrSet. Самая частая содержательная ошибка: кэш безопасен, а пользоваться им небезопасно.
  • defer забыт при раннем return. Мьютекс остаётся захваченным навсегда, и следующая же операция вешает всю программу.
  • Возврат внутреннего значения по ссылке. Если под V прячется *User, []byte или мапа, вызывающий получит доступ к тем же данным и изменит их вне мьютекса. Либо возвращать копию, либо документировать «значение read-only».
  • Мьютекс копируется вместе со структурой. Методы обязаны быть на указателе *Cache; при значении go vet ругнётся, а копия получит свой собственный мьютекс и всю защиту потеряет.
  • RWMutex «на всякий случай». Без бенчмарка это карго-культ; часто медленнее обычного мьютекса.
  • Шардирование по len(key) или первой букве. Неравномерный хеш даёт горячие шарды, и вся затея теряет смысл. Нужен нормальный хеш: maphash, xxhash, fnv. Короче всего maphash.Comparable(seed, key): дженерик-функция, хеширующая любой сравнимый ключ в uint64, без ручной сериализации в байты. В Go 1.27 в том же пакете появились maphash.Hasher и ComparableHasher — пара «хеш + равенство» для случаев, когда своей хеш-таблице нужно нестандартное сравнение ключей (например, строки без учёта регистра).
  • Вытеснения нет. Кэш без TTL и без ограничения размера течёт, просто не сразу. Спроси у интервьюера про политику вытеснения сам, до того как спросят тебя.

Добивки, которые задаст интервьюер

  • «Добавь TTL». Хранить struct{ val V; exp time.Time }, проверять срок на чтении (ленивое вытеснение) плюс фоновый time.Ticker, который раз в минуту чистит просроченное (активное). Без активной чистки память не освобождается для ключей, которые больше не запрашивают; без ленивой проверки читатель успеет увидеть протухшее.
  • «Добавь ограничение размера». Это уже LRU: мапа + двусвязный список, Get двигает элемент в голову, при переполнении выкидываем хвост. Разбирали в главе 1.2, там же ловушка с обновлением при Get.
  • «Как избежать cache stampede?» Когда популярный ключ протух, сотня горутин одновременно пойдёт его пересчитывать. Лечится singleflight (следующий вопрос) — все, кроме одной, ждут её результат.
  • «Сколько шардов брать?» Порядка 4–8 × GOMAXPROCS, округлённое вверх до степени двойки; на практике берут 128 или 256. Возьмёшь меньше, останется конкуренция; возьмёшь больше, вырастет память под мапы и просядет локальность.
  • «Зачем padding в структуре шарда?» False sharing: два мьютекса, попавшие в одну 64-байтную кэш-линию, заставляют ядра гонять эту линию друг у друга, даже когда шарды логически независимы. Padding до размера кэш-линии убирает эффект. Это ровно та деталь, после которой разговор о кэше обычно заканчивается в твою пользу.
  • «Кэш с фоновой горутиной — как ловить утечку?» Кэш с TTL заводит горутину-уборщика, и если у структуры нет Close(), эта горутина переживёт сам кэш. С Go 1.27 для таких случаев есть штатный профиль goroutineleak (в 1.26 он был за GOEXPERIMENT=goroutineleakprofile): он показывает горутины, заблокированные на примитивах, до которых из живого кода уже не дотянуться, то есть разбудить их некому. Именно так выглядит уборщик, который ждёт <-c.stop у выброшенного кэша, или горутина, пишущая результат в канал, читателя у которого больше нет. Берётся он как обычный профиль: curl 'localhost:6060/debug/pprof/goroutineleak?debug=1'. Оговорка, которая покажет, что ты им пользовался: горутину, висящую на достижимом канале или на time.Ticker, профиль не покажет — формально её ещё можно разбудить; такие случаи по-прежнему ловятся глазами по /debug/pprof/goroutine?debug=2 и через go.uber.org/goleak в тестах.
  • «А в Go 1.24 sync.Map же переписали?» Да, реализацию заменили на HashTrieMap — на многих профилях она заметно быстрее старой, особенно на записи. Но общий совет не поменялся: типизированная мапа под мьютексом (или шарды) остаётся дефолтом, а sync.Map берут под её заявленные сценарии.
Суть: ведро ёмкости burst, в которое с частотой rate капают токены. Запрос забирает токен; нет токена — ждёт или получает отказ. Ведро копит «неиспользованное разрешение», поэтому пропускает всплески — этим token bucket и отличается от простого time.Sleep, который жёстко размазывает запросы во времени.
Token bucket: rate = 2 токена/сек, burst = 4 t 0 c 1 c 2 c 3 c 4 c 5 c токеновв ведре 4 — ведро полно, капать некуда пришли 3 запроса разом — забрали 3 токена, все прошли мгновенно за секунду накапало 2 → снова 3, к 3 с ведро полно запросы burst из 3 — прошёл сразу четыре забрали все токены, пятый ждёт 0.5 с капает 1 токен / 0.5 с Пока ведро не полно, каждые 1/rate секунд добавляется токен. Полное ведро переполнить нельзя — лишние токены отбрасываются, поэтому «накопить сутки простоя и выстрелить» не получится. Средняя скорость на длинной дистанции = rate. Мгновенная — до burst. Это и есть весь алгоритм.
Token bucket. Два параметра: rate задаёт устойчивую скорость, burst говорит, насколько сильно разрешено «выстрелить» после простоя. Потолок накопления (burst) обязателен: без него лимитер после долгой паузы пропустит любой залп и уничтожит защищаемый сервис.

Версия 1: на time.Ticker — то, что пишут на доске за две минуты

type TickerLimiter struct {
    tokens chan struct{}
    stop   chan struct{}
}

func NewTickerLimiter(rate int, burst int) *TickerLimiter {
    l := &TickerLimiter{
        tokens: make(chan struct{}, burst),  // ёмкость канала = burst
        stop:   make(chan struct{}),
    }
    for i := 0; i < burst; i++ {             // стартуем с полным ведром
        l.tokens <- struct{}{}
    }

    go func() {
        t := time.NewTicker(time.Second / time.Duration(rate))
        defer t.Stop()
        for {
            select {
            case <-t.C:
                select {
                case l.tokens <- struct{}{}: // капнули токен
                default:                     // ведро полно, токен теряется, и это правильно
                }
            case <-l.stop:
                return
            }
        }
    }()
    return l
}

func (l *TickerLimiter) Wait(ctx context.Context) error {
    select {
    case <-l.tokens:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

func (l *TickerLimiter) Allow() bool {        // неблокирующий вариант
    select {
    case <-l.tokens:
        return true
    default:
        return false
    }
}

func (l *TickerLimiter) Close() { close(l.stop) }
// l := NewTickerLimiter(5, 3)   // 5 токенов в секунду, ведро на 3
// Allow() пять раз подряд:  true true true false false  ← burst израсходован
// затем Wait() три раза:    прошли на 200ms, 400ms, 600ms, ровно 1/rate между токенами

Всё держится на вложенном select с default. Без него горутина тикера заблокируется на полном ведре, перестанет читать t.C, и весь лимитер встанет. Именно тут валятся чаще всего.

Слабое место версии на тикере: она держит горутину на каждый лимитер и не умеет в дробный rate («1 запрос в 3 секунды» ещё можно, а «2.5 запроса в секунду» уже неудобно). Для сотни лимитеров (по одному на клиента) это сто горутин, которые тикают, даже когда трафика нет.

Версия 2: ленивое пополнение — без горутины и без тикера

type Limiter struct {
    mu       sync.Mutex
    tokens   float64        // дробные токены: держим любой rate
    max      float64        // burst
    rate     float64        // токенов в секунду
    last     time.Time      // когда последний раз пересчитывали
}

func NewLimiter(rate float64, burst int) *Limiter {
    return &Limiter{
        tokens: float64(burst),
        max:    float64(burst),
        rate:   rate,
        last:   time.Now(),
    }
}

// refill вызывается под мьютексом: докапывает токены за прошедшее время.
func (l *Limiter) refill(now time.Time) {
    if now.After(l.last) {
        l.tokens += now.Sub(l.last).Seconds() * l.rate
        if l.tokens > l.max {
            l.tokens = l.max                 // ведро не переполняется
        }
        l.last = now
    }
}

// Allow: неблокирующая проверка, есть токен или нет.
func (l *Limiter) Allow() bool {
    l.mu.Lock()
    defer l.mu.Unlock()
    l.refill(time.Now())
    if l.tokens >= 1 {
        l.tokens--
        return true
    }
    return false
}

// Wait ждёт токен или отмены контекста.
func (l *Limiter) Wait(ctx context.Context) error {
    for {
        l.mu.Lock()
        l.refill(time.Now())
        if l.tokens >= 1 {
            l.tokens--
            l.mu.Unlock()
            return nil
        }
        // сколько ждать, пока капнет токен
        need := (1 - l.tokens) / l.rate
        l.mu.Unlock()

        t := time.NewTimer(time.Duration(need * float64(time.Second)))
        select {
        case <-t.C:                          // проснулись, пробуем ещё раз
        case <-ctx.Done():
            t.Stop()
            return ctx.Err()
        }
    }
}
// l := NewLimiter(5, 3)   // 5 запросов в секунду, burst 3
// Allow() шесть раз подряд: true true true false false false
// затем Wait() три раза:    прошли на 200ms, 400ms, 600ms
// Числа устойчивы: в отличие от порядка горутин, тайминги здесь задаёт арифметика токенов.

Фокус в том, чтобы не хранить токены, а вычислять их по времени. Состояние укладывается в три числа, горутин нет вообще, лимитеров можно завести миллион (по одному на пользователя). Именно так устроен golang.org/x/time/rate, только там ещё резервирование (Reserve) и корректная работа с прошлым временем.

Цикл for в Wait стоит там не случайно: после сна нельзя просто выдать токен — за время сна его мог забрать кто-то другой. Проверять надо заново. Этот цикл забывают, и это вторая по частоте ошибка в задаче.

Token bucket vs leaky bucket vs sliding window

АлгоритмКак ведёт себяВсплескиГде применяют
Token bucketкопит разрешения, тратит по запросуразрешает до burstклиентские лимиты, API-квоты, x/time/rate
Leaky bucketочередь вытекает с постоянной скоростьюсглаживает: на выходе всегда ровный потокзащита downstream, шейпинг трафика
Fixed windowсчётчик на календарное окнодвойной залп на границе оконпростые счётчики в Redis (INCR + EXPIRE)
Sliding window logхранит метки времени запросовточный, но O(n) памяти на клиентатам, где точность важнее памяти

Разница token vs leaky в одной фразе: token bucket ограничивает вход и допускает всплеск, leaky bucket выравнивает выход и всплеск размазывает. Защищаешь чужой сервис с жёстким лимитом — бери token bucket с burst, равным его допуску. Свой медленный ресурс, которому вредны пики, прикрывай leaky.

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

  • Нет default при пополнении. Тикер блокируется на полном ведре, лимитер умирает.
  • Нет ограничения burst. Ведро копит токены неограниченно; после часа простоя лимитер пропустит тысячи запросов залпом.
  • Нет цикла в Wait. После сна токен выдают без повторной проверки, и при конкуренции лимит нарушается.
  • time.Sleep вместо select с ctx. Запрос, который клиент уже отменил, продолжает занимать место в очереди.
  • Утечка тикера. time.NewTicker без defer t.Stop() живёт вечно вместе с горутиной. Для time.Timer в цикле та же история.
  • time.Now() вместо монотонного времени. В Go time.Now() содержит и монотонную часть, и Sub использует именно её, так что перевод системных часов ничего не сломает — но об этом стоит сказать вслух, знают немногие.
  • Один глобальный лимитер там, где нужен per-key. За «100 rps на пользователя» стоит мапа лимитеров с вытеснением по неактивности, иначе она растёт бесконечно.

Добивки, которые задаст интервьюер

  • «Сделай лимитер на каждого клиента». map[string]*Limiter под мьютексом (или шардированный кэш из прошлого вопроса) плюс фоновая чистка тех, кто не обращался N минут. Без чистки получишь утечку памяти на все IP интернета.
  • «А распределённо, на несколько инстансов?» Redis: INCR с EXPIRE для fixed window или Lua-скрипт для token bucket (нужна атомарность чтения и записи). Из готового есть redis-cell. Обязательно упомяни, что появляется сетевой round-trip на каждый запрос и вопрос «что делать, если Redis недоступен» (fail-open или fail-closed — решает продукт).
  • «Что вернуть клиенту при отказе?» HTTP 429 Too Many Requests плюс заголовок Retry-After, и по-хорошему X-RateLimit-Limit / -Remaining / -Reset. Ответ про заголовки сразу показывает практический опыт.
  • «Как это протестировать без реального ожидания?» Вынести источник времени в интерфейс (now func() time.Time) и подменить в тесте. Тесты, зависящие от time.Sleep, всегда флакают на CI.
  • «Чем твой лимитер отличается от x/time/rate Тем, что там есть Reserve() — резервирование токена из будущего с возможностью отмены, AllowN/WaitN для «тяжёлых» запросов и аккуратная обработка изменения rate на лету. В проде я бы взял его.
Суть: три разные семантики на одном каркасе. «Первый успешный» — буферизованный канал на N и cancel() после первого успеха. «Все результаты» — запись в out[i] по индексу и wg.Wait(). «Таймаут» — context.WithTimeout плюс возврат ctx.Err(). Общая деталь всех трёх: буферизованный канал ёмкости N, чтобы проигравшие горутины не зависли на записи.

(а) Первый успешный — паттерн «or»

type res struct {
    val string
    err error
}

// First возвращает результат первого успешного вызова.
// Если все упали, возвращает объединение ошибок.
func First(ctx context.Context, urls []string,
    do func(context.Context, string) (string, error)) (string, error) {

    ctx, cancel := context.WithCancel(ctx)
    defer cancel()                       // отменяет всех отставших при любом выходе

    ch := make(chan res, len(urls))      // буфер на всех: никто не зависнет на записи

    for _, u := range urls {
        go func(u string) {
            v, err := do(ctx, u)
            ch <- res{v, err}            // запись не блокируется: буфер вмещает всё
        }(u)
    }

    var errs []error
    for i := 0; i < len(urls); i++ {
        select {
        case r := <-ch:
            if r.err == nil {
                return r.val, nil        // defer cancel() отменит остальных
            }
            errs = append(errs, r.err)
        case <-ctx.Done():
            return "", ctx.Err()
        }
    }
    return "", errors.Join(errs...)      // все N упали
}
// do: "a" падает через 30 мс, "b" отвечает через 10 мс, "c" через 50 мс.
// First(ctx, []string{"a","b","c"}, do) → "ответ от b", <nil>
//   Побеждает не первый в списке, а первый успевший, так что результат зависит
//   от таймингов, а не от порядка urls.
// Если падают все трое, errors.Join печатает их по одной на строку:
//   упал b
//   упал a
//   упал c
//   Это порядок прихода в канал: здесь он задан таймингами, но полагаться на него нельзя.
Почему именно make(chan res, len(urls))

Проверяют обычно именно эту строку. При небуферизованном канале функция вернётся после первого успеха, читателя не останется, и оставшиеся N−1 горутин навсегда зависнут на ch <- res{...}. Буфер на N дешевле и надёжнее всего: каждая горутина запишет результат и завершится, читает её кто-нибудь или нет. Можно вместо этого обернуть запись в select с ctx.Done(), но буфер проще и не зависит от аккуратности внутри горутины. Именно здесь интервьюер обычно спрашивает: «а если убрать буфер?»

(б) Все результаты, с сохранением порядка

func All(ctx context.Context, urls []string,
    do func(context.Context, string) (string, error)) ([]string, error) {

    out := make([]string, len(urls))     // длина фиксирована, реаллокаций нет
    errs := make([]error, len(urls))     // каждая горутина пишет в свой индекс

    var wg sync.WaitGroup
    wg.Add(len(urls))
    for i, u := range urls {
        go func(i int, u string) {
            defer wg.Done()
            out[i], errs[i] = do(ctx, u) // разные слоты, гонки нет, мьютекс не нужен
        }(i, u)
    }
    wg.Wait()                            // здесь happens-before: всё записанное видно

    return out, errors.Join(errs...)     // nil, если в errs одни nil
}
// do: "b" возвращает ошибку 503, остальные «ответ от ...»
// All(ctx, []string{"a","b","c"}, do) → ["ответ от a" "" "ответ от c"], 503
//   Слот упавшего остался нулевым, но соседи на месте: в этом и смысл «всех».
// Если ошибок нет:                   → ["ответ от a" "ответ от b" "ответ от c"], <nil>
// Порядок out всегда совпадает с порядком urls: каждый пишет в свой индекс.

wg.Wait() закрывает вопрос с видимостью: всё, что горутина записала до wg.Done(), гарантированно видно после wg.Wait() по модели памяти Go. Читать out после Wait безопасно, и мьютекс тут не нужен. Проговори это вслух: вопрос «а точно ли тут нет гонки?» прилетает почти всегда.

(в) Таймаут на всю операцию

func AllWithTimeout(parent context.Context, urls []string, d time.Duration,
    do func(context.Context, string) (string, error)) ([]string, error) {

    ctx, cancel := context.WithTimeout(parent, d)
    defer cancel()

    type indexed struct {
        i   int
        val string
        err error
    }
    ch := make(chan indexed, len(urls))  // снова буфер на всех

    for i, u := range urls {
        go func(i int, u string) {
            v, err := do(ctx, u)         // ctx уезжает внутрь: HTTP-клиент оборвёт запрос сам
            ch <- indexed{i, v, err}
        }(i, u)
    }

    out := make([]string, len(urls))
    var errs []error
    for got := 0; got < len(urls); got++ {
        select {
        case r := <-ch:
            if r.err != nil {
                errs = append(errs, fmt.Errorf("%s: %w", urls[r.i], r.err))
                continue
            }
            out[r.i] = r.val
        case <-ctx.Done():
            // Вышли по дедлайну. Горутины допишут в буфер и завершатся сами.
            return out, fmt.Errorf("получено %d из %d: %w", got, len(urls), ctx.Err())
        }
    }
    return out, errors.Join(errs...)
}

В комментарии не зря сказано: при выходе по таймауту мы не дренируем канал, и это корректно ровно потому, что он буферизован. Горутины допишут результаты в буфер, завершатся, а сам канал и буфер соберёт GC, когда на них не останется ссылок. Будь канал небуферизован, такой return означал бы N зависших горутин.

Всё то же самое на errgroup — как выглядит прод-версия

// (б) + (в) одной конструкцией: первая ошибка отменяет остальных, есть таймаут и лимит.
func AllEG(ctx context.Context, urls []string, d time.Duration,
    do func(context.Context, string) (string, error)) ([]string, error) {

    ctx, cancel := context.WithTimeout(ctx, d)
    defer cancel()

    g, ctx := errgroup.WithContext(ctx)  // ctx отменится при первой ошибке
    g.SetLimit(8)                        // не более 8 одновременно

    out := make([]string, len(urls))
    for i, u := range urls {
        i, u := i, u                     // до Go 1.22 обязательно
        g.Go(func() error {
            v, err := do(ctx, u)
            if err != nil {
                return fmt.Errorf("%s: %w", u, err)
            }
            out[i] = v
            return nil
        })
    }
    return out, g.Wait()                 // g.Wait() вернёт первую ошибку
}

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

  • Небуферизованный канал в варианте «первый успешный». Главная ловушка задачи: N−1 горутин зависают навсегда.
  • return сразу после первого элемента без проверки err. Задача про первый успешный, а не первый пришедший. Если первым придёт мгновенная ошибка «connection refused», наивная версия вернёт её и не дождётся успеха.
  • Забыт cancel(). После возврата первого успеха остальные запросы продолжают жечь соединения и трафик. defer cancel() обязателен.
  • append в общий слайс из горутин. Гонка. Либо канал, либо запись по индексу.
  • Таймаут через time.After вместо context. Функция вернётся, а сами HTTP-запросы продолжат выполняться: никто их не оборвал. ctx прерывает работу, time.After лишь прекращает ожидание, и это совсем не одно и то же.
  • Возврат nil результатов при частичном успехе. Часто правильнее вернуть и то, что успели, и ошибку, а вызывающий решит сам. Спроси у интервьюера, какая семантика нужна: этот вопрос сам по себе засчитывается в плюс.

Добивки, которые задаст интервьюер

  • «А если нужен не первый успешный, а кворум — 2 из 3?» Тот же цикл, но считаем успехи и выходим при ok == quorum. Это quorum read из распределённых систем.
  • «Сделай hedged request». Отправляем первый запрос, ждём p95; если не ответил, шлём дубль в другую реплику и берём то, что придёт раньше. Приём из статьи «The Tail at Scale», и сам термин на собеседовании работает хорошо.
  • «Что если N — миллион?» Нельзя запускать миллион горутин: нужен SetLimit/семафор, иначе память и число открытых сокетов кончатся раньше результата.
  • «Как отличить таймаут от отмены клиентом?» errors.Is(err, context.DeadlineExceeded) против context.Canceled. Для HTTP-сервера это разные коды ответа: 504 и, как правило, 499/ничего.
  • «Гарантирует ли wg.Wait() видимость записей?» Да, это прописано в модели памяти Go: Done happens-before возврата из Wait. То же верно для отправки/приёма по каналу и для Unlock/Lock.
Суть: нужна не «синхронизация», а передача права хода — как эстафетная палочка. Два небуферизованных канала: odd и even. Каждая горутина ждёт свой канал, печатает, отдаёт ход в чужой. Единственная сложная часть задачи — корректно завершиться и не оставить вторую горутину висеть на пустом канале.

Решение на двух каналах

func printAlternate(n int) {
    odd := make(chan struct{})       // разрешение печатать нечётное
    even := make(chan struct{})      // разрешение печатать чётное
    done := make(chan struct{})      // сигнал «всё, закончили»

    var wg sync.WaitGroup
    wg.Add(2)

    go func() {                      // нечётные
        defer wg.Done()
        for i := 1; i <= n; i += 2 {
            <-odd                    // жду свой ход
            fmt.Println(i)
            if i+1 > n {             // следующего чётного не будет
                close(done)          // будим соседа на выход
                return
            }
            even <- struct{}{}       // передаю ход
        }
    }()

    go func() {                      // чётные
        defer wg.Done()
        for i := 2; i <= n; i += 2 {
            select {
            case <-even:
            case <-done:             // сосед закончил: выходим, а не виснем
                return
            }
            fmt.Println(i)
            if i+1 > n {
                close(done)
                return
            }
            odd <- struct{}{}
        }
    }()

    odd <- struct{}{}                // даём старт: первый ход у нечётных
    wg.Wait()
}
// printAlternate(5) → 1 2 3 4 5 (каждое с новой строки)
// printAlternate(6) → 1 2 3 4 5 6
// Порядок здесь детерминирован, и в этом весь смысл: право хода передаётся явно,
// планировщику выбирать не из чего.

Тот же ответ короче: один канал с самим значением

Если разрешено передавать по каналу не «палочку», а само число, решение становится короче, а завершение через close выглядит естественно.

func printAlternate2(n int) {
    odd, even := make(chan int), make(chan int)
    var wg sync.WaitGroup
    wg.Add(2)

    worker := func(mine, next chan int, wantOdd bool) {
        defer wg.Done()
        for v := range mine {        // выходим по close: одна точка завершения
            fmt.Println(v)
            if v == n {
                close(next)          // закрываем свой выход: сосед выйдет из range
                return
            }
            next <- v + 1
        }
    }

    go worker(odd, even, true)
    go worker(even, odd, false)

    odd <- 1
    wg.Wait()
}
// printAlternate2(5) → 1 2 3 4 5;  printAlternate2(6) → 1 2 3 4 5 6
// Тот же строгий порядок, что и в варианте на «палочках».

Здесь close(next) формально нарушает правило «закрывает писатель», но тут это осознанный протокол между двумя строго чередующимися сторонами: в момент close сосед гарантированно стоит в range и в канал не пишет. Такой нюанс полезно проговорить: он показывает, что ты понимаешь, почему существует правило, а не просто его помнишь.

Вариант на sync.Cond — если спросят «а без каналов?»

func printCond(n int) {
    var mu sync.Mutex
    cond := sync.NewCond(&mu)
    turn := 1                        // чей ход: 1 — нечётные, 0 — чётные
    var wg sync.WaitGroup
    wg.Add(2)

    run := func(parity int) {
        defer wg.Done()
        for i := parity; i <= n; i += 2 {
            if i == 0 {
                continue
            }
            mu.Lock()
            for turn != parity%2 {   // всегда for, не if: возможны ложные пробуждения
                cond.Wait()
            }
            fmt.Println(i)
            turn = 1 - turn
            cond.Broadcast()         // будим соседа
            mu.Unlock()
        }
        mu.Lock()                    // финальный Broadcast, чтобы сосед не остался в Wait
        cond.Broadcast()
        mu.Unlock()
    }

    go run(1)
    go run(2)
    wg.Wait()
}
// printCond(5) → 1 2 3 4 5;  printCond(6) → 1 2 3 4 5 6
// Порядок снова строгий: его держит переменная turn, а не планировщик.

for вместо if вокруг cond.Wait() требуется в любом языке с условными переменными. Wait может вернуться без изменения условия, поэтому условие перепроверяют в цикле.

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

  • Забыт «стартовый толчок». Обе горутины ждут своего канала, никто не пишет, дедлок с первой же секунды: fatal error: all goroutines are asleep.
  • Нет корректного завершения. Самая частая ошибка: числа печатаются правильно, а потом программа падает с deadlock, потому что вторая горутина осталась висеть на <-even. Проверяют здесь именно завершение, остальное тривиально.
  • Буферизованные каналы. С буфером чередование ломается: горутина может уйти вперёд на несколько шагов. Здесь буфер обязан быть нулевым: он и есть механизм синхронизации, а не оптимизация.
  • Общий счётчик под мьютексом вместо очереди ходов. Мьютекс гарантирует взаимоисключение, но не порядок: одна и та же горутина может захватить его два раза подряд, и получится «1 3 5 … 2 4 6».
  • if вместо for вокруг cond.Wait(). Работает «почти всегда», ломается редко и невоспроизводимо.

Добивки, которые задаст интервьюер

  • «Сделай для K горутин: печатают 1..N по кругу». Кольцо из K каналов: горутина i читает ch[i], пишет в ch[(i+1)%K]. Красивое обобщение: покажи его сразу, и половина добивок отпадёт.
  • «Сколько раз произойдёт переключение контекста?» Порядка N: каждая передача хода через небуферизованный канал паркует одну горутину и будит другую. Это дорого, но задача учебная: в проде такой код не нужен.
  • «А через atomic и busy-wait?» Технически да: for atomic.LoadInt32(&turn) != mine { runtime.Gosched() }. Но это спин, который жжёт CPU. Назови такой вариант и сразу объясни, почему так не делают.
  • «Что произойдёт, если убрать wg.Wait() main завершится и убьёт процесс вместе с горутинами; вывод оборвётся на случайном числе. Наглядно видно: горутины не удерживают программу от выхода.
Суть: Mutex — канал ёмкости 1 (взять элемент = захватить). Once — канал ёмкости 1 с одним токеном плюс канал-сигнал «готово». WaitGroup — счётчик под мьютексом-каналом и канал, который закрывается при достижении нуля: закрытый канал делает <-done готовым сразу для всех ожидающих. Последнее — главный приём: close как broadcast.

Mutex на канале

type Mutex chan struct{}

func NewMutex() Mutex { return make(Mutex, 1) }   // ёмкость 1 = одно разрешение

func (m Mutex) Lock()   { m <- struct{}{} }       // занял слот, вошёл
func (m Mutex) Unlock() { <-m }                   // освободил слот

// Бонус, которого нет у sync.Mutex: неблокирующий захват и захват с таймаутом.
func (m Mutex) TryLock() bool {
    select {
    case m <- struct{}{}:
        return true
    default:
        return false
    }
}

func (m Mutex) LockCtx(ctx context.Context) error {
    select {
    case m <- struct{}{}:
        return nil
    case <-ctx.Done():
        return ctx.Err()
    }
}

Канальный мьютекс умеет больше (таймаут, отмена, TryLock до Go 1.18), но заметно медленнее. sync.Mutex в неконкурентном случае обходится одной атомарной CAS-операцией без системных вызовов; канал тащит за собой мьютекс рантайма и очередь ожидающих. Разница обычно в разы. Отсюда правило: канальный мьютекс берут ради семантики (отмена), а не ради скорости.

Once на каналах

type Once struct {
    token chan struct{}   // ровно один токен: достанется одному вызывающему
    done  chan struct{}   // закрывается после выполнения f
}

func NewOnce() *Once {
    o := &Once{
        token: make(chan struct{}, 1),
        done:  make(chan struct{}),
    }
    o.token <- struct{}{}         // единственный токен кладём заранее
    return o
}

func (o *Once) Do(f func()) {
    select {
    case <-o.token:               // выиграл гонку, выполняю
        defer close(o.done)       // defer: даже при панике в f ожидающие проснутся
        f()
    case <-o.done:                // уже выполнено, выходим сразу
    }
}
Тонкость, ради которой задача и задаётся

Контракт sync.Once сильнее, чем «выполнить один раз»: все вызовы Do возвращаются только после завершения f. То есть второй вызывающий обязан подождать, а не проскочить мимо. Наивная реализация через if atomic.CompareAndSwap(...) { f() } этот контракт нарушает: проигравший уходит немедленно и может обратиться к ещё не проинициализированному объекту. В решении выше за это отвечает ветка <-o.done: канал не закрыт, пока f не отработала, поэтому проигравший ждёт. Проговори это вслух: именно эту идею и проверяют.

WaitGroup на каналах

type WaitGroup struct {
    mu   chan struct{}   // мьютекс-канал вокруг счётчика
    n    int
    done chan struct{}   // закрывается, когда n достигает нуля
}

func NewWaitGroup() *WaitGroup {
    wg := &WaitGroup{
        mu:   make(chan struct{}, 1),
        done: make(chan struct{}),
    }
    return wg
}

func (wg *WaitGroup) Add(delta int) {
    wg.mu <- struct{}{}
    defer func() { <-wg.mu }()

    wg.n += delta
    if wg.n < 0 {
        panic("negative WaitGroup counter")   // как в оригинале
    }
    if wg.n == 0 {
        close(wg.done)                        // broadcast: будим всех ожидающих сразу
    }
}

func (wg *WaitGroup) Done() { wg.Add(-1) }

func (wg *WaitGroup) Wait() { <-wg.done }     // на закрытом канале приём мгновенный

func (wg *WaitGroup) WaitCtx(ctx context.Context) error {
    select {
    case <-wg.done:
        return nil
    case <-ctx.Done():
        return ctx.Err()                      // чего sync.WaitGroup не умеет
    }
}

Закрытие канала как broadcast стоит запомнить отдельно от этой задачи. Отправка в канал будит одного ожидающего, закрытие — всех. Именно поэтому done chan struct{} и ctx.Done() устроены через закрытие, а не через отправку значений. Спросят на собесе «как разбудить N горутин одним действием», отвечай: «закрыть канал».

У этой реализации есть ограничение: она одноразовая. Настоящий sync.WaitGroup можно переиспользовать после Wait (при аккуратном Add), а наш done закрыт навсегда: повторный Add до 1 и снова до 0 даст «close of closed channel». Назови ограничение сам, и станет видно, что ты сравнил свою реализацию с контрактом оригинала, а не просто написал что-то работающее.

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

  • Once без ожидания. Проигравший гонку уходит до того, как отработает f. Самая содержательная ошибка в задаче.
  • close(done) без defer в Once. Паника внутри f оставит все остальные вызовы Do заблокированными навсегда. Кстати, реальный sync.Once ведёт себя так же в части «второй раз f не запустится»: паника считается выполнением.
  • Счётчик WaitGroup без защиты. wg.n += delta из нескольких горутин даёт гонку. Нужен либо канал-мьютекс, либо atomic.
  • Отправка в done вместо закрытия. Разбудит ровно одного ожидающего, остальные повиснут.
  • Мьютекс на структуре-значении. type Mutex chan struct{} работает и по значению, потому что канал это указатель на внутреннюю структуру рантайма. А вот struct { mu chan struct{}; n int } копировать уже нельзя: счётчик продублируется. Отсюда *WaitGroup в методах.

Добивки, которые задаст интервьюер

  • «Что быстрее — твой мьютекс или sync.Mutex sync.Mutex, почти на порядок на неконкурентном пути: там fast path сводится к одной атомарной операции без обращения к рантайму каналов.
  • «Сделай RWMutex на каналах». Заметно сложнее: нужен счётчик читателей, отдельный канал для писателя и правило, которое не даёт писателю голодать. Так и отвечай: «сделаю, но это уже 40 строк, и я бы взял sync.RWMutex».
  • «Почему sync.Mutex нельзя копировать?» Внутри лежит состояние (state, sema). Копия получает своё состояние, и два «мьютекса» перестают защищать общее. go vet ловит это правилом copylocks. И ещё: заблокированный мьютекс не привязан к горутине, Unlock может звать другая горутина, в отличие от, скажем, Java-синхронизации.
  • «Зачем тогда вообще каналы, если sync быстрее?» Каналы дают композицию: их можно положить в select с таймаутом и отменой. Формула «share memory by communicating» про передачу владения данными, а sync про защиту общего состояния. Это два разных инструмента, а не конкуренты.
  • «Что вернёт len() и cap() от твоего мьютекса?» cap всегда 1, len равен 1, если захвачен, и 0, если свободен. Забавный побочный эффект, полезный для метрик и бесполезный для логики (гонка).
Суть: merge sort идеально рекурсивно распараллеливается — две половины независимы. Но если запускать горутину на каждый рекурсивный вызов, их будет 2n, и накладные расходы на создание и планирование съедят весь выигрыш. Лечится двумя строками: порог по размеру (ниже него — последовательно) и ограничение глубины распараллеливания примерно до log₂(GOMAXPROCS).

Наивная версия — та, которую пишут первой и которая медленнее последовательной

func mergeSortNaive(a []int) []int {
    if len(a) <= 1 {
        return a
    }
    mid := len(a) / 2

    var left, right []int
    var wg sync.WaitGroup
    wg.Add(2)
    go func() { defer wg.Done(); left = mergeSortNaive(a[:mid]) }()   // горутина
    go func() { defer wg.Done(); right = mergeSortNaive(a[mid:]) }()  // и ещё одна
    wg.Wait()

    return merge(left, right)
}

Для слайса на 1 000 000 элементов это около двух миллионов горутин. Каждая берёт минимум 2 КБ стека (растущего), её надо зарегистрировать в планировщике, запарковать на wg.Wait() и разбудить. На нижних уровнях рекурсии горутина создаётся ради сортировки двух элементов: полезной работы наносекунды, накладных расходов сотни. Результат стабильно хуже обычного sort.Ints, иногда в разы.

Правильная версия: порог + ограничение глубины

const seqThreshold = 1 << 12    // ниже 4096 элементов только последовательно

func MergeSort(a []int) []int {
    // Глубина, до которой имеет смысл ветвиться: log2 от числа ядер, +1 про запас.
    maxDepth := bits.Len(uint(runtime.GOMAXPROCS(0)))
    buf := make([]int, len(a))   // один общий буфер на всю сортировку
    copy(buf, a)
    sortInto(buf, a, 0, maxDepth)
    return a
}

// sortInto сортирует src в dst; buf и dst меняются ролями на каждом уровне.
func sortInto(src, dst []int, depth, maxDepth int) {
    n := len(src)
    if n <= 1 {
        return
    }
    if n < seqThreshold || depth >= maxDepth {
        insertionOrStdSort(dst)   // мелкий кусок: без горутин и без рекурсии
        return
    }

    mid := n / 2
    if depth < maxDepth {
        var wg sync.WaitGroup
        wg.Add(1)
        go func() {                                   // одна новая горутина
            defer wg.Done()
            sortInto(dst[:mid], src[:mid], depth+1, maxDepth)
        }()
        sortInto(dst[mid:], src[mid:], depth+1, maxDepth)  // вторую половину в текущей
        wg.Wait()
    } else {
        sortInto(dst[:mid], src[:mid], depth+1, maxDepth)
        sortInto(dst[mid:], src[mid:], depth+1, maxDepth)
    }

    mergeInto(src[:mid], src[mid:], dst)
}

func mergeInto(l, r, dst []int) {
    i, j, k := 0, 0, 0
    for i < len(l) && j < len(r) {
        if l[i] <= r[j] {         // <= сохраняет стабильность сортировки
            dst[k] = l[i]
            i++
        } else {
            dst[k] = r[j]
            j++
        }
        k++
    }
    k += copy(dst[k:], l[i:])
    copy(dst[k:], r[j:])
}

Три отличия от наивной версии, каждое даёт заметный вклад:

  1. Одна горутина вместо двух на уровень: вторую половину сортирует текущая горутина. Это вдвое меньше горутин и, что важнее, работа остаётся на «горячем» ядре.
  2. Порог seqThreshold. Ниже него не ветвимся вообще. Тот же приём, что в pdqsort из стандартной библиотеки: там порог 12 элементов для insertion sort.
  3. Один общий буфер. Наивная версия аллоцирует новый слайс на каждом merge. Слияний ровно n−1, значит и аллокаций n−1: линейно, а не O(n log n), замер на n = 65 536 даёт 65 536 вызовов аллокатора. O(n log n) здесь про суммарный объём: 8,9 МБ на том же входе, и вот он и грузит GC. Версия с двумя буферами, которые меняются ролями, делает ровно одну аллокацию на всю сортировку.
Глубже, чем спросят: почему выигрыш всё равно далёк от ×N

Даже идеальный параллельный merge sort не даёт ускорения в число ядер. Причины три. Закон Амдала: последний merge обрабатывает весь массив и по природе последователен, уже он один ограничивает ускорение. Память: сортировка миллиона int упирается в пропускную способность памяти, а не в ALU; восемь ядер делят одну шину и один L3. Планировщик: work stealing в Go не бесплатен, и на мелких задачах горутина чаще мигрирует между P, теряя тёплый кэш. На 8 ядрах реально получить ×2.5–4, и это нормальный результат, а не провал. Фраза «параллельная сортировка упирается в память, а не в CPU, поэтому масштабируется сублинейно» звучит уверенно.

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

  • Горутина на каждый рекурсивный вызов. Главная ошибка задачи; ответ «нужен порог» и есть то, что проверяют.
  • Аллокация на каждом слиянии. result := make([]int, 0, len(l)+len(r)) внутри merge выглядит невинно, но даёт n−1 аллокацию и O(n log n) выделенных байт: профиль превращается в сплошной GC.
  • Гонка на общем слайсе. Две горутины пишут в a[:mid] и a[mid:], и это безопасно (диапазоны не пересекаются). А вот запись в общий result без разделения диапазонов даёт гонку. -race её поймает.
  • Захват переменной цикла при попытке распараллелить итеративный вариант (bottom-up merge sort): до Go 1.22 все горутины получат последний диапазон.
  • Нестабильность. l[i] < r[j] вместо <= ломает стабильность сортировки. Мелочь, но интервьюер, который просил стабильную, это заметит.
  • Замер на маленьком массиве. На 1000 элементах параллельная версия проиграет всегда. Порог окупаемости обычно десятки-сотни тысяч элементов, и его надо назвать вслух.

Добивки, которые задаст интервьюер

  • «С какого размера параллельная версия начинает выигрывать?» Отвечай так: «замерил бы go test -bench с -cpu=1,2,4,8; по опыту порядка 10⁵ элементов, но зависит от типа элемента и от того, дорого ли сравнение».
  • «Как то же самое сделать для quicksort?» Так же: рекурсивно параллелим, но quicksort хуже балансируется (разбиение может быть неравным), поэтому там особенно важен порог. Зато quicksort работает in-place, ему не нужен буфер, и на практике он быстрее.
  • «Что в стандартной библиотеке?» sort.Slice и slices.Sort последовательны и используют pdqsort (с Go 1.19). Параллельной сортировки в stdlib нет: если нужна, берут внешние библиотеки или пишут сами.
  • «А если данные не влезают в память?» Внешняя сортировка: режем на куски по объёму RAM, сортируем каждый и пишем на диск, затем k-way merge через кучу. Тот же merge, только источниками служат файлы.
  • «Как распараллелить сам merge?» Есть параллельный merge через бинарный поиск медианы: делим оба отсортированных массива на пары кусков, которые можно сливать независимо. Сложно, но это правильный ответ на «а как обойти закон Амдала здесь».
Суть: мапа «ключ → идущий сейчас вызов» под мьютексом. Первый создаёт запись с done chan struct{} и идёт выполнять; остальные находят запись и ждут закрытия done, после чего читают уже готовый результат. Закрытие канала здесь снова работает как broadcast на всех ожидающих. Решает cache stampede — лавину запросов к БД, когда протух популярный ключ.

Полная реализация

// call — один «полёт»: сколько бы горутин ни попросило ключ, вызов будет один.
type call struct {
    done chan struct{}    // закрывается, когда результат готов
    val  any
    err  error
    dups int              // сколько вызывающих присоединилось (для метрик)
}

type Group struct {
    mu sync.Mutex
    m  map[string]*call   // ленивая инициализация
}

// Do гарантирует: для одного key в один момент времени fn выполняется ровно один раз.
// Возвращает результат этого выполнения всем ожидающим.
func (g *Group) Do(key string, fn func() (any, error)) (v any, err error, shared bool) {
    g.mu.Lock()
    if g.m == nil {
        g.m = make(map[string]*call)
    }
    if c, ok := g.m[key]; ok {          // полёт уже идёт — присоединяемся
        c.dups++
        g.mu.Unlock()
        <-c.done                        // ждём закрытия канала
        return c.val, c.err, true       // читаем результат ПОСЛЕ закрытия — гонки нет
    }

    c := &call{done: make(chan struct{})}
    g.m[key] = c                        // публикуем полёт под тем же мьютексом
    g.mu.Unlock()

    g.doCall(c, key, fn)
    return c.val, c.err, c.dups > 0
}

func (g *Group) doCall(c *call, key string, fn func() (any, error)) {
    defer func() {
        if r := recover(); r != nil {   // паника не должна оставить ожидающих висеть
            c.err = fmt.Errorf("singleflight: panic: %v", r)
        }
        g.mu.Lock()
        if g.m[key] == c {              // после Forget тут может лежать уже чужой полёт
            delete(g.m, key)            // снимаем свой полёт до закрытия канала
        }
        g.mu.Unlock()
        close(c.done)                   // broadcast: будим всех ожидающих разом
    }()
    c.val, c.err = fn()
}

// Forget убирает ключ из мапы, не дожидаясь завершения:
// следующий вызывающий начнёт новый полёт, а текущий доработает для уже ждущих.
func (g *Group) Forget(key string) {
    g.mu.Lock()
    delete(g.m, key)
    g.mu.Unlock()
}
// 10 горутин одновременно зовут g.Do("top", load), где load спит 50 мс:
//   горутина 10: из БД err=<nil> shared=true
//   горутина  8: из БД err=<nil> shared=true
//   ... (все десять, порядок строк каждый раз разный)
//   реальных вызовов load: 1
// Вот это «1» и есть весь смысл singleflight. Обрати внимание: shared=true даже
// у той горутины, которая реально выполняла вызов — к моменту возврата dups уже 9.
Три детали, которые отличают правильную реализацию от «работает у меня»
  • Мьютекс отпускается до fn(). Если держать его на время вызова, никакой конкурентности не будет вообще: все ключи выстроятся в очередь за одним мьютексом. Запись в мапу и сам вызов живут в разных критических секциях.
  • Запись читается только после <-c.done. Это и есть синхронизация: закрытие канала happens-before возврата из приёма, значит c.val и c.err, записанные до close, гарантированно видны. Без этого -race сразу подсветит гонку на полях структуры.
  • delete до close, и оба — в defer. Если fn паникует, ожидающие обязаны проснуться, иначе они висят навсегда. А если удалить ключ после close, между этими операциями пришедшая горутина присоединится к уже завершённому полёту и получит устаревший результат.

Версия с context и DoChan

У базового Do есть неприятное свойство: ожидающий не может отменить ожидание. Если fn зависла на 30 секунд, все присоединившиеся зависли вместе с ней, даже если их собственные ctx уже отменены. Настоящий golang.org/x/sync/singleflight решает это через DoChan, который возвращает канал вместо значения.

type Result struct {
    Val    any
    Err    error
    Shared bool
}

func (g *Group) DoChan(key string, fn func() (any, error)) <-chan Result {
    ch := make(chan Result, 1)          // буфер 1: отправитель никогда не блокируется
    go func() {
        v, err, shared := g.Do(key, fn)
        ch <- Result{v, err, shared}
    }()
    return ch
}

// Теперь вызывающий может ждать с отменой:
func fetch(ctx context.Context, g *Group, key string) (any, error) {
    select {
    case r := <-g.DoChan(key, func() (any, error) { return loadFromDB(key) }):
        return r.Val, r.Err
    case <-ctx.Done():
        return nil, ctx.Err()           // мы ушли, но полёт продолжается для остальных
    }
}

Буфер 1 в ch — обязателен. Без него горутина-обёртка зависнет навсегда, если вызывающий ушёл по ctx.Done(). Та же логика, что в задаче «первый успешный»: буфер снимает зависимость от того, читает ли кто-нибудь результат.

Зачем это нужно на самом деле: cache stampede

Ключ "top_products" лежит в Redis с TTL 60 секунд и запрашивается 5000 раз в секунду. В момент истечения TTL все 5000 запросов одновременно промахиваются и идут в PostgreSQL за одним и тем же тяжёлым агрегатом. База ложится, ответы замедляются, промахов становится ещё больше — классический каскад. Singleflight превращает эти 5000 обращений в одно: 4999 горутин просто ждут результата первой и получают его копию.

func (s *Service) TopProducts(ctx context.Context) ([]Product, error) {
    if v, ok := s.cache.Get("top_products"); ok {
        return v.([]Product), nil
    }
    v, err, _ := s.sf.Do("top_products", func() (any, error) {
        ps, err := s.db.QueryTopProducts(ctx)   // в БД идёт ровно один запрос
        if err != nil {
            return nil, err
        }
        s.cache.Set("top_products", ps, time.Minute)
        return ps, nil
    })
    if err != nil {
        return nil, err
    }
    return v.([]Product), nil
}

Второй классический приём против stampede — вероятностное раннее обновление: обновлять значение не в момент истечения TTL, а чуть раньше, с вероятностью, растущей по мере приближения к сроку. Тогда лавина не собирается в одну точку времени. Упомянуть оба подхода (singleflight + jitter/early recompute) — сильный ответ.

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

  • Мьютекс удерживается на время fn(). Полная сериализация всех ключей — именно то, чего мы избегали.
  • Нет defer вокруг delete+close. Паника или ранний return в fn оставляют ключ в мапе навсегда: все последующие запросы этого ключа виснут вечно. Отладить такое в проде крайне неприятно.
  • Чтение c.val до <-c.done. Гонка.
  • Ключ не удаляется после завершения. Тогда singleflight превращается в кэш без TTL — и первый результат, включая ошибку, навсегда прилипает к ключу.
  • Забыт буфер в DoChan. Утечка горутины на каждый отменённый вызов.
  • Одна ошибка отдаётся всем. Это осознанный компромисс: если fn упала, все 5000 ожидающих получат один и тот же err. Обычно это желаемое поведение (не добивать упавшую базу), но сказать об этом вслух надо — иначе прозвучит вопрос «а справедливо ли это?».

Добивки, которые задаст интервьюер

  • «Чем это отличается от мьютекса на ключ?» Мьютекс на ключ сериализует N вызовов: они выполнятся по очереди, N раз. Singleflight выполняет один вызов и раздаёт результат — это принципиально другая семантика.
  • «Что если fn зависла?» Все присоединившиеся зависли с ней. Отсюда DoChan + ctx у вызывающего и обязательный таймаут внутри самой fn.
  • «Зачем нужен Forget Для случаев, когда полёт «залип» или когда нужно принудительно обновить данные, не дожидаясь текущего вызова. В x/sync он тоже есть.
  • «А работает ли это между инстансами сервиса?» Нет, singleflight — процессная дедупликация. Для распределённого варианта нужен распределённый лок (Redis SET NX с TTL) — и вместе с ним весь набор проблем: истёкший лок, split-brain, необходимость fencing token.
  • «Есть ли тут утечка памяти?» Мапа растёт только на время активных полётов и сама очищается через delete. Если ключей много, но полёты короткие — мапа остаётся маленькой. Утечка возможна только при незакрытом полёте, что мы закрыли через defer.

1.4Задачи «что выведет код»

Главный дрилл-раздел. Задачи короткие, строк на десять, и почти всегда одни и те же: defer, замыкания в цикле, append, мапы, typed nil, паника в горутине. Проверяют не сообразительность, а наличие модели: понимаешь ли ты, что происходит в рантайме, или угадываешь по внешнему виду. Поэтому и оценивают в первую очередь объяснение, а не сам ответ. «Выведет 10» без разбора даёт половину балла; «выведет 10, потому что return 5 при именованном результате сначала присваивает result = 5, а defer выполняется после и видит ту же переменную» даёт полный.

Любую задачу «что выведет» разбирают пятью вопросами подряд
  1. Что здесь значение, а что ссылка? Слайс, мапа, канал, указатель, функция и интерфейс содержат внутри указатели; массив, структура и строка копируются целиком. На этом построена половина задач.
  2. Когда это вычисляется? Аргументы defer вычисляются в момент объявления, а тело defer выполняется при выходе. Условие range считается один раз, до цикла.
  3. Кто на что смотрит, на переменную или на копию? Замыкание захватывает переменную, а в аргумент функции попадает копия значения.
  4. Есть ли тут недетерминированность? Порядок обхода мапы, выбор ветки select, порядок выполнения горутин язык не определяет. Если задача про них, правильный ответ звучит как «не определено», и он же самый ценный.
  5. Что произойдёт с памятью? append может переиспользовать массив, а может аллоцировать новый, и от этого зависит, увидят ли изменения соседи.
Как отвечать вслух, если не уверен

Никогда не молчи и не гадай наугад. Рассуждай по шагам: «так, тут именованный результат, значит return сначала присваивает… defer сработает после присваивания, но до фактического возврата… значит он видит уже присвоенное значение… получается 10». Даже если в итоге ошибёшься, интервьюер увидит рабочую модель, а её он и оценивает. Отдельный приём: если ответ «не определено», скажи это прямо и объясни почему. Кандидаты, уверенно называющие конкретный порядок обхода мапы, теряют больше, чем те, кто честно говорит «порядок рандомизирован намеренно».

Вопросы

18

Код

func f1() (result int) {          // именованный результат
    defer func() { result *= 2 }()
    return 5
}

func f2() int {                   // анонимный результат
    result := 5
    defer func() { result *= 2 }()
    return result
}

func f3() (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("recovered: %v", r)
            result = -1
        }
    }()
    panic("boom")
}

func f4() (n int) {
    defer func() { n++ }()
    defer func() { n *= 10 }()
    return 1
}

func main() {
    fmt.Println(f1())
    fmt.Println(f2())
    fmt.Println(f3())
    fmt.Println(f4())
}
Вывод:
10
5
-1 recovered: boom
11

Почему: что на самом деле делает return

return X в Go распадается на три операции, строго в этом порядке:

  1. присвоить X переменной-результату;
  2. выполнить отложенные вызовы в порядке LIFO (last in, first out: последний объявленный defer срабатывает первым);
  3. фактически вернуть управление, отдав то, что лежит в переменной-результате сейчас.

Вся разница между f1 и f2 сводится к одному: существует ли эта переменная как именованная. В f1 результат назван result, и defer модифицирует ровно ту ячейку, из которой на шаге 3 будет взято значение: result = 5result *= 2 → вернули 10.

В f2 результат анонимен. return result копирует значение локальной переменной result в невидимый слот результата, и defer, изменяя локальную result, к этому слоту уже никак не относится. Функция возвращает 5, а result становится 10 в никуда.

f3 применяет ту же механику на практике и остаётся единственным способом вернуть ошибку из recover. Без именованных результатов перехваченная паника не превращается в error: писать некуда.

f4 проверяет порядок: отложенные вызовы выполняются LIFO, поэтому сначала n *= 10 (1 → 10), затем n++ (10 → 11). Порядок объявления обратен порядку выполнения.

Тот же механизм, но с ловушкой

Именованные результаты «протекают» и там, где их не ждут: defer file.Close() в функции func read() (err error) теряет ошибку закрытия. Писать надо так: defer func(){ if cerr := f.Close(); cerr != nil && err == nil { err = cerr } }(). Для файла на запись это не педантизм: Close сбрасывает буферы, и именно там обнаруживается «диск переполнен». Назвать этот приём выгодно: он показывает, что механику ты умеешь применять, а не только помнишь.

Как звучит правильное объяснение на собесе

«return сначала присваивает результату, потом идёт defer, потом собственно возврат. Если результат именованный, defer видит ту же переменную и может её поменять, поэтому f1 вернёт 10. Если анонимный, значение уже скопировано, и defer меняет локальную переменную впустую, так что f2 вернёт 5. На этом же механизме держится превращение паники в ошибку через recover, и на нём же стоит правильный defer Close(), который не теряет ошибку закрытия.»

Код

type T struct {
    n int
}

func (t T) Show()   { fmt.Println("Show (значение):", t.n) }
func (t *T) ShowP() { fmt.Println("ShowP (указатель):", t.n) }

func main() {
    i := 0
    defer fmt.Println("A defer со значением:", i)
    defer func() { fmt.Println("B defer с замыканием:", i) }()

    t := T{n: 1}
    defer t.Show()      // получатель-значение
    defer t.ShowP()     // получатель-указатель

    i = 3
    t.n = 2
    fmt.Println("конец main:", i, t.n)
}
Вывод:
конец main: 3 2
ShowP (указатель): 2
Show (значение): 1
B defer с замыканием: 3
A defer со значением: 0

Почему

Правило одно, но у него четыре следствия: defer вычисляет функцию и все её аргументы немедленно, а вызывает при выходе.

  • A. i стоит аргументом fmt.Println, поэтому вычислен и скопирован в момент defer, когда i был равен 0. Последующее i = 3 ни на что не влияет.
  • B. Аргументов у func(){...}() нет вообще. Тело работает как замыкание, захватывает переменную i, а не её значение, и читает её при выполнении: 3.
  • t.Show(). Получатель тоже считается аргументом, просто неявным. Метод объявлен на значении, поэтому t копируется в момент defer: копия помнит n = 1.
  • t.ShowP(). Метод на указателе, значит вычисляется и копируется &t, то есть сам указатель, а не то, на что он смотрит. Через него виден актуальный n = 2.

Порядок вывода подчиняется LIFO: последний объявленный defer выполняется первым.

Практическое следствие, которое приносит баллы

Вот почему defer trace(time.Now()) и defer func(){ log.Println(time.Since(start)) }() делают разное. И поэтому измерение времени пишут так: start := time.Now(); defer func(){ metrics.Observe(time.Since(start)) }(). А вот defer metrics.Observe(time.Since(start)) зафиксирует нулевую длительность, потому что time.Since вычислится немедленно. Эту ошибку регулярно допускают в реальном коде, и на собесе её стоит назвать вслух.

Как звучит правильное объяснение на собесе

«defer значит "запомнить вызов и его аргументы сейчас, выполнить потом". Аргументы, включая получателя метода, копируются в момент объявления. Поэтому defer f(x) замораживает x, а defer func(){ f(x) }() не замораживает: замыкание держит саму переменную. Если метод на значении, заморозится копия структуры; если на указателе, заморозится указатель, и изменения будут видны. Выполняются в порядке LIFO.»

Код

func main() {
    for i := 0; i < 3; i++ {
        defer fmt.Println("defer", i)
    }
    fmt.Println("конец цикла")

    for i := 0; i < 3; i++ {
        func() {
            defer fmt.Println("вложенный defer", i)
        }()
    }
    fmt.Println("конец main")
}
Вывод:
конец цикла
вложенный defer 0
вложенный defer 1
вложенный defer 2
конец main
defer 2
defer 1
defer 0

Почему

defer привязан к функции, а не к блоку. Ни фигурные скобки цикла, ни if, ни switch не создают точку выполнения отложенных вызовов; её создаёт только возврат из функции (или паника). Поэтому три defer из первого цикла копятся в списке текущей функции и разряжаются в самом конце main, в порядке LIFO.

Второй цикл показывает стандартное лекарство: обернуть тело итерации в немедленно вызываемую анонимную функцию. Теперь у каждой итерации своя функция, и defer срабатывает на её выходе сразу, в прямом порядке.

Почему это настоящий баг, а не эстетика
// Открывает 10 000 файлов и НИ ОДНОГО не закрывает до конца функции.
for _, name := range names {
    f, err := os.Open(name)
    if err != nil {
        return err
    }
    defer f.Close()          // ← накопится 10 000 отложенных Close
    process(f)
}

На тысяче файлов это упрётся в лимит дескрипторов процесса (ulimit -n, часто 1024) и вернёт «too many open files». То же самое с rows.Close() в цикле по запросам к БД: соединения не возвращаются в пул, и пул исчерпывается. Лечится либо анонимной функцией, либо явным f.Close() в конце итерации (но тогда придётся аккуратно закрывать и на путях с ошибкой, так что обычно проще функция).

Отдельно про производительность: с Go 1.14 defer в большинстве случаев инлайнится и стоит порядка одной-двух наносекунд. Это уже не тот дорогой defer, о котором писали в 2017-м. Но defer в цикле остаётся дорогим по другой причине: он не выполняется, а накапливается в списке, то есть расходует память пропорционально числу итераций.

Как звучит правильное объяснение на собесе

«defer относится к функции, а не к блоку, поэтому в цикле он не выполняется на каждой итерации, а копится и разряжается в конце функции в порядке LIFO. Для ресурсов внутри цикла это утечка дескрипторов, поэтому тело итерации заворачивают в анонимную функцию, чтобы у каждой итерации была своя точка выхода. В main из примера сначала отработают все вложенные defer по ходу второго цикла, а три "цикловых" сработают только после конец main, в обратном порядке.»

Код

func main() {
    var wg sync.WaitGroup
    for i := 0; i < 3; i++ {
        wg.Add(1)
        go func() { defer wg.Done(); fmt.Print(i, " ") }()
    }
    wg.Wait()
    fmt.Println()

    var fns []func()
    for _, s := range []string{"a", "b", "c"} {
        fns = append(fns, func() { fmt.Print(s, " ") })
    }
    for _, f := range fns {
        f()
    }
    fmt.Println()

    ptrs := []*int{}
    for i := 0; i < 3; i++ {
        ptrs = append(ptrs, &i)
    }
    for _, p := range ptrs {
        fmt.Print(*p, " ")
    }
    fmt.Println()
}
Вывод до Go 1.22 (go 1.21 и ниже в go.mod):
3 3 3
c c c
3 3 3
Пять прогонов подряд — все пять одинаковые: старая семантика детерминирована, потому что все горутины читают одну ячейку уже после цикла.
Вывод начиная с Go 1.22:
0 1 2 — но в произвольном порядке; восемь прогонов подряд дали 2 1 0 пять раз и 2 0 1 три раза
a b c
0 1 2
Вторая и третья строки, наоборот, детерминированы: там нет горутин, замыкания вызываются последовательно.

Почему: что именно изменили в Go 1.22

До Go 1.22 переменная цикла объявлялась один раз на весь цикл и переиспользовалась на каждой итерации. Замыкание захватывает переменную, а не её значение, поэтому все три горутины ссылались на одну и ту же ячейку памяти. К моменту их запуска цикл обычно уже завершался, и в ячейке лежало финальное значение, 3 для счётчика и "c" для range. Отсюда и &i: три указателя на одну ячейку.

Начиная с Go 1.22 переменная цикла объявляется заново на каждой итерации: формально компилятор создаёт новую переменную и копирует в неё значение перед телом итерации. Каждое замыкание получает свою ячейку, и задача перестаёт быть задачей.

Деталь про версию. Поведение определяется не версией установленного компилятора, а строкой go 1.xx в go.mod модуля. Собранный Go 1.27 модуль с go 1.21 получит старую семантику. Именно поэтому вопрос всё ещё живой: легаси-модулей с go 1.19 вокруг очень много, а миграция сводится к правке одной строки, которую боятся трогать ровно из-за этого изменения.

for i := 0; i < 3; i++ { go func(){ print(i) }() } до Go 1.22: одна переменная i на весь цикл i (одна ячейка) после цикла = 3 горутина 0 горутина 1 горутина 2 все три читают ОДНУ ячейку и видят её финальное значение вывод: 3 3 3 Go 1.22 и новее: своя переменная на каждую итерацию i₀ = 0 i₁ = 1 i₂ = 2 горутина 0 горутина 1 горутина 2 каждая горутина держит свою ячейку вывод: 0 1 2 — но порядок произвольный! значения правильные, порядок — на усмотрение планировщика
Захват переменной цикла. Замыкание всегда захватывает переменную, а не значение, и это правило не менялось. Изменилось другое: сколько переменных создаёт цикл. Раньше одну на весь цикл, теперь по одной на итерацию.
Даже на Go 1.22+ выведется «0, 1, 2 в любом порядке», а не «0 1 2»

Это вторая половина вопроса, и на ней срезаются те, кто выучил только про 1.22. Значения теперь правильные, но порядок выполнения горутин языком не определён: планировщик волен запустить их как угодно. На практике вывод часто получается 2 1 0: у планировщика Go есть оптимизация «только что созданную горутину запускаем следующей» (она кладётся в отдельную приоритетную ячейку, а не в хвост очереди), поэтому последняя созданная часто и оказывается первой напечатавшей. Но это деталь реализации, а не обещание языка, и опираться на неё нельзя. Правильный ответ звучит как «выведет 0, 1 и 2 в произвольном порядке; если нужен детерминированный порядок, надо писать в out[i] и печатать после wg.Wait()».

Как писали до 1.22 — и почему это до сих пор встречается

// Способ 1: параметр функции, значение копируется при вызове go.
for i := 0; i < 3; i++ {
    go func(i int) { fmt.Print(i, " ") }(i)
}

// Способ 2: затенение, новая переменная на итерацию вручную.
for i := 0; i < 3; i++ {
    i := i                    // да, это легально и было идиомой
    go func() { fmt.Print(i, " ") }()
}
// Оба способа под go 1.21 в go.mod печатают 0, 1 и 2, но в произвольном порядке.
// Шесть прогонов подряд дали: 2 0 1 / 2 1 0 / 2 1 0 / 2 1 0 / 2 1 0 / 2 0 1.
// (В самом фрагменте нет wg.Wait, так что для проверки его надо дописать,
//  иначе main завершится раньше, чем горутины успеют напечатать хоть что-то.)

Оба приёма продолжают работать и на 1.22+, просто перестали быть обязательными. В новом коде строка i := i теперь избыточна, и линтеры (copyloopvar) на неё ругаются.

Как звучит правильное объяснение на собесе

«Замыкание захватывает переменную, а не значение. До Go 1.22 переменная цикла была одна на весь цикл, поэтому все горутины видели её финальное значение, отсюда 3 3 3. С Go 1.22 на каждой итерации создаётся своя переменная, и выведется 0, 1, 2, но в произвольном порядке, потому что порядок выполнения горутин не гарантирован. Семантику задаёт строка go в go.mod, а не версия компилятора, так что в старом модуле, собранном новым Go, поведение останется старым.»

Код

func main() {
    a := []int{1, 2, 3, 4, 5}
    b := a[1:3]
    fmt.Println(len(b), cap(b))

    b = append(b, 99)
    fmt.Println(a)
    fmt.Println(b)
}
Вывод:
2 4
[1 2 3 99 5] ← четвёртый элемент a затёрт
[2 3 99]

Почему

Слайс устроен как заголовок из трёх полей: {ptr, len, cap}. Операция a[1:3] не копирует данные, она делает новый заголовок на тот же массив: указатель сдвигается на элемент 1, длина становится 2, а ёмкость считается до конца массива: cap(b) = cap(a) - 1 = 4. Именно эта «лишняя» ёмкость и есть корень проблемы.

append(b, 99) видит len(b) < cap(b) и понимает, что место есть, а новый массив выделять не нужно. Он записывает 99 в следующую свободную ячейку того же массива, а это физически a[3]. Значение 4 уничтожено. Возвращается заголовок с len = 3, указывающий туда же.

Один массив, два заголовка: append пишет в чужие данные общий underlying array 1 2 3 4 5 [0][1] [2][3] [4] a = {ptr, 5, 5} видит весь массив b = a[1:3] {ptr+1, len 2, cap 4} len(b) = 2 cap тянется до конца массива → append(b, 99): len < cap, значит места хватает — новый массив НЕ выделяется, 99 пишется в a[3] 99 значение 4 потеряно навсегда Лечение: b := a[1:3:3] — full slice expression обрезает cap, и append будет вынужден скопировать.
append и underlying array. Решает один признак: len < cap. Если места хватает, запись идёт в существующий массив и видна всем, кто на него смотрит. Если не хватает, выделяется новый массив, и слайсы «расходятся». Поэтому результат append зависит не от кода рядом, а от ёмкости, которую в коде обычно не видно.

Где это встречается в реальном коде

// Популярный приём «отфильтровать без аллокации» легко превращается в баг,
// если вызывающий продолжает пользоваться исходным слайсом.
func filterInPlace(s []int, keep func(int) bool) []int {
    out := s[:0]                 // тот же массив, len 0, cap полный
    for _, v := range s {
        if keep(v) {
            out = append(out, v) // пишем поверх s
        }
    }
    return out
}

a := []int{1, 2, 3, 4, 5}
b := filterInPlace(a, func(v int) bool { return v%2 == 0 })
// b = [2 4], но a = [2 4 3 4 5] — исходный слайс изуродован

Как звучит правильное объяснение на собесе

«Срез не копирует данные, он делает новый заголовок на тот же массив, и cap считается до конца массива, а не до конца среза. У b получилось len 2, cap 4, поэтому append нашёл свободное место в том же массиве и записал 99 в ячейку, которая для a служит четвёртым элементом. Если нужно гарантировать независимость, надо либо явно копировать через slices.Clone, либо ограничить ёмкость трёхиндексным срезом a[1:3:3]

Код

func main() {
    x := make([]int, 3, 4)        // len 3, cap 4, есть одна свободная ячейка
    copy(x, []int{1, 2, 3})

    y := append(x, 4)
    z := append(x, 5)
    fmt.Println(x, y, z)

    p := []int{1, 2, 3}           // len 3, cap 3, свободных ячеек нет
    q := append(p, 4)
    q[0] = 99
    fmt.Println(p, q)
}
Вывод:
[1 2 3] [1 2 3 5] [1 2 3 5]y «потерял» четвёрку
[1 2 3] [99 2 3 4] ← а тут слайсы независимы

Почему

Это самая коварная пара примеров во всей теме, потому что они выглядят одинаково, а ведут себя противоположно. Разница ровно одна: хватило ли ёмкости.

Первый случай. При cap(x) = 4 и len(x) = 3 свободная ячейка есть. append(x, 4) записывает 4 в array[3] и возвращает заголовок {ptr, 4, 4}. Затем append(x, 5) смотрит на тот же x, у которого по-прежнему len 3, и записывает 5 в ту же array[3], затирая четвёрку. Теперь y и z держат два заголовка на один массив, и оба показывают [1 2 3 5]. Сам x остаётся [1 2 3], потому что его len никто не менял: append не модифицирует исходный заголовок, он возвращает новый.

Второй случай. cap(p) = 3, места нет. append выделяет новый массив (обычно вдвое больше), копирует туда три элемента, дописывает четвёртый. Слайс q смотрит на новую память, поэтому q[0] = 99 никак не задевает p.

Почему это опасно именно на практике

Поведение append зависит от cap, а его в коде обычно не видно. Слайс, пришедший из make([]T, 0, 100), из json.Unmarshal или из другой функции, имеет непредсказуемую ёмкость. Отсюда железное правило: всегда присваивай результат append обратно (s = append(s, v)) и никогда не считай, что два слайса независимы, если ты сам их не скопировал. Если функция принимает слайс и хочет его сохранить у себя, она обязана сделать slices.Clone. Это ровно та причина, по которой в net/http и в драйверах БД так много защитных копий.

Как растёт ёмкость

Точные коэффициенты остаются деталью реализации и менялись между версиями, но модель такая: для маленьких слайсов ёмкость примерно удваивается, а после порога (около 256 элементов с Go 1.18) рост становится плавнее и с ростом длины сходится к ×1.25, чтобы не тратить память на больших объёмах: сразу за порогом коэффициент ещё около полутора. Поэтому append работает амортизированно за O(1): отдельная операция может стоить O(n) на копировании, но суммарно за n добавлений копирований будет O(n). Заранее известный размер стоит задавать: make([]T, 0, n) убирает все реаллокации.

var s []int
prev := cap(s)
for i := 0; i < 2000; i++ {
    s = append(s, i)
    if cap(s) != prev {
        fmt.Printf("len=%d cap=%d\n", len(s), cap(s))
        prev = cap(s)
    }
}
// len=1 cap=4, len=5 cap=8, len=9 cap=16, len=17 cap=32, ... len=257 cap=512,
// а дальше шаг роста уменьшается: 512 -> 848 -> 1280 -> 1792 -> 2560

Как звучит правильное объяснение на собесе

«append смотрит на len и cap. Если места хватает, он пишет в существующий массив и возвращает заголовок с увеличенной длиной; исходный слайс при этом не меняется, но данные общие. Если места нет, выделяется новый массив, и слайсы становятся независимыми. В первом примере ёмкости хватило, поэтому оба append писали в одну и ту же ячейку, и второй затёр первый. Во втором не хватило, поэтому q уехал на новую память. Из этого следует правило: не полагаться на то, скопировал append данные или нет, а копировать явно, когда независимость нужна.»

Код

func main() {
    m := []int{1, 2, 3, 4, 5}
    n := m[1:3:3]                 // low : high : max
    fmt.Println(len(n), cap(n))

    n = append(n, 99)
    fmt.Println(m)
    fmt.Println(n)
}
Вывод:
2 2cap обрезан до max - low = 3 - 1 = 2
[1 2 3 4 5] ← исходный слайс цел
[2 3 99]

Почему

Full slice expression a[low:high:max] задаёт все три поля заголовка явно: ptr = &a[low], len = high - low, cap = max - low. Действует ограничение low ≤ high ≤ max ≤ cap(a).

В примере cap(n) == len(n) == 2, свободного места нет вообще. Поэтому append обязан выделить новый массив и скопировать туда данные, а исходный m остаётся физически недосягаем. Ровно этого мы и добивались.

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

type Buffer struct {
    data []byte
}

// Плохо: вызывающий может append'нуть и затереть наши данные за границей len.
func (b *Buffer) Head(n int) []byte { return b.data[:n] }

// Хорошо: cap обрезан, любой append у вызывающего сделает копию.
func (b *Buffer) HeadSafe(n int) []byte { return b.data[:n:n] }

Идиома s[:n:n] (или s[:len(s):len(s)]) читается как «отдать read-append-safe вид на слайс». Ровно в таком виде она встречается в стандартной библиотеке и в хорошем чужом коде.

Глубже, чем спросят: обрезка cap ≠ защита данных

a[:n:n] защищает только от append за границей длины. Прямая запись s[0] = 42 по-прежнему меняет исходный массив, ёмкость тут ни при чём. Если нужна настоящая изоляция, остаётся только копия: slices.Clone(s) или append([]T(nil), s...). Кто разводит эти два уровня вслух, сразу выделяется: большинство кандидатов считают, что [:n:n] «делает слайс безопасным».

Как звучит правильное объяснение на собесе

«Третий индекс задаёт cap: a[low:high:max] даёт cap = max - low. Когда я пишу a[1:3:3], ёмкость становится равной длине, свободного места нет, и любой append вынужден скопировать данные в новый массив. Это стандартный приём, когда отдаёшь наружу часть своего буфера: сосед не сможет незаметно записать поверх твоих данных через append. От прямой записи по индексу это не защищает, там нужна копия.»

Код

func mod(s []int) {
    s[0] = 100                    // запись по индексу
    s = append(s, 4)              // может переаллоцировать
    s[1] = 200                    // запись, но куда?
}

func main() {
    a := []int{1, 2, 3}           // len 3, cap 3
    mod(a)
    fmt.Println(a, len(a), cap(a))

    b := make([]int, 3, 4)        // len 3, cap 4
    copy(b, []int{1, 2, 3})
    mod(b)
    fmt.Println(b, len(b), cap(b))
}
Вывод:
[100 2 3] 3 3append переаллоцировал, s[1]=200 ушло в новый массив
[100 200 3] 3 4 ← ёмкости хватило, писали в тот же массив

Почему

В Go всё передаётся по значению, включая слайс. Функция получает копию заголовка {ptr, len, cap}. Отсюда два разных эффекта, которые часто путают в один:

  • Запись по индексу проходит через указатель, который в копии заголовка тот же. Поэтому s[0] = 100 меняет данные, видимые снаружи. Работает всегда.
  • append меняет поля len/cap копии заголовка, а копия локальна. Даже если данные записались в общий массив, снаружи len остался старым, и новый элемент не виден. Именно поэтому обязательно писать s = append(s, v) и возвращать результат.

Первый случай: cap(a) = 3, места нет, append выделил новый массив. Локальный s теперь смотрит туда, и s[1] = 200 пишет в новую память, которую снаружи никто не видит. На выходе [100 2 3].

Второй случай: cap(b) = 4, место есть, append дописал в тот же массив. Локальный s всё ещё указывает на массив b, поэтому s[1] = 200 меняет b[1]. Получается [100 200 3], а четвёрка лежит в b[3], за границей длины, и снаружи не видна.

Почему это самая «дорогая» задача в разделе

Она объединяет три механики сразу: передачу заголовка по значению, зависимость append от cap и разницу между «изменить данные» и «изменить заголовок». Кандидат, который разложил её по этим трём уровням, фактически закрыл всю тему слайсов одним ответом. Формулировка-эталон: «слайс это значение, которое ссылается на общие данные; функция может менять данные, но не может поменять мой заголовок». Если нужно, чтобы функция могла, передавай *[]int или возвращай новый слайс.

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

// Вариант 1: вернуть результат, так идиоматичнее.
func mod(s []int) []int {
    s[0] = 100
    s = append(s, 4)
    s[1] = 200
    return s
}
a = mod(a)

// Вариант 2: указатель на слайс, когда возврат неудобен (редко).
func modP(s *[]int) {
    (*s)[0] = 100
    *s = append(*s, 4)
}

Как звучит правильное объяснение на собесе

«Слайс передаётся по значению, функция получает копию заголовка, но указатель внутри тот же. Поэтому запись по индексу видна снаружи всегда, а append снаружи не виден: он меняет len локальной копии. Дальше всё зависит от ёмкости: если append переаллоцировал, локальный слайс уехал на новую память и последующие записи наружу не видны; если места хватило, они по-прежнему идут в исходный массив. Поэтому в Go и пишут s = append(s, v), а функции, которые дописывают в слайс, возвращают его.»

Код

type P struct {
    Name string
    Age  int
}

func main() {
    ps := []P{{"Аня", 30}, {"Боря", 40}}
    for _, p := range ps {
        p.Age++                       // меняем копию
    }
    fmt.Println(ps)

    for i := range ps {
        ps[i].Age++                   // меняем оригинал
    }
    fmt.Println(ps)

    arr := [3]int{1, 2, 3}            // МАССИВ
    fmt.Print("массив: ")
    for i, v := range arr {
        if i == 0 {
            arr[1] = 100
        }
        fmt.Print(v, " ")
    }
    fmt.Println(arr)

    sl := []int{1, 2, 3}              // СЛАЙС
    fmt.Print("слайс:  ")
    for i, v := range sl {
        if i == 0 {
            sl[1] = 100
        }
        fmt.Print(v, " ")
    }
    fmt.Println(sl)
}
Вывод:
[{Аня 30} {Боря 40}] ← ничего не изменилось
[{Аня 31} {Боря 41}]
массив: 1 2 3 [1 100 3] ← цикл не увидел записи
слайс: 1 100 3 [1 100 3] ← цикл увидел запись

Почему часть первая: переменная цикла — копия

for _, p := range ps на каждой итерации копирует элемент в переменную p. Копия структуры полностью независима, p.Age++ инкрементирует её и выбрасывает при следующей итерации. Чтобы изменить оригинал, нужно обращаться по индексу: ps[i].Age++ (или брать &ps[i]).

Побочный эффект, о котором стоит знать: копирование не бесплатно. Если структура большая — скажем, 200 байт, — цикл for _, v := range bigStructs копирует по 200 байт на итерацию. Для горячего кода это заметно, и там пишут for i := range s с обращением &s[i].

Почему часть вторая: range вычисляет выражение один раз

Перед началом цикла Go один раз вычисляет выражение справа от range и работает с этой копией значения. И вот тут массив и слайс расходятся радикально:

  • Массив — это значение целиком, все элементы. Копируется весь массив. Цикл идёт по копии, поэтому arr[1] = 100 внутри тела на итерации не влияет: копия сделана до цикла. Печатается 1 2 3, хотя сам arr уже [1 100 3].
  • Слайс — это заголовок {ptr, len, cap}. Копируется заголовок, но данные общие. Поэтому sl[1] = 100 видно на следующей итерации: печатается 1 100 3.

Отсюда же следует ещё одно свойство: число итераций фиксируется до цикла. Если внутри range sl делать sl = append(sl, x), цикл всё равно пройдёт исходное число раз — бесконечного цикла не будет, потому что len взят один раз. Это отличает Go от языков, где такой код зацикливается.

Глубже, чем спросят: range по указателю на массив

for i, v := range &arr — легально, и массив в этом случае не копируется: итерация идёт по оригиналу, изменения видны. Это редкий, но настоящий приём для больших массивов в горячем коде. Заодно он хорошо иллюстрирует главный тезис: разница в поведении не в слове range, а в том, что именно копируется — значение или ссылка на него.

Как звучит правильное объяснение на собесе

«range делает две вещи, о которых надо помнить. Первое: он один раз вычисляет выражение справа. Для массива это копия всех элементов, поэтому изменения внутри цикла не видны; для слайса — копия заголовка, а данные общие, поэтому видны. Второе: переменная значения — это копия элемента, менять её бессмысленно, надо идти через индекс. Оба факта сводятся к одному: в Go всё копируется по значению, а разница между массивом и слайсом — в том, что именно лежит в этом значении.»

Код

func main() {
    m := map[string]int{"a": 1, "b": 2, "c": 3, "d": 4, "e": 5, "f": 6}
    for r := 0; r < 3; r++ {
        fmt.Print("проход ", r, ": ")
        for k := range m {
            fmt.Print(k, " ")
        }
        fmt.Println()
    }

    m2 := map[int]int{1: 1, 2: 2, 3: 3, 4: 4}
    seen := 0
    for k := range m2 {
        seen++
        if k == 1 {
            m2[100] = 100          // добавляем во время обхода
            delete(m2, 4)          // и удаляем
        }
    }
    fmt.Println("итераций:", seen, "итог:", len(m2))
}
Вывод: не определён. Порядок обхода разный на каждом проходе и в каждом запуске. Два реальных прогона подряд:
проход 0: a b c d e f / проход 1: e f a b c d / проход 2: b c d e f a
проход 0: a b c d e f / проход 1: a b c d e f / проход 2: d e f a b c ← первые два прохода случайно совпали, это тоже бывает
А число итераций во втором цикле — 3 или 4 в зависимости от запуска: на 300 прогонах вышло 4 итерации 267 раз и 3 итерации 33 раза. Итоговый len(m2) при этом всегда 4.

Почему порядок случаен — и почему это сделано специально

Рантайм Go намеренно рандомизирует стартовую позицию обхода мапы. Это не побочный эффект хеширования, а сознательное решение: чтобы никто не начал полагаться на порядок, который остаётся деталью устройства мапы. Иначе любая переделка внутренностей (а их переписывали: в Go 1.24 мапу перевели на SwissTable) ломала бы код по всей экосистеме. Плюс защита от HashDoS: в этой атаке злоумышленник подбирает ключи с одинаковым хешем, все они ложатся в одну корзину, и поиск в мапе вырождается из O(1) в O(n). Подобрать такие ключи не выйдет: сид (seed, случайное число, которое подмешивается в хеш) у каждого процесса свой и берётся при старте.

Практическое следствие: если порядок нужен, его надо создать явно.

keys := make([]string, 0, len(m))
for k := range m {
    keys = append(keys, k)
}
sort.Strings(keys)               // или slices.Sort(keys)
for _, k := range keys {
    fmt.Println(k, m[k])
}
// Для m := map[string]int{"b": 2, "a": 1, "c": 3} выведет всегда одно и то же:
// a 1
// b 2
// c 3

Почему модификация во время обхода даёт разное число итераций

Спецификация Go описывает это буквально в двух правилах:

  • Удаление безопасно и предсказуемо. Если ключ удалён во время обхода, а цикл до него ещё не дошёл, он не будет выдан. Поэтому for k := range m { delete(m, k) } законно очищает мапу (хотя clear(m) с Go 1.21 лучше).
  • Добавление недетерминированно. Новый ключ может быть выдан в этом же обходе, а может и нет. Зависит от того, попал он в уже пройденную область таблицы или в ещё не пройденную и вызвала ли вставка рост таблицы.

В примере: 4 ключа, а на итерации с ключом 1 один удаляется (4) и один добавляется (100). Если удалённый ключ ещё не был выдан, а это редкий случай — ключ 1 должен выпасть первым, — получается минус одна итерация. Новый попал в непройденную часть, значит плюс одна. Комбинации дают 3 или 4. Итоговый len при этом всегда 4: было 4, минус один, плюс один.

А вот это уже не «недетерминированно», а падение

Всё сказанное относится к обходу и модификации из одной горутины. Одновременный доступ из разных горутин, где хотя бы одна пишет, даёт не гонку данных «в общем смысле», а конкретное падение: рантайм ловит его и завершает процесс с fatal error: concurrent map read and map write. Это не паника, её нельзя перехватить через recover, процесс умирает целиком. Проверку встроили в рантайм именно потому, что молчаливое повреждение хеш-таблицы отлаживать невозможно. Лечится мьютексом, sync.Map или шардированием (см. вопрос 5 в главе 1.3).

Как звучит правильное объяснение на собесе

«Порядок обхода мапы в Go рандомизирован намеренно: чтобы на него не полагались и чтобы защититься от HashDoS. Для стабильного порядка надо собрать ключи в слайс и отсортировать. Модификация во время обхода из одной горутины легальна, но с оговорками: удалённый ключ гарантированно не будет выдан, а добавленный может быть выдан, а может нет. Поэтому число итераций тут не определено, 3 или 4. А конкурентная модификация из другой горутины даёт fatal error от рантайма, который не ловится через recover

Код

func main() {
    var m map[string]int           // объявлена, но НЕ создана — это nil

    fmt.Println(m == nil, len(m), m["x"])
    v, ok := m["x"]
    fmt.Println("чтение:", v, ok)

    delete(m, "x")                 // без паники
    for range m {                  // 0 итераций, без паники
        fmt.Println("не будет")
    }
    fmt.Println("до записи всё ок")

    defer func() { fmt.Println("recover:", recover()) }()
    m["x"] = 1                     // ← вот тут
}
Вывод:
true 0 0
чтение: 0 false
до записи всё ок
recover: assignment to entry in nil map

Почему

Nil-мапа хранит нулевой указатель на хеш-таблицу. Всё, что не требует самой таблицы, работает корректно и без специальных проверок в твоём коде:

ОперацияНа nil-мапеПочему
len(m)0нечего считать
m["x"]нулевое значение типачтение из nil-мапы определено как «ключ не найден»
v, ok := m["x"]0, falseто же самое, с явным флагом
delete(m, "x")no-opс Go 1.0 удаление из nil-мапы легально
for range m0 итерацийобходить нечего
m == niltrueмапу можно сравнивать только с nil
m["x"] = 1паникаписать некуда: таблицы не существует

Асимметрию сделали осознанно: nil-мапа ведёт себя как пустая мапа только для чтения. Поэтому функция спокойно возвращает nil вместо пустой мапы, а вызывающий её читает и обходит, не проверяя на nil. Так же устроен nil-слайс: len, range и append на нём работают.

Где на это реально напарываются
type Config struct {
    Name string
    Tags map[string]string          // забыли инициализировать
}

c := Config{Name: "svc"}
fmt.Println(c.Tags["env"])          // "" — работает, никаких подозрений
c.Tags["env"] = "prod"              // panic: assignment to entry in nil map

Мапа-поле структуры даёт эту панику чаще всего: нулевое значение структуры выглядит рабочим, читается без ошибок, и падает только на первой записи, возможно, спустя месяцы после релиза. Лечится конструктором NewConfig(), который создаёт все мапы и каналы, или ленивой инициализацией в методе-сеттере (if c.Tags == nil { c.Tags = make(map[string]string) }). Та же история с json.Unmarshal: если в JSON поля не было, мапа останется nil.

Как звучит правильное объяснение на собесе

«Nil-мапа ведёт себя как пустая мапа, доступная только для чтения. Чтение, len, range и delete на ней работают и возвращают нулевые значения: так сделано, чтобы функции могли возвращать nil вместо пустой мапы. Паникует только запись, потому что писать физически некуда. Чаще всего на это напарываются с мапой-полем структуры: нулевое значение структуры содержит nil-мапу, и падение приходит на первой же записи. Отсюда правило: мапы и каналы инициализируются в конструкторе.»

Код

type MyErr struct {
    Code int
}

func (e *MyErr) Error() string { return fmt.Sprintf("my error %d", e.Code) }

func doWork(fail bool) *MyErr {      // ← конкретный тип, не error
    if fail {
        return &MyErr{Code: 500}
    }
    return nil                       // честный nil типа *MyErr
}

func wrap(fail bool) error {         // ← а тут уже интерфейс
    return doWork(fail)
}

func main() {
    err := wrap(false)
    fmt.Println("err == nil:", err == nil)
    fmt.Println("err печатается как:", err)
    fmt.Printf("%v | %T\n", err, err)

    var p *MyErr
    var i any = p
    fmt.Println("any(nil-указатель) == nil:", i == nil)
}
Вывод:
err == nil: false ← вот она, ловушка
err печатается как: <nil> ← и это добивает окончательно
<nil> | *main.MyErr ← а тип-то есть
any(nil-указатель) == nil: false

Почему

Внутри значения интерфейса лежит пара (тип, значение). Интерфейс равен nil тогда и только тогда, когда обе части пусты.

ВыражениеДинамический типЗначение== nil
var e errornilniltrue
var p *MyErr; var e error = p*MyErrnilfalse
e = &MyErr{}*MyErrадресfalse

На return doWork(fail) срабатывает неявное преобразование *MyErrerror. Компилятор упаковывает пару: тип *MyErr есть, значение указателя равно nil. Интерфейс получился «непустым», хотя внутри пусто.

Больнее всего делает баг строка err печатается как: <nil>. fmt видит nil-указатель внутри и печатает <nil>, так что в логах всё выглядит нормально, а if err != nil при этом срабатывает. Классический сценарий: сервис отвечает «ошибка», в логе стоит <nil>, и несколько часов уходит на поиск.

Как чинить

// Вариант 1 (лучший): функция сразу возвращает интерфейс error.
func doWork(fail bool) error {
    if fail {
        return &MyErr{Code: 500}
    }
    return nil                       // теперь это настоящий nil-интерфейс
}

// Вариант 2: если сигнатуру менять нельзя — явная проверка при конвертации.
func wrap(fail bool) error {
    if e := doWork(fail); e != nil {  // сравниваем УКАЗАТЕЛЬ с nil, до упаковки
        return e
    }
    return nil
}

Правило, которое стоит произнести: «функции всегда возвращают error, а не конкретный тип ошибки». Это официальная рекомендация из Go FAQ, а вот линтера, который ловил бы этот случай, нет: nilnil проверяет другое — возврат nil, nil, — а nilness из x/tools в go vet не входит и ищет разыменование заведомо нулевого указателя.

Глубже, чем спросят: то же самое с любым интерфейсом

Ловушка не про error, а про интерфейсы вообще. var w io.Writer = (*os.File)(nil) тоже не равен nil, и вызов w.Write(...) уйдёт в метод с nil-получателем. Иногда это даже работает: метод на указателе с nil-получателем вызывается нормально, пока не разыменовывает поля. На этом построены, например, «нулевые» реализации деревьев, где func (t *Tree) Size() int { if t == nil { return 0 }; ... }. Так что typed nil не всегда баг, но всегда требует осознанности.

Как звучит правильное объяснение на собесе

«Интерфейс хранит пару тип-значение и равен nil только когда обе части пусты. Здесь функция вернула nil типа *MyErr; при упаковке в error тип сохранился, поэтому интерфейс не nil, хотя указатель внутри nil. Печатается он при этом как <nil>, что делает баг особенно неприятным. Правило простое: возвращать из функций error, а не конкретный тип ошибки; если сигнатуру не поменять, сравнить конкретный указатель с nil до того, как он попадёт в интерфейс.»

Код

func main() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("main recover:", r)
        }
        fmt.Println("defer в main")
    }()

    go func() {
        defer fmt.Println("defer в горутине")
        panic("boom")
    }()

    time.Sleep(100 * time.Millisecond)
    fmt.Println("сюда не дойдём")
}
Вывод:
defer в горутине
panic: boom + стек, и процесс падает с кодом выхода 2.
Ни main recover, ни defer в main, ни сюда не дойдём не напечатаются.

Почему

recover работает только внутри той же горутины, где случилась паника, и только в отложенной функции, вызванной непосредственно паникующей цепочкой. У каждой горутины свой стек и свой список defer; main о панике в другой горутине ничего не знает: для неё эта паника не «происходит».

Механика раскрутки такая: паника поднимается по стеку своей горутины, выполняя её отложенные вызовы, поэтому defer в горутине и печатается. Если ни один из них не сделал recover, паника доходит до вершины стека горутины, и рантайм печатает сообщение с трассировкой и завершает весь процесс. Отложенные вызовы остальных горутин, включая main, при этом не выполняются вовсе.

Практическое следствие: recover в каждой горутине
// Обёртка, которую стоит завести в любом сервисе с фоновыми горутинами.
func Go(fn func()) {
    go func() {
        defer func() {
            if r := recover(); r != nil {
                log.Printf("паника в горутине: %v\n%s", r, debug.Stack())
                metrics.PanicTotal.Inc()
            }
        }()
        fn()
    }()
}

Именно поэтому http.Server оборачивает каждый обработчик своим recover: один кривой запрос не должен ронять весь сервис. По той же причине воркер в пуле (см. главу 1.3) обязан иметь recover, иначе одна плохая задача убивает процесс со всеми остальными.

Go 1.27: из трейсбека паники видно, какой запрос упал

Раньше в логе паники была только трассировка стека, и связать её с конкретным запросом можно было лишь по соседним строкам лога. В Go 1.26 это добавили под GODEBUG=tracebacklabels=1, а с Go 1.27 оно включено по умолчанию: рантайм печатает в заголовке горутины её метки, те самые, что ставятся через pprof.Labels и pprof.SetGoroutineLabels для разрезания CPU-профиля:

goroutine 1 [running] {request_id: "abc-123", route: "/users/{id}"}:
main.main()

Проставить метки на входе в HTTP-обработчик занимает три строки, а в разборе инцидента это разница между «где-то упало» и «упало на запросе abc-123». Отключается GODEBUG=tracebacklabels=0.

Что recover не ловит вообще

  • Панику из другой горутины: разобрано выше.
  • fatal error от рантайма. «concurrent map writes», «all goroutines are asleep - deadlock», «out of memory», переполнение стека. Это не паники, а фатальные состояния рантайма: их не перехватить в принципе.
  • os.Exit(). Он не разматывает стек и не выполняет defer.
  • recover не в defer. Вызов recover() прямо в коде функции всегда вернёт nil: он работает только из отложенной функции. И только из непосредственно отложенной: defer func(){ helper() }(), где helper вызывает recover, тоже не сработает.

Как звучит правильное объяснение на собесе

«recover действует только в своей горутине: у каждой горутины свой стек и свой список defer. Паника раскручивает стек только этой горутины, поэтому её собственный defer отработает, а потом рантайм печатает трассировку и убивает процесс целиком. defer в main и в других горутинах при этом не выполняется. Отсюда практическое правило: каждая горутина, которую ты запускаешь, обязана иметь свой recover, иначе любая ошибка в фоновой задаче роняет весь сервис.»

Код

func main() {
    a := make(chan int, 1)
    b := make(chan int, 1)
    counts := map[string]int{}
    for i := 0; i < 10000; i++ {
        a <- 1
        b <- 2
        select {                       // ОБА готовы
        case <-a:
            counts["a"]++
            <-b
        case <-b:
            counts["b"]++
            <-a
        }
    }
    fmt.Println("случайный выбор:", counts)

    c := make(chan int)
    close(c)
    for i := 0; i < 3; i++ {
        select {
        case v, ok := <-c:
            fmt.Println("из закрытого:", v, ok)
        }
    }

    var nilCh chan int
    select {
    case <-nilCh:
        fmt.Println("никогда")
    default:
        fmt.Println("nil-канал не готов никогда — сработал default")
    }
}
Вывод:
случайный выбор: map[a:5067 b:4933] ← примерно 50/50, числа пляшут от запуска к запуску: шесть прогонов подряд дали a = 5053, 4979, 4905, 4958, 5054, 5013 из 10 000. Один прогон тут ничего не доказывает — статистика на 10 000 итераций доказывает
из закрытого: 0 false — три раза подряд, без блокировки
nil-канал не готов никогда — сработал default

Почему

Правил у select четыре, и все четыре стоит назвать:

  1. Если готова ровно одна ветка, выполняется она.
  2. Если готовы несколько, выбирается равновероятно случайная. Не первая, не последняя, не по порядку в коде. Это гарантия языка, и держится она на перестановке порядка опроса. Сделано, чтобы select не голодал ни по одной ветке: иначе быстрый канал навсегда заглушил бы медленный.
  3. Если не готова ни одна и есть default, выполняется default, и select становится неблокирующим.
  4. Если не готова ни одна и default нет, горутина блокируется, пока какая-нибудь не станет готова. select{} без веток блокируется навсегда.

Закрытый канал всегда готов на чтение и мгновенно отдаёт нулевое значение с ok == false. Отсюда две вещи. Хорошая: на этом построены done-каналы и ctx.Done(): закрытие будит всех ожидающих сразу. Плохая: если забыть про ok, select в цикле по закрытому каналу начнёт крутиться на 100% CPU, бесконечно получая нули. Лечит обнуление канала:

for a != nil || b != nil {
    select {
    case v, ok := <-a:
        if !ok {
            a = nil                    // выключаем ветку навсегда
            continue
        }
        use(v)
    case v, ok := <-b:
        if !ok {
            b = nil
            continue
        }
        use(v)
    }
}

Nil-канал в select не готов никогда, ни на чтение, ни на запись, и просто «выключает» свою ветку. Это не костыль, а официальная идиома: приём с ch = nil живёт в стандартной библиотеке и в любом нетривиальном pipeline. Вне select операция с nil-каналом блокирует навсегда, и отсюда же берётся её роль «выключателя».

Ловушка с time.After в цикле
for {
    select {
    case v := <-ch:
        handle(v)
    case <-time.After(time.Second):    // НОВЫЙ таймер на каждой итерации
        return
    }
}

На каждой итерации создаётся новый Timer, и до Go 1.23 старые оставались в куче до срабатывания: на быстром канале это заметная нагрузка на память и GC. Начиная с Go 1.23 неиспользуемые таймеры собираются GC сразу, так что утечки больше нет, но лишние аллокации остались. Правильнее завести один time.NewTimer вне цикла с Reset, или context.WithTimeout. Само изменение из 1.23 давно не новость, с тех пор вышли 1.24–1.27. Но на собесе оно до сих пор играет в твою пользу: многие помнят старое правило «time.After в цикле течёт» и не знают, что утечку починили. Сильный ответ называет обе половины и добавляет, что таймер всё равно стоит переиспользовать.

Как звучит правильное объяснение на собесе

«Если в select готовы несколько веток, выбирается случайная. Это гарантия языка, а не деталь реализации, и сделана она против голодания. Закрытый канал всегда готов и отдаёт нулевое значение с ok = false, поэтому в цикле такой select закрутится, если не проверять ok и не обнулять канал. Nil-канал, наоборот, не готов никогда, и присваивание ch = nil служит стандартным способом выключить ветку select. default делает select неблокирующим.»

Код

type S struct {
    mu sync.Mutex
    n  int
    sl []int
    m  map[string]int
    p  *int
}

func main() {
    one := 1
    a := S{n: 1, sl: []int{1, 2, 3}, m: map[string]int{"x": 1}, p: &one}
    b := a                             // копирование структуры

    b.n = 100
    b.sl[0] = 100
    b.m["x"] = 100
    *b.p = 100
    b.sl = append(b.sl, 4)

    fmt.Println("a:", a.n, a.sl, a.m, *a.p)
    fmt.Println("b:", b.n, b.sl, b.m, *b.p)
}
Вывод:
a: 1 [100 2 3] map[x:100] 100
b: 100 [100 2 3 4] map[x:100] 100
Плюс go vet ругается: assignment copies lock value to b: main.S contains sync.Mutex

Почему

Структура копируется поверхностно (shallow copy): побайтово копируются все поля. Дальше всё решает тип поля.

ПолеЧто скопировалосьДанные общие?
n intсамо значениенет — независимы
sl []intзаголовок {ptr,len,cap}да — массив общий, но len у каждого свой
m map[string]intуказатель на хеш-таблицуда — полностью общая
p *intсам указательда — смотрят в одно место
mu sync.Mutexсостояние мьютексанет — и это баг

Тонкий момент со слайсом: b.sl[0] = 100 видно в a, потому что массив общий. А b.sl = append(b.sl, 4) уже не видно: append при cap 3 переаллоцировал, и b.sl уехал на новый массив. Даже если бы ёмкости хватило, a.sl всё равно остался бы длиной 3, заголовки-то разные. Одна строка кода показывает обе половины природы слайса.

Самая опасная строка в примере копирует мьютекс

sync.Mutex внутри устроен как обычная структура с полями state и sema. При копировании b получает свой собственный мьютекс: две горутины одновременно «захватывают блокировку», каждая свою, и спокойно пишут в общую мапу. Взаимоисключения нет, а выглядит код совершенно корректно. Хуже: если копировать структуру в момент, когда мьютекс захвачен, копия родится в состоянии «заблокировано», и первый же Lock на ней повиснет навсегда. Именно поэтому у go vet есть отдельная проверка copylocks, а все типы с мьютексом внутри держат методы на указателе. Та же проверка ловит sync.WaitGroup, sync.Once, sync.Cond и atomic.Int64. А вот strings.Builder она пропускает: у него внутри нет ни лока, ни noCopy, и копия падает уже в рантайме с illegal use of non-zero Builder copied by value.

Как сделать настоящую независимую копию

func (s *S) Clone() *S {
    s.mu.Lock()
    defer s.mu.Unlock()

    c := &S{n: s.n}                    // новый мьютекс в нулевом состоянии
    c.sl = slices.Clone(s.sl)          // копия слайса
    c.m = maps.Clone(s.m)              // копия мапы (тоже поверхностная!)
    if s.p != nil {
        v := *s.p
        c.p = &v                       // копия значения по указателю
    }
    return c
}

Оговорка: slices.Clone и maps.Clone тоже поверхностные. Если внутри слайса лежат структуры со слайсами, их придётся копировать рекурсивно. Настоящего глубокого копирования в стандартной библиотеке нет, и на добивку правильно ответить: «сделал бы явный Clone, потому что автоматического deep copy в Go нет».

Как звучит правильное объяснение на собесе

«Копирование структуры в Go поверхностное: копируются поля как есть. int скопировался по-настоящему. Слайс скопировал заголовок, массив остался общий, но длина у каждого своя, поэтому запись по индексу видна, а append уже нет. Мапа и указатель сами по себе указатели, данные полностью общие. А мьютекс скопировался вместе со своим состоянием, и это баг: у копии свой мьютекс, взаимоисключение сломано. go vet ловит это правилом copylocks, поэтому типы с мьютексом всегда используются через указатель.»

Код

package main

import "fmt"

var a = b + 1                     // объявлены в «неправильном» порядке
var b = c + 1
var c = f()

func f() int { fmt.Println("1. f() — вычисляется c"); return 1 }

func init() { fmt.Println("2. init A:", a, b, c) }
func init() { fmt.Println("3. init B") }

const (
    A = iota                      // 0
    B                             // 1
    _                             // 2 — пропущен
    D                             // 3
)

const (
    _  = iota
    KB = 1 << (10 * iota)          // 1 << 10
    MB                            // повтор выражения с iota = 2
    GB
)

type Weekday int

const (
    Mon Weekday = iota + 1
    Tue
    Wed
)

func main() {
    fmt.Println("4. main:", a, b, c)
    fmt.Println("iota:", A, B, D)
    fmt.Println("размеры:", KB, MB, GB)
    fmt.Println("дни:", Mon, Tue, Wed)
}
Вывод:
1. f() — вычисляется c
2. init A: 3 2 1
3. init B
4. main: 3 2 1
iota: 0 1 3
размеры: 1024 1048576 1073741824
дни: 1 2 3

Почему: переменные пакета инициализируются по зависимостям, а не по порядку

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

Здесь a зависит от b, b зависит от c, а c не зависит ни от чего. Поэтому реальный порядок: c = f() = 1, затем b = 2, затем a = 3. Текстовый порядок объявления полностью игнорируется. Циклическая зависимость (var x = y; var y = x) даёт ошибку компиляции, а не бесконечный цикл.

Полный порядок запуска программы: константы пакета → переменные пакета в порядке зависимостей → все функции init() в порядке объявления (их может быть сколько угодно, в том числе несколько в одном файле) → main(). Пакеты при этом обрабатываются снизу вверх по дереву импортов: импортируемый пакет полностью инициализируется раньше импортирующего, и каждый проходит это ровно один раз, даже если импортирован многократно.

Почему iota ведёт себя именно так

  • iota хранит порядковый номер ConstSpec в блоке const, считая с нуля. Не «счётчик констант», а именно номер строки спецификации. Поэтому пустая строка _ тоже увеличивает iota: A=0, B=1, _=2, D=3.
  • Пропущенное выражение повторяет предыдущее. MB и GB не «наследуют значение», а повторяют формулу 1 << (10 * iota) со своим iota: 2 и 3. Отсюда 1<<20 и 1<<30. Тут спотыкаются чаще всего.
  • iota обнуляется в каждом новом блоке const, а внутри блока считает строки, а не константы: строка X, Y = iota, iota*2 даёт одну спецификацию и один iota.
  • Тип наследуется вместе с выражением. Tue и Wed получают тип Weekday, потому что повторяется вся спецификация целиком, включая тип.
Глубже, чем спросят: нетипизированные константы

Константы в Go вычисляются на этапе компиляции и по умолчанию нетипизированы, с произвольной точностью. const Big = 1 << 62 компилируется, а var x int32 = Big уже нет: константа не помещается в тип. Именно поэтому const Pi = 3.14159... в math хранит больше знаков, чем влезает в float64: обрезка происходит только в момент присваивания конкретному типу. Побочный эффект: у константы нет адреса, на неё нельзя взять указатель, и она не может быть результатом вызова функции (кроме len/cap от константного выражения и подобных).

Как звучит правильное объяснение на собесе

«Переменные пакета инициализируются не сверху вниз, а по графу зависимостей: сначала то, у чего нет неготовых зависимостей. Здесь это c, потом b, потом a, получится 3, 2, 1. Дальше выполняются все init() в порядке объявления, потом main. Пакеты инициализируются снизу вверх по импортам и по одному разу. iota означает номер строки внутри блока const, поэтому пропуск через _ его увеличивает, а пустая строка повторяет предыдущее выражение целиком, включая тип, но уже с новым значением iota

Код

func main() {
    var x any = 1
    var y any = 1.0
    fmt.Println("1)", x == y)

    var s1 any = "go"
    var s2 any = "go"
    fmt.Println("2)", s1 == s2)

    type Pt struct {
        X, Y int
    }
    var p1 any = Pt{1, 2}
    var p2 any = Pt{1, 2}
    fmt.Println("3)", p1 == p2)

    defer func() { fmt.Println("recover:", recover()) }()
    var b1 any = []int{1, 2}
    var b2 any = []int{1, 2}
    fmt.Println("4)", b1 == b2)       // ← компилируется, но падает
}
Вывод:
1) falseint и float64 — разные динамические типы
2) true
3) true ← структуры из сравнимых полей сравнимы
recover: runtime error: comparing uncomparable type []int

Почему

Два значения интерфейса равны, если совпадают их динамические типы И равны их динамические значения. Отсюда первый результат: 1 и 1.0 — числа одинаковые, но типы int и float64 разные, значит false. Никакого неявного приведения при сравнении интерфейсов не происходит.

Второй шаг — сравнимость самого динамического типа. Go делит типы на две категории:

Сравнимые (== работает)Несравнимые (== запрещён)
числа, строки, bool, указатели, каналы, интерфейсы,
массивы из сравнимых элементов,
структуры, все поля которых сравнимы
слайсы, мапы, функции
(и любая структура/массив, содержащие их)

Если несравнимый тип написан явно — []int{1} == []int{2}, — это ошибка компиляции: компилятор видит типы и запрещает. Но когда значение спрятано в интерфейсе, статически тип неизвестен: any == any компилируется всегда. Проверка переезжает в рантайм, и там она превращается в панику comparing uncomparable type []int. Именно этот переход «из compile-time в runtime» и есть содержание вопроса.

Та же паника приходит из мапы — и это неприятнее
m := map[any]int{}
m["строка"] = 1                    // ок
m[42] = 2                          // ок
m[[2]int{1, 2}] = 3                // ок: массив сравним
m[[]int{1, 2}] = 4                 // panic: runtime error: hash of unhashable type []int

Ключ мапы обязан быть сравнимым. С map[any]V компилятор снова не может проверить это статически, и падение приходит в рантайме — часто на редких данных, спустя месяцы. Тот же эффект даёт map[Key]V, где Key — структура, в которую однажды добавили поле-слайс: код компилируется до тех пор, пока... нет, тут как раз сломается компиляция. Опасен именно any в ключе. Правило: не используй any как ключ мапы, если не контролируешь, что туда кладут.

Чем сравнивать несравнимое

slices.Equal(a, b)                 // слайсы, поэлементно, быстро — Go 1.21+
maps.Equal(m1, m2)                 // мапы
reflect.DeepEqual(x, y)            // что угодно, но медленно и с сюрпризами

reflect.DeepEqual стоит упомянуть вместе с его подвохом: он считает []int(nil) и []int{} разными, хотя обе длины нулевые. В тестах это регулярно приводит к ложным падениям — поэтому в новом коде предпочитают slices.Equal (который их как раз уравнивает) или google/go-cmp.

Как звучит правильное объяснение на собесе

«Интерфейсы равны, когда совпадают и динамический тип, и значение. Поэтому any(1) и any(1.0) не равны — типы разные. А если динамический тип несравним — слайс, мапа или функция, — то сравнение компилируется (статически тип неизвестен), но падает в рантайме с comparing uncomparable type. Та же паника прилетает при использовании такого значения как ключа мапы. Для слайсов и мап есть slices.Equal и maps.Equal; reflect.DeepEqual тоже работает, но медленный и по-разному трактует nil и пустой слайс.»

Код

func main() {
    s := "Привет, Go!"
    fmt.Println("len:", len(s))
    fmt.Println("рун:", utf8.RuneCountInString(s))
    fmt.Printf("s[0] = %v, %c, %q\n", s[0], s[0], s[0])
    fmt.Printf("s[:3] = %q\n", s[:3])

    fmt.Print("range: ")
    for i, r := range s[:12] {
        fmt.Printf("(%d:%c) ", i, r)
    }
    fmt.Println()

    fmt.Print("byte-loop: ")
    for i := 0; i < 6; i++ {
        fmt.Printf("%d ", s[i])
    }
    fmt.Println()

    r := []rune(s)
    fmt.Println("[]rune len:", len(r), "первый:", string(r[0]))
}
Вывод:
len: 17 ← байт, а не символов
рун: 11
s[0] = 208, Ð, 'Ð' ← половина буквы «П»
s[:3] = "П\xd1" ← руна разрезана пополам
range: (0:П) (2:р) (4:и) (6:в) (8:е) (10:т) ← индексы через один
byte-loop: 208 159 209 128 208 184
[]rune len: 11 первый: П

Почему

Строка в Go — это неизменяемая последовательность байт, а не символов. Литералы в исходнике хранятся в UTF-8, где кириллическая буква занимает 2 байта, латиница и цифры — 1, а эмодзи — 4. «Привет, Go!» — это 6 кириллических букв (12 байт) + запятая, пробел, G, o, ! (5 байт) = 17 байт при 11 рунах.

  • s[i] возвращает byte (uint8), а не символ. s[0] = 208 — это первый из двух байт буквы «П» (0xD0 0x9F). %c честно печатает символ с кодом 208 — Ð.
  • Срез строки режет байты. s[:3] захватывает «П» целиком и первый байт от «р» — получается невалидный UTF-8, который %q показывает как "П\xd1".
  • range по строке декодирует UTF-8 на лету и выдаёт пары (байтовое смещение, руна). Именно поэтому индексы идут 0, 2, 4, 6… — это не порядковые номера символов, а смещения в байтах. Ловушка: если использовать этот i как «номер символа», всё сломается на первой же кириллице.
  • []rune(s) декодирует всю строку в слайс int32 — по 4 байта на руну. Даёт случайный доступ и корректный len, но стоит O(n) времени и O(n) памяти.

Как делать правильно

ЗадачаПравильноНеправильно
число символовutf8.RuneCountInString(s)len(s)
перебрать символыfor _, r := range sfor i := 0; i < len(s); i++
i-й символ[]rune(s)[i] (один раз конвертировать)s[i]
первые n символовсчитать руны в range и резать по смещениюs[:n]
развернуть строкучерез []runeчерез байты — сломает UTF-8
сравнить без регистраstrings.EqualFold(a, b)strings.ToUpper(a) == strings.ToUpper(b)

Когда индексный цикл по байтам уместен: если ты осознанно работаешь с байтами — парсишь ASCII-протокол, ищешь разделитель, считаешь размер для буфера. Тогда это не ошибка, а оптимизация, и об этом стоит сказать вслух: «здесь я иду по байтам намеренно, потому что разделитель — ASCII, а ASCII-байт не может встретиться внутри многобайтовой руны в UTF-8». Последнее — самосинхронизирующееся свойство UTF-8, и знание про него ценится отдельно.

Глубже, чем спросят: руна — тоже не «символ»

Руна — это code point Unicode, и она не всегда совпадает с тем, что пользователь видит как один символ. «é» может быть одной руной U+00E9 или двумя (e + U+0301). Флаг «🇷🇺» — две руны. Эмодзи с модификатором тона кожи — тоже несколько. То, что видит человек, называется графемным кластером, и в стандартной библиотеке его нет — нужна внешняя библиотека (rivo/uniseg). Поэтому строго корректный ответ на «обрежь строку до 20 символов» — «до 20 рун; если нужны именно видимые символы, это графемные кластеры, и нужна сторонняя библиотека».

Как звучит правильное объяснение на собесе

«Строка в Go — это байты в UTF-8, поэтому len даёт длину в байтах, а s[i] — байт, а не символ: для кириллицы это половина буквы. range по строке декодирует UTF-8 и выдаёт руну и её байтовое смещение — поэтому индексы идут через один, и использовать их как номер символа нельзя. Если нужен случайный доступ к символам — конвертирую в []rune один раз, помня, что это O(n) памяти. Резать строку можно только по границам рун, иначе получится невалидный UTF-8.»