跳到主要内容
预计阅读 20 分钟

算法世界的敲门砖 —— 复杂度分析

不懂复杂度分析,就像不会看地图却想环游世界——你可能最终也能到达目的地,但一定会在路上浪费大量的时间和精力。

📋 开篇自测:你已经知道多少?

  1. 同样是把1000个数字排好序,为什么有的程序跑1秒就完成,有的却要跑10分钟?
  2. 如果一段代码里有两层嵌套循环,它的执行效率一定很差吗?
  3. “空间换时间”这句话到底是什么意思?你能举一个具体的例子吗?

一、为什么每个程序员都应该学算法

假设你正在经营一家快递公司。每天有1000个包裹需要派送,你有两种方案:

方案A: 司机拿到一个包裹,开车送到客户手中,回来再拿下一个包裹。 方案B: 先把所有包裹按区域分好类,规划好路线,然后一趟送完同一片区域的所有包裹。

哪个方案更高效?答案显而易见。但关键在于——当包裹数量从1000变成100万时,方案A和方案B的差距会从”有点浪费”变成”根本不可能完成”。

这就是算法的意义所在。算法不是学术象牙塔里的东西,它是解决实际问题的思维方式。

你可能会说:“现在计算机那么快,随便写写代码不就行了?“我们来算一笔账。假设你的代码每次基本操作需要1微秒(百万分之一秒):

数据量低效算法 (n²)高效算法 (n log n)
1,0001秒0.01秒
100,0002.8小时1.7秒
10,000,0003.17年约4分钟

看到了吗?当数据量增长到千万级别,低效算法需要三年多才能算完,而高效算法只要几分钟。再快的计算机也救不了一个烂算法。

🤔 想一想 打开你手机上的微信,搜索一个联系人的名字,结果几乎瞬间就出来了。微信有十几亿用户,如果用最笨的方法一个个比对名字,你觉得需要多久?

算法思维在日常编程中的体现

你可能觉得自己平时写代码用不到算法。但事实上,算法思维已经渗透到了编程的方方面面:

  • 你用字典(dict)查数据而不是遍历列表——这是哈希表的应用
  • 你用sorted()给数据排序——背后是Timsort算法
  • 数据库的索引能让查询加速百倍——这是B+树在起作用
  • 你在IDE里按Ctrl+F搜索文本——这是字符串匹配算法

所以,学算法不是为了应付面试(虽然面试确实会考),而是让你写出更好的代码,具备分析和优化程序的能力。


二、什么是复杂度分析——给代码效率打分

现在你已经知道算法很重要了。那怎么衡量一个算法的好坏呢?

最直觉的方法是写完代码跑一遍,测测用了多少时间。但这种方法有很大问题:

  • 结果不稳定: 同一段代码,在你的笔记本上跑3秒,在服务器上可能只要0.3秒。
  • 依赖具体数据: 给它排一个已经排好序的数组可能很快,给一个完全随机的数组可能很慢。
  • 无法预测未来: 你测了1万条数据跑得还行,但100万条数据呢?你不可能每次都先测一遍。

所以我们需要一种不依赖具体机器、不依赖具体数据、能预测趋势的分析方法。这就是复杂度分析

你可以把复杂度分析想象成给算法做体检。体检不关心你今天穿了什么衣服、心情好不好,它只关注你的核心健康指标。复杂度分析也是如此——它不关心具体的运行时间是2.3秒还是3.1秒,它关注的是:随着数据规模的增长,程序的执行时间和内存消耗会怎样变化。


三、时间复杂度——你的代码有多”慢”

大O记号:一种粗略但有效的估算

时间复杂度用一个叫做”大O记号”(Big O notation)的符号来表示。它描述的是最坏情况下,代码执行时间随数据规模n增长的趋势。

我们来看最简单的例子:

def find_max(numbers):
    """找到列表中的最大值"""
    max_val = numbers[0]       # 执行1次
    for num in numbers:        # 执行n次
        if num > max_val:      # 执行n次
            max_val = num      # 最多执行n次
    return max_val             # 执行1次

这段代码的核心操作——比较大小——执行了n次(n是列表长度)。我们说它的时间复杂度是 O(n),读作”O of n”。

这里有一个重要的原则:大O记号只保留最高次项,并忽略系数。

为什么?因为当n足够大时,低次项和系数对结果的影响微乎其微。这就像你从北京到上海的飞行时间是2小时,你不会在意登机时多走了30秒。

来看几个例子帮你加深理解:

# 例子1:O(1) —— 常数时间
def get_first(numbers):
    return numbers[0]  # 不管列表多长,都只执行一步

# 例子2:O(n) —— 线性时间
def print_all(numbers):
    for num in numbers:  # 列表多长,就循环多少次
        print(num)

# 例子3:O(n²) —— 平方时间
def print_pairs(numbers):
    for i in numbers:        # 外层循环n次
        for j in numbers:    # 内层循环n次
            print(i, j)      # 总共执行n×n次

# 例子4:O(log n) —— 对数时间
def halve_until_one(n):
    count = 0
    while n > 1:
        n = n // 2  # 每次把n砍一半,所以只需要log₂(n)次
        count += 1
    return count

用生活场景理解常见复杂度

我喜欢用找人来比喻不同的时间复杂度:

O(1) —— 你知道朋友的精确地址 不管这座城市有多大、有多少栋楼,你直接导航到门口,敲门就行。无论城市扩大十倍,你找人的时间不变。

O(log n) —— 翻字典找单词 你要在一本500页的字典里找”Python”这个词。你先翻到中间——第250页,发现是字母M的部分,P在M后面,所以只看后半本。再翻到第375页……每次排除一半,500页的字典只需要翻9次左右就能找到。如果字典变成5000页呢?也只需要翻13次左右。这就是对数增长的魅力——数据量增长10倍,操作次数只多了几次。

O(n) —— 在人群中找一个没见过的人 有人告诉你”会场里有个穿红衣服的人要找你”,但你不知道具体长什么样。你只能从头到尾扫一遍。100人的会场得看100个人,1000人就得看1000个。

O(n log n) —— 给一叠乱七八糟的试卷按学号排序 你用的是”分堆再合并”的方法——先分成小堆,每小堆排好序,再合并。这比逐个比对高效得多,但比只扫一遍还是要慢一些。

O(n²) —— 所有人互相握手 50个人参加聚会,每个人要和其他所有人握一次手。第一个人握49次,第二个人握48次……总共要握1225次。人数翻倍,握手次数翻四倍。

O(2ⁿ) —— 密码暴力破解 一个n位的二进制密码锁,每一位可以是0或1。3位有8种组合,4位有16种,10位有1024种,20位就有一百万种。每多一位,可能性翻一倍。这种增长速度极其恐怖。

如何分析代码的时间复杂度

掌握几条简单规则,你就能分析大多数代码的复杂度:

规则1:顺序执行的代码,取复杂度最大的那段

def example(n):
    # 这一段是O(n)
    for i in range(n):
        print(i)

    # 这一段是O(n²)
    for i in range(n):
        for j in range(n):
            print(i, j)

    # 总复杂度:O(n²),因为n²增长更快,n相比之下可以忽略

规则2:嵌套代码,复杂度相乘

def nested_example(n):
    for i in range(n):          # O(n)
        for j in range(n):      # O(n)
            for k in range(n):  # O(n)
                print(i, j, k)  # 总共:O(n) × O(n) × O(n) = O(n³)

规则3:有条件分支的代码,取最坏情况

def conditional_example(numbers, target):
    for num in numbers:    # 最坏情况:target在最后或者不存在
        if num == target:  # 需要遍历全部n个元素
            return True
    return False
    # 时间复杂度:O(n)

⚠️ 常见误区

  • 误区1:“有循环就是O(n)” —— 不一定!循环次数不随n变化的话,仍然是O(1)。比如for i in range(100),无论n多大,都只循环100次。
  • 误区2:“O(n²)的代码一定比O(n)的慢” —— 只有当n足够大时才一定成立。当n=10时,O(n²)的100次操作可能比一个常数很大的O(n)算法更快。大O分析关注的是趋势,不是具体数值。
  • 误区3:“时间复杂度低就一定好” —— 有时候低复杂度的算法实现复杂、代码难以维护。如果你的数据量永远不超过100,用O(n²)的简单代码完全可以接受。

四、空间复杂度——你的代码有多”胖”

时间复杂度衡量的是执行速度,空间复杂度衡量的则是内存消耗。分析方法和时间复杂度类似——看的是额外使用的内存空间随n的增长趋势。

# O(1) 空间 —— 只用了几个变量
def sum_all(numbers):
    total = 0
    for num in numbers:
        total += num
    return total

# O(n) 空间 —— 创建了一个和输入同等大小的新列表
def double_all(numbers):
    result = []
    for num in numbers:
        result.append(num * 2)
    return result

# O(n²) 空间 —— 创建了一个n×n的二维列表
def create_matrix(n):
    matrix = []
    for i in range(n):
        row = [0] * n
        matrix.append(row)
    return matrix

“空间换时间”——算法世界的常见交易

在很多场景下,你可以用更多的内存来换取更快的执行速度。这就像你可以选择:

  • 省钱方案: 每次做饭都从菜市场买菜(费时间,不费钱)
  • 省时方案: 买个大冰箱,一次性囤一周的菜(费钱,不费时间)

一个经典的例子——判断列表中是否有重复元素:

# 方案A:O(n²)时间,O(1)空间 —— 两两比对
def has_duplicate_v1(numbers):
    for i in range(len(numbers)):
        for j in range(i + 1, len(numbers)):
            if numbers[i] == numbers[j]:
                return True
    return False

# 方案B:O(n)时间,O(n)空间 —— 用集合记住见过的数
def has_duplicate_v2(numbers):
    seen = set()
    for num in numbers:
        if num in seen:
            return True
        seen.add(num)
    return False

方案B快了很多,代价是多用了一个集合来存储已经见过的数字。这就是典型的”空间换时间”。

🤔 想一想 在你平时的编程中,有没有无意间做过”空间换时间”的操作?比如,把数据库查询的结果缓存到内存里,下次直接从内存读取,这算不算?


五、最好、最坏与平均复杂度

前面我们一直在讨论”最坏情况”,但实际上,同一段代码面对不同的输入,表现可能天差地别。

def find_target(numbers, target):
    """在列表中查找目标值,返回索引"""
    for i, num in enumerate(numbers):
        if num == target:
            return i
    return -1
  • 最好情况: 第一个元素就是目标值,只比较1次 —— O(1)
  • 最坏情况: 目标值在最后或不存在,比较n次 —— O(n)
  • 平均情况: 假设目标值在每个位置的概率相同,平均比较n/2次 —— O(n)

在日常分析中,我们最常使用的是最坏情况复杂度,因为它给出了一个性能上限的保证——“不管输入是什么,最多也就这么慢”。就像你查看飞机延误时,关心的往往是”最晚什么时候能到”,而不是”运气好的话几点到”。

不过平均情况有时也很重要。比如快速排序的平均时间复杂度是O(n log n),最坏情况是O(n²),但在实际使用中,最坏情况极少出现,所以它仍然是最常用的排序算法之一。


六、复杂度分析实战练习

让我们用几道小练习来巩固所学。

练习1:以下代码的时间复杂度是多少?

def mystery1(n):
    total = 0
    i = 1
    while i < n:
        total += i
        i = i * 2
    return total

分析:i每次翻倍(1, 2, 4, 8, 16, …),要多少次才能超过n?答案是log₂(n)次。所以时间复杂度是 O(log n)

练习2:以下代码的时间复杂度是多少?

def mystery2(n):
    total = 0
    for i in range(n):
        j = 1
        while j < n:
            total += i + j
            j = j * 2
    return total

分析:外层循环执行n次,内层每次执行log(n)次。总时间复杂度是 O(n log n)

练习3:以下代码的空间复杂度是多少?

def mystery3(n):
    if n <= 1:
        return n
    return mystery3(n - 1) + mystery3(n - 2)

分析:这是递归代码。每次调用都会在调用栈上分配空间。递归最深的时候会嵌套n层,所以空间复杂度是 O(n)(注意不是O(2ⁿ),虽然时间复杂度确实是O(2ⁿ),但空间只看同时存在的栈帧数量,即递归深度)。


📝 掌握度自测

  1. 概念题: 用自己的话解释为什么大O记号要忽略常数系数和低次项。
  2. 分析题: 一段代码先用O(n)遍历数组,再用O(n²)的双重循环处理数组,总时间复杂度是多少?为什么?
  3. 比较题: O(100n) 和 O(n²),当n分别为10、100、10000时,哪个更大?从什么时候开始O(n²)超过O(100n)?
  4. 代码分析题: 分析以下代码的时间和空间复杂度:
def process(data):
    n = len(data)
    result = [0] * n
    for i in range(n):
        for j in range(i):
            result[i] += data[j]
    return result
  1. 思考题: 你能想到一个场景,在这个场景中选择时间复杂度更高的算法反而是更好的选择吗?

💡 自我评估

  • 全部答对:复杂度分析已经是你的基本功了,可以放心进入下一章。
  • 答对3-4题:核心概念已经掌握,建议把做错的题目重新想一遍。
  • 答对1-2题:不着急,回到文中标注”🤔想一想”的地方重新阅读,然后再试一次。
  • 全部答错:这很正常!复杂度分析是一个需要练习才能熟练掌握的技能。建议你拿起纸和笔,对着每个代码示例一步一步推演。

有了复杂度分析这把”尺子”,我们就可以正式开始学习数据结构了。下一章的主角是最基础的两种数据结构——数组和链表。它们一个住”连排别墅”,一个住”散落各处的民宿”,几乎所有其他数据结构都建立在它们之上。

购买课程解锁全部内容

刷题到通关:数据结构与算法面试实战

¥29.90