⚙️

Algorithmique

Complexité, tris, dichotomie, récursion, DP, glouton

Complexité O(n) Tris Récursion

🎯 Introduction

L'algorithmique est l'étude des algorithmes : leur conception, leur correction (font-ils bien ce qu'on veut ?) et leur efficacité (combien de temps et de mémoire consomment-ils ?). Au bac NSI, on évalue surtout la complexité temporelle et les grands algorithmes classiques.

✅ Ce qu'il faut savoir

  • Notations O(1), O(log n), O(n), O(n log n), O(n²) et leur signification
  • Tris par comparaison : bulles, insertion, sélection, fusion, rapide
  • Recherche dichotomique et sa complexité O(log n)
  • Récursivité : cas de base, appel récursif, pile d'appels
  • Programmation dynamique : mémoïsation vs tabulation
  • Algorithmes gloutons : principe et limites

📊 Complexité algorithmique

La complexité temporelle mesure le nombre d'opérations en fonction de la taille de l'entrée n. On utilise la notation grand O pour exprimer le pire cas.

ComplexitéNomExemplen=1000
O(1)ConstanteAccès tableau par indice1 op
O(log n)LogarithmiqueRecherche dichotomique~10 ops
O(n)LinéaireParcours liste1 000 ops
O(n log n)Quasi-linéaireTri fusion, tri rapide (moy.)~10 000 ops
O(n²)QuadratiqueTri bulles, tri insertion (pire)1 000 000 ops
O(2ⁿ)ExponentielleSous-ensembles, backtracking naïf≈ 10³⁰¹ ops 💀
💡 Règle pratique : Pour n = 10⁶, un algorithme O(n log n) tourne en < 1s, un O(n²) prend plusieurs minutes. La complexité détermine la faisabilité réelle.

Comment calculer la complexité ?

# O(n) : une boucle simple
def somme(lst):
    total = 0
    for x in lst:   # n itérations
        total += x   # O(1)
    return total    # → O(n) au total

# O(n²) : deux boucles imbriquées
def paires(lst):
    for i in range(len(lst)):       # n itérations
        for j in range(len(lst)):   # n itérations
            print(lst[i], lst[j])   # → O(n²)

# O(log n) : on divise par 2 à chaque étape
def log_n(n):
    k = 0
    while n > 1:
        n = n // 2   # on divise par 2
        k += 1
    return k         # → O(log n)

🔀 Algorithmes de tri

Principe : On maintient une partie gauche triée. On insère chaque nouvel élément à sa bonne place dans cette partie triée — comme on trie des cartes à la main.

def tri_insertion(lst):
    for i in range(1, len(lst)):
        cle = lst[i]
        j = i - 1
        while j >= 0 and lst[j] > cle:
            lst[j + 1] = lst[j]
            j -= 1
        lst[j + 1] = cle
    return lst

# Complexité : O(n²) pire cas, O(n) meilleur cas (déjà trié)
✅ Avantage : Très efficace sur des listes presque triées. Tri en place (pas de mémoire supplémentaire).

Principe : On cherche le minimum du tableau non trié et on le place au début. On répète sur le reste.

def tri_selection(lst):
    n = len(lst)
    for i in range(n):
        idx_min = i
        for j in range(i + 1, n):
            if lst[j] < lst[idx_min]:
                idx_min = j
        lst[i], lst[idx_min] = lst[idx_min], lst[i]
    return lst

# Complexité : O(n²) dans tous les cas
⚠️ Attention : Toujours O(n²), même si la liste est déjà triée. Non stable.

Principe : Diviser pour régner. On divise le tableau en deux moitiés, on trie chacune récursivement, puis on fusionne les deux parties triées.

def fusion(g, d):
    res, i, j = [], 0, 0
    while i < len(g) and j < len(d):
        if g[i] <= d[j]:
            res.append(g[i]); i += 1
        else:
            res.append(d[j]); j += 1
    return res + g[i:] + d[j:]

def tri_fusion(lst):
    if len(lst) <= 1:
        return lst
    m = len(lst) // 2
    return fusion(tri_fusion(lst[:m]),
                  tri_fusion(lst[m:]))

# Complexité : O(n log n) dans tous les cas
✅ Avantage : Garantit O(n log n) même au pire cas. Stable. Idéal pour les grandes données.

Principe : On choisit un pivot, on place les éléments < pivot à gauche et > pivot à droite, puis on trie récursivement chaque partie.

def tri_rapide(lst):
    if len(lst) <= 1:
        return lst
    pivot = lst[len(lst) // 2]
    gauche  = [x for x in lst if x < pivot]
    milieu  = [x for x in lst if x == pivot]
    droite  = [x for x in lst if x > pivot]
    return tri_rapide(gauche) + milieu + tri_rapide(droite)

# Complexité : O(n log n) en moyenne, O(n²) au pire cas
⚠️ Pire cas : Tableau déjà trié + pivot = premier élément → O(n²). En pratique, le pivot médian évite ce problème.

Tableau récapitulatif des tris

TriMeilleurMoyenPireStableEn place
InsertionO(n)O(n²)O(n²)✅✅
SélectionO(n²)O(n²)O(n²)❌✅
FusionO(n log n)O(n log n)O(n log n)✅❌
RapideO(n log n)O(n log n)O(n²)❌✅*

* Version en place possible mais la version Python ci-dessus ne l'est pas.

🔍 Recherche dichotomique

Sur un tableau trié, on compare l'élément du milieu avec la cible. On réduit l'intervalle de recherche de moitié à chaque étape → O(log n).

def recherche_dicho(lst, cible):
    """Renvoie l'indice de cible ou -1 si absent."""
    gauche, droite = 0, len(lst) - 1
    while gauche <= droite:
        milieu = (gauche + droite) // 2
        if lst[milieu] == cible:
            return milieu
        elif lst[milieu] < cible:
            gauche = milieu + 1   # chercher à droite
        else:
            droite = milieu - 1   # chercher à gauche
    return -1

# Exemple
lst = [2, 5, 8, 12, 16, 23, 38, 56]
print(recherche_dicho(lst, 23))   # → 5
print(recherche_dicho(lst, 10))   # → -1
⚠️ Condition obligatoire : La liste doit être triée avant d'appliquer la dichotomie. Sinon le résultat est faux !

Version récursive

def dicho_rec(lst, cible, g=0, d=None):
    if d is None: d = len(lst) - 1
    if g > d: return -1          # cas de base : non trouvé
    m = (g + d) // 2
    if lst[m] == cible: return m  # cas de base : trouvé
    if lst[m] < cible:
        return dicho_rec(lst, cible, m + 1, d)
    return dicho_rec(lst, cible, g, m - 1)

🔄 Récursivité

Une fonction est récursive si elle s'appelle elle-même. Toute fonction récursive doit avoir un cas de base (condition d'arrêt) et un appel récursif qui se rapproche du cas de base.

# Factorielle : n! = n × (n-1)!
def factorielle(n):
    if n <= 1:            # cas de base
        return 1
    return n * factorielle(n - 1)  # appel récursif

# Suite de Fibonacci
def fib(n):
    if n <= 1: return n
    return fib(n-1) + fib(n-2)  # Complexité : O(2ⁿ) !

# Visualiser la pile d'appels pour fib(4) :
# fib(4) → fib(3) + fib(2)
#          fib(3) → fib(2) + fib(1)
#                   fib(2) → fib(1) + fib(0)  ...
⚠️ Récursion vs Boucle : fib(40) naïf fait ~330 millions d'appels. Python a une limite de récursion (par défaut 1000 appels). Utiliser la mémoïsation !

🧩 Programmation dynamique

La programmation dynamique évite de recalculer les mêmes sous-problèmes en mémorisant les résultats. Deux approches :

On garde la récursion mais on stocke les résultats déjà calculés dans un dictionnaire (ou avec @lru_cache).

# Fibonacci avec mémoïsation
def fib_memo(n, memo={}):
    if n in memo: return memo[n]
    if n <= 1: return n
    memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo)
    return memo[n]
# Complexité : O(n) au lieu de O(2ⁿ) !

# Ou plus élégamment avec le décorateur
from functools import lru_cache

@lru_cache(maxsize=None)
def fib(n):
    if n <= 1: return n
    return fib(n-1) + fib(n-2)

On remplit un tableau depuis les cas de base, sans récursion.

# Fibonacci avec tabulation
def fib_tab(n):
    if n <= 1: return n
    dp = [0] * (n + 1)
    dp[1] = 1
    for i in range(2, n + 1):
        dp[i] = dp[i-1] + dp[i-2]
    return dp[n]

# Rendu de monnaie (problème classique DP)
def rendu_monnaie(pieces, montant):
    dp = [float('inf')] * (montant + 1)
    dp[0] = 0
    for m in range(1, montant + 1):
        for p in pieces:
            if p <= m:
                dp[m] = min(dp[m], dp[m - p] + 1)
    return dp[montant]

🍽️ Algorithmes gloutons

Un algorithme glouton fait à chaque étape le choix localement optimal, sans revenir en arrière. Simple et rapide, mais ne donne pas toujours la solution globalement optimale.

# Problème du rendu de monnaie (version gloutonne)
def rendu_glouton(pieces, montant):
    # pieces doit être triée par ordre décroissant
    pieces_triees = sorted(pieces, reverse=True)
    resultat = []
    for p in pieces_triees:
        while montant >= p:
            resultat.append(p)
            montant -= p
    return resultat

print(rendu_glouton([1, 2, 5, 10, 20], 36))
# → [20, 10, 5, 1] : 4 pièces — optimal ici !

print(rendu_glouton([1, 3, 4], 6))
# → [4, 1, 1] : 3 pièces — mais [3, 3] c'est mieux !
# L'approche gloutonne échoue ici.
💡 Glouton vs DP : Le glouton fonctionne pour les systèmes de monnaie "canoniques" (euro, dollar). Pour des pièces arbitraires, seule la programmation dynamique garantit l'optimal.

🚨 Pièges classiques au bac

⛔ Confondre O(log n) et O(n log n)
La dichotomie est O(log n). Le tri fusion est O(n log n). Ce n'est pas la même chose !
⛔ Oublier le cas de base en récursion
Sans condition d'arrêt → récursion infinie → RecursionError. Toujours vérifier !
⚠️ Tri rapide : pire cas
Sur un tableau déjà trié, avec pivot = premier élément : O(n²). Au bac, préciser "en moyenne O(n log n)".
⚠️ Stable vs non stable
Un tri stable conserve l'ordre relatif des éléments égaux. Tri fusion = stable, tri rapide = non stable en général.

📝 Exercices

Facile

Exo 1 — Complexité à la main

Donner la complexité temporelle (notation O) de chacune des fonctions suivantes :

def f1(n):                    def f2(lst):
    s = 0                        s = 0
    for i in range(n):              for i in range(len(lst)):
        for j in range(n):              for j in range(i):
            s += 1                      s += lst[j]
    return s                     return s

def f3(n):
    if n <= 0: return 0
    return f3(n // 2) + 1

f1 : Deux boucles imbriquées de 0 à n → O(n²)

f2 : La boucle interne va de 0 à i, donc au total 0+1+2+...+(n-1) = n(n-1)/2 → O(n²)

f3 : Appel récursif avec n//2 à chaque fois → on divise par 2 → O(log n)

Intermédiaire

Exo 2 — Tri à la main

Appliquer le tri par insertion sur la liste [5, 2, 8, 1, 9, 3]. Montrer chaque étape.

État initial :  [5, 2, 8, 1, 9, 3]
i=1, clé=2  :  [2, 5, 8, 1, 9, 3]  (2 < 5, on décale 5)
i=2, clé=8  :  [2, 5, 8, 1, 9, 3]  (8 > 5, pas de déplacement)
i=3, clé=1  :  [1, 2, 5, 8, 9, 3]  (1 < 8,5,2, tout décalé)
i=4, clé=9  :  [1, 2, 5, 8, 9, 3]  (9 > 8, pas de déplacement)
i=5, clé=3  :  [1, 2, 3, 5, 8, 9]  (3 entre 2 et 5)
Résultat    :  [1, 2, 3, 5, 8, 9] ✅
Niveau bac

Exo 3 — Rendu de monnaie DP

On dispose de pièces de valeurs [1, 4, 6]. Compléter la fonction DP qui renvoie le nombre minimal de pièces pour rendre un montant m. Tester avec m=8 (réponse attendue : 2 pièces : 4+4).

def min_pieces(pieces, m):
    dp = [float('inf')] * (m + 1)
    dp[0] = 0
    for montant in range(1, m + 1):
        for p in pieces:
            if p <= montant:
                dp[montant] = ___(dp[montant], ___ + 1)
    return dp[m]
def min_pieces(pieces, m):
    dp = [float('inf')] * (m + 1)
    dp[0] = 0
    for montant in range(1, m + 1):
        for p in pieces:
            if p <= montant:
                dp[montant] = min(dp[montant], dp[montant - p] + 1)
    return dp[m]

print(min_pieces([1,4,6], 8))  # → 2 (4+4)
# L'algorithme glouton donnerait 6+1+1 = 3 pièces → sous-optimal !

📌 Fiche synthèse

Complexités clés

  • O(1) : accès tableau
  • O(log n) : dichotomie, arbre équilibré
  • O(n) : parcours liste
  • O(n log n) : tri fusion, tri rapide (moy)
  • O(n²) : tris naïfs, double boucle

Récursion

  • Toujours un cas de base
  • L'appel récursif doit converger
  • Pile d'appels limitée (~1000 en Python)
  • Mémoïsation pour éviter les recalculs

Tris à retenir

  • Insertion : O(n²) / O(n) si presque trié
  • Fusion : toujours O(n log n), stable
  • Rapide : O(n log n) moy, O(n²) pire

DP vs Glouton

  • Glouton : simple, pas toujours optimal
  • DP : optimal, mémorise les sous-problèmes
  • DP nécessite sous-structure optimale + sous-problèmes chevauchants

🧠 QCM

Choisissez le nombre de questions et le niveau, puis lancez.