算法初步——two pointers
什么是 two pointers
以一个例子引入:给定一个递增的正整数序列和一个正整数 M,求序列中的两个不同位置的数 a 和 b,使得它们的和恰好为 M,输出所有满足条件的方案。
本题的一个最直观的想法是,使用二重循环枚举序列中的整数 a 和 b,判断它们的和是否为 M。时间复杂度为 O(n2)。
two pointers 将利用有序序列的枚举特性来有效降低复杂度。它针对本题的算法如下:
令下标 i 的初值为0,下标 j 的初值为 n-1,即令 i、j 分别指向序列的第一个元素和最后一个元素,接下来根据 a[i]+a[j] 与 M 的大小来进行下面三种选择,使 i 不断向右移动、使 j 不断向左移动,直到 i≥j 成立
-
- 若 a[i]+a[j]==M ,说明找到了其中一种方案,令 i=i+1、j=j-1。
- 若 a[i]+a[j]>M,令 j=j-1。
- 若 a[i]+a[j]<M,令 i=i+1。
反复执行上面三个判断,直到 i≥j 成立,时间复杂度为O(n),代码如下:
1 while(i < j) { 2 if(a[i]+a[j] == M) { 3 printf("%d %d\n", i, j); 4 i++; j--; 5 } else if(a[i]+a[j] < M) { 6 i++; 7 } else { 8 j--; 9 } 10 }
再来看序列合并问题。假设有两个递增序列 A 与 B,要求将它们合并为一个递增序列 C。
同样的,可以设置两个下标 i 和 j ,初值均为0,表示分别指向序列 A 的第一个元素和序列 B 的第一个元素,然后根据 A[i] 与 B[j] 的大小来决定哪一个放入序列 C。
-
- 若 A[i]≤B[j],把 A[i] 加入序列 C 中,并让 i 加1
- 若 A[i]>B[j],把 B[j] 加入序列 C 中,并让 j 加1
上面的分支操作直到 i、j 中的一个到达序列末端为止,然后将另一个序列的所有元素依次加入序列 C 中,代码如下:
1 int merge(int A[], int B[], int C[], int n, int m) { 2 int i=0, j=0, index=0; // i指向A,j指向B,index指向C 3 while(i<n && j<m) { 4 if(A[i] <= B[j]) { // 若 A[i]≤B[j] 5 C[index++] = A[i++]; 6 } else { // 若 A[i]>B[j] 7 C[index++] = B[j++]; 8 } 9 } 10 while(i<n) C[index++] = A[i++]; // 若 A 有剩余 11 while(j<m) C[index++] = B[j++]; // 若 B 有剩余 12 return index; // 返回 C 长度 13 }
广义上的 two pointers 是利用问题本身与序列的特性,使用两个下标 i、j 对序列进行扫描(可以同向扫描,也可以反向扫描),以较低的复杂度(一般为 O(n) )解决问题。
归并排序
归并排序是一种基于“归并”思想的排序方法,本节主要介绍其中最基本的 2-路归并排序。2-路归并排序的原理是,将序列两两分组,将序列归并为 n/2 个组,组内单独排序;然后将这些组再两两归并,生成 n/4 个组,组内再单独排序;以此类推,直到只剩下一个组为止。时间复杂度为 O(nlogn)。
1. 递归实现
只需反复将当前区间 [left,right] 分为两半,对两个子区间 [left,mid] 与 [mid+1, right] 分别递归进行归并排序,然后将两个已经有序的子区间合并为有序序列即可。代码如下:
1 const int maxn = 100; 2 // 将数组A的 [L1,R1] 与 [L2,R2] 合并为有序区间(此处 L2=R1+1 ) 3 void merge(int A[], int L1, int R1, int L2, int R2) { 4 int i=L1, j=L2; 5 int temp[maxn], index=0; // temp 临时储存合并序列 6 while(i<=R1 && j<=R2) { 7 if(A[i] <= A[j]) { // 若 A[i] ≤A[j] 8 temp[index++] = A[i++]; 9 } else { // 若 A[i] > A[j] 10 temp[index++] = A[j++]; 11 } 12 while(i <= R1) temp[index++] = A[i++]; 13 while(j <= R2) temp[index++] = A[j++]; 14 for(int i=0; i<index; ++i) { 15 A[L1+i] = temp[i]; // 将合并后的序列赋值回 A 16 } 17 } 18 } 19 // 归并排序递归实现 20 // 只需反复将当前区间 [left,right] 分为两半,对两个子区间 [left,mid] 与 [mid+1, right] 21 // 分别递归进行归并排序,然后将两个已经有序的子区间合并为有序序列即可。 22 void mergeSort(int A[], int left, int right) { 23 if(left < right) { // 当 left==right 时,只有一个元素,认定为有序 24 int mid = (left+right)/2; 25 mergeSort(A, left, mid); // 分为左区间和右区间 26 mergeSort(A, mid+1, right); 27 merge(A, left, mid, mid+1, right); // 将左区间和右区间合并 28 } 29 }
2.非递归实现
非递归实现主要考虑到这样一点:每次分组时组内元素个数上限都是2的幂次。于是就可以想到这样的思路:令步长 step 的初值为2,然后将数组中每 step 个元素作为一组,将其内部进行排序;再令 step 乘以2,重复上面的操作,直到 step/2 超过元素个数 n 。代码如下:
1 const int maxn = 100; 2 3 // 将数组A的 [L1,R1] 与 [L2,R2] 合并为有序区间(此处 L2=R1+1 ) 4 void merge(int A[], int L1, int R1, int L2, int R2) { 5 int i=L1, j=L2; 6 int temp[maxn], index=0; // temp 临时储存合并序列 7 while(i<=R1 && j<=R2) { 8 if(A[i] <= A[j]) { // 若 A[i] ≤A[j] 9 temp[index++] = A[i++]; 10 } else { // 若 A[i] > A[j] 11 temp[index++] = A[j++]; 12 } 13 while(i <= R1) temp[index++] = A[i++]; 14 while(j <= R2) temp[index++] = A[j++]; 15 for(int i=0; i<index; ++i) { 16 A[L1+i] = temp[i]; // 将合并后的序列赋值回 A 17 } 18 } 19 } 20 21 // 归并排序非递归实现 22 // 令步长 step 的初值为2,然后将数组中每 step 个元素作为一组, 23 // 将其内部进行排序;再令 step 乘以2,重复上面的操作,直到 step/2 超过元素个数 n 。 24 void mergeSort(int A[]) { 25 // step 为组内元素个数 26 for(int step=0; step/2 <= n; step *= 2) { 27 for(int i = 1; i <= n; i += step) { // 对每一组,数组下标从1开始 28 int mid = i + step/2 -1; // 左区间元素个数为 step/2 29 if(mid+1 <= n) { // 右区间存在元素 30 // 左区间为 [left,mid],右区间为 [mid+1, min(i+step-1,n) 31 merge(A, i, mid, mid+1, min(i+step-1, n)); 32 } 33 34 /* 35 // 也可以用 sort 代替 merge 函数 36 sort(A+i, A+min(i+step, n+1)); */ 37 } 38 } 39 }
快速排序
快速排序的实现需要先解决这样一个问题:对一个序列 A[1]、A[2]、... 、A[n],调整序列中元素的位置,使得 A[1] (原序列中的 A[1])的左侧元素都不超过 A[1]、右侧所有元素都大于 A[1]。
下面给出速度最快的做法,思想就是 two pointers:
1. 先将 A[1] 存至某个临时变量 temp,并令两个下标 left、right 分别指向序列首尾。
2. 只要 right 指向的元素 A[right] 大于 temp ,就将 right 不断左移;当某个时候 A[right] 小于等于 temp 时,将元素 A[right] 挪到 left 指向的元素 A[left] 处。
3. 只要 left 指向的元素 A[right] 小于等于 temp ,就将 left 不断右移;当某个时候 A[right] 大于 temp 时,将元素 A[left] 挪到 right指向的元素 A[right] 处。
4. 重复 2.3 ,直到 left 与 right 相遇,把 temp(也即原 A[1])放到相遇的地方。
1 // 对区间 [left,right]进行划分 2 int Partition(int A[], int left, int right) { 3 int temp = A[left]; // 1. 4 while(left < right) { 5 while(left<right && A[right]>temp) right--; // 2. 6 A[left] = A[right]; 7 while(left<right && A[left]<=temp) left++; // 3. 8 A[right] = A[left]; 9 } 10 A[left] = temp; // 4. 11 return left; 12 }
接下来就可以正式实现快速排序算法了。快速排序的思路是:
1. 调整序列中的元素,使当前序列的最左端的元素在调整后满足左侧所有元素均不超过该元素、右侧所有元素均大于该元素
2. 对该元素的左侧和右侧分别进行 1 的调整,直到当前调整区间的长度不超过 1
快速排序的递归实现如下:
1 // 快速排序 2 void quickSort(int A[], int left, int right) { 3 if(left < right) { 4 int pos = Partition(A, left, right); // 1. 5 quickSort(A, left, pos); // 2. 6 quickSort(A, pos+1, right); 7 } 8 }
快速排序算法当序列中元素的排列比较随即时效率最高,但是当序列中元素接近有序时,会达到最坏时间复杂度 O(n2)。
其中一种解决办法是随机选择主元,也就是对 A[left...right] 来说,不总是用 A[left] 作为主元,而是从 left...right 随机选择一个作为主元。
下面来看看如何生成随机数。
- 需要添加 stdlib.h 与 time.h 头文件。
- 函数开头需加上 srand((unsigned)time(NULL)); ,这个语句将生成随机数的种子
- rand() 只能生成 [0,RAND_MAX] 范围内的整数,RAND_MAX 在不同系统环境中不同,假设该系统为 32767
- 如果想要输出给定范围 [a,b] 内的随机数,需要使用 rand() % (b-a+1) + a
- 如果需生成更大的数,例如 [a,b],b 大于 32767,可以使用 (int)(round(1.0*rand()/RAND_MAX*(b-a)+a))
在此基础上继续讨论快排的写法。不妨生成一个范围在 [left,right] 内的随机数 p,然后以 A[p] 作为主元来进行划分。具体做法是:将 A[p] 与 A[left] 交换,然后按原先 Partition 函数的写法即可,代码如下:
1 // 随机选择主元,然后进行划分 2 int randPartition(int A[], int left, int right) { 3 // 生成 [left,right] 内的随机数 p 4 int p = (int)(round(1.0*rand()/RAND_MAX*(right-left) +left)); 5 swap(A[p], A[left]); // 交换 A[p] 和 A[left] 6 7 // 以下跟 Partition 完全相同 8 return Partition(A, left, right); 9 }