Isogram in Clojure: Complete Solution & Deep Dive Guide

a close up of a computer screen with code on it

Mastering Clojure Isograms: From First Principles to Elegant Code

An isogram is a word or phrase without repeating letters, ignoring spaces and hyphens. In Clojure, you can efficiently solve this by filtering the input to keep only letters, converting the result into a set to automatically remove duplicates, and then comparing the size of the original filtered sequence with the size of the set.

You’ve probably seen words like "background" or "lumberjacks" and felt they had a certain rhythmic quality, but couldn't quite put your finger on why. These words are special; they are isograms, where each letter appears only once. This seemingly simple linguistic puzzle is a perfect challenge to showcase the elegance and power of functional programming in Clojure. It forces us to think in terms of data transformations and pipelines, which is the very heart of the Clojure philosophy.

If you've been wrestling with how to apply functional concepts to practical problems, you're in the right place. We're not just going to find a solution; we're going to build an understanding from the ground up. This guide will take you from the basic definition of an isogram to crafting idiomatic, efficient Clojure code, exploring the core data structures and sequence operations that make the language so expressive.


What Exactly Is an Isogram?

Before we dive into the code, let's establish a crystal-clear definition. An isogram is a word or phrase where no letter repeats. It's a test of character uniqueness.

However, there are a couple of important rules that add a slight twist to the problem:

  • Case Insensitivity: The check must be case-insensitive. 'A' and 'a' are considered the same letter. So, a word like "Path" is an isogram, but "Anna" is not because 'a' and 'A' count as two occurrences of the same letter.
  • Ignored Characters: Punctuation, specifically spaces and hyphens, are not considered letters and should be ignored. They can appear multiple times without disqualifying the phrase. For example, "six-year-old" is a valid isogram because the letters 's', 'i', 'x', 'y', 'e', 'a', 'r', 'o', 'l', 'd' are all unique.

Here are some classic examples to solidify the concept:

  • Isograms: lumberjacks, background, downstream, six-year-old
  • Not Isograms: isograms (the 's' repeats), apple (the 'p' repeats), bubble (the 'b' repeats)

This problem is a staple in the kodikra.com Clojure learning path because it beautifully illustrates how to handle strings, filter data, and leverage unique data structures—all within a few lines of highly readable code.


Why Clojure is the Perfect Tool for the Job

Clojure, a modern Lisp dialect running on the Java Virtual Machine (JVM), is exceptionally well-suited for problems involving data transformation, like our isogram checker. Its core principles align perfectly with the logical steps required to solve the puzzle.

Key Clojure Features We'll Leverage:

  • Immutability by Default: In Clojure, data structures are immutable. When we "change" something, we are actually creating a new data structure with the change applied. This prevents a whole class of bugs and makes our logic easier to reason about. We'll transform our input string through several stages without ever modifying the original.
  • Powerful Sequence Library: Clojure treats strings, lists, vectors, and many other things as "sequences." It provides a rich, universal set of functions like map, filter, and reduce that work on any sequence. This allows us to build a processing pipeline that is both generic and powerful.
  • Expressive Data Structures: The language comes with a fantastic set of built-in data structures. For the isogram problem, the set is our hero. A set is an unordered collection of unique items. The simple act of converting a sequence of letters into a set automatically discards any duplicates—the core of our solution.
  • The Threading Macro (->>): This is syntactic sugar that makes data pipelines incredibly readable. It allows us to express a series of transformations in a top-to-bottom fashion, mirroring how we think about the problem, rather than nesting functions inside each other.

Instead of writing complex loops and maintaining state variables (e.g., a counter for each letter), Clojure encourages us to think differently: "Take the input string, transform it into lowercase, then filter out non-letters, then convert it to a set, and finally, compare its size." This declarative style leads to code that is often more concise and less error-prone.


How to Construct the Isogram Algorithm in Clojure

Let's architect our solution by breaking it down into logical, sequential steps. This thought process is crucial for writing clean, functional code. We'll build a data transformation pipeline.

Step 1: Normalize the Input

The problem states the check must be case-insensitive. Our first step, therefore, is to convert the entire input string to a single case, typically lowercase. This ensures that 'I' and 'i' are treated as the same character.

In Clojure, the clojure.string namespace provides the perfect tool: clojure.string/lower-case.

Step 2: Isolate the Relevant Characters

Next, we need to discard any characters that aren't letters. The rules specify ignoring spaces and hyphens, but a robust solution should ignore any non-alphabetic character. This makes our function more resilient.

We can achieve this by filtering the string. A highly effective way is to use a regular expression to extract only the alphabetic characters (a-z). The re-seq function is ideal for this, as it returns a lazy sequence of all matches.

Step 3: Check for Uniqueness

This is the heart of the algorithm. Once we have a clean sequence of lowercase letters, we need to determine if any letter is repeated. Clojure's data structures give us an incredibly elegant way to do this.

The strategy is as follows:

  1. Take our sequence of letters (which may contain duplicates).
  2. Convert this sequence into a set. By definition, a set can only contain unique elements. This conversion process automatically and efficiently removes all duplicates.
  3. Compare the number of elements in the original letter sequence with the number of elements in the newly created set.

If the counts are equal, it means no elements were removed during the set conversion, which implies there were no duplicates to begin with. The string is an isogram! If the count of the set is less than the count of the letter sequence, duplicates were present, and it's not an isogram.

The Logic Flowchart

Here is a visual representation of our primary algorithm's data pipeline.

    ● Start with Input String
    │  (e.g., "Six-year-old")
    ▼
  ┌───────────────────────┐
  │ Normalize to Lowercase │
  └──────────┬────────────┘
             │
             ▼
    (e.g., "six-year-old")
             │
  ┌───────────────────────┐
  │ Filter for Letters Only│
  │ (using re-seq #"[a-z]")│
  └──────────┬────────────┘
             │
             ▼
(e.g., ('s' 'i' 'x' 'y' 'e' 'a' 'r' 'o' 'l' 'd'))
             │
             ├─────────────────────────┐
             │                         │
             ▼                         ▼
  ┌───────────────────┐      ┌──────────────────┐
  │ Get Count of       │      │ Convert to a Set  │
  │ Filtered Letters   │      │ (removes dupes)  │
  └──────────┬────────┘      └─────────┬────────┘
             │                         │
             ▼                         ▼
        (Count = 10)            (Set has 10 items)
             │                         │
             └───────────┬─────────────┘
                         ▼
                   ◆ Are Counts Equal? ◆
                  ╱                     ╲
                 Yes                     No
                  │                       │
                  ▼                       ▼
           [Return true]           [Return false]
           (Isogram)                 (Not Isogram)
                  │                       │
                  └──────────┬────────────┘
                             ▼
                           ● End

The Complete Clojure Solution: Code & Walkthrough

Now, let's translate our logic into idiomatic Clojure code. We'll use the thread-last macro ->> to make our data pipeline clean and readable.

The Final Code

Here is the complete, commented function to solve the isogram challenge from the kodikra.com curriculum.


(ns isogram
  (:require [clojure.string :as str]))

(defn isogram?
  "Determines if a word or phrase is an isogram.
  An isogram is a word or phrase without a repeating letter.
  Spaces and hyphens are ignored, and the check is case-insensitive."
  [s]
  ;; Create a clean sequence of only the letters from the input string.
  ;; The ->> macro threads the result of each expression as the *last*
  ;; argument to the next, creating a readable top-to-bottom pipeline.
  (let [letters (->> s
                     ;; 1. Start with the input string `s`.
                     str/lower-case
                     ;; 2. Convert it to lowercase.
                     (re-seq #"[a-z]"))]
                     ;; 3. Find all sequences matching the regex (i.e., all letters).
                     ;;    This returns a lazy sequence of strings, e.g., ("s" "i" "x").

    ;; The core logic: Compare the count of the original letter sequence
    ;; with the count of a set created from that sequence.
    ;; If the counts are equal, no duplicates were found.
    (= (count letters)       ; Count of all letters (including potential duplicates)
       (count (set letters)) ; `set` creates a collection of unique letters,
                             ; then we count them.
       )))

;; Example Usage:
;; (isogram? "lumberjacks") ; => true
;; (isogram? "background")  ; => true
;; (isogram? "six-year-old") ; => true
;; (isogram? "isograms")    ; => false

Detailed Code Walkthrough

Let's break down the execution flow using the example input "Downstream".

  1. (defn isogram? [s] ...)
    We define a function named isogram? that accepts one argument, s, which is the input string. The question mark at the end is a Clojure convention for functions that return a boolean value (a predicate function).
  2. (let [letters (...)] ...)
    We use a let binding to create a local variable named letters. This will hold our cleaned-up sequence of characters. Using let makes the code more readable by giving a name to an intermediate result.
  3. (->> s ...)
    The thread-last macro begins. It takes the first argument, s (which is "Downstream"), and "threads" it as the last argument into the next function in the pipeline.
  4. str/lower-case
    The value of s is passed to str/lower-case.
    Execution: (str/lower-case "Downstream")
    Result: "downstream"
  5. (re-seq #"[a-z]")
    The result from the previous step, "downstream", is threaded as the last argument to this function call.
    Execution: (re-seq #"[a-z]" "downstream")
    Result: A lazy sequence of single-character strings: ("d" "o" "w" "n" "s" "t" "r" "e" "a" "m"). This effectively filters out anything that is not a lowercase letter.
  6. (= (count letters) (count (set letters)))
    This is the final comparison. The let binding is now complete, and letters holds the sequence ("d" "o" "w" "n" "s" "t" "r" "e" "a" "m").
    • (count letters) evaluates to 10.
    • (set letters) converts the sequence to a set. Since all letters are unique, the set becomes #{"d" "o" "w" "n" "s" "t" "r" "e" "a" "m"}.
    • (count (set letters)) evaluates to 10.
    • (= 10 10) compares the two counts, which evaluates to true.
    The function returns true, correctly identifying "Downstream" as an isogram.

Now consider the input "isograms". The letters sequence would be ("i" "s" "o" "g" "r" "a" "m" "s").

  • (count letters) would be 8.
  • (set letters) would produce the set #{"i" "s" "o" "g" "r" "a" "m"}. Notice the second "s" is gone.
  • (count (set letters)) would be 7.
  • (= 8 7) evaluates to false.


Alternative Approaches and Performance Insights

The set-based comparison is arguably the most idiomatic and readable Clojure solution. However, exploring alternative methods can deepen our understanding of the language's capabilities. A great alternative involves using frequency counting.

Alternative: Using `frequencies`

Clojure's core library includes a handy function called frequencies. It takes a sequence and returns a map where keys are the items from the sequence and values are the number of times each item appeared.

We can use this to build another elegant solution. An isogram is simply a word where the frequency of every letter is exactly 1.


(defn isogram-freq?
  "Determines if a word is an isogram using a frequency map."
  [s]
  (let [letters (->> s
                     str/lower-case
                     (re-seq #"[a-z]"))
        freq-map (frequencies letters)]
    ;; `vals` gets all the frequency counts from the map.
    ;; `every?` checks if the predicate (#(= 1 %)) is true for every item.
    ;; If all frequencies are 1, it's an isogram.
    (every? #(= 1 %) (vals freq-map))))

;; Example Usage:
;; (isogram-freq? "apple")
;; 1. letters -> ("a" "p" "p" "l" "e")
;; 2. freq-map -> {"a" 1, "p" 2, "l" 1, "e" 1}
;; 3. (vals freq-map) -> (1 2 1 1)
;; 4. (every? #(= 1 %) '(1 2 1 1)) -> false

Logic Flowchart for the `frequencies` Approach

This diagram shows the alternative path using a frequency map.

    ● Start with Input String
    │
    ▼
  ┌───────────────────────┐
  │ Normalize & Filter     │
  │ (Same as before)       │
  └──────────┬────────────┘
             │
             ▼
(e.g., ('a' 'p' 'p' 'l' 'e'))
             │
  ┌───────────────────────┐
  │ Build Frequency Map    │
  │ (using `frequencies`)  │
  └──────────┬────────────┘
             │
             ▼
(e.g., {'a' 1, 'p' 2, 'l' 1, 'e' 1})
             │
  ┌───────────────────────┐
  │ Extract All Values     │
  │ (the counts)           │
  └──────────┬────────────┘
             │
             ▼
       (e.g., (1 2 1 1))
             │
  ┌───────────────────────┐
  │ Check if EVERY value   │
  │ is equal to 1          │
  └──────────┬────────────┘
             │
             ▼
          ◆ All 1s? ◆
         ╱           ╲
        Yes           No
         │             │
         ▼             ▼
    [Return true] [Return false]
         │             │
         └──────┬──────┘
                ▼
              ● End

Pros and Cons Comparison

Both solutions are excellent, but they have subtle differences in performance and intent.

Aspect Set Comparison Approach Frequencies Approach
Readability Extremely high. The logic `(= (count coll) (count (set coll)))` is a very common and clear idiom for checking uniqueness in Clojure. Also very high. Clearly expresses the intent of checking that "every frequency is 1".
Performance Generally very fast. It involves one pass to create the sequence of letters and another pass to build the set. Set creation is highly optimized. Also very fast. It involves one pass to build the frequency map. For very long strings with early duplicates, this approach might do slightly more work as it processes the whole string.
Short-Circuiting Does not short-circuit. It must process the entire string to build the full set before comparing counts. The `every?` function can short-circuit. If it finds a frequency count that is not 1, it will stop immediately and return `false`. However, `frequencies` itself must first process the entire string. So the net effect is minimal here.
Idiomatic Level Considered the most idiomatic and direct solution for this specific "are all items unique?" problem. Also highly idiomatic. `frequencies` is the go-to tool when you need to know *how many* of each item exist, which is slightly more information than this problem requires.

For the isogram problem, the set-based approach is often preferred for its directness. It perfectly maps to the question "Are there any duplicates?" without the intermediate step of calculating exact counts for every character.


Frequently Asked Questions (FAQ)

What is the time complexity of the Clojure isogram solution?

The time complexity is dominated by the step that processes the entire input string. In our primary solution, creating the set from the list of letters takes, on average, O(N) time, where N is the number of letters in the string. The `count` operations are typically O(1) for the data structures involved. Therefore, the overall time complexity is linear, or O(N).

Can this function handle Unicode characters?

Yes, but with a small modification. The regex #"[a-z]" only matches ASCII letters. To handle Unicode letters from various languages, you should use the Unicode character property for letters: #"\p{L}". The modified line would be (re-seq #"\p{L}"). This makes the function globally robust.

Why is the functional pipeline approach with ->> preferred in Clojure?

The functional pipeline approach is preferred because it aligns with Clojure's core philosophy of data transformation. It's highly readable (reads top-to-bottom), avoids mutable state and side effects, and is easily composable. Each step in the pipeline is a small, pure function, making the code easier to test, debug, and reason about compared to a traditional imperative loop with state variables.

How does (set letters) work to find duplicates?

A set is a data structure that, by mathematical definition, contains no duplicate elements. When you construct a set from a sequence (like our list of letters), the Clojure runtime iterates through the sequence. For each item, it checks if that item is already in the set being built. If it's not, it's added. If it already exists, it's simply ignored. The end result is a collection of only the unique items from the original sequence.

What are some other common sequence processing functions in Clojure?

Besides filter (which `re-seq` effectively does for us here), some of the most essential sequence functions are:

  • map: Applies a function to every item in a sequence, returning a new sequence of the results.
  • reduce: Boils a sequence down to a single value by repeatedly applying a function.
  • remove: The opposite of filter; removes items that match a predicate.
  • sort: Returns a sorted version of a sequence.
These form the building blocks of most data manipulation tasks in Clojure.

Is Clojure case-sensitive in this context?

By default, string and character comparisons in Clojure are case-sensitive (e.g., (= "a" "A") is false). That is precisely why the first step in our pipeline is str/lower-case. By converting the entire input to a single, predictable case, we manually eliminate case-sensitivity from our logic, ensuring the function behaves according to the problem's requirements.

How can I test this function effectively?

Clojure has a built-in testing framework, clojure.test. You would create a separate test file and use the deftest and is macros to assert the function's behavior with various inputs, including empty strings, isograms, non-isograms, phrases with mixed case, and phrases with punctuation.

(require '[clojure.test :refer :all]
         '[isogram :refer :all])

(deftest isogram-test
  (is (isogram? ""))
  (is (isogram? "lumberjacks"))
  (is (not (isogram? "isograms"))))

Conclusion: The Power of Functional Thinking

We've journeyed from a simple word puzzle to a deep appreciation of Clojure's functional elegance. The isogram problem, as demonstrated in this kodikra.com module, is a perfect microcosm of the Clojure paradigm. Instead of mutable variables and complex loops, we constructed a clear, declarative data pipeline: normalize, filter, and check for uniqueness using an appropriate data structure.

The final solution is not just code that works; it's code that tells a story about the data's transformation. This approach, centered on immutability and powerful sequence abstractions, is what allows Clojure developers to build robust, scalable, and maintainable systems. You've now mastered a key pattern that you will use time and time again in your functional programming journey.

Ready to apply these concepts to more complex challenges? Dive deeper into the world of functional programming by exploring the complete Kodikra Clojure guide and continue your progress on the Clojure learning path.

Disclaimer: The code and concepts discussed are based on modern, stable versions of Clojure (1.11+). The core principles of sequence processing and immutable data structures are fundamental to the language and will remain relevant for the foreseeable future.


Published by Kodikra — Your trusted Clojure learning resource.