数据结构 算法的时间复杂度和空间复杂度

  • 目录

    算法效率

    时间复杂度

    空间复杂度

    常见的时间复杂度以及复杂度oj练习

1.算法效率

1.1如何衡量一个算法的好坏

如何衡量一个算法的好坏呢?比如对于以下斐波那契数列:

long long Fib(int N)
{
   if(N < 3)
     return 1;

   return Fib(N-1) + Fib(N-2);
}

波那契数列的递归实现方式非常简洁,但是简洁一定好吗如何衡量其好与坏?

1.2算法的复杂度

算法在编写成可执行程序后,运行时需要耗费时间资源和空间(内存)资源。因此衡量一个算法的好坏,一般是从时间和空间两个维度来衡量的,即时间复杂度和空间复杂度。

                                                            

时间复杂度主要衡量一个算法的运行快慢,而空间复杂度主要是衡量一个算法运行所需要的额外空间。在计算机发展的早期,计算机的存储量很小。所以对空间复杂度很是在乎。但是经过计算机行业的迅速发展,计算机的存储量已经达到了很高的程度。所以我们如今已经不需要在特别关注一个算法的空间复杂度

1.3复杂度在校招中的考察

2.时间复杂度

2.1时间复杂度的概念

时间复杂度的定义:在计算机科学中,算法的时间复杂度是一个函数,它定量描述了该算法的运行时间。一个算法执行所耗费的时间,从理论上来说,是不能算出来的,只有你把你的程序续放在机器上跑起来,才能知道。但是我们需要每个算法都上机测试吗?是可以都上机测试,但是这很麻烦,所以才有了时间复杂度这个分析方式。一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度。

                

即:找到某条基本语句与问题规模N之间的数学表达式,就是算出了该算法的时间复杂度。

// 请计算一下Func1中++count语句总共执行了多少次?
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
", count);
}

  

Func1 执行的基本操作次数 :

F(N) = N *N + 2 *N +10

  • N = 10       F(N) = 130
  • N = 100     F(N) = 10210
  • N = 1000   F(N) = 1002010

实际中我们计算时间复杂度时,我们其实并不一定要计算精确的执行次数,而只需要大概执行次数,那么这里我们使用大O的渐进表示法。

2.2大O的渐进表示法

大O符号(Big O notation) :是用于描述函数渐进行为的数学符号。

推导大O阶方法

  1. 用常数1取代运算时间中的所有加法常数。
  2. 在修改后的运行次数函数中,只保留最高阶项。
  3. 如果·最高阶项存在且不是1,则去除与这个项目相乘的常数。得到的就是大O阶。

使用大O的渐进表示法以后,Func1的时间复杂度为:

O(N*N)

  • N = 10           F(N) = 100
  • N = 100         F(N) = 10000
  • N = 1000       F(N) = 1000000

通过上面我们会发现大O的渐进表示法去掉了那些对结果影响不大的项,简洁明了的表示出了执行次数。

另外有些算法的时间复杂度存在最好、平均和最坏的情况:

最坏情况:任意输入规模的期望运行情况(上界)

平均情况:任意输入规模的期望运行次数

最好情况:任意输入规模的最小运行次数(下界)

例如:在一个长度为N数组中搜索一个数据x

最坏情况:1次找到

平均情况:N次找到

最好情况:N/2次找到

在实际中一般情况关注的是算法的最坏运行情况,所以数组中搜索数据时间复杂度为O(N)

2.3常见时间复杂度计算举例

实例1:

// 计算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
", count);
}

F(N)  = 2N + 10

时间复杂的   =  O(N)

实例2

// 计算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
", count);
}

时间复杂度                       

 不确定M和N的关系                                                                                  O(max(M,N))

O(M+N)

N远大于M

O(N)

M远大于N

O(M)

N和M差不多大

O(N)orO(M)  

实例3

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

时间复杂度       

O(1)

O(1)并不代表1次,而是常数次

实例4

// 计算strchr的时间复杂度?
const char * strchr ( const char * str, int character );

实例5

// 计算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;
    }
}

实例6

思路一   

异或   时间复杂度:  O(N)

int  missingNumber(int* unms, int  numSize)
{
    int x = 0;
    for(int i = 0; i < numsSize;  ++1)
    {
          x  ^= nums[i];
    }

    //[0,n]
    for(int i = 0;  i <=  numsSize;  ++i)
    {
          x  ^= i;
    }

    return x;
}

思路二   
int   missingNumber(int* nums,  int  numsSize)
{
   int  N  = numsSize;

   int  sum = ((0+N)*(N+1))/2;
   
   for(int i = 0 ; i < numsSize;  ++i)
   {
        sum  -= nums[i];
   }

   return  sum;
}

实例7:

// 计算BinarySearch的时间复杂度?
int BinarySearch(int* a, int n, int x)
{
     assert(a);
  
    int begin = 0;
    int end = n-1;
    // [begin, end]:begin和end是左闭右闭区间,因此有=号
    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;
}

实例8

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

  递归调用是加多次调用累       O(N)

// 计算阶乘递归Fac的时间复杂度?
long long Fac(size_t N)
{
     if(0 == N)
        return 1;
  
for(size_t i = 0; i<n; ++i)
{
     //....
}

     return Fac(N-1)*N;
}

实例8

// 计算斐波那契递归Fib的时间复杂度?
long long Fib(size_t N)
{
    if(N < 3)
       return 1;
  
    return Fib(N-1) + Fib(N-2);
}

3.空间复杂度

空间复杂度也是一个数学表达式,是对算法在运行过程中临时占用存储空间大小的量度。

空间复杂度不是程序占了多少bytes的空间,因为这个也也没太大意义,所以空间复杂度算的是变量个数。空间复杂度计算规则基本跟实践复杂度类似,也使用大O渐进表示法

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

 

实例1:
// 冒泡排序函数,对数组a进行排序,数组长度为n
void BubbleSort(int* a, int n)
{
    // 断言确保数组不为空
    assert(a);
    
    // 外层循环控制排序的轮数
    for (size_t end = n; end > 0; --end)
    {
        // 初始化交换标志为0,用于判断本轮是否发生了交换
        int exchange = 0;
        
        // 内层循环控制每一轮中的比较和交换操作
        for (size_t i = 1; i < end; ++i)
        {
            // 如果当前元素比后一个元素大,则交换它们的位置
            if (a[i-1] > a[i])
            {
                Swap(&a[i-1], &a[i]); // 交换函数,这里假设已经定义了Swap函数
                exchange = 1;       // 设置交换标志为1,表示发生了交换
            }
        }
    
        // 如果本轮没有发生交换,则数组已经有序,提前结束排序
        if (exchange == 0)
            break;
    }
}

冒泡排序的空间复杂度是O(1),  因为它是原地排序算法,不需要额外的存储空间

实例2      

// 计算斐波那契数列的前n项,并返回一个包含这些数的数组
long long* Fibonacci(size_t n)
{
    // 如果n为0,则返回空数组
    if(n==0)
        return NULL;
    
    // 为斐波那契数列分配内存空间,大小为n+1(因为数组下标从0开始)
    long long * fibArray = (long long *)malloc((n+1) * sizeof(long long));
    
    // 初始化数组的前两个元素,即斐波那契数列的前两项
    fibArray[0] = 0;    // 第一项为0
    fibArray[1] = 1;    // 第二项为1
    
    // 计算斐波那契数列的其他项
    for (int i = 2; i <= n ; ++i)
    {
        // 根据斐波那契数列的定义,当前项是前两项的和
        fibArray[i] = fibArray[i - 1] + fibArray [i - 2];
    }
    
    // 返回包含斐波那契数列的数组
    return fibArray;
}

动态开辟了大小为(n+1)数组存储斐波那契数列的项。数组的大小与出入参数n之间相关

所以空间复杂度为O(n)

实例3

// 计算阶乘的递归函数
long long Fac(size_t N)
{
    // 如果N为0,则返回1(0的阶乘是定义为1的)
    if(N == 0)
        return 1;
    
    // 否则,递归调用Fac(N-1),并将结果乘以N
    return Fac(N-1)*N;
}

  • 递归函数本身并不直接占用额外的存储空间(除了栈帧),因为它是在调用栈上逐层返回的。
  • 但是,由于递归深度与输入参数 N 有关,因此递归调用的栈空间复杂度是 O(N)。这是因为递归调用的次数大致为 N 次,每次调用都会在调用栈上创建一个新的栈帧。
  • 如果只考虑递归函数本身而不考虑其他因素(如外部变量或输入参数),则该递归函数的空间复杂度为 O(N)。

  4.常见复杂度对比

一般算法常见的复杂度如下:

5201314 O(1) 常数阶
3n+4 O(n) 线性阶
3n^2+4n+8 O(n^2) 平方阶
3log(2)n+4 O(logn) 对数阶
2n+3nlog(2)n+14 O(nlogn) nlogn阶
n^3+2nlog(2)n+16 O(n^3) 立方阶
2^n O(2^n) 指数阶

复杂度oj练习

 旋转数组OJ链接:https://leetcode-cn.com/problems/rotate-array/

// 反转数组中指定范围的元素
void reverse(int*a, int left, int right)
{
   // 当左指针小于右指针时,执行反转操作
   while(left < right)
   {
       // 临时变量存储左指针指向的元素
       int tmp = a[left];
       
       // 右指针指向的元素赋值给左指针指向的位置
       a[left] = a[right];
       
       // 左指针指向的元素(已存储在tmp中)赋值给右指针指向的位置
       a[right] = tmp;
       
       // 左指针右移一位
       ++left;
       
       // 右指针左移一位
       --right;
   }
}

// 将数组旋转k个位置
void rotate(int* nums, int numsSize, int k)
{
   // 对k进行取模操作,保证k的值在0到numsSize-1之间
   k %= numsSize;
   
   // 反转数组的前半部分(从索引0到numsSize-k-1)
   reverse(nums, 0, numsSize - k - 1);
   
   // 反转数组的后半部分(从索引numsSize-k到最后一个元素)
   reverse(nums, numsSize - k, numsSize - 1);
   
   // 反转整个数组(从索引0到最后一个元素)
   reverse(nums, 0, numsSize - 1);
}

// 定义rotate函数,用于将数组nums进行k位的旋转
void rotate(int* nums, int numsSize, int k)
 {
    // 保证k在合理的范围内,即0到numsSize-1
    k %= numsSize;

    // 定义一个临时数组tmp,大小为numsSize
    int tmp[numsSize];
    
    // 初始化j为k,用于拷贝后k个元素到tmp数组的起始位置
    int j = k;
    
    // 拷贝前n-k个元素到tmp数组
    for (int i = 0; i < numsSize - k; ++i) 
    {
        tmp[j++] = nums[i];
    }
    
    // 拷贝后k个元素到tmp数组的起始位置,即覆盖前面的拷贝
    j = 0;
    for (int i = numsSize - k; i < numsSize; ++i) 
    {
        tmp[j++] = nums[i];
    }
    
    // 将tmp数组中的元素拷贝回nums数组,完成旋转操作
    for (int i = 0; i < numsSize; ++i) 
    {
        nums[i] = tmp[i];
    }
}

int removeElement(int* nums, int numsSize, int val) 
{
    int src = 0; // 源指针,用于遍历原始数组
    int dst = 0; // 目标指针,指向新数组的当前位置
    
    while (src < numsSize)
    {
        if (nums[src] != val) 
        {
          nums[dst++] = nums[src++]; // 如果当前元素不等于val,则将其拷贝到目标位置,并移动指针
        }
        else 
        {
            src++; // 如果当前元素等于val,则跳过该元素,只移动源指针
        }
    }
    
    return dst; // 返回新数组的大小(不包括被删除的元素)
}

这段代码的功能是从给定的数组中移除所有与指定值val相等的元素,并返回新数组的大小。它通过双指针的方法实现:一个指针(src)用于遍历原始数组,另一个指针(dst)指向新数组的当前位置。当遇到与val不等的元素时,将其拷贝到新数组的当前位置,并同时移动两个指针;当遇到与val相等的元素时,只移动源指针。最后返回新数组的大小。