ArticleDirectory
- 2.1 Basic Rules
- 2.2 induction rules
- 2.3 example of constructing NFA using regular expressions
Series navigation
- (1) Introduction to lexical analysis
- (2) Input buffering and code locating
- (3) Regular Expressions
- (4) construct NFA
- (5) DFA Conversion
- (6) construct a lexical analyzer
With the regular expression obtained in the previous section, it can be used to construct NFA. NFA can be easily converted from a regular expression, and helps to understand the pattern represented by a regular expression.
1. NFA Representation
Here, an NFA has at least two States: the first State and the last state, as shown in 1. The regular expression $ T $ corresponds to the NFA as N (t ), its initial status is $ h $, and its tail status is $ T $. Only the first and last states are shown in the figure. The transfer between other States and States is not shown, because the recursion described belowAlgorithmYou only need to know the beginning and end status of NFA, and other information does not need to be concerned.
Figure 1 NFA Representation
I use the following NFA class to represent an NFA, which only contains the first State, the last state, and a method for adding a new state.
Namespace cyjb. compiler. lexer {class NFA {// obtain or set the NFA first state. Nfastate headstate {Get; set;} // gets or sets the NFA tail state. Nfastate tailstate {Get; set;} // create a new state in the current NFA. Nfastate newstate (){}}}
In the NFA status, there are only three necessary attributes: Symbol index, state transfer, and State type. Only the symbolic index of the Acceptance status makes sense. It indicates the regular expression corresponding to the current acceptance status. For other statuses, it is set to-1.
Status transfer indicates how to move from the current status to the next status, although the NFA definition, each node may contain multiple $ \ Epsilon $ transfers and multiple character transfers (that is, the transfer marked with characters on the edge ). But here, there is at most one character transfer, which is determined by the features of the NFA constructor given later.
The status type is defined to support forward-looking symbols. It may be one of normal, trailinghead, and trailing enumerated values. This attribute will be described in detail in the forward-looking symbols section.
The nfastate class is defined as follows:
Namespace cyjb. compiler. lexer {class nfastate {// obtain the NFA that contains the current status. NFA; // obtain the index of the current status. Int index; // obtain or set the symbol index in the current state. Int symbolindex; // gets or sets the type of the current status. Nfastatetype statetype; // gets the list of character classes corresponding to the transfer of character classes. Iset <int> charclasstransition; // gets the target status of character class transfer. Nfastate charclasstarget; // gets the set of callback transfer. Ilist <nfastate> epsilontransitions; // Add a transfer to a specific State. Void add (nfastate state, char ch); // Add a transfer to a specific State. Void add (nfastate state, string charclass); // Add a ε transfer to a specific State. Void add (nfastate state );}}
The two additional attributes NFA and index I have defined in the nfastate class are simply for the convenience of using the state. $ \ Epsilon $ transfer is directly defined as a list, while character transfer is defined as two attributes: charclasstarget and charclasstransition. charclasstarget indicates the target status and charclasstransition indicates the character class, the character classes are described in detail below.
The nfastate class also defines three add methods, which are used to add single character transfer, character class transfer, and $ \ Epsilon $ transfer.
2. Construct NFA from a regular expression
The recursive algorithm used here isMcmaughton-Yamada-Thompson Algorithm(Or Thompson constructor), which is easier to understand than glushkov constructor.
2.1 Basic Rules
- For the regular expression $ \ Epsilon $, construct the NFA of 2 (.
- For a regular expression $ \ BF {A} $ that contains a single character $ A $, construct the NFA of 2 (B.
Figure 2 Basic Rules
The first basic rule above is not actually used here, because $ \ Epsilon $ is not defined in the definition of a regular expression. The second rule is used in the regular expression charclassexp class that represents the character class,CodeAs follows:
Void buildnfa (NFA) {NFA. headstate = NFA. newstate (); NFA. tailstate = NFA. newstate (); // Add a character class for transfer. NFA. headstate. Add (NFA. tailstate, charclass );}
2.2 induction rules
With the above two basic rules, the induction rules described below can be used to construct a more complex NFA.
Assume that the NFA values of the regular expressions $ S $ and $ T $ are $ n (s) $ and $ N (t) $ respectively.
1. for $ r = S | T $, construct the NFA of 3 and add a new first state $ h $ and new tail state $ T $, then, the first State from $ h $ to $ n (s) $ and $ N (t) $ each has a $ \ Epsilon $ transfer, each ending state from $ h $ to $ n (s) $ and $ N (t) $ has a $ \ Epsilon $ to a new ending state $ T $. Obviously, after $ h $, you can choose to match $ n (s) $ or $ N (t) $, and eventually reach $ T $.
Figure 3 induction rule alternationexp
Note that the statuses in $ n (s) $ and $ N (t) $ cannot affect each other or be transferred, otherwise, the recognition results may not be expected.
The code in the alternationexp class is as follows:
Void buildnfa (NFA) {nfastate head = NFA. newstate (); nfastate tail = NFA. newstate (); left. buildnfa (NFA); head. add (NFA. headstate); NFA. tailstate. add (tail); right. buildnfa (NFA); head. add (NFA. headstate); NFA. tailstate. add (tail); NFA. headstate = head; NFA. tailstate = tail ;}
2. for the NFA constructed for $ r = ST $, the first State of $ n (s) $ is used as the first state of $ n (r) $, $ N (t) the end state of $ is the end state of $ n (r) $, and is in the end state of $ n (s) $ and $ N (t) add a $ \ Epsilon $ transfer between the first State of $.
Figure 4 inductive rule concatenationexp
The code in the concatenationexp class is as follows:
Void buildnfa (NFA) {left. buildnfa (NFA); nfastate head = NFA. headstate; nfastate tail = NFA. tailstate; right. buildnfa (NFA); tail. add (NFA. headstate); NFA. headstate = head ;}
Literalexp can also be considered as a connection with multiple charclassexp, so this rule can be applied multiple times to construct the corresponding NFA.
3. for $ r = S * $, construct the NFA of 5, add a new first state $ h $ and a new tail state $ T $, and then add four items $ \ Epsilon $ for transfer. However, in the regular expression definition, $ R * $ is not explicitly defined. Therefore, the repeatexp rule is given below.
Figure 5 rule S *
4. for $ r = s \ {M, N \} $, construct the NFA of 6 and Add a new first state $ h $ and new last State $ T $, create $ N $ n (s) $ and connect it, starting from $ m-1 $ n (s) $, add a tail state to $ T $ \ Epsilon $ transfer (if $ m = 0 $, add the $ \ Epsilon $ transfer from $ h $ to $ T $ ). This ensures that at least $ M $ n (s) $ is passed, and at most $ N $ n (s) $ is passed.
Figure 6 inductive rule repeatexp
However, if $ n = \ infty $, You need to construct the NFA of 7. In this case, you only need to create $ M $ n (s) $ and at the last $ n (s) add a $ \ Epsilon $ transfer between the beginning and end states of $, which is similar to $ S * $, to achieve a matching with no upper limit. If $ m = 0 $ is added, it is the same as $ S * $.
Figure 7 inductive rule repeatexp $ n = \ infty $
Based on the above two rules, the repeatexp class constructor is obtained:
Void buildnfa (NFA) {nfastate head = NFA. newstate (); nfastate tail = NFA. newstate (); nfastate lasthead = head; // if there is no upper limit, special processing is required. Int times = maxtimes = int. maxvalue? Mintimes: maxtimes; If (Times = 0) {// It must be constructed at least once. Times = 1 ;}for (INT I = 0; I <times; I ++) {innerexp. buildnfa (NFA); lasthead. add (NFA. headstate); if (I> = mintimes) {// Add to the final tail state transfer. Lasthead. Add (tail) ;}lasthead = NFA. tailstate ;}// add transfer for the last node. Lasthead. Add (tail); // no upper limit. If (maxtimes = int. maxvalue) {// Add an infinite loop at the end. NFA. tailstate. Add (NFA. headstate);} NFA. headstate = head; NFA. tailstate = tail ;}
5. for the forward sign $ r = S/T $, the situation should be special. Here we only set $ n (s) $ and $ N (t) $ connect (same as Rule 2 ). Because if $ T $ matches the forward symbol, You need to trace back to find the end of $ S $ (this is the truly matched content ), therefore, you need to mark the tail state of $ n (s) $ as the trailinghead type, and mark the tail state of $ N (t) $ as the trailing type. The marked processing is described in the next section when it is converted to DFA.
2.3 example of constructing NFA using regular expressions
Here is an example to intuitively see how a regular expression (A | B) * Baa constructs a corresponding NFA. The following describes each step in detail.
Figure 8 Regular Expression (A | B) * Baa construction NFA example
The final NFA is shown in. A total of 14 States are required. In NFA, each part of the regular expression can be distinguished. The NFA constructed here is not the simplest, so it is different from the NFA in the previous section "C # lexical analyzer (3) Regular Expression. However, NFA is only necessary to construct a DFA, so it does not need to be simplified.
Iii. Division of character classes
Although NFA has been obtained, this NFA still has some details to handle. For example, for a regular expression [A-Z] Z, what kind of NFA should be constructed? As one transfer can only correspond to one character, one possible case is 9.
Figure 9 NFA constructed by [a-Z] Z
A total of 26 transitions are required between the first two States, and one is required between the last two states. What if the character range of the regular expression is wider, such as the Unicode range? Adding more than 60 thousand transfer entries is obviously unacceptable for both time and space. Therefore, we need to use character classes to reduce the number of required transfers.
A character class refers to the character equivalence class, which means that all characters corresponding to a character class have the same status transfer. In other words, for an automatic machine, there is no need to distinguish characters in a character class-because they always point to the same State.
Just like the regular expression [A-Z] Z above, there is no need to differentiate characters a-y because they always point to the same State. Character Z needs to be taken out as a character class separately, because the transfer between state 1 and 2 makes the character Z different from other characters. Therefore, we now get two character classes. The first character class corresponds to the character a-y, and the second character class corresponds to the character Z, as shown in the obtained NFA 10.
Figure 10 [A-Z] z nfa constructed using character classes
After the character class is used, the number of transfers required is reduced to three. Therefore, when dealing with a large alphabet, the character class is necessary, which can speed up processing, it can also reduce memory consumption.
The division of character classes is the process of dividing Unicode characters into different character classes. My current algorithm is an online algorithm, that is, when a new transfer is added, the current character class is checked to determine whether to divide the existing character classes, the corresponding character classes are also obtained. The character class uses an Iset <int>, because one transfer may correspond to multiple character classes.
Initially: Only one character class indicates the entire Unicode Range Input: Newly Added transfer $ T $ output: newly Added transfer character classes $ cc_t $ for each (each existing character class $ CC $) {$ cc_1 = \ left \ {c | C \ in T \ & C \ in CC \ right \} $ if ($ cc_1 = \ emptyset $) {continue ;} $ cc_2 = \ left \ {c | C \ in CC \ & C \ notin t \ right \} $ divide $ CC $ into $ cc_1 $ and $ cc_2 $ cc_t = cc_1 \ cup cc_t $ T = \ left \ {c | C \ in T \ & C \ notin CC \ right \} $ if ($ T = \ emptyset $) {break ;}}
Note that every time an existing character class $ CC $ is divided into two subcharacter classes $ cc_1 $ and $ cc_2 $, all character classes corresponding to the transfer that includes $ CC $ must be updated to $ cc_1 $ and $ cc_2 $ to include the newly added child character classes.
I have implemented this algorithm in the charclass class, which fully utilizes the high efficiency of charset class set operations.
Hashset <int> getcharclass (string charclass) {int CNT = charclasslist. count; hashset <int> result = new hashset <int> (); charset set = getcharclassset (charclass); If (set. count = 0) {// does not contain any character classes. Return result;} charset setclone = new charset (SET); For (INT I = 0; I <CNT & set. count> 0; I ++) {charset cc = charclasslist [I]; set. exceptwith (CC); If (set. count = setclone. count) {// The current character class does not overlap with set. Continue;} // obtain the overlapping part of the current character class and set. Setclone. Duplicate twith (SET); If (setclone. Count = cc. Count) {// It is completely included by the current character class and added directly. Result. Add (I);} else {// remove the split part from the current character class. Cc. exceptwith (setclone); // update the character class. Int newcc = charclasslist. count; result. add (newcc); charclasslist. add (setclone); // update the old character class ......} // re-copy the set. Setclone = new charset (SET);} return result ;}
4. Multiple regular expressions, delimiters, and context
Through the above algorithm, you can convert a single regular expression to the corresponding NFA. If there are multiple regular expressions, it is also very simple. As long as you add a new first node as in 11, transfer multiple records to the first state of each regular expression $ \ Epsilon $. The final NFA has a starting status and $ N $ accept status.
Figure 11 NFA of multiple Regular Expressions
For the end of a line, you can directly regard it as a pre-defined forward symbol. r \ $ can be regarded as R/\ n or R/\ r? \ N (this supports windows line breaks and Unix line breaks), which is actually the same.
For the first line qualifier, this regular expression is only matched when the first line is used. You can consider taking out such regular expressions separately-when matching starts from the beginning of the line, match with the regular expression defined at the beginning of the line. When matching starts from other positions, match with other regular expressions.
Of course, even if the match starts from the beginning of a row, the regular expression defined by the non-beginning of the row can also be matched. Therefore, all regular expressions are divided into two sets, one containing all regular expressions, it is used to match from the beginning of a row. The other contains only regular expressions that are not limited to the beginning of a row. It is used to match from other locations. Then, the corresponding NFA is constructed for the two sets respectively.
My lexical analyzer also supports context. You can specify one or more contexts for each regular expression. This regular expression takes effect only in the given context. The context mechanism can be used to control the matching of strings more precisely, and a more powerful lexical analyzer may be constructed. For example, escape characters in strings can be processed while matching strings.
The implementation of context is the same as the first qualifier of the above line. It is to divide the regular expressions corresponding to each context into a group and construct NFA respectively. If a regular expression belongs to multiple contexts, it is copied to multiple groups.
Assume that $ N $ context is defined, and the first line qualifier is added. In total, You need to divide the regular expression into $ 2n $ sets and construct NFA for each set. This will inevitably cause some memory waste, but the string matching speed will be very fast, and the memory waste can be reduced to a certain extent through compression. If you maintain specific information for each State to enable the upper and lower limits and the first limit for the row, although NFA becomes smaller, storing information for each State also consumes additional memory, there will also be many backtracing cases during matching (backtracing is a performance killer), and the effect may not be good.
Although you need to construct $ 2n $ NFA, you only need to construct an NFA with a starting state of $ 2n $. Each starting State corresponds to a context (not) A set of regular expressions defined at the beginning of a row. This is done to ensure that the $ 2n $ NFA character classes are the same, otherwise it will be very troublesome to process later.
Now, the NFA corresponding to the regular expression is constructed. In the next article, I will introduce how to convert NFA into equivalent DFA.
Relevant code can be found here, and some basic classes (such as input buffer) are here.