算法复杂度分析

设计一个算法,一定要考虑到它的复杂度(空间、时间),那么,接下来,我们一步步来学习怎样分析一个算法的复杂度。

一、数据规模

首先,我们要清楚设计这个算法是要处理什么规模的数据的,根据处理的数据的规模,从而设计出适合的算法。

通常,如果要想在 1s 之内解决问题:

  • O(n^2) 的算法可处理约 10^4 级别的数据
  • O(n) 的算法可处理约 10^8 级别的数据
  • O(nlogn) 的算法可处理约 10^7 级别的数据

不过在实际情况中,保险起见,可以对数量级低估一点,除以 10 或者 除以 2,这样就比较保险了。

二、空间复杂度

常见的空间复杂度就不解释了,需要说明的是一种比较特殊的情况。

递归是比较常见的一种算法思路,但递归调用是有空间代价的,因为计算机要把递归之前的数据压入栈中,会占用一定的空间,这点我们需要特别注意。

空间复杂度 O(1)

    public static int sum1(int n) {
        int ret = 0;
        for (int i = 0; i <= n; i++) {
            ret += i;
        }
        return ret;
    }

这是一个简单求和的一个函数,我们只开了一个 ret 存放返回结果,和一个索引 i 的值,所以空间复杂度就是 O(n) ,不过我们也可以用递归来写,但是这样空间复杂度就变了。

空间复杂度 O(n)

    public static int sum2(int n) {
        if (n == 0)
            return 0;

        return n + sum2(n - 1);
    }

这是因为在计算从 0 到 n 这些数的和,需要递归调用的深度是 n,栈中需要装载 n 个状态,也就是空间复杂度为 O(n)。

因此,在设计算法时,如果用到了递归,需要小心,不要让你的递归看起来聪明,但实际上性能却很差。

三、常见的时间复杂度分析

接下来,我们通过常见的一些函数来具体分析一下他们的时间复杂度。

注意,下面这些例子中,我们暂且假设输入都是合法输入,这样方便我们突出重点,但是在实际的算法设计中,是需要考虑到合法输入、边界条件等等信息的。

1. O(1)

e.g. 交换

    private static void swapTwoInts(int a, int b) {
        int temp = a;
        a = b;
        b = temp;
    }

常数级算法,没有数据规模的变化。

2. O(n)

e.g. 求和

    private static int sum(int n) {

        int ret = 0;
        for (int i = 0; i <= n; i++) {
            ret += i;
        }
        return ret;
    }

一个求和的算法,时间复杂度为 O(n) 的算法的典型特征就是存在一个循环,循环的次数和 n 相关,也就是在这个循环中基本操作执行的次数是 c*n,c 是一个常数,不过这个常数不一定是 > 1 的,比如,下面这个算法

    private static void reverse(String s) {

        char[] arr = s.toCharArray();
        int n = arr.length;
        for (int i = 0; i < n / 2; i++) {
            swap(arr[i], arr[n - 1 - i]);
        }
    }

这是个反转字符串的函数,使用逐个交换前面和后面的字符的方式,我们只需要遍历字符串的一半长度就可以,所以这样的算法执行了 1 / 2 * n 次 swap 操作,时间复杂度仍然是 O(n)。

3. O(n^2)

e.g. 选择排序

    private static void selectionSort(int[] arr, int n) {

        for (int i = 0; i < n; i++) {
            int minIndex = i;
            for (int j = i + 1; j < n; j++) {
                if (arr[j] < arr[minIndex]) {
                    minIndex = j;
                }
            }
            swap(arr, i, minIndex);
        }
    }

不严谨地说,在很多情况下,双重循环一般就是 O(n^2)

比较严谨地计算的话,我们需要计算一下基本操作的执行次数。

在 i = 0 时,j = 1,后面比较 n - 1 次;当 i = 1 时,j = 2,后面比较 n - 2 次 …… 当 i = n - 1时,j = n,不会进入里面那层循环,最后比较的次数加起来就是 S = (n - 1) + (n - 2) + … + 0 ,这是一个等差数列,用求和公式可以得到 S = n*(n-1) / 2 = n^2 / 2 - n / 2,显然复杂度取高的,O(n^2)。

前面说了,多数情况双重循环是 O(n^2),但是 具体情况还要具体分析

e.g. 打印每个班级中每个学生的编号

    public void printClassInfo(int n) {
        for (int i = 1; i <= n; i++) {
            for (int j = 1; j <= 30; j++) {
                System.out.println("Class " + i + "-" + "No." + j);
            }
        }
    }

这个函数的时间复杂度就是 O(30n) = O(n)

时间复杂度为 O(n) 并不是因为内层循环是 30,即使是 300000 甚至更高,该函数的时间复杂度也仍然是 O(n),因为这个常数和 n 无关,这一点需要注意一下。

4. O(logn)

能达到 O(logn) 的一定是一个非常优秀的算法!

为什么这么说呢?

我们来举个栗子,开始时,我们有 2^i 个数据,然后每次将数据量增大为之前的两倍。如果我们使用的算法能达到 O(logn) 的话,每次数据量增大后,算法的时间复杂度增长的比例就是 log2n / logn = (log2 + logn) / logn = 1 + log2 / logn

可以看到,我们将数据规模扩大为原来的两倍后,算法的时间复杂度只是增加了 1 + log2 / logn,也就是一点几倍!并且,这个点几,也就是 log2 / logn 还会随着 n 的增大而减小!!

可见,能达到 O(logn) 的算法是有多么优秀了。

下面根据例子具体分析

e.g. 二分查找

    public static int binarySearch(int[] arr, int n, int target) {
        int low = 0;
        int high = n - 1;

        while (low <= high) {
            int mid = (low + high) / 2;
            if (target == arr[mid]) return mid;
            else if (target < arr[mid]) high = mid - 1;
            else low = mid + 1;
        }

        return -1;
    }

为什么二分查找时间复杂度是 O(logn) 呢?

二分查找的过程就是先在 n 个元素中查找,然后根据该元素是比中间元素大还是小,再去另一半查找,重复这个过程。那么,一开始,是在 n 个元素中查找,然后再 n / 2 个元素中查找,接下来是 n / 4 …… 直到剩下 1 个元素,结果是找到或者不存在该元素。

这其实就是相当于 n 经过几次“除以2”操作后等于1?

而这其实就是对数的定义 log2n = O(logn)

那么,我们在判断一个算法是否是 O(logn) 的时间复杂度时,就可以通过类似 n 经过几次“除以x”操作后等于y 这样的方式来判断

e.g. 整型转字符串

    public static String int2String(int num) {
        String s = "";

        while (num) {
            s += '0' + num % 10;
            num /= 10;
        }

        reverse(s);
        return s;
    }

类比前面的过程,这个 int2String 可以看作是 n经过几次“除以10”的操作,等于0?

即 log10n = O(logn)

当然,我们也需要考虑到 reverse() 这个函数的时间复杂度,前面已经说过了,这个函数是需要比较 n / 2 次,它的时间复杂度是 O(n),并且它的这个 n 其实就是字符串 s 的长度,而 s 的长度又等于 int2String 的 while 的执行次数,因此二者是一致的,所以 int2String 的时间复杂度是 O(logn)。

到这里,我们需要解释一个问题,就是为什么 log2n = log10n = O(logn)?

这里涉及一点简单的数学问题:

有这么一个公式,其中 logab 是一个常数
logaN = logab * logbN

因此,log2n 和 log10n 的差别只是一个常数,因此在说明时间复杂度时,这个常数相对于 logn 可以忽略,所以 log2n = log10n = O(logn)

5. O(nlogn)

e.g. 含双重循环的时间复杂度为 O(nlogn) 的函数

    void fun(int n) {

        for (int fast = 1; fast < n; fast += fast) {
            for (int slow = 1; slow < n; slow++) {
                System.out.println("fast or slow?");
            }
        }
    }

这个函数外层循环每次 += 自身,也就是每次循环 fast * 2,这可以看作 fast 经过几次“乘于2”,能达到n,所以外层循环的次数是 logn,而内存循环次数是 n 次,因此整个函数的基本操作执行次数为 nlogn 次,所以就解释了为什么这个包含双重循环的函数时间复杂度为 O(nlogn)。

6. O(sqrt(n))

e.g. 判断素数

    boolean isPrime(int n) {

        for (int i = 2; i*i <= n; i++) {
            if (n % i == 0)
                return false;
            return true;
        }
    }

这是一个 for 循环,但是不能单纯的认为是 O(n),因为很明显这里的循环执行的次数是 sqrt(n) 次,因此时间复杂度也是 O(sqrt(n))

7. 其他

在之前的文章 一个时间复杂度问题 中通过一个字符串排序的问题讨论过算法的时间复杂度,这种情况就是一个算法中包含了多个不同时间复杂度的模块,当然,这种较复杂的时间复杂度的分析其实也是要建立在前面几个基础的时间复杂度的分析之上的,具体的分析方法,可以看我之前的文章。

你可能感兴趣的:(算法)