上篇文章写了关于 Java 内部类的基本知识,感兴趣的朋友能够去看一下:搞懂 JAVA 内部类;本文写的内容是最近学习的算法相关知识中的基本排序算法,排序算法也算是面试中的常客了,实际上也是算法中最基本的知识。因为 Android 开发中用到的地方并很少,因此也很容易遗忘,可是为了进阶高级工程师巩固基本算法和数据结构也是必修课程之一。html
基本排序算法按难易程度来讲能够分为:冒泡排序,选择排序,插入排序,归并排序,选择排序。本文也将从这五种排序算法来说解各自的中心思想,和 Java 实现方式。java
冒泡排序恐怕是咱们计算机专业课程上以第一个接触到的排序算法,也算是一种入门级的排序算法。git
冒泡排序虽然简单可是对于 n 数量级很大的时候,实际上是很低效率的。因此实际生产中不多使用这种排序算法。下面咱们看下这种算法的具体实现思路:github
一次比较过程如图所示(图片 Google 来的侵删)面试
/** * @param arr 待排序数组 * @param n 数组长度 arr.length */ private static void BubbleSort(int[] arr, int n) { for (int i = 0; i < n - 1; i++) { for (int j = 1; j < n - i; j++) { if (arr[j - 1] > arr[j]) { //交换两个元素 int temp = arr[j]; arr[j] = arr[j - 1]; arr[j - 1] = temp; } } } } 复制代码
对于长度为 n 的数组,冒泡排序须要通过 n(n-1)/2 次比较,最坏的状况下,即数组自己是倒序的状况下,须要通过 n(n-1)/2 次交换,因此其算法
冒泡排序的算法时间平均复杂度为O(n²)。空间复杂度为 O(1)。数组
能够想象一下:若是两个相邻的元素相等是不会进行交换操做的,也就是两个相等元素的前后顺序是不会改变的。若是两个相等的元素没有相邻,那么即便经过前面的两两交换把两个元素相邻起来,最终也不会交换它俩的位置,因此相同元素通过排序后顺序并无改变。数据结构
因此冒泡排序是一种稳定排序算法。因此冒泡排序是稳定排序。这也正是算法稳定性的定义:dom
排序算法的稳定性:通俗地讲就是能保证排序前两个相等的数据其在序列中的前后位置顺序与排序后它们两个前后位置顺序相同。post
冒泡排序总结:
选择排序是另外一种简单的排序算法。选择排序之因此叫选择排序就是在一次遍历过程当中找到最小元素的角标位置,而后把它放到数组的首端。咱们排序过程都是在寻找剩余数组中的最小元素,因此就叫作选择排序。
选择排序的思想也很简单:
示意图:
public static void sort(int[] arr) { int n = arr.length; for (int i = 0; i < n; i++) { int minIndex = i; // for 循环 i 以后全部的数字 找到剩余数组中最小值得索引 for (int j = i + 1; j < n; j++) { if (arr[j]< arr[minIndex]) { minIndex = j; } } swap(arr, i, minIndex); } } /** * 角标的形式 交换元素 */ private static void swap(int[] arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } 复制代码
上述 java 代码能够看出咱们除了交换元素并未开辟额外的空间,因此额外的空间复杂度为O(1)。
对于时间复杂度而言,选择排序序冒泡排序同样都须要遍历 n(n-1)/2 次,可是相对于冒泡排序来讲每次遍历只须要交换一次元素,这对于计算机执行来讲有必定的优化。可是选择排序也是名副其实的慢性子,即便是有序数组,也须要进行 n(n-1)/2 次比较,因此其时间复杂度为O(n²)。
即使不管如何也要进行n(n-1)/2 次比较,选择排序还是不稳定的排序算法,咱们举一个例子如:序列5 8 5 2 9, 咱们知道第一趟选择第1个元素5会与2进行交换,那么原序列中两个5的相对前后顺序也就被破坏了。
选择排序总结:
对于插入排序,大部分资料都是使用扑克牌整理做为例子来引入的,咱们打牌都是一张一张摸牌的,没摸到一张牌就会跟手里全部的牌比较来选择合适的位置插入这张牌,这也就是直接插入排序的中心思想,咱们先来看下动图:
相信你们看完动图之后大概知道了插入排序的实现思路了。那么咱们就来讲下插入排序的思想。
下面先看下最基本的实现:
public static void sort(int[] arr) { int n = arr.length; for (int i = 0; i < n; i++) { //内层循环比较 i 与前边全部元素值,若是 j 索引所指的值小于 j- 1 则交换二者的位置 for(int j = i; j > 0 && arr[j-1] > arr[j]; j--){ swap(arr,j-1,j); } } } 复制代码
在上述算法实现中咱们每次寻找 i 应该处在数组中哪一个为位置的时候,都是以交换当前元素与上一个元素为代价的,咱们知道交换操做是要比赋值操做要费时的,由于每次交换都须要通过三次赋值操做,咱们想一下咱们玩扑克的时候没有拿起一张牌一个个向前挪知道放到其该放的位置的吧,都是拿出这张牌,找到位置就插进去(忽然邪恶),实际上咱们是将这个位置之后的牌一次向后挪了一个位置,那么用Java 代码是否能实现呢?答案确定是能够的:
public static void sort(int[] arr) { int n = arr.length; for (int i = 0; i < n; i++) { //拎出来当前未排序的这样牌 int e = arr[i]; //寻找其该放的位置 for(int j = i; j > 0 && arr[j-1] > arr[j]; j--){ arr[j]= arr[j-1]; } //循环结束后 arr[j] >= arr[j-1] 那么 j 角标就是e 应该在的位置。 arr[j] = e; } } 复制代码
对于插入的时间复杂度和空间复杂度,经过代码就能够看出跟选择和冒泡来讲没什么区别同属于 O(n²) 级别的时间复杂度算法 ,只是遍历方式有原来的 n n-1 n-2 ... 1,变成了 1 2 3 ... n 了。最终获得时间复杂度都是 n(n-1)/2。
对于稳定性来讲,插入排序和冒泡同样,并不会改变原有的元素之间的顺序,若是碰见一个与插入元素相等的,那么把待插入的元素放在相等元素的后面。因此,相等元素的先后顺序没有改变,从原无序序列出去的顺序还是排好序后的顺序,因此插入排序是稳定的。
对于插入排序这里说一个很是重要的一点就是:因为这个算法能够提早终止内层比较( arr[j-1] > arr[j])因此这个排序算法颇有用!所以对于一些 NlogN 级别的算法,后边的归并和快速都属于这个级别的,算法来讲对于 n 小于必定级别的时候(Array.sort 中使用的是47)均可以用插入算法来优化,另外对于近乎有序的数组来讲这个提早终止的方式就显得更加又有优点了。
插入排序总结:
接下来咱们看一个 NlogN 级别的排序算法,归并算法。 归并算法正如其名字同样采用归并的方法进行排序:
咱们老是能够将一个数组一分为二,而后二分为四直到,每一组只有两个元素,这能够理解为个递归的过程,而后将两个元素进行排序,以后再将两个元素为一组进行排序。直到全部的元素都排序完成。一样咱们来看下边这个动图。
归并算法其实能够分为递归法和迭代法(自低向上归并),两种实现对于最小集合的归并操做思想是同样的区别在于如何划分数组,咱们先介绍下算法最基本的操做:
假设咱们如今在对一个数组的 arr[l...r]
部分进行归并,按照上述归并思想咱们可将数组分为两部分 假设为 arr[l...mid] 和 arr[mid+1...r]
两部分,注意这两部分可能长度并不相同,由于基数个数的数组划分的时候老是能获得一个 长度为1 和长度为2 的部分进行归并.
那么咱们按照上述思路进行代码编写:
/** * arr[l,mid] 和 arr[mid+1,r] 两部分进行归并 */ private static void merge(int[] arr, int l, int mid, int r) { // 复制等待归并数组 用来进行比较操做,最将原来的 arr 每一个角标赋值为正确的元素 int[] aux = new int[r - l + 1]; for (int i = l; i <= r; i++) { aux[i - l] = arr[i]; } int i = l; int j = mid + 1; for (int k = l; k <= r; k++) { if (i > mid) { //说明左边部分已经全都放进数组了 arr[k] = aux[j - l]; j++; } else if (j > r) { //说明左边部分已经全都放进数组了 arr[k] = aux[i - l]; i++; } else if (aux[i - l] < aux[j - l]) { //当左半个数组的元素值小于右边数组元素值得时候 赋值为左边的元素值 arr[k] = aux[i - l]; i++; } else { //当左半个数组的元素值大于等于右边数组元素值得时候 赋值为左边的元素值 这样也保证了排序的稳定性 arr[k] = aux[j - l]; j++; } } } 复制代码
相信你们配合刚才的动图和上述算法实现已经理解了归并算法了,若是感到迷糊的话能够试着拿个一个数组在纸上演算一下归并的过程,相信你们必定能够理解。上述只是实现了算法核心部分,那么咱们应该怎么对整个数组来进行排序呢?上边也提到了有两种方法,一种是递归划分法,一种是迭代遍历法(自低向上)那么咱们先来开来看递归实现:
/** * * @param arr 待排序数组 * @param l 其实元素角标 0 * @param r 最后一个元素角标 n -1 */ private static void mergeSort(int[] arr, int l, int r) { if (l >= r) { return; } //开始归并排序 向下取整 int mid = (l + r) / 2; //递归划分数组 mergeSort(arr, l, mid); mergeSort(arr, mid + 1, r); //检查是否上一步归并完的数组是否有序,若是有序则直接进行下一次归并 if (arr[mid] <= arr[mid + 1]) { return; } //将两边的元素归并排序 merge(arr, l, mid, r); } 复制代码
若是对递归过程不理解能够配合下边这个图来理解(图片来自网上,侵删):
固然咱们merge先对左半部分进行的也就是先进行到Level3的左边最底层 8 | 6 ,而后归并完成后进行右边递归到底 最终是 8 6 2 3 | 1 5 7 4 进行归并。
对于迭代实现归并其实和递归实现有所不一样,迭代的时候咱们是将数组分为 一个一个的元素,而后每两个归并一次,第二次咱们将数组每两个分一组,两个两个的归并,知道分组大小等于待归并数组长度为止,即先局部排序,逐步扩大到全局排序
/** * 自低向上的归并排序 * * @param n 为数组长度 * @param arr 数组 */ private static void mergeSortBU(Integer[] arr, int n) { //外层遍历从归并区间长度为1 开始 每次递增一倍的空间 1 2 4 8 sz 须要遍历到数组长度那么大 //sz = 1 : [0] [1]... //sz = 2 : [0,1] [2.3] ... //sz = 4 : [0..3] [4...7] ... for (int sz = 1; sz <= n; sz += sz) { //内层遍历要比较 arr[i,i+sz-1] arr[i+sz,i+sz+sz-1] 两个区间的大小 也就是每次对 sz - 1 大小的数组空间进行归并 // 注意每次 i 递增 两个 sz 的长度 ,由于每次 merge 的时候已经归并了两个 sz 长度 部分的数组 for (int i = 0; i + sz < n; i += sz + sz) { merge(arr, i, i + sz - 1, Math.min(i + sz + sz - 1, n - 1)); } } } 复制代码
好比咱们看第一次是 sz = 1 个长度的归并即 i = 0 i = 1 的元素归并 下次归并应该为 i= 2 i = 3 一次类推 因此内层循环 i 每次应该递增 两个 sz 那么大 为了不角标越界且保证归并的右半部分存在 因此 i + sz < n ,又考虑到数组长度为奇数的状况,因此右半边的右边为 Math.min(i + sz + sz - 1, n - 1);能够参考下边的图片:
其实对于归并排序的时间复杂对有一个递归公式来推断出时间复杂度,但简单来说假设数组长度为 N ,那么咱们就有 logN 次划分区间,而最终会划分为常数 级别的归并,将全部层的归并时间加起来获得了一个 NlogN,想要了解归并排序时间复杂度讲解的同窗能够左转 归并排序及其时间复杂度分析,这里再也不过多讲解。
对于空间复杂度,咱们经过算法实现能够看出咱们归并过程申请了 长度为 N 的临时数组,来进行归并因此空间复杂度为 O(n);
又因为咱们在排序过程当中对于 aux[i - l] = aux[j - l] 并无进行位置交换直接取得靠前的元素先赋值,因此算法是稳定的。
** 归并排序总结:**
快速排序为应用最多的排序算法,由于快速二字而闻名。快速排序和归并排序同样,采用的都是分治思想。分治法的基本思想是:将原问题分解为若干个规模更小但结构与原问题类似的子问题。递归地解这些子问题,而后将这些子问题的解组合为原问题的解。咱们只需关注最小问题该如何求解,和如何去递归既能够获得正确的算法实现。快速排序能够分为:单路快速排序,双路快速排序,三路快速排序,他们区别在于选取几个指针来对数组进行遍历下面咱们依次来说解。
首先咱们选取数组中的一个数,将其放在合适的位置,这个位置左边的数所有小于该数值,这个位置右边的数所有大于该数值 。
假设数组为 arr[l...r]
假设指定数值为数组第一个元素 int v = arr[l]
,假设 j 标记为比 v 小的最后一个元素, 即 arr[j+1] > v
。当前考察的元素为 i 则有arr[l + 1 ... j] < v , arr[j+1,i) >= v
如上图所示。
假设正在考察的元素值为 e ,e >= v
的时候咱们只需交将不动,直接 i++ 去考察下一个元素,
当e < v
由上述假设咱们须要将 e 放在<v 的部分 ,此时咱们只需将 arr[j]
和 arr[i]
交换一下位置便可。
最后一个元素考察完成之后,咱们再讲 arr[l]
和 arr[j]
调换一下位置就能够了。
上述遍历完成之后 arr[l + 1 ... j] < v , arr[j+1,i) >= v
就知足了,接下来咱们只须要递归的去考察 arr[l + 1 ... j] 和 arr[j+1,r] 便可。
private static void quickSort(int[] arr, int l, int r) { if (l >= r) { return; } // p 为 第一次 排序完成后 v 应该在的位置,即分治的划分点 int p = partition(arr, l, r); quickSort(arr, l, p - 1); quickSort(arr, p + 1, r); } private static int partition(Integer[] arr, int l, int r) { // 为了提升效率,减小形成快速排序的递归树不均匀的几率, // 对于一个数组,每次随机选择的数为当前 partition 操做中最小最大元素的可能性为 1/n int randomNum = (int) (Math.random() * (r - l + 1) + l); swap(arr, l, randomNum); int v = arr[l]; int j = l; for (int i = l + 1; i <= r; i++) { if (arr[i] < v) { swap(arr, j + 1, i); j++; } } swap(arr, l, j); return j; } private static void swap( int[] arr, int i, int j) { int temp = arr[i]; arr[i] = arr[j]; arr[j] = temp; } 复制代码
对于上述算法中为何选取了当前排序数组中随机一个元素进行比较,假设咱们在考察的数组已经为已经排序好的数组,那么咱们递归树就会向右侧延伸 N 的深度,这种状况使咱们不想要看到的,若是咱们每次 partition 都随机从数组中取一个数,那么这个数是当前排序数组中最小元素可能性为 1/n 那么每次都取到最小的数的可能性就很低了。
跟单路同样,双路快速排序,一样选择数组的第一个元素当作标志位(通过随机选择后的)
双路快速排序要求有两个指针,指针 i j 分别指向 l+1 和 r 的位置而后二者同时向数组中间遍历 在遍历过程当中要保证arr[l+1 ... i) <= v, arr(j....r] >= v
所以咱们能够初始化 i = l+1 以保证左侧区间初始为空,j = r 保证右侧空间为空
遍历过程当中要 i <= r 且 arr[i] <= v 的时候 i ++ 就能够了 当 arr[i] > v 时表示遇到了 i 的值大于 v 数值 此刻能等待 j 角标的值,从右向左遍历数组 当 arr[i] < v 表示遇到了 j 的值小于 v 的元素,它不应在这个位置呆着,
获得了 i j 的角标后 先要判断是否到了循环结束的时候了,即 i 是否已经 大于 j 了。
不然 应该讲 i 位置的元素和 j 位置的元素交换位置,而后 i++ j-- 继续循环
遍历结束的条件是 i>j 此时 arr[j]为最后一个小于 v 的元素 arr[i] 为第一个大于 v 的元素 所以 j 这个位置 就应该是 v 所应该在数组中的位置 所以遍历结束后须要交换 arr[l] 与 arr[j]
private static void quickSort(int[] arr, int l, int r) { if (l >= r) { return; } // 这里 p 为 小于 v 的最后一个元素,=v 的第一个元素 int p = partition(arr, l, r); quickSort(arr, l, p - 1); quickSort(arr, p + 1, r); } private static int partition(int[] arr, int l, int r) { // 为了提升效率,减小形成快速排序的递归树不均匀的几率, // 对于一个数组,每次随机选择的数为当前 partition 操做中最小最大元素的可能性下降 int randomNum = (int) (Math.random() * (r - l + 1) + l); swap(arr, l, randomNum); int v = arr[l]; int i = l + 1; int j = r; while (true) { while (i <= r && arr[i] <= v) i++; while (j >= l + 1 && arr[j] >= v) j--; if (i > j) break; swap(arr, i, j); i++; j--; } //j 最后角标停留在 i > j 即为 比 v 小的最后一个一元素位置 swap(arr, l, j); return j; } 复制代码
双路快速排序为最常用的快速排序实现,java 中对基本数据类型的排序 Arrays.sort() Collections.sort()
内部原理就是经过这种快速排序实现.
上述两种算法咱们发现对于与标志位相同的值得处理老是,作了多余的交换处理,若是咱们可以将数组分为> = <
三部分的话效率可能会有所提升。 以下图所示:
咱们将数组划分为 arr[l+1...lt] <v arr[lt+1..i) =v arr[gt...r] > v
三部分 其中 lt 指向 < v 的最后一个元素前一个元素,gt 指向>v的第一个元素的前一个元素,i 为当前考察元素
定义初始值得时候依旧能够保证这初始的时候这三部分都为空 int lt = l; int gt = r + 1; int i = l + 1;
当 e > v
的时候咱们须要将 arr[i] 与 arr[gt-1]
交换位置,并将 > v
的部分扩大一个元素 即 gt--
可是此时 i 指针并不须要操做,由于换过过来的数尚未被考察。
当 e = v
的时候 i ++ 继续考察下一个
当 e < v
的时候咱们须要将 arr[i] 与 arr[lt+1]
交换位置
当循环结束的时候 lt 位于小于 v 的最后一个元素位置因此最后咱们须要将arr[l] 与 arr[lt] 交换一下位置。
最后再递归的对 arr[l...lt-1] 和 arr[gt...r] 进行排序就能获得正确结果了。
以下图2所示
private static void quickSort3(int[] num, int length) { quickSort(num, 0, length - 1); } private static void quickSort(int[] arr, int l, int r) { if (l >= r) { return; } // 为了提升效率,减小形成快速排序的递归树不均匀的几率, // 对于一个数组,每次随机选择的数为当前 partition 操做中最小最大元素的可能性 下降 1/n! int randomNum = (int) (Math.random() * (r - l + 1) + l); swap(arr, l, randomNum); int v = arr[l]; // 三路快速排序即把数组划分为大于 小于 等于 三部分 //arr[l+1...lt] <v arr[lt+1..i) =v arr[gt...r] > v 三部分 // 定义初始值得时候依旧能够保证这初始的时候这三部分都为空 int lt = l; int gt = r + 1; int i = l + 1; while (i < gt) { if (arr[i] < v) { swap(arr, i, lt + 1); i++; lt++; } else if (arr[i] == v) { i++; } else { swap(arr, i, gt - 1); gt--; //i++ 注意这里 i 不须要加1 由于此次交换后 i 的值仍不等于 v 可能小于 v 也可能等于 v 因此交换完成后 i 的角标不变 } } //循环结束的后 lt 所处的位置为 <v 的最后一个元素 i 确定与 gt 重合 //可是 最终v 要放的位置并非 i 所指的位置 由于此时 i 为大于 v 的第一个元素 v //而 v 应该处的位置为 lt 位置 并非 i-1 所处的位置(arr[i-1] = arr[l]) swap(arr, l, lt); quickSort(arr,l,lt-1); quickSort(arr,gt,r); } 复制代码
因为咱们最常使用的是双路快排所以咱们以此来分析:咱们为了方便分析咱们假定元素不是随机选取的而是取得数组第一个元素,在选取的标准元素和 partition 获得位置交换的时候,颇有可能把前面的元素的稳定性打乱,
好比序列为 5 3 3 4 3 8 9 10 11
如今基准元素5和3(第5个元素,下标从1开始计)交换就会把元素3的稳定性打乱。因此快速排序是一个不稳定的排序算法,不稳定发生在基准元素和a[partition]交换的时刻。
对于快速排序的时间度取决于其递归的深度,若是递归深度又决定于每次关键值得取值因此在最好的状况下每次都取到数组中间值,那么此时算法时间复杂度最优为 O(nlogn)。固然最坏状况就是以前咱们分析的有序数组,那么每次都须要进行 n 次比较则 时间复杂度为 O(n²),可是在平均状况 时间复杂度为 O(nlogn),一样若想看详细的推到这里推荐一个连接 快速排序最好,最坏,平均复杂度分析
快速排序的空间复杂度主要取决于表示为选择的时候的临时空间,因此跟时间复杂度挂钩,因此平均的空间复杂度也是 O(nlogn)。
本文总结了常见的排序算法的实现,经过研究这些算法的思想,也有助于算法题的解题思路。对于这几种算法都是须要咱们熟练掌握的,可是 Android 工做平时不会接触太多的数据处理,所以咱们须要刻意的去常常复习,本文的图片大部分来自于网上,若是有问题的话能够私信我删掉。若是文章所说的内容有技术问题也欢迎联系我。