项目预先规划
在个人项目开始之前,我之前的编程几乎从来没有在开始前预估过时间,所以也就是摸着石头过河,大概根据之前的经验估计了一下时间。
预估时间(单位均为小时)
PSP 2.1 |
Personal Software Process Stages |
Time |
Planning |
计划 |
|
· Estimate |
· 估计这个任务需要多少时间 |
0.1 |
Development |
开发 |
|
· Analysis |
· 需求分析 (包括学习新技术) |
1 |
· Design Spec |
· 生成设计文档 |
0.5 |
· Design Review |
· 设计复审 (和同事审核设计文档) |
0.5 |
· Coding Standard |
· 代码规范 (为目前的开发制定合适的规范) |
1 |
· Design |
· 具体设计 |
2 |
· Coding |
· 具体编码 |
5 |
· Code Review |
· 代码复审 |
1 |
· Test |
· 测试(自我测试,修改代码,提交修改) |
3 |
Reporting |
报告 |
|
· Test Report |
· 测试报告 |
2 |
· Size Measurement |
· 计算工作量 |
0.2 |
· Postmortem & Process Improvement Plan |
· 事后总结, 并提出过程改进计划 |
1 |
合计 |
17.3 |
在估计完时间后,我的内心是崩溃的,完全没想到每部分两三小时的工作加起来竟然需要将近20个小时来完成,不过更没让我想到的是,竟然最后实际花去的时间还要远远大于我最开始的估计。
实际时间(单位均为小时)
PSP 2.1 |
Personal Software Process Stages |
Time |
Planning |
计划 |
|
· Estimate |
· 估计这个任务需要多少时间 |
0.1 |
Development |
开发 |
|
· Analysis |
· 需求分析 (包括学习新技术) |
2 |
· Design Spec |
· 生成设计文档 |
1 |
· Design Review |
· 设计复审 (和同事审核设计文档) |
1 |
· Coding Standard |
· 代码规范 (为目前的开发制定合适的规范) |
2 |
· Design |
· 具体设计 |
3 |
· Coding |
· 具体编码 |
12 |
· Code Review |
· 代码复审 |
2 |
· Test |
· 测试(自我测试,修改代码,提交修改) |
6 |
Reporting |
报告 |
|
· Test Report |
· 测试报告 |
2 |
· Size Measurement |
· 计算工作量 |
0.2 |
· Postmortem & Process Improvement Plan |
· 事后总结, 并提出过程改进计划 |
3 |
合计 |
34.3 |
性能改进过程
从上面两个表格可以看出,最终我完成这个个人项目所用的时间几乎达到了计划值的两倍之多。对于设计-实现-测试这个流程来说,主要比计划多出来的时间都花在了设计与实现上,我认为之所以多花了将近一倍的时间,我想这与我从最开始设计程序开始,就在不停的思考和推翻一个个设计,并不断改进追求程序性能的最大化有关。
如果说在做这个个人项目之前,我把编程的重点放在把需求尽可能完整的而又没有错误的实现上的话,这次贯穿整个项目流程的主线则是不断的优化算法,以寻求程序的性能在保证没有错误的基础上最大化。
下面讲一下我整个优化的流程
第一阶段 设计阶段
在设计之初,我就在一直想一个问题:如何将地铁图抽象成为数据结构,用什么样的数据结构能够最简单,最直接,最利于算法的发挥,并且空间的开销最小。最开始,也是最自然想法就是将所有的地铁站按照线路来进行存储,每条线路中存储着所有的车站,然后再对于换乘站进行特殊处理。相信这是大多数人最开始的思路,于是我也按照这样的思路开始了设计,对于这样的数据结构,很自然的就想到了利用广搜来进行最短路径的搜索,对于完成按照换乘最少和站数最少的搜索,广搜似乎都能很好的得到结果。
但是这时,我又转念一想,广搜在最坏情况下的时间复杂度达到了N^2,虽然这次图的节点数并不多,按照所有的地铁站来算,也就是不到300个节点,而且每个节点并不是与所有节点都有连接,而是大部分只与两个节点(非换乘站),最多与六个(三线换乘站)节点之间有连接,这又会大大降低广搜的搜索空间。这样的算法对于这样的数据规模还是比较合适的。
但是,对着地铁图琢磨了一会以后,我又有了一个新的想法。
问题就在于这地铁图的特殊性,一个站貌似与两个节点之间有连接,但实际上在地铁线路中旅行时,一个最短线路绝对不会走回头路,也就是说实际上在单独的一条线路上,最短路径只能是单向行走,直到出现了换乘站,不同的路径之间才会在这里产生分歧,走向不同的方向。所以,地铁线路中看似有非常非常多的地铁站点,但实际上能够对最短路径产生影响的,只有那些多线交叉的换乘站,而换乘站与换乘站之间的非换乘站,只要把它们抽象成一个带权值的边就足够了。在数据结构中,真正需要的,就是那些换乘站组成的换乘节点。
按照这样的思路,整个地图就能够被简化为将近五十个换乘节点,和换乘节点之间连接的带权边,在这样的图中进行广搜,搜索效率应该能够有所提升。所以,在真正开始实现之前,我就明确了注重换乘站而尽量简化换乘站之间的非换乘站这样的整体设计。
第二阶段 正式编码阶段
于是在编码阶段,虽然为了计算起始点与终点不是换乘站的最短路径,我的程序的数据结构中仍然需要存储所有的站点以及所有的线路,但是在实际的算法实现中,并没有用到这些数据,仅仅是利用了一张代表所有换乘站之间的距离的二维数组,并找到距离起始点和终点最近的两个换乘站,进行最短路径的查找。这样是对于广搜算法的一次优化,提升了一部分程序性能。
接下来,我又对广搜这个算法提出了疑问,对于这样的数据结构,能否有更高效的最短路径搜索算法呢?于是我又想到了迪杰斯特拉算法,这种算法在最坏情况下的时间复杂度要比广搜小了一个数量级,并且之前我就选定好的数据结构非常适合迪杰斯特拉算法算法的应用,因此我觉得要用迪杰斯特拉算法来提升整个程序的性能。
然后就出现了一个小问题,因为所有按照换乘站来存储的数据结构实际上把所有的线路都割裂成了换乘站之间的小段线路的集合,也就是说在经过这样的抽象以后,很难再在寻找最短路径时区分一条边和另一条边是不是同一条路线,至少,对于迪杰斯特拉算法算法来说很难,这也就使得迪杰斯特拉算法算法无法完成对于换乘最少的最短路径的搜索。而此时广搜就可以发挥它的用武之地,在搜索时,可以配合线路图,做“线路优先”的搜索,也就是说将最少换乘最短路线问题,转换成在最少的节点的连接下,由起点线路换乘到终点线路。这是相对容易实现的。
所以在最后的程序当中,对于不同的搜索模式,我分别用了两种不同的搜索算法。在搜索两站之间换乘最少的最短路径时,程序使用广度优先搜索,在搜索站数最少的最短路径时,程序使用迪杰斯特拉算法进行搜索。
程序性能分析图如下,在分析性能时,我分别使用广搜和迪杰斯特拉算法对十对不同的起点终点信息进行了测试,测试结果如下
其中BFSPath是广搜算法,DjistraPath是迪杰斯特拉算法。可以看到,在相同的测试数据下,迪杰斯特拉算法所用的时间大概是广搜的25%左右,而在更详细的分类下,广搜算法所有时间都用在了广搜的递归调用子函数上,而迪杰斯特拉算法中真正计算路径的算法部分只占用了2%左右的时间,而其他时间则用在了处理地图数据和建立路径上。可见,如果不考虑算法的复数函数的时间,两种算法在计算路径的时间上几乎有十倍左右的差距。
而这一切的一切,还是建立在两种算法都对地铁站本身的数据结构进行了优化存储,仅保留换乘的节点的基础之上得到的数据。可想而知,如果直接使用我最开始设想的数据结构,那么用的时间还会延长。
第三阶段 性能优化阶段
在整体编码忘了之后,我跟根据性能分析工具提供的结果检查了一遍所有函数,找到了几个耗时间比较长的函数,发现他们的共性就是都存在调用函数时遍历数组的情况。我对此进行了分析,发现有一个函数为了返回当前线路中的总站数而遍历了整个线路集合。这是一个可以优化的点,因为集合的总站数可以在每次集合中添加或者删除元素时记录在对象中的一个整型变量中而没有必要每次都遍历整个数组。因此我对此进行了修改,并且将几个函数中关于数组遍历的部分都检查了一遍,并修改了所有类似的部分。
在修改后,这几个函数都不在存在于耗时较多的函数列表中了。
十组测试用例
1.MetroSystem.exe -b 2号航站楼 3号航站楼
2.MetroSystem.exe -b 健德门 火器营
3.MetroSystem.exe -b 南邵 角门西
4.MetroSystem.exe -b 朱辛 马泉营
5.MetroSystem.exe -b 西直门 国贸
6.MetroSystem.exe -c 3号航站楼 西局
7.MetroSystem.exe –c 北苑路北 安立路
8.MetroSystem.exe -c 广渠门外 国贸
9.MetroSystem.exe -c 大屯路东5 大屯路东15
10.MetroSystem.exe -c 什刹海 什刹海
第一组测试用例测试单行线
第二组测试用例测试环线
第三组测试用例测试尽头站到换乘站
第四组测试用例测试错误输入不存在的车站
第五组测试用例测试换乘站到换乘站
第六组测试用例同样测试单行线
第七组测试用例测试不能换乘的大屯路东站
第八组测试用例测试不能换乘的双井站
第九组测试用例测试两条线同名车站实际不能换乘的情况
第十组测试用例测试同名车站
精益求精
在这张地铁图中,有一条线和两个站的情况比较特殊。
一条线就是机场线,机场线在三号航站楼和二号航站楼区间内单向运行,我的程序考虑到了这一点,对其进行了特殊处理。
两个站则是大屯路东站和双井站,两站看似5和15号、7和10号线在此交叉,实际上两线之间的换乘通道并未开通,也就是两条线的同名站实际上是两个独立的站点,彼此之间并不能互相换乘。在我的程序中,用大屯路东5和大屯路东15,双井7和双井10来区分这两个车站。
附加题的思路
对于附加题,我有一些想法。
首先,我认为,无论从哪个站点出发,最短环形路线都应该是一样长的。这个很简单就能证明,因为每个站至少都经过一次。因此,对于这张地铁线路图,完全不需要每次都对这个路线进行计算,而是把这个计算得到的最短路线储存起来,从各个站出发的路线只需把环形的起始位置稍加改变即可,站与站之间的相互位置是保持不变的。
所以这样一来,这个问题就成了在一个无向图之中求最短可重复边的回路的问题,而这个问题又叫做中国邮递员问题,已经有相当多的算法可以解决这个问题。但是限于时间所限,我没有能够将这个算法写到我的程序之中,不过相信有了这个思路,实现起来应该并不需要多长的时间了。
最后我甚至还有一个比较有意思的算法,因为求中国邮递员问题就是要将当前所有度为奇数的节点之间加入最短的重复边,使得所有节点的度都为偶数,而北京地铁中度为奇数的节点并不多,这个计算量甚至可以直接在纸上算出来最短回路,然后直接保存于程序中,然后根据起始点的不同进行调整。虽然这种方法只能对应当前的地铁图,但是鉴于地铁图的更新频率并不高,这样的做法看起来也是可以接受的。
个人项目总结
虽然这并不是我第一次编写将近1000行的程序,但是这次编程是我第一次编写一个可以称之为项目的程序。相比于从前,我投入了更多的时间在设计和优化上,不再像以前一样仅仅满足于写出一个正确的程序,而是更多的希望能够写出一个高性能的,优美的程序。虽然这样的目标现在看来还远远没有达到,但是从这次项目之中,我学到了很多。
首先我学到了提前规划,一个好的设计,不仅能够让之后的编码过程更加逻辑清晰,还能让代码的效率提高不少。之前虽然也会提前设计,但是也就是想想需要用什么算法,如果能差不多在大脑里面有了一个可用的算法能够解决这个问题,我一般就差不多开始动手写了。可是根据这次项目的经验,最先跳出脑海的算法往往都是我们最熟悉的算法,其实这是不太好的。首先,熟悉而常见的算法效率不一定是最高的,其次往往忽视了对于问题的简化和深入思考,最后,会导致知识仅仅局限在那几个算法中绕圈,没有尝试也就没有学习与提高。
其次,我学到了优化。之前,我从来没有接触过对于性能方面的分析,也不知道VS竟然有如此方便的工具能够自动生成报表,来告诉我们那些占用时间比较长的函数,来方便我们进行优化。这次学到了一点优化性能的皮毛,现在看来,从最开始的设想到最后的实现,大概将性能提升了十倍左右。这是一件很让人有成就感的事情。
最后,我的调试技术又更进了一步。这次编程中,第一次运行程序免不了会出现各种各样的Bug,利用VS功能强大的调试工具,结合断点和分部调试,并适当在程序中加入判断语句以便更好的定位断点和出错点,出现的Bug基本是一次性找出,一次性全部解决,这比之前经常苦恼找不到问题出在哪里有了很大的提高和进步。
附:GitHub源码地址
https://github.com/shihaoran