Bonjour à tous et bienvenue dans ce troisième article de la série des automates finis. Dans l’article précédent, nous avions mis en place une implémentation en Python nous permettant de manipuler des automates finis déterministes et nous allons aujourd’hui nous intéresser aux opérations et notions élémentaires des AFDs.
Prédécesseurs et successeurs
Dans un premier temps, nous allons introduire les notions de successeur et de prédécesseur d’un état. Reprenons l’automate *aaba* que nous avions créé dans le chapitre précédent :
Les successeurs d’un état q sont l’ensemble des états de destination des transitions partant de q. En d’autres termes, pour un automate A= (Q, q0, Σ, δ, F), successors(q) = {q’ ∈ Q | ∃ s ∈ Σ : δ(q, s) = q’}.
Dans l’automate précédent, on a : successors(0) = [0, 1], successors(1) = [0, 2], successors(2) = [2, 3], successors(3) = [0, 4], successors(4) = [4].
L’ensemble des prédécesseurs d’un état q correspond aux états qui ont (au moins) une transition allant vers q, soit : predecessors(q) = {q’ ∈ Q | ∃ s ∈ Σ : δ(q, s) = q’}. On peut également dire que les prédécesseurs de q sont les états qui ont q comme successeurs.
De la même façon, on a : predecessors(0) = [0, 1, 3], predecessors(1) = [0], predecessors(2) = [1, 2], predecessors(3) = [2], predecessors(4) = [3, 4].
Je vous propose d’implémenter les fonctions successors et predecessors pour notre API Python, que nous mettrons dans le fichier algorithms.py. Ces fonctions prennent en entrée un AFD et un état et retournent la liste des successeurs (resp. prédécesseurs) de cet état.
La fonction successors est la plus simple à créer puisqu’il nous suffit de parcourir toutes les transitions de l’état en question et de retourner la liste des états cibles. Il faut juste faire attention à ne pas compter les états deux fois :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
def successors(dfa, state): """ Returns the list of the successors of the specified state in the DFA. @param dfa the considered automaton. @param state the considered state. @return the list of the successors of the state.""" if state not in dfa.states: print("error : the specified state '" + state + "' is not part of the automaton.") return ret = [] for (symbol, dst_state) in dfa.transitions[state]: if dst_state not in ret: ret.append(dst_state) return ret |
Plutôt simple non ? Pour la fonction predecessors c’est un poil plus difficile, puisqu’il faut parcourir toutes les transitions de tous les états pour savoir si l’état cible est celui qui nous intéresse. Notez qu’une autre solution serait de simplement calculer les successeurs de tous les états et d’ajouter un état si notre état étudié est un de ses successeurs. Voici le code :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
def predecessors(dfa, state): """ Returns the list of the predecessors of the specified state in the DFA. @param dfa the considered automaton. @param state the considered state. @return the list of the predecessors of the state.""" if state not in dfa.states: print("error : the specified state '" + state + "' is not part of the automaton.") return ret = [] for src_state in dfa.states: for (symbol, dst_state) in dfa.transitions[src_state]: if dst_state == state and src_state not in ret: ret.append(src_state) return ret |
Nous pouvons maintenant tester nos deux fonctions et constater que tout fonctionne bien comme prévu :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
~/aumal/src$ python3 Python 3.6.7 (default, Oct 22 2018, 11:32:17) [GCC 8.2.0] on linux Type "help", "copyright", "credits" or "license" for more information. >>> import util >>> from algorithms import * >>> a = util.read("../examples/*aaba*.dfa") >>> >>> successors(a, a.init) ['1', '0'] >>> >>> for state in a.states: ... print("successors(" + state + ") = " + str(successors(a, state))) ... print("predecessors(" + state + ") = " + str(predecessors(a, state))) ... successors(0) = ['1', '0'] predecessors(0) = ['0', '1', '3'] successors(1) = ['2', '0'] predecessors(1) = ['0'] successors(2) = ['2', '3'] predecessors(2) = ['1', '2'] successors(3) = ['4', '0'] predecessors(3) = ['2'] successors(4) = ['4'] predecessors(4) = ['3', '4'] >>> |
Automate complet
Dans cette deuxième partie de l’article, nous allons nous intéresser à la notion d’automate complet.
Un AFD est dit complet, s’il chaque état possède une transition pour chaque symbole de l’alphabet. En d’autres termes, un AFD est complet si sa fonction de transition δ est totale, et incomplet si δ est partielle. Par exemple, l’automate *aaba* est complet, tandis que l’automate suivant ne l’est pas :
Cet automate, que nous appellerons abc*, reconnait les mots sur l’alphabet Σ = {a, b, c} qui commencent par la séquence “abc”.
Note : j’ai utilisé AUDE pour dessiner cet automate, plutôt que notre fonctionnalité d’exportation d’automate en PNG. La raison est que notre implémentation actuelle n’est pas capable de regrouper les transitions de l’état 3 en une seule flèche, ce qui réduit la lisibilité. Nous ajouterons cette fonctionnalité par la suite.
Ajoutons donc la fonction is_complete qui permettra de tester si un automate est complet ou non. L’algorithme est également très simple puisqu’il va simplement falloir itérer sur chaque état et chaque symbole pour s’assurer qu’il existe une transition :
1 2 3 4 5 6 7 8 |
def is_complete(dfa): """ Returns True if the automaton is complete, False otherwise. @param dfa the automaton to be tested.""" for state in dfa.states: for symbol in dfa.alphabet: if dfa.dst_state(state, symbol) == None: return False return True |
C’est très bien tout ça, mais vous vous demandez peut-être à quoi ça peut bien servir de savoir si un automate est complet ou non ?
Pour commencer, plusieurs définitions existent pour les AFDs, et certaines définissent un AFD comme un automate complet. Avec notre définition, puisque nous n’imposons pas que l’automate soit complet pour être valide, l’exécution d’un mot s’arrête s’il n’existe pas de transition pour le symbole en cours.
Certains algorithmes, notamment la négation d’un automate que nous verrons dans le prochain article, imposent que l’automate soit complet pour fonctionner. De la même manière, la minimisation qui sera l’objet de l’article encore après, est plus simple à appréhender lorsqu’on manipule un automate complet.
En pratique, il est toujours possible de transformer un automate en un automate complet équivalent (qui reconnait le même langage). Nous allons d’ailleurs implémenter cette opération de complétion d’automate avec la fonction complete.
L’algorithme de complétion d’un automate consiste à utiliser un état “puits”, non-accepteur, vers lequel on enverra toutes les transitions manquantes. Pour l’automate abc*, voilà ce que ça donne :
Cette opération donne un automate équivalent puisque Qp n’est pas accepteur. Cela dit, l’automate n’est toujours pas complet puisque Qp a été ajouté et n’a aucune transition. Par conséquent, il nous faut également faire boucler Qp sur lui même pour chaque symbole :
Et voilà, l’automate est maintenant complet. Nous pouvons maintenant passer à l’implémentation de la fonction complete. Pour ce faire, nous allons reprendre exactement le même principe : ajout de l’état puits et ajout des transitions manquantes vers l’état puits.
Nous allons tout de même faire face à une petite difficulté supplémentaire liée à notre choix d’identifier les états par leur nom : comment faire si l’état Qp existe déjà ? Nous pourrions décider de rendre ce nom d’état réservé, mais je suggère de simplement ajouter une boucle qui nous permettra de trouver un nom d’état libre :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
def complete(dfa): """ Completes the specified automaton. @param dfa the DFA to complete. @return the modified DFA.""" # Find a name for Qp qp = "Qp" i = 0 while qp in dfa.states: qp = "Qp" + str(i) i += 1 # Complete dfa.add_state(qp) for state in dfa.states: for symbol in dfa.alphabet: if dfa.dst_state(state, symbol) == None: dfa.add_transition(state, symbol, qp) return dfa |
Notre implémentation a tout de même un petit défaut : un nouvel état puits est ajouté à chaque complétion d’un automate. En effet, l’état puits est simplement un état à partir duquel n’importe quel mot est rejeté, on pourrait tout à fait imaginer réutiliser un état existant ayant cette propriété. Cependant, nous allons simplement ajouter if is_complete(dfa): return au début de notre algorithme.
Notez également que je retourne l’automate passé en paramètre. C’est une technique fréquente qui va nous permettre d’écrire du code tel que a2 = complete(a.clone()) plutôt que de devoir ajouter une ligne pour cloner un automate lorsque c’est nécessaire.
Je vous propose donc d’essayer nos nouvelles fonctions avec l’automate abc*, dont je vous donne ci-dessous le fichier .dfa correspondant :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
a = DFA.DFA("abc") a.add_state("0") a.add_state("1") a.add_state("2") a.add_state("3", True) a.init = "0" a.add_transition("0", "a", "1") a.add_transition("1", "b", "2") a.add_transition("2", "c", "3") a.add_transition("3", "c", "3") a.add_transition("3", "b", "3") a.add_transition("3", "a", "3") |
Essayons donc de tester nos algorithmes :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 |
/src$ python3 Python 3.6.7 (default, Oct 22 2018, 11:32:17) [GCC 8.2.0] on linux Type "help", "copyright", "credits" or "license" for more information. >>> import util >>> from algorithms import * >>> aaba = util.read("../examples/*aaba*.dfa") >>> is_complete(aaba) True >>> abc = util.read("../examples/abc*.dfa") >>> is_complete(abc) False >>> >>> a =complete(abc.clone()) >>> >>> print(a) FA : - alphabet : 'abc' - init : 0 - finals : ['3'] - states (5) : - (0): --(a)--> (1) --(b)--> (Qp) --(c)--> (Qp) - (1): --(b)--> (2) --(a)--> (Qp) --(c)--> (Qp) - (2): --(c)--> (3) --(a)--> (Qp) --(b)--> (Qp) - (3): --(c)--> (3) --(b)--> (3) --(a)--> (3) - (Qp): --(a)--> (Qp) --(b)--> (Qp) --(c)--> (Qp) >>> is_complete(a) True >>> util.save(a, "../examples/abc*_complete.dfa") >>> util.to_png(a, "../examples/abc*_complete.png") >>> |
La complétion de l’automate abc* a bien fonctionné, et comme attendu, *aaba* est reconnu comme complet. Par contre, il faut admettre que notre incapacité à regrouper les transitions lors de l’exportation en PNG rend le résultat difficile à lire :
Avant de clore cette partie sur la complétion d’automates, je vous propose de modifier notre implémentation de to_dot de manière à proposer cette option de regroupement des transitions.
Je ne vais pas vous détailler les modifications du code, j’ai simplement transformé les arguments optionnels en utilisant les keyword arguments de manière à simplifier l’interface, et regroupé les transitions allant vers le même état lorsque l’option est activée. J’en ai aussi profité pour définir le fond de l’image comme transparent. Il nous faut également mettre à jour les fonctions to_png et to_pdf :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 |
def to_dot(dfa, **kwargs): """ Returns a string corresponding to the specified DFA in DOT format. @param dfa the DFA to be converted in DOT format. @param_opt name the name of the automaton for the DOT file. ("Graph01") by default. @param_opt group if True, the transition are regrouped when they have the smame origin and destination states. False by default. @returns the automaton in DOT format.""" # Args if "name" not in kwargs: kwargs["name"] = "Graph01" if "group" not in kwargs: kwargs["group"] = False # Header ret = "digraph " + kwargs["name"] + " {\n bgcolor=\"transparent\";\nrankdir=\"LR\";\n\n" ret += " // States (" + str(len(dfa.states)) + ")\n" state_name = lambda s : "Q_" + str(dfa.states.index(s)) # States ret += " node [shape = point ]; __Qi__ // Initial state\n" # Initial state for state in dfa.states: ret += " " if state in dfa.finals: ret += "node [shape=doublecircle]; " else: ret += "node [shape=circle]; " ret += state_name(state) + " [label=" + state + "];\n" # Transitions ret += "\n // Transitions\n" ret += " __Qi__ -> " + state_name(dfa.init) + "; // Initial state arrow\n" for state in dfa.states: if kwargs["group"]: transition_dict = {} for (symbol, dst_state) in dfa.transitions[state]: if dst_state not in transition_dict: transition_dict[dst_state] = [] transition_dict[dst_state].append(symbol) for dst_state in transition_dict: transition_dict[dst_state].sort() ret += " " + state_name(state) + " -> " + state_name(dst_state) + " [label=\"" + ", ".join(transition_dict[dst_state]) + "\"];\n" else: for (symbol, dst_state) in dfa.transitions[state]: ret += " " + state_name(state) + " -> " + state_name(dst_state) + " [label=" + symbol + "];\n" return ret + "}\n" def to_png(dfa, filename, **kwargs): """ Create the PNG image corresponding to the representation of the specified DFA in a file. The automaton is converted in DOT format and the command dot is called in order to generate the PNG. @param_opt name the name of the automaton for the DOT file. ("Graph01") by default. @param_opt group if True, the transition are regrouped when they have the smame origin and destination states. False by default. @param filename the name of the PNG file, use the name of the graph if not specified. """ tmp_file = filename + ".tmp" with open(tmp_file, "w") as file: file.write(to_dot(dfa, **kwargs)) call(("dot -Tpng " + tmp_file + " -o " + filename).split(" ")) call(("rm " + tmp_file).split(" ")) def to_pdf(dfa, filename, **kwargs): """ Create the graphical PDF representation of the specified DFA in a file. The automaton is converted in DOT format and the command dot is called in order to generate the PDF. @param_opt name the name of the automaton for the DOT file. ("Graph01") by default. @param_opt group if True, the transition are regrouped when they have the smame origin and destination states. False by default. @param filename the name of the PDF file, use the name of the graph if not specified. """ tmp_file = filename + ".tmp" with open(tmp_file, "w") as file: file.write(to_dot(dfa, **kwargs)) call(("dot -Tpdf " + tmp_file + " -o " + filename).split(" ")) call(("rm " + tmp_file).split(" ")) |
Nous pouvons donc maintenant exporter notre automate complété avec nos nouvelles versions des fonctions :
1 2 3 4 5 6 7 8 |
#/aumal/src$ python3 Python 3.6.7 (default, Oct 22 2018, 11:32:17) [GCC 8.2.0] on linux Type "help", "copyright", "credits" or "license" for more information. >>> from util import * >>> a = read("../examples/abc*_complete.dfa") >>> to_png(a, "../examples/abc*_complete.png", group=True) >>> |
Voilà qui est donc mieux !
Accessibilité et co-accessibilité
Avant de nous quitter, nous allons nous intéresser au concept d’accessibilité d’un état. Les algorithmes de cette troisième partie sont un poil plus complexes que ce que nous avons vu jusqu’à présent.
Un état est accessible, s’il existe un mot qui permet de l’atteindre. Dans l’automate suivant, l’état 2 n’est pas accessible :
Si tous les états sont accessibles, alors l’automate est accessible. Nous allons donc implémenter la fonction accessible_states qui retourne la liste des états accessibles d’un automate.
Pour ce faire, nous n’allons pas pouvoir simplement itérer parmi tous les états de l’automate, mais nous allons devoir partir de l’état initial, et essayer d’atteindre tout ce qu’on peut à partir des différentes transitions disponibles.
L’algorithme se base sur le principe suivant : si un état Q est accessible, alors ses successeurs sont accessibles. Nous allons donc partir de l’état initial (ici 0) et regarder ses successeurs : 0 et 1. Puis nous allons visiter les successeurs (à l’exception de ceux qui ont déjà été visités) et continuer tant qu’il nous reste des états à visiter.
Lorsque la liste des états à visiter est vide, la liste des états visités correspond aux états accessibles. Pour s’en convaincre, faisons une itération de l’algorithme pour l’automate précédent :
- visité = [], a_visiter = [0].
- On considère l’état 0. successeurs(0) = [0, 1]. On ajoute donc 0 à la liste des états visités et 1 à la liste des états à visiter. => visité = [0], a_visiter = [1]
- On considère l’état 1. successeurs(1) = [3, 4]. => visité = [0, 1], a_visiter = [3, 4]
- On considère l’état 3. successeurs(3) = [3]. => visité = [0, 1, 3], a_visiter = [4]
- On considère l’état 4. successeurs(4) = [0, 3]. => visité = [0, 1, 3, 4], a_visiter = []
- a_visiter est désormais vide, l’algorithme est terminé. 0, 1, 3 et 4 sont accessible, 2 ne l’est pas.
Voici donc pour l’algorithme, nous pouvons donc passer à l’implémentation :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
def accessible_states(dfa): """ Returns the list of all accessible states in the specified automaton. @param dfa the automaton considered. @return the list of the accessible states.""" visited = [] to_visit = [dfa.init] while len(to_visit) > 0: state = to_visit.pop() visited.append(state) for succ in successors(dfa, state): if succ not in visited and succ not in to_visit: to_visit.append(succ) return visited |
Par confort, ajoutons aussi une fonction permettant de savoir si un automate est accessible (en vérifiant que tous les états sont accessibles) et une seconde pour déterminer si un état particulier est accessible :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
def accessible(dfa, state): """ Returns True if the specified state is accessible in the automaton, returns False otherwise. @param dfa the considered automaton. @param state the state to be tested. @return True if the state is accessible, False otherwise.""" if state not in dfa.states: print("error : the state '" + state + "' is not part of the automaton.") return False return state in accessible_states(dfa) def accessible(dfa): """ Returns True if the specified DFA is accessible (if all it's states are, accessible), False otherwise. @param dfa the considered automaton. @return True if the DFA is accessible, False otherwise.""" return len(dfa.states) == len(accessible_states(dfa)) |
Et voilà le travail !
Notez que la notion d’accessibilité d’un automate correspond à la même notion pour les graphes. Dans notre algorithme, nous avons implémenté un parcours en largeur. Cela signifie qu’on va d’abord regarder tous les voisins d’un état, puis leurs voisins etc.
L’autre méthode de parcours d’un graphe est le parcours en profondeur, qui consiste à explorer d’abord le plus loin possible un voisin avant de s’intéresser aux autres voisins. Cela étant, dans notre exemple, les deux parcours donnent le même ordre d’exploration. Notre algorithme correspond à un parcours en largeur, parce qu’on a utilisé la liste to_visit comme une file, c’est à dire qu’on prend l’élément ajouté le plus récemment en premier (LIFO : Last In First Out). Si on l’implémentait comme une pile (FIFO : First In First Out, par exemple en utilisant to_visit.insert(0,succ) plutôt que to_visit.append(succ)), alors l’algorithme correspondrait à un parcours en profondeur.
Si la notion de parcours en profondeur/largeur est floue, ne vous inquiétez pas, ce n’est de toute façon pas l’objet de cet article. Retournons plutôt à notre implémentation.
Il nous reste à introduire la notion de co-accessibilité, qui est à peu de choses près la même chose. Un état Q est co-accessible s’il existe un mot permettant d’aller vers un état final depuis Q. En fait, c’est pratiquement de l’inverse, il s’agit de savoir si un état final est accessible depuis Q.
L’algorithme est donc sensiblement identique : au lieu d’initialiser notre liste d’états à visiter avec l’état initial, on l’initialise avec l’ensemble des états finaux et au lieu de regarder les successeurs d’un état, on regarde les prédécesseurs :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
def coaccessible_states(dfa): """ Returns the list of all co-accessible states in the specified automaton. @param dfa the automaton considered. @return the list of the co-accessible states.""" visited = [] to_visit = dfa.finals.copy() while len(to_visit) > 0: state = to_visit.pop() visited.append(state) for pred in predecessors(dfa, state): if pred not in visited and pred not in to_visit: to_visit.append(pred) return visited def coaccessible(dfa, state): """ Returns True if the specified state is accessible in the automaton, returns False otherwise. @param dfa the considered automaton. @param state the state to be tested. @return True if the state is coaccessible, False otherwise.""" if state not in dfa.states: print("error : the state '" + state + "' is not part of the automaton.") return return state in coaccessible_states(dfa) def coaccessible(dfa): """ Returns True if the specified DFA is coaccessible (if all it's states are, accessible), False otherwise. @param dfa the considered automaton. @return True if the DFA is coaccessible, False otherwise.""" return len(dfa.states) == len(coaccessible_states(dfa)) |
Nous avons donc terminé pour l’implémentation. Pour tester la co-accessibilité, l’automate abc*_complete fera l’affaire, puisque l’état puits est par définition non co-accessible. Pour l’accessibilité, utilisons le graphe précédent dont le fichier est ci-dessous :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
a = DFA.DFA("123") a.add_state("0") a.add_state("1") a.add_state("2") a.add_state("3", True) a.add_state("4") a.init = "0" a.add_transition("0", "1", "0") a.add_transition("0", "2", "1") a.add_transition("0", "3", "0") a.add_transition("1", "1", "4") a.add_transition("1", "2", "3") a.add_transition("1", "3", "4") a.add_transition("2", "1", "0") a.add_transition("2", "2", "4") a.add_transition("2", "3", "2") a.add_transition("3", "1", "3") a.add_transition("3", "2", "3") a.add_transition("4", "1", "0") a.add_transition("4", "2", "3") a.add_transition("4", "3", "3") |
Voyons donc ce que donnent nos nouvelles fonctions :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
~/aumal/src$ python3 Python 3.6.7 (default, Oct 22 2018, 11:32:17) [GCC 8.2.0] on linux Type "help", "copyright", "credits" or "license" for more information. >>> from algorithms import * >>> from util import * >>> a = read("../examples/not_accessible.dfa") >>> accessible(a) False >>> coaccessible(a) True >>> a2 = read("../examples/abc*_complete.dfa") >>> accessible(a2) True >>> coaccessible(a2) False >>> accessible_states(a) ['0', '1', '3', '4'] >>> accessible_states(a2) ['0', 'Qp', '1', '2', '3'] >>> coaccessible_states(a) ['3', '4', '2', '1', '0'] >>> coaccessible_states(a2) ['3', '2', '1', '0'] >>> |
Avant de nous quitter, sachez qu’un automate qui est à la fois accessible et co-accessible est dit émondé (en anglais trim). La fonction trim ressemblerait donc ça :
1 2 3 4 5 6 |
def trim(dfa): """ Returns True if the specified DFA is trim (accessible and coaccessible), False otherwise. @param dfa the considered automaton. @return True is the DFA is trim, False otherwise.""" return accessible(dfa) and coaccessible(dfa) |
Dans ce chapitre, nous avons donc implémenté pas mal de fonctions pour nos automates : successeurs/prédécesseurs, (co)accessibilité et complétude. Un ajout intéressant aurait été la possibilité de rendre un automate accessible, co-accessible ou émondé, mais l’article est déjà bien assez long et cela nous demandera d’ajouter des fonctions de suppression d’états. Nous verrons donc cela une autre fois 🙂 (cela dit, le depôt Github propose peut-être déjà cette fonctionnalité à l’heure où vous lisez ces lignes).
Merci de m’avoir lu jusqu’au bout et je vous dit à très bientôt pour de nouveaux articles.
You must log in to post a comment.