WikiPrépaLivrets

Polytechnique Option Informatique MP 2010Sujet, corrigé et rapport du jury

Pas encore noté
  • Arbres et structures récursives
  • Programmation dynamique
  • Complexité algorithmique
  • Représentation binaire des entiers

Téléchargements

Présentation du sujet

Difficile
Plus proche ancêtre commun (PPAC) dans un arbre : algorithmes et complexité
Afficher ou masquer la section

Le problème étudie plusieurs méthodes pour calculer le plus proche ancêtre commun de deux nœuds dans un arbre, avec un pré-traitement autorisé sur l'arbre. Les parties progressent d'une solution simple vers des méthodes plus efficaces, en passant par le lien avec la recherche du minimum dans un segment de tableau et par le cas particulier des arbres binaires complets.

  1. 1Partie IMéthode simple de calcul du PPAC, avec pré-traitement et réponse tous deux en O(n).
  2. 2Partie IIRéduction du PPAC à la recherche du plus petit élément d'un segment de tableau, résolue par programmation dynamique en pré-traitement O(n log n) et réponse O(log n).
  3. 3Partie IIIDeux questions sur la représentation binaire des entiers, en préparation de la partie IV.
  4. 4Partie IVCas particulier du PPAC dans un arbre binaire complet, avec pré-traitement en O(n) et réponse en O(1).
  5. 5Partie VRéduction du problème du minimum de segment au PPAC, montrant l'équivalence des deux problèmes.

Difficile. Plusieurs questions ont des moyennes très basses avec de forts pourcentages de zéro, par exemple la question 18 notée 0,5/20 avec 96% de zéro.

L'épreuve en chiffres

Moyenne 9,37 / 20 · écart-type 4,24 · 762 copies · où vous situez-vous ?
Afficher ou masquer la section
Moyenne
9,37/ 20
Écart-type
4,24
Copies
762
moyenne 9,3705101520
Deux tiers des copies environ (moyenne ± écart-type)

Votre note sur 20 à ce sujet, en conditions de concours.

Source : rapport du jury. Notes publiées par le concours (après harmonisation le cas échéant). Courbe : estimation par une loi normale.

Ce qu'a observé le jury

5 erreurs relevées
Hypothèse abusive sur la numérotation des nœuds · Absence de test sur l'exemple fourni · Question 9 très mal réussie
Afficher ou masquer la section

Le rapport détaille question par question les moyennes et les pourcentages de zéro. Beaucoup de questions ont été mal traitées, en particulier en fin de sujet, et le jury note que la plupart des fonctions demandées pouvaient s'écrire en moins de dix lignes.

Les erreurs les plus sanctionnées

  1. 1
    Hypothèse abusive sur la numérotation des nœudsQ1, Q3

    Certains candidats ont supposé que la numérotation des nœuds croît dans la liste des fils, ce qui n'était pas impliqué par l'hypothèse de l'énoncé.

    « Cer tains candidats ont abusivement supposé que la numérotation des n oeuds allait en croissant »
  2. 2
    Absence de test sur l'exemple fourniQ2

    Beaucoup de candidats n'ont pas testé leur solution sur l'exemple donné au début du sujet, ce qui a laissé passer des erreurs d'inattention.

  3. 3
    Question 9 très mal réussieQ9

    La plupart des candidats ont oublié le cas d'égalité i = j ou ont tenté de n'utiliser qu'un seul élément de la matrice M alors qu'il en fallait deux.

  4. 4
    Représentation binaire mal maîtriséeQ11, Q12

    Le jury note que les candidats ne semblent pas à l'aise avec la représentation binaire des entiers et les opérations de décalage associées, ce qui pèse sur la partie III.

    « Les candidats ne semblent pas très à l'aise avec la représent ation binaire des entiers et les opérations de décalage associées »
  5. 5
    Complexité amortie quasiment jamais traitéeQ18

    La dernière question portait sur une complexité amortie et a obtenu une moyenne de 0,5 sur 20 avec 96% de copies à zéro.

Ce qui a été bien réussi

  • La question 4 a été correctement traitée par une très grande majorité de candidats.
  • La majorité des candidats a bien visualisé ce qu'est un parcours eulérien à la question 6.
  • Presque tous les candidats ont identifié le pire cas et sa complexité quadratique à la question 16.

Conseils du jury

  • Traiter le problème en entier est nécessaire pour obtenir la note maximale.
  • Une réponse longue doit être expliquée en détail, car un programme très long contient souvent un grand nombre d'erreurs.
  • Tester sa solution sur l'exemple fourni par l'énoncé avant de la considérer comme acquise.

Synthèse rédigée par WikiPrépa à partir du rapport officiel du jury (à télécharger en PDF). Les citations sont extraites du rapport.

Ces sujets peuvent vous intéresser

Lecture du sujet en ligne

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

COMPOSITION D'INFORMATIQUE

(Durée : 4 heures)
L'utilisation des calculatrices n'est pas autorisée pour cette épreuve.
Le langage de programmation choisi par le candidat doit être spécifié en tête de la copie.

Plus proche ancêtre commun

Ce problème s'intéresse à la question suivante : étant donné un arbre et deux nœuds dans cet arbre, quel est le plus proche ancêtre commun de ces deux nœuds ? Une solution efficace à ce problème a de nombreuses applications, notamment en bio-informatique. Ainsi, dans l'arbre

le plus proche ancêtre commun des nœuds 5 et 8 est le nœud 4 , et celui des nœuds 3 et 7 est la racine 0 . De manière générale, on se donne un arbre quelconque, sur lequel on s'autorise un prétraitement, puis on souhaite calculer le plus efficacement possible le plus proche ancêtre commun pour des couples de nœuds dans cet arbre.
Les arbres que l'on considère ici ont des nœuds étiquetés par des entiers distincts. Chaque nœud a un nombre fini de descendants immédiats, appelés ses fils. Un nœud sans descendant est appelé une feuille. Dans l'exemple ci-dessus, le noeud 4 a trois fils, à savoir 5, 6 et 7, et les feuilles sont 2, 3, 5, 6, 8 et 9 . Le sous-arbre du nœud i est défini récursivement de la manière suivante : c'est l'ensemble de nœuds contenant i et l'union de tous les sous-arbres des fils de i. On écrira simplement «sous-arbre i » pour désigner le sous-arbre du nœud i. Si un nœud j appartient au sous-arbre i, on dit que i est un ancêtre de j. En particulier, chaque nœud est son propre ancêtre.
Le plus proche ancêtre commun de deux nœuds i et j, noté PPAC (i, j), est défini formellement de la manière suivante :
  • si i est un ancêtre de j, alors PPAC(i, j) = i;
  • symétriquement, si j est un ancêtre de i, alors PPAC(i, j) = j;
  • sinon, PPAC(i, j) est l'unique nœud a possédant au moins deux fils distincts f_1 et f_2 tels que i appartient au sous-arbre f_1 et j au sous-arbre f_2.
Les arbres seront représentés en Caml et en Pascal de la manière suivante :
        (* Caml *)
type arbre =
| Noeud of
    int * arbre list;;
    { Pascal }
type arbre = `noeud;
liste_arbre = `element;
noeud = record n :integer; l :liste_arbre; end;
element = record a :arbre; r :liste_arbre; end;
Dans l'ensemble de ce problème, on se fixe une constante entière n , avec n ≥ 1, et un arbre A contenant n nœuds numérotés de 0 à n − 1. On suppose en outre que cette numérotation vérifie la propriété suivante :
Pour tout i, les nœuds du sous-arbre i portent des numéros consécutifs à partir de i. On note que la racine de A est donc nécessairement le nœud 0 .
La partie I propose une solution simple mais inefficace pour le problème du plus proche ancêtre commun. La partie II propose ensuite une solution plus efficace, avec un pré-traitement en O(nlogn) et une réponse en temps constant. Les parties III et IV étudient le cas particulier des arbres binaires complets. Enfin, la partie V étudie une application du calcul du plus proche ancêtre commun.
Les parties peuvent être traitées indépendamment. Mais attention, chaque partie utilise des notations et des fonctions introduites dans les parties précédentes. Les tableaux sont indexés à partir de 0 et la notation t [i] est utilisée dans les questions pour désigner l'élément d'indice i du tableau t , indépendamment du langage de programmation choisi. On appelle segment et on note t[i..j] le sous-tableau du tableau t défini en considérant les indices compris entre i et j au sens large.

I. Une solution simple

Dans cette partie, on considère une solution simple qui calcule le plus proche ancêtre commun en temps O(n).
Question 1 On suppose avoir déclaré un tableau global taille de taille n. Écrire une procédure remplir_taille qui stocke dans taille [i] le nombre de nœuds du sous-arbre i, pour tout i entre 0 et n − 1. On garantira une complexité O(n).
(* Caml *) remplir_taille : unit -> unit
{ Pascal } procedure remplir_taille;
Par la suite, on suppose avoir appelé cette procédure.
Question 2 Écrire une fonction appartient qui prend en arguments deux nœuds i et j et détermine, en temps constant, si i appartient au sous-arbre j.
(* Caml *) appartient : int -> int -> bool
{ Pascal } function appartient(i : integer ; j : integer) : boolean;
Question 3 Écrire une fonction ppac1 qui prend en arguments deux nœuds i et j et détermine PPAC(i, j) en temps O(n).
(* Caml *) ppac1 : int -> int -> int
{ Pascal } function ppac1 (i : integer ; j : integer) : integer;

II. Une solution plus efficace

Dans cette partie, on va effectuer un pré-traitement de l'arbre A qui permettra de déterminer ensuite en temps constant le plus proche ancêtre commun pour tout couple de nœuds.
On commence par construire une séquence de nœuds en effectuant un tour Eulérien de l'arbre A à partir de sa racine. D'une manière générale, le tour Eulérien à partir du nœud i est défini comme agissant sur une séquence résultat, construite de la manière suivante :
  1. ajouter le nœud i à la fin de la séquence résultat;
  2. pour chaque fils j de i,
    (a) effectuer un tour Eulérien à partir de j,
    (b) ajouter le nœud i à la fin de la séquence résultat.
Ainsi, sur l'exemple de l'arbre (1), on obtient la séquence suivante :
0, 1, 2, 1, 3, 1, 0, 4, 5, 4, 6, 4, 7, 8, 7, 9, 7, 4, 0
Question 4 Montrer que le tour Eulérien de A contient exactement 2 × n − 1 éléments. Par la suite, on appelle m cette valeur et on suppose avoir déclaré un tableau global euler de taille m destiné à contenir le résultat du tour Eulérien.
Question 5 On suppose avoir déclaré un tableau global index de taille n. Écrire une procédure remplir_euler qui remplit le tableau euler avec le résultat du tour Eulérien de A et, dans le même temps, le tableau index de telle sorte que euler[index[ i ]] = i pour tout i entre 0 et n − 1 (s'il existe plusieurs valeurs possibles pour index [i], on pourra choisir arbitrairement).
(* Caml *) remplir_euler : unit -> unit
{ Pascal } procedure remplir_euler;
Par la suite, on suppose avoir appelé cette procédure.
Question 6 Montrer que le plus proche ancêtre commun de i et j dans A est égal au plus petit élément du tableau euler compris entre les indices index [i] et index [j].
Question 7 Écrire une fonction log2 qui prend en argument un entier n, avec n ≥ 1, et calcule le plus grand entier k tel que 2^k ≤ n.
(* Caml *) log2 : int -> int
{ Pascal } function log2(n : integer) : integer;
Question 8 On pose k = log2(m). On suppose avoir déclaré une matrice globale M de taille m × (k + 1). Écrire une procédure remplir_M qui remplit la matrice M de telle sorte que, pour tout 0 ≤ j ≤ k et tout 0 ≤ i ≤ m − 2^j, on ait
M[i][j] = min_(i ≤ l < i + 2^j) euler [l]
On garantira une complexité O(nlogn), c'est-à-dire au plus proportionnelle à la taille de la matrice M.
(* Caml *) remplir_M : unit -> unit
{ Pascal } procedure remplir_M;
Par la suite, on suppose avoir appelé cette procédure.
Question 9 Écrire une fonction minimum qui prend en arguments deux entiers i et j, avec 0 ≤ i ≤ j < m, et qui détermine le plus petit élément du segment euler [i..j] en temps constant.
(* Caml *) minimum : int -> int -> int
{ Pascal } function minimum(i : integer ; j : integer) : integer;
Question 10 Écrire une fonction ppac2 qui prend en arguments deux nœuds i et j et qui détermine PPAC(i, j) en temps constant.
(* Caml *) ppac2 : int -> int -> int
{ Pascal } function ppac2(i : integer ; j : integer) : integer;

III. Opérations sur les bits des entiers primitifs

Dans cette partie, on introduit des notations et des fonctions qui seront utiles pour la partie IV. Il s'agit de fonctions opérant sur la représentation binaire des entiers primitifs (type int de Caml ou integer de Pascal). On ne considère ici que des entiers positifs ou nuls, s'écrivant en binaire sur un nombre de bits N dont la valeur est non précisée et non connue (mais inférieure ou égale à 30 ). Un entier x s'écrivant en binaire sur N bits est noté (x_(N − 1)…x_1 x_0)_2 où chaque bit x_i vaut 0 ou 1 . Les chiffres les moins significatifs sont écrits à droite, de sorte que la valeur de x est donc ∑_(i = 0)^(N − 1)x_i 2^i.
On suppose que le langage fournit les trois opérations et_bits, ou_bits et ou_excl_bits calculant respectivement les opérations ET, OU et OU-exclusif sur les représentations binaires de deux entiers, en temps constant. Plus précisément, si x = (x_(N − 1)…x_1 x_0)_2 et y = (y_(N − 1)…y_1 y_0)_2, alors et_bits (x, y) est l'entier z = (z_(N − 1)…z_1 z_0)_2 défini par z_i = ET(x_i, y_i) pour tout i; de même pour les opérations ou_bits et ou_excl_bits. On rappelle que les opérations ET, OU et OU-exclusif sont définies par les tables de vérité suivantes :
ET
0 1
0 0 0
1 0 1
OU
0 1
0 0 1
1 1 1
OU-exclusif
0 1
0 0 1
1 1 0
On suppose que le langage fournit également deux opérations decalage_gauche et decalage_droite décalant respectivement les bits d'un entier vers la gauche et vers la droite, en insérant des bits 0 respectivement à droite et à gauche. Plus précisément, si x = (x_(N − 1)…x_1 x_0)_2 et si 0 ≤ k ≤ N alors
decalage_g auche (x, k) = (x_(N − 1 − k)…x_1 x_0 0…0_()_k)_2; decalage_d roite (x, k) = (0…0_()_k x_(N − 1)…x_(k + 1)x_k)_2
Question 11 Écrire une fonction bit_fort qui prend en argument un entier x = (x_(N − 1)…x_1 x_0)_2, supposé non nul, et détermine le plus grand indice i tel que x_i = 1, c'est-à-dire la position du bit le plus significatif de x. On garantira une complexité O(N).
(* Caml *) bit_fort : int -> int
{ Pascal } function bit_fort(n : integer) : integer;
Question 12 Pour plus d'efficacité, on peut pré-calculer et ranger dans un tableau les valeurs de bit_fort pour les 256 premiers entiers. Écrire une nouvelle version de bit_fort qui exploite cette idée pour n'effectuer pas plus de deux décalages. (On rappelle qu'on a supposé N ≤ 30.)

IV. Cas particulier d'un arbre binaire complet

Dans cette partie, on considère le problème du plus proche ancêtre commun dans le cas particulier où l'arbre A est un arbre binaire complet, c'est-à-dire que tout nœud de A a exactement zéro ou deux fils et toutes les feuilles de A sont à la même distance de la racine. (On continue cependant d'utiliser le même type arbre.) Voici un exemple d'arbre binaire complet de hauteur 2 :
D'une manière générale, si on note d la hauteur de A, alors A possède 2^d feuilles et n = 2^(d + 1) − 1 nœuds au total. La hauteur d'un nœud dans l'arbre A est définie comme sa distance aux feuilles. Dans l'exemple ci-dessus, les feuilles 2, 3, 5, 6 ont pour hauteur 0 , les nœuds 1 et 4 ont pour hauteur 1 et la racine 0 a pour hauteur 2 .
L'idée consiste alors à associer à chaque nœud i un entier B(i) dont la représentation en binaire sur d + 1 bits encode sa position dans l'arbre. On procède ainsi : le chemin qui mène de la racine au nœud est représenté par une suite de 0 et de 1 , avec la convention que 0 représente un déplacement vers le sous-arbre gauche et 1 un déplacement vers le sous-arbre droit. Pour un nœud de hauteur h, on obtient donc un chemin de longueur d − h, soit x_d…x_(h + 1). À ce chemin, on concatène alors à droite la représentation binaire de 2^h, c'est-à-dire un bit 1 suivi de h bits 0 . Au final, on a donc pour tout nœud i de hauteur h dans A :
B(i) = (x_d…x_(h + 1)_()_(chemin de 0 à i)10…0_()_h)_2
On note que le chemin est vide pour la racine et que le suffixe de zéros est vide pour les feuilles. Pour l'arbre ci-dessus, on obtient les valeurs suivantes de B(i), données en binaire :
On note que les B(i) prennent toutes les valeurs entre 1 et n , et qu'il y a donc bijection entre les nœuds et les valeurs que leur associe la fonction B.
Question 13 On suppose avoir déclaré deux tableaux globaux, B de taille n et Binv de taille n + 1. Écrire une procédure remplir_B qui remplit le tableau B avec les valeurs de B et le tableau Binv avec les valeurs de B^(− 1) (l'élément d'indice 0 de Binv étant inutilisé). On garantira une complexité O(n).
(* Caml *) remplir_B : unit -> unit
{ Pascal } procedure remplir_B;
Par la suite, on suppose avoir appelé cette procédure.
Question 14 Soient i et j deux nœuds de A tels que i n'est pas un ancêtre de j et j n'est pas un ancêtre de i. Soit x le résultat de ou_excl_bits (B(i), B(j)), puis k le résultat de bit_fort (x). Que représente k ? Comment en déduire la valeur de B(a), où a est le plus proche ancêtre commun de i et j dans A ?
Question 15 Déduire de la question précédente une fonction ppac3 qui prend en arguments deux nœuds i et j et qui détermine PPAC(i, j) en temps constant. On prendra soin de traiter différemment le cas où i est un ancêtre de j ou j un ancêtre de i; on pourra réutiliser à cet effet la fonction appartient de la question .
(* Caml *) ppac3 : int -> int -> int
{ Pascal } function ppac3(i : integer ; j : integer) : integer;

V. Application

Dans cette partie, on considère une application du calcul du plus proche ancêtre commun au problème suivant : étant donné un tableau T de n entiers, on souhaite calculer, pour des paires d'indices i et j tels que 0 ≤ i ≤ j < n, l'élément minimum du segment T[i..j]. Comme pour le calcul du plus proche ancêtre commun, on s'autorise un pré-traitement du tableau T permettant ensuite de répondre en temps constant pour tout segment. L'idée est de construire un arbre A contenant les n éléments de T et de se ramener au problème du plus proche ancêtre commun.
D'une manière générale, on définit un arbre binaire à partir d'un segment non vide T[i..j] en procédant récursivement de la manière suivante :
  1. si i = j, l'arbre est réduit à la feuille T[i];
  2. sinon, sa racine est le plus petit élément de T[i..j]; soit T[m] cet élément, avec i ≤ m ≤ j (s'il y a plusieurs valeurs possibles de m, on choisit arbitrairement). On distingue alors trois cas :
    (a) si m = i, on construit un unique sous-arbre à partir de T[m + 1..j],
    (b) si m = j, on construit un unique sous-arbre à partir de T[i..m − 1],
    (c) sinon, on construit deux sous-arbres, respectivement à partir de T[i..m − 1] et de T[m + 1..j].
    L'arbre A est alors défini en partant du segment complet T[0..n − 1]. On note que les nœuds de A ont directement pour valeurs les éléments de T, i.e. on ne se soucie pas ici de numéroter les nœuds de A de 0 à n − 1 afin de vérifier la propriété ( P ).
On admet le résultat suivant : le plus proche ancêtre commun des nœuds correspondant à i et j dans A est égal à l'élément minimum du segment T[i..j].
Question 16 Donner la complexité dans le pire des cas de l'algorithme récursif ci-dessus construisant A à partir de T, en fonction de n (en supposant le minimum calculé en temps linéaire).
On propose un algorithme plus efficace pour construire l'arbre A. Cet algorithme construit incrémentalement l'arbre correspondant aux éléments de T[0..i], pour i allant de 0 à n − 1. Initialement, l'arbre est réduit à l'unique nœud T[0]. Supposons avoir déjà construit l'arbre correspondant aux i premiers éléments de T , c'est-à-dire au segment T[0..i − 1]. Cet arbre a la forme suivante

avec une «branche droite» formée de k nœuds tels que v_1 ≤ v_2 ≤ … ≤ v_(k − 1) ≤ v_k. Chaque arbre t_i est éventuellement vide et ne contient que des éléments strictement plus grands que v_i. Pour insérer l'élément suivant, soit v = T[i], on cherche la position de v dans la liste v_1, v_2, …, v_k, c'est-à-dire le plus grand j tel que v_j ≤ v < v_(j + 1) (si v < v_1 on pose j = 0 et si v_k ≤ v on pose j = k ). On crée alors un nouveau noeud v que l'on insère à la place de v_(j + 1) et le sous-arbre v_(j + 1) est accroché sous le nœud v. On obtient le nouvel arbre

dont la nouvelle branche droite est donc v_1, …, v_j, v. Si j = k, alors on ajoute v comme fils droit de v_k, et la nouvelle branche droite est simplement l'ancienne étendue par v (à la fin). On ne demande pas de montrer la correction de cet algorithme.
Question 17 Écrire une fonction construire_A qui construit l'arbre A à partir du tableau T en suivant ce nouvel algorithme.
(* Caml *) construire_A : int array -> arbre
{ Pascal } function construire_A(t : array[0..n-1] of integer) : arbre;
Indication : on pourra représenter la branche droite par une liste d'arbre (du type arbre list en Caml et liste_arbre en Pascal), dans l'ordre inverse, c'est-à-dire v_k, …, v_1. Chacun de ces arbres a une racine v_i et au plus un fils t_i.
Question 18 Montrer que la complexité de cet algorithme est O(n).
Note : En 1984, Harel et Tarjan ont montré qu'il existe un pré-traitement de complexité linéaire sur un arbre qui permet d'obtenir ensuite le plus proche ancêtre commun en temps constant. On a donc le même résultat pour le problème du minimum du segment.

Questions fréquentes

4 questions
Sur quels chapitres porte l'épreuve d'informatique MP option info de l'X 2010 ?
Afficher ou masquer la section

Sur quels chapitres porte l'épreuve d'informatique MP option info de l'X 2010 ?

Le sujet porte sur les arbres, le calcul du plus proche ancêtre commun, la programmation dynamique et la représentation binaire des entiers.

Quelles erreurs le jury a-t-il le plus relevées ?

Une hypothèse abusive sur la numérotation des nœuds, l'absence de test sur l'exemple fourni, et une faiblesse générale sur la représentation binaire et les décalages.

Quelle est la moyenne à cette épreuve d'informatique de l'X en 2010 ?

La moyenne est de 9,37 sur 20 avec un écart-type de 4,24, pour 762 copies.

Ce sujet d'informatique X MP 2010 est-il difficile ?

Plusieurs questions ont des moyennes très basses et des taux de zéro élevés, notamment la dernière question sur la complexité amortie, ce qui indique un sujet exigeant.

Pas de description pour le moment