Brain
EngineeringJava

Java Collections

Cheat sheet for the Java collections framework plus strings and arrays, with the Big-O of every structure and a table for picking the right one.

Fast lookup for the Java APIs that show up in coding rounds. The code carries most of it, and there is a line of explanation only where an API is easy to get wrong.

Pick the right one

NeedUse
Index lookup, mostly readsArrayList
FIFO queueArrayDeque
LIFO stackArrayDeque, not Stack
Add and remove at both endsArrayDeque
Key to value, order does not matterHashMap
Key to value, sorted keysTreeMap
Key to value, insertion order keptLinkedHashMap
Unique items, order does not matterHashSet
Unique items, sortedTreeSet
Always pull the smallest or largestPriorityQueue
Count frequency of lowercase lettersint[26]
Count frequency of anything elseHashMap<K, Integer> with merge
A fixed set of known values, never modifiedList.of, Set.of, Map.of

Complexity at a glance

StructureAccessContainsInsertRemoveNotes
ArrayListO(1)O(n)O(1) amortized at end, O(n) in middleO(n)Array backed, capacity doubles on growth
LinkedListO(n)O(n)O(1) at either endO(1) at either end, O(n) by indexDoubly linked
ArrayDequeNot indexableO(n)O(1) both endsO(1) both endsFastest stack and queue
HashMapO(1) avgO(1) avgO(1) avgO(1) avgO(log n) worst case per bucket since Java 8
LinkedHashMapO(1) avgO(1) avgO(1) avgO(1) avgKeeps insertion order
TreeMapO(log n)O(log n)O(log n)O(log n)Red black tree, keys sorted
HashSetn/aO(1) avgO(1) avgO(1) avgHashMap underneath
TreeSetn/aO(log n)O(log n)O(log n)Sorted
PriorityQueuepeek O(1)O(n)O(log n)poll O(log n), remove(Object) O(n)Binary heap, iteration order is not sorted

Strings

String is immutable. Every concat builds a new object, so loop with StringBuilder.

String s = "hello";

s.length();
s.charAt(i);
s.isEmpty();
s.trim();
s.substring(start, end);      // end is exclusive
s.indexOf("lo");              // index or -1
s.contains("ell");
s.equals(other);              // never use == on strings
s.equalsIgnoreCase(other);
s.compareTo(other);           // <0, 0, >0
s.toLowerCase();
s.split(",");                 // takes a regex, not a plain string
s.repeat(3);
String.join(",", list);

StringBuilder

StringBuilder sb = new StringBuilder();
StringBuilder sb = new StringBuilder("Hello world");

sb.append('c');               // char, String, int, double all work
sb.insert(0, "x");
sb.deleteCharAt(i);
sb.setCharAt(i, 'a');
sb.charAt(i);
sb.indexOf("lo");
sb.length();
sb.reverse();
sb.toString();

// Two builders are only equal as strings
sb.toString().equals(other.toString());

String vs StringBuffer vs StringBuilder

FeatureStringStringBufferStringBuilder
StorageString pool if a literal, else heapHeapHeap
Object creationNew object on every change (immutable)Changes the same object (mutable)Changes the same object (mutable)
Memory usageHigh, one object per changeModerate, no new objects but synchronization overheadLow, no new objects and no synchronization
Thread safetyThread safe because immutableThread safe, methods are synchronizedNot thread safe
PerformanceSlow for heavy modificationSlower than StringBuilder because of synchronizationFastest for modification on one thread
Use caseConstant strings and light workString edits shared across threadsHot string edits on one thread

Default to StringBuilder. Reach for StringBuffer only when several threads edit the same buffer.

Conversions

Everything in one place, since these are the lines you forget under pressure.

// String and char[]
char[] chars = s.toCharArray();
String back = String.valueOf(chars);
String back = new String(chars);

// String and int
int n = Integer.parseInt("200");
String s = String.valueOf(200);
String s = Integer.toString(200);

// char and String
String s = Character.toString('a');
String s = String.valueOf('a');

// char and int digit
int d = c - '0';              // '7' becomes 7
int idx = c - 'a';            // 'c' becomes 2, for int[26] buckets

// List and array
List<Integer> list = new ArrayList<>(Arrays.asList(arr));   // arr is Integer[]
Integer[] arr = list.toArray(new Integer[0]);
int[][] grid = list.toArray(new int[0][]);                  // List<int[]>

char

Character.isLetter(c);
Character.isDigit(c);
Character.isLetterOrDigit(c);
Character.isUpperCase(c);
Character.isWhitespace(c);
Character.toUpperCase(c);
Character.toLowerCase(c);

Integer

Integer.MIN_VALUE;
Integer.MAX_VALUE;
Integer.parseInt(s);
Integer.parseInt(s, 2);       // binary string to int
Integer.toBinaryString(n);
Integer.bitCount(n);          // number of set bits
Integer.compare(a, b);        // overflow safe, unlike a - b
Integer.valueOf(s);           // boxed Integer, parseInt gives a primitive int

Integer caches boxed values from -128 to 127, so == on Integer gives the right answer for small numbers and the wrong one for large ones. Use .equals() or unbox to int.

Number limits

Every primitive has a fixed range, and the constants are the readable way to say it. Reach for them when you need a sentinel for a min or max search, or when you are checking whether a value fits.

TypeBitsMinMax
byte8-128127
short16-32,76832,767
char16065,535
int32-2,147,483,6482,147,483,647, about 2.1 billion
long64-9,223,372,036,854,775,8089,223,372,036,854,775,807, about 9.2 quintillion
float32about -3.4e38about 3.4e38, 7 digits of precision
double64about -1.8e308about 1.8e308, 15 digits of precision
Integer.MIN_VALUE;            // -2147483648
Integer.MAX_VALUE;            //  2147483647
Long.MIN_VALUE;               // -9223372036854775808
Long.MAX_VALUE;               //  9223372036854775807
Byte.MIN_VALUE;               Byte.MAX_VALUE;
Short.MIN_VALUE;              Short.MAX_VALUE;
Character.MIN_VALUE;          Character.MAX_VALUE;

Integer.SIZE;                 // 32 bits
Integer.BYTES;                // 4 bytes

Double.MAX_VALUE;             // largest positive double
Double.MIN_VALUE;             // smallest positive double, 4.9e-324, not the most negative
Double.POSITIVE_INFINITY;
Double.NEGATIVE_INFINITY;
Double.NaN;

Double.MIN_VALUE is the trap. It is the smallest positive value, not the lowest one. For the lowest possible double write -Double.MAX_VALUE.

NaN is not equal to itself, so d == Double.NaN is always false. Use Double.isNaN(d), and Double.isInfinite(d) for the other two.

Long

long is the escape hatch when int is too small: sums of a large array, products of two ints, timestamps in milliseconds, anything counting past 2.1 billion.

long x = 10;                       // widening from int is automatic
long big = 10000000000L;           // literal past int range needs the L
int back = (int) big;              // narrowing needs a cast and can silently truncate
int safe = Math.toIntExact(big);   // throws ArithmeticException instead

Long.MIN_VALUE;               Long.MAX_VALUE;
Long.SIZE;                    // 64 bits
Long.BYTES;                   // 8 bytes
Long.parseLong(s);
Long.compare(a, b);
Long.toBinaryString(n);
Long.bitCount(n);

The suffix is the part that bites. A numeric literal is an int until you write L, so the arithmetic happens in int and overflows before the value is ever widened to long.

long ms = 24 * 60 * 60 * 1000;         // fine, fits in int
long us = 24 * 60 * 60 * 1000 * 1000;  // overflows, all int math
long us = 24L * 60 * 60 * 1000 * 1000; // one L on the left fixes the whole expression

long sum = 0;
for (int n : nums) sum += n;           // sum is long, each n widens, safe

Long.MIN_VALUE is one further from zero than Long.MAX_VALUE, so it has no positive twin. Negating or taking the absolute value of it gives you the same number back.

Math.abs(Long.MIN_VALUE);              // still Long.MIN_VALUE, negative
-Long.MIN_VALUE;                       // same, wraps to itself
Long.MIN_VALUE / -1;                   // Long.MIN_VALUE, no exception thrown
Math.negateExact(Long.MIN_VALUE);      // throws ArithmeticException

Java will not let you write the constant as a plain literal either. -9223372036854775808L compiles only because the minus is part of the expression; 9223372036854775808L on its own is a compile error. Use Long.MIN_VALUE.

Boxing rules match Integer. Long caches -128 to 127, so == on two Long objects is right for small values and wrong for big ones. Compare with .equals() or unbox.

Overflow

int arithmetic wraps around in silence. Nothing throws, you get a negative number and a wrong answer far from the line that caused it.

Integer.MAX_VALUE + 1;                 // -2147483648
Math.abs(Integer.MIN_VALUE);           // still -2147483648, abs has no positive answer here

int big = 100000 * 100000;             // overflows before the assignment
long ok = 100000L * 100000;            // one L makes the whole product long

int mid = (low + high) / 2;            // overflows on large indexes
int mid = low + (high - low) / 2;      // safe binary search midpoint

Three ways out. Compute in long and cast back once you know it fits. Use Math.addExact, Math.subtractExact or Math.multiplyExact, which throw ArithmeticException instead of wrapping. Or use Math.toIntExact(longValue) when narrowing.

Sentinels overflow too. Integer.MAX_VALUE is the usual "infinity" for a shortest path or a min search, but the moment you add a weight to it the value wraps negative and the algorithm picks the broken path.

int[] dist = new int[n];
Arrays.fill(dist, Integer.MAX_VALUE);
if (dist[u] != Integer.MAX_VALUE && dist[u] + w < dist[v]) { }   // guard first

// or give yourself headroom
final int INF = Integer.MAX_VALUE / 2;
// or hold distances in long

Math

Math.abs(n);
Math.max(a, b);               Math.min(a, b);
Math.pow(2, 10);              // returns double, cast for int
Math.sqrt(n);                 // double
Math.cbrt(n);
Math.floor(x);                Math.ceil(x);        // double in, double out
Math.round(x);                // long for double, int for float
Math.hypot(a, b);

Math.floorDiv(-7, 2);         // -4, rounds down
Math.floorMod(-7, 3);         // 2, never negative
-7 % 3;                       // -1, the sign follows the left operand

Math.addExact(a, b);          // throws on overflow
Math.multiplyExact(a, b);
Math.toIntExact(longValue);

Integer division truncates toward zero, so 7 / 2 is 3 and -7 / 2 is -3. For a ceiling divide of positive numbers use (a + b - 1) / b.

% keeps the sign of the left side, which breaks index wrapping on a negative step. Math.floorMod(i, n) gives the answer you wanted.

Frequency count

int[] freq = new int[26];
for (char c : s.toCharArray()) {
    freq[c - 'a']++;
}

Map<Character, Integer> freq = new HashMap<>();
for (char c : s.toCharArray()) {
    freq.merge(c, 1, Integer::sum);
}

Arrays

int[] a = new int[20];                 // filled with 0
int[] a = {1, 2, 3, 4, 5};
int[][] grid = new int[10][10];

a.length;                              // field, no parentheses

Arrays.fill(a, 10);
Arrays.fill(grid[0], -1);
Arrays.sort(a);                        // dual pivot quicksort, O(n log n)
Arrays.sort(a, from, to);
Arrays.binarySearch(a, key);           // array must be sorted first
Arrays.copyOf(a, newLength);
Arrays.copyOfRange(a, from, to);
Arrays.equals(a, b);
Arrays.toString(a);
Arrays.deepToString(grid);             // for 2D
Arrays.stream(a).sum();
Arrays.stream(a).max().getAsInt();

Descending sort needs boxed types. Arrays.sort(int[], Comparator) does not compile.

Integer[] a = {3, 1, 2};
Arrays.sort(a, Collections.reverseOrder());

// For int[], sort ascending then reverse by hand
int[] b = {3, 1, 2};
Arrays.sort(b);
for (int i = 0, j = b.length - 1; i < j; i++, j--) {
    int tmp = b[i]; b[i] = b[j]; b[j] = tmp;
}

// Sort 2D by a column
int[][] intervals = {{1, 3}, {0, 2}};
Arrays.sort(intervals, (x, y) -> x[0] - y[0]);
Arrays.sort(intervals, Comparator.comparingInt(x -> x[0]));

List

ArrayList

List<Integer> list = new ArrayList<>();
List<Integer> list = new ArrayList<>(Arrays.asList(60, 25, 12));
List<Integer> list = new ArrayList<>(other);           // copy

list.size();
list.isEmpty();
list.get(i);
list.set(i, value);
list.add(10);                                          // append
list.add(i, 10);                                       // insert at i, shifts right
list.addAll(other);
list.remove(2);                                        // by index for List<Integer>
list.remove(Integer.valueOf(2));                       // by value
list.contains(120);
list.indexOf(o);                                       // first match or -1
list.clear();
list.subList(from, to);                                // view, not a copy
list.toArray(new Integer[0]);

Arrays.asList(...) returns a fixed size view of the array. add and remove on it throw UnsupportedOperationException, though set works and writes straight through to the backing array. Wrap it in new ArrayList<>(...) when you need to mutate. For a list that cannot be changed at all, see the immutable factories below.

Nested lists:

List<List<String>> grid = new ArrayList<>();
for (int i = 0; i < 100; i++) {
    grid.add(new ArrayList<>());
}

Filtering and transforming

removeIf edits in place. Everything else here reads the list and builds a new one, leaving the original alone.

list.removeIf(n -> n % 2 == 0);               // in place, no iterator needed

// Filter and map
List<Integer> evens = list.stream().filter(n -> n % 2 == 0).toList();
List<String> names = people.stream().map(Person::getName).toList();
List<Integer> flat = nested.stream().flatMap(List::stream).toList();

// Group, partition, join
Map<String, List<Person>> byCity =
    people.stream().collect(Collectors.groupingBy(Person::getCity));
Map<Boolean, List<Integer>> split =
    list.stream().collect(Collectors.partitioningBy(n -> n > 10));
String csv = String.join(", ", names);

// Reduce to one value
int sum = list.stream().mapToInt(Integer::intValue).sum();
double avg = list.stream().mapToInt(Integer::intValue).average().orElse(0);
int max = list.stream().mapToInt(Integer::intValue).max().orElseThrow();

// Ask a question
boolean any = list.stream().anyMatch(n -> n > 100);
boolean all = list.stream().allMatch(n -> n > 0);
long count = list.stream().filter(n -> n > 10).count();

// Dedupe and slice
List<Integer> unique = list.stream().distinct().toList();
List<Integer> firstFive = list.stream().limit(5).toList();

// Back to an array
Integer[] arr = list.stream().toArray(Integer[]::new);
int[] prim = list.stream().mapToInt(Integer::intValue).toArray();

Two toList calls exist and they are not the same. stream().toList() (Java 16 and up) returns an unmodifiable list, while collect(Collectors.toList()) returns a mutable one. Choosing the first and then calling add on it throws UnsupportedOperationException somewhere far from the cause.

subList returns a view, not a copy. Writes go through to the backing list, and structurally changing the backing list invalidates the view.

List<Integer> slice = list.subList(2, 5);              // indexes 2, 3, 4
slice.clear();                                          // deletes them from list
List<Integer> copy = new ArrayList<>(list.subList(2, 5));   // real copy

list.sort(...) reorders in place. list.stream().sorted() leaves the original alone and gives you a new sequence, which is the one to use when the caller owns the list.

new ArrayList<>(100) sets capacity, not size. The list is still empty, and the argument only saves the array copies that growth would otherwise cost.

LinkedList

Use it when you need Deque behaviour on a List. For a plain stack or queue, ArrayDeque is faster.

LinkedList<String> ll = new LinkedList<>();

ll.add(e);            ll.add(i, e);
ll.addFirst(e);       ll.addLast(e);
ll.getFirst();        ll.getLast();
ll.peekFirst();       ll.peekLast();        // null if empty
ll.removeFirst();     ll.removeLast();      // throw if empty
ll.pollFirst();       ll.pollLast();        // null if empty
ll.size();

Stack

Stack extends Vector and every method is synchronized, so it is slow. Interviewers expect ArrayDeque.

Deque<Integer> stack = new ArrayDeque<>();

stack.push(10);       // addFirst
stack.pop();          // removeFirst, throws if empty
stack.peek();         // null if empty
stack.isEmpty();
stack.size();

Legacy form, still fine to read:

Stack<Integer> stack = new Stack<>();
stack.push(10);
stack.pop();
stack.peek();
stack.isEmpty();

Queue

Queue<Integer> queue = new ArrayDeque<>();      // LinkedList also works, slower

queue.offer(10);      // add at tail, returns false when full
queue.add(10);        // add at tail, throws when full
queue.poll();         // remove head, null if empty
queue.remove();       // remove head, throws if empty
queue.peek();         // head without removing, null if empty
queue.isEmpty();
queue.size();

The pattern is the same everywhere: offer / poll / peek return a special value, add / remove / element throw. Prefer the first set.

ArrayDeque rejects null elements. LinkedList allows them, which is why poll returning null is ambiguous there.

BFS template

Queue<int[]> q = new ArrayDeque<>();
q.offer(new int[]{0, 0});
boolean[][] seen = new boolean[rows][cols];
seen[0][0] = true;

while (!q.isEmpty()) {
    int size = q.size();               // one level at a time
    for (int i = 0; i < size; i++) {
        int[] cur = q.poll();
        // visit neighbours, mark seen, offer
    }
}

PriorityQueue

A binary heap. peek and poll give the smallest by default. Iterating it does not give sorted order, only poll does.

PriorityQueue<Integer> minHeap = new PriorityQueue<>();
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Collections.reverseOrder());
PriorityQueue<Integer> maxHeap = new PriorityQueue<>((a, b) -> b - a);

// By a field
PriorityQueue<int[]> pq = new PriorityQueue<>((a, b) -> a[1] - b[1]);
PriorityQueue<int[]> pq = new PriorityQueue<>(Comparator.comparingInt(a -> a[1]));

pq.offer(10);
pq.poll();            // smallest, null if empty
pq.peek();
pq.size();
pq.isEmpty();

Top K pattern: keep a min heap of size k, evict the head once it overflows.

PriorityQueue<Integer> pq = new PriorityQueue<>();
for (int n : nums) {
    pq.offer(n);
    if (pq.size() > k) pq.poll();
}
// pq.peek() is the kth largest

Map

HashMap

Map<String, Integer> map = new HashMap<>();
Map<Integer, List<String>> map = new HashMap<>();

map.size();
map.isEmpty();
map.containsKey(key);
map.containsValue(value);                       // O(n)
map.get(key);                                   // null if absent
map.getOrDefault(key, 0);
map.put(key, value);                            // returns the old value or null
map.putIfAbsent(key, value);
map.remove(key);
map.clear();
map.keySet();
map.values();
map.entrySet();

// Counting and grouping without null checks
map.merge(key, 1, Integer::sum);
map.computeIfAbsent(key, k -> new ArrayList<>()).add(item);

getOrDefault, merge, and computeIfAbsent replace the if (map.get(k) == null) dance. Use them.

A key's hashCode and equals must agree. Mutating an object after using it as a key makes the entry unreachable.

TreeMap

Sorted keys, plus navigation methods that HashMap does not have.

TreeMap<String, Integer> tm = new TreeMap<>();                          // ascending
TreeMap<String, Integer> tm = new TreeMap<>(Collections.reverseOrder());

tm.put(key, value);
tm.get(key);
tm.getOrDefault(key, 0);
tm.remove(key);

tm.firstKey();        tm.lastKey();
tm.firstEntry();      tm.lastEntry();
tm.pollFirstEntry();  tm.pollLastEntry();

tm.floorKey(k);       // greatest key <= k
tm.ceilingKey(k);     // smallest key >= k
tm.lowerKey(k);       // greatest key <  k
tm.higherKey(k);      // smallest key >  k
tm.headMap(k);        tm.tailMap(k);        tm.subMap(from, to);

Iterating a map

for (Map.Entry<String, Integer> e : map.entrySet()) {
    String key = e.getKey();
    int value = e.getValue();
}

for (String key : map.keySet()) { }
for (int value : map.values()) { }

map.forEach((k, v) -> System.out.println(k + " " + v));

List<String> keys = new ArrayList<>(map.keySet());
List<Integer> values = new ArrayList<>(map.values());

Removing while looping throws ConcurrentModificationException. Remove through the iterator instead.

Iterator<Map.Entry<String, Integer>> it = map.entrySet().iterator();
while (it.hasNext()) {
    if (it.next().getValue() == 0) it.remove();
}

Set

HashSet

Set<Integer> set = new HashSet<>();
Set<Integer> set = new HashSet<>(list);         // dedupes the list

set.add(10);          // false if already present
set.contains(10);
set.remove(10);
set.size();
set.isEmpty();
set.clear();

set.addAll(other);    // union
set.retainAll(other); // intersection
set.removeAll(other); // difference

LinkedHashSet is the same with insertion order preserved.

TreeSet

Sorted, with the same navigation methods as TreeMap.

TreeSet<Integer> ts = new TreeSet<>();                          // ascending
TreeSet<Integer> ts = new TreeSet<>((a, b) -> b - a);           // descending

ts.add(10);
ts.contains(10);
ts.remove(10);

ts.first();           ts.last();
ts.pollFirst();       ts.pollLast();
ts.floor(k);          ts.ceiling(k);
ts.lower(k);          ts.higher(k);
ts.headSet(k);        ts.tailSet(k);        ts.subSet(from, to);

Immutable factories

Java 9 added of factories to List, Set and Map. They build a small collection in one expression, and the result cannot be changed afterwards.

List<String> list = List.of("a", "b", "c");
Set<Integer> set = Set.of(1, 2, 3);
Map<String, Integer> map = Map.of("a", 1, "b", 2);

// Map.of tops out at 10 pairs, so past that use entries
Map<String, Integer> bigger = Map.ofEntries(
    Map.entry("a", 1),
    Map.entry("b", 2)
);

// unmodifiable snapshot of a collection you were handed
List<String> snapshot = List.copyOf(other);

Four things bite here.

Every mutator throws UnsupportedOperationException, not only add and remove. That includes set, sort, clear and removeIf.

null is rejected everywhere: as an element, as a key, as a value, and even as the argument to contains. List.of("a", null) throws NullPointerException at construction rather than later.

Duplicates are an error, not a silent merge. Set.of(1, 1) and a Map.of with a repeated key both throw IllegalArgumentException.

List.of keeps insertion order, but the iteration order of Set.of and Map.of is unspecified and deliberately varies between JVM runs. Never write code, or a test, that depends on it.

When you want a literal to start from and then edit, wrap it:

List<String> editable = new ArrayList<>(List.of("a", "b"));

The three one-line ways to build a list differ more than they look:

List.of(...)Arrays.asList(...)new ArrayList<>(...)
SizeFixedFixedGrows
set(i, v)ThrowsAllowed, writes through to the arrayAllowed
add, removeThrowsThrowsAllowed
null elementsRejectedAllowedAllowed
Backed byIts own storageThe array you passedA copy

Sorting and comparators

Collections.sort(list);                              // natural order
Collections.sort(list, Collections.reverseOrder());
list.sort(Comparator.naturalOrder());
list.sort(Comparator.reverseOrder());

// By a field
people.sort(Comparator.comparing(Person::getName));
people.sort(Comparator.comparingInt(Person::getAge));

// Descending, then a tie breaker
people.sort(Comparator.comparingInt(Person::getAge).reversed()
                      .thenComparing(Person::getName));

Collections.reverse(list);
Collections.shuffle(list);
Collections.max(list);
Collections.min(list);
Collections.frequency(list, item);
Collections.swap(list, i, j);

Comparable lives on the class through compareTo, so it defines one natural order. Comparator is passed in at the call site, so you can have as many orders as you need.

Subtraction comparators like (a, b) -> a - b overflow when the values can be near Integer.MIN_VALUE or Integer.MAX_VALUE. Integer.compare(a, b) is always safe.

On this page