Précédent Remonter Suivant
Chapitre 4 Exploration

S ouvent, on recherche un ensemble d'éléments satisfaisant une certaine contrainte parmi un ensemble fini donné. Par exemple, on cherche parmi les sommets d'un graphe les sommets figurant sur un chemin reliant deux sommets donnés, ou on cherche parmi tous les mouvements possibles d'un cavalier sur un échiquier ceux qui permettent de parcourir tout l'échiquier à partir d'une position donneé, ou on cherche parmi tous les pavages possibles ceux qui sont légaux avec des dominos de Wang (carrés à bord colorés dont les couleurs de bords adjacents doivent coïncider) ou avec des polyminos (pentaminos, hexaminos, etc) donnés. Parfois, on complique le problème en introduisant une notion de coût, certaines solutions étant meilleures que d'autres, et on recherche alors la solution de meilleur coût. D'un point de vue abstrait, on peut énoncer le problème sous la forme suivante:
Soit E un ensemble fini. A chaque élément e de E, on affecte une valeur v(e) (en général, un entier positif), on se donne de plus un prédicat (une fonction à valeurs booléennes) C sur l'ensemble des parties de E. Le problème consiste à construire un sous ensemble F de E tel que:
Les méthodes développées pour résoudre ces problèmes sont de natures très diverses. Certaines font intervenir la spécificité du problème (par exemple la théorie des graphes pour trouver des chemins reliant deux sommets donnés dans un graphe). Ici nous ne considérerons que des méthodes générales, consistant à explorer tous les sous-ensembles de E, parfois avec des heuristiques particulières. Nous les rangerons en trois catégories: les méthodes gloutonnes, la programmation dynamique et les techniques d'exploration en force brute, encore appelée exploration arborescente (ou backtracking en anglais). L''exploration peut aussi être considérée comme un cas particulier de l'optimisation, quand la notion de coût intervient. Alors on est souvent face à un problème très célèbre et très étudié de l'informatique: la NP-complétude, que nous ne ferons qu'aborder dans ce chapitre.

4.1 Algorithmes gloutons

La technique gloutonne est une des plus rapides méthodes d'exploration. Elle consiste à choisir la solution parmi l'espace de celles possibles en fonction de critères purement locaux, sans besoin d'aucun retour en arrière (backtracking). Cela ne produit pas toujours la solution optimale. Mais, dans les trois cas considérés dans cette section, cela fonctionne très bien.

4.1.1 Affectation d'une ressource

Le problème décrit précisément ci-dessous peut être résolu par l'algorithme glouton (mais, comme on le verra, l'algorithme glouton ne donne pas la solution optimale pour une autre formulation du problème, pourtant proche de celle-ci). Il s'agit d'affecter une ressource unique, non partageable, successivement à un certain nombre d'utilisateurs qui en font la demande en précisant la période exacte pendant laquelle ils souhaitent en disposer.


Figure 4.1 : Un planning de réservation pour les locations d'une voiture


On peut matérialiser ceci en prenant pour illustration la location d'une seule voiture. Des clients formulent un ensemble de demandes de location et, pour chaque demande, sont donnés le jour du début de la location et le jour de restitution du véhicule. Le but est d'affecter le véhicule de façon à satisfaire le maximum de clients (et non pas de maximiser la somme des durées de location). On peut formuler ce problème en utilisant le cadre général considéré plus haut. L'ensemble E est celui des demandes de location, pour chaque élément e de E, on note d(e) et f(e) les dates de début et de fin de location (d(e) < f(e)). La valeur v(e) de tout élément e de E est égale à 1 et la contrainte à respecter pour le sous-ensemble F à construire est la suivante:
" e1,e2 Î F    d(e1) £ d(e2) Þ f(e1) £ d(e2)
puisque, disposant d'un seul véhicule, on ne peut le louer qu'à un seul client à la fois. Sur la figure 4.1, la solution optimale consiste à prendre les locations e1, e3, e5, e7. L'algorithme glouton suivant résoud ce problème:
Montrons que l'on obtient bien ainsi une solution optimale. Soit F = {x1, x2, ... xp} la solution obtenue par l'algorithme glouton et soit G = {y1, y2, ... yq} (q £ p) une solution optimale. Dans les deux cas, nous supposons que les demandes sont classées par dates de fins croissantes. Montrons que p=q. Prenons le premier xk différent de yk, c'est-à-dire xi = yi pour 1 £ i < k et xk ¹ yk. Alors, par construction de F, on a f(xk) £ f(yk). Et, on peut remplacer G par G' = {y1, y2, ... yk-1, xk,yk+1, ... yq} tout en satisfaisant à la contrainte de non chevauchement des demandes. Ainsi G', de même cardinalité que G, est aussi une solution optimale, ayant plus d'éléments en commun avec F que n'en avait G. En répétant cette opération, on trouve un ensemble H optimal qui contient F. Comme H est optimal, on a H = F, et donc p=q.

Le programme correspondant est donné ci-dessous. Les tableaux d et f sont donnés en arguments de la fonction location. Pour chaque indice i, ils produisent les dates d(ei) et f(ei). On supposera que f est déjà trié en ordre croissant. Le résultat est un tableau de booléens indiquant pour tout i si ei fait partie de la solution optimale.
static boolean[ ] location (int[ ] d, int[ ] f) {
  int n = d.length;
  boolean[ ] r = new boolean[n];
  int dernier = -1;
  for (int i = 0; i < n; ++i) {
    r[i] = dernier == -1 || f[dernier] <= d[i];
    dernier = r[i] ? i : dernier;
  }
  return r;
}

Si le but est de maximiser la durée totale de location du véhicule, l'algorithme glouton ne donne pas l'optimum. En particulier, il ne considérera pas comme prioritaire une demande de location de durée très importante. L'idée est alors de classer les demandes par durées décroissantes et d'appliquer l'algorithme glouton, malheureusement cette technique ne donne pas non plus le bon résultat (il suffit de considérer une demande de location de 3 jours et deux demandes qui ne se chevauchent pas mais qui sont incompatibles avec la première chacune de durée égale à 2 jours). Le problème de la maximisation de cette durée totale est NP-complet, il est illusoire de penser en trouver un algorithme simple et efficace.
Exercice 23   Montrer que, si on dispose de deux voitures à louer, l'algorithme glouton précédent, triant sur les dates de fin, ne donne pas la solution optimale.

4.1.2 La marche du cavalier

Dans le jeu d'échecs, les mouvements du cheval sont un peu bizarres. Un problème classique consiste à parcourir toutes les cases d'un échiquier avec un cheval à partir d'une position initiale donnée sans repasser deux fois par une même case. On peut montrer que c'est toujours possible (sur un échiquier standard de 64 cases). Une technique gloutonne permet d'écrire un programme marchant sur toutes les cases de départ, sauf une.

On démarre sur la position de départ donnée (c2 sur la figure 4.2 marquée par un cheval blanc). A toute étape, on choisit d'aller sur la case où, le coup suivant, on a le moins de coups possibles (un coup n'est possible que s'il ne passe pas par une case déjà rencontrée). Sur la figure, les 9 premiers coups sont montrés sur la partie gauche; la suite de coups est indiquée sur la partie droite, le coup 0 est en c2, le coup 1 en a1, le coup 2 en b3, etc. Au 10ème coup, on choisit d'aller en f8 où moins de coups sont possibles qu'en f6. Dans cette marche du cavalier, le choix du mouvement suivant ne prend en compte que le nombre de coups réalisables pour le coup qui suit immédiatement; c'est donc une technique gloutonne.


Figure 4.2 : Marche du cavalier sur un échiquier


Ceci aboutit au programme suivant. La fonction marche prend en paramètre la position de départ indiquée par ses coordonnées cartésiennes i et j (0 £ i < 8 et 0 £ j < 8). La fonction modifie le tableau m qui donne la suite des mouvements (comme sur la partie droite de la figure 4.2). On suppose ce tableau initialisé à la valeur négative LIBRE. Pour calculer les nombres de coups possibles, on utilise une fonction auxiliaire jouable testant si une position donnée par ses coordonnées cartésiennes est une position libre à l'intérieur de l'échiquier.
final static int LIBRE = -1;
final static int[ ] x = {2, 1, -1, -2, -2, -1,  1,  2};
final static int[ ] y = {1, 2,  2,  1, -1, -2, -2, -1};

static void marche (int[ ][ ] m, int i, int j) {
  int coup = -1; int i0, j0;
  do {
    m[i][j] = ++coup;
    i0 = i; j0 = j;
    int min = Integer.MAX_VALUE;
    for (int d = 0; d < x.length; ++d) {
      int n = nbDeCoups (m, i0+x[d], j0+y[d]);
      if (n < min) {
        i = i0+x[d]; j = j0+y[d];
        min = n;
      }
    }
  } while (i != i0 || j != j0);
}

static boolean jouable (int[ ][ ] m, int i, int j) {
  return 0 <= i && i < m.length && 0 <= j && j < m[0].length
         && m[i][j] == LIBRE;
}

static int nbDeCoups (int[ ][ ] m, int i, int j) {
  if ( !jouable (m, i, j) )
    return Integer.MAX_VALUE;
  else {
    int r = 0;
    for (int d = 0; d < x.length; ++d)
      if ( jouable (m, i+x[d], j+y[d]) )
        ++r;
    return r;
  }
}

Exercice 24   Montrer que ce programme fonctionne pour les 64 valeurs de la position de départ, sauf une. Trouver la position de départ problématique. Modifier le programme pour qu'il fonctionne aussi avec cette position de départ.

Cet algorithme ne marche plus pour de grands échiquiers (76 × 76). D'autres algorithmes fonctionnent alors par décomposition de l'échiquier en échiquiers plus petits.

4.1.3 Arbre recouvrant de poids minimal

Un exemple classique d'utilisation de l'algorithme glouton est la recherche d'un arbre recouvrant de poids minimal dans un graphe non dirigé. Plusieurs algorithmes existent: Kruskal, Prim; nous considérons le premier. Ce problème se pose dans la construction de réseaux hydrauliques, électriques ou informatiques. Le graphe correspond alors aux plans d'une ville, d'une maison ou d'un système de relais. Il s'agit de relier des maisons, des pièces ou des sites avec un réseau de coût minimal.

Formellement, on se donne un graphe valué G = (X, A, p) non-orienté et connexe. Pour chaque arc a de A, une fonction de valuation à valeur dans les entiers naturels donne son poids p(a). Comme auparavant, nous représentons le graphe non-orienté par un graphe orienté et symétrique (pour tout a Î A, il existe un arc opposé a' dont l'origine est l'extrémité de a et dont l'extrémité est l'origine de a). La paire (a, a') est une arête. Chaque arête a un poids, et donc a et a' ont même poids, c'est-à-dire p(a) = p(a') pour a Î A. On cherche un arbre recouvrant de poids minimal, c'est à dire un arbre dont la somme des poids des arcs est minimale.

On peut facilement formuler le problème dans le cadre général donné en début de chapitre. On prend pour E l'ensemble des arcs du graphe; la condition C à satisfaire par F est de former un graphe connexe; enfin il faut minimiser la somme des poids des éléments de F. Ce problème peut être résolu très efficacement par l'algorithme glouton suivant :
On montre que l'algorithme glouton donne l'arbre de poids minimal en utilisant la propriété suivante des arbres recouvrants d'un graphe.
Proposition 1   Soient T et U deux arbres recouvrants distincts d'un graphe G et soit a une arête de U qui n'est pas dans T. Alors il existe une arête b de T telle que U \ { a } È { b } soit aussi un arbre recouvrant de G.

Plus généralement on montre que l'algorithme glouton donne le résultat si et seulement si la propriété suivante est vérifiée par les sous ensembles F de E satisfaisant C:
Proposition 2   Si F et G sont deux ensembles qui satisfont la condition C et si x est un élément qui est dans F et qui n'est pas dans G, alors il existe un élément de G tel que F \ {x} È {y } satisfasse C.

Un exemple d'arbre recouvrant de poids minimal est donné sur la figure 4.3.


Figure 4.3 : Un arbre recouvrant de poids minimum


4.2 Exploration arborescente

De très nombreux problèmes d'optimisation ou de recherche de configurations particulières donnent lieu à un algorithme qui consiste à faire une recherche exhaustive des solutions. Ces algorithmes paraissent simples puisqu'il s'agit de parcourir systématiquement un ensemble de solutions, mais bien que leur principe ne soit pas particulièrement ingénieux, la programmation nécessite un certain soin.

4.2.1 Sac à dos

Soit E un ensemble d'objets ei ayant chacun un certain poids p(ei) (p(ei) Î R+). Soit M la charge maximum que l'on peut emporter dans le sac à dos (M Î N). On veut trouver un ensemble d'objets dont la somme des poids soit la plus voisine possible de M tout en lui étant inférieure ou égale. Le problème est ici formulé dans les termes généraux du début du chapitre, la condition C portant sur le poids du sac à ne pas dépasser. Souvent la formulation du problème du sac à dos fait intervenir deux fonctions: le poids et la valeur, ou encore le volume et le poids. Il s'agit alors d'avoir la valeur maximale avec une borne supérieure sur le poids, ou de minimiser le poids avec une borne supérieure sur le volume. Mais notre présentation simplifiée comporte le même degré de difficulté. Il faut toutefois bien faire attention à définir les valeurs des poids comme non entiers, car sinon un algorithme simple de programmation dynamique peut être réalisé (à condition que M ne soit pas trop grand).

Le problème du sac à dos est un exemple typique classique de problème (NP-complet) pour lequel aucun algorithme efficace n'est connu et où il faut explorer toutes les possibilités pour obtenir la meilleure solution.

Notons n le nombre d'éléments de E, le tableau de booléens sac indique pour tout i (0 £ i < n) si l'objet i est dans le sac. On énumère donc toutes les valeurs possibles de sac et on note le meilleur poids total possible ne dépassant pas la borne M. Le flottant meilleur mémorise la plus petite valeur trouvée pour la différence entre la capacité du sac et la somme des poids des objets qui s'y trouvent. Le tableau sacMax garde en mémoire le contenu du sac qui réalise ce minimum. Les arguments de calcul sont l'objet i à partir duquel on doit prendre des décisions, et la capacité u disponible restante.
static float meilleur = Float.MAX_VALUE;
static boolean sac = new boolean[n];
static boolean sacMax = new boolean[n];

static void calcul (int i, float u) {
  if (i >= n) {
    if (u < meilleur) {
      for (int j = 0; j < n; ++j)
 sacMax[j] = sac[j];
      meilleur = u;
    }
  } else {
    if  (p[i] <= u) {
      sac[i] = true;
      calcul(i + 1, u - p[i]);
      sac[i] = false;
    }
    calcul(i + 1, u);
  }
}
On vérifie sur des exemples que cette fonction donne des résultats assez rapidement pour n £ 20. Pour des valeurs plus grandes le temps mis est bien plus long car il croît comme 2n.

4.2.2 Les huit reines

Gauss en 1850 a inventé le problème suivant: placer huit reines sur un échiquier sans qu'aucune d'entre elles ne soit en prise par une autre. On peut en fait définir ce problème pour n reines sur un échiquier de taille n × n. Quand on essaie de le résoudre à la main, cela n'a rien d'évident. Deux solutions se trouvent sur la figure 4.4. On peut démontrer qu'il y a toujours une solution pour n > 3. Un algorithme simple pour en trouver au moins une fonctionne par recherche exhaustive en parcourant l'ensemble des configurations possibles. Pour les valeurs successives de i (0 £ i < 8), on place une reine sur la ligne i et sur une colonne j (0 £ i < 8) en vérifiant bien qu'elle n'est pas en prise. Le tableau pos contient les positions des reines sur chaque ligne. Si une reine figure sur la case de coordonnées i et j, on a pos[i] = j. Pour tester si deux reines placées sur les cases i1,j1 et i2, j2 sont en prise, on regarde si elles sont sur une même ligne (i1 = i2), ou sur une même colonne (j1 = j2), ou sur une même diagonale (|i1 - i2 | = |j1 -j2|).

La fonction qui trouve les solutions au problème des n reines est alors la suivante:

static int[ ] pos = new int[8];

static void reines (int i, int n) {
  if (i < n)
    for (int j = 0; j < n; ++j)
      if (compatible (i, j)) {
        pos[i] = j;
 reines (i+1, n);
      }
  else
    imprimerLaSolution(pos);
}

static boolean compatible (int i1, int j1) {
  for (int i2 = 0; i2 < i1; ++i2) {
    int j2 = pos[i2];
    if (i1 == i2 || j1 == j2 || Math.abs (i1 - i2) == Math.abs (j1 - j2))
      return false;
  }
  return true;
}


Figure 4.4 : Huit reines sur un échiquier


La boucle à l'intérieur de la fonction reines parcourt toutes les positions sur la ligne i compatibles avec les reines déjà placées sur les lignes 0, 1, ...i-1. Les appels successifs de reines modifient la valeur de pos. Quand on a parcouru toutes les lignes, on imprime la solution indiquée par les valeurs du tableau pos, et on continue pour chercher les solutions suivantes. La fonction reines affiche donc toutes les solutions possibles. On peut facilement la modifier pour s'arrêter dès que l'on a trouvé une solution. En lançant reines(0, 8), on trouve ainsi toutes les solutions pour un échiquier 8 × 8. Sur un ordinateur classique, cela prend quelques minutes. Avec des langages plus sophistiqués comme GNU-Prolog, on arrive à trouver toutes les solutions pour n=200. En fait, il existe une formule analytique donnant une solution pour tout n (n > 3).
Exercice 25   Trouver le nombre de solutions au problème des huit reines pour un échiquier de taille 8 × 8.

Dans les deux exemples donnés plus haut, toute la difficulté réside dans le parcours de toutes les solutions possibles, sans en oublier et sans revenir plusieurs fois sur la même. On peut noter que l'ensemble de ces solutions peut être vu comme les sommets d'une arborescence qu'il faut parcourir. La différence avec les algorithmes décrits au chapitre 5 est que l'on ne représente pas cette arborescence en totalité en mémoire mais simplement la partie sur laquelle on se trouve.

4.3 Programmation dynamique

La programmation dynamique consiste à tabuler des résultats intermédiaires pour éviter de recalculer un résultat déjà rencontré. En outre, elle est souvent associée à des problèmes d'optimisation, comme l'avait énoncé son promoteur R. Bellman. Pour résoudre un problème avec de la programmation dynamique, souvent on le généralise, et il devient un des résultats intermédiaires nécessaires à son propre calcul. La tabulation permet d'en accélérer son calcul. Par exemple, considérons le cas de la fonction de Fibonacci. Récursivement elle est définie par
int fib (int n) {
  if (n < 2) return n; else return fib (n-2) + fib (n-1);
}

On supprime la duplication des calculs intermédiaires en écrivant:
int fib (int n) {
  int[ ] tab = new int[n+1];
  tab[0] = 0; tab[1] = 1;
  for (int i = 2; i <= n; ++i)
    tab[i] = tab[i-2] + tab[i-1];
  return t[n];
}

Des esprits chagrins remarqueront que deux valeurs entières suffisent plutot que les n valeurs stockés dans le tableau tab, pour gagner 18 cases de la mémoire dans le calcul de fib(20) qui fait déjà peiner notre ordinateur! La grosse différence vient de la vitesse en O(n) au lieu de O(2n). Le principe de la programmation dynamique est là: les résultats intermédiaires ont été tabulés, en nombre pas trop élevé, mais suffisamment pour obtenir le résultat final. Dans les techniques d'exploration, beaucoup de cas relèvent de la programmation dynamique.

4.3.1 Plus courts chemins dans un graphe

On considère un graphe valué G= (X,A,l) ayant X comme ensemble de sommets et A comme ensemble d'arcs. On se donne une application l de A dans les entiers naturels. La valeur l(a) est la longueur de l'arc a. La longueur d'un chemin est égale à la somme des longueurs des arcs qui le composent. Le problème consiste à déterminer, pour chaque couple (xi, xj) de sommets, le plus court chemin, s'il existe, qui joint xi à xj.

Nous commençons par trouver les longueurs des plus courts chemins notées d(xi, xj); on convient de noter d(xi, xj)= ¥ s'il n'existe pas de chemin entre xi et xj. La construction effective des chemins sera examinée ensuite. On suppose qu'entre deux sommets il y a au plus un arc. Les algorithmes de recherche de chemins les plus courts reposent sur l'observation suivante:
Proposition 3   Si f est un chemin de longueur minimale joignant x à y et qui passe par z, alors il se décompose en deux chemins de longueur minimale l'un qui joint x à z et l'autre qui joint z à y.

Dans la suite, on suppose les sommets numérotés x1, x2, ... xn et, pour tout k>0 on considère la propriété Pk suivante pour un chemin:
(Pk(f))   Tous les sommets de f, autres que son origine et son extrémité, ont un indice strictement inférieur à k.
On peut remarquer qu'un chemin vérifie P1 si et seulement s'il se compose d'un unique arc, d'autre part la condition Pn+1 est satisfaite par tous les chemins du graphe. Notons dk (xi,xj) la longueur du plus court chemin qui vérifie Pk et qui a pour origine xi et pour extrémité xj. Cette valeur est ¥ si aucun tel chemin n'existe. Ainsi d1(xi,xj)= ¥ s'il n'y a pas d'arc entre xi et xj et vaut l(a) si a est cet arc. D'autre part dn+1 = d. Le lemme suivant permet de calculer les dk+1 connaissant les dk (xi,xj). On en déduira un algorithme itératif.
Proposition 4   Les relations suivantes sont satisfaites par les dk:
dk+1(xi, xj) = min(dk(xi,xj), dk(xi,xk) +dk(xk,xj))

Démonstration  Soit un chemin de longueur minimale satisfaisant Pk+1, ou bien il ne passe pas par xk et on a dk+1(xi, xj) = dk(xi,xj) ou bien il passe par xk et, d'après la remarque préliminaire, il est composé d'un chemin de longueur minimale joignant xi à xk et satisfaisant Pk et d'un autre minimal aussi joignant xk à xj. (On ne peut passer plus d'une fois par xk puisque les longueurs sont toutes positives). Il a donc pour longueur: dk(xi,xk) +dk(xk,xj).

L'algorithme suivant pour la recherche du plus court chemin met à jour une matrice d, valant initialement les longueurs des arcs et ¥ (Integer.MAX_VALUE) s'il n'y a pas d'arc entre xi et xj. A chaque itération de la boucle externe, on fait croître l'indice k du dk calculé.
static void distancesMinimales (GrapheMatVal g) {
  int n = g.d.length;
  for (k = 0; k < n; ++k)
    for (i = 0; i < n; ++i)
      for (j = 1; j < n; ++j)
 g.d[i][j] = Math.min(g.d[i][j], g.d[i][k] + g.d[k][j]);
}

La classe GrapheMatVal ressemble à la classe GrapheMat considérée dans le chapitre sur les graphes pour la représentation avec des matrices d'adjacence. Dans cette nouvelle classe, la matrice est à présent une matrice de distances d = d1. Dans le programme précédent, on remarque la similitude avec l'algorithme de Warshall pour trouver la fermeture transitive d'un graphe. Sur l'exemple du graphe donné sur la figure 4.5, la matrice de départ d1 et le résultat final d sont:
d1= æ
ç
ç
ç
ç
ç
ç
ç
è
0   1   ¥   4   ¥   ¥   ¥
¥   0   3   2   ¥   ¥   ¥
¥   ¥   0   ¥   2   ¥   ¥
¥   ¥   ¥   0   2   ¥   6
¥   3   ¥   ¥   0   1   ¥
¥   ¥   ¥   2   ¥   0   1
4   ¥   ¥   ¥   ¥   ¥   0
ö
÷
÷
÷
÷
÷
÷
÷
ø
     d = æ
ç
ç
ç
ç
ç
ç
ç
è
0 1 4 3 5 6 7
10 0 3 2 4 5 6
8 5 0 5 2 3 4
8 5 8 0 2 3 4
6 3 6 3 0 1 2
5 6 9 2 4 0 1
4 5 8 7 9 10 0
ö
÷
÷
÷
÷
÷
÷
÷
ø


Figure 4.5 : Un graphe aux arcs valués


Pour produire les chemins les plus courts entre les sommets i et j, on utilise une matrice S qui donne pour tout i et j, le sommet qui suit i dans le chemin le plus court de i à j. Au début, la valeur S1 de cette matrice contient les éléments S1(i,j) valant j s'il existe un arc de i à j ou si i=j. Sinon ses éléments prennent la valeur indéfinie ^.

Sur l'exemple précédent, on trouve:
S1 = æ
ç
ç
ç
ç
ç
ç
ç
è
0   1   ^   3   ^   ^   ^
^   1   2   3   ^   ^   ^
^   ^   2   ^   4   ^   ^
^   ^   ^   3   4   ^   6
^   1   ^   ^   4   5   ^
^   ^   ^   3   ^   5   6
0   ^   ^   ^   ^   ^   6
ö
÷
÷
÷
÷
÷
÷
÷
ø
     S = æ
ç
ç
ç
ç
ç
ç
ç
è
1 2 2 2 2 2 2
4 2 3 4 4 4 4
5 5 3 5 5 5 5
5 5 5 4 5 5 5
6 2 2 6 5 6 6
7 7 7 4 4 6 7
1 1 1 1 1 1 7
ö
÷
÷
÷
÷
÷
÷
÷
ø

Le calcul de la matrice S et donc du chemin le plus court entre deux sommets i et j se font par les fonctions suivantes:
final static int INDEFINI = -1;

static int[ ][ ] sommetSuivant (GrapheMatVal g) {
  int n = g.d.length;
  int[ ][ ] s = new int[n][n];
  for (i = 0; i < n; ++i)
    for (j = 0; j < n; ++j)
      s[i][j] = (g.d[i][j] != Integer.MAX_VALUE) ? j : INDEFINI;
  for (k = 0; k < n; ++k)
    for (i = 0; i < n; ++i)
      for (j = 0; j < n; ++j)
        if (g.d[i][j] > (g.d[i][k] + g.d[k][j]) {
          g.d[i][j] =  g.d[i][k] + g.d[k][j];
          s[i][j] = s[i][k];
        }
  return s;
}

static void plusCourtChemin (GrapheMatVal g, int i, int j) {
  distancesMinimales (g);
  int[ ][ ] s = sommetSuivant (g);
  for (int k = i; k != j; k = s[k][j])
    if (k == INDEFINI)
      throw new Error ("Chemin inexistant");
    System.out.print (k + " ");
  System.out.println (j);
}

4.3.2 Sous-séquences communes

Le problème est de retrouver la plus grande sous-séquence commune à deux chaînes de caractères, ou encore à deux mots sur un alphabet. Ce problème se pose, par exemple, dans la commande diff du système Unix, qui affiche les différences ``minimales `` entre deux fichiers. Dans diff, on fait la différence sur les lignes, mais le coeur de la commande utilise un algorithme analogue à celui que nous voyons dans cette section. De même, pour comparer des chaînes d'ADN, on doit aussi retrouver les plus longues séquences communes.

Une séquence (ou un mot) est une suite finie de symboles (ou lettres) pris dans un ensemble fini (ou alphabet). La longueur de la séquence u est notée |u|, et e dénote la séquence vide (de longueur nulle). Une séquence v=b1b2··· bm est une sous-séquence de u=a1a2··· an s'il existe des entiers i1,i2, ...im (0 £ i1 < i2 <··· im£ n) tels que aik=bk (1 £ k £ m). Une séquence w est une sous-séquence commune aux séquences u et v si w est sous-séquence de u et de v. Une sous-séquence commune est maximale si elle est de longueur maximale.



La plus grande sous-séquence commune ssc(u,v) entre deux séquences u et v est la séquence donnée par la définition récursive suivante:
ssc(u, e)   =   ssc(e, u) = e
ssc(ua, va)   =   ssc(u,v)a
ssc(ua, vb)   =  
ì
í
î
ssc(ua, v)    si  |ssc(ua, v)| ³ |ssc(u, vb)|
ssc(u, vb)    sinon


Figure 4.6 : Plus longue sous-séquence commune entre bacb et aaac


En effet, soit w une sous-séquence, de longueur maximale, commune à a1a2··· ai-1 et b1b2··· bj-1. Si ai = bj, alors wai est une sous-séquence commune maximale à a1a2··· ai et b1b2··· bj. Si ai ¹ bj, une sous-séquence commune à a1a2··· ai et b1b2··· bj est ou bien commune à a1a2··· ai et b1b2··· bj-1, ou bien commune à a1a2··· ai-1 et b1b2··· bj, en choisissant le cas donnant la sous-séquence de plus grande longueur.

Sur l'exemple de la figure 4.6, cela est illustré dans le cas où u = bacb et v = aaac. Le premier mot est représenté verticalement, le deuxième horizontalement. Pour chaque caractère de l'un (à la position i) et de l'autre (à la position j), on obtient la plus longue sous-séquence commune à a1a2··· ai et b1b2··· bj en suivant les flèches de la table pred. La flèche diagonale correspond à la deuxième ligne des équations vérifiées par ssc; la flèche vers la gauche correspond à la troisième ligne, la flèche vers le haut à la quatrième.

La longueur de la plus grande sous-séquence commune à a1a2 ··· ai et b1b2··· bj est donnée récursivement par:
L(i,j)= ì
í
î
1 + L(i-1,j-1) si   ai = bj
max(L(i,j-1),L(i-1,j)) sinon

On en déduit les fonctions suivantes pour le calcul de la plus longue sous-séquence commune. Le résultat final se fait en remontant le tableau pred à partir de l'extrémité en bas et à droite.
final static int GAUCHE = 1, HAUT = 2, DIAG = 3;

static int longueurSSC (String u, String v, int[ ][ ] pred) {
  int m = u.length(), n = v.length();
  int[ ][ ] longueur = new int[m][n];
  for (int i = 1; i < m + 1; ++i)
    for (int j = 1; j < n + 1; ++j)
      if (u.charAt(i-1) == v.charAt(j-1)) {
        pred[i][j] = DIAG;
 longueur[i][j] = 1 + longueur[i-1][j-1];
      } else if longueur[i][j-1] > longueur[i-1][j] {
        pred[i][j] = GAUCHE;
 longueur[i][j] = longueur[i][j-1];
      } else {
        pred[i][j] = HAUT;
 longueur[i][j] = longueur[i-1][j];
      }
  return longueur[m][n];
}

static String ssc (String u, String v) {
  int m = u.length(), m = v.length();
  int[ ][ ] pred = new int[m][n];
  int lg = longueurSSC (u, v, pred);
  StringBuffer r = new StringBuffer(lg);
  int i = m, j = n;
  for (int k = lg-1; k >= 0; )
      switch (pred[i][j]) {
      case DIAG: r.setCharAt(k, u.charAt(i-1));
         --i; --j; --k; break;
      case GAUCHE: --j; break;
      case HAUT: --i; break;
      }
  return new String(r);
}

4.4 Programmes en OCaml

(* les n reines, voir page ?? *)

let nReines n =
  let pos = Array.make n 0 in

  let conflit i1 j1 i2 j2 =
    i1 = i2 || j1 = j2 ||
    abs(i1 - i2) = abs (j1 - j2) in

  let compatible i j =
    try
      for k = 0 to i-1 do
        if conflit i j k pos.(k) then
           raise Exit
      done;
      true
    with Exit -> false in

  let rec reines i =
    if i >= n then
      imprimerSolution pos
    else
      for j = 0 to n-1 do
        if compatible i j then begin
          pos.(i) <- j;
          reines (i+1);
        end
      done in

  reines 0;;



(* les n reines (suite) *)
open Printf;;

let imprimerSolution pos =
  let n = Array.length(pos) in
  for i = 0 to n-1 do
    for j = 0 to n-1 do
      if j = pos.(i) then
        printf "* "
      else
        printf "  "
     done;
     printf "\n";
   done;
   printf "----------------------\n";;



(* les sous séquences communes *)
let longueur_ssc u v =
   let n = String.length u and
       m = String.length v in
   let longueur = Array.make_matrix (n+1) (m+1) 0 and
       provient = Array.make_matrix (n+1) (m+1) 0 in
   for i=1 to n do
     for j=1 to m do
       if u.[i-1] = v.[j-1] then begin
         longueur.(i).(j) <- 1 + longueur.(i-1).(j-1);
         provient.(i).(j) <- 1;
       end else
       if longueur.(i).(j-1) > longueur.(i-1).(j) then begin
         longueur.(i).(j) <- longueur.(i).(j-1);
         provient.(i).(j) <- 2;
       end else begin
         longueur.(i).(j) <- longueur.(i-1).(j);
         provient.(i).(j) <- 3;
       end
     done
   done;
   longueur, provient;;

let ssc u v =
  let n = String.length u and
      m = String.length v in
  let longueur, provient = longueur_ssc u v in
  let lg = longueur.(n).(m) in
  let res = String.create lg and
      i = ref n and
      j = ref m and
      k = ref (lg-1) in
  while !k >= 0 do
    match provient.(!i).(!j) with
      1 -> res.[!k] <- u.[!i-1]; decr i; decr j; decr k
    | 2 -> decr j
    | _ -> decr i
  done;
  res;;


Précédent Remonter Suivant