03 — Java Syntax Mechanics
Java-specific friction that costs minutes and correctness points. These are the mechanics the Part 2 question files refer back to.
Each section explains why the rule exists — rules you understand are the ones you remember at 2pm in an interview.
Comparators — pointer
Comparators, Comparable, multi-key sorting, sorted collections of custom objects, and sorting maps by key or value all live in 04 — Ordering: Comparable, Comparator & Sorting. Two mechanics from there are repeated below because they are language traps rather than sorting technique.
The subtraction trap
(a, b) -> a - b is wrong in general, because of integer overflow.
Java's int holds roughly −2.1 billion to +2.1 billion. Exceed the range and it silently wraps to the other end:
a = Integer.MIN_VALUE; // -2147483648
b = 1;
a - b; // should be -2147483649 — doesn't fit
// wraps to +2147483647, a POSITIVE numberThe comparator now claims a is greater than b, which is backwards. The sort produces silently wrong output — no exception, no warning.
Use Integer.compare(a, b) or Comparator.comparingInt. They compare rather than subtract, so nothing can overflow.
a - b is fine when you know the values are small and non-negative (array indices, character codes). If you use the short form deliberately, say so aloud — "these are all small positive values so subtraction is safe here" reads as expertise rather than carelessness.
Primitive arrays can't take a comparator
Arrays.sort(intArray, (a, b) -> b - a); // DOES NOT COMPILEThere is no Arrays.sort(int[], Comparator) overload. Comparators work on objects, and int is a primitive. To sort an int[] descending you must box:
Integer[] boxed = Arrays.stream(nums).boxed().toArray(Integer[]::new);
Arrays.sort(boxed, (a, b) -> b - a);But int[][] does work with a comparator, because its elements are int[] — which are objects:
Arrays.sort(intervals, (a, b) -> a[0] - b[0]); // fineThat asymmetry is why interval problems sort cleanly while "sort this int array descending" doesn't.
Characters and strings
Characters are numbers
A char in Java is a 16-bit integer holding a character's numeric code. 'a' is 97, 'b' is 98, '0' is 48. Because they're numbers, you can do arithmetic — and that's the basis of every alphabet-indexing trick.
int idx = c - 'a'; // 'a'->0, 'b'->1, ... 'z'->25
char c = (char) ('a' + idx); // back again — the cast is REQUIRED
int d = c - '0'; // digit character -> its numeric value
char digit = (char) ('0' + d);Why c - 'a' works: if c is 'd' (100) and 'a' is 97, then c - 'a' is 3 — 'd' is the 3rd letter counting from 0. That gives you a perfect index into an int[26].
Why the cast is needed going back: 'a' + idx promotes to int, and Java won't narrow an int to a char implicitly (it could lose data). The explicit cast says "I know this fits."
Character.isLetterOrDigit(c);
Character.isDigit(c);
Character.isLetter(c);
Character.toLowerCase(c);
Character.toUpperCase(c);Strings
s.length(); // METHOD — strings
arr.length; // FIELD — arrays, no parentheses
list.size(); // METHOD — collections
s.charAt(i);
s.toCharArray(); // O(n) copy — do it once, outside loops
s.substring(i, j); // [i, j) — end exclusive. O(j-i), it COPIES
s.indexOf("x");
s.contains("x");
s.equals(t); // NEVER use == for strings
s.split("\\s+"); // takes a regex: "." must be escaped as "\\."
s.trim();
s.toLowerCase();
String.valueOf(x);
String.join(",", list);
new String(charArray);Three cost traps
1. substring copies. In Java 7+, substring allocates a new string and copies the characters. Inside a nested loop that turns an O(n²) algorithm into O(n³) without you noticing. Carry (start, end) indices instead where possible.
2. s += c in a loop is O(n²). Strings are immutable, so every += builds a whole new string by copying the old one. Always StringBuilder (02).
3. == compares references, not content.
String a = "hello";
String b = "hello";
a == b; // true — but only by accident
String c = new String("hello");
a == c; // FALSE — different objects, same content
a.equals(c); // true — correctJava keeps a pool of string literals, so two identical literals point at the same object and == appears to work. Computed strings aren't pooled, so the same code fails on real input. Always use .equals.
Boxing, equality, and null
What boxing is
Java has two parallel worlds:
- Primitives —
int,char,double,boolean. Fast, stored directly, cannot be null. - Wrapper objects —
Integer,Character,Double,Boolean. Slower, stored as references, can be null.
Collections can only hold objects, so List<int> is illegal — you need List<Integer>. Java converts automatically (autoboxing in, unboxing out), which mostly works and occasionally bites hard.
The Integer cache trap
Integer a = 127, b = 127;
a == b; // true
Integer c = 128, d = 128;
c == d; // FALSE
c.equals(d); // trueJava pre-creates Integer objects for −128 to 127 and reuses them, so == (which compares references) happens to work in that range. Above 127 you get distinct objects and == fails.
This bites in Map<Integer, Integer> and List<Integer> comparisons, and it passes small tests before failing on large ones — the worst kind of bug.
The rule:
==for primitives,.equals()for wrappers.
When both sides are boxed and you want numeric comparison, call .intValue() on one side to force primitive comparison:
if (window.get(c).intValue() == need.get(c).intValue()) { ... } // Minimum Window SubstringNull unboxing
Map<String, Integer> m = new HashMap<>();
int v = m.get("missing"); // NullPointerException
int v = m.getOrDefault("missing", 0); // correctget returns null for an absent key. Assigning null to an int means calling .intValue() on null. Always getOrDefault.
List<Integer>.remove is ambiguous
List<Integer> list = new ArrayList<>(List.of(10, 20, 30));
list.remove(1); // removes INDEX 1 -> removes the value 20
list.remove(Integer.valueOf(20)); // removes the VALUE 20remove(int) and remove(Object) both exist. A bare 1 is an int, so Java picks the index version. Wrap it to force the value version.
Integer overflow
Integer.MAX_VALUE; // 2,147,483,647
Integer.MIN_VALUE; // -2,147,483,648
Long.MAX_VALUE; // about 9.2 × 10^18Java wraps silently on overflow — no exception. Three places it matters across the 150:
1. Binary search midpoint
int mid = (lo + hi) / 2; // WRONG: lo + hi can overflow
int mid = lo + (hi - lo) / 2; // correctIf lo and hi are both near 2 billion, their sum wraps negative and mid lands outside the array. The safe form only ever computes a difference, which stays in range. This is a famous bug that sat in the JDK's own binary search for years.
2. Accumulating products or sums
long product = a * b; // WRONG: a * b is computed as int, THEN widened
long product = (long) a * b; // correct: cast first, multiply in longJava evaluates a * b using the operand types. Both are int, so the multiplication happens in int and overflows before the assignment widens it. Casting one operand first forces the whole expression into long.
3. Negating Integer.MIN_VALUE
-Integer.MIN_VALUE == Integer.MIN_VALUE; // true (!)
Math.abs(Integer.MIN_VALUE); // returns Integer.MIN_VALUE, still negativeThe int range is asymmetric: it goes down to −2,147,483,648 but only up to +2,147,483,647. So the positive counterpart of MIN_VALUE does not exist, and negating it wraps back to itself.
The fix is to widen before negating:
long n = Math.abs((long) exponent); // Pow(x, n)Interviewers use n = Integer.MIN_VALUE as a test case specifically to check this.
Sentinel values
When you need "infinity" for a minimization:
int best = Integer.MAX_VALUE;
best = Math.min(best, candidate);But be careful adding to it — Integer.MAX_VALUE + 1 wraps to MIN_VALUE, turning your "worst possible" into "best possible". Two fixes:
if (dp[i] != Integer.MAX_VALUE) dp[i] + 1; // guard before adding
int[] dp = new int[n]; Arrays.fill(dp, amount + 1); // or use a large FINITE sentinelThe second is what Coin Change does — amount + 1 is bigger than any real answer but small enough that adding to it is safe.
Array ↔ List conversions
Constant friction. The four you need:
// int[] -> List<Integer>
List<Integer> list = Arrays.stream(nums).boxed().collect(Collectors.toList());
// List<Integer> -> int[]
int[] arr = list.stream().mapToInt(Integer::intValue).toArray();
// T[] -> List<T> (fixed size, backed by the array)
List<String> view = Arrays.asList(strArr);
// List<T> -> T[]
String[] out = list.toArray(new String[0]);
// List<List<Integer>> -> int[][]
int[][] res = list.stream()
.map(l -> l.stream().mapToInt(Integer::intValue).toArray())
.toArray(int[][]::new);Two traps
Arrays.asList(intArray) doesn't do what you expect.
int[] nums = {1, 2, 3};
List<int[]> wrong = Arrays.asList(nums); // a list of ONE element: the array itselfasList takes varargs of objects. An int[] is a single object, so you get a one-element list. Autoboxing applies to individual values, never to whole arrays. Use the stream form.
Arrays.asList and List.of are structurally immutable.
List<Integer> list = Arrays.asList(1, 2, 3);
list.add(4); // UnsupportedOperationException
List<Integer> ok = new ArrayList<>(Arrays.asList(1, 2, 3)); // now mutableArrays.asList returns a view of the array — you can set an element but not change the size. List.of is fully immutable. Wrap in new ArrayList<>(...) when you need to mutate.
2-D arrays
int[][] grid = new int[rows][cols]; // all zeros
int rows = grid.length; // number of rows
int cols = grid[0].length; // number of columns — guard rows > 0 first
int[][] literal = {{1, 2}, {3, 4}};A Java 2-D array is really an array of arrays, so rows are independent objects. Rows can in principle have different lengths (a "jagged" array); assume rectangular unless told otherwise.
int[] rowCopy = grid[i].clone(); // copies one rowThe direction vector
Every grid traversal in the 150 uses this. Instead of writing four near-identical blocks for up/down/left/right, store the offsets and loop:
int[][] DIRS = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}}; // right, left, down, up
for (int[] d : DIRS) {
int nr = r + d[0]; // new row
int nc = c + d[1]; // new column
if (nr < 0 || nr >= rows || nc < 0 || nc >= cols) continue; // off the grid
// ... visit (nr, nc)
}The bounds check must come before using nr/nc as indices, or you get ArrayIndexOutOfBoundsException. Checking all four conditions in one if with continue is the compact idiom.
Equivalent form with fewer allocations:
int[] dr = {0, 0, 1, -1}, dc = {1, -1, 0, 0};
for (int k = 0; k < 4; k++) {
int nr = r + dr[k], nc = c + dc[k];
}For 8 directions (including diagonals), use all pairs except {0,0}.
Recursion mechanics
Java has no output parameters for primitives
Java passes arguments by value — the function gets a copy. So a recursive helper cannot update an int belonging to its caller:
void helper(int count) { count++; } // modifies the COPY; caller sees nothingThree fixes, in order of interview preference:
// 1. Instance field — cleanest to read
private int best = 0;
public int solve(TreeNode root) {
best = 0; // reset! the field may hold a previous run's value
dfs(root);
return best;
}
// 2. A one-element array as a mutable box
int[] best = new int[1];
dfs(root, best); // helper does best[0] = ...
// 3. Return the value and combine at each level <- prefer when it composes
private int dfs(TreeNode node) {
...
return valueForMyParent;
}Option 2 works because the array reference is copied, but both copies point at the same array — so writes through it are visible to the caller.
Pattern 3 combined with a field is the standard tree shape: return one thing to the parent, record a different thing globally. That's Diameter of Binary Tree and Binary Tree Maximum Path Sum (12).
Remember to reset the field at the start of the public method. Leaving stale state between calls is a real bug on platforms that reuse the object across test cases.
Stack depth
Each pending recursive call occupies a frame on the call stack, and the JVM's default stack holds roughly 10,000 frames. Recursing deeper throws StackOverflowError.
That's fine for a balanced tree (depth ~`log n`). It is not fine for:
- a degenerate skewed tree (depth =
n), - a linked list of length 10^5,
- DFS on a 10^5-node graph.
When n ≥ 10^5, mention the iterative alternative — it shows you're thinking about real limits, not just asymptotics.
Small things that save time
Math.max(a, b); Math.min(a, b); Math.abs(x);
Math.pow(a, b); // returns double — cast if you need an int
Math.sqrt(x);
Math.floorDiv(a, b); // rounds toward -infinity; / truncates toward zero
(a + b - 1) / b; // integer CEILING division, for non-negative a and b
Integer.parseInt(s);
Integer.toBinaryString(x);
Integer.bitCount(x); // count of 1-bits
Integer.compare(a, b);Why (a + b - 1) / b is a ceiling: integer division truncates down. Adding b - 1 first pushes any non-zero remainder up over the next multiple. For a = 7, b = 3: (7 + 2) / 3 = 3, which is ceil(7/3). Use this rather than Math.ceil on doubles — floating point rounding causes off-by-one bugs on large values.
Math.floorDiv vs /: for -7 / 2 Java gives -3 (truncating toward zero), while Math.floorDiv(-7, 2) gives -4 (flooring). Matters whenever negative coordinates appear.
Records (Java 16+)
Make pair-carrying readable when the language level allows:
record Pair(int node, int cost) {}
PriorityQueue<Pair> pq = new PriorityQueue<>(Comparator.comparingInt(Pair::cost));
pq.offer(new Pair(3, 10));Confirm the Java version before relying on them. int[] pairs work everywhere and are what most interviewers expect — slightly less readable, universally safe.