高級四則運算器—結對項目總結(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

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

文件附送

文件已經重新生成中...請靜候佳音...

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM