Prerequisite knowledge: Language and decision problems
Finite State Machine (Finite State Machine, FSM, also abbreviated as an automaton below) is the simplest class of computational models, characterized by extremely limited descriptive power and resources. Automata are widely used in OI and computer science, and their ideas appear in many string algorithms, so it is recommended to learn automata before studying string algorithms such as KMP, the Aho–Corasick automaton, and SAM.
Introduction to Automata
First, let us understand what automata are used for: an automaton is a mathematical model for determining whether a signal sequence satisfies a particular pattern or rule.
Some of the terms in this sentence can be explained more concretely. A “signal sequence” is a sequence of signals arranged in order, such as each character of a string from beginning to end, each number of an array from to , or each digit of a number from most significant to least significant. “Determining whether a rule is satisfied” can be understood as asking whether we care about the sequence belonging to a particular set. This set is defined by rules that we specify in advance, such as “all binary strings of even length” or “all palindromes.”
Sometimes we need to answer questions of this kind: does a given sequence have a certain property? For example, is a binary number odd, is a string a palindrome, or is it a subsequence of another string? An automaton is a mathematical tool for solving such questions.
The way an automaton works is very similar to a flowchart. Suppose you want to order a cup of milk tea on a food-delivery platform; all your choices form a sequence. The following flowchart is an example:
For example, if your sequence of choices is “open the ordering interface -> choose milk tea -> have enough money for the milk tea,” then the states you pass through in order might be “food-delivery platform -> ordering interface -> pay for the milk tea -> bought the milk tea.” In this way, our “milk-tea automaton” determines, based on our choices, whether we bought the milk tea. We can also see that there may be more than one way to reach a state. Likewise, if you did not buy the milk tea, you might have exited directly from the ordering interface, or you might not have had enough money and therefore failed to buy it.
Using this automaton, we divided signal sequences into two classes: signal sequences that bought the milk tea and signal sequences that did not. By looking at the final state, we completed a decision problem.
Although we just used a flowchart as an analogy for how an automaton works, the flowchart itself is only an intuitive visualization tool and does not constitute the mathematical definition of an automaton. To characterize the structure of an automaton more precisely, we need to abstract the elements of the flowchart. After abstraction, we find that the structure of the flowchart can actually be simplified to a directed graph, where each vertex represents a state and each directed edge represents a transition between states.
Thus, the core structure of an automaton can be formally viewed as a directed graph, which we call a state graph.
An automaton works similarly to a flowchart, with the following differences: every vertex of an automaton is a decision vertex; a vertex of an automaton is just a state, not a task; and an edge of an automaton can accept multiple characters (not limited to T or F).
For example, the automaton for “determining whether a binary number is even” is as follows:
Starting from the initial vertex, accept the binary sequence of the number from the most significant digit to the least significant digit, and then see where you end up. If you end at a red-circled vertex, the number is even; otherwise, it is not.
Here, we should emphasize that we will repeatedly use terms such as “character” and “character set” below. This does not mean that automata can only be applied in the string domain: a character need not be a letter such as ; it may also represent a choice.
If we need to determine the relationship between one finite signal sequence and another signal sequence (for example, whether the other signal sequence is a subsequence of a signal sequence), a common method is to construct an automaton for that finite signal sequence. This will be discussed when learning KMP.
Note that an automaton is only a mathematical model, and is neither an algorithm nor a data structure. There are many ways to implement the same automaton, and they may have different time and space complexities.
Next, you can either continue studying automata on this page, or learn concrete examples such as KMP, the Aho–Corasick automaton, or SAM.
FSMs are divided into two categories: deterministic finite automata and nondeterministic finite automata.
Deterministic Finite Automata
A deterministic finite automaton (Deterministic Finite Automaton, DFA) is characterized by a deterministic decision process. Taking the “milk-tea automaton” as an example, as soon as you open the ordering interface, you enter the ordering interface; unexpected events such as a network crash preventing it from opening or a dead phone screen do not occur.
DFA
A DFA is a five-tuple consisting of:
- A finite state set . If a DFA is viewed as a directed graph, its states correspond to the vertices of the graph.
- An alphabet . The automaton can input only these characters.
- A transition function , which is a function that accepts two parameters and returns a value. Its first parameter and return value are states, and its second parameter is a character from the alphabet. If a DFA is viewed as a directed graph, its transition function corresponds to the edges between vertices, with a character on each edge.
- An initial state , which is a distinguished state. In different articles, the initial state is generally denoted by , , or ; this article uses .
- An accepting state set , which is a set of distinguished states.
A DFA can be represented simply by the following structure:
Reference implementation
// Deterministic Finite Automaton (DFA)
struct DFA {
int m; // Alphabet size.
int n; // Number of states.
int q0; // Initial state.
std::vector<std::vector<int>> trans; // Transitions: trans[c][q].
std::vector<int> acc; // Acceptance labels per state:
// - 0 = non-accepting
DFA(int m, int n = 0, int q0 = 0)
: m(m), n(n), q0(q0), trans(m, std::vector<int>(n)), acc(n) {}
// Returns minimized DFA via Hopcroft's algorithm.
DFA hopcroft_minimize() const;
};
The process of finding the state sequence of an input string in a DFA and determining whether it is accepted is called computation.
DFA computation
Let be a DFA, and let be a string. If there exists a sequence of states in such that
- ,
- holds for every ,
- ,
then is said to accept (accepts) . Otherwise, is said to reject .
When a DFA reads a string, it transitions one character at a time from the initial state according to the transition function. If, after reading all characters of a string, it is in an accepting state, we say that the DFA accepts the string; otherwise, we say that the DFA rejects the string.
Formal language
A formal language (or simply a language) over a character set is a set of strings over .
Language recognized by an automaton
For an automaton , the language it recognizes is defined as the set of all strings it accepts, .
Not every language can be recognized by a DFA.
Regular language
If a language can be recognized by some DFA, it is called a regular language.
As mentioned above, an automaton can be represented by a state graph. The following is a DFA that accepts exactly the strings , , and :
(The dead state is omitted from the figure; all transitions not shown point to that dead state.)
Nondeterministic Finite Automata
A nondeterministic finite automaton1 (Nondeterministic Finite Automaton, NFA) is a natural generalization of a DFA. In an NFA, for any state and any character, there may be zero, one, or multiple successor states. At the same time, the NFA discussed in this section allows epsilon transitions, meaning that a state can transition to one of its successors without consuming any character.
For example, consider the “milk-tea automaton” again. After placing the order, even if you have enough money for the milk tea, poor network conditions may cause you not to buy it; this gives multiple successors. It is also possible that, because you were slow, the input string (that is, the sequence of operations) is the same but the milk tea has sold out, so you do not buy it; this is the presence of an epsilon transition, which may or may not be taken. The above automaton can be modified slightly to implement this behavior:
Clearly, every DFA is an NFA, so an NFA can recognize at least all regular languages. But as an extension of a DFA, can an NFA recognize more languages? In fact, it cannot; we will discuss the equivalence of DFAs and NFAs later.
NFA
Let denote the power set of . Let denote the empty string, and write . An NFA is a five-tuple consisting of:
- A finite state set ,
- An alphabet ,
- A transition function , a function that accepts two parameters and returns a state set. The first parameter is a state, the second parameter is a character from the alphabet, and the return value is the set of all possible successor states (which may be empty),
- An initial state ,
- An accepting state set .
The computation of an NFA is equivalent to running multiple DFAs simultaneously. At each step, all possibilities are enumerated; finally, as long as one branch reaches an accepting state, the NFA accepts the entire string.
NFA computation
Let be an NFA. The string can be represented as . If there exists a sequence of states in such that
- ,
- holds for every ,
- ,
then is said to accept . Otherwise, is said to reject .
Because epsilon transitions are allowed, when representing as , any number of epsilon symbols may be inserted. For example, the string can be represented as . Unlike a DFA, where each input corresponds to only one result, each input to an NFA may correspond to multiple results, forming a set of results.
Equivalence of DFAs and NFAs
We call two automata equivalent if and only if they recognize the same language. DFAs and NFAs are equivalent, meaning that every NFA is equivalent to some DFA; therefore, the class of languages recognized by NFAs is also the class of all regular languages. Every DFA can be directly viewed as an NFA; conversely, an NFA can be converted into a DFA using powerset construction.
Powerset construction
Suppose the NFA is . Define as the set of states reachable from state by following only transitions.
Construct the DFA , where:
- Finite state set ,
- Transition function satisfies ,
- Accepting state set .
Clearly, at every step of the computation, the state occupied by corresponds to the set of states that may occupy.
Although NFAs and DFAs have the same language-recognition power, NFAs are still useful. This is because, for some regular languages, an NFA requires far fewer states than a DFA. For example, one can construct an NFA with states whose corresponding minimal DFA has states. In this case, directly computing the NFA has better time complexity.
Time Complexity of Computing DFAs and NFAs
Suppose the given string has length , the automaton has states, and the alphabet size is constant. Then, clearly, the time complexity of DFA computation is ; we only need to simulate the process above.
The time complexity of naive NFA computation is , because we need to consider every possible successor and the complexity required to merge states. Of course, bitsets or the Method of Four Russians can be used to optimize the computation to or .
Regular Expressions and Regular Languages
This section discusses the definitions and properties of regular expressions and regular languages, and examines the relationship between regular expressions and FSMs.
Regular Expressions
A regular expression is another commonly used way to describe regular languages. Although we can see this name in many modern languages (such as Python), those languages actually implement a superset of regular expressions.
Regular expressions
Given a character set , a regular expression is a string of symbols inductively defined by the following rules:
- Any character is a regular expression;
- The empty-string symbol is a regular expression;
- The empty-language symbol is a regular expression;
- If and are regular expressions, then , (also written as ), and are regular expressions.
The goal of a regular expression is to describe a language using these symbols. Every regular expression has a corresponding formal language.
Language represented by a regular expression
Let the formal language corresponding to each regular expression be . Then:
- If , where , then ;
- If , then ;
- If , then ;
- If , then ;
- If , then , where denotes concatenating the two strings in order;
- If , then , also called the Kleene star (or Kleene closure), abbreviated as the closure.
Of course, once the precedence of the operations has been specified, these parentheses can be omitted when doing so causes no ambiguity.
Example
Let and . Then:
- ,
- ,
- .
Every regular expression can be converted into an NFA through Thompson's construction, and every DFA can be converted into a regular expression through the State Elimination Method2. Therefore, regular expressions and FSMs are equivalent.
Regular Languages
In this subsection, rather than considering particular regular expressions, we consider regular expressions parameterized by variables (where the variables may be arbitrary regular languages). Applying the algebraic laws of regular expressions helps simplify regular expressions.
Algebraic properties of regular languages
- Commutativity of union:
- Associativity of union:
- Associativity of concatenation:
- is the identity element for union:
- is the identity element for concatenation:
- is an absorbing element for concatenation:
- Distributivity: ,
- Idempotence of union:
- Laws related to closure: ,,
The closure properties of regular languages are also important. These properties allow us to start from some simple automata and, through certain operations, construct finite-state machines (FSMs) that recognize other languages. In short, closure properties can serve as tools for constructing complex FSMs.
For the closure properties of regular languages, we have:
Closure properties of regular languages
Let be two regular languages over the character set , and let the mapping be given. Define the homomorphism of a string as . Then:
- The union of two regular languages is regular;
- The concatenation of two regular languages is regular;
- The closure of a regular language is regular;
- The complement of a regular language is regular;
- The intersection of two regular languages is regular;
- The difference of two regular languages is regular;
- The reversal of a regular language is regular;
- The homomorphic image of a regular language is regular;
- The inverse homomorphic image of a regular language is regular.
A simple consequence is that every finite language is regular. In fact, a Trie is an automaton that recognizes it.
Myhill–Nerode Theorem
The Myhill–Nerode theorem provides a criterion for determining whether a language is regular. It describes the structural properties of regular languages through the concept of equivalence classes.
Nerode equivalence relation
For a language and any strings , if for every , we have , then the strings and are said to be equivalent with respect to , denoted by .
In other words, if, for two strings and , after appending the same arbitrary string (including the empty string) to and , they are always either both in or both not in , then we say that and are equivalent with respect to .
According to the definition above, we partition the set of all finite strings into one or more equivalence classes. If and only if the number of these equivalence classes is finite, we can use them to construct a DFA that recognizes the language. The number of states of this DFA equals the number of equivalence classes. Moreover, this number of states is the smallest among all DFAs that can recognize the language. This is the Myhill–Nerode theorem.
Myhill–Nerode theorem
A language is regular if and only if the number of equivalence classes into which is partitioned by the equivalence relation is finite.
For any DFA that recognizes the language , any two strings and that drive it to the same state must belong to the same equivalence class.
Consequently, the number of equivalence classes is the number of states of the minimal DFA that recognizes . Every equivalence class corresponds to exactly one state in the minimal DFA. This minimal DFA is unique up to isomorphism.
This theorem provides a way to construct a DFA using an equivalence relation:
- The state set consists of all equivalence classes obtained from the partition induced by the equivalence relation. Choose any representative string for each equivalence class (for example, a shortest string).
- To construct the transition function, append the transition character to the chosen representative string and find the state corresponding to the equivalence class containing the resulting string; this is the successor state of the transition. Since all strings in the same equivalence class are equivalent, the choice of representative string does not affect the transition result.
- The initial state is the equivalence class corresponding to the empty string .
- The accepting state set is the set of equivalence classes whose representative strings belong to the given language.
As a classic example, a suffix automaton is a minimal DFA constructed using the Myhill–Nerode theorem.
The Myhill–Nerode theorem is usually applied to construct a DFA for an infinite regular language. In many cases, the problem conditions are simple enough that we need only examine the set of strings whose lengths are not too large, and can then construct an automaton recognizing the entire language.
Example Problem
This section introduces the practical application of the Myhill–Nerode theorem through an example problem.
P12294 [THUPC 2025 Finals] A 01 string, n ternary operators, final value 1 (Enhanced Version)
The ternary operation table for (where consists only of ) means that if the bit at position of is , it returns ; otherwise, it returns .
Given the operation table and strings of length , for each string you need to answer:
Whether it is possible to perform operations, each replacing three consecutive digits with the corresponding operation value, so that the result of the operation is ; provide a construction or determine that it is impossible.
.
Solution
The set of strings that can be reduced to is a regular language (that is, there exists a DFA that can determine whether a string can be reduced to )3. Therefore, consider using the Myhill–Nerode theorem. Since the conditions are simple, experiments show that we need only partition strings of length into equivalence classes; when testing whether two strings are equivalent, it is enough to enumerate suffixes of length . If, after appending any suffix of length to two strings, they can either both be reduced to the string we want or neither can, then the two strings are equivalent.
Each transition is equivalent to appending a new character to the current string and then replacing the new string by the shortest string in its equivalence class. Construct an automaton according to these transitions. This automaton can determine in time whether a string of length has an operation sequence whose result is . At the same time, the automaton has very few states.
For convenience, we construct automata. These automata represent whether it is possible, through some operation sequence, to obtain , respectively. Over all possible operation tables, the maximum number of states of an automaton is .
Using the automata and appropriate preprocessing, binary lifting or a cat tree can be used to implement static range queries asking whether an operation sequence exists whose result is on an interval. A query takes with the former and with the latter.
Consider using divide and conquer to solve the construction problem. Let denote a construction that combines interval into . We use heuristic splitting: maintain two pointers , one scanning from left to right and the other from right to left, and enumerate the split point as either or . For , enumerate how is split into left and right parts, with one part of having length and the other part of having length . (For example, for the median operation table , can be split into and .) For , is directly split into the left and right parts.
If the two parts obtained this way are and , further check whether can generate and can generate ; if so, recurse immediately. With cat-tree queries, the complexity of this heuristic-splitting construction is ; otherwise, using binary lifting for the checks, the construction complexity is .
If a cat tree is used, the total complexity is , where . For convenience, the reference code uses binary lifting and reduces constants through low-level blocking, giving a total complexity of .
Reference code
#include <array>
#include <iostream>
#include <unordered_map>
#include <vector>
constexpr int L = 3;
std::array<int, 1 << L> op;
constexpr int X = (1 << L) - 2;
// Build a DFA that accepts binary strings which evaluate to a given string.
// 0b000 → "00"
// 0b001 → "01"
// 0b010 → "10"
// 0b011 → "11"
// 0b100 → "0"
// 0b101 → "1"
DFA build(int x) {
constexpr int L1 = 9, L2 = 6;
// The result for string `st` of length `len` is stored at (1 << len) | st.
const auto idx = [](int len, int st) -> int { return (1 << len) | st; };
// Compute which bit strings are accepted using dynamic programming.
std::vector<bool> acc(1 << (L1 + L2 + 1));
acc[(x >> 2) ? idx(1, x & 1) : idx(2, x)] = true;
for (int len = (x >> 2) ? 3 : 4; len <= L1 + L2; len += 2) {
for (int st = 0; st < (1 << len); ++st) {
for (int i = 0; i + L <= len; ++i) {
// Replace st[i..i+2] using the op[] rule, and check if the result is
// accepted. Result = st[0..i-1] + op[st[i..i+2]] + st[i+3..end]
int pr =
((((st >> (i + L)) << 1) | op[(st >> i) & ((1 << L) - 1)]) << i) |
(st & ((1 << i) - 1));
if (acc[idx(len - 2, pr)]) {
acc[idx(len, st)] = true;
break;
}
}
}
}
// Construct a DFA using the Myhill-Nerode theorem.
DFA dfa(2);
std::unordered_map<std::vector<bool>, int>
mp; // Maps characteristic vectors to state IDs.
std::vector<int> sts; // Representative 01 strings for each state.
std::vector<int> ids(1 << (L1 + 1)); // Maps 01 strings to DFA state IDs.
for (int len = 0; len <= L1; ++len) {
for (int st = (1 << len); st < (2 << len); ++st) {
// Construct characteristic vector for this prefix.
std::vector<bool> key(1 << (L2 + 1));
for (int nxt = 0; nxt <= L2; ++nxt)
std::copy(acc.begin() + (st << nxt), acc.begin() + ((st + 1) << nxt),
key.begin() + (1 << nxt));
if (!mp.count(key)) {
mp[key] = dfa.n++;
sts.push_back(st);
dfa.acc.push_back(key[idx(0, 0)]);
}
ids[st] = mp[key];
}
}
// Build transitions.
for (int c = 0; c < 2; ++c) {
dfa.trans[c].resize(dfa.n);
for (int i = 0; i < dfa.n; ++i) dfa.trans[c][i] = ids[(sts[i] << 1) | c];
}
return dfa;
}
std::vector<DFA> dfa;
// Initialize the DFA's.
void init() {
std::string s;
std::cin >> s;
for (int i = 0; i < (1 << L); ++i) op[i] = s[i] - '0';
std::swap(op[1], op[4]);
std::swap(op[3], op[6]);
for (int x = 0; x < X; ++x) dfa.push_back(build(x));
}
std::string s;
constexpr int B = 5; // Block size is 2^B.
std::vector<std::vector<std::array<std::vector<int>, X>>> pre;
void precompute() {
int n = s.size();
for (int i = 0; i < n; ++i) s[i] -= '0';
int nn = ((n - 1) >> B) + 1;
pre.clear();
// Compute transitions over single blocks (2^B characters) for each DFA.
pre.emplace_back(nn);
for (int i = 0; i < nn; ++i) {
for (int x = 0; x < X; ++x) {
pre[0][i][x].resize(dfa[x].n);
for (int j = 0; j < dfa[x].n; ++j) {
int cr = j;
for (int ii = (i << B); ii < ((i + 1) << B) && ii < n; ++ii)
cr = dfa[x].trans[s[ii]][cr];
pre[0][i][x][j] = cr;
}
}
}
// Build higher-level transitions using binary lifting.
for (int d = 1; (1 << d) <= nn; ++d) {
pre.emplace_back(nn - (1 << d) + 1);
for (int i = 0; i <= nn - (1 << d); ++i) {
for (int x = 0; x < X; ++x) {
pre[d][i][x].resize(dfa[x].n);
for (int j = 0; j < dfa[x].n; ++j)
pre[d][i][x][j] =
pre[d - 1][i + (1 << (d - 1))][x][pre[d - 1][i][x][j]];
}
}
}
}
// Check if the x-th DFA accepts s[l...r].
bool check(int l, int r, int x) {
if (l > r) return false;
int cr = dfa[x].q0;
if ((l >> B) == (r >> B)) {
for (; l <= r; ++l) cr = dfa[x].trans[s[l]][cr];
} else {
for (; (l & ((1 << B) - 1)) && (l <= r); ++l) cr = dfa[x].trans[s[l]][cr];
int d = (r >> B) - (l >> B);
for (int z = 0; z < (int)pre.size(); ++z)
if ((d >> z) & 1) {
cr = pre[z][l >> B][x][cr];
l += 1 << (z + B);
}
for (; l <= r; ++l) cr = dfa[x].trans[s[l]][cr];
}
return dfa[x].acc[cr];
}
// Construct expression for s[l...r].
void calc(int l, int r, int x) {
if (l == r) return (void)(std::cout << (x & 1));
if (x >> 2) {
for (int i = l, j = r; i <= j; i += 2, j -= 2) {
for (int k = 0; k < (1 << L); ++k) {
if (op[k] != (x & 1)) continue;
if (check(l, i, (k >> 2) | 4) && check(i + 1, r, k & 3)) {
std::cout << '(';
calc(l, i, (k >> 2) | 4);
calc(i + 1, r, k & 3);
std::cout << ')';
return;
}
if (check(l, j - 1, k >> 1) && check(j, r, (k & 1) | 4)) {
std::cout << '(';
calc(l, j - 1, k >> 1);
calc(j, r, (k & 1) | 4);
std::cout << ')';
return;
}
}
}
} else {
for (int i = l, j = r; i <= j; i += 2, j -= 2) {
if (check(l, i, (x >> 1) | 4) && (check(i + 1, r, (x & 1) | 4))) {
calc(l, i, (x >> 1) | 4);
calc(i + 1, r, (x & 1) | 4);
return;
}
if (check(l, j - 1, (x >> 1) | 4) && check(j, r, (x & 1) | 4)) {
calc(l, j - 1, (x >> 1) | 4);
calc(j, r, (x & 1) | 4);
return;
}
}
}
}
void solve() {
std::cin >> s;
int n = s.size();
precompute();
if (!check(0, n - 1, 0b101)) {
std::cout << -1 << '\n';
return;
}
calc(0, n - 1, 0b101);
std::cout << '\n';
}
int main() {
init();
int q;
std::cin >> q;
for (; q; --q) {
solve();
}
return 0;
}
Exercises
- Median Replace Hard
- JOISC 2024 Card Collection (Build an automaton using the Myhill–Nerode theorem; this problem supports multiple range queries.)
DFA Minimization
As mentioned earlier, two DFAs are equivalent if and only if they recognize the same regular language. According to the languages they recognize, all DFAs are partitioned into infinitely many equivalence classes. When running algorithms such as DP over DP, the of the constructed DFA may be too large, making the transition complexity of the outer DP too high. Therefore, it is often necessary to find the minimal DFA in the equivalence class containing the given DFA, in order to reduce the transition complexity of the outer DP.
The Myhill–Nerode theorem from the previous section provides one construction method. However, for some more complex problems, directly constructing a DFA through the Myhill–Nerode theorem requires traversing a very large set of strings and takes a great deal of time. Therefore, we need a method that can start from an already constructed DFA (usually easier to obtain) and directly construct a minimal DFA. This is called the DFA minimization problem.
A commonly used algorithm for DFA minimization is the Hopcroft algorithm. Since the Myhill–Nerode theorem states that, for any DFA recognizing a language, any strings that drive it to the same state must be Nerode-equivalent, all Nerode-equivalent strings correspond to the same state in the minimal DFA. Thus, every state of the minimal DFA must be a set of states in the current DFA. We can start from the state set of the existing DFA and partition it into equivalence classes without examining specific strings. Hopcroft's algorithm starts from the coarsest partition and uses a series of witnesses to refine this partition until it can no longer be refined. This is the core idea of Hopcroft's algorithm.
A witness is a state set such that it and its complement necessarily correspond to different Nerode equivalence classes. In other words, there exists a string such that, after reading starting from the states in and from the states in , respectively, all states in are accepting while all states in are non-accepting, or vice versa. Therefore, if two states under some character have exactly one transition into the witness and the other transition outside , that is, exactly one of and holds, then likewise cannot belong to the same Nerode equivalence class: exactly one of the states and is accepting. Thus, the partition can be refined using whether holds. Specifically, let
If both and are nonempty, then the state set in the current partition can be refined into and .
Initially, put the accepting state set into the witness set as a witness, namely, , and maintain the current partition as . The initial witness is clearly valid: states in and can never be equivalent. Each time, arbitrarily remove a set from the witness set to refine the current partition. Enumerate all characters . For every state set in the current partition , compute the described above. If and , then can be further split into and , and they directly replace in .
Whenever a finer partition is obtained, new witnesses are obtained as well. In principle, both newly obtained and could be put into the witness set for later verification. However, this is not necessary. It is easy to see that, for the three witnesses , verifying any two is enough to guarantee correctness: the result has only three possibilities, , , and , and partitioning a set into three parts requires only two tests. Therefore, when splitting into and , if is still in the witness set , this means that the witness has not yet been tested, so in must be replaced by the two witnesses and ; otherwise, the current partition is necessarily equivalent to the result of having already tested 4, so it is sufficient to put the smaller of and into the witness set . As with heuristic splitting, this gives good complexity.
Writing the above process as pseudocode gives:
When implementing the algorithm, the bottleneck is computing . It is infeasible to iterate over all and then determine whether . Therefore, before running the algorithm, preprocess the reverse transition edges . Then, using these reverse transitions, iterating over yields the set . This guarantees that each transition is traversed only when belongs to some witness; and the witness-selection method described above guarantees that, in the sequence of witnesses containing actually used by the algorithm, , each preceding set is at least twice the size of the following one, so . In other words, each transition edge is traversed at most times, and the total number of transitions is , so the total complexity is .
The reference implementation is as follows:5
Reference implementation
// DFA minimization via Hopcroft's algorithm.
// Complexity: O(n * m * log(n)).
DFA DFA::hopcroft_minimize() const {
// Construct inverse transition maps:
// - pre[c] stores states sorted by the target of transition c.
// - pos[c][s] is the start index in pre[c] of transitions going to state s.
std::vector<std::vector<int>> pre(m), pos(m);
for (int c = 0; c < m; ++c) {
pre[c].assign(n, 0);
pos[c].assign(n + 1, 0);
// Counting sort.
for (int i = 0; i < n; ++i) ++pos[c][trans[c][i]];
for (int i = 0; i < n; ++i) pos[c][i + 1] += pos[c][i];
for (int i = 0; i < n; ++i) pre[c][--pos[c][trans[c][i]]] = i;
}
// Partition element structure:
// - os: starting index in the state list.
// - sz: number of states in this class.
// - cnt: temporary count of marked states during refinement.
struct EquivClasses {
int os, sz, cnt;
EquivClasses(int os, int sz, int cnt) : os(os), sz(sz), cnt(cnt) {}
};
// Partition and helper data structures.
std::vector<EquivClasses> ec; // Current list of equivalence classes.
std::vector<int> ids(n); // Permutation of states, grouped by ECs.
std::vector<int> par(n); // Maps state to its EC index.
std::vector<bool> tag(n); // Temporary marking for splitting.
std::queue<int> evidences; // Worklist of ECs to check.
// Initial partition by acceptance label.
std::iota(ids.begin(), ids.end(), 0);
std::sort(ids.begin(), ids.end(),
[&](int l, int r) { return acc[l] < acc[r]; });
for (int l = 0, r; l < n; l = r) {
for (r = l; r < n && acc[ids[r]] == acc[ids[l]]; ++r)
par[ids[r]] = ec.size();
if (l) evidences.push(ec.size()); // Add all but first class to worklist.
ec.emplace_back(l, r - l, 0);
}
// Refinement loop.
while (!evidences.empty()) {
int cr = evidences.front();
evidences.pop();
for (int c = 0; c < m; ++c) {
std::vector<int> todo;
for (int i = ec[cr].os; i < ec[cr].os + ec[cr].sz; ++i) {
for (int k = pos[c][ids[i]]; k < pos[c][ids[i] + 1]; ++k) {
int j = pre[c][k];
if (!tag[j]) {
if (!ec[par[j]].cnt) todo.push_back(par[j]);
++ec[par[j]].cnt;
tag[j] = true;
}
}
}
// Perform splits.
for (int i : todo) {
int ti = i;
if (ec[i].cnt != ec[i].sz) {
// Split into two: larger vs smaller segment.
bool majority_tagged = ec[i].cnt * 2 >= ec[i].sz;
int mid =
std::partition(ids.begin() + ec[i].os,
ids.begin() + ec[i].os + ec[i].sz,
[&](int x) { return tag[x] == majority_tagged; }) -
ids.begin() - ec[i].os;
// Assign new EC index to the smaller segment.
for (int j = ec[i].os + mid; j < ec[i].os + ec[i].sz; ++j)
par[ids[j]] = ec.size();
evidences.push(ec.size());
if (!majority_tagged) ti = ec.size();
ec.emplace_back(ec[i].os + mid, ec[i].sz - mid, 0);
ec[i].sz = mid;
}
// Clear temporary counters and tags.
ec[i].cnt = 0;
for (int j = ec[ti].os; j < ec[ti].os + ec[ti].sz; ++j)
tag[ids[j]] = false;
}
}
}
// Build minimized DFA.
DFA res(m, ec.size(), par[q0]);
for (const auto& e : ec) {
int i = ids[e.os]; // Representative state.
res.acc[par[i]] = acc[i];
for (int c = 0; c < m; ++c) res.trans[c][par[i]] = par[trans[c][i]];
}
return res;
}
This reference implementation allows automaton states to carry labels with arbitrary integer values, rather than merely the binary labels “accept” and “reject”. As the reference implementation shows, the only differences from the basic Hopcroft algorithm are the construction of the initial partition and the evidence set. This extended automaton is also called a Moore machine. One application of it appears in the second example in this section.
Examples
This section introduces the practical application of DFA minimization techniques through two examples.
Example
Given a length- string , with initial variable , traverse each position in order and perform the following operations:
- If , set ;
- If , set ;
- If , choose either or , corresponding to one of the two operations above.
If finally , the operation sequence is called good.
For each , find the number of complete “good” sequences under the condition that is forced. In particular, when , the answer is .
. Output the result modulo .
Solution
Consider the naïve DP. Let denote the number of ways to start from , perform the operations over , and currently have value . Let denote the number of ways to start from , perform the operations over , and end with . The answer when is . The complexity is .
We can directly construct a DFA from the transitions of , run DFA minimization, and then perform DP.
Reference code
#include <iostream>
#include <string>
#include <vector>
int main() {
constexpr int M = 998244353;
// Initialize the DFA.
const auto lowbit = [](int x) { return x & -x; };
int n, k, r;
std::cin >> n >> k >> r;
DFA raw_dfa(2, 1 << k);
for (int x = 0; x < (1 << k); ++x) {
raw_dfa.trans[0][x] = x - lowbit(x);
raw_dfa.trans[1][x] = x + lowbit((1 << k) - 1 - x);
if (x <= r) raw_dfa.acc[x] = 1;
}
// DFA minimization.
auto dfa = raw_dfa.hopcroft_minimize();
// DP.
std::string s;
std::cin >> s;
for (int i = 0; i < n; ++i) s[i] -= '0';
std::vector<std::vector<int>> f(n + 1, std::vector<int>(dfa.n, 0));
f[0][dfa.q0] = 1;
for (int i = 0; i < n; ++i) {
for (int c = 0; c < 2; ++c) {
if (s[i] != 1 - c) {
for (int j = 0; j < dfa.n; ++j) {
(f[i + 1][dfa.trans[c][j]] += f[i][j]) %= M;
}
}
}
}
std::vector<std::vector<int>> g(n + 1, std::vector<int>(dfa.n, 0));
for (int j = 0; j < dfa.n; ++j) {
if (dfa.acc[j]) g[n][j] = 1;
}
for (int i = n - 1; i >= 0; --i) {
for (int c = 0; c < 2; ++c) {
if (s[i] != 1 - c) {
for (int j = 0; j < dfa.n; ++j) {
(g[i][j] += g[i + 1][dfa.trans[c][j]]) %= M;
}
}
}
}
for (int i = 0; i < n; ++i) {
long long res = 0;
if (s[i] != 1) {
for (int j = 0; j < dfa.n; ++j) {
(res += (long long)f[i][j] * g[i + 1][dfa.trans[0][j]]) %= M;
}
}
std::cout << res << '\n';
}
return 0;
}
Minimal Subset Difference
Define as the minimum possible absolute value of the result obtained by inserting plus or minus signs between all the digits of the decimal number .
There are queries. Each query gives ; find the number of values satisfying and , with the values denoted by .
, , .
Solution
First, here is a greedy method for computing . Consider a number from its most significant digit to its least significant digit; initially, let the sum of the constructed number be . At each digit, if the number currently formed is negative, add the current digit; if it is positive, subtract the current digit. With this procedure, the absolute value of the greedily computed is always at most . Therefore, the absolute value of the true is also at most .
Let us think further about the largest value that can be formed during the process of constructing the final answer. Since the answer is certainly at most , there are only digits, and at most can be added or subtracted at each step, any value formed during the process must be at most ; otherwise it could not be reduced back down at the end. In fact, this upper bound can be lowered further6.
Consider naïve DP over DP. First, consider how the inner DP determines the answer for a number: define to indicate, using only the first digits of the number, whether can be formed. According to the preceding discussion, we only need to retain values of at most . If the current digit is assigned , the transitions are:
The outer DP is a digit DP. Take differences of the queries. Let the state be ; its subscripts respectively indicate that the first positions have been considered, whether an upper-bound constraint is active, and that the current automaton state is , and so on.
Unlike an ordinary DFA, we need to record the corresponding answer for every automaton state. A brute-force search shows that the inner DP has only states. We can then directly run DFA minimization, reducing the number of states to .
We now precompute all digit-DP answers with ; for multiple test cases, we only need to handle the case, so the answers can be obtained quickly.
The time complexity is (, ).
Reference code
#include <bitset>
#include <cmath>
#include <iostream>
#include <unordered_map>
#include <vector>
constexpr int B = 10, L = 91;
using state = std::bitset<L>;
DFA raw_dfa(B), dfa(B);
std::unordered_map<state, int> ids;
// Construct a DFA by DFS.
int dfs(const state& cr) {
if (ids.count(cr)) return ids[cr];
int id = ids[cr] = raw_dfa.n++;
// Check if accepted.
for (int i = 0; i < B; ++i) {
if (cr[i]) {
raw_dfa.acc.push_back(i);
break;
}
}
// Construct transitions recursively.
for (int c = 0; c < B; ++c) {
raw_dfa.trans[c].push_back(0);
}
for (int c = 0; c < B; ++c) {
state nt;
for (int i = 0; i < L; ++i) {
if (cr[i]) {
if (i + c < L) nt[i + c] = true;
nt[std::abs(i - c)] = true;
}
}
raw_dfa.trans[c][id] = dfs(nt);
}
return id;
};
// DP.
std::vector<long long> memo;
std::vector<int> nums;
long long sol(int x, int len, bool lim, int k) {
if (!len) return dfa.acc[x] <= k;
auto key = (x * 20 + len) * B + k;
if (!lim && memo[key] != -1) return memo[key];
long long res = 0;
for (int c = 0; c <= (lim ? nums[len - 1] : B - 1); ++c)
res += sol(dfa.trans[c][x], len - 1, lim && c == nums[len - 1], k);
return lim ? res : memo[key] = res;
};
long long calc(long long n, int k) {
nums.clear();
for (; n; n /= B) nums.push_back(n % B);
return sol(dfa.q0, nums.size(), true, k);
};
int main() {
// Construct a DFA.
for (int c = 0; c < B; ++c) raw_dfa.trans[c].reserve(20000);
dfs(1);
// DFA minimization.
dfa = raw_dfa.hopcroft_minimize();
memo.assign(dfa.n * 20 * B, -1);
// Queries.
int t;
std::cin >> t;
for (; t; --t) {
long long l, r;
int k;
std::cin >> l >> r >> k;
std::cout << (calc(r, k) - calc(l - 1, k)) << '\n';
}
return 0;
}
Exercises
Common Applications of Automata
This section lists some common applications of automata in algorithmic competitions7.
Trie
Trie is the first automaton encountered by most OIers. It accepts exactly the elements of the specified set of strings. Its transition function is given by the edges of the Trie, and an accepting state is the state reached when each string is inserted into the Trie.
KMP Automaton
KMP algorithm can be viewed as an automaton. The KMP automaton based on the string accepts exactly the strings having as a suffix, and its accepting state is .
Transition function:
AC Automaton
AC automaton accepts exactly the strings that have some element of the specified set of strings as a suffix. In other words, Trie + KMP.
Suffix Automaton
Suffix automaton accepts exactly the suffixes of the specified string.
Generalized Suffix Automaton
Generalized suffix automaton accepts exactly the suffixes of some element in the specified set of strings. In other words, Trie + SAM.
The relationship between the generalized SAM and the SAM is the same as that between the AC automaton and the KMP automaton.
Palindromic Automaton
Palindromic automaton is somewhat special: it cannot be conveniently defined as an automaton.
If a definition is needed, it accepts exactly the center and right half of every palindromic substring of a string.
For an odd-length palindrome, “center and right part” has its literal meaning; for an even-length palindrome, it is defined as a special character followed by the right part. This definition may look strange, but it makes PAM a genuine automaton rather than merely two trees.
Subsequence Automaton
Subsequence automaton accepts exactly the subsequences of the specified string.
DP over DP
DP over DP is an application of automata. It can be viewed as a technique that first constructs an automaton through an inner DP and then uses DP on that automaton in the outer layer to implement counting and optimization tasks.
Suffix Links
Automata and matching are inseparably related, and a basic idea in matching is: “If this string does not work, try seeing whether its suffix does.” Therefore, many automata (KMP, AC automaton, SAM, PAM) have the concept of suffix links.
A state corresponds to several strings. Its suffix link points to the state corresponding to the longest common proper suffix of the strings represented by that state in the automaton. In general, suffix links form a tree, and suffix-link trees of different automata share some properties; these are worth noting while learning.
Further Reading
- Computational Complexity (1) Warming Up: The Automaton Model
- National Training Team 2021 paper by Xu Zhe'an: A Discussion of Finite State Automata and Their Applications
- Myhill–Nerode theorem - Wikipedia
- Knuutila, Timo. "Re-describing an algorithm by Hopcroft." Theoretical Computer Science 250, no. 1-2 (2001): 333-363.
- Hopcroft, John E., Rajeev Motwani, and Jeffrey D. Ullman. "Introduction to automata theory, languages, and computation." Acm Sigact News 32, no. 1 (2001): 60-65.
Footnotes
-
In this definition, we allow transitions between states through the empty character (), so more precisely this is a nondeterministic finite automaton with -transitions (NFA-). Some textbooks simply call it an NFA; for brevity, we use that convention here. In theory, NFA and NFA- are distinguished, but in practice they have the same computational power. ↩
-
See Section 3.2 of National Training Team 2021 paper by Xu Zhe'an: A Discussion of Finite State Automata and Their Applications. ↩
-
See the official editorial. ↩
-
Here, “equivalent to” means that although may not actually have been tested, testing on the current partition would not yield any improvement. Put simply, in the tree of evidence sets produced by splitting sets, one of its ancestors and all the side branches along the path have already been tested; therefore, by induction, it is equivalent to having tested it as well. ↩
-
One detail in the algorithm implementation is that, for an evidence set , the set may have already split into and after only part of the characters have been tested. Assume without loss of generality that . In the reference implementation, the smaller set is inserted at the end of the evidence queue, while the larger evidence set replaces set at its original position. When the algorithm continues, it in fact only uses evidence set to test the remaining characters. This is correct because, for characters already tested, at least the two sets and have been verified; for characters not yet tested, at least the two sets and have been verified. ↩
-
See Example 5.2 in National Training Team 2021 paper by Xu Zhe'an: A Discussion of Finite State Automata and Their Applications. ↩
-
This article requires its definition of an automaton to be complete: every state must have a transition on every character. Descriptions of these string-related automata usually omit the mismatch state; Trie and SAM are examples. To conform to the definition given here, a mismatch state must be added explicitly to the descriptions of these automata. ↩