算法 -- 复杂度计算

估算算法的时间复杂度是分析算法效率的重要手段,以下是详细的估算方法及相关要点:

1. 确定基本操作

首先要明确算法中的基本操作,也就是那些对时间复杂度起决定性作用、执行次数最多的操作。不同类型的算法,基本操作有所不同,例如:

  • 排序算法:像冒泡排序、插入排序等比较类排序算法,元素之间的比较操作以及可能的交换操作通常是基本操作。因为在整个排序过程中,会频繁地进行元素两两比较,并根据比较结果决定是否交换位置,这些操作的执行次数直接影响算法的运行时间。
  • 搜索算法:例如线性搜索算法在数组中查找目标元素时,对数组元素的访问操作(逐个查看元素是否为目标元素)就是基本操作,其执行次数取决于数组的长度以及目标元素所在的位置。

2. 分析基本操作执行次数与输入规模的关系

找出基本操作后,重点关注其执行次数随输入规模(通常用 (n) 表示,比如数组的长度、链表的节点个数、树的节点数等)变化的规律,这是估算时间复杂度的核心步骤。常见的情况如下:

(1)常数阶 (O(1))

如果基本操作的执行次数不随输入规模 (n) 的变化而变化,无论 (n) 取何值,操作执行次数始终是一个固定的常数,那么时间复杂度就是 (O(1))。例如:

1
2
3
function getFirstElement(arr) {
return arr[0];
}

在这个函数中,无论输入的数组 arr 长度 (n) 是多少,都只是执行了一次获取数组第一个元素的操作,基本操作执行次数固定,所以时间复杂度为 (O(1))。

(2)线性阶 (O(n))

当基本操作的执行次数与输入规模 (n) 成正比关系,即随着 (n) 的增大,基本操作执行次数线性增长,那么时间复杂度就是 (O(n))。比如线性搜索算法的示例代码如下:

1
2
3
4
5
6
7
8
function linearSearch(arr, target) {
for (let i = 0; i < arr.length; i++) {
if (arr[i] === target) {
return i;
}
}
return -1;
}

在这个函数中,要在数组 arr 中查找目标元素 target,最坏的情况是遍历完整个数组(长度为 (n))都没有找到,也就是需要对数组元素进行 (n) 次访问操作,基本操作执行次数与 (n) 呈线性关系,所以时间复杂度是 (O(n))。

(3)平方阶 (O(n^2))

如果基本操作执行次数与输入规模 (n) 的平方成正比,通常出现在嵌套循环中,外层循环遍历一次,内层循环要完整遍历 (n) 次,整体执行次数就是 (n \times n = n^2),时间复杂度即为 (O(n^2))。例如冒泡排序的简单实现:

1
2
3
4
5
6
7
8
9
10
11
function bubbleSort(arr) {
const n = arr.length;
for (let i = 0; i < n - 1; i++) {
for (let j = 0; j < n - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
[arr[j], arr[j + 1]] = [arr[j + 1], arr[j]];
}
}
}
return arr;
}

在冒泡排序中,有两层嵌套的 for 循环,外层循环控制比较轮数(一共 (n - 1) 轮),每一轮内层循环都要对当前还未完全排好序的部分数组(长度逐渐减少,但最多是 (n) 个元素)进行相邻元素的比较和交换操作,总的比较交换操作次数大约是 (n^2) 量级,所以时间复杂度为 (O(n^2))。

(4)对数阶 (O(\log n))

这种情况常见于每次操作都能将问题规模缩小一定比例的算法,比如二分查找算法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function binarySearch(arr, target) {
let left = 0;
let right = arr.length - 1;
while (left <= right) {
const mid = Math.floor((left + right) / 2);
if (arr[mid] === target) {
return mid;
} else if (arr[mid] < target) {
left = mid + 1;
} else {
right = mid - 1;
}
}
return -1;
}

在二分查找中,每次比较中间元素后,都能将搜索区间缩小一半,假设数组长度为 (n),经过 (k) 次比较后,搜索区间缩小到只剩一个元素(即 (n / 2^k = 1),可得 (k = \log_2 n)),所以基本操作(元素比较操作)的执行次数与 ( \log_2 n) 成正比,时间复杂度就是 (O(\log n))(通常省略底数,因为不同底数的对数函数之间仅相差一个常数因子,在时间复杂度分析中不影响大 (O) 表示)。

(5)指数阶 (O(2^n))

在一些递归算法中,如果每一层递归调用都会产生两个及以上的子问题,且子问题规模和原问题相近,就容易出现指数阶的时间复杂度。例如经典的斐波那契数列的递归解法:

1
2
3
4
5
6
function fibonacci(n) {
if (n === 0 || n === 1) {
return n;
}
return fibonacci(n - 1) + fibonacci(n - 2);
}

计算斐波那契数列的第 (n) 项时,要分别递归计算第 (n - 1) 项和第 (n - 2) 项,以此类推,会形成一棵二叉树状的递归调用结构,节点数随着 (n) 的增加呈指数增长,大约是 (2^n) 的量级,所以时间复杂度为 (O(2^n))。

3. 忽略低阶项和常数系数

在使用大 (O) 表示法估算时间复杂度时,只关注随着输入规模 (n) 无限增大时起主导作用的项,会忽略低阶项和常数系数。例如,某个算法的执行时间可以表示为 (T(n) = 3n^2 + 5n + 7),当 (n) 足够大时, (n^2) 项的增长速度远远快于 (n) 项和常数项,所以该算法的时间复杂度用大 (O) 表示就是 (O(n^2))。

4. 综合考虑最好、最坏和平均情况

有些算法的执行时间会因输入数据的不同而有较大差异,所以需要分别分析最好情况、最坏情况和平均情况下的时间复杂度。

(1)最好情况

指的是对于给定的算法,在最理想的输入数据下的运行时间复杂度。例如在有序数组中使用线性搜索查找第一个元素,只需要访问一次数组元素就能找到,时间复杂度就是 (O(1)),这就是线性搜索的最好情况时间复杂度,但通常这种最好情况在实际应用中不一定能保证总是出现。

(2)最坏情况

是在最不利的输入数据下算法的运行时间复杂度。还是以线性搜索为例,在数组中查找一个不存在的元素,需要遍历完整个数组,时间复杂度就是 (O(n)),这是它的最坏情况时间复杂度,在分析算法性能的下限,确保算法在任何情况下都能满足一定的时间要求时,考虑最坏情况很重要。

(3)平均情况

考虑各种可能的输入数据及其出现的概率,计算出的平均运行时间复杂度。对于线性搜索,如果假设目标元素在数组中任何位置出现的概率是均等的,那么平均要遍历数组一半的元素才能找到目标元素(或者确定不存在),此时平均情况时间复杂度就是 (O(n/2)),但按照忽略常数系数的原则,同样表示为 (O(n))。

通过以上步骤,就能对算法的时间复杂度进行较为准确的估算,从而在不同的应用场景中选择合适的算法,或者对现有算法进行性能优化。

注意事项

  1. 准确识别基本操作:基本操作的选取直接影响时间复杂度的估算结果,如果选错了基本操作,可能会错误地判断算法的效率。要深入理解算法的核心逻辑,找出真正对运行时间起关键作用的操作,避免将一些次要、执行次数少的操作误当作基本操作来分析。
  2. 考虑实际输入分布(针对平均情况复杂度):计算平均情况时间复杂度时,需要对输入数据的分布有合理的假设,不同的假设可能导致不同的平均情况复杂度结果。在实际应用中,要尽量根据业务场景中数据的真实分布特点来设定假设,使平均情况复杂度更贴近实际的算法性能表现,否则可能得出过于乐观或悲观的结论,影响算法选型和优化决策。
  3. 渐进分析的局限性:大 (O) 表示法是一种渐近分析方法,它关注的是输入规模 (n) 趋向于无穷大时的情况,对于小规模的输入数据,时间复杂度的分析结果可能与实际运行时间不完全相符。例如,一个时间复杂度为 (O(n^2)) 的算法在 (n) 较小时,可能由于常数系数小等原因,实际运行时间比一个时间复杂度为 (O(n)) 但常数系数很大的算法还要短,所以在评估算法性能时,不能仅仅依赖时间复杂度分析,还需要结合实际的测试数据来综合判断。
  4. 不同操作的时间成本差异(在实际环境中):虽然在理论上通过分析基本操作执行次数来估算时间复杂度,但在实际的计算机系统中,不同类型的操作(如内存读写、算术运算、函数调用等)其实际执行时间成本是不一样的,而且还会受到硬件、编程语言实现等因素影响。在对性能要求极高、时间复杂度相近的算法进行抉择时,需要进一步考虑这些实际操作的成本差异,通过实际测试等手段来确定更优的算法选择。

算法 -- 复杂度计算
http://example.com/2023/12/01/算法-时间复杂度/
作者
lyric
发布于
2023年12月1日
许可协议