预先封装约束的状态定义
.引言
\;\;\;\;\;\;\;\; 这个Tips专栏是新开的专栏,主要是因为,有一些思考感觉很重要,且深入,但是又不是一个专门的主题,且不好分类,所以将遇到的小知识都放在这个专栏。今天是2025年10月17日。正好今天在解决Leetcode题目的打家劫舍这道题的时候,使用分治法解决,发现同样是分治法,但是状态转移却截然不同,可谓天差地别。详细见下面。
.打家劫舍
\;\;\;\;\;\;\;\; 先不论标题这一长串是什么意思。先来看一个问题。这个问题是Leetcode里面很经典的问题:打家劫舍
简化点链接的时间,我将题目截图放这里:

假设a[l,r]表示区间[l,r]的最大值。
要求得 a[0,n-1] 得最大值,等价于求的a[0,mid]的最大值+a[mid+1,n-1]的最大值。而a[0,mid]的最大值等于a[0,mid/2]的最大值+a[mid/2+1,n-1]最大值。一直往下分,很显然这就是典型的分治法。
.没有限制的状态
为了从头说起,先使用最简单的没有状态限制定义 的分治法:
我认为代码很直观看出原理:
cpp
class Solution
{
public:
int rob(vector<int>& nums)
{
int n=nums.size();
if(n==0)return 0;
auto dfs=[&](this auto&&dfs,int l,int r)->int
{
if(l==r) //只有一个元素,当然选这个元素
return nums[l];
if(l+1==r) //只有两个元素,选两个之间大的一个
{
return max(nums[l],nums[r]);
}
if(l>r) //不存在区间,返回0
return 0;
int mid=(l+r)>>1;
int res=0;
//选nums[mid]和不选nums[mid]两种取最大值
res=dfs(l,mid-1)+dfs(mid+1,r); //不选nums[mid]
res=max(res,dfs(l,mid-2)+dfs(mid+2,r)+nums[mid]); //选nums[mid],那么nums[mid+1],nums[mid-1]都不能选。
return res;
};
return dfs(0,n-1);
}
};
代码可以优化一下,添加记忆化
cpp
class Solution
{
public:
int rob(vector<int>& nums)
{
int n=nums.size();
if(n==0)return 0;
vector<vector<int>>memo(n,vector<int>(n,-1));
auto dfs=[&](this auto&&dfs,int l,int r)->int
{
if(l==r) //只有一个元素,当然选这个元素
return nums[l];
if(l+1==r) //只有两个元素,选两个之间大的一个
{
return max(nums[l],nums[r]);
}
if(l>r) //不存在区间,返回0
return 0;
int &res=memo[l][r];
if(res!=-1)return res;
int mid=(l+r)>>1;
//选nums[mid]和不选nums[mid]两种取最大值
//不选nums[mid]
res=dfs(l,mid-1)+dfs(mid+1,r);
//选nums[mid],那么nums[mid+1],nums[mid-1]都不能选。
res=max(res,dfs(l,mid-2)+dfs(mid+2,r)+nums[mid]);
return res;
};
return dfs(0,n-1);
}
};
上面的分治法非常简单,同时通过固定枚举nums[mid],将当前区间分为两段。然后比较取nums[mid]和不取两种方案取最大值。这样得方法优点就是省略了边界处理(消除左边和右边相邻两个数组的限制,比如左数组取尾,右数组不能取首)。如何处理的?就是通过nums[mid]隔断,然后单独考虑左右两个数组 ,这两个数组不相邻,自然没有边界约 束,此时这两个子数组又是相同的子问题,所以可以继续分治下去。合并的时候也不需要考虑约束。
.带限制的状态
\;\;\;\;\;\;\;\; 但是,对于区间[L,R],直接考虑左右两个数组[L,mid]和[mid+1,R]呢?此时两个子数组相邻,那么分别解决这两个数组后,合并的时候就有问题了,因为要求不能选取相邻的两个元素,合并两个最大值可能出错,因为左边数组可能取了最后一个元素而右边的数组取了第一个元素。所以子问题[L,mid]和[mid+1,R]不能仅仅表示区间的最大值这单一结论了,而应该添加约束,将这个大结论拆分为几个带约束的结论。
当然,从结构性来说,带约束的结论是不带约束结论的子集。
带限制结论 ∈ 不带限制结论 带限制结论\in 不带限制结论 带限制结论∈不带限制结论
\;\;\;\;\;\;\;\; 两个不带约束结论却受到"不能有相邻元素"的约束从而无法合并。因为合并是带有约束的。因此,我们要将状态设置限制,下面四个状态定义是完全互斥且穷尽所有可能的(首 / 尾选或不选的组合共 4 种),合并时需严格确保 "左区间尾" 和 "右区间首" 不同时被选(相邻冲突)。以下是完整的状态转移逻辑,每个状态的合并都基于左区间(L~mid)和右区间(mid+1~R)的合法组合:
a[L,R][0]表示[L,R]中第一个元素不选,最后一个元素也不选的最大值
a[L,R][1]表示[L,R]中第一个元素不选,最后一个元素选的最大值
a[L,R][2]表示[L,R]中第一个元素选,最后一个元素也选的最大值
a[L,R][3]表示[L,R]中第一个元素选,最后一个元素不选的最大值
上面四个状态就将所有的可能列举出来。那么合并左右两个数组的时候,就要分类讨论合并。
比如已经求得了 a[L,mid][0~3] 和 a[mid+1,R][0~3] 的值。下面是合并过程中的状态转移:
cpp
a[L,R][0] = max(
{
a[L,mid][0] + a[mid+1,R][0],
a[L,mid][0] + a[mid+1,R][3],
a[L,mid][1] + a[mid+1,R][0]
});
a[L,R][1] = max({
a[L,mid][0] + a[mid+1,R][1],
a[L,mid][0] + a[mid+1,R][2],
a[L,mid][1] + a[mid+1,R][1]
});
a[L,R][2] = max({
a[L,mid][2] + a[mid+1,R][1],
a[L,mid][3] + a[mid+1,R][1],
a[L,mid][3] + a[mid+1,R][2]
});
a[L,R][3] = max({
a[L,mid][2] + a[mid+1,R][0],
a[L,mid][3] + a[mid+1,R][0],
a[L,mid][3] + a[mid+1,R][3]
});
代码实现为:
cpp
class Solution {
public:
int rob(vector<int>& nums) {
int n = nums.size();
if (n == 0) return 0;
// 数组大小使用 2 << bit_width(n-1) 是足够的(覆盖所有节点)
vector<array<int, 4>> a(2 << bit_width(static_cast<unsigned int>(n-1)));
// a[i][0]: 第一个不选,最后一个不选
// a[i][1]: 第一个不选,最后一个选
// a[i][2]: 第一个选,最后一个不选
// a[i][3]: 第一个选,最后一个选
auto maintain = [&](int idx) -> void {
auto& b = a[idx];
auto& c = a[idx*2+1]; // 左区间 [l, mid]
auto& d = a[idx*2+2]; // 右区间 [mid+1, r]
// 合并逻辑:严格保证 mid 和 mid+1 不同时被选
b[0] = max({
c[0] + d[0], // 左都不选,右都不选
c[0] + d[2], // 左都不选,右首选尾不选
c[1] + d[0], // 左首不选尾选,右都不选(mid选,mid+1不选)
c[1] + d[2] // 左首不选尾选,右首选尾不选(错误,mid和mid+1都选,排除)
// 注意:c[1](mid选)和d[2](mid+1选)相邻,需排除
});
b[0] = max({c[0]+d[0], c[0]+d[2], c[1]+d[0]});
b[1] = max({
c[0] + d[1], // 左都不选,右首不选尾选
c[0] + d[3], // 左都不选,右首选尾选(mid不选,mid+1选,合法)
c[1] + d[1], // 左首不选尾选,右首不选尾选(mid选,mid+1不选,合法)
c[1] + d[3] // 左首不选尾选,右首选尾选(mid和mid+1都选,排除)
});
b[1] = max({c[0]+d[1], c[0]+d[3], c[1]+d[1]});
b[2] = max({
c[2] + d[0], // 左首选尾不选,右都不选(mid不选,mid+1不选)
c[2] + d[2], // 左首选尾不选,右首选尾不选(mid不选,mid+1选,合法)
c[3] + d[0], // 左首选尾选,右都不选(mid选,mid+1不选,合法)
c[3] + d[2] // 左首选尾选,右首选尾不选(mid和mid+1都选,排除)
});
b[2] = max({c[2]+d[0], c[2]+d[2], c[3]+d[0]});
b[3] = max({
c[2] + d[1], // 左首选尾不选,右首不选尾选(mid不选,mid+1不选,合法)
c[2] + d[3], // 左首选尾不选,右首选尾选(mid不选,mid+1选,合法)
c[3] + d[1], // 左首选尾选,右首不选尾选(mid选,mid+1不选,合法)
c[3] + d[3] // 左首选尾选,右首选尾选(mid和mid+1都选,排除)
});
b[3] = max({c[2]+d[1], c[2]+d[3], c[3]+d[1]});
};
auto div = [&](this auto&& self, int idx, int l, int r) -> void {
if (l == r) {
// 单个元素:修正状态初始化
a[idx][0] = 0; // 都不选
a[idx][1] = 0; // 首不选尾选(矛盾,为0)
a[idx][2] = 0; // 首选尾不选(矛盾,为0)
a[idx][3] = nums[l]; // 首选尾选(合法)
return;
}
int mid = (l + r) >> 1;
self(idx*2+1, l, mid);
self(idx*2+2, mid+1, r);
maintain(idx);
};
div(0, 0, n-1);
// 最终结果取所有合法状态的最大值
return max({a[0][0], a[0][1], a[0][2], a[0][3]});
}
};
.预封装约束的状态
上面的带约束状态转移方程逻辑非常清楚完善。是穷举了合并时约束的所有可能。这当然可以,但是写代码的时候非常冗余不说,有时候也会漏掉一些状态转移。有什么办法呢?
\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\;\; 预先封装好约束的状态转移
为了区别两种方法,我先说第一种方法的本质:
第一种方法(4 个独立状态) :
操作: 合并的时候,穷举4个状态所有的组合(4*4=16),然后根据约束条件删除不合法的组合。
性质: 状态描述"当前的选择结果"。
现在来说第二种方法:
定义:
f00: 首必不选 + 尾必不选
f01: 首必不选 + 尾可选可不选
f10: 首可选可不选 + 尾必不选
f11: 首可选可不选 + 尾可选可不选
代码:
cpp
class Solution
{
public:
// 定义4个状态的返回值:{f00, f01, f10, f11}
using State = tuple<int, int, int, int>;
State dfs(const vector<int>& nums, int l, int r)
{
if (l == r)
{
// 边界:长度为1,按定义返回
return {0, 0, 0, max(nums[l], 0)};
}
int mid = l + (r - l) / 2;
auto [p00, p01, p10, p11] = dfs(nums, l, mid); // 左区间 p = [l, mid]
auto [q00, q01, q10, q11] = dfs(nums, mid + 1, r); // 右区间 q = [mid+1, r]
// 计算当前区间的4个状态
int f00 = max(p00 + q10, p01 + q00);
int f01 = max(p00 + q11, p01 + q01);
int f10 = max(p10 + q10, p11 + q00);
int f11 = max(p10 + q11, p11 + q01);
return {f00, f01, f10, f11};
}
int rob(vector<int>& nums)
{
if (nums.empty()) return 0;
auto [f00, f01, f10, f11] = dfs(nums, 0, nums.size() - 1);
return f11; // f11就是无约束的原始问题答案
}
};
发现了,第二种方法的合并操作简单很少,大大简化了代码量。那么第二种方法为什么可以这样呢?
性质: 隐式约束传递。状态包含选择结果 和对边界的约束信息 。
约束方式: 状态包含选择结果和对边界的约束信息。
本质: 状态本身携带约束信息,合并时通过约束的自然匹配来避免相邻冲突,无需显式检查。
区别:
特性 | 第一种 | 第二种 |
---|---|---|
状态语义 | 记录具体选择结果 | 记录选择结果+边界约束 |
约束保证 | 显式排除非法组合 | 隐式通过约束匹配 |
合并逻辑 | 复杂,需要枚举所有可能 | 简洁,只有2种组合 per state |
代码可读性 | 较低,容易出错 | 较高,逻辑清晰 |
思维模型 | "我有什么选择?排除哪些?" | "在什么约束下我能得到什么?" |