Corrigé du sujet d’informatique du CAPES 2019

jeudi 25 avril 2019
par  Alain BUSSER , Sébastien HOARAU

Le sujet est disponible ici

Le premier problème portait sur la numération en base 3 équilibrée qui est une sorte de binaire signé chiffre par chiffre, et le second problème portait sur des problèmes de théorie de graphe qui apparaissent lorsqu’on veut allouer des registres à des variables.

Problème 1 : La base 3 équilibrée

PARTIE I --- Généralités

Question 1

  1. Voici la traduction décimale des 3 nombres en base 3 équilibrée :
    • 1(-1)0(-1)_e = 1 * 33 + -1 * 32 + 0 * 3 - 1 = 27-9-1 = 17
    • 1111_e = 1 * 33 + 1 * 32 + 1 * 3 + 1 = 27+9+3+1 = 40
    • 1(-1)(-1)(-1)(-1)_e = 34 - 33 - 32 - 3 - 1 = 81-27-9-3-1 = 41
  2. L’écriture en base 3_e des entiers de 1 à 9 :
base 10 base 3_e Explications
1 1 = 1
2 1(-1) = 3 - 1
3 10 = 3
4 11 = 3 + 1
5 1(-1)(-1) = 9 - 3 - 1
6 1(-1)0 = 9 - 3
7 1(-1)1 = 9 - 3 + 1
8 10(-1) = 9 - 1
9 100 = 9

Question 2

  • Ak = 1 1...1_e = 3k + 3k-1 + ... + 3 + 1 = (3k+1-1)/2 est numérotée 3462 sur OEIS
  • Bk = 1(-1)...(-1)_e = 3k - 3k-1 - ... - 3 - 1 = 3k-(3k-1)/2=(3k+1)/2 est la suite 7051 sur OEIS.

Question 3

On modélise par la liste [a_p-1, ... a_1, a_0] le nombre (a_0a_1...a_p-1)_e. Ci-dessous une définition de la fonction valeur(L) qui étant donnée une liste L représentant l’écriture en base 3_e d’un nombre, nous donne sa valeur décimale.

def valeur(L):
    return sum(a * 3 ** index for index, a in enumerate(L))

Explication du code : ici on utilise une fonction génératrice associée à la fonction prédéfinie sum pour avoir une écriture proche de la formule mathématique : la somme des puissances de 3 fois le coefficient pour tous les coefficients de la liste.

PARTIE II --- Existence et unicité

Question 4

(4a) On suppose qu’un entier n est tel que : n = 3q + r et q s’écrit (a0...ap-1)_e. Dès lors n s’écrit :

  • (a0...ap-10)_e si r = 0 ; multiplier q par 3 c’est décaler son écriture vers la gauche et on rajoute 0
  • (a0...ap-11)_e si r = 1 ; même chose mais on rajoute 1.

(4b) On suppose que q > 0 et q + 1 s’écrit (b0...bp-1)_e. Dès lors si r = 2, n = 3(q + 1) - 1 et donc s’écrit (b0...bp-1(-1))_e

(4c) On propose une récurrence sur q :

  • Initialisation : On a vu au début du sujet que les premiers entiers non nuls possèdent une écriture en base 3 équilibrée.
  • Hérédité : On vient de voir comment passer de l’écriture de n à celle de 3n ou 3n+1 (ce qui prouve que ces entiers ont une écriture), et comment on peut passer de l’écriture de 3n+3 à celle de 3n+2. Mais on peut passer de l’écriture de n+1 à celle de 3n+3 ce qui prouve que 3n+3 et 3n+2 ont une écriture.

Question 5

(5a) Définition récursive de incrementeR(L)

def incrementeR(L):
    if len(L) == 0:
        return [1]
    elif L[0] == 0:
        return [1] + L[1:]
    elif L[0] == -1:
        return [0] + L[1:]
    else:
        return [-1] + incrementeR(L[1:])
  • Explication de la ligne 9 : On est dans le cas où n = n’ + 1 (L[0] vaut 1) représenté par L. Dès lors [0]+L[1 :] représente n’ et donc L[1 :] seul est un décalage à gauche de n’ et représente n’/3. L’appel récursif représente alors n’/3 + 1 et en redécalant à droite on a 3*(n’/3 + 1) soit n’ + 3 et en ajoutant -1 on obtient n’ + 2 soit n + 1.
  • Les lignes 1 et 2 sont là pour le cas terminal : à chaque appel récursif, la longueur de L diminue strictement de 1. En partant d’une liste non vide on finira par tomber sur le cas len(L) == 0.

(5b) Version itérative :

def incremente(L):
    p = len(L)
    M = []
    k = 0
    while k < p and L[k] == 1:
        M.append(-1)
        k += 1
    if k == p:
        M.append(-1)
    elif L[k] == 0:
        M.append(1)
        M = M + L[k+1:] 
    elif L[k] == -1:
        M.append(0)
        M = M + L[k+1:]
    return M

Question 6

(6a) Si a0, ... , ap-1 sont les chiffres de C, alors C est la somme des ak3k qui sont tous divisibles par 3 sauf ap-1. C et ap-1 ont donc le même reste dans la division euclidienne par 3. Le reste de C est donc égal à ap-1 si celui-ci est positif et à ap-1+3=2 sinon.

(6b) Si a0, ... , ap-1 et b0, ... , bp-1 désignent les écritures de C en base 3 équilibrée, on veut prouver que qk=bk pour toute valeur de k. On peut le faire par récurrence décroissante sur k : D’après la question précédente, on a nécessairement ap-1=bp-1, et on continue la récurrence après divisions successives par 3.

Question 7

def base3e(n):
    chiffres = [0,1,-1]
    ajout = [0, 0, 1]
    L = []
    while n > 0:
        reste = n % 3
        L.append(chiffres[reste])
        n = n // 3 + ajout[reste]
    return L

PARTIE III --- Chemins de Delannoy

Question 8

(8a) Chemin de 2019 : 2019 = (10-110-110)_e

(en cliquant sur le chemin de Delannoy ci-dessus, on ouvre une page interactive permettant de voir les chemins de Delannoy des nombres allant de 1 à 800)

(8b) Définir une fonction arrivee(n) qui donne les coordonnées du point d’arrivée du chemin de Delannoy associé à l’entier n.

def arrivee(n):
    
    def add(a, b):
        return a[0]+b[0], a[1]+b[1]
    
    steps = [(1,1), (1,0), (0,1)]
    ajout = [0, 0, 1]
    pt_arr = (0, 0)
    while n > 1: # ici 1 pour ne pas prendre en compte le premier chiffre 1
        reste = n % 3
        pt_arr = add(pt_arr, steps[reste])
        n = n // 3 + ajout[reste]
    return pt_arr

Question 9

On considère N(a, b) = { n | arrivee(n) == (a, b) }

(9a) Le min de N(a, a) consiste à avoir le chemin le plus court jusqu’à A(a, a). Donc (10...0)_e Avec a zéros. Ce qui nous donne l’entier 3a. Pour le maximum on va maximiser les puissances de 3... donc (11...1-1...-1)_e (il y a a digits 1 et a digits -1) pour un entier égal à 32a + 32a-1 + ... + 3a - 3a-1 - ... - 3 - 1 =(32a+1-2×3a+1)/2.

(9b) Pour le max N(a, b) avec a < b, ça ne change pas : (11...1-1...-1)_e avec a digits 1 et b digits -1. Il s’agit du nombre (3a+b+1-2×3b+1)/2.

Pour le minimum : min N(a, b) = (10...0-1...-1)_e avec a digits 0 et b - a digits -1. Il s’agit du nombre (2×3a+b-3b-a+1+1)/2.


Problème 2 : Compilation et algorithmes

A0 - Les graphes des modules

La partie A porte sur l’ordre dans lequel on compile différents modules : C’est un ordre topologique sur un graphe orienté. Un tel graphe peut être dessiné à l’aide du logiciel adéquat qui a servi à faire les figures ci-dessous.

Voici le graphe donné au début de la partie A de l’énoncé :

Dessiné, il ressemble à ceci :

On constate qu’il ne possède qu’un sommet sans successeur (« arrivée ») et un seul sommet sans prédécesseur (« départ »). Cette dernière caractéristique permet de jouer à un jeu de Nim sur ce graphe, en plaçant un pion (noir ci-dessous) sur le sommet 5 (le départ) :

Les joueurs déplacent le pion selon une arête, chacun son tour. Celui qui mène le pion au sommet 1 (l’arrivée) gagne le jeu. On voit qu’il y a une stratégie gagnante pour celui qui joue en premier, consistant à mettre le pion sur le sommet 2. Ceci se voit par la coloration du graphe :

PARTIE A --- Ordre topologique sur un graphe

Le graphe de l’énoncé a été obtenu à partir du précédent en enlevant le sommet M2 :

Il est codé ainsi :

N = 9
App = [True, True, False, True, True, True, True, True, True]
P = 8
Succ = [[1], [], [], [6], [0], [3], [0], [4], [0, 1, 7]];
G = [App, Succ, P]

Question 10

def mem(i, g):
    """renvoie True si le sommet numéro i est dans le graphe représenté par g"""
    return g[0][i]

Question 11

def pred(i, g):
    """renvoie la liste des sommets j tels que j->i est présent dans le graphe g."""
    return [j for j in range(N) if mem(j, g) and i in g[1][j]]

Par exemple :

>>> pred(0, G)
[4, 6, 8]

Question 12

def sansSuccesseur(g):
    """renvoie un numéro i tel que Mi n'a pas de successeur
    ou -1 si un tel sommet n'existe pas dans le graphe g"""
    for i in range(N):
        if mem(i, g) and g[1][i] == []:
            return i
    return -1

Par exemple :

>>> sansSuccesseur(G)
1

Question 13

import copy

def suppr(i, g):
    """crée et renvoie une copie de g privé du sommet i et des arcs qui s'y réfèrent"""
    h = copy.deepcopy(g)
    h[2] -= 1
    h[0][i] = False
    h[1][i] = []
    for j in range(N):
        if mem(j, h) and i in h[1][j]:
            h[1][j].remove(i)
    return h

Question 14

On considère un graphe G de p sommets, p ≤ N.

(14a)

Promenade : s0s1...sN... on a donc N+1 sommets. Supposons que les sommets soient tous différents alors p = N+1 ce qui contredit l’hypothèse.

(14b)

Encore une fois par l’absurde : si on suppose que le graphe ne possède pas de cycle, alors à chaque pas de si vers sj on est sur un sommet non déjà visité. Mais alors cela signifie qu’à la fin de la promenade on a N+1 sommets distincts ce qui contredit (14a).

(14c)

D’après (14b), soit si0,si1...sik=si0 le cycle. Supposons qu’il existe un ordre topologique N. Alors par définition à la fois de l’ordre et de la promenade, on a : N(si0) < N(si1) < ... < N(sik) = N(si0) d’où N(si0) est strictement inférieur à lui-même ce qui n’est pas possible. Il n’existe donc pas d’ordre topologique sur ce graphe.

Question 15

Soit s1 et s2 deux sommets de G. Si s1 et s2 ne sont pas s alors si s1 est successeur de s2 on aura NH(s2) < NH(s1) et donc N(s2) < N(s1) et raisonnement analogue s’il s’agit de s2 successeur de s1. Donc N vérifie bien la condition d’ordre topologique.

Si maintenant s2 est le noeud s, alors comme s n’a pas de successeur, s2 ne peut pas être le successeur de s. s peut être le successeur de s2. Dès lors, N(s2) = NH(s2) ≤ p - 2 < p - 1 = N(s). Là encore la contrainte est vérifiée. N est donc bien un ordre topologique sur G.

Question 16

(16a) On en déduit un algorithme pour trouver un ordre topologique (quand il existe)

def ordreTopologique(g):
    """renvoie une liste L telle que L[i] vaut N(i) 
    si N existe et None sinon"""
    n = len(g[0])
    L = [-1] * n
    
    def parcoursReussi(g):
        p = g[2]
        if p != 0:
            s = sansSuccesseur(g)
            if s == -1:
                return False
            else:
                L[s] = p
                return parcoursReussi(suppr(s, g))
        else:
            return True
    
    b = parcoursReussi(g)
    if b:
        return L
    else:
        return None

(16b) La seule fonction susceptible de boucler est parcoursReussi qui est récursive. Mais si p vaut 0 ou si on ne trouve pas de sommet sans successeur, ça termine. L’appel récursif se fait avec un graphe qui possède un de sommet de moins. On tombera donc sur un des deux cas :

  • il n’y a plus de sommet : L’algorithme termine alors
  • chaque sommet restant a au moins un successeur : Là encore l’algorithme termine.

Par exemple :

>>> print(ordreTopologique(G))
[7, 8, -1, 4, 6, 3, 5, 2, 1]

B0 - Python et les registres

La partie B porte sur l’allocation de 4 registres aux variables a, b, c et d utilisées dans cette fonction Python :

def capes():
    d = 1
    b = 2*d
    a = 3
    d = a*b
    print(d)
    c = 2*a-b
    print(a)
    d = c+b
    b = 2*a
    print(c,d)

Pour avoir les affichages du programme il suffit de lancer la fonction en entrant capes()

Mais pour savoir comment Python fait réellement l’allocation de registres, on pense naturellement à cet algorithme :

  1. Compiler la fonction capes() ci-dessus ;
  2. désassembler le bytecode ainsi obtenu ;
  3. regarder le résultat du désassemblage.

Pour cela on importe le module dis (comme « disassemble ») qui possède une fonction dis s’appliquant à toute fonction Python, comme par exemple la fonction capes() ci-dessus. En précédant la définition de la fonction d’un from dis import * et en la suivant d’un dis(capes) on obtient ceci :

4           0 LOAD_CONST               1 (1)
              3 STORE_FAST               0 (d)

  5           6 LOAD_CONST               2 (2)
              9 LOAD_FAST                0 (d)
             12 BINARY_MULTIPLY
             13 STORE_FAST               1 (b)

  6          16 LOAD_CONST               3 (3)
             19 STORE_FAST               2 (a)

  7          22 LOAD_FAST                2 (a)
             25 LOAD_FAST                1 (b)
             28 BINARY_MULTIPLY
             29 STORE_FAST               0 (d)

  8          32 LOAD_GLOBAL              0 (print)
             35 LOAD_FAST                0 (d)
             38 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             41 POP_TOP

  9          42 LOAD_CONST               2 (2)
             45 LOAD_FAST                2 (a)
             48 BINARY_MULTIPLY
             49 LOAD_FAST                1 (b)
             52 BINARY_SUBTRACT
             53 STORE_FAST               3 (c)

 10          56 LOAD_GLOBAL              0 (print)
             59 LOAD_FAST                2 (a)
             62 CALL_FUNCTION            1 (1 positional, 0 keyword pair)
             65 POP_TOP

 11          66 LOAD_FAST                3 (c)
             69 LOAD_FAST                1 (b)
             72 BINARY_ADD
             73 STORE_FAST               0 (d)

 12          76 LOAD_CONST               2 (2)
             79 LOAD_FAST                2 (a)
             82 BINARY_MULTIPLY
             83 STORE_FAST               1 (b)

 13          86 LOAD_GLOBAL              0 (print)
             89 LOAD_FAST                3 (c)
             92 LOAD_FAST                0 (d)
             95 CALL_FUNCTION            2 (2 positional, 0 keyword pair)
             98 POP_TOP
             99 LOAD_CONST               0 (None)
            102 RETURN_VALUE

Ce qui permet de suivre la chronologie de l’exécution du programme :

  • t=0 : La constante 1 est chargée par la machine Python
  • t=3 : La constante est stockée dans la variable d (la ligne 1 du programme est effectuée)
  • t=6 : La constante 2 est chargée
  • t=9 : Le contenu de d est chargé
  • t=12 : La multiplication 2*d est effectuée
  • t=13 : Le résultat est stocké dans b (la ligne 2 est alors effectuée)
  • t=16 à 21 : 3 est mis dans a (ligne 3 du programme Python)
  • t=22 à 31 : a*b est mis dans d
  • t=32 : C’est une fonction (globale) et non une constante qui est chargée : print
  • t=35 : d est chargé aussi
  • t=36 : La fonction print est appelée avec l’argument (contenu de) d
  • t=39 : puis dépilée (ce qui se passe à chaque return ; en effet la machine Python est basée sur une pile)
  • t=42 à 55 : 2*a-b est mis dans c (on constate que la suite des opérations est menée sur la pile avec la notation polonaise inversée 2 a * b - dans l’ordre d’exécution)
  • t=56 à 65 : L’affichage de a est effectué
  • t=66 à 75 : c+b est mis dans d
  • t=76 à 85 : 2*a est mis dans b
  • t=86 : La fonction print est empilée
  • t=89 : La variable c est chargée
  • t=92 : La variable d est chargée
  • t=95 : La fonction print est appelée (mais cette fois-ci avec deux arguments)
  • t=98 : La fonction est dépilée
  • t=99 : La constante 0 (signifiant qu’il n’y a pas eu d’erreur) est chargée
  • t=102 : La constante 0 est renvoyée par la fonction capes()

Comme on le voit, les registres du processeur et la manière dont les variables a, b, c et d leur sont allouées, ne sont pas affichés. C’est parce que la machine virtuelle Python n’est basée que sur cette pile. Ce sont les longueurs successives de la pile qui sont affichées dans l’avant-dernière colonne.

On note que c’est la deuxième année de suite que le sujet de Capes porte sur la notion de graphe d’intervalles.

PARTIE B --- Allocation de registres

Question 17

ligne a b c d
0 M M M M
1 M M M V
2 M V M M
3 V V M M
4 V V M V
5 V V M M
6 V V V M
7 V V V M
8 V M V V
9 M M V V

Question 18

Si par exemple la variable a est vivante en ligne 0, cela signifie qu’elle est utilisée sans avoir été initialisée. Dans ce cas le programme ne s’exécute pas, il s’interrompt avec ce message :

Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
NameError: name 'a' is not defined

Il ne serait donc pas exécutable.

Question 19

""" PROG_EX:"""
d=1 
b=2∗d 
a=3 
d=a∗b 
print(d) 
c=2∗a−b 
print(a) 
d=c+b 
b=2∗a 
print(c,d)

PROG_EX = [(3,[]), (1,[3]), (0,[]), (3,[0,1]), (None ,[3]), (2,[0,1]), (None ,[0]), (3,[1,2]), (1,[0]), (None ,[2,3])]
def determineVie(prog, p):
    """retourne le tableau de vie d'un programme prog de p variables"""
    nb_instructions = len(prog)
    vie = [[False] * p for _ in range(nb_instructions+1)]
    for index in range(nb_instructions - 1, -1, -1):
        left, variables = prog[index]
        for v in range(p):
            if v in variables:
                vie[index][v] = True
            else:
                vie[index][v] = vie[index+1][v]
        if left is not None:
            vie[index][left] = left in variables
    return vie[:-1]

Pas de difficulté particulière sur ce code : on parcourt les lignes de la table en remontant et en mettant à jour la vie d’une variable comme expliqué. Petite astuce la liste de vie comporte une ligne de plus pour ne pas avoir à traiter comme cas particulier la dernière ligne, d’où le vie[:-1] à la fin.

Par exemple :

>>> VIE_EX = determineVie(PROG_EX, 4)
>>> for line in VIE_EX:
           print(line)
[False, False, False, False]
[False, False, False, True]
[False, True, False, False]
[True, True, False, False]
[True, True, False, True]
[True, True, False, False]
[True, True, True, False]
[True, True, True, False]
[True, False, True, True]
[False, False, True, True]

Question 20

def periodesVie(vie, v):
    n = len(vie)
    periodes = []
    enCours = False
    i = n - 1
    while i >= 0:
        if vie[i][v]:
            if not enCours:
                periode = (i,)
                enCours = True
        else:
            if enCours:
                periode = (i,) + periode
                enCours = False
                periodes.append(periode)
        i -= 1
    return periodes

Par exemple :

>>> periodesVie(VIE_EX, 3)
[(7, 9), (3, 4), (0, 1)]

PARTIE C --- Graphe de coexistence

Dans cette partie, c’est le logiciel des graphes non orientés qui sera utilisé. Il a servi notamment à dessiner les graphes ci-dessous.

On a vu dans la question 17 que toute variable parmi a, b, c et d est utilisée en même temps que toute autre variable parmi elles. Si on représente par un graphe la relation « est utilisée en même temps que », le graphe correspondant, pour ces 4 variables, est le graphe K_4 complet à 4 sommets (0 pour a, 1 pour b etc) :

Si on représente chaque registre par une couleur, il faut donc 4 couleurs pour colorier ce graphe. Ce qui signifie qu’au moins 4 registres seront nécessaires pour faire tourner ce programme. La partie C porte sur un autre graphe, que voici au format Nirina974 :

Le graphe ressemble à ceci :

On remarque que ce graphe est dessiné comme un réseau social, avec des sommets dont le diamètre est fonction croissante du degré.

Question 21

  • Un problème de décision (de la forme existe-t-il... ?) est dit dans la classe NP s’il existe un algorithme en temps polynomial pour vérifier qu’une solution donnée en est une.
  • Un problème est NP-complet si tout problème de la classe NP se ramène à celui-là par une réduction polynomiale (en pratique il suffit d’en trouver un).

Dans la suite on se donne :

COLORS = ['bleu', 'rouge', 'vert']
G_EX = [[], [2, 3, 4], [1, 4, 5], [1], [1, 2, 5], [2, 4]]
COLOR_EX = [0, 0, 1, 1, 2, 0] 

Question 22

def bonnesCouleurs(g, couleurs):
    """retourne True si et seulement si couleurs est une coloration correcte
    des sommets de g ie que tous les sommets de g sont bien coloriés."""
    
    def sommet_bien_colorie(s):
        # un sommet s est bien colorié s'il n'a pas la même couleur que ses voisins
        # autrement dit : si tous ses voisins ont une couleur différente
        return all(couleurs[s] != couleurs[v] for v in g[s])
    
    return all(sommet_bien_colorie(s) for s in range(len(g)))

La fonction python all (présentée dans cet article) permet une solution particulièrement concise et élégante de la fonction bonnesCouleurs : un graphe est bien colorié si tous ses sommets sont bien coloriés. Un sommet est lui même bien colorié s’il ne partage pas sa couleur avec l’un de ses voisins.

Par exemple :

>>> bonnesCouleurs(G_EX, COLOR_EX)
True

Question 23

L’énoncé donnait cet algorithme pour trouver une bonne coloration (aucune garantie sur la minimalité de cette coloration) :

  1. choisir la première couleur disponible ;
  2. tant qu’il existe un sommet non colorié répéter les étapes 3 à 6 :
  3. choisir un sommet s non colorié, et le colorier avec la couleur courante ;
  4. répéter l’étape 5 pour tout sommet t non colorié ;
  5. si t n’est adjacent à aucun sommet colorié avec la couleur courante, le colorier avec la couleur courante ;
  6. choisir une nouvelle couleur ;
  7. fin de l’algorithme.

Voici la traduction de l’algorithme sous forme de fonction Python :

def coloriage(g):
    nb_sommets = len(g)
    une_couleur = 0
    couleurs = [None] * nb_sommets
    s_non_colories = list(range(nb_sommets))
    while s_non_colories:
        s = s_non_colories.pop() 
        couleurs[s] = une_couleur
        for t in s_non_colories[:]:
            if coloriable(g[t], couleurs, une_couleur):
                couleurs[t] = une_couleur
                s_non_colories.remove(t)
        une_couleur += 1
    return couleurs
        
        
def coloriable(voisins, couleurs, c):
    return all(couleurs[v] != c for v in voisins)

Par exemple :

>>> coloriage(G_EX)
[0, 0, 2, 1, 1, 0]

C’est bien le coloriage de l’énoncé :


Portfolio

JPEG - 57.2 kio

Commentaires