8. Serialize and Deserialize Binary Tree
«Данные должны передаваться просто. Избавьтесь от лишних украшений — и сеть ответит вам взаимностью».
— Роб Пайк
LeetCode 297. Serialize and Deserialize Binary Tree. Требуется спроектировать и реализовать алгоритм сериализации бинарного дерева в строку и обратной десериализации этой строки в эквивалентное бинарное дерево. Ограничений на формат кодирования не накладывается — главное, чтобы дерево гарантированно восстанавливалось в исходном виде.
В оперативной памяти узлы дерева связаны виртуальными указателями (например, 0xc0000a2040). Однако указатели локальны для адресного пространства конкретного процесса. Их невозможно записать в файл на диск, отправить в топик Apache Kafka или передать через сетевой сокет в gRPC-вызове.
Для сетевой передачи сложный иерархический граф объектов с указателями обязан быть преобразован в плоскую последовательность байтов. Эта задача уровня Hard проверяет зрелость системного мышления инженера: умение проектировать компактные протоколы сериализации, исключать паразитные аллокации строк и мыслить категориями сетевой пропускной способности.
1. Архитектурный выбор: почему стандартный JSON не подходит для High-Load?
Первая мысль кандидата — использовать стандартный пакет encoding/json:
{
"Val": 1,
"Left": { "Val": 2, "Left": null, "Right": null },
"Right": { "Val": 3, "Left": null, "Right": null }
}
2. Алгоритм кодирования: Preorder DFS с маркерами X
Мы используем прямой центрированный обход (Preorder DFS: Корень $\rightarrow$ Лево $\rightarrow$ Право):
- Если узел пуст (
nil), записываем терминальный символX,. - Если узел существует, записываем числовое значение узла и разделитель-запятую:
node.Val,. - Рекурсивно сериализуем левое поддерево, затем правое поддерево.
Фиксация маркеров X для пустых потомков однозначно устраняет топологическую двусмысленность: такое представление восстанавливает единственное возможное бинарное дерево без необходимости сохранять второй порядок обхода (Inorder).
flowchart TD
classDef process fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef success fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
classDef error fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
classDef warning fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef entry fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef accent fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
Root["1"]:::entry --> L["2"]:::process
Root --> R["3"]:::process
L --> L1["X (nil)"]:::error
L --> L2["X (nil)"]:::error
R --> R1["4"]:::accent
R --> R2["5"]:::accent
R1 --> R11["X"]:::error
R1 --> R12["X"]:::error
R2 --> R21["X"]:::error
R2 --> R22["X"]:::error
subgraph Output["Preorder Stream"]
Out["1, 2, X, X, 3, 4, X, X, 5, X, X,"]:::success
end
3. Идиоматическая реализация на Go
package main
import (
"strconv"
"strings"
)
type TreeNode struct {
Val int
Left *TreeNode
Right *TreeNode
}
type Codec struct{}
func Constructor() Codec {
return Codec{}
}
// serialize преобразует дерево в компактную строку Preorder с помощью strings.Builder.
// Временная сложность: O(N) — каждый узел посещается один раз.
// Пространственная сложность: O(N) — размер результирующей строки.
func (this *Codec) serialize(root *TreeNode) string {
var sb strings.Builder
var buildString func(node *TreeNode)
buildString = func(node *TreeNode) {
if node == nil {
sb.WriteString("X,")
return
}
// Записываем значение узла и разделитель
sb.WriteString(strconv.Itoa(node.Val))
sb.WriteByte(',')
buildString(node.Left)
buildString(node.Right)
}
buildString(root)
return sb.String()
}
// deserialize восстанавливает бинарное дерево из строки токенов.
// Временная сложность: O(N) — каждый токен обрабатывается за O(1).
// Пространственная сложность: O(N) — хранение слайса токенов и узлов в куче.
func (this *Codec) deserialize(data string) *TreeNode {
if len(data) == 0 {
return nil
}
tokens := strings.Split(data, ",")
idx := 0
var buildTree func() *TreeNode
buildTree = func() *TreeNode {
if idx >= len(tokens) {
return nil
}
token := tokens[idx]
idx++
// Базовый случай: пустой узел
if token == "X" || token == "" {
return nil
}
val, _ := strconv.Atoi(token)
node := &TreeNode{Val: val}
// Порядок восстановления строго соответствует Preorder (Left -> Right)
node.Left = buildTree()
node.Right = buildTree()
return node
}
return buildTree()
}
4. Механическая симпатия: Эффективная работа со строками и памятью
strings.Builderпротив конкатенации+:
Строки в Go неизменяемы. Операцияstr += "X,"при каждом шаге цикла выделяет новый блок памяти и копирует туда старую строку, порождая квадратичную сложность аллокаций $O(N^2)$.
Внутриstrings.Builderнаходится динамический срез байтов[]byte. Методsb.String()использует безопасный указатель, возвращая готовую строку со сложностью Zero-Copy (без дублирующего копирования буфера в куче).- Промышленный инсайт: Zero-Allocation сканер вместо
strings.Split:
Вызовstrings.Split(data, ",")для дерева из $1\,000\,000$ узлов создаёт массив из миллиона структурstring(каждая по 16 байт = 16 МБ памяти в куче), нагружая сборщик мусора.
В производственных сериализаторах строку читают потоковым итератором по байтовому срезу через функциюstrings.IndexByte(data[offset:], ','), расходуя ровно 0 байт дополнительной памяти на промежуточные токены.
5. Граничные случаи (Edge Cases)
- Пустое дерево (
root == nil):
Сериализатор возвращает"X,". Десериализатор считывает токен"X"и возвращаетnil. - Отрицательные числа:
Функцияstrconv.Itoaкорректно кодирует отрицательные целые числа (например,-42), аstrconv.Atoiбезошибочно парсит знак минус. - Одностороннее вырожденное дерево:
МаркерыXфиксируют форму дерева, предотвращая превращение левой цепочки в правую.
Резюме для интервью
| Характеристика | Значение |
|---|---|
| Формат кодирования | Preorder DFS с терминальными маркерами X |
| Сложность по времени | $O(N)$ на сериализацию и $O(N)$ на десериализацию |
| Сложность по памяти | $O(N)$ на строковый буфер и токены |
| Ключевой приём в Go | strings.Builder для быстрого построения строки без аллокаций |
На этом мы завершаем кластер Деревья. Мы освоили все ключевые аспекты работы с иерархическими структурами данных: от локальности памяти и сборки мусора до послойного обхода, поиска диаметра, валидации BST и сетевой сериализации.
В следующем масштабном разделе мы покидаем рамки деревьев и погружаемся в мир произвольных сетей, циклов и взаимных зависимостей — кластер Графы: 1. Теория. Графы.