Python Arena
Step 4 of 5

Hashing your own keys

Numbers, strings and tuples work as dict keys and set items. Lists don't:

places = {}
places[[2, 3]] = "home"
Python stops with
Traceback (most recent call last):
  File "main.py", line 2, in <module>
    places[[2, 3]] = "home"
    ~~~~~~^^^^^^^^
TypeError: cannot use 'list' as a dict key (unhashable type: 'list')

Why a list can't be a key

A table finds a key by its hash, worked out when the key goes in. A list can change after that: append to it, and its hash, if it had one, would change too. Its pair would then sit in the bucket of the old hash, where no lookup ever looks again. So among Python's own types, only those whose values can't change, the immutable ones, are hashable: numbers, strings and tuples. A tuple can't change, so (2, 3) works as a key, and the usual way to use a list as a key is to make a tuple of it first: places[tuple(position)] = "home". A tuple is hashable only if everything in it is: ([2], 3) isn't.

Keys of your own

Objects of your own classes can be keys, but by default both == and the hash go by identity: two Point(1, 2) objects are two different keys. Once a class has __eq__, Python takes its default hash away, and putting a point in a set stops with TypeError: cannot use 'Point' as a set element (unhashable type: 'Point'). You write __hash__ too, and the two methods must agree:

Objects that are equal must have equal hashes. A table looks for a key only in the bucket of its hash, so if two equal points had different hashes, a lookup for one would never find the other. Objects that aren't equal may share a hash; that's just a collision.

The easy way to keep the rule is to hash a tuple of exactly the fields that __eq__ compares. __hash__ returns a whole number, and the table works out the bucket from it:

class Point:
    def __init__(self, x, y):
        self.x = x
        self.y = y

    def __eq__(self, other):
        return isinstance(other, Point) and self.x == other.x and self.y == other.y

    def __hash__(self):
        return hash((self.x, self.y))

    def __repr__(self):
        return f"Point({self.x}, {self.y})"


p = Point(1, 2)
visited = {p}
visited.add(Point(1, 2))
print(len(visited), Point(1, 2) in visited, Point(2, 1) in visited)

p.x = 5
print(visited)
print(p in visited, Point(1, 2) in visited)
Output
1 True False
{Point(5, 2)}
False False

The second Point(1, 2) is equal to the first and has the same hash, so the set keeps one point. Then the program changes the point while it's in the set. The set still holds it, as the print shows, but nothing finds it any more: p in visited looks in the bucket of the new hash, and Point(1, 2) finds the right bucket but no equal point in it. So a key must not change while it's in a table: hash only fields that never change after __init__, or use tuples.

A legal hash that's terrible

return 0 keeps the rule too: equal objects get equal hashes. But then every object collides with every other, and the table is one long chain. This class counts how often the set calls its __eq__:

class Ticket:
    comparisons = 0

    def __init__(self, number):
        self.number = number

    def __eq__(self, other):
        Ticket.comparisons += 1
        return self.number == other.number

    def __hash__(self):
        return 0


tickets = set()
for i in range(1000):
    tickets.add(Ticket(i))
print(len(tickets), Ticket.comparisons)
Output
1000 613645

Each new ticket is compared with the tickets already there, so building a set of n tickets is O(n²), and each lookup O(n). With return hash(self.number) instead, the program prints 0 comparisons: the set calls __eq__ only for keys whose hashes are equal, and these numbers all have different hashes.

Your task

Challenge 1 of 3

Version is the version number of a program, such as 3.14: a major number and a minor number. Give it __eq__ and __hash__, so that versions work in sets and as dict keys. Two versions are equal when both the major and the minor numbers are equal, and a version is never equal to something that isn't a version. The hidden test puts 50,000 different versions in a set: a hash that's the same for every version makes that O(n²). This program:

versions = {Version(3, 14), Version(2, 7), Version(3, 14)}
print(len(versions), Version(2, 7) in versions, Version(3, 13) in versions)
print(Version(3, 14) == Version(3, 14), Version(3, 14) == (3, 14))

prints:

2 True False
True False

Challenge 2

Write group_anagrams(words). Two words are anagrams when they have the same letters in another order, such as "listen" and "silent". Uppercase and lowercase letters are different letters: "Night" and "thing" aren't anagrams, but "night" and "thing" are. Return a list of groups, each a list of words that are anagrams of each other. Keep the order of words: the group whose first word comes earliest in words comes first, and inside each group the words stay in the order they had.

Use a dict: the sorted letters of a word make a good key, but sorted gives a list, which can't be a key. The hidden test groups 60,000 strings of digits into about 28,000 groups, so comparing each word with every group so far is too slow.

For example, this call:

print(group_anagrams(["stop", "melon", "pots", "lemon", "tops", "kiwi"]))

prints:

[['stop', 'pots', 'tops'], ['melon', 'lemon'], ['kiwi']]

Challenge 3

Two Student objects are the same student when their ids are equal, even when the name was typed in another way. __eq__ says so, but a set of students still counts the same student twice. Find the problem and fix it.

For example, this program:

signups = [Student(1001, "Ada Lovelace"), Student(1002, "Ben Ng"), Student(1001, "ada lovelace")]
print(len(set(signups)))

should print:

2