希尔排序实际上可以理解为插入排序的升级版,其中加入了折半的思想
希尔排序思想:
1.对待排序数组进行增量分组,其中每组的大小可以理解为增量(步长) 分组的规律应该是越分组越大,比如数组的长度是 10,第一次除 2 后是 5,第二次除 2 取整就是 2,第三次就是 1,最后不能小于等于 0 得到的这个数,就是需要分多少组
2.每次分组后,对每组的数组进行排序(分为交换排序和位移排序(只有位移式才是纯正的插入排序)) 实际上是进行直接插入排序,也就是说对每组的每个数都进行一次插入排序,而不是两两比较。
3.对于组包含的元素,不应该是邻近的元素,而是 i+gap(步长)为一组的元素 比如第一次分组 10/2=5,每组 2 个元素,i 取 0 的时候,下一个元素就是 0+5,索引为 5 的元素
交换法完整版:
public static void shellSort(int[] arr) {
int temp = 0;
// 外层循环表示每次分组
// 因为插入排序是需要从后往前依次排序,依次这里是i是从gap开始
// 比如gap = 2的时候,i就是从下标2开始,因为0,1下标相当于是分割出来的两组数的首位,不需要进行比较
// 因此我们分割完成后的第一次比较都是从当前组的第二位比较,与这组的前一位进行比较,0和2,1和3
// 当i达到8的时候,那么就是6和8,4和6,2和4,0和2依次比较
// 之所以i是递增的,因为这样可以依次对每组的每个数进行插入排序的比较
// 第三个for循环就相当于是拿到了一个待插入值,让它与他这组前面的所有值进行比较,虽然一般的插入排序是从末尾开始拿元素
// 但是插入排序实际上是给值寻找合适的位置
for (int gap = arr.length / 2; gap > 0; gap /= 2) {
for (int i = gap; i < arr.length; i++) {
// 然后j就是0,那么就是比较0和5,之所以j -= gap是因为这样可以取到同一组的前一位
for (int j = i - gap; j >= 0; j -= gap) {
// arr[j]相当于是待插入数的前一位,只不过这里是针对分割后的同一组数,arr[j+gap]便是待插入数(仅针对于第一次交换的时候)
// 因为第一次说不定没交换,即使没交换,循环依然会继续,这样就导致了前面其实已经排好序了
if (arr[j] > arr[j+gap]) {
temp = arr[j];
arr[j] = arr[j+gap];
arr[j+gap] = temp;
} else { // 交换式优化,当第一次没有交换的时候,前面也本身是一个有序表,就无序再去对前面的进行判断是否交换了(大幅提高时间效率)
break;
}
}
}
System.out.println(Arrays.toString(arr));
}
}
对我来说,希尔排序不是特别好理解,原因在于被这种位移式的说法误解,以为当我们分割了数组后,对每个数组的元素两两进行比较,互相只比较一次即可,类似于双指针。实际上并不是,当我们拿到一个数组的时候,比如说[1,3,0,9,7],实际上在这里我们使用“直接插入排序”。(补充:对于交换法,虽然大体逻辑上是算插入排序,但是在数据处理的时候,由于使用的交换,其实和冒泡排序交换数据一样)
但是需要注意的是,插入排序我们是有一个双表的概念,有序表和无序表,一般我们使用的直接插入排序是从后往前,比如说拿到无序表末尾的数,然后和有序表的末尾依次往前开始比较,直到遇到比他小的数,就表示找到了合适的位置进行插入。
而希尔排序中说的”直接插入排序“实际上是先默认当前分割数组的第一位是有序的,然后取得第二位和第一位开始比较。上面那个数组中,有序表是1,那么我们就取得第二位3,然后1和3比较,不需要交换,有序表长度增加。等到下一次取到0的时候,有序表实际上是[1,3]了,这个时候0就是先和3比较,然后交换,数组当前为[1,0,3]然后0和1比较,交换,数组为[0,1,3],后面是数也是如此。因此这个直接插入排序取数的时候,是从无序表的第一位开始取的。一般的插入排序是从无序表的末尾开始取的
还有一点需要注意,在上面这个数组中,我们每次都是拿0和前面的依次比较,会让我们觉得是0依次和前面的比较,实际上不一定。比如数组[1,7],这个时候我们拿9就去比较,当7和9比较后,没发生交换,下一次就是1和7比较了,这是和插入排序有所不同的地方。这也是使用交换式希尔排序的一个问题,因为1和7实际上在之前买的比较中已经比较过了,并且是一个有序表了,这就是重复比较了(已经解决:当取到的需要交换的数据的时候,如果第一次没有发生交换,就不需要进行后面的判断了)。所以,我认为实际上我们是通过移动索引,进行索引之间的比较
对于我的难点:
for (int i = gap; i < arr.length; i++)
上面这个循环,实际上是依次取数组中的数,这里是不区分分割后的哪个数组的,因为会在下面的循环中进行区分。
for (int j = i - gap; j >= 0; j -= gap) if (arr[j] > arr[j+gap]) { temp = arr[j]; arr[j] = arr[j+gap]; arr[j+gap] = temp; } else { break; }
在上面这个循环中,实际上不仅仅是实现了数组索引位移,还实现了区分不同数组之间的数,使得不会发生冲突。当 gap 为2的时候,如果 i 是2,那么 j 就是0,这个0实际上是某个分割后数组的首位,然后通过 arr[j+gap] 就拿到了首位的下一位,让他们之间进行比较。
如果 i 移动到了8呢,那么 j 就是6,这个时候 arr[j+gap] 就是8,也就是 i 的值了(从这里可以看出,i 实际上就是待插入数,不过仅限于第三次循环的首次可以这样理解(当交换时优化完后,i也可以叫做被待插入数)),然后6和8的元素进行比较之后,j -= gap,移动到这个数组的前一位,就是4,此时4和6的元素进行比较,然后依次是2和4,0和2,比较完成,i继续移动
其实难点就在于i是一位一位的移动的,可以每次都是不同的分割后的数组,需要知道如何取到同一数组的前一位。再就是为什么是j -= gap 以及 int j = i - gap是因为只有这样,取到的索引才是依次向前(也就是有序表的末尾到前)开始比较。
// 这样也可以,j>=gap会把边界限制到分割后的数组的第二位,不会超过, // 也就是说不会到达数组的第一位去,因为如果到第一位了,就会因为j-gap导致越界 for (int j = i; j >= gap; j -= gap) { if (arr[j] < arr[j - gap]) { temp = arr[j]; arr[j] = arr[j - gap]; arr[j - gap] = temp; } else { break; } }
位移式希尔排序(纯正的插入排序升级版):这个时候,开头说的希尔排序思想就可能存在一点点问题
/**
* 位移法(纯正的插入排序升级)
* @param arr 待排序数组
*/
public static void shellSort2(int[] arr) {
int insertVal;
for (int gap = arr.length / 2; gap > 0; gap /= 2) {
for (int i = gap; i < arr.length; i++) {
// 找到最适合的位置后再移动
// 先保存当前值
insertVal = arr[i];
int j = i;
// j >= gap表示j不能小于当前数组的第一位索引,也可以写作 j-gap >= 0
// insertVal < arr[j - gap]表示待插入的值依然比前面的值小,还可以继续寻找,知道待插入数比某一个数大
while (j >= gap && insertVal < arr[j - gap]) {
// 后移,腾出空位
arr[j] = arr[j - gap];
// j继续往同一数组前移动,只有移动gap步才是同一数组的前一位
j -= gap;
}
// 找到合适的位置了
arr[j] = insertVal;
}
}
}