Pourquoi ces deux algorithmes n'ont pas les mêmes complexités alors qu'ils sont à peu près les mêmes ?

Le problème exposé dans ce sujet a été résolu.

Bonjour,

J’ai essayé de faire la question Decode Ways sur leetcode et j’ai remarqué que mon code est à peu près similaire à un autre mais la complexité est bien différente.

La question est de trouver toutes les manières possibles pour décoder une chaîne (de caractères) d’entiers sachant que les entiers sont associés à des lettres selon la règle suivante : 'A' est associée à 1, 'B' à 2, …, 'Z' à 26. Une chaîne de caractères du genre '02' ne peut pas être décodée et de même pour '0’. Si la chaîne de caractères est '12' alors il y a deux manières de la décoder: (1 1) qui correspond à 'AA' ou (12) qui correspond à 'L’. Par contre il n’y a qu’une seule manière de décoder '10' et qui est 'J’. Il n’y a aucune manière de décoder '1405' par contre, car (1 40 5) est invalide puisqu’on peut pas décoder 40, de même (14 0 5) à cause du 0 et (1 4 05) à cause du 05.

Mon code est le suivant:

class Solution:
    @cache 
    def numDecodings(self, s: str) -> int:
        if (len(s) == 0) or (len(s) == 1 and s != '0'):
            return 1
        return (self.numDecodings(s[1:]) if s[0] != '0' else 0) + \
    (self.numDecodings(s[2:]) if s[0] != '0' and 1 <= int(s[:2]) <= 26 else 0)

Sur les tests de leetcode ce code dure 35 ms et utilise 14.8 MB de mémoire.

Le meilleur code que j’ai trouvé dans les solutions est:

class Solution:
    def numDecodings(self, s: str) -> int:
        @cache
        def dp(i):
            if i>=len(s):return 1
            return (dp(i+2) if i!=len(s)-1 and s[i]!='0' and 0<int(s[i]+s[i+1])<=26 else 0)+(dp(i+1) if s[i]!='0' else 0)
        return dp(0)

Je ne sais pas si ce code a été soumis aux mêmes tests (je suppose que oui) mais il dure 32 ms et utilise 14.5 MB.

En cherchant s’il y a une différence entre ces deux codes je ne vois pas trop la différence. Peut-être que j’ai un test de plus que lui avec mon or mais c’est tout.

J’espère que vous pourrez m’aider à comprendre comment analyser et comparer ces codes.

Merci d’avance.

Salut !

Ce n’est pas un problème de complexité (32 ou 35ms c’est vraiment la même chose à une vache près). Si tu avais une différence de complexité, tu pourrais avoir des résultats qui varient du simple au double/décuple/centuple…

Ici, c’est simplement un phénomène de micro-optimisation. Le fait de stocker la fonction récursive dans une variable locale à la méthode permet à Python de la retrouver plus rapidement, et sans faire d’indirections par rapport à self (qui ont un coût non-nul).

En gros, le second code génère un bytecode un poil plus optimisé.

Dans les deux cas, on est vraiment dans du détail très très pointu et pas spécialement important. Ces deux codes sont certes "rapides" (pour du Python), mais ils sont aussi illisibles l’un que l’autre.

+4 -0

Je ne comprends pas. 3232ms c’est à peu près pareil que 3535ms. De même pour la mémoire.
On est sur la même complexité.

@nohar: J’aurais plutôt dit que c’est le coût de l’allocation et de cache des substrings (les s[:2] et s[:2]) qui expliquent les performances différentes non ?

+0 -0

Je ne comprends pas. 3232ms c’est à peu près pareil que 3535ms. De même pour la mémoire.
On est sur la même complexité.

@nohar: J’aurais plutôt dit que c’est le coût de l’allocation et de cache des substrings (les s[:2] et s[:2]) qui expliquent les performances différentes non ?

ache

C’est possible aussi, oui, les slices ne sont pas gratuites. Et ça a le mérite d’expliquer la différence de RAM.

+0 -0

On a 2 choses qui expliquent potentiellement les 33ms de différences. ^^
Clairement, c’est de la micro-optimisation.

+1 -0

Ce n’est pas un problème de complexité (32 ou 35ms c’est vraiment la même chose à une vache près). Si tu avais une différence de complexité, tu pourrais avoir des résultats qui varient du simple au double/décuple/centuple…

nohar

On pourra ajouter que performances et complexité ne sont pas nécessairement liées. Un algo A1 avec une complexité plus élevée que l’algo A2 pourra avoir de meilleures performances en pratique que l’algo A2 parce qu’il utilise suffisamment bien le langage/la machine cible pour qu’il faille aller chercher des instances du problème tellement grande pour obtenir une différence qu’en pratique cela n’a pas d’importance.

Bonjour,

Hahaha je vois donc c’est du même ordre de grandeur, je n’ai pas remarqué cela ^^’.

Ces deux codes sont certes "rapides" (pour du Python), mais ils sont aussi illisibles l’un que l’autre.

nohar

Je vois, je devrais développer les conditions sur plusieurs lignes. C’est qu’avec ce site je ne trouve que des réponses sur une ligne et j’ai pris cette habitude de "factoriser" le code. Mais c’est sûrement une mauvaise habitude si quelqu’un d’autre doit lire notre code ou même pour soi-même si on veut revoir ce qu’on a fait.

Ok donc en fin de compte c’est de la micro-optimisation avec ce qui pourrait être optimisé dans le premier code :

  • Le passage au self
  • Le slicing

Un algo A1 avec une complexité plus élevée que l’algo A2 pourra avoir de meilleures performances en pratique que l’algo A2 parce qu’il utilise suffisamment bien le langage/la machine cible pour qu’il faille aller chercher des instances du problème tellement grande pour obtenir une différence qu’en pratique cela n’a pas d’importance.

Ksass`Peuk

Ok je vois, la complexité n’est pas forcément une mesure de la performance d’un code / algorithme. Comme mentionné ci-dessus peut-être aussi un code plus lisible et avec une plus grande complexité serait plus adapté dans certains cas.

Merci à tous.

Ok donc en fin de compte c’est de la micro-optimisation avec ce qui pourrait être optimisé dans le premier code :

  • Le passage au self
  • Le slicing
DavidKayo

Alors attention, je précise encore que c’est bien de la micro- optimisation. Ça veut dire que dans la vraie vie, tu ne vas pour ainsi dire jamais avoir besoin de rentrer dans ces considérations (si tu en arrives là, alors la question qui se pose serait plutôt d’utiliser un langage plus bas niveau pour appeler le code depuis Python).

Mais puisque c’est le sujet ici, on peut détailler :

self en soi n’est pas gênant du tout (c’est une variable locale), ce qui se passe et qui a un coût en Python c’est quand tu fais self.numDecodings :

  • On va charger self sur la pile (ça c’est plutôt efficace).
  • On va ensuite chercher s’il a une méthode numDecodings, ce qui revient déjà à faire la même chose qu’une recherche dans un dictionnaire (hachage du nom, recherche dans la table, un objet remonte), puis mettre le résultat sur la pile.
  • Enfin on va appeler la fonction.

Dans le second cas, on va charger dp sur la pile (c’est une variable locale, donc ça va vite), puis on va l’appeler directement.

Après, le slicing en soi est quelque chose de plutôt efficace (si tu prends une slice d’une string, la donnée sous-jacente n’est pas copiée, mais tu vas avoir un nouvel objet str créé, qui va grosso-modo pointer sur la même donné, mais deux caractères plus loin dans ton cas). Ce qui se passe c’est qu’un objet est quand même créé et alloué même si la donnée n’est pas touchée, et du coup cette opération n’est pas "gratuite", quoiqu’elle reste négligeable la plupart du temps.

+2 -0
  • Le passage au self
  • Le slicing
DavidKayo

Ne pas oublier le cache. Tu caches, une string, passage pas valeur en Python, donc tu as une nouvelle allocation.

+0 -0

Salut,

Surtout, c’est un temps mesuré comment, et c’est quel temps ? Parce que 35 ms c’est en gros time python -c pass sur mon système, et ça varie facilement de qqs ms. Donc si ça trouve, vous êtes en train d’essayer d’expliquer du bruit dans le démarrage de l’interpréteur… :-°

+4 -0

Salut,

Surtout, c’est un temps mesuré comment, et c’est quel temps ? Parce que 35 ms c’est en gros time python -c pass sur mon système, et ça varie facilement de qqs ms. Donc si ça trouve, vous êtes en train d’essayer d’expliquer du bruit dans le démarrage de l’interpréteur… :-°

adri1

Et m… Je me suis fait avoir, tu as raison.

En allant jeter un oeil à leetcode, je pense que la durée est celle que le playground affiche, or pour un même script (l’exemple sur leur page de près’) on peut avoir une exécution qui va de 14ms à 48ms.

Les remarques ci-dessus restent à peu près valables en ce qui concerne les micro-optimisations, mais ici c’est certainement négligeable devant le temps qu’il faut pour soumettre un job dans leur interpréteur, que celui-ci s’exécute et qu’il renvoie un résultat à leur backend.

Donc c’est pire que du bruit au démarrage d’un processus, ce sont des latences réseau et une position dans une file d’attente.

Edit: Quoique, l’exemple C++ prend 0ms, donc peut-être que leur système tient compte du temps de compilation/chargement du programme…

La seule chose que l’on peut en tirer, c’est que de toute façon 3ms est une différence négligeable dans ce contexte.

+0 -0

Ne pas oublier le cache. Tu caches, une string, passage pas valeur en Python, donc tu as une nouvelle allocation.

ache

Je ne suis pas sûr exactement de quel concept tu cibles par « passage par valeur » mais si c’est celui du C, alors ça n’est pas applicable en Python. Avec CPython toutes les valeurs que tu passes sont des pointeurs : alors oui ces pointeurs sont passés « par valeur », mais ça ne représente qu’un nombre à copier.

De quelle nouvelle allocation parles-tu ?

Connectez-vous pour pouvoir poster un message.
Connexion

Pas encore membre ?

Créez un compte en une minute pour profiter pleinement de toutes les fonctionnalités de Zeste de Savoir. Ici, tout est gratuit et sans publicité.
Créer un compte