什么样的代码才是好的代码?这是一个将永远继续下去的话题。就如艺术品从来都不仅仅是艺术,艺术级的代码从来都不只是技术问题。什么样的代码才是称得上“艺术级”的代码?没有标准答案。但也许如下几条或许能得到大多数人的认同:
(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; };
而牛顿迭代法的实现也更加简单:
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); }
未完待续……