[Из песочницы] Практика метапрограммирования на C++: бинарное дерево поиска на этапе компиляции
Однако, с приходом C++11 с его variadic
-шаблонами, библиотекой type_traits
, кортежами и constexpr
'ами метапрограммирование стало более удобным и наглядным, открыв дорогу к реализации многих идей, которые раньше можно было воплотить только с помощью расширений конкретного компилятора или сложных многоэтажных макросов.
В данной статье будет разработана реализация бинарного дерева поиска времени компиляции: структуры данных, являющейся логическим «расширением» кортежа. Мы воплотим бинарное дерево в виде шаблона и попрактикуемся в метапрограммировании: переносе рекурсивных и не только алгоритмов на язык шаблонов C++.
Содержание
- Немного теории
- Разминка: операции с кортежами и превращение числа в класс
- Бинарное дерево поиска
- Рекурсивное определение
- Отношение порядка
- Высота
- Обход центрированный (in-order traversal)
- Поиск
- Вставка
- Обход в ширину (breadth-first traversal)
- Удаление?
- Рекурсивное определение
- Применение
- Что потом?
- Литература
Немного теории
Для начала вспомним некоторые определения и понятия о структурах данных и алгоритмах. Можно пропустить этот раздел и сразу перейти к деталям реализации, если читатель знаком с основами теории графов и/или представляет, что такое бинарные деревья и как их можно готовить.
Дерево с корнем — свободное дерево, в котором выделена одна вершина, называемая корнем (root).
Узлы (nodes) — вершины дерева с корнем.
Родительский узел или родитель узла X — последний узел, предшествующий X на пути от корня R к этому узлу X. В таком случае узел X называется дочерним к описанному родительскому узлу Y. Корень дерева не имеет родителя.
Лист — узел, у которого нет дочерних узлов.
Внутренний узел — узел, не являющийся листом.
Степень узла X — количество дочерних узлов этого узла X.
Глубина узла X — длина пути от корня R к этому узлу X.
Высота узла (height) — длина самого длинного простого (без возвратов) нисходящего пути от узла к листу.
Высота дерева — высота корня этого дерева.
Упорядоченное дерево — дерево с корнем, в котором дочерние узлы каждого узла упорядочены (т.е. задано отображение множества дочерних узлов на множество натуральных чисел от 1 до k, где k — общее количество дочерних узлов этого узла). Простыми словами, каждому дочернему узлу присвоено имя: «первый», «второй», …,»k-тый».
Бинарное дерево (binary tree) — (рекурсивно) либо пустое множество (не содержит узлов), либо состоит из трёх непересекающихся множеств узлов: корневой узел, бинарное дерево, называемое левым поддеревом, и бинарное дерево, называемое правым поддеревом. Таким образом, бинарное дерево — это упорядоченное дерево, у которого каждый узел имеет степень не более 2 и имеет «левый» и/или/либо «правый» дочерние узлы.
Полностью бинарное дерево (full binary tree) — бинарное дерево, у которого каждый узел либо лист, либо имеет степень два. Полностью бинарное дерево можно получить из бинарного добавлением фиктивных дочерних листов каждому узлу степени 1.
Бинарное дерево поиска — связанная структура данных, реализованная посредством бинарного дерева, каждый узел которого может быть представлен объектом, содержащим ключ (key) и сопутствующие данные, ссылки на левое и правое поддеревья и ссылку на родительский узел. Ключи бинарного дерева поиска удовлетворяют свойству бинарного дерева поиска:
если X — узел, и узел Y находится в левом поддереве X, то Y.key ≤ X.key. Если узел Y находится в правом поддереве X, то X.key ≤ Y.key.Подразумевается, что мы умеем сравнивать ключи (на множестве значений ключа задано транзитивное отношение порядка, т.е. проще говоря операция «меньше») и говорить о равенстве ключей. В реализации без ограничения общности мы будем оперировать строгими неравенствами порядка, используя только операцию »<" и "==", построенную на её основе из соотношения
$inline$x=y\;\Leftrightarrow\;\;!(x
Обход дерева — формирование списка узлов, порядок определяется типом обхода.
Разминка: операции с кортежами и превращение числа в класс
Прежде чем окунуться с головой в рекурсивные дебри и насладиться буйством угловых скобок, поупражняемся в написании метафункций. Далее нам понадобятся функции конкатенации кортежей и функция добавления типа в существующий кортеж:
template
struct tuple_concat;
template
struct tuple_push;
Стандарт C++11 вводит заголовочный файл type_traits
(как часть библиотеки поддержки типов), содержащий коллекцию полезных метафункций. Все они являются шаблонами структур, и после инстанцирования определяют локально некий тип type
или числовую константу value
(или ничего, как в случае отключения перегрузки с использованием std::enable_if
). Мы будем придерживаться тех же принципов проектирования метафункций.
Первая функция принимает в качестве аргументов шаблона два кортежа, вторая — кортеж и тип, который необходимо добавить в кортеж. Подстановка в качестве аргументов неподходящих типов (например, при попытке сделать конкатенацию
int
и float
) — операция бессмысленная, поэтому базовый шаблон этих структур не определяется (это предотвратит его инстанцирование для произвольных типов), а вся полезная работа делается в частичных специализациях: template class T, class... Alist, class... Blist>
struct tuple_concat, T> { using type = T; };
template class Tuple, class... Args, class T>
struct tuple_push,T> { using type = Tuple; };
Примерный перевод на человеческий язык специализации
tuple_concat
: Если в качестве аргументов шаблона выступают два класса, которые в свою очередь являются результатами инстанцирования одного и того же шаблона класса (T
) с переменным числом аргументов, и они были инстанцированы с parameter pack’ами Alist
и Blist
, то определить локально псевдоним type
как инстанцированую версию того же шаблона класса T
с конкатенированным списком аргументов, т.е. T
.
Звучит зловеще, но на практике всё проще, чем кажется: при попытке вызвать
tuple_concat
с двумя кортежами одного вида (например, с двумя std::tuple
), внутри структуры определится тип того же кортежа со «сшитым» списком аргументов входных кортежей. Иные попытки инстанцирования просто не скомпилируются (компилятор не сможет вывести типы для определенной выше частичной специализации, а инстанцирование общего шаблона окажется невозможным ввиду отсутствия его тела). Пример: using t1 = std::tuple;
using t2 = std::tuple;
using t3 = std::tuple;
using conc = typename tuple_concat::type;
// using err = typename tuple_concat::type; // ошибка
static_assert(std::is_same::value, ""); // утверждение истинно, типы одинаковы
В свете вышесказанного рассмотрение специализации
tuple_push
не должно составить большого труда. Дополнительно, для удобства определим соответственные псевдонимы шаблонов: template
using tuple_concat_t = typename tuple_concat::type;
template
using tuple_push_t = typename tuple_push::type;
Эта удобная возможность появилась в языке в C++11 стандарте: она, например, позволяет для доступа к
type
вместо заковыристого typename tuple_concat::type
писать просто tuple_concat_t
.tuple
содержит определение (не мета-)функции tuple_cat()
, конструирующей кортеж посредством конкатенации неопределенного числа std::tuple
'ей, переданных в качестве аргументов. Внимательный читатель может заметить, что tuple_concat
может быть проще реализована посредством вывода типа результата decltype(tuple_cat(...))
, однако, во-первых, полученная выше реализация не ограничена типом кортежа std::tuple
, а во-вторых, это было разминочным упражнением для постепенного погружения в более сложную арифметику типов.Последние приготовления: не для дела, а для души дебага научимся превращать целые числа в типы: в узлах дерева надо что-то хранить, а что может быть проще и удобнее для отладки, чем хранить в них обычные числа? Но так как дерево у нас не простое, а с типами (и времени компиляции), то и числа должны быть непростыми. На самом деле, реализация крайне проста и известна:
/// Модно-молодёжно STL-like путём
template
struct num_t : std::integral_constant {};
// ИЛИ классически определим внутренность руками
template
struct num_t { enum : size_t { value = number } };
Смысл у обоих определений один: инстанцирование шаблона с различными численными аргументами будет приводить к определению различных типов:
num_t<0>
, num_t<13>
, num_t<42>
и т.д. Не более чем для удобства наделим эту структуру статическим числовым значением value
, что позволит нам явно получать назад число из аргумента шаблона (посредством доступа some_num_type::value
) без прибегания к выводу типа.Бинарное дерево поиска
Рекурсивное определение бинарного дерева поиска оказывается удобным для непосредственного воплощения в виде шаблона. Упрощенное определение
ДЕРЕВО : NIL | [ДЕРЕВО, ДАННЫЕ, ДЕРЕВО]
можно перефразировать как «дерево — ЭТО пустое множество ИЛИ множество из трёх элементов: дерево (т.н. левое поддерево), данные, дерево (т.н. правое поддерево)».
Помимо этого, как уже упоминалось, бинарное дерево поиска требует задания отношения порядка на множестве значений, хранящихся в узлах дерева (мы должны уметь как-то сравнивать и упорядочивать узлы, т.е. иметь операцию «меньше»). Каноничным подходом является разбиение данных узла на ключ и значение (ключи сравниваем, значения просто храним), но в нашей реализации в целях упрощения структуры без ограничения общности будем считать данные узла единым типом, для задания же отношения порядка воспользуемся специальным типом Comp
(comparator, поговорим о нём далее).
/// Пустое множество
struct NIL {};
/// Узел
template
struct node {
using type = T; // node value
using LT = Left; // left subtree
using RT = Right; // right subtree
using comp = Comp; // types comparator
};
/// Лист: узел без потомков (псевдоним шаблона)
template
using leaf = node;
Несмотря на то, что отношение порядка всё-таки задано на множестве хранимых в узлах типов, удобно приписать его самому дереву и хранить как часть типа этого дерева: при вызове метафункций поиска, вставки и обхода непустого дерева нет необходимости в дополнительном указании компаратора.
Сама по себе ситуация не критичная и имеет обходное решение в виде разделения объявлений и определений типов:
template<...>
struct one {
struct two;
using three = one;
struct two : one {};
};
Примечание: экспериментальным путём обнаружено, что такие структуры компилируются и инстанцируются современными gcc и clang, тем не менее, я пока не проверял строгое соответствие стандарту объявлений таких необычных шаблонов.Однако на практике работать с такими сущностями и создавать их оказывается очень, ОЧЕНЬ сложно. Обратная ссылка на родительский элемент производит интересный эффект: фактически наше «односвязное дерево» превращается в настоящий граф (вкусно!), который при любой модификации должен инстанцировать себя «единовременно», заново и полностью (грустно). Более глубокий анализ данной ситуации выходит за рамки настоящей статьи и входит в число моих дальнейших планов по исследованию возможностей метапрограммирования в C++.
Это не единственный способ реализации и представления дерева (например, можно хранить узлы в кортеже и проводить их индексацию), однако такое описание более наглядно и удобно для непосредственного применения алгоритмов работы с деревом.
Отношение порядка
Структура
Comp
должна содержать метафункции сравнения типов (т.е. шаблоны операций «меньше» и «равно»). Напишем пример такого сравнителя, основанного на sizeof
'ах типов (возможно, единственная числовая характеристика, определенная для всех полных типов): struct sizeof_comp {
template
struct lt : std::integral_constant {}; // меньше
template
struct eq : std::integral_constant {}; // равно
};
Здесь всё должно быть прозрачно:
lt
— метафункция «меньше» для типов, eq
— метафункция «равно». Использован подход, показанный ранее для определения типов чисел: наследование от std::integral_constant
наделит инстанцированные lt
и eq
статическими булевыми значениями value
.На практике конкретные деревья конкретных типов должны снабжаться сравнителем, специфичным для данной задачи. Например, напишем сравнитель для описанного ранее класса «числовых типов»:
struct num_comp {
template
struct lt : std::integral_constant {};
template
struct eq : std::integral_constant {};
};
Такой компаратор, вообще говоря, универсален и умеет сравнивать любые типы, содержащие статическое значение
value
./// Шаблон для базового класса всех сгенерированных компараторов
template
struct eq_comp {
template
struct lt : std::integral_constant::value> {};
template
struct eq : std::integral_constant::value && !lt::value)> {};
};
/// Переписаный компаратор sizeof, наследование наделит его метафункцией eq_comp::eq
struct sizeof_comp : public eq_comp {
template
struct lt : std::integral_constant {};
};
/// Переписаный компаратор num_t
struct num_comp : public eq_comp {
template
struct lt : std::integral_constant {};
};
Размер определения уменьшился и появилась математическая подоплёка — разве это не прекрасно? =)Теперь у нас есть всё для того, чтобы явно, «руками», определить первое дерево типов:
using t1 = node<
num_t<5>,
node<
num_t<3>,
leaf>,
leaf>
>,
node<
num_t<7>,
NIL,
leaf>
>
>;
Примечание: Здесь и далее в примерах по-умолчанию подразумевается сравнительОписанное дерево изображено на рисунке.num_comp
, явное указание его в списке аргументов шаблонов опускается. Вообще, после разработки операцииinsert
нам не придётся строить деревья таким образом (явным определением).
Эта отдельная интересная тема для дискуссии и исследования — отладка метапрограмм. У нас нет ни стека вызовов, ни переменных, ни, чёрт возьми, банального printf/std::cout
. Есть техники, позволяющие печатать внутри сообщений об ошибках компилятора удобочитаемые выведенные типы, и, в принципе, это достаточно полезная возможность проверки сгенерированных структур (например, после модификации дерева).
Не будем здесь касаться вопроса многомегабайтных сообщений об ошибках просто в случае некорректной программы: после некоторой практики это перестаёт быть проблемой, так как в подавляющем большинстве случаев лишь первая ошибка инстанцироания ведёт к каскаду дальнейших ошибок: отладка в таком случае ведётся «методом последовательных приближений».
Но, как бы парадоксально это ни звучало, что делать, если программа скомпилировалось успешно? Здесь уже автор более свободен в выборе методов отладки. Тип результата метафункций наподобие определенных в type_trairs
можно просто печатать в виде typeid(t).name()
(начиная с C++11 можно легально подсматривать в RTTI). Простые структуры данных можно выводить на экран специальными метафункциями с хвостовой рекурсией, для сложных придётся городить «принтеры», сопоставимые по сложности с операциями над самой структурой.
Библиотека деревьев содержит такой принтер и примеры его использования, читатель может ознакомиться с ними по ссылке на github в конце статьи. Вот, например, напечатанное дерево из примера выше:
/--{2} /--{3}--< \--{4} --{5}---< \--{7}--\ \--{8}
Высота
Посмотрим на рекурсивную функцию вычисления высоты бинарного дерева:
/// Вход: T - дерево, выход: h - высота
ВЫСОТА(T): ЕСЛИ T == NIL ВЫВОД 0 ИНАЧЕ ВЫВОД 1 + MAX(ВЫСОТА(T.LEFT), ВЫСОТА(T.RIGHT))
Она прекрасна. Просто берём и переносим эту функцию на C++:
/// Объявление общего шаблона
template
struct height;
/// Частичная специализация: "ЕСЛИ T == NIL"
template <>
struct height : std::integral_constant {};
/// Определение общего шаблона (рекурсивное)
template
struct height {
static constexpr size_t value = 1 + max(
height::value,
height::value
);
};
Примечание: мы сознательно пошли на небольшое упрощение, из-за которого вычисленная высота дерева будет на 1 больше её математического определения (высота пустого множества не определена). Читатель без труда сможет исправить при необходимости эту особенность, добавив ещё один уровень косвенности и явно вычитая 1 из результата вызова метафункции, однако тогда придётся запретить инстанцирование height
для пустого дерева.
Код достаточно прост: при попытке вызова height
с пустым множеством будет инстанцирована соответствующая специализация, содержащая статическое value = 0
, в противном случае продолжится каскад инстанцирований, пока мы не наткнёмся на пустое поддерево (то есть тот же самый NIL
), на котором рекурсия и остановится. Это характерная особенность реализации рекурсивных функций посредством шаблонов C++: мы обязаны специализировать дно рекурсии, иначе каскад инстанцирований либо не остановится (компилятор выдаст ошибку о превышении лимита вложенных инстанцирований), либо на одном из шагов произойдёт попытка доступа к несуществующему члену или значению в классе (без специализации описанная выше функция в определенный момент не смогла бы получить Tree::LT
, когда Tree
было бы равно пустому поддереву NIL
).В коде выше используется функция max
. Она должна быть constexpr
(или просто метафункцией, тогда её вызов немного изменится), пример простой и известной реализации:
template constexpr T max(T a, T b) { return a < b ? b : a; }
Наконец, использование функции
height
: static_assert(height::value == 3, "");
Скажем о «сложности» функции
height
: требуется n инстанцирований, где n — число узлов дерева; глубина инстанцирования равна h — т.е. высоте дерева.constexpr
-функций и длину variadic-списков аргументов.В документации к своему любимому компилятору читатель сможет найти конкретные значения: например, gcc-5.4 «из коробки» (без дополнительных опций) инстанцирует шаблоны не глубже 900 уровней. В свете вышесказанного это означает, что нельзя забывать об оптимизации метафункций (как бы дико это ни звучало). Например, вызов height
вполне может отказаться компилироваться gcc, если дерево имеет высоту > 900. Можно даже ввести O-нотацию для описания сложности (хотя часто можно точно посчитать число и глубину инстанцирований), и она будет иметь вполне здравый смысл.
Например, в стандарте C++14 планируется ввести шаблон для генерации целочисленных последовательностей (std::integer_sequence
): прямая рекурсивная реализация требует N инстанцирований для последовательности 0…N-1, однако существуют элегантные древоподобные решения, глубина рекурсии которых растёт со скоростью логарифма от длины последовательности. В конце концов, мы всегда можем реализовать любую функцию полным перебором, но компилятор этому точно не обрадуется (как и мы, час ждущие завершения компиляции 30-строчной программы или вынужденные читать 9000-страничные сообщения об ошибках).
Если превышение допустимой глубины инстанцирований может просто помешать компиляции, то количество инстанцирований (т.е. вызовов метафункций) прямо влияет на время компиляции: для сложных метапрограмм оно может оказать весьма велико. Здесь играет роль множество факторов (необходимость вывода типов, сопоставление частичных специализаций и т.д.), посему не стоит забывать, что компилятор — такая же программа, а механизм шаблонов — своего рода API к внутреннему кодогенератору компилятора, и пользоваться им надо учиться так же, как осваивать отдельный язык программирования — последовательно и аккуратно (и чтобы никаких «Курсов программирования для чайников за 24 часа»!).
Обход центрированный (in-order traversal)
Задача обхода — определенным образом сформировать список узлов (или данных из узлов, вопрос терминологии и имеет значение на практике). Центрированный (симметричный) обход — обход, при котором корень дерева занимает место между результатами соответствующих обходов левого и правого поддеревьев. Вместе со свойством бинарного дерево поиска (которое о неравенствах, см. теорию) это говорит о том, что центрированный обход бинарного дерева поиска сформирует нам отсортированный список узлов — круто! Вот как будет выглядеть обход определенного ранее дерева:
Рекурсивный алгоритм обхода довольно прост:
/// Вход: T - дерево, выход: w - список данных из узлов in-order обхода
INORDER(T): ЕСЛИ T == NIL ВЫВОД {} ИНАЧЕ ВЫВОД INORDER(T.LEFT) + {T.KEY} + INORDER(T.RIGHT)
Операция »+» в данном случае обозначает конкатенацию списков. Да-да, это именно то, ради чего мы реализовывали конкатенацию кортежей, так как кортежи — это наши списки типов на этапе компиляции. И снова — просто берём и пишем код:
/// Объявление общего шаблона
template
struct walk;
/// Частичная специализация: "ЕСЛИ T == NIL"
template <>
struct walk { using type = std::tuple<>; };
/// Определение общего шаблона (рекурсивное)
template
struct walk {
private:
// обход левого поддерева
using accL = typename walk::type;
// добавляем корень
using accX = tuple_push_t;
// конкатенируем с обходом правого поддерева
using accR = tuple_concat_t::type>;
public:
// результат
using type = accR;
};
В данном случае не более чем ради наглядности мы воспользовались локальными
private
псевдонимами: все псевдонимы можно подставить друг в друга и получить фарш, обфусцированный похлеще случайно сгенерированной строки, и даже отступы нас не спасут. Но мы же стараемся для людей, не так ли? Сложность функции walk
: O (n) инстанцирований, где n — число узлов дерева (O-нотация использована для упрощения: аккуратный подсчёт даст примерно 3n вызовов метафункций с учётом конкатенаций). Приятно, что функции tuple_concat
и tuple_push
выполняют свою работу за 1 инстанцирование, так как они не рекурсивны (благодаря возможности вывода типов parameter pack
'ов). Глубина инстанцирований, как и в случае height
, равна h — высоте дерева.
Ещё одно замечание по написанию метафункций: читатель, возможно, обратил внимание на то, что для последних двух функций нет необходимости разделять объявление и определение общего шаблона. Это так, однако я придерживаюсь стандартизированного подхода: «от максимально частного к максимально общему». Многие шаблоны целиком работают на частичных специализациях, и в таком случае очень удобно оказывается описывать эти специализации с возрастающим уровнем общности: мысленно программист проходит тот же путь, какой проходит компилятор в попытках инстанцирования и вывода типов.
Поиск
Поиск по ключу является основной операцией в классических бинарных деревьях поиска (название говорит само за себя). Мы решили не разделять ключ и данные, поэтому для сравнения узлов будем применять введённый сравнитель
Comp
. Рекурсивный алгоритм поиска: /// Вход: T - дерево, k - тип-ключ, /// выход: N - узел, содержащий тип k` == k в терминах сравнителя
SEARCH(T,k): ЕСЛИ T == NIL ИЛИ k == T.KEY ВЫВОД T ИНАЧЕ ЕСЛИ k < T.KEY ВЫВОД SEARCH(T.LEFT, k) ИНАЧЕ ВЫВОД SEARCH(T.RIGHT, k)
Реализация внешне похожа на разработанные ранее:
/// Объявление общего шаблона
template
struct search;
/// Специализация для пустого дерева
template
struct search { using type = NIL; };
/// Определение общего шаблона
template
struct search {
using Comp = typename Tree::comp;
using type = typename std::conditional<
Comp::template eq::value, // k == T.KEY ?
Tree, // поиск завершен, иначе:
typename std::conditional<
Comp::template lt::value, // k < T.KEY ?
typename search::type, // тогда ищем в левом
typename search::type // иначе -- в правом
>::type
>::type;
};
Выглядит несколько сложнее, это неспроста: во-первых, сам алгоритм содержит больше ветвлений (больше сравнений и условий), во-вторых, здесь мы уже должны применять определенное ранее отношение порядка, и на его основе продолжать поиск рекурсивно либо в левом, либо в правом поддереве. Обратим внимание на детали:
- Для сравнения типов используется сравнитель из корня дерева:
Tree::comp
, что логично: отношение порядка определяет как способ построения дерева, так и последующие операции (в том числе, поиск), вот почему было удобно поместить псевдоним сравнителя прямо внутрь шаблона дерева (node<>
). - Для доступа к шаблону, зависящему от аргумента шаблона (
Tree::comp::eq<...>
иTree::comp::lt<...>
), необходимо использовать ключевое словоtemplate
. - Мы пошли по пути наименьшего сопротивления и использовали
std::conditional
— стандартную метафункцию, определяющую результирующий псевдоним в зависимости от булевой переменной (такой аналог тернарного оператора ?: для типов). Почему это может быть не очень хорошо — см. далее, однако из положительных моментов — большая наглядность.
Сложность такой реализации
search
— опять-таки O (n) инстанцирований, глубина — h (высота дерева). «Стоп!» — воскликнет удивленный читатель, — «а как же логарифмическая сложность поиска и всё такое?»Тут-то и летят камни в сторону std::conditional
и выявляется его принципиальное отличие от оператора ?: : тернарный оператор не вычисляет то выражение, которое не будет его результатом (например, мы можем с чистой совестью разыменовывать нулевой указатель в одном из двух выражений, которое отбросится при проверке этого указателя в первом аргументе оператора). std::conditional
же инстанцирует все три аргумента (как обычная метафункция), именно поэтому одного только std::conditional
недостаточно для остановки рекурсии, и мы всё ещё должны специализировать дно рекурсии.
Прямой результат — лишние инстанцирования, захватывающие всё дерево целиком от корня до листьев. Особым колдунством с добавлением ещё одного уровня косвенности можно-таки «отключить» на каждом шаге рекурсии инстанцирования по пути поддерева, в котором точно нет искомого узла (написав ещё пачку специализаций для этого уровня косвенности), и добиться заветной сложности O (h), однако, на мой взгляд, это задача для более глубокого исследования и в данном случае будет являться преждевременной оптимизацией.
Примеры применения (использованы шаблоны псевдонимов, больше примеров см. в репозитории):
using found3 = search_t, num_comp>; // в пустом дереве
using found4 = search_t, num_comp>; // значение в корне
using found5 = search_t, num_comp>; // значение в листе
static_assert(std::is_same::value, ""); // не найдено
static_assert(std::is_same::value, ""); // само дерево
static_assert(std::is_same>>::value, ""); // лист
Это может показаться странным: мы ищем в дереве узел с типом… который фактически уже указан в качестве аргумента — зачем? На самом деле, ничего необычного в этом нет — мы ищем тип, равный аргументу в терминах сравнителя. Деревья STL (
std::map
) тоже хранят в узлах пары (std::pair
), и первый элемент пары считается ключом, который, собственно, и участвует в сравнениях. Достаточно хранить в нашем дереве ту же std::pair
и заставить сравнитель Comp
сравнивать пары по первому типу в паре — и получим классический ассоциативный (мета-)контейнер! Мы ещё вернёмся к этой идее в конце статьи.Вставка
Настало время научиться строить деревья с помощью метафункций (не для того же всё затевалось, чтобы рисовать деревья руками так, как мы это сделали ранее?). Наш рекурсивный алгоритм вставки будет создавать новое дерево:
/// Вход: T - дерево, k - тип-ключ для вставки, /// выход: T' - новое дерево со вставленным элементом
INSERT(T,k): ЕСЛИ T == NIL ВЫВОД {NIL, k, NIL} ИНАЧЕ ЕСЛИ k < T.KEY ВЫВОД {INSERT(T.LEFT,k), T.KEY, T.RIGHT} ИНАЧЕ ВЫВОД {T.LEFT, T.KEY, INSERT(T.RIGHT,k)}
Поясним его работу: если дерево, в которое происходит вставка, пусто, то вставляемый элемент создаст новое дерево {NIL, k, NIL}, т.е. просто лист с этим элементом (дно рекурсии). Если же дерево не пусто, то мы должны рекурсивно проваливаться до пустого дерева (т.е. до момента, пока левое или правое поддеревья не окажутся пустыми), и в итоге сформировать в этом поддереве тот же лист {NIL, k, NIL} вместо NIL, по пути «подвешивая» себя в виде нового левого или правого поддерева. В мире типов изменять существующие типы мы не можем, но можем создавать новые — это и происходит на каждом шаге рекурсии. Реализация:
template
struct insert;
/// Дно рекурсии
template
struct insert { using type = leaf; };
/// Основной шаблон
template
struct insert {
using type = typename std::conditional<
Comp::template lt::value, // k < T.KEY?
node::type,
typename Tree::RT,
Comp>,
node::type,
Comp>
>::type;
};
Для добавления элемента в пустое дерево надо явно указать компаратор
Comp
; если же дерево не пусто, компаратор берётся по-умолчанию из корня этого дерева*.Сложность такой вставки: O (n) инстанцирований (n — количество уже существующих узлов), глубина рекурсии равна h (h — высота дерева). Пример явного использования:
using t2 = leaf, num_comp>;
using t3 = insert_t>;
using t4 = insert_t>;
static_assert(height::value == 2, ""); // первые 2 уровня
using t5 = insert_t>, num_t<4>>, num_t<8>>;
static_assert(std::is_same::value, ""); // равно первоначальному дереву
В библиотеке есть реализация метафункции
insert_tuple
, позволяющая разом положить в дерево кортеж типов (под капотом это просто рекурсия insert
по кортежу), пример: using t6 = insert_tuple_t,
num_t<7>,
num_t<3>,
num_t<4>,
num_t<2>,
num_t<8>
>, num_comp>;
static_assert(std::is_same::value, "");
Обход в ширину (breadth-first traversal)
Обход в ширину бинарного дерева (или поиск в ширину из теории графов) формирует список узлов в порядке «по уровням» — сначала выводит корень, затем узлы с глубиной 1, затем с глубиной 2 и т.д. Алгоритм такого обхода использует очередь узлов для дальнейшего вывода (а не стек), поэтому он плохо поддаётся «конвертации» в рекурсивный. Под спойлером далее интересующийся читатель найдёт обходное решение. Здесь отметим лишь тот полезный факт, что «разобранное» обходом в ширину дерево может быть собрано обратно в точно такое же последовательной вставкой элементов из кортежа результата обхода. На рисунке предствлен обход в ширину нашего тестового дерева: