Why this matters
The modules so far each taught one idea in isolation. Real programs combine them: reading input character by character, managing heap memory, picking a data structure, sorting with a callback, parsing arguments, reporting errors with the right exit status, and cleaning up. This project builds wordfreq, a small but genuinely useful tool (the kind of thing you would otherwise do with a shell pipeline like tr -cs A-Za-z '\n' | sort | uniq -c | sort -rn), in three steps. Along the way, notice how each design decision follows from something you learned earlier.
What we are building
$ ./wordfreq 5 < text.txt
23 words, 14 distinct
5 the
3 dog
3 fox
2 a
1 and
Specification:
- Read text from standard input until end of file.
- A word is a maximal run of letters; anything else (spaces, digits, punctuation, apostrophes) separates words. Words are compared case-insensitively, so they are stored in lowercase. This splits
doesn't into doesn and t; that is a deliberate simplification you can improve later. - Print
W words, D distinct, then the N most frequent words (default 10), most frequent first, ties in alphabetical order, as %7d %s. - Bad arguments produce a message on stderr and exit status 2, following the Unix convention that 2 means "usage error".
Design
Three pieces, each a separate concern:
stdin --> next_word() --> add_word() --> hash table --> array of pointers --> qsort --> print
tokenizer counting collect order
Tokenizer. getchar gives one character at a time, which makes "skip non-letters, then copy letters" a pair of simple loops. The caller provides the buffer and its capacity, the safe pattern from the strings module: the function never writes more than cap bytes, and an overlong word is truncated rather than overflowing.
Counting. We need "find this word, or add it" for every word of input, so lookup must be fast. A hash table with separate chaining does that: an array of buckets, each the head of a linked list of entries.
table[NBUCKETS]
+-----+
| 0 | -> NULL
+-----+
| 1 | -> ["fox", 3] -> ["end", 1] -> NULL
+-----+
| ... |
+-----+
| 812 | -> ["the", 5] -> NULL
+-----+
hash(word) % NBUCKETS picks the bucket. We use the djb2 hash (h = h * 33 + c), simple and good enough for words. A fixed 1024 buckets keeps the code short; the data structures module showed how to grow the table when it fills up.
Each entry owns a heap copy of its word. This is essential: next_word writes into the same local buffer every time, so storing a pointer to that buffer would make every entry point at the last word read. Ownership also decides cleanup: whoever owns the table frees every word and every entry, once, at the end.
Ordering. A hash table has no useful order, so at the end we collect a pointer to every entry into one array and sort that with qsort. Sorting pointers rather than whole structs is cheaper (8 bytes moved per swap) and leaves the table intact for freeing.
Step 1: tokenizing
size_t next_word(char *buf, size_t cap)
{
int c;
size_t len = 0;
while ((c = getchar()) != EOF && !isalpha(c))
; /* skip separators */
while (c != EOF && isalpha(c)) {
if (len < cap - 1)
buf[len++] = (char)tolower(c); /* store, if there is room */
c = getchar(); /* but always consume */
}
buf[len] = '\0';
return len;
}
Details that matter:
c is an int. getchar returns either a byte value (0 to 255) or EOF (-1); a char cannot hold all 257 possibilities.isalpha and tolower take an int that must be EOF or an unsigned char value, which is exactly what getchar returns. (Passing a plain char with a negative value is undefined, a classic bug when the input contains non-ASCII bytes.)- The function returns 0 only when it reached end of input without finding a letter, so the caller can write
while (next_word(w, sizeof w) > 0). - The letter that ends a word is a separator, which the next call skips. Nothing is lost.
Step 2: counting
add_word is the classic "find or insert":
static int add_word(const char *w)
{
unsigned long b = hash(w) % NBUCKETS;
for (struct entry *e = table[b]; e != NULL; e = e->next) {
if (strcmp(e->word, w) == 0) {
e->count++;
return 0;
}
}
struct entry *e = malloc(sizeof *e);
if (e == NULL)
return -1;
e->word = malloc(strlen(w) + 1);
if (e->word == NULL) {
free(e); /* do not leak the half-built entry */
return -1;
}
strcpy(e->word, w);
e->count = 1;
e->next = table[b]; /* push on the front: O(1) */
table[b] = e;
distinct++;
return 0;
}
Two allocations per new word, and either can fail; if the second fails, the first must be released before reporting the error. Freeing the table walks each list, saving next before each free because the entry's memory is invalid afterwards.
The table and distinct are static globals here to keep the project small. In a library you would wrap them in a struct table and pass a pointer, so a program could have several tables.
Step 3: sorting and finishing
qsort sorts any array given its element size and a comparator: a function that receives pointers to two elements and returns negative, zero or positive. Our elements are struct entry *, so the comparator receives pointers to pointers:
static int by_count(const void *a, const void *b)
{
const struct entry *x = *(const struct entry *const *)a;
const struct entry *y = *(const struct entry *const *)b;
if (x->count != y->count)
return x->count < y->count ? 1 : -1; /* bigger counts first */
return strcmp(x->word, y->word); /* then alphabetical */
}
Writing return y->count - x->count; also works for word counts, but the subtraction trick overflows for values far apart, so the explicit comparison is the better habit. The tie-breaker matters too: without it, words with equal counts come out in whatever order the hash table and qsort happen to produce, and tests that compare output would be flaky.
Argument parsing uses strtol with its end pointer, as in the robust parsing lesson: "10" is fine; "", "ten", "5x" and "0" are rejected. Allocating the pointer array needs care with an empty input, where distinct is 0: malloc(0) may legally return NULL, which would look like an out-of-memory error, so the solution asks for at least one element.
Testing it
Beyond the example inputs, test the edges, which is where C programs break: empty input, input with no letters, one enormous word, a word that repeats a million times, invalid arguments. And run it under valgrind:
$ valgrind -q --leak-check=full ./wordfreq < text.txt > /dev/null
$
Silence means no errors and no leaks.
Common mistakes
- Storing the buffer pointer instead of a copy in the entry. Every word then shows up as the last word read, and the program may read freed or overwritten memory.
char c = getchar();: EOF detection breaks (on x86 a byte 0xFF looks like EOF; on platforms with unsigned char, EOF is never seen and the loop never ends).- Comparator confusion: casting
a to struct entry * instead of struct entry ** compiles, and then reads garbage. Draw the picture: the array holds pointers, qsort passes pointers to the array slots. - Forgetting
free(all) or freeing entries before printing from the array. - No tie-breaker, giving nondeterministic output.
Recap
- A project is a pipeline of small, separately testable functions: tokenize, count, collect, sort, print.
- Caller-provided buffers with a capacity make tokenizing safe.
- A chained hash table gives fast find-or-insert; each entry owns a heap copy of its key.
qsort with a comparator sorts an array of pointers; always add a tie-breaker.- Every allocation has one owner and one matching
free, including on error paths.
Going further: extend wordfreq to keep apostrophes inside words (doesn't), to read file names given as extra arguments instead of stdin, or to grow the hash table when the average chain length passes 2. Then time it on a big text: seq 1 200000 | sed 's/.*/word&/' | ./wordfreq generates 200,000 distinct words.
Exercise 1 · write code
Step 1: split text into words
Write next_word, which reads the next word from standard input into buf. A word is a maximal run of letters (isalpha); everything else separates words. Store the word in lowercase. If a word is longer than cap - 1 letters, keep only the first cap - 1 letters but still consume the rest. Always null-terminate buf. Return the word's stored length, or 0 when there are no more words.
main prints each word on its own line. For the input The fox's DOG-house, 42 times! it prints:
the
fox
s
dog
house
times
File: main.c
Starting point
#include <ctype.h>
#include <stdio.h>
#define MAX_WORD 64
size_t next_word(char *buf, size_t cap)
{
/* 1. skip characters that are not letters (stop at EOF)
2. copy letters, lowercased, while there is room
3. terminate buf and return the length */
buf[0] = '\0';
return 0;
}
int main(void)
{
char w[MAX_WORD];
while (next_word(w, sizeof w) > 0)
printf("%s\n", w);
return 0;
}
Example
input
The fox's DOG-house, 42 times!
expected output
the
fox
s
dog
house
times
Example
input
123 ... !!!
expected output
(nothing)
Hint 1
Store getchar's result in an int, not a char, so EOF can be told apart from a real byte.
Hint 2
First loop: while ((c = getchar()) != EOF && !isalpha(c)) ; skips separators. The second loop copies letters while isalpha(c).
Hint 3
Only store a letter when len < cap - 1, but keep reading until the word ends.
Reference solution
#include <ctype.h>
#include <stdio.h>
#define MAX_WORD 64
size_t next_word(char *buf, size_t cap)
{
int c;
size_t len = 0;
while ((c = getchar()) != EOF && !isalpha(c))
;
while (c != EOF && isalpha(c)) {
if (len < cap - 1)
buf[len++] = (char)tolower(c);
c = getchar();
}
buf[len] = '\0';
return len;
}
int main(void)
{
char w[MAX_WORD];
while (next_word(w, sizeof w) > 0)
printf("%s\n", w);
return 0;
}
Exercise 2 · write code
Step 2: count with a hash table
Now count. The starter provides next_word, a hash function and an empty chained hash table. Write add_word(w): find w in its bucket and increment its count, or, if it is not there, allocate a new struct entry with its own copy of the word, count 1, and push it on the front of the bucket's list (incrementing distinct). Return 0 on success, -1 if malloc fails. Then write free_table(), which frees every entry and every word.
The program prints one line, for example for the cat and the hat:
5 words, 4 distinct
The check also runs your program under valgrind and fails on any leak or memory error.
File: main.c
Starting point
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MAX_WORD 64
#define NBUCKETS 1024
struct entry {
char *word;
int count;
struct entry *next;
};
static struct entry *table[NBUCKETS];
static size_t distinct;
static size_t next_word(char *buf, size_t cap)
{
int c;
size_t len = 0;
while ((c = getchar()) != EOF && !isalpha(c))
;
while (c != EOF && isalpha(c)) {
if (len < cap - 1)
buf[len++] = (char)tolower(c);
c = getchar();
}
buf[len] = '\0';
return len;
}
static unsigned long hash(const char *s)
{
unsigned long h = 5381;
while (*s)
h = h * 33 + (unsigned char)*s++;
return h;
}
static int add_word(const char *w)
{
unsigned long b = hash(w) % NBUCKETS;
(void)b;
/* search table[b] for w; if found, count++ and return 0.
otherwise create a new entry (malloc the struct AND a copy of the word),
put it at the front of table[b], distinct++ */
return 0;
}
static void free_table(void)
{
/* free every entry and its word */
}
int main(void)
{
char w[MAX_WORD];
long total = 0;
while (next_word(w, sizeof w) > 0) {
if (add_word(w) != 0) {
fprintf(stderr, "wordfreq: out of memory\n");
free_table();
return 1;
}
total++;
}
printf("%ld words, %zu distinct\n", total, distinct);
free_table();
return 0;
}
Example
input
the cat and the hat
expected output
5 words, 4 distinct
Example
input
The quick brown fox jumps over the lazy dog. The dog sleeps;
the fox doesn't. A fox, a dog, and THE end.
expected output
23 words, 14 distinct
Hint 1
Walk the bucket with for (struct entry *e = table[b]; e != NULL; e = e->next) and compare with strcmp.
Hint 2
The entry must own a copy of the word: w is the caller's buffer and is overwritten by the next word. malloc strlen(w) + 1 bytes and strcpy.
Hint 3
Inserting at the front: e->next = table[b]; table[b] = e;
Hint 4
When freeing a list, save e->next before freeing e.
Reference solution
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MAX_WORD 64
#define NBUCKETS 1024
struct entry {
char *word;
int count;
struct entry *next;
};
static struct entry *table[NBUCKETS];
static size_t distinct;
static size_t next_word(char *buf, size_t cap)
{
int c;
size_t len = 0;
while ((c = getchar()) != EOF && !isalpha(c))
;
while (c != EOF && isalpha(c)) {
if (len < cap - 1)
buf[len++] = (char)tolower(c);
c = getchar();
}
buf[len] = '\0';
return len;
}
static unsigned long hash(const char *s)
{
unsigned long h = 5381;
while (*s)
h = h * 33 + (unsigned char)*s++;
return h;
}
static int add_word(const char *w)
{
unsigned long b = hash(w) % NBUCKETS;
for (struct entry *e = table[b]; e != NULL; e = e->next) {
if (strcmp(e->word, w) == 0) {
e->count++;
return 0;
}
}
struct entry *e = malloc(sizeof *e);
if (e == NULL)
return -1;
e->word = malloc(strlen(w) + 1);
if (e->word == NULL) {
free(e);
return -1;
}
strcpy(e->word, w);
e->count = 1;
e->next = table[b];
table[b] = e;
distinct++;
return 0;
}
static void free_table(void)
{
for (size_t b = 0; b < NBUCKETS; b++) {
struct entry *e = table[b];
while (e != NULL) {
struct entry *next = e->next;
free(e->word);
free(e);
e = next;
}
table[b] = NULL;
}
}
int main(void)
{
char w[MAX_WORD];
long total = 0;
while (next_word(w, sizeof w) > 0) {
if (add_word(w) != 0) {
fprintf(stderr, "wordfreq: out of memory\n");
free_table();
return 1;
}
total++;
}
printf("%ld words, %zu distinct\n", total, distinct);
free_table();
return 0;
}
Exercise 3 · write code
Step 3: the finished tool
Finish wordfreq. Building on step 2 (provided), add:
- An optional argument N, the number of words to list (default 10). If there are more than one argument, print
usage: wordfreq [N] to stderr and exit with status 2. If N is not a whole positive number (use strtol and check the end pointer), print wordfreq: invalid count 'ARG' to stderr and exit with status 2. - Sorted output. After the summary line, print the N most frequent words, most frequent first; words with equal counts in alphabetical order. Each line is the count right-aligned in 7 characters, a space, and the word (
printf("%7d %s\n", ...)). Collect pointers to all entries in a malloc'd array and sort it with qsort. - No leaks, including the array (checked with valgrind).
For It was the best of times, it was the worst of times. and N = 3:
12 words, 7 distinct
2 it
2 of
2 the
File: main.c
Starting point
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MAX_WORD 64
#define NBUCKETS 1024
struct entry {
char *word;
int count;
struct entry *next;
};
static struct entry *table[NBUCKETS];
static size_t distinct;
static size_t next_word(char *buf, size_t cap)
{
int c;
size_t len = 0;
while ((c = getchar()) != EOF && !isalpha(c))
;
while (c != EOF && isalpha(c)) {
if (len < cap - 1)
buf[len++] = (char)tolower(c);
c = getchar();
}
buf[len] = '\0';
return len;
}
static unsigned long hash(const char *s)
{
unsigned long h = 5381;
while (*s)
h = h * 33 + (unsigned char)*s++;
return h;
}
static int add_word(const char *w)
{
unsigned long b = hash(w) % NBUCKETS;
for (struct entry *e = table[b]; e != NULL; e = e->next) {
if (strcmp(e->word, w) == 0) {
e->count++;
return 0;
}
}
struct entry *e = malloc(sizeof *e);
if (e == NULL)
return -1;
e->word = malloc(strlen(w) + 1);
if (e->word == NULL) {
free(e);
return -1;
}
strcpy(e->word, w);
e->count = 1;
e->next = table[b];
table[b] = e;
distinct++;
return 0;
}
static void free_table(void)
{
for (size_t b = 0; b < NBUCKETS; b++) {
struct entry *e = table[b];
while (e != NULL) {
struct entry *next = e->next;
free(e->word);
free(e);
e = next;
}
table[b] = NULL;
}
}
/* TODO: a qsort comparator. It receives pointers to two array elements,
and each element is a struct entry *, so the arguments are really
struct entry *const * in disguise. */
int main(int argc, char *argv[])
{
long top = 10;
(void)argc; (void)argv; /* TODO: parse the optional N */
char w[MAX_WORD];
long total = 0;
while (next_word(w, sizeof w) > 0) {
if (add_word(w) != 0) {
fprintf(stderr, "wordfreq: out of memory\n");
free_table();
return 1;
}
total++;
}
printf("%ld words, %zu distinct\n", total, distinct);
(void)top; /* TODO: collect, sort and print the top words */
free_table();
return 0;
}
Example with arguments 3
input
It was the best of times, it was the worst of times.
expected output
12 words, 7 distinct
2 it
2 of
2 the
Example
input
The quick brown fox jumps over the lazy dog. The dog sleeps;
the fox doesn't. A fox, a dog, and THE end.
expected output
23 words, 14 distinct
5 the
3 dog
3 fox
2 a
1 and
1 brown
1 doesn
1 end
1 jumps
1 lazy
Hint 1
The comparator gets const void * pointers to array elements. Each element is a struct entry *, so convert with *(const struct entry *const *)a.
Hint 2
Return negative if a should come first. For descending counts: if x->count > y->count return -1; if smaller return 1; otherwise return strcmp(x->word, y->word).
Hint 3
For N: char *end; long v = strtol(argv[1], &end, 10); is valid only if argv[1] is not empty, *end == '\0', and v >= 1.
Hint 4
Remember to free the pointer array as well as the table.
Reference solution
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define MAX_WORD 64
#define NBUCKETS 1024
struct entry {
char *word;
int count;
struct entry *next;
};
static struct entry *table[NBUCKETS];
static size_t distinct;
static size_t next_word(char *buf, size_t cap)
{
int c;
size_t len = 0;
while ((c = getchar()) != EOF && !isalpha(c))
;
while (c != EOF && isalpha(c)) {
if (len < cap - 1)
buf[len++] = (char)tolower(c);
c = getchar();
}
buf[len] = '\0';
return len;
}
static unsigned long hash(const char *s)
{
unsigned long h = 5381;
while (*s)
h = h * 33 + (unsigned char)*s++;
return h;
}
static int add_word(const char *w)
{
unsigned long b = hash(w) % NBUCKETS;
for (struct entry *e = table[b]; e != NULL; e = e->next) {
if (strcmp(e->word, w) == 0) {
e->count++;
return 0;
}
}
struct entry *e = malloc(sizeof *e);
if (e == NULL)
return -1;
e->word = malloc(strlen(w) + 1);
if (e->word == NULL) {
free(e);
return -1;
}
strcpy(e->word, w);
e->count = 1;
e->next = table[b];
table[b] = e;
distinct++;
return 0;
}
static void free_table(void)
{
for (size_t b = 0; b < NBUCKETS; b++) {
struct entry *e = table[b];
while (e != NULL) {
struct entry *next = e->next;
free(e->word);
free(e);
e = next;
}
table[b] = NULL;
}
}
static int by_count(const void *a, const void *b)
{
const struct entry *x = *(const struct entry *const *)a;
const struct entry *y = *(const struct entry *const *)b;
if (x->count != y->count)
return x->count < y->count ? 1 : -1;
return strcmp(x->word, y->word);
}
int main(int argc, char *argv[])
{
long top = 10;
if (argc > 2) {
fprintf(stderr, "usage: wordfreq [N]\n");
return 2;
}
if (argc == 2) {
char *end;
top = strtol(argv[1], &end, 10);
if (argv[1][0] == '\0' || *end != '\0' || top < 1) {
fprintf(stderr, "wordfreq: invalid count '%s'\n", argv[1]);
return 2;
}
}
char w[MAX_WORD];
long total = 0;
while (next_word(w, sizeof w) > 0) {
if (add_word(w) != 0) {
fprintf(stderr, "wordfreq: out of memory\n");
free_table();
return 1;
}
total++;
}
printf("%ld words, %zu distinct\n", total, distinct);
struct entry **all = malloc((distinct > 0 ? distinct : 1) * sizeof *all);
if (all == NULL) {
fprintf(stderr, "wordfreq: out of memory\n");
free_table();
return 1;
}
size_t n = 0;
for (size_t b = 0; b < NBUCKETS; b++)
for (struct entry *e = table[b]; e != NULL; e = e->next)
all[n++] = e;
qsort(all, n, sizeof *all, by_count);
for (size_t i = 0; i < n && i < (size_t)top; i++)
printf("%7d %s\n", all[i]->count, all[i]->word);
free(all);
free_table();
return 0;
}