A hash table as a counter
In short: A map used as a counter (dict or collections.Counter in Python) counts how often each element occurs in a single pass: O(n) instead of O(n²) with searching a list.
Problem
The best-selling products of the day
The shop dashboard shows the three best-selling products of the day. The log is a list of orders, one per line. The code keeps two lists, the names and the counts, and for every order it looks for the product from the start of the names:
log = log_of(1000)names = []counts = []comparisons = 0for item in log: i = 0 while i < len(names): comparisons += 1 if names[i] == item: break i += 1 if i == len(names): names.append(item) counts.append(1) else: counts[i] += 1print(f"orders: {len(log)}")print(f"products: {len(names)}")print(f"comparisons: {comparisons}")orders: 1000products: 251comparisons: 125255A thousand orders and 251 different products: 125,255 comparisons. The catalog grows with the shop, and so does the list every order walks through. On a big day with 100,000 orders the dashboard takes minutes to load.
Where the log comes from
The log follows a formula, so that the programs in all four languages and the comparison in the browser below build exactly the same one. The catalog has n / 4 + 1 products:
def log_of(n): catalog = n // 4 + 1 return [f"SKU-{(i * 7919) % catalog}" for i in range(n)]The idea
The map counts
In the lesson “dict and set” you counted orders with a map. Here it becomes a technique: a hash table as a counter (a frequency map). The key is what you count, the value is how many times you have seen it.
- One pass over the data. For every element: find its counter (or 0) and add 1.
- The lookup is in a hash table: O(1) on average, however many products there are. O(n) in total instead of O(n · m).
- After that you answer the questions from the counts, not from the data: which one is most common, which one appears once, whether two things have the same counts.
The memory is O(m): one pair for every different product.
See it
The counter fills up
from collections import defaultdict
log = [ "mouse", "headset", "mouse", "monitor", "keyboard", "mouse", "headset", "monitor", "mouse", "headset",]counts = defaultdict(int)for item in log: counts[item] += 1top = sorted(counts.items(), key=lambda e: e[1], reverse=True)for item, n in top[:3]: print(item, n)mouse 4headset 3monitor 2counts = defaultdict(int)for item in log: counts[item] += 1top = sorted(counts.items(), key=lambda e: e[1], reverse=True)for item, n in top[:3]: print(item, n)Step by step, in words
- An empty map: product → how many times it was sold. The log has 10 orders.
- Order 1: mouse. It is not in the map yet, so it gets a count of 1.
- Order 2: headset. It is not in the map yet, so it gets a count of 1.
- Order 3: mouse, 1 → 2. One lookup in the hash table, without walking the other products.
- Order 4: monitor. It is not in the map yet, so it gets a count of 1.
- Order 5: keyboard. It is not in the map yet, so it gets a count of 1.
- Order 6: mouse, 2 → 3. One lookup in the hash table, without walking the other products.
- Order 7: headset, 1 → 2. One lookup in the hash table, without walking the other products.
- Order 8: monitor, 1 → 2. One lookup in the hash table, without walking the other products.
- Order 9: mouse, 3 → 4. One lookup in the hash table, without walking the other products.
- Order 10: headset, 2 → 3. One lookup in the hash table, without walking the other products.
- 10 orders, 10 counter updates. Only the 4 distinct products get sorted by count, not the orders.
- The top three: mouse 4, headset 3, monitor 2.
defaultdict(int) creates a counter of 0 the first time a new key is read, so counts[item] += 1 also works for a new product. The top three come from sorting the pairs by count: only the different products are sorted, not the orders.
Try it
Fill in
The line that counts:
counts = defaultdict()defaultdict(int) calls int() for a new key, and int() is 0.See it
The tools of your language
Python has a ready-made counter: collections.Counter. It takes the list and counts by itself, gives 0 for a missing key, and most_common(k) returns the top k by count. Equal counts stay in the order of first appearance:
from collections import Counter
log = [ "mouse", "headset", "mouse", "monitor", "keyboard", "mouse", "headset", "monitor", "mouse", "headset",]counts = Counter(log)print(counts["mouse"], counts["tablet"])print(counts.most_common(3))4 0[('mouse', 4), ('headset', 3), ('monitor', 2)]defaultdict(int) is the more general tool: the same counter, but the value can also be a sum or a list.
See it
Measure: a list of pairs against a map
The same thousand orders with the map:
log = log_of(1000)counts = defaultdict(int)updates = 0for item in log: counts[item] += 1 updates += 1print(f"orders: {len(log)}")print(f"products: {len(counts)}")print(f"counter updates: {updates}")orders: 1000products: 251counter updates: 10001,000 updates instead of 125,255 comparisons. Run both in the browser. n is the number of orders, and the catalog grows with it:
- A list of product and count pairs
O(n²) - A map as a counter
O(n)
| Algorithm | n = 10 | n = 1,000 | n = 100,000 |
|---|---|---|---|
| A list of product and count pairs | 16 | 125,255 | ≈ 1,252,550,000 |
| A map as a counter | 10 | 1,000 | 100,000 |
counts = defaultdict(int)for item in log: counts[item] += 1top = sorted(counts.items(), key=lambda e: e[1], reverse=True)for item, n in top[:3]: print(item, n)More precisely, if you are curious
The top k without sorting everything. Sorting m different products is O(m log m). If there are millions of products and you need the top 10, you keep a heap with at most k pairs: every pair goes in, and when there are k + 1, the smallest goes out. That is O(m log k).
A key with several parts. You count sales by city and product: the key is the pair. In Python it is a tuple (city, item): it is immutable and has a hash.
The idea
The basket and the kit
The “home office” kit is a monitor, a keyboard, a mouse and two cables. The warehouse checks that the basket to be shipped is exactly the kit: nothing extra, nothing missing. The order does not matter, the counts do.
One counter: +1 for every product of the kit, −1 for every product in the basket. The basket matches if all counts end up at 0.
def matches(kit, basket): return Counter(kit) == Counter(basket)Counter compares with == by its counts, so the whole check is one line.The same check finds anagrams: “listen” and “silent” have the same letters with the same counts. The counter is over letters instead of products.
Try it
Predict the check
from collections import Counter
kit = ["monitor", "keyboard", "mouse", "cable", "cable"]basket1 = ["cable", "mouse", "monitor", "cable", "keyboard"]basket2 = ["monitor", "keyboard", "mouse", "mouse", "cable"]print(f"basket 1: {'complete kit' if matches(kit, basket1) else 'does not match'}")print(f"basket 2: {'complete kit' if matches(kit, basket2) else 'does not match'}")The first basket has the same five products in a different order: every count becomes 0. The second has two mice and one cable: the mouse stays at −1, the cable at +1, so it does not match, even though there are five products.
The idea
The first customer with a single order
Marketing sends a “come back” voucher to the customers who ordered only once. It wants the first one in time. The counter gives the counts, but not the order:
def first_single(customers): counts = Counter(customers) for c in customers: if counts[c] == 1: return c return Nonefrom collections import Counter
customers = [ "C-17", "C-4", "C-17", "C-9", "C-4", "C-21", "C-9", "C-30",]print(f"first customer with one order: {first_single(customers)}")first customer with one order: C-21Two passes: the first one counts, the second walks the log, in the order of the orders, and stops at the first one with a count of 1. O(n), with no nested loop.
More precisely, if you are curious
Why does the second pass walk the log and not the map? A dict keeps insertion order (promised since Python 3.7), so here walking counts would give the same answer. The log is still clearer: the order comes from it.
The idea
Two products for exactly €50
The customer has a voucher for exactly €50 and wants two products that use it up. The prices in the catalog are not sorted.
You walk through the prices and for each one you ask: have I already seen the price that tops this one up to 50? The map keeps the prices seen so far and their positions:
def voucher_pair(prices, voucher): seen = {} for i, price in enumerate(prices): j = seen.get(voucher - price) if j is not None: return j, i seen[price] = i return Noneprices = [27, 14, 33, 25, 8, 36, 19]a, b = voucher_pair(prices, 50)print(f"products {a} and {b}: €{prices[a]} + €{prices[b]}")products 1 and 5: €14 + €36One pass: O(n) time and O(n) memory. With two pointers it works without a map, with O(1) memory, but the prices must be sorted. If they are not, sorting costs O(n log n) and loses the position of every product in the catalog. So: sorted data, two pointers; unsorted data, a map.
Try it
Find the bug
Someone swapped two lines. Now for a €50 voucher the program offers one product twice:
prices = [27, 14, 33, 25, 8, 36, 19]a, b = voucher_pair(prices, 50)print(f"products {a} and {b}: €{prices[a]} + €{prices[b]}")products 3 and 3: €25 + €25Pick the line with the bug.
The price goes into the map before its pair is looked up. The €25 product looks for 50 − 25 = 25 and finds itself. The write must come after the lookup: then the map holds only the products before the current one, and the program finds €14 + €36.
Exercise
Who spends the most
Solve the task in your own editor.
Revenue per customer. The log has pairs of customer and amount: C-4 for €30, C-17 for €55, C-4 for €20, C-9 for €80, C-17 for €10 and C-4 for €45. Add up the amounts per customer with a map (the value is a sum, not a count) and print the two who spent the most:
C-4: €95C-9: €80More tasks
- Anagrams. Write a check whether two words are anagrams (“listen” and “silent”) by counting the letters with a map. How many operations does it take for words of length n?
- The first repeat. Find the first order whose customer has already ordered before. Do you need a counter, or is a set enough?
- Periods with equal counts. From prefix sums you know how to count periods with a given sum. Count the periods of consecutive days in which the number of returned orders equals the number of delivered ones.
Test
Check yourself
Related lessons
Section titled "Related lessons"- Builds on: Hash table · Priority queue · Two pointers · Prefix sums
- Tasks on this topic: Problem-solving techniques