Data Structures, Algorithms and Programming Patterns · Part 3 of 9
Data Structures and Algorithms, Part 3: Stacks, Queues, Deques and Linked Lists
Some data structures are defined less by how they store things and more by the rules they enforce. This part covers linked lists, which are built from nodes and pointers, and then stacks, queues and deques, which control the order in which things come out. You will build each one, see its cost, and solve classic problems with them.
- A linked list is a chain of nodes; adding at the head is
O(1), searching isO(n). - Reversing, finding the middle and detecting a cycle all come down to careful pointer moves, often with a slow and a fast pointer.
- A stack is LIFO (push, pop, peek); a queue is FIFO (enqueue, dequeue); a deque works at both ends. Kotlin’s
ArrayDequecovers all three. - Arrays usually beat linked lists in practice because of cache and memory, but linked nodes are the base of trees and graphs.
Contents
1. Linked lists: the idea
In Part 2 you met the array: items stored side by side in one block of memory. That layout makes reading arr[5] instant, but it makes inserting at the front slow, because every other item has to shuffle along.
A linked list takes the opposite deal. Items are not side by side. Each item lives in its own small box called a node, and each node holds two things: the value, and a pointer (also called a reference) to the next node. A pointer is just the address of another object. The last node points to nothing, which we write as null.
Think of a treasure hunt. Each clue tells you what is here and where the next clue is hidden. You cannot jump to clue number 5. You must follow clues 1, 2, 3 and 4 first. But adding a new clue in the middle is easy: change one note to point to the new clue, and let the new clue point onward.
head, a pointer to the first node.Because we only hold head, every other node is reachable only by walking. That one fact explains the whole cost table later in this part.
Inserting at the front
To add a new first item, make a node, point it at the old head, then move head to the new node. Two pointer changes, no shuffling, so it is O(1).
2. Build a singly linked list in Kotlin
We need just one small class. A node has a value and a nullable next. In Kotlin, Node? means “a Node or null”, which is exactly what a pointer that might point to nothing needs.
class Node(var value: Int, var next: Node? = null)
Then a list class that remembers head and the size. Read the comments: each method says what it costs.
Run it: your own linked list
Run it, then try removing the last item, and removing from an empty list. Those edge cases are where linked-list bugs hide.
class Node(var value: Int, var next: Node? = null)
class MyLinkedList {
var head: Node? = null
var size = 0
private set
// O(1): the new node becomes the head.
fun addFirst(value: Int) {
head = Node(value, head)
size++
}
// O(n): we must walk to the end, because we only hold the head.
fun addLast(value: Int) {
val node = Node(value)
val first = head
if (first == null) {
head = node
} else {
var cur: Node = first
while (cur.next != null) cur = cur.next!!
cur.next = node
}
size++
}
// O(n): walk until we find it (worst case, it is last or missing).
fun contains(value: Int): Boolean {
var cur = head
while (cur != null) {
if (cur.value == value) return true
cur = cur.next
}
return false
}
// O(n) to find it, O(1) to unlink it.
fun remove(value: Int): Boolean {
val first = head ?: return false
if (first.value == value) { // removing the head is a special case
head = first.next
size--
return true
}
var prev: Node = first
while (true) {
val cur = prev.next ?: return false
if (cur.value == value) {
prev.next = cur.next // skip over it: one pointer change
size--
return true
}
prev = cur
}
}
override fun toString(): String {
val parts = ArrayList<String>()
var cur = head
while (cur != null) { parts.add(cur.value.toString()); cur = cur.next }
return parts.joinToString(" -> ", postfix = " -> null")
}
}
fun main() {
val list = MyLinkedList()
list.addFirst(3)
list.addFirst(7) // 7 -> 3
list.addLast(9) // 7 -> 3 -> 9
println(list)
println("contains 3? ${list.contains(3)} contains 4? ${list.contains(4)}")
println("remove 3: ${list.remove(3)} -> $list")
println("remove 7: ${list.remove(7)} -> $list (that was the head)")
println("remove 42: ${list.remove(42)} size=${list.size}")
}
Open in Kotlin Playground ↗
Notice how remove works. To delete a node we need the node before it, so that we can make that earlier node point past the victim. That is why the loop tracks prev.
| Operation | Cost | Why |
|---|---|---|
| Add at head | O(1) | Two pointer changes. |
| Add at tail (only head kept) | O(n) | Walk to the end first. |
| Find a value | O(n) | Follow pointers one by one. |
| Get the i-th item | O(n) | No jumping; walk i steps. |
| Remove the head | O(1) | Move head forward. |
| Remove a value | O(n) | Find it, then unlink in O(1). |
Tail pointer. If the list also keeps a pointer to the last node, adding at the tail becomes O(1). Removing the tail is still O(n) in a singly linked list, because you need the node before it.
3. Three classic list tricks: reverse, middle, cycle
These three small problems appear in almost every course, because they teach you to think in pointers. Draw them on paper before you code them.
Reversing a list
Walk down the list and flip each pointer to face backwards. You need three variables: prev (the already-reversed part), cur (the node you are fixing) and next (saved first, so you do not lose the rest of the list).
Finding the middle
Use two pointers that start together. The fast pointer moves two nodes per step, the slow pointer moves one. When fast reaches the end, slow is halfway. One pass, no need to know the length.
Run it: reverse a list and find its middle
For an even number of nodes there are two middles; this version returns the second one.
class Node(var value: Int, var next: Node? = null)
fun fromList(items: List<Int>): Node? {
var head: Node? = null
for (x in items.reversed()) head = Node(x, head)
return head
}
fun toList(head: Node?): List<Int> {
val out = ArrayList<Int>()
var cur = head
while (cur != null) { out.add(cur.value); cur = cur.next }
return out
}
fun reverse(head: Node?): Node? {
var prev: Node? = null
var cur = head
while (cur != null) {
val next = cur.next // 1. remember the rest
cur.next = prev // 2. flip the pointer
prev = cur // 3. move both forward
cur = next
}
return prev // prev ends on the old last node: the new head
}
fun middle(head: Node?): Node? {
var slow = head
var fast = head
while (fast?.next != null) {
slow = slow!!.next
fast = fast.next!!.next
}
return slow
}
fun main() {
val list = fromList(listOf(1, 2, 3, 4, 5))
println("middle of 1..5 = ${middle(list)?.value}") // 3
println("middle of 1..4 = ${middle(fromList(listOf(1, 2, 3, 4)))?.value}") // 3 (second of two)
println("reversed: ${toList(reverse(list))}")
println("reverse empty: ${toList(reverse(null))}")
}
Open in Kotlin Playground ↗
Detecting a cycle
Normally the last node points to null. If a bug makes some node point back to an earlier one, the list becomes a loop, and walking it would never end. How can you tell without walking forever?
The two-speed trick again, known as Floyd’s tortoise and hare. Slow moves one step, fast moves two. If there is no cycle, fast falls off the end. If there is a cycle, both run round it, and the fast one gains one step per lap on the slow one until they stand on the same node.
Two runners on a circular track. The faster runner eventually laps the slower one, so they must meet. On a straight road with an end, the fast runner just finishes first.
Run it: tortoise and hare
Note ===. We care whether two references are the same node, not whether two nodes hold equal values.
class Node(var value: Int, var next: Node? = null)
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 // === compares identity: the very same node
}
return false
}
fun main() {
val a = Node(1); val b = Node(2); val c = Node(3); val d = Node(4)
a.next = b; b.next = c; c.next = d
println("straight list: ${hasCycle(a)}") // false
d.next = b // close a loop: 4 points back to 2
println("with a loop: ${hasCycle(a)}") // true
println("empty list: ${hasCycle(null)}") // false
}
Open in Kotlin Playground ↗
Why not just remember visited nodes? A hash set of visited nodes also works, in O(n) time, but it costs O(n) memory. Two pointers need only O(1). Another example of trading space for time, in reverse.
4. Doubly linked lists, and lists versus arrays
A doubly linked list gives each node two pointers: next and prev. You can walk both ways, and if you already hold a node, you can remove it in O(1) without hunting for its predecessor.
The price is one extra pointer per node, and a bit more care when changing links (four pointer updates instead of two). Java’s LinkedList is a doubly linked list. Real uses include an editor’s list of lines, a browser’s back and forward history, and the least-recently-used cache.
class DNode(var value: Int) {
var prev: DNode? = null
var next: DNode? = null
}
// Insert `node` right after `at`: four pointer updates.
fun insertAfter(at: DNode, node: DNode) {
node.prev = at
node.next = at.next
at.next?.prev = node
at.next = node
}
// Unlink a node we already hold: O(1).
fun unlink(node: DNode) {
node.prev?.next = node.next
node.next?.prev = node.prev
}
So, lists or arrays?
On paper, linked lists win at inserting and deleting in the middle (once you hold the spot). Arrays win at reading by position. In practice, arrays usually win overall, for two reasons:
- Cache. A computer loads memory in chunks. Array items sit side by side, so one load brings in many neighbours for free. Linked nodes are scattered, so each step may need a fresh, slow trip to memory.
- Memory. Every node carries a pointer, and often an object header too. A list of numbers can take several times the memory of an array of the same numbers.
| Use a… | when… |
|---|---|
Array / ArrayList | You read by position, loop often, or just want a sensible default. |
| Linked list | You add and remove at the ends or at spots you already hold, you never need position lookup, and you want no big resize copies. |
Be honest about the default. In most real Kotlin code you will reach for ArrayList or ArrayDeque, not a linked list. We build linked lists to learn pointers, which trees (Part 4) and graphs (Part 5) are made of.
5. Stacks: last in, first out
A stack allows work at one end only, called the top. The last item you put in is the first one to come out. This rule is called LIFO: last in, first out.
A pile of plates. You put a clean plate on top and you take a plate from the top. Nobody pulls one from the bottom.
A stack has three core operations:
push(x): putxon top.pop(): remove and return the top item.peek(): look at the top item without removing it.
Implementing a stack
You do not need to write a node class. Kotlin’s ArrayDeque is a resizable array that is fast at both ends, so a stack is just addLast to push and removeLast to pop. Wrapping it in a small class makes the intent obvious.
Run it: a stack, and an undo button
Undo works because the most recent change is the first one you want to reverse. That is exactly LIFO.
class Stack<T> {
private val items = ArrayDeque<T>()
fun push(x: T) = items.addLast(x) // O(1) on average
fun pop(): T = items.removeLast() // O(1); throws if empty
fun peek(): T? = items.lastOrNull() // O(1); null if empty
fun isEmpty() = items.isEmpty()
val size get() = items.size
}
fun main() {
val s = Stack<Int>()
s.push(1); s.push(2); s.push(3)
println("peek=${s.peek()} size=${s.size}")
println("pop=${s.pop()} pop=${s.pop()} left=${s.size}")
// Undo: remember each edit on a stack, undo by popping the latest.
var text = ""
val history = Stack<String>()
for (word in listOf("Hello", " there", " world")) {
history.push(text) // save the state BEFORE the change
text += word
println("typed -> \"$text\"")
}
while (!history.isEmpty()) {
text = history.pop()
println("undo -> \"$text\"")
}
}
Open in Kotlin Playground ↗
Where stacks show up
- Undo and redo in editors, as in the demo.
- The call stack. When function
acallsb, which callsc, the computer pushes a frame for each call. Whencreturns, its frame is popped andbcontinues. This is why too-deep recursion ends in a stack overflow: the stack ran out of room. - Matching brackets and parsing, below.
- Depth-first search, which you will see in Part 5.
Balanced brackets
Is {[()]} well formed? Is ([)]? Scan left to right. Push every opening bracket. On a closing bracket, the top of the stack must be its matching opener; pop it. At the end the stack must be empty. The most recent unclosed opener is always the one that must close first, which is LIFO again.
Run it: balanced brackets
Time O(n), one pass. Space O(n) in the worst case, when everything is an opener.
fun isBalanced(s: String): Boolean {
val pairs = mapOf(')' to '(', ']' to '[', '}' to '{')
val stack = ArrayDeque<Char>()
for (c in s) {
when {
c in pairs.values -> stack.addLast(c) // opener: push
c in pairs.keys -> { // closer: must match top
if (stack.isEmpty() || stack.removeLast() != pairs[c]) return false
}
// any other character is ignored
}
}
return stack.isEmpty() // leftover openers mean it is not balanced
}
fun main() {
for (s in listOf("{[()]}", "([)]", "((", "", "a(b)c")) {
println("%-8s -> %s".format("\"$s\"", isBalanced(s)))
}
}
Open in Kotlin Playground ↗
Evaluating postfix
We usually write maths infix: (3 + 4) * 2. Postfix (also called reverse Polish notation) puts the operator after its operands: 3 4 + 2 *. It needs no brackets and no precedence rules, and a stack evaluates it in one pass: push numbers; on an operator, pop two numbers, apply it, push the result.
Run it: evaluate postfix
Try "2 3 4 * +" and check it by hand.
fun evalPostfix(tokens: List<String>): Int {
val stack = ArrayDeque<Int>()
for (t in tokens) {
when (t) {
"+", "-", "*", "/" -> {
val right = stack.removeLast() // popped first: the RIGHT operand
val left = stack.removeLast()
stack.addLast(when (t) {
"+" -> left + right
"-" -> left - right
"*" -> left * right
else -> left / right
})
}
else -> stack.addLast(t.toInt())
}
}
return stack.removeLast()
}
fun main() {
println(evalPostfix("3 4 + 2 *".split(" "))) // (3 + 4) * 2 = 14
println(evalPostfix("5 1 2 + 4 * + 3 -".split(" "))) // 5 + (1+2)*4 - 3 = 14
println(evalPostfix("10 2 -".split(" "))) // 8, not -8: order matters
}
Open in Kotlin Playground ↗
6. Queues: first in, first out
A queue is the fair line at a shop. Items join at the back and leave from the front. The first one to arrive is the first one served: FIFO, first in, first out.
enqueue(x): join the back of the line.dequeue(): remove and return the front item.peek(): look at the front item.
With ArrayDeque a queue is addLast to enqueue and removeFirst to dequeue. Both are O(1) on average.
Warning: do not build a queue on a plain ArrayList by calling removeAt(0). Removing the first item shifts all the others, so each dequeue costs O(n).
A tiny circular buffer
How can an array give a fast queue? Keep a head index (where the front is) and a count. When an index runs off the end, wrap around to 0 using the remainder operator %. No shuffling is ever needed, so the array behaves like a ring. This is what ArrayDeque does inside, with extra code to grow when it is full.
Run it: a fixed-size circular queue
Every operation is O(1). The cost is a fixed capacity; a growable version copies into a bigger array when full.
class RingQueue(private val capacity: Int) {
private val data = IntArray(capacity)
private var head = 0 // index of the front item
var size = 0
private set
fun enqueue(x: Int): Boolean {
if (size == capacity) return false // full
data[(head + size) % capacity] = x // wrap around with %
size++
return true
}
fun dequeue(): Int? {
if (size == 0) return null // empty
val x = data[head]
head = (head + 1) % capacity
size--
return x
}
}
fun main() {
val q = RingQueue(3)
println(listOf(q.enqueue(1), q.enqueue(2), q.enqueue(3), q.enqueue(4))) // last is false: full
println("dequeue -> ${q.dequeue()}") // 1
println("enqueue 5 -> ${q.enqueue(5)}") // true: it reuses the freed slot
val out = ArrayList<Int?>()
repeat(4) { out.add(q.dequeue()) }
println(out) // [2, 3, 5, null]
}
Open in Kotlin Playground ↗
Where queues show up
- Task scheduling: print jobs, web requests waiting for a server, messages waiting to be handled. Fairness is the whole point.
- Buffers: keyboard input and network data wait their turn.
- Breadth-first search (BFS): visiting things in rings, nearest first. A preview follows; Part 5 explains it fully.
Run it: a preview of breadth-first search
Because the queue is FIFO, everything one step from A is processed before anything two steps away.
fun main() {
// Who is connected to whom. Start at A and visit nearest first.
val friends = mapOf(
"A" to listOf("B", "C"),
"B" to listOf("D"),
"C" to listOf("D", "E"),
"D" to listOf("F"),
"E" to listOf("F"),
"F" to emptyList()
)
val queue = ArrayDeque<String>()
val seen = HashSet<String>()
queue.addLast("A"); seen.add("A")
val order = ArrayList<String>()
while (queue.isNotEmpty()) {
val node = queue.removeFirst() // the one that has waited longest
order.add(node)
for (next in friends.getValue(node)) {
if (seen.add(next)) queue.addLast(next) // join the back, once only
}
}
println(order) // [A, B, C, D, E, F]: A, then its neighbours, then theirs
}
Open in Kotlin Playground ↗
7. Deques: both ends
A deque (say “deck”, short for double-ended queue) lets you add and remove at both ends in O(1). It is more general than a stack or a queue, which is why Kotlin’s one ArrayDeque class can act as either.
| You want a… | Use these calls |
|---|---|
| Stack | addLast and removeLast |
| Queue | addLast and removeFirst |
| Deque | all four |
Run it: a deque as a palindrome checker
Compare the front and the back, then shrink inwards. Only a structure with cheap access at both ends makes this natural.
fun isPalindrome(text: String): Boolean {
val dq = ArrayDeque<Char>()
for (c in text) if (c.isLetterOrDigit()) dq.addLast(c.lowercaseChar())
while (dq.size > 1) {
if (dq.removeFirst() != dq.removeLast()) return false // compare the two ends
}
return true
}
fun main() {
println(isPalindrome("A man, a plan, a canal: Panama")) // true
println(isPalindrome("kotlin")) // false
println(isPalindrome("")) // true
}
Open in Kotlin Playground ↗
Deques also power a few clever tricks, such as sliding-window maximum, which Part 8 covers. For now, remember: if you need both ends, reach for ArrayDeque.
8. A classic puzzle: a queue from two stacks
Can you build a FIFO queue when the only tool you have is two LIFO stacks? Yes, and it teaches something deep: reversing the order twice gives back the original order.
Keep an in stack and an out stack.
enqueue(x): push onto in.dequeue(): if out is empty, pour everything from in into out (this reverses it, so the oldest item lands on top). Then pop out.
Pouring costs O(n) once, but each item is poured exactly once in its life. So over many operations the average cost per call is O(1). We call that amortised O(1): expensive occasionally, cheap on average.
Run it: queue built from two stacks
Interleaving enqueues and dequeues is the tricky case: never pour while outbox still has items, or you will break the order.
class QueueFromStacks {
private val inbox = ArrayDeque<Int>() // used only as a stack
private val outbox = ArrayDeque<Int>() // used only as a stack
fun enqueue(x: Int) { inbox.addLast(x) }
fun dequeue(): Int {
if (outbox.isEmpty()) {
while (inbox.isNotEmpty()) outbox.addLast(inbox.removeLast()) // pour: reverses order
}
return outbox.removeLast()
}
fun isEmpty() = inbox.isEmpty() && outbox.isEmpty()
}
fun main() {
val q = QueueFromStacks()
q.enqueue(1); q.enqueue(2); q.enqueue(3)
println(q.dequeue()) // 1
q.enqueue(4)
println(q.dequeue()) // 2 (still in the outbox from the earlier pour)
println(q.dequeue()) // 3
println(q.dequeue()) // 4
println(q.isEmpty()) // true
}
Open in Kotlin Playground ↗
The reverse puzzle, a stack from queues, also works: after each push, rotate the queue so the new item comes to the front. It makes push cost O(n). Try it on paper; it is a good check of your understanding of FIFO.
9. Comparing the costs
Here is everything side by side. “Avg” for the array’s append means amortised: the occasional resize is spread over many appends.
| Operation | Array / ArrayList | Singly linked list | Stack | Queue (ArrayDeque) |
|---|---|---|---|---|
| Read by position | O(1) | O(n) | not offered | not offered |
| Search for a value | O(n) | O(n) | O(n) | O(n) |
| Add at the front | O(n) | O(1) | n/a | O(1) (deque) |
| Add at the back | O(1) avg | O(n), or O(1) with a tail | push: O(1) | enqueue: O(1) avg |
| Remove from the front | O(n) | O(1) | n/a | dequeue: O(1) |
| Remove from the back | O(1) | O(n) | pop: O(1) | n/a (deque: O(1)) |
| Insert in the middle | O(n) | O(1) once you hold the spot, O(n) to find it | not offered | not offered |
The pattern to take away: a stack or queue gives you fewer operations on purpose. That restriction is a feature. When code uses a stack, the reader instantly knows the order is LIFO, and the structure guarantees nothing else can reach the middle.
- Need to undo, backtrack, match nested things, or follow a call chain? Stack.
- Need fairness or nearest-first order? Queue.
- Need both ends? Deque.
- Need position lookup? Array.
- Learning pointers, or building trees and graphs? Linked nodes.
10. Practice
Five exercises, from pointer wiring to stack tricks. Draw the picture on paper first, then write the code. The edge cases (empty, one item) are in the tests on purpose.
Try it: reverse a linked list
The Node class and two helpers are done for you. Fill in reverse so it flips the pointers in place and returns the new head. Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.
class Node(var value: Int, var next: Node? = null)
fun fromList(items: List<Int>): Node? {
var head: Node? = null
for (x in items.reversed()) head = Node(x, head)
return head
}
fun toList(head: Node?): List<Int> {
val out = ArrayList<Int>()
var cur = head
while (cur != null) { out.add(cur.value); cur = cur.next }
return out
}
fun reverse(head: Node?): Node? {
TODO("flip each next pointer; keep prev, cur and a saved next")
}
Open in Kotlin Playground ↗
Hint: Save cur.next in a variable before you overwrite it. Return prev.
Show a solution
class Node(var value: Int, var next: Node? = null)
fun fromList(items: List<Int>): Node? {
var head: Node? = null
for (x in items.reversed()) head = Node(x, head)
return head
}
fun toList(head: Node?): List<Int> {
val out = ArrayList<Int>()
var cur = head
while (cur != null) { out.add(cur.value); cur = cur.next }
return out
}
fun reverse(head: Node?): Node? {
var prev: Node? = null
var cur = head
while (cur != null) {
val next = cur.next
cur.next = prev
prev = cur
cur = next
}
return prev
}
Try it: detect a cycle
Write hasCycle using two pointers, so it uses O(1) extra memory. The tests build loops by hand. Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.
class Node(var value: Int, var next: Node? = null)
fun hasCycle(head: Node?): Boolean {
TODO("slow moves 1, fast moves 2; do they ever meet?")
}
Open in Kotlin Playground ↗
Hint: Loop while fast and fast.next are not null. Compare nodes with ===, not by value.
Show a solution
class Node(var value: Int, var next: Node? = null)
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
}
return false
}
Try it: balanced brackets, with a twist
Write isBalanced for the three kinds (), [] and {}. Ignore all other characters. Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.
fun isBalanced(s: String): Boolean {
TODO("push openers, match closers against the top")
}
Open in Kotlin Playground ↗
Hint: Check stack.isEmpty() before popping when you see a closer, and check it again at the very end.
Show a solution
fun isBalanced(s: String): Boolean {
val pairs = mapOf(')' to '(', ']' to '[', '}' to '{')
val stack = ArrayDeque<Char>()
for (c in s) {
if (c in pairs.values) stack.addLast(c)
else if (c in pairs.keys) {
if (stack.isEmpty() || stack.removeLast() != pairs[c]) return false
}
}
return stack.isEmpty()
}
Try it: evaluate postfix
Write evalPostfix for + - * / over whole numbers. Remember that the first value you pop is the right operand. Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.
fun evalPostfix(tokens: List<String>): Int {
TODO("push numbers; on an operator pop right, pop left, push the result")
}
Open in Kotlin Playground ↗
Hint: A token like "-3" is a number, not the minus operator, so test for the four exact operator strings only.
Show a solution
fun evalPostfix(tokens: List<String>): Int {
val stack = ArrayDeque<Int>()
for (t in tokens) {
when (t) {
"+", "-", "*", "/" -> {
val right = stack.removeLast()
val left = stack.removeLast()
stack.addLast(when (t) {
"+" -> left + right
"-" -> left - right
"*" -> left * right
else -> left / right
})
}
else -> stack.addLast(t.toInt())
}
}
return stack.removeLast()
}
Try it: a queue from two stacks
Complete MyQueue using only the two stacks provided (use addLast and removeLast on them, nothing else). dequeue and peek return null when empty. Open the starter in the Kotlin Playground, press Run, edit the code, and run again until every line says PASS.
class MyQueue {
private val inbox = ArrayDeque<Int>()
private val outbox = ArrayDeque<Int>()
fun enqueue(x: Int) {
TODO("push onto inbox")
}
private fun refill() {
TODO("if outbox is empty, pour all of inbox into it")
}
fun dequeue(): Int? {
TODO("refill, then pop from outbox")
}
fun peek(): Int? {
TODO("refill, then look at the top of outbox")
}
fun isEmpty(): Boolean {
TODO("both stacks empty")
}
}
Open in Kotlin Playground ↗
Hint: Only pour when outbox is empty. Pouring earlier mixes old and new items and breaks the order.
Show a solution
class MyQueue {
private val inbox = ArrayDeque<Int>()
private val outbox = ArrayDeque<Int>()
fun enqueue(x: Int) {
inbox.addLast(x)
}
private fun refill() {
if (outbox.isEmpty()) {
while (inbox.isNotEmpty()) outbox.addLast(inbox.removeLast())
}
}
fun dequeue(): Int? {
refill()
return outbox.removeLastOrNull()
}
fun peek(): Int? {
refill()
return outbox.lastOrNull()
}
fun isEmpty(): Boolean = inbox.isEmpty() && outbox.isEmpty()
}
Going further. Try adding removeLast to the linked list, writing a doubly linked list with a tail pointer, or building a stack on top of the linked list so that push and pop work at the head. All three are O(1) at the head.
11. Takeaways
- A node holds a value and a pointer; a linked list is only as good as its pointer handling, so draw it first.
- Linked lists give
O(1)insert and delete at a spot you hold, butO(n)access by position. - Stacks are for undo, nesting, matching and call chains; queues are for fairness and nearest-first order.
- Use
ArrayDequefor a stack, a queue or a deque, and neverremoveAt(0)on a list as a queue. - Two pointers at different speeds solve middle and cycle problems in
O(1)space. - Next up: trees, heaps and tries, where each node has more than one next.
| Term | In plain words |
|---|---|
| Node | A small object holding a value and one or more pointers to other nodes. |
| Pointer / reference | The address of another object, or null for nothing. |
| Head / tail | The first / last node of a list. |
| Singly / doubly linked | Nodes have a pointer to the next only / to both next and previous. |
| LIFO | Last in, first out: the stack rule. |
| FIFO | First in, first out: the queue rule. |
| Push / pop / peek | Add to the top / remove from the top / look at the top of a stack. |
| Enqueue / dequeue | Add at the back / remove from the front of a queue. |
| Deque | A double-ended queue: add and remove at both ends. |
| Circular buffer | A fixed array used as a ring by wrapping indexes with %. |
| Call stack | The stack of active function calls the computer keeps while a program runs. |
| Amortised | Averaged over many operations, so an occasional expensive step is spread out. |