La fonction all et les expressions génératrices

vendredi 29 mars 2019
par  Sébastien HOARAU

Le but de cette fiche est de montrer la puissance de la fonction all associée aux expressions génératrices. Puissance à la fois dans la forme (le code résultat est concis et proche du langage naturel) que dans le fond (la rapidité d’exécution est aussi bonne que d’autres solutions). Les tests sont fait sous l’interprète interactif ipython

L’exercice prétexte : la fonction is_prime

Cette fonction prend un entier n positif ou nul en entrée et retourne True si et seulement si n est un nombre premier.

Définition d’un nombre premier

Un nombre premier est un entier naturel qui admet exactement deux diviseurs distincts entiers et positifs : 1 et lui-même.

En partant de cette définition, on serait tenté, pour coder en python une fonction is_prime testant la primalité d’un entier d’énumérer tous les diviseurs du nombre et constater qu’il y en a exactement deux. Ce n’est en général pas ce qu’on fait.

On peut aussi remarquer que n est premier si et seulement si n est supérieur ou égal à 2 et qu’aucun entier entre 2 et n-1 ne divise n. Ci-dessous une première définition sur ce principe :

Quelques appels à is_prime

>>> some_int = [11, 8, 2, 0, -1, 151]
>>>for n in some_int:
      ...print(f'{n:3d} est-il premier ? {is_prime(n)}')
 11 est-il premier ? True
  8 est-il premier ? False
  2 est-il premier ? True
  0 est-il premier ? False
 -1 est-il premier ? False
151 est-il premier ? True

Et l’efficacité ?

Dans un interprète ipython, on peut utiliser %timeit pour mesurer le temps d’exécution d’une instruction.

>>> pair = 79991172
>>> %timeit is_prime(pair)
5.41 s ± 178 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

On constate un très long temps de réponse alors que le nombre est pair. Bien sûr cela est dû à la boucle for : alors même que l’on détecte immédiatement que 2 divise pair, la boucle poursuit jusqu’à la fin. Il convient donc de s’arrêter dès la détection d’un diviseur. Voici une autre version tenant compte de cette remarque :

>>> %timeit is_prime(pair)
230 ns ± 1.88 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

Voilà qui est mieux. Testons cette fois avec un nombre premier.

>>> grand = 79991173
>>> %timeit is_prime(grand)
8.74 s ± 9.64 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

Comme on le constate, le temps de calcul est important pour détecter la primalité d’un entier de l’ordre de la dizaine de millions ; ça ne semble pas très efficace. On peut améliorer les choses en constatant que la recherche d’un diviseur potentiel peut s’arrêter à int(sqrt(n))+1 :

>>> %timeit is_prime(grand)
3.11 ms ± 67.3 µs per loop (mean ± std. dev. of 7 runs, 100 loops each)

Nous voilà avec un temps de réponse acceptable.

Mais quid de la clarté ?

Clairement ce code n’est pas très proche de la définition en langage courant, dont on rappelle qu’elle est :
n est premier ssi n > 1 et pour tout les entiers d entre 2 et racine carrée de n, d ne divise pas n. La clarté du code précédent pourrait amélioré en revenant à une boucle for et l’utilisation de plusieurs instructions return

>>> %timeit is_prime(grand)
596 µs ± 4.82 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

On constate, en plus d’avoir un code plus court, et probablement plus lisible, un gain en temps de réponse.Mais cela reste perfectible : l’utilisation de plusieurs return peut ne pas plaire. Et même si on opte pour cette version :

La fonction all

all est une fonction prédéfinie python qui prend un iterable comme paramètre et retourne True si chaque élément de l’iterable est évalué à True ou si l’itérable est vide. Ci-dessous quelques exemples :

>>> all([True, True])
True
>>> all('hello')
True
>>> all('')
True
>>> all((3 > 2, is_prime(5)))
True
>>> all([is_prime(n) for n in range(10)])
False

Dans les exemples ci-dessus nous avons des itérables très classiques : listes, chaînes de caractères, tuples. Dans le dernier exemple nous utilisons une liste en compréhension. Cette idée peux être améliorée : un itérable peut être un itérateur, ici par exemple obtenu grâce à une expression génératrice.

Associons une expression génératrice

>>> all(is_prime(n) for n in [2, 3, 5, 7, 11])
True

Une expression génératrice est une expression de la forme :

(f(x) for x in iterateur)

Cette expression produit un itérateur. L’avantage principal d’un itérateur par rapport à un autre itérable comme les séquences, c’est qu’aucune structure n’est créée et donc un gain en mémoire appréciable. Ci-dessous quelques exemples de l’utilisation des expressions génératices :

>>> sum(x ** 2 for x in range(10))
285
>>> phrase = """Tu te jugeras donc toi-même, lui répondit le roi. C’ est le plus difficile. Il est bien plus difficile de se juger soi-même que de juger autrui"""
>>> s = set(mot.lower() for mot in phrase.split())
>>> print(s)
{'donc', 'soi-même', 'le', 'toi-même,', 'juger', 'il', 'bien', 'difficile.', 'lui', 'roi.', 'plus', 'se', 'autrui', 'de', 'te', 'jugeras', 'tu', 'est', 'que', 'difficile', 'répondit', 'c’'}
>>> phrase = phrase.lower()
>>> d = dict((c, phrase.count(c)) for c in phrase if 'a' <= c <= 'z')
>>> print(d)
{'t': 7, 'u': 10, 'e': 17, 'j': 3, 'g': 3, 'r': 6, 'a': 2, 's': 7, 'd': 6, 'o': 5, 'n': 3, 'c': 4, 'i': 14, 'm': 4, 'l': 8, 'p': 3, 'f': 4, 'b': 1, 'q': 1}

Nouvelle définition de is_prime

Notons que nous retrouvons la clarté de la définition en langage naturel. Il nous reste à vérifier que le temps de réponse est au moins aussi bon qu’avant :

>>> %timeit is_prime(grand)
915 µs ± 3.94 µs per loop (mean ± std. dev. of 7 runs, 1000 loops each)

Dernier point à vérifier : est-ce que la découverte d’un diviseur stoppe l’itération ?

Première vérification

>>> %timeit is_prime(pair)
1.16 µs ± 1.82 ns per loop (mean ± std. dev. of 7 runs, 1000000 loops each)

Deuxième vérification

Elle s’appuie sur la propriété suivante : un itérateur ne peut être utilisé qu’une fois.

>>> it = (x {{ 2 for x in range(5))
>>>
>>> # Première utilisation
>>> for n in it:
       ... print(n)
0
1
4
9
16
>>>
>>> # deuxième utilisation
>>> for n in it:
       ... print(n)
>>>

Comme on le constate, la deuxième itération ne produit rien : l’itérateur ayant servi une fois, il est vide la seconde fois.

>>> it2 = (d for d in range(2, int(sqrt(pair))+1))
>>> all(pair % d for d in it2)
False
>>>for d in it2:
      ... if d < 6:
      ... ... print(d)
      ... else:
      ... ... break
3
4
5

it2 est donc un itérateur des entiers de 2 à la racine carrée de notre grand nombre pair. La première fois nous l’utilisons dans la fonction all puis une seconde fois pour afficher quelques unes des valeurs. On constate que it2 n’a été utilisée par la fonction all que pour la première valeur (2) et donc la deuxième utilisation commence à 3.

Conclusion

Mal connue, la fonction all associée aux expressions génératrices offre à la fois une concision et une simplicité d’écriture proche du langage naturel sans perdre en performance. Elle mérite donc de figurer dans les concepts à enseigner même aux débutants. A noter que la fonction any propose les mêmes avantages.


Commentaires

Logo de Alain BUSSER
jeudi 20 juin 2019 à 03h23 - par  Alain BUSSER

Dans cet article est proposé un autre test de primalité, totalement inefficace mais basé sur une définition moins calculatoire des nombres premiers : Un nombre est premier si et seulement s’il admet exactement 2 diviseurs.

Logo de Alain BUSSER
jeudi 20 juin 2019 à 03h17 - par  Alain BUSSER

Bien que très éloignée de la langue courante (n’expliquant pas ce qu’est un nombre premier par exemple), cette version est très rapide, parce qu’elle utilise une subtilité de la fonction return qui agit comme break :

def is_prime(n):
    if n<2:
        return False
    else:
        for d in range(2,int(sqrt(n)+1)):
            if n%d==0:
                return False
    return True