MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Python中的时间复杂度与性能评估

2021-09-261.3k 阅读

理解时间复杂度的基础概念

什么是时间复杂度

在计算机科学中,时间复杂度是一个函数,它定性描述该算法的运行时间。这是一个代表算法输入值的字符串的长度的函数。时间复杂度常用大 O 记号表述,不包括这个函数的低阶项和首项系数。使用这种方式时,时间复杂度可被称为是渐近的,亦即考察输入值大小趋近无穷时的情况。

例如,假设有一个简单的 Python 函数用于计算列表中所有元素的和:

def sum_list(lst):
    total = 0
    for num in lst:
        total += num
    return total

在这个函数中,我们对列表中的每个元素进行一次加法操作。如果列表的长度为 n,那么加法操作的次数也为 n。因此,这个函数的时间复杂度为 O(n),其中 n 是列表的长度。

常见的时间复杂度类型

  1. 常数时间复杂度 O(1):无论输入规模如何变化,执行时间都保持恒定。例如访问列表中的某个特定元素:
my_list = [1, 2, 3, 4, 5]
element = my_list[2]  # 无论列表多长,访问操作时间恒定

这里访问列表中索引为 2 的元素,时间复杂度为 O(1),因为无论列表有多大,获取特定索引元素的操作时间基本相同。

  1. 线性时间复杂度 O(n):执行时间与输入规模成正比。前面的 sum_list 函数就是典型的 O(n) 例子。再比如遍历列表并打印每个元素:
def print_list(lst):
    for item in lst:
        print(item)

随着列表 lst 长度 n 的增加,打印操作的次数也线性增加,所以时间复杂度是 O(n)。

  1. 对数时间复杂度 O(log n):常见于二分查找算法。假设我们有一个有序列表,要查找某个元素的位置:
def binary_search(lst, target):
    low, high = 0, len(lst) - 1
    while low <= high:
        mid = (low + high) // 2
        if lst[mid] == target:
            return mid
        elif lst[mid] < target:
            low = mid + 1
        else:
            high = mid - 1
    return -1

在每次循环中,我们将搜索区间大致减半。如果列表长度为 n,那么循环次数约为 log₂n,所以时间复杂度是 O(log n)。

  1. 平方时间复杂度 O(n²):通常出现在嵌套循环中。例如,冒泡排序算法:
def bubble_sort(lst):
    n = len(lst)
    for i in range(n):
        for j in range(0, n - i - 1):
            if lst[j] > lst[j + 1]:
                lst[j], lst[j + 1] = lst[j + 1], lst[j]
    return lst

这里外层循环执行 n 次,内层循环在每次外层循环时执行次数递减,但总体平均下来,内层循环也执行约 n 次。所以总的操作次数约为 n×n,即时间复杂度为 O(n²)。

  1. 指数时间复杂度 O(2ⁿ):随着输入规模 n 的增加,执行时间呈指数级增长。以计算斐波那契数列的递归实现为例:
def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

在这个函数中,每个递归调用都会产生两个新的递归调用,随着 n 的增大,调用次数呈指数级增长,时间复杂度为 O(2ⁿ)。这种复杂度在实际应用中,当 n 较大时,计算量会变得极其庞大。

时间复杂度分析方法

基本操作计数

  1. 确定基本操作:在分析一个算法的时间复杂度时,首先要确定算法中的基本操作。基本操作是指那些执行时间相对稳定,并且与输入规模无关的操作。例如,在算术运算、比较运算、赋值运算等操作中,一次简单的加法运算 a + b 可以被视为一个基本操作。在 Python 中,对于列表操作,如访问 lst[i] 也可看作基本操作。
  2. 计算基本操作执行次数:以一个简单的函数为例,计算两个整数的乘积并返回结果:
def multiply(a, b):
    result = 0
    for _ in range(b):
        result += a
    return result

在这个函数中,基本操作有赋值操作 result = 0,循环中的加法操作 result += a。假设输入的 b 为 n,那么赋值操作执行 1 次,加法操作执行 n 次。总的基本操作执行次数 T(n) = 1 + n。当 n 趋近于无穷大时,低阶项 1 对整体影响较小,可以忽略。所以这个函数的时间复杂度为 O(n)。

循环分析

  1. 单层循环:对于单层循环,时间复杂度通常与循环次数成正比。例如,下面的函数用于计算 1 到 n 的整数和:
def sum_to_n(n):
    total = 0
    for i in range(1, n + 1):
        total += i
    return total

循环体中的加法操作 total += i 是基本操作,循环次数为 n,所以时间复杂度为 O(n)。 2. 嵌套循环:嵌套循环的时间复杂度计算相对复杂。例如,下面的函数用于生成一个 n×n 的矩阵:

def generate_matrix(n):
    matrix = []
    for i in range(n):
        row = []
        for j in range(n):
            row.append(i * j)
        matrix.append(row)
    return matrix

外层循环执行 n 次,对于外层循环的每一次执行,内层循环又执行 n 次。所以总的基本操作次数约为 n×n,时间复杂度为 O(n²)。如果是三层嵌套循环,例如:

def nested_loops(n):
    for i in range(n):
        for j in range(n):
            for k in range(n):
                print(i, j, k)

这里的时间复杂度就是 O(n³),因为总的操作次数约为 n×n×n。

  1. 非均匀循环:有些循环的执行次数不是固定的与输入规模成正比。例如,下面的循环中,内层循环的次数根据外层循环变量变化:
def non_uniform_loop(n):
    for i in range(n):
        for j in range(i):
            print(i, j)

对于外层循环的第 i 次迭代,内层循环执行 i 次。总的操作次数为 1 + 2 + 3 +... + n。根据等差数列求和公式,这个和为 n(n + 1)/2。当 n 趋近于无穷大时,忽略低阶项和系数,时间复杂度为 O(n²)。

递归分析

  1. 递归调用树:对于递归函数,我们可以通过构建递归调用树来分析时间复杂度。以之前的斐波那契数列递归实现为例:
def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)

当 n = 5 时,递归调用树如下:

        fibonacci(5)
       /            \
  fibonacci(4)      fibonacci(3)
  /       \        /      \
fibonacci(3) fibonacci(2) fibonacci(2) fibonacci(1)
 /     \    /     \    /     \
fibonacci(2) fibonacci(1) fibonacci(1) fibonacci(0)
 /     \
fibonacci(1) fibonacci(0)

从树中可以看出,节点数量随着 n 的增大呈指数级增长,所以时间复杂度为 O(2ⁿ)。 2. 主定理:对于一些形式较为规则的递归函数,可以使用主定理来分析时间复杂度。主定理适用于形如 T(n) = aT(n/b) + f(n) 的递归关系,其中 a ≥ 1,b > 1,f(n) 是一个渐近正函数。例如,归并排序的递归关系为 T(n) = 2T(n/2) + O(n),这里 a = 2,b = 2,f(n) = O(n)。根据主定理,其时间复杂度为 O(n log n)。

Python 内置数据结构与时间复杂度

列表(List)

  1. 访问元素:通过索引访问列表中的元素,时间复杂度为 O(1)。例如:
my_list = [10, 20, 30, 40, 50]
element = my_list[3]  # 时间复杂度 O(1)

这是因为列表在内存中是连续存储的,根据索引可以直接计算出元素的内存地址,从而快速访问。 2. 插入元素:在列表末尾插入元素,使用 append 方法,时间复杂度平均为 O(1)。例如:

my_list = [1, 2, 3]
my_list.append(4)  # 平均时间复杂度 O(1)

然而,在列表开头或中间插入元素,使用 insert 方法,时间复杂度为 O(n)。因为插入元素后,后面的元素都需要向后移动。例如:

my_list = [1, 2, 3]
my_list.insert(1, 10)  # 时间复杂度 O(n)
  1. 删除元素:删除列表末尾元素,使用 pop 方法,时间复杂度平均为 O(1)。例如:
my_list = [1, 2, 3]
popped = my_list.pop()  # 平均时间复杂度 O(1)

删除列表中指定索引位置的元素,使用 pop 方法并传入索引,或者使用 del 语句,时间复杂度为 O(n),因为同样需要移动后面的元素。例如:

my_list = [1, 2, 3]
del my_list[1]  # 时间复杂度 O(n)
  1. 查找元素:使用 in 关键字查找元素,时间复杂度为 O(n)。例如:
my_list = [10, 20, 30, 40]
if 30 in my_list:  # 时间复杂度 O(n)
    print('Found')

这是因为它需要遍历列表中的每个元素来查找目标元素。

字典(Dictionary)

  1. 访问元素:通过键访问字典中的值,时间复杂度平均为 O(1)。例如:
my_dict = {'a': 10, 'b': 20, 'c': 30}
value = my_dict['b']  # 平均时间复杂度 O(1)

字典是基于哈希表实现的,通过哈希函数可以快速定位到存储值的位置。但在极端情况下,如哈希冲突严重时,时间复杂度可能会退化为 O(n)。 2. 插入元素:插入新的键值对,平均时间复杂度为 O(1)。例如:

my_dict = {'a': 10}
my_dict['b'] = 20  # 平均时间复杂度 O(1)

同样,在哈希冲突严重时,时间复杂度可能会增加。 3. 删除元素:删除字典中的键值对,使用 del 语句,平均时间复杂度为 O(1)。例如:

my_dict = {'a': 10, 'b': 20}
del my_dict['a']  # 平均时间复杂度 O(1)
  1. 查找元素:通过键查找元素,平均时间复杂度为 O(1),与访问元素类似。但如果通过值查找键,时间复杂度为 O(n),因为需要遍历所有键值对。例如:
my_dict = {'a': 10, 'b': 20, 'c': 30}
def find_key_by_value(dict_obj, value):
    for key, val in dict_obj.items():
        if val == value:
            return key
    return None
key = find_key_by_value(my_dict, 20)  # 时间复杂度 O(n)

集合(Set)

  1. 添加元素:使用 add 方法添加元素,平均时间复杂度为 O(1)。例如:
my_set = {1, 2, 3}
my_set.add(4)  # 平均时间复杂度 O(1)

集合也是基于哈希表实现的,添加元素时通过哈希函数确定位置。 2. 删除元素:使用 remove 方法删除元素,平均时间复杂度为 O(1)。例如:

my_set = {1, 2, 3}
my_set.remove(2)  # 平均时间复杂度 O(1)

如果元素不存在,会引发 KeyError。也可以使用 discard 方法,它不会引发错误,时间复杂度同样平均为 O(1)。 3. 查找元素:使用 in 关键字查找元素,平均时间复杂度为 O(1)。例如:

my_set = {10, 20, 30}
if 20 in my_set:  # 平均时间复杂度 O(1)
    print('Found')

集合的查找效率高是因为其基于哈希表的实现,能够快速定位元素是否存在。

Python 性能评估工具

time 模块

  1. 基本使用time 模块提供了测量时间的功能。可以使用 time.time() 函数获取当前时间的时间戳(从 1970 年 1 月 1 日 00:00:00 UTC 到现在的秒数)。通过记录代码执行前后的时间戳,相减即可得到代码执行时间。例如,测量计算 1 到 1000000 的整数和的时间:
import time

start_time = time.time()
total = 0
for i in range(1, 1000001):
    total += i
end_time = time.time()
execution_time = end_time - start_time
print(f"Execution time: {execution_time} seconds")
  1. 局限性time 模块测量的时间包含了系统调度、其他进程干扰等因素,所以测量结果可能不太精确。而且多次运行代码,由于系统状态不同,得到的时间可能会有波动。

timeit 模块

  1. 简单示例timeit 模块用于更精确地测量小段代码的执行时间。它会多次运行被测代码,并给出平均执行时间。例如,测量 sum 函数计算 1 到 1000 的整数和的时间:
import timeit

def sum_to_n():
    return sum(range(1, 1001))

execution_time = timeit.timeit(sum_to_n, number = 1000)
print(f"Average execution time over 1000 runs: {execution_time / 1000} seconds")

这里 timeit.timeit 函数的第一个参数是要测量的函数,number 参数指定运行函数的次数。通过多次运行取平均值,可以得到更稳定、准确的执行时间。 2. 设置环境timeit 还可以设置运行代码的环境。例如,如果要测量一个依赖于某个全局变量的代码片段的时间,可以通过 globals() 函数将全局变量传递进去:

import timeit

my_list = list(range(1000))

def sum_list():
    return sum(my_list)

execution_time = timeit.timeit(sum_list, number = 1000, globals = globals())
print(f"Average execution time over 1000 runs: {execution_time / 1000} seconds")

cProfile 模块

  1. 性能分析报告cProfile 模块用于生成程序的性能分析报告。它可以统计每个函数的调用次数、执行时间等信息。例如,对于一个包含多个函数调用的程序:
import cProfile

def add_numbers(a, b):
    return a + b

def multiply_numbers(a, b):
    return a * b

def complex_operation():
    result1 = add_numbers(10, 20)
    result2 = multiply_numbers(result1, 30)
    return result2

cProfile.run('complex_operation()')

运行 cProfile.run('complex_operation()') 后,会输出详细的性能分析报告,包括每个函数的调用次数、总运行时间、每次调用的平均时间等信息。这对于定位程序中的性能瓶颈非常有帮助。 2. 分析大型程序:在大型项目中,可以将 cProfile.run() 放在关键函数或模块的入口处,来分析整个模块或部分代码的性能。通过分析报告,可以确定哪些函数执行时间较长,从而针对性地进行优化,比如优化算法、减少不必要的计算等。

优化 Python 代码性能的策略

算法优化

  1. 选择合适的算法:不同的算法在时间复杂度上可能有很大差异。例如,对于排序操作,冒泡排序的时间复杂度为 O(n²),而快速排序平均时间复杂度为 O(n log n)。在处理大规模数据时,选择快速排序会显著提高性能。下面是冒泡排序和快速排序的 Python 实现对比:
def bubble_sort(lst):
    n = len(lst)
    for i in range(n):
        for j in range(0, n - i - 1):
            if lst[j] > lst[j + 1]:
                lst[j], lst[j + 1] = lst[j + 1], lst[j]
    return lst

def quick_sort(lst):
    if len(lst) <= 1:
        return lst
    pivot = lst[len(lst) // 2]
    left = [x for x in lst if x < pivot]
    middle = [x for x in lst if x == pivot]
    right = [x for x in lst if x > pivot]
    return quick_sort(left) + middle + quick_sort(right)

import timeit

lst = list(range(1000))
bubble_time = timeit.timeit(lambda: bubble_sort(lst.copy()), number = 100)
quick_time = timeit.timeit(lambda: quick_sort(lst.copy()), number = 100)
print(f"Bubble sort time: {bubble_time} seconds")
print(f"Quick sort time: {quick_time} seconds")

可以看到,随着数据规模增大,快速排序的优势更加明显。 2. 减少递归深度:递归虽然代码简洁,但深度递归可能导致栈溢出和性能问题。例如之前的斐波那契数列递归实现,时间复杂度高且效率低。可以通过迭代的方式进行优化:

def fibonacci(n):
    if n <= 1:
        return n
    a, b = 0, 1
    for _ in range(2, n + 1):
        a, b = b, a + b
    return b

这种迭代实现的时间复杂度为 O(n),比递归实现的 O(2ⁿ) 效率高得多。

数据结构优化

  1. 合理选择数据结构:根据实际需求选择合适的数据结构可以提高性能。例如,如果需要快速查找元素,字典或集合是更好的选择,而不是列表。假设要统计一个文本文件中每个单词出现的次数,可以使用字典来实现:
def count_words(file_path):
    word_count = {}
    with open(file_path, 'r') as file:
        for line in file:
            words = line.split()
            for word in words:
                if word in word_count:
                    word_count[word] += 1
                else:
                    word_count[word] = 1
    return word_count

这里使用字典可以快速判断单词是否已经在统计中,并更新计数,比使用列表效率更高。 2. 减少数据冗余:避免在数据结构中存储不必要的重复数据。例如,在一个列表中,如果某些元素可以通过其他元素计算得出,就不应该重复存储。假设我们有一个存储圆的半径和面积的列表,如果面积可以根据半径计算,那么只存储半径,在需要时计算面积会更节省空间和提高效率。

代码优化技巧

  1. 减少循环中的计算:将循环中不依赖于循环变量的计算移到循环外部。例如:
import math

# 不好的写法
for i in range(1000):
    result = math.sqrt(25) * i

# 好的写法
sqrt_25 = math.sqrt(25)
for i in range(1000):
    result = sqrt_25 * i

在第一个例子中,math.sqrt(25) 在每次循环中都计算一次,而在第二个例子中,将其移到循环外,只计算一次,提高了效率。 2. 使用生成器:生成器是一种迭代器,它按需生成值,而不是一次性生成所有值,这样可以节省内存。例如,生成一个包含大量数字的序列:

# 使用列表生成所有值
large_list = [i ** 2 for i in range(1000000)]

# 使用生成器按需生成值
large_generator = (i ** 2 for i in range(1000000))

如果只需要逐个处理这些值,使用生成器可以避免一次性占用大量内存,特别是在处理大规模数据时。

  1. 避免不必要的函数调用:函数调用有一定的开销,包括参数传递、栈操作等。如果一个操作在多个地方重复执行,可以考虑将其定义为内联代码,而不是函数调用。例如:
# 频繁调用函数
def square(x):
    return x * x

for i in range(1000):
    result = square(i)

# 内联代码
for i in range(1000):
    result = i * i

在性能敏感的代码中,减少不必要的函数调用可以提高效率。但也要注意代码的可读性,过度内联可能会使代码变得难以理解。

  1. 使用 numba 等加速库numba 是一个用于 Python 的 JIT(Just - In - Time)编译器,可以将 Python 代码编译为机器码,从而显著提高性能。例如,对于一个简单的数值计算函数:
import numba

@numba.jit(nopython = True)
def sum_numbers(a, b):
    return a + b

import timeit

a, b = 10, 20
numba_time = timeit.timeit(lambda: sum_numbers(a, b), number = 1000000)
print(f"Numba execution time: {numba_time} seconds")

def regular_sum(a, b):
    return a + b

regular_time = timeit.timeit(lambda: regular_sum(a, b), number = 1000000)
print(f"Regular execution time: {regular_time} seconds")

通过 @numba.jit(nopython = True) 装饰器,sum_numbers 函数会被编译为机器码,运行速度比普通 Python 函数快很多。不过,使用 numba 时需要注意其对数据类型等方面的要求,以确保正确编译和加速。

  1. 并行计算:对于一些可以并行处理的任务,可以使用 Python 的 multiprocessingconcurrent.futures 模块来利用多核 CPU 的优势。例如,计算多个数的平方:
import concurrent.futures

def square(x):
    return x * x

numbers = list(range(1000))
with concurrent.futures.ProcessPoolExecutor() as executor:
    results = list(executor.map(square, numbers))

这里使用 ProcessPoolExecutor 创建一个进程池,通过 executor.map 方法并行计算列表中每个数的平方,从而加快计算速度。但并行计算也有一定的开销,如进程间通信等,对于小规模任务可能效果不明显,甚至会降低性能,需要根据实际情况选择。

  1. 使用高效的内置函数和库:Python 提供了许多高效的内置函数和标准库。例如,sum 函数比自己手动实现的循环求和效率更高。在处理数值计算时,numpy 库比纯 Python 列表操作要快得多。例如:
import numpy as np

# 使用纯 Python 列表求和
my_list = list(range(1000000))
python_sum_time = timeit.timeit(lambda: sum(my_list), number = 10)

# 使用 numpy 数组求和
np_array = np.array(my_list)
numpy_sum_time = timeit.timeit(lambda: np.sum(np_array), number = 10)
print(f"Python sum time: {python_sum_time} seconds")
print(f"Numpy sum time: {numpy_sum_time} seconds")

可以看到,numpy 在处理大规模数值计算时性能优势明显。所以在编写代码时,应优先考虑使用这些高效的内置函数和库。