时间复杂度
前言
-
近年来,越来越多的的大厂甚至是中小厂在面试的时候都喜欢手撕算法,因为算法是短时间内最能考验一个面试者逻辑思维跟代码实力的最好方式。对于社招来说,数据结构跟算法是一个程序员的必备技能,也是编程的基础,而项目都没有经历过的应届毕业生,数据结构和算法的掌握就决定了这场面试的成败。
-
但是学算法之前需要我们至少掌握一门计算机语言,因为接下来我们的算法主要是使用python来写,所以,要求大家对python有一定的基础,如有零基础小白,可以从Python入门学起,等基础打好再来学习算法。
-
要学算法,那绕不开的两个概念就是:时间复杂度(Time Complexity)和空间复杂度(Space Complexity),接下来我们先学习时间复杂度。
一、时间复杂度是什么?
1.定义
-
时间复杂度表示算法执行所需时间的度量,它关注算法的执行时间如何随着输入规模的增加而增长。
-
在软件开发中,一般通过时间复杂度来预估程序的运行时间。通常以算法的操作单元数量来代表程序消耗的时间(这里默认算法的每个操作单元运行所消耗的时间都相同)
2.表示方法
- 使用大 O O O记法(Big O O O notation)来表示时间复杂度,记为 O ( f ( n ) ) O(f(n)) O(f(n)),其中 n n n 是输入规模, O ( f ( n ) ) O(f(n)) O(f(n)) 是算法执行次数与n的关系。
- 常见的时间复杂度有: O ( 1 ) O(1) O(1)(常数时间)、 O ( l o g ( n ) ) O(log(n)) O(log(n))(对数时间)、 O ( n ) O(n) O(n)(线性时间)、 O ( n log ( n ) ) O(n\log(n)) O(nlog(n))(线性对数时间)、 O ( n 2 ) O(n^2) O(n2) (平方时间)、 O ( n 3 ) O(n^3) O(n3) (立方时间)、 O ( 2 n ) O(2^n) O(2n)(指数时间)等。
3. O O O 指的是什么呢?
-
说到时间复杂度跟空间复杂度,大家可能都能想起来 O ( n ) O(n) O(n)、 O ( n 2 ) O(n^2) O(n2) ,却说不清什么 O O O 是什么。
- 在(算法导论)中: O O O 用来表示上界,当他作为算法的最坏情况下运行时间的上界时,就是对任意数据输入运行时间的上限。
-
但在业内我们一般说的 O O O 代表的是一般情况下的时间复杂度,而并非上界。
-
例如,插入排序算法在数据有序的时候,时间复杂度是 O ( n ) O(n) O(n)。如果是数据是逆序的话,那么时间复杂度就是 O ( n 2 ) O(n^2) O(n2)。对于所以输入情况来看,最坏的情况下的时间复杂度是 O ( n 2 ) O(n^2) O(n2)。所以我们说插入排序的时间复杂度为 O ( n 2 ) O(n^2) O(n2)。
-
同理,我们知道快速排序的时间复杂度是 O ( n log ( n ) ) O(n\log(n)) O(nlog(n)),但是在数据有序的时候(也就是每次都选中了最大或者最小的元素为基准,导致一边的子数组为空,此时算法性能最差),时间复杂度是 O ( n 2 ) O(n^2) O(n2),所以从 O O O 的定义上来说,快速排序的时间复杂度应该是 O ( n 2 ) O(n^2) O(n2),但我们依然说快速排序的时间复杂度是 O ( n log ( n ) ) O(n\log(n)) O(nlog(n)),就是因为 O O O 代表的是平均情况(一般情况)下,而并非上界。如图所示:
-
-
所以我们主要关心的是一般情况下的数据格式。面试中涉及到的算法的时间复杂度指的都是一般情况。当深入讨论一个算法的实现以及性能的时候,就要想着数据用例不一样,时间复杂度也就不一样,大家一定要注意。
二、推算方法
- 算数上界可能有难难理解,一般我们确定
O
O
O 的上界总体分两步:
- 确定操作数量
- 判断上界
1.确定操作数量 ( T ( n ) ) (T(n)) (T(n))
- (1)忽略代码中的常数项,因为他们与 n 无关,所以对时间复杂度不产生影响
- (2) 省略所有系数。例如,循环 2n 次、5n+1 次等,都可以简化记为 n 次,因为 n 前面的系数 对时间复杂度没有影响。
- (3)循环嵌套时使用乘法。总操作数量等于外层循环和内层循环操作数量之积,每一层循环依然可以分别套用前两点的技巧。
我们用以下函数来演示
def algorithm(n: int):
a = 1 # +0(遵循第一点)
a = a + n # +0(遵循第一点)
# +n(遵循第二点)
for i in range(5 * n + 1):
print(0)
# +n*n(遵循第三点)
for i in range(2 * n):
for j in range(n + 1):
print(0)
上述代码的操作数量 T ( n ) T(n) T(n) = 0 + 0 + n n n + n 2 n^2 n2 = n n n + n 2 n^2 n2
2.判断上界
- 时间复杂度由 T ( n ) T(n) T(n)中最高阶的项来决定。这是因为 n n n在趋于无穷大时,最高阶的项将发挥主导作用,其他项的影响都可以忽略。
- 不同操作数量对应的时间复杂度如下所示:
操作数量 T ( n ) T(n) T(n) | 时间复杂度 O ( f ( n ) ) O(f(n)) O(f(n)) |
---|---|
10000000 | O ( 1 ) O(1) O(1) |
5 n + 1 5n+1 5n+1 | O ( n ) O(n) O(n) |
2 n 2 + 5 n + 1 2n^2+5n+1 2n2+5n+1 | O ( n 2 ) O(n^2) O(n2) |
2 n 3 + 100 n 2 2n^3+100n^2 2n3+100n2 | O ( n 3 ) O(n^3) O(n3) |
2 n + 100 n 2 2^n+100n^2 2n+100n2 | O ( 2 n ) O(2^n) O(2n) |
三、不同复杂度的代码演示
1.时间复杂度为 O ( 1 ) O(1) O(1)
例如执行固定次数的操作:
def sum():
a = 5 # 定义变量
b = 10
c = a + b # 执行了固定的数学运算
return c # 返回计算结果
# 调用函数
result = sum()
print(result)
2.时间复杂度为 O ( log ( n ) ) O(\log(n)) O(log(n))
时间复杂度为 O ( log ( n ) ) O(\log(n)) O(log(n))的算法通常与二分查找(Binary Search)算法或某些基于分治策略(Divide and Conquer)的算法相关。下面我将提供一个二分查找算法的实现,它是最典型的时间复杂度为 O ( log ( n ) ) O(\log(n)) O(log(n))的算法之一。
def binary_search(arr, target):
"""
二分查找算法
:param arr: 有序数组
:param target: 需要查找的目标值
:return: 目标值在数组中的索引,如果不存在则返回-1
"""
left = 0, , right = len(arr) - 1 #定义两个索引来标识数组的第一个数跟最后一个数
while left <= right: #因为二分查找的数据必须是有序的,所以要先判断
mid = (left + right) // 2 #两个斜线代表只取整数并且不会四舍五入,找到中间索引
if arr[mid] == target: #如果中间的数刚好等于目标数
return mid # 找到目标值,返回索引
elif arr[mid] < target: #若中间的数小于目标数
left = mid + 1 # 则就要去右半区找目标数,此时将左边界调整
else:
right = mid - 1 # 若中间数大于目标数就要去左半区找目标数调整右边界
return -1 # 若数组不是有序的,则返回未找到目标值
# 示例
arr = [数组]
target = 这里写目标值
index = binary_search(arr, target)
print(f"元素{target}在数组中的索引为:{index}")
- 这段代码定义了一个binary_search函数,它接受一个有序数组arr和一个目标值target作为输入,然后返回目标值在数组中的索引。如果目标值不存在于数组中,则返回-1。其中 n n n是数组的长度,因为每次比较都会将搜索范围减半,所以这个算法的时间复杂度是 O ( log ( n ) ) O(\log(n)) O(log(n))。
3.时间复杂度为 O ( n ) O(n) O(n)
遍历数组并打印每个元素:
def print_array(arr):
"""
:param arr: 输入的数组
:return: 无返回值,直接打印数组元素
"""
for element in arr:
print(element)
# 示例
arr = [数组]
print_array(arr)
-
在这个例子中,使用一个for循环遍历数组中的每个元素,然后打印出来。因为每个元素都恰好被处理了一次,所以算法的时间复杂度是 O ( n ) O(n) O(n)。
-
注意,虽然这个算法的时间复杂度是 O ( n ) O(n) O(n),但在某些情况下,我们可能会遇到一些额外的开销,比如函数调用本身的开销或打印操作的开销。然而,这些开销通常与输入规模 n 无关,因此不会改变算法的时间复杂度类别。我们主要关注的是与 n 成线性关系的操作次数。
4.时间复杂度为 O ( n log ( n ) ) O(n\log(n)) O(nlog(n))
时间复杂度为 O ( n log ( n ) ) O(n\log(n)) O(nlog(n)) 的算法通常涉及到排序算法,如快速排序、归并排序、堆排序等,在某些情况下也可能是其他类型的分治算法。下面我将演示一个归并排序(Merge Sort)的Python实现。
def merge_sort(arr):
"""
:param arr: 输入的数组
:return: 无返回值,直接打印数组元素
"""
if len(arr) > 1:
mid = len(arr) // 2 # 两个斜线代表只取整数并且不会四舍五入,找到中间位置
L = arr[:mid] # 分割左半部分
R = arr[mid:] # 分割右半部分
merge_sort(L) # 递归排序左半部分
merge_sort(R) # 递归排序右半部分
i = j = k = 0
# 合并两个已排序的数组
while i < len(L) and j < len(R):
if L[i] < R[j]:
arr[k] = L[i]
i += 1
else:
arr[k] = R[j]
j += 1
k += 1
# 检查是否还有剩余元素
while i < len(L):
arr[k] = L[i]
i += 1
k += 1
while j < len(R):
arr[k] = R[j]
j += 1
k += 1
# 示例
arr = [数组]
merge_sort(arr)
print("排序后的数组:", arr)
- 归并排序的时间复杂度主要由两部分组成:分解和合并。分解的时间复杂度是 O ( log ( n ) ) O(\log(n)) O(log(n))(因为每次都将数组分成两半),但由于这个分解过程会递归地发生在每一层上,所以总的时间复杂度是 O ( n log ( n ) ) O(n\log(n)) O(nlog(n))(因为每一层都需要遍历整个数组来合并)。合并的时间复杂度也是 O ( n ) O(n) O(n),但由于它发生在每一层上,所以同样贡献了 O ( n log ( n ) ) O(n\log(n)) O(nlog(n)) 的时间复杂度。
5.时间复杂度为 O ( n 2 ) O(n^2) O(n2)
时间复杂度为 O ( n 2 ) O(n^2) O(n2) 的算法通常意味着算法中需要嵌套两层循环,并且每层循环都与输入规模 ( n n n) 直接相关。
def sum_of_pairs(arr):
"""
计算数组中所有元素对的和
:param arr: 输入的整数数组
:return: 所有元素对的和
"""
n = len(arr)
total_sum = 0
for i in range(n):
for j in range(n):
total_sum += arr[i] * arr[j] # 这里也可以是简单的加法 arr[i] + arr[j],但为了展示乘法也适用
return total_sum
# 示例
arr = [1, 2, 3]
result = sum_of_pairs(arr)
print("所有元素对的和(乘法)为:", result)
-
这个例子中嵌套了两层 for 循环,外层循环遍历数组的每个元素(索引为 i ),内层循环则遍历数组的所有元素(索引为 j ),包括外层循环当前正在处理的元素。因此,对于数组中的每个元素,都会与数组中的其他所有元素进行一次操作,导致总的时间复杂度为 O ( n 2 ) O(n^2) O(n2),其中 n n n 是数组的长度。
-
请注意,这个算法在实际应用中可能不是最高效的,特别是当数组很大时,因为它会执行大量的重复计算。
三、细节
- 1、在决定使用哪些算法的时候,并不是时间复杂度越低越好(因为简化后的时间复杂度忽略了常数项等),还需要考虑数据的规模大小,因为如果数据量小的时候,就会出现时间复杂度 O ( n 2 ) O(n^2) O(n2)的算法比时间复杂度 O ( n ) O(n) O(n)的算法更合适的情况(在有常数项的时候)。
- 2、那么为什么计算时间复杂度要忽略了常数项呢?
- 也就是说我们将 O ( 100 n ) O(100n) O(100n) 写成 O ( n ) O(n) O(n), O ( 5 n 2 ) O(5n^2) O(5n2)写成 O ( n 2 ) O(n^2) O(n2),而且默认 O ( n ) O(n) O(n) 优于 O ( n 2 ) O(n^2) O(n2)。这是因为我们所说的 O O O 的定义就是在数据规模非常大的情况下所表现出来的时间复杂度,此时的数据规模是常数项系数已经起不到决定作用的数据规模了。
- 3、所以在默认数据规模足够大的情况下我们有以下排名
- O ( 1 ) O(1) O(1) 常数项 < O ( l o g ( n ) ) O(log(n)) O(log(n)) 对数阶 < O ( n ) O(n) O(n) 线性阶 < O ( n log ( n ) ) O(n\log(n)) O(nlog(n)) 线性对数阶 < O ( n 2 ) O(n^2) O(n2) 平方阶 < O ( n 3 ) O(n^3) O(n3) 立方阶 < O ( 2 n ) O(2^n) O(2n)指数阶
- 4、但也要注意大常数,如果非常大的常数,如 1 0 7 10^7 107, 1 0 9 10^9 109 那么常数项就是我们不得不考虑的事。
总结
- 以上就是今天要讲的内容,本文通过介绍了时间复杂度的定义,并且通过代码解释了不同时间复杂度的判定,能让读者更好的理解时间复杂度的概念。