归并排序:
想要这个数组有序,只需要令左半边有序,右半边也有序,再把两部分合并。
归并排序递归实现:
递归方法: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。