【算法】动态规划—区间DP问题
一、定义
区间DP是动态规划的一种特殊形式,主要用于解决涉及区间操作的问题,比如合并区间、分割区间、区间匹配等。
其核心思想是通过枚举区间的划分点,将大区间的问题分解为小区间的子问题,逐步求解并保存中间结果。
二、基本特征
问题定义:通常涉及一个序列或区间,如数组、字符串等。
状态表示:一般用
dp[i][j]
表示区间[i, j]
的最优解。转移方程:通过枚举区间内的某个分割点
k
,将[i, j]
拆分为[i, k]
和[k+1, j]
,然后合并子问题的解。初始化:通常
dp[i][i]
表示单个元素的最优解(如dp[i][i] = 0
或dp[i][i] = cost[i]
)。遍历顺序:一般采用区间长度从小到大的顺序计算,确保子问题先被求解。
三、区间DP模板
for (int len = 1; len <= n; len++) { // 枚举区间长度 for (int i = 0; i + len - 1 < n; i++) { // 枚举起点i int j = i + len - 1; // 计算终点j if (len == 1) { // 初始化 dp[i][j] = ...; continue; } for (int k = i; k < j; k++) { // 枚举分割点k dp[i][j] = min/max(dp[i][j], dp[i][k] + dp[k+1][j] + cost); } } }
四、例题:石子合并
问题描述:有 n
堆石子排成一排,每次只能合并相邻的两堆,合并的代价是两堆石子的总数。求将所有石子合并成一堆的最小总代价。
输入: stones = [3, 1, 2, 4] 输出: 20 解释: 1. 合并 [1, 2] → 代价=3, 剩余 [3, 3, 4] 2. 合并 [3, 3] → 代价=6, 剩余 [6, 4] 3. 合并 [6, 4] → 代价=10, 总代价=3+6+10=19
(1) 状态定义
dp[i][j]
:表示合并区间[i, j]
的所有石子堆的最小总代价。初始化:
如果
i == j
(即区间长度为1),dp[i][j] = 0
(因为不需要合并)。否则
dp[i][j] = +∞
(初始化为极大值,方便后续取最小值)。
(2) 状态转移方程
对于区间
[i, j]
,枚举所有可能的分割点k
(i ≤ k < j
),将其分成两个子区间:[i, k]
和[k+1, j]
。合并这两个子区间的总代价是:
dp[i][k]
(合并[i, k]
的代价)dp[k+1][j]
(合并[k+1, j]
的代价)sum(i, j)
(当前合并的代价,即区间[i, j]
的石子总重量)。因此,状态转移方程为:
(3) 计算顺序
按区间长度从小到大计算:
先计算所有长度为2的区间,再计算长度为3的区间,依此类推,直到计算整个区间
[0, n-1]
。前缀和优化:
为了快速计算
sum(i, j)
,可以预先计算前缀和数组prefix
:
【算法】归并排序
一、基本思想
归并排序的核心思想是将两个已经有序的子序列合并成一个有序序列。整个过程分为两个主要步骤:
1.分解:将待排序的序列不断二分,直到每个子序列只包含一个元素(此时自然有序)
2.合并:将两个有序的子序列合并为一个有序序列
注意1:若将两个有序表合并成一个有序表,称为2-路归并。
注意2:归并排序则类似于二叉树的后序遍历:它会持续划分区间,直到区间内元素有序,然后利用额外空间对元素进行排序,并将它们合并回原区间,直至整个序列有序。这个过程中,划分区间相当于达到树的最底层,而归并排序则相当于从树的底层开始向上遍历。
二、算法步骤
1.分解阶段:
将长度为n的序列分成两个长度为n/2的子序列
递归地对这两个子序列进行归并排序
直到子序列长度为1(此时已经有序)
2.合并阶段:
申请空间,大小为两个已排序子序列之和
设定两个指针,分别指向两个子序列的起始位置
比较两个指针所指的元素,选择较小的放入合并空间,并移动指针到下一位置
重复上一步直到某一指针超出子序列范围
将另一子序列的剩余元素直接复制到合并序列的末尾
动图如下:
三、时间复杂度
最优情况:O(n log n)
最坏情况:O(n log n)
平均情况:O(n log n)
归并排序的时间复杂度始终是O(n log n),因为它总是将序列分成两半,需要进行log₂n次分解,每次合并操作的时间复杂度为O(n)。
四、空间复杂度
归并排序需要额外的空间来存储临时合并的数组,因此其空间复杂度为O(n)。
五、稳定性
归并排序是稳定的排序算法,因为在合并过程中,当两个元素相等时,可以保证左边的元素先被放入结果数组中。
六、优缺点
优点:
时间复杂度稳定为O(n log n)
适用于大规模数据排序
稳定排序算法
缺点:
需要额外的存储空间(非原地排序)
对于小规模数据排序效率可能不如简单排序算法
【算法】博弈论——取石子游戏
【题目描述】
有两堆石子,数量任意,可以不同。游戏开始由两个人轮流取石子。游戏规定,每次有两种不同的取法,一是可以在任意的一堆中取走任意多的石子;二是可以在两堆中同时取走相同数量的石子。最后把石子全部取完者为胜者。现在给出初始的两堆石子的数目,如果轮到你先取,假设双方都采取最好的策略,问最后你是胜者还是败者。
【输入描述】
输入包含若干行,表示若干种石子的初始情况,其中每一行包含两个非负整数a和b,表示两堆石子的数目,a和b都不大于1,000,000,000。
【输出描述】
输出对应也有若干行,每行包含一个数字1或0,如果最后你是胜者,则为1,反之,则为0。
【样例输入】
2 1
【样例输出】
0
【算法】单调栈
一、单调栈
单调栈(Monotonic Stack)是一种特殊的栈结构,其核心思想是维护栈内元素的单调性(单调递增或单调递减)。单调栈通常用于解决与元素大小关系相关的问题,例如:
找到数组中每个元素的下一个更大(或更小)元素。
计算数组中每个元素左边或右边的第一个更大(或更小)元素。
解决与区间最值相关的问题(如最大矩形面积)。
二、特点
单调性:栈内元素始终保持单调递增或单调递减。
操作规则:
如果新元素满足单调性,直接压入栈。
时间复杂度:由于每个元素最多入栈和出栈一次,因此时间复杂度通常为O(n)
如果新元素破坏了单调性,则弹出栈顶元素,直到满足单调性为止。
三、应用场景
下一个更大元素:
给定一个数组,找到每个元素的下一个更大元素。每日温度:
给定一个温度数组,计算每天需要等待多少天才能遇到更高的温度。最大矩形面积:
给定一个柱状图,计算其中最大的矩形面积。滑动窗口最大值:
在滑动窗口中快速找到最大值。
四、步骤
以找到数组中每个元素的下一个更大元素为例:
初始化:创建一个空栈和一个结果数组。
遍历数组:
如果当前元素大于栈顶元素,则栈顶元素的下一个更大元素就是当前元素。
弹出栈顶元素,并更新结果数组。
重复上述过程,直到当前元素小于等于栈顶元素或栈为空。
将当前元素压入栈。
处理剩余元素:遍历结束后,栈中剩余的元素没有下一个更大元素,将其结果设置为
-1
。
【算法】滑动窗口1—窗口大小固定
一、定义
滑动窗口算法(Sliding Window Algorithm)是一种用于解决数组或字符串中子数组或子串问题的优化技术。它通过维护一个窗口(通常是数组或字符串的一个连续子区间),在遍历过程中动态调整窗口的起始和结束位置,从而高效地解决问题。
滑动窗口的核心思想是:
窗口大小固定:窗口的大小在滑动过程中保持不变。
窗口大小可变:窗口的大小根据条件动态调整。
二、使用场景
滑动窗口算法通常用于以下场景:
1.子数组/子串问题:
求满足条件的最大或最小子数组/子串。
例如:最大子数组和、最小覆盖子串、最长无重复字符子串等。
2.固定窗口大小问题:
例如:计算大小为
k
的子数组的平均值。3.优化暴力解法:
将时间复杂度从
O(n²)
优化到O(n)
。
三、示例
1.例题1:固定窗口大小
【题目描述】给定一个数组和一个整数 k
,计算所有大小为 k
的子数组的平均值。
【题目分析】
(1) 什么是子数组?
子数组是数组中连续的一段元素。
例如,数组
[1, 3, 2, 6, -1]
的子数组包括[1, 3, 2]
、[3, 2, 6]
等。
(2)什么是大小为 k
的子数组?
大小为
k
的子数组是指长度为k
的连续子数组。例如,如果
k = 3
,那么子数组的长度必须是 3。
(3)什么是子数组的平均值?
子数组的平均值是指子数组中所有元素的和除以子数组的长度。
例如,子数组
[1, 3, 2]
的平均值是(1 + 3 + 2) / 3 = 2
。
(4)问题的目标
对于数组中的每一个大小为
k
的子数组,计算其平均值,并返回所有平均值的集合。
【解题思路】
(1)暴力解法
遍历数组,对于每一个起始位置
i
,计算从i
到i + k - 1
的子数组的和,然后除以k
得到平均值。时间复杂度:
O(n * k)
,其中n
是数组的长度。
(2)滑动窗口优化
使用滑动窗口技术,避免重复计算子数组的和。
初始化第一个窗口的和,然后在滑动窗口时,加上新元素,减去旧元素。
时间复杂度:
O(n)
。
#include <iostream> using namespace std; void sw(int nums[], int n, int k) { if (n < k) return; // 如果数组长度小于k,直接返回 double windowSum = 0; // 初始化窗口 for (int i = 0; i < k; i++) { windowSum += nums[i]; } cout << windowSum / k << " "; // 滑动窗口 for (int i = k; i < n; i++) { windowSum += nums[i] - nums[i - k]; // 加上新元素,减去旧元素 cout << windowSum / k << " "; } cout << endl; } int main() { int nums[] = {1, 3, 2, 6, -1, 4, 1, 8, 2}; int n = sizeof(nums) / sizeof(nums[0]); int k = 5; sw(nums, n, k); return 0; }
【过程解释】
初始化窗口:
计算第一个窗口的和
windowSum
,即nums[0]
到nums[k-1]
的和。输出第一个窗口的平均值
windowSum / k
。滑动窗口:
从第
k
个元素开始,滑动窗口。每次滑动时,加上新元素
nums[i]
,减去旧元素nums[i - k]
。输出当前窗口的平均值
windowSum / k
。
图示:
第一次:(1+3+2)/3
第二次:(3+2+6)/3 ,对比上一次,少了第一个数1,多了后一个数6
第二次:(2+6-1)/3 ,对比上一次,少了上一个数2,多了后一个数-1
【算法】前缀和与差分(2)一 一维数组差分
一、差分:一维数组的差分可看作是一维数组前缀和的逆运算。
二、差分数组
首先给定一个原数组a: a[1]、a[2]、a[3]、......
然后构造一个数组b: b[1]、b[2]、b[3]....
使得a[i]=b[1]+b[2]+b[3]+b[4]+.....b[i]。
那么根据上一节讲的,a数组就是b数组的前缀和数组。
也就是说,a数组就是b数组的前缀和数组,反过来,我们把b数组叫做a数组的差分数组。话句话说,每一个a[i]数组都是b数组中从头开始的一段区间和。
三、功能及作用
给区间[L,R]中每个数加上 c: B[L] +=c, B[R+1] -=c
四、构造
考虑如何构造差分数组b:
最为直接的方法:
a[0]=0 b[1]=a[1]-a[0]; b[2]=a[2]-a[1]; b[3]=a[3]-a[2]; ...... b[n]=a[n]-a[n-1];
五、应用
【问题描述】给定区间[L,R],让我们把a数组中的[L,R]区间中的每一个数都加上c, 即a[L]+c, a[L+1]+c, a[L+2]=c , .... a[R]+c
解法一:暴力解法
用for循环,从L到R区间,时间复杂度O(n),如果我们需要对原数组执行m次这样的操作,那么时间复杂度就会变成O(n*m)。
解法二:差分
始终要记住一点:a数组是b数组的前缀和数组。比如对b数组的b[i]的修改,会影响到a数组中从a[i]及往后的每一个数。
首先让差分b数组中的b[L]+c,通过前缀和运算,a数组变成a[L]+c,... a[L+1]+c,.... a[n]+c
然后我们打个补丁, b[R+1] -c ,通过前缀和运算, a数组变成 a[R+1]-c, ... a[R+2]-c, ... a[n] -c, ...
图解过程:
b[L]+c, 效果使得a数组中a[L]及以后的数都加上了c(红色部分),但是我们只要求L到R区间加上c,因此还需要执行b[R+1]-c, 让a数组中a[R+1]以及往后的区间再减去c(绿色部分),这样对于a[r]以后区间的数组相当于没有发生改变。
结论:给a数组中的[L,R]区间中的每一个数加上c。只需要对差分数组b做b[L]+=c,b[R+]-=c,时间复杂度为O(1)。
如果上面的描述不够清楚,我们可以借助下面方式来表示,我们假设a数组是原始数组,b数组是差分数组。我们的目的是让a数组的某个区间段加上一个数c。初始状态如下:
区间端点 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
原始数组a[i] | 0 | 3 | 5 | 4 | 8 | 9 | 7 |
差分数组b[i] | 3 | 2 | -1 | 4 | 1 | -2 |
需求1:我们要将[1,4]范围内所有的数都加上3
区间端点 | 0 | 1 | 2 | 3 | 4 | 5 | 6 |
原始数组a[i] | 0 | 3+3=6 | 5+3=8 | 4+3=7 | 8+3=11 | 9 | 7 |
差分数组b[i] | 3+3=6 | 2不变 | -1不变 | 4不变 | 1-3=-2 | -2 |
规律:当对一个区间进行增减某个值的时候,他的差分数组对应的区间左端点的值会同步变化,而他的右端点的后一个值则会相反地变化。
那么用代码表示:
while(t--){//操作次数 cin>>x>>y>>change;//左右端点值 cin>>c;//c是每次加减的值 b[x]=b[x]+c; b[y+1]=b[y+1]-c; }
那么能不能反过来求a[i]呢,因为b数组是差分数组,满足公式b[i]=a[i]-a[i-1]
那么a[i]=a[i-1]+b[i]
六、题目
【题目描述】
输入一个长度为n的整数序列。
接下来输入m个操作,每个操作包含三个整数l, r, c,表示将序列中[l, r]之间的每个数加上c。
请你输出进行完所有操作后的序列。
【输入描述】
第一行包含两个整数n和m。
第二行包含n个整数,表示整数序列。
接下来m行,每行包含三个整数l,r,c,表示一个操作。
【输出描述】
共一行,包含n个整数,表示最终序列。
【样例输入】
6 3 1 2 2 1 2 1 1 3 1 3 5 1 1 6 1
【样例输出】
3 4 5 3 4 2
【数据范围】
1≤n,m≤100000,
1≤l≤r≤n,
−1000≤c≤1000,
−1000≤整数序列中元素的值≤1000