Algorithms
本文最后更新于:2022年11月27日 中午
一些算法思路, 持续更新。。。
理解一个算法没有任何意义
多练多写才是王道(应试角度)
排序
快速排序
基于分治思想,最难的是划分
- 确定分界点
- 调整区间
- 递归处理左右两边
有边界问题建议模板
模板
1 |
|
如果在递归函数中用i,注意x不可以取q[l],否则容易发生死循环
最开始的边界,如果是在最左侧或者最右侧,然后极端情况下,i与j没有交换过,在边界处相遇,此时下一次递归时,有一个区间会无限递归。比如取x为q[l],在最左侧,然后i与j都是0,递归时又选择了i,递归区间是[0, -1]和[0, n-1],无限递归。如果取x为q[r],在最右侧,i与j都是n-1,递归时选择了j,递归区间为[0, n-1]和[n, n-1],还是有无限递归。而且即使是x取q[(r+l)/2],也要注意向上向下取整的问题,不能取到某个边界。所以必须一左一右
归并排序
基本思想也是分治,最难的是归并
- 找分界点(中间)
- 递归
- 归并 —— 合二为一 O(n)
利用双指针算法,遇到相同的,可以把第一个数组的元素先放进去,因为归并一般是稳定的。快排也可以是稳定的,只要能保证所有元素都不同即可,那如何保证呢?可以把数组的下标也参与到排序中,构建一个二元组,即可保证不同。
时间复杂度:O(nlogn)
模板
1 |
|
二分
整数二分
本质:有单调性一定可以二分,但是二分的不一定都是单调性。二分的本质是边界。某个性质在左侧满足,右侧不满足,这种边界点就可以二分找到。
直接上边界,没有边界的只是特殊情况。
从左到右的最后一个:if(true) l=mid; else r=mid-1
,此时mid需要加上1
从右到左最后一个:if(true) r=mid; else l=mid+1
,mid不需要加1.
1 |
|
1 |
|
选择模板,看如何更新,根据更新结果去改mid,因为C++整除都是向下取整,所以如果更新了l,那么mid需要加1,如果更新的是r,mid就不变。为什么更新l要加1的例子:如果l=r-1,算出来mid等于l,如果又true了,那么l=mid,区间没变,死循环。
eg:数的二分,check时,不是单调或者大小,二是性质,先定性质,而不是大小关系(当然大小关系可以作为性质)。比如大于等于x或者小于x,再比如大于x和小于等于x,不同的性质就可二分。但同时也要注意,一个性质取反后并不一定正确,比如想找到小于等于x的最后一个数字,此时不可用大于x的第一个数字作为性质,会多一个。
浮点数二分
不需要处理边界,因此更加简单。当区间长度很小时,我们就可以认为找到了答案。区间长度往往比要求的精度再小两个数量级。
浮点数二分甚至可以直接循环100次。不再管精度,相当于把区间长度除以2的100次方。
高精度
大整数相加
模板:
1 |
|
前缀和&差分
前缀和
一维
1 |
|
如何求S[i]:for循环
作用:快速求出原数组中一段数字的和,基本也是唯一的应用
1 |
|
tips:S[0] = 0,好处:方便处理边界。不仅求区间和,也能求前缀和。
二维
求前缀和:二维矩阵。
s[i][j]
如何计算:
1 |
|
用前缀和,子矩阵内所有元素的和:
1 |
|
差分
前缀和逆运算
给定a数组,构造一个数组b,使得给定数组是该数组的前缀和。
差分主要应对的场景:对于给定A数组一段区间的元素,统一进行加或减的操作,将O(n)变为O(1)
【l,r】区间中加c,则令b[l] += c, b[r + 1] -= c
,从而达到修改原数组的效果。等到用原数组的时候再O(n)改回原数组。
差分没有构造的过程,可以认为a和b初始都是0,每一个初始值都可以认为是在[x, x]这个长度为1的区间加上c。也就是只有一个操作。
一维模板
构造:
1 |
|
改回:
(所有b依次加起来是对应位置的a)
a[i] = b[i] + a[i - 1];
二维模板
核心操作:以(x1, y1)为左上角,(x2,y2)为右下角的子矩阵的所有数同时加上C。
构造:
1 |
|
改回:(b是相邻元素的差,因此用所有b加起来是a,而且要分开,使得b元素更新,不然下一次用的还是旧的)
a[i][j] = a[i - 1][j] + a[i][j - 1] - a[i - 1][j - 1] + b[i][j];
双指针
一般两大类,一类单线双向奔赴(快速排序),一类双线单向(归并排序),还有单线单向(一般借助单调性)
所有双指针算法都是O(n) , 能够将n方优化到O(n)
模板:
1 |
|
一般都可以先从暴力开始,寻找是否有单调关系。单调性在于:当i向一个方向变化时,j是否只可能向一个方向变化,能够证明这点,基本就可以双指针。
位运算
常见操作:
- 求第k位:
n >> k & 1
- 返回x的最后一位1,
Lowbit(x) = x & (-x)
- lowbit的常见用法:求1的个数
为什么用补码:
计算机里面没有减法,我们是用加法做减法。
一个数的负数,应该满足的是x + (-x) = 0
,所以-x = 0 - x
,在用0减去x的时候我们需要在最前面借一位1,变成1000.000减去x,也就等价于x取反后再加1。
离散化
有些数据本身很大, 自身无法作为数组的下标保存对应的属性。如果这时只是需要这堆数据的相对属性, 那么可以对其进行离散化处理。当数据只与它们之间的相对大小有关,而与具体是多少无关时,可以进行离散化。
难点:
- 去重
- 如何算出离散化后的值(二分)
Essentially, we map the original number to a value which is the index of the discretization array. And the mapping rule is based on the order of the number.
离散化的本质是将数字本身key
映射为它在数组中的索引index
。所以通过二分求索引(value -> index)是离散化的本质。二分时,求的是数字离散化后将会在哪里,也就是下标。比如一个数组是[1, 5, 100, 2000],通过二分,左边界是0,右边界是大小,我们将数字映射成他在数组中的大小顺序,也就是1,2,3,4。
1 |
|
数据结构
每种数据结构都有自己的特点,优点或缺点,而我们使用数据结构一般都是看中了它能快速地支持某种操作。
链表
结构体加指针效率较低,尤其是new节点,一般十万个节点,平时笔试一般不会动态链表的方式,直接上数组。
数组模拟单链表(应用的较多的是邻接表(n个链表),邻接表最多的用处又是存储图和树,这样是用单链表做的)
模拟双链表,用来优化某些问题。
单链表
1 |
|
双链表
1 |
|
邻接表:把每个点的临边保存下来,就是n个单链表,可以用来保存图和树
栈
数组模拟比STL的好处:快
1 |
|
队列
1 |
|
单调栈
常见的只有一类题型
给定一个序列,找到每一个数的左边比他小的 离他最近的数(或者右边)
同样先尝试暴力,然后抽象
随着i
向右移动,j
向左移动,我们可以把i
左边所有的数字放到一个栈里,然后遍历这个栈,(从栈顶开始)。
那么这个栈有什么性质?或者说栈中是否有一些元素,他们永远不可能被输出?——如果x
与y
都在栈中,x在前,y在后,且y更小,那么x后续便不会被输出,所以栈中没有逆序对(x<y 且 a[x] > a[y]),也就是单调的了
每个元素只有一次进栈与出栈,因此复杂度是2n
1 |
|
单调队列
滑动窗口里面的最大值和最小值
先暴力,发现找最大值最小值是O(k)的复杂度,然后我们看看能不能转变成O(1)的复杂度(通过单调性,把没用的元素删掉),挖掘要点,转变
取最值的话是端点,找一个值可以二分
窗口是用队列去维护的,看看哪些元素是没用的
找最大值:
1 |
|
- 暴力算法怎么做
- 如何优化
Trie树
快速存储字符串集合的数据结构
Trie树一般字符不多,要么全大写要么全小写
模板:
1 |
|
并查集
思路精巧,代码比较短,很受面试官喜欢
用法
并查集用来:
- 将两个集合合并
- 询问两个元素是否在一个集合中
并查集能以近乎O(1)的复杂度中实现。
用树的形式维护集合,树根节点的编号就是集合的编号。每个节点存储他的父节点。p[x] 表示x的父节点。
使用操作
判断树根:
p[x] == x
求x的集合编号:
while(p[x] != x) x=p[x];
求x与y 是否在一个集合中:分别求编号
合并集合:把一个树根放到另一个树上即可
优化——路径压缩
求编号的优化(之前是与树的高度成正比,需要n的循环)
如何优化:一旦找到某条路径的根节点,我们就把这条路上所有的节点都指向根节点。这样每一个点,最多走n步,之后每次都是一步。
模板
1 |
|
加上当前集合有多少元素(附加操作)
1 |
|
加上距离根节点的距离信息
1 |
|
堆
堆是一个完全二叉树,使用数组存储
两个基本操作:down(x)
—— 往下调整, up(x)
—— 往上调整
背景:小根堆
如果把一个数字变大了,那么我们应该把数字向下移动——down操作
如果把一个数字变小了,那么我们应该把数字向上走——up操作
注意堆的下标从1开始,因为如果从0开始,但是0的左儿子还是0,冲突
应用
- 插入一个数 :
heap[++ size] = x; up(x);
- 求最小值 :
heap[1];
- 删除最小值 :
heap[1] = heap[size]; size--; down(1);
- 删除k处 :
heap[k] = heap[size]; size--; down(k); up(k);
- 修改任何一个元素 :
heap[k] = x; down(k); up(k);
可能变大变小,于是我们就都做一遍,注意这两个函数实际只会执行一个。 - (后两个STL中没有)
建立堆:一个一个插是nlogn,我们可以实现n。
1 |
|
实现
down
1 |
|
up
1 |
|
idx
不管是链表,Trie树还是堆,他们的基本单元都是一个个结点连接构成的,可以成为“链”式结构。这个结点包含两个基本的属性:本身的值和指向下一个结点的指针。按道理,应该按照结构体的方式来实现这些数据结构的,但是用数组模拟,主要是因为比较快。
原来这两个属性都是以结构体的方式联系在一起的,现在如果用数组模拟,如何才能把这两个属性联系起来呢,如何区分各个结点呢?
idx的操作总是++idx,这就保证了不同的idx值对应不同的结点,这样就可以利用idx把结构体内两个属性联系在一起了。因此,idx可以理解为结点。
对于单链表,利用idx联系结构体本身的值和next指针,因此e[idx]可以作为结点的值,ne[idx]可以作为next指针。同理可以理解双链表。
Trie树中有个二维数组 son[N] [26],表示当前结点的儿子,如果没有的话,可以等于++idx。Trie树本质上是一颗多叉树,对于字母而言最多有26个子结点。所以这个数组包含了两条信息。比如:son[1] [0]=2表示1结点的一个值为a的子结点为结点2;如果son[1] [0] = 0,则意味着没有值为a子结点。这里的son[N] [26]相当于链表中的ne[N]。
堆中的每次插入都是在堆尾,但是堆中经常有up和down操作。所以结点与结点的关系并不是用一个ne[idx] [2]可以很好地维护的。但是好在堆是个完全二叉树。子父节点的关系可以通过下标来联系(左儿子2n,右儿子2n+1)。就数组模拟来说,知道数组的下标就知道结点在堆中的位置。所以核心就在于即使有down和up操作也能维护堆数组的下标(k)和结点(idx)的映射关系。
hash
场景
map,将一个大的集合空间,映射到从0到n的一个较小区间内。
实现快速添加与查找某个数
但可能会出现冲突,根据处理冲突的不同,分为两种:开放寻址与拉链法
存储结构
开放寻址法
不断找下一个可以允许的地方,找到目标地址后,不断向后看
只开一个一维数组,但要原数组的两到三倍,一般能够把冲突降低到比较小
1 |
|
注:
设置一个常量用来代表“无穷大”。
比如对于int类型的数,采用INT_MAX,即0x7fffffff作为无穷大。但是以INT_MAX为无穷大,加一个正数会溢出。
所以常采用0x3f3f3f3f来作为无穷大。0x3f3f3f3f主要有如下好处:
- 0x3f3f3f3f的十进制为1061109567,和INT_MAX一个数量级,即10^9数量级,而一般场合下的数据都是小于10^9的。
- 0x3f3f3f3f * 2 = 2122219134,无穷大相加依然不会溢出。
- 可以使用memset(array, 0x3f, sizeof(array))来为数组设初值为0x3f3f3f3f,因为这个数的每个字节都是0x3f。
拉链法
第一次映射后得到的其实是链表,每个槽是一个链(单链表),期望链的长度是1
其实也就是邻接表,在存储图的时候也是用这个方法
1 |
|
字符串哈希
将字符串看作p进制数,转换成数字,但是数字可能比较大,再去取模
- 不能映射成0(A和AA一样)
- p=131或者13331,Q等于2的64次方,可以假定没有冲突。2的64次方,刚好是无符号long long的大小,所以我们会发现不用取模,直接计算。每次溢出的时候,就等价于模2的64次方
- 前缀哈希,可以获得子串哈希值
1 |
|
STL
公共的:size,empty,clear(队列无)
系统分配空间时,所需的时间与空间大小无关,与申请次数有关。因此vector采用倍增。不够的时候,再申请两倍,然后把之前的copy过来。如果申请n个元素,第一次copy1个,然后2个,4个,8个,最后到了n/2个,申请次数是logn,但copy次数均摊下来每个元素都近似O(1)
vector
front, back, push_back, pop_back
支持比较运算
pair
二元组,两种属性,且需要排序
相当于两个变量的结构体加上一个比较函数
string
- substr:begin and length
- c_str: char array
queue
push pop back front
priority_queue
push top pop
用堆实现,默认大根堆。如何小根堆
- 插入时插入-x,取出时再变成原来的数字
- 定义时:
priority_queue<int, vector<int>, greater<int>>
stack
push pop top
deque
front back push_back pop_back push_front pop_front []
set/multiset
insert find count erase
- erase : 参数为x:删除所有x;参数为迭代器,删一个
- lower_bound:返回大于等于x的最小的数
- upper_bound:返回大于x的最小的数
map
根据索引取值—— [ ] – 时间复杂度是logn
无序家族
基本都是O1,但不支持lower_bound,upper_bound(因为无序),++,–
DFS & BFS
DFS空间需要O(h) , 与树高度成正比。BFS空间则是2的h次方,但是BFS有最短路的概念,能够用于找最短的东西。
DFS
每个DFS都一定对应一个搜索树
关键是想清楚顺序,两个要点:回溯(恢复现场)和剪枝
全排列
1 |
|
八皇后
多种思路
如果分析出每一行只可能会有一个皇后,那么唯一的变数就是皇后在每一行的位置了(也就是列),所以可以使用全排列的思路做,大小为n的全排列(以行为单位枚举)
时间复杂度为n乘以n的阶乘
1 |
|
但也可以格子为单位去枚举,每一个格子都有放和不放两种可能
时间复杂度为2的n方次方
1 |
|
BFS
dp问题和最短路问题其实是互通的,dp是一种特殊的最短路问题(无环存在的最短路问题)
边权为1 时,BFS才能做最短路
1 |
|
Tree & Graph
树是一种特殊的图,无环连通图
图:有向图和无向图(无向图是特殊的有向图)
邻接矩阵储存(不常用)
邻接表
每个节点都是单链表
tip:输入超过1M的时候cin和scanf才有区别
树和图的深度优先遍历和宽度优先遍历,是一种特殊的深度优先搜索和宽度优先搜索。
深度优先搜索
1 |
|
广度优先
1 |
|
图宽搜的应用 - 拓扑排序
求有向图的拓扑排序
拓扑序列所有边都是从前指向后面的。因此有环图不可能有拓扑序列。
有向无环图 = 拓扑图
入度、出度。
因此所有入度为0的点都可以作为起点
1 |
|
最短路
不证明算法的正确性,考察问题抽象,使得原问题变成一个最短路问题,抽象边与顶点
图论侧重于实现,不在于原理
单源最短路
源:起点,汇:终点
n: 顶点数量,m:边数量
稠密图:m是n^2级别
稀疏图:m与n是一个级别
无负权
朴素Dijkstra算法
O(n^2), 与边无关,适应于稠密图(存储用邻接矩阵),边多
伪代码:
1 |
|
基于贪心思想
1 |
|
堆优化的Dijkstra算法
O(mlogn) ,适应于稀疏图(存储用邻接表),如果边多,变成O(n^2logn)
1 |
|
有负权
Bellman-Ford
贝尔曼-福特算法与迪科斯彻算法类似,都以松弛操作为基础,即估计的最短路径值渐渐地被更加准确的值替代,直至得到最优解。在两个算法中,计算时每个边之间的估计距离值都比真实值大,并且被新找到路径的最小长度替代。 然而,迪科斯彻算法以贪心法选取未被处理的具有最小权值的节点,然后对其的出边进行松弛操作;而贝尔曼-福特算法简单地对所有边进行松弛操作,共 |V| - 1次,其中 V 是图的点的数量。在重复地计算中,已计算得到正确的距离的边的数量不断增加,直到所有边都计算得到了正确的路径。这样的策略使得贝尔曼-福特算法比迪科斯彻算法适用于更多种类的输入。
循环
每次循环操作实际上是对相邻节点的访问,第 k 次循环操作保证了所有深度为 k 的路径最短。由于图的最短路径最长不会经过超过 V - 1条边,所以可知贝尔曼-福特算法所得为最短路径。
迪科斯彻算法的基本操作“拓展”是在深度上寻路,而“松弛”操作则是在广度上寻路,这就确定了贝尔曼-福特算法可以对负边进行操作而不会影响结果。
O(nm),可以用来求不超过k条边的最短路
1 |
|
n次迭代,每一次循环所有边,a, b, w
如果迭代n次,第n次还有更新,说明经过n条边,n条边意味着n+1个顶点,所以有环,但是由于条件是选更小的路径,说明有负环。(可以求负环,但不怎么用)
1 |
|
SPFA
SPFA:对上面优化,一般是O(m),最坏O(nm)
思路:一个边要想更新,那么这条路径上他前面的边必须先更新。换句话说,只有更新过的边才有资格去更新别人。因此可以用一个队列去保存更新过的边。
1 |
|
spfa判断负环
在寻找最短路过程中,记录一个cnt数组,只要在寻找过程中出现了大于等于n的路径,那么说明有n+1个点,说明有环,找的是最短的,所以是负环。
注意:此时初始化时不能只放起点,因为可能有负环但是某个点到不了,所以需要把所有的点放进去
1 |
|
多源汇最短路
任意两个点的最短路
Floyd算法——O(n^3) (动态规划)
1 |
|
d[k,i,j] : 从i点开始,经过1到k的中间点,到达j的最短距离
k是阶段,先枚举k,从k-1转移过来
1 |
|
从i到j经过1到k = 1到k-1的阶段,加上第k个点,就是从i到k,再从k到j
最小生成树
Prim
- 初始化距离最大值
- n次循环,每次找到集合外的到集合最近的点
t
,用t
更新其他点到集合的距离 - prim中的
dist[t]
,代表的是t这个点到集合的最短距离 - 对于重边,保留最小值,对于自环,不用管,因为最小生成树里面没有环,实际上只要我们把一个有自环的点放入进去,后续就不会再管到自环
- 与Dijkstra算法很像,不同点在于
dist[t]
的意义,D代表的是t这个点到起点的最短距离,P中代表的是到集合的最短距离 - 先累加再更新,否则自环对于权值计算有影响,一个有自环的点,是经过其他的边进入的集合,和负权自环无影响,但是如果更新了,那么这个点到集合的最短距离可能变成了自环上的负数,有影响。
1 |
|
Kruskal
- 将所有边按照权重从小到大排序 O(mlogm),快排,但是快排常数小
- 枚举每条边,如果两个点不在一个集合,加入到一个集合,更新res
- 由于该算法只关注边,不关注顶点,所以直接结构体存储即可,不需要邻接
二分图
染色法
- 判断一个图是不是二分图
- 一个图是二分图当且仅当图中不含有奇数环
匈牙利
数学知识
质数
质数判定——试除法
b | a表示b整除a,即a是b的倍数
循环除法找到,但是O(n),同时利用约数的性质,约数都是一对一对出现的,如果b | a, 那么 a/b | a,所以不需要枚举到a - 1,只需要枚举到a/b即可,变成logn
1 |
|
分解质因数——试除法
- 从小到大枚举所有因数(brust)——枚举所有数也可以,不需要仅枚举质数。因为当我们枚举到i的时候,n中已经不包含任何2到n-1的质因子了
- n中最多只包含一个大于根号n的质因子,因为如果有两个的话,相乘大于n,否定。因此只需要先枚举到根号n的地方,剩下的如果大于1,就是大于根号n的质因子,最后一个
1 |
|
筛选质数
如果循环再依次判断,复杂度较高
每当遍历到一个,直接将他所有的倍数去除,也就是for循环每次加自己即可
优化后能够达到O(n),只把质数的倍数去掉即可
1 |
|
约数
求约数
- 试除法,成对出现,与质数类似
1 |
|
约数个数
基于约数个数定理
知道质因数组成,则对a进行组合即可,每种组合对应一个约数
int范围内最多也就是1500多个约数
12 :1,2,3,4,6,12
12 = 2 * 2 * 3
动态规划
模型(如背包)、线性DP,区间DP,状态压缩DP
背包
- 01背包(一个物品用一次)
- 完全背包(没有限制)
- 多重背包(每种物品数量不一样,告诉有几个)
- 分组背包(n组,每一组里有若干种,每一组选一个)
01背包
状态表示
f[i][j]
,考虑需要几维来表示状态,每一个状态的含义是什么
- 状态的集合是什么
- 所有选法
- 只从前i个物品里面选
- 总体积 <= j
- 满足上面两个条件的所有选法的集合
f[i][j]
的数值就是状态的属性是什么(最大值,最小值,数量)
状态计算:
把每一个状态计算出来
- 对应集合的划分
- 分成更小的子集,使得子集都能计算出来
- 背包中,分为两大类:含i和不含i
- 划分原则:不重复、不漏掉
- 不含i的最大值:从1到i中选,总体积小于j,并且不包含i。合并这三个条件,就是从1到i-1,总体积小于j,选择的最大值,也就是
f[i][j - 1]
。注意一定存在 - 含i的最大值:从1到i中选,总体积小于j,并且我必须要包含第i个元素。直接求不好求,可以先把i去掉,同时体积也减去vi,因为这种条件下,反正都是要选择第i个物品的,那么我们把第i个物品的价值和体积先拿出来,最大的价值还是最大的,最后再加上第i个物品的价值。此时最大的情况是i-1 和 j - vi,再统一加上第i个物品的价值,也就是
f[i-1][j-vi] + wi
。注意不一定存在,如果当前物品体积大于背包目前最大容量的时候,那么就不存在了。 - 但是要注意,含i的情况,可能不存在,就是指现在背包的容量已经装不下第i个物品了
优化:一般都是对dp方程作等价变形,先做朴素的
状态的集合,i和j就变成了元素的属性
dp问题是没有模板的,具体问题具体分析。
伪代码
1 |
|
一维优化
1 |
|
完全背包
状态表示与上面一样
状态计算
划分集合,将总的划分成 第i个物品选择0-k个,这些情况
对于每一种情况,再去分别考虑:
- 第i个物品选了0个,对应01背包里面不选择的情况
f[i][j] = f[i - 1][j]
- 第i个物品选了1个,对应01背包里面选择的情况:
f[i][j] = w[i] + f[i - 1][j - w[i]]
- 第i个物品选了2个,仍然采取曲线的方式,先把这俩放进去,再考虑后面的最大值:
f[i][j] = w[i] * 2 + f[i - 1][j - v[i] * 2]
- 第i个物品选了k个:
f[i][j] = k * w[i] + f[i - 1][j - k * v[i]]
伪代码
1 |
|
可能超时
优化版
我们发现:
f[i][j] = max(f[i-1][j], f[i-1][j-v]+w, f[i-1][j-2v]+2w, f[i-1][j-3v]+3w)....
f[i][j-v] = max(f[i-1][j-v], f[i-1][j-2v]+w, f[i-1][j-3v]+2w), f[i-1][j-4v]+3w....)
由上式可得:
f[i][j] = max(f[i - 1][j], f[i][j-v] + w)
又变成了两种情况:不拿第i个,变成了f[i-1][j]
,如果拿第i个的话,由于是无限个,所以其实还是考虑前i个物品,只不过此时体积变了。整体变成了f[i][j-v]+w
1 |
|
注意:上面的伪代码有个问题,如果不拿的话,肯定还是能更新f[i][j]
的,但是如果把j初始设置为最小体积,会导致不拿时候更新出错,也就是不再更新。还是尽量分开讨论,先把能更新的更新掉,之后再看体积问题。
1 |
|
一维优化
将二维变成一维
1 |
|
tips
总结:
- 01背包:用第i-1层更新第i层
- 完全背包:用第i层更新第i层
多重背包
状态表示与01背包相同
状态计算
与完全背包其实类似,只不过完全背包的k,变成了每种物品的数量
1 |
|
由于有个数限制,因此无法像完全背包那样去优化
二进制优化
原理复杂
某物品最多有s件,我们需要从所有的物品中选择若干件,使这个背包的价值最大。没有说某物品一定需要选多少件出来,也没有说一共要选多少件出来。只是选择若干件,至于选几件,无所谓,但要保证价值最大。
如果某物品最多k件,那么像上面我们还需要做k次循环,但是我们果真需要完全遍历一遍吗?实则不然。因为从1-k中的k个数,我们只需要用logk个数就可以表示出来。(如同任何一个数都可以用2的多少次幂相加得到)
所以做法是:某物品有s件,我们把它分成了好几个大的物品。第一个大物品包含1件该物品,第二个大物品是包含2件该物品,第三个大物品是包含4件该物品,第四个大物品是包含8件该物品,…..依次类推。
对所有种类的物品都这么表示出来,那么对于初始每个物品最多s件,我们就把这个件数条件给消去了,然后我们只需要从新的物品中中任意选择(每种物品选或不选),就可以得到原来问题的每种物品选几件的目的。
从而变成01背包问题
1 |
|
分组背包
分成几组,每组中只能选一个
f[i][j]
本身表示从前i组物品里面选,总体积不超过j的最大价值
而状态计算的时候,则是将集合划分,将f[i][j]
的集合划分出来,枚举的是第i组物品选哪一个。
也就是说f[i][j]
,可以由这些情况得来:第i组物品不选,第i组物品选择第一个,,,,
1 |
|
线性DP
递推方程有一个明显的线性关系。
多维状态,求的时候有一个明显的顺序
数字三角形
状态表示:对三角形分行列,易知维数。
- 集合
f[i][j]
表示的是什么?——所有从起点开始,走到(i, j)这个点的路径。 - 属性:上述路径和的最大值
- 集合
状态计算:集合划分:来自左上方的和来自右上方的
tips:当计算中涉及到了f[i - 1]
这种情况的时候,一般让i从1开始,不用处理边界
时间复杂度:状态的数量 * 转移一次的数量
f[i][j] = a[i][j] + max(f[i - 1][j - 1], f[i - 1][j]);
注意:初始化时候,需要多初始化一列,因为最右边的一列需要用到更右边的一列,尤其是有负数的时候。
最长递增子序列
状态表示:一维即可
- 意义:以第i个数结尾的上升子序列的集合
- 属性:上述最大长度
状态计算
- 最后一个数已经以i结尾了,所以划分区间时需要考虑第i - 1个数
- 第i - 1个数是几,如果第i - 1个数是 j 的话, 那么f[i] = f[j] + 1
1 |
|
最长递增子序列II
上述的算法时间复杂度为O(n^2),能否继续优化?
对于序列3 1 2 5
来说,先看序列长度为1的子序列,3和1,但是我们发现后面的数,如果能够接在3后面,那么也一定能够接在1后面,并且1更小,情况更好。所以3就没必要存下来了。
求到第i个数,前面的所有序列可以按照长度分类。长度为n的上升子序列,只需要存储一个结尾最小的子序列。
因此可以存储前面的每种长度的上升子序列的结尾的最小值。且随着长度的增加,其结尾的值也递增。
在这种情况下,如果想求一下以a[i]结尾的最长上升子序列,只需要在上面的数组中,找到最后一个小于a[i]的数,然后+1即可。而找到小于ai的最大值,只需要二分即可。再更新q[i + 1]即可
1 |
|
最长公共子序列
- 状态表示:由于两个序列,需要二维(经验 )
f[i][j]
意义:所有在第一个序列的前i个字母中出现,且在第二个序列的前j个字母中出现的公共子序列- 属性:上述最大长度
- 状态计算:
- 选不选a[i]或者b[j],四个子集合
- a[i]和b[j]都不选:
f[i - 1][j - 1]
- a[i]和b[j]都选:
f[i - 1][j - 1] + 1
- a[i]不选,b[j]选,所以a[i]不出现在子序列当中,但是b[j]一定出现在公共子序列当中的情况,注意这个集合不等于
f[i - 1][j]
,f[i - 1][j]
指的是在第一个序列的前i - 1个字母中出现,且在第二个序列的前j个字母中出现的公共子序列最大值,也就是说f[i - 1][j]
其实可能不会选j,是包含了前面我们想要的情况的。但是我们注意到,后者完全包含前者,而我们要求的是最长公共子序列长度,后者又被包含在f[i][j]
中,即从集合个数上来讲,前者小于后者。当我们想在所有情况里选出最大值时,某元素可能会重叠出现在多个情况中,但是对于求最大值来说,是没有影响的。因此是可以使用后者等价替代前者的 - a[i]选,b[j]不选同上
- 同时我们又注意到第一种情况被后面的情况包含,因此可以忽略
dp[i][j] = max(dp[i - 1][j - 1], max(dp[i - 1][j], dp[i][j - 1]));
最短编辑距离
- 状态表示:
- 意义:
f[i][j]
: 所有将a[1 - i]变成b[1 - j]的方式的集合 - 属性:操作的最小值
- 意义:
- 状态计算:
区间dp
合并石头
- 状态表示:二维
- 集合:将第i堆石头到第j堆石头合并为一起的合并方式
- 属性:最小
- 状态计算:
- 逻辑上划分的第一步(或者实际操作的最后一步)一定是将最后剩下的两堆合并在一块,因此状态划分可以以最后一次分界线为依据去划分
- 最后一步的最小代价,就是先合并左边的最小代价,加上右边的最小代价,最后再加上这两堆的总重量(i到j的重量和——前缀和)
- 区间dp需要注意一点:每次计算
f[i][j]
时,要确保他的所有子情况都已经准备好了,而最小的子情况是长度为1的情况。所以我们需要先循环区间的长度,再循环起点,就可以达到长度由小到大了
1 |
|
计数DP
整数划分
正整数 n 可以表示成若干个正整数之和,n 共有多少种不同的划分方法
完全背包思路
将n看作为容量为n的背包,每个数字都是一个物品,要刚好装满背包。由于每个数字可以用多次,所以是完全背包问题。
- 状态表示:二维
- 集合:考虑选前i个数字,刚好和为j的集合
- 属性:集合大小
- 状态计算:
- 用完全背包的思路,
dp[i][j] = dp[i - 1][j] + dp[i][j - i]
- 用完全背包的思路,
another idea
- 状态表示:二维
f[i][j]
- 集合:考虑总和为i,且使用了j个数字的集合(
j<=i
) - 属性:集合大小
- 集合:考虑总和为i,且使用了j个数字的集合(
- 状态计算:
- 分为两大类:j个数字中至少有一个数字1的,以及j个数字里一个数字1都没有的
- 对于至少有一个1的,我们可以先把这个1去了,对应的集合为
f[i - 1][j - 1]
,因为去掉一个1后,相当于总和变为i - 1,同时数字也少了一个 - 对于没有数字1的,说明所有数字都是大于1的,这时我们把所有数字都减去1,还是一个集合,对应
f[i - j][j]
- 最后的结尾需要把总和为n的不同组合数加起来
- 对于至少有一个1的,我们可以先把这个1去了,对应的集合为
- 分为两大类:j个数字中至少有一个数字1的,以及j个数字里一个数字1都没有的