Set-based problems
Problems solved with sets and hashing: union and intersection, membership tests, the longest consecutive sequence, two sum with a hash and counting distinct values in every window.
Learning goals
- Compute union, intersection and difference with sets
- Solve longest consecutive sequence in O(n) by only starting at run starts
- Maintain a distinct count over a sliding window
Prerequisites
Set union/intersection
Sets model the mathematical operations directly. **Union** a.addAll(b) keeps everything; **intersection** a.retainAll(b) keeps what b also has; **difference** a.removeAll(b) drops what b has; the **symmetric difference** is the union minus the intersection. With hash sets each membership test is O(1), so every operation is O(|a| + |b|).
Choose an operation in the animation. A LinkedHashSet keeps the result in insertion order, which makes the output predictable: for A = {1, 2, 3, 4} and B = {3, 4, 5, 6} the union is [1, 2, 3, 4, 5, 6], the intersection [3, 4], the difference [1, 2] and the symmetric difference [1, 2, 5, 6].
1static void setOps(List<Integer> listA, List<Integer> listB) {2Set<Integer> a = new LinkedHashSet<>(listA), b = new LinkedHashSet<>(listB);3Set<Integer> union = new LinkedHashSet<>(a); union.addAll(b);4Set<Integer> inter = new LinkedHashSet<>(a); inter.retainAll(b);5Set<Integer> diff = new LinkedHashSet<>(a); diff.removeAll(b);6Set<Integer> sym = new LinkedHashSet<>(union); sym.removeAll(inter);7System.out.println(union + " " + inter + " " + diff + " " + sym);8}
Set union, intersection and difference
Interactive · edit the inputA
B
A ∪ B
Both sets use hashing, so each membership test below costs O(1); the whole operation is O(|A| + |B|). A LinkedHashSet keeps insertion order so the result is predictable.
1Set<Integer> a = new LinkedHashSet<>(listA), b = new LinkedHashSet<>(listB);2Set<Integer> union = new LinkedHashSet<>(a); union.addAll(b); // option 03Set<Integer> inter = new LinkedHashSet<>(a); inter.retainAll(b); // option 1: keep what b also has4Set<Integer> diff = new LinkedHashSet<>(a); diff.removeAll(b); // option 2: drop what b has5Set<Integer> sym = new LinkedHashSet<>(union); sym.removeAll(inter); // option 3: in exactly one set
Live state
|A|
4
|B|
4
Execution trace (dry run table)Show 10 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L1 | |A|=4 |B|=4 | Both sets use hashing, so each membership test below costs O(1); the whole operation is O(|A| + |B|). A LinkedHashSet keeps insertion order so the result is predictable. |
| 2 | L2 | value=1 in B=false | 1: every element of A belongs to the union, so it is added to the result. |
| 3 | L2 | value=2 in B=false | 2: every element of A belongs to the union, so it is added to the result. |
| 4 | L2 | value=3 in B=true | 3: every element of A belongs to the union, so it is added to the result. |
| 5 | L2 | value=4 in B=true | 4: every element of A belongs to the union, so it is added to the result. |
| 6 | L2 | value=3 in A=true | 3: already in the result, so it is skipped. |
| 7 | L2 | value=4 in A=true | 4: already in the result, so it is skipped. |
| 8 | L2 | value=5 in A=false | 5: addAll adds B's elements; a duplicate is ignored, so it is added to the result. |
| 9 | L2 | value=6 in A=false | 6: addAll adds B's elements; a duplicate is ignored, so it is added to the result. |
| 10 | L2 | size=6 return=[1, 2, 3, 4, 5, 6] | Result: [1, 2, 3, 4, 5, 6]. |
Membership
The basic set question is *is x in the collection?* A list answers it in O(n); a sorted array in O(log n) with binary search; a hash set in expected O(1). When a program tests membership many times, building a set once (O(n)) and querying it pays for itself quickly.
Try the first preset of the hash table animation and add ?22 and ?99: a hit scans part of one chain, a miss scans one whole chain and nothing else.
| Structure | Build | contains(x) |
|---|---|---|
| Unsorted list | O(n) | O(n) |
| Sorted array | O(n log n) | O(log n) |
| HashSet | O(n) | O(1) expected |
| TreeSet | O(n log n) | O(log n) |
Hash table with separate chaining
Interactive · edit the inputbucket 0
bucket 1
bucket 2
bucket 3
bucket 4
5 empty buckets. A hash function maps every key to a bucket index; keys that land in the same bucket share a chain.
1class ChainedHashSet {2private final int m, variant, a; // m buckets3private final LinkedList<Integer>[] table; // one chain per bucket4int hash(int key) { // the option picks one variant5switch (variant) {6case 0: return Math.floorMod(key, m);7case 1: return (int) (m * ((key * 0.6180339887) % 1.0));8case 2: return 0; // worst case: every key collides9default: return ((a * key + 7) % 101) % m; // universal-style family10}11}12boolean add(int key) {13LinkedList<Integer> chain = table[hash(key)];14if (chain.contains(key)) return false; // walk the chain: duplicate15chain.add(key); return true; // append to the chain16}17boolean contains(int key) { return table[hash(key)].contains(key); }18boolean remove(int key) { return table[hash(key)].remove((Integer) key); }19}
Live state
size
0
load factor
0.00
longest chain
0
Execution trace (dry run table)Show 30 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L3 | size=0 load factor=0.00 longest chain=0 | 5 empty buckets. A hash function maps every key to a bucket index; keys that land in the same bucket share a chain. |
| 2 | L6 | size=0 load factor=0.00 longest chain=0 op=add(12) hash=2 | add(12): hash = 12 % 5 = 2, so go to bucket 2. |
| 3 | L15 | size=1 load factor=0.20 longest chain=1 op=add(12) return=true | 12 is not in the chain (the bucket was empty): append it to bucket 2. |
| 4 | L6 | size=1 load factor=0.20 longest chain=1 op=add(7) hash=2 | add(7): hash = 7 % 5 = 2, so go to bucket 2. |
| 5 | L14 | size=1 load factor=0.20 longest chain=1 op=add(7) hash=2 | 12 ≠ 7: keep walking the chain. |
| 6 | L15 | size=2 load factor=0.40 longest chain=2 op=add(7) return=true | 7 is not in the chain (1 comparison): append it to bucket 2. |
| 7 | L6 | size=2 load factor=0.40 longest chain=2 op=add(22) hash=2 | add(22): hash = 22 % 5 = 2, so go to bucket 2. |
| 8 | L14 | size=2 load factor=0.40 longest chain=2 op=add(22) hash=2 | 12 ≠ 22: keep walking the chain. |
| 9 | L14 | size=2 load factor=0.40 longest chain=2 op=add(22) hash=2 | 7 ≠ 22: keep walking the chain. |
| 10 | L15 | size=3 load factor=0.60 longest chain=3 op=add(22) return=true | 22 is not in the chain (2 comparisons): append it to bucket 2. |
| 11 | L6 | size=3 load factor=0.60 longest chain=3 op=add(17) hash=2 | add(17): hash = 17 % 5 = 2, so go to bucket 2. |
| 12 | L14 | size=3 load factor=0.60 longest chain=3 op=add(17) hash=2 | 12 ≠ 17: keep walking the chain. |
| 13 | L14 | size=3 load factor=0.60 longest chain=3 op=add(17) hash=2 | 7 ≠ 17: keep walking the chain. |
| 14 | L14 | size=3 load factor=0.60 longest chain=3 op=add(17) hash=2 | 22 ≠ 17: keep walking the chain. |
| 15 | L15 | size=4 load factor=0.80 longest chain=4 op=add(17) return=true | 17 is not in the chain (3 comparisons): append it to bucket 2. |
| 16 | L6 | size=4 load factor=0.80 longest chain=4 op=contains(22) hash=2 | contains(22): hash = 22 % 5 = 2, so go to bucket 2. |
| 17 | L17 | size=4 load factor=0.80 longest chain=4 op=contains(22) hash=2 | 12 ≠ 22: keep walking the chain. |
| 18 | L17 | size=4 load factor=0.80 longest chain=4 op=contains(22) hash=2 | 7 ≠ 22: keep walking the chain. |
| 19 | L17 | size=4 load factor=0.80 longest chain=4 op=contains(22) hash=2 | 22 equals 22: found after 3 comparisons. |
| 20 | L17 | size=4 load factor=0.80 longest chain=4 op=contains(22) return=true | contains(22) is true. |
| 21 | L6 | size=4 load factor=0.80 longest chain=4 op=remove(7) hash=2 | remove(7): hash = 7 % 5 = 2, so go to bucket 2. |
| 22 | L18 | size=4 load factor=0.80 longest chain=4 op=remove(7) hash=2 | 12 ≠ 7: keep walking the chain. |
| 23 | L18 | size=4 load factor=0.80 longest chain=4 op=remove(7) hash=2 | 7 equals 7: found after 2 comparisons. |
| 24 | L18 | size=3 load factor=0.60 longest chain=3 op=remove(7) return=true | 7 unlinked from bucket 2. |
| 25 | L6 | size=3 load factor=0.60 longest chain=3 op=add(32) hash=2 | add(32): hash = 32 % 5 = 2, so go to bucket 2. |
| 26 | L14 | size=3 load factor=0.60 longest chain=3 op=add(32) hash=2 | 12 ≠ 32: keep walking the chain. |
| 27 | L14 | size=3 load factor=0.60 longest chain=3 op=add(32) hash=2 | 22 ≠ 32: keep walking the chain. |
| 28 | L14 | size=3 load factor=0.60 longest chain=3 op=add(32) hash=2 | 17 ≠ 32: keep walking the chain. |
| 29 | L15 | size=4 load factor=0.80 longest chain=4 op=add(32) return=true | 32 is not in the chain (3 comparisons): append it to bucket 2. |
| 30 | L19 | size=4 load factor=0.80 longest chain=4 return=[[],[],[12,22,17,32],[],[]] | 4 keys in 5 buckets; the longest chain has 4. Lookup cost is the chain length, so the spread of the hash function decides the speed. |
Longest consecutive sequence
Find the length of the longest run of consecutive integers (such as 1, 2, 3, 4) in an unsorted array, in O(n). Put all values in a set. A value x *starts* a run only if x - 1 is not in the set. For each run start, walk x+1, x+2, … while they are in the set.
The “start of a run” test is what makes it linear: each value is visited as a run start at most once, and walked over at most once as part of a run, so the total is O(n) lookups. Without the test the same run would be re-walked from every element and the cost would be O(n²).
1static int longestConsecutive(int[] nums) {2Set<Integer> set = new HashSet<>();3for (int x : nums) set.add(x);4int best = 0;5for (int x : set) {6if (set.contains(x - 1)) continue; // not the start of a run7int end = x;8while (set.contains(end + 1)) end++; // walk the run9best = Math.max(best, end - x + 1);10}11return best;12}
Best
O(n)
Average
O(n)
Worst
O(n)
Space
O(n)
Longest consecutive sequence with a HashSet
Interactive · edit the inputint[] arr · length 6
set (iteration order of a HashSet is unspecified; the answer does not depend on it)
The set has 6 distinct values. Sorting would cost O(n log n); instead only the first value of each run starts a walk, so every value is visited O(1) times.
1static int longestConsecutive(int[] nums) {2Set<Integer> set = new HashSet<>();3for (int x : nums) set.add(x);4int best = 0;5for (int x : set) {6if (set.contains(x - 1)) continue; // not the start of a run7int end = x;8while (set.contains(end + 1)) end++; // walk the run9best = Math.max(best, end - x + 1);10}11return best;12}
Live state
n
6
distinct
6
Execution trace (dry run table)Show 14 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L3 | n=6 distinct=6 | The set has 6 distinct values. Sorting would cost O(n log n); instead only the first value of each run starts a walk, so every value is visited O(1) times. |
| 2 | L6 | x=100 best=0 | 99 is not in the set: 100 starts a run. |
| 3 | L9 | x=100 end=100 length=1 best=1 | 101 is not in the set: the run ends with length 1. best = 1. |
| 4 | L6 | x=4 best=1 | 3 is in the set, so 4 is in the middle of a run: skip it. |
| 5 | L6 | x=200 best=1 | 199 is not in the set: 200 starts a run. |
| 6 | L9 | x=200 end=200 length=1 best=1 | 201 is not in the set: the run ends with length 1. best = 1. |
| 7 | L6 | x=1 best=1 | 0 is not in the set: 1 starts a run. |
| 8 | L8 | x=1 end=2 length=2 best=1 | 2 is in the set: the run 1..2 now has 2 values. |
| 9 | L8 | x=1 end=3 length=3 best=1 | 3 is in the set: the run 1..3 now has 3 values. |
| 10 | L8 | x=1 end=4 length=4 best=1 | 4 is in the set: the run 1..4 now has 4 values. |
| 11 | L9 | x=1 end=4 length=4 best=4 | 5 is not in the set: the run ends with length 4. best = 4. |
| 12 | L6 | x=3 best=4 | 2 is in the set, so 3 is in the middle of a run: skip it. |
| 13 | L6 | x=2 best=4 | 1 is in the set, so 2 is in the middle of a run: skip it. |
| 14 | L11 | return=4 | Longest consecutive run: 4 (1..4). 12 set lookups in total. |
Two-sum hash
Two Sum asks for two indices whose values add up to a target. Scan once and keep a map from value to index of what you have seen. For each x, check whether target - x is already in the map: if it is, the pair is complete; if not, record x. This replaces the O(n²) nested loops by one O(n) pass with O(n) extra space.
Checking before inserting handles duplicates such as [3, 3] with target 6 and never pairs an element with itself.
1static int[] twoSum(int[] nums, int target) {2Map<Integer, Integer> seen = new HashMap<>(); // value -> index3for (int i = 0; i < nums.length; i++) {4Integer j = seen.get(target - nums[i]); // beware: target - nums[i] can overflow for extreme values5if (j != null) return new int[]{j, i};6seen.put(nums[i], i);7}8return new int[0];9}
Optimised: remember what you have seen
Interactive · edit the inputint[] arr · length 6
seen (value:index)
A hash map answers “have I seen this value?” in O(1) on average, so one pass is enough.
1public static int[] twoSum(int[] nums, int target) {2Map<Integer, Integer> seen = new HashMap<>(); // value -> index3for (int i = 0; i < nums.length; i++) {4int need = target - nums[i];5if (seen.containsKey(need)) {6return new int[] { seen.get(need), i };7}8seen.put(nums[i], i);9}10return new int[] { -1, -1 };11}
Live state
target
13
Execution trace (dry run table)Show 7 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L2 | target=13 | A hash map answers “have I seen this value?” in O(1) on average, so one pass is enough. |
| 2 | L5 | i=0 nums[i]=7 need=6 | 13 - 7 = 6, which has not appeared yet. |
| 3 | L8 | i=0 size=1 | Store 7 → 0 for future lookups. |
| 4 | L5 | i=1 nums[i]=11 need=2 | 13 - 11 = 2, which has not appeared yet. |
| 5 | L8 | i=1 size=2 | Store 11 → 1 for future lookups. |
| 6 | L5 | i=2 nums[i]=2 need=11 | 11 is in the map: it was stored at index 1, so that value plus 2 makes 13. |
| 7 | L6 | return=[1, 2] | return [1, 2] after 3 lookups: O(n) time and O(n) extra memory. Brute force needed O(1) memory but O(n²) time. |
Distinct window counts
How many distinct values are in each window of size k? Keep a map of counts for the current window. When a value enters, increment its count; when a value leaves, decrement its count and **remove the key when it reaches 0**; the number of distinct values is the map's size.
Forgetting to remove zero counts is the classic bug: the map would keep keys for values that are no longer in the window and size() would overcount. Every element enters and leaves once, so the scan is O(n).
1static int[] distinctInWindows(int[] a, int k) {2int[] out = new int[a.length - k + 1];3Map<Integer, Integer> count = new HashMap<>();4for (int i = 0; i < a.length; i++) {5count.merge(a[i], 1, Integer::sum); // value enters the window6if (i >= k) {7int old = a[i - k]; // value leaves the window8if (count.merge(old, -1, Integer::sum) == 0) count.remove(old);9}10if (i >= k - 1) out[i - k + 1] = count.size(); // size() = distinct values11}12return out;13}
Best
O(n)
Average
O(n)
Worst
O(n)
Space
O(k)
Distinct values in every window of size k
Interactive · edit the inputint[] arr · length 7
count
Slide a window of 4 over the array. The map holds the count of each value inside it; its size is the number of distinct values.
1static int[] distinctInWindows(int[] a, int k) {2int[] out = new int[a.length - k + 1];3Map<Integer, Integer> count = new HashMap<>();4for (int i = 0; i < a.length; i++) {5count.merge(a[i], 1, Integer::sum); // value enters the window6if (i >= k) {7int old = a[i - k]; // value leaves the window8if (count.merge(old, -1, Integer::sum) == 0) count.remove(old);9}10if (i >= k - 1) out[i - k + 1] = count.size(); // size() = distinct values11}12return out;13}
Live state
n
7
k
4
Execution trace (dry run table)Show 16 stepsHide
| Step | Line | State | What happens |
|---|---|---|---|
| 1 | L3 | n=7 k=4 | Slide a window of 4 over the array. The map holds the count of each value inside it; its size is the number of distinct values. |
| 2 | L5 | i=0 enters=1 distinct=1 | 1 enters: its count becomes 1. |
| 3 | L5 | i=1 enters=2 distinct=2 | 2 enters: its count becomes 1. |
| 4 | L5 | i=2 enters=1 distinct=2 | 1 enters: its count becomes 2. |
| 5 | L5 | i=3 enters=3 distinct=3 | 3 enters: its count becomes 1. |
| 6 | L10 | window=[0..3] distinct=3 | Window [0..3] holds 3 distinct values. |
| 7 | L5 | i=4 enters=4 distinct=4 | 4 enters: its count becomes 1. |
| 8 | L8 | i=4 leaves=1 distinct=4 | 1 leaves: its count drops to 1, so it is still inside the window. |
| 9 | L10 | window=[1..4] distinct=4 | Window [1..4] holds 4 distinct values. |
| 10 | L5 | i=5 enters=2 distinct=4 | 2 enters: its count becomes 2. |
| 11 | L8 | i=5 leaves=2 distinct=4 | 2 leaves: its count drops to 1, so it is still inside the window. |
| 12 | L10 | window=[2..5] distinct=4 | Window [2..5] holds 4 distinct values. |
| 13 | L5 | i=6 enters=3 distinct=4 | 3 enters: its count becomes 2. |
| 14 | L8 | i=6 leaves=1 distinct=3 | 1 leaves and its count reaches 0: remove the key, or size() would overcount. |
| 15 | L10 | window=[3..6] distinct=3 | Window [3..6] holds 3 distinct values. |
| 16 | L12 | return=[3, 4, 4, 3] | Distinct counts per window: [3, 4, 4, 3]. O(n) time: each value enters and leaves once. |
Practice ladder
Now try it yourself
Easy
- Intersection of Two Arrays
- Two Sum
Medium
- Longest Consecutive Sequence
- Find All Anagrams in a String
Advanced
- Longest Substring with At Most K Distinct Characters
- Substring with Concatenation of All Words
Key takeaways
- Hash sets give O(1) membership, so set operations are O(|a| + |b|).
- Only start a walk at a run start: that makes longest consecutive sequence O(n).
- A count map over a window needs keys removed when their count reaches 0.