Перейти к основному содержимому

Задачи с собеседования на Golang-разработчика в OZON

· 15 мин. чтения

Сегодня мы разберем разбор типичных задач с собеседования на позицию 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]

Ключевые моменты для понимания

  1. Слайс — это структура из 3 полей (указатель, длина, емкость). Присваивание Y = X копирует именно эту структуру, а не данные.
  2. Copy-on-Write не работает автоматически. Go не копирует массив при append, пока есть свободная емкость.
  3. Стратегия роста емкости (до Go 1.18: удвоение до 1024, потом 1.25x; с 1.18: плавная кривая) здесь сыграла роль: после 3 элементов емкость стала 4, что создало «ловушку» для общих данных.
  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 бакетами на группу, использующая квадратичное пробирование).

  1. Ключи хешируются (hash(key)).
  2. Хеш определяет номер бакета (bucket).
  3. Внутри бакета ключи хранятся в ячейках (slots), порядок которых зависит от порядка вставки и коллизий.
  4. Итератор 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-уровня

  1. Однократная итерация: В рамках одного запуска программы (одного вызова функции) порядок итерации по одной и той же мапе стабилен, если мапу не модифицировали. Но стоит добавить/удалить ключ — итератор может "прыгнуть" или пойти по новому пути рехеширования.
  2. Удаление во время итерации: Безопасно удалять ключи во время range (ключи, еще не посещенные итератором, могут быть пропущены или посещены — поведение неспецифицировано, но паники не будет). Добавлять ключи нельзя — это приведет к неопределенному поведению (в т.ч. крашу рантайма в старых версиях).
  3. Нулевые значения: Если значение для ключа не установлено, range вернет нулевое значение типа значения (0 для int). range не пропускает "пустые" значения, он итерирует по существующим ключам.
  4. Производительность: Сортировка ключей — 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), но только для чтения. Запись в строку через индекс запрещена на уровне компилятора.

Разбор деталей ответа кандидата

  1. "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.
  2. Неизменяемость — это фундаментальное свойство. Оно позволяет:

    • Безопасно делиться строками между горутинами без синхронизации.
    • Использовать строки как ключи мап (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 / Ошибка сегментации для литералов
RustString (изменяемый, 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 аллокаций (стек).

Итоговый чек-лист для код-ревью этого решения

  1. Проверка длины в рунах (utf8.RuneCountInString) в самом начале.
  2. Использование range по строке (итерирует по рунам, а не байтам).
  3. Ранний выход (counts[r] < 0) внутри второго цикла.
  4. Отсутствие аллокаций в хот-пате (если используется массив для ASCII).
  5. Обработка nil строк (в Go range nil безопасен, utf8.RuneCountInString(nil) == 0).
  6. Документация: чувствительность к регистру, пробелам, нормализация Unicode.