我们身边的很多事物都用“质量”一词来衡量好坏,例如小到存储卡有Class4、Class6和Class10之分,大到汽车有各种品牌各档价位象征着不同的质量;而对于软件来说,源代码无疑是衡量软件质量好坏的最重要的标准,希腊人斯平内利斯所著的《代码质量》一书为我们详细介绍了软件质量的方方面面。
一份好的代码从宏观上来说必须具有良好的可靠性、安全性、可移植性和可维护性,而从微观上来说则应重点考察时间复杂度和空间复杂度的权衡折中。以下我就以一次软件的开发过程来阐述笔者所理解的提高代码质量的方法。
事情要追溯到去年夏天,当时我的一位在银行工作的朋友拜托我帮他一个忙。具体情况是这样的,领导交给他一个任务,现在有一个Excel表格里有大约100~200条数据,每条数据是一位客户的信息,具体包含3个字段,分别是银行卡号、姓名和身份证号;不过这个表格里面的数据有重复的(可能数据来自多份表格的汇总所致),他希望我能帮他编个小程序将重复的数据去除掉,每组重复的数据只保留一条。
听到朋友的要求之后我一条语句都没写,直接告诉他:“不用编软件,Excel里直接就可以通过一些操作去掉这些重复的数据,稍复杂点的可以考虑VBA等自动化处理方式。”我本以为这样的回复会令对方很满意,谁知一周后朋友又来找我,说Excel搞不定,VBA太难不懂。另一方面,银行的数据涉及到客户隐私,又不能将Excel传给我帮他处理,耐不住他的哀求,我只好考虑帮他用程序实现了。
对于这个问题,我是使用VC编写一个小界面实现的,诸如读写Excel等功能只要使用ODBC的方式就不难实现,最核心的还是数据处理算法。不过,参考《编程珠玑》上类似的例子,这个算法还是不难的,那就是首先对数据排序,然后对排好序的记录逐条访问,看相邻记录是否相同,若不同则将后一条记录保留下来,若相同则跳过继续处理下一条记录,这样最终保留的都是不同的记录且没有遗漏。于是,我不假思索地写成了下面的代码片段:
1 #define MAXLEN 20 2 #define MAXREC 300 3 4 typedef struct 5 { 6 char account[MAXLEN]; 7 char name[MAXLEN]; 8 char id[MAXLEN]; 9 } Rec; 10 11 void CDataUniqueDlg::BubbleSort(Rec rec[], int n) 12 { 13 int i, j, k; 14 Rec tmp; 15 16 for (i = 0; i < n-1; i++) 17 { 18 for (j = n-1; j > i; j--) 19 { 20 if (strcmp(rec[j].account, rec[j-1].account) < 0) 21 { 22 tmp = rec[j]; 23 rec[j] = rec[j-1]; 24 rec[j-1] = tmp; 25 } 26 } 27 } 28 } 29 30 void CDataUniqueDlg::OnProcess() 31 { 32 ... 33 FILE *fp = fopen(m_FilePath, "r"); 34 Rec rec[MAXREC], output[MAXREC]; 35 int n = 0, len = 0, i; 36 37 while (fscanf(fp, "%s %s %s", rec[n].account, rec[n].name, rec[n].id) != EOF) n++; 38 39 BubbleSort(rec, n); 40 41 for (i = 0; i < n; i++) 42 if (i == 0 || strcmp(rec[i].account, rec[i-1].account) != 0) 43 output[len++] = rec[i]; 44 ... 45 }
很明显,我采用了简单的冒泡排序算法以账号字段为关键字对整个结构体数组进行排序,而数据处理主算法也是很简单的。对于数据的范围,我是根据朋友的描述进行设置,账号字段是19个字符,姓名字段不超过5个汉字,身份证号字段是18个字符,这样三个字段我都用20个字符的数组存储就够用了;而300的记录数组数目对于100~200的数据量来说也是绰绰有余。最终这份代码我尝试对220个随机乱序的记录进行处理,结果显示用了不到10ms就正确得到了结果;到此,软件设计完成,随后我把它提交给了我的朋友。
朋友使用起来十分满意,并成功地将这种满意度维持了3个月。3个月后,他又找到了我,告诉我说软件有bug。在听他具体描述bug之前,我仔细思索了一下,应该不会啊!不过听到他的说法之后,我赞同了;这是因为他真的挑出了两个问题:
1) 软件不能处理大数据量的记录(例如上万);
2) 软件处理得到的记录与原先的记录顺序不同;
好吧,我承认,原先的软件真的很有问题,不过你提的问题跟原先的需求相比也变了呀!原先只说要100~200条记录,也没说记录顺序的问题。不过,经过认真分析,我觉得作为开发人员没有考虑周详也是责无旁贷,其实这两个问题都暴露了软件的可靠性和健壮性不够。
于是,我几乎把整个数据结构都做了改动,考虑到上万条数据使用冒泡排序这种时间复杂度为O(n^2)的算法会很慢的情况,排序算法被我改成了快速排序(如果一开始就用快速排序,排序算法这块只需宏定义MAXREC改大一些就行了,这说明这份代码的健壮性的确不够)。
1 #define MAXREC 50000 2 #define MAXLEN 20 3 4 typedef struct 5 { 6 char account[MAXLEN]; 7 char name[MAXLEN]; 8 char id[MAXLEN]; 9 int index, flag; 10 } Rec; 11 12 void CDataUnique1Dlg::QuickSort(Rec rec[], int s, int t) 13 { 14 int i = s, j = t; 15 Rec tmp; 16 17 if (s < t) 18 { 19 tmp = rec[s]; 20 while (i != j) 21 { 22 while (j > i && strcmp(rec[j].account, tmp.account) > 0) 23 j--; 24 rec[i] = rec[j]; 25 while (i < j && strcmp(rec[j].account, tmp.account) < 0) 26 i++; 27 rec[j] = rec[i]; 28 } 29 30 rec[i] = tmp; 31 QuickSort(rec, s, i-1); 32 QuickSort(rec, i+1, t); 33 } 34 } 35 36 void CDataUnique1Dlg::OnProcess() 37 { 38 ... 39 FILE *fp = fopen(m_FilePath, "r"); 40 Rec rec[MAXREC], tmp[MAXREC], output[MAXREC]; 41 int n = 0, len = 0, i; 42 43 while (fscanf(fp, "%s %s %s", rec[n].account, rec[n].name, rec[n].id) != EOF) 44 { 45 rec[n].index = n; 46 rec[n].flag = 0; 47 tmp[n] = rec[n]; 48 n++; 49 } 50 51 QuickSort(tmp, 0, n-1); 52 53 for (i = 0; i < n; i++) 54 if (i == 0 || strcmp(tmp[i].account, tmp[i-1].account) != 0) 55 rec[tmp[i].index].flag = 1; 56 57 for (i = 0; i < n; i++) 58 if (rec[i].flag == 1) 59 output[len++] = rec[i]; 60 ... 61 }
可以看出,这份代码对比上一份改动是较大的,首先记录数目的上限增加为50000,排序算法采用了平均时间复杂度为O(nlong(n))的快速排序;其次,数据记录结构体增加了一个索引和一个标志,这样排序的时候仅仅对原始记录的一个副本进行排序,数据处理的时候对排好序的副本进行扫描,但置标志的时候却将原始记录的标志通过索引找到后置上,这样再扫描一次原始记录就可以不打乱原纪录顺序地实现去除重复数据的功能。
显而易见,第二份代码的质量高于第一份,这是因为首先降低了时间复杂度(从O(n^2)到O(nlong(n))),其次增强了健壮性(不管有没有提保持顺序的问题,这样做了总是没有错的)。朋友也对此表示十分赞赏,并通过实测告诉我对20000条记录的处理时间居然没有超过1s,这一结果使我十分振奋,毕竟自己的努力取得了效果,要知道如果仍采用冒泡排序,根据估算处理完20000条记录恐怕至少要20分钟!
原以为事情就到此为止了,不过,好景不长,我的朋友又来找我了。这一次他说他们领导对这个程序很感兴趣,提了三个新的需求:
1) 能不能让软件处理数据的能力接近一个城市中区的人口(数十万左右);
2) 字段数目不再是3个,而是更多(例如增加住址、联系方式等);
3) 重复的记录能否保留第一次出现那个,而不是随便某一个。
我的天啊!这简直有点强人所难了!现在我才明白,我的朋友已经成为了我的客户,而这几乎就是一个软件开发带升级过程的小型项目!不过我喜欢挑战难度,于是答应考虑一下。对于新的需求,我是这么想的:数十万条记录,加上每条记录增加字段数后可能达到几百个字符,这些内容已经不可能同时记录在内存中了,即便可以,也会严重影响软件效率,于是对文件的多次读取不可避免。另外,关于字段数目增加的问题可以用改变读入数据的方式解决,每次将一行内容统统读进来。对于重复记录保留第一次出现记录的问题,其实涉及到排序方式的稳定性,快速排序是不稳定的,因此的确会出现相同记录随机保留的现象,这说明快速排序不能用了!而且,数十万的数据量对于时间复杂度为O(nlong(n))的算法也是吃不消的,没有几分钟绝对跑不出来,所以应该想一想有没有接近O(n)或者具有较小时间常数c的O(cn)的排序方式能够使用。另外,我的朋友给了我一个额外的信息使得上述想法成为可能,这个信息就是账号字段因为都是同城同行客户,前面10位都是相同的,换句话说只需对账号字段的后9位排序就可以了,后面我们将看到,这一信息对软件的时间复杂度的降低起了相当大的作用。基于以上想法,代码的第三版如下所示:
1 #define MAXREC 200000 2 #define LINELEN 200 3 #define MAXACCLEN 19 4 #define ACCLEN 9 5 #define RADIX 10 6 7 typedef struct 8 { 9 char account[ACCLEN]; 10 int index, flag; 11 } Rec; 12 13 Rec rec[MAXREC], tmp[MAXREC], output[MAXREC]; 14 15 void CDataUnique2Dlg::RadixSort(Rec rec[], int n) 16 { 17 int i, j, k, c[RADIX]; 18 19 for (i = ACCLEN - 1; i >= 0; i++) 20 { 21 for (j = 0; j < RADIX; j++) 22 c[j] = 0; 23 24 for (j = 0; j < n; j++) 25 c[rec[j].account[i] - '0']++; 26 27 for (j = 1; j < RADIX; j++) 28 c[j] += c[j-1]; 29 30 for (j = n-1; j >= 0; j--) 31 { 32 k = rec[j].account[i] - '0'; 33 tmp[c[k]] = rec[j]; 34 c[k]--; 35 } 36 } 37 } 38 39 void CDataUnique2Dlg::OnProcess() 40 { 41 ... 42 FILE *fp = fopen(m_FilePath, "r"); 43 char line[LINELEN+1], account[MAXACCLEN+1]; 44 int n = 0, len = 0, i; 45 46 while (fgets(line, LINELEN, fp) != NULL) 47 { 48 sscanf(line, "%s", account); 49 strcpy(rec[n].account, account + MAXACCLEN - ACCLEN); 50 rec[n].index = n; 51 rec[n].flag = 0; 52 tmp[n] = rec[n]; 53 n++; 54 } 55 56 RadixSort(rec, n); 57 58 for (i = 0; i < n; i++) 59 if (i == 0 || strcmp(tmp[i].account, tmp[i-1].account) != 0) 60 rec[tmp[i].index].flag = 1; 61 62 fseek(fp, 0, SEEK_SET); 63 64 while (fgets(line, LINELEN, fp) != NULL) 65 { 66 if (rec[len].flag == 1) 67 { 68 len++; 69 ... 70 } 71 72 ... 73 } 74 75 ... 76 }
代码对比第二版改动又不小。这一次的排序算法采用了基数排序,基数排序内部对每一个字符又用了稳定的计数排序算法,因而整个排序算法是稳定的。而且,这个算法的时间复杂度为Θ(n*length(account)),这里length(account)=ACCLEN=9。试想一下,如果继续使用任何基于交换的排序算法,其平均时间复杂度的上界都是O(nlog(n)),此处n= MAXREC,那么log(n)≈18,可以看出基数排序算法将运行速度至少提高了一倍;而且O(nlog(n))只是那些算法的上界,遇到一些特殊的数据可能会使时间复杂度降低到O(n^2),而基数排序的时间复杂度很稳定,一直都是Θ(n*length(account))。这一切都得益于对账号字段冗余信息的提取,即前10位是冗余的,我们只需对后面9位排序;并且每一位都是字符'0'~'9'中的某一个,这样我们进行内部以计数排序为基础的基数排序就显得游刃有余了。
稳定、快速的基数排序解决了新需求的第1、3条,而第2条就靠我们的读取数据的方式变更来实现了。这一次我们首先把每一行数据全部读取进来,仅仅提取账号字段用来排序,然后采用和第二版代码类似的做法标记每一条记录是否是我们需要的;接下来重新从文件中一行一行读取记录,这一次按照计算出来的标志直接生成结果。由于记录数目相当大,我们的排序算法又是采用“空间换时间”的方式,因而这份代码的空间复杂度相当高,2次读取文件的目的就是为了重新用“时间换空间”,否则内存中无法存储如此多的数据,我们的应用程序就崩溃了。另外整行数据读取的方式也解决了多字段的问题,因为此时我们已经不再分若干字段了,而是将其看成一行一行,无论多少个字段,只要一行的长度小于LINELEN,即200就行了。
经过朋友实测,对当地市某区人口共135000条记录的数据进行了处理,筛选出3500条重复记录,用时24s,而且结果是完整无误的!看来,对于第三版代码,我们的时空复杂度取舍是相当成功的。
以上就是我所经历的一次需求不断变更中的代码修改,而每一次修改实际上都向下兼容了上一次的功能,并且在原有代码基础上进行完善修正,这体现了代码质量的提高。代码的变更使得时间复杂度从O(n^2)改进为O(nlog(n)),最后又提升为O(cn),当然算法的空间复杂度有所增加,不过也是在可接受范围内。另外,代码的可靠性和健壮性越来越好,从小数据量到大数据量,从乱序到维持原顺序,从不稳定排序到稳定排序,从仅支持3个字段到支持多个字段,每一次性能的提升虽然增加了代码的实现难度,但却的的确确改进了代码的质量,使其适用于更多的情况。在实际应用中,需求的变更是常有的,不过更多的需求变更是对已有需求的扩充和引申,这就需要我们考虑能否通过提高代码的质量来满足新的要求,解决了问题又锻炼了能力,何乐而不为呢?