【算法设计与分析】经典代码赏析【1】

  什么样的代码才是好的代码?这是一个将永远继续下去的话题。就如艺术品从来都不仅仅是艺术,艺术级的代码从来都不只是技术问题。什么样的代码才是称得上“艺术级”的代码?没有标准答案。但也许如下几条或许能得到大多数人的认同:

(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);  
    }  
}





4.二分查找:排序的最佳拍档

   对有序数组,可以进行二分查找,以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位是否是1
bool 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);
}

未完待续……







 
 
 
 
 
 

                
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值