Linked Lists Explained: A Visual, Beginner-to-Expert Guide (Java & Kotlin)
A linked list is nothing more than boxes and arrows: a piece of data, and a note telling you where the next piece lives. That's it. Everything else in this guide is just consequences of that one idea. We'll build it up node by node, with a diagram for every move, from your first "what even is this" to writing cycle-detection code from memory.
Why not just use an array?
Before drawing a single node, it helps to know the problem a linked list is solving. Arrays are great — until you need to grow, shrink, or rearrange them a lot.
Array in memory
An array is one continuous block of memory. That makes reading element [i] instant — the computer just does math on the address. But inserting a new value in the middle means physically sliding every element after it over by one slot.
Linked list in memory
A linked list's nodes can live anywhere in memory. Nothing has to move when you insert — you just rewrite two arrows. The trade-off: to reach node 5, you must walk through nodes 1 through 4 first. There is no "jump straight to index i."
Reach for arrays when you read a lot by position. Reach for linked lists when you insert and remove a lot, especially at the ends, and you're fine walking node-by-node to get anywhere else.
Anatomy of a single node
Every node is a tiny two-part container. Learn this shape and the rest of the guide is just this shape, repeated.
In code, that's a small class or struct with two fields. Nothing more.
Node — 2 fields
Java
class Node {
int data;
Node next; // reference to the next node, or null
Node(int data) {
this.data = data;
this.next = null;
}
}
Kotlin
class Node(var data: Int, var next: Node? = null)
// next is nullable — null means "nothing comes after me"
That's the entire vocabulary. A linked list itself is just a class that remembers where the chain starts:
LinkedList — the entry point
Java
class LinkedList {
Node head; // the only thing we truly need to remember
int size = 0;
}
Kotlin
class LinkedList {
var head: Node? = null // the only thing we truly need to remember
var size = 0
}
The singly linked list, assembled
Chain a few nodes together and you have a singly linked list — "singly" because each node only points forward, never back. There's no way to walk backward once you've stepped off a node.
head is the only door into the list. Every single operation — insert, delete, search — starts by walking from head, because that's the only address the list itself remembers.
Insert & delete: rewiring two arrows
This is the part that actually earns a linked list its keep. Every insert or delete is just: point the new arrow, then let go of the old one. Nothing shifts.
Insert at the head
Cheapest operation in the whole structure — O(1), no walking required.
insertAtHead — O(1)
Java
void insertAtHead(int value) {
Node newNode = new Node(value);
newNode.next = head; // point forward into the old chain
head = newNode; // then move the door
size++;
}
Kotlin
fun insertAtHead(value: Int) {
val newNode = Node(value, next = head) // point forward into the old chain
head = newNode // then move the door
size++
}
Insert at the tail
You have to walk to the last node first — O(n) — then attach after it.
insertAtTail — O(n)
Java
void insertAtTail(int value) {
Node newNode = new Node(value);
if (head == null) {
head = newNode;
size++;
return;
}
Node current = head;
while (current.next != null) { // walk until the last node
current = current.next;
}
current.next = newNode; // attach after it
size++;
}
Kotlin
fun insertAtTail(value: Int) {
val newNode = Node(value)
if (head == null) {
head = newNode
size++
return
}
var current = head
while (current?.next != null) { // walk until the last node
current = current.next
}
current?.next = newNode // attach after it
size++
}
Delete a node
Deleting isn't destruction — it's just skipping. Point the previous node's arrow past the one you're removing, and it quietly falls out of reach.
delete — O(n)
Java
void delete(int value) {
if (head == null) return;
if (head.data == value) { // special case: removing the head
head = head.next;
size--;
return;
}
Node current = head;
while (current.next != null && current.next.data != value) {
current = current.next;
}
if (current.next != null) {
current.next = current.next.next; // leapfrog the target
size--;
}
}
Kotlin
fun delete(value: Int) {
if (head == null) return
if (head?.data == value) { // special case: removing the head
head = head?.next
size--
return
}
var current = head
while (current?.next != null && current.next?.data != value) {
current = current.next
}
current?.next?.let {
current.next = it.next // leapfrog the target
size--
}
}
Removing the head is always a special case — there's no "previous node" whose arrow you can rewire, so you just move head itself. Beginners' bugs almost always live in this one branch.
Search & traverse
No shortcuts here — to find anything, you follow the arrows one at a time until you either find it or fall off the end.
contains / traverse — O(n)
Java
boolean contains(int value) {
Node current = head;
while (current != null) {
if (current.data == value) return true;
current = current.next; // step forward
}
return false;
}
void printAll() {
Node current = head;
while (current != null) {
System.out.print(current.data + " -> ");
current = current.next;
}
System.out.println("NULL");
}
Kotlin
fun contains(value: Int): Boolean {
var current = head
while (current != null) {
if (current.data == value) return true
current = current.next // step forward
}
return false
}
fun printAll() {
var current = head
val sb = StringBuilder()
while (current != null) {
sb.append(current.data).append(" -> ")
current = current.next
}
println(sb.append("NULL"))
}
This is the pattern behind almost every linked-list function you'll ever write: current = head, loop while current != null, step with current = current.next. Memorize that shape and the rest is details.
Doubly linked lists
Give every node a second arrow pointing backward, and you can walk the list in either direction — at the cost of one extra pointer to keep correct on every operation.
DoublyNode — 3 fields
Java
class DoublyNode {
int data;
DoublyNode next;
DoublyNode prev;
DoublyNode(int data) {
this.data = data;
}
}
Kotlin
class DoublyNode(
var data: Int,
var next: DoublyNode? = null,
var prev: DoublyNode? = null
)
Deleting a node you already have a reference to becomes O(1) instead of O(n) — you don't need to walk from the head to find "the one before it," because prev already tells you. That's exactly why LinkedList in Java's standard library, and most "undo" or "browser history" implementations, are doubly linked under the hood.
Circular linked lists
Take the last node's arrow and, instead of pointing to NULL, point it back to the head. Now there's no end — just a loop.
Handy anywhere something repeats in a cycle with no natural "end": a music playlist on repeat, round-robin CPU scheduling, or a multiplayer game rotating through players' turns.
A plain while (current != null) traversal never terminates on a circular list — there is no null to hit. You either loop a known number of times, or stop when you arrive back at the node you started from.
The cost sheet
Same operations, four structures. This table is the one thing worth memorizing before an interview.
| Operation | Array | Singly linked | Doubly linked | Circular |
|---|---|---|---|---|
| Access by index | O(1) | O(n) | O(n) | O(n) |
| Search by value | O(n) | O(n) | O(n) | O(n) |
| Insert at head | O(n) | O(1) | O(1) | O(1) |
| Insert at tail | O(1)* | O(n) | O(1)** | O(1)** |
| Delete, node in hand | O(n) | O(n) | O(1) | O(1) |
| Extra memory / node | none | 1 pointer | 2 pointers | 1–2 pointers |
* amortized, assumes room at the end. ** when a tail reference is kept.
Where this actually shows up
Not just an interview topic — you're using linked lists constantly without noticing.
Undo / redo
Each edit is a node; undo just walks backward one step, redo walks forward.
Browser history
Back and forward buttons are a doubly linked list of visited pages.
Music queue
"Next track" and "on repeat" map naturally onto singly and circular lists.
OS task scheduling
Round-robin schedulers rotate through processes with a circular list.
Hash map buckets
Chained hash tables resolve collisions by hanging a tiny linked list off each bucket.
LRU caches
A doubly linked list plus a hash map gives O(1) "move to front" — the classic LRU cache design.
Practice problems
These three questions cover most of what actually gets asked. Try each one on paper before opening the answer.
Reverse a singly linked list, in placeWalk forward once, but at each node flip its arrow to point backward instead of forward. You need three pointers marching together: the node before, the current node, and a temporary hold on "next" before you overwrite it.
reverse — O(n) time, O(1) space
Java
Node reverse(Node head) {
Node prev = null;
Node current = head;
while (current != null) {
Node next = current.next; // save before we overwrite it
current.next = prev; // flip the arrow
prev = current;
current = next;
}
return prev; // prev is now the new head
}
Kotlin
fun reverse(head: Node?): Node? {
var prev: Node? = null
var current = head
while (current != null) {
val next = current.next // save before we overwrite it
current.next = prev // flip the arrow
prev = current
current = next
}
return prev // prev is now the new head
}
Two runners on the same track, one moving twice as fast. If the list loops, the fast one eventually laps the slow one and they meet. If the list ends cleanly, the fast runner hits null first. This is Floyd's cycle-detection algorithm, nicknamed "tortoise and hare."
hasCycle — O(n) time, O(1) space
Java
boolean hasCycle(Node head) {
Node slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) return true; // they lapped each other
}
return false;
}
Kotlin
fun hasCycle(head: Node?): Boolean {
var slow = head
var fast = head
while (fast?.next != null) {
slow = slow?.next
fast = fast.next?.next
if (slow === fast) return true // they lapped each other
}
return false
}
Same tortoise-and-hare idea, minus the cycle. When the fast runner reaches the end, the slow one is standing exactly in the middle — because it only covered half the distance.
findMiddle — O(n) time, O(1) space
Java
Node findMiddle(Node head) {
Node slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
}
return slow; // standing at the midpoint
}
Kotlin
fun findMiddle(head: Node?): Node? {
var slow = head
var fast = head
while (fast?.next != null) {
slow = slow?.next
fast = fast.next?.next
}
return slow // standing at the midpoint
}
Expert notes: what the diagrams don't show
Everything above is correct, and also slightly idealized. Here's what changes once you're optimizing for real hardware.
Cache locality
Arrays win on real hardware more often than Big-O suggests, because array elements sit next to each other in memory and the CPU's cache loads them in bulk. Linked-list nodes are scattered, so every .next can be a fresh cache miss. In practice, a well-tuned array or a hybrid structure often outperforms a linked list even for insert-heavy workloads at small-to-medium sizes.
Skip lists
A skip list stacks several linked lists on top of each other, each one skipping more nodes than the last — like an express lane above the local lane. It gives you O(log n) search in a linked structure, which is how Redis's sorted sets and some databases implement ordered, insertable collections without a balanced tree.
Sentinel (dummy) nodes
A permanent, empty node placed before the real head removes the "is this the head?" special case from every insert and delete. Many production implementations use one purely to delete a branch of bugs.
When to actually avoid it
If you mostly read by index, binary search, or need cache-friendly iteration over millions of elements — use an array or a growable array (ArrayList/ArrayDeque). Reach for a linked list specifically when you're inserting or removing from the ends or middle far more often than you're reading by position.
Comments (0)
Sign in to leave a comment.
No comments yet. Be the first to share your thoughts.