Lab · Playground
Word frequency in fifteen languages
Count the words in a paragraph and list the ten most common — split, count, sort — in fifteen languages, from one-line pipelines in Ruby and Clojure to a hand-rolled hash table in C and a recursive string split in SQL.
The job: take a paragraph, lower-case it, split it into words (runs of the letters a to z), count each word, and print the ten most common — most frequent first, and alphabetically when two words are tied. It is three steps, and most languages have a word for each: split, count (a hash map, or a dedicated counter), sort (by two keys). How short each version is says a lot about what the language’s standard library thinks is normal work.
The tie-break matters more than it looks. Six words occur three times here, and "most common first" alone does not say which order they come in — several languages' built-in "most common" helpers return ties in whatever order they were first seen, and hash maps iterate in an order of their own. Asking for alphabetical order on ties is what makes all fifteen blocks print exactly the same thing.
Things to try: paste in your own text; change the 10; count pairs of words instead of words; or make the sort ignore the tie-break and see which languages' output changes.
Pipelines
const TEXT = `The river rose in the night, and by morning the road to the mill was under
water. The miller walked down to the edge of the water and looked at the mill:
the wheel was turning faster than it had ever turned, and the river was loud.
He thought of the grain in the loft, dry for now, and of the long road round by
the bridge. A boat would be faster than the road, but the boat was at the mill.
So he sat on a stone by the water, and waited for the river to make up its mind.`;
const words = TEXT.toLowerCase().match(/[a-z]+/g);
const counts = new Map();
for (const w of words) counts.set(w, (counts.get(w) ?? 0) + 1);
console.log(`words: ${words.length}, distinct: ${counts.size}`);
const top = [...counts]
.sort(([a, m], [b, n]) => n - m || (a < b ? -1 : 1))
.slice(0, 10);
for (const [word, n] of top) console.log(`${String(n).padStart(3)} ${word}`);
A regular expression with the g flag does the splitting, a Map the counting, and the
comparator’s n - m || … is the two-key sort: when the counts are equal the difference is 0,
which is falsy, so || falls through to the words. It compares with < rather than
localeCompare, whose order depends on the reader’s language settings.
import re
from collections import Counter
TEXT = """The river rose in the night, and by morning the road to the mill was under
water. The miller walked down to the edge of the water and looked at the mill:
the wheel was turning faster than it had ever turned, and the river was loud.
He thought of the grain in the loft, dry for now, and of the long road round by
the bridge. A boat would be faster than the road, but the boat was at the mill.
So he sat on a stone by the water, and waited for the river to make up its mind."""
words = re.findall(r"[a-z]+", TEXT.lower())
counts = Counter(words)
print(f"words: {len(words)}, distinct: {len(counts)}")
for word, n in sorted(counts.items(), key=lambda kv: (-kv[1], kv[0]))[:10]:
print(f"{n:3d} {word}")
Counter is a dictionary built for exactly this. It has a most_common(10) method, and it is
tempting — but it breaks ties by the order words were first seen, so river would come before
by. Sorting on the tuple (-count, word) gives both keys at once.
TEXT = <<~END
The river rose in the night, and by morning the road to the mill was under
water. The miller walked down to the edge of the water and looked at the mill:
the wheel was turning faster than it had ever turned, and the river was loud.
He thought of the grain in the loft, dry for now, and of the long road round by
the bridge. A boat would be faster than the road, but the boat was at the mill.
So he sat on a stone by the water, and waited for the river to make up its mind.
END
words = TEXT.downcase.scan(/[a-z]+/)
counts = words.tally
puts "words: #{words.size}, distinct: #{counts.size}"
counts.sort_by { |word, n| [-n, word] }.first(10).each do |word, n|
puts format("%3d %s", n, word)
end
scan finds every match, tally counts them into a hash (Ruby 2.7+), and sort_by with an array
key sorts on both at once, because arrays compare element by element. The whole program is a
pipeline of methods on the data.
(def text "The river rose in the night, and by morning the road to the mill was under
water. The miller walked down to the edge of the water and looked at the mill:
the wheel was turning faster than it had ever turned, and the river was loud.
He thought of the grain in the loft, dry for now, and of the long road round by
the bridge. A boat would be faster than the road, but the boat was at the mill.
So he sat on a stone by the water, and waited for the river to make up its mind.")
(let [words (re-seq #"[a-z]+" (clojure.string/lower-case text))
counts (frequencies words)]
(println (str "words: " (count words) ", distinct: " (count counts)))
(doseq [[word n] (->> counts
(sort-by (juxt (comp - val) key))
(take 10))]
(println (str (.padStart (str n) 3) " " word))))
frequencies is the counting step as a single core function, and →> threads the result
through the sort and the take. (juxt (comp - val) key) builds the two-key sort key — a vector
of the negated count and the word — from functions, without naming an argument. Scittle is
ClojureScript, so .padStart is JavaScript’s string method, called directly.
val TEXT = """The river rose in the night, and by morning the road to the mill was under
water. The miller walked down to the edge of the water and looked at the mill:
the wheel was turning faster than it had ever turned, and the river was loud.
He thought of the grain in the loft, dry for now, and of the long road round by
the bridge. A boat would be faster than the road, but the boat was at the mill.
So he sat on a stone by the water, and waited for the river to make up its mind."""
fun main() {
val words = Regex("[a-z]+").findAll(TEXT.lowercase()).map { it.value }.toList()
val counts = words.groupingBy { it }.eachCount()
println("words: ${words.size}, distinct: ${counts.size}")
counts.entries
.sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key })
.take(10)
.forEach { (word, n) -> println("%3d %s".format(n, word)) }
}
groupingBy { it }.eachCount() is Kotlin’s counter, and compareByDescending … thenBy builds a
comparator from two key functions — the type annotation is needed once, so the compiler knows what
it is.
text <- "The river rose in the night, and by morning the road to the mill was under
water. The miller walked down to the edge of the water and looked at the mill:
the wheel was turning faster than it had ever turned, and the river was loud.
He thought of the grain in the loft, dry for now, and of the long road round by
the bridge. A boat would be faster than the road, but the boat was at the mill.
So he sat on a stone by the water, and waited for the river to make up its mind."
lower <- tolower(text)
words <- regmatches(lower, gregexpr("[a-z]+", lower))[[1]]
counts <- table(words)
cat(sprintf("words: %d, distinct: %d\n", length(words), length(counts)))
top <- head(counts[order(-counts, names(counts), method = "radix")], 10)
cat(sprintf("%3d %s\n", as.vector(top), names(top)), sep = "")
table counts the distinct values of a vector — it is how R does everything from word counts
to contingency tables — and order takes several keys. method = "radix" sorts the names by
their bytes rather than by the locale’s collation rules, so the order does not depend on where the
code runs. sprintf is vectorised: one call formats all ten lines.
Hash maps, by hand and otherwise
use strict;
use warnings;
my $text = <<'END';
The river rose in the night, and by morning the road to the mill was under
water. The miller walked down to the edge of the water and looked at the mill:
the wheel was turning faster than it had ever turned, and the river was loud.
He thought of the grain in the loft, dry for now, and of the long road round by
the bridge. A boat would be faster than the road, but the boat was at the mill.
So he sat on a stone by the water, and waited for the river to make up its mind.
END
my @words = lc($text) =~ /[a-z]+/g;
my %count;
$count{$_}++ for @words;
printf "words: %d, distinct: %d\n", scalar @words, scalar keys %count;
my @top = (sort { $count{$b} <=> $count{$a} || $a cmp $b } keys %count)[0 .. 9];
printf "%3d %s\n", $count{$_}, $_ for @top;
The language that made this kind of program famous. A match with /g in list context returns
every match, $count{$_}++ creates and increments entries in one go, and the sort block uses
<⇒ (numbers) and cmp (strings) chained with || — the idiom other languages' comparators
copy.
<?php
$text = <<<'END'
The river rose in the night, and by morning the road to the mill was under
water. The miller walked down to the edge of the water and looked at the mill:
the wheel was turning faster than it had ever turned, and the river was loud.
He thought of the grain in the loft, dry for now, and of the long road round by
the bridge. A boat would be faster than the road, but the boat was at the mill.
So he sat on a stone by the water, and waited for the river to make up its mind.
END;
preg_match_all('/[a-z]+/', strtolower($text), $m);
$words = $m[0];
$counts = array_count_values($words);
printf("words: %d, distinct: %d\n", count($words), count($counts));
uksort($counts, fn($a, $b) => $counts[$b] <=> $counts[$a] ?: strcmp($a, $b));
foreach (array_slice($counts, 0, 10, true) as $word => $n) {
printf("%3d %s\n", $n, $word);
}
array_count_values is PHP’s counter, and uksort sorts an array by its keys with a comparator
— here one that looks the counts up. The spaceship <⇒ and the short ternary ?: make the two
keys a one-liner. array_slice needs true to keep the keys, which are the words.
local text = [[
The river rose in the night, and by morning the road to the mill was under
water. The miller walked down to the edge of the water and looked at the mill:
the wheel was turning faster than it had ever turned, and the river was loud.
He thought of the grain in the loft, dry for now, and of the long road round by
the bridge. A boat would be faster than the road, but the boat was at the mill.
So he sat on a stone by the water, and waited for the river to make up its mind.
]]
local counts, total = {}, 0
for word in text:lower():gmatch("%a+") do
counts[word] = (counts[word] or 0) + 1
total = total + 1
end
local list = {}
for word, n in pairs(counts) do list[#list + 1] = { word = word, n = n } end
table.sort(list, function(a, b)
if a.n ~= b.n then return a.n > b.n end
return a.word < b.word
end)
print(("words: %d, distinct: %d"):format(total, #list))
for i = 1, 10 do print(("%3d %s"):format(list[i].n, list[i].word)) end
Lua patterns (not regular expressions: %a+ is "one or more letters") with gmatch to iterate,
and a table as the hash map. A table has no length when used as a map, so the words are copied into
a list to sort and count; table.sort takes a "less than" function.
let text = {|The river rose in the night, and by morning the road to the mill was under
water. The miller walked down to the edge of the water and looked at the mill:
the wheel was turning faster than it had ever turned, and the river was loud.
He thought of the grain in the loft, dry for now, and of the long road round by
the bridge. A boat would be faster than the road, but the boat was at the mill.
So he sat on a stone by the water, and waited for the river to make up its mind.|}
let words s =
let buf = Buffer.create 16 and acc = ref [] in
let flush () = if Buffer.length buf > 0 then (acc := Buffer.contents buf :: !acc; Buffer.clear buf) in
String.iter (fun c -> if c >= 'a' && c <= 'z' then Buffer.add_char buf c else flush ()) (String.lowercase_ascii s);
flush ();
List.rev !acc
let () =
let ws = words text in
let counts = Hashtbl.create 64 in
List.iter (fun w -> Hashtbl.replace counts w (1 + Option.value ~default:0 (Hashtbl.find_opt counts w))) ws;
Printf.printf "words: %d, distinct: %d\n" (List.length ws) (Hashtbl.length counts);
Hashtbl.to_seq counts
|> List.of_seq
|> List.sort (fun (w1, n1) (w2, n2) -> if n1 <> n2 then compare n2 n1 else compare w1 w2)
|> List.filteri (fun i _ -> i < 10)
|> List.iter (fun (w, n) -> Printf.printf "%3d %s\n" n w)
OCaml’s standard library has no regular expressions (that is the separate Str library), so the
splitter is a small state machine with a Buffer. Hashtbl is the mutable hash map; after that it
is a pipeline with |>, and List.sort takes a three-way comparison function.
#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static const char *TEXT =
"The river rose in the night, and by morning the road to the mill was under\n"
"water. The miller walked down to the edge of the water and looked at the mill:\n"
"the wheel was turning faster than it had ever turned, and the river was loud.\n"
"He thought of the grain in the loft, dry for now, and of the long road round by\n"
"the bridge. A boat would be faster than the road, but the boat was at the mill.\n"
"So he sat on a stone by the water, and waited for the river to make up its mind.\n";
/* A fixed-size hash table with open addressing: plenty for one paragraph. */
#define SLOTS 1024
typedef struct { char word[32]; int n; } Entry;
static Entry table[SLOTS];
static int distinct;
static unsigned hash(const char *s) { /* FNV-1a */
unsigned h = 2166136261u;
for (; *s; s++) h = (h ^ (unsigned char)*s) * 16777619u;
return h;
}
static void count(const char *word) {
unsigned i = hash(word) % SLOTS;
while (table[i].n && strcmp(table[i].word, word) != 0) i = (i + 1) % SLOTS;
if (!table[i].n) { strcpy(table[i].word, word); distinct++; }
table[i].n++;
}
static int by_count_then_word(const void *pa, const void *pb) {
const Entry *a = pa, *b = pb;
if (a->n != b->n) return b->n - a->n;
return strcmp(a->word, b->word);
}
int main(void) {
char word[32];
int len = 0, total = 0;
for (const char *p = TEXT;; p++) {
if (isalpha((unsigned char)*p) && len < 31) {
word[len++] = tolower((unsigned char)*p);
} else if (len) {
word[len] = '\0';
count(word);
total++;
len = 0;
}
if (!*p) break;
}
Entry *list = malloc(distinct * sizeof *list);
int k = 0;
for (int i = 0; i < SLOTS; i++)
if (table[i].n) list[k++] = table[i];
qsort(list, distinct, sizeof *list, by_count_then_word);
printf("words: %d, distinct: %d\n", total, distinct);
for (int i = 0; i < 10 && i < distinct; i++) printf("%3d %s\n", list[i].n, list[i].word);
free(list);
return 0;
}
No hash map in the standard library, so here is one: an array of 1024 slots, the FNV-1a hash to
pick a slot, and linear probing to the next free one on a collision. It never grows, which is fine
for a paragraph and is exactly the kind of assumption that turns into a bug later. qsort takes the
comparator as a function pointer.
use std::collections::HashMap;
const TEXT: &str = "The river rose in the night, and by morning the road to the mill was under
water. The miller walked down to the edge of the water and looked at the mill:
the wheel was turning faster than it had ever turned, and the river was loud.
He thought of the grain in the loft, dry for now, and of the long road round by
the bridge. A boat would be faster than the road, but the boat was at the mill.
So he sat on a stone by the water, and waited for the river to make up its mind.";
fn main() {
let lower = TEXT.to_lowercase();
let words: Vec<&str> = lower
.split(|c: char| !c.is_ascii_lowercase())
.filter(|w| !w.is_empty())
.collect();
let mut counts: HashMap<&str, usize> = HashMap::new();
for w in &words {
*counts.entry(w).or_insert(0) += 1;
}
println!("words: {}, distinct: {}", words.len(), counts.len());
let mut top: Vec<(&str, usize)> = counts.into_iter().collect();
top.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(b.0)));
for (word, n) in top.iter().take(10) {
println!("{n:3} {word}");
}
}
The words are slices (&str) borrowed from the lower-cased text, so splitting copies nothing —
and the compiler checks that lower lives as long as they do. entry(…).or_insert(0) gets or
creates a count in one lookup. Rust’s HashMap uses a randomised hash, so its iteration order
changes from run to run; without the tie-break this program’s output would too.
package main
import (
"cmp"
"fmt"
"slices"
"strings"
)
const text = `The river rose in the night, and by morning the road to the mill was under
water. The miller walked down to the edge of the water and looked at the mill:
the wheel was turning faster than it had ever turned, and the river was loud.
He thought of the grain in the loft, dry for now, and of the long road round by
the bridge. A boat would be faster than the road, but the boat was at the mill.
So he sat on a stone by the water, and waited for the river to make up its mind.`
func main() {
words := strings.FieldsFunc(strings.ToLower(text), func(r rune) bool { return r < 'a' || r > 'z' })
counts := map[string]int{}
for _, w := range words {
counts[w]++
}
fmt.Printf("words: %d, distinct: %d\n", len(words), len(counts))
keys := make([]string, 0, len(counts))
for w := range counts {
keys = append(keys, w)
}
slices.SortFunc(keys, func(a, b string) int {
return cmp.Or(cmp.Compare(counts[b], counts[a]), strings.Compare(a, b))
})
for _, w := range keys[:10] {
fmt.Printf("%3d %s\n", counts[w], w)
}
}
strings.FieldsFunc splits wherever the function says "separator", and a missing map key reads
as zero, so counts[w]++ needs no check. Go deliberately randomises map iteration order, so the
keys are collected and sorted; cmp.Or (Go 1.22) returns its first non-zero argument, which is the
two-key comparison.
Queries
DROP TABLE IF EXISTS words;
CREATE TABLE words AS
WITH RECURSIVE
doc(body) AS (SELECT 'The river rose in the night, and by morning the road to the mill was under
water. The miller walked down to the edge of the water and looked at the mill:
the wheel was turning faster than it had ever turned, and the river was loud.
He thought of the grain in the loft, dry for now, and of the long road round by
the bridge. A boat would be faster than the road, but the boat was at the mill.
So he sat on a stone by the water, and waited for the river to make up its mind.'),
cleaned(s) AS (
SELECT replace(replace(replace(replace(lower(body), char(10), ' '), ',', ' '), '.', ' '), ':', ' ') || ' '
FROM doc
),
split(word, rest) AS (
SELECT '', s FROM cleaned
UNION ALL
SELECT substr(rest, 1, instr(rest, ' ') - 1), substr(rest, instr(rest, ' ') + 1)
FROM split WHERE rest <> ''
)
SELECT word FROM split WHERE word <> '';
SELECT 'words: ' || count(*) || ', distinct: ' || count(DISTINCT word) AS summary FROM words;
SELECT printf('%3d %s', count(*), word) AS top
FROM words
GROUP BY word
ORDER BY count(*) DESC, word
LIMIT 10;
SQLite has no regular expressions and no "split" function, so the punctuation is replaced by
spaces and a recursive query peels one word off the front of the string per row. Once the words are
a table, the counting and sorting are what SQL is for: GROUP BY, then ORDER BY with two keys.
WITH
words AS (
SELECT unnest(regexp_extract_all(lower('The river rose in the night, and by morning the road to the mill was under
water. The miller walked down to the edge of the water and looked at the mill:
the wheel was turning faster than it had ever turned, and the river was loud.
He thought of the grain in the loft, dry for now, and of the long road round by
the bridge. A boat would be faster than the road, but the boat was at the mill.
So he sat on a stone by the water, and waited for the river to make up its mind.'), '[a-z]+')) AS word
),
counts AS (SELECT word, count(*) AS n FROM words GROUP BY word),
lines AS (
SELECT 0 AS rank, 'words: ' || (SELECT count(*) FROM words) || ', distinct: ' || count(*) AS line
FROM counts
UNION ALL
SELECT row_number() OVER (ORDER BY n DESC, word), printf('%3d %s', n, word)
FROM counts
)
SELECT line FROM lines WHERE rank <= 10 ORDER BY rank;
DuckDB is SQL with the batteries the SQLite version lacked: regexp_extract_all returns a list of
matches and unnest turns the list into rows. This version is a single query: the summary line and
the top ten are one result, with row_number() — a window function — numbering the words in
count-then-alphabetical order so the outer query can keep the first ten.
What it shows
| Split | Count | Sort on two keys | |
|---|---|---|---|
JavaScript |
regex |
|
comparator with |
Python |
|
|
key tuple |
Ruby |
|
|
key array |
Clojure |
|
|
key from |
Kotlin |
|
|
|
R |
|
|
|
Perl |
|
hash, |
|
PHP |
|
|
|
Lua |
|
table |
|
OCaml |
by hand |
|
compare function |
C |
by hand |
a hash table, by hand |
|
Rust |
|
|
|
Go |
|
|
|
SQL |
recursive query |
|
|
DuckDB |
|
|
|
Three patterns for the two-key sort cover almost everything: a comparator that falls through when
the first key ties (||, ?:, then, cmp.Or), a key that is a tuple and so sorts on both
parts at once (Python, Ruby, Clojure), or a language that takes several keys directly (R, SQL). The
key-tuple style is the hardest to get wrong. And the languages with deliberately unpredictable hash
order — Go, Rust, and Python before 3.7 — are the ones that teach you to ask for the order you
want.