从一道 LeetCode Hard 到吃透一类题:加权区间调度「排序 + 二分 + DP」


从一道 LeetCode Hard 到吃透一类题:加权区间调度「排序 + 二分 + DP」完整模板与 6 题详解

前言

最近做到了 LeetCode 3414:

3414. 不重叠区间的最大得分

这道题第一眼看起来比较复杂:

  • 有很多区间;
  • 每个区间有一个权重;
  • 最多只能选择 4 个;
  • 选择的区间不能重叠;
  • 要让总权重最大;
  • 如果最大权重相同,还要返回字典序最小的原始下标数组。

但做完之后会发现,它的核心其实是一个非常经典的算法模型:

Weighted Interval Scheduling(加权区间调度)

核心方法只有三个:

text 复制代码
排序
+
二分查找
+
动态规划

为了彻底掌握这个模型,我又连续做了 5 道类似题:

text 复制代码
3414 不重叠区间的最大得分
 ↓
1235 规划兼职工作
 ↓
2054 两个最好的不重叠活动
 ↓
1751 最多可以参加的会议数目 II
 ↓
2830 销售利润最大化
 ↓
2008 出租车的最大盈利

这篇文章就从这 6 道题出发,总结这一类题目的统一模型、识别方法、DP 状态设计、二分查找以及不同题目之间的变化。


一、什么是加权区间调度?

最基本的问题是:

给你 n 个区间:

text 复制代码
[start, end, weight]

其中:

text 复制代码
start  = 开始位置
end    = 结束位置
weight = 选择这个区间获得的收益

要求选择若干个互不重叠的区间,使:

text 复制代码
weight 总和最大

例如:

text 复制代码
A = [1, 3, 5]
B = [2, 5, 8]
C = [4, 6, 6]

如果 AC 不重叠,那么:

text 复制代码
A + C = 11

虽然单独看:

text 复制代码
B = 8

比:

text 复制代码
A = 5
C = 6

都大,但是:

text 复制代码
A + C = 11 > 8

所以不能简单地每次选择权重最大的区间。

这就是为什么这类问题通常不能使用简单贪心。


二、怎么识别这种题?

以后看到下面几个关键词同时出现:

text 复制代码
区间
+
每个区间有价值 / 收益 / 权重
+
选择的区间不能重叠
+
最大化总价值

应该马上想到:

加权区间调度

也就是:

text 复制代码
排序 + 二分查找 + DP

例如:

text 复制代码
工作:
[startTime, endTime, profit]

活动:
[startTime, endTime, value]

卖房:
[startHouse, endHouse, gold]

出租车订单:
[startPoint, endPoint, earning]

它们表面完全不同,但本质上都是:

text 复制代码
[start, end, value]

三、为什么不能直接贪心?

普通的「最多选择多少个不重叠区间」可以按照结束时间贪心。

但是如果每个区间有不同收益,就不行了。

例如:

text 复制代码
A = [1, 10], value = 100

B = [1, 4], value = 60
C = [5, 10], value = 60

如果每次选择价值最大的:

text 复制代码
A = 100

最终收益:

text 复制代码
100

但实际上:

text 复制代码
B + C = 120

更大。

因此:

区间数量最大化区间权重最大化是两类不同问题。

看到:

text 复制代码
最大收益
最大权重
最大价值

要警惕不能直接贪心。


四、这类题最核心的思想:选 or 不选

假设现在正在处理第 i 个区间。

对于当前区间,我们其实只有两个选择:

text 复制代码
                    当前区间 i
                        │
              ┌─────────┴─────────┐
              ↓                   ↓
             不选                 选
              ↓                   ↓
        dp[i - 1]          dp[previous] + value[i]

所以核心状态转移就是:

text 复制代码
dp[i] = max(
    dp[i - 1],
    dp[previous] + value[i]
)

这几乎就是整个加权区间调度的灵魂。


五、为什么需要排序?

如果区间完全乱序:

text 复制代码
[8, 10]
[1, 3]
[6, 7]
[2, 5]

我们很难快速判断:

当前区间之前有哪些区间可以和它同时选择?

所以一种非常经典的处理方式是:

按照右端点 end 从小到大排序。

排序以后:

text 复制代码
[1, 3]
[2, 5]
[6, 7]
[8, 10]

对应的右端点:

text 复制代码
3 5 7 10

天然有序。

这样就可以二分查找。


六、二分查找到底在找什么?

假设当前区间:

text 复制代码
[6, 9]

前面区间的右端点:

text 复制代码
2 3 5 7 8

如果题目要求两个区间完全不能共享端点,那么我们需要:

text 复制代码
previous.end < current.start

当前:

text 复制代码
current.start = 6

所以合法的是:

text 复制代码
2 3 5

不合法:

text 复制代码
7 8

我们可以二分寻找:

第一个 end >= 6 的位置。

假设位置为:

text 复制代码
3

那么:

text 复制代码
[0, 3)

恰好有 3 个区间满足:

text 复制代码
end < 6

因此:

cpp 复制代码
dp[3]

就是可以和当前区间组合的前半部分最优解。


七、这里有一个非常重要的坑:端点是否算重叠

这 6 道题并不是完全一样。

必须看清楚题目对于端点的定义。

情况一:共享端点算重叠

例如 3414:

text 复制代码
[1, 3]
[3, 5]

算重叠。

所以必须:

cpp 复制代码
previous.right < current.left

也就是:

text 复制代码
right < left

3414 明确规定共享边界也算重叠。(LeetCode Wiki)

2054 同样是闭区间,前一个活动在 t 结束时,下一个活动至少要从 t + 1 开始。(LeetCode)


情况二:共享端点不算重叠

1235 则不同。

如果:

text 复制代码
工作 A 在 3 结束
工作 B 在 3 开始

允许同时做。

因此条件是:

cpp 复制代码
previous.end <= current.start

LeetCode 1235 的题目明确允许一个工作在 X 结束后,另一个工作在 X 开始。(LeetCode)

2008 出租车也是:

可以在一个点放下乘客,同时在这个点接另一个乘客。

因此也是:

text 复制代码
previous.end <= current.start

(LeetCode)

这个细节非常重要。


八、最基础的 DP 模板

假设:

text 复制代码
arr[i] = [left, right, weight]

并且已经按照:

text 复制代码
right 升序

排序。

定义:

cpp 复制代码
dp[i]

表示:

i 个区间能够得到的最大收益。

对于第 i 个区间:

不选

cpp 复制代码
dp[i] = dp[i - 1];

选择

先二分找到 previous

text 复制代码
前 previous 个区间
都可以和当前区间共存

那么:

cpp 复制代码
dp[i] = dp[previous] + weight;

最后:

cpp 复制代码
dp[i] = max(dp[i - 1], dp[previous] + weight);

这就是最标准的 Weighted Interval Scheduling。


九、如果题目要求最多选择 K 个怎么办?

这时候一维 DP 不够了。

定义:

cpp 复制代码
dp[i][k]

表示:

i 个区间里面,最多选择 k 个时能够获得的最大收益。

仍然只有两个选择。

不选:

cpp 复制代码
dp[i - 1][k]

选择:

cpp 复制代码
dp[previous][k - 1] + weight

所以:

text 复制代码
dp[i][k]
=
max(
    dp[i - 1][k],
    dp[previous][k - 1] + weight[i]
)

这就是 1751 和 3414 的核心。


十、六道题之间到底是什么关系?

可以把它们放到一棵树里:

text 复制代码
                    加权区间调度
                         │
              排序 + 二分 + DP
                         │
          ┌──────────────┴──────────────┐
          ↓                             ↓
      不限制选择数量                 限制选择数量
          │                             │
     1235 / 2830                 2054 / 1751 / 3414
     2008                              │
                                       ↓
                              dp[i][k]
                                       │
                         ┌─────────────┴─────────────┐
                         ↓                           ↓
                      k 固定                     k 是输入
                    2054: 2                      1751
                    3414: 4

其中 3414 又额外要求:

text 复制代码
返回原始下标
+
最大权重相同时
返回字典序最小方案

所以它又需要保存方案。


十一、LeetCode 3414:不重叠区间的最大得分

题目

给:

text 复制代码
intervals[i] = [li, ri, weighti]

最多选择 4 个互不重叠区间,使权重之和最大。

如果存在多个最大权重方案:

返回字典序最小的原始下标数组。

而且共享边界也算重叠。(LeetCode Wiki)

例如:

text 复制代码
intervals =
[
    [1,3,2],
    [4,5,2],
    [1,5,5],
    [6,9,3],
    [6,7,1],
    [8,9,1]
]

答案:

text 复制代码
[2,3]

难点

3414 比标准模板多了两个东西。

第一:最多选择 4 个

所以:

cpp 复制代码
dp[i]

变成:

cpp 复制代码
dp[i][k]

其中:

text 复制代码
k = 0 ~ 4

第二:需要返回具体方案

如果:

cpp 复制代码
dp[i][k]

只保存一个数字:

text 复制代码
最大权重 = 100

我们最后不知道到底选择了哪些区间。

因此定义:

cpp 复制代码
struct State
{
    long long SumWeight = 0;
    vector<int> res;
};

其中:

text 复制代码
SumWeight = 当前方案总权重
res       = 当前方案选择的原始下标

比较两个状态

优先比较:

text 复制代码
总权重

如果相同:

text 复制代码
比较 res 的字典序

因此:

cpp 复制代码
auto Compare = [](const State& a, const State& b) {
    if(a.SumWeight != b.SumWeight)
    {
        return a.SumWeight > b.SumWeight;
    }
    return a.res < b.res;
};

完整代码

cpp 复制代码
class Solution 
{
public:
    struct State
    {
        long long SumWeight = 0;
        vector<int> res;
    };

    struct newintervals
    {
        int left;
        int right;
        int weights;
        int place;
    };
    
    static bool cmp(const newintervals& a, const newintervals& b)
    {
        if(a.right != b.right)
        {
            return a.right < b.right;
        }
        if(a.left != b.left)
        {
            return a.left < b.left;
        }
        return a.place < b.place;
    }

    vector<int> maximumWeight(vector<vector<int>>& intervals) 
    {
        int n = intervals.size();

        vector<newintervals> arr;
        for(int i = 0; i < n; i++)
        {
            arr.push_back({intervals[i][0], intervals[i][1], intervals[i][2], i});
        }

        sort(arr.begin(), arr.end(), cmp);

        vector<vector<State>> dp(n + 1, vector<State>(5));

        auto Compare = [](const State& a, const State& b) {
            if(a.SumWeight != b.SumWeight)
            {
                return a.SumWeight > b.SumWeight;
            }
            return a.res < b.res;
        };

        for(int i = 1; i <= n; i++)
        {
            int nowleft = arr[i - 1].left;

            int left = 0, right = i - 1;
            while(left < right)
            {
                int mid = left + (right - left) / 2;
                if(arr[mid].right < nowleft)
                {
                    left = mid + 1;
                }
                else 
                {
                    right = mid;
                }
            }

            int previous = left;

            for(int k = 1; k <= 4; k++)
            {
                State nochoose = dp[i - 1][k];
                State choose = dp[previous][k - 1];

                choose.SumWeight += arr[i - 1].weights;
                choose.res.push_back(arr[i - 1].place);
                sort(choose.res.begin(), choose.res.end());

                if(Compare(choose, nochoose))
                {
                    dp[i][k] = choose;
                }
                else
                {
                    dp[i][k] = nochoose;
                }
            }
        }

        return dp[n][4].res;
    }
};

十二、例题 1:LeetCode 1235 规划兼职工作

这是整个专题里我认为最应该掌握的一道模板题

题目给:

text 复制代码
startTime[i]
endTime[i]
profit[i]

要求:

选择若干互不重叠工作,使总收益最大。

这道题允许:

text 复制代码
前一个工作 end == 后一个工作 start

也就是说:

text 复制代码
[1,3]
[3,6]

可以同时选择。(LeetCode)


DP 定义

定义:

cpp 复制代码
dp[i]

表示:

i 个工作能够得到的最大收益。


当前工作不选

cpp 复制代码
dp[i] = dp[i - 1];

当前工作选择

找到前面满足:

text 复制代码
end <= current.start

的所有工作。

假设有:

text 复制代码
previous

个。

那么:

cpp 复制代码
dp[previous] + current.profit

状态转移

text 复制代码
dp[i]
=
max(
    dp[i - 1],
    dp[previous] + current.profit
)

C++ 代码

cpp 复制代码
class Solution
{
public:
    struct Job
    {
        int start;
        int end;
        int profit;
    };

    static bool cmp(const Job& a, const Job& b)
    {
        return a.end < b.end;
    }

    int jobScheduling(vector<int>& startTime, vector<int>& endTime, vector<int>& profit)
    {
        int n = startTime.size();
        vector<Job> arr;

        for(int i = 0; i < n; i++)
        {
            arr.push_back({startTime[i], endTime[i], profit[i]});
        }

        sort(arr.begin(), arr.end(), cmp);

        vector<int> dp(n + 1, 0);

        for(int i = 1; i <= n; i++)
        {
            int nowStart = arr[i - 1].start;

            int left = 0, right = i - 1;
            while(left < right)
            {
                int mid = left + (right - left) / 2;
                if(arr[mid].end <= nowStart)
                {
                    left = mid + 1;
                }
                else
                {
                    right = mid;
                }
            }

            int previous = left;
            dp[i] = max(dp[i - 1], dp[previous] + arr[i - 1].profit);
        }

        return dp[n];
    }
};

和 3414 的关系

3414:

cpp 复制代码
dp[i][k]

1235:

cpp 复制代码
dp[i]

因为 1235:

不限制最多选几个。

所以可以把第二维直接去掉。

这就是 3414 的基础版本。


十三、例题 2:LeetCode 2054 两个最好的不重叠活动

题目:

text 复制代码
events[i] = [start, end, value]

最多参加:

text 复制代码
2 个

不重叠活动,使价值最大。(LeetCode)

这道题和 3414 非常像。

3414:

text 复制代码
最多 4 个

2054:

text 复制代码
最多 2 个

DP

定义:

cpp 复制代码
dp[i][k]

表示:

i 个活动最多选择 k 个的最大价值。

其中:

text 复制代码
k = 1 ~ 2

状态转移

text 复制代码
不选择当前活动:

dp[i - 1][k]


选择当前活动:

dp[previous][k - 1] + value

所以:

text 复制代码
dp[i][k]
=
max(
    dp[i - 1][k],
    dp[previous][k - 1] + value
)

注意端点

2054 的:

text 复制代码
[start, end]

是闭区间。

所以:

text 复制代码
[1,3]
[3,5]

不能同时参加。

必须:

text 复制代码
previous.end < current.start

(LeetCode)


C++ 代码

cpp 复制代码
class Solution
{
public:
    static bool cmp(const vector<int>& a, const vector<int>& b)
    {
        return a[1] < b[1];
    }

    int maxTwoEvents(vector<vector<int>>& events)
    {
        int n = events.size();

        sort(events.begin(), events.end(), cmp);

        vector<vector<int>> dp(n + 1, vector<int>(3));

        for(int i = 1; i <= n; i++)
        {
            int nowStart = events[i - 1][0];

            int left = 0, right = i - 1;
            while(left < right)
            {
                int mid = left + (right - left) / 2;
                if(events[mid][1] < nowStart)
                {
                    left = mid + 1;
                }
                else
                {
                    right = mid;
                }
            }

            int previous = left;

            for(int k = 1; k <= 2; k++)
            {
                dp[i][k] = max(dp[i - 1][k], dp[previous][k - 1] + events[i - 1][2]);
            }
        }

        return dp[n][2];
    }
};

十四、例题 3:LeetCode 1751 最多可以参加的会议数目 II

这一题可以认为是:

2054 的通用版本。

2054:

text 复制代码
最多选择 2 个

1751:

text 复制代码
最多选择 k 个

所以核心直接变成:

cpp 复制代码
dp[i][j]

其中:

text 复制代码
i = 前多少个活动
j = 最多选择多少个活动

状态转移

还是完全一样:

text 复制代码
不选择:

dp[i - 1][j]


选择:

dp[previous][j - 1] + value

因此:

text 复制代码
dp[i][j]
=
max(
    dp[i - 1][j],
    dp[previous][j - 1] + value
)

你做到这里应该发现:

2054、1751、3414 的 DP 几乎就是同一个东西。

区别只是:

text 复制代码
2054:K = 2
1751:K = 输入的 k
3414:K = 4 + 还要保存答案下标

C++ 代码

cpp 复制代码
class Solution
{
public:
    static bool cmp(const vector<int>& a, const vector<int>& b)
    {
        return a[1] < b[1];
    }

    int maxValue(vector<vector<int>>& events, int k)
    {
        int n = events.size();

        sort(events.begin(), events.end(), cmp);

        vector<vector<int>> dp(n + 1, vector<int>(k + 1));

        for(int i = 1; i <= n; i++)
        {
            int nowStart = events[i - 1][0];

            int left = 0, right = i - 1;
            while(left < right)
            {
                int mid = left + (right - left) / 2;
                if(events[mid][1] < nowStart)
                {
                    left = mid + 1;
                }
                else
                {
                    right = mid;
                }
            }

            int previous = left;

            for(int j = 1; j <= k; j++)
            {
                dp[i][j] = max(dp[i - 1][j], dp[previous][j - 1] + events[i - 1][2]);
            }
        }

        return dp[n][k];
    }
};

十五、例题 4:LeetCode 2830 销售利润最大化

这道题把「时间区间」换成了:

房子的编号区间。

有:

text 复制代码
offers[i] = [start, end, gold]

买家愿意购买:

text 复制代码
start ~ end

之间所有房子,并支付:

text 复制代码
gold

同一套房不能卖给两个买家。(LeetCode)

是不是还是:

text 复制代码
[start, end, value]

没变。


为什么还是加权区间调度?

一个 offer:

text 复制代码
[1,4,10]

意味着:

text 复制代码
使用区间 [1,4]
收益 10

两个买家不能买相同房子:

text 复制代码
区间不能重叠

目标:

text 复制代码
最大化 gold

所以:

text 复制代码
区间
+
权重
+
不能重叠
+
最大收益

标准加权区间调度。


DP

定义:

cpp 复制代码
dp[i]

表示:

i 个 offer 可以得到的最大收益。

然后:

text 复制代码
不选:
dp[i - 1]

选择:
dp[previous] + gold

C++ 代码

cpp 复制代码
class Solution
{
public:
    static bool cmp(const vector<int>& a, const vector<int>& b)
    {
        return a[1] < b[1];
    }

    int maximizeTheProfit(int n, vector<vector<int>>& offers)
    {
        int m = offers.size();

        sort(offers.begin(), offers.end(), cmp);

        vector<int> dp(m + 1, 0);

        for(int i = 1; i <= m; i++)
        {
            int nowStart = offers[i - 1][0];

            int left = 0, right = i - 1;
            while(left < right)
            {
                int mid = left + (right - left) / 2;
                if(offers[mid][1] < nowStart)
                {
                    left = mid + 1;
                }
                else
                {
                    right = mid;
                }
            }

            int previous = left;

            dp[i] = max(dp[i - 1], dp[previous] + offers[i - 1][2]);
        }

        return dp[m];
    }
};

这也是标准的排序 + 二分 + DP 解法。(LeetCode Wiki)


十六、例题 5:LeetCode 2008 出租车的最大盈利

这一题包装得更深一点。

给:

text 复制代码
rides[i] = [start, end, tip]

如果接这个乘客,赚:

text 复制代码
end - start + tip

而出租车同一时间最多载一个乘客。(LeetCode)

所以我们先把:

text 复制代码
[start, end, tip]

转换成:

text 复制代码
[start, end, value]

其中:

text 复制代码
value = end - start + tip

然后问题瞬间变成:

选择若干不重叠区间,使 value 总和最大。

又回到了 Weighted Interval Scheduling。


特别注意

出租车允许:

text 复制代码
在位置 X 放下一个乘客
然后立刻在 X 接另一个乘客

所以:

text 复制代码
previous.end <= current.start

即可。

这和 1235 是一样的。(LeetCode)


C++ 代码

cpp 复制代码
class Solution
{
public:
    static bool cmp(const vector<int>& a, const vector<int>& b)
    {
        return a[1] < b[1];
    }

    long long maxTaxiEarnings(int n, vector<vector<int>>& rides)
    {
        int m = rides.size();

        sort(rides.begin(), rides.end(), cmp);

        vector<long long> dp(m + 1, 0);

        for(int i = 1; i <= m; i++)
        {
            int nowStart = rides[i - 1][0];

            int left = 0, right = i - 1;
            while(left < right)
            {
                int mid = left + (right - left) / 2;
                if(rides[mid][1] <= nowStart)
                {
                    left = mid + 1;
                }
                else
                {
                    right = mid;
                }
            }

            int previous = left;

            long long value = rides[i - 1][1] - rides[i - 1][0] + rides[i - 1][2];

            dp[i] = max(dp[i - 1], dp[previous] + value);
        }

        return dp[m];
    }
};

十七、六道题统一起来看

这是整个专题最重要的一张表:

题目 区间 权重 最多选择 DP
1235 工作时间 profit 无限 dp[i]
2054 活动时间 value 2 dp[i][2]
1751 活动时间 value k dp[i][k]
2830 房屋区间 gold 无限 dp[i]
2008 乘客行程 路程 + tip 无限 dp[i]
3414 普通区间 weight 4 dp[i][4] + State

所以其实不是 6 套算法。

而是:

1 套算法,6 种包装。


十八、真正应该背下来的模板

如果题目不限制选择数量:

cpp 复制代码
sort(intervals.begin(), intervals.end(), 按右端点排序);

vector<long long> dp(n + 1);

for(int i = 1; i <= n; i++)
{
    int previous = 二分找到前面不冲突区间数量;

    long long nochoose = dp[i - 1];
    long long choose = dp[previous] + 当前收益;

    dp[i] = max(nochoose, choose);
}

return dp[n];

这就是最基础模板。


十九、限制最多选择 K 个的模板

如果题目出现:

text 复制代码
最多选择 K 个

马上想到:

cpp 复制代码
dp[i][k]

模板:

cpp 复制代码
vector<vector<long long>> dp(n + 1, vector<long long>(K + 1));

for(int i = 1; i <= n; i++)
{
    int previous = 二分找到前面不冲突的位置;

    for(int k = 1; k <= K; k++)
    {
        dp[i][k] = max(
            dp[i - 1][k],
            dp[previous][k - 1] + weight
        );
    }
}

这个模板直接覆盖:

text 复制代码
2054
1751
3414

二十、如果还要求返回具体选择方案

3414 又进一步要求:

text 复制代码
不是返回最大值
而是返回选了哪些区间

那就不能只保存:

cpp 复制代码
long long

而需要:

cpp 复制代码
struct State
{
    long long weight = 0;
    vector<int> res;
};

也就是说:

text 复制代码
普通 DP:

dp[i][k] = 最大值


需要恢复答案:

dp[i][k] = {
    最大值,
    具体方案
}

二十一、为什么二分是这类题的关键?

如果不用二分,对于每一个区间 i,我们都从前往后寻找:

text 复制代码
谁和当前区间不冲突?

可能需要:

text 复制代码
O(n)

一共有 n 个区间:

text 复制代码
O(n²)

但是排序之后:

text 复制代码
end[0] <= end[1] <= end[2] <= ...

就可以二分:

text 复制代码
O(log n)

所以整体:

text 复制代码
排序:
O(n log n)

n 次二分:
O(n log n)

DP:
O(n)

最终:

text 复制代码
O(n log n)

这也是「排序 + 二分 + DP」三者为什么经常一起出现。


二十二、二分查找到底用 < 还是 <=

这是这一组题最容易写错的地方之一。

不要背代码。

看题目对于端点的定义。

如果:

text 复制代码
[1,3]
[3,5]

算重叠:

cpp 复制代码
previous.end < current.start

如果不算重叠:

cpp 复制代码
previous.end <= current.start

这 6 道可以记成:

题目 合法条件
3414 previous.end < current.start
2054 previous.end < current.start
1751 previous.end < current.start
2830 previous.end < current.start
1235 previous.end <= current.start
2008 previous.end <= current.start

这个表非常值得记。


二十三、为什么 DP 经常开 n + 1

例如:

cpp 复制代码
vector<int> dp(n + 1);

不是随便多开一个。

我们故意让:

text 复制代码
dp[0]

表示:

一个区间都没有考虑。

自然:

text 复制代码
dp[0] = 0

然后:

text 复制代码
dp[1] = 前 1 个区间
dp[2] = 前 2 个区间
...
dp[n] = 前 n 个区间

这样处理第 i 个 DP 状态时,对应实际数组:

cpp 复制代码
arr[i - 1]

所以经常看到:

cpp 复制代码
for(int i = 1; i <= n; i++)
{
    当前区间 = arr[i - 1];
}

二十四、为什么 previous 可以直接作为 DP 下标?

这是刚开始做这类题最容易迷惑的地方。

假设:

text 复制代码
end = [2, 4, 5, 8, 10]

当前:

text 复制代码
start = 6

合法:

text 复制代码
2
4
5

一共有:

text 复制代码
3 个

于是:

cpp 复制代码
previous = 3;

而:

cpp 复制代码
dp[3]

恰好表示:

前 3 个区间的最优答案。

所以可以直接:

cpp 复制代码
dp[previous]

这也是为什么:

DP 使用"前 i 个区间"的定义特别方便。


二十五、最容易犯的错误

错误 1:忘记排序

二分查找的前提是:

text 复制代码
右端点有序

所以必须先:

cpp 复制代码
sort(...)

错误 2:共享端点判断错

把:

cpp 复制代码
<

写成:

cpp 复制代码
<=

可能直接 WA。

一定先确认:

text 复制代码
共享端点到底算不算重叠?

错误 3:dp[i]arr[i] 混淆

如果:

cpp 复制代码
dp[i]

表示前 i 个区间,

那么当前区间一定是:

cpp 复制代码
arr[i - 1]

不是:

cpp 复制代码
arr[i]

错误 4:选择当前区间以后还用 dp[i - 1]

错误:

cpp 复制代码
choose = dp[i - 1] + weight;

因为:

text 复制代码
dp[i - 1]

里面可能已经选择了和当前区间重叠的区间。

必须:

cpp 复制代码
choose = dp[previous] + weight;

错误 5:最多选择 K 个却只写一维 DP

如果:

text 复制代码
最多选 K 个

那么需要记录:

text 复制代码
已经允许选择多少个

所以通常:

cpp 复制代码
dp[i][k]

错误 6:需要返回方案却只保存最大值

3414 如果只写:

cpp 复制代码
vector<vector<long long>> dp;

最后只能知道:

text 复制代码
最大权重

不知道:

text 复制代码
具体选择哪些下标

所以需要 State


二十六、面试时应该怎么推导?

如果面试遇到这种题,不建议一上来就说:

我背过 Weighted Interval Scheduling。

更好的推导方式是:

第一步:发现不能贪心

因为每个区间价值不同。


第二步:想到 DP

对于当前区间:

text 复制代码
选
or
不选

第三步:发现选当前区间需要知道前面哪里还能接

于是需要寻找:

text 复制代码
previous

第四步:如果线性寻找 previous

复杂度:

text 复制代码
O(n²)

第五步:发现可以排序

按照:

text 复制代码
end

排序后,右端点有序。


第六步:二分 previous

把:

text 复制代码
O(n)

降到:

text 复制代码
O(log n)

最终:

text 复制代码
O(n log n)

这个推导过程其实比直接默写代码更重要。


二十七、这类题和普通区间贪心怎么区分?

这是面试中非常重要的分类能力。

类型 1:合并区间

例如:

text 复制代码
把所有重叠区间合并

通常:

text 复制代码
排序 + 贪心

类型 2:最多选择多少个不重叠区间

如果每个区间价值都一样,只问数量:

text 复制代码
排序 + 贪心

类型 3:会议室数量

例如:

text 复制代码
最少需要几个会议室

通常:

text 复制代码
排序 + 堆

或者:

text 复制代码
扫描线

类型 4:区间有不同价值,求最大收益

text 复制代码
区间
+
weight
+
不能重叠
+
最大收益

通常:

text 复制代码
排序 + 二分 + DP

这才是本文讨论的 Weighted Interval Scheduling。


二十八、六道题真正训练了什么?

刷完这 6 道以后,真正需要获得的能力不是:

text 复制代码
记住 6 份代码

而是看到题目之后能够做这个转换:

text 复制代码
工作
活动
房屋
乘客
普通区间
        ↓
全部去掉题目背景
        ↓
[start, end, value]
        ↓
选择不重叠区间
        ↓
最大化 value
        ↓
Weighted Interval Scheduling
        ↓
排序 + 二分 + DP

这才叫真正掌握一个题型。


二十九、最终万能思维模板

以后遇到区间收益题,可以按照下面的顺序思考:

text 复制代码
① 每个元素是不是一个区间?

        ↓ 是

② 每个区间是不是有收益 / 权重?

        ↓ 是

③ 是不是要求选择的区间不能重叠?

        ↓ 是

④ 是不是要求最大化总收益?

        ↓ 是

      加权区间调度
           ↓
    按端点进行排序
           ↓
    二分找 previous
           ↓
        动态规划
           ↓
     当前区间选 / 不选

然后继续判断:

text 复制代码
是否限制最多选择 K 个?

如果没有:

cpp 复制代码
dp[i]

如果有:

cpp 复制代码
dp[i][k]

如果还要求:

text 复制代码
返回具体选择方案

那么:

cpp 复制代码
dp[i][k]

里面不只保存数字,而保存:

cpp 复制代码
State
{
    value,
    answer
}

三十、最后总结

这 6 道题看起来分别是:

text 复制代码
3414:选择区间
1235:安排工作
2054:参加活动
1751:参加会议
2830:出售房屋
2008:出租车接乘客

但把业务背景全部去掉以后,其实全部都是:

text 复制代码
给我若干:

[start, end, value]

选择若干不冲突区间

让:

Σ value

最大。

最终统一成:

text 复制代码
                 Weighted Interval Scheduling
                           │
                           ↓
                    按区间端点排序
                           │
                           ↓
                   二分寻找 previous
                           │
                           ↓
                     动态规划 DP
                           │
                ┌──────────┴──────────┐
                ↓                     ↓
              不选 i                 选择 i
                ↓                     ↓
          dp[i - 1][k]     dp[previous][k - 1]
                                      +
                                   value[i]
                \                     /
                 \                   /
                  └────── max ──────┘
                           ↓
                       dp[i][k]

如果不限制数量:

text 复制代码
dp[i]
=
max(
    dp[i - 1],
    dp[previous] + value[i]
)

如果最多选择 K 个:

text 复制代码
dp[i][k]
=
max(
    dp[i - 1][k],
    dp[previous][k - 1] + value[i]
)

如果还要求返回方案:

text 复制代码
数字 DP
↓
State DP
↓
同时保存最大收益 + 选择方案

所以这 6 道题最终只需要记住一句话:

区间 + 权重 + 不重叠 + 最大收益 = 加权区间调度;先排序,用二分找到前驱,再用 DP 在"选当前区间"和"不选当前区间"之间取最优。

而真正需要形成的条件反射是:

text 复制代码
看到题目
    ↓
去掉业务背景
    ↓
抽象成 [start, end, value]
    ↓
判断端点能不能相接
    ↓
排序
    ↓
二分 previous
    ↓
DP:选 / 不选

做到这里,这一类题基本就算真正掌握了。(LeetCode)

相关推荐
Navigator_Z1 小时前
LeetCode //C - 1248. Count Number of Nice Subarrays
c语言·算法·leetcode
wzdark2 小时前
多维数组在算法设计中的存储映射问题4
算法
Phil3233 小时前
多智能体不是越多越好:Google《Towards a Science of Scaling Agent Systems》论文深度解读
算法
6Hzlia3 小时前
【Classic 150 刷题计划】 LeetCode 26. 删除有序数组中的重复项 | C++ 快慢双指针经典模板
c++·算法·leetcode
huang5791473 小时前
基于滑动窗口的流式数据算法优化思路3
算法
Ulyanov4 小时前
AudioVision Pro:基于 PySide6 + sounddevice 的实时音频可视化播放器设计
python·算法·音视频
土司大王4 小时前
LeetCode hot100——74.搜索二维矩阵:Java 二分模板
java·算法·leetcode
tyler_download4 小时前
揉扁搓圆transformer架构:NAG优化器算法详解
深度学习·算法·transformer
weixin199701080164 小时前
[特殊字符]《京东POP二手品类对接:B2C订单模型 vs 二手C2C属性的字段转换难题》(附Python源码)
前端·python·算法