Powered By Blogger

Saturday, November 5, 2011

Linux - работа с динамической памятью в ядре

К вящему сожалению разработчиков ядра выделение памяти в режиме ядра происходит не так просто, как в пользовательском приложении. Такая ситуация сложилась среди прочего по следующим причинам:

  • Доступное ядру пространство ограничено 1Гб виртуальной и физической памяти.
  • Память ядра не выгружается.
  • Часто ядро требует физически непрерывных регионов памяти.
  • Зачастую ядро должно выделять память, не засыпая.
  • Ошибки в коде ядра обходятся куда дороже, чем где бы то ни было.

Тем не менее, выделение памяти в ядре не такая уж роскошь и понимание некоторых тонкостей механизма выделения и работы с памятью в режиме ядра может сделать работу с памятью легче.

Общий интерфейс

Общий интерфейс для работы с динамической памятью в ядре представлен функцией kmalloc():

#include <linux/slab.h>

void * kmalloc(size_t size, int flags);

Эта функция очень похожа на старую добрую malloc() из стандартной библиотеки С, используемой пользовательскими приложениями. Исключение составляет то, что kmalloc() принимает 2 аргумента. Второй аргумент - флаги. Пока забудем про флаги. Первый аргумент - размер блока памяти - одинаково измеряется в байтах, как для malloc(), так и для kmalloc(). В случае успеха kmalloc() возвратит указатель на блок памяти запрошенного размера. Выравнивание выделенного блока памяти удобно для доступа и хранения объектов любого типа. Как и malloc(), kmalloc() может завершиться неудачей и поэтому возвращаемое значение должно проверяться на NULL:

struct falcon *p;

p = kmalloc(sizeof (struct falcon), GFP_KERNEL);
if (!p)
        /* запрос завершился неудачей - обрабатываем ошибку выделения */

Флаги

Флаги управляют процессом выделения памяти, определяют поведение функции kmalloc(). Флаги можно условно разбить на 3 группы: модифицирующие собственно поведение; определяющие зону, откуда будет выделена память; тип выделения. Флаги, модифицирующие поведение kmalloc(), говорят ядру, как оно должно себя вести при выделении блока памяти. Например, может ли ядро приостановить выполнение вызывающего потока при выделении памяти (т.е., может ли вызов kmalloc() блокировать выполнение), чтобы удовлетворить запрос. Флаги зоны говорят, откуда именно должна быть выделена память. Например, блок памяти должен быть доступен аппаратному обеспечению - периферийным устройствам - через механизм прямого доступа к памяти (DMA). Наконец, флаги типа определяют тип выделения памяти. Флаги могут комбинироваться. Вместо указания нескольких флагов можно передать в качестве второго аргумента готовую, предопределённую комбинацию для типичных случаев.

В таблице 1 приводятся модификаторы поведения kmalloc(), а в таблице 2 - модификаторы зоны выделения. Могут быть использованы несколько флагов; выделение памяти в ядре нетривиальная задача. Флаги позволяют управлять многими аспектами выделения памяти в ядре. Ваш код должен использовать флаги типов, а не отдельные модификаторы поведения и модификаторы зон. Два наиболее часто используемых флага - GFP_ATOMIC и GFP_KERNEL. Почти весь код должен использовать один из этих двух модификаторов.

Таблица 1. Управление выделением памяти (поведение ядра при выделении блока)
ФлагОписание
__GFP_WAITЕсли для удовлетворения запроса недостаточно страниц, ядро блокирует вызывающий поток до появления свободных страниц.
__GFP_HIGHРазрешения запросов к зарезервированным пулам - высокоприоритетный запрос (не путать с __GFP_HIGHMEM).
__GFP_IOРазрешение на ввод-вывод при дефиците свободных страниц.
__GFP_FSРазрешение операций файловой системы (VFS). Данный флаг не должен использоваться в коде, как-либо связанном с VFS, т.к. его использование может привести к бесконечной рекурсии.
__GFP_COLDРазрешение на использование "холодных" страниц.
__GFP_NOWARNНе выводить предупреждения в случае неудачи.
__GFP_REPEATПовторять попытки удовлетворить запрос на выделение до успеха.
__GFP_NOFAILТо же, что __GFP_REPEAT.
__GFP_NORETRYНе повторять запросы в случае неудачи.
__GFP_COMPФрейм принадлежит расширенной странице памяти.
__GFP_ZEROСтраница будет обнулена при успешном выделении памяти.
__GFP_NOMEMALLOCНе использовать резервные пулы.
__GFP_HARDWALLДанный флаг имеет смысл только для SMP-систем с NUMA (не 80x86 - Alpha, MIPS) и предписывает выделение страниц только из узла, принадлежащего данному процессору, который в свою очередь присвоен данному потоку.
__GFP_THISNODEКак и предыдущий флаг, этот имеет смысл только для NUMA-систем и предписывает использовать выделение только из текущего узла памяти, запрещая обращения к другим узлам, либо узел, из которого должна быть выделена память необходимо задать явным образом.
__GFP_RECLAIMABLEСтраница памяти может быть возвращена системе.
__GFP_MOVABLEСтраница может перемещаться механизмом миграции.
Таблица 2. Модификаторы зон выделения
ФлагОписание
__GFP_DMAПамять будет выделена из зоны DMA - первые 16Мб нижней памяти, к которым могут получить прямой доступ старые ISA-устройства.
флаг не заданПамять может быть выделена из любой зоны.

Флаг GFP_ATOMIC уведомляет аллокатор, что запрос не должен блокировать. Данный флаг должен быть использован там, где поток не должен спать - например, в обработчике прерывания, обработчике нижней половины или в контексте потока, который держит блокировку. В силу того, что ядро не может блокировать вызывающий поток, такой запрос имеет меньше шансов на успех, ведь ядро не может приостановив поток, освободить дополнительную память, если памяти недостаточно для выполнения запроса. Запросы с флагом GFP_ATOMIC имеют меньший шанс на успех, нежели иные. Тем не менее, если вызывающий поток не должен спать ни при каких обстоятельствах, GFP_ATOMIC - единственно доступный вам тип запроса. Использование флага GFP_ATOMIC очень простое:

struct wolf *p;

p = kmalloc(sizeof (struct wolf), GFP_ATOMIC);
if (!p)
        /* error */

Флаг GFP_KERNEL определяет обычный запрос памяти. Используйте этот флаг в контексте потока, который не держит никаких блокировок. kmalloc(), вызванная с этим флагом, может блокировать выполнение вызывающего потока; поэтому вы должны использовать этот флаг только в тех случаях, когда это полностью безопасно. Ядро может использовать блокировку вызывающего потока для того, чтобы освободить память, если это необходимо для удовлетворения запроса. По этой причине запросы с флагом GFP_KERNEL статистически имеют больший шанс на успех. Так, например, если недостаточно памяти, ядро может приостановить наш потом, выгрузить неактивные страницы на диск, урезать кэши ядра, сбросить на диск содержимое буферов и т.д..

В некоторых случаях, как например при создании драйвера ISA-устройства, вам может понадобиться обеспечить выделение памяти, которая была бы пригодна для операций прямого доступа (DMA). Для ISA-устройств диапазон такой памяти лежит в пределах первых 16Мб физической памяти. Чтобы обеспечить выделени блока памяти, соответствующего требованиям DMA, используйте флаг GFP_DMA. В общем, этот флаг вы будете использовать скорее всего либо в сочатении с GFP_ATOMIC, либо с GFP_KERNEL; флаги могут объединяться с помощью логического "ИЛИ". Например, чтобы проинструктировать ядро, что вам необходим блок памяти из зоны DMA и ваш поток может уснуть, вы можете использовать следующий код:

char *buf;

/* нам нужен блок памяти для работы через DMA,
 * и вызывающий поток может при необходимости спать */
buf = kmalloc(BUF_LEN, GFP_DMA | GFP_KERNEL);
if (!buf)
        /* error */

В таблице 3 приводятся флаги типов, а в таблице 4 показывается, какие флаги входят в тот или иной тип. Все эти флаги определяются в заголовочном файле <linux/gfp.h>.

Таблица 3. Типы
ФлагОписание
GFP_ATOMICЗапрос на выделение памяти имеет высокий приоритет и не должен блокировать. Этот флаг полезен при запросе блока памяти из обработчика прерывания, обработчика нижней половины или в любой другой ситуации, когда поток не должен блокироваться.
GFP_NOIOЗапрос может блокировать вызывающий поток, но при этом не инициируются операции блочного ввода-вывода. Этот флаг полезен при использовании в контексте работы с блочным вводом-выводом.
GFP_NOFSЗапрос может блокировать поток, блочный ввод-вывод разрешён, но операции файловой системы (VFS) запрещены. Этот флаг полезен при использовании в коде VFS, когда нежелательны параллельные операции на файловых системах.
GFP_KERNELЭто обычный запрос, который может блокировать поток. Данный флаг используется при выделении памяти в контексте потока, который может заснуть.
GFP_TEMPORARYВременный блок памяти.
GFP_USERОбычный запрос на выделение памяти, который может блокировать. Данный флаг используется с целью выделения памяти для пользовательских процессов.
GFP_HIGHUSERМодификация предыдущего флага, которая предписывает kmalloc() выделить память по просьбе пользовательского процесса. Разница в том, что при использовании этого флага блок будет выделен из верхней памяти.
GFP_DMAЗапрос на выделение памяти из зоны DMA - памяти, поддерживающей прямой доступ. Используется в драйверах устройств, поддерживающих DMA.
GFP_DMA32То же, что выше, но с расширенным пространством памяти DMA (до 4Гб).
Таблица 4. Комибинированные флаги
ФлагОписание
GFP_ATOMIC(__GFP_HIGH)
GFP_NOIO(__GFP_WAIT)
GFP_NOFS(__GFP_WAIT | __GFP_IO)
GFP_KERNEL(__GFP_WAIT | __GFP_IO | __GFP_FS)
GFP_TEMPORARY(__GFP_WAIT | __GFP_IO | __GFP_FS | __GFP_RECLAIMABLE)
GFP_USER(__GFP_WAIT | __GFP_IO | __GFP_FS | __GFP_HARDWALL)
GFP_HIGHUSER(__GFP_WAIT | __GFP_IO | __GFP_FS | __GFP_HARDWALL | __GFP_HIGHMEM)
GFP_DMA__GFP_DMA
GFP_DMA32__GFP_DMA32
GFP_THISNODE(__GFP_THISNODE | __GFP_NOWARN | __GFP_NORETRY)

Освобождение памяти

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

#include <linux/slab.h>

void kfree(const void *objp);

Использование kfree() аналогично использованию её пользовательского аналога. Допустим, p указатель на блок памяти, ранее выделенный с помощью kmalloc(). Тогда следующий код приведёт к освобождению блока и его возврату ядру:

kfree(p);

Как и в случае с free() вызов kfree() на блоке, который ранее был уже освобождён или на адресе, не ссылающемся на начало блока, выделенного с помощью kmalloc() приведёт к ошибке и, вероятно, порче памяти. Следите за выделением и освобождением блоков памяти, чтобы гарантировать вызов kfree() на правильном блоке памяти. Вызов kfree() с NULL в качестве аргумента проверяется отдельно и поэтому такой случай безопасен.

Давайте рассмотрим полный цикл работы с памятью, включающий как выделени блока, так и его освобождени:

struct sausage *s;

s = kmalloc(sizeof (struct sausage), GFP_KERNEL);
if (!s)
        return -ENOMEM;
/* ... */

kfree(s);

Выделение виртуальной памяти

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

Однако, при выделении непрерывных блоков возникает одна проблема: часто найти такой непрерывный блок памяти, в особенности, если речь идёт о большом блоке, сложно. Выделение памяти, которая непрерывна по диапазону виртуальных адресов в таких случаях имеет большее приимущество. Если у вас нет необходимости в непрерывном блоке, используйте vmalloc():

#include <linux/vmalloc.h>

void * vmalloc(unsigned long size);

Память, выделенная с помощью vmalloc(), возвращается функцией vfree():

#include <linux/vmalloc.h>

void vfree(void *addr);

Так же, как и с kmalloc()/kfree(), использование vfree() аналогично пользовательской функции free():

struct black_bear *p;

p = vmalloc(sizeof (struct black_bear));
if (!p)
        /* error */

/* ... */

vfree(p);

vmalloc() может блокировать вызывающий поток.

Во многих местах в ядре возможно использование vmalloc(), т.к. лишь немногие случаи требуют физически непрерывных блоков. Если вам нужно выделить блок памяти, с которым не будет работать какое-либо устройство и этот блок будет использоваться исключительно программным кодом - например, как в случае с данными, присвоенными пользовательскому процессу, необходимости в физически непрерывном регионе памяти нет. Тем не менее, в ядре vmalloc() используется не так часто. Большая часть кода использует kmalloc(), даже если это не так необходимо. Частично такая ситуация сложилась исторически, но отчасти это делается в интересах обеспечения производительности, т.к. использование TLB сокращается значительно. Не смотря на это, если вам нужно выделить блок размером в десятки мегабайт в режиме ядра, vmalloc() будет лучшим выбором.

Фиксированный стек небольшого размера

В отличие от пользовательских процессов, код ядра не располагает расширяющимся стеком большого размера. Каждый поток имеет небольшой стек фиксированного размера. Точный размер стека зависит от архитектуры. В основном под него отводятся 2 страницы, т.о. на 32-битных машинах стек имеет размер 8Кб.

Т.к. размер стека ограничен, автоматические выделения больших блоков памяти в стеке крайне нежелательны. Вы не должны увидеть в коде ядра нечто подобное:

#define BUF_LEN 2048

void rabbit_function(void)
{
        char buf [BUF_LEN];
        /* ...  */
}

Вместо этого предпочтительно делать так:

#define BUF_LEN 2048

void rabbit_function (void)
{
        char *buf;

        buf = kmalloc(BUF_LEN, GFP_KERNEL);
        if (!buf)
                /* error! */

        /* ... */
}

И наоборот, вы редко увидите такое в пользовательском пространстве, потому что заниматься динамическим выделением памяти в условиях, когда необходимый объём памяти известен в момент написания кода и она может быть выделена автоматически, не очень разумно. Но в ядре вы должны использовать динамическое выделение памяти всегда, когда требуемый объём памяти более или менее велик. Это позволяет избежать ошибок переполнения стека, которые в состоянии принести массу неприятных сюрпризов.

Кое-что ещё о kmalloc() & Co

Здесь мы коснёмся не столько интерфейса для работы с динамически выделяемой памятью, сколько с позволения сказать самих потрохов этого интерфейса. Ни в коем случае не стоит рассматривать этот раздел, как исчерпывающий источник по двум причинам: во-первых, код ядра всё-таки динамичен - что-то меняется, что-то добавляется, что-то со временем удаляется за ненадобностью. Это закономерный процесс. И хотя интерфейс работы с памятью, как таковой, едва ли может быть подвержен радикальным изменениям, то, что он скрывает, всё же может и будет меняться. В этом плане самый лучший источник - это первоисточник, т.е. сам код ядра. Во-вторых, подсистема управления памятью очень сложна. Не стоит недооценивать её сложность. Такая простая функция, как kmalloc() скрывает сотни строк кода для работы с кэшами, выделением страниц и ещё всякого разного и это не говоря о более низкоуровневом коде. О подсистеме управления памяти можно писать целые главы книг, а может даже и посвятить отдельную книгу. Естественно, в таком свете информация, приводимая здесь, будет лишь самой общей.

На самом деле, в ядрах версии 2.6.30 kmalloc() определяется в include/linux/slab_def.h и там же находится тело этой функции. kmalloc() определена, как встраиваемая. Иначе говоря, тело kmalloc() всегда подставляется в вызывающий код - нечто похожее на разворачивание макроса. Несмотря на то, что на первый взгляд работа kmalloc() прозрачна, эта функция скрывает за собой массу кода по работе с кэшами, трюки по оптимизации скорости выделения памяти и, конечно, она далеко не так проста, как может показаться на первый взгляд. Посмотрим на её код:

static __always_inline void *kmalloc(size_t size, gfp_t flags)
{
        struct kmem_cache *cachep;
        void *ret;

        if (__builtin_constant_p(size)) {
                int i = 0;

                if (!size)
                        return ZERO_SIZE_PTR;

#define CACHE(x) \
                if (size <= x) \
                        goto found; \
                else \
                        i++;
#include <linux/kmalloc_sizes.h>
#undef CACHE
                return NULL;
found:
#ifdef CONFIG_ZONE_DMA
                if (flags & GFP_DMA)
                        cachep = malloc_sizes[i].cs_dmacachep;
                else
#endif
                        cachep = malloc_sizes[i].cs_cachep;

                ret = kmem_cache_alloc_notrace(cachep, flags);

                trace_kmalloc(_THIS_IP_, ret,
                      size, slab_buffer_size(cachep), flags);

                return ret;
        }
        return __kmalloc(size, flags);
}

Если пояснить этот код буквально парой фраз, то сразу видно, что реальная работа в некоторых случаях производится не самой kmalloc(), а __kmalloc(). Не во всех, а только в некоторых? Но в каких именно? kmalloc() пытается определить, является ли параметр size константой, известной на момент комплиляции кода. Т.е., не вычисляется ли её значение в ходе выполнения кода. Если size константа, то мы можем ускорить выделение блока памяти заранее известного размера. Как? Да очень просто: память будет выделена из кэша для объектов соответствующего размера, но кэш для объектов подходящего размера не будет искаться в процессе исполнения кода. Он уже найден на момент компиляции кода. Здорово, правда? Если же size не является константой, а вычисляется по ходу дела, в игру вступает __kmalloc(). Что делает __kmalloc()? Самое смешное - ничего, кроме того, что вызывает __do_kmalloc(), передавая последней те аргументы, которые получила сама от kmalloc(). Вы поинтересуетесь, к чему все эти матрёшки? Отвечу, что в природе всё не просто так и хоть есть известная шутка о том, что ядро linux пишут под порывом вдохновения (а hurd - под удары шаманского бубна), но здесь есть нечто рациональное. __do_kmalloc() принимает 3 аргумента вместо 2. Третий аргумент при вызове из __kmalloc() - NULL. Это указатель на вызывающую функцию, который может быть использован при отладке для трассировки запросов на выделение памяти. Таким образом, __kmalloc() - это просто обёртка, которая в зависимости от конфигурации ядра (отладочная или "production") просто позволяет осуществлять трассировку. Что ещё делает __do_kmalloc()? Исходя из сказанного выше, методом от противного нетрудно сделать вывод - она ищет подходящий кэш для объекта, если размер этого объекта (блока памяти) неизвестен на этапе компиляции. Что касается аргументов, с которыми работают функции __kmalloc() и __do_kmalloc(), то они полностью соответствуют оным для kmalloc().

Помимо нашей старой знакомой kmalloc() ядро предоставляет аналогичную функцию для выделения памяти из нелокального, явно заданного узла памяти. В начале статьи мы вскользь упоминали о NUMA-системах, в которых доступ к памяти неоднороден по скорости. Иными словами, в NUMA-системах память делится на зоны - участки памяти, к которым процессор обращается за определённое время. Возможно, это несколько непривычно, но как было упомянуто, есть архитектуры, где доступ к разным участкам физической памяти может занимать разное время. Ядро пытается минимизировать накладные расходы при работе на такой архитектуре и поэтому память делится на так называемые узлы - зоны, в пределах которых данный процессор может одинаково быстро получить доступ к памяти. Мы не будем подробно останавливаться на рассмотрении и описании всех тонкостей и особенностей NUMA и лишь упомянем, что для работы на NUMA-системах предусмотрена функция kmalloc_node(). Она принимает 3 аргумента - первые два, как обычная kmаlloc() и третий - целочисленный - номер узла памяти, откуда должна быть выделена память. В ядре, собранном без поддержки NUMA, kmalloc_node() вызывает обычную __kmalloc(), иначе по аналогии с kmalloc() работа разделяется между аналогичными функциями __*kmalloc_node(). kmalloc_node() определена в include/linux/slab_def.h, как и kmalloc(). Обратите внимание, что для освобождения памяти из нелокальных узлов не предусмотрено симметричной функции kfree_node(), что представляется вполне логичным, если учесть, что аллокатор располагает необходимой информацией о блоке памяти на момент его освобождения и в явном указании узла нет абсолютно никакой необходимости и смысла.

Чтобы завершить наше повествование об интерфейсе работы с динамической памятью упомянем ещё несколько функций, которые являются прямыми аналогами пользовательских: kcalloc(), krealloc() и вспомогательных функциях kzfree(), ksize() и kzalloc().

static inline void *kcalloc(size_t n, size_t size, gfp_t flags)
{
        if (size != 0 && n > ULONG_MAX / size)
                return NULL;
        return __kmalloc(n * size, flags | __GFP_ZERO);
}

kcalloc() очевидный и прямой аналог пользовательской функции calloc() - выделяет память под n элементов, каждый из которых имеет размер size. Выделенный блок памяти обнуляется.

void * __must_check krealloc(const void *, size_t, gfp_t);

Как и пользовательский эквивалент - realloc(), krealloc() изменяет размер ранее выделенного блока памяти. Первый аргумент - указатель на начало ранее выделенного блока, размер которого нужно изменить. Далее - новый размер блока и флаги. Примечательно, что krealloc() в действительности не предпринимает никаких действий, если новый размер блока меньше или равен прежнему. Если новый размер блока превышает старый, krealloc() выделяет новый блок памяти затребованного размера, освобождает старый и возвращает указатель на новый блок. Вот как выглядит реализация krealloc() из mm/util.c:

/**
 * __krealloc - like krealloc() but don't free @p.
 * @p: object to reallocate memory for.
 * @new_size: how many bytes of memory are required.
 * @flags: the type of memory to allocate.
 *
 * This function is like krealloc() except it never frees the originally
 * allocated buffer. Use this if you don't want to free the buffer immediately
 * like, for example, with RCU.
 */
void *__krealloc(const void *p, size_t new_size, gfp_t flags)
{
        void *ret;
        size_t ks = 0;

        if (unlikely(!new_size))
                return ZERO_SIZE_PTR;

        if (p)
                ks = ksize(p);

        if (ks >= new_size)
                return (void *)p;

        ret = kmalloc_track_caller(new_size, flags);
        if (ret && p)
                memcpy(ret, p, ks);

        return ret;
}
EXPORT_SYMBOL(__krealloc);

/**
 * krealloc - reallocate memory. The contents will remain unchanged.
 * @p: object to reallocate memory for.
 * @new_size: how many bytes of memory are required.
 * @flags: the type of memory to allocate.
 *
 * The contents of the object pointed to are preserved up to the
 * lesser of the new and old sizes.  If @p is %NULL, krealloc()
 * behaves exactly like kmalloc().  If @size is 0 and @p is not a
 * %NULL pointer, the object pointed to is freed.
 */
void *krealloc(const void *p, size_t new_size, gfp_t flags)
{
        void *ret;

        if (unlikely(!new_size)) {
                kfree(p);
                return ZERO_SIZE_PTR;
        }

        ret = __krealloc(p, new_size, flags);
        if (ret && p != ret)
                kfree(p);

        return ret;
}
EXPORT_SYMBOL(krealloc);

Здесь же, в mm/util.c вы можете найти реализацию ещё одной функции, не имеющей аналога в стандартной библиотеке С - kzfree().

/**
 * kzfree - like kfree but zero memory
 * @p: object to free memory of
 *
 * The memory of the object @p points to is zeroed before freed.
 * If @p is %NULL, kzfree() does nothing.
 */
void kzfree(const void *p) {
        ...

В принципе, нельзя сказать, что эта функция делает нечто из ряда вон выходящее. На самом деле, она освобождает блок памяти с помощью kfree(), но при этом предварительно обнуляет содержимое памяти. Зачем? Для освобождения блока памяти, содержащего чувствительные данные, например. Как и kfree() kzfree() ничего не делает, если в качестве аргумента передан указатель на NULL.

size_t ksize(const void *);

Предназначение этой функции - возврат размера блока памяти, выделенного с помощью kmalloc(), указатель на который был передан в качестве аргумента. Казалось бы, эта функция не очень востребована, так как обычно код, работающий с блоками памяти, полученными от kmalloc() и так знает размер. Однако, как мы увидели на примере kzfree() и krealloc(), иногда полезно иметь возможность получить такую информацию вне кода, вызвавшего ранее kmalloc(). ksize() изначально не была экспортируемым символом. В ядрах 2.6.29-rc5 эта функция использовалась для определения размера структур crypto_tfm в криптографических модулях при затирании чувствительных данных перед возвратом памяти системе. Возможно, эта функция и не заслуживала бы особого внимания и не была бы нужна вообще, если бы не одно обстоятельство, связанное с работой kmalloc(). Дело в том, что kmalloc() выделяет память под объект из кэша. Кэш оперирует именно объектами, а не блоками памяти произвольного размера. Объект в контексте такого кэша это, в принципе, тот же блок памяти, но имеющий некий предопределённый размер. Объекты различаются именно по размеру. Возможно, вы сталкивались с таким понятием, как пул объектов (так называется помимо прочего кэш объектов в ядре Windows NT)? Вот именно из таких пулов-кэшей kmalloc() с помощью нижележащей подсистемы управления кэшем объектов (SLAB, SLOB, SLUB, SLQB) - аллокатора - выделяет память. К чему это всё? Ещё не улавливаете? Кэш объектов выделяет блоки дискретного размера. Например, 16, 32, 64, 128, 65535, ... байт. Именно так, и никак иначе. А это в свою очередь означает, что если вы запросите у ядра блок памяти размером в 257 байт, аллокатор выделит объект размером 512 байт. Удивлены? Именно так, при нижелещащем аллокаторе SLAB или SLUB kmalloc() округляет размер блока в большую сторону до ближайшего размера объекта, с которым работает аллокатор. Запрашивая 257 байт вы фактически получаете 255 лишних байт, которые не могут быть использованы при удовлетворении других запросов до тех пор, пока вы не освободите ваш блок в 257 байт. Это бесполезный расход памяти, который является побочным эффектом скорости работы кэша. Но код, который запросил блок, знает истинный размер данных, хранимых в выделенном блоке и может использовать остаток блока для иных нужд, не запрашивая дополнительно новых выделений памяти. Это обстоятельство интенсивно экплуатируется в сетевой подсистеме при обработке сетевых пакетов. В этих условиях ksize() помогает выяснить реальный размер блока памяти, размер той части объекта кэша, которая реально используется и размер области, которая не используется, но доступна в пределах выделенного объекта.

И, наконец, последняя рассматриваемая нами функция:

/**
 * kzalloc - allocate memory. The memory is set to zero.
 * @size: how many bytes of memory are required.
 * @flags: the type of memory to allocate (see kmalloc).
 */
static inline void *kzalloc(size_t size, gfp_t flags)
{
        return kmalloc(size, flags | __GFP_ZERO);
}

Она делает именно то, что вы видите - просто возвращает блок памяти с обнулённым содержимым. Практически, это могло бы быть эквивалентно вызову malloc() и memset() в пользовательском приложении.

krealloc() и kcalloc() не имеют парных им *_node() функций. Для kzalloc() такая парная функция предусмотрена - kzalloc_node(), третий аргумент которой - целочисленный идентификатор узла, откуда должна быть выделена память.

Заключение

Теперь, когда у вас есть некоторое представление о том, как ядро управляется с памятью, работа с этим ресурсом не должна показаться сложнее, чем в пользовательском процессе. Вот несколько простых правил, которых желательно придерживаться:

  • Определитесь, может ли поток спать (может ли kmalloc() приостановить выполнение вашего потока). Если вы имеете дело с обработчиком прерывания, нижней половиной или если ваш поток держит блокировку, он не может спать. Если ваш код выполняется в контексте процесса и не держит блокировок, то вероятно, можно разрешить kmalloc() блокировать выполнение.
  • Поток может уснуть, если указан флаг GFP_KERNEL.
  • Если ваш поток не должен спать, указывайте флаг GFP_ATOMIC.
  • Если вам нужна память для работы с DMA (например, ISA-устройства или кривое PCI-устройство), используйте флаг GFP_DMA.
  • Всегда проверяйте возврат kmalloc() и обрабатывайте возврат NULL-указателя.
  • Не допускайте утечек памяти; убедитесь, что на каждый kmalloc() где-то есть kfree().
  • Убедитесь, что ваш код не создаёт ситуаций, когда kfree() может вызываться многократно на одном блоке, а также, что к освобождаемому блоку не будет обращений после вызова kfree().

В этой статье затронута интересная тема - менеджеры кэшей объектов (slab). Но я думаю, что было бы не очень уместно пытаться втиснуть всё в одну статью. Я надеюсь вернуться к этой теме и рассмотреть её более детально в одной из будущих статей.

Источники

Чтобы узнать больше, вы можете посмотреть эти файлы из дерева исходного кода ядра.

  • include/linux/gfp.h: флаги.
  • include/linux/slab.h: определение функции kmalloc(), и проч.
  • mm/page_alloc.c: функции выделения страничных фреймов.
  • mm/slab.c: реализация функции kmalloc() и проч.

Saturday, October 22, 2011

Абстрактные типы данных - Бинарные деревья поиска

Концепция
Поиск
Вставка
Удаление
Уничтожение дерева
Обход дерева
Родительские указатели
Правовинтовые деревья
Производительность
Заключение

Деревья являются одной из старейших и наиболее часто используемых структур данных в программировании. В самых общих чертах деревья - это просто неориентированные графы, называемые свободными деревьями. Однако наибольшее распространение получили бинарные деревья поиска. Бинарные деревья поиска упрочились в употреблении так сильно, что если кто-то говорит о деревьях, то подразумевается обычно бинарное дерево поиска, поэтому далее, когда я пишу "дерево", я подразумеваю "бинарное дерево поиска". Если я пишу "бинарное дерево поиска", я подразумеваю "бинарное дерево поиска", что может приводить к путанице. Эта статья описывает базовые концепции деревьев, иногда очень детально.

Я постараюсь приложить все усилия, чтобы уделить внимание важным и практическим аспектам (сопровождая пояснения реальным работающим кодом), не вдаваясь в теорию. У меня нет желания писать ещё одну сухую книгу о деревьях, и поэтому в центре моего внимания находится то, что вы сможете использовать в реальном мире, не имея докторской степени. Если вы ищете леммы и доказательства, то эта статья не для вас. Я недостаточно компетентен и незаинтересован заполнять статью страницу за страницей математической каббалистикой. Если же вы хотите узнать, что за звери эти бинарные деревья поиска или просто не до конца понимаете что-то, вы пришли по верному адресу. Если вы уже использовали деревья в реальном коде или просто в примерах, эта статья позволит вам углубить знания и, возможно, познакомит с вариациями в работе с деревьями.

Я программист, а не учёный. Я зарабатываю на жизнь написанием кода, а не теориями. Поэтому у меня есть строгое мнение о бесполезности теории. Уверен, знание теории придаёт вам учёный вид, но иногда теория недоступна среднестатистическому Ивану, не играя к тому же особой роли на практике. Я хочу, чтобы моя статья была полезна как можно большему числу людей, поэтому я пишу для таких людей, как я сам - для людей, которые не забивают голову бесполезной теорией, если только это действительно необходимо для принятия каких-то важных решений по коду. Пишу я тоже, как программист, с каламбурами, непривычными шутками и различными социальными отклонениями. Это повод добавить немного текста в статью и разбавить текст юмором :-)

Прежде, чем мы приступим, несколько замечаний по коду. Весь код написан на C, потому что используя C я могу описать суть пояснений в тексте без вовлечения фреймворков. C++ или Java тоже были бы неплохим выбором, потому что они хорошо разрекламированы и в наши дни они лучше, чем Jolt!-кола, но при использовании этих языков пришлось бы писать классы в хорошем стиле, который принят для этих языков, что могло бы добавить много ненужной возни. Так как я пишу о деревьях, а не о том, как написать хороший класс для работы с деревом, я предпочитаю C, чтобы иметь возможность избавиться от ненужных деталей и фреймворка. Кроме того, C - база для общераспространённых языков. По своему синтаксису Java и C++ восходят к C, поэтому компетентному программисту не составит труда "перевести" код.

Я не "отупляю" свой код, потому что уверен - бесполезные примеры, написанные так, чтобы их смог понять любой, но в ущерб функциональности глупы и не помогают в конечном счёте никому. Я ожидаю от вас хорошего знания C, но тем не менее по ходу дела я буду объяснять моменты, которые недостаточно ясны. Код написан, как если бы он предназначался для готовой к использованию полноценной библиотеки с мыслью о том, чтобы его можно было использовать на самом деле! Я видел массу руководств, в которых код был написан с явными синтактическими и логическими ошибками, упрощающими допущениями, непрактичными конструкциями и проч.. Всё это делало такой код бесполезным где-либо, помимо самого руководства, в котором он приведён. С этой статьёй иначе - вам не только разрешается использовать приведённый здесь код, но даже более того - я настоятельно советую его использовать!

Вся эта статья является общественным достоянием (public domain). Это значит, что вы можете цитировать текст, не беспокоясь о соблюдении копирайтов, копировать всё содержимое статьи и размещать его где-то ещё и даже копировать код в свою библиотеку, которую вы намереваетесь продавать - всё это без необходимости какого-либо разрешения со стороны автора! Естественно, первоисточник может обновляться и быть более актуальным, но я не накладываю никаких ограничений на использование материалов. Вы можете также взять мой код и выдать его за свой, но это подпортит вашу карму. Я верю, что вы достаточно честны, чтобы упомянуть меня там, где такие упоминания необходимы. Написание руководств - тяжёлая работа, также, как и написание кода

Концепция [наверх]

Итак, мы теперь друзья, так? Вы - программисты или хотите стать программистом, так что, наверняка вы сталкивались с необходимостью хранения кучи данных. Это может быть гистограмма уникальных слов в файле, сортировка огромной кучи IP-адресов, с которых были установлены соединения на сокете или просто база данных адресов электронной почты. Вероятно, вы использовали связный список или (Ха!) массив. Ни то, ни другое не подходит для нужд, описанных выше. То, что нам нужно - это структура данных, которая хранит данные в отсортированном виде без необходимости совершать дополнительные телодвижения с простыми операциями вставки и удаления, а также эффективным поиском. Ни связный список, ни массивы не обладают такими свойствами, а потому они являются плохими решениями.

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

Последовательность

[0][1][2][3][4][5][6][7][8][9]
приобретает следующий вид

bt0

Такова идея, скрытая за деревом. Такая структура называется деревом из-за ветвей, как у настоящего дерева. Возможно, эту структуру стоило бы назвать корнем бинарного поиска, т.к. это было бы проще увязать с иллюстрацией, походящей скорее на корневую систему, но к сожалению в информатике уже укоренилось название "дерево", так что переименование наверно было бы плохой мыслью.

Если вы начнёте поиск элемента с самого верхнего узла, называемого корнем (путаница!), вы можете продолжать поиск заданного элемента, сравнивая его с вершиной на предмет того, меньше ли искомое значение, чем то, что содержится в вершине или же больше. Скажем, нам нужно найти элемент со значением 3. 3 меньше 5, поэтому мы двигаемся влево и теперь у нас вершина со значением 2. 3 больше 2, поэтому мы смещаемся вправо и теперь у нас вершиной становится узел 4, мы смещаемся влево от вершины и вот наше 3. 3 равно 3, вот мы и нашли наш элемент. Следуя этому образцу, вы можете найти любой элемент в дереве.

Хорошо, а что с неудачным поиском? Что, если искомого элемента нет в дереве? Очень просто! Проделайте поиск, как описано выше и если вы выйдете за нижнюю часть дерева, то поиск можно считать безуспешным. Ну, может это не так и просто, ведь мы не знаем, когда мы выйдем за нижнюю часть дерева. На самом деле, в дереве есть специальные узлы, называемые листьями (листовые узлы). Они не содержат данных и если мы достигаем такого листа, значит наш поиск зашёл в тупик. Для обозначения листьев я буду использовать символ тильды - ~, а дерево будет выглядеть так:

bt1

Из диаграммы легко увидеть, что при достижении листа поиск заканчивается. На будущее давайте определимся с терминологией. Вершина дерева называется корнем. Однако, учитывая то, что деревья могут определяться рекурсивно, каждый узел дерева может быть назван корнем (корень поддерева). Если узел является корнем поддерева, у него есть родительский узел, который ссылается на него. Так узел 2 является родительским узлом (родителем) узла 1, а 5 - родительский узел узла 2. Если смотреть с другой стороны, 2 является дочерним узлом узла 5, а 1 - дочерний узел (потомок) узла 2. В отношении деревьев принята аналогия с семейным древом, поэтому не удивляйтесь, если при обсуждении деревьев натолкнётесь на упоминания о дедах, прадедах и прочем таком.

Узлы, имеющие 2 потомка, называются внутренними, а узлы с 1 или меньшим числом дочерних узлов называются внешними. Лист - это лист. Высота дерева (или поддерева - ведь деревья рекурсивны) - это число узлов от корня до листа, исключая сам корень. На рисунке выше высота восьмёрки - 2, потому что самый длинный путь к листу имеет 2 узла - 7 и 6. Иногда вам также может встречаться случай, когда корень дерева также включается в высоту и тогда в нашем случае высота была бы 3. Оба способа верны до тех пор, пока их применение правильно.

Хорошо, поиск в таком дереве эффективен. А что с простотой вставки и удаления узлов? На первый взгляд, это непросто. Вставка в дерево нового узла проста и эффективна, если посмотреть на это с точки зрения безуспешного поиска. Если вы ищете новый элемент, который, как вы знаете, отсутствует в дереве, вы можете заменить лист на новый узел с тем значением, которое вы безуспешно искали. С помощью сравнений "меньше" или "больше" вы можете обрабатывать и дубликаты данных. Оба способа работают и ваши дубликаты будут выстраиваться в линию вниз по пути.

Удаление сложнее (но не настолько, как многие думают), однако мы оставим это на потом и рассмотрим в деталях чуть позже. Что у нас с сортировкой данных? Так как каждый левый дочерний узел меньше или равен по значению своему родителю, данные хранятся в отсортированном виде. Иногда использовать такое положение вещей эффективно не так легко, потому что следующая по порядку запись данных может не быть в прилегающем узле. Допустим, узлы 2 и 3 разделены узлом 4, а 4 и 5 - узлом 2. В дальнейшем мы рассмотрим способы извлечения пользы из такого расположения узлов, а пока всё, что вам нужно запомнить - данные в дереве уже действительно отсортированы!

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

1 struct node {
2         int data;
3         struct node *link[2];
4 };
5 
6 struct tree {
7         struct node *root;
8 };

Итак, у нас структура, ссылающаяся на себя. Если вы знакомы с тем, как работают связные списки, для вас понимание этой структуры не будет проблемой. Структура может содержать указатель на саму себя, так что, чтобы связать два экземпляра структуры, полю, указывающему на другой экземпляр, присваивается значение адреса этого другого экземпляра. Единственным моментом, вводящим в заблуждение, может оказаться массив ссылок. Я мог бы использовать два поля-указателя, но как вы вскоре увидите сами, операции на бинарном дереве поиска симметричны. Используя массив ссылок и булев индекс при доступе к его элементам мы можем избежать ненужных повторений кода лишь слегка уложнив его. Как только вы пообвыкнетесь с этой идиомой, она, возможно, покажется вам удобнее, чем обычная идиома левых и правых указателей. Для индикации листьев мы просто используем NULL, т.к. это общепринятая практика и она легка в использовании.

Наконец, с этими познаниями мы можем приступить к сути этой статьи. Начнём с поиска, ведь вы уже знаете, как он работает.

Поиск [наверх]

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

 1 int find_r (struct node *root, int data)
 2 {
 3         if (root == NULL)
 4                 return 0;
 5         else if (root->data == data)
 6                 return 1;
 7         else {
 8                 int dir = root->data < data;
 9                 return find_r (root->link [dir], data);
10         }
11 }
12 
13 int find (struct tree *tree, int data)
14 {
15         return find_r (tree->root, data);
16 }

Базовыми случаями для рекурсии являются удачный и безуспешный поиск. Если корень равен указателю на NULL, мы достигли листа и можем возвратить код ошибки. Если данные из корня содержат значение, совпадающее с искомым, мы можем возвратить код успешного завершения. Иначе мы сравниваем искомое значение со значением из корня и в зависимости от результата переходим к его левому, если искомое значение меньше, чем значение корня, или правому дочернему узлу, если искомое больше. Код возврата при рекурсии всё время "отодвигается", так что find() возвращает то, что получает. 0 означает, что данные не найдены, а 1 - что поиск завершён успехом. Функция find() определена для удобства. Теперь пользователь может написать find (tree, data) и работать с деревом, как с чёрным ящиком вместо явного вызова find_r (tree->root, data).

Хорошо, я не могу представить себе, что этот код застопорит вас, а если это всё же случилось, возможно, вы ещё не готовы к знакомству с бинарными деревьями поиска. Однако фокус с флагом dir иногда вводит в заблуждение, поэтому я кратко поясню, как это работает. Мы знаем, что link [0] - это левое поддерево, а link [1] - правое поддерево, для перехода влево нам нужно убедиться, что сравнение даёт 0 в результате, а для перехода вправо - 1. Проблема. Ведь наиболее явный способ сравнения data < root->data даёт нам 1 и, следовательно, неправильное направление для дальнейшего движения, потому что нам нужно будет перейти влево. Поэтому, чтобы наша идиома с массивом работала, мы переворачиваем условие так root->data < data. При этом, если data меньше root->data, результат сравнения - 0 и мы получаем нужное направление.

Хотя при работе с деревьями рекурсия упрощает некоторые вещи, многие предпочитают нерекурсивные решения по множеству веских причин. Поиск в дереве без рекурсии ещё проще, чем рекурсивный. Он предотвращает вероятность переполнения стека, выполняется быстрее и требует меньше памяти:

 1 int find (struct tree *tree, int data)
 2 {
 3         struct node *it = tree->root;
 4 
 5         while (it != NULL) {
 6                 if (it->data == data)
 7                         return 1;
 8                 else {
 9                         int dir = it->data < data;
10                         it = it->link [dir];
11                 }
12         }
13 
14         return 0;
15 }

Пара замечаний по поводу дубликатов. Описанные алгоритмы подолжают поиск до первого совпадения и не позволят вам находить и перечислять дубликаты. Работа с дубликатами несколько сложнее. Если вам необходимо вести подсчёт дубликатов, возможно имеет смысл возвращать указатель на данные, сохраняя также указатель на узел, содержащий совпадение с тем, чтобы после вы смогли возобновить поиск там, где остановились в прошлый раз. Таким образом вы сможете найти последующие совпадения после первого. Многие деревья не допускают дубликатов. В этой статье мы в основном также исходим из этого. Это легче и для меня, так как мне не нужно писать дополнительный код для обработки дубликатов :-)

Вставка [наверх]

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

bt2

Код, выполняющий эти действия, представляет собой простую вариацию алгоритма поиска, однако рекурсивный код извлекает некоторую выгоду из рекурсии для упрощения внесения обновлений. Вот рекурсивный код (make_node() просто создаёт новый узел, присваивает ему данные и инициализирует ссылки NULL-значениями):

 1 struct node *insert_r (struct node *root, int data)
 2 {
 3         if (root == NULL)
 4                 root = make_node (data);
 5         else if (root->data == data)
 6                 return root;
 7         else {
 8                 int dir = root->data < data;
 9                 root->link [dir] = insert_r (root->link [dir], data);
10         }
11 
12         return root;
13 }
14 
15 int insert (struct tree *tree, int data)
16 {
17         tree->root = insert_r (tree->root, data);
18         return 1;
19 }

Единственное отличие между insert_r() и find_r() состоит в возвращаемых ими значениях в двух основных случаях. Однако на первый взгляд может быть не совсем ясно, как работает рекурсивное обновление. Когда наша рекурсия сворачивается и мы поднимаемся вверх по дереву, мы присваиваем указателю на следующий узел то значение, которое возвращает insert_r(). Так мы обеспечиваем, что изменения в нижней части дерева дойдут и до верхних уровней. Кстати, распространённая ошибка - не забывайте сбрасывать родительский узел изменённого узла, чтобы ваши изменения вступили в силу.

Мы добавляем новые узлы, замещая листья внизу дерева, так что наше дерево растёт вниз. Это опять вызывает путаницу и снова на ум приходит мысль о "деревьях", как о "корнях". Давайте добавим в новое дерево несколько узлов, чтобы посмотреть, как это всё работает:

bt3

Как и функция find(), insert() может быть реализована без рекурсии - так же, как рекурсивные и нерекурсивные функции поиска. Нерекурсивная вставка имеет некоторые преимущества по сравнению с рекурсивной. При работе на больших деревьях вам не нужно беспокоиться от достижении некого предела рекурсии. Обычный цикл быстрее, чем вызов функции и памяти для хранения локальных переменных также требуется меньше. Иногда рекурсивные алгоритмы являются лучшим выбором из-за их простоты, но на мой взгляд многие нерекурсивные алгоритмы на деревьях не намного сложнее рекурсивных. Вообще, затрата усилий на создание нерекурсивного алгортма оправдывает себя.

Единственная разница между нерекурсивным поиском и нерекурсивной вставкой в том, что алгоритм вставки должен быть осторожным, чтобы не попасть на лист. Ввиду того, что листовой узел - это NULL-указатель, мы не сможем определить, с какого узла мы пришли и не сможем добавить ссылку на новый узел. Это не даёт осуществить саму вставку, потому что мы не сможем внести наши изменения в дерево. Поэтому рабочий цикл на каждой итерации сначала проверяет следующую ссылку прежде чем двигаться дальше. И если следующий узел - листовой, цикл разрывается, а новый узел записывается в переменную, содержавшую ссылку на лист. Так как у нас нет ссылки, которая указывала бы на корень дерева, мы рассматриваем это, как особый случай. Есть разные способы обработки случая пустого дерева, но я думаю, самый ясный способ такой:

 1 int insert (struct tree *tree, int data)
 2 {
 3         if (tree->root == NULL)
 4                 tree->root = make_node (data);
 5         else {
 6                 struct node *it = tree->root;
 7                 int dir;
 8 
 9                 for ( ; ; ) {
10                         dir = it->data < data;
11 
12                         if (it->data == data)
13                                 return 0;
14                         else if (it->link [dir] == NULL)
15                                 break;
16 
17                         it = it->link [dir];
18                 }
19 
20                 it->link [dir] = make_node (data);
21         }
22 
23         return 1;
24 }

Так как условие, при соблюдении которого цикл разрывается, находится между кодом, выбирающим направление и кодом передвижения в этом направлении, мы используем бесконечный цикл. Конечно, можно построить и "нормальный" цикл, но я не думаю, что такие методы здесь уместны, а кроме того, коль скоро я автор статьи, моё мнение и решает :-) Обратите внимание, что в любой из этих реализаций вы можете разрешить дубликаты данных, если уберёте проверку на равенство. Всё будет работать и дальше, а дубликаты будут складываться один под другим.

Удаление [наверх]

Та простота, с которой мы вставляли новые узлы, могла вызвать у вас необоснованное чувство безопасности. Хотя, если всё, что вам нужно, так это добавлять узлы и искать, всё в порядке. Но многие из нас на определённом этапе приходят к необходимости удалять из дерева существующие узлы. Это не так просто, как вставка узлов, т.к. у нас нет выбора, откуда удалять узел. Если это внешний узел, то всё почти так же просто, как и со вставкой. Но если удаляемый узел - внутренний, всё немного сложнее.

Всё, что нужно сделать для удаления внешнего узла - заменить его на его нелистового потомка или на лист, если у данного узла нет иных потомков, кроме листовых узлов. Есть три простых случая удаления внешнего узла. Если у нашей жертвы есть левый дочерний узел, мы заменяем удаляемый узел, который является внешним, левым потомком, потому как его правый потомок с уверенностью окажется листом. Если у удаляемого узла есть правый дочерний узел, то случай симметричен узлу с левым потомком. Если у узла нет дочерних узлов - выберите любой, т.к. они оба будут листьями. И не забудьте обновить ссылку родительского узла, чтобы она указывала на новый дочерний узел. Это удалит узел из дерева:

bt4

Здесь главное проследить, чтобы ссылка родительского узла была обновлена и указывала на узел с или лист. Если это не будет сделано, то узел х не будет успешно удалён из дерева, а последующее освобождение памяти приведёт к неприятностям. К счастью, пока у нас есть доступ к родительскому узлу, все три случая суммируются в одном блоке кода. Вероятно, это самый ужасный случай, о каком я могу подумать применительно к деревьям, потому что здесь необходимо проверять, какой из дочерних узлов х не пустой (NULL) и каким узлом по отношению к р является х таким вот образом:

1 p->link [p->link [1] == x] = x->link [x->link [0] == NULL];

Здорово, правда? Это похоже на удаление записи из связного списка. Ма замещаем ссылку на следующий узел узла р аналогичной ссылкой узла х, таким образом удаляя х из цепи и после этого можем со спокойной душой освободить память, занимаемую х. Оставшаяся часть - сравнение x->link [0] и NULL даёт нам 1, если левая ссылка пустая (NULL) - второй случай и мы будем использовать правую ссылку для замены узла х. Если правая ссылка также пустая, у х не было потомков (третий случай). Если проверка даёт 0, левая ссылка не пуста (первый случай). Сравнение указателя на х и правой ссылки узла р выполняет ту же работу, но иным способом. Если х - узел, на который указывает правая ссылка узла р, мы получаем 1. В противном случае результатом сравнения будет 0 и т.к. х не является листом, мы можем быть уверены, что х - левый дочерний узел р. Все действия выполняются в одной строке (особый случай - удаление корня дерева, - будет рассмотрен немного позже). Это простой случай удаления. Когда мы удаляем узел с двумя потомками, дело обстоит чуть сложнее.

bt5

Давайте удалим из этого дерева узел 5, просто представив, что это не корень :-) Мы не можем просто так заменить его одним из дочерних узлов, потому как это вызовет определённые неудобства. Самое заметное - что делать с одним из дочерних узлов, который окажется сам по себе? Хорошо, ну а как на счёт присоединения левого поддерева узла 5 в качестве левой ссылки узла 7 и заменой 5 на 8?

bt6

Проделав такое, мы соблюдаем правило, что левые узлы содержат меньшие значения, а правые - большие, но такая хирургия несколько нетривиальна. Мы можем сделать лучше с меньшим усилием, не подвергая структуру дерева таким сильным изменениям. Как на счёт простой замены 5 на 4 или 7, вместо беготни по поддеревьям? Данные узлы являются порядковым предшественником и преемником, соответственно. Вся работа сводится к тому, чтобы найти один из этих узлов, потому что всё, что в большинстве деревьев нужно сделать после - скопировать данные одного узла в другой, затем удалить порядкового предшественника или преемника, которым мы заменили удаляемый узел. В этой статье мы будем в этих целях использовать преемника, но на самом деле, это не имеет особой разницы (Здесь возможна терминологическая неточность в переводе. В этом месте оригинал гласит следующее: These are the inorder predecessor and successor, respectively. All of the work is in finding one of those nodes, because then all you need to do in most trees is copy the data of one to the other, then remove the predecessor or successor that you replaced it with. Речь идёт о числах. Т.е., число, по порядку предшествующее 5 - т.е., 4 и следующее за ним из множества тех, что есть в дереве - 7. Я сходу не нашёл удачного эквивалента, потому что спешил, но в целях ясности решил оставить это замечание - перев.)

bt7 bt8

Прелесть этого приёма (удаление копированием) в том, что мы можем взять сложный случай, когда у удаляемого узла два потомка и свести его к случаю удаления узла с одним потомком. Как это работает? А если у старшего узла два потомка?

Младший узел никогда не имеет правого потомка, а старший - левого. Поэтому мы можем быть уверены в том, у старшего узла по меньшей один потомок, а его левая ссылка указывает на лист. Следуя этому, мы можем легко найти старший узел для заданного узла, если у нас есть доступ к его правому поддереву. Простой переход на правую ветку и нисхождение по левой стороне поддерева приведёт нас к старшему узлу. Схожим образом мы можем найти и младший узел, перейдя на левую ветку узла и пройдя по левому поддереву с правой стороны.

Теперь мы можем найти старший узел, следуя вниз по поддереву. Но что, если мы хотим найти старший узел для 4? Это сложнее, но мы не столкнёмся с такой ситуацией в базовом алгоритме удаления, так что можем пока оставить это в стороне. У нас достаточно условий для удаления узла из дерева. Сначала мы находим узел, который нужно удалить - так же, как и в алгоритме вставки, но в случае с удалением мы сохраняем указатель на родительский узел при спуске вниз. Когда мы достигаем нужного узла (если мы достигли листа, то просто возвращаем ошибку), мы смотрим, сколько у него потомков и применяем к нему тот или иной способ удаления в зависимости от того, простой у нас или сложный случай. Вот наш код:

 1 int remove (struct tree *tree, int data)
 2 {
 3         if (tree->root != NULL) {
 4                 struct node *p = NULL, *succ;
 5                 struct node *it = tree->root;
 6                 int dir;
 7 
 8                 for ( ; ; ) {
 9                         if (it == NULL)
10                                 return 0;
11                         else if (it->data == data)
12                                 break;
13 
14                         dir = it->data < data;
15                         p = it;
16                         it = it->link [dir];
17                 }
18 
19                 if (it->link [0] != NULL && it->link [1] != NULL) {
20                         p = it;
21                         succ = it->link [1];
22 
23                         while (succ->link [0] != NULL) {
24                                 p = succ;
25                                 succ = succ->link [0];
26                         }
27 
28                         it->data = succ->data;
29                         p->link [p->link [1] == succ] = succ->link [1];
30 
31                         free (succ);
32                 }
33                 else {
34                         dir = it->link [0] == NULL;
35 
36                         if (p == NULL)
37                                 tree->root = it->link [dir];
38                         else
39                                 p->link [p->link [1] == it] = it->link [dir];
40 
41                         free (it);
42                 }
43         }
44 
45         return 1;
46 }

Поиск узла почти идентичен тому, что используется в нерекурсивном алгоритме вставки, за тем лишь исключением, что здесь мы не разбиваем код определения направления и, собственно, переходов. Мы также присваиваем р значение it, прежде чем it будет присвоено содержимое it->dir (Вообще-то, должно быть it->link [dir]. Тут у автора статьи явная опечатка - перев.). Этим шагом мы сохраняем указатель на родительский узел и если р равен NULL, поиск завершён, а мы должны обработать особый случай - удаление корня дерева.

Давайте посмотрим на обработку двух случаев после окончания поиска. В первом случае, если у узла два потомка, нам нужно найти старший узел для него. Как вы уже знаете, чтобы сделать это, мы просто сдвигаемся на правую ветку и идём по левой стороне, пока не находим лист. Во время этой операции мы сохраняем ссылку на родительский узел текущего узла поддерева, так что в конце нисхождения succ будет содержать старший узел, а р - указатель на его родителя. Мы копируем данные из старшего узла в узел, который собираемся удалить, но фактически удаляется сам старший узел. Обратите внимание, что в этом случае мы работаем всегда с succ->link [1], потому что мы знаем: левая ссылка указывает на лист. Обратная проверка p->link [1] == succ в принципе даст тот же результат - 0, если на succ - указывает левая ссылка узла р и 1 - если правая. Теперь освобождаем память удалённого узла и дело сделано.

Во втором случае у удаляемого узла только один потомок, узел - внешний. Это простой случай, ведь нам не надо искать старший узел. Мы просто вырезаем узел, берём ту сторону, которая не указывает на лист и присоединяем поддерево к узлу р, как замену узла, на который указывает it. Если р равно NULL, мы пытаемся удалить корень дерева. Здесь может возникнуть проблема, потому что изменения, которые мы вносим, должны быть отражены в tree->root или они не будут сохранены, поэтому мы рассматриваем такую ситуацию, как особый случай. Такого особого случая не возникает, если узел, который мы удаляем, имеет двух потомков, потому что даже если такой узел окажется корнем дерева, мы просто перезапишем его данные, не удаляя сам узел.

Такой метод относительно короткий и простой, хоть и несколько наивный. Мы решаем проблему в лоб, хотя могли бы сработать хитрее. Отметьте, как оба случая на самом деле завершают удаление внешнего узла, хотя это всё ещё разные случаи, потому что в одном из них нам необходимо найти старший узел. Давайте попробуем сделать иначе. Вместо останова при нахождении внешнего узла мы просто сохраним указатель на него и продолжим движение вниз к внешнему узлу. Когда мы достигаем "дна", мы копируем данные текущего узла в ранее сохранённый узел и удаляем текущий:

 1 int remove (struct tree *tree, int data)
 2 {
 3         if (tree->root != NULL) {
 4                 struct node head = { 0 };
 5                 struct node *it = &head;
 6                 struct node *p, *f = NULL;
 7                 int dir = 1;
 8 
 9                 it->link [1] = tree->root;
10 
11                 while (it->link [dir] != NULL) {
12                         p = it;
13                         it = it->link [dir];
14                         dir = it->data <= data;
15 
16                         if (it->data == data)
17                                 f = it;
18                 }
19 
20                 if (f != NULL) {
21                         f->data = it->data;
22                         p->link [p->link [1] == it] = it->link [it->link [0] == NULL];
23                         free (it);
24                 }
25 
26                 tree->root = head.link [1];
27         }
28 
29         return 1;
30 }

Эта функция вводит два приёма с целью избежания особых случаев. Первое - корень-пустышка, так что у корня дерева всегда есть родительский узел. Второе - сохранение ссылки на найденный и подлежащий удалению узел с тем, чтобы мы смогли скопировать данные, когда доберёмся до внешнего узла. Таким образом мы избавились от особого случая с удалением корня и поиском старшего узла. Теперь код стал короче и красивее. Заметьте, мы проверяем является ли значение it->data меньше или оно равно data, потому что мы хотим продолжит нисхождение в том числе после нахождения совпадения и нам нужно перейти на правую сторону, чтобы найти старший узел для этой записи.

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

 1 struct node *remove_r (struct node *root, int data)
 2 {
 3         if (root != NULL) {
 4                 int dir;
 5 
 6                 if (root->data == data) {
 7                         if (root->link [0] != NULL && root->link [1] != NULL) {
 8                                 struct node *succ = root->link [1];
 9 
10                                 while (succ->link [0] != NULL)
11                                         succ = succ->link [0];
12 
13                                 data = succ->data;
14                                 root->data = data;
15                         }
16                         else {
17                                 struct node *save = root;
18 
19                                 root = root->link [root->link [0] == NULL];
20                                 free (save);
21 
22                                 return root;
23                         }
24                 }
25 
26                 dir = root->data <= data;
27                 root->link [dir] = remove_r (root->link [dir], data);
28         }
29 
30         return root;
31 }
32 
33 int remove (struct tree *tree, int data)
34 {
35         tree->root = remove_r (tree->root, data);
36         return 1;
37 }

Уничтожение дерева [наверх]

В какой-то момент вас достанут деревья и вы захотите избавиться от них. Но вы не хотите утечек памяти, поэтому вам нужно удалить каждый узел и освободить занимаемую память. Конечно, вы можете достичь цели многократным вызовом remove(), но это не то. Лучшее решение - обойти каждый узел дерева и удалить их все одним залпом. Пока не касаясь деталей обхода скажем, что это может быть сделано обходом в обратном порядке, а рекурсивный алгоритм тривиален:

 1 void destroy_r (struct node *root)
 2 {
 3         if (root != NULL) {
 4                 destroy_r (root->link [0]);
 5                 destroy_r (root->link [1]);
 6                 free (root);
 7         }
 8 }
 9 
10 void destroy (struct tree *tree)
11 {
12         destroy_r (tree->root);
13 }

Можно проделать то же и без рекурсии. Но вообще говоря, нерекурсивный обход дерева в обратном направлении не очень лёгкий. Однако, вам не обязательно обходить дерево в обратном направлении, чтобы удалить его. Вы можете изменить структуру дерева так, как было бы удобно для ваших нужд :-) Если у нас есть дерево, в котором каждый левый потомок - лист, такое дерево очень легко обойти и удалить каждый узел: просто сохраняем правую ссылку, удаляем узел, переходим по сохранённой ссылке:

bt9

Используемый трюк позволяет любое дерево привести к подобной структуре. Достичь такой структуру нам позволит операция под названием вращение. Вращение может быть вправо или влево. Левый поворот превращает правого потомка узла в его родителя, а сам узел становится левым дочерним узлов. Правый поворот симметрично обратен левому:

bt10

Заметьте, как узел 2 двигается из левого поддерева в правое. Узлы, изменившиеся при вращении - 1 и 3. Левая ссылка узла 3 становится правой ссылкой узла 1, а правая ссылка узла 1 указывает на узел 3. Левый поворот на втором дереве превратит его в исходное - первое дерево. Т.е., вращение симметрично. Таким образом, если мы удаляем узел без левой ссылки, то мы совершаем вращение направо; когда левая ссылка указывает на узел, мы можем гарантировать, что будет обойдён каждый узел и каждый узел будет удалён. Код, который всё это делает, на удивление короткий:

 1 void destroy (struct tree *tree)
 2 {
 3         struct node *it = tree->root;
 4         struct node *save;
 5 
 6         while (it != NULL) {
 7                 if (it->link [0] != NULL) {
 8                         /* Right rotation */
 9                         save = it->link [0];
10                         it->link [0] = save->link [1];
11                         save->link [1] = it;
12                 }
13                 else {
14                         save = it->link [1];
15                         free (it);
16                 }
17 
18                 it = save;
19         }
20 }

Ну что ж, если вы ещё не заметили, то мы закончили с основами и дальше нас ждёт средняя ступень. Я разделил статью таким образом, чтобы лёгкий материал находился в начале, потому что мне сказали, что моё предыдущее руководство по бинарным деревьям поиска было сложным. С этого момента подразумевается, что вы хорошо усвоили основу и знаете, что делаете. В следующем разделе мы рассмотрим обход дерева. Эта тема может показаться замороченной, но я постараюсь изложить её доступно. После обхода дерева мы обсудим родительские указатели и резьбу, которые уже можно считать продвинутой ступенью. И, наконец, некоторые замечания по поводу призводительности и намёки, что ещё может последовать за основами.

Обход дерева [наверх]

Теперь, когда у нас есть код для построения бинарных деревьев, мы можем вытворять с ними разные вещи. Полезные. Весёлые вещи. Естественно, вы можете использовать уже ранее описанную функцию поиска для того, чтобы определить, есть ли в дереве некое значение, но было бы неплохо проверять функцию вставки на предмет корректности её работы. Было бы также здорово иметь возможность выполнить некую операцию на всех узлах дерева, например, выводить на экран их содержимое. И это приводит нас к обходу дерева.

Наверно, вы подумали, что обход каждого узла в дереве прост и состоит из одного-двух случаев, которые необходимо обрабатывать. Верно? Нет! На самом деле, есть n! (факториал от n) разных путей обойти дерево, состоящее из n узлов, но большая часть таких маршрутов бесполезна. Из всего многообразия разных способов обхода бинарного дерева мы рассмотрим только две категории: обход в ширину - на примере обхода по уровням и обход в глубину - на примере обхода в прямом, симметричном и обратном порядке. Мы также рассмотрим более гибкий пошаговый обход, который вы можете найти в любой хорошей библиотеке для работы с деревьями.

Обход в глубину начинается с движения вправо или влево, насколько это возможно и продвигается до восхождения. Далее, переходя по ссылке вверх, обход снова движется направо или налево. Процесс провторяется, пока все узлы не будут посещены. Как и ожидалось, обход в глубину может быть написан рекурсивно, потому что маршрут движения построен на принципе стека. Конечно, возникает вопрос, а когда узел может считаться посещённым? Так как мы можем двигаться лишь в одном из двух направлений, нетрудно выяснить, что существует всего 6 путей обхода и посещения узлов при обходе в глубину. У нас есть три операции: "перейти влево", "перейти вправо", "посетить":

  1. посетить, перейти влево, перейти вправо
  2. посетить, перейти вправо, перейти влево
  3. перейти влево, посетить, перейти вправо
  4. перейти вправо, посетить, перейти влево
  5. перейти влево, перейти вправо, посетить
  6. перейти вправо, перейти влево, посетить

Из этих 6 вариаций 3 используются наиболее часто и имеют свои собственные стандартные названия: 1 - обход в пямом порядке, т.к. сперва посещается узел; 3 - симметричный обход, т.к. узлы обходятся в порядке сортировки их значений; и, наконец, 5 - обход в обратном порядке, потому что узел посещается после того, как совершены оба движения. Каждый из этих методов может быть реализован ввиде короткого и красивого рекурсивного алгоритма:

 1 void preorder_r (struct node *root)
 2 {
 3         if (root != NULL) {
 4                 printf ("%d\n", root->data);
 5                 preorder_r (root->link [0]);
 6                 preorder_r (root->link [1]);
 7         }
 8 }
 9
10 void preorder (struct tree *tree)
11 {
12         preorder_r (tree->root);
13 }
14
15 void inorder_r (struct node *root)
16 {
17         if (root != NULL) {
18                 inorder_r (root->link [0]);
19                 printf ("%d\n", root->data);
20                 inorder_r (root->link [1]);
21         }
22 }
23
24 void inorder (struct tree *tree)
25 {
26         inorder_r (tree->root);
27 }
28
29 void postorder_r (struct node *root)
30 {
31         if (root != NULL) {
32                 postorder_r (root->link [0]);
33                 postorder_r (root->link [1]);
34                 printf ("%d\n", root->data);
35         }
36 }
37
38 void postorder (struct tree *tree)
39 {
40         postorder_r (tree->root);
41 }

Давайте рассмотрим пример. Проследим за результатами каждого из способов обхода на дереве, что представлено ниже. Алгоритм обхода в прямом порядке сначала посещает узел, а потом продвигается. Узлы будут обойдены в таком порядке: 5, 3, 2, 7, 6, 8. Алгоритм симметричного обхода сдвигается в самую левую позицию, прежде чем посетить узел и даст такую последовательность: 2, 3, 5, 6, 7, 8. Обход в обратном порядке приведёт нас к последовательности 2, 3, 6, 8, 7, 5.

bt11

Итак, мы можем вывести на экран все узлы дерева. Но давайте сделаем что-нибудь весёлое, просто, чтобы разбавить монотонное повествование. Вместо получения значений узлов давайте посмотрим на структуру дерева. Это можно легко сделать с помощью алгоритма симметричного обхода, который даст вам дерево, повёрнутое на 90 градусов против часовой стрелки:

 1 void structure_r (struct node *root, int level)
 2 {
 3         int i;
 4
 5         if (root == NULL) {
 6                 for (i = 0; i < level; i++)
 7                         putchar ('\t');
 8                 puts ("~");
 9         }
10         else {
11                 structure_r (root->link [1], level + 1);
12 
13                 for (i = 0; i < level; i++)
14                         putchar ('\t');
15                 printf ("%d\n", root->data);
16
17                 structure_r (root->link [0], level + 1);
18         }
19 }
20
21 void structure (struct tree *tree)
22 {
23         structure_r (tree->root, 0);
24 }

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

Несмотря на эти чудесные решения, реализация нерекурсивных алгоритмов обхода может оказаться полезной. Такие алгоритмы сложнее в реализации из-за двойных рекурсивных вызовов. Если вы из тех, кто любит забегать вперёд, чтобы испытать или обхитрить учителя (позор!), вы могли отметить сходство обхода в прямом порядке и обхода по уровням, если очередь будет заменена на стек. Если нет, не волнуйтесь. Хотите верьте, хотите нет, но это всё, что необходимо сделать для создания алгоритма итеративного обхода в прямом порядке:

 1 void preorder (struct tree *tree)
 2 {
 3         struct node *it = tree->root;
 4         struct node *up [50];
 5         int top = 0;
 6
 7         if (it == NULL)
 8                 return;
 9
10         up [top++] = it;
11
12         while (top != 0) {
13                 it = up [--top];
14
15                 printf ("%d\n", it->data);
16
17                 if (it->link [1] != NULL)
18                         up [top++] = it->link [1];
19                 if (it->link [0] != NULL)
20                         up [top++] = it->link [0];
21         }
22 }

Симметричный обход сложнее. Нам надо переходить влево, не теряя из виду правые ссылки или родительские узлы. Это подразумевает как минимум несколько циклов - один для сохранения обратных ссылок, один для посещения сохранённых ссылок и другой для работы с последовательными ветками. К счасть, хоть логика и кажется сложной, код на удивление прост:

 1 void inorder (struct tree *tree)
 2 {
 3         struct node *it = tree->root;
 4         struct node *up [50];
 5         int top = 0;
 6
 7         while (it != NULL) {
 8                 while (it != NULL) {
 9                         if (it->link[1] != NULL)
10                                 up [top++] = it->link [1];
11
12                         up [top++] = it;
13                         it = it->link [0];
14                 }
15
16                 it = up [--top];
17
18                 while (top != 0 && it->link [1] == NULL) {
19                         printf ("%d\n", it->data);
20                         it = up [--top];
21                 }
22
23                 printf ("%d\n", it->data);
24
25                 if (top == 0)
26                         break;
27
28                 it = up [--top];
29         }
30 }

Внешний цикл работает, пока указатель не равен NULL. Это может быть в случае, если дерево пустое или в стеке больше нет узлов. Вы увидите, что последняя строка внешнего цикла аккуратна с присвоением значения NULL, если стек пуст, так что алгоритм, собственно, завершается. Первый внутренний цикл имеет дело с сохранением правых ссылок родительских узлов при спуске вниз по левым ссылкам. Второй внутренний цикл посещает родительские узлы. Ну и наконец последний printf() показывает нам правые ссылки. Диаграмма выполнения построена на том же дереве, что и рекурсивный симметричный обход.

save 7,  stack = { 7 }
save 5,  stack = { 5, 7 }
save 3,  stack = { 3, 5, 7 }
save 2,  stack = { 2, 3, 5, 7 }
visit 2, stack = { 3, 5, 7 }
visit 3, stack = { 5, 7 }
visit 5, stack = { 7 }
pop 7,   stack = {}
save 8,  stack = { 8 }
save 7,  stack = { 7, 8 }
save 6,  stack = { 6, 7, 8 }
visit 6, stack = { 7, 8 }
visit 7, stack = { 8 }
pop 8,   stack = {}
save 8,  stack = { 8 }
visit 8, stack = {}

Самый сложный случай - нерекурсивный обход в глубину в обратном порядке. Сложность состоит в необходимости придумать способ, как посетить узлы на низком уровне, сохраняя родителей и посещая их своевременно. Я неизбежно прихожу к использованию стека и вспомогательных счётчиков, причём 0 означает "сохранить левую ссылку", 1 - "сохранить правую ссылку", а 2 - "посетить вершину стека". Решение удобно, потому что оно хорошо вписывается в мою схему использовать булевы значения при вычислении условий для определения направления - правое или левое:

 1 void postorder (struct tree *tree)
 2 {
 3         struct {
 4                 struct node *p;
 5                 int n;
 6         } up [50], it;
 7         int top = 0, dir;
 8
 9         up [top].p = tree->root;
10         up [top++].n = 0;
11
12         while (top != 0) {
13                 it = up [--top];
14
15                 if (it.n != 2) {
16                         dir = it.n++;
17                         up [top++] = it;
18
19                         if (it.p->link [dir] != NULL) {
20                                 up [top].p = it.p->link [dir];
21                                 up [top++].n = 0;
22                         }
23                 }
24                 else
25                         printf ("%d\n", it.p->data);
26         }
27 }

Код короткий, но крайне непрозрачный. Диаграмма ниже может очень сильно помочь в попытке понять, что на самом деле делает алгоритм. Клянусь, всё не так сложно, как выглядит!

push 5:0,  stack = { 5:0 }
increment, stack = { 5:1 }
push 3:0,  stack = { 3:0, 5:1 }
increment, stack = { 3:1, 5:1 }
push 2:0,  stack = { 2:0, 3:1, 5:1 }
increment, stack = { 2:1, 3:1, 5:1 }
increment, stack = { 2:2, 3:1, 5:1 }
visit 2:2, stack = { 3:1, 5:1 }
increment, stack = { 3:2, 5:1 }
visit 3:2, stack = { 5:1 }
increment, stack = { 5:2 }
push 7:0,  stack = { 7:0, 5:2 }
increment, stack = { 7:1, 5:2 }
push 6:0,  stack = { 6:0, 7:1, 5:2 }
increment, stack = { 6:1, 7:1, 5:2 }
increment, stack = { 6:2, 7:1, 5:2 }
visit 6:2, stack = { 7:1, 5:2 }
increment, stack = { 7:2, 5:2 }
push 8:0,  stack = { 8:0, 7:2, 5:2 }
increment, stack = { 8:1, 7:2, 5:2 }
increment, stack = { 8:2, 7:2, 5:2 }
visit 8:2, stack = { 7:2, 5:2 }
visit 7:2, stack = { 5:2 }
visit 5:2, stack = {}

Для алгоритма левостороннего обхода дерево представляет собой стек уровней, при этом каждый уровень состоит из узлов, находящихся на одной высоте. Такой алгоритм движется по всем узлам уровня прежде чем перейти на следующий уровень. Наиболее общая реализация начинает обход с вершины (корня) и обходит каждый уровень слева направо. Например, в следующем дереве левосторонний обход посетит узлы в такой последовательности: 5, 3, 7, 2, 6, 8. Заметьте, что это не единственный способ проделать левостронний обход. Это просто наиболее распространённая реализация такого обхода. Мы используем этот способ только в качестве иллюстрации.

bt11

Левосторонний обход, вероятно, один из немногих алгоритмов на бинарных деревьях поиска, который не может быть реализован рекурсивно без блуждания всеми самыми неудобными и трудными путями. Те, кто уже хорошо знаком с рекурсией, знают, что рекурсию можно подменить использованием стека. Левосторонний обход, однако, требует очереди, поэтому рекурсия здесь не очень практична.

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

save 5,  queue = { 5 }
visit 5, queue = {}
save 3,  queue = { 3 }
save 7,  queue = { 7, 3 }
visit 3, queue = { 7 }
save 2,  queue = { 2, 7 }
visit 7, queue = { 2 }
save 6,  queue = { 6, 2 }
save 8,  queue = { 8, 6, 2 }
visit 2, queue = { 8, 6 }
visit 6, queue = { 8 }
visit 8, queue = {}

Как только вы поймёте, что происходит, алгоритм станет для вас коротким и красивым. В принципе, это обход в прямом порядке с очередью вместо стека. Я использовал простой массив, как основу для вращающейся очереди с индексом для первого и последнего элемента. В целом, если отвлечься от деталей, функция действительно простая:

 1 void levelorder (struct tree *tree)
 2 {
 3         struct node *it = tree->root;
 4         struct node *q [50];
 5         int front = 0, back = 0;
 6
 7         if (it == NULL)
 8                 return;
 9
10         q [front++] = it;
11
12         while (front != back) {
13                 it = q [back++];
14
15                 printf ("%d\n", it->data);
16
17                 if (it->link [0] != NULL)
18                         q [front++] = it->link [0];
19                 if (it->link [1] != NULL)
20                         q [front++] = it->link [1];
21         }
22 }

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

Все эти обходы хороши для того, что они делают, но обычно не очень хороши для того, чего хотим мы. По крайней мере, они не делают того, что хочу я. А я хочу вот чего:

1 int *x = first (tree);
2
3 while (x != NULL) {
4         printf ("%d\n", *x);
5         x = next (tree);
6 }

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

1 struct traversal {
2         struct node *up [50];   /* Stack */
3         struct node *it;        /* Current node */
4         int top;                /* Top of stack */
5 };

Т.к. самый распространённый алгоритм обхода - симметричный, мы будем использовать его. Это никоим образом не единственный алгоритм, который можно осуществлять пошагово, но доступ к данным у порядоченном виде пожалуй делает его самым полезным, тем более, что добавление операций продвижения вперёд и назад при использовании этого алгоритма доволно просто. Чтобы начать симметричный обход, нам сперва надо найти узел с наименьшим значением или самый дальний левый узел. Напишем простую функцию first(), которая будет инициализировать структуру traversal и установит в качестве текущего узла самый младший узел, сохраняя путь к нему:

 1 int *first (struct traversal *trav, struct tree *tree)
 2 {
 3         trav->it = tree->root;
 4         trav->top = 0;
 5
 6         if (trav->it != NULL) {
 7                 while (trav->it->link [0] != NULL) {
 8                         trav->up [trav->top++] = trav->it;
 9                         trav->it = trav->it->link [0];
10                 }
11         }
12
13         if (trav->it != NULL)
14                 return &trav->it->data;
15         else
16                 return NULL;
17 }

Обратите внимание - возвращается не само значение, указатель на него. Это упрощает проверку границ, ведь когда обход закончен, мы может возвратить указатель на NULL. Это хорошо вписывается в мои желания, которые я упомянул выше. first() - простая функция и мы можем превратить её в last(), заменив 0 на 1, чтобы изменить направление движения на правое вместо левого.

Теперь сложная часть. Пошаговый обход нам нужно проводить начиная с самого младшего узла. Код относительно простой. Если текущий код имеет правую ссылку, мы находим порядкового преемника (старший узел, чьё значение больше текущего) ниже по дереву и обновляем стек соответственно. Если у текущего узла нет правой ссылки, мы ищем старший узел выше по дереву. При этом из стека извлекаются узлы, пока мы не посетим узел по правой ссылке. Если стек пуст, мы завершаем обход и устанавливаем указатель на текущий узел в NULL.

 1 int *next (struct traversal *trav)
 2 {
 3         if (trav->it->link [1] != NULL) {
 4                 trav->up [trav->top++] = trav->it;
 5                 trav->it = trav->it->link [1];
 6
 7                 while (trav->it->link [0] != NULL) {
 8                         trav->up [trav->top++] = trav->it;
 9                         trav->it = trav->it->link [0];
10                 }
11         }
12         else {
13                 struct node *last;
14
15                 do {
16                         if (trav->top == 0) {
17                                 trav->it = NULL;
18                                 break;
19                         }
20
21                         last = trav->it;
22                         trav->it = trav->up [--trav->top];
23                 } while (last == trav->it->link [1]);
24         }
25
26         if (trav->it != NULL)
27                 return &trav->it->data;
28         else
29                 return NULL;
30 }

Теперь-то я могу делать всё, что захочу, минимально изменив код, а всё, что нам для этого понадобилось, это немного помыслить пошаговый обход, переписав код нерекурсивного обхода. Вы рады, что я сделал это для вас? :-)

1 struct traversal it;
2 int *x = first (&it, tree);
3
4 while (x != NULL) {
5         printf ("%d\n", *x);
6         x = next (&it);
7 }

Вот и всё. Пошаговый обход оказался ненамного сложнее нерекурсивного обхода, да к тому же он настолько гибче, что это даже не смешно. Вы можете сделать и другие типы обхода пошаговыми, но как я сказал ранее, симметричный обход самый распространённый. Поразвлекайтесь с нерекурсивными обходами и посмотрите, как сделать их пошаговыми. Если сможете это сделать, я могу сказать спокойно, что вы понимаете концепцию :-)

Родительские указатели [наверх]

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

Теперь вы можете использовать какую-либо хорошую библиотеку для работы со стеком или придумать некий иной механизм, который позволил бы двигаться в обратном направлении вверх по дереву. Как оказывается, последнее решение более распространено, чем первое и почти все распространённые реализации используют дополнительные ссылки, указывающие на родителя, для каждого узла. Эти ссылки называются родительскими указателями:

1 struct node {
2         int data;
3         struct node *up;
4         struct node *link [2];
5 };
6
7 struct tree {
8         struct node *root;
9 };

Вставка узла в дерево с родительскими указателями довольно проста. Нужно добавить совсем немного кода, чтобы адаптировать нашу функцию вставки для корректной работы с родительскими указателями. Однако необходимо позаботиться о назначении листу родительского указателя на корень, так, чтобы мы могли проверить его. Всё это выполняется функцией make_node(), которая выделяет память под новый узел, присваивает данные и устанавливает все ссылки в NULL.

 1 int insert (struct tree *tree, int data)
 2 {
 3         if (tree->root == NULL)
 4                 tree->root = make_node (data);
 5         else {
 6                 struct node *it = tree->root;
 7                 int dir;
 8
 9                 for ( ; ; ) {
10                         dir = it->data < data;
11
12                         if (it->data == data)
13                                 return 0;
14                         else if (it->link [dir] == NULL)
15                                 break;
16
17                         it = it->link [dir];
18                 }
19
20                 it->link [dir] = make_node (data);
21                 it->link [dir]->up = it;
22         }
23
24         return 1;
25 }

Родителем нового узла является узел, адрес которого содержит it, здесь у нас нет проблем с установкой ссылок. До тех пор, пока вы можете получить доступ к родительскому узлу, родительские указатели тривиальны. Хотя, конечно, это усложняет рекурсивные решения по сравнению с вариантами без родительских указателей.

Удаление узла из дерева с родительскими указателями также немного сложнее. Нам нужна дополнительная переменная для хранения адреса родительского узла. Особое внимание надо обратить на обновление родительских ссылок узла, ведь возможно, что заменяющий узел будет листовым! Это может произойти с корнем дерева и далее вниз по дереву, поэтому такие ситуации мы рассматриваем, как особые случаи:

 1 int remove (struct tree *tree, int data)
 2 {
 3         if (tree->root != NULL) {
 4                 struct node head = { 0 };
 5                 struct node *it = &head;
 6                 struct node *f = NULL;
 7                 int dir = 1;
 8
 9                 it->link [1] = tree->root;
10                 tree->root->up = &head;
11
12                 while (it->link [dir] != NULL) {
13                         it = it->link [dir];
14                         dir = it->data <= data;
15
16                         if (it->data == data)
17                                 f = it;
18                 }
19
20                 if (f != NULL) {
21                         int dir = it->link [0] == NULL;
22
23                         f->data = it->data;
24                         it->up->link [it->up->link [1] == it] =
25                                 it->link [dir];
26
27                         if (it->link [dir] != NULL)
28                                 it->link [dir]->up = it->up;
29
30                         free (it);
31                 }
32
33                 tree->root = head.link [1];
34                 if (tree->root != NULL)
35                         tree->root->up = NULL;
36         }
37
38         return 1;
39 }

Наконец, мы подошли к обходу. Ради этого и затевалась вся игра с родительскими указателями. Теперь для перехода к старшему узлу вверх по дереву нам не нужен хитрый стек, достаточно пройти по родительским указателям. struct traversal и first() почти не изменились и всё отличие состоит в том, что здесь не используется стек:

 1 struct traversal {
 2         struct node *it;
 3 };
 4
 5 int *first (struct traversal *trav, struct tree *tree)
 6 {
 7         trav->it = tree->root;
 8
 9         if (trav->it != NULL) {
10                 while (trav->it->link [0] != NULL)
11                         trav->it = trav->it->link [0];
12         }
13
14         if (trav->it != NULL)
15                 return &trav->it->data;
16         else
17                 return NULL;
18 }

Функция next() содержит больше отличий. Здесь мы, отказавшись от стека, в зависимости от необходимости можем двигаться вверх, используя родительские указатели, просто следуя по ссылкам, содержащимся в них. В принципе, логика осталась такой же и изменения минимальны. Неплохая цена за отказ от стека, не так ли?

 1 int *next (struct traversal *trav)
 2 {
 3         if (trav->it->link [1] != NULL) {
 4                 trav->it = trav->it->link [1];
 5
 6                 while (trav->it->link [0] != NULL)
 7                         trav->it = trav->it->link [0];
 8         }
 9         else {
10                 for ( ; ; ) {
11                         if (trav->it->up == NULL || trav->it == trav->it->up->link [0])
12                         {
13                                 trav->it = trav->it->up;
14                                 break;
15                         }
16
17                         trav->it = trav->it->up;
18                 }
19         }
20
21         if (trav->it != NULL)
22                 return &trav->it->data;
23         else
24                 return NULL;
25 }

Правовинтовые деревья [наверх]

Родительские указатели во многих ситуациях полезны, но они требуют внесения некоторой избыточности, которая не везде и не всегда может оказаться приемлемой. Ввиду этой проблемы было придумано очень умное решение, где листья дерева могут использоваться определённым образом, указывая не на NULL, а на старшие и младшие узлы внешних узлов. Это называется винтовым деревом (или резьбовым). Теперь у нас нет избыточности дополнительных указателей, а вместо этого мы добавляем флаг, который определяет, какую ссылку содержит узел - реальную или резьбовую. Зачем флаг? Мы попали бы в бесконечный цикл, если бы не смогли отличить резьбовую ссылку от обычной. На каждую ссылку в дереве нам нужен флаг. Полные резьбовые деревья требуют 2 флагов (Здесь ваш покорный слуга вновь столкнулся с терминолическими проблемами и, в частности, с отсутствием однозначного соответствия английским терминам в русскоязычной литературе. Поневоле создаётся впечатление, что эти разновидности деревьев либо называют исключительно по-английски, либо... Их вообще никак не называют. Посему, я взял в качестве русского эквивалента термина threaded binary search tree выражение "резьбовое бинарное дерево поиска". Продолжая аналогию с резьбой, right threaded BST я назвал правовинтовым деревом. Просто имейте ввиду, что когда речь идёт о резьбе, или правовинтовых деревьях, подразмевается по существу одно и то же - резьбовые деревья - перев.) Наиболее распространённое решение использует только связь через правые ссылки, оставляя левые ссылки такими же, как в обычном дереве:

1 struct node {
2         int data;
3         int thread;
4         struct node *link [2];
5 };
6
7 struct tree {
8         struct node *root;
9 };

Следующая диаграмма построена с применением упомянутой структуры. Каждая правая ссылка, которая указывала бы на лист, теперь указывает на старший узел, в то время, как левая ссылка указывает на обычный лист. Обратите внимание, что теперь мы можем напрямую пройти от узла 4 к узлу 6 вместо того, чтобы прокладывать себе путь через узел 3 при использовании родительских указателей или стека. Это первое преимущество резьбовых деревьев и вскоре вы увидите, что такие пути значительно упрощают пошаговые обходы. Также обратите внимание, как поиск узла 5 обошёл бы по кругу узлы 6 - 3 - 4, возратившись обратно к узлу 6. Вообще говоря, это нежелательно, потому мы используем флаг.

bt12

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

 1 int find (struct tree *tree, int data)
 2 {
 3         struct node *it = tree->root;
 4         int dir;
 5
 6         for ( ; ; ) {
 7                 dir = it->data < data;
 8
 9                 if (it->data == data)
10                         return 1;
11                 else if (dir == 1 && it->thread == 1)
12                         break;
13                 else if (it->link [dir] == NULL)
14                         break;
15
16                 it = it->link [dir];
17         }
18
19         return 0;
20 }

Т.к. симметрия нашего дерева нарушена, теперь нам необходимо учитывать разницу между движением влево (где мы можем найти лист, но не нить) и вправо (где мы можем найти нить, но не лист). При вставке поиск ведётся так же, но непосредственно при добавлении узла алгоритм должен учитывать ассимметрию. Если узел добавляется к правой ссылке, он должен принять от своего родителя нить. Если к левой ссылке, то нить передаётся родительскому узлу. Рассмотрим вставку узла 5 в резьбовое дерево, приведённое выше. 5 будет добавлено, как правая ссылка узла 4. Но правая ссылка узла 4 - нить. Чтобы обеспечить правильное построение резьбового дерева, нам нужно сместить нить вниз так, чтобы правая ссылка узла 5 указывала на узел 6. С другой стороны, если бы мы вставляли 0, то новый узел был бы присоединён к левой ссылке узла 2. Однако нам бы всё же пришлось установить правую нить узла 0 так, чтобы она ссылалась на старший узел - 2:

bt13

До тех пор, пока вы придерживаетесь этих правил для операции вставки, построение резьбового дерева - лёгкая работа, т.к. изменения, которые нужно вносить в дерево, носят локальный характер и вам не приходится обрабатывать всю структуру. Код, который это делает, не особо сложен, но он может вводить в заблуждение, если вы незнакомы с резьбовыми деревьями. В частности, кое-что этот код делает иначе для правых и левых ссылок. make_node() всё так же захватывает память, присваивает данные, устанавливает ссылки в NULL, но для резьбового дерева эта функция также устанавливает флаг в 1, потому что новый узел всегда будет внешним и без потомков. Заметьте, что самый правый узел в дереве имеет нить, ведущую к листу. Это может повлиять на другие операции на дереве. Вот и сам код:

 1 int insert (struct tree *tree, int data)
 2 {
 3         if (tree->root == NULL)
 4                 tree->root = make_node (data);
 5         else {
 6                 struct node *it = tree->root, *q;
 7                 int dir;
 8
 9                 for ( ; ; ) {
10                         dir = it->data < data;
11
12                         if (it->data == data)
13                                 return 0;
14                         else if (dir == 1 && it->thread == 1)
15                                 break;
16                         else if (it->link [dir] == NULL)
17                                 break;
18
19                         it = it->link [dir];
20                 }
21
22                 q = make_node (data);
23
24                 if (dir == 1) {
25                         q->link [1] = it->link [1];
26                         it->thread = 0;
27                 }
28                 else
29                         q->link [1] = it;
30
31                 it->link [dir] = q;
32         }
33
34         return 1;
35 }

Вместо непосредственного присвоения нового узла напрямую родителю, мы сохраняем его во временной переменной. Таким образом текущая нить родительского узла не теряется, если новый узел должен стать правой ссылкой родителя и процесс добавления нового узла в резьбовое дерево вовсе не так сложен, если вы понимаете идею :-)

Удаление узла из резьбового дерева следует тому же шаблону, но здесь у нас есть 4 случая изъятия узла из дерева. Если узел, который мы хотим удалить, не имеет потомков и является левым потомком своего родителя, мы можем просто изменить левую ссылку родителя, чтобы она указывала на лист. Если наш узел - правый и не имеет дочерних узлов, нам нужно установить правую ссылку его родителя в то же значение, которое содержит правая ссылка нашего удаляемого узла, чтобы сохранить нить.

bt14

Если узел, который мы хотим удалить, имеет потомков (это внешний узел, поэтому у него будет только один дочерний узел) и этот потомок - правый узел, мы просто заменяем удаляемый узел этим потомком, т.к. изменений в пути нити вносить не нужно. С другой стороны, если потомок слева, нам нужно передать нить удаляемого узла дочернему узлу, прежде чем мы заменим удаляемый узел его потомком. Здесь есть небольшая хитрость, но она стоит того, чтобы применить её в пошаговом обходе:

bt15

Код для выполнения удаления не очень сложен, но сперва он может показаться запутанным. Как обычно, мы используем элегантное удаление заменой узла, потому что оно короче и красивее. Красота - это хорошо. Я очень-очень серьёзно подхожу к моему коду с эстетических позиций. Конечно, это не значит, что я так же очень-очень серьёзно не подхожу к правильности кода ;-)

 1 int remove (struct tree *tree, int data)
 2 {
 3         if (tree->root != NULL) {
 4                 struct node head = { 0 };
 5                 struct node *it = &head;
 6                 struct node *q, *p, *f = NULL;
 7                 int dir = 1;
 8
 9                 it->link [1] = tree->root;
10
11                 while (it->link [dir] != NULL) {
12                         if (dir == 1 && it->thread == 1)
13                                 break;
14
15                         p = it;
16                         it = it->link [dir];
17                         dir = it->data <= data;
18
19                         if (it->data == data)
20                                 f = it;
21                 }
22
23                 if (f != NULL) {
24                         q = it->link [it->link [0] == NULL];
25                         dir = p->link [1] == it;
26                         f->data = it->data;
27
28                         if (p == q)
29                                 p->link [0] = NULL;
30                         else if (it->link [0] == NULL && it->thread) {
31                                 p->thread = 1;
32                                 p->link [1] = it->link [1];
33                         }
34                         else if (it->link[0] == NULL)
35                                 p->link [dir] = q;
36                         else {
37                                 q->thread = it->thread;
38                                 q->link [1] = it->link [1];
39                                 p->link [dir] = q;
40                         }
41
42                         free (it);
43                 }
44
45                 tree->root = head.link [1];
46         }
47
48         return 1;
49 }

Хорошо, но как эти условия соответствуют рассмотренным выше случаям? p - это родитель, а q - дочерний узел, которым мы заменяем родителя. Если p == q, то q - правый узел it и его нить к p. Это соответствует случаю, где мы на диаграмме удаляем узел 0. Если левая ссылка узла, который мы хотим удалить - лист, а правая - нить, то это соответствует тому случаю, когда мы удаляли узел 5. Если левая ссылка удаляемого узла равна NULL, а правая ссылка - не нить, то у нас случай с удалением узла 4. Ну и наконец последнее - случай с удалением узла 2 на диаграмме. Если вы хорошо понимаете, что происходит в каждом случае, то с пониманием кода у вас проблем возникнуть не должно.

Пошаговый обход очень упростился, потому что нам не нужно искать старший узел. Всё что нам нужно, так это пройти по ссылке. Вот и всё волшебство, которое было нам необходимо для движения вверх по дереву. Но это до тех пор, пока мы не дошли до правой стороны. Представленный алгоритм и является сакральной сутью симметричного обхода (ну, за исключением того, что иметь возможность двигаться в обоих направлениях в полностью резьбовом дереве было бы круче). Позор, что мы так долго шли к построению дерева, которое позволяет это:

 1 struct traversal {
 2         struct node *it;
 3 };
 4
 5 int *first (struct traversal *trav, struct tree *tree)
 6 {
 7         trav->it = tree->root;
 8
 9         if (trav->it != NULL) {
10         while (trav->it->link [0] != NULL)
11                 trav->it = trav->it->link [0];
12         }
13
14         if (trav->it != NULL)
15                 return &trav->it->data;
16         else
17                 return NULL;
18 }
19
20 int *next (struct traversal *trav)
21 {
22         if (trav->it->thread == 0) {
23                 trav->it = trav->it->link [1];
24
25                 if (trav->it != NULL) {
26                         while (trav->it->link [0] != NULL)
27                                 trav->it = trav->it->link[0];
28                 }
29         }
30         else
31                 trav->it = trav->it->link [1];
32
33         if (trav->it != NULL)
34                 return &trav->it->data;
35         else
36                 return NULL;
37 }

Родительские указатели более распространены, чем резьбовые деревья просто потому, что на указателях проще представить, что происходит. Правовинтовые деревья - самая распространённая разновидность резьбовых деревьев, но вы можете реализовать и симметричновинтовое дерево, которое похоже на рассмотренные выше, но позволяет иметь нити для двух направлений. Логика этих деревьев достаточно схожа с рассмотренной нами. Мы рассмотрели только довольно общий вариант, чтобы свести к минимуму затраты на лечение моего туннельного синдрома, который я получу от написания этих статей ;-)

Производительность [наверх]

Немного полезной теории касательно деревьев. Вам нужно знать, насколько хороши деревья на практике и для этого лучше обратиться к анализу, проведённому людьми, которые умнее, чем я. Если операции удаления и вставки случайны, то средняя эффективность дерева O(log N), или логарифм по основанию 2, или число степеней 2, чтобы добраться до N. И на самом деле, это хорошо, кстати. Если эффективность поиска O(log N), то на каждом последующем шаге поиска диапазон вариантов уменьшается где-то наполовину. Поэтому поиск в дереве очень быстрый, если операции вставки и удаления случайны.

Заметьте, я говорю "если вставка и удаление случайны". Таким образом я прикрываюсь от нападок тех дотошных личностей, которые готовы были бы меня сжечь на костре инквизиции, если бы я высказал своё мнение, как абсолютное. На самом деле для бинарных деревьев поиска есть действительно ужасный случай, когда каждый узел дерева - внешний. Самый простой способ получить такое дерево - вставка упорядоченной по возрастанию последовательности:

bt16

Вместо структуры с эффективностью O(log N) мы, в принципе, получили связный список, а производительность упала до O(N). Такой случай называют вырожденным деревом и он проявляется при крайне неблагоприятных последовательностях операций вставки и удаления, которые невозможно предсказать. Это не способствует надёжности деревьев. Дерево, которое пытается противостоять таким случаям, исправляя ситуацию, называется сбалансированным. Все деревья, рассмотренные в этой статье, несбалансированные, поэтому всё, что в ваших силах - убедиться, что вставка и удаления будут производиться случайно. Это уменьшит вероятность вырождения дерева.

Есть разные способы гарантировать близкую к оптимальной производительность либо при каждой операции, либо после последовательности таких операций, но эти способы могут быть очень сложными. Я написал руководства по наиболее распространённым таким способам, поэтому здесь я не буду снова повторять самого себя. Можно также полностью перебалансировать дерево. Например, копировать все узлы дерева в массив, затем выполнять по массиву что-то, вроде бинарного поиска, рекурсивно выбирая средний узел и вставляя его в новое дерево. В результате мы получим хорошо сбалансированное дерево. После такой перебалансировки вырожденное дерево, подобное тому, что приведено выше, примет такой вид:

bt17

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

bt18

Глобальная перебалансировка дерева довольно затратная операция, потому что её эффективность по крайней мере O(N), так что, если вы хотите обеспечивать структуру дерева таким способом, откладывайте перебалансировку как можно дольше :-) В случаях, когда вероятность вырождения дерева высока, используются разные схемы балансировки (такие, как AVL или красно-чёрные деревья), которые обеспечивают баланс за счёт локальных изменений. Это лучший выход, потому что сложность массовой перебалансировки всего дерева распределяется между отдельными операциями. Такой подход эффективнее, чем одна неэффективная массовая операция.

Окончательный вывод: эффективность бинарных деревьев поиска в среднем для дерева из N узлов составляет O(log N) и O(N) в худшем случае, что делает несбалансированное дерево не таким полезным, сбалансированное со средней гарантированной эффективностью O(log N). Но если известно, что данные на входе поступают в случайном порядке, небалансируемое дерево легче в реализации и поддержании..

Заключение [наверх]

Уф! Кто знал, что бинарные деревья поиска такие разносторонние? Я знал, а теперь знаете и вы. Темы, касающиеся деревьев, весьма многочисленны. Я бы мог забить статьями об этом всю квоту на хостинге. Однако, большая часть этого всего не имеет практической пользы в реальной жизни, либо интересно только интеллектуалам, которые сами не пишут реального кода. Я охватил те темы, которые позволят вам заработать ваши <название местной валюты> и те, которые в состоянии реализовать обычный человек. Как это часто бывает со структурами данных, вы можете усложнить их, насколько угодно и некоторые из балансирующихся деревья именно это и делают.

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

Оригинал этой статьи вы можете найти здесь: eternallyconfuzzled.com.

ПОСЕТИТЕЛИ

free counters