Обычное дерево поиска хорошо, пока сбалансировано, но вырождается в список, если данные вставлять по порядку, — и поиск падает с O(log N) до O(N). Красно-чёрное дерево решает эту проблему: оно само себя балансирует при каждой вставке и удалении, гарантируя O(log N) при любом порядке данных. Плата — усложнение алгоритма.
Идея: не дать дереву перекоситься
Балансировка означает, что дерево не должно становиться слишком «однобоким» — глубина ветвей должна оставаться примерно одинаковой. Полностью выравнивать дерево при каждой вставке дорого. Красно-чёрное дерево идёт на компромисс: оно не идеально сбалансировано, но гарантирует, что самая длинная ветвь не более чем вдвое длиннее самой короткой. В цифрах: высота дерева на n ключей не превышает 2·log₂(n + 1), то есть на миллионе ключей худший путь это 40 сравнений против 20 в идеально ровном дереве и против миллиона в выродившемся. Этого достаточно, чтобы удержать O(log N).
Чтобы отслеживать баланс, каждому узлу приписывают цвет — красный или чёрный. Цвет — это не данные, а служебная пометка, по которой дерево понимает, когда пора перестраиваться.
Красно-чёрные правила
Цвет нужен ради одного правила, последнего в списке: на любом пути от узла вниз до края число чёрных узлов одинаково, а красные не идут подряд. Вместе это значит, что самая длинная ветвь не длиннее удвоенной самой короткой, и это вся балансировка. Остальные правила служебные, полный список такой:
- каждый узел либо красный, либо чёрный;
- корень всегда чёрный;
- концы веток — пустые «заглушки» вместо отсутствующих потомков — считаются чёрными;
- у красного узла оба потомка чёрные (красные узлы не идут подряд);
- на любом пути от узла вниз до края число чёрных узлов одинаково.
Третье правило выглядит формальностью, но без него не работает пятое: если бы концы веток не считались узлами, было бы непонятно, до чего именно мы считаем чёрные узлы на пути. В реализациях эти заглушки обычно не хранят как отдельные объекты — просто договариваются, что пустая ссылка чёрная.
Так выглядит дерево, в котором все пять правил соблюдены:
Заглушки на концах веток не нарисованы: они чёрные и входят в счёт одинаково у всех путей. Чёрные узлы задают «скелет» одинаковой высоты, красные его разбавляют, но никогда не стоят подряд. Поэтому самая длинная ветвь не может быть длиннее удвоенной самой короткой, и глубина дерева остаётся логарифмической при любом порядке вставок.
Как только вставка нарушает правила, дерево восстанавливает их — и заодно баланс.
Два инструмента восстановления
Перекраска (color flip) — просто смена цветов узлов. Дешёвая операция: иногда достаточно перекрасить родителя и «дядю» узла, чтобы устранить два красных подряд.
Поворот (rotation) — перестройка формы дерева. При повороте узел «опускается», а его потомок «поднимается» на его место, при этом правило «левый меньше, правый больше» сохраняется. Повороты бывают левые и правые; они физически выравнивают перекошенную ветвь, делая дерево ниже и ровнее.
Поворот выравнивает высоту (3 → 2), перекраска возвращает пять красно-чёрных правил, поиск остаётся O(log N).
Как это работает при вставке
Новый узел всегда вставляют красным (чтобы не нарушить правило о числе чёрных узлов) на обычное место дерева поиска. Если его родитель чёрный — всё в порядке, готово. Если красный — возникают два красных подряд (нарушение правила 4), и дерево чинит это перекрасками и поворотами, поднимаясь от места вставки к корню. В конце корень при необходимости красят в чёрный. Число таких исправлений невелико — пропорционально высоте дерева, то есть O(log N).
Это легко посчитать: дадим дереву функцию сравнения со счётчиком, зальём тысячу ключей по возрастанию — худший вход для дерева поиска — и поищем последний из них.
живой пример
import java.util.Comparator;
import java.util.TreeMap;
public class SortedInsert {
static int compares = 0;
public static void main(String[] args) {
Comparator<Integer> counting = (a, b) -> { compares++; return Integer.compare(a, b); };
TreeMap<Integer, String> tree = new TreeMap<>(counting);
for (int key = 1; key <= 1000; key++) tree.put(key, "заказ " + key);
compares = 0;
tree.get(1000);
System.out.println("сравнений при поиске последнего из " + tree.size() + " ключей: " + compares);
}
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Семнадцать сравнений вместо тысячи: путь до самого дальнего ключа уложился примерно в два логарифма. Обычное дерево поиска на таком входе выродилось бы в список и прошло бы все 1000 узлов.
Дерева в стандартной библиотеке Go нет, поэтому считаем на его ближайшем аналоге: отсортированном срезе с двоичным поиском. Сбалансированное дерево тратит на тот же поиск примерно вдвое больше сравнений, но это всё равно логарифм.
живой пример
package main
import (
"cmp"
"fmt"
"slices"
)
var compares = 0
func counting(a, b int) int {
compares++
return cmp.Compare(a, b)
}
func main() {
keys := make([]int, 0, 1000)
for key := 1; key <= 1000; key++ {
keys = append(keys, key)
}
compares = 0
slices.BinarySearchFunc(keys, 1000, counting)
fmt.Println("сравнений при поиске последнего из", len(keys), "ключей:", compares)
}
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Одиннадцать сравнений вместо тысячи: двоичный поиск делает логарифм шагов и одну проверку на равенство, красно-чёрное дерево на том же входе уложилось бы примерно в семнадцать, два логарифма. Обычное дерево поиска на таком входе выродилось бы в список и прошло бы все 1000 узлов.
Дерева в стандартной библиотеке JavaScript нет, поэтому считаем на его ближайшем аналоге: отсортированном массиве с двоичным поиском. Сбалансированное дерево тратит на тот же поиск примерно вдвое больше сравнений, но это всё равно логарифм.
живой пример
let compares = 0;
function lowerBound(a, key) {
let lo = 0;
let hi = a.length;
while (lo < hi) {
const mid = (lo + hi) >>> 1;
compares++;
if (a[mid] < key) lo = mid + 1;
else hi = mid;
}
return lo;
}
const keys = Array.from({ length: 1000 }, (_, i) => i + 1);
compares = 0;
lowerBound(keys, 1000);
console.log(`сравнений при поиске последнего из ${keys.length} ключей: ${compares}`);
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Десять сравнений вместо тысячи: двоичный поиск делает ровно логарифм шагов, красно-чёрное дерево на том же входе уложилось бы примерно в семнадцать, два логарифма. Обычное дерево поиска на таком входе выродилось бы в список и прошло бы все 1000 узлов.
Дерева в стандартной библиотеке Python нет, поэтому считаем на его ближайшем аналоге: отсортированном списке с bisect. Сбалансированное дерево тратит на тот же поиск примерно вдвое больше сравнений, но это всё равно логарифм.
живой пример
from bisect import bisect_left
compares = 0
class Key(int):
def __lt__(self, other) -> bool:
global compares
compares += 1
return int(self) < int(other)
keys = [Key(k) for k in range(1, 1001)]
compares = 0
bisect_left(keys, Key(1000))
print(f"сравнений при поиске последнего из {len(keys)} ключей: {compares}")
Запустить
Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →
Десять сравнений вместо тысячи: двоичный поиск делает ровно логарифм шагов, красно-чёрное дерево на том же входе уложилось бы примерно в семнадцать, два логарифма. Обычное дерево поиска на таком входе выродилось бы в список и прошло бы все 1000 узлов.
Удаление устроено тем же набором инструментов, но случаев в нём больше, и именно на нём ломаются самописные реализации. Сначала узел удаляют как в обычном дереве поиска: при двух потомках на его место переписывают преемника, так что физически всегда исчезает узел с не более чем одним потомком. Если исчез красный, правила целы: чёрных на путях не убавилось. Если исчез чёрный, на всех путях через него стало на один чёрный меньше, и это нарушение чинят снизу вверх, глядя на цвет брата удалённого узла и его детей: перекраска, если у брата чёрные дети, поворот, если у брата есть красный ребёнок, и подъём проблемы на уровень выше, если чинить на месте нечем. Поворотов при этом не больше трёх, перекрасок до O(log N), и общая цена та же, что у вставки.
Как это сделано в стандартной библиотеке
На красно-чёрном дереве построены упорядоченные словари и множества стандартных библиотек, и писать его самому не нужно даже для тренировки: в поворотах и перекрасках десяток симметричных случаев, а ошибка не видна, дерево продолжает отвечать правильно и лишь перестаёт быть сбалансированным. Знать устройство нужно, чтобы понимать, за что вы доплачиваете, беря упорядоченную коллекцию вместо хеш-таблицы.
Если реализацию всё-таки пишут, «ошибка не видна» лечится проверкой инвариантов в тестах: обойти дерево и убедиться, что корень чёрный, красные не идут подряд, а число чёрных узлов на всех путях до листьев одинаково; заодно проверить порядок ключей симметричным обходом. Такая проверка пишется в двадцать строк, гоняется после каждой вставки и удаления на случайных ключах и ловит сломанный поворот на первой же тысяче операций, тогда как поиск будет работать правильно ещё долго. Так тестируют и JDK: у TreeMap есть свои проверки инвариантов, и ровно так проверяют любую структуру с внутренними правилами.
Красно-чёрное дерево не единственный способ держать порядок. AVL-дерево балансирует строже (высоты поддеревьев отличаются не больше чем на единицу), поэтому ниже и чуть быстрее на поиске, но платит поворотами почти на каждой записи; его берут там, где читают много, а пишут редко. Список с пропусками (skip list) вообще не дерево: это несколько уровней связанных списков, где верхние пропускают большую часть элементов, и монета решает, на сколько уровней поднять новый элемент; тот же O(log N) в среднем, зато без поворотов, и потому он хорошо живёт в многопоточной среде, на нём построен ConcurrentSkipListMap, упорядоченная замена ConcurrentHashMap. И B-дерево из следующей статьи, тот же баланс для диска.
Эталонная реализация — TreeMap (и TreeSet, который внутри тот же TreeMap: ключи настоящие, а на месте значений у всех записей лежит одна общая заглушка). Это буквально то дерево, что разобрано выше: у каждого узла, кроме ключа, значения и трёх ссылок (левый, правый, родитель), есть поле цвета — один булев флаг. Вставка идёт как в обычном дереве поиска, а потом поднимается к корню, починяя нарушения теми же перекрасками и левыми-правыми поворотами. Наружу цвет не виден: ни настроек, ни методов у балансировки нет — она просто есть.
Сложность отсюда честная, без оговорки «в среднем»: get, put, remove — гарантированные O(log n) при любом порядке вставки. Интереснее цена самой балансировки. Перекрасок при вставке может быть до O(log n) — они поднимаются к корню, — а вот поворотов не больше двух, при удалении — не больше трёх. Поворот дороже перекраски: он переставляет ссылки и трогает сразу несколько узлов. То, что поворотов всегда единицы, и есть причина, по которой в библиотеках прижилось именно красно-чёрное дерево, а не более строго сбалансированное (например, AVL): то было бы чуть ниже и чуть быстрее на поиске, но платило бы поворотами на каждой записи.
За упорядоченность вы платите памятью и константой. Каждая запись TreeMap — отдельный объект с шестью полями, разбросанный по куче, и путь к нему — это несколько прыжков по ссылкам. На точечном доступе по ключу хеш-таблица заметно быстрее. Поэтому TreeMap берут не «на всякий случай», а когда порядок или диапазонные запросы действительно нужны.
Грабля. Загружать в дерево уже отсортированные данные поштучно — самый дорогой способ его построить: каждая вставка сначала ищет место, потом запускает балансировку. Если источник уже упорядочен, дерево можно собрать сразу готовым:
SortedMap<Long, Order> source = ...;
Map<Long, Order> copy = new TreeMap<>(source);
Конструктор от SortedMap обходит источник по порядку и раскладывает узлы сразу правильно, не сравнивая ключи вообще: линейный проход вместо n вставок с перестройками. putAll умеет тот же трюк, но только при трёх условиях разом: приёмник пуст, источник — тоже SortedMap, и порядок сравнения у них один и тот же. Не совпало хоть что-то — и это обычные поштучные вставки со всей балансировкой. Поштучно те же 1000 ключей обходятся примерно в 14 500 сравнений, копия целиком — в ноль. На сотне записей разница незаметна, на миллионе — вполне.
На красно-чёрном дереве или его родне построены упорядоченные словари большинства языков, но в стандартной библиотеке Go его нет: там только map без порядка и срез. Писать дерево самому не нужно даже для тренировки: в поворотах и перекрасках десяток симметричных случаев, а ошибка не видна, дерево продолжает отвечать правильно и лишь перестаёт быть сбалансированным. Знать устройство нужно, чтобы понимать, за что вы доплачиваете, беря упорядоченную структуру вместо map.
Если реализацию всё-таки пишут, «ошибка не видна» лечится проверкой инвариантов в тестах: обойти дерево и убедиться, что корень чёрный, красные не идут подряд, а число чёрных узлов на всех путях до листьев одинаково; заодно проверить порядок ключей симметричным обходом. Такая проверка пишется в двадцать строк, гоняется после каждой вставки и удаления на случайных ключах, лучше всего через testing/quick или фаззинг go test -fuzz, и ловит сломанный поворот на первой же тысяче операций, тогда как поиск будет работать правильно ещё долго. Ровно так проверяют любую структуру с внутренними правилами.
Красно-чёрное дерево не единственный способ держать порядок. AVL-дерево балансирует строже (высоты поддеревьев отличаются не больше чем на единицу), поэтому ниже и чуть быстрее на поиске, но платит поворотами почти на каждой записи; его берут там, где читают много, а пишут редко. Список с пропусками (skip list) вообще не дерево: это несколько уровней связанных списков, где верхние пропускают большую часть элементов, и монета решает, на сколько уровней поднять новый элемент; тот же O(log N) в среднем, зато без поворотов, и потому он хорошо живёт в многопоточной среде: на нём построена таблица в памяти у LevelDB и Pebble, хранилища под CockroachDB. И B-дерево из следующей статьи, тот же баланс для диска и для кеша процессора.
Готовые реализации живут в пакетах. Самый ходовой — github.com/google/btree, и это не красно-чёрное дерево, а B-дерево в памяти: в узле по умолчанию до 63 ключей подряд, узлов мало, и обход дружит с кешем; на нём держат индексы etcd и Kubernetes. Красно-чёрное и AVL есть в github.com/emirpasic/gods, а в golang.org/x/exp долго жил экспериментальный treap. Все дают гарантированные O(log n) при любом порядке вставки, и балансировка наружу не видна: ни настроек, ни методов у неё нет — она просто есть.
Интереснее цена самой балансировки. У красно-чёрного дерева перекрасок при вставке может быть до O(log n) — они поднимаются к корню, — а вот поворотов не больше двух, при удалении — не больше трёх. Поворот дороже перекраски: он переставляет указатели и трогает сразу несколько узлов. У B-дерева перестроек ещё меньше: узел на десятки ключей разбивается редко, а внутри узла элементы просто сдвигаются copy. Это и есть причина, по которой в Go прижилось именно B-дерево: меньше объектов, меньше указателей и меньше работы сборщику мусора.
За упорядоченность вы платите памятью и константой. Каждый узел красно-чёрного дерева — отдельный объект с ключом, значением, тремя указателями и цветом, разбросанный по куче, и путь к нему — несколько прыжков по указателям; миллион записей это миллион объектов, которые сборщик мусора обходит на каждом цикле. На точечном доступе по ключу map заметно быстрее. Поэтому дерево берут не «на всякий случай», а когда порядок или диапазонные запросы действительно нужны.
Грабля. Собирать упорядоченную структуру из уже отсортированных данных поштучно — самый дорогой способ её построить. Для отсортированного среза это slices.Insert в цикле, O(N²) на сдвигах; для дерева каждая вставка сначала ищет место, потом запускает балансировку. Если источник уже упорядочен или его можно отсортировать разом, так и делают:
keys := slices.Collect(maps.Keys(source)) // Go 1.23; раньше — цикл с append
slices.Sort(keys) // один проход O(n·log n) вместо n вставок со сдвигами
Отсортированный срез готов к slices.BinarySearch сразу; а если после этого нужны частые вставки вперемешку с поиском, срез один раз переливают в B-дерево пакета в порядке возрастания: вставка следующего по порядку ключа в B-дерево дешёвая, разбиение узла случается раз в десятки вставок. Поштучно те же 1000 ключей в срез стоят полмиллиона сдвинутых элементов, сортировка целиком — около десяти тысяч сравнений. На сотне записей разница незаметна, на миллионе — вполне.
На красно-чёрном дереве или его родне построены упорядоченные словари большинства языков, но в стандартной библиотеке JavaScript его нет: Map и Set помнят порядок вставки, а не порядок ключей. Писать дерево самому не нужно даже для тренировки: в поворотах и перекрасках десяток симметричных случаев, а ошибка не видна, дерево продолжает отвечать правильно и лишь перестаёт быть сбалансированным. Знать устройство нужно, чтобы понимать, за что вы доплачиваете, беря упорядоченную структуру вместо Map.
Если реализацию всё-таки пишут, «ошибка не видна» лечится проверкой инвариантов в тестах: обойти дерево и убедиться, что корень чёрный, красные не идут подряд, а число чёрных узлов на всех путях до листьев одинаково; заодно проверить порядок ключей симметричным обходом. Такая проверка пишется в двадцать строк, гоняется после каждой вставки и удаления на случайных ключах, удобнее всего через fast-check, и ловит сломанный поворот на первой же тысяче операций, тогда как поиск будет работать правильно ещё долго. Ровно так проверяют любую структуру с внутренними правилами.
Красно-чёрное дерево не единственный способ держать порядок. AVL-дерево балансирует строже (высоты поддеревьев отличаются не больше чем на единицу), поэтому ниже и чуть быстрее на поиске, но платит поворотами почти на каждой записи; его берут там, где читают много, а пишут редко. Список с пропусками (skip list) вообще не дерево: это несколько уровней связанных списков, где верхние пропускают большую часть элементов, и монета решает, на сколько уровней поднять новый элемент; тот же O(log N) в среднем, зато без поворотов, и потому он хорошо живёт в многопоточной среде: на нём построены упорядоченные множества Redis, которыми бэкенд на Node пользуется каждый день. И B-дерево из следующей статьи, тот же баланс для диска и для кеша процессора.
Готовые реализации живут в пакетах: sorted-btree даёт B-дерево в памяти с диапазонными запросами, functional-red-black-tree — классическое красно-чёрное, неизменяемое, где каждая вставка возвращает новую версию дерева, деля неизменённые узлы со старой. Оба дают гарантированные O(log n) при любом порядке вставки, и балансировка наружу не видна: ни настроек, ни методов у неё нет — она просто есть. Внутри самого V8 деревья тоже работают, но для своих нужд: так хранятся диапазоны в регулярных выражениях и промежутки жизни регистров в компиляторе.
Интереснее цена самой балансировки. Перекрасок при вставке может быть до O(log n) — они поднимаются к корню, — а вот поворотов не больше двух, при удалении — не больше трёх. Поворот дороже перекраски: он переставляет ссылки и трогает сразу несколько узлов. То, что поворотов всегда единицы, и есть причина, по которой в библиотеках прижилось именно красно-чёрное дерево, а не более строго сбалансированное AVL: то было бы чуть ниже и чуть быстрее на поиске, но платило бы поворотами на каждой записи.
За упорядоченность вы платите памятью и константой. Каждый узел — отдельный объект движка с заголовком и полями, разбросанный по куче, и путь к нему — несколько прыжков по ссылкам; миллион записей это миллион объектов для сборщика мусора. На точечном доступе по ключу Map заметно быстрее. Поэтому дерево берут не «на всякий случай», а когда порядок или диапазонные запросы действительно нужны.
Грабля. Собирать упорядоченную структуру из уже отсортированных данных поштучно — самый дорогой способ её построить. Для отсортированного массива это splice в цикле, O(N²) на сдвигах; для дерева каждая вставка сначала ищет место, потом запускает балансировку. Если источник уже упорядочен или его можно отсортировать разом, так и делают:
const keys = [...source.keys()].sort((a, b) => a - b); // один проход вместо n вставок со сдвигами
Отсортированный массив готов к двоичному поиску сразу; если после этого нужны частые вставки вперемешку с поиском, его переливают в дерево пакета в порядке возрастания. Поштучно те же 1000 ключей через splice стоят полмиллиона сдвинутых элементов, сортировка целиком — около десяти тысяч сравнений. На сотне записей разница незаметна, на миллионе — вполне.
На красно-чёрном дереве или его родне построены упорядоченные словари большинства языков, но в стандартной библиотеке Python его нет: dict помнит порядок вставки, а не порядок ключей. Писать дерево самому не нужно даже для тренировки: в поворотах и перекрасках десяток симметричных случаев, а ошибка не видна, дерево продолжает отвечать правильно и лишь перестаёт быть сбалансированным. Знать устройство нужно, чтобы понимать, за что вы доплачиваете, беря упорядоченную структуру вместо dict.
Если реализацию всё-таки пишут, «ошибка не видна» лечится проверкой инвариантов в тестах: обойти дерево и убедиться, что корень чёрный, красные не идут подряд, а число чёрных узлов на всех путях до листьев одинаково; заодно проверить порядок ключей симметричным обходом. Такая проверка пишется в двадцать строк, гоняется после каждой вставки и удаления на случайных ключах, удобнее всего через hypothesis, и ловит сломанный поворот на первой же тысяче операций, тогда как поиск будет работать правильно ещё долго. Ровно так проверяют любую структуру с внутренними правилами.
Красно-чёрное дерево не единственный способ держать порядок. AVL-дерево балансирует строже (высоты поддеревьев отличаются не больше чем на единицу), поэтому ниже и чуть быстрее на поиске, но платит поворотами почти на каждой записи; его берут там, где читают много, а пишут редко. Список с пропусками (skip list) вообще не дерево: это несколько уровней связанных списков, где верхние пропускают большую часть элементов, и монета решает, на сколько уровней поднять новый элемент; тот же O(log N) в среднем, зато без поворотов, и потому он хорошо живёт в многопоточной среде: на нём построены упорядоченные множества Redis. И B-дерево из следующей статьи, тот же баланс для диска; в Python оно ближе, чем кажется: индексы sqlite3 из стандартной библиотеки это B-деревья.
Готовая реализация упорядоченной коллекции в мире Python одна на всех — пакет sortedcontainers с SortedList, SortedDict и SortedSet. Внутри у него не дерево, а список отсортированных списков по тысяче элементов с индексом над ними: на чистом Python такая конструкция обгоняет любое дерево, потому что сдвиг внутри короткого списка делает быстрая memmove, а объектов-узлов нет вовсе. Сложность близка к O(log n) при любом порядке вставки, а балансировка, здесь это разбиение переполненного подсписка, наружу не видна: ни настроек, ни методов у неё нет — она просто есть.
Интереснее цена самой балансировки в классическом дереве. Перекрасок при вставке может быть до O(log n) — они поднимаются к корню, — а вот поворотов не больше двух, при удалении — не больше трёх. Поворот дороже перекраски: он переставляет ссылки и трогает сразу несколько узлов. То, что поворотов всегда единицы, и есть причина, по которой в библиотеках других языков прижилось именно красно-чёрное дерево, а не более строго сбалансированное AVL; в Python же выиграл sortedcontainers именно потому, что обошёлся без узлов и поворотов вообще.
За упорядоченность вы платите памятью и константой. Каждый узел самодельного дерева — отдельный объект Python с полями и ссылками, разбросанный по куче, и путь к нему — несколько прыжков через интерпретатор; миллион записей это миллион объектов для сборщика мусора. На точечном доступе по ключу dict заметно быстрее. Поэтому упорядоченную структуру берут не «на всякий случай», а когда порядок или диапазонные запросы действительно нужны.
Грабля. Собирать упорядоченную структуру из уже отсортированных данных поштучно — самый дорогой способ её построить. Для отсортированного списка это insort в цикле, O(N²) на сдвигах; для дерева каждая вставка сначала ищет место, потом запускает балансировку. Если источник уже упорядочен или его можно отсортировать разом, так и делают:
keys = sorted(source) # один проход O(n·log n) вместо n вставок со сдвигами
ordered = SortedList(source) # конструктор sortedcontainers делает то же самое внутри
Отсортированный список готов к bisect сразу, а конструктор SortedList сортирует источник один раз и режет его на подсписки, не сравнивая ключи поштучно. Поштучно те же 1000 ключей через insort стоят полмиллиона сдвинутых элементов, сортировка целиком — около десяти тысяч сравнений. На сотне записей разница незаметна, на миллионе — вполне.
Коротко
- Обычное дерево поиска вырождается на отсортированных данных; красно-чёрное дерево балансируется само и держит
O(log N)при любом порядке вставки. - Баланс отслеживают через цвет узлов (красный/чёрный) и пять правил; ключевое — одинаковое число чёрных узлов на всех путях вниз.
- Нарушенные вставкой правила чинят перекраской и поворотами, поднимаясь к корню; это стоит
O(log N). - Дают быстрые поиск/вставку/удаление с сохранением порядка; сложны в реализации — обычно берут готовые из библиотек.
- Высота не больше 2·log₂(n + 1): на миллионе ключей до 40 сравнений против 20 в идеальном дереве.
- Удаление физически убирает узел с одним потомком; исчез чёрный — нехватку чёрного чинят по цвету брата перекрасками и не более чем тремя поворотами.
- Сломанный поворот ловят проверкой инвариантов в тестах после каждой операции; соседи по задаче: AVL (строже, для чтения), skip list (без поворотов, дружит с потоками), B-дерево для диска.
- Готовое дерево —
TreeMapиTreeSet: узел с цветом, поворотов при вставке не больше двух; упорядоченная заменаConcurrentHashMap—ConcurrentSkipListMap; отсортированный источник копируют конструктором отSortedMap, а неputв цикле.
- В стандартной библиотеке дерева нет: отсортированный срез со
slices.BinarySearch, под нагрузкуgoogle/btree(B-дерево в памяти, меньше объектов для сборщика мусора); отсортированный источник собирают однимslices.Sort, а неslices.Insertв цикле.
- В языке дерева нет: отсортированный массив с двоичным поиском, под нагрузку
sorted-btreeилиfunctional-red-black-tree; отсортированный источник собирают однимsort, а неspliceв цикле.
- В стандартной библиотеке дерева нет:
bisectнад отсортированнымlist, под нагрузкуsortedcontainers(список списков без узлов и поворотов); отсортированный источник собирают однимsortedили конструкторомSortedList, а неinsortв цикле.
Что почитать дальше
- Деревья 2-3-4 и B-деревья — другой способ держать баланс; из него выросли индексы баз данных.
- Двоичные деревья — дерево поиска, которое здесь спасали от вырождения.
- Хеш-таблицы — что брать, когда порядок не нужен, а нужен доступ в среднем за
O(1). - Как выбрать структуру данных — когда дерево, когда хеш-таблица, когда список.