什么是IM

IM:即时通信 IM(Instant Messaging),即时通信技术归根结底还是网络编程技术的应用。

很多团队可以直接选择“短平快”的云IM接入APP中,但是,考虑到云IM无论是从商业模式还是运营模式上,都还需要多年的沉淀,才可能真正实现客户和运营商的运营和服务良性循环的双赢局面。

核心功能:

  1. 单聊/群聊/聊天室/多设备登陆/在线状态
  2. 文本消息/多媒体消息/离线同步/历史消息/消息漫游
  3. 多端同步/消息撤回/已读未读/离线推送
  4. 添加好友/会话列表/好友列表

假定指标:

  • DAU 5亿+,每人每天100条消息,收发消息p99 200ms以下,QPS平均50w,峰值75w
  • 每条消息10KB,则每日存储增长量约 450TB $(100*10*5*10^8KB)$

业务划分:

  • 自定义应用层协议(提升性能,解决粘包)
  • 长连接网关(提升性能,替代短连接,可push)
  • 心跳/重连/弱网
  • 分布式缓存
  • 分布式存储
  • 领域驱动设计DDD

数字三角形模型

摘花生】:

状态表示:f[i][j]:从左上角到当前点的最大价值

状态属性:max

1
>f[i][j] = max(f[i-1][j], f[i][j-1]) + q[i];

最低通行费

状态表示:f[i][j]:从左上角到当前点的最小花费

状态属性:min

1
>f[i][j] = max(f[i-1][j], f[i][j-1]) + q[i];

方格取数

状态表示:f[i1][j1][i2][j2]:第一个点在[i1, j2], 第二个点在[i2, j2]的最大价值,并保证两点行走的路径一样长

状态属性:max

1
2
3
4
5
>int t = w[i1][j2];
>if(i1 !=i2 || j1 != j2) t += w[i2][j2]//当前两点重合,则只取一次
>for(int c = 0; c <= 1; c ++)
for(int r = 0; r <= 1; r ++)
f[i1][j2][i1][j2] = max(f[i1][j1][i2][j2], f[i1-c][j2-r][i1-c][j2-r] + t);

f[i1][j1][i2][j2]可以优化为f[k][i1][i2],因为横纵之和一定,可以用k-i1 == j1

传纸条

状态表示:f[i1][j1][i2][j2]:第一个点在[i1, j2], 第二个点在[i2, j2]的最大价值,并保证两点行走的路径一样长

状态属性:max

1
2
3
4
5
>int t = w[i1][j2];
>if(i1 !=i2 || j1 != j2) t += w[i2][j2]//当前两点重合,则只取一次
>for(int c = 0; c <= 1; c ++)
for(int r = 0; r <= 1; r ++)
f[i1][j2][i1][j2] = max(f[i1][j1][i2][j2], f[i1-c][j2-r][i1-c][j2-r] + t);

f[i1][j1][i2][j2]可以优化为f[k][i1][i2],因为横纵之和一定,可以用k-i1 == j1

证明:为什么可以使用【方格取数】一样的状态转移?

  1. 假设两条路径相交:则可以左下侧和有上侧的两条不相交的路径(可能有中途相遇点)
  2. 假设有中途相遇点:一定可以通过某条路径的相交点往左下或右上挪动一个位置
  3. 为什么挪动一个位置不影响最后值:我们状态属性是max,所有挪动一个位置得到的价值最会变大,不会变小
  4. 是否需要特判中途相遇点:不用,因为即使前面更新了中途相遇点,后面永远也不会使用这个点(可以绕得到大于或等于的值)

最长上升子序列模型

朴素:暴力

优化:二分,将当前放入在数组中的合适的位置

怪盗基德的滑翔翼

求先上升再下降的最大长度

转换为某个点作为顶点时的山体最大宽度,两遍lis即可,需要两个数组记录,然后相加取max

登山

求先上升再下降的最大长度

转换为某个点作为顶点时的山体最大宽度,两遍lcs即可,需要两个数组记录,然后相加取max

要求严格上升和严格下降

合唱队形

求先上升再下降的最大长度

转换为某个点作为顶点时的山体最大宽度,两遍lcs即可,需要两个数组记录,然后相加取max

要求严格上升和严格下降

友好城市

转换为互相匹配的两边同增的最大长度

固定一边,求另一边lis即可

证明:为什么按照任意一遍排序都可?

假设左排序右排序得到的结果大,那么必然存在更多的互相匹配同增最大长度,那么按照右排序也一定能找到这个组合

最大上升子序列和

上升子序列的和,这里不一定时最长的。

状态表示:f[i]:以i点结尾的前i个数组成的序列最大和

状态属性:max

状态转移:枚举前面所有小于(原数组权值)i点,取最大的一个转移过来即可(n^2)

优化:

我们在找前i个点小于当前点权值同时累计和最大的点的时候,可以通过维护一个树状数组来优化,思路如下:

  1. 值域较小,100000,利用下标作为单点权值, tree数组没问题(存放max)
  2. 当前树状数组不再是维护前缀和,而是维护前缀最大数
  3. 假设当前权值为w[i],只需要找前w[i]-1个数中的最大数即可
  4. 每次需要更新最大值,直接add即可,在add函数里是取max,所以即使传递了一个小的数,也不影响

拦截导弹

最小个下降子序列个数,并集等价于原数组

维护每一个下降子序列的最小值,顺序遍历原数组,若有大于q[i],则用q[i]更新大于q[i]的最小数,若没有大于q[i],则新开一个下降子序列

证明:为什么这样是可行的?

  1. 每个数都要在某个下降子序列中,与其新开一个,不如加在别人后面(末端最小值是一样的,对后面的影响相同,这样可以少开一个)
  2. 如果能加在别人后面,要加在大于q[i]的最小值后面,更大的末端值能容得下更大的后来的值,这是贪心做法

导弹防御系统

若干个上升子序列下降子序列,并集等价于原数组

y总说没有更好的办法了,直接爆搜(数据范围:50)

直接爆搜会t,需要加入一些剪枝,设上升子序列个数为sc,下降子序列个数为sd,记最优解ans = n(每个数一个序列),当sc+sd>ans则不合理,同时搜到合理方案时及时更新ans,理论上收敛在(1 << 25)以内

最长公共上升子序列

状态表示:f[i][j]:所有a[1~i]b[1~j]中以b[j]结尾的max

状态属性:max

状态转移:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
>f[i][j] = f[i-1][j];//不取最后一组数据
>if(a[i] == b[j]){//最后一组数据相等,可以通过f[i-1][1~j-1]中q[j] < a[i]的点转移而来
//这里是一个循环枚举
for(int k = 1; k < j; k ++)
if(b[j](等价于a[i]) > b[k]) f[i][j] = max(f[i][j], f[i-1][k]+1);
>}
>/************/上述做法为三方小常,所有可以卡过,下面给出优化
>for(int i = 1; i <= n; i ++){
int maxv = 1;//维护的是i恒定,j动态情况下的 [i-1][1~j-1]中q[j] < a[i]的点 的最大值
for(int j = 1; j <= n; j ++){
f[i][j] = f[i-1][j];
if(a[i] == b[j]) f[i][j] = max(f[i][j], maxv);
if(a[i] > b[j]) maxv = max(maxv, f[i-1][j] + 1);//这一步等价于上个做法中循环k哪一行的用法
}
>}

证明:在后面使用的时候,为什么可以使用maxv?

因为我们需要用maxv去更新别人的时候,一定有a[i] == b[j]

所以前面那么多maxv取max的操作的控制条件a[i] > b[k]中其实就等价与b[j] > b[k],从而保证是单调递增的,

我们要找的就是前面小于b[j]的所有b[k]中的最大值,

但是更新条件是a[i] == b[j],所以直接使用a[i]当作b[j]把后面真实的b[j]要用到的数据先处理出来,

这其实就是对原来代码的等价变形。

采药

01背包

体积:时间

价值:草药价值

装箱问题

01背包

体积:体积

价值:体积

比较特殊,需要求剩余的最小体积,等价与求我使用所有体积,能达到的最大价值,刚好这个体积就是价值

宠物小精灵之收服

二维费用背包

体积:球数量

重量:体力

价值:怪物个数

最后暴力遍历一下使用所有精灵球,得到的最大收服数量,并且剩余体力最多

这里有一点:我们在打斗的过程中,体力值不能为0,所以假设我们要击败30的体力

我们就会将这个状态更新在体力为31的状态,并且一定在31这个状态里面

所以最后遍历一遍找到即可

怎么理解这个“一定”?

解释:因为30这个状态被更新的时候,当体力值上限大于30的时候,题目有解,故一定会更新在31状态里面,当体力值上限小于等于30,则无解,则不会更新,最后找解也不会找到30这个状态

数字组合

01背包

f[i]表示组成i这个数字的方案数,故可以使用类似01背包问题的方式来写

两重循环,第二层倒着遍历

买书

完全背包

f[i]表示花费n元(全部花完)的所有方案数

这里需要初始化f[0] = 0

第一维枚举四本书

第二维完全背包即可(值域有上限)

拓展:

如果题面要求花费n元的所有方案数(不要求全部花完),严格怎么计算?

思路历程:首先想到的是容斥,状态表示不变,但是在完全背包的时候考虑累加而不是取max,不可行,因为会有重复的方案。

最后发现,在状态表示不变的情况下,累加最后的f[1~n]即可

货币系统1

完全背包

f[i]表示组成面值m的方案数(一定要达到m)

这里需要初始化f[0] = 0

第一维枚举所有货币

第二位完全背包即可(值域有上限)

货币系统2

完全背包+排序+贪心+剪枝

首先使用最小的货币单位来完全背包值域,如果这个过程中出现了等额于其他货币的金额,则这个大金额失效,可以去掉。重复这个过程,累计失效的个数,最后用n减去并输出即可

多重背包问题 III

版本1暴力,版本2二进制优化,版本3单调队列优化,以下给出具体思路和证明

第一位枚举物品数量

然后我们得到该物品的体积v、价值w、数量s

我们将f数组拆开,得到下面:

1
2
3
4
5
6
我们把 dp[0] --> dp[m] 写成下面这种形式
dp[0], dp[v], dp[2*v], dp[3*v], ... , dp[k*v]
dp[1], dp[v+1], dp[2*v+1], dp[3*v+1], ... , dp[k*v+1]
dp[2], dp[v+2], dp[2*v+2], dp[3*v+2], ... , dp[k*v+2]
...
dp[j], dp[v+j], dp[2*v+j], dp[3*v+j], ... , dp[k*v+j]

我们容易知道,只有每一行中的数会相互影响,所以我们单独拿一行出来讨论

1
2
3
4
5
我们可以得到
dp[j] = dp[j]
dp[j+v] = max(dp[j] + w, dp[j+v])
dp[j+2v] = max(dp[j] + 2w, dp[j+v] + w, dp[j+2v])
dp[j+3v] = max(dp[j] + 3w, dp[j+v] + 2w, dp[j+2v] + w, dp[j+3v])

然后发现没有办法两项合并,所以将数据进行一些转化

1
2
3
4
dp[j]    =     dp[j]
dp[j+v] = max(dp[j], dp[j+v] - w) + w
dp[j+2v] = max(dp[j], dp[j+v] - w, dp[j+2v] - 2w) + 2w
dp[j+3v] = max(dp[j], dp[j+v] - w, dp[j+2v] - 2w, dp[j+3v] - 3w) + 3w

可以发现,虽然dp原数组不是对其的,但是减去一个偏移量就对其了

每个数的偏移量可以通过下标算出来

我们思考维护一个单调队列(下降序列),有效项数不能超过s(当前物品的数量)

同时我们只是需要前s项的最大值(已经减去偏移量)来更新自己

所以我们单调队列中存储的是具体的背包体积k

当队尾的q[tt换算成减去偏移量后小于当前枚举到的体积k对应的f值减去偏移量时

我们就弹出队尾,知道大于或者队列为空为止

当队列为空,表示不需要前面的值来更新自己,自己本来就比他们大

当队列不为空,取队头,来更新f[k]

由于如果一直直接在f数组上更新的话,可能导致最后的结果是由于两次方当前物品得来

举例证明:

1
2
核心在于这一句话,当不更新到一个备份数组中去,而是直接在一个数组上搞,假设当前f[k]被全面某个位置更新,此时物品可用次数变少,但是当该f[k]下面作为g[q[hh]]出现时,会重复使用当前物品,导致可能使用不止s个物品,所以我们只能使用一个滚动数组,这里是使用两个数组,一直交换来操作的
f[k] = max(f[k], g[q[hh]] + (k-q[hh])/v*w);

我觉得该题难点在于需要理解为什么单调队列中要存放体积,为什么用体积搞一搞就能保证“有序”,以及怎么通过体积得到偏移量。

这里简略解释一下(弱弱)

尽管单调队列里面存放的是体积(v),但是其实是按照v的“性质”排序,这个性质就是f[v]-偏移量,就能找到前面有效短里面的max,也就是上面的代码段三种的哪个图,然后对应下面的状态转移,加上代码段三种每一句的后缀( + x*w)来转移

1
if(hh <= tt) f[k] = max(f[k], g[q[hh]] + (k-q[hh])/v*w);

解释:

当单调队列种有元素时,队头就是max(已带偏移量),

g[q[hh]]:花q[hh]体积得到的最大价值

(k-q[hh])/v*w:两个状态之前相隔的v的组数,乘以w

为什么这两个能更新f[k]:

因为单调队列中的体积是按照他们的性质排序的,我们使用他的队头,使用为他队头对应的哪个状态最大,所以我们使用这个“数字”,我们回到代码块三,假设我们现在在讨论最后一行,我们的队头中是里面的某一个状态,容易看出来,里面所有状态,最后都要加上括号外面的偏移量,所以这里我们只需要找到里面最大的即可,其他的就不用管了。

下面证明一下为什么最后只使用了最大的哪个数据对应的体积,而没有使用括号后面的偏移量:

证明:

我们将代码块三中最后一行取到这里来

1
dp[j+3v] = max(dp[j], dp[j+v] - w, dp[j+2v] - 2w, dp[j+3v] - 3w) + 3w

假设我们选择的是第二项dp[j+v] - w,这一项现在在单调队列队头,根据我们的状态转移方程f[k] = max(f[k], g[q[hh]] + (k-q[hh])/v*w);

我们发现dp[j+v] - w + 3w = dp[j+v] + 2w,使用我们的方法:

1
(k-q[hh])/v*w) = (j+3v-(j+v) / v * w) = (2v/v*w) = 2w

结果惊人的相似,下面给出一个解释这个问题的思路:

我们成功选到了队头之后,能保证他是那一组里面的max,所以这个数一定会用,于是我们换一个思路,既然这个数一定会被用到,那么表示一定是从这个数转移过来的,那么当前体积和这个体积的差就全部都是当前物品凑起来的,就能够得到个数,进而得到价值,来更新数组即可。

庆功会

多重背包

二进制拆包或者单调队列优化都可

混合背包问题

混合背包,三混

解法一:多重背包二进制拆包,于01一起做01,完全背包单独完全

解法二:因为值域上限不大,所有进一步将完全背包也转换为多重背包,最后全变为01背包

二维费用的背包问题

二维费用的背包

板子题,三重循环遍历,也可以倒着来滚动数组

潜水员

二维费用的背包

f[i][j]:使用i氧气,j氮气,的最小重量

唯一不同的是这个题是取min的过程,所以需要初始化f数组为0x3f

并且在二维背包的时候for里面不能和普通的一样(>=V[i]),而是必须到达0

因为可以出现”气体不一定全给的情况“

简而言之,就是当前有一个O1 N2 W3的罐子,也可以给f[1][1]更新

那么for里面没有限制的话,在下面是不是需要特判呢,下面给出一种解法:

1
2
3
4
5
6
7
8
9
for(int i = 1; i <= k; i ++)
{
int a, b, c; scanf("%d%d%d", &a, &b, &c);//氧 氮 重量
for(int j = w_o; j >= 0; j --)
for(int z = w_n; z >= 0; z --)
{
f[j][z] = min(f[j][z], f[max(0, j - a)][max(0, z - b)] + c);
}
}

和0取max即可,这里所有本来是负数的情况表示有气体没有完全使用的情况,也就是说当前气体就以及溢出了,所以只需要当前这一个罐子,可以从0转移过来,至于可能出现一边小于0一边大于0的情况,这里不会冲突,为什么?因为当我们使用一边小于0一边大于0的情况来更新别人的时候,一边小于0一边大于0的情况在前面一定被处理过,它的f值一定不是0,所以没有问题,这里可以和普通问题一样思考即可,dp具有严格的思维性!

机器分配

分组背包

理解为每一个公司可以使用一个电脑,使用两个电脑……,

三重循环遍历即可。

题目需要找到一条合法路径,下面给出一种方法:

我们从倒着遍历,首先找到前面在更新n这个点的时候是从哪个状态转移过来的,如果满足下列情况,就说明满足情况,这时候要即使收敛电脑个数,继续下一次寻找。所以倒着找的意义就体现在这里。

1
2
3
4
5
6
7
8
9
for(int i = n; i >= 1; i --){
for(int j = 1; j <= m; j ++){
if(f[i][m] == f[i-1][m-j] + q[i][j]) { <-----
way[i] = j;
m -= j;
break;
}
}
}

能不能保证倒着找一定有一组解,会不会出现找到一半没有解的情况?

当然不会,假设我们找到了当前点的上一个转移点,则上一个转移点一定是再再前面的某个点更新过来,则依此思路能找到最前面的情况,也就是f[0][0]

开心的金明

01背包

体积:价格

价值:价格*重要程度

水!

有依赖的背包问题

树形dp+分组背包

建树、设计状态表示和状态转移

为了尽量避开容斥的环节,可以使用以下状态表示

f[u][j]:以u为根节点使用j体积并且一定使用了u点的max

这样能保证不用判断子树的容斥

  1. 从根节点开始递归,在回溯的时候做其余操作
  2. 任取每一个结点,我们想要更新这个结点,就需要从子树出发
  3. 以每个字数的体积状态来划分,等价于选每个子树中的一种体积(一种状态)
  4. 至此转化为树上的分组背包问题
  5. 为什么要用子树的体积来划分?
    1. 否则则只能使用子树的转移情况来划分,也许是子树对子子树的利用,逐渐退化为爆搜
    2. 体积上限较小,统称为值域较小,是可以作为分组背包来枚举的
  6. 在用子树更新完当前根节点之后,需要将自己加入当前状态中,这里是一定要加入,故倒叙遍历一遍,强行加入
  7. 考虑到一定要加入,所以所有体积小于当前物品体积的状态置为0即可

背包问题求方案数

关于方案数的问题,一般做法为再定义一个数组g,g中下标和f中下标严格对应,再去设计状态表示和转移方程

定义f[i]:使用了体积为i的背包能装下的最大的价值

定义g[i]:使用了体积为i完全使用完的最大方案数

于是在01背包转移的时候我们有以下转移代码

1
2
3
4
5
6
7
8
9
10
11
12
g[0] = 1;
for(int i = 1; i <= n; i ++){//物品
int v, w; cin >> v >> w;
for(int j = m; j >= v; j --){//01背包
int maxv = max(f[j], f[j - v] + w);//先得到最大值,方便下面判断
int cnt = 0;
if(maxv == f[j]) cnt += g[j]; //不使用当前物品是max,则加上方案数
if(maxv == f[j-v]+w) cnt += g[j-v];//使用当前物品是max,则加上方案数
g[j] = cnt % mod; //这里只在g之间转移,而没有取max这样就严格保证了是使用体积j(全用)的最大方案数
f[j] = maxv;
}
}

最后先找一下f中的最大价值是多少,再遍历f,当当前是最大价值的时候,我们对g[i]累加

代码如下:

1
2
3
4
5
6
7
8
9
int maxv = 0;
for(int i = 0; i <= m; i ++) maxv = max(maxv, f[i]);

int ans = 0;
for(int i = 0; i <= m; i ++)
if(f[i] == maxv)
ans = (ans + g[i]) % mod;

cout << ans << endl;

背包问题求具体方案

在前面我们做过一个题目【机器分配】,也是输出一个方案,但是不要求是方案的最小字典序。

并且求具体方案的题目大多不能使用滚动数组来优化,因为我们要在最后用到每一层的数据

上一个题目是分组背包,找方案的时候需要遍历当前分组中的所有元素,该题是01背包,则少一层循环。

上一层我们是倒着找具体方案,所以找到的不是最小字典序,这里我们需要正着来找

但是考虑到正着找的话,我们并不能使用再前面的数据,换言之当我们使用f[i]的时候,不能使用前面的数据f[i-1]来更新,只能使用后面的数据,所以我们在前面做01背包的时候,我们从最后一个开始做。

然后找方案的时候能有靠前面的满足匹配的就一定要选,这样能保证找出的方案的字典序

能量石

基于贪心的DP问题

假设我们以及剔除了贡献为0的宝石(最最后面吃的)

现在我们得到了可以做出贡献的宝石序列a1,a2,a3… ak

那么对于任意两个位置

交换后两个宝石的贡献总和不会变得更大,即(假设之前的总时间为tt ):

整理后:

我们可以把跟i有关的放到一边,调整一下:

这样,我们只要以如上条件作为宝石间排序的条件,进行一次sort。

因为对于其他形式的放置规律,必然可以通过交换满足$SiLi>SjLj$的相邻的两项来得到更小值。

那么最优解的坐标(新的坐标)一定满足:

此时转化为01背包问题

f[i]表示当前正好花费t时间得到的最大值

转移方程:

由于我们背包放物品(宝石)的顺序是坐标从1到n的,所以一定能枚举到最优解。

初始状态:f[0]=0,其余为负无穷

答案:$max(f[i])(1<=i<=∑ni=1si)$

公式摘选自:墨染空

下面给出我的思考:

  1. 前面使用贪心预处理数据,得到先后顺序
  2. 下面利用值域较小,可以枚举的性质,使用f[i]表示恰好使用i的时间吃到的总能量
  3. 吃时候的得到的能量可以通过枚举的当前时间计算而来,价格是固定的

这种类似的题目都是枚举权值的方向来思考状态表示,至于前天的贪心嘛,需要题量来积累。

金明的预算方案

这道题看起来有一股有依赖的背包,但是实则不然,这个依赖关系比较浅,【有依赖的背包问题】依赖关系较深

由于每个点只有最多两个子节点,而且不会有孙子结点,所以我们可以枚举当前子节点的所有可能,进而演化为一个分组背包问题。

记忆点:

  1. 分组背包建图时需要两个二维数组,对应付出价值和回报价值
  2. 分组背包没有办法使用滚动数组来优化,如果使用了会导致同一组物品多个被利用
0%