List algorithms: duplicates, intersection, difference and partition
List algorithms for duplicates, frequency counts, intersections, differences and chunk partitioning, with duplicate-aware complexity trade-offs.
On this page
- Picture it first: coloured regions and shipment boxes
- Removing duplicates under the hood
- Counting duplicates and avoiding the frequency trap
- Finding all duplicates
- Intersection: set style and multiset style
- Differences and symmetric differences
- Partitioning a list into chunks of N
- Advanced: choosing the algorithm by semantics
- Edge cases you should say out loud
- How to say it in the interview
🧮 What are list algorithms?
List algorithms are the repeatable patterns you use once the list API is not enough by itself: remove duplicates, count repeated values, find overlap, subtract one list from another and split work into chunks. A list keeps order and duplicates, while most algorithms lean on a Set or Map as a helper. The interviewer is really asking whether you know when duplicates matter.
For this lesson, we'll focus on the algorithms that come up most in Java interviews and code reviews: deduplication, duplicate counting, finding all duplicates, intersection, difference, symmetric difference and chunk partitioning. We'll use Java 21 collections and streams, and we'll be explicit about whether an algorithm treats the input as a set or as a multiset. The difference is not academic: [A, A, B] and [A, B, B] contain the same distinct values, but they are not the same bag of values.
Below the line (out of scope)
- Everyday
contains,copy,equals,removeIfand random picks: Everyday List operations - Hash table internals and why
equalsplushashCodematter: How HashMap works - Set operations as their own topic: Set operations: union, intersection and difference
- Grouping patterns beyond lists: Grouping and partitioning
The 30-second answer
For duplicate removal, new ArrayList<>(new LinkedHashSet<>(list)) keeps first-seen order, while stream().distinct() expresses the same equality-based idea in a pipeline. For "unique by id", use a HashSet of keys and keep the first element whose key is new. Count duplicates with Collectors.groupingBy(..., counting()) or a Map.merge loop; do not call Collections.frequency for every element because that becomes O(n²). Find duplicates by checking !seen.add(x). For intersection and difference, convert the membership side to a HashSet before using retainAll or removeAll, or you risk O(n*m) scans. If duplicate counts matter, use frequency maps and decrement counts. To split into chunks, loop over subList ranges and copy each chunk, use Guava Lists.partition when a view is okay, or use an IntStream over chunk indexes. partitioningBy is a boolean grouping collector, not chunking.
What you’ll learn
- Remove duplicates with
LinkedHashSet,stream().distinct()and dedupe by key patterns - Count duplicates with
groupingBy(..., counting())orMap.merge, and avoidCollections.frequencyin loops - Find duplicate values using the boolean result of
Set.add - Compute intersections with
retainAllplus aHashSet, and handle multiset intersections with counts - Compute list differences, symmetric differences and multiset differences without losing duplicate semantics
- Partition lists into chunks with
subListcopies, GuavaLists.partitionor anIntStreamindex loop
Interview radar
Asked very often: Remove duplicates from a list while preserving order; find duplicates; find intersection and difference between two lists.
Senior rounds probe: Set semantics versus multiset semantics, O(n*m) traps in removeAll and retainAll, and whether subList is a view or a copy.
Production angle: A reconciliation job subtracts two lists with removeAll, accidentally removes every duplicate instance, and under-reports missing payments.
01Picture it first: coloured regions and shipment boxes
Think of two lists as two trays of labels. A set-style intersection asks "which labels appear in both trays at least once?" A multiset intersection asks "how many copies can I match from both trays?" Chunking is a separate move: packing a long row of labels into boxes of size 3 or 500 without changing the labels inside.
The diagram shows both ideas because they are the two mental models I use in interviews.
A Venn-style diagram shows left-only, intersection and right-only regions, and a second row shows a list split into chunks of size three
The important split is membership versus counts. If the problem says unique values, sets are natural. If it says orders, payments, seats or log lines, ask whether duplicates are meaningful before you choose the algorithm.
02Removing duplicates under the hood
The cleanest order-preserving dedupe is a LinkedHashSet. HashSet keeps uniqueness but does not promise iteration order; LinkedHashSet adds a linked order through entries, so iteration follows first insertion.
Try it yourself first
Remove duplicate product IDs from this import while keeping the first time each ID appeared.
List<String> importedIds = List.of("P-10", "P-20", "P-10", "P-30", "P-20");
List<String> uniqueInOrder = new ArrayList<>(new LinkedHashSet<>(importedIds));
System.out.println(uniqueInOrder); // [P-10, P-20, P-30]
List<String> viaStream = importedIds.stream()
.distinct()
.toList();
System.out.println(viaStream); // [P-10, P-20, P-30]
Both versions use equals and hashCode. The loop is O(n) expected time and O(u) space, where u is the number of distinct values. stream().distinct() is nice when you are already in a stream pipeline, but it returns an unmodifiable list with toList() on Java 16+, so use collect(Collectors.toCollection(ArrayList::new)) if you need to mutate the result.
Dedupe by key is the version production code asks for. Two Order objects may differ in timestamp or status, but the import rule might say "keep the first order per external ID." In that case, store keys in a set and keep the element when the key was not seen before.
record Order(String externalId, String status, Instant receivedAt) { }
static <T, K> List<T> distinctByKey(List<T> values, Function<T, K> key) {
Set<K> seenKeys = new HashSet<>();
List<T> result = new ArrayList<>();
for (T value : values) {
if (seenKeys.add(key.apply(value))) {
result.add(value);
}
}
return result;
}
Notice that the key can be different from object equality. That is useful, but it also means you must name the rule clearly. "Unique orders" and "unique order IDs" are not the same requirement.
Many candidates write new HashSet<>(list) and return it as the answer to "remove duplicates from a list." That removes duplicates, but it also drops list order and the List API. If the output is supposed to stay a list in first-seen order, use LinkedHashSet and wrap it back in an ArrayList.
03Counting duplicates and avoiding the frequency trap
Counting is a map problem. The key is the list element, and the value is how many times you saw it. Streams can express this with groupingBy and counting, while a loop can use merge.
Try it yourself first
Count the number of times each status appears, then print only statuses that appear more than once.
List<String> statuses = List.of("NEW", "PAID", "NEW", "FAILED", "PAID", "PAID");
Map<String, Long> counts = statuses.stream()
.collect(Collectors.groupingBy(
Function.identity(),
LinkedHashMap::new,
Collectors.counting()
));
counts.entrySet().stream()
.filter(entry -> entry.getValue() > 1)
.forEach(System.out::println); // NEW=2, PAID=3
The LinkedHashMap::new supplier keeps keys in first-seen order. Without it, groupingBy typically uses a HashMap, and the iteration order is not part of the contract.
Here is the loop version I write when I want validation or side effects nearby:
Map<String, Integer> counts = new LinkedHashMap<>();
for (String status : statuses) {
counts.merge(status, 1, Integer::sum);
}
Both approaches are O(n) expected time and O(u) space. Collections.frequency(list, x) is fine for one value because it scans once. The trap is calling it once per distinct element or, worse, once per original element.
List<String> values = List.of("A", "B", "A", "C", "B", "A");
Set<String> unique = new LinkedHashSet<>(values);
for (String value : unique) {
System.out.println(value + "=" + Collections.frequency(values, value));
}
That example is O(n*u). If every element is unique, u is n, so it becomes O(n²). For 100,000 unique values, that means about 10,000,000,000 equality checks in the worst shape. Build one frequency map instead.
Collections.frequency scans the entire collection every call. One call over 100,000 elements is fine. 100,000 calls over the same 100,000 element list is quadratic work. A single merge pass performs 100,000 hash lookups instead.
04Finding all duplicates
To find duplicate values, use two sets: one for values already seen, and one for values confirmed as duplicates. Set.add returns false when the value was already present, which gives you the whole algorithm.
static <T> Set<T> duplicatesOf(List<T> values) {
Set<T> seen = new HashSet<>();
Set<T> duplicates = new LinkedHashSet<>();
for (T value : values) {
if (!seen.add(value)) {
duplicates.add(value);
}
}
return duplicates;
}
System.out.println(duplicatesOf(List.of("A", "B", "A", "C", "B", "A")));
// [A, B]
This returns each duplicate value once, in the order it first became a duplicate. It is O(n) expected time and O(u) space. If you need duplicate positions, store indexes in a Map<T, List<Integer>>; if you need counts, use the frequency map from the previous section.
null works with HashSet and LinkedHashSet, so this algorithm can find repeated nulls in a mutable list. List.of cannot contain nulls, so the question only appears with mutable or external inputs.
05Intersection: set style and multiset style
A set-style intersection returns values that appear in both lists at least once. The simplest mutable-list version is copy left, then retainAll(rightMembership). The key is to make the membership side a HashSet first.
Try it yourself first
What is the complexity difference between passing right directly to retainAll and passing new HashSet<>(right)?
List<String> left = List.of("A", "B", "C", "A");
List<String> right = List.of("B", "C", "D");
Set<String> rightSet = new HashSet<>(right); // O(m)
List<String> intersection = new ArrayList<>(left);
intersection.retainAll(rightSet); // O(n) expected membership tests
System.out.println(intersection); // [B, C]
The result above preserves the order and duplicates from the left side for values that are in the right set. If left were [B, B, C], the result would be [B, B, C], because this is membership filtering, not duplicate matching.
Passing a List as the argument can make retainAll O(n*m), because each membership check may scan the argument list. Passing a HashSet makes it O(n + m) expected time and O(m) extra space.
Premium lesson
Keep reading with the Java Pass
The rest of this lesson is part of Java One-Stop Interview Prep: in-depth explanations, diagrams, model answers and how to say them in the interview.
- Differences and symmetric differences
- Partitioning a list into chunks of N
- Advanced: choosing the algorithm by semantics
- Edge cases you should say out loud
- How to say it in the interview
From ₹199 · one-time payment · No auto-renewal