WikiPrépaLivrets

Agrégation informatique externe 2023, épreuve 2Sujet et rapport du jury

Agrégation externe section informatique - Sujet de la deuxième épreuve écrite de la session 2023

Pas encore noté

Téléchargements

  • Corrigé : pas encore disponible

Description

Sujet officiel Agrégation externe en informatique, session 2023.

Ces sujets peuvent vous intéresser

Pas encore de corrigé pour ce sujet : voici des sujets proches corrigés.

Lecture du sujet en ligne

L'énoncé complet, avec les formules et les figures, sans ouvrir le PDF.
Afficher ou masquer la section
SESSION 2023

AGREGATION
CONCOURS EXTERNE

Section : INFORMATIQUE
ÉTUDE D'UN PROBLÉME INFORMATIQUE
Durée : 6 heures
L'usage de tout ouvrage de référence, de tout dictionnaire et de tout matériel électronique (y compris la calculatrice) est rigoureusement interdit.
Il appartient au candidat de vérifier qu'il a reçu un sujet complet et correspondant à l'épreuve à laquelle il se présente.
Si vous repérez ce qui vous semble être une erreur d'énoncé, vous devez le signaler très lisiblement sur votre copie, en proposer la correction et poursuivre l'épreuve en conséquence. De même, si cela vous conduit à formuler une ou plusieurs hypothèses, vous devez la (ou les) mentionner explicitement.
NB : Conformément au principe d'anonymat, votre copie ne doit comporter aucun signe distinctif, tel que nom, signature, origine, etc. Si le travail qui vous est demandé consiste notamment en la rédaction d'un projet ou d'une note, vous devrez impérativement vous abstenir de la signer ou de l'identifier.
Le fait de rendre une copie blanche est éliminatoire.

INFORMATION AUX CANDIDATS

Vous trouverez ci-après les codes nécessaires vous permettant de compléter les rubriques figurant en en-tête de votre copie.
Ces codes doivent être reportés sur chacune des copies que vous remettrez.

Test d'égalité de langages rationnels

L'objet de ce problème est d'étudier comment on peut décider si deux expressions rationnelles E et F décrivent le même langage rationnel. Ce problème est intrinsèquement difficile : notamment si P ≠ NP alors il n'y a pas d'algorithme polynomial pour le résoudre. Dans ce sujet, après des préliminaires algorithmiques, nous allons considérer que les expressions rationnelles sont encodées comme des arbres, et étudier un algorithme pour calculer un automate non-déterministe qui reconnaît le même langage. Si on déterminise les automates qui correspondent à E et F, on se ramène donc à tester si deux automates déterministes reconnaissent le même langage, ce qui fait l'objet de la dernière partie.
Attendus. Il est attendu des candidates et des candidats des réponses construites. Ils seront aussi évalués sur la précision, le soin et la clarté de la rédaction.

Partie I. Préliminaires algorithmiques

Dans tout le sujet, le mot complexité désigne la complexité temporelle.

1 Tableaux redimensionnables

Les listes Python telles qu'elles sont implantées en CPython (l'implantation de référence du langage Python) sont en réalité ce que l'on appelle des tableaux redimensionnables, une structure de données souple et efficace, qui permet de stocker un nombre non borné de données, et en plus d'accéder au i-ème élément en temps constant.
Pour notre usage dans ce problème, un tableau redimensionnable est une structure de données qui supporte les opérations suivantes (sauf mention contraire, on n'utilisera pas les autres) :
  • -initialisation : crée un tableau redimensionnable vide ne contenant aucune donnée (en Python, c'est l'instruction t = [] );
  • -longueur : renvoie le nombre d'éléments stockés dans le tableau redimensionnable (en Python, c'est l'instruction len(t));
  • -accès : permet d'accéder en lecture et en écriture à l'élément en i-ème position dans un tableau redimensionnable ; les indices commencent à 0, donc il faut que i soit compris entre 0 et la longueur moins 1 (en Python, c'est t [i] ).
  • -ajout à la fin : ajoute une donnée après la dernière donnée existante du tableau redimensionnable (en Python, c'est l'instruction t.append(x));
Pour notre analyse algorithmique, on considère les opérations suivantes sur les tableaux (pas les tableaux redimensionnables) et leurs complexités :
  • -allouer un tableau de taille n en temps O(n);
  • -accéder au i-ème élément d'un tableau en temps O(1);
  • -affecter une valeur au i-ème élément d'un tableau en temps O(1).
On souhaite implanter des tableaux redimensionnables en utilisant des enregistrements (les struct du langage C) qui contiennent trois champs : tableau qui est un tableau, capacite qui est la taille allouée au tableau, longueur qui est un entier indiquant combien d'éléments sont stockés dans la structure : une capacite de 8 indique que tableau est alloué pour contenir 8 éléments, et si la longueur vaut 5, cela signifie que seuls les 5 premiers éléments, d'indices 0 à 4 inclus, sont dans la liste qui est représentée, voir Fig. 1.
Figure 1 - Représentation de la liste abstraite [8,4,-2,7,11] au moyen d'un tableau redimensionnable de capacité 8.
Les algorithmes pour les tableaux redimensionnables sont les suivants :
  • -initialisation : crée un enregistrement avec capacite=1, longueur=0 et alloue une case pour tableau;
  • -longueur : renvoie longueur ;
  • -accès : renvoie tableau[i] ;
  • -ajout à la fin : il y a deux cas selon que l'on ait la place pour un nouvel élément ou non
    • -si longueur < capacite, on place le nouvel élément dans la case d'indice longueur du champs tableau et on incrémente longueur;
    • -si longueur == capacite, on alloue un nouveau tableau de capacite doublée, on recopie tous les éléments de l'ancien tableau dedans, puis on place le nouvel élément dans la case d'indice longueur du champs tableau et on incrémente longueur.
Question 1. Représenter, à chaque étape, l'enregistrement encodant un tableau redimensionnable que l'on initialise, puis auquel on ajoute successivement 3, -2, 4, 1 et -2 (6 étapes en tout avec l'initialisation).
Question 2. Parmi les opérations considérées, et dans le modèle pour les tableaux décrit plus haut, quelle est la seule opération à ne pas s'effectuer en temps constant? Dans quels cas précisément, en fonction de la valeur de longueur, l'opération ne se fait pas en temps constant ? Quelle est sa complexité dans ces cas?
Question 3. On initialise puis effectue n ≥ 1 insertions consécutives dans un tableau redimensionnable. On note I_n le nombre total d'écritures dans tableau qui ont été effectuées dans le processus (instructions du type tableau [i] =..., en comptant celles effectuées lors des recopies). Soit k l'unique entier tel que 2^(k − 1) < n ≤ 2^k. Montrer que I_1 = 1 et que pour tout n ≥ 2 on a
I_n = n + ∑_(i = 0)^(k − 1)2^i
Question 4. En déduire qu'initialiser puis effectuer n ≥ 1 insertions consécutives dans un tableau redimensionnable se fait en temps O(n).

2 Tri lexicographique

Dans cette section on s'intéresse au problème de trier pour l'ordre lexicographique n listes contenant chacune k entiers de {0, …, c − 1}, où n, k, c sont des entiers strictement positifs. Quand on est dans ce cadre, on peut trier plus efficacement qu'en utilisant un tri comme le tri fusion. Cet algorithme sera utilisé dans la section 6 .
On considère que les listes sont implantées au moyen de tableaux redimensionnables, et que l'on peut ainsi ré-utiliser les complexités de la section précédente.
Par exemple, l'entrée de l'algorithme pour n = 5, k = 3 et c = 7 pourrait être, en notation Python : L = [[2,5,1], [6,0,0], [1,2,3], [1,0,3], [4,2,1]].
Question 5. Quel est le résultat attendu après avoir trié L ci-dessus selon l'ordre lexicographique?
Question 6. Écrire une fonction Python plus_petit_k_uple qui prend en argument deux listes d'entiers 11 et 12 supposées être de même taille et renvoie True si et seulement si l1 arrive avant 12 dans l'ordre lexicographique, et False sinon. Cette fonction doit posséder une complexité linéaire en la taille commune des listes fournies en argument (sans avoir à la justifier).
Question 7. Si on utilise la fonction plus_petit_k_uple pour comparer deux listes d'entiers de taille k, quelle serait la complexité en fonction de n et de k d'utiliser le Tri Fusion (MergeSort) pour résoudre le problème de trier selon l'ordre lexicographique une liste de n listes d'entiers de taille k ?
La solution ci-dessus n'utilise pas le fait que les valeurs sont toutes comprises entre 0 et c − 1. On va pouvoir proposer une solution algorithmiquement plus performante en exploitant cette spécificité.
On considère l'algorithme TriParCle(L, j, c), où L est une liste de listes de notre problème et j ∈ {0, …, k − 1}. Cet algorithme renvoie une liste K contenant une permutation de L où les éléments sont triés selon leur indice j : pour tous i, i^′ tels que 0 ≤ i < i^′ < n, K[i][j] ≤ K[i^′][j]. On utilise pour cela le procédé suivant :
  • -Créer un tableau redimensionnable T contenant c tableaux redimensionnables initialement vides.
  • -Pour chaque élément (liste de longueur k ) ℓ de L dans l'ordre, ajouter ℓ en fin du tableau redimensionnable T[ℓ[j]].
  • -Renvoyer la concaténation des tableaux redimensionnables T[0], T[1], …, T[c − 1].
Question 8. Que renvoie l'algorithme appliqué à L = [[2, 5, 1], [6, 0, 0], [1,2,3] , [1,0,3], [4,2,1]] avec j = 1 ?
Question 9. Écrire l'algorithme TriParCle (L, j, c) en Python.
Question 10. Montrer que l'algorithme TriParCle(L, j, c) a pour complexité O(c + n).
Question 11. Montrer que l'algorithme TriParCle(L, j, c) est stable : si on a deux indices i, i^′ avec 0 ≤ i < i^′ < n tels que L[i] ≠ L[i^′] et que L[i][j] = L[i^′][j], alors dans le résultat K, l'élément L[i] est avant L[i^′].
L'algorithme TriLexicographique (L, c) consiste à trier successivement L avec TriParCle (L, j, c) en faisant décroître j de k − 1 à 0.
Question 12. Appliquer l'algorithme TriLexicographique(L, 7) à L = [[2, 5, 1], [6,0,0] , [1,2,3] , [1,0,3] , [4,2,1]] , en indiquant la liste obtenue à chaque itération de la boucle sur j.
Question 13. Montrer que l'algorithme TriLexicographique (L) est une solution correcte au problème initial : il trie les éléments de L pour l'ordre lexicographique.
On a ainsi un algorithme de complexité O(k(c + n)) pour résoudre le problème de trier pour l'ordre lexicographique n listes de k entiers compris entre 0 et c − 1.
Important : Pour toute la suite, afin de simplifier les explications, on considérera que les listes de Python ont les opérations initialisation, longueur, accès et ajout à la fin en temps O(1), sans spécifier à chaque fois qu'il s'agit en fait de tableaux redimensionnables et que l'ajout à la fin se fait en réalité en temps O(1) amorti.

Partie II. Des expressions rationnelles aux automates

Dans cette partie, nous explorons l'algorithme de Glushkov dont le but est de trouver un automate (a priori non déterministe) qui reconnait le même langage qu'une expression rationnelle donnée.

3 Représenter des expressions rationnelles non vides

Soit un alphabet fini Σ et ε le mot vide sur cet alphabet.
Les expressions rationnelles non vides sur Σ sont définies inductivement par :
  • -l'ensemble d'assertions {ε, a|a ∈ Σ},
  • -l'ensemble de règles d'inférence {somme : (E_1, E_2) ↦ (E_1) + (E_2), étoile : E ↦ (E)^∗, produit : (E_1, E_2) ↦ (E_1) ⋅ (E_2)}.
On s'autorisera à ne pas mettre systématiquement des parenthèses, en prenant la convention de préséance suivante : l'étoile est prioritaire sur le produit qui est prioritaire sur la somme.
On s'autorisera également à ne pas écrire systématiquement le symbole • pour le produit. Ainsi, l'expression (((a) ⋅ (b))^∗) + (((a) ⋅ (a) + ε)) pourra s'écrire (ab)^∗ + aa + ε.
Par la suite on utilisera le terme expression rationnelle pour désigner une expression rationnelle non vide.
À chaque expression rationnelle E, on peut associer sa longueur |E| et son langage L(E) sur Σ, définis de manière inductive :
expression longueur langage
ε 1 {ε}
a (a ∈ Σ) 1 {a}
(E_1) + (E_2) 1 + |E_1| + |E_2| L(E_1) ∪ L(E_2)
(E_1) ⋅ (E_2) 1 + |E_1| + |E_2| L(E_1) ⋅ L(E_2)
(E)* 1 + |E| L(E)^∗ = ⋃_(i = 0)^∞L(E)^i
avec par convention L^0 = {ε} et L^i = L ⋅ L^(i − 1) pour tout langage L et tout entier i ≥ 1. Sur les langages, on prend la règle de préséance suivante : l'étoile est prioritaire sur le produit qui est prioritaire sur l'union.
On peut représenter de telles expressions sous forme d'arbres syntaxiques (représentation naturelle à partir de la définition inductive) et construire à partir d'un tel arbre une liste de listes imbriquées pour les calculs en Python :
expression arbre Python
ε ε [ '']
a (a ∈ Σ) a [ 'a']
(E_1) + (E_2)
Z17P+H2Te
[ '+', E1, E2 ]
(E_1) ⋅ (E_2)
TeTeTe
[ '.', E1, E2 ]
(E)*
H3B
[^′∗^′, E]
Dans la suite, quand on parlera d'une expression rationnelle en Python, elle sera nécessairement sous forme de liste de listes imbriquées, comme défini ci-dessus, sans qu'on ait besoin de le spécifier.
Question 14. Donner l'arbre syntaxique et la représentation Python de l'expression rationnelle (a + b)^∗ a + (ab + ε)(c + ε).
Question 15. Écrire une fonction Python expression qui prend en argument une expression rationnelle et renvoie une représentation sous forme de chaîne de caractères de cette expression.
Exemple.
expression(['+', [ '*', [ '.', [ 'a'] , [ 'b'] ] ], ['+', [ '.', ['a'], ['a']], ['']]])
s’évalue en ’(((a).(b))*)+(((a).(a))+(_))’, où le caractère ’_’ représente le mot vide.
Question 16. Écrire une fonction Python contient_mot_vide qui prend en argument une expression rationnelle et renvoie True si le mot vide appartient au langage associé à l'expression rationnelle, False sinon. Votre fonction doit avoir une complexité linéaire en la longueur de l'expression rationnelle (sans avoir à le justifier).

4 Automate de Glushkov

L'automate de Glushkov d'une expression rationnelle E est un automate particulier qui reconnaît le langage L(E) associé à cette expression. Le but de cette section est de construire cet automate de manière efficace.

4.1 Rappels et propriétés

La première étape pour construire l'automate de Glushkov associé à une expression rationnelle E sur l'alphabet Σ est de linéariser cette expression, c'est-à-dire construire une nouvelle expression E_ℓ sur un nouvel alphabet Σ_ℓ, dont on déduit facilement E et dans laquelle chaque lettre possède au plus une occurrence. Une telle expression est qualifiée de locale.
On note #E le nombre de lettres apparaissant dans l'expression E et on construit une nouvelle expression E_ℓ sur l'alphabet {a_i|a ∈ Σ, 1 ≤ i ≤ #E}, en adjoignant à chaque lettre qui apparaît dans l'expression E sa position : on numérote les lettres de E de gauche à droite en partant de 1 , et on ajoute ce numéro en indice à chaque lettre. L'ensemble des lettres effectivement utilisées dans E_ℓ est appelé alphabet de E_ℓ et noté Σ_ℓ.
Ainsi, à partir de l'expression E = (ab)^∗ + aa + ε sur l'alphabet Σ = {a, b}, on obtient l'expression linéarisée E_ℓ = (a_1 b_2)^∗ + a_3 a_4 + ε sur l'alphabet Σ_ℓ = {a_1, b_2, a_3, a_4}.
Question 17. Écrire une fonction linearisation qui prend en argument une expression rationnelle et renvoie l'expression obtenue en la linéarisant. Cette fonction doit avoir une complexité linéaire en la longueur de l'expression de départ (sans avoir à le justifier).
Exemple.
linearisation([’+’, [’*’, [’.’, [’a’] , [’b’] ] ], [’+’, [’.’, [’a’], [’a’]], [’’]])
s'évalue en ['+', ['*', ['.', ['a1'], ['b2']]], ['+', ['.', ['a3'], ['a4']], ['']]].
Pour construire l'automate de Glushkov de E à partir de E_ℓ, on définit les sous-ensembles et valeurs suivants :
  • - First(E) est l'ensemble des lettres de Σ_ℓ qui apparaissent au début d'un mot de L(E_ℓ) :
    First(E) = {x ∈ Σ_ℓ|∃u ∈ Σ_ℓ^∗, xu ∈ L(E_ℓ)},
  • -Last (E) est l'ensemble des lettres de Σ_ℓ qui apparaissent à la fin d'un mot de L(E_ℓ) :
    Last(E) = {x ∈ Σ_ℓ|∃u ∈ Σ_ℓ^∗, ux ∈ L(E_ℓ)},
  • - Null(E) = vrai si L(E_ℓ) contient le mot vide, et faux sinon;
  • - Follow(E) est l'ensemble des facteurs de longueur 2 des mots de L(E_ℓ) :
    Follow(E) = {xy ∈ Σ_ℓ^2|∃u, v ∈ Σ_ℓ^∗, uxyv ∈ L(E_ℓ)}.
Question 18. Donner les valeurs de First(E), Last(E), Null(E) et Follow(E) pour l'expression E = (a + b)^∗ a + (ab + ε)(c + ε).
On associe à l'expression E l'automate A_ℓ = (Σ_ℓ, Σ_ℓ ∪ {i}, δ_ℓ, i, F) sur l'alphabet Σ_ℓ, ayant pour ensemble d'états Σ_ℓ ∪ {i} (où i ∉ Σ_ℓ ) et pour état initial i défini par :
  • -pour toute lettre x ∈ First(E), on a la transition δ_ℓ(i, x) = {x} de l'état i par la lettre x,
  • -pour toute lettre x ∈ Σ_ℓ et toute lettre y ∈ Σ_ℓ telles que xy ∈ Follow(E), on a la transition δ_ℓ(x, y) = {y} de l'état x par la lettre y,
  • -l'ensemble des états finaux F = Last(E) ∪ {i} si Null(E) est vrai, et F = Last(E) sinon.
Question 19. Montrer que l'automate A_ℓ reconnaît le langage décrit par l'expression E_ℓ.
On déduit de A_ℓ un automate A en supprimant les indices sur les transitions. Ainsi, la transition a_j→−^(a_k)a_k(a ∈ Σ, j, k ∈ {1, …, #E}) de A_ℓ devient la transition a_j → ^a a_k dans A.
Cet automate est appelé automate de Glushkov associé à E.
Question 20. Justifier que dans l'automate A, toutes les transitions qui arrivent dans un état portent la même étiquette.
Question 21. Montrer que l'automate A reconnait le langage décrit par l'expression E.
Question 22. Donner en la justifiant une comparaison entre le nombre d'états de l'automate A ainsi obtenu et la taille de l'expression rationnelle E.

4.2 Construction de l'automate

Dans cette section, on s'intéresse à l'implantation de l'automate de Glushkov d'une expression rationnelle.
Pour toute expression rationnelle E, les ensembles First(E), Last(E) et Follow(E) sont finis par construction. On suppose qu'on dispose d'une classe Set qui nous servira à représenter et manipuler des ensembles finis, dont voici un extrait de la documentation et qu'on pourra supposer importée (on ne vous demande pas d'écrire le code de cette classe) :
class Set(builtins.object)
    représentation d'un ensemble fini
    Methods defined here:
    disjoint_extend(self, other)
        modifie l'objet courant en l'étendant avec le contenu de
        l'objet passé en paramètre et renvoie l'objet courant ainsi modifié,
        les deux objets doivent représenter des ensembles disjoints,
        le résultat représente l'union des deux ensembles;
        cette méthode s’exécute en temps linéaire en la taille de
        l'ensemble fourni en argument.
    empty()
        renvoie un ensemble vide;
        cette méthode s’exécute en temps constant.
    extend(self, other)
        modifie l'objet courant en l'étendant avec le contenu de
        l'objet passé en paramètre et renvoie l'objet courant ainsi modifié,
        le résultat représente l'union des deux ensembles (sans doublons);
        cette méthode s’exécute en temps proportionnel au produit
        de la taille de l'ensemble sur lequel la méthode est
        invoquée et de la taille de l'ensemble fourni en argument.
    product(self, other)
        renvoie le produit cartésien de deux langages,
        le résultat est un ensemble de couples;
        cette méthode s’exécute en temps proportionnel au produit
        de la taille de l'ensemble sur lequel la méthode est
        invoquée et de la taille de l'ensemble fourni en argument.
    singleton(e)
        renvoie un singleton contenant l'élément fourni en argument;
        cette méthode s'exécute en temps constant.
Une façon d'implanter une telle classe Set est d'utiliser un tableau redimensionnable. On obtient alors la complexité linéaire de la méthode disjoint_extend en suivant le même principe de redimensionnement que celui expliqué en section 1.
Remarque : On n'utilise pas le type set de Python car on souhaite pouvoir exploiter la différence de complexité entre une union disjointe et une union qui a priori ne l'est pas, et on proposera une optimisation de la classe ci-dessus plus tard dans le sujet.
On donne le code suivant pour calculer First, Last, Null et Follow d'une expression rationnelle locale non vide :
def glushkov(exp):
"""renvoie first, last, null et follow de l'expression exp
exp doit être locale et non vide
first, last et follow sont des instances de Set, null est un booléen"""
assert(len(exp) > 0 and len(exp) <= 3)
if len(exp) == 1:
    if exp[0] == ’’:
        first, last, null, follow = Set.empty(), Set.empty(), True, Set.empty()
    else:
        first, last = Set.singleton(exp[0]), Set.singleton(exp[0])
        null, follow = False, Set.empty()
elif len(exp) == 3:
    assert(exp[0] in [’+’, ’.’])
    first1, last1, null1, follow1 = glushkov(exp[1])
    first2, last2, null2, follow2 = glushkov(exp[2])
    if exp[0] == '+':
        first = first1.disjoint_extend(first2)
        last = last1.disjoint_extend(last2)
        null = null1 or null2
        follow = follow1.disjoint_extend(follow2)
    else:
        first = first1 if not null1 else first1.disjoint_extend(first2)
        last = last2 if not null2 else last2.disjoint_extend(last1)
        null = null1 and null2
        follow = follow1.disjoint_extend(follow2).disjoint_extend(last1.product(first2))
else:
    assert(exp[0] == '*')
    first1, last1, null1, follow1 = glushkov(exp[1])
    first, last, null = first1, last1, True
    follow = follow1.extend(last1.product(first1))
return first, last, null, follow
Question 23. Dérouler la fonction glushkov sur l'expression
[’+’, [’*', [’.’, [’a1’] , [’b2’] ] ], [’.’, [’a3’], [’a4’]]].
Question 24. La fonction glushkov réalise un parcours de l'arbre syntaxique de l'expression. Comment est appelé ce parcours?
Question 25. Justifier que les utilisations de disjoint_extend sont adéquates.
Question 26. Donner un exemple pour expliquer pourquoi la dernière extension du code (ligne 29) n'est pas une extension disjointe.
Question 27. Montrer la correction totale de la fonction glushkov.
On s'intéresse maintenant à la complexité temporelle de la fonction glushkov, en séparant l'analyse en fonction de ce qu'on calcule parmi First, Last, Null et Follow. On note n la longueur de l'expression fournie en argument.
Question 28. Montrer que la complexité temporelle du calcul de Null en suivant l'algorithme de la fonction glushkov est en Θ(n).
On admet que la complexité des calculs de First et Last en suivant l'algorithme de la fonction glushkov est linéaire en la longueur de l'expression de départ.
Question 29. Montrer que la complexité dans le pire des cas du calcul de Follow en suivant l'algorithme de la fonction glushkov est en Ω(n^5) où n est la longueur de l'expression fournie en argument. On pourra par exemple considérer la famille d'expressions locales définie récursivement par F_1 = a_1^∗ et F_n = (F_(n − 1) + a_n) *. On rappelle que les complexités des méthodes de la classe Set sont données dans la documentation de cette même classe.

4.3 Amélioration de la complexité

L'extension non disjointe dans la fonction glushkov joue un rôle non négligeable dans sa complexité. Dans cette partie, nous allons voir comment nous ramener à une extension disjointe.
Soit une expression locale E de la forme F^∗. Le calcul de follow repose sur la relation
Follow(E) = Follow(F^∗) = Follow(F) ∪ (Last(F) × First(F)),
où l'union n'est pas nécessairement disjointe, comme on l'a vu à la question 26.
Or, on peut exprimer cette relation avec l'union disjointe suivante (en notant ⊔ l'opérateur d'union disjointe) :
Follow(E) = Follow(F^∗) = Follow(F)⊔((Last(F) × First(F))∖Follow(F)).
Notons
RFoll(F) = (Last(F) × First(F))∖Follow(F).
Le but des questions suivantes est de montrer que si F est une expression rationnelle locale, cet ensemble peut se calculer inductivement.
Question 30. Donner les valeurs de RFoll(ε), RFoll(a) pour une lettre a ∈ Σ et RFoll(E^∗) pour une expression rationnelle locale E.
Question 31. Montrer que si E et F sont des expressions rationnelles locales, on a :
RFoll(E + F), = RFoll(E)⊔RFoll(F)⊔Last(E) × First(F)⊔Last(F) × First(E); RFoll(E ⋅ F), = Last(F) × First(E)⊔Null(F) ⋅ RFoll(E)⊔Null(E) ⋅ RFoll(F),
où le produit b ⋅ X d'un booléen b par un ensemble X est l'ensemble vide si b vaut faux, et l'ensemble X si b vaut vrai.
Question 32. Expliquer brièvement (sans écrire de code) comment on peut utiliser cette formule pour améliorer la complexité du calcul de Follow de manière efficace.
Question 33. Expliquer (sans écrire de code) comment on peut implanter des ensembles avec des listes chaînées de sorte que la complexité de l'extension disjointe soit constante et les complexités des autres opérations présentes dans la classe Set restent inchangées. Quelle serait la conséquence d'un tel choix sur la complexité de la fonction glushkov (en supposant qu'on a utilisé l'implantation suggérée à la question 32) ?

Partie III. Test d'égalité de langages reconnus par automates

Pour tester si deux expressions E et F décrivent le même langage, on se propose de calculer leurs automates de Glushkov, qui sont ensuite déterminisés grâce à la construction classique par sous-ensembles. Il faut alors décider si deux automates déterministes reconnaissent le même langage. Nous verrons deux façons de procéder.
On travaillera uniquement sur des automates déterministes et complets. Soit A = (Σ, Q, δ, i_0, F) un tel automate où :
  • - Σ est un alphabet fini non vide contenant les lettres;
  • - Q est un ensemble fini non vide contenant les états;
  • - δ : Q × Σ → Q est l'application des transitions ;
  • - i_0 ∈ Q est l'état initial;
  • - F ⊆ Q est l'ensemble des états terminaux.
Quand δ(p, a) = q on dira qu'il y a une transition de p à q étiquetée par a, et on le notera également p → ^a q. La taille d'un automate est son nombre d'états, elle est notée ‖A‖. On a ainsi ‖A‖ = |Q|. On notera L(A) le langage reconnu par A.

5 Partitions des états

On rappelle que si E est ensemble fini non vide, une partition P de E est un ensemble {E_0, …, E_(ℓ − 1)} de sous-ensembles non vides de E dont l'union est E tout entier (∪ _(i = 0)^(ℓ − 1)E_i = E) et qui sont deux à deux disjoints : si i et j sont deux indices différents entre 0 et ℓ − 1, alors E_i ∩ E_j = ∅. Chaque E_i est appelé une part de la partition. Par exemple, P = {{0, 1, 4}, {2, 5}, {3}} est une partition de {0, 1, 2, 3, 4, 5} en trois parts.
Dans toute la suite on ne considérera que des partitions d'ensembles d'états d'automates déterministes et complets. Si A = (Σ, Q, δ, i_0, F) est un tel automate, et P est une partition de Q, alors pour tout (p, q) ∈ Q^2 on note p ∼ _P q le fait que p et q sont dans la même part de la partition P. On définit le Σ-raffinement de P comme étant l'unique partition P^′ de Q telle que
∀(p, q) ∈ Q^2, p ∼ _(P^′)q ⟺ {p ∼ _P q,; et; ∀a ∈ Σ, δ(p, a) ∼ _P δ(q, a).
Question 34. On considère l'automate ci-dessous et la partition P = {{0, 1}, {2, 3}} de son ensemble d'états. Calculer le Σ-raffinement de P.
On souhaite à présent calculer efficacement le Σ-raffinement d'une partition de l'ensemble d'états d'un automate. Pour simplifier les représentations en machine, on considérera que l'alphabet Σ est {0, …, m − 1} et que si l'automate possède n états, son ensemble d'états est {0, …, n − 1}. Un tel automate déterministe et complet sera encodé en Python par un tuple (m, n, delta, q0, F), où m est le nombre de lettres de l'alphabet, n est le nombre d'états de l'automate, q0 est l'état initial (un entier entre 0 et n − 1 ) et
  • -delta est une liste contenant n listes de longueur m, appelé la table des transitions ; pour tous états p, q ∈ {0, …, n − 1} et toute lettre a ∈ {0, …, m − 1} on a delta[p] [a]=q si et seulement si p → ^a q (on rappelle que les automates de cette partie sont déterministes et complets, ce qui permet un tel encodage);
  • -F est une liste de n booléens avec, pour tout état p ∈ {0, …, n − 1}, F[p] = True si et seulement si p est terminal.
Les partitions de l'ensemble des états d'un automate sont représentées de la façon suivante. Si P est une partition de {0, …, n − 1} qui contient c parts, on encodera P par une liste part de longueur n, contenant des entiers de {0, …, c − 1}, et tel que pour tous états p, q ∈ {0, …, n − 1}, p et q sont dans la même part de P si et seulement si part[p] == part[q]. Autrement dit, on numérote les parts avec des nombres entre 0 et c − 1, et la liste part associe à chaque état le numéro de sa part. On remarque qu'un tel encodage n'est pas unique car on peut échanger les numéros des parts.
Exemple. Si n = 7 et que la partition est {{0, 3, 4}, {1}, {2, 5, 6}}, on peut la représenter par [0,1,2,0,0,2,2] ou encore [1,2,0,1,1,0,0].
Question 35. Écrire la fonction Python nombre_parts(part) qui calcule le nombre de parts de la partition encodée par part, en temps O(n), où n est la taille de part. On ne demande pas de justifier la complexité.
On souhaite écrire la fonction Python raffine(A, part) qui calcule et renvoie le Σ -raffinement de la partition encodée par une liste part de l'ensemble d'états d'un automate A donné également en argument en utilisant l'équation (1). Pour cela, on va associer à chaque état p la liste s [p] de longueur m + 2 suivante :
s[p] = [part[p], part[ delta[p][0] ], ..., part[ delta[p][m-1] ], p].
On encode donc dans s[p] les informations utiles pour l'équation (1); on y a ajouté p à la fin pour pouvoir directement retrouver l'état p à partir de s[p].
Question 36. En utilisant le tri lexicographique de la section 2, écrire une implantation en Python de la fonction raffine(A, part) en temps O(nm) : elle doit renvoyer un encodage du Σ-raffinement de la partition encodée par part, où A est un encodage de l'automate déterministe et complet avec n états sur un alphabet de taille m. On ne demande pas de justifier la complexité.

6 Partition de Nerode

Soit A = (Σ, Q, δ, i_0, F) un automate déterministe et complet. Soit F¯ = Q∖F le complémentaire de F dans Q. La partition P_0 de Q est la partition telle que pour tout (p, q) ∈ Q^2, p ∼ _(P_0)q si et seulement si (p, q) ∈ F^2 ou (p, q) ∈ F¯^2 : autrement dit p et q sont dans la même part de P_0 quand ils sont soit tous les deux terminaux, soit tous les deux non-terminaux.
Question 37. Écrire une fonction Python calcule_partition0(A) qui calcule et renvoie la partition P_0 des états de l'automate déterministe et complet encodé par A, en temps O(n). On ne demande pas de justifier la complexité.
Soit A un automate déterministe et complet sur l'alphabet Σ. Pour tout entier i ≥ 1, on définit récursivement la partition P_i comme étant le Σ-raffinement de P_(i − 1). Il s'agit donc d'itérer i fois le Σ-raffinement de P_0. Pour simplifier les notations, pour tout i ∈ ℕ on notera ∼ _i au lieu de ∼ _(P_i) dans la suite.
Question 38. Calculer P_0, P_1 et P_2 pour l'automate de la question 34.
Pour un alphabet Σ et un entier i ≥ 0, on note Σ^(≤ i) l'ensemble des mots sur Σ dont la longueur est au plus i : Σ^(≤ i) = {u ∈ Σ^∗||u|≤i}. Si A = (Σ, Q, δ, i_0, F) est un automate déterministe et complet, pour tout q ∈ Q on note A_q = (Σ, Q, δ, q, F), l'automate obtenu en déplaçant l'état initial en q. On note L_q le langage reconnu par A_q. Ainsi, par exemple, L_(i_0) est le langage L(A) reconnu par A car A = A_(i_0).
Question 39. Soit A = (Σ, Q, δ, i_0, F) un automate déterministe et complet. Montrer que pour tout i ∈ ℕ et pour tout (p, q) ∈ Q^2, p ∼ _i q si et seulement si L_p ∩ Σ^(≤ i) = L_q ∩ Σ^(≤ i).
Soit A = (Σ, Q, δ, i_0, F) un automate déterministe et complet. On définit la partition de Nerode N_A associée à A comme étant l'unique partition de Q telle que pour tout (p, q) ∈ Q^2, p et q sont dans la même part de N_A si et seulement si L_p = L_q. Pour simplifier les notations, on écrira p ∼ q au lieu de p ∼ _(N_A)q pour indiquer que p et q sont dans la même part de N_A. L'algorithme de Moore ci-dessous permet de calculer la partition de Nerode d'un automate encodé par A (on considère qu'appliquer la fonction nombre_parts à None renvoie 0).
def moore(A):
    """A est un automate déterministe et complet,
    qui respecte l'encodage du sujet. la fonction renvoie
    la partition de Nerode de A."""
    ancienne_part = None
    part = calcule_partition0(A)
    while nombre_parts(ancienne_part) != nombre_parts(part):
        ancienne_part = part
        part = raffine(A, part)
    return part
Question 40. Montrer que si l'automate possède n ≥ 2 états, il y a au plus n − 1 itérations de la boucle while.
Question 41. Montrer la correction totale de l'algorithme de Moore.
Question 42. Montrer que l'algorithme de Moore a une complexité en O(mn^2), où n est le nombre d'états de l'automate et m le nombre de lettres de l'alphabet.
Question 43. Montrer que l'algorithme de Moore a une complexité en Θ(mn^2), où n est le nombre d'états de l'automate et m le nombre de lettres de l'alphabet.

7 Test d'égalité utilisant la partition de Nerode

Pour les deux questions suivantes, vous pouvez utiliser la définition de la partition de Nerode, ou bien le calcul qui en est fait par l'algorithme de Moore, qui est correct d'après la question 41.
Question 44. Soient p et q deux états de l'automate. Montrer que si p ∼ q alors (p, q) ∈ F^2 ou (p, q) ∈ F¯^2.
Question 45. Soient p et q deux états de l'automate. Montrer que si p ∼ q alors pour tout lettre a ∈ Σ, on a δ(p, a) ∼ δ(q, a).
Les deux propriétés des questions 44 et 45 permettent de définir l'automate quotient de A par sa partition de Nerode qui est l'automate déterministe et complet A/∼=(Σ, N_A, δ_∼, Q_0, F_∼) dont l'ensemble d'états est l'ensemble des parts Q_0, Q_1, …Q_(ℓ − 1) de N_A, où Q_0 est la part de i_0 et :
  • -pour tout Q_i ∈ N_A et pour tout a ∈ Σ, on définit δ_∼(Q_i, a) comme étant l'unique part Q_j de N_A telle que pour tout p ∈ Q_i, δ_∼(p, a) ∈ Q_j (cela est garanti par la question 45);
  • - F_∼ est l'ensemble des Q_i ∈ N_A composés uniquement d'états terminaux (la question 44 assure que les états d'une même part sont tous terminaux ou tous non-terminaux).
Question 46. Calculer l'automate quotient de A par sa partition de Nerode, où A est l'automate de la question 34.
Pour finaliser le test d'équivalence, on utilise un résultat (admis) de théorie des automates : soient A = (Σ, Q_A, δ_A, i_A, F_A) et B = (Σ, Q_B, δ_B, i_B, F_B) deux automates déterministes et complets dont tous les états sont accessibles depuis leurs états initiaux. Alors L(A) = L(B) si et seulement si A/ ∼ et B/ ∼ sont isomorphes, c'est-à-dire qu'il existe une bijection φ de Q_A dans Q_B telle que
  • - φ(i_A) = i_B,
  • -pour tout p ∈ Q_A et tout a ∈ Σ, φ(δ_A(p, a)) = δ_B(φ(p), a),
  • - φ(F_A) = F_B.
En particulier, ce résultat utilise le fait que A et A/ ∼ reconnaissent le même langage, ce qu'on ne vous demande pas de montrer.
Question 47. Sans écrire le programme, expliquer comment on peut en temps O(|Σ|(‖A‖ + ‖B‖) ) tester si deux automates déterministes et complets A et B, dont tous les états sont accessibles depuis leurs états initiaux, sont isomorphes. On rappelle que ‖A‖ est le nombre d'états de l'automate A et |Σ| est le cardinal de l'alphabet.
En conclusion de cette section, on remarque qu'on peut en déduire un algorithme pour tester en temps O(mn^2) si deux automates déterministes et complets A et B reconnaissent le même langage, où n = max{‖A‖, ‖B‖} et m = |Σ| :
  • -on enlève les états qui ne sont pas accessibles depuis les états initiaux dans A et B en les identifiants avec un parcours en profondeur ;
  • -on applique l'algorithme de Moore aux deux automates, et on calcule leur automates quotients par leurs partitions de Nerode respectives;
  • -on teste si les deux automates quotients sont isomorphes.

8 Test d'égalité avec la structure "unir & trouver"

L'objet de cet section est de proposer un algorithme plus efficace pour déterminer si deux automates déterministes et complets A = (Σ, Q_A, δ_A, i_A, F_A) et B = (Σ, Q_B, δ_B, i_B, F_B), définis sur le même alphabet Σ, reconnaissent le même langage.
L'algorithme étudié utilise également des partitions mais diffère fondamentalement des parties précédentes.
On travaille sur les deux automates simultanément, l'ensemble dont on considère les partitions est Q = Q_A ∪ Q_B, l'union des ensembles d'états de A et de B, que l'on suppose disjoints. Si p ∈ Q, on note L_p le langage reconnu en changeant l'état initial de l'automate qui contient p pour le placer en p.
Informellement, l'algorithme fonctionne de la façon suivante. Au début, chaque état est seul dans sa part. Ensuite, on fait l'union, autant que possible, des parts des états p et q qui
class Partition(builtins.object)
    Methods defined here:
    __init__(self, n)
        initialise une partition de {0,...,n-1}
        où chaque élément est seul dans sa part
    trouver(self, x)
        renvoie un identifiant unique de la part de x :
        x et y sont dans la même part si et seulement si
        trouver(x) == trouver(y)
    unir(self, x, y)
        modifie la partition en réalisant l'union des parts de x et de y
Figure 2 - Méthodes de la classe Partition implantant la structure Unir et Trouver.
doivent nécessairement vérifier L_p = L_q si L(A) = L(B) : par exemple, la première union consiste à créer la part {i_A, i_B}, puisque si L(A) = L(B) alors L_(i_A) = L_(i_B). Toujours sous l'hypothèse que L(A) = L(B), pour chaque a ∈ Σ, on doit avoir L_p = L_q quand p = δ_A(i_A, a) et q = δ_B(i_B, a), il faut donc réaliser l'union des parts contenant δ_A(i_A, a) et δ_B(i_B, a) pour toute lettre a. L'algorithme utilise une pile, implantée par une liste Python, pour garder la trace des paires d'états à considérer à chaque étape. L'algorithme a deux façons de s'arrêter :
  • -soit il doit unir les parts de p et q alors que seulement l'un de ces deux états est final, auquel cas il renvoie faux ;
  • -soit il n'y a plus de paire d'états à considérer, auquel cas il renvoie vrai.
Une implantation de l'algorithme est proposée à la Fig. 3 page 17 elle utilise la méthode pop() qui permet d'enlever le dernier élément d'une liste Python et de le renvoyer, en temps constant. Elle utilise également une implantation de la structure de données "unir & trouver" ("union & find" en anglais), qui permet de travailler sur des partitions d'un ensemble fini non vide {0, …, n − 1} comme décrit Fig. 2. L'implantation est réalisée de sorte que si on initialise une partition de taille n puis que l'on effectue t appels à la fonction trouver ou unir, la complexité totale en temps est en O(tα(n)), où α est l'inverse de la fonction d'Ackermann. La fonction α tend vers l'infini extrêmement lentement (on considère en général que α(n) ≤ 5 pour les valeurs de n que l'on peut utiliser en pratique).
L'entrée de test_egalite_langages est constituée des deux automates déterministes et complets A et B, définis sur le même alphabet, encodés par A = (mA, nA, deltaA, iA, FA) et B=(mB,nB,deltaB,iB,FB). Comme cette fonction doit travailler avec une partition de l'union de Q_A et de Q_B et que dans notre choix d'encodage Q_A = {0, …, n_A − 1} et Q_B = {0, …, n_B − 1} ne sont pas disjoints, on différencie les états de Q_B en leur ajoutant n_A : l'ensemble de la partition est {0, …, n_A + n_B − 1}, et les entiers de {0, …, n_A − 1} représentent les états de A, alors que les entiers de {n_A, …, n_A + n_B − 1} représentent les états de B.
Question 48. Montrer qu'à tout moment lors de l'exécution de test_egalite_langages, pour tout couple d'états (p, q) de la liste a_faire, il existe un mot u ∈ Σ^∗ tel que δ_A(i_A, u) = p et δ_B(i_B, u) = q. En déduire que si le programme renvoie faux, les langages reconnus par A et par B sont différents.
def test_egalite_langages(A, B):
    """A et B sont des encodages d'automates déterministes et complets
    sur le même alphabet"""
    mA, nA, deltaA, iA, FA = A
    mB, nB, deltaB, iB, FB = B
    assert(mA == mB)
    partition = Partition(nA + nB)
    a_faire = [(iA, iB)]
    while len(a_faire)>0:
        p, q = a_faire.pop()
        if partition.trouver(p) != partition.trouver(q + nA):
            if FA[p] != FB[q]:
                return False
            else:
                partition.unir(p, q + nA)
                for a in range(mA):
                    a_faire.append( (deltaA[p][a], deltaB[q][a]) )
    return True
Figure 3 - Programme Python pour tester l'égalité des deux langages reconnus par les automates déterministes et complets A et B.
Dans la suite, pour tout état p ∈ Q_A ∪ Q_B et toute lettre a ∈ Σ, on note δ(p, a) = δ_A(p, a) si p ∈ Q_A et δ(p, a) = δ_B(p, a) si p ∈ Q_B.
Question 49. On suppose que le programme test_egalite_langages renvoie vrai. On note P_(fin) la partition obtenue à la fin de l'exécution du programme. Montrer la propriété suivante : à tout moment lors de l'exécution du programme, si deux états p et q sont dans la même part P de la partition courante partition, alors, à la fin de l'exécution, δ(p, a) et δ(q, a) sont dans la même part de P_(fin), pour toute lettre a ∈ Σ. On pourra utiliser une récurrence sur le cardinal de la part P.
Question 50. En déduire que si le programme renvoie vrai, les deux automates reconnaissent le même langage.
Question 51. Montrer que test_egalite_langages s'exécute en temps O(mnα(n)), où n = ‖A‖ + ‖B‖ et m = ‖Σ|.

Pas de description pour le moment