文章预览:
一.基础排序算法
1.冒泡排序
算法分析:
对于一个数组,每次循环让最大的元素冒泡到最后面。
规则:
1.指向数组中相邻的两个数(从最开头的两个数开始),并比较它们的大小‘
2.如果前者比后者大,互换位置;
3.如果后者比前者大,不交换;
4.然后依次后移,每次循环将最大元素后移到最后一个位置。
时间复杂度:
比较次数为(N^2 + N) / 2次,最坏的情况下,每次比较都需要交换,整体即为:(N^2 + N) / 2 + (N^2 + N) / 2 次,所以整体步数介于(N^2 + N) / 2和(N^2 + N) 之间,为O(N^2)。
代码演示
public static void bubbleSort(int[] array) {
// 1. 每次循环,都能冒泡出剩余元素中最大的元素,因此需要循环 array.length 次
for (int i = 0; i < array.length; i++) {
// 2. 每次遍历,只需要遍历 0 到 array.length - i - 1中元素,因此之后的元素都已经是最大的了
for (int j = 0; j < array.length - i - 1; j++) {
//3. 交换元素
if (array[j] > array[j + 1]) {
int temp = array[j + 1];
array[j + 1] = array[j];
array[j] = temp;
}
}
}
}
冒泡排序的优化
1.添加flag
通过置一个flag来检测是否有排序发生,若没有发生则说明数组已经排序完成,后面过程无需进行。
在for循环中设置flag=true,如发生转化,将flag置为false;在循环最后检测flag值,为false则继续循环,为true则跳出。
代码演示
public static int[] bubblingSortOptimize(int[]arrays){
if (arrays == null || arrays.length <= 0) {
return null;
}
for (int i=0;i<arrays.length-1;i++){
// 优化冒泡排序,增加判断位,有序标记,每一轮的初始是true
boolean flag = true;
for(int j=arrays.length-1;j>0;j--){
if (arrays[j]<arrays[j-1]){
int temp = arrays[j];
arrays[j] = arrays[j-1];
arrays[j-1] = temp;
flag = false;
}
}
if (flag){
break;
}
}
return arrays;
}
2.减少循环次数(后面已有序)
在flag的基础上,再增加一个参数存储最后一次发生转换的下标值,当本次循环中后面几个数字已有序不需要排序,那么就返回最后一次转换的数组下标作为下一次循环的结束点。
将循环结束点设置成变量arrBoundary,在每次交换后存储交换的下标,在下一次出现交换时更新,当本次循环结束后,返回最新的发生交换的下标作为下次循环的结束点。
代码演示
public static int [] bubblingSortDownOptimize2(int[]arrays){
if (arrays == null || arrays.length <= 0) {
return null;
}
// 最后一次交换的下标
int lastSwapIndex = 0;
// 无序数组的边界,每次比较比到这里为止
int arrBoundary = arrays.length - 1;
for (int i=0;i<arrays.length-1;i++){
// 优化冒泡排序,增加判断位,有序标记,每一轮的初始是true
boolean flag = true;
for (int j=0;j<arrBoundary;j++){
if (arrays[j]>arrays[j+1]){
int temp = arrays[j];
arrays[j] = arrays[j+1];
arrays[j+1] = temp;
flag = false;
// 最后一次交换元素的位置
lastSwapIndex = j;
}
}
// 把最后一次交换元素的位置赋值给无序数组的边界
arrBoundary = lastSwapIndex;
if (flag){
break;
}
}
return arrays;
}
2.选择排序
算法分析:
在每次遍历数组时,选择最大或最小的一个放在数组的一端。
规则:
1.利用两个变量,一个存当前最大值(从第一个开始存),一个存当前最大值的索引。
2.依次比较后面的元素,如果发现比当前最大值大,则更新最大值,并更新最大值索引。
3.直到遍历结束,将最大值放在数组的最右边,也就是交换最右边的元素和当前最大值元素。
4.重复直到数组排序完成。
时间复杂度:
比较次数一样为(N^2 + N) / 2,所以时间复杂度为O(N^2),但是冒泡排序需要频繁交换相邻两个元素,而选择排序每次遍历只需要交换一次,所以选择排序真实情况比冒泡排序快大约一倍。
代码演示
public static void selectSort(int[] arr) {
// 选择排序的时间复杂度也是O(n^2)
for (int i = 0; i < arr.length; i++) {
int maxIndex = 0;//假定最大值的下标
int max = arr[0];//假定最大值
for (int j = 1; j < arr.length-i; j++) {//循环找到最大值
if (max < arr[j]) {// 说明假定的值不是最大的
max = arr[j];// 重置max
maxIndex = j;// 重置maxIndex
}
// 选择排序的优化,如果假定的最大值就是最大值则不需要交换
}
int temp = arr[maxIndex];
arr[maxIndex] = arr[array.length - i - 1];
arr[arr.length - i - 1] = temp;
}
}
选择排序优化
同时寻找最大值和最小值,并将其放在两侧。
代码演示
public static void selectSort(int[] arr) {
// 选择排序的时间复杂度也是O(n^2)
for (int i = 0; i < arr.length/2; i++) {
int maxIndex = i;//假定最大值的下标
int max = arr[i];//假定最大值
int minIndex = arr.length-1-i;
int min = arr[minIndex];
for (int j = i; j < arr.length-i; j++) {//循环找到最大值
if (max < arr[j]) {// 说明假定的值不是最大的
max = arr[j];// 重置max
maxIndex = j;// 重置maxIndex
}
if(min > arr[j]) {
min = arr[j];
minIndex = j;
}
}
// 选择排序的优化,如果假定的最大值就是最大值则不需要交换
int temp1 = arr[maxIndex];
int temp2 = arr[minIndex];
arr[maxIndex] = arr[array.length - i - 1];
arr[arr.length - i - 1] = temp1;
arr[minIndex] = arr[i];
arr[i]= temp2;
}
3.插入排序
算法分析:
每次遍历时抽离出一个元素当作临时元素,依次比较和移动之后的其他元素,最终将这个元素插入对应位置。
规则:
1.在第一轮,抽取倒数第二个元素作为临时元素。
2.用临时元素跟后面元素作比较,如果后面元素值小于临时元素,则后面的元素左移。
3.如果后面的元素大于临时元素,或者已经移动到数组末尾,则将当前临时元素插入当前空隙中。
4.重复上面步骤,直到遍历到第一个数组元素,完成排序。
时间复杂度:
如果数组全部按照降序排序,,那么每次迭代比较都需要移动,比较步数为O((N^2 - N) / 2)(从倒数第二个开始),移动步数为O((N^2 - N) / 2),所以最差时间复杂度为O(N^2)。
代码演示
//使用while循环完成
public static void insertSort(int[] array) {
// 从倒数第二位开始,遍历到底0位,遍历 N-1 次
for (int i = array.length - 2; i >= 0; i--) {
// 存储当前抽离的元素
int temp = array[i];
// 从抽离元素的右侧开始遍历
int j = i + 1;
while (j <= array.length - 1) {
// 如果某个元素,小于临时元素,则这个元素左移
if (array[j] < temp) {
array[j - 1] = array[j];
} else {
// 如果大于,则直接将临时元素插入,然后退出循环。
array[j - 1] = temp;
break;
}
j++;
}
// 处理到达尾部的情况
if (j == array.length) {
array[j - 1] = temp;
}
}
}
//使用双层for循环完成
public static void insertSort(int[] array) {
// 从倒数第二位开始,遍历到底0位,遍历 N-1 次
for(int i = array.length-2;i>=0;i--){
// 存储当前抽离的元素
int flag = array[i];
for(int j = i;j < array.length;j++){
//到达尾部的处理方式
if(j+1>=array.length){
array[j] = flag;
break;
}
//从抽离元素右侧开始比较
if(flag>array[j+1]){
// 如果某个元素,小于临时元素,则这个元素左移
array[j]=array[j+1];
}else {
// 如果大于,则直接将临时元素插入,然后退出循环。
array[j] = flag;
break;
}
}
}
}
插入排序的进阶 - 二分插入排序
查找的核心逻辑是寻找到一个合适的下标将我们的目标插入,因此我们可以用二分法查找在已排好序的数组中快速定位到我们要插入的位置。
二分法原理:将目标与数组中间值比较,如果目标小于中间值则将索引定位到中间值左边;如果目标大于中间值则将索引定位到中间值右边。
代码演示
// 查找应该插入的索引位置
public static int searchIndex(int[] array, int left, int right, int aim) {
// 循环查找节点位置
while (left < right) {
int middle = (left + right) / 2;
int value = array[middle];
if (value < aim) {
left = middle + 1;
} else {
right = middle - 1;
}
}
// #1. 如果最终元素仍然大于目标元素,则将索引位置往左边移动一个
if(array[left] > aim){
return left -1;
}
// 否则就是当前位置
return left;
}
#1.示例分析
如果我们要查找的数组为25 28 36 54 77,插入数字为38,我们使用二分法查找到的数组下标便是54的下标,这很显然不是我们要找的位置,因此此时我们应该将数组索引减1。
我们通过二分法,将要插入的数值定位目标,查找到其应插入的位置,将前面所有数组前移并将数组插入。
代码演示
// 插入排序
public static void insertSort(int[] array) {
// 从倒数第二位开始,遍历到底0位,遍历 N-1 次
for (int i = array.length - 2; i >= 0; i--) {
// 存储当前抽离的元素
int temp = array[i];
int index = searchIndex(array, i + 1, array.length - 1, temp);
// #1. 根据插入的索引位置,进行数组的移动和插入
int j = i + 1;
while (j <= index) {
array[j - 1] = array[j];
j++;
}
array[j - 1] = temp;
}
}
#1.示例分析
一定要先移动再插入,否则将会改变数组内容。
注:冒泡排序vs选择排序vs插入排序
冒泡排序相对而言最差,因为要经过多次循环。选择排序和插入排序分情况而定:如果原始数据本身有很多元素按希望的顺序排好序了,我们就用插入排序(如上面最好的情况,我们无需进行交换,时间复杂度接近O(N)),否则使用选择排序。
二.递归排序算法
1.分而治之的思想——归并排序
分而治之思想:
分而治之的思想是采用了递归思想,将原问题分解成几个规模较小但是类似于原问题的子问题,通过递归的方式来解决这些小问题,最后将子问题的解合并来得到原问题的解。
归并排序算法:
算法分析:
将大数组分解成小数组,将每个小数组排好序,再将这些有序的小数组合并成大数组。
规则:
1.分。拆分原数组,直到每个数组里只剩一个元素。
2.治。将拆分的数组进行合并,并且保证合并的数组是有序的。
时间复杂度:
在排序方法中,左右两边数组均会遍历一次,因此时间复杂度为O(N)。执行拆分跟合并的时间复杂度跟二分法类似,为O(log(N))。因为每次合并时我们都要进行排序,因此最后的时间复杂度为O(Nlog(N))。归并排序算法速度是稳定的,并没有最好最差之说。
代码实现 - 分
我们通过两个函数来实现分。mergeSort函数划分数组并返回,同时调用subArray函数将分好的数组拷贝下来储存。
// 归并排序,返回排好序的数组
public static int[] mergeSort(int[] array) {
}
// 拷贝原数组的部分内容,从 left 到 right
public static int[] subArray(int[] source, int left, int right) {
}
我们首先完成拷贝数组。
定义数组要拷贝的范围left和right,通过遍历数组将内容存入新数组并返回。
// 拷贝原数组的部分内容,从 left 到 right
public static int[] subArray(int[] source, int left, int right) {
// 创建一个新数组
int[] result = new int[right - left];
// 依次赋值进去
for (int i = left; i < right; i++) {
result[i - left] = source[i];
}
return result;
}
然后完成数组划分。
首先寻找递归结束条件,即基准条件:数组元素只有一个,直接返回。
// 归并排序,返回排好序的数组 public static int[] mergeSort(int[] array) { if(array.length == 1){ return array; } }
在数组元素不止一个的情况下,我们要进行拆分,分别调用mergeSort。
int middle = array.length / 2; // #1. 处理 0 到 middle 左侧数组部分 int[] left = mergeSort(subArray(array, 0, middle)); // #2. 处理 middle 到 array.length 右侧数组部分 int[] right = mergeSort(subArray(array, middle, array.length));
当数组递归到晋升一个数组元素时,便会返回此元素,在第二层中,我们便分别得到了这个数组的左,右元素。同时我们通过一个方法将该数组的左,右元素进行排序合并并返回,在下一层得到的便是已经排好序的左,右元素,如此递归直到最后一层,我们通过此方法将左右数组排序合并,便完成了数组排序。而如何实现左右元素排序合并,便要用到治的思想。
分 - 代码演示
// 归并排序,返回排好序的数组
public static int[] mergeSort(int[] array) {
// 为了方便查看结果,我们将每个数组进行打印
System.out.println(Arrays.toString(array));
if (array.length == 1) {
return array;
}
int middle = array.length / 2;
// #1. 处理 0 到 middle 左侧数组部分
int[] left = mergeSort(subArray(array, 0, middle));
// #2. 处理 middle 到 array.length 右侧数组部分
int[] right = mergeSort(subArray(array, middle, array.length));
// TODO处理合并问题
return array;
}
代码实现 - 治
当我们完成分以后,实际上我们返回的数组都已经是有序的了,此时我们需要完成的只是两个数组的有序合并。创建merge方法实现两个数组的合并,并在margeSort方法中调入,参数分别为左右数组。
public static int[] merge(int[] left, int[] right) {
}
我们应当如何完成数组合并?
很简单,比如我们有如下两个数组:int[] array1 = [3,5,7],int[] array2 = [2,4,6]
我们要将它们添加到新数组mergeArray中(思考数组长度应当如何设置),可以添加两个指针分别遍历array1,array2,同时遍历mergeArray开始储存,并将两指针内容进行比较。若array1>array2,将array1存入mergeArray数组并使array1数组指针后移;若array1<array2,将array2存入mergeArray数组并使array2数组指针后移。当一方数组遍历完毕后,我们直接将剩余数组内容按顺序存入mergeArray。遍历完成后将mergeArray数组返回。
治 - 代码演示
public static int[] merge(int[] left, int[] right) {
//设置合并数组,长度为左右数组之和
int[] result = new int[left.length + right.length];
//设置三个指针分别遍历三个数组
int i = 0, j = 0, k = 0;
//当两数组均未遍历完成时,将遍历到的数组内容进行比较
while (i < left.length && j < right.length) {
//若left<right,将left治存入数组当前位置并执行i++,k++
if (left[i] < right[j]) {
result[k++] = left[i++];
}
//若left>right,将right治存入数组当前位置并执行j++,k++
else {
result[k++] = right[j++];
}
}
//当一方数组遍历完毕后,直接将剩余数组内容按顺序存入
while (i < left.length) {
result[k++] = left[i++];
}
while (j < right.length) {
result[k++] = right[j++];
}
return result;
}
分而治之的思想——快速排序
快速排序是现在编程语言自带排序函数中,使用最多的算法,例如我们常见的List.sort()。
归并排序会创建新的数组,所以空间复杂度会偏大。在平时所见的平均情况下,快速排序确实在性能上表现优异。
算法分析:
选择一个数组元素当轴(如数组最后一个元素),前后遍历数组元素,比轴小的放左边,比轴大的放右边,最后将轴与中间元素交换。
规则:
1.选取最后一个元素为轴,设置两个指针分别指向索引0和索引array.length。
2.左指针依次向右移动,当遇到大于或等于轴的值时,则停止;
3.右指针依次向左移动,当遇到小于或等于轴的值时,则停止。
4.将两个指针指向的值互换。
5.互换以后,按照2-4步骤重复执行,直到左右指针重叠。
6.将指针指向的值与轴互换。
7.左右两边从新调用此方法进行排序。
时间复杂度:
和归并排序一样,每次区分需要全数组比较一次所以为O(N),分区方法类似于二分法,时间复杂度为O(log(N)),所以最终结果为O(Nlog(N))
但是他有一种最坏的情况,就是数组本身是有序的,那么我们每次分组时轴永远是最大值,相当于没有分组而是单纯的将数组遍历了一遍,此时时间复杂度为O(N^2)。
代码实现
首先寻找基准条件。
**基准条件:**当数组里的元素小于等于一个时结束递归。
接下来分析递归思路:
每当一次区分后,获取轴的位置,拆分左右数组继续快速排序
// #1. 快速排序入口 public static void quickSort(int[] array) { // 调用快速排序的核心,传入left,right quickSortCore(array, 0, array.length - 1); } // 快速排序的核心,同样也是递归函数 public static void quickSortCore(int[] array, int left, int right) { // 递归基准条件,left >= right 即表示数组只有1个或者0个元素。 if (left >= right) { return; } // 根据轴分区 int pivotIndex = partition(array, left, right); // 递归调用左侧和右侧数组分区 quickSortCore(array, left, pivotIndex - 1); quickSortCore(array, pivotIndex + 1, right); } // 对数组进行分区,并返回当前轴所在的位置 public static int partition(int[] array, int left, int right) { }
我们需要实现的便是如何进行快速排序。
#1.示例分析
为什么有了快排序方法还要定义quilkSort方法?
这是为了方便外部调用,当外部调用排序只需要传入数组就行了,这是接口友好的一种设计表现。
代码演示
// 快速排序
public static void quickSort(int[] array) {
// 调用快速排序的核心,传入left,right
quickSortCore(array, 0, array.length - 1);
}
// 快速排序的核心,同样也是递归函数
public static void quickSortCore(int[] array, int left, int right) {
// 递归基准条件,left >= right 即表示数组只有1个或者0个元素。
if (left >= right) {
return;
}
// 根据轴分区
int pivotIndex = partition(array, left, right);
// 递归调用左侧和右侧数组分区
quickSortCore(array, left, pivotIndex - 1);
quickSortCore(array, pivotIndex + 1, right);
}
// 对数组进行分区,并返回当前轴所在的位置
public static int partition(int[] array, int left, int right) {
// 选择最右侧的元素作为轴
int pivot = array[right];
// 定义左指针和右指针 3 2 9 7 7
int i = left;
int j = right - 1;
// 左指针和右指针向中间遍历
while (i <= j) {
while (i <= j && array[i] < pivot) {
i++;
}
while (i <= j && array[j] > pivot) {
j--;
}
if (i <= j) {
swap(array, i, j);
i++;
j--;
}
}
// 最后将轴元素放到正确的位置上
swap(array, i, right);
// 返回轴的位置
return i;
}
// 交换函数
public static void swap(int[] array, int i, int j) {
int temp = array[i];
array[i] = array[j];
array[j] = temp;
}
快速排序的应用 - 快速选择
案例分析:
一个班30人,假如班主任想要找到班级前20%的人的成绩范围,应该怎么寻找?
寻找前20%成绩范围,实际上相当于找到第20*0.2=6
名同学的成绩,如果我们此时使用快速排序,并找到排名第六的同学的成绩,此时时间复杂度为O(Nlog(N)),那么在此情况下如何进行优化?
我们先假设,我们在一个包含数字1~10的数组中寻找元素7。我名将数组最后一个元素设为轴,假设他为6。当我们完成第一次排序的时候,7一定在他的右边,此时我们便舍弃左边的数组;当完成第二次排序后,不管选择任何轴一定都不会小于7,那么7一定在他的左边,此时我们便舍弃右边的数组…以此类推,我们可以减少大量任务,最后只返回我们需要的元素7,这便是快速选择算法。
时间复杂度:
在快速选择中,因为我们每次都舍弃了一半的数组,所以时间复杂度为:
f(n)=n + n/2 + … + 1 = 2n - 1/2(n-1),时间复杂度为O(2N)。
去掉常数后时间复杂度为O(N)。
代码实现
首先分析基准条件。
基准条件:当找到目标位置后,递归结束。
接下来分析递归思路:
设置一个目标aim跟轴的值作比较,如果轴小于目标值,只返回右数组;如果轴大于目标值,则返回左数组。
代码演示
// 快速选择,返回选中的元素
public static int quickFind(int[] array, int aim) {
return quickFind(array, 0, array.length - 1, aim);
}
private static int quickFind(int[] array, int left, int right, int aim) {
if (left >= right) {
return array[left];
}
int pivotIndex = partition(array, left, right);
if (pivotIndex == aim) {
return array[pivotIndex];
} else if (pivotIndex < aim) {
return quickFind(array, pivotIndex + 1, right, aim);
} else {
return quickFind(array, left, pivotIndex - 1, aim);
}
}
// 进行分区操作
private static int partition(int[] array, int left, int right) {
int pivot = array[left];
int l = left + 1;
int r = right;
while (l <= r) {//3 9 2 6 7
if (array[l] > pivot && array[r] < pivot) {
swap(array, l, r);
l++;
r--;
}
if (array[l] <= pivot) {
l++;
}
if (array[r] >= pivot) {
r--;
}
}
swap(array, left, r);
return r;
}
// 交换数组中两个元素的位置
private static void swap(int[] array, int i, int j) {
int temp = array[i];
array[i] = array[j];
array[j] = temp;
}