解数独——命令行程序的实现


更新日志:
20/01/19 初版,单元测试、性能改进、流程图需改进
20/04/02 第二版,增加作业头等要求,尝试做单元测试——失败

这个作业属于哪个课程 班级的链接
这个作业要求在哪里 作业要求
这个作业的目标 初步接触软件工程的开发流程,学习和练习单元测试及性能测试
作业正文 如下
参考文献① LeetCode——解数独官方题解
参考文献② bilibili up主happygirlzt 回溯法系列三:解数独(Sudoku Solver)

Github项目地址

地址

PSP表格

PSP2.1 Personal Software Process Stages 预估耗时(min) 实际耗时(min)
Planning 计划 30 15
Estimate 估计这个任务需要多少时间 48*60 12*60
Development 开发 20*60 10*60
Analysis 需求分析 (包括学习新技术) 2*60 5*60
Design Spec 生成设计文档 20 15
Design Review 设计复审 10 10
Coding Standard 代码规范 (为目前的开发制定合适的规范) 30 60
Design 具体设计 2*60 60
Coding 具体编码 12*60 5*60
Code Review 代码复审 20 30
Test 测试(自我测试,修改代码,提交修改) 60 60
Reporting 报告 60 2*60
Test Repor 测试报告 30 30
Size Measurement 计算工作量 15 10
Postmortem & Process Improvement Plan 事后总结, 并提出过程改进计划 15 10
Sum 合计 5320 2340

思路描述

一开始拿到这道题,其实我是拒绝的,一直比较抗拒这种矩阵处理的题,看测试数据和题目描述就已经让我想放上几天〒▽〒。后来啊,DeadLine到了~~~
不管了,硬着头皮也要上!首先是仔细地读了一遍题,原来是需要实现一个能解数独的程序!之前的畏难心理已经消退了不少;静下心后打开搜索引擎,搜索数独相关的资料,了解到数独是需要我们去按规则填写数字:同一行同一列不能出现相同数字,非素数阶数还需要满足每个宫内不能有相同数字

第一种想法 —— 蛮力法

既然是定好范围的数字中选数填空,那肯定可以使用蛮力法:
生成所有可能用范围内数字填充格子为0的解,检查是否合法并保留解,但这样的话,每个格子都需要跑一遍,这将是一个指数级算法,不能采用。

第二种想法 —— DFS

使用搜索来确定每个格子可以填充的数字,嵌套递归能使代码量降低,但是自己只懂一点皮毛,目前花大量时间去练DFS有点来不及啦,遂作罢。

第三种想法 —— 回溯法

这个想法不是自己想到的,在阅读大量关于求解数独的 博客[1] 以及一个 题解视频[2] 后,明白了其中的奥秘,代码也不难,就决定是你了!

设计实现过程

本次程序设计采用定义与实现分离的写法,所有操作由stdafx.cpp完成,声明在stdafx.h中,参数接收由Sudoku.cpp完成,最终由编译器链接成为Sudoku.exe:

  • 类(1个):solveSudoku
  • 函数(4个):init、helper、isValid为私有函数,solve为公有函数

其中solve函数整合了输入输出操作、init初始化盘面函数以及helper解题函数,在helper中使用isValid函数进行数字合法与否的判断。流程图如下:

单元测试

初版原文:关于测试方面,目前只会通过运行整个程序并通过输入输出是否达到预期的这种方式来测试。《构建之法》第二章提到了单元测试对于程序的重要性,这也是我目前学习的目标,截止到现在还未去进行实际操作,只了解到VS可以新建一个单元测试项目,导入需要测试的函数,若完成了测试会显示绿勾(还是不太明白其中的原理,难道是想PTA那样对比输入输出是否满足预期?),之后补上。目前只会通过运行整个程序并通过输入输出是否达到预期的这种方式来测试,如下图所示,最后三张图片为异常:

前几天课上讲了一个三角形的单元测试例子,课后试下通过了——好像也不难嘛!于是想着去填坑,去尝试做单元测试,我的思路是这样的:

  1. 原项目的输入输出皆为文件,那么我需要将项目的实际输出文件转换为可以比较的内容。
  2. 转换好后与预期结果用Assert::areEqual()比较就好了。

理想很丰满,现实很骨感。在写的过程中,我尝试了以下方法:

  • 预期结果为文件,输出结果为文件
    1. 用输入流对其进行逐个字符的比较,每个字符都需要相同
    2. 将两个文件中的内容在测试中全部先转换为string,再比较string
  • 预期结果为string,输出结果也为string
    1. 预期结果:在测试中用const string定下来
    2. 输出结果:在类中提供一个方法,将输出文件转换为string
    3. 输出结果:在单元测试项目中写一个文件转string的方法,运行解数独程序后调用该方法
    4. 比较两个string

上述方法皆以失败告终——无论我如何去调用[3]solveSudoku类中的方法,返回的string皆为空,我佛了。(后来通过清空output输出文件里面的内容,我才发运行单元测试时,这个类根本就没有被运行!!!)

欲知后事如何===请看下次更新呜呜呜呜😭😭😭

三阶

四阶

五阶

六阶

七阶

八阶

九阶

异常

  • 无解情况

  • argv参数设置错误

    后来发现argv[0]是路径....学到了

性能分析

这次的性能分析用到了Visual Studio自带的分析工具,路径为:调试 >> 性能探测器
自动分析完后点击切换到函数的视图,点击关心的调用次数 / 占用时间比,将自动按大小排序。

先上图,这是整体概览:

------------------------------------------ 分割线 ------------------------------------------

在《构建之法》第二章中根据函数调用次数及所耗时间比,优化了一个对象调用其大小的for循环条件,优化了程序性能。通过这张图可以发现满足优化条件的函数分别为helper和isValid,虽然vector的[]运算符重载函数被调用了7万次,但对我而言是无法去做优化的,只能平静的接受了~
helper优化的思路是加上DFS并剪枝(目前只在说说阶段,还不知道怎么去实现),isValid优化的话——目前只想到将算术运算转为二进制位运算来提升速度。

关键代码及解析

要我选出这次程序设计的关键代码,我会选择helper函数:

/*
	@param board 盘面
	@param size  盘面大小
	该函数为数独的回溯函数,通过反复调用和回溯达到解决问题的目的。
*/
bool solveSudoku::helper(vector<vector<char>>& board, int size)
{
	for (int i = 0; i < size; i++)	// 盘面遍历
	{
		for (int j = 0; j < size; j++)
		{
			if (board[i][j] == '0')		// 填数条件
			{
				char max_num = size + '0';
				//	选数
				for (char num = '1'; num <= max_num; num++)	
				{
					// 合法性判断
					if (isValid(board, size, i, j, num))
					{
						board[i][j] = num;
						// 进入下一层
						if (helper(board, size))  return true;	// 不回溯
						board[i][j] = '0';	// 回溯后的还原
					}
				}
				return false;	// 回溯
			}
		}
	}

	return true;	// 遍历完成结束标志
}

首先我们从头到尾遍历盘面,如果当前位置是0,我们就进行填数操作,并判断合法性。紧接着若数字合法,我们就将数填入盘面中,并进入到下一层,这是回溯法的关键:如果下一层判断不合法,将回退到上一层并还原为0。在这一次次的推进与回溯中,程序将完成数独的填写操作。

心路历程与收获

  • 从畏难心理到坦然面对
  • 遇到问题应该静下心来多读几遍题面和分析,而不是直接放掉
  • 了解到《构建之法》中关于如何完整构建一个软件的思路和过程
  • 明白了单元测试的重要性,并将付诸实现
  • 了解到更加深层次的程序性能分析和优化,不能去盲目优化
  • 更清晰的了解到自己目前的水平,还需努力呀!

  1. 详见参考文献①。 ↩︎

  2. 详见参考文献②。 ↩︎

  3. 第一种调用:先 solveSudoku s; 然后 s.某函数;
    第二种调用:先 solveSudoku* s = new solveSudoku(); 然后 s->某函数; ↩︎


免责声明!

本站转载的文章为个人学习借鉴使用,本站对版权不负任何法律责任。如果侵犯了您的隐私权益,请联系本站邮箱yoyou2525@163.com删除。



 
粤ICP备18138465号  © 2018-2025 CODEPRJ.COM