À propos de

Les équations sur ce site sont affichées avec MathJax.

Nous savons déterminer la complexité d'une algorithme. Nous attribuons typiquement à un problème la complexité algorithmique du meilleur algorithme connu pour le résoudre.

Une complexité algorithmique qui s'exprime sous la forme d'un polynôme est typiquement un problème qu'il est raisonnable de confronter et pour lequel des optimisations peuvent ramener un problème difficile à un niveau de difficulté plus raisonnable. On parle ici de la famille des problèmes de complexité .

Quand un problème est tel que sa complexité algorithmique ne s'exprime pas sous forme d'un polynôme, on dit qu'il appartient à la famille des problèmes (non-déterministe polynômial), au sens où seule une procédure de décision non-déterministe (une sorte d'oracle) pourrait ramener la complexité de l'algorithme en question à quelque chose s'exprimant sous la forme d'un polynôme.

La famille des problèmes NP-complets est particulièrement intéressante, au sens où elle se compose de problèmes qui ont ceci en commun qu'il existe un algorithme de complexité polynomiale pour traduire chacun d'eux en un autre de la même famille, et et au sens où dans chaque cas il existe un algorithme de complexité polynomiale pour vérifier qu'une solution est correcte.

La question de savoir si ou si est l'un des problèmes les plus difficiles et les plus fondamentaux de l'informatique. C'est typiquement en attaquant des problèmes NP-complets que l'on cherche à résoudre cette question particulièrement profonde.

Incarnations célèbres des problèmes NP-complets

Bien que la NP-complétude soit un domaine vaste contenant une infinité de variantes, certaines formulations du problème sont plus célèbres que d'autres. En voici quelques-unes (une liste plus riche est disponible sur https://en.wikipedia.org/wiki/List_of_NP-complete_problems)

Problème de la satisfaction d'expressions booléenne (SAT)

Le théorème de Cook-Levin est à l'origine de l'exploration de la question dont nous discutions ici, et a trait à la satisfaction d'une formule booléenne. Soit une expression logique à variables, déterminer s'il existe une combinaison de valeurs pour ces variables qui permette de satisfaire cette expression entre dans la catégorie des problèmes NP-complets.

Par satisfaire ici, on entend faire en sorte que l'évaluation de l'expression entière donne VRAI. Certaines expressions (la plus simple étant ) ne peuvent être satisfaites.

De manière générale, dans le pire cas :

Explications plus détaillées :

Problème du commis voyageur (Travelling Salesman Problem, TSP)

J'aime bien cette variante car elle me semble l'une des plus simples à exprimer pour des non-mathématicien(ne)s. Essentiellement : supposons un commis voyageur, dont le rôle est d'apporter des paquets à divers endroits, un peu comme un facteur le ferait pour la poste.

Le « plus court chemin » est une illustration simplificatrice; il pourrait y avoir des chemins à sens unique, des voies munies de péage, on pourrait vouloir évaluer le chemin le moins coûteux en termes de consommation d'essence, etc.

Le problème du commis voyageur est simple : quel est le plus court chemin passant par chacun des lieux à visiter (une seule fois dans chaque cas) et le ramenant à son point de départ?

Supposons, pour simplifier le problème, un petit village de cinq maisons et un bureau de poste, qui est le point de départ et d'arrivée de notre commis. Supposons aussi qu'il existe un chemin entre chacun des endroits (ce qui est le pire cas envisageabke), pour que le propos soit le plus simple et le plus direct possible. Dans ce cas :

Exprimé sous forme d'une formule, la complexité de ce problème pour une village de cinq maisons est . Le problème est que pour maisons, la complexité de ce problème est ce qui est dément, et n'est nettement pas une complexité polynomiale.

Pour quelques explications plus détaillées :

Voici un programme C++ 14 implémentant de manière naïve une solution à TSP. Trois fichiers suivent, soit Ville.h, déclarant un type représentant un groupe de lieux à visiter :

Ville.h
#ifndef VILLE_H
#define VILLE_H
#include <iosfwd>
#include <vector>
class Ville {
   std::vector<std::vector<int>> distances;
   const int nvilles;
public:
   Ville(int nvilles);
   int nb_villes() const noexcept {
      return nvilles;
   }
   const std::vector<int>& operator[](int n) const {
      return distances[n];
   }
   std::vector<int>& operator[](int n) {
      return distances[n];
   }
};
std::ostream& operator<<(std::ostream&, const Ville&);
#endif

... Ville.cpp, qui définit ce type. Remarquez que les distances entre les lieux sont générées pseudoaléatoirement, mais que cela n'a pas d'incidence sur l'algorithme. Remarquez aussi que les distances sont bêtement Euclidiennes, mais que ce n'est qu'un schème possible parmi plusieurs :

Ville.cpp
#include "Ville.h"
#include <random>
#include <string>
#include <ostream>
using namespace std;
//
// Complexité (nvilles == N)
// - O(N^2)
//
Ville::Ville(int nvilles) : nvilles{nvilles}, distances{ nvilles, vector<int>(nvilles) } {
   random_device rd;
   mt19937 prng{ rd() };
   uniform_int_distribution<> de{ 0, 1000 };
   for (int i = 0; i < nb_villes(); i++) {
      distances[i][i] = 0;
      for (int j = i + 1; j < nb_villes(); j++)
         distances[i][j] = distances[j][i] = de(prng);
   }
}
//
// Complexité (v.nb_villes() == N)
// - O(N^2)
//
ostream& operator<<(ostream &os, const Ville &v) {
   for (int i = 0; i < v.nb_villes(); ++i)
      os << '\t' << static_cast<char>('a' + i);
   os << endl;
   for (int i = 0; i < v.nb_villes(); ++i) {
      os << static_cast<char>('a' + i);
      for (int j = 0; j < v.nb_villes(); ++j)
         os << '\t' << v[i][j];
      os << endl;
   }
   return os;
}

... et Principal.cpp, qui contient le code de test et le code implémentant une visite naïve d'une instance de Ville pour y trouver le chemin le plus court à partir d'un certain point :

Principal.cpp
#include "Ville.h"
#include <iostream>
#include <vector>
#include <algorithm>
#include <iterator>
#include <string>
#include <thread>
#include <chrono>
#include <tuple>
#include <future>
#include <numeric>
#include <iomanip>
using namespace std;
using namespace std::chrono;
class Visite {
   int netapes;
   bool termine;
   vector<bool> deja_visite;
   vector<int> chemin_courant;
   vector<int> chemin_meilleur;
   Ville &ville;
   void trouver_plus_court_chemin() {
      ++netapes;
      if (none_of(begin(deja_visite), end(deja_visite), [](bool b) { return !b; })) {
         if (chemin_meilleur.empty())
            chemin_meilleur = chemin_courant;
         else {
            int dist_meilleur = 0,
                dist_courant = 0;
            for (int i = 1; i < ville.nb_villes(); i++) {
               dist_meilleur += ville[chemin_meilleur[i-1]][chemin_meilleur[i]];
               dist_courant += ville[chemin_courant[i-1]][chemin_courant[i]];
            }
            dist_meilleur += ville[chemin_meilleur[ville.nb_villes()-1]][chemin_meilleur[0]];
            dist_courant += ville[chemin_courant[ville.nb_villes()-1]][chemin_courant[0]];
            if (dist_courant < dist_meilleur)
               chemin_meilleur = chemin_courant;
         }
      } else {
         for (int i = 0; i < ville.nb_villes(); ++i)
            if (!deja_visite[i]) {
               deja_visite[i] = true;
               chemin_courant.push_back(i);
               trouver_plus_court_chemin();
               chemin_courant.pop_back();
               deja_visite[i] = false;
            }
      }
   }
public:
   int nb_etapes() const noexcept {
      return netapes;
   }
   bool est_termine() const noexcept {
      return termine;
   }
   Visite(Ville &v) : ville(v), deja_visite(v.nb_villes(), false), netapes{}, termine{} {
   }
   const vector<int>& TSP() {
      trouver_plus_court_chemin();
      termine = true;
      return chemin_meilleur;
   }
};
template <class F, class M>
   auto tester(F f, M minu) {
      auto avant = minu.now();
      auto res = f();
      auto apres = minu.now();
      return make_pair(res, apres - avant);
   }
int main() {
   constexpr const auto SEP = "----------------------------------------";
   vector<system_clock::duration> temps_tests;
   vector<int> etapes;
   const int MAX_TESTS = 12;
   for (int n = 1; n <= MAX_TESTS; ++n) {
      Ville v{n};
      cout << "Ville de " << n << " arrets:\n" << v << endl;
      Visite visite{ v };
      auto progres = async(
         launch::async, [&visite] {
            for (string s; !visite.est_termine(); this_thread::sleep_for(500ms)) {
               for_each(begin(s), end(s), [](char) { cout << '\b'; });
               s = "Etape(s): " + to_string(visite.nb_etapes());
               cout << s;
            }
         }
      );
      system_clock::duration duree;
      vector<int> chemin;
      tie(chemin, duree) = tester([&visite] { return visite.TSP(); }, system_clock{});
      temps_tests.push_back(duree);
      etapes.push_back(visite.nb_etapes());
      cout << "\n\n" << SEP << "\nMeilleur chemin (index de villes):\n\n-->\t";
      for (auto & n : chemin)
         cout << static_cast<char>('a' + n) << '\t';
      cout << "\n\nDistance totale: ";
      int distance_totale = 0;
      for (vector<int>::size_type i = 1; i < chemin.size(); ++i)
         distance_totale += v[chemin[i-1]][chemin[i]];
      distance_totale += v[chemin[chemin.size()-1]][chemin[0]];
      cout << distance_totale
           << "\n\n" << SEP << "\nNb total d'etapes: " << visite.nb_etapes()
           << " en " << duration_cast<milliseconds>(duree).count() << " ms.\n"
           << SEP << "\n" << endl;
      progres.wait();
   }
   cout << "\n\nTemps des tests (ms):";
   auto largeur = accumulate(begin(temps_tests), end(temps_tests), 0, [](int so_far, system_clock::duration d) {
      auto res = 0;
      auto n = duration_cast<milliseconds>(d).count();
      while (n) {
         n /= 10;
         ++res;
      }
      return res;
   });
   for (vector<int>::size_type i = 0; i < etapes.size(); ++i)
      cout << "\n\t" << setw(largeur) << duration_cast<milliseconds>(temps_tests[i]).count()
           << " ms. pour " << etapes[i] << " etapes";
   cout << "\n\n" << SEP << "\n" << endl;
}

Alors que le nombre de lieux à visiter croît, la solution naïve prend drastiquement plus de temps à trouver une solution; avec treize endroits à visiter, en fait, le nombre d'étapes à réaliser dépasse la capacité d'un entier signé sur 32 bits... Essayez-le et soyez patient(e)s!

Le programme teste des échantillons d'un seul endroit (trivial), puis de deux endroits (aussi trivial), puis de trois, et ainsi de suite jusqu'à une constante de votre choix (que vous pouvez modifier, avec réserve, dans le programme principal). Le temps total de calcul (en millisecondes) est indiqué à chaque fois, de même que le nombre d'étapes requises pour en arriver à une solution.

Lectures complémentaires

Quelques liens pour enrichir le propos.

Le théorème Cook-Levin :

Il arrive régulièrement qu'une preuve ou une réfutation de ou de soit proposée. Quelques exemples parmi plusieurs :

Le blogue Gödel's Lost Letter and P=NP de R. J. Lipton et Ken Regan traite régulièrement du problème . Quelques-uns de leurs textes suivent :

Un  autre blogue intéressant sur les questions ayant trait à la complexité algorithmique est celui de Lance Fortnow et Bill Gasarach. Quelques textes :

Résoudre des problèmes NP-Complets en passant par des chemins novateurs :


Valid XHTML 1.0 Transitional

CSS Valide !