Алгоритмы и лайвкодинг
В РФ на 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 / max | O(n) | O(n) | O(n) | O(log n) | O(1) корень | — |
| Обход в порядке сортировки | O(n log n) — надо отсортировать | невозможно, порядок случайный | O(n log n) | O(n) in-order | O(n log n) | O(n) лексикографически |
| Оверхед памяти | минимальный, данные подряд | бакеты + запас, ~1.3–2× | +1–2 указателя на узел | +2–3 указателя на узел | как у слайса | много узлов и указателей |
Мапа быстрая, но неупорядоченная и без диапазонных запросов. Дерево медленнее на точечном
поиске (O(log n) против O(1)), но умеет ORDER BY и BETWEEN. Это ровно тот
же выбор, который делает СУБД между hash-индексом и B-tree-индексом. Связку очень любят
на собесе: «почему в Postgres по умолчанию B-tree, а не хеш?»
Стек, очередь, дек на слайсе
В Go отдельных типов для них нет, их пишут на слайсе за пять строк, и на лайвкодинге это ждут. Стек тривиален: конец слайса и есть вершина.
type Stack[T any] []T
func (s *Stack[T]) Push(v T) { *s = append(*s, v) }
func (s *Stack[T]) Pop() (T, bool) {
old := *s
var zero T
if len(old) == 0 {
return zero, false
}
v := old[len(old)-1]
old[len(old)-1] = zero // обнуляем: иначе слайс держит ссылку и мешает GC
*s = old[:len(old)-1]
return v, true
}
// var s Stack[string]
// s.Push("a"); s.Push("b"); s.Push("c")
// s.Pop() → "c", true (осталось 2)
// s.Pop() → "b", true (осталось 1)
// s.Pop() → "a", true (осталось 0)
// s.Pop() → "", false — пустой стек отдаёт нулевое значение и false
С очередью хуже. Наивный вариант «голова в начале слайса» выглядит красиво и течёт памятью:
// Плохо: рабочая, но underlying array растёт неограниченно
q = append(q, v) // enqueue
head := q[0] // dequeue
q = q[1:] // указатель поехал вперёд, а массив под ним никто не освободит
q = q[1:] сдвигает только ptr и len. Занятая память
впереди слайса недостижима, но GC её не соберёт: жив весь массив целиком. Для очереди на миллион
элементов это миллион «дырок». Правильный ответ даёт кольцевой буфер (ring buffer): индексы
ходят по кругу через % cap, память переиспользуется.
q = q[1:].// Дек на кольцевом буфере: O(1) на каждую операцию, PopBack пишется зеркально PopFront
type Deque[T any] struct {
buf []T
head int // индекс первого элемента
size int // сколько занято
}
func (d *Deque[T]) Len() int { return d.size }
func (d *Deque[T]) PushBack(v T) {
d.grow()
d.buf[(d.head+d.size)%len(d.buf)] = v
d.size++
}
func (d *Deque[T]) PushFront(v T) {
d.grow()
d.head = (d.head - 1 + len(d.buf)) % len(d.buf) // +len, иначе отрицательный индекс
d.buf[d.head] = v
d.size++
}
func (d *Deque[T]) PopFront() (T, bool) {
var zero T
if d.size == 0 {
return zero, false
}
v := d.buf[d.head]
d.buf[d.head] = zero // не держим ссылку
d.head = (d.head + 1) % len(d.buf)
d.size--
return v, true
}
func (d *Deque[T]) grow() {
if d.size < len(d.buf) {
return
}
n := len(d.buf) * 2
if n == 0 {
n = 8
}
nb := make([]T, n)
for i := 0; i < d.size; i++ { // перекладываем «выпрямляя» кольцо
nb[i] = d.buf[(d.head+i)%len(d.buf)]
}
d.buf, d.head = nb, 0
}
// var d Deque[int]
// d.PushBack(2); d.PushBack(3); d.PushFront(1)
// d.Len() → 3
// PopFront подряд: 1 2 3, затем (0, false)
В стандартной библиотеке есть container/list, двусвязный список на
any. Он проигрывает слайсу на всём, кроме «удалить узел, который уже на руках»:
каждый элемент требует отдельной аллокации, значения лежат в интерфейсе (упаковка, 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.
В trie (префиксное дерево, произносится «трай») путь от корня задаёт
префикс ключа. Поиск слова стоит O(L) при длине слова L и не зависит от числа
слов в словаре. Это его главное отличие от мапы и дерева поиска: сложность привязана к длине
ключа, а не к размеру коллекции. Отсюда классическое применение: поисковые подсказки и
автодополнение. Спускаемся по префиксу за O(L), а дальше обходим поддерево и собираем варианты.
type Trie struct {
kids map[rune]*Trie // для ASCII-словаря быстрее [26]*Trie
end bool // здесь заканчивается слово
}
func NewTrie() *Trie { return &Trie{kids: map[rune]*Trie{}} }
func (t *Trie) Insert(word string) {
cur := t
for _, r := range word { // range по строке даёт руны, не байты
nxt, ok := cur.kids[r]
if !ok {
nxt = NewTrie()
cur.kids[r] = nxt
}
cur = nxt
}
cur.end = true
}
// Suggest возвращает все слова с заданным префиксом.
func (t *Trie) Suggest(prefix string) []string {
cur := t
for _, r := range prefix { // O(L)
nxt, ok := cur.kids[r]
if !ok {
return nil
}
cur = nxt
}
var out []string
var dfs func(node *Trie, acc []rune)
dfs = func(node *Trie, acc []rune) {
if node.end {
out = append(out, prefix+string(acc))
}
for r, k := range node.kids {
dfs(k, append(acc, r)) // Осторожно: см. заметку про append ниже
}
}
dfs(cur, nil)
return out
}
// t := NewTrie(); t.Insert("go"); t.Insert("golang"); t.Insert("gopher"); t.Insert("rust")
// t.Suggest("go") → [go golang gopher] — но порядок плавает: на 30 прогонах вышло
// [go golang gopher] 27 раз и [go gopher golang] 3 раза.
// dfs идёт по kids через range по мапе, а он рандомизирован.
// Опираться нельзя даже на «почти всегда».
// t.Suggest("ru") → [rust]
// t.Suggest("zz") → [] (nil)
В строке dfs(k, append(acc, r)) заложена классическая мина. Если у acc есть запас
cap, соседние ветки DFS будут писать в один и тот же underlying array и
затирать друг друга. Безопасных путей два. Копировать: next := append(append([]rune{}, acc...), r).
Либо держать паттерн «push / рекурсия / pop»: acc = append(acc, r); dfs(k, acc); acc = acc[:len(acc)-1],
тогда в каждый момент времени активна ровно одна ветка. Если интервьюер это заметит, а ты
объяснишь, получится плюс, а не минус.
Куча и container/heap
Дальше речь пойдёт про структуру данных, а не про область памяти (см. разбор двух смыслов слова «куча» в начале главы). Куча-структура сводится к слайсу с одним хитрым инвариантом.
Куча (heap) хранит почти полное бинарное дерево в обычном массиве, без указателей.
Инвариант min-heap: a[parent] ≤ a[child]. Адресация чисто арифметическая:
parent(i) = (i - 1) / 2
left(i) = 2*i + 1
right(i) = 2*i + 2
Вставка: кладём в конец и «всплываем» (sift up), это O(log n). Извлечение минимума: отдаём
a[0], ставим на его место последний элемент и «топим» (sift down), снова O(log n).
Куча из готового массива строится за O(n), а не за O(n log n), и этот неочевидный факт
хорошо звучит на собесе.
В стандартной библиотеке container/heap даёт алгоритмы, а хранилище пишешь ты.
Нужен sort.Interface (Len, Less, Swap)
плюс Push(any) и Pop() any. Главная ловушка: heap.Push и
h.Push делают разные вещи. Твой Push просто добавляет в конец,
а инвариант восстанавливает пакет.
type IntHeap []int
func (h IntHeap) Len() int { return len(h) }
func (h IntHeap) Less(i, j int) bool { return h[i] < h[j] } // "<" = min-heap, ">" = max-heap
func (h IntHeap) Swap(i, j int) { h[i], h[j] = h[j], h[i] }
// Push/Pop на указателе: меняют длину слайса
func (h *IntHeap) Push(x any) { *h = append(*h, x.(int)) }
func (h *IntHeap) Pop() any {
old := *h
n := len(old)
v := old[n-1] // забираем с конца: heap.Pop уже переставил минимум туда
*h = old[:n-1]
return v
}
func main() {
h := &IntHeap{5, 2, 9}
heap.Init(h) // O(n)
heap.Push(h, 1) // O(log n)
fmt.Println((*h)[0]) // 1 — минимум всегда в нулевом элементе, O(1)
fmt.Println(heap.Pop(h)) // 1
}
Граф: представление, BFS и DFS
| Матрица смежности | Список смежности | |
|---|---|---|
| Хранение | [][]bool или [][]int V×V | map[int][]int или [][]int |
| Память | O(V²) всегда | O(V + E) |
| Есть ли ребро u→v | O(1) | O(deg(u)) |
| Перебрать соседей u | O(V) — идём по всей строке | O(deg(u)) |
| Когда брать | плотный граф, V небольшое, много запросов «есть ребро?» | почти всегда: реальные графы разрежены (E ≪ V²) |
type Graph map[int][]int
// BFS: обход по уровням, даёт кратчайший путь в невзвешенном графе. O(V+E)
func BFS(g Graph, start int) []int {
visited := map[int]bool{start: true}
queue := []int{start}
var order []int
for len(queue) > 0 {
u := queue[0]
queue = queue[1:]
order = append(order, u)
for _, v := range g[u] {
if !visited[v] {
visited[v] = true // помечаем при добавлении в очередь, не при извлечении:
queue = append(queue, v) // иначе один узел попадёт в очередь несколько раз
}
}
}
return order
}
// DFS рекурсивно: компоненты связности, топосорт, поиск циклов. O(V+E)
func DFS(g Graph, u int, visited map[int]bool, order *[]int) {
visited[u] = true
*order = append(*order, u)
for _, v := range g[u] {
if !visited[v] {
DFS(g, v, visited, order)
}
}
}
// g := Graph{1: {2, 3}, 2: {4}, 3: {4, 5}, 4: {5}, 5: nil}
// BFS(g, 1) → [1 2 3 4 5] — по уровням
// DFS(g, 1, map[int]bool{}, &order) → [1 2 4 5 3] — вглубь
// Порядок устойчив только потому, что соседи лежат в слайсе.
// Если бы список смежности хранился в мапе, обход поехал бы от запуска к запуску.
BFS берут, когда важна дистанция: кратчайший путь по числу рёбер, «уровни» дерева, «минимум шагов». Памяти он ест O(ширины), у широкого графа это много. DFS берут, когда важна структура: связность, топологическая сортировка, обнаружение циклов, backtracking. Памяти тут O(глубины), зато рекурсия может переполнить стек. В Go стек горутины растёт динамически до 1 ГБ, так что проблема стоит не так остро, как в C или Java.
Сортировки и что делает sort в Go
| Алгоритм | Среднее | Худшее | Память | Стабильна | Чем полезна |
|---|---|---|---|---|---|
| Пузырьком (bubble) | O(n²) | O(n²) | O(1) | да | ничем, кроме собеса |
| Вставками (insertion) | O(n²) | O(n²) | O(1) | да | O(n) на почти отсортированных — поэтому она внутри всех гибридов |
| Выбором (selection) | O(n²) | O(n²) | O(1) | нет | минимум обменов |
| Слиянием (merge) | O(n log n) | O(n log n) | O(n) | да | гарантия худшего случая, внешняя сортировка, легко распараллеливается |
| Быстрая (quick) | O(n log n) | O(n²) | O(log n) стек | нет | лучшая константа, in-place |
| Пирамидальная (heap) | O(n log n) | O(n log n) | O(1) | нет | страховка от худшего случая quicksort |
| Подсчётом / поразрядная | O(n + k) | O(n + k) | O(n + k) | да | целые числа в узком диапазоне — обходит барьер O(n log n) |
Барьер O(n log n) относится к сортировкам сравнением: любая такая сортировка делает не менее
log₂(n!) ≈ n log n сравнений, потому что дерево решений должно различить n! перестановок.
Counting/radix быстрее именно потому, что не сравнивают, а используют структуру ключа.
Что использует Go. До 1.19 стоял introsort: quicksort с медианой из трёх, переход на heapsort при
слишком большой глубине рекурсии и на insertion sort для коротких кусков. С Go 1.19 работает
pdqsort (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 и есть искомая граница.// Вариант 1. Классический: найти элемент, вернуть индекс или -1
func Search(a []int, target int) int {
lo, hi := 0, len(a)-1 // замкнутый отрезок [lo, hi]
for lo <= hi { // именно <=, иначе пропустим отрезок из одного элемента
mid := lo + (hi-lo)/2 // не (lo+hi)/2, привычка против переполнения
switch {
case a[mid] == target:
return mid
case a[mid] < target:
lo = mid + 1
default:
hi = mid - 1
}
}
return -1
}
// Вариант 2. Границы (lower bound): надёжнее и универсальнее
// Возвращает первую позицию, где a[i] >= target. Если такой нет, вернёт len(a).
func LowerBound(a []int, target int) int {
lo, hi := 0, len(a) // полуинтервал [lo, hi)
for lo < hi {
mid := lo + (hi-lo)/2
if a[mid] < target {
lo = mid + 1
} else {
hi = mid
}
}
return lo // lo == hi
}
// a := []int{1, 3, 3, 5, 8}
// Search(a, 5) → 3 Search(a, 4) → -1
// Search(a, 1) → 0 Search(a, 8) → 4
// LowerBound(a, 3) → 1 ← первая из двух троек
// LowerBound(a, 4) → 3 ← элемента нет, это позиция вставки
// LowerBound(a, 0) → 0 LowerBound(a, 9) → 5 (== len(a))
- Переполнение
(lo+hi)/2. В Goint64-битный, и на практике не переполнится, но привычка писатьlo + (hi-lo)/2ничего не стоит, и её ждут. - Путаница
<и<=. Для[lo, hi]нужноlo <= hi, для[lo, hi)—lo < hi. Смешал и либо пропустил элемент, либо вышел за границу. - Зацикливание. Если в полуинтервальной версии написать
lo = midвместоlo = mid + 1, то приhi - lo == 1получитсяmid == loи отрезок перестанет сокращаться: цикл повиснет навсегда. - Забыли отсортированность. Бинарный поиск требует монотонного предиката. На собесе обязательно спроси: «слайс отсортирован?»
// В проде руками писать не надо
i := sort.SearchInts(a, x) // lower bound для []int
i = sort.Search(len(a), func(i int) bool { return a[i] >= x }) // общий случай
// Go 1.21+, дженерики и явный признак «нашли»
i, found := slices.BinarySearch(a, x)
i, found = slices.BinarySearchFunc(users, target, func(u, t User) int {
return cmp.Compare(u.ID, t.ID)
})
sort.Search(n, f) и есть lower bound: он ищет минимальное i из
[0, n), для которого f(i) == true, и требует монотонности:
f должна быть false…false,true…true. Если предикат немонотонен, результат окажется
мусором, причём тихо, без паники. Массивами дело не ограничивается: так ищут границу в любом
монотонном пространстве, например минимальную скорость или размер батча, при которых условие выполняется.
На массиве из int32 линейный поиск обгоняет бинарный на коротких массивах,
примерно до десятка элементов:
бинарный делает непредсказуемые переходы (branch misprediction) и прыгает по памяти, а линейный
идёт по кэш-линиям и векторизуется. Именно поэтому в pdqsort есть порог 12 элементов
для insertion sort, а в старой мапе Go бакет вмещал 8 ключей и просматривался линейно. Если на
собесе после O-оценки добавить фразу «но на маленьких n константа решает, и в стандартной библиотеке
это учтено порогами», ответ заметно выделяется.
Вопросы
12n → ∞ с
точностью до константы. Вслух это оценивают по схеме: что такое n → сколько проходов →
цена операций внутри → отдельно память → худший случай.
Формально g(n) = O(f(n)), если существуют c > 0 и n₀
такие, что g(n) ≤ c·f(n) при всех n ≥ n₀. То есть O
читается как «не хуже, чем», Ω как «не лучше, чем», Θ как «ровно
такого порядка». На собесе почти всегда говорят «O», подразумевая Θ; проговорить
разницу одной фразой уже плюс.
Пространственная сложность
Считается дополнительная (auxiliary) память, то есть всё, что алгоритм выделяет сверх
входных данных. Про что забывают: глубина рекурсии (каждый кадр стека занимает память), мапы
для мемоизации, промежуточные слайсы от append, копия строки при
[]rune(s). Фраза «решение in-place, O(1) дополнительной памяти» сразу показывает,
что ты понимаешь разницу.
Как это звучит на собесе
func twoSum(nums []int, target int) (int, int) {
seen := make(map[int]int, len(nums))
for i, v := range nums {
if j, ok := seen[target-v]; ok {
return j, i
}
seen[v] = i
}
return -1, -1
}
// twoSum([]int{2, 7, 11, 15}, 9) → 0, 1
// twoSum([]int{1, 2}, 100) → -1, -1
«n здесь длина nums. Один проход, внутри одна проверка мапы и одна запись, обе
амортизированно O(1). Итого O(n) по времени. По памяти мапа, в худшем случае на все n
элементов, то есть O(n). Худший случай по времени формально O(n²), если все ключи дадут
коллизии, но в Go хеш рандомизирован сидом, так что практически это O(n).»
Шкала, которую надо чувствовать
| Сложность | n = 10⁶ — это | Типичный источник |
|---|---|---|
| O(1) | мгновенно | доступ по индексу, мапа, вершина стека |
| O(log n) | ~20 шагов | бинарный поиск, куча, сбалансированное дерево |
| O(n) | ~10⁶ — миллисекунды | один проход, два указателя, sliding window |
| O(n log n) | ~2·10⁷ — десятки мс | сортировка, «n раз по log n» через кучу |
| O(n²) | 10¹² — часы | вложенные циклы, конкатенация строк в цикле |
| O(2ⁿ) | часы при n = 40, невозможно к n = 50–60 | наивная рекурсия без мемоизации, перебор подмножеств |
Если интервьюер называет ограничение, оно подсказывает целевую сложность. Увидел
n ≤ 10⁵ — ждут O(n) или O(n log n). При n ≤ 10³ пройдёт и O(n²).
А n ≤ 20 обычно значит, что решение и есть экспоненциальный перебор. Проговорить
это вслух («раз n до 10⁵, квадрат не пройдёт, значит нужен хеш или два указателя») уже
половина успеха, даже если код потом получится не с первого раза.
append в конец — амортизированно O(1), но конкретный вызов может стоить O(n) из-за
роста массива.
Слайс устроен как заголовок из трёх слов (ptr, len, cap)
над непрерывным массивом. Отсюда всё поведение:
| Операция | Сложность | Почему |
|---|---|---|
s[i] | O(1) | адрес = ptr + i·sizeof(T), плюс bounds check |
| поиск значения | O(n) | линейный перебор; O(log n) только если отсортирован |
append(s, v) | O(1) амортизированно | обычно запись в свободный слот; при len == cap — новый массив и копирование |
| вставка в середину | O(n) | надо сдвинуть хвост |
| удаление по индексу | O(n) | slices.Delete = сдвиг хвоста влево |
| удаление без сохранения порядка | O(1) | s[i] = s[len(s)-1]; s = s[:len(s)-1] |
copy(dst, src) | O(n) | memmove, но с отличной константой |
Что такое амортизация
Амортизированная сложность означает среднюю стоимость операции в длинной серии, а не
среднее по случайным входам. Когда cap исчерпан, growslice выделяет новый
массив (примерно вдвое больше для небольших слайсов, с плавным коэффициентом, стремящимся к
1.25, после 256 элементов) и копирует туда всё, а это O(n). Но такое случается тем реже, чем
больше слайс: суммарное копирование при n вставках складывается в геометрическую прогрессию
n + n/2 + n/4 + … < 2n, то есть O(n) на всю серию, O(1) на операцию.
«Значит, append всегда O(1)?» — Нет. Амортизированно O(1), но конкретный вызов,
попавший на рост, стоит O(n). Для latency-чувствительного кода это разница между p50 и p99,
поэтому вместимость выделяют заранее: make([]T, 0, n). Второй вопрос-добивка:
«а если делать append в цикле по n элементам без preallocate, какая сложность?»
Всё равно O(n) суммарно, но с несколькими лишними аллокациями и копированиями, и с нагрузкой
на GC.
Типичный антипаттерн выглядит так: цикл по слайсу и
s = append(s[:i], s[i+1:]...) для каждого «плохого» элемента. Это O(n²) плюс
сломанная индексация (после удаления следующий элемент оказывается на том же i).
Правильнее фильтровать «на месте» одним проходом за O(n):
out := s[:0]; for _, v := range s { if keep(v) { out = append(out, v) } }; s = out.
В Go 1.21+ то же самое делает slices.DeleteFunc. Если элементы указатели,
обнули ещё и хвост, иначе он держит объекты и мешает GC.
Откуда берётся O(1)
Хеш-функция раскидывает ключи по бакетам примерно равномерно, а load factor (в старой мапе 6.5 элементов на бакет из 8 слотов) ограничивает длину линейного поиска внутри бакета константой. При превышении фактора таблица удваивается, и данные переезжают инкрементально, по паре бакетов на каждую операцию, чтобы не было длинной паузы. В Go 1.24 мапу переписали на Swiss Tables: группа из 8 слотов с байтами-метаданными, которые сканируются одним махом: на amd64 это SIMD-инструкция, на остальных — трюк с 64-битным словом. Асимптотика та же, константа и память лучше.
Откуда берётся O(n)
- Патологические коллизии. Если все ключи дают один и тот же бакет, поиск вырождается в перебор. В Go это нельзя вызвать снаружи: при старте процесса генерируется случайный seed, поэтому одна и та же последовательность ключей на разных запусках раскладывается по-разному, и это защита от HashDoS.
- Плохой хеш у своего типа ключа. Только для языков, где хеш пишет пользователь. В Go хеш для comparable-типов генерирует компилятор, так что испортить его ты не можешь, зато можешь выбрать тяжёлый ключ (длинная строка, большая структура).
Скрытые расходы, о которых стоит сказать
m := map[string]int{}
m[longKey] = 1 // хеш считается по всем байтам ключа: O(len(key))
// при коллизии ещё и побайтовое сравнение строк
// Мапу нельзя обойти в порядке ключей: порядок итерации намеренно рандомизирован
for k := range m { ... } // порядок каждый раз другой
// Отсортированный обход стоит O(n log n):
keys := slices.Sorted(maps.Keys(m)) // Go 1.23: итератор + сортировка
«Амортизированно O(1) в среднем при равномерном хеше и ограниченном load factor; худший случай O(n) при вырожденных коллизиях. Плюс стоимость самого хеширования, которая для строкового ключа линейна по длине ключа, а не константна. И мапа не даёт ни порядка, ни диапазонных запросов, за этим уже к дереву.»
delete помечает слот свободным, но выделенные бакеты остаются. Мапа, в которую
насыпали 10 млн ключей и потом всё удалили, продолжит держать память. Помогает только
пересоздать мапу (m = make(map[K]V)); встроенный clear(m) из Go 1.21
очищает содержимое, но тоже не возвращает бакеты аллокатору.
| Операция | Слайс | Двусвязный список |
|---|---|---|
| Доступ по индексу | O(1) | O(n) |
| Поиск по значению | O(n), но с идеальной локальностью | O(n), с промахом кэша на каждом шаге |
| Вставка в конец | O(1) аморт. | O(1) |
| Вставка в начало | O(n) | O(1) |
| Вставка/удаление по известному узлу | O(n) (сдвиг) | O(1) |
| Память на элемент | sizeof(T) | sizeof(T) + 2 указателя + заголовок аллокации |
| Аллокаций | одна на весь массив | одна на каждый узел |
Почему на практике список проигрывает
- Кэш. Процессор читает память линиями по 64 байта и умеет предсказывать последовательный доступ. Слайс из int даёт 8 значений на линию и работающий префетч. Список прыгает по случайному адресу на каждом шаге, префетч бесполезен. На обходе это 10–50× при одинаковой O(n).
- Аллокации и GC. Миллион узлов даёт миллион объектов, которые GC обязан
просканировать (они содержат указатели). Слайс из миллиона int остаётся одним объектом,
и для
[]intGC вообще не сканирует содержимое, потому что в типе нет указателей. - Оверхед.
container/listхранит значения вany, добавляя боксинг и косвенность.
Когда список всё-таки нужен
- LRU-кэш: мапа даёт узел за O(1), список переставляет его в голову за O(1). Слайсом так не сделать, мешает сдвиг O(n).
- Очередь задач с удалением из середины по хендлу (например, планировщик таймеров).
- Структуры, где нельзя инвалидировать указатели на элементы при росте: у слайса
appendможет переселить массив, и все&s[i]станут указывать на старую память.
«Асимптотика у списка местами лучше, но реальный код почти всегда быстрее на слайсе: та же O(n), но на порядок меньшая константа за счёт локальности и одной аллокации. Список беру, когда нужно O(1) удаление по уже известному узлу, то есть ровно в случае LRU.»
q = q[1:] работает, но не освобождает память впереди — нужен кольцевой буфер
с индексами по модулю cap.Стек
stack := []int{}
stack = append(stack, v) // push
top := stack[len(stack)-1] // peek
stack = stack[:len(stack)-1] // pop
// если T содержит указатели, обнули слот перед укорачиванием:
// stack[len(stack)-1] = nil
Очередь: почему наивный вариант плох
q = q[1:] двигает вперёд ptr и уменьшает len, но массив
под слайсом остаётся живым целиком, пока жив хоть один слайс на него. В цикле
enqueue/dequeue underlying array растёт монотонно: обработали миллион задач — держим память
под миллион, хотя в очереди три элемента. Формально это не «утечка» (память достижима), но
ведёт себя именно так.
Кольцевой буфер
Храним массив фиксированного размера плюс head и size. Хвост
вычисляется как (head + size) % cap, поэтому индексы «заворачиваются» через ноль
и память переиспользуется. Все четыре операции стоят O(1), рост амортизированно O(1).
type Queue[T any] struct {
buf []T
head, size int
}
func (q *Queue[T]) Push(v T) {
if q.size == len(q.buf) {
q.grow()
}
q.buf[(q.head+q.size)%len(q.buf)] = v
q.size++
}
func (q *Queue[T]) Pop() (T, bool) {
var zero T
if q.size == 0 {
return zero, false
}
v := q.buf[q.head]
q.buf[q.head] = zero // иначе буфер держит объект
q.head = (q.head + 1) % len(q.buf)
q.size--
return v, true
}
// var q Queue[string] (grow такой же, как у Deque выше)
// q.Push("a"); q.Push("b"); q.Push("c")
// Pop подряд: "a" "b" "c", затем ("", false)
Единственная тонкость в движении головы назад: head = (head - 1 + cap) % cap.
Без + cap получится отрицательный индекс, потому что в Go
-1 % 8 == -1 (остаток берёт знак делимого, как в C, а не как в Python).
Это ровно тот баг, который на лайвкодинге ищут глазами.
В BFS на графе из тысяч узлов q = q[1:] нормален, потому что жизнь очереди
коротка и память освободится вместе с ней. Кольцевой буфер нужен для долгоживущей очереди.
На собесе так и скажи: «в задаче обойдусь слайсом, в проде для долгоживущей очереди возьму
кольцевой буфер или буферизированный канал».
h = log n, у
вырожденного — n. В БД берут B-tree не из-за асимптотики, а потому что узел = страница диска:
ветвление в сотни раз, высота 4 вместо 30.BST
Инвариант: в левом поддереве ключи меньше корня, в правом больше. Поиск, вставка и удаление стоят O(h). Проблема в том, что h зависит от порядка вставки: если добавлять уже отсортированные данные (тот самый автоинкрементный ID), каждое новое значение уходит вправо, и дерево становится связным списком с O(n) на всё.
Лечат это самобалансирующиеся деревья: AVL (разница высот поддеревьев не больше 1;
строже балансирует, значит быстрее читает, но больше поворотов при записи) и
красно-чёрное (слабее балансирует, зато дешевле вставка; на нём построены
std::map в C++ и TreeMap в Java). В стандартной библиотеке Go
сбалансированного дерева нет, есть мапа и sort.
Почему B-tree в базах
- Читается всегда целая страница. Диск и буферный кэш работают блоками по 8–16 КБ. Читать один узел BST с двумя указателями — значит потратить целое чтение страницы ради пары байт.
- Коэффициент ветвления. В страницу 8 КБ влезают сотни ключей, поэтому у B-tree
ветвление ~100–500, а высота на миллиард строк всего 3–4. У BST на том же объёме
log₂ 10⁹ ≈ 30. Получается 30 обращений против 4. - B+tree и range scan. В B+tree данные лежат только в листьях, а листья связаны в
двусвязный список. Поэтому
WHERE x BETWEEN a AND bсводится к одному спуску и последовательному проходу по листьям, аORDER BYпо индексу вообще не требует сортировки. - Заполнение страниц и fillfactor. B-tree держит узлы заполненными минимум наполовину, что ограничивает фрагментацию и число расщеплений.
Отсюда же ответ на смежный вопрос из секции БД: «почему в Postgres по умолчанию B-tree, а не
hash-индекс?» Хеш даёт O(1) на точное равенство, но не умеет <,
BETWEEN, ORDER BY, префиксный LIKE 'abc%' и не
поддерживает составные ключи с частичным использованием. B-tree чуть медленнее на точечном
поиске, но покрывает все эти сценарии одной структурой.
B-tree платит за чтение случайными записями (обновление страницы на месте, WAL, расщепления). LSM-tree (RocksDB, Cassandra, ClickHouse частично) пишет только последовательно, накапливая данные в памяти и периодически сливая отсортированные уровни. Итог: LSM быстрее на записи и сжатии, B-tree выигрывает на чтении и предсказуемости latency. Добавишь это к ответу, и разговор перейдёт на уровень системного дизайна.
Устройство
Каждый узел хранит переходы по символам и флаг «здесь заканчивается слово». Слова с общим
префиксом делят один и тот же путь: «go», «gopher», «gorm» идут по общей ветке g → o
и расходятся дальше. Ключ не хранится целиком нигде, он «размазан» по рёбрам.
| Операция | Trie | Мапа | Отсортированный слайс |
|---|---|---|---|
| Точный поиск слова | O(L) | O(L) на хеш, но с лучшей константой | O(L·log n) |
| Все слова с префиксом | O(L + k), k — размер ответа | невозможно, только полный перебор O(n·L) | O(L·log n + k) |
| Лексикографический обход | O(n) естественным образом | нет | O(n) |
| Память | худшая: узлы и указатели | средняя | лучшая |
Где применяют
- Поисковые подсказки / автодополнение. Спуск по введённому префиксу за O(L), затем обход поддерева. Часто в узлах ещё хранят топ-k популярных продолжений, чтобы не обходить поддерево целиком.
- Роутинг HTTP.
httprouterиchiдержат сжатый trie (radix tree) и разбирают пути вида/users/:id/ordersза один проход по URL, без регулярок. - IP-маршрутизация (longest prefix match), словари T9, проверка орфографии, поиск запрещённых слов (Aho–Corasick это trie плюс суффиксные ссылки).
Главная проблема и её решение
Наивный trie расходует память дико: на каждое слово почти столько узлов, сколько в нём символов, а в каждом узле сидит мапа или массив на весь алфавит. Отсюда radix tree (он же patricia): цепочки узлов с одним ребёнком склеиваются в один узел с целой подстрокой на ребре. Для URL-роутера или IP-таблицы это сокращает число узлов на порядок.
«А чем trie лучше мапы, если у мапы тоже O(1)?» — Мапа не умеет префиксных запросов. Чтобы найти все ключи, начинающиеся на «go», по мапе придётся перебрать все n ключей. Плюс trie даёт лексикографический порядок бесплатно. Ответ «trie нужен ровно тогда, когда запросы по префиксу, а не по точному ключу» засчитают.
a[parent] ≤ a[child]. Min за O(1), вставка и извлечение за O(log n), построение из
готового массива за O(n).Механика
- Push: положить в конец массива и «всплывать» (sift up), меняясь местами с родителем, пока нарушается инвариант. Максимум log n обменов.
- Pop: минимум лежит в
a[0]. Забираем его, переносим последний элемент в корень, сокращаем длину и «топим» (sift down), меняясь с меньшим из детей. - Init: sift down для всех узлов с
n/2 - 1вниз до 0. Это O(n), а не O(n log n): у большинства узлов маленькая высота, и сумма высот всех узлов линейна. - Fix(i): элемент изменился, восстанавливаем инвариант из позиции i за O(log n). Именно так делают «изменить приоритет» в очереди с приоритетами.
container/heap: пакет даёт алгоритмы, хранилище — твоё
// Очередь с приоритетами по задачам
type Item struct {
Value string
Priority int
index int // позиция в куче, нужна для heap.Fix / heap.Remove
}
type PQ []*Item
func (pq PQ) Len() int { return len(pq) }
// Больший Priority = выше приоритет → max-heap: знак ">"
func (pq PQ) Less(i, j int) bool { return pq[i].Priority > pq[j].Priority }
func (pq PQ) Swap(i, j int) {
pq[i], pq[j] = pq[j], pq[i]
pq[i].index, pq[j].index = i, j // синхронизируем index
}
func (pq *PQ) Push(x any) {
it := x.(*Item)
it.index = len(*pq)
*pq = append(*pq, it)
}
func (pq *PQ) Pop() any {
old := *pq
n := len(old)
it := old[n-1]
old[n-1] = nil // помогаем GC
it.index = -1
*pq = old[:n-1]
return it
}
func main() {
pq := &PQ{}
heap.Init(pq)
heap.Push(pq, &Item{Value: "low", Priority: 1})
heap.Push(pq, &Item{Value: "high", Priority: 9})
top := (*pq)[0] // максимум за O(1), без извлечения
top.Priority = 0
heap.Fix(pq, top.index) // приоритет изменился, чиним за O(log n)
it := heap.Pop(pq).(*Item) // O(log n)
_ = it
}
heap.Push(h, x)≠h.Push(x). Твой метод только кладёт в конец; инвариант восстанавливает пакет. Вызвал напрямую — куча сломана иPopвернёт не минимум.Popдолжен забирать элемент с конца. До вызова твоегоPopпакет уже поменял местами корень с последним элементом. Если вернутьold[0], получишь не то и разрушишь кучу.Push/Popобъявляй на указателе (*PQ), потому что они меняют длину слайса.Len/Less/Swapхватит и на значении.
Top-k из большого потока (куча размера k даёт O(n log k) вместо сортировки за O(n log n) и без хранения всего в памяти), слияние k отсортированных потоков, алгоритм Дейкстры, планировщик таймеров (в рантайме Go таймеры лежат в четверичной куче), «ближайший дедлайн».
Представление
// Список смежности берут почти всегда
type Graph map[int][]int
// или, если вершины плотно занумерованы 0..V-1, быстрее и компактнее:
adj := make([][]int, V)
adj[u] = append(adj[u], v)
// Матрица смежности нужна на плотных графах с частыми проверками "есть ребро?"
m := make([][]bool, V)
for i := range m { m[i] = make([]bool, V) }
m[u][v] = true
Реальные графы (соцсеть, дорожная сеть, зависимости сервисов) разрежены: E ≪ V².
Матрица на 10⁵ вершин займёт 10¹⁰ ячеек, то есть неприменима. Поэтому по умолчанию список.
BFS: очередь, уровни, кратчайший путь
// Кратчайший путь в невзвешенном графе + восстановление маршрута
func ShortestPath(g Graph, from, to int) []int {
if from == to {
return []int{from}
}
prev := map[int]int{from: -1}
queue := []int{from}
for len(queue) > 0 {
u := queue[0]
queue = queue[1:]
for _, v := range g[u] {
if _, seen := prev[v]; seen {
continue
}
prev[v] = u // помечаем при добавлении, иначе дубли в очереди
if v == to {
// разворачиваем путь назад по prev
path := []int{to}
for x := u; x != -1; x = prev[x] {
path = append(path, x)
}
slices.Reverse(path)
return path
}
queue = append(queue, v)
}
}
return nil
}
// g := Graph{1: {2, 3}, 2: {4}, 3: {4, 5}, 4: {5}, 5: nil}
// ShortestPath(g, 1, 5) → [1 3 5] — два ребра, а не три через 2→4→5
// ShortestPath(g, 1, 1) → [1]
// ShortestPath(g, 5, 1) → [] (nil) — граф ориентированный, пути назад нет
Обход «по уровням» (сколько шагов до вершины) получается, если замерить длину очереди в начале
итерации: levelSize := len(queue), и сделать ровно столько извлечений. Тот же
приём, что и в задаче «BFS по уровням дерева».
DFS: рекурсия или явный стек
// Поиск цикла в ориентированном графе: три цвета
const (white = 0; grey = 1; black = 2)
func hasCycle(g Graph, V int) bool {
color := make([]int, V)
var dfs func(u int) bool
dfs = func(u int) bool {
color[u] = grey // вошли, ещё не вышли
for _, v := range g[u] {
if color[v] == grey { // ребро в "серую" вершину = обратное ребро = цикл
return true
}
if color[v] == white && dfs(v) {
return true
}
}
color[u] = black
return false
}
for u := 0; u < V; u++ {
if color[u] == white && dfs(u) {
return true
}
}
return false
}
// hasCycle(Graph{0: {1}, 1: {2}, 2: {0}}, 3) → true — 0→1→2→0
// hasCycle(Graph{0: {1}, 1: {2}}, 3) → false
- BFS: кратчайший путь по числу рёбер, «минимум шагов/ходов», уровни дерева, многоисточниковый BFS (сразу кладём в очередь несколько стартов, так решают «расстояние до ближайшего X»).
- DFS: связные компоненты, топологическая сортировка, поиск циклов, мосты и точки сочленения, backtracking (перебор с возвратом).
- Дейкстра = BFS + куча, когда рёбра взвешены неотрицательно. Просто BFS на взвешенном графе даёт неверный ответ, и это популярная добивка.
Пометить вершину посещённой при извлечении из очереди, а не при добавлении. Тогда одна
и та же вершина попадёт в очередь столько раз, сколько у неё входящих рёбер, и сложность
поедет в сторону O(V·E). Помечать надо ровно в момент queue = append(...).
sort.Slice и
slices.Sort нестабильны, стабильные — sort.Stable и
slices.SortStableFunc.Минимальный набор, который надо назвать
- Вставками работает за O(n²), но на почти отсортированных данных сваливается в O(n) и держит минимальную константу на коротких массивах. Поэтому живёт внутри всех промышленных гибридов.
- Слиянием даёт гарантированные O(n log n), стабильна, требует O(n) памяти. Основа внешней сортировки (когда данные не влезают в память) и легко распараллеливается.
- Быстрая: O(n log n) в среднем, O(n²) в худшем (отсортированный вход при наивном выборе опорного), in-place, лучшая константа.
- Пирамидальная гарантирует O(n log n) и O(1) памяти, нестабильна, константа хуже quicksort. Её держат как страховку от вырождения quicksort.
- Подсчётом / поразрядная обходит барьер O(n log n) за O(n + k), потому что не сравнивает элементы, а использует структуру ключа. Работает для целых в ограниченном диапазоне.
Почему O(n log n) — барьер
Любая сортировка сравнением — это дерево решений: каждое сравнение даёт один бит информации, а
различить нужно n! перестановок. Высота дерева не меньше
log₂(n!) ≈ n log₂ n − 1.44n. Отсюда нижняя оценка. Counting/radix её не нарушают —
они просто не сравнивают.
Что внутри Go
| Версия / функция | Алгоритм | Стабильна | Замечание |
|---|---|---|---|
sort.Sort, sort.Slice до Go 1.19 | introsort: quicksort + heapsort как fallback + insertion на коротких | нет | heapsort включался при глубине рекурсии > 2·log n |
| Go 1.19 и новее | pdqsort | нет | распознаёт отсортированные, обратные и «много дубликатов» входы, на них даёт O(n) |
sort.Stable, sort.SliceStable | insertion на блоках по 20 + symmerge | да | O(n log² n) в худшем, зато O(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
Где ошибаются
(lo+hi)/2вместоlo + (hi-lo)/2. В Goint64-битный, реально не переполнится, но это каноничная ошибка (в JDK жила девять лет), и её проверяют.lo < hiпри замкнутом отрезке[lo, hi]— пропускается последний кандидат, когдаlo == hi.lo = midвместоlo = mid + 1в полуинтервальной версии — приhi - lo == 1получаетсяmid == lo, отрезок не сокращается, бесконечный цикл.- Не спросили, отсортирован ли вход. Бинарный поиск требует монотонности — без неё ответ мусор.
- Забыли про дубликаты: «найти любой» и «найти первый» — разные задачи, и именно поэтому lower bound надёжнее.
Стандартная библиотека
i := sort.SearchInts(a, x) // lower bound; если не нашли — позиция вставки
i = sort.Search(len(a), func(i int) bool { return a[i] >= x })
idx, ok := slices.BinarySearch(a, x) // Go 1.21+
idx, ok = slices.BinarySearchFunc(users, u, cmpByID) // с компаратором
sort.Search(n, f) возвращает наименьшее i из [0, n), для которого f(i) == true
(или n, если такого нет), и требует, чтобы f была монотонной:
false…false,true…true. Немонотонный предикат не вызовет паники — просто вернёт неверный результат.
sort.Search ищет границу в любом монотонном пространстве. Это приём
«бинарный поиск по ответу»: минимальный размер батча, при котором укладываемся в SLA;
минимальное число воркеров, при котором очередь не растёт; максимальный лимит, при котором
сервис не падает. Формулировка «здесь предикат монотонный, значит применим бинпоиск по
ответу за O(log(range) · cost)» на собесе звучит очень зрело.
Базовая механика
- Считаем
h = hash(key). - Младшие биты
hдают номер бакета:bucket = h & (2^B - 1). - Внутри бакета ищем слот. Чтобы не сравнивать ключи целиком, сначала сверяем короткий
«отпечаток» хеша (старшие 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или «прочитал, изменил, записал обратно».
Таблица разбита на группы по 8 слотов, а рядом лежит массив control-байтов — по одному на слот, в каждом 7 бит хеша плюс метка «пусто/удалено». Поиск сравнивает контрольный байт сразу со всеми восемью байтами группы разом — на amd64 SIMD-инструкцией, на прочих арифметикой по 64-битному слову — и получает битовую маску кандидатов. Результат: меньше промахов кэша, меньше памяти на элемент, заметно быстрее поиск на больших мапах. Асимптотика прежняя, но упомянуть эту деталь на собесе в 2025–2026 — сильный ход.
1.2Типовые задачи easy / easy-medium
Реальный пул задач на мидла в РФ сводится к полутора десяткам сюжетов, которые кочуют из компании в компанию. Их надо не «уметь решить», а уметь написать за 10 минут, вслух назвать сложность и не наступить на грабли Go: руны, общий underlying array, копия в range.
Протокол лайвкодинга: что делать до первой строчки кода
Проваливают эту секцию чаще всего не потому, что не знают алгоритм, а потому, что молча пишут двадцать минут, а потом код не компилируется. Работающая последовательность:
- Уточнить контракт. Что на входе (отсортировано? уникальны? может быть пусто?),
что на выходе (индексы или значения? порядок важен?), что при отсутствии ответа
(вернуть
-1,ok bool, ошибку?). Один вопрос про пустой вход и один про дубликаты уже идут в плюс к оценке. - Проговорить наивное решение и его O. «Можно в лоб двумя циклами за O(n²)» никого не отпугнёт: это база, от которой ты отталкиваешься.
- Назвать улучшение и чем платишь. «Заменю внутренний цикл мапой: станет O(n) по времени, но появится O(n) памяти». Компромисс время/память надо озвучить самому.
- Согласовать сигнатуру и только потом писать тело.
- Прогнать руками на маленьком примере, вслух: «n = [2,7,11], target = 9 — на i = 0 в мапе пусто, кладём 2; на i = 1 ищем 2, нашли, возвращаем 0 и 1».
- Перечислить краевые случаи: пустой слайс, один элемент, все одинаковые, отрицательные числа, переполнение, юникод вне 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). Работает в трёх вариантах: указатели с концов навстречу
(пара с суммой, палиндром, разворот), два указателя по двум слайсам (слияние, пересечение)
и «медленный / быстрый» по одному слайсу (удаление дубликатов на месте, цикл в списке).
Скользящее окно
Окно сводится к двум указателям: между left и right лежит
«текущий кандидат», и мы поддерживаем какую-то инкрементальную характеристику окна: сумму,
счётчик уникальных, мапу частот. Работает это, только если характеристику удаётся обновить
за O(1) при сдвиге, а не пересчитывать заново. Два подвида:
- Фиксированное окно (длина k задана): вошёл один элемент, вышел один. Сумма:
sum += a[i] - a[i-k]. - Переменное окно: правый край двигается всегда, левый сдвигается, пока нарушено условие
(например, «в окне есть повтор»). Левый суммарно проходит тот же путь, поэтому всё ещё O(n),
несмотря на вложенный
for.
Устройство LRU-кэша
Самая частая «спроектируй структуру» на мидла. От тебя ждут Get и Put
за O(1) в среднем, плюс вытеснение самого давно не используемого элемента. Ни одна структура
поодиночке этого не даёт: мапа находит за O(1), но не хранит порядок; список хранит порядок,
но ищет за O(n). Поэтому их склеивают: мапа key → *node, а сами узлы
лежат в двусвязном списке, упорядоченном по свежести.
head и tail служат пустые узлы; они
нужны только затем, чтобы в коде вставки и удаления не было ни одной проверки на nil.
Это половина успеха: без стражей LRU пишется с четырьмя ветвлениями и обязательно с багом.tail.prev,
и единственным, где надо не забыть удалить ключ из мапы.- Забыли
deleteиз мапы при вытеснении. Список короткий, а мапа течёт — кэш «на 1000 элементов» держит миллион указателей. - Put существующего ключа не обрабатывается отдельно: узел добавляется второй раз, мапа перезаписывается, старый узел навсегда остаётся в списке.
- Get не обновляет порядок. Тогда это не LRU, а FIFO-кэш. Интервьюер спросит именно это.
Обход дерева: три порядка — это один обход, разный момент «посещения»
Рекурсивный DFS всегда идёт одинаково: спустился влево, спустился вправо, вернулся. Отличается только когда мы записываем значение узла — до спуска (pre), между спусками (in) или после (post). BFS устроен иначе: не рекурсия, а очередь, и узлы выходят по уровням.
Цикл в связном списке: черепаха и заяц
Наивно посещённые узлы складывают в map[*Node]bool: O(n) времени, но и O(n) памяти.
Алгоритм Флойда даёт O(1) памяти. Медленный указатель идёт на 1 узел, быстрый на 2. Если цикла
нет, быстрый упрётся в nil. Если цикл есть, оба рано или поздно окажутся внутри него,
и тогда расстояние между ними сокращается ровно на 1 за шаг — значит, встреча неизбежна.
Пусть до входа в цикл 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) в худшем.
Вопросы
18target - 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 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. Скажи про неё вслух, это сильный ход.
Почему именно два прохода
Мапа не хранит порядок вставки, и в 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).
[]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' компилируется, но
меняет копию, а не строку.
Три уровня «правильности»
| Что разворачиваем | Что сломается | Пример |
|---|---|---|
Байты []byte | UTF-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.
Общий случай: множество на мапе
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, ровно первый вариант.
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). - «Найти свободные окна». Дополнение к объединённым интервалам, тот же проход.
Три реализации
// 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 → pushFront | O(1) в среднем |
Get(miss) | поиск в мапе | O(1) в среднем |
Put(существующий) | поиск → обновить val → unlink → pushFront | O(1) |
Put(новый, есть место) | создать узел → записать в мапу → pushFront | O(1) + возможный рост мапы |
Put(новый, полный) | взять tail.prev → unlink → delete из мапы → создать → pushFront | O(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)
}
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): придётся перебирать все ключи, чтобы найти минимум.
Вариант 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», но на собесе его писать не просят,
достаточно назвать.
| Куча размера k | Quickselect | Полная сортировка | |
|---|---|---|---|
| Время | 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— нет. Классическая ловушка: определить все пять на значении и получить «куча не растёт».
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]
Зачем каждый из них нужен на практике
- 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)
Написать 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-order | O(n) | O(h) стек | сериализация, копирование дерева |
| in-order | O(n) | O(h) стек | сортированный вывод BST, валидация BST |
| post-order | O(n) | O(h) стек | высота, размеры, освобождение |
| BFS | O(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; обратно читаем поток тем же порядком.
Базовая версия
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
Вход в цикл и его длина
// Возвращает узел-вход в цикл и длину цикла; (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, это байтовые смещения
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 s | O(n), без аллокаций |
| случайный доступ к i-му символу | []rune(s) один раз | O(n) времени и памяти |
| изменить строку | []byte или []rune, потом обратно | копия, строки неизменяемы |
| склеить много строк | strings.Builder | амортизированно O(суммарной длины) |
| проверить валидность UTF-8 | utf8.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()
}
- Небуферизованный канал задач. Буфер тут не нужен: он не ускоряет, а лишь позволяет продюсеру убежать вперёд. На собесе скажи «буфер оптимизирует под конкретный профиль нагрузки, по умолчанию беру 0, потому что так проще рассуждать о завершении».
- Канал результатов отдельный. Совмещать вход и выход в один канал почти всегда ошибка проектирования.
wg.Add(workers)до цикла, а неwg.Add(1)внутри горутины. Внутри горутины получается гонка:wg.Wait()может успеть отработать раньше первогоAdd.for rangeпо каналу. Самый лаконичный способ выйти по закрытию. Не нужен ниok, ни лишнийselect.- Запись в
resultsподselectсctx.Done(). Это и есть та самая деталь, которую ищут. Без неё воркер повиснет навсегда, если потребитель ушёл по таймауту. - Продюсер в отдельной горутине. Если писать в
tasksиз основной, то при небуферизованном канале и медленных воркерах основная встанет — а нам ещё читатьresults. Дедлок. - Сторож. Писателей в
resultsмного, поэтому закрывает не воркер, а отдельная горутина послеwg.Wait(). Единственная законная причина для этой горутины. - Дренаж. Читаем до закрытия канала. Именно это гарантирует, что ни один воркер не останется висеть на записи.
tasks один, поэтому закрывает его сам. Писателей в results трое, поэтому
закрывает отдельный сторож после wg.Wait(). Результаты собирает основная горутина:
конкурентного доступа там нет, значит нет и гонки.Не начинай печатать сразу. Три-четыре фразы до кода стоят дороже, чем идеальный код без слов. Работающая последовательность:
- Контракт. «На входе слайс задач и число воркеров, на выходе все результаты и ошибка. Уточню: нужно ли остановиться на первой ошибке или собрать все? Порядок результатов важен?» Это два вопроса, которые интервьюер ждёт, и почти никто их не задаёт.
- Схема. «Делаю канал задач, N воркеров читают из него, пишут в канал результатов.
Продюсер закрывает задачи, отдельная горутина после
wg.Wait()закрывает результаты.» Одной фразой ты уже показал, что знаешь про владение каналом. - Опасные места, до того как их найдут. «Запись в результаты обязательно под
selectсctx.Done(), иначе при раннем выходе читателя воркеры утекут.» - Пишешь код и комментируешь только неочевидное. Не озвучивай
for i := 0; i < n; i++. Скажи лучше, почемуwg.Addстоит до цикла. - После кода проверь себя вслух. «Проверю на утечки: продюсер выходит по
ctxили по концу слайса, воркеры по закрытиюtasks, сторож поwg.Wait(). Прогнал быgo test -raceи тест сgoleak.»
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, потому что это ровно этот код, но оттестированный». Так закрыты обе
стороны: и «умеет руками», и «не изобретает велосипед на работе».
Вопросы
11wg.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
}
После 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. Это не косметика:
направленный тип на уровне компилятора запрещает вызывающему писать в канал и закрывать его.
Половина проблем с «кто закрыл канал» решается именно типом. Скажи это вслух: деталь
уровня «писал такое в проде».
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?» Компилятор запретит вызывающему писать и закрывать. Владение выражено типом, а не комментарием.
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
- Каждая стадия владеет только своим выходным каналом. Она его создаёт, пишет в
него и закрывает. Входной канал она только читает и никогда не закрывает. Поэтому
завершение идёт само: закрылся вход — вышел
range— сработалdefer close(out)— закрылся выход следующей стадии, и так до конца. - Однородная сигнатура.
func(ctx, <-chan In, ...) <-chan Out. Стадии свободно складываются друг с другом: их можно переставлять, добавлять, оборачивать вMerge, чтобы распараллелить одну стадию. ctxв каждой стадии. Каскад закрытия работает слева направо, но останавливаться досрочно приходится справа налево — потребитель решил, что хватит. Канала данных для этого нет, нужен отдельный сигнал:ctx(раньше писалиdone chan struct{}: та же идея, только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
}
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, который жёстко размазывает запросы во времени.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()вместо монотонного времени. В Gotime.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на лету. В проде я бы взял его.
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:Donehappens-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, если свободен. Забавный побочный эффект, полезный для метрик и бесполезный для логики (гонка).
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:])
}
Три отличия от наивной версии, каждое даёт заметный вклад:
- Одна горутина вместо двух на уровень: вторую половину сортирует текущая горутина. Это вдвое меньше горутин и, что важнее, работа остаётся на «горячем» ядре.
- Порог
seqThreshold. Ниже него не ветвимся вообще. Тот же приём, что вpdqsortиз стандартной библиотеки: там порог 12 элементов для insertion sort. - Один общий буфер. Наивная версия аллоцирует новый слайс на каждом
merge. Слияний ровно n−1, значит и аллокаций n−1: линейно, а не O(n log n), замер на n = 65 536 даёт 65 536 вызовов аллокатора. O(n log n) здесь про суммарный объём: 8,9 МБ на том же входе, и вот он и грузит GC. Версия с двумя буферами, которые меняются ролями, делает ровно одну аллокацию на всю сортировку.
Даже идеальный параллельный 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 выполняется после и видит ту же
переменную» даёт полный.
- Что здесь значение, а что ссылка? Слайс, мапа, канал, указатель, функция и интерфейс содержат внутри указатели; массив, структура и строка копируются целиком. На этом построена половина задач.
- Когда это вычисляется? Аргументы
deferвычисляются в момент объявления, а телоdeferвыполняется при выходе. Условиеrangeсчитается один раз, до цикла. - Кто на что смотрит, на переменную или на копию? Замыкание захватывает переменную, а в аргумент функции попадает копия значения.
- Есть ли тут недетерминированность? Порядок обхода мапы, выбор ветки
select, порядок выполнения горутин язык не определяет. Если задача про них, правильный ответ звучит как «не определено», и он же самый ценный. - Что произойдёт с памятью?
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())
}
105-1 recovered: boom11Почему: что на самом деле делает return
return X в Go распадается на три операции, строго в этом порядке:
- присвоить
Xпеременной-результату; - выполнить отложенные вызовы в порядке LIFO (last in, first out: последний
объявленный
deferсрабатывает первым); - фактически вернуть управление, отдав то, что лежит в переменной-результате сейчас.
Вся разница между f1 и f2 сводится к одному: существует ли эта
переменная как именованная. В f1 результат назван result, и
defer модифицирует ровно ту ячейку, из которой на шаге 3 будет взято значение:
result = 5 → result *= 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 2ShowP (указатель): 2Show (значение): 1B defer с замыканием: 3A 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конец maindefer 2defer 1defer 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.21 и ниже в go.mod):3 3 3c c c3 3 3Пять прогонов подряд — все пять одинаковые: старая семантика детерминирована, потому что все горутины читают одну ячейку уже после цикла.
Вывод начиная с Go 1.22:
0 1 2 — но в произвольном порядке; восемь прогонов подряд дали
2 1 0 пять раз и 2 0 1 три разаa b c0 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 вокруг очень много, а миграция сводится к правке
одной строки, которую боятся трогать ровно из-за этого изменения.
Это вторая половина вопроса, и на ней срезаются те, кто выучил только про 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, указывающий туда же.
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 2 ← cap обрезан до 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 вид на слайс». Ровно в таком виде она встречается в стандартной библиотеке
и в хорошем чужом коде.
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 3 ← append переаллоцировал, 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 m | 0 итераций | обходить нечего |
m == nil | true | мапу можно сравнивать только с 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 error | nil | nil | true |
var p *MyErr; var e error = p | *MyErr | nil | false |
e = &MyErr{} | *MyErr | адрес | false |
На return doWork(fail) срабатывает неявное преобразование
*MyErr → error. Компилятор упаковывает пару: тип
*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.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 четыре, и все четыре стоит назвать:
- Если готова ровно одна ветка, выполняется она.
- Если готовы несколько, выбирается равновероятно случайная. Не первая, не
последняя, не по порядку в коде. Это гарантия языка, и держится она на перестановке
порядка опроса. Сделано, чтобы
selectне голодал ни по одной ветке: иначе быстрый канал навсегда заглушил бы медленный. - Если не готова ни одна и есть
default, выполняетсяdefault, иselectстановится неблокирующим. - Если не готова ни одна и
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] 100b: 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() — вычисляется c2. init A: 3 2 13. init B4. main: 3 2 1iota: 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) false ← int и float64 — разные динамические типы2) true3) 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 ← байт, а не символоврун: 11s[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 s | for 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.»