什么是大O
n表示数据规模,则 表示运行算法所需要执行的指令数,和 成正比。
一个算法有多个步骤,每个步骤规模相同但时间复杂度不同,最终的时间复杂度以最高的那个为准。
对数据规模的概念
如果想要在1s之内解决问题:
的算法可以处理大约 级别的数据;
的算法可以处理大约 级别的数据;
的算法可以处理大约 级别的数据;
这个结论只是执行简单的数据操作,实际还需要再低估一下。
空间复杂度
多开一个辅助数组: ;
多开一个辅助的二维数组:
多开常数空间: ;
需要注意:递归调用是有空间代价的,递归的深度就是空间复杂度。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 int sum1 (int n) { assert(n >= 0 ); int ret = 0 ; for (int i=0 ;i<=n;i++) { ret += i; } return ret; } int sum2 (int n) { assert(n>=0 ); if (n == 0 ) return 0 ; return n + sum2(n-1 ); }
简单的时间复杂度分析
O(1)
常数级的算法很简单,没有数据规模的变化
1 2 3 4 5 6 7 void swapTwoInts (int &a , int &b) { int temp = a; a = b; b = temp; return ; }
O(n)
O(n)的算法典型的特征就是有个循环,并且循环的次数和n相关。
其实正常来说,应该是 ,其中C是个常数,且不一定大于1
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 int sum (int n) { assert(n >= 0 ); int ret = 0 ; for ( int i = 0 ; i <= n ; i ++ ) ret += i; return ret; } void reverse (string &s) { int n = s.size(); for (int i = 0 ; i < n/2 ; i ++) swap( s[i] , s[n-1 -i] ); return ; }
O(n^2)
见到算法内部有双重循环,每层循环都是n相关基本就八九不离十了。
1 2 3 4 5 6 7 8 9 10 11 12 void selectionSort (int arr[], int n) { for (int i = 0 ; i < n ; i ++){ int minIndex = i; for ( int j = i + 1 ; j < n ; j ++ ) if ( arr[j] < arr[minIndex] ) minIndex = j; swap( arr[i] , arr[minIndex] ); } }
千万要注意两层循环都要和n相关,不要看到双重循环就认为是
1 2 3 4 5 6 7 8 9 10 void printInformation (int n) { for ( int i = 1 ; i <= n ; i ++ ) for ( int j = 1 ; j <= 30 ; j ++ ) cout << "Class " << i << " - " << "No. " << j << endl ; return ; }
O(logn)
经典的二分查找法:
1 2 3 4 5 6 7 8 9 10 11 12 int binarySearch (int arr[], int n, int target) { int l = 0 , r = n-1 ; while ( l <= r ){ int mid = l + (r-l)/2 ; if (arr[mid] == target) return mid; if (arr[mid] > target) r = mid - 1 ; else l = mid + 1 ; } return -1 ; }
将数字整形转化为字符串:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 string intToString (int num) { string s = "" ; string sign = "+" ; if (num < 0 ){ num = -num; sign = "-" ; } while (num){ s += '0' + num % 10 ; num /= 10 ; } if (s == "" ) s = "0" ; reverse(s); if (sign == "-" ) return sign + s; else return s; }
分析算法思想,核心在" num /=
10"步中,就是n经过几次“除以10”操作后等于0,那么就是 ;
虽然上面两个例子,一个是以2为底,一个是以10为底,但都是 ,这个可以通过对数函数的换底公式来理解:有换底公式 ,可见 和 之间只相差一个常数 ,在时间复杂度下可以直接理解为
还需要注意,双重循环也可能出现logN的复杂度,需要注意量增长的规模:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 void hello (int n) { for ( int sz = 1 ; sz < n ; sz += sz ) for ( int i = 1 ; i < n ; i ++ ) cout << "Hello, Algorithm!" << endl ; } bool isPrime (int num) { if ( num <= 1 ) return false ; if ( num == 2 ) return true ; if ( num % 2 == 0 ) return false ; for (int x = 3 ; x * x <= num ; x += 2 ) if ( num%x == 0 ) return false ; return true ; }
递归算法的复杂度分析
面对递归算法需要具体问题具体分析。
递归中只会进行一次递归调用
如果递归函数中只进行一次递归调用,递归深度为depth,在每个递归函数中的时间复杂度为T,则总体的时间复杂度为 ,既只需关注递归的深度
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 int binarySearch (int arr[], int l, int r, int target) { if (l > r) return -1 ; int mid = l + (r - l) / 2 ; if (arr[mid] == target) return mid; else if (arr[mid] > target) return binarySearch(arr, l, mid - 1 , target); else return binarySearch(arr, mid + 1 , r, target); }
二分查找法很容易用递归实现,上述代码每次调用时,内部要么直接返回,要么数组左半边调用递归,要么数组右半边调用递归,只会调用一次,所以分析时间复杂度只有看递归的深度即可。
每次调用数组减少一半,是典型的logn情况,所以复杂度为
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 int sum (int n) { assert(n >= 0 ); if (n == 0 ) return 0 ; return n + sum(n - 1 ); } double pow (double x, int n) { assert(n >= 0 ); if (n == 0 ) return 1.0 ; double t = pow (x, n / 2 ); if (n % 2 ) return x * t * t; return t * t; }
递归中进行多次递归调用
当递归中有多次递归调用时,就需要关注递归调用的次数了。 如果递归中只调用一次,深度就是次数,但如果调用了多次,那么深度和次数就是两个概念了。
1 2 3 4 5 6 7 8 9 10 11 int f (int n) { assert(n >= 0 ); if (n == 0 ) return 1 ; return f(n-1 ) + f(n-1 ); }
均摊复杂度分析
有时候会遇到这种情况:解决某个问题时运用了一系列算法,某个算法的复杂度很高,但这个算法可以降低其他算法的复杂度,这时候就需要计算分摊复杂度。
均摊复杂度的经典问题就是动态数组vector。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 template <typename T> class MyVector {private: T* data; int size; int capacity; void resize (int newCapacity) { assert(newCapacity >= size); T *newData = new T[newCapacity]; for ( int i = 0 ; i < size ; i ++ ) newData[i] = data[i]; delete[] data; data = newData; capacity = newCapacity; } public: MyVector(){ data = new T[100 ]; size = 0 ; capacity = 100 ; } ~MyVector(){ delete[] data; } void push_back (T e) { if (size == capacity) resize(2 * capacity); data[size++] = e; } T pop_back () { assert(size > 0 ); size --; return data[size]; } };
上面的代码只在push操作时resize了空间,但是没有在pop操作时resize空间。我们当然可以参考push操作,在pop到capacity一半时resize容量,但这里涉及一个问题,就是要防止复杂度震荡 。
考虑这个问题:当push到临界点时resize一倍空间,然后立即pop,此时又resize为一半,然后立即push,这种极端场景下时间复杂度无法均摊,会退化为 。
如果想避免这种场景,可以不再临界点处resize,当pop操作到达capacity的1/4处时,resize为1/2
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 T pop_back () { assert(size > 0 ); T ret = data[size-1 ]; size --; if (size == capacity / 4 ) resize(capacity / 2 ); return ret; }