数据结构——复杂度

目录

数据结构前言

数据结构

算法

算法效率

时间复杂度

大O的渐进表示法

示例1

示例2

示例3

示例4

示例5

示例6

示例7

空间复杂度

示例1

示例2

示例3

示例4

常见复杂度对比

旋转数组

优化1

优化2


这一篇文章我们就开始数据结构知识的学习!

数据结构前言

数据结构

我们学习计算机相关内容的时候,都听说过数据结构,那么什么是数据结构呢?
数据结构(Data Structure)是 计算机存储、组织数据的⽅式 ,指 相互之间存在⼀种或多种特定关系的数据元素的集合 。
没有⼀种单⼀的数据结构对所有⽤途都有⽤,所以我们要学各式各样的数据结构, 如:线性表、树、图、哈希等。

算法

算法(Algorithm):
定义 良好的计算过程 ,他 取⼀个或⼀组的值为输⼊ ,并 产⽣出⼀个或⼀组值作为输出 。简单来说 算法就是⼀系列的计算步骤,⽤来将输⼊数据转化成输出结果 。

算法效率

既然是一系列的计算步骤,那么如何衡量⼀个算法的好坏呢?

算法在编写成可执⾏程序后,运⾏时需要 耗费时间资源和空间(内存)资源 。
因此衡量⼀个算法的好 坏,⼀般是从 时间 和 空间 两个维度来衡量的,即 时间复杂度 和 空间复杂度 。
那么这两个维度来衡量算法有什么区别呢?
时间复杂度 主要衡量⼀个算法的 运⾏快慢
空间复杂度 主要衡量⼀个算法 运⾏所需要的额外空间
在计算机发展的早期,计算机的存储容量很⼩,所以对空间复杂度很是在乎。但是经过计算机⾏业的 迅速发展,计算机的存储容量已经达到了很⾼的程度。所以我们如今已经不需要再特别关注⼀个算法 的空间复杂度。

时间复杂度

定义:在计算机科学中, 算法的时间复杂度是⼀个函数式T(N) ,它 定量描述了该算法的运⾏时间。

T(N)函数式计算的是程序的执⾏次数。
通过c语⾔编译链接的学习,算法程序被编译后⽣成⼆进制指令,程序运⾏,就是cpu执⾏这 些编译好的指令。那么我们通过程序代码或者理论思想计算出程序的执⾏次数的函数T(N),假设每 句指令执⾏时间基本⼀样(实际中有差别,但是微乎其微),那么 执⾏次数和运⾏时间就是等⽐正相关 , 这样也脱离了具体的编译运⾏环境。
执⾏次数就可以代表程序时间效率的优劣。 ⽐如解决⼀个问题的 算法a程序T(N) = N,算法b程序T(N) = N^2,那么算法a的效率⼀定优于算法b 时 间
复杂度是衡量程序的时间效率,那么为什么不去计算程序的运⾏时间呢?

  1. 因为程序运⾏时间和编译环境和运⾏机器的配置都有关系,⽐如同⼀个算法程序,⽤⼀个⽼编译 器进⾏编译和新编译器编译,在同样机器下运⾏时间不同。
  2. 同⼀个算法程序,⽤⼀个⽼低配置机器和新⾼配置机器,运⾏时间也不同。
  1. 并且时间只能程序写好后测试,不能写程序前通过理论思想计算评估。
    那么下面这一段代码中,++count语句一共执行了多少次呢?
cpp 复制代码
void Func1(int N)
{
  int count = 0;
  for (int i = 0; i < N ; ++ i)
   {
      for (int j = 0; j < N ; ++ j)
        {
           ++count;
        }
   }
  //外层循环:i------> 0  1  2  3  4  5  6  7 .................. N-1  N
  //内存循环:     N  N  N  N  N  N  N  N .....................N    0(++count每次外层循环进来执行N次)
  //++count执行了N*N次
  for (int k = 0; k < 2 * N ; ++ k)
   {
      ++count;
   }
  //++count执行了2N次
  int M = 10;
  while (M--)
   {
     ++count;
   }
   //++count执行了M(10)次
}

通过分析,我们可以知道++count语句执行了N*N+2*N+10次
T ( N ) = N * N + 2 ∗ N + 10
• N = 10 T(N) = 130
• N = 100 T(N) = 10210
• N = 1000 T(N) = 1002010
通过对N取值分析,对结果影响最⼤的⼀项是N*N
在实际中我们 计算时间复杂度 时,计算的 不是程序的精确的执⾏次数 ,精确执⾏次数计算起来比较 ⿇烦(不同的⼀句程序代码,编译出的指令条数都是不⼀样的),计算出精确的执⾏次数意义也不⼤, 因为我们计算时间复杂度只是想⽐较算法程序的增⻓量级,也就是当N不断变⼤时T(N)的差别。
通过上面的分析,我 们看到当N不断变⼤(趋向于无穷大时)时, 常数和低阶项对结果的影响很⼩ ,所以我们只需要计算程序能代表增⻓量 级的⼤概执⾏次数。

大O的渐进表示法

复杂度的表示通常使⽤⼤O的渐进表示法。
⼤O符号(Big O notation):是⽤于描述函数渐进⾏为的数学符号
我们接下来一起来看看 推导⼤O阶规则:

  1. 时间复杂度函数式 T(N) 中, 只保留最⾼阶项 ,去掉那些低阶项,因为当 N 不断变⼤时, 低阶项对结果影响越来越⼩,当 N ⽆穷⼤时,就可以忽略不计低阶项。
  2. 如果最 ⾼阶项存在且不是1 ,则 去除这个项⽬的常数系数 ,因为当 N 不断变⼤,这个系数
    对结果影响越来越⼩,当 N ⽆穷⼤时,就可以忽略不计了。
  3. T(N) 中如果没有 N 相关的项⽬, 只有常数项 ,⽤ 常数1取代所有加法常数
    (在我们看来一个很大的常数,比如100000000000,在计算机看来也是影响很小的,与常数1的效果差不多)

示例1

cpp 复制代码
// 计算Func2的时间复杂度?
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);
}

Func2执⾏的基本操作次数:T ( N ) = 2 N + 10
根据推导规则得出Func2的时间复杂度为: O ( N )
(保留高阶项2N, 2作为常数系数可以省略,所以时间复杂度为O(N)

示例2

cpp 复制代码
// 计算Func3的时间复杂度?
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执⾏的基本操作次数:T ( N ) = M+N
根据推导规则得出:
如果M>>N(M远远大于N) *,*Func3的时间复杂度为:O(M)
如果M<<N(M远远小于N) Func3的时间复杂度为:O(N)
如果M==N(M近似等于N) Func3的时间复杂度为
:O
(M+N )

示例3

cpp 复制代码
// 计算Func4的时间复杂度?
void Func4(int N)
{
     int count = 0;
     for (int k = 0; k < 100; ++ k)
        {
            ++count;
        }
     printf("%d\n", count);
}

Func4执⾏的基本操作次数:T(N) = 100

根据推导规则得出:Func3的时间复杂度为:O(1)

(100为常数项,用常数1来取代常数)

示例4

cpp 复制代码
// 计算strchr的时间复杂度?
const char * strchr ( const char* str, int character)
{
    const char* p_begin = str;
    while (*p_begin != character)
    {
        if (*p_begin == '\0')
        return NULL;
        p_begin++;
    }
    return p_begin;
}

这一段代码是在一个字符串中找到我们想要的字符
strchr执⾏的基本操作次数:
(1)若要查找的字符在字符串第⼀个位置,则: T ( N ) = 1
(2)若要查找的字符在字符串最后的⼀个位置, 则: T ( N ) = N
(3)若要查找的字符在字符串中间位置,则: T ( N ) = N / 2
strchr的时间复杂度分为:
最好情况: O (1)
最坏情况: O ( N )
平均情况: O ( N ) (1/2高阶项系数可以忽略不计)

通过上⾯我们会发现,有些算法的时间复杂度存在最好、平均和最坏情况。
最坏情况:任意输⼊规模的 最⼤运⾏次数(上界)
平均情况:任意输⼊规模的 期望运⾏次数
最好情况:任意输⼊规模的 最⼩运⾏次数(下界)
⼤O的渐进表⽰法 在实际中⼀般情况 关注的是算法的上界 ,也就是 最坏运⾏情况 。

示例5

cpp 复制代码
// 计算BubbleSort的时间复杂度?
void BubbleSort(int* a, int n)
{
    assert(a);
    for (size_t j = 0; j < n - 1; j++)
    {
        int exchange = 0;
        for (size_t i = 0; i < n - 1 - j; i++)
        {
            if (a[i + 1] < a[i])
            {
                Swap(&a[i + 1], &a[i]);
                exchange = 1;
            }
        }
        if (exchange == 0)
            break;
    }
}

外层循环:j------> 0 1 2 3............n-3 n-2 n-1
内层循环次数: n-1 n-2 n-3 n-4......... 2 1 0

1)若数组有序,则:T ( N ) = N - 1
2)若数组有序且为降序,则:T ( N ) = N ∗ ( N - 1) /2
3)若要查找的字符在字符串中间位置,则:( 首项+末项)*项数/2
因此:BubbleSort的 时间复杂度取最差情况 为: O ( N^ 2 )==O(N*N)

示例6

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

当n=2时,执⾏次数为1
当n=4时,执⾏次数为2
当n=16时,执⾏次数为4
假设执⾏次数为 x ,则 2*^* x = n , 执⾏次数: x = log2( n ) ------2为底数
因此:func5的时间复杂度取最差情况为*:* O (log 2(n ) )
当 n接近⽆穷⼤ 时, 底数的⼤⼩对结果影响不⼤ 。
因此,⼀般情况下不管底数是多少都可以省略不 写,即可以表⽰为 log n

示例7

cpp 复制代码
// 计算阶乘递归Fac的时间复杂度?
 int f ( unsigned int n )
 {
    if (n == 0 || n==1) 
      return 1;
    else 
      return n * f(n-1);
  }

调⽤⼀次Fac函数的时间复杂度为 O(1), 在Fac函数中,存在 (n-1)次递归调⽤Fac函数
因此: 阶乘递归的时间复杂度为:单次的递归的时间复杂度*递归次数 ==O(1)*(N-1)==O(N)
保留高阶项,省略常数项

空间复杂度

空间复杂度也是⼀个数学表达式,是对⼀个算法在运⾏过程中因为算法的需要额外临时开辟的空间。空间复杂度不是程序占⽤了多少bytes的空间,因为 常规情况每个对象⼤⼩差异不会很⼤ ,所以 空间复杂度算的是变量的个数 。 空间复杂度计算规则基本跟实践复杂度类似,也使⽤⼤O渐进表⽰法。

注意:函数运⾏时所需要的栈空间(存储参数、局部变量、⼀些寄存器信息等)在编译期间就已经确定好了,因 此空间复杂度主要通过函数在 运⾏时候显式申请的额外空间 来确定

示例1

cpp 复制代码
// 计算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额外申请的空间有exchange等有限个局部变量,使⽤了常数个额外空间

因此空间复杂度为 O(1),1代表的是常数。

示例2

cpp 复制代码
// 计算阶乘递归Fac的空间复杂度?
long long Fac(size_t N)
{
    if(N == 0)
        return 1;
    return Fac(N-1)*N;
}

Fac递归调⽤了N次 ,额外开辟了N个函数栈帧,每个栈帧使⽤了常数个空间 (空间复杂度O(1) )
因此空间复杂度为:单次的递归的空间复杂度*递归次数==O(1)*N == O ( N )

示例3

通过动态内存申请内容也涉及到空间复杂度的计算,比如下面这个代码

cpp 复制代码
int func(int n)
{
   int arr[n]=malloc(sizeof(int)*n);
}

这里使用malloc向内存申请了n个整型大小的空间,它的空间复杂度是O(n)

示例4

cpp 复制代码
 //求fun的空间复杂度?
int** fun(int n)
 {
    int ** s = (int **)malloc(n * sizeof(int *));
    while(n--)
      s[n] = (int *)malloc(n * sizeof(int));
    return s;
 }

此处开辟的是一个二维数组

数组有n行

每行分别有1,2,3,...n列

所以是n(n + 1)/2个元素空间,空间复杂度为O(n^2)

常见复杂度对比


我们可以看到,O(n^2),O(2^n),O(n!),当n的值越来越大时,时间复杂度变化得越来越快,我们当然是希望时间复杂度变化得慢一些,所以我们会通过时间复杂度来优化代码。
比如下面的旋转数组的例子

旋转数组

接下来,我们来看看下面的旋转数组问题

在这里,我们很容易想到使用两个循环,把最后一个数据保存下来,前面的数据依次往后面移动,再把最后一个数据放在第一个,可以得到下面的代码:

cpp 复制代码
//旋转数组
#include<stdio.h>
void rorate(int* arr, int sz, int n)
{
	while (n--)
	{
		int end = arr[sz - 1];//最后一个数据保存下来
		for (int i = sz - 1; i > 0; i--)
			arr[i] = arr[i - 1];
		arr[0] = end;//最后一个数据放在第一位
	}
}
void print_arr(int* arr, int sz)
{
	for (int i = 0; i < sz; i++)
		printf("%d ", arr[i]);
}
int main()
{
	int arr[] = { 1,2,3,4,5,6,7 };
	int sz = sizeof(arr) / sizeof(arr[0]);
	int n = 0;
	print_arr(arr, sz);
	printf("\n请输入逆置数据个数:");
	scanf("%d", &n);
	rorate(arr, sz, n);
	print_arr(arr, sz);
	return 0;
}


在了解复杂度的基础上,我们可以计算出函数rorate的时间复杂度是O(N^2),当N越来越大时,时间复杂度也会很大,有没有什么方法可以优化呢?

优化1

有一种思路是我们可以 申请新数组空间 ,先将 后k个数据放到新数组 中,再将 剩下的数据挪到新数组 中 ,最后再把新数组元素赋值到原数组中。

cpp 复制代码
#include<stdio.h>
void rorate(int* arr, int sz, int n)
{
	int newArr[7];//VS不支持变长数组,这里直接创建一个7个整型元素的新数组
	int i = 0;
	for (i = 0; i < sz; i++)
	{
		newArr[(i + n) % sz] = arr[i];
	}
	for (i = 0; i < sz; i++)
	{
		arr[i] = newArr[i];
	}
}
void print_arr(int* arr, int sz)
{
	for (int i = 0; i < sz; i++)
		printf("%d ", arr[i]);
}
int main()
{
	int arr[] = { 1,2,3,4,5,6,7 };
	int sz = sizeof(arr) / sizeof(arr[0]);
	int n = 0;
	print_arr(arr, sz);
	printf("\n请输入旋转数据个数:");
	scanf("%d", &n);
	rorate(arr, sz, n);
	print_arr(arr, sz);
	return 0;
}


这个方法中,函数rorate的时间复杂度就是O(n),系数2忽略不计
这里创建了一个数组,事实上是用 空间换时间来达到了优化

优化2

还有一种更加巧妙的方式,采用了多次逆置的方式,这种方法不容易想到
我们以arr[7]轮转3个数字为例
• 前n-k个逆置: 4 3 2 1 5 6 7
• 后k个逆置 :4 3 2 1 7 6 5
• 整体逆置 : 5 6 7 1 2 3 4

cpp 复制代码
#include<stdio.h>
void reverse(int* arr, int begin, int end)
{
	while (begin < end)
	{
		int temp = arr[begin];
		arr[begin] = arr[end];
		arr[end] = temp;
		begin++;
		end--;
	}
}
void rorate(int* arr, int sz, int n)
{
	n = n % sz;
	reverse(arr, 0, sz - 1 - n);
	reverse(arr, sz - n, sz - 1);
	reverse(arr, 0, sz - 1);
}
void print_arr(int* arr, int sz)
{
	for (int i = 0; i < sz; i++)
		printf("%d ", arr[i]);
}
int main()
{
	int arr[] = { 1,2,3,4,5,6,7 };
	int sz = sizeof(arr) / sizeof(arr[0]);
	int n = 0;
	print_arr(arr, sz);
	printf("\n请输入旋转数据个数:");
	scanf("%d", &n);
	rorate(arr, sz, n);
	print_arr(arr, sz);
	return 0;
}
相关推荐
pianmian11 小时前
python数据结构基础(7)
数据结构·算法
好奇龙猫3 小时前
【学习AI-相关路程-mnist手写数字分类-win-硬件:windows-自我学习AI-实验步骤-全连接神经网络(BPnetwork)-操作流程(3) 】
人工智能·算法
sp_fyf_20244 小时前
计算机前沿技术-人工智能算法-大语言模型-最新研究进展-2024-11-01
人工智能·深度学习·神经网络·算法·机器学习·语言模型·数据挖掘
ChoSeitaku4 小时前
链表交集相关算法题|AB链表公共元素生成链表C|AB链表交集存放于A|连续子序列|相交链表求交点位置(C)
数据结构·考研·链表
偷心编程4 小时前
双向链表专题
数据结构
香菜大丸4 小时前
链表的归并排序
数据结构·算法·链表
jrrz08284 小时前
LeetCode 热题100(七)【链表】(1)
数据结构·c++·算法·leetcode·链表
oliveira-time4 小时前
golang学习2
算法
@小博的博客4 小时前
C++初阶学习第十弹——深入讲解vector的迭代器失效
数据结构·c++·学习
南宫生5 小时前
贪心算法习题其四【力扣】【算法学习day.21】
学习·算法·leetcode·链表·贪心算法