Commit 8f77f55f authored by ir1d's avatar ir1d
Browse files

Merge remote-tracking branch '24OI/master'

parents 929fdc94 053beaa1
Loading
Loading
Loading
Loading
+114 −3
Original line number Diff line number Diff line
@@ -2,9 +2,120 @@ By [hsfzLZH1](https://github.com/hsfzLZH1)

本章主要讲解动态规划的几种基础 ** 优化 ** 方法。

## 单调队列优化
## 四边形不等式优化

学习本章前,请务必先学习 [单调队列](https://oi-wiki.org/ds/monotonous-queue/)
### 例题 [luogu P1880 [NOI1995]石子合并](https://www.luogu.org/problemnew/show/P1880)

题目大意:在一个环上有 $n$ 个数,进行 $n-1$ 次合并操作,每次操作将相邻的两堆合并成一堆,能获得新的一堆中的石子数量的和的得分。你需要最大化你的得分。

我们首先 ** 破环成链 ** ,然后进行动态规划。设 $f_{i,j}$ 表示从位置 $i$ 合并到位置 $j$ 所能得到的最大得分, $sum_i$ 为前 $i$ 堆石子数的前缀和。

写出 ** 状态转移方程 **: $f_{i,j}=max{f_{i,k}+f_{k+1,j}+(sum_j-sum_i)}(i\le k\le j)$

考虑常规的转移方法,枚举 $i$ , $j$ 和 $k$ ,时间复杂度为 $O(n^3)$。

### 什么是四边形不等式?

对于 $a<b\le c<d$,如果有$f_{a,c}+f_{b,d}\le f_{b,c}+f_{a,d}$,则称该数组满足四边形不等式,可以用通俗的方法表述为“交叉小于包含”。

两个定理:

1.四边形不等式能优化的状态转移方程能表示为 $f_{i,j}=max{f_{i,k}+f_{k+1,j}+cost(i,j)}(i\le k\le j)$。如果 $cost$ 函数同时满足单调性和四边形不等式,那么数组 $f$ 也满足四边形不等式。

定义 $idx_{i,j}$ 为在转移 $f_{i,j}$ 的过程中在 $k=idx_{i,j}$ 时取得最小值,那么有如下定理:

2.如果 $f$ 数组满足四边形不等式,那么 $idx$ 函数满足单调性,即有 $idx_{i,j}\le idx_{i,j+1}\le idx_{i+1,j+1}$ 。

证明会和题目解法一起 $qwq$ 

### 回到题目

第一步:证明 $cost$ 满足四边形不等式

要证明,对于所有满足 $i<i+1\le j<j+1$ 的 $i,j$ , 均有 $cost_{i,j}+cost_{i+1,j+1}\le cost_{i+1,j}+cost_{i,j+1}$。

移项得 $cost_{i,j}-cost_{i+1,j}\le cost_{i,j+1}-cost_{i+1,j+1}$

设 $F(j)=cost_{i,j}-cost{i+1,j}$ ,如果要使这个四边形不等式成立,那么就要证明 $F(j)$ 单调非降。

在本题中, $F(j)=(sum_j-sum_{i-1})-(sum_j-sum_i)=sum_i-sum_{i-1}=a_i$ ,与 $j$ 无关,自然一定满足四边形不等式。

证毕。

第二步:证明 $f$ 满足四边形不等式

同样的,应有如下结论:对于所有满足 $i<i+1\le j<j+1$ 的 $i,j$ , 均有 $f_{i,j}+f_{i+1,j+1}\le f_{i+1,j}+f_{i,j+1}$

我们假设 $x=idx_{i+1,j},y=idx_{i,j+1}$。不妨设 $x<=y$。

将 $x,y$ 带入得, $f_{i,j}+f_{i+1,j+1}=f_{i,x}+f_{x+1,j}+cost_{i,j}+f_{i+1,y}+f_{y+1,j+1}+cost_{i+1,j+1}$

由于上一步已经证明出了$cost$满足四边形不等式,而该不等式的左边在上式出现过,将其替换得

$f_{i,x}+f_{x+1,j}+cost_{i,j}+f_{i+1,y}+f_{y+1,j+1}+cost_{i+1,j+1}\le f_{i,x}+f_{x+1,j+1}+cost{i,j+1}+f_{i+1,y}+f_{y+1,j}+cost_{i+1,j}$

消去公共项可得 $f_{i,j}+f_{i+1,j+1}\le f_{i+1,j}+f_{i,j+1}$

证毕。

第三步:证明决策的单调性

现在我们已经证明了 $cost$ 和 $f$ 满足四边形不等式,要证明决策的单调性以证明优化的正确性。

即证 $idx_{i,j-1}\le idx_{i,j}\le idx_{i+1,j}$

我们只证明式子的前半部分,后半部分可以有类似的方法推出。

设 $y=idx_{i,j-1},x\le y$ ,因为 $x+1\le y+1\le j-1<j$,由四边形不等式可得,

$f_{x+1,j-1}+f_{y+1,j}\le f_{y+1,j-1}+f_{x+1,j}$

由于我们是令 $y=idx_{i,j-1},x\le y$ 时 $f_{i,j-1}$ 取得最小值,那么 $f_{i,j-1}(idx_{i,j-1}=x)$ 一定大于等于 $dp[i][j-1](idx_{i,j-1}=y)$ ,所以对于 $f_{i,j-1}$ 可以取到最优值的 $y$ ,所有小于它的值,对于 $f_{i,j}$ 来说,都没有 $y$ 优,所以最优决策一定不是小于$y$ 的,那么一定有 
$idx_{i,j-1}\le idx_{i,j}$ 

证毕。

### 说了这么多,怎么进行状态转移呢?

给出核心代码:

```cpp
for(int i=n;i>=1;i--)
{
	for(int j=i+1;j<=n;j++)
	{
		f[i][j]=inf;
		for(int k=s[i][j-1];k<=s[i+1][j];k++)
		{
			if(f[i][j]<f[i][k]+f[k+1][j]+sum[j]-sum[i-1])
			{
				f[i][j]=f[i][k]+f[k+1][j]+sum[j]-sum[i-1];
				idx[i][j]=k;
			}
		}
	}
}
```

注意:由于在计算 $f_{i,j}$ 的时候需要知道 $s_{i,j-1}$ 和 $s_{i+1,j}$ 的值,所以 $i$ 的循环逆序。

### 时间复杂度证明

计算 $f_{i,j}$ 时,我们要循环 $s_{i+1,j}-s_{i,j-1}$ 次,那么一共加起来会循环多少次呢?

因为 $\sum_{i=1}^{n-1}(idx_{i+1,i+1}-idx_{i,i})=idx{n,n}-idx{1,1}$ 很显然和 $n$ 同阶,那么它的 $n$ 倍就和 $n^2$ 同阶,时间复杂度是 $O(n^2)$。

### 几道练习题

[luogu P4767 [IOI2000]邮局](https://www.luogu.org/problemnew/show/P4767)

### 参考资料

[NOIAu 的CSDN 博客](https://blog.csdn.net/noiau/article/details/72514812)

## 单调队列&单调栈优化

学习本节前,请务必先学习 [单调队列](https://oi-wiki.org/ds/monotonous-queue/)

### 例题 [CF372C Watching Fireworks is Fun](http://codeforces.com/problemset/problem/372/C)

@@ -33,7 +144,7 @@ $f_{i,j}=max\{f_{i-1,k}+b_i+|a_i-j|\}=max\{f_{i-1,k}+|a_i-j|\}+b_i$

讲完了,让我们归纳一下单调队列优化动态规划问题的基本形态:当前状态的所有值可以从上一个状态的某个连续的段的值得到,要对这个连续的段进行 RMQ 操作,相邻状态的段的左右区间满足非降的关系。

几道练习题(按笔者所认为的难度排序,仅供参考)
### 几道练习题:

[luogu P1886 滑动窗口](https://www.luogu.org/problemnew/show/P1886)

+0 −0

File moved.

+99 −0
Original line number Diff line number Diff line
## __gnu_pbds :: priority_queue 

附 :[官方文档地址——复杂度及常数测试](https://gcc.gnu.org/onlinedocs/libstdc++/ext/pb_ds/pq_performance_tests.html#std_mod1)

```cpp
#include <ext/pb_ds/priority_queue.hpp>
using namespace __gnu_pbds;
__gnu_pbds :: priority_queue<T, Compare, Tag, Allocator> // 由于 OI 中很少出现空间配置器,故这里不做讲解(其实是我也不知道是啥,逃
/*
 * T : 储存的元素类型
 * Compare : 提供严格的弱序比较类型
 * Tag : 是__gnu_pbds提供的不同的五种堆,Tag参数默认是 pairing_heap_tag
 * 五种分别是 :
 * pairing_heap_tag -> 配对堆 // 官方文档认为在非原生元素(如自定义结构体/ std :: string / pair)中
 * pairing heap 表现的最好
 * binary_heap_tag -> 二叉堆 // 官方文档认为在原生元素中 二叉堆表现最好,不过我测试的表现并没有那么好
 * binomial_heap_tag -> 二项堆 // 二项堆在合并操作的表现要优于配对堆* 但是其取堆顶元素的
 * rc_binomial_heap_tag -> 冗余计数二项堆
 * thin_heap_tag -> 除了合并的复杂度都和 Fibonacci 堆一样的一个 tag
 * 由于本篇文章只是提供给学习算法竞赛的同学们,故对于后四个 tag 只会简单的介绍复杂度,第一个会介绍成员函数和
 * 使用方法,经作者本机 Core i5@3.1 GHz On macOS 测试堆的基础操作/结合 GNU 官方的复杂度测试/Dijkstra
 * 测试,都表明至少对于*** OIer ***来讲,除了配对堆的其他4个tag都是鸡肋,不是没什么用就是常数大到不如 std 的
 * 且有可能造成 MLE    
 * priority_queue,故这里只推荐用默认的 pairing_heap。
 * 同样,配对堆也优于 algorithm 库中的 make_heap()
 */
 // 构造方式 : 要注明命名空间因为和std的类名称重复
 __gnu_pbds :: priority_queue<int>
 __gnu_pbds :: priority_queue<int, greater<int> >
 __gnu_pbds :: priority_queue<int, greater<int>, pairing_heap_tag>
 // 迭代器 :迭代器是一个内存地址,在modify和push的时候都会返回一个迭代器,下文会详细的讲使用方法
 __gnu_pbds :: priority_queue<int> :: point_iterator id;
 id = q.push(1);
```

复杂度如下表 :

|                      | push                                     | pop                                      | modify                                   | erase                                     | Join              |
| -------------------- | ---------------------------------------- | :--------------------------------------- | ---------------------------------------- | ----------------------------------------- | ----------------- |
| Pairing_heap_tag     | $O(1)$                                   | 最坏$\Theta(n)$    均摊$\Theta(\log(n))$ | 最坏$\Theta(n)$    均摊$\Theta(\log(n))$ | 最坏$\Theta(n)$    均摊$\Theta(\log(n))$  | $O(1)$            |
| Binary_heap_tag      | 最坏$\Theta(n)$    均摊$\Theta(\log(n))$ | 最坏$\Theta(n)$    均摊$\Theta(\log(n))$ | $\Theta(n)$                              | $\Theta(n)$                               | $\Theta(n)$       |
| Binomial_heap_tag    | 最坏$\Theta(\log(n))$   均摊$O(1)$       | $\Theta(\log(n))$                        | $\Theta(\log(n))$                        | $\Theta(\log(n))$                         | $\Theta(\log(n))$ |
| Rc_Binomial_heap_tag | $O(1)$                                   | $\Theta(\log(n))$                        | $\Theta(\log(n))$                        | $\Theta(\log(n))$                         | $\Theta(\log(n))$ |
| Thin_heap_tag        | $O(1)$                                   | 最坏$\Theta(n)$    均摊$\Theta(\log(n))$ | 最坏$\Theta(\log(n))$   均摊$O(1)$       | 最坏$\Theta(n)$    0均摊$\Theta(\log(n))$ | $\Theta(n)$       |

##### 成员函数:

1. ```push()```:向堆中压入一个元素, 返回该元素位置的迭代器
2. ```pop()```:将堆顶元素弹出
3. ```top()```:返回堆顶元素
4. ```size()```返回元素个数
5. ```empty()```返回是否非空
6. ```modify(point_iterator, const key)``` : 把迭代器位置的key修改为传入的key,并对底层储存结构进行排序
7. ```erase(point_iterator)``` : 把迭代器位置的键值从堆中擦除
8. ```join(__gnu_pbds :: priority_queue &other)```:把other合并到*this并把other清空。

##### 示例:

```cpp
#include <cstdio>
#include <iostream>
#include <algorithm>
#include <ext/pb_ds/priority_queue.hpp>
using namespace __gnu_pbds;
// 由于面向OIer, 本文以常用堆 : pairing_heap_tag作为范例
// 为了更好的阅读体验,定义宏如下 :
#define pair_heap __gnu_pbds :: priority_queue<int>
pair_heap q1; //大根堆, 配对堆
pair_heap q2;
pair_heap :: point_iterator id; // 一个迭代器
int main() {
	id = q1.push(1); 
	// 堆中元素 : [1];
	for(int i = 2; i <= 5; i ++ ) q1.push(i);
	// 堆中元素 :  [1, 2, 3, 4, 5];
	std :: cout << q1.top() << std :: endl;
	// 输出结果 : 5;
	q1.pop();
	// 堆中元素 : [1, 2, 3, 4];
	id = q1.push(10);
	// 堆中元素 : [1, 2, 3, 4, 10];
	q1.modify(id, 1);
	// 堆中元素 :  [1, 1, 2, 3, 4];
	std :: cout << q1.top() << std :: endl;
	// 输出结果 : 4;
	q1.pop();
	// 堆中元素 : [1, 1, 2, 3];
	id = q1.push(7);
	// 堆中元素 : [1, 1, 2, 3, 7];
	q1.erase(id);
	// 堆中元素 : [1, 1, 2, 3];
	q2.push(1), q2.push(3), q2.push(5);
	// q1中元素 : [1, 1, 2, 3], q2中元素 : [1, 3, 5];
	q2.join(q1);
	// q1中无元素,q2中元素 :[1, 1, 1, 2, 3, 3, 5];
}


```
+154 −0
Original line number Diff line number Diff line
** 替罪羊树 ** 是一种依靠重构操作维持平衡的重量平衡树。替罪羊树会在插入、删除操作时,检测途经的节点,若发现失衡,则将以该节点为根的子树重构。

我们在此实现一个可重的权值平衡树。

``` cpp
int cnt, // 树中元素总数
  rt, // 根节点,初值为 0 代表空树
  w[MAXN], // 点中的数据 / 权值
 lc[MAXN], rc[MAXN], // 左右子树
 wn[MAXN], // 本数据出现次数(为 0 代表已删除)
  s[MAXN], // 以本节点为根的子树大小
 sd[MAXN]; // 已删除节点不计的子树大小
 
void Calc(int k) {
// 重新计算以 k 为根的子树大小
 s[k] = s[lc[k]] + s[rc[k]] + wn[k];
 sd[k] = sd[lc[k]] + sd[rc[k]] + wn[k];
}
```

## 重构

首先,如前所述,我们需要判定一个节点是否应重构。为此我们引入一个比例常数 $\alpha$(取值在 $(0.5,1)$,一般采用 $0.7$ 或 $0.8$),若某节点的子节点大小占它本身大小的比例超过 $\alpha$,则重构。

另外由于我们采用惰性删除(删除只使用 `wn[k]--`),已删除节点过多也影响效率。因此若未被删除的子树大小占总大小的比例低于 $\alpha$,则亦重构。

``` cpp
inline bool CanRbu(int k) {
// 判断节点 k 是否需要重构
 return wn[k] && (alpha * s[k] <= (double)std::max(s[lc[k]], s[rc[k]])
  || (double)sd[k] <= alpha * s[k]);
}
```

重构分为两个步骤——先前序遍历展开存入数组,再二分重建成树。

``` cpp
void Rbu_Flatten(int& ldc, int k) {
// 前序遍历展开以 k 节点为根子树
 if(!k) return;
 Rbu_Flatten(ldc, lc[k]);
 if(wn[k]) ldr[ldc++] = k;
// 若当前节点已删除则不保留
 Rbu_Flatten(ldc, rc[k]);
}

int Rbu_Build(int l, int r) {
// 将 ldr[] 数组内 [l, r) 区间重建成树,返回根节点
 int mid = l + r >> 1; // 选取中间为根使其平衡
 if(l >= r) return 0;
 lc[ldr[mid]] = Rbu_Build(l, mid);
 rc[ldr[mid]] = Rbu_Build(mid + 1, r); // 建左右子树
 Calc(ldr[mid]);
 return ldr[mid];
}

void Rbu(int& k) {
// 重构节点 k 的全过程
 int ldc = 0;
 Rbu_Flatten(ldc, k);
 k = Rbu_Build(0, ldc);
}
```

## 基本操作

几种操作的处理方式较为类似,都规定了 ** 到达空结点 **** 找到对应结点 ** 的行为,之后按 ** 小于向左、大于向右 ** 的方式向下递归。

### 插入

插入时,到达空结点则新建节点,找到对应结点则 `wn[k]++`。递归结束后,途经的节点可重构的要重构。

``` cpp
void Ins(int& k, int p) {
// 在以 k 为根的子树内添加权值为 p 节点
 if(!k) { k = ++cnt; if(!rt) rt = 1;
  w[k] = p; lc[k] = rc[k] = 0; wn[k] = s[k] = sd[k] = 1;
 } else {
  if(w[k] == p) wn[k]++;
  else if(w[k] < p) Ins(rc[k], p);
  else Ins(lc[k], p);
  Calc(k); if(CanRbu(k)) Rbu(k);
 }
}
```

### 删除

惰性删除,到达空结点则忽略,找到对应结点则 `wn[k]--`。递归结束后,可重构节点要重构。

``` cpp
void Del(int& k, int p) {
// 从以 k 为根子树移除权值为 p 节点
 if(!k) return; else {
  sd[k]--; if(w[k] == p) { if(wn[k]) wn[k]--; }
   else {
    if(w[k] < p) Del(rc[k], p);
    else Del(lc[k], p);
    Calc(k);
   }
 }
 if(CanRbu(k)) Rbu(k);
}
```

### upper_bound

返回权值严格大于某值的最小名次。

到达空结点则返回 1,因为只有该子树左边的数均小于查找数才会递归至此。找到对应结点,则返回该节点所占据的最后一个名次 + 1。

``` cpp
int MyUprBd(int k, int p) {
// 在以 k 为根子树中,大于 p 的最小数的名次
 if(!k) return 1;
 else if(w[k] == p && wn[k]) return sd[lc[k]] + 1 + wn[k];
 else if(p < w[k]) return MyUprBd(lc[k], p);
 else return sd[lc[k]] + wn[k] + MyUprBd(rc[k], p);
}
```

以下是反义函数,相当于采用 `std::greater<>` 比较,即返回权值严格小于某值的最大名次。查询一个数的排名可以用 `MyUprGrt(rt, x) + 1`

``` cpp
int MyUprGrt(int k, int p) {
 if(!k) return 0;
 else if(w[k] == p && wn[k]) return sd[lc[k]];
 else if(w[k] < p) return sd[lc[k]] + wn[k] + MyUprGrt(rc[k], p);
 else return MyUprGrt(lc[k], p);
}
```

### at

给定名次,返回该名次上的权值。到达空结点说明无此名次,找到对应结点则返回其权值。

``` cpp
int MyAt(int k, int p) {
// 以 k 为根的子树中,名次为 p 的权值
 if(!k) return 0;
 else if(sd[lc[k]] < p && p <= sd[lc[k]] + wn[k]) return w[k];
 else if(sd[lc[k]] + wn[k] < p) return MyAt(rc[k], p - sd[lc[k]] - wn[k]);
 else return MyAt(lc[k], p);
}
```

### 前驱后继

以上两种功能结合即可。

``` cpp
inline int  MyPre(int k, int p) { return MyAt(k, MyUprGrt(k, p)); }
inline int MyPost(int k, int p) { return MyAt(k, MyUprBd (k, p)); }
```
+377 −0

File changed.

Preview size limit exceeded, changes collapsed.

Loading