軟工個人專案之生成和求解數獨
軟工個人專案之生成和求解數獨
在這次完成個人專案的過程中,我第一次嘗試了寫csdn部落格,用vs進行效能分析,在vs裡面寫單元測試,這次收穫了很多。雖然還有很多需要改進的地方,但我會做得越來越好的~
1、Github地址
首先給出我的github的地址:
https://github.com/hll455/Project-Sodoku
2、psp表格—估計花費時間
psp2.1 | Personal Software Process Stages | 預估耗時(分鐘) |
---|---|---|
Planning | 計劃 | 30 |
Estimate | 估計這個任務需要多少時間 | 20 |
Development | 開發 | 1440 |
Analysis | 需求分析(包括學習新技術) | 1000 |
Design Spec | 生成設計文件 | 40 |
Design Review | 設計複審(和同事稽核設計文件) | 60 |
Coding Standard | 程式碼規範(為目前的開發制定合適的規範) | 90 |
Design | 具體設計 | 1200 |
Coding | 具體程式碼 | 1200 |
Code Review | 程式碼複審 | 600 |
Test | 測試(自我測試、修改程式碼、修改提交) | 1200 |
Reproting | 報告 | 1200 |
3、解題思路
1)生成數獨
之前對數獨沒有太多瞭解,只知道數獨每一行每一列每一宮都需要滿足1-9不重複。我對生成數獨的第一理解,就是相當於對全是0的數獨的求解,由於第一行第一個數固定,所以第一行固定排列(一共有8!=40320種),之後開始遞迴搜尋遍歷,這樣生成到最後一個數字時則生成了數獨;生成一個數獨之後,可對整個數獨進行轉置40320✖2=80640,再對2、3行,4、5、6行,7、8、9行交換順序,這樣就可以滿足(80640✖2✖6✖6>1000000)。但具體實現時,發現這樣的話每生成一個數獨的遞迴求解時,會花費很多時間,在結果的效能評分上肯定不行,所以我就去網上找了一下數獨有沒有什麼簡單的規律,比如只需要確定一行,剩下的都可以直接寫出來,果然,有一種簡單數獨就是這樣的規律,即:只需要確定第一行,後面的8行都可以通過平移第一行來獲得
6 1 2 3 4 5 7 8 9
7 8 9 6 1 2 3 4 5
3 4 5 7 8 9 6 1 2
9 6 1 2 3 4 5 7 8
5 7 8 9 6 1 2 3 4
2 3 4 5 7 8 9 6 1
8 9 6 1 2 3 4 5 7
4 5 7 8 9 6 1 2 3
1 2 3 4 5 7 8 9 6
由上面的陣列可以看到,以第一行為基準,9行分別移動的位數為{0,3,6,1,4,7,2,5,8},根據此位移陣列,我們可以根據第一行唯一確定一個數獨。生成一個數獨後,剩餘的數獨與上面類似,都可以轉換成位移陣列的位置交換來體現:即3,6可以互換,8,2,5可以互換,7,1,4可以互換,這樣可以生成8!✖2✖6✖6=2903040>1000000,符合題目要求。
2)求解數獨
我看到求解數獨時,思路就是按照我們做數獨的思路,當所在位置的數字為0的時候,我們就找這一行、這一列、這一宮有沒有1-9中沒有出現的數字,有的話則繼續往後填寫,沒有的話則返回上一步,將上一步填寫的數字換成另外一個符合要求的數字,依次類推,直到數獨中的最後一個0被填充完成,則求解成功。這樣的話,只需要遞迴求解就好,不過如果每次對一個0的那一行那一宮那一列遍歷的話,時間複雜度會很高,所以我採取“以空間換時間”,對數獨進行預處理,直接將行列宮的數字出現與否用陣列表示出來,這樣,在每次判斷是否可以填入某數時,則不需要進行遍歷,只需要直接檢視該陣列的某一個元素的值是否為0。這裡我設定了一個三維陣列大小為visit[3][10][10]的陣列,利用每個元素的值來表示該宮/行/列中的某個數字是否出現過,0為未出現,1為出現。visit陣列第一維中0表示宮,1表示行,2表示列,第二維中表示第幾行/第幾列/第幾宮(範圍為0到9),第三維表示1-9數字。
4、設計實現過程
1)類與函式及函式間關係
其實最開始寫的程式碼為面向過程的c語言,後來因為單元測試需要類,所以我將面向過程直接改成了面向物件的c++。
只設置了一個類sodoku,將輸入的兩個引數作為屬性;
主要設定了三個函式,其中choosecors函式對solvesodoku和createsodoku函式進行呼叫。
-
choosecors函式——對輸入的引數進行處理,
-
createsodoku函式——生成數獨,
-
solvesodoku——求解數獨。
流程圖如下所示:
2)單元測試的設計
我設計了10個測試用例,其中5個檢查生成數獨時輸入引數的合法性,1個測試非-s和-c的輸入的處理,2個測試求解數獨的正確性和格式的正確性,2個檢查生成數獨時輸入引數的合法性。完成對所有路徑的測試,除了輸入時的引數個數問題不能在單元測試中體現。
十個測試用例分別為:其中choosecors函式的輸入引數分別為argv[1]和argv[2]
1、int ans = s1.choosecors("-c", "a");
2、int ans = s1.choosecors("-c", "1000001");
3、int ans = s1.choosecors("-c", "123");
4、int ans = s1.choosecors("-c", "-1");
5、int ans = s1.choosecors("-c", "");
6、int ans = s1.choosecors("-a", "123");
7、int ans = s1.choosecors("-s", "test1.txt");
8、int ans = s1.choosecors("-s", "123.t");
9、 s1.choosecors("-s", "test2.txt");
10、s1.choosecors("-s", "test1.txt");
-前八個分別用ans獲得返回值與期望值進行比較 ,後兩個用print陣列與設定的期望陣列進行比較
3)單元測試的例項截圖(其中三個)
由於篇幅有限,這裡只放三個,剩下的可以在程式碼庫中看到。
a、s1.choosecors("-s", “test1.txt”);
測試cpp中:
TEST_METHOD(TestMethod10)
{
// TODO: Your test code here
sudoku s1;
s1.choosecors("-s", "test1.txt");
char aa[300] = {
"6 1 2 3 4 5 7 9 8\n"
"3 4 5 9 7 8 6 1 2\n"
"9 7 8 6 1 2 3 4 5\n"
"1 8 3 4 2 9 5 7 6\n"
"4 5 9 7 8 6 1 2 3\n"
"7 2 6 1 5 3 4 8 9\n"
"2 3 4 5 9 7 8 6 1\n"
"5 9 7 8 6 1 2 3 4\n"
"8 6 1 2 3 4 9 5 7\n"
};
Assert::AreEqual(aa, print);//print為輸出至檔案的字串
}
其中test1.txt如下所示:
b、int ans = s1.choosecors("-c", “a”);
測試cpp中:
TEST_METHOD(TestMethod1)
{
// TODO: Your test code here
sudoku s1;
int ans = s1.choosecors("-c", "a");
Assert::AreEqual(1, ans);
}
原始碼中:
c、int ans = s1.choosecors("-a", “123”);
測試cpp中:
TEST_METHOD(TestMethod6)
{
// TODO: Your test code here
sudoku s1;
int ans = s1.choosecors("-a", "123");
Assert::AreEqual(3, ans);
}
原始碼中:
3)單元測試的結果
對於十個測試用例,實際值與預期值都相同,如圖所示:
5、關於改進
在完成基本功能過後,改進算是花了很多時間吧,從完成基本功能到一些小bug的修復,再到各種情況輸入的處理,最後到效能時間的優化。這裡主要說明效能的優化過程。
1)關於輸出至檔案
最開始實現生成和求解數獨的時候,沒有直接輸入到檔案,而是用printf列印到命令列裡。個數少的時候還好,但是個數多時發現佔的時間很多,如下圖所示:
後來輸入至檔案的時候,對於生成數獨,我採取了生成一個字元便fputc的辦法,而求解數獨時採用的是生成一個數組便puts,發現效果不盡人意。於是後來想到了一個辦法,那就是將所有的要輸入至檔案的字串全部存進一個字元數組裡(包括要求格式裡的空格和回車)。這裡我採用的是一個print陣列,用整型變數p表示指標,隨著p的移動來將字元插入print數組裡,最後一起fputs入檔案;同樣的求解數獨裡,我將save陣列一個接一個的拼接至print數組裡再一起fputs入陣列。這樣,時間有很大程度的縮減,但其實輸出依舊佔據了很大時間。
2)關於生成數獨裡的next_permutation
這是一個生成全排列函式,是在我對數獨考慮全排列變換時發現的一個函式,據說它的效率很高,可以生成不重複的下一個全排列函式,具體的介紹在下一個程式碼部分。剛開始接觸到這個函式時便直接使用了,不管第2、3行的2個排列還是4、5、6,7、8、9行的分別6個全排列,我在createsodoku函式裡寫了4次next_permutation。後來,我在效能分析時發現,其實next_permutation花費的時間也很多,原因是這個函式呼叫了很多其他的函式,比如交換函式。在生成100000個數獨裡如果反反覆覆的呼叫它,花費時間在整個執行程式中會更加突出。如下圖所示:
之後我的解決方法就是爭取少用next_permutation。我將行交換的72種排列放入一個二維字元數組裡直接進行求解,另外將next_permutation放在外層,儘可能地少呼叫…(ps:這個方法太笨了,如果不是為了更快我是不會用的…)
3)展示效能分析圖
a、生成數獨
1000000個執行時間大概在2.6s左右,感覺還可以再優化的,因為周圍有同學只有1.5s左右。我嘗試將fputs改成ofstream裡的輸出至檔案,還嘗試了改變將整型陣列改成字元陣列,但速度並沒有有所提升,所以就放棄了,如果我還有時間提升效能的話,應該就要修改演算法了。
生成數獨的效能分析圖如下所示:由圖可見,佔用時間最多的還是fputs函式。
b、求解數獨
沒有過多優化來縮短時間,如果繼續優化的話,我應該考慮在預處理choosecors函式上進行更深度的優化,下面是它的效能分析圖
6、關鍵程式碼說明
1)choosecors函式:int sudoku::choosecors(char a[], char b[])
a、生成數獨分支
這裡利用atoi函式將字串轉換成數字,b為輸入的生成數獨的個數。這裡atoi也能保證輸入的合法性,如果b為字母,則atoi(b)=0,跳入return1的分支。
int n = atoi(b);
if (n<=1000000 && n>0)
createsodoku(n);
else {
printf("-c後面的引數必須是1到1000000的整數\n");
return 1;//方便單元測試
}
return 6;//方便單元測試
b、求解數獨分支
輸入的第二個引數作為fp2,進行讀入。由於fgets以回車視為結尾,所以每次獲取一行,num表示行數,當集滿9行時進行處理,首先進行預處理,預處理中設定visit函式,再進入solvesodoku進行遞迴求解,最後將每次求得的數獨帶空格和回車地拼接到輸出字串print。print負責將所有字串輸出至sudoku.txt檔案裡。
while (!feof(fp2)) {
fgets(temp, 22, fp2);
if (strcmp(temp, "\n") == 0)
continue;
strcat(save[num], temp);
num++;
if (num == 9) {
num = 0;
//save陣列已經裝下一個數獨,開始求解
memset(visit, 0, sizeof(visit));
/*初始化visit 行列宮都屬於[0,8]*/
//注意 每一行一個數字過後緊跟著空格 換算至沒有空格時候的visit陣列
findans = 0;
preprocess();//預處理函式
solvesodoku(0, 0);
//firstsodoku初始值為1
//是為了滿足輸出時最後一個數獨後沒有空行而引入的變數
if (firstsodoku == 0) {
char temm[] = "\n";
strcat(print, temm);
}//如果不是第一行 則在前面輸出空格
if (firstsodoku == 1) {
firstsodoku = 0;
}
for (int i = 0; i<9; i++)
strcat(print, save[i]);
memset(save, 0, sizeof(save));
memset(visit, 0, sizeof(visit));
}
}
c、預處理函式
visit函式在設計部分我有介紹,是利用每個元素的值來表示該宮/行/列中的某個數字是否出現過,進行預處理後,求解數獨時便不用每行每列每宮進行遍歷。save陣列為輸入的一個數獨,帶每行末尾的回車和每行內的空格,在進行預處理時,需要跳過空格,考慮save中實際數字和visit陣列的對應關係。
for (int i = 0; i < 9; i++)
for (int j = 0; j < 17; j++)
{
if (save[i][j] != '0'&& save[i][j] != ' ')
{
visit[0][i / 3 * 3 + j / 6][save[i][j] - '0'] = 1;//宮
visit[1][i][save[i][j] - '0'] = 1;//行
visit[2][j / 2][save[i][j] - '0'] = 1;//列
}
}
2)createsodoku函式:
這裡需要介紹的是next_permutation函式,這個函式是全排列函式,能夠保證不重複的得到全部的全排列,符合我們的要求。參考的網址為http://www.cplusplus.com/reference/algorithm/next_permutation/,這裡介紹了它的用法,符合我的設計需要,具體用法如下圖所示。這裡我利用next_permutation函式,進行第一行後面8個數,第2、3行,第4、5、6行,第7、8、9行的全排列,保證生成的數獨不重複而且符合要求。
下面是我的最終修改前的createsodoku函式:
void sudoku::createsodoku(int n)
{
FILE* create_outputfile;
create_outputfile = fopen("sudoku.txt", "w");
if (!create_outputfile)
{
printf("CANNOT open the sudoku.txt!\n");
exit(1);
}
int shift[9] = { 0,3,6,1,4,7,2,5,8 };
char num[10] = "612345789";
for(int i = 0; i < 2 && n; i++)
{ //第2、3行交換
if (i)
next_permutation(shift + 1, shift + 3);
for (int j = 0; j < 6 && n; j++)
{//第4、5、6行交換
if (j)
next_permutation(shift + 3, shift + 6);
for (int k = 0; k < 6 && n; k++)
{//第7、8、9行交換
if (k)
next_permutation(shift + 6, shift + 9);
for (int l = 0; l < 40320 && n; l++)
{//8個數字的全排列
if (l)
next_permutation(num + 1, num + 9);
//生成一個數獨
for (int m = 0; m < 9; m++)
{
for (int h = 0; h < 9; h++)
{
print[p++] = num[(h + shift[m]) % 9];
if (h != 8)
print[p++] = ' ';
}
print[p++] = '\n';
}
n--;
//保證除了最後一個數獨末尾只有一個回車,其餘數獨的末尾都有兩個回車
if (n!=0)
print[p++] = '\n';
sum++;
}
}
}
}
fputs(print, create_outputfile);
//注意一定要fclose
fclose(create_outputfile);
}
在效能分析時,由於發現next-permutation函式耗費時間過大,於是將部分的全排列函式手動排列放如字元數組裡直接進行處理…這樣確實快了將近1s。修改後的部分函式內容為:
//change二維字元陣列記錄了2、3行,4、5、6行,7、8、9行的全排列結果。
for (int j = 0; j < 40320 && n; j++) {
if (j)
next_permutation(num + 1, num + 9);
for (int i = 0; i < 72 && n; i++) {
//下面是生成一個數獨
for (int m = 0; m < 9; m++)
{
for (int h = 0; h < 9; h++)
{
print[p++] = num[(h + (change[i][m]-'0')) % 9];
if (h != 8)
print[p++] = ' ';
}
print[p++] = '\n';
}
n--;
if (n!=0)
print[p++] = '\n';
}
}
print[p] = '\0';
3)solvesodoku函式部分:void sudoku::solvesodoku(int i, int j)
這裡主要是一個遞迴,處理數獨中為0的地方。k從1到9向裡面填數,凡是符合0所在行所在列所在宮沒有出現這個數即可填入,再進入下一個遞迴。如果沒有找到符合要求的數而又沒有求解完成,則進入上一層遞迴重新填數,直到求解完成。findans用來判斷是否讀到最後一個字元。
if (save[i][j] == '0')
{//解
bool flag = 0;
for (int k = 1; k <= 9; k++)
{
if (visit[0][i / 3 * 3 + j / 6][k] == 0 && visit[1][i][k] == 0 && visit[2][j / 2][k] == 0)
{//找到符合要求的數字
save[i][j] = k+'0';
visit[0][i / 3 * 3 + j / 6][k] = 1;//宮
visit[1][i][k] = 1;//行
visit[2][j / 2][k] = 1;//列
flag = 1;
solvesodoku(i, j);
}
if (flag)
{
flag = 0;
if (findans)
return;
else
{
save[i][j] = '0';
visit[0][i / 3 * 3 + j / 6][k] = 0;//宮
visit[1][i][k] = 0;//行
visit[2][j / 2][k] = 0;//列
}
}
}
}
7、利用cppcheck進行程式碼質量分析
消除了所有警告:
8、psp表格—實際花費時間
psp2.1 | Personal Software Process Stages | 預估耗時(分鐘) | 實際耗時(分鐘) |
---|---|---|---|
Planning | 計劃 | 30 | 40 |
Estimate | 估計這個任務需要多少時間 | 20 | 10 |
Development | 開發 | 1440 | 1200 |
Analysis | 需求分析(包括學習新技術) | 1000 | 1000 |
Design Spec | 生成設計文件 | 40 | 60 |
Design Review | 設計複審(和同事稽核設計文件) | 60 | 40 |
Coding Standard | 程式碼規範(為目前的開發制定合適的規範) | 90 | 60 |
Design | 具體設計 | 1200 | 1440 |
Coding | 具體程式碼 | 1200 | 1500 |
Code Review | 程式碼複審 | 600 | 1200 |
Test | 測試(自我測試、修改程式碼、修改提交) | 1200 | 1500 |
Reproting | 報告 | 1200 | 1000 |