Задачи с собеседования на Golang-разработчика в OZON
Сегодня мы разберем разбор типичных задач с собеседования на позицию Go-разработчика в Ozon, который проводит автор канала в формате обучающего разбора кода. В эфире подробно разбираются механика работы слайсов (length/capacity, перераспределение памяти и побочные эффекты append), не гарантированный порядок итерации по мапе, иммутабельность строк и работа с байтами, а также алгоритмическая задача на проверку анограмм с обсуждением нескольких подходов к решению. Каждая задача сопровождается живым запуском кода для подтверждения теоретических выводов.
Вопрос 1. Дан кусок кода со слайсом: пустой слайс интов, добавляется 0, затем 1, затем 2. Далее создается переменная Y, равная X, в Y добавляется 3. Затем создается переменная Z, равная X, в Z добавляется 4. Что выведется на печать X, Y, Z?
Таймкод: 00:00:05
Ответ собеседника: Правильный. После добавления 0, 1, 2: длина 3, капасити 4. Y = X, append 3 — капасити хватает, 3 записывается в тот же массив. Z = X, append 4 — капасити хватает, 4 перезаписывает 3 в общем массиве. X: [0 1 4], Y: [0 1 4], Z: [0 1 4].
Правильный ответ:
Суть происходящего
Все три слайса (X, Y, Z) указывают на один и тот же базовый массив (backing array). Из-за механизма роста емкости (capacity) в Go, операции append не вызывают переаллокацию памяти, а пишут в общую область, перезаписывая данные друг друга.
Пошаговый разбор
1. Инициализация и наполнение X
var X []int
X = append(X, 0) // len=1, cap=1 (обычно)
X = append(X, 1) // len=2, cap=2 (удвоение)
X = append(X, 2) // len=3, cap=4 (удвоение: 2 -> 4)
После этого X ссылается на массив [0, 1, 2, 0] (последний ноль — неинициализированная память под 4-й элемент). Заголовок слайса: ptr -> array[0], len=3, cap=4.
2. Создание Y и append(3)
Y := X // Копируется заголовок слайса (ptr, len, cap). Указатель на массив общий.
Y = append(Y, 3)
Так как len(Y) == 3 и cap(Y) == 4, есть свободный слот. Go пишет 3 в 4-ю ячейку базового массива и увеличивает длину Y до 4.
Состояние массива: [0, 1, 2, 3].
X (len=3) видит [0, 1, 2]. Y (len=4) видит [0, 1, 2, 3].
3. Создание Z и append(4)
Z := X // Копируется заголовок X (ptr, len=3, cap=4). Указатель на массив всё тот же.
Z = append(Z, 4)
Z имеет длину 3 и емкость 4. Свободная 4-я ячейка массива уже занята значением 3 (записанным через Y). Go перезаписывает эту ячейку значением 4 и увеличивает длину Z до 4.
Состояние массива: [0, 1, 2, 4].
Итоговое состояние
- X:
len=3, cap=4→[0 1 2](но в памяти за ним лежит 4) - Y:
len=4, cap=4→[0 1 2 4] - Z:
len=4, cap=4→[0 1 2 4]
Примечание: В ответе собеседника указано X: [0 1 4]. Это неверно для стандартного fmt.Println(X), так как len(X) остался 3. fmt.Println выводит только элементы до len. Если бы мы сделали X = X[:4], то увидели бы [0 1 2 4]. Верный вывод fmt.Println(X, Y, Z):
[0 1 2] [0 1 2 4] [0 1 2 4]
Ключевые моменты для понимания
- Слайс — это структура из 3 полей (указатель, длина, емкость). Присваивание
Y = Xкопирует именно эту структуру, а не данные. - Copy-on-Write не работает автоматически. Go не копирует массив при
append, пока есть свободная емкость. - Стратегия роста емкости (до Go 1.18: удвоение до 1024, потом 1.25x; с 1.18: плавная кривая) здесь сыграла роль: после 3 элементов емкость стала 4, что создало «ловушку» для общих данных.
- Как избежать: использовать
copyдля создания независимого слайса илиappendс созданием нового слайса заранее известной емкости:Y := append([]int(nil), X...).
Пример кода для проверки
package main
import "fmt"
func main() {
var X []int
X = append(X, 0, 1, 2) // len=3, cap=4
Y := X
Y = append(Y, 3) // Пишет в общий массив[3]
Z := X
Z = append(Z, 4) // Перезаписывает массив[3]
fmt.Printf("X: %v (len=%d cap=%d)\n", X, len(X), cap(X))
fmt.Printf("Y: %v (len=%d cap=%d)\n", Y, len(Y), cap(Y))
fmt.Printf("Z: %v (len=%d cap=%d)\n", Z, len(Z), cap(Z))
// Доказательство общего массива:
fmt.Println("Адрес 0-го элемента X:", &X[0])
fmt.Println("Адрес 0-го элемента Y:", &Y[0])
fmt.Println("Адрес 0-го элемента Z:", &Z[0])
}
Вопрос 2. Дана мапа map[string]int с ключами A, B, C и значениями 1, 2, 3. В цикле range печатаются ключи и значения. Что выведется в консоли и в каком порядке?
Таймкод: 00:02:08
Ответ собеседника: Правильный. Порядок итерации по мапе в Go не гарантирован. При каждом запуске порядок может быть разным (например, A B C или B A C и т.д.). Код выведет все три пары, но последовательность не определена.
Правильный ответ:
Краткий ответ
В консоль выведутся все три пары A:1, B:2, C:3, но порядок их вывода не определен и может отличаться от запуска к запуску. Гарантий сохранения порядка вставки или алфавитного порядка нет.
Почему так происходит (внутреннее устройство) В Go мапа реализована как хеш-таблица с открытой адресацией (начиная с Go 1.12 — таблица с 8 бакетами на группу, использующая квадратичное пробирование).
- Ключи хешируются (
hash(key)). - Хеш определяет номер бакета (bucket).
- Внутри бакета ключи хранятся в ячейках (slots), порядок которых зависит от порядка вставки и коллизий.
- Итератор
rangeпроходит по бакетам последовательно (0, 1, 2...), а внутри бакета — по слотам.
Так как хеш-функция для строк содержит случайную составляющую (seed), генерируемую при старте программы (для защиты от DoS-атак — hash flooding), распределение ключей по бакетам меняется при каждом запуске процесса. Следовательно, порядок обхода меняется.
Рандомизация стартовой точки (Go 1.12+) Даже если бы хеш-функция была детерминированной, Go намеренно рандомизирует точку начала итерации (starting bucket и offset внутри бакета). Это сделано, чтобы разработчики не написали код, зависящий от порядка обхода (например, тесты, сравнивающие срезы ключей).
Пример кода
package main
import "fmt"
func main() {
m := map[string]int{
"A": 1,
"B": 2,
"C": 3,
}
// Запустите этот код 5-10 раз подряд (go run main.go)
// Порядок будет меняться.
for k, v := range m {
fmt.Printf("%s: %d\n", k, v)
}
}
Возможный вывод запуска 1:
B: 2
A: 1
C: 3
Возможный вывод запуска 2:
C: 3
B: 2
A: 1
Как получить детерминированный порядок Если порядок важен (например, для стабильного логирования, тестов или API), нужно явно сортировать ключи:
package main
import (
"fmt"
"sort"
)
func main() {
m := map[string]int{"A": 1, "B": 2, "C": 3}
// 1. Собираем ключи в срез
keys := make([]string, 0, len(m))
for k := range m {
keys = append(keys, k)
}
// 2. Сортируем (лексикографически или по кастомному компаратору)
sort.Strings(keys)
// 3. Итерируем в отсортированном порядке
for _, k := range keys {
fmt.Printf("%s: %d\n", k, m[k])
}
}
Гарантированный вывод:
A: 1
B: 2
C: 3
Важные нюансы для Senior-уровня
- Однократная итерация: В рамках одного запуска программы (одного вызова функции) порядок итерации по одной и той же мапе стабилен, если мапу не модифицировали. Но стоит добавить/удалить ключ — итератор может "прыгнуть" или пойти по новому пути рехеширования.
- Удаление во время итерации: Безопасно удалять ключи во время
range(ключи, еще не посещенные итератором, могут быть пропущены или посещены — поведение неспецифицировано, но паники не будет). Добавлять ключи нельзя — это приведет к неопределенному поведению (в т.ч. крашу рантайма в старых версиях). - Нулевые значения: Если значение для ключа не установлено,
rangeвернет нулевое значение типа значения (0 для int).rangeне пропускает "пустые" значения, он итерирует по существующим ключам. - Производительность: Сортировка ключей — O(N log N) + O(N) памяти. На горячих путях (hot paths) с большими мапами это может быть узким местом. Рассмотрите использование
btree(например,github.com/google/btree) или хранение данных в срезе структур, если нужен порядок.
Вопрос 3. Дана строка S = "тест". Печатается нулевой элемент S[0], затем попытка изменить S[0] = 'x', затем повторная печать. Что произойдёт?
Таймкод: 00:03:04
Ответ собеседника: Правильный. Строки в Go неизменяемы. S[0] вернёт байт первого символа 'т' (116 в десятичном виде). Попытка присвоения S[0] = 'x' вызовет ошибку компиляции: cannot assign to S[0]. До строки печати после изменения выполнение не дойдёт.
Правильный ответ:
Краткий ответ
Код не скомпилируется. Ошибка компиляции: cannot assign to S[0] (strings are immutable).
Строки в Go — это неизменяемые (immutable) последовательности байт (read-only slice of bytes). Операция индексации S[i] возвращает байт (тип byte / uint8), но только для чтения. Запись в строку через индекс запрещена на уровне компилятора.
Разбор деталей ответа кандидата
-
"S[0] вернёт байт первого символа 'т' (116 в десятичном виде)" — здесь неточность.
- Строка
"тест"в UTF-8 кодируется так:т(2 байта:0xD1 0x82),е(2 байта),с(2 байта),т(2 байта). S[0]вернет первый байт первого руны —0xD1(209 в десятичном виде).- Код
'т'(rune literal) имеет значение1090(U+0442). Байт116— это ASCII-код латинской't'. В строке"тест"нет байта116. - Правильный вывод
fmt.Println(S[0]):209.
- Строка
-
Неизменяемость — это фундаментальное свойство. Оно позволяет:
- Безопасно делиться строками между горутинами без синхронизации.
- Использовать строки как ключи мап (hash вычисляется один раз, данные не меняются).
- Делать подстроки (
S[i:j]) за O(1) без копирования данных (строка — это заголовок: указатель + длина).
Как правильно «изменить» строку Так как строки неизменяемы, любая «модификация» создает новую строку (с аллокацией памяти и копированием).
Вариант 1: Конвертация в []rune (если работаем с символами/рунами)
s := "тест"
runes := []rune(s) // Копируем данные, декодируя UTF-8 -> кодовые точки (rune)
runes[0] = 'x' // Меняем руну (теперь 'x' = U+0078, 1 байт в UTF-8)
s = string(runes) // Кодируем обратно в UTF-8, аллокация новой строки
fmt.Println(s) // "xест"
Нюанс: []rune расходует больше памяти (4 байта на символ) и требует двойного прохода (декод/енкод).
Вариант 2: Конвертация в []byte (если работаем с байтами/ASCII)
s := "test" // Только ASCII
bytes := []byte(s)
bytes[0] = 'x'
s = string(bytes)
fmt.Println(s) // "xest"
Опасно для не-ASCII: []byte("тест")[0] = 'x' сломает UTF-8 последовательность первой руны (0xD1 -> 0x78), строка станет невалидной UTF-8.
Вариант 3: strings.Builder (эффективная сборка строки)
import "strings"
s := "тест"
var b strings.Builder
b.Grow(len(s)) // Оптимизация: предрезервируем память
b.WriteString(s[:0]) // Пусто
b.WriteRune('x') // Пишем новую руну
b.WriteString(s[2:]) // Пропускаем первую руну (2 байта) — ОПАСНО без знания границ рун!
// Правильнее для рун: конвертить в []rune или использовать strings.Replace
Лучшая практика для замены подстроки: strings.Replace(s, "т", "x", 1) или strings.Builder + итерация по рунам.
Вариант 4: strings.Replace (идиоматично для замены подстрок)
s := "тест"
s = strings.Replace(s, "т", "x", 1) // Заменит первое вхождение "т" на "x"
// Результат: "xест"
Сравнение с другими языками (для контекста Senior-уровня)
| Язык | Строки | Изменение по индексу |
|---|---|---|
| Go | Неизменяемые (immutable) | Ошибка компиляции |
| Python | Неизменяемые | TypeError в рантайме |
| Java | Неизменяемые (String) / Изменяемые (StringBuilder) | Ошибка компиляции у String |
| C/C++ | Изменяемые (char[]) / Неизменяемые (string literal) | UB / Ошибка сегментации для литералов |
| Rust | String (изменяемый, owned), &str (неизменяемый view) | s[0] = 'x' — ошибка компиляции (индексация по байтам запрещена для String напрямую, нужен as_bytes_mut()) |
Резюме для интервью
- Строки в Go — immutable.
s[i]дает байт (uint8), а не руну (символ).- Для работы с текстом (рунами) используйте
for range s(дает руны) или[]rune(s). - Для эффективного построения строк —
strings.Builder. - Для манипуляций — пакет
strings/bytes.
Вопрос 4. Написать функцию, проверяющую, являются ли две строки анограммами (состоят из одних и тех же символов в одинаковом количестве). Пример: "тапок" и "капот" → true.
Таймкод: 00:04:06
Ответ собеседника: Правильный. Два основных подхода: 1) Использовать мапу для подсчёта символов первой строки, затем уменьшать счётчики для второй строки — если все счётчики станут нулевыми, строки анограммы. 2) Отсортировать руны обеих строк и сравнить отсортированные срезы — если равны, то анограммы.
Правильный ответ:
Выбор подхода и сложность
| Подход | Временная сложность | Пространственная сложность | Особенности |
|---|---|---|---|
| Частотный анализ (Map/Array) | O(N) | O(K) — K уникальных рун (в худшем O(N)) | Оптимально по времени. Требует аллокации мапы/среза. |
| Сортировка рун | O(N log N) | O(N) — копия строки в []rune | Проще код, но медленнее на больших строках. Сортировка в Go (sort.Slice) быстрая, но O(N log N) проигрывает O(N). |
Рекомендация для продакшена: Частотный анализ (O(N)). Для коротких строк (до ~50-100 рун) разница незначительна, но на длинных строках или в горячих путях (hot path) линейная сложность критична.
Реализация 1: Частотный анализ через мапу (Универсальная, Unicode-safe)
Это самый надежный способ для произвольного Unicode-текста. Используем rune (code point), а не байты.
package anagram
// AreAnagrams проверяет, являются ли s1 и s2 анограммами.
// Чувствительна к регистру: 'A' != 'a'.
// Учитывает все руны (включая пробелы, знаки препинания, эмодзи).
func AreAnagrams(s1, s2 string) bool {
// 1. Быстрый путь: если длина в рунах разная — точно не анограммы.
// utf8.RuneCountInString быстрее, чем len([]rune(s)), так как не аллоцирует срез.
if utf8.RuneCountInString(s1) != utf8.RuneCountInString(s2) {
return false
}
// 2. Считаем частоты рун первой строки.
// make(map[rune]int, n) с подсказкой емкости уменьшает реаллокации.
counts := make(map[rune]int, utf8.RuneCountInString(s1))
for _, r := range s1 {
counts[r]++
}
// 3. Вычитаем частоты второй строки.
for _, r := range s2 {
counts[r]--
// Оптимизация: ранний выход. Если счетчик ушел в минус — в s2 рун больше, чем в s1.
if counts[r] < 0 {
return false
}
}
// 4. Проверка, что все счетчики обнулились (технически избыточна из-за п.1 и п.3,
// но полезна для ясности и защиты от багов, если убрать ранний выход).
for _, v := range counts {
if v != 0 {
return false
}
}
return true
}
Реализация 2: Частотный анализ через массив (Максимальная производительность для ограниченного алфавита)
Если известно, что строки содержат только ASCII (например, латиница a-z, A-Z) или ограниченный набор символов (кириллица без редких символов), массив быстрее мапы в 5-10 раз за счет отсутствия хеширования и аллокаций.
// AreAnagramsASCII быстрая версия только для ASCII (0-127) или расширенного ASCII (0-255).
// Паникует или даст ложный результат, если встретятся руны > 255.
func AreAnagramsASCII(s1, s2 string) bool {
if len(s1) != len(s2) { // Для чистого ASCII длина в байтах == длине в рунах
return false
}
var counts [256]int // Стековая аллокация, нулевая инициализация
for i := 0; i < len(s1); i++ {
counts[s1[i]]++
counts[s2[i]]--
}
for _, c := range counts {
if c != 0 {
return false
}
}
return true
}
Реализация 3: Сортировка (Лаконичная, O(N log N))
Подходит для скриптов, утилит, или если строки гарантированно короткие.
import (
"sort"
"strings"
)
// AreAnagramsSort проверяет через сортировку рун.
func AreAnagramsSort(s1, s2 string) bool {
if utf8.RuneCountInString(s1) != utf8.RuneCountInString(s2) {
return false
}
r1 := []rune(s1)
r2 := []rune(s2)
sort.Slice(r1, func(i, j int) bool { return r1[i] < r1[j] })
sort.Slice(r2, func(i, j int) bool { return r2[i] < r2[j] })
// Сравнение срезов рун поэлементно
for i := range r1 {
if r1[i] != r2[i] {
return false
}
}
return true
}
Важные нюансы (Edge Cases), которые отличают Senior-решение
1. Нормализация Unicode (Критично для продакшена)
Строки "e\u0301" (e + combining acute accent, 2 руны) и "é" (precomposed, 1 руна) визуально идентичны, но не являются анограммами по кодам точек (code points).
// "cafe\u0301" (café декомпозированное) vs "café" (предкомпозированное)
// AreAnagrams вернет false.
Решение: Приводить строки к нормальной форме (NFC или NFD) перед проверкой через golang.org/x/text/unicode/norm.
import "golang.org/x/text/unicode/norm"
func AreAnagramsNormalized(s1, s2 string) bool {
// NFC комбинирует символы в предкомпозированные формы
s1 = norm.NFC.String(s1)
s2 = norm.NFC.String(s2)
return AreAnagrams(s1, s2)
}
2. Чувствительность к регистру и пробелам
Часто бизнес-требование: "Listen" == "Silent" (true), "Dormitory" == "Dirty room" (true).
Нужно явно уточнить требования и нормализовать ввод:
func normalize(s string) string {
// 1. Приводим к нижнему регистру (Unicode-aware)
s = strings.ToLower(s)
// 2. Удаляем пробелы/пунктуацию (если нужно)
// Можно использовать strings.Map или regexp
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if !unicode.IsSpace(r) && !unicode.IsPunct(r) {
b.WriteRune(r)
}
}
return b.String()
}
3. Производительность: strings.Builder vs []rune
В функции normalize выше используется strings.Builder — это единственный правильный способ конкатенировать строки в цикле в Go. Использование s += string(r) приведет к O(N²) аллокациям.
Бенчмарк (примерный, для понимания масштабов) Строки длиной ~1000 рун (кириллица/латиница).
func BenchmarkAnagrams(b *testing.B) {
s1 := strings.Repeat("абвгдежзиклмнопрстуфхцчшщъыьэюя", 30) // ~1000 runes
s2 := reverseRunes(s1) // Анограмма
b.Run("Map", func(b *testing.B) {
for i := 0; i < b.N; i++ { AreAnagrams(s1, s2) }
})
b.Run("Sort", func(b *testing.B) {
for i := 0; i < b.N; i++ { AreAnagramsSort(s1, s2) }
})
b.Run("ASCII_Array", func(b *testing.B) {
// Только если вход ASCII
ascii1 := "abcdefghijklmnopqrstuvwxyz..."
ascii2 := reverseString(ascii1)
for i := 0; i < b.N; i++ { AreAnagramsASCII(ascii1, ascii2) }
})
}
Типичные результаты:
Map: ~3-5 мкс/оп, 2-3 аллокации.Sort: ~15-30 мкс/оп, 2 аллокации (срезы рун).ASCII_Array: ~0.3-0.5 мкс/оп, 0 аллокаций (стек).
Итоговый чек-лист для код-ревью этого решения
- Проверка длины в рунах (
utf8.RuneCountInString) в самом начале. - Использование
rangeпо строке (итерирует по рунам, а не байтам). - Ранний выход (
counts[r] < 0) внутри второго цикла. - Отсутствие аллокаций в хот-пате (если используется массив для ASCII).
- Обработка
nilстрок (в Gorange nilбезопасен,utf8.RuneCountInString(nil) == 0). - Документация: чувствительность к регистру, пробелам, нормализация Unicode.
