什么样的代码才是好的代码?这是一个将永远继续下去的话题。就如艺术品从来都不仅仅是艺术,艺术级的代码从来都不只是技术问题。什么样的代码才是称得上“艺术级”的代码?没有标准答案。但也许如下几条或许能得到大多数人的认同:
(1)简洁的,但简洁的背后蕴含深刻的道理。这里的简洁是指最后的呈现方式是简洁的,但简洁绝不仅仅是简单,而是一种将千头万绪料理得整整齐齐,清爽直接的力量。
(2)复杂的,这貌似是和简洁矛盾的,但其实是统一的。这里的复杂指的是处理复杂的问题,解决复杂的情况,化千丝万缕为整洁归一,是不是和简洁是统一的?
(3)高效的,化笨拙迟缓为高效灵动,这本身就是一件需要智慧的事。
(4)优雅的,优雅的代码就如优雅的文字,本身可以赏心悦目的。
(5)哲理的,有的时候,处理问题不仅仅是技术,而是一种处理事物的哲学——“贪心”“分治”等等都是一种处世哲学。
(6)数学的,这里不是指处理的是数学问题,而是指处理算法,毕竟最坚实的技术还是需要数学基础的。
这么多的要求加起来,真正符合这些条件的代码有吗?不知道。但我们仍然有许多接近这些标准的,在某种意义上堪称“艺术级”的代码。我将我所喜爱的一些代码,分为【数学篇】【比特篇】【字符篇】【智能篇】【杂篇】和大家分享:
【数学篇】
1.埃托尔斯筛法:素数生成
素数历来都是数学的热点,而实践中素数也有着广泛的用途。素数生成是诸多素数应用的基础步骤,而埃托尔斯筛法是目前已知的最高效素数生成算法,不带之一。而其优美而富含哲理的处理方式也值得深思,我们仍然能清晰得感受到千年前古人的智慧:
void genePrime(int *prime, int n)
{
/*初始化操作*/
for(int i=1; i<n; ++i)
{ prime[i] = 1; }
prime[1] = 0;
prime[2] = 1;
/*埃托尔斯筛法*/
for(int i=2; i<n; ++i)
if( prime[i]==1 )
for(int j=i+i; j<n; j+=i)
prime[j] = 0;
}
素数生成的一个典型应用就是素因子分解:任何一个数都可以分解为素数的乘积,且分解唯一。这个有用的性质亦有诸多应用,密码学中尤其广泛。但如何高效得分解呢?这是一个复杂的问题,但借助埃托尔斯筛法,这个问题也可以得到优美的解决:
void geneFactor(int *factor, int n)
{
int *prime = (int *)malloc((n+1)*sizeof(int));
genePrime(prime,n+1);
int j = -1;
int t = n;
for(int i=0; i<=n; ++i)
if( prime[i] == 1)
if( t % i == 0 )
{
t = t / i;
factor[++j] = i--;
}
}
2.辗转相除法:最大公约数和最小公倍数问题
求最大公约数和最小公倍数是一个基本数学问题,而辗转相除法这个历经千年的算法再一次向世人证明了古人的智慧。这一算法堪称化繁为简的典范,透过人们不太在意的一条性质——两数的相除的余数仍然应该是最大公约数的倍数,高效简洁地解决了问题:
int geneGCD(int m,int n)
{
int r = m%n; /*求得余数*/
while( r!= 0 )
{
m = n; /* 除数 成为新的 被除数*/
n = r; /* 余数 成为新的 除数 */
r = m%n;
}
return n;
}
int geneLCM(int m, int n)
{
int GCD = geneGCD(m,n);
return (m*n)/GCD;
}
当然必须隆重推荐两个帅地爆掉的写法:
int gcd(int m,int n)
{ return n ? gcd(n,m%n):m; }
int lcm(int m,int n)
{ return (m*n)/gcd(m,n); }
3.快速排序:分治思想的范例
排序是一个数学问题吗?是的,数值的大小是一个数学问题,排列数值的大小也是数学问题。而如果说排序算法中是否有最高效的算法要视不同情形而定,但如果要说最经典的排序算法,那么一定属于冒泡和快排。冒泡是因为简单而闻名,快排因为其高效而著称:
void qsort(int *arr,int n)
{
if( n > 1 )
{
int low = 0;
int high = n-1;
int pivot = arr[low];/* 取第1个数作为中轴,进行划分 */
while( low < high )
{
while( low < high && arr[high] >= pivot )
--high;
arr[low] = arr[high];
while( low < high && arr[low] <= pivot )
++low;
arr[high] = arr[low];
}
arr[low] = pivot;
qsort_op(arr,low);
qsort_op(arr+low+1,n-low-1);
}
}
对有序数组,可以进行二分查找,以logn的时间复杂度内取得敌将首级,和快速排序一起成为远古时期最佳数据管理拍档:
int binarysearch(int *arr,int length, int key)
{
int low = 0;
int high = length -1;
while( low <= high)
{
/******************************************************/
/*这是那个著名的导致只有10%专业程序员才能正确实现的Bug*/
/* int mid = (low + high)>>1; */
/*正确写法可以是: */
/* int mid = low + ((high - low)>>1); */
/******************************************************/
int mid = (unsigned)(low+high)>>1;
if( arr[mid] == key )
return mid;
else if( arr[mid] > key )
high = mid - 1;
else
low = mid + 1;
}
return -low;
}
5.快速乘方和快速加法:极致的力量
对于求n的k次方这样的简单问题,在不考虑溢出的情况下,一般会被这样处理:
int pow(int n, int k)
{
int result = 1;
for(int i=k; i>0; --i)
result *= n;
return result;
}
然而这样的运算却是昂贵的,例如当k=30的时候,这个乘方运算需要做30次乘法,而乘法本身就是昂贵的,那么更加高效的做法是怎样的呢?下面的这种乘法,在k=32时,只做5次乘法,k=64的时候只做6次乘法,接近logk次的乘法运算量:
int pow(int num,int pow)
{
int sum = 1;
int tmp = num;
while( pow )
{
if( pow&1 == 0 )
{
tmp = tmp*tmp;
pow = pow>>1;
}
else
{
sum *= tmp;
--pow;
}
}
return sum;
}
int sum=0;
for(int i=0; i<20; ++i)
{ sum += arr[i]; }
但这样的运算极其低效的,因为i<20判断运算和++i运算的运算量反而多于了求和的+本身,极致的做法是元编程:
template <int Dim,typename T>
struct Sum{
static T sum(T *arr){
return (*arr) + Sum<Dim-1,T>::sum(arr+1);
};
};
template<typename T>
struct Sum<1,T>{
static T sum(T *arr){
return *arr;
};
};
/*使用示例*/
int arr[20] = {0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
10,11,12,13,14,15,16,17,18,19};
int sum = Sum<20,int>::sum(arr);
这样的元编程带来的速度是可观的,但也出现了一个致命的硬伤,那就是必须知道数组的大小,而且大小需要是常数,这个苛刻的条件在某些时候是不可忍受的。于是我们利用数的二进制特点进一步强化这个求和,从而完成任意长度数组的求和:
int sum(int *arr, int n)
{
int result = 0;
while( n > 0 )
{
int t = n&(-n);/*取出最右侧1*/
switch(t)
{
case 1:result += Sum<1,int>::sum(arr+n-t);break;
case 2:result += Sum<2,int>::sum(arr+n-t);break;
case 4:result += Sum<4,int>::sum(arr+n-t);break;
case 8:result += Sum<8,int>::sum(arr+n-t);break;
case 16:result += Sum<16,int>::sum(arr+n-t);break;
case 32:result += Sum<32,int>::sum(arr+n-t);break;
case 64:result += Sum<64,int>::sum(arr+n-t);break;
default:
while(t >= 64)
{
result += Sum<64,int>::sum(arr+n-64);
n -= 64;
t -= 64;
}
}
n = n-t;/*将最右侧1置零*/
}
return result;
};
6.浮点数的任意次幂:数学的光辉
这个问题需要一步一步深入,循序渐进。首先,计算一个数的整数次方是比较简单的,例如2次方、5次方等、n次方等都是可以直接通过乘法运算得到的(技巧可参见上述的快速乘方方法),但是如果你叫你运算50的0.5次方呢?微积分还有印象的同学应该会马上想到“泰勒公式”—— 一种将任意函数展开到幂函数的方法:
其实求平方根、sinx、cosx、lnx等一系列的数学运算基本都可以通过泰勒公式来计算,下面我们先看看泰勒公式如果计算平方根的:
double sqrt_Tylor(double num)
{
double sum = 0;
double term = 1;
double factorial = 1;
double coeff = 1;
double xpower = 1;
int times = 1;
while( num >= 2)
{
num /= 4;
times = times << 1;
}
for (int i=0; i<100; ++i)
{
sum += term;
coeff *= (0.5 - i);
xpower *= (num - 1);
factorial *= ( i + 1);
term = (coeff * xpower) / factorial;
}
return sum*times;
};
但是我们还有更好的方法吗?如果你熟悉泰勒公式,那么你就知道泰勒公式的收敛速度是比较慢的,所以要得到比较精确的结果需要大量的运算(上述代码中是100次迭代)。对于求平方根这个问题而言,牛顿迭代法是更好的选择:
而牛顿迭代法的实现也更加简单:
double sqrt_Newton(double a){
double x = a;
for(int i=0;i<8;++i)/*牛顿迭代算法8次*/
x=(x+a/x)/2;
return x;
};
但是还可以更好吗?可以!牛顿迭代法的收敛速度取决于最先的猜测值是否接近平方根,显然应该有比a本身更好的猜测值;另外除法是一种昂贵的运算,可以用乘法代替吗?下面看看QuakeIII(著名游戏引擎)中的魔幻代码吧:
float sqrt_magic(float number)
{
const float f = 1.5F;
float x = number * 0.5F;
long i = * ( long * ) &number; /*将float当作long强行取出*/
i = 0x5f3759df - ( i >> 1 ); /*获得一个比较理想的猜测值*/
float y = * ( float * ) &i; /*转换回float*/
for(int i=0; i<3; ++i)
y = y * ( f - ( x * y * y ) );/*牛顿法求1/√number*/
return number * y; /* number *1/√number =√number */
};
关于这段代码,最神奇是0x5f3759df这个魔法数字,要理解这个数字你需要对IEEE754浮点数格式十分了解,而关于这个算法,也有相应论文:http://www.matrix67.com/data/InvSqrt.pdf。这个算法的收敛速度十分之快,快于标准库函数的sqrt。
现在我们能运算整数次幂和0.5次方,那么任意次方怎样运算呢(你可以尝试一下新的pow函数了)?方法如下:
float sqrt_magic(float number)
{
const float f = 1.5F;
float x = number * 0.5F;
long i = * ( long * ) &number;
i = 0x5f3759df - ( i >> 1 );
float y = * ( float * ) &i;
for(int i=0; i<3; ++i)
y = y * ( f - ( x * y * y ) );
return number * y;
};
float pow_int(float num,int pow)
{
float sum = 1.0;
float tmp = num;
while( pow )
{
if( pow&1 == 0 )
{
tmp = tmp*tmp;
pow = pow>>1;
}
else
{
sum *= tmp;
--pow;
}
}
return sum;
};
float pow_op(float num, float pow)
{
/*先计算整数次幂*/
int integer = (int)pow;
float result_int = pow_int(num,integer);
/*再计算小数次幂*/
float fraction = pow - integer;
float result_float = 1.0;
float result_tmp = num;
while( fraction != 0.0)
{
result_tmp = sqrt_magic(result_tmp);
fraction *= 2.0;
if( fraction >= 1.0)
result_float *= result_tmp;
int int_tmp = (int)fraction;
fraction = fraction - int_tmp;
}
return result_int*result_float;
};
【比特篇】
——汇编时代的前辈们留下了许多有用的技巧,至今这些技巧在追求高性能的领域依然发挥着关键的作用
1.异或交换:无空间交换
许多初学计算的人会被告知,交换两个值一定需要一个中间临时空间,就算是直接交换地址,那么编译器再帮你交换地址的时候也需要临时中间变量!但这一说法其实是错的!在多年前,汇编语言盛行的年代,高手们乐于创造很多所谓bit hack,而其中就有一种无中间变量进行交换的方法。这种方法到如今已经没有太多实际意义,但其内涵的思想和突破传统极限的勇气是值得思考的:
一般我们的交换代码是这样写的:
void swap(int a, int b)
{
int tmp = a;
a = b;
b = tmp;
}
但我们其实可以这样写:
void swap(int a, int b)
{
a = a + b;
b = a - b;
a = a - b;
}
前述加减运算其实存在溢出风险,更安全、更高效的方式是采用异或:
void swap(int a, int b)
{ /* ^ 可以换成 + - */
a = a ^ b;
b = a ^ b;
a = a ^ b;
}
2.判断奇偶:你需要看的其实只是那一个bit
许多人判断奇偶的时候都会敲下诸如下例的代码,对2求余简单轻松:
bool isEven(int a)
{
if( a%2 == 0 )
return 1;
else
return 0;
}
但其实你的思维也许还不属于计算机世界,在01交织的世界里,其实最后一位是否是1足以判断奇偶:
bool isEven(int a)
{
if( a&1 == 0 )
return 1;
else
return 0;
}
而其实你还可以写得更加简洁清爽:
bool isEven(int a)
{
return ( a&1 == 0 );
}
3.比特数组:很多时候你需要的只是1/32或者1/64假想这样一个场景,你需要存储100W条8位电话号码,要求排序。你也许会想到用用64位long或者long long来存储数据,然后写上一个冒泡或者快排,问题也就解决了。但可以有更好的方式,采用bit存储电话号码,一个32位int就可以存32个电话号码了,每个bit代表一个号码,1表示号码存在,0表示不存在。这个问题可以引申为一类无重复主键数据的高效处理方式,这是现在bit的一种典型应用。而所有bit操作都需要如下基本操作:
(1)测试第n位是否是1bool testN( int x, int n)
{
return (x & (1<<n));
}
(2)设置第n位为1
void setN(int &x, int n)
{
x = x | (1<<n);
}
(3)设置第n位为0
void unsetN(int &x, int n)
{
x = x & ~(1<<n);
}
(4)取反第n位(0变1,1变0)
void toggleN(int &x, int n)
{
x = x ^ (1<<n);
}
(5)设置最右边的1为0
void offright(int &x, int n)
{
x = x & (x-1);
}
(6)设置最右边的0为1
void offright(int &x, int n)
{
x = x | (x+1);
}
4.分治计数:5次运算计算出32位的数中1的个数
这是一道出现过无数大公司面试题中的经典题目,google、微软都考过。初看是一道没事找事做的无聊题目,但这却是也是一个诞生于实际问题的题。初看几乎不可能,但如果你熟悉分治思想且熟悉比特操作,那么这道题将不难解决。而这道题本身最早记录于《编程珠玑》,而后几乎成为分治算法的典范流传开来:
你也许能够很快完成一次32次操作的算法:
int count (unsigned int a)
{
int countN = 0;
for( int i=0; i<32; ++i)
if( testN( a, i ) ) /*借助上述bit操作的testN*/
++countN;
return countN;
}
但是却有一个精巧到极致的算法,只需要5次运算即可:其中涉及到许多魔法数字,但是你将其展开为二进制将能够迅速理解为什么会这样做,0x55555555是01010101010101010101010101010101,而0x33333333是00110011001100110011001100110011,现在你能理解这个算法了吗?
int count(unsigned int a)
{
int t[5] = {0x55555555,
0x33333333,
0x0F0F0F0F,
0x00FF00FF,
0x0000FFFF};
for(int i=0; i<5; ++i )
a = (a & t[i]) + ((a & ( t[i]<<(1<<i) ))>>(1<<i));
return a;
}
5.查看最右边1的位置
这个题目没有前两题那么耀眼,但却依然有广泛的应用——求最大数,当然这需要和比特存数方法(每个比特代表一个数,其下标就代表其值,如001101中最左边的1代表5)联系起来用。理论上最32位数找到最左边的1可能需要 32/2 次寻找,但其实3次运算就够了:
int getfirst(unsigned int a)
{
int t = a - 1;
t = a ^ t;
t = a & t;
return t;
}
甚至一次就够了:
int getfirset(unsigned int x)
{
return x & (-x);
}
未完待续……