Phones-to-Words Challenge IV: Clojure as an alternative to Java

(This is part of the Phones-to-Words Challenge series.)

There’s an old programming challenge where the digits in a list of phone numbers are converted to letters according to rules and a given dictionary file. The results suggested that Lisp would be a potentially superior alternative to Java, since Lisper participants were able to produce solutions in, on average, fewer lines of code and less time than Java programmers.

Some years ago I tackled it in Emacs Lisp and Bash. I’ve now done it in Clojure.

My spoilers are about to start — so if you haven’t tried it yet, but want to, then leave NOW, go look at the original challenge’s instructions, solve it, and come back here later, once you’re satisfied with your own solution. It’s ok, I’ll wait.

A fresh solution

I wanted a fresh solution, so I didn’t look at my previous ones; I reread the instructions and started from an empty buffer.

I started anew because I see Clojure as a higher-level language than both Emacs Lisp and Bash, and one which more naturally — or, perhaps, almost forcefully — pushes you towards a pure functional programming style of solving things. As much as I tend to functional-program the heck out of Elisp and Bash, there seems to be a limit to how much you can do this before it becomes a bit awkward. In particular, some imperative constructs seem fine and almost unavoidable over there, as does changing state of locally-scoped variables. Clojure does it differently.

The design

(The below will not make much sense if you haven’t yet studied the problem.)

My approach was to write functions that would:

  • Make a translation tree for a phone number (a string of digits).
    • It should create two-part-splits of the string, starting with the whole string on the left side.
    • It should loop-recur on those, terminating when the left-side substring size reaches zero.
    • The loop-recur should keep track of:
      • all complete solutions found so far
      • left-side-substring’s size, to know when to terminate
      • left-side-substring’s word hits, because this is necessary (but not sufficient) to decide whether using a digit for the right-side-substring would be allowed
    • Potential new complete solution: when a left-side translation happens, return it if the right side is empty (was the whole string); otherwise recurse this very function to find all translations of the right side and, if not empty, splice it into a vector after the left-side translation.
  • Enumerate all root-to-leaf paths of such a tree.
  • Take a dictionary file plus a translation map from alphabet to digits and return a map from sequence-of-digits to vector-of-dictionary-words.
  • Use the last two to build the final string line(s) for a single phone number.

Finally, a function would loop over lines of phone numbers read one by one from a file to print their corresponding final string lines. This function should receive a map and declare default values for character-to-digit translation scheme, a dictionary file, and an input file.

The code

Excluding commented lines and empty lines, the size of Lispers’ versions ranged from 50 to 182 lines of code, with median 134. It’s unclear to me whether these numbers include docstrings.

Excluding commented lines and empty lines, my Clojure solution has 78 LoC. Excluding also docstrings, it has 58 LoC.

;;; p2w/core.clj --- Convert phone numbers to words
;;
;; SPDX-FileCopyrightText: © flandrew <https://flandrew.srht.site/listful>
;; SPDX-License-Identifier: EPL-2.0
;;
;;; Commentary
;;
;; Original instructions: <https://flownet.com/ron/papers/lisp-java/>
;;
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;


;;; Code
;;;; Namespace

(ns ^{:doc    "My solution to the 'Convert phone numbers to words' challenge."
      :author "flandrew" :version "0.1.0" :updated "2026-04-10"}
    p2w.core
  (:require [clojure.string  :as s]
            [clojure.java.io :as io]))


;;;; Functions
;;;;; Make translation trees

(defn- split
  "Split a string s in two at c characters from the left."
  [s c] (vector (subs s 0 c) (subs s c (count s))))

(defn- xlate-deep
  "Return tree of all licit translations of string s and its substrings.
  m is a digits-string->matching-words map.
  Through ok-digit, keep track of whether it'd be ok to use a digit."
  [s m ok-digit]
  (loop [sub (count s), hits [], res []]
    (if (zero? sub)
      res (let [[s1 s2] (split s sub)
                [x1 ok] (as-> (into [] (m s1)) it
                          (if (and (= sub 1) (empty? it)
                                   ok-digit  (empty? hits))
                            [[s1] false]
                            [it   true]))
                new-res (when (seq x1)
                          (if (empty? s2)
                            [x1] (let [xa2 (xlate-deep s2 m ok)]
                                   (when (seq xa2)
                                     `[~x1 ~@xa2]))))]
            (recur (dec sub)
                   (into hits x1)
                   (if new-res (conj res new-res) res))))))

(defn- paths
  "All root-to-leaf paths of a translation tree."
  [tree]
  (mapcat (fn [[b & bs]]
            (for [b b, n (if (empty? bs) [bs] (paths bs))]
              `(~b ~@n)))
          tree))


;;;;; Build dictionary

(defn- only-lower [s] (s/replace s #"[^a-z]" ""))

(defn- word->num
  "Translate word to numeric string using alphas-to-digits map a2d."
  [word a2d]
  (->> word  s/lower-case  only-lower  (map a2d)  (reduce str "")))

(defn- dict->num-words
  "Convert a dict string into a map of numeric-string to dict-words.
  Use a2d — an alpha-to-digits conversion map."
  [dict a2d]
  (loop [[w & ws] (s/split-lines (s/trim dict)), res {}]
    (if (empty? w)
      res (recur ws (update res (word->num w a2d)
                            (fnil #(conj % w) []))))))

(defn- as->ds
  "Create mapping from lowercase alphas to digits."
  [alphas digits] (into {} (map vector alphas digits)))


;;;;; Make final strings

(defn- only-digit [s] (s/replace s #"[^0-9]" ""))

(defn- xlation-lines-1
  "String of translation line(s) for a given phone s.
  m is a digits-string->matching-words map."
  [s m]
  (->> (xlate-deep (only-digit s) m true)  paths
       (map #(format "%s: %s\n" s (s/join " " %)))
       s/join))

(defn xlate-phones
  "Print translations for lines of phones.
  Optionally pass a map to change default keys."
  [& {:keys [alphas digits input dict]
      :or   {alphas "ejnqrwxdsyftamcivbkulopghz"
             digits "01112223334455666777888999"
             input  (io/resource "in/input.txt")
             dict   (io/resource "in/dictionary.txt")}}]
  (let [a2d (as->ds alphas digits)
        map (dict->num-words (slurp dict) a2d)]
    (with-open [rdr (io/reader input)]
      (binding [*in* rdr]
        (loop [phone (read-line)]
          (when phone
            (print (xlation-lines-1 phone map))
            (recur (read-line))))))))


;;;;; Main

(defn -main [& args]
  (apply xlate-phones (map read-string args)))


;;; Local Variables:
;;  coding:                     utf-8
;;  indent-tabs-mode:           nil
;;  sentence-end-double-space:  nil
;;  outline-regexp:             ";;;;* "
;;  End:

;;; p2w/core.clj ends here

The comments

Running this would then output the results from the standard dictionary and input files:

lein run

There was an allowed degree of freedom regarding the exact order of the solution lines for a single phone. With that in mind, my sorted results match the given expected output’s sorted results. This returns empty:

diff <(lein run | sort) <(sort resources/in/output.txt)

And this uses the small sample input and dictionary, whose 12 results also match the sample output:

lein run '{:dict "resources/in/sample-dict" :input "resources/in/sample-input"}'

That’s all for the challenge itself.

A different keypad

What if we wanted to use some other alpha-to-digit mapping? For example, we could use, instead, the standard E.161 keypad:

lein run '{:alphas "abcdefghijklmnopqrstuvwxyz" :digits "22233344455566677778889999"}'

Curiously, this gives us 255 hits — very close to the 261 hits of the quite different keypad provided by the challenge. Here are a couple of output examples:

/4368406-/4/3: genug 0 nie
/4368406-/4/3: genug 0 oh 3
...
6246: Main
6246: nahm
6246: Mai 6
6246: nah 6
6246: ob im
6246: ob in

See next

I then decided to write a new Emacs Lisp solution, inspired by this one.

📆 2026-W15-5📆 2026-04-10