Двоичное дерево: Построение и визуализация

Модуль "Деревья" • урок 6 из 11 • ~15 минут

Суть урока. Изучаем процесс построения бинарного дерева в памяти на языке C. Разбираем выделение памяти под узлы, связывание их указателями и визуальное представление получившейся структуры.
Двоичное дерево: Построение и визуализация - вставка числа 6 спуском от корня и печать дерева боком
Спуск по сравнениям находит пустое место, а в консоли то же дерево лежит набок

Двоичное дерево: построение и визуализация в языке C

Дерево строится динамически. Это значит, что под каждый новый узел мы запрашиваем у операционной системы блок памяти с помощью функции malloc. После этого мы прописываем туда данные, а оба указателя обнуляем, так как новый узел изначально всегда является листом.

В зависимости от задачи, алгоритм вставки может различаться. Чаще всего в программировании используют Дерево поиска (BST), в котором соблюдается правило: все элементы меньше корня идут в левое поддерево, а все элементы больше или равные - в правое. Вот как выглядит рекурсивный код такой вставки:

Построение дерева: код целиком C, 32 строки
#include <stdlib.h>

// Вспомогательная функция создания нового узла
struct node* create_node(int value) {
    struct node* p = (struct node*)malloc(sizeof(struct node));
    if (p != NULL) {
        p->key = value;
        p->left = NULL;
        p->right = NULL;
    }
    return p;
}

// Рекурсивная вставка в двоичное дерево поиска
struct node* insert(struct node* root, int value) {
    // Базовый случай: нашли пустое место, вставляем узел
    if (root == NULL) {
        return create_node(value);
    }
    
    // Рекурсивный спуск
    if (value < root->key) {
        // Идем налево и обновляем левый указатель
        root->left = insert(root->left, value);
    } else {
        // Идем направо и обновляем правый указатель
        root->right = insert(root->right, value);
    }
    
    // Возвращаем измененный корень поддерева обратно вверх
    return root;
}
А как в C++ и Python? В стандартной библиотеке C++ (STL) древовидные структуры скрыты внутри контейнеров (std::set и std::map). Программисту не нужно вручную выделять память. В Python дерево обычно моделируют объектами (class Node: ...). За счет сборщика мусора и отсутствия явных указателей код выглядит чище, но суть остается та же: узлы динамически порождаются и связываются друг с другом.

Визуализация в консоли

При отладке очень важно видеть форму дерева. Так как консоль плоская, мы можем рисовать дерево сбоку (повернутым на 90 градусов), используя рекурсивный обход и передавая текущий уровень глубины. Уровень задает отступ, а сами ветви рисуются псевдографикой: / ведет к правому сыну, \ - к левому, а | продолжает ветвь предка, через которую мы прошли насквозь.

Отступ узла складывается из отступа его отца плюс четыре знака: либо четыре пробела, либо палочка и три пробела. Эту накопленную строку удобно передавать в рекурсию готовым префиксом. Корень тут исключение: своей ветки у него нет, поэтому его ключ просто прижимается вправо к линии ствола форматом %2d. Однозначное число встанет на знак левее ствола, двузначное - вплотную к нему, и ни то, ни другое на ствол не наедет.

Визуализация дерева в консоли: код целиком C, 38 строк
#include <stdio.h>
#include <string.h>

// Вывод дерева боком: правое поддерево сверху, левое снизу.
// prefix - столбик отступов и палочек, накопленный предками узла.
void print_tree(struct node* root, const char* prefix, int level, int is_right) {
    if (root == NULL) return;

    char child[128];

    // Правое поддерево уходит вверх
    snprintf(child, sizeof child, "%s%s", prefix,
             level == 0 ? "  " : (is_right ? "    " : "|   "));
    print_tree(root->right, child, level + 1, 1);

    // Ключ корня прижат вправо к линии ствола, у остальных узлов есть ветка
    if (level == 0) {
        printf("%2d\n", root->key);
    } else {
        printf("%s%s-- %d\n", prefix, is_right ? "/" : "\\", root->key);
    }

    // Левое поддерево уходит вниз
    snprintf(child, sizeof child, "%s%s", prefix,
             level == 0 ? "  " : (is_right ? "|   " : "    "));
    print_tree(root->left, child, level + 1, 0);
}

// Использование: print_tree(root, "", 0, 1);
//
// Для дерева 8 с детьми 3 и 10, у которых свои листья, вывод такой:
//       /-- 12
//   /-- 10
//   |   \-- 9
//  8
//   |   /-- 6
//   \-- 3
//       \-- 1

Коротко: главное

  • Узел дерева моделируется структурой из информационного поля и двух ссылок на детей.
  • Дерево растет динамически. Под каждый узел выделяется память, а новые узлы (листья) всегда инициализируются с NULL ссылками.
  • Связь узлов осуществляется через возвращение обновленного указателя из рекурсии (либо через двойную косвенность **).
  • Визуализация дерева в терминале строится на базе обхода с передачей накопленного префикса: отступ задает уровень, а палочки /, | и \ рисуют ветви.

Проверьте себя

Чему равны указатели left и right у свежесозданного узла (до того как к нему добавят детей)?

Как вам урок? Спасибо за отзыв!