FSM有限状态机运用分析系列一 —— 文本处理二
- 用有穷状态机解一道面试题。
刚毕业的时候,我到一家外企面试,面试题里有这样一道题:
统计一篇英文文章里的单词个数。
有多种方法可以解这道题,这里我们选择用有穷状态机来解,做法如下:
先把这篇英文文章读入到一个缓冲区里,让一个指针从缓冲区的头部一直移到缓冲区的尾部,指针会处于两种状态:“单词内”或“单词外”,加上后面提到的初始状态和接受状态,就是有穷状态机的状态集。缓冲区中的字符集合就是有穷状态机的字母表。
如果当前状态为“单词内”,移到指针时,指针指向的字符是非单词字符(如标点和空格),那状态会从“单词内”转换到“单词外”。如果当前状态为“单词外”, 移到指针时,指针指向的字符是单词字符(如字母),那状态会从“单词外”转换到“单词内”。这些转换规则就是状态转换函数。
指针指向缓冲区的头部时是初始状态。
指针指向缓冲区的尾部时是接受状态。
每次当状态从“单词内”转换到“单词外”时,单词计数增加一。
这个有穷状态机的图形表示如下:
下面我们看看程序怎么写:
1 int count_word(const char* text) 2 { 3 /*定义各种状态,我们不关心接受状态,这里可以不用定义。*/ 4 enum _State 5 { 6 STAT_INIT, 7 STAT_IN_WORD, 8 STAT_OUT_WORD, 9 }state = STAT_INIT; 10 11 int count = 0; 12 const char* p = text; 13 14 /*在一个循环中,指针从缓冲区头移动缓冲区尾*/ 15 for(p = text; *p != '/0'; p++) 16 { 17 switch(state) 18 { 19 case STAT_INIT: 20 { 21 if(IS_WORD_CHAR(*p)) 22 { 23 /*指针指向单词字符,状态转换为单词内*/ 24 state = STAT_IN_WORD; 25 } 26 else 27 { 28 /*指针指向非单词字符,状态转换为单词外*/ 29 state = STAT_OUT_WORD; 30 } 31 break; 32 } 33 case STAT_IN_WORD: 34 { 35 if(!IS_WORD_CHAR(*p)) 36 { 37 /*指针指向非单词字符,状态转换为单词外,增加单词计数*/ 38 count++; 39 state = STAT_OUT_WORD; 40 } 41 break; 42 } 43 case STAT_OUT_WORD: 44 { 45 if(IS_WORD_CHAR(*p)) 46 { 47 /*指针指向单词字符,状态转换为单词内*/ 48 state = STAT_IN_WORD; 49 } 50 break; 51 } 52 default:break; 53 } 54 } 55 56 if(state == STAT_IN_WORD) 57 { 58 /*如果由单词内进入接受状态,增加单词计数*/ 59 count++; 60 } 61 62 return count; 63 }
用状态机来解这道题目,思路清晰,程序简单,不易出错。
这道题目只是为了展示一些奇技淫巧,还是有一些实际用处呢?回答这个问题之前,我们先对上面的程序做点扩展,不只是统计单词的个数,而且要分离出里面的每个单词。
1 int word_segmentation(const char* text, OnWordFunc on_word, void* ctx) 2 { 3 enum _State 4 { 5 STAT_INIT, 6 STAT_IN_WORD, 7 STAT_OUT_WORD, 8 }state = STAT_INIT; 9 10 int count = 0; 11 char* copy_text = strdup(text); 12 char* p = copy_text; 13 char* word = copy_text; 14 15 for(p = copy_text; *p != '/0'; p++) 16 { 17 switch(state) 18 { 19 case STAT_INIT: 20 { 21 if(IS_WORD_CHAR(*p)) 22 { 23 word = p; 24 state = STAT_IN_WORD; 25 } 26 break; 27 } 28 case STAT_IN_WORD: 29 { 30 if(!IS_WORD_CHAR(*p)) 31 { 32 count++; 33 *p = '/0'; 34 on_word(ctx, word); 35 state = STAT_OUT_WORD; 36 } 37 break; 38 } 39 case STAT_OUT_WORD: 40 { 41 if(IS_WORD_CHAR(*p)) 42 { 43 word = p; 44 state = STAT_IN_WORD; 45 } 46 break; 47 } 48 default:break; 49 } 50 } 51 52 if(state == STAT_IN_WORD) 53 { 54 count++; 55 on_word(ctx, word); 56 } 57 58 free(copy_text); 59 60 return count; 61 }
状态机不变,只是在状态转换时,做是事情不一样。这里从“单词内”转换到其它状态时,增加单词计数,并分离出当前的单词。至于拿分离出的单词来做什么,由传入的回调函数决定,比如可以用来统计每个单词出现的频率。
但如果讨论还是限于英文文章,这个程序的意义仍然不大,现在来做进一步扩展。我们考虑的文本不再是英文文章,而是一些文本数据,这些数据由一些分隔符分开,我们把数据称为token,现在我们要把这些token分离出来。
1 typedef void (*OnTokenFunc)(void* ctx, int index, const char* token); 2 3 #define IS_DELIM(c) (strchr(delims, c) != NULL) 4 int parse_token(const char* text, const char* delims, OnTokenFunc on_token, void* ctx) 5 { 6 enum _State 7 { 8 STAT_INIT, 9 STAT_IN, 10 STAT_OUT, 11 }state = STAT_INIT; 12 13 int count = 0; 14 char* copy_text = strdup(text); 15 char* p = copy_text; 16 char* token = copy_text; 17 18 for(p = copy_text; *p != '/0'; p++) 19 { 20 switch(state) 21 { 22 case STAT_INIT: 23 case STAT_OUT: 24 { 25 if(!IS_DELIM(*p)) 26 { 27 token = p; 28 state = STAT_IN; 29 } 30 break; 31 } 32 case STAT_IN: 33 { 34 if(IS_DELIM(*p)) 35 { 36 *p = '/0'; 37 on_token(ctx, count++, token); 38 state = STAT_OUT; 39 } 40 break; 41 } 42 default:break; 43 } 44 } 45 46 if(state == STAT_IN) 47 { 48 on_token(ctx, count++, token); 49 } 50 51 on_token(ctx, -1, NULL); 52 free(copy_text); 53 54 return count; 55 }
用分隔符分隔的文本数据有很多,如:
环境PATH,它由‘:’分开的多个路径组成。如:
/usr/lib/qt-3.3/bin:/usr/kerberos/bin:/backup/tools/jdk1.5.0_18/bin/:/usr/lib/ccache:/usr/local/bin:/bin:/usr/bin:/home/lixianjing/bin
文件名,它由‘/’分开的路径组成。如:
/usr/lib/qt-3.3/bin
URL中的参数,它‘&’分开的多个key/value对组成。
hl=zh-CN&q=limodev&btnG=Google+搜索&meta=&aq=f&oq=
所有这些数据都可以用上面的函数处理,所以这个小函数是颇具实用价值的。