Colle 3 - Représentation binaire
Les fonctions sont à écrire en OCaml. Chaque question de programmation est suivie de tests qui doivent passer sans erreur.
L'objet de cette colle est la manipulation de représentations binaires d'entiers naturels. Si \(n \in \N^*\), on appelle représentation binaire de \(n\) l'expression notée \(\left(b_{N-1} b_{N-2} \ldots b_{1} b_{0}\right)_{2}\) où les entiers \(b_{i}\) prennent leurs valeurs dans l'ensemble \(\{0,1\}\), excepté \(b_{N-1}\) qui vaut \(1\). Cette expression se déduit de la décomposition binaire de l'entier \(n\) sous la forme :
Les entiers \(b_{i}\) sont appelés bits de \(n\). \(b_{N-1}\) est appelé bit de poids fort ; \(b_{0}\) est appelé bit de poids faible. \(N\) est le nombre de bits de la représentation binaire de \(n\).
On admettra que \(0\) se représente sur \(0\) bit par convention.
Question 1. Si \(n\) est un entier naturel, combien de bits sa représentation binaire comporte-t-elle ?
Corrigé
Si \(n > 0\), on a \(2^{N-1} \le n < 2^N\) d'après \((E)\) car \(b_{N-1} = 1\). Ainsi \(N - 1 \le \log_2 n < N\), c'est-à-dire \(N = \lfloor \log_2 n \rfloor + 1\). Par exemple, \(16 = (10000)_2\) s'écrit sur \(5\) bits.
Question 2. Écrire une fonction nb_bits : int -> int qui calcule ce
nombre à l'aide des seules opérations arithmétiques \(+,-, *, /\).
Corrigé
let rec nb_bits n =
if n = 0
then 0
else 1 + nb_bits (n / 2)
Tests :
assert(nb_bits 5 = 3);
assert(nb_bits 0 = 0);
assert(nb_bits 1 = 1);
assert(nb_bits 16 = 5)
Question 3. Écrire une fonction nat2bin : int -> int array qui renvoie le
plus petit tableau associé à la représentation binaire d'un entier naturel, son
bit de poids fort étant le dernier élément du tableau, son bit de poids faible
le premier élément du tableau. Ainsi, le nombre précédent s'écrit
[|b0;b1;...;bN-1|].
Corrigé
let nat2bin n =
let nb = nb_bits n in
let a = Array.make nb 0 in
let x = ref n in
for i = 0 to nb-1 do
a.(i) <- !x mod 2;
x := !x / 2
done;
a
Tests :
assert(nat2bin 5 = [|1;0;1|]);
assert(nat2bin 0 = [||]);
assert(nat2bin 1 = [|1|]);
assert(nat2bin 6 = [|0;1;1|])
Question 4. La relation \((E)\) peut être mise sous la forme équivalente suivante.
En quoi cette forme est-elle plus avantageuse, en termes de nombre d'opérations élémentaires, que la forme \((E)\) ? Justifier votre réponse.
Corrigé
Avec la forme \((E)\), si on calcule chaque puissance \(2^i\) indépendamment avec \(i-1\) multiplications, on effectue \(\sum_{i=1}^{N-1} i = O(N^2)\) multiplications. Même en calculant les puissances successivement (\(2^{i+1} = 2 \times 2^i\)), il faut deux multiplications et une addition par bit.
La seconde forme, dite de Horner, se calcule de l'intérieur vers l'extérieur avec exactement une multiplication et une addition par bit, soit \(N-1\) multiplications et \(N-1\) additions, sans calcul de puissance.
Question 5. En déduire une fonction bin2nat : int array -> int qui
renvoie l'entier dont le tableau argument est la représentation binaire.
Corrigé
let bin2nat a =
let nb = Array.length a in
let n = ref 0 in
for i = 0 to nb-1 do
n := 2 * !n + a.(nb-i-1)
done;
!n
Tests :
assert(bin2nat [|1;0;1|] = 5);
assert(bin2nat [||] = 0);
assert(bin2nat [|1|] = 1);
assert(bin2nat [|0;1;1|] = 6)
On définit les opérateurs binaires \(\wedge\) (et) et \(\oplus\) (ou exclusif) sur 1 bit par les règles suivantes :
| \(a\) | \(b\) | \(a \wedge b\) | \(a \oplus b\) |
|---|---|---|---|
| 0 | 0 | 0 | 0 |
| 0 | 1 | 0 | 1 |
| 1 | 0 | 0 | 1 |
| 1 | 1 | 1 | 0 |
Question 6. Écrire deux fonctions bit_and et bit_xor de même signature
int -> int -> int associées à ces opérateurs.
Corrigé
let bit_and x y =
if x = 1 && y = 1 then 1 else 0
let bit_xor x y =
if x <> y then 1 else 0
Tests :
assert(bit_and 0 0 = 0);
assert(bit_and 1 0 = 0);
assert(bit_and 0 1 = 0);
assert(bit_and 1 1 = 1);
assert(bit_xor 0 0 = 0);
assert(bit_xor 1 0 = 1);
assert(bit_xor 0 1 = 1);
assert(bit_xor 1 1 = 0)
Si \(x\) et \(y\) sont deux entiers naturels non nuls, on note \(\left(x_{n_{x}-1} \ldots x_{1} x_{0}\right)_{2}\) et \(\left(y_{n_{y}-1} \ldots y_{1} y_{0}\right)_{2}\) leurs représentations binaires sur respectivement \(n_{x}\) et \(n_{y}\) bits. On pose \(n_{\max }=\max \left(n_{x}, n_{y}\right)\) et on complète la plus courte des deux représentations par des bits nuls : \(x_i = 0\) pour \(i \ge n_x\) et \(y_i = 0\) pour \(i \ge n_y\).
On définit deux fonctions \(f_{\wedge}, f_{\oplus}\) telles que \(f_{\wedge}(x, y)\) et \(f_{\oplus}(x, y)\) sont respectivement les représentations binaires de deux entiers \(p\) et \(r\), avec pour tout entier \(i \in \range{0}{n_{\max}-1}\) :
Ainsi, pour \(i \ge \min(n_x, n_y)\), on a \(p_i = 0\) et \(r_i = z_i\) où \(z_i\) désigne le bit d'indice \(i\) du plus long des deux tableaux.
Question 7. Écrire une fonction :
bin_func : (int -> int -> int) -> int array -> int array -> int array
qui reçoit une fonction func comme bit_and ou bit_xor, deux tableaux
associés aux représentations binaires de deux entiers et qui renvoie le tableau
de taille \(n_{\max}\) obtenu en appliquant func à chaque bit des deux tableaux
arguments. Ce tableau peut se terminer par des bits nuls : ce n'est alors pas
le plus petit tableau représentant le résultat.
Corrigé
let bin_func func ax ay : int array =
let nx = Array.length ax in
let ny = Array.length ay in
(* bit d'indice i de a, de longueur n, complété par des 0 *)
let bit a n i = if i < n then a.(i) else 0 in
Array.init (max nx ny) (fun i -> func (bit ax nx i) (bit ay ny i))
Tests :
assert(bin_func bit_and [|1;0;1|] [|0;1|] = [|0;0;0|]);
assert(bin_func bit_xor [|1;0;1|] [|0;1|] = [|1;1;1|])
Question 8. En déduire deux fonctions bin_and et bin_xor de signature
int -> int -> int qui reçoivent deux entiers naturels et qui renvoient
l'entier naturel associé à l'appel de bin_func avec les fonctions bit_and
et bit_xor et des fonctions qui vous semblent utiles.
Corrigé
let bin_and x y =
bin2nat (bin_func bit_and (nat2bin x) (nat2bin y))
let bin_xor x y =
bin2nat (bin_func bit_xor (nat2bin x) (nat2bin y))
Tests :
assert(bin_and 5 7 = 5);
assert(bin_xor 5 7 = 2);
assert(bin_and 1664 1337 = 1024);
assert(bin_xor 1664 1337 = 953);
assert(bin_and 6 203 = 2);
assert(bin_xor 6 203 = 205);
assert(bin_and 16 15 = 0)
Remarque
On retrouve ainsi les opérateurs land et lxor d'OCaml : par exemple
16 land 15 vaut 0.
Question 9. x et y étant deux variables de type int, quel effet le
script suivant a-t-il sur le contenu de ces deux variables ? Justifier votre
réponse.
let x = bin_xor x y
let y = bin_xor x y
let x = bin_xor x y
Corrigé
Il échange les valeurs de x et y. Notons \(a\) et \(b\) les valeurs
initiales. L'opération \(\oplus\) est, bit à bit, associative, commutative,
d'élément neutre \(0\) et vérifie \(u \oplus u = 0\). Après la première ligne,
x vaut \(a \oplus b\). Après la deuxième, y vaut \((a \oplus b) \oplus b
= a \oplus (b \oplus b) = a\). Après la troisième, x vaut \((a \oplus b)
\oplus a = b\).
En toute rigueur, en OCaml, chaque let définit une nouvelle variable qui
masque la précédente : ce sont les noms x et y qui désignent, à la fin,
les valeurs échangées.
On définit les deux fonctions g : int -> int et h : int -> int suivantes.
let g x0 =
let c = ref 0 in
let x = ref x0 in
while !x > 0 do
c := !c + bin_and !x 1;
x := !x / 2
done;
!c
let h x0 =
let c = ref 0 in
let x = ref x0 in
while !x > 0 do
x := bin_and !x (!x-1);
c := !c + 1
done;
!c
Question 10. Justifier que la boucle présente dans chaque fonction n'est pas une boucle infinie.
Corrigé
Dans les deux cas, !x est un entier naturel et c'est un variant : il
décroît strictement à chaque tour de boucle.
- Dans
g, si \(x > 0\), alors \(\lfloor x/2 \rfloor < x\). - Dans
h, si \(x > 0\), notons \(i\) l'indice du bit à \(1\) de poids le plus faible de \(x\) : \(x = (\ldots 1 0 \ldots 0)_2\) avec \(i\) zéros à droite. Alors \(x - 1 = (\ldots 0 1 \ldots 1)_2\) : les bits d'indice \(> i\) sont inchangés, le bit \(i\) passe à \(0\) et les bits d'indice \(< i\) passent à \(1\). Ainsi \(x \wedge (x-1)\) est \(x\) dont on a mis le bit \(i\) à \(0\), donc \(x \wedge (x-1) = x - 2^i < x\).
Une suite strictement décroissante d'entiers naturels étant finie, les deux boucles terminent.
Question 11. Que calculent ces fonctions ? Justifier votre réponse.
Corrigé
Les deux fonctions calculent le nombre de bits à \(1\) dans la
représentation binaire de x0.
- Dans
g,bin_and !x 1vaut le bit de poids faible de!x, et diviser par \(2\) décale la représentation binaire d'un cran vers la droite. On ajoute donc successivement àcchacun des bits dex0. - Dans
h, d'après la question précédente, chaque tour de boucle met à \(0\) exactement un bit à \(1\) de!x. La boucle s'arrête quand!xvaut \(0\), donc après autant de tours quex0a de bits à \(1\), etccompte ces tours.
Question 12. L'une d'entre elles est-elle temporellement plus efficace que
l'autre si on admet que toutes les opérations élémentaires (y compris
bin_and) ont un même coût temporel ? Si oui, laquelle et pourquoi ?
Corrigé
Chaque tour de boucle effectue un nombre constant d'opérations dans les
deux cas. g effectue \(N\) tours, où \(N = \lfloor \log_2 x_0 \rfloor + 1\)
est le nombre de bits de x0, alors que h effectue autant de tours que
de bits à \(1\), ce qui est inférieur ou égal à \(N\). h est donc plus
efficace, nettement quand x0 comporte peu de bits à \(1\) : par exemple,
pour \(x_0 = 2^{20}\), g fait \(21\) tours et h un seul. Les deux sont
équivalentes quand tous les bits valent \(1\).