Data Structures
Every algorithm in this series depends on a data structure doing its job efficiently — this is the lookup card for the 10 that show up most in interviews.
- Covers arrays, hash structures, stack, queue, heap, linked list, binary tree, trie, graph, and union find with exact Java API and complexity tables
- Each entry has key operations + Big O, when to reach for it, Java usage snippets, real scenario examples, and gotchas
- Use this alongside the pattern guides — patterns tell you the technique, this tells you the mechanics of the structure underneath
Array
A fixed-size contiguous block of memory with O(1) index access. int[] is a true array; ArrayList<Integer> is a resizable wrapper around one.
Operations
| Operation | int[] |
ArrayList |
|---|---|---|
| Access by index | O(1) | O(1) |
| Search (unsorted) | O(n) | O(n) |
| Insert / Delete at end | N/A — fixed size | O(1) amortized |
| Insert / Delete at middle | N/A — fixed size | O(n) |
Space: O(n)
When to Reach For It
- Input is already an array — work with it in place
- Need O(1) random access by index
- Prefix sum, two pointer, sliding window — all live on arrays
- Fixed-range counting: 26 letters →
int[26], digits →int[10]
Java
// Fixed array
int[] arr = new int[5]; // [0, 0, 0, 0, 0]
int[] filled = {1, 2, 3, 4, 5};
Arrays.sort(arr); // O(n log n)
Arrays.fill(arr, -1); // fill all with -1
// Dynamic list
List<Integer> list = new ArrayList<>();
list.add(10);
list.get(0); // 10
list.size();
Collections.sort(list);
// 2D array
int[][] grid = new int[3][4]; // 3 rows, 4 cols
Scenarios
// Prefix sum — build once in O(n), answer range queries in O(1)
int[] prefix = new int[nums.length + 1];
for (int i = 0; i < nums.length; i++) {
prefix[i + 1] = prefix[i] + nums[i];
}
int rangeSum = prefix[r + 1] - prefix[l]; // sum of nums[l..r]
// Frequency count with fixed range
int[] freq = new int[26];
for (char c : s.toCharArray()) {
freq[c - 'a']++;
}
Gotchas
int[]cannot be used with generics — useInteger[]orList<Integer>when a collection is neededArrays.sort()on primitives uses dual-pivot quicksort — no worst-case guarantee unlikeCollections.sort()- Assignment copies the reference, not the data — use
Arrays.copyOf(arr, arr.length)to clone
HashMap / HashSet
HashMap maps keys to values; HashSet stores unique keys only. Both give O(1) average for all core operations.
Operations
| Operation | Time |
|---|---|
| Insert | O(1) avg, O(n) worst |
| Lookup | O(1) avg, O(n) worst |
| Delete | O(1) avg, O(n) worst |
| Iterate | O(n) |
Space: O(n). Worst case O(n) per op occurs with hash collisions — rare in practice with Java's default hash.
When to Reach For It
- Count frequencies of elements
- Check membership or detect duplicates in O(1)
- Cache computed results (memoization in DP)
- Group elements by a computed key (anagram grouping, index grouping)
- Two Sum style: "have I seen the complement before?"
Java
// HashMap
Map<String, Integer> map = new HashMap<>();
map.put("a", 1);
map.get("a"); // 1
map.getOrDefault("b", 0); // 0 — always prefer over null check
map.containsKey("a"); // true
map.remove("a");
for (Map.Entry<String, Integer> e : map.entrySet()) {
System.out.println(e.getKey() + " -> " + e.getValue());
}
// HashSet
Set<Integer> set = new HashSet<>();
set.add(1);
set.contains(1); // true
set.remove(1);
Scenarios
// Frequency count
Map<Character, Integer> freq = new HashMap<>();
for (char c : s.toCharArray()) {
freq.put(c, freq.getOrDefault(c, 0) + 1);
}
// Two Sum complement check
Map<Integer, Integer> seen = new HashMap<>(); // value → index
for (int i = 0; i < nums.length; i++) {
int complement = target - nums[i];
if (seen.containsKey(complement)) {
return new int[]{seen.get(complement), i};
}
seen.put(nums[i], i);
}
// Group by computed key
Map<String, List<String>> groups = new HashMap<>();
for (String word : words) {
char[] chars = word.toCharArray();
Arrays.sort(chars);
String key = new String(chars);
groups.computeIfAbsent(key, k -> new ArrayList<>()).add(word);
}
Gotchas
- Always use
getOrDefault— a null check + put is longer and error-prone HashMapallows onenullkey; iteration order is not guaranteed — useLinkedHashMapif insertion order matterscontains()on aListis O(n) — if you're checking membership in a loop, put it in aHashSetfirst- For
Map<int[], ...>— arrays don't overrideequals/hashCode. UseArrays.toString(arr)as the key instead
Stack
Last-in, first-out. Use Deque<T> backed by ArrayDeque<T> — the legacy Stack<> class is synchronized and slow.
Operations
| Operation | Time |
|---|---|
| push | O(1) |
| pop | O(1) |
| peek | O(1) |
| isEmpty | O(1) |
Space: O(n)
When to Reach For It
- Matching or validating nested structures (brackets, tags)
- "Next greater / smaller element" problems (monotonic stack)
- Iterative DFS as an explicit alternative to recursion
- Undo / redo, call stack simulation, expression evaluation
Java
Deque<Integer> stack = new ArrayDeque<>();
stack.push(1); // addFirst — top of stack
stack.push(2);
stack.peek(); // 2 — top, no remove
stack.pop(); // 2 — removes top
stack.isEmpty(); // false
Scenarios
// Bracket matching
Deque<Character> stack = new ArrayDeque<>();
for (char c : s.toCharArray()) {
if (c == '(') {
stack.push(c);
} else if (!stack.isEmpty()) {
stack.pop();
} else {
return false;
}
}
return stack.isEmpty();
// Monotonic decreasing stack — next greater element
int[] result = new int[nums.length];
Deque<Integer> stack = new ArrayDeque<>(); // stores indices
for (int i = 0; i < nums.length; i++) {
while (!stack.isEmpty() && nums[stack.peek()] < nums[i]) {
result[stack.pop()] = nums[i];
}
stack.push(i);
}
// Iterative DFS on a tree
Deque<TreeNode> stack = new ArrayDeque<>();
stack.push(root);
while (!stack.isEmpty()) {
TreeNode node = stack.pop();
process(node);
if (node.right != null) {
stack.push(node.right);
}
if (node.left != null) {
stack.push(node.left);
}
}
Gotchas
push/pop/peekare the stack-style names onDeque— prefer them overofferFirst/pollFirstfor clarity- Always check
isEmpty()beforepeek()orpop()— both throw on an empty deque - For monotonic stack: store indices not values so you can update a result array by position
Queue
First-in, first-out. Use Deque<T> backed by ArrayDeque<T> — same class as stack, different methods.
Operations
| Operation | Time |
|---|---|
| offer (enqueue) | O(1) |
| poll (dequeue) | O(1) |
| peek (front) | O(1) |
| isEmpty | O(1) |
Space: O(n)
When to Reach For It
- BFS on trees (level-order traversal)
- BFS on graphs — shortest path in unweighted graphs
- Sliding window maximum (monotonic deque)
- Any "process in the order received" scenario
Java
Deque<Integer> queue = new ArrayDeque<>();
queue.offer(1); // addLast — enqueue
queue.offer(2);
queue.peek(); // 1 — front, no remove
queue.poll(); // 1 — removes front
queue.isEmpty(); // false
Scenarios
// BFS level-order — capture level size before inner loop
Deque<TreeNode> queue = new ArrayDeque<>();
queue.offer(root);
while (!queue.isEmpty()) {
int size = queue.size(); // freeze this level's count
for (int i = 0; i < size; i++) {
TreeNode node = queue.poll();
if (node.left != null) { queue.offer(node.left); }
if (node.right != null) { queue.offer(node.right); }
}
}
// BFS shortest path on a grid
Deque<int[]> queue = new ArrayDeque<>();
queue.offer(new int[]{startR, startC});
visited[startR][startC] = true;
int steps = 0;
while (!queue.isEmpty()) {
int size = queue.size();
for (int i = 0; i < size; i++) {
int[] curr = queue.poll();
for (int[] d : dirs) {
int nr = curr[0] + d[0], nc = curr[1] + d[1];
if (inBounds(nr, nc) && !visited[nr][nc]) {
visited[nr][nc] = true;
queue.offer(new int[]{nr, nc});
}
}
}
steps++;
}
Gotchas
- Use
offer/pollnotadd/remove—add/removethrow exceptions on failure;offer/pollreturn null/false - Always freeze
int size = queue.size()BEFORE the inner for-loop — the queue grows as you add children - For sliding window max: use
Dequeas a monotonic deque, removing from both ends as needed
Heap (Priority Queue)
A complete binary tree satisfying the heap property — always gives you the min (or max) in O(1). Java's PriorityQueue is a min-heap by default.
Operations
| Operation | Time |
|---|---|
| offer (insert) | O(log n) |
| poll (remove min/max) | O(log n) |
| peek (read min/max) | O(1) |
| contains | O(n) |
| build from collection | O(n) |
Space: O(n)
When to Reach For It
- Always need the current minimum or maximum
- Top K elements (Kth largest, K most frequent)
- Merge K sorted lists or arrays
- Scheduling problems, meeting rooms
- Dijkstra's shortest path (weighted graph)
Java
// Min-heap (default)
PriorityQueue<Integer> minHeap = new PriorityQueue<>();
minHeap.offer(3);
minHeap.offer(1);
minHeap.peek(); // 1
minHeap.poll(); // 1 — removes min
// Max-heap
PriorityQueue<Integer> maxHeap = new PriorityQueue<>((a, b) -> b - a);
// Custom comparator — sort int[] by second element
PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> a[1] - b[1]);
Scenarios
// Top K frequent — min-heap of size K, evict the least frequent
PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> a[1] - b[1]);
for (Map.Entry<Integer, Integer> e : freq.entrySet()) {
pq.offer(new int[]{e.getKey(), e.getValue()});
if (pq.size() > k) {
pq.poll(); // remove least frequent
}
}
// Merge K sorted arrays — always pull the globally smallest next
// Each entry: [value, arrayIndex, elementIndex]
PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> a[0] - b[0]);
for (int i = 0; i < arrays.length; i++) {
if (arrays[i].length > 0) {
pq.offer(new int[]{arrays[i][0], i, 0});
}
}
while (!pq.isEmpty()) {
int[] curr = pq.poll();
result.add(curr[0]);
int ai = curr[1], ei = curr[2];
if (ei + 1 < arrays[ai].length) {
pq.offer(new int[]{arrays[ai][ei + 1], ai, ei + 1});
}
}
Gotchas
- Java's
PriorityQueueis a min-heap — pass(a, b) -> b - afor max-heap peek()is O(1) butcontains()is O(n) — never use a heap to check membership- No O(log n) update-in-place — to change a priority, remove the element and re-insert
- For
int[]elements, always provide a comparator — arrays have no natural ordering
Linked List
A chain of nodes where each node holds a value and a pointer to the next node. Always built manually in interviews — ListNode is not in the standard library.
Operations
| Operation | Time |
|---|---|
| Access by index | O(n) |
| Insert / Delete at head | O(1) |
| Insert / Delete at tail (with tail pointer) | O(1) |
| Insert / Delete at known pointer | O(1) |
| Search | O(n) |
Space: O(n)
When to Reach For It
- Problem gives you a
ListNode— you're working with a linked list - In-place reversal, cycle detection, finding middle
- Merging two sorted lists
- When O(1) insert/delete at a known position matters more than random access
Java
class ListNode {
int val;
ListNode next;
ListNode(int val) { this.val = val; }
}
// Build: 1 → 2 → 3
ListNode head = new ListNode(1);
head.next = new ListNode(2);
head.next.next = new ListNode(3);
Scenarios
// Reverse in-place
ListNode prev = null, curr = head;
while (curr != null) {
ListNode next = curr.next;
curr.next = prev;
prev = curr;
curr = next;
}
// prev is the new head
// Find middle — slow/fast pointers
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
// slow is the middle node
// Dummy node — simplifies edge cases when head might change
ListNode dummy = new ListNode(0);
dummy.next = head;
ListNode curr = dummy;
// ... manipulate list ...
return dummy.next;
Gotchas
- Always use a dummy node when the head itself might be deleted or replaced
- Save
curr.nextto a temp variable BEFORE overwritingcurr.nextduring reversal - Cycle detection: fast pointer must check
fast != null && fast.next != null— in that order - Deleting a node requires a pointer to the previous node, not the node to delete
Binary Tree
A hierarchical structure where each node has at most two children — left and right. A BST is a binary tree with one extra rule: left < root < right.
Operations
| Operation | Balanced Tree | Skewed (worst) |
|---|---|---|
| DFS / BFS traversal | O(n) | O(n) |
| Search (BST) | O(log n) | O(n) |
| Insert (BST) | O(log n) | O(n) |
| Height | O(n) | O(n) |
Space: O(n) for the tree, O(h) for the recursion stack. Balanced: h = O(log n). Skewed: h = O(n).
When to Reach For It
- Problem gives you
TreeNode root - Path problems, depth, height, diameter, LCA
- Level-order output → BFS with a queue
- BST: sorted order, range queries, kth smallest
Java
class TreeNode {
int val;
TreeNode left, right;
TreeNode(int val) { this.val = val; }
}
Scenarios
// DFS — post-order (bottom-up): compute children first, combine at current
public int height(TreeNode root) {
if (root == null) {
return 0;
}
int left = height(root.left);
int right = height(root.right);
return Math.max(left, right) + 1;
}
// BFS — level-order with level boundary
Deque<TreeNode> queue = new ArrayDeque<>();
queue.offer(root);
while (!queue.isEmpty()) {
int size = queue.size();
for (int i = 0; i < size; i++) {
TreeNode node = queue.poll();
if (node.left != null) { queue.offer(node.left); }
if (node.right != null) { queue.offer(node.right); }
}
}
// BST in-order traversal — produces values in sorted order
public void inOrder(TreeNode root, List<Integer> result) {
if (root == null) {
return;
}
inOrder(root.left, result);
result.add(root.val);
inOrder(root.right, result);
}
// BST validation — pass bounds down, not just parent comparison
public boolean isValid(TreeNode root, long min, long max) {
if (root == null) {
return true;
}
if (root.val <= min || root.val >= max) {
return false;
}
return isValid(root.left, min, root.val)
&& isValid(root.right, root.val, max);
}
Gotchas
- Always check
root == nullas your base case before accessing.leftor.right - BST validation requires passing min/max bounds through recursion — comparing only with parent misses grandparent violations
- Balanced tree ≠ BST — balanced means even height distribution; BST means ordering guarantee
- BFS uses a queue; DFS uses recursion (implicit stack) or an explicit
Dequefor iterative version
Trie
A tree where each path from root to a node spells out a prefix. Each node represents one character, not one word.
Operations
| Operation | Time |
|---|---|
| Insert | O(L) |
| Search (exact word) | O(L) |
| Prefix check | O(L) |
L = length of the string. Space: O(total characters inserted across all words).
When to Reach For It
- Prefix matching or autocomplete
- "Does any word in the set start with this prefix?"
- Word search on a board — prune paths that match no prefix early
- Replacing a
HashSet<String>when prefix queries are needed alongside membership
Java
class TrieNode {
TrieNode[] children = new TrieNode[26]; // lowercase a-z only
boolean isEnd;
}
TrieNode root = new TrieNode();
Scenarios
// Insert a word
TrieNode node = root;
for (char c : word.toCharArray()) {
int i = c - 'a';
if (node.children[i] == null) {
node.children[i] = new TrieNode();
}
node = node.children[i];
}
node.isEnd = true;
// Search exact word
TrieNode node = root;
for (char c : word.toCharArray()) {
int i = c - 'a';
if (node.children[i] == null) {
return false;
}
node = node.children[i];
}
return node.isEnd; // must reach a word-end marker
// Prefix check — identical to search but drop the isEnd requirement
TrieNode node = root;
for (char c : prefix.toCharArray()) {
int i = c - 'a';
if (node.children[i] == null) {
return false;
}
node = node.children[i];
}
return true; // any node reachable = valid prefix
Gotchas
children = new TrieNode[26]assumes lowercase a–z — use 128 for full ASCIIisEndmust be set explicitly — reaching the last character is not enough to confirm a complete word- Prefix match and exact search differ only in the final
isEndcheck — easy to mix up under pressure
Graph
A set of nodes (vertices) connected by edges. Can be directed or undirected, weighted or unweighted.
Adjacency List vs Matrix
| Adjacency List | Adjacency Matrix | |
|---|---|---|
| Space | O(V + E) | O(V²) |
| Check if edge (u, v) exists | O(degree of u) | O(1) |
| Get all neighbors of u | O(degree of u) | O(V) |
| Best for | Sparse graphs — most interview problems | Dense graphs, fast edge lookup |
When to Reach For It
- List: connectivity, BFS/DFS, course schedule, clone graph, islands
- Matrix: when edge existence lookup is the core operation, or graph is given as a matrix
Java
int n = 5;
// Adjacency list
List<List<Integer>> adj = new ArrayList<>();
for (int i = 0; i < n; i++) {
adj.add(new ArrayList<>());
}
adj.get(0).add(1); // directed edge 0 → 1
adj.get(1).add(0); // add both for undirected
// Adjacency matrix
int[][] matrix = new int[n][n];
matrix[0][1] = 1; // directed edge 0 → 1
matrix[1][0] = 1; // add both for undirected
// Weighted adjacency list — [neighbor, weight]
List<List<int[]>> wAdj = new ArrayList<>();
for (int i = 0; i < n; i++) {
wAdj.add(new ArrayList<>());
}
wAdj.get(0).add(new int[]{1, 5}); // edge 0→1 weight 5
Scenarios
// DFS — connectivity, flood fill
private void dfs(int node, List<List<Integer>> adj, boolean[] visited) {
visited[node] = true;
for (int neighbor : adj.get(node)) {
if (!visited[neighbor]) {
dfs(neighbor, adj, visited);
}
}
}
// BFS — shortest path on unweighted graph
Deque<Integer> queue = new ArrayDeque<>();
boolean[] visited = new boolean[n];
queue.offer(start);
visited[start] = true;
int steps = 0;
while (!queue.isEmpty()) {
int size = queue.size();
for (int i = 0; i < size; i++) {
int node = queue.poll();
for (int neighbor : adj.get(node)) {
if (!visited[neighbor]) {
visited[neighbor] = true;
queue.offer(neighbor);
}
}
}
steps++;
}
// 3-state DFS — cycle detection in a directed graph
// 0 = unvisited, 1 = on current path, 2 = fully processed
private boolean hasCycle(int node, List<List<Integer>> adj, int[] state) {
state[node] = 1;
for (int neighbor : adj.get(node)) {
if (state[neighbor] == 1) {
return true; // back edge = cycle
}
if (state[neighbor] == 0 && hasCycle(neighbor, adj, state)) {
return true;
}
}
state[node] = 2;
return false;
}
// Grid as implicit graph — 4-directional DFS
int[][] dirs = {{1,0},{-1,0},{0,1},{0,-1}};
private void dfs(char[][] grid, int r, int c) {
if (r < 0 || r >= grid.length || c < 0 || c >= grid[0].length
|| grid[r][c] != '1') {
return;
}
grid[r][c] = '0'; // mark visited in-place
for (int[] d : dirs) {
dfs(grid, r + d[0], c + d[1]);
}
}
Gotchas
- Mark visited when adding to queue/stack, not when popping — prevents re-processing the same node
- Undirected graphs: add edges in both directions or you'll miss connections
- Directed graph cycle detection needs 3 states — 2-state visited only works for undirected
- Grid problems are implicit graphs — no adjacency list needed, 4-direction array is your neighbor lookup
Union Find
Tracks which nodes belong to the same connected component. Supports near-constant-time union and find with path compression and union by rank.
Operations
| Operation | Basic | Path Compression + Union by Rank |
|---|---|---|
| find | O(n) worst | O(α(n)) ≈ O(1) |
| union | O(n) worst | O(α(n)) ≈ O(1) |
| connected | O(n) worst | O(α(n)) ≈ O(1) |
α(n) = inverse Ackermann — effectively constant for all practical n. Space: O(n)
When to Reach For It
- "Are these two nodes in the same component?"
- Merging components dynamically as edges are added
- Counting connected components
- Detecting cycles in an undirected graph
- Kruskal's minimum spanning tree
Java — Basic
int[] parent = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i; // each node is its own root
}
int find(int[] parent, int x) {
while (parent[x] != x) {
x = parent[x];
}
return x;
}
void union(int[] parent, int x, int y) {
parent[find(parent, x)] = find(parent, y);
}
boolean connected(int[] parent, int x, int y) {
return find(parent, x) == find(parent, y);
}
Java — Optimized (Path Compression + Union by Rank)
int[] parent, rank;
void init(int n) {
parent = new int[n];
rank = new int[n];
for (int i = 0; i < n; i++) {
parent[i] = i;
}
}
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // path compression — flatten the tree
}
return parent[x];
}
void union(int x, int y) {
int px = find(x), py = find(y);
if (px == py) {
return;
}
if (rank[px] < rank[py]) {
parent[px] = py; // attach shorter tree under taller
} else if (rank[px] > rank[py]) {
parent[py] = px;
} else {
parent[py] = px;
rank[px]++;
}
}
Scenarios
// Count connected components — start at n, decrement per successful union
int components = n;
for (int[] edge : edges) {
if (find(edge[0]) != find(edge[1])) {
union(edge[0], edge[1]);
components--;
}
}
// Cycle detection in undirected graph — if two nodes share a root, adding the edge creates a cycle
for (int[] edge : edges) {
if (find(edge[0]) == find(edge[1])) {
return true; // cycle detected
}
union(edge[0], edge[1]);
}
return false;
Gotchas
- Always use path compression — without it, find degrades to O(n) on a chain-shaped tree
unionmust callfindon both nodes — never union raw node indices directly- Count components by starting at
nand decrementing each time two different roots merge
Quick Reference
| Structure | Best For | Key Operation | Time |
|---|---|---|---|
| Array | Index access, prefix sum, frequency | arr[i] |
O(1) |
| HashMap | Frequency, lookup, complement check | get / put |
O(1) avg |
| HashSet | Membership, dedup | contains / add |
O(1) avg |
| Stack | Brackets, monotonic, iterative DFS | push / pop |
O(1) |
| Queue | BFS, level-order | offer / poll |
O(1) |
| Heap | Top K, min/max stream, K-way merge | offer / poll |
O(log n) |
| Linked List | Reversal, cycle, merge | pointer rewiring | O(1) at pointer |
| Binary Tree | Paths, depth, BST ops | DFS / BFS | O(n) / O(h) BST |
| Trie | Prefix search, word dictionary | insert / search | O(L) |
| Graph (list) | Connectivity, BFS/DFS traversal | neighbor iteration | O(V + E) |
| Graph (matrix) | Fast edge existence check | matrix[u][v] |
O(1) |
| Union Find | Components, cycle detection, merging | find / union | O(α(n)) |