本文首发于个人博客之编程珠玑,之后会陆续将博客转移至个人博客,期待与各位的交流
这不是一本具体算法的讲解或者代码编写的教程,但是从书中的字里行间,我们可以学到的是更多的软知识:对编程新的认识、更加发散的思维方式、更严格的代码要求、堪比瑞士军刀的小技巧…… 编程也许入门并不难,但是要想真正成为一名优秀的软件工程师,还是需要很多锤炼。内外兼修,方成大器。
第一章 开篇
首先作者提出一个实际问题:
如何给磁盘的某个文件排序,更具体来说就是是对一个最多包含1千万条记录,每条记录都是7位整数的文件,而且只有1MB的内存可以使用
从实际问题中提炼出更明确的数学定义:
输入:一个最多包含n个整数的文件,每个数都小于n,其中n=10^7。可以保证输入文件中不存在重复整数
输出:按升序排列的输入整数的列表
约束:1MB左右的内存空间,充足的磁盘存储空间。运行时间最多几分钟,控制在10秒内不再需要进一步优化
考虑一般的解法,直接读入所有的整数,然后进行快排堆排之类的排序,时间复杂度很明显是O(nlogn),但空间复杂度是O(n),即如果n=10^7时,用4个字节的int型存储每个整数,那么需要的空间是(4*107)/210210=38MB,很显然超出了内存限制,而考虑实际n的大小限制和每个整数只会出现一次的限制,而所谓的排序也只是把从文件中的整数按在1-n内出现的顺序输出而已,因此只要对n之内出现的整数做一下标记最后输出标记过的整数就可以了,考虑到这,位向量(也叫位图)就成了比较合适的数据结构的选择,每个位的0、1值表示一个数字是否出现过,实现的时间复杂度为O(n),空间复杂度为O(n),考虑n取最大值10^7时,需要的空间为107/8/210/210=1.2MB,代码实现如下:
#include
#include
#include
#include
#define N 1000000
using namespace std;
void intSort(){
bitset numBits;
ifstream = testFile("/Users/smy/temp/data.txt");
string s;
int count = 0;
while(testFile >> s){
numBits[atoi(s.c_str())-1] = 1;
++count;
}
testFile.close();
for(int i=0;iif(numBits[i] == 1){
cout<1<int main(int argc, const char * argv[]) {
intSort();
return 0;
}
那么进一步考虑:
如果严格限制程序占用内存不能超过1M,应该怎么处理?
如果每个数最多出现10次,又应该如何改动算法?所用存储空间是怎样变化的呢?
在解决这个实际问题的方案中,我们看到了不同于比较排序的一种排序方式:位向量排序,而且从这个问题中也引出了作者的一些思考和对读者的启示:
第二章 啊哈!算法
- 给定一个最多包含m=40亿个随机排列的n=32位整数的顺序文件,找出一个不在文件中的32位整数。在内存足够的情况下如何解决该问题?如果有几个外部的“临时文件“可用,但是仅有几百字节的内存,又该如何处理?
- 将一个n元一维向量想做旋转i个位置(如对n=3元向量“abc“,当i=1时,结果为“bca“)
- 给定一个英文字典,找出其中的所有变位词集合。(变位词指的包含相同数量相同字母的单词,因为他们通过调整字母的顺序可以变为一个单词所以叫变位词,如”stop”和”tops”和”pots”)
对于问题1,首先明确一下肯定是存在整数不在文件中的,因为32位整数最多可以表示的数字是2^32=4294967296>4000000000
(1)在内存充足的情况下,可以用上述位向量的做法,初始化2^32个位为0,每读到一个数字就将对应下标的位数组的值置为1,最后统计值仍为0的元素就是没有出现过的整数,这样的做法时间复杂度是O(2^n),空间占用大约为O(2n/8)B,当n=32时占用空间大小约为2n/8/210/210=512MB
(2)内存不够有临时文件可以使用时,又该如何做呢?既然有临时文件可以用,那可以将原来大文件中的数字分配到临时文件中,当临时文件的规模够小时再采用上面说的位向量的算法。分配可以采用散列的方法,比如通过简单的模上临时文件的个数将结果相同的聚合到一个文件,判断缺失元素在哪个文件的方法是在向临时文件插入元素的时候统计每个临时文件的数字个数,因为我们是可以知道根据我们选定的散列算法在没有元素缺失情况下的数字个数k的,那么这样就只需要比较散列完成后临时文件的个数与k比较,如果比k小的话那么这个临时文件中就存在缺失元素,接下来以这个临时文件为主文件,再次采用前面所说的散列方法(此时散列算法的参数可能需要调整)如此迭代下去直到限制的内存空间足以承载包含缺失元素的文件规模时,回到第一种情况处理。 可以看到这也体现了二分搜索的思想,只不过是以文件包含的一系列整数为范围,用包含这些整数的文件表示这个范围,通过比较文件中整数个数与期望个数判定缺失元素所在文件范围,从而达到了缩小问题规模的效果将问题转换成内存充足的情况。
对于问题二,看起来比较简单,代码实现起来也不难,不过作者提供的几个巧妙的算法倒让人耳目一新。
我一开始的想法是作者所说的“杂技算法“,
void rotate(string str,int k){
int len = str.length();
if(len > 1 && k % len != 0){
//控制k在[1,len-1]内方便下标操作
k = (k > len) ? k%len : k;
k = (k < 0) ? k+len : k;
char curr_val = str[k]; //将被调整的值
int to_pos = 0; //将被调整到的位置
char to_val = str[to_pos]; //现在将被调整到的位置对应的值
int count = 0;
while (count != len) {
str[to_pos] = curr_val;
curr_val = to_val;
to_pos = (to_pos-k+len)%len;
to_val = str[to_pos];
++count;
}
}
cout<<str<
时间复杂度为O(n),额外的空间复杂度为O(1),比较理想的一种算法,不过先不要满足,再来看一种跌破眼睛却又不得不为之叫好的算法
void rotate2(string str,int k){
//假设k已经在[1,len-1]范围内,处理同上
reverse(str.begin(), str.begin()+k);
reverse(str.begin()+k, str.end());
reverse(str.begin(), str.end());
cout<<str<
具体来看这种算法的理论支持:可以把向量x看作两段子向量的集合即x=ab,其中b[0]=x[k],我们的目的是使x=ba,但b和a的内部不会变化,想象线性代数中我们会怎么做,yes:
ab -> a^b -> a^b^ ->(a^b^)^=ba,a^表示a的逆向量
尝试下用左右手模拟10元数组向上旋转5个位置的例子,相信你也会感叹它的神奇,虽然它要比上面那种算法稍慢一些,因为他做了中间转换,每个字符不是一次性地移到目标位置,但是这种拆分向量用数学运算简化算法的思想确实值得借鉴。
再来看问题三,千万不要想通过排列组合的方式去解决,要知道一个有26个字母的单词就可能有26!种排列方式。考虑一下变位词的定义,它们有共通点:组成字母集合相同。也就是说我们只要为每个单词选择标识和聚集相同标识的单词就好了。标识可以是按所包含字母顺序排列的单词,比如”pots”,”stop”,”tops”的标识都是 “opst”。
#include
#include
#include
#include
#include
#include
using namespace std;
map<string,vector<string> > getWords(){
map<string,vector<string> > words;
ifstream testFile("/Users/smy/wuque/study/ios/pro/demo/data.txt");
string s;
while(testFile >> s){
string preStr = s;
sort(s.begin(),s.end());
if(words.count(s)==0){
vector<string> lines;
lines.push_back(preStr);
words.insert(pair<string, vector<string>>(s,lines));
}else{
words.find(s)->second.push_back(preStr);
}
}
testFile.close();
return words;
}
void lookup(map<string,vector<string> > words,string word){
sort(word.begin(), word.end());
if(words.count(word) == 0){
cout<<"no results"<return ;
}
vector<string> lines = words.find(word)->second;
for(vector<string>::iterator iter = lines.begin();iter != lines.end();++iter){
cout<<*iter<int main(int argc, const char * argv[]) {
// intSort();
map<string,vector<string> > words = getWords();
string word;
cout<<"please input a word to lookup:";
cin>>word;
lookup(words, word);
return 0;
}
这一章围绕这三个问题,作者在给我们解决问题思路的同时也给我们留下更进一步思考的空间,而其中体现出的一些解决问题的模式很有借鉴意义:
第四章 编写正确的程序
这一章以二分搜索为例演示了算法验证的“三步曲“:初始化、保持和终止
程序员也应该充当测试人员的一部分角色,证明所用算法的正确性(实际中并不用一字一句地去证明),用全面的测试用例验证程序结果,尽早在自己手中发现Bug,虽然实际工作中程序员不需要也没有时间像测试人员一样逐个地去考察程序的各个指标,但一些显而易见的逻辑漏洞和性能问题最好还是在提测之前主动check一下才好。由于实际工作中,提测、测试、提Bug单的流程会浪费一定的时间,而且突然的一个Bug单也会打乱现在工作的节奏,所以这样可以在一定程度上缩短程序开发的总周期,也可以增加别人对自己编程能力的肯定。试想如果你不时就会听到测试人员对某位开发人员说“某某地方程序结果又有些不对啊“,想必这将会大大降低对这位开发者的信任程度。
以上为本书的基础篇,通篇读下来可以看到作者安排章节的顺序和我们日常产品开发的流程是一致的:
分析问题->设计算法->结合语言选择合适的数据结构编程实现->测试->调试
保证流程的规范性和每段流程的严谨性必定会大幅度提高程序的质量,减少后期维护的投入成本,在考虑输出/投入比的前提下千万不要理会那些“只要程序正常工作怎么改都行“的催促,在每个阶段都保持程序的美观、可扩展性、健壮性等等都是一名优秀程序员应该具备的素质。
性能的重要性不言而喻,但始终牢记“过早的优化是万恶之源“,When “I feel the need …the need for speed”,then just do it and do well.
第六章 程序性能分析
以一个大牛Andrew Appel在解决一个“重力场中多个物体相互作用的n体问题“的实际经验,描述了性能调优的几个方面:算法和数据结构、算法调优、数据结构重组、代码调优、系统软件、硬件等等。
第七章 粗略估算
估算在建筑、机械等工程方面的应用比比皆是,几乎成为了从业者的一项必备技能,但显然这项技能在软件工程领域被很多人忽视了。估算可以做什么?如果你会时间复杂度和空间复杂度的估计就能仅从代码分析出不同算法的优劣,如果你知道你的计算机每秒钟可以执行多少条指令你甚至可以知道程序大概的运行时间,如果你知道一个项目的难度就会知道如何合理分配人力和资源安排项目的进度……
第八章 算法设计技术
像第二章提到的一样,算法上的灵机一动也许就会让程序更加高效,算法设计是一门技术,也是一门艺术,我们把想法落实在代码,然后从空间、时间、简洁性等等去分析程序,一点点地去调整去优化,直到达到我们满意的效果。
问题:计算n元整数向量中连续子向量的最大和,比如[3,-2,3,-1]的最大连续子向量的和4,对应向量为[3,-2,3].
解法一:一个一个地求每个子向量的和,记录最大值
int maxSum(vector<int> nums){
int len = nums.size();//假定len>1
int maxSum = nums[0];
for(int i=0;ifor(int j=i+1;jint tempSum = 0;
for(int k=i;k<=j;++k){
tempSum += nums[k];
}
maxSum = max(maxSum,tempSum);
}
}
return maxSum;
}
这应该是最粗鲁最挫的一种方式了,O(n3)的时间复杂度,而其中求和的n次内循环其实是可以避免的
解法二:在以[i]开头的向量中依次增加后面的元素值
int maxSum(vector<int> nums){
int len = nums.size();//假定len>1
int maxSum = nums[0];
for(int i=0;iint tempSum = 0;
for(int j=i+1;jreturn maxSum;
}
解法三:还是消除求和的内循环,不过是通过向量和之差计算
int maxSum(vector<int> nums){
int len = nums.size();//假定len>1
int tempSumArray[len+1] = {0};
for(int i=0;i1] = tempSumArray[i-1] + nums[i];
}
int *tempArray = tempSumArray + 1;
int maxSum = nums[0];
for(int i=0;iint tempSum = 0;
for(int j=i;j1];
maxSum = max(maxSum,tempSum);
}
}
return maxSum;
}
解法二和解法三都达到了将时间复杂度缩减为O(n2)的效果,解法二稍微好一点,解法三还引入了额外的O(n)空间。不过上面这三种算法都是对所有的子向量求和取最大值,而实际上通过遍历整个向量可以筛选掉一些不可能作为目标向量的向量,即求所有以每个元素作为目标向量的元素的向量的和的最大值,yes,动态规划的思想
int maxSum4(vector<int> nums){
int len = nums.size();//假定len>1
int maxSum = nums[0];
int* sumArray = new int[len]{0};
sumArray[0] = nums[0];
for(int i=1;iint sum1 = sumArray[i-1] + nums[i];
int sum2 = nums[i];
sumArray[i] = max(sum1,sum2);
maxSum = max(maxSum,sumArray[i]);
}
return maxSum;
}
算法四的时间复杂度为O(n),从数量级来看应该是最低了,美中不足的是它还引入了额外的O(n)的空间,如何在时间和空间折中就要根据实际条件而论了。
第九章 代码调优
这一章作者以一个实际的图形分析程序的调优和整数取模、函数宏和内联代码、顺序搜索、二分搜索的搜索问题展示了调优的一些技巧,这些问题实现起来都比较容易,简要记录一下调优的过程。
- 整数取模问题:%运算的开销比一般的加减运算要高1个数量级的时间,所以在不会让代码十分复杂而又需要性能调优的话可以尝试用等价的代数表达式替换模运算:
k=(i+j)%len
可以转换为k=i+j;while(k>=n){k-=n;}
优化效果取决于j的值,当j=1时,算法是顺序访问内存的,根绝缓存的预见性,可以提前取出下个要操作的值,运算时间主要在模运算上,而当j过大时,每次从数组中拿值时内存都要重新加载到高速缓存中,时间大多消耗在这里,模运算的消耗相比就小很多了。因此实际优化的时候还要考虑对应的参数和机器类型、配置。- 函数、宏和内联代码:一般来就运算时间来说函数>內联代码>宏
- 顺序搜索:添加哨兵元素合并测试条件和展开循环获得CPU多通道加速
- 二分搜索:通过添加哨兵元素、改变不变式为
x[l]
第十章 节省空间
上一章的优化主要是对时间优化而言的,这一章重点在于优化空间使用。
假设一个200x200的地图中有2000个点存在住户i,1<=i<=2000,如何存储这2000个住户的位置?
方案1:最直接的就是用一个二维数组,所有住户在的位置置,其他置0,这样需要200x200x4=160000B=156.25KB
方案1明显浪费了很多无用的空间存储根本就不需要的值,对于这类稀疏数据的存储,可以用专门的数据结构存储
方案2:类似散列的存储方式,可以把x坐标作为散列值,对应一个包括y坐标和编号的列表:
0->2,17->3,14
1->3,12
…
2000
这样就只存储了需要的信息+多余的指针,空间大概为2000*12+800=24800B=24KB,不过这种存储方式在查找某位置的元素值时确实要比方案一直接用数组表示慢一些,因为每次查找的时候都要对x链接的列表逐个查找
以上是性能篇的大概内容,性能的重要性不言而喻,即使随着硬件技术的发展硬件变得越来也便宜,作为程序员也应该保持对性能的追求,追求用户的极致体验。对性能的预估、测试、监控、优化应该是优秀程序员必备的技能,在大型团队中,在保证项目可用性和开发效率的情况下,可能还会设立单独的性能调优小组专门负责性能的测试和优化。
这一部分是建立在第一部分和第二部分的基础上,讲解了几个比较常用的算法,由于这些算法比较普遍,在一般的辅导书上也都有所讲解,这里不再按每一章的内容依次记录,只是把主要的内容和需要注意的地方总结一下。
以上只是在读书的时候做的一些笔记和总结,现在对这本书还远远没有吃透,计划先去补充一些算法知识,然后回过头来再过一遍这本书,弄懂每一道习题,学到真正的“编程珠玑”