性能优化
第1节中已经提到过快速原则1:尽量减少对数据的复制。在这一节将讨论更多的提高程序执行速度的方法。
快速原则2:尽量利用先前算出的结果。
比如说计算数列:1,1,2,3,5,8,13„„的第n位,即f(0)=f(1)=1,f(n)=f(n-1)+f(n-2),最直观的代码就是这样:
int f(int n){
if(n==0 || n==1)return 1;
else return f(n-1)+f(n-2);
}
但是这样做效率极低,比如要算f(10),就要算f(9)+f(8),而算这里的f(9)又要算f(8)+f(7),所以f(8)的值就重复计算了,算f(8)又要算f(7)+f(6),所以f(7)重复计算了„„最后出现无数的重复计算,尤其是f(0)。好的做法是不用递归,每个值只算一次:
int f(int n){
int* p=new int[n+1];
p[0]=p[1]=1;
for(int i=2;i<=n;i++)
p[i]=p[i-1]+p[i-2];
int t=p[n]; //用临时变量保存一下结果,因为delete p后p就不能用了
delete p;
return t;
}
这种思想叫做“动态规划”。凡是具有f(n)=F(f(n-1),f(n-2),…,f(0))这种形式的函数关系,都可以考虑用动态规划法。其中F可以是任意函数,f不一定要一元,可以是多元函数,但是参数必须是整数。
动态规划的思想有很大的实用价值,不过,尽量利用先前算出的结果的原则更具有普遍性。
上面的例子还可以再改进,我们注意到,当算完f(2)之后,f(0)的值就不会再用到了,那么可不可以把f(2)的计算结果直接保存到f(0)所在的位置呢?同样,f(3)的计算结果也可以直接保存在f(1)里„„最后我们只需要f(0)和f(1)这两个位置就够了:
int f(int n){
int p[2]={1,1};
for(int i=2;i<=n;i++)
p[i%2]=p[0]+p[1];
return p[n%2];
}
这个改进,在时间上不会快多少,但是它节省了空间,所以,尽量不让用不到的变量继续占有空间(省空间原则)。
快速原则3:尽量减少对用不到的数据的计算。
考虑一个函数int f(int n),我们且不管这个函数具体是算什么,只知道它每次计算都需
28 / 46
要很长时间。而在我们的程序里,只需要用到n<100的情况,而且有多次会用到相同的n,那么根据上一个原则,我们可以预先把n<100的结果全部算好,把结果保存在一个数组里,要用到的时候直接到数组里读取就可以。这样对每个n都只算一次,避免重复的计算。这种思想叫“预加载”。
但是,也许我们并不会用到n<100的所有结果,可能我们的程序整个运行过程只用到了n=1,4,23,46,53,79这几种情况,但是我们却把n<100的全算了,所以这是一种浪费。所以我们采取延迟计算的方式:
bool flag[100];
int result[100];
一开始所有的flag都设为false,表示尚未计算。需要用到某个f(n),就用以下代码:
int F(int n){
if(!flag[n]){ //如果没有现成结果,就计算结果
result[n]=f(n);
flag[n]=true;
}
return result[n];
}
这种思想叫“延迟加载”。预加载体现了原则2,延迟加载体现了原则3,两种思想结合,就得到上面的最佳方案。
可以把flag和result定义为函数内部的静态变量:
int F(int n){
static bool flag[100];
static int result[100];
„„
}
静态变量本质上还是全局变量,但是只有函数F才能访问它们,这样就避免了可能和其它变量同名的问题。
快速原则4:尽量减少循环内的重复计算。
比如我们有一个已知半径的球ball1,现在要计算这个球在多种不同密度下的质量:
for(int i=0;i<n;i++)
mass[i]=density[i]*ball1.volume();
其中volume()函数就是算4*PI*r*r*r/3。
但是在这个循环里,ball1.volume()的值被反复地计算,这是浪费,应该这样:
double v=ball1.volume();
for(int i=0;i<n;i++)
mass[i]=density[i]*v;
这样就只算一次。其实这个原则和原则2没有太大差别,只是这个比较具体一些,比较容易做到。有时候即使你自己没做到,编译器也会帮你优化。但是,像volume这样的函数调用,编译器无法肯定每一次调用都会出来同样的结果,所以未必会进行优化。
快速原则5:尽量减少不必要的函数调用,或使用内联函数。
其实第五节谈内联函数的时候已经说到。内联函数性能比普通函数好就是因为它不需要真正的函数调用的开销。不过,内联函数里面不能有复杂结构,比如循环、条件结构,
29 / 46
也不能有递归,否则,编译器会把这个函数编译成普通函数,而无视inline修饰符。
快速原则6:尽量使用局部变量。
还是考虑求和函数:
int s;
int sum(int a[],int n){
s=0;
for(int i=0;i<n;i++)
s+=a[i];
return s;
}
和另一种方式
int sum(int a[],int n){
int s=0;
„„
}
后一种可能性能更高,因为s是局部变量,生存期短,编译器可能把它优化到CPU的寄存器里储存,而不是储存在内存里,而CPU寄存器的访问速度要比内存快得多。但如果s是全局变量,整个程序运行期间它都存在,寄存器的数目是有限的,不可能让一个变量长期占着不放,所以s只能储存在内存。
不过如果你对局部变量取了地址:
int s=0;
int* ps=s;
那么s就只能储存在内存里了,因为寄存器里的变量是无法取地址的。
快速原则7:提高数据访问的时空局部性。
时空局部性,就是指在短时间内访问的数据地址基本集中在一个小的区间内。比如遍历一个二维数组a,可以这样:
for(int i=0;i<n;i++)
for(int j=0;j<n;j++)
a[i][j]=0;
也可以这样:
for(int j=0;j<n;j++)
for(int i=0;i<n;i++)
a[i][j]=0;
唯一的不同就是i和j在循环嵌套中的次序不同。那么第一种方法有更好的时空局部性,比如对于3*3的矩阵,按第一种的访问次序是123456789,按第二种的访问次序是147258369,更具有跳跃性。
为什么内存访问有跳跃性不好?因为计算机的内存是多级缓存机制的。比如一块1M大的内存,配上一个1k大的缓存,缓存的访问速度比内存快得多,假如我们集中访问某个地址区间中的内存地址,那么计算机就自动把这个地址区间的数据复制到缓存里,让我们直接访问缓存就可以,这样就提高了数据访问的速度。这其实跟前面的预加载是同样的思想。但是,如果我们跳跃式地访问内存,尤其是跳跃的幅度超过了缓存的大小的时候,计算机就根本不知道该把哪一块内存加载来缓存里,刚加载一块地址区间进来,马上又跳到
30 / 46
别的地址区间去访问了。所以提高数据访问的时空局部性可以提高性能。实际的缓存机制比这里说的要复杂得多,但是基本思想是一致的。
最后强调一下,决定程序运行速度最根本的因素是算法与数据结构,而不是编程语言或者是编译器的优化。只是算法与数据结构太博大精深,这里就不作讨论了。
转自:《高效编程十八式》