【PKU算法课0x03】分治算法

分治算法概念

把一个任务,分成形式和原任务相同,但规模更小的几个部分任务(通常是两个部分),分别完成,或只需要选一部分完成。然后再处理完成后的这一个或几个部分的结果,实现整个任务的完成。

听上去和整体来做没什么区别,其实不然。根据上面的定义,我们如果只选一部分就能完成任务,那么会大大降低时间复杂度。而且,就算是每个部分都要搞,那某些时候时间复杂度也会大大降低的,可以用数学论证。

分治实例

称硬币

问题描述:

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个过程,直到左右碰头。

具体怎么做的呢?

  1. 记待排序序列左端下标为i,右端下标为j。
  2. 从j开始向左扫描,如果碰到比key小的值, 则arr[i]与arr[j]的值进行交换。
  3. 从i开始向右扫描,如果碰到大于等于key的值, 则arr[i]与arr[j]的值进行交换。
  4. 直到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;
    }
发布了333 篇原创文章 · 获赞 22 · 访问量 2万+

猜你喜欢

转载自blog.csdn.net/weixin_41687289/article/details/104152261