Recently one of my favourite topics came up over dinner: the relative power of programming languages, in the guise of a programming challenge the person talked about as an interview question. I asked him to forward it on to me, and here it is:
Shell
I wrote the following script in almost no time at all. This is the kind of task that the Unix shell really excels at, with much of the heavy lifting being done in the uniq routine. The sort | uniq -c
for f in pg*.txt; do
echo $f
echo "Words: 1"
tr -s "t " "n" < $f | sort | uniq -c | sort -nr | head
echo "Words: 2"
tr -s "t " "n" < $f | awk 'NR>1 {print w" "$0} {w=$0}' | sort | uniq -c | sort -nr | head
done
Python
My next try was Python. This took a little longer to write, but appeared to run a bit faster. This is not surprising as the above code traverses each text file twice and contains four sorts. The Python code uses the really fast dictionary object to count the words, rather than using the sort and count approach in the shell code.
import glob
from collections import defaultdict
from operator import itemgetter
topN = 10 # Display this number of elements in the report
for filename in glob.glob('pg*.txt'):
# Parse
previousword = None
# Create a single-word and double-word counter
counters = [defaultdict(int), defaultdict(int)]
for word in open(filename, 'r').read().split():
counters[0][word] += 1
if previousword: counters[1][previousword + ' ' + word] += 1
previousword = word
# Report
print "File:", filename
for i, c in enumerate(counters):
print "Length", i+1
# Sort the counters by their counts, reverse order, take topN elements
for item in sorted(c.items(), reverse=True, key=itemgetter(1))[:topN]:
print "%-20s%5i" % item
C++
The C++ version follows the same strategy as the Python program, using the C++ standard library equivalents of Python’s dictionaries and lists. I’ve left out the globbing, as the C++ STL doesn’t have functions for that. The typical use of this kind of commandline function would rely on the shell for globbing as I have here. Of course, if your shell is CMD.EXE you’re SOL. This program didn’t take that long to write, but it took ages to debug due to the heavy use of templates.
#include <iostream>
#include <map>
#include <string>
#include <cmath>
#include <fstream>
#include <iterator>
#include <vector>
#include <algorithm>
using namespace std;
bool value(const pair<string, int>& a, const pair<string, int>& b)
{
return a.second > b.second;
}
int main (int argc, char *argv[]) {
for (int i=1; i<argc; i++) {
ifstream infile(argv[i]);
map<string, int> counter[2];
cout << argv[i] << endl;
// read file
string word, previousword;
bool firstword=true;
while (infile >> word) {
counter[0][word]++;
if (!firstword) {
counter[1][previousword + " " + word]++;
previousword = word;
} else {
firstword = false;
}
}
for (int c=0; c<2; c++) {
cout << "Words:" << c+1 << endl;
// sort
vector<pair<string,int> > wordlist;
copy(counter[c].begin(), counter[c].end(),
back_inserter<vector<pair<string, int> > >(wordlist));
sort(wordlist.begin(), wordlist.end(), &value);
// write output
for (int j=0; j < 10; j++) {
cout << wordlist[j].first << ": " << wordlist[j].second << endl;
}
}
}
}
Java
The guy had said that a typical Java program to do this was about 100 lines. Here is my attempt. Again, I have tried to keep the basic philosophy the same as with the Python program, using the Java standard library for maps and sorting rather than getting clever. Interestingly, this Java program is both the largest and the slowest of the lot. The Eclipse IDE cut a little of the time off writing this, and the Java generics are much easier to debug than C++ templates.
import java.io.*;
import java.util.*;
public class ngrammer {
public static void main(String[] args) throws FileNotFoundException {
class WordCounter extends HashMap<String, Integer> {
class ValueComparator implements Comparator<Map.Entry<String, Integer>> {
// Comparator to allow sorting on the value of the HashMap elements
@Override
public int compare(Map.Entry<String, Integer> o1, Map.Entry<String, Integer> o2) {
return o2.getValue() - o1.getValue();
}
}
void add(String word) {
if (containsKey(word)) {
put(word, get(word)+1);
} else {
put(word, 1);
}
}
void printN(Integer N) {
// Prints the top N elements of the counter
// Make a list of the counter entries
List<Map.Entry<String,Integer>> entries = new LinkedList<Map.Entry<String,Integer>>(entrySet());
// Sort it on the value
Collections.sort(entries, new ValueComparator());
// Print out at most N items
int i=N;
for (Map.Entry<String,Integer> entry: entries) {
System.out.println(entry.getKey() + ": " + entry.getValue());
if (--i == 0) break;
}
}
}
final int topN = 10;
// Process each file in the input arguments
for (int fi = 1; fi < args.length; fi++) {
File file = new File(args[fi]);
// Initialise new counters
WordCounter[] counter = {new WordCounter(), new WordCounter()};
String word, previousword="";
// Tokenise file
Scanner scanner = new Scanner(file);
while (scanner.hasNext()) {
word = scanner.next();
counter[0].add(word);
if (!previousword.isEmpty())
counter[1].add(previousword + " " + word);
previousword = word;
}
// Report
System.out.println("Filename:" + file.getAbsolutePath());
for (int i = 0; i < counter.length; i++) {
System.out.println("Words: " + (i+1));
counter[i].printN(topN);
}
}
}
}
C#
Note that this is my least fluent language, so this may be inelegant. Also, before you say it, I did have a version that used Linq. This was much more succinct, but it was really dog slow. I was really surprised that this program was the fastest of all the ones on this page, especially since I was running it on Mono.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
class MainClass
{
class WordCounter : Dictionary<string, int>
{
public void CountWord (string word)
{
if (ContainsKey (word)) {
this [word]++;
} else {
this [word] = 1;
}
}
public void Report (int N)
{
var list = this.ToList ();
list.Sort ((a, b) => { return b.Value.CompareTo (a.Value); });
foreach (var item in list.Take(N)) {
Console.WriteLine(item.Key + ": " + item.Value);
}
}
}
public static void Main (string[] args)
{
const int Ntop=10;
foreach (string f in args) {
WordCounter[] wordcounter = {new WordCounter(), new WordCounter()};
string lastword = "";
foreach (var word in File.ReadAllText(f).Split(' ')) {
wordcounter[0].CountWord(word);
if (lastword.Length > 0) {
wordcounter[1].CountWord (lastword + " " + word);
}
lastword = word;
}
Console.WriteLine(f);
for (int i=1; i<2; i++) {
Console.WriteLine ("Words: " + (i+1));
wordcounter[i].Report (Ntop);
}
}
}
}
Haskell
module Main where import System.Environment (getArgs) import Data.List (sort, group) countgroup xs = (length xs, head xs) twowords (a, b) = a ++ " " ++ b count = reverse . sort . map countgroup . group . sort format (a, b) = b ++ ": " ++ show a report = mapM_ putStrLn . map format . take 10 . count countfile f = do putStrLn f contents <- readFile f let thewords = words contents putStrLn "Words: 1" report $ thewords putStrLn "Words: 2" report . map twowords $ zip thewords (tail thewords) main = getArgs >>= mapM_ countfile
Clojure
(use '[clojure.string :only (split)])
(defn twowords [words]
(apply str (interpose " " words)))
(defn bigrams [words]
(map twowords (map list words (rest words))))
(defn report [N, words]
(take N (reverse (sort-by second (frequencies words)))))
(defn dofile [N, filename]
(let [words (split (slurp filename) #"s+")]
(dorun (map println
(concat [filename]
["Words: 1"]
(report N words)
["Words: 2"]
(report N (bigrams words)))))))
(dorun (map (partial dofile 10) (rest *command-line-args*)))
Results
For what it’s worth, here’s a table of metrics. I counted the non-comment non-blank lines, then ran the program through gzip to get an idea of the inherent token-complexity of the program (so that shorter variable names don’t make you win). I also ran the programs on the three files mentioned in the problem statement.
| Language | Noncomment Lines | Gzipped bytes | Runtime / s |
|---|---|---|---|
| Shell | 7 | 151 | 0.88 |
| Python | 16 | 360 | 0.48 |
| C++ | 40 – 7 = 23† | 484 | 0.70 |
| Java | 49 – 12 = 37† | 680 | 1.92 |
| C# | 46 – 21 = 25† | 548 | 0.33 |
| Haskell | 17 | 308 | 3.5 |
| Clojure | 16 | 287 | 4.2‡ |
† I have subtracted the lines occupied by braces on their own line.
‡ Includes compile time
I would also like to comment on the idea that this is an unrealistic, non-real-world problem. I am a lecturer and have required similar programs several times when working with class lists. Questions routinely arise about which students have been in more than one class, or who needs to do a particular activity given the ones they have already done. Often the fastest way to get the answer us by a process similar to the word counting problem we have here. In fact, this is why I was able to bang out the shell solution so quickly.
Another aside: It has taken me longer to get the code properly input in this blog than it took me to write all the programs. And Blogger is supposed to be “easy to use”.
Leave a Reply