Choosing the right data structure
Pick a set or dict for fast membership and lookups instead of scanning a list.
Why membership cost decides your data structure
Reach for the wrong container and a fast function turns slow without a single line looking "wrong." The trap is x in collection. It reads the same for a list, a set, and a dict, but it does very different amounts of work. On a list, Python compares x against elements one at a time until it finds a match or runs out. On a million-element list that is up to a million comparisons for one lookup. Do that inside a loop and you have an O(n²) function that crawls on real data. Interviewers hand you exactly this shape and watch whether you notice.
The mental model
A list is a dynamic array: values laid out in order, great for indexing and iteration, but membership means scanning. A set (and a dict) is a hash table: each element runs through a hash function that computes where it lives, so x in a_set jumps almost straight to the right slot instead of walking everything.
x in a_list # O(n): walk the list until found or exhausted
x in a_set # O(1) average: hash x, look in one slot
x in a_dict # O(1) average: same hashing, keyed lookup
O(n) means cost grows with size; O(1) means it stays flat whether the set holds ten items or ten million. That is the instinct to build: when you repeatedly ask "have I seen this?", reach for a set.
| Operation | list | set | dict |
|---|---|---|---|
| x in c | O(n): scans until found | O(1) average | O(1) average, over keys |
| c[i] by position | O(1) | not supported | not supported |
| c[key] by key | not supported | not supported | O(1) average |
| Add one item | O(1) amortised (append) | O(1) average (add) | O(1) average |
| Keeps insertion order | yes | no | yes, since Python 3.7 |
| Accepts unhashable items | yes | no | values yes, keys no |
The classic upgrade
The demo below turns a list into a set and compares lengths. set(nums) drops duplicates, so if the set is shorter than the list, something repeated. That length comparison is the whole idea behind has_duplicates. When you need the scan itself, grow a seen set as you go:
seen = set()
for x in nums:
if x in seen: # O(1) check, not a rescan
... # x is a repeat, handle it here
seen.add(x)
That loop is O(n): one pass, each check flat. The list version, if x in seen against a growing list, would be O(n²).
Pitfalls
set(nums)throws away order. It can tell you THAT a value repeated, but not WHICH one repeated first. Forfirst_repeatedyou must scan left to right and test a growingseenset, returning the firstxthat is already inside it.
- Set and dict elements must be hashable, which in practice means immutable.
{[1, 2]}raisesTypeError: unhashable type: 'list'. Numbers, strings, and tuples are fine; lists, dicts, and sets are not.
Sort each job by the container that fits it better.
Interview nuance: O(1) membership is average case, not a guarantee. A hash table is fast because elements scatter across many slots, but adversarial or unlucky inputs can collide into one slot and degrade a single lookup toward O(n). You also trade memory for that speed. So the honest answer to "why not always use a set?" is that sets cost extra memory, accept only hashable values, and keep no order.
nums = [3, 1, 4, 1, 5, 9, 2, 6]
distinct = set(nums)
print(1 in distinct) # True , O(1) membership
print(len(distinct) != len(nums)) # True , there was a duplicateApply
Your turn
The task this lesson builds to.
Implement has_duplicates(nums): return True if any value appears more than once in nums,
otherwise False. Use a set so it stays fast.
[1, 2, 2] returns True; [1, 2, 3] returns False.
3 hints and 4 automated checks are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, so it survives past today.
Implement first_repeated(nums): return the first value that appears a second time as you scan
left to right, or None if every value is unique. Track what you've seen with a set.
[1, 2, 3, 2, 1] returns 2 (2 repeats before 1 does).
3 hints and 4 automated checks are waiting in the workspace.