Объясните концепцию персистентных (immutable) структур данных в функциональном программировании: как реализовать эффективный доступ и обновление (например, для массива или дерева) без копирования всего объекта
Коротко — идея персистентных (immutable) структур данных: при изменении не модифицировать существующий объект, а создавать новую версию, максимально переиспользуя (шаря) неизменные части старой. Это даёт логические «снимки» (versions) за дешёвую цену за счёт структурного шаринга. Ключевые приёмы - Структурный шаринг и path-copying: меняют только узлы на пути от корня к месту изменения, остальные узлы разделяются между версиями. - Высокая арность/широкие узлы: уменьшают глубину дерева и число копируемых узлов (практически константная глубина). - Сжатая/битовая адресация (HAMT): разбивают хэш/индекс на куски по ccc бит и идут по уровням дерева. - Транзиенты (transients): внутри локальной области делать мутабельные изменения, потом «заморозить» в иммутабельную структуру для эффективных батч-операций. Примеры и сложность 1) Дерево (балансированное дерево: AVL/Red‑Black) - При обновлении (insert/delete) копируются только узлы вдоль пути к изменяемой позиции. - Высота h=O(logn)h = O(\log n)h=O(logn), поэтому создаётся O(h)O(h)O(h) новых узлов. - Стоимость: доступ/обновление O(logn)O(\log n)O(logn), память на одну операцию O(logn)O(\log n)O(logn) новых узлов. 2) Персистентный массив / вектор (bit‑partitioned trie, как в Clojure/Scala) - Индекс разбивается на куски по ccc бит; каждая вершина имеет арность b=2cb = 2^cb=2c. - Глубина h≈⌈log2nc⌉=O(logbn)h \approx \left\lceil \dfrac{\log_2 n}{c} \right\rceil = O(\log_b n)h≈⌈clog2n⌉=O(logbn). - При обновлении копируется только путь длины hhh (каждый новый узел содержит массив из bbb ссылок, но создаются только эти hhh узлов). - Часто используют c=5c=5c=5 (b=32b=32b=32), тогда глубина невелика: h≈⌈log2n5⌉h \approx \left\lceil \dfrac{\log_2 n}{5} \right\rceilh≈⌈5log2n⌉ (для миллионов элементов hhh порядка 3 − 43\!-\!43−4). - Стоимость: доступ/assoc O(h)O(h)O(h) с малым константным фактором; память на одну assoc O(h⋅bnode_size)O(h\cdot b_{\text{node\_size}})O(h⋅bnode_size) только для новых узлов. 3) Hash Array Mapped Trie (HAMT) для отображений (maps/sets) - Берут хэш ключа, разбивают на уровни по ccc бит и используют битовые маски/bitmap для компактного представления детей. - Глубина h=⌈wc⌉h = \left\lceil \dfrac{w}{c} \right\rceilh=⌈cw⌉ где www — разрядность хэша; на практике константная и мала. - Обновление/поиск O(h)O(h)O(h), практически константа, разделяется большая часть структуры. 4) Улучшения: RRB‑tree и др. - Для операций concat/slice используют «relaxed radix balanced» деревья (RRB), которые сохраняют быстрые конкатенации/сплайсы при сохранении шаринга. Иллюстрация path-copying (упрощённо) - Для дерева: при изменении листа создаём новый лист; затем создаём новую версию каждого родителя по пути до корня, где каждый новый родитель ссылается на старых детей, кроме изменённого — остальные дети остаются общими. Транзиенты (batch-оптимизация) - Для многих последовательных обновлений: сделать временную мутабельную версию (транзиент), выполнить kkk операций за O(k)O(k)O(k) как для изменяемой структуры, затем «заморозить» — вернуть персистентную структуру. Это комбинирует безопасность иммутабельности с эффективностью in-place для пакетной работы. Итог (комплексно) - Доступ/обновление в персистентных структурах: обычно O(logbn)O(\log_b n)O(logbn) (баланс/ширина bbb выбирается для малой глубины); для HAMT — практически константа. - Память на одну операцию: создаётся только O(logbn)O(\log_b n)O(logbn) новых узлов (не копируется весь объект), остальное — шарится. - Практически: использовать широкие деревья (b≈32), HAMT для мап, RRB/bit‑tries для векторов, и транзиенты для массовых изменений.
Ключевые приёмы
- Структурный шаринг и path-copying: меняют только узлы на пути от корня к месту изменения, остальные узлы разделяются между версиями.
- Высокая арность/широкие узлы: уменьшают глубину дерева и число копируемых узлов (практически константная глубина).
- Сжатая/битовая адресация (HAMT): разбивают хэш/индекс на куски по ccc бит и идут по уровням дерева.
- Транзиенты (transients): внутри локальной области делать мутабельные изменения, потом «заморозить» в иммутабельную структуру для эффективных батч-операций.
Примеры и сложность
1) Дерево (балансированное дерево: AVL/Red‑Black)
- При обновлении (insert/delete) копируются только узлы вдоль пути к изменяемой позиции.
- Высота h=O(logn)h = O(\log n)h=O(logn), поэтому создаётся O(h)O(h)O(h) новых узлов.
- Стоимость: доступ/обновление O(logn)O(\log n)O(logn), память на одну операцию O(logn)O(\log n)O(logn) новых узлов.
2) Персистентный массив / вектор (bit‑partitioned trie, как в Clojure/Scala)
- Индекс разбивается на куски по ccc бит; каждая вершина имеет арность b=2cb = 2^cb=2c.
- Глубина h≈⌈log2nc⌉=O(logbn)h \approx \left\lceil \dfrac{\log_2 n}{c} \right\rceil = O(\log_b n)h≈⌈clog2 n ⌉=O(logb n).
- При обновлении копируется только путь длины hhh (каждый новый узел содержит массив из bbb ссылок, но создаются только эти hhh узлов).
- Часто используют c=5c=5c=5 (b=32b=32b=32), тогда глубина невелика: h≈⌈log2n5⌉h \approx \left\lceil \dfrac{\log_2 n}{5} \right\rceilh≈⌈5log2 n ⌉ (для миллионов элементов hhh порядка 3 − 43\!-\!43−4).
- Стоимость: доступ/assoc O(h)O(h)O(h) с малым константным фактором; память на одну assoc O(h⋅bnode_size)O(h\cdot b_{\text{node\_size}})O(h⋅bnode_size ) только для новых узлов.
3) Hash Array Mapped Trie (HAMT) для отображений (maps/sets)
- Берут хэш ключа, разбивают на уровни по ccc бит и используют битовые маски/bitmap для компактного представления детей.
- Глубина h=⌈wc⌉h = \left\lceil \dfrac{w}{c} \right\rceilh=⌈cw ⌉ где www — разрядность хэша; на практике константная и мала.
- Обновление/поиск O(h)O(h)O(h), практически константа, разделяется большая часть структуры.
4) Улучшения: RRB‑tree и др.
- Для операций concat/slice используют «relaxed radix balanced» деревья (RRB), которые сохраняют быстрые конкатенации/сплайсы при сохранении шаринга.
Иллюстрация path-copying (упрощённо)
- Для дерева: при изменении листа создаём новый лист; затем создаём новую версию каждого родителя по пути до корня, где каждый новый родитель ссылается на старых детей, кроме изменённого — остальные дети остаются общими.
Транзиенты (batch-оптимизация)
- Для многих последовательных обновлений: сделать временную мутабельную версию (транзиент), выполнить kkk операций за O(k)O(k)O(k) как для изменяемой структуры, затем «заморозить» — вернуть персистентную структуру. Это комбинирует безопасность иммутабельности с эффективностью in-place для пакетной работы.
Итог (комплексно)
- Доступ/обновление в персистентных структурах: обычно O(logbn)O(\log_b n)O(logb n) (баланс/ширина bbb выбирается для малой глубины); для HAMT — практически константа.
- Память на одну операцию: создаётся только O(logbn)O(\log_b n)O(logb n) новых узлов (не копируется весь объект), остальное — шарится.
- Практически: использовать широкие деревья (b≈32), HAMT для мап, RRB/bit‑tries для векторов, и транзиенты для массовых изменений.