• Aucun résultat trouvé

Informatique pour tous deuxième année

N/A
N/A
Protected

Academic year: 2022

Partager "Informatique pour tous deuxième année"

Copied!
59
0
0

Texte intégral

(1)

Informatique pour tous deuxième année

Julien Reichert

2021/2022

(2)
(3)

Table des matières

I Cours 5

1 Piles 7

2 Récursivité 11

2.1 Principes . . . 11

2.2 Preuves de terminaison et de correction . . . 15

2.3 Complexité . . . 16

3 Tris 19 3.1 Tri par insertion et tri par sélection . . . 20

3.2 Tri à bulles . . . 22

3.3 Tri fusion . . . 22

3.4 Tri rapide . . . 24

3.5 Borne inférieure de complexité . . . 28

3.6 Tris en temps linéaire . . . 28

II Travaux pratiques 31

TP 1 : Piles 33

TP 2 : Applications des piles 35

TP 3 : Récursivité 39

TP 4 : Tris 43

3

(4)

TP 5 : Inversion de matrices 47

TP 6 : Descente de gradient 49

(5)

Première partie Cours

5

(6)
(7)

Chapitre 1 Piles

Ce cours présente une structure de données avancée, la pile, permettant de remplacer d'autres structures plus basiques lorsque leur utilisation est moins pertinente ou ecace. Nous verrons comment implémenter des piles de manière eective, ainsi que les opérations que l'on peut réaliser sur elles, puis des applications classiques.

Dénition

Une pile est une structure de données dont les éléments sont organisés de sorte que seul un d'entre eux, le dernier ajouté (on dit empilé ), soit accessible directement.

Les autres sont par ailleurs stockés à une profondeur correspondant à leur ordre d'apparition, du plus récent au plus ancien.

Exemple : Les piles d'objets rencontrés dans la réalité s'approchent bien de la struc- ture de pile, à ceci près qu'avec un minimum d'adresse on peut accéder à n'importe quel élément d'une pile. En informatique, on ne peut cependant pas soulever directement la quantité qui encombre pour prendre un élément quelconque. On note d'ailleurs une diérence similaire entre la sélection dans une liste en informatique, en temps linéaire, et dans la réalité, où c'est souvent immédiat.

On qualie la pile de structure LIFO, pour Last In, First Out, car le dernier arrivé est le premier sorti. Ceci distingue une pile d'une le, structure First In, First Out d'implémentation plus complexe et hors programme en IPT.

7

(8)

Une pile se distingue par le caractère dynamique de sa taille (des éléments doivent pouvoir être ajoutés et retirés à l'envi, en respectant la structure). Bien qu'on sou- haiterait disposer de piles non bornées (ou du moins de capacité susante pour que le souci du dépassement ne se pose pas), ceci est problématique au niveau de l'im- plémentation. On considèrera alors dans un premier temps qu'on alloue au moment de la création d'une pile une capacité maximale à ne pas dépasser, et on veillera au niveau des opérations à respecter ce seuil.

La première opération est alors la création d'une pile : creer_pile(c), renvoie une pile vide de capacité l'entier c.

La deuxième opération est l'empilement : empiler(p, v) empile l'élément v au som- met de la pile p. On peut soit faire renvoyer la nouvelle pile par la fonction, soit considérer qu'il s'agit d'un eet de bord (plus fréquent).

La troisième opération est le dépilement : depiler(p) dépile le sommet de la pile p (sous réserve que p ne soit pas vide) et le renvoie. Là aussi, le retrait du sommet est souvent un eet de bord et la pile modiée n'est pas renvoyée elle-même.

D'autres opérations, qui peuvent s'obtenir à partir des premières mais qu'on considère comme existantes pour se faciliter la vie1, sont classiques pour les piles : taille(p) renvoie le nombre d'éléments contenus dans une pile (ce n'est pas la capacité !), est_vide(p) détermine si une pile est vide, et sommet(p) renvoie le sommet d'une pile sans le dépiler.

Python possède également une implémentation native des piles à l'aide de listes, avec les méthodes p.append(a) pour empiler l'élément a dans la pile p, et p.pop() pour dépiler le sommet de la pile p, qui est renvoyé par la fonction. En outre, la pile vide est []. L'avantage est qu'on n'a pas besoin d'allouer de taille a priori à la liste qui représentera la pile.

Pour aller plus loin. . .

En ce qui concerne la gestion des listes, la méthode pop utilise éventuellement un argument indiquant la position à laquelle le dépilement (qui ne mérite alors plus tellement son nom) se fait. Les indices supérieurs à ladite position subissent un

1. Encore du sucre syntaxique, comme partout. . .

(9)

9 décalage d'un cran de sorte que la complexité de l'opération l.pop(i) est assimilée à len(l)-i.

D'autres façons de retirer des éléments dans une liste sont la méthode remove, qui cette fois-ci impose de préciser la valeur à retirer, avec une erreur si l'élément est absent, et l'instruction del qui eace un ou plusieurs éléments de la liste, avec les mêmes décalages que pour pop (la complexité n'est cependant pas en O(nk) si on retirek éléments d'une liste de taillen mais enO(n+k)dans la mesure où on ne fait les décalages qu'une fois et qu'on libère les cases mémoire d'une certaine façon (il faut penser qu'on ne supprime pas aussi vite un milliard d'éléments qu'une dizaine. . .).

(10)
(11)

Chapitre 2 Récursivité

2.1 Principes

Dans ce cours, nous présentons une alternative à l'utilisation de boucles en program- mation, qui rejoint la notion mathématique de suite récurrente : la récursivité.

Il s'agit, lors de la dénition d'une fonction, d'utiliser la fonction elle-même appelée sur un autre argument de sorte qu'un cas de base nisse par être atteint1.

Par exemple, si on veut trouver la factorielle d'un nombre entier n, une version itérative2 de la fonction sera :

def fact(n):

rep = 1

for i in range(1, n+1):

rep *= i return rep

. . . et une version récursive dénira une suite un telle que un=nun−1 etu0 = 1 : def factrec(n):

1. C'est préférable. . . 2. donc avec boucles

11

(12)

if n < 0:

raise ValueError("On voudrait un nombre positif !") elif n == 0:

return 1 else:

return n * factrec(n-1)

Un appel à factrec se fera forcément avec un argument valant 1 de moins que l'argument actuel, de sorte que 0 nisse par être atteint dès lors que la fonction est appelée initialement sur un entier naturel. Nous retrouvons le problème de preuve de terminaison qui se pose pour les boucles utilisant while, ainsi que le calcul de complexité d'une fonction.

Les appels récursifs sont stockés dans une pile de taille susante (environ mille en Python, modiable par la fonction setrecursionlimit du module sys) pour ne pas causer de problème quand la fonction termine.3

Contrairement à un humain, un ordinateur ne va pas spontanément se contenter de ne calculer qu'une fois une valeur d'une fonction récursive pour un même argu- ment, et c'est au programmeur de prendre garde à ne pas causer une explosion de la complexité.4

Par exemple, pour le calcul d'une approximation de la racine carrée d'un nombre a à l'aide d'une suite dénie par u0 = 1 (ou quelconque > 0) et un+1 = 12(un+ ua

n), imaginons qu'on écrive :

def racine(a, n): # a flottant, n nombre d'appels if n < 0:

raise ValueError("On voudrait un nombre positif d'appels !") elif n == 0:

return a else:

return 1/2 * (racine(a, n-1) + a/racine(a, n-1))

L'appel de racine(2, 3) va faire calculer racine(2, 2) deux fois, où à chaque fois

3. À ce sujet, regarder ce que Python retourne dans la version récursive de factorielle quand on l'appelle sur -1 sans mettre dans la fonction le test n < 0

4. Pour ceux qui font l'option, penser à la programmation dynamique.

(13)

2.1. PRINCIPES 13 racine(2, 1) sera calculée deux fois, pour un total de quinze appels à la fonction, soit une complexité exponentielle. A contrario, réécrivons le code ainsi :

def racine(a, n): # a flottant, n nombre d'appels if n < 0:

raise ValueError("On voudrait un nombre positif d'appels !") elif n == 0:

return a else:

rac = racine(a, n-1)

return 1/2 * (rac + a/rac)

Cette fois, le nombre d'appels à la fonction sera linéaire.

Pour reprendre le détail de l'état du système tel que présenté en première année dans la section sur la programmation, au moment d'aecter à la variable rac la valeur racine(a, n-1), cette dernière est indéterminée et nécessite de traiter l'appel récursif, qui va lui-même engendrer la même situation jusqu'à ce que n vaille 0.

L'état du système pourra être présenté en tant qu'empilement d'états avec un point d'interrogation au niveau de la valeur de rac à côté de la valeur de n et de la valeur (xe) de a, correspondant à l'appel à racine(a, n).

Une fois le cas de base atteint, les aectations peuvent s'enchaîner pour retrouver la valeur attendue lors du premier appel. On peut avoir un aperçu des opérations eectuées en procédant à un débogage.

Le principe d'écriture d'une fonction récursive reprend celui du raisonnement par récurrence, en trouvant au moins un cas de base et en déterminant les relations d'hérédité sans oublier de cas.

Il n'est bien entendu pas interdit de faire plus de cas de base que nécessaire, bien que cela alourdisse l'écriture et l'exécution (passage par plus de conditionnelles).

Du point de vue de l'ordinateur, il existe aussi une diérence d'ecacité suivant le moment où l'appel récursif est eectué.

(14)

Prenons l'exemple d'une fonction puissance5 : def puissance(a, n): # a ** n

if n < 0:

raise ValueError("On voudrait un exposant positif !") elif n == 0:

return 1 else:

return a * puissance(a,n-1)

La pile d'appels nécessite d'aller jusqu'au calcul de puissance(a, 0), avant de pro- céder aux n multiplications. A contrario, il est envisageable d'écrire une version com- prenant un accumulateur et dans lequel la dernière opération serait l'appel récursif (au lieu de la multiplication ici).

def puissance(a, n): # a ** n

def puiss3(a, i, accu): # a ** i * accu vaut à tout moment a ** n if n < 0:

raise ValueError("On voudrait un exposant positif !") elif n == 0:

return accu else:

return puiss3(a, i-1, a*accu) return puiss3(a, n, 1)

On parle alors de récursivité terminale, et l'optimisation au niveau de la pile d'ap- pels, bien que n'impactant pas la complexité a priori, rend en gros des programmes récursifs aussi ecaces que leurs pendants itératifs.

Cette remarque ne doit pas pour autant forcer à utiliser la récursivité terminale systématiquement, notamment en Python, où l'intérêt est limité par choix personnel du créateur du langage6.

5. Rappelons au passage que même si la fonction que l'on s'apprête à écrire est de complexité polynomiale en temps, on peut écrire une autre version, éventuellement récursive, de complexité logarithmique en temps.

6. Pour les curieux, voir http://neopythonic.blogspot.fr/2009/04/

tail-recursion-elimination.html, et un contre-argument : http://flyingfrogblog.

blogspot.fr/2009/04/when-celebrity-programmers-attack-guido.html (liens en anglais)

(15)

2.2. PREUVES DE TERMINAISON ET DE CORRECTION 15 Signalons enn que la récursivité peut s'appliquer indirectement, ce qui permet d'écrire des fonctions s'appelant l'une l'autre :

def pair(n):

if n == 0:

return True else:

return impair(n-1) def impair(n):

if n == 0:

return False else:

return pair(n-1)

Bien entendu, la limitation de profondeur de récursion fait que cette fonction ne marche que pour des entiers entre 0 et 998 inclus.

2.2 Preuves de terminaison et de correction

Bien entendu, prouver la terminaison et la correction d'une fonction récursive se fait essentiellement par récurrence. En quelque sorte, on fait tourner la fonction à la main, et on expose les principes mathématiques qui ont conduit à l'écrire.

Prenons l'exemple de la fonction puissance. On prouve par récurrence sur n que puissance(a, n) (première version) termine quelle que soient les valeurs de a et de n (entier) et retourne a à la puissance n si n est positif ou nul.

On écritP(n)cette propriété. Si n est strictement négatif, on constate que la fonction déclenche une erreur ; si n vaut 0, la fonction termine immédiatement et renvoie 1, ce qui est eectivement a à la puissance 0pour tout a.

Supposons P(n) vraie. Montrons que cela implique P(n+1) : puissance(a, n+1) retourne a * puissance(a, n), ce qui correspond à a fois a à la puissance n par hypothèse de récurrence. Ceci permet de conclure quant à la terminaison et la cor- rection, et donc de généraliser.

Il est également possible de prouver la terminaison d'une fonction récursive en exhi-

(16)

bant une fonctionf à valeurs dansNtelle que l'image parf des arguments successifs des appels récursifs imbriqués décroisse strictement à chaque étape. Le principe est le même que pour les variants des boucles conditionnelles.

Notons que démontrer la terminaison n'est pas toujours à notre portée7, comme en témoigne l'exemple classique de la conjecture de Syracuse : soit un telle que un+1 vaille u2n si un est pair et 3un+ 1 sinon. On pense que la suite atteint à un moment la valeur 1 quelle que soit la valeur de u0, mais il n'existe pas de preuve à ce jour.

2.3 Complexité

Les complexités en temps s'expriment toujours en termes d'opérations élémentaires, en omettant éventuellement des opérations élémentaires dont le coût est négligeable devant celui d'autres opérations, si elles ne sont pas largement majoritaires.

Pour calculer la complexité d'une fonction récursive, on introduit une suite récurrente dont les indices sont les arguments de la fonction et les valeurs représentent les coûts des appels récursifs.

Par exemple, la complexité de la fonction factorielle se trouve ainsi :

Pour n < 0, on a une comparaison concernant n et une erreur sans calcul.

Pour n == 0, on a deux comparaisons et une valeur de retour sans calcul.

Pour n > 0, on a deux comparaisons, une addition (le calcul de n-1), une multiplication et le coût de l'appel de la fonction pour n-1.

En négligeant le coût des comparaisons à zéro (voire des additions), on introduit la suite cn des coûts, avecc0 = 0 (on oublie les négatifs) et cn+1 =cn+ 2, d'oùcn= 2n. En cas de doute sur les opérations élémentaires, on peut aussi exprimer la complexité en termes d'appels récursifs. Quant à la complexité en espace, elle se calcule sur le même principe, mais il s'agit de savoir si les opérations se font sur place ou non, c'est-à-dire si les données sont écrasées (on fait en quelque sorte le coût maximal parmi tous les appels) ou non (auquel cas on aura une somme).

On donne (ou rappelle) ici des complexités classiques à partir de relations de récur- rences entre le coût cn et le coût pourn−1ou n2 ou une autre valeur moindre.

7. Et c'est un problème indécidable, à la base de la théorie de la calculabilité en informatique !

(17)

2.3. COMPLEXITÉ 17 cn =cn−1+O(1) : cn=O(n).

cn =acn−1+O(1),a >1 : cn =O(an). cn =cn−1+O(n) : cn =O(n2).

cn =ncn−1+O(1) : cn =O(n!). cn =cn

2 +O(1) : cn =O(logn). cn =cn

2 +O(n) :cn=O(n). cn = 2cn

2 +O(1) :cn=O(n). cn = 2cn

2 +O(n): cn=O(nlogn). cn =acn

b, a et b étant des entiers strictement positifs : cn =O(nlogba).

(18)
(19)

Chapitre 3 Tris

Partant du principe que des structures de données ordonnées sont utiles dans bien des situations, notamment la recherche d'un élément1, nous allons nous intéresser dans ce cours à quelques-uns des nombreux algorithmes permettant de trier une liste d'éléments à valeurs dans un même ensemble totalement ordonné.

Les tris sont discriminés par leur complexité en termes de comparaisons et d'aecta- tions d'éléments de la liste (ou d'échanges, si le tri se fait en place).

Ils sont présentés avec leur principe, une implémentation en Python, une preuve du programme et la complexité.

En ce qui concerne la complexité en espace, quand les tris sont en place (au prix d'un eort de compréhension supplémentaire. . . et de rigueur dans les preuves de programme), aucune mémoire supplémentaire n'est utilisée. Sinon, un espace linéaire est généralement requis.

1. Imaginons ne serait-ce qu'une seconde un dictionnaire dans lequel les mots sont triés par ordre chronologique de leur ajout.

19

(20)

3.1 Tri par insertion et tri par sélection

Les deux algorithmes présentés dans cette section sont traités en parallèle, de par leur ressemblance et le fait qu'ils sont les deux tris les plus naturels possibles.

Ils consistent à construire la liste triée par ajout successif d'un élément, soit en insérant à chaque fois au bon endroit dans la liste en construction l'élément suivant de la liste de départ, soit en récupérant à chaque fois l'élément minimal (ou maximal) de la liste des éléments non encore triés.

def insertion_sort(l):

for i in range(1, len(l)): # on parcourt l j = i-1 # position après laquelle

# on va finalement insérer le i-ième élément x = l[i] # mémorisation de l'élément

while j >= 0 and l[j] > x: # recherche de la bonne position l[j+1] = l[j] # on décale le reste de la liste

j -= 1

l[j+1] = x # insertion proprement dite

Voyons ce qui se passe sur la liste [101,103,105,102] quand i vaut 3 dans la boucle principale.

La variable j est initialisée à 2 et la variable x à 102, puis la première comparaison est eectuée : 105 > 102, donc on met à jour l[3] à 105 et j à 1, puis 103 > 102, donc on met à jour l[2] à 103 et j à 0, mais 101 <= 102, donc on sort de la boucle puis on met à jour l[1] à 102, de sorte qu'aucun élément ne soit perdu.

Par la condition dans la boucle intérieure, on constate qu'un élément supplémentaire a été inséré dans l'ordre, ce qui permet de donner l'invariant de boucle suivant : après le i-ième tour de la boucle intérieure, les i+1 premiers éléments de la liste sont dans l'ordre croissant, et bien entendu la liste complète est une permutation de la liste de départ.

La complexité dans le pire des cas est atteinte quand l est initialement dans l'ordre inverse. Dans ce cas, il faudra faire toutes les comparaisons à chaque tour dans la boucle, soit un nombre quadratique et autant de mises à jour.

(21)

3.1. TRI PAR INSERTION ET TRI PAR SÉLECTION 21 On signale que la suppression et l'insertion de t[i] ne ferait pas formellement gagner de complexité. La complexité dans le meilleur des cas est atteinte pour une liste triée, où seules n-1 comparaisons sont nécessaires. Pour la majorité des algorithmes, les optima de complexité sont par ailleurs atteints quand la liste de départ est ordonnée.

def selection_sort(l):

for i in range(len(l)-1):

# si i = n-1, le minimum est le seul élément restant ind_min = i # où est le minimum a priori for j in range(i+1, len(l)):

if l[j] < l[ind_min]: # mise à jour du minimum ind_min = j

if ind_min != i:

# l'avantage du tri par sélection est le faible nombre d'échanges,

# on fait l'hypothèse qu'ils sont plus chers que les comparaisons l[i], l[ind_min] = l[ind_min], l[i]

En considérant la même liste qu'avant, pour i valant 0 on cherche le minimum de la liste, qui est 101, ce qui n'occasionne pas d'échange, puis pour i valant 1 on cherche le minimum du reste, qui est 102, échangé avec 103 en tant qu'élément en position i, puis pour i valant 2 c'est 103 qui est le minimum, occasionnant un dernier échange, et le dernier élément ne peut alors être que le maximum.

L'invariant de boucle est cette fois le suivant : après le i-ième tour de la boucle intérieure, les i premiers éléments de la liste sont les i plus petits éléments de la liste de départ dans l'ordre croissant, et là encore la liste complète est une permutation de la liste de départ.

La complexité du tri par sélection en nombre de comparaisons est forcément n(n−1)2 , car la recherche du minimum à chaque étape n'est pas optimisée et donc elle doit toujours être faite. A contrario, le nombre d'aectations est linéaire, ce qui est un plus par rapport aux autres tris.

En fait, le tri par sélection est celui qui sera souvent spontanément eectué par un humain devant par exemple classer toutes les cartes d'une seule couleur pour ranger un paquet : il cherchera d'abord la plus petite, puis la suivante, etc. jusqu'à les avoir toutes récupérées.

(22)

3.2 Tri à bulles

Le tri à bulles tient son nom de son principe : faire remonter les éléments les plus grands vers la n d'une liste comme des bulles remontent vers la surface d'un liquide.

Il procède à un échange de deux éléments consécutifs à chaque fois qu'ils sont mal ordonnés, ce qui occasionne une complexité quadratique en moyenne2, le nombre de comparaisons étant de toute façon n(n−1)2 3.

En guise de compensation, le code est concis et simple à comprendre : def bubble_sort(l):

for i in range(len(l)-1, 0, -1):

for j in range(i):

if l[j] > l[j+1]:

l[j], l[j+1] = l[j+1], l[j]

L'invariant de boucle est similaire : à la n du k-ième tour de la boucle principale, les k derniers éléments de la liste sont les k plus grands et sont bien ordonnés, et (pour changer. . .) la liste complète est une permutation de la liste de départ.

En eet, dans le parcours au k-ième tour, les éléments aux positions entre 0 et len(l)-k sont traités et les échanges sont eectués si besoin, de sorte que le plus grand de ces éléments ne peut être que tout à droite une fois le parcours ni.

3.3 Tri fusion

Le tri présenté dans cette section applique le principe de diviser pour régner , et sa complexité en est grandement améliorée : un O(nlogn) dans tous les cas, ce qui en fait le meilleur tri en termes de complexité au pire quand l'entrée n'a pas de propriété particulière.

Le tri fusion s'adapte à merveille à la récursivité : on prend la moitié gauche et la moitié droite de la liste, on les trie par le tri fusion, puis on les fusionne en prenant à chaque fois l'élément minimum parmi les deux sommets.

2. voir le TP associé

3. sauf à vérier à chaque tour que la liste n'est pas encore triée

(23)

3.3. TRI FUSION 23 Il serait laborieux d'écrire une version en place du tri fusion, donc la version écrite ici renvoie une liste et utilise une mémoire annexe linéaire, en comptant sur Python pour libérer la mémoire qui n'est plus nécessaire.

Pour une liste de taille n, le tri fusion commence par créer deux listes de taille n/2 (arrondi), il les trie et les fusionne en procédant à un nombre de comparaisons entre n/2 et n-1 suivant le cas.

On a donc une complexité en nombre de comparaisons (et aussi en nombre d'aec- tations) qui suit la formule de récurrence cn = 2cn

2 + Θ(n), soit un Θ(nlogn).

def merge(l1, l2): # l est la fusion triée de l1 et l2 supposées triées n1, n2, i1, i2 = len(l1), len(l2), 0, 0

l = [None] * (n1+n2)

while i1 < n1 and i2 < n2:

if l1[i1] < l2[i2]:

l[i1+i2] = l1[i1]

i1 += 1 else:

l[i1+i2] = l2[i2]

i2 += 1

for i in range(i1, n1): # vide si i1 = n1, termine l l[i+i2] = l1[i]

for i in range(i2, n2): # vide si i2 = n2, termine l l[i1+i] = l2[i]

return l

def merge_sort(l):

n = len(l) if n <= 1:

return l

l1 = merge_sort(l[:n//2]) l2 = merge_sort(l[n//2:]) return merge(l1, l2)

(24)

3.4 Tri rapide

Comme le tri fusion, le tri rapide applique le principe de diviser pour régner, et sa complexité moyenne est un O(nlogn), quoique dans le pire des cas le temps mis est quadratique.

Pour autant, le facteur constant particulièrement faible fait du tri rapide celui qui est privilégié en pratique.

Ici, les comparaisons se font au moment de créer les deux listes sur lesquelles les appels récursifs doivent être exécutés, ce qui se fait autour d'un pivot qui sera un élément arbitraire (le premier, disons).

Lorsque le pivot est particulièrement petit ou grand, les listes sont déséquilibrées et la complexité s'en ressent.

En particulier, pour éviter les problèmes quand la liste de départ est presque triée par ordre croissant ou décroissant, les programmes rencontrés dans la pratique com- mencent par mélanger la liste ou prennent toujours un pivot aléatoire.

Ceci donnera de meilleurs résultats en moyenne que le calcul de la médiane des médianes (voir plus loin) pour servir de pivot, cette dernière méthode garantissant néanmoins une complexité toujours en O(nlogn).

Là encore, une version simple pour commencer ne fait pas le tri en place et utilise un espace mémoire de taille linéaire.

def split(l): # séparer l avec le premier élément comme pivot pivot = l[0]

l1, l2 = [], []

for i in range(1, len(l)):

if l[i] < pivot:

l1.append(l[i]) else:

l2.append(l[i]) return pivot, l1, l2

(25)

3.4. TRI RAPIDE 25 def quick_sort(l):

if len(l) <= 1:

return l

pivot, l1, l2 = split(l) ll1 = quick_sort(l1) ll1.append(pivot)

return ll1 + quick_sort(l2)

Pour autant, le tri rapide a pour principal avantage d'être facilement implémentable en place an de battre en moyenne les autres tris4 :

def split1(l, deb, fin):

# séparer l entre les indices deb et fin inclus (deb <= fin)

# avec le premier élément à séparer comme pivot ind, ind_fin = deb+1, fin

pivot = l[deb]

while ind <= ind_fin:

if l[ind] <= pivot: # l'élément est du bon côté ind += 1

else: # on l'envoie à la fin, ce qui rend le tri non stable l[ind], l[ind_fin] = l[ind_fin], l[ind]

ind_fin -= 1

# on n'incrémente pas ind, un nouvel élément y apparaissant

l[deb], l[ind-1] = l[ind-1], l[deb] # met le pivot à sa place return ind-1 # la position où le pivot a été placé

def quick_sort1(l):

def quick_sort_aux(deb, fin):

if deb < fin:

ind = split1(l, deb, fin) quick_sort_aux(deb, ind-1) quick_sort_aux(ind+1, fin) quick_sort_aux(0, len(l)-1)

4. Voir à ce sujet la vidéo https://www.youtube.com/watch?v=es2T6KY45cA montrant l'utili- sation de mémoire auxiliaire de taille linéaire pour le tri fusion et non pour le tri rapide, justiant le résultat de la compétition.

(26)

Médiane des médianes

L'algorithme dit de la médiane des médianes retourne une valeur dans le peloton des valeurs d'une liste en calculant la médiane des médianes de sous-listes de taille5, qu'on calcule naturellement, d'une liste.

La valeur nale obtenue est donc supérieure ou égale à la moitié des médianes (se prouve par récurrence), qui sont supérieures ou égales à deux fois autant d'éléments de la liste, et on a de manière similaire une infériorité.

Par exemple, pour une liste de taille 100, on récupère 20 médianes et on en déduit une valeur médiane (on a besoin d'avoir un élément de la liste dans notre cas), qui est supérieure ou égale à10médianes donc à30éléments inférieurs aux médianes en question.

La complexité de cet algorithme vérie alors cn = cn

5 +c7n

10 +O(n) : le coût d'un appel récursif de médiane des médianes, celui sur une liste après retrait d'une partie et le coût du traitement, ce qui donne un O(n).

La complexité du calcul de la médiane des médianes est linéaire, et l'utilisation de cette valeur comme pivot pour le tri rapide garantit qu'à chaque itération la taille des intervalles d'étude est toujours multipliée par moins de 107, de sorte que le nombre d'itérations est logarithmique et les appels à même niveau sont de complexité linéaire car ils couvrent moins de la liste, d'où la complexité dans le pire des cas telle qu'annoncée.

Vu la façon dont le tri rapide sépare une liste, on peut trouver un élément médian5 d'une liste par une variante de split fondée sur la médiane de médianes.

On remarque que les appels récursifs sont circulaires : mediane appelle split2 qui appelle mediane_medianes qui appelle mediane.

def med5(l): # l est de taille au plus 5

ll = sorted(l) # vu la taille, l'algorithme n'importe pas return ll[len(ll)//2]

5. La médiane nécessiterait pour des listes de taille paire de trouver deux éléments, ce qui doublerait dans le pire des cas le temps de recherche mais surtout qui compliquerait l'algorithme.

(27)

3.4. TRI RAPIDE 27 def split2(l):

l1, l2, pivot = [], [], mediane_medianes(l) for i in range(len(l)):

if l[i] < pivot:

l1.append(l[i])

elif l[i] > pivot: # En particulier, si c'est égal ça disparaît l2.append(l[i])

return pivot, l1, l2 def mediane_medianes(l):

if len(l) <= 5:

return med5(l) ll = []

for i in range(len(l)//5):

ll.append(med5(l[5*i:5*i+5])) return mediane(ll)

def mediane(l):

if len(l) <= 5:

return med5(l) indice = len(l)//2 while True:

pivot, lg, ld = split2(l)

if len(lg) <= indice <= len(l)-len(ld):

return pivot

if indice == len(lg)-1: # optionnel return max(lg)

if indice == len(l)-len(ld): # optionnel return min(ld)

if len(lg) > indice:

l = lg else:

indice -= len(l)-len(ld) l = ld

(28)

3.5 Borne inférieure de complexité

Dans cette section, nous allons prouver le théorème suivant : Théorème

On considère un algorithme de tri fonctionnant par comparaisons sur des listes quelconques. Le nombre de comparaisons eectuées par cet algorithme dans le pire des cas ne peut pas être négligeable devant nlog2(n), où n est la taille de la liste.

Considérons une liste de taille n. Il existe n! permutations de ses éléments6. Sans perte de généralité, supposons que les éléments de la liste sont les entiers de 1 àn. Deviner quelle permutation deSnnotre liste est permet de savoir quelle permutation eectuer an d'arriver à [1,2,...,n]. En fait, pour une liste quelconque, on cherche quelle permutation permet d'obtenir la version triée de la liste.

Chaque comparaison permet d'exclure un certain nombre de permutations candi- dates pour le problème du tri. Le meilleur algorithme possible exclurait la moitié des permutations (arrondie), de sorte que dans le pire des cas le nombre de comparaisons à eectuer soit dlog2(n!)e, soit d'après la formule de Stirling unO(nlog2n).

3.6 Tris en temps linéaire

D'après la section précédente, annoncer des tris en temps linéaire paraît absurde, mais en fait l'idée ici est de ne pas utiliser de comparaisons en protant de propriétés que la liste à trier est supposée avoir.

Le tri par dénombrement ou tri comptage (counting sort) permet de trier une liste dont les éléments proviennent d'un ensemble ni, et si possible de taille très petite devant la taille de la liste.

Par exemple, on voudrait trier un million de caractères de la table ASCII, ce qui s'assimile à trier un million de nombres entre 0et 127.

6. On suppose les éléments diérents deux à deux pour faciliter les choses, mais le théorème reste vrai sans cette hypothèse, car dans l'idée se rendre compte d'une égalité nécessite une comparaison, donc on agit comme si les éléments étaient uniques.

(29)

3.6. TRIS EN TEMPS LINÉAIRE 29 Pour ce faire, on crée un tableau de taille 128 dont les éléments sont le nombre d'occurrences de chacun des nombres de 0à127, que l'on va compter en parcourant la liste. Une fois le parcours terminé, on constitue directement la version triée de la liste. La complexité est donc un O(n).

Le tri par base (radix sort) est un tri coûteux en espace mais dont la complexité en temps est unO(kn), oùk est la taille (en tant que nombres ou chaîne de caractères) des éléments de la liste.

Ceci correspond à un O(nlogn) pour les nombres de 1 à n, mais on peut imaginer des cas où le nombre k est plus intéressant. Historiquement, ce tri a été introduit pour classer des cartes perforées.

Pour illustrer le principe, on considère une liste de n nombres entre 0 et 999. Le tri par base fait trois passages dans une boucle principale, consistant à insérer de gauche à droite les nombres dans 10 listes, une par chire des unités au premier passage, dizaines au deuxième, centaines au troisième, avant de fusionner les listes dans l'ordre de leur identiant.

À la n du deuxième passage dans la boucle principale, les nombres sont dans l'ordre croissant des dizaines, et à même dizaine, ils sont dans l'ordre croissant des unités en raison du résultat du premier passage, ce qui permet de déduire la correction de l'algorithme.

(30)

Une implémentation sur les entiers du tri par base peut être la suivante : def radix_sort(liste):

"""On suppose la liste composée uniquement d'entiers naturels.

Le tri se fait en séparant la liste en dix listes

suivant chaque chiffre depuis les unités, en fusionnant les dix listes et en recommençant à partir de la liste obtenue

jusqu'au chiffre maximal."""

taille_max = 0

paquets = [[] for i in range(10)] # surtout pas [[]] * 10 for elem in liste:

assert type(elem) == int and elem >= 0 if len(str(elem)) > taille_max:

taille_max = len(str(elem))

paquets[elem %10].append(elem) # On profite de la première boucle puiss10 = 1

l = []

for paquet in range(10):

l.extend(paquets[paquet]) for i in range(1, taille_max+1):

paquets = [[] for i in range(10)]

puiss10 *= 10 for elem in l:

paquets[(elem//puiss10)%10].append(elem) l = []

for paquet in range(10):

l.extend(paquets[paquet]) return l

(31)

Deuxième partie Travaux pratiques

31

(32)
(33)

TP 1 : PILES 33

TP 1 : Piles

Ce TP permet de mettre en pratique le cours sur les piles en écrivant soi-même une implémentation en Python de cette structure de données.

Exercice 1 : Recopier le code des opérations de base sur les piles.

Exercice 2 : Écrire les fonctions avancées sur les piles (est_vide(p), taille(p) et sommet(p)).

Les quatre exercices suivants sont à faire deux fois : naturellement puis à l'aide des opérations déjà dénies.

Exercice 3 : Écrire une fonction qui accède au i-ième élément d'une pile en partant du fond (qui est le premier, donc).

Exercice 4 : Écrire une fonction qui échange les deux éléments au sommet d'une pile.

Exercice 5 : Écrire une fonction qui met l'élément du sommet au fond de la pile.

Exercice 6 : Écrire une fonction qui inverse l'ordre des éléments de la pile.

Nous verrons plus en détail la notion de pile d'appels (en Python) dans la section sur la récursivité. Il peut être intéressant de chercher à provoquer une erreur de dépassement de pile7, aussi appelée stack overow.

Une remarque pratique : dans un éditeur tel que Libreoce, la pile des modications est de capacité relativement courte. Ici, il s'agit d'une pile dont le fond est progres- sivement eacé plutôt que de provoquer une erreur quand on tente d'empiler trop d'éléments.

On peut rééchir à une implémentation de la pile qui utilise toujours une liste mais pour lequel le fond de la pile n'est pas nécessairement au début : le premier élément de la liste mémorise non seulement la taille, mais aussi l'indice de départ, de sorte qu'on écrase le deuxième élément après que le bout de la liste est atteint.

Pour ceux que cela intéresse, cela ressemble grandement à l'implémentation intuitive

7. quand on programme, elles arrivent vite si on fait un oubli bête. . .

(34)

d'une le, à l'aide d'une liste que l'on qualierait de circulaire, la diérence réside dans le fait que retirer un élément se fait diéremment et donc l'indice de départ est modié à chaque fois que l'on retire un élément.

Exercice 7 : Réaliser cette nouvelle implémentation d'une pile et réécrire les fonctions de ce TP.

(35)

TP 2 : APPLICATIONS DES PILES 35

TP 2 : Applications des piles

Ce TP illustre quelques-unes des applications les plus courantes des piles. Il se com- pose de questions de programmation et de questions plus théoriques. Il est impératif d'utiliser des piles pour chaque exercice de programmation.

1 Analyse d'expressions bien parenthésées

Nous nous intéressons à des expressions mathématiques utilisant des parenthèses, avec la question de déterminer si les parenthèses ne provoquent pas d'erreur de syntaxe. Dans la mesure où tout ce qui n'est pas une parenthèse n'a pas de pertinence dans cette étude, on considèrera que les expressions sont des mots n'utilisant que les caractères '(' et ')'.

Exercice 1 : Donner une condition nécessaire et susante pour qu'un mot soit bien parenthésé.

Exercice de mathématiques : Prouver que le nombre de mots bien parenthésés de taille 2n est n+11 2nn

(c'est le n-ième nombre de Catalan).

Exercice 2 : Écrire un programme qui vérie si un mot est bien parenthésé.8

Exercice 3 : Écrire un programme qui, étant donné un mot bien parenthésé, retourne la liste des couples i,j représentant les indices (en commençant à 0) des couples de parenthèses suivant le parenthésage.

On considère maintenant un nombre arbitraire de parenthèses diérentes, qui auront un identiant entier strictement positif. Les parenthèses ouvrantes seront marquées par l'identiant et les parenthèses fermantes par l'opposé de l'identiant. Une ex- pression sera alors une liste d'entiers non nuls9

Exercice 4 : Écrire un programme qui vérie si une telle liste est bien parenthésée.

8. L'utilisation des piles n'est pas obligatoire ici.

9. On pourrait assimiler le zéro à autre chose qu'une parenthèse.

(36)

2 Notation polonaise inversée

Une expression en notation polonaise inversée a le bon goût de ne pas nécessiter de parenthèses.

La notation est postxe, dans la mesure où l'opérateur est situé après ses opérandes, de sorte que par exemple (2 + 3) x 5 s'écrira 2 3 + 5 x.

En fait, rencontrer un opérateur dans la lecture de l'expression fait chercher les valeurs (opérandes ou résultats d'opérations) les plus récentes et applique l'opération.

Le nombre d'opérandes d'un opérateur étant important, il faut alors utiliser deux symboles - diérents : un pour le signe (s'appliquant à un opérande) et un pour la soustraction (s'appliquant à deux opérandes)10.

Exercice 5 : Écrire un programme qui évalue une expression arithmétique qui est donnée en notation polonaise inversée, par exemple sous la forme d'une liste de chaînes de caractères11. L'expression utilisera seulement des entiers, des ottants et les opérateurs usuels sur ces nombres.

3 Parcours de labyrinthe

Pour sortir d'un labyrinthe dit parfait, une méthode simple est la méthode de la main gauche : on suit un chemin en maintenant constamment sa main gauche contre un mur12. Cette méthode consiste simplement à faire un parcours en profondeur.

Les labyrinthes que nous considérons ici sont des matrices dont les cellules contiennent une information sur 4 bits, indiquant la présence ou non d'un mur en haut, en bas, à gauche et à droite. Il est essentiel que les informations soient cohérentes d'une cellule à l'autre et que les bords de la matrice indiquent des murs vers les cellules inexistantes.

Exercice 6 : Écrire un programme qui vérie si une matrice correspond à un labyrinthe valide.

10. De nombreuses calculatrices ont utilisé cette notation, ce qui explique l'emploi historique des deux boutons.

11. ou, pour les plus sportifs, d'une chaîne qu'il faudra éclater suivant les espaces 12. Attention aux éraures !

(37)

TP 2 : APPLICATIONS DES PILES 37 Nous allons implémenter un algorithme équivalent, consistant, à chaque position (en pratique à chaque intersection), à :

empiler la position courante et mémoriser la position depuis laquelle on y est arrivé, si on la visite pour la première fois ;

tester successivement les sorties possibles de la position courante dans le sens des aiguilles d'une montre à partir du point cardinal d'où on est arrivé dans la position.

Exercice 7 : Écrire un programme correspondant.

Bonus pour les plus motivés : il est possible de générer un labyrinthe parfait, c'est-à- dire dans lequel il existe un et un seul chemin de n'importe quelle position à n'importe quelle position, à partir de tirages aléatoires et de piles. N'hésitez pas à le tenter.

(38)
(39)

TP 3 : RÉCURSIVITÉ 39

TP 3 : Récursivité

Dans ce TP, toutes les fonctions demandées devront être récursives.

Avant de commencer, comparer des résultats de la fonction racine du cours avec la fonction sqrt du module math.

Déclencher également une erreur de dépassement de la pile d'appels en comptant la profondeur maximale atteinte.

Exercice 1 : Écrire une fonction puissance de complexité logarithmique en temps.

Exercice 2 : Écrire une fonction imprimant les instructions pour résoudre le problème des tours de Hanoï.

Remarque : Ce problème consiste à déplacer une pile de n (en argument) anneaux de taille croissante d'un tas (matérialisé par un piquet) à un autre (parmi trois), les opérations élémentaires étant le déplacement d'un anneau du haut d'une pile sur le haut d'une autre pile, à condition qu'il soit plus petit que l'ancien sommet de la pile d'arrivée.

Le nombre optimal d'opérations élémentaires est 2n−1.

Exercice 3 : Écrire une fonction comptant le nombre de façons de tracer une ligne de longueur n (en argument) avec des segments de longueur 2 ou 3 (l'ordre est important).

Exercice 4 : Adapter le code de l'exercice précédent pour retourner la liste des façons de tracer la ligne en question.

Exercice 5 : Déterminer ce que retourne la fonction de McCarthy.

def mccarthy(n):

if n > 100:

return n-10 else:

return mccarthy(mccarthy(n+11))

(40)

Exercice 6 : Recopier le code de la fonction d'Ackermann et l'appeler pour certaines valeurs des arguments.

def ackermann(m, n):

if m == 0:

return n+1 elif n == 0:

return ackermann(m-1, 1) else:

return ackermann(m-1, ackermann(m, n-1))

Exercice 7 : Écrire une fonction retournant le n-ième terme de la suite de Fibonacci.

Déterminer la complexité de la fonction, et chercher à la rendre linéaire.

Exercice 8 : Écrire une fonction qui calcule le PGCD de deux entiers à l'aide de l'algorithme d'Euclide.

Exercice 9 : Écrire une fonction qui détermine si un entier relatif ne comporte que des 0et des 1 dans son écriture en base 3.

Terminons ce TP par quelques compléments. La fonction de Morris est un cas par- ticulier de fonction récursive, dont la terminaison se prouve à l'aide d'un variant. . . et qui pourtant ne termine pas.

En pratique, c'est dû au fait que Python, comme la plupart des langages, évalue d'abord les arguments d'une fonction avant de procéder à son appel.

Ainsi, dans le code suivant : def morris(m, n):

if m == 0:

return 1 else:

return morris(m-1, morris(m, n))

. . . appeler morris(1,0) provoquera un dépassement de la pile d'exécution, car Py- thon calculera morris(0, morris(0, ... morris(0, morris(1, 0))...)), qui vaut bien entendu 1, mais cette valeur ne sera jamais obtenue.

(41)

TP 3 : RÉCURSIVITÉ 41 Ceci incite à la prudence lors de l'écriture de preuves de terminaison.

Le dernier exercice met surtout en ÷uvre le premier chapitre, du point de vue al- gorithmique, mais rien n'empêche de chercher une solution en tant que fonction récursive.

Exercice 10 : Écrire des programmes qui résolvent des problèmes classiques de pas- sage de rivière , notamment celui du loup, de la chèvre et du chou.

(42)
(43)

TP 4 : TRIS 43

TP 4 : Tris

Pour tout ce TP, nous allons évaluer le temps mis par les programmes écrits, grâce à la fonction clock du module time, et générer des listes grâce à la fonction shuffle du module random :

from time import time from random import shuffle

def temps_execution(tri, liste):

avant = time() tri(liste)

return time() - avant l = list(range(20000)) shuffle(l)

temps_execution(mon_tri, l)

Si on souhaite comparer plusieurs tris en place sur une même liste, il est bon de rappeler que l2 = l est une mauvaise idée, car les modications aectant l aectent l2 et vice-versa.

Une façon de copier une liste dans une autre est de faire par exemple l2 = l[:]

(pour une liste de nombres, cela sut, sinon il s'agit de faire une copie profonde).

Exercice 1 : Écrire une version non en place des tris par insertion et par sélection.

La fonction devra alors avoir une valeur de retour, puisque la liste en argument ne sera pas modiée.

Exercice de mathématiques : Montrer que le nombre moyen d'inversions13 dans une permutation est n(n−1)4 .

Le tri à bulles pèche par sa complexité, et seules sa compréhension relativement facile et son originalité font qu'il est enseigné.

Ainsi donc, rentabilisons-le en faisant quelques calculs dessus. Vérier si la liste est

13. Une inversion dans une permutationσest un couple(x, y)tel quex < y etσx> σy.

(44)

déjà triée peut se faire de manière très pratique dans le tri à bulles : on utilise un booléen déterminant si un échange a été eectué dans un parcours de la boucle principale. Si le booléen reste à False, c'est que la liste est déjà triée.

L'utilité est de ne pas perdre de temps quand la liste de départ est presque triée, dans la mesure où par exemple quelques éléments parmi les plus grands sont au début de la liste.

Le principe du tri à bulles fait que de tels éléments seront vite envoyés à leur place14, mais le souci est qu'au contraire des éléments parmi les plus petits sont peut-être à la n de la liste15, et ils devront être échangés au cours d'un nombre considérable16 de parcours de la boucle principale.

Le tri cocktail pallie ce souci en faisant des parcours de la liste dans les deux sens, an qu'il n'y ait pas à proprement parler de tortue . Après chaque (double) parcours dans le tri cocktail, un élément supplémentaire est à la bonne place dans les deux extrémités.

Exercice 2 : Implémenter le tri cocktail.

Exercice d'informatique : Faire les preuves de terminaison et correction du tri fusion et du tri rapide.

Exercice d'informatique : Faire tourner à la main un tri au choix du cours sur une entrée de taille 16.

Exercice 3 : Implémenter une fonction de tri qui applique un algorithme le plus ecace possible dans l'hypothèse où la liste en entrée est obtenue à partir d'une liste triée après un nombre très faible d'insertions de nouveaux éléments17

Exercice 4 : Implémenter le tri par dénombrement.

Exercice 5 : Implémenter le tri par base pour des chaînes de caractères.

14. On utilise le terme imagé de lièvres . 15. Et ceux-là sont appelés des tortues . 16. . . . de lièvre ?

17. Le chier http://jdreichert.fr/Enseignement/CPGE/IPTSPE/liste.py contient une liste de la sorte, le but du jeu est de faire le meilleur temps possible sur cet exemple.

(45)

TP 4 : TRIS 45 Exercice 6 : Implémenter un algorithme de tri le plus ecace possible pour une liste correspondant à une permutation quelconque dans Sn.

(46)
(47)

TP 5 : INVERSION DE MATRICES 47

TP 5 : Inversion de matrices 1 Algorithme de Strassen

Le point de départ de notre étude est l'algorithme de Strassen pour la multiplication matricielle.

Le principe est le suivant : on sait que les multiplications matricielles peuvent se faire par blocs.

Or, découper deux matrices (supposées carrées et de taille paire. . . et bien entendu identique, du coup) en quatre blocs chacune, toutes les tailles étant égales, fait se ramener au produit de huit matrices de taille la moitié, ce qui n'apporte rien en termes de complexité.

Des relations particulièrement pertinentes entre les blocs ont permis à Volker Strassen de se ramener à sept multiplications (et beaucoup d'additions), pour un coût nal abaissé à O(nlog2(7)).

Exercice 1 : Rechercher l'algorithme de Strassen sur internet. Ensuite, l'écrire en Python et prouver que les blocs correspondent bien aux produits attendus18.

2 Inverse d'une matrice

Soit M une matrice carrée supposée inversible. D'après les propriétés de la transpo- sition, on a M−1 = (tM M)−1 ×tM et donc on peut ramener le calcul de l'inverse d'une matrice au calcul de l'inverse d'une matrice symétrique19.

Soit donc M symétrique de taille une puissance de 2 (pour simplier). M étant inversible dès qu'elle est dénie positive, on va supposer ceci (c'est le cas pour une matrice de la forme tN N, ce qui tombe bien).

On décompose M en blocs de taille identique, en notant que le bloc en haut à droite et le bloc en bas à gauche sont la transposée l'un de l'autre20, d'où les blocs (dans

18. (exercice de mathématiques)

19. (exercice de mathématiques : prouver l'égalité, et prouver aussi quetM M est symétrique) 20. (exercice de mathématiques : le prouver)

(48)

l'ordre de lecture) A, B,tB, C.

On appelle complément de Schur de C la matrice S=C−B×A−1×tB. Il s'avère que l'inverse de M est exactement

A−1+A−1×tB×S−1×B×A−1 −A−1×tB×S−1

−S−1×B×A−1 S−1

Dans ce cas, l'inversion d'une matrice de taille 2n se fait récursivement (les matrices en question sont elles aussi symétriques), et on en déduit l'inverse de M à l'aide de deux inversions de matrices de taillen, ainsi qu'un nombre restreint de multiplications matricielles21, là aussi sur des matrices de taillen.

Exercice 2 : Implémenter cette méthode d'inversion matricielle. Prouver que la com- plexité est dominée par la complexité du produit (en partant du lemme selon lequel la complexité du produit domine n2).

21. (exercice de mathématiques : combien ?)

(49)

TP 6 : DESCENTE DE GRADIENT 49

TP 6 : Descente de gradient

Le but de ce TP est d'illustrer une méthode pour déterminer approximativement (et avec un risque de se tromper totalement) les paramètres d'une équation diéren- tielle dont on connaît la forme, une intuition éventuelle de certains paramètres et surtout des mesures expérimentales. Statistiquement, une poignée de TIPE peuvent bénécier de ce TP chaque année.22

Le principe de la descente de gradient est de partir d'un n-uplet de valeurs présumées pour une équation diérentielle et d'engendrer les valeurs attendues par la théorie pour la fonction solution, pour les comparer aux valeurs expérimentales. Si l'écart est susamment faible, on peut admettre qu'il s'agit de la bonne solution, mais évi- demment on n'arrive pas par magie à un tel écart. Pour des raisons pratiques, l'écart se calcule comme la somme des carrés des diérences entre les valeurs théoriques et expérimentales.

Une fois qu'un écart initial est calculé, il s'agit de voir ce qu'une légère modication d'un des paramètres deviné provoque au niveau de l'écart : s'il est amélioré, on pro- gresse dans cette direction, sinon on annule et on cherche une nouvelle modication, a priori plus légère et éventuellement dans une autre direction (c'est-à-dire de signe inversé ou d'un autre paramètre).

Une façon simple de faire les choses est de tester une modication inme pour com- mencer, an de déterminer la direction à prendre pour le début de l'algorithme (recherche du gradient) avant de passer à des modications raisonnables.

Le risque évoqué en début d'énoncé est de trouver un minimum local vers lequel on va converger sans espoir d'en sortir, alors qu'il ne s'agit pas de la bonne réponse.

La méthode plus ecace mais plus dicile du recuit simulé permet de pallier ce problème, et le lecteur intéressé pourra se renseigner sur le sujet.

Il faut noter que même les mesures expérimentales ne sont pas forcément ables, et il faut tenir compte de la présence éventuelle de bruitage. Ainsi, rien n'empêche de considérer un écart non nul entre la première valeur théorique et la première valeur expérimentale.

22. Merci à mon éminent collègue de physique pour la suggestion et la collaboration pour élaborer ce TP.

(50)

Enn, pour limiter au maximum les erreurs d'approximation dues à la résolution théorique de l'équation diérentielle, on utilisera plutôt odeint (même si réécrire d'abord la méthode d'Euler ne peut que faire du bien à la mémoire).

Exercice unique : Implémenter une descente de gradient pour les deux équations ci-après avec les valeurs expérimentales associées.

Première équation : ressort avec amortissement liquide et solide. L'élongation du ressort est donné par une équation de la forme mx¨+λx˙ +kx+ sgn( ˙x)F = 0. On fournit la valeur de la masse :50kilogrammes (simulation d'un amortisseur de vélo).

Valeurs expérimentales : Pour les temps de 0à10s inclus, par pas d'un soixantième de seconde (donc np.linspace(0, 10, 601)), on a le tableau (en mètres, valeurs arrondies au millimètre près) solide_avec_bruit du chier http://jdreichert.

fr/Enseignement/CPGE/IPTSPE/tp6_base.py.

Deuxième équation : ressort avec frottements uides turbulents. L'élongation du ressort est donné par une équation de la formex¨+α|x|˙ x˙+ω20x= 0, où on a ici divisé par la masse.

Valeurs expérimentales : Même tableau pour les temps, on trouvera dans le même chier le tableau turbulence_avec_bruit.

Les valeurs à retrouver devront être bien dimensionnées !

(51)

TP -1 : PROGRAMMATION AVANCÉE 51

TP -1 : Programmation avancée

Dans ce TP, des techniques avancées de programmation en Python sont introduites, essentiellement pour la culture (avec une relativement faible probabilité d'apparition d'une occasion de briller aux concours en s'en servant, donc) ou dans une perspective d'utilisation approfondie de Python au cours de la suite des études voire de la carrière.

1 Fonctions anonymes

Certaines fonctions utilisées au cours de ces deux années d'études, essentiellement en ingéniérie numérique, avaient au moins une autre fonction parmi leurs arguments.

Or, une telle fonction en argument pouvait être une expression relativement simple, par exemple une fonction polynomiale de bas degré.

Quand il s'agissait de l'exponentielle, par exemple, tout allait bien, on pouvait écrire exp comme argument, ce nom étant associé à un objet appelable (callable).

Dans le cas général, pour ne pas être obligé d'écrire une ligne de dénition pour une expression courte et utilisée une seule fois (conditions à respecter pour ne pas tout compliquer ni écrire du code moche), on peut recourir aux fonctions anonymes.

La syntaxe est la suivante : lambda x1, x2, ..., xn : expression s'évalue en une fonction à n arguments et ayant une valeur de retour qui en dépend.

Par exemple, la fonction carré peut s'écrire lambda x : x ** 2. Il est possible de stocker ceci dans une variable, mais presque tout le monde verra d'un mauvais ÷il la première de ces deux syntaxes équivalentes :

carre = lambda x : x ** 2 def carre(x):

return x ** 2

Encore pire, on peut (et on ne doit pas) écrire en Python (lambda x : x ** 2)(42).

Encore encore pire : (lambda x : (lambda y : x + y))(30)(12).

(52)

2 Ensembles

La structure d'ensemble a été rapidement mentionnée dans le chapitre 2 du cours de première année. Il s'avère qu'en Python un ensemble est syntaxiquement proche d'un dictionnaire. Dans les deux cas, le délimiteur est une accolade, mais les éléments d'un ensemble sont quelconques alors que ceux d'un dictionnaire s'écrivent clef : valeur, avec unicité des clés.

Les ensembles sont apparus tardivement en Python, et la syntaxe mentionnée ci- avant date de la version 3. D'ailleurs, l'ensemble vide doit encore être écrit set() car {} est le dictionnaire vide, qui n'est pas une instance du type ensemble.

Avec des noms intuitifs, voici quelques opérations fondamentales (à côté de len et in / not in déjà connues) :

E.add(x) E.remove(x)

E.pop() # le premier élément de E est retiré et renvoyé E1.issubset(E2) # c'est E1 qui est un sous-ensemble E1.issuperset(E2)

E1.union(E2)

E1.intersection(E2) E1.difference(E2)

E1.symmetric_difference(E2) E.copy()

Pour rappel, un ensemble ne supporte pas l'indexation, donc on ne peut pas accéder à un élément particulier d'un ensemble, cependant on peut le parcourir (l'ordre n'est pas nécessairement l'ordre donné à la création) à l'aide d'un for comme on le ferait avec d'autres structures.

3 Listes en compréhension

On peut créer une liste en compréhension, c'est-à-dire en mettant entre crochets un code raccourci qui aurait provoqué l'ajout d'éléments à la liste.

Dans ce cas, si on simule une double boucle, il faut faire attention à l'ordre d'écriture.

Il est par ailleurs également possible de mettre d'utiliser des tests conditionnels.

(53)

TP -1 : PROGRAMMATION AVANCÉE 53

# Deux façons de créer [1,3,3,3,5,5,5,5,5,7,7,7,7,7,7,7].

# On pourrait aussi utiliser range(1,9,2).

l = []

for i in range(8):

if i % 2 == 1:

for j in range(i):

l.append(i)

ll = [i for i in range(8) if i % 2 == 1 for j in range(i)]

Cette syntaxe se rapproche de l'écriture mathématique des ensembles, elle peut four- nir une alternative intéressante par sa concision (voire être plus facile à comprendre).

Attention : les deux codes présentés ne sont pas équivalents. En eet, dans le premier cas, les variables i et j ont une existence en-dehors de la boucle (c'est ainsi en Python), mais dans le deuxième cas elles n'existent pas en-dehors du crochet.

4 Les mots-clés break et continue

Les deux mots-clés présentés ici sont à utiliser le moins souvent possible (presque aussi rarement que goto, qui est un tabou absolu). Cependant, autant savoir qu'ils existent.

break permet d'interrompre une boucle (la plus interne) immédiatement. L'inter- ruption concerne bien une boucle et non un bloc, bien que le bloc soit également court-circuité dans la foulée. Pour sortir de plusieurs boucles à la fois, on utilisera si possible des techniques plus propres. . .

continue permet d'aller directement au tour suivant d'une boucle (la plus interne), avec la même remarque.

Tester par exemple les codes ci-après : for i in range(9):

for j in range(9):

print(i, j) if j == 5:

(54)

break

print("Ceci ne sera jamais lu") print("Ceci sera lu pour j < 5") for i in range(9):

for j in range(9):

print(i, j) if j == 5:

continue

print("Ceci ne sera jamais lu") print("Ceci sera lu pour j != 5")

# Attention, horreur en approche ! for i in range(9):

for j in range(9):

print(i, j) if i == j == 5:

break

print("Ceci ne sera jamais lu") print("Ceci sera lu pour j != 5")

else: # ignoré si et seulement s'il y a eu un break continue # seul moyen d'éviter le break suivant break # et donc le break précédent est répercuté

Une interruption encore plus violente est exit, mais ceci est une fonction (donc suivie de parenthèses). Au contraire, le mot-clé pass se contente de ne rien faire. Il apparaît éventuellement pour éviter des erreurs de syntaxe, mais on peut toujours faire sans.

if n == 42:

else:pass

print("Ce n'est pas la réponse !")

5 La fonction map

La fonction map s'applique à une fonction et à un itérable, et elle renvoie un objet map (en Python 2, elle renvoyait une liste) qui contient toutes les images des éléments de l'itérable par la fonction. L'ordre de parcours est préservé (donc attention

(55)

TP -1 : PROGRAMMATION AVANCÉE 55 aux ensembles, notamment).

Un objet engendré par map est itérable, mais non indexable et on ne peut pas non plus récupérer sa taille à l'aide de len, bien que la taille soit en pratique celle de l'objet de départ (les doublons ne sont pas supprimés). Pour cette raison, on appellera souvent la fonction list après avoir appelé map.

6 Générateurs

Nous avons déjà vu qu'une fonction ne pouvait avoir qu'une valeur de retour. Pour autant, il peut être intéressant de disposer de fonctions qui renvoient des objets au fur et à mesure , ne serait-ce que pour économiser l'espace mémoire nécessaire pour stocker une liste énorme qui serait retournée, donc si toutes les valeurs étaient fournies d'un coup.

Python dispose en fait d'objets dits générateurs, qui sont engendrés par des fonctions utilisant non pas return, mais le mot-clé yield (produire, en anglais), qui renvoie un objet tout en n'interrompant pas l'exécution de la fonction23.

Sans entrer dans les détails concernant le fonctionnement des générateurs, il faut savoir qu'une fonction ne peut pas avoir à la fois des return et des yield, même si la spécication garantit qu'un des deux n'est jamais rencontré.24 De plus, tout comme return, il est interdit d'écrire yield en-dehors d'une fonction.

On remarquera qu'en Python 3, de nombreux objets sont en fait des générateurs notamment les objets map et les range. Ceci explique l'accélération du code quand on fait un range sur un intervalle énorme.

7 Fonctions avec variables optionnelles

Au moment de dénir une fonction, il est possible de rendre certaines des variables optionnelles en précisant une valeur par défaut si lors de l'appel de la fonction ces arguments ne sont pas fournis.

23. Cette exécution n'étant en pratique eectuée que quand on cherche l'objet suivant au moment de parcourir un générateur. . .

24. Allez l'expliquer à Python !

(56)

Mieux que cela : les noms de variables retenus pour chaque argument permettent lors de l'appel de la fonction de fournir les arguments dans l'ordre que l'on souhaite, à condition de préciser quel argument on est en train de renseigner.

Pour éviter toute ambiguïté, des règles s'imposent, notamment le fait que les ar- guments dits nommés gurent uniquement après les arguments non nommés, qui doivent, quant à eux, être fournis dans l'ordre et donc être les premiers arguments sans interruption.

Un exemple très classique de fonction avec variables optionnelles est range, et on peut observer des arguments nommés dans la fonction print de Python3.

Pour xer les idées, un code permet de comprendre la fonctionnalité présentée ici.

def poly(a=1, b=1, c=1):

return a**3 + b**2 + c poly()

poly(2)

poly(c=10, a=20, b=1) poly(3, c=10, b=5) poly(3, c=0)

poly(3, a=2, c=4) # Erreur

poly(c=3, 2, a=-1) # Erreur aussi

Au passage, une astuce classique consiste à bricoler les arguments pour compenser leur absence, notamment en leur donnant des valeurs par défaut exceptionnelles, comme None.

Puisqu'on parlait de print, il a également été constaté qu'on peut donner un nombre arbitraire d'arguments à une fonction. Ceci utilise une syntaxe particulière, à l'aide d'un symbole * avant un nom d'argument.

(57)

TP -1 : PROGRAMMATION AVANCÉE 57 En pratique, l'idée est que les arguments sont stockés dans une liste et la décons- truction permet elle-même de récupérer directement un nombre arbitraire d'éléments d'un itérable.

Seul un nom peut être précédé du symbole * pour éviter toute ambiguïté, et dans la dénition d'une fonction ces arguments doivent être après les arguments obligatoires, de préférence après les arguments optionnels aussi.

Comparer :

l = list(range(42)) a, b, *c = l

print(a) # 1 print(b) # 2

print(c) # la liste du reste

def multadd(coeff=1, *valeurs):

somme = 0

for x in valeurs:

somme += coeff*x return somme

multadd(1, 2, 3, 4, coeff=5) # erreur car le coefficient

# est déjà considéré comme la première valeur multadd(1, 2, 3, 4, 5) # 14

def addmult(*valeurs, coeff=1):

somme = 0

for x in valeurs:

somme += coeff*x return somme

addmult(1, 2, 3, 4, coeff=5) # 50, pas de souci

addmult(1, 2, 3, 4, 5) # 15 car coeff est considéré comme omis

Références

Documents relatifs

Sinon, s'il reste et ne paie pas le loyer, si une clause de solidarité existe au bail, vous pourriez être appelée à payer pour lui (comme une caution) au maximum pour 6 mois. S'il n'y

Veiller à laisser un espace d’environ 7-10 cm pour la bobine (fil enroulé) à gauche et à droite du dispositif anti-tartre multiple après l’avoir monté.. Fixer le

ALGORITHMES SUR LES GRAPHES 37 En outre, l'algorithme de Bellman-Ford, dont la complexité est certes supérieure à celle de l'algorithme de Dijkstra, présente sur ce dernier

Exercice 1 : Dessiner ou décrire un automate reconnaissant tous les mots sur l'alphabet {0, 1} dont la plus longue suite de 1 est de taille exactement 3.. Exercice 2 : Même

Exercice 2 : Remplacer les lignes adéquates du programme ci-dessous mettant en œuvre l’algorithme du pivot de Gauss pour obtenir une version exacte sur des rationnels, en justifiant

Nous nous proposons donc de représenter un arbre binaire à l’aide d’une chaîne de caractères constituée des diverses occurrences de l’arbre séparée les unes des autres par

RÉVISIONS Trouve des points alignés et complète

Tu peux t'aider du tableau des nombres.. 3- Écris les résultats