算法复杂度分析
前言
算法(Algorithm)是指用来操作数据、解决程序问题的一组方法。对于同一个问题,使用不同的算法,也许最终得到的结果是一样的,但在过程中消耗的资源和时间却会有很大的区别。
那么我们应该如何去衡量不同算法之间的优劣呢?
主要还是从算法所占用的「时间」和「空间」两个维度去考量。
- 时间维度:是指执行当前算法所消耗的时间,我们通常用时间复杂度来描述。
- 空间维度:是指执行当前算法需要占用多少内存空间,我们通常用空间复杂度来描述。
因此,评价一个算法的效率主要是看它的时间复杂度和空间复杂度情况。然而,有的时候时间和空间却又是「鱼和熊掌」,不可兼得的,那么我们就需要从中去取一个平衡点。
算法复杂度分析
算法复杂度分析是评估算法效率和资源消耗的关键方法。它主要关注两个方面:
- 时间复杂度 (Time Complexity):衡量算法执行时间随输入数据规模增长而变化的趋势。
- 空间复杂度 (Space Complexity):衡量算法执行过程中所需额外存储空间随输入数据规模增长而变化的趋势。
我们通常使用大O表示法 (Big O Notation) 来描述复杂度,它表示算法执行时间/空间消耗的上界,关注的是当输入规模n
趋向于无穷大时的增长率,忽略常数项和低阶项。
常见的复杂度级别(从低到高):
- O(1):常数复杂度 (Constant) - 执行时间/空间与输入规模无关。
- O(log n):对数复杂度 (Logarithmic) - 执行时间/空间随
n
的对数增长。 - O(n):线性复杂度 (Linear) - 执行时间/空间随
n
线性增长。 - O(n log n):线性对数复杂度 (Linearithmic) - 常见于高效排序算法。
- O(n^2):平方复杂度 (Quadratic) - 执行时间/空间随
n
的平方增长,常见于简单排序算法(如冒泡、选择)。 - O(n^3):立方复杂度 (Cubic) - 执行时间/空间随
n
的立方增长。 - O(2^n):指数复杂度 (Exponential) - 执行时间/空间随
n
指数级增长,非常慢。 - O(n!):阶乘复杂度 (Factorial) - 比指数增长更快,通常只适用于非常小的
n
。
如何分析和统计?
1. 时间复杂度分析:
- 找出基本操作:确定算法中执行次数最多的语句或操作。
- 计算执行次数:计算该基本操作相对于输入规模
n
的执行次数函数f(n)
。 - 取最高阶项:如果
f(n)
是一个多项式,只保留最高阶项。 - 去掉常数系数:去掉最高阶项的常数系数。
- 表示为大O:结果即为时间复杂度。
关注点:
- 循环结构:单层循环通常是 O(n),嵌套循环通常是 O(n^k) (k是嵌套层数)。
- 递归结构:分析递归调用的次数和每次调用的操作数。
- 最坏情况、平均情况、最好情况:通常我们关注最坏情况复杂度,因为它提供了性能的保证。
2. 空间复杂度分析:
-
识别额外空间
:确定算法在执行过程中除了存储输入数据本身外,还需要哪些额外的存储空间。这包括:
- 为存储变量、对象分配的空间。
- 递归调用栈的空间(每次递归调用都会在栈上创建一个新的栈帧)。
- 动态分配的数据结构(如数组、哈希表等)。
-
计算额外空间大小:计算这些额外空间相对于输入规模
n
的大小函数g(n)
。 -
表示为大O:
O(g(n))
即为空间复杂度。
注意:
- 输入空间:通常不计入空间复杂度分析,我们关心的是辅助空间 (Auxiliary Space)。
- 原地算法 (In-place Algorithm):如果算法所需的额外空间是常数级的,即 O(1),则称为原地算法。
Go 语言示例
示例 1:线性搜索 (Linear Search)
package main
import "fmt"
// linearSearch 在一个整数切片中查找目标值
// arr: 输入的整数切片
// target: 要查找的目标值
// 返回目标值的索引,如果未找到则返回 -1
func linearSearch(arr []int, target int) int {
// 1. n 是输入切片 arr 的长度
n := len(arr)
// 2. 这是一个单层循环
// 在最坏情况下(目标值在最后或不存在),循环会执行 n 次
for i := 0; i < n; i++ {
// 3. 核心操作是比较 arr[i] 和 target
if arr[i] == target {
return i // 找到目标,立即返回
}
}
return -1 // 遍历完整个切片都未找到
}
func main() {
data := []int{10, 20, 80, 30, 60, 50, 110, 100, 130, 170}
target1 := 30
target2 := 99
index1 := linearSearch(data, target1)
fmt.Printf("Target %d found at index: %d\n", target1, index1) // Target 30 found at index: 3
index2 := linearSearch(data, target2)
fmt.Printf("Target %d found at index: %d\n", target2, index2) // Target 99 found at index: -1
}
复杂度分析 (linearSearch):
- 时间复杂度:O(n)
n = len(arr)
是输入规模。for
循环在最坏情况下(目标元素在最后或不存在)会执行n
次。- 循环体内的操作(
arr[i] == target
)是常数时间 O(1)。 - 因此,总的执行时间与
n
成正比。所以时间复杂度是 O(n)。
- 空间复杂度:O(1)
- 算法使用的额外空间主要是几个变量:
n
(存储长度),i
(循环计数器)。 - 这些变量占用的空间是固定的,不随输入规模
n
的变化而变化。 - 输入数据
arr
和target
的空间不计入额外空间复杂度。 - 因此,空间复杂度是 O(1)。
- 算法使用的额外空间主要是几个变量:
示例 2:冒泡排序 (Bubble Sort)
package main
import "fmt"
// bubbleSort 使用冒泡排序对整数切片进行升序排序
// arr: 输入的整数切片
func bubbleSort(arr []int) {
// 1. n 是输入切片 arr 的长度
n := len(arr)
if n <= 1 {
return // 如果切片为空或只有一个元素,则无需排序
}
// 2. 外层循环控制排序的轮数
// 对于长度为 n 的数组,最多需要 n-1 轮
for i := 0; i < n-1; i++ {
swapped := false // 优化:如果在一轮中没有发生交换,说明数组已经有序
// 3. 内层循环进行相邻元素的比较和交换
// 每一轮会将当前未排序部分的最大(或最小)元素“冒泡”到正确位置
// 内层循环的次数会随着 i 的增加而减少:n-1, n-2, ..., 1
for j := 0; j < n-1-i; j++ {
// 4. 核心操作是比较和可能的交换
if arr[j] > arr[j+1] {
arr[j], arr[j+1] = arr[j+1], arr[j] // 交换
swapped = true
}
}
if !swapped { // 如果本轮没有发生任何交换,则数组已经完全排序
break
}
}
}
func main() {
data1 := []int{64, 34, 25, 12, 22, 11, 90}
fmt.Println("Original array:", data1)
bubbleSort(data1)
fmt.Println("Sorted array: ", data1) // Sorted array: [11 12 22 25 34 64 90]
data2 := []int{1, 2, 3, 4, 5}
fmt.Println("Original array:", data2)
bubbleSort(data2)
fmt.Println("Sorted array: ", data2) // Sorted array: [1 2 3 4 5] (优化会提前结束)
}
复杂度分析 (bubbleSort):
- 时间复杂度:O(n^2)
n = len(arr)
是输入规模。- 外层循环执行
n-1
次(近似n
次)。 - 内层循环在最坏情况下(例如,数组逆序)的执行次数:
- 第一轮:
n-1
次比较 - 第二轮:
n-2
次比较 - …
- 最后一轮:
1
次比较
- 第一轮:
- 总的比较次数大约是
(n-1) + (n-2) + ... + 1 = n*(n-1)/2
。 - 这是一个
n^2
级别的操作数。去掉常数系数1/2
和低阶项-n/2
,得到 O(n^2)。 - 最好情况:如果数组已经排序,并且有
swapped
优化,外层循环只执行一次,内层循环执行n-1
次,时间复杂度为 O(n)。但大O通常指最坏情况。
- 空间复杂度:O(1)
- 算法使用的额外空间主要是几个变量:
n
(存储长度),i
(外层循环计数器),j
(内层循环计数器),swapped
(布尔标志)。 - 这些变量占用的空间是固定的,不随输入规模
n
的变化而变化。 - 排序是在原始数组上进行的(in-place),没有使用额外的与
n
相关的数组或数据结构。 - 因此,空间复杂度是 O(1)。
- 算法使用的额外空间主要是几个变量:
示例 3:归并排序(时间复杂度 (O(n \log n)),空间复杂度 (O(n)))
归并排序是一种分治算法,通过递归地将数组分成两半,分别排序后合并。
package main
import "fmt"
// MergeSort 归并排序主函数
func MergeSort(arr []int) []int {
if len(arr) <= 1 {
return arr
}
mid := len(arr) / 2
left := MergeSort(arr[:mid])
right := MergeSort(arr[mid:])
return merge(left, right)
}
// merge 合并两个有序数组
func merge(left, right []int) []int {
result := make([]int, 0, len(left)+len(right))
i, j := 0, 0
for i < len(left) && j < len(right) {
if left[i] <= right[j] {
result = append(result, left[i])
i++
} else {
result = append(result, right[j])
j++
}
}
result = append(result, left[i:]...)
result = append(result, right[j:]...)
return result
}
func main() {
arr := []int{3, 1, 4, 1, 5, 9, 2, 6, 5, 3}
sorted := MergeSort(arr)
fmt.Println("排序后数组:", sorted)
}
复杂度分析:
- 时间复杂度:
- 归并排序将数组递归地分成两半,递归深度为 (\log n)(每次数组长度减半)。
- 在每一层递归中,
merge
操作需要比较和合并两个子数组,总共处理 (n) 个元素,因此每层的工作量为 (O(n))。 - 总时间复杂度为:递归层数 (\times) 每层工作量,即 (O(n \log n))。
- 无论最好、最坏还是平均情况,时间复杂度均为 (O(n \log n))。
- 空间复杂度:
- 递归调用栈的深度为 (\log n),因此递归栈空间为 (O(\log n))。
merge
操作中需要一个额外的数组来存储合并结果,大小为 (n),因此额外空间为 (O(n))。- 总空间复杂度为 (O(n))(忽略较低阶项 (O(\log n)))。
常见排序算法算法复杂度
排序算法 | 平均时间复杂度 | 最好情况 | 最坏情况 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(n2) | O(n) | O(n2) | O(1) | 稳定 |
选择排序 | O(n2) | O(n2) | O(n2) | O(1) | 不稳定 |
插入排序 | O(n2) | O(n) | O(n2) | O(1) | 稳定 |
希尔排序 | O(nlogn)~O(n2) | O(n2) | O(n2) | O(1) | 不稳定 |
归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 稳定 |
快速排序 | O(nlogn) | O(nlogn) | O(n2) | O(logn)~O(n) | 不稳定 |
堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 不稳定 |
计数排序 | O(n+k) | O(n+k) | O(n+k) | O(k) | 稳定 |
桶排序 | O(n+k) | O(n+k) | O(n2) | O(n+k) | 稳定 |
基数排序 | O(n×k) | O(n×k) | O(n×k) | O(n+k) | 稳定 |
总结
- 算法分析不是精确计算时间/空间,而是关注其随输入规模增长的趋势。
- 大O表示法是描述这种趋势的标准工具,它关注的是上界和最坏情况(通常)。
- 选择合适的算法对程序的性能至关重要,尤其是在处理大规模数据时。一个 O(n^2) 算法在
n
很大时会比 O(n log n) 算法慢得多。 - 在Go中,除了理论分析,还可以使用
testing
包中的基准测试 (benchmark) 功能来实际测量代码段的执行时间,这对于比较不同实现或优化效果很有帮助。但基准测试结果受具体机器和环境影响,而复杂度分析提供的是更通用的理论指导。