There are several ways of compiling a regular expression (RE) down to a deterministic finite-state automaton (DFA). One such way is based on Brzozowski derivatives of regular expressions. In this post, I describe a concise OCaml implementation of this transformation. This is an opportunity to illustrate the use of fix, a library that offers facilities for constructing (recursive) memoized functions and for performing least fixed point computations.

From REs to DFAs, via Brzozowski derivatives

Suppose e denotes a set of words. Then, its derivative delta a e is the set of words obtained by keeping only the words that begin with a and by crossing out, in each such word, the initial letter a. For instance, the derivative of the set { ace, amid, bar } with respect to a is the set { ce, mid }.

A regular expression is a syntactic description of a set of words. If the set e is described by a regular expression, then its derivative delta a e is also described by a regular expression, which can be effectively computed.

Now, suppose that I am a machine and I am scanning a text, searching for a certain pattern. At each point in time, my current state of mind is described by a regular expression e: this expression represents the set of words that I am hoping to read, and that I am willing to accept. After I read one character, say a, my current state changes to delta a e, because I have restricted my attention to the words of e that begin with a, and I am now hoping to recognize the remainder of such a word.

Thus, the idea, in a nutshell, is to build a deterministic automaton whose states are regular expressions and whose transition function is delta.

The main nontrivial aspect of this apparently simple-minded approach is the fact that only a finite number of states arise when one starts with a regular expression e and explores its descendants through delta. In other words, a regular expression e only has a finite number of iterated derivatives, up to a certain equational theory. Thanks to this property, which I won’t prove here, the construction terminates, and yields a finite-state automaton.

For more details, please consult the paper Regular-expression derivatives re-examined by Scott Owens, John Reppy and Aaron Turon. In particular, Definition 4.1 in that paper gives a number of equations that must be exploited when deciding whether two regular expressions are equal. In the following, I refer to these equations collectively as EQTH, for equational theory. Among other things, these equations state that disjunction is associative, commutative, and idempotent. In other words, a disjunction must be viewed as a set of disjuncts. The empty regular expression can be viewed as an empty disjunction.

An alphabet

Throughout, I assume that the alphabet is given by a module Char whose signature is as follows:

Char : sig
  type t
  val equal: t -> t -> bool
  val hash: t -> int
  val foreach: (t -> unit) -> unit
  val print: t -> string
end

The fact that this alphabet is finite is witnessed by the existence of the function Char.foreach, which enumerates all characters.

As an exercise for the reader, this can be used to define an auxiliary function exists_char of type (Char.t -> bool) -> bool.

Regular expressions, hash-consed

The syntax of regular expressions (expressions, for short) is naturally described by an algebraic data type regexp.

It is the same syntax as in Owens et al.’s paper, except I use n-ary disjunctions and conjunctions. As explained above, this is dictated by the need to take EQTH into account. The data constructors EDisj and EConj carry a list of subexpressions. This list is normalized in such a way that, if two lists are equal as sets, then they are equal as lists, too. This list is never a singleton list. It can be empty: EDisj [] is the empty expression zero, while EConj [] is the universal expression one.

type regexp =
  skeleton HashCons.cell

and skeleton =
  | EEpsilon
  | EChar of Char.t
  | ECat of regexp * regexp
  | EStar of regexp
  | EDisj of regexp list
  | EConj of regexp list
  | ENeg of regexp

A slight twist is that expressions are hash-consed. That is, every expression is decorated with an integer identifier. (A record of type skeleton HashCons.cell is a pair of an integer identifier and a skeleton.) These identifiers are unique: two expressions are equal if and only if they carry the same identifier. (This notion of equality takes EQTH into account.) This allows testing very quickly whether two expressions are equal. This also allows building efficient dictionaries whose keys are expressions, or in other words, efficient memoized functions of type regexp -> .... This is heavily exploited in the code that follows: the functions nullable, delta, and nonempty are three examples, and there are more.

The module HashCons, which is part of fix, provides facilities for hash-consing. (The reader is encouraged to take a look at its implementation: it is very short.) It is used as follows:

let make : skeleton -> regexp =
  let module H = HashCons.ForHashedType(struct
    (* Define equality and hashing of skeletons. *)
    type t = skeleton
    let equal = ...
    let hash = ...
  end)
  in H.make

let skeleton : regexp -> skeleton =
  HashCons.data

The function make is where the magic takes place: whenever one wishes to construct a new expression, one constructs just a skeleton and passes it to make, which takes care of determining whether this skeleton is already known (in which case an existing integer identity is re-used) or is new (in which case a fresh integer identity is allocated). The function skeleton, which converts between an expression and a skeleton in the reverse direction, is just a pair projection.

Here are two basic examples of the use of make:

let epsilon : regexp =
  make EEpsilon
let zero : regexp =
  make (EDisj [])

Still using make, one can define several smart constructors that build expressions: concatenation @@, disjunction, conjunction, iteration, negation. These smart constructors reduce expressions to a normal form with respect to the equational theory EQTH. In particular, disjunction flattens nested disjunctions, sorts the disjuncts, and removes duplicate disjuncts, so that two disjunctions that are equal according to EQTH are indeed recognized as equal.

let (@@) : regexp -> regexp -> regexp = ...
let star : regexp -> regexp = ...
let disjunction : regexp list -> regexp = ...
let conjunction : regexp list -> regexp = ...
let neg : regexp -> regexp = ...

Nullability

An expression is nullable if and only if it accepts the empty word. Determining whether an expression is nullable is a simple matter of writing a recursive function nullable of type regexp -> bool.

I memoize this function, so the nullability of an expression is computed at most once and can be retrieved immediately if requested again. (This is not mandatory, but it is convenient to be able to call nullable without worrying about its cost. Another approach, by the way, would be to store nullability information inside each expression.)

The module Memoize, which is part of fix, provides facilities for memoization. To use it, I apply the functor Memoize.ForHashedType to a little module R (not shown) which equips the type regexp with equality, comparison, and hashing functions. (Because expressions carry unique integer identifiers, the definition of R is trivial.) This functor application yields a module M which offers a memoizing fixed-point combinator M.fix.

Then, instead of defining nullable directly as a recursive function, I define it as an application of M.fix, as follows.

let nullable : regexp -> bool =
  let module M = Memoize.ForHashedType(R) in
  M.fix (fun nullable e ->
    match skeleton e with
    | EChar _ ->
        false
    | EEpsilon
    | EStar _ ->
        true
    | ECat (e1, e2) ->
        nullable e1 && nullable e2
    | EDisj es ->
        exists nullable es
    | EConj es ->
        forall nullable es
    | ENeg e ->
        not (nullable e)
  )

Derivation

It is now time to define a key operation: computing the Brzozowski derivative of an expression. If a is a character and e is an expression, then delta a e is the derivative of e with respect to a.

Implementing delta is a textbook exercise. A key remark, though, is that this function must be memoized in order to ensure good complexity. A naive, non-memoizing version of it would have exponential cost, due to the duplication that takes place in the cases of concatenation and iteration.

In order to memoize a function of two arguments, one possible approach would be to rewrite it as a function that takes a pair as an argument. Here, instead, I rely on currying. For every character a, delta a is a function of type regexp -> regexp. I memoize each such function independently using fix, and I memoize the function delta itself using memoize, a nonrecursive memoization combinator.

let delta : Char.t -> regexp -> regexp =
  let module C = Memoize.ForHashedType(Char) in
  let module M = Memoize.ForHashedType(R) in
  C.memoize (fun a ->
    M.fix (fun delta e ->
      match skeleton e with
      | EEpsilon ->
          zero
      | EChar b ->
          if Char.equal a b then epsilon else zero
      | ECat (e1, e2) ->
          delta e1 @@ e2 ||| if nullable e1 then delta e2 else zero
      | EStar e ->
          delta e @@ star e
      | EDisj es ->
          disjunction (map delta es)
      | EConj es ->
          conjunction (map delta es)
      | ENeg e ->
          neg (delta e)
    )
  )

(Non)Emptiness

During the construction of the DFA (which comes up next), it is convenient to be able to (efficiently) decide whether an expression is nonempty (that is, whether it accepts at least one word).

Following Owens et al., I have included all three Boolean operators (disjunction, conjunction, negation) in the syntax of expressions. The presence of conjunction makes it apparently nontrivial to determine whether an expression is nonempty.

Fortunately, by exploiting nullable, delta, and with the help of fix, I am able to define an efficient nonemptiness test in four lines of code:

let nonempty : regexp -> bool =
  let module F = Fix.ForHashedType(R)(Prop.Boolean) in
  F.lfp (fun e nonempty ->
    nullable e || exists_char (fun a -> nonempty (delta a e))
  )

This test is based on the following fact: e is nonempty if and only if either e is nullable or there exists a character a such that delta a e is nonempty.

This statement, however, cannot be used directly as a recursive definition of nonempty, because such a definition would not be well-founded. An attempt to define nonempty using let rec or M.fix would construct a possibly nonterminating function.

Instead, the above statement must be viewed as an inductive characterization of nonemptiness. That is, nonempty is the least fixed point in the Boolean lattice (where false is less than true) of the equation:

nonempty e = nullable e || exists_char (fun a -> nonempty (delta a e))

The main module of the fix library, named Fix, is an algorithm for computing a least fixed point of type variable -> property, where the type property forms a lattice. The functor application Fix.ForHashedType(R)(Prop.Boolean) instantiates this algorithm so that the type variable is regexp and the type property is bool. Then, the fixed point combinator F.lfp allows us to give a straightforward definition of nonempty.

The function nonempty thus defined is incremental. Nothing is computed when the function is constructed; computation takes place only when the function is invoked. It is also memoized: the nonemptiness of an expression, once computed, is recorded and is never recomputed. Thus, one can invoke this function without worrying about its cost.

The reader might note that nonemptiness could also be computed via a graph traversal: an expression is nonempty if only there is exists a path from this expression to a nullable expression in the graph whose vertices are expressions and whose edges are determined by delta. What I have just done is exploit the fact that co-accessibility is easily expressed as a least fixed point.

Constructing a DFA

The tools are now at hand to convert an expression to a deterministic finite-state automaton.

I must first settle on a representation of such an automaton as a data structure in memory. I choose to represent a state as an integer in the range of 0 to n-1, where n is the number of states. An automaton can then be described as follows:

type state =
  int

type dfa = {
  n: int;
  init: state option;
  decode: state -> regexp;
  transition: state -> Char.t -> state option;
}

init is the initial state. If it is absent, then the automaton rejects every input.

The function decode maps every state to the expression that this state accepts. This expression is guaranteed to be nonempty. This state is a final state if and only if this expression is nullable.

The function transition maps every state and character to an optional target state.

Now, how does one construct a DFA for an expression e? The answer is simple, really. Consider the infinite graph whose vertices are the nonempty expressions and whose edges are determined by delta. The fragment of this graph that is reachable from e is guaranteed to be finite, and is exactly the desired automaton.

There are several ways of approaching the construction of this finite graph fragment. I choose to first perform a forward graph traversal during which I discover the vertices that are reachable from e and number them from 0 to n-1. Once this is done, completing the construction of a data structure of type dfa is easy.

let dfa (e : regexp) : dfa =
  let module G = struct
    type t = regexp
    let foreach_successor e yield =
      Char.foreach (fun a ->
        let e' = delta a e in
        if nonempty e' then yield e'
      )
    let foreach_root yield =
      if nonempty e then yield e
  end in
  let module N = Number.ForHashedType(R)(G) in
  let n, decode = N.n, N.decode in
  let encode e = if nonempty e then Some (N.encode e) else None in
  let init = encode e in
  let transition q a = encode (delta a (decode q)) in
  { n; init; decode; transition }

In the above code, the module G is a description of the graph that I wish to traverse.

The module Number, which is part of fix, helps explore this graph. The functor application Number.ForHashedType(R)(G) performs a traversal of the graph G and constructs a numbering N of its vertices. The module N contains the following data:

  val n: int
  val encode: regexp -> int
  val decode: int -> regexp

Here, n is the number of vertices that have been discovered. The functions encode and decode record the correspondence between vertices and numbers. In other words, decode has type state -> regexp and maps a state to the nonempty expression that this state stands for. Conversely, encode has type regexp -> state and maps a nonempty expression to a state.

I extend encode to a function of type regexp -> state option, which can be applied to a possibly empty expression. Once this is done, the initial state init is obtained by transporting the expression e through the encoding, while the transition function transition is obtained by transporting the function delta through the encoding.

That’s all!

The automaton thus obtained is not necessarily minimal.

Examples

The expression dead gives rise, quite naturally, to the following automaton. As usual, every transition is labelled with a character. In addition, every state is labelled with the expression that it represents.

Naturally, this automaton is not very useful, as it merely tests whether the input begins with dead. To search the input for the word dead, one should use the expression 1dead. (Here, 1 denotes the universal expression one.) This expression gives rise to the following automaton:

Here is another example of searching for a nontrivial pattern. This automaton corresponds to the expression 1a(a|b)*(bc)*:

The reader may notice that a final state can have outgoing transitions. If one is interested in finding all matches or in finding a longest match, then this is useful.

However, if one is interested only in searching for a first match and a shortest match, then there is no need for a final state to have outgoing transitions. The above construction can easily be modified so that final states do not carry any outgoing transitions. This leads to slightly simpler automata. For instance, the expression 1dead leads to the following first-match automaton:

Feeding an input text into this automaton is essentially equivalent to searching for the word dead using the Knuth-Morris-Pratt algorithm.

The expression 1(add|dead), which is used to search a text for one of the words add and dead, gives rise to the following first-match automaton:

Feeding an input text into this automaton is essentially equivalent to searching for one of the words add and dead using Aho and Corasick’s algorithm.

Conclusion

Memoization, hash-consing, and fixed points are powerful tools. The fix library makes these tools easy to use. The conversion of regular expressions to deterministic finite-state automata by Brzozowski’s method is a good illustration of their application.

For more details, please look at the full source code for this demo.