Algorithmiques élémentaires & pensée computationnelle · L1 · Section 1/6
Décomposition de problèmes
Progression
#Décomposition de problèmes
Découper un problème complexe en sous-problèmes simples, puis définir pour chacun une interface nette et des invariants vérifiables.
#Prérequis
Savoir lire un programme Python élémentaire (boucle, dictionnaire, comparaison). Le module programmation Python couvre ces bases.
#Objectifs d'apprentissage
- Découper un objectif en sous-problèmes ordonnés et indépendants.
- Écrire pour chaque sous-problème un contrat : entrée, sortie, garantie.
- Formaliser une décomposition récursive en définition inductive (base, règles) et vérifier qu'elle est non ambiguë.
- Énoncer un invariant de boucle et vérifier qu'il tient à chaque itération.
- Reconnaître quand une décomposition est terminée : chaque brique est testable isolément.
#Le fil rouge : statistiques de mots
Problème : à partir d'un texte, produire la liste des mots classés par fréquence décroissante. La décomposition naturelle donne cinq sous-problèmes : lire, nettoyer, découper, compter, trier. Chaque étape est volontairement écrite « à la main » pour se concentrer sur la logique plutôt que sur des fonctions toutes faites.
#Étape 1 : deux briques, un pipeline
Sortie exacte :
1[('bonjour', 2), ('le', 1), ('monde', 1)]#Étape 2 : la version décomposée
Le même comportement, mais chaque responsabilité a sa fonction et son contrat explicite :
Sorties exactes :
1['les', 'tudiants', 'posent', 'des', 'questions', 'posent', 'encore', 'des', 'questions']2[('des', 2), ('posent', 2), ('questions', 2), ('encore', 1), ('les', 1), ('tudiants', 1)]#Le contrat de chaque brique
Une bonne décomposition rend chaque brique remplaçable et testable. Voici les contrats de la version décomposée :
| Fonction | Entrée | Sortie | Garantie |
|---|---|---|---|
filtrer_caracteres | un texte quelconque | texte ne contenant que a-z, 0-9 et espaces | tout caractère non conservé devient un espace |
separer_mots | un texte quelconque | liste de mots | chaque mot ne contient que des caractères autorisés |
compter_occurrences | liste de mots | dictionnaire mot vers effectif | chaque clé apparaît une seule fois avec son vrai effectif |
trier_compteurs | dictionnaire mot vers effectif | liste de paires | triée par effectif décroissant, puis alphabétique à égalité |
Ces contrats sont ce qui permet de remplacer le tri par sélection par sorted(tableau, key=lambda p: (-p[1], p[0])) sans toucher au reste : le consommateur de la sortie ne voit aucune différence.
#Invariants de boucle
Un invariant est une propriété qui reste vraie à chaque itération d'une boucle. C'est l'outil qui prouve qu'un algorithme fait ce qu'il prétend.
Dans le tri par sélection ci-dessus, l'invariant est : au début de l'itération i, la tranche tableau[:i] contient les i plus grandes paires du dictionnaire, dans l'ordre final.
- Avant la première itération (
i = 0) : la tranche est vide, la propriété tient trivialement. - Si elle tient à l'itération
i: la boucle interne cherche le maximum detableau[i:]selon le critère et l'amène en positioni, donc elle tient encore à l'itérationi + 1. - À la sortie (
i = len(tableau)) : la tranche est le tableau entier, trié selon le critère voulu.
Le même raisonnement s'applique au comptage : après traitement des k premiers mots, compteurs[mot] vaut le nombre d'occurrences de mot parmi ces k mots.
#Définition inductive : la décomposition formalisée
Décomposer un problème récursif, c'est écrire une définition inductive. Le cours en donne la forme générale : se donner un sous-ensemble fini (la base) et un ensemble fini d'opérations d'arité ; l'ensemble cherché est alors le plus petit ensemble vérifiant
Deux exemples canoniques :
- sur : et « si alors » définit l'ensemble des entiers pairs — le plus petit ensemble contenant 0 et clos par ajout de 2 ;
- sur l'alphabet : et « si alors et » définit le langage de Dyck, l'ensemble des mots bien parenthésés.
Le mot « plus petit » n'est pas décoratif : sans lui, tout entier vérifierait la première définition. Une décomposition correcte est donc une base plus des règles de construction, et rien d'autre.
#L'ambiguïté, ou comment une décomposition correcte compte faux
Une définition inductive est non ambiguë lorsque chaque élément admet une et une seule construction. Cette propriété n'est pas un luxe théorique : elle décide si la récurrence de comptage qu'on en déduit est juste.
Le cours donne l'exemple des « additions booléennes » sur l'alphabet , avec la base , et la règle « si alors ». Cette définition est ambiguë : le mot se construit de deux façons, en groupant à gauche ou à droite. La récurrence de comptage qu'on croit lire dans la définition,
est alors fausse, parce qu'elle compte plusieurs fois les mêmes mots.
La définition corrigée est non ambiguë :
Elle donne immédiatement , , puis pour : une suite géométrique, pour . Le même ensemble, deux définitions, deux récurrences — une seule correcte. La leçon est nette : quand une décomposition admet plusieurs lectures d'un même objet, elle est à revoir avant d'être comptée.
#La décomposition détermine la récurrence
Le lien entre décomposition et coût est direct. Considérons l'ensemble des mots sur sans deux 0 consécutifs, défini par , , et « si alors et ». Cette définition est non ambiguë, et elle se lit comme un algorithme de construction : pour obtenir un mot de longueur , soit on ajoute un 1 à la fin d'un mot de longueur , soit on ajoute 10 à la fin d'un mot de longueur . Le nombre de ces mots vérifie donc
c'est-à-dire : la suite de Fibonacci. Autrement dit, la décomposition choisie détermine l'équation de récurrence, donc le coût de l'algorithme récursif qui la suit. Changer de décomposition, c'est changer de coût — et c'est exactement le geste de conception que décrit ce chapitre.
#Stratégies de décomposition
#Étapes
- Identifier le problème global : reformuler ce qui est demandé en une phrase avec entrée et sortie.
- Diviser en sous-problèmes : chercher des étapes qui transforment les données de proche en proche.
- Définir les interfaces : pour chaque étape, entrée, sortie et garantie.
- Résoudre les sous-problèmes : implémenter chaque brique indépendamment.
- Intégrer : assembler les briques et vérifier le comportement global sur des exemples.
#Avantages
- Simplicité : chaque sous-problème tient dans la tête.
- Réutilisabilité :
compter_occurrencessert pour tout comptage, pas seulement les mots. - Testabilité : chaque brique se teste avec ses propres cas limites (texte vide, mot unique, égalités parfaites).
- Collaboration : plusieurs personnes travaillent sur des briques distinctes dès que les contrats sont fixés.
#Quand s'arrêter ?
Une décomposition est terminée quand chaque brique restante est triviale à écrire et à tester, et que ses entrées et sorties sont simples à décrire. Découper plus finement ajoute de l'indirection sans gain : une fonction incrementer_compteur(compteurs, mot) d'une ligne sous compter_occurrences n'apporterait rien.
#Exercices
- Décomposez « vérifier si une phrase est un palindrome » (en ignorant ponctuation, espaces et casse) en trois briques, et écrivez le contrat de chacune.
- Énoncez l'invariant de boucle de
compter_occurrences, puis montrez qu'il tient encore après avoir traité le mot suivant. - Remplacez le tri par sélection de l'étape 2 par un appel à
sortedavec une clé adaptée, et vérifiez que la sortie est identique sur les deux exemples du chapitre.
#Éléments de correction
- Trois briques :
normaliser(texte)(entrée : texte quelconque; sortie : uniquement des lettres minuscules; garantie : l'ordre des lettres conservées est inchangé),inverser(s)(entrée : chaîne; sortie : chaîne inversée; garantie :inverser(inverser(s)) == s),est_palindrome(texte)(entrée : texte quelconque; sortie : booléen; garantie : vrai si et seulement si la version normalisée est égale à son inverse). En Python l'inversion se fait par tranches[::-1]. - Invariant : après les
kpremiers mots traités, chaque clé decompteursest un mot déjà vu et sa valeur est exactement son nombre d'apparitions parmi ceskmots. Le motk+1est soit nouveau (on crée la clé à 1, l'invariant passe àk+1mots), soit déjà présent (on incrémente, la valeur redevient exacte). Dans les deux cas l'invariant est rétabli pourk+1. sorted(compteurs.items(), key=lambda p: (-p[1], p[0])): la fréquence décroissante s'obtient en niant le compte, l'ordre alphabétique croissant reste la clé secondaire. Sur les deux exemples du chapitre, la sortie est identique à celle du tri par sélection, caractère pour caractère.