项目规划与实际
本题的分析与解答基于以下个人项目需求:
个人项目链接
我的PSP2.1的表格如下,其中计算工作量这一步我不是很懂。我觉得是属于团队合作里的一项步骤,所以没有填写。
PSP2.1 | Personal Software Process Stages | Time |
---|---|---|
Planning | 计划 | |
Estimate | 估计这个任务需要多少时间 | 30 |
Development | 开发 | |
Analysis | 需求分析(包括学习新技术) | 10 |
Design Spec | 生成设计文档 | 10 |
Design Review | 设计复审 | 4 |
Coding Standard | 代码规范(为目前的开发制定合适的规范) | 1 |
Design | 具体设计 | 20 |
Coding | 具体编码 | 8 |
Code Review | 代码复审 | 5 |
Test | 测试(自我测试,修改代码,提交修改) | 5 |
Reporting | 报告 | |
Test Report | 测试报告 | 0.5 |
Size Measurement | 计算工作量(这是?) | ? |
Postmortem & Process Improvement Plan | 事后总结, 并提出过程改进计划 | 5 |
合计 | 68.5 |
从上面的计划与实现表格也能看出来,实际上我的开发时间是预估时间的两倍之久。并且投入在设计上的时间非常之长(需求分析+生成设计文档+设计复审+具体设计=44)。这样的时间投入主要是因为我在设计过程中遇到的两个很大的困惑:
1. 高效率地构造交换律下满足不重复的表达式 与 2. 能够承受足够大数据的测试 。
这两个问题困惑了我很久,我的大部分时间在解决这两项看起来不简单,实际做起来更难的问题:因为这两个问题实际上是互相制约的。
如果要能够承受足够大范围的数据测试,那么我们就必须了解一点:生成数量足够大带来的是重复率的急速上升。
假设我们是通过随机化的方法生成操作数和操作符的,那么一开始我们可能重复的概率是很小的。比如某个值域范围内我们全部排列的个数大约为10万个,只要求生成1000个,那么重复的概率平均下来要比1%低得多。这样按随机化处理的话,前后表达式重复的可能很低,最后也不会浪费太多的时间在随机的处理上。
但是,如果我们要求生成5万个表达式呢?那这时候我们前后“随机化”生成的表达式碰撞的概率会变得非常之高。那么我们可能会陷入一个困境,即生成前百分之80的表达式所用的时间可能没有生成后百分之20的表达式所用的时间多。
但是这也是没有办法的,高效率地构造满足交换律下不重复的表达式本身就要求随机化后重复的概率偏低。遍历查询是否重复是有必要的,所以瓶颈在于随机产生的时间以及遍历时是否能快速查询所采用的算法。
以下是我在项目过程中遇到的一些问题和一些自己取巧的方案。之前并不清楚(树的最小表示法)这种神奇的判断树同构的方法,也没有史神那么扎实的算法基础,所以给各位献丑了。
项目难点分析
我觉得从我的角度来说,这次项目有几个比较难以解决的问题:
- 如何构造表达式可以实现不重复?
- 如何能够支持尽可能大范围内的表达式构造?
- 如何实现分数与整数的混合运算?
- 如何能保证减法与除法结果的有效性?
- 如何能够计算中缀表达式的值?
按我的想法,第一个问题和第二个问题结合起来是最让人为难的。之前在罗老师的博客讨论区里跟罗老师确认了“结合律”构造出的同构不属于重复的范畴,心里稍稍放轻松了点。但是这个问题依旧不好解决。我先阐述一下其他问题的解决方案。
分数与整数的混合运算
我一开始曾经想,是重新定义输入,比如
public static operator +(int a,Fraction b)
还是增加一个ParseFrac
来将整数显式地变成分数?这个函数实现的功能应当是:可以将一个正整数转换为一个分母为1,分子为该正整数的分数。
我最后找到了一个更好的方法来解决这个问题,解决方案在于C#的自定义隐式转换!使用关键字 implicit 可以自定义隐性类型转换,我举一段代码作为例子。
public static implicit operator Fraction(int input) { //implicit means obscure //code to convert from int to Fraction Fraction output = new Fraction(input, 1); return output; }
这个自定义类型转换的使用方式也很好用,比如以下的代码
int a = 3; Fraction b = new Fraction(3,4); // b = 3/4 Fraction c = a + b; // Here ‘a‘ convert to Fraction class automaticlly
当然为了实现上面的运算,我们是需要将操作符重载的,这也是我学到的第二点知识。
public static Fraction operator +(Fraction lhs,Fraction rhs)...
上面是关于分数类的‘+‘加法操作符重载。重载之后我们就可以使用+
在两个Fraction
对象中间来直接计算其和式。
当然为了分数计算过程中的简洁性我所使用的过程中的运算涉及到的分数有了如下规定:
1. 不使用带余真分数作为计算单元,带余真分数只是在展示的时候会打印,其余时候都以假分数形式存在从而利于计算。
2. 不带余的真分数在打印时依旧是原样。
3. 整数统一定制为分母为1的假分数形式。
当然还需要有一个考虑,即分子分母有公约数时的约分问题,这个最简式在打印、计算以及最重要的相等比较中尤为重要。
但是问题这时候就又出现了,如果我们需要化简的话,我之前使用的方法就是从分子和分母中较小的数字开始向下遍历寻找两个数的最大公约数,然后上下同时除最大公约数从而化为最简式。但是这样会带来一个很大的问题在于,我的化简是在每次运算后都需要的,因为每种运算都可能使得分母和分子产生公约数。比如下面的例子
- 1/2 - 1/6 = 2/6
- 1/9 + 2/9 = 3/9
- 1/9 * 3/4 = 3/36
- 1/9 ÷ 1/3 = 3/9
但是有个问题会随着值域(r)的扩大而产生,由于分子的范围为(0,r^2),所以实际上我们在分数计算式中产生的最大的数字可能高达r^4,甚至可能为r^8!当值域为100时,我们的分子和分母最大可能为几千亿的数量级。
这样的话在化简时耗时是惊人的,尤其是分子分母互质时,每次运算后对程序的影响都非常大。
所以后来我改进了算法,将求最大公约数的算法改进为欧几里得算法,果然在值域较大的范围内优化后的代码效果大大提升。
但是我们同样有另一个问题存在,这个问题同样是十分令人害怕的。
我们刚才提到了值域的扩大对于最终结果的最大值的影响是十分之大的,其能高达r^8倍。比如下面这个例子,如果我们的值域是20:
( 16‘11/15 ÷ 17‘4/19 ) × ( 6‘1/2 ÷ 12‘1/15 )
这里还有一个坑的地方,在于值域的上限。上面提到分子中最高有可能到r^8的水准是有计算依据的。我们设想存在这样一个数字,其分子接近r^2,分母接近r,但是分子与分母互质。设这个数为x,那么
x * x * x * x 完全可以达到r^8的数量级。按这样计算,如果使用int
定义分子分母的类型,那么只要 r 达到 15 ,就可能出现超过 int 型范围的数字,最后导致结果为 负数。
为了解决这个问题,我们首先要将分子分母定义为long
类型,这样的话,按粗略计算来讲,我们可以知道这样可以最高定义到 2^63-1
开8次根号的值,即最高可以支持到200左右的值域,这个值域已经足够了。
但是这时候坑出现了,C#里的Random
函数不支持long
类型的生成,不过这个问题在StackOverflow上已经有了答案。
于是乎我采纳了这位小哥的意见,最终使用了一个long的Random
函数:
public static long LongRandom(long min, long max, Random rand) { byte[] buf = new byte[8]; rand.NextBytes(buf); long longRand = BitConverter.ToInt64(buf, 0); return (Math.Abs(longRand % (max - min)) + min); }
最终成功解决了这个问题。
保证减法与除法结果的有效性
根据题目需求,本题目里要求减法的结果不可以出现负数。之前我想了三种算法:
1. 随机生成两个数,如果他们俩相减结果小于0,则舍弃这个算式。(实际上第二个数比第一个数大的概率有百分之(1-1/n)/2 )
2. 随机生成一个数作为被减数,按这个数字为新的范围重新生成一个随机数作减数,但是这样产生随机数会浪费一定时间。
3. 如果a-b<0
,则交换a
和b
。这个算法只是交换了一下a
和b
的位置,所以非常简单便捷!
最终我采用了第三种算法来保证减法结果的有效性。
对于除法来说,如果随机到的除数为0的话,则将其加1,这样可以造出一个整数。
计算中缀表达式的值
在大二的数据结构课上学到了中缀表达式转后缀表达式的算法,并且进行了练习。所以在计算中缀表达式的算法中并没有太大的困难。
构造表达式的方法
我原先的想法也比较平凡朴素,随机化处理构造一个表达式。随机化处理的含义是随机生成操作数和操作符之后写进字符串里,然后随机加括号进字符串里,然后对字符串表达式进行运算,求值。
但是如果是随机加括号进字符串,则对于括号前后的匹配会比较麻烦。所以我就有了两种想法:
1、后缀表达式--->中缀表达式
2、中缀表达式--->后缀表达式
即先构造后缀表达式,计算值,然后转成中缀表达式显示呢?
还是直接构造中缀表达式,显示,再变成后缀表达式进行计算。
开始时考虑到括号匹配与合理(即括号存在都是必要)的问题,我原本准备选择随机化构造后缀表达式,但是随机化这样一套下来表达式合法的概率太小,同时在生成大量表达式的时候会有很严重的重复问题。同时在随机化处理方面由于生成两个随机数的时间间隔太小很容易造成随机数的重复问题。
所以我决定直接构造一个合法的中缀表达式。
那么生成中缀表达式该怎样生成又快又能满足一定量的需求呢?我在思考再三后想到了一种裂解的方法,裂解的方法步骤如下:
1. 生成操作数
2. 将操作数分裂成为两个操作数与运算操作符
3. 随机指定某个操作数进行不断分裂,直到达到预期的操作符的数量(数目也是随机生成的)。
实际上这样的算法本来是满足小学生的真实需求的,因为不仅可以生成真正随机的表达式,而且可以满足每个表达式的值都在小学生所认知的数域范围内。
但是问题又来了:
1、由操作数生成操作数和运算操作符,相当于从结果推出过程,那么我的第一个操作数要定在多少合适?如果只是在值域内,会不会很局限?如果不在值域范围内,会不会很难合法?
2、由操作数裂解生成操作数和运算操作符,不可避免地减少了运算的自由度问题。
但是这样效率还是很低,同时因为表达式值的限定减少了很多能够生成的式子。重复率变得很高,代价太大,同时生成合法式子(即所有数字都在预定范围内)的概率并不高。
所以这个算法夭折了。下面就介绍一下我现在在使用的一个算法,当然它也有很多缺陷:推广性不强(不能应用于更多的操作符式子)、不能获得极大部分的算术表达式。但是它有一个很大的优点:生成过程简单并且效率较高。
重复性的检测与避免
补充说明:在询问了罗老师之后,我获得了关于重复性的严格定义的说明本题中的重复性是基于交换律的较小差异而产生的,所以我们只需要考虑交换律产生的较小差异
关于重复性的检测,我之前的想法是:
每一个算式都能对应一个二叉树,我们只要在每次计算时将式子对应的二叉树来构造一个特殊的唯一编码,并将其所有父亲结点为+或者*的地方将其子树构造出其对称树,然后将其对称树的所有编码一起加入到Hash编码序列中以检测重复。
但是这样有一个比较大的弊端:每个二叉树各自Hash特征码计算中,在值域要求相当大时Hash码的计算量会变得很大,很拖累性能。所以最后这种想法被否定了。(但是后来我才发现,即使使用了ulong,也不过能将值域扩大到200左右而已,所以这种方案实际上是可行的。尤其在看到史神的博客中提到的树的最小表示法
后感觉这种方法的适用性更强。这里是我的设计上的失误,没有实践就否定了某种方案。)
于是我就开始思考,如何能把避免重复做成理想化的一件事,那就是怎样能在构造的时候就尽量避免重复。
那么我想:既然式子要求上限是三个操作符,那我们把三个操作符全部使用即可。
那么现在我的算法就是一种朴素的算法:
- 首先通过二元运算生成大量的单项表达式^单项表达式,四种运算的每种单项表达式的个数大约相同(我最后采用了1/20数量来生成单项表达式)。
生成二元式的逻辑表示如下:
1、+法生成——>在这个过程中,要把生成的二元式放入Add
Array中,并且对于每一个生成的式子,都要查询是否与前面的重复,比较时需要重载==运算符。
2、-法生成——>在这个过程中,要把生成的减法二元式放入Sub
Array中,并且为了保证减法的结果一定大于0,如果前面的数比后面的数小的话,则将两个数调换顺序。
3、*法生成——>在这个过程中,都要把生成的乘二元式放入Mult
Array中。
4、/法生成——>在这个过程中,都要把生成的除法二元式放入Div
Array中,并且为了保证除法的除数不为0,如果除数为0时将其变为1。
- 使用二元表达式子生成四元表达式。这时候比如这样:
(2+3)*(8*7),2+3,8*7是二元式的值。为了避免交换律意义下的重复出现,在生成四元表达式时我们遵循以下原则:
1、如果操作符为*或者+,则第二个二元式在二元式数组中的位置一定没有第一个二元式的序号小。
2、如果操作符为-并且结果为负数,则调换二元式的前后顺序。
3、如果操作符为/并且除数为0,则重新选取除数二元式。
实际上这种算法本来也可以增加更多的表达式的数量,比如使用二元式+操作数生成三元式,再用三元式加操作符生成四元式。但是由于我们的题目实际上对于生成的数量并没有极端大的要求,所以我就没有实现。因为通过数学上的计算发现,四元式的数量占了总表达式的数量约有(大部分情况)(r^2-1)/r^2。所以四元式的数量已经足够满足需要。