如何进行算法时间复杂度和空间复杂度分析
在计算机科学中,分析算法的效率和资源使用是非常重要的。
算法的效率通常通过时间复杂度和空间复杂度来衡量。
一、时间复杂度分析
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
}
矩阵乘法中,如果我们要将两个的矩阵相乘,结果矩阵的大小也是,因此空间复杂度为 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)
}
在求解一个集合的所有子集时,子集的数量为
,因此存储这些子集所需的空间复杂度为 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!)。
三、总结
理解和分析算法的时间复杂度和空间复杂度,是编写高效算法的基础。通过识别和优化这些复杂度,我们能够设计出更高效的程序,从而提升软件性能。