[TOC]
说来惭愧,作为程序员直到现在才开始在真正意义上阅读《编程珠玑》这本程序员宝典。这本书介绍了很多平时写程序不会关注的细节,然而正是这些细节最能体现程序员的功底。书中一些地方写的比较晦涩(也可能是本人愚钝的原因🤣),有些结论作者只是一句带过,需要读者自己思考为什么,阅读过程中还是遇到了不少困难,因此决定用三篇文章记录一下要点。
修订:缺了代码好像显得有点空洞,于是补了几段代码。代码是自己写的,为了便于理解,设置了很多局部变量,代码风格会稍显啰嗦。
基础
一、开篇
文章开篇抛出了一个电话号码排序的问题。对于海量数据的排序,通常会有具体的限制条件,例如耗费时间需要控制在多少秒以内、占用内存需要控制在多少兆字节以内。这些限制条件无疑会增大程序设计的难度,因此在程序设计过程中更应该思虑周全,在明确了可行的解决方案和清晰的处理流程之后,才着手编码工作。
1.1 问题抽象
首先是问题抽象,将工程问题转化为编程问题,文中的工程问题是对 7 位长度的纽约市所有固话进行排序。将其抽象为以下编程问题:
问题抽象:
输入:一个最多包含 n 个正整数的文件,每个数都小于 n,其中 n = 10,000,000。如果在输入文件中有任何重复的整数出现就是致命错误。没有其他数据与该整数相关联。
输出:按升序排列的输入整数的列表。
约束:最多有大约 1MB 的内存空间可用,有充足的磁盘存储空间可用,运行时间最多几分钟,运行时间为 10 秒就不需要进一步优化了。
1.2 程序设计
程序设计包括方案选型和具体流程设计。方法选型也可以视为对程序的渐进式优化的过程。对于上述问题,可选的方案有以下三种:
方法一:使用基于磁盘的归并排序。该方法只需读取输入文件一次,并借助工作文件完成归并排序,归并排序期间由于运行内存空间的限制需要分趟完成,期间必然会多次输入输出工作文件。另外,归并排序本身存在一个重要的局限性,运行期间具有 O(n) 的空间复杂度,而且多次输入输出工作文件必然耗费大量的时间;
方法二:基于该排序问题的特殊性考虑,使用分趟方式。可以将每个正整数用 4 字节的int表示,那么 1MB 内存空间就可以保存 250,000 个正整数,则 10,000,000 可以划分为 40 趟。第一趟遍历整个数组,并将所有值在 0~249,999 之间的整数读取到内存,然后使用快速排序,完成后写入到输出文件;第二趟还是遍历整个数组,排序所有值在 250,000~499,999 之间的整数,以此类推,40 趟后整个数组排序完成。该方法的优势在于:1、无需借助工作文件,减少了排序过程的 I/O 次数和所需磁盘空间;2、快速排序是基于交换的排序,运行期间只有 O(1) 的空间复杂度;
方法三:使用位图方式,只需要借助 1MB 运行内存空间,遍历输入文件一次,就可以完成排序。如果说方法二略显高级的话,那么方法三就有点“黑科技”的意味了。考虑到排序目标是互异的,而且有明确取值范围的,密集的正整数集合。因此用 10,000,000 个二进制位,来表示取值范围在 0~10,000,000 之间的互异整数集。例如将整数集{1, 2, 3, 5, 8, 13}用二进制序列0111 0100 1000 0100,第 0 位为0表示整数集不包含整数 0,第 1 位为1表示整数集包含整数 1,第 2 位为1表示整数集包含整数2,以此类推。那么剩下的问题就是如何严格使用 1MB 内存(只能表示 8,000,000 个取值范围为 0~8,000,000 的互异整数集)表示 10,000,000 个取值范围为 0~10,000,000 的互异整数集。解决该问题只需要根据整数集的取值规则建立一个取值范围映射即可。例如:1、设定整数集第 0 位表示整数 10000(规则一:明确整数集中不存在 10,000 以下的数),则第 89999 位表示整数 99999;2、设定第 90000 位表示整数 1,000,000(规则二:明确整数集中不存在 100,000~999,999 之间的数);以此类推;
从以上三种方案的原理不难发现,方法三无论在时间复杂度、空间复杂度上都具有非常大的优势,而且实现也比方法一、方法二更为简单,显然是三者中的最优解。
1.3 编码实现
书中并没有给出算法的具体实现,上面的方法三的设计比较巧妙,让人忍不住想试着实现一下。这里当作练习,定义phoneNumberSort函数,用于实现基于位图的电话号码排序并输出到特定文件夹,返回true表示排序并输出结果成功,返回false表示失败。输入三个参数:
phoneNumbers:电话号码数组;length:电话号码数组的长度,电话号码的实际数量;max:电话号码的最大值,例如 7 位号码最大值为 10e7,决定了位图的长度;
bool phoneNumberSort(int* phoneNumbers, int length, int max){
// 步骤一:打开输出文件
// 注意:下面获取输出文件路径的代码只在 iOS 平台下有效,在其他平台上则替换成合适的实现
// 1.1 获取输出文件路径
NSString* docPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) lastObject];
const char* filePath = [[NSString stringWithFormat:@"%@/sorted.txt", docPath] UTF8String];
// 1.2 打开输出文件,若失败则直接返回
FILE *fpWrite = fopen(filePath, "w");
if (fpWrite == NULL) {
return false;
}
// 步骤二:计算位图
// 分配位图 bitmap 的内存空间,表示为字节数组
int numberOfBytes = max/8 + 1;
uint8_t* bitmap = calloc(numberOfBytes, sizeof(uint8_t));
for(int i = 0; i < length; i++){
int curPhoneNumber = phoneNumbers[i];
// 找到"号码对应的位图字节和位"
int targetByteIdx = curPhoneNumber / 8;
uint8_t targetBitIdx = curPhoneNumber % 8;
// 用按位或将"号码对应的位"置为 1
uint8_t bitWiseOr = 1 << (7 - targetBitIdx);
bitmap[targetByteIdx] |= bitWiseOr;
}
// 步骤三:计算位图
// 若直接输出数组占用内存为 4 * (9e6) B = 36 MB,因此以文件输出排序结果
int savedCount = 0;
for(int i = 0; i < numberOfBytes; i++){
uint8_t targetByte = bitmap[i];
for(int j = 0; j < 8; j++){
uint8_t bitWiseAnd = 1 << (7 - j);
if((targetByte & bitWiseAnd) && savedCount < length){
fprintf(fpWrite, "%d\n", i * 8 + j);
savedCount++;
}
}
}
fclose(fpWrite);
free(bitmap);
return true;
}
1.4 小结
学习本章总结到以下几点:
- 解决问题前,应先对问题建模,将实践应用问题转化为明确的程序设计问题;
- 解决问题时,需要在程序设计阶段形成明确并可行的方案、清晰的处理流程,以此为指导以至编码实现阶段可一气呵成。犹如音乐家花大量时间谱写乐谱,而演奏时只需按照乐谱弹奏即可;
- 程序优化是循序渐进,步步为营的过程,对最优解要有孜孜追求的信念;
- 另外,从本章的例子可以总结出以下结论:
- 明确问题所在,对目标问题有一个清晰的认知;
- 具体问题具体分析,根据具体场景设计算法;
- 位图数据结构,该数据结构非常适用于描述一个有限定义域内的密集集合,元素互异且不与其他数据相关联;
- 多趟算法,多趟读入数据,将复杂的单个问题划分为简单的问题集;
- 时间和空间的折中;
- 简单的设计;
- 程序设计阶段的重要性;
注意:位图数据建构也不仅限于元素互异的场景。例如,如何找出全国 5,000,000 高考考生中分数前一百名的考生。由于可以将考生分数分布用一个长度为 750 的数组表示(假设最高分 750,不存在 0.5 的分数),该数组每个元素表示考取这个分数的考生个数,该数组本质就是位图,第一趟遍历可以定位前一百名的最低分,第二趟遍历可以找出前一百名的考生。借助内存空间为 750*4 字节,时间复杂度为 O(n),也算是一种比较理想的解决方案。
二、啊哈!算法
本章以三个问题介绍了算法设计过程中的几个重要套路:二分搜索、翻转、递归、排序、标识。
2.1 二分搜索
二分搜索是重复探测当前范围的中点来定位目标元素的一种快速搜索套路。对有序数组进行二分法搜索可以实现 O(LogN) 的时间复杂度。然而文中提出的问题更具挑战性:
问题一: 给定一个最多包含 40 亿个随机排列的 32 位整数的顺序文件,找出一个不在文件中的 32 位整数。如果有足够内存,应如何解决?如果有几个外部的临时文件可用,但仅有几百字节的内存又该如何解决?
如果内存足够,则可以考虑用第一章的位图方式解决,32 位整数集合,可以用 232 位长度的位图表示,遍历目标整数集合一次就可以得到目标整数集合的位图,位图中值为的0的位则表示该位所对应的整数不存在。这种方法优点是快,但是需要耗费 232 bit = 229 Byte = 512MB 的运行内存。
如果内存空间存在限制,则使用二分搜索。取当前搜索范围 [0~232) 的中点 231,第一次搜索值在 [0~231) 范围内的元素个数,如果个数小于 231,则表示 [0~231) 范围内一定缺失了某个整数,如果个数大于等于 231(之所以存在大于的情况是因为文件中的整数并不互异),则表示 [231~232) 范围内一定缺失了某个整数。以此类推,每次可以排除一半范围,直到搜索到目标缺失整数为止(遍历输入文件 32 次后得到结果)。
2.2 神奇的翻转
翻转的奇妙之处是,它不仅仅能解决翻转的问题,譬如文章所举的问题二。
问题二: 将一个 n 元一维向量向左旋转 i 个位置。例如,当 n=8,且 i=3 时,向量 abcdefgh 旋转为 defghabc。
解决上述问题的方法有很多:
方法一:解决该问题的最简单的方式是借助长度为 i 的内存空间。但由于 i 的大小是不确定的,当 n 的值很大时,该方法很可能需要耗费大量的内存空间。
// 方法一:借助 i 个空间
void rotate(char* vector, int charCount, int num){
if(charCount <= 1 || !num)
return;
int bias = num % charCount;
char* temp = calloc(bias + 1, sizeof(char));
for(int i = 0; i < bias; i++){
temp[i] = vector[i];
}
for(int i = bias; i < charCount; i++){
vector[i - bias] = vector[i];
}
int start = charCount - bias;
for(int i = start; i < charCount; i++){
vector[i] = temp[i - start];
}
}
方法二:可以只借助 1 个单元的内存空间,以更加精巧的方式进行旋转。首先将第 0 个元素保存到一个临时空间中,此时将第 i 个元素填入第 0 索引,将第 2i 个元素填入第 i 索引,依次类推直到完成第一趟;第二趟则是旋转索引为 {1, i+1, 2i+1, ...} 的元素;以此类推。因此该方法的本质是逐一旋转 i 个元素。但是需要注意的是,完成了以上 i 趟旋转后,得到的通常还不是最终结果。例如:长度为 n 的向量向左旋转 m 个单位,第 i 个元素经过一趟旋转后到达的位置为 (n - n % m + i),超出长度的元素旋转到子向量左端,但其正确位置应该是 n - m + i,因此两者之间存在 ((n - n % m + i) - (n - m + i)) = m - n % m 的偏差。也就是说,除非 n % m = 0,否则末端 m 长度的子向量需要向左旋转 m - n % m 才能到达正确位置。此时可以使用递归得到最终结果。方法二的实现,需要非常周密的控制逻辑,所以很容易出错
注意:原文方法二的设计更为精巧,这里的方法二是为了方便实现而经过“改装”的。原文方法二在取出第 i 个元素后,开始向后搜索需要填充到前面的“空穴”的元素,当探测的目标索引 idx 超出数组范围时,则对目标索引按数组长度取模(idx = idx % n),然后继续将目标索引中的元素填入“空穴”,直到空穴位置回到索引 i 并被其他元素填充,此时才将最初取出元素 i 填入此时的“空穴”,则元素 i 被填充到正确的位置。若 i+1 索引的元素尚未探测,则继续以 i+1 为起点进行下一趟探测。最终循环探测的总趟数为 n、m 的最大公约数 gcd(n,m),该结论的推导需要一定的数学基础,这方面能力实在捉急,所以这里就不介绍了。
// 方法二:借助 1 个空间
void rotate1(char* vector, int charCount, int num){
if(charCount <= 1 || !num)
return;
int bias = num % charCount;
for(int i = 0; i < bias; i++){
char temp = vector[i]; //产生空穴
int current = i;
while (current + bias < charCount) {
int next = current + bias;
vector[current] = vector[next];
current += bias;
}
vector[current] = temp;
}
//最末尾空穴位置
int rearangeCount = bias;
if(!rearangeCount)
return;
int rearangeBias = rearangeCount - charCount % rearangeCount;
char* rearangeVector = vector + charCount - rearangeCount;
rotate1(rearangeVector, rearangeCount, rearangeBias);
}
方法三:将旋转向量本质是交换向量 ab 的两段得到 ba,其中 a 表示向量前 i 个元素。假设 a 比 b 短,则将 b 分量表示为左右两个部分 bl、br,其中 br 的长度等于 a 的长度,此时交换 a 和 br 分量得到 brbla,则分量 a 到达了最终位置。对 brbl 分量继续做上述的交换操作,使用递归过程得到最终的向量旋转结果。
// 方法三:递归交换
void rotate2(char* vector, int charCount, int num){
if(charCount <= 1 || !num)
return;
int bias = num % charCount;
int min = MIN(bias, charCount - bias);
for(int i = 0; i < min; i++){
char temp = vector[i];
int swapIdx = charCount - min + i;
vector[i] = vector[swapIdx];
vector[swapIdx] = temp;
}
if(bias * 2 == charCount){
return;
}
int rearangedNum = charCount - min;
int rearangedBias;
char* rearangedVector;
if(bias < charCount - bias){
rearangedVector = vector;
rearangedBias = bias;
}else{
rearangedVector = vector + min;
rearangedBias = charCount - 2 * min;
}
rotate2(rearangedVector, rearangedNum, rearangedBias);
}
方法四:通过三次翻转向量分量也可以实现向量的旋转,这种方法十分精妙但是实现非常简单。首先将 a 分量翻转得到 arb,其次将 b 分量翻转得到 arbr,最后翻转整个向量得到 ba。整个过程只需要实现一个翻转函数void reverse(int start, int end),并依次调用reverse(0, i-1)、reverse(i, n - 1)、reverse(0, n - 1)即可完成向量旋转。该方法的最精妙之处是无需借助临时内存空间。
尝试实现一下上述四种方法:
void reverse(char* vector, int charCount){
for(int i = 0; i < charCount/2; i++){
char temp = vector[i];
int swapIdx = charCount - 1 - i;
vector[i] = vector[swapIdx];
vector[swapIdx] = temp;
}
}
// 方法四:利用翻转
void rotate3(char* vector, int charCount, int num){
if(charCount <= 1 || !num)
return;
int bias = num % charCount;
reverse(vector, bias);
reverse(vector + bias, charCount - bias);
reverse(vector, charCount);
}
越简单的实现往往越不容易出错,上面的四种方法中,方法四的实现是最简单的,基于已知成立的翻手原理,使用三步翻转实现向量旋转基本上是最佳选择。
2.3 排序
本节的需要解决的问题是:
问题三: 给定一个英文字典,找出其中所有变位词集合。例如,“tops”、“stop”、“pots”互为变位词,每个单词都通过改变字母顺序变换成另外一个单词。
当一个问题包含“所有”之类的关键字时,通常会面临复杂度的考验,本题也不例外。如果逐一搜索每个单词的变位词,则获得某个单词的所有变位词需要遍历整个英文单词集合,求解所有变位词集合则需要 O(N2) 的时间复杂度。这个复杂度等级的方案在解决十万级规模的问题时,会显得十分吃力。
算法时间复杂度从 O(N2) 进化到 O(NLogN) 就足以使算法性能产生质的飞跃。文中解决问题的方式是通过标识+排序。首先为字典中的每个词指定一个标识,标识的具体方式不一而足,只要满足两个必要条件:
- 互为变位词的单词具有相同标识;
- 不是互为变位词的两个单词具有不同标识;
例如,“tops”、“stop”、“pots”都使用“1o1p1s1t”标识,其中数字表示字母出现次数,字母按照字母表顺序排列。建立标识后,可以对英文字典中的所有单词,按照标识进行排序,排序完成后,只要遍历排序结果即可得出所有变位词集合。由于有大量 O(NLogN) 复杂度的排序算法可选,第一步建立标识和最后一步遍历排序结果都只要 O(N) 的时间复杂度,因此整个算法的时间复杂度是 O(NLogN)。
个人观点:上面的方法是通过对标识表进行排序,实现了同位词 O(LogN) 的查找效率(二分法)。但是,如果使用哈希表保存同位词,则可以直接省略掉排序过程,而且实现同位词 O(1) 的查找效率。
2.4 小结
学习本章总结到几种很好用的算法套路:
- 二分搜索:二分搜索对有序数组的搜索十分高效;
- 排序:数据集排序后可使数据享受二分搜索等福利,在处理随机分布的数据集时,排序很多时候是解决问题的突破口;
- 标识:利用表示可以将数据划分为多个数据子集。子集内的元素具有相同标识,表示同一类元素。不同子集间的元素具有不同表示,表示不同类元素。这种方法对涉及数据分类的问题上非常有用;
- 翻转:翻转是算法的一种十分重要的基本操作,文中三次翻转可以还实现平移效果,文中三次翻转的公式 (arbr)r=ba 也很有参考价值;
- 递归:个人对递归的理解是将总问题划分为若干子问题(定义递归函数),然后对每个子问题都按照主问题的方式处理(递归函数内的递归调用),在子问题简化到特定复杂度时直接返回结果(递归出口)。递归是基于分而治之的思想,是很多算法的惯用套路;
注意:解决问题二时,作者使用了数学公式描述解决方案的原理,使用数学公式的好处是更加严谨,而且有利于理解推导过程,非常值得借鉴学习。
三、数据决定数据结构
本章重点阐述了,编写程序前,确定合理的数据结构的重要性,恰当的数据视图实际上决定了程序的结构。本章所举的几个例子分别用于论证以下观点:
- 合理利用数组等容器可以有效减少重复代码;
- 封装复杂的结构。将具有强耦合性的逻辑封装为类不仅有利于数据管理和表示,还可以将数据的常用操作定义为类的方法,即利于处理逻辑复用;
- 尽可能使用高级工具。例如超文本、键值对、电子表格、数据库、编程语言等等,将数据提取到外部的结构化数据或可执行文件可以使代码专注于处理;
- 从数据得出程序的结构。本章主题就是:通过使用恰当的数据结构来代替复杂的代码,从数据可以得出程序的结构。万变不离其宗:在动手编写代码之前,优秀的程序员会彻底理解输入、输出和中间数据结构,并围绕这些结构构建程序(本条完全摘自原文)。
四、编写正确的程序
本章论述的观点是,纵使在程序设计阶段做到了最周密的考虑,在代码实现过程中总是会遭遇各种各样的问题,因此真正动手去写很重要,如何验证程序的正确性也同等重要。文中提到,第一篇讨论二分搜索的文章是 1946 年发表,而真正没有错误的二分搜索程序直到 1962 才出现,这个例子相信会让大多数人感到惊愕。
关于验证程序的正确性,文中介绍了断言的方式(也叫不变式 invariant)。在顺序逻辑、选择控制逻辑、循环控制逻辑、函数中插入断言可以衍生出丰富的不变式形式,意在各种控制逻辑下插入保证程序正确性的先决条件判断语句。
函数可以用两个断言来陈述其目的,实际上就是描述函数的输入数据及输出数据特征,分别是前置条件(precondition)和后置条件(postcondition),前者描述输入数据的特征,表示若函数传入参数满足前置条件的约束,则函数可返回满足后置条件的约束的结果。前置条件和后置条件都可以以断言的形式,集成在函数的实现逻辑中用于验证函数的正确性。
个人观点:目前被更广泛地采纳,用于验证程序的正确性的方式是单元测试。个人认为,若采用 TDD 或者 BDD 方式进行开发,则应尽量使用单元测试验证程序正确性,毕竟断言在开发阶段会造成程序强退的比较极端的结果,虽然有利于快速定位问题,但是立即中段程序毕竟会强制打断调试过程。因此个人理解,应只在验证函数或者重要代码块的前置条件时才使用断言。
五、编程小事
本章介绍了完成了程序设计阶段后,在程序编码实现阶段应遵循怎样的步骤。大致步骤如下:
- 脚手架(scaffolding):个人理解,脚手架相当于一个小 Demo,当开发具有高复用性的模块时,最好建立一个独立的 Demo 工程开发,这样有利于满足高聚低耦的原则,并明确模块与外部模块的依赖关系。另外还可以在 Demo 中实现简单的演示应用,专注于说明如何使用或扩展该模块的功能;
- 编码:对于比较复杂的函数,可以先用高级伪代码构建程序框架,再使用具体的编程语言实现;
- 测试:可以在脚手架程序中编写测试(个人认为在条件允许的情况下应尽量采用单元测试方式);
- 计时:线性搜索的实现要比二分搜索简单得多,如果对性能没有严格要求或者可以保证处理数据量表较少,完全可以使用线性搜索,因为简单、可维护性、可读性对程序也是同样重要,需要开发者根据具体使用场景进行权衡。若程序对性能有较高要求,则可以通过计时来验证程序是否能达到所预期的性能;
- 调试:BUG 调试考验的是程序员透过现象看本质的能力,因此充分分析现象是重要前提。测试人员报备 BUG 时,很多时候只有一句话描述,此时程序员要善于发现 BUG 复现操作和正常操作在细节上的差异,以及充分考虑 BUG 复现时的上下文的影响。在调试过程中需要不断提出问题(必要时询问测试人员),不断具化 BUG 场景,推断其中可能存在的因果关系,逐步缩小调试的目标范围。
个人观点:文中介绍的编程方式是先开发后测试,实际上目前测试前置的 TDD 和 BDD 已经相当流行,TDD 和 BDD 有利于在实现编码阶段前对程序生成一个清晰的概念框架,并对程序正确性建立一套足够明确的验证标准。但是我们重点是汲取文中所表达的理念:编码实现时需要充分考虑测试问题,必要时度量程序的运行性能。