归并排序
今天开始搞归并排序。归并排序是借助于归并操作来实现快速排序的算法。但归并操作有个前提,即合并的两个子序列本身必须是有序的。所以实现归并排序时的第一步就是对无序数组做切割,切成一个个的有序子序列;第二步再对这些生成的有序子序列两两进行合并,即归并操作。第三步,重复第二步。针对合并过的序列继续归并,直到全部合并完成。至此就完成了归并排序操作。
归并排序可以说是分治法的典型应用。先切割数组,再对切割后的子串层层递归合并,每个子串合并可以同时进行而不互相影响。并且排序速度非常快,仅次于快排。
归并操作
归并即合并两个有序子串。这里用两个有序数组举例,并按从小到大排列。
两个有序子串为nums1,[0,5,8];nums2, [1,2,7,21,34]。合并后的数组为temp,长度为nums1.length + nums2.length。初始时指针i1指向数组nums1的0号位,i2指向nums2的0号位,指针t指向temp的0号位置。
第一次,比较i1和i2指向的数值,i1值0小于i2值1,将数值0复制到t指针指向的位子。i1和t分别向右挪动一位。结果如下图所示。
第二次,继续比较i1和i2指向的值,5大于1,所以将i2指向的值1复制到t指针位置。i2和t右移一位。结果如下图所示。
重复上面的步骤,直到i1指向nums1的末尾时结束。结果如下:
这时发现无需再次比较,将nums2从i2指向的节点开始到末尾全数复制到temp数组中即可。完成后的合并数组为:
用代码实现一遍:
java
private static int[] mergeArray(int[] nums1, int[] nums2){
if(null == nums1 || 0 == nums1.length) return nums2;
if(null == nums2 || 0 == nums2.length) return nums1;
int[] temp = new int[nums1.length + nums2.length];
int t = 0;
int i1 = 0;
int i2 = 0;
while (i1< nums1.length && i2 < nums2.length){
if(nums1[i1] <= nums2[i2]){
temp[t++] = nums1[i1++];
}else {
temp[t++] = nums2[i2++];
}
}
//nums1数组有剩余
while (i1< nums1.length){
temp[t++] = nums1[i1++];
}
//nums2数组有剩余
while (i2 < nums2.length){
temp[t++] = nums2[i2++];
}
return temp;
}
一般进行归并排序的时候我们都是在同一个数组中进行操作的,所以需要将上面提到的nums1和nums2两个数组放置在同一个数组nums中然后进行归并操作。最终的结果也会返回到输入的nums数组中。
因此我们对上面的归并函数入参做出调整。设计三个指针,头中尾。start指向nums1的开始,mid指向nums1的末尾,end指向nums2的末尾。
调整后的代码为:
java
private static void mergeArray2(int[] nums, int start, int mid, int end) {
if (null == nums || 0 == nums.length) return;
int[] temp = new int[nums.length];
int t = start;
int i1 = start;
int i2 = mid + 1;
//这里的mid和end是数组的下标,不同于刚才的数组长度length,所以要加等号
while (i1 <= mid && i2 <= end) {
if (nums[i1] <= nums[i2]) {
temp[t++] = nums[i1++];
} else {
temp[t++] = nums[i2++];
}
}
//拷贝剩余的数据
while (i1 <= mid) {
temp[t++] = nums[i1++];
}
//拷贝剩余的数据
while (i2 <= end) {
temp[t++] = nums[i2++];
}
//t重新指向temp数组的开头位置
t = start;
//将temp中的数值拷贝回nums中
while (t <= end) {
nums[t] = temp[t];
t++;
}
}
数组的切割
开头的时候说过归并排序首先要做的就是数组的切割。
切割动作可以用递归动作来简单实现。如上图所示,start指向0号位,end指向7号为。计算mid=(start + end) / 2 = 3。递归调用,start到mid的分一组,mid+1到end的分一组。
java
private static void mergeSort(int[] nums, int start, int end){
int mid = (start + end) / 2;
mergeSort(nums, start, mid); //继续切割左边的。
mergeSort(nums, mid +1, end); //继续切割右边的。
}
我们默认认为只有一个数组元素的子串即为有序数组。因此添加一个递归的调用结束条件 start >= end时结束。
java
private static void mergeSort(int[] nums, int start, int end) {
if (start >= end) {
return;
}
int mid = (start + end) / 2;
mergeSort(nums, start, mid); //继续切割左边的。
mergeSort(nums, mid + 1, end); //继续切割右边的。
}
切割+归并操作=归并排序
至此我们已经完成了归并排序的归并动作和切割动作。将这两者合在一起就是完整的归并排序。
java
private static void mergeSort(int[] nums, int start, int end) {
if (start >= end) {
return;
}
int mid = (start + end) / 2;
mergeSort(nums, start, mid); //继续切割左边的。
mergeSort(nums, mid + 1, end); //继续切割右边的。
mergeArray2(nums, start, mid, end); //合并两个有序子串
}
算法空间的优化
观察上面的归并操作,发现每次都要new一个临时数组,这里有浪费空间的嫌疑。而且每次归并都是在一个和nums同等大小的数组的不同位置进行操作。所以可以提前创建好一个temp数组传入即可,避免了浪费空间。
java
private static void mergeSort(int[] nums, int start, int end, int[] temp) {
if (start >= end) {
return;
}
int mid = (start + end) / 2;
mergeSort(nums, start, mid, temp); //继续切割左边的。
mergeSort(nums, mid + 1, end, temp); //继续切割右边的。
mergeArray2(nums, start, mid, end, temp); //合并两个有序子串
}
private static void mergeArray2(int[] nums, int start, int mid, int end, int[] temp) {
if (null == nums || 0 == nums.length) return;
int t = start;
int i1 = start;
int i2 = mid + 1;
//这里的mid和end是数组的下标,不同于刚才的数组长度length,所以要加等号
while (i1 <= mid && i2 <= end) {
if (nums[i1] <= nums[i2]) {
temp[t++] = nums[i1++];
} else {
temp[t++] = nums[i2++];
}
}
//拷贝剩余的数据
while (i1 <= mid) {
temp[t++] = nums[i1++];
}
//拷贝剩余的数据
while (i2 <= end) {
temp[t++] = nums[i2++];
}
//t重新指向temp数组的开头位置
t = start;
//将temp中的数值拷贝回nums中
while (t <= end) {
nums[t] = temp[t];
t++;
}
}
算法分析
时间复杂度
根据以上的切割和合并两个操作发现,在进行归并排序时无论数组里的元素是什么情况这两步是少不了的。所以说归并排序的最好、最坏、平均时间复杂度都是一样的。
同样记录结果:O( nlogn )
详细的推导过程可以参考:排序算法之 快速排序 及其时间复杂度和空间复杂度_快去排序算法的评论时间复杂度为-CSDN博客
空间复杂度
归并排序的空间分为两部分,1递归栈,O(logn);2临时数组temp,O(n)
O(logn) + O(n) = O(n)
稳定性
归并操作时,元素值如果相同,位置不会发生变化。因此归并排序是一个稳定的排序算法。
