算法二:归并排序、快排、堆、堆排序、堆应用

归并排序:

想要这个数组有序,只需要令左半边有序,右半边也有序,再把两部分合并。

 

归并排序递归实现:

递归方法:f(arr,left, right),

递归出口:如果left == right,即当前部分只有一个元素,直接返回,

递归调用:中点mid = left +(right-left)>> 1,左边排序f(arr,left,mid),右边排序f(arr,mid+1,right),合并merge(arr,left,mid,right),

其中merge不涉及递归,只是一个普通方法,

在merge中,arr分成left到mid和mid+1到right两部分,有左右两指针分别指向两部分的开始,

创建一个新数组,长度为right-left+1,即要合并的数组有几个元素就开多大,

比较左右两指针指向的数据,谁小就把谁添加到新数组中,指针++,如果有一个指针再++就越界了,就把另一个指针后面的全部添加到新数组。

 

归并排序非递归实现:

思路:

第一轮每2个分成一组,每组左部分有1个右部分有1个,merge,让这两个元素有序,

下一轮每4个分成一组,每组左部分有2个已经有序,右部分有两个已经有序,merge,让这一组有序,

如果不能整分剩下两个或一个,就不管他,剩下三个,就分成左边2个右边1个,

……

直到让整个数组有序。

public static void mergeSort2(int[] arr) {
   if (arr == null || arr.length < 2) {
      return;
   }
   int N = arr.length;
   int mergeSize = 1;// 当前有序的,左组长度
   while (mergeSize < N) { // log N
      int L = 0;
      // 0.... 
      while (L < N) {
         // L...M  左组(mergeSize)
         int M = L + mergeSize - 1;
         if (M >= N) {
            break;
         }
         //  L...M   M+1...R(mergeSize)
         int R = Math.min(M + mergeSize, N - 1);
         merge(arr, L, M, R);
         L = R + 1;
      }
      if (mergeSize > N / 2) {
         break;
      }
      mergeSize <<= 1;
   }
}

mergeSize是merge时左右两部分的长度,如果mergeSize >= N,说明整个数组都位于左半边,而merge时左右两边已经是有序的,说明这时数组已经有序了;

M = L + mergeSize - 1,L到M就是左组,如果数据够,R = M + mergeSize,M+1到R就是右组,如果数据不够,M到N-1就是右组;

为什么要判断 mergeSize > N / 2 ?

如果数据量不大,这句代码是多余的,

如果mergeSize接近于N了,且mergeSize * 2会溢出int的范围(N是题目给出的肯定不会溢出),就必须要判断一下,否则直接乘2会溢出,发生不可预知的错误。

 

归并排序时间复杂度:

递归实现:

分成了2个子问题,每个子问题的范围缩小到1/2,merge的复杂度是O(N),因为左右指针总共遍历的范围最大是N,

根据Master公式,T(N)=2 * T(N/2)+O(N),a=2,b=2,d=1,满足3),T(N)=O(N * logN)。

非递归实现:

循环体中除了merge都是常数时间操作,每merge一轮就相当于遍历一遍,O(N),

遍历了多少遍?每次mergeSize * 2,判断mergeSize是否>N,那就是遍历了logN遍,

所以T(N)=O(N * logN)。

 

为什么归并比选择插入冒泡快:

因为后者在浪费比较行为。

以选择为例,第一轮把0到n-1都比较一遍,结果仅仅得到下标0的正确元素,没有给下一轮比较贡献一点帮助,

而归并排序没有把比较浪费,把一组merge之后,留下一组有序的,下一轮有用。

 

归并排序应用:求数组的小和:

数组中一个数左边所有比他小的数的和叫做这个数的小和,所有元素小和加起来叫这个数组的小和。

解法:套用归并排序的壳子,因为归并排序merge的时候左右两部分分别有序,且有比较操作,

归并排序merge的时候判断如果leftPoint <= rightPoint就把leftPoint加入新数组,leftPoint++,如果leftPoint > rightPoint就把rightPoint加入新数组,rightPoint++,

求小和的时候判断如果leftPoint < rightPoint就把leftPoint加入新数组,并算出右半部分有几个比leftPoint大的数count =(R - rightPoint + 1),数组小和 += count * leftPoint。

否则直接把rightPoint加入新数组。

 

归并排序应用:求数组的所有逆序对:

[1,6,2,5,4] 的所有逆序对有:6, 5,6, 4,5, 4。

解法:核心就是找到一个数右边所有比他小的数,

merge的时候判断如果leftPoint > rightPoint,把rightPoint加入新数组,同时把rightPoint到R的所有数作为第二个元素和leftPoint组成逆序对,

否则直接把leftPoint加入新数组。

判断的时候,判断左半边有几个数比rightPoint大  和  判断右半边有几个数比leftPoint小  效果一样。

 

Partition:

提供一个数组arr和一个数num,把数组中 <= num的放左边,> num的放右边。

解法:

划定一个<=区,位于数组的最左边,边界less = -1,

从0遍历数组,比较arr[ i ]和num的大小:

如果arr[ i ] <= num,就把arr[ i ]和边界less下一个数交换位置,边界扩展一位,然后i++下一个,

即wrap(arr,i++,++less),

如果arr[ i ] > num,直接i++下一个,

即如果碰到<=的就移到<=区(>区的最左边一个),遇到>的就不动他,留在>区。

 

荷兰国旗问题:

一个数组arr,在范围L到R内,以R为基准,把<R的放左边,=R的放中间,>R的放右边,返回一个数组,元素是=区的左右边界。

划定一个<区,边界less = -1,划定一个>区,边界more = R,

从0遍历数组,如果arr[ i ] < arr[R],就把arr[ i ]和边界less下一个数交换位置,less边界向右扩展一位,然后i++下一个,

即wrap(arr,i++,++less),

如果arr[ i ] > arr[R],就把arr[ i ]和边界more前一个数交换位置,more边界向左扩展一位,然后 i 留在原地,

即wrap(arr,i,--more),因为从右边界换过来 的元素没有经过判断,不能i++,

如果arr[ i ] = arr[R],不动,直接i++下一个,

到 i 和more边界相等就停下,最后把arr[R]和more边界处的数交换位置,让R加入=区。

(为什么>区边界不是R+1?都行,把arr[R]保存到一个num里,more=R+1,最后就不用交换arr[R])

 

快排1.0:

递归方法:quickSort(arr,L,R),

递归出口:L >= R,区间内没有元素,或只有一个元素已经有序,直接返回,

递归调用前:partition,以arr[R]为基准,从L到R-1遍历,把 <= arr[R]的放左边,> arr[R]的放右边,最后把arr[R]和less边界的下一个交换位置,让arr[R]加入<=区,这样之后<=区最后一个就是arr[R],不管前面还有没有和arr[R]相等的,反正arr[R]已经在他该在的位置了,记为M。

递归调用:把左边<=区不加M的部分递归quickSort(arr,L,M-1),再把右边>区递归quickSort(arr,M+1,R)。

 

快排2.0:

用荷兰国旗方法代替partition,以arr[R]为基准,划分成<区,=区,>区,

荷兰国旗方法返回一个数组index,index[0]是=区左边界,index[1]是=区右边界,

再对<区递归quickSort(arr,L,index[0]-1),对>区递归quickSort(arr,index[1]+1,R),

这样每次递归确定一组相等的数的位置。

 

快排1.0和2.0时间复杂度都是O(N2),

最好情况:每次找的数都能把数组划分为差不多长度的两部分,T(N)=2 * T(N/2)+ O(N)= O(N * logN),

最坏情况:已经有序或者倒序了,每个数找位置都要遍历一遍,一共遍历N遍,O(N2),

 

快排3.0:

和前面版本的区别是,不直接以最后一个元素当基准,而是随机找一个数,和最后一个数交换,然后接下来和上面版本完全一样。

这样时间复杂度成了概率事件,最坏情况的概率是1/N,所有情况的概率都是1/N,然后计算时间复杂度的期望,Σ ( 概率 * 时间复杂度 ) 最后= O(N*logN)。

 

完全二叉树:

二叉树的每一层要么是满的,要么是从左到右依次变满的,或者说从右到左依次缺少的。

完全二叉树可以用数组表示,

如果数组从下标0开始用,任意节点n的左子节点下标是n*2+1,右子节点下标是n*2+2,父节点下标是(n-1)/2,

如果数组从下标1开始用,任意节点n的左子节点下标是n*2,右子节点下标是n*2+1,父节点下标是n/2。

为什么要从1开始用?为了求子节点父节点运算更快,因为从下标1开始用的话,算术运算表达式全部可以用位运算代替:

任意节点n的左子节点下标是n<<1,右子节点下标是n<<1 | 1,父节点下标是n>>1。

 

堆就是节点值满足一定规则的完全二叉树,

大根堆要求根是当前子树的最大值,小根堆要求根是当前子树的最小值。

 

一个一个地提供值,创建一个大根堆:

定义一个数组heap,int heapSize = 0,来一个元素,添加到heap[heapSize],判断如果heap[heapSize]比heap[(heapSize+1)/2]大,就把heap[heapSize]和heap[(heapSize+1)/2]交换位置,然后继续往上判断,一直到不大于父节点,或到根节点。

最后一个节点上浮的过程,上浮成本是O(logN),因为只会和父节点比较,一次上浮一层,N个数一共有ceiling(logN)+1层,是logN级别的。

 

一个大根堆,要求取出并删除最大值,使剩下的结构依然保持大根堆:

记录根节点的值,把数组heap[heapSize-1]即最后一个节点的值赋值给根节点,heapSize--,表示最后一个节点已经排除在heap堆之外了。

最后一个节点放到根节点上肯定不满足大根堆,这时要和左右子节点中较大的那个比较,如果比子节点小,就和该子节点交换,根节点到达新位置,这时还有可能不满足,继续和子节点比较,直到不比子节点小或者没有子节点了。

即新的根节点要不断下沉,一直下沉到孩子不比他大,或者没有孩子。

 

堆排序:

第一步:先把数组构造成大根堆:依次添加到heap末尾并上浮,

第二步:把根节点heap[0]和末尾节点heap[heapSize-1]交换,heapSize--,这时根节点已经不在堆中,但是到了数组末尾,即到了他该到的位置,

然后新的根节点下沉,产生新的大根堆,再把根节点和末尾节点交换,heapSize--,又确定了一个数的位置……一直到heapSize==0就完成了排序。

N个数添加N次,每次上浮O(logN),每下沉N次,次下沉O(logN),时间复杂度是O(N*logN),

 

一股脑提供所有的值,创建大根堆:

不是一个一个给,一下子给一个数组,那么也可以按照前面创建大根堆的方法,从0到heapSize-1遍历节点,依次上浮(遍历到一个相当于提供了一个)。复杂度O(N * logN)。

有更快的方式,反着来,直接把数组看作完全二叉树,然后从末尾节点向根节点遍历,每个节点依次下沉,

N个数据,最后一层有N/2个节点,只遍历不需要下沉,倒数第二层有N/4个节点,遍历并且每个节点最多下沉1层,倒数第三层有N/8个节点,遍历并且每个节点最多下沉3层……

遍历这个节点算一次操作,下沉一层又算一次操作操作,

所以T(N) = N/2 * 1 + N/4 * 2 + N/8 * 3 + …… + 2 * (logN-1) + 1 * logN

两边*2,T(N) * 2 = N/2 * 2 + N/2 * 2 + N/4 * 3 + …… 2 * logN

下面减上面(下面第二个减上面第一个),T(N) = N + N/2 + N/4 + …… + 2 - logN

等比数列求和,最后O(N)。

这样堆排序的第一步可以优化到O(N),但是第二步还是O(N*logN),因此堆排序还是O(N*logN)。

 

一股脑提供一个数组创建大根堆,为什么从尾到头下沉比从头到尾上浮复杂度低:

因为从尾到头下沉,堆最下层元素最多的时候复杂度是O(1),堆最上层元素最少的时候是O(logN),

而从头到尾上浮,堆最下层元素最多的时候复杂度是O(logN),堆最上层元素最少的时候是O(1),谁大谁小显而易见。

为什么从头到尾上浮,复杂度到不了了O(N):

假设N个数据复杂度是O(N),那么2N个数据的复杂度也应该是O(N),而2N个数据的话,实际上后N个数据在加入的时候,

前N个数据已经形成堆,高度是logN,那么后N个数据上浮的复杂度都是logN级别的,那么后N个数据的复杂度就已经到了log(N * logN)。

 

 

堆排序应用:

已知一个几乎有序的数组arr,几乎有序是指,如果把元素排好序,每个元素的移动距离不会超过k,并且k对于数组长度来说是较小的。

即每个元素距离他应该在的位置不超过k。

可以用小根堆实现,假设k=5,那么有序数组arr[0]处的正确元素只可能存在于arr[0~5]之间,

因此将arr[0~5]压入小根堆,弹出最小值放到arr[0]处,arr[0]就确定好了,

下一步,arr[1]处的正确元素只可能存在于arr[0~6]之间,排除掉已经确定的一个,还剩下5个可能的位置,

因此将arr[6]也压入小根堆,此时堆内有5个元素,再弹出最小值放到arr[1]处,arr[1]也确定好了,

同理,arr[10]处的正确元素只可能存在于arr[5~15]之间,但找到arr[10]了就说明前面已经确定了10个位置,其中0~4的原有元素的正确位置肯定不能在arr[10],因为距离大于5,因此0~4的原有元素肯定已经确定下来了,剩下的5个已经确定的元素是可能位于arr[10]的,但他们既然已经确定了,就应该从arr[10]的候选名单中减去,即arr[10]的候选名单还剩5个,根据前面的经验,小根堆总是维持5个,所以这样是合理的,现在把arr[15]压入小根堆,又变成5个,然后弹出最小值放到arr[10]。当最后剩余没确定的数组元素小于5个时,直接把小根堆依次弹出最小值放到剩余位置。

即,几乎有序的距离不超过几,就维持长度是几的小根堆,每次弹出最小值在压入下一个元素。直到没有元素可压,就一股脑弹出全部。

小根堆大小是k,那么弹出元素再尾节点放到根节点再下沉 + 压入元素上浮 = O(logk) + O(logk) = O(logk),一共要弹N个元素,因此这个排序的时间复杂度是O(N * logk)。

如果小根堆大小是N,即全部元素上去先创建小根堆,再每次弹出一个最小值然后尾节点顶上下沉,那不就是堆排序,只不过刚才讲的堆排序是大根堆,这次换成小根堆了。

 

比较器:

目前讲的排序都是基于比较的排序,可以套用在任何数据类型的排序,只要规定比较规则,

自己定义的类型,要想实现比较,要借助比较器,

首先自己创建比较器类实现Comparator接口,MyComp implements Comparator,

然后重写compare(T o1,T o2)结果返回负数:o1<o2即o1排在o2前面,返回正数:o1>o2即o1排在o2后面,返回0:o1=o2,

即升序就是compare(T o1,T o2){ return o1 - o2 };降序就是compare(T o1,T o2){ return o2 - o1 };

最后在创建基于排序的数据结构时传入比较器。

 

Java提供的堆api默认是小根堆,怎样改成大根堆:

自己定义一个降序的比较器,

在创建Java提供的堆时传入比较器PriorityQueue<Integer> heap = new PriorityQueue<>(MyComp);

 

heapify操作:

就是弹出最值,尾元素换到根节点上之后,做的逐渐下沉的操作。做的是一个循环操作,循环条件是本节点下标<heapSize,

循环内部判断如果本节点比左右孩子中最大的孩子大,就退出循环。

 

heapInsert操作:

就是新元素添加到堆的末尾后,做的逐渐上浮的操作,是一个循环操作,循环条件是当前元素heap[index]比heap[(index-1)/2]的元素(父元素)小,

当自己index=0时,(index-1)/2也=0,不比他小,因此也会退出。

 

要求改变堆的元素后仍然能保持堆结构:

改变某个元素值后,如果需要得到下标,就定义一个Map<T,Integer>,记录每个节点对应的下标,每次增删节点都修改Map,

定义一个resign方法,当修改节点的值后,传入新值调用resign,在内部干了什么事:

用Map和值得到修改的下标,拿着这个下标先heapInsert(上浮)再heapify(下沉),

这两个操作都是循环判断,虽然把两个都写上但是上浮和下沉最多只能中一个,另一个循环不满足条件就退出了,

因为如果应该上浮,说明比父节点小(假设小根堆),根据小根堆性质,一个节点的父节点一定比这个节点的子节点小,

而这个节点比父节点都小,肯定比子节点小,因此不会下沉。反过来同理。

因此这个修改后还原堆结构的操作是O(logN)。如果题目涉及到修改堆中已存在的节点,那么就自己写上面的方法,复杂度O(logN),

不要用系统API,那个复杂度指不定多少。

如果题目只需要增删节点,那可以用系统的API。

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值