Двоичным деревьям поиска посвящено множество глав учебников и статей (например, раз, два, три), но они все концентрируются на общих идеях и поддержании баланса для сохранения эффективности. Методы неточного поиска, без которых двоичное дерево вообще не имеет преимущества перед реализациями без упорядочения ключей (как хэш-таблица), обычно не расписываются даже в учебниках. А в них есть интересные моменты... Сравним стили реализации такого поиска:

 (*1) Здесь при отсутствии в дереве элемента с ключом, равным искомому, генерируется исключение. Непонятно, зачем так сделано — можно легко убрать это ограничение без потери функциональности.
(*2) Методы называются в стиле TryWeakSuccessor, FindMin.
(*3)Присутствуют только те методы, которые подходят и для других видов контейнеров STL. Но так как итераторы позволяют дёшево переходить к соседнему элементу, отсутствие части методов может быть компенсировано не выходя за пределы O(N log N). Посмотрим на реализации на примере lower_bound и upper_bound (они же ceilingEntry и higherEntry, они же Гоша и Магоша... В сравнении опустим вопросы стиля, такие, как возврат null или возбуждение исключения, как возвращается значение (по ссылке или напрямую) и т.п. Реализация в C5. Удобно начать с неё, как с самой интуитивно понятной вследствие стиля, в котором обычно учат теме алгоритмов. Файл C5/Trees/TreeBag.cs, метод TreeBag<T>.TryWeakSuccessor(). Начиная с корня, проверяем значение ключа в текущем рассматриваемом узле.

 Промежуточные кандидаты, естественно, запоминаются. Чуть менее очевидно (подумать пару секунд;)) — что не надо проверять, что следующий найденный кандидат будет лучше предыдущего (ключ будет строго меньше предыдущего). Пример: ищем по ключу 21.

 Существенно: компаратор выдаёт три варианта (меньше, равно, больше). Обычно это экономнее компаратора с выдачей только «меньше» или «не меньше»: если мы вообще добрались до сравнения элементов, почему не сделать это полностью? Поиск для строго большего значения — тот же файл: Что происходит, если мы нашли точное совпадение ключа? Включается спецобработка: мы от узла с точным совпадением ищем правую подветку, а в ней — самый маленький ключ (для этого достаточно идти вниз по левым подветкам, пока есть куда). Так как в результате такого поиска оказывается cursor == null, это становится последней итерацией поиска (не нужно делать отдельный return). Пример: ищем по ключу 20.

 Можно ли было обойтись без отдельной обработки такого случая? Да, можно было, увидев совпадение ключа, просто пойти на правую подветку. Основной цикл точно так же выполнялся бы, получая comp > 0 на каждой итерации, пока cursor != null. То есть, если заменить проверку «comp > 0» на «comp >= 0» и выкинуть весь код с «comp == 0», принципиальная работоспособность сохранилась бы.  Как искалось бы в случае того же 20?

 Репозиторий. Код метода CPYTHON_ABCTree.ceiling_item(): Продвигаясь по дереву от корня (как обычно, это вверх или вниз?):

 Замечания:

 Похожий код для поиска строго большего ключа: Здесь логика чуть посложнее. Те же методы в случае, если ключ текущей вершины отличается от искомого; но что происходит, если мы попали на точное совпадение? Мы выходим из первого цикла, а дальше начинается тот же фокус, что в C5 — мы уходим на правую подветку и находим в ней минимальный узел. Ещё можно заметить, что если ключа с искомым значением нет в дереве, то будет сгенерировано исключение. Например, в дереве минимальный ключ 5, а мы ищем начиная от 4… будет исключение на первом «stay at dead end». Аналогично для промежуточных значений ключей. Смысл этого ограничения непонятен; никакая из других рассмотренных реализаций не делает такого. Можно было бы без потери функциональности удалить весь второй цикл, подправив сравнение в первом цикле и возвращая параметры succ_node, если она есть. Вдогонку отметим, что данная реализация bintrees как перенос подхода компилируемых языков оказалась неожиданно медленной по сравнению с SortedDict в Sorted Containers со всего двухуровневым хранилищем на плоских списках и дополнительным поддеревом для поиска на верхнем уровне. Но это уже специфика затрат рантайма на структуры данных Python и не портит идею двоичных деревьев per se. исходник stl_tree.h: Метод вызывается при x указывающем на корень дерева. В отличие от предыдущих, отдельно условие равенство ключей не отрабатывается. При точном совпадении (compare даёт false) запоминаем полученное в y (в C5 оно названо лучше — bestsofar, да и succ_node из bintrees тоже даёт больше конкретики), но равно идём на продолжение (на подветку left), где гарантированно ничего не найдём, потому что там все ключи строго меньше... Пример: ищем по ключу 30.
 — key(x) == 40: идём на левую подветку, запомнили 40.
 — key(x) == 30: идём на левую подветку, запомнили 30.
 — key(x) == 25: идём на левую подветку (nullptr).
 — x == nullptr: остановились. Поиск строго большего значения отличается ненамного, и интересно сравнить, как это выглядит в его реализации: Отличие только в условиях сравнения — всё остальное 1:1. Если для _M_lower_bound переход на левую подветку с запоминанием узла как кандидата — выполнялся при key(x) >= k, то для _M_upper_bound — при key(x) > k. Характер сравнения замутнён тем, что _M_key_compare(a,b) выдаёт true при a<b, false в остальных случаях (см. std::less); авторы предпочли инвертировать условие в if, но сохранить идентичным код веток. Пример: ищем по ключу 20.

 Если бы над этим кодом работал детектив, он бы предположил, что сначала был написан _M_upper_bound, для которого подобная реализация предельно естественна и не несёт заметных лишних затрат, а затем _M_lower_bound был для однообразности «подточен» под максимальное совпадение по строчкам кода. Мы заметим, что это не обязательно так, но оно вполне напрашивается;)) Главный вопрос — стоит ли оптимизация «если нашли точный ключ, не ищем дальше по дереву» затрат на два сравнения на каждый пройденный узел вместо одного? Clang STL — код 1:1 с GCCʼшным, с точностью до стиля (но порядок тел после if совпадает с GCCʼшным;)) Похоже, на идею реализации влияет принципиальный дизайн STL, что компараторы (в отличие от принятого в Java, C#) проверяют только на «меньше», но не выдают три варианта (меньше/равно/больше)? Всё равно O(n log n) выполняется, так чего ж? Реализация в TreeMap использует странный вариант (один и тот же в версиях 6..13): Здесь появляется очень странная реакция на граничный случай «хотел пойти вправо вниз, но некуда». Посмотрим, что происходит в нашем дереве при поиске по значению 39.

 Похожий приём применён в higherEntry: Разница: аварийный раскрут обратно к корню включается и нахождении узла с равным ключом без правой подветки. Прошлый пример с поиском по 39 остаётся в силе, но чтобы увидеть поведение при совпадении, можно заменить искомое на 35. Изумление по сравнению с прошлыми вариантами вызывает сама необходимость такого раскрута обратно вверх по дереву (остановимся, как обычно тут, что дерево у нас инопланетное и растёт вниз). Пока мы шли по нему от корня к листьям, мы же получали все необходимые узлы — так зачем возвращаться? Сэкономили одну переменную? В самих файлах авторы не указаны. Прежде чем задавать странные вопросы на StackExchange и тому подобных местах, хочется поговорить без возможных авторов, на взгляде со стороны и в более новых условиях :) Разумеется, все рассмотренные методы работают, несмотря на разнообразие подходов и видимых целей оптимизаций (странно было бы предполагать иное для столь популярных средств). Алгоритмическую корректность проверить легко — каждый может это сделать сам. Но я хотел поделиться тем, как даже в столь вроде бы простой задаче есть место пусть странному, но творчеству. Хочется думать, что ответом на вопрос «почему???» было бы «мы сравнили производительность 100500 вариантов на тестовых данных». Тогда насколько этот результат актуален через 10-15 лет развития процессоров, рантаймов, ОС и компиляторов? (Не обещаю, но долгими зимними вечерами можно и измерить самому...) Программист (backend/сети)