目录
一.什么是数据结构
二.什么是算法
三.算法的时间复杂度
四.算法的空间复杂度
五.复杂度练习
题一:消失的数字
题二:旋转数组
一.什么是数据结构
数据结构:是相互之间存在一种或多种特定关系的数据元素的集合。
数据结构的三要素:
二.什么是算法
程序=数据结构+算法。数据结构研究如何把现实世界的问题信息化,将信息存进计算机。同时还要实现对数据结构的基本操作。算法研究如何处理这些信息,解决实际问题。
三.算法的时间复杂度
算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。一个算法所花费的时间与其中语句的执行次数成正比,算法中的基本操作的执行次数,为算法的时间复杂度。
实际中我们计算时间复杂度时,其实并不一定要计算精确的执行次数,而只需要大概执行次数,那么这里我们使用大O的渐进表示法。
大O符号:是用于描述函数渐进行为的的数学符号。推导大O阶方法:
- 用常数1取代运行时间中的所有加法常数;
- 在修改后的运行次数函数中,只保留最高项;
- 如果最高阶项存在且系数不是1,则去除与这个项相乘的系数,得到的结果就是大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);
}
分析:
总体时间复杂度为:O(2N + 10)
采用大O渐进法得时间复杂度为:O(N)
案例二:
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);
}
分析:
总体时间复杂度为:O(N+M)
采用大O渐进法得时间复杂度为:O(N+M)
案例三:
void Func3(int N)
{
int count = 0;
for (int k = 0; k < 100; ++k)
{
++count;
}
printf("%d\n", count);
}
分析:
总体时间复杂度为:O(100)
采用大O渐进法得时间复杂度为:O(1)
案例四:
const char * strchr ( const char * str, int character );
分析:
strchr函数是一个字符查找函数,用以实现在字符串str中查找字符character。
最好的情况:第一个元素即为要查找的元素,时间复杂度为O(1)。最坏的情况:最后一个元素才为要查找的元素或者遍历整个字符串之后查找失败,时间复杂度为O(N)。而时间复杂度一般看的是最坏情况,因此总体时间复杂度为:O(N)。
采用大O渐进法得时间复杂度为:O(N)
案例五:
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;
}
}
分析:
冒泡排序:比较相邻的两个元素,数值小的元素慢慢的向前移动,数值大的元素慢慢的向后移动。
最好的情况:整个数组是有序排列的,只需要比较n-1次,而不需要移动元素,此时的时间复杂度为:O(N)。最坏的情况:整个数组是由大到小排列的,总共需要n-1轮循环,则第一轮循环需要比较n-1次,第二轮循环需要比较n-2次,第三轮循环需要比较n-3次,...,第n-1轮循环需要比较1次。则总共比较的次数为(n-1)*n/2次。而时间复杂度一般看的是最坏情况,因此总体时间复杂度为:O((N - 1)*N/2)。
采用大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;
else
return mid;
}
return -1;
}
分析:
二分查找法:二分查找法是一种高效的查找算法,用于在有序数组或有序列表中快速定位目标元素的位置。它通过将目标值与数组中间元素进行比较,从而将查找范围缩小一半,不断迭代直到找到目标元素或确定目标元素不存在。
最好的情况:查找一次变查找到所要查找的元素,此时的时间复杂度为O(1)。最坏的情况:当数组中只剩下最后一个元素,或者查找失败,由于二分查找法每次范围都是缩小一半,则:n / 2 / 2 / 2… /2 = 1。假设查找x次,则2^x=n,两边取对数得:x=log2^n,所以总共比较的次数为次log2^n。而时间复杂度一般看的是最坏情况,因此总体时间复杂度为:O(log2^N)。
采用大O渐进法得时间复杂度为:O(log2^N)。
案例七:
long long Fac(size_t N)
{
if(1 == N)
return 1;
return Fac(N-1)*N;
}
分析:
在计算阶乘递归Fac时,可以发现函数只递归调用了N-1次。
总体时间复杂度为:O(N-1)
采用大O渐进法得时间复杂度为:O(N)
案例八:
long long Fib(size_t N)
{
if(N < 3)
return 1;
return Fib(N-1) + Fib(N-2);
}
分析:
在计算斐波那契递归Fib时,可以发现函数递归呈现指数式增长。
将2^0+2^1+2^2+2^3+…2^(N-2)进行累加得:2^(N-1) - 1,此数也即为函数递归调用的次数。
总体时间复杂度为:O(2^(N-1) - 1)
采用大O渐进法得时间复杂度为:O(2^N)
四.算法的空间复杂度
空间复杂度也是一个数学表达式,是对一个算法在运行过程中临时占用存储空间大小的量度。空间复杂度算的是变量的个数。它的计算规则基本和时间复杂度类似,也使用大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和i,以及exchange。
总体空间复杂度为:O(3)
采用大O渐进法得空间复杂度为:O(1)
案例二:
long long* Fibonacci(size_t n)
{
if(n==0)
return NULL;
long long * fibArray = (long long *)malloc((n+1) * sizeof(long long));
fibArray[0] = 0;
fibArray[1] = 1;
for (int i = 2; i <= n ; ++i)
{
fibArray[i] = fibArray[i - 1] + fibArray[i - 2];
}
return fibArray;
}
分析:
该函数返回的是斐波那契数列的前n项,为了存放该数列的前n项,需要调用malloc函数来动态开辟n+1个空间来存放。
总体空间复杂度为:O(n+1)
采用大O渐进法得空间复杂度为:O(N)
案例三:
long long Fib(size_t N)
{
if(N < 3)
return 1;
return Fib(N-1) + Fib(N-2);
}
分析:
在解答此题之前需要明白的一点是:时间是累计的,空间是不累计的,可以重复利用。在绝大多数情况下,函数递归调用时的空间复杂度=递归调用的深度。
Fib(N-1)和Fib(N-2)在递归的过程中,存在很多相同的部分,在这些相同的部分,它们是共用同一块内存空间的。
采用大O渐进法得空间复杂度为:O(N)
案例四:
long long Fac(size_t N)
{
if(N == 1)
return 1;
return Fac(N-1)*N;
}
分析:
在绝大多数情况下,函数递归调用时的空间复杂度=递归调用的深度。该函数递归调用了N次,开辟了N个栈帧,每个栈帧使用了常量个空间。
采用大O渐进法得空间复杂度为:O(N)
五.复杂度练习
题一:消失的数字
描述:
数组nums包含从0到n的所有整数,但其中缺了一个。请编写代码找出那个缺失的整数。你有办法在O(n)时间内完成吗?
分析:
法一:
使用malloc开辟一个大小为N+1的数组,并初始化为-1。然后遍历数组nums中的各个数字,这个数字是多少就写到新开辟的数组的对应的位置上去,最后再遍历一遍新开辟的数组,哪个位置是-1,这个位置的下标就是缺失的数字。 时间复杂度为:O(N),空间复杂度为:O(N)。
法二:
用异或的思想。设x=0,将x跟数组中的这些数据都异或一遍,然后再跟0-N之间的数字异或一遍,最后得到的x就是缺失的数字 时间复杂度为:O(N)
注意:一个数与自身异或,总是为0,一个数与0异或,总是其自身。
实现:
int missingNumber(int* nums, int numsSize)
{
int x = 0;
for (int i = 0; i < numsSize; i++)
{
x ^= nums[i];
}
for (int j = 0; j < numsSize + 1; j++)
{
x ^= j;
}
return x;
}
法三:
排序+二分查找相结合。
排序算法的时间复杂度:冒泡O(N^2) ,快排O(N*logN)
二分查找的时间复杂度:O(log2^N)
法四:
使用求和公式。如果有n个数,则0+1+2…+n,最后整体再减去数组中的值的累加就是缺失的数。 时间复杂度:O(N)
实现:
int missingNumber(int* nums, int numsSize)
{
int i = 0;
int sum = 0;
for (i = 0; i < numsSize + 1; i++)
{
sum += i;
}
for (i = 0; i < numsSize; i++)
{
sum -= nums[i];
}
return sum;
}
题二:旋转数组
描述:
给你一个数组,将数组中的元素向右轮转k个位置,其中k是非负数。使用时间复杂度为O(N),空间复杂度为O(1)的原地算法解决这个问题。
分析:
法一:
设置变量tmp。将最右边的元素拷贝到tmp中,然后将数组中的值依次右移动一位,再把tmp中的内容存放到数组起始位置;重复上述操作k次,就可以实现数组的旋转。 时间复杂度为O(N^2),空间复杂度为O(1)。
法二:
以空间换时间。首先开辟一个新数组,用以存放原数组中的元素。然后将原数组中的后k个元素保持起始序列依次存放到新数组的开始k个位置。其次将原数组剩余的n - k个元素保持起始序列依次存放到新数组的后n-k个位置。最后再将新数组的元素依次拷贝到原数组中。 时间复杂度为O(N),空间复杂度为O(N)。
实现:
void rotate(int* nums, int numsSize, int k)
{
int n = numsSize;
int tmp[n];//变长数组【C99】
//首先对k做一个取模操作,防止数组访问越界
k %= n;
//将后k个数字移动到前面
int j = 0;
for (int i = n - k; i < n; ++i)
{
tmp[j++] = nums[i];
}
//将前n - k个数字移动到后面
for (int i = 0; i < n - k; ++i)
{
tmp[j++] = nums[i];
}
//将移动完的数组再拷贝回原数组
for (int z = 0; z < n; ++z)
{
nums[z] = tmp[z];
}
}
法三:
三步翻转法。首先将前n-k个元素进行逆置,然后将后k个元素进行逆置,最后再将n个元素进行整体逆置。 时间复杂度为O(N),空间复杂度为O(1)。
实现:
void reverse(int* nums, int left, int right)
{
while (left < right)
{
int tmp = nums[left];
nums[left] = nums[right];
nums[right] = tmp;
++left;
--right;
}
}
void rotate(int* nums, int numsSize, int k)
{
k %= numsSize;
reverse(nums, numsSize - k, numsSize - 1);
reverse(nums, 0, numsSize - k - 1);
reverse(nums, 0, numsSize - 1);
}