目录
归并排序
归并排序详解
归并排序是将待排序的数组递归执行一分为二的操作,直到最后只有每一部分只有一个数值,然后返回,结合为新的数组,然后回代,数组之间结合,组成新的排好序的数组
public static void mergeSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}//如果数组为空或者数组长度为1,直接返回
mergeSort(arr, 0, arr.length - 1);
}
public static void mergeSort(int[] arr, int l, int r) {
if (l == r) {//如果数组只有一个数,直接返回
return;
}
int mid = l + ((r - l) >> 1);//中间值
mergeSort(arr, l, mid);//将左边的部分进行排序
mergeSort(arr, mid + 1, r);//将右边的部分进行排序
merge(arr, l, mid, r);//将左右两部分合起来排序
}
public static void merge(int[] arr, int l, int m, int r) {
int[] help = new int[r - l + 1];//建立一个辅助数组,和原数组大小相同
int i = 0;
int p1 = l;//指向第一个位置的指针
int p2 = m + 1;//指向中间位置下一个的指针
while (p1 <= m && p2 <= r) {//当第一个指针小于中间值且第二个指针小于数组最大值,也就是两部分分别不越界
help[i++] = arr[p1] < arr[p2] ? arr[p1++] : arr[p2++];//两个指针分别从头开始两两比较,将最小的值放入辅助数组,指针位置移动到下一个
}
//上述循环执行完毕以后,两个部分中还剩余的元素直接复制到辅助数组,两部分只有一部分有剩余
while (p1 <= m) {
help[i++] = arr[p1++];
}
while (p2 <= r) {
help[i++] = arr[p2++];
}
for (i = 0; i < help.length; i++) {
arr[l + i] = help[i];//将排序好的辅助数组的值复制回原数组
}
}
这个程序实现时使用递归将数组根据中间值无限细分,细分为每一部分只有一个数返回,每左右两部分设置一个同样大小的辅助数组,暂时存储数组中的数值,左右两部分均设置一个指针,初始时分别指向两部分的开头,当两部分指针均没有指向结尾时,执行循环,指针指向的数比大小,小的放到辅助数组中,指针向后移动,继续比较,直到循环结束。当循环结束后将两部分的数组中剩余数的数组剩余的排好序的部分直接复制到辅助数组后面。最后将放在辅助数组中排好序的数值复制到原数组。
这个程序的递归子问题规模相同,可以使用master公式对上述程序的时间复杂度进行分析,,(除去递归行为,merge过程中,指针移动是
的过程,复制回原数组也是
的过程,两者加和是
的过程)。根据相应系数的大小,上面程序的时间复杂度为
,程序需要一个和原数组相同的辅助空间,空间复杂度为
。对于时间复杂度也可以看成是满二叉树进行理解。
归并排序的优越性
归并排序相比于那些时间复杂度为的排序,优越性主要体现在每一次比较的过程信息没有浪费,那些时间复杂度为
的排序在每一次比较结束后,只是排好了一个数字,然后继续下一次比较,浪费了每一次的比较信息,时间复杂度较高。而归并排序中merge过程,通过指针对数组的两部分比较结合为一个数组的过程,比较信息依次传递下去没有浪费,所以时间复杂度是要低的。
归并排序的拓展
小和问题
在一个数组中,每一个数左边比当前数小的数累加起来,叫做这个数组的小和。求一个数组的小和。例子:[1,3,4,2,5] 1左边比1小的数,没有;3左边比3小的数,1;4左边比4小的数,1、3;2左边比2小的数,1;5左边比5小的数,1、3、4、2;所以小和为1+1+3+1+1+3+4+2=16
对于这个问题当然可以使用遍历的思想去求解,但是这样的话时间复杂度就是。使用归并排序的思想可以将这个问题优化,达到
的时间复杂度。可以换一个角度去思考,求每个数左边比它小的数之和,也就相当于求一个数右边有几个数比它大,再乘以这个数,求和也就是小和。这样的话就可以利用归并排序的思想,将数组利用中间值二分拆分,直到最左右两部分分别只有一个数,返回,然后改写加上一个比较的步骤,左右两部分进行比较,如果左边的部分小于右边,那么即为小和,用数组的最大长度减去右边指针的当前位置加1的结果乘以左边指针所指的位置得到小和,按照这种方式,将数组的每一个数字都求一遍,得到数组的小和。(其实这个比较的步骤也就是在求每一个数右边比它大的数)
//这个代码只是在归并排序的基础上进行了适当的改写
public static int smallSum(int[] arr) {
if (arr == null || arr.length < 2) {
return 0;
}
return mergeSort(arr, 0, arr.length - 1);
}
//既要排好序,又要求小和
public static int mergeSort(int[] arr, int l, int r) {
if (l == r) {
return 0;//当数组只有一个数字的时候,小和为0
}
int mid = l + ((r - l) >> 1);
return mergeSort(arr, l, mid) + mergeSort(arr, mid + 1, r) + merge(arr, l, mid,r);//数组的小和等于左边结合排序过程中产生的小和加上右边排序过程中产生的小和再加上两部分结合起来产生的小和
}
public static int merge(int[] arr, int l, int m, int r) {
int[] help = new int[r - l + 1];
int i = 0;
int p1 = l;
int p2 = m + 1;
int res = 0;//用来计算小和
while (p1 <= m && p2 <= r) {
res += arr[p1] < arr[p2] ? (r - p2 + 1) * arr[p1] : 0;//如果左部分的数比右部分的数小,那么小和等于右部分的最大值减去右部分当前指针所指位置加上1再乘以左边指针所指的数值即为小和。指针移动,计算重复上述过程。
help[i++] = arr[p1] < arr[p2] ? arr[p1++] : arr[p2++];
}
while (p1 <= m) {
help[i++] = arr[p1++];
}
while (p2 <= r) {
help[i++] = arr[p2++];
}
for (i = 0; i < help.length; i++) {
arr[l + i] = help[i];
}
return res;
}
逆序对问题
在一个数组中,左边的数如果比右边的数大,则这两个数构成一个逆序对,请求出所有的逆序对数量。
这个问题和上面题目的解决思想相同,也就是归并排序的思想。求一个数组中一个数字右边比它小的数字,只需要对上面的程序适当改写就可以。
public static int nixuDui(int[] arr) {
if (arr == null || arr.length < 2) {
return 0;
}
return mergeSort(arr, 0, arr.length - 1);
}
public static int mergeSort(int[] arr, int l, int r) {
if (l == r) {
return 0;
}//当数组只有一个数时,逆序对数量为0
int mid = l + ((r - l) >> 1);
return mergeSort(arr, l, mid) + mergeSort(arr, mid + 1, r) + merge(arr, l, mid,r);//数组的逆序对总数等于左边结合排序过程中逆序对的数量加上右边排序过程中逆序对数量再加上两部分结合起来产生的逆序对数量
}
public static int merge(int[] arr, int l, int m, int r) {
int[] help = new int[r - l + 1];
int i = 0;
int p1 = l;
int p2 = m + 1;
int res = 0;//用来统计逆序对数量
while (p1 <= m && p2 <= r) {
res += arr[p1] > arr[p2] ? (r - p2 + 1) : 0;//如果左部分的数比右部分的数大,那么逆序对数量等于右部分的最大值减去右部分当前指针所指位置加上1即为逆序对数量。指针移动,计算重复上述过程。
help[i++] = arr[p1] > arr[p2] ? arr[p1++] : arr[p2++];
}
while (p1 <= m) {
help[i++] = arr[p1++];
}
while (p2 <= r) {
help[i++] = arr[p2++];
}
for (i = 0; i < help.length; i++) {
arr[l + i] = help[i];
}
return res;
}
快速排序
快速排序的引入
荷兰国旗问题
问题一 给定一个数组arr和一个数num,请把小于等于num的数放在数组的左边,大于num的数放在数组的右边。要求额外空间复杂度,时间复杂度
。
这个问题就相当于对给定的数组,有一个<=区域,初始时位于数组第一个位置的前一个,然后从数组的第一个位置开始依次将数组的数和给定的数进行比较,如果小于等于给定的数,那么<=区域向后移动,如果大于给定的数,那么<=区域的数字不移动,继续比较后面的数字,直到全部比较结束,这时得到的数组小于等于num的数放在了数组的左边,大于num的数放在了数组的右边。
public static void Q1(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}//如果数组为空或者数组中只有一个数字,直接返回
partition(arr, 0, arr.length - 1);
}
public static int[] partition(int[] arr, int l, int r) {
int less = l - 1;//<=区域的最右侧
int more = r;//数组的最大范围
while (l < more) {//当移动查找的指针小于最大区域时执行循环
if (arr[l] < =num) {//如果所指位置的数字比给定的数字小或等于
swap(arr, ++less, l++);//将所指位置的数字和<=区域后一个数字交换位置
} else {
l++;//如果不比给定的数字小,那么查找位置的指针移动
}
}
return new int[] arr;//执行完上述操作以后,返回得到的数组
}
public static void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}//交换两个位置的数
问题二(荷兰国旗问题) 给定一个数组arr和一个数num,请把小于num的数放在数组的左边,等于num的数放在数组的中间,大于num的数放在数组的右边。要求额外空间复杂度,时间复杂度
。
对于这个问题其实是上面一个问题的升华,解题的思想相同。假设我们有两个区域,<区域和>区域,<区域的右边界位于数组第一个位置的前一位,>区域的左边界位于数组最后一个位置的后一位。从数组的第一个数开始和给定的数组进行比较,如果数组的数比给定的数字小,那么将数组的数和<区域的下一个数交换位置,<区域移动一个位置,数组中的数字继续进行比较。如果数组的数和给定的数字相等,两个区域均不移动,继续比较下一个数字。如果数组中的数比给定的数字大,那么将当前位置的数和>区域前一个数字交换位置,>区域向前移动一位,数组中的数字继续和给定数字比较。直到数组需要比较的位置和>区域的位置重合时,比较结束。得到的数组左边都是小于num的数,中间是等于num的数,右边是大于num的数。
public static void Q2(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}//如果数组为空或者数组中只有一个数字,直接返回
partition(arr, 0, arr.length - 1);
}
public static int[] partition(int[] arr, int l, int r) {
int less = l - 1;//<区域的右边界
int more = r + 1;//>区域的左边界
while (l < more) {//在数组中的查找指针没有和>区域重合之前执行循环
if (arr[l] < num) {//如果数组中的数比给定的数字小
swap(arr, ++less, l++);//将数组中指针所指的数字和<区域的后一个数字交换位置,<区域向后移动,查找指针向后移动
} else if (arr[l] > arr[r]) {//如果数组中的数比给定的数字大
swap(arr, --more, l);//将数组中指针所指的数字和>区域的前一个数字交换位置,>区域向前移动,查找指针不移动位置
} else {//如果数组中的数和给定的数相等,查找指针向后移动
l++;
}
}
return new int[] arr;
}
public static void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}//交换两个位置的数
通过上面这两个问题引入快速排序,快速排序的思想和上面两个问题的解决思想相同。
快速排序的介绍
快速排序一:将数组中最后一个数字作为划分值,然后对前面的元素进行上述问题一样的划分,将小于等于划分值的数排到数组的左边,大于划分值的数排到数组的右边,然后将数组的最后一个元素和大于区域左边界的数交换位置,将这个数字作为划分值,分为两部分。然后分别在两部分中重复上述操作,直到所有数字全部有序。
快速排序二:将数组中最后一个数字作为划分值,然后对前面的元素进行上述问题一样的划分,将小于划分值的数排到数组的左边,等于划分值的数位于数组的中间,大于划分值的数排到数组的右边,然后将数组的最后一个元素和大于区域左边界的数交换位置。然后分别在<区域和>区域两部分中重复上述操作,直到所有数字全部有序。
快速排序二相对于快速排序一更快一些,一次划分排好序的数字多一些,但是两种快速排序的操作都存在弊端。划分值越靠近两侧,时间复杂度越高;划分值越靠近中间,时间复杂度越低。最差情况下时间复杂度会达到。所以上面的两种方式都不推荐使用,下面介绍第三种快速排序的方式。
快速排序三:对划分值的选取这个步骤进行优化,采用随机从数组中选择一个数作为划分值,这样的话选择的划分值是不是偏,转化为一个概率问题,对每一个数字作为划分值情况的时间复杂度进行求解,每一种情况出现的概率均为1/N,计算数学期望得到时间复杂度
快速排序的其他操作和上面的相同。
public static void quickSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}//如果数组为空或者数组中只有一个数直接返回
quickSort(arr, 0, arr.length - 1);
}
public static void quickSort(int[] arr, int l, int r) {
if (l < r) {
swap(arr, l + (int) (Math.random() * (r - l + 1)), r);//在数组中随机选择一个数作为划分值,将划分值与数组的最后一个值交换位置
int[] p = partition(arr, l, r);//执行partition过程,选择出两个标记值,用来划分左右两个区域,数组中只有两个数
//利用划分值将数组分为两部分
quickSort(arr, l, p[0] - 1);//在<区域递归
quickSort(arr, p[1] + 1, r);//在>区域递归
}
}
public static int[] partition(int[] arr, int l, int r) {
int less = l - 1;//<区域的右边界
int more = r;//>区域的左边界
while (l < more) {//当查找指针小于>区域的左边界
if (arr[l] < arr[r]) {//如果查找指针所指的数小于划分值
swap(arr, ++less, l++);//将查找指针所指的数与<区域的后一个数交换位置,查找指针和<区域均向后移动
} else if (arr[l] > arr[r]) {//如果查找指针所指的数大于划分值
swap(arr, --more, l);//将查找指针所指的数和>区域的前一个数交换位置,>区域向前移动一个位置,查找指针不移动
} else {//如果查找指针所指的数和划分值相同,查找指针向下移动
l++;
}
}
swap(arr, more, r);//循环执行完毕以后,将>区域的左边界的数和数组的最后一个数(划分值)交换位置
return new int[] { less + 1, more };//返回两个标记值,分别是此时>区域的前一个和<区域的后一个(也就是等于区域的左边界和右边界)
}
public static void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}//交换数组中的两个数
上面这个代码的时间复杂度为 ,对于空间复杂度如果划分值选取的不好,每次位于靠近边上的位置,那么空间复杂度为
,对于每一种情况下求相应的空间复杂度,每一次的概率1/N,求数学期望,求得空间复杂度为
。
堆排序
堆结构
对于一个从0位置出发连续的数组可以转化为一个完全二叉树,对于第i位置的左子树为2*i+1,右子树为2*i+2,父节点为(i-1)/2。堆是一个特殊的完全二叉树,分为大根堆和小根堆。每一棵子树头节点的值都是最大值的数称为大根堆,每一棵子树头节点的值都是最小值的树称为小根堆。另外所说的优先级队列结构就是堆结构。
堆的形成
大根堆的形成:对于给定的一个从0位置出发连续的数组,从第一个数字开始作为根节点,然后下一个数字到左子树, 接着跟父节点进行比较,如果比父节点大,那么两者交换,否则继续其它的节点插入,重复上述操作。
//某个数在index位置,能否向上移动
public static void heapInsert(int[] arr, int index) {
while (arr[index] > arr[(index - 1) / 2]) {//当新插入的值比它的父节点的值大时进入循环
swap(arr, index, (index - 1) /2);//交换两者的位置
index = (index - 1)/2 ;//将交换后的节点更新位置为父节点的位置
}
}
public static void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}//交换两个数的位置
小根堆的形成:对于给定的一个从0位置出发连续的数组,从第一个数字开始作为根节点,然后下一个数字到左子树, 接着跟父节点进行比较,如果比父节点小,那么两者交换,否则继续其它的节点插入,重复上述操作。
public static void heapInsert(int[] arr, int index) {
while (arr[index] < arr[(index - 1) / 2]) {//当新插入的值比它的父节点的值小时进入循环
swap(arr, index, (index - 1) /2);//交换两者的位置
index = (index - 1)/2 ;//将交换后的节点更新位置为父节点的位置
}
}
public static void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}//交换两个数的位置
对于上述程序的时间复杂度分析,相当于一个完全二叉树,新插入一个值,从最后一个位置开始向上调整,根据完全二叉树的深度可以知道,最坏情况下也就是调整一个完全二叉树的深度的次数,时间复杂度为。
堆的输出与重建
对于一个建立好的堆(这里以大根堆为例,小根堆类似)输出堆顶元素并重建堆的过程,首先将堆顶元素输出后,将堆的最后一个元素放入堆顶,然后从堆顶开始,将堆顶元素分别与自己的左右子树进行比较,将大的一个放到堆顶,然后继续往下调整,直到到达叶节点,调整完毕。
//某个数在index位置,能否往下移动,index当前也就是父节点
public static void heapify(int[] arr, int index, int size) {//size为堆的大小
int left = index * 2 + 1;//左孩子的下标
while (left < size) {//下方还有孩子的时候
int largest = left + 1 < size && arr[left + 1] > arr[left] ? left + 1 : left;//当右孩子没有超过堆的大小且右孩子比左孩子数值大时,将右孩子的位置赋值给largest,否则将左孩子的位置赋给largest
largest = arr[largest] > arr[index] ? largest : index;//当largest位置的数比父节点的数大时,将largest赋给largest,否则将父节点的位置赋给largest
if (largest == index) {//如果父节点就是最大值的位置,直接跳出循环
break;
}
swap(arr, largest, index);//交换最大位置和父节点的数值
index = largest;//父节点往下移动到largest位置
left = index * 2 + 1;//移动后左孩子的下标进行调整,重新进入循环判断
}
}
public static void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}//交换两个位置的数
对于上述程序相当于对于一个完全二叉树,移除一个位置的数向下调整,同样最坏情况下也就是调整一个完全二叉树深度的次数,时间复杂度为。
堆排序
堆排序其实就是上面两种操作的整合,一个给定的需要排序的从零位置开始的数组,采用堆的形成(大根堆)的方式,一个一个的插入二叉树,然后向上调整,直到全部调整完毕,第一步完成。然后接下来的就是将数组的第一个数和最后一个数交换位置,同时堆的大小减一,接着按照堆的重建的操作将交换后的根节点的值向下调整,依次交换调整,直到堆的大小为0,最后得到的数组全部排好序。
public static void heapSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}//如果数组为空或者数组的长度为1直接返回
for (int i = 0; i < arr.length; i++) {
heapInsert(arr, i);//将数组中的每一个值插入调整形成一个完全二叉树
}
//下面这个操作同样也可以完成上面的步骤而且时间复杂度为O(N),更快一些,但是对于整体的堆排序的时间复杂度并没有改变(当对于一个给定的完整的完全二叉数调整成堆的操作时可以使用下面这个程序)
//for(int i=arr.length;i>=0;i--){
// heapify(arr,i,arr.length);
//}
int size = arr.length;//堆的长度为数组长度
swap(arr, 0, --size);//将数组第一个位置的数和最后一个位置的数交换位置,堆的长度减1
while (size > 0) {//当堆的长度大于0时进入循环
heapify(arr, 0, size);//将交换后的堆顶位置的数向下调整位置
swap(arr, 0, --size);//继续将数组的第一个位置的数和最后一个位置的数交换,堆的长度减1
}
}//最后得到的就是排好序的数组
public static void heapInsert(int[] arr, int index) {
while (arr[index] > arr[(index - 1) / 2]) {
swap(arr, index, (index - 1) /2);
index = (index - 1)/2 ;
}
}
public static void heapify(int[] arr, int index, int size) {
int left = index * 2 + 1;
while (left < size) {
int largest = left + 1 < size && arr[left + 1] > arr[left] ? left + 1 : left;
largest = arr[largest] > arr[index] ? largest : index;
if (largest == index) {
break;
}
swap(arr, largest, index);
index = largest;
left = index * 2 + 1;
}
}
public static void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
对于上面程序的时间复杂度的分析,可以对每一种情况的复杂度进行分析,然后求和。也可以先假设有一个大小为2N的数组,对于heapInsert过程后N个数每一个向上调整的时间复杂度分别为,所以2N个数全部完成这个操作的时间复杂度为
,同样的对heapify过程进行分析,时间复杂度也为
,所以总体程序的时间复杂度为
。对于空间复杂度并没有使用额外的空间,所以空间复杂度为
。
堆排序拓展
实例应用
已知一个几乎有序的数组,几乎有序是指,如果把数组排好顺序的话,每个元素移动的距离可以不超过k,并且k相对于数组来说比较小。请选择一个合适的排序算法针对这个数据进行排序。
对于这个问题可以使用小根堆解决,假设给定的数组,k=6,那么我们取数组的前七个数形成小根堆,把小根堆的第一个数放到0位置,它一定是最小的,接着第八个数放进小根堆,小根堆下来一个放到数组的一位置,依次类推,执行完毕以后数组全部排好序。对于这个算法小根堆上的数最多移动位置,全部执行完毕时间复杂度为
,题目中提出k相对于数组来说非常小,所以这个算法是相当优越的。
public void sortedArrDistanceLessK(int[] arr, int k) {
PriorityQueue<Integer> heap = new PriorityQueue<>();//java中内置的优先级队列的小根堆的实现
int index = 0;
for (; index < = Math.min(arr.length, k); index++) {
heap.add(arr[index]);
}//在允许的范围内,先加入一些数组的值
int i = 0;
for (; index < arr.length; i++, index++) {//从堆中弹出堆顶数字,接着进入新的数字
heap.add(arr[index]);
arr[i] = heap.poll();
}
while (!heap.isEmpty()) {
arr[i++] = heap.poll();
}//最后弹出堆中所有的数字
}
对于上面程序的系统堆结构程序的扩容问题需要考虑,当需要扩容时,采用成倍扩容的方式,对于整体时间复杂度影响不大。
内置堆结构的解释
对于系统自带的内置堆结构相当于一个黑盒,支持你给它一个数字,它给你弹出一个数字,而并不支持对于堆中间的位置进行一些其它的操作。当在实际应用中需要对堆进行一些复杂的操作时需要手写堆;当只需要给他一个,弹出一个这样的操作时例如上面的例题使用内置函数完成即可。