Cyclic Sort: Find Missing and Duplicate Numbers in O(n) Time, O(1) Space
Place every number at its correct index in one sweep — then scan for mismatches. No HashSet, no extra memory.
Abstract AlgorithmsIntermediate
For developers with some experience. Builds on fundamentals.
Estimated read time: 15 min
AI-assisted content. This post may have been written or enhanced with AI tools. Please verify critical information independently.
TLDR: If an array holds n numbers in range [1, n], each number belongs at index
num - 1. Cyclic sort places every element at its correct index in O(n) time using O(1) space — then a single scan reveals every missing and duplicate number. Five interview problems, one technique.
📖 The Missing Number Challenge That Bans the Easy Solution
Here is the exact constraint that forces cyclic sort into existence:
"Given an array of n integers in the range [1, n], find all missing numbers — in O(n) time and O(1) space. You may not use a HashSet or any additional array."
Without the O(1) space constraint, this is trivial: drop every number into a HashSet, then scan 1..n for gaps. That costs O(n) time and O(n) space. With the constraint, the HashSet is off the table.
The key observation is that the input array itself is the auxiliary space — if the array has n slots and contains numbers 1 through n, then the correct home for number k is index k - 1. An array that is fully sorted in cyclic order looks like [1, 2, 3, ..., n]. Any slot where nums[i] != i + 1 after a sort reveals a missing or duplicate number.
Cyclic sort exploits this identity: it places every number at its correct index by cycling through swaps, then reads the answer directly from the index-value mismatches.
🔍 Index as Identity: The Invariant That Drives the Algorithm
The invariant is deceptively simple:
Number
kbelongs at indexk − 1.
For an array [3, 1, 4, 2] (n = 4, range [1, 4]):
| Index | Current value | Correct value | In place? |
| 0 | 3 | 1 | ❌ |
| 1 | 1 | 2 | ❌ |
| 2 | 4 | 3 | ❌ |
| 3 | 2 | 4 | ❌ |
After cyclic sort, every element sits at its correct index: [1, 2, 3, 4]. No mismatches — no missing numbers.
For a missing number scenario like [3, 4, -1, 1] (where −1 is a placeholder for missing 2), the algorithm places what it can and leaves the slot for 2 empty (or containing a wrong value), revealing the gap.
This index-as-identity relationship is what makes the algorithm O(1) space: you are not recording presence in a separate structure, you are rearranging the input array into its own sorted-by-identity form.
⚙️ The Cyclic Sort Algorithm: Place Every Number at Its Correct Index
Walk the array from left to right. At each position i:
- Compute the correct index for
nums[i]: that isj = nums[i] - 1. - If
nums[i] != nums[j](the number is not already at its correct home), swapnums[i]withnums[j]. - If
nums[i] == nums[j](either in place or a duplicate), advancei.
The swap sends nums[i] toward its correct slot. You do not advance i after a swap because the value that arrived at position i from the swap may itself need to move. You only advance when nums[i] is already correct (or cannot be placed due to a duplicate).
public class CyclicSort {
// Core cyclic sort — places each number at index nums[i] - 1
public void cyclicSort(int[] nums) {
int i = 0;
while (i < nums.length) {
int j = nums[i] - 1; // correct index for nums[i]
if (nums[i] != nums[j]) {
swap(nums, i, j); // send nums[i] to its correct home
} else {
i++; // nums[i] is in place (or duplicate) — advance
}
}
}
private void swap(int[] nums, int i, int j) {
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
}
Trace on [3, 1, 4, 2]:
| Step | i | Array state | Action |
| 1 | 0 | [3, 1, 4, 2] | nums[0]=3, j=2; nums[0]≠nums[2] → swap(0,2) |
| 2 | 0 | [4, 1, 3, 2] | nums[0]=4, j=3; nums[0]≠nums[3] → swap(0,3) |
| 3 | 0 | [2, 1, 3, 4] | nums[0]=2, j=1; nums[0]≠nums[1] → swap(0,1) |
| 4 | 0 | [1, 2, 3, 4] | nums[0]=1, j=0; nums[0]==nums[0] → i++ |
| 5 | 1 | [1, 2, 3, 4] | nums[1]=2, j=1; in place → i++ |
| 6 | 2 | [1, 2, 3, 4] | nums[2]=3, j=2; in place → i++ |
| 7 | 3 | [1, 2, 3, 4] | nums[3]=4, j=3; in place → i++ |
Result: fully sorted. No missing numbers.
🧠 Deep Dive: The Cycle Invariant and Why O(n) Holds Despite Nested Loops
Under the Hood: Internals of Cyclic Placement
The algorithm looks like it could be O(n²) — there is a while loop and each iteration may not advance i. But the key insight is that every swap permanently places at least one element at its correct index. Each element can be swapped into its correct position at most once. After that, it never moves again because nums[j] == j + 1 is satisfied, and any future visit to that index will trigger the i++ branch immediately.
Formally: across all iterations, the total number of swaps is at most n − 1 (you need at most n−1 swaps to fully sort n elements). The i++ operation also happens at most n times — once per index. Total operations = at most n + (n−1) = 2n − 1 = O(n).
The data structure is the input array itself. No additional structure is maintained. After cyclic sort completes, the array stores its "answer" in the index-value mismatch pattern — the structure is the answer.
Performance Analysis: O(n) Despite the Apparent Inner Loop
| Dimension | Complexity | Justification |
| Time | O(n) | Each element is swapped to its correct position at most once; total swaps ≤ n−1 |
| Space | O(1) | All operations are in-place; only scalar variables i, j, temp used |
| Post-sort scan | O(n) | One additional pass to detect mismatches |
| Total | O(n) time, O(1) space | Both phases are linear; sorting dominates with a constant factor of ~2 |
This is why cyclic sort beats HashSet for the space-constrained variant: HashSet gives O(n) time and O(n) space, while cyclic sort gives O(n) time and O(1) space.
📊 Cyclic Placement Step by Step: Visualizing the Swap Cycle
flowchart TD
A[Start: i = 0] --> B[Compute j = nums[i] - 1]
B --> C{nums[i] == nums[j]?}
C -->|"No wrong position"| D[Swap nums[i] with nums[j]]
D --> B
C -->|"Yes in place or duplicate"| E[Advance: i++]
E --> F{i < n?}
F -->|Yes| B
F -->|No| G[Cyclic sort complete]
G --> H[Scan array for index-value mismatches]
H --> I[Report missing / duplicate values]
After the sort completes, every slot where nums[i] != i + 1 is a mismatch. The mismatch's index tells you which number is missing (i + 1), and the mismatch's value tells you which number is a duplicate (nums[i]).
🌍 Real-World Applications: In-Place Array Mutation at Scale
Deduplication pipelines in data engineering often process arrays of entity IDs (sequential integers assigned to database rows). When a batch of IDs in range [1, n] arrives and missing IDs indicate failed inserts, cyclic sort's O(1) space is critical — the data batch itself can be tens of millions of rows, and allocating an auxiliary array of equal size may be cost-prohibitive.
Embedded systems with severe memory constraints (microcontrollers, IoT firmware) use in-place algorithms where heap allocation is banned. Cyclic sort's zero-allocation pattern is a direct fit.
Cache-efficient array processing: because cyclic sort only accesses nums[i] and nums[j] (nearby memory after a few swaps settle), it has better cache behavior than approaches that allocate a parallel array and write to scattered positions.
Input / Process / Output for a missing-number audit:
| Stage | Data |
| Input (raw IDs) | [4, 3, 2, 7, 8, 2, 3, 1] |
| After cyclic sort | [1, 2, 3, 4, 3, 2, 7, 8] |
| Mismatch scan | index 4: 3≠5, index 5: 2≠6 |
| Missing numbers | [5, 6] |
🧪 Five Problems, One Technique
Each problem below is a minor variation on the core cyclic sort — the sort itself is nearly identical, only the post-sort scan changes.
Problem 1: Find the Missing Number (array has n numbers in range [0, n])
Range is [0, n], so index j = nums[i] (not nums[i] − 1). Skip nums[i] == n since n has no slot.
public int missingNumber(int[] nums) {
int i = 0;
while (i < nums.length) {
int j = nums[i];
// nums[i] is in range [0, n-1] and not already in place
if (nums[i] < nums.length && nums[i] != nums[j]) {
swap(nums, i, j);
} else {
i++;
}
}
// Scan for the slot where index != value
for (int k = 0; k < nums.length; k++) {
if (nums[k] != k) return k;
}
return nums.length; // n itself is missing
}
Problem 2: Find All Missing Numbers (range [1, n], duplicates allowed)
Same core sort as the base algorithm. After sorting, every slot where nums[i] != i + 1 is a missing number.
public List<Integer> findAllMissingNumbers(int[] nums) {
int i = 0;
while (i < nums.length) {
int j = nums[i] - 1;
if (nums[i] != nums[j]) {
swap(nums, i, j);
} else {
i++;
}
}
List<Integer> missing = new ArrayList<>();
for (int k = 0; k < nums.length; k++) {
if (nums[k] != k + 1) missing.add(k + 1);
}
return missing;
}
Problem 3: Find the Duplicate Number (n+1 numbers in range [1, n])
One number appears twice. When nums[i] == nums[j] and nums[i] != i + 1, the value cannot be placed — it is the duplicate.
public int findDuplicate(int[] nums) {
int i = 0;
while (i < nums.length) {
if (nums[i] != i + 1) {
int j = nums[i] - 1;
if (nums[i] != nums[j]) {
swap(nums, i, j);
} else {
return nums[i]; // duplicate detected
}
} else {
i++;
}
}
return -1;
}
Problem 4: Find All Duplicates (range [1, n], each number appears once or twice)
After sorting, every slot where nums[i] != i + 1 holds a value that appeared twice.
public List<Integer> findAllDuplicates(int[] nums) {
int i = 0;
while (i < nums.length) {
int j = nums[i] - 1;
if (nums[i] != nums[j]) {
swap(nums, i, j);
} else {
i++;
}
}
List<Integer> duplicates = new ArrayList<>();
for (int k = 0; k < nums.length; k++) {
if (nums[k] != k + 1) duplicates.add(nums[k]);
}
return duplicates;
}
Problem 5: Find the Corrupt Pair — Missing and Duplicate in One Pass
One number is missing; one number appears twice. The sort leaves one slot with a wrong value: the value at that slot is the duplicate, and the slot's index + 1 is the missing number.
public int[] findCorruptPair(int[] nums) {
int i = 0;
while (i < nums.length) {
int j = nums[i] - 1;
if (nums[i] != nums[j]) {
swap(nums, i, j);
} else {
i++;
}
}
for (int k = 0; k < nums.length; k++) {
if (nums[k] != k + 1) {
return new int[]{nums[k], k + 1}; // {duplicate, missing}
}
}
return new int[]{-1, -1};
}
Summary of all five variants:
| Problem | Range | Sort tweak | Scan output |
| Missing Number | [0, n] | j = nums[i], skip if nums[i] == n | First index where nums[k] != k |
| All Missing Numbers | [1, n] | Standard | All indices where nums[k] != k+1 |
| Find Duplicate | [1, n], n+1 elements | Return early on collision | Return duplicate at collision |
| All Duplicates | [1, n] | Standard | All values where nums[k] != k+1 |
| Corrupt Pair | [1, n] | Standard | First mismatch: value=dup, index+1=missing |
⚖️ Cyclic Sort vs HashSet: When Space Actually Matters
The cyclic sort vs HashSet trade-off is not just academic:
| Approach | Time | Space | Mutates input? | Works on stream? |
| HashSet | O(n) | O(n) | No | Yes |
| Cyclic sort | O(n) | O(1) | Yes | No (needs random access) |
| Bit vector | O(n) | O(n/8) | No | Yes |
| XOR trick | O(n) | O(1) | No | Yes (single missing only) |
Cyclic sort is the right choice when: the input can be modified, random access is available (array, not stream), the O(1) space constraint is hard, and you need a single pattern that handles missing numbers, duplicates, and corrupt pairs uniformly.
The XOR trick handles the "single missing number" case in O(1) space without mutation, but it cannot be extended to "all missing numbers" or "all duplicates" — cyclic sort handles all those variants with the same core code.
🧭 When to Reach for Cyclic Sort
| Situation | Recommendation |
| Find missing/duplicate in [1, n] array, O(1) space required | Cyclic sort — it is the canonical solution |
| Input is a stream (no random access) | HashSet or Bit vector instead |
| Array must not be modified | HashSet or bit vector |
| Single missing number, no duplicates | XOR trick is simpler |
| Multiple missing numbers in [1, n] | Cyclic sort or bit vector |
| Range is not [1, n] (e.g., [0, n]) | Adjust j = nums[i] instead of nums[i] - 1 |
Edge case: nums[i] > n (out of range) | Guard with bounds check before computing j |
🛠️ Java's BitSet: The JDK's O(n/8) Space Approach to the Same Problem
java.util.BitSet is the JDK's built-in solution for presence-tracking problems. It achieves O(n/8) space (1 bit per element) — not O(1), but practical when n is very large.
import java.util.BitSet;
public class BitSetMissingNumbers {
// Using BitSet — O(n/8) space, does not mutate input
public List<Integer> findMissingWithBitSet(int[] nums) {
BitSet bitset = new BitSet(nums.length + 1);
for (int num : nums) {
bitset.set(num); // mark num as present
}
List<Integer> missing = new ArrayList<>();
for (int i = 1; i <= nums.length; i++) {
if (!bitset.get(i)) missing.add(i);
}
return missing;
}
}
BitSet vs Cyclic Sort:
| Criterion | Cyclic Sort | BitSet |
| Space | O(1) — in-place | O(n/8) — separate allocation |
| Input mutation | Yes | No |
| Code simplicity | Higher (swap logic) | Lower (set/get) |
| Handles duplicates | Yes (all 5 variants) | Partial (needs extra pass) |
Cyclic sort wins on space and generality. BitSet wins on simplicity and non-mutation. For an interview where the O(1) constraint is stated, cyclic sort is the expected answer. In production code where mutation is unacceptable, BitSet is more pragmatic.
📚 Lessons from Cyclic Sort Pitfalls
Lesson 1 — Forgetting the bounds check. If the problem has out-of-range values (e.g., nums[i] = 0 in a [1, n] array, or nums[i] > n), computing j = nums[i] - 1 will produce a negative or out-of-bounds index. Always guard: if (nums[i] >= 1 && nums[i] <= n && nums[i] != nums[j]).
Lesson 2 — Advancing i after every swap. A critical mistake is writing i++ unconditionally. After swapping nums[i] to nums[j], a new value arrives at position i that may not be in its correct place yet. Only advance i when nums[i] == nums[j] (already placed or collision detected).
Lesson 3 — Reading the scan result backward. In the corrupt pair problem, the mismatch means the value at the mismatch index is the duplicate, and the index + 1 is the missing number — not the other way around.
Lesson 4 — Mixing up range [0, n] vs [1, n]. The LeetCode "Missing Number" problem uses range [0, n], requiring j = nums[i] (not nums[i] - 1). Confusing the two ranges silently produces wrong answers.
Lesson 5 — Expecting the algorithm to work on sorted input. If the input is already sorted, cyclic sort terminates in O(n) time correctly — but if you need to detect duplicates in sorted input, a simpler two-pointer scan is cleaner and more readable.
📌 Summary: The Cyclic Sort Playbook
- Core invariant: number
kbelongs at indexk − 1. After sorting, mismatches tell the whole story. - Advance
ionly whennums[i] == nums[j]— otherwise swap without advancing. This is what keeps the algorithm O(n) despite looking like it could be O(n²). - Each element is swapped at most once — the total swap count is bounded by
n − 1, making the total work O(n). - O(1) space — the input array is the only storage. The sorted state encodes the answer.
- Five problems, same core: Missing Number, All Missing Numbers, Find Duplicate, All Duplicates, Corrupt Pair all share the same sort loop with minor scan-phase variations.
- When to skip it: no random access (streaming), input must not be mutated, range is not [1, n].
One-liner to remember: Place every number at
nums[i] − 1, advance only on collision, then scan — mismatches are the answer.
🔗 Related Patterns and Posts
- Merge Intervals Pattern: Solve Scheduling Problems with Sort and Sweep
- In-Place Reversal of a Linked List
- Hash Tables Explained: The Data Structure Powering O(1) Lookups
Test Your Knowledge
Ready to test what you just learned?
AI will generate 4 questions based on this article's content.

Written by
Abstract Algorithms
@abstractalgorithms
More Posts
HyperLogLog Explained: Counting Billions of Unique Items with 12 KB
TLDR: HyperLogLog estimates the number of distinct elements in a dataset using ~12 KB of memory regardless of cardinality — with ±0.81% error. The insight: if you hash every element to a random bit string, the maximum length of leading zeros you obse...
Count-Min Sketch Explained: Frequency Estimation at Streaming Scale
TLDR: Count-Min Sketch (CMS) is a fixed-size d × w counter matrix that estimates how often any element has appeared in a stream. Insert: hash the element with each of the d hash functions to get one column per row, increment those d counters. Query: ...
Bloom Filters Explained: Membership Testing with Zero False Negatives
TLDR: A Bloom filter is a bit array of m bits + k independent hash functions that sets k bits on insert and checks those same k bits on lookup. If any checked bit is 0, the element is definitely not in the set — false negatives are mathematically imp...
Java 21 to 25: Virtual Threads, Pattern Matching, and Structured Concurrency
TLDR: Java 21 LTS makes virtual threads a production-ready replacement for bounded thread pools — your newFixedThreadPool(200) can become newVirtualThreadPerTaskExecutor() and handle 10× the concurrency with no architectural changes. Pattern switch w...
