数据结构---时空复杂度

总观

数据结构就是组织数据以及存储数据的一种方式,不同的数据结构在不同的场景之下会有不同的作用。而算法就是将输入的数据经过一系列的计算步骤,最后输出我们想要的结果。

为了更好的说明算法复杂度的问题,首先来看一道算法题:

https://leetcode.cn/problems/rotate-array

解法一:暴力解法

题干给了我们一个数组,要轮转k次,显然需要用到循环,那我们只需要先考虑轮转一次的方法,剩下k次只需要利用循环就可以了。

因此就产生了我们的暴力解法,以题干示例数组 **nums = **[1,2,3,4,5,6,7]****为例。

我们要先将轮转的那个元素保存在变量tmp里边,也就是7,再定义一个变量i指向数组元素6下标的位置,开始循环,我们将nums[i]给给nums[i+1],然后i--,一直循环直到i==0的时候,此时将下标0的数据给给完下标为1之后i--,循环结束,最后将tmp赋值给nums[i+1]就完成了依次轮转操作。

代码实现:

cpp 复制代码
void rotate(int* nums, int numsSize, int k) {
    while(k--)
    {
        int tmp = nums[numsSize - 1];
        for(int i = numsSize - 1 - 1;i >= 0;i--)
        {
            nums[i + 1] = nums[i];
        }
        nums[0] = tmp;
    }
}

复杂度的概念

对于上边的那道算法题,用解法一去提交的话会超出时间限制,这里所谓的时间限制就涉及到了复杂度的概念,复杂度分为时间复杂度和空间复杂度,它们用于衡量一个算法的好坏,时间复杂度就是用于衡量一个算法的运行快慢,空间复杂度就是用于衡量一个算法运行所需要的额外空间。当然由于时代的发展,现代的计算机的容量一般都是足够的,所以空间复杂度相比于时间复杂度来说就显得少重要一点。

时间复杂度

时间复杂度是一个函数表达式,它不是去计算程序所执行的具体的时间,因为相同的代码可能在不用的编译器上的运行时间都会有着区别,这就带来了不确定性。但是一段相同的代码它所执行的次数肯定是确定的,这里所说的执行的次数其实就比如像这个算法里边的for循环执行了多少次这样的一个概念。最后总结一下,时间复杂度就可以等同理解为程序的执行次数

一段C语言的代码,它最终会被编译器编译成一段二进制的指令,所以程序的运行时间就等于二进制指令运行时间乘执行次数。其中每一条的二进制指令的执行时间是可以忽略不计的,因为CPU每一秒可能就可以执行成千上万条指令,因此程序的运行时间也可以等价理解为程序的执行次数。

所以通过分析时间复杂度,我们就可以知道不同程序的快与慢。

时间复杂度的经典案例分析

案例一:计算Func1函数的时间复杂度

cpp 复制代码
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;
	}
}

通过分析很快就可以得出程序的执行次数 T(N) = N * N + 2 * N + 10 ,但是我们描述时间复杂度的时候并不会这样去描述,随着N的增大,N * N 的增长速度是远远超过其他剩下的部分的,因此除去 N * N 的部分其实是可以忽略不计的,通过N * N 就可以去描述案例一的时间复杂度。表示为O(N^2)。这就是大O表示法。

大O渐近表示法:用来描述时间复杂度(本质是一个趋势,但在计算的时候理解为执行次数就可以了。)

注意:下边函数T里边所有的N其实都是指代变量,没有具体的含义。

我们一般是用大O表示法去描述算法的时间复杂度。它有三个规则。

  1. 时间复杂度函数式T(N)中,只保留最⾼阶项,去掉那些低阶项,因为当N不断变大时,低阶项对结果影响越来越⼩,当N⽆穷⼤时,就可以忽略不计了。

  2. 如果最⾼阶项存在且不是1,则去除这个项⽬的常数系数,因为当N不断变⼤,这个系数对结果影响越来越⼩,当N⽆穷⼤时,就可以忽略不计了。

  3. T(N)中如果没有N相关的项⽬,只有常数项,⽤常数1取代所有加法常数。

案例二:

cpp 复制代码
void Func2(int N)
{
	int count = 0;
	for (int k = 0; k < 2 * N; ++k)
	{
		++count;
	}
	int M = 10;
	while (M--)
	{
		++count;
	} 
	printf("%d\n", count);
}

先写出执行次数函数T的表达式,T(N) = 2 * N + 10 ,根据大O表示法的规则可以轻松得出时间复杂度为O(N)

案例三:

cpp 复制代码
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);
}

T(N、M) = M + N,时间复杂度O(M + N)

其实还可以做进一步的讨论,讨论M和N的范围

如果M == N O(N) / O(M)

如果M >> N O(M)

如果M << N O(N)

案例四:

cpp 复制代码
void Func4(int N)
{
	int count = 0;
	for (int k = 0; k < 100; ++k)
	{
		++count;
	} 
	printf("%d\n", count);
}

T(N) = 100,O(1)

案例五:

cpp 复制代码
const char* strchr(const char* str, char character)
{
	const char* p_begin = s;
	while (*p_begin != character)
	{
		if(*p_begin == '\0')
			return NULL;
		p_begin++;
	}
	return p_begin;
}

本题的代码整体逻辑就是指定了一个字符character,p_begin一开始指向str字符串的第一个字符的位置,便利str字符串寻找,找到了返回地址,找不到返回NULL。

有了上边对于案例五代码的逻辑分析,我们就知道它的时间复杂度受到character,假设这个字符串为 "Hello................World\0",总长度为n。如果要查找的字符正好是H,只需要执行一次,时间复杂度就为O(1),如果要查找的字符为d,需执行n次,时间复杂度就变成了O(N),那如果要查找的字符正好在字符串中间的位置,T(N) == N / 2,时间复杂度为O(N)。

以上的三种情况就对应了最好情况,平均情况和最差情况。而大O表示法一般只会关心最差的情况。

案例六:

cpp 复制代码
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

第二次 n - 2

第三次 n - 3

.

.

.

第n次 1

最终执行次数为T(N) = 1 + 2 + 3 + ... + (n - 1)

通过等差数列求和公式化简得:T(N) = (N^2 - N) / 2

由大O表示法的规则得出时间复杂度为O(N^2)

案例七:

cpp 复制代码
void func5(int n)
{
    int cnt = 1;
    while (cnt < n)
    {
        cnt *= 2;
    }
}

像这种循环次数受到变量控制的代码,直接假设总的执行次数为k,每次进入while循环,cnt都乘2,执行k次跳出循环的话就说明满足了 2^k == n 这个式子,最终算出来 k = logn,则最终的时间复杂度为O(logn)。注意这里有一个小细节,时间复杂度如果是log函数,那它的底数就可以直接省略掉了,因为当n趋于无穷大的时候,log函数为无穷大了,底数是几都不重要了。按照这个道理,用lgn表示也跟logn是一个意思。它们在时间复杂度里边是等价的。

案例八:

cpp 复制代码
long long Fac(size_t N)
{
	if (0 == N)
		return 1;
	return Fac(N - 1) * N;
}

这是一段递归代码,求n!,那递归算法的时间复杂度 = 单次递归的时间复杂度 * 递归次数。

这个案例里边单次递归次数为1,而递归次数为n + 1次,从Fac(N) -> Fac(N - 1) -> Fac(N - 2) -> ......Fac(0)。总N + 1次。最终的总的执行次数就为(N + 1) * 1,时间复杂度就为O(N)。

空间复杂度

跟时间复杂度一样,空间复杂度也是用大O表示法去表示,它也是一个表达式,它计算的是函数体内因执行算法所额外开辟的空间。像形参以及函数栈帧之类的都不用管了,因为它们在编译的时候就已经确定好空间了。说白了空间复杂度就是申请空间的次数,不用管具体是多少个字节。

案例一:

cpp 复制代码
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;
    }
}

案例一里的函数体内就创建了exchange,end等变量,空间复杂度就为O(1)

案例二:

cpp 复制代码
long long Fac(size_t N)
{
    if(N == 0)    
        return 1;
    return Fac(N-1)*N;
}

递归算法的空间复杂度 = 单次递归的空间复杂度 * 递归次数,上述代码的递归次数已经在上边分析过了,为n + 1次,而单次递归的空间复杂度为O(1),所以最终的空间复杂度为O(N)

常见复杂度对比

由上边两张表可以看出不用的时间复杂度之间的时间差异。

再看轮转数组

在总观那个大标题里边已经给出了解法一,暴力解法,简单分析一下就可得出它的时间复杂度为O(N^2) (经过分析很容易可以知道执行次数为k * numsSize,为两个变量,我们可以将它们都看成N,因为numsSize和k谁大谁小不知道,都有可能为无穷大,所以我们可以将它们看成统一的变量N),空间复杂度为O(1)

解法二:创建一个新数组

创建一个跟题给数组nums一样大小的数组tmp,将nums数组里边要轮转的后k个数据放到tmp的前k个位置,nums数组里剩下的数据依次放到tmp里边就可以了。这样一来我们只需要从头便利nums数组一次。

题给示例:

轮转次数k == 3

nums 1,2,3,4,5,6,7

tmp 5,6,7,1,2,3,4

下标 0,1,2,3,4,5,6

先定义一个变量i指向nums数组下标为0的位置,nums[i]给给tmp[i+ k],接着i++,但是当i的值为4的时候i + k == 7就已经越界了,此时我们只需要将(i + k) % numsSize,此时的i + k就相当于变成了0,而i为4,即指向了要轮转的数字,直到i越界,整个轮转数组的结果就到了tmp数组里边,最后将tmp数组里边的值赋值到nums数组就可以了。

代码实现:

cpp 复制代码
void rotate(int* nums, int numsSize, int k) {
    //创建新数组
    int tmp[numsSize];
    //向右轮转k次
    for(int i = 0;i < numsSize;i++)
    {
        int j = (i + k) % numsSize;
        tmp[j] = nums[i];
    }
    //拷贝
    for(int i = 0;i < numsSize;i++)
    {
        nums[i] = tmp[i];
    }
}

易得时间复杂度为O(N),空间复杂度为O(N),这就是典型的空间换时间操作。

解法三:逆置

先将前numsSize - k个数据逆置,再将后k个数据逆置,最后将数组整体逆置,就得到了我们想要的最终数组。时间复杂度为O(N),空间复杂度为O(1)。

我们只需要写一个逆置reverese函数,它的两个形参就代表要逆置区间的左右下标,但是我们要考虑到边界情况,轮转次数k可能大于数组的长度,这就导致数组发生越界情况,我们只需要将轮转次数k % numsSize就可以避免这种情况了。

关于时间复杂度还想解释一下,reverse函数里边left和right合力便利了数组一遍,单独看一个变量的话其实最差的情况就便利了N / 2个数组,因为一个while循环里边,left和right同时在移动,那根据大O表示法,时间复杂度就为O(N)。

代码实现

cpp 复制代码
void reverse(int* num,int left,int right)
{
    while(left < right)
    {
        int tmp = num[left];
        num[left] = num[right];
        num[right] = tmp;
        left++;
        right--;
    }
}

void rotate(int* nums, int numsSize, int k) {
    k = k % numsSize;
    reverse(nums,0,numsSize - k - 1);
    reverse(nums,numsSize - k,numsSize - 1);
    reverse(nums,0,numsSize - 1);
}
相关推荐
立志成为大牛的小牛2 小时前
数据结构——四十、折半查找(王道408)
数据结构·学习·程序人生·考研·算法
程序员东岸3 小时前
数据结构精讲:从栈的定义到链式实现,再到LeetCode实战
c语言·数据结构·leetcode
laocooon5238578864 小时前
大数的阶乘 C语言
java·数据结构·算法
WBluuue13 小时前
数据结构与算法:树上倍增与LCA
数据结构·c++·算法
lkbhua莱克瓦2414 小时前
Java基础——集合进阶用到的数据结构知识点1
java·数据结构·笔记·github
杨福瑞14 小时前
数据结构:单链表(2)
c语言·开发语言·数据结构
王璐WL15 小时前
【数据结构】单链表及单链表的实现
数据结构
z1874610300315 小时前
list(带头双向循环链表)
数据结构·c++·链表
T.Ree.17 小时前
cpp_list
开发语言·数据结构·c++·list