Download as pdf or txt
Download as pdf or txt
You are on page 1of 71

算法分析与设计

——以大学生程序设计竞赛为例

芦旭
lxuu306@sdau.edu.cn

1
第3章 递归与分治策略
3.1 递归算法 3.2 分治策略
3.1.1 Fibonacci数列 3.2.1 分治法的基本步骤
3.1.2 集合的全排列问题 3.2.2 分治法的适用条件
3.1.3 整数划分问题 3.2.3 二分搜索技术
3.2.4循环赛日程表
3.2.5 棋盘覆盖问题
3.2.6 选择问题
3.2.7输油管道问题
3.2.8 半数集问题
3.2.9 整数因子分解
3.2.10取余运算
3.3 Big String 2
第3章 递归与分治策略
任何一个可以用计算机求解的问题所需的计算时间都与其规模n
有关。问题的规模越小,越容易直接求解,解题所需的计算时间
也越少。
分治法的设计思想是,将一个难以直接解决的大问题,分割成一
些规模较小的相同问题,以便各个击破,分而治之。(将大问题
分解成若干个子问题,然后分别解决这些子问题,最后将子问题
的解合并起来得到原问题的解)
如果原问题可分割成k个子问题(1<k≤n),且这些子问题都
可解,并可利用这些子问题的解求出原问题的解,那么这种分治
法就是可行的。(通过解决子问题,我们可以逐步解决原问题,
并最终得到原问题的解。)
由分治法产生的子问题往往是原问题的较小模式,这就为使用递
归技术提供了方便。
3
3.1 递归算法
程序直接或间接调用自身的编程技巧称为递归算法
(Recursion)。
通过递归调用解决子问题,最终可以得到原问题的解。
一个过程或函数在其定义或说明中又直接或间接调用自
身的一种方法,它通常把一个大型复杂的问题层层转化
为一个与原问题相似的规模较小的问题来求解,递归策
略只需少量的程序就可描述出解题过程所需要的多次重
复计算,大大地减少了程序的代码量。
在计算机科学中,递归算法可以通过函数的递归调用来
实现。当一个函数在执行过程中遇到递归调用时,它将
暂停当前的执行,转而执行被调用的函数,直到达到某
4
个终止条件。然后,递归函数开始返回结果,并继续执
行之前的调用。
3.1 递归算法
递归策略在解决问题时具有一些优势。首先,递归算法可
以用较少的程序代码来描述解题过程中所需的多次重复计
算。这是因为递归算法可以通过不断调用自身来处理相同
的操作,从而减少了代码的冗余性。
其次,递归算法能够更加直观地描述问题的解决过程。通
过将复杂的问题分解为相似但规模较小的子问题,递归算
法使问题的解决变得更加清晰和易于理解。
然而,递归算法也需要注意一些问题。首先,递归算法可
能会导致函数调用的层级过深,从而消耗大量的内存和计
算资源。因此,在设计递归算法时需要注意终止条件的设
置,以避免无限递归的情况发生。
5
3.1 递归算法
递归需要有边界条件、递归前进段和递归返回段。
⚫ 当边界条件不满足时,递归前进;
⚫ 当边界条件满足时,递归返回。
⚫ 注意:在使用递增归策略时,必须有一个明确的递归结束条件,
称为递归出口,否则将无限进行下去(死锁)。
递归的缺点:
⚫ 递归算法解题的运行效率较低。
在递归调用过程中,系统为每一层的返回点、局部变量等开辟了堆
栈来存储。递归次数过多容易造成堆栈溢出等。

6
3.1.1 FIBONACCI数列

7
3.1.1 FIBONACCI数列

8
3.1.1 FIBONACCI数列
1 (n = 0,1)
F ( n) = 
F(n-1 ) + F(n-2 ) (n  1)

算法3.1 Fibonacci数列的递归算法
int fib(int n) //声明一个函数fib,它接受一个整数参数n,并返回一个整数。
{
if (n<=1) return 1; //判断n的值是否小于等于1。如果n小于等于1,则
返回1。这是递归算法的终止条件,当n为0或1时,Fibonacci数列的第n个数为1。
return fib(n-1)+fib(n-2); //如果n大于1,那么函数将调用自身两次,
并将两次调用的结果相加。第一次调用是fib(n-1),它返回Fibonacci数列的第n-1
个数;第二次调用是fib(n-2),它返回Fibonacci数列的第n-2个数。然后将这两个
数相加,得到Fibonacci数列的第n个数。
}
该算法的效率非常低,因为重复递归的次数太多。 9
3.1.1 FIBONACCI数列
1 (n = 0,1)
F ( n) = 
F(n-1 ) + F(n-2 ) (n  1)

算法3.2 Fibonacci数列的递推算法
int fib[50]; //声明了一个名为 fib的整型数组,大小为 50。这个数组用于保存斐波那契数
列的中间结果.
void fibonacci(int n) //定义了一个名为 fibonacci 的函数,它接受一个整数参数 n
{
fib[0] = 1; //将数组的第一个元素设置为 1
fib[1] = 1; //将数组的第二个元素设置为 1
for (int i=2; i<=n; i++) //使用一个循环来计算斐波那契数列的其他元素,循环
的起始条件是 i=2
fib[i] = fib[i-1]+fib[i-2]; //将数组中第 i 个元素的值设置为前两个
元素的和 10

}
3.1.1 FIBONACCI数列
 Fibonacci数列的递推算法比递归算法好的原因有几点:
✓ 效率更高:递推算法使用循环来计算斐波那契数列的每个元素,
而不是通过递归调用函数。递推算法的时间复杂度是线性的,而
递归算法的时间复杂度是指数级的。因此,递推算法在处理大规
模的斐波那契数列时更高效。
✓ 避免重复计算:递推算法使用一个数组来保存中间结果,每次计
算新的斐波那契数时可以直接使用已经计算过的结果,而不需要
重复计算。相比之下,递归算法每次调用函数都会重新计算前两
个斐波那契数,导致重复计算的问题。
✓ 减少函数调用:递推算法不需要频繁地进行函数调用,而递归算
法需要多次递归调用自身。函数调用会引入额外的开销,包括栈
帧的创建和销毁,以及参数传递的开销。递推算法通过循环直接
计算结果,避免了这些开销。
11
 综上所述,Fibonacci数列的递推算法在效率和性能方面优于递归
算法。它更适合处理大规模的斐波那契数列计算,并且避免了重
复计算和频繁的函数调用。
3.1.1 FIBONACCI数列
在线测试题目,浙江工业大学在线题库
(http://acm.zjut.edu.cn)

1029—Fibonacci数 1451—Fib数的2幂次模

1091—Fibonacci数列 1457—Fibonacci again

1211—菲波那契数 1500—Fibnacci Number

1282—Fibonacci String 1520—等差数列&Fibonacci数列

12
3.1.1 FIBONACCI数列
http://acm.zjut.edu.cn/ShowProblem.aspx?ShowID=1209

1 (n = 0)

Pn ( x) =  x (n = 1)
((2n − 1) xP ( x) − (n − 1) P ( x)) / n (n  1)
 n −1 n−2

13
3.1.2 集合的全排列问题

r1=1的排列 r2=2的排列 r3=3的排列 r4=4的排列


1234 2134 3214 4231
1243 2143 3241 4213
1324 2314 3124 4321
1342 2341 3142 4312
1432 2431 3412 4132
1423 2413 3421 4123 14
3.1.2 集合的全排列问题

15
3.1.2 集合的全排列问题

r1=1的排列 r2=2的排列 r3=3的排列 r4=4的排列


1234 2134 3214 4231
1243 2143 3241 4213
1324 2314 3124 4321 16
1342 2341 3142 4312
1432 2431 3412 4132
1423 2413 3421 4123
算法3.3 全排列问题的递归算法
//产生从元素k~m的全排列,作为前k—1个元素的后缀
void Perm(int list[], int k, int m)
{
if(k==m) //判断 k 是否等于 m。如果相等,表示已经生成了一次全排列
{
for(int i=0;i<=m;i++)
cout<<list[i]<<" ";
cout<<endl;
}
else //当 k 不等于 m 时执行
//在数组list中,产生从元素k~m的全排列
for(int j=k;j<=m;j++)
{
swap(list[k],list[j]); //交换数组 list 中索引为 k 和 j
的元素的值,用于生成不同的排列。
Perm(list,k+1,m); //递归调用 Perm 函数,生成从元
素 k+1 到 m 的全排列。
swap(list[k],list[j]); //再次交换数组 list 中索引为 k
和 j 的元素的值,用于恢复数组的原始状态,以便生成其他排列。 17
}
数组list[]={1, 2, 3, 4, 5, 6,7},则调用Perm(list,0,3)
}
就是产生元素1~4的全排列。
算法3.3 全排列问题的递归算法
//产生从元素k~m的全排列,作为前k—1个元素的后缀
void Perm(int list[], int k, int m)
{
if(k==m) //构成了一次全排列,输出结果
{
for(int i=0;i<=m;i++)
cout<<list[i]<<" ";
cout<<endl;
}
else
//在数组list中,产生从元素k~m的全排列
for(int j=k;j<=m;j++)
{
swap(list[k],list[j]);
Perm(list,k+1,m);
swap(list[k],list[j]);
}
} 12345
12354
数组list[]={1, 2, 3, 4, 5, 6,7},则调用Perm(list,2,4)
12435
就是产生元素3~5的全排列。 18
12453
令int from=k; 12543
则for(int i=from;i<=m;i++) 12534
3.1.3 整数划分问题
整数划分问题是算法中的一个经典命题之一。把一个正整
数n表示成一系列正整数之和:

正整数n的这种表示称为正整数n的划分。正整数n的不同
划分个数称为正整数n的划分数,记作 p(n) 。
⚫ 正整数6有如下11种不同的划分,所以 p(6) = 11 。
6
5+1
4+2, 4+1+1
3+3, 3+2+1, 3+1+1+1
2+2+2, 2+2+1+1, 2+1+1+1+1
1+1+1+1+1+1
19
3.1.3 整数划分问题—算法分析

20
3.1.3 整数划分问题—算法分析
正整数n的划分数p(n)=f(n,n)
1 n = 1, m = 1
 f (n, n) nm

f (n, m) = 
1 + f (n, n − 1) n=m
 f (n, m − 1) + f (n − m, m) n  m 1

f (6, 4) = f (6, 3) + f (2, 4) = f (6, 3) + f (2, 2)


f(6,4)=9 f(6,3)=7 f(2,2)=2
4+2, 4+1+1 3+3, 3+2+1, 3+1+1+1 4+2, 4+1+1
3+3, 3+2+1, 3+1+1+1 2+2+2, 2+2+1+1, (实际上是2的划分)
2+2+2, 2+2+1+1, 2+1+1+1+1
2+1+1+1+1 1+1+1+1+1+1 21
1+1+1+1+1+1
3.1.3 整数划分问题—算法分析
正整数n的划分数p(n)=f(n,n)

1 n = 1, m = 1
 f (n, n) nm

f (n, m) = 
1 + f (n, n − 1) n=m
 f (n, m − 1) + f (n − m, m) n  m 1

算法3.4 正整数n的划分算法
int split(int n,int m)
{
if(n==1||m==1) return 1;
else if (n<m) return split(n,n);
else if(n==m) return split(n,n-1)+1;
else return split(n,m-1)+split(n-m,m); 22
}
3.2 分治策略
分治策略是对于一个规模为n的问题,若该问题可以
容易地解决(比如说规模n较小)则直接解决,否则
将其分解为k个规模较小的子问题,这些子问题互相
独立且与原问题形式相同。
递归地解这些子问题,然后将各子问题的解合并得
到原问题的解。

23
3.2.1 分治法的基本步骤
分治法在每一层递归上都有三个步骤:
1. 分解:将原问题分解为若干个规模较小,相互独立,与原问题
形式相同的子问题;
2. 解决:若子问题规模较小而容易被解决则直接解,否则递归地
解各个子问题;
3. 合并:将各个子问题的解合并为原问题的解。

24
3.2.1 分治法的基本步骤
算法3.5 分治策略的算法设计模式
Divide_and_Conquer(P) //定义函数,接受一个参数P.
{
if (|P|<=n0 ) return adhoc(P);//判断当前问题P的规模是否小于等于阈值
n0。如果小于等于阈值,那么调用adhoc(P)函数直接解决该问题,并返回结果。
divide P into smaller substances P1,P2,…,Pk;//将问题P分解
为k个规模较小的子问题P1、P2、...、Pk。这个步骤将原问题分解成了多个子问题,为后续的递归解决
做准备。
for (i=1; i<=k; k++) //进入循环,从1到k遍历每个子问题。
yi=Divide-and-Conquer(Pi) //通过递归调 用
Divide_and_Conquer函数来解决子问题Pi。这是分治策略的核心步骤,通过递归地将子问题分解为
更小的子问题,并解决它们。
Return merge(y1,y2,…,yk) //合并子问题
}

25
3.2.1 分治法的基本步骤
根据分治法的分割原则,原问题应该分为多少个子问题才
较适宜?各个子问题的规模应该怎样才为适当?这些问题
很难予以肯定的回答。
在用分治法设计算法时,最好使子问题的规模大致相同。
如分成大小相等的k个子问题,许多问题可以取k=2。
这种使子问题规模大致相等的做法是出自一种平衡
(Balancing)子问题的思想,它几乎总是比子问题规模
不等的做法要好。

26
3.2.2 分治法的适用条件
分治法所能解决的问题一般具有以下几个特征:
1. 该问题的规模缩小到一定的程度就可以容易地解决;
2. 该问题可以分解为若干个规模较小的相同问题,即该问题具有
最优子结构性质;
3. 利用该问题分解出的子问题的解可以合并为该问题的解;
4. 该问题所分解出的各个子问题是相互独立的,即子问题之间不
包含公共的子子问题。

27
3.2.3 二分搜索技术
给定n个元素a[0:n-1],需要在这n个元素中找出一个
特定元素x。

28
3.2.3 二分搜索技术
给定n个元素a[0:n-1],需要在这n个元素中找出一个
特定元素x。
⚫ 首先对n个元素进行排序,可以使用C++标准模板库函数sort()。
⚫ 比较容易想到的是用顺序搜索方法,逐个比较a[0:n-1]中的元
素,直至找到元素x或搜索遍整个数组后确定x不在其中。
⚫ 因此在最坏的情况下,顺序搜索方法需要 O(n)次比较。
⚫ 二分搜索技术充分利用了n个元素已排好序的条件,采用分治策
略的思想,在最坏情况下用O(log n) 时间完成搜索任务。

0 1 2 3 4 5 6 7 8 9 10
7 14 17 21 27 31 38 42 46 53 75

left middle right

29
二分搜索算法的基本思想是将n个元素分成个数大致相
同的两半,取a[n/2]与x作比较。
⚫ 如果x=a[n/2],则找到x,算法终止。
⚫ 如果x<a[n/2],则我们只要在数组a的左半部分继续搜索x。
⚫ 如果x>a[n/2],则我们只要在数组a的右半部分继续搜索x。

30
算法3.6 二分搜索算法
//数组a[]中有n个元素,已经按升序排序,待查找的元素x
template<class Type> //这是一个模板声明,表示接下来的代码是一个泛型函数,可以适用
于不同类型的数据。
int BinarySearch(Type a[],const Type& x,int n) //声明函数
BinarySearch,接受三个参数:数组a,要查找的元素x,以及数组的大小n。函数返回一个整数,表示
查找结果。
{
int left=0; //定义并初始化一个整型变量left,表示左边界的索引,初始值为0。
int right=n-1; //定义并初始化一个整型变量right,表示右边界的索引,初始值为数组
大小减1。
while(left<=right) //循环语句,条件是左边界小于等于右边界。
{
int middle=(left+right)/2; //计算中点的索引,将左边界和右边界相
加后除以2得到中点的位置。
if (x==a[middle]) return middle; //判断要查找的元素x是否等
于中点位置的元素a[middle]。如果相等,说明找到了目标元素,直接返回中点的索引middle。
if (x>a[middle]) left=middle+1; //判断要查找的元素x是否大于
中点位置的元素a[middle]。如果是,说明目标元素在中点的右侧,将左边界更新为middle+1。
else right=middle-1; //执行了一个else分支,说明要查找的元素x小于
中点位置的元素a[middle]。如果是,说明目标元素在中点的左侧,将右边界更新为middle-1。 31
} return -1; //如果循环结束后仍然没有找到目标元素,即左边界大于右边界,
表示未找到目标元素,返回-1。
3.2.4循环赛日程表
问题描述:设有n=2k个运动员要进行网球循环赛。
现要设计一个满足以下要求的比赛日程表:
1. 每个选手必须与其他n-1个选手各赛一次;
2. 每个选手一天只能参赛一次;
3. 循环赛在n-1天内结束。
⚫ 请按此要求将比赛日程表设计成有n行和n-1列的一个表。
⚫ 在表中的第i行,第j列处填入第i个选手在第j天所遇到的选手,
其中1≤i≤n,1≤j≤n-1。

32
3.2.4循环赛日程表
1 2 3 4 5 6 7 8 1 2 3 4 5 6 7 8
2 1 4 3 6 5 8 7 2 1 4 3 6 7 8 5
第1天,
1与2比, 3 4 1 2 7 8 5 6 3 4 1 2 7 8 5 6
2与1比 4 3 2 1 8 7 6 5 4 3 2 1 8 5 6 7
5 6 7 8 1 4 3 2
1 2 3 4 5 6 7 8 1 2 3 4
6 5 8 7 2 1 4 3
天数 ① 2 1 4 3 6 5 8 7 2 1 4 3
7 8 5 6 3 2 1 4
1 2 3 4 1 2 7 8 5 6 3 4 1 2
8 7 6 5 4 3 2 1
2 1 4 3 2 1 8 7 6 5 4 3 2 1
教材中的表格有误
1个选手 4个选手 8个选手

每个选手必须与其他n-1个选手各赛一次;
每个选手一天只能参赛一次;
循环赛在n-1天内结束。

按分治策略,将所有的选手分为两半, 33
n个选手的比赛日程表就可以通过为n/2个选手设计的比赛日程表来决定。
递归地用对选手进行分割,直到只剩下1个选手时,比赛日程表则不再安排。
void Table(int k) //构造循环赛日程表,
{
int i, r;
选手的数量N=2K
int n = 1 << k; //将1左移k位,相当于将1乘以2的k次方
//构造正方形表格的第一行数据
for (i=0; i<n; i++)
a[0][i] = i + 1;
//采用分治算法,构造整个循环赛日程表
for (r=1; r<n; r<<=1) //用于构造整个循环赛日程表,通过不断增加比赛轮次r来完成。
初始化r为1,每次循环左移一位(相当于乘以2).
for (i=0; i<n; i+=2*r) //用于遍历每一轮比赛的起始位置
{
Copy(r, r + i, 0, i, r); //①
Copy(r, i, 0, r + i, r); //②
}
}

34
int n = 1 << k;
这行代码使用了位运算符<<,将数字1左移了k位,并将结果
赋值给变量n。位运算符<<表示左移操作,左移操作将一个
数的二进制表示向左移动指定的位数,右侧空出的位用0填充。
在这里,左移操作等价于将1乘以2的k次方。所以这行代码
的作用是计算2的k次方,并将结果赋值给变量n。

r <<= 1;
这行代码使用了位运算符<<=,表示将变量r的值左移1位,
并将结果赋值给变量r。位运算符<<=是一个组合赋值运算符,
相当于将左操作数左移指定的位数,并将结果赋值给左操作
数。在这里,r <<= 1;的作用是将变量r的值乘以2,相当于
将r左移1位。这个操作通常用于快速实现乘以2的效果。
35
实现方阵的拷贝
//源方阵的左上角顶点坐标(fromx, fromy),行列数为r
//目标方阵的左上角顶点坐标(tox, toy),行列数为r
//r:要复制的区域的大小(正方形区域的边长)
//用于将一个二维数组中的一部分内容复制到另一个位置
void Copy(int tox, int toy, int fromx, int fromy, int r)
{
for (int i=0; i<r; i++)
for (int j=0; j<r; j++)
a[tox+i][toy+j] = a[fromx+i][fromy+j];
}

通过这段代码,我们可以实现将一个二维数组中指定区域
的内容复制到另一个位置。外层循环控制行的遍历,内层
36
循环控制列的遍历,通过索引的变化,实现了源数组到目
标数组的元素复制。
3.2.5 棋盘覆盖问题
 在一个 2k×2k个方格组成的棋盘中,若恰有一个方格与其他方格不
同,称该方格为特殊方格,且称该棋盘为特殊棋盘(Defective
Chessboard)。
⚫ 特殊方格在棋盘中出现的位置有 4k种情形,就有4k种不同的棋盘。
 图中的特殊棋盘是当 k=2时16个特殊棋盘中一个。在棋盘覆盖问题
中,要求用图所示的4种不同形状的L型骨牌覆盖给定棋盘上除特殊
方格以外的所有方格,且任何两个L型骨牌不得重叠覆盖。在任何一
个个 2k×2k的棋盘覆盖中,用到的L型骨牌个数为 (4k-1)/3。

37
有哪些现实世界的应用?
3.2.5 棋盘覆盖问题
⚫ 当k=0时(1 × 1棋盘),只有一个方格,即特殊方格,骨牌数为
0;

38
3.2.5 棋盘覆盖问题
用分治策略,可以设计解棋盘覆盖问题的一个简捷算法。
分治的技巧在于如何划分棋盘,使划分后的子棋盘大小相
同,并且每个子棋盘均包含一个特殊方格,从而将原问题
分解为规模较小的棋盘覆盖问题。
⚫ 当k>0时,将2k×2k的棋盘划分为4个2k-1×2k-1子棋盘。
⚫ 原棋盘只有一个特殊方格,则其余3个子棋盘中没有特殊方格。
⚫ 递归之前,要将原问题转化为4个较小规模的相同子问题;
⚫ 用一个L型骨牌覆盖这3个较小棋盘的会合处。从而将原问题转化
为4个较小规模的棋盘覆盖问题,以便采用递归方法求解。
⚫ 递归地使用这种划分策略,直至将棋盘分割为1×1的子棋盘。

39
3.2.5 棋盘覆盖问题
总得来说,这个问题是一个边划分边覆盖的过程,不涉及
合并。
✓ Step1:将大棋盘分成四个小棋盘;
✓ Step2:四个小棋盘中必定有3个没有特殊方格,我们可以用一个L
型骨牌覆盖这3个小棋盘的会合处构造出特殊方格;
✓ Step3:现在我们将一个大问题分解成了四个小问题,都有特殊方格
,重复Step1和Step2,直到棋盘简化为1×1棋盘。

40
采用分治算法解决棋盘覆盖问题的数据结构
 令size=2k ,表示棋盘的规格。
1. 棋盘:使用二维数组表示:
⚫ int board[1025][1025];
⚫ 为了方便递归调用,将数组board设为全局变量。board[0][0]是棋
盘的左上角方格。
2. 子棋盘:由棋盘左上角的坐标tr,tc和棋盘大小s表示。
3. 特殊方格:在二维数组中的坐标位置是(dr,dc)。
4. L型骨牌:用到的L型骨牌个数为(4k-1)/3 ,将所有L型骨牌从
1开始连续编号,用一个全局变量表示:
static int tile=1;

41
棋盘覆盖问题的分治算法

42
棋盘覆盖问题的分治算法

43
3.2.6 选择问题
对于给定的n个元素的数组a[0:n—1],要求从中找出第
k小的元素。
输入
输入有多组测试例。
对每一个测试例有2行,第一行是整数n和k(1≤k<n≤1000),
第二行是n个整数。
输出
第k小的元素。
输入样例 输出样例
52 3
39416 23
73
4 59 7 23 61 55 46
44
3.2.6 选择问题
一种简单的解决方法就是对全部数据进行排序,于是得到
问题的解。
⚫ 但即使用较好的排序方法,算法的复杂性也为nlogn 。
快速排序算法是分治策略的典型应用,不过不是对问题进
行等份分解(二分法),而是通过分界数据(支点)将问
题分解成独立的子问题。

45
3.2.6 选择问题
首先选第一个数作为分界数据,将比它小的数据存储在它
的左边,比它大的数据存储在它的右边,它存储在左、右
两个子集之间。这样左、右子集就是原问题分解后的独立
子问题。
⚫ 再用同样的方法,继续解决这些子问题,直到每个子集只有一个
数据,就完成了全部数据的排序工作。

46
3.2.6 选择问题
利用快速排序算法的思想,来解决选择问题。
⚫ 记一趟快速排序后,分解出左子集中元素个数为 nleft,则选
择问题可能是以下几种情况之一:
① nleft =k﹣1,则分界数据就是选择问题的答案。
② nleft >k﹣1,则选择问题的答案继续在左子集中找,
问题规模变小了。
③ nleft <k﹣1,则选择问题的答案继续在右子集中找,
问题变为选择第k-nleft-1 小的数,问题的规模变小了。

输入样例 输出样例 1 3 9 4 6
52 3
39416

此算法在最坏情况时间复杂度为 O(n2) ,
此时nleft总是为0,左子集为空,即第
k小元素总是位于right子集中。 47

平均时间复杂度为 O(n )。
算法3.9 采用分治策略
找出第K小元素的算法
i=1 do…while语句:
j=3 当条件为true时继续
31496 循环的处理过程
i=2
输入样例 输出样例
j=1
j=1 left=0 52 3

3 39416

left right
3 9 4 1 6

pivot i j

left right
3 9 4 1 6

48
pivot i j
3.2.7输油管道问题
某石油公司计划建造一条由东向西的主输油管道。该管道
要穿过一个有n口油井的油田。从每口油井都要有一条输
油管道沿最短路经(或南或北)与主管道相连。
如果给定n口油井的位置,即它们的x坐标(东西向)和y
坐标(南北向),应如何确定主管道的最优位置,即使各
油井到主管道之间的输油管道长度总和最小的位置?
给定n口油井的位置,编程计算各油井到主管道之间的输
油管道最小长度总和。

49
3.2.7输油管道问题
输入
第1行是一个整数n,表示油井的数量(1≤n≤10 000)。
接下来n行是油井的位置,每行两个整数x和y
(﹣10 000≤x,y≤10 000)。
输出
各油井到主管道之间的输油管道最小长度总和。
输入样例
5
12
22
13
3 -2
33
输出样例
50
6
3.2.7输油管道问题
设n口油井的位置分别为 pi = ( xi , yi ) ,i=1~n。由于主
输油管道是东西向的,因此可用其主轴线的y坐标唯一确
定其位置。主管道的最优位置y应该满足:
n
min  | y − y i |
i =1

由中位数定理可知,y是中位数。
输入样例
5
12
22
13
3 -2
33
输出样例
51
6
算法1:对数组A排序(一般是升序),取中间的元素
int n; //油井的数量
int x; //x坐标,读取后丢弃
int a[1000]; //y坐标
cin>>n;
for(int k=0;k<n;k++)
cin>>x>>a[k];
sort(a,a+n); //按升序排序。 a 是待排序数组的起始位置,表示数组
的首元素的地址;a+n 是待排序数组的结束位置,表示数组最后一个元
素的下一个位置的地址。其中,n 是数组的长度。
//计算各油井到主管道之间的输油管道最小长度总和
int min=0;
for(int i=0;i<n;i++)
min += (int)fabs(a[i]-a[n/2]);
cout<<min<<endl; 52
算法1:对数组A排序(一般是升序),取中间的元素

53
算法2:采用分治策略求中位数
int n; //油井的数量
int x; //x坐标,读取后丢弃
int a[1000]; //y坐标
cin>>n;
for (int i=0; i<n; i++)
cin>>x>>a[i];
int y = select(0, n-1, n/2); //采用分治算法计算中位数
//计算各油井到主管道之间的输油管道最小长度总和
int min=0;
for(int i=0;i<n;i++)
min += (int)fabs(a[i]-y);
cout<<min<<endl;

54
3.2.8 半数集问题
给定一个自然数n,由n开始可以依次产生半数集set(n)
中的数如下。
(1) n set(n);
(2) 在n的左边加上一个自然数,但该自然数不能超过最近
添加的数的一半;
(3) 按此规则进行处理,直到不能再添加自然数为止。
例如,若n=6,求6的半数集。
6半数集肯定包含他本身;
1不大于的一半 所以16;
2不大于6的一半,26;1又不大于2,所以126;
3不大于6的一半,36;1又不大于3,所以136;

55
3.2.8 半数集问题
设set(n)中的元素个数为 f (n) ,则显然有:
n/2
f (n) = 1 +  f (i )
i =1

第一次半数集 112 212 312 412 512 612


忽略原始数字
1 2 3 4 5 6
12
12 13 14 15 16
24,124 25,125 26,126
36,136

数字12为例

56
算法3.12 计算半数集问题的递归算法
int comp(int n)
{
int ans=1;
if (n>1) for(int i=1;i<=n/2;i++)
ans+=comp(i); //在循环内部,将函数 comp 以
参数 i 调用,并将返回值累加到 ans 变量上,相当于执行
ans = ans + comp(i)。
return ans;
} n/2
f (n) = 1 +  f (i )
i =1

算法中显然有很多重复的子问题计算。 57
使用数组存储忆计算过的结果,避免重复计算,
可明显改进算法的效率。
算法3.13 计算半数集问题的递归算法—记忆式搜索
int a[1001]; //声明一个整型数组a,大小为 1001。它用于存储中间结果,以
便在计算过程中进行缓存。
int comp(int n)
{
int ans=1;
if(a[n]>0)return a[n]; //判断数组 a 中索引为 n 的元素是否大于
0。如果条件成立(即已经计算过),则直接返回数组中的值,避免重复计算。
for(int i=1;i<=n/2;i++) //通过递归调用自身计算从 1 到 n/2 的所
有整数的 comp 值,并将这些值累加到 ans 变量上。注意,当n=1时,不满足
i<=n/2,因此直接跳过循环。
ans+=comp(i);
a[n]=ans; //保存结果
return ans;
}
58
3.2.9 整数因子分解
 大于1的正整数n可以分解为:
12 = 12
n = x1  x2      xm
12 = 6  2
当n=12时,共有8种不同的分解式: 12 = 4  3
⚫ 对于给定的正整数n,编程计算n共 12 = 3  4
有多少种不同的分解式。 12 = 3  2  2
 输入 12 = 2  6
⚫ 数据有多行,给出正整数n 12 = 2  3  2
(1≤n≤2000000000)。
12 = 2  2  3
 输出
⚫ 每个数据输出1行,是正整数n的不
同的分解式数量。

59
算法3.14 整数因子分解的算法
12 = 12
int total; //定义为全局变量
12 = 6  2
void solve(int n) 12 = 4  3
{ 12 = 3  4
if (n==1) total++; //获得一个分解 12 = 3  2  2
else for (int i=2; i<=n; i++) 12 = 2  6
if (n%i==0) solve(n/i); 12 = 2  3  2
} 12 = 2  2  3

//主函数main()中数据的读取与调用
cin>>n
int n;
while( scanf("%d",&n)!=EOF)
{ cout<<total<<endl;
total = 0;
solve(n);
printf("%d\n",total); 60
}
当N=12时,整数因子分解的递归过程

12

n%i=0 2 3 4 5 6 7 8 9 10 11 12
n/i 6 4 3 × 2 × × × × × 1

n%i=0 2 3 4 5 6 2 3 4 2 3 2
n/i 3 2 ×× 1 2×1 ×1 1

n%i=0 2 3 2 2
n/i × 1 1 1

61
3.2.10取余运算
输入三个正整数a,p,k ,求ap%k 的值。
输入
⚫ 输入有多组测试例。
⚫ 对每组测试例,有三个正整数a,p,k (0<a,p,k2 <232)。
输出
⚫ 对每组测试例输出1行,是ap%k 的值。

样例输入 样例输出
2 10 9 7
3 18132 17 13

62
3.2.10取余运算
由于数据的规模很大,如果直接计算,不仅需要采用高精
度,而且时间复杂度很大。
例如,10 25 %7 = 3 ,但1025超出整形数的表示范围,不能直接计算。
模运算有如下运算规则:

(a  b)%n = (a%n  b%n)%n (1)


a b %n = ((a%n) b )%n (2)

63
3.2.10取余运算
递推公式:
a % k ( p = 1)

a %k = (a  a p −1 %k )%k
p
( p是奇数 )

(( a  a )% k ) p/2
%k ( p是偶数 )

⚫ 该递推公式体现了分治策略的应用,将一个大的指数p逐渐减小,
同时对运算过程的中间结果不断进行模运算,降低中间结果的数
字大小,避免了使用高精度运算。

64
算法3.15利用递推公式实现取余运算的算法
//为了防止运算过程中的溢出,采用64位整数,在C++环境中是long long
int mod(__int64 a, __int64 p, __int64 k)
{
if (p == 1) return a%k;
if (p%2) return mod(a%k, p-1, k)*a%k; //p是奇数
else return mod((a*a)%k, p/2, k); //p是偶数
}

a % k
//主函数main()中数据的读取与调用 ( p = 1)
 a, p, pk;−1
punsigned
%k =(scanf("%u%u%u",&a,&p,&k)!=EOF)
a while (a  a %k )%k ( p是奇数 )
printf("%d\n",
p / 2mod(a, p, k));
(( a  a)% k ) %k ( p是偶数 )

65
3.3 BIG STRING
设A=“^__^”(4个字符),B=“T.T”(3个字符),
然后以AB为基础,构造无限长的字符串。
⚫ 重复规则如下:
把A接在B的后面构成新的字符串C。
例如,A=“^__^”,B=“T.T”,则C=BA=“T.T^__^”。
令A=B,B=C,如上例所示,则A=“T.T”,B=“T.T^__^”。
编程任务:给出此无限长字符串中的第n个字符。
输入样例
输入 输出样例
1 T
输入有多组测试例。
2 .
每个测试例只有一个整数N(1≤N≤263-1)。

输出 4 ^
8 T
对每个测试例输出一行,是此无限长字符串中的第
N个字符(序号从1开始)。
66
3.3 BIG STRING
如果研究叠加后的字符串规律。
⚫ 结果发现,叠加后的字符串虽然有规
律,但是与输入的数据n之间没有直
接的联系。
把A接在B的后面构成新的字符
串C=BA,令A=B,B=C。

67
(1)如果从字符串的长度考虑:
3.3 BIG STRING a=strlen("^__^")
b=strlen("T.T")
→ a=4
→ b=3
c=strlen("T.T^__^") → c=7
 再按照题目给定的步骤重复,容易发现,
这正是以a,b为基数的斐波纳契(
Fibonacci)数列。
 无论它如何叠加,该位置的字符总是在字
符串C中。
⚫ 本题就变成给定一个正整数n,求出小于n
的最大斐波纳契数,n与该斐波纳契数的差
正是该字符在字符串C中的位置。

68
n 0 1 2 3 4

3.3 BIG STRING fib数列 7 10 17 27 44 …

n n=4 n=8 n=14 n=21 n=42 n=55 n=60


-fib / 0 1 2 3 4 4
1 1 1
n 1 4 4 5 1 6

69
3.3 BIG STRING
(2)算法优化
由于n最大可达263—1,对于输入的每个n,都去计算小
于n的最大斐波纳契数,显然是非常浪费时间的。
⚫ 解决的办法是预先把在263—1范围内的所有斐波纳契数求出来,
放到一个数组中。
⚫ 经过测算,该斐波纳契数列最多为86项,第86项的斐波纳契数约
是6.02×1018,而263—1约是9.22×1018。

70
算法3.16利用斐波纳契数列计算
#define LEN 88 无限长字符串第N个字符的算法
string base="T.T^__^";
//将斐波纳契(Fibonacci)数列在263—1范围之内的数全部计算出来
long long int f[LEN];
f[0]=7; f[1]=10;
for(int i=2; i<LEN; i++)
f[i] = f[i-1]+f[i-2];
long long int n;
while(cin>>n)
{
//对于每一个n,减去小于n的最大斐波纳契数
while(n>7)

int i=0;
while (i<LEN && f[i]<n) i++;
n-=f[i-1];
}
//n中剩下的值,就是该字符在base中的位置
cout<<base[n-1]<<endl;
} 71

You might also like