高级四则运算器—结对项目总结(193 &105)

高级四则运算器—结对项目总结

 

为了将感想与项目经验体会分割一下,特在此新开一篇博文。

界面设计

啥都不说,先上图震慑一下...

上面的三个界面是我们本次结对项目的主界面,恩,我也觉得挺漂亮的!你问我界面设计花了多久?其实只有6个小时,然后6个小时中有2个小时都是为了一个bug,这个bug之后我们会提到,也是让我长了一回见识。

关于整个界面的美化

关于整个界面的美化,因为之前做Java的Swing开发,知道有这种控件的皮肤(Swing里是叫LAF=LookAndFeel),所以在一开始我就敲定了要在C#里也选择一款皮肤为我美化的想法。最后使用了这款开源的控件实现,感觉很漂亮,确实也很漂亮,效果非常之棒。下面给出链接,大家以后可以用到http://www.cskin.net/。这个控件的按钮做得非常晶莹剔透,而且它的窗体整个做过一定的美化,而且使用整个界面的美化也非常简单:

 public partial class MainForm : CCSkinMain

但实际上我在这个过程中遇到的问题并不是界面美化的问题,主要问题在于相对引用一个dll文件的问题。因为CCSKin.dll需要被项目引用,如果想在另一台电脑上也可以编译,就必须修改一些参数,在百般挣扎后我还是在stackoverflow上找到了解决的正解:修改.csproj文件,将原先的绝对引用改为相对引用即可

    <Reference Include="CSkin, Version=15.3.10.1, Culture=neutral, processorArchitecture=MSIL">
      <SpecificVersion>False</SpecificVersion>
      <HintPath>lib\CSkin.dll</HintPath>

现在我就相当于引用了.exe同目录下的lib文件夹下的CSkin.dll文件。

其实为了保险,也可以将引用的空间的复制本地选项选择为True,这样基本上不会出错。

关于各种控件的选取动机

我选择了:

  • MenuStrip作为菜单栏
  • TabControl作为切换页
  • OpenFileDialog作为浏览文件的弹出窗口
  • FolderBrowserDialog作为浏览文件夹的弹出窗口
  • Checkbox作为是否选中某选项
  • NumericUpDown作为诸如值域以及其他的限制
  • Button作为启动的按键和计算器的界面
  • TextBox作为一些输出参数的显示
  • ProgressBar作为等待提醒

下面我讲一下关于这些控件的妙用吧~

关于值域有效的限制

关于值域,由于其本身是有限制的——右边的必须比左边的大至少一个精度单位,而且其精度也是有考量的。所以说我最后采用了这样的做法:
左值域设定最小值为0,最大值为999;右值域最小值为1,最大值为1000。两个的精度都是1,所以只会是整数。
并且我还为左值域里加入了这一条语句:

 private void leftRange_ValueChanged(object sender, EventArgs e)
 {
            rightRange.Minimum = leftRange.Value + 1;
 }

当然,我们一样可以为右值域的NumericUpDown设置一样作用的函数。但是,我们能否两个一起设置呢?比如像这样:

     private void leftRange_ValueChanged(object sender, EventArgs e)
        {
            rightRange.Minimum = leftRange.Value + 1;
        }
        private void rightRange_ValueChanged(object sender, EventArgs e)
        {
            leftRange.Maximum = rightRange.Value - 1;
        }

我实践发现这样做是不行的,这样做就相当于两个循环影响,A影响B,B影响A。最后发现你初始触发改变的那个值根本没有改变,so sad。所以只能写一个触发改变阈值大小的语句即可。

关于导入答案和题目文件

关于导入答案和题目文件的问题,我使用了OpenFileDialog控件来实现,实现的大概功能就是当我们点击下导入答案文件的按钮时,会出现一个打开文件的Dialog,然后必须选中一个文件才能成功返回。而我在这过程中对文件的后缀进行了判断,如果是".txt"文件才可以成功导入,否则需要重新再导入一个文件。

     private void ExeButton_Click(object sender, EventArgs e)
        {
            try
            {
                string path = "";
                //实例化一个打开文件窗口
                OpenFileDialog Dialog = new OpenFileDialog();
                DialogResult result = Dialog.ShowDialog();
                //这里的意思就是说当结果打开了正确的文件时
          if ((result == DialogResult.OK) || (result == DialogResult.Yes))
                {
             //获取导入的文件名字(自动带绝对路径)
                    path = Dialog.FileName;
                    if (!path.EndsWith(".txt"))
                        throw new FileNotFoundException("文件后缀不正确,请重新打开!");
                    //在TextBox中显示文件名
                    ExeText.Text = path;
                }
            }
            catch (Exception e1)
            {
                ErrorForm error = new ErrorForm(e1.Message);
                error.ShowDialog();//show Dialog指定只能关闭本模块后才可以关闭其他
            }
        }

关于自定义生成路径

我在菜单栏中增加了一些自定义的功能,比如能够改变生成菜单栏的问题

这样是为了用户自定义路径的友好性:)。并且在导入后都有各自的Log记录输出框进行记录。

关于计算器的键盘映射

在计算器键盘映射这里,我就是在这里调bug调了2个小时之久,以至于后面差一点放弃键盘映射的功能。
因为是几天前刚接触C#界面开发,只知道C#界面与事件分离带来的清爽,但是却不知道事件和控件是如何绑定的,一直以为

private void ExeButton_Click(object sender, EventArgs e)

只要这样写就可以让界面记住,恩,只要有个叫ExeButton的玩意,它被单击时就自动调用这个函数。可是后来一想,这不对啊,那我随便写个啥Click那岂不是可能会乱套吗?比如我写两个,这下怎么识别?

private void ExeButton_Click1(object sender, EventArgs e)
private void ExeButton_Click2(object sender, EventArgs e)

我之前键盘映射也是这个问题,通过各种资料都告诉我要设置FormKeypreview属性,让它为True。然后写一个窗体的keyDown事件,就可以建立键盘与按钮之间的映射啦!然而还差一步,这一步就是将事件控件绑定起来。
我们可以看向这里

我们需要在闪电符号代表的事件里,为我们自己写的事件 和 想要绑定的控件对应在一起。

其实我们还可以自己修改.Designer.cs文件,在里面加一句

this.ExeButton.Click += new System.EventHandler(this.ExeButton_Click);

这样就相当于在代码里帮助事件绑定~

关于等候时间

为了有更加人性化的界面,我增加了等待进度条。又由于长条进度条太丑...所以我使用了圆形进度条,效果如下图所示:

这个一开始使用的时候遇到了问题,什么问题呢,就是常遇到的问题——单线程如果要在处理完才释放主线程资源的话,会造成运算期间界面的不响应。所以我使用了多线程。大致了解了下线程的产生与事件的委托,我就开始雄心勃勃地写了:

            Thread Genthread = new Thread(Generate);
            Genthread.Start();

其中Generate函数中已经封装好了产生算式的功能。于是我高兴地在Generate函数的最后加了一句

                GenProgressIndicator.Hide();
                ExeAnsTextBox.Text += "已经生成了" + factcount + "道题目与答案到指定的文件中."+Environment.NewLine;
                ExeAnsTextBox.Show();

就是说圆形滚动条隐藏起来,然后将生成了答案的实际数量打印到log日志里面去,然后把log日志框重新展示出来。
但是这时我却遭遇了一个蛋疼的异常:

“System.InvalidOperationException”类型的未经处理的异常在 System.Windows.Forms.dll 中发生 

其他信息: 线程间操作无效: 从不是创建控件“GenProgressIndicator”的线程访问它。

实际上C#为了保证线程操作的安全性,于是就控制了不能让其他线程修改非该线程创建的UI控件的状态。在搜索了许多答案都感觉异常复后,我发现其实只要加一句话就好使:

Control.CheckForIllegalCrossThreadCalls = false;

只要在窗体构造的函数的函数里写上这一句就够了...因为暂时我们不存在线程同步数据的问题。
当然有更好的解决方法,我也尝试了一下,感觉还不错,BackgroundWorker,这个类好像是专门为这种情况设计的一样:D。

算法设计

算法达到的效果

这一次,我重构了算法,是的,你没有看错——因为上一个算法没有扩展性。它没有办法适应新时代——自定义运算符个数的时代的到来,于是无情地被淘汰了。

于是乎,我加入了所有目前可行的优点来做一个算法的表达式,在上面投入了大量的时间来进行算法的优化与性能提升,随机化的提升等。最后做到的效果就如下,上次我的个人项目里范围为3时,只能生成不到1000个式子,现在随机生成可以生成10万数量级的式子数量。即使去掉负数的选项,也能生成3万左右数量的式子,所以数量的生成上是十分有保障的。无图无真相,上图:

需求分析—控制参数

算法的第一步是要有一个明确的参数控制列表,哪些参数会控制哪些函数要很明白

  • 是否含有负数——要求在生成数字、减法过程中控制

  • 是否含有乘除法——要求在生成操作符过程中控制

  • 是否含有分数——要求在生成数字、除法过程中控制

  • 是否含有括号——要求在生成表达式过程中控制

  • 归纳下来实际上要在生成随机数上需要有所控制的有:是否含有分数、是否含有负数

  • 要在生成随机操作符上需要有所控制的有:是否含有乘除法

  • 要在表达式生成上需要有所控制的有:是否含有分数、是否含有负数、是否含有括号

分析完这些之后,我就开始动手构建算法主体了,这次因为要对重复检测等进行优化,最终我选取了树的结构作为我的表达式的组成结构。

重复性—树的最小表示法

首先要说明的当然是这个很厉害的东西—树的最小表示法。在反复思考史神博客所说,结合网上的一些poj做题的算法解题过程——虽然它们对我最后也没有产生什么影响。但是我总算是理解了树的最小表示法的真正含义

最小表示实际上是一种自定义有序的一种表示方法,放在树里的话,实际上是对每个结点来说,都要对它下面的左右子树进行自定义的有序排序。然后由于自定义序是一定的序,所以只要自定义序是稳定的方法。那么放在一棵树上,不管左右子树如何扭,或者左子树的左右子树如何扭,它们最终都会被有序排序。

下面上我写的代码:

        //根据root递归生成最小表示法获得的字符串
        public string GenerateMinusExp(Node root)
        {
            //如果是叶结点的话,则直接返回该结点的值
            if (root.IsLeaf())
                return root.Value;
            else if (root.Value == "+" || root.Value == "×")
            {
                //对左子树进行递归,得到左子树的最小表示字符串
                string LeftMinus = GenerateMinusExp(root.Left);
                //对右子树进行递归,得到右子树的最小表示字符串
                string RightMinus = GenerateMinusExp(root.Right);
                //对左子树和右子树进行统一排序
                if (string.Compare(LeftMinus, RightMinus) <= 0)
                    return root.Value + LeftMinus + RightMinus;
                else
                    return root.Value + RightMinus + LeftMinus;
            }
            //否则就按照正常次序进行最小字符串表示
            else
                return root.Value + GenerateMinusExp(root.Left) + GenerateMinusExp(root.Right);
        }

有括号—二叉树中序遍历

有括号的情况实际上相对而言比较简单,树可以很好的递归生成(并且这样随机性很强)。我在实际中也是递归来生成树的。实际上递归的逻辑相对也很简单,按如下步骤则可得到一颗随机的树:

1.判断当前符号栈是否有符号,无符号说明必须有两个操作数为子结点。
2.取0到3之间(不包括3)的随机值
3.根据随机值判断是哪一种情况:

  • 0-左符号 右符号
  • 1-左数字 右符号
  • 2-左符号 右数字(为什么不是4种?因为第四种是被迫出现的,是因为符号栈里的符号都被用光了,而使得两个都是操作数)

4.如果满足某个域的值为操作符,那么就向这个方向递归,最终生成数式。

这样由于生成的是一颗树,我们再对树进行中序遍历,就可以得到这棵树对应的中缀表达式,也就得到一个有几率出现括号的表达式了。

无括号—中缀表达式转二叉树

由于加入了无括号选项,即我们允许用户不选择括号。所以在这个里面我的想法是反着的,是先随机生成中缀表达式,然后再由中缀表达式生成一颗二叉树。最后性能这样的做法效率也挺高的。

无括号除法整除——替换因子

整数型的无括号算式,老板突然要求整除,咋办?

我一开始想:重新生成一个呗。后来发现薪水少了一半—表达式的数量少了很多,生成表达式所用的时间也大幅增长。

后来我想了想,改进了一下算法,改进的步骤如下:
在生成的时候,每遇到除号,就去检测一下前面那个符号是不是除号(如果是第一个符号则不检测,用逻辑短路就短路过了),如果前面那个不是除号,那么就让除号后面这个式子的值变为除号前的数字的某个因子。

这个因子如何找呢,就在leftRangedivider+1之间随机取一个数,使用Fraction类中早已经写好的获取最大公约数的函数找出两个数的最大公约数即可。这样既不需要生成被除数的因子全集而浪费大量时间,也不会因为每次都使用同样的因子而减少多样性。这样能做的原因是利用了除法的左结合性和优先级,因为除非前面的符号为除号,否则当前算法最先计算的一定是divider/divisor的组合,所以这样可以很大程度上地减少一些除法不整除的情况。

当然我们说了凡事都有前提,如果出现了连除式怎么办呢?那么下来就要利用我们有括号的情况下整除的处理方法一起处理了。

有括号除法整除—裂解因子

有括号除法的整除,我是和我做个人项目时被自我否定的一个叫裂解的方法结合在一起的(确实脑洞够大的)。

裂解的思路就是使用一个操作数去裂成两个操作数和一个操作符,这样做的好处就是可以控制结果,通过结果来生成源数。但是这样做较为繁琐。

可能你应该就会想到我的算法了,实际上我是这样的做法。延续上面无括号的分格,如果有括号的情况下,发现某个子树的除法不能通过,因为不是个整数,这下该怎么办呢?

我会先按上面无括号的情况随机构造一个能够整除左子树的随机的操作数,然后根据原有右子树的操作符的个数,以该操作数为起点,裂解生成一颗与之前的右子树操作符个数相同的树。这样做虽然繁琐,但是相比重新 掷色子 更有优势的地方在于其优秀的修补能力——对,就是修补能力。修补得能够使得表达式从非法变成合法表达式,相比起完全重新生成,这样是令人非常愉悦的。就像是一双打了补丁的衣服,虽然打了补丁,但也是件可以保暖的衣服。

一个裂解的例子如下:
1. 随机数 2
2. 随机符号 +,随机数 9, 产生数 -7,于是有下面这种简单树形结构
    +
  9   -7
3. 随机符号 -,随机数 10,产生数 1,于是再扩展一支
      +
    -   -7
10  1

有括号减法不为负—翻转子树

在树的表达里,有括号的表达式要控制减法不为负数这简直太简单了,是吧?
在有括号的情况下,一颗树只需要翻转两颗子树即可达到结果为非负数的效果。代码示例如下

                 case "-":
                    Fraction LExp = AmendTreeAndCalculate(root.Left,leftRange);
                    Fraction RExp = AmendTreeAndCalculate(root.Right,leftRange);
                    //如果结果为负数、不允许出现负数且是有括号的式子
                    //不允许结果中出现负数的话,就把两颗子树翻转
                    if ((RExp > LExp) & !HasNegative & HasBrack)
                        Transfer(root);
                    return LExp - RExp;

无括号减法不为负—偷天换日

针对没有括号的情况,我想了很久,发现自己只有偷天换日这一条路可以走。简单来说,就是在无括号式子减法过程中发现了负数&要求不可以产生负数,则将-替换为+

。。。。。

我知道你看到一定会无语,但是这确实是事实,后来通过热力图发现这个替换的次数还是蛮多的...主要原因就是因为我们没办法通过交换子树的方法来造就减法,因为一旦交换,就可能产生带括号的式子了,而且交换后产生括号的概率还挺大的。

之后我优化了一点来避免这个问题:在生成不带括号的运算表达式时,如果遇到符号为-,就判断下一个符号如果是-+或者没有符号了,就将减数随机为一个letfRange,被减数之间的数,这样可以降低很多的减法被舍弃的情况。

溢出避免—check关键字

由于这次思考再三没有使用上次助教老师所说的double类型的分子与分母定义,因为在它们计算时,要想没有偏差地产生整数值,需要用它们的整数部分参与运算,但是没找到相关直接截取整数作为double类型参与运算的方法。后来又觉得使用Decimal类型太小题大做,于是使用了提供的check关键字检查了溢出。如果遇到溢出的话,就把生成的式子重新随机生成,毕竟若干个连乘的概率还是很低的。

long A;
long B;
check{
 long C = A + B;
}
//check的基本用法

界面与算法的对接

界面与计算模块的对接本来应该是很融洽的,但是由于个人比较糟糕的设计—在附加题做的时候也被鸣神吐槽了的—接口很不规范等问题,于是我跟钟焕讨论了一下,决定使用xml作为前后端的传参工具。

计算模块的生成

计算模块的生成还好,不算难,直接新建了项目,类库的问题就可以了。这过程中只是遇到了引用受保护的类的问题,所以最后只开放了一个public类作为结果而已。

xml的使用

xml的使用其实不难,如果看懂了一些示例的话。最终在十分钟入门教程后,我成功写出了xml文件的建立与写入。在xml文件里有一点比较坑的是,在更改xml属性后一定要注意

XmlDocument.save("文件路径");

否则xml文档是没有办法更改的。

以上就是我本次结对项目里获得的一些经验和总结之谈,希望你能有所收获。暂时这么多,后面想到什么还会补充。

 鸣谢

 

核桃:sighingnow

他们可能比我做的更好

fzyz999 & hoerwing

kanelim

kibbon

PocetPanacea

SyncShinee

希望你能在百忙之中也可以抽空看看他们的博客,一定会有所收获:)。

文件附送

文件已经重新生成中...请静候佳音...

 

posted @ 2015-10-06 17:05  SivilTaram  阅读(1074)  评论(10编辑  收藏  举报