如何进行算法时间复杂度和空间复杂度分析

700 阅读3分钟

如何进行算法时间复杂度和空间复杂度分析

在计算机科学中,分析算法的效率和资源使用是非常重要的。

算法的效率通常通过时间复杂度和空间复杂度来衡量。

一、时间复杂度分析

1.1 什么是时间复杂度?

时间复杂度是一个函数, 它定量描述了算法的运行时间, 反映一个算法在输入规模增加时,运行时间增长的速度。

它通常用大O符号(Big O)表示,描述了最坏情况下的算法运行时间的上限。

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

实际操作中,我们并不是要计算算法的精确的执行次数,而是大概执行次数, 以此来衡量算法所消耗的时间规模。

1.2 常见的时间复杂度分类

常见的时间复杂度从低到高依次为:

  • O(1): 常数时间复杂度,无论输入规模多大,算法的运行时间都不变。
  • O(log n): 对数时间复杂度,问题规模逐步减半,例如二分查找算法。
  • O(n): 线性时间复杂度,例如遍历数组。
  • O(n log n): 线性对数时间复杂度,例如归并排序和快速排序的平均情况。
  • O(n^k): k次方时间复杂度,例如K层嵌套循环
  • O(2^n): 指数时间复杂度,例如解决背包问题的暴力算法。
  • O(n!): 阶乘时间复杂度,例如解决旅行商问题的暴力算法。

1.3 如何分析时间复杂度

1.3.1 逐行分析

分析算法时,通常将算法逐行分解,分析每一行代码的运行次数。

fun sum(array: IntArray): Int {
    var total = 0             // O(1)
    for (i in array.indices) { // O(n)
        total += array[i]      // O(1)
    }
    return total               // O(1)
}

在上述代码中:

  • var total = 0 的时间复杂度是O(1)
  • for (i in array.indices) 的循环执行了n次,时间复杂度是O(n)
  • total += array[i] 在循环中执行,每次的时间复杂度是O(1),总的时间复杂度是O(n)
  • return total 的时间复杂度是O(1)

总体时间复杂度是O(n)

1.3.2 找出主导项

在分析算法的时间复杂度时,我们通常忽略常数和低阶项,关注增长最快的项, 并且忽略与最高阶相乘的常数。

例如,对于一个算法的时间复杂度为O(3n^2 + 2n + 5),我们可以忽略常数项5和线性项2n,将时间复杂度简化为O(n^2)

1.4 时间复杂度实例分析

1.4.1 O(1)实例
fun printMessage() {
    println("Hello")
    println("World")
}

该方法和问题规模无关,执行固定的次数,所以时间复杂度为O(1)

fun printMessage() {
    for(i in 1..10) {
        println("$i")
    }
}

虽然这个方法中出现了for循环,但由于循环次数是确定的常数,和问题规模无关,所以时间复杂度依然是O(1)

1.4.2 O(n)实例
fun printMessage(n: Int) {
    for(i in 0 until n) {
        println("$i")
    }
}

它的时间复杂度为O(n),因为代码中for循环要执行n次,执行次数与n的大小相关

fun printMessage(n: Int, m: Int) {
    for(i in 0 until n) {
        println("$i")
    }
    
    for(i in 0 until m) {
        println("$i")
    }
}

这里基本操作会执行m+n次,有2个未知数m和n,所以时间复杂度为O(m+n),可以作为复杂度O(n)的一种变种

fun indexOfFirst(source: String, c: Char) {
    for(index in source.indices) {
        if(source[index] == c) {
            return index
        }
    }
    return -1
}

在这个方法中基本操作最好情况下只需执行1次,最坏情况下要执行n次(n为source的长度),而时间复杂度一般是指算法最坏的情况,所以它的时间复杂度也是O(n)

1.4.3 O(lg n)实例
fun printMessage(n: Int) {
    while(n > 1) {
        println("$n")
        n = n/ 2
    }
}

它的时间复杂度为O(log n),每次循环执行后问题规模都会减半,当循环折半的时候,时间复杂度就会变为O(log n),当算法中出现循环折半的时候,复杂度中就会出现logn

fun binarySearch(arr: IntArray, target: Int): Int {
    var low = 0
    var high = arr.size - 1
    while (low <= high) {
        val mid = (low + high) / 2
        when {
            arr[mid] < target -> low = mid + 1
            arr[mid] > target -> high = mid - 1
            else -> return mid
        }
    }
    return -1
}

二分查找的时间复杂度也为O(log n),通过观察可以发现每次操作都会将搜索空间缩小一半,即问题规模减小一半

1.4.4 O(n log n)实例
fun mergeSort(arr: IntArray): IntArray {
    if (arr.size <= 1) return arr
    val mid = arr.size / 2
    val left = mergeSort(arr.sliceArray(0 until mid))
    val right = mergeSort(arr.sliceArray(mid until arr.size))
    return merge(left, right)
}

fun merge(left: IntArray, right: IntArray): IntArray {
    var i = 0
    var j = 0
    val result = IntArray(left.size + right.size)
    for (k in result.indices) {
        if (i >= left.size) {
            result[k] = right[j++]
        } else if (j >= right.size) {
            result[k] = left[i++]
        } else if (left[i] <= right[j]) {
            result[k] = left[i++]
        } else {
            result[k] = right[j++]
        }
    }
    return result
}

归并排序是一个典型的时间复杂度为O(n log n)的算法实例,因为归并排序过程中每次都会将数组递归分成两半,相当于问题规模减半,需要递归将数组分隔log n次, 而合并操作,则需要线性时间O(n)来将2个子数组进行合并,所以它总的时间复杂度为O(n log n)

1.4.4 O(n^k)实例
fun bubbleSort(arr: IntArray) {
    val n = arr.size
    for (i in 0 until n) {           // O(n)
        for (j in 0 until n - i - 1) { // O(n)
            if (arr[j] > arr[j + 1]) {  // O(1)
                val temp = arr[j]
                arr[j] = arr[j + 1]
                arr[j + 1] = temp
            }
        }
    }
}

冒泡排序有2个嵌套循环,每个循环的时间复杂度为O(n),因些总的时间复杂度为O(n^2)

一般我们可以认为有k层和n相关的嵌套循环,就认为时间复杂度为O(n^k)

fun printMessage(n: Int) {
    for(i in 0 until n) {
        for(j in 0 until n) {
            for(k in 0 until n) {
                print("$i $j $k")
            }
        }
    }
}

这个算法的时间复杂度则为O(n^3),因为它是由三导和n有关的循环嵌套而成的,每一层循环时间复杂度为O(n),总的时间则为O(n^3)

1.4.5 O(2^n)实例
fun fibonacci(n: Int): Int {
    return if (n <= 1) {
        n
    } else {
        fibonacci(n - 1) + fibonacci(n - 2)
    }
}

一个时间复杂度为O(2^n)的典型算法是计算斐波那契数列的递归方法。 每次计算都需要进行2个递归调用,导致计算量呈指数级增长,因此时间复杂度为O(2^n)

但我们也可以通过添加记录功能,避免上述算法中产生的大量重复计算,从而将时间复杂度由O(2^n)降为O(n)

比如

val memo = mutableMapOf<Int, Int>()

fun fibonacci(n: Int): Int {
    if (n <= 1) return n
    if (memo.containsKey(n)) return memo[n]!!
    val result = fibonacci(n - 1) + fibonacci(n - 2)
    memo[n] = result
    return result
}

  • 通过memo记录已经计算过的斐波那契数,避免不必要的重复计算
  • 这种方法的时间复杂度为O(n),因为它只需要遍历n次即可得到结果。
1.4.6 O(n!)实例
fun permute(nums: IntArray): List<List<Int>> {
    val result = mutableListOf<List<Int>>()
    backtrack(nums.toList(), emptyList(), result)
    return result
}

fun backtrack(nums: List<Int>, path: List<Int>, result: MutableList<List<Int>>) {
    if (nums.isEmpty()) {
        result.add(path)
    } else {
        for (i in nums.indices) {
            backtrack(nums - nums[i], path + nums[i], result)
        }
    }
}

一个时间复杂度为O(n!)的典型算法是解决全排列问题

全排列涉及生成一个长度为 n 的数组的所有可能排列,随着 n 的增加,排列数量呈阶乘增长。

backtrack函数递归地构建排列,每次选择一个元素加入到当前路径,并继续对剩余元素进行排列。

生成 n 个元素的全排列,总共有n!种可能性,因此时间复杂度为O(n!)

二、空间复杂度分析

2.1 什么是空间复杂度?

空间复杂度是指算法在运行过程中所需的存储空间。它衡量的是算法在执行期间所需的额外内存。

2.2 常见的空间复杂度分类

  • O(1): 常数空间复杂度,算法所需的额外空间不随输入规模的变化而变化。
  • O(n): 线性空间复杂度,算法所需的额外空间与输入规模成正比。
  • O(n^2): 二次空间复杂度。
  • 其他复杂度,和时间复杂度类似

2.3 如何分析空间复杂度

分析空间复杂度时,需要考虑以下几个方面:

  • 变量的数量和大小:考虑所有声明的变量。
  • 数据结构:例如,数组、链表、哈希表等可能占用的空间。
  • 递归调用:递归调用会占用堆栈空间,尤其是递归深度较大时。

2.4 空间复杂度实例分析

2.4.1 O(1)实例
fun bubbleSort(arr: IntArray) {
    val n = arr.size          // O(1)
    for (i in 0 until n) {    // O(1)
        for (j in 0 until n - i - 1) {
            if (arr[j] > arr[j + 1]) {
                val temp = arr[j] // O(1)
                arr[j] = arr[j + 1]
                arr[j + 1] = temp
            }
        }
    }
}

上述代码的空间复杂度为O(1),因为它所使用的额外空间与输入规模无关,只占用了几个额外的变量。

2.4.2 O(n)实例
fun fibonacci(n: Int): Int {
    val memo = IntArray(n + 1) { -1 }
    return fibonacciHelper(n, memo)
}

fun fibonacciHelper(n: Int, memo: IntArray): Int {
    if (n <= 1) return n
    if (memo[n] != -1) return memo[n]
    memo[n] = fibonacciHelper(n - 1, memo) + fibonacciHelper(n - 2, memo)
    return memo[n]
}

一个空间复杂度为O(n)的典型实例是使用递归方法计算斐波那契数列时存储所有中间结果(动态规划中的记忆化技术)。

在这种方法中,我们需要一个数组来存储计算过程中生成的所有斐波那契数,因此空间复杂度为O(n)

2.4.3 O(log n)实例
fun binarySearch(arr: IntArray, target: Int, low: Int = 0, high: Int = arr.size - 1): Int {
    if (low > high) return -1
    val mid = low + (high - low) / 2
    return when {
        arr[mid] == target -> mid
        arr[mid] > target -> binarySearch(arr, target, low, mid - 1)
        else -> binarySearch(arr, target, mid + 1, high)
    }
}

二分查找是一种经典的在有序数组中查找元素的算法。它使用递归的方式,每次将搜索空间减半,因此递归调用栈的深度为O(log n)

2.4.2 计算斐波那契数列的算法中也存在递归调用,其空间复杂度公式中也存在O(log n),但由于其存在更高阶的O(n)空间复杂度,所以O(log n)被忽略

2.4.4 O(n^2)实例
fun matrixMultiply(A: Array<IntArray>, B: Array<IntArray>): Array<IntArray> {
    val n = A.size
    val C = Array(n) { IntArray(n) }
    for (i in 0 until n) {
        for (j in 0 until n) {
            C[i][j] = 0
            for (k in 0 until n) {
                C[i][j] += A[i][k] * B[k][j]
            }
        }
    }
    return C
}

矩阵乘法中,如果我们要将两个𝑛×𝑛𝑛×𝑛的矩阵相乘,结果矩阵的大小也是n×nn×n,因此空间复杂度为 O(n^2)

2.4.5 O(2^n)实例
fun subsets(nums: IntArray): List<List<Int>> {
    val result = mutableListOf<List<Int>>()
    generateSubsets(nums, 0, mutableListOf(), result)
    return result
}

fun generateSubsets(nums: IntArray, index: Int, current: MutableList<Int>, result: MutableList<List<Int>>) {
    if (index == nums.size) {
        result.add(ArrayList(current))
        return
    }
    generateSubsets(nums, index + 1, current, result)
    current.add(nums[index])
    generateSubsets(nums, index + 1, current, result)
    current.removeAt(current.size - 1)
}

在求解一个集合的所有子集时,子集的数量为2n2^n ,因此存储这些子集所需的空间复杂度为 O(2^n)

2.4.6 O(n!)实例
fun permute(nums: IntArray): List<List<Int>> {
    val result = mutableListOf<List<Int>>()
    generatePermutations(nums.toList(), mutableListOf(), result)
    return result
}

fun generatePermutations(nums: List<Int>, current: MutableList<Int>, result: MutableList<List<Int>>) {
    if (nums.isEmpty()) {
        result.add(ArrayList(current))
    } else {
        for (i in nums.indices) {
            current.add(nums[i])
            generatePermutations(nums - nums[i], current, result)
            current.removeAt(current.size - 1)
        }
    }
}

在求解一个数组的所有排列时,排列的数量为𝑛!,存储这些排列所需的空间复杂度为O(n!)

三、总结

理解和分析算法的时间复杂度和空间复杂度,是编写高效算法的基础。通过识别和优化这些复杂度,我们能够设计出更高效的程序,从而提升软件性能。