A maneira mais eficiente de gerar todos os descendentes de todos os nós em uma árvore


9

Estou procurando o algoritmo mais eficiente para pegar uma árvore (armazenada como uma lista de arestas; OU como uma lista de mapeamentos do nó pai para uma lista de nós filhos); e produza, para TODOS os nós, uma lista de todos os nós dele descendentes (nível de folha e nível não-folha).

A implementação deve ser via loops, em vez de recusão, devido à escala; e idealmente deve ser O (N).

Essa pergunta do SO cobre uma solução padrão razoavelmente óbvia para encontrar a resposta para UM nó em uma árvore. Mas, obviamente, repetir esse algoritmo em todos os nós das árvores é altamente ineficiente (em primeiro lugar, O (NlogN) a O (N ^ 2)).

A raiz da árvore é conhecida. A árvore tem uma forma absolutamente arbitrária (por exemplo, não é N-nária, não é equilibrada de forma alguma, forma ou forma, não possui profundidade uniforme) - alguns nós têm 1-2 filhos, outros 30K filhos.

Em um nível prático (embora não deva afetar o algoritmo), a árvore possui ~ 100K-200K nós.


Você pode simular a recursão usando um loop e uma pilha, isso é permitido para sua solução?
Giorgio

@Giorgio - é claro. Foi o que tentei sugerir com "via loops em vez de recusion".
DVK 20/01

Respostas:


5

Se você realmente deseja PRODUZIR todas as listas como cópias diferentes, na pior das hipóteses, poderá obter melhor que n ^ 2 de espaço. Se você só precisa de ACESSO para cada lista:

Eu executaria uma travessia em ordem da árvore a partir da raiz:

http://en.wikipedia.org/wiki/Tree_traversal

Em seguida, para cada nó na árvore, armazene o número mínimo de pedido e o número máximo de pedidos em sua subárvore (isso é facilmente mantido por meio de recursão - e você pode simular isso com uma pilha, se desejar).

Agora você coloca todos os nós em uma matriz A de comprimento n onde o nó com o número em ordem i está na posição i. Então, quando você precisar encontrar a lista para um nó X, procure em A [X.min, X.max] - observe que esse intervalo incluirá o nó X, que também pode ser facilmente corrigido.

Tudo isso é realizado em O (n) tempo e ocupa O (n) espaço.

Eu espero que isso ajude.


2

A parte ineficiente não é atravessar a árvore, mas construir as listas de nós. Parece sensato criar a lista assim:

descendants[node] = []
for child in node.childs:
    descendants[node].push(child)
    for d in descendants[child]:
        descendants[node].push(d)

Como cada nó descendente é copiado na lista de cada pai, acabamos com O (n log n) complexidade em média para árvores balanceadas e O (n²) pior caso para árvores degeneradas que são realmente listas vinculadas.

Podemos passar para O (n) ou O (1), dependendo de você precisar fazer alguma configuração, se usarmos o truque de calcular as listas preguiçosamente. Suponha que temos um child_iterator(node)que nos dá os filhos desse nó. Podemos então definir trivialmente algo descendant_iterator(node)como isto:

def descendant_iterator(node):
  for child in child_iterator(node):
    yield from descendant_iterator(child)
  yield node

Uma solução não recursiva está muito mais envolvida, pois o fluxo de controle do iterador é complicado (corotinas!). Atualizarei esta resposta hoje mais tarde.

Como a travessia de uma árvore é O (n) e a iteração sobre uma lista também é linear, esse truque adia completamente o custo até que seja pago de qualquer maneira. Por exemplo, a impressão da lista de descendentes para cada nó tem O (n²) na pior das hipóteses: A iteração em todos os nós é O (n) e a iteração nos descendentes de cada nó, estejam eles armazenados em uma lista ou calculados ad hoc .

Obviamente, isso não funcionará se você precisar de uma coleção real para trabalhar.


Desculpe -1. Todo o objetivo do agloritmo é pré-computar os dados. A computação preguiçosa está derrotando completamente o motivo de executar o algo.
DVK 21/01

2
@DVK Ok, posso ter entendido mal suas necessidades. O que você está fazendo com as listas resultantes? Se a pré-computação das listas for um gargalo (mas não as estiver usando), isso indicará que você não está usando todos os dados agregados e que o cálculo preguiçoso seria uma vitória. Mas se você usar todos os dados, o algoritmo para pré-computação é amplamente irrelevante - a complexidade algorítmica do uso dos dados será pelo menos igual à complexidade da criação das listas.
amon

0

Este algoritmo curto deve fazê-lo. Dê uma olhada no código public void TestTreeNodeChildrenListing()

Na verdade, o algoritmo passa pelos nós da árvore em sequência e mantém a lista de pais do nó atual. Conforme seu requisito, o nó atual é filho de cada nó pai e é adicionado a cada um deles como filho.

O resultado final é armazenado no dicionário.

    [TestFixture]
    public class TreeNodeChildrenListing
    {
        private TreeNode _root;

        [SetUp]
        public void SetUp()
        {
            _root = new TreeNode("root");
            int rootCount = 0;
            for (int i = 0; i < 2; i++)
            {
                int iCount = 0;
                var iNode = new TreeNode("i:" + i);
                _root.Children.Add(iNode);
                rootCount++;
                for (int j = 0; j < 2; j++)
                {
                    int jCount = 0;
                    var jNode = new TreeNode(iNode.Value + "_j:" + j);
                    iCount++;
                    rootCount++;
                    iNode.Children.Add(jNode);
                    for (int k = 0; k < 2; k++)
                    {
                        var kNode = new TreeNode(jNode.Value + "_k:" + k);
                        jNode.Children.Add(kNode);
                        iCount++;
                        rootCount++;
                        jCount++;

                    }
                    jNode.Value += " ChildCount:" + jCount;
                }
                iNode.Value += " ChildCount:" + iCount;
            }
            _root.Value += " ChildCount:" + rootCount;
        }

        [Test]
        public void TestTreeNodeChildrenListing()
        {
            var iteration = new Stack<TreeNode>();
            var parents = new List<TreeNode>();
            var dic = new Dictionary<TreeNode, IList<TreeNode>>();

            TreeNode node = _root;
            while (node != null)
            {
                if (node.Children.Count > 0)
                {
                    if (!dic.ContainsKey(node))
                        dic.Add(node,new List<TreeNode>());

                    parents.Add(node);
                    foreach (var child in node.Children)
                    {
                        foreach (var parent in parents)
                        {
                            dic[parent].Add(child);
                        }
                        iteration.Push(child);
                    }
                }

                if (iteration.Count > 0)
                    node = iteration.Pop();
                else
                    node = null;

                bool removeParents = true;
                while (removeParents)
                {
                    var lastParent = parents[parents.Count - 1];
                    if (!lastParent.Children.Contains(node)
                        && node != _root && lastParent != _root)
                    {
                        parents.Remove(lastParent);
                    }
                    else
                    {
                        removeParents = false;
                    }
                }
            }
        }
    }

    internal class TreeNode
    {
        private IList<TreeNode> _children;
        public string Value { get; set; }

        public TreeNode(string value)
        {
            _children = new List<TreeNode>();
            Value = value;
        }

        public IList<TreeNode> Children
        {
            get { return _children; }
        }
    }
}

Para mim, isso se parece muito com a complexidade de O (n log n) a O (n²) e melhora apenas marginalmente sobre a resposta à qual o DVK vinculou em sua pergunta. Portanto, se isso não melhora, como isso responde à pergunta? O único valor que essa resposta agrega é mostrar uma expressão iterativa do algoritmo ingênuo.
amon

É O (n). Se você olhar atentamente o algoritmo, ele itera uma vez sobre os nós. Ao mesmo tempo, cria a coleção de nós filhos para cada nó pai ao mesmo tempo.
Pelicano de vôo baixo

11
Você percorre todos os nós, que é O (n). Então você percorre todas as crianças, as quais ignoraremos por enquanto (vamos imaginar que seja um fator constante). Então você percorre todos os pais do nó atual. Em uma árvore de saldos, esse é O (log n), mas no caso degenerado em que nossa árvore é uma lista vinculada, pode ser O (n). Portanto, se multiplicarmos o custo do loop através de todos os nós pelo custo do loop através de seus pais, obteremos a complexidade de tempo de O (n log n) a O (n²). Sem multithreading, não há "ao mesmo tempo".
21615 amon

"ao mesmo tempo" significa que ele cria a coleção no mesmo loop e não há outros loops envolvidos.
Pelicano de vôo baixo

0

Normalmente, você usaria apenas uma abordagem recursiva, pois permite alternar sua ordem de execução para que você possa calcular o número de folhas começando das folhas para cima. Como você precisaria usar o resultado da sua chamada recursiva para atualizar o nó atual, seria necessário um esforço especial para obter uma versão recursiva final. Se você não fizer esse esforço, é claro que essa abordagem simplesmente explodiria sua pilha em uma grande árvore.

Dado que percebemos que a idéia principal é obter uma ordem em loop começando nas folhas e voltando para a raiz, a idéia natural que vem à mente é executar uma classificação topológica na árvore. A sequência resultante de nós pode ser percorrida linearmente para somar o número de folhas (supondo que você possa verificar se um nó é uma folha O(1)). A complexidade geral do tempo da classificação topológica é O(|V|+|E|).

Suponho que Nseja o número de nós, o que |V|normalmente seria (da nomenclatura do DAG). O tamanho de, Epor outro lado, depende muito da aridade da sua árvore. Por exemplo, uma árvore binária tem no máximo 2 arestas por nó, portanto O(|E|) = O(2*|V|) = O(|V|), nesse caso, o que resultaria em um O(|V|)algoritmo geral . Observe que, devido à estrutura geral de uma árvore, você não pode ter algo parecido O(|E|) = O(|V|^2). De fato, como cada nó tem um pai único, você pode ter no máximo uma borda para contar por nó quando considerar apenas as relações com os pais; portanto, para árvores, garantimos isso O(|E|) = O(|V|). Portanto, o algoritmo acima é sempre linear no tamanho da árvore.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.