🟡时间复杂度
时间复杂度用来描述算法运行时间随输入规模增加而增加的速度。按照快慢排序常见的时间复杂度:
- 脑子秒想:O(1) :常数 ,算法的执行时间是固定的,与输入规模无关,例如直接访问数组中的某个元素。
- 对半计算:O(log n) :对数 ,常见于二分查找 等每次操作都能将问题规模减少一半的算法。
- 逐个遍历:O(n) :线性 ,算法的执行时间与输入规模成正比,例如遍历数组中的所有元素。
- 快速排序:O(n log n) :线性对数 ,常见于快速排序 、归并排序等分治的算法。
- 两两对比:O(n^2) :平方,通常出现在双重嵌套循环中,例如简单的选择排序或者插入排序。
- 暴力枚举:O(2^n) :指数,通常出现在递归算法的每一层可以分解成多个子问题的情况。
- 暴力枚举:O(n!) :阶乘,通常出现在求解旅行商问题等需要考虑所有排列的问题上。
复杂度 | 比喻 | 典型算法 | 真实互联网场景 |
---|---|---|---|
O(1) | 直接看价格 | 哈希查找、数组随机访问 | 缓存系统、数据库索引、静态资源加载 |
O(log n) | 猜价格,猜高低,每次减半范围 | 二分查找、平衡二叉树 | 搜索引擎、数据库查询优化、推荐系统 |
O(n) | 逐个翻价格标签 | 线性扫描、KMP 匹配 | 日志分析、广告投放、图像处理 |
O(n log n) | 先分批次筛选,再细排 | 快速排序、归并排序 | 大数据排序、搜索引擎排名、电商商品排序 |
O(n²) | 一件一件比价格 | 冒泡排序、插入排序 | 社交网络好友推荐、碰撞检测、小规模数据分析 |
O(2ⁿ) | 计算所有购物组合 | 回溯法、暴力递归 | 密码破解、路径规划、AI决策树 |
O(n!) | 衣服搭配排列,组合数爆炸 | 全排列生成、旅行商问题 | 物流调度、任务调度、基因序列比对 |
🔘 中学数学理解
在上述提到的各种时间复杂度中,只有 O(n²)、O(2ⁿ) 和 O(n!) 与排列组合直接相关。其余的复杂度(如 O(1)、O(log n)、O(n)、O(n log n))并不涉及排列组合的概念。童鞋们,回顾一下中学课本吧哈哈,我举例了三个情况:
假设环科院内有 10 名员工(编号为 1 到 10)。公司需要从中选出 4 名员工参加培训,并安排其中的 2 名员工担任特定的角色(如组长和副组长),而剩下的 2 名员工不分配具体角色。
1、组合问题:如果只关心从 10 名员工中选出 4 名员工(不考虑角色分配),有多少种不同的选择方式?
- 使用组合公式 <math xmlns="http://www.w3.org/1998/Math/MathML"> C ( n , m ) C(n, m) </math>C(n,m):
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> C ( n , m ) = n ! m ! ( n − m ) ! = C ( 10 , 4 ) = 10 ! 4 ! ( 10 − 4 ) ! = 10 ⋅ 9 ⋅ 8 ⋅ 7 4 ⋅ 3 ⋅ 2 ⋅ 1 = 210 C(n, m) = \frac{n!}{m!(n-m)!}= C(10, 4) = \frac{10!}{4!(10-4)!} = \frac{10 \cdot 9 \cdot 8 \cdot 7}{4 \cdot 3 \cdot 2 \cdot 1} = 210 </math>C(n,m)=m!(n−m)!n!=C(10,4)=4!(10−4)!10!=4⋅3⋅2⋅110⋅9⋅8⋅7=210
2、排列问题:如果已经选出了 4 名员工,现在需要从中选出 2 名员工并安排他们的具体角色(如组长和副组长),有多少种不同的安排方式?
- 使用排列公式 <math xmlns="http://www.w3.org/1998/Math/MathML"> A ( m , k ) A(m, k) </math>A(m,k):
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> A ( m , k ) = m ! ( m − k ) ! = A ( 4 , 2 ) = 4 ! ( 4 − 2 ) ! = 4 ⋅ 3 1 = 12 A(m, k) = \frac{m!}{(m-k)!}=A(4, 2) = \frac{4!}{(4-2)!} = \frac{4 \cdot 3}{1} = 12 </math>A(m,k)=(m−k)!m!=A(4,2)=(4−2)!4!=14⋅3=12
3、排列组合问题:如果既要从 10 名员工中选出 4 名员工,又要给其中的 2 名员工安排具体角色(如组长和副组长),有多少种不同的安排方式?那得分两步计算:
- 先计算从 10 名员工中选出 4 名员工的组合数 <math xmlns="http://www.w3.org/1998/Math/MathML"> C ( 10 , 4 ) C(10, 4) </math>C(10,4)。
- 再对选出的 4 名员工中的 2 名进行排列,安排具体角色 <math xmlns="http://www.w3.org/1998/Math/MathML"> A ( 4 , 2 ) A(4, 2) </math>A(4,2)。
<math xmlns="http://www.w3.org/1998/Math/MathML" display="block"> 总方案数 = C ( 10 , 4 ) ⋅ A ( 4 , 2 ) = 210 ⋅ 12 = 2520 总方案数 = C(10, 4) \cdot A(4, 2)= 210 \cdot 12 = 2520 </math>总方案数=C(10,4)⋅A(4,2)=210⋅12=2520
🔘 O(1)
无论输入多大,执行的操作次数都是固定的。
比如办公室门口放着快递柜,领导让你拿一个指定包裹。你直接打开快递柜,找到编号123的包裹,拿走,1秒钟搞定。不管今天有1000个包裹还是10000个包裹,找编号的速度永远是固定的。典型例子是数组索引访问,直接通过索引获取值,无需遍历。不管数组有 10 还是 100 万个元素,它只访问一次,第 0 个元素,时间复杂度是 O(1)。
python
def get_first_element(arr):
""" 获取数组的第一个元素,时间复杂度 O(1) """
return arr[101] # 只执行一次操作,和数组大小无关
🔘 O(n)
需要遍历所有数据,数据量翻倍,执行次数也翻倍。
领导让你去仓库找出一个坏掉的包裹(比如破损的)。 仓库有100个包裹,你只能从第1个开始一个个检查过去,直到找到破损的。如果包裹有100个,最多要找100次如果有10000个,最多要翻10000次。当然也看运气。典型例子是遍历数组查找某个元素(线性查找),最坏情况下需要遍历整个数组,执行 n 次,时间复杂度是 O(n)。
python
def linear_search(arr, target):
""" 遍历数组查找目标元素,时间复杂度 O(n) """
for i in range(len(arr)): # 需要遍历整个数组
if arr[i] == target:
return i # 找到了,返回索引
return -1 # 没找到
🔘 O(log n)
每次操作能把问题规模缩小一半 适用于有序数据,通过"分治"快速锁定目标。
快递老板给你一个清单,上面是包裹编号从小到大排序好的,让你找某个编号的包裹。你先翻一半,如果中间的编号比目标大,说明目标在前半部分;再翻一半,直到找到。100个包裹大概只用翻7次!10000个包裹最多翻14次! 有点像玩"你猜我心里想的数字"的游戏,包裹越多,节省的时间越明显,比如你猜这个衣服的价格,如果是100元以内,我猜个数你说大了还是小了,我也不会从1元猜到100...越大越明显体现在如果这个衣服是2万,我总不能从1元猜到2万吧,这算法比O(n)香多了。
python
def binary_search(arr, target):
""" 在有序数组 arr 中查找 target,时间复杂度 O(log n) """
left, right = 0, 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 # 没找到,返回 -1
快速排序 vs 二分查找的都用到了"分而治之"的思想:两者都通过某种方式将问题分成更小的部分,逐步缩小问题规模。都涉及"基准值"或"中间值",在二分查找中,我们选择数组的中间值作为基准,来判断目标值在左半部分还是右半部分。在快速排序中,我们选择一个基准值(pivot),根据它将数组分成两部分。但二分查找目标是从一个有序数组 中找到某个特定值的位置。因此前提条件是有序数组。每次比较的是中间值 ,决定继续搜索左半部分还是右半部分。而快速排序 目标是对一个无序数组 进行排序。每次选择一个基准值,将数组分成两部分(比基准值小的部分和比基准值大的部分),然后递归排序,可以说快速排序本身就是为了把无序数组变成有序数组。自然二者结果不同 二分查找 :最终结果是一个具体的索引值,表示目标值在数组中的位置。快速排序:最终结果是一个完整的有序数组。
🔘 O(n log n)
每次划分需要 O(n) 时间,总共划分 log(n) 次
你要把100个包裹从轻到重排序(比如称重分类),你先把包裹一分为二,分别称重;再把两堆继续分成四堆称重; 最后再一层层合并回来。这个过程比单纯挨个比较快,类似归并排序 ,它不是挨个比较,而是先大致分组,再合并 ,速度比 O(n²) 的冒泡排序快得多。再以衣服为例,你想从 100 件衣服里挑出最贵的 10 件,不要一个个比,但是这衣服又不是按照价格顺序排列的,那就先把衣服大致 分成两堆(比如 50 件和 50 件),再继续分(25 件、25 件、25 件、25 件),直到每一小堆只有 1-2 件,这个过程很快啊,因为不涉及比较,只是单纯不停的对半砍而已,不动脑子,然后再进行排序,最后把各堆的最贵衣服合并起来。 典型例子是快速排序 ,使用分治法,每一轮把数组分成两部分再递归排序。每次递归都把问题规模减半,拆分 log(n) 层,每层操作 O(n) 次,所以最终是 O(n log n)。
python
def quick_sort(arr):
""" 使用快速排序对数组排序,时间复杂度 O(n log n) """
if len(arr) <= 1: # 递归终止条件
return arr
pivot = arr[len(arr) // 2] # 选取中间元素作为基准
left = [x for x in arr if x < pivot] # 比基准小的放左边
middle = [x for x in arr if x == pivot] # 等于基准的放中间
right = [x for x in arr if x > pivot] # 比基准大的放右边
return quick_sort(left) + middle + quick_sort(right) # 递归排序并合并
🔘 O(n²)
嵌套循环,每个元素都需要和其他元素比较,导致执行次数指数增长。
比如你有 100 件衣服,要从中找出最贵的那件。你非要拿第 1 件衣服,和第 2 件比,再和第 3 件比... 一直到第 100 件。然后换第 2 件,再和剩下所有衣服比......这就是冒泡排序或选择排序 ,每次都要遍历整个列表,非常低效典型例子是冒泡排序(Bubble Sort),每次遍历都要两两比较 n(n-1)/2 次,数据稍大就会非常慢!
python
def bubble_sort(arr):
""" 冒泡排序,时间复杂度 O(n²) """
n = len(arr)
for i in range(n): # 遍历 n 次
for j in range(n - i - 1): # 每次都遍历剩余未排序部分
if arr[j] > arr[j + 1]: # 如果当前元素比下一个大
arr[j], arr[j + 1] = arr[j + 1], arr[j] # 交换位置
🔘 O(2ⁿ)
递归爆炸,每个子问题都会生成多个子问题
比如你去商场买衣服,有 10 件衣服可以选,每件衣服你可以买或不买。你要列出所有可能的购物组合:你可以买 1 件(10 种可能)、你可以买 2 件(45 种可能 C10 2) 。注意区分O(n²)有两个层级的循环,每次循环都对比一个元素与其他元素,计算量增加是每增加一个元素,运算次数增加的是 n 次。O(2ⁿ)是每个决策点都要做选择,即每个元素都有"选"或"不选"的两种选择,随着元素增多,可能的决策组合呈指数增长。O(n²):相当于你一个个和别人比,看谁跑得快,人数每多一倍,你就要比多两倍。O(2ⁿ):相当于你在一个晚宴上,每个人都需要决定是否参加,所以人数每增加一倍,可能的组合数就成倍增加。典型例子是斐波那契数列。每次计算都要计算两个子问题,计算量呈指数级增长,非常慢
python
def fibonacci(n):
""" 递归计算斐波那契数列,时间复杂度 O(2ⁿ) """
if n <= 1:
return n
return fibonacci(n - 1) + fibonacci(n - 2) # 每次递归都会拆成两个子问题
🔘 O(n!)
全排列、旅行商问题,枚举所有可能的情况。
复杂度是最恐怖的,只能用在 n 很小的情况,比如 n ≤ 10。,n = 10 时就有 3,628,800 种可能,n = 20 直接卡死。老板让你安排10个客户的送货顺序,而且必须找出最快的一条路线。10个包裹需要 10! = 3628800 条路线去试!再比如你要决定 10 套衣服的搭配顺序,每天穿不同的。 第一天你可以穿 10 件中的任何一件,第二天可以穿剩下的 9 件,第三天可以穿剩下的 8 件......总共的搭配方式是: 10! = 10 × 9 × 8 × 7 × 6 × 5 × 4 × 3 × 2 × 1 = 3,628,800。10 件衣服就有几百万种搭配 ,如果是 20 件衣服,那就基本算不动了((20! \approx 2.4 × 10^{18}))。这就是旅行商问题(TSP) ,也是排列组合问题,计算量大到爆炸。:
python
from itertools import permutations
def traveling_salesman(cities, distances):
""" 暴力求解旅行商问题,时间复杂度 O(n!) """
min_distance = float('inf')
best_route = None
for route in permutations(cities): # 生成所有城市的排列
distance = sum(distances[route[i]][route[i + 1]] for i in range(len(route) - 1)) # 计算总距离
if distance < min_distance: # 记录最短路径
min_distance = distance
best_route = route
return best_route, min_distance
🟡算法优化是什么?
算法工程师的工作核心之一就是优化任务的时间复杂度,但并不是所有问题都能选择最快的算法,有些问题本身就是计算量极大,只能寻找可接受的近似解。
🔘常见优化方案
场景 | 原始复杂度 | 理论最优复杂度 | 为什么不能更快? | 优化方案 |
---|---|---|---|---|
旅行商问题(TSP) | O(n!) | O(n²2ⁿ) | 需要枚举所有城市间的路径组合,规模爆炸 | 近似算法(如遗传算法、模拟退火)快速逼近较优解 |
抖音推荐系统 | O(n²) | O(n log n) | 用户与视频特征数量乘积太大,逐一计算不可行 | 矩阵分解/ANN |
社交网络最短路径(如微信好友推荐) | O(n³) | O(m + n log n) | 图节点和边数巨大,暴力遍历所有路径太慢 | Dijkstra/A* |
AI 训练(GPT、BERT) | O(n³) | O(n²) | 超大数据量和参数矩阵乘法,计算量激增 | 并行计算/模型剪枝 |
自动驾驶路径规划 | O(2ⁿ) | O(n²) | 障碍物和路径组合指数增长,需全局最优 | 动态规划/Dijkstra |
搜索引擎关键词匹配 | O(n²) | O(n log n) | 海量网页逐一比对关键词,线性扫描太慢 | 倒排索引:预建词-文档映射表,查询时直接定位相关文档。 |
在线支付欺诈检测 | O(n²) | O(n log n) | 每笔交易需与历史记录比对,复杂度随量级增 | 特征工程/流式计算:在实时流中提取关键特征,降低全量比对需求。 |
下面是几个常见但能极大优化的例子。
🔘线性查找 → 二分查找
低效做法(O(n)): 直接遍历数组找目标值。
高效做法(O(log n)): 如果数组是有序的 ,可以用二分查找减少大量无意义的比较。
python
# O(n) 线性查找(低效)
def linear_search(arr, target):
for i in range(len(arr)):
if arr[i] == target:
return i
return -1 # 没找到
# O(log n) 二分查找(高效)
def binary_search(arr, target):
left, right = 0, 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 # 没找到
🔘冒泡排序 → 快速排序
低效做法(O(n²)): 冒泡排序,每次遍历整个 整个 整个 数组,把最大值冒到最后。
高效做法(O(n log n)): 快速排序,每次选一个基准值, 避免了无意义的遍历交换
python
# O(n²) 冒泡排序(低效)
def bubble_sort(arr):
n = len(arr)
for i in range(n):
for j in range(n - i - 1):
if arr[j] > arr[j + 1]:
arr[j], arr[j + 1] = arr[j + 1], arr[j] # 交换
# O(n log n) 快速排序(高效)
def quick_sort(arr):
if len(arr) <= 1:
return arr
pivot = arr[len(arr) // 2] # 选一个基准值
left = [x for x in arr if x < pivot]
mid = [x for x in arr if x == pivot]
right = [x for x in arr if x > pivot]
return quick_sort(left) + mid + quick_sort(right)
🔘递归 → 动态规划
低效做法(O(2ⁿ)): 递归计算斐波那契数,重复计算太多。
高效做法(O(n)): 用动态规划 ,递归会重复计算,而动态规划用数组存储中间结果,避免不必要的计算。
python
# O(2ⁿ) 递归斐波那契(低效)
def fib(n):
if n <= 1:
return n
return fib(n - 1) + fib(n - 2)
# O(n) 动态规划(高效)
def fib_dp(n):
dp = [0, 1] # 记录已计算的值
for i in range(2, n + 1):
dp.append(dp[i - 1] + dp[i - 2])
return dp[n]
🔘 两数之和(暴力法 → 哈希表)
低效做法(O(n²)): 两层循环找两个数的和。
高效做法(O(n)): 用哈希表存已经遍历过的数,快速查找。
python
# O(n²) 暴力法(低效)
def two_sum(arr, target):
for i in range(len(arr)):
for j in range(i + 1, len(arr)):
if arr[i] + arr[j] == target:
return [i, j]
# O(n) 哈希表(高效)
def two_sum_hash(arr, target):
num_map = {} # 记录数值 -> 索引
for i, num in enumerate(arr):
diff = target - num
if diff in num_map:
return [num_map[diff], i] # 直接查找,无需遍历
num_map[num] = i # 存入字典