Unverified Commit 09e95e75 authored by XLor's avatar XLor Committed by GitHub
Browse files

Merge pull request #6 from OI-wiki/master

merge
parents 2c86ce50 a9832701
Loading
Loading
Loading
Loading
+5 −1
Original line number Diff line number Diff line
[![Word Art](https://raw.githubusercontent.com/24OI/OI-wiki/master/docs/images/wordArt.png)](https://oi-wiki.org/)
[![Word Art](docs/images/OI_wiki_new_year_ver.png)](https://oi-wiki.org/)

# 欢迎来到 **OI Wiki**!

@@ -137,3 +137,7 @@ python2 -m SimpleHTTPServer

<!-- <img src='https://i.loli.net/2018/12/07/5c0a6e4c31b30.png' alt='QVQNetWork' width=233> 
鸣谢 QVQNetwork 赞助的服务器。 -->

感谢 北大算协 和 Hulu 的支持!

![](https://assets.pcmag.com/media/images/560767-hulu.png?width=333&height=245)
+1 −0
Original line number Diff line number Diff line
@@ -26,6 +26,7 @@ $$
C++ 代码:

```cpp
// 假设数组的大小是n+1,冒泡排序从数组下标1开始
void bubble_sort(int *a, int n) {
  bool flag = true;
  while (flag) {
+8 −8
Original line number Diff line number Diff line
author: fudonglai
author: fudonglai, AngelKitty

首先简单阐述一下递归,分治算法,动态规划,贪心算法这几个东西的区别和联系,心里有个印象就好。

@@ -27,11 +27,11 @@ int func(传入数值) {
}
```

其实仔细想想, **递归运用最成功的是什么?我认为是数学归纳法。** 我们高中都学过数学归纳法,使用场景大概是:我们推不出来某个求和公式,但是我们试了几个比较小的数,似乎发现了一点规律,然后了一个公式,看起来应该是正确答案。但是数学是很严谨的,你哪怕穷举了一万个数都是正确的,但是第一万零一个数正确吗?这就要数学归纳法发挥神威了,可以假设我们的这个公式在第 k 个数时成立,如果证明在第 k + 1 时也成立,那么我们的这个公式就是正确的。
其实仔细想想, **递归运用最成功的是什么?我认为是数学归纳法。** 我们高中都学过数学归纳法,使用场景大概是:我们推不出来某个求和公式,但是我们试了几个比较小的数,似乎发现了一点规律,然后猜想了一个公式,看起来应该是正确答案。但是数学是很严谨的,你哪怕穷举了一万个数都是正确的,但是第一万零一个数正确吗?这就要数学归纳法发挥神威了,可以假设我们猜想的这个公式在第 k 个数时成立,如果证明在第 k + 1 时也成立,那么我们猜想的这个公式就是正确的。

那么数学归纳法和递归有什么联系?我们刚才说了,递归代码必须要有结束条件,如果没有的话就会进入无穷无尽的自我调用,直到内存耗尽。而数学证明的难度在于,你可以尝试有穷种情况,但是难以将你的结论延伸到无穷大。这里就可以看出联系了——无穷。

递归代码的精髓在于调用自去解决规模更小的子问题,直到到达结束条件;而数学归纳法之所以有用,就在于不断把我们的猜测向上加一,扩大结论的规模,没有结束条件,从而把结论延伸到无穷无尽,也就完成了猜测正确性的证明。
递归代码的精髓在于调用自去解决规模更小的子问题,直到到达结束条件;而数学归纳法之所以有用,就在于不断把我们的猜测向上加一,扩大结论的规模,没有结束条件,从而把结论延伸到无穷无尽,也就完成了猜测正确性的证明。

### 为什么要写递归

@@ -137,13 +137,13 @@ int count(TreeNode node, int sum) {

题目看起来很复杂吧,不过代码却极其简洁,这就是递归的魅力。我来简单总结这个问题的 **解决过程**

首先明确,递归求解树的问题必然是要遍历整棵树的,所以 **二叉树的遍历框架** (分别对左右孩子递归调用函数本身)必然要出现在主函数 pathSum 中。那么对于每个节点,们应该干什么呢?们应该看看,自己和脚底下的小弟们包含多少条符合条件的路径。好了,这道题就结束了。
首先明确,递归求解树的问题必然是要遍历整棵树的,所以 **二叉树的遍历框架** (分别对左右孩子递归调用函数本身)必然要出现在主函数 pathSum 中。那么对于每个节点,们应该干什么呢?们应该看看,自己和脚底下的小弟们包含多少条符合条件的路径。好了,这道题就结束了。

按照前面说的技巧,根据刚才的分析来定义清楚每个递归函数应该做的事:

PathSum 函数:给一个节点和一个目标值,返回以这个节点为根的树中,和为目标值的路径总数。
PathSum 函数:给一个节点和一个目标值,返回以这个节点为根的树中,和为目标值的路径总数。

count 函数:给一个节点和一个目标值,返回以这个节点为根的树中,能凑出几个以该节点为路径开头,和为目标值的路径总数。
count 函数:给一个节点和一个目标值,返回以这个节点为根的树中,能凑出几个以该节点为路径开头,和为目标值的路径总数。

```cpp
/* 有了以上铺垫,详细注释一下代码 */
@@ -167,7 +167,7 @@ int count(TreeNode node, int sum) {
}
```

还是那句话, **明白每个函数能做的事,并相信们能够完成。** 
还是那句话, **明白每个函数能做的事,并相信们能够完成。** 

总结下,PathSum 函数提供的二叉树遍历框架,在遍历中对每个节点调用 count 函数,看出先序遍历了吗(这道题什么序都是一样的);count 函数也是一个二叉树遍历,用于寻找以该节点开头的目标值路径。好好体会吧!

@@ -200,6 +200,6 @@ void merge_sort(一个数组) {
}
```

好了,这个算法也就这样了,完全没有任何难度。记住之前说的,相信函数的能力,传给半个数组,那么这半个数组就已经被排好了。而且你会发现这不就是个二叉树遍历模板吗?为什么是后序遍历?因为我们分治算法的套路是 **分解 -> 解决(触底)-> 合并(回溯)** 啊,先左右分解,再处理合并,回溯就是在退栈,就相当于后序遍历了。至于 `merge` 函数,参考两个有序链表的合并,简直一模一样。
好了,这个算法也就这样了,完全没有任何难度。记住之前说的,相信函数的能力,传给半个数组,那么这半个数组就已经被排好了。而且你会发现这不就是个二叉树遍历模板吗?为什么是后序遍历?因为我们分治算法的套路是 **分解 -> 解决(触底)-> 合并(回溯)** 啊,先左右分解,再处理合并,回溯就是在退栈,就相当于后序遍历了。至于 `merge` 函数,参考两个有序链表的合并,简直一模一样。

LeetCode 上有分治算法的专项练习, [点这里去做题](https://leetcode.com/tag/divide-and-conquer/) 
+20 −13
Original line number Diff line number Diff line
@@ -36,7 +36,7 @@ $$
还有一点需要注意的是,很容易写出这样的错误核心代码:

```cpp
for (int i = 1; i <= W; i++)
for (int i = 1; i <= n; i++)
  for (int l = 0; l <= W - w[i]; l++)
    f[l + w[i]] = max(f[l] + v[i], f[l + w[i]]);
// 由 f[i][l + w[i]] = max(max(f[i - 1][l + w[i]],f[i - 1][l] + w[i]),f[i][l +
@@ -82,15 +82,13 @@ for (int i = 1; i <= n; i++)

可以考虑一个朴素的做法:对于第 $i$ 件物品,枚举其选了多少个来转移。这样做的时间复杂度是 $O(n^3)$ 的。

尽管这样看起来很蠢,我们还是写一下 dp 方程
状态转移方程如下

$$
f_{i,j}=\max_{k=0}^{+\infty}(f_{i-1,j-k\times w_i}+v_i\times k)
$$

然而这样显然不够优秀,我们要对它进行优化。

可以发现,对于 $f_{i,j}$ ,只要通过 $f_{i,j-w_i}$ 转移就可以了。dp 方程为:
考虑做一个简单的优化。可以发现,对于 $f_{i,j}$ ,只要通过 $f_{i,j-w_i}$ 转移就可以了。因此状态转移方程为:

$$
f_{i,j}=\max(f_{i-1,j},f_{i,j-w_i}+v_i)
@@ -121,15 +119,21 @@ $$

## 多重背包

多重背包也是 0-1 背包的一个变式。与 0-1 背包的区别在于每种物品可以选 $k_i$ 次,而非 $1$ 次。
多重背包也是 0-1 背包的一个变式。与 0-1 背包的区别在于每种物品 y 有 $k_i$ 个,而非 $1$ 个。

一个很朴素的想法就是:把“每种物品选 $k_i$ 次”等价转换为“有 $k_i$ 个相同的物品,每个物品选一次”。这样就转换成了一个 0-1 背包模型,套用上文所述的方法就可已解决。状态转移方程如下:

$$
f_{i,j}=\max_{k=0}^{k_i}(f_{i-1,j-k\times w_i}+v_i\times k)
$$

一个很朴素的想法就是:把“每种物品选 $k_i$ 次”等价转换为“有 $k_i$ 个相同的物品,每个物品选一次”。这样就转换成了一个 0-1 背包模型,套用上文所述的方法就可已解决。时间复杂度 $O(nW\sum k_i)$ ,可以轻松 TLE
时间复杂度 $O(nW\sum k_i)$ 。

虽然我们失败了,但是这个朴素的想法可以给我们的思路提供一些借鉴——我们可以把多重背包转化成 0-1 背包模型来求解。
### 二进制分组优化

显然,复杂度中的 $O(nW)$ 部分无法再优化了,我们只能从 $O(\sum k_i)$ 处入手
考虑优化。我们仍考虑把多重背包转化成 0-1 背包模型来求解

为了表述方便,我们用 $A_{i,j}$ 代表第 $i$ 种物品拆分出的第 $j$ 个物品。
显然,复杂度中的 $O(nW)$ 部分无法再优化了,我们只能从 $O(\sum k_i)$ 处入手。为了表述方便,我们用 $A_{i,j}$ 代表第 $i$ 种物品拆分出的第 $j$ 个物品。

在朴素的做法中, $\forall j\le k_i$ , $A_{i,j}$ 均表示相同物品。那么我们效率低的原因主要在于我们进行了大量重复性的工作。举例来说,我们考虑了“同时选 $A_{i,1},A_{i,2}$ ”与“同时选 $A_{i,2},A_{i,3}$ ”这两个完全等效的情况。这样的重复性工作我们进行了许多次。那么优化拆分方式就成为了解决问题的突破口。

@@ -146,7 +150,7 @@ $$

显然,通过上述拆分方式,可以表示任意 $\le k_i$ 个物品的等效选择方式。将每种物品按照上述方式拆分后,使用 0-1 背包的方法解决即可。

时间复杂度 $O(nW\sum\log k_i)$ 
时间复杂度 $O(W\sum_{i=1}^n\log_2k_i)$ 

??? 二进制分组代码
    ```cpp
@@ -165,8 +169,11 @@ $$
    }
    ```

??? note "[「Luogu P1776」宝物筛选\_NOI 导刊 2010 提高(02)](https://www.luogu.org/problemnew/show/P1776)"
    题意概要:有 $n$ 种物品和一个容量为 $W$ 的背包,每种物品有 $m_v{i}$ 个,同时每个物品有两种属性:重量 $w_{i}$ 和价值 $v_{i}$ 。要求选若干个物品放入背包使背包中物品的总价值最大且背包中物品的总重量不超过背包的容量。有一点需要注意的是本题数据范围较大,情况较多。
### 单调队列优化

[单调队列/单调栈优化](opt/monotonous-queue-stack.md)

习题: [「Luogu P1776」宝物筛选\_NOI 导刊 2010 提高(02)](https://www.luogu.org/problemnew/show/P1776) 

## 混合背包

docs/dp/opt/binary-knapsack.md

deleted100644 → 0
+0 −35
Original line number Diff line number Diff line
author: TrisolarisHD, hsfzLZH1, greyqz, Ir1d, abc1763613206, tigerruanyifan

## 介绍

??? note " 例题[经典问题 - 多重背包](../knapsack.md#_2)"
    题目大意:有 $n$ 种物品,每种物品有 $a_i$ 件,购买一件这种物品的费用为 $c_i$ ,价值为 $v_i$ 。有一个容量为 $t$ 的背包,现在让你找到最优的一种方案,使得装入背包的物品的总价值最大。

考虑常规的动规方式,定义 $f_{i,j}$ 为当前考虑到第 $i$ 个物品,背包容量为 $j$ 所能获得的最大价值。

状态转移方程为, $f_{i,j}=\max\{f_{i-1,j},f_{i-1,j-c_i}+v_i\}$ 。

对于 **每件** 物品,都要这样循环一次,时间复杂度为 $t\times \sum_{i=1}^n a_i$ ,某些时候可能不可接受,需要优化。

考虑这样一种情况,如果我们有 $17$ 个硬币,要去买 $1$ 到 $17$ 元钱的物品,只需将这些硬币打包成 $1,2,4,8$ 和 $2$ 这样的几包。前面的 $4$ 包能保证覆盖 $1$ 到 $15$ 所有的情况,最后一包在之前的基础上再加上一个值,能保证实现支付的时候取整包,肯定能保证支付。这就是二进制优化的原理和基本思想。

用上述的方法,就可以把 $k$ 件相同的物品看作是 $O(\log k)$ 件物品了。优化后

代码实现:

```cpp
for (int i = 1; i <= n; i++) {
  scanf("%d", a + i);
  tot += c[i] * a[i];
  for (int j = 1; j <= a[i]; j *= 2)
    if (a[i] >= j) a[i] -= j, v[++cur] = c[i] * j;
  if (a[i]) v[++cur] = c[i] * a[i];
}
for (int i = 1; i <= cur; i++)
  for (int j = m; j >= v[i]; j--)
    if (f[j - v[i]]) f[j] = true;
```

## 习题

 [HDU 2844 Coins](http://acm.hdu.edu.cn/showproblem.php?pid=2844) 
Loading