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.
Remarque: En règle générale, vous devez éviter d'analyser les fichiers manuellement, car vous pourriez ignorer cas de bord. Par exemple, dans l'un des champs, le caractère de tabulation de délimitation peut être utilisé littéralement entre guillemets, ce qui briserait le nombre de colonnes. Dans la mesure du possible, essayez de trouver un module pertinent dans la bibliothèque standard ou un module tiers fiable.
Au final, vous souhaitez vous retrouver avec deux fichiers texte à votre disposition:
names.txt
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.
Remarque: Parfois, il peut y avoir plusieurs réponses correctes en raison de articles en double ou similaires. Par exemple, si vous avez quelques contacts du même nom, ils correspondent tous à vos critères de recherche. À d'autres moments, il peut n'y avoir qu'une réponse approximative ou pas de réponse du tout.
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.
Recherche aléatoire
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.
Remarque: Curieusement, cette stratégie pourrait être le plus efficace, en théorie, si vous étiez très chanceux ou avait un petit nombre d'éléments dans la collection.
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.
Remarque: Si vous souhaitez effectuer cette expérience vous-même, reportez-vous aux instructions de l'introduction de ce didacticiel. Pour mesurer les performances de votre code, vous pouvez utiliser le temps
et timeit
modules, ou vous pouvez chronométrer les fonctions avec un décorateur personnalisé.
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.
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:
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.
Remarque: le dans
L'opérateur ne fait pas toujours une recherche linéaire. Lorsque vous l'utilisez sur un ensemble
, par exemple, il effectue une recherche basée sur le hachage à la place. L'opérateur peut travailler avec itérable, comprenant tuple
, liste
, ensemble
, dicter
, et str
. Vous pouvez même prendre en charge vos classes personnalisées avec elle en implémentant la méthode magique .__ contient __ ()
pour définir la logique sous-jacente.
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!
Recherche binaire
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.
Remarque: Parfois, si les valeurs sont uniformément réparties, vous pouvez calculer l'indice du milieu avec interpolation linéaire plutôt que de prendre la moyenne. Cette variation de l'algorithme nécessitera encore moins d'étapes.
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:
À 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:
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:
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:
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.
Remarque: Ne confondez pas diviser pour mieux régner avec programmation dynamique, qui est une technique quelque peu similaire.
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.
Recherche basée sur le hachage
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.
Remarque: Python a deux structures de données intégrées, à savoir ensemble
et dicter
, qui s'appuient sur la fonction de hachage pour rechercher des éléments. Alors qu’un ensemble
hache ses éléments, un dicter
utilise la fonction de hachage contre les clés des éléments. Pour savoir exactement comment un dicter
est implémenté en Python, consultez la conférence de Raymond Hettinger sur les dictionnaires Python modernes.
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:
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.
Remarque: Les seaux, ainsi que leur contenu, ne sont généralement pas dans un ordre particulier.
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.
Remarque: Il vous appartient de trier la liste avant de la transmettre à l'une des fonctions. Si les éléments ne sont pas triés, vous obtiendrez très probablement des résultats incorrects.
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:
-
Est le indice dans la taille de la liste?
-
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.
Remarque: Ceci est juste un exemple illustratif. Vous feriez mieux d’utiliser la recette recommandée, mentionnée dans la documentation officielle.
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:
- Iteratively
- 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.
Remarque: My favorite definition of recursion was given in an episode of the Fun Fun Function series about functional programming in JavaScript:
“Recursion is when a function calls itself until it doesn’t.”
– Mattias Petter Johansson
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:
- Local scope
- Enclosing scope
- Global scope
- 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.
Remarque: I once fell victim to the binary search algorithm during a technical screening. There were a couple of coding puzzles to solve, including a binary search one. Guess which one I failed to complete? Ouais.
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.
Remarque: le stack overflow error is very common among languages with manual memory management. People would often google those errors to see if someone else already had similar issues, which gave the name to a popular Q&A site for programmers.
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.
Remarque: If you require maximum precision, then steer away from floating-point numbers. They’re great for engineering purposes. However, for monetary operations, you don’t want rounding errors to accumulate. It’s recommended to scale down all prices and amounts to the smallest unit, such as cents or pennies, and treat them as integers.
Alternatively, many programming languages have support for fixed-point numbers, such as the decimal type in Python. This puts you in control of when and how rounding is taking place.
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.
Analyzing the Time-Space Complexity of Binary Search
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.
Remarque: Algorithms that don’t need to allocate more memory than their input data already consumes are called in-place, ou in-situ, algorithms. This results in mutating the original data, which sometimes may have unwanted side-effects.
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:
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:
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.
Remarque: Note that the same algorithm may have different optimistic, pessimistic, et average time complexity. For example, in the best-case scenario, a linear search algorithm will find the element at the first index, after running just one comparison.
On the other end of the spectrum, it’ll have to compare a reference value to all elements in the collection. In practice, you want to know the pessimistic complexity of an algorithm.
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:
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.
Remarque: When you say that some algorithm has complexity O(f(n))
, où n
is the size of the input data, then it means that the function f(n)
is an upper bound of the graph of that complexity. In other words, the actual complexity of that algorithm won’t grow faster than f(n)
multiplied by some constant, when n
approaches infinity.
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.
The Complexity of Binary Search
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:
- Tabular
- Graphical
- 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:
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:
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]