【数据结构与算法基础】代码仓库:https://github.com/jinrunheng/datastructure-and-algorithm

一:时间复杂度

在计算机科学中,算法的时间复杂度(Time Complexity) 是一个衡量算法性能的重要指标的函数,它定性描述了一个算法的运行时间。我们知道,衡量一个算法的快慢,一定要考虑数据规模的大小。一般来说,数据规模越大,算法的用时就越长。

时间复杂度通常使用大 O 符号来表述,不包括这个函数的的低阶项和高阶项的系数。因此时间复杂度可被称为是渐近的,譬如,一个算法对于输入为 n 的数据规模,至多需要 5n3 + 3n 的时间运行完毕,那么它的渐近时间复杂度为 O(n3),而我们平时所说的时间复杂度指的就是渐进时间复杂度。

那么如何计算时间复杂度呢?同一个算法在不同的计算机上运行的速度必然有一定的差别,并且实际运行速度难以在理论上进行计算,所以,我们通常考虑的不是算法运行的实际用时,而是算法运行所需要进行的基本操作的数量

在普通的计算机上,加减乘除,访问变量,给变量赋值这些操作都可以看作是基本操作。对基本操作的计数或是估测可以作为评判算法用时的指标。

举个例子:

给定一个数组,和一个目标值,请返回该目标值在数组当中的索引,如果没有则返回 -1

我们可以很轻松地写出这段代码:

  1. int getIndex(int[] nums,int target) {
  2. for(int i = 0; i < nums.length; i++)
  3. if(target == nums[i])
  4. return i;
  5. return -1;
  6. }

这个算法的时间复杂度该如何估计呢?

在这个算法中,我们依次遍历数组的每一个元素,让其与 target 进行比较,比较这个操作就可以看作是一个基本操作。那么一共需要多少次比较操作呢?我们考虑两种极端的情况,第一种情况是,当 target 根本就不在数组当中时,我们需要遍历整个数组,一共需要执行 n 次比较操作;第二种情况是,数组的第一个元素就是我们要找的那个数字,我们只需要 1 次比较操作就可以将结果返回了。

按照第一种情况来看,这个算法的时间复杂度为 O(n);按照第二种情况来看,这个算法的时间复杂度为 O(1)。第一种情况下的复杂度,我们称为最坏时间复杂度,它是指在最坏的情况下,算法的时间复杂度;第二种情况下的复杂度我们则称为最好时间复杂度,其是指在最优的情况下,算法的时间复杂度。

很显然,这两种情况都是比较极端的,我们的评估应该是在输入实例在等概率出现的情况下,算法的期望运行时间。也就是说,我们要考虑的是一个平均时间复杂度。通过数学期望求解,比较操作的平均发生次数为 n/2,按照复杂度的定义,我们需要忽略高阶项的系数 1/2,所以,该算法的时间复杂度为 O(n)。

二:空间复杂度

空间复杂度(Space Complexity),通常指的是额外空间复杂度,是对一个算法在运行过程中临时占用存储空间大小的量度。这里面额外空间的含义是指,刨去算法本身程序所占的空间大小,输入数据所占的空间大小外,额外的辅助变量所需要的空间与输入数据量 n 的关系,通常我们也是使用大 O 符号来表述的。

我们知道,时间复杂度对应的是算法的时间维度,也就是执行当前算法所消耗的时间;空间复杂度对应的则是算法的空间维度,是指执行当前算法需要占用的内存空间大小,当两个算法的时间复杂度相同时,空间复杂度则是评价这两个算法性能的重要指标之一。

我们来看一个示例:

求连续整数 1 ~ n 的和

我们可以使用两种思路进行求解:

  1. 迭代法

    1. int sum(int n){
    2. int s = 0;
    3. for(int i = 1; i <= n; i++)
    4. s += i;
    5. return s;
    6. }
  2. 递归

    1. int sum(int n){
    2. if(n == 1)
    3. return 1;
    4. return sum(n - 1) + n;
    5. }

这两个算法均可以求解,且他们的时间复杂度是一样的,均为 O(n);但是迭代算法仅需要一个变量 s 来记录返回结果,其空间复杂度为 O(1),而递归算法则需要开辟额外的栈空间来记录方法返回值,其空间复杂度是 O(n)。所以这两个算法虽然时间复杂度相同,但是空间复杂度是不同的,第一种算法要优于第二种算法。

三:均摊复杂度

在我的文章 🔗【动态数组】里,曾经介绍过均摊复杂度这一概念。

动态数组之所以动态,是因为在向数组添加元素时,如果数组容量为满,就要进行扩容操作,Java 中 util 包下的 ArrayList 就是这样做的。

如果初始化数组的原始 capacity 为 10,开始时,数组内没有任何元素。一直使用addLast(E e)向数组末尾添加元素,在添加10次后,即第十一次再次添加元素则触发了一次扩容操作,扩容操作的复杂度为 O(n),扩容后的capacity为20,即原来的capacity的2倍。而在第21次添加元素操作时,又再次触发了扩容的操作。

本身,向数组末尾添加元素的方法 addLast 是一个 O(1) 级别的算法,但是由于扩容机制的存在,使得该算法的复杂度呈现出这样的一个趋势:

1、算法复杂度 - 图1

我们对 addLast 操作的复杂度进行分析:

  • 第一次 addLast 操作,时间复杂度为 O(1)
  • 第二次 addLast 操作,时间复杂度为 O(1)
  • 第三次 addLast 操作,时间复杂度为 O(1)
  • … …
  • 第 n + 1(data.length + 1) 次 addLast 操作,触发 resize,时间复杂度为 O(n)

也就是说:第 n+1 次 addLast 操作会触发一次 resize 方法。

如果将 O(1) 的操作称作 1 次基本操作的话,从第 1 次添加元素至第 n+1 次添加元素,一共进行了2n + 1次基本操作(resize相当于n次基本操作,并且共执行了n+1次addLast操作,所以算上 resize 共执行了 2n + 1 次基本操作)。

计算机做了 2n+1 次基本操作——即:O(1) 的操作,那么平均下来,每1次 addLast,计算机就要做(2n + 1)/(n+1)次 O(1) 操作。也就是说当 n 这个数字趋近无穷大时,则每 1 次 addLast 操作,计算机会进行 2 次基本的 O(1) 操作,那么,这就就可以说明—— addLast 操作和 n 没有关系,它仍然是一个 O(1) 级别的算法。

所以,即便有 resize 扩容这样一个 O(n) 级别的算法夹杂在 addLast 这个方法中,addLast 通过均摊复杂度的思想,仍然是一个 O(1) 级别的算法。

四:主定理

主定理(Master Theorem),可以用来快速求解关于递归算法的复杂度。

对于将一个过程分解成均等子过程的递归算法来说,我们可以使用主定理公式来计算时间复杂度。

公式如下:
image.png
公式中,b 代表的含义是递归分解为几个均等的子过程;a 代表的含义是一次递归的子过程执行了几次,O(Nd) 表示除去递归以外,额外需要的过程的时间复杂度。

接下来,我们使用 Master 主定理来分析一个递归算法的复杂度:

设计一个递归算法,要求返回一个无序数组的最大值

代码如下:

  1. int findMax(int[] arr){
  2. return findMax(arr,0,arr.length - 1);
  3. }
  4. int findMax(int[] arr,int l,int r){
  5. if(arr == null)
  6. throw new IllegalArgumentException("Array is Empty!");
  7. if(l == r)
  8. return arr[l];
  9. int mid = l + ((r - l) >> 1);
  10. int maxInLeft = findMax(arr,l,mid);
  11. int maxInRight = findMax(arr,mid + 1,r);
  12. return maxInLeft > maxInRight ? maxInLeft : maxInRight;
  13. }

这个递归算法的本质是将数组劈成两半,递归求出左边最大的值和右边最大的值,然后返回左边最大的值与右边最大的值中最大的数值。

从宏观的角度来看,一个原问题,被分解成了两个均等的子问题,我们对这两个子问题分别进行求解,所以,a = 2,b = 2,最后刨去递归的过程,我们将左边最大的值和右边最大的值进行了一次比较,返回两者中最大的值,这个操作的时间复杂度为 O(1)。

我们将这个递归算法转化为 Master 通式为 2T(N/2) + O(1),即:a = 2,b = 2,d = 0,满足 log(b,a) > d,所以,这个递归算法的时间复杂度为 O(N)。递归调用额外开辟了栈空间,其空间复杂度为 O(N)。

五:常见的算法复杂度

接下来,我会给出几种常见的时间复杂度以及对应的算法示例,这一章节,不要求掌握这几种算法具体的写法,我们只需要有一个大致的印象,在后面的文章中,我们遇到这些算法会再具体分析。

O(1)

举例:

判断给定的数字 n 是否为偶数?

代码:

  1. boolean isEven(int num){
  2. return num % 2 == 0;
  3. }

O(logn)

举例:

判断某个数字是否在一个已序数组中?

代码:

  1. boolean exist(int[] sortedArr,int target){
  2. int l = 0;
  3. int r = sortedArr.length - 1;
  4. while(l < r){
  5. int mid = l + ((r - l) >> 1);
  6. if(target < sortedArr[mid])
  7. r = mid - 1;
  8. else if (target > sortedArr[mid])
  9. l = mid + 1;
  10. else {
  11. return true;
  12. }
  13. }
  14. return sortedArr[l] == target;
  15. }

O(√n)

举例:

求解一个数所有的约数?

代码:

  1. List<Integer> getAllDivisor(int num){
  2. List<Integer> res = new ArrayList<>();
  3. for(int i = 1; i * i <= num; i++){
  4. if(num % i == 0) {
  5. if(num / i == i)
  6. res.add(i);
  7. else {
  8. res.add(i);
  9. res.add(num / i);
  10. }
  11. }
  12. }
  13. return res;
  14. }

O(n)

举例:

在一个无序数组中,寻找目标值的索引,没有找到则返回 -1

代码:

  1. int linearSearch(int[] nums,int target){
  2. for(int i = 0; i < nums.length; i++){
  3. if(nums[i] == target)
  4. return i;
  5. }
  6. return -1;
  7. }

O(n*logn)

举例:

归并排序

代码:

  1. void mergetSort(int[] arr){
  2. if(arr == null || arr.length < 2)
  3. return;
  4. sort(arr,0,arr.length - 1);
  5. }
  6. void sort(int[] arr,int l,int r){
  7. if(l == r)
  8. return;
  9. int mid = l + ((r - l) >> 1);
  10. sort(arr,l,mid);
  11. sort(arr,mid + 1,r);
  12. merge(arr,l,mid,r);
  13. }
  14. void merge(int[] arr,int l,int mid,int r){
  15. int p1 = l;
  16. int p2 = mid + 1;
  17. int[] tmp = new int[r - l + 1];
  18. int i = 0;
  19. while(p1 <= mid && p2 <= r)
  20. tmp[i++] = arr[p1] < arr[p2] ? arr[p1++] : arr[p2++];
  21. while(p1 <= mid)
  22. tmp[i++] = arr[p1++];
  23. while(p2 <= r)
  24. tmp[i++] = arr[p2++];
  25. for(i = 0; i < tmp.length; i++)
  26. arr[l + i] = tmp[i];
  27. }

O(n2)

举例:

冒泡排序

代码:

  1. void bubbleSort(int[] arr){
  2. for(int i = 0; i < arr.length - 1; i++){
  3. for(int j = 0; j < arr.length - 1 - i; j++){
  4. if(arr[j] > arr[j + 1])
  5. swap(arr,j,j + 1);
  6. }
  7. }
  8. }

O(2n)

举例:

求一个长度为 n 的所有可能的二进制数字

代码:

  1. List<String> getAllBinaryOfLengthN(int n) {
  2. List<String> res = new ArrayList<>();
  3. if(n == 0)
  4. return res;
  5. if(n == 1){
  6. res.add("0");
  7. res.add("1");
  8. return res;
  9. }
  10. List<String> list = getAllBinaryOfLengthN(n - 1);
  11. for(String s : list){
  12. res.add(s + "0");
  13. res.add(s + "1");
  14. }
  15. return res;
  16. }

O(n!)

举例:

全排列,链接🔗:https://leetcode-cn.com/problems/permutations/

代码:

  1. class Solution {
  2. public List<List<Integer>> permute(int[] nums) {
  3. List<List<Integer>> res = new ArrayList<>();
  4. backtrack(nums,0,res);
  5. return res;
  6. }
  7. private void backtrack(int[] nums,int idx,List<List<Integer>> res){
  8. if(idx == nums.length)
  9. return;
  10. if(idx == 0){
  11. List<Integer> list = new ArrayList<>();
  12. list.add(nums[idx]);
  13. res.add(list);
  14. }else {
  15. int size = res.size();
  16. for(int i = 0; i < size; i++){
  17. List<Integer> list = res.remove(0);
  18. for(int j = 0; j <= list.size(); j++){
  19. List<Integer> tmp = new ArrayList<>(list);
  20. tmp.add(j,nums[idx]);
  21. res.add(tmp);
  22. }
  23. }
  24. }
  25. backtrack(nums,idx + 1,res);
  26. }
  27. }

以上内容,就是常见的算法时间复杂度以及对应的示例程序。