Complexité des problèmes#

Hide code cell source

import matplotlib.pyplot as plt
import matplotlib.patches as patches
import numpy as np
import seaborn as sns
import itertools
import time

sns.set_theme(style="whitegrid", palette="muted", font_scale=1.1)

Introduction#

Tout au long de ce livre, nous avons étudié des algorithmes efficaces — triant en O(n log n), cherchant en O(log n), parcourant des graphes en O(V+E). Mais une question fondamentale demeure : existe-t-il des problèmes pour lesquels aucun algorithme efficace n’est possible ? La théorie de la complexité s’attache à classifier les problèmes non pas selon l’efficacité d’un algorithme particulier, mais selon la difficulté intrinsèque du problème lui-même.

Cette discipline, née dans les années 1970, a révélé une frontière mystérieuse entre les problèmes solubles rapidement et ceux pour lesquels les meilleurs algorithmes connus requièrent un temps exponentiel. Comprendre cette frontière est essentiel pour tout informaticien : cela permet de savoir quand chercher un algorithme polynomial, quand se contenter d’une approximation, et quand le problème est fondamentalement insoluble.

Classes de complexité P et NP#

La théorie de la complexité s’appuie sur la notion de machine de Turing comme modèle de calcul universel. Nous travaillerons avec des problèmes de décision — des problèmes dont la réponse est « oui » ou « non » — car ils sont plus simples à classifier que les problèmes d’optimisation (bien que les deux soient étroitement liés).

Définition 100 (Classe P)

La classe P (Polynomial time) est l’ensemble des problèmes de décision solubles par un algorithme déterministe en temps polynomial en la taille de l’entrée. Formellement, un problème L est dans P s’il existe un algorithme A et un polynôme p tels que, pour toute entrée x de taille n, A répond correctement en au plus p(n) étapes.

Exemples : tri (décider si un tableau est trié), recherche, plus court chemin, arbre couvrant minimal, 2-colorabilité d’un graphe, programmation linéaire.

Définition 101 (Classe NP)

La classe NP (Nondeterministic Polynomial time) est l’ensemble des problèmes de décision pour lesquels une réponse « oui » peut être vérifiée en temps polynomial à partir d’un certificat (ou témoin) de taille polynomiale.

Formellement, un problème L est dans NP s’il existe un vérificateur V (algorithme polynomial) tel que : pour toute instance x avec réponse « oui », il existe un certificat c de taille polynomiale vérifiant V(x, c) = « oui » ; pour toute instance x avec réponse « non », il n’existe pas de tel certificat.

Exemples : satisfaisabilité booléenne (le certificat est une assignation satisfaisante), clique (le certificat est la clique), coloration de graphe, problème du voyageur de commerce (le certificat est une tournée).

Remarque 45

P ⊆ NP. Tout problème dans P est aussi dans NP : si on peut résoudre un problème en temps polynomial, on peut certainement vérifier une solution en temps polynomial (il suffit de la recalculer).

La grande question ouverte est : P = NP ? Si c’était le cas, tout problème dont la solution est vérifiable rapidement serait aussi soluble rapidement. Si P ≠ NP (ce que la plupart des informaticiens croient), il existerait des problèmes NP qui ne sont pas dans P — des problèmes dont on peut vérifier les solutions efficacement mais qu’on ne sait pas résoudre efficacement.

La question P = NP#

La question de l’égalité entre P et NP est l’un des sept problèmes du millénaire de l’Institut Clay, assortie d’une récompense d’un million de dollars. Posée formellement par Stephen Cook en 1971, elle demeure sans réponse plus de cinquante ans plus tard.

Remarque 46

Enjeux de la question P = NP.

Si P = NP :

  • La cryptographie à clé publique (RSA, HTTPS) s’effondrerait : factoriser un nombre serait aussi facile que de multiplier.

  • Les problèmes d’optimisation combinatoire (planification, logistique, conception de médicaments) deviendraient tractables.

  • L’intelligence artificielle ferait un bond prodigieux : prouver des théorèmes mathématiques, comprendre le langage naturel, seraient solubles polynomialement.

Si P ≠ NP (cas probable) :

  • Il existe une hiérarchie de problèmes intrinsèquement difficiles.

  • La cryptographie moderne est fondée sur des bases solides.

  • Les heuristiques et les approximations restent indispensables pour les problèmes NP-difficiles.

Malgré des décennies d’efforts, personne n’a réussi à prouver que P ≠ NP. Les meilleurs algorithmes connus pour les problèmes NP-complets sont exponentiels, mais cela ne prouve pas l’inexistence d’algorithmes polynomiaux.

Hide code cell source

import math

# Illustration : croissance exponentielle vs polynomiale
tailles = np.array(range(1, 51))

fig, ax = plt.subplots(figsize=(12, 6))

# Algorithmes polynomiaux (classe P)
ax.plot(tailles, tailles**2, label='O(n²) — quadratique', color='#2ecc71', linewidth=2.5)
ax.plot(tailles, tailles**3, label='O(n³) — cubique', color='#27ae60', linewidth=2.5, linestyle='--')
ax.plot(tailles, tailles * np.log2(np.maximum(tailles, 1)),
        label='O(n log n)', color='#1abc9c', linewidth=2, linestyle=':')

# Algorithmes exponentiels (NP-complets connus)
expo_vals = np.minimum(2.0**tailles, 1e15)
ax.plot(tailles, expo_vals, label='O(2ⁿ) — exponentiel', color='#e74c3c', linewidth=2.5)

ax.set_yscale('log')
ax.set_xlabel('Taille de l\'entrée n', fontsize=12)
ax.set_ylabel('Nombre d\'opérations (échelle log)', fontsize=12)
ax.set_title('Croissance polynomiale vs exponentielle\n(la frontière entre P et NP-complet)',
             fontsize=13, fontweight='bold')
ax.legend(fontsize=10, loc='upper left')
ax.set_ylim(1, 1e15)
ax.set_xlim(1, 50)
ax.grid(True, alpha=0.4)

# Annotation de la zone tractable
ax.axvspan(1, 50, alpha=0.03, color='#2ecc71')
ax.text(25, 1e2, 'Tractable (P)', ha='center', fontsize=11, color='#27ae60',
        fontweight='bold', alpha=0.7)
ax.text(40, 1e10, 'Intractable\n(NP-complet connu)', ha='center', fontsize=10,
        color='#c0392b', fontweight='bold', alpha=0.8)

plt.tight_layout()
plt.show()
_images/88d26f9a2b4607a64ccab9a0094c4529815e538bb94446d53720b4586f2ff23c.png

NP-complétude et réductions#

Le concept de NP-complétude formalise l’idée qu’il existe des problèmes NP « les plus difficiles » — tels que résoudre l’un d’eux polynomialement permettrait de résoudre tous les problèmes NP polynomialement.

Définition 102 (Réduction polynomiale)

Un problème A est polynomialement réductible à un problème B (noté A ≤_P B) s’il existe un algorithme polynomial f qui transforme toute instance x de A en une instance f(x) de B, de sorte que x est une instance « oui » de A si et seulement si f(x) est une instance « oui » de B.

Intuitivement, si A ≤_P B, alors B est au moins aussi difficile que A : si on sait résoudre B en temps polynomial, on sait résoudre A en temps polynomial.

Définition 103 (NP-complétude)

Un problème L est NP-complet si :

  1. L est dans NP (une solution peut être vérifiée en temps polynomial).

  2. Tout problème de NP est polynomialement réductible à L (L est NP-difficile).

Un problème est NP-difficile s’il satisfait la condition 2 mais pas nécessairement la condition 1 (il peut ne pas être dans NP, par exemple si c’est un problème d’optimisation plutôt que de décision).

Remarque 47

Théorème de Cook-Levin (1971). Le problème SAT (satisfaisabilité booléenne) est NP-complet. C’est le premier problème prouvé NP-complet. La preuve consiste à montrer que n’importe quelle machine de Turing non déterministe polynomiale peut être simulée par une formule booléenne satisfaisable si et seulement si la machine accepte.

Une fois qu’on dispose d’un problème NP-complet de référence, on peut en déduire d’autres par réduction : pour montrer que B est NP-complet, il suffit de montrer que B est dans NP et qu’un problème NP-complet connu A se réduit polynomialement à B (A ≤_P B).

Problèmes NP-complets célèbres#

Exemple 13 (Problèmes NP-complets classiques)

3-SAT. Étant donné une formule booléenne en forme normale conjonctive (conjonction de clauses, chaque clause ayant exactement 3 littéraux), existe-t-il une assignation des variables qui satisfait la formule ? 3-SAT est NP-complet (Karp, 1972), mais 2-SAT est dans P.

Problème de la clique. Étant donné un graphe G et un entier k, existe-t-il un sous-ensemble de k sommets mutuellement adjacents (une clique de taille k) ?

Coloration de graphe. Étant donné un graphe G et un entier k, peut-on colorier les sommets avec k couleurs de sorte que deux sommets adjacents aient des couleurs différentes ? Pour k ≥ 3, le problème est NP-complet ; pour k = 2, il est dans P (test de bipartitité).

Problème du voyageur de commerce (TSP). Étant donné un graphe complet pondéré et un budget B, existe-t-il une tournée hamiltonienne de coût ≤ B ? NP-complet même pour les graphes métriques.

Problème du sac à dos (0/1). Étant donné des objets de poids et valeurs, une capacité W et un seuil V, peut-on choisir des objets de poids total ≤ W et de valeur ≥ V ?

def est_3sat_satisfaisable_force_brute(clauses, n_variables):
    """
    Vérifie par force brute si une instance de 3-SAT est satisfaisable.
    clauses : liste de listes de littéraux (entier positif = variable, négatif = négation)
    n_variables : nombre de variables
    Complexité : O(2^n · |clauses|)
    """
    def evaluer(assignation, clause):
        return any(
            (assignation[abs(lit) - 1] if lit > 0 else not assignation[abs(lit) - 1])
            for lit in clause
        )

    for bits in itertools.product([False, True], repeat=n_variables):
        if all(evaluer(bits, clause) for clause in clauses):
            # Trouver le certificat
            return True, {f'x{i+1}': bits[i] for i in range(n_variables)}
    return False, {}

# Instance satisfaisable : (x1 ∨ x2 ∨ ¬x3) ∧ (¬x1 ∨ x3 ∨ x4) ∧ (x2 ∨ ¬x3 ∨ x4) ∧ (¬x2 ∨ x3 ∨ ¬x4)
clauses_sat = [[1, 2, -3], [-1, 3, 4], [2, -3, 4], [-2, 3, -4]]
sat, certificat = est_3sat_satisfaisable_force_brute(clauses_sat, 4)
print(f"Instance satisfaisable : {sat}")
if sat:
    print(f"Certificat : {certificat}")
    print(f"Vérification : ", end='')
    for c in clauses_sat:
        val = any((certificat[f'x{abs(l)}'] if l > 0 else not certificat[f'x{abs(l)}']) for l in c)
        print(f"{val}", end=' ')
    print()

# Instance non satisfaisable : (x1 ∨ x1 ∨ x1) ∧ (¬x1 ∨ ¬x1 ∨ ¬x1)... cas trivial
clauses_unsat = [[1, 1, 1], [-1, -1, -1]]
unsat, _ = est_3sat_satisfaisable_force_brute(clauses_unsat, 1)
print(f"\nInstance non satisfaisable : {not unsat}")

# Illustration du temps exponentiel
print("\n--- Temps de résolution par force brute ---")
for n_vars in range(5, 22, 2):
    # Générer une instance aléatoire (satisfaisable)
    import random
    random.seed(42)
    clauses_test = [[random.choice([-1, 1]) * random.randint(1, n_vars) for _ in range(3)]
                    for _ in range(n_vars * 3)]
    t0 = time.perf_counter()
    sat_test, _ = est_3sat_satisfaisable_force_brute(clauses_test, n_vars)
    dt = time.perf_counter() - t0
    print(f"  n={n_vars:2d} variables : {2**n_vars:8d} assignations, {dt*1000:.2f} ms")
    if dt > 0.5:
        print("  (arrêt : trop lent)")
        break
Instance satisfaisable : True
Certificat : {'x1': False, 'x2': False, 'x3': False, 'x4': False}
Vérification : True True True True 

Instance non satisfaisable : True

--- Temps de résolution par force brute ---
  n= 5 variables :       32 assignations, 0.05 ms
  n= 7 variables :      128 assignations, 0.11 ms
  n= 9 variables :      512 assignations, 1.19 ms
  n=11 variables :     2048 assignations, 3.74 ms
  n=13 variables :     8192 assignations, 0.38 ms
  n=15 variables :    32768 assignations, 22.26 ms
  n=17 variables :   131072 assignations, 284.25 ms
  n=19 variables :   524288 assignations, 42.97 ms
  n=21 variables :  2097152 assignations, 257.89 ms

Stratégies face aux problèmes NP-difficiles#

Lorsqu’on est confronté à un problème NP-difficile en pratique, plusieurs stratégies sont disponibles selon les contraintes.

Algorithmes d’approximation#

Définition 104 (Algorithme d’approximation)

Un algorithme d’approximation à facteur α pour un problème de minimisation produit une solution de coût au plus α fois le coût optimal, pour toute instance. On dit que l’algorithme est une α-approximation (avec α ≥ 1). Pour les problèmes de maximisation, le facteur α ≤ 1.

def vertex_cover_approximation(graphe, n_sommets):
    """
    Algorithme d'approximation 2 pour le vertex cover.
    Algorithme : tant qu'il existe une arête non couverte, ajouter ses deux extrémités.
    Garantie : la solution retournée a au plus 2× la taille optimale.
    """
    couverture = set()
    aretes_restantes = list(graphe)

    while aretes_restantes:
        # Prendre une arête arbitraire
        u, v = aretes_restantes.pop()
        couverture.add(u)
        couverture.add(v)
        # Retirer toutes les arêtes couvertes
        aretes_restantes = [(a, b) for a, b in aretes_restantes
                            if a not in couverture and b not in couverture]

    return couverture

def tsp_approximation_2opt(distances, n):
    """
    Heuristique 2-opt pour le TSP métrique.
    Commence par une tournée gloutonne, puis améliore par échanges 2-opt.
    """
    # Tournée initiale greedy (voisin le plus proche)
    non_visites = set(range(1, n))
    tournee = [0]
    courant = 0
    while non_visites:
        suivant = min(non_visites, key=lambda v: distances[courant][v])
        tournee.append(suivant)
        non_visites.remove(suivant)
        courant = suivant
    tournee.append(0)

    def cout_tournee(t):
        return sum(distances[t[i]][t[i+1]] for i in range(len(t) - 1))

    # Amélioration 2-opt
    ameliore = True
    while ameliore:
        ameliore = False
        for i in range(1, n - 1):
            for j in range(i + 1, n):
                # Essayer d'inverser le segment [i, j]
                nouvelle_tournee = tournee[:i] + list(reversed(tournee[i:j+1])) + tournee[j+1:]
                if cout_tournee(nouvelle_tournee) < cout_tournee(tournee):
                    tournee = nouvelle_tournee
                    ameliore = True

    return tournee, cout_tournee(tournee)

# Exemple Vertex Cover
graphe = [(0,1), (1,2), (2,3), (3,0), (0,2)]
couverture = vertex_cover_approximation(graphe, 4)
print(f"Graphe : {graphe}")
print(f"Vertex Cover approx (2-approx) : {couverture} (taille {len(couverture)})")

# Exemple TSP
import random
random.seed(42)
n_villes = 8
coords = [(random.randint(0, 100), random.randint(0, 100)) for _ in range(n_villes)]
distances = [[int(((coords[i][0]-coords[j][0])**2 + (coords[i][1]-coords[j][1])**2)**0.5)
              for j in range(n_villes)] for i in range(n_villes)]

tournee, cout = tsp_approximation_2opt(distances, n_villes)
print(f"\nTSP 2-opt ({n_villes} villes) :")
print(f"  Tournée : {' → '.join(map(str, tournee))}")
print(f"  Coût    : {cout}")
Graphe : [(0, 1), (1, 2), (2, 3), (3, 0), (0, 2)]
Vertex Cover approx (2-approx) : {0, 2} (taille 2)

TSP 2-opt (8 villes) :
  Tournée : 0 → 4 → 7 → 5 → 1 → 2 → 3 → 6 → 0
  Coût    : 320

Heuristiques et métaheuristiques#

Remarque 48

Recuit simulé (simulated annealing). Inspiré du processus physique de recuit des métaux, le recuit simulé explore l’espace des solutions en acceptant parfois des solutions moins bonnes, avec une probabilité décroissant au fil du temps (la « température »). Cela permet d’échapper aux optima locaux.

Algorithmes génétiques. Inspirés de l’évolution darwinienne, ils maintiennent une population de solutions candidates et les font évoluer par sélection, croisement et mutation. Ils sont efficaces sur de nombreux problèmes d’optimisation combinatoire.

Algorithmes de colonies de fourmis, essaims de particules. D’autres métaheuristiques bio-inspirées, efficaces sur des instances pratiques même sans garantie de performance.

import math

def recuit_simule_tsp(distances, n, T_init=1000, T_fin=0.01, alpha=0.995, n_iter=5000):
    """
    Recuit simulé pour le TSP.
    Explore l'espace des tournées en acceptant des dégradations avec probabilité exp(-Δ/T).
    """
    random.seed(0)

    def cout(tournee):
        return sum(distances[tournee[i]][tournee[(i+1) % n]] for i in range(n))

    # Tournée initiale aléatoire
    tournee = list(range(n))
    random.shuffle(tournee)
    meilleur_cout = cout(tournee)
    meilleure_tournee = tournee[:]

    T = T_init
    historique = [meilleur_cout]

    for _ in range(n_iter):
        # Perturbation : échange de deux villes (2-opt swap)
        i, j = sorted(random.sample(range(n), 2))
        nouvelle_tournee = tournee[:i] + list(reversed(tournee[i:j+1])) + tournee[j+1:]
        delta = cout(nouvelle_tournee) - cout(tournee)

        if delta < 0 or random.random() < math.exp(-delta / T):
            tournee = nouvelle_tournee
            c = cout(tournee)
            if c < meilleur_cout:
                meilleur_cout = c
                meilleure_tournee = tournee[:]

        T = max(T * alpha, T_fin)
        historique.append(meilleur_cout)

    return meilleure_tournee, meilleur_cout, historique

if n_villes <= 10:
    tournee_rs, cout_rs, hist = recuit_simule_tsp(distances, n_villes)
    print(f"Recuit simulé ({n_villes} villes) :")
    print(f"  Meilleure tournée : {tournee_rs}")
    print(f"  Meilleur coût     : {cout_rs}")
    print(f"  2-opt coût        : {cout}")
Recuit simulé (8 villes) :
  Meilleure tournée : [1, 2, 3, 6, 0, 4, 7, 5]
  Meilleur coût     : 320
  2-opt coût        : 320

PSPACE et EXPTIME#

Au-delà de NP, la hiérarchie de complexité continue :

Remarque 49

PSPACE est la classe des problèmes solubles avec une quantité polynomiale de mémoire (indépendamment du temps). On a P ⊆ NP ⊆ PSPACE. Des problèmes PSPACE-complets incluent : l’évaluation des jeux à deux joueurs à information parfaite (échecs généralisés, go généralisé), la quantified boolean formula (QBF).

EXPTIME est la classe des problèmes solubles en temps exponentiel O(2^{p(n)}). On a PSPACE ⊆ EXPTIME. Des problèmes EXPTIME-complets sont liés à des jeux comme les échecs et le go sur des plateaux de taille générale.

On a la hiérarchie : P ⊆ NP ⊆ co-NP ⊆ PSPACE ⊆ EXPTIME, et toutes ces inclusions sont supposées strictes (mais seul P ⊊ EXPTIME est prouvé).

Hide code cell source

fig, axes = plt.subplots(1, 2, figsize=(14, 7))

# --- Gauche : diagramme de Venn des classes de complexité ---
ax = axes[0]
ax.set_xlim(-1, 11)
ax.set_ylim(-1, 9)
ax.axis('off')
ax.set_title('Hiérarchie des classes de complexité\n(sous hypothèse P ≠ NP)',
             fontsize=13, fontweight='bold')

# EXPTIME (la plus grande)
ellipse_exp = patches.Ellipse((5, 4.5), 9.5, 8.5, angle=0,
                               facecolor='#fadbd8', edgecolor='#e74c3c',
                               linewidth=2.5, alpha=0.7)
ax.add_patch(ellipse_exp)
ax.text(5, 8.2, 'EXPTIME', ha='center', va='center', fontsize=12,
        fontweight='bold', color='#c0392b')

# PSPACE
ellipse_psp = patches.Ellipse((5, 4.3), 7.5, 6.5,
                               facecolor='#fdebd0', edgecolor='#e67e22',
                               linewidth=2.5, alpha=0.8)
ax.add_patch(ellipse_psp)
ax.text(5, 7.1, 'PSPACE', ha='center', va='center', fontsize=11,
        fontweight='bold', color='#d35400')

# NP ∪ co-NP
ellipse_np = patches.Ellipse((5, 4.1), 5.5, 5.0,
                              facecolor='#d5e8d4', edgecolor='#27ae60',
                              linewidth=2.5, alpha=0.9)
ax.add_patch(ellipse_np)
ax.text(5, 6.2, 'NP ∪ co-NP', ha='center', va='center', fontsize=10,
        fontweight='bold', color='#1e8449')

# NP (légèrement décalé à droite)
ellipse_np2 = patches.Ellipse((5.8, 3.8), 3.5, 3.8,
                               facecolor='#d6eaf8', edgecolor='#2980b9',
                               linewidth=2, alpha=0.7)
ax.add_patch(ellipse_np2)
ax.text(7.2, 5.0, 'NP', ha='center', va='center', fontsize=10,
        fontweight='bold', color='#1a5276')

# P
ellipse_p = patches.Ellipse((4.5, 3.5), 2.8, 2.8,
                             facecolor='#ebdef0', edgecolor='#8e44ad',
                             linewidth=2.5, alpha=0.95)
ax.add_patch(ellipse_p)
ax.text(4.5, 3.5, 'P', ha='center', va='center', fontsize=14,
        fontweight='bold', color='#6c3483')

# Exemples de problèmes dans chaque zone
ax.text(4.5, 3.5, '\nTri, SPP\nLCS, 2-SAT', ha='center', va='center',
        fontsize=7, color='#4a235a', style='italic')
ax.text(7.5, 3.2, '3-SAT\nClique\nTSP', ha='center', va='center',
        fontsize=7, color='#154360', style='italic')
ax.text(5, 5.5, 'QBF\nJeux', ha='center', va='center',
        fontsize=7, color='#7d6608', style='italic')
ax.text(5, 7.5, 'Échecs (généralisés)\nGo', ha='center', va='center',
        fontsize=7, color='#922b21', style='italic')

# --- Droite : réseau de réductions entre problèmes NP-complets ---
ax2 = axes[1]
ax2.set_xlim(-0.5, 10.5)
ax2.set_ylim(-0.5, 8.5)
ax2.axis('off')
ax2.set_title('Réductions polynomiales entre problèmes NP-complets\n(A → B : A se réduit à B)',
              fontsize=12, fontweight='bold')

problemes = {
    'SAT': (5, 7.5),
    '3-SAT': (5, 6.0),
    'CLIQUE': (2.5, 4.5),
    'VERTEX\nCOVER': (7.5, 4.5),
    '3-COLOR': (2.5, 2.5),
    'HAMILTONIEN': (5, 2.5),
    'TSP': (7.5, 2.5),
    'SAC À DOS\n(0/1)': (5, 0.8),
}

couleurs_prob = {
    'SAT': '#e74c3c', '3-SAT': '#e67e22', 'CLIQUE': '#3498db',
    'VERTEX\nCOVER': '#9b59b6', '3-COLOR': '#1abc9c',
    'HAMILTONIEN': '#f39c12', 'TSP': '#e74c3c', 'SAC À DOS\n(0/1)': '#2ecc71',
}

for nom, (x, y) in problemes.items():
    couleur = couleurs_prob.get(nom, '#3498db')
    box = patches.FancyBboxPatch((x - 0.9, y - 0.45), 1.8, 0.9,
                                  boxstyle="round,pad=0.1",
                                  facecolor=couleur, edgecolor='white',
                                  linewidth=2, alpha=0.85)
    ax2.add_patch(box)
    ax2.text(x, y, nom, ha='center', va='center', fontsize=8,
             fontweight='bold', color='white')

# Flèches de réduction
reductions = [
    ('SAT', '3-SAT'), ('3-SAT', 'CLIQUE'), ('3-SAT', 'VERTEX\nCOVER'),
    ('CLIQUE', '3-COLOR'), ('VERTEX\nCOVER', 'HAMILTONIEN'),
    ('HAMILTONIEN', 'TSP'), ('3-SAT', 'SAC À DOS\n(0/1)'),
]

for src, dst in reductions:
    x1, y1 = problemes[src]
    x2, y2 = problemes[dst]
    ax2.annotate('', xy=(x2, y2 + 0.5), xytext=(x1, y1 - 0.5),
                 arrowprops=dict(arrowstyle='->', color='#2c3e50', lw=1.5,
                                 connectionstyle='arc3,rad=0.1'))

plt.tight_layout()
plt.show()
_images/bead0d67c47ebd9327d9e15254eb79e575732980d1676514940cd643a4d51cfe.png

Cas particuliers polynomiaux#

Même pour un problème NP-difficile, des restrictions sur la structure de l’entrée peuvent rendre le problème polynomial.

Exemple 14 (Cas polynomiaux de problèmes NP-complets)

  • Coloration de graphe k=2 : la 2-coloration (test de bipartitité) est dans P via un simple BFS/DFS.

  • TSP sur arbre : si le graphe est un arbre, on peut trouver le circuit hamiltonien optimal en temps linéaire.

  • Sac à dos avec petits poids : si tous les poids w_i sont des entiers bornés par un polynôme en n, la DP en O(nW) est pseudo-polynomiale (polynomiale en la valeur numérique, non en la taille binaire).

  • 2-SAT : la satisfaisabilité de formules avec exactement 2 littéraux par clause est dans P, soluble par des composantes fortement connexes en O(n+m).

  • Graphes planaires : la coloration de graphes planaires avec 4 couleurs est toujours possible (théorème des 4 couleurs), et la 3-coloration des graphes planaires reste NP-complète.

def deux_sat(n_vars, implications):
    """
    Résolution de 2-SAT par Kosaraju (composantes fortement connexes).
    implications : liste de (u, v) signifiant ¬u → v et ¬v → u (clause u ∨ v)
    Variables : 0..n-1 (variable i), n..2n-1 (négation i)
    """
    N = 2 * n_vars

    def neg(x):
        return x + n_vars if x < n_vars else x - n_vars

    graph = [[] for _ in range(N)]
    graph_rev = [[] for _ in range(N)]

    for u, v in implications:
        # Clause (u ∨ v) ≡ (¬u → v) ∧ (¬v → u)
        graph[neg(u)].append(v)
        graph[neg(v)].append(u)
        graph_rev[v].append(neg(u))
        graph_rev[u].append(neg(v))

    # Kosaraju : ordre de fin de DFS sur le graphe original
    visittes = [False] * N
    ordre_fin = []

    def dfs1(u):
        stack = [(u, iter(graph[u]))]
        visittes[u] = True
        while stack:
            v, it = stack[-1]
            try:
                w = next(it)
                if not visittes[w]:
                    visittes[w] = True
                    stack.append((w, iter(graph[w])))
            except StopIteration:
                ordre_fin.append(v)
                stack.pop()

    for i in range(N):
        if not visittes[i]:
            dfs1(i)

    # DFS sur le graphe transposé dans l'ordre inverse
    composante = [-1] * N
    comp_id = 0

    def dfs2(u, cid):
        stack = [u]
        composante[u] = cid
        while stack:
            v = stack.pop()
            for w in graph_rev[v]:
                if composante[w] == -1:
                    composante[w] = cid
                    stack.append(w)

    for u in reversed(ordre_fin):
        if composante[u] == -1:
            dfs2(u, comp_id)
            comp_id += 1

    # Vérification et assignation
    assignation = {}
    for i in range(n_vars):
        if composante[i] == composante[neg(i)]:
            return None  # Non satisfaisable
        assignation[i] = composante[i] > composante[neg(i)]

    return assignation

# Instance 2-SAT : (x0 ∨ x1) ∧ (¬x0 ∨ x2) ∧ (¬x1 ∨ ¬x2)
# Encodage : variable i → i, ¬i → i + n_vars
n_vars = 3
# Clause (x0 ∨ x1) : u=0, v=1
# Clause (¬x0 ∨ x2) : u=3 (¬x0), v=2
# Clause (¬x1 ∨ ¬x2) : u=4 (¬x1), v=5 (¬x2)
clauses_2sat = [(0, 1), (3, 2), (4, 5)]
sol = deux_sat(n_vars, clauses_2sat)
print(f"2-SAT résultat : {sol}")
if sol:
    print("Solution :")
    for i, val in sol.items():
        print(f"  x{i} = {val}")
2-SAT résultat : {0: True, 1: False, 2: True}
Solution :
  x0 = True
  x1 = False
  x2 = True

Résumé#

Dans ce chapitre, nous avons exploré les fondements de la théorie de la complexité des problèmes :

  • La classe P contient les problèmes solubles en temps polynomial — les problèmes tractables.

  • La classe NP contient les problèmes dont les solutions peuvent être vérifiées en temps polynomial. On a P ⊆ NP, et la question P = NP reste l’une des plus grandes énigmes ouvertes de l’informatique.

  • Un problème est NP-complet s’il est dans NP et que tout problème de NP se réduit polynomialement à lui. Le premier problème NP-complet, SAT, a été identifié par Cook et Levin en 1971.

  • Des centaines de problèmes pratiques — coloration de graphes, TSP, sac à dos 0/1, satisfaisabilité — sont NP-complets.

  • Face à un problème NP-difficile, les stratégies pratiques incluent les algorithmes d’approximation (garantie de performance), les heuristiques et métaheuristiques (recuit simulé, algorithmes génétiques), et l’exploitation de cas particuliers polynomiaux.

  • La hiérarchie P ⊆ NP ⊆ PSPACE ⊆ EXPTIME structure les problèmes par leur difficulté intrinsèque.

Dans le chapitre suivant, nous étudierons les algorithmes probabilistes et randomisés, qui utilisent l’aléatoire comme outil algorithmique pour contourner les pires cas et concevoir des structures de données efficaces.