7. Serialize and Deserialize Binary Tree
«Формат данных переживает код, который его создал; проектируйте его так, будто он останется навсегда».
— Брайан Керниган
Сериализация и десериализация бинарного дерева: проектирование протоколов и строковая инженерия
Задача проектирования алгоритма сериализации и десериализации бинарного дерева (Serialize and Deserialize Binary Tree, LeetCode 297) венчает раздел алгоритмических задач.
В отличие от задач на вычисление одного числового ответа, здесь проверяется системный инженерный дизайн:
- Сериализация: преобразование древовидной структуры в компактную строку или поток байтов.
- Десериализация: однозначное, детерминированное восстановление исходной структуры дерева из строки.
В распределенных бэкенд-системах этот процесс лежит в основе сетевых протоколов (gRPC / Protocol Buffers, JSON RPC), передачи деревьев выражений в SQL-оптимизаторах и персистентного сохранения деревьев состояний на диск.
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
subgraph Serialization["Сериализация: Preorder DFS"]
A["Корень node"]:::entry --> B{"node == nil ?"}:::warning
B -->|"Да"| C["Записать маркер '#,'"]:::process
B -->|"Нет"| D["Записать 'val,'"]:::process
D --> E["Рекурсия для node.Left"]:::process
E --> F["Рекурсия для node.Right"]:::process
end
subgraph Deserialization["Десериализация: Посимвольный парсинг"]
G["Чтение следующего токена"]:::entry --> H{"token == '#' ?"}:::warning
H -->|"Да"| I["Вернуть nil"]:::process
H -->|"Нет"| J["Создать TreeNode(val)"]:::process
J --> K["node.Left = Десериализация"]:::process
K --> L["node.Right = Десериализация"]:::process
L --> M["Вернуть восстановленный узел"]:::success
end
1. Архитектура протокола: почему одного обхода обычно недостаточно?
В классической теории деревьев (1. Теория. Деревья) известно: чтобы однозначно восстановить бинарное дерево по значениям узлов, требуются два обхода (например, Preorder + Inorder).
Однако если мы сохраняем маркеры пустых указателей (Nil / Null Sentinel), например символ #, то дерево однозначно восстанавливается всего по одному Preorder (или Postorder) обходу!
Сравнение форматов обхода:
- Preorder DFS (Корень → Лево → Право):
Самый лаконичный и естественный формат. Десериализация строится через прямую рекурсию без очередей. - BFS (Поуровневый обход):
Использует очередь. Удобен для потокового чтения без глубокого рекурсивного стека вызовов, но требует хранения очередей в памяти.
2. Идиоматичная реализация на Go: Zero-Allocation парсинг
Наивный подход использует strings.Split(data, ","). Это порождает огромный срез строк в куче, создавая двойное потребление памяти.
В промышленной реализации мы используем:
strings.Builderдля линейной конкатенации строки сериализации.- Посимвольный парсинг по указателю смещения
posдля десериализации (срезы строк без выделения памяти).
package treecodec
import (
"strconv"
"strings"
)
// TreeNode представляет узел бинарного дерева
type TreeNode struct {
Val int
Left *TreeNode
Right *TreeNode
}
// Codec инкапсулирует логику кодирования и декодирования
type Codec struct{}
func Constructor() Codec {
return Codec{}
}
// Serializes a tree to a single string.
func (c *Codec) Serialize(root *TreeNode) string {
var sb strings.Builder
c.buildString(root, &sb)
return sb.String()
}
func (c *Codec) buildString(node *TreeNode, sb *strings.Builder) {
if node == nil {
sb.WriteString("#,")
return
}
sb.WriteString(strconv.Itoa(node.Val))
sb.WriteByte(',')
c.buildString(node.Left, sb)
c.buildString(node.Right, sb)
}
// Deserializes your encoded data to tree.
func (c *Codec) Deserialize(data string) *TreeNode {
if len(data) == 0 {
return nil
}
pos := 0
return c.buildTree(data, &pos)
}
func (c *Codec) buildTree(data string, pos *int) *TreeNode {
if *pos >= len(data) {
return nil
}
// Извлекаем следующий токен до запятой за O(1) памяти
start := *pos
for *pos < len(data) && data[*pos] != ',' {
*pos++
}
token := data[start:*pos]
*pos++ // пропускаем разделительную запятую
if token == "#" {
return nil
}
val, err := strconv.Atoi(token)
if err != nil {
return nil
}
node := &TreeNode{Val: val}
node.Left = c.buildTree(data, pos)
node.Right = c.buildTree(data, pos)
return node
}
3. Аппаратная эффективность и механика строк в Go
Zero-Copy срезы строк
В коде token := data[start:*pos] не происходит копирования символов.
В рантайме Go строка представляется структурой StringHeader:
type StringHeader struct {
Data unsafe.Pointer
Len int
}
Срез строки лишь сдвигает указатель Data и изменяет Len. Он живет в регистрах процессора и не аллоцируется в куче.
Преимущества strings.Builder
Вызов оператора + для строк выделяет новую строку в куче на каждом шаге ($O(N^2)$ аллокаций).
strings.Builder использует внутренний байтовый буфер []byte, удваивающийся геометрически, что гарантирует амортизированное линейное время $O(N)$ и минимальную фрагментацию памяти.
4. Вопросы для собеседования
Резюме
- Добавление маркеров пустых узлов (
#) позволяет восстановить структуру дерева всего по одному обходу. - Использование
strings.Builderи посимвольного извлечения токенов исключает ненужные аллокации памяти в рантайме Go. - Механическая симпатия проявляется в использовании zero-copy срезов строк, что делает сериализатор масштабируемым под высокие нагрузки.
🎓 Завершение Модуля 21: Вы готовы к собеседованиям любого уровня
Вы прошли колоссальный путь через 29 фундаментальных кластеров и 247 лекций:
- От базовых трюков со слайсами и указателями до параллельных графовых обходов и динамического программирования.
- От системных структур данных (LRU/LFU, Swiss Tables, Min-Heap) до потоковых вероятностных скетчей (HyperLogLog, Count-Min, $P^2$).
- Вы овладели не просто академическими алгоритмами, а механической симпатией к рантайму Go, ядру Linux и архитектуре современных процессоров.
Теперь любые алгоритмические секции уровня Middle, Senior и Lead — это не препятствие, а возможность продемонстрировать инженерное превосходство. В добрый путь!