Pourquoi un dict Python peut-il avoir plusieurs clés avec le même hachage?

90

J'essaie de comprendre la hashfonction Python sous le capot. J'ai créé une classe personnalisée où toutes les instances renvoient la même valeur de hachage.

class C:
    def __hash__(self):
        return 42

J'ai juste supposé qu'une seule instance de la classe ci-dessus peut être dans un dictà tout moment, mais en fait, a dictpeut avoir plusieurs éléments avec le même hachage.

c, d = C(), C()
x = {c: 'c', d: 'd'}
print(x)
# {<__main__.C object at 0x7f0824087b80>: 'c', <__main__.C object at 0x7f0823ae2d60>: 'd'}
# note that the dict has 2 elements

J'ai expérimenté un peu plus et j'ai trouvé que si je remplace la __eq__méthode de telle sorte que toutes les instances de la classe se comparent égales, alors la dictseule n'autorise qu'une seule instance.

class D:
    def __hash__(self):
        return 42
    def __eq__(self, other):
        return True

p, q = D(), D()
y = {p: 'p', q: 'q'}
print(y)
# {<__main__.D object at 0x7f0823a9af40>: 'q'}
# note that the dict only has 1 element

Je suis donc curieux de savoir comment un dictpeut avoir plusieurs éléments avec le même hachage.

Praveen Gollakota
la source
3
Comme vous l'avez découvert, les ensembles et les dictionnaires peuvent contenir plusieurs objets avec des hachages égaux si les objets ne sont pas égaux eux-mêmes. Que demandez-vous? Comment fonctionnent les tables? C'est une question assez générale avec beaucoup de matériel existant ...
@delnan J'y pensais plus après avoir posté la question; que ce comportement ne peut pas être limité à Python. Et tu as raison. Je suppose que je devrais approfondir la littérature générale sur les tables de hachage. Merci.
Praveen Gollakota

Réponses:

55

Pour une description détaillée du fonctionnement du hachage de Python, consultez ma réponse à Pourquoi le retour anticipé est-il plus lent qu'autre chose?

Fondamentalement, il utilise le hachage pour choisir un emplacement dans la table. S'il y a une valeur dans l'emplacement et que le hachage correspond, il compare les éléments pour voir s'ils sont égaux.

Si le hachage ne correspond pas ou si les éléments ne sont pas égaux, il essaie un autre emplacement. Il existe une formule pour choisir ceci (que je décris dans la réponse référencée), et elle extrait progressivement les parties inutilisées de la valeur de hachage; mais une fois qu'il les a tous utilisés, il finira par se frayer un chemin à travers tous les emplacements de la table de hachage. Cela garantit finalement que nous trouvons un élément correspondant ou un emplacement vide. Lorsque la recherche trouve un emplacement vide, elle insère la valeur ou abandonne (selon que nous ajoutons ou obtenons une valeur).

La chose importante à noter est qu'il n'y a pas de listes ou de buckets: il y a juste une table de hachage avec un nombre particulier d'emplacements, et chaque hachage est utilisé pour générer une séquence d'emplacements candidats.

Duncan
la source
7
Merci de m'avoir indiqué la bonne direction concernant l'implémentation de la table de hachage. J'ai lu beaucoup plus que je ne l'aurais jamais voulu sur les tables de hachage et j'ai expliqué mes résultats dans une réponse séparée. stackoverflow.com/a/9022664/553995
Praveen Gollakota
112

Voici tout sur les dictionnaires Python que j'ai pu rassembler (probablement plus que quiconque voudrait savoir; mais la réponse est complète). Un cri à Duncan pour avoir signalé que les dictionnaires Python utilisent des créneaux horaires et m'ont conduit dans ce terrier de lapin.

  • Les dictionnaires Python sont implémentés sous forme de tables de hachage .
  • Les tables de hachage doivent permettre des collisions de hachage, c'est-à-dire que même si deux clés ont la même valeur de hachage, l'implémentation de la table doit avoir une stratégie pour insérer et récupérer les paires clé et valeur sans ambiguïté.
  • Python dict utilise l'adressage ouvert pour résoudre les collisions de hachage (expliqué ci-dessous) (voir dictobject.c: 296-297 ).
  • La table de hachage Python est juste un bloc de mémoire contingent (un peu comme un tableau, vous pouvez donc effectuer une O(1)recherche par index).
  • Chaque emplacement du tableau peut stocker une et une seule entrée. C'est important
  • Chaque entrée du tableau est en fait une combinaison des trois valeurs -. Ceci est implémenté comme une structure C (voir dictobject.h: 51-56 )
  • La figure ci-dessous est une représentation logique d'une table de hachage python. Dans la figure ci-dessous, 0, 1, ..., i, ... sur la gauche sont les indices des slots dans la table de hachage (ils sont juste à des fins d'illustration et ne sont pas stockés avec la table évidemment!).

    # Logical model of Python Hash table
    -+-----------------+
    0| <hash|key|value>|
    -+-----------------+
    1|      ...        |
    -+-----------------+
    .|      ...        |
    -+-----------------+
    i|      ...        |
    -+-----------------+
    .|      ...        |
    -+-----------------+
    n|      ...        |
    -+-----------------+
    
  • Lorsqu'un nouveau dict est initialisé, il commence avec 8 emplacements . (voir dictobject.h: 49 )

  • Lors de l'ajout d'entrées à la table, nous commençons avec un emplacement, ibasé sur le hachage de la clé. CPython utilise initial i = hash(key) & mask. Où mask = PyDictMINSIZE - 1, mais ce n'est pas vraiment important). Notez simplement que l'emplacement initial, i, qui est vérifié dépend du hachage de la clé.
  • Si cet emplacement est vide, l'entrée est ajoutée à l'emplacement (par entrée, je veux dire, <hash|key|value>). Mais que faire si cet emplacement est occupé!? Très probablement parce qu'une autre entrée a le même hachage (collision de hachage!)
  • Si l'emplacement est occupé, CPython (et même PyPy) compare le hachage ET la clé (par comparaison, je veux dire ==comparaison, pas la iscomparaison) de l'entrée dans l'emplacement avec la clé de l'entrée actuelle à insérer ( dictobject.c: 337 , 344-345 ). Si les deux correspondent, alors il pense que l'entrée existe déjà, abandonne et passe à l'entrée suivante à insérer. Si le hachage ou la clé ne correspondent pas, il commence à sonder .
  • Sonder signifie simplement qu'il recherche les emplacements par emplacement pour trouver un emplacement vide. Techniquement, nous pourrions simplement aller un par un, i + 1, i + 2, ... et utiliser le premier disponible (c'est le sondage linéaire). Mais pour des raisons bien expliquées dans les commentaires (voir dictobject.c: 33-126 ), CPython utilise un sondage aléatoire . Dans le sondage aléatoire, l'emplacement suivant est choisi dans un ordre pseudo aléatoire. L'entrée est ajoutée au premier emplacement vide. Pour cette discussion, l'algorithme réel utilisé pour choisir le prochain slot n'est pas vraiment important (voir dictobject.c: 33-126 pour l'algorithme de sondage). Ce qui est important, c'est que les emplacements soient sondés jusqu'à ce que le premier emplacement vide soit trouvé.
  • La même chose se produit pour les recherches, commence juste par l'emplacement initial i (où i dépend du hachage de la clé). Si le hachage et la clé ne correspondent pas à l'entrée de l'emplacement, il commence à sonder jusqu'à ce qu'il trouve un emplacement avec une correspondance. Si tous les emplacements sont épuisés, il signale un échec.
  • BTW, le dict sera redimensionné s'il est plein aux deux tiers. Cela évite de ralentir les recherches. (voir dictobject.h: 64-65 )

Voilà! L'implémentation Python de dict vérifie à la fois l'égalité de hachage de deux clés et l'égalité normale ( ==) des clés lors de l'insertion d'éléments. Donc, en résumé, s'il y a deux clés, aet bet hash(a)==hash(b), mais a!=b, alors les deux peuvent exister harmonieusement dans un dict Python. Mais si hash(a)==hash(b) et a==b , alors ils ne peuvent pas être tous les deux dans le même dict.

Parce que nous devons sonder après chaque collision de hachage, un effet secondaire d'un trop grand nombre de collisions de hachage est que les recherches et les insertions deviendront très lentes (comme le souligne Duncan dans les commentaires ).

Je suppose que la réponse courte à ma question est: "Parce que c'est ainsi qu'il est implémenté dans le code source;)"

Bien que cela soit bon à savoir (pour les points de geek?), Je ne sais pas comment cela peut être utilisé dans la vraie vie. Parce qu'à moins que vous n'essayiez de casser explicitement quelque chose, pourquoi deux objets qui ne sont pas égaux auraient-ils le même hachage?

Praveen Gollakota
la source
8
Cela explique comment le remplissage du dictionnaire fonctionne. Mais que se passe-t-il s'il y a une collision de hachage lors de la récupération d'une paire key_value. Supposons que nous ayons 2 objets A et B, tous deux hachés sur 4. Ainsi, d'abord A se voit attribuer le slot 4, puis B se voit attribuer un slot par sondage aléatoire. Que se passe-t-il lorsque je veux récupérer B.B hashes à 4, donc python vérifie d'abord l'emplacement 4, mais la clé ne correspond pas, donc elle ne peut pas retourner A. Parce que l'emplacement de B a été attribué par sondage aléatoire, comment B est-il retourné à nouveau dans le temps O (1)?
sayantankhan
4
@ Bolt64 le sondage aléatoire n'est pas vraiment aléatoire. Pour les mêmes valeurs de clé, il suit toujours la même séquence de sondes, donc il finira par trouver B. Les dictionnaires ne sont pas garantis d'être O (1), si vous obtenez beaucoup de collisions, ils peuvent prendre plus de temps. Avec les anciennes versions de Python, il est facile de construire une série de clés qui entreront en collision et dans ce cas, les recherches dans le dictionnaire deviennent O (n). C'est un vecteur possible pour les attaques DoS, donc les nouvelles versions de Python modifient le hachage pour rendre plus difficile de le faire délibérément.
Duncan
2
@Duncan et si A est supprimé et que nous effectuons une recherche sur B? Je suppose que vous ne supprimez pas réellement les entrées mais les marquez comme supprimées? Cela voudrait dire que les dictionnaires ne conviennent pas aux insertions et suppressions continues ....
gen-ys
2
@ gen-ys yes supprimé et inutilisé sont traités différemment pour la recherche. Inutilisé arrête la recherche d'une correspondance, mais pas supprimé. Lors de l'insertion, supprimés ou inutilisés sont traités comme des emplacements vides pouvant être utilisés. Les insertions et les suppressions continues sont très bien. Lorsque le nombre d'emplacements inutilisés (non supprimés) tombe trop bas, la table de hachage sera reconstruite de la même manière que si elle était devenue trop grande pour la table actuelle.
Duncan
1
Ce n'est pas une très bonne réponse sur le point de collision auquel Duncan a tenté de remédier. C'est une réponse particulièrement mauvaise à la référence pour la mise en œuvre de votre question. Ce qui est primordial pour comprendre cela, c'est qu'en cas de collision, Python essaie à nouveau d'utiliser une formule pour calculer le prochain décalage dans la table de hachage. Lors de la récupération, si la clé n'est pas la même, il utilise la même formule pour rechercher le décalage suivant. Il n'y a rien d'aléatoire.
Evan Carroll
20

Edit : la réponse ci-dessous est l'un des moyens possibles de gérer les collisions de hachage, ce n'est cependant pas ainsi que Python le fait. Le wiki de Python référencé ci-dessous est également incorrect. La meilleure source donnée par @Duncan ci-dessous est l'implémentation elle-même: https://github.com/python/cpython/blob/master/Objects/dictobject.c Je m'excuse pour la confusion.


Il stocke une liste (ou un seau) d'éléments au niveau du hachage, puis parcourt cette liste jusqu'à ce qu'il trouve la clé réelle dans cette liste. Une image en dit plus que mille mots:

Table de hachage

Ici, vous voyez John Smithet les Sandra Deedeux hachage 152. Bucket les 152contient tous les deux. Lors de la recherche, Sandra Deeil trouve d'abord la liste dans le seau 152, puis parcourt cette liste jusqu'à ce qu'il Sandra Deesoit trouvé et retourne 521-6955.

Ce qui suit est faux, c'est seulement ici pour le contexte: Sur le wiki de Python, vous pouvez trouver du code (pseudo?) Comment Python effectue la recherche.

Il existe en fait plusieurs solutions possibles à ce problème, consultez l'article wikipedia pour un bel aperçu: http://en.wikipedia.org/wiki/Hash_table#Collision_resolution

Rob Wouters
la source
Merci pour l'explication et surtout pour le lien vers l'entrée du wiki Python avec le pseudo code!
Praveen Gollakota
2
Désolé, mais cette réponse est tout simplement fausse (tout comme l'article du wiki). Python ne stocke pas de liste ou de seau d'éléments au niveau du hachage: il stocke précisément un objet dans chaque emplacement de la table de hachage. Si l'emplacement qu'il essaie d'utiliser en premier est occupé, il choisit un autre emplacement (en tirant le plus longtemps possible les parties inutilisées du hachage), puis un autre et un autre. Puisqu'aucune table de hachage n'est pleine à plus d'un tiers, elle doit finalement trouver un emplacement disponible.
Duncan
@Duncan, le wiki de Python dit qu'il est implémenté de cette façon. Je serais heureux de trouver une meilleure source. La page wikipedia.org n'est certainement pas fausse, c'est juste l'une des solutions possibles comme indiqué.
Rob Wouters
@Duncan Pouvez-vous expliquer s'il vous plaît ... extraire les parties inutilisées du hachage aussi longtemps que possible? Tous les hachages dans mon cas évaluent à 42. Merci!
Praveen Gollakota
@PraveenGollakota Suivez le lien dans ma réponse, qui explique en détail sanglant comment le hachage est utilisé. Pour un hachage de 42 et une table avec 8 emplacements initialement, seuls les 3 bits les plus bas sont utilisés pour trouver l'emplacement numéro 2 mais si cet emplacement est déjà utilisé, les bits restants entrent en jeu. Si deux valeurs ont exactement le même hachage, la première passe dans le premier emplacement essayé et la seconde obtient l'emplacement suivant. S'il y a 1000 valeurs avec des hachages identiques, nous finissons par essayer 1000 slots avant de trouver la valeur et la recherche dans le dictionnaire devient très très lente!
Duncan
4

Les tables de hachage, en général, doivent permettre les collisions de hachage! Vous aurez de la malchance et deux choses finiront par devenir la même chose. En dessous, il y a un ensemble d'objets dans une liste d'éléments qui ont la même clé de hachage. Habituellement, il n'y a qu'une seule chose dans cette liste, mais dans ce cas, elle continuera à les empiler dans la même liste. La seule façon dont il sait qu'ils sont différents est d'utiliser l'opérateur égal.

Lorsque cela se produit, vos performances se dégraderont avec le temps, c'est pourquoi vous voulez que votre fonction de hachage soit aussi "aléatoire que possible".

Donald Miner
la source
2

Dans le fil de discussion, je n'ai pas vu ce que fait exactement python avec les instances d'une classe définie par l'utilisateur lorsque nous le mettons dans un dictionnaire en tant que clés. Lisons une documentation: elle déclare que seuls les objets hachables peuvent être utilisés comme clés. Les hashable sont toutes les classes intégrées immuables et toutes les classes définies par l'utilisateur.

Les classes définies par l'utilisateur ont par défaut les méthodes __cmp __ () et __hash __ (); avec eux, tous les objets se comparent de manière inégale (sauf avec eux-mêmes) et x .__ hash __ () renvoie un résultat dérivé de id (x).

Donc, si vous avez un __hash__ constamment dans votre classe, mais ne fournissant aucune méthode __cmp__ ou __eq__, alors toutes vos instances sont inégales pour le dictionnaire. D'un autre côté, si vous fournissez une méthode __cmp__ ou __eq__, mais pas __hash__, vos instances sont toujours inégales en termes de dictionnaire.

class A(object):
    def __hash__(self):
        return 42


class B(object):
    def __eq__(self, other):
        return True


class C(A, B):
    pass


dict_a = {A(): 1, A(): 2, A(): 3}
dict_b = {B(): 1, B(): 2, B(): 3}
dict_c = {C(): 1, C(): 2, C(): 3}

print(dict_a)
print(dict_b)
print(dict_c)

Production

{<__main__.A object at 0x7f9672f04850>: 1, <__main__.A object at 0x7f9672f04910>: 3, <__main__.A object at 0x7f9672f048d0>: 2}
{<__main__.B object at 0x7f9672f04990>: 2, <__main__.B object at 0x7f9672f04950>: 1, <__main__.B object at 0x7f9672f049d0>: 3}
{<__main__.C object at 0x7f9672f04a10>: 3}
chèque
la source