L'algorithmie pour les nuls Episode #2
Cet article fait partie d'une série commencée ici :
. N'hésitez pas à lire l'introduction pour en savoir plus sur les algorithmes.
Je vous propose ici d'analyser ensemble l'algorithme "Add Two Numbers". Que peut-il y avoir de moins compliqué que d'additionner deux nombres ?
Add Two Numbers
Description
Pour deux paramètres non-vide de type `linked-list` (voir plus bas pour la définition et l'application) représentant deux entiers positifs. Les nombres sont présentés en `reverse-order` et chaque noeud ne contient qu'un chiffre. Additionnez les deux nombres et retournez le résultat sous forme de `linked-list`.
Vous pouvez supposer que les nombres ne commencent pas par 0, à l'exception du chiffre 0 lui même.
Exemple 1 :
Paramètres: N°1: (2 -> 4 -> 3) + N°2: (5 -> 6 -> 4)
Application du `reverse-order` : (243 -> 342) + (564 -> 465 )= 807
Retour `linked-list` : (7 -> 0 -> 8)
Exemple 2 :
Paramètres: N°1: (1) + N°2: (9 -> 9)
Application `reverse-order` : (1 -> 1) + (99 -> 99) = 100
Retour `linked-list` : (0 -> 0 -> 1)
Cette description permet de retenir quatre points plus ou moins utiles :
- Les paramètres n'étant pas vides, nous n'avons pas besoin de les tester ;
- Additionner uniquement des entiers positifs facilitera la gestion globale ;
- Chaque nœud n’excédera pas le chiffre 9 et chaque addition n’excédera donc pas 18.
- Puisque nous devons retourner la même structure de données, nous devons gérer les nœuds ayant une valeur strictement supérieure à 9.
Linked-list ?
Une `linked-list` ou `liste chaînée` en français est une structure de données qui à un nœud de départ contenant une valeur et un lien vers la valeur suivante. Nous utiliserons ici la version `simple` (un nœud pointe vers un seul nœud, toujours dans le même sens) mais la page Wikipédia pourra vous en dire plus. Ce qu'il est important de retenir sur cette structure :
- La liste chaînée commence toujours par le même nœud ;
- Cette structure simple se traverse toujours dans le même sens / ordre ;
- La seule façon de connaître la taille de la liste est de la parcourir entièrement ;
- Elle termine systématiquement par un nœud qui ne pointe vers rien.
Javascript n'offre pas cette structure de données en tant que telle mais on pourrait la représenter comme l'objet suivant :
const listeChainee = { valeur: 123, noeudSuivant: { valeur: "xyz", noeudSuivant: { valeur: false, noeudSuivant: { ...n } } } }
Application
Les `linked-list` sont par exemple utiles lorsque vous devez traiter une donnée en streaming. Par exemple pour regarder un film Netflix, votre ordinateur va recevoir des "morceaux" de film qu'il devra reconstituer exactement et strictement dans l'ordre où il les reçoit. Impossible de coller un morceau sans avoir traité le précédent. Par ailleurs, et pour accélérer encore le téléchargement, votre ordinateur pourra recevoir plusieurs morceaux en même temps qu'il devra ensuite reconstituer en un seul et unique film. Ce qui est exactement ce que va faire notre algorithme.
Leetcode
Pour nous aider, Leetcode nous fournit dans la `sandbox` une fonction pour simuler ce comportement :
/** * Definition for singly-linked list. * function ListNode(val) { * this.val = val; * this.next = null; * } */
Code v1
Voici la première version fonctionnelle à laquelle je suis parvenue est ci-dessous :
var addTwoNumbers = function(list1, list2) { //Initialisation de la List Chainée que nous allons retourner //const List = new ListNode(0); //Initialisation d'un curseur pour progresser dans la liste au fur et // à mesure des additions / noeuds let curseur = List; //Mon approche est récursive - nous verrons une seconde //approche dans la suite de ce post //Ma fonction récursive accepte deux noeuds et une retenu // La retenu étant la dizaine qu'il faudra répercuter sur le prochain calcul var recursive = function(list1, list2, retenu){ //Si mes deux noeuds ont une valeur : if(list1 && list2){ //Je les additionne avec la retenu var add = list1.val + list2.val + retenu; //Si mon addition est >= 10, elle ne sera pas acceptée dans // ma liste chainée. if(add >= 10) { //Je retiens 1, jamais plus car maxAdd = 9 + 9 = 18%10 = 1 retenu = 1; //Je supprime de ma valeur le 10 que je viens de retenir add = add - 10 }else { //Si mon addition est inférieure à 10, je n'ai plus besoin de retenir. retenu = 0; } //Je peux désormais ajouter le prochain maillon de ma chaine listée curseur.next = new ListNode(add); //Et faire progresser le curseur sur ce nouveau maillon avant // de poursuivre ma récursion curseur = curseur.next; //Je relance ma fonction avec les prochain noeuds de mes // paramètres et la nouvelle retenu return recursive(list1.next, list2.next, retenu); } else if(list1 && list2 === null) { //Si j'ai parcouru entièrement mon second paramètre, je n'ai // plus à l'inclure lors de l'addition var add = list1.val + retenu; //Le reste est similaire à la première condition... if(add >= 10) { retenu = 1; add = add - 10 }else { retenu = 0; } curseur.next = new ListNode(add); curseur = curseur.next; //Mon second paramètre est désormais null return recursive(list1.next, null, retenu); } else if(list1 === null && list2) { //Idem que la boucle précédente mais dans le cas ou le // premier paramètre s'épuise avant le second var add = list2.val + retenu; if(add >= 10) { retenu = 1; add = add - 10 }else { retenu = 0; } curseur.next = new ListNode(add); curseur = curseur.next; return recursive(null, list2.next, retenu); } else { //Dans le cas ou j'ai (enfin) parcouru mes deux paramètres : //Je rajoute un noeud s'il me reste une retenu depuis la // précédente addition - Case [5][5] if(retenu !== 0) { curseur.next = new ListNode(retenu) } console.log('Done ;-)'); //Peu importe la valeur de retour ici return true; } } //J'initialise ma fonction récursive avec mes deux paramètres et // une retenu valant par défaut 0 recursive(list1, list2, 0); //Je retourne une liste chainée A PARTIR du second noeud - le // premier noeud étant une simple initialisation return List.next; };
Un code fonctionnel mais qui peut être re-factorisé pour être au moins plus agréable à travailler. La complexité est néanmoins bonne :
- En espace : O(max(m,n )), la taille de la chaîne listée que nous retournons ne fera jamais plus que la longueur maximale de m ou n - soit m si m > n ou n dans le cas contraire ;
- En temps : O(max(m,n)), pour les mêmes raisons, nous lisons une fois chaque nœud - jamais plus.
Leetcode met 380ms pour 1563 tests. Peut mieux faire.
Code v1.1
Il suffit ensuite de refactoriser pour avoir un code plus simple et lisible :
const addTwoNumbers = function(l1, l2) { //L'initialisation ne change pas const List = new ListNode(); let curseur = List; //Je défini une fonction qui va gérer le retenu //J'envoie le résultat de mon addition et je retourne la valeur de // mon noeud et de mon retenu const retenuHelper = add => { if (add > 9) return { add: add - 10, retenu: 1 }; else return { add: add, retenu: 0 }; }; //Mon curseur étant une variable dans le scope principal, je peux // aussi avoir une fonction pour le déplacer const moveCurseur = add => { curseur.next = new ListNode(add); curseur = curseur.next; }; //Ma fonction récursive est strictement identique, je ne fais // qu'utiliser mes fonctions d'aide pour la rendre plus concise const recursive = (l1, l2, retenu) => { //Les conditions ne changent pas if (l1 && l2) { //Je pourrai même regrouper retenuHelper() et moveCurseur() // en une seule fonction mais cela me semble plus lisible // comme ceci. Refactoriser != Obfuscer var { add, retenu } = retenuHelper(l1.val + l2.val + retenu); moveCurseur(add); return recursive(l1.next, l2.next, retenu); } else if (l1 && l2 === null) { var { add, retenu } = retenuHelper(l1.val + retenu); moveCurseur(add); return recursive(l1.next, null, retenu); } else if (l1 === null && l2) { var { add, retenu } = retenuHelper((add = l2.val + retenu)); moveCurseur(add); return recursive(null, l2.next, retenu); } else { if (retenu !== 0) curseur.next = new ListNode(retenu); return true; } }; //init et retour ne changent pas recursive(l1, l2, 0); return List.next; };
La même logique, écrite dans une philosophie plus Don't Repeat Yourself, met 116ms à être exécutée. Soit un gain de près de 70% ! C'est beaucoup mieux :-)
Code des autres
Pour chaque problème, Leetcode offre un espace de discussion où vous pouvez - après avoir essayé tout seul - voir ce qu'ont fait les autres, ce qui est toujours intéressant. Le code javascript le plus concis que j'ai trouvé parmi ces autres solutions est celui-ci :
function addTwoNumbers(l1, l2) { //Initialisation de la List à retourner const before = new ListNode(); //Définition du pointer let tail = before; //Définition du carry (retenu) let c = 0; /*while(l1 || l2 || c) va s'exécuter tant que l'un de ses trois paramètres sont vrais. Cela permet de s'épargner la récursion et donc l'initialisation. while est très utilisé dans d'autres languages - je n'ai pas l'habitude de m'en servir en Javascript. Peut-être à tord...*/ while (l1 || l2 || c) { //Plutôt que de tester chaque noeud dans une condition - on // définit une valeur de 0 pour chaque ABSENCE de noeud const v1 = l1 ? l1.val : 0; const v2 = l2 ? l2.val : 0; //Tous nos noeuds étant définis, on peut les additionner avec la retenu const v = v1+v2+c; //Même principe que les codes précédents tail.next = new ListNode(v%10); tail = tail.next; c = v >= 10 ? 1 : 0; //On fait évoluer les curseurs de nos paramètres seulement s'ils // existent - smart l1 = l1&&l1.next; l2 = l2&&l2.next; } //idem return before.next; }
Pour 1563 tests, cette version s'exécute en 112ms soit un gain d'a peine 3%. Néanmoins, le code est près de deux fois plus court (16 lignes contre 31), ce qui peut avoir son importance. Savoir lequel de cet extrait ou de ma version re-factorisée est meilleure vous appartient et cela dépendra principalement de vos habitudes de développement, de celles de vos collègues, bref, de l'ensemble du contexte dans lequel s'inscrit votre algorithme.
C# et le reste du monde
Pour les adeptes de C#, j'ai benchmarké deux solutions ici - beats 100% et là - simple solution. Les benchmarks sont de 204ms et 132ms - soit plus lent que Javascript. On arrive à un beau 19ms en Java par ici, 63ms en Python et 28ms en C++.
Inutile de préciser que ces valeurs ne doivent pas être prises pour un benchmark sérieux.
Pour continuer :
Si dans le Code v1.1 vous changez ce morceau de code (et la lecture qui est faite du retour évidemment) :
const retenuHelper = add => { if (add > 9) return { add: add - 10, retenu: 1 }; else return { add: add, retenu: 0 }; };
par
const retenuHelper = add => { if (add > 9) return [add - 10, 1]; else return [add, 0]; };
vous aurez une fonction de 27% plus lente.
Pourquoi ? Un indice, c'est lié à l'exploitation de deux structures de données fondamentalement différentes. Si vous le savez, à vos commentaires :)
Commentaires