Projects · about 90 min

Project: word frequency counter

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:

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:

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

Recap

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.

Exercises

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:

  1. 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.
  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.
  3. 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;
}

Exercise 4 · quiz

Why a hash table?

A text has W words, D of them distinct. Instead of the hash table, you could keep the distinct words in an unsorted array and search it linearly for each new word. Roughly how many string comparisons does that take in total, compared with the hash table (1024 buckets, D around a few thousand)?

Answer
  • Linear search needs up to about W x D comparisons; the hash table needs only about W x (D / 1024), a few per word

With linear search each of the W words may be compared against all D distinct words seen so far, so the work grows like W x D: for a novel (W = 200,000, D = 15,000) that is billions of comparisons. Hashing sends each word straight to one bucket whose list holds about D / 1024 entries, so each lookup costs one hash plus a handful of comparisons. A hash table that grows (rehashing into more buckets as D increases, as in the data structures module) keeps that constant no matter how big D gets.