排序算法:希尔、归并


本篇主要讲解的是归并排序的自顶向下及自底向上的实现和优化。

希 尔 排 序
1959年Shell发明,第一个突破O(n²)的排序算法,是简单插入排序的改进版。它与插入排序的不同之处在于,它会优先比较距离较远的元素。希尔排序又叫缩小增量排序。
算法描述
希尔排序就是插入排序的延伸,在插入排序中每一次都和之前的一个元素进行比较,而希尔排序尝试每次和之前第h个元素进行比较,这样通过将h从一个很大的值逐渐缩小到1,一步一步的将完全无序的数组变成近乎有序的数据-》变成有序性更强的数组,最后当h等于1的时候变成一个排好序的数组。
在这里插入图片描述

int len = arr.length;
int h = len / 2;
int index = 0;
while (h > 0) {
  for (int i = h; i < len; i ++){
    int temp = arr[i];
    index = i - h;
    while (index >= 0 && temp < arr[index]){
      arr[index + h] = arr[index];
      index -= h;
    }
    arr[index + h] = temp;
  }
  h /= 2;
}

优化:

int n = arr.length;
// 计算 increment sequence: 1, 4, 13, 40, 121, 364, 1093...
int h = 1;
while (h < n/3) h = 3 * h + 1;while (h >= 1) {
  for (int i = h; i < n; i++) {
    // 对 arr[i], arr[i-h], arr[i-2*h], arr[i-3*h]... 使用插入排序
    int e = arr[i];
    int j = i;
    for ( ; j >= h && e < arr[j-h]; j -= h) {
      arr[j] = arr[j-h];
    }
    arr[j] = e;
  }
  h /= 3;
}

算法分析
希尔排序的核心在于间隔序列的设定。既可以提前设定好间隔序列,也可以动态的定义间隔序列。动态定义间隔序列的算法是《算法(第4版)》的合著者Robert Sedgewick提出的。 
O(nlogn)
从这开始将开始介绍n(logn)级的算法(归并排序、堆排序)、快速排序我打算单独拿一章出来讲。

简单看下nlogn 比 n^2快多少
在这里插入图片描述

假设n=100000,nlogn的算法要执行一天,那n^2的算法则要执行6020天(16年)。你细品。。。

不管是nlogn还是n2前面都有一个常数,有可能nlogn前面的常数比n2的大,但随着n的逐渐增大常数的影响将越来越小,因此整体上会说nlogn比n^2的算法要快,并且随着n的逐渐增大,速度优势将越来越明显。

在这里插入图片描述

归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为2-路归并。
在这里插入图片描述

简单聊聊为什么要费这么大的劲把他们先分成一半,之后再逐渐归并呢?

如上图:是对8个元素进行排序,一步一步划分的过程,在这个过程其实可以看到一层层下来一共分成了三级,到第三级的时候每一部分就只剩下一个元素了。

这3是怎么来的呢?

有8个元素,每次二分,这样下来经过三次这样的除以2的计算,每一部分就只剩一个元素了。也就是log28=3,因此可以想到,如果是n个元素那么就有log(n)这样的层级。如果这个n不是一个2的x次方,那么log(n)可能是一个浮点数,只需要上取整就好了。

如果整个归并的过程可以使用O(n)的复杂度来解决的话,那么就形成了nlogn级别的算法。事实上这也是nlogn这个时间复杂度算法主要的来源,通常是以二分法达到了log(n)这样的一个层级之后每一层级用O(n)级别的算法来做事。

注:归并排序需要使用O(n)的额外存储空间来完成排序,但在现在,时间的效率比空间的效率要重要的多(内存和硬盘越来越廉价)。因此在这种情况下,我们设计一个算法通常是优先考虑时间复杂度的,除非我们意识到了数据存储的空间是我这个算法中的一个重要的瓶颈。
在这里插入图片描述

public static void sort() {
  mergeSort(arr, 0, arr.length - 1);
}/**
 * 递归使用归并排序  对arr[left...right]的范围进行排序
 */
private static void mergeSort(int[] arr, int left, int right) {
  if (left < right) {
    int mid = left + (right - left) / 2;
    mergeSort(arr, left, mid);
    mergeSort(arr, mid + 1, right);
    merge(arr, left, mid, right);
  }
}/**
 * 将arr[left...mid] 和arr[mid + 1...right]两部分进行归并
 */
private static void merge(int[] arr, int left, int mid, int right) {
  // copy函数是前闭后开的  所以+ 1
  int[] temp = Arrays.copyOfRange(arr, left, right + 1);int i = left, j = mid + 1;
  for (int k = left; k <= right; ++k) {
    // 因为拷贝的时候做了left的偏移  所以 取值的时候  要减去left
    // i > mid 表示左边的都取完了  把右边的全部放进去
    if (i > mid) {
      arr[k] = temp[j - left];
      j++;
    } else if (j > right) { // 右边的都取完了  把左边的全部放进去
      arr[k] = temp[i - left];
      i++;
    } else if (temp[i - left] < temp[j - left]) {
      arr[k] = temp[i - left];
      i++;
    } else {
      arr[k] = temp[j - left];
      j++;
    }
  }
}

注:大部分人获取mid都是采用(left + right) / 2

一个非常著名的历史上的计算机事件就发生在这样一行代码中,只不过是在另一个算法二分查找算法中。这行代码隐含的一个危险是,当left和right都非常大的时候会(left + right)会发生溢出。

所以为了解决这个问题,我们这里采用的是:left + (right - left) / 2;

优化:

1、在近乎有序的数组排序上,插入排序会快于归并排序

2、上面mergeSort方法中,不管如何都会对数据执行merge操作,这其实是没有必要的,如果arr[mid]已经小于等于了arr[mid + 1],则相当于整个arr是有序的。

private static void mergeSort(int[] arr, int left, int right) { 
  // 优化1: 对于小规模数组, 使用插入排序
  if (right - left <= 15) {
    insertSort(arr, left, right);
    return;
  }
  int mid = left + (right - left) / 2;
  mergeSort(arr, left, mid);
  mergeSort(arr, mid + 1, right);
  // 优化2:排完序的左边数组最大值比 排完序的右边数组最小值 还大的时候才需要进行merge
  if (arr[mid] > arr[mid + 1]) {
    merge(arr, left, mid, right);
  }
}public static void insertSort(int[] arr, int l, int r) {
  for (int i = l + 1; i <= r; i++) {
    int e = arr[i];
    int j = i;
    for (; j > l && arr[j - 1]  >e; j--) {
      arr[j] = arr[j - 1];
    }
    arr[j] = e;
  }
}

使用插入排序的考虑

1、当元素数据量比较小的时候,整个数组近乎有序的概率就比较大此时插入排序有优势

2、另外一个方面:虽然插入排序最差的时间复杂度是O(n²)级别的,而归并排序是O(nlogn)级别的,但是对于时间复杂度来说前面是有一个常数系数的,对于这个系数而言,插入排序是要比归并排序小的。换句话说,当n小到一定程度的时候(这里我设置的是小于等于15),插入排序会比归并排序快。
自底向上
上面的代码都是基于自顶向下逐步递归实现的归并排序,当真正理解后,我们可以来尝试自底向上的归并排序。

自底向上:不在通过递归去拆分元素了,直接从最底下开始向上逐级合并元素,不在使用递归而是迭代就能完成了。

merge方法沿用上面的就不重复写了。

public static void sort(int[] arr) {
  int n = arr.length;
  for (int sz = 1; sz < n; sz *= 2) {
    for (int i = 0; i < n - sz; i += sz + sz) {
      // 对 arr[i...i+sz-1] 和 arr[i+sz...i+2*sz-1] 进行归并
      merge(arr, i, i + sz - 1, Math.min(i + sz + sz - 1, n - 1));
    }
  }      
}

优化:把自顶向下的两个优化加入

public static void sort(int[] arr) {
  int n = arr.length;for (int i = 0; i < n; i += 16) {
    insertSort(arr, i, Math.min(i + 15, n - 1));
  }for (int sz = 16; sz < n; sz += sz) {
    for (int i = 0; i < n - sz; i += sz + sz) {
      // 对于arr[mid] <= arr[mid+1]的情况,不进行merge
      if (arr[i + sz - 1] > arr[i + sz]) {
        merge(arr, i, i + sz - 1, Math.min(i + sz + sz - 1, n - 1));
      }
    }
  }
}

算法分析
归并排序是一种稳定的排序方法。和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是O(nlogn)的时间复杂度。代价是需要额外的内存空间。
在这里插入图片描述

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值