Tableaux dynamiques¶
On va se servir d'un champ mutable pour définir un type 'a dynarray de
tableaux dynamiques, c'est-à-dire de tableaux dont la taille pourra changer en
ajoutant des éléments dedans à la manière des list de Python.
Pour cela, on va d'abord commencer par définir des fonctions sur les array
(qui existent pour beaucoup dans la bibliothèque standard mais c'est surtout
l'occasion de s'entraîner).
Préliminaires : fonctions de manipulation de tableaux¶
Question 1. Écrire une fonction array_blit telle que
array_blit tab1 debut1 tab2 debut2 nb copie les nb éléments de tab1 à
partir de l'indice debut1 dans le tableau tab2 à partir de l'indice
debut2.
On appellera toujours cette fonction en faisant en sorte qu'il n'y ait pas d'accès erronés en mémoire.
Tests
let t1 = Array.init 5 (fun i -> i) in
let t2 = Array.init 6 (fun i -> i+1) in
array_blit t1 3 t2 1 2;
assert(t2 = [|1;3;4;4;5;6|]);
let t1 = [| 'a';'b';'c' |] in
let t2 = [| 'd'; 'e' |] in
array_blit t1 1 t2 0 2;
assert(t2 = [|'b';'c'|]);
"Valide";;
Corrigé
let array_blit (tab1:'a array) (debut1:int)
(tab2:'a array) (debut2:int) (nb:int) : unit =
for i = 0 to nb-1 do
tab2.(debut2 + i) <- tab1.(debut1 + i)
done
Question 2. Écrire une fonction array_extend telle que l'appel
array_extend tab debut nb renvoie un nouveau tableau contenant le même
contenu que tab sauf que l'on a rajouté nb éléments à partir de l'indice
debut. La valeur des éléments rajoutés n'est pas signifiante.
On suppose que tab est non vide.
Si debut vaut la longueur de tab, les nouveaux éléments sont rajoutés
après la recopie de tab.
On utilisera array_blit.
Tests
let t = Array.init 5 (fun i -> i) in
let t2 = array_extend t 3 5 in
assert(Array.length t2 = 10
&& Array.sub t2 0 3 = [|0;1;2|]
&& Array.sub t2 8 2 = [|3;4|]);
let t = Array.init 5 (fun i -> char_of_int (i+97) ) in
let t2 = array_extend t 0 3 in
assert(Array.length t2 = 8 && Array.sub t2 3 5 = t);
let t2 = array_extend t 5 3 in
assert(Array.length t2 = 8 && Array.sub t2 0 5 = t);
"Valide";;
Corrigé
let array_extend (tab:'a array) (debut:int) (nb:int) : 'a array =
let n = Array.length tab in
let tabn = Array.make (n + nb) tab.(0) in
array_blit tab 0 tabn 0 debut;
array_blit tab debut tabn (debut + nb) (n - debut);
tabn
En exécutant le code suivant, vous devriez obtenir
[|0; 1; 2; 0; 0; 0; 0; 0; 3; 4|] si vous avez rempli les nouvelles cases
avec tab.(0) comme dans le corrigé (seules les trois premières et les deux
dernières valeurs sont imposées).
let t = Array.init 5 (fun i -> i)
in array_extend t 3 5
Question 3. Écrire une fonction array_concat telle que l'appel
array_concat tab1 tab2 renvoie le nouveau tableau obtenu en plaçant les
éléments de tab2 à la suite de ceux de tab1.
Tests
assert(array_concat [|1;2|] [|3|] = [|1;2;3|]);
assert(array_concat [||] [||] = [||]);
assert(array_concat [||] [|1|] = [|1|]);
assert(array_concat [|1|] [||] = [|1|]);
"Valide";;
Corrigé
let array_concat (tab1:'a array) (tab2:'a array) : 'a array =
let n1 = Array.length tab1 in
let n2 = Array.length tab2 in
if n1 = 0 then tab2
else if n2 = 0 then tab1
else
let tab = Array.make (n1+n2) tab1.(0) in
array_blit tab1 0 tab 0 n1;
array_blit tab2 0 tab n1 n2;
tab
Attention, quand l'un des deux tableaux est vide, ce corrigé renvoie l'autre tableau lui-même et non une copie.
Question 4. Écrire une fonction array_delete telle que l'appel
array_delete tab debut nb renvoie un nouveau tableau obtenu en
supprimant nb éléments de tab à partir de l'indice debut.
On suppose que nb > 0 et donc que tab est non vide.
Tests
assert(array_delete [|1;2;3;4;5;6|] 2 2 = [|1;2;5;6|]);
assert(array_delete [|1;2;3;4;5;6|] 0 3 = [|4;5;6|]);
"Valide";;
Corrigé
let array_delete (tab : 'a array) (debut : int)
(nb : int) : 'a array =
let n = Array.length tab in
let t = Array.make (n - nb) tab.(0) in
array_blit tab 0 t 0 debut;
array_blit tab (debut + nb) t debut (n - nb - debut);
t
Premier type de tableaux dynamiques¶
On définit le type suivant pour contenir un tableau dynamique. On va devoir réallouer les éléments, donc on aura besoin d'accéder en écriture au tableau qui les contient, le plus simple dans un premier temps est de faire une référence sur un tableau.
Note le type est un type synonyme, par défaut, OCaml va afficher
'a array ref dans ses résultats mais on peut forcer l'affichage de
'a dynarray en spécifiant les types dans les fonctions comme on va le faire
en dessous.
type 'a dynarray = 'a array ref
Comme on va définir des tableaux en partant du tableau vide, comme en Python
avec [], on peut se permettre de ne pas donner le type des éléments. En
fait, contrairement à ce qu'il semble, la valeur v ci-dessous n'a pas un type
magique : le 'a sera instancié le plus vite possible, ce qu'on peut voir
avec le '_weak1 dans son type.
Attention : vraiment comprendre ces notions de paramètres génériques faibles est trop complexe et dépasse le cadre du cours de CPGE.
let dynarray_init () = ref [||]
let v = dynarray_init ()
v := [|1; 2|]
(* Maintenant v est un int dynarray *)
v
On va maintenant définir des fonctions d'ajout et de suppression d'éléments
dans le tableau. Pour cela, on va réallouer le tableau grâce aux fonctions de
la partie précédente. Attention : on rappelle que dans array_extend on a
supposé le tableau non vide, il faudra donc sûrement traiter ce cas à part.
Question 5. Écrire une fonction dynarray_append telle que l'appel
dynarray_append tab x ajoute x à la fin du tableau tab.
Tests
let t = dynarray_init () in
dynarray_append t 1;
dynarray_append t 2;
dynarray_append t 3;
dynarray_append t 4;
assert(!t = [|1;2;3;4|]);
"Valide";;
Corrigé
let dynarray_append (tab : 'a dynarray) (x : 'a) : unit =
let n = Array.length !tab in
if n = 0 then tab := [|x|]
else begin
let t = array_extend !tab n 1 in
t.(n) <- x;
tab := t
end
Question 6. Écrire une fonction dynarray_pop telle que l'appel à
dynarray_pop tab supprime le dernier élément de tab et le renvoie.
Tests
let t = ref [|1;2;3;4|] in
let x = dynarray_pop t in
assert(x = 4 && !t = [|1;2;3|]);
"Valide";;
Corrigé
let dynarray_pop (tab : 'a dynarray) : 'a =
let n = Array.length !tab in
let x = !tab.(n-1) in
let t = array_delete !tab (n-1) 1 in
tab := t;
x
Chaque ajout et chaque suppression recopie tout le tableau : une suite de \(n\) ajouts à partir du tableau vide coûte \(\sum_{k=1}^{n} O(k) = O(n^2)\).
Type efficace pour les tableaux dynamiques¶
Afin de résoudre les problèmes de complexité observés précédemment, on va
définir un nouveau type pour les tableaux en permettant d'avoir un tableau
contenant plus de cases que ce qui est vraiment utilisé. Ainsi, si on a un
tableau de taille 10 mais qui ne contient que 9 éléments réels, on pourra en
temps constant rajouter un élément. Pour cela, on va avoir besoin non
seulement de gérer les éléments du tableau, mais aussi un paramètre length
donnant la longueur logique, c'est-à-dire le nombre d'éléments signifiants du
tableau.
type 'a dynarray = {
mutable elements : 'a array;
mutable length : int
}
let dynarray_init () = { elements = [||]; length = 0 }
Question 7. Écrire une fonction dynarray_of_array telle que l'appel
dynarray_of_array tab où tab est un array renvoie le dynarray le
contenant : on initialisera sa longueur à la longueur de tab.
Tests
assert(dynarray_of_array [|1;2|] = { elements = [|1;2|]; length = 2 });
"Valide";;
Corrigé
let dynarray_of_array (tab : 'a array) : 'a dynarray =
{ elements = tab; length = Array.length tab }
Question 8. Écrire des fonctions get et set telles que l'appel
get tab i renvoie le \(i\)-ème élément du tableau contenu par tab et l'appel
set tab i x place la valeur x dans le tableau tab à l'indice i. On
vérifiera que i est un indice valide, c'est-à-dire strictement inférieur à
la longueur logique.
Tests
let d = dynarray_of_array [|1;2|] in
assert(get d 1 = 2);
set d 1 3;
assert(d.elements = [|1;3|]);
"Valide";;
Corrigé
let get (tab : 'a dynarray) (i : int) : 'a =
if i < 0 || i >= tab.length then invalid_arg "get";
tab.elements.(i)
let set (tab : 'a dynarray) (i : int) (x : 'a) : unit =
if i < 0 || i >= tab.length then invalid_arg "set";
tab.elements.(i) <- x
Pour ajouter des éléments au tableau, on va regarder s'il y a de la place
(t.length < Array.length t.elements) et sinon on va doubler sa taille.
Si le tableau contenait 3 éléments, on va en allouer 6 donc 3 de plus, ce qui
va permettre de faire 3 ajouts sans réallocation (dont celui en cours).
Attention : quand le tableau est vide, on ne pourra pas doubler sa longueur, on se contente de faire un tableau de taille 1 dans ce cas.
Question 9. Écrire une fonction dynarray_append telle que l'appel
dynarray_append tab x ajoute x à la fin du tableau tab avec la stratégie
exposée.
Tests
let t = dynarray_init () in
dynarray_append t 1;
dynarray_append t 2;
dynarray_append t 3;
dynarray_append t 4;
dynarray_append t 5;
assert(Array.sub t.elements 0 5 = [|1; 2; 3; 4; 5|]
&& t.length = 5);
"Valide";;
Corrigé
let dynarray_append (tab : 'a dynarray) (x : 'a) : unit =
let n = Array.length tab.elements in
if n = 0 then begin
tab.elements <- [|x|];
tab.length <- 1
end
else begin
if tab.length = Array.length tab.elements
then tab.elements <-
array_extend tab.elements tab.length tab.length;
tab.elements.(tab.length) <- x;
tab.length <- 1 + tab.length
end
Question 10. Écrire une fonction dynarray_pop telle que l'appel
dynarray_pop tab supprime le dernier élément de tab et le renvoie.
On ne supprime rien physiquement, on se contente de changer la valeur
length.
Tests
let t = { elements = [|1;2;3;4;5|]; length = 4 } in
let x = dynarray_pop t in
assert(x = 4 && t.elements = [|1;2;3;4;5|] && t.length = 3);
"Valide";;
Corrigé
let dynarray_pop (tab : 'a dynarray) : 'a =
if tab.length = 0 then failwith "tableau vide";
let x = tab.elements.(tab.length-1) in
tab.length <- tab.length - 1;
x
Question 11. Prouver que si on effectue une suite de \(n\) append sur un
tableau vide on fait \(O(n)\) opérations élémentaires.
Même question avec des append et des pop.
Corrigé
Un append sans réallocation coûte \(O(1)\). Une réallocation a lieu
quand la capacité \(c\) est atteinte, et coûte \(O(c)\) (recopie de \(c\)
éléments). Lors de \(n\) ajouts à partir du tableau vide, les capacités
successives sont \(1, 2, 4, \dots, 2^k\) avec \(2^k < n\) pour celles qui
sont recopiées : le coût total des recopies est
\(O(1 + 2 + \dots + 2^k) = O(2^{k+1}) = O(n)\), et le coût total est donc
\(O(n)\).
Avec des append et des pop, un pop coûte \(O(1)\) et ne diminue
jamais la capacité. Si on effectue \(n\) opérations, la longueur reste
inférieure à \(n\), donc la capacité aussi (elle vaut au plus le double
d'une longueur atteinte). Les réallocations se font pour des capacités
deux à deux distinctes, puissances de 2 et inférieures à \(n\) : leur coût
total est encore \(O(n)\).
Si on effectue 1 milliard d'append suivis d'1 milliard de pop, la taille
physique sera toujours d'1 milliard d'éléments. Cela semble peu efficace et on
aimerait que la taille physique soit toujours proche du nombre réel
d'éléments.
Question 12. Comment peut-on modifier dynarray_pop pour s'assurer que le
nombre d'éléments réels est toujours proportionnel à la taille physique tout
en conservant un temps constant en moyenne ?
Faites la modification et faites une preuve assurant que cela fonctionne.
Corrigé
Diviser la capacité par 2 dès que le tableau est à moitié vide ne
convient pas : en alternant append et pop autour de cette limite, on
réalloue à chaque opération. On divise donc la capacité par 2 quand le
tableau n'est plus rempli qu'au quart :
let dynarray_pop (tab : 'a dynarray) : 'a =
if tab.length = 0 then failwith "tableau vide";
let x = tab.elements.(tab.length-1) in
tab.length <- tab.length - 1;
let cap = Array.length tab.elements in
(* on divise la capacité par 2 quand le tableau est rempli au quart *)
if 4 * tab.length <= cap && cap > 1
then tab.elements <- Array.sub tab.elements 0 (cap / 2);
x
En notant \(\ell\) la longueur et \(c\) la capacité, on vérifie que \(c \le 4\ell\) reste vrai après chaque opération dès que \(\ell \ge 1\) (après une division, \(c = 2\ell\)) : la taille physique reste proportionnelle au nombre d'éléments.
Pour la complexité, on utilise la méthode du potentiel (voir le chapitre sur la complexité amortie) avec \(\Phi = |2\ell - c|\), qui est positif et nul pour le tableau vide initial (\(\ell = c = 0\)). Une opération sans réallocation modifie \(\ell\) de 1 donc \(\Phi\) d'au plus 2 : coût amorti \(O(1)\). Juste avant un doublement, \(\ell = c\) donc \(\Phi = c\), et juste après \(\Phi = 2(c+1) - 2c = 2\) : la baisse de \(\Phi\) paie la recopie de \(c\) éléments. Juste avant une division par 2, \(\ell = \frac{c}{4}\) donc \(\Phi = \frac{c}{2}\), et juste après \(\Phi = |\frac{c}{2} - \frac{c}{2}| = 0\) : la baisse de \(\Phi\) paie la recopie des \(\frac{c}{2}\) cases. Le coût amorti de chaque opération est donc \(O(1)\), et une suite de \(n\) opérations coûte \(O(n)\).