基于比较的排序算法
时间复杂度
常数时间的操作
一个操作如果和样本的数据量没有关系,每次都是固定时间内完成的操作,叫做常数操作。
时间复杂度为一个算法流程中,常数操作数量的一个指标。通常用 O(读 big O)来表示。
在表达式中,只要高阶项,不要低阶项,也不要高阶项的系数,剩下的部分如果为f(N),那么时间复杂度为0(f(N))。
评价一个算法流程的好坏,先看时间复杂度的指标,然后再分析不同数据样本下的实际运行时间,也就是“常数项时间”。
O(N²) 时间复杂度的排序
1. 选择排序
每次找到 i 往后最小的元素,跟 i 交换
// big O(N²)
public static void selectionSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
for (int i = 0; i < arr.length - 1; i++) {
int minIndex = i;
for (int j = i + 1; j < arr.length; j++) {
minIndex = arr[j] < arr[minIndex] ? j : minIndex;
}
swap(arr, i, minIndex);
}
}
2. 冒泡排序
每次让最小的浮上来
// big O(N²)
public static void bubbleSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
for (int e = arr.length - 1; e > 0; e--) { // 0 ~ e
for (int i = 0; i < e; i++) {
if (arr[i] > arr[i + 1]) {
swap(arr, i, i + 1);
}
}
}
}
3. 插入排序
类似扑克牌排序emmm
// 最好O(N),最差O(N²),所以时间复杂度为O(N²),额外空间复杂度O(1)
public static void insertionSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
for (int i = 1; i < arr.length; i++) {
for (int j = i - 1; j >= 0 && arr[j] > arr[j + 1]; j--) {
swap(arr, j, j + 1);
}
}
}
递归行为的时间复杂度
public class GetMax {
public static int getMax(int[] arr) {
if (arr == null || arr.length == 0) {
//
}
return process(arr, 0, arr.length - 1);
}
// arr[L..R]范围上求最大值
private static int process(int[] arr, int L, int R) {
if (L == R) { // arr[L..R]范围上只有一个数,直接返回
return arr[L];
}
int mid = L + ((R - L) >> 1); // 中点
int leftMax = process(arr, L, mid);
int rightMax = process(arr, mid + 1, R);
return Math.max(leftMax, rightMax);
}
}
- 上面代码的时间复杂度(L到R上N个数):
- T(N) = 2T(N/2) + O(1)
- 按照master公式:a=2, b=2, d=0 故而 时间复杂度为O(N)
- T(N) = 2T(N/2) + O(1)
- master公式只能解决子问题数据规模一样的递归
O(N*logN)时间复杂度的排序
为什么求中点不推荐写 **mid = ( L + R ) / 2 **?
- 防止溢出,如果下标非常大,有可能溢出。
- mid = L + ( R - L ) / 2
- mid = L + (( R - L ) >> 1 )
1. 归并排序
整体就是简单的递归,左边排序,右边排序,再整体排序。
- 时间复杂度O(N*logN),额外空间复杂度O(N)
public class MergeSort {
public static void mergeSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
process(arr, 0, arr.length - 1);
}
public static void process(int[] arr, int L, int R) {
if (L == R) {
return;
}
int mid = L + ((R - L) >> 1);
process(arr, L, mid);
process(arr, mid + 1, R);
merge(arr, L, mid, R);
}
public static void merge(int[] arr, int L, int M, int R) {
int[] help = new int[R - L + 1];
int i = 0;
int p1 = L;
int p2 = M + 1;
while (p1 <= M && p2 <= R) {
help[i++] = arr[p1] <= arr[p2] ? arr[p1++] : arr[p2++];
}
while (p1 <= M) {
help[i++] = arr[p1++];
}
while (p2 <= R) {
help[i++] = arr[p2++];
}
for (i = 0; i < help.length; i++) {
arr[L + i] = help[i];
}
}
}
2. 快速排序
荷兰国旗问题
- 不改进的快速排序
- 把数组范围中的最后一个数作为划分值,然后数组分成三个部分:
- 左侧 < 划分值
- 中间 == 划分值
- 右侧 > 划分值
- 划分值越靠近两侧,复杂度越高,划分值越靠近中间,复杂度越低
- 所以不改进的快速排序时间复杂度为O(N^2)
- 把数组范围中的最后一个数作为划分值,然后数组分成三个部分:
- 改进后的快速排序(随即快速排序)
- 在数组范围中,等概率随机选一个数作为划分值
- 时间复杂度是按照最差情况来计算的,但是如果引入了概率,那么最差情况将会变成概率事件。
- 每个位置被选中的概率都是均等的。 1/N
- 复杂度的长期期望,收敛于O(N*logN)
- 额外空间复杂度:最优 O(logN) , 最差O(N)
public class QuickSort {
public static void quickSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
quickSort(arr, 0, arr.length - 1);
}
// arr[L..R]排序
private static void quickSort(int[] arr, int L, int R) {
if (L < R) {
swap(arr, L + (int) (Math.random() * (R - L + 1)), R); // 取一个随机位置的数,跟最后一个位置做交换(这条语句将会改进快排变为概率事件)
int[] p = partition(arr, L, R);
quickSort(arr, L, p[0] - 1); // < 区
quickSort(arr, p[1] + 1, R); // > 区
}
}
// 这是一个处理arr[L..R]的函数
// 默认以arr[R]做划分,arr[R]->p, <p ==p >p
// 返回等于区域(左边界,右边界),所以返回一个长度为2的数组res,res[0],res[1]
public static int[] partition(int[] arr, int L, int R) {
int less = L - 1; // < 区右边界
int more = R; // > 区左边界
while (L < more) { // L 表示当前数的位置 arr[R] -> 划分值
if (arr[L] < arr[R]) {
swap(arr, ++less, L++);
} else if (arr[L] > arr[R]) {
swap(arr, --more, L);
} else {
L++;
}
}
swap(arr, more, R);
return new int[] { less + 1, more };
}
public static void swap(int[] arr, int i, int j) {
if (i == j) {
return;
}
arr[i] = arr[i] ^ arr[j];
arr[j] = arr[i] ^ arr[j];
arr[i] = arr[i] ^ arr[j];
}
}
3. 堆排序
堆
- 堆结构,就是用数组实现的完全二叉树
- 什么是完全二叉树?
- 完全二叉树:满树 或者 处在逐渐变满的路上
- 什么是完全二叉树?
- 大根堆:每颗子树的最大值在顶部
- 小根堆:每颗子树的最小值在顶部
- heapInsert 和 heapify 操作
- 对于任意一个 i :
- 其左孩子 = 2i + 1
- 其有孩子 = 2i + 2
- 父节点: ( i - 1 ) / 2
排序
- 先让整个数组都变成大根堆结构,建立堆的过程:
- 从上到下的方法,时间复杂度为O(N*logN)
- 从下到上的方法,时间复杂度为O(N)
- 把堆的最大值和堆末尾的值交换,然后减少堆的大小之后,再去调整堆,一直周而复始,时间复杂度为O(n*logN)
- 堆大小减小成0之后,排序完成
- 时间复杂度:O(N*logN) , 额外空间复杂度:O(1)
public class HeapSort {
public static void heapSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
// 数组变大根堆(1): O(N*logN)
// for (int i = 0; i < arr.length; i++) { // O(N)
// heapInsert(arr, i); // O(logN)
// }
// 数组变大根堆(2): O(N)
// T(N) = N/2 + (N/4)*2 + (N/8)*3... : 2T(N)-T(N) = T(N) = N + N/2 + N/4 ...
// 最终收敛到O(N)
for (int i = arr.length - 1; i >= 0; i--) {
heapify(arr, i, arr.length);
}
int heapSize = arr.length;
while (heapSize > 0) { // O(N)
swap(arr, 0, --heapSize); // O(logN)
heapify(arr, 0, heapSize); // O(1)
}
}
// 某个数现在处于index位置,往上继续移动
public static void heapInsert(int[] arr, int index) {
while (arr[index] > arr[(index - 1) / 2]) {
swap(arr, index, (index - 1) / 2);
index = (index - 1) / 2;
}
}
// 某个数再index位置,能否往下移动
public static void heapify(int[] arr, int index, int heapSize) {
int left = index * 2 + 1; // 左孩子的下标
while (left < heapSize) { // 下方还有孩子的时候
// 两个孩子中,谁的值大,把下标给largest
int largest = left + 1 < heapSize && arr[left + 1] > arr[left] ? left + 1 : left;
// 父节点和较大的孩子节点之间,谁的值大,就把下标给largets
largest = arr[largest] > arr[index] ? largest : index;
if (largest == index) {
break;
}
swap(arr, largest, index);
index = largest;
left = index * 2 + 1;
}
}
public static void swap(int[] arr, int i, int j) {
if (i == j) {
return;
}
arr[i] = arr[i] ^ arr[j];
arr[j] = arr[i] ^ arr[j];
arr[i] = arr[i] ^ arr[j];
}
}
-
Java 默认系统实现 — 堆 : PriorityQueue
-
为什么不能从上往下 heapify 呢?
- 例如:1 | 0 0 | 9 9 9 9 这种树。。。而从下往上就能解决这种问题
二分查找
// 时间复杂度为O(logN)
public static boolean binarySearch(int[] arr, int num) {
if (arr == null | arr.length == 0) {
return false;
}
int L = 0;
int R = arr.length - 1;
while (L < R) {
int mid = L + ((R - L) >> 1);
if (arr[mid] == num) {
return true;
} else if (arr[mid] > num) {
R = mid - 1;
} else {
L = mid + 1;
}
}
return arr[L] == num;
}
- 当然,也可以写成递归版本的。
public static boolean binarySearch(int[] arr, int num, int L, int R) {
if (arr == null || arr.length == 0 || R < L) {
return false;
}
int mid = L + ((R - L) >> 1);
if (arr[mid] == num) {
return true;
} else if (arr[mid] > num) {
return binarySearch(arr, num, L, mid - 1);
} else {
return binarySearch(arr, num, mid + 1, R);
}
}
异或(exclusive OR):无进位相加
-
0 ^ N = N
-
N ^ N = 0
练习题目
二分查找相关题目
1. 有序数组中,找大于等于某个数的最左侧的位置
- 解法:
// 在arr上,找满足>=value的最左位置
public static int nearestIndex(int[] arr, int value) {
int L = 0;
int R = arr.length - 1;
int index = -1;
while (L < R) {
int mid = L + ((R - L) >> 1);
if (arr[mid] >= num) {
index = mid;
R = mid - 1;
} else {
L = mid + 1;
}
}
return index;
}
2. 局部最小值问题
- 解法:
public static int getMin(int[] arr) {
return process(arr, 0, arr.length - 1);
}
public static int process(int[] arr, int L, int R) {
if (L == R) {
return arr[L];
}
int mid = L + ((R - L) >> 1);
int leftMin = process(arr, L, mid);
int rightMin = process(arr, mid + 1, R);
return Math.min(leftMin, rightMin);
}
异或相关题目
1. 找到奇数次的数
-
一个数组中有一种数出现了奇数次,其他数都出现了偶次数,怎么找到这个数?
-
解:
public static void printOddTimesNum1 (int[] arr){
int eO = 0; // 任何一个数,异或0,等于自己
for(int i:arr){
eO ^ i;
}
System.out.println(eO);
}
-
如果有两种数出现了奇数次,并把它们找出来?
-
解:
-
a≠ b => eor = a ^ b ≠ 0,那么 eor 一定有个位置是1。
-
假设eor第8位是1,那么整个数组可以被分为两块区域,一个是第8位是1的数,另一个是第8位是0的数。一定互斥,a 和 b必然分开。
-
这时候异或所有的第8位是1/0的数字,就会得到a或者b的其中一个
-
再用拿到的数字异或eor,就会得到另外一个(当然也可以两次异或所有,只是这个更快)
-
public static void printOddTimesNum2(int[] arr) {
int eor = 0;
for (int i : arr) {
eor ^= i;
}
// eor = a ^ b != 0,必然有一个位置上是1
int rightOne = eor & (~eor + 1); // 提取最右侧的1
int anotherOne = 0;
for (int i : arr) {
if ((i & rightOne) != 0) {
// if ((i & rightOne) == 0) {
anotherOne ^= i;
}
}
System.out.println(anotherOne + " " + (eor ^ anotherOne));
}
- 如何获得最右侧的 1 呢?
二进制 | 意义 | |
---|---|---|
a | 0 1 1 0 1 0 0 0 | |
a - 1 | 0 1 1 0 0 1 1 1 | |
a & a - 1 | 0 1 1 0 0 0 0 0 | |
a ^ ( a & a-1 ) | 0 0 0 0 1 0 0 0 | 方法1:提取最右侧的1 |
或者可以 | ||
~a | 1 0 0 1 0 1 1 1 | |
~a + 1 | 1 0 0 1 1 0 0 0 | 代表只有最右侧的1和它右边被保留了,左边全相反 |
a & ( ~a + 1 ) | 0 0 0 0 1 0 0 0 | 方法2:提取最右侧的1 |
归并排序相关题目
1. 小和问题和逆序对问题
小和问题:在一个数组中,每一个数左边比当前数小的数累加起来,叫做这个数组的小和。求一个数组的小和。
- 例子:[1,3,4,2,5]
- 1 左边比 1 小的数,没有;
- 3 左边比 3 小的数,1;
- 4 左边比 4 小的数,1、3;
- 2 左边比 2 小的数,1;
- 5 左边比 5 小的数,1、3、4、2;
- 所以小和为 1+1+3+1+1+3+4+2=16
逆序对问题:在一个数组中,左边的数如果比右边的数大,则这两个数构成一个逆序对,打印出所有逆序对。
public class SmallSum {
// 左边比当前数小的数累加,等同于(右边比当前大的个数*当前数字)的累加
public static int smallSum(int[] arr) {
if (arr == null || arr.length < 2) {
return 0;
}
return process(arr, 0, arr.length - 1);
}
// arr[L..R]既要排好序,也要求小和
public static int process(int[] arr, int l, int r) {
if (l == r) {
return 0;
}
int mid = l + ((r - l) >> 1);
return process(arr, l, mid) + process(arr, mid + 1, r) + merge(arr, l, mid, r);
}
private static int merge(int[] arr, int l, int mid, int r) {
int[] help = new int[r - l + 1];
int i = 0;
int p1 = l;
int p2 = mid + 1;
int res = 0;
while (p1 <= mid && p2 <= r) {
res += arr[p1] < arr[p2] ? (r - p2 + 1) * arr[p1] : 0;
// 排序的同时,算出右边有多少个比自己大的,求和
help[i++] = arr[p1] < arr[p2] ? arr[p1++] : arr[p2++];
}
while (p1 <= mid) {
help[i++] = arr[p1++];
}
while (p2 <= r) {
help[i++] = arr[p2++];
}
for (i = 0; i < help.length; i++) {
arr[l + i] = help[i];
}
return res;
}
}
快速排序相关题目
荷兰国旗问题
1. 问题一
给定一个数组arr,和一个数num,请把小于等于num的数放在数组的左边,大于num的数放在数组的右边。要求额外空间复杂度0(1),时间复杂度0(N)
2. 问题二
给定一个数组arr,和一个数num,请把小于num的数放在数组的左边,等于num的数放在数组的中间,大于num的数放在数组的右边。要求额外空间复杂度0(1),时间复杂度0 (N)
public class NetherlandsFlag {
// 荷兰国旗问题
public static int[] partition(int[] arr, int l, int r, int p) {
int less = l - 1; // < 区的右边界
int more = r + 1; // > 区的左边界
while (l < more) { // L 是当前数的下标
if (arr[l] < p) {
swap(arr, ++less, l++);
} else if (arr[l] > p) {
swap(arr, --more, l);
} else {
l++;
}
}
return new int[] { less + 1, more - 1 };
}
private static void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
}
总结
1. 比较器
- 比较器的实质是重载比较运算符
- 可以很好的应用在特殊标准的排序上
- 可以很好的应用在根据特殊标准排序的结构上
2. 基于比较的排序算法的总结
1. 不具备稳定性的排序:
- 选择排序
- 快速排序
- 堆排序
2. 具备稳定性的而排序:
- 冒泡排序
- 插入排序
- 归并排序
- 一切桶排序思想下的排序下(但是不是**基于比较的排序)