Tips:预封装约束的状态定义

预先封装约束的状态定义

.引言

\;\;\;\;\;\;\;\; 这个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
代码可读性 较低,容易出错 较高,逻辑清晰
思维模型 "我有什么选择?排除哪些?" "在什么约束下我能得到什么?"
相关推荐
代码充电宝3 小时前
LeetCode 算法题【简单】290. 单词规律
java·算法·leetcode·职场和发展·哈希表
Juan_20124 小时前
P1040题解
c++·算法·动态规划·题解
Onesoft%J1ao4 小时前
C++竞赛递推算法-斐波那契数列常见题型与例题详解
c++·算法·动态规划·递推·信息学奥赛
以己之4 小时前
NC313 两个数组的交集
算法·哈希算法
Brookty4 小时前
【算法】前缀和
java·学习·算法·前缀和·动态规划
And_Ii4 小时前
LeetCode 3397. 执行操作后不同元素的最大数量
数据结构·算法·leetcode
额呃呃5 小时前
leetCode第33题
数据结构·算法·leetcode
隐语SecretFlow5 小时前
【隐语SecretFlow用户案例】亚信科技构建统一隐私计算框架探索实践
科技·算法·安全·隐私计算·隐私求交·开源隐私计算
dragoooon345 小时前
[优选算法专题四.前缀和——NO.27 寻找数组的中心下标]
数据结构·算法·leetcode