1、该系列为ACWing中算法基础课,已购买正版,课程作者为yxc
2、y总培训真的是业界良心,大家有时间可以报一下
3、为啥写在这儿,问就是oneNote的内存不够了QAQ
本节内容:排序(快排、归并排序)、二分(整数二分、浮点数二分)
一、排序
排序算法中稳定:如果说原序列中两个值是相同的,排完序后两个值的位置不发生变化则是稳定的,可能发生变化就是不稳定的。
推荐阅读:十大排序算法(力扣)、十大排序算法总结(ACWing)
1.1 快排
- 快排基于分治的思想,是不稳定的算法,但是如果将快排的 a i a_{i} ai变为 < a i , i > <a_{i},i> <ai,i>的二元组(相同的数变为不同的数),就是稳定的算法;
- 时间复杂度为
O(NlogN)
,最坏N^2
- 快排的步骤:
-
- 确定分界点:
- 确定分界点的常用方式:(1) 直接取左边界
q[l]
;(2) 取两端的中间点q[(l+r)/2]
,(3) 取右边界q[r]
;(4) 取随机点;
-
- 调整区间:(假设确定
x
为分界点)满足小于等于x
的在左边,大于等于x
的在右边;
- 调整区间:(假设确定
-
- 递归:递归的处理左右两段区间;
-
=============================================================
-
调整区间的简单实现方法1(暴力做法但时间复杂度是线性的):
- 1、先开两个额外的数组
a[],b[]
; - 2、扫描整个区间
q[l-r]
,小于等于x插到a,大于x插到b; - 3、将数组
a[],b[]
的数据放入q[]
中;
- 1、先开两个额外的数组
-
调整区间的实现方法2(双指针):
- 1、用两个指针
i,j
,从两边同时向中间走; - 2、先开始移动
i
,i
指向的数字小于x,则i
后移一位;直到如果数字大于等于x
(应该放在右边),则停止移动i
,开始持续移动j
; - 3、当
j
指向的数大于x
,持续移动j
;当j
指向的数小于x
,i,j
的数字错位(此时i
指向的数需要放到右边,j
指向的数需要放到左边),交换i,j
指向的两个数字,并继续移动i,j
,直到两个指针相遇;(注意,这里不需要交换的两个数字在新的区间里是有顺序的,它的顺序会在下一次递归里进行排序)
- 1、用两个指针
快排代码实现:
在C++输入大量的数字时建议用scanf
,而不是cin
;同理,JAVA中不用sanner
,而是BufferedReader
#include <iostream>
using namespace std;
const int N = 100010;
int q[N];
void quick_sort(int q[], int l, int r)
{
if (l >= r) return;// 判断边界:没有数或只有1个数就return
int i = l - 1, j = r + 1, x = q[l + r >> 1];// 两侧和中间的指针
while (i < j)
{
do i ++ ; while (q[i] < x); // q[i] <= x 会发生数组越界
do j -- ; while (q[j] > x);
if (i < j) swap(q[i], q[j]);// 或设置第三个变量交换
}
quick_sort(q, l, j);
quick_sort(q, j + 1, r);
}
int main()
{
int n;
scanf("%d", &n);
for (int i = 0; i < n; i ++ ) scanf("%d", &q[i]);
quick_sort(q, 0, n - 1);
for (int i = 0; i < n; i ++ ) printf("%d ", q[i]);
return 0;
}
-
注意循环
while (q[i] < x)
不能写为q[i]<=x
,否则会一直满足<=
关键值得这个条件,就会发生数组越界(如49,59,88, 37,98,97,68,54,31,3,其中x=98) -
两个指针交换完之后都要移动一格,因此设计
i
,j
都在边界外,不管三七二十一先移动左右指针,再判断; -
x可以取
q[l]
、q[(l+r)/2]
、q[r]
; -
如果在
quick_sort(q, l, j); quick_sort(q, j + 1, r);
时取x = r
;或在quick_sort(q, l, i-1); quick_sort(q, i, r);
时取x = l
会面临边界造成的quick_sort()
函数递归死循环的问题。例如长度为2的区间[1, 2]
,如果取x = q[l]
,那么划分结束后会分成一个长度是0的区间quick_sort(q, l, i-1)——>quick_sort(q, 0, -1)
和一个长度是2的区间quick_sort(q, i, r)——>quick_sort(q, 0, 1)
,就无限递归了。具体代码如下: -
以
j
为中间点:
-
以
i
为中间点:
-
注意:由于使用
do-while
循环,所以i
和j
一定会自增!!,使得循环会继续下去,但是如果采用while
循环(i
和j
的初始化做出对应的变更),i
和j
在特殊情况下不自增的话,循环就会卡死; -
在循环中产生的边界问题:
quick_sort(a, l, j); quick_sort(a, j + 1, r);
为什么不能换成quick_sort(a, l, i); quick_sort(a, i + 1, r)
(即将 j+1 换成 i+1 作为中间区域的边界 / 为什么不能将 i-1 换为 j-1)?- 比如
x=3, 一段数 ,2,4,5,*
,此时i走到了4下面,j走到了2下面,quick_sort(a, l, i);
中,左边区间包括了一个大于x=3的4,会导致排序错误。所以如果是 i 的话,i 要包括在右边区间里面,也就是quick_sort(a, l, i-1); quick_sort(a, i, r);
- 比如
do i++; while(q[i] < x)
和do j--; while(q[j] > x)
不能用q[i] <= x
和q[j] >= x
- 假设
q[l..r]
全相等则执行完do i++; while(q[i] <= x);
之后,i
会自增到r+1
;然后继续执行q[i] <= x
判断条件,造成数组下标越界(但这貌似不会报错) 并且如果之后的q[i] <= x
(此时i > r
) 条件也不幸成立,就会造成一直循环下去,造成内存超限(Memory Limit Exceeded)
- 假设
- while循环代替do-while:用
while(q[i] < x) i++;
和while(q[j] > x) j--;
判断, 当q[i]
和q[j]
都为 x 时,i
和j
都不会更新,导致 while 陷入死循环。(应改为while(q[i] <= x) i++;
while(q[j] >= x) j--;
)。可以代替为以下代码,这样即使循环结束时q[i]==q[j]==x
,i,j
也能更新。
另一种快排方法
int partition( int[] nums, int left, int right)
{
int pivot = nums[left]; // 左边第一个数为基准数
int i = left + 1;
int j = right;
while(true)
{
//向右遍历扫描
while(i <= j && nums[i] <= pivot) i++;
//向左遍历扫描
while(i <= j && nums[j] => pivot) j--;
if(i >= j)
break;
//交换
int temp = nums[i];
nums[i] = nums[j];
nums[j] = temp;
}
//把nums[j]和基准数交换
nums[left] = nums[j];
nums[j] = povit;
return j;
}
void quickSort(int[] nums, int l, int r) {
// 子数组长度为 1 时终止递归
if (l >= r) return;
// 哨兵划分操作
int i = partition(nums, l, r);
// 递归左(右)子数组执行哨兵划分
quickSort(nums, l, i - 1);
quickSort(nums, i + 1, r);
}
// 调用
int main()
{
vector<int> nums = { 4, 1, 3, 2, 5, 1};
quickSort(nums, 0, nums.size() - 1);
for (int i = 0; i < nums.size(); i ++ ) printf("%d ", nums[i]);
return 0;
}
- 快排的边界问题:取中间区域的值作为基准值也需要考虑向上还是向下取整的问题,想要用
i - 1 和 i
的话,需要用x = q[l + r + 1 >> 1]
或x = q[r]
作为基准值;用j 和 j + 1
的时候,需要用x = q[l + r >> 1]
或x = q[l]
。
============================================================================================
-
java模板1
import java.util.*; import java.io.*; public class Main{ public static void main(String[] args) throws IOException{ BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); int n = Integer.parseInt(reader.readLine()); int[] arr = new int[n]; String[] strs = reader.readLine().split(" "); for(int i = 0; i < n; i++){ arr[i] = Integer.parseInt(strs[i]); } quickSort(arr, 0, arr.length - 1); for(int i = 0; i < arr.length; i++){ System.out.print(arr[i] + " "); } reader.close(); } public static void quickSort(int[] arr, int start, int end){ if(start < end){ int low = start; int high = end; int stard = arr[start]; while(low < high){ while(low < high && stard <= arr[high]){ high--; } arr[low] = arr[high]; while(low < high && arr[low] <= stard){ low++; } arr[high] = arr[low]; } arr[low] = stard; quickSort(arr, start, low); quickSort(arr, low+1 ,end); } } }
-
java模板2
public class Main { public static void main(String[] args) throws IOException { // 输入 BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); int n = Integer.parseInt(reader.readLine()); int[] arr = new int[n]; String[] strs = reader.readLine().split(" "); for( int i=0; i<n;i++){ arr[i] = Integer.parseInt(strs[i]); } quickSort(arr, 0, arr.length-1); // 输出 for(int i = 0; i< arr.length;i++){ System.out.print(arr[i]+ " "); } reader.close(); } public static void quickSort(int[] arr, int start , int end) { if (start >= end) return; int left = start - 1, right = end + 1, mid = arr[start+((end-start) >> 1)]; while (left < right) { do { left++; } while (arr[left] < mid); do { right--; } while (arr[right] > mid); if (left < right) { int tmp = arr[left]; arr[left] = arr[right]; arr[right] = tmp; } } quickSort(arr, start, right); quickSort(arr, right + 1, end); } }
-
问题证明:快速排序算法的证明与边界分析
-
快排虽然每次划分的区间不一定恰好是
N/2
,但是期望是N/2
的,此情况下其递归的层数期望也是 l o g 2 N log_{2}N log2N的。
1.2 归并排序
-
归并的基本思想也是分治,时间复杂度为
O(Nlog(N))
,且算法是稳定的。 -
归并的步骤:
- 找分界点,以中间点为分界线,归并是先递归再分别处理(快排是先分两边再递归)
- 递归排序左边和右边,两边变为有序的链表;
- 归并,将两个有序的数组合并为一个有序的数组;
-
归并两个有序数组的方法(暴力方法)
- 比较两个指针位置的数的大小,找到两个数组中的最小值,直到一个指针到达终点,可以把剩下的补到结果里(新开一个存结果的数组);
- 在归并两个数组过程中,一个指针最多扫描一半的数组,因此总共扫描长度为
O(N)
,每个元素只会被比较一次,时间复杂度为O(N)
;该步骤只是算法中的递归一次执行的;
-
算法时间复杂度
O(Nlog(N))
的具体计算:数组为n,需要除 log 2 n \log_{2}n log2n次才能除到1,因此共有 log 2 n \log_{2}n log2n层,每一层的时间复杂度为O(n)
,因此总共O(Nlog(N))
。
-
C++模板
#include <iostream> using namespace std; const int N = 1000010; int q[N], tmp[N]; void merge_sort(int q[], int l, int r) { if (l >= r) return; // 元素个数是1或没有元素; int mid = l + r >> 1; merge_sort(q, l, mid), merge_sort(q, mid + 1, r); // 归并 int k = 0, i = l, j = mid + 1; // k是当前已经合并的数目,ij是左右指针 while (i <= mid && j <= r) if (q[i] <= q[j]) tmp[k ++ ] = q[i ++ ]; // 如果左半部分现在指的数小于右半部分,tmp[k] = q[i]; k++, j++; else tmp[k ++ ] = q[j ++ ]; // 右半部分现在指的数小于左半部分; // 处理未循环完的部分; while (i <= mid) tmp[k ++ ] = q[i ++ ]; while (j <= r) tmp[k ++ ] = q[j ++ ]; // 复制结果,将tmp[0..r-l+1]复制到q[l..r]中 for (i = l, j = 0; i <= r; i ++, j ++ ) q[i] = tmp[j]; } int main() { int n; scanf("%d", &n); for (int i = 0; i < n; i ++ ) scanf("%d", &a[i]); merge_sort(a, 0, n - 1); for (int i = 0; i < n; i ++ ) printf("%d ", a[i]); return 0; }
-
归并排序求逆序对:
- 将所有的逆序对分为三大类:
- (1)两个数同时出现在左半边;
- (2)两个数同时出现在右半边;
- (3)一个在左半边,一个在右半边;
- 假定归并排序的函数可以在整个区间排好序的同时,求出区间内部所有逆序对的个数(
Sj = mid-i+1
)从i
到mid
都比j
大。逆序对的个数最坏情况下是n*(n-1)/2
#include <iostream> using namespace std; typedef long long LL; const int N = 100010; int n; int q[N], tmp[N]; LL merge_sort(int l, int r) { if(l>=r) return 0; // 或(l==r) int mid = l+r >> 1; LL res = merge_sort(l, mid) + merge_sort(mid+1, r); // 归并的过程 int k = 0, i = l, j = mid+1; while(i<=mid && j <=r) if(q[i]<=q[j]) tmp[k++] = q[i++]; // 如果左半部分现在指的数小于右半部分 else { tmp[k++] = q[j++]; // 右半部分现在指的数小于左半部分(i到mid的数都大于j); res+=mid-i+1; } //处理未循环完的部分 while(i<=mid) tmp[k++] = q[i++]; while(j<= r) tmp[k++] = q[j++]; //物归原主,i循环原始数组,j循环临时数组 for (int i = l, j=0; i <= r; i ++, j++ ) q[i] = tmp[j]; return res; } int main() { cin >> n; for (int i = 0; i < n; i ++) cin >> q[i]; cout << merge_sort(0, n-1) << endl; return 0; }
1.3 快速选择算法
-
快速选择算法的时间复杂度是O(n)(依次处理的区间长度为
n+n/2+n/4+... <= 2n
)。 -
快速选择算法的含义是:
-
首先对数组进行快速排序(找到分界点;左边所有数left <=x, 右边所有数 right >=x,中间值不一定等于x;递归排序 left,递归排序 right)(Sl和Sr是左右两边数的数量)
-
其次选择k,如果
k<=Sl
,则递归左半边;反之,k>Sr
递归右半边,找k-Sl
的数
-
-
与快排的不同在于快排需要递归两边,快速选择每次只需要递归一边。
-
举例:给定一个长度为 n 的整数数列,以及一个整数 k,请用快速选择算法求出数列从小到大排序后的第 k 个数。
do-while循环,这里将第k个数转化为找k-1的下标 #include <iostream> #include <vector> using namespace std; vector<int> a; int quick_sort(int l, int r, int k) { if(l >= r) return a[k]; int x = a[l], i = l - 1, j = r + 1; while (i < j) { do i++; while (a[i] < x); do j--; while (a[j] > x); if (i < j) swap(a[i], a[j]); } if (k <= j) return quick_sort(l, j, k); else return quick_sort(j + 1, r, k); } int main() { int n, k; cin >> n >> k; a = vector<int>(n, 0); for (int i = 0; i < n; i++) { cin >> a[i]; } cout << quick_sort(0, n - 1, k - 1) << endl; return 0; }
while循环 #include <iostream> using namespace std; const int N = 100010; int n, k; int q[N]; int quick_sort(int l, int r, int k) { if(l == r) return q[l]; int x = q[l], i= l-1, j = r+1; while(i<j) { while( q[++i] < x); while( q[--j] > x); if(i<j) swap(q[i], q[j]); } //查看左边数的数量 int sl = j-l+1; if(k <= sl) return quick_sort(l, j, k); return quick_sort(j+1, r, k-sl); } int main() { cin >> n >> k; for (int i = 0; i < n; i ++ ) cin >> q[i]; cout << quick_sort(0, n-1, k) << endl; return 0; }
-
快速选择算法里可以写
if(l == r) return q[l];
或if(l >= r) return q[l];
;但是在快排里必须写if(l == r) return q[l];
,因为在快排里区间可能是没有数的(即出现l>r
的情况)
二、二分
2.1 整数二分
-
整数二分的本质不是单调性:如果有单调性,那么一定可以二分,但是可以二分的题目不一定非要单调性。
-
整数二分的本质是边界:假设给定一个区间
[l,r]
,我们在区间中定义了某种性质。使得在右半边区间是满足的,在左半边区间是不满足的,整个区间可以被一分为二,二分可以寻找性质的边界(两个边界都可以)。(即找到一个性质,可以将数据一分为二)
-
整数二分需要注意边界问题。
-
整数二分有两个模板,分别适用不同的情况。两个模板核心的区别是
int mid = l + r + 1 >> 1;
要不要加一,代表了两个区域的边界点; -
首先讨论寻找红色区域的边界点:
- 1、找到中间点
mid = (l+r+1)/2
,并判断中间值是否满足该性质if (check(mid))
,check
为true
说明满足条件,中间值在红色区间,答案在[mid, r]
,更新方式是将[l, r]
区间更新为[mid, r]
区间(l = mid
);2、check
为false
说明,mid
取在绿色区域,答案在[l, mid-1]
,更新方式是将[l, r]
区间更新为[l, mid-1]
区间(r = mid-1
)。
- 1、找到中间点
-
其次讨论寻找绿色的边界点:
- 1、求
mid =(l+r)/2
并if (check(mid))
是否满足绿色的性质check
为true
说明满足条件,中间值在绿色区间,答案在[l, mid]
,更新方式是将[l, r]
区间更新为[l, mid]
区间(r = mid
);2、check
为false
说明,mid
取在红色区域,答案在[mid+1, r]
,更新方式是将[l, r]
区间更新为[mid+1, r]
区间(l = mid+1
)。
- 1、求
-
因此选择两个模板是看
l
还是r
等于mid
;// check 函数 bool check(int x) {/* ... */} // 检查x是否满足某种性质 // 区间[l, r]被划分成[l, mid]和[mid + 1, r]时使用: // mid 属于左半边 int bsearch_1(int l, int r) { while (l < r) { int mid = l + r >> 1; if (check(mid)) r = mid; // check()判断mid是否满足性质 else l = mid + 1; } return l; } // 区间[l, r]被划分成[l, mid - 1]和[mid, r]时使用: // mid 属于右半边 int bsearch_2(int l, int r) { while (l < r) { int mid = l + r + 1 >> 1; if (check(mid)) l = mid; else r = mid - 1; } return l; }
-
为什么
mid
要加一:- c++整数除法是下取整,当
l = r-1
时,如果mid = (l+r)/2
则mid=l
,如果寻找红色边界且check
返回true
那么区间更新为[mid, r], l = mid => l
,边界进入死循环; - 因此当
mid = (l+r+1)/2
,相同情况下mid=r
不会死循环。
- c++整数除法是下取整,当
-
举例(数的范围):
- 给定一个按照升序排列的长度为 n 的整数数组,以及 q 个查询。对于每个查询,返回一个元素 k 的起始位置和终止位置(位置从 0 开始计数)。如果数组中不存在该元素,则返回
-1 -1
。 - 输入格式,第一行包含整数 n 和 q,表示数组长度和询问个数。第二行包含 n 个整数(均在 1∼10000 范围内),表示完整数组。接下来 q 行,每行包含一个整数 k,表示一个询问元素。
- 输出格式。共 q 行,每行包含两个整数,表示所求元素的起始位置和终止位置。如果数组中不存在该元素,则返回
-1 -1
。
- 给定一个按照升序排列的长度为 n 的整数数组,以及 q 个查询。对于每个查询,返回一个元素 k 的起始位置和终止位置(位置从 0 开始计数)。如果数组中不存在该元素,则返回
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 100010;
int n, m;
int q[N];
int main()
{
scanf("%d%d", &n, &m);
for (int i = 0; i < n; i ++ ) scanf("%d", &q[i]);
while(m --)
{
int x;
scanf("%d", &x);
int l = 0, r = n-1;
// 查询左端点
while(l < r)
{
// x以后所有数大于等于x;
int mid = l+r >> 1;
if (q[mid] >=x) r = mid;
else l = mid+1;
}
if(q[l] != x) cout<< "-1 -1"<< endl;
// 如果用q[l]>x来判断,当数组中所有数都小于x时,判断就错了
else
{
cout << l << ' ';
// 查询右端点
int l = 0, r = n-1;
while (l<r)
{
int mid = l+r+1 >>1;
if(q[mid] <=x) l = mid;
else r = mid - 1;
}
cout << l << endl;
}
}
return 0;
}
2.2 浮点数二分
-
浮点数二分本质上也是一个边界,但没有整除,每次区间可以严格的缩小一半。
-
当区间长度很小时(
r-l<= 10-e^6
),认为找到了答案。 -
浮点数二分需要注意,当求一个浮点数的平方根时,右边界需要扩大,否则二分会产生问题。(如浮点数
x=0.01
,平方根是0.1,不在0-0.01之间,需要将右边界改为x+1
或max(1, x)
,不能直接取右边界为x
) -
举例,开平方:
#include <iostream>
using namespace std;
int main()
{
double x;
cin >> x;
double l = 0, r = 100; // r不能等于x,x的二次方根一定在这个区间里;
while ( r-l >1e-6)
{
double mid= (l+r)/2;
if(mid*mid >= x) r = mid;
else l = mid;
}
printf("%lf\n", l);
return 0;
}
-
保留4位小数需要
r-l >1e-6
,保留5位小数需要r-l >1e-7
;保留6位小数需要r-l >1e-8
-
浮点数二分的另一种写法,不用精度去表示迭代,而是直接循环100次。
#include <iostream>
using namespace std;
int main()
{
double x;
cin >> x;
double l = 0, r = 100;
for(int i = 0; i<100, i++)
{
double mid= (l+r)/2;
if(mid*mid >= x) r = mid;
else l = mid;
}
printf("%lf\n", l);
return 0;
}