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

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

Первым делом определим структуру.

struct node {
        unsigned long number;
        unsigned long count;
        unsigned long step;
        struct node *parent;
        struct node *left;
        struct node *right;
};

Здесь number это число, по которому будет идти сортировка node. Count это количество совпадений. Step нужен будет для вывода совпадений в консоль, он будет определять, было ли вхождение в этот node или нет.

Делаем глобальную ссылку на корень дерева.

struct node *root;

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

static void add_node (const int number)
{
	register struct node *prev = NULL;
	register unsigned long left = 0;
	register struct node *p = root;

	while (1)
	{
		if (p == NULL)
		{
			p = calloc (1, sizeof (struct node));
			p->number = number;
			p->count = 1;
			if (prev)
			{
				if (left) prev->left = p;
				else prev->right = p;
				p->parent = prev;
			}
			if (root == NULL) 
			{
				root = p;
				p->parent = NULL;
			}
			return;
		}
		prev = p;
		if (p->number > number)
		{
			left = 1;
			if (p->left && p->number < p->left->number)
			{
				register struct node *up = p;
				register struct node *down = p->left;
				p = calloc (1, sizeof (struct node));
				p->number = number;
				p->count = 1;
				p->parent = up;
				p->left = down;
				return;
			}
			p = p->left;
		} else if (p->number < number)
		{
			left = 0;
			if (p->right && p->number > p->right->number)
			{
				register struct node *up = p;
				register struct node *down = p->right;
				p = calloc (1, sizeof (struct node));
				p->number = number;
				p->count = 1;
				p->parent = up;
				p->right = down;
				return;
			}
			p = p->right;
		} else if (p->number == number)
		{
			p->count++;
			return;
		}
	}
}

Я указал локальные переменные как регистры, чтобы хоть чуточку быстрее работало. Если посмотреть через дизассемблер, то можно увидеть, что у 64 битного проца хватает регистров.

[0x00401080]> s sym.add_node
[0x00401166]> pd 30
            ;-- add_node:
            0x00401166      55             push rbp
            0x00401167      4889e5         mov rbp, rsp
            0x0040116a      4155           push r13
            0x0040116c      4154           push r12
            0x0040116e      53             push rbx
            0x0040116f      4883ec18       sub rsp, 0x18
            0x00401173      897ddc         mov dword [rbp - 0x24], edi
            0x00401176      41bc00000000   mov r12d, 0
            0x0040117c      41bd00000000   mov r13d, 0
            0x00401182      488b1dcf2e00.  mov rbx, qword [obj.root]   ; [0x404058:8]=0
            0x00401189      4885db         test rbx, rbx
        ┌─< 0x0040118c      7559           jne 0x4011e7

Теперь добавление происходит без рекурсии. Далее надо пройтись по дереву и посмотреть есть ли совпадения. Такое тоже возможно не применяя рекурсию. Вот код.

static void find_matches ( )
{
	register struct node *n = root;
	register nm = 0;
	register n->step = 0;
	while (n)
	{
		if (n->step == 0 && n->count > 1) 
		{ 
			printf ("%ld: %ld\n", n->number, n->count); 
			nm++; 
		}
		n->step = 1;
		if (n->left && n->left->step == 0)
		{
			n = n->left;
			continue;
		}
		else if (n->right && n->right->step == 0)
		{
			n = n->right;
			continue;
		}
		else if (n->step == 1) 
		{
			if (n->left) n->left->step = 0;
			if (n->right) n->right->step = 0;
			n = n->parent;
			if (n && n->step == 1 && n->parent == NULL) 
			{
				n->step == 2;
				continue;
			} else if (!n) break;
		}
		if (n->step == 1 && n->parent == NULL) break;
	}
}

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

Теперь посмотрите полный код.

static void add_node (const int number)
{
    register struct node *prev = NULL;
    register unsigned long left = 0;
    register struct node *p = root;

    while (1)
    {
        if (p == NULL)
        {
            p = calloc (1, sizeof (struct node));
            p->number = number;
            p->count = 1;
            if (prev)
            {
                if (left) prev->left = p;
                else prev->right = p;
                p->parent = prev;
            }
            if (root == NULL) 
            {
                root = p;
                p->parent = NULL;
            }
            return;
        }
        prev = p;
        if (p->number > number)
        {
            left = 1;
            if (p->left && p->number < p->left->number)
            {
                register struct node *up = p;
                register struct node *down = p->left;
                p = calloc (1, sizeof (struct node));
                p->number = number;
                p->count = 1;
                p->parent = up;
                p->left = down;
                return;
            }
            p = p->left;
        } else if (p->number < number)
        {
            left = 0;
            if (p->right && p->number > p->right->number)
            {
                register struct node *up = p;
                register struct node *down = p->right;
                p = calloc (1, sizeof (struct node));
                p->number = number;
                p->count = 1;
                p->parent = up;
                p->right = down;
                return;
            }
            p = p->right;
        } else if (p->number == number)
        {
            p->count++;
            return;
        }
    }
}

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

Я поменял статью, дерево теперь сбалансированное.

Всем спасибо за внимание.

Комментарии (22)


  1. ubunifu
    21.11.2021 06:03
    +1

    Nested sets часто используется в крупных магазинах, смысл не только в том что можно от рекурсии избавится, а к примеру получить дерево одним запросом

    SELECT id, name, step FROM category_tree ORDER BY left

    В итоге, после небольшой обработки (в которой step играет роль множителя отступа), получим следующий список:

    • Узел 1
    • • Узел 2
    • • • Узел 5
    • • • • Узел 10
    • • • • Узел 11
    • • Узел 3
    • • • Узел 6
    • • • Узел 7
    • • • • Узел 12
    • • • • Узел 13
    • • • • Узел 14
    • • • Узел 8
    • • Узел 4
    • • • Узел 9
    • • • • Узел 15
    • • • • Узел 16


  1. SpiderEkb
    21.11.2021 07:45

    1. xverizex Автор
      21.11.2021 07:49

      О, не видел такой. Спасибо. Позже почитаю.


  1. Politura
    21.11.2021 08:23
    +11

    Оно, конечно, полезно пытаться что-то сделать самому, но неплохо-бы еще прокачать оценку своих изысканий и не тащить на хабр откровенно нубский материал.

    Проблемы в порядке чтения, а не в порядке важности:

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

    2. Дерево не самобалансирующее, а это значит, что на отсортированных данных заполнение дерева даст квадратичную сложность. Ну и на рандомных данных сложность будет гулять от O(n*log(n)) до O(n*n).

    3. Для задачи поиска повторяющихся значений деревья вообще не нужны, можно использовать HashMap у которого сложность добавления и поиска элементов - константа, в результате сложность заполнения мапы будет линейная O(n), а если еще дубли просто класть в HasSet, то проход по всем дублям будет размером с количество оных дублей.

    4. Поиск дублей капец замороченный, прям мозг отказывается читать, ибо надо чуть напрячься, тогда как в нормальном проходе по дереву все ясно с первого взгляда.

    5. С учетом того, что во время поиска в step кладутся либо 0, либо 1, либо 2, есть стойкое подозрение, что на длинных ветках оный поиск просто будет ломаться.

    Вообще в рекурсии нет ничего плохого, есть знать, что делаешь.


    1. xverizex Автор
      21.11.2021 09:38
      -7

      1. Дерево не самобалансирующее, а это значит, что на отсортированных данных заполнение дерева даст квадратичную сложность. Ну и на рандомных данных сложность будет гулять от O(n*log(n)) до O(n*n).

      Вы придумываете проблемы, чтобы придраться чтоли? если данные отсортированы, то мы просто бинарным поиском ищем, если данные как попало поступают, то делаем дерево.


      1. tyomitch
        21.11.2021 10:25
        +3

        Пусть все данные отсортированы, кроме двух элементов, которые идут в обратном порядке. Как будете искать?


    1. xverizex Автор
      21.11.2021 10:09
      -4

      я сделал сбалансированное дерево. исправил. можете проверить даже, что всё работает.


      1. tyomitch
        21.11.2021 10:21
        +3

        Вы уверены, что вы понимаете, что такое «сбалансированное дерево»?
        Не вникая в ваш код — вижу, что в root всегда остаётся первый добавленный узел. Значит, это не оно.


  1. GospodinKolhoznik
    21.11.2021 08:57
    +3

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

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

    А цикл, в котором происходит что то забористое и замороченное плохо поддается анализу. Невозможно предугадать как он себя поведет в том или ином ом случае и какие в нем могут быть скрыты потенциальные баги и проблемы.


  1. qrdl
    21.11.2021 09:05
    +4

    Я думаю что оно в некоторых случаях будет более удобно, нежели дерево с рекурсией.

    В каких случаях? Чем удобнее? Удалось ли подтвердить свое предположение? Непонятен смысл всех этих танцев с бубном.

    Я указал локальные переменные как регистры, чтобы хоть чуточку быстрее работало. Если посмотреть через дизассемблер, то можно увидеть, что у 64 битного проца хватает регистров.

    Это уже давно не влияет, компилятор сам решает, где ему лучше разместить переменные, так что использовать register смысла нет.

    И количество регистров никак не связано с разрядностью проца, если вдруг это подразумевалось.


    1. tyomitch
      21.11.2021 09:12
      +2

      У x64 вдвое больше регистров общего назначения по сравнению с x86, если вдруг это подразумевалось.


      1. qrdl
        21.11.2021 09:17
        +1

        Несомненно, но ведь вызвано это отнюдь не увеличением разврядности. Не удивлюсь, если у какого-то древнего 32-битного RISC'а из 90-х (типа IBM POWER2) регистров намного больше, чем у x86_64

        А из текста автора может показаться, что разрядность и кол-во регистров - вещи связанные.


      1. xverizex Автор
        21.11.2021 09:33

        Да, это подразумевалось.


    1. xverizex Автор
      21.11.2021 09:33
      -5

      В каких случаях? Чем удобнее? Удалось ли подтвердить свое предположение? Непонятен смысл всех этих танцев с бубном.

      Ну как? Даже Robert Sedgewick пишет в своей книге Algorithms in C, что стек может переполниться. ну вы представляете если у вас 30 000 000 или больше вложений получиться в дереве, какой будет стек? 30 000 000 * 8 = 180 000 000, а тут же ещё память выделенная для данных. Короче я опять думаю что вы не подумав мне написали.


      1. qrdl
        21.11.2021 09:54
        +5

        Вы считаете все атомы во Вселенной, что у вас 30 млн уровней в двоичном дереве? И если вы имели в виду 30 млн узлов в дереве, то это всего лишь 25 уровней в сбалансированном дереве, значит глубина рекурсии не более 25.
        С учетом того, что по AMD64 ABI все параметры и результат передаются в регистрах, локальные переменные скорее всего тоже будут в регистрах, стэк будет использоваться по минимуму.
        А хамить не стоит.


        1. tyomitch
          22.11.2021 08:10
          +1

          Добавлю, что атомов во вселенной порядка 2^270, так что миллионы и даже тысячи уровней в дереве не могут быть вообще никогда.


      1. qrdl
        21.11.2021 09:58

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


  1. tyomitch
    21.11.2021 09:09
    +7

    Компилятор сам отлично умеет разворачивать хвостовую рекурсию в цикл (равно как и раскладывать локальные переменные по регистрам). Какой смысл все это расписывать вручную? Автор черпал вдохновение в афоризме Ларри Уолла "Real programmers can write assembly code in any language"?


  1. winwood
    21.11.2021 12:33
    +2

    У меня вопрос - зачем это делать, если есть стандартная библиотека С++ и на выбор set, multiset, map, multimap, unorderes_set, unorderes_multiset, unorderes_map, unorderes_multimap?
    Кроме того, стандартная библиотека шаблонная. А здесь, при смене типа хранимых данных, придется все переписывать.
    И еще - чем больше кода, тем больше ошибок. И времени на отладку. Например n->step == 2; в функции find_matches. Или calloc без free.
    На С++ со стандартными библиотеками это могло выглядеть примерно так:

    int main(int argc, char **argv)
    {
    srandom(time(0));
    std::map tree;
    for (unsigned long i = 0; i < 1000000; i++){
    unsigned long r = random () % 1000;
    tree[r]++;
    }
    for (auto e : tree) {
    std::cout << e.first << ": " << e.second << std::endl;
    }
    return 0;
    }


  1. AirLight
    22.11.2021 04:26

    Зачем м в узле определять и родителей и потомков? Это не избыточно?


    1. xverizex Автор
      22.11.2021 04:36

      а вы попробуйте без рекурсии пройтись по всем элементам и отобразить их.


      1. tyomitch
        22.11.2021 08:05

        Вместо того, чтобы тратить O(log n) дополнительной памяти на хранение пути от корня до текущего узла, предлагаете тратить O(n) дополнительной памяти на хранение родителя каждого узла?