Objective:
Write a program to count the frequencies of unique words from standard input, then print them out with their frequencies, ordered most frequent first. For example, given this input:
The foo the foo the
defenestration the
The program should print the following:
the 4
foo 2
defenestration 1
The test input file will be the text of the King James Bible, concatenated 10 times.
Code:
I used the public-domain stb library stb_ds.h for the hash table.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <stdint.h>
#include <stdbool.h>
#include <limits.h>
#include <ctype.h>
#define STB_DS_IMPLEMENTATION
#include "stb_ds.h"
/* pneumonoultramicroscopicsilicovolcanoconiosis - a lung disease caused by
* inhaling silica dust. */
#define LONGEST_WORD 45
#define CHUNK_SIZE (8 * 1024)
typedef struct count {
char *key;
size_t value;
} count;
static int cmp_func(const void *a, const void *b)
{
const count *const p = *(const count * const *) a;
const count *const q = *(const count * const *) b;
return (p->value < q->value) - (p->value > q->value);
}
static void replace_punctuation(size_t len, char s[static len])
{
// ".,;:!?\"()[]{}-"
static const char table[UCHAR_MAX + 1] = {
['.'] = '.' ^ ' ',[','] = ',' ^ ' ',[';'] = ';' ^ ' ',[':'] = ':' ^ ' ',
['!'] = '!' ^ ' ',['?'] = '?' ^ ' ',['"'] = '"' ^ ' ',['('] = '(' ^ ' ',
[')'] = ')' ^ ' ',['['] = '[' ^ ' ',[']'] = ']' ^ ' ',['{'] = '{' ^ ' ',
['}'] = '}' ^ ' ',['-'] = '-' ^ ' '
};
for (size_t i = 0; i < len; ++i) {
s[i] ^= table[((unsigned char *) s)[i]];
}
}
static count *load_ht(FILE * stream)
{
count *ht = NULL;
/* Store the string keys in an arena private to this hash table. */
sh_new_arena(ht);
char chunk[CHUNK_SIZE];
size_t offset = 0;
while (true) {
const size_t nread =
fread(chunk + offset, 1, CHUNK_SIZE - offset, stream);
if (ferror(stream)) {
shfree(ht);
return NULL;
}
if (nread + offset == 0) {
break;
}
/* Search for last white-space character in chunk and process up to there. */
/* Can we replace this with a library function? */
size_t curr_chunk_end;
for (curr_chunk_end = nread + offset - 1; curr_chunk_end != SIZE_MAX;
--curr_chunk_end) {
const unsigned char c = (unsigned char) chunk[curr_chunk_end];
if (isspace(c)) {
break;
}
}
/* How can we iterate the chunk just once? */
const size_t curr_chunk_size =
curr_chunk_end != SIZE_MAX ? curr_chunk_end : nread + offset;
replace_punctuation(curr_chunk_size, &chunk[0]);
size_t i = 0;
while (true) {
/* Malformed input? Perhaps add a check and make the program slower,
* or give the user what it deserves. */
char word[LONGEST_WORD];
size_t word_len = 0;
while (isspace((unsigned char) chunk[i])) {
++i;
}
const size_t start = i;
/* Profiling showed that much of the time is spent in this loop. */
for (; i < curr_chunk_size && !isspace((unsigned char) chunk[i]);
++i) {
word[word_len++] = (char) tolower((unsigned char) chunk[i]);
}
if (i == start) {
break;
}
word[word_len] = '\0';
/* Skip words beginning with a digit. */
if (!isdigit((unsigned char) word[0])) {
/* Strip possessive nouns. */
if (word_len >= 2 && word[word_len - 1] == 's'
&& word[word_len - 2] == '\'') {
word[word_len - 2] = '\0';
} else if (word[word_len - 1] == '\'') {
word[word_len - 1] = '\0';
}
const size_t new_count = shget(ht, word);
shput(ht, word, new_count + 1U);
}
}
/* Move down remaining partial word. */
if (curr_chunk_end != SIZE_MAX) {
offset = (nread + offset - 1) - curr_chunk_end;
memmove(chunk, chunk + curr_chunk_end + 1, offset);
} else {
offset = 0;
}
}
return ht;
}
int main(void)
{
count *ht = load_ht(stdin);
if (!ht) {
perror("fread()");
return EXIT_FAILURE;
}
size_t ht_len = shlenu(ht);
/* Profiling the code didn't show malloc()/mmap()/brk()/sbrk() to be a
* bottleneck.
*/
count **const ordered = malloc(sizeof *ordered * ht_len);
int rv = EXIT_FAILURE;
if (!ordered) {
goto cleanup;
}
for (size_t i = 0; i < ht_len; ++i) {
ordered[i] = malloc(sizeof **ordered);
if (!ordered[i]) {
while (i) {
free(ordered[i--]);
}
goto cleanup;
}
ordered[i]->key = ht[i].key;
ordered[i]->value = ht[i].value;
}
qsort(ordered, ht_len, sizeof *ordered, cmp_func);
for (size_t i = 0; i < ht_len; ++i) {
printf("%-*s\t%zu\n", LONGEST_WORD, ordered[i]->key, ordered[i]->value);
free(ordered[i]);
}
rv = EXIT_SUCCESS;
cleanup:
free(ordered);
shfree(ht);
return rv;
}
The program has 127 LOC (excluding stb_ds.h), and the final executable (stripped) sizes around 19 KB.
And this is how it performed:
» time ./wordfreq < kjvbible_10.txt 1> /dev/null
./wordfreq < kjvbible_10.txt > /dev/null 2.39s user 0.10s system 99% cpu 2.507 total
-----------------------------------------------------------------------------------
» time wc kjvbible_10.txt
998170 8211330 43325060 kjvbible_10.txt
wc kjvbible_10.txt 1.12s user 0.02s system 98% cpu 1.156 total
Review Goals:
- How can the total running time be reduced? What's a better algorithm for processing the file?
- Does any part of my code invokes undefined behavior? Have I missed any edge-cases?
- General coding comments, style, et cetera.
replace_punctuation()
is wiki, yet thanks for your comment.. \$\endgroup\$