时间复杂度与空间复杂度-python

160 阅读9分钟

时间效率和空间效率是算法效率分析的两种方式。其中时间效率被称为时间复杂度,空间效率被称为空间复杂度

那我们常见的 O(n)O(n)O(n2)O(n^2),又是什么,以及如何定义的呢?

指的是计算时间复杂度与空间复杂度的一种规定的方法——>大 O 的渐进表示法规则,让我们来学习一下这个规则~

1. 大 O 的渐进表示法规则

时间复杂度和空间复杂度一般都是用大 O 的渐进表示法进行表示,规则如下:

  • 所有常数都用常数 1 表示;
  • 只保留最高阶项;
  • 如果最高阶项存在且系数不是 1,则去除这个项的系数,得到的结果就是大 O 阶。

接下来介绍几个关于时间复杂度和空间复杂度的例子让我们熟悉用 大 O的渐进法进行表示。

2. 时间复杂度

2.1 定义

一个算法所花费的时间与其中语句的执行次数成正比例,算法中的基本操作的执行次数,为算法的时间复杂度

2.2 示例一

2.2.1 代码

#计算 Func1 的时间复杂度---Python
def Func1(N) -> int:
  count=0
  for i in range(2*N):
    for j in range(2*N):
      count+=1
      j+=1
    i+=1
  for k in range(2*N):
    count+=1
    k+=1
  print('count')
  return count       #42

2.2.2 详解

Func1 函数执行了一个嵌套的 for 循环,共执行了 4N24N^2 次(2N2N=4N22*N*2*N=4*N^2),又执行了一个单独的 for 循环,共执行 2N2N 次,所以 Func1函数的时间复杂度为:T(N)=4N2+2NT(N)=4*N^2+2*N

那我们再次套用刚刚说的 大 O 的渐进表示法 规则,表示函数 Func1函数的时间复杂度为:O(n2)O(n^2)

2.3 示例二

2.3.2 代码

#计算 Func2 的时间复杂度---Python
def Func2(N) ->int:
  count=0
  for i in range(100):
    count+=1
    i+=1
  
  print(count)

Func2(2)  #100

2.3.2 详解

Func2 函数内部执行了一个 for 循环(共 100 次),Func2 函数内语句的执行次数不会随着传入变量 N 的改变而改变,即执行的次数为常数次。 Func2 函数的时间复杂度为 T(N)=100。

同样,根据 大 O 的渐进表示法规则进行表示,所有常数用常数 1 表示,所以,Func2函数的时间复杂度为 O(1)。

PS:**在刷力扣题时,题目要求时间复杂度为 O(1),并不是要求函数内部不能包含有循环,而是要求循环的次数为常数次。 **

2.4 示例三

计算二分查找的时间复杂度,二分查找有两点需要注意的:

  1. 必须是有序数组;
  2. target 元素可能找不到。

2.4.1 代码

下面展示了Python实现二分查找的两种方法:

  • 第一种:前闭后闭
#示例三:计算二分查找函数的时间复杂度---Python
#在一个有序列表中查找目标值的索引/位置
def BinarySearch1(list,target):
  left=0
  right=len(list)-1
  #在list[left……right]里查找 target,此时是左闭右闭
  while left <=right:
    mid=left+(right-left)//2
    #mid=(right+left)//2   #取中间元素的索引,//整数除法,/浮点除法,%取余
    if list[mid]==target:
      return mid
    elif target > list[mid]:
      left=mid+1  #到[mid+1……right]里查找
    else:
      right=mid-1 #到 [left……mid-1] 里查找
  return -1 #未找到 target 元素
  • 另外一种:前闭后开
#---Python
def BinarySearch2(list,target):
  left=0
  right=len(list)
  #在 list[left……right) 里查找 target,此时是左闭右开
  while left<right:
    mid=(right-left)//2+left  #防止上面的写法整型溢出
    if list[mid]==target:
      return mid
    elif list[mid]<target:
      left=mid+1  #到 list[mid+1……right) 里面进行查找
    else:
      right=mid #到 list[left……mid) 里面进行查找
  return -1   #未找到 target 元素

求中位数这里有一点需要注意,一般写为:mid=(left+right)//2 比较好理解,但是我们更常见的是写为:mid=left+(right-left)//2 或者mid=left+((right-left)>>1)

原因:代码为 left+right 时,当 left 和 right 都很大的时候,可能会造成越界。

  • >> 是右移运算符,右移一位相当于除 2,右移 n 位相当于除以 2 的 n 次方;mid=(left+right)>>1等价于mid=(left+right)/2

因为不管是什么值数据类型,底层都是有字节限制的,所以要是 left+right 造成位数的溢出,mid结果不就出错了嘛。

虽然说 python3 自动转换整数和长整数不需要考虑溢出,这么写有些多余,
可是算法并不局限于某一种语言,而是一种思想,所以以后还是要具备这种思想。

2.4.2 详解

感觉第一种的终止条件比较好理解,以此代码为例说一下二分法查找的时间复杂度

  • 代码分析

用二分法查找数据时,查找一次后可以筛选掉一半的数据,经过一次次的筛选,最后会使得待查数据只剩一个,那么我们查找的次数就是 While 循环执行的次数。

  • 从数学的角度理解

设数据个数为 N,一次查找筛选掉一半的数据,即还剩 N/2 个数据,经过一次次的筛选,数据最后剩下1个,那么查找的次数可以理解为 N 除以若干个 2,最后得 1,那么 While 循环执行的次数就是 N 除以 2 的次数,那 N 除以了多少次 2 最终等于 1 即是我们需要计算的值,也是 while 循环执行的次数。

N/2/2/2/=1N/2/2/2/……=1

设 N 除以 x 个 2,最终等于 1,那么

N=2xN=2^x

由这个式子求 x :两边同时取 2 的对数,得 While 循环执行得次数,即 x=logNx=logN。时间复杂度:T(N)=logNT(N)=logN

用 大 O 的渐进表示法表示二分查找函数的时间复杂度为:O(logN)O(logN)

注:表示时间和空间复杂度时,log 表示以 2 为底的对数。

2.5 示例四

求解 斐波那契函数的时间复杂度

让我们先简单来了解一下斐波那契数列算法(又称:Fibonacci 数列)

斐波那契数列又称为"兔子数列"、"黄金分割数列",对于一个数列,从第 3 项开始,每项都是前面两项之和。其中第 0 项为 0,第 1 项为 1,第二项为 1。

示例:1,1,2,3,5,8,13,21,34,55,89,144,233,377,610 ……

用数学表达式:

F(n)=F(n2)+F(n1)F(n)=F(n-2)+F(n-1)
F(1)=1F(1)=1
F(2)=1F(2)=1

或者:

F(n)=F(n2)+F(n1)F(n)=F(n-2)+F(n-1)
F(0)=0F(0)=0
F(1)=1F(1)=1

2.5.1 代码

实现斐波那契数列的方法有很多,用最常见的按照定义递归方法举例:

#求斐波那契函数---Python
def Fibonacci2(N) ->int:
  if N<0:
    raise ValueError("输入的值不合法!")

  if N<=1:
    return N
  
  return Fibonacci2(N-1)+Fibonacci2(N-2)

2.5.2 详解

利用定义递归求解斐波那契数时,通过求解这个数的前面两个斐波那契数,相加得出。设要求解的为第N个斐波那契数,那求一下递归的次数(也即代码的时间复杂度):

个数式子
202^0F(N)
212^1F(N-1)、F(N-2)
222^2F(N-2)、 F(N-3)、F(N-3)、F(N-4)、
232^3F(N-3)、F(N-4)、F(N-4)、F(N-5)、F(N-4)、F(N-5)、F(N-5)、F(N-6)
22^{…}……
2N22^{N-2}F(2) ……
2N12^{N-1}F(1) ……

也可以用下面的正三角图表示递归次数:

image.png

总共调用斐波那契函数的次数为:

20+21++2N1=2N12^0+2^1+ … +2^{N-1}=2^N-1
Sn=anqa1q1=a1×(qn1)q1S_n=\frac{a_{n}q-a_1}{q-1}=\frac{a_1\times(q^n-1)}{q-1}

用大 O 的渐进表示法表示斐波那契函数的时间复杂度为:O(2N)O(2^N)

注: 递归算法的时间复杂度=递归的次数 * 每次递归函数中的次数。

3. 空间复杂度

3.1 定义

空间复杂度是对一个算法在运行过程中临时占用存储空间大小的量度。空间复杂度不是程序占用了多少字节的空间,因为这个也没太大意义,所以空间复杂度算的是变量的个数。空间复杂度计算规则基本跟时间复杂度类似,也使用大 O 渐进表示法

3.2 示例一

求解冒泡排序的空间复杂度

此处摘抄一下冒泡排序思想及步骤:

冒泡排序的思想

Bubble Sort:从第一个(或最后一个)元素开始,重复地走访要排序的数列,一次只能比较相邻的两个元素,如果这两个元素顺序错误,就把他们交换位置,否则,继续向前(向后)比较。

直到没有再需要交换的元素,数列遍历结束,此时,数列排序完成。

在这个排序过程中,元素值越小则会经由交换慢慢“浮出”到数列顶端。因此被称为“冒泡排序”

算法步骤

  1. 比较相邻的元素。如果第一个比第二个大,就交换位置,值大的放后面。
  2. 对每一对相邻的元素进行比较,从第一对到结尾的最后一对。这一轮结束后,最后的元素将是最大的数。
  3. 针对所有元素重复步骤1、2,除了最后一个。
  4. 持续对每次越来越少的元素重复1,2,3,直到没有一个数字需要进行比较。

image.png

这样的话,当需要排序的数据已经是正序的时候,最快;当输入的数据全部是反序的时候,最慢

3.2.1 代码

#计算冒泡排序的空间复杂度---Python
def bubblesort(arr):
  #遍历的次数、重复的次数,因此最后一个元素不需要再和后面的进行比较:从第一个元素遍历到倒数第二个元素
  for i in range(1,len(arr)):
    for j in range(0,len(arr)-i):
      #从第一对元素开始比较,因为每次遍历,最大的元素都会后置到正确的位置,即遍历的次数就是后面不用成对比较的次数。
      if arr[j]>arr[j+1]:
        #将元素值大的交换到后面去
        arr[j],arr[j+1]=arr[j+1],arr[j]
  return arr

3.2.2 详解

空间复杂度算的是变量的个数

在冒泡排序中,使用了常数个变量,即常熟个额外空间,所以用大 O 的渐进表示法表示冒泡排序函数的空间复杂度为 O(1)。

3.3 示例二

求解阶乘递归函数的空间复杂度

用数学表达式求一个数的阶乘:

N=1N=1

N!=1N!=1

N1N\ge1 时:

N!=N×(N1)×(N2)××2×1N!=N\times(N-1)\times(N-2)\times\cdots\times2\times1

递归函数:在一个函数体内调用该函数本身。

  1. 组成部分:递归调用与递归终止条件。
  2. 缺点:占用内存多、效率低下。
  3. 优点:思路和代码简单。

3.3.1 代码

#示例:阶乘递归函数---Python
def fun4(N) ->int:
  if N==1:
    return 1
  return fun4(N-1)*N

3.3.2 详解

首先明确递归函数的调用过程:

递归函数的调用过程:1. 每递归调用一次函数,都会在栈内分配一个栈帧。2. 每次执行完一次函数,都会释放相应的空间。

当使用上述代码求 N 的阶乘时, 会依次调用 fun4(N),fun4(N-1),……,fun4(2),fun4(1 ),相当于开辟了 N 个空间,所以空间复杂度为 O(N)。

小技巧: 递归算法的空间复杂度通常是递归的深度,或者说 递归了多少层。


下一节我们计算十种排序算法的时间复杂度和空间复杂度~


参考文章: