Blog

Percurso de árvores binárias em C#: as quatro ordens

Árvores são a outra estrutura que o C# não te entrega pronta. Não existe BinaryTreeNode<T> na BCL, então todo programa aqui declara um:

class TreeNode(int value, TreeNode? left = null, TreeNode? right = null)
{
    public int Value = value;
    public TreeNode? Left = left, Right = right;
}

Esta parte é só percurso — chegar a todo nó, numa ordem útil. A parte 13 é o que você calcula depois de chegar lá.

Todo programa abaixo é completo, foi rodado no .NET 10, e a saída está colada da execução.

Padrão 56 — As três ordens recursivas

Pré-ordem, em ordem e pós-ordem são a mesma função três vezes. A única diferença é em que linha a visita fica.

1 2 3 4 5 6 pré-ordem 1 2 4 5 3 6 visita, depois esquerda, depois direita em ordem 4 2 5 1 6 3 esquerda, depois visita, depois direita pós-ordem 4 5 2 6 3 1 esquerda, depois direita, depois visita

As três funções são idênticas exceto por em que linha a visita fica. Essa única linha de diferença é a distinção inteira, e ela decide se um nó é tratado antes ou depois de as respostas dos filhos existirem.

//         1
//       /   \
//      2     3
//     / \   /
//    4   5 6
TreeNode tree = new(1,
    new TreeNode(2, new TreeNode(4), new TreeNode(5)),
    new TreeNode(3, new TreeNode(6)));

// The three orders differ by ONE line: where the visit sits relative to the
// two recursive calls.
static void PreOrder(TreeNode? n, List<int> outp)
{
    if (n is null) return;
    outp.Add(n.Value);                 // visit, then children
    PreOrder(n.Left, outp);
    PreOrder(n.Right, outp);
}

static void InOrder(TreeNode? n, List<int> outp)
{
    if (n is null) return;
    InOrder(n.Left, outp);
    outp.Add(n.Value);                 // left, visit, right
    InOrder(n.Right, outp);
}

static void PostOrder(TreeNode? n, List<int> outp)
{
    if (n is null) return;
    PostOrder(n.Left, outp);
    PostOrder(n.Right, outp);
    outp.Add(n.Value);                 // children, then visit
}

foreach ((string name, Action<TreeNode?, List<int>> walk) in new (string, Action<TreeNode?, List<int>>)[]
         { ("pre-order ", PreOrder), ("in-order  ", InOrder), ("post-order", PostOrder) })
{
    List<int> got = [];
    walk(tree, got);
    Console.WriteLine($"{name}  {string.Join(" ", got)}");
}

Console.WriteLine();
Console.WriteLine("pre-order  visits a node BEFORE its subtrees  -> copying a tree, serialising");
Console.WriteLine("in-order   visits left, node, right           -> a BST comes out sorted");
Console.WriteLine("post-order visits a node AFTER its subtrees   -> freeing, or any answer that");
Console.WriteLine("                                                 depends on both children");

class TreeNode(int value, TreeNode? left = null, TreeNode? right = null)
{
    public int Value = value;
    public TreeNode? Left = left, Right = right;
}

Ele imprime:

pre-order   1 2 4 5 3 6
in-order    4 2 5 1 6 3
post-order  4 5 2 6 3 1

pre-order  visits a node BEFORE its subtrees  -> copying a tree, serialising
in-order   visits left, node, right           -> a BST comes out sorted
post-order visits a node AFTER its subtrees   -> freeing, or any answer that
                                                 depends on both children

Nomear as ordens pela posição é como elas costumam ser ensinadas, e não é o que as torna úteis. A distinção útil é se um nó é tratado antes ou depois de as respostas dos filhos existirem:

  • Pré-ordem processa um nó sem saber nada das subárvores. Bom para copiar uma árvore, serializar, ou qualquer coisa em que os dados do próprio nó bastam.
  • Em ordem numa árvore binária de busca produz saída ordenada. Isso não é coincidência — é a definição de uma BST expressa como um percurso.
  • Pós-ordem processa um nó depois que os dois filhos terminaram. Qualquer resposta que combina resultados de baixo é pós-ordem, com ou sem esse nome. A parte 13 inteira é pós-ordem.

Custo: O(n) de tempo, O(h) de espaço para a pilha de chamadas, onde h é a altura.

Use quando — sempre. Este é o vocabulário em que o resto do trabalho com árvores é escrito.

Padrão 57 — Em ordem sem recursão

A parte 7 mostrou que o C# encerra o processo num stack overflow, sem chance de capturar, e que uma árvore degenerada é profunda. Então a versão iterativa importa mais aqui do que em outras linguagens.

As pessoas não conseguem reconstruir isso sob pressão porque tentam lembrar do código. O código sai de nomear o que a pilha guarda.

4 2 6 1 3 5 pilha 1 2 4 fundo Todo nó aqui já terminou o lado ESQUERDO, e ainda não foi visitado. É isso que a recursão guardava na pilha de chamadas. Vá para a esquerda o máximo possível, empilhando. Desempilhe, visite, e recomece pelo filho direito.

Escrever a pilha na mão só é difícil até você conseguir nomear o que está nela. Quando “esquerda pronta, ainda não visitado” é o invariante, o laço se escreve sozinho — e ele não pode estourar como a versão recursiva estoura numa árvore degenerada.

//      4
//    /   \
//   2     6
//  / \   /
// 1   3 5
TreeNode tree = new(4,
    new TreeNode(2, new TreeNode(1), new TreeNode(3)),
    new TreeNode(6, new TreeNode(5)));

// Recursion keeps its place implicitly, on the call stack. Doing it by hand
// means the stack holds the nodes whose LEFT side is done but which have not
// themselves been visited yet.
static List<int> InOrderIterative(TreeNode? root, bool trace)
{
    List<int> outp = [];
    Stack<TreeNode> st = [];
    TreeNode? cur = root;

    while (cur is not null || st.Count > 0)
    {
        while (cur is not null)                 // go as far left as possible
        {
            st.Push(cur);
            if (trace) Console.WriteLine($"  push {cur.Value}   stack=[{string.Join(",", st.Select(x => x.Value).Reverse())}]");
            cur = cur.Left;
        }
        cur = st.Pop();                         // nothing further left: visit
        outp.Add(cur.Value);
        if (trace) Console.WriteLine($"  pop  {cur.Value}   visit -> [{string.Join(" ", outp)}]");
        cur = cur.Right;                        // then handle the right subtree
    }
    return outp;
}

var got = InOrderIterative(tree, true);
Console.WriteLine($"\nin-order: {string.Join(" ", got)}");
Console.WriteLine($"sorted:   {got.SequenceEqual(got.Order())}   <- it is a BST, so in-order is sorted");

class TreeNode(int value, TreeNode? left = null, TreeNode? right = null)
{
    public int Value = value;
    public TreeNode? Left = left, Right = right;
}

Ele imprime:

  push 4   stack=[4]
  push 2   stack=[4,2]
  push 1   stack=[4,2,1]
  pop  1   visit -> [1]
  pop  2   visit -> [1 2]
  push 3   stack=[4,3]
  pop  3   visit -> [1 2 3]
  pop  4   visit -> [1 2 3 4]
  push 6   stack=[6]
  push 5   stack=[6,5]
  pop  5   visit -> [1 2 3 4 5]
  pop  6   visit -> [1 2 3 4 5 6]

in-order: 1 2 3 4 5 6
sorted:   True   <- it is a BST, so in-order is sorted

A pilha guarda nós cuja subárvore esquerda terminou mas que ainda não foram visitados. É exatamente isso que a pilha de chamadas guardava implicitamente. Com essa frase na cabeça, o laço é forçado:

  1. Vá para a esquerda o máximo possível, empilhando tudo no caminho.
  2. Nada mais à esquerda? Desempilhe e visite — o lado esquerdo está pronto por construção.
  3. Depois vá para o filho direito, e volte ao passo 1.

A condição externa é cur is not null || st.Count > 0, e as duas metades são necessárias. A pilha pode estar vazia enquanto ainda existe uma subárvore direita para descer, que é o caso bem no começo e de novo no nó 4 do trace acima.

Custo: O(n) de tempo, O(h) de espaço — o mesmo espaço, mas no heap, onde ele não pode matar o processo.

Use quando a árvore pode ser profunda, ou quando o problema quer o percurso pausado e retomado — um iterador de BST é esse laço com o miolo tirado.

Padrão 58 — Percurso por níveis

Todo padrão até aqui foi em profundidade. O percurso por níveis é em largura, e é a mesma fila da parte 7 com uma adição.

O problema: uma fila é uma sequência plana e não sabe nada de níveis. Nós do nível 2 entram na fila enquanto o nível 1 ainda está sendo drenado, então tudo fica misturado.

fila 2 3 largura = 2, capturada ANTES do laço depois 2 3 4 5 6 o próximo nível, já misturado Leia q.Count dentro do laço e ele cresce conforme os filhos entram, e o nível nunca acaba. Leia uma vez, numa variável local, e exatamente um nível é drenado.

Uma fila não sabe de níveis — ela é uma sequência plana. A fronteira é recuperada tirando uma foto de quantos nós estavam esperando no momento em que o nível começou.

//         1
//       /   \
//      2     3
//     / \     \
//    4   5     6
//       /
//      7
TreeNode tree = new(1,
    new TreeNode(2, new TreeNode(4), new TreeNode(5, new TreeNode(7))),
    new TreeNode(3, null, new TreeNode(6)));

// The queue naturally mixes levels together. Capturing Count BEFORE the inner
// loop is what puts the boundaries back.
static List<List<int>> LevelOrder(TreeNode? root)
{
    List<List<int>> levels = [];
    if (root is null) return levels;

    Queue<TreeNode> q = [];
    q.Enqueue(root);

    while (q.Count > 0)
    {
        int width = q.Count;              // exactly this many nodes are on this level
        List<int> level = [];
        for (int i = 0; i < width; i++)
        {
            TreeNode n = q.Dequeue();
            level.Add(n.Value);
            if (n.Left is not null) q.Enqueue(n.Left);
            if (n.Right is not null) q.Enqueue(n.Right);
        }
        levels.Add(level);
        Console.WriteLine($"level {levels.Count - 1}: width was {width}, values [{string.Join(", ", level)}], queue now holds {q.Count}");
    }
    return levels;
}

var levels = LevelOrder(tree);
Console.WriteLine($"\nlevels: [{string.Join("], [", levels.Select(l => string.Join(", ", l)))}]");
Console.WriteLine($"depth: {levels.Count}");

class TreeNode(int value, TreeNode? left = null, TreeNode? right = null)
{
    public int Value = value;
    public TreeNode? Left = left, Right = right;
}

Ele imprime:

level 0: width was 1, values [1], queue now holds 2
level 1: width was 2, values [2, 3], queue now holds 3
level 2: width was 3, values [4, 5, 6], queue now holds 1
level 3: width was 1, values [7], queue now holds 0

levels: [1], [2, 3], [4, 5, 6], [7]
depth: 4

O truque inteiro é int width = q.Count; antes do laço interno. Essa foto é quantos nós pertencem a este nível, e drenar exatamente essa quantidade acerta a fronteira.

Escreva for (int i = 0; i < q.Count; i++) no lugar e a condição é reavaliada a cada iteração contra uma fila que cresce conforme os filhos entram. O nível nunca acaba, e você recebe uma lista plana só — o trace acima mostra a contagem indo de 2 para 3 no meio do nível, que é exatamente o que daria errado.

Custo: O(n) de tempo, O(w) de espaço, onde w é o nível mais largo.

Use quando o problema menciona níveis, profundidade, ou o mais próximo. Profundidade mínima em particular deve ser BFS, não DFS — o BFS pode parar na primeira folha que encontra, enquanto o DFS tem que explorar tudo.

Padrão 59 — Zigzag, vistas laterais, e o resto

Uma vez que o percurso por níveis existe, um número surpreendente de problemas de árvore é uma linha em cima dele.

TreeNode tree = new(1,
    new TreeNode(2, new TreeNode(4), new TreeNode(5, new TreeNode(7))),
    new TreeNode(3, null, new TreeNode(6)));

static List<List<int>> Levels(TreeNode? root)
{
    List<List<int>> levels = [];
    if (root is null) return levels;
    Queue<TreeNode> q = [];
    q.Enqueue(root);
    while (q.Count > 0)
    {
        int width = q.Count;
        List<int> level = [];
        for (int i = 0; i < width; i++)
        {
            TreeNode n = q.Dequeue();
            level.Add(n.Value);
            if (n.Left is not null) q.Enqueue(n.Left);
            if (n.Right is not null) q.Enqueue(n.Right);
        }
        levels.Add(level);
    }
    return levels;
}

var levels = Levels(tree);

// Zigzag: do NOT alternate the traversal. Reverse alternate rows afterwards.
var zigzag = levels.Select((l, i) => i % 2 == 1 ? Enumerable.Reverse(l).ToList() : l).ToList();

// Right side view: the last value of each level.
var rightView = levels.Select(l => l[^1]).ToList();

// Left side view is the first of each level, for free.
var leftView = levels.Select(l => l[0]).ToList();

Console.WriteLine($"levels     : [{string.Join("], [", levels.Select(l => string.Join(", ", l)))}]");
Console.WriteLine($"zigzag     : [{string.Join("], [", zigzag.Select(l => string.Join(", ", l)))}]");
Console.WriteLine($"right view : {string.Join(", ", rightView)}");
Console.WriteLine($"left view  : {string.Join(", ", leftView)}");
Console.WriteLine($"max depth  : {levels.Count}");
Console.WriteLine($"widest     : {levels.Max(l => l.Count)} nodes, at level {levels.FindIndex(l => l.Count == levels.Max(x => x.Count))}");

Console.WriteLine();
Console.WriteLine("Every one of these is the level list plus one line. Trying to build");
Console.WriteLine("zigzag by alternating the traversal itself is where people tie themselves");
Console.WriteLine("in knots — the queue order stays the same, only the output is reversed.");

class TreeNode(int value, TreeNode? left = null, TreeNode? right = null)
{
    public int Value = value;
    public TreeNode? Left = left, Right = right;
}

Ele imprime:

levels     : [1], [2, 3], [4, 5, 6], [7]
zigzag     : [1], [3, 2], [4, 5, 6], [7]
right view : 1, 3, 6, 7
left view  : 1, 2, 4, 7
max depth  : 4
widest     : 3 nodes, at level 2

Every one of these is the level list plus one line. Trying to build
zigzag by alternating the traversal itself is where people tie themselves
in knots — the queue order stays the same, only the output is reversed.

O zigzag é onde as pessoas se enrolam, porque o instinto é alternar o percurso — enfileirar direita antes de esquerda nos níveis ímpares, ou trocar a fila por uma pilha. Isso funciona, é chato de escrever, e é fácil de errar.

A ordem da fila não precisa mudar. Monte os níveis normalmente, depois inverta as linhas alternadas. Mesma resposta, uma linha, nada para depurar.

As vistas laterais são o último e o primeiro elemento de cada nível. A profundidade máxima é a contagem. O nível mais largo é um Max. Nenhum deles precisa de algoritmo próprio.

Use quando qualquer pergunta for formulada por nível. Monte os níveis, depois responda a pergunta sobre a lista.

Padrão 60 — Reconstruir uma árvore a partir de dois percursos

Dados o percurso em pré-ordem e o em ordem, reconstrua a árvore.

Um percurso sozinho nunca basta — muitas árvores diferentes compartilham a mesma pré-ordem. Dois, com o percurso em ordem entre eles, fixam a árvore exatamente.

pré-ordem 1 2 4 5 3 6 o primeiro item é a raiz em ordem 4 2 5 1 6 3 subárvore esquerda raiz subárvore direita Três valores à esquerda, então os próximos três valores da pré-ordem são toda a subárvore esquerda.

Ache a posição da raiz no array em ordem com um dicionário, não com uma varredura. Uma varredura faz cada nível ser O(n) e a reconstrução inteira ser O(n²) numa árvore degenerada — que é exatamente a entrada que um teste vai usar.

int[] preorder = [1, 2, 4, 5, 3, 6];
int[] inorder  = [4, 2, 5, 1, 6, 3];

// pre-order gives you the ROOT first. in-order tells you how much of the rest
// belongs on each side of it. A dictionary makes "where is the root in inorder"
// O(1) instead of a scan, which is the difference between O(n) and O(n^2).
Dictionary<int, int> where = inorder.Select((v, i) => (v, i)).ToDictionary(t => t.v, t => t.i);
int cursor = 0;

TreeNode? Build(int lo, int hi, int depth)
{
    if (lo > hi) return null;
    int value = preorder[cursor++];
    int mid = where[value];
    Console.WriteLine($"{new string(' ', depth * 2)}root {value}: inorder[{lo}..{hi}], splits at {mid} " +
                      $"-> left [{lo}..{mid - 1}], right [{mid + 1}..{hi}]");
    var node = new TreeNode(value);
    node.Left = Build(lo, mid - 1, depth + 1);       // must come first: it consumes
    node.Right = Build(mid + 1, hi, depth + 1);      // the pre-order cursor in order
    return node;
}

TreeNode? root = Build(0, inorder.Length - 1, 0);

static void Pre(TreeNode? n, List<int> o) { if (n is null) return; o.Add(n.Value); Pre(n.Left, o); Pre(n.Right, o); }
static void In(TreeNode? n, List<int> o) { if (n is null) return; In(n.Left, o); o.Add(n.Value); In(n.Right, o); }

List<int> p = [], i = [];
Pre(root, p); In(root, i);
Console.WriteLine($"\nrebuilt pre-order: {string.Join(" ", p)}   matches: {p.SequenceEqual(preorder)}");
Console.WriteLine($"rebuilt in-order : {string.Join(" ", i)}   matches: {i.SequenceEqual(inorder)}");

class TreeNode(int value, TreeNode? left = null, TreeNode? right = null)
{
    public int Value = value;
    public TreeNode? Left = left, Right = right;
}

Ele imprime:

root 1: inorder[0..5], splits at 3 -> left [0..2], right [4..5]
  root 2: inorder[0..2], splits at 1 -> left [0..0], right [2..2]
    root 4: inorder[0..0], splits at 0 -> left [0..-1], right [1..0]
    root 5: inorder[2..2], splits at 2 -> left [2..1], right [3..2]
  root 3: inorder[4..5], splits at 5 -> left [4..4], right [6..5]
    root 6: inorder[4..4], splits at 4 -> left [4..3], right [5..4]

rebuilt pre-order: 1 2 4 5 3 6   matches: True
rebuilt in-order : 4 2 5 1 6 3   matches: True

Duas coisas sustentam isso.

O primeiro valor não consumido da pré-ordem é sempre a próxima raiz. Um único cursor compartilhado anda para frente, e é por isso que Build(left) tem que ser chamado antes de Build(right) — a subárvore esquerda consome a parte dela da pré-ordem primeiro. Troque essas duas linhas e a árvore sai espelhada, sem nenhum erro.

O percurso em ordem diz onde dividir. Tudo à esquerda da posição da raiz pertence à subárvore esquerda, tudo à direita pertence à direita. O dicionário where faz esse lookup ser O(1); varrer o array em ordem no lugar disso faz a reconstrução inteira ser O(n²) numa árvore degenerada, que é precisamente a entrada que um caso de teste vai escolher.

Repare que o caso base lo > hi trata os intervalos vazios do trace — [0..-1] e [2..1] são como um filho ausente se anuncia.

Pós-ordem mais em ordem funciona do mesmo jeito, consumindo a pós-ordem de trás para frente e construindo a direita antes da esquerda. Pré-ordem mais pós-ordem não determina uma árvore de forma única.

Custo: O(n) de tempo e de espaço.

Use quando te derem dois percursos, ou quando estiver serializando e desserializando uma árvore.

O que lembrar

  • As três ordens diferem por uma linha. O que importa é se um nó é tratado antes ou depois de as respostas dos filhos existirem.

  • Em ordem numa BST sai ordenado. Essa é a definição, percorrida.

  • A pilha iterativa guarda “esquerda pronta, ainda não visitado”. Nomeie o invariante e o código sai; decorar o código não sobrevive à pressão.

  • Guarde q.Count numa variável local antes de drenar um nível. Reler isso na condição do laço significa que o nível nunca acaba.

  • Não alterne o percurso para fazer zigzag. Monte os níveis, inverta as linhas alternadas.

  • Construa a subárvore esquerda antes da direita. Elas compartilham um cursor de pré-ordem, e a ordem dessas duas chamadas é toda a diferença entre uma árvore e o espelho dela.

  • Indexe o array em ordem com um dicionário. Varrer transforma O(n) em O(n²) exatamente na entrada que um teste vai escolher.

A parte 13 é o que você faz depois de conseguir chegar a todo nó: somas de caminho, diâmetro, ancestrais, e a distinção entre o que uma recursão retorna e o que ela carrega para baixo.

How useful was this post?

Click on a heart to rate it!

Average rating 0 / 5. Vote count: 0

No votes so far! Be the first to rate this post.