Word count program comparison

Written by

in

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:

Pride and Prejudice, A Christmas Carol and A Tale of Two Cities can be downloaded from http://www.gutenberg.org/browse/scores/top. Write a program in your favourite language to count the number of times every word in each book is used. Also calculate the top 10 pairs of words in each book. For example, “in the” and “at most” are words that are frequently found together.

Send back a list of the top 10 words in each book, the top 10 words in each book that do not appear in the other two and lastly the top 10 word pairs that are found together in each book. Include in each list the number of times each word or word pair appeared in the book. Clean up your source code and add comments to it, so that it is easy to understand, and send that along too.

First, I downloaded these books and converted them to unix line-endings, then I wrote some programs to do the same job.

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

is a really idiomatic way of counting things.

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

Here’s another short one. Haskell makes this problem almost solve itself.

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

Let’s call this a late entry. Clojure is a Lisp that targets a couple of platforms.

(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 be the first to agree that this kind of test is not a good place to check performance, but I am quite pleased at how well Python compares to the other options here. I am also very surprised at how fast the shell solution is. I will admit that I am more comfortable with the first two languages than the second two, so perhaps I have missed some performance problems, but I suspect any additional gains in performance will be obtained at the cost of more lines and less succinct expression.

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”.

Comments

  1. Mike Breytenbach Avatar

    Very nice. The Clojure code looks very strange to me.

    How long did it take you to write each program?

  2. Carl Sandrock Avatar

    Learning Lisp is a bit like taking a mind-altering drug. It really changed the way I program in every language. This was the first time I really dug into Clojure, as I'd been doing more Common Lisp after working through this: http://www.gigamonkeys.com/book/

    The times are also pretty hard to judge, but less than an hour each and a lot less for shell and python solutions, as those are my most fluent languages.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.