// STUHUB · C++ DATA STRUCTURES
Stacks in C++: Array vs Linked Implementation, Overflow, Underflow, and Balanced Parentheses
How to build a stack in C++ twice over: a fixed array with a `top` index and a singly linked list pushed at the head. Covers exactly where the off-by-one bugs live — why push writes at `top` after incrementing and why pop must save the value before decrementing — the overflow (`top == MAX - 1`) and underflow (`top == -1`) tests that pair with them, `std::stack`'s split `top()`/`pop()`, and balanced-parentheses checking that reports the position of the offending bracket. Every listing compiles under `g++ -std=c++17 -Wall -Wextra`.
Introduction
A stack is the smallest interesting data structure. One rule — you may only touch the end you most recently added to — and everything else follows: constant-time operations, a natural fit for anything nested, and two implementations that fit on a single screen each.
Which is why it is worth being precise about it. The array version is about nine lines of real code and at least four of them are places people get the index wrong. top starts at -1; push advances it then writes; pop reads then retreats, and if you write those two in the wrong order you get a stack that returns the element underneath the one you asked for and never reports an error. The linked version has no capacity limit and no overflow, but every pop has to free a node, and the order of the three lines that do it decides whether you get the value or a use-after-free.
This page builds both, states each boundary condition next to the operation it guards, and finishes with balanced-parentheses checking — a stack application that is genuinely different from expression evaluation, and where the non-obvious move is to push the index of each opening bracket rather than the bracket itself. Every listing here has been compiled with g++ -std=c++17 -Wall -Wextra and run; the linked-list one also runs clean under AddressSanitizer and UBSan.
LIFO, and the restriction that buys you O(1)
A stack is a linear structure in which all insertions and deletions happen at one end, called the top. That is the whole definition. It gives you LIFO order — Last In, First Out — or equivalently FILO, First In, Last Out: the most recently added element is the first one removed, and the first one added is the last one out.
The usual picture is a stack of plates. You add a plate to the top of the pile and you take one from the top of the pile; the plate at the bottom is the one you put down first and the one you will reach last. Note what the picture does not let you do: slide a plate out of the middle. Middle elements are inaccessible. If the value you want is three down, the only way to it is to remove the two above it, and they are gone unless you kept them somewhere.
Five operations make up the interface:
| Operation | What it does | Cost |
|---|---|---|
push(v) | Adds an element to the top of the stack | O(1) |
pop() | Removes the top element (and usually returns it) | O(1) |
peek() / top() | Returns the top element without removing it | O(1) |
isEmpty() | Checks whether the stack contains no elements; returns bool | O(1) |
size() | Returns how many elements are currently stored; returns an integer | O(1) |
The two that get confused are pop and peek, because both are about the top element. The distinguishing phrase is without removing: that is always peek/top. The other pair worth keeping apart is isEmpty and size — one answers whether, the other answers how many.
Every one of those is O(1), and that is not a coincidence you have to memorise — it is a direct consequence of the restriction. Because the only reachable position is one end, no operation ever has to shift elements along or walk the structure to find where to work. Compare an array where you may insert anywhere: inserting at the front costs O(n) because everything after it moves. Forbid that, and the cost disappears. Restricting the interface is what buys the performance.
When is a stack the right shape? Whenever the thing you need next is the thing you saw most recently:
- Function calls and recursion. Each call pushes an activation record; returning pops it. The most recent call is always the one that finishes first, which is exactly LIFO.
- Undo, and the browser Back button. Every action or visited page is pushed; undo pops the latest one.
- Bracket and tag matching. Nesting is LIFO by definition — the bracket opened most recently must be the first one closed. Covered in full below.
- Infix → postfix conversion and postfix evaluation. Operators wait on a stack until their operands are ready.
- Depth-first search, and backtracking generally. An explicit stack of unexplored branches, or the call stack doing the same job implicitly.
And the contrast that makes the definition stick: a queue is FIFO, insertions at the rear and removals at the front, two markers instead of one. Stacks reverse order; queues preserve it. Both make their middle unreachable, and both are O(1) for exactly the same reason.
The array stack: what `top` actually points at
The array implementation is a fixed buffer plus one integer. That integer is the whole design, so state precisely what it means:
topis the index of the element currently on top. An empty stack istop == -1, because > there is no valid index to point at. The stack holdstop + 1elements.
Every bug in this section comes from drifting away from that sentence. Hold it fixed and the code writes itself.
Push: advance, then write. After top = top + 1, the marker is already sitting on the slot the new element belongs in. So the store is arr[top] = value:
top = top + 1; // move the marker to the new slot...
arr[top] = value; // ...and write AT top, not at top + 1Writing arr[top + 1] = value after the increment skips a slot: it leaves a hole at the real top and stores the element one place beyond where pop will look for it. The two lines compress into the idiom arr[++top] = value; — pre-increment, because the marker must move first. arr[top++] = value is the other bug in one character: it writes at the old top, overwriting the element already there.
Pop: read, then retreat — and the value must be saved first. This is the trap that costs more marks and more debugging hours than anything else on this page:
int value = arr[top]; // save the value BEFORE the marker moves
top = top - 1;
return value; // NOT return arr[top]By the time you reach the return, top has already moved down one. return arr[top] therefore hands back the element underneath the one you popped. Concretely: push 5, 6, 2 onto an empty stack, then run the broken version. The first pop returns 6 instead of 2, the second returns 5 instead of 6, and the third reads arr[-1] — out of bounds, undefined behaviour, and in practice no crash and no warning, just a plausible-looking number. Compiled with -Wall -Wextra it is silent. The correct version prints 2 6 5; the broken one prints 6 5 and then garbage.
The compressed idiom here is return arr[top--]; — post-decrement, because the read must happen at the old top and the decrement afterwards. So the two idioms are asymmetric on purpose: arr[++top] = value for push, return arr[top--] for pop. If you ever find yourself writing arr[top++] or arr[--top], you have them backwards.
Overflow and underflow pair one-to-one with the two boundary tests. They are not vague warnings; each is a specific condition guarding a specific operation:
| Failure | When it happens | Array test | Linked-list equivalent |
|---|---|---|---|
| Overflow | push onto a full stack | top == MAX - 1 | Does not occur — bounded only by memory |
| Underflow | pop or peek on an empty stack | top == -1 | top == nullptr |
Check before you act: push tests for full before incrementing, pop and peek test for empty before reading. Two spellings to get right:
top == -1, nottop = -1. The second is an assignment that empties the stack and then evaluates to-1, which is truthy — so the guard fires every time and destroys your data.top == MAX - 1, nottop == MAX.topis an index andMAXis a count; the last valid index is one less than the count. Testingtop == MAXlets one push run off the end of the array. This is the same off-by-one as writing<=where you meant<in a loop bound.
isEmpty() and the underflow test are literally the same expression, which is a useful check on your own code: if they differ, one of them is wrong.
A complete array stack in C++
Here is the whole thing, compiled and run. Two decisions in it are worth explaining before you read it.
First, pop and peek report failure through a bool return and write the value into an out-parameter, rather than returning a sentinel like -1. The textbook signature int pop() that returns -1 on an empty stack is fine right up until someone pushes -1, at which point the caller cannot distinguish a real value from a failure. If you prefer a value-returning pop, throw std::underflow_error instead — that is what the linked version below does.
Second, the guards print and return rather than crashing, which keeps the failure visible in the output. Notice in the run below that the final pop reports Stack Underflow and leaves the caller's variable untouched instead of scribbling a garbage value into it.
The program pushes 5, 6, 2, peeks, then drains. It prints:
top=2 size=3
2 6 5 Stack Underflow2 6 5 is the push order reversed — LIFO, visible in one line of output. The loop keeps popping until pop returns false, which is why the underflow message appears at the end.
#include <iostream>
const int MAX = 100;
class ArrayStack {
public:
ArrayStack() : top_(-1) {}
bool isEmpty() const { return top_ == -1; }
bool isFull() const { return top_ == MAX - 1; }
int size() const { return top_ + 1; }
bool push(int value) {
if (isFull()) { // overflow test
std::cout << "Stack Overflow\n";
return false;
}
top_ = top_ + 1; // move the marker first...
data_[top_] = value; // ...then write AT top_, not top_ + 1
return true;
}
bool pop(int& out) {
if (isEmpty()) { // underflow test
std::cout << "Stack Underflow\n";
return false;
}
out = data_[top_]; // save BEFORE moving the marker
top_ = top_ - 1;
return true; // returning data_[top_] here would be wrong
}
bool peek(int& out) const {
if (isEmpty()) {
std::cout << "Stack is empty\n";
return false;
}
out = data_[top_]; // read only; top_ does not move
return true;
}
private:
int data_[MAX];
int top_;
};
int main() {
ArrayStack s;
s.push(5); s.push(6); s.push(2);
int v = 0;
s.peek(v);
std::cout << "top=" << v << " size=" << s.size() << "\n"; // top=2 size=3
while (s.pop(v)) std::cout << v << ' '; // 2 6 5, then underflow
std::cout << "\n";
}The linked stack: push at the head, and free what you pop
Back the stack with a singly linked list instead and both problems with the array version go away: there is no fixed capacity, so no overflow (you are limited only by available memory), and you pay for exactly the nodes you are holding.
The design is one sentence: a linked stack is a singly linked list where insertion and deletion only ever happen at the head. top is the head pointer. Push is insert-at-beginning; pop is delete-from-beginning. Both are O(1) because the head is directly reachable — which is precisely why the head, and not the tail, is the right end to designate as the top. If you pushed at the tail of a singly linked list you would traverse the whole list on every operation.
An empty stack is top == nullptr, and that is also the underflow test.
Push, in the order that matters:
newNode->link = top; // point the new node at the old top FIRST
top = newNode; // then make the new node the topReverse those two lines and top already equals newNode when the first executes, so newNode->link = top makes the node point at itself. You get a one-element cycle, the rest of the stack is unreachable and leaked, and the next pop leaves top pointing at freed memory.
Pop, in four steps that cannot be reordered:
- Read the value out of the top node into a local.
- Save the node's address in a temporary (
temp = top). - Move
topdown one:top = top->link. delete temp.
Step 4 must come last. delete top; top = top->link; reads a member of an object that has already been destroyed — use-after-free, which in practice often returns the right answer on the first few runs and then corrupts the heap under load. That is the worst possible failure mode, and it is why the listing below is also run under AddressSanitizer. And you must actually delete: moving top without freeing leaks one node per pop, silently.
One more thing this class does that a classroom sketch usually skips. It owns raw pointers, so it needs a destructor, a copy constructor and an assignment operator. The compiler-generated copy would duplicate the top pointer, giving you two stacks sharing one chain of nodes and two destructors racing to free them. The copy constructor here rebuilds the chain in order by appending through a Node** cursor, so the copy has the same top as the original rather than a reversed one.
The program pushes 5, 6, 2, takes a copy, drains the original and shows that the copy is untouched. It prints:
top=2 size=3
265 copy_size=3
pop on empty stack#include <iostream>
#include <stdexcept>
#include <utility>
// Invariant: top_ is the head of a singly linked list running top -> bottom.
// An empty stack is top_ == nullptr. There is no capacity, so no overflow.
class LinkedStack {
struct Node {
int info;
Node* link;
Node(int i, Node* l) : info(i), link(l) {}
};
Node* top_ = nullptr;
std::size_t count_ = 0;
public:
LinkedStack() = default;
~LinkedStack() { clear(); }
LinkedStack(const LinkedStack& other) { // copy, deepest node first
Node** tail = &top_;
for (Node* p = other.top_; p != nullptr; p = p->link) {
*tail = new Node(p->info, nullptr);
tail = &(*tail)->link;
}
count_ = other.count_;
}
LinkedStack& operator=(LinkedStack other) { swap(other); return *this; }
LinkedStack(LinkedStack&& other) noexcept { swap(other); }
void swap(LinkedStack& other) noexcept {
std::swap(top_, other.top_);
std::swap(count_, other.count_);
}
bool empty() const { return top_ == nullptr; }
std::size_t size() const { return count_; }
void push(int value) {
Node* node = new Node(value, top_); // link to the old top FIRST
top_ = node; // then the new node becomes the top
++count_;
}
int pop() {
if (empty()) throw std::underflow_error("pop on empty stack");
Node* old = top_;
int value = old->info; // 1. read the value
top_ = old->link; // 2. unlink: top_ moves one node down
delete old; // 3. free the detached node -- never before step 2
--count_;
return value;
}
int peek() const {
if (empty()) throw std::underflow_error("peek on empty stack");
return top_->info;
}
void clear() { while (!empty()) pop(); }
};
int main() {
LinkedStack s;
s.push(5); s.push(6); s.push(2);
std::cout << "top=" << s.peek() << " size=" << s.size() << "\n"; // top=2 size=3
LinkedStack copy = s; // deep copy: two independent chains
while (!s.empty()) std::cout << s.pop(); // 265
std::cout << " copy_size=" << copy.size() << "\n"; // copy_size=3
try { s.pop(); }
catch (const std::underflow_error& e) { std::cout << e.what() << "\n"; }
}Choosing between them
| Array-based | Linked-list-based | |
|---|---|---|
push | O(1) | O(1) |
pop | O(1) | O(1) |
peek / top | O(1) | O(1) |
isEmpty / size | O(1) | O(1) |
| Size limit | Fixed at MAX_SIZE | Limited only by available memory |
| Overflow possible? | Yes — top == MAX - 1 | Not in practice; allocation failure instead |
| Memory per element | The element only | The element plus one pointer |
| Memory paid up front | The whole buffer, used or not | Nothing |
Every operation is O(1) in both. The table above is therefore not the interesting comparison — the constant factors and the failure modes are.
The array wins on speed. Its elements sit in one contiguous block, so the top of the stack is almost always in cache and pushing is an increment and a store. The linked stack calls new on every push and delete on every pop; allocation dominates the cost of the operation, and its nodes scatter across the heap so each pop risks a cache miss following top->link. Same O(1), routinely several times slower.
The list wins on not falling over. A fixed array turns "more input than expected" into a hard failure. If a stack overflow would be a correctness bug rather than an inconvenience, and you cannot bound the depth in advance, use the list — or use a growable array, which is the third option and usually the best one.
The third option. Back the stack with std::vector<T>: push_back and pop_back are the stack operations, capacity doubles as needed, and push becomes amortised O(1) rather than worst-case O(1) — most pushes are an increment and a store, and occasionally one copies everything into a bigger buffer. Across n pushes the copying totals O(n), so the average stays constant. You get the array's cache behaviour without the fixed ceiling. In real C++ this is what you should reach for, and std::stack gives it to you for free.
`std::stack`, and why `top()` and `pop()` are separate
For anything other than an exercise in implementing one, use the standard library. std::stack is a container adaptor: it wraps another container (a std::deque by default; std::stack<int, std::vector<int>> gives you the vector-backed version) and exposes only the stack interface — push, pop, top, empty, size, emplace. There is deliberately no way to iterate it or index into it, which is the restriction from the first section enforced by the type system.
The one thing that surprises people coming from other languages: pop() returns nothing. top() reads the top element without removing it; pop() removes it and returns void. So they always come in pairs:
int value = s.top();
s.pop();This is not an oversight. A pop that both removed the element and returned it by value could not be exception-safe: the element has to be copied out to be returned, and if that copy throws, the element has already been removed from the container and is now lost. Splitting the operation means the read can fail without changing the container.
Two more things to know. top() on an empty stack is undefined behaviour, not an exception — std::stack does no checking, so if (!s.empty()) is your responsibility. And top() returns a reference, so s.top() = 42; legitimately modifies the top element in place.
The program below prints:
size=3 top=2
2 6 5
empty=1#include <iostream>
#include <stack>
#include <string>
int main() {
std::stack<int> s;
s.push(5);
s.push(6);
s.push(2);
std::cout << "size=" << s.size() << " top=" << s.top() << "\n"; // size=3 top=2
// top() reads, pop() removes and returns nothing: they always come in pairs.
while (!s.empty()) {
int value = s.top();
s.pop();
std::cout << value << ' ';
}
std::cout << "\nempty=" << s.empty() << "\n"; // 2 6 5 / empty=1
}Parentheses matching: push the index, not the bracket
Checking that brackets are balanced is the cleanest stack application there is, because the problem is LIFO: the bracket most recently opened must be the first one closed. Nesting and last-in-first-out are the same statement.
The algorithm is a single left-to-right scan:
- Scan the expression from left to right.
- When a left parenthesis is encountered, push its position onto the stack.
- When a right parenthesis is encountered, pop the matching position and record the pair.
- If a right parenthesis arrives while the stack is empty, the expression is unbalanced — there is nothing for it to close.
- If the scan ends and the stack is not empty, the expression is unbalanced — those are opening brackets that were never closed.
Everything that is not a bracket is skipped entirely; the stack does not move. Both failure conditions are needed: check only rule 4 and ((a+b) passes; check only rule 5 and )( passes.
Why the index and not the character. For a single bracket type the character carries no information at all — anything you push is a '(', so a std::stack<char> is just an expensive counter. The position does carry information: it lets you report the pair, and more usefully it lets you point at the exact column of the bracket that has no partner. That is the difference between "unbalanced" and "unmatched ( at index 7", which is the difference between a toy and a diagnostic. So the declaration is std::stack<int>, and the push is s.push(i).
A trace. Take this expression, with indices counted from 0:
(((a+b)*c+d-e)/(f+g)-(h+j)*(k-l))/(m-n)
0123456789...The pairs are produced in the order the closing brackets arrive, which is not the order the opening brackets arrived:
) at index | Pops | Pair |
|---|---|---|
| 6 | 2 | (2, 6) |
| 13 | 1 | (1, 13) |
| 19 | 15 | (15, 19) |
| 25 | 21 | (21, 25) |
| 31 | 27 | (27, 31) |
| 32 | 0 | (0, 32) |
| 38 | 34 | (34, 38) |
Look at where (0, 32) lands: after (27, 31), not first. The bracket at index 0 was opened first and is therefore closed last — it sits at the bottom of the stack the whole time while five other pairs come and go above it. That ordering is the LIFO property showing up in the output, and it is the detail people get wrong when asked to list the pairs by hand: the scan is strictly left to right, and a bracket is only matched when its own closer is reached.
At the end the stack is empty, so the expression is balanced.
When a counter would do. With one bracket type you can replace the stack with an int: increment on (, decrement on ), fail if it ever goes negative, fail if it is non-zero at the end. The stack earns its place the moment you want positions, or more than one kind of bracket — with (), [] and {} a counter cannot detect ([)], because the counts balance but the nesting does not. That extension is exercise 3.
The program below prints the pair list above, then checks two broken inputs. Its output is:
pairs, in the order they close:
(2,6)
(1,13)
(15,19)
(21,25)
(27,31)
(0,32)
(34,38)
balanced=1 offender=-1
(a+b)) balanced=0 offender=5
((a+b) balanced=0 offender=0#include <iostream>
#include <stack>
#include <string>
// The plain test: is every parenthesis matched?
bool isBalanced(const std::string& expr) {
std::stack<int> open; // stack<int>, not stack<char>
for (std::size_t i = 0; i < expr.size(); ++i) {
if (expr[i] == '(') {
open.push(static_cast<int>(i)); // push the POSITION
} else if (expr[i] == ')') {
if (open.empty()) return false; // a ')' with nothing to close
open.pop();
}
// every other character is skipped; the stack does not move
}
return open.empty(); // leftover '(' means unbalanced
}
// The same scan, but it prints each pair at the moment the pair closes.
void printPairs(const std::string& expr) {
std::stack<int> open;
for (std::size_t i = 0; i < expr.size(); ++i) {
if (expr[i] == '(') {
open.push(static_cast<int>(i));
} else if (expr[i] == ')' && !open.empty()) {
std::cout << " (" << open.top() << "," << i << ")\n";
open.pop();
}
}
}
// -1 when balanced; otherwise the index of the offending parenthesis.
int firstOffender(const std::string& expr) {
std::stack<int> open;
for (std::size_t i = 0; i < expr.size(); ++i) {
if (expr[i] == '(') open.push(static_cast<int>(i));
else if (expr[i] == ')') {
if (open.empty()) return static_cast<int>(i); // unmatched ')'
open.pop();
}
}
if (open.empty()) return -1;
while (open.size() > 1) open.pop(); // the leftmost leftover sits at the bottom
return open.top(); // unmatched '('
}
int main() {
const std::string expr = "(((a+b)*c+d-e)/(f+g)-(h+j)*(k-l))/(m-n)";
std::cout << "pairs, in the order they close:\n";
printPairs(expr);
std::cout << "balanced=" << isBalanced(expr)
<< " offender=" << firstOffender(expr) << "\n";
for (const std::string bad : {"(a+b))", "((a+b)"}) {
std::cout << bad << " balanced=" << isBalanced(bad)
<< " offender=" << firstOffender(bad) << "\n";
}
}The mistakes people actually make
Array stack
- Writing at
top + 1after already incrementingtop. Leaves a hole at the real top and stores the value where nothing will look for it. Pick one: increment then write attop, or write attop + 1then increment. Never both. return arr[top]aftertop--inpop. Returns the element underneath. Save the value in a local before the decrement, or usereturn arr[top--];.arr[top++] = valuein push. Post-increment writes at the old top, overwriting the element that was already there. Push needs pre-increment; pop needs post-decrement. The asymmetry is real.top == MAXas the full test. Off by one —topis an index,MAXis a count. It istop == MAX - 1, and getting it wrong writes one element past the end of the array.top = -1where you meanttop == -1. Assignment inside a condition: it empties the stack, evaluates to-1, and-1is truthy, so the guard fires unconditionally. Compilers warn about this only sometimes; write the comparison.- Initialising
top = 0. Then an empty stack and a stack holding one element are indistinguishable. If you genuinely prefertop = 0, it has to mean "index of the next free slot", which changespush(write then increment),pop(decrement then read),size()(top, nottop + 1) and both boundary tests. Both conventions work; mixing them does not. Write the one you chose in a comment above the field. - Returning a sentinel that is also a legal value.
return -1on underflow is unambiguous only until someone pushes-1. Use an out-parameter, an exception, orstd::optional. - A
peek()that falls off the end of the function. The common textbook version prints a message on the empty branch and then returns nothing at all — undefined behaviour.-Wallcatches it (warning: control reaches end of non-void function); listen to it.
Linked stack
top = newNodebeforenewNode->link = top. The new node ends up pointing at itself; the rest of the stack is orphaned. Link first, then advance.deletebefore reading, or before movingtop.delete top; top = top->link;is a use-after-free that frequently produces the correct answer in testing and corrupts the heap in production. Read, save, move, delete — in that order.delete topinstead ofdelete temp. By the time you get there,tophas already moved to the next node, so this frees the node that is now the top of the stack. Every subsequent operation is undefined.- Moving
topwithout deleting. One leaked node per pop, and no symptom until the process runs out of memory. A missing destructor leaks the entire remaining chain. - Letting the compiler copy the stack for you. The default copy duplicates one raw pointer: two objects, one node chain, double free at scope exit. Define the copy operations, or
= deletethem. - Checking for overflow in the linked version. There is no capacity to be full. If you want to be robust about allocation failure, that is a different question and
newthrowsstd::bad_allocfor it.
Parentheses matching
- Only checking one of the two failure conditions. An empty stack on
)catches)(; a non-empty stack at the end catches((a+b). You need both tests, and the finalreturn s.empty()is the easiest one to leave out. stack<char>where the position is what you want. Pushing'('throws away the only useful information. Pushi.- Popping before checking
empty().s.pop()on an emptystd::stackis undefined behaviour, not an exception. The emptiness check is the unmatched-)test, so this is one line doing two jobs — do not skip it. - Trying to match brackets by counting when there is more than one type.
([)]has equal counts of every bracket and is still wrong. Once you have(),[]and{}you need the stack, and you need to compare the closer against the top opener rather than just popping.
Exercises with solutions
Work through each question before opening the solution below it.
Exercise 1
Trace the top pointer. An array-based stack has capacity MAX = 3, and top is initialised to -1. Execute this sequence and give the value of top, the array contents, and what each operation returns or prints:
push(5),push(6),push(2)push(9)peek()pop(),pop(),pop()pop()
Then: after step 4, the array still physically contains 5, 6 and 2. Why is the stack nonetheless empty, and what would go wrong if isEmpty() were implemented by inspecting the array contents instead of top?
Solution
Step 1. Each push advances top and then writes at the new top.
| after | arr[0..2] | top | size() = top + 1 |
|---|---|---|---|
| start | [ . . . ] | -1 | 0 |
push(5) | [5 . . ] | 0 | 1 |
push(6) | [5 6 . ] | 1 | 2 |
push(2) | [5 6 2] | 2 | 3 |
Step 2. The full test is top == MAX - 1, i.e. 2 == 2 — true. push(9) prints Stack Overflow and changes nothing. Note that a test written as top == MAX would have been false here and let the push write arr[3], one past the end of a three-element array.
Step 3. peek() returns 2 and leaves top at 2. Nothing moves.
Step 4. Each pop reads at the current top, saves the value, then decrements.
| call | reads | returns | top after |
|---|---|---|---|
pop() | arr[2] | 2 | 1 |
pop() | arr[1] | 6 | 0 |
pop() | arr[0] | 5 | -1 |
Output order 2 6 5 is the reverse of the push order — LIFO. If pop had returned arr[top] after the decrement it would have produced 6, then 5, then a read of arr[-1].
Step 5. top == -1, so this prints Stack Underflow and returns the failure indicator. The two boundary conditions have now both fired, at opposite ends: overflow at top == MAX - 1 in step 2, underflow at top == -1 here.
Why the stack is empty although the array is not. Popping never erases anything; it only moves top. The values 5, 6 and 2 are still sitting in arr[0..2] as raw bytes, but they are outside the live region, which is defined entirely by top. They will be overwritten by the next three pushes and until then they are meaningless.
So an isEmpty() that inspected the array — scanning for zeroes, say, or for some "unused" marker — would be wrong twice over. It would report this stack as non-empty when it is empty, and it would report a genuinely non-empty stack as empty the moment someone pushed the marker value itself. top is the single source of truth about how many elements exist; the array only says what those elements are. That is also why clearing a stack is top = -1 and costs O(1), not a loop over the buffer.
Exercise 2
Find the four bugs. The class below compiles: g++ -std=c++17 -Wall -Wextra reports two warnings and no errors. It has four defects. Identify each, say what it does at run time, and give the fix — then say which two of the four the compiler was pointing at, and why it could not see the other two.
#define MAX 100
class Stack {
int arr[MAX];
int top = 0;
public:
bool isEmpty() { return top == -1; }
bool isFull() { return top == MAX; }
void push(int value) {
if (isFull()) { cout << "Stack Overflow"; return; }
top = top + 1;
arr[top + 1] = value;
}
int pop() {
if (isEmpty()) { cout << "Stack Underflow"; return -1; }
int value = arr[top];
top = top - 1;
return arr[top];
}
int peek() {
if (!isEmpty()) return arr[top];
cout << "Stack is empty";
}
};Solution
Bug 1 — int top = 0; contradicts isEmpty(). The empty test is top == -1, so on a freshly constructed stack isEmpty() returns false. pop() on a brand-new stack sails past the underflow guard and reads uninitialised memory. Worse, the stack can never be recognised as empty again: after enough pops top reaches -1 and then the guard works, meaning the whole structure is off by one element for its entire life.
Fix: int top = -1;. (The alternative reading of top — "index of the next free slot", starting at 0 — is a legitimate design, but then isEmpty() is top == 0, isFull() is top == MAX, push writes then increments and pop decrements then reads. The bug is the mixture, not either convention.)
Bug 2 — isFull() is off by one. top is an index into an array of MAX elements, so the last valid index is MAX - 1. top == MAX is only true after a push has already gone out of bounds. Fix: return top == MAX - 1;.
Bug 3 — push increments and then writes at top + 1. Both. The element lands two slots above where it should, so the first push writes arr[1] while top says 0, and pop reads arr[0], which was never written. Every value you ever push is invisible and every value you pop is garbage — and the last push writes one element past the end of the array. Fix: arr[top] = value; (or drop the separate increment and write arr[++top] = value;).
Bug 4 — pop returns arr[top] after decrementing. value is computed correctly and then thrown away. The function returns the element underneath the one it just removed, and once the stack has one element left it reads arr[-1]. Fix: return value; (or delete the local and write return arr[top--];).
Bug 4 — peek() can fall off the end. When the stack is empty the function prints a message and then reaches the closing brace of a non-void function without returning: undefined behaviour, and in practice whatever happens to be in the return register. Fix it the same way as pop's error path — return a sentinel, or better, change the signature to bool peek(int& out), or throw.
What the compiler saw. The two warnings are unused variable 'value' on the local in pop, and control reaches end of non-void function (-Wreturn-type) on peek. Those point at bugs 3 and 4. An unused local in a four-line function is almost always a discarded result, which is exactly what bug 3 is — treat that warning as a correctness report, not tidiness.
It could not see bugs 1 and 2 because both are consistent code that means the wrong thing: top = 0 and top == MAX are perfectly well-formed, and nothing in the language records that top is supposed to be an index whose empty value is -1. Type systems catch disagreements they were told about; this invariant only lives in your head, so put it in a comment.
The lesson underneath all four. Every one of these is a disagreement between two places about what top means. Write the invariant down once — `top` is the index of the top element; `-1` means empty — and then read each declaration against it in order: initialiser ✗, isEmpty ✓, isFull ✗, push ✗, pop ✗, peek ✗ on its error path. Six checks, no debugger, no test run.
Exercise 3
Three kinds of bracket. Extend the parentheses matcher to handle (), [] and {} simultaneously. It must reject ([)] — where the counts of every bracket type balance but the nesting is crossed — and it must still report the position of the first bracket that goes wrong. Explain why a std::stack<char> is now enough for correctness but still not enough for the error message, and identify the three distinct ways the input can be invalid.
Solution
Why a counter fails and a stack does not. Counting opens and closes per type checks a necessary condition, not a sufficient one: ([)] has one of each and balances on every counter, yet the ) at index 2 tries to close a region opened by [. Correct nesting is a statement about order, and the stack is what remembers order — the top of the stack is always the innermost region still open, which is exactly the one a closing bracket must match.
Why stack<char> is not enough. With three bracket types the character finally does carry information (which type is open), so stack<char> gets you correctness. But to say where the unclosed { is, you still need its index. Push both — a two-field struct, or a std::stack<std::pair<char,int>>.
The three failure modes, all of which the code below reports as an index:
- A closing bracket arrives with an empty stack — nothing to close.
)(fails here at index 0. - A closing bracket arrives whose type does not match the top opener — crossed nesting.
([)]fails here at index 2, anda[(b+c])at index 6. - The scan ends with a non-empty stack — openers never closed.
{[()]fails here, and the position to report is the leftmost leftover, which is at the bottom of the stack: index 0.
Only the second condition is new; the first and third are the same two checks as the single-bracket version.
#include <iostream>
#include <stack>
#include <string>
struct Opener { char ch; int pos; };
static char closerFor(char open) {
if (open == '(') return ')';
if (open == '[') return ']';
return '}';
}
// -1 when balanced; otherwise the index of the first bracket that goes wrong.
int firstError(const std::string& expr) {
std::stack<Opener> open;
for (std::size_t i = 0; i < expr.size(); ++i) {
char c = expr[i];
if (c == '(' || c == '[' || c == '{') {
open.push(Opener{c, static_cast<int>(i)});
} else if (c == ')' || c == ']' || c == '}') {
if (open.empty()) return static_cast<int>(i); // (1)
if (closerFor(open.top().ch) != c) return static_cast<int>(i); // (2)
open.pop();
}
}
if (open.empty()) return -1;
while (open.size() > 1) open.pop();
return open.top().pos; // (3)
}Run over a[(b+c)*{d}], a[(b+c]), ([)], {[()] and )( it prints -1, 6, 2, 0, 0 — balanced, then one instance of each failure mode.
Two details worth noticing. The order of the two guards inside the closing branch matters: open.top() on an empty stack is undefined behaviour, so the emptiness test has to come first. And for case 3, the loop that pops down to the last element is only there to find the leftmost unclosed opener; if you are happy reporting the innermost one instead, open.top().pos on its own will do.
Where this generalises. This is the parsing kernel of every editor that highlights matching braces, every linter that complains about an unclosed tag, and the first pass of most compilers. Replace "bracket type" with "HTML tag name" and the algorithm is unchanged.
Published and updated 2026-08-09 · DUOCODE TECHNOLOGY