从马尔可夫链看程序设计的细节问题

开发 开发工具
这里我们将从一次《程序设计方法学》课程的一个小小的作业谈起,讲到程序设计的细节问题。

程序设计的细节问题,是我们编程者经常会疏忽的问题。本文将从一个小小的作业开始,并结合数据结构方面的知识,来进行讲解。

马尔可夫链(Markov Chain),这是我们《程序设计方法学》课程的一个小小的作业,这个作业,主要目的并不是实现算法,而是“如何”实现算法,以及从代码中看出每个人程序设计的“风格”。 因为即使是很少的代码也能暴露出一个编程者的功底和风格。

我觉得这是个很有意思的话题,所以也在这里把我的部分代码发出来,并加以说明以作抛砖引玉。

目标

先稍稍介绍下马尔可夫链,简单地说就是输入一篇文章(其实是单词序列),建立前缀表后缀表,然后根据前缀随机选择后缀,如此迭代,生成一篇“看起来像文章的随机文本”。当然这只是马尔可夫链的一个应用,不过也算挺典型的。我曾经在开发一些应用的时候用类似的程序来生成测试数据。

程序结构

根据我们老师的要求,程序从文件读入样本数据,从标准输出打印生成的文本,其他没有具体要求。 我选择C#来实现这一程序。

我的程序流程非常简单:

读取样本 -> 建立前缀、后缀表 -> 生成 -> 输出

数据结构

根据编程经验和《程序设计实践》,前缀表采用哈希表,后缀表采用链表。
后缀表非常简单,每一个后缀都有一个Next,也就是说后缀本身就是一个链表节点,也就不需要LinkedList来帮忙了。

  1. Suffix  
  2. class Suffix {  
  3.     public string Word { getset; }  
  4.     public Suffix Next { getset; }  

相比之下,前缀就稍微麻烦一点了。首先“前缀”是一个单词序列,存储上不管用数组还是.NET FCL中的各种集合都没问题,但是这二者都无法方便地哈希。所以我采取了一个投机取巧的方法,就是把前缀拼接成一个字符串,用string做哈希表的Key。
由于前缀涉及到一些具体操作,我把它单独提出来写成Prefix,而以整合了Prefix与Suffix的State来做迭代中的算子,所以我的前缀表就是Dictionary,这个前缀表的设计,显然有点寒碜,而后缀表就是由Suffix构成的链表。

Prefix

  1. class Prefix {  
  2.     #region Properties  
  3.  
  4.     ///   
  5.     /// 前缀数  
  6.     ///   
  7.     public static int PrefixCount = 2;  
  8.  
  9.     ///   
  10.     /// 前缀词  
  11.     ///   
  12.     public string[] PrefixWords { getset; }  
  13.  
  14.     #endregion  
  15.  
  16.     #region Public Methods  
  17.  
  18.     ///   
  19.     /// 构造一个空的Prefix  
  20.     ///   
  21.     ///   
  22.     public static Prefix CreateEmpty() {  
  23.         return new Prefix();  
  24.     }  
  25.  
  26.     ///   
  27.     /// 滚动一下  
  28.     ///   
  29.     public Prefix Roll(string suf) {  
  30.         if (PrefixCount == 1) {  
  31.             PrefixWords[0] = suf;  
  32.         } else {  
  33.             Array.Copy(PrefixWords, 1, PrefixWords, 0, PrefixCount - 1);  
  34.             PrefixWords[PrefixCount - 1] = suf;  
  35.         }  
  36.         return this;  
  37.     }  
  38.  
  39.     ///   
  40.     /// 克隆一个完全一样的Prefix对象  
  41.     ///   
  42.     public Prefix Clone() {  
  43.         return new Prefix(PrefixWords);  
  44.     }  
  45.  
  46.     #endregion  
  47.  
  48.     #region Constructors  
  49.  
  50.     private Prefix() {  
  51.         PrefixWords = new string[PrefixCount];  
  52.     }  
  53.  
  54.     ///   
  55.     /// 根据已知单词构造一个Prefix  
  56.     ///   
  57.     /// 前缀单词序列  
  58.     public Prefix(params string[] words) {  
  59.         if (words.Length != PrefixCount) {  
  60.             throw new ArgumentException("Prefix count error!""words");  
  61.         }  
  62.         PrefixWords = new string[PrefixCount];  
  63.         Array.Copy(words, PrefixWords, PrefixCount);  
  64.     }  
  65.  
  66.     #endregion  

重写GetHashCode()的代码我就省略了。

细节

不论是建立词缀表还是生成文本的过程中,只要选择了一个后缀,前缀就需要滚动一次,所以我这里做了一种“古怪”的设计。首先是在迭代过程中,Prefix 对象始终是同一个对象的引用,只是它内部维护的数组在滚动,这个应该很好理解。但是这样会出现一个问题,那就是State对Pref的引用会出现混乱。所以我只好给Pref设计了一个Clone方法,而事后回想,这是一个完全错误的设计,因为我可以用另外的方法来避免这种窘境(下文会说到)。
在生成中,涉及到一个怎么设计返回值的问题,关于这个问题我考虑了不少,也改了几次。
最直接的办法:直接向命令行输出,因为题目要求最终输出到命令行,所以这个方法的确是可行的,但是我考虑到这些代码的重用性,没采取这种方法。
厚道点的办法:不像命令行输出,而是传入一个TextWriter,虽然这个方法和上一种比,完全是换汤不换药,但是好歹也是考虑的多了一点点。

上面两种方法都有一个问题,就是我们在设计函数的时候,给一个函数多大的权限呢?我们常认为:要么就输入输出都自己处理,要么就都不处理,把输入输出交给别的函数专职处理。上面两种方法无疑违背了这一个规律,因为TextGenerator类的构造函数参数是IEnumerable,也就是说,输入是不由TextGenerator处理的,而这里却又自作主张地处理了输出,显然让人晕乎乎。

最简单的办法:直接返回一个生成的字符串,我想这会是大多数人的方案。但是也有明显的缺陷:对于英文,很自然地我们会在每个单词后面加上一个空格,但是如果处理的是中文呢?加上个空格显然很郁闷。也就是说这样设计就完全没有给用户(函数的调用者,下同)选择格式的机会。难免有自作聪明之嫌。虽然我***的程序中保留了这段代码,但也是觉得聊胜于无了。
较自由的办法:调用的时候,传入一个Action,也就是一个委托,决定每一个被选中的单词做怎么操作,在这个例子中,也就是

采用委托的方式

  1. s => {  
  2.     Console.Write(s);  
  3.     Console.Write(" ");  

这样做看起来已经不错了,还挺现代的写法,不过我最终还是没有选择这样的方法,因为我采取了——

我最终的办法:返回IEnumerable,这里可以发挥C#强大的语言特性,使用yield来返回,这样用户可以直接

采用迭代器的方式

  1. foreach (string s in gen.Generate(maxWordCount)) {  
  2.     Console.Write(s);  
  3.     Console.Write(" ");  

这个也算一个迭代器模式的小小的运用吧。其实这个方法和传递委托的方法相差已经很小了,但是我个人喜欢后者。

遗憾

这就不得不说文中提到的那个我***的错误了。
首先就是我从数据的定义上就出现了问题,因为State里保留的Prefix引用根本没有发挥作用,而Suffix也只是一个头指针,也就是说与其如此复杂还弄出个Prefix.Clone(),还不如直接就把State的小命给革掉。Prefix直接就能映射到Suffix,也省的一个State在中间耽搁着。而Prefix采用了一种“猥琐”的哈希方式,也是有待改进。

小结

虽然是一个小程序(据说perl只用19行),基本算法也相当简单,但是从中暴露的程序设计的问题却不少,接口职责的设计毫无疑问是程序设计当中的重要部分,“高内聚低耦合”几个字天天挂在嘴皮边上,但真正干活的时候也不是那么容易实现的。身为程序员,难道不应该在这些方面多动动脑筋吗?

扩展

在这个程序中,我完全没有考虑API设计中的另一重要环节——异常。并不是疏忽,而是我从一开始就没有把这个列入考虑范围,所以这也是一个可扩展的地方。什么地方抛出异常,抛出什么异常,怎么接到异常,怎么处理,都值得设计。

原文标题:马尔可夫链——从一个编程作业中看看程序设计的一些细节问题

链接:JimLiu

【编辑推荐】

  1. C#语言读书心得备忘
  2. 详解C#制做Active控件的五个步骤
  3. 总结C#多线程的点点滴滴
  4. 学习C#多线程:lock的用法
  5. 各种C#数组的定义和初始化
责任编辑:彭凡 来源: 博客园
相关推荐

2022-11-21 17:44:03

机器学习文本生成器自然语言

2017-09-21 21:34:12

计算语言学隐马尔可夫模型机器学习

2022-04-11 09:30:00

自然语言HMM深度学习

2011-12-06 09:42:51

Java

2011-12-06 12:16:58

Java

2019-12-09 16:08:19

区块链分片分布式

2019-04-28 16:10:50

设计Redux前端

2010-12-15 10:03:17

twitter

2018-01-23 11:09:04

区块链技术重用

2013-12-12 16:30:20

Lua脚本语言

2019-12-19 09:26:34

区块链安全应用程序

2022-04-22 09:00:00

自然语言处理HMMCRF

2011-04-21 13:04:06

笔记本

2017-09-06 15:54:14

2009-12-04 10:53:06

VS WEB

2010-12-28 10:12:39

PHP

2021-03-10 08:20:54

设计模式OkHttp

2011-07-05 16:05:43

面向对象编程

2013-06-07 11:31:36

面向对象设计模式
点赞
收藏

51CTO技术栈公众号