What’s one thing you learned? What’s still confusing?
Linked Lists, Part 2: Doubly Linked, Merge & LRU Cache
Doubly linked lists with prev/next pointers, merging two sorted lists, recursive reversal, and a complete LRU cache implementation.
Stacks, Queues & Deque
LIFO and FIFO using lists and deque. MinStack, circular queue, monotonic stack.
Trees & BST, Part 1: Binary Trees & Traversals
Tree terminology, the TreeNode class, BST insert/search, and all four traversals (BFS, inorder, preorder, postorder) recursively and iteratively.
Interactive Labs for This Track
Loop Visualizer
You're a factory robot repeating the same task on an assembly line — watch how loops automate repetitive work
List Slicing
You have a playlist of 50 songs — grab just tracks 10 through 20 with a single slice expression
Sorting Algorithms
You're organizing a library of 10,000 books — which sorting method is fastest?
Ask questions, share insights
collections.deque is a doubly-linked list of arrays. Part 1 covers the singly linked foundation; Part 2 builds on it to cover doubly linked lists, merging, and LRU cache.[10, 20, 30, 40] in RAM:i, you must follow the chain of pointers from the head — there is no direct address calculation.| What You Gain | What You Lose |
|---|---|
| O(1) insert/delete at front or any known node | O(n) to access element at index i |
| No memory reallocation | Extra memory per node (the pointer itself) |
| Predictable insert/delete time (no shifting) | Poor cache performance (pointer chasing) |
Insert and delete nodes, watch pointers update, and step through Floyd's cycle detection. Every pointer operation is animated so you can see what the code is doing in memory.
You have a linked list: 1 → 2 → 3 → 4 → None. What is the time complexity of inserting a new node at index 2 (between 2 and 3)?
class Node:
def __init__(self, data):
self.data = data # The payload — any Python object
self.next = None # Reference to the next Node, or None
def __repr__(self):
return f"Node({self.data!r})"next attribute is just a normal Python variable that happens to hold either:Node instance (like any object reference in Python)None — indicating this is the last node in the chainBuilding a chain manually to see exactly what is happening:
# Create three nodes
a = Node(1)
b = Node(2)
c = Node(3)
# Wire them together with pointer assignments
a.next = b # Node(1).next now points to Node(2)
b.next = c # Node(2).next now points to Node(3)
c.next = None # Node(3) is the tail (next is already None by default)
# Traverse: start at head, follow .next until None
current = a
while current is not None:
print(current.data, end=" -> ")
current = current.next
print("None")
# Output: 1 -> 2 -> 3 -> NoneThe chain in memory:
LinkedList class with every common operation explained line by line:from __future__ import annotations
from typing import Any, Iterator, Optional
class Node:
"""A single node in a linked list."""
def __init__(self, data: Any) -> None:
self.data = data
self.next: Optional[Node] = None
def __repr__(self) -> str:
return f"Node({self.data!r})"
class LinkedList:
"""
Singly linked list with O(1) head/tail access.
Maintains both a head pointer (front of list) and a tail pointer
(end of list) so that append() is O(1) instead of O(n).
"""
def __init__(self) -> None:
self.head: Optional[Node] = None
self._tail: Optional[Node] = None # private tail pointer
self._size: int = 0
# ------------------------------------------------------------------
# Core Mutations
# ------------------------------------------------------------------
def append(self, data: Any) -> None:
"""
Add a new node to the END of the list.
With a tail pointer this is O(1). Without it you would need to
traverse the entire list to find the last node — O(n).
"""
new_node = Node(data)
if self.head is None:
# Empty list: head and tail both point to the single node
self.head = new_node
self._tail = new_node
else:
# Link the current tail to the new node, then advance tail
self._tail.next = new_node # type: ignore[union-attr]
self._tail = new_node
self._size += 1
def prepend(self, data: Any) -> None:
"""
Add a new node to the FRONT of the list.
Always O(1) — we only touch the head pointer, no traversal needed.
This is the operation where linked lists beat arrays.
"""
new_node = Node(data)
new_node.next = self.head # new node points to old head
self.head = new_node # head is now the new node
if self._tail is None:
# Was an empty list; tail = new node too
self._tail = new_node
self._size += 1
def insert(self, index: int, data: Any) -> None:
"""
Insert data BEFORE the node at the given index.
O(n) — must traverse to index - 1 to splice in the new node.
Raises IndexError if index is out of range.
"""
if index < 0 or index > self._size:
raise IndexError(f"Index {index} out of range for list of size {self._size}")
if index == 0:
self.prepend(data)
return
if index == self._size:
self.append(data)
return
# Walk to the node BEFORE the insertion point
new_node = Node(data)
current = self.head
for _ in range(index - 1):
current = current.next # type: ignore[union-attr]
# Splice: new_node.next = what was at index, predecessor.next = new_node
new_node.next = current.next # type: ignore[union-attr]
current.next = new_node # type: ignore[union-attr]
self._size += 1
def delete(self, data: Any) -> bool:
"""
Delete the FIRST node whose .data equals the given value.
Returns True if a node was deleted, False if value not found.
O(n) — must scan from head.
"""
if self.head is None:
return False
# Special case: head contains the target value
if self.head.data == data:
self.head = self.head.next
if self.head is None:
self._tail = None # list is now empty
self._size -= 1
return True
# General case: walk until we find the predecessor of the target node
current = self.head
while current.next is not None:
if current.next.data == data:
if current.next is self._tail:
self._tail = current # deleted node was the tail
current.next = current.next.next # bypass the target node
self._size -= 1
return True
current = current.next
return False # value not found
def delete_at(self, index: int) -> Any:
"""
Delete and return the value at the given index.
O(n) — must traverse to index - 1.
Raises IndexError if index is out of range.
"""
if index < 0 or index >= self._size:
raise IndexError(f"Index {index} out of range for list of size {self._size}")
if index == 0:
value = self.head.data # type: ignore[union-attr]
self.head = self.head.next # type: ignore[union-attr]
if self.head is None:
self._tail = None
self._size -= 1
return value
current = self.head
for _ in range(index - 1):
current = current.next # type: ignore[union-attr]
target = current.next # type: ignore[union-attr]
value = target.data # type: ignore[union-attr]
current.next = target.next # type: ignore[union-attr]
if target is self._tail:
self._tail = current
self._size -= 1
return value
# ------------------------------------------------------------------
# Queries
# ------------------------------------------------------------------
def search(self, data: Any) -> int:
"""
Return the index of the first node with the given value.
Returns -1 if not found. O(n).
"""
current = self.head
index = 0
while current is not None:
if current.data == data:
return index
current = current.next
index += 1
return -1
def get(self, index: int) -> Any:
"""
Return the value at the given index. O(n).
Raises IndexError if index is out of range.
"""
if index < 0 or index >= self._size:
raise IndexError(f"Index {index} out of range for list of size {self._size}")
current = self.head
for _ in range(index):
current = current.next # type: ignore[union-attr]
return current.data # type: ignore[union-attr]
# ------------------------------------------------------------------
# Reversal — O(n) with three-pointer technique
# ------------------------------------------------------------------
def reverse(self) -> None:
"""
Reverse the list in-place using the classic three-pointer technique.
We maintain three pointers: prev, curr, next_node.
At each step we flip curr.next to point BACKWARD, then advance.
Before: head → 1 → 2 → 3 → None
After: head → 3 → 2 → 1 → None
O(n) time, O(1) space.
"""
prev = None
curr = self.head
self._tail = self.head # old head becomes new tail
while curr is not None:
next_node = curr.next # save next before overwriting
curr.next = prev # flip the pointer backward
prev = curr # advance prev
curr = next_node # advance curr
self.head = prev # prev is now the last node we processed = new head
# ------------------------------------------------------------------
# Python Protocol Methods
# ------------------------------------------------------------------
def __len__(self) -> int:
"""O(1) because we maintain a size counter."""
return self._size
def __str__(self) -> str:
"""Human-readable: '1 -> 2 -> 3 -> None'"""
parts = []
current = self.head
while current is not None:
parts.append(str(current.data))
current = current.next
parts.append("None")
return " -> ".join(parts)
def __repr__(self) -> str:
return f"LinkedList([{', '.join(str(n) for n in self)}])"
def __iter__(self) -> Iterator[Any]:
"""Make the list iterable — enables: for x in ll: ..."""
current = self.head
while current is not None:
yield current.data
current = current.next
def __contains__(self, data: Any) -> bool:
return self.search(data) != -1
def __getitem__(self, index: int) -> Any:
return self.get(index)
# ------------------------------------------------------------------
# Conversions
# ------------------------------------------------------------------
def to_list(self) -> list:
"""Convert to a regular Python list. O(n)."""
return list(self)
@classmethod
def from_list(cls, lst: list) -> "LinkedList":
"""Create a LinkedList from a Python list. O(n)."""
ll = cls()
for item in lst:
ll.append(item)
return ll# Build a list
ll = LinkedList.from_list([10, 20, 30, 40, 50])
print(ll) # 10 -> 20 -> 30 -> 40 -> 50 -> None
print(len(ll)) # 5
# Prepend is O(1)
ll.prepend(5)
print(ll) # 5 -> 10 -> 20 -> 30 -> 40 -> 50 -> None
# Insert in the middle
ll.insert(3, 25) # insert 25 before index 3 (which is 30)
print(ll) # 5 -> 10 -> 20 -> 25 -> 30 -> 40 -> 50 -> None
# Delete by value
ll.delete(25)
print(ll) # 5 -> 10 -> 20 -> 30 -> 40 -> 50 -> None
# Reverse
ll.reverse()
print(ll) # 50 -> 40 -> 30 -> 20 -> 10 -> 5 -> None
# Iteration works naturally
total = sum(ll)
print(total) # 155
# Conversion
print(ll.to_list()) # [50, 40, 30, 20, 10, 5]The three-pointer reversal is one of the most important linked list operations to internalize:
Three-Pointer Reversal — Step by Step
| Operation | Linked List (with tail) | Python list |
|---|---|---|
Access by index lst[i] | O(n) — must traverse | O(1) — direct pointer arithmetic |
| Search (unsorted) | O(n) | O(n) |
| Insert at front | O(1) | O(n) — must shift all elements |
| Insert at end | O(1) amortized | O(1) amortized |
| Insert in middle | O(n) traverse + O(1) splice | O(n) traverse + O(n) shift |
| Delete at front | O(1) | O(n) — must shift all elements |
list 99% of the time. Here are the rare cases where linked list thinking is justified:from collections import deque
# Case 1: You need O(1) popleft AND O(1) append — use deque (doubly LL internally)
queue = deque()
queue.append("task_1") # O(1) right append
queue.append("task_2")
first = queue.popleft() # O(1) left pop — Python list would be O(n)
# Case 2: Implementing an LRU Cache (OrderedDict + deque logic)
# Case 3: Undo/Redo where operations happen at specific history positions
# Case 4: When you hold a direct reference to a node and need O(1) removalll[500000] traverses 500,000 nodes while lst[500000] is a single memory address calculation.def has_cycle(head: Optional[Node]) -> bool:
"""
Detect a cycle in O(n) time, O(1) space.
Slow pointer moves 1 step, fast pointer moves 2 steps.
If they meet, there is a cycle.
"""
slow = head
fast = head
while fast is not None and fast.next is not None:
slow = slow.next # 1 step
fast = fast.next.next # 2 steps
if slow is fast:
return True # pointers met — cycle confirmed
return False # fast reached None — no cycle
def find_cycle_start(head: Optional[Node]) -> Optional[Node]:
"""
Find the node where the cycle begins.
Phase 1: detect the cycle (same as above).
Phase 2: move one pointer back to head, advance both at speed 1.
They will meet at the cycle entry point.
"""
slow = fast = head
# Phase 1: find the meeting point
while fast is not None and fast.next is not None:
slow = slow.next
fast = fast.next.next
if slow is fast:
break
else:
return None # no cycle
# Phase 2: find the start
slow = head
while slow is not fast:
slow = slow.next
fast = fast.next
return slow # cycle start nodedef find_middle(head: Optional[Node]) -> Optional[Node]:
"""
Return the middle node in one pass.
Fast pointer moves 2x — when fast reaches end, slow is at middle.
For even-length list, returns the second middle node.
O(n) time, O(1) space.
"""
slow = fast = head
while fast is not None and fast.next is not None:
slow = slow.next
fast = fast.next.next
return slow
# Example:
# 1 -> 2 -> 3 -> 4 -> 5 -> None => returns Node(3)
# 1 -> 2 -> 3 -> 4 -> None => returns Node(3) (second middle)Watch linked list operations step by step. See append, prepend, insert, delete, reverse, and Floyd's cycle detection animated in real time:
Tests · assert str(ll) == '3 -> 1 -> 0 -> None'; assert len(ll) == 3; ll2 = LinkedList(); ll2.append(5); assert len(ll2) == 1
list[i] is one pointer dereference and runs in O(1); use collections.deque when you need O(1) operations at both ends.next pointers without allocating new nodes — O(n) time and O(1) extra spaceWhat is the time complexity of `prepend(data)` in a singly linked list?
Array (Contiguous Memory)
Linked List (Scattered in Memory)
Three Nodes Wired Together
| Delete at end (singly LL) |
| O(n) — can't find new tail cheaply |
| O(1) amortized |
| Delete at end (doubly LL) | O(1) | O(1) amortized |
| Delete in middle | O(n) | O(n) |
| Memory per element | More (value + pointer overhead) | Less (contiguous array of pointers) |
| Cache performance | Poor (pointer chasing) | Excellent (sequential memory reads) |