算法分析之时间复杂度和空间复杂度

1 算法简介

1.1 算法的定义

​ 算法(Algorithm)是对特定问题求解步骤的一种描述,它是指令的有限序列,其中每一条指令表示一个或多个操作。

1.2 算法的特性

​ 1、有穷性(Finiteness):算法必须能在执行有限个步骤后终止。
​ 2、确定性(Definiteness):算法的每一步骤必须有确切的含义。
​ 3、输入项(Input):一个算法有零个或多个输入,这些输入取自某个特定的对象集合。
​ 4、输出项(Output):一个算法有一个或多个输出,以反映对输入数据加工后的结果。
​ 5、可行性(Effectiveness):算法中描述的操作都可以通过已经实现的基本运算执行有限次来实现。

1.3 算法的表示

​ 1、自然语言
​ 2、流程图
​ 3、程序设计语言
​ 4、伪代码

1.4 算法设计

​ 常用的算法设计策略有:分治法、动态规划法、贪心法、回溯法、分支限界法、概率算法和近似算法等。

1.5 算法分析

​ 算法分析技术的主要内容:正确性、可靠性、简单性、易理解性、时间复杂度和空间复杂度等。

2 算法分析之时间复杂度和空间复杂度

2.1 时间复杂度之“大O符号表示法”

时间复杂度,全称为“渐进时间复杂度”,是对一个算法的运行时间的一个量度,反映的是一个趋势,我们用 T(n) 来定义。

计算时间复杂度时,为了避免受到运行环境和测试数据规模的影响,我们使用一种通用方法:“大O符号表示法”。

在“大O符号表示法”中,时间复杂度的公式是:T(n) = O(f(n)),其中f(n)表示每行代码执行次数之和,而O表示正比例关系,这个公式的全称是:算法的渐进时间复杂度。因为“大O符号表示法”并不是用来真实表示算法的执行时间,它是用来表示代码执行时间的增长变化趋势的,所以式子中的常量就没有了意义,我们可以直接使用量级表示。

常见的时间复杂度量级有:
1、常数阶 O(1)
2、对数阶 O(log₂n
3、线性阶 O(n)
4、线性对数阶 O(n log₂n)
5、平方阶 O(n²)
6、立方阶 O(n³)
7、K次方阶 O(n^k)
8、指数阶(2^n)
上面从上至下依次的时间复杂度越来越大,执行的效率越来越低。

2.1.1 常数阶 O(1)

示例代码:

int i = 1;
int j = 2;
++i;
j++;
int m = i + j;
int n = i * j;

无论代码执行了多少行,只要是没有循环等复杂结构,那这个代码的时间复杂度就都是O(1)。

2.1.2 对数阶 O(logn)

示例代码:

int i = 1;
while(i

示例代码中,在 while 循环里面,每次都将 i 乘以 2,乘完之后,i 距离 n 就越来越近了。
我们试着求解一下,假设循环 x 次之后,i 就不小于 n 了,此时这个循环就退出了,也就是说 2 的 x 次方等于 n,那么 x = log₂n 。也就是说当循环 log₂n 次以后,这个代码就结束了。
因此,这个代码的时间复杂度为 O(log₂n),“大O符号表示法”为 O(logn)。

2.1.3 线性阶 O(n)、O(m+n)

示例代码:

for(i=1; i<=n; ++i)
{
   j = i;
   j++;
}

示例代码中,for循环里面的代码会执行n遍,它消耗的时间是随着 n 的变化而变化的。
因此,这类代码的时间复杂度为:O(n) 。

当数据规模有两个,分别是m和n,而我们并不知道m和n谁的量级大时,“加法法则(总复杂度等于量级最大的那段代码的复杂度)”失效,这时的时间复杂度是 O(m+n) 。

2.1.4 线性对数阶 O(n logN)

示例代码:

for(m=1; m

线性对数阶 O(n log₂N) 其实非常容易理解,将时间复杂度为 O(log₂N) 的代码循环n遍的话,那么它的时间复杂度就是 n * O(log₂N),“大O符号表示法”就是 O(n logN) 。

2.1.5 平方阶 O(n²)、O(m*n)

示例代码1:

for(x=1; i<=n; x++)
{
   for(i=1; i<=n; i++)
    {
       j = i;
       j++;
    }
}

示例代码2:

for(x=1; i<=m; x++)
{
   for(i=1; i<=n; i++)
    {
       j = i;
       j++;
    }
}

平方阶 O(n²) 就更容易理解了,如果把 O(n) 的代码再嵌套循环一遍,它的时间复杂度就是 O(n²) 了。

示例代码1中,其实就是嵌套了2层n循环,它的时间复杂度就是 O(n*n),即 O(n²) 。

示例代码2中,将其中一层循环的n改成m,即那它的时间复杂度就变成了 O(m*n) 。

2.1.6 立方阶 O(n³)、K次方阶 O(n^k)

可以参考上面的平方阶 O(n²) 理解,立方阶 O(n³) 相当于三层n循环,K次方阶 O(n^k) 以此类推。

2.2 时间复杂度之最好、最坏、平均、均摊

示例:在给定数组中寻找目标元素的位置,如果找到返回下标,结束循环;如果找不到则返回 -1。

    /**
     * 找出给定数组中目标元素的位置,如果找不到返回-1
     * @param arr 给定数组
     * @param target 目标元素
     * @return
     */
    public int find(int[] arr, int target) {
        int n = arr.length;
        for (int i = 0; i < n; i++) {
            // 依次遍历数组,如果找到和目标元素相同的值,在返回该值的数组下标
            if (arr[i] == target) {
                return i;
            }
        }
        return -1;
    }

分析:目标元素在数组中位置的不同导致时间复杂度的不同。

2.2.1 最好情况时间复杂度

目标元素刚好在数组第一个位置,那么只需要一次就能找到,时间复杂度很明显是常数阶 O(1) 。

2.2.2 最坏情况时间复杂度

目标元素在数组最后一个位置或者不在数组中,那么得需要遍历完整个数组才能得出结果,时间复杂度为 O(n) 。

最坏情况运行时间是在任何输入下运行时间的一个上限。通常,除非特别指定,我们提到的运行时间都是最坏情况运行时间。

2.2.3 平均时间复杂度

可以看出,由于目标元素位置的不同,导致时间复杂度出现量级差异。这种情况下就需要考虑平均时间复杂度。

下面简单分析下:目标元素如果在数组中,出现的位置有n种情况,加上不在数组中这一种情况,总共n+1种情况。每种情况下需要遍历的次数如下表:

目标元素所在位置 遍历次数
第1个位置 1次
第2个位置 2次
第3个位置 3次
第n个位置 n次
不在数组中 n次

由上表可以得出,平均遍历次数 = 各种情况遍历次数相加 ÷ 总的情况数。
即遍历次数f(n)与数据规模之间的关系为:f(n) = (1+2+3+…+n+n) / (n+1) = [n(n+3)] / [2(n+1)]
根据大O分析法,忽略低阶项和系数得,T(n) = O(f(n)) = O(n)
因此,平均时间复杂度为:O(n)。
平均时间复杂度是所有情况中最有意义的,因为它是期望的运行时间。

2.2.4 均摊时间复杂度

均摊时间复杂度,就是把量级高的操作所耗费的时间分担到量级低的操作上,看平摊后的量级是多少。是平均时间复杂度的补充。

均摊时间复杂度应用的场景:对一个数据结构进行一组连续操作,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系,这个时候,适合运用均摊时间复杂度分析代码,而此时均摊时间复杂度就等于最好情况时间复杂度。

示例:这段代码实现了往数据中插入数据的功能。当数组没有满的时候,直接插入数据到数组中。当数组满了以后,遍历数组求和,将求和之后的sum值放在数组中的第一位置,然后把count指针指向索引为1的位置,再将新的数据插入。

 // array 表示一个长度为 n 的数组
 // 代码中的 array.length 就等于 n
 int[] array = new int[n];
 int count = 0;
 
 public void insert(int val) {
    if (count == array.length) {
       int sum = 0;
       for (int i = 0; i < array.length; ++i) {
          sum = sum + array[i];
       }
       array[0] = sum;
       count = 1;
    }

    array[count] = val;
    ++count;
 }

分析:

最好情况时间复杂度:当数组没有满的时候,直接将数据插入数组中即可,这是最理想的情况,所以最好情况时间复杂度为O(1)。

最坏情况时间复杂度:当数组满的时候,需要遍历数组求和,把求和后的数据插入,这是最坏的情况,所以最坏情况时间复杂度为O(n)。

平均时间复杂度:假设数组的长度是n,根据数据插入的位置的不同,我们可以分为n种情况,每种情况的时间复杂度是O(1)。当数组满了的时候插入一个数据,这个时候需要遍历求和,这个时候的时间复杂度是O(n),而n+1种情况发生的概率一样,都是1/n+1。所以根据加权平均计算法,求得的平均时间复杂度是:1*(1/(n+1)) + 1*(1/(n+1)) + 1*(1/(n+1)) + … + n*(1/(n+1)) = 2n/(n+1) → O(1) 。

均摊时间复杂度:每次O(n)的遍历求和操作后面,会跟着1次O(1)的插入操作,然后跟着n-1次O(1)的插入操作,所以把耗时多的遍历求和操作的时间均摊到接下来的n次耗时少的操作上,也就是平均执行2次操作,所以这一组连续的操作的均摊复杂度就是O(1)。

2.3 空间复杂度

空间复杂度是对一个算法在运行过程中临时占用存储空间大小的一个量度,反映的是一个趋势,我们用 S(n) 来定义。S(n) = O(f(n)),其中n为问题的规模。

通常来说,只要算法不涉及到动态分配的空间以及递归、栈所需的空间,空间复杂度通常为 O(1)。

2.3.1 空间复杂度 O(1)

示例代码:

int i = 1;
int j = 2;
++i;
j++;
int m = i + j;
int n = i * j;

如果算法执行所需要的临时空间不随着某个变量n的大小而变化,即此算法空间复杂度为一个常量,可表示为 O(1)。

示例代码中的 i、j、m、n 所分配的空间都不随着处理数据量变化,因此它的空间复杂度 S(n) = O(1)

2.3.2 空间复杂度 O(n)

示例代码:

int[] m = new int[n]
for(i=1; i<=n; ++i)
{
   j = i;
   j++;
}

示例代码中,第一行new了一个数组出来,这个数据占用的大小为n,这段代码的2~6行,虽然有循环,但没有再分配新的空间,因此,这段代码的空间复杂度主要看第一行即可,即 S(n) = O(n)

2.4 常见排序的时间复杂度和空间复杂度(网络收集,待验证)

排序方法 时间复杂度(平均) 时间复杂度(最坏) 时间复杂度(最好) 空间复杂度 稳定性
直接插入排序 O(n²) O(n²) O(n) O(1) 稳定
希尔(Shell)排序 O(n log₂n) / O(n^1.3) O(n²) O(n) O(1) 不稳定
直接选择排序 O(n²) O(n²) O(n²) O(1) 不稳定
堆排序 O(n log₂n) O(n log₂n) O(n log₂n) O(1) 不稳定
冒泡排序 O(n²) O(n²) O(n) O(1) 稳定
快速排序 O(n log₂n) O(n²) O(n log₂n) O(n log₂n) 不稳定
归并排序 O(n log₂n) O(n log₂n) O(n log₂n) O(n) 稳定
基数排序 O(d(n+r)) O(d(n+r)) O(d(n+r)) O(n+r) 稳定

你可能感兴趣的:(算法设计与分析,算法,数据结构)