JAVA / COLLECTIONS
LinkedList structure and its real access cost
Explain how a LinkedList stores elements as linked nodes, predict the real cost of get(i), and choose iterator-based access over quadratic loops.
What you will learn
- Read get(i) on a LinkedList as 'walk up to n/2 nodes', not as an array index
- Swap indexed for loops for for-each or ListIterator to keep a traversal linear
- Use addFirst/addLast/removeFirst/removeLast or a positioned ListIterator for O(1) edits
- Predict when ArrayList beats LinkedList even though both look O(n)
Understanding LinkedList structure and its real access cost
A java.util.LinkedList is not a row of slots but a chain of small objects. Each element sits in its own Node, which holds the item plus a next and a prev reference, while the list object itself keeps only first, last and size. Nothing in that layout lets the JVM compute where element 7 lives, so there is no address arithmetic to perform: the only way to reach a position is to start at an end and follow references one at a time.
get(int) exists because LinkedList implements List, and it hides a loop. The lookup compares the index against size >> 1 and walks forward from first or backward from last, whichever end is nearer, so one get costs up to about n/2 hops and the middle index is the worst case rather than the last one. Halving the walk changes the constant, not the complexity: an indexed loop calling get(i) restarts the walk every iteration and visits roughly n squared over 4 nodes, where a for-each keeps a single cursor and visits n. The same hidden walk lives inside add(index, e), remove(int), remove(Object), indexOf and contains, so "linked lists insert in constant time" is only true once you already hold the position.
Past complexity there is a hardware cost. Every element needs a separate heap object with roughly 24 bytes of bookkeeping, and those nodes land wherever the allocator put them, so each hop is a dependent load the CPU cannot prefetch and you risk a cache miss per element even during a clean for-each. An ArrayList keeps references contiguous and shifts them with System.arraycopy, which is why it usually wins even on mid-list inserts: moving a block of memory is cheaper than chasing pointers to find where to insert. LinkedList earns its keep when you hold a cursor and splice repeatedly, or when you want a List that is also a Deque and never index into it.
import java.util.LinkedList;
import java.util.List;
public class LinkedListAccessCost {
// Mirrors LinkedList's own node lookup: walk from whichever end is nearer.
static int nodesVisited(int size, int index) {
return index < (size >> 1) ? index + 1 : size - index;
}
public static void main(String[] args) {
List<String> chain = new LinkedList<>();
for (int i = 0; i < 10; i++) {
chain.add("n" + i);
}
System.out.println("size = " + chain.size());
for (int index : new int[] {0, 4, 5, 9}) {
System.out.println("get(" + index + ") -> " + chain.get(index)
+ ", nodes visited: " + nodesVisited(chain.size(), index));
}
int indexedLoop = 0;
for (int i = 0; i < chain.size(); i++) {
indexedLoop += nodesVisited(chain.size(), i);
}
System.out.println("indexed loop visits " + indexedLoop + " nodes");
System.out.println("for-each loop visits " + chain.size() + " nodes");
}
}A LinkedList has no address to compute, so every position except the two ends must be reached by walking node by node.
Worked examples
Editing at a cursor instead of at an index
Shows that a ListIterator already standing at a node can splice in constant time, and that prev links make backward traversal just as cheap as forward.
import java.util.LinkedList;
import java.util.List;
import java.util.ListIterator;
public class PositionedEdits {
public static void main(String[] args) {
LinkedList<String> words = new LinkedList<>(List.of("a", "b", "c", "d"));
ListIterator<String> it = words.listIterator();
while (it.hasNext()) {
String w = it.next();
if (w.equals("b") || w.equals("d")) {
it.add(w.toUpperCase());
}
}
System.out.println(words);
ListIterator<String> back = words.listIterator(words.size());
StringBuilder reversed = new StringBuilder();
while (back.hasPrevious()) {
reversed.append(back.previous());
}
System.out.println(reversed);
}
}Example explained
Line 1it.add(w.toUpperCase()) rewires two references between the cursor's neighbours, with no index arithmetic and no walk, so the insert really is constant time.
Line 2The new node lands directly after the element just returned by next(), which is why B follows b and the loop's next call still returns c.
Line 3ListIterator.add updates the iterator's own expectedModCount, so inserting this way is legal, unlike calling words.add(i, x) during a for-each.
Line 4words.listIterator(words.size()) parks the cursor past the last node, and previous() then follows prev links, so reverse traversal costs the same as forward traversal.
Ends are cheap, the middle is not
Contrasts the operations that only touch the first and last fields with the index-based calls that hide a walk.
import java.util.LinkedList;
import java.util.List;
public class EndsVersusMiddle {
public static void main(String[] args) {
LinkedList<Integer> nums = new LinkedList<>();
for (int i = 1; i <= 6; i++) {
nums.addLast(i);
}
StringBuilder order = new StringBuilder();
while (!nums.isEmpty()) {
order.append(nums.removeFirst());
if (!nums.isEmpty()) {
order.append(nums.removeLast());
}
}
System.out.println("drained from both ends: " + order);
LinkedList<Integer> mid = new LinkedList<>(List.of(10, 20, 30, 40));
mid.add(2, 25);
mid.addFirst(5);
System.out.println(mid);
System.out.println("indexOf(30) = " + mid.indexOf(30));
}
}Example explained
Line 1removeFirst() and removeLast() read the stored first and last references and fix one neighbour link, so alternating from both ends costs the same per element at any size.
Line 2mid.add(2, 25) is not constant time: it walks to the node at index 2 exactly as get(2) would, then splices.
Line 3mid.addFirst(5) performs no walk at all, which is why the two ends are the only positions with an honest constant-time guarantee.
Line 4mid.indexOf(30) compares from the head and reports 4 only after visiting five nodes, so searching by value carries the same walk as searching by index.
Important notes
Because the walk starts from the nearer end, get(size / 2) is the expensive call and get(size - 1) is nearly free; a fast last element is not evidence of random access.
LinkedList is unsynchronized and spends roughly 24 bytes per element on node bookkeeping on a 64-bit JVM with compressed object pointers, several times what an ArrayList spends holding the same references.
Common mistakes
Looping with get(i) over a large LinkedList: on 100,000 elements that is about 2.5 billion node hops, so a pass that should take milliseconds looks like a hang.
Assuming add(index, e) is constant time. The splice is, but reaching index is a walk, so a loop of mid-list inserts is quadratic just like a get(i) loop.
Calling list.add or list.remove inside a for-each over the same list: the next iteration throws ConcurrentModificationException, and the fix is the ListIterator's own add and remove.
Try it yourself
Change, predict, then run
Fill a LinkedList with 20,000 Integers and sum it twice, once with an indexed get(i) loop and once with a for-each, printing a System.nanoTime delta for each. Then change 20,000 to 40,000 and confirm the indexed time grows about fourfold while the for-each time only doubles.
Open the Java workspaceCheck your understanding
A LinkedList holds 1,000,000 elements. Which single call makes the JVM touch the most nodes?
- get(0)
- get(500_000)
- get(999_999)
- addFirst(x)
Show answer
The internal lookup starts from first when the index is below size >> 1 and from last otherwise, so get(500_000) walks about half a million links and the middle is the worst case. get(999_999) looks worse but is reached in one step backwards from the stored last reference, and addFirst never walks at all.