1. 程式人生 > >軟工個人專案之生成和求解數獨

軟工個人專案之生成和求解數獨

軟工個人專案之生成和求解數獨

在這次完成個人專案的過程中,我第一次嘗試了寫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]

1int ans = s1.choosecors("-c", "a");
2int ans = s1.choosecors("-c", "1000001");
3int ans = s1.choosecors("-c", "123");
4int ans = s1.choosecors("-c", "-1");
5int ans = s1.choosecors("-c", "");
6int ans = s1.choosecors("-a", "123");
7int ans = s1.choosecors("-s", "test1.txt");
8int 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如下所示:

這是上面程式碼中的輸入引數中的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耗費太多
之後我的解決方法就是爭取少用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