web-dev-qa-db-fra.com

implémentation trompeusement simple du tri topologique dans python

Extrait de ici nous avons obtenu une routine dfs itérative minimale, je l'appelle minimale parce que vous pouvez à peine simplifier davantage le code:

def iterative_dfs(graph, start, path=[]):
    q = [start]
    while q:
        v = q.pop(0)
        if v not in path:
            path = path + [v]
            q = graph[v] + q

    return path

graph = {
    'a': ['b', 'c'],
    'b': ['d'],
    'c': ['d'],
    'd': ['e'],
    'e': []
}
print(iterative_dfs(graph, 'a'))

Voici ma question, comment pourriez-vous transformer cette routine en une méthode de tri topologique où la routine devient également "minimale"? J'ai regardé ça vidéo et l'idée est assez intelligente donc je me demandais s'il serait possible d'appliquer la même astuce dans le code ci-dessus pour que le résultat final de topological_sort devienne également "minimal".

Ne demandant pas une version de tri topologique qui n'est pas une petite modification de la routine ci-dessus, j'en ai déjà vu peu. La question n'est pas "comment implémenter le tri topologique en python" mais plutôt de trouver le plus petit ensemble possible d'ajustements du code ci-dessus pour devenir un topological_sort.

COMMENTAIRES SUPPLÉMENTAIRES

Dans l'article original, l'auteur dit:

Il y a quelque temps, j'ai lu une implémentation graphique de Guido van Rossen qui était d'une simplicité trompeuse. Maintenant, j'insiste sur un système minimal python minimal avec la moindre complexité. L'idée est de pouvoir explorer l'algorithme. Plus tard, vous pouvez affiner et optimiser le code mais vous voudrez probablement faites-le dans une langue compilée.

Le but de cette question n'est pas d'optimiser iterative_dfs mais à la place, proposer une version minimale de topological_sort qui en dérive (juste pour en savoir plus sur les algorithmes de théorie des graphes). En fait, je suppose qu'une question plus générale pourrait être quelque chose comme étant donné l'ensemble d'algorithmes minimaux, {iterative_dfs, recursive_dfs, iterative_bfs, recursive_dfs}, quelles seraient leurs dérivations topological_sort? Bien que cela rendrait la question plus longue/complexe, il est donc suffisant de déterminer le tri topologique à partir de iterative_dfs.

19
BPL

Il n'est pas facile de transformer une implémentation itérative de DFS en tri topologique, car le changement à effectuer est plus naturel avec une implémentation récursive. Mais vous pouvez toujours le faire, il vous suffit d'implémenter votre propre pile.

Tout d'abord, voici une version légèrement améliorée de votre code (c'est beaucoup plus efficace et pas beaucoup plus compliqué):

def iterative_dfs_improved(graph, start):
    seen = set()  # efficient set to look up nodes in
    path = []     # there was no good reason for this to be an argument in your code
    q = [start]
    while q:
        v = q.pop()   # no reason not to pop from the end, where it's fast
        if v not in seen:
            seen.add(v)
            path.append(v)
            q.extend(graph[v]) # this will add the nodes in a slightly different order
                               # if you want the same order, use reversed(graph[v])

    return path

Voici comment je modifierais ce code pour faire un tri topologique:

def iterative_topological_sort(graph, start):
    seen = set()
    stack = []    # path variable is gone, stack and order are new
    order = []    # order will be in reverse order at first
    q = [start]
    while q:
        v = q.pop()
        if v not in seen:
            seen.add(v) # no need to append to path any more
            q.extend(graph[v])

            while stack and v not in graph[stack[-1]]: # new stuff here!
                order.append(stack.pop())
            stack.append(v)

    return stack + order[::-1]   # new return value!

La partie que j'ai commentée avec "de nouvelles choses ici" est la partie qui détermine l'ordre lorsque vous montez dans la pile. Il vérifie si le nouveau nœud trouvé est un enfant du nœud précédent (qui se trouve en haut de la pile). Sinon, il saute le haut de la pile et ajoute la valeur à order. Pendant que nous faisons le DFS, order sera dans l'ordre topologique inverse, en commençant par les dernières valeurs. Nous l'inversons à la fin de la fonction et nous la concaténons avec les valeurs restantes sur la pile (qui sont déjà dans le bon ordre).

Comme ce code doit vérifier v not in graph[stack[-1]] Plusieurs fois, il sera beaucoup plus efficace si les valeurs du dictionnaire graph sont des ensembles plutôt que des listes. Un graphique ne se soucie généralement pas de l'ordre dans lequel ses bords sont enregistrés, donc effectuer une telle modification ne devrait pas causer de problèmes avec la plupart des autres algorithmes, bien que le code qui produit ou met à jour le graphique puisse nécessiter une correction. Si vous avez l'intention d'étendre votre code graphique pour prendre en charge les graphiques pondérés, vous finirez probablement par modifier les listes en dictionnaires mappant du nœud au poids, et cela fonctionnerait tout aussi bien pour ce code (les recherches de dictionnaire sont O(1) tout comme les recherches d'ensemble). Alternativement, nous pourrions construire nous-mêmes les ensembles dont nous avons besoin, si graph ne peut pas être modifié directement.

Pour référence, voici une version récursive de DFS, et une modification de celui-ci pour faire un tri topologique. La modification nécessaire est en effet très faible:

def recursive_dfs(graph, node):
    result = []
    seen = set()

    def recursive_helper(node):
        for neighbor in graph[node]:
            if neighbor not in seen:
                result.append(neighbor)     # this line will be replaced below
                seen.add(neighbor)
                recursive_helper(neighbor)

    recursive_helper(node)
    return result

def recursive_topological_sort(graph, node):
    result = []
    seen = set()

    def recursive_helper(node):
        for neighbor in graph[node]:
            if neighbor not in seen:
                seen.add(neighbor)
                recursive_helper(neighbor)
        result.insert(0, node)              # this line replaces the result.append line

    recursive_helper(node)
    return result

C'est ça! Une ligne est supprimée et une ligne similaire est ajoutée à un emplacement différent. Si vous vous souciez des performances, vous devriez probablement faire result.append Dans la deuxième fonction d'assistance, et faire return result[::-1] Dans la fonction de haut niveau recursive_topological_sort. Mais l'utilisation de insert(0, ...) est un changement plus minimal.

Il convient également de noter que si vous souhaitez un ordre topologique de l'ensemble du graphique, vous ne devriez pas avoir besoin de spécifier un nœud de départ. En effet, il peut ne pas y avoir un seul nœud qui vous permet de parcourir l'intégralité du graphique, vous devrez donc peut-être effectuer plusieurs traversées pour accéder à tout. Un moyen simple d'y parvenir dans le tri topologique itératif consiste à initialiser q à list(graph) (une liste de toutes les clés du graphique) au lieu d'une liste avec un seul nœud de départ. Pour la version récursive, remplacez l'appel à recursive_helper(node) par une boucle qui appelle la fonction d'assistance sur chaque nœud du graphique si elle n'est pas encore dans seen.

22
Blckknght

Mon idée est basée sur deux observations clés:

  1. Ne sautez pas l'élément suivant de la pile, conservez-le pour émuler le déroulement de la pile.
  2. Au lieu de pousser tous les enfants à s'empiler, il suffit d'en pousser un.

Ces deux éléments nous aident à parcourir le graphique exactement comme les DFS récursifs. Comme l'autre réponse mentionnée ici, c'est important pour ce problème particulier. Le reste devrait être facile.

def iterative_topological_sort(graph, start,path=set()):
    q = [start]
    ans = []
    while q:
        v = q[-1]                   #item 1,just access, don't pop
        path = path.union({v})  
        children = [x for x in graph[v] if x not in path]    
        if not children:              #no child or all of them already visited
            ans = [v]+ans 
            q.pop()
        else: q.append(children[0])   #item 2, Push just one child

    return ans

q voici notre pile. Dans la boucle principale, nous "accédons" à notre nœud actuel v à partir de la pile. "accès", pas "pop", car nous devons être en mesure de revenir à nouveau sur ce nœud. Nous découvrons tous les enfants non visités de notre nœud actuel. et Poussez uniquement le premier à empiler (q.append(children[0])), pas tous ensemble. Encore une fois, c'est précisément ce que nous faisons avec les dfs récursifs.

Si aucun enfant éligible n'est trouvé (if not children), Nous avons visité l'intégralité du sous-arbre en dessous. Il est donc prêt à être inséré dans ans. Et c'est à ce moment-là qu'on le fait vraiment éclater.

(Cela va sans dire, ce n'est pas très bon en termes de performances. Au lieu de générer tous les enfants non visités dans la variable children, nous devons simplement générer le premier, style générateur, peut-être en utilisant un filtre. Nous devons également inverser ce ans = [v] + ans Et appelez un reverse sur ans à la fin. Mais ces choses sont omises pour l'insistance d'OP sur la simplicité.)

7