Source : http://lacl.fr/~lpellissier/Algo1/TD3.pdf, auteur : Luc Pélissier (2020-21).
Le problème étudié est l’impression équilibrée d’un paragraphe sur une imprimante. Le texte d’entrée est modélisé comme une séquence de $n$ mots de longueurs $l_1,l_2, \dots, l_n$ (mesurées en caractères, que l'on suppose tous de même largeur - c'est le cas par exemple avec une police dite à chasse fixe).
On souhaite imprimer ce paragraphe de manière équilibrée sur un certain nombre de lignes qui contiennent un maximum de $M\geq1$ caractères chacune. Le critère d’équilibre est le suivant : Si une ligne donnée contient les mots $i$ à $j$ (avec $i \leq j$) et qu’on laisse exactement une espace) entre deux mots, le nombre de caractères d’espacements supplémentaires à la fin de la ligne est $f(M - j+i - \sum\limits_{k=i}^j l_k)$, qui doit être positif ou nul pour que les mots tiennent sur la ligne. L’objectif est de minimiser la somme, sur toutes les lignes hormis la dernière, des cubes des nombres de caractères d’espacement présents à la fin de chaque ligne : cela correspond à $f(s) = s^3$.
Remarque : pour bien visualiser ces espaces en fin de fichier, je termine chaque ligne par ;
.
1. Est-ce que l’algorithme glouton consistant à remplir les lignes une à une en mettant à chaque fois le maximum de mots possibles sur la ligne en cours, fournit l’optimum ?
Comme le coût est la somme des cubes d'espaces en fin de ligne, on peut penser à un contre-exemple qui va exploiter le fait que $(2x)^3 >> 2 x^3$, et produire un texte qui aura deux lignes identiques (avec $k$ espaces en fin de lignes) lorsqu'on le met en page optimalement, et une ligne quasi complète mais une deuxième ligne quasi vide :
%%bash
cat << EOF
AA AA AA AA AA AA B ;
AA AA AA AA AA AA B ;
EOF > /tmp/test_nongreedy_optimal.txt
AA AA AA AA AA AA B ; AA AA AA AA AA AA B ; EOF > /tmp/test_nongreedy_optimal.txt
bash: ligne 4: avertissement : « here-document » à la ligne 1 délimité par la fin du fichier (au lieu de « EOF »)
cat /tmp/test_nongreedy_optimal.txt
AA AA AA AA AA AA B ; AA AA AA AA AA AA B ;
%%bash
cat << EOF
AA AA AA AA AA AA B AA AA AA AA AA AA ;
B ;
EOF > test_greedy_suboptimal.txt
AA AA AA AA AA AA B AA AA AA AA AA AA ; B ; EOF > test_greedy_suboptimal.txt
bash: ligne 4: avertissement : « here-document » à la ligne 1 délimité par la fin du fichier (au lieu de « EOF »)
cat /tmp/test_greedy_suboptimal.txt
AA AA AA AA AA AA AA AA AA AA AA AA AA ; B ;
Pour l'instant, j'ai codé ça vite fait en Bash pour calculer le coût des deux fichiers :
%%bash
clear
for file in /tmp/test_*txt; do
echo $file
hr
cat $file
hr
n=0
echo $n
for line in $(cat $file | grep -o ' *;' | sed s/';'/''/g | tr ' ' 'X'); do
echo $line; i=$(echo $line | wc -c)
i=$((i-1))
echo "n = $n, i = $i"; n=$((n + i*i*i))
echo "=> n = $n, i = $i"
done
done
/tmp/test_greedy_suboptimal.txt ################################################################################ AA AA AA AA AA AA AA AA AA AA AA AA AA ; B ; ################################################################################ 0 X n = 0, i = 1 => n = 1, i = 1 XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX n = 1, i = 38 => n = 54873, i = 38 /tmp/test_nongreedy_optimal.txt ################################################################################ AA AA AA AA AA AA B ; AA AA AA AA AA AA B ; ################################################################################ 0 XXXXXXXXXXXXXXXXXXXX n = 0, i = 20 => n = 8000, i = 20 XXXXXXXXXXXXXXXXXXXX n = 8000, i = 20 => n = 16000, i = 20
On voit que la solution gourmande a un coût de 54873 alors que la solution non gourmande (optimale) a un coût de 16000.
On peut juste produire $n$ fois ces deux lignes, et le coût de la solution gourmande sera $54873 n$ et le coût optimal sera $16000 n$. Cela montre que la différence entre les deux coûts n'est pas bornée.
On devrait pouvoir aussi faire croître le rapport des deux coûts vers l'infini : plutôt que de générer ces $n$ lignes identiques, on a juste à augmenter la longueur de ces lignes (et n'en avoir que deux, mais très longues). Comme le coût est cubique en le nombre d'espaces, on aura bien un rapport non borné entre le coût gourmand (sous optimal) et le coût optimal.
Corollaire : cela montre que la solution gourmande n'est pas un k-approximation du problème étudié.
Même si elle n'est pas efficace, on va commencer par écrire cette méthode gloutonne :
from typing import Tuple, List
def longueur_ligne(ligne: List[str]) -> int:
return sum(len(mot) for mot in ligne)
def mise_en_page_paragraphe_gloutonne(longueur_max:int, mots: List[str]) -> List[List[str]]:
print(f"Longueur maximum de la ligne = {longueur_max}")
print(f"Longueur des mots = {longueurs_mots}")
assert all(
1 <= len(mot) <= longueur_max
for mot in mots
)
mots = list(mots)[::-1] # on les lit de la fin
paragraphes = []
ligne_actuelle = []
longueur_ligne_actuelle = 0
while mots:
# print(f"mots = {mots}")
mot_a_placer = mots.pop()
# print(f" mot_a_placer = {mot_a_placer}")
# print(f" ligne_actuelle = {ligne_actuelle}")
if longueur_ligne(ligne_actuelle) + len(mot_a_placer) <= longueur_max:
ligne_actuelle += [mot_a_placer]
longueur_ligne_actuelle += len(mot_a_placer)
if longueur_ligne_actuelle < longueur_max:
ligne_actuelle += [" "]
longueur_ligne_actuelle += 1
# 1 + car on ajoute l'espace
if longueur_ligne_actuelle + 1 >= longueur_max:
paragraphes.append(ligne_actuelle)
ligne_actuelle = []
longueur_ligne_actuelle = 0
# print(f" ligne_actuelle = {ligne_actuelle}")
# print(f" paragraphes = {paragraphes}")
# dernière ligne si pas encore ajoutée
if ligne_actuelle:
paragraphes.append(ligne_actuelle)
# puis on complète avec des espaces en fin de lignes
for ligne in paragraphes:
espaces_fin_paragraphe = longueur_max - longueur_ligne(ligne)
ligne += [" "] * espaces_fin_paragraphe
assert all(
longueur_ligne(ligne) == longueur_max
for ligne in paragraphes
)
return paragraphes
def print_paragraphes(paragraphes: List[List[str]]):
print(f"\n# Mise en page finale d'un texte de {len(paragraphes)} lignes ")
for ligne in paragraphes:
print("".join(ligne) + ";")
from typing import Callable
def cout_paragraphes(paragraphes: List[List[str]], cout: Callable[[int], int]) -> int:
lignes = [ "".join(ligne) for ligne in paragraphes ]
espaces_de_fin = [
len(ligne) - len(ligne.rstrip())
for ligne in lignes
]
return sum(cout(es) for es in espaces_de_fin)
def print_couts(paragraphes):
print("- Nombre d'espaces en fin de lignes =", cout_paragraphes(paragraphes, cout= lambda i: i))
print("- Somme des carrés des nombres d'espaces en fin de lignes =", cout_paragraphes(paragraphes, cout= lambda i: i**2))
print("- Somme des cubes des nombres d'espaces en fin de lignes =", cout_paragraphes(paragraphes, cout= lambda i: i**3))
Un premier exemple simple :
longueur_max = len("AA AA ") # sans le ;
mots = ["AA", "AA", "AA", "B"]
paragraphes = mise_en_page_paragraphe_gloutonne(longueur_max, mots)
Longueur maximum de la ligne = 6 Longueur des mots = [2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1]
print_paragraphes(paragraphes)
print_couts(paragraphes)
# Mise en page finale d'un texte de 2 lignes AA AA ; AA B ; - Nombre d'espaces en fin de lignes = 3 - Somme des carrés des nombres d'espaces en fin de lignes = 5 - Somme des cubes des nombres d'espaces en fin de lignes = 9
Peut-on retrouver la solution suivante, qui avait été calculée à la main ?
cat /tmp/test_greedy_suboptimal.txt
AA AA AA AA AA AA AA AA AA AA AA AA AA ; B ;
longueur_max = len("AA AA AA AA AA AA AA AA AA AA AA AA AA ") # sans le ;
mots = ["AA"]*13 + ["B"]*1
Vérifions cela :
paragraphes = mise_en_page_paragraphe_gloutonne(longueur_max, mots)
Longueur maximum de la ligne = 39 Longueur des mots = [2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1]
print_paragraphes(paragraphes)
print_couts(paragraphes)
# Mise en page finale d'un texte de 2 lignes AA AA AA AA AA AA AA AA AA AA AA AA AA ; B ; - Nombre d'espaces en fin de lignes = 39 - Somme des carrés des nombres d'espaces en fin de lignes = 1445 - Somme des cubes des nombres d'espaces en fin de lignes = 54873
2. Donner un algorithme de programmation dynamique résolvant le problème. Analyser sa complexité en temps et en espace. Et implémenter le dans le langage de votre choix. Vérifier qu'il donne la réponse optimale sur l'exemple trouvé en question 1. (ou en tous cas, une meilleure réponse).
On va déjà écrire le problème d'optimisation à résoudre, puis une relation de récurrence. En écrivant un algorithme récursif naïf mais avec mémoïsation, on obtiendra un algorithme de programmation dynamique.
On se donne $M\in\mathbb{N}^*$ la taille de ligne, et un nombre $N\in\mathbb{N}^*$ objets, de longueurs $l_k \in [1,\dots,M]$. On souhaite minimiser le coût suivant, qui dépend de :
Initialisation : S'il n'y a qu'un seul mot, la solution est triviale : on le place sur la première ligne, et on a terminé.
Hérédité : On considère le premier mot $l_1$ et le deuxième mot $l_2$. Le coût de la solution optimale est le minimum des coûts des deux solutions optimales aux sous-problèmes suivants (de taille strictement plus petite) :
TODO
from typing import List, Tuple
from functools import lru_cache as memoize
couts = {
"lineaire": lambda i: i,
"quadratique": lambda i: i**2,
"cubique": lambda i: i**3,
}
@memoize(maxsize=None)
def mise_en_page_paragraphe(
longueur_max:int,
mots: Tuple[str],
choix_cout: str="cubique",
) -> List[List[str]]:
print(f"Longueur maximum de la ligne = {longueur_max}")
mots = list(mots)
print(f"Longueur des mots = {mots}")
assert len(mots) > 0
if len(mots) == 1:
return [ [mots[0]] ]
else:
cout = couts[choix_cout]
# première possibilité, on regroupe les deux premiers mots ensemble
mots1 = [mots[0] + " " + mots[1]] + mots[2:]
cout1 = float('+inf')
if len(mots1[0]) <= longueur_max:
solution1 = mise_en_page_paragraphe(longueur_max, tuple(mots1))
cout1 = cout_paragraphes(solution1, cout)
# deuxième possibilité, on place mots[0] tout seul, et on résoud les autres mots
sous_solution2 = mise_en_page_paragraphe(longueur_max, tuple(mots[1:]))
morceau_gauche2 = [ mots[0] ] + [" "] * (longueur_max - len(mots[0]))
solution2 = [ morceau_gauche2 ] + sous_solution2
cout2 = cout_paragraphes(solution2, cout)
if cout1 < cout2:
recombinaison_1 = []
for ligne in solution1:
mots_ici = "".join(ligne).split(" ")
ligne_ici = [ mots_ici[0] ]
for mot in mots_ici[1:]:
if mot:
ligne_ici += [" ", mot]
ligne_ici += [" "] * (longueur_max - longueur_ligne(ligne_ici))
recombinaison_1.append(ligne_ici)
return recombinaison_1
else:
recombinaison_2 = solution2
return recombinaison_2
longueur_max = len("AA AA AA")
mots = ["AA", "AA", "AA", "B"]
mots = tuple(mots) # pour le rendre Hashable pour le @memoize
Vérifions cela :
paragraphes = mise_en_page_paragraphe(longueur_max, mots)
Longueur maximum de la ligne = 8 Longueur des mots = ['AA', 'AA', 'AA', 'B'] Longueur maximum de la ligne = 8 Longueur des mots = ['AA AA', 'AA', 'B'] Longueur maximum de la ligne = 8 Longueur des mots = ['AA AA AA', 'B'] Longueur maximum de la ligne = 8 Longueur des mots = ['B'] Longueur maximum de la ligne = 8 Longueur des mots = ['AA', 'B'] Longueur maximum de la ligne = 8 Longueur des mots = ['AA B'] Longueur maximum de la ligne = 8 Longueur des mots = ['AA', 'AA', 'B'] Longueur maximum de la ligne = 8 Longueur des mots = ['AA AA', 'B'] Longueur maximum de la ligne = 8 Longueur des mots = ['AA AA B']
print_paragraphes(paragraphes)
print_couts(paragraphes)
# Mise en page finale d'un texte de 2 lignes AA ; AA AA B ; - Nombre d'espaces en fin de lignes = 7 - Somme des carrés des nombres d'espaces en fin de lignes = 37 - Somme des cubes des nombres d'espaces en fin de lignes = 217
Peut-on retrouver la solution suivante, qui avait été calculée à la main ?
cat /tmp/test_nongreedy_optimal.txt
AA AA AA AA AA AA B ; AA AA AA AA AA AA B ;
longueur_max = len("AA AA AA AA AA AA B ")
mots = (["AA"]*6 + ["B"]*1) * 2
mots = tuple(mots) # pour le rendre Hashable pour le @memoize
Vérifions cela :
paragraphes = mise_en_page_paragraphe_gloutonne(longueur_max, mots)
Longueur maximum de la ligne = 38 Longueur des mots = (2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1)
print_paragraphes(paragraphes)
print_couts(paragraphes)
# Mise en page finale d'un texte de 2 lignes AA AA AA AA AA AA B AA AA AA AA AA AA ; B ; - Nombre d'espaces en fin de lignes = 38 - Somme des carrés des nombres d'espaces en fin de lignes = 1370 - Somme des cubes des nombres d'espaces en fin de lignes = 50654
Et pour la solution dynamique :
paragraphes = mise_en_page_paragraphe(longueur_max, mots)
print_paragraphes(paragraphes)
print_couts(paragraphes)
# Mise en page finale d'un texte de 2 lignes AA AA AA AA AA AA B ; AA AA AA AA AA AA B ; - Nombre d'espaces en fin de lignes = 38 - Somme des carrés des nombres d'espaces en fin de lignes = 722 - Somme des cubes des nombres d'espaces en fin de lignes = 13718
3. Supposons que pour la fonction de coût à minimiser, on ait simplement choisi la somme des nombres de caractères d’espacement présents à la fin de chaque ligne. Est-ce que l’on peut faire mieux en complexité que pour la question ?
Oui la solution gourmande, qui est donc au plus linéaire en temps et demande une mémoire de travail supplémentaire constante (ou bornée par la taille du plus long mot, selon de savoir si len(mot)
est en $O(1)$ ou en $O(|\text{mot}|)$), sera optimale.
4. (Plus informel) Qu’est-ce qui à votre avis peut justifier le choix de prendre les cubes plutôt quesimplement les nombres de caractères d’espacement en fin de ligne ?
Si le coût est linéaire, alors la solution gourmande sera optimale (ou en tous cas une approximation à facteur constant). Mais c'est aussi que l'affichage ne fera pas de différence entre les deux exemples ci dessous, alors que l'on est clairement plus satisfait du rendu visuel du deuxième, qui équilibre mieux les deux lignes.
(je ne suis pas trop sûr de tout ça)
TODO mieux expliquer !
On peut vérifier la solution trouvée avec un coût carré et pas cubique :
paragraphes = mise_en_page_paragraphe(longueur_max, mots, choix_cout="quadratique")
print_paragraphes(paragraphes)
print_couts(paragraphes)
# Mise en page finale d'un texte de 2 lignes AA AA AA AA AA AA B ; AA AA AA AA AA AA B ; - Nombre d'espaces en fin de lignes = 38 - Somme des carrés des nombres d'espaces en fin de lignes = 722 - Somme des cubes des nombres d'espaces en fin de lignes = 13718
Sur cet exemple, on obtient exactement la même solution.
Mais je pense qu'on peut trouver des exemples où la solution avec un coût cubique est différente de celle avec un coût quadratique.
Et si vous cherchiez à résoudre ça dans votre langage de programmation favori ? En Python ou en OCaml, cela ne devrait pas être trop difficile.