#031. 编程一:最大子数组与子序列

最大子数组与子序列问题的编程模式示意图

#学习目标:一个家族,三种武器

如果只允许带一个算法进量化面试的编程环节,Kadane 大概是性价比最高的候选:它只有一行状态转移,却同时覆盖了「返回最大和」「返回取得最大和的区间」「判断是否存在和为正的子数组」等至少四种问法,而且在源题库里以四种不同措辞反复出现。本章的第一个目标,就是让你无论被问到哪一种问法,都能直接写出同一套内核,只改输出层。

第二个目标是分清「子数组(subarray,连续)」和「子序列(subsequence,可跳过)」。源题在最长递增问题上先问连续版再问一般版,正是想看你会不会把两者混为一谈:连续版一遍扫描即可,一般版需要动态规划(dynamic programming, DP),而且有 \(O(n^2)\) 与 \(O(n \log n)\) 两档复杂度,两档都要能写、能证。

第三个目标是掌握「前缀和 + 哈希表」这把万能钥匙:任何「找一段和等于某定值的子数组」问题,都等价于「找两个相等的前缀和」,于是从平方级降到线性。最长零和子数组是它的标准载体。最后,我们会用最大均值子数组演示一类特殊的陷阱题:不加约束条件就动手写 Kadane,答案很可能是错的。

子数组(subarray)

必须连续。长度为 \(n\) 的数组有 \(\tfrac{n(n+1)}{2}\) 个子数组。对应工具:Kadane、前缀和、滑动窗口(见 033 章)。

子序列(subsequence)

可以跳过元素、但保持相对顺序,共 \(2^n\) 个。对应工具:DP 或「贪心 + 二分」,本章以最长递增子序列为代表。

前缀和(prefix sum)

\(P_i = a_1 + \cdots + a_i\)。任意区间和变成两点之差;配上哈希表就能线性地找「和为定值」的区间。

#知识讲解:Kadane 与它的不变量

Kadane 算法求解「最大子数组和」(maximum subarray problem):在所有非空连续子数组中找和最大的一个。它的状态不是「前 \(i\) 个元素的答案」,而是以第 \(i\) 个元素结尾的最大子数组和,记作 \(cur_i\)。转移只有一个分支:

\[cur_i = \max\bigl(a_i,\; cur_{i-1} + a_i\bigr), \qquad best_n = \max_{1 \le i \le n} cur_i.\]

怎么读:以 \(i\) 结尾的最优子数组,要么只有 \(a_i\) 自己一段,要么把「以 \(i-1\) 结尾的最优段」整体接过来。全局答案在所有结尾位置里取最大,因为每个子数组都有唯一结尾。

为什么这个转移是对的?任取一个以 \(i\) 结尾的子数组,去掉 \(a_i\) 之后剩下的部分(如果非空)是一个以 \(i-1\) 结尾的子数组。如果它的和是负数,接上 \(a_i\) 只会拖累,不如从 \(i\) 重新开段;如果它的和非负,那么接上「以 \(i-1\) 结尾的最优段」一定不劣——因为最优段至少和「剩下那部分」一样大。这正是最优子结构(optimal substructure)的标准论证,面试时用一两句话讲清即可。

不变量:负前缀是负债

扫描过程中维持的不变量是:\(cur\) 永远等于「以当前位置结尾的最大子数组和」。一旦 \(cur\) 变负,它对任何后继段落都是负债——无论后面接什么数,扔掉负的 \(cur\) 都不会更差。所以「current_sum 变负就清零」这个广为流传的口诀并非魔法,而是转移式 \(cur_i = \max(a_i, cur_{i-1}+a_i)\) 的等价说法。

三种问法共用一个内核

源题库对这个问题的四种措辞——只返回和、返回区间下标与和、找一段和为正的子数组、以及自带一份伪代码解法的标准版——内核完全相同,差别只在输出层和判定层:

问法返回什么在 Kadane 上改哪里
最大子数组和一个整数只维护 \(best\),无需记录下标
最大和 + 区间和与左右端点每次 \(cur\) 清零重开段时记录候选左端点,更新 \(best\) 时同步记录右端点
找和为正的子数组布尔值(或任一可行区间)算出 \(best\) 后判断 \(best \gt 0\)(见例题 3 的等价性论证)
和为 0 的子数组存在性 / 最长的一段换武器:前缀和 + 哈希(例题 5)

值得专门指出源题自带解法的一个细节坑:那份解法写「current_sum 变负就清零、max_sum 用 current_sum 覆盖」,隐含把 \(max\_sum\) 初始化为 0。这在「允许空子数组」的口径下自洽(空段和为 0),但一旦数组全为负数,它返回 0 而不是最大的负数——若题目要求非空子数组,就是错的。正确做法是把 \(best\) 初始化为 \(-\infty\)(或直接用 \(a_1\)),例题 2 会展开。

#知识讲解:子数组还是子序列——LIS 的两条路

源题给过 subarray 的严格定义:由连续元素构成的数组,例如 \(a = [a_1, a_2, a_3, a_4]\) 中 \([a_2, a_3, a_4]\) 是子数组而 \([a_1, a_3, a_4]\) 不是——后者是子序列。最长递增问题恰好横跨这两个世界:连续版问最长的严格递增连续段,一遍扫描就能解决;一般版(最长递增子序列,longest increasing subsequence, LIS)允许跳过元素,是 DP 的经典入门题。

一般版的 \(O(n^2)\) 解法先定义状态 \(f_i\) 为以 \(a_i\) 结尾的 LIS 长度(注意「以 i 结尾」这个定语与 Kadane 如出一辙),转移时枚举上一个接在前面的元素:

\[f_i = 1 + \max\bigl(\{0\} \cup \{f_j : j \lt i,\ a_j \lt a_i\}\bigr), \qquad \mathrm{LIS} = \max_i f_i.\]

怎么读:以 \(a_i\) 结尾的递增子序列,前一个元素可以是任何一个更早出现且严格小于 \(a_i\) 的 \(a_j\),接上其中 LIS 最长的那个;若不存在这样的 \(a_j\),\(a_i\) 自成长度 1。严格递增对应 \(a_j \lt a_i\);若改为非降(允许相等),把条件换成 \(a_j \le a_i\) 即可。

贪心 + 二分:tails 数组

把复杂度降到 \(O(n \log n)\) 靠一个贪心观察:维护数组 \(tails\),其中 \(tails_k\) 是「所有长度为 \(k+1\) 的递增子序列中,最小的结尾元素」。它有两个关键性质。其一,\(tails\) 严格递增:任何长度为 \(k+1\) 的子序列去掉最后一个元素就得到一个长度为 \(k\) 的子序列,其结尾元素严格小于前者,所以 \(tails_k \lt tails_{k+1}\)。其二,新来一个元素 \(x\) 时,用 \(x\) 替换 \(tails\) 中第一个大于等于 \(x\) 的位置(二分查找 lower_bound),若不存在则追加——替换不会破坏长度含义,但让同长度的结尾更小,给后续元素留出更多接续空间。最终 \(tails\) 的长度就是 LIS 长度。注意这不是直接构造出 LIS 本身,面试被追问时要能说清「\(tails\) 不是原序列的子序列,只是长度信息等价」。

严格与非严格在二分版本里的区别只有一个函数名:严格递增用 lower_bound(相等元素替换掉旧的),非降用 upper_bound。源题的例子 \([1,2,2,3]\) 期望返回 \([1,2,3]\)(长度 3,严格递增),用 lower_bound 恰好正确;用 upper_bound 会得到 4,正是一道检查你细节的试金石。

#例题详解:Kadane 家族三连问

例题 1:给定整数数组,找出和最大的连续子数组,返回该子数组的左右下标与最大和。

思路。标准的「Kadane + 记录区间」:维护 \(cur\)(以当前元素结尾的最大和)、\(best\)(历史最优)、以及当前段左端点 \(l\)。当 \(cur \lt 0\) 时,带着它只会拖累后面的任何段,于是在当前位置重开一段;更新 \(best\) 时同步记录 \([l, r]\)。用数组 \([-2, 1, -3, 4, -1, 2, 1, -5, 4]\) 手算一遍(1 下标):

\(i\)\(a_i\)\(cur\)\(best\)当前最优区间
1−2−2−2[1,1]
2111[2,2]
3−3−21[2,2]
4444[4,4]
5−134[4,4]
6255[4,6]
7166[4,7]
8−516[4,7]
9456[4,7]

答案是和为 6,区间 \([4,7]\),即子数组 \([4,-1,2,1]\)。

代码。

struct Result { long long sum; int l, r; };   // 闭区间 [l, r],0 下标

Result maxSubarray(const vector<int>& a) {
    long long cur = 0, best = LLONG_MIN;      // best 初始化为 -inf,兼容全负数组
    int l = 0, bestL = 0, bestR = 0;
    for (int r = 0; r < (int)a.size(); ++r) {
        if (cur < 0) { cur = 0; l = r; }      // 负前缀只会拖累:从 r 重新开段
        cur += a[r];
        if (cur > best) { best = cur; bestL = l; bestR = r; }
    }
    return {best, bestL, bestR};
}

复杂度。时间 \(O(n)\),一遍扫描;空间 \(O(1)\)。

边界。空数组需要和面试官确认(返回什么);单元素返回自身;全负数组返回最大的单个元素(因为 \(best\) 从 \(-\infty\) 起步,永不被「空段和 0」污染);元素绝对值接近 \(10^9\)、长度 \(10^5\) 时累加和超出 int 范围,用 long long。

面试怎么讲。先说状态定义「以 i 结尾的最大和」,再给一行转移,最后主动提区间版只需多记两个下标。手算表能极大增强可信度,建议至少口算前四个位置。

例题 2:同一道最大子数组题,只要求和;源题库自带了一份伪代码解法——请实现并指出它的隐患。

思路。与例题 1 同型,仅输出不同:只返回和,不需要下标。题库自带的解法是:设 \(max\_sum\) 与 \(current\_sum\),遍历数组,若 \(current\_sum \ge max\_sum\) 则更新,若 \(current\_sum\) 变负则清零。这个描述的隐患有两处:一是没有交代 \(max\_sum\) 的初值——若初始化为 0,等于默认允许空子数组,全负数组会错误地返回 0;二是「先比较再清零」的顺序必须保持,若先清零再比较,会把恰好为 0 的段处理错。用 \(a_1\) 同时初始化两者即可同时修掉两个坑。

代码。

int maxSubArray(const vector<int>& a) {       // 经典 Kadane:只返回和
    int best = a[0], cur = a[0];              // 用 a[0] 初始化,全负数组也正确
    for (size_t i = 1; i < a.size(); ++i) {
        cur = max(a[i], cur + a[i]);          // 以 i 结尾的最优:接上前段或另起一段
        best = max(best, cur);
    }
    return best;
}

复杂度。时间 \(O(n)\),空间 \(O(1)\)。

边界。约定子数组非空,则 \(n \ge 1\);全负输入如 \([-3,-1,-4]\) 返回 −1 而非 0。若面试官明确允许空子数组,把 best 初始化为 0 即可,先问口径再写代码是加分动作。

面试怎么讲。主动复述那份伪代码并指出两处隐患(初值口径、比较与清零的顺序),比直接写出正确版本更能展示你读代码的能力。源题在这道编程题后面还挂了一串金融与统计追问(过拟合、蒙特卡洛、Black–Scholes、期权对冲、bootstrapping 与 swap curve),它们属于金融直觉板块,去向见本章「误区与边界」末尾的交叉引用。

例题 3:给定一个含正负数的交易流水数组,找出一段和为正的连续子数组(即判断或输出一个盈余区间)。

思路。先做一次等价转化:「存在和为正的子数组」当且仅当「最大子数组和为正」。必要性显然(那段本身就是候选);充分性同样成立:若最大和 \(best \gt 0\),取得 \(best\) 的那段就是所求。于是问题归约为 Kadane,只需在最后把「返回 \(best\)」改成「返回 \(best \gt 0\)」,要输出具体区间时复用例题 1 的下标记录。这类「先证等价、再复用已验证的算法」的路径,是面试里处理陌生措辞的通用方法。

代码。

bool hasPositiveSubarray(const vector<int>& tx) {  // 等价于最大子数组和是否为正
    long long best = LLONG_MIN, cur = 0;
    for (int x : tx) {
        cur = max((long long)x, cur + x);     // Kadane 内核不变
        best = max(best, cur);
    }
    return best > 0;                          // 输出层从「返回和」改成「判定」
}

复杂度。时间 \(O(n)\),空间 \(O(1)\)。

边界。全正数组返回 true 且任意单元素段都可行;全非正数组返回 false;若要求「恰好和为某个给定值」而不仅是正数,则等价转化失效,需要前缀和 + 哈希(例题 5 的方法)。

面试怎么讲。把等价性证明放在代码前面讲:一句「和为正的存在性由最大和的符号完全刻画」即可。追问变式:「找和最大的非空段且长度不超过 k」需要前缀和加单调队列,可引到 033 章

#例题详解:最长递增子序列

例题 4:在整数列表中找最长的递增序列:先假设必须连续,再解一般情形(可跳过元素)。例如 \([1,2,6,3,2,4]\) 应返回 \([1,2,3,4]\),\([1,2,2,3]\) 应返回 \([1,2,3]\)。

思路(连续版)。「最长的严格递增连续段」只需一遍扫描:维护当前段长 \(cur\),\(a_i \gt a_{i-1}\) 时延长,否则重置为 1。用 \(f_i = f_{i-1} + 1\)(若递增)否则 \(f_i = 1\) 的 DP 语言说出来也行,但它退化为常数状态。

代码(连续版)。

int longestIncreasingRun(const vector<int>& a) {  // 连续版:一遍扫描
    if (a.empty()) return 0;
    int best = 1, cur = 1;
    for (size_t i = 1; i < a.size(); ++i) {
        cur = (a[i] > a[i - 1]) ? cur + 1 : 1;    // 严格递增才延长,否则重置
        best = max(best, cur);
    }
    return best;
}

思路(一般版,\(O(n^2)\))。状态 \(f_i\) 为以 \(a_i\) 结尾的 LIS 长度,按前文转移式枚举前驱 \(j\)。它好写、好证,是面试的第一落点;数组规模到 \(10^5\) 时再升级。

代码(一般版 \(O(n^2)\))。

int lisQuadratic(const vector<int>& a) {     // O(n^2) DP:先保证正确再谈优化
    int n = (int)a.size(), ans = 0;
    vector<int> f(n, 1);                       // f[i]:以 a[i] 结尾的 LIS 长度
    for (int i = 0; i < n; ++i) {
        for (int j = 0; j < i; ++j)
            if (a[j] < a[i]) f[i] = max(f[i], f[j] + 1);
        ans = max(ans, f[i]);
    }
    return ans;
}

代码(一般版 \(O(n \log n)\))。tails 数组 + lower_bound,构造过程见下表(输入 \([1,2,6,3,2,4]\)):

读入 \(x\)二分结果操作tails 之后
1无 ≥ 1 的元素追加[1]
2无 ≥ 2 的元素追加[1,2]
6无 ≥ 6 的元素追加[1,2,6]
3第一个 ≥ 3 的是 6替换[1,2,3]
2第一个 ≥ 2 的是 2替换(值不变)[1,2,3]
4无 ≥ 4 的元素追加[1,2,3,4]
int lisFast(const vector<int>& a) {          // O(n log n):贪心 + 二分
    vector<int> tails;                         // tails[k]:长度 k+1 的 LIS 的最小结尾
    for (int x : a) {
        auto it = lower_bound(tails.begin(), tails.end(), x);
        if (it == tails.end()) tails.push_back(x);  // x 能把最长长度推高 1
        else *it = x;                          // 换成更小的结尾,给后续留空间
    }
    return (int)tails.size();
}

复杂度。连续版 \(O(n)\);一般版 \(O(n^2)\) 与 \(O(n \log n)\) 两档,后者每个元素一次二分。

边界。\([1,2,2,3]\):严格递增的答案是 \([1,2,3]\)(长度 3),lower_bound 版返回 3 ✓,若误用 upper_bound 返回 4 ✗——相等元素的处理是最常见的追问点。空数组返回 0;全相同元素如 \([5,5,5]\) 严格 LIS 为 1。若题目要求输出子序列本身,\(O(n^2)\) 版倒着回溯 \(f\) 的前驱最直接;\(O(n \log n)\) 版要额外记录每个元素替换的位置再回溯。

面试怎么讲。按「连续版一遍扫描 → 一般版 \(O(n^2)\) DP → 需要更快吗?→ tails + 二分」的顺序推进,每一档都先给复杂度再写代码;最后主动指出严格 / 非严格与 lower_bound / upper_bound 的对应。

#例题详解:零和子数组与均值陷阱

例题 5:找出数组中和为 0 的最长连续子数组(源题的另一子问只要求判断「是否存在和为 0 的子数组」,一并解决)。

思路。前缀和把区间和变成两点差:定义 \(P_0 = 0\)、\(P_i = a_1 + \cdots + a_i\),则子数组 \((l, r]\) 的和为 \(P_r - P_l\)。它等于 0 当且仅当 \(P_r = P_l\)——「找和为 0 的子数组」等价于「找两个位置的前缀和相等」。于是从左到右扫一遍,用哈希表记录每个前缀和值首次出现的下标;再次遇到同一个值时,两者之间的子数组和为 0,用「当前下标 − 首次下标」更新最长。只记首次出现,是因为首次出现离后来者最远,区间才可能最长。存在性版本更简单:只要某个前缀和值出现两次,或数组里出现 0,答案即为是。

\[\sum_{j=l+1}^{r} a_j = P_r - P_l = 0 \iff P_r = P_l.\]

手算 \([4, -1, -1, -1, 3]\):前缀和序列为 \(0, 4, 3, 2, 1, 4\)。值 4 出现在下标 1 与 4(前缀和数组的 0 下标从 \(P_0\) 起算),对应子数组下标 2 到 4(1 下标),即 \([-1,-1,-1,3]\),长度 4,和恰为 0。

代码。

// 返回 {最长长度, 起点下标};不存在和为 0 的子数组时长度为 0
pair<int, int> longestZeroSum(const vector<int>& a) {
    unordered_map<long long, int> first;      // 前缀和 -> 首次出现下标
    long long s = 0;
    int bestLen = 0, bestL = -1;
    first[0] = -1;                             // 空前缀 P[0] = 0 记在 -1
    for (int i = 0; i < (int)a.size(); ++i) {
        s += a[i];
        auto it = first.find(s);
        if (it != first.end()) {               // 同一前缀和两次出现:中间一段和为 0
            if (i - it->second > bestLen) { bestLen = i - it->second; bestL = it->second + 1; }
        } else first[s] = i;                   // 只记首次出现,区间才可能最长
    }
    return {bestLen, bestL};
}

复杂度。时间 \(O(n)\)(哈希均摊),空间 \(O(n)\)。

边界。必须预置 \(first[0] = -1\),否则「从数组开头开始的零和段」(如 \([1,-1]\))会被漏掉。前缀和可能溢出 int,用 long long。变式「和等于 k 的最长子数组」完全同构:找 \(P_r - P_l = k\),即查 \(s - k\) 是否出现过。

面试怎么讲。先写前缀和定义,再说「等值前缀和 ↔ 零和区间」这句等价话,代码就水到渠成。主动提「和为 k」的推广,以及为什么不能滑动窗口(负数让窗口和失去单调性,见 033 章)。

例题 6:实现 max_average(a):输入整数数组,输出任意子数组的最大平均值。(另一道源题专门强调了 subarray 的定义:连续元素。)

思路(先问口径!)。这道题的精髓在澄清:「任意子数组」是否允许长度 1?是否要求长度至少为某个 \(k\)?若长度不受限,答案是最大单元素——因为任何子数组的平均数不超过其最大元素(加权平均不超过最大值),而单元素子数组恰好取到最大元素本身,所以答案就是 \(\max_i a_i\)。这是面试官设的陷阱:很多人条件反射地写 Kadane 或对「平均值」做窗口,方向就错了。若要求长度至少为 \(k\),则用二分答案:判定「是否存在长度 ≥ k、平均值 ≥ x 的子数组」,把每个元素减去 \(x\) 后,问题变成「是否存在长度 ≥ k、和 ≥ 0 的子数组」,用前缀和与前缀最小值一遍判定;答案对值域二分到给定精度。

\[\frac{1}{r-l+1}\sum_{j=l}^{r} a_j \le \max_j a_j, \qquad \text{等号在单元素处取得}.\]

代码(不受限版)。

double maxAverage(const vector<int>& a) {     // 不限长度:答案就是最大单元素
    return *max_element(a.begin(), a.end());
}

代码(长度至少为 k 版)。

double maxAverageAtLeastK(const vector<int>& a, int k) {  // 长度 >= k 的最大均值
    double lo = *min_element(a.begin(), a.end());
    double hi = *max_element(a.begin(), a.end());
    auto ok = [&](double x) {                 // 是否存在长度 >= k 且均值 >= x 的子数组
        vector<double> P(1, 0.0);              // P:每个元素减 x 后的前缀和
        for (int v : a) P.push_back(P.back() + v - x);
        double preMin = 0.0;
        for (size_t i = k; i < P.size(); ++i) {
            preMin = min(preMin, P[i - k]);    // 维护 min P[0..i-k],与 P[i] 恰好拉开 k
            if (P[i] - preMin >= 0) return true;
        }
        return false;
    };
    while (hi - lo > 1e-6) {                   // 值域二分到精度即可
        double mid = (lo + hi) / 2;
        (ok(mid) ? lo : hi) = mid;
    }
    return lo;
}

复杂度。不受限版 \(O(n)\) 且几乎零成本;至少 k 版每次判定 \(O(n)\),共 \(O\bigl(n \log\frac{V}{\varepsilon}\bigr)\),\(V\) 为值域宽度、\(\varepsilon\) 为精度。

边界。空数组非法;\(k = 1\) 退化为不受限版;均值是浮点数,比较用容差而非严格相等;二分的循环条件用区间宽度控制而不是固定次数时,注意精度的十进制位与题面要求对齐。

面试怎么讲。第一句话应当是反问:「子数组长度有下限吗?」然后分两个口径作答。把「平均值不超过最大元素」这步不等式说清楚,是这道题的得分点;直接报出「不限长度就是 max」往往正是面试官想听的信号。

#误区与边界

四个高频翻车点

一、把 Kadane 的 best 初始化为 0:全负数组返回 0,混淆了「空子数组」与「非空」口径。二、LIS 用错二分:严格递增必须 lower_bound,upper_bound 解决的是非降版本,\([1,2,2,3]\) 一测便知。三、零和子数组忘记预置 \(first[0] = -1\):从下标 0 开始的零和段全部漏掉。四、均值题不问长度口径就动手:不限长度的正确答案就是最大单元素,任何「对平均值跑 Kadane」的写法既难写对也不必要。

考官追问的三个变式

环形数组的最大子数组和:答案是「普通最大子数组和」与「总和 − 最小子数组和」的较大者(注意全负数组的特判)。限定长度不超过 k 的最大和子数组:前缀和 + 单调队列维护滑动最小,是 033 章窗口方法的直接练习。要求输出 LIS 本身而非长度:在 \(O(n^2)\) 版里记录前驱下标回溯即可。

最后交代几道与本章主题相邻、但在别处展开的源题的去向:

sqrt(number, precision):给定浮点数与精度逐位开方,属于二分与牛顿法,完整推导与实现见 035. 编程五:二分、开方与蒙特卡洛

矩阵里最大的全 1 正方形:源题库里出现了两次(一次只问 DP、一次是标准题面),同型合并,二维 DP 的完整解答放在 032. 编程二:花束、跳跃与拆分 DP

最大子数组的金融追问:过拟合与统计学习方法的讨论见 027. 统计二:岭回归、Lasso 与模型选择;蒙特卡洛见 035 章;Black–Scholes 与期权对冲见 038. 金融二:期权入门;bootstrapping、swap curve 与组合回测见 039040 章

#检查清单

  • 我能写出 Kadane 的状态定义与转移式,并解释「以 i 结尾」这个定语为什么必要。
  • 我能在 Kadane 上加两个下标变量,输出最大和对应的区间,并在白板上手算一张状态表。
  • 我能证明「存在和为正的子数组」与「最大子数组和为正」等价,并说明何时该换成前缀和 + 哈希。
  • 我能分别写出 LIS 的 \(O(n^2)\) DP 与 \(O(n \log n)\) tails + 二分版本,并说清 lower_bound 与 upper_bound 的区别。
  • 我能用前缀和 + 哈希求「和为 0(或为 k)的最长子数组」,记得预置空前缀。
  • 我知道 max_average 在不限长度时的答案是最大单元素,并能推导长度 ≥ k 版的二分判定。
  • 我能列举本章四个高频翻车点:初值口径、二分函数选择、空前缀预置、均值题长度口径。