目录
数组nums包含从0-n的每个整数,但缺少了一个。请在O(N)时间内找到它。
一、时间复杂度
程序的优化不仅仅是在说简化代码,减少代码行这样,而是要尽量减少时间复杂度和空间复杂度。时间复杂度用来判断一个程序运行所耗费的时间。然而它并不是所想象的计算运行时间,而是计算基本操作被执行的次数。这里的操作不是那些定义变量的活,而是要执行的语句。有没有作用都可。即使要排序某个东西,在排序之前,操作者又把所有数字遍历输出了一遍,这也会进入到计算中。一个形象的例子:
void Func1(int N)
{
int count = 0;
for (int i = 0; i < N; ++i)
{
for (int j = 0; j < N; ++j)
{
++count;
}
}
for (int k = 0; k < 2 * N; ++k)
{
++count;
}
int M = 10;
while (M--)
{
++count;
}
printf("%d\n", count);
}
一个简单的代码块。这个程序的时间复杂度怎么算?现在里头有三个循环,次数分别是N*N, 2 * N, 10。虽然程序执行了这么多次语句,但实际上,时间复杂度并不就是这个数。我们可以带入一个数字,比如N = 10,,N = 20,,N = 100,,N = 10000等等越来越大,这时候会发现N * N,也就是N的2次方这个数占比很大,而2N+10这个数占比则越来越小。当N趋近于无穷大时,2N+10可以忽略。所以我们只需要取N的2次方即可。
关于时间复杂度的表现形式如下:
O()
括号里就是我们得到的数值。比如上述程序时间复杂度就是O(N^2)。时间复杂度的表现形式就是大O符号,是用来描述函数渐进行为的数学符号。由上面的例子我们可引出大O阶的一个规则:在修改后的运行次数函数中,只保留最高阶项。现在继续例子。
void Func1(int N)
{
int count = 0;
for (int k = 0; k < 2 * N; ++k)
{
++count;
}
int M = 10;
while (M--)
{
++count;
}
printf("%d\n", count);
}
现在去掉上面的ij循环,只剩下2*N+10,10一样可以忽略,这对于计算机来讲不算什么。所以最后得到的数值就是2N吗?这涉及到另一个规则:如果最高阶项存在且不是1,则去除与这个项目相乘的常数,得到的结果就是大O阶。啥意思?意思就是这个程序的时间复杂度就是O(N)。去掉了2,最终数值就是N。回头看一下,或许还有一个疑问,为什么强调不是1?最后一个规则:用常数1取代运行时间中的所有加法常数。看例子
void Func1(int N)
{
int count = 0;
int M = 10;
while (M--)
{
++count;
}
printf("%d\n", count);
}
还是这个。现在只有一个10了。大O阶就是O(1).其实大O阶是个估算的东西,估算的就是大概次数所属量级。到了现在,你一定有疑惑,为什么就可以去除掉一些东西,真的没影响吗?其实这对于计算机来说确实没影响。请看代码
int main()
{
size_t begin = clock();
size_t n = 0;
for (size_t i = 0; i < 100000000000; ++i)
{
++n;
}
size_t end = clock();
printf("%d毫秒\n", end - begin);
return 0;
}
这里用到了clock这个库函数,这得需要<time.h>头文件。此程序计算了这个循环的用时。在debug下,随着数字越来越大,毫秒数也会变大。但是即使现在这个11个0的大数字,也就是几百毫秒。如果换成release版本,就会变成0毫秒了。所以这对计算机来说真不算压力。
继续一些实例。
回想一下冒泡排序。i和j,都需要遍历。遍历一次后,就从N个数字变成N-1个数字,然后一点点减小,直到1。这就是一个等差数列,算出来的总次数就是N*(N-1)/2,也就是1/2 * N^2 - 1/2 * N。按照规则,0.5就可以去掉,0.5N也可以去掉。所以这个的大O阶就是O(N^2)。
二分查找
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 + 1;
else if (a[mid] > x)
end = mid - 1;
else
return mid;
}
return -1;
}
分半,一次判断之后,再次分半......二分查找是个很有效的想法,这不仅仅是体现在代码量的减少,更体现在时间复杂度上。这个程序的时间复杂度是O(log N)。二分查找每次查找,查找区间都会缩减一半。而关于运行语句的次数,最后数字量会变为1, N /2/2/2/2/2.....=1。所以得出N是2的次方,那么x就是logN。所以时间复杂度就是O(log N)。带入具体的数字就会发现,和暴力查找,只是简单的循环遍历而言,二分查找高效得多。
递归
long long Fac(size_t N)
{
if (0 == N)
return 1;
return Fac(N - 1) * N;
}
传入N这个数字,进入函数中,我们也就是判断了1次,是就退出,不是就继续递归。每次进入就执行1次,那么总体的时间复杂度就是O(N)。
变化下
long long Fac(size_t N)
{
if (0 == N)
return 1;
for (size_t i = 0; i < N; ++i)
{
//....
}
return Fac(N - 1) * N;
}
同之前一样,是个等差数列。进入一次就会遍历N次。当然我们也可以加上 if 语句的判断。实际上下来,时间复杂度就是O(N^2)。
二、空间复杂度
时间复杂度写完,现在看空间复杂度。空间复杂度其实计算的是整个程序临时占用存储空间大小的量度,同样也使用大O阶。函数运行时所需要的栈空间(存储参数,局部变量,一些寄存器信息等等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时显式申请的额外空间来确定。
冒泡排序
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;
}
}
a,n都是已经安排好的,而这里的变数就是end, exchange, i这三个临时建立,只有这三个常数,所以空间复杂度就是O(1)。
斐波那契数列
long long* Fib(size_t n)
{
if (0 == n)
return NULL;
long long* fib = (long long*)malloc((n + 1) * sizeof(long long));
fib[0] = 0;
fib[1] = 1;
for (int i = 2; i <= n; ++i)
{
fib[i] = fib[i - 1] + fib[i - 2];
}
return fib;
}
动态开辟了n+1个空间。再加上i,也就是O(N)。实际上,空间复杂度大多都是O(1) O(N)。
递归
递归在每一次进入后,都会开辟栈帧所需的空间,最终会消耗N个栈帧。其实和时间复杂度一样,时间会去计算每次调用的执行次数,而空间会计算变量个数。所以空间复杂度就是O(N)。
三、练习
数组nums包含从0-n的每个整数,但缺少了一个。请在O(N)时间内找到它。
我们现在有一些办法:
1、求和相减
0-n作为一个等差数列,算出他们的值再减去nums的总和,这样就能找到缺少的值。这个方法时间为O(N),空间为O(1)。
2、qsort排序
这个方法并不推荐。时间为O(logN * N),空间为O(log N)。如果是冒泡排序,时间则为O(N^2),,空间为O(1)。
3、异或
时间为O(N), 空间为O(1)。
int missingNumber(int* nums, int numsSize)
{
int N = numsSize;
int x = 0;
for (int i = 0; i < numsSize; ++i)
{
x ^= nums[i];
}
for (size_t j = 0; j <= N; ++j)
{
x ^= j;
}
return x;
}
借助这些例子,更好地理解时空间复杂度。
结束。