【算法笔记】第四章:入门篇(2)——算法初步
标签(空格分隔):【算法笔记】
第四章:入门篇(2)——算法初步
void selectSort(){
for( int i = 0; i < n; i++){
int k = i;
for( int j = i; j < n; j++){
if( A[j] < A[k])
k = j;
}
swap( A[i], A[k]);
}
}
void insertSort(){
for( int i = 1; i < n; i++){
int temp = A[i], j = i;
while( j > 0 && temp < A[ j - 1]){
A[j] = A[j-1];
j--;
}
A[j] = temp;
}
}
在排序题中,推荐直接使用C语言中的库函数qsort或者C++中的sort函数直接进行排序。
相关结构体的定义:根据题意定义结构体存放信息即可。
cmp函数的编写:
补充:strcmp 是string.h 文件下用来比较两个char型数组的字典序大小的函数。当st1 的字典序小于 str2 的字典序时,strcmp(str1,str2)返回一个负数;当二者相等时,strcmp(str1,str2)返回零;当st1 的字典序大于 str2 的字典序是,strcmp(str1,str2)返回一个整数。
排名的实现:规则一般是,分属不同的排名不同,分数相同的占用一个排位。
分治:divide and conquer, 即“分而治之”,指将原问题划分为若干规模较小而与原问 mb题相同/相似的子问题,然后分别解决这些子问题,最后合并子问题的解,即可以得到原问题的解。
分治的三个步骤:1. 分解; 2. 解决; 3. 合并。
需要注意的是,分治法分解出的子问题应该是相互独立、没 有交叉的。如果两个子问题存在交叉部分,就不应该用分治法来解决。
从广义上来讲,分治法分解的子问题个数只要大于0即可。但是从严格定义上来讲,一般把子问题个数为1的情况成为减而治之(decrease and conquer),而把子问题个数大于1的情况成为分治。但是通常情况下不必区分。
另外,分治作为一种思想,既可以使用递归的手段实现,也可以使用非递归的手段实现。
递归在于反复调用自身函数,但是每次能把问题的规模缩小,直到缩小到数据边界。递归适合实现分治。
递归两个重要概念:1. 递归边界;2. 递归式。
例: 使用递归求解n的阶乘。
n!= n * (n-1) * (n-2) * … * 1.
如果用F(n) 表示n!, 那么F(n) = F(n-1) * n,(递归式), 令F(0) = 1,为递归边界。
代码:
int F( int n){
if( n == 0 ) return 1;
return F(n-1) * n;
}
例2:Fibonacci数列的第n项。
递归式:F(n) = F(n-1) + F(n-2).
递归边界: F(0) = 1, F(1) = 1.
由于使用递归写成的代码进行了多次重复计算,效率极低,因此不建议直接使用递归求解。有性能更好的方法(如矩阵法)
例3:全排列(Full Permutation),一般把 1~n这n个整数按照某一顺序摆放的结果称为n个整数的一个排列,而全排列的指这n个整数所能形成的所有排列。
如,1、2、3这三个整数,123, 132, 213, 231, 312, 321 就是1~3的一个全排列。
我们需要实现一个按照字典序的全排列。
从递归的角度考虑,该问题可以划分为若干个子问题:“输出以1开头的全排列”、“输出以2开头的全排列"…于是不妨设定一个数组P,用来存放当前的排列;再设定一个散列函数hashTable,其中hashTable[x] 当整数x已经在数组P中时为true.
现在按照顺序往P的第1位到第n位中填写数字。不妨假定当前已经填好了P[1] ~ P[index - 1] ,正准备填P[index]. 显然需要枚举 1 ~ n,如果当前枚举的数字x还不在P[1] ~ P[index - 1]中(即hashTable[x] = false),那么就把它填入P[index], 同时将hashTable[x] 置为 true, 然后处理P的第 index +1 位即可(递归处理);当递归完成时,再将hashTable[x] 还原为 fasle ,以便让P[index]填写下一个数字。
index 达到 n + 1时为递归边界。
代码:
int n, P[MaxSize], hashTable[ MaxSize] = {false};
//----------------------------
void generateP( int index){
if( index == n + 1){//递归边界,
for( int i = 1; i <= n; i++)//输出当前排列
cout<<P[i];
cout<<endl;
return ;
}
for( int x = 1; x <= n; x++)//枚举1~n
if( hashTable[x] == false){//如果x没有出现
P[index] = x;//则放入x
hashTable[x] = true;//标记x已经出现
generateP( index + 1);//进行下一位
hashTable[ x] = false;
}
}
补充:next_permutation()函数的使用。
next_permutation() 给出一个序列在全排列中的下一个序列。
示例:
int a[10] = { 1,2,3};
do{
printf("%d%d%d,", a[0], a[1], a[2]);
}while( next_permutation(a,a+3));
输出结果为:123,132,213,231,312,321
使用do…while是因为123本身写需要输出。
例4:n皇后,n皇后是指在一个n*n的棋盘上放n个皇后,使得这n个皇后两量均不在同一列、同一行、同一对角线上。求合法的方案数。
方法一:如果采用枚举的方法,即从n2个位置中选择n个位置,枚举数量过大。运行时间为C(n2,n).
方法二:换一种思路,考虑另外一种情形,由于条件限制,我们可以写出1~n的全排列,查看每一种排列所对应的位置是否合法,再统计出合法的方案即可。运行时间变为O(n!)
方法三:回溯法。当放置一部分皇后时,剩余的皇后无论怎么放置都不会合法,此时即不需要递归,直接返回上层即可。
贪心是一种求解最优化问题的方法,它总是考虑在当前状态下的局部最优的策略,以此来达到全局最优的结果。
区间不相交问题:给出N个开区间 (x,y), 从中选择尽可能多的开区间,使得这些开区间两两没有交集。
基于一个严格递增的序列,如何找出某一个元素?
方法一:线性扫描,查找的时间复杂度为$ O(n)$
方法二:二分查找,代码如下:
int binartSearch( int A[], int left, int right, int x){
int mid;
while( left < right){
/*注意, left<=right或left < right 是由问题本身决定的。*/
/*如何查找元素不存在时需返回-1, 则使用"<="; */
/*如何查找元素不存在时需返回其应该插入的位置, 则使用"<"; 注, 这也可以求第一个大于某个元素的位置。*/
mid = left + (right - left) / 2;
/*不采用mid = (left + right)/2 是因为左侧形式可能溢出*/
if( A[mid] == x) return mid;
else if( A[mid] > x)
right = mid;
else if( A[mid] < x)
left = mid + 1;
}
return left;
}
问题:如何计算 ( 2 ) \sqrt(2) (2)的近似值,假设精度为1e-5.
由于 x 2 x^2 x2为递增函数,因此为使用二分法创造了条件。我们使用迭代的思想来解决问题。
const double eps = 1e-5;
double calSqrt(){
int left = 1, right = 2, mid;
while ( left - right >eps){
mid = left + (right- left)/2;
if( mid * mid > 2)
right = mid;
else
left = mid;
}
return mid;
}
同理,对于给定某一个区间上的单调函数,上述方法可以用来求方程的根。
对于给定的三个正整数a,b,m. 求$a^b $ % m
常规方法不再赘述。
快速幂解法:这是基于二分的思想,1. 当b为奇数时,有 a b = a ∗ a b − 1 a^b = a* a^{b-1} ab=a∗ab−1; 2. 当b为偶数时,有 a b = a b / 2 ∗ a b / 2 a^b = a^{b/2} * a^{b/2} ab=ab/2∗ab/2.
显然,任何情况下,再经过log(n)两集的转换后,可以把b转换为0,而任何正整数的0次方都是1.
//递归
long long binaryPow( long long a, long long b, long long m){
if( b == 0)
return 1;
if( b % 2 == 1)
return a * binaryPow(a, b - 1,m)%m;
//注意最后求余号
else if( !b%2){
long long mul = binaryPow( a, b/2, m);
// 细节,不要写成return binaryPow * binaryPow,这样会造成重复计算。
return mul * mul % m;
}
}
//迭代写法
long long binaryPow( long long a, long long b, long long m){
long long ans = 1;
while( b > 0){
if( b&1){ //等价于 检查b是否为计数
ans = ans * a % m;
}
a = a * a %m;
b>>=1;
}
return ans;
}
双指针是一种编程技巧。
例如:给定一个递增的正整数序列和一个正整数M,求序列中两个不同位置的数a和数b,使得 a + b = M,输出所有方案。
解法一:最直观的解法是使用二重循环枚举序列中的所有数字。该算法的运行时间为 O ( n 2 ) O(n^2) O(n2),对于稍微大点的n便不可接受。
造成上述复杂度高的原因有:对于a[i] + a[j] > M, 则必定有a[i+1] + a[j] > M 并且 a[i] + a[j+1] > M.在二重循环的算法中,枚举了很多不必要的元素。
方法二:双指针。要充分使用题目中序列递增的性质。(如果题目中没有给出是递增序列,则应该进行排序预处理来降低运行时间)
双指针方法见代码:
int find( int A[]){
int i = 0, j = n -1;
// 其中i指向数组第一个元素,j指向最后一个元素。
while( i<j){
if( A[i] + A[j] == M){
printf("%d, %d", A[i], A[j]);
i++, j--;
//此时满足条件,输出。
//可知A[i+1] + A[j] > M,A[i] + A[j-1] < M;
//而A[i+1] + A[j-1] 与M的关系未知
//因此不能单纯地移动某一个指针,需要两个一起移动。
}
else if( A[i] + A[j] > M)
j--;
else if( A[i] + A[j] < M)
i++;
}
}
运行时间为O(n)。
序列合并问题:假设有两个递增序列A和B,要求它们合并成一个递增序列C。
解答:设置两个指针,i,= 0, j = 0,分别指向A和B的第一个值。同时开辟一个数组和指针k,数组用来存放数组C,指针指向C的当前位置.
归并排序是一种基于“归并”思想的排序方法,这里主要介绍二路归并。
二路归并排序原理:将序列两两分组,将序列归并为 ⌈ 1 2 ⌉ \lceil \frac{1}{2} \rceil ⌈21⌉个组,然后组内单独排序;然后再将这些组两辆归并,生成 ⌈ 1 4 ⌉ \lceil \frac{1}{4} \rceil ⌈41⌉个组,然后组内单独排序;以此类推,知道只剩下一个组为止。
归并排序的时间复杂度为O(nlogn).
例:对于序列{66,12,33,57,64,27,18}进行归并排序。
初 始 序 列 66 , 12 ‾ , 33 , 57 ‾ , 64 , 27 ‾ , 18 ‾ 第 一 趟 12 , 66 ‾ , 33 , 57 ‾ , 27 , 64 ‾ , 18 ‾ 第 二 趟 12 , 33 , 57 , 66 ‾ , 18 , 27 , 64 ‾ 第 三 趟 12 , 18 , 27 , 33 , 57 , 64 , 66 ‾ 初始序列 \ \ \underline{66,12} ,\underline{33, 57} ,\underline{64, 27} ,\underline{18} \\ 第一趟 \ \ \underline{12 ,66} , \underline{33 , 57} ,\underline{27 , 64}, \underline{18}\\ 第二趟 \ \ \underline{12 ,33 ,57, 66} ,\underline{18 ,27 ,64}\\ 第三趟 \ \ \underline{12, 18, 27,33 ,57 ,64 ,66} 初始序列 66,12,33,57,64,27,18第一趟 12,66,33,57,27,64,18第二趟 12,33,57,66,18,27,64第三趟 12,18,27,33,57,64,66
//这里给出主要的merge函数
const int max =100;
void merge( int A[], int L1, int L2, int R1, int R2){
int i = L1, j = L2; // i指向A[L1], j 指向A[L2]
int temp[maxn], index = 0;
while( i <= R1 && j <= R2){
if( A[i] <= A[j])
temp[index++] = A[i++];
else
temp[index++] = A[j++];
}
while( i <= R1) temp[index++] = A[i++];
while( j <= R2) temp[index++] = A[j++];
for( i = 0; i < index; i++)
A[L1+i] = temp[i];
}
快速排序是目前最快的一种排序算法,其运行时间为O(nlogn).
快速排序的思想如下:对于某一次递归而言,选取某一个元素,经过一系列操作,使其数组中左侧位置上的元素均小于该选定元素,右侧的元素均大于该选定元素。
步骤如下:对于序列A[0,1,…,n-1]
//划分区间
int Partition( int A[], int left, int right){
int temp = A[left];
while( left < right){
while( left < right && A[right] > temp)
right--;
A[left] = A[right];
while( left < right && A[left] <= temp)
left++;
A[right] = A[left];
}
A[left] = temp;
return left;
}
//快速排序
void quickSort( int A[], int left, int right){
if( left < right){
int pos = Partition( A, left, right);
quickSort( A, left, pos - 1);
quickSort( A, pos, right);
}
}
如何实现随机快排?
首先要解决的问题是如何生成一个随机数。
rand()函数可以生成[0, RAND_MAX]范围内的数( RAND_MAX在不同环境下数值不同)。
如果想输出给定范围[a,b]内的数字,则可以使用 rand()%( b - a + 1) + a. (备注,此时b < RAND_MAX)
当 b > RAND_MAX 时,则可以多次生成rand随机数,然后相乘或用位运算拼接起来。
在此之上,我们来讨论随机快速排序的写法。
首先生成[left, right)内的随机数p,然后以A[p]为主元来进行划分,此时,需要首先将A[p]与A[left]进行交换,再运行Partition函数。
打表法常见的用法:
如何从一个无序的数组中求出第K大的数。
方法一:对数组排序,直接选出第K个数,时间复杂度为O(nlogn).
方法二:随机选择算法,类似于快速排序,选择某一个元素,使其左侧元素均小于该元素,右侧元素均大于该元素,那么该元素为第 左侧元素个数 + 1 大的数。。。一次推类。