La forma más eficiente de generar todos los descendientes de todos los nodos en un árbol

9

Estoy buscando el algoritmo más eficiente para tomar un árbol (almacenado como una lista de bordes; O como una lista de asignaciones del nodo principal a una lista de nodos secundarios); y produce, para CADA nodo, una lista de todos los nodos que descienden de él (nivel hoja y nivel no hoja).

La implementación debe ser a través de bucles en lugar de recusión, debido a la escala; e idealmente debería ser O (N).

Esta pregunta SO cubre una solución estándar razonablemente obvia para encontrar la respuesta para UN nodo en un árbol. Pero obviamente, repetir ese algoritmo en cada nodo del árbol es altamente ineficiente (fuera de mi cabeza, O (NlogN) a O (N ^ 2)).

La raíz del árbol es conocida. El árbol tiene una forma absolutamente arbitraria (por ejemplo, no N-nary, no está equilibrado de ninguna manera, forma o forma, no tiene una profundidad uniforme): algunos nodos tienen 1-2 hijos, algunos tienen 30K hijos.

En un nivel práctico (aunque no debería afectar el algoritmo) el árbol tiene ~ 100K-200K nodos.

DVK
fuente
Puede simular la recursión utilizando un bucle y una pila, ¿está esto permitido para su solución?
Giorgio
@Giorgio, por supuesto. Eso es lo que traté de implicar por "vía bucles en lugar de recusación".
DVK

Respuestas:

5

Si realmente desea PRODUCIR cada lista como copias diferentes, no puede esperar lograr un espacio mejor que n ^ 2 en el peor de los casos. Si solo necesita ACCESO a cada lista:

Realizaría un recorrido en orden del árbol a partir de la raíz:

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

Luego, para cada nodo en el árbol, almacene el número mínimo en orden y el número máximo en orden en su subárbol (esto se mantiene fácilmente a través de la recursión, y puede simular eso con una pila si lo desea).

Ahora coloca todos los nodos en una matriz A de longitud n donde el nodo con el número en orden i está en la posición i. Luego, cuando necesite encontrar la lista para un nodo X, busque en A [X.min, X.max]; tenga en cuenta que este intervalo incluirá el nodo X, que también puede repararse fácilmente.

Todo esto se logra en O (n) tiempo y ocupa O (n) espacio.

Espero que esto ayude.

Jesper Nielsen
fuente
2

La parte ineficiente no es atravesar el árbol, sino construir las listas de nodos. Parecería sensato crear la lista así:

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

Como cada nodo descendiente se copia en la lista de cada padre, terminamos con una complejidad O (n log n) en promedio para árboles balanceados, y O (n²) en el peor de los casos para árboles degenerados que realmente son listas vinculadas.

Podemos caer a O (n) u O (1) dependiendo de si necesita hacer alguna configuración si usamos el truco de calcular las listas de manera perezosa. Supongamos que tenemos un child_iterator(node)que nos da los hijos de ese nodo. Entonces podemos definir trivialmente un tipo descendant_iterator(node)como este:

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

Una solución no recursiva está mucho más involucrada, ya que el flujo de control del iterador es complicado (¡rutinas!). Actualizaré esta respuesta más tarde hoy.

Dado que el recorrido de un árbol es O (n) y la iteración sobre una lista también es lineal, este truco difiere completamente el costo hasta que se pague de todos modos. Por ejemplo, imprimir la lista de descendientes para cada nodo tiene O (n²) peor complejidad: iterar sobre todos los nodos es O (n) y, por lo tanto, iterar sobre los descendientes de cada nodo, ya sea que estén almacenados en una lista o calculados ad hoc .

Por supuesto, esto no funcionará si necesita una colección real para trabajar.

amon
fuente
Lo siento, -1. Todo el propósito del agloritmo es precomputar los datos. La computación perezosa está derrotando por completo la razón para incluso ejecutar el algo.
DVK
2
@DVK Ok, puede que haya entendido mal sus requisitos. ¿Qué haces con las listas resultantes? Si el cálculo previo de las listas es un cuello de botella (pero no se usan las listas), esto indicaría que no está utilizando todos los datos que agrega, y la computación diferida sería una victoria. Pero si usa todos los datos, el algoritmo para la precomputación es en gran medida irrelevante: la complejidad algorítmica de usar los datos será al menos igual a la complejidad de construir las listas.
amon
0

Este breve algoritmo debería hacerlo. Echa un vistazo al código. public void TestTreeNodeChildrenListing()

El algoritmo en realidad pasa por los nodos del árbol en secuencia y mantiene la lista de padres del nodo actual. Según su requisito, el nodo actual es un elemento secundario de cada nodo primario que se agrega a todos y cada uno de ellos como elemento secundario.

El resultado final se almacena en el diccionario.

    [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; }
        }
    }
}
Pelican de bajo vuelo
fuente
Para mí, esto se parece mucho a la complejidad de O (n log n) a O (n²), y mejora solo marginalmente sobre la respuesta a la que DVK se vinculó en su pregunta. Entonces, si esto no mejora, ¿cómo responde esto a la pregunta? El único valor que agrega esta respuesta es mostrar una expresión iterativa del algoritmo ingenuo.
amon
Es O (n), si observa el algoritmo de cerca, itera una vez sobre los nodos. Al mismo tiempo, crea la colección de nodos secundarios para cada nodo primario al mismo tiempo.
Low Flying Pelican
1
Recorres todos los nodos, que es O (n). Luego recorre todos los hijos, lo que ignoraremos por ahora (imaginemos que es un factor constante). Luego recorre todos los padres del nodo actual. En un árbol de saldos, esto es O (log n), pero en el caso degenerado donde nuestro árbol es una lista vinculada, puede ser O (n). Entonces, si multiplicamos el costo de recorrer todos los nodos con el costo de recorrer sus padres, obtenemos O (n log n) a O (n²) complejidad de tiempo. Sin multithreading, no hay "al mismo tiempo".
amon
"al mismo tiempo" significa que crea la colección en el mismo bucle y no hay otros bucles involucrados.
Low Flying Pelican
0

Normalmente, solo usaría un enfoque recursivo, ya que le permite cambiar su orden de ejecución para que pueda calcular el número de hojas comenzando desde las hojas hacia arriba. Como tendría que usar el resultado de su llamada recursiva para actualizar el nodo actual, se necesitaría un esfuerzo especial para obtener una versión recursiva de cola. Si no toma ese esfuerzo, entonces, por supuesto, este enfoque simplemente explotaría su pila para un árbol grande.

Dado que nos dimos cuenta de que la idea principal es obtener un orden de bucle que comience en las hojas y regrese hacia la raíz, la idea natural que se nos ocurre es realizar una clasificación topológica en el árbol. La secuencia resultante de nodos puede atravesarse linealmente para sumar el número de hojas (suponiendo que pueda verificar que un nodo es una hoja O(1)). La complejidad temporal general del tipo topológico es O(|V|+|E|).

Supongo que su Nes el número de nodos, que |V|normalmente sería (de la nomenclatura DAG). El tamaño de, Epor otro lado, depende en gran medida de la aridad de su árbol. Por ejemplo, un árbol binario tiene como máximo 2 aristas por nodo, por lo tanto, O(|E|) = O(2*|V|) = O(|V|)en ese caso, lo que daría como resultado un O(|V|)algoritmo general . Tenga en cuenta que debido a la estructura general de un árbol, no puede tener algo así O(|E|) = O(|V|^2). De hecho, dado que cada nodo tiene un padre único, puede tener como máximo un borde para contar por nodo cuando considera solo las relaciones padre, por lo que para los árboles tenemos una garantía de eso O(|E|) = O(|V|). Por lo tanto, el algoritmo anterior siempre es lineal en el tamaño del árbol.

Franco
fuente