Exercices : Récursivité et complexité
Cette série d'exercices porte sur la récursivité et l'analyse de complexité des algorithmes. Vous serez amené à écrire des fonctions récursives pour résoudre divers problèmes et à estimer leur complexité temporelle.
Vérification de tableau trié
Un tableau X est trié par ordre croissant si x(i) ≤ x(i+1) pour tout i.
- Élaborer un programme récursif permettant de vérifier qu'un tableau X est trié ou non.
- Estimer sa complexité.
Un tableau de taille 0 ou 1 est toujours trié. Pour un tableau de taille n, il est trié si le premier élément est ≤ au second ET si le reste du tableau (à partir de l'indice 1) est trié.
FONCTION est_trie(T, n):
SI n ≤ 1 ALORS
RETOURNER Vrai
SINON
SI T[0] > T[1] ALORS
RETOURNER Faux
SINON
RETOURNER est_trie(T[1:], n-1)
FINSI
FINSIdef est_trie(T):
"""
Vérifie récursivement si un tableau est trié par ordre croissant.
"""
if len(T) <= 1:
return True
if T[0] > T[1]:
return False
return est_trie(T[1:])
# Version avec indices pour éviter les copies
def est_trie_indices(T, debut=0):
"""
Version optimisée utilisant des indices pour éviter les copies.
"""
if debut >= len(T) - 1:
return True
if T[debut] > T[debut + 1]:
return False
return est_trie_indices(T, debut + 1)
# Tests
T1 = [1, 2, 3, 4, 5]
T2 = [1, 3, 2, 4, 5]
print(f"{T1} est trié ? {est_trie(T1)}")
print(f"{T2} est trié ? {est_trie(T2)}")
print(f"{T1} est trié (indices) ? {est_trie_indices(T1)}")
print(f"{T2} est trié (indices) ? {est_trie_indices(T2)}")La fonction effectue une comparaison par appel récursif, et il y a au plus n appels. La complexité temporelle est donc O(n).
La version avec indices évite les copies de tableaux et a également une complexité O(n) mais avec un meilleur facteur constant.
[1, 2, 3, 4, 5] est trié ? True [1, 3, 2, 4, 5] est trié ? False
Conversion en binaire
Pour convertir un nombre entier positif N de la base décimale à la base binaire, il faut opérer par des divisions successives du nombre N par 2. Les restes des divisions constituent la représentation binaire.

- Écrire une fonction récursive « Binaire » permettant d'imprimer à l'écran la représentation binaire d'un nombre N.
- Donner la formule récurrente exprimant sa complexité en nombre de divisions. Estimer cette complexité.
La conversion en binaire s'obtient par divisions successives par 2. Le dernier quotient est le bit de poids fort, et les restes successifs sont les bits de poids faible au fort. On peut utiliser la récursivité pour imprimer d'abord les bits de poids fort (appel récursif) puis le reste.
def binaire(N):
"""
Affiche la représentation binaire de N.
"""
if N > 1:
binaire(N // 2)
print(N % 2, end='')
def binaire_chaine(N):
"""
Retourne la représentation binaire sous forme de chaîne.
"""
if N == 0:
return '0'
if N == 1:
return '1'
return binaire_chaine(N // 2) + str(N % 2)
# Tests
print("Binaire de 13 : ", end='')
binaire(13)
print()
print(f"Binaire de 42 : {binaire_chaine(42)}")
print(f"Binaire de 255 : {binaire_chaine(255)}")Le nombre de divisions nécessaires est égal au nombre de bits de N, soit environ ⌊log₂(N)⌋ + 1.
La relation de récurrence est : C(N) = C(N//2) + 1, avec C(1) = 1. En développant, on obtient C(N) = O(log N).
La complexité temporelle est donc O(log N).
Binaire de 13 : 1101 Binaire de 42 : 101010 Binaire de 255 : 11111111
Suite de Fibonacci
La suite de Fibonacci est définie par :

- Écrire un programme récursif calculant Fib(n).
- Déterminer sa complexité.
def fib_rec(n):
"""
Version récursive naïve de Fibonacci.
"""
if n <= 1:
return n
return fib_rec(n-1) + fib_rec(n-2)
def fib_memo(n, memo=None):
"""
Version récursive avec mémoïsation.
"""
if memo is None:
memo = {}
if n <= 1:
return n
if n not in memo:
memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo)
return memo[n]
from functools import lru_cache
@lru_cache(maxsize=None)
def fib_cache(n):
"""
Version avec décorateur lru_cache.
"""
if n <= 1:
return n
return fib_cache(n-1) + fib_cache(n-2)
# Tests
print("Fib(10) (récursif) =", fib_rec(10))
print("Fib(40) (mémoïsation) =", fib_memo(40))
print("Fib(100) (cache) =", fib_cache(100))Version récursive naïve : La relation de récurrence est C(n) = C(n-1) + C(n-2) + 1, avec C(0) = C(1) = 1. La solution de cette récurrence est exponentielle : O(φⁿ) où φ ≈ 1.618.
Version avec mémoïsation : Chaque valeur n'est calculée qu'une fois, donc la complexité est O(n) en temps et O(n) en espace.
Fib(10) (récursif) = 55 Fib(40) (mémoïsation) = 102334155 Fib(100) (cache) = 354224848179261915075
Suite définie par récurrence
Soit la suite définie par :

- Écrire un programme récursif permettant de calculer le n-ième terme de la suite.
- Estimer sa complexité.
La suite est définie par : U₁ = 1, U₂ = 1, Uₙ = 3Uₙ₋₁ + Uₙ₋₂ pour n ≥ 3.
def suite(n):
"""
Version récursive naïve.
"""
if n <= 2:
return 1
return 3 * suite(n-1) + suite(n-2)
def suite_memo(n, memo=None):
"""
Version avec mémoïsation.
"""
if memo is None:
memo = {}
if n <= 2:
return 1
if n not in memo:
memo[n] = 3 * suite_memo(n-1, memo) + suite_memo(n-2, memo)
return memo[n]
# Tests
print("U₅ (récursif) =", suite(5))
print("U₁₀ (mémoïsation) =", suite_memo(10))
print("U₂₀ (mémoïsation) =", suite_memo(20))Version récursive naïve : La relation de récurrence est similaire à Fibonacci, avec une complexité exponentielle O(φⁿ).
Version avec mémoïsation : Chaque terme est calculé une seule fois, donc O(n) en temps et O(n) en espace.
U₅ (récursif) = 43 U₁₀ (mémoïsation) = 38809 U₂₀ (mémoïsation) = 370248451
Récursivité mutuelle : Pair / Impair
Un nombre N est pair si (N-1) est impair, et un nombre N est impair si (N-1) est pair.
- Écrire deux fonctions récursives mutuelles pair(N) et impair(N) permettant de savoir si un nombre N est pair et si un nombre N est impair.
- Estimer la complexité de la fonction Pair(N) en nombre d'appels récursifs.
Les deux fonctions s'appellent mutuellement jusqu'à atteindre le cas de base.
def pair(N):
"""
Détermine si N est pair par récursivité mutuelle.
"""
if N == 0:
return True
return impair(N-1)
def impair(N):
"""
Détermine si N est impair par récursivité mutuelle.
"""
if N == 0:
return False
return pair(N-1)
# Tests
for i in range(10):
print(f"{i} est pair ? {pair(i)} - impair ? {impair(i)}")Chaque appel récursif décrémente N de 1. Il y a donc exactement N appels pour atteindre le cas de base. La complexité est O(N) en nombre d'appels.
Cette approche est purement pédagogique car il existe des méthodes plus efficaces (opérateur modulo) mais elle illustre bien le concept de récursivité mutuelle.
0 est pair ? True - impair ? False 1 est pair ? False - impair ? True 2 est pair ? True - impair ? False 3 est pair ? False - impair ? True 4 est pair ? True - impair ? False 5 est pair ? False - impair ? True 6 est pair ? True - impair ? False 7 est pair ? False - impair ? True 8 est pair ? True - impair ? False 9 est pair ? False - impair ? True
Maximum d'un tableau (diviser pour régner)
Étant donné un tableau X composé de N éléments entiers. On voudrait déterminer son maximum par un programme récursif basé sur le paradigme « diviser pour régner » :
- En considérant que le maximum est le plus grand entre le dernier terme et le maximum des (n-1) premiers termes. Estimer sa complexité.
- En considérant que le maximum est le plus grand entre les maximums des deux moitiés du tableau. Estimer sa complexité.
Le maximum est le plus grand entre le dernier élément et le maximum des n-1 premiers.
def max_rec1(T, n):
"""
Maximum récursif : max(T[0..n-1]) = max(T[n-1], max(T[0..n-2]))
Complexité : O(n)
"""
if n == 1:
return T[0]
max_prec = max_rec1(T, n-1)
return max_prec if max_prec > T[n-1] else T[n-1]def max_rec2(T, debut, fin):
"""
Maximum par diviser pour régner : on divise le tableau en deux moitiés.
Complexité : O(n log n) ? Non, c'est O(n) car chaque élément est visité une fois.
La relation de récurrence est T(n) = 2T(n/2) + 1, ce qui donne O(n).
"""
if debut == fin:
return T[debut]
milieu = (debut + fin) // 2
max_gauche = max_rec2(T, debut, milieu)
max_droit = max_rec2(T, milieu + 1, fin)
return max_gauche if max_gauche > max_droit else max_droit
# Test
T = [3, 7, 2, 9, 1, 8, 5, 4]
print(f"Tableau : {T}")
print(f"Version 1 : {max_rec1(T, len(T))}")
print(f"Version 2 : {max_rec2(T, 0, len(T)-1)}")Version 1 : Relation de récurrence T(n) = T(n-1) + 1, avec T(1) = 1. En développant, T(n) = n. Donc O(n).
Version 2 : Relation de récurrence T(n) = 2T(n/2) + 1, avec T(1) = 1. Par le master theorem, T(n) = O(n). En effet, chaque élément est visité une fois dans l'arbre de récursion.
Les deux versions ont une complexité linéaire, mais la version 2 a l'avantage de mieux se paralléliser.
Tableau : [3, 7, 2, 9, 1, 8, 5, 4] Version 1 : 9 Version 2 : 9
Plus longue coupe de 1
Soit un tableau X composé de N entiers pouvant être 0 ou 1. Une coupe (i,j) de X est le sous-tableau commençant à i et finissant à j. On voudrait déterminer la plus longue coupe ne contenant que des 1.

Exemple : dans le tableau ci-dessus, la coupe (7,14) est une coupe de 1.
- Écrire une fonction « PlusLongueCoupe » récursive basée sur le paradigme « diviser pour régner » qui détermine la plus longue coupe ne contenant que des 1 d'un tableau X.
- Estimer sa complexité moyenne en nombre de comparaisons des éléments du tableau.
On divise le tableau en deux moitiés. La plus longue séquence de 1 peut être :
- Entièrement dans la moitié gauche
- Entièrement dans la moitié droite
- À cheval sur les deux moitiés (séquence qui commence dans la gauche et finit dans la droite)
Pour la séquence à cheval, on cherche la séquence de 1 la plus longue se terminant à la fin de la moitié gauche, et la séquence de 1 la plus longue commençant au début de la moitié droite.
def plus_longue_coupe(T, debut, fin):
"""
Retourne la longueur de la plus longue séquence de 1 consécutifs
dans T[debut:fin+1] (approche diviser pour régner).
"""
if debut == fin:
return 1 if T[debut] == 1 else 0
milieu = (debut + fin) // 2
# Cas 1 et 2 : séquence dans une moitié
gauche = plus_longue_coupe(T, debut, milieu)
droit = plus_longue_coupe(T, milieu + 1, fin)
# Cas 3 : séquence à cheval
# Longueur de la séquence de 1 se terminant à la fin de la moitié gauche
longueur_gauche = 0
i = milieu
while i >= debut and T[i] == 1:
longueur_gauche += 1
i -= 1
# Longueur de la séquence de 1 commençant au début de la moitié droite
longueur_droit = 0
j = milieu + 1
while j <= fin and T[j] == 1:
longueur_droit += 1
j += 1
cheval = longueur_gauche + longueur_droit
return max(gauche, droit, cheval)
# Version alternative avec retour de plusieurs informations
def plus_longue_coupe_complet(T, debut, fin):
"""
Retourne un triplet (max, prefixe, suffixe) où :
- max : longueur max de 1 consécutifs dans l'intervalle
- prefixe : longueur du préfixe de 1
- suffixe : longueur du suffixe de 1
"""
if debut == fin:
if T[debut] == 1:
return (1, 1, 1)
else:
return (0, 0, 0)
milieu = (debut + fin) // 2
g_max, g_pref, g_suff = plus_longue_coupe_complet(T, debut, milieu)
d_max, d_pref, d_suff = plus_longue_coupe_complet(T, milieu + 1, fin)
# Calcul du nouveau préfixe
if g_pref == milieu - debut + 1: # toute la gauche est remplie de 1
pref = g_pref + d_pref
else:
pref = g_pref
# Calcul du nouveau suffixe
if d_suff == fin - milieu: # toute la droite est remplie de 1
suff = d_suff + g_suff
else:
suff = d_suff
# Séquence à cheval
cheval = g_suff + d_pref
# Maximum global
maxi = max(g_max, d_max, cheval)
return (maxi, pref, suff)
# Test
T = [0, 1, 1, 1, 0, 1, 1, 0, 1, 1, 1, 1, 0, 1]
print(f"Tableau : {T}")
print(f"Longueur max (version simple) : {plus_longue_coupe(T, 0, len(T)-1)}")
maxi, pref, suff = plus_longue_coupe_complet(T, 0, len(T)-1)
print(f"Longueur max (version complète) : {maxi}")La version simple peut avoir une complexité O(n²) dans le pire cas à cause des boucles while qui peuvent parcourir tout le tableau pour chaque appel récursif.
La version complète qui retourne également les préfixes et suffixes a une complexité O(n log n) si elle est bien implémentée, car chaque niveau de récursion traite chaque élément un nombre constant de fois.
Une solution optimale pour ce problème est itérative et se fait en O(n) en parcourant simplement le tableau et en comptant les séquences de 1.
Tableau : [0, 1, 1, 1, 0, 1, 1, 0, 1, 1, 1, 1, 0, 1] Longueur max (version simple) : 4 Longueur max (version complète) : 4
Multiplication par additions successives
On voudrait écrire une fonction « multiplier » permettant de multiplier deux entiers positifs ou nuls a et b en utilisant uniquement des opérations d'addition. Le prototype de la fonction doit être « def multiplier(a, b) ».
- Proposer une version récursive de la fonction « multiplier ». Estimer sa complexité en nombre d'additions après avoir déterminé une formule récurrente de la complexité.
- Proposer une autre version récursive de la fonction « multiplier » basée sur le paradigme « diviser pour régner ». Donner une formule récurrente de sa complexité en nombre d'additions et l'estimer.
def multiplier_naif(a, b):
"""
Multiplication par additions successives.
a * b = a + a + ... + a (b fois)
Complexité : O(b) additions
"""
if b == 0:
return 0
return a + multiplier_naif(a, b-1)
def multiplier_opt(a, b):
"""
Version optimisée : on prend le plus petit des deux comme compteur.
Complexité : O(min(a,b)) additions
"""
if a == 0 or b == 0:
return 0
if a < b:
return multiplier_opt(b, a) # inversion pour minimiser les appels
return a + multiplier_opt(a, b-1)
# Test
print(f"5 × 3 = {multiplier_naif(5, 3)}")
print(f"100 × 2 = {multiplier_naif(100, 2)}")
print(f"3 × 5 (opt) = {multiplier_opt(3, 5)}")def multiplier_diviser(a, b):
"""
Multiplication par diviser pour régner utilisant la propriété :
a * b = 2 * (a * (b//2)) si b est pair
a * b = a + 2 * (a * (b//2)) si b est impair
Complexité : O(log b) additions (chaque appel réduit b de moitié)
"""
if b == 0:
return 0
if b % 2 == 0:
# b pair : a * b = 2 * (a * (b//2))
moitie = multiplier_diviser(a, b // 2)
return moitie + moitie
else:
# b impair : a * b = a + 2 * (a * (b//2))
moitie = multiplier_diviser(a, b // 2)
return a + moitie + moitie
# Version avec optimisation (prendre le plus petit comme diviseur)
def multiplier_diviser_opt(a, b):
"""
Version optimisée : on prend le plus petit des deux comme diviseur.
Complexité : O(log min(a,b)) additions
"""
if a < b:
return multiplier_diviser_opt(b, a)
if b == 0:
return 0
if b % 2 == 0:
moitie = multiplier_diviser_opt(a, b // 2)
return moitie + moitie
else:
moitie = multiplier_diviser_opt(a, b // 2)
return a + moitie + moitie
# Test
print(f"5 × 3 (diviser) = {multiplier_diviser(5, 3)}")
print(f"100 × 100 (diviser) = {multiplier_diviser(100, 100)}")
print(f"123 × 456 (diviser opt) = {multiplier_diviser_opt(123, 456)}")Version naïve : Relation de récurrence C(b) = C(b-1) + 1, avec C(0) = 0. Donc C(b) = b additions. Complexité O(b).
Version diviser pour régner : Relation de récurrence C(b) = C(b//2) + 1 (en ignorant les additions supplémentaires qui sont constantes). En développant, C(b) = O(log b). Chaque appel récursif réduit b de moitié.
La version diviser pour régner est donc exponentiellement plus rapide : O(log b) au lieu de O(b).
5 × 3 = 15 100 × 2 = 200 3 × 5 (opt) = 15 5 × 3 (diviser) = 15 100 × 100 (diviser) = 10000 123 × 456 (diviser opt) = 56088
Récapitulatif des complexités
| Exercice | Algorithme | Complexité |
|---|---|---|
| 1 - Tableau trié | Vérification récursive | O(n) |
| 2 - Binaire | Divisions successives | O(log n) |
| 3 - Fibonacci | Récursif naïf / mémoïsation | O(φⁿ) / O(n) |
| 4 - Suite définie | Récursif naïf / mémoïsation | O(φⁿ) / O(n) |
| 5 - Pair/Impair | Récursivité mutuelle | O(n) |
| 6 - Maximum | Diviser pour régner | O(n) |
| 7 - Plus longue coupe | Diviser pour régner | O(n log n) |
| 8 - Multiplication | Naïve / Diviser | O(b) / O(log b) |
- La récursivitée permet d'exprimer élégamment des algorithmes mais peut être coûteuse en mémoire.
- La mémoïsation transforme des algorithmes exponentiels en algorithmes polynomiaux.
- Le paradigme diviser pour régner permet souvent d'atteindre des complexités logarithmiques.
- L'analyse de complexité par relations de récurrence est essentielle pour évaluer l'efficacité des algorithmes.
- La version optimisée de la multiplication montre qu'un bon algorithme peut être exponentiellement plus rapide.