18、算法优化:时间复杂度分析,空间换时间策略,分治与动态规划

聊到算法优化,很多同学第一反应就是「快」。但到底怎么个快法?快多少?值不值得快?

我做了十几年C语言开发,见过太多人一上来就撸代码,结果跑起来才发现——嗯,慢得离谱。其实,算法优化的核心就三件事:算清楚复杂度、拿空间换时间、选对策略。今天咱们就掰开揉碎聊聊。

18.1 时间复杂度分析:别凭感觉,要算账

先问个问题:你写的代码,到底跑多快?

很多人说「我测过,挺快的」。但测出来的数据有局限性——数据量一变,性能可能天差地别。我个人习惯,先做理论分析,再动手优化

18.1.1 大O表示法:最坏情况下的「天花板」

大O表示法,说白了就是看输入规模n变大时,你的代码要干多少活。常见的有:

复杂度 含义 典型场景
O(1) 常数时间,跟n无关 数组随机访问
O(log n) 对数时间,增长极慢 二分查找
O(n) 线性时间,跟n成正比 遍历数组
O(n log n) 线性对数时间 归并排序、快速排序
O(n²) 平方时间,n一大就崩 冒泡排序、双重循环
O(2ⁿ) 指数时间,基本不可用 暴力递归斐波那契

举个例子,你写了个双重循环:

for (int i = 0; i < n; i++) {
    for (int j = 0; j < n; j++) {
        // 做一些操作
    }
}

这就是O(n²)。当n=1000时,循环100万次。当n=10000时,循环1亿次。你看,n只翻了10倍,工作量翻了100倍。这就是为什么我常说:别小看复杂度,它决定了你的代码能撑多大场面

我的经验: 在嵌入式项目中,我遇到过有人用O(n²)的算法处理传感器数据流,结果CPU占用率飙到90%。换成O(n log n)的排序后,直接降到15%。有时候,换算法比换硬件更管用。

18.1.2 空间复杂度:内存不是无限的

时间复杂度看CPU,空间复杂度看内存。很多新手只盯着时间,忽略了内存。但你要知道,内存不够用,程序一样崩

空间复杂度同样用大O表示:

  • O(1):只用了几个变量,跟n无关
  • O(n):用了一个跟n等长的数组
  • O(n²):用了一个n×n的矩阵

我曾经在项目中遇到一个坑:用递归实现深度优先搜索,没控制好递归深度,结果栈溢出了。嗯,这就是空间复杂度没算清楚——递归调用栈也是空间开销。

18.2 空间换时间策略:用内存买性能

这是我最常用的优化手段。说白了就是:多存点数据,少算几遍

18.2.1 查表法:预计算,直接取

比如你要计算0~255每个数的平方。每次用的时候算一遍?太慢了。直接建个表:

static int square_table[256];
void init_table() {
    for (int i = 0; i < 256; i++) {
        square_table[i] = i * i;
    }
}
int get_square(int x) {
    return square_table[x];
}

查表是O(1),计算是O(n)。你想想看,如果这个函数被调用几百万次,查表能省多少时间?

核心思想: 把「重复计算」变成「一次计算+多次读取」。空间换时间,本质上是把时间成本提前支付。

18.2.2 缓存结果:避免重复劳动

我在做图像处理时遇到过一个问题:同一个像素点的颜色值被反复计算。后来我加了个缓存,算过的结果直接存起来,下次直接取。效果立竿见影。

举个简单的例子,斐波那契数列:

// 普通递归(O(2ⁿ))
int fib(int n) {
    if (n <= 1) return n;
    return fib(n-1) + fib(n-2);
}

// 带缓存的递归(O(n))
int fib_memo(int n, int* memo) {
    if (n <= 1) return n;
    if (memo[n] != -1) return memo[n];
    memo[n] = fib_memo(n-1, memo) + fib_memo(n-2, memo);
    return memo[n];
}

你看,加了个数组缓存,复杂度从指数级降到线性。这就是空间换时间的经典案例。

注意: 空间换时间不是无脑加内存。你要评估:
1. 缓存命中率够不够高?
2. 内存开销是否在可接受范围内?
3. 有没有更好的算法可以根本解决问题?

18.3 分治策略:化整为零,各个击破

分治,说白了就是「大事化小」。把一个大问题拆成几个小问题,分别解决,再合并结果。

18.3.1 分治的经典套路

分治有三个步骤:

  1. 分解:把原问题拆成若干子问题
  2. 解决:递归地解决每个子问题
  3. 合并:把子问题的解合并成原问题的解

最典型的例子就是归并排序:

void merge_sort(int arr[], int left, int right) {
    if (left >= right) return;
    int mid = left + (right - left) / 2;
    merge_sort(arr, left, mid);      // 分解
    merge_sort(arr, mid + 1, right); // 分解
    merge(arr, left, mid, right);    // 合并
}

归并排序的时间复杂度是O(n log n),空间复杂度是O(n)。它比冒泡排序(O(n²))快得多,但需要额外空间来合并。

18.3.2 分治的适用场景

不是所有问题都能分治。你得满足两个条件:

  • 子问题相互独立:子问题之间没有依赖关系
  • 子问题可以合并:子问题的解能组合成原问题的解

我遇到过有人硬拿分治去解有重叠子问题的问题,结果效率还不如暴力。嗯,这时候就该动态规划上场了。

18.4 动态规划:记住历史,避免重算

动态规划(DP)和分治很像,但有一个关键区别:DP的子问题会重复出现。所以DP的核心就是「记住已经算过的结果」。

18.4.1 动态规划的三要素

我个人总结,DP就三件事:

  1. 状态定义:dp[i]表示什么?
  2. 状态转移方程:dp[i]怎么从dp[i-1]或dp[i-2]推出来?
  3. 初始条件:边界值是什么?

拿经典的「爬楼梯」问题举例:

// 爬n阶楼梯,每次可以爬1阶或2阶,有多少种方法?
int climb_stairs(int n) {
    if (n <= 2) return n;
    int dp[n+1];
    dp[1] = 1;  // 初始条件
    dp[2] = 2;  // 初始条件
    for (int i = 3; i <= n; i++) {
        dp[i] = dp[i-1] + dp[i-2];  // 状态转移
    }
    return dp[n];
}

你看,状态转移方程就是dp[i] = dp[i-1] + dp[i-2]。为什么?因为到第i阶,要么从i-1跨1步,要么从i-2跨2步。这就是DP的精髓——用历史推导未来

避坑指南: 我曾经在项目中用DP处理路径规划,结果状态定义错了,导致结果全偏。后来我养成了一个习惯:先手动画出状态转移图,再写代码。你想想看,画图花5分钟,debug省2小时,值不值?

18.4.2 空间优化:滚动数组

很多DP问题,其实不需要整个dp数组。比如上面的爬楼梯,只需要记住前两个状态:

int climb_stairs_opt(int n) {
    if (n <= 2) return n;
    int prev1 = 1, prev2 = 2, curr;
    for (int i = 3; i <= n; i++) {
        curr = prev1 + prev2;
        prev1 = prev2;
        prev2 = curr;
    }
    return curr;
}

空间复杂度从O(n)降到了O(1)。这就是「滚动数组」技巧——只保留需要的历史状态,扔掉没用的

18.5 知识体系总览

下面这张图,是我自己梳理的算法优化知识体系。你可以把它当作一个「决策树」:遇到性能问题,先看复杂度,再选策略。

算法优化 时间复杂度分析 空间换时间策略 大O表示法 常见复杂度对比 空间复杂度 查表法 缓存结果 滚动数组 策略选择 分治 动态规划 图:算法优化知识体系总览

18.6 实战建议:怎么选?

最后,我分享几个实战中的判断标准:

  • 数据量小(n < 100):O(n²)也能接受,别过度优化
  • 数据量大(n > 10000):至少O(n log n),优先考虑分治或DP
  • 重复计算多:空间换时间,加缓存
  • 子问题有重叠:动态规划,别用分治
  • 子问题独立:分治,简单高效

记住一句话:没有最好的算法,只有最合适的算法。我见过太多人为了炫技,用DP解决一个O(n)就能搞定的事——结果代码又长又难维护。嗯,没必要。

总结: 算法优化的本质,就是「算清楚账,选对策略」。时间复杂度帮你算CPU的账,空间复杂度帮你算内存的账。空间换时间、分治、动态规划,都是你工具箱里的工具。用对工具,事半功倍。

公众号:蓝海资料掘金营,微信deep3321