基于比较的归并排序-时间复杂度为O(nlogn)

归并排序

如果要排序一个数组,先把数组从之间分成前后两部分,并将这两部分划分为最小单位后排序,最后将排好序的两部分再合并,这样整个数组就有序了。
在这里插入图片描述
其实,归并排序可以用分治的思想最后用递归的方式自顶向下归并排序,其实如果自底向上看的化也可以用循环来解决。

  • 方式一:自底向下的归并排序(递归法)

分析过程:

写出归并排序的递推公式:
mergeSort(p_r) = merge(merge(mergeSort(p_q),mergeSort(q+1_r))
终止条件:
p >= r
其实这里可以优化,如果数据量太大,导致递归深度过深,可以改为处理小规模的问题,此方法可以改进大多数递归算法的性能,比如这里
可以修改为 r-p > num; 这个num是一个比较小的数,当递归到只剩num个数时候就可以用插入排序或者其他适合小规模问题的排序

其中:
mergeSort(p...r) 表示:给下标从 p 到 r 之间的数组排序。将这个排序转化程两个子问题,
mergeSort(p...q)mergeSort(q+1...r),其中下标q等于p和r的中间位置。
当两个数组都排好之后直接将两个有序的子数组合并一起,这样从p到r之间数据就排好了。

伪代码翻译:

// 归并排序算法

mergeSort(int[] arr,int n) {
  mergeSort_c(arr,0,n-1)
}

// 递归调用函数
mergeSort_c(int[] arr,int p,int r) {
    
    // 递归的终止条件
    if (p >= r) {
        return;
    }

    // 取 p 到 r 之间的中间位置 q
    q = (p+r) / 2;

    // 分治递归
    mergeSort_c(arr,p,q);
    mergeSort_c(arr,q+1,r);

    // 将 arr[p...q] 和 arr[q+1...r] 合并为 arr[p...r]
    merge(mergeSort_c(arr,p,r),mergeSort_c(arr,p,q),mergeSort_c(arr,q+1,r)){
    
    }
}

其中 merge(mergeSort_c(arr,p,r),mergeSort_c(arr,p,q),mergeSort_c(arr,q+1,r)) 这个就相当于归并操作,将已有序arr[p...q]和arr[q+1...r],并且放入 arr[p...r].

归并操作的具体实现:
申请一个临时数组 tmp,大小与 arr[p…r] 相同。用两个游标 i 和 j,分别指向 arr[p…q] 和 arr[q+1…r] 的第一个元素。比较 arr[i] 和 arr[j],如果 arr[i] <= arr[j],就把 arr[i] 放到临时数组 tmp,并且 i 后移一位,否则将 arr[j] 放到数组 tmp, j 后移一位。
直到其中一个数组中的所有数据都放入临时数组中,再把另一个数组中的数据依次加入到临时数组的末尾。最后再把时数组 tmp 中的数据拷贝到原数组 arr[p…r]中
在这里插入图片描述
代码实现:

public class MergeSort {
	
	// 归并排序,n表示数组大小
	public static void mergeSort(int[] arr,int n) {
		mergeSortInternally(arr,0,n-1);
	}
	
	// 递归调用函数
	private static void mergeSortInternally(int[] arr,int p,int r) {
		// 递归终止条件
		if (p >= r) {
			return;
		}
		
		// 取 p 到 r 之间的中间位置 q,防止 (p+r) 的和超过 int 类型最大值
		int q = p + (r-p)/2;
		
		// 分治递归
		mergeSortInternally(arr,p,q);
		mergeSortInternally(arr,q+1,r);
		
		// 将arr[p...q] 和 arr[q+1...r] 合并为 arr[p...r]
		merge(arr,p,q,r);
	}
	
	private static void merge(int[] arr,int p,int q,int r) {
		int i = p;
		int j = q+1;
		int k = 0; // 初始化变量 i,j,k
		int[] tmp = new int[r-p+1]; // 申请一个大小跟 arr[p...r]一样的临时数组
		while (i <= q && j <= r) {
			if (arr[i] <= arr[j]) {
				tmp[k++] = arr[i++]; // i++等于 i:=i+1
			} else {
				tmp[k++] = arr[j++];
			}
		}
		// 判断哪个子数组中有剩余的数据
		int start = i;
		int end = q;
		if (j <= r) {
			start = j;
			end = r;
		}
		
		// 将剩余的数据拷贝到临时数组tmp
		while (start <= end) {
		  tmp[k++] = a[start++];
		}

		// 将tmp中的数组拷贝回a[p...r]
		for (i = 0; i <= r-p; ++i) {
		  a[p+i] = tmp[i];
		}
	}
}

其中归并操作这个方法可以用哨兵优化

private static void mergeBySentry(int[] arr, int p, int q, int r) {
    int[] leftArr = new int[q - p + 2];
    int[] rightArr = new int[r - q + 1];

    for (int i = 0; i <= q - p; i++) {
      leftArr[i] = arr[p + i];
    }
    // 第一个数组添加哨兵(最大值)
    leftArr[q - p + 1] = Integer.MAX_VALUE;

    for (int i = 0; i < r - q; i++) {
      rightArr[i] = arr[q + 1 + i];
    }
    // 第二个数组添加哨兵(最大值)
    rightArr[r-q] = Integer.MAX_VALUE;

    int i = 0;
    int j = 0;
    int k = p;
    while (k <= r) {
      // 当左边数组到达哨兵值时,i不再增加,直到右边数组读取完剩余值,同理右边数组也一样
      if (leftArr[i] <= rightArr[j]) {
        arr[k++] = leftArr[i++];
      } else {
        arr[k++] = rightArr[j++];
      }
    }
  }
}

性能分析:

  1. 归并排序是稳定的排序算法吗?
    归并排序是否稳定其实关键要看 merge() 函数,在合并的过程中,如果 arr[p…q] 和 arr[q+1,r] 之间有值相同的元素,可以先把arr[p…q] 中的元素放入 tmp 数组。这样就保证了值相同的元素合并后顺序不变。所以归并是一个稳定的排序算法。

  2. 归并排序的时间复杂度?
    归并涉及到了递归,所以要分析递归代码的时间复杂度
    定义求解问题 a分解成问题b、c,则求解问题 a 的时间是 T(a),求解问题 b、c的时间分别为 T(b)、T©,可以得到这样的递推关系式:

    分析方式一:

    T(a) = T(b) + T( c) + K // 其中 K 等于两个子问题 b、c的结果合并成问题 a 的结果所消耗的时间。
    结论:不仅递归求解的问题可以写成递推公式、递归代码的时间复杂度也可以写成递推公式。

    套用这个公式,分析假设对 n 个元素进行归并排序的时间是 T(n), 那分解成两个子数组排序时间都是T(n/2).而我们知道合并两个有序子数组的时间复杂度是 O(n). 所以,套用前面的公式,归并排序的时间复杂度的计算公式就是:

    T(1) = C; n = 1时,只需要常量级的执行实际那,所以表示为 C。
    T(n) = 2*T(n/2) +n; n>1

    这个公式不够直观,我们可以进行分解计算
    T(n) = 2*T(n/2) + n
    = 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n
    = 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n
    = 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n
    ......
    = 2^k * T(n/2^k) + k * n

    当 T(n/2^k)=T(1)时,也就是 n/2^k=1,我们得到 k=log n 。我们将 k 值代入上面的公式,得到
    T(n)=Cn+nlog n 。如果我们用大 O 标记法来表示的话,T(n) 就等于 O(nlogn)。所以归并排序的时间复杂度是 O(nlogn)。

    分析方式二:用递归树

  3. 归并排序的空间复杂度是多少?
    归并排序的时间复杂度在任何情况下都是 O(nlogn),看起来非常优秀(因为即便是快排最坏情况下,时间复杂度也是 O(n^2)) 但是,归并排序并没有像快排那样,应用广泛,原因在于它有一个致命的"弱点",就是不是原地排序算法。
    这是因为归并排序的合并函数,在合并的时候两个有序数组的时候,需要借助额外的存储空间,这点是比较容易理解的,那归并的空间复杂度是多少?是 O(n) 还是 O(nlogn)?
    实际上递归代码的空间复杂度并不能像时间复杂度那样累加,因为尽管每次合并操作都需要申请额外的内存空间,但是在合并完成之后,临时开辟的内存空间就被释放掉了。在任意时刻,CPU只会有一个函数在执行,也就只会有一个临时的内存空间在使用.临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n).

  4. 应用场景
    从原理分析和代码可以看出,归并排序的执行效率与排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不论是最好情况、最坏情况还是平均情况,时间复杂度都是 O(nlogn)。

猜你喜欢

转载自blog.csdn.net/qq_40488936/article/details/105567276