目录
数据结构前言
1、什么是数据结构?
专业术语:数据结构是计算机存储、组织数据的方式,指相互之间存在一种或多种特定关系的数据元素的集合。
自我理解:数据结构研究的是数据在计算机中如何存储,是计算机的底层逻辑,也是每一位程序员需要修炼的内功。
2、什么是算法?
如果说数据结构是数据的存储方式,那算法就是一系列的计算方法。一种算法定义了一个良好的计算过程,这个计算过程可以将一大批输入的数据转化为一批 “ 有迹可循 ” 的输出结果。
算法的时间复杂度和空间复杂度
1、算法效率
要衡量一个算法的好坏,我们需要知道算法的效率,而算法效率可从两个维度来衡量:时间和空间,即时间复杂度和空间复杂度。
时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要衡量一个算法运行所需要的额外空间。
2、时间复杂度
2.1 时间复杂度的概念
一个程序在不同配置的计算机环境下运行的时间是不一样的,而程序运行时我们无法保证环境都一模一样,为了抛开运行环境的因素,我们关心的是程序运行的大概次数,而不是具体的多少秒。
因此,时间复杂度是指算法中基本操作的执行次数,它是一个函数(数学中的函数式,不是C语言中的函数),既然是大概执行次数,我们求的是函数的一个量级,即对结果影响最大的项。
2.2 大O的渐进表示法
大O符号:用于描述函数渐进行为的数学符号。
我们使用大O的渐进表示法来刻画大概执行次数。
推导大O阶方法:
- 只保留最高阶项
- 去掉最高阶项的系数
- 用常数1取代加法常数
注:加法常数指的是在算法分析中,执行次数固定不变的加法操作。即 O(1) 不是表示执行次数为1次,而是常数次。
例如:
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 < 3 * N; ++k)
{
++count;
}
int M = 100;
while (M--)
{
++count;
}
printf("%d\n", count);
}
Func1执行的基本操作次数:F(N) = N^2 + 3*N + 100
Func1的时间复杂度为:O(N^2)
另外,有些算法的时间复杂度存在最好、平均和最坏情况。
例如:在一个长度为N数组中搜索一个数据x
最好情况:1次找到
最坏情况:N次找到
平均情况:N/2次找到
而实际中,我们关注更多的是算法的最坏运行情况,所以数组中搜索数据的时间复杂度为O(N)。
2.3 常见时间复杂度计算举例
实例1:
void Func2(int N)
{
int count = 0;
for (int k = 0; k < 10000 * N; ++k)
{
++count;
}
int M = 500000;
while (M--)
{
++count;
}
printf("%d\n", count);
}
Func2执行的基本操作次数为:F(N) = 10000*N + 500000
首先,我们只保留最高阶项,常数为N的0次项,则保留N的1次项10000*N。
其次,我们要去掉最高阶项的系数,不管系数多大,在N为无穷大面前都不值一提。
所以,Func2的时间复杂度为:O(N) 。
实例2:
void Func3(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);
}
Func3执行的基本操作次数为:F(N) = M + N
两个未知数都应保留,Func3的时间复杂度为:O(M + N)
此题若设置前提:M >> N,则时间复杂度为O(M),反之为O(N)。
实例3:
void Func4(int N)
{
int count = 0;
for (int k = 0; k < 10000; ++k)
{
++count;
}
printf("%d\n", count);
}
Func4基本操作执行了10000次,为常数次,则Func4的时间复杂度为:O(1)。
实例4:
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;
}
}
}
首先,最好的情况是有序,而程序不知道是否有序,也需要每个数字判断一遍,所以实例4基本操作最好情况执行N次。
而最坏的情况是完全逆序,第1个数字需交换N-1次,第2个数字交换N-2次......第N-1个数字交换1次,第N个数字交换0次,显然这是一个等差数列,总共有N个数字,求和得执行次数为(N*(N+1)) / 2 次,通过推导大O阶方法以及时间复杂度一般看最坏情况,实例4的时间复杂度为:O(N^2)。
实例5:
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) / 2;
if (a[mid] < x)
begin = mid + 1;
else if (a[mid] > x)
end = mid - 1;
else
return mid;
}
return -1;
}
最好的情况是一次就找到,O(1);
最坏的情况是区间折半缩到只有一个值的时候,要么找到,要么找不到。假设折半了x次,折半查找了多少次就除了多少个2,则有N / 2^x = 1 ==> N = 2^x ==> x = logN (logN在算法分析中表示以2为底,N的对数),所以实例5的时间复杂度为O(logN)。
实例6:
//阶乘递归
long long Fac(siz_t N)
{
if (0 == N)
return 1;
return Fac(N - 1) * N;
}
函数递归调用了N次,所以实例6的时间复杂度为O(N)。
实例7:
//计算斐波那契递归的时间复杂度
long long Fib(size_t N)
{
if (N < 3)
return 1;
return Fib(N - 1) + Fib(N - 2);
}
3、空间复杂度
空间复杂度也是一个数学表达式,是指算法运行过程中临时额外占用的存储空间大小。但并不是计算程序占用了多少bytes的空间,而是计算变量的个数。计算规则和时间复杂度类似,也使用大O渐进表示法。
注意:函数运行时所需要的栈空间(存储参数、局部变量、一些寄存器信息等)在编译期间已经确定好了,因此空间复杂度主要通过函数在运行时显式申请的额外空间来确定。
- 显式申请的额外空间:除了上述在栈上自动分配的空间之外,有些算法和函数还需要在运行时动态地分配额外的空间。 例如:使用malloc函数在堆上分配内存,或者创建新的数据结构等。这类空间的分配通常取决于输入数据的大小或其他运行时条件,因此其大小在编译时是不确定的。
- 空间复杂度的主要关注点:分析一个算法的空间复杂度是,我们主要关注的是这些显式申请的额外空间,因为它们的大小通常是随输入数据的变化而变化的。对于栈上的空间,由于它是固定的,因此在分析空间复杂度时通常不考虑这部分。 例如:考虑一个简单的递归函数,它只需要在栈上分配局部变量和参数的空间,但没有显式地分配额外的堆空间。在这种情况下,该函数的空间复杂度通常被认为是O(1),即与输入数据的大小无关。
实例1:
//计算Fibonacci的空间复杂度
//返回斐波那契数列的前n项
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+1
个存放long long
类型值的内存空间,空间复杂度为O(N)。
实例2:
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;
}
}
}
实例2虽然没有动态分配内存空间,但是在栈上为定义的3个变量(end, exchange, i)分配了空间,即使用了常数个空间,所以空间复杂度为O(1)。
实例3:
//计算阶乘递归Fac的空间复杂度
long long Fac(size_t N)
{
if (N == 0)
return 1;
return Fac(N - 1) * N;
}
每一次函数的调用都重新开辟了一块空间。递归调用了N次,开辟了N个栈帧,每个栈帧使用了常数个空间。空间复杂度为O(N)。
实例4:
//计算斐波那契递归的空间复杂度
long long Fib(size_t N)
{
if (N < 3)
return 1;
return Fib(N - 1) + Fib(N - 2);
}
实例4:时间复杂度为 O(2^n) ;空间复杂度为:O(N)。
ps:时间一去不复返,不可重复利用 !!
空间用了以后归还,可以重复利用
4、复杂度的oj练习
练习1:消失的数字oj链接. - 力扣(LeetCode)
思路一:排序+二分查找 ==> 时间复杂度O(N*logN)
思路二:异或 ==> O(N)
int missingNumber(int* nums, int numsSize){ int val = 0; for(int i = 0; i < numsSize; ++i) { val ^= nums[i]; } for(int i = 0; i <= numsSize; ++i) { val ^= i; } return val; }
val 依次跟数组中的每个数字异或,再跟0到n之间的数字异或,除了消失的数字只出现一次,所有的数字都出现两次。
思路三:公式计算 :0-n求和 - 数组中的值 ==> O(N)
int missingNumber(int* nums, int numsSize) { int sum = numsSize * (numsSize + 1) / 2; for (int i = 0; i < numsSize; ++i) { sum -= nums[i]; } return sum; }
练习2:轮转数组oj链接. - 力扣(LeetCode)
思路一:
输入: nums = [1,2,3,4,5,6,7], k = 3 输出: [5,6,7,1,2,3,4] 解释: 向右轮转 1 步: [7,1,2,3,4,5,6] 向右轮转 2 步: [6,7,1,2,3,4,5] 向右轮转 3 步: [5,6,7,1,2,3,4]
思路二:
输入: nums = [1,2,3,4,5,6,7], k = 3
输出: [5,6,7,1,2,3,4]
前n-k个逆置:4 3 2 1 5 6 7
后k个逆置: 4 3 2 1 7 6 5
整体逆置: 5 6 7 1 2 3 4
void reverse(int* nums, int begin, int end) { while (begin < end) { int tmp = nums[begin]; nums[begin] = nums[end]; nums[end] = tmp; ++begin; --end; } } void rotate(int* nums, int numsSize, int k) { if (k > numsSize) k %= numsSize; //注意越界 reverse(nums, 0, numsSize - k-1); reverse(nums, numsSize - k, numsSize-1); reverse(nums, 0, numsSize-1); }
时间复杂度:O(N)
空间复杂度:O(1)
思路三:
使用额外的数组,拷贝过去
void rotate(int* nums, int numsSize, int k) { if (k > numsSize) k %= numsSize; int* tmp = (int*)malloc(sizeof(int) * numsSize); //拷贝后k个数字 memcpy(tmp, nums + numsSize - k, sizeof(int) * k); //拷贝前numsSize-k个 memcpy(tmp+k, nums, sizeof(int) * (numsSize-k)); //拷贝到原数组 memcpy(nums, tmp, sizeof(int) * numsSize); free(tmp); tmp = NULL; }
时间复杂度:O(N)
空间复杂度:O(N)
写到最后的话:小编也是进修阶段,如果发现有错误或对你造成困扰的地方,欢迎指正和交流讨论,我们一起共同进步!如果你觉得还不错或对你有帮助的话,可以给小编一个鼓励的小心心呀~