目录
1. 时间复杂度的概念
在计算机科学中,时间复杂性,又称时间复杂度,算法的时间复杂度是一个函数,它定性描述该算法的运行时间。这是一个代表算法输入值的字符串的长度的函数。时间复杂度常用大O符号表述,不包括这个函数的低阶项和首项系数。
算法中的基本操作的执行次数,为算法的时间复杂度。
为什么要用时间复杂度来描述算法的运行时间?
举个例子,同样的一段代码,放在学校机房老掉牙的电脑上跑 和 在用i9的CPU的游戏本上跑,所花费的时间显然是不一样的。代码运行时间还会被电脑的负载、硬件资源等等方面影响。
所以,直白的用运行时间进行评估是不可以的。
找到某条基本语句与问题规模N之间的数学表达式,就是算出了该算法的时间复杂度。
示例:计算Func1的时间复杂度。
void Func1(int N)
{
int count = 0;
for (int i = 0; i < N; ++i)
{
for (int j = 0; j < N; ++k)
{
++count;
}
}
for (int k = 0; k < 2 * N; ++k)
{
++count;
}
int M = 10;
while (M--)
{
++count;
}
}
一条语句的重复执行次数称为“语句频度”。
两个嵌套的for循环语句频度是,第二个一层的for循环是
,while循环是
(因为M = 10)。
所以可以得到时间复杂度(一个带未知数N的函数式):
2. 大O渐近表示法
对于Func1()这种比较简单的算法,可以直接计算出所有语句的频度;但是对于稍微复杂一点的算法,计算所有语句的频度式比较困难的。
所以,为了客观反映一个算法的执行时间,可以只用算法中的基本语句的执行次数来衡量算法的工作量。所谓的“基本语句”就是对算法运行时间影响最大的语句。
这就是 大O渐近表示法,这种方法属于估算它属于那个量级。
举个例子,算财富值可以分为 超级富豪、富豪、中产等等,身价多少属于哪个等级,不会计较在富豪里是一亿还是两亿。
大O渐近表示法的推导:
1. 用常数1取代运行时间中的所有加法常数;
2. 在修改后的运行次数函数中,只保留最高阶项;
3. 如果最高阶项存在且不为1,则去掉与这个项相乘的常数,得到的结果就是大O阶。
所以使用大O渐近表示法后,Func1的时间复杂度为
常见的时间复杂度有 :、
、
、
、
、
示例:计算Func2的时间复杂度。
void Func2(int N, int M)
{
int count = 0;
for (int k = 0; k < M; ++k)
{
++count;
}
for (int k = 0; k < N; ++k)
{
++count;
}
printf("%d\n", count);
}
因为M和N的具体的值没有给出,Func2的时间复杂度是
假如条件中有:
M远大于N,可以写成 ;
N远大于M,可以写成 。
示例:计算Func3的时间复杂度。
void Func3(int N)
{
int count = 0;
for (int k = 0; k < 100; ++k)
{
++count;
}
printf("%d\n", count);
}
Func3的时间复杂度是
注意:O(1)并不是代表1次,代表常数次。
示例:计算strchr的时间复杂度。
const char* strchr(const char* str, int character);
这是一个字符查找函数,大致的过程是:
while (*str)
{
if (*str == chraracter)
{
return str;
}
else
{
++str;
}
}
这个函数的时间复杂度和前面的不太一样,因为字符串长度是未知的。
运气好的话,第一次就能找到目标;
运气一般般,在中间找到目标;
运气贼差,那就在最后一次找到。
所以,有一些算法的时间复杂度是存在最好、平均、最坏的情况:
最好情况:任意输入规模的最小运行次数(下限);
平均情况:任意输入规模的期望运行次数;
最坏情况:任意输入规模的最大运行次数(上界)。
在实际情况关注的是算法的最坏运行时间,所以数组中搜索数据时间复杂度为O(N)
示例:计算BubbleSort的时间复杂度。
void BubbleSort(int a, int n)
{
assert(a);
for (size_t end = n; end > 0; --end)
{
int exchange = 0;
for (size_t i = 1; i < end; ++i)
{
if (a[i - 1] > a[i])
{
Swap(&a[i - 1], &a[i]);
exchange = 1;
}
}
if (exchange == 0)
break;
}
}
BubbleSort的时间复杂度是
冒泡排序的语句频度是等差数列
示例:计算BinarySearch的时间复杂度。
int BinarySearch(int* a, int n, int x)
{
assert(a);
int begin = 0;
int end = n - 1;
while (begin <= end)
{
int mid = begin + ((end - begin) >> 1);
if (a[mid] < x)
begin = mid;
else if (a[mid] > x)
end = mid - 1;
else
return mid;
}
return -1;
}
二分查找的本质是缩小区间,区间最开始有个值,不断减半
最差的情况是:直到,区间里只有一个数,这个数就是要找的。
除了多少个2,就查找了多少次,根据这个性质可以推出:
那么,
所以,BinarySearch的时间复杂度是
注意:通常我们会写成,这是因为对数不好书写,除非是用专业公式支持的编辑器(只是在时间复杂度这儿)
3. 例题
3.1 消失的数字
思路解析:
思路一:先排序,在后一个数不等于前一个数+1,那么本该的后一个数就是消失的数字。
如果使用冒泡排序,冒泡的时间复杂度是,遍历是
如果使用qsort,时间复杂度是
思路二:创建一个x先和0~n的所有值异或,再和数组中的值异或,结果就是消失的数字。
(N + 1) + N,所以时间复杂度是
思路三:计算0~n累加的和,再用和 减数组中的值,结果就是消失的数字。
时间复杂度是
示例代码:
//思路二
int missingNumber(int* nums, int numsSize){
int x = 0;
int n = numsSize;
for(int i = 0; i <= n; ++i)
{
x ^= i;
}
for(int j = 0; j < numsSize; ++j)
{
x ^= nums[j];
}
return x;
}
//思路三
int missingNumber(int* nums, int numsSize){
int sum = 0;
for(int i = 0;i<numsSize+1;i++)
{
sum+=i;
}
for(int i = 0;i<numsSize;i++)
{
sum-=nums[i];
}
return sum;
}
3.2 轮转数组
思路解析:
思路一:每次挪动旋转一位,右旋k次。最好情况:k是N的倍数,此时相当于没有旋转。时间复杂度是。最坏情况:k % N == N - 1,此时的时间复杂度是
思路二:三步旋转法。前n-k个逆置,然后后k个逆置,最后整体逆置。(N - k) + k + N = 2N。时间复杂度是
思路三:新建一个数组,把轮转的值放进去,返回新数组。时间复杂度是
示例代码:
//思路二
void Reverse(int* arr, int left, int right){
while(left < right)
{
int tmp = arr[left];
arr[left] = arr[right];
arr[right] = tmp;
++left;
--right;
}
}
void rotate(int* nums, int numsSize, int k) {
k %= numsSize;
//逆置前n-k个
Reverse(nums, 0, numsSize-k-1);
//逆置后k个
Reverse(nums, numsSize-k, numsSize-1);
//整体逆置
Reverse(nums, 0, numsSize-1);
}
//思路三
void rotate(int* nums, int numsSize, int k) {
int tmp[numsSize];
k %= numsSize;
int n = numsSize;
memcpy(tmp, nums + n - k, sizeof(int)*k);
memcpy(tmp + k, nums, sizeof(int)*(n - k));
memcpy(nums, tmp, sizeof(int)*n);
}
3.3 计算阶乘递归Fac的时间复杂度
long long Fac(size_t N) {
if (0 == N)
return 1;
return Fac(N - 1) * N;
}
思路解析:
递归算法的时间复杂度是每次递归子函数消耗累加起来。
所以,时间复杂度是
3.4 计算斐波那契递归Fib的时间复杂度
long long Fib(size_t N) {
if (N < 3)
return 1;
return Fib(N - 1) + Fib(N - 2);
}
思路解析:
所以,Fib的时间复杂度是