认识时间复杂度
一、常数时间的操作
一个操作如果和样本的数量没有关系,每次都是固定使劲按内完成的操作,叫做常数操作。
时间复杂度为一个算法流程中,常数操作数量的一个指标。常用0(读作big 0)来表示。具体来说,先要对一个算法流程非常熟悉,然后去写出这个算法流程,发生了多少常数操作,进而总结出常数操作数量的表达式。
在表达式中,只要高阶项,不要低阶项,也不要高阶项的系数,剩下的部分如果为f(N),那么时间复杂度为O(f(N))。
当两个算法所用到的时间复杂度的最高阶相同,通过常数项来比较算法优劣,这就需要通过实际的代码运行时间来检测。评价一个算法流程的好坏,先看时间复杂度的指标,然后再分析不同数据样本下的实际运行时间们也就是“常数项时间”。
//查找数组中第i个元素,这就是一个常数操作
int a = arr[i];
//查找链表中的第i个节点,这就不是一个常数操作
int b = list.get(i);
二、选择排序
public static void selectionSort(int[] arr) {
if(arr == null || arr.length < 2){
return;
}
for(int i = 0; i < arr.length-1; i++) { //i~N-1次查找最小的数放在数组头部
int minIndex = i;
for(int j = i+1; j<arr.length; j++) { //i~N-1找最小值下标
minIndex = arr[j] < arr[minIndex] ? j : minIndex;
}
swap(arr, i, minIndex);
}
}
选择排序分为N轮,每一轮找到数组中最小的数放在数组首部,时间复杂度为O(N^2)
。
在选择排序的过程中,开辟的变量空间只有i,j,minIndex
三个,而minIndex在每一轮排序完成后都会释放空间,因此用到的辅助空间为常数,空间复杂度为O(1)
。
三、冒泡排序
public static void bubbleSort(int[] arr) {
if(arr == null || arr.length < 2) {
returns;
}
for(int i = arr.length-1; i > 0; i--) {
for(int j = 0; j < i; j++) {
if(arr[j]>arr[j+1]) {
swap(arr, j, j+1);
}
}
}
}
在冒泡排序的N轮中,每一轮都是前一个数与后一个数比较大小,大的放在后边,直到本轮所有数排列完毕,这样就可以确定最后一个数是最大的,如此重复N轮,时间复杂度为O(N)
。
在冒泡排序的过程中,开辟的辅助空间只有i,j
,因此空间复杂度为O(1)
。
一个简单的冒泡排序到底坑死了多少人
四、异或
异或运算相信大家都知道是相同为0、不同为1,我们也可以将其理解为无进位相加,下面介绍异或的几个性质:
- 0 ^ N = N
- N ^ N = 0
- a ^ b = b ^ a
- ( a ^ b ) ^ c = a ^ ( b ^ c )
- 同样一组数异或成为一个数的结果与它们异或的顺序无关
//交换arr的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];
}
对上面的代码进行解释:
假设定义两个变量A
的值为甲,B
的值为乙
- 第一行代码执行完后
A
= 甲 ^ 乙,B
= 乙 - 第二行代码执行完后
A
= 甲 ^ 乙,B
= 甲 ^ 乙 ^ 乙 = 甲(乙 ^ 乙 = 0,甲 ^ 0 = 甲) - 第三行代码执行完后
A
= 甲 ^ 乙 ^ 甲 = 乙(甲 ^ 甲 = 0,乙 ^ 0 = 乙),B
= 甲
至此两个变量的值交换。此方法有一个前提:必须保证两个数在内存中占用的是不同的空间。
假设在数组中运用此方法对两个数进行交换,一定要保证这两个数的值不同。
我们再来看一道题:
假设有一个整形数组 int arr[]
,要求在时间复杂度为O(n)
,空间复杂度为O(1)
的情况下完成:
1) 数组中有一个数出现了奇数次,其他数都出现了偶数次,求这个数
首先准备一个变量eor
,将这个变量与数组中所有数进行异或操作,最后的结果就是这个出现奇数次的数。因为从之前异或运算的性质来看,运算的顺序对于结果没有影响,因此如果将所有数排序进行运算,那么出现偶数次的数运算之后的结果为0,出现奇数次异或运算后的结果还将是其本身。
public static void printOddTimesNum1(int[] arr) {
int eor = 0;
for (int cur : arr) {
eor ^= cur;
}
System.out.println(eor);
}
2)数组中有两个数出现了奇数次,其他数都出现了偶数次,求这两个数
public static void printOddTimesNum2(int[] arr) {
int eor = 0;
for(int i = 0; i < arr.length; i++) {
eor ^= arr[i];
}
//eor = a ^ b
//eor != 0
//eor必然有一个位置上是1
int rightOne = eor & (~eor + 1); //提取出最右的1
int onlyOne = 0;
for(int cur : arr) {
if((cur & rightOne) == 0) {
onlyOne ^= cur;
}
}
System.out.println(onlyOne + "" + (eor ^ onlyOne));
}
奶奶嘀,不解释了,想知道咋弄的吗?不告诉你,虽然我也是抄的。
如果数组中有两种出现了奇数次的数,剩下的都是偶数次,同样是设置一个变量将所有数异或,假设这两个数是a
和b
,异或的结果就是a^b
。因为是两种数,所以这个结果不可能是0,再假设这个结果的第7位是1,就可以将数组中所有数分为两类:一类是第7位为1的数,另一类是第7位为0的数。a
和b
两个数一定在不同的分类,这时设置另外一个变量与第7位是1的数进行异或,得到的结果就是a
和b
其中一个数,最后用这个结果与eor
异或就可以得到另外一个数。
五、插入排序
插入排序的基本思想是:将整个数组a分为有序和无序的两个部分。左边是有序的,右边是无序的。开始有序的部分只有a[0] , 其余都属于无序的部分。每次取出无序部分的第一个(最左边)元素,把它加入有序部分。假设插入合适的位置p,则原p位置及其后面的有序部分元素都向右移动一个位置,有序的部分即增加了一个元素。一直做下去,直到无序的部分没有元素。
public static void insertionSort(int[] arr) {
if (arr == null || arr.length < 2)
returns;
for( int i = 1; i < arr.length; i++) { //想要的是0~i位置上的有序
for(int j = i - 1; j >= 0 && arr[j] > arr[j+1]; j--) { //从后往前看,如果无序就交换,直到越界或有序
swap(arr, j, j + 1);
}
}
}
按照最差情况来估计时间复杂度,时间复杂度位O(n^2)
,空间复杂度位O(1)
。
对数器
对数器的概念和使用
- 有一个你想要测的方法A
- 实现复杂度不好但是容易实现的方法B
- 实现一个随机样本发生器
- 把方法A和方法B跑相同的随机样本,看看得到的结果是否一样
- 如果有一个随机样本使得对比结果不一致,打印样本进行人工干预,改方法A或者方法B
- 当样本数量很多时对比测试依然正确,可以确定方法A已经正确
我们将之前提到的插入排序当作方法A,系统提供的排序方法作方法B,让它们两个方法作对数器。
//方法B
public static void comparator(int[] arr) {
Arrays.sort(arr);
}
测试过程
public static void main(String[] args) {
int testTime = 500000; //测试次数
int maxSize = 100; //数组长度(随机)
int maxValue = 100; //数组元素值(随机)
boolean succeed = true;
for(int i = 0; i < testTimes; i++) {
int[] arr1 = generateRandomArray(maxSize,maxValue);
int[] arr2 = copyArray(arr1);
insertionSort(arr2);
comparator(arr2);
if(!isEqual(arr1, arrr2)) {
succeed = false;
berak;
}
}
}
这里介绍一下generateRandomArray()
函数,作用是生成一个所有值都随机的数组
public static int[] generateRandomArray(int maxSize,int maxValue) {
//Math.random() [0,1)所有的小数,等概率返回一个
//Math.random() * N [0,N)所有小数,等概率返回一个
//(int)(Math.random() * N) [0,N-1]所有的整数,等概率返回一个
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;
}
六、递归
剖析递归行为和递归行为和递归行为时间复杂度的估算
用递归方法找一个数组中的最大值,系统上是如何实现的?
public class GetMax {
public static int getMax(int[] arr) {
return process(arr, 0, arr.length - 1);
}
//arr[L,R]范围上求最大值
public static int process(int[] arr,int L, int R) {
if(L == R)
return arr[L];
//这里中点为什么不是(R + L)/ 2呢?
//如果数组比较大的时候R + L可能会溢出,因此就采用这种方法,>>表示右移一位,相当于除以2
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公式
T( N ) = a × T( N / b ) + O( N ^ d )
- log(b,a) > d
复杂度为 O( N ^ log( b , a ) ) - log(b,a) = d
复杂度为 O( N ^ d × log N) - log(b,a) < d
复杂度为 O( N ^ d )
对上述公式进行解释:在递归的过程中,T(N)
是母问题的数据规模,将母问题数据规模等分解为b
份(如果不是等分master公式失效),T(N/b)
是子问题的数据规模。a
是子问题一共递归调用的次数,最后的O(N^d)
是递归之外的操作的时间复杂度。
针对上面的寻找最大值的代码进行分析,从数组中间等分为两部分,因此b=2
,对左右两部分分别递归,因此a=2
。除此之外的操作就是对比左右两部分的最大值比较大小然后返回,这个过程的时间复杂度为O(1)。最终的master公式为T(N) = 2×T(N/2) + O(1)
。
补充阅读:https://blog.gocalf.com/algorithm-complexity-and-master-theorem