Back to Stream: Performance & Pitfalls
Q3Question 3 of 5

Why is using a HashSet for the lookup list faster than filter(list::contains) for intersection queries?

Key Concepts
  • ArrayList.contains scans linearly at O(m) per call; filtering n elements costs O(n*m) total
  • HashSet.contains does a hash lookup at O(1) average per call; filtering n elements costs O(n) total
  • Build the HashSet once outside the stream at O(m) cost, then use set::contains inside the filter
  • For n=100K and m=10K, set lookup is roughly 10,000x faster than list scanning
  • HashSet depends on correct hashCode and equals on the element type
  • This is the canonical optimization for membership and intersection queries in streams

Definition

`list::contains` is O(n*m) for intersection; `set::contains` is O(n+m) because HashSet lookup is O(1) average.
  • ArrayList.contains scans the list linearly: O(m) per call.
  • Filtering n elements against a list: O(n*m) total.
  • HashSet.contains computes hash and probes one bucket: O(1) average per call.
  • Filtering n elements against a set: O(n) total (after O(m) set build).
  • Build the set once outside the stream; use set::contains inside filter.

Checking a name against a plain list means reading the whole list each time. Checking against a HashSet is like an index lookup: instant. At National Bank of India, converting a block-list from a List to a HashSet made the intersection filter orders of magnitude faster.

Understanding the Concepts

Why is list contains slow?

ArrayList.contains walks the list element by element, comparing each with equals. For a list of m elements, each contains call costs O(m). In an intersection where you call contains for each of n outer elements, the total is O(n*m).

Why is set contains fast?

HashSet.contains computes the hash code, jumps to the correct bucket, and compares within the bucket. Average cost: O(1). For n outer elements, the total filter cost is O(n).

How do you demonstrate the fix?

JAVA
package com.bank.hr;

import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class SetVsListContains {

    public static void main(String[] args) {
        List<String> allAccounts = IntStream.rangeClosed(1, 100_000)
            .mapToObj(i -> "SAV" + i)
            .toList();

        List<String> blockedIds = IntStream.rangeClosed(1, 10_000)
            .mapToObj(i -> "SAV" + (i * 3))  // every 3rd account
            .toList();

        // SLOW: O(n*m) = 100K * 10K = 1 billion comparisons
        long start = System.nanoTime();
        long slowCount = allAccounts.stream()
            .filter(id -> !blockedIds.contains(id))
            .count();
        long slowTime = System.nanoTime() - start;

        // FAST: O(n+m) = 110K operations
        Set<String> blockedSet = new HashSet<>(blockedIds);
        start = System.nanoTime();
        long fastCount = allAccounts.stream()
            .filter(id -> !blockedSet.contains(id))
            .count();
        long fastTime = System.nanoTime() - start;

        System.out.printf("List: %,d ms, count=%d%n", slowTime / 1_000_000, slowCount);
        System.out.printf("Set:  %,d ms, count=%d%n", fastTime / 1_000_000, fastCount);
    }
}
TEXT
List: 4,200 ms, count=66667
Set:  12 ms, count=66667

What is the complexity comparison?

| Approach | Per-call cost | Total (n outer, m lookup) |
|---|---|---|
| list::contains | O(m) linear scan | O(n * m) |
| set::contains | O(1) hash lookup | O(n + m) |

What are common mistakes?

Mistake: Not building the set outside the stream.

JAVA
// WRONG: building a new set per element (still O(n*m))
allAccounts.stream()
    .filter(id -> !new HashSet<>(blockedIds).contains(id))  // set built n times!
    .count();

// CORRECT: build once outside
Set<String> blockedSet = new HashSet<>(blockedIds);
allAccounts.stream()
    .filter(id -> !blockedSet.contains(id))
    .count();

What about custom objects?

HashSet relies on correct hashCode and equals. If those are wrong, the set degrades toward linear probing and the benefit evaporates.

JAVA
// Records auto-generate correct hashCode and equals
record AccountId(String branch, int number) {}
Set<AccountId> blockedAccounts = new HashSet<>(blockedList);

What about using a Map for lookup?

When you need to look up a related value (not just membership), use a Map instead of a Set.

JAVA
Map<String, Employee> employeeMap = staff.stream()
    .collect(Collectors.toMap(Employee::id, e -> e));

// O(1) lookup by id
Employee emp = employeeMap.get("SAV2001");

What should a senior engineer know?

Recognizing the hidden O(n*m) cost of list::contains inside a filter and converting to set::contains is the canonical first optimization for membership and intersection queries. The one-time cost of building the set is O(m), which is dwarfed by the savings for any non-trivial n.

What are common interview follow-up questions?

  • "What is the complexity of the list approach?" O(n*m).
  • "What is the complexity of the set approach?" O(n+m).
  • "What if hashCode is wrong?" The set degrades toward O(m) per lookup.
  • "What about LinkedHashSet?" Same O(1) lookup, but preserves insertion order.

Visual Overview

DIAGRAM
  filter(list::contains):            filter(set::contains):
  ┌──────────────────────────┐      ┌──────────────────────────┐
  │  For each of n elements: │      │  Build set: O(m)         │
  │    scan m elements       │      │  For each of n elements: │
  │    O(m) per call         │      │    hash lookup: O(1)     │
  │  Total: O(n * m)         │      │  Total: O(n + m)         │
  │                          │      │                          │
  │  100K * 10K = 1 BILLION  │      │  100K + 10K = 110K       │
  └──────────────────────────┘      └──────────────────────────┘

Key Takeaway

The difference is the cost of the membership check. ArrayList.contains scans linearly at O(m) per call, so filtering n elements against a list costs O(n*m). HashSet.contains computes a hash and probes a single bucket at O(1) average, so filtering against a set costs O(n+m) total including the one-time set construction.

At the National Bank of India, converting a block-list from a List to a HashSet made an intersection filter go from 4 seconds to 12 milliseconds on real data. The one-time cost of building the set is O(m), dwarfed by the savings for any non-trivial n.

The HashSet depends on correct hashCode and equals for the element type. If those are wrong, the set degrades toward linear probing and the benefit evaporates. For any membership or intersection query over sizable data, building a HashSet for the lookup side is the first optimization to reach for.

Remember one sentence: filter(set::contains) is O(n+m) versus filter(list::contains) at O(n*m) because HashSet lookup is O(1) average.

---

Share
Use ← → arrow keys to navigate