目录
1 认识时间复杂度
1.1 常数时间的操作
一个操作如果和样本的数据量没有关系,每次都是固定时间内完成的操作,叫做常数操作。
时间复杂度为一个算法流程中,常数操作数量的一个指标。常用O(读作big O)来表示。具体来说,先要对一个算法流程非常熟悉,然后在去写出这个算法流程中,发生了多少的常数操作,进而总结出常数操作数量的表达式。
在表达式中,只要高阶项,不要低阶项,也不要高阶项的系数,剩下的部分如果为f(N),那么时间复杂度为O(f(N))。
评价一个算法流程的好坏,先看时间复杂度的指标,然后再分析不同数据样本下的实际运行时间,也就是“常数项时间”。(进行常数操作的时间可能是不一样的)
例如在下面的这段代码中process1和process2这两个方法的时间复杂度都是O(N),然后比较常数项,最后比较实际的运行时间,才能知道哪个算法更优。
public class Test {
public static void process1() {
int N = 1000;
int a = 0;
for (int i = 0; i < N; i++) {
a = 2 + 5;
a = 4 * 7;
a = 6 * 8;
}
}
public static void process2() {
int N = 1000;
int a = 0;
for (int i = 0; i < N; i++) {
a = 3 | 6;
a = 3 & 4;
a = 4 | 785;
}
}
public static void main(String[] args) {
process1();
process2();
}
}
1.2 额外空间复杂度
只需要有限的变量就可以完成算法流程的话,额外空间复杂度就是O(1)
如果必须开辟一个额外的数组,这数组还和原来的数组等规模,额外空间复杂度就是O(N)
2 简单排序算法
2.1 选择排序
- 看(从剩下的数中找出最小的数)的次数:N + N - 1 + N - 2 + … + 1
- 比较(每次看的数,和既定数作比较):N + N - 1 + N - 2 + … + 1
- 交换swap: N 次
不难看出前两次操作的次数的和可以用等差数列求和来求。
等差数列求和公式 : 或
所以进行的总操作数可以粗略的写为: 。
所以说,选择排序是一个时间复杂度为 O() 的算法。
代码实现如下:
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);
}
}
public static void swap(int[] arr, int i, int j) {
int tmp = arr[i];
arr[i] = arr[j];
arr[j] = tmp;
}
2.2 冒泡排序
- 比较两个相邻的元素,如果第一个数比第二个数大,就交换他们两个。
- 从开始到结尾的每一对,对每一对相邻元素做相同的操作。因此,最后的元素会是最大的数。
- 针对所有的元素重复以上的步骤,除了上次操作的最后一个数。(因为最后一个已经排好,是最大的数)
不难看出最后进行的总操作数仍然为一个等差数列求和,故冒泡排序也是一个时间复杂度为O() 的算法。
代码实现:
public static void bubbleSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
for (int e = arr.length - 1; e > 0; e--) { // 0 ~ N-1上做一轮;下回就是 0 ~ N - 2
for (int i = 0; i < e; i++) {
if (arr[i] > arr[i + 1]) {
swap(arr, i, i + 1);
}
}
}
}
// 交换arr的i和j位置上的值,前提i ≠ j
public static void swap(int[] arr, int i, int j) {
arr[i] = arr[i] ^ arr[j];
arr[j] = arr[i] ^ arr[j];
arr[i] = arr[i] ^ arr[j];
}
补充:异或运算
相同为0,不同为1。也可以理解为无进位相加。eg:10010+01110=11100
性质:
- 0 ^ N = N 、N ^ N = 0
- 满足交换律和结合律
根据上面这两条性质可以推出:a ^ b ^ c ^ d ^ e ^ f .... m ^ n 它的运算结果跟abcdef进行异或运算的顺序无关。
利于这些性质可以使用异或操作来进行数值交换,例如交换a和b的值,可用下面代码实现
int a = c; //c代表一个数
int b = d; //d代表一个数
a = a ^ b; //这行代码跑完,a = c ^ d, b = d
b = a ^ b; //这行代码跑完,a = c ^ d, b = c ^ d ^ d = c
a = a ^ b; //这行代码跑完,a = c ^ d ^ c = d, b = c 交换完成
使用这种方式交换数值可以不用额外的变量,即不用申请额外的空间。
但是注意能够使用这个方法的前提是,要交换数值的两个变量在内存中是两个独立的区域,即a和b 指向内存中不同的空间,否则结果一直为0。(a和b的数值可以相同,数值存放的内存空间不能相同)
尽量别使用这个方法
异或运算应用
面试题:在一个整型数组中,已知只有一种数出现了奇数次,其他所有数都出现了偶数次,怎么找出出现奇数次的数?如果有两种数出现了奇数次,其他所有数都出现了偶数次,怎么找出这两种奇数次的数?要求时间复杂度为O(N),额外空间复杂度为O (1)。
解:①
public static void printOddTimesNum1(int[] arr) {
int eor = 0;
for (int cur : arr) {
eor ^= cur;
}
System.out.println(eor);
}
因为异或运算和运算顺序无关,得到的结果为一个。所以可以先让出现偶数次的数先异或结果为0,剩下奇数次的数再异或得到它自己。
②
public static void printOddTimesNum2(int[] arr) {
int eor = 0;
// 得到eor = a ^ b
for (int i = 0; i < arr.length; i++) {
eor ^= arr[i];
}
// eor 必有一个位置上是1
// 提取出eor最右侧的1(位运算的常见操作)& 两个位都为1时,结果才为1,~ 0变1,1变0
int rightOne = eor & (~eor + 1);
int onlyOne = 0; //eor'
for (int cur : arr) { //cur为arr中的一个数
if ((cur & rightOne) == 1) { //cur中与eor最右侧的1位数相同的位也为1
onlyOne ^= cur;
}
}
System.out.println(onlyOne + " " + (eor ^ onlyOne));
}
假设a和b这两个数是出现奇数次的两个数
同样设变量 eor,在数组arr 中从头到尾进行异或,最后 eor = a ^ b,且 已知a ≠ b,所以 eor ≠ 0
那么a 和 b 的某一位上一定不一样(不然 a ^ b 就等于0 了)
即 eor 一定有一位是1
假设eor 的第n位为1,(int整数32位),根据这个第n位把数组中的数进行分类
- 第一类为第n位 = 1的数
- 第二类为第n位 = 0的数
所以,a和b一定分别属于两边。现在再来一个变量eor',eor' 只去异或第n位上为1的那些数,这样 eor’ 到最后一定是a 或者 b
即eor’ = a or b
现在另一个数只需要用 eor ^ eor'即可求出
位运算比算术运算快多了
2.3 插入排序
插入排序算法的一般步骤:
- 从第一个元素开始,该元素可以认为已被排序;
- 取出下一个元素,在已经排序的元素序列中从后向前扫描;
- 如果该元素(已排序)大于新元素,将该元素移到下一个位置;
- 重复步骤3,直到找到已排序的元素小于或者等于新元素的位置;
- 将新元素插入到该位置后,重复2~5
插入排序与数据状况有关系,影响排序流程进行。
所以最差时间复杂度:O(n^2),最优时间复杂度:O(n),平均时间复杂度:O(n^2)
代码实现如下 :
public static void insertionSort(int[] arr) {
if (arr == null || arr.length < 2) {
return;
}
//0 ~ 0 有序的
//0 ~ i 想有序
for (int i = 1; i < arr.length; i++) { // 从索引为1 开始看,0上的数不用看
for (int j = i - 1; j >= 0 && arr[j] > arr[j + 1]; j--) { // 不越界为前提
swap(arr, j, j + 1);
}
}
}
// 交换arr的i和j位置上的值,前提i ≠ j
public static void swap(int[] arr, int i, int j) {
arr[i] = arr[i] ^ arr[j];
arr[j] = arr[i] ^ arr[j];
arr[i] = arr[i] ^ arr[j];
}
3 二分法的详解与扩展
3.1在一个有序数组中,找某个数是否存在
基本思想是:目标值通过与中间元素比较,可分为三种情况。第一种情况:目标值与中间元素相等,查找结束;第二种情况:目标值比中间元素大,则把后半部分的中间元素与目标值比较;第二种情况:目标值比中间元素小,则把前半部分的中间元素与目标值比较;这三步一直循环,直到查找结束。
所以时间复杂度为O( )
代码实现如下:
public static boolean exist(int[] sortedArr, int num) {
if (sortedArr == null || sortedArr.length == 0) {
return false;
}
int L = 0;
int R = sortedArr.length - 1;
int mid = 0;
while (L < R) {
mid = L + ((R - L) >> 1);
if (sortedArr[mid] == num) {
return true;
} else if (sortedArr[mid] > num) {
R = mid - 1;
} else {
L = mid + 1;
}
}
return sortedArr[L] == num;
}
3.2在一个有序数组中,找>=某个数最左侧的位置
时间复杂度也为O( )
与3.1的区别在于3.1找到数就可以返回,3.2是二分到一个范围上没有数才返回。
代码实现如下:
// 在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] >= value) {
index = mid;
R = mid - 1;
} else {
L = mid + 1;
}
}
return index;
}
3.3 局部最小值问题
局部最小:对于0 位置和 1位置上的数,如果 0位置上的数 < 1位置上的数 那么0位置上的数就是局部最小,N-1位置上的数 和 N位置上的数,如果N-1 位置上的数> N位置上的数那么N 位置上的数的就是局部最小,对于 i-1、i、 i+1 三个位置上的数,有i - 1位置上的数 > i位置上的数 且 i位置上的数 < i + 1 位置上的数),那么 i 位置上的数就是局部最小。
arr 数组无序,任何两个相邻的数一定不相等,求其局部最小值。(不一定有序才能二分)
要求:时间复杂度好于 O(N)
代码实现如下:
public static int getLessIndex(int[] arr) {
if (arr == null || arr.length == 0) {
return -1; // no exist
}
if (arr.length == 1 || arr[0] < arr[1]) {
return 0;
}
if (arr[arr.length - 1] < arr[arr.length - 2]) {
return arr.length - 1;
}
int left = 1;
int right = arr.length - 2;
int mid = 0;
while (left < right) {
mid = (left + right) / 2;
if (arr[mid] > arr[mid - 1]) {
right = mid - 1;
} else if (arr[mid] > arr[mid + 1]) {
left = mid + 1;
} else {
return mid;
}
}
return left;
}
4 对数器的概念和使用
- 有一个你想要测的方法a
- 实现复杂度不好但是容易实现的方法b
- 实现一个随机样本产生器
- 把方法a和方法b跑相同的随机样本,看看得到的结果是否一样。
- 如果有一个随机样本使得比对结果不一致,打印样本进行人工干预,改对方法a或者方法b
- 当样本数量很多时比对测试依然正确,可以确定方法a已经正确。
比如我们之前写的选择排序 ,自己实现的方法作为方法a,也是我们想测的方法
方法b:系统函数排序
// for test
public static void comparator(int[] arr) {
Arrays.sort(arr);
}
生成随机数组
public static int[] generateRandomArray(int maxSize, int maxValue) {
int[] arr = new int[(int) ((maxSize + 1) * Math.random())];
for (int i = 0; i < arr.length; i++) {
arr[i] = (int) ((maxValue + 1) * Math.random()) - (int) (maxValue * Math.random());
}
return arr;
}
完整代码
// for test
public static void comparator(int[] arr) {
Arrays.sort(arr);
}
// for test
public static int[] generateRandomArray(int maxSize, int maxValue) {
int[] arr = new int[(int) ((maxSize + 1) * Math.random())];
for (int i = 0; i < arr.length; i++) {
arr[i] = (int) ((maxValue + 1) * Math.random()) - (int) (maxValue * Math.random());
}
return arr;
}
// for test
public static int[] copyArray(int[] arr) {
if (arr == null) {
return null;
}
int[] res = new int[arr.length];
for (int i = 0; i < arr.length; i++) {
res[i] = arr[i];
}
return res;
}
// for test
public static boolean isEqual(int[] arr1, int[] arr2) {
if ((arr1 == null && arr2 != null) || (arr1 != null && arr2 == null)) {
return false;
}
if (arr1 == null && arr2 == null) {
return true;
}
if (arr1.length != arr2.length) {
return false;
}
for (int i = 0; i < arr1.length; i++) {
if (arr1[i] != arr2[i]) {
return false;
}
}
return true;
}
// for test
public static void printArray(int[] arr) {
if (arr == null) {
return;
}
for (int i = 0; i < arr.length; i++) {
System.out.print(arr[i] + " ");
}
System.out.println();
}
// for test
public static void main(String[] args) {
int testTime = 500000;
int maxSize = 100;
int maxValue = 100;
boolean succeed = true;
for (int i = 0; i < testTime; i++) {
int[] arr1 = generateRandomArray(maxSize, maxValue);
int[] arr2 = copyArray(arr1);
selectionSort(arr1);
comparator(arr2);
if (!isEqual(arr1, arr2)) {
succeed = false;
printArray(arr1);
printArray(arr2);
break;
}
}
System.out.println(succeed ? "Nice!" : "Fucking fucked!");
int[] arr = generateRandomArray(maxSize, maxValue);
printArray(arr);
selectionSort(arr);
printArray(arr);
}
5 剖析递归行为和递归行为时间复杂度的估算
用递归方法找一个数组中的最大值,系统上到底是怎么做的?
master公式的使用
T(N) = a*T(N/b) + O()
-
T(N):母问题数据量为N
-
T(N/b):调用的子过程每次数据量等量,都是 N/b(子问题规模)
-
a:子问题等量情况下,被调了多少次
-
O():除去调用子过程,剩下操作的时间复杂度
1) < d =>时间复杂度为O( )
2) > d =>时间复杂度为O( )
3) = d =>时间复杂度为O( )
代码实现如下:
public static int getMax(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 leftMax = process(arr, L, mid);
int rightMax = process(arr, mid + 1, R);
return Math.max(leftMax, rightMax);
}
根据master公式不难算出上述代码的时间复杂度为O(N)