冒泡排序

冒泡排序是最基础,最经典的排序算法。联想一下可乐,最大的气泡总是会最快的上升到最上面。所以,冒泡的原理就是,对于一个数组, 每一次循环都让最大的元素冒泡到最后面
核心规则:

  1. 从数组的第一个元素开始,比较相邻两个元素的大小;
  2. 如果前者比后者大,就交换位置;
  3. 如果后者比前者大,位置不改变;
  4. 依次后移,每次循环都让最大的元素排到最后面。

代码如下:

  1. // 冒泡排序
  2. public static void bubbleSort(int[] array) {
  3. // 1. 每次循环,都能冒泡出剩余元素中最大的元素,因此需要循环 array.length 次
  4. for (int i = 0; i < array.length; i++) {
  5. // 2. 每次遍历,只需要遍历 0 到 array.length - i - 1中元素,因为之后的元素都已经是最大的了
  6. for (int j = 0; j < array.length - i - 1; j++) {
  7. //3. 交换元素
  8. if (array[j] > array[j + 1]) {
  9. int temp = array[j + 1];
  10. array[j + 1] = array[j];
  11. array[j] = temp;
  12. }
  13. }
  14. }
  15. }

时间复杂度分析

比较的次数为:

  1. N + (N - 1) + (N - 2) + ... + 2 + 1 = (N^2 + N)/2

最坏情况下,每次比较都要交换,所以交换的次数为 (N^2 + N)/2
所以时间复杂度为 O(N^2)

选择排序

选择排序的重点在于 选择 ,每次从剩余数组中, 选择最大或者最小 的一个,放到数组的一端。
核心规则:

  1. 维护两个变量,一个用来存储当前最大值,另一个用来存储最大值的索引
  2. 将当前最大值依次与后面的元素进行比较,如果比当前最大值大,就更新最大值及其索引
  3. 遍历结束,将最大值放到数组的最右边,即将最右边的元素位置和最大值的位置进行交换
  4. 重复上面的步骤,直到完成排序

代码如下:

  1. // 选择排序
  2. public static void selectSort(int[] array) {
  3. // 遍历N次,每次选择数组剩余元素中最大的元素
  4. for (int i = 0; i < array.length; i++) {
  5. // 暂时把第一个元素当做最大元素,并且记录最大元素的索引
  6. int maxIndex = 0;
  7. int max = array[0];
  8. // 注意 j 从 索引1 开始,到 array.length - i 截止
  9. for (int j = 1; j < array.length - i; j++) {
  10. // 如果元素大于当前最大值,则替换 max,maxIndex
  11. if (array[j] > max) {
  12. max = array[j];
  13. maxIndex = j;
  14. }
  15. }
  16. // 交换最大值元素 和 数组末尾元素
  17. int temp = array[maxIndex];
  18. array[maxIndex] = array[array.length - i - 1];
  19. array[array.length - i - 1] = temp;
  20. }
  21. }

时间复杂度分析

比较次数:

  1. N + (N - 1) + (N - 2) + ... + 2 + 1 = (N^2 + N)/2

冒泡 vs 选择

和冒泡排序一样,时间复杂度为 O(N^2) ,但是明显 选择排序要快一点 ,因为冒泡排序需要频繁的交换相邻的两个元素,而选择排序每次遍历只需要交换一次,所以 选择排序真实情况速度要比冒泡快一倍

插入排序

插入排序的重点是 插入 ,每次抽离一个元素当作临时元素,将临时元素依次和后面的元素比较,通过比较的大小移动后面元素的位置,找到临时元素最合适的位置插入。
核心规则:

  1. 第一轮,先将倒数第二个元素抽离作为临时元素
  2. 将临时元素与后面的元素进行比较,如果后面的元素小于临时元素,则后面的元素左移
  3. 如果后面的元素大于临时元素,或者已经到达数组末尾,就将临时元素插入当前空隙位置
  4. 重复上面步骤,完成排序

插入排序已经不是选择最大元素,而是通过每次迭代,将部分元素排好序,以达到全部排序的效果。
这里用图解来解释以上步骤:

  1. 第一次迭代

数据结构与算法-排序 - 图1

  1. 第二次迭代

数据结构与算法-排序 - 图2

  1. 第三次迭代

数据结构与算法-排序 - 图3
可以看到,每次迭代都将 末尾元素排好序
代码如下:
一定要记得处理到达尾部的情况。

  1. // 插入排序
  2. public static void insertSort(int[] array) {
  3. // 从倒数第二位开始,遍历到底0位,遍历 N-1 次
  4. for (int i = array.length - 2; i >= 0; i--) {
  5. // 存储当前抽离的元素
  6. int temp = array[i];
  7. // 从抽离元素的右侧开始遍历
  8. int j = i + 1;
  9. while (j <= array.length - 1) {
  10. // 如果某个元素,小于临时元素,则这个元素左移
  11. if (array[j] < temp) {
  12. array[j - 1] = array[j];
  13. } else {
  14. // 如果大于,则直接将临时元素插入,然后退出循环。
  15. array[j - 1] = temp;
  16. break;
  17. }
  18. j++;
  19. }
  20. // 处理到达尾部的情况
  21. if (j == array.length) {
  22. array[j - 1] = temp;
  23. }
  24. }
  25. }

也可以抽离第二个元素,比较其和前面的有序数组的大小来找到合适的位置。代码比较简洁:

  1. /**
  2. * 插入排序
  3. * 每次在排好序的数组中找到合适的位置插入
  4. * 插入排序的大部分时间都浪费在了移动元素上面
  5. * 升序-具体算法:
  6. * 1.将第一个元素作为有序数组,抽离后面一个元素
  7. * 2.从后向前扫描有序数组
  8. * 3.如果元素大于抽离的元素,就向后移动一位
  9. * 4.直到找到小于或者等于抽离元素的元素,将抽离元素插入到它的后面
  10. * @param nums
  11. */
  12. public static void insertSorted(int[] nums) {
  13. if (nums == null || nums.length < 2) {
  14. return;
  15. }
  16. for (int i = 1; i < nums.length; i++) {
  17. int temp = nums[i];
  18. int j = i - 1;
  19. while (j >= 0 && nums[j] > temp) {
  20. nums[j + 1] = nums[j];
  21. j--;
  22. }
  23. nums[j + 1] = temp;
  24. }
  25. }

时间复杂度分析

  • 最好情况下,数组本身就是升序的,每次迭代不需要进行移动,那么时间复杂度就是 O(N)
  • 最坏情况下,数组为降序排列,每次迭代每次比较都需要进行移动,那么比较次数为 (N^2 + N)/2 ,

移动次数为 (N^2 + N)/2 ,时间复杂度为 O(N^2)

冒泡 vs 选择 vs 插入

冒泡相比较而言肯定是最差的。
选择和插入就要分情况而定了,如果数组本身有很多元素就已经按照希望的顺序排好序了,那就使用插入排序,否则就使用选择排序。

插入排序的进阶-二分插入排序

插入排序的核心逻辑是找到临时元素的插入位置,而且是在 有序 数组中查找临时元素需要插入的位置,这就立刻可以想到 二分查找
所以可以写一个二分查找函数,用来查找元素的插入位置:
数据结构与算法-排序 - 图4

  1. // 查找应该插入的索引位置
  2. public static int searchIndex(int[] array, int left, int right, int aim) {
  3. // 循环查找节点位置
  4. while (left < right) {
  5. int middle = (left + right) / 2;
  6. if (array[middle] < aim) {
  7. left = middle + 1;
  8. } else {
  9. right = middle - 1;
  10. }
  11. }
  12. // 根据当前元素和目标元素进行对比,判断是否需要插入到当前元素之前
  13. if(array[left] > aim){
  14. return left -1;
  15. }
  16. // 否则就是当前位置
  17. return left;
  18. }

修改插入排序的代码:

  1. // 插入排序
  2. public static void insertSort(int[] array) {
  3. // 从倒数第二位开始,遍历到底0位,遍历 N-1 次
  4. for (int i = array.length - 2; i >= 0; i--) {
  5. // 存储当前抽离的元素
  6. int temp = array[i];
  7. int index = searchIndex(array, i + 1, array.length - 1, temp);
  8. // #1. 根据插入的索引位置,进行数组的移动和插入
  9. int j = i + 1;
  10. while (j <= index) {
  11. array[j - 1] = array[j];
  12. j++;
  13. }
  14. array[j - 1] = temp;
  15. }
  16. }

注释 #1 处,已经找到了抽离元素的插入位置 index ,就需要让小于抽离元素的元素左移,然后插入 temp

归并排序-分治思想

分治就是将一个大问题分解为几个子问题进行求解最后再合并几个子问题的解得到原问题的解。分治常常和递归一起使用。
生活中就有很多这样的例子,在疫情期间学校就有统计任务,学校会将任务分配给各个学院的院长,再由院长分配给各个年纪辅导员,辅导员再分配给各个班级的班长….
数据结构与算法-排序 - 图5
而利用分治的思想就实现了归并排序~
归并排序的核心思想就是将要排序的数组分解为小数组,将小数组排好序进行合并就得到排序数组。

数据结构与算法-排序 - 图6

  1. public static void main(String[] args) {
  2. int[] nums = {2,45,6,8,43,24,0};
  3. mergeSort(nums,0, nums.length - 1);
  4. for (int i = 0; i < nums.length; i++) {
  5. System.out.print(nums[i] + " ");
  6. }
  7. }
  8. /**
  9. * 归并排序 - 分治
  10. * 归并排序是先将数组拆分然后分别排好序再合并
  11. * 重点在于将两个排好序的数组进行合并
  12. * @param arr
  13. * @param left
  14. * @param right right = nums.length - 1,注意下标越界问题
  15. */
  16. public static void mergeSort(int[] arr, int left, int right) {
  17. if (right <= left) return;
  18. int mid = (left + right) >> 1;//右移一位相当于除以2
  19. mergeSort(arr, left, mid);
  20. mergeSort(arr, mid + 1, right);
  21. merge(arr, left, mid, right);
  22. }
  23. /**
  24. * 将两个排好序的数组进行合并,并且保证合并之后的数组是排好序的
  25. * 具体算法:
  26. * 首先需要维护一个中间数组temp,用来放合并的元素
  27. * 循环遍历两个子数组,放入temp中
  28. * 记得将最后剩余的元素放入temp
  29. * 再复制
  30. 给原数组
  31. * @param arr
  32. * @param left
  33. * @param mid
  34. * @param right
  35. */
  36. private static void merge(int[] arr, int left, int mid, int right) {
  37. int[] temp = new int[right - left + 1];
  38. //i表示左数组的第一个元素的下标,j表示右数组的第一个元素下标,k用来记录放入temp中的元素个数
  39. int i = left,j = mid + 1,k = 0;
  40. while (i <= mid && j <= right) {
  41. temp[k++] = arr[i] <= arr[j] ? arr[i++] : arr[j++];
  42. }
  43. while (i <= mid) temp[k++] = arr[i++];
  44. while (j <= right) temp[k++] = arr[j++];
  45. for (int l = 0; l < temp.length; l++) {
  46. arr[left + l] = temp[l];
  47. }
  48. //System.arraycopy(temp, 0, arr, left, temp.length);
  49. }

快速排序 - 分治

快排也用到了分治的思想,但是和归并排序刚好是反过来的。
归并是先排序左右子数组,然后将有序子数组进行合并排序;快排是先调配出左右子数组,然后对两个子数组进行排序。
所以,快排的重点是如何调配出左右子数组。

  1. 我们可以通过找到一个基准元素(这个基准元素可以随便选)
  2. 将数组元素分别与基准元素比较
  3. 比基准元素大的就放右边,比基准元素小的就放左边
  4. 然后对分出的两个子数组进行排序
  5. 达到全部排好序 ```java /**
    • 快速排序
    • 快排是通过一个基准元素,将数组分为小于基准元素和大于基准元素的两个子数组
    • 然后分别排序以达到全排好序
    • @param arr
    • @param begin
    • @param end / public static void quickSorted(int[] arr, int begin, int end) { if (end <= begin) return; int pivot = partition(arr, begin, end); quickSorted(arr, begin, pivot - 1); quickSorted(arr, pivot + 1, end); } /*
    • 这个方法的作用就是返回基准元素pivot的下标
    • 且保证pivot左侧的元素都小于pivot,右侧的元素都大于pivot
    • 方法巧妙的地方在于维护一个变量counter记录小于pivot的数量,counter初始化为begin,
    • 只要有小于pivot的元素就和counter交换位置,最后counter就是基准元素的下标位置
    • @param arr
    • @param begin
    • @param end
    • @return */ private static int partition(int[] arr, int begin, int end) { //基准元素随便选 int pivot = end, counter = begin; for (int i = begin; i < end; i++) {
      1. if (arr[i] < arr[pivot]) {
      2. int temp = arr[counter];
      3. arr[counter] = arr[i];
      4. arr[i] = temp;
      5. counter++;
      6. }
      } int temp = arr[pivot]; arr[pivot] = arr[counter]; arr[counter] = temp; return counter; }

```