Professional Documents
Culture Documents
notes de cours
G. Falquet, 1999
2 Chaines de caractères
5 Arbres
6 Graphes
8 Algorithmique
9 Données persistantes
● mémoire centrale: sert d'une part à stocker le programme à exécuter et d'autre part à stocker les
données à traiter
● mémoires auxiliaires: ces mémoires, de type disque dur, disque optique, etc.servent
essentiellement à augmenter la capacité de la mémoire centrale, mais également à assurer la
permanence des données et éventuellement leur transmission (par transport physique des disques).
● systèmes de communication (entrées/sorites): assurent la transmission d'informations entre
l'ordinateur et son environnement, qui est en général soit un être humain (transmission par écran,
claviers, souris, etc.) soit d'autres ordinateurs (transmission à travers un réseau de
télécommunication) soit des appareils (moteurs, capteurs, etc.).
On retrouve une structure similaire dans les machines abstraites telles que les machines de Turing ou les
machines RAM (Random Acccess Machine) qui servent à étudier, d'un point de vue théorique, la notion
d'algorithme, de calculabilité, etc.
Nous considérerons donc qu'un ordinateur est un automate capable de manipuler des symboles (par
exemple des 0 et des 1) dans une mémoire.
Structure de la mémoire
Pour qu'un ordinateur puisse traiter une information il faut que celle-ci soit stockée, sous une forme
codée dans la mémoire. On parlera alors de donnée.
La mémoire possède pratiquement toujours une structure composée d'un ensemble de cellules toutes
identiques et repérées par un numéro appelé adresse. Chaque cellule est elle-même composée d'un
nombre fixe d'unités, appelées bits, que l'on peut mettre dans deux états différents généralement désignés
par 0 et 1. Si les cellules d'une mémoire sont composées de 8 bits (ce qui est un cas fréquent), le contenu
d'une cellule sera une suite de 8 valeurs binaires, par exemple, 10011010. Un groupe de 8 bits est appelé
octet (ou byte en anglais).
Pour des raisons d'efficacité "électronique", les octes de la mémoire sont en général groupés pour former
des mots de 16, 32 ou 64 bits.
Processeur et mémoire
Le schéma général de fonctionnement du processeur consiste à
1. "lire" le contenu des cellules mémoires contenant les données à traiter et les stocker
temporairement dans ses cellules mémoire locales appelées registres
Nombres entiers de 0 à 2 n
Pour écrire un entier inférieur à 2 n il faut n chiffres binaires. Si une cellule mémoire est composée de n
bits, elle peut contenir la représentation binaire de n'importe quel entier compris entre 0 et 2 n - 1. P.ex.
dans un cellule de 8 bits le nombre décimal 23, qui s'écrit 10111 en binaire, sera représenté par la
configuration de bits [00010111].
Pour stocker un nombre plus grand que 2 n on utilisera un nombre suffisant de cellules adjacentes. P.ex.
pour des nombres compris entre 0 et 2'000'000'000 il faut 4 cellules de 8 bits.
(b8) b7 b6 b5 b4 b3 b2 b1 b0
1 0 0 0 0 0 0 0 0
- 0 0 0 0 0 1 1 0
=0 1 1 1 1 1 0 1 0
L'opération de division doit également être redéfinie de manière à fournir un nombre entier comme
résultat. En général il s'agit de l'entier inférieur le plus proche. Par exemple: 7/3 = 2 ou 39/20 = 1. La
division par 0 est considérée comme une erreur.
Selon cette norme, une séquence de bits [s e 7 e 6 ... e 0 m 23 m 22 ...m 1 m 0 ] représente le nombre
Exemple
a) [1 10000000 010010000000000000000000 ] représente
- 1.01001 ¥ 2 10000000-127 = -(1 + 1/2 2 + 1/2 5 ) ¥ 2 128-127 = -(2 + 1/2 + 1/2 4 ) = 2.5625
b) Représentation de 1/3
1/3 = 0.010101010101010101010... en binaire
= 1.01010101010101010... ¥ 2 -2
donc:
signe: 0
exposant: 127-2 = 125 = 01111101 en binaire
mantisse: 01010101010101010101010
Le nombre 1/3 est donc représenté par la séquence de 32 bits
[0 01111101 01010101010101010101010]
Le plus petit nombre strictement positif que l'on peut représenter est
en simple précision: 1.4 x 10 -45
en double précision: 4.9 x 10 -324
Le plus grand nombre positif que l'on peut représenter est
en simple précision: 3.4 x 10 38
en double précision: 1.8 x 10 308
De plus, des configuration de bits réservées à cet effet permettent de représenter l'infini positif, l'infini
négatif (résultats de divisions par 0, p.ex.), ainsi que l'indéterminé (0/0).
Précision de la représentation
La précision de la représentation est l'erreur que l'on peut commt en représentant un nombre.
Par exemple, la représentation de 1/3, ci-dessus, néglige les chiffres après la 23 ème position. L'erreur est
donc 0.0....(23 fois 0) ...01010101010 ¥ 2 -2 @ 1.5 ¥ 10 -8 .
Si l'on prend le cas des nombres flottants en simple précisions, la plus grande erreur arrive lorsque les
chiffres négligés sont tous des 1. Dans ce cas, pour un nombre d'exposant exp, l'erreur est:
0.0...(23 fois)...011111111... ¥ 2 exp-127 = 2 -23 ¥ 2 exp-127 .
Le rapport entre l'erreur et le nombre représenté est donc :
(2 -23 ¥ 2 exp-127 ) / (1.m 23 m 22 ... m 1 m 0 ¥ 2 exp-127 ) £ 2 -23 @ 10 -7
Cela signifie que l'erreur relative maximum pour la représentation d'un nombre réel en simple précision
est inférieure à 10 -7 ; en d'autres termes, les 7 premiers chiffres sont représentés correctement.
Dans le cas de la double précision l'erreur relative est inférieure à 2 -52 @ 10 -16 .
[Remonter] [Precedent] [ Suivant]
On utilise l'algorithme suivant pour calculer une approximation de ex en prenant les 20 premier termes de
la série de Taylor:
1. t <-- 1; e <-- 1;
2. pour n de 1 à 20 {
3. t <-- t * x / n
4. e <-- e + t
5. }
En programmant cet algorithme en Java avec des nombres de type float on obtient les résultats suivants:
x e x calculé e x exact
-1 0.367879 0.367879...
-5 0.00670682 0.0067379...
-10 -27.7064 0.0000454...
-20 -2.1866 10 7 2.0612 10 -9
La branche des mathématique qui s'intéresse aux algorithmes numériques et aux problèmes de précision
de calculs s'appelle l' analyse numérique . Les travaux en analyse numétique on mis en évidence deux
notions fondamentales que nous allons présenter brièvement ci-dessous.
Problèmes mal conditionnés
Il existe des problèmes pour lesquels on ne peut trouver de méthodes de calcul qui donnent des résultats
précis (sauf en utilisant des nombres de taille illimitée !).
Exemple
On souhaite calculer
donc a +/- (a 2 - b) 1/2 = 1000 +/- 0.01 1/2 = 1000.1 et 999.9 alors que les vraies valeurs sont
1000.111111... et 999.888888. Il ne reste donc que 4 à 5 chiffres corrects. Ceci vient de la soustraction
qui nous a fait perdre beaucoup de précision. C'est un exemple de problème mal conditionné.
Un problème consistant à calculer y = F(x) est mal conditionné si une petite variation de la valeur de x
entraine une grande variation de y. Le nombre de condition C d'un problème y = F(x), pour une valeur b
du paramètre x est défini comme
Stabilité numérique
Le fait qu'un problème soit bien conditionné ne veut pas dire que tout algorithme donnera une réponse
précise. On dira qu'un algorithme est numériquement stable si la valeur F calc (b) calculée par l'algorithme
(à la place de la vraie valeur F(b)) est la solution d'un problème proche. C'est à dire que F calc (b) = F(b +
e ) avec e petit.
L'algorithme de calcul de F(b) = e b que nous avons présenté plus haut n'est pas stable, en effet: pour b =
-10 il donne F calc (b) = -27.7064, or il n'existe pas d' e , même assez grand, tel que
e -10+e = -27.7064.
Cet algorithme est par contre stable pour la valeurs de b comprises entre -1 et +1. On en déduit un nouvel
algorithme:
Soit y la partie entière de x et z = x-y
donc e x = e y+z = e y e z
1. calculer e y par multiplications (et inversion à la fin si y < 0)
2. calculer e z par l'algorithme précédent
3. multiplier les deux résultats
Cette brève incursion dans l'analyse numérique avait essentiellement pour but de montrer que
l'arithmétique des nombres flottants ne doit surtout pas être assimilée à l'arithmétique des nombres réels
"intuitifs" ou tels qu'ils sont définis en analyse mathématique. Il n'y a pas de correspondance bi-univoque
entre l'arithmétique en virgule flottante des machines et celle du corps R des réels.
Dans d'autres langages la sémantique peut être différente. On peut par exemple avoir une erreur à
l'exécution si un dépassement de capacité se produit.
Il est utile de rappeler ici que l'échec du premier tir de la fusée Ariane 5 a été causé par une erreur du
système informatique de guidage. Cette erreur est survenue lors d'une conversion de type qui a causé un
dépassement de capacité d'une variable. Parmi les recommandations émises suite à cet accident on notera
:
Identifier toutes les hypothèses implicites faites par le code et ses documents de justification sur les
paramètres fournis par l'équipement. Vérifier ces hypothèses au regard des restrictions d'utilisation de
l'équipement.
Vérifier la plage des valeurs prises dans les logiciels par l'une quelconque des variables internes ou de
communication.
[Remonter] [Precedent] [ Suivant]
D'autre part il faut distinguer le caractère (la valeur d'un code) et son affichage sur l'écran ou sur le
papier. On a donc une notion de caractère abstrait , par exemple :
● "LATIN CHARACTER CAPITAL A"
et une notion de glyph qui est la marque faite sur un écran ou sur papier pour représenter visuellement un
caractère, par exemple
● AAAAA
sont des glyphs qui représentent le caractère "LATIN CHARACTER CAPITAL A". Unicode ne definit
pas les glyphs et ne spécifie donc pas la taille, forme, orientation des caractères sur l'écran.
Il existe également une notion de caractères composites (p.ex. â) qui est formé de
● une lettre de base (qui occupe un espace) "a"
Unicode spécifie
● l'ordre des caractères pour créer un composite
● "ü" peut être encodé par le code U+00FC 1 (un seul caractère de 16-bits)
1. La notation U+dddd signifie qu'il faut lire le nombre dddd comme un code Unicode en base 16.
7 Digit value
8 Numeric value
9 "mirrored" (pour l'écritures bidirectionnelles)
10 Unicode 1.0 Name
11 10646 Comment field
12 Upper case equivalent mapping
13 Lower case equivalent mapping
14 Title case equivalent mapping
Quelques exemples tirés de la base ftp://ftp.unicode.org/Public/2.1-Update3/UnicodeData-2.1.8.txt :
0041;LATIN CAPITAL LETTER A ;Lu;0;L;;;;;N;;;;0061;
005E;CIRCUMFLEX ACCENT;Sk;0;ON;<compat> 0020 0302;;;;N;SPACING
CIRCUMFLEX;;;;
0F19;TIBETAN ASTROLOGICAL SIGN SDONG TSHUGS; Mn;220;ON;;;;;N;;dong
tsu;;;
112C;HANGUL CHOSEONG KAPYEOUNSSANGPIEUP; Lo;0;L;<compat> 1107 1107
110B;;;;N;;;;;
1EE4;LATIN CAPITAL LETTER U WITH DOT BELOW; Lu;0;L;0055
0323;;;;N;;;;1EE5;
FC64;ARABIC LIGATURE YEH WITH HAMZA ABOVE WITH REH FINAL
FORM;Lo;0; R;<final> 0626 0631; ;;;N;;;;;
Formes d'encodage
Il faut faire la différence entre la définition des codes Unicode pour la représentation des caractères et la
manière dont ces caractères sont stockés sur les supports physiques (p.ex. dans des fichiers). Il existe
deux modes principaux d'encodage :
UTF-16
● caractères 16-bits
UTF-8
● codage à longueur variable
● etc.
Si l'on prend le standard Unicode, ces opérations nécessitent une consultation de la base de données des
caractères ainsi que l'application de règles de compositions non triviales.
2 Chaines de caractères
2.1 Vision abstraite des chaînes
Le type de données «chaîne de caractères» est formé de l'ensemble de toutes les séquences possibles (y
compris la séquence vide <>).
Sur la base de cette définition on peut définir les principales opérations sur les séquences
● concaténation
● <c 1 , ..., c n > + <d 1 , ..., d k > Æ <c 1 , ..., c n , d 1 , ..., d k >
● élément à la position i
● <c 1 , ..., c n > Æ c i
● modifier l'élément à la position i
● <c 1 , ..., c i , ..., c n > Æ <c 1 , ..., c i ', ..., c n >
● insérer/retirer à la position i
● <c 1 , ..., c i , ..., c n > Æ <c 1 , ..., c i , d, c i+1 ,..., c n >
● <c 1 , ..., c i , ..., c n > Æ <c 1 , ..., c i-1 , c i+1 ,..., c n >
"swing" = "swing"
"swing" "Swing"
"mel" "mél"
Dans certaines applications cette notion d'égalité est cependant trop stricte. On peut lui préférer l'égalité
par degré définie comme
<c 1 , ..., c n > = <d 1 , ..., d k > si
● n=k
● c i = degré d i (i = 1, n)
Donc
"xyZot" £ "xyaot" ("Z" < "a" dans le code Unicode ou ISO ou ASCII)
"truc" £ "truc " (et "truc" "truc ")
"1205" £ "205"
On peut également instaurer des degrés dans la comparaison en considérant l'égalité par degré des
caractères. La relation < n'est plus définie entre caractères mais entre groupes de caractères. Par exemple
{"A", "a", "à", "À"} < {"b", "B"} < {"c", "C", "ç", "Ç"} < {"d", "D"} < {"E", "È", "É", "Ê", "e", "è", "é",
"ê"} < ... Ce qui donnerait
"patate" < "pâté" mais
"pâté" = "pâte" = "Pate"
1. (s + t) + u = s + (t + u) (associativité)
2. (s + "") = s = "" + s (la chaîne vide "" est l'élément neutre)
Il est possible de construire toutes les chaînes de S par concaténation à partir des chaines primitives
formées d'un seul caractère :
"A" "B" ... "Z" "a" ... "z" ...
On peut également donner une définition algébrique des autres opérations en se basant sur des équations.
longueur
● longueur("") = 0
● longueur("c") = 1
element no. i
● element(i, s+t)
● = element(i, s) si longueur(s) i
Nous reviendrons sur cette définition algébrique lorsque nous parlerons de la spécification abstraite des
types de données.
[Remonter] [Precedent] [ Suivant]
Parmi ces questions on trouve le choix entre une représentation immuable ou mutable.
Représentation immuable
Dans ce qui suit nous utilisons le terme objet pour désigner aussi bien un objet d'un langage OO qu'une
zone de la mémoire (tableau, structure, etc.) d'un langage non OO.
Dans une représentation immuable, une chaîne est un objet qui ne peut changer de valeur au cours du
temps. Par conséquent les résultat d'une opération, p.ex. de concaténation, est toujours un nouvel objet.
Par exemple :
objet 1 ("cos") + objet 2 ("mos") Æ objet 3 ("cosmos")
Représentation mutable
Une représentation mutable signifie que l'objet qui représente une chaîne peut changer de valeur au cours
du temps, suite à des opéréations. On aura par exemple :
objet 1 ("cos") .ajoute( objet 2 ("mos") ) Æ objet 1 ("cosmos")
Allocation et extensibilité
En général un objet n'est pas extensible «sur place» ; si l'objet a besoin de plus de place on ne peut pas
agrandir sa zone mémoire sans entrer en conflit avec les zones attribuées à d'autres objets. Il faut donc
trouver d'autres stratégies pour étendre les objets. On a deux types de stratégies qui consistent à
● allouer une nouvelle zone mémoire suffisament grande et recopier l'objet dans cette zone (stratégie
contigüe) ;
● allouer une ou des autres zones et répartir l'objet (les sous-objets) dans ces zones, l'intégrité de
l'objet étant assurée par des références (pointeurs) entres zones (stratégie non contigüe).
A partir de ces stratégies de bases on peut imaginer toutes sortes de variantes, comme, par exemple :
allouer une zone plus grande que nécessaire en prévision des extensions futures ; utiliser une stratégie
non contigüe mais «compacter» l'objet de temps en temps, etc.
La performance de ces stratégies dépend énormément du type d'opération qu'on effectue sur les objets. Il
n'est donc pas possible de déterminer une stratégie optimale.
Nous reviendrons en détail sur ce point dans le chapitre sur la représentation des collections.
3.1 Motivation
Considérons un programme qui doit traiter deux types de données: des dates et des poids. Une donnée de
chacun de ces type peut s'exprimer par un nombre entier:
● le nombre de jours écoulés entre le 1.1.1800 et cette date;
Si ces deux types utilisent bien le même domaine de valeurs, ils diffèrent cependant sur les opérations
appicables à ces valeurs. En effet, s'il est légitime de soustraire deux dates pour trouver le nombre de
jours qui les séparent, on imagine difficilement l'intérêt d'additionnner deux dates. Par contre on peut
bien calculer la différence entre deux poids ou la somme de deux poids. Pour le type date on pourra avoir
une opération jour-de-la-semaine qui donne comme résultat l'une des chaînes de caractères "dimanche",
"lundi", "mardi", etc. suivant le jour de la semaine correspondant à cette date. Une telle opération n'a
évidemment pas de sens avec un poids.
Il est donc normal de considérer qu'un type de données n'est pas seulement un ensemble de valeurs mais
un ensemble de valeurs muni d'un ensemble d'opérations (de même qu'en mathématiques un groupe est
un ensemble muni d'une opération, un anneau est un ensemble muni de deux opérations, etc.).
On peut même aller plus loin en disant qu'il suffit pour décrire un type de données, de décrire très
précisément ses opérations, sans donner explicitement l'ensemble de ses valeurs. On parle dans ce cas de
type abstrait. L'avantage de la définition abstraite est qu'elle se concentre sur ce qu'on peut faire avec des
objets de ce type, indépendamment de la manière dont ces objets sont représentés. Par exemple, dans la
définition abstraite d'un type date on n'a pas besoin de spécifier si les dates sont représentées par des
entiers, des triplets (jour, mois, année) ou des chaînes de caractères. Ce n'est qu'au moment de
l'implémentation concrète que l'on choisira une représentation en fonction de critères tels que la
performance, l'économie de place mémoire, la simplicité de programmation, etc.
[Remonter] [Precedent] [ Suivant]
Disque
surface : Disque Æ Nombre
rayon : Disque Æ Nombre
définir un disque : Nombre, Nombre, Nombre Æ Disque
coord x du centre : Disque Æ Nombre
coord y du centre : Disque Æ Nombre
Quelques équations
coord x du centre(définir un disque(u, v, r)) = u
Une expression construite à l'aide des opérations et de variables et qui respecte la signature est appelée
un terme . Par exemple:
zero
est-pair(zero)
addition(Y, addition(X, zero))
Parmi les opérations certaines sont appelées génératrices , ce sont celles qui serviront à construire les
valeurs d'une sorte. Par exemple, l'opération "sucesseur" permet de construire tous les entiers à partir de
la constante zéro.
Les axiomes décrivent les propriétés des opérations sous forme d'équivalences entre termes. Par exemple
addition(X, Y) == addition(Y, X)
signifie que pour toute valeur des variables X et Y, si l'on change l'ordre des paramètres de l'opération
d'addition on obtient une expression équivalente, c-à-d que l'addition est commutative.
addition(X, zero) == X
signifie que l'addition de la constante zero à n'importe quelle valeur donne la valeur elle-même.
Le symbole "==" doit se lire comme «est équivalent à», il n'a pas de direction privilégiée et signifie qu'on
peut remplacer ce qui est à gauche par ce qui est à droite et réciproquement.
L'application des axiomes à des termes et des sous-termes permet d'obtenir d'autres expressions
équivalentes. Par exemple, l'expression
addition(zero, X)
est équivalente à
addition(X, zero)
par application du premier axiome. L'application du second axiome nous donne ensuite
X.
Nous avons donc prouvé que addition(zero, X) == X .
En général trouver la valeur d'une expression consiste à la réduire, grâce aux axiomes, à une expression
équivalente qui ne contient que des constantes et des opérations génératrices.
La forme générale des axiomes est:
terme g1 == terme d1 et ... et terme gn == terme dn => terme 1 == terme 2
La partie avant le "=>" restreint l'application de l'équivalence à terme 1 et terme 2 , c-à-d terme 1 est
équivalent terme 2 seulement si l'on peut auparavant prouver toutes les équivalences qui se trouvent avant
le signe "=>". Pour alléger l'écriture, on remplacera souvent les deux équations
b == vrai => t1 == t2 (où b est un terme de sorte booléen)
b == faux => t1 == t3
par un équation compacte
t1 == si b alors t2 sinon t3
ou encore par
t1 == t2 si b, == t3 sinon
SORTES nat
OPERATIONS
0 : -> nat;
succ : nat -> nat;
_+_ : nat, nat -> nat;
_-_ : nat, nat -> nat;
_*_ : nat, nat -> nat;
_^_ : nat, nat -> nat;
_=_ : nat, nat -> bool;
AXIOMES
VAR X, Y : nat;
[1] X + 0 == X;
[2] X + succ(Y) == succ(X + Y);
[3] 0 - X == 0; -- convention pour éviter de sortir des entiers
-- naturels.
[4] X - 0 == X;
[5] succ(X) - succ(Y) == X - Y;
[6] X * 0 == 0;
[7] X * succ(Y) == X + (X * Y);
[8] X ^ 0 == succ(0);
[9] X ^ succ(Y) == X * (X ^ Y);
[10] 0 = 0 == vrai;
[11] succ(X) = 0 == faux;
[12] 0 = succ(X) == faux;
[13] succ(X) = succ(Y) == X = Y
Calculons 2+1:
succ(succ(0)) + succ(0)
== succ(succ(succ(0)) + 0) -- [par 2 en prenant X=succ(succ(0)) et Y=0]
== succ(succ(succ(0))) -- [par 1]
[Remonter] [Precedent] [ Suivant]
""+X == X;
X+"" == X;
vide("") == vrai;
vide("C") == faux;
vide(X+Y) == vide(X) et vide(Y);
premier("C") = C
premier(X+Y) = si vide(X) alors premier(Y) sinon premier(X)
reste("C") = ""
reste(X+Y) = si vide(X) alors reste(Y) sinon reste(X) + Y
"" = "" == vrai;
"" = X == si vide(X) alors vrai sinon faux
X = "" == si vide(X) alors vrai sinon faux
premier(X) = premier(Y) == vrai => X=Y == reste(X) = reste(Y)
premier(X) = premier(Y) == faux => X=Y == faux
Une spécification
On peut spécifier un type liste en se basant sur l'idée qu'un liste est soit vide soit composée d'un premier
élément (la tête) suivi d'une liste (le reste). Nous donnons ci dessous une spécification générique où elem
, le type des éléments, peut être n'importe quel type pourvu qu'il possède l'opération " = ".
SPECIFICATION Liste
UTILISE Bool, Nat
SORTES liste, elem
OPERATIONS
-- constructeurs
vide : -> liste;
cons : elem, liste -> liste;
-- sélecteurs
tete : liste -> nat;
reste : liste -> liste;
est-vide : liste -> bool;
element : elem, liste -> bool;
position : elem, liste -> nat;
AXIOMES
VAR E, E1, E2 : elem; L : liste
[1] tete(cons(E, L)) == E;
[2] reste(cons(E, L)) == L;
[3] est-vide(vide) == true;
[4] est-vide(cons(E, L)) == false;
[5] element(E, vide) == false;
[6] element(E1, cons(E2, L)) ==
if E1 = E2 then true else element(E1, L);
Extension
À partir de la spécification ci dessus on peut définir de nouvelles opérations telles que la concaténation
de listes, l'ajout et la suppression d'éléments au début, à la fin ou à une certaine position, etc. On définit
ainsi une nouvelle spécification qui étend la première.
Ajouter un élément au début d'une liste:
ajout-d: elem, liste -> liste
axiomes:
ajout-d(E, L) == cons(E, L)
Ajouter un élément à la fin:
ajout-f: elem, liste -> liste
axiomes:
ajout-f(E, vide) == cons(E, vide)
ajout-f(E, cons(E', L)) == cons(E', ajout-f(E, L))
Ajouter à la position i:
ajout-pos: elem, liste, nat -> liste
axiomes:
ajout-pos(E, L, 1) == cons(E, L)
accéder au i e composant
modifier le i e composant
En général on préfère désigner les composant par un nom plutôt que par leur numéro d'ordre, pour rendre
plus évidente la sémantique des spécifications et programmes.
On peut définir une structure générale pour la spécification de type produit cartésien, selon le schéma
suivant :
SPECIFICATION Prod
UTILISE: ...
SORTES: produit
OPERATIONS:
cons : s1, s2, ..., sn -> produit
comp1 : produit -> s1
...
compn : produit -> sn
m-comp1 : s1, produit -> produit
...
m-compn : sn, produit -> produit
Les opérations comp1 à compn servent à accéder aux composants alors que m-comp1 à m-compn servent
à modifier les composants d'un produit. Les schémas d'axiomes sont :
AXIOMES:
-- n axiomes de la forme
compi(cons(x1, x2, ..., xn) == xi (i = 1, 2, ..., n)
-- n axiomes
compi(m-compi(x, p)) == x (i = 1, 2, ..., n)
-- n x (n-1) axiomes
compi(m-compj(x, p)) == compi(p) (i = 1, ..., n; j = 1, ..., n; i j)
-- spécifie que la modification du composant i n'affecte pas
-- les autres composants
Remarque: les types produit cartésien existent dans la plupart des langages de programmation, sous
différentes appellations : record en Pascal, struct en C, etc. Les composants des tuples s'appellent
souvent des champs.
[Remonter] [Precedent] [ Suivant]
spéciales. Mais il est souvent plus simple de le faire à l'aide de pré-conditions. La pré-condition, ou une
partie de celle-ci, peut donc servir de barrière d'entrée et déclencher une erreur du programme si jamais
elle n'est pas vraie.
[Remonter] [Precedent] [ Suivant]
● son nom
Une méthode agit toujours sur un objet (appelé cible). La spécification algébrique va servir à écrire les
pré et post conditions qui portent sur la valeur de l'objet cible avant et après l'exécution de la méthode.
Exemple:
Interface d'une classe Liste d'éléments de type T
type Liste
specification Liste(Liste pour liste, T pour elem)
à chaque sorte de la spécification algébrique on fait correspondre un type du système en cours de
définition.
méthodes
vide
post self-post = vide
self-post représente la valeur de l'objet cible après l'exécution de la méthode et self-pre sa valeur avant.
Dans ce cas la postcondition signifie qu'après l'exécution de la méthode la liste est vide (plus exactement,
la valeur de l'objet self est une liste vide).
Il n'y a pas de précondition car la méthode peut s'appliquer sur n'importe quelle liste.
cons: t: T
4.1 Piles
4.2 Files
4.3 Séquence
4.7 Fonctions
4.1 Piles
Une pile est une collection d'objets qui obéit au protocole FILO (First In Last Out), on ne peut accéder et
retirer de la pile que le dernier élément qu'on y a mis. On appelle cet élément le sommet de la pile.
Exemple 1.
Algorithme de vérification de l'équilibrage des parenthèses dans un texte.
Il s'agit de vérifier qu'un texte qui contient des caractères standard, des parenthèses ouvrantes de quatre
types : (, [, { et <, et des parenthèses fermantes des mêmes types : ), ], } et > est syntaxiquement correct
du point de vue des parenthèses. Cela signifie qu'à toute parenthèse ouvrante doit correspondre, plus loin
dans le texte, une parenthèse fermante du même type. Le texte compris entre ces deux parenthèses doit
également être correct : une parenthèse ouverte doit y être refermée. L'algorithme ci-dessous utilise une
pile pour mémoriser les ouvertures de parenthèses :
Données: un tableau c de N caractères qui contient le texte
p = new Pile()
pour i de 0 à N-1 {
si (c[i] est une parenthèse ouvrante) p.empiler(c[i])
sinon si (c[i] est une parenthèse fermante) {
si (p.estVide()) retourne "ERREUR il manque une parenthèse ouvrante"
si (p.sommet()) est du même type que c[i]) p.depiler()
sinon retourne "ERREUR: parenthèses de types différents"
}
}
si (p.estVide()) retourne "OK"
sinon retourne "ERREUR: il manque au moins une parenthèse fermante"
Remarque. Nous exprimerons les algorithmes dans un pseudo-langage orienté-objet inspiré du langage
Java. Nous négligerons en général les déclarations de variables et nous écrirons en français les parties de
l'algorithme qui ne posent pas de problème particulier.
La boucle 2. sert à copier p dans temp, mais à l'envers, la boucle 3. recopie temp sur p et q.
Si la pile est implémentée à l'aide d'un tableau on voit qu'il serait plus rapide de copier directement ce
tableau. Ceci est vrai quelle que soit la structure utilisée pour l'implémentation. Pour des raisons de
performance il est sera donc souhaitable de définir une opération copier() qui recopie directement la
structure interne de la pile, sans passer par des empiler(), dépiler().
4.1.4 Généricité
Dans la définition que nous avons donnée nous n'avons pas précisé quel devait être le type T des élément
d'une pile. Notre type abstrait Pile est donc générique. Cette généricité pourra être conservé ou non au
niveau de l'implémentation du type. Si les algorithmes utilisés pour implémenter la pile font appel à des
opérations sur les éléments, par exemple une opération de test d'égalité ou de comparaison, cette
implémentation réduira la généricité du type en imposant des contraintes sur le type des éléments.
On appelle instantiation d'un type générique le fait de fixer le type T. On pourra, par exemple, créer une
pile de String (T = String), un pile de Rectangles (T = Rectangle), etc.
4.1.5 Polymorphisme
Les langages à objets possèdent une notion de sous type, ou d'extension de type. Un sous-type U dans
type T possède les mêmes opérations que T, plus des opérations propres 1 . Donc un objet de type U peut
être utilisé partout où un objet de type T est requis (selon le principe "qui peut le plus peut le moins").
Donc si l'on a un type Forme muni des sous-types Rectangle, Carre et Cercle, il sera possible de placer
dans une pile de Forme des objets de n'importe lequel de ces trois types. On obtiendra ainsi une pile
polymorphe constituée d'objets de différents types (mais tous sous-types de Forme).
Dans beaucoup de systèmes à objets on a un type Objet dont tous les autres types sont des sous types.
Donc si l'on crée une pile d'Objets on pourra mettra dedans n'importe quel objet de n'importe quel type.
C'est la pile la plus polymorphe qu'on puisse créer.
1. Il existe différentes définitions, plus ou moins sophistiquées, de la notion de sous-type. Nous nous
contenterons de cette définition simple.
[Remonter] [Precedent] [ Suivant]
4.2 Files
Une file («queue» en anglais) est une collection qui obéit au protocole FIFO (First In First Out). L'ajout
d'un élément se fait à l'arrière de la file alors que le retrait se fait à l'avant. Les éléments restent dans le
même ordre tant qu'ils sont dans la file (il est interdit de dépasser !).
premier(entrer(11,vide)) == (par 4)
11
4.3 Séquence
Une séquence est une collection d'objets du même type qui sont placés selon un ordre. Chaque objet
possède donc une position. Le modèle mathématique d'une liste <a 1 , a 2 , ..., a n > d'éléments de type T
est la fonction {1 Æ a 1 , 2 Æ a 2 , ..., n Æ a n } de {1, ..., n} dans {a 1 , a 2 , ..., a n }.
1. supprimer(I, inserer(E, I, S) == S;
2. element(J, inserer(E, I, S) == element(J, S) si J < I
== E si J = I
== element(J-1, S) sinon
3. element(J, supprimer(I, S)) == element(J, S) si J < I
== element(J+1, S) sinon
4. element(J, remplacer(I, E, S)) == E si J = I
== element(J, S) sinon
5. longueur(vide) == 0
6. longueur(inserer(E, I, S) == longueur(S)+1;
7. longueur(supprimer(I, S) == longueur(S)-1;
8. longueur(remplacer(E, I, S) == longueur(S);
9. indice(E, vide) == 1;
10. indice(E, S) == 0 si element(0, S) = E
== indice(E, supprimer(0, S))+1 sinon
N.B. si e ne se trouve pas dans l, le résultat est l.longueur()+1
11. indice(E, I, S) == indice(E, I-1, supprimer(0, S)) si I > 0
== indice(E, S) sinon;
12. est-vide(S) == longueur(S) = 0
13. est-vide(vide) == vrai
Exemples d'application des équations:
a.
element(1, inserer(A, 0, inserer(B, 0, S)))
== (par 2.) element(0, inserer(B, 0, S))
== (par 2.) B
b.
element(0, supprimer(0, inserer(A, 0, inserer(B, 0, S))))
== (3) element(1, inserer(A, 0, inserer(B, 0, S)))
== (2) element(0, inserer(B, 0, S))
== (2) B
Certaines expressions ne sont pas complètement évaluable, c'est-à-dire qu'on ne peut les réduire à des
expressions ne faisant intervenir que les constructeurs et les constantes. Par exemple :
element(6, inserer(a, 0, inserer(b, 0, vide))) ==
element(5, inserer(a, 0, vide)) ==
element(4, vide)
Aucun axiome ne permet de réduire cette dernière expression
Les listes l1 et l2 ne sont pas égales au sens de la première définition car l'élément no. 1 de l1 est l'objet s
alors que l'élément no. 1 de l2 est u. Par contre elles sont égales au sens de la seconde définition car les
objets s et u ont les mêmes valeurs (0, 1, 12, 15) et sont donc égaux (mais pas identiques).
Il faut noter que la seconde définition est récursive car la notion d'égalité des valeurs des objets peut
impliquer la comparaison d'autres objets (composantes). Par exemple, si l'on crée une liste dont les
éléments sont eux-même des listes.
[Remonter] [Precedent] [ Suivant]
Exemples:
Construire la liste <6, 55, 444>:
Ce genre de liste est utilisé dans les langages tels que LISP et PROLOG. En LISP la liste est la structure
de données de base, elle sert non seulement à représenter les données mais également les instructions
d'une programme. Les expressions qui forment un programme LISP sont des listes dont la tête est un
opérateur et le reste les arguments sur lesquels porte l'opération. Par exemple:
(+ 6 4) calcule 6 + 3
(* 8 (- 12 x)) calcule 8 * (12 - x)
(cond ((eq a 5) (+ 3 b)) ((eq a 7) x))
== si a = 5 le résultat est b+3 et si a = 7 le résultat est x
Ceci permet de traiter les programmes comme des données. On peut donc écrire facilement des
programmes qui construisent des expressions et les évaluent (méta-programmes).
L'implémentation de ce genre de liste est immédiat: il suffit de définir des objets de type Liste muni de
deux variables d'instance, l'un faisant référence à l'objet tête et l'autre faisant référence à l'objet Liste qui
constitue le reste.
class Liste {
Object tete; Liste reste;
...
}
La programmation des méthodes est également immédiate, il ne s'agit que d'affectations. Ce genre de
liste est également appelé liste simplement liée.
On remarquera que ces opérations n'introduisent pas de nouveaux constructeurs, c'est-à-dire qu'une
expression de type liste "correcte" peut toujours se ramener à une suite de cons et vide .
[Remonter] [Precedent] [ Suivant]
Exemple 1. Réduction d'une expression composée de ajoute et retire à une expression composée
uniquement de ajoute
r(33, a(4, a(33, r(1, a(20, a(1, vide))))))
== r(33, a(4, a(33, a(20, r(1, a(1, vide))))))
== r(33, a(4, a(33, a(20, r(1, vide)))))
== r(33, a(4, a(33, a(20, vide))))
== a(4, r(33, a(33, a(20, vide))))
== a(4, r(33, a(20, vide)))
== a(4, a(20, r(33, vide)))
== a(4, a(20, vide))
Le calcul de la cardinalité tient compte du fait qu'on peut ajouter plusieurs fois le même élément à un
ensemble sans que l'ensemble ne change de cardinalité. On voit que cette opération est assez complexe
car elle fait intervenir à chaque étape la fonction appartient .
De même que la pile, l'ensemble a une structure monolithique, car il est composé d'éléments et non pas
de sous-ensembles.
[Remonter] [Precedent] [ Suivant]
AXIOMES:
deja-vu(initial(E) == vide;
a-voir(inital(E)) == E;
courant(avancer(I)) == choix(a-voir(I))
deja-vu(avancer(I)) == ajouter(choix(a-voir(I),deja-vu(I))
a-voir(avancer(I)) == retirer(choix(a-voir(I),a-voir(I))
encore(avancer(I)) == non est-vide(a-voir(I))
On remarque que les opérations deja-vu et a-voir de la spécification algébrique ne sont que des
auxiliaires pour la définition, elles n'ont pas d'utilité pratique pour le type concret.
4.6.4 Multi-ensembles
Dans un multi ensemble on a un certain nombre d'occurences de chaque élément, c-à-d chaque élément
peut apparaitre une ou plusieurs fois.
La cardinalité unique d'un multi-ensemble est le nombre de ses éléments distincts.
Les opérations sur les multi-ensembles sont celles des ensembles plus la cardinalité unique et le nombre
d'occurences d'un élément dans le multi-ensemble.
int cardinaliteUnique()
int nombreOccurences(T elem)
Pour les multiensembles les équations de calcul de la cardinalité, de la cardinalité unique et du nombre
d'occurences sont:
taille(ajoute(E, S)) == succ(taille(S));
nb-occurences(E, vide) == 0;
4.7 Fonctions
Une fonction partielle d'un ensemble A de départ dans un ensemble d'arrivée B associe à chaque élément
a de A au plus un élément b de B (éventuellement aucun). L'élément b est appelé image de a. Si un
élément possède une image on dira qu'il est lié .
Une fonction F est donc un ensemble de paires (a -> b) qui satisfait la contrainte de fonctionnalité
suivante:
si (a -> b) appartient à F alors il ne peut pas y avoir dans F une autre paire (a -> b2) telle que b est
différent de b2.
Remarque. Attention à ne pas confondre ce type abstrait fonction, qui est une collection de données, avec
la notion de fonction que l'on trouve dans les langages de programmation et qui correspond à une suite
d'instructions qui calculent un résultat à partir des paramètres fournis. Il s'agit bien de fonctions puisqu'en
fin de compte elles créent des paires (paramètres, résultat) mais ce paires ne sont pas des données
stockées dans une collection, elles sont calculées à chaque fois. De plus, ces fonctions ne sont pas
modifiables (sauf si le langage permet de modifier des instructions en cours d'exécution).
On remarquera que ces équations sont très proches de celles des ensembles. Ce n'est pas un hasard car on
peut considérer une fonction P comme un ensemble de paires (d Æ a) qui satisfait la contrainte de
fonctionnalité: si (d Æ a) appartient à P alors il ne peut pas y avoir une autre paire (d Æ a') avec a
différent de a' dans P. Cette contrainte est représentée par le fait que l'axiome pour image "s'arrête" au
premier lien trouvé, donc l'opération lie remplace un éventuel lien précédent:
image(6, lie(6, 3, lie(6, 4, lie(6, 2, lie(8, 1)))))
== 3 -- [par l'axiome 3]
Dans cet exemple la fonction est construite en liant 8 à 1 puis 6 successivement à 2, 4 et 3; la fonction
résultante est {8 Æ 1, 6 Æ 3}.
5 Arbres
Les structures d'arbres possèdent un double intérêt: d'une part les données qui interviennent dans de
nombreux problèmes sont naturellement structurées en arbres (hiérarchies d'objets, choix et décisions,
arbres syntaxiques, etc.), d'autre part elle permettent de représenter efficacement des ensembles d'objets
ou des applications, on parlera dans ce cas d'arbres de recherche (search trees).
5.1 Définitions
5.1 Définitions
Un arbre avec racine est composé de deux ensembles N et A appelés respectivement l'ensemble des
noeuds et l'ensemble des arcs et d'un noeud particulier r appelé racine de l'arbre. Les éléments de A sont
des paires ( n1 , n2 ) d'éléments de N . Une arc ( n1 , n2 ) établit une relation entre n1 , appelé noeud
parent, et n2 , appelé noeud enfant de n1 , A doit être tel que chaque noeud, sauf la racine, a exactement
un parent.
On appelle feuille de l'arbre les noeuds qui n'ont pas d'enfant et noeud intérieur les noeuds qui ne sont ni
des feuilles ni la racine. Le degré d'un noeud est le nombre de ses enfants et le niveau d'un noeud est le
nombre d'arc qu'il faut remonter pour atteindre la racine depuis ce noeud (la racine est donc de niveau 0).
La hauteur de l'arbre est le plus grand degré qu'on trouve parmi les noeuds.
Si l'ordre entre les sous-arbre enfants est pris en compte on parlera d'arbre ordonné (à ne pas confondre
avec un arbre trié).
On peut fixer un degré maximum pour chaque arbre. On parlera alors d'arbre unaire, binaire, ternaire, etc.
● ou bien un noeud racine qui possède une valeur et qui est lié à 0, 1 ou plusieurs sous-arbres
éventuellement vides
On peut définir un type abstrait arbre dont les valeurs sont de type T à l'aide des opérations ci-dessous
type résultat opération paramètres description
fournit un arbre formé uniquement d'un noeud racine qui a la
Arbre new Arbre T val
valeur val
insère b comme n-ième enfant de la racine
insère int n, Arbre b Précondition: n doit être compris entre 0 et le nombre d'enfants
de la racine.
supprime int n supprime le n-ième enfant
modifVal T val la racine de l'arbre prend la valeur val
T valeur
int nbEnfants nombre d'enfants de la racine
Arbre enfant int i fournit l'arbre qui est le n-ième enfant de la racine
boolean estVide vrai si l'arbre n'a aucun noeud
new Arbre("haut")
.insere(0, new Arbre("milieu-1"))
.insere(1, new Arbre("milieu-2")
.insere(0, new Arbre("bas-1"))
.insere(1, new Arbre("bas-2"))
);
);
● post ordre: on parcourt le sous-arbre gauche, puis le sous-arbre droit, puis la racine
(Le lecteur averti aura sans doute remarqué qu'il ne s'agit de rien d'autre qu'un automate à état fini qui
reconnaît le langage formé des mots donnés).
L'intérêt de l'arbre à lettres réside dans la rapidité de la reconnaissance d'un mot. Pour tester si un mot
formé des lettres c1, c2, ..., ck appartient au dictionnaire on part de la racine et on suit les arcs indexés
par les lettre c1, c2, etc. Le mot appartient au dictionnaire si et seulement si l'on arrive sur un noeud de
fin de mot. Le temps de calcul est donc proportionnel à la longueur du mot à reconnaître et est
indépendant de la taille du dictionnaire !
Evaluation:
L'évaluation consiste à remplacer un (sous-)arbre par la valeur de l'expression qu'il représente. Ainsi,
l'arbre A1 ci-dessus sera remplacé par l'arbre constitué du seul noeud (10); pour l'arbre A2 on évalue
d'abord le sous-arbre (- 4 6) qui donne -2, puis (* 3 -2) qui donne -6.
Simplification:
Si l'expression contient des variables comme opérandes, on ne peut pas l'évaluer complètement. Par
contre, on peut appliquer des règles de simplification du genre:
● remplacer (* 1 expr ) ou (* expr 1) par ( expr )
Dérivation:
On peut calculer la dérivé (selon x) d'une formule en appliquant des transformations telles que:
d(* cte x) = (cte)
d(** x n) = (* n (** x (- n 1)))
d(+ f g) = (+ d(f) d(g))
d(* f g) = (+ (* d(f) g) (* f d(g)))
etc.
Cette représentation, avec quelques variantes, est la base des systèmes de manipulation et de résolution
symbolique d'équations mathématiques (Mathematica, Maple, etc.). Elle peut également être utilisée dans
les compilateurs pour vérifier le bon typage des expressions et pour les optimiser. Bien que nous n'ayons
montré que des opérations mathématiques, cette représentation peut aussi s'appliquer à d'autres domaines
où la notion d'opération et d'opérandes existe.
Si le nombre maximum d'enfants est fixé on peut utiliser pour la variable enfants un tableau de Noeuds,
pour un arbre binaire on utilisera deux variables gauche et droite de type Noeud, dans le cas général on
utilisera une liste de Noeuds.
N.B. Nous noterons Liste<Noeud> le type Liste que nous avons défini précédemment où le type T de
éléments est Noeud. De même pour les ensembles, files, etc.
6 Graphes
Le graphe est une structure que l'on trouve dans la modélisation d'un grand nombre de situations très
diverses (réseaux de transports et de communications, ordonnancement de tâches, relations entre
personnes ou institutions, etc.). En fait, dès qu'intervient une relation binaire entre des objets d'un même
ensemble on a une structure de graphe. De ce fait, de nombreux problèmes peuvent se ramener à des
problèmes classiques de la théorie des graphes : recherche de chemins (à coût minimal), détection de
cycles, arbres de recouvrement, coloration, etc.
6.1 Définitions
6.4 Implémentation
6.1 Définitions
Graphes non orientés
Un graphe est composé de deux ensembles V et E appelés respectivement l'ensemble des sommets (ou
noeuds ou points) et l'ensemble des arêtes. Les éléments de E sont des paires { n1 , n2 } d'éléments de V
.ou des singletons { n }. Une arête { n1 , n2 } représente un lien non orienté entre n1 et n2 ; un singletion
{ n } représente un lien de n avec lui-même (une boucle).
Le degré d'un sommet est le nombre d'arêtes qui contiennent ce sommet.
Deux sommet n1 , n2 sont adjacents s'il existe une arête { n1 , n2 }.
Un chemin non orienté de n 0 à n t (ou chaîne) est une séquence d'arêtes de la forme {n 0 , n 1 }, {n 1 , n 2
}, {n 2 , n 3 }, ..., {n t-2 , n t-1 }, {n t-1 , n t }. Un chemin est dit simple s'il ne rencontre pas deux fois le
même sommet. Un chemin de n 0 à n 0 est un cycle.
Un graphe est connexe si tous ses sommets sont reliés deux à deux par au moins un chemin.
Un sous-graphe d'un graphe G = (V, E) est un graphe G' = (V', E') tel que V' Õ V, E' Õ E.
Une composante connexe de G est un sous-graphe G' de G, tel que 1) G' est connexe, 2) il n'existe pas de
sous-graphe G'' de G tel que G' est un sous-graphe de G'' et G'' est connexe.
Graphes orientés
Un graphe orienté est composé d'un ensemble de sommets et d'arcs. Les arcs sont des paires ( n1 , n2 ).
Le premier sommets de la paire est appelé origine de l'arc et le second destination .
Un chemin de n 0 à n t est une séquence d'arcs de la forme (n 0 , n 1 ), (n 1 , n 2 ), (n 2 , n 3 ), ..., (n t-2 , n t-1
), (n t-1 , n t ). Un chemin est dit simple s'il ne rencontre pas deux fois le même sommet. Un chemin de n 0
à n 0 est un circuit.
On peut également associer une valeur à chaque sommet et à chaque arête (par exemple pour indiquer un
cout ou une distance). Comme dans le graphe ci-dessous.
Remarques:
Pour éviter qu'un sommet puisse appartenir à deux graphes différents, c'est l'opération ajoute qui créee
elle-même un nouveau sommet. On ne peut donc pas prendre un sommet existant et l'ajouter au graphe. Il
en va de même pour l'ajout d'arcs.
Dans le cas d'un graphe non orienté on remplacera les opérations entrants(s) et sortants(s) par une seule
opération adjacents(c) (voir également le type Arc).
Le type Sommet
Le type somet possède les opérations nécessaires à sélectionner et modifier la valeur, de type VS, d'un
sommet.
type résultat opération paramètres description
Sommet new Sommet crée un sommet
metValeur VS v modifie la valeur du sommet
VS valeur donne la valeur du sommet
Ensemble<Arc> sortants Sommet s fournit l'ensemble des arcs dont la destination est s
Le type Arc
Un arc possède un sommet origine, un sommet destination et une valeur de type VA
type résultat opération paramètres description
Arc new Arc Sommet s1, s2 crée un arc de s1 à s2
Arc metValeur VA v
Sommet origine sommet origine
Sommet destination sommet destination
VA valeur Arc a valeur de l'arc
Remarque
Le type arc ne possède pas d'opérations pour modifier l'origine et la destination, ceci pour garantir que
l'on ne puisse pas associer des sommets de deux graphes différents.
Dans le cas d'un arc non orienté (arête) on remplacera origine() et destination() par un opération
sommets() qui retourne les deux sommets sous forme d'un tableau (p.ex.).
[Remonter] [Precedent] [ Suivant]
On pourra effectuer un parcours en largeur manière analogue au parcours en largeur des arbres. Mais
comme dans l'algorithme ci-dessus, il faut mémoriser les sommets déjà vus pour éviter de les visiter
plusieurs fois et de boucler indéfiniment.
[Remonter] [Precedent] [ Suivant]
6.4 Implémentation
En fonction du type d'algorithme que l'on veut utiliser on utilisera différentes implémentations des
graphes. Les techniques les plus classiques pour représenter un graphe sont:
● la liste de sommets et d'arêtes
● la matrice d'adjacence
sommet: s1 s2 s3 s4 s5 s6
valeur: milano tokyo london lyon paris geneve
liste-adj.: (a1 a2 a3) (a1) (a2) (a3 a4 a5) (a4 a6) (a5 a6)
arc: a1 a2 a3 a4 a5 a6
source: s1 s1 s1 s5 s6 s6
destination: s2 s3 s4 s4 s4 s5
valeur: 401 501 601 201 301 101
Dans le cas des graphes orientés ont peut, si nécessaire, distinguer la liste des arcs entrants et sortants
pour chaque sommet. Dans l'implémentations des opérations il faudra tenir compte de l'existence de ces
listes qui créent de la redondance dans la structure. Par exemple, lors de l'ajout d'une nouvelle arête il
faudra non seulement mettre celle-ci dans la liste des arêtes mais également dans la liste d'adjacence des
deux sommets qu'elle relie. De même lors de la suppression.
Matrice d'adjacence
Si on a n sommets on construit une matrice n x n. La valeur de l'élément (i,j) de la matrice indique si les
sommets i et j sont adjacents (i.e. s'il existe un arc entre i et j). Dans le cas ou les ars sont valuées
l'élément (i,j) contiendra la valeur de l'arc et une valeur particulière (p.ex. l'objet Nil) i et j ne sont pas
Algorithme de Disjkstra
Calcul du coût du chemin le plus court de s0 à chaque autre sommet du graphe G = (V, A) et
mémorisation des chemins les plus courts.
Principe:
● On part du sommet s0
Algorithme:
Soit c(s1, s2) le coût de l'arc (s1, s2) ou bien · s'il n'y a pas d'arc de s1 à s2
S = {}
D[s0] ¨ 0
pour chaque sommet s dans V - S { D[s] ¨ c(s0, s) }
tant que (S V) {
L'application de l'algorithme sur le graphe G ci-dessous, à partir du sommet 1, fournit les distances
minimales montrées sur le graphe MIN.
Algorithme de Kruskal
Ar := Ø; Reste := A;
tant que (S, Ar) ne couvre pas G {
choisir l'arête a de Reste de coùt minimal
supprimer a de Reste
si a ne crée pas de cycle avec Ar alors ajouter a à Ar
}
● Sinon a relie deux cc qui maintenant n'en forment plus qu'une. Il faut mettre à jour les
représentants des sommets de l'une des cc.
[Remonter] [Precedent] [ Suivant]
Couverture de sommets
Etant donné un entier K, existe-t-il un sous-ensemble V' des sommets de V tel que |V'| £ K et pour
chaque arête {s1, s2}, {s1, s2} « V' Ø ?
Clique
Etant donné un entier J, le graphe contient-il une clique de taille J ou plus ? Une clique est un
sous-ensemble des sommets tel que les membres de la cliques sont tous reliés par une arête.
Circuit Hamiltonien
Existe-t-il un circuit hamiltonien dans le graphe ? C'est-à-dire une séquence <s 1 , s 2 , ..., s n > des
sommets de G telle que {s i , s i+1 } (1 £ i < n) est une arête de G, de même que {s 1 , s n }.
Mais également:
L'isomorphisme de sous-graphes, l'arbre de recouvrement à degré limité, la coloration avec 3 couleurs,
etc..
[Remonter] [Precedent] [ Suivant]
7.7 B-arbres
7.1 Introduction
Implémenter un type abstrait consiste à
● définir la structure interne des objets et
Structure interne
La structure interne d'un objet est composée d'un ensemble de variables d'instance de divers types. Pour
implémenter une type abstrait T on procédera par agrégation, c'est à dire qu'on construira un objet de
type T en utilisant un des objets de types T 1 , T 2 , ..., T k déjà implémentés. Attention, il ne faut pas
confondre cette technique avec l'héritage multiple. De même, l'implémentation de chacun des T i se base
sur d'autres types déjà implémentés, et ainsi de suite. Il y a donc construction d'une hiérarchie
d'abstraction de types (qui n'est pas la hiérarchie des sous-classes) qui repose sur les types les plus
simples ou types de base.
Suivant le langage et l'environnement utilisé, les types de base peuvent varier. Nous considérerons
comme types de base :
● les type élémentaire (entier, flottant, caractères, booléen)
Algorithmes
Nous définirons les algorithmes en pseudo-langage orienté-objet. En plus des instructions de contrôle (si
() ... sinon ... ; tant que {...}, etc.), de l'affectation et des invocations de méthodes nous utiliserons la
notation pointée <objet>.<varialbe d'instance> pour faire référence aux variables d'instance des objets.
Propriétés de l'implémentation
On s'intéressera plus particulièrement à deux propriétés d'une implémentation d'un type :
● la correction (obligatoire): les opérations implémentées doivent satisfaire les contraintes de la
spécification du type abstrait: types des paramètres et résultats, équations, invariants.
● la complexité (mesure): pour chaque implémentation on veut connaaître la complexité en temps de
chaque opération (évolution du temps de calcul en fonction de la taille des objets traités) et la
complexité en espace (mémoire occupée par la structure de données).
Remarque
Si l'extension de tableau n'intervient pas trop souvent, par exemple si l'on double à chaque fois la taille du
tableau, le temps moyen pour empiler est quasiment constant. Supposons que la taille de départ du
tableau soit N 0 , qu'on choisisse de doubler chaque fois que le tableau est plein, et qu'on fasse 2 t N 0
opérations empiler(). On devra étendre le tableau t fois, le nombre d'éléments à copier lors des extensions
sera: N 0 , 2N 0 , 4N 0 , ..., 2 t N 0 = (2 t+1 -1)N 0 . Le nombre moyen de recopies par opération sera donc
(2 t+1 -1)N 0 /2 t N 0 = 2 - 1/2 t . On voit que ce nombre est quasiment constant.
Si l'on choisit des extensions de taille fixe D on peut tomber dans des complexité bien plus grande. Le
plus mauvais choix étant bien sûr D = 1 qui oblige à étendre le tableau à chaque opération.
Cette dernière variable n'est pas strictement nécessaire, mais elle accélère les opérations. Sans elle il
faudrait à chaque fois parcourir toute la liste pour calculer sa longueur. La See Une liste liée montre
graphiquement les objets composant une liste liée.
On peut implémenter directement les opérations d'insertion et de suppression au début de la liste liée :
méthode insererDebut(T e) de ListeLiée {
nn ¨ new Noeud()
nn.contenu ¨ e
nn.suivant ¨ premier
premier ¨ nn
longueur ¨ longueur+1}
Contrairement au tableau, la liste liée n'offre pas de mécanisme d'accès direct à un élément. Pour accéder
au i-ième élément d'une liste l il faut suivre les référence suivant depuis le début de la liste.
n ¨ l . premier;
pour j de 1 à i-1 { n ¨ n . suivant }
Etant donné qu'il est coûteux d'atteindre une position donnée dans une liste liée on a tout avantage à
1. mémoriser la position atteinte, au cas ou plusieurs traitements sont à faire sur l'objet atteint.
2. organiser les traitements de manière à ce qu'ils accèdent séquentiellement aux éléments de la
liste plutôt que dans un ordre aléatoire.
Dans ces conditions il est avantageur de définir une structure d'itérateur qui représente un curseur se
déplaçant sur la liste. L'itérateur permet de se positionner à l'endroit voulu, de mémoriser la position et
d'y d'effectuer des opérations d'insertion ou de suppression. L'implémentation d'un itérateur sur une liste
liée est directe. La structure interne de l'intérateur est composée des variables
Liste liste la liste sur laquelle opère l'itérateur
Noeud position le noeud courant
Retirer un élément revient à remplacer le début de la liste par le noeud suivant le premier
méthode retirer() de Pile
{elements.supprimerDebut() }
Etant donné qu'il est coûteux d'accéder au i-ième élément d'une liste liée, mais qu'en revanche il est
rapide de passer d'un noeud au suivant, on a tout intérêt à organiser les traitements à effectuer sur la liste
de manière séquentielle et non pas aléatoire.
La performance de ces opérations globales dépend fortement de la structure de données choisie. Prenons
l'exemple de l'union d'ensembles: avec une représentation par liste on ne peut pas simplement concaténer
les listes pour réaliser l'union car il y a risque de créations de doublons. L'opération nécessite une
vérification (relativement longue) pour éviter les doublons. Par contre, si l'univers des éléments
considérés est ordonné, il est avantageux de placer les éléments dans la liste en fonction de cet ordre.
L'union revient alors à une opération de fusion . Une représentation par liste triée est également plus
efficace pour l'intersection et la différence.
[Remonter] [Precedent] [ Suivant]
Structure interne:
On s'appuye sur l'implémentation des ensembles par les listes liées. La varialbe ll est un tableau de N
ensembles représentés par des listes.
Opérations:
boolean estVide
i ¨ t.h()
boolean appartient Tt
ll[ i] . appartient(t)
boolean inclus Ensemble e teste l'inclusion sous-liste par sous-liste
Complexité en temps
Si la fonction h répartit suffisament uniformément les valeurs entre 1 et N les listes auront une taille
moyenne de S/N où S est le nombre d'éléments de l'ensemble. Dans le pire des cas, si la fonction de
hachage est très mal choisie, tous les éléments vont se placer dans la même liste. On se retrouve dans la
situation d'une représentation par liste liée.
Dans le meilleur des cas il n'y a que zéro ou un élément par liste (ceci implique que le tableau de hachage
est plus grand que le nombre d'éléments). Le temps d'accès à un élément se résume alors à <temps de
calcul du h-code> + <accès au premier élément d'une liste>, ce temps est constant.
Dans un cas "moyen" les éléments seront uniformément répartis entre les N sous-listes. Le temps d'accès
sera alors divisé par N par rapport à la représentation par une seule liste.
Fonctions de hachage
La principale propriété requise pour la fonction de hachage est qu'elle répartisse le plus uniformément
possible les objets dans l'intervalle 1..N et qu'elle soit simple à calculer (sinon on perd tout le bénéfice de
la méthode). On peut immaginer différentes fonctions de hachage suivant le type de données. Pour des
données de type chaine de caractères on peut prendre par exemple:
● la somme modulo N des codes des caractères de la chaine;
Etant donné que la performance de la fonction de hachage dépend du domaine de valeur réellement
utilisé par l'application, il n'est pas possible de définir une fonction qui soit bonne en toutes
circonstances. La fonction de hachage doit donc être une opération du type T des objets à stocker 1 .
début)
● on cherche une place libre à h(x) + k, h(x) +2k, h(x) + 3k, etc. (modulo la taille de la table), si la
taille de la table est une puissance d'une nombre premier on est sûr d'explorer toutes les positions
possibles avec cette technique
● (déplacement quadratique) on cherche une place libre à h(x) + ai + bi**2 (i = 1, 2, 3, ...) (modulo
la taille de la table)
1. Dans un système à objet tel que Java la class Object possède un méthode hash() qui est héritée par
toutes les autres classes. Chaque classe peut redéfinir cette méthode en fonction des caractéristiques de
ses objets.
[Remonter] [Precedent] [ Suivant]
● les noeuds du sous-arbre gauche ne contiennent que des éléments de valeur inférieure à celle de
l'élément racine
● les noeuds du sous-arbre droit ne contiennent que des éléments de valeur supérieure à celle de
l'élément racine
Nous supposerons que si les éléments considérés sont d'un type T qui possède les opérations de
comparaison
T.egal(T) -> booléen
T.inférieur(T) -> booléen.
L'implémentation de la structure de l'arbre binaire de recherche est immédiate, il suffit de définir une
classe Noeud qui contient trois variables d'instance :
T contenu // l'élément contenu dans ce noeud, de type T
Noeud gauche, droite // références aux racines des sous-arbres de droite et de gauche.
Un ensemble sera représenté par un objet contenant une variable d'instance arbre de type Noeud. Si arbre
= nul l'ensemble est vide, sinon arbre fait référence au noeud racine de l'arbre de recherche où sont
stockés les éléments de l'ensemble.
On remarque que le temps de recherche (le nombre de comparaison d'éléments à effectuer) dépend de la
hauteur de l'arbre, au pire on atteint la feuille la plus basse de l'arbre. Si l'arbre est complet (chaque noeud
intérieur a exactement 2 enfants) et équilibré (toutes les feuilles sont au même niveau), sa hauteur est
environ égale à log 2 (nombre de noeuds + 1) - 1.
Supprimer un élément
L'opération de suppression est un peu plus problématique. En particulier si l'élément à supprimer ne se
trouve pas dans une feuille de l'arbre. Dans ce dernier cas il faut restructurer l'arbre. L'aglorithme procède
de la manière suivante.
3. Dans le cas où le sous-arbre a deux enfants ont ne peut rien faire directement, il faut
faire descendre x dans l'arbre par une série de rotations, jusqu'à ce qu'on se ramène au
cas See Si ce sous-arbre est une feuille, on l'enlève de l'arbre ou See Si ce sous-arbre
n'a qu'un enfant, on le remplace dans l'arbre par cet enfant .Si on fait une rotation à
gauche il faut ensuite supprimer l'élément du sous-arbre gauche du nouvel arbre.
r1 = r.droite
r.droite = r1.gauche
r1.gauche = r
La nouvelle racine est r1. On voit qu'il suffit de trois affectations pour effecture la rotation. La méthode
de suppression est alors la suivante:
7.7 B-arbres
Les B-arbres (B pour "balanced" = équilibré) sont des arbres multiples où chaque noeud peut avoir entre
0 et m+1 enfants et contenir entre 0 et m valeurs. Afin de maintenir l'arbre complètement équilibré on va
accepter que ses noeuds soient partiellement vides, c-à-d que les m places réservées pour stocker des
valeurs ne soient pas toutes occupées.
Par définition un B-arbre d'ordre m est un arbre tel que:
● La racine a au moins 2 descendants - sauf si c'est une feuille
Le sous-arbre le plus à gauche contient toutes les valeurs inférieures à v1, le 2ème sous-arbre contient
celles comprises entre v1 et v2, et ainsi de suite.
Insertion
chercher le noeud où le nouvel élément doit être inséré
insérer l'élément dans son noeud
si débordement
partager le bloc en deux blocs à moitié pleins
si (le noeud n'est pas la racine)
insérer de la même manière la valeur médiane dans le noeud de niveau supérieur où elle
servira de critère de séparation (cette insertion peut elle-même créer une séparation dans les
noeuds supérieurs)
sinon créer un nouveau noeud racine pour y mettre cette valeur médiane.
Suppression
chercher le noeud s où se trouve l'élément à supprimer
enlever l'élément du noeud
si le noeud devient moins qu'à moitié plein
si (nb. élt d'un noeud adjacent t + nb élt de s < m)
fusionner s, t et l'élément du noeud supérieur e qui les séparait en un noeud u
enlever e du noeud supérieur
remplacer les deux références à s et t par une seule référence à u
si (le noeud supérieur devient moins qu'à moitié plein)
appliquer récursivement le même traitement, sauf si c'est la racine
sinon
effectuer une répartition égale des éléments entre s et un noeud adjacent t,
mettre à jour l'élément séparateur dans le noeud supérieur.
● rechercher tous les points qui ont une valeur donnée pour la coordonnée x (ou y ou z) (recherche
partielle)
Il est bien entendu possible de définir un ordre sur les éléments composés. Par exemple l'ordre
lexicographique sur des coordonnées 3-D est donné par
<x, y, z> < <x', y', z'> si (x < x') ou (x = x' et y < y') ou (x = x' et y = y' et z < z')
mais si cet ordre permet de construire un arbre (binaire ou autre) pour stocker les éléments de cet
ensemble, cet arbre n'est d'aucune utilité pour les recherches partielles. Si, par exemple, on veut trouver
tous les points dont la coordonnée z vaut 12, il n'y a pas d'autre solution que d'explorer tout l'arbre. Seule
la recherche sur la première coordonnée est efficace.
Une recherche exacte dans un arbre k-d s'effectue comme dans un arbre binaire normal, mais en
comporant alternativement sur chacune des composantes.
Pour effectuer une recherche partielle, dans un arbre 2-d, on doit alternativment choisir le bon sous-arbre
ou explorer les deux sous-arbres du niveau courant.
Algorithme
Recherche des noeuds qui ont la valeur v dans la i -ème composante
soit j le niveau de la racine du sous-arbre qu'on explore
si ( j mod k + 1 = i )
si ( v < la i -ème composante de la racine)
recherche dans le s-a gauche
sinon
si ( v > la i -ème composante de la racine)
recherche dans le s-a droit
sinon (* trouvé *) ajoute ce noeud à la liste des noeuds trouvés
sinon recherche dans le s-a gauche puis recherche dans le s-a droit
On remarque que plus k devient grand plus on explore une grande partie de l'arbre.
7.8.2 Quad-tree
Dans un quad-tree de dimension k, chaque noeud possède 2 k descendants. Chacun des descendants
correspond à un résultat de comparaison possible des composantes de l'élément à placer et des
composantes du noeud. Pour un quad-tree d'ordre 3 on aura:
Dans ce cas la recherche sur une composante, disons Y, nécessite l'exploration de 4 des 8 sous-arbres du
niveau inférieur.
Chaque noeud d'un quad-tree de dimension k correspond à une division de l'espace en 2 k sous-espaces.
Chacun de ses sous-espaces étant lui-même re-subdivisé au niveau suivant de l'arbre.
[Remonter] [Precedent] [ Suivant]
7.9.1 Exemples
Exemple 1. Déterminer les classes d'équivalence correspondant à une relation d'équivalence R donnée.
Rappel
R est une relation d'équivalence si
1. aRa pour tout a (réflexivité)
2. aRb => bRa pour tout a, b (symétrie)
3. aRb et bRc => aRc pour tout a, b, c (transitivité)
La classe d'équivalence d'un élément a, notée [a] est l'ensemble de tous les éléments x tels que aRx. Il est
facile de voir que si x est dans [a] alors [x] = [a] et que si x n'est pas dans [a] alors [a] et [x] sont
disjointes. Les classes d'équivalences sont donc des ensembles disjoints.
Algorithme.
On peut construire itérativement l'ensemble des classes d'équivalence d'une relation R sur l'ensemble S =
{s 1 , s 2 , ..., s n } de la manière suivante:
1. On commence par créer les ensembles C 1 = {s 1 }, C 2 = {s 2 }, ..., C n = {s n }.
2. On considère successivement toutes les paires (a, b) telles que aRb, deux situations peuvent se
présenter:
a) a et b se trouvent déjà dans la même classe C i , il n'y a rien à faire
b) a et b se trouvent dans des classes C i et C j différentes, le fait que aRb indique que a et b sont dans la
même classe d'équivalence, il faut donc faire l'union de C i et C j pour produire une nouvelle classe
d'équivalence C k qui remplace C i et C j .
● si u et v appartiennent à deux c.c. différentes C1 et C2, il n'y a pas de création de cycle, par contre
il faut fusionner C1 et C2 en une nouvelle c.c. C3
● si u appartient à une c.c. C1 et v n'appartient à aucune c.c., il ajouter v à C1, idem si c'est u qui
n'appartient à aucune c.c. et v appartient à C1
● si ni u ni v n'appartiennent à une c.c. il faut créer une nouvelle c.c. C = {u, v}
● on doit pouvoir déterminer dans quel ensemble se trouve un objet (opération RECHERCHE)
Opération RECHERCHE
Opération UNION
Pour faire l'union des ensembles i et j on parcourt tout le tableau T et pour chaque s tel que T[s] = j on
fait T[s] ¨ i.
Le temps d'exécution d'une opération UNION est donc proportionnel au nombre total d'objets.
2. Accélération de l'union
On peut représenter chaque ensemble par une liste liée dont les noeuds contiennent les objets. L'union
devient triviale, il suffit de connecter le dernier élément de C i au premier de C j .
Par contre la RECHERCHE se complique. Si l'on garde dans le dernier objet de chaque liste une
référence à l'ensemble qui correspond à cette liste, il faut parcourir en moyenne la moitié de la liste pour
atteindre ce dernier élément et savoir dans quel ensmble on se trouve. On pourrait bien sûr garder dans
avec chaque objet une référence à son ensmble, mais alors il faudrait mettre à jour cette référence à
chaque union et l'on perdrait le bénéfice de la méthode d'union rapide.
Exemple
Les arbres de la figure suivante représentent les ensembles {0,, 1, 3, 5, 2, 6}, {4, 7, 8}, {12, 10, 9} et
{11}.
UNION
Pour unir les ensembles i et j, c-à-d les ensembles dont les racines sont aux positions i et j de P, on choisit
au hasard entre i et j, disons j et on "accroche" l'arbre sous P[j] à P[i] en faisant simplement P[j] ¨ i.
RECHERCHE
Pour trouver l'ensemble auquel appartient l'objet k on rechrche la racine de l'arbre auquel appartient k. Le
temps de recherche est proportionnel à la profondeur de l'objet dans son arbre.
Le problème qui subsiste est que les arbres ainsi produits peuvent être très dégénérés et se comporter
comme des listes. Ce qui rend le temps de rechrche linéaire en fonction du nombre d'objets. Pour
améliorer la situation il suffit, lorsqu'on fait une union d'accrocher toujours le plus petit arbre à la racine
du plus grand.
Ainsi, si x appartient à un arbre de taille T, à chaque fois que son arbre devient sous-arbre d'un autre, et
donc que x s'éloigne d'un cran de la racine, l'ensemble auquel appartient x double au moins de taille. Un
ensemble ne peut doubler qu'au maximum log 2 (N) fois. Donc x se trouve au pire à une distance log 2
(N) de la racine.
Pour implémenter cette méthode il est nécessaire de connaître à tout moment la taille de chaque
ensemble, sans devoir la calculer. L'astuce consiste à garder l'inverse de la taille d'un arbre de racine i
dans P[i].
On peut réduire progressivement la longueur du chemin à parcourir depuis un objet x jusqu'à la racine en
procédant de la manière suivante. Chaque fois qu'on parcourt le chemin de x jusqu'à la racine, on en
profite pour attacher les noeuds rencontrés directement à la racine.
8 Algorithmique
Bien qu'il n'existe pas d'algorithme pour fabriquer un algorithme pour résoudre un problème donné, il
existe des techniques de conception des algorithmes. Parmi celles-ci on peut citer:
● diviser et conquérir
● la programmation dynamique
T(n) = 1 si n = 2
= 2T(n/2) + 2 si n > 2
Cette équation a pour solution la fonction T: n -> 3n/2 - 2
On voit donc que cette algorithme est 25% meilleur que le précédent.
Un algorithme exhaustif doit essayer toutes les valeurs possibles de i et j, ce qui donne n + (n-1) + (n-2)
+ ... + 2 + 1 = (n (n+1))/2 sommes. Chaque somme peut se calculer en une opération à partir de la somme
précédente, on doit donc effectuer (n (n+1))/2 additions et comparaisons.¨
Si l'on divise S en deux parties S1 = <s 1 , ..., s n/2 > et S2 = <s n/2+1 , ..., s n > on a trois cas possibles
soit la sous-suite max. se trouve dans S1; soit elle se trouve dans S2; soit elle commence dans S1 et finit
dans S2. Notons bien que dans ce dernier cas les éléments s n/2 et s n/2+1 appartiennent à la sous-suite.
L'algorithme s'écrit alors
entier MAXSOM(a, b)
si a = b
retourner s[a]
sinon {
d = (a+b)/2
max1 = MAXSOM(a, d)
max2 = MAXSOM(d+1, b)
-- calculer la plus grande somme qui commence en S1 et
-- finit en S2
-- 1. la plus grande somme qui commence en S1 et finit à d
m1 <- s[d]; somme <- s[d];
pour i de d-1 à a {
somme <- somme + s[i]
si (somme > m1) m1 <- t
-- 2. la plus grande somme qui commence en d+1 et finit dans S2
m2 <- s[d+1]; somme <- s[d+1];
pour i de d+1 à b {
somme <- somme + s[i]
si (somme > m2) m2 <- t
retourner max(max1, max2, m1+m2)
Le nombre de sommes et comparaisons est
T(n) = 1 si n = 1
= 2T(n/2) + 2n si n>1
Ce qui donne une complexité en O(n log n) (voir ci-après)
Preuve:
T(c k ) = aT(c k-1 ) +bc k = a(aT(c k-2 ) +bc k-1 )+bc k = ...
= a k b + a k-1 bc + a k-2 bc 2 + ... + abc k-1 + bc k
= bc k (a k /c k + a k-1 /c k-1 + ... + a/c + 1/1)
si a < c, la somme des a k /c k tend vers une limite constante r, donc T(n = c k ) £ bc k r
si a = c, la somme est égale à k+1 = log c n + 1, donc T(n) est de l'ordre de n log n
C'est un peu comme si on divisait le tableau en une partie de taille 1 et une autre de taille n-1. On sait que
la complexité de ce tri est quadratique.
Le tri-fusion est une méthode par division équilibrée
● trier les éléments de 0 à n/2-1
Temps d'exécution:
T(n) = 0 si n = 1
= 2T(n/2) + n si n>1
d'après les formules ci-dessus: T(n) est O(n log n)
8.3.1 Exemple.
Etant donné une monnaie qui possède des pièces de valeur v 1 , v 2 , ..., v k et une somme s, quel est le
nombre minimum de pièces pour obtenir cette somme ?
Un algorithme récursif descendant peut s'écrire comme
NMP(s) =
si s = 0 retourne 0
sinon retourne 1 + min{ NMP(s - v i ) | 1 £ i £ k et s - v i 0 }
8.3.2 Principe
L'idée consiste à stocker les résultats partiels déjà obtenus, de façon à éviter tout recalcul.
En général on procède de manière "bottom-up", c'est-à-dire qu'on commence par calculer les résultats
pour les cas les plus simples, puis on s'appuye sur ces résultats pour calculer des cas plus complexes, et
ainsi de suite.
Dans l'exemple précédent on aura:
NMP(0) = 0 -- cas trivial
NMP(1) = 1 + NMP(0) = 1
NMP(2) = 1 + min{NMP(1), NMP(0)} = 1
NMP(3) = 1 + min{NMP(2), NMP(1)} = 2
NMP(4) = 1 + min{NMP(3), NMP(2)} = 2
NMP(5) = 1 + min{NMP(4), NMP(3)} = 3
NMP(6) = 1 + min{NMP(5), NMP(4)} = 3
NMP(7) = 1 + min{NMP(6), NMP(5), NMP(0)} = 1
NMP(8) = 1 + min{NMP(7), NMP(6), NMP(1)} = 2
NMP(9) = 1 + min{NMP(8), NMP(7), NMP(2)} = 2
NMP(10) = 1 + min{NMP(9), NMP(8), NMP(3), NMP(0)} = 1
etc.
jusqu'à la valeur s
La complexité en temps est O(sk)
Le coût des multiplications n'est pas le même suivant l'ordre d'évaluation. Il s'agit donc de choisir le
meilleur ordre possible. Mais le nombre de possibilités est exponentiel en fonction de n. Il faut donc
éviter d'évaluer le coût de chaque ordre.
Lorsqu'on multiplie une matric p ¥ q par une matric q ¥ r on effectue O(pqr) opérations.
On peut établir la relation de récurrence:
soit m ij le coût de la multiplication (M i *...* M j )
m ik = 0 si i = j
On construit itérativement une solution en essayant de placer une reine dans la première colonne, puis
dans la deuxième, et ainsi de suite. Si, arrivé à la colonne k il n'y a plus de possibilité de placer une reine,
on revient à la colonne k-1 et on essaye de déplacer la reine qui s'y trouve. Si on peut le faire, on repart
en avant, à la colonne k, sinon on revient à la colonne k-2, et ainsi de suite.
Exemple sur un échiquier 4 ¥ 4:
Algortihme
méthode SolutionNReines
// mémorisation de la solution
int colonne[N] // ligne de chaque reine dans chaque colonne
// uniquement pour accélérer les tests
booléen rangee[N], diagonale1[2*N-1] , diagonale2[2*N-1]
essayer_colonne(j)
i¨ N
tant que (i > 0) {
si (case_non_menacée(i, j)) {
placer_reine(i, j)
si (j>1) essayer_colonne(j-1)
sinon afficher_échiquier
enlever_reine(i, j)
}
}
placer_reine(i, j)
colonne[j] ¨ i; rangée[i] ¨ vrai;
diagonale1[i+j] ¨ vrai; diagonale2[N+i-j] ¨ vrai
}
case_non_menacée(i, j)
retourne non rangee[i] et non diagonale1[i+j]
et non diagonale2[N+i-j]
[]
8.5.1 Exemple 1.
Dans le problème de l'obtention d'une somme à l'aide du minimum de pièces de monnaie on pourrait
définir un algorithme glouton de la forme:
S : la somme à atteindre
A¨0
NP ¨ 0
tant que (A < S) {
choisir la pièce v i de plus grand valeur telle que A+v i £ S
A ¨ A + v i ; NP ¨ NP + 1
}
On peut assez facilement se convaincre que cet algorithme donne bien le nombre minimum de pièces
pour toute somme S dans le cas où v 1 = 1, v 2 = 2, v 3 = 5 et v 4 = 10.
8.5.2 Exemple 2.
L'algorithme de Kruskal pour la recherche de l'arbre de recouvrement à coût minimal est un algorithme
glouton.
On peut prouver qu'il donne effectivement le meilleur arbre.
peut ensuite améliorer par des techniques telles que TABOU ou le "recuit simulé".
Algorithme:
entier témoin(a, i, n)
// calcule récursivement a^i mod n
// retourne 0 si le test du thm-2 échoue pendant le calcul
si i = 0 retourner 1
sinon
x <- témoin(a, i/2, n)
si x = 0 retourne 0 // n est composite
y = x^2 mod n
si (y = 1 et x =/= 1 et x =/= n-1) retourne 0 // échec thm-2
si i est impair (i mod 2 = 1)
retourne (a * y) mod n
sinon
retourne y
[]
booleen premier(n)
pour i de 1 à NB_ESSAIS
r = nb. aléatoire
si témoin(r, n-1, n) != 1 retourne faux
[]
retourne vrai // avec une probabilité d'erreur de 1/4^NB_ESSAIS
[Remonter] [Precedent] [ Suivant]
9 Données persistantes
On peut classer les données en deux catégories en fonction de leur durée de vie. Les données volatiles ou
temporaires n'existent que pendant l'exécution d'un programme particulier. Ce sont des données qui
représentent l'état d'un processus, des résultats partiels de calculs, ou d'autres données mais sous une
forme différente. Les données persistantes existent indépendemment de l'exécution des programmes.
Elles représentent typiquement des informations du monde réel sur lesquelles le système d'information
doit travailler. On demande aux données persistantes plusieurs qualités :
● qu'elles survivent à l'exécution des programmes et restent disponibles pendant un temps
arbitrairement long ;
● qu'elles soient utilisables par plusieurs programmes (éventuellement en parallèle);
Contraintes matérielles
La distinction entre données persitantes et volatiles ne trouve pas son origine dans des motifs d'ordre
conceptuels mais dans des contraintes matérielles. En effet, pour obtenir des mémoires d'ordinateurs
rapides et pas trop chères, il faut utiliser des technologies qui nécessitent une alimentation électrique
permanente. Ces systèmes (mémoires RAM) ne peuvent donc assurer de manière fiable le stockage
permanent de l'information. Ils sont par contre nécessaire pour stocker les programmes et données en
cours de traitement si l'on veut obtenir de bonnes performances. Par conséquent, les objets manipulés par
les programmes résident en général en mémoire centrale (RAM) et disparaissent à la fin de l'exécution du
programme.
La garantie de la persistance requière donc l'utilisation d'une mémoire "externe" (p.ex. disque
magnétique) qui possède d'autres caractéristiques que la mémoire centrale. Il s'agira donc de
● gérer les transferts entre types de mémoires ;
Coûts
En l'état actuel de la technologie (1999) la persistence des données est principalement assurée par des
supports de type disque magnétique, essentiellement pour des questions de coût.
La mémoire centrale électronique rapide (RAM) est chère (env. SFr. 5.- / megabyte) donc inutilisable
pour de grandes quantités des données persistantes. De plus elle nécessite une alimentation électrique
permanente.
Les mémoire magnétiques (disques) sont meilleure marché: (env. SFr. 0.20 / megabyte) et plus
compactes.
Sur les disques l'information est stockée sous forme de blocs d'octets de taille fixe (p.ex. 512 ou 1024
octets). La plus petite unité de lecture/écriture est le bloc. Il n'est pas possible de modifier directement un
octet particulier sur un disque. Pour y arriver il faudrait : lire tout le bloc en mémoire centrale ; modifier
l'octet en mémoire centrale ; récrire le bloc sur disque.
Temps d'accès
Le temps d'accès à un bloc (lecture ou écriture) étant très long (env. 10ms) par rapport à l'accès à une
cellule de la mémoire centrale (env. 80ns, donc 80 m s pour 1000 octets), il est impératif que les
traitements minimisent le nombre d'accès au disque. Ceci implique de
● regrouper toutes les opérations portant sur les octets d'un bloc pendant qu'il est en mémoire RAM.
● grouper par dans le même bloc les données qui sont en général manipulées ensemble.
Du point de vue du développement du logiciel on voit bien qu'il n'est pas simple de développer des
applications qui doivent travailler sur des données persistantes. Les différentes contraintes de temps
d'accès, d'organisation par blocs, de transfert entre types de mémoire vont considérablement compliquer
la tâche du concepteur et du développeur d'applications.
Comme d'habitude, la bonne idée consiste
1) à isoler et encapsuler dans des modules appropriés tout ce qui concerne la gestion de l'accès aux
supports physiques persistants ;
2) à définir des concepts abstraits sur lesquels pourra s'appuyer le développement d'applications à
données persistantes.
Parmi les abstractions qui ont eu le plus de succès pour la représentation des données persistantes on peut
citer :
les fichiers : des séquences d'octets persistants
les bases de données relationnelles : des tables persistantes, un élément d'une table est un n-tuple de
valeurs simples (nombres, chaînes de caractères, séquences d'octets, ...)
les bases de données "réseau" : des ensembles d'enregistrements de même type et des ensembles de liens
entre enregistrements ;
les bases de données hiérarchiques : des ensembles d'enregistrements et des liens hiérarchiques entre
enregistrements ;
objets persistants : les mêmes objets que ceux du programme mais persistants
9.1 Fichiers
9.1 Fichiers
Buts
Abstraction :
s'abstraire de la structure de blocs, voir les données sous forme d'objets de plus haut niveau.
Repérage :
nommer les données persistantes pour pouvoir les retrouver et y accéder à partir de n'importe quel
programme.
● supprimer : fichier
En général il existe dans les langages de programmation un type Fichier . Un objet de ce type, appelé
fichier logique , sert à représenter un fichier du disque, appelé fichier physique .
Les opérations effectuées sur le fichier logique sont reportées sur le fichier physique (lecture, écriture).
Par contre la création ou la disparition d'un fichier logique n'entraine en général pas celle du fichier
physique correspondant.
[Remonter] [Precedent] [ Suivant]
Exemples
Apple II
DEC PDP11 - RT11
IBM 3090 - VM (minidisques)
Avantage
Repérage immédiat du n-ième bloc
Problème
Pour le gros disques (1 gigaoctet) la FAT devient très grande
taille(FAT) = (nb. de blocs du disque) * [log 2 nb de blocs] bits
Si le fichier fait plus de 10 blocs on alloue un bloc d'index primaire qui contient les nos des N blocs
suivants.
Si le fichier fait plus de N + 10 blocs on alloue un bloc secondaire qui contient les nos des N blocs
d'index primaire.
Si le fichier fait plus de N 2 + N + 10 = (N + 10)(N - 9) + 100 blocs on alloue un bloc tertiaire ...
Suivant où se trouvent les octets à lire il faut accéder à 0, 1, 2,ou 3 blocs d'index avant d'arriver au bloc
de données. Pour accélérer les accès on peut essayer de garder en mémoire centrale le plus possible de
blocs d'index.
On peut considérer cette structure comme la juxtaposition de B-arbres de hauteur 1, 2, 3 et 4
respectivement.
Taille de TB en bits:
nb. [octets du disque / nb. octets par bloc]
ex. 1000Mo / 1K = 1000000 bits
Taille de TB en blocs:
[taille de TB en bits / (8 x nb. octets par bloc)].
ex. 1000000 / (8 x 1024) = 123 blocs
soit 0.0123 % du disque
● ajouter un tuple
● supprimer un tuple
Sélection:
Balayage séquentiel du fichier
Insertion:
le champ clé de l'enregistrement à insérer détermine la page où il doit aller
s'il existe une place libre dans la page : insérer dans cette page
sinon
stratégie 1: essayer une re-répartiion avec la page suivante si elle a de la place, et ainsi de suite
● problème: les enregistrements bougent, il faut modifier l'index
Sélection:
si la sélection porte sur l'attribut clé: recherche dichotomique dans l'index puis dans la page indiquée par
l'index; pour les autres attributs: balayage séquentiel.
[Remonter] [Precedent] [ Suivant]
● chaque tuple est suivi d'un "relation id" qui indique à quelle relation il appartient
Chaque tuple possède un "tuple identifier" (tid) qui est une paire (no. page, déplacement)
● une zone est réservée à la fin de la page pour les pointeurs de début de tuples, le déplacement est
relatif au premier pointeur (en partant de la fin)
● le tuple peut être déplacé dans la page sans changer son tid
A l'insertion on peut demander à placer un tuple le plus près possible d'un autre
si le prédicat est de la forme ( C 1 = v 1 et P' ) et qu'il existe un index sur C 1 : sélectionner par l'index le
sous-ensemble S des tuples qui satisfont C 1 = v 1 , puis sélectionner les tuples de S qui satisfont P' .
10.5.4 Jointure
1. balayage imbriqué
Pour calculer R * A = B S on fait
pour chaque tuple r de R
pour chaque tuple s de S
si r.A = s.B alors ajouter (r, s) au résultat
2. tri et composition
Trier R selon A et S selon B
Effectuer une fusion de R et S selon A et B.
Le temps de calcul est composé du temps de tri (au moins taille(rel)*log(taille(rel))) et du parcours des
deux relations (taille(R) + taille(S)).
● etc.
Liste
Une liste est représentée par un B-arbre dont les clés sont les positions des éléments dans la liste.
=> on peut retrouver efficacement le n-ième objet de la liste
Multi-ensemble
Un ensemble non unique (multi-ensemble) est représenté par
1) un B-arbre qui représente l'ensemble (unique) des éléments
2) un fichier séquentiel d'identificateurs des enregistrements du B-arbre
self-identifying
pour les SmallInt, Character, Boolean
byte
pour les String, Float, etc.
named
pour accéder aux composantes d'un objet par leur nom (noms des variables d'instance)
indexed
accès aux composantes d'un objet par le numéro, p.ex. pour les Array
non-sequenceable collections
utilisé pour les Set, Bag, etc. dont les composantes ne sont ni nommées ni numérottées.
Le système utilise des pointeurs orientés-objet (OOP) pour référencer les objets et une table de
correspondance entre OOP et adresse physique.
● L'identificateur d'un OdS (oid) est une paire (no. page, no. emplacement).
● Pour les grands objet l'oid pointe vers vers une entête de grand objet (EGO) qui réside dans une
page découpée avec d'autre EGO et petits objets. L'EGO pointe vers d'autres pages qui servent à
représenter le grand objet.