Structs & Data Structures · intermediate · ~15 min
By the end of this lesson you will be able to: - Define what a **binary tree** is and how it differs from a **binary search tree (BST)**. - Build a node `struct` with two child pointers and link nodes into a tree. - **Insert** values into a BST while preserving its ordering rule (invariant). - **Traverse** a tree in pre-order, in-order, and post-order, and explain why in-order on a BST prints sorted values. - Recognize why an **unbalanced** tree degrades to O(n) and what to do about it. - Free an entire tree without leaking memory or causing use-after-free.
A binary tree is one of the most useful data structures in programming. Instead of laying values out in a straight line like an array or a linked list, a tree organizes them in a branching, hierarchical shape: each piece of data lives in a node, and each node points to up to two other nodes below it. That branching shape is what makes trees fast to search.
You already know two ideas this lesson builds on. From Structs you know how to bundle related data into one type. From Linked lists you know how to connect nodes with pointers and walk from one to the next. A binary tree is the natural next step: instead of a single next pointer, each node has two pointers — usually called left and right. A linked list is, in a sense, a tree where every node has only one child.
The most common kind of binary tree is the binary search tree (BST). A BST adds an ordering rule so that finding a value becomes a series of yes/no decisions ("go left or go right?") rather than a scan of every element. When the tree is reasonably balanced, each decision throws away half the remaining nodes, giving roughly O(log n) lookups.
Trees are everywhere in real software: file-system directories, the parse trees compilers build, database indexes, decision logic in games and AI, and the auto-complete structures behind search boxes. Understanding the plain binary tree here is the foundation for all of them.
Key terms used throughout: a node holds a value and child pointers; the root is the topmost node; a leaf is a node with no children; the height is the number of nodes on the longest path from root to a leaf; and a subtree is any node together with everything beneath it.
Trees turn slow linear searches into fast logarithmic ones, and that difference shows up constantly in real systems:
std::map, Java's TreeMap) as balanced binary search trees, so you get sorted iteration and fast lookup for free.From a robustness standpoint, trees are also a classic place where pointer mistakes cause crashes: a forgotten reassignment silently drops nodes, and a sloppy free routine causes use-after-free or leaks. Learning to build, traverse, and tear down a tree correctly trains exactly the careful pointer discipline that production C demands.
Definition. A binary-tree node is a struct that stores a value plus two pointers to nodes of the same type — left and right. A NULL pointer means "no child here."
typedef struct tnode {
int v; /* the value stored in this node */
struct tnode *l, *r; /* left child, right child (NULL = none) */
} tnode_t;
Notice the struct refers to itself through pointers (struct tnode *). This is the same self-referential trick used in Linked lists, just with two links instead of one. The whole tree is represented by a single pointer to its root; NULL represents an empty tree.
(8) <- root
/ \
(3) (10)
/ \ \
(1) (6) (14) <- 14 is a leaf, so are 1 and 6
When to use a tree vs. a list/array. Use a tree when you need fast ordered lookup and the data changes (insert/delete) often. Use an array when data is fixed and you want cache-friendly scans; use a linked list when you only ever step front-to-back.
Pitfall. Beginners forget to set both child pointers to NULL when creating a node. An uninitialized pointer holds garbage, and the first traversal will dereference it and crash.
Knowledge check: In the diagram above, which nodes are leaves, and what is the height of the tree (count nodes on the longest root-to-leaf path)?
Definition. A BST is a binary tree with an ordering rule that must hold at every node: every value in the left subtree is less than the node's value, and every value in the right subtree is greater than it. (This lesson treats duplicates as ignored.)
An invariant is a condition you promise to keep true before and after every operation. Insert and delete must each preserve it.
node = 8
/ \
all values < 8 all values > 8
{ 1, 3, 6 } { 10, 14 }
How searching works internally. To find a value, start at the root and compare. If the target is smaller, go left; if larger, go right; if equal, you found it. Each comparison discards one whole subtree. In a balanced tree that halves the search space each step, giving O(log n). In an unbalanced tree it can be O(n) (see concept 4) — that is exactly what the quiz asks.
When NOT to use a BST. If you never search by key and only need ordering, an array + sort may be simpler. If you need guaranteed O(log n) under any input, a plain BST is not enough — use a self-balancing tree.
Pitfall. Confusing the local rule (only compare with the immediate parent) with the global invariant (the rule must hold for the whole subtree, not just the direct child). A node can satisfy its parent yet violate an ancestor.
Knowledge check: Is the following a valid BST? Root 8, left child 3, and 3's right child is 9. Explain why or why not.
Definition. A traversal is a systematic visit of every node exactly once. The three depth-first orders differ only in when you process the current node relative to its children.
| Order | Visit pattern | Common use |
|---|---|---|
| Pre-order | node, left, right | copy/serialize a tree |
| In-order | left, node, right | print a BST in sorted order |
| Post-order | left, right, node | free/delete a tree safely |
For the tree above (root 8):
in-order -> 1 3 6 8 10 14 (sorted!)
pre-order -> 8 3 1 6 10 14
post-order-> 1 6 3 14 10 8
How it works internally. Each traversal is naturally recursive: handle the left subtree, the node, and the right subtree in some order. The call stack remembers where to return. Recursion depth equals the tree's height, which matters for deep trees (concept 4).
When to use which. In-order to read sorted data; post-order to destroy a tree (children before parent); pre-order to rebuild or clone.
Pitfall. Freeing in pre-order or in-order: if you free(node) before recursing into its children, the child pointers are gone (dangling) and you crash or leak. Always free in post-order.
Knowledge check: Predict the in-order output for a BST built by inserting 5, 2, 8, 1, 3 in that order.
Definition. A tree is balanced when the left and right sides stay roughly equal in height, so height stays near log2(n). It is degenerate when nodes line up along one side, making height grow to n.
Balanced (n=7, height 3) Degenerate (n=4, height 4)
4 1
/ \ \
2 6 2
/ \ / \ \
1 3 5 7 3
\
4 (acts like a linked list)
Why it matters. A hand-written BST has no balancing logic. If you insert already-sorted input (1, 2, 3, 4, ...), every new value goes to the right, and the tree collapses into a chain. Lookups then cost O(n) — the worst case the quiz refers to. For production, use a self-balancing variant (AVL or red-black tree) or a tested library that maintains balance automatically.
Pitfall. Benchmarking a plain BST on random data, concluding it is O(log n), and then feeding it sorted real-world data that quietly degrades to O(n).
The core pieces are the node type, a node allocator, recursive insert, and recursive traversal. Here is the shape with comments on the non-obvious lines.
#include <stdlib.h>
typedef struct tnode {
int v;
struct tnode *l, *r;
} tnode_t;
/* Allocate one node; returns NULL on allocation failure. */
tnode_t *node_new(int v) {
tnode_t *n = malloc(sizeof *n); /* sizeof *n = size of one tnode_t */
if (!n) return NULL; /* always check malloc */
n->v = v;
n->l = n->r = NULL; /* a fresh node has no children */
return n;
}
/* Insert v; return the (possibly new) subtree root. */
tnode_t *insert(tnode_t *root, int v) {
if (!root) return node_new(v); /* empty spot -> create here */
if (v < root->v)
root->l = insert(root->l, v); /* reassign the child pointer! */
else if (v > root->v)
root->r = insert(root->r, v);
/* v == root->v: duplicate, ignore */
return root; /* unchanged root flows back up */
}
The critical idiom is root->l = insert(root->l, v);. Insert returns the subtree root, and the caller stores it back. That single line is what attaches a new node to its parent.
A binary tree is made of nodes. Each node can have up to two children, called left and right.
A binary search tree (BST) adds one rule, called an invariant (a condition that must always hold):
This ordering lets you find, insert, or delete a value in O(log n) time — as long as the tree stays balanced. "Balanced" means the left and right sides stay roughly equal in height.
A hand-written BST has a weakness: if you insert already-sorted input, every value goes to one side. The tree turns into a long chain — effectively a linked list — and lookups slow down to O(n).
For real-world use, prefer a self-balancing variant (such as red-black or AVL trees) or a tested library.
#include <stdio.h>
#include <stdlib.h>
typedef struct tnode {
int v;
struct tnode *l, *r;
} tnode_t;
/* Create a single node with no children. NULL on out-of-memory. */
static tnode_t *node_new(int v) {
tnode_t *n = malloc(sizeof *n);
if (!n) return NULL;
n->v = v;
n->l = n->r = NULL;
return n;
}
/* Insert v keeping the BST invariant. Returns the subtree root. */
static tnode_t *insert(tnode_t *root, int v) {
if (!root) return node_new(v);
if (v < root->v) root->l = insert(root->l, v);
else if (v > root->v) root->r = insert(root->r, v);
/* equal -> duplicate, ignored */
return root;
}
/* In-order traversal: prints a BST in ascending order. */
static void inorder(const tnode_t *root) {
if (!root) return;
inorder(root->l);
printf("%d ", root->v);
inorder(root->r);
}
/* Search: 1 if found, 0 otherwise. */
static int contains(const tnode_t *root, int v) {
while (root) {
if (v == root->v) return 1;
root = (v < root->v) ? root->l : root->r;
}
return 0;
}
/* Post-order free: children before parent, so no dangling pointers. */
static void free_tree(tnode_t *root) {
if (!root) return;
free_tree(root->l);
free_tree(root->r);
free(root);
}
int main(void) {
int vals[] = {8, 3, 10, 1, 6, 14};
size_t n = sizeof vals / sizeof vals[0];
tnode_t *root = NULL;
for (size_t i = 0; i < n; i++) {
tnode_t *updated = insert(root, vals[i]);
if (!updated) { /* allocation failed */
fprintf(stderr, "out of memory\n");
free_tree(root);
return 1;
}
root = updated; /* keep root current */
}
printf("sorted: ");
inorder(root);
putchar('\n');
printf("contains 6? %s\n", contains(root, 6) ? "yes" : "no");
printf("contains 7? %s\n", contains(root, 7) ? "yes" : "no");
free_tree(root); /* release every node */
return 0;
}
What it does. It builds a BST from {8, 3, 10, 1, 6, 14}, prints the values in sorted order via an in-order traversal, searches for two keys, and frees the whole tree.
Expected output:
sorted: 1 3 6 8 10 14
contains 6? yes
contains 7? no
Edge cases. An empty tree (root == NULL) is handled everywhere by the leading if (!root) guards. Duplicate inserts are ignored. malloc failure is checked, and on failure the partially built tree is freed before exiting so nothing leaks.
We trace the build and the in-order print.
Building the tree — root starts at NULL, then we insert each value:
| Insert | What happens |
|---|---|
| 8 | root is NULL, so node_new(8) becomes the root |
| 3 | 3 < 8 -> go left; left is NULL -> new node, attached via root->l = ... |
| 10 | 10 > 8 -> go right; right is NULL -> new node attached |
| 1 | 1 < 8 -> left to 3; 1 < 3 -> left of 3 is NULL -> new node |
| 6 | 6 < 8 -> left to 3; 6 > 3 -> right of 3 -> new node |
| 14 | 14 > 8 -> right to 10; 14 > 10 -> right of 10 -> new node |
Resulting shape:
(8)
/ \
(3) (10)
/ \ \
(1) (6) (14)
In-order traversal of the root (8). inorder does left, then self, then right. Following the recursion:
inorder(8) calls inorder(3) first.inorder(3) calls inorder(1); node 1 has no left, so it prints 1, then has no right -> returns.inorder(3): print 3, then inorder(6) prints 6.inorder(8): print 8.inorder(10): no left, print 10, then inorder(14) prints 14.Output order: 1 3 6 8 10 14 — sorted, because in-order always visits everything smaller (left subtree) before the node and everything larger (right subtree) after it.
Why root = updated; matters. The very first insert (8) creates the root and returns it; if we did not store the return value, root would stay NULL and the tree would never exist. For later inserts the root is unchanged, but the inner root->l = insert(...) lines are what wire each new node to its parent.
Mistake 1 — Discarding the return value of insert.
/* WRONG */
insert(root->l, v); /* result thrown away */
If root->l was NULL, insert creates a node and returns it, but nothing stores it, so the node is never attached (and leaks). Fix: always reassign.
/* CORRECT */
root->l = insert(root->l, v);
How to recognize it: values "disappear" — you insert them but searches and traversals never find them.
Mistake 2 — Forgetting to set children to NULL.
/* WRONG */
tnode_t *n = malloc(sizeof *n);
n->v = v; /* l and r still hold garbage */
return n;
The first traversal dereferences a garbage pointer and crashes. Fix: n->l = n->r = NULL; right after allocation.
Mistake 3 — Freeing in the wrong order.
/* WRONG */
free(root);
free_tree(root->l); /* root is already freed: use-after-free */
Fix: recurse into both children first, then free the node (post-order). See free_tree in the example.
Mistake 4 — Not checking malloc.
Ignoring a NULL return from malloc means the next n->v = v; writes through a null pointer. Fix: check every allocation and handle failure gracefully (free what you built, report, exit).
Mistake 5 — Assuming the tree is balanced.
Inserting sorted data into a plain BST makes it a chain. Fix: shuffle inputs if you can, or use a self-balancing tree/library when O(log n) must be guaranteed.
Compiler errors
struct tnode fields before the struct is fully declared, or used the typedef name inside the struct body. Inside the struct use struct tnode *l;, not tnode_t *l;.malloc/free — include <stdlib.h>.Runtime errors (crashes)
node_new sets l and r to NULL.free_tree.gcc -g -fsanitize=address,undefined tree.c && ./a.out catches use-after-free, leaks, and out-of-bounds immediately. valgrind ./a.out reports leaks and invalid reads/writes.Logic errors
< vs > and that you compare against root->v.insert (Mistake 1), or your duplicate handling silently swallows them.Questions to ask when it doesn't work
root still point at a real node (not NULL)?Binary trees are heap-allocated and pointer-heavy, so they touch most of C's classic hazards:
v, l, and r set before use. Uninitialized l/r are the number-one cause of tree segfaults.malloc as able to fail. After every allocation, check for NULL before writing fields. Every recursive function here also guards if (!root) return; so a NULL subtree is safe.free_tree, the caller's root is dangling — do not touch it; set it to NULL if it will be reused.free_tree. On an early error path (like the out-of-memory branch in main), free what was already built before returning.int; accumulate into a wider type such as long (the companion exercise tree_sum returns long for exactly this reason).Concrete uses.
WHERE id = ... lookup is logarithmic, not a full table scan.std::map/std::set, Java TreeMap/TreeSet — are red-black trees, giving sorted iteration plus O(log n) operations.Professional best-practice habits.
Beginner rules:
NULL and check malloc.free_tree and run the result under AddressSanitizer/Valgrind.insert, contains, inorder, free_tree) and keep one responsibility each.const tnode_t * to document intent and catch accidental writes.Advanced rules:
Beginner 1 — Count the nodes.
int count(const tnode_t *root) returning the number of nodes.{8,3,10,1,6,14} returns 6.1 + count(left) + count(right), with if (!root) return 0;.Beginner 2 — Find the minimum.
int tree_min(const tnode_t *root) returning the smallest value in a non-empty BST.root is non-NULL; do it without a full traversal.{8,3,10,1,6,14} returns 1.->l until it is NULL.Intermediate 1 — Search returning the node.
tnode_t *find(tnode_t *root, int v) returning the matching node or NULL.while loop), O(height).find(root, 6) returns the node holding 6; find(root, 7) returns NULL.Intermediate 2 — Pre-order and post-order printers.
preorder and postorder printing functions.const.{8,3,10,1,6,14}, pre-order prints 8 3 1 6 10 14 and post-order prints 1 6 3 14 10 8.Challenge — Validate a BST.
int is_bst(const tnode_t *root) returning 1 if the tree obeys the BST invariant globally, else 0.(min, max) range and tighten it as you descend (use the widest sentinels for the root).left and right; NULL means no child and an empty tree is just a NULL root.root->l = insert(root->l, v); — insert returns the subtree root and the caller stores it back. Forgetting this drops nodes.malloc failure, and assuming balance.