Lect3 时间复杂度/空间复杂度

Posted pdev

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Lect3 时间复杂度/空间复杂度相关的知识,希望对你有一定的参考价值。

 时间复杂度

复杂度可能对应的算法备注
O(1) 位运算 常数级复杂度,一般面试中不会有
O(logn) 二分法,倍增法,快速幂算法,辗转相除法  
O(n) 枚举法,双指针算法,单调栈算法,KMP算法,Rabin Karp,Manacher‘s Algorithm 又称作线性时间复杂度
O(nlogn) 快速排序,归并排序,堆排序  
O(n^2) 枚举法,动态规划,Dijkstra  
O(n^3) 枚举法,动态规划,Floyd  
O(2^n) 与组合有关的搜索问题  
O(n!) 与排列有关的搜索问题  

在面试中,经常会涉及到时间复杂度的计算。当你在对于一个问题给出一种解法之后,面试官常会进一步询问,是否有更优的方法。此时就是在问你是否有时间复杂度更小的方法(有的时候也要考虑空间复杂度更小的方法),这个时候需要你对常用的数据结构操作和算法的时间复杂度有清晰的认识,从而分析出可优化的部分,给出更优的算法。

例如,给定一个已经排序的数组,现在有多次询问,每次询问一个数字是否在这个数组中,返回True or False.

  • 方法1: 每次扫描一遍数组,查看是否存在。
    这个方法,每次查询的时间复杂度是: O(n)

  • 方法2:由于已经有序,可以使用二分查找的方法。
    这个方法,每次查询的时间复杂度是: O(logn)

  • 方法3:将数组中的数存入Hashset。
    这个方法,每次查询的时间复杂度是: O(1)

可以看到,上述的三种方法是递进的,时间复杂度越来越小。

在面试中还有很多常见常用的方法,他们的时间复杂度并不是固定的,都需要掌握其时间复杂度的分析,要能够根据算法过程自己推算出时间复杂度。

 

T 函数推导法
我们介绍一种时间复杂度的推导方法:T函数推导法
比如二分法。二分法是每次通过 O(1) 的时间将规模为 n 的问题降低为规模为 n/2 的问题。
这里我们用 T(n) 来表示规模为 n 的问题在该算法下的时间复杂度,那么我们得出推导公式:

T(n)=T(n/2)+O(1)

我们来逐个说明一下这个公式的意义。

首先 T 代表的是 Time Complexity, nnn 代表的是问题规模(二分法里就是数组的大小)。
那么 T(n) 代表的就是:求处理问题规模为n的数据的时间复杂度是多少。注意这里是一个问句,不是一个答案。
T(n) 根据算法的不同可以是 O(n) 也可以是 O(nlogn) 或任何值,而 O(n) 就是 O(n)。

然后 O 代表的是时间复杂度。O(1) 就意味着,你大概用一个 if 语句,或者简单的加加减减,就可以完成。O 在这里的意思是数量级约等于。在 O 的世界里,我们只考虑最高项是什么,不考虑系数和常数项。比如:

    O(100n)=O(n)
    O(n^2+n)=O(n^2)
    O(2^n+n^2+10)=O(2^n)


如何推导 T 函数
我们可以使用不断展开的方法进行推导:
T(n) = T(n/2) + O(1)
     = T(n/4) + O(1) + O(1)
     = T(n/8) + O(1) * 3
     = T(n/16) + O(1) * 4
     ...
     = T(1) + O(1) * logn
     = O(logn)

在时间复杂度的领域里,有如下的一些性质:

    T(1)=O(1) // 解决规模为1的问题通常时间复杂度为O(1)。这个不100%对,但是99.9%的情况下都是如此。
    k∗O(n)=O(kn)
    O(n)+O(m)=O(n+m)
    
上面的方法,是采用 T 函数展开的方法,将二分法的时间复杂度最终用 O(...) 来表示。


空间复杂度

类似于时间复杂度,空间复杂度就是衡量算法运行时所占用的临时存储空间的度量。也是一个与问题规模n有关的函数。

算法所占用的空间主要有三个方面:算法代码本身占用的空间、输入输出数据占用的空间、算法运行时临时占用的空间。
其中,代码本身和输入输出数据占用的空间不是算法空间复杂度考虑的范围内,空间复杂度只考虑运行时临时占用的空间,又称为算法的额外空间(Extra space)。

临时占用的空间包括:

    • 为参数列表中形参变量分配的空间
    • 为函数体中局部变量分配的空间
      (如果是递归函数,需要将上述两部分占用空间的和乘以递归的深度,这是堆栈空间)

在递归函数中,除了变量和数组所开辟的临时空间以外,还有一个空间我们需要纳入考虑,就是递归时占用的栈空间(Stack)。

递归函数需要保存当前的环境,以便在递归返回的时候能够还原之前的现场。因此递归的深度越深,所要占用的栈空间越大。当空间超出一定范围的时候就会出现程序爆栈(Stack Overflow)的情况。

很多博客文章中会写堆栈空间与递归调用的次数成正比,这个是不完全正确的,应该是与递归的深度成正比(此处只讨论单线程)。
因为递归在返回到上一层的时侯,就会将本层的空间释放,因此占用的栈空间不会比最深的一次调用所占用的空间更多。

 

在面试中,很多时候面试官给出的问题会附带一个“不能使用多余的空间”这样的要求。很多时候这是在要求你的空间复杂度只能是O(1)的,也就是你只能开几个辅助变量,而不能开大数组。

其他常见的还有,分析一下你的算法空间复杂度,寻找空间复杂度更优的解法等。
一般来说,算法占用的时间和空间会是两个互相平衡的元素,有的时候我们牺牲空间来换取时间,有的时候我们牺牲时间来换取空间。

在面试中常见算法的空间复杂度:

    快速排序: 最优:O(logn),最差:O(n)
    二分查找: O(1)
    最短路(Dijkstra)算法: O(V)  (V表示点集大小)

大部分的空间复杂度计算方法

累加下面两个部分的内容即是你代码的空间复杂度:

  1. 你的代码里开辟了多少新的空间(new 了多少新的内容出来)
  2. 你的递归深度 * 递归函数内部的参数和局部变量所占用的空间

以快速排序为例子

快速排序的思路如下:
1.选择一个基准元素,将原数组分为两部分,左边部分小于该元素,右边部分大于该元素。
2.分别递归处理左边和右边。

  • 最好情况:
    每次都能恰好将数组分成左右相同长度的两部分,需要的递归深度是:lgnlgnlgn,每次将数组分成两部分时,我们选择不使用辅助数组,在原数组上“就地”处理,所以每层的空间是O(1)
    因此总的复杂度是:O(logn)

  • 最差情况:
    每次都将数组分成长度差最大的两部分,即一边只有一个元素,其余的在另外一边,最大深度为:n, 因此空间复杂度为:O(n)

有些递归算法的空间复杂度是稳定的,不会退化,快排的递归深度与其每次选择的“基准值”有很大关系,因此存在退化的情况。

 


内存中的栈空间与堆空间

我们通常所说的内存空间,包含了两个部分:栈空间(Stack space)和堆空间(Heap space)

当一个程序在执行的时候,操作系统为了让进程可以使用一些固定的不被其他进程侵占的空间用于进行函数调用,递归等操作,会开辟一个固定大小的空间(比如 8M)给一个进程使用。这个空间不会太大,否则内存的利用率就很低。这个空间就是我们说的栈空间,Stack space。

我们通常所说的栈溢出(Stack Overflow)是指在函数调用,或者递归调用的时候,开辟了过多的内存,超过了操作系统余留的那个很小的固定空间导致的。那么哪些部分的空间会被纳入栈空间呢?栈空间主要包含如下几个部分:

  1. 函数的参数与返回值
  2. 函数的局部变量

我们来看下面的这段代码:
Java:

public int f(int n) 
    int[] nums = new int[n];
    int sum = 0;
    for (int i = 0; i < n; i++) 
        nums[i] = i;
        sum += i;
    
    return sum;

Python:

def f(n):
    nums = [0]*n  # 相当于Java中的new int[n]
    sum = 0
    for i in range(n):
        nums[i] = i
        sum += i
    return sum

C++:

int f(int n) 
   int *nums = new int[n];
    int sum = 0;
    for (int i = 0; i < n; i++) 
        nums[i] = i;
        sum += i;
    
    return sum;

根据我们的定义,参数 n,最后的函数返回值f,局部变量 sum 都很容易的可以确认是放在栈空间里的。那么主要的难点在 nums。

这里 nums 可以理解为两个部分:

  1. 一个名字叫做 nums 的局部变量,他存储了指向内存空间的一个地址(Reference),这个地址也就是 4 个字节(32位地址总线的计算机,地址大小为 4 字节)
  2. new 出来的,一共有 n 个位置的整数数组,int[n]。一共有 4 * n 个字节。

这里 nums 这个变量本身,是存储在栈空间的,因为他是一个局部变量。但是 nums 里存储的 n 个整数,是存储在堆空间里的,Heap space。他并不占用栈空间,并不会导致栈溢出。

在大多数的编程语言中,特别是 Java, Python 这样的语言中,万物皆对象,基本上每个变量都包含了变量自己和变量所指向的内存空间两个部分的逻辑含义。

来看这个例子:
Java:

public int[] copy(int[] nums) 
    int[] arr = new int[nums.length];
    for (int i = 0; i < nums.length; i++) 
        arr[i] = nums[i]
    
    return arr;


public void main() 
    int[] nums = new int[10];
    nums[0] = 1;
    int[] new_nums = copy(nums);

Python:

def copy(nums):
    arr = [0]*len(nums)  # 相当于Java中的new int[nums.length]
		for i in range(len(nums)):
        arr[i] = nums[i]
    return arr
		
# 用list comprehension实现同样功能
def copy(nums):
    arr = [x for x in nums]
    return arr
		
# 以下相当于Java中的main函数
if __name__ == "__main__":
    nums = [0]*10
    nums[0] = 1
    new_nums = copy(nums)

C++:

int* copy(int nums[], int length) 
    int *arr = new int[length];
    for (int i = 0; i < length; i++) 
        arr[i] = nums[i];
    
    return arr;


int main() 
    int *nums = new int[10];
    nums[0] = 1;
    int *new_nums = copy(nums, 10);
	return 0;

在 copy 这个函数中,arr 是一个局部变量,他在 copy 函数执行结束之后就会被销毁。但是里面 new 出来的新数组并不会被销毁。
这样,在 main 函数里,new_nums 里才会有被复制后的数组。所以可以发现一个特点:

    栈空间里存储的内容,会在函数执行结束的时候被撤回

简而言之可以这么区别栈空间和堆空间:

    new 出来的就放在堆空间,其他都是栈空间

 


什么是递归深度

递归深度就是递归函数在内存中,同时存在的最大次数
例如下面这段求阶乘的代码:
Java:

int factorial(int n) 
    if (n == 1) 
        return 1;
    
    return factorial(n - 1) * n;

Python:

def factorial(n):
    if n == 1:
        return 1
    return factorial(n-1) * n

C++:

int factorial(int n) 
    if (n == 1) 
        return 1;
    
    return factorial(n - 1) * n;

n=100时,递归深度就是100。一般来说,我们更关心递归深度的数量级

在该阶乘函数中递归深度是O(n),而在二分查找中,递归深度是O(log(n))。在后面的教程中,我们还会学到基于递归的快速排序、归并排序、以及平衡二叉树的遍历,这些的递归深度都是(O(log(n))。注意,此处说的是递归深度,而并非时间复杂度。

太深的递归会内存溢出

首先,函数本身也是在内存中占空间的,主要用于存储传递的参数,以及调用代码的返回地址。
函数的调用,会在内存的栈空间中开辟新空间,来存放子函数。递归函数更是会不断占用栈空间,例如该阶乘函数,展开到最后n=1时,内存中会存在factorial(100), factorial(99), factorial(98) ... factorial(1)这些函数,它们从栈底向栈顶方向不断扩展。
当递归过深时,栈空间会被耗尽,这时就无法开辟新的函数,会报出stack overflow这样的错误。
所以,在考虑空间复杂度时,递归函数的深度也是要考虑进去的

Follow up:
尾递归:若递归函数中,递归调用是整个函数体中最后的语句,且它的返回值不属于表达式的一部分时,这个递归调用就是尾递归。(上例 factorial 函数满足前者,但不满足后者,故不是尾递归函数)

尾递归函数的特点是:在递归展开后该函数不再做任何操作,这意味着该函数可以不等子函数执行完,自己直接销毁,这样就不再占用内存。一个递归深度O(n)的尾递归函数,可以做到只占用O(1)空间。这极大的优化了栈空间的利用。

但要注意,这种内存优化是由编译器决定是否要采取的,不过大多数现代的编译器会利用这种特点自动生成优化的代码。在实际工作当中,尽量写尾递归函数,是很好的习惯。
而在算法题当中,计算空间复杂度时,建议还是老老实实地算空间复杂度了,尾递归这种优化提一下也是可以,但别太在意。

 


分解质因数

sqrt(n)为时间复杂度的算法并不多见,最具代表性的就是分解质因数了。

题目描述

http://www.lintcode.com/problem/prime-factorization/

具体步骤

  1.     记up=sqrt(n)作为质因数k的上界, 初始化k=2
  2.     当k<=up 且 n不为1 时,执行步骤3,否则执行步骤4。
  3.     当n被k整除时,不断整除并覆盖n,同时结果中记录k,直到n不能整出k为止。之后k自增,执行步骤2。
  4.     当n不为1时,把n也加入结果当中,算法结束。

几点解释

  • 不需要判定k是否为质数,如果k不为质数,且能整出n时,n早被k的因数所除。故能整除n的k必是质数。
  • 为何引入up?为了优化性能。当k大于up时,k已不可能整除n,除非k是n自身。也即为何步骤4判断n是否为1,n不为1时必是比up大的质数。
  • 步骤2中,也判定n是否为1,这也是为了性能,当n已为1时,可早停。

代码

 1 public List<Integer> primeFactorization(int n) 
 2     List<Integer> result = new ArrayList<>();
 3     int up = (int) Math.sqrt(n);
 4     
 5     for (int k = 2; k <= up && n > 1; ++k) 
 6         while (n % k == 0) 
 7             n /= k;
 8             result.add(k);
 9         
10     
11     
12     if (n > 1) 
13         result.add(n);
14     
15     
16     return result;
17 

 

复杂度分析


最坏时间复杂度 O(sqrtn)。当n为质数时,取到其最坏时间复杂度。
空间复杂度 O(log(n)),当n质因数很多时,需要空间大,但总不会多于O(log(n))


 

以上是关于Lect3 时间复杂度/空间复杂度的主要内容,如果未能解决你的问题,请参考以下文章

时间复杂度和空间复杂度

时间复杂度和空间复杂度

递归的空间复杂度

时间复杂度与空间复杂度

时间复杂度和空间复杂度

php算法基础----时间复杂度和空间复杂度