Comment faire une recherche binaire en Python – Real Python

By | mars 16, 2020

Formation gratuite Python

Recherche binaire est un algorithme classique en informatique. Cela revient souvent dans les concours de programmation et les interviews techniques. L'implémentation de la recherche binaire s'avère être une tâche difficile, même lorsque vous comprenez le concept. À moins que vous ne soyez curieux ou que vous n'ayez une affectation spécifique, vous devez toujours tirer parti des bibliothèques existantes pour effectuer une recherche binaire en Python ou dans tout autre langage.

Dans ce didacticiel, vous allez apprendre à:

  • Utilisez le couper en deux module pour faire une recherche binaire en Python
  • Implémentez une recherche binaire en Python à la fois récursivement et itérativement
  • Reconnaître et réparer défauts dans une implémentation Python de recherche binaire
  • Analysez le complexité spatio-temporelle de l'algorithme de recherche binaire
  • Rechercher même plus rapide que la recherche binaire

Ce didacticiel suppose que vous êtes un étudiant ou un programmeur intermédiaire avec un intérêt pour les algorithmes et les structures de données. À tout le moins, vous devez être familiarisé avec les types de données intégrés de Python, tels que les listes et les tuples. En outre, une certaine familiarité avec la récursivité, les classes, les classes de données et les lambdas vous aidera à mieux comprendre les concepts que vous verrez dans ce didacticiel.

Ci-dessous, vous trouverez un lien vers l'exemple de code que vous verrez tout au long de ce didacticiel, qui nécessite Python 3.7 ou version ultérieure pour s'exécuter:

Analyse comparative

Dans la section suivante de ce didacticiel, vous allez utiliser un sous-ensemble de la base de données Internet Movie (IMDb) pour comparer les performances de quelques algorithmes de recherche. Cet ensemble de données est gratuit pour un usage personnel et non commercial. Il est distribué sous la forme d'un groupe de fichiers de valeurs séparées par des tabulations (TSV) compressées, qui reçoivent des mises à jour quotidiennes.

Pour vous faciliter la vie, vous pouvez utiliser un script Python inclus dans l'exemple de code. Il récupérera automatiquement le fichier correspondant dans IMDb, le décompressera et extraira les éléments intéressants:

$ python download_imdb.py
Récupération des données depuis IMDb ...
Création de "names.txt" et "sorted_names.txt"

Soyez averti que cela va télécharger et extraire environ 600 Mo de données, ainsi que produire deux fichiers supplémentaires, qui sont environ la moitié de cette taille. Le téléchargement, ainsi que le traitement de ces données, peuvent prendre une ou deux minutes.

Télécharger IMDb

Pour obtenir manuellement les données, accédez à votre navigateur Web sur https://datasets.imdbws.com/ et récupérez le fichier appelé name.basics.tsv.gz, qui contient les enregistrements d'acteurs, de réalisateurs, d'écrivains, etc. Lorsque vous décompressez le fichier, vous voyez le contenu suivant:

nconst primaryName birthYear deathYear (...)
nm0000001 Fred Astaire 1899 1987 (...)
nm0000002 Lauren Bacall 1924 2014 (...)
nm0000003 Brigitte Bardot 1934  N (...)
nm0000004 John Belushi 1949 1982 (...)

Il a un entête avec les noms de colonne dans la première ligne, suivi de enregistrements de données dans chacune des lignes suivantes. Chaque enregistrement contient un identifiant unique, un nom complet, l'année de naissance et quelques autres attributs. Ils sont tous délimités par un caractère de tabulation.

Il y a des millions d'enregistrements, alors n'essayez pas d'ouvrir le fichier avec un éditeur de texte normal pour éviter de planter votre ordinateur. Même un logiciel spécialisé tel que des feuilles de calcul peut avoir des problèmes pour l'ouvrir. Au lieu de cela, vous pouvez profiter du visualiseur de grille de données hautes performances inclus dans JupyterLab, par exemple.

Lire les valeurs séparées par des tabulations

Il existe plusieurs façons d'analyser un fichier TSV. Par exemple, vous pouvez le lire avec Pandas, utiliser une application dédiée ou tirer parti de quelques outils de ligne de commande. Cependant, il est recommandé d'utiliser le script Python sans tracas inclus dans l'exemple de code.

Au final, vous souhaitez vous retrouver avec deux fichiers texte à votre disposition:

  1. names.txt
  2. sorted_names.txt

L'un contiendra une liste de noms obtenus en coupant la deuxième colonne du fichier TSV d'origine:

Fred Astaire
Lauren Bacall
Brigitte Bardot
John Belushi
Ingmar Bergman
...

Le second sera la version triée de ceci.

Une fois que les deux fichiers sont prêts, vous pouvez les charger dans Python en utilisant cette fonction:

def load_names(chemin):
    avec ouvert(chemin) comme fichier texte:
        revenir fichier texte.lis().splitlines()

des noms = load_names('names.txt')
sorted_names = load_names('sorted_names.txt')

Ce code renvoie une liste de noms extraits du fichier donné. Notez que l'appel .splitlines () sur la chaîne résultante supprime le caractère de fin de ligne de chaque ligne. Comme alternative, vous pouvez appeler text_file.readlines (), mais cela conserverait les nouvelles lignes indésirables.

Mesurer le temps d'exécution

Pour évaluer les performances d'un algorithme particulier, vous pouvez mesurer son temps d'exécution par rapport à l'ensemble de données IMDb. Cela se fait généralement à l'aide de la fonction intégrée temps ou timeit modules, qui sont utiles pour chronométrer un bloc de code.

Vous pouvez également définir un décorateur personnalisé pour chronométrer une fonction si vous le souhaitez. L'exemple de code fourni utilise time.perf_counter_ns (), introduit dans Python 3.7, car il offre une haute précision en nanosecondes.

Comprendre les algorithmes de recherche

La recherche est omniprésente et se situe au cœur de l'informatique. Vous avez probablement effectué plusieurs recherches sur le Web aujourd'hui, mais vous êtes-vous déjà demandé recherche signifie vraiment?

Les algorithmes de recherche prennent de nombreuses formes différentes. Par exemple, vous pouvez:

Dans ce didacticiel, vous apprendrez à rechercher un élément dans une liste triée d'éléments, comme un annuaire téléphonique. Lorsque vous recherchez un tel élément, vous vous posez peut-être l'une des questions suivantes:

Question Répondre
C'est ici? Oui
Où est-ce? Sur la 42e page
Laquelle est-ce? Une personne nommée John Doe

La réponse à la première question vous indique si un élément est présent dans la collection. Il détient toujours soit vrai ou faux. La deuxième réponse est la emplacement d'un élément dans la collection, qui peut être indisponible si cet élément était manquant. Enfin, la troisième réponse est la élément lui-même, ou un manque de celui-ci.

Dans le cas le plus courant, vous serez recherche par valeur, qui compare les éléments de la collection à celui que vous fournissez comme référence. En d'autres termes, vos critères de recherche sont l'élément entier, tel qu'un nombre, une chaîne ou un objet comme une personne. Même la plus petite différence entre les deux éléments comparés ne donnera pas lieu à un match.

D'un autre côté, vous pouvez être plus précis avec vos critères de recherche en choisissant une propriété d'un élément, comme le nom de famille d'une personne. C'est appelé recherche par clé car vous choisissez un ou plusieurs attributs à comparer. Avant de vous lancer dans la recherche binaire en Python, jetons un coup d'œil à d'autres algorithmes de recherche pour obtenir une vue d'ensemble et comprendre comment ils fonctionnent.

Comment pourriez-vous chercher quelque chose dans votre sac à dos? Vous pourriez simplement y enfoncer votre main, choisir un élément au hasard et voir si c'est celui que vous vouliez. Si vous n’avez pas de chance, vous remettez l’article, rincez et recommencez. Cet exemple est un bon moyen de comprendre recherche aléatoire, qui est l'un des algorithmes de recherche les moins efficaces. L'inefficacité de cette approche vient du fait que vous courez le risque de choisir plusieurs fois la même mauvaise chose.

Le principe fondamental de cet algorithme peut être exprimé avec l'extrait de code Python suivant:

importation Aléatoire

def trouver(éléments, valeur):
    tandis que Vrai:
        random_element = Aléatoire.choix(éléments)
        si random_element == valeur:
            revenir random_element

La fonction boucle jusqu'à ce qu'un élément choisi au hasard corresponde à la valeur donnée en entrée. Cependant, ce n'est pas très utile car la fonction renvoie soit Aucun implicitement ou la même valeur qu'il a déjà reçue dans un paramètre. Vous pouvez trouver l'implémentation complète dans l'exemple de code disponible en téléchargement sur le lien ci-dessous:

Pour les jeux de données microscopiques, l'algorithme de recherche aléatoire semble faire son travail assez rapidement:

>>>

>>> de search.random importation *  # Exemple de code à télécharger
>>> des fruits = [[[['Orange', 'prune', 'banane', 'Pomme']
>>> contient(des fruits, 'banane')
Vrai
>>> find_index(des fruits, 'banane')
2
>>> trouver(des fruits, clé=len, valeur=4)
'prune'

Cependant, imaginez devoir chercher comme ça à travers des millions d'éléments! Voici un bref aperçu d'un test de performances effectué sur l'ensemble de données IMDb:

Terme de recherche Index des éléments Meilleur temps Temps moyen Le pire moment
Fred Astaire 0 0,74 s 21,69 s 43.16s
Alicia Monica 4 500 000 1.02s 26.17s 66,34 s
Baoyin Liu 9 500 000 0,11 s 17.41s 51.03s
disparu N / A 5m 16s 5m 40s 5m 54s

Des éléments uniques à différents emplacements de mémoire ont été spécifiquement choisis pour éviter les biais. Chaque terme a été recherché dix fois pour tenir compte du caractère aléatoire de l'algorithme et d'autres facteurs tels que la collecte des ordures ou les processus système s'exécutant en arrière-plan.

L'algorithme a un performances non déterministes. Alors que le temps moyen pour trouver un élément ne dépend pas de l'endroit où il se trouve, les meilleurs et les pires moments sont séparés de deux à trois ordres de grandeur. Il souffre également d'un comportement incohérent. Pensez à disposer d'une collection d'éléments contenant des doublons. Parce que l'algorithme sélectionne des éléments au hasard, il retournera inévitablement différentes copies lors des exécutions suivantes.

Comment pouvez-vous améliorer cela? Une façon de résoudre les deux problèmes à la fois consiste à utiliser un recherche linéaire.

Lorsque vous décidez quoi manger pour le déjeuner, vous pouvez regarder le menu de façon chaotique jusqu'à ce que quelque chose attire votre attention. Alternativement, vous pouvez adopter une approche plus systématique en parcourant le menu de haut en bas et en examinant chaque élément dans un séquence. C'est une recherche linéaire en bref. Pour l'implémenter en Python, vous pouvez énumérer() éléments pour garder une trace de l'index de l'élément actuel:

def find_index(éléments, valeur):
    pour indice, élément dans énumérer(éléments):
        si élément == valeur:
            revenir indice

La fonction parcourt une collection d'éléments dans un ordre prédéfini et cohérent. Il s'arrête lorsque l'élément est trouvé ou lorsqu'il n'y a plus d'éléments à vérifier. Cette stratégie garantit qu'aucun élément n'est visité plus d'une fois car vous les parcourez dans l'ordre en indice.

Voyons dans quelle mesure la recherche linéaire gère le jeu de données IMDb que vous avez utilisé auparavant:

Terme de recherche Index des éléments Meilleur temps Temps moyen Le pire moment
Fred Astaire 0 491ns 1,17µs 6.1µs
Alicia Monica 4 500 000 0,37 s 0,38 s 0,39 s
Baoyin Liu 9 500 000 0,77 s 0,79 s 0,82s
disparu N / A 0,79 s 0,81s 0,83 s

Il n'y a pratiquement pas de variation dans le temps de recherche d'un élément individuel. Le temps moyen est pratiquement le même que le meilleur et le pire. Étant donné que les éléments sont toujours parcourus dans le même ordre, le nombre de comparaisons nécessaires pour trouver le même élément ne change pas.

Cependant, le temps de recherche augmente avec l'index croissant d'un élément dans la collection. Plus l'élément est éloigné du début de la liste, plus les comparaisons doivent être exécutées. Dans le pire des cas, lorsqu'un élément manque, la collection entière doit être vérifiée pour donner une réponse définitive.

Lorsque vous projetez des données expérimentales sur un tracé et connectez les points, vous verrez immédiatement la relation entre l'emplacement de l'élément et le temps nécessaire pour les trouver:

Performances de recherche linéaire

Tous les échantillons se trouvent sur une ligne droite et peuvent être décrits par un fonction linéaire, d'où vient le nom de l'algorithme. Vous pouvez supposer qu'en moyenne, le temps nécessaire pour rechercher un élément à l'aide d'une recherche linéaire sera proportionnel au nombre de tous les éléments de la collection. Ils ne s'adaptent pas bien à mesure que la quantité de données à rechercher augmente.

Par exemple, les scanners biométriques disponibles dans certains aéroports ne reconnaîtraient pas les passagers en quelques secondes, s'ils avaient été mis en œuvre à l'aide de la recherche linéaire. D'un autre côté, l'algorithme de recherche linéaire peut être un bon choix pour les petits ensembles de données, car il ne nécessite pas prétraitement les données. Dans un tel cas, les avantages du prétraitement ne rembourseraient pas son coût.

Python est déjà livré avec une recherche linéaire, il est donc inutile de l'écrire vous-même. le liste la structure de données, par exemple, expose une méthode qui retournera l'index d'un élément ou déclenchera une exception sinon:

>>>

>>> des fruits = [[[['Orange', 'prune', 'banane', 'Pomme']
>>> des fruits.indice('banane')
2
>>> des fruits.indice('myrtille')
Traceback (dernier appel le plus récent):
  Fichier "", ligne 1, dans 
ValueError: 'myrtille' n'est pas dans la liste

Cela peut également vous dire si l'élément est présent dans la collection, mais une manière plus Pythonique impliquerait d'utiliser le polyvalent dans opérateur:

>>>

>>> 'banane' dans des fruits
Vrai
>>> 'myrtille' dans des fruits
Faux

Il convient de noter que malgré l'utilisation de la recherche linéaire sous le capot, ces fonctions et opérateurs intégrés feront sauter votre mise en œuvre hors de l'eau. C'est parce qu'ils ont été écrits en C pur, qui se compile en code machine natif. L'interpréteur Python standard ne fait pas le poids, peu importe vos efforts.

Un test rapide avec le timeit Le module révèle que l'implémentation Python peut fonctionner presque dix fois plus lentement que l'équivalent natif équivalent:

>>>

>>> importation timeit
>>> de search.linear importation contient
>>> des fruits = [[[['Orange', 'prune', 'banane', 'Pomme']
>>> timeit.timeit(lambda: contient(des fruits, 'myrtille'))
1.8904765040024358
>>> timeit.timeit(lambda: 'myrtille' dans des fruits)
0,22473459799948614

Cependant, pour des ensembles de données suffisamment volumineux, même le code natif atteindra ses limites, et la seule solution sera de repenser l'algorithme.

Dans les scénarios réels, l'algorithme de recherche linéaire doit généralement être évité. Par exemple, il fut un temps où je n’étais pas en mesure d’inscrire mon chat à la clinique vétérinaire parce que leur système continuait de planter. Le médecin m'a dit qu'il devait éventuellement mettre à niveau son ordinateur car l'ajout de nouveaux enregistrements dans la base de données la rendait de plus en plus lente.

Je me souviens avoir pensé à ce moment-là que la personne qui a écrit ce logiciel ne savait clairement pas recherche binaire algorithme!

Le mot binaire est généralement associé au nombre 2. Dans ce contexte, il s'agit de diviser une collection d'éléments en deux moitiés et d'en jeter une à chaque étape de l'algorithme. Cela peut réduire considérablement le nombre de comparaisons nécessaires pour trouver un élément. Mais il y a un hic: les éléments de la collection doivent être trié premier.

L'idée derrière cela ressemble aux étapes pour trouver une page dans un livre. Au début, vous ouvrez généralement le livre sur une page complètement aléatoire ou au moins une page proche de l'endroit où vous pensez que la page souhaitée pourrait se trouver.

Parfois, vous aurez la chance de trouver cette page du premier coup. Cependant, si le numéro de page est trop bas, vous savez que la page doit être à droite. Si vous dépassez lors du prochain essai et que le numéro de la page actuelle est supérieur à la page que vous recherchez, alors vous savez avec certitude qu'elle doit se situer quelque part entre les deux.

Vous répétez le processus, mais plutôt que de choisir une page au hasard, vous vérifiez la page située en plein milieu de cette nouvelle gamme. Cela minimise le nombre d'essais. Une approche similaire peut être utilisée dans le jeu de devinettes numériques. Si vous n'avez pas entendu parler de ce jeu, vous pouvez le consulter sur Internet pour obtenir une pléthore d'exemples mis en œuvre en Python.

Les numéros de page qui restreignent la plage de pages à parcourir sont appelés borne inférieure et le limite supérieure. Dans la recherche binaire, vous commencez généralement par la première page comme limite inférieure et la dernière page comme limite supérieure. Vous devez mettre à jour les deux limites au fur et à mesure. Par exemple, si la page vers laquelle vous vous tournez est inférieure à celle que vous recherchez, c'est votre nouvelle borne inférieure.

Supposons que vous recherchiez une fraise dans une collection de fruits triés par ordre croissant de taille:

Fruits dans l'ordre croissant de leur taille

À la première tentative, l'élément au milieu se trouve être un citron. Puisqu'elle est plus grosse qu'une fraise, vous pouvez jeter tous les éléments à droite, y compris le citron. Vous allez déplacer la limite supérieure vers une nouvelle position et mettre à jour l'index du milieu:

Fruits dans l'ordre croissant de leur taille

Maintenant, il ne vous reste que la moitié des fruits avec lesquels vous avez commencé. L'élément central actuel est en effet la fraise que vous cherchiez, ce qui conclut la recherche. Si ce n'est pas le cas, vous devez simplement mettre à jour les limites en conséquence et continuer jusqu'à ce qu'elles se croisent. Par exemple, la recherche d'une prune manquante, qui irait entre la fraise et un kiwi, se terminera par le résultat suivant:

Fruits dans l'ordre croissant de leur taille

Notez qu'il n'y avait pas beaucoup de comparaisons à faire pour trouver l'élément souhaité. C’est la magie de la recherche binaire. Même si vous traitez avec un million d’éléments, vous n’avez besoin que d’une poignée de vérifications au maximum. Ce nombre ne dépassera pas le logarithme base deux du nombre total d'éléments dus à la réduction de moitié. En d'autres termes, le nombre d'éléments restants est réduit de moitié à chaque étape.

Ceci est possible car les éléments sont déjà triés par taille. Cependant, si vous souhaitez rechercher des fruits par une autre clé, telle qu'une couleur, vous devrez alors trier à nouveau l'intégralité de la collection. Pour éviter les frais généraux coûteux de tri, vous pouvez essayer de calculer à l'avance différentes vues de la même collection. Cela ressemble un peu à la création d'un index de base de données.

Considérez ce qui se passe si vous ajoutez, supprimez ou mettez à jour un élément dans une collection. Pour qu'une recherche binaire continue de fonctionner, vous devez conserver l'ordre de tri approprié. Cela peut être fait avec le couper en deux module, que vous lirez dans la prochaine section.

Vous verrez comment implémenter l'algorithme de recherche binaire en Python plus loin dans ce didacticiel. Pour l'instant, confrontons-le avec l'ensemble de données IMDb. Notez qu'il existe différentes personnes à rechercher qu'auparavant. C'est parce que l'ensemble de données doit être trié pour la recherche binaire, qui réorganise les éléments. Les nouveaux éléments sont situés à peu près aux mêmes indices que précédemment, pour garder les mesures comparables:

Terme de recherche Index des éléments Temps moyen Comparaisons
(…) Berendse 0 6,52µs 23
Jonathan Samuangte 4,499,997 6,99µs 24
Yorgos Rahmatoulin 9 500 001 6.5µs 23
disparu N / A 7.2µs 23

Les réponses sont presque instantanées. Dans le cas moyen, il ne faut que quelques microsecondes à la recherche binaire pour trouver un élément parmi les neuf millions! En dehors de cela, le nombre de comparaisons pour les éléments choisis reste presque constant, ce qui coïncide avec la formule suivante:

La formule du nombre de comparaisons

Trouver la plupart des éléments nécessitera le plus grand nombre de comparaisons, qui peut être dérivé d'un logarithme de la taille de la collection. Inversement, il n'y a qu'un seul élément au milieu qui peut être trouvé lors du premier essai avec une seule comparaison.

La recherche binaire est un excellent exemple de diviser et conquérir technique, qui divise un problème en un tas de petits problèmes du même genre. Les solutions individuelles sont ensuite combinées pour former la réponse finale. Un autre exemple bien connu de cette technique est l'algorithme de tri rapide.

Contrairement à d'autres algorithmes de recherche, la recherche binaire peut être utilisée au-delà de la simple recherche. Par exemple, il permet de tester l'appartenance à un ensemble, de trouver la valeur la plus grande ou la plus petite, de trouver le plus proche voisin de la valeur cible, d'effectuer des requêtes de plage, etc.

Si la vitesse est une priorité absolue, la recherche binaire n'est pas toujours le meilleur choix. Il existe des algorithmes encore plus rapides qui peuvent tirer parti des structures de données basées sur le hachage. Cependant, ces algorithmes nécessitent beaucoup de mémoire supplémentaire, tandis que la recherche binaire offre un bon compromis espace-temps.

Pour rechercher plus rapidement, vous devez affiner espace de problème. La recherche binaire atteint cet objectif en réduisant de moitié le nombre de candidats à chaque étape. Cela signifie que même si vous avez un million d'éléments, il faut au plus vingt comparaisons pour déterminer si l'élément est présent, à condition que tous les éléments soient triés.

Le moyen le plus rapide de rechercher consiste à savoir où trouver ce que vous recherchez. Si vous connaissiez l'emplacement exact de la mémoire d'un élément, vous y accéderiez directement sans avoir besoin de chercher en premier lieu. Le mappage d'un élément ou (plus communément) l'une de ses clés à l'emplacement de l'élément en mémoire est appelé hachage.

Vous pouvez considérer le hachage non pas comme la recherche de l'élément spécifique, mais plutôt le calcul de l'index en fonction de l'élément lui-même. C’est le travail d’un fonction de hachage, qui doit posséder certaines propriétés mathématiques. Une bonne fonction de hachage devrait:

Dans le même temps, il ne devrait pas être trop coûteux en termes de calcul, sinon son coût serait supérieur aux gains. Une fonction de hachage est également utilisée pour la vérification de l'intégrité des données ainsi qu'en cryptographie.

Une structure de données qui utilise ce concept pour mapper des clés en valeurs est appelée carte, une table de hachage, une dictionnaireou tableau associatif.

Une autre façon de visualiser le hachage consiste à imaginer ce que l'on appelle seaux d'éléments similaires regroupés sous leurs clés respectives. Par exemple, vous pouvez récolter des fruits dans différents seaux en fonction de la couleur:

Fruits groupés par couleur

La noix de coco et un kiwi vont dans le seau étiqueté marron, tandis qu'une pomme se retrouve dans un seau avec le rouge étiquette, etc. Cela vous permet de parcourir rapidement une fraction des éléments. Idéalement, vous ne voulez avoir qu'un seul fruit dans chaque seau. Sinon, vous obtenez ce qu'on appelle un collision, ce qui entraîne un surcroît de travail.

Mettons les noms de l'ensemble de données IMDb dans un dictionnaire, de sorte que chaque nom devienne une clé et que la valeur correspondante devienne son index:

>>>

>>> de référence importation load_names  # Exemple de code à télécharger
>>> des noms = load_names('names.txt')
>>> index_by_name = 
...     Nom: indice pour indice, Nom dans énumérer(des noms)
... 

Après avoir chargé des noms textuels dans une liste plate, vous pouvez énumérer() à l'intérieur d'une compréhension de dictionnaire pour créer la cartographie. Désormais, vérifier la présence de l'élément et obtenir son index est simple:

>>>

>>> «Guido van Rossum» dans index_by_name
Faux
>>> 'Arnold Schwarzenegger' dans index_by_name
Vrai
>>> index_by_name[[[['Arnold Schwarzenegger']
215

Grâce à la fonction de hachage utilisée en arrière-plan, vous n'avez pas besoin de mettre en œuvre de recherche du tout!

Voici comment l'algorithme de recherche basé sur le hachage fonctionne par rapport à l'ensemble de données IMDb:

Terme de recherche Index des éléments Meilleur temps Temps moyen Le pire moment
Fred Astaire 0 0,18µs 0,4µs 1,9µs
Alicia Monica 4 500 000 0,17µs 0,4µs 2,4µs
Baoyin Liu 9 500 000 0,17µs 0,4µs 2,6µs
disparu N / A 0,19µs 0,4µs 1,7µs

Non seulement le temps moyen est un ordre de grandeur plus rapide que l'implémentation Python de recherche binaire déjà rapide, mais la vitesse est également maintenue sur tous les éléments, où qu'ils se trouvent.

Le prix de ce gain est d'environ 0,5 Go de mémoire supplémentaire consommée par le processus Python, du temps de chargement plus lent et de la nécessité de garder ces données supplémentaires cohérentes avec le contenu du dictionnaire. À son tour, la recherche est très rapide, tandis que les mises à jour et les insertions sont légèrement plus lentes par rapport à une liste.

Une autre contrainte que les dictionnaires imposent à leurs clés est qu’elles doivent être lavable, et leur valeurs de hachage ne peut pas changer avec le temps. Vous pouvez vérifier si un type de données particulier est hachable en Python en appelant hacher() dessus:

>>>

>>> clé = [[[['rond', 'juteux']
>>> hacher(clé)
Traceback (dernier appel le plus récent):
  Fichier "", ligne 1, dans 
Erreur-type: type inutilisable: 'liste'

Collections mutables, telles qu'un liste, ensemble, et dicter—N'est pas lavable. En pratique, les clés du dictionnaire doivent être immuable car leur valeur de hachage dépend souvent de certains attributs de la clé. Si une collection mutable était lavable et pouvait être utilisée comme clé, sa valeur de hachage serait différente chaque fois que le contenu changerait. Considérez ce qui se passerait si un fruit particulier changeait de couleur à cause de la maturation. Vous ne le cherchiez pas dans le mauvais seau!

La fonction de hachage a de nombreuses autres utilisations. Par exemple, il est utilisé en cryptographie pour éviter de stocker des mots de passe sous forme de texte brut, ainsi que pour la vérification de l'intégrité des données.

En utilisant le couper en deux Module

La recherche binaire en Python peut être effectuée à l'aide de la fonction intégrée couper en deux module, qui aide également à conserver une liste dans l'ordre trié. Il est basé sur la méthode de la bissection pour trouver les racines des fonctions. Ce module est livré avec six fonctions réparties en deux catégories:

Trouver l'index Insérer un élément
couper en deux() insort ()
bisect_left () insort_left ()
bisect_right () insort_right ()

Ces fonctions vous permettent de trouver un index d'un élément ou d'ajouter un nouvel élément dans la bonne position. Ceux de la première ligne ne sont que des alias pour bisect_right () et insort_right (), respectivement. En réalité, vous n’avez affaire qu’à quatre fonctions.

Sans plus tarder, voyons le couper en deux module en action.

Trouver un élément

Pour rechercher l'index d'un élément existant dans une liste triée, vous souhaitez bisect_left ():

>>>

>>> importation couper en deux
>>> fruits_sortis = [[[['Pomme', 'banane', 'Orange', 'prune']
>>> couper en deux.bisect_left(fruits_sortis, 'banane')
1

La sortie vous indique qu'une banane est le deuxième fruit de la liste car elle a été trouvée à l'index 1. Cependant, si un élément manquait, vous obtiendriez toujours sa position attendue:

>>>

>>> couper en deux.bisect_left(fruits_sortis, 'abricot')
1
>>> couper en deux.bisect_left(fruits_sortis, 'pastèque')
4

Même si ces fruits ne figurent pas encore sur la liste, vous pouvez vous faire une idée de l'endroit où les mettre. Par exemple, un abricot devrait se placer entre la pomme et la banane, tandis qu'une pastèque devrait devenir le dernier élément. Vous saurez si un élément a été trouvé en évaluant deux conditions:

  1. Est le indice dans la taille de la liste?

  2. Est le valeur de l'élément souhaité?

Cela peut être traduit en une fonction universelle pour trouver des éléments par valeur:

def find_index(éléments, valeur):
    indice = couper en deux.bisect_left(éléments, valeur)
    si indice < len(éléments) et éléments[[[[indice] == valeur:
        revenir indice

En cas de correspondance, la fonction renvoie l'index d'élément correspondant. Sinon, il reviendra Aucun implicitement.

Pour effectuer une recherche par clé, vous devez gérer une liste de clés distincte. Étant donné que cela entraîne un coût supplémentaire, il vaut la peine de calculer les clés à l'avance et de les réutiliser autant que possible. Vous pouvez définir une classe d'assistance pour pouvoir rechercher par différentes clés sans introduire beaucoup de duplication de code:

classe Recherché par:
    def __init__(soi, clé, éléments):
        soi.elements_by_key = trié([([([([(clé(X), X) pour X dans éléments])
        soi.clés = [[[[X[[[[0] pour X dans soi.elements_by_key]

La clé est une fonction passée comme premier paramètre à __init __ (). Une fois que vous l'avez, vous créez une liste triée de paires clé-valeur pour pouvoir récupérer un élément de sa clé ultérieurement. La représentation de paires avec des tuples garantit que le premier élément de chaque paire sera trié. Dans l'étape suivante, vous extrayez les clés pour créer une liste plate qui convient à votre implémentation Python de recherche binaire.

Ensuite, il y a la méthode actuelle pour trouver des éléments par clé:

classe Recherché par:
    def __init__(soi, clé, éléments):
        ...

    def trouver(soi, valeur):
        indice = couper en deux.bisect_left(soi.clés, valeur)
        si indice < len(soi.clés) et soi.clés[[[[indice] == valeur:
            revenir soi.elements_by_key[[[[indice][[[[1]

Ce code bissecte la liste des clés triées pour obtenir l'index d'un élément par clé. Si une telle clé existe, son index peut être utilisé pour obtenir la paire correspondante à partir de la liste précédemment calculée de paires clé-valeur. Le deuxième élément de cette paire est la valeur souhaitée.

Si vous aviez plusieurs bananes, alors bisect_left () retournerait l'instance la plus à gauche:

>>>

>>> fruits_sortis = [[[[
...     'Pomme',
...     'banane', 'banane', 'banane',
...     'Orange',
...     'prune'
... ]
>>> couper en deux.bisect_left(fruits_sortis, 'banane')
1

Comme on pouvait s'y attendre, pour obtenir la banane la plus à droite, vous devez appeler bisect_right () ou son couper en deux() alias. Cependant, ces deux fonctions renvoient un index plus loin de la banane réelle la plus à droite, ce qui est utile pour trouver le point d'insertion d'un nouvel élément:

>>>

>>> couper en deux.bisect_right(fruits_sortis, 'banane')
4
>>> couper en deux.couper en deux(fruits_sortis, 'banane')
4
>>> fruits_sortis[[[[4]
'Orange'

Lorsque vous combinez le code, vous pouvez voir combien de bananes vous avez:

>>>

>>> l = couper en deux.bisect_left(fruits_sortis, 'banane')
>>> r = couper en deux.bisect_right(fruits_sortis, 'banane')
>>> r - l
3

Si un élément manquait, les deux bisect_left () et bisect_right () retournerait le même indice donnant zéro banane.

Insertion d'un nouvel élément

Une autre application pratique du couper en deux module maintient l'ordre des éléments dans une liste déjà triée. Après tout, vous ne voudriez pas trier la liste entière à chaque fois que vous devez y insérer quelque chose. Dans la plupart des cas, les trois fonctions peuvent être utilisées de manière interchangeable:

>>>

>>> importation couper en deux
>>> fruits_sortis = [[[['Pomme', 'banane', 'Orange']
>>> couper en deux.insulte(fruits_sortis, 'abricot')
>>> couper en deux.insort_left(fruits_sortis, 'pastèque')
>>> couper en deux.insort_right(fruits_sortis, 'prune')
>>> fruits_sortis
['apple', 'apricot', 'banana', 'orange', 'plum', 'watermelon']

Vous ne verrez aucune différence tant qu'il n'y aura pas doublons dans votre liste. Mais même alors, cela ne deviendra pas évident tant que ces doublons seront de simples valeurs. Ajouter une autre banane à gauche aura le même effet que l'ajouter à droite.

To notice the difference, you need a data type whose objects can have unique identities despite having equal values. Let’s define a Person type using the @dataclass decorator, which was introduced in Python 3.7:

de dataclasses import dataclass, field

@dataclass
class Person:
    Nom: str
    nombre: int = field(comparer=Faux)

    def __repr__(soi):
        return F"self.name(self.number)'

A person has a Nom and an arbitrary nombre assigned to it. By excluding the nombre field from the equality test, you make two people equal even if they have different values of that attribute:

>>>

>>> p1 = Person('John', 1)
>>> p2 = Person('John', 2)
>>> p1 == p2
Vrai

On the other hand, those two variables refer to completely separate entities, which allows you to make a distinction between them:

>>>

>>> p1 est p2
Faux
>>> p1
John(1)
>>> p2
John(2)

The variables p1 et p2 are indeed different objects.

Note that instances of a data class aren’t comparable by default, which prevents you from using the bisection algorithm on them:

>>>

>>> alice, bob = Person('Alice', 1), Person('Bob', 1)
>>> alice < bob
Traceback (most recent call last):
  Fichier "", line 1, dans 
TypeError: '<' not supported between instances of 'Person' and 'Person'

Python doesn’t know to order alice et bob, because they’re objects of a custom class. Traditionally, you’d implement the magic method .__lt__() in your class, which stands for less than, to tell the interpreter how to compare such elements. Cependant, le @dataclass decorator accepts a few optional Boolean flags. One of them is order, which results in an automatic generation of the magic methods for comparison when set to Vrai:

@dataclass(order=Vrai)
class Person:
    ...

In turn, this allows you to compare two people and decide which one comes first:

>>>

>>> alice < bob
Vrai
>>> bob < alice
Faux

Finally, you can take advantage of the Nom et nombre properties to observe where various functions insert new people to the list:

>>>

>>> sorted_people = [[[[Person('John', 1)]
>>> bisect.insort_left(sorted_people, Person('John', 2))
>>> bisect.insort_right(sorted_people, Person('John', 3))
>>> sorted_people
[John(2), John(1), John(3)]

The numbers in parentheses after the names indicate the insertion order. In the beginning, there was just one John, who got the number 1. Then, you added its duplicate to the left, and later one more to the right.

Implementing Binary Search in Python

Keep in mind that you probably shouldn’t implement the algorithm unless you have a strong reason to. You’ll save time and won’t need to reinvent the wheel. The chances are that the library code is mature, already tested by real users in a production environment, and has extensive functionality delivered by multiple contributors.

That said, there are times when it makes sense to roll up your sleeves and do it yourself. Your company might have a policy banning certain open source libraries due to licensing or security matters. Maybe you can’t afford another dependency due to memory or network bandwidth constraints. Lastly, writing code yourself might be a great learning tool!

You can implement most algorithms in two ways:

  1. Iteratively
  2. Recursively

However, there are exceptions to that rule. One notable example is the Ackermann function, which can only be expressed in terms of recursion.

Before you go any further, make sure that you have a good grasp of the binary search algorithm. You can refer to an earlier part of this tutorial for a quick refresher.

Iteratively

The iterative version of the algorithm involves a loop, which will repeat some steps until the stopping condition is met. Let’s begin by implementing a function that will search elements by value and return their index:

def find_index(elements, value):
    ...

You’re going to reuse this function later.

Assuming that all elements are sorted, you can set the lower and the upper boundaries at the opposite ends of the sequence:

def find_index(elements, value):
    la gauche, droite = 0, len(elements) - 1

Now, you want to identify the middle element to see if it has the desired value. Calculating the middle index can be done by taking the average of both boundaries:

def find_index(elements, value):
    la gauche, droite = 0, len(elements) - 1
    middle = (la gauche + droite) // 2

Notice how an integer division helps to handle both an odd and even number of elements in the bounded range by flooring the result. Depending on how you’re going to update the boundaries and define the stopping condition, you could also use a ceiling function.

Next, you either finish or split the sequence in two and continue searching in one of the resultant halves:

def find_index(elements, value):
    la gauche, droite = 0, len(elements) - 1
    middle = (la gauche + droite) // 2

    si elements[[[[middle] == value:
        return middle

    si elements[[[[middle] < value:
        la gauche = middle + 1
    elif elements[[[[middle] > value:
        droite = middle - 1

If the element in the middle was a match, then you return its index. Otherwise, if it was too small, then you need to move the lower boundary up. If it was too big, then you need to move the upper boundary down.

To keep going, you have to enclose most of the steps in a loop, which will stop when the lower boundary overtakes the upper one:

def find_index(elements, value):
    la gauche, droite = 0, len(elements) - 1

    while la gauche <= droite:
        middle = (la gauche + droite) // 2

        si elements[[[[middle] == value:
            return middle

        si elements[[[[middle] < value:
            la gauche = middle + 1
        elif elements[[[[middle] > value:
            droite = middle - 1

In other words, you want to iterate as long as the lower boundary is below or equal to the upper one. Otherwise, there was no match, and the function returns Aucun implicitly.

Searching by key boils down to looking at an object’s attributes instead of its literal value. A key could be the number of characters in a fruit’s name, for example. You can adapt find_index() to accept and use a key parameter:

def find_index(elements, value, key):
    la gauche, droite = 0, len(elements) - 1

    while la gauche <= droite:
        middle = (la gauche + droite) // 2
        middle_element = key(elements[[[[middle])

        si middle_element == value:
            return middle

        si middle_element < value:
            la gauche = middle + 1
        elif middle_element > value:
            droite = middle - 1

However, you must also remember to sort the list using the same key that you’re going to search with:

>>>

>>> des fruits = [[[['orange', 'plum', 'watermelon', 'apple']
>>> des fruits.sort(key=len)
>>> des fruits
['plum', 'apple', 'orange', 'watermelon']
>>> des fruits[[[[find_index(des fruits, key=len, value=dix)]
'watermelon'
>>> impression(find_index(des fruits, key=len, value=3))
Aucun

In the example above, pastèque was chosen because its name is precisely ten characters long, while no fruits on the list have names made up of three letters.

That’s great, but at the same time, you’ve just lost the ability to search by value. To remedy this, you could assign the key a default value of Aucun and then check if it was given or not. However, in a more streamlined solution, you’d always want to call the key. By default, it would be an identity function returning the element itself:

def identity(element):
    return element

def find_index(elements, value, key=identity):
    ...

Alternatively, you might define the identity function inline with an anonymous lambda expression:

def find_index(elements, value, key=lambda X: X):
    ...

find_index() answers only one question. There are still two others, which are “Is it there?” and “What is it?” To answer these two, you can build on top of it:

def find_index(elements, value, key):
    ...

def contient(elements, value, key=identity):
    return find_index(elements, value, key) est ne pas Aucun

def find(elements, value, key=identity):
    index = find_index(elements, value, key)
    return Aucun si index est Aucun autre elements[[[[index]

With these three functions, you can tell almost everything about an element. However, you still haven’t addressed duplicates in your implementation. What if you had a collection of people, and some of them shared a common name or surname? For example, there might be a Smith family or a few guys going by the name of John among the people:

people = [[[[
    Person('Bob', 'Williams'),
    Person('John', 'Doe'),
    Person('Paul', 'Brown'),
    Person('Alice', 'Smith'),
    Person('John', 'Smith'),
]

To model the Person type, you can modify a data class defined earlier:

de dataclasses import dataclass

@dataclass(order=Vrai)
class Person:
    Nom: str
    surname: str

Notice the use of the order attribute to enable automatic generation of magic methods for comparing instances of the class by all fields. Alternatively, you might prefer to take advantage of the namedtuple, which has a shorter syntax:

de collections import namedtuple
Person = namedtuple('Person', 'name surname')

Both definitions are fine and interchangeable. Each person has a Nom et un surname attribute. To sort and search by one of them, you can conveniently define the key function with an attrgetter() available in the built-in operator module:

>>>

>>> de operator import attrgetter
>>> by_surname = attrgetter('surname')
>>> people.sort(key=by_surname)
>>> people
[Person(name='Paul'surname='Brown')[Person(name='Paul'surname='Brown')[Person(name='Paul'surname='Brown')[Person(name='Paul'surname='Brown')
    Person(name='John', surname='Doe'),
    Person(name='Alice', surname='Smith'),
    Person(name='John', surname='Smith'),
    Person(name='Bob', surname='Williams')]

Notice how people are now sorted by surname in ascending order. Il y a John Smith et Alice Smith, but binary searching for the Smith surname currently gives you only one arbitrary result:

>>>

>>> find(people, key=by_surname, value='Smith')
Person(name='Alice', surname='Smith')

To mimic the features of the bisect module shown before, you can write your own version of bisect_left() et bisect_right(). Before finding the leftmost instance of a duplicate element, you want to determine if there’s such an element at all:

def find_leftmost_index(elements, value, key=identity):
    index = find_index(elements, value, key)
    si index est ne pas Aucun:
        ...
    return index

If some index has been found, then you can look to the left and keep moving until you come across an element with a different key or there are no more elements:

def find_leftmost_index(elements, value, key=identity):
    index = find_index(elements, value, key)
    si index est ne pas Aucun:
        while index >= 0 et key(elements[[[[index]) == value:
            index -= 1
        index += 1
    return index

Once you go past the leftmost element, you need to move the index back by one position to the right.

Trouver le rightmost instance is quite similar, but you need to flip the conditions:

def find_rightmost_index(elements, value, key=identity):
    index = find_index(elements, value, key)
    si index est ne pas Aucun:
        while index < len(elements) et key(elements[[[[index]) == value:
            index += 1
        index -= 1
    return index

Instead of going left, now you’re going to the right until the end of the list. Using both functions allows you to find all occurrences of duplicate items:

def find_all_indices(elements, value, key=identity):
    la gauche = find_leftmost_index(elements, value, key)
    droite = find_rightmost_index(elements, value, key)
    si la gauche et droite:
        return ensemble(range(la gauche, droite + 1))
    return ensemble()

This function always returns a set. If the element isn’t found, then the set will be empty. If the element is unique, then the set will be made up of only a single index. Otherwise, there will be multiple indices in the set.

To wrap up, you can define even more abstract functions to complete your binary search Python library:

def find_leftmost(elements, value, key=identity):
    index = find_leftmost_index(elements, value, key)
    return Aucun si index est Aucun autre elements[[[[index]

def find_rightmost(elements, value, key=identity):
    index = find_rightmost_index(elements, value, key)
    return Aucun si index est Aucun autre elements[[[[index]

def find_all(elements, value, key=identity):
    return elements[[[[je] pour je dans find_all_indices(elements, value, key)

Not only does this allow you to pinpoint the exact location of elements on the list, but also to retrieve those elements. You’re able to ask very specific questions:

Is it there? Where is it? Qu'Est-ce que c'est?
contains() find_index() find()
find_leftmost_index() find_leftmost()
find_rightmost_index() find_rightmost()
find_all_indices() find_all()

The complete code of this binary search Python library can be found at the link below:

Recursively

For the sake of simplicity, you’re only going to consider the recursive version of contains(), which tells you if an element was found.

The most straightforward approach would be to take the iterative version of binary search and use the slicing operator to chop the list:

def contient(elements, value):
    la gauche, droite = 0, len(elements) - 1

    si la gauche <= droite:
        middle = (la gauche + droite) // 2

        si elements[[[[middle] == value:
            return Vrai

        si elements[[[[middle] < value:
            return contient(elements[[[[middle + 1:], value)
        elif elements[[[[middle] > value:
            return contient(elements[:[:[:[:middle], value)

    return Faux

Instead of looping, you check the condition once and sometimes call the same function on a smaller list. What could go wrong with that? Well, it turns out that slicing generates copies of element references, which can have noticeable memory and computational overhead.

To avoid copying, you might reuse the same list but pass different boundaries into the function whenever necessary:

def contient(elements, value, la gauche, droite):
    si la gauche <= droite:
        middle = (la gauche + droite) // 2

        si elements[[[[middle] == value:
            return Vrai

        si elements[[[[middle] < value:
            return contient(elements, value, middle + 1, droite)
        elif elements[[[[middle] > value:
            return contient(elements, value, la gauche, middle - 1)

    return Faux

The downside is that every time you want to call that function, you have to pass initial boundaries, making sure they’re correct:

>>>

>>> sorted_fruits = [[[['apple', 'banana', 'orange', 'plum']
>>> contient(sorted_fruits, 'apple', 0, len(sorted_fruits) - 1)
Vrai

If you were to make a mistake, then it would potentially not find that element. You can improve this by using default function arguments or by introducing a helper function that delegates to the recursive one:

def contient(elements, value):
    return recursive(elements, value, 0, len(elements) - 1)

def recursive(elements, value, la gauche, droite):
    ...

Going further, you might prefer to nest one function in another to hide the technical details and to take advantage of variable reuse from outer scope:

def contient(elements, value):
    def recursive(la gauche, droite):
        si la gauche <= droite:
            middle = (la gauche + droite) // 2
            si elements[[[[middle] == value:
                return Vrai
            si elements[[[[middle] < value:
                return recursive(middle + 1, droite)
            elif elements[[[[middle] > value:
                return recursive(la gauche, middle - 1)
        return Faux
    return recursive(0, len(elements) - 1)

le recursive() inner function can access both elements et value parameters even though they’re defined in the enclosing scope. The life cycle and visibility of variables in Python is dictated by the so-called LEGB rule, which tells the interpreter to look for symbols in the following order:

  1. Local scope
  2. Enclosing scope
  3. Global scope
  4. Built-in symbols

This allows variables that are defined in outer scope to be accessed from within nested blocks of code.

The choice between an iterative and a recursive implementation is often the net result of performance considerations, convenience, as well as personal taste. However, there are also certain risks involved with recursion, which is one of the subjects of the next section.

Covering Tricky Details

Here’s what the author of The Art of Computer Programming has to say about implementing the binary search algorithm:

“Although the basic idea of binary search is comparatively straightforward, the details can be surprisingly tricky, and many good programmers have done it wrong the first few times they tried.”

— Donald Knuth

If that doesn’t deter you enough from the idea of writing the algorithm yourself, then maybe this will. The standard library in Java had a subtle bug in their implementation of binary search, which remained undiscovered for a decade! But the bug itself traces its roots much earlier than that.

The following list isn’t exhaustive, but at the same time, it doesn’t talk about common mistakes like forgetting to sort the list.

Integer Overflow

This is the Java bug that was just mentioned. If you recall, the binary search Python algorithm inspects the middle element of a bounded range in a sorted collection. But how is that middle element chosen exactly? Usually, you take the average of the lower and upper boundary to find the middle index:

middle = (la gauche + droite) // 2

This method of calculating the average works just fine in the overwhelming majority of cases. However, once the collection of elements becomes sufficiently large, the sum of both boundaries won’t fit the integer data type. It’ll be larger than the maximum value allowed for integers.

Some programming languages might raise an error in such situations, which would immediately stop program execution. Unfortunately, that’s not always the case. For example, Java silently ignores this problem, letting the value flip around and become some seemingly random number. You’ll only know about the problem as long as the resulting number happens to be negative, which throws an IndexOutOfBoundsException.

Here’s an example that demonstrates this behavior in jshell, which is kind of like an interactive interpreter for Java:

jshell> var une = Integer.MAX_VALUE
une ==> 2147483647

jshell> une + 1
$2 ==> -2147483648

A safer way to find the middle index could be calculating the offset first and then adding it to the lower boundary:

middle = la gauche + (droite - la gauche) // 2

Even if both values are maxed out, the sum in the formula above will never be. There are a few more ways, but the good news is that you don’t need to worry about any of these, because Python is free from the integer overflow error. There’s no upper limit on how big integers can be other than your memory:

>>>

>>> 2147483647**sept
210624582650556372047028295576838759252690170086892944262392971263

However, there’s a catch. When you call functions from a library, that code might be subject to the C language constraints and still cause an overflow. There are plenty of libraries based on the C language in Python. You could even build your own C extension module or load a dynamically-linked library into Python using ctypes.

Stack Overflow

le stack overflow problem may, theoretically, concern the recursive implementation of binary search. Most programming languages impose a limit on the number of nested function calls. Each call is associated with a return address stored on a stack. In Python, the default limit is a few thousand levels of such calls:

>>>

>>> import sys
>>> sys.getrecursionlimit()
3000

This won’t be enough for a lot of recursive functions. However, it’s very unlikely that a binary search in Python would ever need more due to its logarithmic nature. You’d need a collection of two to the power of three thousand elements. That’s a number with over nine hundred digits!

Nevertheless, it’s still possible for the infinite recursion error to arise if the stopping condition is stated incorrectly due to a bug. In such a case, the infinite recursion will eventually cause a stack overflow.

You can temporarily lift or decrease the recursion limit to simulate a stack overflow error. Note that the effective limit will be smaller because of the functions that the Python runtime environment has to call:

>>>

>>> def countup(limit, n=1):
...     impression(n)
...     si n < limit:
...         countup(limit, n + 1)
...
>>> import sys
>>> sys.setrecursionlimit(sept)  # Actual limit is 3
>>> countup(dix)
1
2
3
Traceback (most recent call last):
  Fichier "", line 1, dans 
  
  
  
  Fichier "", line 4, dans countup
  Fichier "", line 4, dans countup
  Fichier "", line 2, dans countup
RecursionError: maximum recursion depth exceeded while calling a Python object

The recursive function was called three times before saturating the stack. The remaining four calls must have been made by the interactive interpreter. If you run that same code in PyCharm or an alternative Python shell, then you might get a different result.

Duplicate Elements

You’re aware of the possibility of having duplicate elements in the list and you know how to deal with them. This is just to emphasize that a conventional binary search in Python might not produce deterministic results. Depending on how the list was sorted or how many elements it has, you’ll get a different answer:

>>>

>>> de search.binary import *
>>> sorted_fruits = [[[['apple', 'banana', 'banana', 'orange']
>>> find_index(sorted_fruits, 'banana')
1
>>> sorted_fruits.append('plum')
>>> find_index(sorted_fruits, 'banana')
2

There are two bananas on the list. At first, the call to find_index() returns the left one. However, adding a completely unrelated element at the end of the list makes the same call give you a different banane.

The same principle, known as algorithm stability, applies to sorting algorithms. Some are stable, meaning they don’t change the relative positions of equivalent elements. Others don’t make such guarantees. If you ever need to sort elements by multiple criteria, then you should always start from the least significant key to retain stability.

Floating-Point Rounding

So far you’ve only searched for fruits or people, but what about numbers? They should be no different, right? Let’s make a list of floating-point numbers at 0,1 increments using a list comprehension:

>>>

>>> sorted_numbers = [[[[0,1*je pour je dans range(1, 4)]

The list should contain numbers the one-tenth, two-tenths, et three-tenths. Surprisingly, only two of those three numbers can be found:

>>>

>>> de search.binary import contient
>>> contient(sorted_numbers, 0,1)
Vrai
>>> contient(sorted_numbers, 0.2)
Vrai
>>> contient(sorted_numbers, 0.3)
Faux

This isn’t a problem strictly related to binary search in Python, as the built-in linear search is consistent with it:

>>>

>>> 0,1 dans sorted_numbers
Vrai
>>> 0.2 dans sorted_numbers
Vrai
>>> 0.3 dans sorted_numbers
Faux

It’s not even a problem related to Python but rather to how floating-point numbers are represented in computer memory. This is defined by the IEEE 754 standard for floating-point arithmetic. Without going into much detail, some decimal numbers don’t have a finite representation in binary form. Because of limited memory, those numbers get rounded, causing a floating-point rounding error.

If you do need to work with floating-point numbers, then you should replace exact matching with an approximate comparison. Let’s consider two variables with slightly different values:

>>>

>>> une = 0.3
>>> b = 0,1 * 3
>>> b
0.30000000000000004
>>> une == b
Faux

Regular comparison gives a negative result, although both values are nearly identical. Fortunately, Python comes with a function that will test if two values are close to each other within some small neighborhood:

>>>

>>> import math
>>> math.isclose(une, b)
Vrai

That neighborhood, which is the maximum distance between the values, can be adjusted if needed:

>>>

>>> math.isclose(une, b, rel_tol=1e-16)
Faux

You can use that function to do a binary search in Python in the following way:

import math

def find_index(elements, value):
    la gauche, droite = 0, len(elements) - 1

    while la gauche <= droite:
        middle = (la gauche + droite) // 2

        si math.isclose(elements[[[[middle], value):
            return middle

        si elements[[[[middle] < value:
            la gauche = middle + 1
        elif elements[[[[middle] > value:
            droite = middle - 1

On the other hand, this implementation of binary search in Python is specific to floating-point numbers only. You couldn’t use it to search for anything else without getting an error.

The following section will contain no code and some math concepts.

In computing, you can optimize the performance of pretty much any algorithm at the expense of increased memory use. For instance, you saw that a hash-based search of the IMDb dataset required an extra 0.5 GB of memory to achieve unparalleled speed.

Conversely, to save bandwidth, you’d compress a video stream before sending it over the network, increasing the amount of work to be done. This phenomenon is known as the space-time tradeoff and is useful in evaluating an algorithm’s complexity.

Time-Space Complexity

The computational complexity is a relative measure of how many resources an algorithm needs to do its job. The resources include computation time as well as the amount of memory it uses. Comparing the complexity of various algorithms allows you to make an informed decision about which is better in a given situation.

You looked at a few search algorithms and their average performance against a large dataset. It’s clear from those measurements that a binary search is faster than a linear search. You can even tell by what factor.

However, if you took the same measurements in a different environment, you’d probably get slightly or perhaps entirely different results. There are invisible factors at play that can be influencing your test. Besides, such measurements aren’t always feasible. So, how can you compare time complexities quickly and objectively?

The first step is to break down the algorithm into smaller pieces and find the one that is doing the most work. It’s likely going to be some elementary operation that gets called a lot and consistently takes about the same time to run. For search algorithms, such an operation might be the comparison of two elements.

Having established that, you can now analyze the algorithm. To find the time complexity, you want to describe the relation between the number of elementary operations executed versus the size of the input. Formally, such a relationship is a mathematical function. However, you’re not interested in looking for its exact algebraic formula but rather Estimation its overall shape.

There are a few well-known classes of functions that most algorithms fit in. Once you classify an algorithm according to one of them, you can put it on a scale:

Common Classes of Time Complexity
Common Classes of Time Complexity

These classes tell you how the number of elementary operations increases with the growing size of the input. They are, from left to right:

  • Constant
  • Logarithmic
  • Linear
  • Quasilinear
  • Quadratic
  • Exponential
  • Factorial

This can give you an idea about the performance of the algorithm you’re considering. A constant complexity, regardless of the input size, is the most desired one. A logarithmic complexity is still pretty good, indicating a divide-and-conquer technique at use. The further to the right on this scale, the worse the complexity of the algorithm, because it has more work to do.

When you’re talking about the time complexity, what you typically mean is the asymptotic complexity, which describes the behavior under very large data sets. This simplifies the function formula by eliminating all terms and coefficients but the one that grows at the fastest rate (for example, n squared).

However, a single function doesn’t provide enough information to compare two algorithms accurately. The time complexity may vary depending on the volume of data. For example, the binary search algorithm is like a turbocharged engine, which builds pressure before it’s ready to deliver power. On the other hand, the linear search algorithm is fast from the start but quickly reaches its peak power and ultimately loses the race:

Time Complexity of Linear Search and Binary Search

In terms of speed, the binary search algorithm starts to overtake the linear search when there’s a certain number of elements in the collection. For smaller collections, a linear search might be a better choice.

There are a few mathematical notations of the asymptotic complexity, which are used to compare algorithms. By far the most popular one is the Big-O notation.

The Big-O Notation

le Big-O notation represents the worst-case scenario of asymptotic complexity. Although this might sound rather intimidating, you don’t need to know the formal definition. Intuitively, it’s a very rough measure of the rate of growth at the tail of the function that describes the complexity. You pronounce it as “big-oh” of something:

The Big-O Notation

That “something” is usually a function of data size or just the digit “one” that stands for a constant. For example, the linear search algorithm has a time complexity of O(n), while a hash-based search has O(1) complexity.

In real-life, the Big-O notation is used less formally as both an upper and a lower bound. This is useful for the classification and comparison of algorithms without having to worry about the exact function formulas.

You’ll estimate the asymptotic time complexity of binary search by determining the number of comparisons in the worst-case scenario—when an element is missing—as a function of input size. You can approach this problem in three different ways:

  1. Tabular
  2. Graphical
  3. Analytical

le tabular method is about collecting empirical data, putting it in a table, and trying to guess the formula by eyeballing sampled values:

Number of Elements Number of Comparisons
0 0
1 1
2 2
3 2
4 3
5 3
6 3
sept 3
8 4

The number of comparisons grows as you increase the number of elements in the collection, but the rate of growth is slower than if it was a linear function. That’s an indication of a good algorithm that can scale with data.

If that doesn’t help you, you can try the graphical method, which visualizes the sampled data by drawing a graph:

Empirical Data of Binary Search

The data points seem to overlay with a curve, but you don’t have enough information to provide a conclusive answer. It could be a polynomial, whose graph turns up and down for larger inputs.

Taking the analytical approach, you can choose some relationship and look for patterns. For example, you might study how the number of elements shrinks in each step of the algorithm:

Comparison Number of Elements
n
1er n/2
2nd n/4
3rd n/8
k-th n/2 k

In the beginning, you start with the whole collection of n elements. After the first comparison, you’re left with only half of them. Next, you have a quarter, and so on. The pattern that arises from this observation is that after k-th comparison, there are n/2 k elements. Variable k is the expected number of elementary operations.

After all k comparisons, there will be no more elements left. However, when you take one step back, that is k – 1, there will be exactly one element left. This gives you a convenient equation:

The Equation of Binary Search Complexity

Multiply both sides of the equation by the denominator, then take the logarithm base two of the result, and move the remaining constant to the right. You’ve just found the formula for the binary search complexity, which is on the order of O(log(n)).

Conclusion

Now you know the binary search algorithm inside and out. You can flawlessly implement it yourself, or take advantage of the standard library in Python. Having tapped into the concept of time-space complexity, you’re able to choose the best search algorithm for the given situation.

Now you can:

  • Utilisez le bisect module to do a binary search in Python
  • Implement binary search in Python recursively et iteratively
  • Recognize and fix defects in a binary search Python implementation
  • Analyze the time-space complexity of the binary search algorithm
  • Search even faster than binary search

With all this knowledge, you’ll rock your programming interview! Whether the binary search algorithm is an optimal solution to a particular problem, you have the tools to figure it out on your own. You don’t need a computer science degree to do so.

You can grab all of the code you’ve seen in this tutorial at the link below:

[ad_2]