结对编程作业
Part One 任务明细
-
我的博客 brunuh
-
队友的博客 原来是ZFGG啊
-
Github仓库 PictureHuaRongDao
-
任务 负责人 项目整体框架 张峰 页面实现 张峰 接口 张峰 博客 张峰 AI算法 李兴瀚 原型设计 李兴瀚
Part Two 原型设计
-
提供此次结对作业的设计说明,要求文字准确、样式清晰、图文并茂
- 游戏规则:玩家通过WSAD控制空白图片进行上下左右移动,玩家需要将已经打乱顺序的小图拼接回去,使图片恢复原始状态
- 功能需求:
- 可玩性,满足正常的游戏需求
- 当玩家不知道下一步怎么走时,可借助AI代跑,指示玩家下一步该怎么走
- AI走法满足最优解
- 往次得分面板,显示玩家往次走过的步数
- 玩家可记录自己的信息 以输入名字的方式登录游戏
- 游戏规则提醒
- 主界面
- 游戏界面
点击打乱键,自动生成一组乱序的图片,玩家需要将这组乱序图片进行恢复。
- 往次得分界面
往次得分界面以名字得分以及对应游戏场次的二维表展示每位玩家的当前总得分情况
- 排行榜界面
按分数从高到低进行排名
- AI界面
在此界面玩家仍可正常进行游戏,当玩家需要AI提示时,点击AI演示,AI算法将代替玩家移动下一步
-
原型模型设计工具:Axure Rp
-
描述结对的过程,提供非摆拍的两人在讨论、细化和使用专用原型模型工具时的结对照片
-
遇到的困难及解决方法
- 困难描述
- 两个人之前都没做过原型设计,也不了解原型设计工具,甚至不知道原型是干啥的
- 解决尝试
- 哔哩哔哩大法好,遇事不决哔哩哔哩,就跟着哔哩哔哩开始学习Axure,了解实现原型的目的、方法,然后从项目需求入手,逐一击破。
- 是否解决
- 已解决!
- 有何收获
- 对原型的设计有了进一步的了解,同时也明晰了我们本次项目所要完成的内容、达到的目标,以及对各个部分的细化等。
- 困难描述
Part Three AI与原型设计实现
-
代码实现思路
整体思路
- 将获取到的img进行base64编码成图片,切割图片,对该图片进行比对,判断其所对应的字母图片
- 将九宫格图片转换成一组3×3的数字序列矩阵
- 对数字序列进行预处理,判断有无解,以及对无解情况进行处理
- 调用AI算法得到一组操作序列
- 通过接口提交操作序列
AI部分
- 本项目基于BFS算法
- 不同算法之间对比:广度搜索和深度搜索是按不同方法来进行搜索,如果找寻步数最少解,广度可以得到最优值,如果搜索时间,那么广度和深度要以问题情况进行选择。A*算法是一种预测算法,时间开销相对较低,但是不能保证解为最优值,时间开销方面相对于前两种较小,但也视问题规模和情况具体分析。
棋盘逻辑部分
- 主要讲一下移动算法:数字 0 所在位置为 (row, column)
- column≠3 那么按下左箭头之后,(row, column) 和 (row, column+1) 位置上的数组互换。
- column≠0 那么按下右箭头之后,(row, column) 和 (row, column-1) 位置上的数组互换。
- row≠3 那么按下上箭头之后,(row, column) 和 (row+1, column) 位置上的数组互换。
- row≠0 那么按下下箭头之后,(row, column) 和 (row-1, column) 位置上的数组互换。
页面交互
- 本项目为一个主页面,点击按钮可分别进入游戏界面、游戏规则界面、往次得分界面、排行榜界面
- 游戏界面:开始游戏之前玩家输入名字,即可开启游戏,游戏过程中玩家若遇到困难,可请求AI解题,AI解题功能可逐步提示玩家下一步应该怎么走,直至玩家得出结果。游戏结束后,程序将记录下玩家名字以及对应的步数,以便在后续往次得分面板以及排行榜面板展示
- 游戏规则界面:以文字形式介绍本游戏的规则,以及各种操作介绍。
- 往次得分界面:以"名字 步数"的形式呈现
- 排行榜界面:完成游戏步数越少的玩家排名越靠前
-
代码组织与内部实现设计(类图)
-
说明算法的关键与关键实现部分流程图
-
输入参数为图片处理后的数字序列,示例3,0,2,4,5,7,8,9,1 其中0为空白。
根据数字1-9的和为45,用45减去输入序列的和,可以得知0替换了编号为几的图片,示例为6
由编号几的图片被替换,可知最终答案的排列destLayout,示例为1,2,3,4,5,0,7,8,9。 -
定义字典g_dict_shifts,key为0,1,2,3,4,5,6,7,8 依次代表棋盘的9个位置,其中每个位置可以wasd移动到的位置是确定的,如0可以向右移动到1或者向下移动到3,棋盘中心4可以向四个方向移动到1,3,5,7。将每个位置经1步可以移动到的位置记为value。
-
swap_chr为交换位置函数,输入当前状态的数字序列,和要交换的2个位置,返回交换后的数字序列
-
g_dict_layouts是存储已经经过的状态的字典,key是经过的状态,每个状态的value是它上一步的状态(即key的上一步是value,value的下一步是key)
-
stack_layouts为待处理(进入循环)的状态队列(形式为list)
while len(stack_layouts) > 0: curLayout = stack_layouts.pop(0) # 出队列 if curLayout == destLayout: # 判断当前状态是为目 标状态 break index_slide = curLayout.index("0") # 寻找0 位 置。 lst_shifts = g_dict_shifts[index_slide] # 前可 进行交换的位置集合 for nShift in lst_shifts: newLayout = swap_chr(curLayout,nShift, index_slide) if g_dict_layouts.get(newLayout) ==None: # 判断交换后的状态是否已经查询过 g_dict_layouts[newLayout] = curLayout stack_layouts.append(newLayout) # 存入集合
-
从stack_layouts 取出首位元素,赋值给curLayout当前状态
判断curLayout是否为最终答案的状态,如果是立即跳出循环 -
寻找数字0的在curLayout中的检索,其范围为0-8。即查找出数字0位于棋盘的位置
由数字0的位置和字典g_dict_shifts可得到它移动一步能达到的位置,将这些位置存为lst_shifts -
对于lst_shifts中的每个元素(位置)
调用交换函数swap_chr,得到新状态newLayout
查询字典g_dict_layouts,判断newLayout是否为已经走过的状态。
如果没有走过,把newLayout作为key存入g_dict_layouts,它的value是curLayout(即上一步是curLayout)
并把newLayout存入待处理队列stack_layouts。 -
这个算法是一个广度搜索算法,其遍历的顺序是按层的,根据循环和队列的性质,遍历完移动1步的状态后,开始遍历移动2步的状态,然后遍历移动3步的状态.....直到找到最终状态destLayout。
lst_steps = [] lst_steps.append(curLayout) while g_dict_layouts[curLayout] != -1: # 存入路径 curLayout = g_dict_layouts[curLayout] lst_steps.append(curLayout) lst_steps.reverse()
-
当curLayout与destLayout一致时,循环会终止。这部分借鉴链表的思想,通过g_dict_shifts查询curLayout上一步,然后再查询上上步,直到查询到初始状态srcLayout,srcLayout的预设value为-1
每一次查询都记录在lst_steps中,最后将lst_steps翻转,得到正序的从srcLayout走到destLayout每一步的序列。motion = "" # 创建移动motion字符串,即wasd操作序列 for cc in range(len(lst_steps) - 1): index_a = lst_steps[cc].index("0") index_b = lst_steps[cc + 1].index("0") abs_ab = (index_a - index_b) # 比较空格0在当前状态和下一状态的index检索,得到移动方向wasd if abs_ab > 0: if abs_ab == 1: motion = motion + "a" else: motion = motion + "w" else: if abs_ab == -1: motion = motion + "d" else: motion = motion + "s" return motion
-
根据lst_steps序列,index检索当前步和下一步0的位置,再查询字典g_dict_shifts,可判断出移动方向,将其对应的wasd字母存入motion,最终可得到完整的wasd移动序列
-
-
贴出你认为重要的/有价值的代码片段,并解释
-
判断矩阵是否可解 isSolvable()
# 计算逆序数之和 def inverseNum(nums): count = 0 for i in range(len(nums)): if nums[i] != 0: for j in range(i): if nums[j] > nums[i]: count += 1 return count # 逆序数 # 根据逆序数之和判断所给八数码是否可解 def isSolvable(now, target): now = now[0] + now[1] + now[2] target = target[0] + target[1] + target[2] N1 = inverseNum(now) N2 = inverseNum(target) if N1 % 2 == N2 % 2: # 奇偶性相同则有解 return True else: return False
进行解题之前想必是需要先判断一下这道题目前是否有解,否则在无解情况下你怎么走都是徒劳的。
在九宫图中,0的移动可以为上下或者左右,无论是左右还是上下移动都不会对排列的奇偶性造成影响(至于为什么,此处不过多赘诉),也就是说,奇偶排列不可能通过这样的排列完成互转!因此判断八数码问题的可解性,可以通过判断其逆序数的奇偶来确定。
八数码问题的有解无解的结论:当前序列与目标序列逆序的奇偶性相同情况有解,否则无解。
-
完成检验
def checkResult(self): for row in range(3): for column in range(3): if row == self.zero_row and column== self.zero_column: pass # 值是否对应 elif self.blocks[row][column] !=row * 3 + column + 1: return False return True
检验当前矩阵是否为目标矩阵,即检验游戏是否完成
-
将一组图片集合转换为数字序列的集合
def getlist(): path_one = '' listnow = [] for i in range(1, 10): filename = './' + "subject" + str(i) + ". jpg" if not compareImages(filename, 'white.jpg'): if not compareImages(filename, 'black. jpg') : path_one = filename break # 找到一张既不是全黑也不是全白的小图进行比对 for groupNumber in range(36): # 遍历对比 path = '../切割图片/' + str(groupNumber) + '_' for j in range(1, 10): path_two = path + str(j) + '.jpg' if compareImages(path_one, path_two): return groupNumber # groupNumber即该组图片所属组号 for i in range(1, 10): pathOne = './' + "subject" + str(i) + ".jpg" if compareImages(pathOne, 'white.jpg'): listnow.append(0) else: for j in range(1, 10): pathTwo = '../切割图片/' + str(groupNumber) + '_' + str(j) + ".jpg" if compareImages(pathOne, pathTwo): listnow.append(j) # 当前得到的乱序图片的序列 return listnow
将图片序列转换为一组数字的序列对于解出本题的操作序列十分关键,后续的操作均是对转换后的数字序列进行操作,而不是对图片直接操作。
-
-
性能分析与改进
- 一开始跑代码的时间太久了
- 算法经过两番改进后 计算时间大幅缩短
-
描述你改进的思路
- 提前打表,并在调用计算函数solve前,将这部分可以预知的数据加载到内存中,以字典的形式存储
dq7 = "q123456089.npz" destQ7 = np.load(dq7) destk7 = destQ7['k'] destv7 = destQ7['v'] destQdict7 = dict(zip(destk7, destv7))
r4="n4.npz" rm4 = np.load(r4) rk4 = rm4['k'] rv4 = rm4['v'] rma4 = dict(zip(rk4 , rv4))
- 修改主函数solve的布局,细化分支和条件判断,对有限种数的解题流程中的每个流程,不需要的操作舍弃,在哪一种解题流程中,需要的再去操作调用。这样尽管在形式上使得一些代码在更多的地方会有重复,但使得运行的细节更加可控。
_dispatcher = {} def _copy_list(_l): ret = _l.copy() for idx, item in enumerate(ret): cp = _dispatcher.get(type(item)) if cp is not None: ret[idx] = cp(item) return ret _dispatcher[list] = _copy_list
- 弃用python库copy中的deepcopy函数,因为它的效率实在太差。
使用新的自定义函数_copy_list来深拷贝列表list,其效率约为deepcopy的4倍 - 对改变可解性的change函数进行修改,原先每个状态list调用一次change会返回31或32种状态,数据量大幅增加,拖慢计算时间
经过观察与思考,发现最优解所对应的change操作,有相同的规律,即必然使至少1个数字换到它原来的位置,基于这样的操作才会产生最优解。由此将函数修改为change2,每个状态list调用返回的数量小于9个。
其效果也是可观的,原来4秒才算完的题,改动change后1秒就算完了def change2(al): cl = [] zeroin = al.index(0) z1 = zerofor - 1 for i in range(9): if al[i] == dlist[i]: continue ti=al.index(dlist[i]) if i==zeroin or ti==zeroin: if i%2!=ti%2: continue else: t1 = al[i] t2 = al[ti] al[i] = t2 al[ti] = t1 cl.append(_copy_list(al)) al[i] = t1 al[ti] = t2 else: t1 = al[i] t2 = al[ti] al[i] = t2 al[ti] = t1 cl.append(_copy_list(al)) al[i] = t1 al[ti] = t2 return cl
- solve算法解读
- 首先通过逆序数判断初始状态有解还是无解,无解进入nosolve,有解进入yessolve
- 根据哪个数字被0替代,将destQdict和drm指向对应的已经提前加载的字典
- rangelist为在强制交换step前所能达到的所有状态
- 如果swap两个位置一样,则不发生交换,原先无解,交换后依然无解
- 如果swap[a,b],a,b同为奇数或者同为偶数,那么交换后必将改变可解性,无解会变成有解
- 第三种情况是swap[a,b],a,b一奇数一偶数。这种情况下,如果交换的数字有0,则可解性不变,无0,则可解性改变。
- 对于交换后有解的情况,只需要getv查找相应状态在字典中的value,value最大为31,最小为0,0即是答案的状态
- 最终wasd序列由ansleft与ansright拼接而成,其中ansleft为初始到强制交换前的移动,ansright为交换后到答案状态的移动。
- yessovle与nosolve大同小异,原理是一样的,就不过多叙述了,详细讲解的话篇幅会很长。
- 提前打表,并在调用计算函数solve前,将这部分可以预知的数据加载到内存中,以字典的形式存储
-
展示性能分析图和程序中消耗最大的函数
- 性能改进前
- 性能改进之后
- 性能改进前
-
展示出项目部分单元测试代码,并说明测试的函数,构造测试数据的思路
-
利用博客中给的接口获取数据测试算法,以下只展示部分代码。
def getSubject(): url = "http://47.102.118.1:8089/api/problem?stuid=031802437" headers = { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/79.0.3941. 4 Safari/537.36' } r = requests.get(url, headers=headers) r.raise_for_status() r.encoding = r.apparent_encoding text = json.loads(r.text) # 获取到的数据 img_base64 = text["img"] step = text["step"] swap = text["swap"] uuid = text["uuid"] img = base64.b64decode(img_base64) # base64 编码后的题目存在本地 with open("photo.jpg", "wb") as fp: fp.write(img) img = cv2.imread("photo.jpg", cv2.IMREAD_GRAYSCALE) # 切分题目图片 for row in range(3): for colum in range(3): sub = img[row * 300:(row + 1) * 300, colum * 300: (colum + 1) * 300] cv2.imwrite("subject" + str(row * 3 + colum + 1) + ". jpg", sub) zuhao, alist, disnumber = getlist() return step, swap, uuid, zuhao, alist, disnumber step, swap, uuid, groupNumber, listProblem, disNumber = getSubject() assignment(step, swap, uuid, groupNumber, listProblem, disNumber) app = QApplication(sys.argv) ex = pictureHuaRongDao() sys.exit(app.exec_())
-
-
贴出Github的代码签入记录,合理记录commit信息。
-
遇到的代码模块异常或结对困难及解决方法。
-
问题描述
- 这道题比一般的华容道多了一些游戏规则,所以要找出这道题的最优解,就需要从游戏规则入手,一开始我们的AI算法在题目一开始无解的情况下,是随机进行下一步,直到强制调换以及自我调换后有解情况下再调用广搜算法进行解题,显然这样的做法无论是在步数还是时间上都不是最优,因此我们从游戏规则入手,寻找到一种让步数最少的方案(此处不过多赘述,想了解算法请移步前面的算法介绍),然而发现这种算法所耗费的时间又非常巨大,然后神奇的操作就来了
-
解决尝试
- 打表大法好(尖叫)
-
是否解决
- 已解决!
-
有何收获
- 任何重要的进步,都需要在逼迫自己再深入一步,才能发生。
-
-
评价你的队友。
- 值得学习的地方:做东西进度稳,实践能力强。
- 需要改进的地方:反正这次感觉挺靠谱的,挑不出什么大问题 。
第N周 | 新增代码(行) | 累计代码(行) | 本周学习耗时(小时) | 累计学习耗时(小时) | 重要成长 |
---|---|---|---|---|---|
1 | 110 | 110 | 8 | 8 | 查阅普通的数字华容道的解题算法 |
2 | 180 | 290 | 17 | 25 | 完成第一版的AI大比拼解题算法 |
3 | 260 | 540 | 25 | 50 | 深入查阅八数码相关的数学原理,为设计新算法进行理论铺垫和部分实践验证 |
4 | 380 | 920 | 30 | 80 | AI解题算法的一次重构,两次重要优化。原型设计 |