9.4.3 素性测试
本节将考察确定一个大数是否是素数的问题。将给出一个可以测试素性的多项式时间算法。如果这个算法宣称一个数不是素数,可以肯定这个数不是素数。如果该算法宣称一个数是素数,那么,这个数将很可能(以高的概率)但不是100%是素数。错误的概率不依赖于被测试的特定的数,而是依赖于由算法做出的随机选择。因此,这个算法偶尔会出错,不过我们将会看到,可以让出错的几率任意小。
算法的关键是著名的费马(Fermat)定理。
定理9.10(费马小定理) 如果P是素数,且0<A<P,那么A^(P-1)≡1(mod P)。
假如A是整数,P是质数,且A,P互质(即两者只有一个公约数1),那么A的(P-1)次方除以P的余数恒等于1。这个算法偶尔会出错,但问题是它总出一些相同的错误。换句话说,存在N的一个固定的集合,对于这个集合该方法行不通。可以尝试将该算法按下面的方式随机化:随机取1<A<N-1。如果A^(N-1)≡1(mod N),声明N可能是素数,否则声明N肯定不是素数。
虽然这看起来没有问题,但是却存在一些数,对于A的大部分选择它们甚至可以骗过该算法。一种这样的数集称为Carmichael数(Carmichael number),这些数不是素数但对所有与N互素的0<A<N却满足A^(N-1)≡1(mod N)。最小这样的数是561。
定理9.11 如果P是素数且0<X<P,那么X^2≡1 (mod P)仅有的解为X=1,P-1。
这种策略在下面以伪代码的形式给出。
/**
* Function that implements the basic primality test.
* if witness does not return 1. n is definitely composite.
* Do this by computing a^i (mod n) and Looking for
* non-trivial square roots of 1 along the way.
*/
HugeInt witness( const HugeInt & a, const HugeInt & i, const HugeInt & n )
{
if( i == 0 )
return 1;
HugeInt x = witness( a, i / 2, n );
if( x == 0 ) // If n is recursively composite, stop
return 0;
// n is not prime if we find a non-trivial square root of 1
HugeInt y = ( x * x ) % n;
if( y == 1 && x != 1 && x != n - 1 )
return 0;
if( i % 2 != 0 )
y = ( a * y ) % n;
return y;
}
/**
* The number of witnesses queried in randomized primality test.
*/
const int TRIALS = 5;
/**
* Randomized primality test.
* Adjust TRIALS to increase confidence level.
* n is the number to test.
* If return value is false, n is definitely not prime.
* If return value is true, n is probably prime.
*/
bool isPrime( const HugeInt & n )
{
Random r;
for( int counter = 0; counter < TRIALS; couter++ )
if( witness( r.randomInt( 2, ( int ) n - 2 ), n - 1, n ) != 1 )
return false;
return true;
}
素性测试的随机算法非常重要,因为它们明显比最佳非随机算法快。而且,非随机算法偶尔会产生错误的判断,但这种机会非常小,是可忽略的。
9.5 回溯算法
我们将要考察的最后一个算法设计技巧是回溯(backtracking)。许多情况情况下,回溯算法相当于穷举搜索的巧妙实现,但性能一般不理想。不过,情况并非总是如此,即使是如此,在某些情形下它相对于蛮力穷举搜索,工作量也有显著的节省。当然,性能是相对的:对于排序而言,Ο(N^2)的算法是相当差的,但对旅行商(或任何NP完全)问题,Ο(N^5)算法则是里程碑式的结果。
回溯算法的一个具体例子是在一套新房子内摆放家具的问题。存在许多尝试的可能性,但一般只有一些具体要考虑的。开始什么也不摆放,然后是每件家具被摆放在室内的某个位置。如果所有的家具都已摆好而且户主都很满意,那么算法终止。如果摆到某一步,该步之后的所有家具摆放方法都不理想,那么必须撤销这一步并尝试另外的摆放方法。当然,这也可能导致另外的撤销,等待。如果我们发现撤销了所有可能的第一步摆放位置,那么就不存在满意的家具摆放方法。否则,最终将终止在满意的摆放位置上。注意,虽然这个算法基本上是蛮力的,但它并不直接尝试所有的可能,因为令人讨厌的摆放的子集是已知的。在一步内删除一大组可能性的做法叫做剪裁(pruning)。
9.5.1 公路收费点重建问题
设给定N个点p1,p2,…,pN,它们位于x轴上。xi是pi点的x坐标。进一步假设x1=0,并且这些点从左到右给出。这N个点确定在每一对点间的N(N-1)/2个(不必是唯一的)形如|xi-xj|(i≠j)的距离d1,d2,…,dN。显然,如果给定点集,那么容易以Ο(N^2)时间构造距离的集合。这个集合不是排好序的,但是如果愿意花Ο((N^2)logN)时间整理,那么这些距离也可以被排序。公路收费点重建问题(turnpike reconstruction problem)是从这些距离重建一个点集,它在物理学和分子生物都有应用。令D是距离的集合,并设|D|=M=N(N-1)/2。作为例子,设
D={1,2,2,2,3,3,3,4,5,5,5,6,7,8,10}
由于|D|=15,因此以置x1=0开始。显然,x6=10,因为10是D中最大元素。将10从D中删除,我们放置的点和剩下的距离如下图所示。
剩下的距离中最大的是8,这就是说,或者x2=2,或者x5=8。由对称性可以判断这种选择是不重要的,因为或者两个选择都产生解(它们互为镜像),或者都不产生最终的解,所以可置x5=8而不至于影响问题的解。然后从D中删除距离x6-x5=2和x5-x1=8,得到
下一步是不明显的。由于7是D中最大的数,因此或者x4=7,或者x2=3。如果x4=7,那么距离x6-7=3和x5-7=1也必须出现在D中,它们也的确存在D中。另一方面,如果置x2=3,那么3-x1=3和x5-3=5就必须在D中,这些距离也在D中。因此我们选哪种都是对的。这样,尝试其中的一种看它是否产生问题的解。如果不相,那么退回来再尝试另外的选择,尝试第一个选择,x4=7,得到
此时,我们得到x1=0,x4=7,x5=8和x6=10。现在最大的距离是6,因此或者x3=6,或者x2=4。但是,如果x3=6,那么x4-x3=1,这是不可能的,因为1不再属于D。另一方面,如果x2=4,那么x2-x0=4和x5-x2=4,这也是不可能的,因为4只在D中出现一次。因此,这个推导思路得到不解,我们需要回溯。
由于x4=7不可能产生解,因此尝试x2=3。如果这也不行,那么停止计算并报告无解。现在,我们有
我们必须再一次在x4=6和x3=4之间选择。x3=4是不可能的,因为D中只出现一个4,而该选择意味着要有两个。x4=6是可能的,于是得到
唯一剩下的选择是x3=5,这是可以的,因为它使得D称为空集,因此我们得到问题的一个解。
图9-46是一棵决策树,代表为得到解而采取的行动。这里,没有对分支进行标记,而是把标记放在子分支的目的节点上。带有一个星号的节点表示所选的点与给定的距离不一致:带有两个星号的节点只有不可能的节点作为儿子,因此表示一条不正确的路径。
下面是该算法的伪代码:
bool turnpike( vector<int> & x, DistSet d, int n )
{
x[ 1 ] = 0;
d.deleteMax( x[ n ] );
d.deleteMax( x[ n - 1 ] );
if( x[ n ] - x[ n - 1 ] ∈ d )
{
d.remove( x[ n ] - x[ n - 1 ] );
return place( x, d, n, 2, n - 2 );
}
else
return false;
}
下面是回溯算法。与大多数回溯算法一样,最方便的实现方法是递归。我们传递同样的参数以及界Left和Right:xLeft,…,xRight是试图放置的点的x坐标。如果D是空集(或Left>Right),那么解已经找到,可以返回。否则,首先使xRight=dmax。如果所有适当的距离都(以正确的值)出现,那么尝试性地放下这一点,删除相应的距离,并尝试从Left到Right-1填入。如果这些距离不出现,或者尝试从Left到Right-1填入失败,那么尝试置xRight=xN-dmax,使用类似的方法。如果这样不行,则问题无解:否则,已经找到了一个解,而这个信息最终通过return语句和x数组传递桶turnpike。