La sous-séquence de plus grande somme¶
Ce problème est connu sur le nom de Maximum subarray problem. Notion abordée : programmation dynamique.
[1]:
%matplotlib inline
Enoncé¶
On suppose qu’on a une liste . On souhaite trouver la sous-séquence de plus grande somme. En d’autres termes, on veut trouver tels que :
Solution naïve¶
La première solution consiste à calculer les sommes de toutes les sous-séquences et de garder les qui ont permis d’obtenir la meilleure sous-séquence. On divise le programme en deux fonctions :
somme_partielle
: calcule la somme de la sous-séquencel[i:j]
(coût de la fonction : )plus_grande_sous_liste
: passe en revue toutes les sous-séquences et retourne la meilleure (coût de la fonction )
Le coût de cet algorithme est en .
[2]:
def somme_partielle(li, i, j):
r = 0
for a in range(i, j):
r += li[a]
return r
# on pourrait juste écrire
# return sum(li[i:j])
def plus_grande_sous_liste(li):
meilleur = min(li) # ligne A
im, jm = -1, -1
for i in range(0, len(li)):
for j in range(i + 1, len(li) + 1): # ne pas oublier +1 car sinon
# le dernier élément n'est jamais pris en compte
s = somme_partielle(li, i, j)
if s > meilleur:
meilleur = s
im, jm = i, j
return li[im:jm]
# si li ne contient que des valeurs positives, la solution est évidemment la liste entière
# c'est pourquoi il faut tester cette fonction avec des valeurs négatives
li = [4, -6, 7, -1, 8, -50, 3]
m = plus_grande_sous_liste(li)
m
[2]:
[7, -1, 8]
La ligne A contient l’instruction meilleur = min(li)
. Pour une liste où tous les nombres sont négatifs, la meilleure sous-liste est constitué du plus petit élément de la liste. Remplacer cette instruction par meilleur = 0
a pour conséquence de retourner une liste vide dans ce cas précis.
Solution plus rapide¶
Il est possible de modifier cette fonction de telle sorte que le coût soit en car on évite la répétition de certains calculs lors du calcul de la somme des sous-séquences. On peut écrire :
Dans la seconde boucle, il suffit d’ajouter l’élément li[j]
à la somme précédente.
[3]:
def plus_grande_sous_liste_n2(li):
meilleur = 0
im, jm = -1, -1
for i in range(0, len(li)):
s = 0
for j in range(i, len(li)):
s += li[j]
if s > meilleur:
meilleur = s
im, jm = i, j + 1
return li[im:jm]
li = [4, -6, 7, -1, 8, -50, 3]
m = plus_grande_sous_liste_n2(li)
print(m)
li = [1, 2, 3, 4, 5, -98, 78, 9, 7, 7]
m = plus_grande_sous_liste_n2(li)
print(m)
li = [0, 2, 4, -7, -2, 7, -1, 8, -10, 3]
m = plus_grande_sous_liste_n2(li)
m
[7, -1, 8]
[78, 9, 7, 7]
[3]:
[7, -1, 8]
Solution dichotomique¶
Il existe une dernière version plus rapide encore. Si on considère la liste dont on cherche à extraire la sous-séquence de somme maximale. Supposons que appartienne à cette sous-séquence. On construit la fonction suivante :
On cherche alors les valeurs et telles que :
La sous-séquence de somme maximale cherchée est avec dans cet intervalle et le coût de cette recherche est en . Mais ceci n’est vrai que si on sait que appartient à la sous-séquence de somme maximale.
Autre considération : pour deux listes et , la séquence maximale de la réunion des deux appartient soit à l’une, soit à l’autre, soit elle inclut le point de jonction.
Ces deux idées mises bout à bout donne l’algorithme suivant construit de façon récursive. On coupe la liste en deux parties de longueur égale :
On calcule la meilleure séquence sur la première sous-séquence.
On calcule la meilleure séquence sur la seconde sous-séquence.
On calcule la meilleure séquence en suppose que l’élément du milieu appartient à cette séquence.
La meilleure séquence est nécessairement l’une des trois.
[4]:
def plus_grande_sous_liste_nlnn2_r(li, i, j):
if i == j:
return 0
elif i + 1 == j:
return li[i], i, i + 1
milieu = (i + j) // 2
# on coupe le problème deux
ma, ia, ja = plus_grande_sous_liste_nlnn2_r(li, i, milieu)
mb, ib, jb = plus_grande_sous_liste_nlnn2_r(li, milieu, j)
# pour aller encore plus vite dans un cas précis
if ja == ib:
total = ma + mb
im, jm = ia, jb
else:
# on étudie la jonction
im, jm = milieu, milieu + 1
meilleur = li[milieu]
s = meilleur
for k in range(milieu + 1, j):
s += li[k]
if s > meilleur:
meilleur = s
jm = k + 1
total = meilleur
meilleur = li[milieu]
s = meilleur
for k in range(milieu - 1, i - 1, -1):
s += li[k]
if s > meilleur:
meilleur = s
im = k
total += meilleur - li[milieu]
if ma >= max(mb, total):
return ma, ia, ja
elif mb >= max(ma, total):
return mb, ib, jb
else:
return total, im, jm
def plus_grande_sous_liste_nlnn2(li):
m, i, j = plus_grande_sous_liste_nlnn2_r(li, 0, len(li))
return li[i:j]
li = [4, -6, 7, -1, 8, -50, 3]
m = plus_grande_sous_liste_nlnn2(li)
print(m)
li = [1, 2, 3, 4, 5, -98, 78, 9, 7, 7]
m = plus_grande_sous_liste_nlnn2(li)
print(m)
li = [0, 2, 4, -7, -2, 7, -1, 8, -10, 3]
m = plus_grande_sous_liste_nlnn2(li)
m
[7, -1, 8]
[78, 9, 7, 7]
[4]:
[7, -1, 8]
Le coût de cette fonction est au pire en . A chaque itération, on effectue trois calculs :
meilleure séquence à droite :
meilleure séquence à gauche :
meilleure séquence incluant :
Le coût de l’iteration est avec . On calcule les premières termes :
[5]:
cout = lambda n: 0 if n == 1 else n + 2 * cout(n // 2)
for i in range(1, 10):
print("f({0})={1} --> f({0})/{0} = {2}".format(2**i, cout(2**i), cout(2**i) / 2**i))
f(2)=2 --> f(2)/2 = 1.0
f(4)=8 --> f(4)/4 = 2.0
f(8)=24 --> f(8)/8 = 3.0
f(16)=64 --> f(16)/16 = 4.0
f(32)=160 --> f(32)/32 = 5.0
f(64)=384 --> f(64)/64 = 6.0
f(128)=896 --> f(128)/128 = 7.0
f(256)=2048 --> f(256)/256 = 8.0
f(512)=4608 --> f(512)/512 = 9.0
On suppose que . Il suffit de vérifier cela avec la récurrence :
C’est le coût d’une itération. Comme à chaque itération on coupe le problème en deux, le coût total de l’algorithme est :
Par conséquent, le coût est en .
Solution linéaire¶
La dernière solution est la plus rapide. Elle consiste à parcourir la liste dans le sens croissant des indices et à construire la série cumulée.
[6]:
import matplotlib.pyplot as plt
li = [0, 2, 4, -7, -2, 7, -1, 8, -10, 3]
cumul = [li[0]]
for i in li[1:]:
cumul.append(cumul[-1] + i)
cumul2 = [li[0]]
for i in li[1:]:
cumul2.append(max(cumul2[-1] + i, 0))
plt.plot(cumul, label="cumul")
plt.plot(cumul2, label="cumul2")
plt.plot([0 for c in cumul])
plt.legend()
plt.title("somme cumulée");
La courbe devient négative au quatrième nombre. L’astuce consiste à dire qu’on peut traiter les deux ensembles séparément et que la meilleure sous-séquence n’inclura pas ce nombre en quatrième position. Si on cherche la meilleure sous-séquence se terminant à la position , il suffit juste de revenir en arrière et de trouver le minimum de la courbe cumulée avant la position . Pour , le point le plus bas qui précède est le point . Au point , on sait qu’il n’existe pas de sous-séquence positive précédent .
On découpe la courbe en segments vérifiant et et .
[7]:
from IPython.core.display import Image
Image("sommemax.png")
[7]:
On parcourt la série cumulée. A chaque fois qu’on passe sous zéro, au premier nombre positif suivant, on redémarre à zéro. La sous-séquence de somme maximale est forcément dans un de ces tronçons, elle a pour point de départ le premier élément et pour point d’arrivée le maximum obtenu sur le tronçon en question : pour chaque point d’un tronçon, le minimum de la courbe cumulée précédent est nécessairement le premier élément du tronçon.
[8]:
def plus_grande_sous_liste_n(li):
meilleur = [None for i in li]
somme = [None for i in li]
best = None
for i, el in enumerate(li):
if el >= 0:
if i > 0:
somme[i] = max(somme[i - 1], 0) + el
meilleur[i] = meilleur[i - 1] if somme[i - 1] >= 0 else i
else:
somme[i] = el
meilleur[i] = i
if best is None or somme[i] > somme[best]:
best = i
else:
somme[i] = (somme[i - 1] + el) if i > 0 else el
if somme[i] >= 0:
meilleur[i] = meilleur[i - 1]
i, j = meilleur[best], best + 1
return li[i:j]
li = [4, -6, 7, -1, 8, -10, 3]
m = plus_grande_sous_liste_n(li)
print(m) # affiche [7, -1, 8]
li = [1, 2, 3, 4, 5, -98, 78, 9, 7, 7]
m = plus_grande_sous_liste_n(li)
print(m)
li = [0, 2, 4, -7, -2, 7, -1, 8, -10, 3]
m = plus_grande_sous_liste_n(li)
m
[7, -1, 8]
[78, 9, 7, 7]
[8]:
[7, -1, 8]
Comparaison des temps de calcul¶
On effectue cela sur une liste de nombres aléatoire négatifs et positifs.
[9]:
import random
li100 = [random.randint(-10, 50) for i in range(0, 100)]
Coût en :
[10]:
%timeit plus_grande_sous_liste(li100)
15.6 ms ± 1.19 ms per loop (mean ± std. dev. of 7 runs, 100 loops each)
Coût en :
[11]:
%timeit plus_grande_sous_liste_n2(li100)
565 µs ± 33.7 µs per loop (mean ± std. dev. of 7 runs, 1,000 loops each)
Coût en :
[12]:
%timeit plus_grande_sous_liste_nlnn2(li100)
134 µs ± 12.9 µs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
Coût en :
[13]:
%timeit plus_grande_sous_liste_n(li100)
70.9 µs ± 3.32 µs per loop (mean ± std. dev. of 7 runs, 10,000 loops each)
Application¶
Le drawdown est un indicateur de performance pour les systèmes de trading. Il mesure la perte maximale enregistrée sur une période. En d’autres, il correspond à la sous-séquence de somme minimale. On vient de montrer qu’il n’est pas plus long à calculer qu’une moyenne.
[ ]: