排序算法总结
- 问题一:冒泡排序的优化
1 分类
- 内部排序:整个排序工作能够在主存中完成
- 冒泡排序、快速排序、直接插入排序、希尔排序、简单选择排序、堆排序、归并排序
- 外部排序:在磁盘上或者磁带上完成的排序
- 计数排序、桶排序、基数排序
区别:(引自十大经典排序算法)
- n:数据规模
- k:"桶"的个数
- In-place:占用常数内存,不占用额外内存
- Out-place:占用额外内存
- 稳定性:排序后 2 个相等键值的顺序和排序之前它们的顺序相同
2 内部排序
2.1 冒泡排序
-
思想:重复比较数组中相邻两个元素,如果二者顺序错误就进行交换,依次比较一次遍历后会将最大的元素放到最后面;每次遍历缩小区间,让大的元素一步一步冒到最后面,因此叫冒泡排序。
-
步骤:
- 比较相邻的元素,如果前一个比后一个大,进行交换,第一遍结束后最大的元素刚好在最后一个位置
- 缩小遍历范围,重复上述操作
-
代码:
public static void bubbleSort(int[] nums) { if (nums == null || nums.length == 0) return; int n = nums.length; for (int i = n - 1; i > 0; i--) { // 维护每轮遍历的终点 for (int j = 1; j <= i; j++) { // 趟 if (nums[j] < nums[j - 1]) swap(nums, j, j - 1); } } } public static void swap(int[] nums, int i, int j){ int temp = nums[i]; nums[i] = nums[j]; nums[j] = temp; }
-
问题:首先一种极端情况,数组刚开始就有序,此时其实不用继续比较了,然而上面的代码还会继续进行下一轮比较,直到n-1次,显然这种操作是不合理的。
-
优化:设定一个标记位flag,初始化为false表示没有交换,发生了交换改为true,如果一次遍历后flag仍为true,则说明已经排好序啦结束循环即可。
public static void bubbleSort(int[] nums) { if (nums == null || nums.length == 0) return; int n = nums.length; boolean flag = false; // 设置标记位,初始为false for (int i = n - 1; i > 0; i--) { // 维护每轮遍历的终点 for (int j = 1; j <= i; j++) { // 趟 if (nums[j] < nums[j - 1]) { swap(nums, j, j - 1); flag = true; } } if (!flag) break; } } public static void swap(int[] nums, int i, int j){ int temp = nums[i]; nums[i] = nums[j]; nums[j] = temp; }
-
进一步优化:对于这样的数组[4, 3, 2, 1, 6, 7, 8, 9]来说后面的四个元素已经有序了,再去进行比较显得多此一举。优化的方法可以记录最后一次的交换位置,然后下次遍历时只需要比较到这个位置即可。
public static void bubbleSort(int[] nums) { if (nums == null || nums.length == 0) return; int n = nums.length; int lastSwap = 0; // 设置一个指针指向最后一次的比较位置 for (int i = n - 1; i > 0; i--) { // 维护每轮遍历的终点 for (int j = 1; j <= i; j++) { // 趟 if (nums[j] < nums[j - 1]) { swap(nums, j, j - 1); lastSwap = j; } } i = lastSwap; } } public static void swap(int[] nums, int i, int j){ int temp = nums[i]; nums[i] = nums[j]; nums[j] = temp; }
-
继续优化:双管齐下式加标记位,即从左到右将最大值冒到最右边,从右往左将最小值冒到最左边。
public static void bubbleSort(int[] nums) { if (nums == null || nums.length == 0) return; int n = nums.length; int left = 1, right = n - 1; int lastSwap = 0; // 设置一个指针指向最后一次的比较位置 while (left < right) { // 向右冒泡 for (int i = left; i <= right; i++) { if (nums[i] < nums[i - 1]) { swap(nums, i, i - 1); lastSwap = i; } } right = lastSwap; // 向左冒泡 for (int j = right; j > left; j--) { if (nums[j] < nums[j - 1]) { swap(nums, j, j - 1); lastSwap = j - 1; } } left = lastSwap; } } public static void swap(int[] nums, int i, int j){ int temp = nums[i]; nums[i] = nums[j]; nums[j] = temp; }
2.2 选择排序
-
思想:将数组分成有序区和无序区,在无序区里找一个最小的元素跟在有序区的后面。注意选择排序算法由于什么数据进去都是O(n²)的时间复杂度,因此用到它的时候,数据规模越小越好。
-
步骤:
- 首先在未排序序列中找到最小(最大)值,存放在排序序列的起始位置
- 再从剩余未排序元素中继续寻找最小(大)值,放到已排序序列的末尾
- 重复第二步,知道所有元素均排序完毕
-
代码:
public static void selectSort(int[] nums) { if (nums == null || nums.length <= 1) return; int n = nums.length; // int minNum = nums[0], min = 0; for (int i = 0; i < n - 1; i++) { int min = i; for (int j = i + 1; j < n; j++) { if (nums[j] < nums[min]) { min = j; } } // 将min位置和i位置进行交换 swap(nums, i, min); } } public static void swap(int[] nums, int i, int j){ int temp = nums[i]; nums[i] = nums[j]; nums[j] = temp; }
2.3 直接插入排序
-
思想:类似于扑克牌整理,通过构建有序序列,对于未排序的数据,在已排序中从后往前扫描找到相应位置并插入。
-
步骤:
- 将序列中第一个元素看做一个有序序列,把第二个元素到最后一个元素看成未排序序列
- 从头到尾扫描未排序序列,将扫描的每个元素插入有序序列的适当位置。
-
代码:
public static void insertSort(int[] nums) { if (nums == null || nums.length == 0) return; int n = nums.length; // 遍历待排序列 int preIndex, current; for (int i = 1; i < n; i++) { preIndex = i - 1; current = nums[i]; // 从后往前扫描寻找插入位置 while (preIndex >= 0 && nums[preIndex] > current) { nums[preIndex + 1] = nums[preIndex]; preIndex--; } nums[preIndex + 1] = current; } }
-
优化:拆半插入
在上面的排序中,我们都会和遍历整个已排序列表去寻找,由于前半部分已是有序的,因此可以改用二分查找来找到待插入位置,然后进行插入。
public static void insertSort(int[] nums) { if (nums == null || nums.length == 0) { return; } int n = nums.length; int current; for (int i = 1; i < n; i++) { int left = 0; int right = i - 1; current = nums[i]; // 二分查找已排序序列 while (left <= right) { int mid = left + (right - left) / 2; if (current > nums[mid]) { left = mid + 1; } else { right = mid - 1; } // 插入到left+1处 for (int j = right; j > left; j--) { nums[j + 1] = nums[j]; } nums[left + 1] = current; } } }
2.4 希尔排序
-
思想:希尔排序也称递减增量排序,是插入排序的更高效的改进版本,但它是非稳定排序算法。基本思想是先将整个待排序列的记录序列分割成若干子序列分别进行直接插入排序,待整个序列中的记录“基本有序时”再对全体记录进行直接插入排序。
它是基于直接插入排序的以下两种特性提出的:
- 插入排序在堆几乎已经排好序的数据操作时,效率高,即可以达到线性排序的效率。
- 但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位。
-
步骤:
- 选择增量gap = length/2,缩小增量以gap = gap/2的方式,用序列{n/2,(n/2)/2…1}来表示。
- 对每个子序列进行直接插入排序,直到增量为1即最后一趟直接插入排序后记录有序。
图源:https://www.runoob.com/data-structures/shell-sort.html
- 初始增量第一趟gap = length/2 = 4
- 第二趟,增量缩小为2
- 第三趟,增量缩小为1,得到最终排序结果
-
代码:
public static void shellSort(int[] nums) { if (nums == null || nums.length == 0) return; int len = nums.length; int temp; for (int gap = len / 2; gap >= 1; gap /= 2) { for (int i = gap; i < len; i++) { temp = nums[i]; int j = i - gap; // 当前位置的上一个位置,差一个增量 // 直接插入排序 while (j >= 0 && nums[j] > temp) { nums[j + gap] = nums[j]; j -= gap; } nums[j + gap] = temp; } } }
2.5 归并排序
-
思想:该算法采用分治法思想,将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
-
步骤:
- 把长度为n的序列分成两个长度相等的n/2的子序列
- 对这两个子序列分别采用归并排序
- 将两个排序好的子序列合并成一个最终的排序序列
-
代码:
public static void mergeSort(int[] nums) { if (nums == null || nums.length == 0) return; int n = nums.length; int[] temp = new int[n]; // 避免递归时频繁开辟空间,先建好一个长度等于原数组长度的临时数组 mergeSortRecursive(nums, 0, n - 1, temp); } private static void mergeSortRecursive(int[] nums, int left, int right, int[] temp) { if (left < right) { int mid = left + (right - left) / 2; mergeSortRecursive(nums, left, mid, temp); // 左边归并排序 mergeSortRecursive(nums, mid + 1, right, temp); // 右边归并排序 merge(nums, left, mid, right, temp); // 将两个有序数组合并 } } private static void merge(int[] nums, int left, int mid, int right, int[] temp) { int i = left; // 左序列指针 int j = mid + 1; // 右序列指针 int t = 0; // 临时数组指针 while (i <= mid && j <= right) { if (nums[i] <= nums[j]) { temp[t++] = nums[i++]; } else { temp[t++] = nums[j++]; } } // 将左边剩余元素放到temp中 while (i <= mid) { temp[t++] = nums[i++]; } // 将右边剩余元素放入temp中 while (j <= right) { temp[t++] = nums[j++]; } t = 0; // 将temp中元素拷贝到原数组中 while (left <= right) { nums[left++] = temp[t++]; } }
2.6 快速排序
-
思想:通过一趟排序将待排序列分隔成独立的两部分,其中一部分记录关键字小于基准,另一部分大于等于基准,分别对这两部分继续排序,以达到整个序列有序。
-
步骤:
- 从数列中挑选出一个元素,作为基准(privot);
- 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
- 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序;
-
代码:
public static void quickSort(int[] nums, int left, int right) { if (nums == null || nums.length <= 1) return; // 1. 以第一个位置元素为基准进行一次排序,并返回基准的索引 if (left < right) { int part = partical(nums, left, right); quickSort(nums, left, part - 1); quickSort(nums, part + 1, right); } } public static int partical(int[] nums, int left, int right) { // 1. 基准初始left,从left加1开始遍历 int index = left + 1; // 遍历从1到right,进行判断交换 for (int i = index; i <= right; i++) { if (nums[i] < nums[left]) { // 交换,将小于part的值放在左边,大于的放右边,然后将part的值根index-1的值交换 swap(nums, i, index); index++; } } swap(nums, left, index - 1); return index - 1; } public static void swap(int[] nums, int i, int j) { int temp = nums[i]; nums[i] = nums[j]; nums[j] = temp; }
2.7 堆排序
-
相关概念
-
介绍:堆是一类数据结构,通常是一个可以被看做一棵完全二叉树的数组对象。满足如下性质:
- 堆中某个节点的值总是不大于或不小于其父节点的值。
- 堆总是一棵完全二叉树。
-
堆的shift up:把向一个最大堆添加元素的操作,称为shift up。如下例子:
-
* 首先交换索引5和11数组中数值的位置,即交换52和16。
* 此时52依然比父节点大,继续交换。
* 这时52比其父节点小了满足最大堆的定义。我们称这个过程为最大堆的shift up。
- 堆的shift down:从一个最大堆中取出一个元素称为shift down,只能取出根节点。
-
思想:堆排序是指利用堆的数据结构设计的一种排序算法。堆积一个近似完全二叉树的结构,并满足堆积的性质:子结点的键值总小于(大于)它的父结点。堆有两种形态:
- 大顶堆:每个节点的值都大于或等于其子节点的值,在堆排序算法中用于升序排列,arr[i] >= arr[2i+1] && arr[i] >= arr[2i+2]
- 小顶堆:每个节点的值都小于或等于其子节点的值,在堆排序算法中用于降序排列,arr[i] <= arr[2i+1] && arr[i]<= arr[2i+2]
-
步骤:
-
构造初始堆,即将给定无序序列构造成一个大顶堆;
-
给定无序序列如下图:
-
从最后一个非叶子节点开始(arr.length/2 - 1),也就是6开始,从左到右,从下至上进行调整。
-
-
3. 找到第二个非叶子节点4,由于[4, 9, 8]中9最大,4和9交换。
-
这时,字根[4,5,6]结构混乱,继续调整,交换4和6。至此就构建成一个大顶堆了。(注:构建代码方法如下bulidMaxHeap)
-
把堆首和堆尾互换;
-
把堆的尺寸缩小1,并调用shift_down(0);
-
重复步骤2,直到堆的尺寸为1.
-
代码:
public static void heapSort(int[] nums) { // 1. 构建大顶堆 buildMaxHeap(nums); // 2. 交换堆首和堆尾,重新调整堆结构 for (int i = nums.length - 1; i >= 0; i--) { swap(nums, 0, i); adjustHeap(nums, 0, i); } } /* 构建大顶堆 */ private static void buildMaxHeap(int[] nums) { if (nums == null || nums.length == 0) return; int len = nums.length; // 从最后一个非叶子节点开始依次往上调整 for (int i = len / 2 - 1; i >= 0; i--) { // 调整堆 adjustHeap(nums, i, len); } } /** * 调整大顶堆:注意从传入参数节点往下都要判断调整,因为上面的变化有可能会改变大顶堆的结构 */ private static void adjustHeap(int[] nums, int i, int len) { // 初始最大值为i位置的元素 int largest = i; int left = 2 * i + 1, right = 2 * i + 2; // 更新largest为三个元素的最大值位置 if (left < len && nums[left] > nums[largest]) largest = left; if (right < len && nums[right] > nums[largest]) largest = right; // 如果largest改变,则需要交换 if (largest != i) { // 交换 swap(nums, i, largest); // 递归判断下一层 adjustHeap(nums, largest, len); } } public static void swap(int[] nums, int i, int j) { int temp = nums[i]; nums[i] = nums[j]; nums[j] = temp; }
// 调整堆的迭代版本 private static void adjustHeap(int[] nums, int i, int len) { // 取出i位置的元素 int temp = nums[i]; // 开始往下判断左右节点 for (int k = i * 2 + 1, k < len; k = k * 2 + 1) { // 将k指向左右边的较大者 if (k + 1 < len && nums[k] < nums[k + 1]) { k++; } // 判断k位置元素和temp的大小 if (nums[k] > temp) { nums[i] = nums[k]; i = k; } else { // 已经符合大顶堆结构,结束 break; } } // 把temp放入到最终位置 nums[i] = temp; }