Pearls Key idea: The same algorithms every language teaches — sorting, searching, trees, dictionaries — with Haskell's own honest tradeoffs, including a few genuine gotchas

Common Algorithms and Data Structures

A practical survey tying Pearls' Quicksort and Mergesort to the rest of the sorting landscape, showing why binary search needs the right data structure to actually be fast, and building a binary search tree from scratch before handing off to the real, production-grade Data.Map and Data.Set.

Sorting, compared

Pearls already built two genuinely different sorting algorithms — Quicksort (partition around a pivot) and Mergesort (split, sort halves, merge). A third, simpler than either, rounds out the comparison:

insertionSort :: Ord a => [a] -> [a]
insertionSort = foldr insert []
  where
    insert x []     = [x]
    insert x (y:ys)
      | x <= y      = x : y : ys
      | otherwise   = y : insert x ys

ghci> insertionSort [5,3,8,1]
[1,3,5,8]

insert slides one element into its correct position in an already-sorted list; foldr (Chapter 3) builds the whole sorted list one insertion at a time, right to left. It’s the most direct possible transcription of “keep a sorted pile, slide each new card into place” — genuinely how most people sort a hand of playing cards.

AlgorithmBestAverageWorstStable?
Insertion SortO(n)O(n)O(n2)O(n^2)O(n2)O(n^2)yes
Quicksort (Pearls)O(nlog⁡n)O(n \log n)O(nlog⁡n)O(n \log n)O(n2)O(n^2)yes*
Mergesort (Pearls)O(nlog⁡n)O(n \log n)O(nlog⁡n)O(n \log n)O(nlog⁡n)O(n \log n)yes

* the list-based Quicksort from Pearls happens to preserve relative order of equal elements; a mutating, in-place Quicksort generally does not.

Mergesort’s worst case never degrades, because its split point is always the midpoint by position (Pearls) — Insertion Sort’s worst case is an already-reverse-sorted list, where every single insertion has to walk all the way to the far end.

Searching: why the data structure matters as much as the algorithm

Linear search needs no ceremony — elem already is linear search, walking the list until it finds a match or runs out, O(n)O(n) either way. Binary search is the famous O(log⁡n)O(\log n) improvement — but only if it’s built on the right foundation.

import Data.Array

binarySearch :: Ord a => a -> Array Int a -> Maybe Int
binarySearch target arr = go lo hi
  where
    (lo, hi) = bounds arr
    go l h
      | l > h     = Nothing
      | otherwise =
          let mid    = (l + h) `div` 2
              midVal = arr ! mid
          in case compare target midVal of
               EQ -> Just mid
               LT -> go l (mid - 1)
               GT -> go (mid + 1) h
⚠Common Pitfall

This binary search is only O(log⁡n)O(\log n) because Data.Array’s ! is O(1)O(1) random access. Writing the exact same algorithm over a plain Haskell [a] instead — using !! to index the middle element — is a genuine trap: list indexing is itself O(n)O(n), so an O(log⁡n)O(\log n)-step binary search over a list costs O(n)O(n) per step, for a real total of O(nlog⁡n)O(n \log n) — asymptotically worse than a single linear scan. The algorithm’s shape alone doesn’t guarantee its complexity; the data structure underneath has to actually support the access pattern the algorithm assumes.

Trees: building a binary search tree from scratch

A binary search tree (BST) keeps values ordered so that everything in a node’s left subtree is smaller, and everything in its right subtree is larger — the same ordering invariant that makes binary search on an array work, just expressed as a shape instead of a sorted sequence.

data BST a = Leaf | Node (BST a) a (BST a)

insert :: Ord a => a -> BST a -> BST a
insert x Leaf = Node Leaf x Leaf
insert x t@(Node l y r)
  | x < y     = Node (insert x l) y r
  | x > y     = Node l y (insert x r)
  | otherwise = t   -- already present

member :: Ord a => a -> BST a -> Bool
member _ Leaf = False
member x (Node l y r)
  | x < y     = member x l
  | x > y     = member x r
  | otherwise = True

toList :: BST a -> [a]
toList Leaf         = []
toList (Node l x r) = toList l ++ [x] ++ toList r
Searching a binary search tree for the value 7, taking two comparisons and two hops

Figure: Two comparisons, two hops, to find 7 in a tree of seven values — the same halving-the-candidates idea as array-based binary search above, expressed as tree structure instead of index arithmetic.

toList’s in-order traversal — left subtree, then the node, then right subtree — always produces a sorted list, for free, as a direct consequence of the insertion invariant; it’s a nice self-check that insert is behaving correctly. insert and member both run in O(log⁡n)O(\log n) on average, following exactly the halving pattern the figure shows — but only on average.

★Cool Fact

An unbalanced BST’s worst case is O(n)O(n): inserting already-sorted data produces a tree that’s really just a linked list wearing a Node costume, with every element hanging off the right child of the last. Real-world tree-based dictionaries fix this by self-balancing — rotating the tree during insertion (AVL trees, red-black trees, and the size-balanced trees Data.Map actually uses internally) to guarantee O(log⁡n)O(\log n) height no matter what order values arrive in, not just on average.

Dictionaries and sets: Data.Map and Data.Set

Hand-rolling a BST is the right way to understand an ordered dictionary; Data.Map, from the containers package, is the right way to actually use one — a genuinely balanced, guaranteed-O(log⁡n)O(\log n) tree, battle-tested, and already in nearly every Haskell project’s dependency tree.

import qualified Data.Map as Map

wordCounts :: [String] -> Map.Map String Int
wordCounts = foldr (\w -> Map.insertWith (+) w 1) Map.empty

ghci> wordCounts ["the","cat","sat","on","the","mat"]
fromList [("cat",1),("mat",1),("on",1),("sat",1),("the",2)]

ghci> Map.lookup "the" (wordCounts ["the","cat","the"])
Just 2

Map.insertWith (+) w 1 reads as “insert w with value 1, or if w is already a key, combine the old and new values with (+)” — the classic word-frequency counter, in one line. Data.Set, from the same package, is the same balanced-tree idea with no attached values at all — conceptually close to a Map k (), used whenever membership alone (not an associated value) is what matters:

import qualified Data.Set as Set

ghci> Set.member 3 (Set.fromList [1,2,3,4])
True
ghci> Set.fromList [1,2,3] `Set.union` Set.fromList [3,4,5]
fromList [1,2,3,4,5]
⚠Common Pitfall

Data.Map (and Data.Set) are lazy in their values by default — Map.insertWith (+) w 1 builds up exactly the same kind of unevaluated thunk chain Practical Haskell’s tuple trap warned about, if a key is updated many times before anything ever looks at its value. Data.Map.Strict (a drop-in replacement, same API) forces each value to WHNF on every insert, which is almost always what you want for accumulator-style usage like wordCounts above — reach for Data.Map.Strict by default, and the lazy Data.Map specifically when you have a good reason to defer evaluation.

In the Wild

Data.Map and Data.Set are two of the most-imported modules in the entire Haskell ecosystem — configuration lookups, deduplication, frequency counts, graph adjacency lists, and caching all reach for them constantly, precisely because “give me a balanced tree with guaranteed logarithmic operations, already written and already correct” is such a common need that almost nobody hand-rolls their own BST in production code the way this chapter just did for teaching purposes.

The throughline across this whole chapter is the same one Pearls opened with: a naive, obviously-correct version first (insertionSort, a hand-rolled BST), then the real, production-hardened version once the underlying idea is genuinely understood (Data.Map, self-balancing trees, array-backed binary search) — clarity earning its way to performance, never skipping straight to the second half.