分治算法概念
把一个任务,分成形式和原任务相同,但规模更小的几个部分任务(通常是两个部分),分别完成,或只需要选一部分完成。然后再处理完成后的这一个或几个部分的结果,实现整个任务的完成。
听上去和整体来做没什么区别,其实不然。根据上面的定义,我们如果只选一部分就能完成任务,那么会大大降低时间复杂度。而且,就算是每个部分都要搞,那某些时候时间复杂度也会大大降低的,可以用数学论证。
分治实例
称硬币
问题描述:
16枚硬币,有可能有1枚假币,假币比真币轻。有一架天平,用最少称量次数确定有没有假币,若有的话,假币是哪一枚。
如果用整体的方法来找的话,我们两个两个称一次,则需要8次才能称出来。
如果我们第一次8个 8个 一称的话,可以排除掉8个。
第二次4个4个一称,可以排除掉4个
第三次2个2个一称,可以排除掉2个。
第四次1个1个一称,找到正确答案。
归并排序
数组排序任务可以如下完成:
1) 把前一半排序
2) 把后一半排序
3) 把两半归并到一个新的有序数组,然后再拷贝回原数组,排序完成。
这就是归并排序。归并排序是一个典型的分治程序。
归并排序代码:
private void mergeSort(int[] arr,int left, int right, int[] tmp){
if(left < right){
int mid = (left+right)/2;
System.out.println(mid);
// 分
this.mergeSort(arr,left,mid,tmp);
this.mergeSort(arr,mid+1,right,tmp);
// 治
this.merge(arr,left,mid,right,tmp);
}
}
private void merge(int[] arr, int left, int mid, int right, int[] tmp){
// 归并函数
int ptrF = left,ptrS = mid+1,index=left;
while(ptrF<=mid&&ptrS<=right){
if(arr[ptrF]<arr[ptrS]){
tmp[index++] = arr[ptrF++];
}else{
tmp[index++] = arr[ptrS++];
}
}
while(ptrF<=mid){
tmp[index++] = arr[ptrF++];
}
while(ptrS<=right){
tmp[index++] = arr[ptrS++];
}
// 将改动体现回原数组
int i = left;
while(i<=right){
arr[left++] = tmp[i++];
}
}
归并排序的时间复杂度分析:
快速排序
快速排序也是分治算法的典型程序。
数组排序任务可以如下完成:
1)设k=a[0], 将k挪到适当位置,使得比k小的元素都在k左边,比k大的元素都在k右边,和k相等的,不关心,在k左右出现均可 (O(n)时间完成)
2) 把k左边的部分快速排序
3) 把k右边的部分快速排序
一般来说,我们都选取待排序序列的第一个元素作为key。然后把比它小的挪动到它的左边,比它大的挪动到它的右边。思想:从右往左扫描, 遇到比key小的元素,就将它与key互换。然后从左往右扫描,遇到大于等于key的元素就将它与key互换。重复以上2个过程,直到左右碰头。
具体怎么做的呢?
- 记待排序序列左端下标为i,右端下标为j。
- 从j开始向左扫描,如果碰到比key小的值, 则arr[i]与arr[j]的值进行交换。
- 从i开始向右扫描,如果碰到大于等于key的值, 则arr[i]与arr[j]的值进行交换。
- 直到i == j 时退出循环
为什么要先从j往左扫描?
因为我们选择的key是序列的第一个值,如果从左边开始扫描的话,我们要交换arr[i]和arr[j]的值,我们并不知道此时arr[j]的值比key大还是小,但我们知道arr[i]的值与key的值相等。
为什么交换arr[i]和arr[j]就能起到这个作用呢?因为其实我们在第一次交换的时候,key已经被交换到arr[j]了。 然后第二次交换,key又跑到arr[i]了。
现在我们该把左边序列和右边序列都进行快排了。现在有个问题,怎么分左序列的右序列?
快排有一个典型的特点,那就是每趟快排下来,都有一个元素在它最终的位置上了。所以说这个元素不用参与排序了。这个元素是什么?那就是中枢值, 即arr[i]。所以我们在分治的时候不需要考虑arr[i]。我们只需要快排arr[left,i-1], arr[i+1,right]即可。
AC代码:
private void quikSort(int[] arr, int left, int right){
if(left >= right){
// 这也是递归的,所以应当有退出条件
return;
}
int flag = arr[left];
int i = left,j = right;
while(i!=j){
while(j>i && arr[j]>=flag){
j--;
}
this.swap(arr,i,j);
// swap完毕,中枢元素肯定在j
while(j>i && arr[i]<flag){
i++;
}
this.swap(arr,i,j);
// swap完毕,中枢元素肯定在i
}
// 所以可以放心得递归
this.quikSort(arr,left,i-1);
this.quikSort(arr,i+1,right);
}
private void swap(int[] arr,int left,int right){
// 注意,用位运算进行交换时,left和right不能是同一片内存单元
if(left == right){
return;
}
arr[left] = arr[left]^arr[right];
arr[right] = arr[left]^arr[right];
arr[left] = arr[left]^arr[right];
}
输出前m大的数
问题描述:
给定一个数组包含n个元素,统计前m大的数并且把这m个数从大到小输出。
输入:
第一行包含一个整数n,表示数组的大小。n < 100000。第二行包含n个整数,表示数组的元素,整数之间以一个空格分开。每个整数的绝对值不超过100000000。
第三行包含一个整数m。m < n。
输出:
从大到小输出前m大的数,每个数一行。
思路:
我们一个朴素的想法就是,先排序,然后直接输出,复杂度是排序的复杂度,即O(nlogn).
这个复杂度说实话已经特别好了,但是我们学了分治之后有一种更好的方法。那就是,我们想办法把k个最大的元素移动到一边,然后对它进行排序即可。我们可以用分治算法将把k个最大的元素移动到一边的时间复杂度降为O(n)。
由此可见,我们可以通过快排稍加改动来做到这一点。
我们来算一下时间复杂度:
AC代码:
private void swap(int[] arr,int left,int right){
// 注意,用位运算进行交换时,left和right不能是同一片内存单元
if(left == right){
return;
}
arr[left] = arr[left]^arr[right];
arr[right] = arr[left]^arr[right];
arr[left] = arr[left]^arr[right];
}
private void topK(int[] arr,int k){
this.arrangeRight(arr,k,0,arr.length-1);
// 对右面k个元素进行快排
this.quikSort(arr,arr.length-k,arr.length-1);
for(int i = arr.length-k; i < arr.length; i++){
if(i == arr.length-k){
System.out.print(arr[i]);
}else{
System.out.print(" ");
System.out.print(arr[i]);
}
}
System.out.println();
}
private void arrangeRight(int[] arr,int k,int head, int tail){
/*
* 函数功能:把k个最大值移动到arr右端
* */
if(head >= tail){
return;
}
int flag = arr[head];
int i = head,j = tail;
while(i!=j){
while(i<j&&flag<arr[j]){
j--;
}
this.swap(arr,i,j);
while(i<j&&flag>=arr[i]){
i++;
}
this.swap(arr,i,j);
}
if(tail-i+1 == k){
return;
}else if(tail-i+1 > k){
/*
* 为什么要这么缩小范围?因为我们明确的得知了i肯定不符合题意,
* 所以就摒弃掉i就可以了
* */
// 如果>k 则从i+1开始,以期缩小范围
this.arrangeRight(arr,k,i+1,tail);
}else{
// 如果<k, 则从i-1开始,以期缩小范围
this.arrangeRight(arr,k-(tail-i+1),head,i-1);
}
}
求排列的逆序数
问题描述:
方法:
2) 的关键:左半边和右半边都是排好序的。比如,都是从大到小排序的。这样,左右半边只需要从头到尾各扫一遍,就可以找出由两边各取一个数构成的逆序个数。
排序怎么做?用归并排序。扫描怎么做?仔细想想,拿纸模拟一下。
由于我们只是在挪动ptr, 所以说我们的时间复杂度可以做到O(n)。
总结:
由归并排序改进得到,加上计算逆序的步骤。
AC代码:
import java.util.*;
public class Main{
static long COUNT = 0;
public static void main(String[] args){
Main main = new Main();
Scanner scanner = new Scanner(System.in);
int n = scanner.nextInt();
int[] arr = new int[n];
int[] tmp = new int[n];
for(int i = 0; i < n; i++){
arr[i] = scanner.nextInt();
}
main.mergeSortReverse(arr,0,arr.length-1,tmp);
System.out.println(COUNT);
}
void mergeSortReverse(int[] arr, int left, int right,int[] tmp){
if(left >= right) return;
int mid = (left+right)/2;
this.mergeSortReverse(arr,left,mid,tmp);
this.mergeSortReverse(arr,mid+1,right,tmp);
// 到这儿,左序列和右序列分别有序
int ptr1 = left, ptr2 = mid+1;
long count = 0;
while(ptr1<=mid){
while(ptr2<=right && arr[ptr2]<arr[ptr1]){
count += mid-ptr1+1;
ptr2++;
}
ptr1++;
}
COUNT+= count;
this.merge(arr,left,mid,right,tmp);
}
void merge(int[] arr,int left,int mid,int right,int[] tmp){
int ptr1 = left,ptr2 = mid+1,index = left;
while(ptr1<=mid && ptr2<=right){
if(arr[ptr1]<=arr[ptr2]) tmp[index++] = arr[ptr1++];
else tmp[index++] = arr[ptr2++];
}
while(ptr1<=mid) tmp[index++] = arr[ptr1++];
while(ptr2<=right) tmp[index++] = arr[ptr2++];
int i = left;
while(left<=right) arr[left++] = tmp[i++];
}
}
注意,存储count要用long,防止溢出。
彩蛋-快速幂算法
问题描述:
这个是leetcode让网友实现pow的原题。
面试官考这个题目,并不是想让大家实现一个能在工业当中应用的pow函数,而是想考察大家的分治算法掌握的怎么样。
问题分析:
如果我们算24的话,我们要算4次的乘法运算。
如果我们用快速幂的话,我们需要3次乘法运算。
比如216,我们大可不必一样一样的乘下去,因为216 = 28*28,所以说我们算出来28就行了,递归下去就行了。
所以我们快速幂的复杂度是logb的。
递归AC代码:
private int powA(int a,int b){
if(b == 0){
return 1;
}
if((b&1) == 1){
// 如果b是奇数
return a*this.powA(a,b-1);
}else{
int tmp = this.powA(a,b/2);
return tmp*tmp;
}
}
我们可以把递归转成非递归。
但是如果硬转我们会一头雾水。所以:
如果我们要求2^7按照我以前的思维就是 ans=1*7*7*7*7*7*7*7
但是他的时间复杂度是O(n)
所以我们可以知道 27我们可以拆成 27=24*22*21 这样是不是发现就只要计算三次了呢!
如果这个不明显我们可以看看263按照一般方法我们要计算63次,但是263=232*216*28*24*22*21 这样只计算了6次! 差别就明显了.
现在我们就要想办法如何实现这个过程了 我们可以发现其实我们是把指数看成了二进制数7=111 63=111111 我们要判断指数在二进制的时候 每一位上是否为1 就知道是否要乘了。
AC代码:
private int powB(int a,int b){
// 递归改为非递归,效率更高
int result = 1;
int base = a;
while(b != 0){
if((b&1) == 1){
// b的某个二进制位为奇数,代表要乘了
result *= base;
}
base *= base; // 每次往上乘,base都是当前base的平方
// 因为高位的权重是2^i,低位的权重是2^(i/2)
// 而base*base就代表了当前因子的值
// 我们的算法是:
/* 7的二进制表示是1111
* n^7 = n^4*n^2*n^1*n^0
* 而base就是n^1
* 右移一位7,变成111 base变成了n^2
* 所以直接相乘即可
* 也就是说,base*base代表了当前n^2的值
* */
b>>=1; // b = b/2;
}
return result;
}