diff --git a/Algorithm/a_template/index.html b/Algorithm/a_template/index.html index 37b9a7bfa..0ea79df9f 100644 --- a/Algorithm/a_template/index.html +++ b/Algorithm/a_template/index.html @@ -612,9 +612,9 @@

计算几何

- + - README + binary-search 上一篇 @@ -622,8 +622,8 @@

计算几何

- - divide-and-conquer + + dfs-and-similar 下一篇 diff --git a/Algorithm/binary-search/index.html b/Algorithm/binary-search/index.html index 2f598cb17..4965cdf72 100644 --- a/Algorithm/binary-search/index.html +++ b/Algorithm/binary-search/index.html @@ -580,9 +580,9 @@

【二分查找】Bomb

- + - divide-and-conquer + README 上一篇 @@ -590,8 +590,8 @@

【二分查找】Bomb

- - data-structure + + a_template 下一篇 diff --git a/Algorithm/data-structure/index.html b/Algorithm/data-structure/index.html index 1461c8f5d..6b9fdaaa6 100644 --- a/Algorithm/data-structure/index.html +++ b/Algorithm/data-structure/index.html @@ -792,9 +792,9 @@

【线
- + - binary-search + dp 上一篇 @@ -802,12 +802,6 @@

【线 diff --git a/Algorithm/dfs-and-similar/index.html b/Algorithm/dfs-and-similar/index.html index b623ad770..ef6f43d59 100644 --- a/Algorithm/dfs-and-similar/index.html +++ b/Algorithm/dfs-and-similar/index.html @@ -857,9 +857,9 @@

【dfs】将
- + - data-structure + a_template 上一篇 @@ -867,8 +867,8 @@

【dfs】将
- - dp + + divide-and-conquer 下一篇 diff --git a/Algorithm/divide-and-conquer/index.html b/Algorithm/divide-and-conquer/index.html index 6cf89a0ae..2f3819536 100644 --- a/Algorithm/divide-and-conquer/index.html +++ b/Algorithm/divide-and-conquer/index.html @@ -425,9 +425,9 @@

【分治】随机排列

- + - a_template + dfs-and-similar 上一篇 @@ -435,8 +435,8 @@

【分治】随机排列

- - binary-search + + dp 下一篇 diff --git a/Algorithm/dp/index.html b/Algorithm/dp/index.html index 545026ecf..7bf9f8993 100644 --- a/Algorithm/dp/index.html +++ b/Algorithm/dp/index.html @@ -979,9 +979,9 @@

【状压dp】Avoid K Palindrome
- + - dfs-and-similar + divide-and-conquer 上一篇 @@ -989,6 +989,12 @@

【状压dp】Avoid K Palindrome diff --git a/Algorithm/games/index.html b/Algorithm/games/index.html index 0bca8277d..6500ef3e3 100644 --- a/Algorithm/games/index.html +++ b/Algorithm/games/index.html @@ -426,9 +426,9 @@

【博弈/贪心/交
- + - geometry + back-end-guide 上一篇 @@ -436,8 +436,8 @@

【博弈/贪心/交
- - hashing + + graphs 下一篇 diff --git a/Algorithm/geometry/index.html b/Algorithm/geometry/index.html index c81407478..0d1ee8f7d 100644 --- a/Algorithm/geometry/index.html +++ b/Algorithm/geometry/index.html @@ -472,9 +472,9 @@

【凸包】奶牛过马路

- + - back-end-guide + number-theory 上一篇 @@ -482,8 +482,8 @@

【凸包】奶牛过马路

- - games + + prefix-and-difference 下一篇 diff --git a/Algorithm/graphs/index.html b/Algorithm/graphs/index.html index d0032bb0b..e74a766ae 100644 --- a/Algorithm/graphs/index.html +++ b/Algorithm/graphs/index.html @@ -694,9 +694,9 @@

【LCA】树的直径

- + - number-theory + games 上一篇 diff --git a/Algorithm/greedy/index.html b/Algorithm/greedy/index.html index 8c18674f5..b1c88b80f 100644 --- a/Algorithm/greedy/index.html +++ b/Algorithm/greedy/index.html @@ -819,8 +819,8 @@

【按位贪心/分类讨论】

- - prefix-and-difference + + hashing 下一篇 diff --git a/Algorithm/hashing/index.html b/Algorithm/hashing/index.html index d972de37d..fe8fba200 100644 --- a/Algorithm/hashing/index.html +++ b/Algorithm/hashing/index.html @@ -466,9 +466,9 @@

【哈希/枚举/思维】T
- + - games + greedy 上一篇 diff --git a/Algorithm/number-theory/index.html b/Algorithm/number-theory/index.html index 1654b35fe..edf6fc8e1 100644 --- a/Algorithm/number-theory/index.html +++ b/Algorithm/number-theory/index.html @@ -484,8 +484,8 @@

【组合数学】序列数量

- - graphs + + geometry 下一篇 diff --git a/Algorithm/prefix-and-difference/index.html b/Algorithm/prefix-and-difference/index.html index c19517c84..efba6876c 100644 --- a/Algorithm/prefix-and-difference/index.html +++ b/Algorithm/prefix-and-difference/index.html @@ -463,9 +463,9 @@

【差分/贪心】增减序列

- + - greedy + geometry 上一篇 diff --git a/BackEnd/back-end-guide/index.html b/BackEnd/back-end-guide/index.html index 3378a59b0..b82aa224a 100644 --- a/BackEnd/back-end-guide/index.html +++ b/BackEnd/back-end-guide/index.html @@ -433,8 +433,8 @@

参考

- - geometry + + games 下一篇 diff --git a/DataBase/data-base-guide/index.html b/DataBase/data-base-guide/index.html index 53a2635bc..871fb5d44 100644 --- a/DataBase/data-base-guide/index.html +++ b/DataBase/data-base-guide/index.html @@ -421,9 +421,9 @@

参考

- + - self-config + solve-clion-decoding-error 上一篇 diff --git a/DevTools/CLion/solve-clion-decoding-error/index.html b/DevTools/CLion/solve-clion-decoding-error/index.html index 22a2baff3..25dc62c8e 100644 --- a/DevTools/CLion/solve-clion-decoding-error/index.html +++ b/DevTools/CLion/solve-clion-decoding-error/index.html @@ -451,9 +451,9 @@

解决方案

- + - git-self-define-command + self-config 上一篇 @@ -461,8 +461,8 @@

解决方案

- - self-config + + data-base-guide 下一篇 diff --git a/DevTools/DevCpp/devc-self-config/index.html b/DevTools/DevCpp/devc-self-config/index.html index 76a8b8c2b..984cd8c16 100644 --- a/DevTools/DevCpp/devc-self-config/index.html +++ b/DevTools/DevCpp/devc-self-config/index.html @@ -452,9 +452,9 @@

四、快捷键选项

- + - solve-clion-decoding-error + git-self-define-command 上一篇 @@ -462,8 +462,8 @@

四、快捷键选项

- - data-base-guide + + solve-clion-decoding-error 下一篇 diff --git a/DevTools/Git/git-self-define-command/index.html b/DevTools/Git/git-self-define-command/index.html index 4a7f0f94e..64c633173 100644 --- a/DevTools/Git/git-self-define-command/index.html +++ b/DevTools/Git/git-self-define-command/index.html @@ -449,8 +449,8 @@

宏定义

- - solve-clion-decoding-error + + self-config 下一篇 diff --git a/GPA/2nd-term/ObjectOrientedClassDesign/index.html b/GPA/2nd-term/ObjectOrientedClassDesign/index.html index 9d92f3b61..3fcf60c27 100644 --- a/GPA/2nd-term/ObjectOrientedClassDesign/index.html +++ b/GPA/2nd-term/ObjectOrientedClassDesign/index.html @@ -437,9 +437,9 @@

代码仓库

- + - LinearAlgebra + DataStructureClassDesign 上一篇 diff --git a/GPA/3rd-term/DataStructure/index.html b/GPA/3rd-term/DataStructure/index.html index 4c42f85d6..04ca78c9c 100644 --- a/GPA/3rd-term/DataStructure/index.html +++ b/GPA/3rd-term/DataStructure/index.html @@ -1415,8 +1415,8 @@

10.8 归并排序

- - DataStructureClassDesign + + LinearAlgebra 下一篇 diff --git a/GPA/3rd-term/DataStructureClassDesign/index.html b/GPA/3rd-term/DataStructureClassDesign/index.html index 4eda3d393..54fb1b882 100644 --- a/GPA/3rd-term/DataStructureClassDesign/index.html +++ b/GPA/3rd-term/DataStructureClassDesign/index.html @@ -601,9 +601,9 @@

仓库地址

- + - DataStructure + DigitalLogicCircuit 上一篇 @@ -611,8 +611,8 @@

仓库地址

- - DigitalLogicCircuit + + ObjectOrientedClassDesign 下一篇 diff --git a/GPA/3rd-term/DigitalLogicCircuit/index.html b/GPA/3rd-term/DigitalLogicCircuit/index.html index 4ee9c207a..e617578c3 100644 --- a/GPA/3rd-term/DigitalLogicCircuit/index.html +++ b/GPA/3rd-term/DigitalLogicCircuit/index.html @@ -1522,9 +1522,9 @@

6.3 用 verilog 描述
- + - DataStructureClassDesign + LinearAlgebra 上一篇 @@ -1532,8 +1532,8 @@

6.3 用 verilog 描述
- - LinearAlgebra + + DataStructureClassDesign 下一篇 diff --git a/GPA/3rd-term/LinearAlgebra/index.html b/GPA/3rd-term/LinearAlgebra/index.html index 183275f32..87825a223 100644 --- a/GPA/3rd-term/LinearAlgebra/index.html +++ b/GPA/3rd-term/LinearAlgebra/index.html @@ -1177,9 +1177,9 @@

行列式角度

- + - DigitalLogicCircuit + DataStructure 上一篇 @@ -1187,8 +1187,8 @@

行列式角度

- - ObjectOrientedClassDesign + + DigitalLogicCircuit 下一篇 diff --git a/GPA/4th-term/MachineLearning/index.html b/GPA/4th-term/MachineLearning/index.html index 364e88929..e98f2562f 100644 --- a/GPA/4th-term/MachineLearning/index.html +++ b/GPA/4th-term/MachineLearning/index.html @@ -104,7 +104,7 @@ - + @@ -398,7 +398,7 @@

MachineLearning

- 本文最后更新于 2024年8月28日 中午 + 本文最后更新于 2024年10月10日 下午

@@ -1300,11 +1300,11 @@

5.2.2 多层感知机

神经网络图例:多层感知机

所谓的多层感知机其实就是增加了一个隐藏层,则神经网络模型就变为三层,含有一个输入层,一个隐藏层,和一个输出层,更准确的说应该是“单隐层网络”。其中隐藏层和输出层中的所有神经元均为功能神经元。

为了学习出网络中的连接权 wiw_i 以及所有功能神经元中的阈值 θj\theta_j,我们需要通过每一次迭代的结果进行参数的修正,对于连接权 wiw_i 而言,我们假设当前感知机的输出为 y^\hat y,则连接权 wiw_i 应做以下调整。其中 η\eta 为学习率。

-

wiwi+ΔwiΔi=η(yy^)xi\begin{aligned} +

wiwi+ΔwiΔwi=η(yy^)xi\begin{aligned} w_i \leftarrow w_i + \Delta w_i \\ -\Delta_i = \eta (y - \hat y) x_i +\Delta w_i = \eta (y - \hat y) x_i \end{aligned} -

+

-

SQL

-

安全性

-

完整性

+

2.4 关系代数

+

用符号表示所有的关系运算逻辑有助于在理论上进行化简,从而降低计算开销。

+
传统的集合运算
+

\cap、差 -、交 \cup 和笛卡尔积 ×\times 都是针对两个关系中相同类型的属性组进行的集合运算。除了差,其余运算都有交换律。

+
专门的关系运算
+

先补充几个必要的符号表示:

+
    +
  • 元组。在关系 R 中,tRt \in R 表示 t 是关系 R 的一个元组。t[Ai]t[A_i] 表示元组在 AiA_i 属性上的分量。
  • +
  • 取反。针对属性集合 X,取反就是属性全集 U 和属性集合 X 的差。
  • +
  • 串接。将两个元组左右连接。
  • +
  • 象集。对于关系 R(A1,A2)R(A_1,A_2)A1A_1 的象集就是 A1A_1 所有取值对应的 A2A_2 取值集合。
  • +
+

选择 σF(R)\sigma_F(R)。筛选出关系 R 中符合条件 F(Filter)F\text{(Filter)} 的行。FF 按照优先级分别为:()>θ>¬>>()>\theta>\lnot>\land>\lor。其中 θ={>,<,,,=,}\theta=\{ >,<,\ge,\le,=,\ne \}

+

选择

+

投影 ΠA(R)\Pi_A(R)。筛选出关系 R 中含有属性集合 A(Attribute)A\text{(Attribute)} 的列。筛完后可能需要再进一步删除重复的行。

+

投影

+

连接 RSR \Join S。筛选出两个关系 R 和 S 的笛卡尔积 R×SR\times S 中 R 的属性 A 和 S 的属性 S 符合条件 θ\theta 的行。

+

连接

+
    +
  • 一般连接。就是上述所述。
  • +
  • 左外连接。当 R 的属性 A 的取值不在 S 的 B 中时,在结果中保留 R 的结果,S 对应的值填 NULL。
  • +
  • 右外连接。当 S 的属性 B 的取值不在 R 的 A 中时,在结果中保留 S 的结果,R 对应的值填 NULL。
  • +
+

除法 R÷SR \div S 。对于两个关系 R(X,Y)R(X,Y)S(Y,Z)S(Y,Z)。找到 R 符合「R(X)R(X) 包含 S(X)S(X)」的元组在 X 上的投影。

+

除法

+

3 SQL

+

4 安全性

+

5 完整性

开发篇

关系数据理论

数据库设计

@@ -554,7 +586,7 @@

并发

更新于
-
2024年9月26日
+
2024年10月10日
diff --git a/GPA/5th-term/NeuralNetworkAndDeepLearning/index.html b/GPA/5th-term/NeuralNetworkAndDeepLearning/index.html index db9812c51..7999c5a76 100644 --- a/GPA/5th-term/NeuralNetworkAndDeepLearning/index.html +++ b/GPA/5th-term/NeuralNetworkAndDeepLearning/index.html @@ -28,7 +28,7 @@ - + @@ -247,7 +247,7 @@ @@ -322,7 +322,7 @@

NeuralNetworkAndDeepLearning

- 本文最后更新于 2024年10月8日 凌晨 + 本文最后更新于 2024年10月10日 下午

@@ -420,10 +420,15 @@

线性模型

优化算法:最小二乘法、梯度下降法、拟牛顿法。

有监督学习

-

基础神经网络模型

-

前馈神经网络

-

卷积神经网络

-

循环神经网络

+

1 基础神经网络模型

+

1.1 前馈神经网络

+

神经元中的激活函数:

+
    +
  • Sigmoid 函数:例如 logistic 函数和 Tanh 函数。
  • +
  • Relu 函数
  • +
+

1.2 卷积神经网络

+

1.3 循环神经网络

记忆与注意力机制

网络优化与正则化

无监督学习

@@ -502,7 +507,7 @@

个人大作业

更新于
-
2024年10月8日
+
2024年10月10日
diff --git a/README/index.html b/README/index.html index 4ca6497e4..93afa74f4 100644 --- a/README/index.html +++ b/README/index.html @@ -450,8 +450,8 @@

未来展望

diff --git a/archives/2024/03/page/2/index.html b/archives/2024/03/page/2/index.html index f24dff2be..faae61dc7 100644 --- a/archives/2024/03/page/2/index.html +++ b/archives/2024/03/page/2/index.html @@ -248,9 +248,9 @@

2024

- + -
ProbAndStat
+
PyAlgo
@@ -272,9 +272,9 @@ - + -
DataStructureClassDesign
+
LinearAlgebra
@@ -284,9 +284,9 @@ - + -
LinearAlgebra
+
DataStructureClassDesign
diff --git a/archives/2024/03/page/3/index.html b/archives/2024/03/page/3/index.html index aefa72d85..09f5763a4 100644 --- a/archives/2024/03/page/3/index.html +++ b/archives/2024/03/page/3/index.html @@ -272,15 +272,15 @@ - + -
solve-clion-decoding-error
+
self-config
- + -
self-config
+
solve-clion-decoding-error
diff --git a/archives/2024/03/page/4/index.html b/archives/2024/03/page/4/index.html index db69a353f..f99a4bdb0 100644 --- a/archives/2024/03/page/4/index.html +++ b/archives/2024/03/page/4/index.html @@ -248,39 +248,39 @@

2024

- + -
geometry
+
games
- + -
games
+
graphs
- + -
hashing
+
greedy
- + -
number-theory
+
hashing
- + -
graphs
+
number-theory
- + -
greedy
+
geometry
@@ -296,15 +296,15 @@ - + -
a_template
+
binary-search
- + -
divide-and-conquer
+
a_template
diff --git a/archives/2024/03/page/5/index.html b/archives/2024/03/page/5/index.html index 5054a0334..3f0fcd50b 100644 --- a/archives/2024/03/page/5/index.html +++ b/archives/2024/03/page/5/index.html @@ -248,27 +248,27 @@

2024

- + -
binary-search
+
dfs-and-similar
- + -
data-structure
+
divide-and-conquer
- + -
dfs-and-similar
+
dp
- + -
dp
+
data-structure
diff --git a/archives/2024/page/5/index.html b/archives/2024/page/5/index.html index 120457577..66a4d1f1c 100644 --- a/archives/2024/page/5/index.html +++ b/archives/2024/page/5/index.html @@ -290,15 +290,15 @@ - + -
PyAlgo
+
ProbAndStat
- + -
ProbAndStat
+
PyAlgo
diff --git a/archives/2024/page/6/index.html b/archives/2024/page/6/index.html index 5c604e32c..3aa18e6d7 100644 --- a/archives/2024/page/6/index.html +++ b/archives/2024/page/6/index.html @@ -260,9 +260,9 @@ - + -
DataStructureClassDesign
+
LinearAlgebra
@@ -272,9 +272,9 @@ - + -
LinearAlgebra
+
DataStructureClassDesign
diff --git a/archives/2024/page/7/index.html b/archives/2024/page/7/index.html index 782d1fabf..8243ca392 100644 --- a/archives/2024/page/7/index.html +++ b/archives/2024/page/7/index.html @@ -260,15 +260,15 @@ - + -
solve-clion-decoding-error
+
self-config
- + -
self-config
+
solve-clion-decoding-error
@@ -296,15 +296,15 @@ - + -
geometry
+
games
- + -
games
+
graphs
diff --git a/archives/2024/page/8/index.html b/archives/2024/page/8/index.html index 47adf790b..4ba332173 100644 --- a/archives/2024/page/8/index.html +++ b/archives/2024/page/8/index.html @@ -248,27 +248,27 @@

2024

- + -
hashing
+
greedy
- + -
number-theory
+
hashing
- + -
graphs
+
number-theory
- + -
greedy
+
geometry
@@ -284,27 +284,27 @@ - + -
a_template
+
binary-search
- + -
divide-and-conquer
+
a_template
- + -
binary-search
+
dfs-and-similar
- + -
data-structure
+
divide-and-conquer
diff --git a/archives/2024/page/9/index.html b/archives/2024/page/9/index.html index 8790cc335..842356af0 100644 --- a/archives/2024/page/9/index.html +++ b/archives/2024/page/9/index.html @@ -248,15 +248,15 @@

2024

- + -
dfs-and-similar
+
dp
- + -
dp
+
data-structure
diff --git a/archives/page/5/index.html b/archives/page/5/index.html index 5ca7a5456..a6acfe70a 100644 --- a/archives/page/5/index.html +++ b/archives/page/5/index.html @@ -290,15 +290,15 @@ - + -
PyAlgo
+
ProbAndStat
- + -
ProbAndStat
+
PyAlgo
diff --git a/archives/page/6/index.html b/archives/page/6/index.html index e3de1101d..6ed0c14a7 100644 --- a/archives/page/6/index.html +++ b/archives/page/6/index.html @@ -260,9 +260,9 @@ - + -
DataStructureClassDesign
+
LinearAlgebra
@@ -272,9 +272,9 @@ - + -
LinearAlgebra
+
DataStructureClassDesign
diff --git a/archives/page/7/index.html b/archives/page/7/index.html index 2366ae904..88ef0b241 100644 --- a/archives/page/7/index.html +++ b/archives/page/7/index.html @@ -260,15 +260,15 @@ - + -
solve-clion-decoding-error
+
self-config
- + -
self-config
+
solve-clion-decoding-error
@@ -296,15 +296,15 @@ - + -
geometry
+
games
- + -
games
+
graphs
diff --git a/archives/page/8/index.html b/archives/page/8/index.html index b189179b5..e73aa01c9 100644 --- a/archives/page/8/index.html +++ b/archives/page/8/index.html @@ -248,27 +248,27 @@

2024

- + -
hashing
+
greedy
- + -
number-theory
+
hashing
- + -
graphs
+
number-theory
- + -
greedy
+
geometry
@@ -284,27 +284,27 @@ - + -
a_template
+
binary-search
- + -
divide-and-conquer
+
a_template
- + -
binary-search
+
dfs-and-similar
- + -
data-structure
+
divide-and-conquer
diff --git a/archives/page/9/index.html b/archives/page/9/index.html index 184d679f7..c916f215d 100644 --- a/archives/page/9/index.html +++ b/archives/page/9/index.html @@ -248,15 +248,15 @@

2024

- + -
dfs-and-similar
+
dp
- + -
dp
+
data-structure
diff --git a/categories/Algorithm/index.html b/categories/Algorithm/index.html index c61a11d4a..85ac49c79 100644 --- a/categories/Algorithm/index.html +++ b/categories/Algorithm/index.html @@ -254,39 +254,39 @@ - + -
geometry
+
games
- + -
games
+
graphs
- + -
hashing
+
greedy
- + -
number-theory
+
hashing
- + -
graphs
+
number-theory
- + -
greedy
+
geometry
@@ -296,15 +296,15 @@ - + -
a_template
+
binary-search
- + -
divide-and-conquer
+
a_template
diff --git a/categories/Algorithm/page/2/index.html b/categories/Algorithm/page/2/index.html index 9d33edc2d..826aca71f 100644 --- a/categories/Algorithm/page/2/index.html +++ b/categories/Algorithm/page/2/index.html @@ -248,27 +248,27 @@

2024

- + -
binary-search
+
dfs-and-similar
- + -
data-structure
+
divide-and-conquer
- + -
dfs-and-similar
+
dp
- + -
dp
+
data-structure
diff --git a/categories/DevTools/index.html b/categories/DevTools/index.html index e6644197a..5228e0f5f 100644 --- a/categories/DevTools/index.html +++ b/categories/DevTools/index.html @@ -290,15 +290,15 @@ - + -
solve-clion-decoding-error
+
self-config
- + -
self-config
+
solve-clion-decoding-error
diff --git a/categories/GPA/3rd-term/index.html b/categories/GPA/3rd-term/index.html index 0a36936e2..de3d5d6ee 100644 --- a/categories/GPA/3rd-term/index.html +++ b/categories/GPA/3rd-term/index.html @@ -260,9 +260,9 @@ - + -
DataStructureClassDesign
+
LinearAlgebra
@@ -272,9 +272,9 @@ - + -
LinearAlgebra
+
DataStructureClassDesign
diff --git a/categories/GPA/4th-term/index.html b/categories/GPA/4th-term/index.html index 2a964b74f..f47c5e107 100644 --- a/categories/GPA/4th-term/index.html +++ b/categories/GPA/4th-term/index.html @@ -272,15 +272,15 @@ - + -
PyAlgo
+
ProbAndStat
- + -
ProbAndStat
+
PyAlgo
diff --git a/categories/GPA/page/2/index.html b/categories/GPA/page/2/index.html index 1c3003054..2bd298fae 100644 --- a/categories/GPA/page/2/index.html +++ b/categories/GPA/page/2/index.html @@ -260,15 +260,15 @@ - + -
PyAlgo
+
ProbAndStat
- + -
ProbAndStat
+
PyAlgo
@@ -290,9 +290,9 @@ - + -
DataStructureClassDesign
+
LinearAlgebra
@@ -302,9 +302,9 @@ - + -
LinearAlgebra
+
DataStructureClassDesign
diff --git a/local-search.xml b/local-search.xml index 38bf1abba..ab512c20a 100644 --- a/local-search.xml +++ b/local-search.xml @@ -514,7 +514,7 @@ /GPA/5th-term/NeuralNetworkAndDeepLearning/ - 神经网络与深度学习

前言

学科地位:

主讲教师学分配额学科类别
宋歌3自发课

成绩组成:

小组作业个人大作业
40%60%

教材情况:

课程名称选用教材版次作者出版社ISBN 号
神经网络与深度学习《神经网络与深度学习》1邱锡鹏机械工业出版社978-7-111-64968-7

学习资源:

为什么要学这门课?

还记得 NJU 的 jyy 老师在上 OS 时说过的一句话,“我们现在学习的微积分是 300 年前的人类智慧结晶,何不再学学 50 年前的人类智慧结晶呢?”印象深刻。当一切都可以用层层嵌套的简单函数模拟时,人类社会必将发生翻天覆地的变化!

会收获什么?

如何学习特征?如何优化调参?为什么要这样做?背后的原理是什么?

概述

绪论

表示学习是什么?与传统的特征工程目的一致,为了得到数据中的更好的特征。不同的是,特征工程中的策略都是可控的方式,而表示学习就是利用深度学习从数据中学习高层的有效特征。

深度学习是什么?我们知道机器学习就是在手动处理完特征后,构建对应的模型 预测输出。而深度学习就是将机器学习的手动特征工程也用模型进行 表示学习 来学习出有效特征,然后继续构建模型 预测输出。如下图所示:

深度学习的数据处理流程

为什么会有深度学习?最简单的一点就是,很多特征我们根本没法定义一种表示规则来表示特征,比如说对于图像,怎么定义复杂的图像的特征呢?比如说对于音频,又怎么定义复杂的音频的特征呢?没办法,我们直接学特征!

神经网络是什么?就是万千模型中的一种,仅此而已。

为什么用神经网络进行深度学习?有了上面对深度学习定义的理解,可以发现其中最具有挑战性的特点就是,模型怎么知道什么才是好特征?什么是不好的特征?神经网络可以很好的解决这个问题。通过由浅到深层层神经元的特征提取,越深的神经元就可以学习更高语义的特征。说的高大上一点就是,神经网络可以很好的解决深度学习中的「贡献度分配」问题。

机器学习概述

数据 \to 模型 \to 学习准则 \to 优化算法

线性模型

学习任务:分类、回归。

学习准则:

  • 经验风险最小化:通过 最小化训练集上的损失函数 来学习模型参数。例如:线性回归中的最小二乘损失。
  • 结构风险最小化:引入 正则化 来控制模型的复杂度,避免过拟合。
  • 最大似然估计:通过 最大化模型在给定数据上的似然函数 来估计模型参数。例如二分类 logistic 和多分类 softmax 中的交叉熵损失。
  • 最大后验估计:结合了似然函数和 先验 分布,在贝叶斯框架下估计模型参数。

优化算法:最小二乘法、梯度下降法、拟牛顿法。

有监督学习

基础神经网络模型

前馈神经网络

卷积神经网络

循环神经网络

记忆与注意力机制

网络优化与正则化

无监督学习

概率图模型

深度生成模型

强化学习

深度强化学习

个人大作业

  1. 背景介绍:做好国内外研究现状的调研。
  2. 项目理解:深入对模型与代码的理解。
  3. 充分实验:对比实验、验证实验(消融实验)、参数实验(验证模型对不同的参数的敏感性)。
  4. 项目总结。
]]> + 神经网络与深度学习

前言

学科地位:

主讲教师学分配额学科类别
宋歌3自发课

成绩组成:

小组作业个人大作业
40%60%

教材情况:

课程名称选用教材版次作者出版社ISBN 号
神经网络与深度学习《神经网络与深度学习》1邱锡鹏机械工业出版社978-7-111-64968-7

学习资源:

为什么要学这门课?

还记得 NJU 的 jyy 老师在上 OS 时说过的一句话,“我们现在学习的微积分是 300 年前的人类智慧结晶,何不再学学 50 年前的人类智慧结晶呢?”印象深刻。当一切都可以用层层嵌套的简单函数模拟时,人类社会必将发生翻天覆地的变化!

会收获什么?

如何学习特征?如何优化调参?为什么要这样做?背后的原理是什么?

概述

绪论

表示学习是什么?与传统的特征工程目的一致,为了得到数据中的更好的特征。不同的是,特征工程中的策略都是可控的方式,而表示学习就是利用深度学习从数据中学习高层的有效特征。

深度学习是什么?我们知道机器学习就是在手动处理完特征后,构建对应的模型 预测输出。而深度学习就是将机器学习的手动特征工程也用模型进行 表示学习 来学习出有效特征,然后继续构建模型 预测输出。如下图所示:

深度学习的数据处理流程

为什么会有深度学习?最简单的一点就是,很多特征我们根本没法定义一种表示规则来表示特征,比如说对于图像,怎么定义复杂的图像的特征呢?比如说对于音频,又怎么定义复杂的音频的特征呢?没办法,我们直接学特征!

神经网络是什么?就是万千模型中的一种,仅此而已。

为什么用神经网络进行深度学习?有了上面对深度学习定义的理解,可以发现其中最具有挑战性的特点就是,模型怎么知道什么才是好特征?什么是不好的特征?神经网络可以很好的解决这个问题。通过由浅到深层层神经元的特征提取,越深的神经元就可以学习更高语义的特征。说的高大上一点就是,神经网络可以很好的解决深度学习中的「贡献度分配」问题。

机器学习概述

数据 \to 模型 \to 学习准则 \to 优化算法

线性模型

学习任务:分类、回归。

学习准则:

  • 经验风险最小化:通过 最小化训练集上的损失函数 来学习模型参数。例如:线性回归中的最小二乘损失。
  • 结构风险最小化:引入 正则化 来控制模型的复杂度,避免过拟合。
  • 最大似然估计:通过 最大化模型在给定数据上的似然函数 来估计模型参数。例如二分类 logistic 和多分类 softmax 中的交叉熵损失。
  • 最大后验估计:结合了似然函数和 先验 分布,在贝叶斯框架下估计模型参数。

优化算法:最小二乘法、梯度下降法、拟牛顿法。

有监督学习

1 基础神经网络模型

1.1 前馈神经网络

神经元中的激活函数:

  • Sigmoid 函数:例如 logistic 函数和 Tanh 函数。
  • Relu 函数

1.2 卷积神经网络

1.3 循环神经网络

记忆与注意力机制

网络优化与正则化

无监督学习

概率图模型

深度生成模型

强化学习

深度强化学习

个人大作业

  1. 背景介绍:做好国内外研究现状的调研。
  2. 项目理解:深入对模型与代码的理解。
  3. 充分实验:对比实验、验证实验(消融实验)、参数实验(验证模型对不同的参数的敏感性)。
  4. 项目总结。
]]> @@ -535,7 +535,7 @@ /GPA/5th-term/DataBase/ - 数据库

前言

学科地位:

主讲教师学分配额学科类别
孔力3.5自发课

成绩组成:

平时作业+实验期末(闭卷 or 开卷)
10%40%50%

教材情况:

课程名称选用教材版次作者出版社ISBN 号
数据库原理与应用《数据库系统概论》6王珊等高等教育出版社978-7-04-059125-5

学习资源:

为什么要学这门课?

感觉没什么意义,毕竟只讲关系型数据库,而这个是再简单不过的数据模型了,哪怕加上了外键也不会很复杂。但我深知 db 的奥妙与深度绝不止于此,但是就目前上课情况来看应该是达不到这种程度了,毕竟连最基本的数据库编程都是不做要求的。

会收获什么?

熟悉一下关系型数据库的理论吧,顺便准备好被国产的 openGuass ex 一把。其余的数据库类型以及拓展知识就靠自学吧。

基础篇

绪论

数据库发展范式:人工系统 \to 文件系统 \to 数据库系统

数据库系统概念图:

---title: 数据库系统---graph TB    DBM[数据库管理员]    subgraph 数据    DB[(数据库\n「内模式」)]    DBMS[数据库管理系统\n「逻辑模式」]    subgraph DBAP[应用程序]    direction LR    out_model1[「外模式1」]    out_model2[「外模式2」]    out_model1 <-.-> 应用A    out_model1 <-.-> 应用B    out_model2 <-.-> 应用C    end    end    DBM --> 数据    DB <-.-> DBMS    DBMS <-.-> DBAP

数据库的三级模式:从硬件到应用将一个数据库抽象为三层。其中:

  • 内模式:硬件存储数据的方式;
  • 逻辑模式:数据库管理的方式,也就是数据存储方式的 逻辑 抽象。进而引出后续如何存储数据的「数据模型」概念;
  • 外模式:不同的应用对全体数据有不同的访存权限。一个外模式可以对应多个应用程序,但是一个应用程序只能对应一个外模式。

数据模型:逻辑模式的具体实现策略。我们需要对现实世界的数据进行抽象进而便于虚拟化存储,以及后续对数据库进行增删改查等操作。从发展角度来看,数据模型一共经历了三个阶段,分别为 层次模型 \to 网状模型 \to 关系模型 三个阶段。其中关系模型是数据库「逻辑模式」的实现方式,有以下两个关键点:

  • 三要素:数据结构(二维表)、数据操作(增删改查)和关系的完整性约束(每一个实体能通过主键唯一检索、外键引用必须存在、关系要根据业务需求设定完备)

  • 名词对照:关系(一张表)、元组(一行数据)、属性(一个字段)和码(主键)。

关系模型

基本概念:

基本概念图

关系操作的最小单位是什么?所有的增删改查都是以集合为最小单位。

外键是什么?有什么用?一张表的外键必须是另一张表的主键以确保数据的完整性,因为主键是必须全部存在的。当然,外键也可以引用本表的主键。有了外键就可以实现表与表之间一对多或多对多的关系。

  • 关于一对多。例如,一个客户可以购买很多商品,而一个被购买的商品只能隶属于一个客户;

    假设有两个表:customersorderscustomers 表保存客户的信息,orders 表保存订单信息。每个订单都应该对应一个客户,所以可以使用外键将这两个表关联起来。

    创建表

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    -- 创建 customers 表
    CREATE TABLE customers (
    customer_id INT PRIMARY KEY,
    customer_name VARCHAR(100)
    );

    -- 创建 orders 表,order_id 是主键,customer_id 是外键,引用 customers 表中的 customer_id
    CREATE TABLE orders (
    order_id INT PRIMARY KEY,
    order_date DATE,
    customer_id INT,
    FOREIGN KEY (customer_id) REFERENCES customers(customer_id)
    );

    在上述示例中,orders 表中的 customer_id 是一个外键,它引用了 customers 表中的 customer_id 列。这样一来,只有在 customers 表中存在的 customer_id 才能在 orders 表中作为合法的 customer_id。这确保了每个订单都有有效的客户。

    插入数据(增):当向 orders 表中插入一条新记录时,数据库会检查 customer_id 是否在 customers 表中存在。如果不存在,插入操作会失败。

    1
    2
    3
    4
    5
    6
    7
    8
    -- 插入一条合法记录
    INSERT INTO customers (customer_id, customer_name) VALUES (1, 'Alice');

    -- 插入订单记录,外键 customer_id 存在于 customers 表中
    INSERT INTO orders (order_id, order_date, customer_id) VALUES (101, '2024-09-26', 1);

    -- 尝试插入一个无效的订单记录(客户ID为2在 customers 表中不存在)
    INSERT INTO orders (order_id, order_date, customer_id) VALUES (102, '2024-09-26', 2); -- 会失败

    删除或更新时(删 | 改):如果试图删除 customers 表中的某个 customer_id,而该 ID 在 orders 表中被引用,会引发外键约束错误。可以通过设置外键的级联操作(如 ON DELETE CASCADEON UPDATE CASCADE)来控制这种行为,使删除或更新操作可以自动级联到相关表。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    -- 定义外键时使用级联删除
    CREATE TABLE orders (
    order_id INT PRIMARY KEY,
    order_date DATE,
    customer_id INT,
    FOREIGN KEY (customer_id) REFERENCES customers(customer_id) ON DELETE CASCADE
    );

    -- 删除客户1时,orders 表中所有引用 customer_id = 1 的订单都会自动被删除
    DELETE FROM customers WHERE customer_id = 1;
  • 关于多对多。例如,一门课程需要多门先修课,而一门课程也可以成为很多课程的选修课。

    这里课程与先修课之间的实体对象是相同的,并且一门课程可以有许多门先修课,而一门课程也可能成为很多课程的先修课,如果还是像一对多那样进行存储,即课程表中增加一列先修课,就会导致先修课列中可能出现多个信息的情况,这也就不符合关系型数据库中的第一范式:原子性。因此我们不得不创建一个新表,也就是中间表,来存储课程和先修课之间的关系。

    创建表

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    -- 创建 courses 表
    CREATE TABLE courses (
    course_id INT PRIMARY KEY,
    course_name VARCHAR(100)
    );

    -- 创建 prerequisites 表,其中 course_id 和 prerequisite_id 都是外键
    CREATE TABLE prerequisites (
    course_id INT, -- 课程ID
    prerequisite_id INT, -- 先修课ID
    PRIMARY KEY (course_id, prerequisite_id), -- 组合主键
    FOREIGN KEY (course_id) REFERENCES courses(course_id) ON DELETE CASCADE, -- 课程ID外键
    FOREIGN KEY (prerequisite_id) REFERENCES courses(course_id) ON DELETE CASCADE -- 先修课ID外键
    );

    在这个设计中:

    • courses 表保存所有课程的基本信息,每门课程都有唯一的 course_id
    • prerequisites 表用于表示多对多的先修课关系,其中 course_id 是当前课程的 ID,prerequisite_id 是该课程的先修课程的 ID。两者都引用了 courses 表中的 course_id 列,构成多对多的关系。

    插入数据(增)

    插入课程记录:

    1
    2
    3
    4
    5
    -- 插入一些课程记录
    INSERT INTO courses (course_id, course_name) VALUES (1, 'Mathematics');
    INSERT INTO courses (course_id, course_name) VALUES (2, 'Physics');
    INSERT INTO courses (course_id, course_name) VALUES (3, 'Computer Science');
    INSERT INTO courses (course_id, course_name) VALUES (4, 'Linear Algebra');

    插入先修课关系记录:

    1
    2
    3
    4
    5
    6
    -- 课程 Computer Science 需要 Mathematics 和 Physics 作为先修课
    INSERT INTO prerequisites (course_id, prerequisite_id) VALUES (3, 1); -- Computer Science -> Mathematics
    INSERT INTO prerequisites (course_id, prerequisite_id) VALUES (3, 2); -- Computer Science -> Physics

    -- 课程 Physics 需要 Linear Algebra 作为先修课
    INSERT INTO prerequisites (course_id, prerequisite_id) VALUES (2, 4); -- Physics -> Linear Algebra

    在上述例子中:

    • prerequisites 表表示课程之间的先修关系。例如,Computer Science 课程的 course_id 是 3,它的先修课是 Mathematicsprerequisite_id 为 1)和 Physicsprerequisite_id 为 2)。
    • 通过这种方式,可以表示一门课程有多门先修课,同时一门课程也可以作为多门课程的先修课。
    • 插入的 course_idprerequisite_id 必须都是 courses 表中已有的课程,否则会插入失败。

    删除数据(删)

    由于在创建表时设置了级联操作 ON DELETE CASCADE,因此当我们删除任何一个存在的课程时,与该课程相关的所有先修课信息也都会被级联删除。无论该课程是其他课程的先修课,还是它自身有先修课。

SQL

安全性

完整性

开发篇

关系数据理论

数据库设计

数据库编程 *

这一章不作考试要求。樂。

进阶篇

关系数据库存储管理

关系查询

怎么查询关系的?又可以怎么优化查询策略呢?

数据库恢复技术

并发

]]> + 数据库

前言

学科地位:

主讲教师学分配额学科类别
孔力3.5自发课

成绩组成:

平时作业+实验期末(闭卷 or 开卷)
10%40%50%

教材情况:

课程名称选用教材版次作者出版社ISBN 号
数据库原理与应用《数据库系统概论》6王珊等高等教育出版社978-7-04-059125-5

学习资源:

为什么要学这门课?

感觉没什么意义,毕竟只讲关系型数据库,而这个是再简单不过的数据模型了,哪怕加上了外键也不会很复杂。但我深知 db 的奥妙与深度绝不止于此,但是就目前上课情况来看应该是达不到这种程度了,毕竟连最基本的数据库编程都是不做要求的。

会收获什么?

熟悉一下关系型数据库的理论吧,顺便准备好被国产的 openGuass ex 一把。其余的数据库类型以及拓展知识就靠自学吧。

基础篇

1 绪论

数据库发展范式:人工系统 \to 文件系统 \to 数据库系统

数据库系统概念图:

---title: 数据库系统---graph TB    DBM[数据库管理员]    subgraph 数据    DB[(数据库\n「内模式」)]    DBMS[数据库管理系统\n「逻辑模式」]    subgraph DBAP[应用程序]    direction LR    out_model1[「外模式1」]    out_model2[「外模式2」]    out_model1 <-.-> 应用A    out_model1 <-.-> 应用B    out_model2 <-.-> 应用C    end    end    DBM --> 数据    DB <-.-> DBMS    DBMS <-.-> DBAP

数据库的三级模式:从硬件到应用将一个数据库抽象为三层。其中:

  • 内模式:硬件存储数据的方式;
  • 逻辑模式:数据库管理的方式,也就是数据存储方式的 逻辑 抽象。进而引出后续如何存储数据的「数据模型」概念;
  • 外模式:不同的应用对全体数据有不同的访存权限。一个外模式可以对应多个应用程序,但是一个应用程序只能对应一个外模式。

数据模型:逻辑模式的具体实现策略。我们需要对现实世界的数据进行抽象进而便于虚拟化存储,以及后续对数据库进行增删改查等操作。从发展角度来看,数据模型一共经历了三个阶段,分别为 层次模型 \to 网状模型 \to 关系模型 三个阶段。其中关系模型是数据库「逻辑模式」的实现方式,有以下两个关键点:

  • 三要素:数据结构(二维表)、数据操作(增删改查)和关系的完整性约束(每一个实体能通过主键唯一检索、外键引用必须存在、关系要根据业务需求设定完备)

  • 名词对照:关系(一张表)、元组(一行数据)、属性(一个字段)和码(主键)。

2 关系模型

2.1 基本概念

基本概念:R=(A1:D1,A2:D2,...,An:Dn,)R = (A_1:D_1,A_2:D_2,...,A_n:D_n,)。其中 R 表示关系,A 表示属性,D 表示域。

基本概念图

2.2 关系操作

关系操作的最小单位是什么?所有的增删改查都是以集合为最小单位。

2.3 关系的完整性

外键是什么?有什么用?一张表的外键必须是另一张表的主键以确保数据的完整性,因为主键是必须全部存在的。当然,外键也可以引用本表的主键。有了外键就可以实现表与表之间一对多或多对多的关系。

  • 关于一对多。例如,一个客户可以购买很多商品,而一个被购买的商品只能隶属于一个客户;

    假设有两个表:customersorderscustomers 表保存客户的信息,orders 表保存订单信息。每个订单都应该对应一个客户,所以可以使用外键将这两个表关联起来。

    创建表

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    -- 创建 customers 表
    CREATE TABLE customers (
    customer_id INT PRIMARY KEY,
    customer_name VARCHAR(100)
    );

    -- 创建 orders 表,order_id 是主键,customer_id 是外键,引用 customers 表中的 customer_id
    CREATE TABLE orders (
    order_id INT PRIMARY KEY,
    order_date DATE,
    customer_id INT,
    FOREIGN KEY (customer_id) REFERENCES customers(customer_id)
    );

    在上述示例中,orders 表中的 customer_id 是一个外键,它引用了 customers 表中的 customer_id 列。这样一来,只有在 customers 表中存在的 customer_id 才能在 orders 表中作为合法的 customer_id。这确保了每个订单都有有效的客户。

    插入数据(增):当向 orders 表中插入一条新记录时,数据库会检查 customer_id 是否在 customers 表中存在。如果不存在,插入操作会失败。

    1
    2
    3
    4
    5
    6
    7
    8
    -- 插入一条合法记录
    INSERT INTO customers (customer_id, customer_name) VALUES (1, 'Alice');

    -- 插入订单记录,外键 customer_id 存在于 customers 表中
    INSERT INTO orders (order_id, order_date, customer_id) VALUES (101, '2024-09-26', 1);

    -- 尝试插入一个无效的订单记录(客户ID为2在 customers 表中不存在)
    INSERT INTO orders (order_id, order_date, customer_id) VALUES (102, '2024-09-26', 2); -- 会失败

    删除或更新时(删 | 改):如果试图删除 customers 表中的某个 customer_id,而该 ID 在 orders 表中被引用,会引发外键约束错误。可以通过设置外键的级联操作(如 ON DELETE CASCADEON UPDATE CASCADE)来控制这种行为,使删除或更新操作可以自动级联到相关表。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    -- 定义外键时使用级联删除
    CREATE TABLE orders (
    order_id INT PRIMARY KEY,
    order_date DATE,
    customer_id INT,
    FOREIGN KEY (customer_id) REFERENCES customers(customer_id) ON DELETE CASCADE
    );

    -- 删除客户1时,orders 表中所有引用 customer_id = 1 的订单都会自动被删除
    DELETE FROM customers WHERE customer_id = 1;
  • 关于多对多。例如,一门课程需要多门先修课,而一门课程也可以成为很多课程的选修课。

    这里课程与先修课之间的实体对象是相同的,并且一门课程可以有许多门先修课,而一门课程也可能成为很多课程的先修课,如果还是像一对多那样进行存储,即课程表中增加一列先修课,就会导致先修课列中可能出现多个信息的情况,这也就不符合关系型数据库中的第一范式:原子性。因此我们不得不创建一个新表,也就是中间表,来存储课程和先修课之间的关系。

    创建表

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    -- 创建 courses 表
    CREATE TABLE courses (
    course_id INT PRIMARY KEY,
    course_name VARCHAR(100)
    );

    -- 创建 prerequisites 表,其中 course_id 和 prerequisite_id 都是外键
    CREATE TABLE prerequisites (
    course_id INT, -- 课程ID
    prerequisite_id INT, -- 先修课ID
    PRIMARY KEY (course_id, prerequisite_id), -- 组合主键
    FOREIGN KEY (course_id) REFERENCES courses(course_id) ON DELETE CASCADE, -- 课程ID外键
    FOREIGN KEY (prerequisite_id) REFERENCES courses(course_id) ON DELETE CASCADE -- 先修课ID外键
    );

    在这个设计中:

    • courses 表保存所有课程的基本信息,每门课程都有唯一的 course_id
    • prerequisites 表用于表示多对多的先修课关系,其中 course_id 是当前课程的 ID,prerequisite_id 是该课程的先修课程的 ID。两者都引用了 courses 表中的 course_id 列,构成多对多的关系。

    插入数据(增)

    插入课程记录:

    1
    2
    3
    4
    5
    -- 插入一些课程记录
    INSERT INTO courses (course_id, course_name) VALUES (1, 'Mathematics');
    INSERT INTO courses (course_id, course_name) VALUES (2, 'Physics');
    INSERT INTO courses (course_id, course_name) VALUES (3, 'Computer Science');
    INSERT INTO courses (course_id, course_name) VALUES (4, 'Linear Algebra');

    插入先修课关系记录:

    1
    2
    3
    4
    5
    6
    -- 课程 Computer Science 需要 Mathematics 和 Physics 作为先修课
    INSERT INTO prerequisites (course_id, prerequisite_id) VALUES (3, 1); -- Computer Science -> Mathematics
    INSERT INTO prerequisites (course_id, prerequisite_id) VALUES (3, 2); -- Computer Science -> Physics

    -- 课程 Physics 需要 Linear Algebra 作为先修课
    INSERT INTO prerequisites (course_id, prerequisite_id) VALUES (2, 4); -- Physics -> Linear Algebra

    在上述例子中:

    • prerequisites 表表示课程之间的先修关系。例如,Computer Science 课程的 course_id 是 3,它的先修课是 Mathematicsprerequisite_id 为 1)和 Physicsprerequisite_id 为 2)。
    • 通过这种方式,可以表示一门课程有多门先修课,同时一门课程也可以作为多门课程的先修课。
    • 插入的 course_idprerequisite_id 必须都是 courses 表中已有的课程,否则会插入失败。

    删除数据(删)

    由于在创建表时设置了级联操作 ON DELETE CASCADE,因此当我们删除任何一个存在的课程时,与该课程相关的所有先修课信息也都会被级联删除。无论该课程是其他课程的先修课,还是它自身有先修课。

2.4 关系代数

用符号表示所有的关系运算逻辑有助于在理论上进行化简,从而降低计算开销。

传统的集合运算

\cap、差 -、交 \cup 和笛卡尔积 ×\times 都是针对两个关系中相同类型的属性组进行的集合运算。除了差,其余运算都有交换律。

专门的关系运算

先补充几个必要的符号表示:

  • 元组。在关系 R 中,tRt \in R 表示 t 是关系 R 的一个元组。t[Ai]t[A_i] 表示元组在 AiA_i 属性上的分量。
  • 取反。针对属性集合 X,取反就是属性全集 U 和属性集合 X 的差。
  • 串接。将两个元组左右连接。
  • 象集。对于关系 R(A1,A2)R(A_1,A_2)A1A_1 的象集就是 A1A_1 所有取值对应的 A2A_2 取值集合。

选择 σF(R)\sigma_F(R)。筛选出关系 R 中符合条件 F(Filter)F\text{(Filter)} 的行。FF 按照优先级分别为:()>θ>¬>>()>\theta>\lnot>\land>\lor。其中 θ={>,<,,,=,}\theta=\{ >,<,\ge,\le,=,\ne \}

选择

投影 ΠA(R)\Pi_A(R)。筛选出关系 R 中含有属性集合 A(Attribute)A\text{(Attribute)} 的列。筛完后可能需要再进一步删除重复的行。

投影

连接 RSR \Join S。筛选出两个关系 R 和 S 的笛卡尔积 R×SR\times S 中 R 的属性 A 和 S 的属性 S 符合条件 θ\theta 的行。

连接

  • 一般连接。就是上述所述。
  • 左外连接。当 R 的属性 A 的取值不在 S 的 B 中时,在结果中保留 R 的结果,S 对应的值填 NULL。
  • 右外连接。当 S 的属性 B 的取值不在 R 的 A 中时,在结果中保留 S 的结果,R 对应的值填 NULL。

除法 R÷SR \div S 。对于两个关系 R(X,Y)R(X,Y)S(Y,Z)S(Y,Z)。找到 R 符合「R(X)R(X) 包含 S(X)S(X)」的元组在 X 上的投影。

除法

3 SQL

4 安全性

5 完整性

开发篇

关系数据理论

数据库设计

数据库编程 *

这一章不作考试要求。樂。

进阶篇

关系数据库存储管理

关系查询

怎么查询关系的?又可以怎么优化查询策略呢?

数据库恢复技术

并发

]]> @@ -951,7 +951,7 @@ /GPA/4th-term/MachineLearning/ - 机器学习与模式识别

前言

学科地位:

主讲教师学分配额学科类别
杨琬琪3专业课

成绩组成:

理论课:

作业+课堂课堂测验(2次)期末(闭卷)
30%20%50%

实验课:

19次实验(五级制)
100%

教材情况:

课程名称选用教材版次作者出版社ISBN号
机器学习与模式识别《机器学习与模式识别》周志华清华大学出版社978-7-302-42328-7

学习资源:

实验平台:

本地路径:

  • 🍉 西瓜书电子书:[机器学习_周志华](D:\华为云盘\2. Score\4. 机器学习与模式识别\机器学习_周志华.pdf)
  • 🎃 南瓜书电子书:[pumpkin_book](D:\华为云盘\2. Score\4. 机器学习与模式识别\pumpkin-book\pdf\pumpkin_book_1.9.9.pdf)
  • 📃 上课 PPT by 周志华:[机器学习_课件_周志华](D:\华为云盘\2. Score\4. 机器学习与模式识别\机器学习_课件_周志华)
  • 📃 上课 PPT by 杨琬琪:[机器学习_课件_杨琬琪](D:\华为云盘\2. Score\4. 机器学习与模式识别\机器学习_课件_杨琬琪)

第1章 绪论

1.1 引言

pass

1.2 基本术语

NameIntroduction
机器学习定义利用经验改善系统自身性能,主要研究智能数据分析的理论和方法。
计算学习理论最重要的理论模型是 PAC(Probably Approximately Correct, 概率近似正确) learning model,即以很高的概率得到很好的模型 $P(
P 问题在多项式时间内计算出答案的解
NP 问题在多项式时间内检验解的正确性
特征(属性)
特征(属性)值连续 or 离散
样本维度特征(属性)个数
特征(属性、输入)空间特征张成的空间
标记(输出)空间标记张成的空间
样本<x>
样例<x,y>
预测任务监督学习、无监督学习、半监督学习、噪音标记学习、多标记学习
泛化能力应对未来未见的测试样本的能力
独立同分布假设历史和未来的数据来自相同的分布

1.3 假设空间

假设空间:所有可能的样本组合构成的集合空间

版本空间:根据已知的训练集,将假设空间中与正例不同的、反例一致的样本全部删掉,剩下的样本组合构成的集合空间

1.4 归纳偏好

No Free Launch 理论,没有很好的算法,只有适合的算法。好的算法来自于对数据的好假设、好偏执,大胆假设,小心求证

1.5 发展历程

pass

1.6 应用现状

pass

第2章 模型评估与选择

2.1 经验误差与过拟合

概念辨析

  • 错误率:针对测试数据而言,分错的样本数 aa 占总样本数 mm 的比例 E=amE=\frac{a}{m}
  • 经验误差:针对训练数据而言,随着训练轮数或模型的复杂度越高,经验误差越小

误差训练曲线

过拟合解决方法

  • Early Stopping (当发现有过拟合现象就停止训练)

  • Penalizing Large Weight (在经验风险上加一个正则化项)

    在后面的学习中经常会和正则化打交道,因此此处补充一下相关的概念。

    在目标函数中添加正则化到底有什么好处?给几个解释:

    1. 防止过拟合。为什么可以防止过拟合?其实可以形象化的将添加的正则化项理解为一个可以调节的「累赘」,为了让原始问题尽可能的最优,我让累赘愈发拖累目标函数的取值,这样原始问题就不得不更优以此来抵消累赘带来的拖累。
    2. 可以进行特征选择。个人认为属于第一点的衍生意义,为什么这么说?同样用累赘来比喻正则化项。当原始问题的某些变量为了代偿拖累导致系数都接近于零了,那么这个变量也就没有存在的意义了,于是对应的特征也就被筛选掉了,也就是所谓的特征选择了。常常添加 L1 正则化项来进行所谓的特征选择。
    3. 提升计算效率。同样可以理解为第三点的衍生意义,为什么这么说?因为你都把变量筛掉了,那么对应的解空间是不是就相应的大幅度减少了,于是最优解的搜索也就更加快速了。

    当然正则化项并非万能的万金油,在整个目标函数中正则化项有这举足轻重的意义,一旦正则化项的系数发生了微小的变动,对于整个模型的影响都是巨大的。因此有时添加正则化项并不一定可以带来泛化性能的提升。

    正则化有以下形式:

    1. 一般式:

      xpk=((i=1mxip)1p)k||\mathbf{x}||_p^k = \left ( \left ( \sum_{i=1}^{m}|x_i|^{p} \right)^{\frac{1}{p}} \right)^k

    2. L1 正则化:

      x1=i=1mxi||\mathbf{x}||_1 = \sum_{i=1}^m |x_i|

    3. L2 正则化:

      x22=((i=1mxi2)12)2=i=1mxi2\begin{aligned}||\mathbf{x}||_2^2 &= \left ( \left ( \sum_{i=1}^{m}|x_i|^{2} \right)^{\frac{1}{2}} \right)^2 \\&= \sum_{i=1}^{m}|x_i|^{2}\end{aligned}

  • Bagging 思想 (对同一样本用多个模型投票产生结果)

  • Boosting 思想 (多个弱分类器增强分类能力,降低偏差)

  • Dropconnection (神经网络全连接层中减少过拟合的发生)

欠拟合解决方法

  • 决策树:拓展分支

  • 神经网络:增加训练轮数

2.2 评估方法

  • 留出法(hold-out):将数据集分为三个部分,分别为训练集、验证集、测试集。测试集对于训练是完全未知的,我们划分出测试集是为了模拟未来未知的数据,因此当下的任务就是利用训练集和验证集训练出合理的模型来尽可能好的拟合测试集。那么如何使用划分出的训练集和验证集来训练、评估模型呢?就是根据模型的复杂度 or 模型训练的轮数,根据上图的曲线情况来选择模型。

  • 交叉验证法(cross validation):一般方法为 p 次 k 折交叉验证,即 p 次将训练数据随机划分为 k 个大小相似的互斥子集。将其中 k1k-1 份作为训练数据,11 份作为验证数据,每轮执行 kk 次获得平均误差。执行 p 次划分主要是为了减小划分方法带来的误差。

  • 自助法(bootstrapping):有放回采样获得训练集。每轮从数据集 DD 中(共 mm 个样本)有放回的采样 mm 次,这 mm 个抽出来的样本集合 DD' 大约占数据集的 23\frac{2}{3},于是就可以将抽出的样本集合 DD' 作为训练集,DDD-D' 作为测试集即可

    测试集占比 1/3 证明过程

2.3 性能度量

2.3.1 回归任务

均方误差:MSE=1mi=1m(f(xi)yi)2\displaystyle MSE=\frac{1}{m} \sum_{i=1}^m(f(x_i) - y_i)^2

均方根误差:RMSE=1mi=1m(f(xi)yi)2\displaystyle RMSE=\sqrt{\frac{1}{m} \sum_{i=1}^m(f(x_i) - y_i)^2}

R2R^2 分数:R2=1i=1m(f(xi)yi)2i=1m(yˉyi)2,yˉ=1mi=1myi\displaystyle R^2 = 1 - \frac{\sum_{i=1}^m(f(x_i)-y_i)^2}{\sum_{i=1}^m(\bar{y} - y_i)^2},\quad \bar{y} = \frac{1}{m}\sum_{i=1}^m y_i

首先理解各部分的含义。减数的分子表示预测数据的平方差,减数的分母表示真实数据的平方差。而平方差是用来描述数据离散程度的统计量。

为了保证回归拟合的结果尽可能不受数据离散性的影响,我们通过相除来判断预测的数据是否离散。如果和原始数据离散性差不多,那么商就接近1,R方就接近0,表示性能较差,反之如果比原始数据离散性小,那么商就接近0,R方就接近1,表示性能较优。

2.3.2 分类任务

混淆矩阵

混淆矩阵 - 图例

  • 准确率(accuracy)P=TP+TNTP+FN+FP+TN\displaystyle P=\frac{TP+TN}{TP+FN+FP+TN}

  • 查准率/精度(precision)P=TPTP+FP\displaystyle P = \frac{TP}{TP+FP} - 适用场景:商品搜索推荐(尽可能推荐出适当的商品即可,至于商品数量无所谓)

  • 查全率/召回率(recall)R=TPTP+FN\displaystyle R = \frac{TP}{TP+FN} - 适用场景:逃犯、病例检测(尽可能将正例检测出来,至于查准率无所谓)

  • F1 度量(F1-score)F1=2×P×RP+R\displaystyle F_1 = \frac{2\times P \times R}{P + R}​ - 用于综合查准率和查全率的指标

  • 对于多分类问题,我们可以将该问题分解为多个二分类问题(ps:假设为 n 个)。从而可以获得多个上述的混淆矩阵,那么也就获得了多个 PiP_iRiR_i 以及全局均值 TP\overline{TP}FP\overline{FP}FN\overline{FN},进而衍生出两个新的概念

      • 宏查准率:macroP=1ni=1nPi\displaystyle macroP = \frac{1}{n} \sum_{i=1}^n P_i
      • 宏查全率:macroR=1ni=1nRi\displaystyle macroR = \frac{1}{n} \sum_{i=1}^n R_i
      • F1F1macroF1=2×macroP×macroRmacroP+macroR\displaystyle macroF_1 = \frac{2 \times macroP \times macroR}{macroP+macroR}
      • 微查准率:microP=TPTP+FP\displaystyle microP = \frac{\overline{TP}}{\overline{TP}+\overline{FP}}
      • 微查全率:microR=TPTP+FN\displaystyle microR = \frac{\overline{TP}}{\overline{TP}+\overline{FN}}
      • F1F1microF1=2×microP×microRmicroP+microR\displaystyle microF_1 = \frac{2 \times microP \times microR}{microP+microR}

P-R 曲线

P-R 曲线趋势图

  • 横纵坐标:横坐标为查全率(Recall),纵坐标为查准率(Precision)

  • 如何产生?我们根据学习器对于每一个样本的预测值(正例性的概率)进行降序排序,然后调整截断点将预测后的样本进行二分类,将截断点之前的所有数据全部认为预测正例,截断点之后的所有数据全部认为预测反例。然后计算两个指标进行绘图。

    我们知道学习器得到最终的结果一般不是一个绝对的二值,如 0,1。往往是一个连续的值,比如 [0,1],也就是“正例性的概率”。因此我们才可以选择合适的截断点将所有的样本数据划分为两类。

  • 趋势解读:随着截断点的值不断下降,很显然查全率 RR 会不断上升,查准率 PP 会不断下降

  • 不同曲线对应学习器的性能度量:曲线与横纵坐标围成的面积衡量了样本预测排序的质量。因此上图中 A 曲线的预测质量比 C 曲线的预测质量高。但是我们往往会遇到比较 A 与 B 的预测质量的情况,由于曲线与坐标轴围成的面积难以计算,因此我们引入了平衡点的概念。平衡点就是查准率与查询率相等的曲线,即 P=RP=R 的曲线。平衡点越往右上,学习器的预测性能越好。

ROC 曲线与 AUC

ROC 曲线图 - 受试者工作特征

  • 横纵坐标:横坐标为假正例率 FPR=FPFP+TN\displaystyle FPR = \frac{FP}{FP+TN},纵坐标为真正例率 TPR=TPTP+FN\displaystyle TPR = \frac{TP}{TP+FN}

  • 如何产生?与 P-R 图的产生类似,只不过计算横纵坐标的规则不同,不再赘述。

  • 趋势解读:随着截断点的值不断下降,真正例率与假正例率均会不断上升,因为分子都是从 0 开始逐渐增加的

  • 不同曲线对应学习器的性能度量:AUC 衡量了样本预测的排序质量。AUC 即 ROC 曲线右下方的面积,面积越大则对应的预测质量更高,学习器性能更好。不同于上述引入平衡点的概念,此处的面积我们可以直接计算,甚至 1-AUC 也可以直接计算。

    我们定义 AUC\text{AUC} 的计算公式为:(其实就是每一块梯形的面积求和,ps:矩形也可以用梯形面积计算公式代替)

    i=1m1(yi+yi+1)(xi+1xi)2\sum _{i=1}^{m-1} \frac{(y_{i}+y_{i+1}) \cdot (x_{i+1} - x_i)}{2}

    我们定义损失函数(losslosslrank=1AUCl_{rank} = 1-AUC 的计算公式为:(ps:感觉下述公式不是很准,因为正反例预测值相等的比例比不一定就是一比一)

    损失函数计算公式

2.4 比较检验

理论依据:统计假设检验(hypothesis test)

两个学习器性能比较:

  • 交叉验证 t 检验:对于 k 折两个学习期产生的 k 个误差之差,求得其均值 μ\mu 个方差 σ2\sigma ^2,若变量 Γt\Gamma_t 小于临界值,则表明学习器没有显著差异,其中变量 Γt\Gamma_t

    Γt=kμσ\Gamma_t = |\frac{\sqrt{k}\mu}{\sigma}|

  • McNemar 检验:对于二分类问题,我们可以得到下方的列联表

    列联表

    若变量 Γχ2\Gamma_{\chi ^2} 小于临界值,则表明学习器没有显著差异,其中变量 Γχ2\Gamma_{\chi ^2}

    Γχ2=(e01e101)2e01+e10\Gamma_{\chi ^2} = \frac{(|e_{01} - e_{10}| - 1)^2}{e_{01}+e_{10}}

2.5 偏差与方差

现在我们得到了学习算法的泛化性能,我们还想知道为什么会有这样的泛化性能,即我们应该如何理论的解释这样的泛化性能呢?我们引入 偏差-方差分解 的概念来从理论的角度解释期望泛化误差。那么这个方法一定是完美解释的吗?也有一定的缺点,因此我们还会引入 偏差-方差窘境 的概念来解释偏差和方差对于泛化误差的贡献

在此之前我们需要知道偏差、方差和噪声的基本定义:

  • 偏差:学习算法的期望输出与真实结果的偏离程度,刻画算法本身的拟合能力
  • 方差:使用同规模的不同训练集进行训练时带来的性能变化,刻画数据扰动带来的影响
  • 噪声:当前任务上任何算法所能达到的期望泛化误差的下界(即不可能有算法取得更小的误差),刻画问题本身的难度

2.5.1 偏差-方差分解

我们定义以下符号:xx 为测试样本,yDy_Dxx 在数据集中的标记,yyxx 的真实标记,f(x;D)f(x;D) 为模型在训练集 DD 上学习后的预测输出。

我们以回归任务为例:(下面的全部变量均为在所有相同规模的训练集下得到的期望结果)

  • 输出:f(x)=ED[f(x;D)]\overline{f}(x) = E_D[f(x;D)]
  • 方差:var(x)=ED[(f(x)f(x;D))2]var(x) = E_D[(\overline{f}(x) - f(x;D))^2]
  • 偏差:bias2(x)=(f(x)y)2bias^2(x) = (\overline{f}(x) - y)^2
  • 噪声:ϵ2=ED[(yDy)2]\epsilon ^2 = E_D[(y_D - y)^2]

偏差-方差分解的结论:

E(f;D)=bias2(x)+var(x)+ϵ2E(f;D) = bias^2(x) + var(x) + \epsilon^2

偏差-方差分解结论推导

解释说明:泛化性能是由学习算法的能力、数据的充分性以及学习任务本身的难度共同决定的。因此给定一个学习任务,我们可以从偏差和方差两个角度入手进行优化,即需要使偏差较小(充分拟合数据),且需要使方差较小(使数据扰动产生的影响小)

2.5.2 偏差-方差窘境

其实偏差和方差是有冲突的,这被称为偏差-方差窘境(bias-variance-dilemma)。对于以下的示意图我们可以知道:对于给定的学习任务。一开始拟合能力较差,学习器对于不同的训练数据不够敏感,此时泛化错误率主要来自偏差;随着训练的不断进行,学习器的拟合能力逐渐增强,对于数据的扰动更加敏感,使得方差主导了泛化错误率;在训练充分以后,数据的轻微扰动都可能导致预测输出发生显著的变化,此时方差就几乎完全主导了泛化错误率。

偏差-方差窘境 示意图

第3章 线性模型

本章介绍机器学习模型之线性模型,将从学习任务展开。学习任务分别为:

  • 回归(最小二乘法、岭回归)
  • 二分类(对数几率(逻辑)回归、线性判别分析)
  • 多分类(一对一、一对其余、多对多)

3.1 基本形式

f(xi)=wTxi+bw=(w1;w2;;wd),wRd,bR\begin{aligned}f(x_i) &= w^T x_i + b \\w &= (w_1; w_2; \cdots; w_d), w \in R^d, b \in R\end{aligned}

线性模型的优点:形式简单、易于建模、高可解释性、非线性模型的基础

线性模型的缺点:线性不可分

3.2 线性回归

预测式已经在 3.1 中标明了,现在的问题就是,如何获得 wwbb 使得预测值 f(x)f(x) 与真实值 yy 尽可能的接近,也就是误差 ϵ=f(x)y\epsilon = ||f(x) - y|| 尽可能的小?在前面的 2.3 节性能度量中,我们知道对于一般的回归任务而言,可以通过均方误差来评判一个回归模型的性能。借鉴该思想,线性回归也采用均方误差理论,求解的目标函数就是使均方误差最小化。

在正式开始介绍求解参数 wwbb 之前,我们先直观的理解一下均方误差。我们知道,均方误差对应了欧氏距离,即两点之间的欧几里得距离。于是在线性回归任务重,就是寻找一条直线使得所有的样本点距离该直线的距离之和尽可能的小。

基于均方误差最小化的模型求解方法被称为 最小二乘法 (least squre method)。而求解 wwbb 使得目标函数 E(w,b)=i=1m(yif(xi))E_{(w,b)} = \sum_{i=1}^{m}(y_i - f(x_i)) 最小化的过程,被称为线性回归模型的 最小二乘“参数估计” (parameter estimation)

于是问题就转化为了无约束的最优化问题求解。接下来我们将从一元线性回归引入,进而推广到多维线性回归的参数求解,最后补充广义的线性回归与其他线性回归的例子。

3.2.1 一元线性回归

现在假设只有一个属性 x,对应一维输出 y。现在我们试图根据已知的 <x,y> 样本数据学习出一个模型 f(xi)=wxi+bf(x_i) = wx_i+b 使得尽可能准确的预测未来的数据。那么此时如何求解模型中当目标函数取最小值时的参数 w 和 b 呢?很显然我们可以使用无约束优化问题的一阶必要条件求解。

前置说明:在机器学习中,很少有闭式解(解析解),但是线性回归是特例,可以解出闭式解。

闭式解推导过程:

一元线性回归:参数 w 和 b 的求解推导(式 3.7、式 3.8)

3.2.2 多元线性回归

现在我们保持输出不变,即 yy 仍然是一维,将输入的样本特征从一维扩展到 dd 维。现在同样适用最小二乘法,来计算 wwbb 使得均方误差最小。只不过现在的 ww 是一个一维向量 w=(w1,w2,,wd)w = (w_1,w_2, \cdots , w_d)

现在我们按照原来的方法进行求解。在求解之前我们采用向量的方式简化一下数据的表示,XX 为修改后的样本特征矩阵,w^\hat w 为修改后的参数矩阵,yy 为样本标记值,f(x)f(x) 为模型学习结果:

X=[x11x12x1d1x21x22x2d11xm1xm2xmd1],w^=(w;b)=[w1w2wdb],y=[y1y2ym],f(x)=[f(x1)f(x2)f(xm)]=[x1Tw^x2Tw^xdTw^]X = \begin{bmatrix}x_{11} & x_{12} & \cdots & x_{1d} & 1 \\x_{21} & x_{22} & \cdots & x_{2d} & 1 \\\vdots & \vdots & & \vdots & 1 \\x_{m1} & x_{m2} & \cdots & x_{md} & 1\end{bmatrix},\hat w = (w;b) = \begin{bmatrix}w_1 \\w_2 \\\vdots \\w_d \\b\end{bmatrix},y = \begin{bmatrix}y_1 \\y_2 \\\vdots \\y_m\end{bmatrix},f(x) = \begin{bmatrix}f(x_1) \\f(x_2) \\\vdots \\f(x_m)\end{bmatrix} = \begin{bmatrix}x_1 ^ T \hat w \\x_2 ^ T \hat w \\\vdots \\x_d ^ T \hat w \end{bmatrix}

于是损失函数 Ew^E_{\hat w} 就定义为:

Ew^=(yXw^)T(yXw^)E_{\hat w} = (y - X \hat w) ^T (y - X \hat w)

我们用同样的方法求解其闭式解:

多元线性回归:参数 w 的求解推导(式 3.10)

我们不能直接等式两边同 ×\times 矩阵 XTXX^TX 的逆,因为不清楚其是否可逆,于是进行下面的两种分类讨论:

  1. XTXX^T X​ 可逆:则参数 w^=(XTX)1XTy\hat w ^* = (X^TX)^{-1}X^Ty,令样本 x^i=(xi,1)\hat x_i = (x_i,1),则线性回归模型为:

    f(xi)=x^iTw^f(x_i) = \hat x_i ^T \hat w^*

  2. XTXX^T X 不可逆:我们引入 L2L_2 正则化项 αw^2\alpha || \hat w ||^2,此时就是所谓的「岭回归」算法:

    现在的损失函数就定义为:

    Ew^=(yXw^)T(yXw^)+αw^2E_{\hat w} = (y - X \hat w) ^T (y - X \hat w) + \alpha || \hat w ||^2

    同样将损失函数对参数向量 w^\hat w 求偏导,得:

    Ew^w^==2XTXw^2XTy+2αw^=2XT(Xw^y)+2αw^\begin{aligned}\frac{\partial E_{\hat w}}{\partial \hat w} &= \cdots \\&= 2X^TX\hat w - 2 X^T y + 2 \alpha \hat w \\&= 2 X ^T(X \hat w - y) + 2 \alpha \hat w\end{aligned}

    我们令其为零,得参数向量 w^\hat w 为:

    w^=(XTX+αI)1XTy\hat w = (X^T X + \alpha I)^{-1} X^T y

3.2.3 广义线性回归

可否令模型的预测值 wTx+bw^Tx+b 逼近 yy 的衍生物?我们以对数线性回归为例,令:

lny=wTx+b\ln y = w^Tx+b

本质上我们训练的线性模型 lny=wTx+b\ln y = w^Tx+b 现在可以拟合非线性数据 y=ewTx+by = e^{w^Tx+b}。更广义的来说,就是让训练的线性模型去拟合:

y=g1(wTx+b)y = g^{-1}(w^Tx+b)

此时得到的线性模型 g(y)=wTx+bg(y) = w^Tx +b 被称为广义线性模型,要求非线性函数 g()g(\cdot) 是单调可微的。而此处的对数线性回归其实就是广义线性模型在 g()=ln()g(\cdot) = \ln (\cdot) 时的特例

线性模型拟合非线性数据

3.2.4 其他线性回归

  • 支持向量机回归

  • 决策树回归

  • 随机森林回归

  • LASSO 回归:增加 L1L_1 正则化项

    LASSO 回归

  • ElasticNet 回归:增加 L1L_1L2L_2 正则化项

    ElasticNet 回归

  • XGBoost 回归

3.3 对数几率回归

对数几率回归准确来说应该叫逻辑回归,并且不是回归的学习任务,而是二分类的学习任务。本目将从阈值函数的选择模型参数的求解两个维度展开讲解。

3.3.1 阈值函数的选择

对于二分类任务。我们可以将线性模型的输出结果 z=wTx+bz=w^Tx+b 通过阈值函数 g()g(\cdot) 进行映射,然后根据映射结果 y=g(z)y=g(z) 进行二分类。那么什么样的非线性函数可以胜任阈值函数一职呢?

  1. 最简单的一种阈值函数就是单位阶跃函数。映射关系如下。但是有一个问题就是单位阶跃函数不是单调可微函数,因此不可取

    一直有一个疑问,单位跃阶函数已经可以将线性模型的进行二值映射了,干嘛还要求阈值函数的反函数呢?

    其实是因为知道的太片面了。我们的终极目的是为了通过训练数据,学习出线性模型中的 wwbb 参数。在《最优化方法》课程中我们知道,优化问题是需要进行迭代的,在迭代寻找最优解(此处就是寻找最优参数)的过程中,我们需要不断的检验当前解,以及计算修正量。仅仅知道线性模型 z=wTx+bz=w^Tx+b 关于阈值函数 g()g(\cdot) 的映射结果 y=g(z)y=g(z) 是完全不够的,因为我们在检验解、计算修正量时,采用梯度下降等算法来计算损失函数关于参数 wwbb 的导数时,需要进行求导操作,比如上述损失函数 E(w,b)=i=1m(yig(xi))2E_{(w,b)} = \sum_{i=1}^m (y_i - g(x_i))^2,如果其中的 g()g(\cdot) 不可导,自然也就没法继续计算损失函数关于参数的导数了。

    至此疑问解决!让我们畅快的学习单调可微的阈值函数吧!

    y={0,z<00.5,z=01,z>0y = \begin{cases}0, &z < 0 \\0.5,& z=0 \\1, & z>0\end{cases}

  2. 常用的一种阈值函数是对数几率函数,也叫逻辑函数(logistic function\text{logistic function}。映射关系如下:

    y=11+ezy = \frac{1}{1+e^{-z}}

3.3.2 模型参数的求解

现在我们站在前人的肩膀上学习到了一种阈值函数:逻辑函数(logistic function\text{logistic function})。在开始讲解参数 wwbb 的求解过程之前,我们先解决一个疑问:为什么英文名是 logistic\text{logistic}​,中文翻译却成了 对数几率 函数呢?

这就要从逻辑函数的实际意义出发了。对于逻辑函数,我们代入线性模型,并做以下转化:

y=11+ezy=11+e(wTx+b)lny1y=wTx+b\begin{aligned}& y = \frac{1}{1 + e^{-z}} \\& y = \frac{1}{1 + e^{-(w^Tx+b)}} \\& \ln \frac{y}{1 - y} = w^Tx+b \\\end{aligned}

若我们定义 yy 为样本 xx 是正例的可能性,则 1y1-y 显然就是反例的可能性。两者比值 y1y\frac{y}{1-y} 就是几率,取对数 lny1y\ln{\frac{y}{1-y}} 就是对数几率。

知道了逻辑函数的实际意义是真实标记的对数几率以后,接下来我们实际意义出发,讲解模型参数 wwbb​ 的推导过程。

若我们将 yy 视作类后验概率 p(y=1  x)p(y=1 \ | \ x)​,则有

lnp(y=1  x)p(y=0  x)=wTx+b\ln \frac{p(y=1 \ | \ x)}{p(y=0 \ | \ x)} = w^Tx+b

同时,显然有

p(y=1  x)=11+e(wTx+b)=ewTx+b1+ewTx+bp(y=0  x)=e(wTx+b)1+e(wTx+b)=11+ewTx+b\begin{aligned}p(y=1 \ | \ x) = \frac{1}{1 + e^{-(w^Tx+b)}} = \frac{e^{w^Tx+b}}{1 + e^{w^Tx+b}} \\p(y=0 \ | \ x) = \frac{e^{-(w^Tx+b)}}{1 + e^{-(w^Tx+b)}} = \frac{1}{1 + e^{w^Tx+b}}\end{aligned}

于是我们可以确定目标函数了。我们取当前目标函数为对数似然函数

argmaxw,bl(w,b)=i=1mlnp(yi  xi;w,b)\arg \max_{w,b} l(w,b) = \sum_{i=1}^m \ln p(y_i\ | \ x_i;w,b)

显然的,当上述目标函数取最大值时,得到的参数 wwbb​ 即为所求。因为目标函数尽可能大就对应样本属于真实标记的概率尽可能大,也就是所谓的最大化类后验概率

我们将变量进行一定的变形:

{β=(w;b)x^=(x;1)wTx+b=βTx^{p1(x^;β)=p(y=1  x^;β)p0(x^;β)=p(y=0  x^;β)p(yi  xi;w,b)=yip1(x^;β)+(1yi)p0(x^;β)\begin{aligned}\begin{cases}\beta = (w;b) \\\hat x = (x;1) \\\end{cases}&\to w^Tx + b = \beta^T\hat x \\\begin{cases}p_1(\hat x; \beta) = p(y = 1 \ | \ \hat x; \beta) \\p_0(\hat x; \beta) = p(y = 0 \ | \ \hat x; \beta) \\\end{cases}&\to p(y_i\ | \ x_i;w,b) = y_i p_1(\hat x; \beta) + (1 - y_i) p_0(\hat x; \beta)\end{aligned}

于是上述对数似然函数就可以进行以下转化:

l(w,b)=l(β)=i=1mln[yip1(x^;β)+(1yi)p0(x^;β)]=i=1mln[yip(y=1  x^;β)+(1yi)p(y=0  x^;β)]=i=1mln[yieβTx^1+eβTx^+(1yi)11+eβTx^]=i=1mln[yiβTx^+1yi1+eβTx^]={i=1mln(11+eβTx^),yi=0i=1mln(eβTx^1+eβTx^),yi=1=i=1mln((eβTx^)yi1+eβTx^)=i=1m(yieβTx^ln(1+eβTx^))\begin{aligned}l(w,b) &= l(\beta) \\&= \sum_{i=1}^m \ln \left [y_i p_1(\hat x; \beta) + (1 - y_i) p_0(\hat x; \beta) \right ] \\&= \sum_{i=1}^m \ln \left [y_i p(y = 1 \ | \ \hat x; \beta) + (1 - y_i) p(y = 0 \ | \ \hat x; \beta) \right ] \\&= \sum_{i=1}^m \ln \left [ y_i \frac{e^{\beta^T\hat x}}{1 + e^{\beta^T\hat x}} + (1-y_i) \frac{1}{1 + e^{\beta^T\hat x}} \right ] \\&= \sum_{i=1}^m \ln \left [ \frac{y_i \beta^T\hat x +1 -y_i}{1 + e^{\beta^T\hat x}} \right ] \\&= \begin{cases}\sum_{i=1}^m \ln \left ( \frac{1}{1 + e^{\beta^T\hat x}} \right ), & y_i=0 \\\sum_{i=1}^m \ln \left ( \frac{e^{\beta^T\hat x}}{1 + e^{\beta^T\hat x}} \right ), & y_i=1\\\end{cases}\\&= \sum_{i=1}^m \ln \left ( \frac{\left(e^{\beta^T\hat x}\right)^{y_i}}{1 + e^{\beta^T\hat x}} \right ) \\&= \sum_{i=1}^m \left( y_i e^{\beta^T\hat x} - \ln({1 + e^{\beta^T\hat x}})\right )\end{aligned}

进而从极大似然估计转化为:求解「极小化负的上述目标函数时」参数 β\beta 的值:

argminβl(β)=i=1m(yieβTx^+ln(1+eβTx^))\arg \min_{\beta} l(\beta) = \sum_{i=1}^m \left(- y_i e^{\beta^T\hat x} + \ln({1 + e^{\beta^T\hat x}})\right )

由于上式是关于 β\beta 的高阶可导连续凸函数,因此我们有很多数值优化算法可以求得最优解时的参数值,比如梯度下降法、拟牛顿法等等。我们以牛顿法(Newton Method)为例:

目标函数:

β=argminβl(β)\beta ^* = \arg \min_{\beta} l(\beta)

t+1t+1​ 轮迭代解的更新公式:

βt+1=βt(2l(β)ββT)1l(β)β\beta ^{t+1} = \beta^t - \left( \frac{\partial^2{l(\beta)}}{\partial{\beta} \partial{\beta^T}} \right)^{-1} \frac{\partial{l(\beta)}}{\partial{\beta}}

其中 l(β)l(\beta) 关于 β\beta 的一阶导、二阶导的推导过程如下:

一阶导

二阶导

3.4 线性判别分析

线性判别分析的原理是:对于给定的训练集,设法将样本投影到一条直线上,使得同类的投影点尽可能接近,异类样本的投影点尽可能远离;在对新样本进行分类时,将其投影到这条直线上,再根据投影点的位置来确定新样本的类别。

3.5 多分类学习

我们一般采用多分类+集成的策略来解决多分类的学习任务。具体的学习任务大概是:将多分类任务拆分为多个二分类任务,每一个二分类任务训练一个学习器;在测试数据时,将所有的分类器进行集成以获得最终的分类结果。这里有两个关键点:如何拆分多分类任务?如何集成二分类学习器?集成策略见第 8 章,本目主要介绍多分类学习任务的拆分。主要有三种拆分策略:一对多、一对其余、多对多。对于 N 个类别而言:

3.5.1 一对一

一对一

  • 名称:One vs. One,简称 OvO
  • 训练:需要对 N 个类别进行 N(N1)2\frac{N(N-1)}{2} 次训练,得到 N(N1)2\frac{N(N-1)}{2} 个二分类学习器
  • 测试:对于一个样本进行分类预测时,需要用 N(N1)2\frac{N(N-1)}{2} 个学习器分别进行分类,最终分得的结果种类最多的类别就是样本的预测类别
  • 特点:类别较少时,时间和内存开销往往更大;类别较多时,时间开销往往较小

3.5.2 一对其余

一对其余

  • 名称:One vs. Rest,简称 OvR
  • 训练:需要对 N 个类别进行 NN 次训练,得到 NN 个二分类学习器。每次将目标类别作为正例,其余所有类别均为反例
  • 测试:对于一个样本进行分类预测时,需要用 NN 个学习器分别进行分类,每一个学习器显然只会输出二值,假定为正负。正表示当前样例属于该学习器的正类,反之属于反类。若 NN 个学习器输出了多个正类标签,则还需通过执行度选择最终的类别。

3.5.3 多对多

多对多

  • 名称:Many vs. Many,简称 MvM
  • 训练(编码):对于 N 个类别数据,我们自定义 M 次划分。每次选择若干个类别作为正类,其余类作为反类。每一个样本在 M 个二分类学习器中都有一个分类结果,也就可以看做一个 M 维的向量。m 个样本也就构成了 m 个在 M 维空间的点阵。
  • 测试(解码):对于测试样本,对于 M 个学习器同样也会有 M 个类别标记构成的向量,我们计算当前样本与训练集构造的 m 个样本的海明距离、欧氏距离,距离当前测试样本最近的点属于的类别,我们就认为是当前测试样本的类别。

3.5.4 softmax

类似于 M-P 神经元,目的是为了将当前测试样本属于各个类别的概率之和约束为 1。

3.6 类别不平衡问题

在分类任务的数据集中,往往会出现类别不平衡的问题,即使在类别的样本数量相近时,在使用一对其余等算法进行多分类时,也会出现类比不平衡的问题,因此解决类比不平衡问题十分关键。

3.6.1 阈值移动

常规而言,对于二分类任务。我们假设 yy 为样本属于正例的概率,则 p=y1yp=\frac{y}{1-y} 就是正确划分类别的概率。在假定类别数量相近时,我们用下式表示预测为正例的情况:

y1y>1\frac{y}{1-y}>1

但是显然,上述假设不总是成立,我们令 m+m^+ 为样本正例数量,mm^- 为样本反例数量。我们用下式表示预测为正例的情况:

y1y>m+m\frac{y}{1-y} > \frac{m^+}{m^-}

根本初衷是为了让 m+m\frac{m^+}{m^-} 表示数据类别的真实比例。但是由于训练数据往往不能遵循独立分布同分布原则,也就导致我们观测的 m+m\frac{m^+}{m^-} 其实不能准确代表数据的真实比例。那还有别的解决类别不平衡问题的策略吗?答案是有的!

3.6.2 欠采样

即去除过多的样本使得正反例的数量近似,再进行学习。

  • 优点:训练的时间开销小
  • 缺点:可能会丢失重要信息

典型的算法是:EasyEnsemble

3.6.3 过采样

即对训练集中类别数量较少的样本进行重复采样,再进行学习。

  • 缺点:简单的重复采样会导致模型过拟合数据,缺少泛化能力。

典型的算法是:SMOTE

第4章 决策树

4.1 基本流程

决策树算法遵循自顶向下、分而治之的思想。

决策树结构解读:

决策树结构

  1. 非叶子结点:属性
  2. 边:属性值
  3. 叶子结点:分类结果

决策树生成过程:

决策树算法伪代码

  1. 生成结点:选择最优的属性作为当前结点决策信息
  2. 产生分支:根据结点属性,为测试数据所有可能的属性值生成一个分支
  3. 生成子结点:按照上述分支属性对当前训练样本进行划分
  4. 不断递归:不断重复上述 1-3 步直到递归终点
  5. 递归终点:共三种(存疑)
    1. 当前结点的训练样本类别均属于某个类别。则将当前结点设定为叶子结点,并标记当前叶子结点对应的类别为当前结点同属的类别
    2. 当前结点的训练样本数为 00。则将当前结点为设定为叶子结点,并标记当前叶子结点对应的类别为父结点中最多类别数量对应的类别
    3. 当前结点的训练样本的所有属性值都相同。则将当前结点设定为叶子结点,并标记当前叶子结点对应的类别为当前训练样本中最多类别数量对应的类别

4.2 划分选择

现在我们解决 4.1 节留下的坑,到底如何选择出当前局面下的最优属性呢?我们从“希望当前结点的所包含的样本尽可能属于同一类”的根本目的出发,讨论三种最优属性选择策略。

4.2.1 信息增益

第一个问题:如何度量结点中样本的纯度? 我们引入 信息熵 (information entropy) 的概念。显然,信息熵越小,分支结点的纯度越高;信息熵越大,分支结点的纯度越低。假设当前样本集合中含有 tt 个类别,第 kk 个类别所占样本集合比例为 pkp_k,则当前样本集合的信息熵 Ent(D)\text{Ent}(D) 为:

Ent(D)=k=1tpklog2(pk)\text{Ent}(D) = -\sum _{k=1}^t p_k \log_2(p_k)

第二个问题:如何利用结点纯度选择最优属性进行划分? 我们引入 信息增益 (Information gain) 的概念。显然,我们需要计算每一个属性对应的信息增益,然后取信息增益最大的属性作为当前结点的最优划分属性。假设当前结点对应的训练数据集为 DD,可供选择的属性列表为 a,b,,γa,b,\cdots,\gamma。我们以 aa 为例给出信息增益的表达式。假设属性 aa 共有 VV 个属性值,即 a={a1,a2,.aV}a=\{a^1,a^2,\cdots.a^V\},于是便可以将当前结点对应的训练数据集 DD 划分为 VV 个子结点的训练数据 D={D1,D2,.DV}D=\{D^1,D^2,\cdots.D^V\},由于每一个子结点划到的训练数据量不同,引入权重理念,得到最终的信息增益表达式为:

Gain(D,a)=Ent(D)i=1VDiDEnt(Di)\text{Gain}(D,a)=\text{Ent}(D) - \sum_{i=1}^V \frac{|D^i|}{|D|} \text{Ent}(D^i)

代表算法:ID3

为了更好的理解信息增益的表达式,我们从终极目标考虑问题。我们知道,决策树划分的终极目的是尽可能使得子结点中的样本纯度尽可能高。对于当前已知的结点,信息熵已经是一个定值了,只有通过合理的划分子结点才能使得整体的熵减小,因此我们从划分后熵减的理念出发,得到了信息增益的表达式。并且得出熵减的结果(信息增益)越大,对应的属性越优。

4.2.2 增益率

由于信息增益会在属性的取值较多时有偏好,因此我们引入 增益率 (Gain ratio) 的概念来减轻这种偏好。显然,增益率越大越好

我们定义增益率为:

Gain_ratio(D,a)=Gain(D,a)IV(a)\text{Gain}\_\text{ratio}(D,a) = \frac{\text{Gain}(D,a)}{\text{IV}(a)}

可以看到就是将信息增益除上了一个值 IV(a)\text{IV}(a),我们定义属性 aa 的固有值 IV(a)\text{IV}(a) 为:

IV(a)=i=1VDiDlog2DiD\text{IV}(a) = -\sum_{i=1}^V \frac{|D^i|}{|D|} \log_2 \frac{|D^i|}{|D|}

一般而言,属性的属性取值越多,信息增益越大,对应的 IV(a)\text{IV}(a) 的值也越大,这样就可以在一定程度上抵消过大的信息增益

代表算法:C4.5

4.2.3 基尼指数

最后引入一种最优划分策略:使用 基尼指数 (Gini index) 来划分。基尼指数越小越好。假设当前样本集合中含有 tt 个类别,第 kk 个类别所占样本集合比例为 pkp_k,则当前样本集合 DD 的基尼值 Gini(D)\text{Gini(D)} 定义为:

Gini(D)=1k=1tpk2\text{Gini(D)} = 1 - \sum_{k=1}^{t}p_k^2

于是属性 aa 的基尼指数 Gini_index(D,a)\text{Gini}\_\text{index}(D,a) 定义为:

Gini_index(D,a)=i=1VDiDGini(Di)\text{Gini}\_\text{index}(D,a) = \sum_{i=1}^V \frac{|D^i|}{|D|} \text{Gini}(D^i)

代表算法:CART

4.3 剪枝处理

处理过拟合的策略:剪枝。

4.3.1 预剪枝

基于贪心的思想,决策每次划分是否需要进行。我们知道最佳属性的选择是基于信息增益等关于结点纯度的算法策略,而是否进行子结点的生成需要我们进行性能评估,即从测试精度的角度来考虑。因此决策划分是否进行取决于子结点生成前后在验证集上的测试精度,如果可以得到提升则进行生成,反之则不生成子结点,也就是预剪枝的逻辑。

4.3.2 后剪枝

同样是预剪枝的精度决策标准。我们在一个决策树完整生成以后,从深度最大的分支结点开始讨论是否可以作为叶子结点,也就是是否删除该分支的子结点。决策的依据是删除子结点前后在测试集上的精度是否有提升,如果有则删除子结点,反之不变。

4.3.3 区别与联系(补)

预剪枝是基于贪心,也就是说没有考虑到全局的情况,可能出现当前结点划分后测试精度下降,但是后续结点继续划分会得到性能提升,从而导致预剪枝的决策树泛化性能下降。

后剪枝就可以规避贪心导致的局部最优。但是代价就是时间开销更大。

4.4 连续与缺失值

4.4.1 连续值处理

这里讲解二分法 (bi-partition)。主要就是在计算信息增益时增加了一步,将属性的取值情况划分为了两类。那么如何划分呢?关键在于划分点的取值。假设当前属性 a 的取值是连续值,去重排序后得到 n 个数值,我们取这 n 个数值的 n-1 个间隔的中值作为划分点集合,枚举其中的每一个划分点计算最大信息增益,对应的划分点就是当前连续取值的属性的二分划分点。时间复杂度极高!也不知道 C4.5 算法怎么想的

4.4.2 缺失值处理

当然我们可以直接删除含有缺失信息的样本,但是这对数据信息过于浪费,尤其是当数据量不大时,如何解决这个问题呢?我们需要解决两个问题:

  1. 在选择最优划分属性时,如何计算含有缺失值的属性对应的信息增益呢?
  2. 在得到最优划分属性时,如何将属性值缺失的样本划分到合理的叶子结点呢?

对于第一个问题:只计算属性值没有缺失的样本,然后放缩到原始的数据集合大小即可

对于第二个问题:对于已知属性值的样本,我们可以计算出每一个属性值的样本数量,从而计算出一个集合比例,这样对于未知属性值的样本,只需要按照前面计算出来的集合,按照概率划分到对应的子结点即可

4.5 多变量决策树

很好,本目就是来解决上述连续值处理的过高时间复杂度的问题的。现在对于一个结点,不是选择最优划分属性,而是对建一个合适的线性分类器,如图:

合适的线性分类器

第5章 神经网络

5.1 神经元模型

M-P 神经元模型

我们介绍 M-P 神经元模型。该神经元模型必须具备以下三个特征:

  1. 输入:来自其他连接的神经元传递过来的输入信号
  2. 处理:输入信号通过带权重的连接进行传递,神经元接受到所有输入值的总和,再与神经元的阈值进行比较
  3. 输出:通过激活函数的处理以得到输出

激活函数可以参考 3.3 中的逻辑函数(logistic function),此处将其声明为 sigmoid 函数,同样不采用不。连续光滑的分段函数。

5.2 感知机与多层网络

本目从无隐藏层的感知机出发,介绍神经网络在简单的线性可分问题上的应用;接着介绍含有一层隐藏层的多层感知机,及其对于简单的非线性可分问题上的应用;最后引入多层前馈神经网络模型的概念。

5.2.1 感知机

感知机(Perceptron)

感知机(Perceptron)由两层神经元组成。第一层是输入层,第二层是输出层。其中只有输出层的神经元为功能神经元,也即 M-P 神经元。先不谈如何训练得到上面的 w1,w2,θw_1,w_2,\theta,我们先看看上面的感知机训练出来以后可以有什么功能?

通过单层的感知机,我们可以实现简单的线性可分的分类任务,比如逻辑运算中的 与、或、非 运算,下面演示一下如何使用单层感知机实现上述三种逻辑运算:

与运算、或运算是二维线性可分任务,一定可以找到一条直线将其划分为两个类别:

二维线性可分任务

非运算是一维线性可分任务,同样也可以找到一条直线将其划分为两个类别:

一维线性可分任务

5.2.2 多层感知机

神经网络图例:多层感知机

所谓的多层感知机其实就是增加了一个隐藏层,则神经网络模型就变为三层,含有一个输入层,一个隐藏层,和一个输出层,更准确的说应该是“单隐层网络”。其中隐藏层和输出层中的所有神经元均为功能神经元。

为了学习出网络中的连接权 wiw_i 以及所有功能神经元中的阈值 θj\theta_j,我们需要通过每一次迭代的结果进行参数的修正,对于连接权 wiw_i 而言,我们假设当前感知机的输出为 y^\hat y,则连接权 wiw_i 应做以下调整。其中 η\eta 为学习率。

wiwi+ΔwiΔi=η(yy^)xi\begin{aligned}w_i \leftarrow w_i + \Delta w_i \\\Delta_i = \eta (y - \hat y) x_i\end{aligned}

使用多层感知机实现异或逻辑运算

5.2.3 多层前馈神经网络

多层前馈神经网络结构示意图

所谓多层前馈神经网络,定义就是各层神经元之间不会跨层连接,也不存在同层连接,其中:

  • 输入层仅仅接受外界输入,没有函数处理功能
  • 隐藏层和输出层进行函数处理

5.3 误差逆传播算法

多层网络的学习能力比感知机的学习能力强很多。想要训练一个多层网络模型,仅仅通过感知机的参数学习规则是不够的,我们需要一个全新的、更强大的学习规则。这其中最优秀的就是误差逆传播算法(errorBackPropagation,简称 BP),往往用它来训练多层前馈神经网络。下面我们来了解一下 BP 算法的内容、参数推导与算法流程。

5.3.1 模型参数

我们对着神经网络图,从输入到输出进行介绍与理解:

单隐层神经网络

  • 隐层:对于隐层的第 hh 个神经元
    • 输入:αh=i=1dxivih\alpha_h = \sum_{i=1}^dx_i v_{ih}
    • 输出:bh=f(αhγh)b_h = f(\alpha_h - \gamma_h)
  • 输出层:对于输出层的第 jj 个神经元
    • 输入:βj=h=1qbhwhj\beta_j=\sum_{h=1}^q b_h w_{hj}
    • 输出:y^j=f(βjθj)\hat y_j = f(\beta j - \theta_j)

现在给定一个训练集学习一个分类器。其中每一个样本都含有 dd 个特征,ll 个输出。现在使用标准 BP 神经网络模型,每输入一个样本都迭代一次。对于单隐层神经网络而言,一共有 4 种参数,即:

  • 输入层到隐层的 d×qd \times q 个权值 vih(i=1,2,,d, h=1,2,,q)v_{ih}(i=1,2,\cdots,d,\ h=1,2,\cdots,q)
  • 隐层的 qq 个 M-P 神经元的阈值 γh(h=1,2,,q)\gamma_h(h=1,2,\cdots,q)
  • 隐层到输出层的 q×lq\times l 个权值 whj(h=1,2,,q, j=1,2,,l)w_{hj}(h=1,2,\cdots,q,\ j=1,2,\cdots,l)
  • 输出层的 ll 个 M-P 神经元的阈值 θj(j=1,2,,l)\theta_j(j=1,2,\cdots,l)

5.3.2 参数推导

确定损失函数。

  • 对于上述 4 种参数,我们均采用梯度下降策略。以损失函数的负梯度方向对参数进行调整。每次输入一个训练样本,都会进行一次参数迭代更新,这叫标准 BP 算法

  • 根本目标是使损失函数尽可能小,我们定义损失函数 EE 为当前样本的均方误差,并为了求导计算方便添加一个常量 12\frac{1}{2},对于第 kk 个训练样本,有如下损失函数:

Ek=12j=1l(y^jkyjk)2E_k = \frac{1}{2} \sum _{j=1}^l (\hat y_j^k - y_j^k)^2

确定迭代修正量。

  • 假定当前学习率为 η\eta,对于上述 4 种参数的迭代公式为:

    whjwhj+Δwhjθjθj+Δθjvihvih+Δvihγhγh+Δγh\begin{aligned}w_{hj} &\leftarrow w_{hj}+\Delta w_{hj} \\\theta_{j} &\leftarrow \theta_{j}+\Delta \theta_{j} \\v_{ih} &\leftarrow v_{ih}+\Delta v_{ih} \\\gamma_{h} &\leftarrow \gamma_{h}+\Delta \gamma_{h} \\\end{aligned}

  • 其中,修正量分别为:

    Δwhj=ηgjbhΔθj=ηgjΔvih=ηehxiΔγh=ηeh\begin{aligned}\Delta w_{hj} &= \eta g_j b_h \\\Delta \theta_{j} &= -\eta g_j \\\Delta v_{ih} &= \eta e_h x_i \\\Delta \gamma_{h} &= -\eta e_h \\\end{aligned}

公式表示:

公式表示

隐层到输出层的权重、输出神经元的阈值:

隐层到输出层的权重、输出神经元的阈值

输入层到隐层的权重、隐层神经元的阈值:

输入层到隐层的权重、隐层神经元的阈值

5.3.3 算法流程

对于当前样本的输出损失 EkE_k 和学习率 η\eta,我们进行以下迭代过程:

BP 神经网络算法流程

还有一种 BP 神经网络方法就是累计 BP 神经网络算法,基本思路就是对于全局训练样本计算累计误差,从而更新参数。在实际应用过程中,一般先采用累计 BP 算法,再采用标准 BP 算法。还有一种思路就是使用随机 BP 算法,即每次随机选择一个训练样本进行参数更新。

第6章 支持向量机

依然是分类学习任务。我们希望找到一个超平面将训练集中样本划分开来,那么如何寻找这个超平面呢?下面开始介绍。

本章知识点逻辑链:

支持向量机知识点关系图

6.1 间隔与支持向量

对于只有两个特征,输出只有两种状态的训练集而言,很显然我们得到如下图所示的超平面,并且显然应该选择最中间的泛化能力最强的那一个超平面:

间隔与支持向量

我们定义超平面为:

wTx+b=0w^Tx+b=0

定义支持向量机为满足下式的样例:

wT+b=1wT+b=1\begin{aligned}w^T+b&=1 \\w^T+b&=-1\end{aligned}

很显然,为了求得这“最中间”的超平面,就是让异类支持向量机之间的距离尽可能的大,根据两条平行线距离的计算公式,可知间隔为:

γ=2w\gamma = \frac{2}{|| w ||}

于是最优化目标函数就是:

maxw,b2w\max_{w,b} \frac{2}{||w||}

可以等价转化为:

minw,b12w2s.t.yi(wTxi+b)1(i=1,2,,m)\begin{aligned}&\min_{w,b} \frac{1}{2} ||w||^2 \\&s.t. \quad y_i(w^Tx_i+b) \ge 1 \quad(i=1,2,\cdots,m)\end{aligned}

这就是 SVM(support vector machine)的基本型

6.2 对偶问题

将上述 SVM 基本型转化为对偶问题,从而可以更高效的求解该最优化问题。

对偶转化推导 - 1

对偶转化推导 - 2

于是模型 f(x)f(x)​ 就是:

f(x)=wTx+b=i=1mαiyixiTx+b\begin{aligned}f(x) &= w^Tx+b \\&= \sum_{i=1}^m\alpha_iy_ix_i^Tx+b\end{aligned}

其中参数 b 的求解可通过支持向量得到:

yif(xi)=1yi(i=1mαiyixiTx+b)=1y_if(x_i) = 1 \to y_i\left(\sum_{i=1}^m\alpha_iy_ix_i^Tx+b \right)=1

由于原问题含有不等式约束,因此还需要满足 KKT 条件:

{αi0,对偶可行性yif(xi)1,原始可行性αi(yif(xi)1)=0,互补松弛性\begin{cases}\alpha_i \ge 0&,\text{对偶可行性} \\y_if(x_i) \ge 1&,\text{原始可行性} \\\alpha_i(y_if(x_i)-1) = 0&,\text{互补松弛性}\end{cases}

对于上述互补松弛性:

  • αi>0\alpha_i > 0,则 yif(xi)=1y_if(x_i)=1,表示支持向量,需要保留
  • yif(xi)>1y_if(x_i)>1,则 αi=0\alpha_i = 0​,表示非支持向量,不用保留

现在得到的对偶问题其实是一个二次规划问题,我们可以采用 SMO(Sequential Minimal Optimization) 算法求解。具体略。

6.3 核函数

对原始样本进行升维,即 xiϕ(xi)x_i \to \phi(x_i),新的问题出现了,计算内积 ϕ(xi)Tϕ(xi)\phi(x_i)^T \phi(x_i) 变得很困难,我们尝试解决这个内积的计算,即使用一个函数(核函数)来近似代替上述内积的计算结果,常用的核函数如下:

常用核函数

表格中的高斯核也就是所谓的径向基函数核 (Radial Basis Function Kernel, 简称 RBF 核)\text{(Radial Basis Function Kernel, 简称 RBF 核)},其中的参数 γ=12σ2\gamma=\frac{1}{2\sigma^2},因此 RBF 核的表达式也可以写成:

κ(xi,xj)=exp(γxixj2)\kappa(x_i, x_j) = \exp(-\gamma \|x_i - x_j\|^2)

  • γ\gamma 较大时,exp(γxixj2)\exp(-\gamma \|x_i - x_j\|^2) 的衰减速度会很快。这意味着只有非常接近的样本点才会有较高的相似度。此时,模型会更关注局部特征。并且会导致模型具有较高的复杂度,因为模型会更容易拟合训练数据中的细节和噪声,从而可能导致过拟合。
  • γ\gamma 较小时,exp(γxixj2)\exp(-\gamma \|x_i - x_j\|^2) 的衰减速度会变慢。较远的样本点之间也可能会有较高的相似度。此时,模型会更关注全局特征。但此时模型的复杂度较低,容易忽略训练数据中的细节,从而可能导致欠拟合

6.4 软间隔与正则化

对于超平面的选择,其实并不是那么容易,并且即使训练出了一个超平面,我们也不知道是不是过拟合产生的,因此我们需要稍微减轻约束条件的强度,因此引入软间隔的概念。

我们定义软间隔为:某些样本可以不严格满足约束条件 yi(wTx+b)1y_i(w^Tx+b) \ge 1 从而需要尽可能减少不满足的样本个数,因此引入新的优化项:替代损失函数

loptionl_{\text{option}}

常见的平滑连续的替代损失函数为:

常见的平滑连续的替代损失函数

我们引入松弛变量 ξi\xi_i 得到原始问题的最终形式:

minw,b,ξi12w2+Ci=1mξi\min_{w,b,\xi_i} \quad \frac{1}{2}||w||^2+C\sum_{i=1}^m \xi_i

6.5 支持向量回归

支持向量回归(Support Vector Regression,简称 SVR)与传统的回归任务不同,传统的回归任务需要计算每一个样本的误差,而支持向量回归允许有一定的误差,即,仅仅计算落在隔离带外面的样本损失。

原始问题:

原始问题

约束条件

对偶问题:

对偶问题

KKT 条件:

KKT 条件

预测模型:

预测模型

6.6 核方法

通过上述:支持向量基本型、支持向量软间隔化、支持向量回归,三个模型的学习,可以发现最终的预测模型都是关于核函数与拉格朗日乘子的线性组合,那么这是巧合吗?并不是巧合,这其中其实有一个表示定理:

h(x)=i=1mαiκ(x,xi)h^*(x) = \sum_{i=1}^m\alpha_i \kappa(x, x_i)

第7章 贝叶斯分类器

7.1 贝叶斯决策论

pass

7.2 极大似然估计

根本任务:寻找合适的参数 θ^\hat \theta 使得「当前的样本情况发生的概率」最大。又由于假设每一个样本相互独立,因此可以用连乘的形式表示上述概率,当然由于概率较小导致连乘容易出现浮点数精度损失,因此尝尝采用取对数的方式来避免「下溢」问题。也就是所谓的「对数似然估计」方法。

我们定义对数似然 (log-likelihood)\text{(log-likelihood)} 估计函数如下:

LL(θc)=logP(Dc  θc)=xDclogP(x  θc)\begin{aligned}LL(\theta_c) &= \log P(D_c\ |\ \theta_c) \\ &= \sum_{x \in D_c} \log P(x\ |\ \theta_c)\end{aligned}

此时参数 θ^\hat \theta 的极大似然估计就是:

θ^c=argmaxθcLL(θc)\hat \theta_c = \arg \max_{\theta_c} LL(\theta_c)

7.3 朴素贝叶斯分类器

我们定义当前类别为 cc,则 P(c)P(c) 称为类先验概率,P(x  c)P(x\ |\ c) 称为类条件概率。最终的贝叶斯判定准则为:

P(c  x)=P(c)P(x  c)P(x)P(c\ |\ x) = \frac{P(c)P(x\ |\ c)}{P(x)}

现在假设各属性之间相互独立,则对于拥有 d 个属性的训练集,在利用贝叶斯定理时,可以通过连乘的形式计算类条件概率 P(x  c)P(x \ | \ c),于是上式变为:

P(c  x)=P(c)P(x)i=1dP(xi  c)P(c\ |\ x) = \frac{P(c)}{P(x)} \prod_{i=1}^d P(x_i\ |\ c)

注意点:

  • 对于离散数据。上述类条件概率的计算方法很好计算,直接统计即可
  • 对于连续数据。我们就没法直接统计数据数量了,替换方法是使用高斯函数。我们根据已有数据计算得到一个对于当前属性的高斯函数,后续计算测试样例对应属性的条件概率,代入求得的高斯函数即可。
  • 对于类条件概率为 0 的情况。我们采用拉普拉斯修正。即让所有属性的样本个数 +1+1,于是总样本数就需要 +d+d 来确保总概率仍然为 11。这是额外引入的 bias

7.4 半朴素贝叶斯分类器

朴素贝叶斯的问题是假设过于强,现实不可能所有的属性都相互独立。半朴素贝叶斯弱化了朴素贝叶斯的假设。现在假设每一个属性最多只依赖一个其他属性。即独依赖估计 (One-Dependent Estimator, 简称 ODE)(\text{One-Dependent Estimator, 简称 ODE}),于是就有了下面的贝叶斯判定准测:

P(c  x)P(c)i=1dP(xi  c,pai)P(c\ |\ x) \propto P(c) \prod _{i=1}^d P(x_i\ |\ c, pa_i)

如何寻找依赖关系?我们从属性依赖图出发

属性依赖图

如上图所示:

  • 朴素贝叶斯算法中:假设所有属性相互独立,因此各属性之间没有连边
  • SPODE 确定属性父属性算法中:假设所有属性都只依赖于一个属性(超父),我们只需要找到超父即可
  • TAN 确定属性父属性算法中:我们需要计算每一个属性之间的互信息,最后得到一个以互信息为边权的完全图。最终选择最大的一些边构成一个最大带权生成树
  • AODE 确定属性父属性算法中:采用集成的思想,以每一个属性作为超父属性,最后选择最优即可

7.5 贝叶斯网

构造一个关于属性之间的 DAG 图,从而进行后续类条件概率的计算。三种典型依赖关系:

同父结构V型结构顺序结构
同父结构V型结构顺序结构
在已知 x1x_1 的情况下 x3,x4x_3,x_4 独立x4x_4 未知则 x1,x2x_1,x_2 独立,反之不独立在已知 xx 的情况下 y,zy,z 独立

概率计算公式参考:超详细讲解贝叶斯网络(Bayesian network)

7.6 EM 算法

现在我们需要解决含有隐变量 ZZ 的情况。如何在已知数据集含有隐变量的情况下计算出模型的所有参数?我们引入 EM 算法。EM 迭代型算法共两步

  • E-step:首先利用观测数据 XX 和参数 Θt\Theta_t 得到关于隐变量的期望 ZtZ^t
  • M-step:接着对 XXZtZ^t 使用极大似然函数来估计新的最优参数 Θt+1\Theta_{t+1}

有两个问题:哪来的参数?什么时候迭代终止?

  • 对于第一个问题:我们随机化初始得到参数 Θ0\Theta_0
  • 对于第二个问题:相邻两次迭代结果中参数差值的范数小于阈值 (θ(i+1)θ(i))<ϵ1)(|| \theta^{(i+1)} - \theta^{(i)}) || < \epsilon_1)隐变量条件分布期望差值的范数小于阈值 (Q(θ(i+1),θ(i)))Q(θ(i),θ(i))<ϵ2)(|| Q(\theta^{(i+1)} , \theta^{(i)})) - Q(\theta^{(i)} , \theta^{(i)}) || < \epsilon_2)

第8章 集成学习

8.1 个体与集成

集成学习由多个不同的组件学习器组合而成。学习器不能太坏并且学习器之间需要有差异。如何产生并结合“好而不同”的个体学习器是集成学习研究的核心。集成学习示意图如下:

多个不同的学习组件

根据个体学习器的生成方式,目前集成学习可分为两类,代表作如下:

  1. 个体学习器直接存在强依赖关系,必须串行生成的序列化方法:Boosting
  2. 个体学习器间不存在强依赖关系,可以同时生成的并行化方法:Bagging随机森林 (Random Forest)

8.2 Boosting

Boosting 算法族的逻辑:

  1. 个体学习器之间存在强依赖关系
  2. 串行生成每一个个体学习器
  3. 每生成一个新的个体学习器都要调整样本分布

以 AdaBoost 算法为例,问题式逐步深入算法实现:

  1. 如何计算最终集成的结果?利用加性模型 (additive model),假定第 ii 个学习器的输出为 h(x)h(x),第 ii 个学习器的权重为 αi\alpha_i,则集成输出 H(x)H(x) 为:

    H(x)=sign(i=1Tαihi(x))H(x) = \text{sign} \left(\sum_{i=1}^T \alpha_i h_i(x)\right)

  2. 如何确定每一个学习器的权重 αi\alpha_i ?我们定义 αi=12ln(1ϵiϵi)\displaystyle \alpha_i=\frac{1}{2}\ln (\frac{1-\epsilon_i}{\epsilon_i})

  3. 如何调整样本分布?我们对样本进行赋权。学习第一个学习器时,所有的样本权重相等,后续学习时的样本权重变化规则取决于上一个学习器的分类情况。上一个分类正确的样本权重减小,上一个分类错误的样本权重增加,即:

    Di+1(x)=Di(x)Zi×{eαi,hi(x)=f(x)eαi,hi(x)f(x)D_{i+1}(x) = \frac{D_i(x)}{Z_i} \times \begin{cases}e^{-\alpha_i}&,h_i(x)=f(x) \\e^{\alpha_i}&,h_i(x)\ne f(x)\end{cases}

代表算法:AdaBoost、GBDT、XGBoost

8.3 Bagging 与 随机森林

在指定学习器个数 TT 的情况下,并行训练 TT 个相互之间没有依赖的基学习器。最著名的并行式集成学习策略是 Bagging,随机森林是其的一个扩展变种。

8.3.1 Bagging

问题式逐步深入 Bagging 算法实现:

  1. 如何计算最终集成的结果?直接进行大数投票即可,注意每一个学习器都是等权重的
  2. 如何选择每一个训练器的训练样本?顾名思义,就是进行 TT 次自助采样法
  3. 如何选择基学习器?往往采用决策树 or 神经网络

8.3.2 随机森林

问题式逐步深入随机森林 (Random Forest,简称 RF) 算法实现:

  1. 如何计算最终集成的结果?直接进行大数投票即可,注意每一个学习器都是等权重的
  2. 为什么叫森林?每一个基学习器都是「单层」决策树
  3. 随机在哪?首先每一个学习器对应的训练样本都是随机的,其次每一个基学习器的属性都是随机的 k(k[1,V])k(k \in [1,V])​ 个(由于基学习器是决策树,并且属性是不完整的,故这些决策树都被称为弱决策树)

8.3.3 区别与联系(补)

区别:随机森林与 Bagging 相比,多增加了一个属性随机,进而提升了不同学习器之间的差异度,进而提升了模型性能,效果如下:

提升了模型性能

联系:两者均采用自助采样法。优势在于,不仅解决了训练样本不足的问题,同时 T 个学习器的训练样本之间是有交集的,这也就可以减小测试的方差。

8.4 区别与联系(补)

区别:

  • 序列化基学习器最终的集成结果往往采用加权投票
  • 并行化基学习器最终的集成结果往往采用平权投票

联系:

  • 序列化减小偏差。即可以增加拟合性,降低欠拟合
  • 并行化减小方差。即可以减少数据扰动带来的误差

8.5 结合策略

基学习器有了,如何确定最终模型的集成输出呢?我们假设每一个基学习器对于当前样本 xx 的输出为 hi(x),i=1,2,,Th_i(x),i=1,2,\cdots,T,结合以后的输出结果为 H(x)H(x)

8.5.1 平均法

对于数值型输出 hi(x)Rh_i(x) \in R,常见的结合策略采是平均法。分为两种:

  1. 简单平均法:H(x)=1Ti=1Thi(x)H(x) = \displaystyle \frac{1}{T} \sum_{i =1}^T h_i(x)
  2. 加权平均法:H(x)=i=1Twihi(x),wi0,i=1Twi=1H(x) = \displaystyle\sum_{i=1}^T w_i h_i(x),\quad w_i \ge 0, \sum_{i=1}^Tw_i = 1

显然简单平均法是加权平均法的特殊。一般而言,当个体学习器性能差距较大时采用加权平均法,相近时采用简单平均法。

8.5.2 投票法

对于分类型输出 hi(x)=[hi1(x),hi2(x),,hiN(x)]h_i(x) = [h_i^1(x), h_i^2(x), \cdots, h_i^N(x)],即每一个基学习器都会输出一个 NN 维的标记向量,其中只有一个打上了标记。常见的结合策略是投票法。分为三种:

  1. 绝对多数投票法:选择超过半数的,如果没有则拒绝投票
  2. 相对多数投票法:选择票数最多的,如果有多个相同票数的则随机取一个
  3. 加权投票法:每一个基学习器有一个权重,从而进行投票

8.5.3 学习法

其实就是将所有基学习器的输出作为训练数据,重新训练一个模型对输出结果进行预测。其中,基学习器称为“初级学习器”,输出映射学习器称为“次级学习器”或”元学习器” (meta-learner)\text{(meta-learner)}。对于当前样本 (x,y)(x,y)nn 个基学习器的输出为 y1=h1(x),y2=h2(x),,yn=hn(x)y_1 = h_1(x),y_2 = h_2(x),\cdots,y_n = h_n(x),则最终输出 H(x)H(x) 为:

H(x)=G(y1,y2,,yn)H(x) = G(y_1, y_2, \cdots, y_n)

其中 GG 就是次级学习器。关于次级学习器的学习算法,大约有以下几种:

  1. Stacking
  2. 多响应线性回归 (Mutil-response linear regression, 简称 MLR)\text{(Mutil-response linear regression, 简称 MLR)}
  3. 贝叶斯模型平均 (Bayes Model Averaging, 简称 BMA)\text{(Bayes Model Averaging, 简称 BMA)}

经过验证,Stacking 的泛化能力往往比 BMA 更优。

8.6 多样性

8.6.1 误差-分歧分解

根据「回归」任务的推导可得下式。其中 EE 表示集成的泛化误差、E\overline{E} 表示个体学习器泛化误差的加权均值、A\overline{A} 表示个体学习器的加权分歧值

E=EAE = \overline{E} - \overline{A}

8.6.2 多样性度量

如何估算个体学习器的多样化程度呢?典型做法是考虑两两学习器的相似性。所有指标都是从两个学习器的分类结果展开,以两个学习器 hi,hjh_i,h_j 进行二分类为例,有如下预测结果列联表:

预测结果列联表

显然 a+b+c+d=ma+b+c+d=m。基于此有以下常见的多样性度量指标:

  • 不合度量
  • 相关系数
  • Q-统计量
  • κ\kappa​-统计量

以「κ\kappa-统计量」为例进行说明。两个学习器的卡帕结果越接近1,则越相似。可以分析出:

  • 串行的基学习器多样性较并行的基学习器更高
  • 串行的基学习器误差较并行的基学习器也更高

卡帕-统计量

8.6.3 多样性增强

为了训练出多样性大的个体学习器,我们引入随机性。介绍几种扰动策略:

  • 数据样本扰动
  • 输入属性扰动
  • 输出表示扰动
  • 算法参数扰动

第9章 聚类

9.1 聚类任务

典型的无监督学习方法。即将众多无标签数据进行分类。在开始介绍几类经典的聚类学习方法之前,我们先讨论聚类算法涉及到的两个基本问题:性能度量和距离计算。

9.2 性能度量

一来是进行聚类的评估,二来也可以作为聚类的优化目标。分为两种,分别是外部指标和内部指标。

9.2.1 外部指标

所谓外部指标就是已经有一个“参考模型”存在了,将当前模型与参考模型的比对结果作为指标。我们考虑两两样本的聚类结果,定义下面的变量:

外部指标变量

显然 a+b+c+d=m(m1)/2a+b+c+d=m(m-1)/2,常见的外部指标如下:

  • JC 指数:JC=aa+b+c\displaystyle JC = \frac{a}{a+b+c}
  • FM 指数:aa+baa+c\displaystyle \sqrt{\frac{a}{a+b} \cdot \frac{a}{a+c}}
  • RI 指数:2(a+d)m(m1)\displaystyle \frac{2(a+d)}{m(m-1)}

上述指数取值均在 [0,1][0,1] 之间,且越大越好。

9.2.2 内部指标

所谓内部指标就是仅仅考虑当前模型的聚类结果。同样考虑两两样本的聚类结果,定义下面的变量:

内部指标变量

常见的外部指标如下:

  • DB 指数
  • Dunn 指数

9.3 距离计算

有序属性:闵可夫斯基距离

无序属性:VDM 距离

9.4 原型聚类

9.4.1 k 均值算法

算法流程大体上可以归纳为三步:

  1. 初始化 k 个聚类中心
  2. 枚举所有样本并将其划分到欧氏距离最近的中心
  3. 更新 k 个簇中心

重复上述迭代过程直到聚类中心不再发生变化,当然为了防止迭代时间过久,可以弱化迭代终止条件,比如增设:最大迭代论述或对聚类中心的变化增加一个阈值而非绝对的零变化。k 均值算法伪代码如下:

k 均值算法伪代码

9.4.2 学习向量量化算法

9.4.3 高斯混合聚类算法

高斯混合聚类算法 - 流程图

9.5 密度聚类

DBSCAN 算法

适用于簇的大小不定,距离不定的情况。大概就是多源有向bfs的过程。定义每一源为“核心对象”,每次以核心对象为起始进行bfs,将每一个符合“范围约束”的近邻点纳入当前簇,不断寻找知道所有核心对象都访问过为止。

9.6 层次聚类

AGNES 算法

适用于可选簇大小、可选簇数量的情况。

第10章 降维与度量学习

本章我们通过 k 近邻监督学习方法引入「降维」和「度量学习」的概念。

  • 对于降维算法:根本原因是样本往往十分稀疏,需要我们对样本的属性进行降维,从而达到合适的密度进而可以进行基于距离的邻居选择相关算法。我们将重点讨论以下几个降维算法:MDS 多维缩放PCA 主成分分析等度量映射
  • 对于度量学习:上述降维的根本目的是选择合适的维度,确保一定可以通过某个距离计算表达式计算得到每一个样本的 k 个邻居。而度量学习直接从目的出发,尝试直接学习样本之间的距离计算公式。我们将重点讨论以下几个度量学习算法:近邻成分分析LMNN

10.1 k 近邻学习

k 近邻(k-Nearest Neighbor,简称 KNN)是一种监督学习方法。一句话概括就是「近朱者赤近墨者黑」,每一个测试样本的分类或回归结果取决于在某种距离度量下的最近的 k 个邻居的性质。不需要训练,可以根据检测样本实时预测,即懒惰学习。为了实现上述监督学习的效果,我们需要解决以下两个问题:

  • 如何确定「距离度量」的准则?就那么几种,一个一个试即可。
  • 如何定义「分类结果」的标签?分类任务是 k 个邻居中最多类别的标签,回归任务是 k 个邻居中最多类别标签的均值。

上述学习方法有什么问题呢?对于每一个测试样例,我们需要确保能够通过合适的距离度量准则选择出 k 个邻居出来,这就需要保证数据集足够大。在距离计算时,每一个属性都需要考虑,于是所需的样本空间会呈指数级别的增长,这被称为「维数灾难」。这是几乎不可能获得的数据量同时在进行矩阵运算时时间的开销也是巨大的。由此引入本章的关键内容之一:降维。

为什么能降维?我们假设学习任务仅仅是高维空间的一个低维嵌入。

10.2 多维缩放降维

多维缩放(MDS)降维算法」的原则:对于任意的两个样本,降维后两个样本之间的距离保持不变。

基于此思想,可以得到以下降维流程:我们定义 bijb_{ij} 为降维后任意两个样本之间的内积,distijdist_{ij} 表示任意两个样本的原始距离,ZRd×m,ddZ \in R^{d'\times m},d' \le d 为降维后数据集的属性值矩阵。

内积计算:

内积计算

新属性值计算:特征值分解法。其中 B=VΛVTB = V \Lambda V^T

新属性值计算

10.3 主成分分析降维

主成分分析(Principal Component Analysis,简称 PCA)降维算法」的两个原则:

  • 样本到超平面的距离都尽可能近
  • 样本在超平面的投影都尽可能分开

基于此思想可以得到 PCA 的算法流程:

PCA 算法流程

假设我们有一个简单的数据集 DD,包括以下三个样本点:

x1=(23),x2=(34),x3=(45)x_1 = \begin{pmatrix} 2 \\ 3 \end{pmatrix}, \quad x_2 = \begin{pmatrix} 3 \\ 4 \end{pmatrix}, \quad x_3 = \begin{pmatrix} 4 \\ 5 \end{pmatrix}

我们希望将这些样本从二维空间降维到一维空间(即 d=1d' = 1 )。

步骤 1: 样本中心化

首先计算样本的均值向量:

μ=13(x1+x2+x3)=13(23)+13(34)+13(45)=(34)\mu = \frac{1}{3} (x_1 + x_2 + x_3) = \frac{1}{3} \begin{pmatrix} 2 \\ 3 \end{pmatrix} + \frac{1}{3} \begin{pmatrix} 3 \\ 4 \end{pmatrix} + \frac{1}{3} \begin{pmatrix} 4 \\ 5 \end{pmatrix} = \begin{pmatrix} 3 \\ 4 \end{pmatrix}

然后对所有样本进行中心化:

x~1=x1μ=(23)(34)=(11)\tilde{x}_1 = x_1 - \mu = \begin{pmatrix} 2 \\ 3 \end{pmatrix} - \begin{pmatrix} 3 \\ 4 \end{pmatrix} = \begin{pmatrix} -1 \\ -1 \end{pmatrix}

x~2=x2μ=(34)(34)=(00)\tilde{x}_2 = x_2 - \mu = \begin{pmatrix} 3 \\ 4 \end{pmatrix} - \begin{pmatrix} 3 \\ 4 \end{pmatrix} = \begin{pmatrix} 0 \\ 0 \end{pmatrix}

x~3=x3μ=(45)(34)=(11)\tilde{x}_3 = x_3 - \mu = \begin{pmatrix} 4 \\ 5 \end{pmatrix} - \begin{pmatrix} 3 \\ 4 \end{pmatrix} = \begin{pmatrix} 1 \\ 1 \end{pmatrix}

步骤 2: 计算协方差矩阵

样本的协方差矩阵为:

XXT=1mi=1mx~ix~iT=13((11)(11)+(00)(00)+(11)(11))=13((1111)+(0000)+(1111))=13(2222)=(23232323)\begin{aligned}XX^T &= \frac{1}{m} \sum_{i=1}^m \tilde{x}_i \tilde{x}_i^T \\&= \frac{1}{3} \left( \begin{pmatrix} -1 \\ -1 \end{pmatrix} \begin{pmatrix} -1 & -1 \end{pmatrix} + \begin{pmatrix} 0 \\ 0 \end{pmatrix} \begin{pmatrix} 0 & 0 \end{pmatrix} + \begin{pmatrix} 1 \\ 1 \end{pmatrix} \begin{pmatrix} 1 & 1 \end{pmatrix} \right)\\&= \frac{1}{3} \left( \begin{pmatrix} 1 & 1 \\ 1 & 1 \end{pmatrix} + \begin{pmatrix} 0 & 0 \\ 0 & 0 \end{pmatrix} + \begin{pmatrix} 1 & 1 \\ 1 & 1 \end{pmatrix} \right) \\&= \frac{1}{3} \begin{pmatrix} 2 & 2 \\ 2 & 2 \end{pmatrix} \\&= \begin{pmatrix} \frac{2}{3} & \frac{2}{3} \\ \frac{2}{3} & \frac{2}{3} \end{pmatrix}\end{aligned}

步骤 3: 对协方差矩阵进行特征值分解

协方差矩阵的特征值分解:

(23232323)=(1111)(43000)(1111)\begin{pmatrix} \frac{2}{3} & \frac{2}{3} \\ \frac{2}{3} & \frac{2}{3} \end{pmatrix} = \begin{pmatrix} 1 & 1 \\ -1 & 1 \end{pmatrix} \begin{pmatrix} \frac{4}{3} & 0 \\ 0 & 0 \end{pmatrix} \begin{pmatrix} 1 & -1 \\ 1 & 1 \end{pmatrix}

特征值为 λ1=43\lambda_1 = \frac{4}{3}λ2=0\lambda_2 = 0,对应的特征向量分别为:

w1=(11),w2=(11)w_1 = \begin{pmatrix} 1 \\ 1 \end{pmatrix}, \quad w_2 = \begin{pmatrix} -1 \\ 1 \end{pmatrix}

步骤 4: 取最大的 dd' 个特征值对应的特征向量

我们选择最大的特征值对应的特征向量 w1=(11)w_1 = \begin{pmatrix} 1 \\ 1 \end{pmatrix} 作为最终的投影矩阵。

10.4 核化线性降维

核化线性降维算法」的原则:pass

10.5 流形学习降维

假定数据满足流形结构。

10.5.1 等度量映射

流形样本中,直接计算两个样本之间的欧式距离是无效的。我们引入「等度量映射」理念。根本算法逻辑是:利用最短路算法计算任意两个样本之间的「测地线距离」得到 distijdist_{ij},接着套用上述 10.2 中的 MDS 算法即可进行降维得到最终的属性值矩阵 ZRd×m,ddZ \in R^{d'\times m},d' \le d

10.5.2 局部线性嵌入

pass

10.6 度量学习

降维的本质是寻找一种合适的距离度量方法,与其降维为什么不直接学习一种距离计算方法呢?我们引入「度量学习」的理念。

为了“有参可学”,我们需要定义距离计算表达式中的超参,我们定义如下「马氏距离」:

马氏距离

为什么用所谓的马氏距离呢?欧氏距离不行吗?我们有以下结论:

  • 欧式距离具有旋转不变性和平移不变性,在低维和属性直接相互独立时是最佳实践。但是当属性之间有相关性并且尺度相差较大时,直接用欧式距离计算会丢失重要的特征之间的信息;
  • 马氏距离具有尺度不变性,在高维和属性之间有关系且尺度不同时是最佳实践。缺点在于需要计算协方差矩阵导致计算量远大于欧氏距离的计算量。

下面介绍两种度量学习算法来学习上述 M 矩阵,也就是数据集的「协方差矩阵的逆」中的参数。准确的说是学习一个矩阵来近似代替协方差矩阵的逆矩阵。

10.6.1 近邻成分分析

近邻成分分析 (Neighborhood Component Analysis, 简称 NCA)\text{(Neighborhood Component Analysis, 简称 NCA)},目标函数是:最小化所有数据点的对数似然函数的负值。

10.6.2 LMNN

大间隔最近邻 (Large Margin Nearest Neighbor, 简称 LMNN)\text{(Large Margin Nearest Neighbor, 简称 LMNN)},目标函数是:最小化同一个类别中最近邻点的距离,同时最大化不同类别中最近邻点的距离。

paper: Distance Metric Learning for Large Margin Nearest Neighbor Classification

explain: 【LMNN】浅析"从距离测量到基于Margin的邻近分类问题"

第13章 半监督学习

半监督学习的根本目标:同时利用有标记和未标记样本的数据进行学习,提升模型泛化能力。主要分为三种:

  1. 主动学习
  2. 纯半监督学习
  3. 直推学习

三种半监督学习

13.1 未标记样本

对未标记数据的分布进行假设,两种假设:

  1. 簇状分布
  2. 流形分布

13.2 生成式方法

分别介绍「生成式方法」和「判别式方法」及其区别和联系。

生成式方法:核心思想就是用联合分布 p(x,y)p(x,y) 进行建模,即对特征分布 p(x)p(x) 进行建模,十分关心数据是怎么来(生成)的。生成式方法需要对数据的分布进行合理的假设,这通常需要计算类先验概率 p(y)p(y) 和特征条件概率 p(x  y)p(x\ |\ y),之后再在所有假设之上进行利用贝叶斯定理计算后验概率 p(y  x)p(y\ |\ x)。典型的例子如:

  • 朴素贝叶斯
  • 高斯混合聚类
  • 马尔科夫模型

判别式方法:核心思想就是用条件分布 p(y  x)p(y\ |\ x) 进行建模,不对特征分布 p(x)p(x) 进行建模,完全不管数据是怎么来(生成)的。即直接学一个模型 p(y  x)p(y\ |\ x) 来对后续的输入进行预测。不需要对数据分布进行过多的假设。典型的例子如:

  • 线性回归
  • 逻辑回归
  • 决策树
  • 神经网络
  • 支持向量机
  • 条件随机场

自监督训练(补)

根本思想就是利用有标记数据进行模型训练,然后对未标记数据进行预测,选择置信度较高的一些样本加入训练集重新训练模型,不断迭代进行直到最终训练出来一个利用大量未标记数据训练出来的模型。

如何定义置信度高?我们利用信息熵的概念,即对于每一个测试样本都有一个预测向量,信息熵越大表明模型对其的预测结果越模糊,因此置信度高正比于信息熵小,将信息熵较小的测试样本打上「伪标记」加入训练集。

13.3 半监督 SVM

以经典 S3VM 中的经典算法 TSVM 为例。给出优化函数、算法图例、算法伪代码:

优化函数

算法图例

二分类 - 伪代码

13.4 图半监督学习

同样解决分类问题,以「迭代式标记传播算法」为例。

二分类,可以直接求出闭式解。

算法逻辑。每一个样本对应图中的一个结点,两个结点会连上一个边,边权正比于两结点样本的相似性。最终根据图中已知的某些结点进行传播标记即可。与基于密度的聚类算法类似,区别在于此处不同的簇 cluster 可能会对应同一个类别 class。

如何进行连边?不会计算每一个样本的所有近邻,一般采用局部近邻选择连边的点,可以 k 近邻,也可以范围近邻。

优化函数。定义图矩阵的能量损失函数为图中每一个结点与所有结点的能量损失和,目标就是最小化能量损失和:

优化函数

多分类,无法直接求出闭式解,只能进行迭代式计算。

新增变量。我们定义标记矩阵 F,其形状为 (l+u)×d(l+u) \times d,学习该矩阵对应的值,最终每一个未标记样本 xix_i 就是 argmaxFi\arg \max F_i

多分类 - 伪代码

13.5 基于分歧的方法

多学习器协同训练。

第14章 概率图模型

为了分析变量之间的关系,我们建立概率图模型。按照图中边的性质可将概率图模型分为两类:

  • 有向图模型,也称贝叶斯网
  • 无向图模型,也称马尔可夫网

14.1 隐马尔可夫模型

隐马尔可夫模型 (Hidden Markov Model, 简称 HMM)\text{(Hidden Markov Model, 简称 HMM)} 是结构最简单的动态贝叶斯网。是为了研究变量之间的关系而存在的,因此是生成式方法。

需要解决三个问题:

  1. 如何评估建立的网络模型和实际观测数据的匹配程度?
  2. 如果上面第一个问题中匹配程度不好,如何调整模型参数来提升模型和实际观测数据的匹配程度呢?
  3. 如何根据实际的观测数据利用网络推断出有价值的隐藏状态?

14.2 马尔科夫随机场

马尔科夫随机场 (Markov Random Field, 简称 MRF)\text{(Markov Random Field, 简称 MRF)} 是典型的马尔科夫网。同样是为了研究变量之间的关系而存在的,因此也是生成式方法。

联合概率计算逻辑按照势函数展开。其中团可以理解为完全子图;极大团就是结点最多的完全子图,即在当前的完全子图中继续添加子图之外的点就无法构成新的完全子图;势函数就是一个关于团的函数。

14.3 条件随机场

判别式方法。

14.4 学习和推断

精确推断。

14.5 近似推断

近似推断。降低计算时间开销。

  • 采样法:蒙特卡洛采样法
  • 变分推断:考虑近似分布

考试大纲

  • 单选 10 * 3':单选直接一个单刀咆哮。注意审题、注意一些绝对性的语句;
  • 简答 4 * 5':想拿满分就需要写出理论对应的具体公式;
  • 计算 3 * 10':老老实实写每一个计算过程,不要跳步骤;
  • 论述 2 * 10':没考过,大约是简答题加强版。注意逻辑一定要清晰。
]]> + 机器学习与模式识别

前言

学科地位:

主讲教师学分配额学科类别
杨琬琪3专业课

成绩组成:

理论课:

作业+课堂课堂测验(2次)期末(闭卷)
30%20%50%

实验课:

19次实验(五级制)
100%

教材情况:

课程名称选用教材版次作者出版社ISBN号
机器学习与模式识别《机器学习与模式识别》周志华清华大学出版社978-7-302-42328-7

学习资源:

实验平台:

本地路径:

  • 🍉 西瓜书电子书:[机器学习_周志华](D:\华为云盘\2. Score\4. 机器学习与模式识别\机器学习_周志华.pdf)
  • 🎃 南瓜书电子书:[pumpkin_book](D:\华为云盘\2. Score\4. 机器学习与模式识别\pumpkin-book\pdf\pumpkin_book_1.9.9.pdf)
  • 📃 上课 PPT by 周志华:[机器学习_课件_周志华](D:\华为云盘\2. Score\4. 机器学习与模式识别\机器学习_课件_周志华)
  • 📃 上课 PPT by 杨琬琪:[机器学习_课件_杨琬琪](D:\华为云盘\2. Score\4. 机器学习与模式识别\机器学习_课件_杨琬琪)

第1章 绪论

1.1 引言

pass

1.2 基本术语

NameIntroduction
机器学习定义利用经验改善系统自身性能,主要研究智能数据分析的理论和方法。
计算学习理论最重要的理论模型是 PAC(Probably Approximately Correct, 概率近似正确) learning model,即以很高的概率得到很好的模型 $P(
P 问题在多项式时间内计算出答案的解
NP 问题在多项式时间内检验解的正确性
特征(属性)
特征(属性)值连续 or 离散
样本维度特征(属性)个数
特征(属性、输入)空间特征张成的空间
标记(输出)空间标记张成的空间
样本<x>
样例<x,y>
预测任务监督学习、无监督学习、半监督学习、噪音标记学习、多标记学习
泛化能力应对未来未见的测试样本的能力
独立同分布假设历史和未来的数据来自相同的分布

1.3 假设空间

假设空间:所有可能的样本组合构成的集合空间

版本空间:根据已知的训练集,将假设空间中与正例不同的、反例一致的样本全部删掉,剩下的样本组合构成的集合空间

1.4 归纳偏好

No Free Launch 理论,没有很好的算法,只有适合的算法。好的算法来自于对数据的好假设、好偏执,大胆假设,小心求证

1.5 发展历程

pass

1.6 应用现状

pass

第2章 模型评估与选择

2.1 经验误差与过拟合

概念辨析

  • 错误率:针对测试数据而言,分错的样本数 aa 占总样本数 mm 的比例 E=amE=\frac{a}{m}
  • 经验误差:针对训练数据而言,随着训练轮数或模型的复杂度越高,经验误差越小

误差训练曲线

过拟合解决方法

  • Early Stopping (当发现有过拟合现象就停止训练)

  • Penalizing Large Weight (在经验风险上加一个正则化项)

    在后面的学习中经常会和正则化打交道,因此此处补充一下相关的概念。

    在目标函数中添加正则化到底有什么好处?给几个解释:

    1. 防止过拟合。为什么可以防止过拟合?其实可以形象化的将添加的正则化项理解为一个可以调节的「累赘」,为了让原始问题尽可能的最优,我让累赘愈发拖累目标函数的取值,这样原始问题就不得不更优以此来抵消累赘带来的拖累。
    2. 可以进行特征选择。个人认为属于第一点的衍生意义,为什么这么说?同样用累赘来比喻正则化项。当原始问题的某些变量为了代偿拖累导致系数都接近于零了,那么这个变量也就没有存在的意义了,于是对应的特征也就被筛选掉了,也就是所谓的特征选择了。常常添加 L1 正则化项来进行所谓的特征选择。
    3. 提升计算效率。同样可以理解为第三点的衍生意义,为什么这么说?因为你都把变量筛掉了,那么对应的解空间是不是就相应的大幅度减少了,于是最优解的搜索也就更加快速了。

    当然正则化项并非万能的万金油,在整个目标函数中正则化项有这举足轻重的意义,一旦正则化项的系数发生了微小的变动,对于整个模型的影响都是巨大的。因此有时添加正则化项并不一定可以带来泛化性能的提升。

    正则化有以下形式:

    1. 一般式:

      xpk=((i=1mxip)1p)k||\mathbf{x}||_p^k = \left ( \left ( \sum_{i=1}^{m}|x_i|^{p} \right)^{\frac{1}{p}} \right)^k

    2. L1 正则化:

      x1=i=1mxi||\mathbf{x}||_1 = \sum_{i=1}^m |x_i|

    3. L2 正则化:

      x22=((i=1mxi2)12)2=i=1mxi2\begin{aligned}||\mathbf{x}||_2^2 &= \left ( \left ( \sum_{i=1}^{m}|x_i|^{2} \right)^{\frac{1}{2}} \right)^2 \\&= \sum_{i=1}^{m}|x_i|^{2}\end{aligned}

  • Bagging 思想 (对同一样本用多个模型投票产生结果)

  • Boosting 思想 (多个弱分类器增强分类能力,降低偏差)

  • Dropconnection (神经网络全连接层中减少过拟合的发生)

欠拟合解决方法

  • 决策树:拓展分支

  • 神经网络:增加训练轮数

2.2 评估方法

  • 留出法(hold-out):将数据集分为三个部分,分别为训练集、验证集、测试集。测试集对于训练是完全未知的,我们划分出测试集是为了模拟未来未知的数据,因此当下的任务就是利用训练集和验证集训练出合理的模型来尽可能好的拟合测试集。那么如何使用划分出的训练集和验证集来训练、评估模型呢?就是根据模型的复杂度 or 模型训练的轮数,根据上图的曲线情况来选择模型。

  • 交叉验证法(cross validation):一般方法为 p 次 k 折交叉验证,即 p 次将训练数据随机划分为 k 个大小相似的互斥子集。将其中 k1k-1 份作为训练数据,11 份作为验证数据,每轮执行 kk 次获得平均误差。执行 p 次划分主要是为了减小划分方法带来的误差。

  • 自助法(bootstrapping):有放回采样获得训练集。每轮从数据集 DD 中(共 mm 个样本)有放回的采样 mm 次,这 mm 个抽出来的样本集合 DD' 大约占数据集的 23\frac{2}{3},于是就可以将抽出的样本集合 DD' 作为训练集,DDD-D' 作为测试集即可

    测试集占比 1/3 证明过程

2.3 性能度量

2.3.1 回归任务

均方误差:MSE=1mi=1m(f(xi)yi)2\displaystyle MSE=\frac{1}{m} \sum_{i=1}^m(f(x_i) - y_i)^2

均方根误差:RMSE=1mi=1m(f(xi)yi)2\displaystyle RMSE=\sqrt{\frac{1}{m} \sum_{i=1}^m(f(x_i) - y_i)^2}

R2R^2 分数:R2=1i=1m(f(xi)yi)2i=1m(yˉyi)2,yˉ=1mi=1myi\displaystyle R^2 = 1 - \frac{\sum_{i=1}^m(f(x_i)-y_i)^2}{\sum_{i=1}^m(\bar{y} - y_i)^2},\quad \bar{y} = \frac{1}{m}\sum_{i=1}^m y_i

首先理解各部分的含义。减数的分子表示预测数据的平方差,减数的分母表示真实数据的平方差。而平方差是用来描述数据离散程度的统计量。

为了保证回归拟合的结果尽可能不受数据离散性的影响,我们通过相除来判断预测的数据是否离散。如果和原始数据离散性差不多,那么商就接近1,R方就接近0,表示性能较差,反之如果比原始数据离散性小,那么商就接近0,R方就接近1,表示性能较优。

2.3.2 分类任务

混淆矩阵

混淆矩阵 - 图例

  • 准确率(accuracy)P=TP+TNTP+FN+FP+TN\displaystyle P=\frac{TP+TN}{TP+FN+FP+TN}

  • 查准率/精度(precision)P=TPTP+FP\displaystyle P = \frac{TP}{TP+FP} - 适用场景:商品搜索推荐(尽可能推荐出适当的商品即可,至于商品数量无所谓)

  • 查全率/召回率(recall)R=TPTP+FN\displaystyle R = \frac{TP}{TP+FN} - 适用场景:逃犯、病例检测(尽可能将正例检测出来,至于查准率无所谓)

  • F1 度量(F1-score)F1=2×P×RP+R\displaystyle F_1 = \frac{2\times P \times R}{P + R}​ - 用于综合查准率和查全率的指标

  • 对于多分类问题,我们可以将该问题分解为多个二分类问题(ps:假设为 n 个)。从而可以获得多个上述的混淆矩阵,那么也就获得了多个 PiP_iRiR_i 以及全局均值 TP\overline{TP}FP\overline{FP}FN\overline{FN},进而衍生出两个新的概念

      • 宏查准率:macroP=1ni=1nPi\displaystyle macroP = \frac{1}{n} \sum_{i=1}^n P_i
      • 宏查全率:macroR=1ni=1nRi\displaystyle macroR = \frac{1}{n} \sum_{i=1}^n R_i
      • F1F1macroF1=2×macroP×macroRmacroP+macroR\displaystyle macroF_1 = \frac{2 \times macroP \times macroR}{macroP+macroR}
      • 微查准率:microP=TPTP+FP\displaystyle microP = \frac{\overline{TP}}{\overline{TP}+\overline{FP}}
      • 微查全率:microR=TPTP+FN\displaystyle microR = \frac{\overline{TP}}{\overline{TP}+\overline{FN}}
      • F1F1microF1=2×microP×microRmicroP+microR\displaystyle microF_1 = \frac{2 \times microP \times microR}{microP+microR}

P-R 曲线

P-R 曲线趋势图

  • 横纵坐标:横坐标为查全率(Recall),纵坐标为查准率(Precision)

  • 如何产生?我们根据学习器对于每一个样本的预测值(正例性的概率)进行降序排序,然后调整截断点将预测后的样本进行二分类,将截断点之前的所有数据全部认为预测正例,截断点之后的所有数据全部认为预测反例。然后计算两个指标进行绘图。

    我们知道学习器得到最终的结果一般不是一个绝对的二值,如 0,1。往往是一个连续的值,比如 [0,1],也就是“正例性的概率”。因此我们才可以选择合适的截断点将所有的样本数据划分为两类。

  • 趋势解读:随着截断点的值不断下降,很显然查全率 RR 会不断上升,查准率 PP 会不断下降

  • 不同曲线对应学习器的性能度量:曲线与横纵坐标围成的面积衡量了样本预测排序的质量。因此上图中 A 曲线的预测质量比 C 曲线的预测质量高。但是我们往往会遇到比较 A 与 B 的预测质量的情况,由于曲线与坐标轴围成的面积难以计算,因此我们引入了平衡点的概念。平衡点就是查准率与查询率相等的曲线,即 P=RP=R 的曲线。平衡点越往右上,学习器的预测性能越好。

ROC 曲线与 AUC

ROC 曲线图 - 受试者工作特征

  • 横纵坐标:横坐标为假正例率 FPR=FPFP+TN\displaystyle FPR = \frac{FP}{FP+TN},纵坐标为真正例率 TPR=TPTP+FN\displaystyle TPR = \frac{TP}{TP+FN}

  • 如何产生?与 P-R 图的产生类似,只不过计算横纵坐标的规则不同,不再赘述。

  • 趋势解读:随着截断点的值不断下降,真正例率与假正例率均会不断上升,因为分子都是从 0 开始逐渐增加的

  • 不同曲线对应学习器的性能度量:AUC 衡量了样本预测的排序质量。AUC 即 ROC 曲线右下方的面积,面积越大则对应的预测质量更高,学习器性能更好。不同于上述引入平衡点的概念,此处的面积我们可以直接计算,甚至 1-AUC 也可以直接计算。

    我们定义 AUC\text{AUC} 的计算公式为:(其实就是每一块梯形的面积求和,ps:矩形也可以用梯形面积计算公式代替)

    i=1m1(yi+yi+1)(xi+1xi)2\sum _{i=1}^{m-1} \frac{(y_{i}+y_{i+1}) \cdot (x_{i+1} - x_i)}{2}

    我们定义损失函数(losslosslrank=1AUCl_{rank} = 1-AUC 的计算公式为:(ps:感觉下述公式不是很准,因为正反例预测值相等的比例比不一定就是一比一)

    损失函数计算公式

2.4 比较检验

理论依据:统计假设检验(hypothesis test)

两个学习器性能比较:

  • 交叉验证 t 检验:对于 k 折两个学习期产生的 k 个误差之差,求得其均值 μ\mu 个方差 σ2\sigma ^2,若变量 Γt\Gamma_t 小于临界值,则表明学习器没有显著差异,其中变量 Γt\Gamma_t

    Γt=kμσ\Gamma_t = |\frac{\sqrt{k}\mu}{\sigma}|

  • McNemar 检验:对于二分类问题,我们可以得到下方的列联表

    列联表

    若变量 Γχ2\Gamma_{\chi ^2} 小于临界值,则表明学习器没有显著差异,其中变量 Γχ2\Gamma_{\chi ^2}

    Γχ2=(e01e101)2e01+e10\Gamma_{\chi ^2} = \frac{(|e_{01} - e_{10}| - 1)^2}{e_{01}+e_{10}}

2.5 偏差与方差

现在我们得到了学习算法的泛化性能,我们还想知道为什么会有这样的泛化性能,即我们应该如何理论的解释这样的泛化性能呢?我们引入 偏差-方差分解 的概念来从理论的角度解释期望泛化误差。那么这个方法一定是完美解释的吗?也有一定的缺点,因此我们还会引入 偏差-方差窘境 的概念来解释偏差和方差对于泛化误差的贡献

在此之前我们需要知道偏差、方差和噪声的基本定义:

  • 偏差:学习算法的期望输出与真实结果的偏离程度,刻画算法本身的拟合能力
  • 方差:使用同规模的不同训练集进行训练时带来的性能变化,刻画数据扰动带来的影响
  • 噪声:当前任务上任何算法所能达到的期望泛化误差的下界(即不可能有算法取得更小的误差),刻画问题本身的难度

2.5.1 偏差-方差分解

我们定义以下符号:xx 为测试样本,yDy_Dxx 在数据集中的标记,yyxx 的真实标记,f(x;D)f(x;D) 为模型在训练集 DD 上学习后的预测输出。

我们以回归任务为例:(下面的全部变量均为在所有相同规模的训练集下得到的期望结果)

  • 输出:f(x)=ED[f(x;D)]\overline{f}(x) = E_D[f(x;D)]
  • 方差:var(x)=ED[(f(x)f(x;D))2]var(x) = E_D[(\overline{f}(x) - f(x;D))^2]
  • 偏差:bias2(x)=(f(x)y)2bias^2(x) = (\overline{f}(x) - y)^2
  • 噪声:ϵ2=ED[(yDy)2]\epsilon ^2 = E_D[(y_D - y)^2]

偏差-方差分解的结论:

E(f;D)=bias2(x)+var(x)+ϵ2E(f;D) = bias^2(x) + var(x) + \epsilon^2

偏差-方差分解结论推导

解释说明:泛化性能是由学习算法的能力、数据的充分性以及学习任务本身的难度共同决定的。因此给定一个学习任务,我们可以从偏差和方差两个角度入手进行优化,即需要使偏差较小(充分拟合数据),且需要使方差较小(使数据扰动产生的影响小)

2.5.2 偏差-方差窘境

其实偏差和方差是有冲突的,这被称为偏差-方差窘境(bias-variance-dilemma)。对于以下的示意图我们可以知道:对于给定的学习任务。一开始拟合能力较差,学习器对于不同的训练数据不够敏感,此时泛化错误率主要来自偏差;随着训练的不断进行,学习器的拟合能力逐渐增强,对于数据的扰动更加敏感,使得方差主导了泛化错误率;在训练充分以后,数据的轻微扰动都可能导致预测输出发生显著的变化,此时方差就几乎完全主导了泛化错误率。

偏差-方差窘境 示意图

第3章 线性模型

本章介绍机器学习模型之线性模型,将从学习任务展开。学习任务分别为:

  • 回归(最小二乘法、岭回归)
  • 二分类(对数几率(逻辑)回归、线性判别分析)
  • 多分类(一对一、一对其余、多对多)

3.1 基本形式

f(xi)=wTxi+bw=(w1;w2;;wd),wRd,bR\begin{aligned}f(x_i) &= w^T x_i + b \\w &= (w_1; w_2; \cdots; w_d), w \in R^d, b \in R\end{aligned}

线性模型的优点:形式简单、易于建模、高可解释性、非线性模型的基础

线性模型的缺点:线性不可分

3.2 线性回归

预测式已经在 3.1 中标明了,现在的问题就是,如何获得 wwbb 使得预测值 f(x)f(x) 与真实值 yy 尽可能的接近,也就是误差 ϵ=f(x)y\epsilon = ||f(x) - y|| 尽可能的小?在前面的 2.3 节性能度量中,我们知道对于一般的回归任务而言,可以通过均方误差来评判一个回归模型的性能。借鉴该思想,线性回归也采用均方误差理论,求解的目标函数就是使均方误差最小化。

在正式开始介绍求解参数 wwbb 之前,我们先直观的理解一下均方误差。我们知道,均方误差对应了欧氏距离,即两点之间的欧几里得距离。于是在线性回归任务重,就是寻找一条直线使得所有的样本点距离该直线的距离之和尽可能的小。

基于均方误差最小化的模型求解方法被称为 最小二乘法 (least squre method)。而求解 wwbb 使得目标函数 E(w,b)=i=1m(yif(xi))E_{(w,b)} = \sum_{i=1}^{m}(y_i - f(x_i)) 最小化的过程,被称为线性回归模型的 最小二乘“参数估计” (parameter estimation)

于是问题就转化为了无约束的最优化问题求解。接下来我们将从一元线性回归引入,进而推广到多维线性回归的参数求解,最后补充广义的线性回归与其他线性回归的例子。

3.2.1 一元线性回归

现在假设只有一个属性 x,对应一维输出 y。现在我们试图根据已知的 <x,y> 样本数据学习出一个模型 f(xi)=wxi+bf(x_i) = wx_i+b 使得尽可能准确的预测未来的数据。那么此时如何求解模型中当目标函数取最小值时的参数 w 和 b 呢?很显然我们可以使用无约束优化问题的一阶必要条件求解。

前置说明:在机器学习中,很少有闭式解(解析解),但是线性回归是特例,可以解出闭式解。

闭式解推导过程:

一元线性回归:参数 w 和 b 的求解推导(式 3.7、式 3.8)

3.2.2 多元线性回归

现在我们保持输出不变,即 yy 仍然是一维,将输入的样本特征从一维扩展到 dd 维。现在同样适用最小二乘法,来计算 wwbb 使得均方误差最小。只不过现在的 ww 是一个一维向量 w=(w1,w2,,wd)w = (w_1,w_2, \cdots , w_d)

现在我们按照原来的方法进行求解。在求解之前我们采用向量的方式简化一下数据的表示,XX 为修改后的样本特征矩阵,w^\hat w 为修改后的参数矩阵,yy 为样本标记值,f(x)f(x) 为模型学习结果:

X=[x11x12x1d1x21x22x2d11xm1xm2xmd1],w^=(w;b)=[w1w2wdb],y=[y1y2ym],f(x)=[f(x1)f(x2)f(xm)]=[x1Tw^x2Tw^xdTw^]X = \begin{bmatrix}x_{11} & x_{12} & \cdots & x_{1d} & 1 \\x_{21} & x_{22} & \cdots & x_{2d} & 1 \\\vdots & \vdots & & \vdots & 1 \\x_{m1} & x_{m2} & \cdots & x_{md} & 1\end{bmatrix},\hat w = (w;b) = \begin{bmatrix}w_1 \\w_2 \\\vdots \\w_d \\b\end{bmatrix},y = \begin{bmatrix}y_1 \\y_2 \\\vdots \\y_m\end{bmatrix},f(x) = \begin{bmatrix}f(x_1) \\f(x_2) \\\vdots \\f(x_m)\end{bmatrix} = \begin{bmatrix}x_1 ^ T \hat w \\x_2 ^ T \hat w \\\vdots \\x_d ^ T \hat w \end{bmatrix}

于是损失函数 Ew^E_{\hat w} 就定义为:

Ew^=(yXw^)T(yXw^)E_{\hat w} = (y - X \hat w) ^T (y - X \hat w)

我们用同样的方法求解其闭式解:

多元线性回归:参数 w 的求解推导(式 3.10)

我们不能直接等式两边同 ×\times 矩阵 XTXX^TX 的逆,因为不清楚其是否可逆,于是进行下面的两种分类讨论:

  1. XTXX^T X​ 可逆:则参数 w^=(XTX)1XTy\hat w ^* = (X^TX)^{-1}X^Ty,令样本 x^i=(xi,1)\hat x_i = (x_i,1),则线性回归模型为:

    f(xi)=x^iTw^f(x_i) = \hat x_i ^T \hat w^*

  2. XTXX^T X 不可逆:我们引入 L2L_2 正则化项 αw^2\alpha || \hat w ||^2,此时就是所谓的「岭回归」算法:

    现在的损失函数就定义为:

    Ew^=(yXw^)T(yXw^)+αw^2E_{\hat w} = (y - X \hat w) ^T (y - X \hat w) + \alpha || \hat w ||^2

    同样将损失函数对参数向量 w^\hat w 求偏导,得:

    Ew^w^==2XTXw^2XTy+2αw^=2XT(Xw^y)+2αw^\begin{aligned}\frac{\partial E_{\hat w}}{\partial \hat w} &= \cdots \\&= 2X^TX\hat w - 2 X^T y + 2 \alpha \hat w \\&= 2 X ^T(X \hat w - y) + 2 \alpha \hat w\end{aligned}

    我们令其为零,得参数向量 w^\hat w 为:

    w^=(XTX+αI)1XTy\hat w = (X^T X + \alpha I)^{-1} X^T y

3.2.3 广义线性回归

可否令模型的预测值 wTx+bw^Tx+b 逼近 yy 的衍生物?我们以对数线性回归为例,令:

lny=wTx+b\ln y = w^Tx+b

本质上我们训练的线性模型 lny=wTx+b\ln y = w^Tx+b 现在可以拟合非线性数据 y=ewTx+by = e^{w^Tx+b}。更广义的来说,就是让训练的线性模型去拟合:

y=g1(wTx+b)y = g^{-1}(w^Tx+b)

此时得到的线性模型 g(y)=wTx+bg(y) = w^Tx +b 被称为广义线性模型,要求非线性函数 g()g(\cdot) 是单调可微的。而此处的对数线性回归其实就是广义线性模型在 g()=ln()g(\cdot) = \ln (\cdot) 时的特例

线性模型拟合非线性数据

3.2.4 其他线性回归

  • 支持向量机回归

  • 决策树回归

  • 随机森林回归

  • LASSO 回归:增加 L1L_1 正则化项

    LASSO 回归

  • ElasticNet 回归:增加 L1L_1L2L_2 正则化项

    ElasticNet 回归

  • XGBoost 回归

3.3 对数几率回归

对数几率回归准确来说应该叫逻辑回归,并且不是回归的学习任务,而是二分类的学习任务。本目将从阈值函数的选择模型参数的求解两个维度展开讲解。

3.3.1 阈值函数的选择

对于二分类任务。我们可以将线性模型的输出结果 z=wTx+bz=w^Tx+b 通过阈值函数 g()g(\cdot) 进行映射,然后根据映射结果 y=g(z)y=g(z) 进行二分类。那么什么样的非线性函数可以胜任阈值函数一职呢?

  1. 最简单的一种阈值函数就是单位阶跃函数。映射关系如下。但是有一个问题就是单位阶跃函数不是单调可微函数,因此不可取

    一直有一个疑问,单位跃阶函数已经可以将线性模型的进行二值映射了,干嘛还要求阈值函数的反函数呢?

    其实是因为知道的太片面了。我们的终极目的是为了通过训练数据,学习出线性模型中的 wwbb 参数。在《最优化方法》课程中我们知道,优化问题是需要进行迭代的,在迭代寻找最优解(此处就是寻找最优参数)的过程中,我们需要不断的检验当前解,以及计算修正量。仅仅知道线性模型 z=wTx+bz=w^Tx+b 关于阈值函数 g()g(\cdot) 的映射结果 y=g(z)y=g(z) 是完全不够的,因为我们在检验解、计算修正量时,采用梯度下降等算法来计算损失函数关于参数 wwbb 的导数时,需要进行求导操作,比如上述损失函数 E(w,b)=i=1m(yig(xi))2E_{(w,b)} = \sum_{i=1}^m (y_i - g(x_i))^2,如果其中的 g()g(\cdot) 不可导,自然也就没法继续计算损失函数关于参数的导数了。

    至此疑问解决!让我们畅快的学习单调可微的阈值函数吧!

    y={0,z<00.5,z=01,z>0y = \begin{cases}0, &z < 0 \\0.5,& z=0 \\1, & z>0\end{cases}

  2. 常用的一种阈值函数是对数几率函数,也叫逻辑函数(logistic function\text{logistic function}。映射关系如下:

    y=11+ezy = \frac{1}{1+e^{-z}}

3.3.2 模型参数的求解

现在我们站在前人的肩膀上学习到了一种阈值函数:逻辑函数(logistic function\text{logistic function})。在开始讲解参数 wwbb 的求解过程之前,我们先解决一个疑问:为什么英文名是 logistic\text{logistic}​,中文翻译却成了 对数几率 函数呢?

这就要从逻辑函数的实际意义出发了。对于逻辑函数,我们代入线性模型,并做以下转化:

y=11+ezy=11+e(wTx+b)lny1y=wTx+b\begin{aligned}& y = \frac{1}{1 + e^{-z}} \\& y = \frac{1}{1 + e^{-(w^Tx+b)}} \\& \ln \frac{y}{1 - y} = w^Tx+b \\\end{aligned}

若我们定义 yy 为样本 xx 是正例的可能性,则 1y1-y 显然就是反例的可能性。两者比值 y1y\frac{y}{1-y} 就是几率,取对数 lny1y\ln{\frac{y}{1-y}} 就是对数几率。

知道了逻辑函数的实际意义是真实标记的对数几率以后,接下来我们实际意义出发,讲解模型参数 wwbb​ 的推导过程。

若我们将 yy 视作类后验概率 p(y=1  x)p(y=1 \ | \ x)​,则有

lnp(y=1  x)p(y=0  x)=wTx+b\ln \frac{p(y=1 \ | \ x)}{p(y=0 \ | \ x)} = w^Tx+b

同时,显然有

p(y=1  x)=11+e(wTx+b)=ewTx+b1+ewTx+bp(y=0  x)=e(wTx+b)1+e(wTx+b)=11+ewTx+b\begin{aligned}p(y=1 \ | \ x) = \frac{1}{1 + e^{-(w^Tx+b)}} = \frac{e^{w^Tx+b}}{1 + e^{w^Tx+b}} \\p(y=0 \ | \ x) = \frac{e^{-(w^Tx+b)}}{1 + e^{-(w^Tx+b)}} = \frac{1}{1 + e^{w^Tx+b}}\end{aligned}

于是我们可以确定目标函数了。我们取当前目标函数为对数似然函数

argmaxw,bl(w,b)=i=1mlnp(yi  xi;w,b)\arg \max_{w,b} l(w,b) = \sum_{i=1}^m \ln p(y_i\ | \ x_i;w,b)

显然的,当上述目标函数取最大值时,得到的参数 wwbb​ 即为所求。因为目标函数尽可能大就对应样本属于真实标记的概率尽可能大,也就是所谓的最大化类后验概率

我们将变量进行一定的变形:

{β=(w;b)x^=(x;1)wTx+b=βTx^{p1(x^;β)=p(y=1  x^;β)p0(x^;β)=p(y=0  x^;β)p(yi  xi;w,b)=yip1(x^;β)+(1yi)p0(x^;β)\begin{aligned}\begin{cases}\beta = (w;b) \\\hat x = (x;1) \\\end{cases}&\to w^Tx + b = \beta^T\hat x \\\begin{cases}p_1(\hat x; \beta) = p(y = 1 \ | \ \hat x; \beta) \\p_0(\hat x; \beta) = p(y = 0 \ | \ \hat x; \beta) \\\end{cases}&\to p(y_i\ | \ x_i;w,b) = y_i p_1(\hat x; \beta) + (1 - y_i) p_0(\hat x; \beta)\end{aligned}

于是上述对数似然函数就可以进行以下转化:

l(w,b)=l(β)=i=1mln[yip1(x^;β)+(1yi)p0(x^;β)]=i=1mln[yip(y=1  x^;β)+(1yi)p(y=0  x^;β)]=i=1mln[yieβTx^1+eβTx^+(1yi)11+eβTx^]=i=1mln[yiβTx^+1yi1+eβTx^]={i=1mln(11+eβTx^),yi=0i=1mln(eβTx^1+eβTx^),yi=1=i=1mln((eβTx^)yi1+eβTx^)=i=1m(yieβTx^ln(1+eβTx^))\begin{aligned}l(w,b) &= l(\beta) \\&= \sum_{i=1}^m \ln \left [y_i p_1(\hat x; \beta) + (1 - y_i) p_0(\hat x; \beta) \right ] \\&= \sum_{i=1}^m \ln \left [y_i p(y = 1 \ | \ \hat x; \beta) + (1 - y_i) p(y = 0 \ | \ \hat x; \beta) \right ] \\&= \sum_{i=1}^m \ln \left [ y_i \frac{e^{\beta^T\hat x}}{1 + e^{\beta^T\hat x}} + (1-y_i) \frac{1}{1 + e^{\beta^T\hat x}} \right ] \\&= \sum_{i=1}^m \ln \left [ \frac{y_i \beta^T\hat x +1 -y_i}{1 + e^{\beta^T\hat x}} \right ] \\&= \begin{cases}\sum_{i=1}^m \ln \left ( \frac{1}{1 + e^{\beta^T\hat x}} \right ), & y_i=0 \\\sum_{i=1}^m \ln \left ( \frac{e^{\beta^T\hat x}}{1 + e^{\beta^T\hat x}} \right ), & y_i=1\\\end{cases}\\&= \sum_{i=1}^m \ln \left ( \frac{\left(e^{\beta^T\hat x}\right)^{y_i}}{1 + e^{\beta^T\hat x}} \right ) \\&= \sum_{i=1}^m \left( y_i e^{\beta^T\hat x} - \ln({1 + e^{\beta^T\hat x}})\right )\end{aligned}

进而从极大似然估计转化为:求解「极小化负的上述目标函数时」参数 β\beta 的值:

argminβl(β)=i=1m(yieβTx^+ln(1+eβTx^))\arg \min_{\beta} l(\beta) = \sum_{i=1}^m \left(- y_i e^{\beta^T\hat x} + \ln({1 + e^{\beta^T\hat x}})\right )

由于上式是关于 β\beta 的高阶可导连续凸函数,因此我们有很多数值优化算法可以求得最优解时的参数值,比如梯度下降法、拟牛顿法等等。我们以牛顿法(Newton Method)为例:

目标函数:

β=argminβl(β)\beta ^* = \arg \min_{\beta} l(\beta)

t+1t+1​ 轮迭代解的更新公式:

βt+1=βt(2l(β)ββT)1l(β)β\beta ^{t+1} = \beta^t - \left( \frac{\partial^2{l(\beta)}}{\partial{\beta} \partial{\beta^T}} \right)^{-1} \frac{\partial{l(\beta)}}{\partial{\beta}}

其中 l(β)l(\beta) 关于 β\beta 的一阶导、二阶导的推导过程如下:

一阶导

二阶导

3.4 线性判别分析

线性判别分析的原理是:对于给定的训练集,设法将样本投影到一条直线上,使得同类的投影点尽可能接近,异类样本的投影点尽可能远离;在对新样本进行分类时,将其投影到这条直线上,再根据投影点的位置来确定新样本的类别。

3.5 多分类学习

我们一般采用多分类+集成的策略来解决多分类的学习任务。具体的学习任务大概是:将多分类任务拆分为多个二分类任务,每一个二分类任务训练一个学习器;在测试数据时,将所有的分类器进行集成以获得最终的分类结果。这里有两个关键点:如何拆分多分类任务?如何集成二分类学习器?集成策略见第 8 章,本目主要介绍多分类学习任务的拆分。主要有三种拆分策略:一对多、一对其余、多对多。对于 N 个类别而言:

3.5.1 一对一

一对一

  • 名称:One vs. One,简称 OvO
  • 训练:需要对 N 个类别进行 N(N1)2\frac{N(N-1)}{2} 次训练,得到 N(N1)2\frac{N(N-1)}{2} 个二分类学习器
  • 测试:对于一个样本进行分类预测时,需要用 N(N1)2\frac{N(N-1)}{2} 个学习器分别进行分类,最终分得的结果种类最多的类别就是样本的预测类别
  • 特点:类别较少时,时间和内存开销往往更大;类别较多时,时间开销往往较小

3.5.2 一对其余

一对其余

  • 名称:One vs. Rest,简称 OvR
  • 训练:需要对 N 个类别进行 NN 次训练,得到 NN 个二分类学习器。每次将目标类别作为正例,其余所有类别均为反例
  • 测试:对于一个样本进行分类预测时,需要用 NN 个学习器分别进行分类,每一个学习器显然只会输出二值,假定为正负。正表示当前样例属于该学习器的正类,反之属于反类。若 NN 个学习器输出了多个正类标签,则还需通过执行度选择最终的类别。

3.5.3 多对多

多对多

  • 名称:Many vs. Many,简称 MvM
  • 训练(编码):对于 N 个类别数据,我们自定义 M 次划分。每次选择若干个类别作为正类,其余类作为反类。每一个样本在 M 个二分类学习器中都有一个分类结果,也就可以看做一个 M 维的向量。m 个样本也就构成了 m 个在 M 维空间的点阵。
  • 测试(解码):对于测试样本,对于 M 个学习器同样也会有 M 个类别标记构成的向量,我们计算当前样本与训练集构造的 m 个样本的海明距离、欧氏距离,距离当前测试样本最近的点属于的类别,我们就认为是当前测试样本的类别。

3.5.4 softmax

类似于 M-P 神经元,目的是为了将当前测试样本属于各个类别的概率之和约束为 1。

3.6 类别不平衡问题

在分类任务的数据集中,往往会出现类别不平衡的问题,即使在类别的样本数量相近时,在使用一对其余等算法进行多分类时,也会出现类比不平衡的问题,因此解决类比不平衡问题十分关键。

3.6.1 阈值移动

常规而言,对于二分类任务。我们假设 yy 为样本属于正例的概率,则 p=y1yp=\frac{y}{1-y} 就是正确划分类别的概率。在假定类别数量相近时,我们用下式表示预测为正例的情况:

y1y>1\frac{y}{1-y}>1

但是显然,上述假设不总是成立,我们令 m+m^+ 为样本正例数量,mm^- 为样本反例数量。我们用下式表示预测为正例的情况:

y1y>m+m\frac{y}{1-y} > \frac{m^+}{m^-}

根本初衷是为了让 m+m\frac{m^+}{m^-} 表示数据类别的真实比例。但是由于训练数据往往不能遵循独立分布同分布原则,也就导致我们观测的 m+m\frac{m^+}{m^-} 其实不能准确代表数据的真实比例。那还有别的解决类别不平衡问题的策略吗?答案是有的!

3.6.2 欠采样

即去除过多的样本使得正反例的数量近似,再进行学习。

  • 优点:训练的时间开销小
  • 缺点:可能会丢失重要信息

典型的算法是:EasyEnsemble

3.6.3 过采样

即对训练集中类别数量较少的样本进行重复采样,再进行学习。

  • 缺点:简单的重复采样会导致模型过拟合数据,缺少泛化能力。

典型的算法是:SMOTE

第4章 决策树

4.1 基本流程

决策树算法遵循自顶向下、分而治之的思想。

决策树结构解读:

决策树结构

  1. 非叶子结点:属性
  2. 边:属性值
  3. 叶子结点:分类结果

决策树生成过程:

决策树算法伪代码

  1. 生成结点:选择最优的属性作为当前结点决策信息
  2. 产生分支:根据结点属性,为测试数据所有可能的属性值生成一个分支
  3. 生成子结点:按照上述分支属性对当前训练样本进行划分
  4. 不断递归:不断重复上述 1-3 步直到递归终点
  5. 递归终点:共三种(存疑)
    1. 当前结点的训练样本类别均属于某个类别。则将当前结点设定为叶子结点,并标记当前叶子结点对应的类别为当前结点同属的类别
    2. 当前结点的训练样本数为 00。则将当前结点为设定为叶子结点,并标记当前叶子结点对应的类别为父结点中最多类别数量对应的类别
    3. 当前结点的训练样本的所有属性值都相同。则将当前结点设定为叶子结点,并标记当前叶子结点对应的类别为当前训练样本中最多类别数量对应的类别

4.2 划分选择

现在我们解决 4.1 节留下的坑,到底如何选择出当前局面下的最优属性呢?我们从“希望当前结点的所包含的样本尽可能属于同一类”的根本目的出发,讨论三种最优属性选择策略。

4.2.1 信息增益

第一个问题:如何度量结点中样本的纯度? 我们引入 信息熵 (information entropy) 的概念。显然,信息熵越小,分支结点的纯度越高;信息熵越大,分支结点的纯度越低。假设当前样本集合中含有 tt 个类别,第 kk 个类别所占样本集合比例为 pkp_k,则当前样本集合的信息熵 Ent(D)\text{Ent}(D) 为:

Ent(D)=k=1tpklog2(pk)\text{Ent}(D) = -\sum _{k=1}^t p_k \log_2(p_k)

第二个问题:如何利用结点纯度选择最优属性进行划分? 我们引入 信息增益 (Information gain) 的概念。显然,我们需要计算每一个属性对应的信息增益,然后取信息增益最大的属性作为当前结点的最优划分属性。假设当前结点对应的训练数据集为 DD,可供选择的属性列表为 a,b,,γa,b,\cdots,\gamma。我们以 aa 为例给出信息增益的表达式。假设属性 aa 共有 VV 个属性值,即 a={a1,a2,.aV}a=\{a^1,a^2,\cdots.a^V\},于是便可以将当前结点对应的训练数据集 DD 划分为 VV 个子结点的训练数据 D={D1,D2,.DV}D=\{D^1,D^2,\cdots.D^V\},由于每一个子结点划到的训练数据量不同,引入权重理念,得到最终的信息增益表达式为:

Gain(D,a)=Ent(D)i=1VDiDEnt(Di)\text{Gain}(D,a)=\text{Ent}(D) - \sum_{i=1}^V \frac{|D^i|}{|D|} \text{Ent}(D^i)

代表算法:ID3

为了更好的理解信息增益的表达式,我们从终极目标考虑问题。我们知道,决策树划分的终极目的是尽可能使得子结点中的样本纯度尽可能高。对于当前已知的结点,信息熵已经是一个定值了,只有通过合理的划分子结点才能使得整体的熵减小,因此我们从划分后熵减的理念出发,得到了信息增益的表达式。并且得出熵减的结果(信息增益)越大,对应的属性越优。

4.2.2 增益率

由于信息增益会在属性的取值较多时有偏好,因此我们引入 增益率 (Gain ratio) 的概念来减轻这种偏好。显然,增益率越大越好

我们定义增益率为:

Gain_ratio(D,a)=Gain(D,a)IV(a)\text{Gain}\_\text{ratio}(D,a) = \frac{\text{Gain}(D,a)}{\text{IV}(a)}

可以看到就是将信息增益除上了一个值 IV(a)\text{IV}(a),我们定义属性 aa 的固有值 IV(a)\text{IV}(a) 为:

IV(a)=i=1VDiDlog2DiD\text{IV}(a) = -\sum_{i=1}^V \frac{|D^i|}{|D|} \log_2 \frac{|D^i|}{|D|}

一般而言,属性的属性取值越多,信息增益越大,对应的 IV(a)\text{IV}(a) 的值也越大,这样就可以在一定程度上抵消过大的信息增益

代表算法:C4.5

4.2.3 基尼指数

最后引入一种最优划分策略:使用 基尼指数 (Gini index) 来划分。基尼指数越小越好。假设当前样本集合中含有 tt 个类别,第 kk 个类别所占样本集合比例为 pkp_k,则当前样本集合 DD 的基尼值 Gini(D)\text{Gini(D)} 定义为:

Gini(D)=1k=1tpk2\text{Gini(D)} = 1 - \sum_{k=1}^{t}p_k^2

于是属性 aa 的基尼指数 Gini_index(D,a)\text{Gini}\_\text{index}(D,a) 定义为:

Gini_index(D,a)=i=1VDiDGini(Di)\text{Gini}\_\text{index}(D,a) = \sum_{i=1}^V \frac{|D^i|}{|D|} \text{Gini}(D^i)

代表算法:CART

4.3 剪枝处理

处理过拟合的策略:剪枝。

4.3.1 预剪枝

基于贪心的思想,决策每次划分是否需要进行。我们知道最佳属性的选择是基于信息增益等关于结点纯度的算法策略,而是否进行子结点的生成需要我们进行性能评估,即从测试精度的角度来考虑。因此决策划分是否进行取决于子结点生成前后在验证集上的测试精度,如果可以得到提升则进行生成,反之则不生成子结点,也就是预剪枝的逻辑。

4.3.2 后剪枝

同样是预剪枝的精度决策标准。我们在一个决策树完整生成以后,从深度最大的分支结点开始讨论是否可以作为叶子结点,也就是是否删除该分支的子结点。决策的依据是删除子结点前后在测试集上的精度是否有提升,如果有则删除子结点,反之不变。

4.3.3 区别与联系(补)

预剪枝是基于贪心,也就是说没有考虑到全局的情况,可能出现当前结点划分后测试精度下降,但是后续结点继续划分会得到性能提升,从而导致预剪枝的决策树泛化性能下降。

后剪枝就可以规避贪心导致的局部最优。但是代价就是时间开销更大。

4.4 连续与缺失值

4.4.1 连续值处理

这里讲解二分法 (bi-partition)。主要就是在计算信息增益时增加了一步,将属性的取值情况划分为了两类。那么如何划分呢?关键在于划分点的取值。假设当前属性 a 的取值是连续值,去重排序后得到 n 个数值,我们取这 n 个数值的 n-1 个间隔的中值作为划分点集合,枚举其中的每一个划分点计算最大信息增益,对应的划分点就是当前连续取值的属性的二分划分点。时间复杂度极高!也不知道 C4.5 算法怎么想的

4.4.2 缺失值处理

当然我们可以直接删除含有缺失信息的样本,但是这对数据信息过于浪费,尤其是当数据量不大时,如何解决这个问题呢?我们需要解决两个问题:

  1. 在选择最优划分属性时,如何计算含有缺失值的属性对应的信息增益呢?
  2. 在得到最优划分属性时,如何将属性值缺失的样本划分到合理的叶子结点呢?

对于第一个问题:只计算属性值没有缺失的样本,然后放缩到原始的数据集合大小即可

对于第二个问题:对于已知属性值的样本,我们可以计算出每一个属性值的样本数量,从而计算出一个集合比例,这样对于未知属性值的样本,只需要按照前面计算出来的集合,按照概率划分到对应的子结点即可

4.5 多变量决策树

很好,本目就是来解决上述连续值处理的过高时间复杂度的问题的。现在对于一个结点,不是选择最优划分属性,而是对建一个合适的线性分类器,如图:

合适的线性分类器

第5章 神经网络

5.1 神经元模型

M-P 神经元模型

我们介绍 M-P 神经元模型。该神经元模型必须具备以下三个特征:

  1. 输入:来自其他连接的神经元传递过来的输入信号
  2. 处理:输入信号通过带权重的连接进行传递,神经元接受到所有输入值的总和,再与神经元的阈值进行比较
  3. 输出:通过激活函数的处理以得到输出

激活函数可以参考 3.3 中的逻辑函数(logistic function),此处将其声明为 sigmoid 函数,同样不采用不。连续光滑的分段函数。

5.2 感知机与多层网络

本目从无隐藏层的感知机出发,介绍神经网络在简单的线性可分问题上的应用;接着介绍含有一层隐藏层的多层感知机,及其对于简单的非线性可分问题上的应用;最后引入多层前馈神经网络模型的概念。

5.2.1 感知机

感知机(Perceptron)

感知机(Perceptron)由两层神经元组成。第一层是输入层,第二层是输出层。其中只有输出层的神经元为功能神经元,也即 M-P 神经元。先不谈如何训练得到上面的 w1,w2,θw_1,w_2,\theta,我们先看看上面的感知机训练出来以后可以有什么功能?

通过单层的感知机,我们可以实现简单的线性可分的分类任务,比如逻辑运算中的 与、或、非 运算,下面演示一下如何使用单层感知机实现上述三种逻辑运算:

与运算、或运算是二维线性可分任务,一定可以找到一条直线将其划分为两个类别:

二维线性可分任务

非运算是一维线性可分任务,同样也可以找到一条直线将其划分为两个类别:

一维线性可分任务

5.2.2 多层感知机

神经网络图例:多层感知机

所谓的多层感知机其实就是增加了一个隐藏层,则神经网络模型就变为三层,含有一个输入层,一个隐藏层,和一个输出层,更准确的说应该是“单隐层网络”。其中隐藏层和输出层中的所有神经元均为功能神经元。

为了学习出网络中的连接权 wiw_i 以及所有功能神经元中的阈值 θj\theta_j,我们需要通过每一次迭代的结果进行参数的修正,对于连接权 wiw_i 而言,我们假设当前感知机的输出为 y^\hat y,则连接权 wiw_i 应做以下调整。其中 η\eta 为学习率。

wiwi+ΔwiΔwi=η(yy^)xi\begin{aligned}w_i \leftarrow w_i + \Delta w_i \\\Delta w_i = \eta (y - \hat y) x_i\end{aligned}

使用多层感知机实现异或逻辑运算

5.2.3 多层前馈神经网络

多层前馈神经网络结构示意图

所谓多层前馈神经网络,定义就是各层神经元之间不会跨层连接,也不存在同层连接,其中:

  • 输入层仅仅接受外界输入,没有函数处理功能
  • 隐藏层和输出层进行函数处理

5.3 误差逆传播算法

多层网络的学习能力比感知机的学习能力强很多。想要训练一个多层网络模型,仅仅通过感知机的参数学习规则是不够的,我们需要一个全新的、更强大的学习规则。这其中最优秀的就是误差逆传播算法(errorBackPropagation,简称 BP),往往用它来训练多层前馈神经网络。下面我们来了解一下 BP 算法的内容、参数推导与算法流程。

5.3.1 模型参数

我们对着神经网络图,从输入到输出进行介绍与理解:

单隐层神经网络

  • 隐层:对于隐层的第 hh 个神经元
    • 输入:αh=i=1dxivih\alpha_h = \sum_{i=1}^dx_i v_{ih}
    • 输出:bh=f(αhγh)b_h = f(\alpha_h - \gamma_h)
  • 输出层:对于输出层的第 jj 个神经元
    • 输入:βj=h=1qbhwhj\beta_j=\sum_{h=1}^q b_h w_{hj}
    • 输出:y^j=f(βjθj)\hat y_j = f(\beta j - \theta_j)

现在给定一个训练集学习一个分类器。其中每一个样本都含有 dd 个特征,ll 个输出。现在使用标准 BP 神经网络模型,每输入一个样本都迭代一次。对于单隐层神经网络而言,一共有 4 种参数,即:

  • 输入层到隐层的 d×qd \times q 个权值 vih(i=1,2,,d, h=1,2,,q)v_{ih}(i=1,2,\cdots,d,\ h=1,2,\cdots,q)
  • 隐层的 qq 个 M-P 神经元的阈值 γh(h=1,2,,q)\gamma_h(h=1,2,\cdots,q)
  • 隐层到输出层的 q×lq\times l 个权值 whj(h=1,2,,q, j=1,2,,l)w_{hj}(h=1,2,\cdots,q,\ j=1,2,\cdots,l)
  • 输出层的 ll 个 M-P 神经元的阈值 θj(j=1,2,,l)\theta_j(j=1,2,\cdots,l)

5.3.2 参数推导

确定损失函数。

  • 对于上述 4 种参数,我们均采用梯度下降策略。以损失函数的负梯度方向对参数进行调整。每次输入一个训练样本,都会进行一次参数迭代更新,这叫标准 BP 算法

  • 根本目标是使损失函数尽可能小,我们定义损失函数 EE 为当前样本的均方误差,并为了求导计算方便添加一个常量 12\frac{1}{2},对于第 kk 个训练样本,有如下损失函数:

Ek=12j=1l(y^jkyjk)2E_k = \frac{1}{2} \sum _{j=1}^l (\hat y_j^k - y_j^k)^2

确定迭代修正量。

  • 假定当前学习率为 η\eta,对于上述 4 种参数的迭代公式为:

    whjwhj+Δwhjθjθj+Δθjvihvih+Δvihγhγh+Δγh\begin{aligned}w_{hj} &\leftarrow w_{hj}+\Delta w_{hj} \\\theta_{j} &\leftarrow \theta_{j}+\Delta \theta_{j} \\v_{ih} &\leftarrow v_{ih}+\Delta v_{ih} \\\gamma_{h} &\leftarrow \gamma_{h}+\Delta \gamma_{h} \\\end{aligned}

  • 其中,修正量分别为:

    Δwhj=ηgjbhΔθj=ηgjΔvih=ηehxiΔγh=ηeh\begin{aligned}\Delta w_{hj} &= \eta g_j b_h \\\Delta \theta_{j} &= -\eta g_j \\\Delta v_{ih} &= \eta e_h x_i \\\Delta \gamma_{h} &= -\eta e_h \\\end{aligned}

公式表示:

公式表示

隐层到输出层的权重、输出神经元的阈值:

隐层到输出层的权重、输出神经元的阈值

输入层到隐层的权重、隐层神经元的阈值:

输入层到隐层的权重、隐层神经元的阈值

5.3.3 算法流程

对于当前样本的输出损失 EkE_k 和学习率 η\eta,我们进行以下迭代过程:

BP 神经网络算法流程

还有一种 BP 神经网络方法就是累计 BP 神经网络算法,基本思路就是对于全局训练样本计算累计误差,从而更新参数。在实际应用过程中,一般先采用累计 BP 算法,再采用标准 BP 算法。还有一种思路就是使用随机 BP 算法,即每次随机选择一个训练样本进行参数更新。

第6章 支持向量机

依然是分类学习任务。我们希望找到一个超平面将训练集中样本划分开来,那么如何寻找这个超平面呢?下面开始介绍。

本章知识点逻辑链:

支持向量机知识点关系图

6.1 间隔与支持向量

对于只有两个特征,输出只有两种状态的训练集而言,很显然我们得到如下图所示的超平面,并且显然应该选择最中间的泛化能力最强的那一个超平面:

间隔与支持向量

我们定义超平面为:

wTx+b=0w^Tx+b=0

定义支持向量机为满足下式的样例:

wT+b=1wT+b=1\begin{aligned}w^T+b&=1 \\w^T+b&=-1\end{aligned}

很显然,为了求得这“最中间”的超平面,就是让异类支持向量机之间的距离尽可能的大,根据两条平行线距离的计算公式,可知间隔为:

γ=2w\gamma = \frac{2}{|| w ||}

于是最优化目标函数就是:

maxw,b2w\max_{w,b} \frac{2}{||w||}

可以等价转化为:

minw,b12w2s.t.yi(wTxi+b)1(i=1,2,,m)\begin{aligned}&\min_{w,b} \frac{1}{2} ||w||^2 \\&s.t. \quad y_i(w^Tx_i+b) \ge 1 \quad(i=1,2,\cdots,m)\end{aligned}

这就是 SVM(support vector machine)的基本型

6.2 对偶问题

将上述 SVM 基本型转化为对偶问题,从而可以更高效的求解该最优化问题。

对偶转化推导 - 1

对偶转化推导 - 2

于是模型 f(x)f(x)​ 就是:

f(x)=wTx+b=i=1mαiyixiTx+b\begin{aligned}f(x) &= w^Tx+b \\&= \sum_{i=1}^m\alpha_iy_ix_i^Tx+b\end{aligned}

其中参数 b 的求解可通过支持向量得到:

yif(xi)=1yi(i=1mαiyixiTx+b)=1y_if(x_i) = 1 \to y_i\left(\sum_{i=1}^m\alpha_iy_ix_i^Tx+b \right)=1

由于原问题含有不等式约束,因此还需要满足 KKT 条件:

{αi0,对偶可行性yif(xi)1,原始可行性αi(yif(xi)1)=0,互补松弛性\begin{cases}\alpha_i \ge 0&,\text{对偶可行性} \\y_if(x_i) \ge 1&,\text{原始可行性} \\\alpha_i(y_if(x_i)-1) = 0&,\text{互补松弛性}\end{cases}

对于上述互补松弛性:

  • αi>0\alpha_i > 0,则 yif(xi)=1y_if(x_i)=1,表示支持向量,需要保留
  • yif(xi)>1y_if(x_i)>1,则 αi=0\alpha_i = 0​,表示非支持向量,不用保留

现在得到的对偶问题其实是一个二次规划问题,我们可以采用 SMO(Sequential Minimal Optimization) 算法求解。具体略。

6.3 核函数

对原始样本进行升维,即 xiϕ(xi)x_i \to \phi(x_i),新的问题出现了,计算内积 ϕ(xi)Tϕ(xi)\phi(x_i)^T \phi(x_i) 变得很困难,我们尝试解决这个内积的计算,即使用一个函数(核函数)来近似代替上述内积的计算结果,常用的核函数如下:

常用核函数

表格中的高斯核也就是所谓的径向基函数核 (Radial Basis Function Kernel, 简称 RBF 核)\text{(Radial Basis Function Kernel, 简称 RBF 核)},其中的参数 γ=12σ2\gamma=\frac{1}{2\sigma^2},因此 RBF 核的表达式也可以写成:

κ(xi,xj)=exp(γxixj2)\kappa(x_i, x_j) = \exp(-\gamma \|x_i - x_j\|^2)

  • γ\gamma 较大时,exp(γxixj2)\exp(-\gamma \|x_i - x_j\|^2) 的衰减速度会很快。这意味着只有非常接近的样本点才会有较高的相似度。此时,模型会更关注局部特征。并且会导致模型具有较高的复杂度,因为模型会更容易拟合训练数据中的细节和噪声,从而可能导致过拟合。
  • γ\gamma 较小时,exp(γxixj2)\exp(-\gamma \|x_i - x_j\|^2) 的衰减速度会变慢。较远的样本点之间也可能会有较高的相似度。此时,模型会更关注全局特征。但此时模型的复杂度较低,容易忽略训练数据中的细节,从而可能导致欠拟合

6.4 软间隔与正则化

对于超平面的选择,其实并不是那么容易,并且即使训练出了一个超平面,我们也不知道是不是过拟合产生的,因此我们需要稍微减轻约束条件的强度,因此引入软间隔的概念。

我们定义软间隔为:某些样本可以不严格满足约束条件 yi(wTx+b)1y_i(w^Tx+b) \ge 1 从而需要尽可能减少不满足的样本个数,因此引入新的优化项:替代损失函数

loptionl_{\text{option}}

常见的平滑连续的替代损失函数为:

常见的平滑连续的替代损失函数

我们引入松弛变量 ξi\xi_i 得到原始问题的最终形式:

minw,b,ξi12w2+Ci=1mξi\min_{w,b,\xi_i} \quad \frac{1}{2}||w||^2+C\sum_{i=1}^m \xi_i

6.5 支持向量回归

支持向量回归(Support Vector Regression,简称 SVR)与传统的回归任务不同,传统的回归任务需要计算每一个样本的误差,而支持向量回归允许有一定的误差,即,仅仅计算落在隔离带外面的样本损失。

原始问题:

原始问题

约束条件

对偶问题:

对偶问题

KKT 条件:

KKT 条件

预测模型:

预测模型

6.6 核方法

通过上述:支持向量基本型、支持向量软间隔化、支持向量回归,三个模型的学习,可以发现最终的预测模型都是关于核函数与拉格朗日乘子的线性组合,那么这是巧合吗?并不是巧合,这其中其实有一个表示定理:

h(x)=i=1mαiκ(x,xi)h^*(x) = \sum_{i=1}^m\alpha_i \kappa(x, x_i)

第7章 贝叶斯分类器

7.1 贝叶斯决策论

pass

7.2 极大似然估计

根本任务:寻找合适的参数 θ^\hat \theta 使得「当前的样本情况发生的概率」最大。又由于假设每一个样本相互独立,因此可以用连乘的形式表示上述概率,当然由于概率较小导致连乘容易出现浮点数精度损失,因此尝尝采用取对数的方式来避免「下溢」问题。也就是所谓的「对数似然估计」方法。

我们定义对数似然 (log-likelihood)\text{(log-likelihood)} 估计函数如下:

LL(θc)=logP(Dc  θc)=xDclogP(x  θc)\begin{aligned}LL(\theta_c) &= \log P(D_c\ |\ \theta_c) \\ &= \sum_{x \in D_c} \log P(x\ |\ \theta_c)\end{aligned}

此时参数 θ^\hat \theta 的极大似然估计就是:

θ^c=argmaxθcLL(θc)\hat \theta_c = \arg \max_{\theta_c} LL(\theta_c)

7.3 朴素贝叶斯分类器

我们定义当前类别为 cc,则 P(c)P(c) 称为类先验概率,P(x  c)P(x\ |\ c) 称为类条件概率。最终的贝叶斯判定准则为:

P(c  x)=P(c)P(x  c)P(x)P(c\ |\ x) = \frac{P(c)P(x\ |\ c)}{P(x)}

现在假设各属性之间相互独立,则对于拥有 d 个属性的训练集,在利用贝叶斯定理时,可以通过连乘的形式计算类条件概率 P(x  c)P(x \ | \ c),于是上式变为:

P(c  x)=P(c)P(x)i=1dP(xi  c)P(c\ |\ x) = \frac{P(c)}{P(x)} \prod_{i=1}^d P(x_i\ |\ c)

注意点:

  • 对于离散数据。上述类条件概率的计算方法很好计算,直接统计即可
  • 对于连续数据。我们就没法直接统计数据数量了,替换方法是使用高斯函数。我们根据已有数据计算得到一个对于当前属性的高斯函数,后续计算测试样例对应属性的条件概率,代入求得的高斯函数即可。
  • 对于类条件概率为 0 的情况。我们采用拉普拉斯修正。即让所有属性的样本个数 +1+1,于是总样本数就需要 +d+d 来确保总概率仍然为 11。这是额外引入的 bias

7.4 半朴素贝叶斯分类器

朴素贝叶斯的问题是假设过于强,现实不可能所有的属性都相互独立。半朴素贝叶斯弱化了朴素贝叶斯的假设。现在假设每一个属性最多只依赖一个其他属性。即独依赖估计 (One-Dependent Estimator, 简称 ODE)(\text{One-Dependent Estimator, 简称 ODE}),于是就有了下面的贝叶斯判定准测:

P(c  x)P(c)i=1dP(xi  c,pai)P(c\ |\ x) \propto P(c) \prod _{i=1}^d P(x_i\ |\ c, pa_i)

如何寻找依赖关系?我们从属性依赖图出发

属性依赖图

如上图所示:

  • 朴素贝叶斯算法中:假设所有属性相互独立,因此各属性之间没有连边
  • SPODE 确定属性父属性算法中:假设所有属性都只依赖于一个属性(超父),我们只需要找到超父即可
  • TAN 确定属性父属性算法中:我们需要计算每一个属性之间的互信息,最后得到一个以互信息为边权的完全图。最终选择最大的一些边构成一个最大带权生成树
  • AODE 确定属性父属性算法中:采用集成的思想,以每一个属性作为超父属性,最后选择最优即可

7.5 贝叶斯网

构造一个关于属性之间的 DAG 图,从而进行后续类条件概率的计算。三种典型依赖关系:

同父结构V型结构顺序结构
同父结构V型结构顺序结构
在已知 x1x_1 的情况下 x3,x4x_3,x_4 独立x4x_4 未知则 x1,x2x_1,x_2 独立,反之不独立在已知 xx 的情况下 y,zy,z 独立

概率计算公式参考:超详细讲解贝叶斯网络(Bayesian network)

7.6 EM 算法

现在我们需要解决含有隐变量 ZZ 的情况。如何在已知数据集含有隐变量的情况下计算出模型的所有参数?我们引入 EM 算法。EM 迭代型算法共两步

  • E-step:首先利用观测数据 XX 和参数 Θt\Theta_t 得到关于隐变量的期望 ZtZ^t
  • M-step:接着对 XXZtZ^t 使用极大似然函数来估计新的最优参数 Θt+1\Theta_{t+1}

有两个问题:哪来的参数?什么时候迭代终止?

  • 对于第一个问题:我们随机化初始得到参数 Θ0\Theta_0
  • 对于第二个问题:相邻两次迭代结果中参数差值的范数小于阈值 (θ(i+1)θ(i))<ϵ1)(|| \theta^{(i+1)} - \theta^{(i)}) || < \epsilon_1)隐变量条件分布期望差值的范数小于阈值 (Q(θ(i+1),θ(i)))Q(θ(i),θ(i))<ϵ2)(|| Q(\theta^{(i+1)} , \theta^{(i)})) - Q(\theta^{(i)} , \theta^{(i)}) || < \epsilon_2)

第8章 集成学习

8.1 个体与集成

集成学习由多个不同的组件学习器组合而成。学习器不能太坏并且学习器之间需要有差异。如何产生并结合“好而不同”的个体学习器是集成学习研究的核心。集成学习示意图如下:

多个不同的学习组件

根据个体学习器的生成方式,目前集成学习可分为两类,代表作如下:

  1. 个体学习器直接存在强依赖关系,必须串行生成的序列化方法:Boosting
  2. 个体学习器间不存在强依赖关系,可以同时生成的并行化方法:Bagging随机森林 (Random Forest)

8.2 Boosting

Boosting 算法族的逻辑:

  1. 个体学习器之间存在强依赖关系
  2. 串行生成每一个个体学习器
  3. 每生成一个新的个体学习器都要调整样本分布

以 AdaBoost 算法为例,问题式逐步深入算法实现:

  1. 如何计算最终集成的结果?利用加性模型 (additive model),假定第 ii 个学习器的输出为 h(x)h(x),第 ii 个学习器的权重为 αi\alpha_i,则集成输出 H(x)H(x) 为:

    H(x)=sign(i=1Tαihi(x))H(x) = \text{sign} \left(\sum_{i=1}^T \alpha_i h_i(x)\right)

  2. 如何确定每一个学习器的权重 αi\alpha_i ?我们定义 αi=12ln(1ϵiϵi)\displaystyle \alpha_i=\frac{1}{2}\ln (\frac{1-\epsilon_i}{\epsilon_i})

  3. 如何调整样本分布?我们对样本进行赋权。学习第一个学习器时,所有的样本权重相等,后续学习时的样本权重变化规则取决于上一个学习器的分类情况。上一个分类正确的样本权重减小,上一个分类错误的样本权重增加,即:

    Di+1(x)=Di(x)Zi×{eαi,hi(x)=f(x)eαi,hi(x)f(x)D_{i+1}(x) = \frac{D_i(x)}{Z_i} \times \begin{cases}e^{-\alpha_i}&,h_i(x)=f(x) \\e^{\alpha_i}&,h_i(x)\ne f(x)\end{cases}

代表算法:AdaBoost、GBDT、XGBoost

8.3 Bagging 与 随机森林

在指定学习器个数 TT 的情况下,并行训练 TT 个相互之间没有依赖的基学习器。最著名的并行式集成学习策略是 Bagging,随机森林是其的一个扩展变种。

8.3.1 Bagging

问题式逐步深入 Bagging 算法实现:

  1. 如何计算最终集成的结果?直接进行大数投票即可,注意每一个学习器都是等权重的
  2. 如何选择每一个训练器的训练样本?顾名思义,就是进行 TT 次自助采样法
  3. 如何选择基学习器?往往采用决策树 or 神经网络

8.3.2 随机森林

问题式逐步深入随机森林 (Random Forest,简称 RF) 算法实现:

  1. 如何计算最终集成的结果?直接进行大数投票即可,注意每一个学习器都是等权重的
  2. 为什么叫森林?每一个基学习器都是「单层」决策树
  3. 随机在哪?首先每一个学习器对应的训练样本都是随机的,其次每一个基学习器的属性都是随机的 k(k[1,V])k(k \in [1,V])​ 个(由于基学习器是决策树,并且属性是不完整的,故这些决策树都被称为弱决策树)

8.3.3 区别与联系(补)

区别:随机森林与 Bagging 相比,多增加了一个属性随机,进而提升了不同学习器之间的差异度,进而提升了模型性能,效果如下:

提升了模型性能

联系:两者均采用自助采样法。优势在于,不仅解决了训练样本不足的问题,同时 T 个学习器的训练样本之间是有交集的,这也就可以减小测试的方差。

8.4 区别与联系(补)

区别:

  • 序列化基学习器最终的集成结果往往采用加权投票
  • 并行化基学习器最终的集成结果往往采用平权投票

联系:

  • 序列化减小偏差。即可以增加拟合性,降低欠拟合
  • 并行化减小方差。即可以减少数据扰动带来的误差

8.5 结合策略

基学习器有了,如何确定最终模型的集成输出呢?我们假设每一个基学习器对于当前样本 xx 的输出为 hi(x),i=1,2,,Th_i(x),i=1,2,\cdots,T,结合以后的输出结果为 H(x)H(x)

8.5.1 平均法

对于数值型输出 hi(x)Rh_i(x) \in R,常见的结合策略采是平均法。分为两种:

  1. 简单平均法:H(x)=1Ti=1Thi(x)H(x) = \displaystyle \frac{1}{T} \sum_{i =1}^T h_i(x)
  2. 加权平均法:H(x)=i=1Twihi(x),wi0,i=1Twi=1H(x) = \displaystyle\sum_{i=1}^T w_i h_i(x),\quad w_i \ge 0, \sum_{i=1}^Tw_i = 1

显然简单平均法是加权平均法的特殊。一般而言,当个体学习器性能差距较大时采用加权平均法,相近时采用简单平均法。

8.5.2 投票法

对于分类型输出 hi(x)=[hi1(x),hi2(x),,hiN(x)]h_i(x) = [h_i^1(x), h_i^2(x), \cdots, h_i^N(x)],即每一个基学习器都会输出一个 NN 维的标记向量,其中只有一个打上了标记。常见的结合策略是投票法。分为三种:

  1. 绝对多数投票法:选择超过半数的,如果没有则拒绝投票
  2. 相对多数投票法:选择票数最多的,如果有多个相同票数的则随机取一个
  3. 加权投票法:每一个基学习器有一个权重,从而进行投票

8.5.3 学习法

其实就是将所有基学习器的输出作为训练数据,重新训练一个模型对输出结果进行预测。其中,基学习器称为“初级学习器”,输出映射学习器称为“次级学习器”或”元学习器” (meta-learner)\text{(meta-learner)}。对于当前样本 (x,y)(x,y)nn 个基学习器的输出为 y1=h1(x),y2=h2(x),,yn=hn(x)y_1 = h_1(x),y_2 = h_2(x),\cdots,y_n = h_n(x),则最终输出 H(x)H(x) 为:

H(x)=G(y1,y2,,yn)H(x) = G(y_1, y_2, \cdots, y_n)

其中 GG 就是次级学习器。关于次级学习器的学习算法,大约有以下几种:

  1. Stacking
  2. 多响应线性回归 (Mutil-response linear regression, 简称 MLR)\text{(Mutil-response linear regression, 简称 MLR)}
  3. 贝叶斯模型平均 (Bayes Model Averaging, 简称 BMA)\text{(Bayes Model Averaging, 简称 BMA)}

经过验证,Stacking 的泛化能力往往比 BMA 更优。

8.6 多样性

8.6.1 误差-分歧分解

根据「回归」任务的推导可得下式。其中 EE 表示集成的泛化误差、E\overline{E} 表示个体学习器泛化误差的加权均值、A\overline{A} 表示个体学习器的加权分歧值

E=EAE = \overline{E} - \overline{A}

8.6.2 多样性度量

如何估算个体学习器的多样化程度呢?典型做法是考虑两两学习器的相似性。所有指标都是从两个学习器的分类结果展开,以两个学习器 hi,hjh_i,h_j 进行二分类为例,有如下预测结果列联表:

预测结果列联表

显然 a+b+c+d=ma+b+c+d=m。基于此有以下常见的多样性度量指标:

  • 不合度量
  • 相关系数
  • Q-统计量
  • κ\kappa​-统计量

以「κ\kappa-统计量」为例进行说明。两个学习器的卡帕结果越接近1,则越相似。可以分析出:

  • 串行的基学习器多样性较并行的基学习器更高
  • 串行的基学习器误差较并行的基学习器也更高

卡帕-统计量

8.6.3 多样性增强

为了训练出多样性大的个体学习器,我们引入随机性。介绍几种扰动策略:

  • 数据样本扰动
  • 输入属性扰动
  • 输出表示扰动
  • 算法参数扰动

第9章 聚类

9.1 聚类任务

典型的无监督学习方法。即将众多无标签数据进行分类。在开始介绍几类经典的聚类学习方法之前,我们先讨论聚类算法涉及到的两个基本问题:性能度量和距离计算。

9.2 性能度量

一来是进行聚类的评估,二来也可以作为聚类的优化目标。分为两种,分别是外部指标和内部指标。

9.2.1 外部指标

所谓外部指标就是已经有一个“参考模型”存在了,将当前模型与参考模型的比对结果作为指标。我们考虑两两样本的聚类结果,定义下面的变量:

外部指标变量

显然 a+b+c+d=m(m1)/2a+b+c+d=m(m-1)/2,常见的外部指标如下:

  • JC 指数:JC=aa+b+c\displaystyle JC = \frac{a}{a+b+c}
  • FM 指数:aa+baa+c\displaystyle \sqrt{\frac{a}{a+b} \cdot \frac{a}{a+c}}
  • RI 指数:2(a+d)m(m1)\displaystyle \frac{2(a+d)}{m(m-1)}

上述指数取值均在 [0,1][0,1] 之间,且越大越好。

9.2.2 内部指标

所谓内部指标就是仅仅考虑当前模型的聚类结果。同样考虑两两样本的聚类结果,定义下面的变量:

内部指标变量

常见的外部指标如下:

  • DB 指数
  • Dunn 指数

9.3 距离计算

有序属性:闵可夫斯基距离

无序属性:VDM 距离

9.4 原型聚类

9.4.1 k 均值算法

算法流程大体上可以归纳为三步:

  1. 初始化 k 个聚类中心
  2. 枚举所有样本并将其划分到欧氏距离最近的中心
  3. 更新 k 个簇中心

重复上述迭代过程直到聚类中心不再发生变化,当然为了防止迭代时间过久,可以弱化迭代终止条件,比如增设:最大迭代论述或对聚类中心的变化增加一个阈值而非绝对的零变化。k 均值算法伪代码如下:

k 均值算法伪代码

9.4.2 学习向量量化算法

9.4.3 高斯混合聚类算法

高斯混合聚类算法 - 流程图

9.5 密度聚类

DBSCAN 算法

适用于簇的大小不定,距离不定的情况。大概就是多源有向bfs的过程。定义每一源为“核心对象”,每次以核心对象为起始进行bfs,将每一个符合“范围约束”的近邻点纳入当前簇,不断寻找知道所有核心对象都访问过为止。

9.6 层次聚类

AGNES 算法

适用于可选簇大小、可选簇数量的情况。

第10章 降维与度量学习

本章我们通过 k 近邻监督学习方法引入「降维」和「度量学习」的概念。

  • 对于降维算法:根本原因是样本往往十分稀疏,需要我们对样本的属性进行降维,从而达到合适的密度进而可以进行基于距离的邻居选择相关算法。我们将重点讨论以下几个降维算法:MDS 多维缩放PCA 主成分分析等度量映射
  • 对于度量学习:上述降维的根本目的是选择合适的维度,确保一定可以通过某个距离计算表达式计算得到每一个样本的 k 个邻居。而度量学习直接从目的出发,尝试直接学习样本之间的距离计算公式。我们将重点讨论以下几个度量学习算法:近邻成分分析LMNN

10.1 k 近邻学习

k 近邻(k-Nearest Neighbor,简称 KNN)是一种监督学习方法。一句话概括就是「近朱者赤近墨者黑」,每一个测试样本的分类或回归结果取决于在某种距离度量下的最近的 k 个邻居的性质。不需要训练,可以根据检测样本实时预测,即懒惰学习。为了实现上述监督学习的效果,我们需要解决以下两个问题:

  • 如何确定「距离度量」的准则?就那么几种,一个一个试即可。
  • 如何定义「分类结果」的标签?分类任务是 k 个邻居中最多类别的标签,回归任务是 k 个邻居中最多类别标签的均值。

上述学习方法有什么问题呢?对于每一个测试样例,我们需要确保能够通过合适的距离度量准则选择出 k 个邻居出来,这就需要保证数据集足够大。在距离计算时,每一个属性都需要考虑,于是所需的样本空间会呈指数级别的增长,这被称为「维数灾难」。这是几乎不可能获得的数据量同时在进行矩阵运算时时间的开销也是巨大的。由此引入本章的关键内容之一:降维。

为什么能降维?我们假设学习任务仅仅是高维空间的一个低维嵌入。

10.2 多维缩放降维

多维缩放(MDS)降维算法」的原则:对于任意的两个样本,降维后两个样本之间的距离保持不变。

基于此思想,可以得到以下降维流程:我们定义 bijb_{ij} 为降维后任意两个样本之间的内积,distijdist_{ij} 表示任意两个样本的原始距离,ZRd×m,ddZ \in R^{d'\times m},d' \le d 为降维后数据集的属性值矩阵。

内积计算:

内积计算

新属性值计算:特征值分解法。其中 B=VΛVTB = V \Lambda V^T

新属性值计算

10.3 主成分分析降维

主成分分析(Principal Component Analysis,简称 PCA)降维算法」的两个原则:

  • 样本到超平面的距离都尽可能近
  • 样本在超平面的投影都尽可能分开

基于此思想可以得到 PCA 的算法流程:

PCA 算法流程

假设我们有一个简单的数据集 DD,包括以下三个样本点:

x1=(23),x2=(34),x3=(45)x_1 = \begin{pmatrix} 2 \\ 3 \end{pmatrix}, \quad x_2 = \begin{pmatrix} 3 \\ 4 \end{pmatrix}, \quad x_3 = \begin{pmatrix} 4 \\ 5 \end{pmatrix}

我们希望将这些样本从二维空间降维到一维空间(即 d=1d' = 1 )。

步骤 1: 样本中心化

首先计算样本的均值向量:

μ=13(x1+x2+x3)=13(23)+13(34)+13(45)=(34)\mu = \frac{1}{3} (x_1 + x_2 + x_3) = \frac{1}{3} \begin{pmatrix} 2 \\ 3 \end{pmatrix} + \frac{1}{3} \begin{pmatrix} 3 \\ 4 \end{pmatrix} + \frac{1}{3} \begin{pmatrix} 4 \\ 5 \end{pmatrix} = \begin{pmatrix} 3 \\ 4 \end{pmatrix}

然后对所有样本进行中心化:

x~1=x1μ=(23)(34)=(11)\tilde{x}_1 = x_1 - \mu = \begin{pmatrix} 2 \\ 3 \end{pmatrix} - \begin{pmatrix} 3 \\ 4 \end{pmatrix} = \begin{pmatrix} -1 \\ -1 \end{pmatrix}

x~2=x2μ=(34)(34)=(00)\tilde{x}_2 = x_2 - \mu = \begin{pmatrix} 3 \\ 4 \end{pmatrix} - \begin{pmatrix} 3 \\ 4 \end{pmatrix} = \begin{pmatrix} 0 \\ 0 \end{pmatrix}

x~3=x3μ=(45)(34)=(11)\tilde{x}_3 = x_3 - \mu = \begin{pmatrix} 4 \\ 5 \end{pmatrix} - \begin{pmatrix} 3 \\ 4 \end{pmatrix} = \begin{pmatrix} 1 \\ 1 \end{pmatrix}

步骤 2: 计算协方差矩阵

样本的协方差矩阵为:

XXT=1mi=1mx~ix~iT=13((11)(11)+(00)(00)+(11)(11))=13((1111)+(0000)+(1111))=13(2222)=(23232323)\begin{aligned}XX^T &= \frac{1}{m} \sum_{i=1}^m \tilde{x}_i \tilde{x}_i^T \\&= \frac{1}{3} \left( \begin{pmatrix} -1 \\ -1 \end{pmatrix} \begin{pmatrix} -1 & -1 \end{pmatrix} + \begin{pmatrix} 0 \\ 0 \end{pmatrix} \begin{pmatrix} 0 & 0 \end{pmatrix} + \begin{pmatrix} 1 \\ 1 \end{pmatrix} \begin{pmatrix} 1 & 1 \end{pmatrix} \right)\\&= \frac{1}{3} \left( \begin{pmatrix} 1 & 1 \\ 1 & 1 \end{pmatrix} + \begin{pmatrix} 0 & 0 \\ 0 & 0 \end{pmatrix} + \begin{pmatrix} 1 & 1 \\ 1 & 1 \end{pmatrix} \right) \\&= \frac{1}{3} \begin{pmatrix} 2 & 2 \\ 2 & 2 \end{pmatrix} \\&= \begin{pmatrix} \frac{2}{3} & \frac{2}{3} \\ \frac{2}{3} & \frac{2}{3} \end{pmatrix}\end{aligned}

步骤 3: 对协方差矩阵进行特征值分解

协方差矩阵的特征值分解:

(23232323)=(1111)(43000)(1111)\begin{pmatrix} \frac{2}{3} & \frac{2}{3} \\ \frac{2}{3} & \frac{2}{3} \end{pmatrix} = \begin{pmatrix} 1 & 1 \\ -1 & 1 \end{pmatrix} \begin{pmatrix} \frac{4}{3} & 0 \\ 0 & 0 \end{pmatrix} \begin{pmatrix} 1 & -1 \\ 1 & 1 \end{pmatrix}

特征值为 λ1=43\lambda_1 = \frac{4}{3}λ2=0\lambda_2 = 0,对应的特征向量分别为:

w1=(11),w2=(11)w_1 = \begin{pmatrix} 1 \\ 1 \end{pmatrix}, \quad w_2 = \begin{pmatrix} -1 \\ 1 \end{pmatrix}

步骤 4: 取最大的 dd' 个特征值对应的特征向量

我们选择最大的特征值对应的特征向量 w1=(11)w_1 = \begin{pmatrix} 1 \\ 1 \end{pmatrix} 作为最终的投影矩阵。

10.4 核化线性降维

核化线性降维算法」的原则:pass

10.5 流形学习降维

假定数据满足流形结构。

10.5.1 等度量映射

流形样本中,直接计算两个样本之间的欧式距离是无效的。我们引入「等度量映射」理念。根本算法逻辑是:利用最短路算法计算任意两个样本之间的「测地线距离」得到 distijdist_{ij},接着套用上述 10.2 中的 MDS 算法即可进行降维得到最终的属性值矩阵 ZRd×m,ddZ \in R^{d'\times m},d' \le d

10.5.2 局部线性嵌入

pass

10.6 度量学习

降维的本质是寻找一种合适的距离度量方法,与其降维为什么不直接学习一种距离计算方法呢?我们引入「度量学习」的理念。

为了“有参可学”,我们需要定义距离计算表达式中的超参,我们定义如下「马氏距离」:

马氏距离

为什么用所谓的马氏距离呢?欧氏距离不行吗?我们有以下结论:

  • 欧式距离具有旋转不变性和平移不变性,在低维和属性直接相互独立时是最佳实践。但是当属性之间有相关性并且尺度相差较大时,直接用欧式距离计算会丢失重要的特征之间的信息;
  • 马氏距离具有尺度不变性,在高维和属性之间有关系且尺度不同时是最佳实践。缺点在于需要计算协方差矩阵导致计算量远大于欧氏距离的计算量。

下面介绍两种度量学习算法来学习上述 M 矩阵,也就是数据集的「协方差矩阵的逆」中的参数。准确的说是学习一个矩阵来近似代替协方差矩阵的逆矩阵。

10.6.1 近邻成分分析

近邻成分分析 (Neighborhood Component Analysis, 简称 NCA)\text{(Neighborhood Component Analysis, 简称 NCA)},目标函数是:最小化所有数据点的对数似然函数的负值。

10.6.2 LMNN

大间隔最近邻 (Large Margin Nearest Neighbor, 简称 LMNN)\text{(Large Margin Nearest Neighbor, 简称 LMNN)},目标函数是:最小化同一个类别中最近邻点的距离,同时最大化不同类别中最近邻点的距离。

paper: Distance Metric Learning for Large Margin Nearest Neighbor Classification

explain: 【LMNN】浅析"从距离测量到基于Margin的邻近分类问题"

第13章 半监督学习

半监督学习的根本目标:同时利用有标记和未标记样本的数据进行学习,提升模型泛化能力。主要分为三种:

  1. 主动学习
  2. 纯半监督学习
  3. 直推学习

三种半监督学习

13.1 未标记样本

对未标记数据的分布进行假设,两种假设:

  1. 簇状分布
  2. 流形分布

13.2 生成式方法

分别介绍「生成式方法」和「判别式方法」及其区别和联系。

生成式方法:核心思想就是用联合分布 p(x,y)p(x,y) 进行建模,即对特征分布 p(x)p(x) 进行建模,十分关心数据是怎么来(生成)的。生成式方法需要对数据的分布进行合理的假设,这通常需要计算类先验概率 p(y)p(y) 和特征条件概率 p(x  y)p(x\ |\ y),之后再在所有假设之上进行利用贝叶斯定理计算后验概率 p(y  x)p(y\ |\ x)。典型的例子如:

  • 朴素贝叶斯
  • 高斯混合聚类
  • 马尔科夫模型

判别式方法:核心思想就是用条件分布 p(y  x)p(y\ |\ x) 进行建模,不对特征分布 p(x)p(x) 进行建模,完全不管数据是怎么来(生成)的。即直接学一个模型 p(y  x)p(y\ |\ x) 来对后续的输入进行预测。不需要对数据分布进行过多的假设。典型的例子如:

  • 线性回归
  • 逻辑回归
  • 决策树
  • 神经网络
  • 支持向量机
  • 条件随机场

自监督训练(补)

根本思想就是利用有标记数据进行模型训练,然后对未标记数据进行预测,选择置信度较高的一些样本加入训练集重新训练模型,不断迭代进行直到最终训练出来一个利用大量未标记数据训练出来的模型。

如何定义置信度高?我们利用信息熵的概念,即对于每一个测试样本都有一个预测向量,信息熵越大表明模型对其的预测结果越模糊,因此置信度高正比于信息熵小,将信息熵较小的测试样本打上「伪标记」加入训练集。

13.3 半监督 SVM

以经典 S3VM 中的经典算法 TSVM 为例。给出优化函数、算法图例、算法伪代码:

优化函数

算法图例

二分类 - 伪代码

13.4 图半监督学习

同样解决分类问题,以「迭代式标记传播算法」为例。

二分类,可以直接求出闭式解。

算法逻辑。每一个样本对应图中的一个结点,两个结点会连上一个边,边权正比于两结点样本的相似性。最终根据图中已知的某些结点进行传播标记即可。与基于密度的聚类算法类似,区别在于此处不同的簇 cluster 可能会对应同一个类别 class。

如何进行连边?不会计算每一个样本的所有近邻,一般采用局部近邻选择连边的点,可以 k 近邻,也可以范围近邻。

优化函数。定义图矩阵的能量损失函数为图中每一个结点与所有结点的能量损失和,目标就是最小化能量损失和:

优化函数

多分类,无法直接求出闭式解,只能进行迭代式计算。

新增变量。我们定义标记矩阵 F,其形状为 (l+u)×d(l+u) \times d,学习该矩阵对应的值,最终每一个未标记样本 xix_i 就是 argmaxFi\arg \max F_i

多分类 - 伪代码

13.5 基于分歧的方法

多学习器协同训练。

第14章 概率图模型

为了分析变量之间的关系,我们建立概率图模型。按照图中边的性质可将概率图模型分为两类:

  • 有向图模型,也称贝叶斯网
  • 无向图模型,也称马尔可夫网

14.1 隐马尔可夫模型

隐马尔可夫模型 (Hidden Markov Model, 简称 HMM)\text{(Hidden Markov Model, 简称 HMM)} 是结构最简单的动态贝叶斯网。是为了研究变量之间的关系而存在的,因此是生成式方法。

需要解决三个问题:

  1. 如何评估建立的网络模型和实际观测数据的匹配程度?
  2. 如果上面第一个问题中匹配程度不好,如何调整模型参数来提升模型和实际观测数据的匹配程度呢?
  3. 如何根据实际的观测数据利用网络推断出有价值的隐藏状态?

14.2 马尔科夫随机场

马尔科夫随机场 (Markov Random Field, 简称 MRF)\text{(Markov Random Field, 简称 MRF)} 是典型的马尔科夫网。同样是为了研究变量之间的关系而存在的,因此也是生成式方法。

联合概率计算逻辑按照势函数展开。其中团可以理解为完全子图;极大团就是结点最多的完全子图,即在当前的完全子图中继续添加子图之外的点就无法构成新的完全子图;势函数就是一个关于团的函数。

14.3 条件随机场

判别式方法。

14.4 学习和推断

精确推断。

14.5 近似推断

近似推断。降低计算时间开销。

  • 采样法:蒙特卡洛采样法
  • 变分推断:考虑近似分布

考试大纲

  • 单选 10 * 3':单选直接一个单刀咆哮。注意审题、注意一些绝对性的语句;
  • 简答 4 * 5':想拿满分就需要写出理论对应的具体公式;
  • 计算 3 * 10':老老实实写每一个计算过程,不要跳步骤;
  • 论述 2 * 10':没考过,大约是简答题加强版。注意逻辑一定要清晰。
]]> @@ -989,11 +989,11 @@ - PyAlgo - - /GPA/4th-term/PyAlgo/ + ProbAndStat + + /GPA/4th-term/ProbAndStat/ - 算法设计与分析

前言

学科地位:

主讲教师学分配额学科类别
段博佳3自发课

成绩组成:

平时(5次作业+签到)期末(书本+讲义)
60%40%

教材情况:

课程名称选用教材版次作者出版社ISBN号
算法设计与分析《算法设计与分析(python版)》1王秋芬清华大学出版社978-7-302-57072-1

一、贪心

Dijkstra、Prim、Kruskal、Huffman

二、分治

二分、归并、快排(第 k 个数)

三、动规

  • 区间dp:矩阵连乘
  • 网格图dp:LCS
  • 背包:完全背包、多重背包

四、深搜

  • 排列树:TSP、图染色、调度、n 皇后
  • 子集树:01背包

考后碎碎念

考的中规中矩,但是忘带修正带导致卷面一坨hh。按照试卷顺序罗列一下:表达式树、大根堆、Dijkstra、图染色、第 k 个数、Kruskal、完全背包。

试卷一看就是 wq 出的,但还是用 python 写的代码。有一说一这次考试还加深了我对快排的理解,尤其是在让我给出分治后的序列时hh。硬要按照上面四个标签分类的话大概就是:

  • 贪心:Dijkstra、Kruskal
  • 分治:第 k 个数
  • 动规:完全背包
  • 深搜:表达式树、大根堆、图染色
]]> + 概率论与数理统计

前言

学科情况:

主讲教师学分配额学科类别
周效尧4学科基础课

成绩组成:

平时测验(×2)期末
10%30%60%

教材情况:

课程名称选用教材版次作者出版社ISBN号
概率论与数理统计Ⅰ《概率论与数理统计》第一版刘国祥王晓谦 等主编科学出版社978-7-03-038317-4

学习资源:

第1章 事件与概率

1.1 随机事件与样本空间

1.1.1 样本空间

随机事件发生的总集合 Ω\Omega

1.1.2 随机事件

事件是否发生取决于观察结果的事件

1.1.3 事件之间的关系与运算

  1. 包含:ABA \subset B or BAB \subset A
  2. 相等:A=BA=B
  3. 并(和):ABA \cup B
  4. 交(积):AB(AB)A \cap B \quad (AB)
  5. 互斥(互不相容):AB=ΦAB=\Phi
  6. 对立事件(余事件):AB=ΦAB=ΩA \cap B=\Phi \land A \cup B=\Omega
  7. 差:AB=AB=ABA-B=A \cap \overline{B} = A \overline B
  8. 德摩根律

将事件发生的概率论转化为集合论进行计算与分析

1.2 概率的三种定义及其性质

1.2.1 概率的统计定义

从频率出发得到。

1.2.2 概率的古典定义

特征:

  • 样本空间是有限集
  • 等可能性(试验中每个基本事件发生的概率是等可能的)

内容:

  1. 模型与计算公式
  2. 基本组合分析公式
    • 乘法、加法原理
    • 排列公式
    • 组合公式
  3. 实例
    • 超几何概率
    • 分房问题
    • 生日问题
  4. 古典概率的基本性质

1.2.3 概率的几何定义

特征:

  • 样本空间不可列
  • 等可能性

内容:

  1. 模型与计算公式
  2. 实例
    • 一维几何图形:公交车乘车问题
    • 二维几何图形:会面问题、蒲丰(Buffon)投针问题
  3. 几何概率的基本性质

1.2.4 概率的性质

pass

1.3 常用概型公式

1.3.1 条件概率计算公式

P(BA)=P(AB)P(A)P(B|A) = \frac{P(AB)}{P(A)}

1.3.2 乘法原理计算公式

  • 基本:前提:P(A)>0\text{基本}:\text{前提}:P(A)>0

    P(AB)=P(A)P(BA)P(AB) = P(A)P(B|A)

  • 推广:前提:P(A1A2,...,An)>0\text{推广}:\text{前提}:P(A_1A_2,...,A_n)>0

    P(A1A2...An)=P(A1)P(A2A1)P(A3A1A2)P(AnA1A2...An1)P(A_1A_2...A_n) = P(A_1)P(A_2|A_1)P(A_3|A_1A_2) \cdots P(A_n|A_1A_2...A_{n-1})

1.3.3 全概公式

我们将样本空间 Ω\Omega 完全划分为 nn 个互斥的区域,即 Ω=i=1nAi\Omega = \displaystyle \sum_{i=1}^{n} A_i ,则在样本空间中事件 BB 发生的概率 P(B)P(B) 就是在各子样本空间中概率之和,经过上述乘法公式变形,计算公式如下:

P(B)=P(BΩ)=P(BA1)+P(BA2)++P(BAn)=P(A1)P(BA1)+P(A2)P(BA2)++P(An)P(BAn)=i=1nP(Ai)P(BAi)\begin{equation*}\begin{aligned} P(B) &= P(B \Omega) \\ &= P(BA_1) + P(BA_2) + \cdots + P(BA_n) \\ &= P(A_1)P(B|A_1) + P(A_2)P(B|A_2) + \cdots + P(A_n)P(B|A_n) \\ &= \sum_{i=1}^n P(A_i)P(B|A_i)\end{aligned}\end{equation*}

1.3.4 贝叶斯公式

在上述全概公式的背景之下,现在希望求解事件 BB 在第 jj 个子样本空间 AjA_j 中发生的概率,或者说第 jj 个子样本空间对于事件 BB 的发生贡献了多少概率,记作 P(AjB)P(A_j|B) ,计算公式如下:

P(AjB)=P(Aj)P(BAj)i=1nP(Ai)P(BAi)P(A_j|B) = \frac{P(A_j)P(B|A_j)}{\displaystyle \sum_{i=1}^n P(A_i)P(B|A_i)}

可以发现全概公式是计算事件发生的所有子样本空间的概率贡献,而贝叶斯公式是计算事件发生的总概率中某些子样本空间的概率贡献,前者是正向思维,后者是逆向思维

1.4 事件的独立性及伯努利概型

1.4.1 独立性

定义

  • 基本:若 A,BA,B 相互独立,则满足:

    P(AB)=P(A)P(B)P(AB)=P(A)P(B)

  • 推广:若 A1,A2,...,AnA_1,A_2,...,A_n 相互独立,则满足:

    1i1<i2<<ikn (k=2,3,,n)s.t.P(Ai1Ai2Aik)=P(Ai1)P(Ai2)P(Aik)\begin{aligned}\forall \quad 1 \le i_1<i_2<\cdots<i_k \le n\ (k=2,3,\cdots,n) \\s.t. \quad P(A_{i_1}A_{i_2}\cdots A_{i_k}) = P(A_{i_1})P(A_{i_2})\cdots P(A_{i_k})\end{aligned}

定理

  • 基本:若 A,BA,B 相互独立,则 A,BA,\overline{B} 相互独立;A,B\overline{A},B 相互独立;A,B\overline{A},\overline{B} 相互独立

  • 推广:若 A1,A2,...,AnA_1,A_2,...,A_n 相互独立,则其中任意 k(2kn)k(2 \le k \le n) 个也相互独立,且满足:

    P(Ai1^Ai2^Aik^)=P(Ai1^)P(Ai2^)P(Aik^)s.t.Aij^=A or A (j=1,2,,k)\begin{aligned}P(\hat{A_{i_1}}\hat{A_{i_2}}\cdots \hat{A_{i_k}}) = P(\hat{A_{i_1}})P(\hat{A_{i_2}})\cdots P(\hat{A_{i_k}}) \\s.t. \quad \hat{A_{i_j}} = A \ or \ \overline{A}\ (j=1,2,\cdots,k)\end{aligned}

概念辨析

  • 两两独立:对于 nn 个事件,两两独立,而不考虑三个及以上的关系。

  • 相互独立:对于 nn 个事件,2n2 \to n 个事件的独立关系都需要考虑。

  • 总结:对于 nn 个事件,满足两两独立需要 Cn2C_n^2 个等式关系,对于相互独立需要 2n(n+1)2^n-(n+1) 个等式关系,因此:

    两两独立相互独立\text{两两独立} \subset \text{相互独立}

1.4.2 伯努利概型

定义:nn 重伯努利概型

  • nn 重:发生 nn 次独立试验
  • 伯努利概型:每次试验只有两种可能的结果

模型:

  • 二项概率公式:n 次独立重复试验发生 k 次的概率:

    Cnkpk(1p)nkC_n^k p^k (1-p)^{n-k}

  • 几何概率公式:在第 n 次试验首次成功的概率:

    (1p)n1p(1-p)^{n-1}p

第2章 随机事件及其分布

我们知道,解决事件发生概率的问题,除了事件表示以外,我们还关心每一个事件发生的概率 P(X=k)P(X=k),以及某些事件发生的概率 P(X=[range))P(X=[range))。接下来我们将:

  • 首先介绍随机变量的概念以及分布函数的概念
  • 接着介绍随机变量对应的概率发生情况组成的集合。离散型的叫分布列,连续型的叫概率密度函数,并在其中贯穿分布函数的应用
  • 最后介绍分布函数的复合。从离散型和连续型随机变量两个方向展开

2.1 随机变量及其概率分布

2.1.1 随机变量的概念

总的来说,随机变量就是一个样本空间与实数集的映射。我们定义样本空间 Ω={ω}\Omega=\{ \omega \},其中 ω\omega 表示所有可能的事件,实数集 RR,随机变量 XX,则随机变量满足以下映射关系

X(ω)=RX(\omega)=R

2.1.2 随机变量的分布函数

  1. 分布函数的定义:F(x)=P(Xx)F(x)=P(X \le x)
  2. 分布函数的性质:
    • 非负有界性:0F(x)10 \le F(x) \le 1
    • 单调不减性:若 x1<x2x_1 < x_2,则 F(x1)F(x2)F(x_1) \le F(x_2)
    • F()=limxF(x)=0\displaystyle F(-\infty) = \lim_{x \to -\infty} F(x) = 0F(+)=limx+F(x)=1\displaystyle F(+\infty) = \lim_{x \to +\infty} F(x) = 1
    • 右连续性:limxx0+F(x)=F(x0)(<x0<+)\displaystyle \lim_{x\to x_0^+}F(x) = F(x_0)\quad(-\infty < x_0 < +\infty)

2.2 离散型随机变量及其分布列

2.2.1 离散性随机变量的分布列

随机变量的取值都是整数,有以下三种表示方法

  1. 公式法

    pk=P(X=xk),k=1,2,,p_k = P(X=x_k),\quad k = 1,2,\cdots,

  2. 服从法

    X(x1x2x3p1p2p3)X \sim \begin{pmatrix}x_1 & x_2 & x_3 & \cdots \\p_1 & p_2 & p_3 & \cdots\end{pmatrix}

  3. 表格法

    Xx1x2x3Pp1p2p3\begin{array}{c|cccc}X & x_1 & x_2 & x_3 & \cdots \\\hlineP & p_1 & p_2 & p_3 & \cdots\end{array}

2.2.2 常用离散性随机变量及其分布列

  • 0-1分布:即一个事件只有两面性,我们称这样的随机变量服从0-1分布或者两点分布,记作

    X(011pp)X \sim \begin{pmatrix}0 & 1 \\1-p & p\end{pmatrix}

  • 二项分布:其实就是 n 重伯努利试验,我们称这样的随机变量服从二项分布,分布列为 P(X=k)=Cnkpk(1p)nkP(X=k) = C_n^kp^k(1-p)^{n-k},记作

    XB(n,p)X \sim B(n,p)

  • 几何分布:同样是伯努利事件,现在需要求解第 kk 次事件首次发生的概率,此时分布列为 P(X=k)=(1p)k1pP(X=k)=(1-p)^{k-1}p,记作

    XG(p)X \sim G(p)

  • 超几何分布:就是在 N 件含有 M 件次品的样品中无放回的抽取 n 件,问其中含有次品数量的分布列,为 P(X=k)=CMkCNMnkCNn,k=0,1,2,,min(n,M)\displaystyle P(X=k)=\frac{C_M^k C_{N-M}^{n-k}}{C_N^n}, \quad k=0,1,2,\cdots,\min{(n, M)},记作

    X超几何分布(n,N,M)X \sim \text{超几何分布}(n,N,M)

  • 泊松分布:当二项分布中,试验次数很大或者概率很小时,可以近似为泊松分布,即 P(X=k)=Cnkpk(1p)nkλkk!eλ\displaystyle P(X=k)=C_n^k p^k(1-p)^{n-k} \to \frac{\lambda^k}{k!}e^{-\lambda},其中常数 λ>0\lambda > 0,记作

    XP(λ)X \sim P(\lambda)

    显然,泊松分布含有下面两个性质

    1. P(X=k)>0,k=0,1,P(X=k) > 0,k=0,1,\cdots

    2. k=0P(X=k)=1\displaystyle \sum_{k=0}^\infty P(X=k)=1

      泊松分布正规性证明

2.3 连续型随机变量及其概率密度函数

说白了其实就是离散性随机变量的积分加强版。现在随着事件发生的不同取值 xx,随机变量 XX 发生的概率 P(X=x)P(X=x) 变成了连续的取值了(学名概率密度函数),于是分布函数(离散的叫分布列)的取值就没那么容易求了(其实一重定积分就可以)。接下来就从定义、性质、应用三个角度出发介绍概率密度函数以及相应的随机变量的分布函数。

2.3.1 连续型随机变量的密度函数

概率密度函数,简称:密度函数 or 概率密度

  • 定义:设随机变量 XX 的分布函数为 F(x)F(x),如果存在非负可积函数 p(x)p(x),使下式成立,则称 XX 为连续型随机变量,p(x)p(x)XX 的概率密度函数

    xR,F(x)=xp(t)dt\forall x \in R,F(x) = \int_{-\infty}^{x} p(t)dt

  • 性质:

    1. 非负性:p(x)0p(x) \ge 0

    2. 正规性:+p(x)dx=1\int_{-\infty}^{+\infty} p(x)dx = 1

    3. 可积性:x1x2,P(x1Xx2)=F(x2)F(x1)=x1x2p(x)dx\forall x_1 \le x_2,P(x_1 \le X \le x_2) = F(x_2) - F(x_1) = \int_{x_1}^{x_2}p(x)dx

    4. 分布函数可导性:若 p(x)p(x) 在点 xx 处连续,则 F(x)=p(x)F'(x) = p(x)

    5. 已知事件但无意义性:xR,P(X=x)=F(x)F(x)=0\forall x \in R, P(X=x) = F(x) - F(x) = 0

      • 离散型变量可以通过列举随机变量 XX 的取值来计算概率,但连续型随机变量这么做是无意义的
      • P(A)=0P(A) = 0 不能推出 AA 是不可能事件,P(A)=1P(A)=1 不能推出 AA 是必然事件
      • 对于连续型随机变量 XX 有:P(x1<X<X2)=P(x1<XX2)=P(x1X<X2)=P(x1XX2)P(x_1 < X < X_2)=P(x_1 < X \le X_2)=P(x_1 \le X < X_2)=P(x_1 \le X \le X_2)
    6. 实际描述性:密度函数的数值反映了随机变量 XXxx 的临近值的概率的大小,因为

      p(x)Δxxx+Δxp(t)dt=F(x+Δx)F(x)=P(xXx+Δx)p(x)\Delta x \approx \int_{x}^{x+\Delta x} p(t)dt = F(x+\Delta x) - F(x) = P(x \le X \le x+\Delta x)

2.3.2 常用连续型随机变量及其密度函数

分布定义式概率密度函数分布函数
均匀分布XU[a,b]X \sim U[a,b]p(x)={1ba,axb,0,其他p(x) = \begin{cases} \frac{1}{b-a}, & a \le x \le b, \\ 0, & \text{其他} \end{cases}F(x)={0,x<axaba,ax<b1,xbF(x) = \begin{cases} 0, & x < a \\ \frac{x - a}{b - a}, & a \le x < b \\ 1, & x \ge b \end{cases}
指数分布Xe(λ)X \sim e (\lambda)p(x)={0,x<0λeλx,x0p(x) = \begin{cases} 0, & x < 0 \\ \lambda e^{-\lambda x} , & x \ge 0 \end{cases}F(x)={0,x<01eλx,x0F(x) = \begin{cases} 0, & x < 0 \\ 1- e^{-\lambda x}, & x \ge 0 \end{cases}
正态分布XN(μ,σ2)X \sim N(\mu,\sigma^2)p(x)=12πσe(xμ)22σ2,<x<+p(x) = \frac{1}{\sqrt{2 \pi} \sigma } e^{- \frac{(x - \mu)^2}{2 \sigma ^2}} , \quad -\infty < x < + \inftyF(x)=12πσxe(yμ)22σ2dyF(x) = \frac{1}{\sqrt{2 \pi} \sigma } \int_{- \infty}^x e^{- \frac{(y - \mu)^2}{2 \sigma ^2}} dy

补充说明:

  • 指数分布:其中参数 λ>0\lambda >0

  • 正态分布:一般正态函数 F(x)F(x) 转化为标准正态函数 Φ(x)\Phi(x) 公式:

    F(x)=Φ(xμσ)F(x) = \Phi(\frac{x - \mu}{\sigma})

    于是对于计算一般正态函数的函数值,就可以通过下式将其转化为标准正态函数,最后查表即可:

P(Xx)=F(x)=Φ(xμσ)P(X \le x) = F(x) = \Phi (\frac{x - \mu}{\sigma})

2.4 随机变量函数的分布

本目主要介绍给定一个随机变量 XX 的分布情况,通过一个关系式 y=g(x)y=g(x) 来求解随机变量 YY​ 的分布情况

2.4.1 离散型随机变量函数的分布

通过关系式 y=g(x)y=g(x)​ 将所有的 YY 的取值全部枚举出来,然后一一统计即可。

2.4.2 连续型随机变量函数的分布

给定随机变量 XX 的概率密度函数 pX(x)p_X(x),以及关系式 y=g(x)y=g(x),求解随机变量 YY 的分布函数 FY(y)F_Y(y)、概率密度函数 pY(y)p_Y(y)

  • 方法一:先求解随机变量 YY 的分布函数 FY(y)F_Y(y),再通过对其求导得到概率密度函数 pY(y)p_Y(y)

    即先 FY(y)=PY(Yy)=PY(g(X)y)=PX(Xf(y))=FX(f(y))F_Y(y) = P_Y(Y \le y) = P_Y(g(X) \le y) = P_X(X \le f(y)) = F_X(f(y)) 得到 YY 的分布函数

    再对 FY(y)F_Y(y) 求导得 pY(y)=ddyFY(y)=ddyFX(f(y))=FX(f(y))f(y)=pX(f(y))f(y)\displaystyle p_Y(y) = \frac{d}{dy} F_Y(y) = \frac{d}{dy} F_X(f(y)) = F_X'(f(y)) \cdot f'(y) = p_X(f(y)) \cdot f'(y)

  • 方法二:如果关系式 y=g(x)y=g(x) 单调且反函数 x=h(y)x=h(y) 连续可导,则可以直接得出随机变量 YY 的概率密度函数 pY(y)p_Y(y) 为下式。其中 α\alphaβ\betaY=g(X)Y=g(X) 的取值范围(xx 应该怎么取值,h(y)h(y) 就应该怎么取值,从而计算出 yy 的取值范围)

    pY(y)={pX(h(y))h(y),α<y<β0,其他p_Y(y) = \begin{cases}p_X(h(y)) \cdot |h'(y)|, & \alpha < y < \beta \\0, & \text{其他}\end{cases}

第3章 随机向量及其分布

实际生活中,只采用一个随机变量描述事件往往是不够的。本章引入多维的随机变量概念,构成随机向量,从二维开始,推广到 nn​ 维。

3.1 二维随机向量的联合分布

现在我们讨论二维随机向量的联合分布。所谓的联合分布,其实就是一个曲面的概率密度(离散型就是点集),而分布函数就是对其积分得到的三维几何体的体积(散点和)而已。

3.1.1 联合分布函数

定义:我们定义满足下式的二元函数 F(x,y)F(x,y) 为二维随机向量 (X,Y)(X,Y) 的联合分布函数

F(x,y)=P((Xx)(Yy))=P(Xx,Yy)F(x,y) = P((X \le x) \cap (Y \le y)) = P(X \le x, Y \le y)

联合分布函数的几何意义

性质:其实配合几何意义理解就会很容易了

  1. 固定某一维度,另一维度是单调不减的
  2. 对于每个维度都是右连续的
  3. 固定某一维度,另一维度趋近于负无穷对应的函数值为 00
  4. 二维前缀和性质,右上角的矩阵面积 0\ge 0

3.1.2 联合分布列

定义:若二维随机向量 (X,Y)(X,Y) 的所有可能取值是至多可列的,则称 (X,Y)(X,Y) 为二维离散型随机向量

表示:有两种表示二维随机向量分布列的方法,如下

  1. 公式法

    pij=P(X=xi,Y=yi),i,j=1,2,p_{ij} = P(X=x_i,Y = y_i), \quad i,j=1,2,\cdots

  2. 表格法:

    二维联合分布列

性质:

  1. 非负性:pij0,i,j=1,2,p_{ij} \ge 0, \quad i,j=1,2,\cdots
  2. 正规性:ijpij=1\displaystyle \sum_{i} \sum_{j} p_{ij} = 1

3.1.3 联合密度函数

定义:

F(x,y)=xyp(u,v)dudvF(x,y) = \int_{-\infty}^x \int_{-\infty}^y p(u,v)dudv

性质:

  1. 非负性:x,yR,p(x,y)0\forall x,y \in R,p(x,y) \ge 0
  2. 正规性:++p(x,y)dxdy=1\displaystyle \int_{-\infty}^{+\infty} \int_{-\infty}^{+\infty} p(x,y)dxdy = 1

结论:

  1. 联合分布函数相比于一元分布函数,其实就是从概率密度函数与 xx 轴围成的面积转变为了概率密度曲面与 xOyxOy​ 平面围成的体积
  2. 若概率密度曲面在 xOyxOy 平面的投影为点集或线集,则对应的概率显然为零

常见的连续型二维分布:

  1. 二维均匀分布:假设该曲面与 xOyxOy 面的投影面积为 SS,则分布函数其实就是一个高为定值 1S\frac{1}{S} 的柱体,密度函数为:

    p(x,y)={1S,(x,y)G0,其他p(x,y) = \begin{cases}\frac{1}{S}, &(x,y) \in G \\0, &\text{其他}\end{cases}

  2. 二元正态分布:不要求掌握密度函数,可以感受一下密度函数的图像:

    二元正态分布 - 密度函数的图像

计算题:往往给出一个二元密度函数,然后让我们求解(1)密度函数中的参数、(2)分布函数、(3)联合事件某个区域下的概率

(1)我们利用二元密度函数的正规性,直接积分值为 11 即可

(2)划分区间后进行曲面积分即可,在曲面积分时往往结合 XX 型和 YY 型的二重积分进行

(3)画出概率密度曲面在 xOyxOy 面的投影,然后积分即可

3.2 二维随机向量的边缘分布

对于二元分布函数,我们也可以研究其中任意一个随机变量的分布情况,而不需要考虑另一个随机变量的取值情况。举一个实例就是,假如当前的随机向量是身高和体重,所谓的只研究其中一个随机变量,即边缘分布函数的情形就是,我们不考虑身高只考虑体重的分布情况;或者我们不考虑体重,只考虑身高的分布情况。接下来,我们将从边缘分布函数入手,逐渐学习离散型的分布列与连续型的分布函数。

3.2.1 边缘分布函数

我们称 FX(x),FY(yF_X(x),F_Y(y) 分别为 (X,Y)(X,Y) 关于 X,YX,Y 的边缘分布函数,定义式为:

FX(x)=P(Xx)=P(Xx,Y<+)=limy+F(x,y)=F(x,+)FY(y)=P(Yy)=P(X<+,Yy)=limx+F(x,y)=F(+,y)\begin{aligned}F_X(x) = P(X \le x) = P(X \le x,Y < +\infty) = \lim_{y \to +\infty} F(x,y) = F(x,+\infty) \\F_Y(y) = P(Y \le y) = P(X < +\infty, Y \le y) = \lim_{x \to +\infty} F(x,y) = F(+\infty,y)\end{aligned}

3.2.2 边缘分布列

所谓的边缘分布列,就是固定一个随机变量,另外的随机变量取遍,组成的分布列。即:

P(X=xi)=pi=j=1+pij,i=1,2,P(Y=yj)=pj=i=1+pij,j=1,2,\begin{aligned}P(X=x_i) = p_{i\cdot}=\sum_{j=1}^{+\infty} p_{ij}, \quad i=1,2,\cdots \\P(Y=y_j) = p_{\cdot j}=\sum_{i=1}^{+\infty} p_{ij}, \quad j=1,2,\cdots\end{aligned}

我们称:

  • P(X=xi)P(X=x_i) 为随机向量 (X,Y)(X,Y) 关于 XX 的边缘分布列

  • P(Y=yj)P(Y=y_j) 为随机向量 (X,Y)(X,Y) 关于 YY 的边缘分布列

3.2.3 边缘密度函数

所谓的的边缘密度函数,可以与边缘分布列进行类比,也就是固定一个随机变量,另外的随机变量取遍。只不过连续型的取遍就是无数个点,而离散型的取遍是可列个点,仅此而已。即:

P(X=x)=pX(x)=ddxFX(x)=ddxF(x,+)=ddxx[+p(u,v)dv]du=+p(x,y)dy\begin{aligned}P(X=x) &= p_X(x) \\&= \frac{d}{dx} F_X(x) \\&= \frac{d}{dx} F(x,+\infty) \\&= \frac{d}{dx} \int_{-\infty}^{x} \left [ \int_{-\infty}^{+\infty} p(u,v) dv \right ] du \\&= \int_{-\infty}^{+\infty} p(x,y) dy \\\end{aligned}

P(Y=y)=pY(y)=ddyFY(y)=ddyF(+,y)=ddy+[yp(u,v)dv]du=ddyy[+p(u,v)du]dv=+p(x,y)dx\begin{aligned}P(Y=y) &= p_Y(y) \\&= \frac{d}{dy} F_Y(y) \\&= \frac{d}{dy} F(+\infty,y) \\&= \frac{d}{dy} \int_{-\infty}^{+\infty} \left [ \int_{-\infty}^{y} p(u,v) dv \right ] du \\&= \frac{d}{dy} \int_{-\infty}^{y} \left [ \int_{-\infty}^{+\infty} p(u,v) du \right ] dv \\&= \int_{-\infty}^{+\infty} p(x,y) dx \\\end{aligned}

我们称:

  • P(X=x)P(X=x) 为随机向量 (X,Y)(X,Y) 关于 XX 的边缘密度函数

  • P(Y=y)P(Y=y) 为随机向量 (X,Y)(X,Y) 关于 YY 的边缘密度函数

3.3 随机向量的条件分布

本目主要介绍的是条件分布。所谓的条件分布,其实就是在约束一个随机变量为定值的情况下,另外一个随机变量的取值情况。与上述联合分布、边缘分布的区别在于:

  • 联合分布、边缘分布的分布函数是一个体积(散点和),概率密度(分布列)是一个曲面(点集)
  • 条件分布的分布函数是一个面积(散点和),概率密度(分布列)是一个曲线(点集)

3.3.1 离散型随机向量的条件分布列和条件分布函数

条件分布列,即散点情况:

pij=P(X=xi  Y=yj)=P(X=xi,Y=yi)P(Y=yi)=pijpj,i=1,2,pji=P(Y=yj  X=xi)=P(X=xi,Y=yi)P(X=xi)=pijpi,j=1,2,\begin{aligned}p_{i|j} = P(X=x_i\ |\ Y=y_j) = \frac{P(X=x_i,Y=y_i)}{P(Y=y_i)} = \frac{p_{ij}}{p_{\cdot j}}, \quad i=1,2,\cdots \\p_{j|i} = P(Y=y_j\ |\ X=x_i) = \frac{P(X=x_i,Y=y_i)}{P(X=x_i)} = \frac{p_{ij}}{p_{i\cdot }}, \quad j=1,2,\cdots\end{aligned}

我们称:

  • pijp_{i|j} 为在给定 Y=yjY=y_j 的条件下 XX 的条件分布列

  • pjip_{j|i} 为在给定 X=xiX=x_i 的条件下 YY 的条件分布列

条件分布函数,即点集情况:

F(xyj)=P(Xx  Y=yj)=xixpijpjF(yxi)=P(Yy  X=xi)=yjypijpi\begin{aligned}F(x|y_j) = P(X \le x\ | \ Y=y_j) = \sum _{x_i\le x} \frac{p_{ij}}{p_{\cdot j}} \\F(y|x_i) = P(Y \le y\ | \ X=x_i) = \sum _{y_j\le y} \frac{p_{ij}}{p_{i \cdot}}\end{aligned}

我们称:

  • F(xyj)F(x|y_j) 为在给定 Y=yjY=y_j 的条件下 XX 的条件分布函数
  • F(yxi)F(y|x_i) 为在给定 X=xiX=x_i 的条件下 YY 的条件分布函数

3.3.2 连续型随机向量的条件密度函数和条件分布函数

条件密度函数,即联合分布的概率密度曲面上,约束了某一维度的随机变量为定值,于是条件密度函数的图像就是一个空间曲线:

p(xy)=p(x,y)pY(y),<x<+p(yx)=p(x,y)pX(x),<y<+\begin{aligned}p(x|y) = \frac{p(x,y)}{p_Y(y)}, \quad -\infty < x < +\infty \\p(y|x) = \frac{p(x,y)}{p_X(x)}, \quad -\infty < y < +\infty\end{aligned}

我们称:

  • p(xy)p(x|y) 为在给定 Y=yY=y 的条件下 XX 的条件密度函数
  • p(yx)p(y|x) 为在给定 X=xX=x 的条件下 YY 的条件密度函数

条件分布函数,即上述曲线的分段积分结果:

F(xy)=P(Xx  Y=y)=xp(u,y)pY(y)du,<x<+F(yx)=P(Yy  X=x)=yp(x,v)pX(x)dv,<y<+\begin{aligned}F(x|y) = P(X \le x \ | \ Y=y) = \int_{-\infty}^x \frac{p(u,y)}{p_Y(y)} du,\quad -\infty < x < +\infty \\F(y|x) = P(Y \le y \ | \ X=x) = \int_{-\infty}^y \frac{p(x,v)}{p_X(x)} dv, \quad -\infty < y < +\infty\end{aligned}

我们称:

  • F(xy)F(x|y) 为在给定 Y=yY=y 的条件下 XX 的条件分布函数
  • F(yx)F(y|x) 为在给定 X=xX=x 的条件下 YY 的条件分布函数

3.4 随机变量的独立性

本目主要介绍随机变量的独立性。我们知道随机事件之间是有独立性的,即满足 P(AB)=P(A)P(B)P(AB)=P(A)P(B) 的事件,那么随机变量之间也有独立性吗?答案是有的,以生活中的例子为实例,比如我和某个同学进教室,就是独立的两个随机变量。下面开始介绍。

  • 定义:我们定义如果两个随机变量的分布函数满足下式,则两个随机变量相互独立:

    F(x,y)=FX(x)FY(y)F(x,y)=F_X(x)F_Y(y)

  • 性质:对于随机向量 (X,Y)(X,Y)

    1. 随机变量 XXYY 相互独立的充分必要条件是:

      离散型:P(X=xi,Y=yj)=P(X=xi)P(Y=yj)连续型:p(x,y)=pX(x)pY(y)\begin{aligned}\text{离散型:}& P(X=x_i,Y=y_j) = P(X=x_i)P(Y=y_j) \\\text{连续型:}& p(x,y) = p_X(x)p_Y(y)\end{aligned}

    2. 若随机变量 XXYY 相互独立,且 h()h(\cdot)g()g(\cdot) 连续,则 h(X),g(Y)h(X),g(Y) 也相互独立

3.5 随机向量函数的分布

在 2.4 目中我们了解到了随机变量函数的分布,现在我们讨论随机向量函数的分布。在生活中,假设我们已经知道了一个人群中所有人的身高和体重的分布情况,现在想要血糖根据身高和体重的分布情况,就需要用到本目的理念。我们从离散型和连续型随机向量 (X,Y)(X,Y) 出发,讨论 g(X,Y)g(X,Y) 的分布情况。

3.5.1 离散型随机向量函数的分布

按照规则枚举即可。

3.5.2 连续型随机向量函数的分布

与连续型随机变量函数的分布类似,这类题目一般也是:给定随机向量 (X,Y)(X,Y) 的密度函数 p(x,y)p(x,y) 和 映射函数 g(x,y)g(x,y),现在需要求解 Z=g(X,Y)Z=g(X,Y) 的分布函数(若 g(x,y)g(x,y) 二元连续,则 ZZ 也是连续型随机变量)。方法同理,先求解 ZZ 的分布函数,再对 zz 求导得到密度函数 pZ(z)p_Z(z)​。接下来我们介绍两种常见随机向量的分布。

(1) 和的分布:

  • 先求分布函数 FZ(z)F_Z(z)

    FZ(z)=P(X+Yz)=x+yzp(x,y)dxdy=z[+p(x,tx)dx]dt=z[+p(ty,y)dy]dt\begin{aligned}F_Z(z) &= P(X+Y \le z) \\&= \iint\limits_{x+y \le z} p(x,y) dxdy \\&\begin{align}&= \int _{-\infty}^z \left [ \int_{-\infty}^{+\infty} p(x,t-x)dx \right ] dt \\&= \int _{-\infty}^z \left [ \int_{-\infty}^{+\infty} p(t-y,y)dy \right ] dt\end{align}\end{aligned}

  • 由分布函数定义:

    FX(x)=xp(u)duF_X(x) = \int_{-\infty}^xp(u)du

  • 所以可得 Z=X+YZ=X+Y 的密度函数 pZ(z)p_Z(z) 为:

    pZ(z)=+p(x,zx)dx(1)pZ(z)=+p(zy,y)dy(2)\begin{aligned}p_Z(z) = \int_{-\infty}^{+\infty} p(x,z-x)dx \quad &(1) \\p_Z(z) = \int_{-\infty}^{+\infty} p(z-y,y)dy \quad &(2) \\\end{aligned}

  • 若 X 和 Y 相互独立,还可得卷积式:

    pZ(z)=+p(x,zx)dx=+pX(x)pY(zx)dx(1)pZ(z)=+p(zy,y)dy=+pX(zy)pY(y)dy(2)\begin{aligned}p_Z(z) &= \int_{-\infty}^{+\infty} p(x,z-x)dx \\&= \int_{-\infty}^{+\infty} p_X(x)\cdot p_Y(z-x) dx \quad &(1) \\p_Z(z) &= \int_{-\infty}^{+\infty} p(z-y,y)dy \\&= \int_{-\infty}^{+\infty} p_X(z-y)\cdot p_Y(y) dy \quad &(2)\end{aligned}

(2) 次序统计量的分布(对于两个相互独立的随机变量 X 和 Y):

  • 对于 M=max(X,Y)M=\max{(X,Y)} 的分布函数,有:

    FM(z)=P(Mz)=P(max(X,Y)z)=P(Xz,Yz)=P(Xz)P(Yz)=FX(z)FY(z)\begin{aligned}F_M(z) &= P(M \le z) \\&= P(\max{(X,Y)} \le z) \\&= P(X \le z, Y \le z) \\&= P(X \le z) \cdot P(Y \le z) \\&= F_X(z) \cdot F_Y(z)\end{aligned}

  • 对于 N=min(X,Y)N=\min{(X,Y)} 的分布函数,有:

    FN(z)=P(Nz)=P(min(X,Y)z)=1P(min(X+Y)z)=1P(Xz,Yz)=1P(Xz)P(Yz)=1[1FX(z)][1FY(z)]\begin{aligned}F_N(z) &= P(N \le z) \\&= P(\min{(X,Y)} \le z) \\&= 1 - P(\min{(X+Y)} \ge z) \\&= 1 - P(X \ge z,Y \ge z) \\&= 1 - P(X \ge z) \cdot P(Y \ge z) \\&= 1 - [1 - F_X(z)] \cdot [1 - F_Y(z)]\end{aligned}

  • 若拓展到 nn 个相互独立且同分布的随机变量,则有:

    FM(z)=[F(z)]npM(z)=np(z)[F(z)]n1\begin{aligned}F_M(z) &= [F(z)]^n \\p_M(z) &= np(z)[F(z)]^{n-1}\end{aligned}

    FN(z)=1[1F(z)]npN(z)=np(z)[1F(z)]n1\begin{aligned}F_N(z) &= 1 - [1-F(z)]^n \\p_N(z) &= np(z)[1-F(z)]^{n-1}\end{aligned}

第4章 随机变量的数字特征

本章我们将学习随机变量的一些数字特征。所谓的数字特征其实就是随机变量分布的一些内在属性,比如均值、方差、协方差等等,有些分布特性甚至可以通过某个数字特征而直接觉得。其中期望方差往往用来衡量单个随机变量的特征,而协方差相关系数则是用来衡量随机变量之间的数字特征。接下来开始介绍。

4.1 数学期望

加权平均概念的严格数学定义。

4.1.1 随机变量的数学期望

  • 离散型

    EX=i=1xipiEX = \sum_{i=1}^{\infty} x_i p_i

  • 连续型

    EX=+xp(x)dx\begin{aligned}&EX = \int_{-\infty}^{+\infty} xp(x)dx\end{aligned}

4.1.2 随机变量函数的数学期望

  • 离散型

    • 一元

      Eg(X)=i=1g(xi)piEg(X) = \sum_{i=1}^{\infty}g(x_i)p_i

    • 二元

      Eg(X,Y)=i=1j=1g(xi,yi)pijEg(X,Y) = \sum_{i=1}^{\infty}\sum_{j=1}^{\infty}g(x_i,y_i)p_{ij}

  • 连续型

    • 一元

      Eg(X)=+g(x)p(x)dxEg(X) = \int_{-\infty}^{+\infty}g(x)p(x)dx

    • 二元

      Eg(X,Y)=++g(xi,yi)p(x,y)dxdyEg(X,Y) = \int_{-\infty}^{+\infty}\int_{-\infty}^{+\infty}g(x_i,y_i)p(x,y)dxdy

4.1.3 数学期望的性质

  1. EC=CEC=C
  2. E(CX)=CEXE(CX)=CEX
  3. E(X+Y)=EX+EYE(X+Y)=EX+EY
  4. XXYY 相互独立,则 E(XY)=EXEYE(XY)=EXEY

4.2 方差

随机变量的取值与均值之间的离散程度

4.2.1 方差的定义

我们定义随机变量 XX 的方差 D(X)D(X) 为:(全部可由期望的性质推导而来)

D(X)=E[(XEX)2]=E(X2)(EX)2\begin{aligned}D(X) &= E\left[(X-EX)^2\right ] \\&= E\left ( X^2 \right ) - (EX)^2\end{aligned}

4.2.2 方差的性质

下列方差的性质全部可由上述方差的定义式,结合期望的性质推导而来:

  1. D(aX+b)=a2D(X)D(aX+b) = a^2D(X)

  2. X1,X2,X_1,X_2,\cdots 相互独立,则 D(aX1±bX2±)=a2D(X1)+b2D(X2)+D(aX_1 \pm bX_2 \pm \cdots) = a^2D(X_1) + b^2D(X_2) + \cdots

  3. E[(XEX)2]E[(XC)2]E\left[ (X-EX)^2 \right] \le E \left [ (X-C)^2 \right ]

  4. 切比雪夫不等式 (本以为不要求掌握的,但是被小测拷打了,补一下)

    ϵ>0,P(XEX<ϵ)1DXϵ2\forall \epsilon >0, P(|X - EX| < \epsilon) \ge 1 - \frac{DX}{\epsilon^2}

4.3 结论与推导(补)

类型分布符号期望 E(X)E(X)方差 D(X)D(X)
离散型0-1 分布X(011pp)X \sim \begin{pmatrix} 0 & 1 \\ 1-p & p \end{pmatrix}ppp(1p)p(1-p)
*二项分布XB(n,p)X \sim B(n,p)npnpnp(1p)np(1-p)
几何分布XG(p)X \sim G(p)1p\displaystyle \frac{1}{p}1pp2\displaystyle \frac{1-p}{p^2}
*泊松分布XP(λ)X \sim P(\lambda)λ\lambdaλ\lambda
连续型均匀分布XU[a,b]X \sim U[a,b]a+b2\displaystyle \frac{a+b}{2}(ba)212\displaystyle \frac{(b-a)^2}{12}
指数分布Xe(λ)X \sim e(\lambda)1λ\displaystyle \frac{1}{\lambda}1λ2\displaystyle \frac{1}{\lambda^2}
*正态分布XN(μ,σ2)X \sim N(\mu,\sigma^2)μ\muσ2\sigma^2

注:打星号表示在两个随机变量 X,YX,Y 相互独立时,具备可加性。具体的:

  1. XN(μ1,σ12),YN(μ2,σ22)X±YN(μ1±μ2,σ12+σ22)X \sim N(\mu_1,\sigma_1^2), Y \sim N(\mu_2,\sigma_2^2) \to X\pm Y\sim N(\mu_1\pm\mu_2,\sigma_1^2+\sigma_2^2)
  2. XB(n1,p),YB(n2,p)X+YB(n1+n2,p)X \sim B(n_1,p), Y \sim B(n_2,p) \to X+Y\sim B(n_1+n_2,p)
  3. XP(λ1),YP(λ2)X+YP(λ1+λ2)X \sim P(\lambda_1),Y\sim P(\lambda_2) \to X+Y \sim P(\lambda_1+\lambda_2)

推导的根本方式还是从定义出发。当然为了省事也可以从性质出发。

0-1 分布

0-1 分布

二项分布

二项分布

几何分布

几何分布

泊松分布

泊松分布

均匀分布

均匀分布

指数分布

指数分布

4.4 协方差与相关系数

4.4.1 协方差

定义:随机变量 X 与 Y 的协方差 Cov(X,Y)Cov(X,Y) 为:

Cov(X,Y)=E[(XEX)(YEY)]=E(XY)EXEY\begin{aligned}Cov(X,Y)&= E[(X-EX)(Y-EY)] \\&= E(XY) - EXEY\end{aligned}

特别的:

Cov(X,X)=DXCov(X,X) = DX

性质:

  1. 交换律:Cov(X,Y)=Cov(Y,X)Cov(X,Y)=Cov(Y,X)
  2. 提取率:Cov(aX,bY)=abCov(X,Y)Cov(aX,bY)=abCov(X,Y)
  3. 分配率:Cov(X1+X2,Y)=Cov(X1,Y)+Cov(X2,Y)Cov(X_1+X_2,Y) = Cov(X_1,Y)+Cov(X_2,Y)
  4. 独立性:若 X 与 Y 相互独立,则 Cov(X,Y)=0Cov(X,Y)=0;反之不一定成立
  5. 放缩性:[Cov(X,Y)]2DXDY\left[Cov(X,Y)\right]^2 \le DX \cdot DY

4.4.2 相关系数

定义:相关系数 ρ\rho 是用来刻画两个随机变量之间线性相关关系强弱的一个数字特征,注意是线性关系。ρ|\rho| 越接近 0,则说明两个随机变量越不线性相关;ρ|\rho| 越接近 1,则说明两个随机变量越线性相关,定义式为

ρX,Y=Cov(X,Y)DXDY\rho_{X,Y} = \frac{Cov(X,Y)}{\sqrt{DX}\sqrt{DY}}

特别的:

  1. 0<ρ<10 < \rho < 1,则称 X 与 Y 正相关
  2. 1<ρ<0-1<\rho<0,则称 X 与 Y 负相关

性质:

  1. 放缩性(由协方差性质5可得):ρ1|\rho| \le 1
  2. 独立性(由协方差性质4可得):若 X 与 Y 相互独立,则 p=0p=0;反之不一定成立
  3. 线性相关性(不予证明):ρ=1|\rho|=1 的充分必要条件是存在常数 a(a0),ba(a\ne0),b 使得 P(Y=aX+b)=1P(Y=aX+b)=1

4.4.3 独立性与线性相关性(补)

一般的:对于两个随机变量 XXYY

  • XXYY 相互独立 \rightarrow XXYY 线性无关(可以用线性相关的定义式结合协方差计算公式导出)
  • XXYY 相互独立 \nleftarrow XXYY 线性无关(因为有可能出现 XXYY 非线性相关)

特别的:对于满足二维正态分布的随机变量 XXYY,即 (X,Y)(μ1,μ2,σ12,σ22,ρ)(X,Y) \sim (\mu_1,\mu_2,\sigma_1^2,\sigma_2^2,\rho)

  • XXYY 相互独立 \rightarrow XXYY 线性无关
  • XXYY 相互独立 \leftarrow XXYY​ 线性无关

第5章 大数定律与中心极限定理

本章只需要知道一个独立同分布中心极限定理即可,至于棣莫弗-拉普拉斯中心极限定理其实就是前者的 {Xi}i=1\{X_i\}_{i=1}^{\infty} 服从伯努利 nn 重分布罢了。

独立同分布中心极限定理

定义:{Xi}i=1\{X_i\}_{i=1}^{\infty} 独立同分布且非零方差,其中 EXi=μ,DXi=σ2EX_i=\mu,DX_i=\sigma^2,则有:

i=1nXiN(i=1n(EXi),i=1n(DXi))N(nμ,nσ2)\begin{aligned}\sum_{i=1}^n X_i &\sim N(\sum_{i=1}^n(EX_i),\sum_{i=1}^n(DX_i)) \\&\sim N(n\mu,n\sigma^2)\end{aligned}

解释:其实就是对于独立同分布的随机事件 XiX_i,在事件数 nn 足够大时,就近似为正态分布(术语叫做依分布)。这样就可以很方便利用正态分布的性质计算当前事件的概率。至于棣莫弗-拉普拉斯中心极限定理就是上述 μ=p,σ2=p(1p)\mu=p,\sigma^2=p(1-p) 的特殊情况罢了

第6章 数理统计的基本概念

开始统计学之旅。

6.1 总体与样本

类比 ML:数据集=总体,样本=样本。

我们只研究一种样本:简单随机样本 (X1,X2,...,Xn)(X_1,X_2,...,X_n)。符合下列两种特点:

  1. (X1,X2,...,Xn)(X_1,X_2,...,X_n) 相互独立
  2. (X1,X2,...,Xn)(X_1,X_2,...,X_n) 同分布

同样的,我们研究总体 XX 的分布与概率密度,一般概率密度会直接给,需要我们在此基础之上研究所有样本的联合密度:

  • 分布:由于样本相互独立,故:

    F(x1,x2,...,xn)=F(x1)F(x2)F(xn)F(x_1,x_2,...,x_n)=F(x_1)F(x_2) \cdots F(x_n)

  • 联合密度:同样由于样本相互独立,故:

    p(x1,x2,...,xn)=p(x1)p(x2)p(xn)p(x_1,x_2,...,x_n)=p(x_1)p(x_2) \cdots p(x_n)

6.2 经验分布与频率直方图

经验分布函数是利用样本得到的。也是给区间然后统计样本频度进而计算频率,只不过区间长度不是固定的。

频率直方图就是选定固定的区间长度,然后统计频度进而计算频率作图。

6.3 统计量

统计量定义:关于样本不含未知数的表达式。

常见统计量:假设 (X1,X2,...,Xn)(X_1,X_2,...,X_n) 为来自总体 XX 的简单随机样本

一、样本均值和样本方差

  • 样本均值:X=1ni=1nXi\displaystyle \overline{X} = \frac{1}{n} \sum_{i=1}^n X_i

  • 样本方差:S02=1ni=1n(XiX)2=1ni=1nXi2X2\displaystyle S_0^2 = \frac{1}{n} \sum_{i=1}^n (X_i - \overline{X})^2 = \frac{1}{n}\sum_{i=1}^n X_i^2 - \overline{X}^2

  • 样本标准差:S0=S02\displaystyle S_0 = \sqrt{S_0^2}

  • 修正样本方差:S2=1n1i=1n(XiX)2\displaystyle S^2 = \frac{1}{n-1} \sum_{i=1}^n (X_i - \overline{X})^2

  • 修正样本标准差:S=S2\displaystyle S = \sqrt{S^2}

    设总体 XX 的数学期望和方差分别为 μ\muσ2\sigma^2(X1,X2,...,Xn)(X_1,X_2,...,X_n) 是简单随机样本,则:

    样本均值的数学期望与总体的数学期望相等

    即:样本均值的数学期望 == 总体的数学期望

    样本方差的数学期望与总体的数学期望 不相等

    即:样本方差的数学期望 \ne 总体的数学期望

    修正样本方差推导

    上图即:修正样本方差推导

  • 样本 kk 阶原点矩:Ak=1ni=1nXik,k=1,2,\displaystyle A_k = \frac{1}{n} \sum_{i=1}^n X_i^k,\quad k=1,2,\cdots

  • 样本 kk 阶中心矩:Bk=1ni=1n(XiX)k,k=2,3,\displaystyle B_k = \frac{1}{n} \sum_{i=1}^n (X_i-\overline{X})^k,\quad k=2,3,\cdots

二、次序统计量

  • 序列最小值
  • 序列最大值
  • 极差 = 序列最大值 - 序列最小值

6.4 正态总体抽样分布定理

时刻牢记一句话:构造性定义!

6.4.1 卡方分布、t 分布、F 分布

分位数

  • 我们定义实数 λα\lambda_\alpha 为随机变量 XX 的上侧 α\alpha 分位数(点)当且仅当 P(X>λα)=αP(X > \lambda_\alpha) = \alpha
  • 我们定义实数 λ1β\lambda_{1-\beta} 为随机变量 XX 的下侧 β\beta 分位数(点)当且仅当 P(X<λ1β)=βP(X < \lambda_{1-\beta})=\beta

χ2\chi^2 分布

密度函数图像

定义:

  • 对于 nn 个独立同分布的标准正态随机变量 X1,X2,,XnX_1,X_2,\cdots ,X_n,若 Y=X12+X22++Xn2Y = X_1^2 + X_2^2 + \cdots + X_n^2
  • YY 服从自由度为 nnχ2\chi^2 分布,记作:Yχ2(n)Y \sim \chi^2(n)

性质:

  • 可加性:若 Y1χ2(n1),Y2χ2(n2)Y_1 \sim \chi^2(n_1), Y_2 \sim \chi^2(n_2)Y1,Y2Y_1,Y_2 相互独立,则 Y1+Y2χ2(n1+n2)Y_1+Y_2 \sim \chi^2(n_1+n_2)

  • 统计性:对于 Yχ2(n)Y \sim \chi^2(n),有 EY=n,DY=2nEY = n, DY = 2n

    EY 的推导利用:EX2=DX(EX)2EX^2 = DX - (EX)^2

    EY 的推导

    DY 的推导利用:方差计算公式、随机变量函数的数学期望进行计算

    DY 的推导

tt 分布

密度函数图像

定义:

  • 若随机变量 XN(0,1),Yχ2(n)X \sim N(0, 1),Y \sim \chi^2 (n)X,YX,Y 相互独立
  • 则称随机变量 T=XY/nT = \displaystyle \frac{X}{\sqrt{Y/n}} 为服从自由度为 nntt 分布,记作 Tt(n)T \sim t(n)

性质:

  • 密度函数是偶函数,具备对称性

FF 分布

密度函数图像

定义:

  • 若随机变量 Xχ2(m),Yχ2(n)X \sim \chi^2(m), Y \sim \chi^2(n) 且相互独立
  • 则称随机变量 G=X/mY/nG=\displaystyle \frac{X/m}{Y/n} 服从自由度为 (m,n)(m,n)FF 分布,记作 GF(m,n)G \sim F(m, n)

性质:

  • 倒数自由度转换:1GF(n,m)\displaystyle \frac{1}{G} \sim F(n, m)
  • 三变性质F1α(m,n)=[Fα(n,m)]1\displaystyle F_{1-\alpha}(m, n) = \left [F_\alpha (n, m)\right]^{-1}

6.4.2 正态总体抽样分布基本定理

X1,X2,,XnX_1,X_2,\cdots ,X_n 是来自正态总体 N(μ,σ2)N(\mu, \sigma^2) 的简单随机样本,X,S2\overline{X},S^2 分别是样本均值和修正样本方差。则有:

定理:

  • XN(μ,σ2n)\displaystyle \overline{X} \sim N(\mu, \frac{\sigma^2}{n})
  • (n1)S2σ2χ2(n1)\displaystyle \frac{(n-1)S^2}{\sigma^2} \sim \chi^2(n-1)
  • X\overline{X}S2S^2 相互独立

推论:

  • n(Xμ)St(n1)\displaystyle \frac{\sqrt{n}(\overline{X} - \mu)}{S} \sim t(n-1)

第7章 参数估计

有些时候我们知道数据的分布类型,但是不清楚表达式中的某些参数,这就需要我们利用「已有的样本」对分布表达式中的参数进行估计。本章我们将从点估计、估计评价、区间估计三部分出发进行介绍。

7.1 点估计

所谓点估计策略,就是直接给出参数的一个估计值。本目我们介绍点估计策略中的两个方法:矩估计法、极大似然估计法。

7.1.1 矩估计法

其实就一句话:我们用样本的原点矩 AkA_k 来代替总体 E(Xk)E(X^k)kk 个未知参数就需要用到 kk 个原点矩:

E(Xk)=Ak=1ni=1nXikE(X^k) = A_k = \frac{1}{n}\sum_{i=1}^nX_i^k

7.1.2 极大似然估计法

基本原理是:在当前样本数据的局面下,我们希望找到合适的参数使得当前的样本分布情况发生的概率最大。由于各样本相互独立,因此我们可以用连乘的概率公式来计算当前局面的概率值:

L(θ;x1,x2,,xn)L(\theta;x_1,x_2,\cdots,x_n)

上述 L(θ;x1,x2,,xn)L(\theta;x_1,x_2,\cdots,x_n) 即似然函数,目标就是选择适当的参数 θ\theta 来最大化似然函数。无论是离散性还是连续型,都可以采用下面的方式来计算极大似然估计:

  1. 写出似然函数 L(θ)L(\theta)
  2. 将上述似然函数取对数
  3. 求对数似然函数关于所有未知参数的偏导并计算极值点
  4. 解出参数关于样本统计量的表达式

离散型随机变量的似然函数表达式

L(θ)=i=1np(xi;θ)=i=1nP(Xi=xi)L(\theta) = \prod_{i=1}^n p(x_i;\theta) = \prod_{i=1}^n P(X_i = x_i)

连续型随机变量的似然函数表达式

L(θ)=i=1np(xi;θ)L(\theta) = \prod_{i=1}^n p(x_i;\theta)

可以看出极大似然估计本质上就是一个多元函数求极值的问题。特别地,当我们没法得到参数关于样本统计量的表达式 L(θ)L(\theta) 时,可以直接从定义域、原函数恒增或恒减等角度出发求解这个多元函数的极值。

7.2 估计量的评价标准

如何衡量不同的点估计方法好坏?我们引入三种点估计量的评价指标:无偏性、有效性、一致性。其中一致性一笔带过,不做详细讨论。补充一点,参数的估计量 θ\theta 是关于样本的统计量,因此可以对其进行求期望、方差等操作。

7.2.1 无偏性

顾名思义,就是希望估计出来的参数量尽可能不偏离真实值。我们定义满足下式的估计量 θ^\hat \theta 为真实参数的无偏估计:

Eθ^=θE\hat \theta =\theta

7.2.2 有效性

有效性是基于比较的定义方法。对于两个无偏估计 θ^1,θ^2\hat\theta_1,\hat\theta_2,谁的方差越小谁就越有效。即若 D(θ^1),D(θ^2)D(\hat\theta_1),D(\hat\theta_2) 满足下式,则称 θ^1\hat\theta_1 更有效

D(θ^1)<D(θ^2)D(\hat\theta_1) < D(\hat\theta_2)

7.2.3 一致性

即当样本容量 n 趋近于无穷时,参数的估计值也能趋近于真实值,则称该估计量 θ^\hat\thetaθ\theta 的一致估计量

7.3 区间估计

由于点估计只能进行比较,无法对单一估计进行性能度量。因此引入「主元法」的概念与「区间估计」策略

7.3.1 基本概念

可靠程度:参数估计区间越长,可靠程度越高

精确程度:参数估计区间越短,可靠程度越高

7.3.2 区间估计常用方法之主元法

主元法的核心逻辑就一个:在已知数据总体分布的情况下,构造一个关于样本 XX 和待估参数 θ\theta 的函数 Z(X,θ)Z(X,\theta),然后利用置信度和总体分布函数,通过查表得到 Z(X,θ)Z(X,\theta) 的取值范围,最后通过移项变形得到待估参数的区间,也就是估计区间。

7.3.3 正态总体的区间估计

我们只需要掌握「一个总体服从正态分布」的情况。这种情况下的区间估计分为三种,其中估计均值 μ\mu 有 2 种,估计方差 σ2\sigma^2 有 1 种。估计的逻辑我总结为了以下三步:

  1. 构造主元 Z(X,θ)Z(X,\theta)
  2. 利用置信度 1α1-\alpha 计算主元 ZZ 的取值范围
  3. 对主元 ZZ 的取值范围移项得到参数 θ\theta 的取值范围

为了提升区间估计的可信度,我们希望上述第 2 步计算出来的关于主元的取值范围尽可能准确。我们不加证明的给出以下结论:取主元的取值范围为 主元服从的分布的上下 α2\frac{\alpha}{2} 分位数之间

(一) 求 μ\mu 的置信区间,σ2\sigma^2 已知

构造主元 Z(X,θ)Z(X,\theta)

Z=Xμσ/nN(0,1)Z = \frac{\overline{X} - \mu}{\sigma / \sqrt{n}} \sim N(0,1)

利用置信度 1α1-\alpha 计算主元 ZZ 的取值范围:

P(Zλ)=1αZ[λ,λ]=[uα2,uα2]\begin{aligned}P(|Z| \le \lambda) &= 1-\alpha \\&\downarrow\\Z \in [-\lambda,\lambda] &= [-u_{\frac{\alpha}{2}},u_\frac{\alpha}{2}]\end{aligned}

对主元 ZZ 的取值范围移项得到参数 θ\theta 的取值范围:

Xσnuα2μX+σnuα2\overline{X} - \frac{\sigma}{\sqrt{n}} u_\frac{\alpha}{2} \le \mu \le \overline{X} + \frac{\sigma}{\sqrt{n}} u_\frac{\alpha}{2}

(二) 求 μ\mu 的置信区间,σ2\sigma^2 未知

构造主元 Z(X,θ)Z(X,\theta)

Z=XμS/nt(n1)Z = \frac{\overline{X} - \mu}{S / \sqrt{n}} \sim t(n-1)

利用置信度 1α1-\alpha 计算主元 ZZ 的取值范围:

P(Zλ)=1αZ[λ,λ]=[tα2(n1),tα2(n1)]\begin{aligned}P(|Z| \le \lambda) &= 1-\alpha \\&\downarrow\\Z \in [-\lambda,\lambda] &= [-t_{\frac{\alpha}{2}}(n-1),t_\frac{\alpha}{2}(n-1)]\end{aligned}

对主元 ZZ 的取值范围移项得到参数 θ\theta 的取值范围:

XSntα2(n1)μX+Sntα2(n1)\overline{X} - \frac{S}{\sqrt{n}} t_\frac{\alpha}{2}(n-1) \le \mu \le \overline{X} + \frac{S}{\sqrt{n}} t_\frac{\alpha}{2}(n-1)

(三) 求 σ2\sigma^2 的置信区间,构造的主元与总体均值无关,因此不需要考虑 μ\mu 的情况:

构造主元 Z(X,θ)Z(X,\theta)

Z=(n1)S2σ2χ2(n1)Z = \frac{(n-1)S^2}{\sigma^2}\sim \chi^2(n-1)

利用置信度 1α1-\alpha 计算主元 ZZ 的取值范围:

P(λ1Zλ2)=1αZ[λ1,λ2]=[χ1α22(n1),χα22(n1)]\begin{aligned}P(\lambda_1 \le Z \le \lambda_2) &= 1-\alpha \\&\downarrow\\Z \in [\lambda_1,\lambda_2] &= [\chi^2_{1-\frac{\alpha}{2}}(n-1),\chi^2_\frac{\alpha}{2}(n-1)]\end{aligned}

对主元 ZZ 的取值范围移项得到参数 θ\theta 的取值范围:

(n1)S2χα22(n1)σ2(n1)S2χ1α22(n1)\frac{(n-1)S^2}{\chi^2_\frac{\alpha}{2}(n-1)} \le \sigma^2 \le \frac{(n-1)S^2}{\chi^2_{1-\frac{\alpha}{2}}(n-1)}

第8章 假设检验

第 7 章的参数估计是在总体分布已知且未知分布表达式中某些参数的情况下,基于「抽取的少量样本」进行的参数估计。

现在的局面同样,我们已知总体分布和不完整的分布表达式参数。现在需要我们利用抽取的少量样本判断样本所在向量空间是否符合某种性质。本章的「假设检验」策略就是为了解决上述情况而诞生的。我们主要讨论单个正态总体的情况并针对均值和方差两个参数进行假设和检验:

  • 假设均值满足某种趋势,利用已知数据判断假设是否成立
  • 假设方差满足某种趋势,利用已知数据判断假设是否成立

8.1 假设检验的基本概念

基本思想:首先做出假设并构造一个关于样本观察值和已知参数的检验统计量,接着计算假设发生的情况下小概率事件发生时该检验统计量的取值范围(拒绝域),最终代入已知样本数据判断计算结果是否在拒绝域内。如果在,则说明在当前假设的情况下小概率事件发生了,对应的假设为假;反之说明假设为真。

为了量化「小概率事件发生」这个指标,我们引入显著性水平 α\alpha 这一概念。该参数为一个很小的正数,定义为「小概率事件发生」的概率上界。

基于数据的实验导致我们无法避免错误,因此我们定义以下两类错误:

  • 第一类错误:弃真错误。即假设正确,但由于数据采样不合理导致拒绝了真实的假设
  • 第二类错误:存伪错误。即假设错误,同样因为数据的不合理导致接受了错误的假设

8.2 单个正态总体均值的假设检验

X1,X2,,XnX_1,X_2,\cdots ,X_n 是来自正态总体 N(μ,σ2)N(\mu, \sigma^2) 的简单随机样本。后续进行假设判定计算统计量 ZZ 的真实值时,若总体均值 μ\mu 已知就直接代入,若未知题目也一定会给一个阈值,代这个阈值即可。

当总体方差 σ2\sigma^2 已知时,我们构造样本统计量 ZZ 为正态分布:

Z=Xμσ/nN(0,1)Z = \frac{\overline{X} - \mu}{\sigma / \sqrt{n}} \sim N(0,1)

  • 检验是否则求解双侧 α\alpha 分位数
  • 检验单边则求解单侧 α\alpha 分位数

当总体方差 σ2\sigma^2 未知时,我们构造样本统计量 ZZtt 分布:

Z=XμS/nt(n1)Z = \frac{\overline{X} - \mu}{S / \sqrt{n}} \sim t(n-1)

注:之所以这样构造是因为当总体 σ\sigma 未知时,上一个方法构造的主元已经不再是统计量,我们需要找到能够代替未知参数 σ\sigma 的变量,这里就采用其无偏估计「修正样本方差 S2S^2」来代替 σ2\sigma^2。也是说直接拿样本的修正方差来代替总体的方差了。

  • 检验是否则求解双侧 α\alpha 分位数
  • 检验单边则求解单侧 α\alpha 分位数

8.3 单个正态总体方差的假设检验

X1,X2,,XnX_1,X_2,\cdots ,X_n 是来自正态总体 N(μ,σ2)N(\mu, \sigma^2) 的简单随机样本。后续进行假设判定计算统计量 ZZ 的真实值时,若总体方差 σ2\sigma^2 已知就直接代入,若未知题目也一定会给一个阈值,代这个阈值即可。

我们直接构造样本统计量 ZZχ2\chi^2 分布:

Z=(n1)S2σ2χ2(n1)Z = \frac{(n-1)S^2}{\sigma^2} \sim \chi^2(n-1)

  • 检验是否则求解双侧 α\alpha 分位数
  • 检验单边则求解单侧 α\alpha 分位数
]]> @@ -1010,11 +1010,11 @@ - ProbAndStat - - /GPA/4th-term/ProbAndStat/ + PyAlgo + + /GPA/4th-term/PyAlgo/ - 概率论与数理统计

前言

学科情况:

主讲教师学分配额学科类别
周效尧4学科基础课

成绩组成:

平时测验(×2)期末
10%30%60%

教材情况:

课程名称选用教材版次作者出版社ISBN号
概率论与数理统计Ⅰ《概率论与数理统计》第一版刘国祥王晓谦 等主编科学出版社978-7-03-038317-4

学习资源:

第1章 事件与概率

1.1 随机事件与样本空间

1.1.1 样本空间

随机事件发生的总集合 Ω\Omega

1.1.2 随机事件

事件是否发生取决于观察结果的事件

1.1.3 事件之间的关系与运算

  1. 包含:ABA \subset B or BAB \subset A
  2. 相等:A=BA=B
  3. 并(和):ABA \cup B
  4. 交(积):AB(AB)A \cap B \quad (AB)
  5. 互斥(互不相容):AB=ΦAB=\Phi
  6. 对立事件(余事件):AB=ΦAB=ΩA \cap B=\Phi \land A \cup B=\Omega
  7. 差:AB=AB=ABA-B=A \cap \overline{B} = A \overline B
  8. 德摩根律

将事件发生的概率论转化为集合论进行计算与分析

1.2 概率的三种定义及其性质

1.2.1 概率的统计定义

从频率出发得到。

1.2.2 概率的古典定义

特征:

  • 样本空间是有限集
  • 等可能性(试验中每个基本事件发生的概率是等可能的)

内容:

  1. 模型与计算公式
  2. 基本组合分析公式
    • 乘法、加法原理
    • 排列公式
    • 组合公式
  3. 实例
    • 超几何概率
    • 分房问题
    • 生日问题
  4. 古典概率的基本性质

1.2.3 概率的几何定义

特征:

  • 样本空间不可列
  • 等可能性

内容:

  1. 模型与计算公式
  2. 实例
    • 一维几何图形:公交车乘车问题
    • 二维几何图形:会面问题、蒲丰(Buffon)投针问题
  3. 几何概率的基本性质

1.2.4 概率的性质

pass

1.3 常用概型公式

1.3.1 条件概率计算公式

P(BA)=P(AB)P(A)P(B|A) = \frac{P(AB)}{P(A)}

1.3.2 乘法原理计算公式

  • 基本:前提:P(A)>0\text{基本}:\text{前提}:P(A)>0

    P(AB)=P(A)P(BA)P(AB) = P(A)P(B|A)

  • 推广:前提:P(A1A2,...,An)>0\text{推广}:\text{前提}:P(A_1A_2,...,A_n)>0

    P(A1A2...An)=P(A1)P(A2A1)P(A3A1A2)P(AnA1A2...An1)P(A_1A_2...A_n) = P(A_1)P(A_2|A_1)P(A_3|A_1A_2) \cdots P(A_n|A_1A_2...A_{n-1})

1.3.3 全概公式

我们将样本空间 Ω\Omega 完全划分为 nn 个互斥的区域,即 Ω=i=1nAi\Omega = \displaystyle \sum_{i=1}^{n} A_i ,则在样本空间中事件 BB 发生的概率 P(B)P(B) 就是在各子样本空间中概率之和,经过上述乘法公式变形,计算公式如下:

P(B)=P(BΩ)=P(BA1)+P(BA2)++P(BAn)=P(A1)P(BA1)+P(A2)P(BA2)++P(An)P(BAn)=i=1nP(Ai)P(BAi)\begin{equation*}\begin{aligned} P(B) &= P(B \Omega) \\ &= P(BA_1) + P(BA_2) + \cdots + P(BA_n) \\ &= P(A_1)P(B|A_1) + P(A_2)P(B|A_2) + \cdots + P(A_n)P(B|A_n) \\ &= \sum_{i=1}^n P(A_i)P(B|A_i)\end{aligned}\end{equation*}

1.3.4 贝叶斯公式

在上述全概公式的背景之下,现在希望求解事件 BB 在第 jj 个子样本空间 AjA_j 中发生的概率,或者说第 jj 个子样本空间对于事件 BB 的发生贡献了多少概率,记作 P(AjB)P(A_j|B) ,计算公式如下:

P(AjB)=P(Aj)P(BAj)i=1nP(Ai)P(BAi)P(A_j|B) = \frac{P(A_j)P(B|A_j)}{\displaystyle \sum_{i=1}^n P(A_i)P(B|A_i)}

可以发现全概公式是计算事件发生的所有子样本空间的概率贡献,而贝叶斯公式是计算事件发生的总概率中某些子样本空间的概率贡献,前者是正向思维,后者是逆向思维

1.4 事件的独立性及伯努利概型

1.4.1 独立性

定义

  • 基本:若 A,BA,B 相互独立,则满足:

    P(AB)=P(A)P(B)P(AB)=P(A)P(B)

  • 推广:若 A1,A2,...,AnA_1,A_2,...,A_n 相互独立,则满足:

    1i1<i2<<ikn (k=2,3,,n)s.t.P(Ai1Ai2Aik)=P(Ai1)P(Ai2)P(Aik)\begin{aligned}\forall \quad 1 \le i_1<i_2<\cdots<i_k \le n\ (k=2,3,\cdots,n) \\s.t. \quad P(A_{i_1}A_{i_2}\cdots A_{i_k}) = P(A_{i_1})P(A_{i_2})\cdots P(A_{i_k})\end{aligned}

定理

  • 基本:若 A,BA,B 相互独立,则 A,BA,\overline{B} 相互独立;A,B\overline{A},B 相互独立;A,B\overline{A},\overline{B} 相互独立

  • 推广:若 A1,A2,...,AnA_1,A_2,...,A_n 相互独立,则其中任意 k(2kn)k(2 \le k \le n) 个也相互独立,且满足:

    P(Ai1^Ai2^Aik^)=P(Ai1^)P(Ai2^)P(Aik^)s.t.Aij^=A or A (j=1,2,,k)\begin{aligned}P(\hat{A_{i_1}}\hat{A_{i_2}}\cdots \hat{A_{i_k}}) = P(\hat{A_{i_1}})P(\hat{A_{i_2}})\cdots P(\hat{A_{i_k}}) \\s.t. \quad \hat{A_{i_j}} = A \ or \ \overline{A}\ (j=1,2,\cdots,k)\end{aligned}

概念辨析

  • 两两独立:对于 nn 个事件,两两独立,而不考虑三个及以上的关系。

  • 相互独立:对于 nn 个事件,2n2 \to n 个事件的独立关系都需要考虑。

  • 总结:对于 nn 个事件,满足两两独立需要 Cn2C_n^2 个等式关系,对于相互独立需要 2n(n+1)2^n-(n+1) 个等式关系,因此:

    两两独立相互独立\text{两两独立} \subset \text{相互独立}

1.4.2 伯努利概型

定义:nn 重伯努利概型

  • nn 重:发生 nn 次独立试验
  • 伯努利概型:每次试验只有两种可能的结果

模型:

  • 二项概率公式:n 次独立重复试验发生 k 次的概率:

    Cnkpk(1p)nkC_n^k p^k (1-p)^{n-k}

  • 几何概率公式:在第 n 次试验首次成功的概率:

    (1p)n1p(1-p)^{n-1}p

第2章 随机事件及其分布

我们知道,解决事件发生概率的问题,除了事件表示以外,我们还关心每一个事件发生的概率 P(X=k)P(X=k),以及某些事件发生的概率 P(X=[range))P(X=[range))。接下来我们将:

  • 首先介绍随机变量的概念以及分布函数的概念
  • 接着介绍随机变量对应的概率发生情况组成的集合。离散型的叫分布列,连续型的叫概率密度函数,并在其中贯穿分布函数的应用
  • 最后介绍分布函数的复合。从离散型和连续型随机变量两个方向展开

2.1 随机变量及其概率分布

2.1.1 随机变量的概念

总的来说,随机变量就是一个样本空间与实数集的映射。我们定义样本空间 Ω={ω}\Omega=\{ \omega \},其中 ω\omega 表示所有可能的事件,实数集 RR,随机变量 XX,则随机变量满足以下映射关系

X(ω)=RX(\omega)=R

2.1.2 随机变量的分布函数

  1. 分布函数的定义:F(x)=P(Xx)F(x)=P(X \le x)
  2. 分布函数的性质:
    • 非负有界性:0F(x)10 \le F(x) \le 1
    • 单调不减性:若 x1<x2x_1 < x_2,则 F(x1)F(x2)F(x_1) \le F(x_2)
    • F()=limxF(x)=0\displaystyle F(-\infty) = \lim_{x \to -\infty} F(x) = 0F(+)=limx+F(x)=1\displaystyle F(+\infty) = \lim_{x \to +\infty} F(x) = 1
    • 右连续性:limxx0+F(x)=F(x0)(<x0<+)\displaystyle \lim_{x\to x_0^+}F(x) = F(x_0)\quad(-\infty < x_0 < +\infty)

2.2 离散型随机变量及其分布列

2.2.1 离散性随机变量的分布列

随机变量的取值都是整数,有以下三种表示方法

  1. 公式法

    pk=P(X=xk),k=1,2,,p_k = P(X=x_k),\quad k = 1,2,\cdots,

  2. 服从法

    X(x1x2x3p1p2p3)X \sim \begin{pmatrix}x_1 & x_2 & x_3 & \cdots \\p_1 & p_2 & p_3 & \cdots\end{pmatrix}

  3. 表格法

    Xx1x2x3Pp1p2p3\begin{array}{c|cccc}X & x_1 & x_2 & x_3 & \cdots \\\hlineP & p_1 & p_2 & p_3 & \cdots\end{array}

2.2.2 常用离散性随机变量及其分布列

  • 0-1分布:即一个事件只有两面性,我们称这样的随机变量服从0-1分布或者两点分布,记作

    X(011pp)X \sim \begin{pmatrix}0 & 1 \\1-p & p\end{pmatrix}

  • 二项分布:其实就是 n 重伯努利试验,我们称这样的随机变量服从二项分布,分布列为 P(X=k)=Cnkpk(1p)nkP(X=k) = C_n^kp^k(1-p)^{n-k},记作

    XB(n,p)X \sim B(n,p)

  • 几何分布:同样是伯努利事件,现在需要求解第 kk 次事件首次发生的概率,此时分布列为 P(X=k)=(1p)k1pP(X=k)=(1-p)^{k-1}p,记作

    XG(p)X \sim G(p)

  • 超几何分布:就是在 N 件含有 M 件次品的样品中无放回的抽取 n 件,问其中含有次品数量的分布列,为 P(X=k)=CMkCNMnkCNn,k=0,1,2,,min(n,M)\displaystyle P(X=k)=\frac{C_M^k C_{N-M}^{n-k}}{C_N^n}, \quad k=0,1,2,\cdots,\min{(n, M)},记作

    X超几何分布(n,N,M)X \sim \text{超几何分布}(n,N,M)

  • 泊松分布:当二项分布中,试验次数很大或者概率很小时,可以近似为泊松分布,即 P(X=k)=Cnkpk(1p)nkλkk!eλ\displaystyle P(X=k)=C_n^k p^k(1-p)^{n-k} \to \frac{\lambda^k}{k!}e^{-\lambda},其中常数 λ>0\lambda > 0,记作

    XP(λ)X \sim P(\lambda)

    显然,泊松分布含有下面两个性质

    1. P(X=k)>0,k=0,1,P(X=k) > 0,k=0,1,\cdots

    2. k=0P(X=k)=1\displaystyle \sum_{k=0}^\infty P(X=k)=1

      泊松分布正规性证明

2.3 连续型随机变量及其概率密度函数

说白了其实就是离散性随机变量的积分加强版。现在随着事件发生的不同取值 xx,随机变量 XX 发生的概率 P(X=x)P(X=x) 变成了连续的取值了(学名概率密度函数),于是分布函数(离散的叫分布列)的取值就没那么容易求了(其实一重定积分就可以)。接下来就从定义、性质、应用三个角度出发介绍概率密度函数以及相应的随机变量的分布函数。

2.3.1 连续型随机变量的密度函数

概率密度函数,简称:密度函数 or 概率密度

  • 定义:设随机变量 XX 的分布函数为 F(x)F(x),如果存在非负可积函数 p(x)p(x),使下式成立,则称 XX 为连续型随机变量,p(x)p(x)XX 的概率密度函数

    xR,F(x)=xp(t)dt\forall x \in R,F(x) = \int_{-\infty}^{x} p(t)dt

  • 性质:

    1. 非负性:p(x)0p(x) \ge 0

    2. 正规性:+p(x)dx=1\int_{-\infty}^{+\infty} p(x)dx = 1

    3. 可积性:x1x2,P(x1Xx2)=F(x2)F(x1)=x1x2p(x)dx\forall x_1 \le x_2,P(x_1 \le X \le x_2) = F(x_2) - F(x_1) = \int_{x_1}^{x_2}p(x)dx

    4. 分布函数可导性:若 p(x)p(x) 在点 xx 处连续,则 F(x)=p(x)F'(x) = p(x)

    5. 已知事件但无意义性:xR,P(X=x)=F(x)F(x)=0\forall x \in R, P(X=x) = F(x) - F(x) = 0

      • 离散型变量可以通过列举随机变量 XX 的取值来计算概率,但连续型随机变量这么做是无意义的
      • P(A)=0P(A) = 0 不能推出 AA 是不可能事件,P(A)=1P(A)=1 不能推出 AA 是必然事件
      • 对于连续型随机变量 XX 有:P(x1<X<X2)=P(x1<XX2)=P(x1X<X2)=P(x1XX2)P(x_1 < X < X_2)=P(x_1 < X \le X_2)=P(x_1 \le X < X_2)=P(x_1 \le X \le X_2)
    6. 实际描述性:密度函数的数值反映了随机变量 XXxx 的临近值的概率的大小,因为

      p(x)Δxxx+Δxp(t)dt=F(x+Δx)F(x)=P(xXx+Δx)p(x)\Delta x \approx \int_{x}^{x+\Delta x} p(t)dt = F(x+\Delta x) - F(x) = P(x \le X \le x+\Delta x)

2.3.2 常用连续型随机变量及其密度函数

分布定义式概率密度函数分布函数
均匀分布XU[a,b]X \sim U[a,b]p(x)={1ba,axb,0,其他p(x) = \begin{cases} \frac{1}{b-a}, & a \le x \le b, \\ 0, & \text{其他} \end{cases}F(x)={0,x<axaba,ax<b1,xbF(x) = \begin{cases} 0, & x < a \\ \frac{x - a}{b - a}, & a \le x < b \\ 1, & x \ge b \end{cases}
指数分布Xe(λ)X \sim e (\lambda)p(x)={0,x<0λeλx,x0p(x) = \begin{cases} 0, & x < 0 \\ \lambda e^{-\lambda x} , & x \ge 0 \end{cases}F(x)={0,x<01eλx,x0F(x) = \begin{cases} 0, & x < 0 \\ 1- e^{-\lambda x}, & x \ge 0 \end{cases}
正态分布XN(μ,σ2)X \sim N(\mu,\sigma^2)p(x)=12πσe(xμ)22σ2,<x<+p(x) = \frac{1}{\sqrt{2 \pi} \sigma } e^{- \frac{(x - \mu)^2}{2 \sigma ^2}} , \quad -\infty < x < + \inftyF(x)=12πσxe(yμ)22σ2dyF(x) = \frac{1}{\sqrt{2 \pi} \sigma } \int_{- \infty}^x e^{- \frac{(y - \mu)^2}{2 \sigma ^2}} dy

补充说明:

  • 指数分布:其中参数 λ>0\lambda >0

  • 正态分布:一般正态函数 F(x)F(x) 转化为标准正态函数 Φ(x)\Phi(x) 公式:

    F(x)=Φ(xμσ)F(x) = \Phi(\frac{x - \mu}{\sigma})

    于是对于计算一般正态函数的函数值,就可以通过下式将其转化为标准正态函数,最后查表即可:

P(Xx)=F(x)=Φ(xμσ)P(X \le x) = F(x) = \Phi (\frac{x - \mu}{\sigma})

2.4 随机变量函数的分布

本目主要介绍给定一个随机变量 XX 的分布情况,通过一个关系式 y=g(x)y=g(x) 来求解随机变量 YY​ 的分布情况

2.4.1 离散型随机变量函数的分布

通过关系式 y=g(x)y=g(x)​ 将所有的 YY 的取值全部枚举出来,然后一一统计即可。

2.4.2 连续型随机变量函数的分布

给定随机变量 XX 的概率密度函数 pX(x)p_X(x),以及关系式 y=g(x)y=g(x),求解随机变量 YY 的分布函数 FY(y)F_Y(y)、概率密度函数 pY(y)p_Y(y)

  • 方法一:先求解随机变量 YY 的分布函数 FY(y)F_Y(y),再通过对其求导得到概率密度函数 pY(y)p_Y(y)

    即先 FY(y)=PY(Yy)=PY(g(X)y)=PX(Xf(y))=FX(f(y))F_Y(y) = P_Y(Y \le y) = P_Y(g(X) \le y) = P_X(X \le f(y)) = F_X(f(y)) 得到 YY 的分布函数

    再对 FY(y)F_Y(y) 求导得 pY(y)=ddyFY(y)=ddyFX(f(y))=FX(f(y))f(y)=pX(f(y))f(y)\displaystyle p_Y(y) = \frac{d}{dy} F_Y(y) = \frac{d}{dy} F_X(f(y)) = F_X'(f(y)) \cdot f'(y) = p_X(f(y)) \cdot f'(y)

  • 方法二:如果关系式 y=g(x)y=g(x) 单调且反函数 x=h(y)x=h(y) 连续可导,则可以直接得出随机变量 YY 的概率密度函数 pY(y)p_Y(y) 为下式。其中 α\alphaβ\betaY=g(X)Y=g(X) 的取值范围(xx 应该怎么取值,h(y)h(y) 就应该怎么取值,从而计算出 yy 的取值范围)

    pY(y)={pX(h(y))h(y),α<y<β0,其他p_Y(y) = \begin{cases}p_X(h(y)) \cdot |h'(y)|, & \alpha < y < \beta \\0, & \text{其他}\end{cases}

第3章 随机向量及其分布

实际生活中,只采用一个随机变量描述事件往往是不够的。本章引入多维的随机变量概念,构成随机向量,从二维开始,推广到 nn​ 维。

3.1 二维随机向量的联合分布

现在我们讨论二维随机向量的联合分布。所谓的联合分布,其实就是一个曲面的概率密度(离散型就是点集),而分布函数就是对其积分得到的三维几何体的体积(散点和)而已。

3.1.1 联合分布函数

定义:我们定义满足下式的二元函数 F(x,y)F(x,y) 为二维随机向量 (X,Y)(X,Y) 的联合分布函数

F(x,y)=P((Xx)(Yy))=P(Xx,Yy)F(x,y) = P((X \le x) \cap (Y \le y)) = P(X \le x, Y \le y)

联合分布函数的几何意义

性质:其实配合几何意义理解就会很容易了

  1. 固定某一维度,另一维度是单调不减的
  2. 对于每个维度都是右连续的
  3. 固定某一维度,另一维度趋近于负无穷对应的函数值为 00
  4. 二维前缀和性质,右上角的矩阵面积 0\ge 0

3.1.2 联合分布列

定义:若二维随机向量 (X,Y)(X,Y) 的所有可能取值是至多可列的,则称 (X,Y)(X,Y) 为二维离散型随机向量

表示:有两种表示二维随机向量分布列的方法,如下

  1. 公式法

    pij=P(X=xi,Y=yi),i,j=1,2,p_{ij} = P(X=x_i,Y = y_i), \quad i,j=1,2,\cdots

  2. 表格法:

    二维联合分布列

性质:

  1. 非负性:pij0,i,j=1,2,p_{ij} \ge 0, \quad i,j=1,2,\cdots
  2. 正规性:ijpij=1\displaystyle \sum_{i} \sum_{j} p_{ij} = 1

3.1.3 联合密度函数

定义:

F(x,y)=xyp(u,v)dudvF(x,y) = \int_{-\infty}^x \int_{-\infty}^y p(u,v)dudv

性质:

  1. 非负性:x,yR,p(x,y)0\forall x,y \in R,p(x,y) \ge 0
  2. 正规性:++p(x,y)dxdy=1\displaystyle \int_{-\infty}^{+\infty} \int_{-\infty}^{+\infty} p(x,y)dxdy = 1

结论:

  1. 联合分布函数相比于一元分布函数,其实就是从概率密度函数与 xx 轴围成的面积转变为了概率密度曲面与 xOyxOy​ 平面围成的体积
  2. 若概率密度曲面在 xOyxOy 平面的投影为点集或线集,则对应的概率显然为零

常见的连续型二维分布:

  1. 二维均匀分布:假设该曲面与 xOyxOy 面的投影面积为 SS,则分布函数其实就是一个高为定值 1S\frac{1}{S} 的柱体,密度函数为:

    p(x,y)={1S,(x,y)G0,其他p(x,y) = \begin{cases}\frac{1}{S}, &(x,y) \in G \\0, &\text{其他}\end{cases}

  2. 二元正态分布:不要求掌握密度函数,可以感受一下密度函数的图像:

    二元正态分布 - 密度函数的图像

计算题:往往给出一个二元密度函数,然后让我们求解(1)密度函数中的参数、(2)分布函数、(3)联合事件某个区域下的概率

(1)我们利用二元密度函数的正规性,直接积分值为 11 即可

(2)划分区间后进行曲面积分即可,在曲面积分时往往结合 XX 型和 YY 型的二重积分进行

(3)画出概率密度曲面在 xOyxOy 面的投影,然后积分即可

3.2 二维随机向量的边缘分布

对于二元分布函数,我们也可以研究其中任意一个随机变量的分布情况,而不需要考虑另一个随机变量的取值情况。举一个实例就是,假如当前的随机向量是身高和体重,所谓的只研究其中一个随机变量,即边缘分布函数的情形就是,我们不考虑身高只考虑体重的分布情况;或者我们不考虑体重,只考虑身高的分布情况。接下来,我们将从边缘分布函数入手,逐渐学习离散型的分布列与连续型的分布函数。

3.2.1 边缘分布函数

我们称 FX(x),FY(yF_X(x),F_Y(y) 分别为 (X,Y)(X,Y) 关于 X,YX,Y 的边缘分布函数,定义式为:

FX(x)=P(Xx)=P(Xx,Y<+)=limy+F(x,y)=F(x,+)FY(y)=P(Yy)=P(X<+,Yy)=limx+F(x,y)=F(+,y)\begin{aligned}F_X(x) = P(X \le x) = P(X \le x,Y < +\infty) = \lim_{y \to +\infty} F(x,y) = F(x,+\infty) \\F_Y(y) = P(Y \le y) = P(X < +\infty, Y \le y) = \lim_{x \to +\infty} F(x,y) = F(+\infty,y)\end{aligned}

3.2.2 边缘分布列

所谓的边缘分布列,就是固定一个随机变量,另外的随机变量取遍,组成的分布列。即:

P(X=xi)=pi=j=1+pij,i=1,2,P(Y=yj)=pj=i=1+pij,j=1,2,\begin{aligned}P(X=x_i) = p_{i\cdot}=\sum_{j=1}^{+\infty} p_{ij}, \quad i=1,2,\cdots \\P(Y=y_j) = p_{\cdot j}=\sum_{i=1}^{+\infty} p_{ij}, \quad j=1,2,\cdots\end{aligned}

我们称:

  • P(X=xi)P(X=x_i) 为随机向量 (X,Y)(X,Y) 关于 XX 的边缘分布列

  • P(Y=yj)P(Y=y_j) 为随机向量 (X,Y)(X,Y) 关于 YY 的边缘分布列

3.2.3 边缘密度函数

所谓的的边缘密度函数,可以与边缘分布列进行类比,也就是固定一个随机变量,另外的随机变量取遍。只不过连续型的取遍就是无数个点,而离散型的取遍是可列个点,仅此而已。即:

P(X=x)=pX(x)=ddxFX(x)=ddxF(x,+)=ddxx[+p(u,v)dv]du=+p(x,y)dy\begin{aligned}P(X=x) &= p_X(x) \\&= \frac{d}{dx} F_X(x) \\&= \frac{d}{dx} F(x,+\infty) \\&= \frac{d}{dx} \int_{-\infty}^{x} \left [ \int_{-\infty}^{+\infty} p(u,v) dv \right ] du \\&= \int_{-\infty}^{+\infty} p(x,y) dy \\\end{aligned}

P(Y=y)=pY(y)=ddyFY(y)=ddyF(+,y)=ddy+[yp(u,v)dv]du=ddyy[+p(u,v)du]dv=+p(x,y)dx\begin{aligned}P(Y=y) &= p_Y(y) \\&= \frac{d}{dy} F_Y(y) \\&= \frac{d}{dy} F(+\infty,y) \\&= \frac{d}{dy} \int_{-\infty}^{+\infty} \left [ \int_{-\infty}^{y} p(u,v) dv \right ] du \\&= \frac{d}{dy} \int_{-\infty}^{y} \left [ \int_{-\infty}^{+\infty} p(u,v) du \right ] dv \\&= \int_{-\infty}^{+\infty} p(x,y) dx \\\end{aligned}

我们称:

  • P(X=x)P(X=x) 为随机向量 (X,Y)(X,Y) 关于 XX 的边缘密度函数

  • P(Y=y)P(Y=y) 为随机向量 (X,Y)(X,Y) 关于 YY 的边缘密度函数

3.3 随机向量的条件分布

本目主要介绍的是条件分布。所谓的条件分布,其实就是在约束一个随机变量为定值的情况下,另外一个随机变量的取值情况。与上述联合分布、边缘分布的区别在于:

  • 联合分布、边缘分布的分布函数是一个体积(散点和),概率密度(分布列)是一个曲面(点集)
  • 条件分布的分布函数是一个面积(散点和),概率密度(分布列)是一个曲线(点集)

3.3.1 离散型随机向量的条件分布列和条件分布函数

条件分布列,即散点情况:

pij=P(X=xi  Y=yj)=P(X=xi,Y=yi)P(Y=yi)=pijpj,i=1,2,pji=P(Y=yj  X=xi)=P(X=xi,Y=yi)P(X=xi)=pijpi,j=1,2,\begin{aligned}p_{i|j} = P(X=x_i\ |\ Y=y_j) = \frac{P(X=x_i,Y=y_i)}{P(Y=y_i)} = \frac{p_{ij}}{p_{\cdot j}}, \quad i=1,2,\cdots \\p_{j|i} = P(Y=y_j\ |\ X=x_i) = \frac{P(X=x_i,Y=y_i)}{P(X=x_i)} = \frac{p_{ij}}{p_{i\cdot }}, \quad j=1,2,\cdots\end{aligned}

我们称:

  • pijp_{i|j} 为在给定 Y=yjY=y_j 的条件下 XX 的条件分布列

  • pjip_{j|i} 为在给定 X=xiX=x_i 的条件下 YY 的条件分布列

条件分布函数,即点集情况:

F(xyj)=P(Xx  Y=yj)=xixpijpjF(yxi)=P(Yy  X=xi)=yjypijpi\begin{aligned}F(x|y_j) = P(X \le x\ | \ Y=y_j) = \sum _{x_i\le x} \frac{p_{ij}}{p_{\cdot j}} \\F(y|x_i) = P(Y \le y\ | \ X=x_i) = \sum _{y_j\le y} \frac{p_{ij}}{p_{i \cdot}}\end{aligned}

我们称:

  • F(xyj)F(x|y_j) 为在给定 Y=yjY=y_j 的条件下 XX 的条件分布函数
  • F(yxi)F(y|x_i) 为在给定 X=xiX=x_i 的条件下 YY 的条件分布函数

3.3.2 连续型随机向量的条件密度函数和条件分布函数

条件密度函数,即联合分布的概率密度曲面上,约束了某一维度的随机变量为定值,于是条件密度函数的图像就是一个空间曲线:

p(xy)=p(x,y)pY(y),<x<+p(yx)=p(x,y)pX(x),<y<+\begin{aligned}p(x|y) = \frac{p(x,y)}{p_Y(y)}, \quad -\infty < x < +\infty \\p(y|x) = \frac{p(x,y)}{p_X(x)}, \quad -\infty < y < +\infty\end{aligned}

我们称:

  • p(xy)p(x|y) 为在给定 Y=yY=y 的条件下 XX 的条件密度函数
  • p(yx)p(y|x) 为在给定 X=xX=x 的条件下 YY 的条件密度函数

条件分布函数,即上述曲线的分段积分结果:

F(xy)=P(Xx  Y=y)=xp(u,y)pY(y)du,<x<+F(yx)=P(Yy  X=x)=yp(x,v)pX(x)dv,<y<+\begin{aligned}F(x|y) = P(X \le x \ | \ Y=y) = \int_{-\infty}^x \frac{p(u,y)}{p_Y(y)} du,\quad -\infty < x < +\infty \\F(y|x) = P(Y \le y \ | \ X=x) = \int_{-\infty}^y \frac{p(x,v)}{p_X(x)} dv, \quad -\infty < y < +\infty\end{aligned}

我们称:

  • F(xy)F(x|y) 为在给定 Y=yY=y 的条件下 XX 的条件分布函数
  • F(yx)F(y|x) 为在给定 X=xX=x 的条件下 YY 的条件分布函数

3.4 随机变量的独立性

本目主要介绍随机变量的独立性。我们知道随机事件之间是有独立性的,即满足 P(AB)=P(A)P(B)P(AB)=P(A)P(B) 的事件,那么随机变量之间也有独立性吗?答案是有的,以生活中的例子为实例,比如我和某个同学进教室,就是独立的两个随机变量。下面开始介绍。

  • 定义:我们定义如果两个随机变量的分布函数满足下式,则两个随机变量相互独立:

    F(x,y)=FX(x)FY(y)F(x,y)=F_X(x)F_Y(y)

  • 性质:对于随机向量 (X,Y)(X,Y)

    1. 随机变量 XXYY 相互独立的充分必要条件是:

      离散型:P(X=xi,Y=yj)=P(X=xi)P(Y=yj)连续型:p(x,y)=pX(x)pY(y)\begin{aligned}\text{离散型:}& P(X=x_i,Y=y_j) = P(X=x_i)P(Y=y_j) \\\text{连续型:}& p(x,y) = p_X(x)p_Y(y)\end{aligned}

    2. 若随机变量 XXYY 相互独立,且 h()h(\cdot)g()g(\cdot) 连续,则 h(X),g(Y)h(X),g(Y) 也相互独立

3.5 随机向量函数的分布

在 2.4 目中我们了解到了随机变量函数的分布,现在我们讨论随机向量函数的分布。在生活中,假设我们已经知道了一个人群中所有人的身高和体重的分布情况,现在想要血糖根据身高和体重的分布情况,就需要用到本目的理念。我们从离散型和连续型随机向量 (X,Y)(X,Y) 出发,讨论 g(X,Y)g(X,Y) 的分布情况。

3.5.1 离散型随机向量函数的分布

按照规则枚举即可。

3.5.2 连续型随机向量函数的分布

与连续型随机变量函数的分布类似,这类题目一般也是:给定随机向量 (X,Y)(X,Y) 的密度函数 p(x,y)p(x,y) 和 映射函数 g(x,y)g(x,y),现在需要求解 Z=g(X,Y)Z=g(X,Y) 的分布函数(若 g(x,y)g(x,y) 二元连续,则 ZZ 也是连续型随机变量)。方法同理,先求解 ZZ 的分布函数,再对 zz 求导得到密度函数 pZ(z)p_Z(z)​。接下来我们介绍两种常见随机向量的分布。

(1) 和的分布:

  • 先求分布函数 FZ(z)F_Z(z)

    FZ(z)=P(X+Yz)=x+yzp(x,y)dxdy=z[+p(x,tx)dx]dt=z[+p(ty,y)dy]dt\begin{aligned}F_Z(z) &= P(X+Y \le z) \\&= \iint\limits_{x+y \le z} p(x,y) dxdy \\&\begin{align}&= \int _{-\infty}^z \left [ \int_{-\infty}^{+\infty} p(x,t-x)dx \right ] dt \\&= \int _{-\infty}^z \left [ \int_{-\infty}^{+\infty} p(t-y,y)dy \right ] dt\end{align}\end{aligned}

  • 由分布函数定义:

    FX(x)=xp(u)duF_X(x) = \int_{-\infty}^xp(u)du

  • 所以可得 Z=X+YZ=X+Y 的密度函数 pZ(z)p_Z(z) 为:

    pZ(z)=+p(x,zx)dx(1)pZ(z)=+p(zy,y)dy(2)\begin{aligned}p_Z(z) = \int_{-\infty}^{+\infty} p(x,z-x)dx \quad &(1) \\p_Z(z) = \int_{-\infty}^{+\infty} p(z-y,y)dy \quad &(2) \\\end{aligned}

  • 若 X 和 Y 相互独立,还可得卷积式:

    pZ(z)=+p(x,zx)dx=+pX(x)pY(zx)dx(1)pZ(z)=+p(zy,y)dy=+pX(zy)pY(y)dy(2)\begin{aligned}p_Z(z) &= \int_{-\infty}^{+\infty} p(x,z-x)dx \\&= \int_{-\infty}^{+\infty} p_X(x)\cdot p_Y(z-x) dx \quad &(1) \\p_Z(z) &= \int_{-\infty}^{+\infty} p(z-y,y)dy \\&= \int_{-\infty}^{+\infty} p_X(z-y)\cdot p_Y(y) dy \quad &(2)\end{aligned}

(2) 次序统计量的分布(对于两个相互独立的随机变量 X 和 Y):

  • 对于 M=max(X,Y)M=\max{(X,Y)} 的分布函数,有:

    FM(z)=P(Mz)=P(max(X,Y)z)=P(Xz,Yz)=P(Xz)P(Yz)=FX(z)FY(z)\begin{aligned}F_M(z) &= P(M \le z) \\&= P(\max{(X,Y)} \le z) \\&= P(X \le z, Y \le z) \\&= P(X \le z) \cdot P(Y \le z) \\&= F_X(z) \cdot F_Y(z)\end{aligned}

  • 对于 N=min(X,Y)N=\min{(X,Y)} 的分布函数,有:

    FN(z)=P(Nz)=P(min(X,Y)z)=1P(min(X+Y)z)=1P(Xz,Yz)=1P(Xz)P(Yz)=1[1FX(z)][1FY(z)]\begin{aligned}F_N(z) &= P(N \le z) \\&= P(\min{(X,Y)} \le z) \\&= 1 - P(\min{(X+Y)} \ge z) \\&= 1 - P(X \ge z,Y \ge z) \\&= 1 - P(X \ge z) \cdot P(Y \ge z) \\&= 1 - [1 - F_X(z)] \cdot [1 - F_Y(z)]\end{aligned}

  • 若拓展到 nn 个相互独立且同分布的随机变量,则有:

    FM(z)=[F(z)]npM(z)=np(z)[F(z)]n1\begin{aligned}F_M(z) &= [F(z)]^n \\p_M(z) &= np(z)[F(z)]^{n-1}\end{aligned}

    FN(z)=1[1F(z)]npN(z)=np(z)[1F(z)]n1\begin{aligned}F_N(z) &= 1 - [1-F(z)]^n \\p_N(z) &= np(z)[1-F(z)]^{n-1}\end{aligned}

第4章 随机变量的数字特征

本章我们将学习随机变量的一些数字特征。所谓的数字特征其实就是随机变量分布的一些内在属性,比如均值、方差、协方差等等,有些分布特性甚至可以通过某个数字特征而直接觉得。其中期望方差往往用来衡量单个随机变量的特征,而协方差相关系数则是用来衡量随机变量之间的数字特征。接下来开始介绍。

4.1 数学期望

加权平均概念的严格数学定义。

4.1.1 随机变量的数学期望

  • 离散型

    EX=i=1xipiEX = \sum_{i=1}^{\infty} x_i p_i

  • 连续型

    EX=+xp(x)dx\begin{aligned}&EX = \int_{-\infty}^{+\infty} xp(x)dx\end{aligned}

4.1.2 随机变量函数的数学期望

  • 离散型

    • 一元

      Eg(X)=i=1g(xi)piEg(X) = \sum_{i=1}^{\infty}g(x_i)p_i

    • 二元

      Eg(X,Y)=i=1j=1g(xi,yi)pijEg(X,Y) = \sum_{i=1}^{\infty}\sum_{j=1}^{\infty}g(x_i,y_i)p_{ij}

  • 连续型

    • 一元

      Eg(X)=+g(x)p(x)dxEg(X) = \int_{-\infty}^{+\infty}g(x)p(x)dx

    • 二元

      Eg(X,Y)=++g(xi,yi)p(x,y)dxdyEg(X,Y) = \int_{-\infty}^{+\infty}\int_{-\infty}^{+\infty}g(x_i,y_i)p(x,y)dxdy

4.1.3 数学期望的性质

  1. EC=CEC=C
  2. E(CX)=CEXE(CX)=CEX
  3. E(X+Y)=EX+EYE(X+Y)=EX+EY
  4. XXYY 相互独立,则 E(XY)=EXEYE(XY)=EXEY

4.2 方差

随机变量的取值与均值之间的离散程度

4.2.1 方差的定义

我们定义随机变量 XX 的方差 D(X)D(X) 为:(全部可由期望的性质推导而来)

D(X)=E[(XEX)2]=E(X2)(EX)2\begin{aligned}D(X) &= E\left[(X-EX)^2\right ] \\&= E\left ( X^2 \right ) - (EX)^2\end{aligned}

4.2.2 方差的性质

下列方差的性质全部可由上述方差的定义式,结合期望的性质推导而来:

  1. D(aX+b)=a2D(X)D(aX+b) = a^2D(X)

  2. X1,X2,X_1,X_2,\cdots 相互独立,则 D(aX1±bX2±)=a2D(X1)+b2D(X2)+D(aX_1 \pm bX_2 \pm \cdots) = a^2D(X_1) + b^2D(X_2) + \cdots

  3. E[(XEX)2]E[(XC)2]E\left[ (X-EX)^2 \right] \le E \left [ (X-C)^2 \right ]

  4. 切比雪夫不等式 (本以为不要求掌握的,但是被小测拷打了,补一下)

    ϵ>0,P(XEX<ϵ)1DXϵ2\forall \epsilon >0, P(|X - EX| < \epsilon) \ge 1 - \frac{DX}{\epsilon^2}

4.3 结论与推导(补)

类型分布符号期望 E(X)E(X)方差 D(X)D(X)
离散型0-1 分布X(011pp)X \sim \begin{pmatrix} 0 & 1 \\ 1-p & p \end{pmatrix}ppp(1p)p(1-p)
*二项分布XB(n,p)X \sim B(n,p)npnpnp(1p)np(1-p)
几何分布XG(p)X \sim G(p)1p\displaystyle \frac{1}{p}1pp2\displaystyle \frac{1-p}{p^2}
*泊松分布XP(λ)X \sim P(\lambda)λ\lambdaλ\lambda
连续型均匀分布XU[a,b]X \sim U[a,b]a+b2\displaystyle \frac{a+b}{2}(ba)212\displaystyle \frac{(b-a)^2}{12}
指数分布Xe(λ)X \sim e(\lambda)1λ\displaystyle \frac{1}{\lambda}1λ2\displaystyle \frac{1}{\lambda^2}
*正态分布XN(μ,σ2)X \sim N(\mu,\sigma^2)μ\muσ2\sigma^2

注:打星号表示在两个随机变量 X,YX,Y 相互独立时,具备可加性。具体的:

  1. XN(μ1,σ12),YN(μ2,σ22)X±YN(μ1±μ2,σ12+σ22)X \sim N(\mu_1,\sigma_1^2), Y \sim N(\mu_2,\sigma_2^2) \to X\pm Y\sim N(\mu_1\pm\mu_2,\sigma_1^2+\sigma_2^2)
  2. XB(n1,p),YB(n2,p)X+YB(n1+n2,p)X \sim B(n_1,p), Y \sim B(n_2,p) \to X+Y\sim B(n_1+n_2,p)
  3. XP(λ1),YP(λ2)X+YP(λ1+λ2)X \sim P(\lambda_1),Y\sim P(\lambda_2) \to X+Y \sim P(\lambda_1+\lambda_2)

推导的根本方式还是从定义出发。当然为了省事也可以从性质出发。

0-1 分布

0-1 分布

二项分布

二项分布

几何分布

几何分布

泊松分布

泊松分布

均匀分布

均匀分布

指数分布

指数分布

4.4 协方差与相关系数

4.4.1 协方差

定义:随机变量 X 与 Y 的协方差 Cov(X,Y)Cov(X,Y) 为:

Cov(X,Y)=E[(XEX)(YEY)]=E(XY)EXEY\begin{aligned}Cov(X,Y)&= E[(X-EX)(Y-EY)] \\&= E(XY) - EXEY\end{aligned}

特别的:

Cov(X,X)=DXCov(X,X) = DX

性质:

  1. 交换律:Cov(X,Y)=Cov(Y,X)Cov(X,Y)=Cov(Y,X)
  2. 提取率:Cov(aX,bY)=abCov(X,Y)Cov(aX,bY)=abCov(X,Y)
  3. 分配率:Cov(X1+X2,Y)=Cov(X1,Y)+Cov(X2,Y)Cov(X_1+X_2,Y) = Cov(X_1,Y)+Cov(X_2,Y)
  4. 独立性:若 X 与 Y 相互独立,则 Cov(X,Y)=0Cov(X,Y)=0;反之不一定成立
  5. 放缩性:[Cov(X,Y)]2DXDY\left[Cov(X,Y)\right]^2 \le DX \cdot DY

4.4.2 相关系数

定义:相关系数 ρ\rho 是用来刻画两个随机变量之间线性相关关系强弱的一个数字特征,注意是线性关系。ρ|\rho| 越接近 0,则说明两个随机变量越不线性相关;ρ|\rho| 越接近 1,则说明两个随机变量越线性相关,定义式为

ρX,Y=Cov(X,Y)DXDY\rho_{X,Y} = \frac{Cov(X,Y)}{\sqrt{DX}\sqrt{DY}}

特别的:

  1. 0<ρ<10 < \rho < 1,则称 X 与 Y 正相关
  2. 1<ρ<0-1<\rho<0,则称 X 与 Y 负相关

性质:

  1. 放缩性(由协方差性质5可得):ρ1|\rho| \le 1
  2. 独立性(由协方差性质4可得):若 X 与 Y 相互独立,则 p=0p=0;反之不一定成立
  3. 线性相关性(不予证明):ρ=1|\rho|=1 的充分必要条件是存在常数 a(a0),ba(a\ne0),b 使得 P(Y=aX+b)=1P(Y=aX+b)=1

4.4.3 独立性与线性相关性(补)

一般的:对于两个随机变量 XXYY

  • XXYY 相互独立 \rightarrow XXYY 线性无关(可以用线性相关的定义式结合协方差计算公式导出)
  • XXYY 相互独立 \nleftarrow XXYY 线性无关(因为有可能出现 XXYY 非线性相关)

特别的:对于满足二维正态分布的随机变量 XXYY,即 (X,Y)(μ1,μ2,σ12,σ22,ρ)(X,Y) \sim (\mu_1,\mu_2,\sigma_1^2,\sigma_2^2,\rho)

  • XXYY 相互独立 \rightarrow XXYY 线性无关
  • XXYY 相互独立 \leftarrow XXYY​ 线性无关

第5章 大数定律与中心极限定理

本章只需要知道一个独立同分布中心极限定理即可,至于棣莫弗-拉普拉斯中心极限定理其实就是前者的 {Xi}i=1\{X_i\}_{i=1}^{\infty} 服从伯努利 nn 重分布罢了。

独立同分布中心极限定理

定义:{Xi}i=1\{X_i\}_{i=1}^{\infty} 独立同分布且非零方差,其中 EXi=μ,DXi=σ2EX_i=\mu,DX_i=\sigma^2,则有:

i=1nXiN(i=1n(EXi),i=1n(DXi))N(nμ,nσ2)\begin{aligned}\sum_{i=1}^n X_i &\sim N(\sum_{i=1}^n(EX_i),\sum_{i=1}^n(DX_i)) \\&\sim N(n\mu,n\sigma^2)\end{aligned}

解释:其实就是对于独立同分布的随机事件 XiX_i,在事件数 nn 足够大时,就近似为正态分布(术语叫做依分布)。这样就可以很方便利用正态分布的性质计算当前事件的概率。至于棣莫弗-拉普拉斯中心极限定理就是上述 μ=p,σ2=p(1p)\mu=p,\sigma^2=p(1-p) 的特殊情况罢了

第6章 数理统计的基本概念

开始统计学之旅。

6.1 总体与样本

类比 ML:数据集=总体,样本=样本。

我们只研究一种样本:简单随机样本 (X1,X2,...,Xn)(X_1,X_2,...,X_n)。符合下列两种特点:

  1. (X1,X2,...,Xn)(X_1,X_2,...,X_n) 相互独立
  2. (X1,X2,...,Xn)(X_1,X_2,...,X_n) 同分布

同样的,我们研究总体 XX 的分布与概率密度,一般概率密度会直接给,需要我们在此基础之上研究所有样本的联合密度:

  • 分布:由于样本相互独立,故:

    F(x1,x2,...,xn)=F(x1)F(x2)F(xn)F(x_1,x_2,...,x_n)=F(x_1)F(x_2) \cdots F(x_n)

  • 联合密度:同样由于样本相互独立,故:

    p(x1,x2,...,xn)=p(x1)p(x2)p(xn)p(x_1,x_2,...,x_n)=p(x_1)p(x_2) \cdots p(x_n)

6.2 经验分布与频率直方图

经验分布函数是利用样本得到的。也是给区间然后统计样本频度进而计算频率,只不过区间长度不是固定的。

频率直方图就是选定固定的区间长度,然后统计频度进而计算频率作图。

6.3 统计量

统计量定义:关于样本不含未知数的表达式。

常见统计量:假设 (X1,X2,...,Xn)(X_1,X_2,...,X_n) 为来自总体 XX 的简单随机样本

一、样本均值和样本方差

  • 样本均值:X=1ni=1nXi\displaystyle \overline{X} = \frac{1}{n} \sum_{i=1}^n X_i

  • 样本方差:S02=1ni=1n(XiX)2=1ni=1nXi2X2\displaystyle S_0^2 = \frac{1}{n} \sum_{i=1}^n (X_i - \overline{X})^2 = \frac{1}{n}\sum_{i=1}^n X_i^2 - \overline{X}^2

  • 样本标准差:S0=S02\displaystyle S_0 = \sqrt{S_0^2}

  • 修正样本方差:S2=1n1i=1n(XiX)2\displaystyle S^2 = \frac{1}{n-1} \sum_{i=1}^n (X_i - \overline{X})^2

  • 修正样本标准差:S=S2\displaystyle S = \sqrt{S^2}

    设总体 XX 的数学期望和方差分别为 μ\muσ2\sigma^2(X1,X2,...,Xn)(X_1,X_2,...,X_n) 是简单随机样本,则:

    样本均值的数学期望与总体的数学期望相等

    即:样本均值的数学期望 == 总体的数学期望

    样本方差的数学期望与总体的数学期望 不相等

    即:样本方差的数学期望 \ne 总体的数学期望

    修正样本方差推导

    上图即:修正样本方差推导

  • 样本 kk 阶原点矩:Ak=1ni=1nXik,k=1,2,\displaystyle A_k = \frac{1}{n} \sum_{i=1}^n X_i^k,\quad k=1,2,\cdots

  • 样本 kk 阶中心矩:Bk=1ni=1n(XiX)k,k=2,3,\displaystyle B_k = \frac{1}{n} \sum_{i=1}^n (X_i-\overline{X})^k,\quad k=2,3,\cdots

二、次序统计量

  • 序列最小值
  • 序列最大值
  • 极差 = 序列最大值 - 序列最小值

6.4 正态总体抽样分布定理

时刻牢记一句话:构造性定义!

6.4.1 卡方分布、t 分布、F 分布

分位数

  • 我们定义实数 λα\lambda_\alpha 为随机变量 XX 的上侧 α\alpha 分位数(点)当且仅当 P(X>λα)=αP(X > \lambda_\alpha) = \alpha
  • 我们定义实数 λ1β\lambda_{1-\beta} 为随机变量 XX 的下侧 β\beta 分位数(点)当且仅当 P(X<λ1β)=βP(X < \lambda_{1-\beta})=\beta

χ2\chi^2 分布

密度函数图像

定义:

  • 对于 nn 个独立同分布的标准正态随机变量 X1,X2,,XnX_1,X_2,\cdots ,X_n,若 Y=X12+X22++Xn2Y = X_1^2 + X_2^2 + \cdots + X_n^2
  • YY 服从自由度为 nnχ2\chi^2 分布,记作:Yχ2(n)Y \sim \chi^2(n)

性质:

  • 可加性:若 Y1χ2(n1),Y2χ2(n2)Y_1 \sim \chi^2(n_1), Y_2 \sim \chi^2(n_2)Y1,Y2Y_1,Y_2 相互独立,则 Y1+Y2χ2(n1+n2)Y_1+Y_2 \sim \chi^2(n_1+n_2)

  • 统计性:对于 Yχ2(n)Y \sim \chi^2(n),有 EY=n,DY=2nEY = n, DY = 2n

    EY 的推导利用:EX2=DX(EX)2EX^2 = DX - (EX)^2

    EY 的推导

    DY 的推导利用:方差计算公式、随机变量函数的数学期望进行计算

    DY 的推导

tt 分布

密度函数图像

定义:

  • 若随机变量 XN(0,1),Yχ2(n)X \sim N(0, 1),Y \sim \chi^2 (n)X,YX,Y 相互独立
  • 则称随机变量 T=XY/nT = \displaystyle \frac{X}{\sqrt{Y/n}} 为服从自由度为 nntt 分布,记作 Tt(n)T \sim t(n)

性质:

  • 密度函数是偶函数,具备对称性

FF 分布

密度函数图像

定义:

  • 若随机变量 Xχ2(m),Yχ2(n)X \sim \chi^2(m), Y \sim \chi^2(n) 且相互独立
  • 则称随机变量 G=X/mY/nG=\displaystyle \frac{X/m}{Y/n} 服从自由度为 (m,n)(m,n)FF 分布,记作 GF(m,n)G \sim F(m, n)

性质:

  • 倒数自由度转换:1GF(n,m)\displaystyle \frac{1}{G} \sim F(n, m)
  • 三变性质F1α(m,n)=[Fα(n,m)]1\displaystyle F_{1-\alpha}(m, n) = \left [F_\alpha (n, m)\right]^{-1}

6.4.2 正态总体抽样分布基本定理

X1,X2,,XnX_1,X_2,\cdots ,X_n 是来自正态总体 N(μ,σ2)N(\mu, \sigma^2) 的简单随机样本,X,S2\overline{X},S^2 分别是样本均值和修正样本方差。则有:

定理:

  • XN(μ,σ2n)\displaystyle \overline{X} \sim N(\mu, \frac{\sigma^2}{n})
  • (n1)S2σ2χ2(n1)\displaystyle \frac{(n-1)S^2}{\sigma^2} \sim \chi^2(n-1)
  • X\overline{X}S2S^2 相互独立

推论:

  • n(Xμ)St(n1)\displaystyle \frac{\sqrt{n}(\overline{X} - \mu)}{S} \sim t(n-1)

第7章 参数估计

有些时候我们知道数据的分布类型,但是不清楚表达式中的某些参数,这就需要我们利用「已有的样本」对分布表达式中的参数进行估计。本章我们将从点估计、估计评价、区间估计三部分出发进行介绍。

7.1 点估计

所谓点估计策略,就是直接给出参数的一个估计值。本目我们介绍点估计策略中的两个方法:矩估计法、极大似然估计法。

7.1.1 矩估计法

其实就一句话:我们用样本的原点矩 AkA_k 来代替总体 E(Xk)E(X^k)kk 个未知参数就需要用到 kk 个原点矩:

E(Xk)=Ak=1ni=1nXikE(X^k) = A_k = \frac{1}{n}\sum_{i=1}^nX_i^k

7.1.2 极大似然估计法

基本原理是:在当前样本数据的局面下,我们希望找到合适的参数使得当前的样本分布情况发生的概率最大。由于各样本相互独立,因此我们可以用连乘的概率公式来计算当前局面的概率值:

L(θ;x1,x2,,xn)L(\theta;x_1,x_2,\cdots,x_n)

上述 L(θ;x1,x2,,xn)L(\theta;x_1,x_2,\cdots,x_n) 即似然函数,目标就是选择适当的参数 θ\theta 来最大化似然函数。无论是离散性还是连续型,都可以采用下面的方式来计算极大似然估计:

  1. 写出似然函数 L(θ)L(\theta)
  2. 将上述似然函数取对数
  3. 求对数似然函数关于所有未知参数的偏导并计算极值点
  4. 解出参数关于样本统计量的表达式

离散型随机变量的似然函数表达式

L(θ)=i=1np(xi;θ)=i=1nP(Xi=xi)L(\theta) = \prod_{i=1}^n p(x_i;\theta) = \prod_{i=1}^n P(X_i = x_i)

连续型随机变量的似然函数表达式

L(θ)=i=1np(xi;θ)L(\theta) = \prod_{i=1}^n p(x_i;\theta)

可以看出极大似然估计本质上就是一个多元函数求极值的问题。特别地,当我们没法得到参数关于样本统计量的表达式 L(θ)L(\theta) 时,可以直接从定义域、原函数恒增或恒减等角度出发求解这个多元函数的极值。

7.2 估计量的评价标准

如何衡量不同的点估计方法好坏?我们引入三种点估计量的评价指标:无偏性、有效性、一致性。其中一致性一笔带过,不做详细讨论。补充一点,参数的估计量 θ\theta 是关于样本的统计量,因此可以对其进行求期望、方差等操作。

7.2.1 无偏性

顾名思义,就是希望估计出来的参数量尽可能不偏离真实值。我们定义满足下式的估计量 θ^\hat \theta 为真实参数的无偏估计:

Eθ^=θE\hat \theta =\theta

7.2.2 有效性

有效性是基于比较的定义方法。对于两个无偏估计 θ^1,θ^2\hat\theta_1,\hat\theta_2,谁的方差越小谁就越有效。即若 D(θ^1),D(θ^2)D(\hat\theta_1),D(\hat\theta_2) 满足下式,则称 θ^1\hat\theta_1 更有效

D(θ^1)<D(θ^2)D(\hat\theta_1) < D(\hat\theta_2)

7.2.3 一致性

即当样本容量 n 趋近于无穷时,参数的估计值也能趋近于真实值,则称该估计量 θ^\hat\thetaθ\theta 的一致估计量

7.3 区间估计

由于点估计只能进行比较,无法对单一估计进行性能度量。因此引入「主元法」的概念与「区间估计」策略

7.3.1 基本概念

可靠程度:参数估计区间越长,可靠程度越高

精确程度:参数估计区间越短,可靠程度越高

7.3.2 区间估计常用方法之主元法

主元法的核心逻辑就一个:在已知数据总体分布的情况下,构造一个关于样本 XX 和待估参数 θ\theta 的函数 Z(X,θ)Z(X,\theta),然后利用置信度和总体分布函数,通过查表得到 Z(X,θ)Z(X,\theta) 的取值范围,最后通过移项变形得到待估参数的区间,也就是估计区间。

7.3.3 正态总体的区间估计

我们只需要掌握「一个总体服从正态分布」的情况。这种情况下的区间估计分为三种,其中估计均值 μ\mu 有 2 种,估计方差 σ2\sigma^2 有 1 种。估计的逻辑我总结为了以下三步:

  1. 构造主元 Z(X,θ)Z(X,\theta)
  2. 利用置信度 1α1-\alpha 计算主元 ZZ 的取值范围
  3. 对主元 ZZ 的取值范围移项得到参数 θ\theta 的取值范围

为了提升区间估计的可信度,我们希望上述第 2 步计算出来的关于主元的取值范围尽可能准确。我们不加证明的给出以下结论:取主元的取值范围为 主元服从的分布的上下 α2\frac{\alpha}{2} 分位数之间

(一) 求 μ\mu 的置信区间,σ2\sigma^2 已知

构造主元 Z(X,θ)Z(X,\theta)

Z=Xμσ/nN(0,1)Z = \frac{\overline{X} - \mu}{\sigma / \sqrt{n}} \sim N(0,1)

利用置信度 1α1-\alpha 计算主元 ZZ 的取值范围:

P(Zλ)=1αZ[λ,λ]=[uα2,uα2]\begin{aligned}P(|Z| \le \lambda) &= 1-\alpha \\&\downarrow\\Z \in [-\lambda,\lambda] &= [-u_{\frac{\alpha}{2}},u_\frac{\alpha}{2}]\end{aligned}

对主元 ZZ 的取值范围移项得到参数 θ\theta 的取值范围:

Xσnuα2μX+σnuα2\overline{X} - \frac{\sigma}{\sqrt{n}} u_\frac{\alpha}{2} \le \mu \le \overline{X} + \frac{\sigma}{\sqrt{n}} u_\frac{\alpha}{2}

(二) 求 μ\mu 的置信区间,σ2\sigma^2 未知

构造主元 Z(X,θ)Z(X,\theta)

Z=XμS/nt(n1)Z = \frac{\overline{X} - \mu}{S / \sqrt{n}} \sim t(n-1)

利用置信度 1α1-\alpha 计算主元 ZZ 的取值范围:

P(Zλ)=1αZ[λ,λ]=[tα2(n1),tα2(n1)]\begin{aligned}P(|Z| \le \lambda) &= 1-\alpha \\&\downarrow\\Z \in [-\lambda,\lambda] &= [-t_{\frac{\alpha}{2}}(n-1),t_\frac{\alpha}{2}(n-1)]\end{aligned}

对主元 ZZ 的取值范围移项得到参数 θ\theta 的取值范围:

XSntα2(n1)μX+Sntα2(n1)\overline{X} - \frac{S}{\sqrt{n}} t_\frac{\alpha}{2}(n-1) \le \mu \le \overline{X} + \frac{S}{\sqrt{n}} t_\frac{\alpha}{2}(n-1)

(三) 求 σ2\sigma^2 的置信区间,构造的主元与总体均值无关,因此不需要考虑 μ\mu 的情况:

构造主元 Z(X,θ)Z(X,\theta)

Z=(n1)S2σ2χ2(n1)Z = \frac{(n-1)S^2}{\sigma^2}\sim \chi^2(n-1)

利用置信度 1α1-\alpha 计算主元 ZZ 的取值范围:

P(λ1Zλ2)=1αZ[λ1,λ2]=[χ1α22(n1),χα22(n1)]\begin{aligned}P(\lambda_1 \le Z \le \lambda_2) &= 1-\alpha \\&\downarrow\\Z \in [\lambda_1,\lambda_2] &= [\chi^2_{1-\frac{\alpha}{2}}(n-1),\chi^2_\frac{\alpha}{2}(n-1)]\end{aligned}

对主元 ZZ 的取值范围移项得到参数 θ\theta 的取值范围:

(n1)S2χα22(n1)σ2(n1)S2χ1α22(n1)\frac{(n-1)S^2}{\chi^2_\frac{\alpha}{2}(n-1)} \le \sigma^2 \le \frac{(n-1)S^2}{\chi^2_{1-\frac{\alpha}{2}}(n-1)}

第8章 假设检验

第 7 章的参数估计是在总体分布已知且未知分布表达式中某些参数的情况下,基于「抽取的少量样本」进行的参数估计。

现在的局面同样,我们已知总体分布和不完整的分布表达式参数。现在需要我们利用抽取的少量样本判断样本所在向量空间是否符合某种性质。本章的「假设检验」策略就是为了解决上述情况而诞生的。我们主要讨论单个正态总体的情况并针对均值和方差两个参数进行假设和检验:

  • 假设均值满足某种趋势,利用已知数据判断假设是否成立
  • 假设方差满足某种趋势,利用已知数据判断假设是否成立

8.1 假设检验的基本概念

基本思想:首先做出假设并构造一个关于样本观察值和已知参数的检验统计量,接着计算假设发生的情况下小概率事件发生时该检验统计量的取值范围(拒绝域),最终代入已知样本数据判断计算结果是否在拒绝域内。如果在,则说明在当前假设的情况下小概率事件发生了,对应的假设为假;反之说明假设为真。

为了量化「小概率事件发生」这个指标,我们引入显著性水平 α\alpha 这一概念。该参数为一个很小的正数,定义为「小概率事件发生」的概率上界。

基于数据的实验导致我们无法避免错误,因此我们定义以下两类错误:

  • 第一类错误:弃真错误。即假设正确,但由于数据采样不合理导致拒绝了真实的假设
  • 第二类错误:存伪错误。即假设错误,同样因为数据的不合理导致接受了错误的假设

8.2 单个正态总体均值的假设检验

X1,X2,,XnX_1,X_2,\cdots ,X_n 是来自正态总体 N(μ,σ2)N(\mu, \sigma^2) 的简单随机样本。后续进行假设判定计算统计量 ZZ 的真实值时,若总体均值 μ\mu 已知就直接代入,若未知题目也一定会给一个阈值,代这个阈值即可。

当总体方差 σ2\sigma^2 已知时,我们构造样本统计量 ZZ 为正态分布:

Z=Xμσ/nN(0,1)Z = \frac{\overline{X} - \mu}{\sigma / \sqrt{n}} \sim N(0,1)

  • 检验是否则求解双侧 α\alpha 分位数
  • 检验单边则求解单侧 α\alpha 分位数

当总体方差 σ2\sigma^2 未知时,我们构造样本统计量 ZZtt 分布:

Z=XμS/nt(n1)Z = \frac{\overline{X} - \mu}{S / \sqrt{n}} \sim t(n-1)

注:之所以这样构造是因为当总体 σ\sigma 未知时,上一个方法构造的主元已经不再是统计量,我们需要找到能够代替未知参数 σ\sigma 的变量,这里就采用其无偏估计「修正样本方差 S2S^2」来代替 σ2\sigma^2。也是说直接拿样本的修正方差来代替总体的方差了。

  • 检验是否则求解双侧 α\alpha 分位数
  • 检验单边则求解单侧 α\alpha 分位数

8.3 单个正态总体方差的假设检验

X1,X2,,XnX_1,X_2,\cdots ,X_n 是来自正态总体 N(μ,σ2)N(\mu, \sigma^2) 的简单随机样本。后续进行假设判定计算统计量 ZZ 的真实值时,若总体方差 σ2\sigma^2 已知就直接代入,若未知题目也一定会给一个阈值,代这个阈值即可。

我们直接构造样本统计量 ZZχ2\chi^2 分布:

Z=(n1)S2σ2χ2(n1)Z = \frac{(n-1)S^2}{\sigma^2} \sim \chi^2(n-1)

  • 检验是否则求解双侧 α\alpha 分位数
  • 检验单边则求解单侧 α\alpha 分位数
]]> + 算法设计与分析

前言

学科地位:

主讲教师学分配额学科类别
段博佳3自发课

成绩组成:

平时(5次作业+签到)期末(书本+讲义)
60%40%

教材情况:

课程名称选用教材版次作者出版社ISBN号
算法设计与分析《算法设计与分析(python版)》1王秋芬清华大学出版社978-7-302-57072-1

一、贪心

Dijkstra、Prim、Kruskal、Huffman

二、分治

二分、归并、快排(第 k 个数)

三、动规

  • 区间dp:矩阵连乘
  • 网格图dp:LCS
  • 背包:完全背包、多重背包

四、深搜

  • 排列树:TSP、图染色、调度、n 皇后
  • 子集树:01背包

考后碎碎念

考的中规中矩,但是忘带修正带导致卷面一坨hh。按照试卷顺序罗列一下:表达式树、大根堆、Dijkstra、图染色、第 k 个数、Kruskal、完全背包。

试卷一看就是 wq 出的,但还是用 python 写的代码。有一说一这次考试还加深了我对快排的理解,尤其是在让我给出分治后的序列时hh。硬要按照上面四个标签分类的话大概就是:

  • 贪心:Dijkstra、Kruskal
  • 分治:第 k 个数
  • 动规:完全背包
  • 深搜:表达式树、大根堆、图染色
]]>
@@ -1094,11 +1094,11 @@ - DataStructureClassDesign - - /GPA/3rd-term/DataStructureClassDesign/ + LinearAlgebra + + /GPA/3rd-term/LinearAlgebra/ - 数据结构课程设计

效果演示

sort 窗口程序

hash 窗口程序

[!note]

以下为课设报告内容

一、必做题

题目:编程实现希尔、快速、堆排序、归并排序算法。要求随机产生 10000 个数据存入磁盘文件,然后读入数据文件,分别采用不同的排序方法进行排序,并将结果存入文件中。

1.1 数据结构设计与算法思想

本程序涉及到四种排序算法,其中:

  • 希尔排序:通过倍增方法逐渐扩大选择插入数据量的规模,而达到减小数据比较的次数,时间复杂度优化到近似 O(n1.65)O(n^{1.65})
  • 快速排序:通过分治的算法策略,不断确定每一个数的最终位置,从而达到近似 O(nlogn)O(n \log n) 的时间复杂度
  • 堆排序:通过堆的树形结构,减小两数的比较次数,最终通过不断维护堆结构来获得有序序列,时间复杂度为 O(nlogn)O(n \log n)
  • 归并排序:通过分治排序回溯时左右两支有序的特点,进行归并,使算法整体的时间复杂度为固定的 O(nlogn)O(n \log n)

1.2 程序结构

为了更好的了解排序算法内部的机制,我统计了每一种排序算法内部的比较次数,并结合了 Qt 的可视化框架进行编写。将四种排序作为算法内核嵌入 GUI 界面,程序结构如下:

sort 程序结构图

UI

1
2
3
4
5
6
7
8
9
10
11
// 窗口 继承窗口库组件
<widget class="QWidget" name="SortWidget">
// 标题 标签组件
<widget class="QLabel" name="titleLabel"></widget>
// 输入 网格布局组件
<widget class="QGridLayout" name="inputGridLayout"></widget>
// 输出 网格布局组件
<widget class="QGridLayout" name="outputGridLayout"></widget>
// 交互 水平布局组件
<widget class="QHBoxLayout" name="buttonHLayout"></widget>
</widget>

Qt Application

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#ifndef DATASTRUCTURECLASSDESIGN_SORTWIDGET_H
#define DATASTRUCTURECLASSDESIGN_SORTWIDGET_H

#include <QWidget>


QT_BEGIN_NAMESPACE
namespace Ui {
class SortWidget;
}
QT_END_NAMESPACE

class SortWidget : public QWidget {
Q_OBJECT

private:
Ui::SortWidget* ui; // 窗口对象指针
QString path; // 文件存储路径

private slots:
void pushFolderButton(); // 槽函数 - 触发事件:获取存储路径的窗口对话
void pushCommitButton(); // 槽函数 - 触发事件:根据输入数据量执行算法
void pushCancelButton(); // 槽函数 - 触发事件:清空窗口所有标签的数据

public:
explicit SortWidget(QWidget* parent = nullptr); // 窗口构造函数

~SortWidget() override; // 窗口析构函数
};

Algorithm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>
#include <vector>
#include <fstream>

class sortAlgorithm {
private:
int Size, Range;
std::string Path;
std::vector<int> arr;

void Generate(int num, int range); // 数据生成

int ShellSort(std::vector<int> a); // 希尔排序
int QuickSort(std::vector<int> a); // 快速排序
int HeapSort(std::vector<int> a); // 堆排序
int MergeSort(std::vector<int> a); // 归并排序

void WriteToFile(std::string path, std::vector<int>& a); // 写入文件

public:
sortAlgorithm(int _Size, int _Range, std::string _Path); // 构造函数

int ShellSort(); // 用户调用希尔排序
int QuickSort(); // 用户调用快速排序
int HeapSort(); // 用户调用堆排序
int MergeSort(); // 用户调用归并排序
};

1.3 实验数据与测试结果分析

原始数据序列生成算法使用时间种子的除留余数法,在数据规模为 1000010000 的情况下,四种算法的比较次数如下:

shellheapquickmerge
112941923182439061120488
212805123220438723120487
313487323194539052120251
413571923209639158120510
512882123205138747120434
average13137623202438948120434

可以发现:在数据规模在 1e41e4 的情况下,堆排序的比较次数最多,而快速排序的比较次数最少

1.4 程序清单

  • /Src/Algorithm/sortAlgorithm.cpp:内核排序算法
  • /Src/Forms/SortWidget.ui:ui 界面
  • /Src/Headers/SortWidget.h:排序组件逻辑头文件
  • /Src/Post/SortWidget.cpp:排序组件逻辑源文件
  • /Src/Test/TestSort.cpp:程序测试文件

其中,除了 ui 界面使用 Qt Designer 设计师工具进行编写,其余文件均为与文件名同名的 C++ 类,便于维护与扩展

二、选做题

题三:扫描一个 C 源程序,利用两种方法统计该源程序中的关键字出现的频度,并比较各自查找的比较次数。

  1. 用 Hash 表存储源程序中出现的关键字,利用 Hash 查找技术统计该程序中的关键字出现的频度。用线性探测法解决 Hash 冲突。设 Hash 函数为 Hash(key) = [(key 的第一个字母序号) × 100 + (key 的最后一个字母序号)] % 41

  2. 用顺序表存储源程序中出现的关键字,利用二分查找技术统计该程序中的关键字出现的频度。

2.1 数据结构设计与算法思想

  • 第一问:
    • 首先用关键字序列构造哈希表,哈希函数使用题中所给,容易算出哈希表的最大容量为 25×100+4025 \times 100+40 ,我们设置为 30003000 ,哈希表数据结构为:int: <string, int> 的键值对形式,可以使用动态数组容器 std::vector<std::pair> 进行存储。其中键设为 int 可以使得对于一个 int 类型的哈希值进行 O(1)O(1) 的存储与查找,获得 <word, cnt>,即 <std::string, int>

    • 然后对于文件中的每一个单词采用哈希搜索。利用哈希函数计算出每一个单词的哈希值,然后通过线性探测法进行搜索比对。关键在于如何解析出一个完整的单词,对于流读入的字符串(不包含空格、换行符、制表符等空白符),我们删除其中的符号后将剩余部分进行合并,之后进行异常处理,排除空串后进行哈希查找。查找次数包含成功和失败的比较次数

  • 第二问:将关键字存储于顺序表中,排序后,利用二分查找技术进行搜索统计。查找次数包含成功和失败的比较次数

2.2 程序结构

hash 程序结构图

UI

1
2
3
4
5
6
7
8
9
10
11
// 窗口 继承窗口库组件
<widget class="QWidget" name="SortWidget">
// 标题 标签组件
<widget class="QLabel" name="titleLabel"></widget>
// 输入 网格布局组件
<widget class="QGridLayout" name="inputGridLayout"></widget>
// 输出 网格布局组件
<widget class="QGridLayout" name="outputGridLayout"></widget>
// 交互 水平布局组件
<widget class="QHBoxLayout" name="buttonHLayout"></widget>
</widget>

Qt Application

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#ifndef DATASTRUCTURECLASSDESIGN_HASHWIDGET_H
#define DATASTRUCTURECLASSDESIGN_HASHWIDGET_H

#include <QWidget>


QT_BEGIN_NAMESPACE
namespace Ui {
class HashWidget;
}
QT_END_NAMESPACE

class HashWidget : public QWidget {
Q_OBJECT

private:
Ui::HashWidget* ui; // 窗口对象指针
QString inputFilePath, outputFolderPath; // 输入输出路径

private slots:
void pushInputFileButton(); // 槽函数 - 触发事件:获取输入文件路径的窗口对话
void pushOutputFolderButton(); // 槽函数 - 触发事件:获取存储文件路径的窗口对话
void pushCommitButton(); // 槽函数 - 触发事件:根据输入的文件开始执行算法
void pushCancelButton(); // 槽函数 - 触发事件:清除当前窗口所有标签的内容

public:
explicit HashWidget(QWidget* parent = nullptr); // 窗口构造函数

~HashWidget() override; // 窗口析构函数

};

#endif //DATASTRUCTURECLASSDESIGN_HASHWIDGET_H

Algorithm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>
#include <unordered_map>
#include <fstream>
#include <vector>
#include <algorithm>


class hashAlgorithm {
private:
std::vector<std::string> keywords = {
"auto", "break", "case", "char", "const",
"continue", "default", "do", "double", "else",
"enum", "extern", "float", "for", "goto",
"if", "int", "long", "register", "return",
"short", "signed", "sizeof", "static", "struct",
"switch", "typedef", "union", "unsigned", "void",
"volatile", "while"
};

std::string inputPath, outputPath;
int hashSize;

// std 检验
void writeToFile(std::unordered_map<std::string, int>& keywordCount, std::string path);

// 自定义哈希表检验
void writeToFile(std::vector<std::pair<std::string, int>>& keywordCount, std::string path);

public:
// 构造函数
hashAlgorithm(std::string _inputPath, std::string _outputPath);

void stdHash(); // 标准哈希 - 用于测试检验
int selfHash(); // 自定义哈希
int binaryHash(); // 二分哈希
};

2.3 实验数据与测试结果分析

代码文件从 github 上下载而来,其中 100 行与 500 行的代码文件为完整程序,2500 行代码文件为手动构造程序,三种文件数据规模的查找比较次数如下:

self hashbinary hash
100 lines120775
500 lines154610300
2500 lines556139097

可以发现:使用线性探测法进行哈希的比较次数远小于二分搜索的比较次数

2.4 程序清单

  • /Src/Algorithm/hashAlgorithm.cpp:内核哈希算法
  • /Src/Forms/HashWidget.ui:ui 界面
  • /Src/Headers/HashWidget.h:哈希组件逻辑头文件
  • /Src/Post/HashWidget.cpp:哈希组件逻辑源文件
  • /Src/Test/TestHash.cpp:程序测试文件

其中,除了 ui 界面使用 Qt Designer 设计师工具进行编写,其余文件均为与文件名同名的 C++ 类,便于维护与扩展

三、收获与体会

算法思维。四种排序算法总共也就 100 行,加上堆排序采用树形迭代,快排与归并排序采用递归,整体编码难度并不算高。即使加上了文件 I/O 操作也不过 150 行。而第二道题也是 hash 查找与 binary 查找加上文件 I/O 操作,整体算法难度也十分有限。但是对于我来说也加强了 hash 构建与查找的逻辑。

前后端开发。为了锻炼设计模式思维与软件工程思维,我加入了 Qt 集成的 GUI 界面,不仅体会到了算法对于软件的核心作用所在,也体会到了窗口程序的数据交互与数据接口的开发过程。比如 Qt 中信号与槽的概念,其实就是一种数据交互的 API 格式。同时在体验开发过程的同时,也感受到了用户体验的前端板块,通过使用 Qt Designer 的设计师开发界面,可以通过拖动组件的形式进行可视化设计界面,提高了开发效率,如果可以多人组队,也实现了前后端分离的同步开发模式。

异常处理。与传统 OJ 不同的是,一个软件还需要考虑更多的异常处理,没有标准的 std 数据,加上用户输入、操作的无穷性,注定了软件的异常处理不是一个简单的事,比如在解析用户输入的字符串以及解析用户按钮操作的过程中,需要添加很多的字符串处理结构以及事件触发处理结构,这对于我强化软件开发的健壮性很有帮助。

版本管理。通过 Git 版本管理与 Github 的云端同步功能,也更能体会到开发留痕与 bug 检测的优势。

当然美中不足的也有很多,比如单人全栈开发并不利于锻炼团队协作的能力,同时对于程序的架构设计也没有经过深度考虑,仅仅有界面,数据正常交互就戛然而止。希望在未来的算法与开发路上可以走的更远、更坚定。

仓库地址

github: https://github.com/Explorer-Dong/DataStructureClassDesign

gitee: https://gitee.com/explorer-dong/DataStructureClassDesign

]]>
+ 线性代数

第1章 行列式

1.1 二阶与三阶行列式

1.1.1 二元线性方程组与二阶行列所式

线性代数是为了解决多元线性方程组而诞生的

1.1.2 三阶行列式

记住对角线法则即可

1.2 全排列和对换

1.2.1 排列及其逆序数

排列:就是每个数位上数字的排序方式

逆序数:就是一个排列 tt 中每一个数位之前比其大的数字数量之和,即

i=1nti\sum_{i=1}^{n}t_i

1.2.2 对换

对换:就是排列中某两个数位之间的数字进行交换的操作

  • 定理一:一个排列中两个元素对换,排列逆序数的奇偶性改变
  • 推论:奇排列对换成标准排列的对换次数为奇数,偶排列对换成标准排列的对换次数为偶数

1.3 n阶行列式的定义

n阶行列式的值为 n!n! 个项之和,每一项的组成方式为:每行选一个元素,每列选一个元素,这些元素之积,符号为

(1)N(row)+N(col)(-1)^{N(row)+N(col)}

1.4 行列式的性质

  • 性质一:行列式与它的转置行列式相等

  • 性质二:对换行列式的两个行或者列,行列式变号

    • 推论:若行列式有两行或两列完全相同,则行列式为零
  • 性质三:行列式中若某一行(列)都乘以k,等于整个行列式的值乘以k

    • 推论一:行列式的某一行(列)中的公因子可以提出到行列式之外
    • 推论二:若行列式中有两行(列)成比例,则行列式的值为零
  • 性质四:若行列式中某一行(列)都是两数之和,则可以拆分成两个行列式之和

  • 性质五:把行列式中的某一行(列)乘以一个常数加到另一个行(列)上,行列式的值不变

技巧

  • 计算技巧:在一开始对换行或者列的时候,尽可能保证左上角是数字1
  • 所有的行(列)之和相等:先全部加到一行(列),再配凑上三角(下三角)

1.5 行列式按行(列)展开

余子式:MijM_{ij}

代数余子式:AijA_{ij}

关系:Aij=(1)i+jMijA_{ij}=(-1)^{i+j}M_{ij}

1.5.1 引理

一行(列)只有一个元素不为零,则 D=aijAijD=a_{ij}A_{ij}

证明:

  • 对于特殊情况,即不为零的元素在左上角,则根据上述分块矩阵,可知

    D=(1)1+1aijMij=aijAijD=(-1)^{1+1}a_{ij}M_{ij}=a_{ij}A_{ij}

  • 对于一般情况,即某行(列)唯一不为零的元素在任意位置,则经过 i+j2i+j-2 次对换后,就是上述特殊情况,可知

    D=(1)i+j2aijMij=(1)i+jaijMij=aijAijD=(-1)^{i+j-2}a_{ij}M_{ij}=(-1)^{i+j}a_{ij}M_{ij}=a_{ij}A_{ij}

1.5.2 定理

某行(列)(x)有多个元素不为零,则

D=i=1naxiAxiD=\sum_{i=1}^n a_{xi}A_{xi}

证明:就是将展开的那一行(列)通过加法原理进行拆分,然后利用上述引理的一般情况进行证明即可

1.5.3 推论

对于上述的定理,讨论代数余子式前面的系数数组 axi(i=1,2,,n)a_{xi}(i=1,2,\cdots,n)

如果系数数组不是第x行(列)的元素,而是其他行(列),那么上述Σ之和就为0

证明很简单,就是从原行列式出发,如果两行(列)元素完全一致,那么行列式显然为0

考点:

一般就是把不同行(列)的元素乘上其他行(列)的元素,然后适当配凑即可

例题

几种特殊的行列式(补)

分块行列式

0 在左下或右上就是左上角与右下角行列式之积(D=D1D2D=D_1D_2),0 在左上或右下就是左下角与右上角行列式之积加上符号判定

分块行列式

证明:分区域转换为上三角即可

2n 阶行列式

先行对换,再列对换,通过分块行列式和数学归纳法,可得答案为一个等比数列

2n 阶行列式

范德蒙德行列式

范德蒙德行列式

证明:首先从最后一行开始,依次减去前一行的 x1x_1 倍,凑出第一列一个元素不为零的情况,最后通过数学归纳发,即可求解。项数为

Cn2C_n^2

第2章 矩阵及其运算

2.1 线性方程组和矩阵

2.1.1 线性方程组

把线性方程组中的数据搬到了矩阵中而已

2.1.2 矩阵的定义

相较于行列式是一个数,矩阵就是一个数表

  • n阶矩阵
  • 行(列)矩阵
  • 零矩阵
  • 对角矩阵: Λ=diag(λ1,λ2,,λn)\Lambda=diag(\lambda_1,\lambda_2,\cdots,\lambda_n)
  • 单位矩阵: 即主对角线全1,其余全0
  • 线性变换:y=x1+x2...y = x_1 + x_2 ... 叫做x到的线性变换

2.2 矩阵的运算

2.2.1 矩阵的加法

按元素一个一个加

2.2.2 数与矩阵相乘

按元素一个一个乘

2.2.3 矩阵与矩阵相乘

  1. 基本规则:AB=CAB=Ccijc_{ij} 就是A的第 i 行与 B 的第 j 列元素依次相乘求和
  2. 没有交换律: ABAB 称为 A 左乘 B,交换成立的前提是 A 和 B 两个方阵左乘和右乘相等才可以
  3. 有结合律、分配率
  4. 单位矩阵:主对角线上元素全为1,其余全为0
  5. 纯量矩阵:主对角线上元素全为 λ\lambda ,其余全为0
  6. 幂运算:当A、B两个方阵可交换时,有幂运算规律(因为有结合律)

矩阵乘法算律:

矩阵乘法算律

2.2.4 矩阵的转置

矩阵转置算律:

矩阵转置算律

证明 (4):左边的 cijc_{ij} 其实应该是 ABABcjic_{ji} ,对应 AA 的第 jj 行与 BB 的第 ii 列,那么反过来对于 ij 就是 B 转置的第 i 行与 A 转置的第 j 列

对称矩阵:对于一个方阵 A,如果 A=ATA = A^T 则称 A 为对称阵

对称矩阵 - 例题

2.2.5 方阵的行列式

行列式算律:

行列式算律

伴随矩阵:

AA=AA=AEAA^* = A^*A = \left | A \right |E

伴随矩阵

2.3 逆矩阵

2.3.1 逆矩阵的定义、性质、求法

定义:

  • 如果对于矩阵 A,有 AB=BA=EAB = BA = E ,则称 B 为 A 的逆矩阵

性质:

  • 唯一性:如果矩阵 A 可逆,则 A 的逆矩阵是唯一的

  • 行列式:如果矩阵A可逆,则 A0|A| \ne 0

  • 奇异矩阵:A=0|A| = 0 ,非奇异矩阵:A0|A| \ne 0

  • 必要条件:若 AB=EAB=E (或 BA=EBA = E),则 A 可逆且 B=A1B = A^{-1}

求法:

  • A0|A| \ne 0 ,则矩阵 A 可逆,且 A1=1AAA^{-1} = \frac{1}{|A|}A^*

逆矩阵算律:

(A1)1=A(λA)1=1λA1(AB)1=B1A1(AT)1=(A1)TA1=A1A=An1\begin{aligned}{(A^{-1})}^{-1} &=& A \\({\lambda A})^{-1} &=& \frac{1}{\lambda} A^{-1}\\({AB})^{-1} &=& B^{-1}A^{-1} \\(A^T)^{-1} &=& (A^{-1})^{T} \\|A^{-1}| &=& {|A|}^{-1} \\|A^*| &=& {|A|}^{n - 1}\end{aligned}

2.3.2 逆矩阵的初步应用

(一)求逆矩阵:

已知矩阵 A,B,C 且 AXB=C,求矩阵 X只需将矩阵 X 左乘 A1 右乘 B1 即可\begin{aligned}\text{已知矩阵 $A,B,C$ 且 $AXB=C$,求矩阵 $X$}\\\text{只需将矩阵 $X$ 左乘 $A^{-1}$ 右乘 $B^{-1}$ 即可}\end{aligned}

(二)矩阵多项式:

定义:

定义

计算技巧一:直接代入

直接代入

计算技巧二:对角阵幂运算

对角阵幂运算

计算技巧三:对角矩阵多项式转化为数的多项式计算

对角矩阵多项式转化为数的多项式计算

2.4 克拉默法则

应用:

  • 求解未知数数量和方程个数相等,且系数行列式不为零的线性方程组

  • 是求解一般线性方程组的一个特殊场景

结论:

如果线性方程组

线性方程组

的系数矩阵 A 的行列式不为零,即

系数矩阵 A 的行列式不为零

则方程组有唯一解

方程组有唯一解

其中 Aj(j=1,2,...,n)A_j(j=1,2,...,n) 是把系数矩阵 A 中第 jj 列的元素用方程组右端的常数项代替后所得到的 n 阶矩阵,即

任意一列

证明:

第一步:方程组转化为矩阵方程

方程组转化为矩阵方程

第二步:应用逆矩阵消元

应用逆矩阵消元

第三步:应用行列式的性质计算

应用行列式的性质计算

2.5 矩阵分块法

个人感觉就是一种向量化的更高级的思维,对于一个向量,进行全新向量的拆解,从而实现拆分计算。以下是 5 个拆分规则,重点关注第 5 点,即分块对角矩阵以及最后的按行按列分块的两个应用。

2.5.1 拆分规则

首先需要知道的是,在对矩阵进行分块计算的时候,前提有两个:一个是两个矩阵一开始的规格要相同,另一个是两个矩阵分块之后的规格也要相同

按位加:

若

则

按位数乘:

若

则

矩阵乘法:

若

则

其中

其中

按位转置:

若

则

对角分块矩阵:

对角分块矩阵

其中 A1,A2,...,AsA_1,A_2,...,A_s 都是方阵,则称 AA 为对角分块矩阵

2.5.2 运算性质

幂运算就是主对角线相应元素的幂运算

幂运算就是主对角线相应元素的幂运算

矩阵行列式运算性质

矩阵行列式运算性质

矩阵的逆就是主对角线的块按位取逆

矩阵的逆就是主对角线的块按位取逆

按行按列分块的应用

  • ATA=OA^T A=O 的充要条件是 A=OA=O
  • 线性方程组的三种表示方式:
    1. 就是类似于一开始的矩阵数表的表示方式
    2. 将系数表示为一个矩阵,将未知数表示成一个矩阵,将常数项也表示成一个矩阵
    3. 同上,只是未知数保持不变,即 x1a1+x2a2++xna3=bx_1 {a_1} + x_2 {a_2} + \cdots + x_n {a_3} = {b}
  • 线性方程组的解的两种表示方式:
    1. 一一表示
    2. 列向量表示

2.5.3 好题举例

分块的整体运算思想 + 矩阵提取公因子

分块的整体运算思想 + 矩阵提取公因子

逆矩阵的按定义的求法,即配凑求出逆矩阵(常规计算法是利用了伴随矩阵的计算思想)

配凑求出逆矩阵

第3章 矩阵的初等变换与线性方程组

3.1 矩阵的初等变换

3.1.1 引入

矩阵的变换 \to 矩阵增广矩阵的行变换 \to 行最简形矩阵

3.1.2 矩阵的初等行变换

定义:

  1. rirjr_i \leftrightarrow r_j
  2. riri×k (k0)r_i \leftarrow r_i \times k\ (k \neq 0)
  3. riri+krjr_i \leftarrow r_i + kr_j

性质:

  1. 将行变为列,就是矩阵的初等列变换
  2. 初等行变换与初等列变化统称初等变换
  3. 三种变换都是可逆的

3.1.3 矩阵的等价关系

定义:

  1. AA 经过有限次初等行变换变成矩阵 BB,就称 AABB 行等价,记作 ArBA \stackrel{r}{\sim} B
  2. AA 经过有限次初等列变换变成矩阵 BB,就称 AABB 列等价,记作 AcBA \stackrel{c}{\sim} B
  3. AA 经过有限次初等变换变成矩阵 BB,就称 AABB 等价,记作 ABA \sim B

性质:

  1. 反身性:AAA \sim A
  2. 对称性:若  AB\ A \sim B ,则  BA\ B \sim A
  3. 传递性:若  AB\ A \sim BBCB \sim C ,则  AC\ A \sim C

3.1.3 三种形式的矩阵

行阶梯型矩阵

行阶梯型矩阵

行最简形矩阵

行最简形矩阵

标准形

标准形

image-20231028112702780

3.1.4 性质1:矩阵初等变换的性质

  • Am×nA_{m\times n} 施行一次初等行变换,相当于在 AA 的左边乘以相应的 mm 阶初等矩阵

  • Am×nA_{m\times n} 施行一次初等列变换,相当于在 AA 的右边乘以相应的 nn 阶初等矩阵

矩阵初等变换的性质

其中:

  • Em(i,j)E_m(i,j) 表示:交换初等矩阵的第 ii 行与第 jj
  • Em(i(k))E_m(i(k)) 表示:将初等矩阵的第 ii 行乘以 kk
  • Em(ij(k))E_m(ij(k)) 表示:将初等矩阵的第 ii 行加上第 jj 行的 kk

3.1.5 性质2:可逆的充要条件

可逆的充要条件

3.1.6 定理:矩阵初等变换的存在性定理

矩阵初等变换的存在性定理

对于(1)计算 PP 的方法:有配凑的味道 - 计算变换矩阵

计算变换矩阵

3.1.7 推论:方阵可逆的等价推导

方阵可逆的等价推导

于是证明一个方阵 AA可逆就又多了一个策略,即将 AA 经过有限次的初等行变换之后变成了单位阵。

对于(1)当 AA 为可逆方阵时计算 PP 的方法:此时计算出来的 PP 就是 A1A^{-1} - 证明可逆 + 计算逆矩阵

当 A 为可逆方阵时计算 P 的方法

3.1.8 初等变换的应用

(一)解方程

问题:已知矩阵 A,BA,B,且 AX=BAX=B,现在需要求解 XX 矩阵

思路:首先需要证明 AA 可逆,然后需要计算 A1BA^{-1}B,那么采用本节的知识:如果 ArEA \stackrel{r}{\sim} E,则 AA 可逆,即 PA=EPA=E,还需要求 A1BA^{-1}B。可以发现,此时的 P=A1P = A^{-1},那么答案就是 PBPB,于是我们只需要将 A,BA,B 同时进行 PP 初等变换即可

答案:最终的目标就是将拼接后的矩阵转化为行最简形矩阵,左边是一个单位矩阵即可

(二)解线性方程组

问题:求解方程数量和未知数数量一致的线性方程组

思路:同上方解方程的思路 Ax=bAx=b,只不过 AA 就是系数矩阵,bb 就是常数矩阵,xx 就是解方程

补充:解这种线性方程组的策略:

  1. 高中学的:消元
  2. 第二章第 3 目:求 A1bA^{-1}b
  3. 第二章第 4 目:克拉默法则
  4. 上面刚学的:线性变换

3.2 矩阵的秩

定义:矩阵的非零子式的最高阶数,记作 R(A)R(A)

性质:

  1. 转置不变性:R(AT)=R(A)R(A^T)=R(A)

  2. 相似不变性:若 $A \sim B $,则 R(A)=R(B)R(A)=R(B)

  3. 初等变换不变性:若 P,QP,Q 可逆,则 R(PAQ)=R(A)R(PAQ)=R(A)

  4. 乘法性质:0R(Am×n)min{m,n}0 \le R(A_{m\times n}) \le \min \{m, n\}

  5. 加法性质:

    加法性质

  6. 压缩性:若 Am×nA_{m\times n} 的秩为 rr,则 AA 一定可以转化为

    [ErOOO]\begin{bmatrix}E_r & O \\O & O\end{bmatrix}

3.3 线性方程组的解

求解策略:利用 矩阵的初等变换矩阵的秩 求解一般的线性方程组

对于 Ax=bAx=b 的线性方程组:

  • 无解的充要条件:R(A)<R(A,b)R(A)<R(A,b)
  • 有唯一解的充要条件:R(A)=R(A,b)=nR(A)=R(A,b)=n
  • 有无限多解的充要条件:R(A)=R(A,b)<nR(A)=R(A,b)<n

求解齐次线性方程组:

  • 化简为行最简 or 行阶梯

求解非齐次线性方程组:

  • 化简为行最简 or 行阶梯

第4章 向量组的线性相关性

4.1 向量组及其线性组合

4.1.1 n维向量的概念

显然的 n>3n>3 的向量没有直观的几何形象,所谓向量组就是由同维度的列(行)向量所组成的集合。

向量组与矩阵的关系:

向量组与矩阵的关系

4.1.2 线性组合和线性表示

定义:

(一)线性组合:

线性组合定义

(二)线性表示:

线性表示定义

判定:转化为判定方程组有解问题,从而转化为求解矩阵的秩的问题 5

  • 判定向量 bb 能否被向量组 AA 线性表示:

    向量被向量组线性表示

  • 判定向量组 BB 能否被向量组 AA 线性表示:

    向量组被向量组线性表示

    该判定定理有以下推论:

    放缩性质

  • 判定向量组 BB向量组 AA 等价:

    向量组与向量组等价

4.2 向量组的线性相关性

定义:

线性相关定义

注意

判定:

  • 定理一:

    定理一

    证明:按照定义,只需要移项 or 同除,进行构造即可

  • 定理二:

    定理二

    证明:按照定义,转化为齐次线性方程组解的问题

    • 有非零解 \Leftrightarrow 无数组解(将解方程取倍数即可),R(A)=R(A,0)<mR(A)=R(A,0)<m
    • 仅有零解 \Leftrightarrow 唯一解,R(A)=R(A,0)=mR(A)=R(A,0)=m

结论:

  • 结论一:

    结论一

    证明:R(A)<mR(B)R(A)+1<m+1R(A)<m \to R(B)\le R(A)+1 <m+1

  • 结论二:

    结论二

    证明:R(Ax×m)=mR(Ab)=mR(A_{x\times m})=m \to R\binom{A}{b}=m

  • 结论三:

    结论三

    证明:R(A)n<mR(A)\le n <m

  • 结论四:

    结论四

    证明:R(A)=m,R(A,b)<m+1Ax=b有唯一解R(A)=m,R(A,b)<m+1 \to Ax=b\text{有唯一解}

    • max{R(A),R(b)}R(A,b)m+1mR(A,b)m+1\max \{ R(A),R(b) \} \le R(A,b) \le m+1 \to m \le R(A,b) \le m+1
    • R(A,b)<m+1R(A,b)<m+1
    • R(A,b)=mR(A,b)=m
    • 因此 R(A)=R(A,b)=m有唯一解R(A)=R(A,b)=m \to \text{有唯一解}

4.3 向量组的秩

4.3.1 最大无关组的定义

定义一:

定义一

注意:

  • 最大无关组之间等价
  • 最大无关组 A0A_0 和原向量组 AA 等价

定义二:

定义二

4.3.2 向量组的秩和矩阵的秩的关系

向量组的秩和矩阵的秩的关系

4.3.3 向量组的秩的结论

向量组的秩的结论 1-2

向量组的秩的结论 3-5

证明:全部可以使用矩阵的秩的性质进行证明

4.4 向量空间

4.4.1 向量空间的概念

可以从高中学到的平面向量以及空间向量入手进行理解,即平面向量就是一个二维向量空间,同理空间向量就是一个三维向量空间,那么次数就是拓展到 n 维向量空间,道理是一样的,只不过超过三维之后就没有直观的效果展示罢了。

4.4.2 向量空间的基与维数

同样可以从高中学到的向量入手,此处的基就是基底,维数就是有几个基底。所有的基之间都是线性无关的,这是显然的。然后整个向量空间中任意一个向量都可以被基线性表示,也就很显然了,此处有三个考点,分别为:

考点一:求解空间中的某向量 x 在基 A 下的坐标

其实就是求解向量 x 在基 A 的各个“轴”上的投影。我们定义列向量 λ\lambda 为向量 x 在基 A 下的坐标,那么就有如下的表述:

x=A λx = A \ \lambda

考点二:求解过度矩阵 P

我们已知一个向量空间中的两个基分别为 A 和 B,若有矩阵 P 满足基变换公式:B=APB = AP,我们就称 P 为从基 A 到基 B 的过渡矩阵

考点三:已知空间中的某向量 x 在基 A 下坐标为 λ\lambda,以及从基 A 到基 B 的过渡矩阵为 P,求解转换基为 B 之后的坐标 γ\gamma

求解过程

4.5 线性方程组的解的结构

本目其实就是 3.3 目的一个知识补充,具体的线性方程组求解方法与 3.3 目几乎完全一致,只不过通过解的结构将解的结构进行了划分从而看似有些不同。但是殊途同归,都是一个东西。下面介绍本目与 3.3 目不同的地方:

我们从 3.3 目可以知道,无论是齐次线性方程组还是非齐次线性方程组,求解步骤都是:将系数矩阵(非齐次就是增广矩阵)进行行等价变换,然后对得到的方程组进行相对应未知变量的赋值即可。区别在于:

非齐次线性方程组的通解=非齐次线性方程组的一个特解+齐次线性方程组的通解\text{非齐次线性方程组的通解}=\text{非齐次线性方程组的一个特解}+\text{齐次线性方程组的通解}

解释:我们将

  • 齐次线性方程组记为 Ax=0Ax=0,解为 η\eta,则有 Aη=0A \eta = 0
  • 非齐次线性方程组记为 Ax=bAx=b,假如其中的一个特解为 η\eta^*,则 Aη=bA\eta^*=b,假如此时我们又计算出了该方程组的其次线性解 η\eta,则有 Aη=0A\eta=0。那么显然有 A(η+η)=bA(\eta^*+\eta)=b,此时 η+η\eta^* + \eta 就是该非齐次线性方程组的通解

也就是说本目对 3.3 目的线性方程组的求解给出了进一步的结构上的解释,即非齐次线性方程组的解的结构是基于本身的一个特解与齐次的通解之上的,仅此而已。当然了,本目在介绍齐次线性方程组解的结构时还引入了一个新的定理:

若矩阵 Am×n 的秩为 r,则该矩阵的解空间的维度(基础解系中线性无关向量的个数)就是 nr,即:\begin{aligned}\text{若矩阵 $A_{m\times n}$ 的秩为 $r$,则该矩阵的解空间的维度(基础解系中线性无关向量的个数)就是 $n-r$,即:}\end{aligned}

dimS=nrdimS = n-r

该定理可以作为一些证明秩相等的证明题的切入点。若想要证明两个$ n$ 元矩阵 AABB 的秩相等,可以转化为证明两个矩阵的基础解析的维度相等,即解空间相等。证明解空间相等进一步转向证明 Ax=0Ax=0Bx=0Bx=0 同解,证明同解就很简单了,就是类似于证明一个充要条件,即证明 Ax=0Bx=0Ax=0 \to Bx=0 以及 Bx=0Ax=0Bx=0 \to Ax=0

第5章 相似矩阵及二次型

5.1 向量的内积、长度及正交性

5.1.1 内积

即各个位置的数依次相乘。运算规律如下:

运算规律

推导全部按照内积的定义来,肥肠煎蛋

5.1.2 长度

类似于模长,有以下性质:

性质

n 维向量与 n 维向量间的夹角:

n 维向量与 n 维向量间的夹角

5.1.3 正交

类似于两个非零向量垂直的关系,即两向量内积为 0。

(一)正交向量组

  • 定义:向量组之间的任意两两向量均正交
  • 性质:正交向量组一定线性无关

(二)标准正交基

  • 定义:是某空间向量的基+正交向量组+每一个向量都是单位向量

  • 求解方法:施密特正交化

    1. 正交化:(其实就是对基础解系的一个线性组合)

      正交化

      正交化

    2. 单位化:

      单位化

5.1.4 正交矩阵与正交变换

正交矩阵:(方阵)

  1. 定义:满足 ATA=E or AAT=EA^TA=E\ \text{or} \ AA^T=E 的矩阵
  2. 定理:正交矩阵的充要条件为矩阵的行(列)向量为单位向量且两两正交

正交变换:

  1. 定义:对于正交矩阵 AAy=Axy=Ax 称为称为正交变换
  2. 性质:y=yTy=xTATAx=xTEx=xTx=x||y||=\sqrt{y^Ty}=\sqrt{x^TA^TAx}=\sqrt{x^TEx}=\sqrt{x^Tx}=||x||,即线段经过正交变换之后长度保持不变

5.2 方阵的特征值与特征向量

5.2.1 定义

对于一个n阶方阵 A,存在一个复数 λ\lambda 和一组n阶非零向量 x 使得

Ax=λxAx=\lambda x

则称 x 为特征向量,λ\lambda 为特征值,AλE|A-\lambda E| 为特征多项式

5.2.2 特征值的性质

性质一

n阶矩阵 A 在复数范围内含有 n 个特征值,且

i=1nλi=i=1naiii=1nλi=A\begin{aligned}\sum_{i=1}^{n} \lambda _i &=& \sum_{i=1}^{n} a_{ii} \\\prod_{i=1}^{n} \lambda _i &=& \left | A \right |\end{aligned}

性质二

λ\lambda 是 A 的特征值,则 ϕ(λ)\phi{(\lambda)}ϕ(A)\phi{(A)} 的特征值

5.2.3 特征向量的性质

对于同一个矩阵,不同的特征值对应的特征向量之间是线性无关

5.3 相似矩阵

5.3.1 定义

对于两个 n 阶方阵 A, B 而言,若存在可逆矩阵 P 使得

PAP1=BPAP^{-1}=B

则称 B 为 A 的相似矩阵,A 与 B 相似,也称对 A 进行相似变换,P 为相似变换矩阵

5.3.2 性质

若矩阵 A 与 B 相似,则 A 与 B 的特征多项式相同,则 A 与 B 的特征值也就相同,A 与 B 的行列式也就相同

5.3.3 矩阵多项式

一个矩阵 A 的多项式 ϕ(A)\phi{(A)} 可以通过其相似矩阵 Λ\Lambda 很轻松地计算出来为 Pϕ(Λ)P1P \phi{(\Lambda)} P^{-1},即对角矩阵左乘一个可逆阵,右乘可逆阵的逆矩阵即可,而对角矩阵的幂运算就是对角元素的幂运算,故而非常方便就可以计算一个矩阵的多项式。那么计算的关键在于如何找到一个矩阵的相似矩阵?下面给出判定一个矩阵是否存在相似矩阵(可对角化)的判定定理:

n 阶方阵可对角化的充要条件为该方阵含有 n 个线性无关的特征向量

5.4 对称矩阵的对角化

本目讨论一个 n 阶方阵具备什么条件才能拥有 n 个线性无关的特征向量,从而可对角化。但是对于一般的方阵,情况过于复杂,此处只讨论 n 阶对称矩阵。即:一个 n 阶对角矩阵具备什么条件才能拥有 n 个线性无关的特征向量,从而可对角化。

答案是 n 阶对角矩阵一定是可对角化的。因为有一个定理是这样的:对于一个对称矩阵 A 而言,一定可以找到一个正交矩阵 P 使得 P1AP=ΛP^{-1}AP=\Lambda,又由于正交矩阵一定是可逆矩阵,因此一定可以找到矩阵 A 的 n 个线性无关的特征向量,从而 A 一定可对角化。

对称矩阵的性质

  1. 对称矩阵的特征值均为实数
  2. 对称矩阵 A 的两个特征值 λ1\lambda _1λ2\lambda _2 对应的两个特征向量分别为 P1P_1P2P_2,若 λ1λ2\lambda_1 \ne \lambda_2,相比于一般的矩阵 P1P_1P2P_2 线性无关,此时两者关系更强,即:P1P_1P2P_2 正交
  3. 对称矩阵的每一个 k 重根,一定对应有 k 个线性无关的特征向量

因此本目相较于 5.3 目其实就是通过可对角化这一个概念,来告诉我们对称矩阵是一定可以求出对角矩阵的。而不用判断当前矩阵是否可对角化了。只不过在此基础之上还附加了一个小定理(也没给出证明),就是对称矩阵的相似变换矩阵一定是一个正交矩阵,那么也就复习回顾了 5.1 目中学到的正交矩阵的概念。为了求解出这个正交矩阵,我们需要在 5.3 目求解特征向量之后再加一个操作,即:对于一个 k 重根,根据上面的性质3我们知道当前的根一定有 k 个线性无关的特征向量,为了凑出最终的正交矩阵,我们需要对这 k 个线性无关的特征向量正交化。那么所有的特征值下的特征向量都正交化之后,又由性质2可知,不同的特征值下的特征向量又是正交的,于是最终的正交的相似变换矩阵也就求出来了,也就得到了对角矩阵 Λ\Lambda

5.5 二次型及其标准型(部分)

本目只需要掌握到:将一个二次型转化为标准型,即可。其实就是比 5.4 目多一个将二次齐次函数的系数取出组成一个二次型的步骤。其中二次型就是一个对称矩阵。接着就是重复 5.4 目中的将对称矩阵转化为对角矩阵的过程了。

对称矩阵和正定性之间的关系

在最优化方法中我们需要通过目标函数海塞矩阵的正定性来判断凸性,显然的海塞矩阵是对称方阵。可以分别从特征值和行列式的角度进行判断。

特征值角度

  • 一个对称矩阵 A 是正定的,当且仅当它的所有特征值 λi>0\lambda_i>0
  • 一个对称矩阵 A 是正半定的,当且仅当它的所有特征值 λi0\lambda_i \ge 0

行列式角度

  • 一个对称矩阵 A 是正定的,当且仅当所有主子矩阵的行列式都大于零
  • 一个对称矩阵 A 是正半定的,当且仅当所有主子矩阵的行列式都大于或等于零
]]>
@@ -1136,11 +1136,11 @@ - LinearAlgebra - - /GPA/3rd-term/LinearAlgebra/ + DataStructureClassDesign + + /GPA/3rd-term/DataStructureClassDesign/ - 线性代数

第1章 行列式

1.1 二阶与三阶行列式

1.1.1 二元线性方程组与二阶行列所式

线性代数是为了解决多元线性方程组而诞生的

1.1.2 三阶行列式

记住对角线法则即可

1.2 全排列和对换

1.2.1 排列及其逆序数

排列:就是每个数位上数字的排序方式

逆序数:就是一个排列 tt 中每一个数位之前比其大的数字数量之和,即

i=1nti\sum_{i=1}^{n}t_i

1.2.2 对换

对换:就是排列中某两个数位之间的数字进行交换的操作

  • 定理一:一个排列中两个元素对换,排列逆序数的奇偶性改变
  • 推论:奇排列对换成标准排列的对换次数为奇数,偶排列对换成标准排列的对换次数为偶数

1.3 n阶行列式的定义

n阶行列式的值为 n!n! 个项之和,每一项的组成方式为:每行选一个元素,每列选一个元素,这些元素之积,符号为

(1)N(row)+N(col)(-1)^{N(row)+N(col)}

1.4 行列式的性质

  • 性质一:行列式与它的转置行列式相等

  • 性质二:对换行列式的两个行或者列,行列式变号

    • 推论:若行列式有两行或两列完全相同,则行列式为零
  • 性质三:行列式中若某一行(列)都乘以k,等于整个行列式的值乘以k

    • 推论一:行列式的某一行(列)中的公因子可以提出到行列式之外
    • 推论二:若行列式中有两行(列)成比例,则行列式的值为零
  • 性质四:若行列式中某一行(列)都是两数之和,则可以拆分成两个行列式之和

  • 性质五:把行列式中的某一行(列)乘以一个常数加到另一个行(列)上,行列式的值不变

技巧

  • 计算技巧:在一开始对换行或者列的时候,尽可能保证左上角是数字1
  • 所有的行(列)之和相等:先全部加到一行(列),再配凑上三角(下三角)

1.5 行列式按行(列)展开

余子式:MijM_{ij}

代数余子式:AijA_{ij}

关系:Aij=(1)i+jMijA_{ij}=(-1)^{i+j}M_{ij}

1.5.1 引理

一行(列)只有一个元素不为零,则 D=aijAijD=a_{ij}A_{ij}

证明:

  • 对于特殊情况,即不为零的元素在左上角,则根据上述分块矩阵,可知

    D=(1)1+1aijMij=aijAijD=(-1)^{1+1}a_{ij}M_{ij}=a_{ij}A_{ij}

  • 对于一般情况,即某行(列)唯一不为零的元素在任意位置,则经过 i+j2i+j-2 次对换后,就是上述特殊情况,可知

    D=(1)i+j2aijMij=(1)i+jaijMij=aijAijD=(-1)^{i+j-2}a_{ij}M_{ij}=(-1)^{i+j}a_{ij}M_{ij}=a_{ij}A_{ij}

1.5.2 定理

某行(列)(x)有多个元素不为零,则

D=i=1naxiAxiD=\sum_{i=1}^n a_{xi}A_{xi}

证明:就是将展开的那一行(列)通过加法原理进行拆分,然后利用上述引理的一般情况进行证明即可

1.5.3 推论

对于上述的定理,讨论代数余子式前面的系数数组 axi(i=1,2,,n)a_{xi}(i=1,2,\cdots,n)

如果系数数组不是第x行(列)的元素,而是其他行(列),那么上述Σ之和就为0

证明很简单,就是从原行列式出发,如果两行(列)元素完全一致,那么行列式显然为0

考点:

一般就是把不同行(列)的元素乘上其他行(列)的元素,然后适当配凑即可

例题

几种特殊的行列式(补)

分块行列式

0 在左下或右上就是左上角与右下角行列式之积(D=D1D2D=D_1D_2),0 在左上或右下就是左下角与右上角行列式之积加上符号判定

分块行列式

证明:分区域转换为上三角即可

2n 阶行列式

先行对换,再列对换,通过分块行列式和数学归纳法,可得答案为一个等比数列

2n 阶行列式

范德蒙德行列式

范德蒙德行列式

证明:首先从最后一行开始,依次减去前一行的 x1x_1 倍,凑出第一列一个元素不为零的情况,最后通过数学归纳发,即可求解。项数为

Cn2C_n^2

第2章 矩阵及其运算

2.1 线性方程组和矩阵

2.1.1 线性方程组

把线性方程组中的数据搬到了矩阵中而已

2.1.2 矩阵的定义

相较于行列式是一个数,矩阵就是一个数表

  • n阶矩阵
  • 行(列)矩阵
  • 零矩阵
  • 对角矩阵: Λ=diag(λ1,λ2,,λn)\Lambda=diag(\lambda_1,\lambda_2,\cdots,\lambda_n)
  • 单位矩阵: 即主对角线全1,其余全0
  • 线性变换:y=x1+x2...y = x_1 + x_2 ... 叫做x到的线性变换

2.2 矩阵的运算

2.2.1 矩阵的加法

按元素一个一个加

2.2.2 数与矩阵相乘

按元素一个一个乘

2.2.3 矩阵与矩阵相乘

  1. 基本规则:AB=CAB=Ccijc_{ij} 就是A的第 i 行与 B 的第 j 列元素依次相乘求和
  2. 没有交换律: ABAB 称为 A 左乘 B,交换成立的前提是 A 和 B 两个方阵左乘和右乘相等才可以
  3. 有结合律、分配率
  4. 单位矩阵:主对角线上元素全为1,其余全为0
  5. 纯量矩阵:主对角线上元素全为 λ\lambda ,其余全为0
  6. 幂运算:当A、B两个方阵可交换时,有幂运算规律(因为有结合律)

矩阵乘法算律:

矩阵乘法算律

2.2.4 矩阵的转置

矩阵转置算律:

矩阵转置算律

证明 (4):左边的 cijc_{ij} 其实应该是 ABABcjic_{ji} ,对应 AA 的第 jj 行与 BB 的第 ii 列,那么反过来对于 ij 就是 B 转置的第 i 行与 A 转置的第 j 列

对称矩阵:对于一个方阵 A,如果 A=ATA = A^T 则称 A 为对称阵

对称矩阵 - 例题

2.2.5 方阵的行列式

行列式算律:

行列式算律

伴随矩阵:

AA=AA=AEAA^* = A^*A = \left | A \right |E

伴随矩阵

2.3 逆矩阵

2.3.1 逆矩阵的定义、性质、求法

定义:

  • 如果对于矩阵 A,有 AB=BA=EAB = BA = E ,则称 B 为 A 的逆矩阵

性质:

  • 唯一性:如果矩阵 A 可逆,则 A 的逆矩阵是唯一的

  • 行列式:如果矩阵A可逆,则 A0|A| \ne 0

  • 奇异矩阵:A=0|A| = 0 ,非奇异矩阵:A0|A| \ne 0

  • 必要条件:若 AB=EAB=E (或 BA=EBA = E),则 A 可逆且 B=A1B = A^{-1}

求法:

  • A0|A| \ne 0 ,则矩阵 A 可逆,且 A1=1AAA^{-1} = \frac{1}{|A|}A^*

逆矩阵算律:

(A1)1=A(λA)1=1λA1(AB)1=B1A1(AT)1=(A1)TA1=A1A=An1\begin{aligned}{(A^{-1})}^{-1} &=& A \\({\lambda A})^{-1} &=& \frac{1}{\lambda} A^{-1}\\({AB})^{-1} &=& B^{-1}A^{-1} \\(A^T)^{-1} &=& (A^{-1})^{T} \\|A^{-1}| &=& {|A|}^{-1} \\|A^*| &=& {|A|}^{n - 1}\end{aligned}

2.3.2 逆矩阵的初步应用

(一)求逆矩阵:

已知矩阵 A,B,C 且 AXB=C,求矩阵 X只需将矩阵 X 左乘 A1 右乘 B1 即可\begin{aligned}\text{已知矩阵 $A,B,C$ 且 $AXB=C$,求矩阵 $X$}\\\text{只需将矩阵 $X$ 左乘 $A^{-1}$ 右乘 $B^{-1}$ 即可}\end{aligned}

(二)矩阵多项式:

定义:

定义

计算技巧一:直接代入

直接代入

计算技巧二:对角阵幂运算

对角阵幂运算

计算技巧三:对角矩阵多项式转化为数的多项式计算

对角矩阵多项式转化为数的多项式计算

2.4 克拉默法则

应用:

  • 求解未知数数量和方程个数相等,且系数行列式不为零的线性方程组

  • 是求解一般线性方程组的一个特殊场景

结论:

如果线性方程组

线性方程组

的系数矩阵 A 的行列式不为零,即

系数矩阵 A 的行列式不为零

则方程组有唯一解

方程组有唯一解

其中 Aj(j=1,2,...,n)A_j(j=1,2,...,n) 是把系数矩阵 A 中第 jj 列的元素用方程组右端的常数项代替后所得到的 n 阶矩阵,即

任意一列

证明:

第一步:方程组转化为矩阵方程

方程组转化为矩阵方程

第二步:应用逆矩阵消元

应用逆矩阵消元

第三步:应用行列式的性质计算

应用行列式的性质计算

2.5 矩阵分块法

个人感觉就是一种向量化的更高级的思维,对于一个向量,进行全新向量的拆解,从而实现拆分计算。以下是 5 个拆分规则,重点关注第 5 点,即分块对角矩阵以及最后的按行按列分块的两个应用。

2.5.1 拆分规则

首先需要知道的是,在对矩阵进行分块计算的时候,前提有两个:一个是两个矩阵一开始的规格要相同,另一个是两个矩阵分块之后的规格也要相同

按位加:

若

则

按位数乘:

若

则

矩阵乘法:

若

则

其中

其中

按位转置:

若

则

对角分块矩阵:

对角分块矩阵

其中 A1,A2,...,AsA_1,A_2,...,A_s 都是方阵,则称 AA 为对角分块矩阵

2.5.2 运算性质

幂运算就是主对角线相应元素的幂运算

幂运算就是主对角线相应元素的幂运算

矩阵行列式运算性质

矩阵行列式运算性质

矩阵的逆就是主对角线的块按位取逆

矩阵的逆就是主对角线的块按位取逆

按行按列分块的应用

  • ATA=OA^T A=O 的充要条件是 A=OA=O
  • 线性方程组的三种表示方式:
    1. 就是类似于一开始的矩阵数表的表示方式
    2. 将系数表示为一个矩阵,将未知数表示成一个矩阵,将常数项也表示成一个矩阵
    3. 同上,只是未知数保持不变,即 x1a1+x2a2++xna3=bx_1 {a_1} + x_2 {a_2} + \cdots + x_n {a_3} = {b}
  • 线性方程组的解的两种表示方式:
    1. 一一表示
    2. 列向量表示

2.5.3 好题举例

分块的整体运算思想 + 矩阵提取公因子

分块的整体运算思想 + 矩阵提取公因子

逆矩阵的按定义的求法,即配凑求出逆矩阵(常规计算法是利用了伴随矩阵的计算思想)

配凑求出逆矩阵

第3章 矩阵的初等变换与线性方程组

3.1 矩阵的初等变换

3.1.1 引入

矩阵的变换 \to 矩阵增广矩阵的行变换 \to 行最简形矩阵

3.1.2 矩阵的初等行变换

定义:

  1. rirjr_i \leftrightarrow r_j
  2. riri×k (k0)r_i \leftarrow r_i \times k\ (k \neq 0)
  3. riri+krjr_i \leftarrow r_i + kr_j

性质:

  1. 将行变为列,就是矩阵的初等列变换
  2. 初等行变换与初等列变化统称初等变换
  3. 三种变换都是可逆的

3.1.3 矩阵的等价关系

定义:

  1. AA 经过有限次初等行变换变成矩阵 BB,就称 AABB 行等价,记作 ArBA \stackrel{r}{\sim} B
  2. AA 经过有限次初等列变换变成矩阵 BB,就称 AABB 列等价,记作 AcBA \stackrel{c}{\sim} B
  3. AA 经过有限次初等变换变成矩阵 BB,就称 AABB 等价,记作 ABA \sim B

性质:

  1. 反身性:AAA \sim A
  2. 对称性:若  AB\ A \sim B ,则  BA\ B \sim A
  3. 传递性:若  AB\ A \sim BBCB \sim C ,则  AC\ A \sim C

3.1.3 三种形式的矩阵

行阶梯型矩阵

行阶梯型矩阵

行最简形矩阵

行最简形矩阵

标准形

标准形

image-20231028112702780

3.1.4 性质1:矩阵初等变换的性质

  • Am×nA_{m\times n} 施行一次初等行变换,相当于在 AA 的左边乘以相应的 mm 阶初等矩阵

  • Am×nA_{m\times n} 施行一次初等列变换,相当于在 AA 的右边乘以相应的 nn 阶初等矩阵

矩阵初等变换的性质

其中:

  • Em(i,j)E_m(i,j) 表示:交换初等矩阵的第 ii 行与第 jj
  • Em(i(k))E_m(i(k)) 表示:将初等矩阵的第 ii 行乘以 kk
  • Em(ij(k))E_m(ij(k)) 表示:将初等矩阵的第 ii 行加上第 jj 行的 kk

3.1.5 性质2:可逆的充要条件

可逆的充要条件

3.1.6 定理:矩阵初等变换的存在性定理

矩阵初等变换的存在性定理

对于(1)计算 PP 的方法:有配凑的味道 - 计算变换矩阵

计算变换矩阵

3.1.7 推论:方阵可逆的等价推导

方阵可逆的等价推导

于是证明一个方阵 AA可逆就又多了一个策略,即将 AA 经过有限次的初等行变换之后变成了单位阵。

对于(1)当 AA 为可逆方阵时计算 PP 的方法:此时计算出来的 PP 就是 A1A^{-1} - 证明可逆 + 计算逆矩阵

当 A 为可逆方阵时计算 P 的方法

3.1.8 初等变换的应用

(一)解方程

问题:已知矩阵 A,BA,B,且 AX=BAX=B,现在需要求解 XX 矩阵

思路:首先需要证明 AA 可逆,然后需要计算 A1BA^{-1}B,那么采用本节的知识:如果 ArEA \stackrel{r}{\sim} E,则 AA 可逆,即 PA=EPA=E,还需要求 A1BA^{-1}B。可以发现,此时的 P=A1P = A^{-1},那么答案就是 PBPB,于是我们只需要将 A,BA,B 同时进行 PP 初等变换即可

答案:最终的目标就是将拼接后的矩阵转化为行最简形矩阵,左边是一个单位矩阵即可

(二)解线性方程组

问题:求解方程数量和未知数数量一致的线性方程组

思路:同上方解方程的思路 Ax=bAx=b,只不过 AA 就是系数矩阵,bb 就是常数矩阵,xx 就是解方程

补充:解这种线性方程组的策略:

  1. 高中学的:消元
  2. 第二章第 3 目:求 A1bA^{-1}b
  3. 第二章第 4 目:克拉默法则
  4. 上面刚学的:线性变换

3.2 矩阵的秩

定义:矩阵的非零子式的最高阶数,记作 R(A)R(A)

性质:

  1. 转置不变性:R(AT)=R(A)R(A^T)=R(A)

  2. 相似不变性:若 $A \sim B $,则 R(A)=R(B)R(A)=R(B)

  3. 初等变换不变性:若 P,QP,Q 可逆,则 R(PAQ)=R(A)R(PAQ)=R(A)

  4. 乘法性质:0R(Am×n)min{m,n}0 \le R(A_{m\times n}) \le \min \{m, n\}

  5. 加法性质:

    加法性质

  6. 压缩性:若 Am×nA_{m\times n} 的秩为 rr,则 AA 一定可以转化为

    [ErOOO]\begin{bmatrix}E_r & O \\O & O\end{bmatrix}

3.3 线性方程组的解

求解策略:利用 矩阵的初等变换矩阵的秩 求解一般的线性方程组

对于 Ax=bAx=b 的线性方程组:

  • 无解的充要条件:R(A)<R(A,b)R(A)<R(A,b)
  • 有唯一解的充要条件:R(A)=R(A,b)=nR(A)=R(A,b)=n
  • 有无限多解的充要条件:R(A)=R(A,b)<nR(A)=R(A,b)<n

求解齐次线性方程组:

  • 化简为行最简 or 行阶梯

求解非齐次线性方程组:

  • 化简为行最简 or 行阶梯

第4章 向量组的线性相关性

4.1 向量组及其线性组合

4.1.1 n维向量的概念

显然的 n>3n>3 的向量没有直观的几何形象,所谓向量组就是由同维度的列(行)向量所组成的集合。

向量组与矩阵的关系:

向量组与矩阵的关系

4.1.2 线性组合和线性表示

定义:

(一)线性组合:

线性组合定义

(二)线性表示:

线性表示定义

判定:转化为判定方程组有解问题,从而转化为求解矩阵的秩的问题 5

  • 判定向量 bb 能否被向量组 AA 线性表示:

    向量被向量组线性表示

  • 判定向量组 BB 能否被向量组 AA 线性表示:

    向量组被向量组线性表示

    该判定定理有以下推论:

    放缩性质

  • 判定向量组 BB向量组 AA 等价:

    向量组与向量组等价

4.2 向量组的线性相关性

定义:

线性相关定义

注意

判定:

  • 定理一:

    定理一

    证明:按照定义,只需要移项 or 同除,进行构造即可

  • 定理二:

    定理二

    证明:按照定义,转化为齐次线性方程组解的问题

    • 有非零解 \Leftrightarrow 无数组解(将解方程取倍数即可),R(A)=R(A,0)<mR(A)=R(A,0)<m
    • 仅有零解 \Leftrightarrow 唯一解,R(A)=R(A,0)=mR(A)=R(A,0)=m

结论:

  • 结论一:

    结论一

    证明:R(A)<mR(B)R(A)+1<m+1R(A)<m \to R(B)\le R(A)+1 <m+1

  • 结论二:

    结论二

    证明:R(Ax×m)=mR(Ab)=mR(A_{x\times m})=m \to R\binom{A}{b}=m

  • 结论三:

    结论三

    证明:R(A)n<mR(A)\le n <m

  • 结论四:

    结论四

    证明:R(A)=m,R(A,b)<m+1Ax=b有唯一解R(A)=m,R(A,b)<m+1 \to Ax=b\text{有唯一解}

    • max{R(A),R(b)}R(A,b)m+1mR(A,b)m+1\max \{ R(A),R(b) \} \le R(A,b) \le m+1 \to m \le R(A,b) \le m+1
    • R(A,b)<m+1R(A,b)<m+1
    • R(A,b)=mR(A,b)=m
    • 因此 R(A)=R(A,b)=m有唯一解R(A)=R(A,b)=m \to \text{有唯一解}

4.3 向量组的秩

4.3.1 最大无关组的定义

定义一:

定义一

注意:

  • 最大无关组之间等价
  • 最大无关组 A0A_0 和原向量组 AA 等价

定义二:

定义二

4.3.2 向量组的秩和矩阵的秩的关系

向量组的秩和矩阵的秩的关系

4.3.3 向量组的秩的结论

向量组的秩的结论 1-2

向量组的秩的结论 3-5

证明:全部可以使用矩阵的秩的性质进行证明

4.4 向量空间

4.4.1 向量空间的概念

可以从高中学到的平面向量以及空间向量入手进行理解,即平面向量就是一个二维向量空间,同理空间向量就是一个三维向量空间,那么次数就是拓展到 n 维向量空间,道理是一样的,只不过超过三维之后就没有直观的效果展示罢了。

4.4.2 向量空间的基与维数

同样可以从高中学到的向量入手,此处的基就是基底,维数就是有几个基底。所有的基之间都是线性无关的,这是显然的。然后整个向量空间中任意一个向量都可以被基线性表示,也就很显然了,此处有三个考点,分别为:

考点一:求解空间中的某向量 x 在基 A 下的坐标

其实就是求解向量 x 在基 A 的各个“轴”上的投影。我们定义列向量 λ\lambda 为向量 x 在基 A 下的坐标,那么就有如下的表述:

x=A λx = A \ \lambda

考点二:求解过度矩阵 P

我们已知一个向量空间中的两个基分别为 A 和 B,若有矩阵 P 满足基变换公式:B=APB = AP,我们就称 P 为从基 A 到基 B 的过渡矩阵

考点三:已知空间中的某向量 x 在基 A 下坐标为 λ\lambda,以及从基 A 到基 B 的过渡矩阵为 P,求解转换基为 B 之后的坐标 γ\gamma

求解过程

4.5 线性方程组的解的结构

本目其实就是 3.3 目的一个知识补充,具体的线性方程组求解方法与 3.3 目几乎完全一致,只不过通过解的结构将解的结构进行了划分从而看似有些不同。但是殊途同归,都是一个东西。下面介绍本目与 3.3 目不同的地方:

我们从 3.3 目可以知道,无论是齐次线性方程组还是非齐次线性方程组,求解步骤都是:将系数矩阵(非齐次就是增广矩阵)进行行等价变换,然后对得到的方程组进行相对应未知变量的赋值即可。区别在于:

非齐次线性方程组的通解=非齐次线性方程组的一个特解+齐次线性方程组的通解\text{非齐次线性方程组的通解}=\text{非齐次线性方程组的一个特解}+\text{齐次线性方程组的通解}

解释:我们将

  • 齐次线性方程组记为 Ax=0Ax=0,解为 η\eta,则有 Aη=0A \eta = 0
  • 非齐次线性方程组记为 Ax=bAx=b,假如其中的一个特解为 η\eta^*,则 Aη=bA\eta^*=b,假如此时我们又计算出了该方程组的其次线性解 η\eta,则有 Aη=0A\eta=0。那么显然有 A(η+η)=bA(\eta^*+\eta)=b,此时 η+η\eta^* + \eta 就是该非齐次线性方程组的通解

也就是说本目对 3.3 目的线性方程组的求解给出了进一步的结构上的解释,即非齐次线性方程组的解的结构是基于本身的一个特解与齐次的通解之上的,仅此而已。当然了,本目在介绍齐次线性方程组解的结构时还引入了一个新的定理:

若矩阵 Am×n 的秩为 r,则该矩阵的解空间的维度(基础解系中线性无关向量的个数)就是 nr,即:\begin{aligned}\text{若矩阵 $A_{m\times n}$ 的秩为 $r$,则该矩阵的解空间的维度(基础解系中线性无关向量的个数)就是 $n-r$,即:}\end{aligned}

dimS=nrdimS = n-r

该定理可以作为一些证明秩相等的证明题的切入点。若想要证明两个$ n$ 元矩阵 AABB 的秩相等,可以转化为证明两个矩阵的基础解析的维度相等,即解空间相等。证明解空间相等进一步转向证明 Ax=0Ax=0Bx=0Bx=0 同解,证明同解就很简单了,就是类似于证明一个充要条件,即证明 Ax=0Bx=0Ax=0 \to Bx=0 以及 Bx=0Ax=0Bx=0 \to Ax=0

第5章 相似矩阵及二次型

5.1 向量的内积、长度及正交性

5.1.1 内积

即各个位置的数依次相乘。运算规律如下:

运算规律

推导全部按照内积的定义来,肥肠煎蛋

5.1.2 长度

类似于模长,有以下性质:

性质

n 维向量与 n 维向量间的夹角:

n 维向量与 n 维向量间的夹角

5.1.3 正交

类似于两个非零向量垂直的关系,即两向量内积为 0。

(一)正交向量组

  • 定义:向量组之间的任意两两向量均正交
  • 性质:正交向量组一定线性无关

(二)标准正交基

  • 定义:是某空间向量的基+正交向量组+每一个向量都是单位向量

  • 求解方法:施密特正交化

    1. 正交化:(其实就是对基础解系的一个线性组合)

      正交化

      正交化

    2. 单位化:

      单位化

5.1.4 正交矩阵与正交变换

正交矩阵:(方阵)

  1. 定义:满足 ATA=E or AAT=EA^TA=E\ \text{or} \ AA^T=E 的矩阵
  2. 定理:正交矩阵的充要条件为矩阵的行(列)向量为单位向量且两两正交

正交变换:

  1. 定义:对于正交矩阵 AAy=Axy=Ax 称为称为正交变换
  2. 性质:y=yTy=xTATAx=xTEx=xTx=x||y||=\sqrt{y^Ty}=\sqrt{x^TA^TAx}=\sqrt{x^TEx}=\sqrt{x^Tx}=||x||,即线段经过正交变换之后长度保持不变

5.2 方阵的特征值与特征向量

5.2.1 定义

对于一个n阶方阵 A,存在一个复数 λ\lambda 和一组n阶非零向量 x 使得

Ax=λxAx=\lambda x

则称 x 为特征向量,λ\lambda 为特征值,AλE|A-\lambda E| 为特征多项式

5.2.2 特征值的性质

性质一

n阶矩阵 A 在复数范围内含有 n 个特征值,且

i=1nλi=i=1naiii=1nλi=A\begin{aligned}\sum_{i=1}^{n} \lambda _i &=& \sum_{i=1}^{n} a_{ii} \\\prod_{i=1}^{n} \lambda _i &=& \left | A \right |\end{aligned}

性质二

λ\lambda 是 A 的特征值,则 ϕ(λ)\phi{(\lambda)}ϕ(A)\phi{(A)} 的特征值

5.2.3 特征向量的性质

对于同一个矩阵,不同的特征值对应的特征向量之间是线性无关

5.3 相似矩阵

5.3.1 定义

对于两个 n 阶方阵 A, B 而言,若存在可逆矩阵 P 使得

PAP1=BPAP^{-1}=B

则称 B 为 A 的相似矩阵,A 与 B 相似,也称对 A 进行相似变换,P 为相似变换矩阵

5.3.2 性质

若矩阵 A 与 B 相似,则 A 与 B 的特征多项式相同,则 A 与 B 的特征值也就相同,A 与 B 的行列式也就相同

5.3.3 矩阵多项式

一个矩阵 A 的多项式 ϕ(A)\phi{(A)} 可以通过其相似矩阵 Λ\Lambda 很轻松地计算出来为 Pϕ(Λ)P1P \phi{(\Lambda)} P^{-1},即对角矩阵左乘一个可逆阵,右乘可逆阵的逆矩阵即可,而对角矩阵的幂运算就是对角元素的幂运算,故而非常方便就可以计算一个矩阵的多项式。那么计算的关键在于如何找到一个矩阵的相似矩阵?下面给出判定一个矩阵是否存在相似矩阵(可对角化)的判定定理:

n 阶方阵可对角化的充要条件为该方阵含有 n 个线性无关的特征向量

5.4 对称矩阵的对角化

本目讨论一个 n 阶方阵具备什么条件才能拥有 n 个线性无关的特征向量,从而可对角化。但是对于一般的方阵,情况过于复杂,此处只讨论 n 阶对称矩阵。即:一个 n 阶对角矩阵具备什么条件才能拥有 n 个线性无关的特征向量,从而可对角化。

答案是 n 阶对角矩阵一定是可对角化的。因为有一个定理是这样的:对于一个对称矩阵 A 而言,一定可以找到一个正交矩阵 P 使得 P1AP=ΛP^{-1}AP=\Lambda,又由于正交矩阵一定是可逆矩阵,因此一定可以找到矩阵 A 的 n 个线性无关的特征向量,从而 A 一定可对角化。

对称矩阵的性质

  1. 对称矩阵的特征值均为实数
  2. 对称矩阵 A 的两个特征值 λ1\lambda _1λ2\lambda _2 对应的两个特征向量分别为 P1P_1P2P_2,若 λ1λ2\lambda_1 \ne \lambda_2,相比于一般的矩阵 P1P_1P2P_2 线性无关,此时两者关系更强,即:P1P_1P2P_2 正交
  3. 对称矩阵的每一个 k 重根,一定对应有 k 个线性无关的特征向量

因此本目相较于 5.3 目其实就是通过可对角化这一个概念,来告诉我们对称矩阵是一定可以求出对角矩阵的。而不用判断当前矩阵是否可对角化了。只不过在此基础之上还附加了一个小定理(也没给出证明),就是对称矩阵的相似变换矩阵一定是一个正交矩阵,那么也就复习回顾了 5.1 目中学到的正交矩阵的概念。为了求解出这个正交矩阵,我们需要在 5.3 目求解特征向量之后再加一个操作,即:对于一个 k 重根,根据上面的性质3我们知道当前的根一定有 k 个线性无关的特征向量,为了凑出最终的正交矩阵,我们需要对这 k 个线性无关的特征向量正交化。那么所有的特征值下的特征向量都正交化之后,又由性质2可知,不同的特征值下的特征向量又是正交的,于是最终的正交的相似变换矩阵也就求出来了,也就得到了对角矩阵 Λ\Lambda

5.5 二次型及其标准型(部分)

本目只需要掌握到:将一个二次型转化为标准型,即可。其实就是比 5.4 目多一个将二次齐次函数的系数取出组成一个二次型的步骤。其中二次型就是一个对称矩阵。接着就是重复 5.4 目中的将对称矩阵转化为对角矩阵的过程了。

对称矩阵和正定性之间的关系

在最优化方法中我们需要通过目标函数海塞矩阵的正定性来判断凸性,显然的海塞矩阵是对称方阵。可以分别从特征值和行列式的角度进行判断。

特征值角度

  • 一个对称矩阵 A 是正定的,当且仅当它的所有特征值 λi>0\lambda_i>0
  • 一个对称矩阵 A 是正半定的,当且仅当它的所有特征值 λi0\lambda_i \ge 0

行列式角度

  • 一个对称矩阵 A 是正定的,当且仅当所有主子矩阵的行列式都大于零
  • 一个对称矩阵 A 是正半定的,当且仅当所有主子矩阵的行列式都大于或等于零
]]>
+ 数据结构课程设计

效果演示

sort 窗口程序

hash 窗口程序

[!note]

以下为课设报告内容

一、必做题

题目:编程实现希尔、快速、堆排序、归并排序算法。要求随机产生 10000 个数据存入磁盘文件,然后读入数据文件,分别采用不同的排序方法进行排序,并将结果存入文件中。

1.1 数据结构设计与算法思想

本程序涉及到四种排序算法,其中:

  • 希尔排序:通过倍增方法逐渐扩大选择插入数据量的规模,而达到减小数据比较的次数,时间复杂度优化到近似 O(n1.65)O(n^{1.65})
  • 快速排序:通过分治的算法策略,不断确定每一个数的最终位置,从而达到近似 O(nlogn)O(n \log n) 的时间复杂度
  • 堆排序:通过堆的树形结构,减小两数的比较次数,最终通过不断维护堆结构来获得有序序列,时间复杂度为 O(nlogn)O(n \log n)
  • 归并排序:通过分治排序回溯时左右两支有序的特点,进行归并,使算法整体的时间复杂度为固定的 O(nlogn)O(n \log n)

1.2 程序结构

为了更好的了解排序算法内部的机制,我统计了每一种排序算法内部的比较次数,并结合了 Qt 的可视化框架进行编写。将四种排序作为算法内核嵌入 GUI 界面,程序结构如下:

sort 程序结构图

UI

1
2
3
4
5
6
7
8
9
10
11
// 窗口 继承窗口库组件
<widget class="QWidget" name="SortWidget">
// 标题 标签组件
<widget class="QLabel" name="titleLabel"></widget>
// 输入 网格布局组件
<widget class="QGridLayout" name="inputGridLayout"></widget>
// 输出 网格布局组件
<widget class="QGridLayout" name="outputGridLayout"></widget>
// 交互 水平布局组件
<widget class="QHBoxLayout" name="buttonHLayout"></widget>
</widget>

Qt Application

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#ifndef DATASTRUCTURECLASSDESIGN_SORTWIDGET_H
#define DATASTRUCTURECLASSDESIGN_SORTWIDGET_H

#include <QWidget>


QT_BEGIN_NAMESPACE
namespace Ui {
class SortWidget;
}
QT_END_NAMESPACE

class SortWidget : public QWidget {
Q_OBJECT

private:
Ui::SortWidget* ui; // 窗口对象指针
QString path; // 文件存储路径

private slots:
void pushFolderButton(); // 槽函数 - 触发事件:获取存储路径的窗口对话
void pushCommitButton(); // 槽函数 - 触发事件:根据输入数据量执行算法
void pushCancelButton(); // 槽函数 - 触发事件:清空窗口所有标签的数据

public:
explicit SortWidget(QWidget* parent = nullptr); // 窗口构造函数

~SortWidget() override; // 窗口析构函数
};

Algorithm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>
#include <vector>
#include <fstream>

class sortAlgorithm {
private:
int Size, Range;
std::string Path;
std::vector<int> arr;

void Generate(int num, int range); // 数据生成

int ShellSort(std::vector<int> a); // 希尔排序
int QuickSort(std::vector<int> a); // 快速排序
int HeapSort(std::vector<int> a); // 堆排序
int MergeSort(std::vector<int> a); // 归并排序

void WriteToFile(std::string path, std::vector<int>& a); // 写入文件

public:
sortAlgorithm(int _Size, int _Range, std::string _Path); // 构造函数

int ShellSort(); // 用户调用希尔排序
int QuickSort(); // 用户调用快速排序
int HeapSort(); // 用户调用堆排序
int MergeSort(); // 用户调用归并排序
};

1.3 实验数据与测试结果分析

原始数据序列生成算法使用时间种子的除留余数法,在数据规模为 1000010000 的情况下,四种算法的比较次数如下:

shellheapquickmerge
112941923182439061120488
212805123220438723120487
313487323194539052120251
413571923209639158120510
512882123205138747120434
average13137623202438948120434

可以发现:在数据规模在 1e41e4 的情况下,堆排序的比较次数最多,而快速排序的比较次数最少

1.4 程序清单

  • /Src/Algorithm/sortAlgorithm.cpp:内核排序算法
  • /Src/Forms/SortWidget.ui:ui 界面
  • /Src/Headers/SortWidget.h:排序组件逻辑头文件
  • /Src/Post/SortWidget.cpp:排序组件逻辑源文件
  • /Src/Test/TestSort.cpp:程序测试文件

其中,除了 ui 界面使用 Qt Designer 设计师工具进行编写,其余文件均为与文件名同名的 C++ 类,便于维护与扩展

二、选做题

题三:扫描一个 C 源程序,利用两种方法统计该源程序中的关键字出现的频度,并比较各自查找的比较次数。

  1. 用 Hash 表存储源程序中出现的关键字,利用 Hash 查找技术统计该程序中的关键字出现的频度。用线性探测法解决 Hash 冲突。设 Hash 函数为 Hash(key) = [(key 的第一个字母序号) × 100 + (key 的最后一个字母序号)] % 41

  2. 用顺序表存储源程序中出现的关键字,利用二分查找技术统计该程序中的关键字出现的频度。

2.1 数据结构设计与算法思想

  • 第一问:
    • 首先用关键字序列构造哈希表,哈希函数使用题中所给,容易算出哈希表的最大容量为 25×100+4025 \times 100+40 ,我们设置为 30003000 ,哈希表数据结构为:int: <string, int> 的键值对形式,可以使用动态数组容器 std::vector<std::pair> 进行存储。其中键设为 int 可以使得对于一个 int 类型的哈希值进行 O(1)O(1) 的存储与查找,获得 <word, cnt>,即 <std::string, int>

    • 然后对于文件中的每一个单词采用哈希搜索。利用哈希函数计算出每一个单词的哈希值,然后通过线性探测法进行搜索比对。关键在于如何解析出一个完整的单词,对于流读入的字符串(不包含空格、换行符、制表符等空白符),我们删除其中的符号后将剩余部分进行合并,之后进行异常处理,排除空串后进行哈希查找。查找次数包含成功和失败的比较次数

  • 第二问:将关键字存储于顺序表中,排序后,利用二分查找技术进行搜索统计。查找次数包含成功和失败的比较次数

2.2 程序结构

hash 程序结构图

UI

1
2
3
4
5
6
7
8
9
10
11
// 窗口 继承窗口库组件
<widget class="QWidget" name="SortWidget">
// 标题 标签组件
<widget class="QLabel" name="titleLabel"></widget>
// 输入 网格布局组件
<widget class="QGridLayout" name="inputGridLayout"></widget>
// 输出 网格布局组件
<widget class="QGridLayout" name="outputGridLayout"></widget>
// 交互 水平布局组件
<widget class="QHBoxLayout" name="buttonHLayout"></widget>
</widget>

Qt Application

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#ifndef DATASTRUCTURECLASSDESIGN_HASHWIDGET_H
#define DATASTRUCTURECLASSDESIGN_HASHWIDGET_H

#include <QWidget>


QT_BEGIN_NAMESPACE
namespace Ui {
class HashWidget;
}
QT_END_NAMESPACE

class HashWidget : public QWidget {
Q_OBJECT

private:
Ui::HashWidget* ui; // 窗口对象指针
QString inputFilePath, outputFolderPath; // 输入输出路径

private slots:
void pushInputFileButton(); // 槽函数 - 触发事件:获取输入文件路径的窗口对话
void pushOutputFolderButton(); // 槽函数 - 触发事件:获取存储文件路径的窗口对话
void pushCommitButton(); // 槽函数 - 触发事件:根据输入的文件开始执行算法
void pushCancelButton(); // 槽函数 - 触发事件:清除当前窗口所有标签的内容

public:
explicit HashWidget(QWidget* parent = nullptr); // 窗口构造函数

~HashWidget() override; // 窗口析构函数

};

#endif //DATASTRUCTURECLASSDESIGN_HASHWIDGET_H

Algorithm

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <iostream>
#include <unordered_map>
#include <fstream>
#include <vector>
#include <algorithm>


class hashAlgorithm {
private:
std::vector<std::string> keywords = {
"auto", "break", "case", "char", "const",
"continue", "default", "do", "double", "else",
"enum", "extern", "float", "for", "goto",
"if", "int", "long", "register", "return",
"short", "signed", "sizeof", "static", "struct",
"switch", "typedef", "union", "unsigned", "void",
"volatile", "while"
};

std::string inputPath, outputPath;
int hashSize;

// std 检验
void writeToFile(std::unordered_map<std::string, int>& keywordCount, std::string path);

// 自定义哈希表检验
void writeToFile(std::vector<std::pair<std::string, int>>& keywordCount, std::string path);

public:
// 构造函数
hashAlgorithm(std::string _inputPath, std::string _outputPath);

void stdHash(); // 标准哈希 - 用于测试检验
int selfHash(); // 自定义哈希
int binaryHash(); // 二分哈希
};

2.3 实验数据与测试结果分析

代码文件从 github 上下载而来,其中 100 行与 500 行的代码文件为完整程序,2500 行代码文件为手动构造程序,三种文件数据规模的查找比较次数如下:

self hashbinary hash
100 lines120775
500 lines154610300
2500 lines556139097

可以发现:使用线性探测法进行哈希的比较次数远小于二分搜索的比较次数

2.4 程序清单

  • /Src/Algorithm/hashAlgorithm.cpp:内核哈希算法
  • /Src/Forms/HashWidget.ui:ui 界面
  • /Src/Headers/HashWidget.h:哈希组件逻辑头文件
  • /Src/Post/HashWidget.cpp:哈希组件逻辑源文件
  • /Src/Test/TestHash.cpp:程序测试文件

其中,除了 ui 界面使用 Qt Designer 设计师工具进行编写,其余文件均为与文件名同名的 C++ 类,便于维护与扩展

三、收获与体会

算法思维。四种排序算法总共也就 100 行,加上堆排序采用树形迭代,快排与归并排序采用递归,整体编码难度并不算高。即使加上了文件 I/O 操作也不过 150 行。而第二道题也是 hash 查找与 binary 查找加上文件 I/O 操作,整体算法难度也十分有限。但是对于我来说也加强了 hash 构建与查找的逻辑。

前后端开发。为了锻炼设计模式思维与软件工程思维,我加入了 Qt 集成的 GUI 界面,不仅体会到了算法对于软件的核心作用所在,也体会到了窗口程序的数据交互与数据接口的开发过程。比如 Qt 中信号与槽的概念,其实就是一种数据交互的 API 格式。同时在体验开发过程的同时,也感受到了用户体验的前端板块,通过使用 Qt Designer 的设计师开发界面,可以通过拖动组件的形式进行可视化设计界面,提高了开发效率,如果可以多人组队,也实现了前后端分离的同步开发模式。

异常处理。与传统 OJ 不同的是,一个软件还需要考虑更多的异常处理,没有标准的 std 数据,加上用户输入、操作的无穷性,注定了软件的异常处理不是一个简单的事,比如在解析用户输入的字符串以及解析用户按钮操作的过程中,需要添加很多的字符串处理结构以及事件触发处理结构,这对于我强化软件开发的健壮性很有帮助。

版本管理。通过 Git 版本管理与 Github 的云端同步功能,也更能体会到开发留痕与 bug 检测的优势。

当然美中不足的也有很多,比如单人全栈开发并不利于锻炼团队协作的能力,同时对于程序的架构设计也没有经过深度考虑,仅仅有界面,数据正常交互就戛然而止。希望在未来的算法与开发路上可以走的更远、更坚定。

仓库地址

github: https://github.com/Explorer-Dong/DataStructureClassDesign

gitee: https://gitee.com/explorer-dong/DataStructureClassDesign

]]>
@@ -1302,18 +1302,18 @@ - solve-clion-decoding-error - - /DevTools/CLion/solve-clion-decoding-error/ + self-config + + /DevTools/DevCpp/devc-self-config/ - CLion 解决中文输出乱码的问题

问题介绍

Clion 的默认设置下,输出中文会出现乱码,如下

1
2
3
4
5
6
7
#include <iostream>
using namespace std;

int main() {
cout << "你好" << endl;
return 0;
}

输出

1
2
3
浣犲ソ

Process finished with exit code 0

解决方案

编码问题就需要修改编码方式,按照下面的流程进行操作即可

进入设置
进入设置

选择 Editor 中的 File Encodings
选择 Editor 中的 File Encodings

将这两个下拉框中的选项全部选择为 UTF-8,点击 OK
将这两个下拉框中的选项全部选择为 UTF-8

在主页面的右下角,将这个选项设置为 GBK
设置为 GBK

选择 Convert
选择 Convert

重新运行即可😎

1
2
你好
Process finished with exit code 0

ENDEND

]]>
+ DevCpp 偏好配置

前言

为了在比赛中不因为编辑器而拖后腿,写一个配置说明。编辑器为 Dev-C++ 5.11

一、编译标准

进入以下目录:

进入以下目录

添加以下编译命令:

添加以下编译命令

二、环境选项

进入以下目录

注意其中的主题和 UI font

三、编辑器选项

进入以下目录:

进入以下目录

取消中括号的配对

四、快捷键选项

进入以下目录:

进入以下目录

设置注释配置:

设置注释配置

]]>
DevTools - CLion + DevCpp @@ -1323,18 +1323,18 @@ - self-config - - /DevTools/DevCpp/devc-self-config/ + solve-clion-decoding-error + + /DevTools/CLion/solve-clion-decoding-error/ - DevCpp 偏好配置

前言

为了在比赛中不因为编辑器而拖后腿,写一个配置说明。编辑器为 Dev-C++ 5.11

一、编译标准

进入以下目录:

进入以下目录

添加以下编译命令:

添加以下编译命令

二、环境选项

进入以下目录

注意其中的主题和 UI font

三、编辑器选项

进入以下目录:

进入以下目录

取消中括号的配对

四、快捷键选项

进入以下目录:

进入以下目录

设置注释配置:

设置注释配置

]]>
+ CLion 解决中文输出乱码的问题

问题介绍

Clion 的默认设置下,输出中文会出现乱码,如下

1
2
3
4
5
6
7
#include <iostream>
using namespace std;

int main() {
cout << "你好" << endl;
return 0;
}

输出

1
2
3
浣犲ソ

Process finished with exit code 0

解决方案

编码问题就需要修改编码方式,按照下面的流程进行操作即可

进入设置
进入设置

选择 Editor 中的 File Encodings
选择 Editor 中的 File Encodings

将这两个下拉框中的选项全部选择为 UTF-8,点击 OK
将这两个下拉框中的选项全部选择为 UTF-8

在主页面的右下角,将这个选项设置为 GBK
设置为 GBK

选择 Convert
选择 Convert

重新运行即可😎

1
2
你好
Process finished with exit code 0

ENDEND

]]>
DevTools - DevCpp + CLion @@ -1422,11 +1422,11 @@ - geometry - - /Algorithm/geometry/ + games + + /Algorithm/games/ - 计算几何

【二维/数学】Minimum Manhattan Distance

https://codeforces.com/gym/104639/problem/J

题意:给定两个圆的直径的两个点坐标,其中约束条件是两个圆一定是处在相离的两个角上。问如何在C2圆上或圆内找到一点p,使得点p到C1圆的所有点的曼哈顿距离的期望值最小

思路:

  • 看似需要积分,其实我们可以发现,对于点p到C1中某个点q1的曼哈顿距离,我们一定可以找到q1关于C1对称的点q2,那么点p到q1和q2的曼哈顿距离之和就是点p到C1的曼哈顿距离的两倍(证明就是中线定理)那么期望的最小值就是点p到C1的曼哈顿距离的最小值。目标转化后,我们开始思考如何计算此目标的最小值,思路如下图

    image-20240116175917260

注意点:

  • double的读取速度很慢,可以用 int or long long 读入,后续强制类型转换(显示 or 和浮点数计算)
  • 注意输出答案的精度控制 cout << fixed << setprecision(10) << res << "\n";
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void solve() {
double x1, y1, x2, y2;
long long a, b, c, d;

cin >> a >> b >> c >> d;
x1 = (a + c) / 2.0;
y1 = (b + d) / 2.0;

cin >> a >> b >> c >> d;
x2 = (a + c) / 2.0;
y2 = (b + d) / 2.0;

double r2 = sqrt((a - c) * (a - c) + (b - d) * (b - d)) / 2;

cout << fixed << setprecision(10) << abs(x1 - x2) + abs(y1 - y2) - sqrt(2) * r2 << "\n";
}

【枚举】三角形

https://www.acwing.com/problem/content/5383/

题意:给定两个直角三角形的两条直角边的长度 a, b,问能不能在坐标轴上找到三个整数点使得三点满足该直角三角形且三遍均不与坐标轴垂直

思路:首先确定两个直角边的顶点为原点 (0, 0),接着根据对称性直接在第一象限中按照边长枚举其中一个顶点 A,对于每一个枚举到的顶点 A,按照斜率枚举最后一个顶点 B,如果满足长度以及不平行于坐标轴的条件就是合法的点。如果全部枚举完都没有找到就是没有合法组合,直接输出 NO 即可。

时间复杂度:O(a2b)O(a^2b)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <bits/stdc++.h>
using namespace std;

#define int long long

int a, b;

void solve() {
cin >> a >> b;

for (int i = 0; i <= a; i++) {
for (int j = 0; j <= a; j++) {
if (i * i + j * j == a * a && i && j) {
int gcd = __gcd(i, j);
int p = -i / gcd, q = j / gcd;
int y = p, x = q;
while (x * x + y * y < b * b) {
x += q, y += p;
}
if (x * x + y * y == b * b && x != i && y != j) {
cout << "YES\n";
cout << 0 << ' ' << 0 << "\n";
cout << i << ' ' << j << "\n";
cout << x << ' ' << y << "\n";
return;
}
}
}
}

cout << "NO\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
//cin >> T;
while (T--) solve();
return 0;
}

【凸包】奶牛过马路

https://www.acwing.com/problem/content/5572/

题意:给定一个二维坐标系,现在有两个东西可以进行平移。

  • 一个是起始位于 (0,0)(0,0) 的奶牛,可以沿着 yy 轴以 [0,u][0,u] 的速度正向或负向移动
  • 一个是起始位于第一象限的凸包形状的车辆,可以向 xx 轴负半轴以恒定的速度 vv 移动

在给定凸包的 nn 个顶点的情况下,以及不允许奶牛被车辆撞到的情况下,奶牛前进到 (0,w)(0,w) 的最短时间

思路:由于是凸包,因此每一个顶点同时满足的性质,就可以代表整个凸包满足的性质。那么对于奶牛和凸包,就一共有三个局面:

  1. 奶牛速度足够快。可以使得对于任意一个顶点 (x,y)(x,y) 在到达 y 轴时,都小于此时奶牛的 y 值,用时间来表示就是 yuxv\frac{y}{u} \le \frac{x}{v},即 yuvxy \le \frac{u}{v}x,表示凸包上所有的顶点都在直线 y=uvy=\frac{u}{v} 的下方
  2. 凸包速度足够快。可以使得对于任意一个顶点 (x,y)(x,y) 在到达 y 轴时,都大于此时奶牛的 y 值,用时间来表示就是 yuxv\frac{y}{u} \ge \frac{x}{v},即 yuvxy \ge \frac{u}{v}x,表示凸包上所有的顶点都在直线 y=uvy=\frac{u}{v} 的上方
  3. 上述两种都不满足,即直线 y=uvy=\frac{u}{v} 与凸包相交。此时奶牛就不能一直全速(u)前进到终点了,需要进行一定的减速 or 返回操作。有一个显然的结论就是,对于减速 or 返回的操作,都可以等价于后退一段距离后再全速(u)前进,因此对于需要减速 or 返回的操作,我们只需要计算出当前状态下应该后退的距离即可。再简洁的,我们就是需要平移直线 y=uvy=\frac{u}{v},显然只能向下平移而非向上平移,因此其实就是计算出直线 y=uv+by=\frac{u}{v}+b 的截距 bb 使得最终的直线 y=uv+by=\frac{u}{v}+b 满足第二种条件即可。那么如何计算这个 b 呢?很显然我们可以遍历每一个顶点取满足所有顶点都在直线 y=uv+by=\frac{u}{v}+b​ 上方的最大截距 b 即可。

注意点:

  • 浮点数比较注意精度误差,尽可能将除法转化为乘法
  • 在比较相等时可以引入一个无穷小量 ϵ=107\epsilon=10^{-7}
  • 注意答案输出精度要求,10610^{-6} 就需要我们出至少 77 位小数

时间复杂度:O(n)O(n)

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
#include <cstring>
#include <algorithm>
#include <iomanip>
using namespace std;
using ll = long long;

int main() {
double n, w, v, u;
cin >> n >> w >> v >> u;

bool up = false, down = false;
double b = 0;

while (n--) {
double x, y;
cin >> x >> y;

// 除法无法通过
// if (y / x < u / v) down = true;
// else if (y / x > u / v) up = true;

// 转化为乘法以后才能通过
if (y < u / v * x) down = true;
else if (y > u / v * x) up = true;

b = min(b, y - u / v * x);
}

if (up && down) cout << setprecision(7) << (w - b) / u;
else cout << setprecision(7) << w / u;

return 0;
}

Py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
read = lambda: map(int, input().split())

n, w, v, u = read()
k = u / v
b = 0
up = False
down = False

for _ in range(n):
x, y = read()
if (y < k * x): down = True
if (y > k * x): up = True
b = min(b, y - k * x)

if (up and down): print((w - b) / u)
else: print(w / u)
]]>
+ 博弈论

思考如何必胜态和必败态是什么以及如何构造这样的局面。

【博弈/贪心/交互】Salyg1n and the MEX Game

https://codeforces.com/contest/1867/problem/C

标签:博弈、贪心、交互

题面:对于给定n个数的数列,先手可以放入一个数列中不存在的数(0-1e9),后手可以从数列中拿掉一个数,但是这个数必须严格小于刚才先手放入的数。终止条件:后手没法拿数或者操作次数达到了 2n+1 次。问:当你是先手时,如何放数可以使得最终数列的 MEX 值最大。

思路:先手每次放入的数一定是当前数列的 MEX 值,此后不管后手拿掉什么数,先手都将刚刚被拿掉的数放进去即可。那么最多操作次数就刚好是 2n+1次,因为加入当前数列就是一个从 0 开始的连续整数数列,那么先手放入的数就是最大数 +1,即 n,那么假如后手从 n-1 开始拿,后手最多拿 n 次,先手再放 n 次,那么就是 2n+1 次。

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <iostream>
#include <vector>

using namespace std;

int main()
{
int T; cin >> T;

while (T--)
{
int n; cin >> n;
vector<int> a(n);

for (int i = 0; i < n; i ++)
cin >> a[i];

int mex = n;
for (int i = 0; i < n; i ++)
if (a[i] != i)
{
mex = i;
break;
}

cout << mex << endl;

int remove;
cin >> remove;

while (remove != -1)
{
cout << remove << endl;
cin >> remove;
}
}

return 0;
}
]]>
@@ -1441,11 +1441,11 @@ - games - - /Algorithm/games/ + graphs + + /Algorithm/graphs/ - 博弈论

思考如何必胜态和必败态是什么以及如何构造这样的局面。

【博弈/贪心/交互】Salyg1n and the MEX Game

https://codeforces.com/contest/1867/problem/C

标签:博弈、贪心、交互

题面:对于给定n个数的数列,先手可以放入一个数列中不存在的数(0-1e9),后手可以从数列中拿掉一个数,但是这个数必须严格小于刚才先手放入的数。终止条件:后手没法拿数或者操作次数达到了 2n+1 次。问:当你是先手时,如何放数可以使得最终数列的 MEX 值最大。

思路:先手每次放入的数一定是当前数列的 MEX 值,此后不管后手拿掉什么数,先手都将刚刚被拿掉的数放进去即可。那么最多操作次数就刚好是 2n+1次,因为加入当前数列就是一个从 0 开始的连续整数数列,那么先手放入的数就是最大数 +1,即 n,那么假如后手从 n-1 开始拿,后手最多拿 n 次,先手再放 n 次,那么就是 2n+1 次。

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <iostream>
#include <vector>

using namespace std;

int main()
{
int T; cin >> T;

while (T--)
{
int n; cin >> n;
vector<int> a(n);

for (int i = 0; i < n; i ++)
cin >> a[i];

int mex = n;
for (int i = 0; i < n; i ++)
if (a[i] != i)
{
mex = i;
break;
}

cout << mex << endl;

int remove;
cin >> remove;

while (remove != -1)
{
cout << remove << endl;
cin >> remove;
}
}

return 0;
}
]]>
+ 图论

【拓扑】有向图的拓扑序列

https://www.acwing.com/problem/content/850/

题意:输出一个图的拓扑序,不存在则输出-1

思路:

  • 首先我们要知道拓扑图的概念,感官上就是一张图可以从一个方向拓展到全图,用数学语言就是:若一个由图中所有点构成的序列 A 满足:对于图中的每条边 (x,y),x 在 A 中都出现在 y 之前,则称 A 是该图的一个拓扑序列
  • 接着我们就想要寻找这样的序列 A 了,可以发现对于每一个可扩展的点,入度一定为0,那么我们就从这些点开始宽搜,将搜到的点的入度-1,即删除这条边,直到最后。如果全图的点的入度都变为了0,则此图可拓扑

时间复杂度:O(n+m)O(n+m)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <bits/stdc++.h>
using namespace std;

const int N = 100010;

int n, m;
vector<int> G[N];

void solve() {
// 建图
cin >> n >> m;
vector<int> d(n + 1, 0);
for (int i = 1; i <= m; i++) {
int a, b;
cin >> a >> b;
d[b]++;
G[a].push_back(b);
}

// 预处理宽搜起始点集
queue<int> q;
for (int i = 1; i <= n; i++)
if (!d[i])
q.push(i);

// 宽搜处理
vector<int> res;
while (q.size()) {
auto h = q.front();
q.pop();
res.push_back(h);

for (auto& ch: G[h]) {
d[ch]--;
if (!d[ch]) q.push(ch);
}
}

// 输出合法拓扑序
if (res.size() == n) {
for (auto& x: res) {
cout << x << " ";
}
} else {
cout << -1 << "\n";
}
}

int main() {
solve();
return 0;
}

【基环树/拓扑】Mad City🔥

https://codeforces.com/contest/1873/problem/H

tag:基环树、拓扑排序

题意:给定一个基环树,现在图上有两个点,分别叫做A,B。现在B想要逃脱A的抓捕,问对于给定的局面,B能否永远逃离A的抓捕

思路:思路很简单,我们只需要分B所在位置的两种情况讨论即可

  1. B不在环上:此时我们记距离B最近的环上的那个点叫 tagtag,我们需要比较的是A到tag点的距离 dAd_A 和B到tag的距离 dBd_B,如果 dB<dAd_B < d_A,则一定可以逃脱,否则一定不可以逃脱
  2. B在环上:此时我们只需要判断当前的A点是否与B的位置重合即可,如果重合那就无法逃脱,反之B一定可以逃脱。

代码实现:

  1. 对于第一种情况,我们需要找到tag点以及计算A和B到tag点的距离,

时间复杂度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
#include <bits/stdc++.h>
using namespace std;

const int N = 200010;

int n, a, b;
vector<int> G[N];
int rd[N], tag, d[N];
bool del[N], vis[N];

void init() {
for (int i = 1; i <= n; i++) {
G[i].clear(); // 存无向图
rd[i] = 0; // 统计每一个结点的入度
del[i] = false; // 拓扑删点删边时使用
d[i] = 0; // 图上所有点到 tag 点的距离
vis[i] = false; // bfs计算距离时使用
}
}

void topu(int now) {
if (rd[now] == 1) {
rd[now]--;
del[now] = true;
for (auto& ch: G[now]) {
if (del[ch]) continue;
rd[ch]--;
if (now == tag) {
tag = ch;
}
topu(ch);
}
}
}

void bfs() {
queue<int> q;
q.push(tag);
d[tag] = 0;

while (q.size()) {
auto now = q.front();
vis[now] = true;
q.pop();

for (auto& ch: G[now]) {
if (!vis[ch]) {
d[ch] = d[now] + 1;
q.push(ch);
vis[ch] = true;
}
}
}
}

void solve() {
// 初始化
cin >> n >> a >> b;
init();

// 建图
for (int i = 1; i <= n; i++) {
int u, v;
cin >> u >> v;
G[u].push_back(v), rd[v]++;
G[v].push_back(u), rd[u]++;
}

// 拓扑删边 & 缩b点
tag = b;
for (int i = 1; i <= n; i++) {
topu(i);
}

// 判断结果 & 计算距离
if (rd[b] == 2 && a != b) {
// b点在环上
cout << "Yes\n";
} else {
// b不在环上
bfs();
cout << (d[a] > d[b] ? "Yes\n" : "No\n");
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(0);
int T = 1;
cin >> T;
while (T--) solve();
return 0;
}

【二分图】染色法判定二分图

https://www.acwing.com/problem/content/862/

题意:给定一个无向图,可能有重边和自环。问是否可以构成二分图。

二分图的定义:一个图可以被分成两个点集,每个点集内部没有边相连(可以不是连通图)

思路:利用染色法,遍历每一个连通分量,选择连通分量中的任意一点进行染色扩展

  • 如果扩展到的点没有染过色,则染成与当前点相对的颜色
  • 如果扩展到的点已经被染过色了且染的颜色和当前点的颜色相同,则无法构成二分图(奇数环)

时间复杂度:O(n+e)O(n+e)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const int N = 100010;

int n, m;
vector<int> G[N], col(N);

bool bfs(int u) {
queue<int> q;
q.push(u);
col[u] = 1;

while (q.size()) {
int now = q.front();
q.pop();
for (auto& ch: G[now]) {
if (!col[ch]) {
col[ch] = -col[now];
q.push(ch);
}
else if (col[ch] == col[now]) {
return false;
}
}
}

return true;
}

void solve() {
cin >> n >> m;
while (m--) {
int u, v;
cin >> u >> v;
G[u].push_back(v);
G[v].push_back(u);
}

// 遍历每一个连通分量
for (int i = 1; i <= n; i++) {
if (!col[i]) {
bool ok = bfs(i);
if (!ok) {
cout << "No\n";
return;
}
}
}

cout << "Yes\n";
}

【最小生成树】Kruskal算法求最小生成树

https://www.acwing.com/problem/content/861/

题意:给定一个无向图,可能含有重边和自环。试判断能否求解其中的最小生成树,如果可以给出最小生成树的权值

思路:根据数据量,可以发现顶点数很大,不适用 PrimPrim 算法,只能用 KruskalKruskal 算法,下面简单介绍一下该算法的流程

  • 自环首先排除 - 显然这条边连接的“两个”顶点是不可能选进 MSTMST
  • 首先将每一个结点看成一个连通分量
  • 接着按照权值将所有的边升序排序后,依次选择
    • 如果选出的这条边的两个顶点不在一个连通分量中,则选择这条边并将两个顶点所在的连通分量合并
    • 如果选出的这条边的两个顶点在同一个连通分量中,则不能选择这条边(否则会使得构造的树形成环)
  • 最后统计选择的边的数量 numnum 进行判断即可
    • num=n1num=n-1,则可以生成最小生成树
    • num<n1num<n-1,则无法生成最小生成树
  • 时间复杂度:O(eloge)O(e\log e)​ - 因为最大的时间开销在对所有的边的权值进行排序上

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 100010;

struct edge {
int a, b;
int w;
};

int n, m;
vector<edge> edges;
vector<int> p(N);

int Find(int now) {
if (p[now] != now) {
p[now] = Find(p[now]);
}
return p[now];
}

void solve() {
cin >> n >> m;
for (int i = 1; i <= m; i++) {
int a, b, w;
cin >> a >> b >> w;
if (a == b) {
continue;
}
edges.push_back({a, b, w});
}

// 按照边权升序排序
sort(edges.begin(), edges.end(), [&](edge& x, edge& y) {
return x.w < y.w;
});

// 选边
for (int i = 1; i <= n; i++) {
p[i] = i;
}

int res = 0, num = 0;

for (auto& e: edges) {
int pa = Find(e.a), pb = Find(e.b);
if (pa != pb) {
num++;
p[pa] = pb;
res += e.w;
}

if (num == n - 1) {
break;
}
}

// 特判:选出来的边数无法构成一棵树
if (num < n - 1) {
cout << "impossible\n";
return;
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def Find(x: int, p: list) -> int:
if p[x] != x: p[x] = Find(p[x], p)
return p[x]

def kruskal(n: int, m: int, edges: list) -> int:
# 按边权对边进行降序排序
edges.sort(key=lambda edge: edge[-1])

# dsu 初始化
p = [None] + [i for i in range(1, n + 1)]

# 选边
cnt = sum = 0
for edge in edges:
if cnt == n - 1: break

pa, pb = Find(edge[0], p), Find(edge[1], p)
if pa != pb:
p[pa] = pb
cnt += 1
sum += edge[2]

return sum if cnt == n - 1 else 0


if __name__ == "__main__":
n, m = map(int, input().split())

edges = []
for i in range(m):
edge = tuple(map(int, input().split()))
edges.append(edge)

res = kruskal(n, m, edges)

if res: print(res)
else: print("impossible")

JavaScript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});

let n = null;
let m = null;
const edges = [];

rl.on('line', line => {
const [a, b, c] = line.split(' ').map(i => Number(i));
if (n === null) {
n = a;
m = b;
} else {
edges.push([a, b, c]);
}
});

rl.on('close', () => {
const res = kurskal(n, m, edges);
console.log(res === Infinity ? 'impossible' : res);
});

function Find(x, p) {
if (p[x] != x) p[x] = Find(p[x], p);
return p[x];
}

function kurskal(n, m, edges) {
// 对边进行升序排序
edges.sort((a, b) => a[2] - b[2]);

// 初始化 dsu
p = [];
for (let i = 1; i <= n; i++) p[i] = i;

// 选边
let cnt = 0, sum = 0;
for (let [a, b, w] of edges) {
if (cnt == n - 1) {
break;
}

let pa = Find(a, p), pb = Find(b, p);
if (pa !== pb) {
cnt++;
p[pa] = pb;
sum += w;
}
}

if (cnt === n - 1) return sum;
else return Infinity;
}

【最小生成树】Prim算法求最小生成树

https://www.acwing.com/problem/content/860/

题意:给定一个稠密无向图,有重边和自环。求出最小生成树

思路:根据题目的数据量,可以使用邻接矩阵存储的方法配合 PrimPrim 算法求解最小生成树,下面给出该算法的流程

  • 首先明确一下变量的定义:
    • g[i][j] 为无向图的邻接矩阵存储结构
    • MST[i] 表示 ii 号点是否加入了 MSTMST 集合
    • d[i] 表示 i 号点到 MSTMST 集合的最短边长度
  • 自环不存储,重边只保留最短的一条
  • 任选一个点到集合 MSTMST 中,并且更新 dd 数组
  • 选择剩余的 n1n-1 个点,每次选择有以下流程
    • 找到最短边,记录最短边长度 ee 和相应的在 UMSTU-MST 集合中对应的顶点序号 vv
    • vv 号点加入 MSTMST 集合,同时根据此时选出的最短边的长度来判断是否存在最小生成树
    • 根据 vv 号点,更新 dd 数组,即更新在集合 UMSTU-MST 中的点到 MSTMST 集合中的点的交叉边的最短长度
  • 时间复杂度:O(n2)O(n^2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 510;

int n, m;
vector<vector<int>> g(N, vector<int>(N, INT_MAX));
vector<int> d(N, INT_MAX); // d[i]表示i号点到MST集合中的最短边长度
bool MST[N];
int res;

void prim() {
// 选任意一个点到MST中并更新d数组
MST[1] = true;
for (int i = 1; i <= n; i++)
if (!MST[i])
d[i] = min(d[i], g[i][1]);

// 选剩下的n-1个点到MST中
for (int i = 2; i <= n; i++) {
// 1. 找到最短边
int e = INT_MAX, v = -1; // e: 最短边长度,v: 最短边不在MST集合中的顶点
for (int j = 1; j <= n; j++)
if (!MST[j] && d[j] < e)
e = d[j], v = j;

// 2. 加入MST集合
MST[v] = true;
if (e == INT_MAX) {
// 特判无法构造MST的情况
cout << "impossible\n";
return;
} else {
res += e;
}

// 3. 更新交叉边 - 迭代(覆盖更新)
for (int j = 1; j <= n; j++)
if (!MST[j])
d[j] = min(d[j], g[j][v]);
}

cout << res << "\n";
}

void solve() {
cin >> n >> m;
while (m--) {
int a, b, w;
cin >> a >> b >> w;

if (a == b) {
continue;
}

if (g[a][b] == INT_MAX) {
g[a][b] = w;
g[b][a] = w;
} else {
g[a][b] = min(g[a][b], w);
g[b][a] = min(g[b][a], w);
}
}

prim();
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【最短路】Dijkstra求最短路 🔥

朴素版 - https://www.acwing.com/problem/content/851/

堆优化 - https://www.acwing.com/problem/content/852/

题意:给定一个正边权的有向图,可能存在重边与自环,问 11 号点到 nn 号点的最短路径长度是多少,如果不可达就输出 1-1

思路一:朴素版。点数 1n5001\le n \le 500,边数 1m1051 \le m\le 10^5

  • 思路:根据数据量,我们采用邻接矩阵的方式存储「点少边多」的稠密图。我们定义 d[i] 数组表示起点到 i 号点的最短距离。先将起点放入 SPT (Shortest Path Tree) 集合,然后更新所有 V-SPT 中的点到 SPT 集合的最短路径长度。接着循环 n-1 次迭代更新剩余的 n-1 个点,每次迭代的过程中,首先选择距离起点最近的点 vex,然后将该点加入 SPT 集合,最后利用该点更新 V-SPT 集合中和该点有连边的点到起点的最短距离。最终的 d[end] 就是起点 start 到终点 end 的最短距离。

  • 总结:算法整体采用贪心与动态规划的思路。与 Prim\text{Prim} 算法仔细比对可知,其中的贪心过程几乎一致,即每次选择加入 SPT 集合的点均为当前局面 V-SPT 集合中距离起点最近的点。而动态规划的过程体现在,在求解出集合 V-SPT 中到集合 STP 最短距离的点 vex 之后,利用该点对「在 V-SPT 集合且和 vex 点有连边的点 i」更新 d[i] 的过程。更新前的状态都是在之前的子结构下的最优解。

  • 时间复杂度:O(n2)O(n^2)

思路二:堆优化。点数 1n1.5×1051\le n \le 1.5 \times 10^5,边数 1m1.5×1051 \le m \le 1.5 \times 10^5

  • 思路:根据数据量,我们采用邻接表的方式存储「点多边少」的稀疏图。如果采用上述朴素 Dijkstra 算法进行求解必然会因为点数过多而超时,因此我们利用数据结构「堆」进行时间开销上的优化。不难发现朴素 Dijkstra 算法在迭代过程中主要有三部分:

    1. 选择距离起点最近的点 vex。因为需要枚举所有的顶点,因此总的时间复杂度为 O(n2)O(n^2)
    2. 将该点加入 SPT 集合。因为只是简单的打个标记,因此总的时间复杂度为 O(n)O(n)
    3. 利用该点更新 V-SPT 集合中和该点相连的点到起点的最短距离。因为此时枚举的是该点所有的连边,而邻接表的图存储方式无法进行重边的删除,因此最坏情况下会枚举所有的边,时间复杂度为 O(m)O(m)
  • 时间复杂度:

朴素版C++:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

int dijkstra_ori(std::vector<std::vector<int>>& g, int start, int end) {
int n = g.size() - 1;
std::vector<int> d(n + 1, INT_MAX >> 1);
std::vector<bool> SPT(n + 1, false);

// update start vex
d[start] = 0;
SPT[start] = true;
for (int i = 1; i <= n; i++) {
if (!SPT[i] && g[start][i] != INT_MAX >> 1) {
d[i] = std::min(d[i], d[start] + g[start][i]);
}
}

// update remain n-1 vex
for (int k = 0; k < n - 1; k++) {
int vex = -1;
for (int i = 1; i <= n; i++) {
if (!SPT[i] && (vex == -1 || d[i] < d[vex])) {
vex = i;
}
}
SPT[vex] = true;
for (int i = 1; i <= n; i++) {
if (!SPT[i] && g[vex][i] != INT_MAX >> 1) {
d[i] = std::min(d[i], d[vex] + g[vex][i]);
}
}
}

return d[end] == INT_MAX >> 1 ? -1 : d[end];
}

void solve() {
int n, m;
cin >> n >> m;

vector<vector<int>> g(n + 1, vector<int>(n + 1, INT_MAX >> 1));

while (m--) {
int u, v, w;
cin >> u >> v >> w;
g[u][v] = min(g[u][v], w);
}

cout << dijkstra_ori(g, 1, n) << "\n";
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
// std::cin >> T;
while (T--) solve();
return 0;
}

朴素版Python:

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import heapq
from collections import defaultdict
from typing import List, Tuple
import math
from itertools import combinations

II = lambda: int(input())
FI = lambda: float(input())
MII = lambda: tuple(map(int, input().split()))
LII = lambda: list(map(int, input().split()))


def dijkstra_ori(g: List[List[int]], start: int, end: int) -> int:
n = len(g) - 1
d = [10 ** 5] * (n + 1)
SPT = [False] * (n + 1)

d[start] = 0
SPT[start] = True
for i in range(1, n + 1):
if not SPT[i] and g[start][i] != 10 ** 5:
d[i] = min(d[i], d[start] + g[start][i])

for _ in range(n - 1):
vex = -1
for i in range(1, n + 1):
if not SPT[i] and (vex == -1 or d[i] < d[vex]):
vex = i
SPT[vex] = True
for i in range(1, n + 1):
if not SPT[i] and g[vex][i] != 10 ** 5:
d[i] = min(d[i], d[vex] + g[vex][i])

return -1 if d[end] == 10 ** 5 else d[end]


def solve() -> None:
n, m = MII()
g = [[10 ** 5] * (n + 1) for _ in range(n + 1)]
for _ in range(m):
u, v, w = MII()
g[u][v] = min(g[u][v], w)
print(dijkstra_ori(g, 1, n))


if __name__ == '__main__':
T = 1
# T = II()
while T: solve(); T -= 1

堆优化版C++:

1
2
3
4
5
6
```

堆优化版Python:

```python

【最短路】Floyd求最短路

https://www.acwing.com/problem/content/856/

题意:给定一个稠密有向图,可能存在重边与自环,给出多个询问,需要给出每一个询问的两个点之前的最短路径长度

思路:我们采用动态规划的思路。在此使用多阶段决策的方法,即每一个路径状态为选择 1k1\to k 个点的情况下的最短路径长度

  • 状态表示:f[k][i][j] 表示在前 kk 个顶点中进行选择(中转),ii 号点到 jj 号点的最短路径长度
  • 状态转移:对于第 kk 个顶点,我们可以选择中转,也可以不中转。
    • 对于不选择中转的情况:f[k][i][j] = f[k-1][i][j]
    • 对于可选择中转的情况:f[k][i][j] = f[k-1][i][k] + f[k-1][k][j]
    • 在其中取最小值即可,但是有一个注意点:对于第二种情况,选择是有一个约束的:即如果选择了 kk 号点进行转移的话,那么 ii 号点到 kk 号点以及 kk 号点到 jj 号点都是需要有路径可达的,从而可以选择最小距离
  • 初始化:即选择 0 个站点进行中转时,即 f[0][i][j] 的情况中,
    • 如果 ii 号点与 jj 号点自环,则取 00
    • 如果 ii 号点与 jj 号点之间有边,则取重边的最小值
    • 如果 ii 号点与 jj 号点之间无边,则初始化为正无穷
  • 答案状态:对于 aa 号点到 bb 号点之间的最小路径长度,就是 f[n][a][b]
  • 时间复杂度:O(n3)O(n^3)
  • 空间复杂度:O(n3)O(n^3)

空间优化推导:我们尝试优化掉记忆数组的第一维度

  • 对于不选择的情况:由于决策局面 kk 是从前往后枚举,故当前状态 f[k][i][j] 可以直接依赖于已经更新出来且不会被当前状态之后的状态再次覆盖的最优子结构 f[i][j]。即上一个局面的选择情况,就是不选择第 kk 个顶点的情况

  • 对于选择的情况:如果删除第一维度,我们担心的是当前状态 f[k][i][j] 依赖的两个状态 f[i][k]f[k][j] 会不会被后续覆盖掉,即我们不确定 f[i][k]f[k][j] 是否是当前第 k 个局面的最优子结构。尝试推导:

    为了确定 f[i][k]f[k][j] 是否是当前第 kk 个局面的最优子结构,其实就是确定对于当前第 kk 个局面,这两个状态会不会在当前状态 f[i][j] 之后被更新覆盖,那么我们就看这两个状态是从哪里转移过来进行更新的。如果 f[i][k]f[k][j] 这两个状态的转移会依赖于当前状态之后的状态,那么删除第一维度就是错误的,反之就是成立的。

    尝试推导 f[i][k]f[k][j] 从何转移更新:利用我们未删除维度时正确的状态转移方程进行推演

    我们知道:f[k][i][k] = min(f[k-1][i][k], f[k-1][i][k] + f[k-1][k][k]),其中的 f[k-1][k][k] 就是一个自环的路径长度,由于 floydfloyd 算法的约束条件是没有负环,因此 f[k-1][k][k] 一定大于零,故 f[k][i][k] 一定取前者,即 f[k][i][k] = f[k-1][i][k]

    同理可知:

    f[k][k][j] = f[k-1][k][j]

    基于上述推导我们可以知道,当前第 kk 个决策局面中的 f[k][i][k]f[k][k][j] 是依赖于上一个决策局面 k1k-1 的,也就是说这两个状态一定是早于当前状态 f[i][j] 被更新覆盖的,故 f[i][k]f[k][j] 就是当前第 kk 个局面的最优子结构,证毕,可以进行维度的删除

  • 时间复杂度:O(n3)O(n^3)

  • 空间复杂度:O(n2)O(n^2)

不优化空间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <bits/stdc++.h>
using namespace std;

const int N = 210, INF = 0x3f3f3f3f;

int n, m, Q;
int f[N][N][N];

int main() {
cin >> n >> m >> Q;

// init
memset(f, INF, sizeof f);

// add edges and generate base
while (m--) {
int a, b, w;
cin >> a >> b >> w;
if (a == b) continue; // 重边就不赋值
else if (f[0][a][b] == INF) f[0][a][b] = w; // 第一次加边则直接赋值
else f[0][a][b] = min(f[0][a][b], w); // 再次赋边权就取最小值
}

// generate base again
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (i == j)
f[0][i][j] = 0; // 自环取边权为 0

// dp
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++) {
// 不选第k个顶点
f[k][i][j] = f[k - 1][i][j];

// 选择第k个顶点
if (f[k - 1][i][k] != INF && f[k - 1][k][j] != INF)
f[k][i][j] = min(f[k][i][j], f[k - 1][i][k] + f[k - 1][k][j]);
}

// query
while (Q--) {
int a, b;
cin >> a >> b;
if (f[n][a][b] == INF) cout << "impossible\n";
else cout << f[n][a][b] << "\n";
}

return 0;
}

优化空间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <bits/stdc++.h>
using namespace std;

const int N = 210, INF = 0x3f3f3f3f;

int n, m, Q;
int f[N][N];

int main() {
cin >> n >> m >> Q;

// init
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (i == j) f[i][j] = 0;
else f[i][j] = INF;

// base
while (m--) {
int a, b, w;
cin >> a >> b >> w;
if (a == b) continue;
else if (f[a][b] == INF) f[a][b] = w;
else f[a][b] = min(f[a][b], w);
}

// dp
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (f[i][k] != INF && f[k][j] != INF)
f[i][j] = min(f[i][j], f[i][k] + f[k][j]);

// query
while (Q--) {
int a, b;
cin >> a >> b;
if (f[a][b] == INF) cout << "impossible\n";
else cout << f[a][b] << "\n";
}

return 0;
}

【最短路】关闭分部的可行集合数目

https://leetcode.cn/problems/number-of-possible-sets-of-closing-branches/

标签:二进制枚举、最短路

题意:给定一个含有 nn 个顶点的无向图,如何删点可以使得剩余的图中顶点两两可达且最大距离不超过 maxDistance?返回所有删点的方案数。

思路:由于 nn 的数据范围只有 1101 \to 10,我们可以直接枚举所有的删点方案。那么如何检查一个方案的合法性呢?直接使用最短路算法检查「所有顶点到每一个顶点」的最远距离即可。这里我们采用朴素 dijkstra 算法。

时间复杂度:O(2n×n3)O(2^n \times n^3) - 其中枚举需要 O(2n)O(2^n)、计算所有顶点到某个顶点的最远距离需要 O(n2)O(n^2)、检查所有顶点需要 O(n)O(n)

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
class Solution {
public:
int numberOfSets(int n, int maxDistance, vector<vector<int>>& roads) {
vector<vector<int>> g(n, vector<int>(n, INT_MAX >> 1));
for (auto& r: roads) {
int u = r[0], v = r[1], w = r[2];
g[u][v] = g[v][u] = min(g[u][v], w);
}

auto get_max_dist = [&](int mask, int v) {
vector<bool> SPT(n);
vector<int> d(n, INT_MAX);

d[v] = 0;
SPT[v] = true;

int cnt = 0;
for (int i = 0; i < n; i++) {
if (mask & (1 << i) && !SPT[i]) {
cnt++;
d[i] = min(d[i], d[v] + g[v][i]);
}
}

for (int k = 1; k <= cnt - 1; k++) {
int vex = -1;
for (int i = 0; i < n; i++) {
if (mask & (1 << i) && !SPT[i] && (vex == -1 || d[i] < d[vex])) {
vex = i;
}
}
SPT[vex] = true;
for (int i = 0; i < n; i++) {
if (mask & (1 << i) && !SPT[i]) {
d[i] = min(d[i], d[vex] + g[vex][i]);
}
}
}

int max_dist = -1;
for (int i = 0; i < n; i++) {
if (mask & (1 << i)) {
max_dist = max(max_dist, d[i]);
}
}

return max_dist;
};

int res = 0;
for (int mask = 0; mask < 1 << n; mask++) {
bool ok = true;
for (int i = 0; i < n; i++) {
if (mask & (1 << i) && get_max_dist(mask, i) > maxDistance) {
ok = false;
break;
}
}
res += ok;
}

return res;
}
};
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class Solution:
def numberOfSets(self, n: int, maxDistance: int, roads: List[List[int]]) -> int:
g = [[10 ** 6 for _ in range(n)] for _ in range(n)]
for u, v, w in roads:
g[u][v] = g[v][u] = min(g[u][v], w)

def get_max_dist(mask: int, v: int):
SPT = [False for _ in range(n)]
d = [10 ** 6 for _ in range(n)]

SPT[v] = True
d[v] = 0

cnt = 0
for i in range(n):
if mask & (1 << i) and not SPT[i]:
cnt += 1
d[i] = min(d[i], d[v] + g[v][i])

for _ in range(cnt - 1):
vex = -1
for i in range(n):
if mask & (1 << i) and not SPT[i] and (vex == -1 or d[i] < d[vex]):
vex = i
SPT[vex] = True
for i in range(n):
if mask & (1 << i) and not SPT[i]:
d[i] = min(d[i], d[vex] + g[vex][i])

max_dist = -1
for i in range(n):
if mask & (1 << i):
max_dist = max(max_dist, d[i])

return max_dist

res = 0
for mask in range(1 << n):
ok = True
for i in range(n):
if mask & (1 << i) and get_max_dist(mask, i) > maxDistance:
ok = False
break
res += ok

return res

【思维/遍历】图的遍历

https://www.luogu.com.cn/problem/P3916

题意:给定一个有向图,求解每一个点可以到达的编号最大的点

思路:如果从正向考虑,很显然的一个暴力方法就是对于每一个点都跑一遍 dfs 或者 bfs 获取可达的最大点编号,时间复杂度 O(n2)O(n^2),如果想要在遍历的过程中同时更新其余的点,那只有起点到最大点之间的点可以被更新,可以通过递归时记录路径点进行,时间复杂度几乎不变。我们尝试反向考虑:反向建边。既然正向考虑时需要标记的点为最大点与起点的路径,那不如直接从最大值点开始遍历搜索,在将所有的边全部反向以后,从最大值点开始遍历图,这样就可以在线性时间复杂度内解决问题

时间复杂度:O(n+m)O(n+m)

bfs 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <iostream>
#include <queue>
#include <cstring>
#include <vector>
using namespace std;

const int N = 100010;

int n, m;
vector<int> g[N], res(N);

void bfs(int now) {
queue<int> q;

res[now] = now;
q.push(now);

while (q.size()) {
int h = q.front();
q.pop();
for (auto& ch: g[h]) {
if (!res[ch]) {
res[ch] = now;
q.push(ch);
}
}
}
}

void solve() {
cin >> n >> m;
while (m--) {
int a, b;
cin >> a >> b;
g[b].push_back(a);
}

for (int i = n; i >= 1; i--) {
if (!res[i]) {
bfs(i);
}
}

for (int i = 1; i <= n; i++) {
cout << res[i] << ' ';
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

dfs 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <iostream>
#include <queue>
#include <cstring>
#include <vector>
using namespace std;

const int N = 100010;

int n, m, val;
vector<int> g[N], res(N);

void dfs(int now) {
res[now] = val;
for (auto& ch: g[now]) {
if (!res[ch]) {
dfs(ch);
}
}
}

void solve() {
cin >> n >> m;
while (m--) {
int a, b;
cin >> a >> b;
g[b].push_back(a);
}

for (int i = n; i >= 1; i--) {
if (!res[i]) {
val = i;
dfs(i);
}
}

for (int i = 1; i <= n; i++) {
cout << res[i] << ' ';
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【遍历/并查集】孤立点数量

https://www.acwing.com/problem/content/5560/

题意:给定一个无向图,可能不连通,没有重边和自环。现在需要给图中的每一条无向边定向,要求所有的边定完向以后 0 入度的点尽可能的少,给出最少的 0 入度点的数量

思路:我们知道对于一棵树而言,n 个结点一共有 n-1 条边,也就可以贡献 n-1 个入度,因此至少有一个点的入度为 0。而如果不是一棵树,就会有至少 n 条边,也就至少可以贡献 n 个入度,那么 n 个结点就至少全都有入度了。显然的,一个图至少含有 n 条边时就一定有环。有了上述思路以后就可以发现,这道题本质上就是在判断连通分量是否含有环,如果有环,那么该连通分量定向边以后就不会产生 0 入度的顶点,反之,如果没有环,那么定向边以后最少产生 1 个 0 入度的点。

  • 算法一:遍历图。我们采用 dfs 遍历的方式即可解决问题。一边遍历一边打标记,遇到已经打过标记的非父结点就表明当前连通分量有环。我们使用 C++ 实现

    时间复杂度:O(n+m)O(n+m)

  • 算法二:并查集。由于没有重边,因此在判断每一个连通分量是否含有环时,可以直接通过该连通分量中点和边的数量关系得到结果。我们使用 Python 和 JavaScript 实现

    时间复杂度:O(n)O(n)

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 实现 dfs 算法

#include <iostream>
#include <cstring>
#include <vector>
#include <queue>
#include <stack>
#include <algorithm>
#include <unordered_map>
#include <set>
using namespace std;

const int N = 100010;

int n, m;
vector<int> G[N];
bool vis[N];

void dfs(int fa, int now, bool& hasLoop) {
vis[now] = true;
for (auto& ch: G[now]) {
if (ch != fa) {
if (vis[ch]) hasLoop = true;
else dfs(now, ch, hasLoop);
}
}
}

void solve() {
cin >> n >> m;
while (m--) {
int a, b;
cin >> a >> b;
G[a].push_back(b);
G[b].push_back(a);
}

int res = 0;

for (int i = 1; i <= n; i++) {
if (!vis[i]) {
bool hasLoop = false;
dfs(-1, i, hasLoop);
if (!hasLoop) res++;
}
}

cout << res << "\n";
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# 实现 dsu 算法

p = [_ for _ in range(100010)]


def Find(x: int) -> int:
if x != p[x]: p[x] = Find(p[x])
return p[x]


def solve() -> None:
n, m = map(int, input().split())

edgeNum = [0] * (n + 1) # 每个点的连边数

for _ in range(m):
u, v = map(int, input().split())
edgeNum[u] += 1
edgeNum[v] += 1
p[Find(u)] = Find(v)

union = {}

class node:
def __init__(self):
self.v = self.e = 0

for i in range(1, n + 1):
nowp = Find(i)
if nowp not in union: union[nowp] = node()

union[nowp].v += 1
union[nowp].e += edgeNum[i]

res = 0

for comp in union:
if union[comp].e >> 1 == union[comp].v - 1:
res += 1

print(res)


if __name__ == "__main__":
solve()

JavaScript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// 实现 dsu 算法

const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});

let n = null;
let m = null;
let p = [], edgeNum = [];

rl.on('line', line => {
const [a, b] = line.split(' ').map(i => Number(i));
if (n === null) {
n = a;
m = b;
for (let i = 1; i <= n; i++) {
p[i] = i;
edgeNum[i] = 0;
}
} else {
edgeNum[a]++;
edgeNum[b]++;
p[Find(a)] = Find(b);
}
});

rl.on('close', () => {
const res = solve();
console.log(res);
});

function Find(x) {
if (x != p[x]) p[x] = Find(p[x]);
return p[x];
}

function solve() {
let res = 0;

// 自定义结构体
class Node {
constructor() {
this.v = 0;
this.e = 0;
}
}

/*
另一种结构体定义方法
function Node() {
this.v = 0;
this.e = 0;
}
*/

// 哈希
let union = new Map();

for (let i = 1; i <= n; i++) {
let nowp = Find(i); // 当前结点的祖宗结点 nowp
if (!union.has(nowp)) union.set(nowp, new Node());

union.get(nowp).v += 1;
union.get(nowp).e += edgeNum[i];
}

// 判断
for (let i of union.keys()) {
if (union.get(i).e >> 1 === union.get(i).v - 1) {
res++;
}
}

return res;
}

【LCA】树的直径

https://www.acwing.com/problem/content/5563/

题意:给定一棵树,初始时含有 4 个结点分别为 1 到 4,其中 1 号为根结点,2 到 4 均为根结点的叶子结点。现在进行 Q 次操作,每次指定一个已经存在的结点向其插入两个新结点作为叶节点。现在需要在每次操作以后输出这棵树的直径。我们定义树的直径为:树中距离最远的两个点之间的距离。

思路一:暴力搜索。

  • 我们将树重构为无向图,对于每一个状态的无向图,首先从任意一个已存在的结点 A 开始搜索到距离他最远的点 B,然后从 B 点出发搜索到离他最远的点 C,则 B 与 C 之间的距离就是当前状态的树的直径。由于每一个状态的树都要遍历两遍树,于是时间复杂度就是平方阶

  • 时间复杂度:O(qn)O(qn)

思路二:最近公共祖先 LCA。

  • 从树的直径出发。我们知道,树的直径由直径的两个结点之间的距离决定,因此我们着眼于这两个结点 AABB 展开。不妨设当前局面直径的两个结点已知为 AABB,现在插入两个叶子结点 L1L_1L2L_2。是否改变了树的直径大小取决于新插入的两个结点对于当前树的影响情况。如果 L1L_1L2L_2 可以替代 AABB,则树的直径就会改变。很显然新插入的两个叶子结点对于直径的两个端点影响是同效果的,因此我们统称新插入的叶子结点为 LL

  • 什么时候树的直径会改变?对于 AABBLL 来说,直径是否改变取决于 LL 能否替代 AABB,一共有六种情况。我们记 dist(A,L)=da\text{dist}(A,L)=dadist(B,L)=db\text{dist}(B,L)=db,当前树的直径为 resres,六种情况如下:

    1. max(da,db)res\text{max}(da, db) \le \text{res},交换 AABB 得到 22
    2. min(da,db)res\text{min}(da,db) \ge \text{res},交换 AABB 得到 22
    3. max(da,db)>res,min(da,db)<res\text{max}(da,db) >res,\text{min}(da,db) < \text{res},交换 AABB 得到 22​ 种

    如图:我们只需要在其中的最大值严格超过当前树的直径 res\text{res} 时更新直径对应的结点以及直径的长度即可

    六种情况

  • 如何快速计算树上任意两个点之间的距离?我们可以使用最近公共祖先 LCA 算法。则树上任意两点 x,yx,y 之间的距离 dist(x,y)\text{dist}(x,y) 为:

    dist(x,y)=dist(x,root)+dist(y,root)2×dist(lca(x,y),root)\text{dist}(x,y) = \text{dist}(x,root) + \text{dist}(y,root) - 2 \times \text{dist}(\text{lca}(x,y),root)

  • 时间复杂度:O(qlogn)O(q \log n)

暴力搜索代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#include <iostream>
#include <cstring>
#include <vector>
#include <queue>
#include <stack>
#include <algorithm>
#include <unordered_map>
#include <set>
using namespace std;

const int N = 500010;

vector<int> g[N];
int d[N];
bool vis[N];
pair<int, int> res; // first 为最远距离;second 为对应结点编号

void dfs(int pre, int now) {
if (vis[now]) return;

vis[now] = true;

if (pre != -1) {
d[now] = d[pre] + 1;
if (d[now] > res.first) {
res = {d[now], now};
}
}

for (auto& ch: g[now]) {
dfs(now, ch);
}
}

void solve() {
// init
for (int i = 2; i <= 4; i++) {
g[1].push_back(i);
g[i].push_back(1);
}

int now = 4;

int Q;
cin >> Q;
while (Q--) {
int id;
cin >> id;

g[id].push_back(++now);
g[now].push_back(id);

g[id].push_back(++now);
g[now].push_back(id);

res = {-1, -1};

// 第一趟
memset(vis, false, sizeof vis);
memset(d, 0, sizeof d);
d[1] = 0;
dfs(-1, 1);

// 第二趟
memset(vis, false, sizeof vis);
memset(d, 0, sizeof d);
d[res.second] = 0;
dfs(-1, res.second);

cout << res.first << "\n";
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

LCA 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include <iostream>
#include <cstring>
#include <vector>
#include <queue>
#include <stack>
#include <algorithm>
#include <unordered_map>
#include <set>
using namespace std;

const int N = 1000010, M = 20;

int d[N]; // d[i] 表示 i 号点到根结点的距离
int to[N][M]; // to[i][j] 表示 i 号点向上跳 2^j 步后到达的结点编号

int lca(int a, int b) {
if (d[a] < d[b]) swap(a, b);

for (int k = M - 1; k >= 0; k--)
if (d[to[a][k]] >= d[b])
a = to[a][k];

if (a == b) return a;

for (int k = M - 1; k >= 0; k--)
if (to[a][k] != to[b][k])
a = to[a][k], b = to[b][k];

return to[a][0];
}

int dist(int a, int b) {
return d[a] + d[b] - 2 * d[lca(a, b)];
}

void solve() {
int Q;
cin >> Q;

// init lca
for (int i = 2; i <= 4; i++) {
d[i] = 1;
to[i][0] = 1;
}

int A = 2, B = 4, now = 4, res = 2;

while (Q--) {
int fa;
cin >> fa;

int L1 = ++now, L2 = ++now;

// upd lca
d[L1] = d[fa] + 1;
d[L2] = d[fa] + 1;
to[L1][0] = fa;
to[L2][0] = fa;
for (int k = 1; k <= M - 1; k++) {
to[L1][k] = to[ to[L1][k-1] ][ k-1 ];
to[L2][k] = to[ to[L2][k-1] ][ k-1 ];
}

int da = dist(A, L1), db = dist(B, L1);

if (max(da, db) <= res) res = res;
else if (min(da, db) >= res) {
if (da > db) res = da, B = L1;
else res = db, A = L1;
} else {
if (da > db) res = da, B = L1;
else res = db, A = L1;
}

cout << res << "\n";
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}
]]>
@@ -1460,11 +1460,11 @@ - hashing - - /Algorithm/hashing/ + greedy + + /Algorithm/greedy/ - 哈希

【哈希】分组

https://www.acwing.com/problem/content/5182/

存储不想同组和想同组的人员信息:存入数组,数据类型为一对字符串

存储所有的组队信息:存入哈希表,数据类型为“键:字符串”“值:一对字符串”

想要知道最终的分组情况,只需要查询数组中的队员情况与想同组 or 不想同组的成员名字是否一致即可

时间复杂度 O(n)O(n),空间复杂度 O(n len(name))maxO(n\ len(name))_{max}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <iostream>
#include <vector>
#include <unordered_map>
using namespace std;

int main()
{
int x;
cin >> x;

vector<pair<string, string>> X(x);

for (int i = 0; i < x; i ++)
cin >> X[i].first >> X[i].second;

int y;
cin >> y;

vector<pair<string, string>> Y(y);

for (int i = 0; i < y; i ++)
cin >> Y[i].first >> Y[i].second;

int sum;
cin >> sum;

unordered_map<string, pair<string, string>> a;

for (int i = 0; i < sum; i ++)
{
string s, t, p;
cin >> s >> t >> p;
a[s] = {t, p};
a[t] = {s, p};
a[p] = {s, t};
}

int res = 0;

// 想同组
for (int i = 0; i < x; i ++)
{
string s = X[i].first, t = X[i].second;
if (a[s].first != t && a[s].second != t)
res ++;
}

// 不想同组
for (int i = 0; i < y; i ++)
{
string s = Y[i].first, t = Y[i].second;
if (a[s].first == t || a[s].second == t)
res ++;
}

cout << res << endl;

return 0;
}

【哈希】海港

https://www.luogu.com.cn/problem/P2058

  • 题意:给定 n 艘船只的到达时间、载客信息(载客人数和每一个客人的国籍),现在需要知道对于每一艘抵达的船只,前 24 小时中抵达的客人的国籍总数
  • 思路:本题思路很简单,就是一个队列的应用以及哈希客人国籍的过程。由于船只抵达的时间是顺序增加的,故每抵达一艘船只,就对新来的客人国籍进行哈希,为了计算前 24 小时的情况,需要对船只抵达队列进行删减,即只保留 24 小时以内的船只抵达信息。对于删除的船只信息,需要将这些船只上的客人国籍信息从哈希表中删除,故每一艘船只的访问次数为 2。
  • unordered_map 补充:在进行哈希统计时。为了判断当前 24 小时内客人国籍数,在删除哈希记录时,为了判断是否将当前国籍的游客全部删除时,需要统计哈希表中某个国籍是否减为了 0,我用了 .count(x) 内置方法,但这是不正确的,因为我想要统计的是 值 是否为 0,而 .count(x) 统计的是哈希表中 x 这个 键 的个数,而 unordered_map 中是没有重复的键的,故 .count(x) 方法只会返回 0 或 1,返回 0 就表示当前哈希表中没有 x 这个键,返回 1 就表示哈希表中有 x 这个键,但是有这个键不代表对应的值就存在,可能是 x: 0 的情况,即键存在,但是值记录为 0
  • 时间复杂度:O(2xi)O(2 \sum x_i) - 即两倍的所有游客数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// #include <bits/stdc++.h>
// #define int long long
#include <iostream>
#include <unordered_map>
#include <stack>
#include <queue>
using namespace std;

const int N = 1e5 + 10;

struct Ship { int idx, t; };

int n;
queue<Ship> q;
vector<int> G[N];
unordered_map<int, int> cnt;
int kind;

void solve() {
cin >> n;

for (int i = 1; i <= n; i++) {
int t, num;
cin >> t >> num;

q.push({i, t});

// 哈希
while (num--) {
int id;
cin >> id;

if (!cnt[id]) kind++;
cnt[id]++;
G[i].push_back(id);
}

// 去哈希
Ship h = q.front();
while (t - h.t >= 86400) {
for (auto& id: G[h.idx]) {
cnt[id]--;
if (!cnt[id]) kind--;
}
q.pop();
h = q.front();
}

cout << kind << "\n";
}
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【哈希】Cities and States S

https://www.luogu.com.cn/problem/P3405

题意:给定 n 个字符串,每一个字符串归属一个集合,现在需要统计字符串与集合名相反相等的对数

思路:很显然的哈希计数。难点有两个,如何哈希?如何计数?哈希可以采用扩展字符的方法进行,即第一个字符乘某一个较大的质数再加上第二个字符。此处采用一种较为巧妙的方法,直接将两个字符串与集合名加起来进行唯一性哈希,降低编码难度。计数有两种方式,第一种就是全部哈希结束之后,再遍历哈希表进行统计,最后将结果除二即可。第二种就是边哈希边计数,遇到相反相等的就直接计数,这样就不会重复计数了,也很巧妙

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
#include <algorithm>
#include <unordered_map>
#include <stack>
#include <queue>
#include <set>
using namespace std;

int n;
unordered_map<string, int> a;

void solve() {
cin >> n;

int res = 0;

while (n--) {
string s, t;
cin >> s >> t;
s = s.substr(0, 2);
res += a[t + " " + s] * (s != t);
a[s + " " + t]++;
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【哈希/枚举/思维】Torn Lucky Ticket

https://codeforces.com/contest/1895/problem/C

题意: 给定一个长度为 n 的字符串数组 nums, 数组的每一个元素长度不超过 5 且仅由数字组成. 问能找到多少对 (i,j)(i,j) 可以使得拼接后的 nums[i]+nums[j]nums[i]+nums[j] 长度为偶数且左半部分的数字之和与右半部分的数字之和相等.

思路:

  • 首先最暴力的做法就是 O(n2)O(n^2) 枚举,所有的 (i,j)(i,j) 然后 check 合法性.

  • 尝试优化掉第二层的枚举循环. 对于第二层循环, 我们就是要寻找合适的 nums[j]nums[j] 并和当前的 nums[i]nums[i] 拼接. 显然我们可以通过扫描当前的 nums[i]nums[i]O(1)O(1) 的计算出所有 len(nums[j])len(nums[i])len(nums[j])\le len(nums[i]) 且可以和 nums[i]nums[i] 匹配的字符串的 [长度][数字和] 信息, 只需要一个二维数组预存储每一个字符串的 长度 数字和 信息即可.

  • 那么对于 len(nums[j])>len(nums[i])len(nums[j])> len(nums[i]) 的情况如何统计呢. 显然此时我们没法 O(1)O(1) 的检查 nums[i]+nums[j]nums[i]+nums[j] 的合法性. 不妨换一个角度, 当我们枚举 nums[i]nums[i] 时:

    统计右侧拼接长度更大的 nums[j] 的合法情况数    统计左侧拼接长度更小的 nums[j] 的合法情况数\text{统计右侧拼接长度更大的 nums[j] 的合法情况数} \iff \text{统计左侧拼接长度更小的 nums[j] 的合法情况数}

    于是合法情况数就可以表示为 i=0n1[cond1(nums[i])+cond2(nums[i])]\displaystyle \sum_{i=0}^{n-1}\big[\text{cond}_1(nums[i])+\text{cond}_2(nums[i])\big], 其中第一种情况 cond1\text{cond}_1 就是统计右侧拼接长度更小的字符串数量, 第二种情况 cond1\text{cond}_1 就是统计左侧拼接长度更小的字符串数量. 这两步可以同时计算.

时间复杂度: O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
from typing import List, Tuple, Dict, Optional
from collections import defaultdict, deque
from itertools import combinations, permutations
import math, heapq, queue

II = lambda: int(input())
FI = lambda: float(input())
MII = lambda: tuple(map(int, input().split()))
LII = lambda: list(map(int, input().split()))
LSI = lambda: list(map(str, input().split()))

def solve() -> Optional:
n, nums = II(), LSI()

f = [[0 for _ in range(46)] for _ in range(6)]
for num in nums:
m = len(num)
s = sum([int(c) for c in num])
f[m][s] += 1

res = 0
for num in nums:
m = len(num)
s = [0] * (m + 1)
for i in range(m - 1, -1, -1):
s[i] = s[i + 1] + int(num[i])

# cond1: now + right -> len(now) >= len(right)
for i in range(m - 1, -1, -1):
now_len, now_sum = i + 1, s[0] - s[i + 1]
r_len, r_sum = now_len - (m - 1 - i), now_sum - s[i + 1]
if 1 <= r_len <= now_len and r_sum >= 0:
res += f[r_len][r_sum]

# cond2: left + now -> len(left) < len(now)
for i in range(m):
now_len, now_sum = m - i, s[i]
l_len, l_sum = now_len - i, now_sum - (s[0] - s[i])
if 1 <= l_len < now_len and l_sum >= 0:
res += f[l_len][l_sum]

return res

if __name__ == '__main__':
OUTs = []
N = 1
# N = II()
for _ in range(N):
OUTs.append(solve())
print('\n'.join(map(str, OUTs)))
]]>
+ 贪心

大胆猜测,小心求证(不会证也没事,做下一题吧)。证明方法总结了以下几种

  • 反证法:假设取一种方案比贪心方案更好,得出相反的结论
  • 边界法:从边界开始考虑,因为满足边界条件更加容易枚举,从而进行后续的贪心
  • 直觉法:遵循社会法则()

1. green_gold_dog, array and permutation

https://codeforces.com/contest/1867/problem/A

题意:给定n个数a[i],其中可能有重复数,请构造一个n个数的排列b[i],使得c[i]=a[i]-b[i]中,c[i]的不同数字的个数最多

思路:思路比较好想,就是最大的数匹配最小的数,次大的数匹配次小的数,以此类推。起初我想通过将原数列的拷贝数列降序排序后,创建一个哈希表来记录每一个数的排位,最终通过原数列的数作为键,通过哈希表直接输出排位,但是问题是原数列中的数可能会有重复的,那么在哈希的时候如果有重复的数,那么后来再次出现的数就会顶替掉原来的数的排位,故错误。

正确思路:为了保证原数列中每个数的唯一性,我们可以给原数列每一个数赋一个下标,排序后以下标进行唯一的一一对应的索引。那么就是:首先赋下标,接着以第一关键词进行排序,然后最大的数(其实是下标)匹配最小的结果以此类推

模拟一个样例:
5
8 7 4 5 5 9

最终的答案应该是
2 3 6 4 5 1

首先对每一个数赋予一个下标作为为唯一性索引
8 7 4 5 5 9
1 2 3 4 5 6(替身)

接着将上述数列按照第一关键词进行排序
9 8 7 5 5 4
6 1 2 4 5 3(替身)

对每一个数进行赋值匹配
9 8 7 5 5 4
6 1 2 4 5 3(替身)
1 2 3 4 5 6(想要输出的结果)

按照替身进行排序
8 7 4 5 5 9
1 2 3 4 5 6(替身)(排序后)
2 3 6 4 5 1(想要输出的结果)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <bits/stdc++.h>
using namespace std;

void solve()
{
int n; cin >> n;

// 第一关键词是原数列,第二关键词是赋予的唯一下标
vector<pair<int, int>> a(n + 1);
for (int i = 1; i <= n; i ++)
{
cin >> a[i].first;
a[i].second = i;
}

// 按照第一关键词排序
sort(a.begin() + 1, a.end(), [&](pair<int, int> x, pair<int, int> y) {
return x.first > y.first;
});

// 以下标作为原数列的替身,每一个替身对应一个升序的最终排名
vector<pair<int, int>> res(n + 1);
for (int i = 1; i <= n; i ++)
{
res[i].first = a[i].second;
res[i].second = i;
}

// 通过下标,还原原数列的排序
sort(res.begin() + 1, res.end());

// 输出第二关键词
for (int i = 1; i <= n; i ++)
cout << res[i].second << " \n"[i == n];
}

int main()
{
int T; cin >> T;
while (T --) solve();
return 0;
}

2. Good Kid

https://codeforces.com/contest/1873/problem/B

题意:对于一个数列,现在可以选择其中的一个数使其+1,问如何选择这个数,可以使得修改后的数列中的所有数之积的值最大

思路:其实就是选择n-1个数的乘积值加一倍,关键在于选哪一个n-1个数的乘积值,逆向思维就是对于n个数,去掉那个最小值,那么剩下来的n-1个数之积就会最大了,于是就是选择最小的数+1,最终数列之积就是答案了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

void solve()
{
int n; cin >> n;
vector<int> a(n);

for (int i = 0; i < n; i ++)
cin >> a[i];

sort(a.begin(), a.end());

ll res = a[0] + 1;
for (int i = 1; i < n; i ++)
res *= a[i];
cout << res << endl;
}

int main()
{
int T; cin >> T;
while (T --) solve();
return 0;
}

3. 1D Eraser

https://codeforces.com/contest/1873/problem/D

题意:给定一个只有B和W两种字符的字符串,现在有一种操作可以将一个指定大小的区间(大小为k)中所有的字符全部变为W,问对于这个字符串,至少需要几次上述操作才可以将字符串全部变为W字符

思路:我们直接遍历一边字符串即可,在遍历到B字符的时候,指针向后移动k位,如果是W字符,指针向后移动1位即可,最终统计一下遇到B字符的次数即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

void solve()
{
int n, k;
cin >> n >> k;
string s;
cin >> s;

int res = 0;

for (int i = 0; i < n;)
{
if (s[i] == 'B')
{
res ++;
i += k;
}
else i++;
}
cout << res << endl;
}

int main()
{
int T; cin >> T;
while (T --) solve();
return 0;
}

4. ABBC or BACB

https://codeforces.com/contest/1873/problem/G

题意:现在有一个只有A和B两种字符的字符串,有如下两种操作:第一种,可以将AB转化为BC,第二种,可以将BA转化为CB。问最多可以执行上述操作几次

思路:其实一开始想到了之前做过的翻转数字为-1,最后的逻辑是将数字向左右移动,于是这道题就有了突破口了。上述两种操作就可以看做将A和B两种字符交换位置,并且B保持不变,A变为另一种字符C。由于C是无法执行上述操作得到,因此就可以理解为B走过的路就不可以再走了,那么就是说对于一个字符串,最终都会变成B走过的路C。并且只要有B,那么一个方向上所有的A都会被利用转化为C(直到遇到下一个B停止),那么我们就可以将B看做一个独立的个体,他可以选择一个方向上的所有的A并且执行操作将该方向的A全部转化为C,那么在左和右的抉择中,就是要选择A数量最多的方向进行操作,但是如果B的足够多,那就不需要考虑哪个方向了,所有的A都可以获得一次操作机会。现在就转向考虑B需要多少呢,

答案是两种:如果B的数量小于A“块”的数量,其实就是有一块A无法被转化,那么为了让被转化的A数量尽可能的多,就选择A块数量最少的那一块不被转化。如果B的数量>=A块的数量,那么可以保证所有的A都可以被转化为C块,从而最终的答案就是A字符的数量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

void solve()
{
string s; cin >> s;
int cntb = 0, blocka = 0, cnta = 0; // B字符的数量,A连续区间的数量,A字符的个数

vector<int> a; // 每一个A连续区间中A字符的数量

for (int i = 0; i < s.size();)
{
if (s[i] == 'A')
{
int sum = 0;
while (s[i] == 'A') cnta ++, sum ++, i ++;
a.emplace_back(sum);
blocka ++;
}
else
{
cntb ++;
i ++;
}
}

if (cntb >= blocka) cout << cnta << endl;
else
{
sort(a.begin(), a.end());
cout << cnta - a[0] << endl;
}
}

int main()
{
int T; cin >> T;
while (T --) solve();
return 0;
}

5. Smilo and Monsters

https://codeforces.com/contest/1891/problem/C

题意:给定一个序列,其中每一个元素代表一个怪物群中的怪物的数量。现在有两种操作:

  1. 选择一个怪物群,杀死其中的一只怪物。同时累加值 x+=1x+=1
  2. 选择一个怪物群,通过之前积累的累加值,一次性杀死当前怪物群中的 xx 只怪物,同时累加值 xx 归零

现在询问杀死所有怪物的最小操作次数

思路:一开始我是分奇偶进行判断的,但是那只是局部最优解,全局最优解的计算需要我们“纵观全局”。

我们可以先做一个假设:假设当前只有一个怪物群,为了最小化操作次数,最优解肯定是先杀死一半的怪物(假设数量为n,则获得累计值x=n),然后无论剩余n个还是n+1个,我们都使用累计值x一次性杀死n个怪物。

现在我们回到原题,有很多个怪物群,易证,最终的最优解也一点是最大化利用操作2,和特殊情况类似,能够利用的一次性杀死的次数就是怪物总数的一半。区别在于:此时不止一个怪物群。那么我们就要考虑累加值如何使用。很容易想到先将怪物群按照数量大小进行排序,关键就在于将累加值从小到大进行使用还是从大到小进行使用。可以发现,对于一个确定的累加值,由于操作2的次数也会算在答案中。那么如果从最小的怪物群开始使用,就会导致操作2的次数变多。因此我们需要从最大值开始进行操作2。

那么最终的答案就是:

  • 首先根据怪物数量的总和计算出最终的累加值s(s=sum/2)
  • 接着我们将怪物群按照数量进行升序排序
  • 然后我们从怪物数量最大的开始进行操作2,即一次性杀死当前怪物群,没进行一次,答案数量+1
  • 最后将答案累加上无法一次性杀死的怪物群中怪物的总数

时间复杂度:O(nlogn)O(n \log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void solve() {
n = read(); s = 0; res = 0;
for (int i = 1; i <= n; i++) {
a[i] = read();
s += a[i];
}

s >>= 1;

sort(a + 1, a + n + 1);

for (int i = n; i >= 1; i--) {
if (s >= a[i]) {
s -= a[i], a[i] = 0, res++;
} else if (s) {
a[i] -= s;
s = 0;
res++;
}
}

for (int i = 1; i <= n; i++) {
res += a[i];
}

printf("%lld\n", res);
}

6. 通关

https://www.lanqiao.cn/problems/5889/learning/?contest_id=145

声明:谨以此题记录我的第一道正式图论题(存图技巧抛弃了y总的纯数组,转而使用动态数组vector进行建图)e.g.

1
2
3
4
5
6
struct Node {
int id; // 当前结点的编号
int a; // 属性1
int b; // 属性2
};
vector<Node> G[N];

题意:给定一棵树,每个结点代表一个关卡,每个关卡拥有两个属性:通关奖励值val和可通关的最低价值cost。现在从根节点开始尝试通关,每一个结点下面的关卡必须要当前结点通过了之后才能继续闯关。问应该如何选择通关规划使得通过的关卡数最多?

思路:一开始想到的是直接莽bfs,对每一层结点按照cost升序排序进行闯关,然后wa麻了。最后一看正解原来是还不够贪。正确的闯关思路应该是始终选择当前可以闯过的cost最小的关卡,而非限定在了一层上。因此我们最终的闯关顺序是:对于所有解锁的关卡,选择cost最小的关卡进行通关,如果连cost最小的关卡都无法通过就直接结束闯关。那么我们应该如何进行代码的编写呢?分析可得,解锁的关卡都是当前可通过的关卡的所有子结点,而快速获得当前cost最小值的操作可以通过一个堆来维护。

时间复杂度:O(nlogn)O(n \log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
const int N = 100010;
typedef long long ll;

struct Node {
int id;
int val;
int cost;
// 有趣的重载
bool operator< (const Node& t) const {
return this->cost > t.cost;
}
};

int n, p;
ll res;
vector<Node> G[N];
priority_queue<Node> q;

void bfs() {
q.push(G[0][0]);

while (q.size()) {
Node now = q.top();
q.pop();
if (p >= now.cost) {
res++;
p += now.val;
for (auto& child: G[now.id]) {
q.push(child);
}
} else {
break;
}
}
}

void solve() {
cin >> n >> p;
for (int i = 1; i <= n; i++) {
int fa, v, c;
cin >> fa >> v >> c;
G[fa].push_back({i, v, c});
}
bfs();
cout << res << "\n";
}

7. 串门

https://www.lanqiao.cn/problems/5890/learning/?contest_id=145

题意:给定一棵无向树,边权为正。现在询问在访问到每一个结点的情况下最小化行走的路径长度

思路:首先我们不管路径长度的长短,我们直接开始串门。可以发现,我们一点会有访问的起点和终点,那么在起点到终点的这条路上,可能会有很多个分叉,对于每一个分叉,我们都需要进入再返回来确保分叉中的结点也被访问到,那么最终的路径长度就是总路径长度的2倍 - 起点到终点的距离,知道了这个性质后。我们可以发现,路径的总长度是一个定值,我们只有最大化起点到终点距离才能确保行走路径长度的最小值。那么问题就转化为了求解一棵树中,最长路径长度的问题。即求解树的直径的问题。

树的直径:首先我们反向考虑,假设知道了直径的一个端点,那么树的直径长度就是这棵树以当前结点为根的深度。那么我们就需要先确定一个直径的端点。不难发现,对于树中的任意一个结点,距离它最远的一个(可能是两个)结点一定是树的直径的端点。那么问题就迎刃而解了。

为了降低代码量,我们可以设置一个dist数组来记录当前结点到根的距离。

  • 在确定直径端点的时候,选择任意一个结点为根节点,然后维护dist数组,最终dist[i~n]中的最大值对应的下标maxi就是直径的一个端点。
  • 接着在计算直径长度的时候,以maxi为树根,再来一次上述的维护dist数组的过程,最终dist[1~n]中的最大值就是树的直径的长度

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
const int N = 100010;
typedef long long ll;

struct Node {
int id;
ll w;
};

int n;
vector<Node> G[N];
bool st[N];
ll sum, d, dist[N];

/**
* @note 计算当前所有子结点到当前点的距离
* @param now 当前点的编号
* @param x 上一个点到当前点的距离
*/
void dfs(int now, ll x) {
if (!st[now]) {
st[now] = true;
dist[now] = x;
for (int i = 0; i < G[now].size(); i++) {
int ch = G[now][i].id;
dfs(ch, x + G[now][i].w);
}
}
}

void solve() {
cin >> n;
for (int i = 0; i < n - 1; i++) {
int a, b, w;
cin >> a >> b >> w;
sum += w;
G[a].push_back({b, w});
G[b].push_back({a, w});
}

// 以1号点为根,计算所有点到1号点的距离
dfs(1, 0);

memset(st, 0, sizeof st);

// 到1号点最远的那个点的编号 maxi,即直径的一个端点
int maxi = 0;
for (int i = 1; i <= n; i++) {
if (dist[i] > dist[maxi]) {
maxi = i;
}
}

// 以直径的一个端点 maxi 为根,计算所有点到 maxi 点的距离
dfs(maxi, 0);

// 找到直径长度
for (int i = 1; i <= n; i++) {
d = max(d, dist[i]);
}

cout << sum * 2 - d << "\n";
}

8. Codeforces rating

https://vijos.org/d/nnu_contest/p/1532

题意:现在已知一个初始值R,现在给定了n个P值,选择其中的合适的值,使得最终的 R=3/4R+1/4PR'=3/4 R + 1/4 P 最大

思路:首先一点就是我们一定要选择P比当前R大的值,接下来就是选择合适的P使得最终迭代出来的R’最大。首先我们知道,对于筛选出来的比当前R大的P集合,任意选择其中的一个P,都会让R增大,但是不管增加多少都是不会超过选择的P。那么显然,如果筛选出来的P集合是一个递增序列,那么就可以让R不断的增加。但是这一定是最大的吗?我们不妨反证一下,现在我们有两个P,分别为x,y,其中 x<yx<y

那么按照上述思路,首先就是

R=34R+14x(R<x)R'=\frac{3}{4}R+\frac{1}{4}x(R'<x)

接着就是

R1=34R+14y(R1<y)=916R+316x+14yR''_1=\frac{3}{4}R'+\frac{1}{4}y(R''_1<y)=\frac{9}{16}R+\frac{3}{16}x+\frac{1}{4}y

反之,首先选择一个较大的P=y,再选择一个较小的P=x,即首先就是

R=34R+14y(R<y)R'=\frac{3}{4}R+\frac{1}{4}y(R'<y)

此时我们还不一点可以继续选择x,因为此时的R’可能已经超过了x的值,那么我们按照最优的情况计算,可以选择x,那么就是

R2=34R+14x(R2<x)=916R+316y+14xR''_2=\frac{3}{4}R'+\frac{1}{4}x(R''_2<x)=\frac{9}{16}R+\frac{3}{16}y+\frac{1}{4}x

可以发现

R2R1=116(xy)<0R''_2-R''_1=\frac{1}{16}(x-y)<0

即会使得最终的答案变小。因此最优的策略就是按照增序进行叠加计算

==注意点:==

四舍五入的语句

1
cout << (int)(res + 0.5) << "\n";
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void solve() {
cin >> n >> k;
for (int i = 0; i < n; i++) {
cin >> a[i];
}

sort(a, a + n);

int i = 0;
for (; i < n; i++) {
if (a[i] > k) {
break;
}
}

double res = k;

for (; i < n; i++) {
res = (res * 3.0 + a[i] * 1.0) / 4.0;
}

cout << (int)(res + 0.5) << "\n";
}

9. 货运公司

https://www.acwing.com/problem/content/5380/

题意:给定 nn 个运货订单,每个订单需要运送一定量的货物,同时给予一定量的报酬。现在有 kk 个卡车进行运送,每个卡车有运送容量。需要合理规划卡车的装配,使得最终获得的总报酬最大。有以下约束条件:

  • 每一个订单只能用一辆货车运送
  • 每一辆货车只能运送不超过最大容量的货物
  • 每一辆货车只能运送一次
  • 每一辆货车在装得下的情况下只能装运一个订单

思路:一开始尝试采用 01 背包的思路,但是题目中货物是否可以被运送是取决于卡车的装载的。思考无果后考虑贪心。考虑一般情况

  • 为了让总收益最大,很显然报酬越多,就越优先选择该订单

  • 在报酬一致时,货物量越少,就越优先选择该订单

  • 按照上述规则对订单排序后,我们需要安排合理的货车进行运输。我们优先将运输量小的货车安排给合适的订单。因为运输量大的货车既可以满足当前订单,同样也满足后续的订单,那么为了最大化增加利润,就需要在运输量小的货车满足当前订单时,保留运输量大的货车用于后续的订单。我们可以画一个图进一步理解为什么这么安排货车进行运输:

    image-20231223222627134
    图例:我们按照上述红笔的需要进行货车的选择进行运输

    如果红色序号顺序变掉了,则就不能将上述图例中的 6 个订单全部运输了

时间复杂度 O(nk)O(nk)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include <bits/stdc++.h>
using namespace std;

const int N = 1010;

struct Item {
int id, weight, money;
bool operator< (const Item& t) const {
if (money != t.money) return money > t.money;
else return weight < t.weight;
}
} item[N];

struct Car {
int id, v;
bool operator< (const Car& t) const {
return v < t.v;
}
} car[N];

pair<int, int> res[N];
bool vis[N];
int cnt, sum;

void solve() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) {
item[i].id = i;
cin >> item[i].weight >> item[i].money;
}

int k;
cin >> k;
for (int i = 1; i <= k; i++) {
car[i].id = i;
cin >> car[i].v;
}

sort(item + 1, item + n + 1);
sort(car + 1, car + k + 1);

for (int i = 1; i <= n; i++) {
for (int j = 1; j <= k; j++) {
if (!vis[j] && car[j].v >= item[i].weight) {
sum += item[i].money;
vis[j] = true;
res[cnt++] = {item[i].id, car[j].id};
break;
}
}
}

cout << cnt << " " << sum << "\n";

for (int i = 0; i < cnt; i++) {
cout << res[i].first << " " << res[i].second << "\n";
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

10. 部分背包问题

https://www.luogu.com.cn/problem/P2240

  • 题意:给定一组物品,每一个物品有一定的重量与价值,现在给定一个背包总承重,需要从中选择合适的物品进行装载,使得最终装载的物品的总价值最大。现在规定每一个物品可以进行随意分割,按照单位重量计算价值
  • 思路:考虑一般情况。很显然,如果不说可以随意分割,那就是一个 01 背包,既然说了可以随意分割,那就直接贪心选择即可,按照单位重量的价值降序进行选择
  • 时间复杂度:O(nlogn)O(n \log n)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 110;

int n, W;
double res;

struct Item {
double w;
double val;

bool operator< (const Item& t) const {
return this->val / this->w > t.val / t.w;
}
} a[N];

void solve() {
cin >> n >> W;
for (int i = 0; i < n; i++) cin >> a[i].w >> a[i].val;

sort(a, a + n);

for (int i = 0; i < n; i++) {
if (W > a[i].w) {
W -= a[i].w;
res += a[i].val;
} else {
res += W * a[i].val / a[i].w;
break;
}
}

cout << fixed << setprecision(2) << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
cout << fixed << setprecision(2);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

11. 排队接水

https://www.luogu.com.cn/problem/P1223

  • 题意:贪心经典,排队接水,每一个人都有一个打水时间,现在询问如何排队能够使得平均等待时间最短

  • 思路:考虑一般情况。注意问的是平均等待时间,如果是打完水总花费时间那就是一个定值,即所有人打水的时间总和。现在为了让平均等待时间最短,我们的第一反应应该是,在人多的时候让打水时间短的先打,人少的时候让打水时间长的再打,那么很显然就是按照打水时间的升序进行排队。为了证明这个结论,我们采用数学归纳法,如下,对于一个排队序列来说,我们将其中的两个顺序进行颠倒,计算结果得到让打水时间短的排在前面更优,进而推广到一般情况,于是按照打水时间升序进行打水的策略是最优的。证毕

    demo
  • 时间复杂度:O(nlogn)O(n \log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 1010;

int n;

struct Item {
int time;
int i;

bool operator< (const Item& t) const {
return this->time < t.time;
}
} a[N];

void solve() {
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i].time;
a[i].i = i;
}

sort(a + 1, a + n + 1);

double sum = 0;

for (int i = 1; i <= n; i++) {
sum += a[i].time * (n - i);
cout << a[i].i << " \n"[i == n];
}

cout << fixed << setprecision(2) << sum / n << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

12. 线段覆盖

https://www.luogu.com.cn/problem/P1803

  • 题意:给定一个数轴和数轴上的 n 个线段,现在需要从中选出尽可能多的 k 个线段,使得线段之间两两不相交,问 k 最大是多少
  • 思路:绞尽脑汁想着各种情况进行分类讨论,什么包含、相交、相离,但是都忽视了最重要的一点,那就是一开始应该选择线段。即考虑边界,对于最左侧我们应该选择哪一条线段呢?答案是最左侧的右端点最靠左的那一条线段,这样子后续的线段才能有更多的摆放空间
  • 时间复杂度:O(nlogn)O(n \log n)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 1000010;

int n;

struct Item {
int l, r;
bool operator< (const Item& t) const {
return this->r < t.r;
}
} a[N];

void solve() {
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i].l >> a[i].r;

sort(a + 1, a + n + 1);

int right = -1, res = 0;

for (int i = 1; i <= n; i++) {
if (a[i].l >= right) {
res++;
right = a[i].r;
}
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

13. 小A的糖果

https://www.luogu.com.cn/problem/P3817

  • 题意:给定一个序列,现在每次可以选择序列中的某个数进行减一操作,问最少需要减多少可以使得序列中相邻两个元素之和不超过规定的数值 x
  • 思路:同 T12 的思路展开,考虑边界,我们直接从任意的一个边界开始思考,假设从左边界开始考虑。为了让左边界满足,那么 a[1] 与 a[2] 之和就需要满足条件,为了后续可以尽可能少的进行减一操作,很显然我们最好从两个数的右边的数开始下手,于是贪心的结果就有了
  • 时间复杂度:O(n)O(n)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 100010;

int n, x;
int a[N];

void solve() {
cin >> n >> x;
for (int i = 1; i <= n; i++) cin >> a[i];

int res = 0;

for (int i = 2; i <= n; i++) {
if (a[i] + a[i - 1] > x) {
int eat = a[i] + a[i - 1] - x;
res += eat;
a[i] = a[i] >= eat ? a[i] - eat : 0;
}
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

15. 陶陶摘苹果(升级版)

https://www.luogu.com.cn/problem/P1478

  • 题意:现在有 n 个苹果,含有高度和消耗体力值两个属性,现在你有一个最大高度以及总体力值,每次只能摘高度值不超过最大高度的苹果且会消耗相应的体力值。问最多可以摘多少个苹果
  • 思路:既然是想要摘的苹果尽可能多,那么高度都是浮云,体力值才是关键。为了摘得尽可能多的苹果,自然是消耗体力越少越优先摘。于是按照消耗体力值升序排序后,扫描一遍即可计数
  • 时间复杂度:O(nlogn)O(n\log n)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 5010;

int n, s;
int aa, bb;

struct Apple {
int h, cost;
bool operator< (const Apple& t) const {
return this->cost < t.cost;
}
} a[N];

void solve() {
cin >> n >> s;
cin >> aa >> bb;
for (int i = 1; i <= n; i++) cin >> a[i].h >> a[i].cost;

sort(a + 1, a + n + 1);

int cnt = 0;

for (int i = 1; i <= n; i++) {
if (s >= a[i].cost && a[i].h <= aa + bb) {
cnt++;
s -= a[i].cost;
}
}

cout << cnt << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

16. [NOIP2018 提高组] 铺设道路

https://www.luogu.com.cn/problem/P5019

  • 题意:给定一个序列,每次可以选择一个连续的区间使得其中的所有数 -1,前提是区间中的数都要 > 1,问最少选择几次区间可以将所有的数全部都减到 0

  • 思路:很显然的一个贪心思路就是,对于一个数,左右两侧均呈现递减的趋势,那么这个区间的选择数就是这个最大的数的值。但是这不符合代码逻辑,我们尝试从端点开始考虑。为了更好的理解,我们从递推的角度进行推演。假设填平前 i 个数花费的选择次数为 f[i],那么对于第一个数 a[1],我们一定需要花费 a[1] 次选择来将其减到零,于是 f[1] = a[1];对于后续的数而言,可以发现

    • 如果 a[i] > a[i-1],那么之前的选择没有完全波及当前的数,还需要一定的次数来将其减到零,于是 f[i] = f[i-1] + a[i] - a[i-1]
    • 如果 a[i] \le a[i-1],那么当前的数值一定会被之前的选择“波及”而直接减到零,于是 f[i] = f[i-1]

    从递推的角度理解之后我们直接滚动一遍数组记录増势即可

  • 时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 100010;

int n, a[N];

void solve() {
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];

int res = a[1];

for (int i = 2; i <= n; i++)
if (a[i] > a[i - 1])
res += a[i] - a[i - 1];

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

17. [USACO1.3] 混合牛奶 Mixing Milk

https://www.luogu.com.cn/problem/P1208

  • 题意:现在需要收集 need 容量的牛奶,同时有 n 个农户提供奶源,每一个商户的奶源有一定的数量与单价,问如何采购可以在满足需求 need 的情况下成本最低化
  • 思路:很显然单价越低越优先采购
  • 时间复杂度:O(nlogn)O(n \log n)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 2000010;

int need, n;

struct Milk {
int per, tot;
bool operator< (const Milk& t) const {
return per < t.per;
}
} a[N];

void solve() {
cin >> need >> n;
for (int i = 1; i <= n; i++) cin >> a[i].per >> a[i].tot;

sort(a + 1, a + n + 1);

int res = 0;

for (int i = 1; i <= n; i++) {
if (need >= a[i].tot) {
need -= a[i].tot;
res += a[i].tot * a[i].per;
} else {
res += need * a[i].per;
need = 0;
}
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

18. [NOIP2007 普及组] 纪念品分组

https://www.luogu.com.cn/problem/P1094

  • 题意:给定 n 个数,问如何分组可以使得在每一组之和不超过定值 lim 且每一组最多两个数的情况下,分得的组数最少
  • 思路:对序列没有要求,显然直接排序然后从一端开始考虑。首先有一个概念就是,为了尽可能的减少分得的组数,那么一定想要每一组分完后距离 lim 的差距越小越好。我们将 n 个数降序排序后,从最大值开始考虑,对于当前的数,为了找到一个数与当前数之和尽可能接近 lim,从贪心的角度来说一定是从当前数之后开始寻找那个配对的数从而使得两数之和与 lim 的差距最小,那么时间复杂度就是 O(n2)O(n^2)。但是由于题目说了最多只有两个数,我们不妨换一种寻找配对数的策略,可以从原始的寻找方法得到启发,可以发现,原始的寻找配对数的方法中,如果找到的那个配对数可以与当前数之和不超过 lim 且之和尽可能的大,那么这个寻找到的配对数也一定可以和当前数的下一个数进行配对。由于我们一组只能有两个数,因此我们不用考虑两数组合之后比 lim 小的数尽可能的小,只需要考虑两个数能否组合即可,因此我们直接双指针进行寻找配对数操作即可
  • 时间复杂度:O(n)O(n)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 30010;

int lim, n;
int a[N];

void solve() {
cin >> lim >> n;
for (int i = 1; i <= n; i++) cin >> a[i];

sort(a + 1, a + n + 1, [&](int x, int y) {
return x > y;
});

int res = 0, l = 1, r = n;

while (l < r) {
if (a[l] + a[r] <= lim) r--;
l++, res++;
}

if (l == r) res++;

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

19. 跳跳!

https://www.luogu.com.cn/problem/P4995

  • 题意:给定 n 个数,每一个数代表一个高度,现在有一只青蛙从地面(高度为 0)开始起跳,每一个数字只能被跳上去一次。问如何设定跳数顺序 h[],可以使得下式尽可能的大

    i=1n(hihi1)2\sum_{i=1}^n (h_i-h_{i-1})^2

  • 思路:题目意思其实就是让每一次跳跃的两个高度之差尽可能的大。那么一个贪心的思路就是从地面跳到最高,再从最高跳到次低,接着从次低跳到次高,以此类推得到的结果就是最优解,为了证明我们可以用数学归纳法,假设当前只有两个高度可以选择,那么肯定是先跳高的再跳低的;假设当前有三个高度可以选择,那么可以证明先从地面跳到最高处,再跳到最低处,最后跳到第二高的方法得到的值最大

  • 时间复杂度:O(nlogn)O(n \log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 310;

int n, a[N];

void solve() {
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];

sort(a + 1, a + n + 1, [&](int x, int y) {
return x > y;
});

int res = a[1] * a[1], l = 1, r = n;

while (l < r) {
res += (a[l] - a[r]) * (a[l] - a[r]);
l++;
if (l < r) {
res += (a[l] - a[r]) * (a[l] - a[r]);
r--;
}
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

20. 安排时间

https://www.acwing.com/problem/content/5466/

  • 题意:给定 n 个时间段处理餐厅事务,m 个用餐预定信息,含有三个信息分别为订单发起时间,订单需要准备的时间,客人来用餐时间。问如何安排每一个时间段,可以让所有的用户得到正常的服务。如果不能全部满足则输出 -1
  • 思路:玄学贪心,先到的用户先做它的菜
  • 时间复杂度:O(n2)O(n^2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 110;

int n, m;
int res[N];

struct Item {
int id;
int begi, arri, time;
bool operator< (const Item& t) const {
return arri < t.arri;
}
} a[N];

void solve() {
cin >> n >> m;
for (int i = 1; i <= m; i++) {
cin >> a[i].begi >> a[i].arri >> a[i].time;
a[i].id = i;
res[a[i].arri] = m + 1; // 接客
}

sort(a + 1, a + m + 1);

for (int i = 1; i <= m; i++) {
int t = a[i].time;
for (int j = a[i].begi; j <= a[i].arri - 1; j++) {
if (t > 0 && !res[j]) {
res[j] = a[i].id; // 做菜
t--;
}
}

if (t) {
cout << -1 << "\n";
return;
}
}

for (int i = 1; i <= n; i++) cout << res[i] << " \n"[i == n];
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

21. [NOIP2004 提高组] 合并果子

https://www.luogu.com.cn/problem/P1090

  • 题意:给定 n 个数,现在需要将其两两合并为最终的一个数,每次合并的代价是两个数之和,问如何对这些数进行合并可以使得总代价最小
  • 思路:很显然一共需要合并 n-1 次。那么我们从三个物品开始考虑,然后使用数学归纳法推导到 n 个数。对于三个物品,很显然先将两个物品进行合并,再将这个结果与最大的数进行合并方案时最优的。那么推广到 n 个数,我们只需要每次合并当前局面中最小的两个数即可
  • 时间复杂度:O(nlogn)O(n \log n)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 10010;

int n, a[N];
priority_queue<int, vector<int>, greater<int>> q;
int res;

void solve() {
cin >> n;
for (int i = 1; i <= n; i++) {
int x;
cin >> x;
q.push(x);
}

for (int i = 1; i <= n - 1; i++) {
int a = q.top(); q.pop();
int b = q.top(); q.pop();

res += a + b;

q.push(a + b);
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

22. 删数问题

https://www.luogu.com.cn/problem/P1106

  • 题意:给定一个数字字符串长度为 n,现在需要选择其中的 m 位数,使得这 m 位组成的数最小是多少 (m=n-k)
  • 思路:我们从一端考虑问题。为了组成的数尽可能的小,我们希望从首位开始就尽可能的小,同时如果有重复的数字,就选择最左侧的,剩余的数字留给后续的位数继续选择。有一个需要注意的点就是选择每一位的数字时,范围的约束,我们假设需要从 10 位的数字串中选择 3 位,那么我们第一次选择时需要在 0-7位中选择一位,第二次选择在 上次选择的下一个位置到第8位中选择一位,以此类推进行贪心选择
  • 时间复杂度:O(n2)O(n^2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <bits/stdc++.h>
#define int long long
using namespace std;

string s, res;
int k;

void solve() {
cin >> s >> k;
int n = s.size();

// 特判保留的数字个数 n-k 为负数的情况
if (n - k <= 0) {
cout << 0 << "\n";
return;
}

// 循环 n-k 次选择保留的数
int l = 0, r = k;
for (int i = 0; i < n - k; i++) {
int idx = -1;
for (int j = l; j <= r; j++)
if (idx == -1 || s[j] < s[idx])
idx = j;

res += s[idx];

l = idx + 1, r++;
}

// 删除前导零
int i = 0;
while (res[i] == '0') i++;

// 输出结果
if (i == res.size())
cout << 0;
else
for (int j = i; j < res.size(); j++)
cout << res[j];
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

23. 截断数组

https://www.acwing.com/problem/content/5483/

题意:定义平衡数组为奇偶数数量相同的数组,现在给定一个平衡数组,和一个总成本数,最多可以将原数组截成多少截子平衡数组,且截断代价总和不超过总成本,截断代价计算公式为 aiai+1|a_i-a_{i+1}|

思路:我们直接枚举所有的可以被截断的位置,统计所有的代价值,然后根据成本总数按降序枚举代价值即可

时间复杂度:O(nlogn)O(n \log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <iostream>
#include <queue>
#include <cstring>
#include <vector>
#include <unordered_map>
#include <algorithm>
using namespace std;

const int N = 110;

int n, b;
int a[N], odd[N], even[N];

void solve() {
cin >> n >> b;

for (int i = 1; i <= n; i++) {
cin >> a[i];
if (a[i] % 2 == 0) {
even[i] = even[i - 1] + 1;
odd[i] = odd[i - 1];
} else {
even[i] = even[i - 1];
odd[i] = odd[i - 1] + 1;
}
}

vector<int> c;
for (int i = 2; i <= n - 2; i += 2) {
if (odd[i] == even[i]) {
c.push_back(abs(a[i] - a[i + 1]));
}
}

int res = 0;
sort(c.begin(), c.end());
for (auto& x: c) {
if (b >= x) {
b -= x;
res++;
}
}

cout << res;
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

24. 修改后的最大二进制字符串

https://leetcode.cn/problems/maximum-binary-string-after-change/description/

题意:给定一个仅由01组成的字符串,现在可以执行下面两种操作任意次,使得最终的字符串对应的十进制数值最大,给出最终的字符串

  1. 001000 \to 10
  2. 100110 \to 01

思路:

  • 有点像之前做的翻转转化为平移的问题,事实确实如此。首先需要确定一点就是:如果字符串起始有连续的1,则保留,因为两种操作都是针对0的。接着就是对从第一个0开始的尾串处理的思路
  • 对于尾串而言,我们自然希望1越多、越靠前越好,但是第二种操作只能将1向后移,第一种操作又必须要连续的两个0,因此我们就产生了这样的贪心思路:将所有的1通过操作二移动到串尾,接着对尾串的开头连续的0串执行第二种操作,这样就可以得到最大数值的二进制串

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution {
public:
string maximumBinaryString(string binary) {
int n = binary.size();

string res;

// 前缀1全部加入答案
int i = 0;
for (i = 0; i < n; i++) {
if (binary[i] == '0') {
break;
}
else {
res += '1';
}
}

// 非前缀1的部分一定可以操作为 00...011..1,进而转化为 11...11011...1
int zero = count(binary.begin() + i, binary.end(), '0');
if (zero > 0) {
for (int j = 0; j < zero - 1; j++) res += '1';
res += '0';
}

while (res.size() < n) res += '1';

return res;
}
};

25. 子序列最大优雅度

https://leetcode.cn/problems/maximum-elegance-of-a-k-length-subsequence/

标签:反悔贪心

题意:给定长度为 n 的物品序列,现在需要从中寻找一个长度为 k 的子序列使得子序列的总价值最大,给出最终的最大价值。一个序列的价值定义为每个物品的 本身价值之和 + 物品种类数的平方

思路:我们采用反悔贪心的思路。首先按照物品本身价值进行降序排序并选择前 k 个物品作为集合 S,然后按顺序枚举剩下的物品进行替换决策。我们记每个物品有一个「本身价值」,集合 S 有一个「种类价值」。那么就会遇到下面两种情况:

  • 第 i 个物品对应的种类存在集合 S 中。此时该物品「一定不选」,因为无论当前物品替代集合中的 S 哪一个物品,本身价值的贡献会下降、种类价值的贡献保持不变。
  • 第 i 个物品对应的种类不在集合 S 中。此时该物品一定选择吗?不一定。有两种情况:
    • 集合 S 没有重复种类的物品。此时该物品「一定不选」,同样的,无论当前物品替代集合中的 S 哪一个物品,本身价值的贡献会下降、种类价值的贡献保持不变。
    • 集合 S 存在重复种类的物品。此时该物品「一定选」,因为一定可以提升种类价值的贡献,至于减去本身价值的损失后是否使得总贡献增加,并不具备可能的性质,只能每次替换物品时更新全局答案进行记录。

时间复杂度:O(nlogn)O(n \log n)

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class Solution {
public:
long long findMaximumElegance(std::vector<std::vector<int>>& items, int k) {
using ll = long long;

std::sort(items.rbegin(), items.rend());

ll t = 0, cat = 0, res = 0;
std::unordered_map<int, int> cnt;
std::stack<ll> stk;

for (int i = 0; i < items.size(); i++) {
ll v = items[i][0], c = items[i][1];
if (i < k) {
// 前 k 个物品
t += v;
cnt[c]++;
cat += cnt[c] == 1;
if (cnt[c] > 1) {
stk.push(v);
}
} else {
// 后 n-k 个物品
if (cnt[c]) {
// 已经出现过的物品种类
continue;
} else {
// 没有出现过的物品种类
if (stk.size()) {
cat++;
t -= stk.top();
t += v;

stk.pop();
cnt[c]++;
}
}
}
// 更新全局答案
res = max(res, t + cat * cat);
}

return res;
}
};
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Solution:
def findMaximumElegance(self, items: List[List[int]], k: int) -> int:
from collections import defaultdict
items.sort(reverse=True)

t, cat, res = 0, 0, 0
cnt, stk = defaultdict(int), []

for i in range(len(items)):
v, c = items[i][0], items[i][1]
if i < k:
t += v
cnt[c] += 1
cat += cnt[c] == 1
if cnt[c] > 1:
stk.append(v)
else:
if cnt[c]:
continue
else:
if len(stk):
t -= stk[-1]
t += v
cat += 1
stk.pop()
cnt[c] += 1
res = max(res, t + cat**2)

return res

【按位贪心/分类讨论】

https://www.acwing.com/problem/content/1000/

题意:给定 nn 个运算数和对应的 nn 个操作,仅有 &, |, ^ 三种。现在需要选择一个数 num[0,m]num \in [0,m] 使得 numnum 经过这 nn 个二元运算后得到的结果 resres 最大。给出最终的 resres

思路:首先不难发现这些运算操作在二进制位上是相互独立的,没有进位或结尾的情况,因此我们可以「按二进制位」单独考虑。由于运算数和 mm109\le 10^9,因此我们枚举 [0,30][0,30] 对应的二进制位。接下来我们单独考虑第 ii 位的情况,显然的 numnum 的第 ii 位可以取 11 也可以取 00,对应的 resres 的第 ii 位可以取 11 也可以取 00。有以下 222^2 种运算情况:

$\text{num 的第 i 位填 1 \to res 第 i 位的情况}$$\text{num 的第 i 位填 0 \to res 第 i 位的情况}$选择方案
情况一101 \to 0000 \to 0num 填 0,res 填 0
情况二101 \to 0010 \to 1num 填 0,res 填 1
情况三111 \to 1000 \to 0待定
情况四111 \to 1010 \to 1num 填 0,res 填 1

选择方案时我们需要考虑两个约束,numnum 不能超过 mmresres 尽可能大,因此有了上述表格第四列的贪心选择结果。之所以情况三待定是因为其对两种约束的相互矛盾的,并且其余情况没有增加 numnum,即对 numnum 上限的约束仅存在于情况三。假设有 kk 位是上述情况三,那么显然的其余位枚举顺序是随意的,因为答案是固定的。对于 kk 个情况三,从贪心的角度考虑,我们希望 resres 尽可能大,那么就有「高位能出 11 就出 11」的结论。因此我们需要从高位到低位逆序枚举这 kk 个情况三,由于其他位枚举顺序随意,因此总体就是从高位到低位枚举。

时间复杂度:O(nlogm)O(n \log m)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

void solve() {
int n, m;
cin >> n >> m;

vector<pair<string, int>> a(n);
for (int i = 0; i < n; i++) {
cin >> a[i].first >> a[i].second;
}

int res = 0, num = 0;
for (int i = 30; i >= 0; i--) {
int x = 1 << i, y = 0;
for (int j = 0; j < n; j++) {
string op = a[j].first;
int t = a[j].second & (1 << i);
if (op == "AND") {
x &= t, y &= t;
} else if (op == "OR") {
x |= t, y |= t;
} else {
x ^= t, y ^= t;
}
}
if ((num | (1 << i)) <= m && x > y) {
num |= 1 << i;
res |= 1 << i;
} else {
res += y;
}
}

cout << res << "\n";
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
// std::cin >> T;
while (T--) solve();
return 0;
}
]]>
@@ -1479,11 +1479,11 @@ - number-theory - - /Algorithm/number-theory/ + hashing + + /Algorithm/hashing/ - 数论

整数问题。

【质数】Divide and Equalize

题意:给定 nn 个数,问能否找到一个数 numnum,使得 numn=i=1nainum^n = \prod_{i=1}^{n}a_i

原始思路:起初我的思路是二分,我们需要寻找一个数使得n个数相乘为原数组所有元素之积,那么我们预计算出所有数之积,并且在数组最大值和最小值之间进行二分,每次二分出来的数计算n次方进行笔比较即可。但是有一个很大的问题是,数据量是 10410^4,而数字最大为 10610^6,最大之积为 101010^{10} 吗?不是!最大之和才是,最大之积是 106×10410^{6\times10^4}

最终思路:我们可以将选数看做多个水池匀水的过程。现在每一个水池的水高都不定,很显然我们一定可以一个值使得所有的水池的高度一致,即 i=1nain\frac{\sum_{i=1}^{n}a_i}{n}。但是我们最终的数字是一个整数,显然不可以直接求和然后除以n,那么应该如何分配呢?我们知道水池之所以可以直接除以n,是因为水的最小分配单位是无穷小,可以随意分割;而对于整数而言,最小分配单位是什么呢?答案是质因子!为了通过分配最小单位使得最终的“水池高度一致”,我们需要让每一个“水池”获得的数值相同的质因子数量相同。于是我们只需要统计一下数组中所有数的质因子数量即可。如果对于每一种质因子的数量都可以均匀分配每一个数(即数量是n的整数倍),那么就一定可以找到这个数使得 numn=i=1nainum^n = \prod_{i=1}^{n}a_i

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void solve() {
int n;
cin >> n;

// 统计所有数字的所有质因数
unordered_map<int, int> m;
for (int i = 0; i < n; i++) {
int x;
cin >> x;

for (int k = 2; k <= x / k; k++) {
if (x % k == 0) {
while (x % k == 0) {
m[k]++;
x /= k;
}
}
}

if (x > 1) {
m[x]++;
}
}

// 查看每一种质因数是否是n的整数倍
for (auto& x: m) {
if (x.second % n) {
cout << "No\n";
return;
}
}

cout << "Yes\n";
}

【整除】Deja Vu

https://codeforces.com/contest/1891/problem/B

题意:给点序列 a 和 b,对于 b 中的每一个元素 bib_i,如果 a 中的元素 aja_j 能够整除 2bi2^{b_i},则将 aja_j 加上 2bi12^{b_i - 1}。给出最后的 a 序列

思路一:暴力枚举。

  • 我们很容易就想到暴力的做法,即两层循环,第一层枚举 b 中的元素,第二层枚举 a 中的元素,如果 a 中的元素能够整除 2 的 bib^i 次方,就将 a 中相应的元素加上一个值即可。但是时间复杂度肯定过不了,考虑优化。

  • 时间复杂度:O(nm)O(nm)

思路二:整除优化。

  • 现在我们假设a中有一个数 aja_j2bi2^{b_i} 的整数倍(其中 bib_i 是b序列中第一个枚举到的能够让 aia_i 整除的数),那么就有 aj=k2bi(k=1,2,...,)a_j = k2^{b_i}(k=1,2,...,),那么 aja_j 就要加上 2bi12^{b_i-1},于是 aja_j 就变为了 k2bi+2bi1=(2k+1)2bi1k2^{b_i}+2^{b_i-1}=(2k+1)2^{b_i-1}。此后 aja_j 就一定是 2t(t[1,bi1])2^t(t\in \left[ 1,b_i-1 \right]) 的倍数。因此我们需要做的就是首先找到b序列中第一个数x,能够在a中找到数是 2x2^x 的整数倍。这一步可以这样进行:对于 a中的每一个数,我们进行30次循环统计当前数是否是 2i2^i 的倍数,如果是就用哈希表记录当前的 ii。最后我们在遍历寻找 x 时,只需要查看当前的 x 是否被哈希过即可。接着我们统计b序列中从x开始的严格降序序列c(由题意知,次序列的数量一定 \le 30,因为b序列中数值的值域为 [1 30][1~30])。最后我们再按照原来的思路,双重循环 a 序列和 c 序列即可。

  • 时间复杂度:O(30n)O(30n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
void solve() {
int n, m;
cin >> n >> m;

vector<int> a(n + 1), b(m + 1);
unordered_map<int, int> ha;

// 边读边哈希
for (int i = 1; i <= n; i++) {
cin >> a[i];
for (int j = 30; j >= 1; j--) {
if (a[i] % (1 << j) == 0) {
ha[j]++;
}
}
}

for (int i = 1; i <= m; i++) {
cin >> b[i];
}

// 寻找b中第一个能够让a[j]整除的数b[flag]
int flag = -1;
for (int i = 1; i <= m; i++) {
if (ha[b[i]]) {
flag = i;
break;
}
}

// 特判
if (flag == -1) {
for (int j = 1; j <= n; j++) {
cout << a[j] << " \n"[j == n];
}
return;
}

// 寻找b中从flag开始的严格单调递减的序列c
vector<int> c;
c.push_back(b[flag]);
for (; flag <= m; flag++) {
if (b[flag] < c.back()) {
c.push_back(b[flag]);
}
}

// 暴力循环一遍即可
for (int j = 1; j <= n; j++) {
for (int k = 0; k < c.size(); k++) {
if (a[j] % (1 << c[k]) == 0) {
a[j] += 1 << (c[k] - 1);
}
}
}

for (int j = 1; j <= n; j++) {
cout << a[j] << " \n"[j == n];
}
}

【组合数学】序列数量

https://www.acwing.com/problem/content/5571/

题意:给定 i(i=1,2,,n)i(i=1,2,\cdots,n) 个苹果,将其分给 mm 个人,问一共有多少种分配方案,给出结果对 106+310^6+3 取模的结果

思路:整数分配问题。我们采用隔板法,隔板法相关例题见这篇博客:https://www.acwing.com/solution/content/241669/。下面开始讲解

  • 利用隔板法推导结果。首先我们考虑当前局面,即只有 i 个苹果的情况下的方案数。于是题目就是 i 个苹果分给 m 个人,允许分到 0 个。于是借鉴上述链接中“少分型”的思路,先借 m 个苹果,那么此时局面中就有 i+m 个苹果,现在就等价于将 i+m 个苹果分给 m 个人,每人至少分得 1 个苹果。(分完以后每个人都还回去就行了),此时的隔板操作就是”标准型”,即 i+m 个苹果产生 i+m-1 个间隔,在其中插 m-1 块板,从而将划分出来的 m 个部分分给 m 个人。此时的划分方案就是 Ci+m1m1C_{i+m-1}^{m-1},那么对于所有的 i,结果就是

    i=1nCi+m1m1=Cmm1+Cm+1m1++Cn+m1m1=Cm1+Cm+12++Cn+m1n=(Cm0+Cm0)+Cm1+Cm+12++Cn+m1n=Cm0+(Cm0+Cm1+Cm+12++Cn+m1n)=1+(Cm+11+Cm+12++Cn+m1n)=1+(Cm+22++Cn+m1n)=1+Cn+mn\begin{aligned}\sum_{i=1}^n C_{i+m-1}^{m-1} &= C_{m}^{m-1} + C_{m+1}^{m-1} + \cdots + C_{n+m-1}^{m-1} \\&= C_{m}^{1} + C_{m+1}^{2} + \cdots + C_{n+m-1}^{n} \\&= (-C_{m}^{0}+C_{m}^{0})+C_{m}^{1} + C_{m+1}^{2} + \cdots + C_{n+m-1}^{n} \\&= -C_{m}^{0}+(C_{m}^{0}+C_{m}^{1} + C_{m+1}^{2} + \cdots + C_{n+m-1}^{n}) \\&= -1+(C_{m+1}^{1} + C_{m+1}^{2} + \cdots + C_{n+m-1}^{n}) \\&= -1+(C_{m+2}^{2} + \cdots + C_{n+m-1}^{n}) \\&= -1+C_{n+m}^n\end{aligned}

  • 利用乘法逆元计算组合数。结果已经知道了,现在如何计算上述表达式呢?由于 n×mn\times m 超过了常规递推的组合数计算方法内存空间,因此我们采用乘法逆元的思路计算。值得一提的是,本题在计算某个数关于 1e6+3 的乘法逆元时,不需要判断两者是否互质,因为 1e6+3 是一个质数,并且数据范围中的数均小于 1e6+3,因此两数一定互质,可以直接使用费马小定理计算乘法逆元

时间复杂度:O(nlog1e6)O(n\log 1e6)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>
using namespace std;
using ll = long long;

const int N = 7e5 + 10;
const int mod = 1e6 + 3;

int fact[N], infact[N];

int qmi(int a, int b, int p) {
int res = 1 % p;
while (b) {
if (b & 1) res = (ll)res * a % p;
a = (ll)a * a % p;
b >>= 1;
}
return res;
}

void init() {
fact[0] = 1, infact[0] = 1;
for (int i = 1; i < N; i++) {
fact[i] = (ll)fact[i - 1] * i % mod;
infact[i] = (ll)infact[i - 1] * qmi(i, mod - 2, mod) % mod;
}
}

int main() {
init();

int n, m;
cin >> n >> m;

cout << (ll)fact[n + m] * infact[n] % mod * infact[m] % mod - 1;

return 0;
}
]]>
+ 哈希

【哈希】分组

https://www.acwing.com/problem/content/5182/

存储不想同组和想同组的人员信息:存入数组,数据类型为一对字符串

存储所有的组队信息:存入哈希表,数据类型为“键:字符串”“值:一对字符串”

想要知道最终的分组情况,只需要查询数组中的队员情况与想同组 or 不想同组的成员名字是否一致即可

时间复杂度 O(n)O(n),空间复杂度 O(n len(name))maxO(n\ len(name))_{max}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <iostream>
#include <vector>
#include <unordered_map>
using namespace std;

int main()
{
int x;
cin >> x;

vector<pair<string, string>> X(x);

for (int i = 0; i < x; i ++)
cin >> X[i].first >> X[i].second;

int y;
cin >> y;

vector<pair<string, string>> Y(y);

for (int i = 0; i < y; i ++)
cin >> Y[i].first >> Y[i].second;

int sum;
cin >> sum;

unordered_map<string, pair<string, string>> a;

for (int i = 0; i < sum; i ++)
{
string s, t, p;
cin >> s >> t >> p;
a[s] = {t, p};
a[t] = {s, p};
a[p] = {s, t};
}

int res = 0;

// 想同组
for (int i = 0; i < x; i ++)
{
string s = X[i].first, t = X[i].second;
if (a[s].first != t && a[s].second != t)
res ++;
}

// 不想同组
for (int i = 0; i < y; i ++)
{
string s = Y[i].first, t = Y[i].second;
if (a[s].first == t || a[s].second == t)
res ++;
}

cout << res << endl;

return 0;
}

【哈希】海港

https://www.luogu.com.cn/problem/P2058

  • 题意:给定 n 艘船只的到达时间、载客信息(载客人数和每一个客人的国籍),现在需要知道对于每一艘抵达的船只,前 24 小时中抵达的客人的国籍总数
  • 思路:本题思路很简单,就是一个队列的应用以及哈希客人国籍的过程。由于船只抵达的时间是顺序增加的,故每抵达一艘船只,就对新来的客人国籍进行哈希,为了计算前 24 小时的情况,需要对船只抵达队列进行删减,即只保留 24 小时以内的船只抵达信息。对于删除的船只信息,需要将这些船只上的客人国籍信息从哈希表中删除,故每一艘船只的访问次数为 2。
  • unordered_map 补充:在进行哈希统计时。为了判断当前 24 小时内客人国籍数,在删除哈希记录时,为了判断是否将当前国籍的游客全部删除时,需要统计哈希表中某个国籍是否减为了 0,我用了 .count(x) 内置方法,但这是不正确的,因为我想要统计的是 值 是否为 0,而 .count(x) 统计的是哈希表中 x 这个 键 的个数,而 unordered_map 中是没有重复的键的,故 .count(x) 方法只会返回 0 或 1,返回 0 就表示当前哈希表中没有 x 这个键,返回 1 就表示哈希表中有 x 这个键,但是有这个键不代表对应的值就存在,可能是 x: 0 的情况,即键存在,但是值记录为 0
  • 时间复杂度:O(2xi)O(2 \sum x_i) - 即两倍的所有游客数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// #include <bits/stdc++.h>
// #define int long long
#include <iostream>
#include <unordered_map>
#include <stack>
#include <queue>
using namespace std;

const int N = 1e5 + 10;

struct Ship { int idx, t; };

int n;
queue<Ship> q;
vector<int> G[N];
unordered_map<int, int> cnt;
int kind;

void solve() {
cin >> n;

for (int i = 1; i <= n; i++) {
int t, num;
cin >> t >> num;

q.push({i, t});

// 哈希
while (num--) {
int id;
cin >> id;

if (!cnt[id]) kind++;
cnt[id]++;
G[i].push_back(id);
}

// 去哈希
Ship h = q.front();
while (t - h.t >= 86400) {
for (auto& id: G[h.idx]) {
cnt[id]--;
if (!cnt[id]) kind--;
}
q.pop();
h = q.front();
}

cout << kind << "\n";
}
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【哈希】Cities and States S

https://www.luogu.com.cn/problem/P3405

题意:给定 n 个字符串,每一个字符串归属一个集合,现在需要统计字符串与集合名相反相等的对数

思路:很显然的哈希计数。难点有两个,如何哈希?如何计数?哈希可以采用扩展字符的方法进行,即第一个字符乘某一个较大的质数再加上第二个字符。此处采用一种较为巧妙的方法,直接将两个字符串与集合名加起来进行唯一性哈希,降低编码难度。计数有两种方式,第一种就是全部哈希结束之后,再遍历哈希表进行统计,最后将结果除二即可。第二种就是边哈希边计数,遇到相反相等的就直接计数,这样就不会重复计数了,也很巧妙

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
#include <algorithm>
#include <unordered_map>
#include <stack>
#include <queue>
#include <set>
using namespace std;

int n;
unordered_map<string, int> a;

void solve() {
cin >> n;

int res = 0;

while (n--) {
string s, t;
cin >> s >> t;
s = s.substr(0, 2);
res += a[t + " " + s] * (s != t);
a[s + " " + t]++;
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【哈希/枚举/思维】Torn Lucky Ticket

https://codeforces.com/contest/1895/problem/C

题意: 给定一个长度为 n 的字符串数组 nums, 数组的每一个元素长度不超过 5 且仅由数字组成. 问能找到多少对 (i,j)(i,j) 可以使得拼接后的 nums[i]+nums[j]nums[i]+nums[j] 长度为偶数且左半部分的数字之和与右半部分的数字之和相等.

思路:

  • 首先最暴力的做法就是 O(n2)O(n^2) 枚举,所有的 (i,j)(i,j) 然后 check 合法性.

  • 尝试优化掉第二层的枚举循环. 对于第二层循环, 我们就是要寻找合适的 nums[j]nums[j] 并和当前的 nums[i]nums[i] 拼接. 显然我们可以通过扫描当前的 nums[i]nums[i]O(1)O(1) 的计算出所有 len(nums[j])len(nums[i])len(nums[j])\le len(nums[i]) 且可以和 nums[i]nums[i] 匹配的字符串的 [长度][数字和] 信息, 只需要一个二维数组预存储每一个字符串的 长度 数字和 信息即可.

  • 那么对于 len(nums[j])>len(nums[i])len(nums[j])> len(nums[i]) 的情况如何统计呢. 显然此时我们没法 O(1)O(1) 的检查 nums[i]+nums[j]nums[i]+nums[j] 的合法性. 不妨换一个角度, 当我们枚举 nums[i]nums[i] 时:

    统计右侧拼接长度更大的 nums[j] 的合法情况数    统计左侧拼接长度更小的 nums[j] 的合法情况数\text{统计右侧拼接长度更大的 nums[j] 的合法情况数} \iff \text{统计左侧拼接长度更小的 nums[j] 的合法情况数}

    于是合法情况数就可以表示为 i=0n1[cond1(nums[i])+cond2(nums[i])]\displaystyle \sum_{i=0}^{n-1}\big[\text{cond}_1(nums[i])+\text{cond}_2(nums[i])\big], 其中第一种情况 cond1\text{cond}_1 就是统计右侧拼接长度更小的字符串数量, 第二种情况 cond1\text{cond}_1 就是统计左侧拼接长度更小的字符串数量. 这两步可以同时计算.

时间复杂度: O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
from typing import List, Tuple, Dict, Optional
from collections import defaultdict, deque
from itertools import combinations, permutations
import math, heapq, queue

II = lambda: int(input())
FI = lambda: float(input())
MII = lambda: tuple(map(int, input().split()))
LII = lambda: list(map(int, input().split()))
LSI = lambda: list(map(str, input().split()))

def solve() -> Optional:
n, nums = II(), LSI()

f = [[0 for _ in range(46)] for _ in range(6)]
for num in nums:
m = len(num)
s = sum([int(c) for c in num])
f[m][s] += 1

res = 0
for num in nums:
m = len(num)
s = [0] * (m + 1)
for i in range(m - 1, -1, -1):
s[i] = s[i + 1] + int(num[i])

# cond1: now + right -> len(now) >= len(right)
for i in range(m - 1, -1, -1):
now_len, now_sum = i + 1, s[0] - s[i + 1]
r_len, r_sum = now_len - (m - 1 - i), now_sum - s[i + 1]
if 1 <= r_len <= now_len and r_sum >= 0:
res += f[r_len][r_sum]

# cond2: left + now -> len(left) < len(now)
for i in range(m):
now_len, now_sum = m - i, s[i]
l_len, l_sum = now_len - i, now_sum - (s[0] - s[i])
if 1 <= l_len < now_len and l_sum >= 0:
res += f[l_len][l_sum]

return res

if __name__ == '__main__':
OUTs = []
N = 1
# N = II()
for _ in range(N):
OUTs.append(solve())
print('\n'.join(map(str, OUTs)))
]]>
@@ -1498,11 +1498,11 @@ - graphs - - /Algorithm/graphs/ + number-theory + + /Algorithm/number-theory/ - 图论

【拓扑】有向图的拓扑序列

https://www.acwing.com/problem/content/850/

题意:输出一个图的拓扑序,不存在则输出-1

思路:

  • 首先我们要知道拓扑图的概念,感官上就是一张图可以从一个方向拓展到全图,用数学语言就是:若一个由图中所有点构成的序列 A 满足:对于图中的每条边 (x,y),x 在 A 中都出现在 y 之前,则称 A 是该图的一个拓扑序列
  • 接着我们就想要寻找这样的序列 A 了,可以发现对于每一个可扩展的点,入度一定为0,那么我们就从这些点开始宽搜,将搜到的点的入度-1,即删除这条边,直到最后。如果全图的点的入度都变为了0,则此图可拓扑

时间复杂度:O(n+m)O(n+m)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <bits/stdc++.h>
using namespace std;

const int N = 100010;

int n, m;
vector<int> G[N];

void solve() {
// 建图
cin >> n >> m;
vector<int> d(n + 1, 0);
for (int i = 1; i <= m; i++) {
int a, b;
cin >> a >> b;
d[b]++;
G[a].push_back(b);
}

// 预处理宽搜起始点集
queue<int> q;
for (int i = 1; i <= n; i++)
if (!d[i])
q.push(i);

// 宽搜处理
vector<int> res;
while (q.size()) {
auto h = q.front();
q.pop();
res.push_back(h);

for (auto& ch: G[h]) {
d[ch]--;
if (!d[ch]) q.push(ch);
}
}

// 输出合法拓扑序
if (res.size() == n) {
for (auto& x: res) {
cout << x << " ";
}
} else {
cout << -1 << "\n";
}
}

int main() {
solve();
return 0;
}

【基环树/拓扑】Mad City🔥

https://codeforces.com/contest/1873/problem/H

tag:基环树、拓扑排序

题意:给定一个基环树,现在图上有两个点,分别叫做A,B。现在B想要逃脱A的抓捕,问对于给定的局面,B能否永远逃离A的抓捕

思路:思路很简单,我们只需要分B所在位置的两种情况讨论即可

  1. B不在环上:此时我们记距离B最近的环上的那个点叫 tagtag,我们需要比较的是A到tag点的距离 dAd_A 和B到tag的距离 dBd_B,如果 dB<dAd_B < d_A,则一定可以逃脱,否则一定不可以逃脱
  2. B在环上:此时我们只需要判断当前的A点是否与B的位置重合即可,如果重合那就无法逃脱,反之B一定可以逃脱。

代码实现:

  1. 对于第一种情况,我们需要找到tag点以及计算A和B到tag点的距离,

时间复杂度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
#include <bits/stdc++.h>
using namespace std;

const int N = 200010;

int n, a, b;
vector<int> G[N];
int rd[N], tag, d[N];
bool del[N], vis[N];

void init() {
for (int i = 1; i <= n; i++) {
G[i].clear(); // 存无向图
rd[i] = 0; // 统计每一个结点的入度
del[i] = false; // 拓扑删点删边时使用
d[i] = 0; // 图上所有点到 tag 点的距离
vis[i] = false; // bfs计算距离时使用
}
}

void topu(int now) {
if (rd[now] == 1) {
rd[now]--;
del[now] = true;
for (auto& ch: G[now]) {
if (del[ch]) continue;
rd[ch]--;
if (now == tag) {
tag = ch;
}
topu(ch);
}
}
}

void bfs() {
queue<int> q;
q.push(tag);
d[tag] = 0;

while (q.size()) {
auto now = q.front();
vis[now] = true;
q.pop();

for (auto& ch: G[now]) {
if (!vis[ch]) {
d[ch] = d[now] + 1;
q.push(ch);
vis[ch] = true;
}
}
}
}

void solve() {
// 初始化
cin >> n >> a >> b;
init();

// 建图
for (int i = 1; i <= n; i++) {
int u, v;
cin >> u >> v;
G[u].push_back(v), rd[v]++;
G[v].push_back(u), rd[u]++;
}

// 拓扑删边 & 缩b点
tag = b;
for (int i = 1; i <= n; i++) {
topu(i);
}

// 判断结果 & 计算距离
if (rd[b] == 2 && a != b) {
// b点在环上
cout << "Yes\n";
} else {
// b不在环上
bfs();
cout << (d[a] > d[b] ? "Yes\n" : "No\n");
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(0);
int T = 1;
cin >> T;
while (T--) solve();
return 0;
}

【二分图】染色法判定二分图

https://www.acwing.com/problem/content/862/

题意:给定一个无向图,可能有重边和自环。问是否可以构成二分图。

二分图的定义:一个图可以被分成两个点集,每个点集内部没有边相连(可以不是连通图)

思路:利用染色法,遍历每一个连通分量,选择连通分量中的任意一点进行染色扩展

  • 如果扩展到的点没有染过色,则染成与当前点相对的颜色
  • 如果扩展到的点已经被染过色了且染的颜色和当前点的颜色相同,则无法构成二分图(奇数环)

时间复杂度:O(n+e)O(n+e)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
const int N = 100010;

int n, m;
vector<int> G[N], col(N);

bool bfs(int u) {
queue<int> q;
q.push(u);
col[u] = 1;

while (q.size()) {
int now = q.front();
q.pop();
for (auto& ch: G[now]) {
if (!col[ch]) {
col[ch] = -col[now];
q.push(ch);
}
else if (col[ch] == col[now]) {
return false;
}
}
}

return true;
}

void solve() {
cin >> n >> m;
while (m--) {
int u, v;
cin >> u >> v;
G[u].push_back(v);
G[v].push_back(u);
}

// 遍历每一个连通分量
for (int i = 1; i <= n; i++) {
if (!col[i]) {
bool ok = bfs(i);
if (!ok) {
cout << "No\n";
return;
}
}
}

cout << "Yes\n";
}

【最小生成树】Kruskal算法求最小生成树

https://www.acwing.com/problem/content/861/

题意:给定一个无向图,可能含有重边和自环。试判断能否求解其中的最小生成树,如果可以给出最小生成树的权值

思路:根据数据量,可以发现顶点数很大,不适用 PrimPrim 算法,只能用 KruskalKruskal 算法,下面简单介绍一下该算法的流程

  • 自环首先排除 - 显然这条边连接的“两个”顶点是不可能选进 MSTMST
  • 首先将每一个结点看成一个连通分量
  • 接着按照权值将所有的边升序排序后,依次选择
    • 如果选出的这条边的两个顶点不在一个连通分量中,则选择这条边并将两个顶点所在的连通分量合并
    • 如果选出的这条边的两个顶点在同一个连通分量中,则不能选择这条边(否则会使得构造的树形成环)
  • 最后统计选择的边的数量 numnum 进行判断即可
    • num=n1num=n-1,则可以生成最小生成树
    • num<n1num<n-1,则无法生成最小生成树
  • 时间复杂度:O(eloge)O(e\log e)​ - 因为最大的时间开销在对所有的边的权值进行排序上

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 100010;

struct edge {
int a, b;
int w;
};

int n, m;
vector<edge> edges;
vector<int> p(N);

int Find(int now) {
if (p[now] != now) {
p[now] = Find(p[now]);
}
return p[now];
}

void solve() {
cin >> n >> m;
for (int i = 1; i <= m; i++) {
int a, b, w;
cin >> a >> b >> w;
if (a == b) {
continue;
}
edges.push_back({a, b, w});
}

// 按照边权升序排序
sort(edges.begin(), edges.end(), [&](edge& x, edge& y) {
return x.w < y.w;
});

// 选边
for (int i = 1; i <= n; i++) {
p[i] = i;
}

int res = 0, num = 0;

for (auto& e: edges) {
int pa = Find(e.a), pb = Find(e.b);
if (pa != pb) {
num++;
p[pa] = pb;
res += e.w;
}

if (num == n - 1) {
break;
}
}

// 特判:选出来的边数无法构成一棵树
if (num < n - 1) {
cout << "impossible\n";
return;
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
def Find(x: int, p: list) -> int:
if p[x] != x: p[x] = Find(p[x], p)
return p[x]

def kruskal(n: int, m: int, edges: list) -> int:
# 按边权对边进行降序排序
edges.sort(key=lambda edge: edge[-1])

# dsu 初始化
p = [None] + [i for i in range(1, n + 1)]

# 选边
cnt = sum = 0
for edge in edges:
if cnt == n - 1: break

pa, pb = Find(edge[0], p), Find(edge[1], p)
if pa != pb:
p[pa] = pb
cnt += 1
sum += edge[2]

return sum if cnt == n - 1 else 0


if __name__ == "__main__":
n, m = map(int, input().split())

edges = []
for i in range(m):
edge = tuple(map(int, input().split()))
edges.append(edge)

res = kruskal(n, m, edges)

if res: print(res)
else: print("impossible")

JavaScript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});

let n = null;
let m = null;
const edges = [];

rl.on('line', line => {
const [a, b, c] = line.split(' ').map(i => Number(i));
if (n === null) {
n = a;
m = b;
} else {
edges.push([a, b, c]);
}
});

rl.on('close', () => {
const res = kurskal(n, m, edges);
console.log(res === Infinity ? 'impossible' : res);
});

function Find(x, p) {
if (p[x] != x) p[x] = Find(p[x], p);
return p[x];
}

function kurskal(n, m, edges) {
// 对边进行升序排序
edges.sort((a, b) => a[2] - b[2]);

// 初始化 dsu
p = [];
for (let i = 1; i <= n; i++) p[i] = i;

// 选边
let cnt = 0, sum = 0;
for (let [a, b, w] of edges) {
if (cnt == n - 1) {
break;
}

let pa = Find(a, p), pb = Find(b, p);
if (pa !== pb) {
cnt++;
p[pa] = pb;
sum += w;
}
}

if (cnt === n - 1) return sum;
else return Infinity;
}

【最小生成树】Prim算法求最小生成树

https://www.acwing.com/problem/content/860/

题意:给定一个稠密无向图,有重边和自环。求出最小生成树

思路:根据题目的数据量,可以使用邻接矩阵存储的方法配合 PrimPrim 算法求解最小生成树,下面给出该算法的流程

  • 首先明确一下变量的定义:
    • g[i][j] 为无向图的邻接矩阵存储结构
    • MST[i] 表示 ii 号点是否加入了 MSTMST 集合
    • d[i] 表示 i 号点到 MSTMST 集合的最短边长度
  • 自环不存储,重边只保留最短的一条
  • 任选一个点到集合 MSTMST 中,并且更新 dd 数组
  • 选择剩余的 n1n-1 个点,每次选择有以下流程
    • 找到最短边,记录最短边长度 ee 和相应的在 UMSTU-MST 集合中对应的顶点序号 vv
    • vv 号点加入 MSTMST 集合,同时根据此时选出的最短边的长度来判断是否存在最小生成树
    • 根据 vv 号点,更新 dd 数组,即更新在集合 UMSTU-MST 中的点到 MSTMST 集合中的点的交叉边的最短长度
  • 时间复杂度:O(n2)O(n^2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 510;

int n, m;
vector<vector<int>> g(N, vector<int>(N, INT_MAX));
vector<int> d(N, INT_MAX); // d[i]表示i号点到MST集合中的最短边长度
bool MST[N];
int res;

void prim() {
// 选任意一个点到MST中并更新d数组
MST[1] = true;
for (int i = 1; i <= n; i++)
if (!MST[i])
d[i] = min(d[i], g[i][1]);

// 选剩下的n-1个点到MST中
for (int i = 2; i <= n; i++) {
// 1. 找到最短边
int e = INT_MAX, v = -1; // e: 最短边长度,v: 最短边不在MST集合中的顶点
for (int j = 1; j <= n; j++)
if (!MST[j] && d[j] < e)
e = d[j], v = j;

// 2. 加入MST集合
MST[v] = true;
if (e == INT_MAX) {
// 特判无法构造MST的情况
cout << "impossible\n";
return;
} else {
res += e;
}

// 3. 更新交叉边 - 迭代(覆盖更新)
for (int j = 1; j <= n; j++)
if (!MST[j])
d[j] = min(d[j], g[j][v]);
}

cout << res << "\n";
}

void solve() {
cin >> n >> m;
while (m--) {
int a, b, w;
cin >> a >> b >> w;

if (a == b) {
continue;
}

if (g[a][b] == INT_MAX) {
g[a][b] = w;
g[b][a] = w;
} else {
g[a][b] = min(g[a][b], w);
g[b][a] = min(g[b][a], w);
}
}

prim();
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【最短路】Dijkstra求最短路 🔥

朴素版 - https://www.acwing.com/problem/content/851/

堆优化 - https://www.acwing.com/problem/content/852/

题意:给定一个正边权的有向图,可能存在重边与自环,问 11 号点到 nn 号点的最短路径长度是多少,如果不可达就输出 1-1

思路一:朴素版。点数 1n5001\le n \le 500,边数 1m1051 \le m\le 10^5

  • 思路:根据数据量,我们采用邻接矩阵的方式存储「点少边多」的稠密图。我们定义 d[i] 数组表示起点到 i 号点的最短距离。先将起点放入 SPT (Shortest Path Tree) 集合,然后更新所有 V-SPT 中的点到 SPT 集合的最短路径长度。接着循环 n-1 次迭代更新剩余的 n-1 个点,每次迭代的过程中,首先选择距离起点最近的点 vex,然后将该点加入 SPT 集合,最后利用该点更新 V-SPT 集合中和该点有连边的点到起点的最短距离。最终的 d[end] 就是起点 start 到终点 end 的最短距离。

  • 总结:算法整体采用贪心与动态规划的思路。与 Prim\text{Prim} 算法仔细比对可知,其中的贪心过程几乎一致,即每次选择加入 SPT 集合的点均为当前局面 V-SPT 集合中距离起点最近的点。而动态规划的过程体现在,在求解出集合 V-SPT 中到集合 STP 最短距离的点 vex 之后,利用该点对「在 V-SPT 集合且和 vex 点有连边的点 i」更新 d[i] 的过程。更新前的状态都是在之前的子结构下的最优解。

  • 时间复杂度:O(n2)O(n^2)

思路二:堆优化。点数 1n1.5×1051\le n \le 1.5 \times 10^5,边数 1m1.5×1051 \le m \le 1.5 \times 10^5

  • 思路:根据数据量,我们采用邻接表的方式存储「点多边少」的稀疏图。如果采用上述朴素 Dijkstra 算法进行求解必然会因为点数过多而超时,因此我们利用数据结构「堆」进行时间开销上的优化。不难发现朴素 Dijkstra 算法在迭代过程中主要有三部分:

    1. 选择距离起点最近的点 vex。因为需要枚举所有的顶点,因此总的时间复杂度为 O(n2)O(n^2)
    2. 将该点加入 SPT 集合。因为只是简单的打个标记,因此总的时间复杂度为 O(n)O(n)
    3. 利用该点更新 V-SPT 集合中和该点相连的点到起点的最短距离。因为此时枚举的是该点所有的连边,而邻接表的图存储方式无法进行重边的删除,因此最坏情况下会枚举所有的边,时间复杂度为 O(m)O(m)
  • 时间复杂度:

朴素版C++:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

int dijkstra_ori(std::vector<std::vector<int>>& g, int start, int end) {
int n = g.size() - 1;
std::vector<int> d(n + 1, INT_MAX >> 1);
std::vector<bool> SPT(n + 1, false);

// update start vex
d[start] = 0;
SPT[start] = true;
for (int i = 1; i <= n; i++) {
if (!SPT[i] && g[start][i] != INT_MAX >> 1) {
d[i] = std::min(d[i], d[start] + g[start][i]);
}
}

// update remain n-1 vex
for (int k = 0; k < n - 1; k++) {
int vex = -1;
for (int i = 1; i <= n; i++) {
if (!SPT[i] && (vex == -1 || d[i] < d[vex])) {
vex = i;
}
}
SPT[vex] = true;
for (int i = 1; i <= n; i++) {
if (!SPT[i] && g[vex][i] != INT_MAX >> 1) {
d[i] = std::min(d[i], d[vex] + g[vex][i]);
}
}
}

return d[end] == INT_MAX >> 1 ? -1 : d[end];
}

void solve() {
int n, m;
cin >> n >> m;

vector<vector<int>> g(n + 1, vector<int>(n + 1, INT_MAX >> 1));

while (m--) {
int u, v, w;
cin >> u >> v >> w;
g[u][v] = min(g[u][v], w);
}

cout << dijkstra_ori(g, 1, n) << "\n";
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
// std::cin >> T;
while (T--) solve();
return 0;
}

朴素版Python:

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
import heapq
from collections import defaultdict
from typing import List, Tuple
import math
from itertools import combinations

II = lambda: int(input())
FI = lambda: float(input())
MII = lambda: tuple(map(int, input().split()))
LII = lambda: list(map(int, input().split()))


def dijkstra_ori(g: List[List[int]], start: int, end: int) -> int:
n = len(g) - 1
d = [10 ** 5] * (n + 1)
SPT = [False] * (n + 1)

d[start] = 0
SPT[start] = True
for i in range(1, n + 1):
if not SPT[i] and g[start][i] != 10 ** 5:
d[i] = min(d[i], d[start] + g[start][i])

for _ in range(n - 1):
vex = -1
for i in range(1, n + 1):
if not SPT[i] and (vex == -1 or d[i] < d[vex]):
vex = i
SPT[vex] = True
for i in range(1, n + 1):
if not SPT[i] and g[vex][i] != 10 ** 5:
d[i] = min(d[i], d[vex] + g[vex][i])

return -1 if d[end] == 10 ** 5 else d[end]


def solve() -> None:
n, m = MII()
g = [[10 ** 5] * (n + 1) for _ in range(n + 1)]
for _ in range(m):
u, v, w = MII()
g[u][v] = min(g[u][v], w)
print(dijkstra_ori(g, 1, n))


if __name__ == '__main__':
T = 1
# T = II()
while T: solve(); T -= 1

堆优化版C++:

1
2
3
4
5
6
```

堆优化版Python:

```python

【最短路】Floyd求最短路

https://www.acwing.com/problem/content/856/

题意:给定一个稠密有向图,可能存在重边与自环,给出多个询问,需要给出每一个询问的两个点之前的最短路径长度

思路:我们采用动态规划的思路。在此使用多阶段决策的方法,即每一个路径状态为选择 1k1\to k 个点的情况下的最短路径长度

  • 状态表示:f[k][i][j] 表示在前 kk 个顶点中进行选择(中转),ii 号点到 jj 号点的最短路径长度
  • 状态转移:对于第 kk 个顶点,我们可以选择中转,也可以不中转。
    • 对于不选择中转的情况:f[k][i][j] = f[k-1][i][j]
    • 对于可选择中转的情况:f[k][i][j] = f[k-1][i][k] + f[k-1][k][j]
    • 在其中取最小值即可,但是有一个注意点:对于第二种情况,选择是有一个约束的:即如果选择了 kk 号点进行转移的话,那么 ii 号点到 kk 号点以及 kk 号点到 jj 号点都是需要有路径可达的,从而可以选择最小距离
  • 初始化:即选择 0 个站点进行中转时,即 f[0][i][j] 的情况中,
    • 如果 ii 号点与 jj 号点自环,则取 00
    • 如果 ii 号点与 jj 号点之间有边,则取重边的最小值
    • 如果 ii 号点与 jj 号点之间无边,则初始化为正无穷
  • 答案状态:对于 aa 号点到 bb 号点之间的最小路径长度,就是 f[n][a][b]
  • 时间复杂度:O(n3)O(n^3)
  • 空间复杂度:O(n3)O(n^3)

空间优化推导:我们尝试优化掉记忆数组的第一维度

  • 对于不选择的情况:由于决策局面 kk 是从前往后枚举,故当前状态 f[k][i][j] 可以直接依赖于已经更新出来且不会被当前状态之后的状态再次覆盖的最优子结构 f[i][j]。即上一个局面的选择情况,就是不选择第 kk 个顶点的情况

  • 对于选择的情况:如果删除第一维度,我们担心的是当前状态 f[k][i][j] 依赖的两个状态 f[i][k]f[k][j] 会不会被后续覆盖掉,即我们不确定 f[i][k]f[k][j] 是否是当前第 k 个局面的最优子结构。尝试推导:

    为了确定 f[i][k]f[k][j] 是否是当前第 kk 个局面的最优子结构,其实就是确定对于当前第 kk 个局面,这两个状态会不会在当前状态 f[i][j] 之后被更新覆盖,那么我们就看这两个状态是从哪里转移过来进行更新的。如果 f[i][k]f[k][j] 这两个状态的转移会依赖于当前状态之后的状态,那么删除第一维度就是错误的,反之就是成立的。

    尝试推导 f[i][k]f[k][j] 从何转移更新:利用我们未删除维度时正确的状态转移方程进行推演

    我们知道:f[k][i][k] = min(f[k-1][i][k], f[k-1][i][k] + f[k-1][k][k]),其中的 f[k-1][k][k] 就是一个自环的路径长度,由于 floydfloyd 算法的约束条件是没有负环,因此 f[k-1][k][k] 一定大于零,故 f[k][i][k] 一定取前者,即 f[k][i][k] = f[k-1][i][k]

    同理可知:

    f[k][k][j] = f[k-1][k][j]

    基于上述推导我们可以知道,当前第 kk 个决策局面中的 f[k][i][k]f[k][k][j] 是依赖于上一个决策局面 k1k-1 的,也就是说这两个状态一定是早于当前状态 f[i][j] 被更新覆盖的,故 f[i][k]f[k][j] 就是当前第 kk 个局面的最优子结构,证毕,可以进行维度的删除

  • 时间复杂度:O(n3)O(n^3)

  • 空间复杂度:O(n2)O(n^2)

不优化空间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <bits/stdc++.h>
using namespace std;

const int N = 210, INF = 0x3f3f3f3f;

int n, m, Q;
int f[N][N][N];

int main() {
cin >> n >> m >> Q;

// init
memset(f, INF, sizeof f);

// add edges and generate base
while (m--) {
int a, b, w;
cin >> a >> b >> w;
if (a == b) continue; // 重边就不赋值
else if (f[0][a][b] == INF) f[0][a][b] = w; // 第一次加边则直接赋值
else f[0][a][b] = min(f[0][a][b], w); // 再次赋边权就取最小值
}

// generate base again
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (i == j)
f[0][i][j] = 0; // 自环取边权为 0

// dp
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++) {
// 不选第k个顶点
f[k][i][j] = f[k - 1][i][j];

// 选择第k个顶点
if (f[k - 1][i][k] != INF && f[k - 1][k][j] != INF)
f[k][i][j] = min(f[k][i][j], f[k - 1][i][k] + f[k - 1][k][j]);
}

// query
while (Q--) {
int a, b;
cin >> a >> b;
if (f[n][a][b] == INF) cout << "impossible\n";
else cout << f[n][a][b] << "\n";
}

return 0;
}

优化空间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <bits/stdc++.h>
using namespace std;

const int N = 210, INF = 0x3f3f3f3f;

int n, m, Q;
int f[N][N];

int main() {
cin >> n >> m >> Q;

// init
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (i == j) f[i][j] = 0;
else f[i][j] = INF;

// base
while (m--) {
int a, b, w;
cin >> a >> b >> w;
if (a == b) continue;
else if (f[a][b] == INF) f[a][b] = w;
else f[a][b] = min(f[a][b], w);
}

// dp
for (int k = 1; k <= n; k++)
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (f[i][k] != INF && f[k][j] != INF)
f[i][j] = min(f[i][j], f[i][k] + f[k][j]);

// query
while (Q--) {
int a, b;
cin >> a >> b;
if (f[a][b] == INF) cout << "impossible\n";
else cout << f[a][b] << "\n";
}

return 0;
}

【最短路】关闭分部的可行集合数目

https://leetcode.cn/problems/number-of-possible-sets-of-closing-branches/

标签:二进制枚举、最短路

题意:给定一个含有 nn 个顶点的无向图,如何删点可以使得剩余的图中顶点两两可达且最大距离不超过 maxDistance?返回所有删点的方案数。

思路:由于 nn 的数据范围只有 1101 \to 10,我们可以直接枚举所有的删点方案。那么如何检查一个方案的合法性呢?直接使用最短路算法检查「所有顶点到每一个顶点」的最远距离即可。这里我们采用朴素 dijkstra 算法。

时间复杂度:O(2n×n3)O(2^n \times n^3) - 其中枚举需要 O(2n)O(2^n)、计算所有顶点到某个顶点的最远距离需要 O(n2)O(n^2)、检查所有顶点需要 O(n)O(n)

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
class Solution {
public:
int numberOfSets(int n, int maxDistance, vector<vector<int>>& roads) {
vector<vector<int>> g(n, vector<int>(n, INT_MAX >> 1));
for (auto& r: roads) {
int u = r[0], v = r[1], w = r[2];
g[u][v] = g[v][u] = min(g[u][v], w);
}

auto get_max_dist = [&](int mask, int v) {
vector<bool> SPT(n);
vector<int> d(n, INT_MAX);

d[v] = 0;
SPT[v] = true;

int cnt = 0;
for (int i = 0; i < n; i++) {
if (mask & (1 << i) && !SPT[i]) {
cnt++;
d[i] = min(d[i], d[v] + g[v][i]);
}
}

for (int k = 1; k <= cnt - 1; k++) {
int vex = -1;
for (int i = 0; i < n; i++) {
if (mask & (1 << i) && !SPT[i] && (vex == -1 || d[i] < d[vex])) {
vex = i;
}
}
SPT[vex] = true;
for (int i = 0; i < n; i++) {
if (mask & (1 << i) && !SPT[i]) {
d[i] = min(d[i], d[vex] + g[vex][i]);
}
}
}

int max_dist = -1;
for (int i = 0; i < n; i++) {
if (mask & (1 << i)) {
max_dist = max(max_dist, d[i]);
}
}

return max_dist;
};

int res = 0;
for (int mask = 0; mask < 1 << n; mask++) {
bool ok = true;
for (int i = 0; i < n; i++) {
if (mask & (1 << i) && get_max_dist(mask, i) > maxDistance) {
ok = false;
break;
}
}
res += ok;
}

return res;
}
};
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class Solution:
def numberOfSets(self, n: int, maxDistance: int, roads: List[List[int]]) -> int:
g = [[10 ** 6 for _ in range(n)] for _ in range(n)]
for u, v, w in roads:
g[u][v] = g[v][u] = min(g[u][v], w)

def get_max_dist(mask: int, v: int):
SPT = [False for _ in range(n)]
d = [10 ** 6 for _ in range(n)]

SPT[v] = True
d[v] = 0

cnt = 0
for i in range(n):
if mask & (1 << i) and not SPT[i]:
cnt += 1
d[i] = min(d[i], d[v] + g[v][i])

for _ in range(cnt - 1):
vex = -1
for i in range(n):
if mask & (1 << i) and not SPT[i] and (vex == -1 or d[i] < d[vex]):
vex = i
SPT[vex] = True
for i in range(n):
if mask & (1 << i) and not SPT[i]:
d[i] = min(d[i], d[vex] + g[vex][i])

max_dist = -1
for i in range(n):
if mask & (1 << i):
max_dist = max(max_dist, d[i])

return max_dist

res = 0
for mask in range(1 << n):
ok = True
for i in range(n):
if mask & (1 << i) and get_max_dist(mask, i) > maxDistance:
ok = False
break
res += ok

return res

【思维/遍历】图的遍历

https://www.luogu.com.cn/problem/P3916

题意:给定一个有向图,求解每一个点可以到达的编号最大的点

思路:如果从正向考虑,很显然的一个暴力方法就是对于每一个点都跑一遍 dfs 或者 bfs 获取可达的最大点编号,时间复杂度 O(n2)O(n^2),如果想要在遍历的过程中同时更新其余的点,那只有起点到最大点之间的点可以被更新,可以通过递归时记录路径点进行,时间复杂度几乎不变。我们尝试反向考虑:反向建边。既然正向考虑时需要标记的点为最大点与起点的路径,那不如直接从最大值点开始遍历搜索,在将所有的边全部反向以后,从最大值点开始遍历图,这样就可以在线性时间复杂度内解决问题

时间复杂度:O(n+m)O(n+m)

bfs 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <iostream>
#include <queue>
#include <cstring>
#include <vector>
using namespace std;

const int N = 100010;

int n, m;
vector<int> g[N], res(N);

void bfs(int now) {
queue<int> q;

res[now] = now;
q.push(now);

while (q.size()) {
int h = q.front();
q.pop();
for (auto& ch: g[h]) {
if (!res[ch]) {
res[ch] = now;
q.push(ch);
}
}
}
}

void solve() {
cin >> n >> m;
while (m--) {
int a, b;
cin >> a >> b;
g[b].push_back(a);
}

for (int i = n; i >= 1; i--) {
if (!res[i]) {
bfs(i);
}
}

for (int i = 1; i <= n; i++) {
cout << res[i] << ' ';
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

dfs 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <iostream>
#include <queue>
#include <cstring>
#include <vector>
using namespace std;

const int N = 100010;

int n, m, val;
vector<int> g[N], res(N);

void dfs(int now) {
res[now] = val;
for (auto& ch: g[now]) {
if (!res[ch]) {
dfs(ch);
}
}
}

void solve() {
cin >> n >> m;
while (m--) {
int a, b;
cin >> a >> b;
g[b].push_back(a);
}

for (int i = n; i >= 1; i--) {
if (!res[i]) {
val = i;
dfs(i);
}
}

for (int i = 1; i <= n; i++) {
cout << res[i] << ' ';
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【遍历/并查集】孤立点数量

https://www.acwing.com/problem/content/5560/

题意:给定一个无向图,可能不连通,没有重边和自环。现在需要给图中的每一条无向边定向,要求所有的边定完向以后 0 入度的点尽可能的少,给出最少的 0 入度点的数量

思路:我们知道对于一棵树而言,n 个结点一共有 n-1 条边,也就可以贡献 n-1 个入度,因此至少有一个点的入度为 0。而如果不是一棵树,就会有至少 n 条边,也就至少可以贡献 n 个入度,那么 n 个结点就至少全都有入度了。显然的,一个图至少含有 n 条边时就一定有环。有了上述思路以后就可以发现,这道题本质上就是在判断连通分量是否含有环,如果有环,那么该连通分量定向边以后就不会产生 0 入度的顶点,反之,如果没有环,那么定向边以后最少产生 1 个 0 入度的点。

  • 算法一:遍历图。我们采用 dfs 遍历的方式即可解决问题。一边遍历一边打标记,遇到已经打过标记的非父结点就表明当前连通分量有环。我们使用 C++ 实现

    时间复杂度:O(n+m)O(n+m)

  • 算法二:并查集。由于没有重边,因此在判断每一个连通分量是否含有环时,可以直接通过该连通分量中点和边的数量关系得到结果。我们使用 Python 和 JavaScript 实现

    时间复杂度:O(n)O(n)

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// 实现 dfs 算法

#include <iostream>
#include <cstring>
#include <vector>
#include <queue>
#include <stack>
#include <algorithm>
#include <unordered_map>
#include <set>
using namespace std;

const int N = 100010;

int n, m;
vector<int> G[N];
bool vis[N];

void dfs(int fa, int now, bool& hasLoop) {
vis[now] = true;
for (auto& ch: G[now]) {
if (ch != fa) {
if (vis[ch]) hasLoop = true;
else dfs(now, ch, hasLoop);
}
}
}

void solve() {
cin >> n >> m;
while (m--) {
int a, b;
cin >> a >> b;
G[a].push_back(b);
G[b].push_back(a);
}

int res = 0;

for (int i = 1; i <= n; i++) {
if (!vis[i]) {
bool hasLoop = false;
dfs(-1, i, hasLoop);
if (!hasLoop) res++;
}
}

cout << res << "\n";
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

Python

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
# 实现 dsu 算法

p = [_ for _ in range(100010)]


def Find(x: int) -> int:
if x != p[x]: p[x] = Find(p[x])
return p[x]


def solve() -> None:
n, m = map(int, input().split())

edgeNum = [0] * (n + 1) # 每个点的连边数

for _ in range(m):
u, v = map(int, input().split())
edgeNum[u] += 1
edgeNum[v] += 1
p[Find(u)] = Find(v)

union = {}

class node:
def __init__(self):
self.v = self.e = 0

for i in range(1, n + 1):
nowp = Find(i)
if nowp not in union: union[nowp] = node()

union[nowp].v += 1
union[nowp].e += edgeNum[i]

res = 0

for comp in union:
if union[comp].e >> 1 == union[comp].v - 1:
res += 1

print(res)


if __name__ == "__main__":
solve()

JavaScript

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
// 实现 dsu 算法

const readline = require('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});

let n = null;
let m = null;
let p = [], edgeNum = [];

rl.on('line', line => {
const [a, b] = line.split(' ').map(i => Number(i));
if (n === null) {
n = a;
m = b;
for (let i = 1; i <= n; i++) {
p[i] = i;
edgeNum[i] = 0;
}
} else {
edgeNum[a]++;
edgeNum[b]++;
p[Find(a)] = Find(b);
}
});

rl.on('close', () => {
const res = solve();
console.log(res);
});

function Find(x) {
if (x != p[x]) p[x] = Find(p[x]);
return p[x];
}

function solve() {
let res = 0;

// 自定义结构体
class Node {
constructor() {
this.v = 0;
this.e = 0;
}
}

/*
另一种结构体定义方法
function Node() {
this.v = 0;
this.e = 0;
}
*/

// 哈希
let union = new Map();

for (let i = 1; i <= n; i++) {
let nowp = Find(i); // 当前结点的祖宗结点 nowp
if (!union.has(nowp)) union.set(nowp, new Node());

union.get(nowp).v += 1;
union.get(nowp).e += edgeNum[i];
}

// 判断
for (let i of union.keys()) {
if (union.get(i).e >> 1 === union.get(i).v - 1) {
res++;
}
}

return res;
}

【LCA】树的直径

https://www.acwing.com/problem/content/5563/

题意:给定一棵树,初始时含有 4 个结点分别为 1 到 4,其中 1 号为根结点,2 到 4 均为根结点的叶子结点。现在进行 Q 次操作,每次指定一个已经存在的结点向其插入两个新结点作为叶节点。现在需要在每次操作以后输出这棵树的直径。我们定义树的直径为:树中距离最远的两个点之间的距离。

思路一:暴力搜索。

  • 我们将树重构为无向图,对于每一个状态的无向图,首先从任意一个已存在的结点 A 开始搜索到距离他最远的点 B,然后从 B 点出发搜索到离他最远的点 C,则 B 与 C 之间的距离就是当前状态的树的直径。由于每一个状态的树都要遍历两遍树,于是时间复杂度就是平方阶

  • 时间复杂度:O(qn)O(qn)

思路二:最近公共祖先 LCA。

  • 从树的直径出发。我们知道,树的直径由直径的两个结点之间的距离决定,因此我们着眼于这两个结点 AABB 展开。不妨设当前局面直径的两个结点已知为 AABB,现在插入两个叶子结点 L1L_1L2L_2。是否改变了树的直径大小取决于新插入的两个结点对于当前树的影响情况。如果 L1L_1L2L_2 可以替代 AABB,则树的直径就会改变。很显然新插入的两个叶子结点对于直径的两个端点影响是同效果的,因此我们统称新插入的叶子结点为 LL

  • 什么时候树的直径会改变?对于 AABBLL 来说,直径是否改变取决于 LL 能否替代 AABB,一共有六种情况。我们记 dist(A,L)=da\text{dist}(A,L)=dadist(B,L)=db\text{dist}(B,L)=db,当前树的直径为 resres,六种情况如下:

    1. max(da,db)res\text{max}(da, db) \le \text{res},交换 AABB 得到 22
    2. min(da,db)res\text{min}(da,db) \ge \text{res},交换 AABB 得到 22
    3. max(da,db)>res,min(da,db)<res\text{max}(da,db) >res,\text{min}(da,db) < \text{res},交换 AABB 得到 22​ 种

    如图:我们只需要在其中的最大值严格超过当前树的直径 res\text{res} 时更新直径对应的结点以及直径的长度即可

    六种情况

  • 如何快速计算树上任意两个点之间的距离?我们可以使用最近公共祖先 LCA 算法。则树上任意两点 x,yx,y 之间的距离 dist(x,y)\text{dist}(x,y) 为:

    dist(x,y)=dist(x,root)+dist(y,root)2×dist(lca(x,y),root)\text{dist}(x,y) = \text{dist}(x,root) + \text{dist}(y,root) - 2 \times \text{dist}(\text{lca}(x,y),root)

  • 时间复杂度:O(qlogn)O(q \log n)

暴力搜索代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
#include <iostream>
#include <cstring>
#include <vector>
#include <queue>
#include <stack>
#include <algorithm>
#include <unordered_map>
#include <set>
using namespace std;

const int N = 500010;

vector<int> g[N];
int d[N];
bool vis[N];
pair<int, int> res; // first 为最远距离;second 为对应结点编号

void dfs(int pre, int now) {
if (vis[now]) return;

vis[now] = true;

if (pre != -1) {
d[now] = d[pre] + 1;
if (d[now] > res.first) {
res = {d[now], now};
}
}

for (auto& ch: g[now]) {
dfs(now, ch);
}
}

void solve() {
// init
for (int i = 2; i <= 4; i++) {
g[1].push_back(i);
g[i].push_back(1);
}

int now = 4;

int Q;
cin >> Q;
while (Q--) {
int id;
cin >> id;

g[id].push_back(++now);
g[now].push_back(id);

g[id].push_back(++now);
g[now].push_back(id);

res = {-1, -1};

// 第一趟
memset(vis, false, sizeof vis);
memset(d, 0, sizeof d);
d[1] = 0;
dfs(-1, 1);

// 第二趟
memset(vis, false, sizeof vis);
memset(d, 0, sizeof d);
d[res.second] = 0;
dfs(-1, res.second);

cout << res.first << "\n";
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

LCA 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
#include <iostream>
#include <cstring>
#include <vector>
#include <queue>
#include <stack>
#include <algorithm>
#include <unordered_map>
#include <set>
using namespace std;

const int N = 1000010, M = 20;

int d[N]; // d[i] 表示 i 号点到根结点的距离
int to[N][M]; // to[i][j] 表示 i 号点向上跳 2^j 步后到达的结点编号

int lca(int a, int b) {
if (d[a] < d[b]) swap(a, b);

for (int k = M - 1; k >= 0; k--)
if (d[to[a][k]] >= d[b])
a = to[a][k];

if (a == b) return a;

for (int k = M - 1; k >= 0; k--)
if (to[a][k] != to[b][k])
a = to[a][k], b = to[b][k];

return to[a][0];
}

int dist(int a, int b) {
return d[a] + d[b] - 2 * d[lca(a, b)];
}

void solve() {
int Q;
cin >> Q;

// init lca
for (int i = 2; i <= 4; i++) {
d[i] = 1;
to[i][0] = 1;
}

int A = 2, B = 4, now = 4, res = 2;

while (Q--) {
int fa;
cin >> fa;

int L1 = ++now, L2 = ++now;

// upd lca
d[L1] = d[fa] + 1;
d[L2] = d[fa] + 1;
to[L1][0] = fa;
to[L2][0] = fa;
for (int k = 1; k <= M - 1; k++) {
to[L1][k] = to[ to[L1][k-1] ][ k-1 ];
to[L2][k] = to[ to[L2][k-1] ][ k-1 ];
}

int da = dist(A, L1), db = dist(B, L1);

if (max(da, db) <= res) res = res;
else if (min(da, db) >= res) {
if (da > db) res = da, B = L1;
else res = db, A = L1;
} else {
if (da > db) res = da, B = L1;
else res = db, A = L1;
}

cout << res << "\n";
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}
]]>
+ 数论

整数问题。

【质数】Divide and Equalize

题意:给定 nn 个数,问能否找到一个数 numnum,使得 numn=i=1nainum^n = \prod_{i=1}^{n}a_i

原始思路:起初我的思路是二分,我们需要寻找一个数使得n个数相乘为原数组所有元素之积,那么我们预计算出所有数之积,并且在数组最大值和最小值之间进行二分,每次二分出来的数计算n次方进行笔比较即可。但是有一个很大的问题是,数据量是 10410^4,而数字最大为 10610^6,最大之积为 101010^{10} 吗?不是!最大之和才是,最大之积是 106×10410^{6\times10^4}

最终思路:我们可以将选数看做多个水池匀水的过程。现在每一个水池的水高都不定,很显然我们一定可以一个值使得所有的水池的高度一致,即 i=1nain\frac{\sum_{i=1}^{n}a_i}{n}。但是我们最终的数字是一个整数,显然不可以直接求和然后除以n,那么应该如何分配呢?我们知道水池之所以可以直接除以n,是因为水的最小分配单位是无穷小,可以随意分割;而对于整数而言,最小分配单位是什么呢?答案是质因子!为了通过分配最小单位使得最终的“水池高度一致”,我们需要让每一个“水池”获得的数值相同的质因子数量相同。于是我们只需要统计一下数组中所有数的质因子数量即可。如果对于每一种质因子的数量都可以均匀分配每一个数(即数量是n的整数倍),那么就一定可以找到这个数使得 numn=i=1nainum^n = \prod_{i=1}^{n}a_i

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
void solve() {
int n;
cin >> n;

// 统计所有数字的所有质因数
unordered_map<int, int> m;
for (int i = 0; i < n; i++) {
int x;
cin >> x;

for (int k = 2; k <= x / k; k++) {
if (x % k == 0) {
while (x % k == 0) {
m[k]++;
x /= k;
}
}
}

if (x > 1) {
m[x]++;
}
}

// 查看每一种质因数是否是n的整数倍
for (auto& x: m) {
if (x.second % n) {
cout << "No\n";
return;
}
}

cout << "Yes\n";
}

【整除】Deja Vu

https://codeforces.com/contest/1891/problem/B

题意:给点序列 a 和 b,对于 b 中的每一个元素 bib_i,如果 a 中的元素 aja_j 能够整除 2bi2^{b_i},则将 aja_j 加上 2bi12^{b_i - 1}。给出最后的 a 序列

思路一:暴力枚举。

  • 我们很容易就想到暴力的做法,即两层循环,第一层枚举 b 中的元素,第二层枚举 a 中的元素,如果 a 中的元素能够整除 2 的 bib^i 次方,就将 a 中相应的元素加上一个值即可。但是时间复杂度肯定过不了,考虑优化。

  • 时间复杂度:O(nm)O(nm)

思路二:整除优化。

  • 现在我们假设a中有一个数 aja_j2bi2^{b_i} 的整数倍(其中 bib_i 是b序列中第一个枚举到的能够让 aia_i 整除的数),那么就有 aj=k2bi(k=1,2,...,)a_j = k2^{b_i}(k=1,2,...,),那么 aja_j 就要加上 2bi12^{b_i-1},于是 aja_j 就变为了 k2bi+2bi1=(2k+1)2bi1k2^{b_i}+2^{b_i-1}=(2k+1)2^{b_i-1}。此后 aja_j 就一定是 2t(t[1,bi1])2^t(t\in \left[ 1,b_i-1 \right]) 的倍数。因此我们需要做的就是首先找到b序列中第一个数x,能够在a中找到数是 2x2^x 的整数倍。这一步可以这样进行:对于 a中的每一个数,我们进行30次循环统计当前数是否是 2i2^i 的倍数,如果是就用哈希表记录当前的 ii。最后我们在遍历寻找 x 时,只需要查看当前的 x 是否被哈希过即可。接着我们统计b序列中从x开始的严格降序序列c(由题意知,次序列的数量一定 \le 30,因为b序列中数值的值域为 [1 30][1~30])。最后我们再按照原来的思路,双重循环 a 序列和 c 序列即可。

  • 时间复杂度:O(30n)O(30n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
void solve() {
int n, m;
cin >> n >> m;

vector<int> a(n + 1), b(m + 1);
unordered_map<int, int> ha;

// 边读边哈希
for (int i = 1; i <= n; i++) {
cin >> a[i];
for (int j = 30; j >= 1; j--) {
if (a[i] % (1 << j) == 0) {
ha[j]++;
}
}
}

for (int i = 1; i <= m; i++) {
cin >> b[i];
}

// 寻找b中第一个能够让a[j]整除的数b[flag]
int flag = -1;
for (int i = 1; i <= m; i++) {
if (ha[b[i]]) {
flag = i;
break;
}
}

// 特判
if (flag == -1) {
for (int j = 1; j <= n; j++) {
cout << a[j] << " \n"[j == n];
}
return;
}

// 寻找b中从flag开始的严格单调递减的序列c
vector<int> c;
c.push_back(b[flag]);
for (; flag <= m; flag++) {
if (b[flag] < c.back()) {
c.push_back(b[flag]);
}
}

// 暴力循环一遍即可
for (int j = 1; j <= n; j++) {
for (int k = 0; k < c.size(); k++) {
if (a[j] % (1 << c[k]) == 0) {
a[j] += 1 << (c[k] - 1);
}
}
}

for (int j = 1; j <= n; j++) {
cout << a[j] << " \n"[j == n];
}
}

【组合数学】序列数量

https://www.acwing.com/problem/content/5571/

题意:给定 i(i=1,2,,n)i(i=1,2,\cdots,n) 个苹果,将其分给 mm 个人,问一共有多少种分配方案,给出结果对 106+310^6+3 取模的结果

思路:整数分配问题。我们采用隔板法,隔板法相关例题见这篇博客:https://www.acwing.com/solution/content/241669/。下面开始讲解

  • 利用隔板法推导结果。首先我们考虑当前局面,即只有 i 个苹果的情况下的方案数。于是题目就是 i 个苹果分给 m 个人,允许分到 0 个。于是借鉴上述链接中“少分型”的思路,先借 m 个苹果,那么此时局面中就有 i+m 个苹果,现在就等价于将 i+m 个苹果分给 m 个人,每人至少分得 1 个苹果。(分完以后每个人都还回去就行了),此时的隔板操作就是”标准型”,即 i+m 个苹果产生 i+m-1 个间隔,在其中插 m-1 块板,从而将划分出来的 m 个部分分给 m 个人。此时的划分方案就是 Ci+m1m1C_{i+m-1}^{m-1},那么对于所有的 i,结果就是

    i=1nCi+m1m1=Cmm1+Cm+1m1++Cn+m1m1=Cm1+Cm+12++Cn+m1n=(Cm0+Cm0)+Cm1+Cm+12++Cn+m1n=Cm0+(Cm0+Cm1+Cm+12++Cn+m1n)=1+(Cm+11+Cm+12++Cn+m1n)=1+(Cm+22++Cn+m1n)=1+Cn+mn\begin{aligned}\sum_{i=1}^n C_{i+m-1}^{m-1} &= C_{m}^{m-1} + C_{m+1}^{m-1} + \cdots + C_{n+m-1}^{m-1} \\&= C_{m}^{1} + C_{m+1}^{2} + \cdots + C_{n+m-1}^{n} \\&= (-C_{m}^{0}+C_{m}^{0})+C_{m}^{1} + C_{m+1}^{2} + \cdots + C_{n+m-1}^{n} \\&= -C_{m}^{0}+(C_{m}^{0}+C_{m}^{1} + C_{m+1}^{2} + \cdots + C_{n+m-1}^{n}) \\&= -1+(C_{m+1}^{1} + C_{m+1}^{2} + \cdots + C_{n+m-1}^{n}) \\&= -1+(C_{m+2}^{2} + \cdots + C_{n+m-1}^{n}) \\&= -1+C_{n+m}^n\end{aligned}

  • 利用乘法逆元计算组合数。结果已经知道了,现在如何计算上述表达式呢?由于 n×mn\times m 超过了常规递推的组合数计算方法内存空间,因此我们采用乘法逆元的思路计算。值得一提的是,本题在计算某个数关于 1e6+3 的乘法逆元时,不需要判断两者是否互质,因为 1e6+3 是一个质数,并且数据范围中的数均小于 1e6+3,因此两数一定互质,可以直接使用费马小定理计算乘法逆元

时间复杂度:O(nlog1e6)O(n\log 1e6)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>
using namespace std;
using ll = long long;

const int N = 7e5 + 10;
const int mod = 1e6 + 3;

int fact[N], infact[N];

int qmi(int a, int b, int p) {
int res = 1 % p;
while (b) {
if (b & 1) res = (ll)res * a % p;
a = (ll)a * a % p;
b >>= 1;
}
return res;
}

void init() {
fact[0] = 1, infact[0] = 1;
for (int i = 1; i < N; i++) {
fact[i] = (ll)fact[i - 1] * i % mod;
infact[i] = (ll)infact[i - 1] * qmi(i, mod - 2, mod) % mod;
}
}

int main() {
init();

int n, m;
cin >> n >> m;

cout << (ll)fact[n + m] * infact[n] % mod * infact[m] % mod - 1;

return 0;
}
]]>
@@ -1517,11 +1517,11 @@ - greedy - - /Algorithm/greedy/ + geometry + + /Algorithm/geometry/ - 贪心

大胆猜测,小心求证(不会证也没事,做下一题吧)。证明方法总结了以下几种

  • 反证法:假设取一种方案比贪心方案更好,得出相反的结论
  • 边界法:从边界开始考虑,因为满足边界条件更加容易枚举,从而进行后续的贪心
  • 直觉法:遵循社会法则()

1. green_gold_dog, array and permutation

https://codeforces.com/contest/1867/problem/A

题意:给定n个数a[i],其中可能有重复数,请构造一个n个数的排列b[i],使得c[i]=a[i]-b[i]中,c[i]的不同数字的个数最多

思路:思路比较好想,就是最大的数匹配最小的数,次大的数匹配次小的数,以此类推。起初我想通过将原数列的拷贝数列降序排序后,创建一个哈希表来记录每一个数的排位,最终通过原数列的数作为键,通过哈希表直接输出排位,但是问题是原数列中的数可能会有重复的,那么在哈希的时候如果有重复的数,那么后来再次出现的数就会顶替掉原来的数的排位,故错误。

正确思路:为了保证原数列中每个数的唯一性,我们可以给原数列每一个数赋一个下标,排序后以下标进行唯一的一一对应的索引。那么就是:首先赋下标,接着以第一关键词进行排序,然后最大的数(其实是下标)匹配最小的结果以此类推

模拟一个样例:
5
8 7 4 5 5 9

最终的答案应该是
2 3 6 4 5 1

首先对每一个数赋予一个下标作为为唯一性索引
8 7 4 5 5 9
1 2 3 4 5 6(替身)

接着将上述数列按照第一关键词进行排序
9 8 7 5 5 4
6 1 2 4 5 3(替身)

对每一个数进行赋值匹配
9 8 7 5 5 4
6 1 2 4 5 3(替身)
1 2 3 4 5 6(想要输出的结果)

按照替身进行排序
8 7 4 5 5 9
1 2 3 4 5 6(替身)(排序后)
2 3 6 4 5 1(想要输出的结果)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <bits/stdc++.h>
using namespace std;

void solve()
{
int n; cin >> n;

// 第一关键词是原数列,第二关键词是赋予的唯一下标
vector<pair<int, int>> a(n + 1);
for (int i = 1; i <= n; i ++)
{
cin >> a[i].first;
a[i].second = i;
}

// 按照第一关键词排序
sort(a.begin() + 1, a.end(), [&](pair<int, int> x, pair<int, int> y) {
return x.first > y.first;
});

// 以下标作为原数列的替身,每一个替身对应一个升序的最终排名
vector<pair<int, int>> res(n + 1);
for (int i = 1; i <= n; i ++)
{
res[i].first = a[i].second;
res[i].second = i;
}

// 通过下标,还原原数列的排序
sort(res.begin() + 1, res.end());

// 输出第二关键词
for (int i = 1; i <= n; i ++)
cout << res[i].second << " \n"[i == n];
}

int main()
{
int T; cin >> T;
while (T --) solve();
return 0;
}

2. Good Kid

https://codeforces.com/contest/1873/problem/B

题意:对于一个数列,现在可以选择其中的一个数使其+1,问如何选择这个数,可以使得修改后的数列中的所有数之积的值最大

思路:其实就是选择n-1个数的乘积值加一倍,关键在于选哪一个n-1个数的乘积值,逆向思维就是对于n个数,去掉那个最小值,那么剩下来的n-1个数之积就会最大了,于是就是选择最小的数+1,最终数列之积就是答案了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

void solve()
{
int n; cin >> n;
vector<int> a(n);

for (int i = 0; i < n; i ++)
cin >> a[i];

sort(a.begin(), a.end());

ll res = a[0] + 1;
for (int i = 1; i < n; i ++)
res *= a[i];
cout << res << endl;
}

int main()
{
int T; cin >> T;
while (T --) solve();
return 0;
}

3. 1D Eraser

https://codeforces.com/contest/1873/problem/D

题意:给定一个只有B和W两种字符的字符串,现在有一种操作可以将一个指定大小的区间(大小为k)中所有的字符全部变为W,问对于这个字符串,至少需要几次上述操作才可以将字符串全部变为W字符

思路:我们直接遍历一边字符串即可,在遍历到B字符的时候,指针向后移动k位,如果是W字符,指针向后移动1位即可,最终统计一下遇到B字符的次数即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

void solve()
{
int n, k;
cin >> n >> k;
string s;
cin >> s;

int res = 0;

for (int i = 0; i < n;)
{
if (s[i] == 'B')
{
res ++;
i += k;
}
else i++;
}
cout << res << endl;
}

int main()
{
int T; cin >> T;
while (T --) solve();
return 0;
}

4. ABBC or BACB

https://codeforces.com/contest/1873/problem/G

题意:现在有一个只有A和B两种字符的字符串,有如下两种操作:第一种,可以将AB转化为BC,第二种,可以将BA转化为CB。问最多可以执行上述操作几次

思路:其实一开始想到了之前做过的翻转数字为-1,最后的逻辑是将数字向左右移动,于是这道题就有了突破口了。上述两种操作就可以看做将A和B两种字符交换位置,并且B保持不变,A变为另一种字符C。由于C是无法执行上述操作得到,因此就可以理解为B走过的路就不可以再走了,那么就是说对于一个字符串,最终都会变成B走过的路C。并且只要有B,那么一个方向上所有的A都会被利用转化为C(直到遇到下一个B停止),那么我们就可以将B看做一个独立的个体,他可以选择一个方向上的所有的A并且执行操作将该方向的A全部转化为C,那么在左和右的抉择中,就是要选择A数量最多的方向进行操作,但是如果B的足够多,那就不需要考虑哪个方向了,所有的A都可以获得一次操作机会。现在就转向考虑B需要多少呢,

答案是两种:如果B的数量小于A“块”的数量,其实就是有一块A无法被转化,那么为了让被转化的A数量尽可能的多,就选择A块数量最少的那一块不被转化。如果B的数量>=A块的数量,那么可以保证所有的A都可以被转化为C块,从而最终的答案就是A字符的数量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

void solve()
{
string s; cin >> s;
int cntb = 0, blocka = 0, cnta = 0; // B字符的数量,A连续区间的数量,A字符的个数

vector<int> a; // 每一个A连续区间中A字符的数量

for (int i = 0; i < s.size();)
{
if (s[i] == 'A')
{
int sum = 0;
while (s[i] == 'A') cnta ++, sum ++, i ++;
a.emplace_back(sum);
blocka ++;
}
else
{
cntb ++;
i ++;
}
}

if (cntb >= blocka) cout << cnta << endl;
else
{
sort(a.begin(), a.end());
cout << cnta - a[0] << endl;
}
}

int main()
{
int T; cin >> T;
while (T --) solve();
return 0;
}

5. Smilo and Monsters

https://codeforces.com/contest/1891/problem/C

题意:给定一个序列,其中每一个元素代表一个怪物群中的怪物的数量。现在有两种操作:

  1. 选择一个怪物群,杀死其中的一只怪物。同时累加值 x+=1x+=1
  2. 选择一个怪物群,通过之前积累的累加值,一次性杀死当前怪物群中的 xx 只怪物,同时累加值 xx 归零

现在询问杀死所有怪物的最小操作次数

思路:一开始我是分奇偶进行判断的,但是那只是局部最优解,全局最优解的计算需要我们“纵观全局”。

我们可以先做一个假设:假设当前只有一个怪物群,为了最小化操作次数,最优解肯定是先杀死一半的怪物(假设数量为n,则获得累计值x=n),然后无论剩余n个还是n+1个,我们都使用累计值x一次性杀死n个怪物。

现在我们回到原题,有很多个怪物群,易证,最终的最优解也一点是最大化利用操作2,和特殊情况类似,能够利用的一次性杀死的次数就是怪物总数的一半。区别在于:此时不止一个怪物群。那么我们就要考虑累加值如何使用。很容易想到先将怪物群按照数量大小进行排序,关键就在于将累加值从小到大进行使用还是从大到小进行使用。可以发现,对于一个确定的累加值,由于操作2的次数也会算在答案中。那么如果从最小的怪物群开始使用,就会导致操作2的次数变多。因此我们需要从最大值开始进行操作2。

那么最终的答案就是:

  • 首先根据怪物数量的总和计算出最终的累加值s(s=sum/2)
  • 接着我们将怪物群按照数量进行升序排序
  • 然后我们从怪物数量最大的开始进行操作2,即一次性杀死当前怪物群,没进行一次,答案数量+1
  • 最后将答案累加上无法一次性杀死的怪物群中怪物的总数

时间复杂度:O(nlogn)O(n \log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
void solve() {
n = read(); s = 0; res = 0;
for (int i = 1; i <= n; i++) {
a[i] = read();
s += a[i];
}

s >>= 1;

sort(a + 1, a + n + 1);

for (int i = n; i >= 1; i--) {
if (s >= a[i]) {
s -= a[i], a[i] = 0, res++;
} else if (s) {
a[i] -= s;
s = 0;
res++;
}
}

for (int i = 1; i <= n; i++) {
res += a[i];
}

printf("%lld\n", res);
}

6. 通关

https://www.lanqiao.cn/problems/5889/learning/?contest_id=145

声明:谨以此题记录我的第一道正式图论题(存图技巧抛弃了y总的纯数组,转而使用动态数组vector进行建图)e.g.

1
2
3
4
5
6
struct Node {
int id; // 当前结点的编号
int a; // 属性1
int b; // 属性2
};
vector<Node> G[N];

题意:给定一棵树,每个结点代表一个关卡,每个关卡拥有两个属性:通关奖励值val和可通关的最低价值cost。现在从根节点开始尝试通关,每一个结点下面的关卡必须要当前结点通过了之后才能继续闯关。问应该如何选择通关规划使得通过的关卡数最多?

思路:一开始想到的是直接莽bfs,对每一层结点按照cost升序排序进行闯关,然后wa麻了。最后一看正解原来是还不够贪。正确的闯关思路应该是始终选择当前可以闯过的cost最小的关卡,而非限定在了一层上。因此我们最终的闯关顺序是:对于所有解锁的关卡,选择cost最小的关卡进行通关,如果连cost最小的关卡都无法通过就直接结束闯关。那么我们应该如何进行代码的编写呢?分析可得,解锁的关卡都是当前可通过的关卡的所有子结点,而快速获得当前cost最小值的操作可以通过一个堆来维护。

时间复杂度:O(nlogn)O(n \log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
const int N = 100010;
typedef long long ll;

struct Node {
int id;
int val;
int cost;
// 有趣的重载
bool operator< (const Node& t) const {
return this->cost > t.cost;
}
};

int n, p;
ll res;
vector<Node> G[N];
priority_queue<Node> q;

void bfs() {
q.push(G[0][0]);

while (q.size()) {
Node now = q.top();
q.pop();
if (p >= now.cost) {
res++;
p += now.val;
for (auto& child: G[now.id]) {
q.push(child);
}
} else {
break;
}
}
}

void solve() {
cin >> n >> p;
for (int i = 1; i <= n; i++) {
int fa, v, c;
cin >> fa >> v >> c;
G[fa].push_back({i, v, c});
}
bfs();
cout << res << "\n";
}

7. 串门

https://www.lanqiao.cn/problems/5890/learning/?contest_id=145

题意:给定一棵无向树,边权为正。现在询问在访问到每一个结点的情况下最小化行走的路径长度

思路:首先我们不管路径长度的长短,我们直接开始串门。可以发现,我们一点会有访问的起点和终点,那么在起点到终点的这条路上,可能会有很多个分叉,对于每一个分叉,我们都需要进入再返回来确保分叉中的结点也被访问到,那么最终的路径长度就是总路径长度的2倍 - 起点到终点的距离,知道了这个性质后。我们可以发现,路径的总长度是一个定值,我们只有最大化起点到终点距离才能确保行走路径长度的最小值。那么问题就转化为了求解一棵树中,最长路径长度的问题。即求解树的直径的问题。

树的直径:首先我们反向考虑,假设知道了直径的一个端点,那么树的直径长度就是这棵树以当前结点为根的深度。那么我们就需要先确定一个直径的端点。不难发现,对于树中的任意一个结点,距离它最远的一个(可能是两个)结点一定是树的直径的端点。那么问题就迎刃而解了。

为了降低代码量,我们可以设置一个dist数组来记录当前结点到根的距离。

  • 在确定直径端点的时候,选择任意一个结点为根节点,然后维护dist数组,最终dist[i~n]中的最大值对应的下标maxi就是直径的一个端点。
  • 接着在计算直径长度的时候,以maxi为树根,再来一次上述的维护dist数组的过程,最终dist[1~n]中的最大值就是树的直径的长度

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
const int N = 100010;
typedef long long ll;

struct Node {
int id;
ll w;
};

int n;
vector<Node> G[N];
bool st[N];
ll sum, d, dist[N];

/**
* @note 计算当前所有子结点到当前点的距离
* @param now 当前点的编号
* @param x 上一个点到当前点的距离
*/
void dfs(int now, ll x) {
if (!st[now]) {
st[now] = true;
dist[now] = x;
for (int i = 0; i < G[now].size(); i++) {
int ch = G[now][i].id;
dfs(ch, x + G[now][i].w);
}
}
}

void solve() {
cin >> n;
for (int i = 0; i < n - 1; i++) {
int a, b, w;
cin >> a >> b >> w;
sum += w;
G[a].push_back({b, w});
G[b].push_back({a, w});
}

// 以1号点为根,计算所有点到1号点的距离
dfs(1, 0);

memset(st, 0, sizeof st);

// 到1号点最远的那个点的编号 maxi,即直径的一个端点
int maxi = 0;
for (int i = 1; i <= n; i++) {
if (dist[i] > dist[maxi]) {
maxi = i;
}
}

// 以直径的一个端点 maxi 为根,计算所有点到 maxi 点的距离
dfs(maxi, 0);

// 找到直径长度
for (int i = 1; i <= n; i++) {
d = max(d, dist[i]);
}

cout << sum * 2 - d << "\n";
}

8. Codeforces rating

https://vijos.org/d/nnu_contest/p/1532

题意:现在已知一个初始值R,现在给定了n个P值,选择其中的合适的值,使得最终的 R=3/4R+1/4PR'=3/4 R + 1/4 P 最大

思路:首先一点就是我们一定要选择P比当前R大的值,接下来就是选择合适的P使得最终迭代出来的R’最大。首先我们知道,对于筛选出来的比当前R大的P集合,任意选择其中的一个P,都会让R增大,但是不管增加多少都是不会超过选择的P。那么显然,如果筛选出来的P集合是一个递增序列,那么就可以让R不断的增加。但是这一定是最大的吗?我们不妨反证一下,现在我们有两个P,分别为x,y,其中 x<yx<y

那么按照上述思路,首先就是

R=34R+14x(R<x)R'=\frac{3}{4}R+\frac{1}{4}x(R'<x)

接着就是

R1=34R+14y(R1<y)=916R+316x+14yR''_1=\frac{3}{4}R'+\frac{1}{4}y(R''_1<y)=\frac{9}{16}R+\frac{3}{16}x+\frac{1}{4}y

反之,首先选择一个较大的P=y,再选择一个较小的P=x,即首先就是

R=34R+14y(R<y)R'=\frac{3}{4}R+\frac{1}{4}y(R'<y)

此时我们还不一点可以继续选择x,因为此时的R’可能已经超过了x的值,那么我们按照最优的情况计算,可以选择x,那么就是

R2=34R+14x(R2<x)=916R+316y+14xR''_2=\frac{3}{4}R'+\frac{1}{4}x(R''_2<x)=\frac{9}{16}R+\frac{3}{16}y+\frac{1}{4}x

可以发现

R2R1=116(xy)<0R''_2-R''_1=\frac{1}{16}(x-y)<0

即会使得最终的答案变小。因此最优的策略就是按照增序进行叠加计算

==注意点:==

四舍五入的语句

1
cout << (int)(res + 0.5) << "\n";
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
void solve() {
cin >> n >> k;
for (int i = 0; i < n; i++) {
cin >> a[i];
}

sort(a, a + n);

int i = 0;
for (; i < n; i++) {
if (a[i] > k) {
break;
}
}

double res = k;

for (; i < n; i++) {
res = (res * 3.0 + a[i] * 1.0) / 4.0;
}

cout << (int)(res + 0.5) << "\n";
}

9. 货运公司

https://www.acwing.com/problem/content/5380/

题意:给定 nn 个运货订单,每个订单需要运送一定量的货物,同时给予一定量的报酬。现在有 kk 个卡车进行运送,每个卡车有运送容量。需要合理规划卡车的装配,使得最终获得的总报酬最大。有以下约束条件:

  • 每一个订单只能用一辆货车运送
  • 每一辆货车只能运送不超过最大容量的货物
  • 每一辆货车只能运送一次
  • 每一辆货车在装得下的情况下只能装运一个订单

思路:一开始尝试采用 01 背包的思路,但是题目中货物是否可以被运送是取决于卡车的装载的。思考无果后考虑贪心。考虑一般情况

  • 为了让总收益最大,很显然报酬越多,就越优先选择该订单

  • 在报酬一致时,货物量越少,就越优先选择该订单

  • 按照上述规则对订单排序后,我们需要安排合理的货车进行运输。我们优先将运输量小的货车安排给合适的订单。因为运输量大的货车既可以满足当前订单,同样也满足后续的订单,那么为了最大化增加利润,就需要在运输量小的货车满足当前订单时,保留运输量大的货车用于后续的订单。我们可以画一个图进一步理解为什么这么安排货车进行运输:

    image-20231223222627134
    图例:我们按照上述红笔的需要进行货车的选择进行运输

    如果红色序号顺序变掉了,则就不能将上述图例中的 6 个订单全部运输了

时间复杂度 O(nk)O(nk)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
#include <bits/stdc++.h>
using namespace std;

const int N = 1010;

struct Item {
int id, weight, money;
bool operator< (const Item& t) const {
if (money != t.money) return money > t.money;
else return weight < t.weight;
}
} item[N];

struct Car {
int id, v;
bool operator< (const Car& t) const {
return v < t.v;
}
} car[N];

pair<int, int> res[N];
bool vis[N];
int cnt, sum;

void solve() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) {
item[i].id = i;
cin >> item[i].weight >> item[i].money;
}

int k;
cin >> k;
for (int i = 1; i <= k; i++) {
car[i].id = i;
cin >> car[i].v;
}

sort(item + 1, item + n + 1);
sort(car + 1, car + k + 1);

for (int i = 1; i <= n; i++) {
for (int j = 1; j <= k; j++) {
if (!vis[j] && car[j].v >= item[i].weight) {
sum += item[i].money;
vis[j] = true;
res[cnt++] = {item[i].id, car[j].id};
break;
}
}
}

cout << cnt << " " << sum << "\n";

for (int i = 0; i < cnt; i++) {
cout << res[i].first << " " << res[i].second << "\n";
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

10. 部分背包问题

https://www.luogu.com.cn/problem/P2240

  • 题意:给定一组物品,每一个物品有一定的重量与价值,现在给定一个背包总承重,需要从中选择合适的物品进行装载,使得最终装载的物品的总价值最大。现在规定每一个物品可以进行随意分割,按照单位重量计算价值
  • 思路:考虑一般情况。很显然,如果不说可以随意分割,那就是一个 01 背包,既然说了可以随意分割,那就直接贪心选择即可,按照单位重量的价值降序进行选择
  • 时间复杂度:O(nlogn)O(n \log n)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 110;

int n, W;
double res;

struct Item {
double w;
double val;

bool operator< (const Item& t) const {
return this->val / this->w > t.val / t.w;
}
} a[N];

void solve() {
cin >> n >> W;
for (int i = 0; i < n; i++) cin >> a[i].w >> a[i].val;

sort(a, a + n);

for (int i = 0; i < n; i++) {
if (W > a[i].w) {
W -= a[i].w;
res += a[i].val;
} else {
res += W * a[i].val / a[i].w;
break;
}
}

cout << fixed << setprecision(2) << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
cout << fixed << setprecision(2);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

11. 排队接水

https://www.luogu.com.cn/problem/P1223

  • 题意:贪心经典,排队接水,每一个人都有一个打水时间,现在询问如何排队能够使得平均等待时间最短

  • 思路:考虑一般情况。注意问的是平均等待时间,如果是打完水总花费时间那就是一个定值,即所有人打水的时间总和。现在为了让平均等待时间最短,我们的第一反应应该是,在人多的时候让打水时间短的先打,人少的时候让打水时间长的再打,那么很显然就是按照打水时间的升序进行排队。为了证明这个结论,我们采用数学归纳法,如下,对于一个排队序列来说,我们将其中的两个顺序进行颠倒,计算结果得到让打水时间短的排在前面更优,进而推广到一般情况,于是按照打水时间升序进行打水的策略是最优的。证毕

    demo
  • 时间复杂度:O(nlogn)O(n \log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 1010;

int n;

struct Item {
int time;
int i;

bool operator< (const Item& t) const {
return this->time < t.time;
}
} a[N];

void solve() {
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i].time;
a[i].i = i;
}

sort(a + 1, a + n + 1);

double sum = 0;

for (int i = 1; i <= n; i++) {
sum += a[i].time * (n - i);
cout << a[i].i << " \n"[i == n];
}

cout << fixed << setprecision(2) << sum / n << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

12. 线段覆盖

https://www.luogu.com.cn/problem/P1803

  • 题意:给定一个数轴和数轴上的 n 个线段,现在需要从中选出尽可能多的 k 个线段,使得线段之间两两不相交,问 k 最大是多少
  • 思路:绞尽脑汁想着各种情况进行分类讨论,什么包含、相交、相离,但是都忽视了最重要的一点,那就是一开始应该选择线段。即考虑边界,对于最左侧我们应该选择哪一条线段呢?答案是最左侧的右端点最靠左的那一条线段,这样子后续的线段才能有更多的摆放空间
  • 时间复杂度:O(nlogn)O(n \log n)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 1000010;

int n;

struct Item {
int l, r;
bool operator< (const Item& t) const {
return this->r < t.r;
}
} a[N];

void solve() {
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i].l >> a[i].r;

sort(a + 1, a + n + 1);

int right = -1, res = 0;

for (int i = 1; i <= n; i++) {
if (a[i].l >= right) {
res++;
right = a[i].r;
}
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

13. 小A的糖果

https://www.luogu.com.cn/problem/P3817

  • 题意:给定一个序列,现在每次可以选择序列中的某个数进行减一操作,问最少需要减多少可以使得序列中相邻两个元素之和不超过规定的数值 x
  • 思路:同 T12 的思路展开,考虑边界,我们直接从任意的一个边界开始思考,假设从左边界开始考虑。为了让左边界满足,那么 a[1] 与 a[2] 之和就需要满足条件,为了后续可以尽可能少的进行减一操作,很显然我们最好从两个数的右边的数开始下手,于是贪心的结果就有了
  • 时间复杂度:O(n)O(n)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 100010;

int n, x;
int a[N];

void solve() {
cin >> n >> x;
for (int i = 1; i <= n; i++) cin >> a[i];

int res = 0;

for (int i = 2; i <= n; i++) {
if (a[i] + a[i - 1] > x) {
int eat = a[i] + a[i - 1] - x;
res += eat;
a[i] = a[i] >= eat ? a[i] - eat : 0;
}
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

15. 陶陶摘苹果(升级版)

https://www.luogu.com.cn/problem/P1478

  • 题意:现在有 n 个苹果,含有高度和消耗体力值两个属性,现在你有一个最大高度以及总体力值,每次只能摘高度值不超过最大高度的苹果且会消耗相应的体力值。问最多可以摘多少个苹果
  • 思路:既然是想要摘的苹果尽可能多,那么高度都是浮云,体力值才是关键。为了摘得尽可能多的苹果,自然是消耗体力越少越优先摘。于是按照消耗体力值升序排序后,扫描一遍即可计数
  • 时间复杂度:O(nlogn)O(n\log n)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 5010;

int n, s;
int aa, bb;

struct Apple {
int h, cost;
bool operator< (const Apple& t) const {
return this->cost < t.cost;
}
} a[N];

void solve() {
cin >> n >> s;
cin >> aa >> bb;
for (int i = 1; i <= n; i++) cin >> a[i].h >> a[i].cost;

sort(a + 1, a + n + 1);

int cnt = 0;

for (int i = 1; i <= n; i++) {
if (s >= a[i].cost && a[i].h <= aa + bb) {
cnt++;
s -= a[i].cost;
}
}

cout << cnt << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

16. [NOIP2018 提高组] 铺设道路

https://www.luogu.com.cn/problem/P5019

  • 题意:给定一个序列,每次可以选择一个连续的区间使得其中的所有数 -1,前提是区间中的数都要 > 1,问最少选择几次区间可以将所有的数全部都减到 0

  • 思路:很显然的一个贪心思路就是,对于一个数,左右两侧均呈现递减的趋势,那么这个区间的选择数就是这个最大的数的值。但是这不符合代码逻辑,我们尝试从端点开始考虑。为了更好的理解,我们从递推的角度进行推演。假设填平前 i 个数花费的选择次数为 f[i],那么对于第一个数 a[1],我们一定需要花费 a[1] 次选择来将其减到零,于是 f[1] = a[1];对于后续的数而言,可以发现

    • 如果 a[i] > a[i-1],那么之前的选择没有完全波及当前的数,还需要一定的次数来将其减到零,于是 f[i] = f[i-1] + a[i] - a[i-1]
    • 如果 a[i] \le a[i-1],那么当前的数值一定会被之前的选择“波及”而直接减到零,于是 f[i] = f[i-1]

    从递推的角度理解之后我们直接滚动一遍数组记录増势即可

  • 时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 100010;

int n, a[N];

void solve() {
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];

int res = a[1];

for (int i = 2; i <= n; i++)
if (a[i] > a[i - 1])
res += a[i] - a[i - 1];

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

17. [USACO1.3] 混合牛奶 Mixing Milk

https://www.luogu.com.cn/problem/P1208

  • 题意:现在需要收集 need 容量的牛奶,同时有 n 个农户提供奶源,每一个商户的奶源有一定的数量与单价,问如何采购可以在满足需求 need 的情况下成本最低化
  • 思路:很显然单价越低越优先采购
  • 时间复杂度:O(nlogn)O(n \log n)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 2000010;

int need, n;

struct Milk {
int per, tot;
bool operator< (const Milk& t) const {
return per < t.per;
}
} a[N];

void solve() {
cin >> need >> n;
for (int i = 1; i <= n; i++) cin >> a[i].per >> a[i].tot;

sort(a + 1, a + n + 1);

int res = 0;

for (int i = 1; i <= n; i++) {
if (need >= a[i].tot) {
need -= a[i].tot;
res += a[i].tot * a[i].per;
} else {
res += need * a[i].per;
need = 0;
}
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

18. [NOIP2007 普及组] 纪念品分组

https://www.luogu.com.cn/problem/P1094

  • 题意:给定 n 个数,问如何分组可以使得在每一组之和不超过定值 lim 且每一组最多两个数的情况下,分得的组数最少
  • 思路:对序列没有要求,显然直接排序然后从一端开始考虑。首先有一个概念就是,为了尽可能的减少分得的组数,那么一定想要每一组分完后距离 lim 的差距越小越好。我们将 n 个数降序排序后,从最大值开始考虑,对于当前的数,为了找到一个数与当前数之和尽可能接近 lim,从贪心的角度来说一定是从当前数之后开始寻找那个配对的数从而使得两数之和与 lim 的差距最小,那么时间复杂度就是 O(n2)O(n^2)。但是由于题目说了最多只有两个数,我们不妨换一种寻找配对数的策略,可以从原始的寻找方法得到启发,可以发现,原始的寻找配对数的方法中,如果找到的那个配对数可以与当前数之和不超过 lim 且之和尽可能的大,那么这个寻找到的配对数也一定可以和当前数的下一个数进行配对。由于我们一组只能有两个数,因此我们不用考虑两数组合之后比 lim 小的数尽可能的小,只需要考虑两个数能否组合即可,因此我们直接双指针进行寻找配对数操作即可
  • 时间复杂度:O(n)O(n)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 30010;

int lim, n;
int a[N];

void solve() {
cin >> lim >> n;
for (int i = 1; i <= n; i++) cin >> a[i];

sort(a + 1, a + n + 1, [&](int x, int y) {
return x > y;
});

int res = 0, l = 1, r = n;

while (l < r) {
if (a[l] + a[r] <= lim) r--;
l++, res++;
}

if (l == r) res++;

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

19. 跳跳!

https://www.luogu.com.cn/problem/P4995

  • 题意:给定 n 个数,每一个数代表一个高度,现在有一只青蛙从地面(高度为 0)开始起跳,每一个数字只能被跳上去一次。问如何设定跳数顺序 h[],可以使得下式尽可能的大

    i=1n(hihi1)2\sum_{i=1}^n (h_i-h_{i-1})^2

  • 思路:题目意思其实就是让每一次跳跃的两个高度之差尽可能的大。那么一个贪心的思路就是从地面跳到最高,再从最高跳到次低,接着从次低跳到次高,以此类推得到的结果就是最优解,为了证明我们可以用数学归纳法,假设当前只有两个高度可以选择,那么肯定是先跳高的再跳低的;假设当前有三个高度可以选择,那么可以证明先从地面跳到最高处,再跳到最低处,最后跳到第二高的方法得到的值最大

  • 时间复杂度:O(nlogn)O(n \log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 310;

int n, a[N];

void solve() {
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];

sort(a + 1, a + n + 1, [&](int x, int y) {
return x > y;
});

int res = a[1] * a[1], l = 1, r = n;

while (l < r) {
res += (a[l] - a[r]) * (a[l] - a[r]);
l++;
if (l < r) {
res += (a[l] - a[r]) * (a[l] - a[r]);
r--;
}
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

20. 安排时间

https://www.acwing.com/problem/content/5466/

  • 题意:给定 n 个时间段处理餐厅事务,m 个用餐预定信息,含有三个信息分别为订单发起时间,订单需要准备的时间,客人来用餐时间。问如何安排每一个时间段,可以让所有的用户得到正常的服务。如果不能全部满足则输出 -1
  • 思路:玄学贪心,先到的用户先做它的菜
  • 时间复杂度:O(n2)O(n^2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 110;

int n, m;
int res[N];

struct Item {
int id;
int begi, arri, time;
bool operator< (const Item& t) const {
return arri < t.arri;
}
} a[N];

void solve() {
cin >> n >> m;
for (int i = 1; i <= m; i++) {
cin >> a[i].begi >> a[i].arri >> a[i].time;
a[i].id = i;
res[a[i].arri] = m + 1; // 接客
}

sort(a + 1, a + m + 1);

for (int i = 1; i <= m; i++) {
int t = a[i].time;
for (int j = a[i].begi; j <= a[i].arri - 1; j++) {
if (t > 0 && !res[j]) {
res[j] = a[i].id; // 做菜
t--;
}
}

if (t) {
cout << -1 << "\n";
return;
}
}

for (int i = 1; i <= n; i++) cout << res[i] << " \n"[i == n];
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

21. [NOIP2004 提高组] 合并果子

https://www.luogu.com.cn/problem/P1090

  • 题意:给定 n 个数,现在需要将其两两合并为最终的一个数,每次合并的代价是两个数之和,问如何对这些数进行合并可以使得总代价最小
  • 思路:很显然一共需要合并 n-1 次。那么我们从三个物品开始考虑,然后使用数学归纳法推导到 n 个数。对于三个物品,很显然先将两个物品进行合并,再将这个结果与最大的数进行合并方案时最优的。那么推广到 n 个数,我们只需要每次合并当前局面中最小的两个数即可
  • 时间复杂度:O(nlogn)O(n \log n)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 10010;

int n, a[N];
priority_queue<int, vector<int>, greater<int>> q;
int res;

void solve() {
cin >> n;
for (int i = 1; i <= n; i++) {
int x;
cin >> x;
q.push(x);
}

for (int i = 1; i <= n - 1; i++) {
int a = q.top(); q.pop();
int b = q.top(); q.pop();

res += a + b;

q.push(a + b);
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

22. 删数问题

https://www.luogu.com.cn/problem/P1106

  • 题意:给定一个数字字符串长度为 n,现在需要选择其中的 m 位数,使得这 m 位组成的数最小是多少 (m=n-k)
  • 思路:我们从一端考虑问题。为了组成的数尽可能的小,我们希望从首位开始就尽可能的小,同时如果有重复的数字,就选择最左侧的,剩余的数字留给后续的位数继续选择。有一个需要注意的点就是选择每一位的数字时,范围的约束,我们假设需要从 10 位的数字串中选择 3 位,那么我们第一次选择时需要在 0-7位中选择一位,第二次选择在 上次选择的下一个位置到第8位中选择一位,以此类推进行贪心选择
  • 时间复杂度:O(n2)O(n^2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <bits/stdc++.h>
#define int long long
using namespace std;

string s, res;
int k;

void solve() {
cin >> s >> k;
int n = s.size();

// 特判保留的数字个数 n-k 为负数的情况
if (n - k <= 0) {
cout << 0 << "\n";
return;
}

// 循环 n-k 次选择保留的数
int l = 0, r = k;
for (int i = 0; i < n - k; i++) {
int idx = -1;
for (int j = l; j <= r; j++)
if (idx == -1 || s[j] < s[idx])
idx = j;

res += s[idx];

l = idx + 1, r++;
}

// 删除前导零
int i = 0;
while (res[i] == '0') i++;

// 输出结果
if (i == res.size())
cout << 0;
else
for (int j = i; j < res.size(); j++)
cout << res[j];
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

23. 截断数组

https://www.acwing.com/problem/content/5483/

题意:定义平衡数组为奇偶数数量相同的数组,现在给定一个平衡数组,和一个总成本数,最多可以将原数组截成多少截子平衡数组,且截断代价总和不超过总成本,截断代价计算公式为 aiai+1|a_i-a_{i+1}|

思路:我们直接枚举所有的可以被截断的位置,统计所有的代价值,然后根据成本总数按降序枚举代价值即可

时间复杂度:O(nlogn)O(n \log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <iostream>
#include <queue>
#include <cstring>
#include <vector>
#include <unordered_map>
#include <algorithm>
using namespace std;

const int N = 110;

int n, b;
int a[N], odd[N], even[N];

void solve() {
cin >> n >> b;

for (int i = 1; i <= n; i++) {
cin >> a[i];
if (a[i] % 2 == 0) {
even[i] = even[i - 1] + 1;
odd[i] = odd[i - 1];
} else {
even[i] = even[i - 1];
odd[i] = odd[i - 1] + 1;
}
}

vector<int> c;
for (int i = 2; i <= n - 2; i += 2) {
if (odd[i] == even[i]) {
c.push_back(abs(a[i] - a[i + 1]));
}
}

int res = 0;
sort(c.begin(), c.end());
for (auto& x: c) {
if (b >= x) {
b -= x;
res++;
}
}

cout << res;
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

24. 修改后的最大二进制字符串

https://leetcode.cn/problems/maximum-binary-string-after-change/description/

题意:给定一个仅由01组成的字符串,现在可以执行下面两种操作任意次,使得最终的字符串对应的十进制数值最大,给出最终的字符串

  1. 001000 \to 10
  2. 100110 \to 01

思路:

  • 有点像之前做的翻转转化为平移的问题,事实确实如此。首先需要确定一点就是:如果字符串起始有连续的1,则保留,因为两种操作都是针对0的。接着就是对从第一个0开始的尾串处理的思路
  • 对于尾串而言,我们自然希望1越多、越靠前越好,但是第二种操作只能将1向后移,第一种操作又必须要连续的两个0,因此我们就产生了这样的贪心思路:将所有的1通过操作二移动到串尾,接着对尾串的开头连续的0串执行第二种操作,这样就可以得到最大数值的二进制串

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution {
public:
string maximumBinaryString(string binary) {
int n = binary.size();

string res;

// 前缀1全部加入答案
int i = 0;
for (i = 0; i < n; i++) {
if (binary[i] == '0') {
break;
}
else {
res += '1';
}
}

// 非前缀1的部分一定可以操作为 00...011..1,进而转化为 11...11011...1
int zero = count(binary.begin() + i, binary.end(), '0');
if (zero > 0) {
for (int j = 0; j < zero - 1; j++) res += '1';
res += '0';
}

while (res.size() < n) res += '1';

return res;
}
};

25. 子序列最大优雅度

https://leetcode.cn/problems/maximum-elegance-of-a-k-length-subsequence/

标签:反悔贪心

题意:给定长度为 n 的物品序列,现在需要从中寻找一个长度为 k 的子序列使得子序列的总价值最大,给出最终的最大价值。一个序列的价值定义为每个物品的 本身价值之和 + 物品种类数的平方

思路:我们采用反悔贪心的思路。首先按照物品本身价值进行降序排序并选择前 k 个物品作为集合 S,然后按顺序枚举剩下的物品进行替换决策。我们记每个物品有一个「本身价值」,集合 S 有一个「种类价值」。那么就会遇到下面两种情况:

  • 第 i 个物品对应的种类存在集合 S 中。此时该物品「一定不选」,因为无论当前物品替代集合中的 S 哪一个物品,本身价值的贡献会下降、种类价值的贡献保持不变。
  • 第 i 个物品对应的种类不在集合 S 中。此时该物品一定选择吗?不一定。有两种情况:
    • 集合 S 没有重复种类的物品。此时该物品「一定不选」,同样的,无论当前物品替代集合中的 S 哪一个物品,本身价值的贡献会下降、种类价值的贡献保持不变。
    • 集合 S 存在重复种类的物品。此时该物品「一定选」,因为一定可以提升种类价值的贡献,至于减去本身价值的损失后是否使得总贡献增加,并不具备可能的性质,只能每次替换物品时更新全局答案进行记录。

时间复杂度:O(nlogn)O(n \log n)

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class Solution {
public:
long long findMaximumElegance(std::vector<std::vector<int>>& items, int k) {
using ll = long long;

std::sort(items.rbegin(), items.rend());

ll t = 0, cat = 0, res = 0;
std::unordered_map<int, int> cnt;
std::stack<ll> stk;

for (int i = 0; i < items.size(); i++) {
ll v = items[i][0], c = items[i][1];
if (i < k) {
// 前 k 个物品
t += v;
cnt[c]++;
cat += cnt[c] == 1;
if (cnt[c] > 1) {
stk.push(v);
}
} else {
// 后 n-k 个物品
if (cnt[c]) {
// 已经出现过的物品种类
continue;
} else {
// 没有出现过的物品种类
if (stk.size()) {
cat++;
t -= stk.top();
t += v;

stk.pop();
cnt[c]++;
}
}
}
// 更新全局答案
res = max(res, t + cat * cat);
}

return res;
}
};
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Solution:
def findMaximumElegance(self, items: List[List[int]], k: int) -> int:
from collections import defaultdict
items.sort(reverse=True)

t, cat, res = 0, 0, 0
cnt, stk = defaultdict(int), []

for i in range(len(items)):
v, c = items[i][0], items[i][1]
if i < k:
t += v
cnt[c] += 1
cat += cnt[c] == 1
if cnt[c] > 1:
stk.append(v)
else:
if cnt[c]:
continue
else:
if len(stk):
t -= stk[-1]
t += v
cat += 1
stk.pop()
cnt[c] += 1
res = max(res, t + cat**2)

return res

【按位贪心/分类讨论】

https://www.acwing.com/problem/content/1000/

题意:给定 nn 个运算数和对应的 nn 个操作,仅有 &, |, ^ 三种。现在需要选择一个数 num[0,m]num \in [0,m] 使得 numnum 经过这 nn 个二元运算后得到的结果 resres 最大。给出最终的 resres

思路:首先不难发现这些运算操作在二进制位上是相互独立的,没有进位或结尾的情况,因此我们可以「按二进制位」单独考虑。由于运算数和 mm109\le 10^9,因此我们枚举 [0,30][0,30] 对应的二进制位。接下来我们单独考虑第 ii 位的情况,显然的 numnum 的第 ii 位可以取 11 也可以取 00,对应的 resres 的第 ii 位可以取 11 也可以取 00。有以下 222^2 种运算情况:

$\text{num 的第 i 位填 1 \to res 第 i 位的情况}$$\text{num 的第 i 位填 0 \to res 第 i 位的情况}$选择方案
情况一101 \to 0000 \to 0num 填 0,res 填 0
情况二101 \to 0010 \to 1num 填 0,res 填 1
情况三111 \to 1000 \to 0待定
情况四111 \to 1010 \to 1num 填 0,res 填 1

选择方案时我们需要考虑两个约束,numnum 不能超过 mmresres 尽可能大,因此有了上述表格第四列的贪心选择结果。之所以情况三待定是因为其对两种约束的相互矛盾的,并且其余情况没有增加 numnum,即对 numnum 上限的约束仅存在于情况三。假设有 kk 位是上述情况三,那么显然的其余位枚举顺序是随意的,因为答案是固定的。对于 kk 个情况三,从贪心的角度考虑,我们希望 resres 尽可能大,那么就有「高位能出 11 就出 11」的结论。因此我们需要从高位到低位逆序枚举这 kk 个情况三,由于其他位枚举顺序随意,因此总体就是从高位到低位枚举。

时间复杂度:O(nlogm)O(n \log m)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

void solve() {
int n, m;
cin >> n >> m;

vector<pair<string, int>> a(n);
for (int i = 0; i < n; i++) {
cin >> a[i].first >> a[i].second;
}

int res = 0, num = 0;
for (int i = 30; i >= 0; i--) {
int x = 1 << i, y = 0;
for (int j = 0; j < n; j++) {
string op = a[j].first;
int t = a[j].second & (1 << i);
if (op == "AND") {
x &= t, y &= t;
} else if (op == "OR") {
x |= t, y |= t;
} else {
x ^= t, y ^= t;
}
}
if ((num | (1 << i)) <= m && x > y) {
num |= 1 << i;
res |= 1 << i;
} else {
res += y;
}
}

cout << res << "\n";
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
// std::cin >> T;
while (T--) solve();
return 0;
}
]]>
+ 计算几何

【二维/数学】Minimum Manhattan Distance

https://codeforces.com/gym/104639/problem/J

题意:给定两个圆的直径的两个点坐标,其中约束条件是两个圆一定是处在相离的两个角上。问如何在C2圆上或圆内找到一点p,使得点p到C1圆的所有点的曼哈顿距离的期望值最小

思路:

  • 看似需要积分,其实我们可以发现,对于点p到C1中某个点q1的曼哈顿距离,我们一定可以找到q1关于C1对称的点q2,那么点p到q1和q2的曼哈顿距离之和就是点p到C1的曼哈顿距离的两倍(证明就是中线定理)那么期望的最小值就是点p到C1的曼哈顿距离的最小值。目标转化后,我们开始思考如何计算此目标的最小值,思路如下图

    image-20240116175917260

注意点:

  • double的读取速度很慢,可以用 int or long long 读入,后续强制类型转换(显示 or 和浮点数计算)
  • 注意输出答案的精度控制 cout << fixed << setprecision(10) << res << "\n";
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void solve() {
double x1, y1, x2, y2;
long long a, b, c, d;

cin >> a >> b >> c >> d;
x1 = (a + c) / 2.0;
y1 = (b + d) / 2.0;

cin >> a >> b >> c >> d;
x2 = (a + c) / 2.0;
y2 = (b + d) / 2.0;

double r2 = sqrt((a - c) * (a - c) + (b - d) * (b - d)) / 2;

cout << fixed << setprecision(10) << abs(x1 - x2) + abs(y1 - y2) - sqrt(2) * r2 << "\n";
}

【枚举】三角形

https://www.acwing.com/problem/content/5383/

题意:给定两个直角三角形的两条直角边的长度 a, b,问能不能在坐标轴上找到三个整数点使得三点满足该直角三角形且三遍均不与坐标轴垂直

思路:首先确定两个直角边的顶点为原点 (0, 0),接着根据对称性直接在第一象限中按照边长枚举其中一个顶点 A,对于每一个枚举到的顶点 A,按照斜率枚举最后一个顶点 B,如果满足长度以及不平行于坐标轴的条件就是合法的点。如果全部枚举完都没有找到就是没有合法组合,直接输出 NO 即可。

时间复杂度:O(a2b)O(a^2b)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <bits/stdc++.h>
using namespace std;

#define int long long

int a, b;

void solve() {
cin >> a >> b;

for (int i = 0; i <= a; i++) {
for (int j = 0; j <= a; j++) {
if (i * i + j * j == a * a && i && j) {
int gcd = __gcd(i, j);
int p = -i / gcd, q = j / gcd;
int y = p, x = q;
while (x * x + y * y < b * b) {
x += q, y += p;
}
if (x * x + y * y == b * b && x != i && y != j) {
cout << "YES\n";
cout << 0 << ' ' << 0 << "\n";
cout << i << ' ' << j << "\n";
cout << x << ' ' << y << "\n";
return;
}
}
}
}

cout << "NO\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
//cin >> T;
while (T--) solve();
return 0;
}

【凸包】奶牛过马路

https://www.acwing.com/problem/content/5572/

题意:给定一个二维坐标系,现在有两个东西可以进行平移。

  • 一个是起始位于 (0,0)(0,0) 的奶牛,可以沿着 yy 轴以 [0,u][0,u] 的速度正向或负向移动
  • 一个是起始位于第一象限的凸包形状的车辆,可以向 xx 轴负半轴以恒定的速度 vv 移动

在给定凸包的 nn 个顶点的情况下,以及不允许奶牛被车辆撞到的情况下,奶牛前进到 (0,w)(0,w) 的最短时间

思路:由于是凸包,因此每一个顶点同时满足的性质,就可以代表整个凸包满足的性质。那么对于奶牛和凸包,就一共有三个局面:

  1. 奶牛速度足够快。可以使得对于任意一个顶点 (x,y)(x,y) 在到达 y 轴时,都小于此时奶牛的 y 值,用时间来表示就是 yuxv\frac{y}{u} \le \frac{x}{v},即 yuvxy \le \frac{u}{v}x,表示凸包上所有的顶点都在直线 y=uvy=\frac{u}{v} 的下方
  2. 凸包速度足够快。可以使得对于任意一个顶点 (x,y)(x,y) 在到达 y 轴时,都大于此时奶牛的 y 值,用时间来表示就是 yuxv\frac{y}{u} \ge \frac{x}{v},即 yuvxy \ge \frac{u}{v}x,表示凸包上所有的顶点都在直线 y=uvy=\frac{u}{v} 的上方
  3. 上述两种都不满足,即直线 y=uvy=\frac{u}{v} 与凸包相交。此时奶牛就不能一直全速(u)前进到终点了,需要进行一定的减速 or 返回操作。有一个显然的结论就是,对于减速 or 返回的操作,都可以等价于后退一段距离后再全速(u)前进,因此对于需要减速 or 返回的操作,我们只需要计算出当前状态下应该后退的距离即可。再简洁的,我们就是需要平移直线 y=uvy=\frac{u}{v},显然只能向下平移而非向上平移,因此其实就是计算出直线 y=uv+by=\frac{u}{v}+b 的截距 bb 使得最终的直线 y=uv+by=\frac{u}{v}+b 满足第二种条件即可。那么如何计算这个 b 呢?很显然我们可以遍历每一个顶点取满足所有顶点都在直线 y=uv+by=\frac{u}{v}+b​ 上方的最大截距 b 即可。

注意点:

  • 浮点数比较注意精度误差,尽可能将除法转化为乘法
  • 在比较相等时可以引入一个无穷小量 ϵ=107\epsilon=10^{-7}
  • 注意答案输出精度要求,10610^{-6} 就需要我们出至少 77 位小数

时间复杂度:O(n)O(n)

C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
#include <cstring>
#include <algorithm>
#include <iomanip>
using namespace std;
using ll = long long;

int main() {
double n, w, v, u;
cin >> n >> w >> v >> u;

bool up = false, down = false;
double b = 0;

while (n--) {
double x, y;
cin >> x >> y;

// 除法无法通过
// if (y / x < u / v) down = true;
// else if (y / x > u / v) up = true;

// 转化为乘法以后才能通过
if (y < u / v * x) down = true;
else if (y > u / v * x) up = true;

b = min(b, y - u / v * x);
}

if (up && down) cout << setprecision(7) << (w - b) / u;
else cout << setprecision(7) << w / u;

return 0;
}

Py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
read = lambda: map(int, input().split())

n, w, v, u = read()
k = u / v
b = 0
up = False
down = False

for _ in range(n):
x, y = read()
if (y < k * x): down = True
if (y > k * x): up = True
b = min(b, y - k * x)

if (up and down): print((w - b) / u)
else: print(w / u)
]]>
@@ -1568,11 +1568,11 @@ - a_template - - /Algorithm/a_template/ + binary-search + + /Algorithm/binary-search/ - 板子

优雅的解法,少不了优雅的板子。目前仅编写 C++ 和 Python 语言对应的板子。前者用于备赛 Xcpc,后者用于备赛蓝桥杯。

基础算法

高精度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
class Int {
private:
int sign;

std::vector<int> v;

void zip(int unzip) {
if (unzip == 0) {
for (int i = 0; i < (int) v.size(); i++) {
v[i] = get_pos(i * 4) + get_pos(i * 4 + 1) * 10 + get_pos(i * 4 + 2) * 100 + get_pos(i * 4 + 3) * 1000;
}
} else {
for (int i = (v.resize(v.size() * 4), (int) v.size() - 1), a; i >= 0; i--) {
a = (i % 4 >= 2) ? v[i / 4] / 100 : v[i / 4] % 100, v[i] = (i & 1) ? a / 10 : a % 10;
}
}
setsign(1, 1);
}

int get_pos(unsigned pos) const {
return pos >= v.size() ? 0 : v[pos];
}

Int& setsign(int newsign, int rev) {
for (int i = (int) v.size() - 1; i > 0 && v[i] == 0; i--) {
v.erase(v.begin() + i);
}
if (v.size() == 0 || (v.size() == 1 && v[0] == 0)) {
sign = 1;
} else {
sign = rev ? newsign * sign : newsign;
}
return *this;
}

bool absless(const Int& b) const {
if (v.size() != b.v.size()) {
return v.size() < b.v.size();
}
for (int i = (int) v.size() - 1; i >= 0; i--) {
if (v[i] != b.v[i]) {
return v[i] < b.v[i];
}
}
return false;
}

void add_mul(const Int& b, int mul) {
v.resize(std::max(v.size(), b.v.size()) + 2);
for (int i = 0, carry = 0; i < (int) b.v.size() || carry; i++) {
carry += v[i] + b.get_pos(i) * mul;
v[i] = carry % 10000, carry /= 10000;
}
}

std::string to_str() const {
Int b = *this;
std::string s;
for (int i = (b.zip(1), 0); i < (int) b.v.size(); ++i) {
s += char(*(b.v.rbegin() + i) + '0');
}
return (sign < 0 ? "-" : "") + (s.empty() ? std::string("0") : s);
}

public:
Int() : sign(1) {}

Int(const std::string& s) { *this = s; }

Int(int v) {
char buf[21];
sprintf(buf, "%d", v);
*this = buf;
}

Int operator-() const {
Int c = *this;
c.sign = (v.size() > 1 || v[0]) ? -c.sign : 1;
return c;
}

Int& operator=(const std::string& s) {
if (s[0] == '-') {
*this = s.substr(1);
} else {
for (int i = (v.clear(), 0); i < (int) s.size(); ++i) {
v.push_back(*(s.rbegin() + i) - '0');
}
zip(0);
}
return setsign(s[0] == '-' ? -1 : 1, sign = 1);
}

bool operator<(const Int& b) const {
if (sign != b.sign) {
return sign < b.sign;
} else if (sign == 1) {
return absless(b);
} else {
return b.absless(*this);
}
}

bool operator==(const Int& b) const {
return v == b.v && sign == b.sign;
}

Int& operator+=(const Int& b) {
if (sign != b.sign) {
return *this = (*this) - -b;
}
v.resize(std::max(v.size(), b.v.size()) + 1);
for (int i = 0, carry = 0; i < (int) b.v.size() || carry; i++) {
carry += v[i] + b.get_pos(i);
v[i] = carry % 10000, carry /= 10000;
}
return setsign(sign, 0);
}

Int operator+(const Int& b) const {
Int c = *this;
return c += b;
}

Int operator-(const Int& b) const {
if (b.v.empty() || b.v.size() == 1 && b.v[0] == 0) {
return *this;
}
if (sign != b.sign) {
return (*this) + -b;
}
if (absless(b)) {
return -(b - *this);
}
Int c;
for (int i = 0, borrow = 0; i < (int) v.size(); i++) {
borrow += v[i] - b.get_pos(i);
c.v.push_back(borrow);
c.v.back() -= 10000 * (borrow >>= 31);
}
return c.setsign(sign, 0);
}

Int operator*(const Int& b) const {
if (b < *this) {
return b * *this;
}
Int c, d = b;
for (int i = 0; i < (int) v.size(); i++, d.v.insert(d.v.begin(), 0)) {
c.add_mul(d, v[i]);
}
return c.setsign(sign * b.sign, 0);
}

Int operator/(const Int& b) const {
Int c, d;
Int e = b;
e.sign = 1;

d.v.resize(v.size());
double db = 1.0 / (b.v.back() + (b.get_pos((unsigned) b.v.size() - 2) / 1e4) +
(b.get_pos((unsigned) b.v.size() - 3) + 1) / 1e8);
for (int i = (int) v.size() - 1; i >= 0; i--) {
c.v.insert(c.v.begin(), v[i]);
int m = (int) ((c.get_pos((int) e.v.size()) * 10000 + c.get_pos((int) e.v.size() - 1)) * db);
c = c - e * m, c.setsign(c.sign, 0), d.v[i] += m;
while (!(c < e)) {
c = c - e, d.v[i] += 1;
}
}
return d.setsign(sign * b.sign, 0);
}

Int operator%(const Int& b) const { return *this - *this / b * b; }

bool operator>(const Int& b) const { return b < *this; }

bool operator<=(const Int& b) const { return !(b < *this); }

bool operator>=(const Int& b) const { return !(*this < b); }

bool operator!=(const Int& b) const { return !(*this == b); }

friend ostream& operator<<(ostream& os, const Int& a) {
os << a.to_str();
return os;
}
};

/* 使用说明
Int a, b;

// 赋值
a = 123;
a = "123";
a = std::string("123");
b = a;

// 输出
cout << a << "\n";
cout << a << a << ' ' << a;

// 运算
a = a + b;
a = a - b;
a = a * b;
a = a / b;

// 比较
bool f1 = a < b;
bool f2 = a <= b;
bool f3 = a > b;
bool f4 = a >= b;
bool f5 = a == b;
bool f6 = a != b;

// 参考
https://github.com/Baobaobear/MiniBigInteger/blob/main/bigint_tiny.h
*/

二分

闭区间寻找左边界:

1
2
3
4
5
6
7
8
9
10
bool findLeft(int x) {
int l = 0, r = n - 1;
while (l < r) {
int mid = (l + r) >> 1;
if (check(mid)) l = mid + 1;
// if (a[mid] < x) l = mid + 1;
else r = mid;
}
return a[r] == x;
}

闭区间寻找右边界:

1
2
3
4
5
6
7
8
9
10
bool findRight(int x) {
int l = 0, r = n - 1;
while (l < r) {
int mid = (l + r + 1) >> 1;
if (check(mid)) l = mid;
// if (a[mid] <= x) l = mid;
else r = mid - 1;
}
return a[r] == x;
}

哈希

使用 std::unordered_map 时可能会因为哈希冲突导致查询、插入操作降低到 O(n)O(n),此时可以使用 std::map 进行替代,或者自定义一个哈希函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
template<class T>
struct CustomHash {
size_t operator()(T x) const {
static const size_t _prime = 0x9e3779b97f4a7c15;
size_t _hash_value = std::hash<T>()(x);
return _hash_value ^ (_hash_value >> 30) ^ _prime;
}
};

// 示例
std::unordered_map<int, int, CustomHash<int>> f1;
std::unordered_map<long long, int, CustomHash<long long>> f2;
std::unordered_map<std::string, int, CustomHash<long long>> f3;

数据结构

并查集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
struct DisjointSetUnion {
int sz;
std::vector<int> p, cnt;

DisjointSetUnion(int n) : p(n), cnt(n, 1) {
for (int i = 0; i < n; i++) {
p[i] = i;
}
sz = n;
}
int find(int x) {
if (p[x] != x) {
p[x] = find(p[x]);
}
return p[x];
}
void merge(int a, int b) {
int pa = find(a), pb = find(b);
if (pa != pb) {
p[pa] = pb;
cnt[pb] += cnt[pa];
sz--;
}
}
bool same(int a, int b) {
return find(a) == find(b);
}
int size() {
return sz;
}
int size(int a) {
int pa = find(a);
return cnt[pa];
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class DSU:
def __init__(self, n: int) -> None:
self.n = n
self.p = [i for i in range(n + 1)]

def find(self, x: int) -> int:
if self.p[x] != x: self.p[x] = self.find(self.p[x])
return self.p[x]

def merge(self, a: int, b: int) -> None:
self.p[self.find(a)] = self.find(b)

def same(self, a: int, b: int) -> bool:
return self.find(a) == self.find(b)

def block(self) -> int:
return sum([1 for i in range(1, self.n + 1) if self.p[i] == i])

树状数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template<class T>
struct BinaryIndexedTree {
std::vector<T> arr;
int n;
BinaryIndexedTree(int n) : n(n), arr(n + 1) {}
int lowbit(int x) {
return x & (-x);
}
void add(int pos, T x) {
while (pos <= n) {
arr[pos] += x;
pos += lowbit(pos);
}
}
T sum(int pos) {
T ret = 0;
while (pos) {
ret += arr[pos];
pos -= lowbit(pos);
}
return ret;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class BinaryIndexedTree:
def __init__(self, n: int):
self.n = n
self.arr = [0] * (n + 1)

def lowbit(self, x: int) -> int:
return x & (-x)

def add(self, pos: int, x: int) -> None:
while pos <= self.n:
self.arr[pos] += x
pos += self.lowbit(pos)

def sum(self, pos: int) -> int:
ret = 0
while pos:
ret += self.arr[pos]
pos -= self.lowbit(pos)
return ret

SortedList

有序列表,改编于 from sortedcontainers import SortedList,共有以下内容:

  • 公开方法:全部都是 O(logn)O(\log n) 的时间复杂度
    1. add(value): 添加一个值到有序列表
    2. discard(value): 删除列表中的值(如果存在)
    3. remove(value): 删除列表中的值(必须存在)
    4. pop(index=-1): 删除并返回指定索引处的值
    5. bisect_left(value): 返回插入值的最左索引
    6. bisect_right(value): 返回插入值的最右索引
    7. count(value): 计算值在列表中的出现次数
  • 魔法方法
    1. __init__():初始化传入一个可迭代对象
    2. __len__(): 返回列表的长度
    3. __getitem__(index): 获取指定索引处的值 - O(logn)O(\log n)
    4. __delitem__(index): 删除指定索引处的值 - O(logn)O(\log n)
    5. __contains__(value): 检查值是否在列表中 - O(logn)O(\log n)
    6. __iter__(): 返回列表的迭代器
    7. __reversed__(): 返回列表的反向迭代器
    8. __repr__(): 返回列表的字符串表示形式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
class SortedList:
def __init__(self, iterable=[], _load=200):
"""Initialize sorted list instance."""
values = sorted(iterable)
self._len = _len = len(values)
self._load = _load
self._lists = _lists = [values[i:i + _load] for i in range(0, _len, _load)]
self._list_lens = [len(_list) for _list in _lists]
self._mins = [_list[0] for _list in _lists]
self._fen_tree = []
self._rebuild = True

def _fen_build(self):
"""Build a fenwick tree instance."""
self._fen_tree[:] = self._list_lens
_fen_tree = self._fen_tree
for i in range(len(_fen_tree)):
if i | i + 1 < len(_fen_tree):
_fen_tree[i | i + 1] += _fen_tree[i]
self._rebuild = False

def _fen_update(self, index, value):
"""Update `fen_tree[index] += value`."""
if not self._rebuild:
_fen_tree = self._fen_tree
while index < len(_fen_tree):
_fen_tree[index] += value
index |= index + 1

def _fen_query(self, end):
"""Return `sum(_fen_tree[:end])`."""
if self._rebuild:
self._fen_build()

_fen_tree = self._fen_tree
x = 0
while end:
x += _fen_tree[end - 1]
end &= end - 1
return x

def _fen_findkth(self, k):
"""Return a pair of (the largest `idx` such that `sum(_fen_tree[:idx]) <= k`, `k - sum(_fen_tree[:idx])`)."""
_list_lens = self._list_lens
if k < _list_lens[0]:
return 0, k
if k >= self._len - _list_lens[-1]:
return len(_list_lens) - 1, k + _list_lens[-1] - self._len
if self._rebuild:
self._fen_build()

_fen_tree = self._fen_tree
idx = -1
for d in reversed(range(len(_fen_tree).bit_length())):
right_idx = idx + (1 << d)
if right_idx < len(_fen_tree) and k >= _fen_tree[right_idx]:
idx = right_idx
k -= _fen_tree[idx]
return idx + 1, k

def _delete(self, pos, idx):
"""Delete value at the given `(pos, idx)`."""
_lists = self._lists
_mins = self._mins
_list_lens = self._list_lens

self._len -= 1
self._fen_update(pos, -1)
del _lists[pos][idx]
_list_lens[pos] -= 1

if _list_lens[pos]:
_mins[pos] = _lists[pos][0]
else:
del _lists[pos]
del _list_lens[pos]
del _mins[pos]
self._rebuild = True

def _loc_left(self, value):
"""Return an index pair that corresponds to the first position of `value` in the sorted list."""
if not self._len:
return 0, 0

_lists = self._lists
_mins = self._mins

lo, pos = -1, len(_lists) - 1
while lo + 1 < pos:
mi = (lo + pos) >> 1
if value <= _mins[mi]:
pos = mi
else:
lo = mi

if pos and value <= _lists[pos - 1][-1]:
pos -= 1

_list = _lists[pos]
lo, idx = -1, len(_list)
while lo + 1 < idx:
mi = (lo + idx) >> 1
if value <= _list[mi]:
idx = mi
else:
lo = mi

return pos, idx

def _loc_right(self, value):
"""Return an index pair that corresponds to the last position of `value` in the sorted list."""
if not self._len:
return 0, 0

_lists = self._lists
_mins = self._mins

pos, hi = 0, len(_lists)
while pos + 1 < hi:
mi = (pos + hi) >> 1
if value < _mins[mi]:
hi = mi
else:
pos = mi

_list = _lists[pos]
lo, idx = -1, len(_list)
while lo + 1 < idx:
mi = (lo + idx) >> 1
if value < _list[mi]:
idx = mi
else:
lo = mi

return pos, idx

def add(self, value):
"""Add `value` to sorted list."""
_load = self._load
_lists = self._lists
_mins = self._mins
_list_lens = self._list_lens

self._len += 1
if _lists:
pos, idx = self._loc_right(value)
self._fen_update(pos, 1)
_list = _lists[pos]
_list.insert(idx, value)
_list_lens[pos] += 1
_mins[pos] = _list[0]
if _load + _load < len(_list):
_lists.insert(pos + 1, _list[_load:])
_list_lens.insert(pos + 1, len(_list) - _load)
_mins.insert(pos + 1, _list[_load])
_list_lens[pos] = _load
del _list[_load:]
self._rebuild = True
else:
_lists.append([value])
_mins.append(value)
_list_lens.append(1)
self._rebuild = True

def discard(self, value):
"""Remove `value` from sorted list if it is a member."""
_lists = self._lists
if _lists:
pos, idx = self._loc_right(value)
if idx and _lists[pos][idx - 1] == value:
self._delete(pos, idx - 1)

def remove(self, value):
"""Remove `value` from sorted list; `value` must be a member."""
_len = self._len
self.discard(value)
if _len == self._len:
raise ValueError('{0!r} not in list'.format(value))

def pop(self, index=-1):
"""Remove and return value at `index` in sorted list."""
pos, idx = self._fen_findkth(self._len + index if index < 0 else index)
value = self._lists[pos][idx]
self._delete(pos, idx)
return value

def bisect_left(self, value):
"""Return the first index to insert `value` in the sorted list."""
pos, idx = self._loc_left(value)
return self._fen_query(pos) + idx

def bisect_right(self, value):
"""Return the last index to insert `value` in the sorted list."""
pos, idx = self._loc_right(value)
return self._fen_query(pos) + idx

def count(self, value):
"""Return number of occurrences of `value` in the sorted list."""
return self.bisect_right(value) - self.bisect_left(value)

def __len__(self):
"""Return the size of the sorted list."""
return self._len

def __getitem__(self, index):
"""Lookup value at `index` in sorted list."""
pos, idx = self._fen_findkth(self._len + index if index < 0 else index)
return self._lists[pos][idx]

def __delitem__(self, index):
"""Remove value at `index` from sorted list."""
pos, idx = self._fen_findkth(self._len + index if index < 0 else index)
self._delete(pos, idx)

def __contains__(self, value):
"""Return true if `value` is an element of the sorted list."""
_lists = self._lists
if _lists:
pos, idx = self._loc_left(value)
return idx < len(_lists[pos]) and _lists[pos][idx] == value
return False

def __iter__(self):
"""Return an iterator over the sorted list."""
return (value for _list in self._lists for value in _list)

def __reversed__(self):
"""Return a reverse iterator over the sorted list."""
return (value for _list in reversed(self._lists) for value in reversed(_list))

def __repr__(self):
"""Return string representation of sorted list."""
return 'SortedList({0})'.format(list(self))

引自:https://www.acwing.com/activity/content/code/content/8475415/

官方:https://github.com/grantjenks/python-sortedcontainers/blob/master/src/sortedcontainers/sortedlist.py

数学

模运算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
template<class T>
T modPower(T a, T b, T p) {
// return: a^b % p
T res = 1 % p;
for (; b; b >>= 1, a = (a * a) % p) {
if (b & 1) {
res = (res * a) % p;
}
}
return res;
}

template<class T>
T modAdd(T a, T b, T p) {
// return: a+b % p
return ((a % p) + (b % p)) % p;
}

template<class T>
T modMul(T a, T b, T p) {
// return: a*b % p
T res = 0;
for (; b; b >>= 1, a = modAdd(a, a, p)) {
if (b & 1) {
res = modAdd(res, a, p);
}
}
return res;
}

template<class T>
T modSumOfEqualRatioArray(T q, T k, T p) {
// return: (q^0 + q^1 + ... + q^k) % p
if (k == 0) {
return 1;
}
if (k % 2 == 0) {
return modAdd<T>((T) 1, modMul(q, modSumOfEqualRatioArray(q, k - 1, p), p), p);
}
return modMul(((T) 1 + modPower(q, k / 2 + (T) 1, p)), modSumOfEqualRatioArray(q, k / 2, p), p);
}

质数筛

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
struct PrimesCount {
int n;
vector<int> pre, vis;
PrimesCount(int n) : n(n), pre(n + 1), vis(n + 1) {
eulerFilter();
}
void eulerFilter() {
// O(n)
vector<int> primes;
for (int i = 2; i <= n; i++) {
if (!vis[i]) {
primes.push_back(i);
pre[i] = pre[i - 1] + 1;
} else {
pre[i] = pre[i - 1];
}
for (int j = 0; primes[j] <= n / i; j++) {
vis[primes[j] * i] = true;
if (i % primes[j] == 0) {
break;
}
}
}
}
void eratosthenesFilter() {
// O(nloglogn)
for (int i = 2; i <= n; i++) {
if (!vis[i]) {
pre[i] = pre[i - 1] + 1;
for (int j = i; j <= n; j += i) {
vis[j] = true;
}
} else {
pre[i] = pre[i - 1];
}
}
}
void simpleFilter() {
// O(nlogn)
for (int i = 2; i <= n; i++) {
if (!vis[i]) {
pre[i] = pre[i - 1] + 1;
} else {
pre[i] = pre[i - 1];
}
for (int j = i; j <= n; j += i) {
vis[j] = true;
}
}
}
};

/* usage
PrimesCount obj(n); // construct an object
cout << obj.pre[n] << "\n"; // pre[i] means prime numbers in range of [1, i]
*/

乘法逆元

之所以需要知道一个数 aa 的乘法逆元,是为了将除法在模 pp 的前提下转化为乘法,从而简化运算。推导 aa 的乘法逆元的逻辑如下:

  1. 对于任意 aa 的整数倍 tt,一定有下式成立:其中的 xx 就是整数 aa 的乘法逆元,记作 a1a^{-1}

    tat×x(modp)1a1×x(modp)1a×x(modp)\begin{aligned}\frac{t}{a} \equiv t \times x\quad (\mod p) \\\frac{1}{a} \equiv 1 \times x\quad (\mod p) \\1 \equiv a \times x\quad (\mod p) \\\end{aligned}

  2. 费马小定理:对于两个互质的整数 g,hg,h 而言,一定有下式成立:

    gh11(modh)g^{h-1} \equiv 1\quad (\mod h)

  3. 于是本题的推导就可以得到,当 aapp 互质时,有:

    ap11(modp)a^{p-1} \equiv 1 \quad (\mod p)

  4. 于是 aa 的乘法逆元 xx 就是:

    x=a1=ap2x = a^{-1} = a^{p-2}

那么本道题就迎刃而解了。需要注意的是,上述乘法逆元的计算前提,即两个整数互质,由于其中一个数 pp 一定是一个质数。因此判断 aapp 是否互质只需要判断 aa 是否是 pp 的倍数即可。

时间复杂度:nlogxn \log x

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
using namespace std;
using ll = long long;

ll qmi(ll a, ll b, ll p) {
ll res = 1 % p;
while (b) {
if (b & 1) res = res * a % p;
a = a * a % p;
b >>= 1;
}
return res;
}

int main() {
int n;
cin >> n;
while (n--) {
ll a, p;
cin >> a >> p;
if (a % p == 0) cout << "impossible\n";
else cout << qmi(a, p - 2, p) << "\n";
}
return 0;
}

组合数

法一:利用递推式

思路:利用 Cab=Ca1b+Ca1b1C_a^b = C_{a-1}^b + C_{a-1}^{b-1} 进行递推

时间复杂度:O(x2)O(x^2)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>
using namespace std;

const int N = 2010;
const int MOD = 1e9 + 7;

int f[N][N];

void init() {
for (int i = 0; i < N; i++) {
for (int j = 0; j <= i; j++) {
if (!j) f[i][j] = 1;
else f[i][j] = (f[i - 1][j] + f[i - 1][j - 1]) % MOD;
}
}
}

int main() {
init();

int q;
cin >> q;
while (q--) {
int a, b;
cin >> a >> b;
cout << f[a][b] << "\n";
}

return 0;
}

法二:利用乘法逆元

思路:除法转换为乘一个逆元

(i!)1=(i1)!1×i1i1=imod2\begin{aligned}(i!)^{-1} &= (i-1)!^{-1} \times i^{-1} \\i^{-1} &= i^{mod-2}\end{aligned}

时间复杂度:O(nlogb)O(n\log b)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
using ll = long long;

const int N = 100010;
const int mod = 1e9 + 7;

int fact[N]; // fact[i] 表示 i! % mod
int infact[N]; // infact[i] 表示 (i!)^{-1}

int qmi(int a, int b, int p) {
int res = 1 % p;
while (b) {
if (b & 1) res = (ll)res * a % p;
a = (ll)a * a % p;
b >>= 1;
}
return res;
}

void init() {
fact[0] = 1, infact[0] = 1;
for (int i = 1; i < N; i++) {
// i! = (i-1)! * i
fact[i] = (ll)fact[i - 1] * i % mod;

// (i!)^{-1} = (i-1)!^{-1} * i^{-1}
// i^{-1} = i^{mod-2}
infact[i] = (ll)infact[i - 1] * qmi(i, mod - 2, mod) % mod;
}
}

int main() {
init();

int n;
cin >> n;
while (n--) {
int a, b;
cin >> a >> b;
cout << (ll)fact[a] * infact[b] % mod * infact[a - b] % mod << "\n";
}

return 0;
}

字符串

sstream

控制中间结果的运算精度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include <iomanip>
#include <sstream>

using ll = long long;
using namespace std;

// 控制中间结果的运算精度
void solve() {
double x = 1.2345678;
cout << x << "\n"; // 输出 1.23457

stringstream ss;
ss << fixed << setprecision(3) << x;
cout << ss.str() << "\n"; // 输出 1.235
}

计算几何

浮点数默认输出 6 位,范围内的数据正常打印,最后一位四舍五入,范围外的数据未知。

]]>
+ 二分

二分本质上是一个线性的算法思维,只是比线性思维更进一步的是,二分思维需要提炼出题面中两个线性相关的变量,即单调变化的两个变量,从而采用二分加速检索。

【二分答案】Building an Aquarium

https://codeforces.com/contest/1873/problem/E

题意:想象有一个二维平面,现在有一个数列,每一个数表示平面对应列的高度,现在要给这个平面在两边加上护栏,问护栏最高可以设置为多高,可以使得在完全填满的情况下,使用的水量不会超过给定的用水量。已知最大用水量为k

思路:对于一个护栏高度,水池高度低于护栏高度的地方都需要被水填满。为了便于分析,我们可以将水池高度进行排序。那么就会很显然的一个二分题目了,我们需要二分的就是护栏的高度(最小为1,最大需要考虑一下,就是只有一列的情况下,最大高度就是最高水池高度 max(ai)+max(k)\max(a_i)+max(k)),check的条件就是当前h的护栏高度时,消耗的水量与最大用水量之间的大小关系,如果超过了,那么高度就要下降,反之可以上升。由于是求最大高度,因此要使用的是求右边界的二分板子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

void solve()
{
int n, w;
cin >> n >> w;
vector<ll> a(n);

for (int i = 0; i < n; i ++)
cin >> a[i];

sort(a.begin(), a.end());

ll l = 0, r = 2e9 + 1;
while (l < r)
{
ll h = (l + r + 1) >> 1;

ll t = 0;
for (int i = 0; i < n; i ++)
if (a[i] < h)
t += h - a[i];
else break;

if (t <= w) l = h;
else r = h - 1;
}
cout << r << endl;
}

int main()
{
int T; cin >> T;
while (T --) solve();
return 0;
}

【二分答案】分组

https://www.lanqiao.cn/problems/5129/learning/

题意:给定一个序列,现在需要将这个数列分为k组,如何分组可以使得每一组的极差中,最大值最小

最开始想到的思路:很容易联想到的一种方法其实就是高中组合数学中学到的“隔板法”,现在有n个数,需要分成k组,则方案数就是在n-1个空档中插入k-1个隔板,即 Cn1k1C_{n-1}^{k-1} 种方案

时间复杂度 O(n2)O(n^2)

优化思路:上述思路是正向思维,即对于构思分组情况计算极差。我们不妨逆向思维,即枚举极差的情况,判断此时的分组情况。如果对于当前的极差lim,我们显然可以分成n组,即有一个最大分组值;我们也可以求一个最小分组值cnt,即如果再少分一组那么此时的极差就会超过当前约束的极差值lim。因此对于当前约束的极差值lim,我们可以求一个最小分组值cnt

  • 如果当前的最小分组值cnt > k,那么 [cnt,n]\left [ cnt,n \right ] 就无法包含k,也就是说当前约束的极差lim不符合条件,lim偏小
  • 如果当前的最小分组值cnt <= k,那么 [cnt,n]\left [ cnt,n \right ] 就一定包含k,且当前分组的最小极差一定是 <= 约束的极差值lim,lim偏大

于是二分极差的思路就跃然纸上了。我们二分极差,然后根据可以分组的最小数量cnt判断二分的结果进行左右约束调整即可。

时间复杂度 O(nlogn)O(n \log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <bits/stdc++.h>
using namespace std;


bool check(int lim, vector<int>& a, int n, int k) {
int cnt = 1; // 当前可以分的最小组数
int pre = a[0];
for (int i = 0; i < n; i++) {
if (a[i] - pre > lim) {
pre = a[i];
cnt++;
}
}
return cnt <= k;
}


void solve() {
int n, k;
cin >> n >> k;

vector<int> a(n);
for (int i = 0; i < n; i++) {
cin >> a[i];
}

sort(a.begin(), a.begin() + n);

int l = 0, r = a[n - 1] - a[0];
while (l < r) {
int mid = (l + r) >> 1;
if (check(mid, a, n, k)) {
// 分的最小组数 <= k,则当前极差大了
r = mid;
} else {
// 分的最小组数 > k,则当前极差小了
l = mid + 1;
}
}

cout << r << "\n";
}


int main() {
ios::sync_with_stdio(false);
cin.tie(0), cout.tie(0);
int T = 1;
// cin >> T;
while (T--) {
solve();
}
return 0;
}

【二分答案】木材加工

https://www.luogu.com.cn/problem/P2440

题意:给定一个序列,现在需要将这个序列切分为等长的 k 段,长度必须为整数且尽可能的长,如果无法切分可以将多余的长度丢弃,问最长的长度是多少

思路:可以发现切分长度与切分的段数具有单调性,即切分的长度越长,切出来的段数就越少,可以进行二分。二分的思路就是直接二分答案,根据长度判断可切得的段数,最终套右边界的模板找到最大的长度即可。需要注意的是,对于无法切割的情况,就是需要切出的段数 k 超过了序列之和

时间复杂度:O(nlog(1e8))O(n\log {(1e8)})

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 1e5 + 10;

int n, k;
int a[N];

bool chk(int x) {
int sum = 0;
for (int i = 0; i < n; i++)
sum += a[i] / x;
return sum >= k;
}

void solve() {
cin >> n >> k;

int sum = 0;
for (int i = 0; i < n; i++) {
cin >> a[i];
sum += a[i];
}

int l = 1, r = 1e8;
while (l < r) {
int mid = (l + r + 1) >> 1;
if (chk(mid)) l = mid;
else r = mid - 1;
}

if (k > sum) cout << "0\n";
else cout << r << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【二分答案】跳石头

https://www.luogu.com.cn/problem/P2678

题意:给定 n 个递增的不重复数,现在可以从其中拿掉 k 个数,使得相邻数字之间的最小差值最大,问最大的最小差值是多少

思路:二分答案。将答案看做 y 轴,拿掉的数的数量看做 x 轴,就是二分 y 值。可以发现拿的数字越多,最小差值就越大,具有单调性,可以二分。我们直接二分答案,即直接二分最小差值的具体数值,通过判断当前的最小差值至少需要拿掉多少个数才能满足,进行 check 操作。至于如何计算至少要拿掉的数字,我们采用右贪心准则,即检查当前点与上一个点之间的距离是否满足最小差值的要求,如果不满足就需要记数,为了便于后续的计算,直接将当前的下标用上一个点的下标覆盖掉即可

时间复杂度:O(nlogn)O(n\log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 5e4 + 10;

int lim, n, k;
int a[N], b[N];
bool del[N];

bool ok(int x) {
int cnt = 0;
memset(del, false, sizeof del);

for (int i = 1; i <= n; i++) {
b[i] = a[i];
}

for (int i = 1; i <= n; i++) {
if (b[i] - b[i - 1] < x) {
del[i] = true;
b[i] = b[i - 1];
cnt++;
}
}

if (lim - b[n] < x) {
cnt++;
}

return cnt <= k;
}

void solve() {
cin >> lim >> n >> k;
for (int i = 1; i <= n; i++)
cin >> a[i];

int l = 1, r = lim;
while (l < r) {
int mid = (l + r + 1) >> 1;
if (ok(mid)) l = mid;
else r = mid - 1;
}

cout << r << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【二分答案】路标设置

https://www.luogu.com.cn/problem/P3853

题意:与第四题题面几乎一致,只是现在不是从序列中拿走 k 数个,而是往序列中插入 k 个数(插入数字后要保证序列仍然没有重复数且递增),问在插入一定数量数字的情况下,最小的最大差值是多少

思路:**二分答案。将答案看做 y 轴,拿掉的数的数量看做 x 轴,就是二分 y 值。**同样可以发现,插入的数字越多,最大差值就越小,具有单调性,可以二分。我们依然直接二分答案,即直接二分最大差值的具体数值,通过判断当前的最大差值需要插入多少个数来检查当前状态是否合理。需要插入的数字的个数为:

a[i]a[i1]distmax1\left \lceil \frac{a[i]-a[i-1]}{dist_{max}}\right \rceil - 1

时间复杂度:O(nlogn)O(n\log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 1e6;

int lim, n, k;
int a[N];

bool chk(int x) {
int cnt = 0;
for (int i = 1; i < n; i++) {
int gap = a[i] - a[i - 1];
if (gap > x) {
cnt += (gap + x - 1) / x - 1;
}
}
return cnt > k;
}

void solve() {
cin >> lim >> n >> k;
for (int i = 0; i < n; i++) {
cin >> a[i];
}

int l = 1, r = lim;
while (l < r) {
int mid = (l + r) >> 1;
if (chk(mid)) l = mid + 1;
else r = mid;
}

cout << r << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【二分答案】数列分段 Section II

https://www.luogu.com.cn/problem/P1182

题意:给定一个无序的序列,现在需要将这个序列进行分段(连续的),分成指定的段数。问应该如何分段可以使得所有段的分段和的最大值最小,输出这个最小的最大值

思路:**二分答案,将答案看做 y 轴,拿掉的数的数量看做 x 轴,就是二分 y 值。**可以发现,分的段数越多,所有分段和的最大值就越小,具有单调性,可以二分。我们直接二分答案,即直接二分分段最大值,通过判断当前最大值的约束条件下可以分的组数进行判断。至于如何计算当前最大值条件下可分得的组数,直接线性扫描进行局部求和即可

时间复杂度:O(nlogn)O(n \log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 1e5 + 10;

int n, k;
int a[N];

// 当前分组时最大子段和为 x
bool chk(int x) {
int cnt = 0;
for (int i = 0, s = 0; i < n; i++) {
if (a[i] > x) return true;

if (s + a[i] <= x) s += a[i];
else {
cnt++;
s = a[i];
}
}
cnt += 1;

return cnt > k;
}

void solve() {
cin >> n >> k;
for (int i = 0; i < n; i++) {
cin >> a[i];
}

int l = 0, r = 1e9;
while (l < r) {
int mid = (l + r) >> 1;
if (chk(mid)) l = mid + 1;
else r = mid;
}

cout << r << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【二分答案】kotori的设备

https://www.luogu.com.cn/problem/P3743

题意:现在有一批电子设备,每一个电子设备有一个电量消耗速度与当前剩余电量,现在有一个充电器有一个确定的充电速度。问这批设备最久可以运作多久?当可以无限运作时输出 -1

思路:可以发现,想要运行的越久,需要补充的电量就越多,具有单调性,可以直接二分答案。很显然我们可以根据期望运行的时间进行 check 操作,通过比对当前期望时间可以充的电量与需要补充的电量进行比对来修改边界值。需要注意的是边界的选择,最长可运行时间为 1e10,具体 推导 待定

时间复杂度:O(n(log1010))O(n(\log{10^{10})})

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 100010;

int n;
double p, v[N], s[N];

bool chk(double x) {
// x 为当前状态期望使用的时间
double need = 0;
for (int i = 1; i <= n; i++)
if (v[i] * x > s[i])
need += v[i] * x - s[i];
return need <= x * p;
}

void solve() {
cin >> n >> p;
for (int i = 1; i <= n; i++) {
cin >> v[i] >> s[i];
}

double l = 0, r = 1e10 + 10;
while (r - l > 1e-6) {
double mid = (l + r) / 2;
if (chk(mid)) l = mid;
else r = mid;
}

if (r > 1e10) cout << -1 << "\n";
else cout << r << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【二分答案/并查集】关押罪犯 🔥

https://www.luogu.com.cn/problem/P1525

题意:给定一个无向图,没有重边和自环,边权为正。现在需要将图中所有的顶点分为两部分,使得两部分中最大的边权尽可能小,问该最小边权是多少

思路一:二分答案。

  • 思路:本题一眼二分图问题,但是有些变化的是左右部并非散点图,其中是有连边的。如何求出最小边权呢?我们可以这么想,首先答案一定出自左右部的连边中(除非左右部全是散点,那答案就是 0),之所以可以采用二分图将点集分为两部分,是因为我们在某种规则下忽略了其中奇数环(二分图定理)的一些边。当规则定义为 忽略边权不超过 x 的边时,该图若可以二分,那么忽略边权比 x 值更大的边时,该图同样一定也可以二分,反之则不一定可以二分(特性图如下)。具备单调性,于是我们可以通过 二分阈值、检查图是否可二分 的方法来计算出答案

image-20240223011704197

时间复杂度:Θ(logCmax×(n+e))\Theta(\log C_{max} \times (n+e))

思路二:并查集。

  • 思路:

  • 时间复杂度:

二分判定二分图代码:

1
2
3
4
5
```

并查集代码:

```cpp

【二分答案】摆放棋子

https://www.acwing.com/problem/content/5562/

题意:给定一个 01 序列表示一个 1×n1\times n 的棋子序列,其中 1 表示有棋子,0 表示没有棋子。现在给定 k 个棋子,如何放置可以使得棋盘上连续的棋子长度尽可能长?给出一个合法的最长的序列

思路:可以发现,想要序列尽可能的长,那么需要放置的棋子就要尽可能的多,具备单调性,可以二分。我们二分答案,即最长序列长度。对于已知的 k 个可放置棋子,我们需要找到最大的序列长度,于是我们套用寻找右边界模板。检查方法就是判断对于当前的长度,通过前缀和与滑动窗口的形式计算当前窗口内需要放置多少颗棋子才能连为一体:

  • 若需要的棋子数 < k,说明可以继续增大长度
  • 若需要的棋子数 > k,说明当前长度无法满足,要缩小长度
  • 若需要的棋子数 = k,归属在 < k 的类比中即可。

时间复杂度:O(nlogn)O(n \log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <iostream>
#include <cstring>
#include <vector>
#include <queue>
#include <stack>
#include <algorithm>
#include <unordered_map>
#include <set>
using namespace std;

const int N = 300010;

int n, k;
int a[N], s[N];

bool chk(int x) {
for (int i = x; i <= n; i++) {
if (x - (s[i] - s[i - x]) <= k) {
return true;
}
}
return false;
}

void solve() {
cin >> n >> k;
for (int i = 1; i <= n; i++) {
cin >> a[i];
s[i] = s[i - 1] + a[i];
}

int l = 0, r = n;
while (l < r) {
int mid = (l + r + 1) >> 1;
if (chk(mid)) l = mid;
else r = mid - 1;
}

for (int i = r; i <= n; i++) {
if (r - (s[i] - s[i - r]) <= k) {
for (int j = i - r + 1; j <= i; j++) {
a[j] = 1;
}
break;
}
}

cout << r << "\n";
for (int i = 1; i <= n; i++) cout << a[i] << " ";
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【二分答案】盖楼

https://www.acwing.com/problem/content/description/5569/

题意:给定 H 个正整数分别为 1 到 H。现在要将这 H 个正整数分给两个人,其中一个人希望获得可以不被 x 整除的数,另一个人希望可以获得不被 y 整除的数。其中 x 和 y 均为质数,问最小的 H 是多少?

思路:

  • 很显然的一个二分答案。因为 H 越大,越有可能满足这两个人的要求,具备单调性。

  • 那么现在的问题就是检查函数的设计。对于当前的高度 h,即 h 个正整数,很显然可以划分出下面的集合关系

    集合关系

    其中 h-p-q+a 的部分两个人都可以获得。最策略是,p-a 与 q-a 的都给另外的人,如果不够,从 h-p-q+a 中拿,如果还不够那就说明当前 h 无法满足,需要增大 h,反之说明 h 可以满足,继续寻找答案的左边界。

时间复杂度:Θ(log(N+M))\Theta(\log (N+M))

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
using namespace std;
typedef long long ll;

ll n, m, x, y;

bool chk(ll h) {
ll p = h / x, q = h / y, a = h / (x * y);

return max(0ll, n - (q - a)) + max(0ll, m - (p - a)) <= h - p - q + a;
}

void solve() {
cin >> n >> m >> x >> y;

ll l = 1, r = 1e15;
while (l < r) {
ll mid = (l + r) >> 1;
if (chk(mid)) r = mid;
else l = mid + 1;
}

cout << r << "\n";
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

小插曲。一开始写的检查函数 code 始终只能通过 9/10,检查函数的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool chk(ll h) {
ll p = h / x, q = h / y, a = h / (x * y);

if (h - p >= n && h - q >= m) {
// 如果满足第一人,检查第二人是否满足
if (q - a >= n) return true;
else if (h - (n - (q - a)) - q >= m) return true;

// 如果满足第二人,检查第一人是否满足
if (p - a >= m) return true;
else if (h - (n - (p - a)) - p >= n) return true;
}
return false;
}

其实是因为有逻辑错误,即对于当前的 h,必须两个人都能满足才行。而上述代码可能会出现第一个人不满足,但是第二个人满足,却返回 true 的情况,而这是错误的。因此上述代码改成两个人同时满足就可以通过了,即如果有一个人不满足,则返回 false:

1
2
3
4
5
6
7
8
9
10
11
12
bool chk(ll h) {
ll p = h / x, q = h / y, a = h / (x * y);

if (h - p >= n && h - q >= m) {
// 如果满足第一人,检查第二人是否满足
if (q - a >= n) return true;
else if (h - (n - (q - a)) - q >= m) return true;

return false;
}
return false;
}

小结:过多的分支语句不如一个 max 来的更加清晰,也可以避免一定的逻辑错误。

【二分答案】找出叠涂元素

https://leetcode.cn/problems/first-completely-painted-row-or-column/description/

题意:给定一个 m×nm\times n 的矩阵,以及一个存储矩阵中所有元素值的数组,现在从左往右将数组中的元素在对应矩阵中涂色,问序列中最左边使得矩阵中某一行或列全部涂色的下标是什么

思路:显然,下标越往右越有可能出现矩阵中某一行或列全部涂色,具备单调性,可以二分答案。我们直接二分序列下标,对于 chk 函数我们直接 O(n×m)O(n\times m) 模拟即可

时间复杂度:O(n×mlog(n×m))O(n\times m \log (n\times m))

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Solution {
public:
int firstCompleteIndex(vector<int>& arr, vector<vector<int>>& mat) {
int m = mat.size(), n = mat[0].size();

auto chk = [&](int idx) {
bool vis[100010] {};
for (int i = 0; i <= idx; i++) {
vis[arr[i]] = true;
}

for (int i = 0; i < m; i++) {
int s = 0;
for (int j = 0; j < n; j++) {
s += vis[mat[i][j]];
}
if (s == n) return true;
}

for (int j = 0; j < n; j++) {
int s = 0;
for (int i = 0; i < m; i++) {
s += vis[mat[i][j]];
}
if (s == m) return true;
}

return false;
};

int l = 0, r = m * n - 1;
while (l < r) {
int mid = (l + r) >> 1;
if (chk(mid)) r = mid;
else l = mid + 1;
}
return r;
}
};

【二分答案/双指针】找出唯一性数组的中位数

https://leetcode.cn/problems/find-the-median-of-the-uniqueness-array/

题意:给定一个数组,返回其「唯一性数组」的中位数。如果唯一性数组有奇数个元素,则返回中间元素,如果有偶数个元素,则返回两个中间元素中较小的那个,即左边那个中间元素。唯一性数组定义为:原数组的所有非空子数组中不同元素的个数组成的序列。

思路:

  • 首先根据题意可以明确的计算出这个唯一性数组的元素个数 tottot。假设原数组有 nn 个元素,则其唯一性数组一定有 tot=(1+n)×n2\displaystyle tot=\frac{(1+n) \times n}{2} 个元素,且最小值一定是 11,最大值 n\le n。最暴力的做法就是 O(n2)O(n^2) 的维护出这个唯一性数组然后返回其中位数,但这对于 10510^5 级别的数据显然是不合适的。观察其他性质。
  • 不难发现,对于「不同元素的个数 xx」而言,xx 越大,满足条件的子数组数量就越多,反之 xx 越小,则满足条件的子数组数量就越少,具备单调性,考虑二分。
  • 问题就是在 [1,n][1,n] 内找到一个尽可能小的 upperupper 使得满足「不同元素的个数 upper\le upper 的子数组」的数量 tot2\displaystyle \ge \lceil \frac{tot}{2} \rceil
  • 我们能否在指定不同元素个数上限为 upperupper 的情况下,快速计算合法子数组的个数呢?答案是可以的,我们引入双指针算法进行 O(n)O(n)checkcheck。我们枚举子数组的右边界并利用哈希表维护子数组的左边界进行统计即可。

时间复杂度:O(nlogn)O(n\log n)

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Solution {
public:
int medianOfUniquenessArray(vector<int>& nums) {
using ll = long long;
int n = nums.size();

ll tot = 1ll * (1 + n) * n / 2;
int ma = *max_element(nums.begin(), nums.end());
int f[ma + 1];
memset(f, 0, sizeof f);

// 双指针 check
auto chk = [&](int upper) -> bool {
ll cnt = 0;
int size = 0; // 哈希表大小
for (int l = 0, r = 0; r < n; r++) {
size += f[nums[r]] == 0;
f[nums[r]]++;
while (size > upper) {
--f[nums[l]];
size -= f[nums[l]] == 0;
l++;
}
// 此时以 nums[r] 结尾且 l 为左边界的所有子数组的不同元素的数量均 <= upper
cnt += r - l + 1;
}
memset(f, 0, sizeof f);
return cnt >= (tot + 1) / 2; // 上取整写法
};

int l = 1, r = n;
while (l < r) {
int mid = (l + r) >> 1;
if (chk(mid)) r = mid;
else l = mid + 1;
}
return r;
}
};
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution:
def medianOfUniquenessArray(self, nums: List[int]) -> int:
n = len(nums)
ma = max(nums)
tot = (1 + n) * n >> 1

def chk(upper: int) -> int:
f = [0] * (ma + 1)
cnt, l, size = 0, 0, 0
for r in range(n):
size += f[nums[r]] == 0
f[nums[r]] += 1
while size > upper:
f[nums[l]] -= 1
size -= f[nums[l]] == 0
l += 1
cnt += r - l + 1
return cnt >= (tot + 1) >> 1

l, r = 1, n
while l < r:
mid = (l + r) >> 1
if chk(mid): r = mid
else: l = mid + 1
return r

【二分查找】Bomb

https://codeforces.com/contest/1996/problem/F

题意:给定两个序列 aabb 以及最大操作次数 kk,问在不超过最大操作次数的情况下,最多可以获得多少收益?收益的计算方法为:每次选择 aa 中一个数 a[i] 加入收益,并将该数减去 b[i] 直到为 00

思路:

  • 暴力抬手:最暴力的做法是我们每次操作时选择 aa 中的最大值进行计数并对其修改,但是显然 O(nk)O(nk) 会超时,我们考虑加速这个过程。
  • 加速进程:由于最终结果中每次操作都是最优的,因此操作次数越多获得的收益就越大,具备单调性。如何利用这个单调性呢?关键在于全体数据的操作次数,我们记作 kk'。假设我们对 aa 中全体数据可取的范围设置一个下限 lowerlower,那么显然的 lowerlower 越高,kk' 越小,lowerlower 越低,kk' 越大。于是根据单调的传递性可知 lowerlower 也和最终的收益具备单调性,lowerlower 越高,收益越小,lowerlower 越低,收益越大。因此我们二分 lowerlower,使得 kk' 左逼近 kk
  • 细节处理:二分的边界是什么?这需要从计算最终结果的角度来思考。对于二分出的下界 lowerlower,即 rr,此时扫描一遍计算出来的操作次数 kk' 一定是 kkk'\le k。也就是说我们还需要操作 kkk-k' 次,显然我们可以将每个数操作后维护到最终的结果,然后执行上述的暴力思路,但是这样仍然会超时,因为 kkk-k' 的计算结果最大是 nn,此时进行暴力仍然会达到 O(n2)O(n^2)。正难则反,我们不妨将上述二分出的 lowerlower 减一进行最终答案的计算,这样对应的操作次数 kk' 一定会超过 kk,并且对于超过 kk 的操作,累加到答案的数值一定都是 lower1lower-1,这一步也是本题最精妙的一步。从上述分析不难发现,二分的下界需要设定为 11,因为后续累加答案时会对 lowerlower 进行减一操作;二分的上界需要设定为严格大于 10910^9 的数,比如 109+110^9+1,因为我们需要保证 lower1lower-1 后对应的操作次数 kk' 严格大于 kk

时间复杂度:O(nlogk)O(n\log k)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <bits/stdc++.h>

using ll = long long;

void solve() {
int n, k;
std::cin >> n >> k;

std::vector<int> a(n), b(n);
for (int i = 0; i < n; i++) std::cin >> a[i];
for (int i = 0; i < n; i++) std::cin >> b[i];

auto chk = [&](int x) {
ll cnt = 0;
for (int i = 0; i < n; i++) {
if (a[i] < x) continue;
cnt += (a[i] - x) / b[i] + 1;
}
return cnt;
};

int l = 1, r = 1e9 + 10;
while (l < r) {
int mid = (l + r) >> 1;
if (chk(mid) <= k) r = mid;
else l = mid + 1;
}

int best = r - 1;
ll res = 0, cnt = 0;
for (int i = 0; i < n; i++) {
if (a[i] < best) continue;
ll t = (a[i] - best) / b[i] + 1;
cnt += t;
res += t * a[i] - t * (t - 1) / 2 * b[i];
}
res -= (cnt - k) * best;

std::cout << res << "\n";
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
std::cin >> T;
while (T--) solve();
return 0;
}
]]>
@@ -1587,11 +1587,11 @@ - divide-and-conquer - - /Algorithm/divide-and-conquer/ + a_template + + /Algorithm/a_template/ - 分治

将大问题转化为等价小问题进行求解。

【分治】随机排列

https://www.acwing.com/problem/content/5469/

题意:给定一个 n 个数的全排列序列,并将其进行一定的对换,问是对换了 3n 次还是 7n+1 次

思路:可以发现对于两种情况,就对应对换次数的奇偶性。当 n 为奇数:3n 为奇数,7n+1 为偶数;当 n 为偶数:3n 为偶数,7n+1 为奇数。故我们只需要判断序列的逆序数即可。为了求解逆序数,我们可以采用归并排序的 combine 过程进行统计即可

时间复杂度:O(nlogn)O(n \log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 1000010;

int n, a[N], t[N];
int cnt; // 逆序数

void MergeSort(int l, int r) {
if (l >= r) return;

int mid = (l + r) >> 1;

MergeSort(l, mid), MergeSort(mid + 1, r);

int i = l, j = mid + 1, idx = 0;

while (i <= mid && j <= r) {
if (a[i] < a[j]) t[idx++] = a[i++];
else {
cnt += mid - i + 1;
t[idx++] = a[j++];
}
}

while (i <= mid) t[idx++] = a[i++];
while (j <= r) t[idx++] = a[j++];

for (i = l, idx = 0; i <= r; i++, idx++) a[i] = t[idx];
}

void solve() {
cin >> n;

for (int i = 0; i < n; i++) cin >> a[i];

MergeSort(0, n - 1);

int res;

if (n % 2 == 1) {
if (cnt % 2) res = 1;
else res = 2;
} else {
if (cnt % 2) res = 2;
else res = 1;
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
//cin >> T;
while (T--) solve();
return 0;
}
]]>
+ 板子

优雅的解法,少不了优雅的板子。目前仅编写 C++ 和 Python 语言对应的板子。前者用于备赛 Xcpc,后者用于备赛蓝桥杯。

基础算法

高精度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
class Int {
private:
int sign;

std::vector<int> v;

void zip(int unzip) {
if (unzip == 0) {
for (int i = 0; i < (int) v.size(); i++) {
v[i] = get_pos(i * 4) + get_pos(i * 4 + 1) * 10 + get_pos(i * 4 + 2) * 100 + get_pos(i * 4 + 3) * 1000;
}
} else {
for (int i = (v.resize(v.size() * 4), (int) v.size() - 1), a; i >= 0; i--) {
a = (i % 4 >= 2) ? v[i / 4] / 100 : v[i / 4] % 100, v[i] = (i & 1) ? a / 10 : a % 10;
}
}
setsign(1, 1);
}

int get_pos(unsigned pos) const {
return pos >= v.size() ? 0 : v[pos];
}

Int& setsign(int newsign, int rev) {
for (int i = (int) v.size() - 1; i > 0 && v[i] == 0; i--) {
v.erase(v.begin() + i);
}
if (v.size() == 0 || (v.size() == 1 && v[0] == 0)) {
sign = 1;
} else {
sign = rev ? newsign * sign : newsign;
}
return *this;
}

bool absless(const Int& b) const {
if (v.size() != b.v.size()) {
return v.size() < b.v.size();
}
for (int i = (int) v.size() - 1; i >= 0; i--) {
if (v[i] != b.v[i]) {
return v[i] < b.v[i];
}
}
return false;
}

void add_mul(const Int& b, int mul) {
v.resize(std::max(v.size(), b.v.size()) + 2);
for (int i = 0, carry = 0; i < (int) b.v.size() || carry; i++) {
carry += v[i] + b.get_pos(i) * mul;
v[i] = carry % 10000, carry /= 10000;
}
}

std::string to_str() const {
Int b = *this;
std::string s;
for (int i = (b.zip(1), 0); i < (int) b.v.size(); ++i) {
s += char(*(b.v.rbegin() + i) + '0');
}
return (sign < 0 ? "-" : "") + (s.empty() ? std::string("0") : s);
}

public:
Int() : sign(1) {}

Int(const std::string& s) { *this = s; }

Int(int v) {
char buf[21];
sprintf(buf, "%d", v);
*this = buf;
}

Int operator-() const {
Int c = *this;
c.sign = (v.size() > 1 || v[0]) ? -c.sign : 1;
return c;
}

Int& operator=(const std::string& s) {
if (s[0] == '-') {
*this = s.substr(1);
} else {
for (int i = (v.clear(), 0); i < (int) s.size(); ++i) {
v.push_back(*(s.rbegin() + i) - '0');
}
zip(0);
}
return setsign(s[0] == '-' ? -1 : 1, sign = 1);
}

bool operator<(const Int& b) const {
if (sign != b.sign) {
return sign < b.sign;
} else if (sign == 1) {
return absless(b);
} else {
return b.absless(*this);
}
}

bool operator==(const Int& b) const {
return v == b.v && sign == b.sign;
}

Int& operator+=(const Int& b) {
if (sign != b.sign) {
return *this = (*this) - -b;
}
v.resize(std::max(v.size(), b.v.size()) + 1);
for (int i = 0, carry = 0; i < (int) b.v.size() || carry; i++) {
carry += v[i] + b.get_pos(i);
v[i] = carry % 10000, carry /= 10000;
}
return setsign(sign, 0);
}

Int operator+(const Int& b) const {
Int c = *this;
return c += b;
}

Int operator-(const Int& b) const {
if (b.v.empty() || b.v.size() == 1 && b.v[0] == 0) {
return *this;
}
if (sign != b.sign) {
return (*this) + -b;
}
if (absless(b)) {
return -(b - *this);
}
Int c;
for (int i = 0, borrow = 0; i < (int) v.size(); i++) {
borrow += v[i] - b.get_pos(i);
c.v.push_back(borrow);
c.v.back() -= 10000 * (borrow >>= 31);
}
return c.setsign(sign, 0);
}

Int operator*(const Int& b) const {
if (b < *this) {
return b * *this;
}
Int c, d = b;
for (int i = 0; i < (int) v.size(); i++, d.v.insert(d.v.begin(), 0)) {
c.add_mul(d, v[i]);
}
return c.setsign(sign * b.sign, 0);
}

Int operator/(const Int& b) const {
Int c, d;
Int e = b;
e.sign = 1;

d.v.resize(v.size());
double db = 1.0 / (b.v.back() + (b.get_pos((unsigned) b.v.size() - 2) / 1e4) +
(b.get_pos((unsigned) b.v.size() - 3) + 1) / 1e8);
for (int i = (int) v.size() - 1; i >= 0; i--) {
c.v.insert(c.v.begin(), v[i]);
int m = (int) ((c.get_pos((int) e.v.size()) * 10000 + c.get_pos((int) e.v.size() - 1)) * db);
c = c - e * m, c.setsign(c.sign, 0), d.v[i] += m;
while (!(c < e)) {
c = c - e, d.v[i] += 1;
}
}
return d.setsign(sign * b.sign, 0);
}

Int operator%(const Int& b) const { return *this - *this / b * b; }

bool operator>(const Int& b) const { return b < *this; }

bool operator<=(const Int& b) const { return !(b < *this); }

bool operator>=(const Int& b) const { return !(*this < b); }

bool operator!=(const Int& b) const { return !(*this == b); }

friend ostream& operator<<(ostream& os, const Int& a) {
os << a.to_str();
return os;
}
};

/* 使用说明
Int a, b;

// 赋值
a = 123;
a = "123";
a = std::string("123");
b = a;

// 输出
cout << a << "\n";
cout << a << a << ' ' << a;

// 运算
a = a + b;
a = a - b;
a = a * b;
a = a / b;

// 比较
bool f1 = a < b;
bool f2 = a <= b;
bool f3 = a > b;
bool f4 = a >= b;
bool f5 = a == b;
bool f6 = a != b;

// 参考
https://github.com/Baobaobear/MiniBigInteger/blob/main/bigint_tiny.h
*/

二分

闭区间寻找左边界:

1
2
3
4
5
6
7
8
9
10
bool findLeft(int x) {
int l = 0, r = n - 1;
while (l < r) {
int mid = (l + r) >> 1;
if (check(mid)) l = mid + 1;
// if (a[mid] < x) l = mid + 1;
else r = mid;
}
return a[r] == x;
}

闭区间寻找右边界:

1
2
3
4
5
6
7
8
9
10
bool findRight(int x) {
int l = 0, r = n - 1;
while (l < r) {
int mid = (l + r + 1) >> 1;
if (check(mid)) l = mid;
// if (a[mid] <= x) l = mid;
else r = mid - 1;
}
return a[r] == x;
}

哈希

使用 std::unordered_map 时可能会因为哈希冲突导致查询、插入操作降低到 O(n)O(n),此时可以使用 std::map 进行替代,或者自定义一个哈希函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
template<class T>
struct CustomHash {
size_t operator()(T x) const {
static const size_t _prime = 0x9e3779b97f4a7c15;
size_t _hash_value = std::hash<T>()(x);
return _hash_value ^ (_hash_value >> 30) ^ _prime;
}
};

// 示例
std::unordered_map<int, int, CustomHash<int>> f1;
std::unordered_map<long long, int, CustomHash<long long>> f2;
std::unordered_map<std::string, int, CustomHash<long long>> f3;

数据结构

并查集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
struct DisjointSetUnion {
int sz;
std::vector<int> p, cnt;

DisjointSetUnion(int n) : p(n), cnt(n, 1) {
for (int i = 0; i < n; i++) {
p[i] = i;
}
sz = n;
}
int find(int x) {
if (p[x] != x) {
p[x] = find(p[x]);
}
return p[x];
}
void merge(int a, int b) {
int pa = find(a), pb = find(b);
if (pa != pb) {
p[pa] = pb;
cnt[pb] += cnt[pa];
sz--;
}
}
bool same(int a, int b) {
return find(a) == find(b);
}
int size() {
return sz;
}
int size(int a) {
int pa = find(a);
return cnt[pa];
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class DSU:
def __init__(self, n: int) -> None:
self.n = n
self.p = [i for i in range(n + 1)]

def find(self, x: int) -> int:
if self.p[x] != x: self.p[x] = self.find(self.p[x])
return self.p[x]

def merge(self, a: int, b: int) -> None:
self.p[self.find(a)] = self.find(b)

def same(self, a: int, b: int) -> bool:
return self.find(a) == self.find(b)

def block(self) -> int:
return sum([1 for i in range(1, self.n + 1) if self.p[i] == i])

树状数组

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template<class T>
struct BinaryIndexedTree {
std::vector<T> arr;
int n;
BinaryIndexedTree(int n) : n(n), arr(n + 1) {}
int lowbit(int x) {
return x & (-x);
}
void add(int pos, T x) {
while (pos <= n) {
arr[pos] += x;
pos += lowbit(pos);
}
}
T sum(int pos) {
T ret = 0;
while (pos) {
ret += arr[pos];
pos -= lowbit(pos);
}
return ret;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class BinaryIndexedTree:
def __init__(self, n: int):
self.n = n
self.arr = [0] * (n + 1)

def lowbit(self, x: int) -> int:
return x & (-x)

def add(self, pos: int, x: int) -> None:
while pos <= self.n:
self.arr[pos] += x
pos += self.lowbit(pos)

def sum(self, pos: int) -> int:
ret = 0
while pos:
ret += self.arr[pos]
pos -= self.lowbit(pos)
return ret

SortedList

有序列表,改编于 from sortedcontainers import SortedList,共有以下内容:

  • 公开方法:全部都是 O(logn)O(\log n) 的时间复杂度
    1. add(value): 添加一个值到有序列表
    2. discard(value): 删除列表中的值(如果存在)
    3. remove(value): 删除列表中的值(必须存在)
    4. pop(index=-1): 删除并返回指定索引处的值
    5. bisect_left(value): 返回插入值的最左索引
    6. bisect_right(value): 返回插入值的最右索引
    7. count(value): 计算值在列表中的出现次数
  • 魔法方法
    1. __init__():初始化传入一个可迭代对象
    2. __len__(): 返回列表的长度
    3. __getitem__(index): 获取指定索引处的值 - O(logn)O(\log n)
    4. __delitem__(index): 删除指定索引处的值 - O(logn)O(\log n)
    5. __contains__(value): 检查值是否在列表中 - O(logn)O(\log n)
    6. __iter__(): 返回列表的迭代器
    7. __reversed__(): 返回列表的反向迭代器
    8. __repr__(): 返回列表的字符串表示形式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
class SortedList:
def __init__(self, iterable=[], _load=200):
"""Initialize sorted list instance."""
values = sorted(iterable)
self._len = _len = len(values)
self._load = _load
self._lists = _lists = [values[i:i + _load] for i in range(0, _len, _load)]
self._list_lens = [len(_list) for _list in _lists]
self._mins = [_list[0] for _list in _lists]
self._fen_tree = []
self._rebuild = True

def _fen_build(self):
"""Build a fenwick tree instance."""
self._fen_tree[:] = self._list_lens
_fen_tree = self._fen_tree
for i in range(len(_fen_tree)):
if i | i + 1 < len(_fen_tree):
_fen_tree[i | i + 1] += _fen_tree[i]
self._rebuild = False

def _fen_update(self, index, value):
"""Update `fen_tree[index] += value`."""
if not self._rebuild:
_fen_tree = self._fen_tree
while index < len(_fen_tree):
_fen_tree[index] += value
index |= index + 1

def _fen_query(self, end):
"""Return `sum(_fen_tree[:end])`."""
if self._rebuild:
self._fen_build()

_fen_tree = self._fen_tree
x = 0
while end:
x += _fen_tree[end - 1]
end &= end - 1
return x

def _fen_findkth(self, k):
"""Return a pair of (the largest `idx` such that `sum(_fen_tree[:idx]) <= k`, `k - sum(_fen_tree[:idx])`)."""
_list_lens = self._list_lens
if k < _list_lens[0]:
return 0, k
if k >= self._len - _list_lens[-1]:
return len(_list_lens) - 1, k + _list_lens[-1] - self._len
if self._rebuild:
self._fen_build()

_fen_tree = self._fen_tree
idx = -1
for d in reversed(range(len(_fen_tree).bit_length())):
right_idx = idx + (1 << d)
if right_idx < len(_fen_tree) and k >= _fen_tree[right_idx]:
idx = right_idx
k -= _fen_tree[idx]
return idx + 1, k

def _delete(self, pos, idx):
"""Delete value at the given `(pos, idx)`."""
_lists = self._lists
_mins = self._mins
_list_lens = self._list_lens

self._len -= 1
self._fen_update(pos, -1)
del _lists[pos][idx]
_list_lens[pos] -= 1

if _list_lens[pos]:
_mins[pos] = _lists[pos][0]
else:
del _lists[pos]
del _list_lens[pos]
del _mins[pos]
self._rebuild = True

def _loc_left(self, value):
"""Return an index pair that corresponds to the first position of `value` in the sorted list."""
if not self._len:
return 0, 0

_lists = self._lists
_mins = self._mins

lo, pos = -1, len(_lists) - 1
while lo + 1 < pos:
mi = (lo + pos) >> 1
if value <= _mins[mi]:
pos = mi
else:
lo = mi

if pos and value <= _lists[pos - 1][-1]:
pos -= 1

_list = _lists[pos]
lo, idx = -1, len(_list)
while lo + 1 < idx:
mi = (lo + idx) >> 1
if value <= _list[mi]:
idx = mi
else:
lo = mi

return pos, idx

def _loc_right(self, value):
"""Return an index pair that corresponds to the last position of `value` in the sorted list."""
if not self._len:
return 0, 0

_lists = self._lists
_mins = self._mins

pos, hi = 0, len(_lists)
while pos + 1 < hi:
mi = (pos + hi) >> 1
if value < _mins[mi]:
hi = mi
else:
pos = mi

_list = _lists[pos]
lo, idx = -1, len(_list)
while lo + 1 < idx:
mi = (lo + idx) >> 1
if value < _list[mi]:
idx = mi
else:
lo = mi

return pos, idx

def add(self, value):
"""Add `value` to sorted list."""
_load = self._load
_lists = self._lists
_mins = self._mins
_list_lens = self._list_lens

self._len += 1
if _lists:
pos, idx = self._loc_right(value)
self._fen_update(pos, 1)
_list = _lists[pos]
_list.insert(idx, value)
_list_lens[pos] += 1
_mins[pos] = _list[0]
if _load + _load < len(_list):
_lists.insert(pos + 1, _list[_load:])
_list_lens.insert(pos + 1, len(_list) - _load)
_mins.insert(pos + 1, _list[_load])
_list_lens[pos] = _load
del _list[_load:]
self._rebuild = True
else:
_lists.append([value])
_mins.append(value)
_list_lens.append(1)
self._rebuild = True

def discard(self, value):
"""Remove `value` from sorted list if it is a member."""
_lists = self._lists
if _lists:
pos, idx = self._loc_right(value)
if idx and _lists[pos][idx - 1] == value:
self._delete(pos, idx - 1)

def remove(self, value):
"""Remove `value` from sorted list; `value` must be a member."""
_len = self._len
self.discard(value)
if _len == self._len:
raise ValueError('{0!r} not in list'.format(value))

def pop(self, index=-1):
"""Remove and return value at `index` in sorted list."""
pos, idx = self._fen_findkth(self._len + index if index < 0 else index)
value = self._lists[pos][idx]
self._delete(pos, idx)
return value

def bisect_left(self, value):
"""Return the first index to insert `value` in the sorted list."""
pos, idx = self._loc_left(value)
return self._fen_query(pos) + idx

def bisect_right(self, value):
"""Return the last index to insert `value` in the sorted list."""
pos, idx = self._loc_right(value)
return self._fen_query(pos) + idx

def count(self, value):
"""Return number of occurrences of `value` in the sorted list."""
return self.bisect_right(value) - self.bisect_left(value)

def __len__(self):
"""Return the size of the sorted list."""
return self._len

def __getitem__(self, index):
"""Lookup value at `index` in sorted list."""
pos, idx = self._fen_findkth(self._len + index if index < 0 else index)
return self._lists[pos][idx]

def __delitem__(self, index):
"""Remove value at `index` from sorted list."""
pos, idx = self._fen_findkth(self._len + index if index < 0 else index)
self._delete(pos, idx)

def __contains__(self, value):
"""Return true if `value` is an element of the sorted list."""
_lists = self._lists
if _lists:
pos, idx = self._loc_left(value)
return idx < len(_lists[pos]) and _lists[pos][idx] == value
return False

def __iter__(self):
"""Return an iterator over the sorted list."""
return (value for _list in self._lists for value in _list)

def __reversed__(self):
"""Return a reverse iterator over the sorted list."""
return (value for _list in reversed(self._lists) for value in reversed(_list))

def __repr__(self):
"""Return string representation of sorted list."""
return 'SortedList({0})'.format(list(self))

引自:https://www.acwing.com/activity/content/code/content/8475415/

官方:https://github.com/grantjenks/python-sortedcontainers/blob/master/src/sortedcontainers/sortedlist.py

数学

模运算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
template<class T>
T modPower(T a, T b, T p) {
// return: a^b % p
T res = 1 % p;
for (; b; b >>= 1, a = (a * a) % p) {
if (b & 1) {
res = (res * a) % p;
}
}
return res;
}

template<class T>
T modAdd(T a, T b, T p) {
// return: a+b % p
return ((a % p) + (b % p)) % p;
}

template<class T>
T modMul(T a, T b, T p) {
// return: a*b % p
T res = 0;
for (; b; b >>= 1, a = modAdd(a, a, p)) {
if (b & 1) {
res = modAdd(res, a, p);
}
}
return res;
}

template<class T>
T modSumOfEqualRatioArray(T q, T k, T p) {
// return: (q^0 + q^1 + ... + q^k) % p
if (k == 0) {
return 1;
}
if (k % 2 == 0) {
return modAdd<T>((T) 1, modMul(q, modSumOfEqualRatioArray(q, k - 1, p), p), p);
}
return modMul(((T) 1 + modPower(q, k / 2 + (T) 1, p)), modSumOfEqualRatioArray(q, k / 2, p), p);
}

质数筛

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
struct PrimesCount {
int n;
vector<int> pre, vis;
PrimesCount(int n) : n(n), pre(n + 1), vis(n + 1) {
eulerFilter();
}
void eulerFilter() {
// O(n)
vector<int> primes;
for (int i = 2; i <= n; i++) {
if (!vis[i]) {
primes.push_back(i);
pre[i] = pre[i - 1] + 1;
} else {
pre[i] = pre[i - 1];
}
for (int j = 0; primes[j] <= n / i; j++) {
vis[primes[j] * i] = true;
if (i % primes[j] == 0) {
break;
}
}
}
}
void eratosthenesFilter() {
// O(nloglogn)
for (int i = 2; i <= n; i++) {
if (!vis[i]) {
pre[i] = pre[i - 1] + 1;
for (int j = i; j <= n; j += i) {
vis[j] = true;
}
} else {
pre[i] = pre[i - 1];
}
}
}
void simpleFilter() {
// O(nlogn)
for (int i = 2; i <= n; i++) {
if (!vis[i]) {
pre[i] = pre[i - 1] + 1;
} else {
pre[i] = pre[i - 1];
}
for (int j = i; j <= n; j += i) {
vis[j] = true;
}
}
}
};

/* usage
PrimesCount obj(n); // construct an object
cout << obj.pre[n] << "\n"; // pre[i] means prime numbers in range of [1, i]
*/

乘法逆元

之所以需要知道一个数 aa 的乘法逆元,是为了将除法在模 pp 的前提下转化为乘法,从而简化运算。推导 aa 的乘法逆元的逻辑如下:

  1. 对于任意 aa 的整数倍 tt,一定有下式成立:其中的 xx 就是整数 aa 的乘法逆元,记作 a1a^{-1}

    tat×x(modp)1a1×x(modp)1a×x(modp)\begin{aligned}\frac{t}{a} \equiv t \times x\quad (\mod p) \\\frac{1}{a} \equiv 1 \times x\quad (\mod p) \\1 \equiv a \times x\quad (\mod p) \\\end{aligned}

  2. 费马小定理:对于两个互质的整数 g,hg,h 而言,一定有下式成立:

    gh11(modh)g^{h-1} \equiv 1\quad (\mod h)

  3. 于是本题的推导就可以得到,当 aapp 互质时,有:

    ap11(modp)a^{p-1} \equiv 1 \quad (\mod p)

  4. 于是 aa 的乘法逆元 xx 就是:

    x=a1=ap2x = a^{-1} = a^{p-2}

那么本道题就迎刃而解了。需要注意的是,上述乘法逆元的计算前提,即两个整数互质,由于其中一个数 pp 一定是一个质数。因此判断 aapp 是否互质只需要判断 aa 是否是 pp 的倍数即可。

时间复杂度:nlogxn \log x

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#include <iostream>
using namespace std;
using ll = long long;

ll qmi(ll a, ll b, ll p) {
ll res = 1 % p;
while (b) {
if (b & 1) res = res * a % p;
a = a * a % p;
b >>= 1;
}
return res;
}

int main() {
int n;
cin >> n;
while (n--) {
ll a, p;
cin >> a >> p;
if (a % p == 0) cout << "impossible\n";
else cout << qmi(a, p - 2, p) << "\n";
}
return 0;
}

组合数

法一:利用递推式

思路:利用 Cab=Ca1b+Ca1b1C_a^b = C_{a-1}^b + C_{a-1}^{b-1} 进行递推

时间复杂度:O(x2)O(x^2)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>
using namespace std;

const int N = 2010;
const int MOD = 1e9 + 7;

int f[N][N];

void init() {
for (int i = 0; i < N; i++) {
for (int j = 0; j <= i; j++) {
if (!j) f[i][j] = 1;
else f[i][j] = (f[i - 1][j] + f[i - 1][j - 1]) % MOD;
}
}
}

int main() {
init();

int q;
cin >> q;
while (q--) {
int a, b;
cin >> a >> b;
cout << f[a][b] << "\n";
}

return 0;
}

法二:利用乘法逆元

思路:除法转换为乘一个逆元

(i!)1=(i1)!1×i1i1=imod2\begin{aligned}(i!)^{-1} &= (i-1)!^{-1} \times i^{-1} \\i^{-1} &= i^{mod-2}\end{aligned}

时间复杂度:O(nlogb)O(n\log b)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
using ll = long long;

const int N = 100010;
const int mod = 1e9 + 7;

int fact[N]; // fact[i] 表示 i! % mod
int infact[N]; // infact[i] 表示 (i!)^{-1}

int qmi(int a, int b, int p) {
int res = 1 % p;
while (b) {
if (b & 1) res = (ll)res * a % p;
a = (ll)a * a % p;
b >>= 1;
}
return res;
}

void init() {
fact[0] = 1, infact[0] = 1;
for (int i = 1; i < N; i++) {
// i! = (i-1)! * i
fact[i] = (ll)fact[i - 1] * i % mod;

// (i!)^{-1} = (i-1)!^{-1} * i^{-1}
// i^{-1} = i^{mod-2}
infact[i] = (ll)infact[i - 1] * qmi(i, mod - 2, mod) % mod;
}
}

int main() {
init();

int n;
cin >> n;
while (n--) {
int a, b;
cin >> a >> b;
cout << (ll)fact[a] * infact[b] % mod * infact[a - b] % mod << "\n";
}

return 0;
}

字符串

sstream

控制中间结果的运算精度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#include <iostream>
#include <iomanip>
#include <sstream>

using ll = long long;
using namespace std;

// 控制中间结果的运算精度
void solve() {
double x = 1.2345678;
cout << x << "\n"; // 输出 1.23457

stringstream ss;
ss << fixed << setprecision(3) << x;
cout << ss.str() << "\n"; // 输出 1.235
}

计算几何

浮点数默认输出 6 位,范围内的数据正常打印,最后一位四舍五入,范围外的数据未知。

]]>
@@ -1606,11 +1606,11 @@ - binary-search - - /Algorithm/binary-search/ + dfs-and-similar + + /Algorithm/dfs-and-similar/ - 二分

二分本质上是一个线性的算法思维,只是比线性思维更进一步的是,二分思维需要提炼出题面中两个线性相关的变量,即单调变化的两个变量,从而采用二分加速检索。

【二分答案】Building an Aquarium

https://codeforces.com/contest/1873/problem/E

题意:想象有一个二维平面,现在有一个数列,每一个数表示平面对应列的高度,现在要给这个平面在两边加上护栏,问护栏最高可以设置为多高,可以使得在完全填满的情况下,使用的水量不会超过给定的用水量。已知最大用水量为k

思路:对于一个护栏高度,水池高度低于护栏高度的地方都需要被水填满。为了便于分析,我们可以将水池高度进行排序。那么就会很显然的一个二分题目了,我们需要二分的就是护栏的高度(最小为1,最大需要考虑一下,就是只有一列的情况下,最大高度就是最高水池高度 max(ai)+max(k)\max(a_i)+max(k)),check的条件就是当前h的护栏高度时,消耗的水量与最大用水量之间的大小关系,如果超过了,那么高度就要下降,反之可以上升。由于是求最大高度,因此要使用的是求右边界的二分板子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <bits/stdc++.h>
using namespace std;

typedef long long ll;

void solve()
{
int n, w;
cin >> n >> w;
vector<ll> a(n);

for (int i = 0; i < n; i ++)
cin >> a[i];

sort(a.begin(), a.end());

ll l = 0, r = 2e9 + 1;
while (l < r)
{
ll h = (l + r + 1) >> 1;

ll t = 0;
for (int i = 0; i < n; i ++)
if (a[i] < h)
t += h - a[i];
else break;

if (t <= w) l = h;
else r = h - 1;
}
cout << r << endl;
}

int main()
{
int T; cin >> T;
while (T --) solve();
return 0;
}

【二分答案】分组

https://www.lanqiao.cn/problems/5129/learning/

题意:给定一个序列,现在需要将这个数列分为k组,如何分组可以使得每一组的极差中,最大值最小

最开始想到的思路:很容易联想到的一种方法其实就是高中组合数学中学到的“隔板法”,现在有n个数,需要分成k组,则方案数就是在n-1个空档中插入k-1个隔板,即 Cn1k1C_{n-1}^{k-1} 种方案

时间复杂度 O(n2)O(n^2)

优化思路:上述思路是正向思维,即对于构思分组情况计算极差。我们不妨逆向思维,即枚举极差的情况,判断此时的分组情况。如果对于当前的极差lim,我们显然可以分成n组,即有一个最大分组值;我们也可以求一个最小分组值cnt,即如果再少分一组那么此时的极差就会超过当前约束的极差值lim。因此对于当前约束的极差值lim,我们可以求一个最小分组值cnt

  • 如果当前的最小分组值cnt > k,那么 [cnt,n]\left [ cnt,n \right ] 就无法包含k,也就是说当前约束的极差lim不符合条件,lim偏小
  • 如果当前的最小分组值cnt <= k,那么 [cnt,n]\left [ cnt,n \right ] 就一定包含k,且当前分组的最小极差一定是 <= 约束的极差值lim,lim偏大

于是二分极差的思路就跃然纸上了。我们二分极差,然后根据可以分组的最小数量cnt判断二分的结果进行左右约束调整即可。

时间复杂度 O(nlogn)O(n \log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <bits/stdc++.h>
using namespace std;


bool check(int lim, vector<int>& a, int n, int k) {
int cnt = 1; // 当前可以分的最小组数
int pre = a[0];
for (int i = 0; i < n; i++) {
if (a[i] - pre > lim) {
pre = a[i];
cnt++;
}
}
return cnt <= k;
}


void solve() {
int n, k;
cin >> n >> k;

vector<int> a(n);
for (int i = 0; i < n; i++) {
cin >> a[i];
}

sort(a.begin(), a.begin() + n);

int l = 0, r = a[n - 1] - a[0];
while (l < r) {
int mid = (l + r) >> 1;
if (check(mid, a, n, k)) {
// 分的最小组数 <= k,则当前极差大了
r = mid;
} else {
// 分的最小组数 > k,则当前极差小了
l = mid + 1;
}
}

cout << r << "\n";
}


int main() {
ios::sync_with_stdio(false);
cin.tie(0), cout.tie(0);
int T = 1;
// cin >> T;
while (T--) {
solve();
}
return 0;
}

【二分答案】木材加工

https://www.luogu.com.cn/problem/P2440

题意:给定一个序列,现在需要将这个序列切分为等长的 k 段,长度必须为整数且尽可能的长,如果无法切分可以将多余的长度丢弃,问最长的长度是多少

思路:可以发现切分长度与切分的段数具有单调性,即切分的长度越长,切出来的段数就越少,可以进行二分。二分的思路就是直接二分答案,根据长度判断可切得的段数,最终套右边界的模板找到最大的长度即可。需要注意的是,对于无法切割的情况,就是需要切出的段数 k 超过了序列之和

时间复杂度:O(nlog(1e8))O(n\log {(1e8)})

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 1e5 + 10;

int n, k;
int a[N];

bool chk(int x) {
int sum = 0;
for (int i = 0; i < n; i++)
sum += a[i] / x;
return sum >= k;
}

void solve() {
cin >> n >> k;

int sum = 0;
for (int i = 0; i < n; i++) {
cin >> a[i];
sum += a[i];
}

int l = 1, r = 1e8;
while (l < r) {
int mid = (l + r + 1) >> 1;
if (chk(mid)) l = mid;
else r = mid - 1;
}

if (k > sum) cout << "0\n";
else cout << r << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【二分答案】跳石头

https://www.luogu.com.cn/problem/P2678

题意:给定 n 个递增的不重复数,现在可以从其中拿掉 k 个数,使得相邻数字之间的最小差值最大,问最大的最小差值是多少

思路:二分答案。将答案看做 y 轴,拿掉的数的数量看做 x 轴,就是二分 y 值。可以发现拿的数字越多,最小差值就越大,具有单调性,可以二分。我们直接二分答案,即直接二分最小差值的具体数值,通过判断当前的最小差值至少需要拿掉多少个数才能满足,进行 check 操作。至于如何计算至少要拿掉的数字,我们采用右贪心准则,即检查当前点与上一个点之间的距离是否满足最小差值的要求,如果不满足就需要记数,为了便于后续的计算,直接将当前的下标用上一个点的下标覆盖掉即可

时间复杂度:O(nlogn)O(n\log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 5e4 + 10;

int lim, n, k;
int a[N], b[N];
bool del[N];

bool ok(int x) {
int cnt = 0;
memset(del, false, sizeof del);

for (int i = 1; i <= n; i++) {
b[i] = a[i];
}

for (int i = 1; i <= n; i++) {
if (b[i] - b[i - 1] < x) {
del[i] = true;
b[i] = b[i - 1];
cnt++;
}
}

if (lim - b[n] < x) {
cnt++;
}

return cnt <= k;
}

void solve() {
cin >> lim >> n >> k;
for (int i = 1; i <= n; i++)
cin >> a[i];

int l = 1, r = lim;
while (l < r) {
int mid = (l + r + 1) >> 1;
if (ok(mid)) l = mid;
else r = mid - 1;
}

cout << r << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【二分答案】路标设置

https://www.luogu.com.cn/problem/P3853

题意:与第四题题面几乎一致,只是现在不是从序列中拿走 k 数个,而是往序列中插入 k 个数(插入数字后要保证序列仍然没有重复数且递增),问在插入一定数量数字的情况下,最小的最大差值是多少

思路:**二分答案。将答案看做 y 轴,拿掉的数的数量看做 x 轴,就是二分 y 值。**同样可以发现,插入的数字越多,最大差值就越小,具有单调性,可以二分。我们依然直接二分答案,即直接二分最大差值的具体数值,通过判断当前的最大差值需要插入多少个数来检查当前状态是否合理。需要插入的数字的个数为:

a[i]a[i1]distmax1\left \lceil \frac{a[i]-a[i-1]}{dist_{max}}\right \rceil - 1

时间复杂度:O(nlogn)O(n\log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 1e6;

int lim, n, k;
int a[N];

bool chk(int x) {
int cnt = 0;
for (int i = 1; i < n; i++) {
int gap = a[i] - a[i - 1];
if (gap > x) {
cnt += (gap + x - 1) / x - 1;
}
}
return cnt > k;
}

void solve() {
cin >> lim >> n >> k;
for (int i = 0; i < n; i++) {
cin >> a[i];
}

int l = 1, r = lim;
while (l < r) {
int mid = (l + r) >> 1;
if (chk(mid)) l = mid + 1;
else r = mid;
}

cout << r << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【二分答案】数列分段 Section II

https://www.luogu.com.cn/problem/P1182

题意:给定一个无序的序列,现在需要将这个序列进行分段(连续的),分成指定的段数。问应该如何分段可以使得所有段的分段和的最大值最小,输出这个最小的最大值

思路:**二分答案,将答案看做 y 轴,拿掉的数的数量看做 x 轴,就是二分 y 值。**可以发现,分的段数越多,所有分段和的最大值就越小,具有单调性,可以二分。我们直接二分答案,即直接二分分段最大值,通过判断当前最大值的约束条件下可以分的组数进行判断。至于如何计算当前最大值条件下可分得的组数,直接线性扫描进行局部求和即可

时间复杂度:O(nlogn)O(n \log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 1e5 + 10;

int n, k;
int a[N];

// 当前分组时最大子段和为 x
bool chk(int x) {
int cnt = 0;
for (int i = 0, s = 0; i < n; i++) {
if (a[i] > x) return true;

if (s + a[i] <= x) s += a[i];
else {
cnt++;
s = a[i];
}
}
cnt += 1;

return cnt > k;
}

void solve() {
cin >> n >> k;
for (int i = 0; i < n; i++) {
cin >> a[i];
}

int l = 0, r = 1e9;
while (l < r) {
int mid = (l + r) >> 1;
if (chk(mid)) l = mid + 1;
else r = mid;
}

cout << r << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【二分答案】kotori的设备

https://www.luogu.com.cn/problem/P3743

题意:现在有一批电子设备,每一个电子设备有一个电量消耗速度与当前剩余电量,现在有一个充电器有一个确定的充电速度。问这批设备最久可以运作多久?当可以无限运作时输出 -1

思路:可以发现,想要运行的越久,需要补充的电量就越多,具有单调性,可以直接二分答案。很显然我们可以根据期望运行的时间进行 check 操作,通过比对当前期望时间可以充的电量与需要补充的电量进行比对来修改边界值。需要注意的是边界的选择,最长可运行时间为 1e10,具体 推导 待定

时间复杂度:O(n(log1010))O(n(\log{10^{10})})

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 100010;

int n;
double p, v[N], s[N];

bool chk(double x) {
// x 为当前状态期望使用的时间
double need = 0;
for (int i = 1; i <= n; i++)
if (v[i] * x > s[i])
need += v[i] * x - s[i];
return need <= x * p;
}

void solve() {
cin >> n >> p;
for (int i = 1; i <= n; i++) {
cin >> v[i] >> s[i];
}

double l = 0, r = 1e10 + 10;
while (r - l > 1e-6) {
double mid = (l + r) / 2;
if (chk(mid)) l = mid;
else r = mid;
}

if (r > 1e10) cout << -1 << "\n";
else cout << r << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【二分答案/并查集】关押罪犯 🔥

https://www.luogu.com.cn/problem/P1525

题意:给定一个无向图,没有重边和自环,边权为正。现在需要将图中所有的顶点分为两部分,使得两部分中最大的边权尽可能小,问该最小边权是多少

思路一:二分答案。

  • 思路:本题一眼二分图问题,但是有些变化的是左右部并非散点图,其中是有连边的。如何求出最小边权呢?我们可以这么想,首先答案一定出自左右部的连边中(除非左右部全是散点,那答案就是 0),之所以可以采用二分图将点集分为两部分,是因为我们在某种规则下忽略了其中奇数环(二分图定理)的一些边。当规则定义为 忽略边权不超过 x 的边时,该图若可以二分,那么忽略边权比 x 值更大的边时,该图同样一定也可以二分,反之则不一定可以二分(特性图如下)。具备单调性,于是我们可以通过 二分阈值、检查图是否可二分 的方法来计算出答案

image-20240223011704197

时间复杂度:Θ(logCmax×(n+e))\Theta(\log C_{max} \times (n+e))

思路二:并查集。

  • 思路:

  • 时间复杂度:

二分判定二分图代码:

1
2
3
4
5
```

并查集代码:

```cpp

【二分答案】摆放棋子

https://www.acwing.com/problem/content/5562/

题意:给定一个 01 序列表示一个 1×n1\times n 的棋子序列,其中 1 表示有棋子,0 表示没有棋子。现在给定 k 个棋子,如何放置可以使得棋盘上连续的棋子长度尽可能长?给出一个合法的最长的序列

思路:可以发现,想要序列尽可能的长,那么需要放置的棋子就要尽可能的多,具备单调性,可以二分。我们二分答案,即最长序列长度。对于已知的 k 个可放置棋子,我们需要找到最大的序列长度,于是我们套用寻找右边界模板。检查方法就是判断对于当前的长度,通过前缀和与滑动窗口的形式计算当前窗口内需要放置多少颗棋子才能连为一体:

  • 若需要的棋子数 < k,说明可以继续增大长度
  • 若需要的棋子数 > k,说明当前长度无法满足,要缩小长度
  • 若需要的棋子数 = k,归属在 < k 的类比中即可。

时间复杂度:O(nlogn)O(n \log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <iostream>
#include <cstring>
#include <vector>
#include <queue>
#include <stack>
#include <algorithm>
#include <unordered_map>
#include <set>
using namespace std;

const int N = 300010;

int n, k;
int a[N], s[N];

bool chk(int x) {
for (int i = x; i <= n; i++) {
if (x - (s[i] - s[i - x]) <= k) {
return true;
}
}
return false;
}

void solve() {
cin >> n >> k;
for (int i = 1; i <= n; i++) {
cin >> a[i];
s[i] = s[i - 1] + a[i];
}

int l = 0, r = n;
while (l < r) {
int mid = (l + r + 1) >> 1;
if (chk(mid)) l = mid;
else r = mid - 1;
}

for (int i = r; i <= n; i++) {
if (r - (s[i] - s[i - r]) <= k) {
for (int j = i - r + 1; j <= i; j++) {
a[j] = 1;
}
break;
}
}

cout << r << "\n";
for (int i = 1; i <= n; i++) cout << a[i] << " ";
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【二分答案】盖楼

https://www.acwing.com/problem/content/description/5569/

题意:给定 H 个正整数分别为 1 到 H。现在要将这 H 个正整数分给两个人,其中一个人希望获得可以不被 x 整除的数,另一个人希望可以获得不被 y 整除的数。其中 x 和 y 均为质数,问最小的 H 是多少?

思路:

  • 很显然的一个二分答案。因为 H 越大,越有可能满足这两个人的要求,具备单调性。

  • 那么现在的问题就是检查函数的设计。对于当前的高度 h,即 h 个正整数,很显然可以划分出下面的集合关系

    集合关系

    其中 h-p-q+a 的部分两个人都可以获得。最策略是,p-a 与 q-a 的都给另外的人,如果不够,从 h-p-q+a 中拿,如果还不够那就说明当前 h 无法满足,需要增大 h,反之说明 h 可以满足,继续寻找答案的左边界。

时间复杂度:Θ(log(N+M))\Theta(\log (N+M))

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <iostream>
using namespace std;
typedef long long ll;

ll n, m, x, y;

bool chk(ll h) {
ll p = h / x, q = h / y, a = h / (x * y);

return max(0ll, n - (q - a)) + max(0ll, m - (p - a)) <= h - p - q + a;
}

void solve() {
cin >> n >> m >> x >> y;

ll l = 1, r = 1e15;
while (l < r) {
ll mid = (l + r) >> 1;
if (chk(mid)) r = mid;
else l = mid + 1;
}

cout << r << "\n";
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

小插曲。一开始写的检查函数 code 始终只能通过 9/10,检查函数的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
bool chk(ll h) {
ll p = h / x, q = h / y, a = h / (x * y);

if (h - p >= n && h - q >= m) {
// 如果满足第一人,检查第二人是否满足
if (q - a >= n) return true;
else if (h - (n - (q - a)) - q >= m) return true;

// 如果满足第二人,检查第一人是否满足
if (p - a >= m) return true;
else if (h - (n - (p - a)) - p >= n) return true;
}
return false;
}

其实是因为有逻辑错误,即对于当前的 h,必须两个人都能满足才行。而上述代码可能会出现第一个人不满足,但是第二个人满足,却返回 true 的情况,而这是错误的。因此上述代码改成两个人同时满足就可以通过了,即如果有一个人不满足,则返回 false:

1
2
3
4
5
6
7
8
9
10
11
12
bool chk(ll h) {
ll p = h / x, q = h / y, a = h / (x * y);

if (h - p >= n && h - q >= m) {
// 如果满足第一人,检查第二人是否满足
if (q - a >= n) return true;
else if (h - (n - (q - a)) - q >= m) return true;

return false;
}
return false;
}

小结:过多的分支语句不如一个 max 来的更加清晰,也可以避免一定的逻辑错误。

【二分答案】找出叠涂元素

https://leetcode.cn/problems/first-completely-painted-row-or-column/description/

题意:给定一个 m×nm\times n 的矩阵,以及一个存储矩阵中所有元素值的数组,现在从左往右将数组中的元素在对应矩阵中涂色,问序列中最左边使得矩阵中某一行或列全部涂色的下标是什么

思路:显然,下标越往右越有可能出现矩阵中某一行或列全部涂色,具备单调性,可以二分答案。我们直接二分序列下标,对于 chk 函数我们直接 O(n×m)O(n\times m) 模拟即可

时间复杂度:O(n×mlog(n×m))O(n\times m \log (n\times m))

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Solution {
public:
int firstCompleteIndex(vector<int>& arr, vector<vector<int>>& mat) {
int m = mat.size(), n = mat[0].size();

auto chk = [&](int idx) {
bool vis[100010] {};
for (int i = 0; i <= idx; i++) {
vis[arr[i]] = true;
}

for (int i = 0; i < m; i++) {
int s = 0;
for (int j = 0; j < n; j++) {
s += vis[mat[i][j]];
}
if (s == n) return true;
}

for (int j = 0; j < n; j++) {
int s = 0;
for (int i = 0; i < m; i++) {
s += vis[mat[i][j]];
}
if (s == m) return true;
}

return false;
};

int l = 0, r = m * n - 1;
while (l < r) {
int mid = (l + r) >> 1;
if (chk(mid)) r = mid;
else l = mid + 1;
}
return r;
}
};

【二分答案/双指针】找出唯一性数组的中位数

https://leetcode.cn/problems/find-the-median-of-the-uniqueness-array/

题意:给定一个数组,返回其「唯一性数组」的中位数。如果唯一性数组有奇数个元素,则返回中间元素,如果有偶数个元素,则返回两个中间元素中较小的那个,即左边那个中间元素。唯一性数组定义为:原数组的所有非空子数组中不同元素的个数组成的序列。

思路:

  • 首先根据题意可以明确的计算出这个唯一性数组的元素个数 tottot。假设原数组有 nn 个元素,则其唯一性数组一定有 tot=(1+n)×n2\displaystyle tot=\frac{(1+n) \times n}{2} 个元素,且最小值一定是 11,最大值 n\le n。最暴力的做法就是 O(n2)O(n^2) 的维护出这个唯一性数组然后返回其中位数,但这对于 10510^5 级别的数据显然是不合适的。观察其他性质。
  • 不难发现,对于「不同元素的个数 xx」而言,xx 越大,满足条件的子数组数量就越多,反之 xx 越小,则满足条件的子数组数量就越少,具备单调性,考虑二分。
  • 问题就是在 [1,n][1,n] 内找到一个尽可能小的 upperupper 使得满足「不同元素的个数 upper\le upper 的子数组」的数量 tot2\displaystyle \ge \lceil \frac{tot}{2} \rceil
  • 我们能否在指定不同元素个数上限为 upperupper 的情况下,快速计算合法子数组的个数呢?答案是可以的,我们引入双指针算法进行 O(n)O(n)checkcheck。我们枚举子数组的右边界并利用哈希表维护子数组的左边界进行统计即可。

时间复杂度:O(nlogn)O(n\log n)

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Solution {
public:
int medianOfUniquenessArray(vector<int>& nums) {
using ll = long long;
int n = nums.size();

ll tot = 1ll * (1 + n) * n / 2;
int ma = *max_element(nums.begin(), nums.end());
int f[ma + 1];
memset(f, 0, sizeof f);

// 双指针 check
auto chk = [&](int upper) -> bool {
ll cnt = 0;
int size = 0; // 哈希表大小
for (int l = 0, r = 0; r < n; r++) {
size += f[nums[r]] == 0;
f[nums[r]]++;
while (size > upper) {
--f[nums[l]];
size -= f[nums[l]] == 0;
l++;
}
// 此时以 nums[r] 结尾且 l 为左边界的所有子数组的不同元素的数量均 <= upper
cnt += r - l + 1;
}
memset(f, 0, sizeof f);
return cnt >= (tot + 1) / 2; // 上取整写法
};

int l = 1, r = n;
while (l < r) {
int mid = (l + r) >> 1;
if (chk(mid)) r = mid;
else l = mid + 1;
}
return r;
}
};
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution:
def medianOfUniquenessArray(self, nums: List[int]) -> int:
n = len(nums)
ma = max(nums)
tot = (1 + n) * n >> 1

def chk(upper: int) -> int:
f = [0] * (ma + 1)
cnt, l, size = 0, 0, 0
for r in range(n):
size += f[nums[r]] == 0
f[nums[r]] += 1
while size > upper:
f[nums[l]] -= 1
size -= f[nums[l]] == 0
l += 1
cnt += r - l + 1
return cnt >= (tot + 1) >> 1

l, r = 1, n
while l < r:
mid = (l + r) >> 1
if chk(mid): r = mid
else: l = mid + 1
return r

【二分查找】Bomb

https://codeforces.com/contest/1996/problem/F

题意:给定两个序列 aabb 以及最大操作次数 kk,问在不超过最大操作次数的情况下,最多可以获得多少收益?收益的计算方法为:每次选择 aa 中一个数 a[i] 加入收益,并将该数减去 b[i] 直到为 00

思路:

  • 暴力抬手:最暴力的做法是我们每次操作时选择 aa 中的最大值进行计数并对其修改,但是显然 O(nk)O(nk) 会超时,我们考虑加速这个过程。
  • 加速进程:由于最终结果中每次操作都是最优的,因此操作次数越多获得的收益就越大,具备单调性。如何利用这个单调性呢?关键在于全体数据的操作次数,我们记作 kk'。假设我们对 aa 中全体数据可取的范围设置一个下限 lowerlower,那么显然的 lowerlower 越高,kk' 越小,lowerlower 越低,kk' 越大。于是根据单调的传递性可知 lowerlower 也和最终的收益具备单调性,lowerlower 越高,收益越小,lowerlower 越低,收益越大。因此我们二分 lowerlower,使得 kk' 左逼近 kk
  • 细节处理:二分的边界是什么?这需要从计算最终结果的角度来思考。对于二分出的下界 lowerlower,即 rr,此时扫描一遍计算出来的操作次数 kk' 一定是 kkk'\le k。也就是说我们还需要操作 kkk-k' 次,显然我们可以将每个数操作后维护到最终的结果,然后执行上述的暴力思路,但是这样仍然会超时,因为 kkk-k' 的计算结果最大是 nn,此时进行暴力仍然会达到 O(n2)O(n^2)。正难则反,我们不妨将上述二分出的 lowerlower 减一进行最终答案的计算,这样对应的操作次数 kk' 一定会超过 kk,并且对于超过 kk 的操作,累加到答案的数值一定都是 lower1lower-1,这一步也是本题最精妙的一步。从上述分析不难发现,二分的下界需要设定为 11,因为后续累加答案时会对 lowerlower 进行减一操作;二分的上界需要设定为严格大于 10910^9 的数,比如 109+110^9+1,因为我们需要保证 lower1lower-1 后对应的操作次数 kk' 严格大于 kk

时间复杂度:O(nlogk)O(n\log k)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <bits/stdc++.h>

using ll = long long;

void solve() {
int n, k;
std::cin >> n >> k;

std::vector<int> a(n), b(n);
for (int i = 0; i < n; i++) std::cin >> a[i];
for (int i = 0; i < n; i++) std::cin >> b[i];

auto chk = [&](int x) {
ll cnt = 0;
for (int i = 0; i < n; i++) {
if (a[i] < x) continue;
cnt += (a[i] - x) / b[i] + 1;
}
return cnt;
};

int l = 1, r = 1e9 + 10;
while (l < r) {
int mid = (l + r) >> 1;
if (chk(mid) <= k) r = mid;
else l = mid + 1;
}

int best = r - 1;
ll res = 0, cnt = 0;
for (int i = 0; i < n; i++) {
if (a[i] < best) continue;
ll t = (a[i] - best) / b[i] + 1;
cnt += t;
res += t * a[i] - t * (t - 1) / 2 * b[i];
}
res -= (cnt - k) * best;

std::cout << res << "\n";
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
std::cin >> T;
while (T--) solve();
return 0;
}
]]>
+ 搜索

无论是深搜还是宽搜,都逃不掉图的思维。我们将搜索图建立起来之后,剩余的编码过程就会跃然纸上。

【dfs】机器人的运动范围

https://www.acwing.com/problem/content/22/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Solution {
public:
int res = 0;

int movingCount(int threshold, int rows, int cols)
{
if (!rows || !cols) return 0;
vector<vector<int>> g(rows, vector<int>(cols, 0));
vector<vector<bool>> vis(rows, vector<bool>(cols, false));
dfs(g, vis, 0, 0, threshold);
return res;
}

void dfs(vector<vector<int>>& g, vector<vector<bool>>& vis, int x, int y, int threshold)
{
vis[x][y] = true;
res ++;

int dx[] = {-1, 0, 1, 0}, dy[] = {0, 1, 0, -1};
for (int k = 0; k < 4; k ++)
{
int i = x + dx[k], j = y + dy[k];
if (i < 0 || i >= int(g.size()) || j < 0 || j >= int(g[0].size()) || vis[i][j] || cnt(i, j) > threshold) continue;
dfs(g, vis, i, j, threshold);
}
}

int cnt(int x, int y)
{
int sum = 0;
while (x) sum += x % 10, x /= 10;
while (y) sum += y % 10, y /= 10;
return sum;
}
};

【dfs】CCC单词搜索

https://www.acwing.com/problem/content/5168/

搜索逻辑:分为正十字与斜十字

更新答案逻辑:需要进行两个条件的约数,一个是是否匹配到了最后一个字母,一个是转弯次数不超过一次

转弯判断逻辑:

首先不能是起点开始的
对于正十字:如果next的行 & 列都与pre的行和列不相等,就算转弯
对于斜十字:如果next的行 | 列有和pre相等的,就算转弯

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
#include <iostream>
#include <algorithm>
#include <cstring>

using namespace std;

const int N = 110;

string s;
int m, n;
char g[N][N];
int res;

// 正十字,a,b为之前的位置,x,y为当前的位置,now为当前待匹配的字母位,cnt为转弯次数
void dfs1(int a, int b, int x, int y, int now, int cnt)
{
if (g[x][y] != s[now]) return;

if (now == s.size() - 1)
{
if (cnt <= 1) res++;
return;
}

int dx[] = {-1, 0, 1, 0}, dy[] = {0, 1, 0, -1};

for (int k = 0; k < 4; k ++)
{
int i = x + dx[k], j = y + dy[k];
if (x < 0 || x >= m || y < 0 || y >= n) continue;

// 判断是否转弯(now不是起点 且 pre和next行列均不相等)
if (a != -1 && b != -1 && a != i && b != j) dfs1(x, y, i, j, now + 1, cnt + 1);
else dfs1(x, y, i, j, now + 1, cnt);
}
}

// 斜十字
void dfs2(int a, int b, int x, int y, int now, int cnt)
{
if (g[x][y] != s[now]) return;

if (now == s.size() - 1)
{
if (cnt <= 1) res++;
return;
}

int dx[] = {-1, -1, 1, 1}, dy[] = {-1, 1, 1, -1};

for (int k = 0; k < 4; k ++)
{
int i = x + dx[k], j = y + dy[k];
if (x < 0 || x >= m || y < 0 || y >= n) continue;

// 判断是否转弯(now不是起点 且 不在同一对角线)
if (a != -1 && b != -1 && (a == i || b == j)) dfs2(x, y, i, j, now + 1, cnt + 1);
else dfs2(x, y, i, j, now + 1, cnt);
}
}


int main()
{
cin >> s;
cin >> m >> n;

for (int i = 0; i < m; i ++)
for (int j = 0; j < n; j ++)
cin >> g[i][j];

for (int i = 0; i < m; i ++)
for (int j = 0; j < n; j ++)
dfs1(-1, -1, i, j, 0, 0);

for (int i = 0; i < m; i ++)
for (int j = 0; j < n; j ++)
dfs2(-1, -1, i, j, 0, 0);

cout << res << "\n";

return 0;
}

【dfs/二进制枚举】数量

https://www.acwing.com/problem/content/5150/

题意:给定一个数n,问[1, n]中有多少个数只含有4或7

思路一:dfs

  • 对于一个数,我们可以构造一个二叉搜数进行搜索,因为每一位只有两种可能,那么从最高位开始搜索。如果当前数超过了n就return,否则就算一个答案
  • 时间复杂度:Θ(21+lg(max(a[i])))\Theta(2^{1 + \lg{(\max(a[i])})})

思路二:二进制枚举

  • 按照数位进行计算。对于一个 x 位的数,1 到 x-1 位的情况下所有的数都符合条件,对于一个 t 位的数,满情况就是 2t2^t 种,所以 [1,x-1] 位就一共有 21+22++2x1=2x22^1 + 2^2 + \cdots + 2^{x - 1} = 2^{x} - 2 种情况 。对于第 x 位,采取二进制枚举与原数进行比较,如果小于原数,则答案 +1,反之结束循环输出答案即可

dfs 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
using namespace std;

#define int long long

int n, res;

void dfs(int x) {
if (x > n) return;

res ++;

dfs(x * 10 + 4);
dfs(x * 10 + 7);
}

signed main() {
cin >> n;
dfs(4);
dfs(7);
cout << res << "\n";
return 0;
}

二进制枚举代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <iostream>
using namespace std;

int WS(int x) {
int res = 0;
while (x) {
res++;
x /= 10;
}
return res;
}

int calc(int a[], int ws) {
int res = 0;
for (int i = ws - 1; i >= 0; i --) {
res = res * 10 + a[i];
}
return res;
}

int main() {
int n;
cin >> n;

int ws = WS(n);

int ans = (1 << ws) - 2;

int a[20] {};
for (int i = 0; i < (1 << ws); i ++) {
for (int j = 0; j < ws; j ++) {
if ((1 << j) & i) {
a[j] = 7;
} else {
a[j] = 4;
}
}
if (calc(a, ws) <= n) {
ans ++;
} else {
break;
}
}

cout << ans;

return 0;
}

【dfs】组合总和

https://leetcode.cn/problems/combination-sum/

题意:给定一个序列,其中的元素没有重复,问如何选取其中的元素,使得选出的数字总和为指定的数字target,选取的数字可以重复

思路:思路比较简答,很容易想到用dfs搜索出所有的组合情况,即对于每一个“结点”,我们直接遍历序列中的元素即可。但是由于题目的限制,即不允许合法的序列经过排序后相等。那么为了解决这个约束,我们可以将最终搜索到的序列排序后进行去重,但是这样的时间复杂度会很高,于是我们从搜索的过程切入。观看这一篇题解 防止出现重复序列的启蒙题解,我们提取其中最关键的一个图解

subset_sum_i_pruning.png

可见3,4和4,3的剩余选项(其中可能包含了答案序列)全部重复,因此我们直接减去这个枝即可。不难发现,我们根据上述优化思想,剪枝的操作可以为:让当前序列开始枚举的下标 idx 从上一层开始的下标 i 开始,于是剪枝就可以实现了。

时间复杂度:Θ(2nlogn)\Theta \left ( 2^{\frac{n}{\log n}}\right)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
// 答案数组res,目标数组c,目标总和target,答案数组now,当前总和sum,起始下标idx
void dfs(vector<vector<int>>& res, vector<int>& c, int target, vector<int>& now, int sum, int idx) {
if (sum > target) {
return;
} else if (sum == target) {
res.emplace_back(now);
return;
}
for (int i = idx; i < c.size(); i++) {
now.emplace_back(c[i]);
dfs(res, c, target, now, sum + c[i], i);
now.pop_back();
}
}

vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
vector<vector<int>> res;
vector<int> now;
dfs(res, candidates, target, now, 0, 0);
return res;
}
};

【递归】扩展字符串

https://www.acwing.com/problem/content/5284/

题意:给定一种字符串的构造方式,问构造n次以后的字符串中的第k个字符是什么

思路:由于构造的方法是基于上一种情况的,很容易可以想到一个递归搜索树来解决。只是这道题有好几个坑,故记录一下。

  • 首先说一下搜索的思路:对于当前的状态,我们想要知道第k个位置上的字符,很显然我们可以通过预处理每一种构造状态下的字符串长度得到下一个字符串的长度,于是我们可以在当前的字符串中,通过比对下标与五段字符串长度的大小,来确定是继续递归还是直接输出
  • 特判:可以发现,对于 n=0n=0 的情况,我们无法采用相同的结构进行计算,故进行特判,如果当前来到了最初始的字符串状态,我们直接输出相应位置上的字符即可
  • 最后说一下递归终点的设计:与搜索所有的答案情况不同,这道题的答案是唯一的,因此我们在搜索到答案后,可以通过一个 bool 变量作为一个标记,表示已经找到答案了,只需要不断回溯直到回溯结束为止,就不需要再遍历其他的分支了
  • **坑:**这道题的坑说实话有点难崩。
    1. 首先是一个k的大小,是一定要开 long long 的,我一开始直接全局宏定义 intlong long
    2. 还有一个坑可能是只要我才会犯的,就是字符串按照下标输出字符的时候,是需要 -1 的,闹心的是我有的加了,有的没加,还是debug的时候调出来的
    3. 最后一个大坑,属于是引以为戒了。就是这句 len[i] = min(len[i], (int)2e18),因为我们可以发现,抛开那三个固定长度的字符串来说,每一次新构造出来的字符串长度都是上一个字符串长度 22 倍,那么构造 nn 次后的字符串长度就是 s0s_0 长度的 2n2^n 倍,那么对于 nn 的取值范围来说,直接存储长度肯定是不可取的。那么如何解决这个问题呢?方法是我们对 len[i] 进行一个约束即可,见代码。最后进行递归比较长度就没问题了。
  • 时间复杂度:O(n)O(n) - 由于每一个构造的状态我们都是常数级别的比较,因此相当于一个状态的搜索时间复杂度为 O(1)O(1),那么总合就是 O(n)O(n)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
#include <bits/stdc++.h>
using namespace std;

#define int long long

int n, k;
string s = "DKER EPH VOS GOLNJ ER RKH HNG OI RKH UOPMGB CPH VOS FSQVB DLMM VOS QETH SQB";
string t1 = "DKER EPH VOS GOLNJ UKLMH QHNGLNJ A";
string t2 = "AB CPH VOS FSQVB DLMM VOS QHNG A";
string t3 = "AB";

// 记录每一层构造出来的字符串 Si 的长度 len,当前递归的层数 i (i>=1),对于当前层数需要查询的字符的下标 pos
void dfs(vector<int>& len, int i, int pos, bool& ok) {
// 已经搜到答案了就不断返回
if (ok) {
return;
}

// 如果还没有搜到答案,并且已经递归到了最开始的一层,就输出原始字符串相应位置的字符即可
if (!i) {
cout << s[pos - 1];
return;
}

int l1 = t1.size(), l2 = l1 + len[i - 1], l3 = l2 + t2.size(), l4 = l3 + len[i - 1];
if (pos <= l1) {
cout << t1[pos - 1];
ok = true;
return;
} else if (pos <= l2) {
dfs(len, i - 1, pos - l1, ok);
} else if (pos <= l3) {
cout << t2[pos - l2 - 1];
ok = true;
return;
} else if (pos <= l4) {
dfs(len, i - 1, pos - l3, ok);
} else {
cout << t3[pos - l4 - 1];
ok = true;
return;
}
}

void solve() {
cin >> n >> k;

vector<int> len(n + 10);
len[0] = s.size();

for (int i = 1; i <= n; i++) {
len[i] = 2 * len[i - 1] + t1.size() + t2.size() + t3.size();
len[i] = min(len[i], (int)2e18); // 点睛之笔...
}

// 特判下标越界的情况
if (k > len[n]) {
cout << ".";
return;
}

// 否则开始从第n层开始递归搜索
bool ok = false;
dfs(len, n, k, ok);
}

signed main() {
int T = 1;
cin >> T;
while (T--) {
solve();
}
return 0;
}

【dfs】让我们异或吧

https://www.luogu.com.cn/problem/P2420

题意:给定一棵树,树上每一条边都有一个权值,现在有Q次询问,对于每次询问会给出两个结点编号u,v,需要输出结点u到结点v所经过的路径的所有边权的异或之和

思路:对于每次询问,我们当然可以遍历从根到两个结点的所有边权,然后全部异或计算结果,但是时间复杂度是 O(n)O(n),显然不行,那么有什么优化策略吗?答案是有的。我们可以发现,对于两个结点之间的所有边权,其实就是根到两个结点的边权相异或得到的结果(异或的性质),我们只需要预处理出根结点到所有结点的边权已异或值,后续询问的时候直接 O(1)O(1) 计算即可

时间复杂度:Θ(n+q)\Theta(n+q)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
const int N = 100010;

struct node {
int id;
int w;
};

int n, m, f[N]; // f[i] 表示从根结点到 i 号结点的所有边权的异或值
vector<node> G[N];
bool vis[N];

void dfs(int fa) {
if (!vis[fa]) {
vis[fa] = true;
for (auto& ch: G[fa]) {
f[ch.id] = f[fa] ^ ch.w;
dfs(ch.id);
}
}
}

void solve() {
cin >> n;

for (int i = 0; i < n - 1; i++) {
int a, b, w;
cin >> a >> b >> w;
G[a].push_back({b, w});
G[b].push_back({a, w});
}

dfs(1);

cin >> m;

while (m--) {
int u, v;
cin >> u >> v;
cout << (f[u] ^ f[v]) << "\n";
}
}

【记忆化搜索】Function

https://www.luogu.com.cn/problem/P1464

题意:

思路一:直接dfs

  • 直接按照题意进行dfs代码的编写,但是很显然时间复杂极高
  • 时间复杂度:O(T×情况数)O(T \times \text{情况数})

思路二:记忆化dfs

  • 记忆化逻辑:
    • 如果当前的状态没有记忆过,就记忆一下
    • 如果当前的状态已经记忆过了,就不需要继续递归搜索了,直接使用之前已经记忆过的答案即可
  • 上述起始状态需要和搜到答案的状态做一个区别。我们知道,对于一组合法的输入,答案一定是
  • 注意点:
    • 输入终止条件不是 a != -1 && b != -1 && c != -1,而是要三者都不是 -1 才行
    • 对于每一组输入,我们不需要 memset 记忆数组,因为每一组的记忆依赖是相同的
    • 由于答案一定是 >0>0 的,因此是否记忆过只需要看当前状态的答案是否 >0>0 即可
  • 时间复杂度:<O(T×n3)<O(T \times n^3)

直接dfs代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

ll dfs(int a, int b, int c) {
if (a <= 0 || b <= 0 || c <= 0) return 1;
else if (a > 20 || b > 20 || c > 20) return dfs(20, 20, 20);
else if (a < b && b < c) return dfs(a, b, c - 1) + dfs(a, b - 1, c - 1) - dfs(a, b - 1, c);
else return dfs(a - 1, b, c) + dfs(a - 1, b - 1, c) + dfs(a - 1, b, c - 1) - dfs(a - 1, b - 1, c - 1);
}

void solve() {
int a, b, c;
cin >> a >> b >> c;
while (a != -1 && b != -1 && c != -1) {
printf("w(%d, %d, %d) = %lld\n", a, b, c, dfs(a, b, c));
cin >> a >> b >> c;
}
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

记忆化dfs代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 25;

ll f[N][N][N];

ll dfs(ll a, ll b, ll c) {
// 上下界
if (a <= 0 || b <= 0 || c <= 0) return 1;
else if (a > 20 || b > 20 || c > 20) return dfs(20, 20, 20);

if (f[a][b][c]) {
// 已经记忆化过了,直接返回当前状态的解
return f[a][b][c];
}
else {
// 没有记忆化过,就递归计算并且记忆化
if (a < b && b < c) return f[a][b][c] = dfs(a, b, c - 1) + dfs(a, b - 1, c - 1) - dfs(a, b - 1, c);
else return f[a][b][c] = dfs(a - 1, b, c) + dfs(a - 1, b - 1, c) + dfs(a - 1, b, c - 1) - dfs(a - 1, b - 1, c - 1);
}
}

void solve() {
ll a, b, c;
cin >> a >> b >> c;
while (!(a == -1 && b == -1 && c == -1)) {
printf("w(%lld, %lld, %lld) = %lld\n", a, b, c, dfs(a, b, c));
cin >> a >> b >> c;
}
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【递归】外星密码

https://www.luogu.com.cn/problem/P1928

线性递归

题意:给定一个压缩后的密码串,需要解压为原来的形式。压缩形式距离

  • AC[3FUN] \to ACFUNFUNFUN
  • AB[2[2GH]]OP \to ABGHGHGHGHOP

思路:

  • 我们采用递归的策略

  • 我们知道,对于每一个字符,一共有4种情况,分别是:“字母”、“数字”、“[”、“]”。如果是字母。我们分情况考虑

    • “字母”:
      1. 直接加入答案字符串即可
    • “[”:
      1. 获取左括号后面的整体 - 采用递归策略获取后面的整体
      2. 加入答案字符串
    • “数字”:
      1. 获取完整的数 - 循环小trick
      2. 获取数字后面的整体 - 采用递归策略获取后面的整体
      3. 加入答案字符串 - 循环尾加入即可
      4. 返回当前的答案字符串
    • “]”:
      1. 返回当前的答案字符串 - 与上述 “[” 对应
  • 代码设计分析:

    • 我们将压缩后的字符串看成由下面两种单元组成:

      1. 最外层中括号组成的单元:如 [2[2AB]] 就算一个最外层中括号组成的单元
      2. 连续的字母单元:如 OPQ 就算一个连续的字母单元
    • 解决各单元连接问题:

    • 为了在递归处理完第一种单元后还能继续处理后续的第二种单元,我们直接按照压缩字符串的长度进行遍历,即 while (i < s.size()) 操作

    • 解决两种单元内部问题:

      • 最外层中括号组成的单元:递归处理
      • 连续的字母单元:直接加入当前答案字符串即可
  • 手玩样例:

    手玩样例

    • 显然按照定义,上述压缩字符串一共有五个单元
    • 我们用红色表示进入递归,蓝色表示驱动递归结束并回溯。可以发现
  • 时间复杂度:Θ(res.length())\Theta(\text{res.length()})

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

string s;
int i;

string dfs() {
string res;

while (i < s.size()) {
if (s[i] >= 'A' && s[i] <= 'Z') {
while (s[i] >= 'A' && s[i] <= 'Z') {
res += s[i++];
}
}
if (s[i] == '[') {
i++;
res += dfs();
}
if (isdigit(s[i])) {
int cnt = 0;
while (isdigit(s[i])) {
cnt = cnt * 10 + s[i] - '0';
i++;
}
string t = dfs();
while (cnt--) {
res += t;
}
return res;
}
if (s[i] == ']') {
i++;
return res;
}
}

return res;
}

void solve() {
cin >> s;
cout << dfs() << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs+剪枝/二进制枚举】选数

https://www.luogu.com.cn/problem/P1036

题意:给定n个数,从中选出k个数,问一共有多少种方案可以使得选出来的k个数之和为质数

思路一:dfs+剪枝

  • 按照数据量可以直接暴搜,搜索依据是每一个数有两种状态,即选和不选,于是搜索树就是一棵二叉树
  • 搜索状态定义为:对于当前第idx个数,已经选择了cnt个数,已经选择的数之和为sum
  • 搜索终止条件为:idx越界
  • 剪枝:已经选择了k个数就直接返回,不用再选剩下的数了
  • 时间复杂度:O(2n)O(2^n) - 剪枝后一定是小于这个复杂度的

思路二:二进制枚举

  • 直接枚举 02n10\to 2^n-1,按照其中含有的 11 的个数,来进行选数判断
  • 时间复杂度:O(2n)O(2^n) - 一定会跑满的

dfs+剪枝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 30;

int n, k, a[N];
int res;

bool isPrime(int x) {
if (x < 2) return false;
for (int i = 2; i <= x / i; i++)
if (x % i == 0)
return false;
return true;
}

/**
* @param cnt 当前已经选择的数的数量
* @param idx 当前数的下标
* @param sum 当前选数状态下的总和
*/
void dfs(int cnt, int idx, int sum) {
if (idx > n) return;

if (cnt == k) {
if (isPrime(sum)) res++;
return;
}

dfs(cnt, idx + 1, sum);
dfs(cnt + 1, idx + 1, sum + a[idx + 1]);
}

void solve() {
cin >> n >> k;
for (int i = 1; i <= n; i++)
cin >> a[i];

dfs(0, 1, 0); // 不选第一个数
dfs(1, 1, a[1]); // 选第一个数

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

二进制枚举

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 30;

int n, k, a[N];
int res;

bool isPrime(int x) {
if (x < 2) return false;
for (int i = 2; i <= x / i; i++)
if (x % i == 0)
return false;
return true;
}

void solve() {
cin >> n >> k;
for (int i = 1; i <= n; i++)
cin >> a[i];

for (int i = 0; i < (1 << n); i++) {
int cnt = 0, sum = 0;
for (int j = 0; j < n; j++)
if (i & (1 << j))
cnt++, sum += a[j + 1];

if (cnt != k) continue;

if (isPrime(sum)) res++;
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs/bfs】01迷宫

https://www.luogu.com.cn/problem/P1141

题意:给定一个01矩阵,行走规则为“可以走到相邻的数字不同的位置”,现在给定m次询问 (u,v),输出从 (u,v) 开始最多可以走多少个位置?

思路:我们可以将此问题转化为一个求解连通块的问题。对于矩阵中的一个连通块,我们定义为:在其中任意一个位置开始行走,都可以走过整个连通块每一个位置。那么在询问时,只需要输出所在连通块元素的个数即可。现在将问题转化为了

  1. 如何遍历每一个连通块?按照标记数组的情况,如果一个位置没有被标记,就从这个位置出发开始打标记并统计

  2. 如何统计每一个连通块中元素的个数?按照题目中给定的迷宫行走规则,可以通过bfs或者dfs实现遍历

bfs代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#include <bits/stdc++.h>
using namespace std;

const int N = 1010;

int n, m, res[N][N];
char g[N][N];
bool vis[N][N];

void bfs(int u, int v) {
queue<pair<int, int>> q;
int cnt = 0; // 当前“连通块”的大小
vector<pair<int, int>> a;

q.push({u, v});
a.push_back({u, v});
vis[u][v] = true;
cnt++;

int dx[4] = {-1, 1, 0, 0}, dy[4] = {0, 0, 1, -1};

while (q.size()) {
auto& now = q.front();
q.pop();

for (int i = 0; i < 4; i++) {
int x = dx[i] + now.first, y = dy[i] + now.second;
if (x >= 1 && x <= n && y >= 1 && y <= n && !vis[x][y] && g[x][y] != g[now.first][now.second]) {
q.push({x, y});
a.push_back({x, y});
vis[x][y] = true;
cnt++;
}
}
}

for (auto& loc: a) {
res[loc.first][loc.second] = cnt;
}
}

void solve() {
cin >> n >> m;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
cin >> g[i][j];

for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (!vis[i][j])
bfs(i, j);

while (m--) {
int a, b;
cin >> a >> b;
if (vis[a][b]) {
cout << res[a][b] << "\n";
} else {
cout << 1 << "\n";
}
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

dfs代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <bits/stdc++.h>
using namespace std;

const int N = 1010;

int n, m, res[N][N];
char g[N][N];
bool vis[N][N];

// 当前点的坐标 (u, v),当前连通块的元素个数cnt,当前连通块的元素存到 a 数组
void dfs(int u, int v, int& cnt, vector<pair<int, int>>& a) {
cnt++;
a.push_back({u, v});
vis[u][v] = true;

int dx[4] = {0, 0, 1, -1}, dy[4] = {1, -1, 0, 0};

for (int k = 0; k < 4; k++) {
int x = u + dx[k], y = v + dy[k];
if (x >= 1 && x <= n && y >= 1 && y <= n && !vis[x][y] && g[x][y] != g[u][v]) {
dfs(x, y, cnt, a);
}
}
}

void solve() {
cin >> n >> m;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
cin >> g[i][j];

for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (!vis[i][j]) {
int cnt = 0;
vector<pair<int, int>> a;
dfs(i, j, cnt, a);
for (auto& loc: a) {
res[loc.first][loc.second] = cnt;
}
}

while (m--) {
int a, b;
cin >> a >> b;
if (vis[a][b]) {
cout << res[a][b] << "\n";
} else {
cout << 1 << "\n";
}
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs/二进制枚举】kkksc03考前临时抱佛脚

https://www.luogu.com.cn/problem/P2392

题意:给定四组数据,每组数据由 n 个数字组成,对于每一组数字需要分为两组使得两组之和相差尽可能小,问最终四组数据分组后,每一组之和的最大值之和是多少

  • 思路一:二进制枚举

    可以发现,我们可以只设计处理一组数据的算法,其余组数的数据调用该算法即可。对于一个序列,想要将其划分为 2 组使得 2 组之和的差值最小,我们可以发现,对于序列中的一个数而言,有两个状态,要么分到第一组,要么就分到第二组,因此我们可以采用二进制枚举的方式,将所有的分组情况全部枚举出来,每一个状态计算一下和的差值,最后取最小差值时,两组中和的最大值即可

    时间复杂度:O(4×2n)O(4 \times 2^n)

  • 思路二:dfs

    从上面的二进制枚举得到启发,一定可以进行二叉树搜索。很显然就直接左结点让当前数分到左组,右结点让当前数分到右组即可。本题无法剪枝,因为两组之和的差值没有规律

    时间复杂度:O(4×2n)O(4 \times 2^n)

二进制枚举代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 25;

int a[N], res;

void fun(int n) {
int MIN = 20 * 60;
for (int i = 0; i <= (1 << n); i++) {
int l = 0, r = 0;
for (int j = 0; j < n; j++) {
if (i & (1 << j)) r += a[j];
else l += a[j];
}
MIN = min(MIN, max(l, r));
}
res += MIN;
}

void solve() {
int x[4] {};
for (int i = 0; i < 4; i++) cin >> x[i];

for (int i = 0; i < 4; i++) {
for (int j = 0; j < x[i]; j++) cin >> a[j];
fun(x[i]);
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

dfs代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 25;

int a[N], res;
int MIN; // 每一组数据划分为 2 组后每一组的最大和值

// 第 idx 个数分到某一组时左组之和 ls,右组之和 rs
void dfs(int idx, bool isRight, int ls, int rs, int n) {
if (idx == n) {
MIN = min(MIN, max(ls, rs));
return;
}

if (ls > MIN || rs > MIN) {
// 剪枝:当前左组之和 or 右组之和比最大和值都大
return;
}

if (isRight) rs += a[idx];
else ls += a[idx];

dfs(idx + 1, false, ls, rs, n);
dfs(idx + 1, true , ls, rs, n);
}

void solve() {
int x[4] {};
for (int i = 0; i < 4; i++) cin >> x[i];

for (int i = 0; i < 4; i++) {
for (int j = 0; j < x[i]; j++) cin >> a[j];

MIN = 20 * 60;
dfs(0, false, 0, 0, x[i]);
dfs(0, true , 0, 0, x[i]);
res += MIN;
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs/二进制枚举】PERKET

https://www.luogu.com.cn/problem/P2036

题意:给定一个二元组序列,每一个二元组中,一个表示酸度,一个表示苦度,现在在至少需要选择一个二元组的情况下,希望酸度之积与苦度之和的差值最小

  • 思路一:二进制枚举

    很显然的一个二进制枚举。每一个二元组都只有两个状态,即被选择 or 不被选择,故可采用二进制枚举,对于每一个状态统计酸度之积与苦度之和即可

    时间复杂度:O(2n)O(2^n)

  • 思路二:dfs

    按照上述二进制枚举的思路进行模拟即可,本题无法剪枝,因为酸度与苦度之差没有规律

    时间复杂度:O(2n)O(2^n)

二进制枚举代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 15;

int n;
int res = 1e9;

struct {
int l, r;
} a[N];

void solve() {
cin >> n;
for (int i = 0; i < n; i++) cin >> a[i].l >> a[i].r;

for (int i = 0; i <= (1 << n); i++) {
// 特判没有调料的情况
int cnt = 0;
for (int j = 0; j < n; j++)
if (i & (1 << j))
cnt++;

if (!cnt) continue;

// 计算两个味道的差值
int ls = 1, rs = 0;
for (int j = 0; j < n; j++)
if (i & (1 << j))
ls *= a[j].l, rs += a[j].r;

res = min(res, abs(ls - rs));
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

dfs代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 15;

int n;
int res = 1e9;

struct {
int l, r;
} a[N];

// 当前决策元素的下标idx,是否被选择choose,酸度之积ls,苦度之和rs
void dfs(int idx, bool choose, int ls, int rs) {
if (idx == n) {
if (ls == 1 && rs == 0) return;
res = min(res, abs(ls - rs));
return;
}

if (choose) {
ls *= a[idx].l;
rs += a[idx].r;
}

dfs(idx + 1, false, ls, rs);
dfs(idx + 1, true , ls, rs);
}

void solve() {
cin >> n;
for (int i = 0; i < n; i++) cin >> a[i].l >> a[i].r;

dfs(0, false, 1, 0);
dfs(0, true , 1, 0);

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs】迷宫

https://www.luogu.com.cn/problem/P1605

题意:矩阵寻路,有障碍物,每一个点只能走一次,找到起点到终点可达路径数

思路:其实就是一个四叉树的题目,只需要进行四个方向的遍历搜索即可找到路径,由于会进行:越界、障碍物、以及不可重复遍历的剪枝,故遍历的数量会很少

时间复杂度:<<O(4nm)<< O(4^{nm})

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 10;

int n, m, k;
int aa, bb, cc, dd;
int g[N][N], vis[N][N];
int res;

int dx[] = {-1, 1, 0, 0}, dy[] = {0, 0, 1, -1};

void dfs(int x, int y) {
if (x == cc && y == dd) {
res++;
return;
}

for (int k = 0; k < 4; k++) {
int xx = x + dx[k], yy = y + dy[k];
if (xx > 0 && xx <= n && yy > 0 && yy <= m && !g[xx][yy] && !vis[xx][yy]) {
vis[x][y] = true;
dfs(xx, yy);
vis[x][y] = false;
}
}
}

void solve() {
cin >> n >> m >> k;
cin >> aa >> bb >> cc >> dd;

while (k--) {
int x, y;
cin >> x >> y;
g[x][y] = 1;
}

dfs(aa, bb);

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs】单词方阵

https://www.luogu.com.cn/problem/P1101

  • 题意:给定一个字符串方阵,问对于其中的 8 个方向,是否存在一个指定的字符串
  • 思路:很显然的暴力枚举,只不过采用 dfs 进行优化,我们可以发现搜索的逻辑非常的简单,只需要在约束方向的情况下每次遍历八个方向即可。本题的关键在于如何快速正确的编码。对于八个角度判断是否与原始方向一致,我们采用增量的思路,只需要通过传递父结点的位置坐标即可唯一确定,因为两点确定一条了一条射线,方向也就确定了。其次就是如何构造答案矩阵,思路与两点确定射线的逻辑类似,我们在抵达搜索的终点时,只需要通过当前点的坐标与父结点的坐标唯一确定来的路径的方向,进行构造即可
  • 时间复杂度:难以计算,但是 dfs 一定可行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 110;

int n;
char g[N][N], res[N][N], s[10] = "yizhong";

// oa,ob 父结点坐标,a,b 当前结点坐标,idx 当前匹配的位置
void dfs(int oa, int ob, int a, int b, int idx) {
if (s[idx] != g[a][b]) return;

if (idx == 6) {
int i = a, j = b;
for (int t = 6; t >= 0; t--) {
res[i][j] = s[t];
i -= a - oa;
j -= b - ob;
}
return;
}

int dx[] = {1, -1, 0, 0, 1, -1, -1, 1}, dy[] = {0, 0, 1, -1, 1, 1, -1, -1};

for (int k = 0; k < 8; k++) {
int x = a + dx[k], y = b + dy[k];
if (x < 1 || x > n || y < 1 || y > n) continue;

if ((oa == -1 && ob == -1) || (x - a == a - oa && y - b == b - ob))
dfs(a, b, x, y, idx + 1);
}
}

void solve() {
cin >> n;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
cin >> g[i][j], res[i][j] = '*';

for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
dfs(-1, -1, i, j, 0);

for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++)
cout << res[i][j];
cout << "\n";
}
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs】自然数的拆分问题

https://www.luogu.com.cn/problem/P2404

  • 题意:给定一个自然数 n,问有多少种拆分方法能将该数拆分为一定数量的升序自然数之和
  • 思路:树形搜索的例题。我们采用递增凑数的思路,对于每一个结点值,我们从其父结点的值开始深度搜索,从而确保了和数递增。递归终点就是和值为自然数 n 的值。剪枝就是和值超过了自然数 n 的值
  • 时间复杂度:<<O(nn)<<O(n^n)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <bits/stdc++.h>
#define int long long
using namespace std;

int n;
vector<int> res;

// now 表示当前结点的值,sum 表示根到当前结点的路径之和
void dfs(int now, int sum) {
if (sum > n) return;

if (sum == n) {
for (int i = 0; i < res.size(); i++)
cout << res[i] << "+\n"[i == res.size() - 1];
return;
}

for (int i = now; i < n; i++) {
res.push_back(i);
dfs(i, sum + i);
res.pop_back();
}
}

void solve() {
cin >> n;

for (int i = 1; i < n; i++) {
res.push_back(i);
dfs(i, i);
res.pop_back();
}
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs】Lake Counting S

https://www.luogu.com.cn/problem/P1596

  • 题意:给定一个矩阵,计算其中连通块的数量
  • 思路:直接逐个元素遍历即可,对于每一个元素,我们采用 dfs 或者 bfs 的方式进行打标签从而将整个连通块都标记出来即可
  • 时间复杂度:O(nm)O(nm)

dfs 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 110;

int n, m;
char g[N][N];
bool vis[N][N];
int res, dx[] = {1, -1, 0, 0, 1, 1, -1, -1}, dy[] = {0, 0, 1, -1, 1, -1, 1, -1};

void dfs(int i, int j) {
if (i < 1 || i > n || j < 1 || j > m || vis[i][j] || g[i][j] != 'W') return;

vis[i][j] = true;

for (int k = 0; k < 8; k++) {
int x = i + dx[k], y = j + dy[k];
dfs(x, y);
}
}

void solve() {
cin >> n >> m;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
cin >> g[i][j];

for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
if (!vis[i][j] && g[i][j] == 'W')
dfs(i, j), res++;

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

bfs 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 110;

int n, m;
char g[N][N];
bool vis[N][N];
int res, dx[] = {1, -1, 0, 0, 1, 1, -1, -1}, dy[] = {0, 0, 1, -1, 1, -1, 1, -1};

void bfs(int i, int j) {
queue<pair<int, int>> q;

q.push({i, j});
vis[i][j] = true;

while (q.size()) {
auto h = q.front();
q.pop();

int x = h.first, y = h.second;
for (int k = 0; k < 8; k++) {
int xx = x + dx[k], yy = y + dy[k];
if (!vis[xx][yy] && g[xx][yy] == 'W') {
q.push({xx, yy});
vis[xx][yy] = true;
}
}
}
}

void solve() {
cin >> n >> m;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
cin >> g[i][j];

for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
if (!vis[i][j] && g[i][j] == 'W')
bfs(i, j), res++;

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs】填涂颜色

https://www.luogu.com.cn/problem/P1162

  • 题意:给定一个方阵,其中只有 0 和 1,其中的 1 将部分的 0 围成了一个圈,现在需要将被围住的 0 转为 2 后,将转化后的方阵输出
  • 思路:题意很简单,思路也很显然,我们要做的就是区分开圈内与圈外的 0,如何区分呢?我们采用搜索打标记的方式将外圈的 0 全部打标记之后,遇到的没有打标记的 0 显然就是圈内的了。为了满足所有情况下圈内的 0,我们从方阵的四条边进行探测式打标签即可
  • 时间复杂度:O(n2)O(n^2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 35;

int n, g[N][N];
int dx[] = {0, 0, 1, -1}, dy[] = {1, -1, 0, 0};
bool vis[N][N];

void dfs(int i, int j) {
if (i < 1 || i > n || j < 1 || j > n || g[i][j] == 1 || vis[i][j]) return;

vis[i][j] = true;

for (int k = 0; k < 4; k++) {
int x = i + dx[k], y = j + dy[k];
dfs(x, y);
}
}

void solve() {
cin >> n;

for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
cin >> g[i][j];

for (int i = 1; i <= n; i++) if (g[1][i] == 0 && !vis[1][i]) dfs(1, i);
for (int i = 1; i <= n; i++) if (g[i][n] == 0 && !vis[i][n]) dfs(i, n);
for (int i = 1; i <= n; i++) if (g[n][i] == 0 && !vis[n][i]) dfs(n, i);
for (int i = 1; i <= n; i++) if (g[i][1] == 0 && !vis[i][1]) dfs(i, 1);

for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (vis[i][j]) cout << 0 << " \n"[j == n];
else if (g[i][j] == 0) cout << 2 << " \n"[j == n];
else cout << g[i][j] << " \n"[j == n];
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【bfs】马的遍历

https://www.luogu.com.cn/problem/P1443

  • 题意:给定一个矩阵棋盘范围与一个马的位置坐标,现在问棋盘中每一个点的最小到达距离是多少
  • 思路:很显然的一个 bfs 宽搜模型,我们只需要从该颗马的起始位置开始宽搜,对于每一个第一个搜索到的此前没有到达的点,计算此时到起点的步长距离就是最小到达距离
  • 时间复杂度:O(n×m)O(n \times m)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 410;

struct Idx {
int x, y;
};

int n, m, x, y;
int d[N][N];
int dx[] = {1, 1, -1, -1, 2, 2, -2, -2}, dy[] = {2, -2, 2, -2, 1, -1, 1, -1};

void bfs() {
queue<Idx> q;
q.push({x, y});
d[x][y] = 0;

while (q.size()) {
auto h = q.front();
q.pop();

for (int k = 0; k < 8; k++) {
int i = h.x + dx[k], j = h.y + dy[k];
if (i < 1 || i > n || j < 1 || j > m || d[i][j] != -1) continue;
d[i][j] = d[h.x][h.y] + 1;
q.push({i, j});
}
}
}

void solve() {
cin >> n >> m >> x >> y;

for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
d[i][j] = -1;

bfs();

for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
cout << d[i][j] << " \n"[j == m];
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【bfs】奇怪的电梯

https://www.luogu.com.cn/problem/P1135

  • 题意:给定一个电梯,第 i 层只能上升或者下降 a[i] 层,问从起点开始到终点最少需要乘坐几次电梯
  • 思路:很显然的一个宽搜,关键在于需要对打标记避免重复访问结点造成死循环
  • 时间复杂度:O(n)O(n)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 210;

int n, x, y, a[N];
int d[N]; // d[i] 表示起点到第 i 层楼的最小操作次数
bool vis[N];

void bfs() {
queue<int> q;

q.push(x);
d[x] = 0;
vis[x] = true;

while (q.size()) {
int now = q.front();
q.pop();

if (now == y) break;

int high = now + a[now], low = now - a[now];

if (!vis[high] && high >= 1 && high <= n) {
q.push(high);
d[high] = d[now] + 1;
vis[high] = true;
}
if (!vis[low] && low >= 1 && low <= n) {
q.push(low);
d[low] = d[now] + 1;
vis[low] = true;
}
}
}

void solve() {
cin >> n >> x >> y;
for (int i = 1; i <= n; i++) {
cin >> a[i];
d[i] = -1;
}

bfs();

cout << d[y] << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs/状压dp】吃奶酪

题意:给定一个平面直角坐标系与 n 个点的坐标,起点在坐标原点。问如何选择行进路线使得到达每一个点且总路程最短

  • 思路一:爆搜。题中的 n15n \le 15 直接无脑爆搜,但是 TLE。爆搜的思路为:每次选择其中的一个点,接下来选择剩余的没有被选择过的点继续搜索,知道所有的点全部都搜到为止

    时间复杂度:O(n!)O(n!)

  • 思路二:状态压缩 DP。

    时间复杂度:O()O()

爆搜代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 20;

int n;
double res = 4000.0;
bool vis[N];

struct Idx {
double x, y;
} a[N];

double d(Idx be, Idx en) {
return sqrt((be.x - en.x) * (be.x - en.x) + (be.y - en.y) * (be.y - en.y));
}

// 父结点坐标 fa,当前结点次序 now,当前路径长度 len
void dfs(Idx fa, int now, double len) {
vis[now] = true;

if (count(vis + 1, vis + n + 1, true) == n) {
res = min(res, len);
}

for (int i = 1; i <= n; i++)
if (!vis[i])
dfs(a[now], i, len + d(a[now], a[i]));

vis[now] = false;
}

void solve() {
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i].x >> a[i].y;
}

for (int i = 1; i <= n; i++) {
Idx fa = {0, 0};
dfs(fa, i, d(fa, a[i]));
}

cout << fixed << setprecision(2) << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

状压 dp 代码

1

【dfs】递归实现指数型枚举

https://www.acwing.com/problem/content/94/

  • 题意:给定 n 个数,从其中选择 0-n 个数,将所有的选择方案打印出来,每一个方案中数字按照升序排列
  • 思路:很显然的一个二叉树问题。每一个数只有两种状态,选择 or 不选择,于是可以采用二进制枚举 or 二叉树 dfs 的方法进行。为了满足升序,二进制枚举时从低位到高位判断是否为 1 即可;搜索时从低位开始搜索,通过一个动态数组存储搜索路径上的数字即可
  • 时间复杂度:O(2n)O(2^n)

二进制枚举

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <bits/stdc++.h>
#define int long long
using namespace std;

int n;

void solve() {
cin >> n;

for (int i = 0; i < (1 << n); i++) {
for (int j = 0; j < n; j++) {
if (i & (1 << j)) {
cout << j + 1 << ' ';
}
}
cout << "\n";
}
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

dfs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <bits/stdc++.h>
#define int long long
using namespace std;

int n;
vector<int> a;

void dfs(int now, bool cho) {
if (now == n) {
for (auto& x: a) {
cout << x << ' ';
}
cout << "\n";
return;
}

dfs(now + 1, false);

a.push_back(now + 1);
dfs(now + 1, true);
a.pop_back();
}

void solve() {
cin >> n;

dfs(1, false);

a.push_back(1);
dfs(1, true);
a.pop_back();
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs】递归实现排列型枚举

https://www.acwing.com/problem/content/96/

  • 题意:按照字典序升序,打印 n 个数的所有全排列情况
  • 思路:从第一位开始枚举每一位可以选择的数,显然每一位可选择数的数量逐渐减少,直到只有一种选择结束搜索
  • 时间复杂度:O(n!)O(n!)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 10;

int n;
bool vis[N];
vector<int> a;

// 当前数位 now
void dfs(int now) {
if (now > n) {
for (auto& x: a) {
cout << x << ' ';
}
cout << "\n";
return;
}

for (int i = 1; i <= n; i++) {
if (!vis[i]) {
a.push_back(i);
vis[i] = true;
dfs(now + 1);
a.pop_back();
vis[i] = false;
}
}
}

void solve() {
cin >> n;
dfs(1);
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs/树形dp】树的直径

树的路径问题,考虑回溯。如何更新值?如何返回值?

参考:https://www.bilibili.com/video/BV17o4y187h1/

【dfs】在带权树网络中统计可连接服务器对数目

https://leetcode.cn/problems/count-pairs-of-connectable-servers-in-a-weighted-tree-network/description/

题意:给定一棵带权无根树,定义「中转结点」为以当前结点为根,能够寻找到两个不同分支下的结点,使得这两个结点到当前结点的简单路径长度可以被给定值 k 整除,问树中每一个结点的「中转结点」的有效对数是多少

思路:dfs+乘法原理。

  • 由于数据量是 n=103n=10^3,可以直接枚举每一个顶点,并且每一个顶点的操作可以是 O(n)O(n) 的,我们考虑遍历。对于每一个结点,对应的有效对数取决于每一个子树中的简单路径长度合法的结点数,通过深搜统计即可
  • 统计出每一个子树的合法结点后还需要进行答案的计算,也就是有效对数的统计。对于每一个合法结点,都可以和非当前子树上的所有结点结合形成一个合法有效对,直接这样统计会导致结果重复计算一次,因此需要答案除以二。当然也可以利用乘法原理,一边统计每一个子树中有效的结点数,一边和已统计过的有效结点数进行计算

时间复杂度:O(n2)O(n^2)

注:总结本题根本原因是提升对建图的理解以及针对 vector 用法的总结

  • 关于建图
    • 一开始编码时,我设置了结点访问状态数组 vector<bool> vis 和每一个结点到当前根结点的距离数组 vector<int> d,但其实都可以规避,因为本题是「树」形图,可以通过在深搜时同时传递父结点来规避掉 vis 数组的使用
    • 同时由于只需要在遍历时计算路径是否合法从而计数,因此不需要存储每一个结点到当前根结点的路径值,可以通过再增加一个搜索状态参数来规避掉 d 数组的使用
  • 关于 vector
    • 一开始使用了全局 vis 数组,因此每次都需要进行清空操作。我使用了 .clear() 方法试图重新初始化数组,但这导致了状态的错误记录,可能是 LeetCode 平台 C++ 语言特有的坑,还是少用全局变量
    • .clear() 方法会导致 .size() 为 0,但是仍然可以通过 [] 方法获得合法范围内的元素值,这是 vector 内存分配优化的结果
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Solution {
public:
vector<int> countPairsOfConnectableServers(vector<vector<int>>& edges, int signalSpeed) {
int n = edges.size() + 1;

struct node { int to, w; };
vector<vector<node>> g(n, vector<node>());
for (auto e: edges) {
int u = e[0], v = e[1], w = e[2];
g[u].push_back({v, w});
g[v].push_back({u, w});
}

function<int(int, int, int)> dfs = [&](int fa, int now, int d) {
int res = d % signalSpeed == 0;
for (auto ch: g[now]) {
if (ch.to != fa) {
res += dfs(now, ch.to, d + ch.w);
}
}
return res;
};

vector<int> res(n);
for (int i = 0; i < n; i++) {
int sum = 0;
for (auto ch: g[i]) {
int cnt = dfs(i, ch.to, ch.w);
res[i] += cnt * sum;
sum += cnt;
}
}

return res;
}
};
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution:
def countPairsOfConnectableServers(self, edges: List[List[int]], signalSpeed: int) -> List[int]:
n = len(edges) + 1

g = [[] for _ in range(n)]
for u, v, w in edges:
g[u].append((v, w))
g[v].append((u, w))

def dfs(fa: int, now: int, d: int) -> int:
ret = d % signalSpeed == 0
for ch in g[now]:
if ch[0] != fa:
ret += dfs(now, ch[0], d + ch[1])
return ret

res = [0] * n
for i in range(n):
sum = 0
for ch in g[i]:
cnt = dfs(i, ch[0], ch[1])
res[i] += sum * cnt
sum += cnt

return res

【dfs】将石头分散到网格图的最少移动次数

https://leetcode.cn/problems/minimum-moves-to-spread-stones-over-grid/

标签:搜索、全排列、库函数

题意:给定一个 3×33\times 3 的矩阵 gg,其中数字总和为 9 且 g[i][j]0g[i][j] \ge 0,现在需要将其中 >1>1 的数字逐个移动到值为 0 的位置上使得最终矩阵全为 1,问最少移动长度是多少。

思路一:手写全排列

  • 思路:可以将这道题抽象为求解「将 a 个大于 1 位置的数分配到 b 个 0 位置」的方案中的最小代价问题。容易联想到全排列选数的母问题:数字的位数对应这里的 b 个 0 位置,每个位置可以填的数对应这里的用哪个 a 来填。区别在于:0 的位置顺序不是固定的,用哪个 a 来填的顺序也不是固定的。这与全排列数中:被填的位置顺序是固定的,用哪个数来填不是固定的,有所区别。因此我们可以全排列枚举每一个位置,在此基础之上再全排列枚举每一个位置上可选的 a 进行填充。可以理解为全排列的嵌套。那么最终递归树的深度就是 0 的个数,递归时再用一个参数记录每一个选数分支对应的代价即可。
  • 时间复杂度:O(9×9!)O(9\times 9!)

思路二:库函数全排列

  • 思路:由于方阵的总和为 9,因此 >1 的位置上减去 1 剩下的数值之和一定等于方阵中 0 的个数。因此我们可以将前者展开为和 0 相同大小的向量,并全排列枚举任意一者进行两者的匹配计算,维护其中的最小代价即是答案。
    • C++ 的全排列枚举库函数为 std::next_permutation(ItFirst, ItEnd)
    • Python 的全排列枚举库函数为 itertools.permutations(Iterable)
  • 时间复杂度:O(9×9!)O(9\times 9!)
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class Solution {
public:
int minimumMoves(vector<vector<int>>& g) {
vector<pair<int, int>> z, a;
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
if (!g[i][j]) {
z.push_back({i, j});
} else if (g[i][j] > 1) {
a.push_back({i, j});
}
}
}

int res = INT_MAX, n = z.size();
vector<bool> vis(n);

auto dfs = [&](auto&& dfs, int dep, int t) -> void {
if (dep == n) {
res = min(res, t);
return;
}

for (int i = 0; i < n; i++) {
if (vis[i]) continue;
vis[i] = true;
for (auto& [x, y]: a) {
if (g[x][y] <= 1) continue;
g[x][y]--;
dfs(dfs, dep + 1, t + abs(z[i].first - x) + abs(z[i].second - y));
g[x][y]++;
}
vis[i] = false;
}
};

dfs(dfs, 0, 0);

return res;
}
};
[C++库函数]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Solution {
public:
int minimumMoves(vector<vector<int>>& g) {
vector<pair<int, int>> z, a;
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
if (!g[i][j]) {
z.push_back({i, j});
} else {
while (g[i][j] > 1) {
a.push_back({i, j});
g[i][j]--;
}
}
}
}

int res = INT_MAX;
do {
int t = 0;
for (int i = 0; i < z.size(); i++) {
t += abs(a[i].first - z[i].first) + abs(a[i].second - z[i].second);
}
res = min(res, t);
} while (next_permutation(a.begin(), a.end()));

return res;
}
};
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution:
def minimumMoves(self, g: List[List[int]]) -> int:
a, z = [], []
for i in range(3):
for j in range(3):
if not g[i][j]:
z.append((i, j))
elif g[i][j] > 1:
a.append((i, j))

res = 1000
n = len(z)
vis = [False] * n

def dfs(dep: int, t: int) -> None:
nonlocal res
if dep == n:
res = min(res, t)
return
for i in range(n):
if vis[i]: continue
vis[i] = True
for x, y in a:
if g[x][y] <= 1: continue
g[x][y] -= 1
dfs(dep + 1, t + abs(z[i][0] - x) + abs(z[i][1] - y))
g[x][y] += 1
vis[i] = False

dfs(0, 0)

return res
[Python库函数]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution:
def minimumMoves(self, g: List[List[int]]) -> int:
from itertools import permutations
a, z = [], []
for i in range(3):
for j in range(3):
if not g[i][j]:
z.append((i, j))
while g[i][j] > 1:
a.append((i, j))
g[i][j] -= 1

res, n = 1000, len(a)
for p in permutations(a):
t = 0
for i in range(n):
t += abs(p[i][0] - z[i][0]) + abs(p[i][1] - z[i][1])
res = min(res, t)

return res
]]>
@@ -1625,11 +1625,11 @@ - data-structure - - /Algorithm/data-structure/ + divide-and-conquer + + /Algorithm/divide-and-conquer/ - 数据结构

数据结构由 数据结构 两部分组成。我们主要讨论的是后者,即结构部分。

按照 逻辑结构 可以将其区分为 线性结构非线性结构

线性数据结构 与 非线性数据结构

按照 物理结构 可以将其区分为 连续结构分散结构

连续空间存储 与 分散空间存储

【模板】双链表

https://www.acwing.com/problem/content/829/

思路:用两个空结点作为起始状态的边界,避免所有边界讨论。

时间复杂度:插入、删除结点均为 O(1)O(1),遍历为 O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

template<class T>
class myList {
private:
int idx;
std::vector<T> val;
std::vector<int> left, right;

public:
myList(const int n) {
idx = 2;
val.resize(n + 10);
left.resize(n + 10);
right.resize(n + 10);
left[1] = 0, right[0] = 1;
}

void push_back(T x) {
insert_left(1, x);
}

void push_front(T x) {
insert_right(0, x);
}

void insert_left(int k, T x) {
insert_right(left[k], x);
}

void insert_right(int k, T x) {
val[idx] = x;
right[idx] = right[k];
left[right[k]] = idx;
left[idx] = k;
right[k] = idx++;
}

void erase(int k) {
right[left[k]] = right[k];
left[right[k]] = left[k];
}

void output() {
for (int i = right[0]; i != 1; i = right[i]) {
cout << val[i] << " \n"[i == 1];
}
}
};

void solve() {
int n;
cin >> n;

myList<int> ls(n);

while (n--) {
string op;
cin >> op;

int k, x;

if (op == "L") {
cin >> x;
ls.push_front(x);
} else if (op == "R") {
cin >> x;
ls.push_back(x);
} else if (op == "D") {
cin >> k;
ls.erase(k + 1);
} else if (op == "IL") {
cin >> k >> x;
ls.insert_left(k + 1, x);
} else {
cin >> k >> x;
ls.insert_right(k + 1, x);
}
}

ls.output();
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
// std::cin >> T;
while (T--) solve();
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
import heapq
from collections import defaultdict
from typing import List, Tuple
import math
from itertools import combinations

II = lambda: int(input())
FI = lambda: float(input())
MII = lambda: tuple(map(int, input().split()))
LII = lambda: list(map(int, input().split()))


class myList:
def __init__(self, n: int) -> None:
self.val = [0] * (n + 10)
self.left = [0] * (n + 10)
self.right = [0] * (n + 10)
self.idx = 2
self.right[0] = 1
self.left[1] = 0

def push_front(self, x: int):
self.insert_right(0, x)

def push_back(self, x: int):
self.insert_left(1, x)

def insert_left(self, k: int, x: int):
self.insert_right(self.left[k], x)

def insert_right(self, k: int, x: int):
self.val[self.idx] = x
self.right[self.idx] = self.right[k]
self.left[self.right[k]] = self.idx
self.left[self.idx] = k
self.right[k] = self.idx
self.idx += 1

def erase(self, k: int):
self.left[self.right[k]] = self.left[k]
self.right[self.left[k]] = self.right[k]

def output(self) -> None:
i = self.right[0]
while i != 1:
print(self.val[i], end=' ')
i = self.right[i]


def solve() -> None:
n = II()

ls = myList(n)

for _ in range(n):
op = input().split()

if op[0] == 'L':
ls.push_front(int(op[-1]))
elif op[0] == 'R':
ls.push_back(int(op[-1]))
elif op[0] == 'D':
ls.erase(int(op[-1]) + 1)
elif op[0] == 'IL':
ls.insert_left(int(op[1]) + 1, int(op[-1]))
else:
ls.insert_right(int(op[1]) + 1, int(op[-1]))

ls.output()


if __name__ == '__main__':
T = 1
# T = II()
while T: solve(); T -= 1

【模板】单调栈

https://www.acwing.com/problem/content/832/

题意:对于一个序列中的每一个元素,寻找每一个元素左侧最近的比其小的元素。

思路一:暴力枚举

  • 显然对于每一个元素 nums[i],我们可以枚举倒序 [0, i-1] 直到找到第一个 nums[j] < nums[i]
  • 时间复杂度:O(n2)O(n^2)

思路二:单调栈

  • 可以发现时间开销主要在倒序枚举上,我们能少枚举一些元素吗?答案是可以的。我们定义「寻找 [i-1, 0] 中比当前元素小的第一个元素」的行为叫做「寻找合法对象」。显然我们在枚举每一个元素时都需要 查询维护 这样的合法对象线性序列,可以理解为记忆化从而加速查询。那么如何高效查询和维护这样的线性序列呢?不妨考虑每一个合法对象对曾经的合法对象的影响:

    • 若当前元素 nums[i] 可以成为后续 [i+1, n-1] 元素的合法对象。则从 i-1 开始一直往左,只要比当前大的元素,都不可能成为 [i+1, n-1] 的合法对象,肯定都被 nums[i] “拦住了”。那么在「合法对象序列」中插入当前合法对象之前,需要不断尾弹出比当前大的元素
    • 若当前元素 nums[i] 不能成为后续 [i+1, n-1] 元素的合法对象。表明当前元素过大,此时就不用将当前元素插入「合法对象序列」
  • 经过上述两个角度的讨论,很容易发现这样维护出来的的合法序列是严格单调递增的。于是,在查询操作时仅需要进行尾比较与尾弹出即可,在维护操作时,仅需要尾插入即可。满足这样的线性数据结构有很多,如栈、队列、数组、链表,我们就使用栈来演示,与标题遥相呼应

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

void solve() {
int n;
cin >> n;

vector<int> a(n);
for (int i = 0; i < n; i++) {
cin >> a[i];
}

stack<int> s;
for (int i = 0; i < n; i++) {
// 查询
while (s.size() && s.top() >= a[i]) {
s.pop();
}
cout << (s.size() ? s.top() : -1) << " ";

// 维护
s.push(a[i]);
}
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
// std::cin >> T;
while (T--) solve();
return 0;
}

相似题:

下一个更大元素 II

【模板】单调队列

标签:双端队列、滑动窗口、单调队列

题意:给定一个含有 n 个元素的序列,求解其中每个长度为 k 的子数组中的最值。

思路:显然我们可以 O(nk)O(nk) 暴力求解,有没有什么方法可以将「求解子数组中最值」的时间开销从 O(k)O(k) 降低为 O(1)O(1) 呢?有的!我们重新定义一个队列就好了。为了做到线性时间复杂度的优化,我们对队列做以下自定义,以「求解子数组最小值」为例:

  1. 插入元素到队尾:此时和单调栈的逻辑类似。如果当前元素可以作为当前子数组或后续子数组的最小值,则需要从当前队尾开始依次弹出比当前元素严格大的元素,最后再将当前元素入队。注意:当遇到和当前元素值相等的元素时不能出队,因为每一个元素都会经历入队和出队的操作,一旦此时出队了,后续进行出队判定时会提前弹出本不应该出队的与其等值的元素。
  2. 弹出队头元素:如果队头元素和子数组左端点 nums[i-k] 的元素值相等,则弹出。
  3. 获得队头元素:O(1)O(1) 的获取队头元素,即队列中的最小值。

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

template<class T>
struct minQueue {
std::deque<T> q;
void pushBack(T x) {
while (q.size() && x < q.back()) {
q.pop_back();
}
q.push_back(x);
}
void popFront(T x) {
if (q.size() && q.front() == x) {
q.pop_front();
}
}
T getMinValue() {
return q.front();
}
};

template<class T>
struct maxQueue {
std::deque<T> q;
void pushBack(T x) {
while (q.size() && x > q.back()) {
q.pop_back();
}
q.push_back(x);
}
void popFront(T x) {
if (q.size() && q.front() == x) {
q.pop_front();
}
}
T getMaxValue() {
return q.front();
}
};

void solve() {
int n, k;
cin >> n >> k;

vector<int> nums(n);
for (int i = 0; i < n; i++) {
cin >> nums[i];
}

minQueue<int> minq;
for (int i = 0; i < n; i++) {
minq.pushBack(nums[i]);
if (i >= k) {
minq.popFront(nums[i - k]);
}
if (i >= k - 1) {
cout << minq.getMinValue() << " \n"[i == n - 1];
}
}

maxQueue<int> maxq;
for (int i = 0; i < n; i++) {
maxq.pushBack(nums[i]);
if (i >= k) {
maxq.popFront(nums[i - k]);
}
if (i >= k - 1) {
cout << maxq.getMaxValue() << " \n"[i == n - 1];
}
}
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
// std::cin >> T;
while (T--) solve();
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
from collections import defaultdict, deque
from typing import List, Tuple
from itertools import combinations, permutations
import math, heapq, queue

II = lambda: int(input())
FI = lambda: float(input())
MII = lambda: tuple(map(int, input().split()))
LII = lambda: list(map(int, input().split()))


def solve() -> None:
n, k = MII()
nums = LII()

qa, qb = deque(), deque()
ra, rb = [], []
for i in range(n):
# push back
while len(qa) and nums[i] < qa[-1]:
qa.pop()
qa.append(nums[i])
while len(qb) and nums[i] > qb[-1]:
qb.pop()
qb.append(nums[i])
if i >= k:
# pop front
if len(qa) and qa[0] == nums[i - k]:
qa.popleft()
if len(qb) and qb[0] == nums[i - k]:
qb.popleft()
if i >= k - 1:
# get ans
ra.append(qa[0])
rb.append(qb[0])

print(' '.join(map(str, ra)))
print(' '.join(map(str, rb)))


if __name__ == '__main__':
T = 1
# T = II()
while T: solve(); T -= 1

【模板】最近公共祖先

https://www.luogu.com.cn/problem/P3379

题意:寻找树中指定两个结点的最近公共祖先 (Lowest Common Ancestor, 简称 LCA)\text{(Lowest Common Ancestor, 简称 LCA)}

思路:对于每次查询,我们可以从指定的两个结点开始往上跳,第一个公共结点就是目标的 LCA,每一次询问的时间复杂度均为 O(n)O(n),为了加速查询,我们可以采用倍增法,预处理出往上跳的结果,即 fa[i][j] 数组,表示 ii 号点向上跳 2j2^j 步后到达的结点。接下来在往上跳跃的过程中,利用二进制拼凑的思路,即可在 O(logn)O(\log n) 的时间内查询到 LCA。

预处理:可以发现,对于 fa[i][j],我们可以通过递推的方式获得,即 fa[i][j] = fa[fa[i][j-1]][j-1],当前结点向上跳跃 2j2^j 步可以拆分为先向上 2j12^{j-1} 步, 在此基础之上再向上 2j12^{j-1} 步.于是我们可以采用宽搜 oror 深搜的顺序维护 fafa 数组。

跳跃:我们首先需要将两个结点按照倍增的思路向上跳到同一个深度,接下来两个结点同时按照倍增的思路向上跳跃,为了确保求出最近的,我们需要确保在跳跃的步调一致的情况下,两者的祖先始终不相同,那么倍增结束后,两者的父结点就是最近公共祖先,即 fa[x][k]fa[y][k]

时间复杂度:Θ(nlogn+mlogn)\Theta(n \log n + m \log n)

  • nlognn \log n 为预处理每一个结点向上跳跃抵达的情况
  • mlognm \log nmm 次询问的情况
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
const int N = 5e5 + 10;

int n, Q, root;
vector<int> G[N];
int fa[N][20], dep[N];
queue<int> q;

void init() {
dep[root] = 1;
q.push(root);

while (q.size()) {
int now = q.front();
q.pop();
for (int ch: G[now]) {
if (!dep[ch]) {
dep[ch] = dep[now] + 1;
fa[ch][0] = now;
for (int k = 1; k <= 19; k++) {
fa[ch][k] = fa[ fa[ch][k-1] ][k-1];
}
q.push(ch);
}
}
}
}

int lca(int a, int b) {
if (dep[a] < dep[b]) swap(a, b);

// 二进制拼凑从而跳到一样高
for (int k = 19; k >= 0; k--)
if (dep[fa[a][k]] >= dep[b])
a = fa[a][k];

if (a == b) return a;

for (int k = 19; k >= 0; k--)
if (fa[a][k] != fa[b][k])
a = fa[a][k], b = fa[b][k];

return fa[a][0];
}

void solve() {
cin >> n >> Q >> root;
for (int i = 0; i < n - 1; ++i) {
int a, b;
cin >> a >> b;
G[a].push_back(b);
G[b].push_back(a);
}

init();

while (Q--) {
int a, b;
cin >> a >> b;
cout << lca(a, b) << "\n";
}
}

【栈】验证栈序列

https://www.luogu.com.cn/problem/P4387

  • 题意:给定入栈序列与出栈序列,问出栈序列是否合法

  • 思路:思路很简单,就是对于当前出栈的数,和入栈序列中最后已出栈的数之间,如果还有数没有出,那么就是不合法的出栈序列,反之合法。这是从入栈的结果来看的,如果这么判断就需要扫描入栈序列 n 次,时间复杂度为 O(n2)O(n^2)。我们按照入栈的顺序来看,对于当前待入栈的数,若与出栈序列的队头不等,则成功入栈等待后续出栈;若与出栈序列相等,则匹配成功直接出栈无需入栈,同时对已入栈的数与出栈序列队头不断匹配直到不相等。最后判断待入栈的数与出栈序列是否全部匹配掉了,如果全部匹配掉了说明该出栈序列合法,反之不合法

    抽象总结上述思路:为了判断出栈序列是否合法,我们不妨思考:对于每一个出栈的数,出栈的时机是什么?可以发现出栈的时机无非两种:

    • 一入栈就出栈(对应于枚举待入栈序列时发现待入栈的数与出栈序列队头相等)
    • 紧跟着刚出栈的数继续出栈(对应于枚举待入栈序列时发现待入栈的数与出栈序列队头相等之后,继续判断出栈序列队头与已入栈的数是否相等,若相等则不断判断并出栈)
  • 时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// #include <bits/stdc++.h>
// #define int long long
#include <iostream>
#include <unordered_map>
#include <stack>
#include <queue>
using namespace std;

void solve() {
int n;
cin >> n;

vector<int> a(n), b(n);
for (int i = 0; i < n; i++) cin >> a[i];
for (int i = 0; i < n; i++) cin >> b[i];

stack<int> stk;
int i = 0, j = 0;
while (i < n) {
if (a[i] != b[j]) stk.push(a[i++]);
else {
i++, j++;
while (!stk.empty() && b[j] == stk.top()) {
stk.pop();
j++;
}
}
}

cout << (stk.empty() ? "Yes" : "No") << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
cin >> T;
while (T--) solve();
return 0;
}

二叉搜索树 & 平衡树

如果我们想要在 O(logn)O(\log n) 时间复杂度内对数据进行增删查改的操作,就可以引入 二叉搜索树 (Binary Search Tree) 这一数据结构。然而,在某些极端的情况下,例如当插入的数据是单调不减或不增时,这棵树就会退化为一条链从而导致所有的增删查改操作退化到 O(n)O(n),这是我们不愿意看到的。因此我们引入 平衡二叉搜索树 (Balanced Binary Search Tree) 这一数据结构。

关于平衡二叉搜索树,有非常多的变种与实现,不同的应用场景会选择不同的平衡树类型。Treap 更灵活,通过随机化优先级实现预期的平衡,但在最坏情况下可能退化。AVL 树 严格保持平衡,保证了 O(logn)O(\log n) 的性能,但在频繁插入和删除的场景下可能有较大的旋转开销。红黑树 通过较宽松的平衡条件实现了较好的插入和删除性能,通常被广泛用于需要高效插入删除操作的系统(如 STL 中的 mapset)。一般来说,红黑树是一个较为通用的选择,而在需要严格平衡性时,AVL 树可能是更好的选择。

Treap

Treap 是二叉搜索树和堆的结合体。它通过维护两种性质来保持平衡:

  • 二叉搜索树性质:每个节点的左子树的所有节点值小于该节点的值,右子树的所有节点值大于该节点的值。
  • 堆性质:每个节点的优先级(通常随机生成)要大于或等于其子节点的优先级。

平衡机制

  • Treap 使用随机化优先级使得树的形状接近于理想的平衡树(期望树高为 O(logn)O(\log n))。
  • 通过旋转操作(左旋和右旋)在插入和删除时保持堆的性质。

优点

  • 实现相对简单。
  • 由于随机化的优先级,在期望情况下,树的高度是 O(logn)O(\log n)
  • 灵活性高,可以根据需要调整优先级函数。

缺点

  • 最坏情况下,树的高度可能退化为 O(n)O(n)(例如所有优先级相同或顺序生成的优先级),尽管发生概率很低。

AVL 树

AVL 树是最早发明的自平衡二叉搜索树之一,1962 年由 Adelson-Velsky 和 Landis 发明。

  • 平衡因子:每个节点的左右子树高度差不能超过 11,且需要记录每个节点的高度。

平衡机制

  • 插入或删除节点后,如果某个节点的平衡因子不再为 1-10011,就需要通过旋转(单旋转或双旋转)来恢复平衡。
  • 旋转操作包括:左旋转、右旋转、左右双旋转和右左双旋转。

优点

  • 严格的平衡条件保证了树的高度始终为 O(logn)O(\log n),因此搜索、插入和删除操作的时间复杂度为 O(logn)O(\log n)

缺点

  • 由于平衡条件严格,每次插入和删除后可能需要较多的旋转操作,从而导致实现较复杂,插入和删除操作的常数时间开销较大。

红黑树

红黑树是一种较为宽松的自平衡二叉搜索树,由 Rudolf Bayer 于 1972 年发明。

  • 颜色属性:每个节点都有红色或黑色两种颜色,通过这些颜色约束树的平衡性。

平衡机制

  • 通过遵循红黑树的五个性质来保持平衡:
    1. 每个节点要么是红色,要么是黑色。
    2. 根节点是黑色。
    3. 叶子节点(NIL 节点)是黑色。
    4. 如果一个节点是红色的,那么它的子节点必须是黑色(红节点不能连续出现)。
    5. 从任一节点到其每个叶子节点的所有路径都包含相同数量的黑色节点。
  • 插入和删除操作可能破坏红黑树的性质,需要通过重新着色和旋转来恢复平衡。

优点

  • 红黑树的高度最多是 Θ(2logn)\Theta (2 \log n),因此搜索、插入和删除操作的时间复杂度仍为 O(logn)O(\log n)
  • 由于平衡条件较为宽松,插入和删除操作需要的旋转操作通常比 AVL 树少,效率更高。

缺点

  • 实现较复杂,特别是插入和删除的平衡修复过程。
  • 虽然红黑树的搜索效率与 AVL 树相似,但由于平衡条件较宽松,实际应用中的树高度通常略高于 AVL 树,因此搜索操作的效率稍低。

【二叉树】美国血统

https://www.luogu.com.cn/problem/P1827

题意:给定二叉树的中序和先序序列,输出后序序列

思路:经典二叉树的题目,主要用于巩固加强对于递归的理解。指针其实是没有必要的,为了得到后序序列,我们只需要有一个 dfs 序即可,为了得到 dfs 序,我们只需要根据给出的中序和前序序列即可得到 dfs 序

时间复杂度:O(n)O(n)

指针做法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 30;

string mid, pre;

struct Node {
char data;
Node* le, * ri;
Node(char _data) : data(_data), le(nullptr), ri(nullptr) {}
};

Node* build(int i, int j, int p, int q) {
if (i > j) return nullptr;

Node* root = new Node(pre[i]);

int k; // 根结点在中序序列的下标
for (k = p; k <= q; k++)
if (mid[k] == root->data)
break;

root->le = build(i + 1, k - p + i, p, k - 1);
root->ri = build(k - p + i + 1, j, k + 1, q);

cout << root->data;

return root;
}

void solve() {
cin >> mid >> pre;

int i = 0, j = pre.size() - 1;
int p = 0, q = mid.size() - 1;

build(i, j, p, q);
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

构造出 dfs 序直接输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 30;

string mid, pre;

// 前序起始 i,前序末尾 j,中序起始 p,中序末尾 q
void build(int i, int j, int p, int q) {
if (i > j) return;

char root = pre[i];

int k;
for (k = p; k <= q; k++)
if (mid[k] == root)
break;

build(i + 1, k - p + i, p, k - 1);
build(k - p + i + 1, j, k + 1, q);

cout << root;
}

void solve() {
cin >> mid >> pre;

int i = 0, j = pre.size() - 1;
int p = 0, q = mid.size() - 1;

build(i, j, p, q);
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【二叉树】新二叉树

https://www.luogu.com.cn/problem/P1305

题意:给定一棵二叉树的 n 个结点信息,分别为当前结点的数据信息、左孩子结点信息和右结点信息,输出这棵二叉树的前序序列

思路:我们首先将这棵二叉树构建出来,接着遍历输出前序序列即可。关键在于如何构建二叉树?我们使用数组存储二叉树,对于每一个树上结点,我们将数组中元素的索引存储为树上结点信息,每一个结点再存储左孩子与右孩子的信息

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <bits/stdc++.h>
#define int long long
using namespace std;

int n;
string s;
char root;

struct Node {
char l, r;
} tree[200];

void pre(char now) {
if (now == '*') return;
cout << now;
pre(tree[now].l);
pre(tree[now].r);
}

void solve() {
cin >> n;

for (int i = 1; i <= n; i++) {
cin >> s;
if (i == 1) root = s[0];
tree[s[0]].l = s[1];
tree[s[0]].r = s[2];
}

pre(root);
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【二叉树】遍历问题

https://www.luogu.com.cn/problem/P1229

题意:给定一棵二叉树的前序序列与后序序列,问中序序列的可能情况有多少种

思路:我们采用从最小结构单元的思路进行考虑,即假设当前二叉树只有一个根结点与两个叶子结点,而非两棵子树。然后将题意进行等价变换,即问对于已经固定的前序和后序二叉树,该二叉树有多少种不同的形状?对于当前的最小结构二叉树,形状就是 左右根 or 根左右,现在的根可以直接确定,那么就只能从左右孩子进行变形,很显然只能进行左右交换的变形,但是问题是一旦左右变换,前序 or 后序都会变掉,说明这种左右孩子都存在的前后序固定的二叉树是唯一的,那么如何才是不唯一的呢?我们考虑减少孩子数量。假设没有孩子,那么很显然也只有一个形状,就是一个根结点,故排除。于是答案就呼之欲出了,就是当根结点只有一个孩子时,这个孩子无论是在左边还是右边,前后序都是相同的,但是中序序列就不同了,于是就产生了两种中序序列。于是最终的结论是:对于前后序确定的二叉树来说,中序序列的情况是就是 2单分支结点数2^{\text{单分支结点数}} 个。现在的问题就转变为了在给定前后序的二叉树中求解单分支结点个数的问题。

如何寻找单分支结点呢?根据下面的递归图可以发现,无论是左单分支还是右单分支,如果 pre 的连续两个结点与 post 的连续两个结点对称相同,那么就一定有一个单分支结点,故只需要寻找前后序序列中连续两个字符对称相同的情况数 cnt 即可。最终的答案数就是 2cnt2^{cnt}

图例

时间复杂度:O(nm)O(nm)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <bits/stdc++.h>
#define int long long
using namespace std;

string pre, post;

void solve() {
cin >> pre >> post;

int cnt = 0;

for (int i = 0; i < pre.size() - 1; i++)
for (int j = 0; j < post.size(); j++)
if (pre[i] == post[j + 1] && pre[i + 1] == post[j])
cnt++;

cout << (1 << cnt) << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【二叉树】医院设置 🔥

https://www.luogu.com.cn/problem/P1364

题意:给定一棵二叉树,树中每一个结点存储了一个数值表示一个医院的人数,现在需要在所有的结点中将一个结点设置为医院使得其余结点中的所有人到达该医院走的路总和最小。路程为结点到医院的最短路,边权均为 1。给出最终的最短路径总和

思路一:暴力

  • 显然的对于已经设置好医院的局面,需要求解的路径总和就直接将树遍历一边即可。每一个结点都可以作为医院进行枚举,每次遍历是 O(n)O(n)

  • 时间复杂度:O(n2)O(n^2)

思路二:带权树的重心

  • TODO
  • 时间复杂度:O(n)O(n)

暴力代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 110;

int n;

vector<int> G[N];
int cnt[N];

int bfs(int v) {
int res = 0;
vector<bool> vis(n + 1, false);
vector<int> d(n + 1, 0); // d[i] 表示点 i 到点 v 的距离

queue<int> q;
vis[v] = true;
d[v] = 0;
q.push(v);

while (q.size()) {
int now = q.front();
q.pop();

for (auto& ch: G[now]) {
if (!vis[ch]) {
vis[ch] = true;
d[ch] = d[now] + 1;
q.push(ch);

res += cnt[ch] * d[ch];
}
}
}

return res;
}

void solve() {
cin >> n;
for (int i = 1; i <= n; i++) {
int count, l, r;
cin >> count >> l >> r;
cnt[i] = count;

if (l) {
G[i].push_back(l);
G[l].push_back(i);
}

if (r) {
G[i].push_back(r);
G[r].push_back(i);
}
}

int res = 1e7 + 10;

for (int i = 1; i <= n; i++) {
res = min(res, bfs(i));
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

优化代码

1

【二叉树】二叉树深度

https://www.luogu.com.cn/problem/P4913

题意:给定一棵二叉树,求解这棵二叉树的深度

思路:有两个考点,一个是如何根据给定的信息(从根结点开始依次给出已存在树上结点的左右孩子的编号)构建二叉树,一个是如何求解已经构建好的二叉树的深度。对于构建二叉树,我们沿用 T5 数组模拟构建的思路,直接定义结点类型即可;对于求解深度,很显然的一个递归求解,即左右子树深度值 +1 即可

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 1000010;

int n;

struct Node {
int l, r;
} t[N];

int dep(int now) {
if (!now) return 0;
return max(dep(t[now].l), dep(t[now].r)) + 1;
}

void solve() {
cin >> n;
for (int i = 1; i <= n; i++) {
int x, y;
cin >> x >> y;
t[i].l = x, t[i].r = y;
}

cout << dep(1);
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【完全二叉树】淘汰赛

https://www.luogu.com.cn/problem/P1364

题意:给定 2n2^n 支球队的编号与能力值,进行淘汰赛,能力值者晋级下一轮直到赛出冠军。输出亚军编号

思路:很显然的一个完全二叉树的题目。我们都不需要进行递归操作,直接利用完全二叉树的下标性质利用数组模拟循环计算即可。给出的信息就是完全二叉树的全部叶子结点的信息,分别为球队编号 id 与球队能力值 val,我们从第 n-1 个结点开始循环枚举到第 1 个结点计算每一轮的胜者信息,最终输出最后一场的能力值较小者球队编号即可

时间复杂度:Θ(2n)\Theta(2n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 1 << 8;

struct Node {
int id, val;
} a[N];

int n;

void solve() {
cin >> n;

n = 1 << n;

for (int i = n; i <= 2 * n - 1; i++) {
a[i].id = i - n + 1;
cin >> a[i].val;
}

for (int i = n - 1; i >= 1; i--)
if (a[i * 2].val > a[i * 2 + 1].val) a[i] = a[i * 2];
else a[i] = a[i * 2 + 1];

if (a[2].val > a[3].val) cout << a[3].id;
else cout << a[2].id;
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【二叉树/LCA】二叉树问题

https://www.luogu.com.cn/problem/P3884

题意:给定一棵二叉树的结点关系信息,求出这棵二叉树的深度、宽度和两个指定结点之间的最短路径长度

思路:二叉树的构建直接采用有向图的构造方法。深度直接 dfs 即可,宽度直接在 dfs 遍历时哈希深度值即可。问题的关键在于如何求解两个给定结点之间的路径长度,很显然需要求解两个结点的 LCA,由于结点数 100\le 100 故直接采用暴力的方法,可以重定义结点,增加父结点域。也可以通过比对根结点到两个指定结点的路径信息得到 LCA 即最后一个相同的结点编号(本题采用),通过在 dfs 遍历树时存储路径即可得到根结点到两个指定结点的路径信息。之后直接根据题中新定义的路径长度输出即可,即

length=2×(dxdlca)+(dydlca)\text{length} = 2 \times (d_x - d_{lca}) + (d_y - d_{lca})

其中 did_i 表示:根结点到第 ii 号点之间的路径长度,在 dfs 时通过传递深度值维护得到

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 110;

int n, x, y;
vector<int> G[N];
int depth, width;
unordered_map<int, int> ha; // 将所有的深度值进行哈希
int d[N]; // d[i] 表示第 i 个点到根结点的边数
vector<int> temp, rx, ry; // 根结点到 x 号点与 y 号点直接的路径结点编号

// 当前结点编号 now,当前深度 level
void dfs(int now, int level) {
depth = max(depth, level);

temp.push_back(now);
if (now == x) rx = temp;
if (now == y) ry = temp;

ha[level]++;
d[now] = level - 1;

for (auto& ch: G[now]) {
dfs(ch, level + 1);
temp.pop_back();
}
}

// 暴力 lca + 计算路径长度
int len(int x, int y) {
int i = 0;
while (i < rx.size() && i < ry.size() && rx[i] == ry[i]) i++;

int lca = rx[--i];

return 2 * (d[x] - d[lca]) + (d[y] - d[lca]);
}

void solve() {
cin >> n;

for (int i = 1; i <= n - 1; i++) {
int a, b;
cin >> a >> b;
G[a].push_back(b);
}

cin >> x >> y;

// 二叉树的深度 depth
dfs(1, 1);
cout << depth << "\n";

// 二叉树的宽度 width
for (auto& item: ha) width = max(width, item.second);
cout << width << "\n";

// 两个结点之间的路径长度
cout << len(x, y) << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【set】营业额统计

https://www.luogu.com.cn/problem/P2234

题意:给定一个序列 a,需要计算 a1+i=2,1j<inminaiaja_1 + \displaystyle \sum_{i=2,1 \le j <i}^{n} \min {|a_i - a_j|} ,即计算每一个数与序列中当前数之前的数的最小差值之和

思路:很显然的思路,对于每一个数,我们需要对之前的序列在短时间内找到一个数值最接近当前数的数。

  • TLE:一开始的思路是每次对之前的序列进行排序,然后二分查找与当前值匹配的数,为了确保所有的情况都找到,就直接判断二分查到的数,查到的数之前的一个数,之后的一个数,但是时间复杂度极高(我居然没想到),是 O(n2logn)O(n^2 \log n)
  • AC:后来看了题解才知道 set 的正确用法,就是一个 平衡树的 STL。我们对于之前的序列不断的插入平衡树中(默认升序排序),每次利用 s.lower_bound(x) 返回「集合 s 中第一个 \ge 当前数的迭代器」,然后进行判断即可。lower_bound() 的时间复杂度为 O(logn)O(\log n) 。需要注意的是边界的判断,一开始的思路虽然会超时,但是二分后边界的判断很简单,使用 STL 后同样需要考虑边界的情况。分为三种(详情见代码)
    • 当前数比集合中所有的数都大,那么 lower_bound 就会返回 s.end() 答案就是当前数与集合中最后一个数的差值
    • 当前数比集合中所有的数都小,那么 lower_bound 就会返回 s.bigin() 答案就是集合中第一个数与当前数的差值
    • 当前数存在于集合中 or 集合中既有比当前数大的又有比当前数小的,那么就比较查到的数与查到的数前一个数和当前数的差值,取最小的差值即可

时间复杂度:O(nlogn)O(n \log n)

TLE 但逻辑清晰代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <iostream>
#include <algorithm>
using namespace std;

const int N = 1 << 16;

int n, a[N];

void solve() {
cin >> n;

int res = 0;
cin >> a[1];
res += a[1];

for (int i = 2; i <= n; i++) {
// 维护之前序列有序
sort(a + 1, a + i);
cin >> a[i];

// 二分查找目标数
int l = 1, r = i - 1;
while (l < r) {
int mid = (l + r) >> 1;
if (a[mid] < a[i]) l = mid + 1;
else r = mid;
}

// 边界判断
int ans = abs(a[i] - a[r]);
if (r + 1 >= 1 && r + 1 <= i - 1) ans = min(ans, abs(a[i] - a[r + 1]));
if (r - 1 >= 1 && r - 1 <= i - 1) ans = min(ans, abs(a[i] - a[r - 1]));

res += ans;
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

AC 的 set 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <iostream>
#include <algorithm>
#include <set>
using namespace std;

int n, res;
set<int> s;

void solve() {
cin >> n;

int x;
cin >> x;
res += x;
s.insert(x);

while (--n) {
cin >> x;

auto it = s.lower_bound(x);

if (it == s.end()) {
// 没有比当前数大的
res += x - *s.rbegin();
} else if (it == s.begin()) {
// 没有比当前数小的
res += *s.begin() - x;
} else {
// 当前数已存在于集合中 or 既有比当前数大的也有比当前数小的
auto pre = it;
pre--;
res += min(abs(x - *it), abs(x - *pre));
}

s.insert(x);
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【multiset】切蛋糕

https://www.acwing.com/problem/content/description/5581/

题意:给定一个矩形,由左下角和右上角的坐标确定。现在对这个矩形进行切割操作,要么竖着切,要么横着切。现在需要给出每次切割后最大子矩形的面积

思路:STL set。很容易想到,横纵坐标是相互独立的,最大子矩形面积一定产生于最大长度与最大宽度的乘积。因此我们只需要维护一个序列的最大值即可。对于二维可以直接当做一维来看,于是问题就变成了,需要在 logn\log n 的时间复杂度内,对一个序列完成下面三个操作:

  1. 删除一个数
  2. 增加一个数(执行两次)
  3. 获取最大值

如何实现呢?我们需要有序记录每一个子线段的长度,并且子线段的长度可能重复,因此我们用 std::multiset 来存储所有子线段的长度

  1. 使用 M.erase(M.find(value)) 实现:删除一个子线段长度值
  2. 使用 M.insert(value) 实现:增加子线段一个长度值
  3. 使用 *M.rbegin() 实现:获取当前所有子线段长度的最大值

由于给的是切割的位置坐标 x,因此上述操作 1 不能直接实现,我们需要利用给定的切割坐标 x 计算出当前切割位置对应子线段的长度。如何实现呢?我们知道,对于当前切割的坐标 x,对应的子线段的长度取决于当前切割坐标左右两个切割的位置 rp, lp,因此我们只需要存储每一个切割的坐标即可。由于切割位置不会重复,并且需要在 logn\log n 的时间复杂度内查询到,因此我们还是可以使用 std::set 来存储切割位置

时间复杂度:O(nlogn)O(n \log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <iostream>
#include <set>
using namespace std;
using ll = long long;

void work(int x, set<int>& S, multiset<int>& M) {
set<int>::iterator rp = S.upper_bound(x), lp = rp;
lp--;
S.insert(x);

M.erase(M.find(*rp - *lp));
M.insert(*rp - x);
M.insert(x - *lp);
}

void solve() {
int w, h, n;
cin >> w >> h >> n;

set<int> S1, S2;
multiset<int> M1, M2;
S1.insert(0), S1.insert(w), M1.insert(w);
S2.insert(0), S2.insert(h), M2.insert(h);

while (n--) {
char op;
int x;
cin >> op >> x;
if (op == 'X') work(x, S1, M1);
else work(x, S2, M2);

cout << (ll)*M1.rbegin() * *M2.rbegin() << "\n";
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【并查集】Milk Visits S

https://www.luogu.com.cn/problem/P5836

题意:给定一棵树,结点被标记成两种,一种是 H,一种是 G,在每一次查询中,需要知道指定的两个结点之间是否含有某一种标记

思路:对于树上标记,我们可以将相同颜色的分支连成一个连通块

  • 如果查询的两个结点在同一个连通块,则查询两个结点所在的颜色与所需的颜色是否匹配即可
  • 如果查询的两个结点不在同一个连通块,两个结点之间的路径一定是覆盖了两种颜色的标记,则答案一定是 1

时间复杂度:Θ(n+m)\Theta(n+m)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
const int N = 100010;

int n, m, p[N];
char col[N];

int find(int x) {
if (p[x] != x) {
p[x] = find(p[x]);
}
return p[x];
}

void solve() {
cin >> n >> m;
cin >> (col + 1);

for (int i = 1; i <= n; i++) {
p[i] = i;
}

for (int i = 1; i <= n - 1; i++) {
int a, b;
cin >> a >> b;
if (col[a] == col[b]) {
p[find(a)] = find(b);
}
}

string res;

while (m--) {
int u, v;
cin >> u >> v;

char cow;
cin >> cow;

if (find(u) == find(v)) {
res += to_string(col[u] == cow);
} else {
res += '1';
}
}

cout << res << "\n";
}

【并查集】尽量减少恶意软件的传播

https://leetcode.cn/problems/minimize-malware-spread/description/

题意:给定一个由邻接矩阵存储的无向图,其中某些结点具备感染能力,可以感染相连的所有结点,问消除哪一个结点的感染能力可以使得最终不被感染的结点数量尽可能少,给出消除的有感染能力的最小结点编号

思路:很显然我们可以将当前无向图的多个连通分量,共三种感染情况:

  1. 如果一个连通分量中含有 2\ge 2 个感染结点,则当前连通分量一定会被全部感染;

  2. 如果一个连通块中含有 00 个感染结点,则无需任何操作;

  3. 如果一个连通块中含有 11 个感染结点,则最佳实践就是移除该连通块中唯一的那个感染结点。

当然了,由于只能移走一个感染结点,我们需要从所有只含有 11 个感染结点的连通块中移走连通块结点最多的那个感染结点。因此我们需要统计每一个连通分量中感染结点的数量以及总结点数,采用并查集进行统计。需要注意的是题目中的“索引最小”指的是结点编号最小而非结点在序列 initialinitial 中的下标最小。算法流程见代码。

时间复杂度:O(n2)O(n^2)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
class Solution {
public:
int p[310];

int Find(int x) {
if (x != p[x]) p[x] = Find(p[x]);
return p[x];
}

int minMalwareSpread(vector<vector<int>>& graph, vector<int>& initial) {
// 1. 维护并查集数组:p[]
int n = graph.size();
for (int i = 0; i < n; i++) p[i] = i;
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
if (graph[i][j])
p[Find(i)] = Find(j);

// 2. 维护哈希表:每一个连通块中的感染结点数、总结点数
unordered_map<int, pair<int, int>> ha;
for (auto& x: initial) ha[Find(x)].first++;
for (int i = 0; i < n; i++) ha[Find(i)].second++;

// 3. 排序:按照感染结点数升序,总结点数降序
vector<pair<int, int>> v;
for (auto& it: ha) v.push_back(it.second);
sort(v.begin(), v.end(), [&](pair<int, int>& x, pair<int, int>& y){
if (x.first == y.first) return x.second > y.second;
return x.first < y.first;
});

// 4. 寻找符合条件的连通块属性:找到序列中第一个含有 1 个感染结点的连通块祖宗结点编号 idx
int idx = -1;
for (int i = 0; i < v.size(); i++) {
if (v[i].first == 1) {
idx = i;
break;
}
}

// 5. 返回答案:比对感染结点所在的连通块属性与目标连通块属性
if (idx == -1) {
// 特判没有连通块只含有 1 个感染结点的情况
return *min_element(initial.begin(), initial.end());
}

int res = n + 10;
for (auto& x: initial) {
int px = Find(x);
if (ha[px].first == v[idx].first && ha[px].second == v[idx].second) {
res = min(res, x);
}
}

return res;
}
};

【并查集】账户合并

https://leetcode.cn/problems/accounts-merge/

题意:给定 n 个账户,每一个账户含有一个用户名和最多 m 个绑定的邮箱。由于一个用户可能注册多个账户,因此我们需要对所有的账户进行合并使得一个用户对应一个账户。合并的规则是将所有「含有相同邮箱的账户」视作同一个用户注册的账户。返回合并后的账户列表。

思路:这道题的需求很显然,我们需要合并含有相同邮箱的账户。显然有一个暴力的做法,我们直接枚举每一个账户中所有的邮箱,接着枚举剩余账户中的邮箱进行匹配,匹配上就进行合并,但这样做显然会造成大量的冗余匹配和冗余合并,我们不妨将这两个过程进行拆分。我们需要解决两个问题:

  • 哪些账户需要合并?很容易想到并查集这样的数据结构。我们使用哈希表存储每一个邮箱的账户编号,最后进行集合合并即可维护好每一个账号归属的集合编号。O(nm)O(nm)
  • 如何合并指定账户?对于上述维护好的集合编号,我们需要合并所有含有相同“祖先”的账户。排序去重或使用有序列表均可实现。O(nlogn)O(n\log n)

时间复杂度:O(nlogn)O(n\log n)

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
struct dsu {
int n;
std::vector<int> p;
dsu(int _n) { n = _n; p.resize(n + 1); for (int i = 1; i <= n; i++) p[i] = i; }
int find(int x) { return (p[x] == x ? p[x] : p[x] = find(p[x])); }
void merge(int a, int b) { p[find(a)] = find(b); }
bool query(int a, int b) { return find(a) == find(b); }
int block() { int ret = 0; for (int i = 1; i <= n; i++) ret += p[i] == i; return ret; }
};

class Solution {
public:
vector<vector<string>> accountsMerge(vector<vector<string>>& accounts) {
// 维护每一个子账户归属的集合
int n = accounts.size();
unordered_map<string, vector<int>> hash;
for (int i = 1; i <= n; i++) {
for (int j = 1; j < accounts[i - 1].size(); j++) {
hash[accounts[i - 1][j]].push_back(i);
}
}
dsu d(n);
for (auto& it: hash) {
vector<int> v = it.second;
for (int i = 1; i < v.size(); i++) {
d.merge(v[i - 1], v[i]);
}
}

// 按照子账户归属的集合合并出最终的账户
unordered_set<int> fa;
for (int i = 1; i <= n; i++) {
fa.insert(d.find(i));
}
vector<vector<string>> res;
for (auto p: fa) {
set<string> se;
vector<string> ans;
for (int i = 1; i <= n; i++) {
if (d.find(i) == p) {
if (ans.empty()) {
ans.push_back(accounts[i - 1][0]);
}
for (int j = 1; j < accounts[i - 1].size(); j++) {
se.insert(accounts[i - 1][j]);
}
}
}
for (auto mail: se) {
ans.push_back(mail);
}
res.push_back(ans);
}

return res;
}
};
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class dsu:
def __init__(self, n: int) -> None:
self.n = n
self.p = [i for i in range(n + 1)]
def find(self, x: int) -> int:
if self.p[x] != x: self.p[x] = self.find(self.p[x])
return self.p[x]
def merge(self, a: int, b: int) -> None:
self.p[self.find(a)] = self.find(b)
def query(self, a: int, b: int) -> bool:
return self.find(a) == self.find(b)
def block(self) -> int:
return sum([1 for i in range(1, self.n + 1) if self.p[i] == i])

class Solution:
def accountsMerge(self, accounts: List[List[str]]) -> List[List[str]]:
from collections import defaultdict

n = len(accounts)
hash = defaultdict(list)
for i in range(1, n + 1):
for j in range(1, len(accounts[i - 1])):
hash[accounts[i - 1][j]].append(i)

d = dsu(n)
for _, ids in hash.items():
for i in range(1, len(ids)):
d.merge(ids[i - 1], ids[i])

fa = set()
for i in range(1, n + 1):
fa.add(d.find(i))

res = []
for p in fa:
ans = []
se = set()
for i in range(1, n + 1):
if d.find(i) == p:
if len(ans) == 0:
ans.append(accounts[i - 1][0])
for j in range(1, len(accounts[i - 1])):
se.add(accounts[i - 1][j])
ans += sorted(se)
res.append(ans)

return res

【树状数组】将元素分配到两个数组中 II

https://leetcode.cn/problems/distribute-elements-into-two-arrays-ii/description/

题意:给定 nn 个数,现在需要将这些数按照某种规则分配到两个数组 AABB 中。初始化分配 nums[0]AA 中,nums[1]BB 中,接下来对于剩余的每个元素 nums[i],分配取决于 AABB 中比当前元素 nums[i] 大的个数,最终返回两个分配好的数组

思路:首先每一个元素都需要进行枚举,那么本题需要考虑的就是如何在 log\log 时间复杂度内统计出两数组中比当前元素大的元素个数。针对 C++ 和 Python 分别讨论

  • C++
    • 法一:std::multiset<int>。可惜不行,因为统计比当前元素大的个数时,s.rbegin() - s.upper_bound(nums[i]) 是不合法的,因为 std::multiset<int> 的迭代器不是基于指针的,因此无法直接进行加减来计算地址差,遂作罢
    • 法二:树状数组。很容易想到利用前缀和统计比当前数大的数字个数,但是由于此处需要对前缀和进行单点修改,因此时间复杂度肯定会寄。有什么数据结构支持「单点修改,区间更新」呢?我们引入树状数组。我们将数组元素哈希到 [1, len(set(nums))] 区间,定义哈希后的当前元素 nums[i]x,对于当前哈希后的 x 而言想要知道两个数组中有多少数比当前数严格大,只需要计算前缀和数组 arrarr[n] - arr[x] 的结果即可
  • Python
    • SortedList。python 有一个 sortedcontainers 包其中有 SortedList 模块,可以实现 std::multiset<int> 所有 log\log 操作并且可以进行随机下标访问,于是就可以进行下标访问 O(1)O(1) 计算比当前数大的元素个数
    • 题外话。LeetCode 可以进行第三方库导入的操作,某些比赛不允许,需要手搓 SortedList 模块,当然可以用树状数组 or 线段树解决,板子链接:https://blog.dwj601.cn/Algorithm/a_template/#SortedList

时间复杂度:O(nlogn)O(n \log n)

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
template<class T>
class BinaryIndexedTree {
private:
std::vector<T> _arr;
int _n;

int lowbit(int x) { return x & (-x); }

public:
BinaryIndexedTree(int n) :_n(n) {
_arr.resize(_n + 1, 0);
}

void add(int pos, T x) {
while (pos <= _n) {
_arr[pos] += x;
pos += lowbit(pos);
}
}

T sum(int pos) {
T ret = 0;
while (pos) {
ret += _arr[pos];
pos -= lowbit(pos);
}
return ret;
}
};


class Solution {
public:
vector<int> resultArray(vector<int>& nums) {
vector<int> copy = nums;
sort(copy.begin(), copy.end());
copy.erase(unique(copy.begin(), copy.end()), copy.end());

int n = copy.size(), cnt = 1;
unordered_map<int, int> a;
for (int i = 0; i < n; i++) {
a[copy[i]] = cnt++;
}

vector<int> v1, v2;
v1.push_back(nums[0]);
v2.push_back(nums[1]);

BinaryIndexedTree<int> t1(n), t2(n);
t1.add(a[nums[0]], 1);
t2.add(a[nums[1]], 1);

for (int i = 2; i < nums.size(); i++) {
int d1 = t1.sum(n) - t1.sum(a[nums[i]]);
int d2 = t2.sum(n) - t2.sum(a[nums[i]]);

if (d1 > d2) {
v1.push_back(nums[i]);
t1.add(a[nums[i]], 1);
} else if (d1 < d2) {
v2.push_back(nums[i]);
t2.add(a[nums[i]], 1);
} else if (d1 == d2 && v1.size() < v2.size()) {
v1.push_back(nums[i]);
t1.add(a[nums[i]], 1);
} else if (d1 == d2 && v1.size() > v2.size()) {
v2.push_back(nums[i]);
t2.add(a[nums[i]], 1);
} else {
v1.push_back(nums[i]);
t1.add(a[nums[i]], 1);
}
}

for (int x: v2) {
v1.push_back(x);
}

return v1;
}
};
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class BinaryIndexedTree:
def __init__(self, n: int):
self._n = n
self._arr = [0] * (n + 1)

def _lowbit(self, x: int) -> int:
return x & (-x)

def add(self, pos: int, x: int) -> None:
while pos <= self._n:
self._arr[pos] += x
pos += self._lowbit(pos)

def sum(self, pos: int) -> int:
ret = 0
while pos:
ret += self._arr[pos]
pos -= self._lowbit(pos)
return ret


class Solution:
def resultArray(self, nums: List[int]) -> List[int]:
copy = sorted(set(nums))

n, cnt, a = len(copy), 1, {}
for x in copy:
a[x] = cnt
cnt += 1

v1, v2 = [nums[0]], [nums[1]]
t1, t2 = BinaryIndexedTree(n), BinaryIndexedTree(n)
t1.add(a[nums[0]], 1)
t2.add(a[nums[1]], 1)

for x in nums[2:]:
d1, d2 = t1.sum(n) - t1.sum(a[x]), t2.sum(n) - t2.sum(a[x])

if d1 > d2:
v1.append(x)
t1.add(a[x], 1)
elif d1 < d2:
v2.append(x)
t2.add(a[x], 1)
elif d1 == d2 and len(v1) < len(v2):
v1.append(x)
t1.add(a[x], 1)
elif d1 == d2 and len(v1) > len(v2):
v2.append(x)
t2.add(a[x], 1)
else:
v1.append(x)
t1.add(a[x], 1)

return v1 + v2
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Solution:
def resultArray(self, nums: List[int]) -> List[int]:
from sortedcontainers import SortedList

v1, v2 = copy.deepcopy(nums[:1]), copy.deepcopy(nums[1:2])
s1, s2 = SortedList(v1), SortedList(v2)

for x in nums[2:]:
d1, d2 = len(v1) - s1.bisect_right(x), len(v2) - s2.bisect_right(x)

if d1 > d2:
v1.append(x)
s1.add(x)
elif d1 < d2:
v2.append(x)
s2.add(x)
elif d1 == d2 and len(v1) < len(v2):
v1.append(x)
s1.add(x)
elif d1 == d2 and len(v1) > len(v2):
v2.append(x)
s2.add(x)
else:
v1.append(x)
s1.add(x)

return v1 + v2

【线段树/二分】以组为单位订音乐会的门票 🔥

https://leetcode.cn/problems/booking-concert-tickets-in-groups/

题意:给定一个长为 n5×104n\le 5 \times 10^4 且初始值均为 00 的数组 aa,数组中的每个元素最多增加到 mm。现在需要以这个数组为基础进行 q5×104q\le 5 \times 10^4 次询问,每次询问是以下两者之一:

  1. 给定一个 kklimlim,找到最小的 i[0,lim]i \in [0,lim] 使得 maikm - a_i \ge k
  2. 给定一个 kklimlim,找到最小的 i[0,lim]i \in [0,lim] 使得 m×(i+1)j=0iajk\displaystyle m\times (i+1) - \sum_{j=0}^i a_j \ge k

思路一:暴力。对于询问 1,我们直接顺序遍历 a 数组直到找到第一个符合条件的即可;对于询问 2,同样直接顺序遍历 a 数组直到找到第一个符合条件的即可。时间复杂度为 O(qn)O(qn)

思路二:线段树上二分

暴力代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class BookMyShow:

def __init__(self, n: int, m: int):
self.a = [0] * n # a[i] 表示第 i 行已入座的人数
self.n = n
self.m = m

def gather(self, k: int, lim: int) -> List[int]:
# 在 [0, lim] 行中找到第一个可以容纳 k 人的行
for i in range(lim + 1):
if self.m - self.a[i] >= k:
l, r = i, self.a[i]
self.a[i] += k
return [l, r]
return []

def scatter(self, k: int, lim: int) -> bool:
# 在 [0, lim] 行中找到最小的 i 使得 [0, i] 行可以容纳 k 人
if self.m * (lim + 1) - sum(self.a[:lim+1]) < k:
return False

i = 0
while k > 0:
if self.m - self.a[i] >= k:
self.a[i] += k
k = 0
else:
k -= self.m - self.a[i]
self.a[i] = self.m
i += 1
return True

线段树上二分代码:

1

]]>
+ 分治

将大问题转化为等价小问题进行求解。

【分治】随机排列

https://www.acwing.com/problem/content/5469/

题意:给定一个 n 个数的全排列序列,并将其进行一定的对换,问是对换了 3n 次还是 7n+1 次

思路:可以发现对于两种情况,就对应对换次数的奇偶性。当 n 为奇数:3n 为奇数,7n+1 为偶数;当 n 为偶数:3n 为偶数,7n+1 为奇数。故我们只需要判断序列的逆序数即可。为了求解逆序数,我们可以采用归并排序的 combine 过程进行统计即可

时间复杂度:O(nlogn)O(n \log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 1000010;

int n, a[N], t[N];
int cnt; // 逆序数

void MergeSort(int l, int r) {
if (l >= r) return;

int mid = (l + r) >> 1;

MergeSort(l, mid), MergeSort(mid + 1, r);

int i = l, j = mid + 1, idx = 0;

while (i <= mid && j <= r) {
if (a[i] < a[j]) t[idx++] = a[i++];
else {
cnt += mid - i + 1;
t[idx++] = a[j++];
}
}

while (i <= mid) t[idx++] = a[i++];
while (j <= r) t[idx++] = a[j++];

for (i = l, idx = 0; i <= r; i++, idx++) a[i] = t[idx];
}

void solve() {
cin >> n;

for (int i = 0; i < n; i++) cin >> a[i];

MergeSort(0, n - 1);

int res;

if (n % 2 == 1) {
if (cnt % 2) res = 1;
else res = 2;
} else {
if (cnt % 2) res = 2;
else res = 1;
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
//cin >> T;
while (T--) solve();
return 0;
}
]]>
@@ -1644,11 +1644,11 @@ - dfs-and-similar - - /Algorithm/dfs-and-similar/ + dp + + /Algorithm/dp/ - 搜索

无论是深搜还是宽搜,都逃不掉图的思维。我们将搜索图建立起来之后,剩余的编码过程就会跃然纸上。

【dfs】机器人的运动范围

https://www.acwing.com/problem/content/22/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Solution {
public:
int res = 0;

int movingCount(int threshold, int rows, int cols)
{
if (!rows || !cols) return 0;
vector<vector<int>> g(rows, vector<int>(cols, 0));
vector<vector<bool>> vis(rows, vector<bool>(cols, false));
dfs(g, vis, 0, 0, threshold);
return res;
}

void dfs(vector<vector<int>>& g, vector<vector<bool>>& vis, int x, int y, int threshold)
{
vis[x][y] = true;
res ++;

int dx[] = {-1, 0, 1, 0}, dy[] = {0, 1, 0, -1};
for (int k = 0; k < 4; k ++)
{
int i = x + dx[k], j = y + dy[k];
if (i < 0 || i >= int(g.size()) || j < 0 || j >= int(g[0].size()) || vis[i][j] || cnt(i, j) > threshold) continue;
dfs(g, vis, i, j, threshold);
}
}

int cnt(int x, int y)
{
int sum = 0;
while (x) sum += x % 10, x /= 10;
while (y) sum += y % 10, y /= 10;
return sum;
}
};

【dfs】CCC单词搜索

https://www.acwing.com/problem/content/5168/

搜索逻辑:分为正十字与斜十字

更新答案逻辑:需要进行两个条件的约数,一个是是否匹配到了最后一个字母,一个是转弯次数不超过一次

转弯判断逻辑:

首先不能是起点开始的
对于正十字:如果next的行 & 列都与pre的行和列不相等,就算转弯
对于斜十字:如果next的行 | 列有和pre相等的,就算转弯

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
#include <iostream>
#include <algorithm>
#include <cstring>

using namespace std;

const int N = 110;

string s;
int m, n;
char g[N][N];
int res;

// 正十字,a,b为之前的位置,x,y为当前的位置,now为当前待匹配的字母位,cnt为转弯次数
void dfs1(int a, int b, int x, int y, int now, int cnt)
{
if (g[x][y] != s[now]) return;

if (now == s.size() - 1)
{
if (cnt <= 1) res++;
return;
}

int dx[] = {-1, 0, 1, 0}, dy[] = {0, 1, 0, -1};

for (int k = 0; k < 4; k ++)
{
int i = x + dx[k], j = y + dy[k];
if (x < 0 || x >= m || y < 0 || y >= n) continue;

// 判断是否转弯(now不是起点 且 pre和next行列均不相等)
if (a != -1 && b != -1 && a != i && b != j) dfs1(x, y, i, j, now + 1, cnt + 1);
else dfs1(x, y, i, j, now + 1, cnt);
}
}

// 斜十字
void dfs2(int a, int b, int x, int y, int now, int cnt)
{
if (g[x][y] != s[now]) return;

if (now == s.size() - 1)
{
if (cnt <= 1) res++;
return;
}

int dx[] = {-1, -1, 1, 1}, dy[] = {-1, 1, 1, -1};

for (int k = 0; k < 4; k ++)
{
int i = x + dx[k], j = y + dy[k];
if (x < 0 || x >= m || y < 0 || y >= n) continue;

// 判断是否转弯(now不是起点 且 不在同一对角线)
if (a != -1 && b != -1 && (a == i || b == j)) dfs2(x, y, i, j, now + 1, cnt + 1);
else dfs2(x, y, i, j, now + 1, cnt);
}
}


int main()
{
cin >> s;
cin >> m >> n;

for (int i = 0; i < m; i ++)
for (int j = 0; j < n; j ++)
cin >> g[i][j];

for (int i = 0; i < m; i ++)
for (int j = 0; j < n; j ++)
dfs1(-1, -1, i, j, 0, 0);

for (int i = 0; i < m; i ++)
for (int j = 0; j < n; j ++)
dfs2(-1, -1, i, j, 0, 0);

cout << res << "\n";

return 0;
}

【dfs/二进制枚举】数量

https://www.acwing.com/problem/content/5150/

题意:给定一个数n,问[1, n]中有多少个数只含有4或7

思路一:dfs

  • 对于一个数,我们可以构造一个二叉搜数进行搜索,因为每一位只有两种可能,那么从最高位开始搜索。如果当前数超过了n就return,否则就算一个答案
  • 时间复杂度:Θ(21+lg(max(a[i])))\Theta(2^{1 + \lg{(\max(a[i])})})

思路二:二进制枚举

  • 按照数位进行计算。对于一个 x 位的数,1 到 x-1 位的情况下所有的数都符合条件,对于一个 t 位的数,满情况就是 2t2^t 种,所以 [1,x-1] 位就一共有 21+22++2x1=2x22^1 + 2^2 + \cdots + 2^{x - 1} = 2^{x} - 2 种情况 。对于第 x 位,采取二进制枚举与原数进行比较,如果小于原数,则答案 +1,反之结束循环输出答案即可

dfs 代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <iostream>
using namespace std;

#define int long long

int n, res;

void dfs(int x) {
if (x > n) return;

res ++;

dfs(x * 10 + 4);
dfs(x * 10 + 7);
}

signed main() {
cin >> n;
dfs(4);
dfs(7);
cout << res << "\n";
return 0;
}

二进制枚举代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <iostream>
using namespace std;

int WS(int x) {
int res = 0;
while (x) {
res++;
x /= 10;
}
return res;
}

int calc(int a[], int ws) {
int res = 0;
for (int i = ws - 1; i >= 0; i --) {
res = res * 10 + a[i];
}
return res;
}

int main() {
int n;
cin >> n;

int ws = WS(n);

int ans = (1 << ws) - 2;

int a[20] {};
for (int i = 0; i < (1 << ws); i ++) {
for (int j = 0; j < ws; j ++) {
if ((1 << j) & i) {
a[j] = 7;
} else {
a[j] = 4;
}
}
if (calc(a, ws) <= n) {
ans ++;
} else {
break;
}
}

cout << ans;

return 0;
}

【dfs】组合总和

https://leetcode.cn/problems/combination-sum/

题意:给定一个序列,其中的元素没有重复,问如何选取其中的元素,使得选出的数字总和为指定的数字target,选取的数字可以重复

思路:思路比较简答,很容易想到用dfs搜索出所有的组合情况,即对于每一个“结点”,我们直接遍历序列中的元素即可。但是由于题目的限制,即不允许合法的序列经过排序后相等。那么为了解决这个约束,我们可以将最终搜索到的序列排序后进行去重,但是这样的时间复杂度会很高,于是我们从搜索的过程切入。观看这一篇题解 防止出现重复序列的启蒙题解,我们提取其中最关键的一个图解

subset_sum_i_pruning.png

可见3,4和4,3的剩余选项(其中可能包含了答案序列)全部重复,因此我们直接减去这个枝即可。不难发现,我们根据上述优化思想,剪枝的操作可以为:让当前序列开始枚举的下标 idx 从上一层开始的下标 i 开始,于是剪枝就可以实现了。

时间复杂度:Θ(2nlogn)\Theta \left ( 2^{\frac{n}{\log n}}\right)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
// 答案数组res,目标数组c,目标总和target,答案数组now,当前总和sum,起始下标idx
void dfs(vector<vector<int>>& res, vector<int>& c, int target, vector<int>& now, int sum, int idx) {
if (sum > target) {
return;
} else if (sum == target) {
res.emplace_back(now);
return;
}
for (int i = idx; i < c.size(); i++) {
now.emplace_back(c[i]);
dfs(res, c, target, now, sum + c[i], i);
now.pop_back();
}
}

vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
vector<vector<int>> res;
vector<int> now;
dfs(res, candidates, target, now, 0, 0);
return res;
}
};

【递归】扩展字符串

https://www.acwing.com/problem/content/5284/

题意:给定一种字符串的构造方式,问构造n次以后的字符串中的第k个字符是什么

思路:由于构造的方法是基于上一种情况的,很容易可以想到一个递归搜索树来解决。只是这道题有好几个坑,故记录一下。

  • 首先说一下搜索的思路:对于当前的状态,我们想要知道第k个位置上的字符,很显然我们可以通过预处理每一种构造状态下的字符串长度得到下一个字符串的长度,于是我们可以在当前的字符串中,通过比对下标与五段字符串长度的大小,来确定是继续递归还是直接输出
  • 特判:可以发现,对于 n=0n=0 的情况,我们无法采用相同的结构进行计算,故进行特判,如果当前来到了最初始的字符串状态,我们直接输出相应位置上的字符即可
  • 最后说一下递归终点的设计:与搜索所有的答案情况不同,这道题的答案是唯一的,因此我们在搜索到答案后,可以通过一个 bool 变量作为一个标记,表示已经找到答案了,只需要不断回溯直到回溯结束为止,就不需要再遍历其他的分支了
  • **坑:**这道题的坑说实话有点难崩。
    1. 首先是一个k的大小,是一定要开 long long 的,我一开始直接全局宏定义 intlong long
    2. 还有一个坑可能是只要我才会犯的,就是字符串按照下标输出字符的时候,是需要 -1 的,闹心的是我有的加了,有的没加,还是debug的时候调出来的
    3. 最后一个大坑,属于是引以为戒了。就是这句 len[i] = min(len[i], (int)2e18),因为我们可以发现,抛开那三个固定长度的字符串来说,每一次新构造出来的字符串长度都是上一个字符串长度 22 倍,那么构造 nn 次后的字符串长度就是 s0s_0 长度的 2n2^n 倍,那么对于 nn 的取值范围来说,直接存储长度肯定是不可取的。那么如何解决这个问题呢?方法是我们对 len[i] 进行一个约束即可,见代码。最后进行递归比较长度就没问题了。
  • 时间复杂度:O(n)O(n) - 由于每一个构造的状态我们都是常数级别的比较,因此相当于一个状态的搜索时间复杂度为 O(1)O(1),那么总合就是 O(n)O(n)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
#include <bits/stdc++.h>
using namespace std;

#define int long long

int n, k;
string s = "DKER EPH VOS GOLNJ ER RKH HNG OI RKH UOPMGB CPH VOS FSQVB DLMM VOS QETH SQB";
string t1 = "DKER EPH VOS GOLNJ UKLMH QHNGLNJ A";
string t2 = "AB CPH VOS FSQVB DLMM VOS QHNG A";
string t3 = "AB";

// 记录每一层构造出来的字符串 Si 的长度 len,当前递归的层数 i (i>=1),对于当前层数需要查询的字符的下标 pos
void dfs(vector<int>& len, int i, int pos, bool& ok) {
// 已经搜到答案了就不断返回
if (ok) {
return;
}

// 如果还没有搜到答案,并且已经递归到了最开始的一层,就输出原始字符串相应位置的字符即可
if (!i) {
cout << s[pos - 1];
return;
}

int l1 = t1.size(), l2 = l1 + len[i - 1], l3 = l2 + t2.size(), l4 = l3 + len[i - 1];
if (pos <= l1) {
cout << t1[pos - 1];
ok = true;
return;
} else if (pos <= l2) {
dfs(len, i - 1, pos - l1, ok);
} else if (pos <= l3) {
cout << t2[pos - l2 - 1];
ok = true;
return;
} else if (pos <= l4) {
dfs(len, i - 1, pos - l3, ok);
} else {
cout << t3[pos - l4 - 1];
ok = true;
return;
}
}

void solve() {
cin >> n >> k;

vector<int> len(n + 10);
len[0] = s.size();

for (int i = 1; i <= n; i++) {
len[i] = 2 * len[i - 1] + t1.size() + t2.size() + t3.size();
len[i] = min(len[i], (int)2e18); // 点睛之笔...
}

// 特判下标越界的情况
if (k > len[n]) {
cout << ".";
return;
}

// 否则开始从第n层开始递归搜索
bool ok = false;
dfs(len, n, k, ok);
}

signed main() {
int T = 1;
cin >> T;
while (T--) {
solve();
}
return 0;
}

【dfs】让我们异或吧

https://www.luogu.com.cn/problem/P2420

题意:给定一棵树,树上每一条边都有一个权值,现在有Q次询问,对于每次询问会给出两个结点编号u,v,需要输出结点u到结点v所经过的路径的所有边权的异或之和

思路:对于每次询问,我们当然可以遍历从根到两个结点的所有边权,然后全部异或计算结果,但是时间复杂度是 O(n)O(n),显然不行,那么有什么优化策略吗?答案是有的。我们可以发现,对于两个结点之间的所有边权,其实就是根到两个结点的边权相异或得到的结果(异或的性质),我们只需要预处理出根结点到所有结点的边权已异或值,后续询问的时候直接 O(1)O(1) 计算即可

时间复杂度:Θ(n+q)\Theta(n+q)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
const int N = 100010;

struct node {
int id;
int w;
};

int n, m, f[N]; // f[i] 表示从根结点到 i 号结点的所有边权的异或值
vector<node> G[N];
bool vis[N];

void dfs(int fa) {
if (!vis[fa]) {
vis[fa] = true;
for (auto& ch: G[fa]) {
f[ch.id] = f[fa] ^ ch.w;
dfs(ch.id);
}
}
}

void solve() {
cin >> n;

for (int i = 0; i < n - 1; i++) {
int a, b, w;
cin >> a >> b >> w;
G[a].push_back({b, w});
G[b].push_back({a, w});
}

dfs(1);

cin >> m;

while (m--) {
int u, v;
cin >> u >> v;
cout << (f[u] ^ f[v]) << "\n";
}
}

【记忆化搜索】Function

https://www.luogu.com.cn/problem/P1464

题意:

思路一:直接dfs

  • 直接按照题意进行dfs代码的编写,但是很显然时间复杂极高
  • 时间复杂度:O(T×情况数)O(T \times \text{情况数})

思路二:记忆化dfs

  • 记忆化逻辑:
    • 如果当前的状态没有记忆过,就记忆一下
    • 如果当前的状态已经记忆过了,就不需要继续递归搜索了,直接使用之前已经记忆过的答案即可
  • 上述起始状态需要和搜到答案的状态做一个区别。我们知道,对于一组合法的输入,答案一定是
  • 注意点:
    • 输入终止条件不是 a != -1 && b != -1 && c != -1,而是要三者都不是 -1 才行
    • 对于每一组输入,我们不需要 memset 记忆数组,因为每一组的记忆依赖是相同的
    • 由于答案一定是 >0>0 的,因此是否记忆过只需要看当前状态的答案是否 >0>0 即可
  • 时间复杂度:<O(T×n3)<O(T \times n^3)

直接dfs代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

ll dfs(int a, int b, int c) {
if (a <= 0 || b <= 0 || c <= 0) return 1;
else if (a > 20 || b > 20 || c > 20) return dfs(20, 20, 20);
else if (a < b && b < c) return dfs(a, b, c - 1) + dfs(a, b - 1, c - 1) - dfs(a, b - 1, c);
else return dfs(a - 1, b, c) + dfs(a - 1, b - 1, c) + dfs(a - 1, b, c - 1) - dfs(a - 1, b - 1, c - 1);
}

void solve() {
int a, b, c;
cin >> a >> b >> c;
while (a != -1 && b != -1 && c != -1) {
printf("w(%d, %d, %d) = %lld\n", a, b, c, dfs(a, b, c));
cin >> a >> b >> c;
}
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

记忆化dfs代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 25;

ll f[N][N][N];

ll dfs(ll a, ll b, ll c) {
// 上下界
if (a <= 0 || b <= 0 || c <= 0) return 1;
else if (a > 20 || b > 20 || c > 20) return dfs(20, 20, 20);

if (f[a][b][c]) {
// 已经记忆化过了,直接返回当前状态的解
return f[a][b][c];
}
else {
// 没有记忆化过,就递归计算并且记忆化
if (a < b && b < c) return f[a][b][c] = dfs(a, b, c - 1) + dfs(a, b - 1, c - 1) - dfs(a, b - 1, c);
else return f[a][b][c] = dfs(a - 1, b, c) + dfs(a - 1, b - 1, c) + dfs(a - 1, b, c - 1) - dfs(a - 1, b - 1, c - 1);
}
}

void solve() {
ll a, b, c;
cin >> a >> b >> c;
while (!(a == -1 && b == -1 && c == -1)) {
printf("w(%lld, %lld, %lld) = %lld\n", a, b, c, dfs(a, b, c));
cin >> a >> b >> c;
}
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【递归】外星密码

https://www.luogu.com.cn/problem/P1928

线性递归

题意:给定一个压缩后的密码串,需要解压为原来的形式。压缩形式距离

  • AC[3FUN] \to ACFUNFUNFUN
  • AB[2[2GH]]OP \to ABGHGHGHGHOP

思路:

  • 我们采用递归的策略

  • 我们知道,对于每一个字符,一共有4种情况,分别是:“字母”、“数字”、“[”、“]”。如果是字母。我们分情况考虑

    • “字母”:
      1. 直接加入答案字符串即可
    • “[”:
      1. 获取左括号后面的整体 - 采用递归策略获取后面的整体
      2. 加入答案字符串
    • “数字”:
      1. 获取完整的数 - 循环小trick
      2. 获取数字后面的整体 - 采用递归策略获取后面的整体
      3. 加入答案字符串 - 循环尾加入即可
      4. 返回当前的答案字符串
    • “]”:
      1. 返回当前的答案字符串 - 与上述 “[” 对应
  • 代码设计分析:

    • 我们将压缩后的字符串看成由下面两种单元组成:

      1. 最外层中括号组成的单元:如 [2[2AB]] 就算一个最外层中括号组成的单元
      2. 连续的字母单元:如 OPQ 就算一个连续的字母单元
    • 解决各单元连接问题:

    • 为了在递归处理完第一种单元后还能继续处理后续的第二种单元,我们直接按照压缩字符串的长度进行遍历,即 while (i < s.size()) 操作

    • 解决两种单元内部问题:

      • 最外层中括号组成的单元:递归处理
      • 连续的字母单元:直接加入当前答案字符串即可
  • 手玩样例:

    手玩样例

    • 显然按照定义,上述压缩字符串一共有五个单元
    • 我们用红色表示进入递归,蓝色表示驱动递归结束并回溯。可以发现
  • 时间复杂度:Θ(res.length())\Theta(\text{res.length()})

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

string s;
int i;

string dfs() {
string res;

while (i < s.size()) {
if (s[i] >= 'A' && s[i] <= 'Z') {
while (s[i] >= 'A' && s[i] <= 'Z') {
res += s[i++];
}
}
if (s[i] == '[') {
i++;
res += dfs();
}
if (isdigit(s[i])) {
int cnt = 0;
while (isdigit(s[i])) {
cnt = cnt * 10 + s[i] - '0';
i++;
}
string t = dfs();
while (cnt--) {
res += t;
}
return res;
}
if (s[i] == ']') {
i++;
return res;
}
}

return res;
}

void solve() {
cin >> s;
cout << dfs() << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs+剪枝/二进制枚举】选数

https://www.luogu.com.cn/problem/P1036

题意:给定n个数,从中选出k个数,问一共有多少种方案可以使得选出来的k个数之和为质数

思路一:dfs+剪枝

  • 按照数据量可以直接暴搜,搜索依据是每一个数有两种状态,即选和不选,于是搜索树就是一棵二叉树
  • 搜索状态定义为:对于当前第idx个数,已经选择了cnt个数,已经选择的数之和为sum
  • 搜索终止条件为:idx越界
  • 剪枝:已经选择了k个数就直接返回,不用再选剩下的数了
  • 时间复杂度:O(2n)O(2^n) - 剪枝后一定是小于这个复杂度的

思路二:二进制枚举

  • 直接枚举 02n10\to 2^n-1,按照其中含有的 11 的个数,来进行选数判断
  • 时间复杂度:O(2n)O(2^n) - 一定会跑满的

dfs+剪枝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 30;

int n, k, a[N];
int res;

bool isPrime(int x) {
if (x < 2) return false;
for (int i = 2; i <= x / i; i++)
if (x % i == 0)
return false;
return true;
}

/**
* @param cnt 当前已经选择的数的数量
* @param idx 当前数的下标
* @param sum 当前选数状态下的总和
*/
void dfs(int cnt, int idx, int sum) {
if (idx > n) return;

if (cnt == k) {
if (isPrime(sum)) res++;
return;
}

dfs(cnt, idx + 1, sum);
dfs(cnt + 1, idx + 1, sum + a[idx + 1]);
}

void solve() {
cin >> n >> k;
for (int i = 1; i <= n; i++)
cin >> a[i];

dfs(0, 1, 0); // 不选第一个数
dfs(1, 1, a[1]); // 选第一个数

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

二进制枚举

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 30;

int n, k, a[N];
int res;

bool isPrime(int x) {
if (x < 2) return false;
for (int i = 2; i <= x / i; i++)
if (x % i == 0)
return false;
return true;
}

void solve() {
cin >> n >> k;
for (int i = 1; i <= n; i++)
cin >> a[i];

for (int i = 0; i < (1 << n); i++) {
int cnt = 0, sum = 0;
for (int j = 0; j < n; j++)
if (i & (1 << j))
cnt++, sum += a[j + 1];

if (cnt != k) continue;

if (isPrime(sum)) res++;
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs/bfs】01迷宫

https://www.luogu.com.cn/problem/P1141

题意:给定一个01矩阵,行走规则为“可以走到相邻的数字不同的位置”,现在给定m次询问 (u,v),输出从 (u,v) 开始最多可以走多少个位置?

思路:我们可以将此问题转化为一个求解连通块的问题。对于矩阵中的一个连通块,我们定义为:在其中任意一个位置开始行走,都可以走过整个连通块每一个位置。那么在询问时,只需要输出所在连通块元素的个数即可。现在将问题转化为了

  1. 如何遍历每一个连通块?按照标记数组的情况,如果一个位置没有被标记,就从这个位置出发开始打标记并统计

  2. 如何统计每一个连通块中元素的个数?按照题目中给定的迷宫行走规则,可以通过bfs或者dfs实现遍历

bfs代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#include <bits/stdc++.h>
using namespace std;

const int N = 1010;

int n, m, res[N][N];
char g[N][N];
bool vis[N][N];

void bfs(int u, int v) {
queue<pair<int, int>> q;
int cnt = 0; // 当前“连通块”的大小
vector<pair<int, int>> a;

q.push({u, v});
a.push_back({u, v});
vis[u][v] = true;
cnt++;

int dx[4] = {-1, 1, 0, 0}, dy[4] = {0, 0, 1, -1};

while (q.size()) {
auto& now = q.front();
q.pop();

for (int i = 0; i < 4; i++) {
int x = dx[i] + now.first, y = dy[i] + now.second;
if (x >= 1 && x <= n && y >= 1 && y <= n && !vis[x][y] && g[x][y] != g[now.first][now.second]) {
q.push({x, y});
a.push_back({x, y});
vis[x][y] = true;
cnt++;
}
}
}

for (auto& loc: a) {
res[loc.first][loc.second] = cnt;
}
}

void solve() {
cin >> n >> m;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
cin >> g[i][j];

for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (!vis[i][j])
bfs(i, j);

while (m--) {
int a, b;
cin >> a >> b;
if (vis[a][b]) {
cout << res[a][b] << "\n";
} else {
cout << 1 << "\n";
}
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

dfs代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
#include <bits/stdc++.h>
using namespace std;

const int N = 1010;

int n, m, res[N][N];
char g[N][N];
bool vis[N][N];

// 当前点的坐标 (u, v),当前连通块的元素个数cnt,当前连通块的元素存到 a 数组
void dfs(int u, int v, int& cnt, vector<pair<int, int>>& a) {
cnt++;
a.push_back({u, v});
vis[u][v] = true;

int dx[4] = {0, 0, 1, -1}, dy[4] = {1, -1, 0, 0};

for (int k = 0; k < 4; k++) {
int x = u + dx[k], y = v + dy[k];
if (x >= 1 && x <= n && y >= 1 && y <= n && !vis[x][y] && g[x][y] != g[u][v]) {
dfs(x, y, cnt, a);
}
}
}

void solve() {
cin >> n >> m;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
cin >> g[i][j];

for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (!vis[i][j]) {
int cnt = 0;
vector<pair<int, int>> a;
dfs(i, j, cnt, a);
for (auto& loc: a) {
res[loc.first][loc.second] = cnt;
}
}

while (m--) {
int a, b;
cin >> a >> b;
if (vis[a][b]) {
cout << res[a][b] << "\n";
} else {
cout << 1 << "\n";
}
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs/二进制枚举】kkksc03考前临时抱佛脚

https://www.luogu.com.cn/problem/P2392

题意:给定四组数据,每组数据由 n 个数字组成,对于每一组数字需要分为两组使得两组之和相差尽可能小,问最终四组数据分组后,每一组之和的最大值之和是多少

  • 思路一:二进制枚举

    可以发现,我们可以只设计处理一组数据的算法,其余组数的数据调用该算法即可。对于一个序列,想要将其划分为 2 组使得 2 组之和的差值最小,我们可以发现,对于序列中的一个数而言,有两个状态,要么分到第一组,要么就分到第二组,因此我们可以采用二进制枚举的方式,将所有的分组情况全部枚举出来,每一个状态计算一下和的差值,最后取最小差值时,两组中和的最大值即可

    时间复杂度:O(4×2n)O(4 \times 2^n)

  • 思路二:dfs

    从上面的二进制枚举得到启发,一定可以进行二叉树搜索。很显然就直接左结点让当前数分到左组,右结点让当前数分到右组即可。本题无法剪枝,因为两组之和的差值没有规律

    时间复杂度:O(4×2n)O(4 \times 2^n)

二进制枚举代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 25;

int a[N], res;

void fun(int n) {
int MIN = 20 * 60;
for (int i = 0; i <= (1 << n); i++) {
int l = 0, r = 0;
for (int j = 0; j < n; j++) {
if (i & (1 << j)) r += a[j];
else l += a[j];
}
MIN = min(MIN, max(l, r));
}
res += MIN;
}

void solve() {
int x[4] {};
for (int i = 0; i < 4; i++) cin >> x[i];

for (int i = 0; i < 4; i++) {
for (int j = 0; j < x[i]; j++) cin >> a[j];
fun(x[i]);
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

dfs代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 25;

int a[N], res;
int MIN; // 每一组数据划分为 2 组后每一组的最大和值

// 第 idx 个数分到某一组时左组之和 ls,右组之和 rs
void dfs(int idx, bool isRight, int ls, int rs, int n) {
if (idx == n) {
MIN = min(MIN, max(ls, rs));
return;
}

if (ls > MIN || rs > MIN) {
// 剪枝:当前左组之和 or 右组之和比最大和值都大
return;
}

if (isRight) rs += a[idx];
else ls += a[idx];

dfs(idx + 1, false, ls, rs, n);
dfs(idx + 1, true , ls, rs, n);
}

void solve() {
int x[4] {};
for (int i = 0; i < 4; i++) cin >> x[i];

for (int i = 0; i < 4; i++) {
for (int j = 0; j < x[i]; j++) cin >> a[j];

MIN = 20 * 60;
dfs(0, false, 0, 0, x[i]);
dfs(0, true , 0, 0, x[i]);
res += MIN;
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs/二进制枚举】PERKET

https://www.luogu.com.cn/problem/P2036

题意:给定一个二元组序列,每一个二元组中,一个表示酸度,一个表示苦度,现在在至少需要选择一个二元组的情况下,希望酸度之积与苦度之和的差值最小

  • 思路一:二进制枚举

    很显然的一个二进制枚举。每一个二元组都只有两个状态,即被选择 or 不被选择,故可采用二进制枚举,对于每一个状态统计酸度之积与苦度之和即可

    时间复杂度:O(2n)O(2^n)

  • 思路二:dfs

    按照上述二进制枚举的思路进行模拟即可,本题无法剪枝,因为酸度与苦度之差没有规律

    时间复杂度:O(2n)O(2^n)

二进制枚举代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 15;

int n;
int res = 1e9;

struct {
int l, r;
} a[N];

void solve() {
cin >> n;
for (int i = 0; i < n; i++) cin >> a[i].l >> a[i].r;

for (int i = 0; i <= (1 << n); i++) {
// 特判没有调料的情况
int cnt = 0;
for (int j = 0; j < n; j++)
if (i & (1 << j))
cnt++;

if (!cnt) continue;

// 计算两个味道的差值
int ls = 1, rs = 0;
for (int j = 0; j < n; j++)
if (i & (1 << j))
ls *= a[j].l, rs += a[j].r;

res = min(res, abs(ls - rs));
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

dfs代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 15;

int n;
int res = 1e9;

struct {
int l, r;
} a[N];

// 当前决策元素的下标idx,是否被选择choose,酸度之积ls,苦度之和rs
void dfs(int idx, bool choose, int ls, int rs) {
if (idx == n) {
if (ls == 1 && rs == 0) return;
res = min(res, abs(ls - rs));
return;
}

if (choose) {
ls *= a[idx].l;
rs += a[idx].r;
}

dfs(idx + 1, false, ls, rs);
dfs(idx + 1, true , ls, rs);
}

void solve() {
cin >> n;
for (int i = 0; i < n; i++) cin >> a[i].l >> a[i].r;

dfs(0, false, 1, 0);
dfs(0, true , 1, 0);

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs】迷宫

https://www.luogu.com.cn/problem/P1605

题意:矩阵寻路,有障碍物,每一个点只能走一次,找到起点到终点可达路径数

思路:其实就是一个四叉树的题目,只需要进行四个方向的遍历搜索即可找到路径,由于会进行:越界、障碍物、以及不可重复遍历的剪枝,故遍历的数量会很少

时间复杂度:<<O(4nm)<< O(4^{nm})

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 10;

int n, m, k;
int aa, bb, cc, dd;
int g[N][N], vis[N][N];
int res;

int dx[] = {-1, 1, 0, 0}, dy[] = {0, 0, 1, -1};

void dfs(int x, int y) {
if (x == cc && y == dd) {
res++;
return;
}

for (int k = 0; k < 4; k++) {
int xx = x + dx[k], yy = y + dy[k];
if (xx > 0 && xx <= n && yy > 0 && yy <= m && !g[xx][yy] && !vis[xx][yy]) {
vis[x][y] = true;
dfs(xx, yy);
vis[x][y] = false;
}
}
}

void solve() {
cin >> n >> m >> k;
cin >> aa >> bb >> cc >> dd;

while (k--) {
int x, y;
cin >> x >> y;
g[x][y] = 1;
}

dfs(aa, bb);

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs】单词方阵

https://www.luogu.com.cn/problem/P1101

  • 题意:给定一个字符串方阵,问对于其中的 8 个方向,是否存在一个指定的字符串
  • 思路:很显然的暴力枚举,只不过采用 dfs 进行优化,我们可以发现搜索的逻辑非常的简单,只需要在约束方向的情况下每次遍历八个方向即可。本题的关键在于如何快速正确的编码。对于八个角度判断是否与原始方向一致,我们采用增量的思路,只需要通过传递父结点的位置坐标即可唯一确定,因为两点确定一条了一条射线,方向也就确定了。其次就是如何构造答案矩阵,思路与两点确定射线的逻辑类似,我们在抵达搜索的终点时,只需要通过当前点的坐标与父结点的坐标唯一确定来的路径的方向,进行构造即可
  • 时间复杂度:难以计算,但是 dfs 一定可行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 110;

int n;
char g[N][N], res[N][N], s[10] = "yizhong";

// oa,ob 父结点坐标,a,b 当前结点坐标,idx 当前匹配的位置
void dfs(int oa, int ob, int a, int b, int idx) {
if (s[idx] != g[a][b]) return;

if (idx == 6) {
int i = a, j = b;
for (int t = 6; t >= 0; t--) {
res[i][j] = s[t];
i -= a - oa;
j -= b - ob;
}
return;
}

int dx[] = {1, -1, 0, 0, 1, -1, -1, 1}, dy[] = {0, 0, 1, -1, 1, 1, -1, -1};

for (int k = 0; k < 8; k++) {
int x = a + dx[k], y = b + dy[k];
if (x < 1 || x > n || y < 1 || y > n) continue;

if ((oa == -1 && ob == -1) || (x - a == a - oa && y - b == b - ob))
dfs(a, b, x, y, idx + 1);
}
}

void solve() {
cin >> n;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
cin >> g[i][j], res[i][j] = '*';

for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
dfs(-1, -1, i, j, 0);

for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++)
cout << res[i][j];
cout << "\n";
}
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs】自然数的拆分问题

https://www.luogu.com.cn/problem/P2404

  • 题意:给定一个自然数 n,问有多少种拆分方法能将该数拆分为一定数量的升序自然数之和
  • 思路:树形搜索的例题。我们采用递增凑数的思路,对于每一个结点值,我们从其父结点的值开始深度搜索,从而确保了和数递增。递归终点就是和值为自然数 n 的值。剪枝就是和值超过了自然数 n 的值
  • 时间复杂度:<<O(nn)<<O(n^n)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <bits/stdc++.h>
#define int long long
using namespace std;

int n;
vector<int> res;

// now 表示当前结点的值,sum 表示根到当前结点的路径之和
void dfs(int now, int sum) {
if (sum > n) return;

if (sum == n) {
for (int i = 0; i < res.size(); i++)
cout << res[i] << "+\n"[i == res.size() - 1];
return;
}

for (int i = now; i < n; i++) {
res.push_back(i);
dfs(i, sum + i);
res.pop_back();
}
}

void solve() {
cin >> n;

for (int i = 1; i < n; i++) {
res.push_back(i);
dfs(i, i);
res.pop_back();
}
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs】Lake Counting S

https://www.luogu.com.cn/problem/P1596

  • 题意:给定一个矩阵,计算其中连通块的数量
  • 思路:直接逐个元素遍历即可,对于每一个元素,我们采用 dfs 或者 bfs 的方式进行打标签从而将整个连通块都标记出来即可
  • 时间复杂度:O(nm)O(nm)

dfs 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 110;

int n, m;
char g[N][N];
bool vis[N][N];
int res, dx[] = {1, -1, 0, 0, 1, 1, -1, -1}, dy[] = {0, 0, 1, -1, 1, -1, 1, -1};

void dfs(int i, int j) {
if (i < 1 || i > n || j < 1 || j > m || vis[i][j] || g[i][j] != 'W') return;

vis[i][j] = true;

for (int k = 0; k < 8; k++) {
int x = i + dx[k], y = j + dy[k];
dfs(x, y);
}
}

void solve() {
cin >> n >> m;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
cin >> g[i][j];

for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
if (!vis[i][j] && g[i][j] == 'W')
dfs(i, j), res++;

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

bfs 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 110;

int n, m;
char g[N][N];
bool vis[N][N];
int res, dx[] = {1, -1, 0, 0, 1, 1, -1, -1}, dy[] = {0, 0, 1, -1, 1, -1, 1, -1};

void bfs(int i, int j) {
queue<pair<int, int>> q;

q.push({i, j});
vis[i][j] = true;

while (q.size()) {
auto h = q.front();
q.pop();

int x = h.first, y = h.second;
for (int k = 0; k < 8; k++) {
int xx = x + dx[k], yy = y + dy[k];
if (!vis[xx][yy] && g[xx][yy] == 'W') {
q.push({xx, yy});
vis[xx][yy] = true;
}
}
}
}

void solve() {
cin >> n >> m;
for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
cin >> g[i][j];

for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
if (!vis[i][j] && g[i][j] == 'W')
bfs(i, j), res++;

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs】填涂颜色

https://www.luogu.com.cn/problem/P1162

  • 题意:给定一个方阵,其中只有 0 和 1,其中的 1 将部分的 0 围成了一个圈,现在需要将被围住的 0 转为 2 后,将转化后的方阵输出
  • 思路:题意很简单,思路也很显然,我们要做的就是区分开圈内与圈外的 0,如何区分呢?我们采用搜索打标记的方式将外圈的 0 全部打标记之后,遇到的没有打标记的 0 显然就是圈内的了。为了满足所有情况下圈内的 0,我们从方阵的四条边进行探测式打标签即可
  • 时间复杂度:O(n2)O(n^2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 35;

int n, g[N][N];
int dx[] = {0, 0, 1, -1}, dy[] = {1, -1, 0, 0};
bool vis[N][N];

void dfs(int i, int j) {
if (i < 1 || i > n || j < 1 || j > n || g[i][j] == 1 || vis[i][j]) return;

vis[i][j] = true;

for (int k = 0; k < 4; k++) {
int x = i + dx[k], y = j + dy[k];
dfs(x, y);
}
}

void solve() {
cin >> n;

for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
cin >> g[i][j];

for (int i = 1; i <= n; i++) if (g[1][i] == 0 && !vis[1][i]) dfs(1, i);
for (int i = 1; i <= n; i++) if (g[i][n] == 0 && !vis[i][n]) dfs(i, n);
for (int i = 1; i <= n; i++) if (g[n][i] == 0 && !vis[n][i]) dfs(n, i);
for (int i = 1; i <= n; i++) if (g[i][1] == 0 && !vis[i][1]) dfs(i, 1);

for (int i = 1; i <= n; i++)
for (int j = 1; j <= n; j++)
if (vis[i][j]) cout << 0 << " \n"[j == n];
else if (g[i][j] == 0) cout << 2 << " \n"[j == n];
else cout << g[i][j] << " \n"[j == n];
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【bfs】马的遍历

https://www.luogu.com.cn/problem/P1443

  • 题意:给定一个矩阵棋盘范围与一个马的位置坐标,现在问棋盘中每一个点的最小到达距离是多少
  • 思路:很显然的一个 bfs 宽搜模型,我们只需要从该颗马的起始位置开始宽搜,对于每一个第一个搜索到的此前没有到达的点,计算此时到起点的步长距离就是最小到达距离
  • 时间复杂度:O(n×m)O(n \times m)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 410;

struct Idx {
int x, y;
};

int n, m, x, y;
int d[N][N];
int dx[] = {1, 1, -1, -1, 2, 2, -2, -2}, dy[] = {2, -2, 2, -2, 1, -1, 1, -1};

void bfs() {
queue<Idx> q;
q.push({x, y});
d[x][y] = 0;

while (q.size()) {
auto h = q.front();
q.pop();

for (int k = 0; k < 8; k++) {
int i = h.x + dx[k], j = h.y + dy[k];
if (i < 1 || i > n || j < 1 || j > m || d[i][j] != -1) continue;
d[i][j] = d[h.x][h.y] + 1;
q.push({i, j});
}
}
}

void solve() {
cin >> n >> m >> x >> y;

for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
d[i][j] = -1;

bfs();

for (int i = 1; i <= n; i++)
for (int j = 1; j <= m; j++)
cout << d[i][j] << " \n"[j == m];
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【bfs】奇怪的电梯

https://www.luogu.com.cn/problem/P1135

  • 题意:给定一个电梯,第 i 层只能上升或者下降 a[i] 层,问从起点开始到终点最少需要乘坐几次电梯
  • 思路:很显然的一个宽搜,关键在于需要对打标记避免重复访问结点造成死循环
  • 时间复杂度:O(n)O(n)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 210;

int n, x, y, a[N];
int d[N]; // d[i] 表示起点到第 i 层楼的最小操作次数
bool vis[N];

void bfs() {
queue<int> q;

q.push(x);
d[x] = 0;
vis[x] = true;

while (q.size()) {
int now = q.front();
q.pop();

if (now == y) break;

int high = now + a[now], low = now - a[now];

if (!vis[high] && high >= 1 && high <= n) {
q.push(high);
d[high] = d[now] + 1;
vis[high] = true;
}
if (!vis[low] && low >= 1 && low <= n) {
q.push(low);
d[low] = d[now] + 1;
vis[low] = true;
}
}
}

void solve() {
cin >> n >> x >> y;
for (int i = 1; i <= n; i++) {
cin >> a[i];
d[i] = -1;
}

bfs();

cout << d[y] << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs/状压dp】吃奶酪

题意:给定一个平面直角坐标系与 n 个点的坐标,起点在坐标原点。问如何选择行进路线使得到达每一个点且总路程最短

  • 思路一:爆搜。题中的 n15n \le 15 直接无脑爆搜,但是 TLE。爆搜的思路为:每次选择其中的一个点,接下来选择剩余的没有被选择过的点继续搜索,知道所有的点全部都搜到为止

    时间复杂度:O(n!)O(n!)

  • 思路二:状态压缩 DP。

    时间复杂度:O()O()

爆搜代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 20;

int n;
double res = 4000.0;
bool vis[N];

struct Idx {
double x, y;
} a[N];

double d(Idx be, Idx en) {
return sqrt((be.x - en.x) * (be.x - en.x) + (be.y - en.y) * (be.y - en.y));
}

// 父结点坐标 fa,当前结点次序 now,当前路径长度 len
void dfs(Idx fa, int now, double len) {
vis[now] = true;

if (count(vis + 1, vis + n + 1, true) == n) {
res = min(res, len);
}

for (int i = 1; i <= n; i++)
if (!vis[i])
dfs(a[now], i, len + d(a[now], a[i]));

vis[now] = false;
}

void solve() {
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i].x >> a[i].y;
}

for (int i = 1; i <= n; i++) {
Idx fa = {0, 0};
dfs(fa, i, d(fa, a[i]));
}

cout << fixed << setprecision(2) << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

状压 dp 代码

1

【dfs】递归实现指数型枚举

https://www.acwing.com/problem/content/94/

  • 题意:给定 n 个数,从其中选择 0-n 个数,将所有的选择方案打印出来,每一个方案中数字按照升序排列
  • 思路:很显然的一个二叉树问题。每一个数只有两种状态,选择 or 不选择,于是可以采用二进制枚举 or 二叉树 dfs 的方法进行。为了满足升序,二进制枚举时从低位到高位判断是否为 1 即可;搜索时从低位开始搜索,通过一个动态数组存储搜索路径上的数字即可
  • 时间复杂度:O(2n)O(2^n)

二进制枚举

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <bits/stdc++.h>
#define int long long
using namespace std;

int n;

void solve() {
cin >> n;

for (int i = 0; i < (1 << n); i++) {
for (int j = 0; j < n; j++) {
if (i & (1 << j)) {
cout << j + 1 << ' ';
}
}
cout << "\n";
}
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

dfs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <bits/stdc++.h>
#define int long long
using namespace std;

int n;
vector<int> a;

void dfs(int now, bool cho) {
if (now == n) {
for (auto& x: a) {
cout << x << ' ';
}
cout << "\n";
return;
}

dfs(now + 1, false);

a.push_back(now + 1);
dfs(now + 1, true);
a.pop_back();
}

void solve() {
cin >> n;

dfs(1, false);

a.push_back(1);
dfs(1, true);
a.pop_back();
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs】递归实现排列型枚举

https://www.acwing.com/problem/content/96/

  • 题意:按照字典序升序,打印 n 个数的所有全排列情况
  • 思路:从第一位开始枚举每一位可以选择的数,显然每一位可选择数的数量逐渐减少,直到只有一种选择结束搜索
  • 时间复杂度:O(n!)O(n!)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 10;

int n;
bool vis[N];
vector<int> a;

// 当前数位 now
void dfs(int now) {
if (now > n) {
for (auto& x: a) {
cout << x << ' ';
}
cout << "\n";
return;
}

for (int i = 1; i <= n; i++) {
if (!vis[i]) {
a.push_back(i);
vis[i] = true;
dfs(now + 1);
a.pop_back();
vis[i] = false;
}
}
}

void solve() {
cin >> n;
dfs(1);
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【dfs/树形dp】树的直径

树的路径问题,考虑回溯。如何更新值?如何返回值?

参考:https://www.bilibili.com/video/BV17o4y187h1/

【dfs】在带权树网络中统计可连接服务器对数目

https://leetcode.cn/problems/count-pairs-of-connectable-servers-in-a-weighted-tree-network/description/

题意:给定一棵带权无根树,定义「中转结点」为以当前结点为根,能够寻找到两个不同分支下的结点,使得这两个结点到当前结点的简单路径长度可以被给定值 k 整除,问树中每一个结点的「中转结点」的有效对数是多少

思路:dfs+乘法原理。

  • 由于数据量是 n=103n=10^3,可以直接枚举每一个顶点,并且每一个顶点的操作可以是 O(n)O(n) 的,我们考虑遍历。对于每一个结点,对应的有效对数取决于每一个子树中的简单路径长度合法的结点数,通过深搜统计即可
  • 统计出每一个子树的合法结点后还需要进行答案的计算,也就是有效对数的统计。对于每一个合法结点,都可以和非当前子树上的所有结点结合形成一个合法有效对,直接这样统计会导致结果重复计算一次,因此需要答案除以二。当然也可以利用乘法原理,一边统计每一个子树中有效的结点数,一边和已统计过的有效结点数进行计算

时间复杂度:O(n2)O(n^2)

注:总结本题根本原因是提升对建图的理解以及针对 vector 用法的总结

  • 关于建图
    • 一开始编码时,我设置了结点访问状态数组 vector<bool> vis 和每一个结点到当前根结点的距离数组 vector<int> d,但其实都可以规避,因为本题是「树」形图,可以通过在深搜时同时传递父结点来规避掉 vis 数组的使用
    • 同时由于只需要在遍历时计算路径是否合法从而计数,因此不需要存储每一个结点到当前根结点的路径值,可以通过再增加一个搜索状态参数来规避掉 d 数组的使用
  • 关于 vector
    • 一开始使用了全局 vis 数组,因此每次都需要进行清空操作。我使用了 .clear() 方法试图重新初始化数组,但这导致了状态的错误记录,可能是 LeetCode 平台 C++ 语言特有的坑,还是少用全局变量
    • .clear() 方法会导致 .size() 为 0,但是仍然可以通过 [] 方法获得合法范围内的元素值,这是 vector 内存分配优化的结果
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Solution {
public:
vector<int> countPairsOfConnectableServers(vector<vector<int>>& edges, int signalSpeed) {
int n = edges.size() + 1;

struct node { int to, w; };
vector<vector<node>> g(n, vector<node>());
for (auto e: edges) {
int u = e[0], v = e[1], w = e[2];
g[u].push_back({v, w});
g[v].push_back({u, w});
}

function<int(int, int, int)> dfs = [&](int fa, int now, int d) {
int res = d % signalSpeed == 0;
for (auto ch: g[now]) {
if (ch.to != fa) {
res += dfs(now, ch.to, d + ch.w);
}
}
return res;
};

vector<int> res(n);
for (int i = 0; i < n; i++) {
int sum = 0;
for (auto ch: g[i]) {
int cnt = dfs(i, ch.to, ch.w);
res[i] += cnt * sum;
sum += cnt;
}
}

return res;
}
};
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution:
def countPairsOfConnectableServers(self, edges: List[List[int]], signalSpeed: int) -> List[int]:
n = len(edges) + 1

g = [[] for _ in range(n)]
for u, v, w in edges:
g[u].append((v, w))
g[v].append((u, w))

def dfs(fa: int, now: int, d: int) -> int:
ret = d % signalSpeed == 0
for ch in g[now]:
if ch[0] != fa:
ret += dfs(now, ch[0], d + ch[1])
return ret

res = [0] * n
for i in range(n):
sum = 0
for ch in g[i]:
cnt = dfs(i, ch[0], ch[1])
res[i] += sum * cnt
sum += cnt

return res

【dfs】将石头分散到网格图的最少移动次数

https://leetcode.cn/problems/minimum-moves-to-spread-stones-over-grid/

标签:搜索、全排列、库函数

题意:给定一个 3×33\times 3 的矩阵 gg,其中数字总和为 9 且 g[i][j]0g[i][j] \ge 0,现在需要将其中 >1>1 的数字逐个移动到值为 0 的位置上使得最终矩阵全为 1,问最少移动长度是多少。

思路一:手写全排列

  • 思路:可以将这道题抽象为求解「将 a 个大于 1 位置的数分配到 b 个 0 位置」的方案中的最小代价问题。容易联想到全排列选数的母问题:数字的位数对应这里的 b 个 0 位置,每个位置可以填的数对应这里的用哪个 a 来填。区别在于:0 的位置顺序不是固定的,用哪个 a 来填的顺序也不是固定的。这与全排列数中:被填的位置顺序是固定的,用哪个数来填不是固定的,有所区别。因此我们可以全排列枚举每一个位置,在此基础之上再全排列枚举每一个位置上可选的 a 进行填充。可以理解为全排列的嵌套。那么最终递归树的深度就是 0 的个数,递归时再用一个参数记录每一个选数分支对应的代价即可。
  • 时间复杂度:O(9×9!)O(9\times 9!)

思路二:库函数全排列

  • 思路:由于方阵的总和为 9,因此 >1 的位置上减去 1 剩下的数值之和一定等于方阵中 0 的个数。因此我们可以将前者展开为和 0 相同大小的向量,并全排列枚举任意一者进行两者的匹配计算,维护其中的最小代价即是答案。
    • C++ 的全排列枚举库函数为 std::next_permutation(ItFirst, ItEnd)
    • Python 的全排列枚举库函数为 itertools.permutations(Iterable)
  • 时间复杂度:O(9×9!)O(9\times 9!)
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class Solution {
public:
int minimumMoves(vector<vector<int>>& g) {
vector<pair<int, int>> z, a;
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
if (!g[i][j]) {
z.push_back({i, j});
} else if (g[i][j] > 1) {
a.push_back({i, j});
}
}
}

int res = INT_MAX, n = z.size();
vector<bool> vis(n);

auto dfs = [&](auto&& dfs, int dep, int t) -> void {
if (dep == n) {
res = min(res, t);
return;
}

for (int i = 0; i < n; i++) {
if (vis[i]) continue;
vis[i] = true;
for (auto& [x, y]: a) {
if (g[x][y] <= 1) continue;
g[x][y]--;
dfs(dfs, dep + 1, t + abs(z[i].first - x) + abs(z[i].second - y));
g[x][y]++;
}
vis[i] = false;
}
};

dfs(dfs, 0, 0);

return res;
}
};
[C++库函数]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Solution {
public:
int minimumMoves(vector<vector<int>>& g) {
vector<pair<int, int>> z, a;
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
if (!g[i][j]) {
z.push_back({i, j});
} else {
while (g[i][j] > 1) {
a.push_back({i, j});
g[i][j]--;
}
}
}
}

int res = INT_MAX;
do {
int t = 0;
for (int i = 0; i < z.size(); i++) {
t += abs(a[i].first - z[i].first) + abs(a[i].second - z[i].second);
}
res = min(res, t);
} while (next_permutation(a.begin(), a.end()));

return res;
}
};
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution:
def minimumMoves(self, g: List[List[int]]) -> int:
a, z = [], []
for i in range(3):
for j in range(3):
if not g[i][j]:
z.append((i, j))
elif g[i][j] > 1:
a.append((i, j))

res = 1000
n = len(z)
vis = [False] * n

def dfs(dep: int, t: int) -> None:
nonlocal res
if dep == n:
res = min(res, t)
return
for i in range(n):
if vis[i]: continue
vis[i] = True
for x, y in a:
if g[x][y] <= 1: continue
g[x][y] -= 1
dfs(dep + 1, t + abs(z[i][0] - x) + abs(z[i][1] - y))
g[x][y] += 1
vis[i] = False

dfs(0, 0)

return res
[Python库函数]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution:
def minimumMoves(self, g: List[List[int]]) -> int:
from itertools import permutations
a, z = [], []
for i in range(3):
for j in range(3):
if not g[i][j]:
z.append((i, j))
while g[i][j] > 1:
a.append((i, j))
g[i][j] -= 1

res, n = 1000, len(a)
for p in permutations(a):
t = 0
for i in range(n):
t += abs(p[i][0] - z[i][0]) + abs(p[i][1] - z[i][1])
res = min(res, t)

return res
]]>
+ 动态规划

动态规划分为被动转移和主动转移,而其根本在于状态表示和状态转移。如何完整表示所有状态?如何不重不漏划分子集从而进行状态转移?

被动转移 vs 主动转移

【递推】反转字符串

https://www.acwing.com/problem/content/5574/

题意:给定 n 个字符串,每一个字符串对应一个代价 wiw_i,现在需要对这 n 个字符串进行可能的翻转操作使得最终的 n 个字符串呈现字典序上升的状态,给出最小翻转代价。

思路:很显然每一个字符串都有两种状态,我们可以进行二叉搜索或者二进制枚举。那么我们可以进行 dp 吗?答案是可以的。我们可以发现,对于第 i 个字符串,是否需要翻转仅仅取决于第 i-1 个字符串的大小,无后效性,可以进行递推。我们定义状态数组 f[i][j] 进行状态标识。其中

  • f[i][0] 表示第 i 个字符串不需要翻转时的最小代价
  • f[i][1] 表示第 i 个字符串需要翻转时的最小代价

状态转移就是当前一个字符串比当前字符串的字典序小时进行转移,即当前最小代价是前一个状态的最小代价加上当前翻转状态的代价。至于什么时候可以进行状态转移,一共有 4 种情况,即:

  • s[i-1] 不翻转,s[i] 不翻转。此时 f[i][0] = min(f[i][0], f[i-1][0])
  • s[i-1] 翻转,s[i] 不翻转。此时 f[i][0] = min(f[i][0], f[i-1][1])
  • s[i-1] 不翻转,s[i] 翻转。此时 f[i][1] = min(f[i][1], f[i-1][0] + w[i])
  • s[i-1] 翻转,s[i] 翻转。此时 f[i][1] = min(f[i][1], f[i-1][1] + w[i])

最终答案就在 f[n][0]f[n][1] 中取 min 即可

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
using ll = long long;

const int N = 100010;

int n, w[N];
string s[N][2]; // s[i][0] 表示原始字符串,s[i][1] 表示反转后的字符串
ll f[N][2]; // f[i][j] 表示第i个字符串在翻转状态为j的情况下的最小代价

ll dp() {
f[1][0] = 0, f[1][1] = w[1];
for (int i = 2; i <= n; i++) {
if (s[i - 1][0] <= s[i][0]) f[i][0] = min(f[i][0], f[i - 1][0]);
if (s[i - 1][1] <= s[i][0]) f[i][0] = min(f[i][0], f[i - 1][1]);
if (s[i - 1][0] <= s[i][1]) f[i][1] = min(f[i][1], f[i - 1][0] + w[i]);
if (s[i - 1][1] <= s[i][1]) f[i][1] = min(f[i][1], f[i - 1][1] + w[i]);
}
return min(f[n][0], f[n][1]);
}

int main() {
cin >> n;
for (int i = 1; i <= n; i++) cin >> w[i];
for (int i = 1; i <= n; i++) {
cin >> s[i][0];
s[i][1] = s[i][0];
reverse(s[i][1].begin(), s[i][1].end());
}
memset(f, 0x3f, sizeof f);

ll res = dp();
if (res == 0x3f3f3f3f3f3f3f3fll) res = -1;
cout << res << "\n";

return 0;
}

【递推】最大化子数组的总成本

https://leetcode.cn/problems/maximize-total-cost-of-alternating-subarrays/

题意:给定长度为 n 的序列,现在需要将其分割为子数组,并定义每一个子数组的价值为「奇数位置为原数值,偶数位置为相反数」,返回最大分割价值

思路:~~一开始还在想着如何贪心,即仅考虑连续的负数区间,但是显然的贪不出全局最优解。~~于是转而考虑暴力 dp。思路和朴素 01 背包有相似之处。

  • 状态定义。首先我们一定需要按照元素进行枚举,因此第一维度我们就定义为序列长度,但是仅仅这样定义就能表示全部的状态了吗?显然不行。根据题目的奇偶价值约束,我们在判定每一个负数是否可以「取相反数」价值时,是取决于上一个负数是否取了相反数价值。为了统一化,我们将正负数一起考虑前一个数是否取了相反价值的情况,因此我们需要再增加「前一个数是否取了相反价值」的状态表示,而这仅仅是一个二值逻辑,直接开两个空间即可。于是状态定义呼之欲出:

    • f[i][0] 表示第 i 个数取原始价值的情况下,序列 [0, i-1] 的最大价值
    • f[i][1] 表示第 i 个数取相反价值的情况下,序列 [0, i-1] 的最大价值
  • 状态转移。显然对于上述状态定义,仅仅需要对「连续负数区间」进行考虑,因此对于 nums[i] >= 0 的情况是没有选择约束的,原始价值和相反价值都是可行的方案那么为了最大化最终收益显然选择原始正价值,并且可以从上一个情况的任意状态转移过来那么同样为了最大化最终受益显然选择最大的基状态,于是可得:

    • f[i][0] = max(f[i-1][0], f[i-1][1]) + nums[i]
    • f[i][1] = max(f[i-1][0], f[i-1][1]) - nums[i]

    而对于 nunms[i] < 0 的情况,能否选择相反数取决于前一个数的正负性。当前一个数 nums[i-1] >= 0 时,显然当前负数原始价值和相反价值都可以取到;当前一个数 nums[i-1] < 0 时,则当前负数仅有在前一个负数取原始价值的情况下才能取相反价值,于是可得:

    if nums[i-1] >= 0

    • f[i][0] = max(f[i-1][0], f[i-1][1]) + nums[i]
    • f[i][1] = max(f[i-1][0], f[i-1][1] - nums[i])

    if nums[i-1] < 0

    • f[i][0] = max(f[i-1][0], f[i-1][1] + nums[i])
    • f[i][1] = f[i-1][0] - nums[i]
  • 答案表示。max(f[n-1][0], f[n-1][1])

时间复杂度:O(n)O(n)

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
long long maximumTotalCost(vector<int>& nums) {
using ll = long long;

int n = nums.size();

vector<vector<ll>> f(n, vector<ll>(2, 0));
f[0][0] = f[0][1] = nums[0];
for (int i = 1; i < n; i++) {
if (nums[i] >= 0 || nums[i-1] >= 0) {
f[i][0] = max(f[i-1][0], f[i-1][1]) + nums[i];
f[i][1] = max(f[i-1][0], f[i-1][1]) - nums[i];
} else {
f[i][0] = max(f[i-1][0], f[i-1][1]) + nums[i];
f[i][1] = f[i-1][0] - nums[i];
}
}

return max(f[n-1][0], f[n-1][1]);
}
};
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution:
def maximumTotalCost(self, nums: List[int]) -> int:
n = len(nums)

f = [[0] * 2 for _ in range(n)]
f[0][0] = f[0][1] = nums[0]
for i in range(1, n):
if nums[i] >= 0 or nums[i-1] >= 0:
f[i][0] = max(f[i-1][0], f[i-1][1]) + nums[i]
f[i][1] = max(f[i-1][0], f[i-1][1]) - nums[i]
else:
f[i][0] = max(f[i-1][0], f[i-1][1]) + nums[i]
f[i][1] = f[i-1][0] - nums[i]

return max(f[n-1][0], f[n-1][1])

【递推】费解的开关

https://www.acwing.com/problem/content/97/

题意:给定 n 个 5*5 的矩阵,代表当前局面。矩阵中每一个元素要么是 0 要么是 1,现在需要计算从当前状态操作到全 1 状态最少需要几次操作?操作描述为改变当前状态为相反状态后,四周的四个元素也需要改变为相反的状态

思路:我们采用递推的思路。为了尽可能少的进行按灯操作,我们从第二行开始考虑,若前一行的某一元素为 0,则下一行的同一列位置就需要按一下,以此类推将 252 \to 5 行全部按完。现在考虑两点,当前按下状态是否合法?当前按下状态是是否最优?

  1. 对于第一个问题:从上述思路可以看出,1n11 \to n-1 行一定全部都是 1 的状态,但是第 n1n-1 行不一定全 1,因此不合法状态就是第 n1n-1 行不全为 1
  2. 对于第二个问题:可以发现,上述算法思路中,对于第一行是没有任何操作的(可以将第一行看做递推的初始化条件),第一行的状态影响全局的总操作数,我们不能确定不对第一行进行任何操作得到的总操作数就是最优的,故我们需要对第一行 5 个灯进行枚举按下。我们采用 5 位二进制的方法对第一行的 5 个灯进行枚举按下操作,然后对于当前第一行的按下局面(递推初始化状态)进行 2n2 \to n 行的按下递推操作。对于每一种合法状态更新最小的操作数即可

时间复杂度:O(T×25×25×5)O(T \times 2^5 \times 25 \times 5)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 10;

char g[N][N], now[N][N];
int dx[] = {0, 1, -1, 0, 0}, dy[] = {0, 0, 0, 1, -1};

void turn(int x, int y) {
for (int k = 0; k < 5; k++) {
int nx = x + dx[k], ny = y + dy[k];
now[nx][ny] ^= 1;
}
}

void solve() {
for (int i = 1; i <= 5; i++) {
cin >> (g[i] + 1);
}

int res = 30;

for (int op = 0; op < (1 << 5); op++) {
memcpy(now, g, sizeof g);
int step = 0;

// 统计第 1 行的按下次数
for (int i = 0; i < 5; i++) {
if (op & (1 << i)) {
step++;
turn(1, 5 - i);
}
}

// 统计 2 ~ 5 行的按下次数
for (int i = 1; i <= 4; i++) {
for (int j = 1; j <= 5; j++) {
if (now[i][j] == '0') {
step++;
turn(i + 1, j);
}
}
}

// 判断当前操作方案是否合法
bool ok = true;
for (int j = 1; j <= 5; j++) {
if (now[5][j] == '0') {
ok = false;
}
}

// 若当前操作方案合法则更新最小操作次数
if (ok) {
res = min(res, step);
}
}

cout << (res > 6 ? -1 : res) << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
cin >> T;
while (T--) solve();
return 0;
}

【递推/线性dp/哈希】Decode

https://codeforces.com/contest/1996/problem/E

题意:给定一个01字符串,问所有区间中01数量相等的子串总共有多少个。

思路:

  • 我们可以最直接的想到 O(n5)O(n^5) 的暴力做法,即 O(n2)O(n^2) 枚举区间的左右端点,O(n2)O(n^2) 枚举区间中子串的左右端点,最后 O(n)O(n) 进行检查计数。当然可以用前缀和优化掉 O(n)O(n) 的检查计数。但这样做显然无法通过 10510^5 的数据量。考虑别的思路。
  • 容易想到动态规划的思路。我们定义 dp[i] 表示右端点是 s[i] 的所有区间中,合法01子串的数量,那么显然 dp[0] = s[i] == '1'。现在考虑 dp[i] 如何转移。不难发现以 s[i] 为右端点的所有区间一定包含以 s[i - 1] 为右端点的所有区间,多出来的合法子串进存在于以 s[i] 结尾的子串中,我们假设以 s[i] 结尾的合法子串数量为 t,则状态转移方程为:dp[i] = dp[i - 1] + t。显然我们可以用一个滚动变量来代替 dp 数组,记作now,每次维护完 now 以后,将其累加到答案即可。现在我们将枚举区间从 O(n2)O(n^2) 通过递推的思路优化到了 O(n)O(n)。那么如何求解递推式中的 t 呢?
  • 通过一个小 trick 求解「以 s[i] 结尾的合法子串」的数量。显然可以通过前缀和枚举 j[1,i]j \in [1,i] 统计使得 i - j + 1 == 2 * (pre[i] - pre[j - 1]) 的数量。但是这样就是 O(n2)O(n^2) 的了,仍然无法通过本题。引入 trick:我们稍微修改一下前缀和的维护逻辑,即当 s[i] == '0' 时,将其记作 -1,当 s[i] == '1' 时,保持不变仍然记作 1。这样我们在向前枚举 j 寻找合法区间时,本质上就是在寻找 pre[i] - pre[j - 1] == 0,即 pre[j - 1] == pre[i] 的个数。假设下标从 0 开始,则每找到一个合法的 j,都会对答案贡献 j + 1。因此我们只需要哈希存储每一个前缀和对应的下标累计值即可。

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

void solve() {
string s;
cin >> s;

int n = s.size();
vector<ll> pre(n + 1);
for (int i = 0; i < n; i++) {
pre[i + 1] = pre[i] + (s[i] == '0' ? -1 : 1);
}

auto add = [&](ll x, ll y) {
ll mod = 1e9 + 7;
return ((x % mod) + (y % mod)) % mod;
};

ll res = 0, now = 0;
unordered_map<ll, ll> f;
for (ll i = 0; i <= n; i++) {
now = add(now, f[pre[i]]);
res = add(res, now);
f[pre[i]] += i + 1;
}

cout << res << "\n";
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
std::cin >> T;
while (T--) solve();
return 0;
}

【递推/数学】Squaring

https://codeforces.com/contest/1995/problem/C

题意:给定一个序列,可以对其中任何一个数进行任意次平方操作,即 aiai2a_i \to a_i^2,问最少需要执行多少次操作可以使得序列不减。

思路:

  • 首先显然的我们不希望第一个数 a0a_0 变大,因此我们应该从第二个数开始和前一个数进行比较,从而进行可能的平方操作并计数。但是由于数字可能会很大,使用高精度显然会超时,而这些数字本身的意义是用来和前一个数进行大小比较的,并且变化的形式也仅仅是平方操作。我们不妨维护其指数,这样既可以进行大小比较,也不用存储数字本身而造成溢出。当然变为使用指数存储以后如何进行大小比较以及如何维护指数序列有所不同,我们先从一般的角度出发,进而从表达式本身的数学角度讨论特殊情况的处理。

  • 对于当前数字 aia_i 和前一个数字 ai1a_{i-1} 及其指数 zi1z_{i-1},如果想要当前数字经过可能的 k 次平方操作后不小于前一个数,即 ai12zi1ai2k\displaystyle a_{i-1}^{2^{z_{i-1}}}\le a_i^{2^{k}},则易得下面的表达式:

    k:=zi1+log2(log2ai1log2ai)k:= z_{i-1}+ \left\lceil \log_2(\frac{\log_2 {a_{i-1}}}{\log_2{a_i}}) \right \rceil

    由于 aj[1,106],zj0,k0a_j \in [1, 10^6],z_j\ge 0,k \ge 0,因此我们有必要:

    1. 讨论 aia_iai1a_{i-1}11 的大小关系。因为它们的对数计算结果在真数部分。
    2. 讨论 kk00 的大小关系。因为一旦 ai1<aia_{i-1} < a_i,则有可能出现计算结果为负的情况,这种情况表明需要对当前数 aia_i 进行开根的操作。又由于 ai1a_i\ge1,因此开根操作就表明减小当前的数,也就表明当前数在不进行平方操作的情况下就已经满足不减的条件了,也就不需要操作了。此时对应的指数 ziz_i 就是 00,对于答案的贡献也是 00

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

void solve() {
int n;
cin >> n;

vector<int> a(n);
for (int i = 0; i < n; i++) {
cin >> a[i];
}

vector<ll> z(n, 0);
ll res = 0;
for (int i = 1; i < n; i++) {
if (a[i] == 1 && a[i - 1] > 1) {
cout << -1 << "\n";
return;
} else if (a[i] == 1 && a[i - 1] == 1 || a[i - 1] == 1) {
continue;
}
ll t = z[i - 1] + ceil(log2(log2(a[i - 1]) / log2(a[i])));
z[i] = max(0ll, t);
res += z[i];
}

cout << res << "\n";
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
std::cin >> T;
while (T--) solve();
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from typing import List, Tuple
from collections import defaultdict, deque
from itertools import combinations, permutations
import math, heapq, queue

II = lambda: int(input())
FI = lambda: float(input())
MII = lambda: tuple(map(int, input().split()))
LII = lambda: list(map(int, input().split()))


def solve() -> None:
n = II()
a = LII()

z = [0] * n
for i in range(1, n):
if a[i] == 1 and a[i - 1] > 1:
print(-1)
return
elif a[i] == 1 and a[i - 1] == 1 or a[i - 1] == 1:
continue
t = z[i - 1] + math.ceil(math.log2(math.log2(a[i - 1]) / math.log2(a[i])))
z[i] = max(0, t)

print(sum(z))


if __name__ == '__main__':
T = 1
T = II()
while T: solve(); T -= 1

【递推/dfs】牛的语言学

https://www.acwing.com/problem/content/description/5559/

题意:已知一个字符串由前段的一个词根和后段的多个词缀组成。词根的要求是长度至少为 5,词缀的要求是长度要么是 2, 要么是 3,以及不允许连续的相同词缀组成后段。现在给定最终的字符串,问一共有多少种词缀?按照字典序输出所有可能的词缀

  • 思路一:搜索。很显然我们应该从字符串的最后开始枚举词缀进行搜索,因为词缀前面的所有字符全部都可以作为词根合法存在,我们只需要考虑当前划分出来的词缀是否合法即可。约束只有一个,不能与后面划分出来的词缀相同即可,由于我们是从后往前搜索,因此我们在搜索时保留后一个词缀即可。如果当前词缀和上一个词缀相同则返回,反之如果合法则加入 set 自动进行字典序排序。

    时间复杂度:O(2n2)O(2^{\frac{n}{2}})

  • 思路二:动态规划(递推)。动规其实就是 dfs 的逆过程,我们从已知结果判断当前局面是否合法。很显然词根是包罗万象的,只要长度合法,不管什么样的都是可行的,故我们在判断当前局面是否合法时,只需要判断当前词缀是否合法即可。于是便可知当前状态是从后面的字符串转移过来的。我们定义状态转移记忆数组 f[i] 表示字符串 s[1,i] 是否可以组成词根。如果 f[i] 为真,则表示 s[1,i] 为一个合法的词根,s[i+1,n] 为一个合法的词缀串,那么词根后面紧跟着的一定是一个长度为 2 或 3 的合法词缀。

    • 我们以紧跟着长度为 2 的合法词缀为例。如果 s[i+1,i+2] 为一个合法的词缀,则必须要满足以下两个条件之一

      1. s[i+1,i+2]s[i+3,i+4] 不相等,即后面的后面是一个长度也为 2 且合法的词缀
      2. s[1,i+5] 是一个合法的词根,即 f[i+5] 标记为真,即后面的后面是一个长度为 3 且合法的词缀
    • 以紧跟着长度为 3 的哈法词缀同理。如果 s[i+1,i+3] 为一个合法的词缀,则必须要满足以下两个条件之一

      1. s[i+1,i+3]s[i+4,i+6] 不相等,即后面的后面是一个长度也为 3 且合法的词缀
      2. s[1,i+5] 是一个合法的词根,即 f[i+5] 标记为真,即后面的后面是一个长度为 2 且合法的词缀

    时间复杂度:O(nlogn)O(n \log n) - dp 的过程是线性的,主要时间开销在 set 的自动排序上

dfs 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <iostream>
#include <cstring>
#include <vector>
#include <queue>
#include <stack>
#include <algorithm>
#include <unordered_map>
#include <set>
using namespace std;

string s;
set<string> res;

// 当前词缀起始下标 idx,当前词缀长度 length,后方相邻的词缀 post
void dfs(int idx, int length, string post) {
if (idx <= 5) return;

string now = s.substr(idx, length);

if (now == post) {
// 不合法直接返回
return;
} else {
// 合法则将当前词缀加入集合自动排序,并继续搜索接下来可能的词缀
res.insert(now);
dfs(idx - 2, 2, now);
dfs(idx - 3, 3, now);
}
}

void solve() {
cin >> s;
s = "$" + s;

int tail_point = s.size();

dfs(tail_point - 2, 2, "");
dfs(tail_point - 3, 3, "");

cout << res.size() << "\n";
for (auto& str: res) cout << str << "\n";
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

dp 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <iostream>
#include <cstring>
#include <vector>
#include <queue>
#include <stack>
#include <algorithm>
#include <unordered_map>
#include <set>
using namespace std;

const int N = 50010;

string s;
set<string> res;
bool f[N]; // f[i] 表示 s[1,i] 是否可以作为词根存在

void solve() {
cin >> s;
s = "$" + s;

// 字符串定义在 [1,n] 上
int n = s.size() - 1;

// 长度为 n 的字符串一定可以作为词根
f[n] = true;

for (int i = n; i >= 5; i--) {
for (int len = 2; len <= 3; len++) {
if (f[i + len]) {
string a = s.substr(i + 1, len);
string b = s.substr(i + 1 + len, len);
if (a != b || f[i + 5]) {
res.insert(a);
f[i] = true;
}
}
}
}

cout << res.size() << "\n";

for (auto& x: res) cout << x << "\n";
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【线性dp】最小化网络并发线程分配

https://vijos.org/d/nnu_contest/p/1492

题意:现在有一个线性网络需要分配并发线程,每一个网络有一个权重,现在有一个线程分配规则。对于当前网络,如果权重比相邻的网络大,则线程就必须比相邻的网络大。

思路:我们从答案角度来看,对于一个网络,我们想知道它的相邻的左边线程数和右边线程数,如果当前网络比左边和右边的权重都大,则就是左右线程数的最大值+1,当然这些的前提是左右线程数已经是最优的状态,因此我们要先求“左右线程”。分析可知,左线程只取决于左边的权重与线程数,右线程同样只取决于右边的权重和线程数,因此我们可以双向扫描一遍即可求得“左右线程”。最后根据“左右线程”即可求得每一个点的最优状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void solve() {
int n; cin >> n;
vector<int> w(n + 1), l(n + 1, 1), r(n + 1, 1);
for (int i = 1; i <= n; i++) {
cin >> w[i];
}
for (int i = 2, j = n - 1; i <= n && j >= 1; i++, j--) {
if (w[i] > w[i - 1]) {
l[i] = l[i - 1] + 1;
}
if (w[j] > w[j + 1]) {
r[j] = r[j + 1] + 1;
}
}
int res = 0;
for (int i = 1; i <= n; i++) {
res += max(l[i], r[i]);
}
cout << res << "\n";
}

【线性dp】Block Sequence

https://codeforces.com/contest/1881/problem/E

题意:给定一个长为 n2×105n\le 2\times 10^5 的数组 a,问最少可以删除其中的几个元素,使得最终的数组可以被划分为连续的子数组且每一个子数组的元素个数均为「子数组首元素数值 1-1」的形式。

思路:

  • 首先可以看出,序列的首元素一定是对应子数组剩余元素的个数。我们不妨倒序枚举每一个元素并定义 f[i] 表示使数组 a[i:] 符合定义的最小删除次数,这样每次枚举到的元素都一定是所在子数组的首元素。
  • 接下来考虑状态转移。对于当前枚举到的元素 a[i],如果不删除,则 f[i] 将从 f[i+a[i]+1] 转移过来;如果删除,则 f[i] 将从 f[i+1] 转移过来。两者取最小值即可。

时间复杂度 O(n)O(n)

1
2
3
4
5
6
7
8
9
10
def solve() -> Optional:
n = II()
a = LII()
f = [INF] * (n + 1)
f[n] = 0
for i in range(n - 1, -1, -1):
f[i] = f[i + 1] + 1
if i + a[i] + 1 <= n:
f[i] = min(f[i], f[i + a[i] + 1])
return f[0]

【线性dp】覆盖墙壁

https://www.luogu.com.cn/problem/P1990

题意:给定两种砖块,分别为日字型与L型,问铺满 2n2*n 的地板一共有多少种铺法

思路:

  • 我们采用递推的思路
  • define:define: f[i] 表示铺满前 2i2*i 个地板的方案数,g[i] 表示铺满前 2i+12*i+1 块地板的方案数
  • base:base: f[0] = 1, f[1] = 1, g[0] = 0, g[1] = 1
  • dp:dp: f[i] = f[i-1] + f[i-2] + g[i-1] || g[i] = f[i-1] + g[i-1]
  • result:result: f[n]

手绘:

图例

图例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <bits/stdc++.h>
using namespace std;

const int N = 1000010, mod = 10000;

int n;
int f[N], g[N];

void solve() {
cin >> n;

// base
f[0] = 1, f[1] = 1;
g[0] = 0, g[1] = 1;

// dp
for (int i = 2; i <= n; i++) {
f[i] = (f[i - 1] + f[i - 2] + 2 * g[i - 2]) % mod;
g[i] = (f[i - 1] + g[i - 1]) % mod;
}

// result
cout << f[n] << "\n";
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【线性dp】施咒的最大总伤害

https://leetcode.cn/problems/maximum-total-damage-with-spell-casting/

标签:线性 dp

题意:给定一个数组,选择一个子序列使得总和最大。约束为:如果选择了值为 xx 的元素,则不允许选择值为 x2,x1,x+1,x+2x-2,x-1,x+1,x+2 的元素

思路:

  • 哈希计数。首先很显然的我们需要进行哈希计数,便于按数值升序枚举元素。我们定义哈希后的序列为 v,大小为 m,存储每一种数的数值 val: int 和个数 cnt: int
  • 状态定义。我们定义状态 f[i] 表示哈希序列 [1,i] 中子序列总和的最大值,则最终答案就是 f[m]
  • 状态转移。对于每一个哈希元素,都有选择和不选两种状态。假设当前枚举到的哈希元素为 i
    • 不选当前元素。就相当于没有当前元素,则 f[i] = f[i-1]
    • 选择当前元素。我们就需要从哈希序列 [1,i-1] 中,比当前哈希元素 v[i].val 小 2 且对应子序列之和最大的那个状态 f[j] 转移过来。显然我们可以直接枚举哈希序列 [1,i-1],但是这样就是 O(n2)O(n^2) 必然超时,考虑优化。不难发现 f[] 数组是单调递增的并且哈希序列的元素数值 v[].val 也是单调递增的。也就是说对于当前状态 f[i],在枚举哈希序列 [1,i-1] 时,其实并不需要从 1 开始枚举,可以从上一个状态枚举到的状态(记作 j)开始枚举(因为上一个状态对应的 f[j] 是上一个状态可转移的方案中最大的并且 v[j].val 一定比当前方案的哈希数值 v[i].val 小 2)。于是状态转移的时间开销就从 O(n)O(n) 优化到 O(1)O(1),可以通过此题

时间复杂度:O(n)O(n)

空间复杂度:O(n)O(n)

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
public:
long long maximumTotalDamage(vector<int>& power) {
using ll = long long;

// hash
map<int, int> a;
for (int x: power) {
a[x]++;
}
int m = a.size(), idx = 1;
struct node { ll val, cnt; };
vector<node> v(m + 1);
for (auto it: a) {
v[idx++] = {it.first, it.second};
}

// dp
vector<ll> f(m + 1);
f[1] = v[1].val * v[1].cnt;
for (int i = 2, j = 1; i <= m; i++) {
while (j < i && v[j].val < v[i].val - 2) {
j++;
}
j--;

f[i] = max(f[i - 1], f[j] + v[i].val * v[i].cnt);
}

return f[m];
}
};
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution:
def maximumTotalDamage(self, power: List[int]) -> int:
from collections import defaultdict

# hash
a = defaultdict(int)
for x in power:
a[x] += 1
a = sorted(a.items(), key=lambda x: x[0])

class node:
def __init__(self, val: int, cnt: int) -> None:
self.val = val
self.cnt = cnt

m, idx = len(a), 1
v = [node(0, 0)] * (m + 1)
for it in a:
v[idx] = node(it[0], it[1])
idx += 1

# dp
f = [0] * (m + 1)
f[1], j = v[1].val * v[1].cnt, 1
for i in range(2, m + 1):
while j < i and v[j].val < v[i].val - 2:
j += 1
j -= 1

f[i] = max(f[i - 1], f[j] + v[i].val * v[i].cnt)

return f[m]

【线性dp】奇怪的汉诺塔

https://www.acwing.com/problem/content/98/

题意:四塔汉诺塔问题。求在给定 nn 个圆盘的情况下的最少移动方案数。

思路:

  • 我们定义「最小完成单元」为:在满足游戏规则的情况下,将一座塔的所有圆盘移动到另一座塔所需的最少塔数。那么显然的最小完成单元为 3 座塔。定义 d[i] 表示三塔模式下 ii 个盘的最少移动次数,f[i] 表示四塔模式下 ii 个盘的最少移动次数。

  • 对于 4 座塔的情况,相当于最小完成单元又多了 1 座塔。那么显然多出来的这座塔有两个处置方案:

    • 如果我们不利用这座塔。那么还剩 3 座塔,也就是最小完成单元,此时的最少移动次数是唯一的,也就是 f[i] = d[i]

    • 如果我们要利用这座塔。那么我们只能在这一座塔上按规则放圆盘。因为要确保最小完成单元来让剩余的圆盘移动到另一座塔。如果还在别的塔上放置了圆盘,那么将不符合最小完成单元的定义,游戏无法结束。当然我们不能将所有的圆盘都先放到这座塔上,因为这种情况下是不可能成立的,我们不可能让一个「我们正在求解的问题」作为我们的答案。也就是说 j<ij<i

  • 至此四塔模式下 ii 个圆盘游戏方案的组成集合已全部确定,如下图所示,即:在四塔模式下移动 j[0,i1]j \in [0,i-1] 个圆盘到其中一座空塔上,方案数为 f[j]f[j];剩余的 iji-j 个圆盘处于最小完成单元的局面,方案数是唯一的 d[ij]d[i-j];最后再将一开始移动的 jj 个圆盘在四塔模式下移动到刚才剩余圆盘移动到的塔上,方案数为 f[j]f[j]

集合划分

于是最终的状态转移方程为:

fi=min{fj+dij+fj},i[1,n],j[0,i1]f_i = \min{ \{ f_j+d_{i-j}+f_j \} },\quad i \in [1,n],\quad j \in [0,i-1]

时间复杂度:O(n2)O(n^2)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

void solve() {
int n = 12;
vector<int> d(n + 1); // d[i] means move i pans to another tower with 3 towers
vector<int> f(n + 1, 1e9); // f[i] means move i pans to another tower with 4 towers

d[1] = 1;
for (int i = 1; i <= n; i++) {
d[i] = 1 + 2 * d[i - 1];
}
f[1] = 1;
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= i - 1; j++) {
f[i] = min(f[i], f[j] + d[i - j] + f[j]);
}
cout << f[i] << "\n";
}
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
// std::cin >> T;
while (T--) solve();
return 0;
}

【线性dp/dfs】数的计算

https://www.luogu.com.cn/problem/P1028

题意:给定一个数和一种构造方法,即对于当前的数,可以在其后面添加一个最大为当前一半大的数,以此类推构造成一个数列。问一共可以构造出多少个这种数列

思路一:dfs

  • 非常显然的一个搜索树,答案就是结点数
  • 时间复杂度:O(方案数)O(\text{方案数})

思路二:dp

  • 非常显然的一个dp,我们定义一个dp记忆数组。其中 dp[i] 表示数字 i 的构造方案总数,那么状态转移方程就是

    dp[i]=j=1i/2dp[j]+1dp[i]=\sum_{j=1}^{\left\lfloor i/2 \right\rfloor}dp[j]+1

  • 时间复杂度:O(n2)O(n^2)

dfs代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int n, res;

void dfs(int x) {
res++;
for (int i = x >> 1; i >= 1; i--) {
dfs(i);
}
}

void solve() {
cin >> n;
dfs(n);
cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

dp代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int n;

void solve() {
cin >> n;

vector<ll> dp(n + 1, 1);
for (int i = 2; i <= n; i++) {
for (int j = 1; j <= i >> 1; j++) {
dp[i] += dp[j];
}
}

cout << dp[n] << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【线性dp/二分答案】规划兼职工作

https://leetcode.cn/problems/maximum-profit-in-job-scheduling/description/

题意:给定 n 份工作的起始时间、终止时间、收益值,现在需要不重叠时间的选择工作使得收益最大

思路:动态规划、二分答案。为了不重不漏的枚举每一份工作,我们将工作按照结束时间进行排序,然后就可以枚举每一份工作了。接下来要解决的问题是,如何根据起始时间和终止时间进行工作的选择。显然的,每一份工作有选与不选两种状态,是否选择取决于收益是否更优,我们考虑动态规划。我们定义状态表 f[i] 表示在前 i 个工作中选择的最大工作收益,返回值就是 f[n],先看图

图例

  1. 选择第 i 个工作:f[i] = f[r] + profit[i](其中 r 表示 [1,i-1] 份工作中,结束时间早于当前工作起始时间的最右边的工作,在 [1,i-1] 中二分查找即可)
  2. 不选第 i 个工作:f[i] = f[i - 1]

选择最大属性进行状态转移即可:f[i] = max(f[i - 1], f[r] + profit[i])

时间复杂度:O(nlogn)O(n\log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Solution {
public:
int jobScheduling(vector<int>& startTime, vector<int>& endTime, vector<int>& profit) {
int n = startTime.size();
vector<array<int, 3>> jobs(n + 1);

for (int i = 1; i <= n; i++) {
jobs[i] = {startTime[i - 1], endTime[i - 1], profit[i - 1]};
}

sort(jobs.begin() + 1, jobs.end(), [&](array<int, 3>& x, array<int, 3>& y){
return x[1] < y[1];
});

vector<int> f(n + 1);
for (int i = 1; i <= n; i++) {
int l = 0, r = i - 1;
while (l < r) {
int mid = (l + r + 1) >> 1;
if (jobs[mid][1] <= jobs[i][0]) l = mid;
else r = mid - 1;
}
f[i] = max(f[i - 1], f[r] + jobs[i][2]);
}

return f[n];
}
};

【线性dp/二分查找】最长上升子序列

https://www.luogu.com.cn/problem/B3637

题意:给定序列,求解其中最长上升子序列 (Longest Increasing Subsequence, 简称 LIS) 的长度。

思路:我们采用动态规划。

状态定义:定义状态数组 f[i] 表示以元素 a[i] 结尾的最长上升子序列的长度,显然的初始状态全都是 1

状态转移

  • 暴力枚举。首先很显然的 f[i] 一定是从 [0,i-1] 中比当前元素值小的状态转移过来,于是可以很轻松的写出双重循环的代码,时间复杂度 O(n2)O(n^2)

  • 贪心+二分优化

    • 回顾朴素。优化需要在理解转移本质的基础上展开,因此我们先来回顾一下朴素版的转移方案:从 [0,i-1] 中比当前元素小的元素 a[j] 对应的最长上升子序列长度 f[j] 转移过来。我们枚举了 [0,i-1] 所有的状态确保了不重不漏。对于 i = 6 来说,如下图所示。其中✔表示进入了 if 的判断逻辑。最终我们选择从 j = 5 对应的 f[j] = 3 转移而来。这里的判断准则为:对于每一个元素,需要找前面的元素中比当前元素小的元素。

      枚举元素

    • 转换视角。同样对于每一个元素 a[i],我们不再按顺序枚举前面已经计算出来的 f[1:i-1],而是预先按照长度存储所有的 f[1:i-1] 从而枚举长度 j,显然可以贪心的从最大长度 mxa_len 开始枚举。一旦遇到比 a[i] 小的元素,那么 f[i] 就等于当前枚举的长度+1,即 f[i] = j + 1。对于 i = 6 来说,如下图所示。这样也可以做到不重不漏的枚举所有状态。但是显然的,这种方法只是变相地枚举了所有的元素,仍然是 O(n2)O(n^2),并且和枚举元素相比还多了一点思维量和代码量。

      枚举长度

    • 优化诞生。在枚举元素时,并没有额外有价值的信息,比如元素并非单调排列,f[] 数组也并非单调。但转换到枚举长度,让我们有机会进行优化!不难发现,对于每一个元素 a[i],我们在按长度 [1,max_len] 枚举可能的尾元素 f[1:i-1] 时,由于 max_len 每一次更新时的增量都是 1,因此 [1,max_len] 的每一个取值都一定会有对应的元素存在!贪心的,对于同一个长度下所有子序列的尾元素,我们只关心其中值最小的那一个尾元素,因此我们定义一个 mi[] 数组来存储每一个长度下子序列尾元素的最小值,其中 mi[i] 表示长度为 i 的最长上升子序列中,最小的尾元素值。这样在枚举长度 [1,max_len] 时,就不再需要枚举每一个长度下所有的尾元素,仅需枚举每一个长度下唯一的那个最小尾元素。但如果每一个长度都刚好只有一个元素,那岂不是还是需要线性枚举?更进一步的,我们可以发现,此时每一个长度对应的唯一元素组成的序列是严格单调递增的(反证法易证)!有了这样单调的性质我们就可以利用二分加速枚举,这样可以将枚举的时间复杂度严格控制在 O(logn)O(\log n)。显然的,我们希望当前的元素可以拼接在尽可能长的子序列后面,因此此处的二分使用的是寻找右边界的板子。

      优化

    • 代码实现。枚举每一个元素肯定是少不了的,我们使用 mi[j] 表示最长上升子序列长度为 j 的序列尾元素的最小值。初始状态显然就是 mi[1] = a[0]。在每一个元素二分查询「最大的比当前元素小的元素」时,如果查出来的元素 mi[r] 确实比当前元素小,则需要更新 mi[r+1] 的值;反之如果查出来的元素 mi[r] 比当前元素还小,则表明二分到了左边界,此时 r 一定是 1,直接用当前元素覆盖 mi[1] 即可。

答案表示。最终答案就是 max_len

时间复杂度:O(nlogn)O(n \log n)

暴力枚举元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

void solve() {
int n;
cin >> n;

vector<int> a(n);
for (int i = 0; i < n; i++) {
cin >> a[i];
}

vector<int> f(n, 1);
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) {
if (a[i] > a[j]) {
f[i] = max(f[i], f[j] + 1);
}
}
}

cout << *max_element(f.begin(), f.end()) << "\n";
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
// std::cin >> T;
while (T--) solve();
return 0;
}

暴力枚举长度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

void solve() {
int n;
cin >> n;

vector<int> a(n);
for (int i = 0; i < n; i++) {
cin >> a[i];
}

vector<vector<int>> dp(n + 1, vector<int>());
int max_len = 1;
dp[1].push_back(a[0]);
for (int i = 1; i < n; i++) {
bool ok = false;
// 贪心的从已经出现过的最大长度开始枚举
for (int j = max_len; j >= 1; j--) {
// 对于当前长度下的所有元素
for (int x: dp[j]) {
if (a[i] > x) {
dp[j + 1].push_back(a[i]);
max_len = max(max_len, j + 1);
ok = true;
goto flag;
}
}
}
flag:
if (!ok) {
dp[1].push_back(a[i]);
}
}

cout << max_len << "\n";
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
// std::cin >> T;
while (T--) solve();
return 0;
}

贪心二分优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

void solve() {
int n;
cin >> n;

vector<int> a(n);
for (int i = 0; i < n; i++) {
cin >> a[i];
}

vector<int> mi(n + 1, INT_MAX);
int max_len = 1;
mi[1] = a[0];
for (int i = 1; i < n; i++) {
int l = 1, r = max_len;
while (l < r) {
int mid = (l + r + 1) >> 1;
if (a[i] > mi[mid]) l = mid;
else r = mid - 1;
}

if (a[i] > mi[r]) {
mi[r + 1] = min(mi[r + 1], a[i]);
max_len = max(max_len, r + 1);
} else {
// 此时长度 r 一定是 1 并且当前元素是长度为 1 的子序列尾元素中最小的
mi[1] = a[i];
}
}

cout << max_len << "\n";
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
// std::cin >> T;
while (T--) solve();
return 0;
}

【网格图dp/dfs】过河卒

https://www.luogu.com.cn/problem/P1002

题意:给定一个矩阵,现在需要从左上角走到右下角,问一共有多少种走法?有一个特殊限制是,对于图中的9个点是无法通过的。

思路一:dfs

  • 我们可以采用深搜的方法。但是会超时,我们可以这样估算时间复杂度:对于每一个点,我们都需要计算当前点的右下角的矩阵中的每一个点,那么总运算次数就近似为阶乘级别。当然实际的时间复杂度不会这么大,但是这种做法 nmn*m 一旦超过100就很容易tle

  • 时间复杂度:O(nm!)O(nm!)

思路二:dp

  • 我们可以考虑,对于当前的点,可以从哪些点走过来,很显然就是上面一个点和左边一个点,而对于走到当前这个点的路线就是走到上面的点和左边的点的路线之和,base状态就是 dp[1][1] = 1,即起点的路线数为1

  • 时间复杂度:O(nm)O(nm)

dfs代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 30;

int n, m, a, b;
int res;
bool notsafe[N][N];

void init() {
int px[9] = {0, -1, -2, -2, -1, 1, 2, 2, 1};
int py[9] = {0, 2, 1, -1, -2, -2, -1, 1, 2};
for (int i = 0; i < 9; i++) {
int na = a + px[i], nb = b + py[i];
if (na < 0 || nb < 0) continue;
notsafe[na][nb] = true;
}
}

void dfs(int x, int y) {
if (x > n || y > m || notsafe[x][y]) {
return;
}

if (x == n && y == m) {
res++;
return;
}

dfs(x, y + 1);
dfs(x + 1, y);
}

void solve() {
cin >> n >> m >> a >> b;
init();
dfs(0, 0);
cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

dp代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 30;

int n, m, a, b;
bool notsafe[N][N];
ll dp[N][N];

void solve() {
cin >> n >> m >> a >> b;

// 初始化
++n, ++m, ++a, ++b;
int px[9] = {0, -1, -2, -2, -1, 1, 2, 2, 1};
int py[9] = {0, 2, 1, -1, -2, -2, -1, 1, 2};
for (int i = 0; i < 9; i++) {
int na = a + px[i], nb = b + py[i];
if (na < 0 || nb < 0) continue;
notsafe[na][nb] = true;
}

// dp求解
dp[1][1] = 1;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (!notsafe[i - 1][j]) dp[i][j] += dp[i - 1][j];
if (!notsafe[i][j - 1]) dp[i][j] += dp[i][j - 1];
}
}

cout << dp[n][m] << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【网格图dp】摘樱桃 II

https://leetcode.cn/problems/cherry-pickup-ii/

题意:给定一个 nnmm 列的网格图,每个格子拥有一个价值。现在有两个人分别从左上角和右上角开始移动,移动方式为「左下、下、右下」三个方向,问两人最终最多一共可以获得多少价值?如果两人同时经过一个位置,则只能算一次价值。

思路:

  • 状态定义。最终状态一定是两人都在最后一行的任意两列,我们如何定义状态可以不重不漏的表示两人的运动状态呢?可以发现两人始终在同一行,但是列数不一定相同,我们定义状态 f[i][j][k]f[i][j][k] 表示两人走到第 ii 行时,一人在第 jj 列,另一人在第 kk 列时的总价值。则答案就是:

    max(f[n1][j][k]),j,k[0,m]\max{(f[n-1][j][k])},j,k\in[0,m]

  • 状态转移。本题难点在于状态定义,一旦定义好了以后状态转移就不困难了。对于当前状态 f[i][j][k]f[i][j][k],根据乘法原理,两人均有 3 种走法,因此当前状态可以从 3×3=93\times 3=9 种合法状态转移过来。取最大值即可。

时间复杂度:O(nm2)O(nm^2)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution:
def cherryPickup(self, g: List[List[int]]) -> int:
n, m = len(g), len(g[0])
f = [[[-1] * m for _ in range(m)] for _ in range(n)]
f[0][0][m - 1] = g[0][0] + g[0][m - 1]
for i in range(1, n):
for j in range(m):
for k in range(m):
# 枚举 9 个子问题
for p in range(j - 1, j + 2):
for q in range(k - 1, k + 2):
if 0 <= p < m and 0 <= q < m and f[i - 1][p][q] != -1:
f[i][j][k] = max(f[i][j][k], f[i - 1][p][q] + g[i][j] + (0 if j == k else g[i][k]))

res = 0
for j in range(m):
for k in range(j, m):
res = max(res, f[n - 1][j][k])

return res

【网格图dp】摘樱桃

弱化版 (n50)(n\le 50)https://leetcode.cn/problems/cherry-pickup/

强化版 (n300)(n\le300)https://codeforces.com/problemset/problem/213/C

题意:给定一个 n 行 n 列的网格图,问从左上走到右下,再从右下走到左上最多可以获得多少价值?一个单元格只能被计算一次价值。

注:弱化版中 -1 表示不可达,强化版没有不可达的约束。强化版 AC 代码

思路一:暴力dp

  • 首先对于来回问题可以转化为两次去的问题,即问题等价于求解「两个人从左上角走到右下角」的最大收益。
  • 显然我们可以定义四维的状态,其中 f[i][j][p][q]f[i][j][p][q] 表示第一个人走到 (i,j)(i,j) 且第二个人走到 (p,q)(p,q) 时的最大收益。直接枚举这四个维度,然后从 4 个合法的子问题转移过来即可。
  • 时间复杂度:O(n4)O(n^4)

思路二:优化dp

  • 注意到问题可以进一步等价于「两个人同时从左上角走到右下角」的最大收益。也就是两个人到起点 (0,0)(0,0) 的曼哈顿距离应该是一样的,即 i+j=p+qi+j=p+q,也就是说如果其中一个人 (i,j)(i,j) 的位置确定了,则另一个人只需要枚举一个下标,另一个下标就可以 O(1)O(1) 的确定了。可以将时间复杂度降低一个维度。
  • 状态定义。我们定义 f[k][i1][i2]f[k][i_1][i_2] 表示第一个人走到 (i1,ki1)(i_1,k-i_1) 且第二个人走到 (i2,ki2)(i_2,k-i_2) 时的最大收益。则最终答案就是 f[2n2][n1][n1]f[2n-2][n-1][n-1]。状态转移同理,从 4 个合法子问题转移过来即可。
  • 时间复杂度:O(n3)O(n^3)

暴力dp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
template<class T>
void chmax(T& a, T b) {
a = max(a, b);
}

int di[4] = {-1, -1, 0, 0};
int dj[4] = {0, 0, -1, -1};
int dp[4] = {-1, 0, -1, 0};
int dq[4] = {0, -1, 0, -1};

class Solution {
public:
int cherryPickup(vector<vector<int>>& g) {
int n = g.size();
int f[n][n][n][n];
memset(f, -1, sizeof f);
f[0][0][0][0] = g[0][0] == -1 ? -1 : g[0][0];
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (g[i][j] == -1) {
continue;
}
for (int p = 0; p < n; p++) {
for (int q = 0; q < n; q++) {
if (g[p][q] == -1) {
continue;
}
// 枚举 4 个子问题
for (int k = 0; k < 4; k++) {
int ni = i + di[k], nj = j + dj[k];
int np = p + dp[k], nq = q + dq[k];
if (ni >= 0 && nj >= 0 && np >= 0 && nq >= 0 && f[ni][nj][np][nq] != -1) {
chmax(f[i][j][p][q], f[ni][nj][np][nq] + g[i][j] + (i == p && j == q ? 0 : g[p][q]));
}
}
}
}
}
}
return f[n - 1][n - 1][n - 1][n - 1] == -1 ? 0 : f[n - 1][n - 1][n - 1][n - 1];
}
};

优化dp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
template<class T>
void chmax(T& a, T b) {
a = max(a, b);
}

int dx[] = {0, 0, -1, -1};
int dy[] = {0, -1, 0, -1};

class Solution {
public:
int cherryPickup(vector<vector<int>>& g) {
int n = g.size();
int f[2 * n - 1][n][n];
memset(f, -1, sizeof f);
f[0][0][0] = g[0][0] == -1 ? -1 : g[0][0];
for (int k = 1; k <= 2 * n - 2; k++) {
for (int i1 = 0; i1 < n; i1++) {
for (int i2 = 0; i2 < n; i2++) {
int j1 = k - i1, j2 = k - i2;
if (j1 < 0 || j1 >= n || j2 < 0 || j2 >= n || g[i1][j1] == -1 || g[i2][j2] == -1) {
continue;
}
// 枚举 4 个子问题
for (int t = 0; t < 4; t++) {
int ni1 = i1 + dx[t], nj1 = k - ni1;
int ni2 = i2 + dy[t], nj2 = k - ni2;
if (ni1 >= 0 && nj1 >= 0 && ni2 >= 0 && nj2 >= 0 && f[k - 1][ni1][ni2] != -1) {
chmax(f[k][i1][i2], f[k - 1][ni1][ni2] + g[i1][j1] + (i1 == i2 && j1 == j2 ? 0 : g[i2][j2]));
}
}
}
}
}
return f[2 * n - 2][n - 1][n - 1] == -1 ? 0 : f[2 * n - 2][n - 1][n - 1];
}
};

【树形dp】最大社交深度和

https://vijos.org/d/nnu_contest/p/1534

算法:树形dp、BFS

题意:给定一棵树,现在需要选择其中的一个结点为根节点,使得深度和最大。深度的定义是以每个结点到树根所经历的结点数

思路一:暴力

  • 显然可以直接遍历每一个结点,计算每个结点的深度和,然后取最大值即可

  • 时间复杂度:O(n2)O(n^2)

思路二:树形dp

树形dp图解

  • 我们可以发现,对于当前的根结点 fa,我们选择其中的一个子结点 ch,将 ch 作为新的根结点(如右图)。那么对于当前的 ch 的深度和,我们可以借助 fa 的深度和进行求解。我们假设以 ch 为子树的结点总数为 x,那么这 x 个结点在换根之后,相对于 ch 的深度和,贡献了 -x 的深度;而对于 fa 的剩下来的 n-x 个结点,相对于 ch 的深度和,贡献了 n-x 的深度。于是 ch 的深度和就是 fa的深度和 -x+n-x,即:

    dep[ch]=dep[fa]x+nx=dep[fa]+n2×xdep[ch] = dep[fa]-x+n-x = dep[fa]+n-2\times x

    于是我们很快就能想到利用前后层的递推关系,O(1)O(1) 的计算出所有子结点的深度和。

    代码实现:我们可以先计算出 base 的情况,即任选一个结点作为根结点,然后基于此进行迭代计算。在迭代计算的时候需要注意的点就是在一遍 dfs 计算某个结点的深度和 dep[root] 时,如果希望同时计算出每一个结点作为子树时,子树的结点数,显然需要分治计算一波。关于分治的计算我熟练度不够高,特此标注一下debug了3h的点:即在递归到最底层,进行回溯计算的时候,需要注意不能统计父结点的结点值(因为建的是双向图,所以一定会有从父结点回溯的情况),那么为了避开这个点,就需要在 O(1)O(1) 的时间复杂度内获得当前结点的父结点的编号,从而进行特判,采用的方式就是增加递归参数 fa

  • 没有考虑从父结点回溯的情况的dfs代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    void dfs(int now, int depth) {
    if (!st[now]) {
    st[now] = true;
    dep[root] += depth;
    for (auto& ch: G[now]) {
    dfs(ch, depth + 1);
    cnt[now] += cnt[ch];
    }
    }
    }
  • 考虑了从父结点回溯的情况的dfs代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    void dfs(int now, int fa, int depth) {
    if (!st[now]) {
    st[now] = true;
    dep[root] += depth;
    for (auto& ch: G[now]) {
    dfs(ch, now, depth + 1);
    if (ch != fa) {
    cnt[now] += cnt[ch];
    }
    }
    }
    }
  • 时间复杂度:Θ(2n)\Theta(2n)

暴力代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const int N = 500010;

int n;
vector<int> G[N];
int st[N], dep[N];

void dfs(int id, int now, int depth) {
if (!st[now]) {
st[now] = 1;
dep[id] += depth;
for (auto& node: G[now]) {
dfs(id, node, depth + 1);
}
}
}

void solve() {
cin >> n;
for (int i = 1; i <= n - 1; i++) {
int a, b;
cin >> a >> b;
G[a].push_back(b);
G[b].push_back(a);
}

int res = 0;

for (int i = 1; i <= n; i++) {
memset(st, 0, sizeof st);
dfs(i, i, 1);
res = max(res, dep[i]);
}

cout << res << "\n";
}

优化代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
const int N = 500010;

int n, dep[N], root = 1;
vector<int> G[N], cnt(N, 1);;
bool st[N];

// 当前结点编号 now,当前结点的父结点 fa,当前结点深度 depth
void dfs(int now, int fa, int depth) {
if (!st[now]) {
st[now] = true;
dep[root] += depth;
for (auto& ch: G[now]) {
dfs(ch, now, depth + 1);
if (ch != fa) {
cnt[now] += cnt[ch];
}
}
}
}

void bfs() {
memset(st, 0, sizeof st);
queue<int> q;
q.push(root);
st[root] = true;

while (q.size()) {
int fa = q.front(); // 父结点编号 fa
q.pop();
for (auto& ch: G[fa]) {
if (!st[ch]) {
st[ch] = true;
dep[ch] = dep[fa] + n - 2 * cnt[ch];
q.push(ch);
}
}
}
}

void solve() {
cin >> n;
for (int i = 1; i <= n - 1; i++) {
int a, b;
cin >> a >> b;
G[a].push_back(b);
G[b].push_back(a);
}

dfs(root, -1, 1);
bfs();

cout << *max_element(dep, dep + n + 1) << "\n";
}

【高维dp/dfs】栈

https://www.luogu.com.cn/problem/P1044

题意:n个数依次进栈,随机出栈,问一共有多少种出栈序列?

思路一:dfs

  • 我们可以这么构造搜索树:已知对于当前的栈,一共有两种状态
    • 入栈 - 如果当前还有数没有入栈
    • 出栈 - 如果当前栈内还有元素
  • 搜索参数:i,j 表示入栈数为 i 出栈数为 j 的状态
  • 搜索终止条件
    • 入栈数 < 出栈数 - i<ji<j
    • 入栈数 > 总数 nn - i=ni = n
  • 答案状态:入栈数为n,出栈数也为n
  • 时间复杂度:O(方案数)O(\text{方案数})

思路二:dp

  • 采用上述dfs时的状态表示方法,i,j 表示入栈数为 i 出栈数为 j 的状态。

  • 我们在搜索的时候,考虑的是接下来可以搜索的状态

    1. 即出栈一个数的状态 - i+1,j

    2. 和入栈一个数的状态 - i,j+1

    如图:

    图解

    而我们在dp的时候,需要考虑的是子结构的解来得出当前状态的答案,就需要考虑之前的状态。即当前状态是从之前的哪些状态转移过来的。和上述dfs思路是相反的。我们需要考虑的是

    1. 上一个状态入栈一个数到当前状态 - i-1,j \to i,j
    2. 上一个状态出栈一个数到当前状态 - i,j-1 \to i,j
    • 特例:i=ji=j 时,只能是上述第二种状态转移而来,因为要始终保证入栈数大于等于出栈数,即 iji \ge j

    如图:

    图解

  • 我们知道,入栈数一定是大于等于出栈数的,即 iji\ge j。于是我们在枚举 jj 的时候,枚举的范围是 [1,i][1,i]

  • basebase 状态的构建取决于 j=0j=0 时的所有状态,我们知道没有任何数出栈也是一种状态,于是

    dp[i][0]=0,(i=1,2,3,...,n)dp[i][0]=0,(i=1,2,3,...,n)

  • 时间复杂度:O(n2)O(n^2)

dfs代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int n, res;

// 入栈i个数,出栈j个数
void dfs(int i, int j) {
if (i < j || i > n) return;

if (i == n && j == n) res++;

dfs(i + 1, j);
dfs(i, j + 1);
}

void solve() {
cin >> n;
dfs(0, 0);
cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

dp代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 20;

int n;
ll dp[N][N]; // dp[i][j] 表示入栈数为i,出栈数为j的方案总数

void solve() {
cin >> n;

// base状态:没有数出栈也是一种状态
for (int i = 1; i <= n; i++) dp[i][0] = 1;

// dp转移
for (int i = 1; i <= n; i++)
for (int j = 1; j <= i; j++)
if (i == j) dp[i][j] = dp[i][j - 1];
else dp[i][j] = dp[i - 1][j] + dp[i][j - 1];

cout << dp[n][n] << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【高维dp】找出所有稳定的二进制数组 II

https://leetcode.cn/problems/find-all-possible-stable-binary-arrays-ii/

题意:构造一个仅含有 nn00mm11 的数组,且长度超过 kk 的子数组必须同时含有 0011。给出构造的总方案数对 109+710^9+7 取余后的结果。

思路:

  • 定义状态表。我们从左到右确定每一位的数字时,需要确定这么三个信息:当前是哪一位?还剩几个 0011?这一位填 00 还是 11?对于这三个信息,我们可以定义状态数组 f[i][j][k]f[i][j][k] 表示当前已经选了 ii00jj11,且第 i+ji+j 位填 kk 时的方案数。这样最终答案就是 f[n][m][0]+f[n][m][1]f[n][m][0]+f[n][m][1]

  • 定义子问题。以当前第 i+ji+j 位填 00 为例,填 11 同理,即当前需要维护的是 f[i][j][0]f[i][j][0]。显然的如果前一位是和当前位不同,即 11 时,可以直接转移过来;如果前一位和当前位相同,即 00 时,有可能会出现长度超过 kk 的连续 00 子数组,即前段数组刚好是以「一个 11kk00 结尾的」合法数组,此时再拼接一个 00 就不合法了。

  • 状态转移方程。综上所述,可以得到以下两个状态转移方程:

    f[i][j][0]=f[i1][j][1]+f[i1][j][0]f[ik1][j][1]f[i][j][1]=f[i][j1][0]+f[i][j1][1]f[i][jk1][0]\begin{aligned}f[i][j][0] = f[i - 1][j][1] + f[i - 1][j][0] - f[i - k - 1][j][1] \\f[i][j][1] = f[i][j - 1][0] + f[i][j - 1][1] - f[i][j - k - 1][0]\end{aligned}

时间复杂度:O(nm)O(nm)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Solution {
public:
int numberOfStableArrays(int n, int m, int k) {
const int mod = 1e9 + 7;
vector<vector<array<int, 2>>> f(n + 1, vector<array<int, 2>>(m + 1));

for (int i = 0; i <= min(n, k); i++) {
f[i][0][0] = 1;
}
for (int j = 0; j <= min(m, k); j++) {
f[0][j][1] = 1;
}

for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
f[i][j][0] = (f[i - 1][j][1] + f[i - 1][j][0]) % mod;
if (i - k - 1 >= 0) {
f[i][j][0] = (f[i][j][0] - f[i - k - 1][j][1] + mod) % mod;
}
f[i][j][1] = (f[i][j - 1][0] + f[i][j - 1][1]) % mod;
if (j - k - 1 >= 0) {
f[i][j][1] = (f[i][j][1] - f[i][j - k - 1][0] + mod) % mod;
}
}
}

return (f[n][m][0] + f[n][m][1]) % mod;
}
};

【高维dp】学生出勤记录 II

https://leetcode.cn/problems/student-attendance-record-ii/description/

题意:构造一个仅含有 P,A,LP,A,L 三种字符的字符串,使得 AA 最多出现 11 次,同时 LL 最多连续出现 22 次。问一共有多少种构造方案?对 109+710^9+7 取模。

思路:

  • 模拟分析问题。我们从前往后构造。对于当前第 ii 个字符 s[i]s[i],有 33 种可能的选项,其中 PP 是一定合法的选项,AA 是否合法取决于 s[0:i1]s[0:i-1] 中的 AA 的数量,LL 是否合法取决于 s[0:i1]s[0:i-1] 的末尾连续 LL 的数量。因此我们可以通过被动转移的动态规划求解。
  • 状态定义。从上述分析不难发现需要 33 种状态来表示所有情况,因此我们定义 f[i][j][k]f[i][j][k] 表示构造第 ii 位时,s[0:i]s[0:i] 中含有 jjAA 且末尾含有连续 kkLL 时的方案数。
  • 初始化。每一位填什么取决于前缀 s[0:i1]s[0:i-1] 的情况,因此我们需要初始化 f[0][][]f[0][][] 即第 00 位的情况。显然的第 00P,A,LP,A,L 三种都可以填,对应的就是 f[0][0][0]=f[0][1][0]=f[0][0][1]=1f[0][0][0] = f[0][1][0] = f[0][0][1] = 1,其余初始化为 00 即可。
  • 状态转移。考虑第 ii 位的三种情况:
    1. PP:可以用前缀的所有状态来更新 f[i][j][0]f[i][j][0]
    2. AA:可以用前缀中不含 AA 的所有状态来更新 f[i][1][0]f[i][1][0]
    3. LL:可以用前缀中末尾不超过 11LL 的所有状态来更新 f[i][j][k]f[i][j][k]
  • 最终答案。为 j=01k=02f[n1][j][k]\displaystyle \sum_{j=0}^{1}\sum_{k=0}^{2}f[n-1][j][k]

时间复杂度:Θ(6n)\Theta (6n)

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class Solution {
public:
int checkRecord(int n) {
const int mod = 1e9 + 7;

// f[i][j][k]表示从左往右构造第i位时,s[0:i]中含有j个A且尾部含有连续k个L时的方案数
int f[n][2][3];
memset(f, 0, sizeof f);

// 初始化
f[0][0][0] = f[0][1][0] = f[0][0][1] = 1;

// 转移
for (int i = 1; i < n; i++) {
// s[i]填P
for (int j = 0; j <= 1; j++) {
for (int k = 0; k <= 2; k++) {
f[i][j][0] = (f[i][j][0] + f[i - 1][j][k]) % mod;
}
}
// s[i]填A
for (int k = 0; k <= 2; k++) {
f[i][1][0] = (f[i][1][0] + f[i - 1][0][k]) % mod;
}
// s[i]填L
for (int j = 0; j <= 1; j++) {
for (int k = 1; k <= 2; k++) {
f[i][j][k] = (f[i][j][k] + f[i - 1][j][k - 1]) % mod;
}
}
}

// 计算答案
int res = 0;
for (int j = 0; j <= 1; j++) {
for (int k = 0; k <= 2; k++) {
res = (res + f[n - 1][j][k]) % mod;
}
}
return res;
}
};
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution:
def checkRecord(self, n: int) -> int:
mod = int(1e9 + 7)
f = [[[0, 0, 0] for _ in range(2)] for _ in range(n)]
f[0][0][0] = f[0][1][0] = f[0][0][1] = 1
for i in range(1, n):
# P
for j in range(2):
for k in range(3):
f[i][j][0] = (f[i][j][0] + f[i - 1][j][k]) % mod
# A
for k in range(3):
f[i][1][0] = (f[i][1][0] + f[i - 1][0][k]) % mod
# L
for j in range(2):
for k in range(1, 3):
f[i][j][k] = (f[i][j][k] + f[i - 1][j][k - 1]) % mod
res = 0
for j in range(2):
for k in range(3):
res = (res + f[n - 1][j][k]) % mod
return res

【区间dp】对称山脉

https://www.acwing.com/problem/content/5169/

模拟,时间复杂度 O(n3)O(n^3)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
#include <cmath>

using namespace std;

const int N = 5010;

int n;
int a[N];

int main() {
cin >> n;

for (int i = 1; i <= n; i++)
cin >> a[i];

// 枚举区间长度
for (int len = 1; len <= n; len++) {
int res = 2e9;
// 枚举相应长度的所有区间
for (int i = 1, j = i + len - 1; j <= n; i++, j++) {
// 计算区间的不对称值
int l = i, r = j;
int sum = 0;
while (l < r) {
sum += abs(a[l] - a[r]);
l++, r--;
}
res = min(res, sum);
}
cout << res << ' ';
}

return 0;
}

dp优化,时间复杂度 O(n2)O(n^2)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>
#include <cmath>
#include <cstring>

using namespace std;

const int N = 5010;

int n;
int a[N];

int dp[N][N]; // dp[i][j] 表示第 i 到 j 的不对称值
int res[N]; // res[len] 表示长度为 len 的山脉的最小不对称值

int main() {
cin >> n;

for (int i = 1; i <= n; i++)
cin >> a[i];

memset(res, 0x3f, sizeof res);

// 长度为 1 的情况
res[1] = 0;

// 长度为 2 的情况
for (int i = 1, j = i + 1; j <= n; i++, j++) {
dp[i][j] = abs(a[i] - a[j]);
res[2] = min(res[2], dp[i][j]);
}

// 长度 >= 3 的情况
for (int len = 3; len <= n; len++) {
for (int i = 1, j = i + len - 1; j <= n; i++, j++) {
dp[i][j] = dp[i + 1][j - 1] + abs(a[i] - a[j]);
res[len] = min(res[len], dp[i][j]);
}
}

for (int i = 1; i <= n; i++)
cout << res[i] << ' ';

return 0;
}

【状压dp】Avoid K Palindrome 🔥

https://atcoder.jp/contests/abc359/tasks/abc359_d

题意:给定一个长度为 n1000n\le 1000 的字符串 ss 和一个整数 k10k\le10,其中含有若干个 'A','B''?'。其中 '?' 可以转化为 'A''B',假设有 qq'?',则一共可以转化出 2q2^q 个不同的 ss。问所有转化出的 ss 中,有多少是不含有长度为 kk 的回文子串的。

思路:最暴力的做法就是 2^q 枚举所有可能的字符串,然后再 O(n) 的检查,这样时间复杂度为 O(n2n)O(n2^n),只能通过 20 以内的数据。一般字符串回文问题可以考虑 dp。

时间复杂度:

1

]]>
@@ -1663,11 +1663,11 @@ - dp - - /Algorithm/dp/ + data-structure + + /Algorithm/data-structure/ - 动态规划

动态规划分为被动转移和主动转移,而其根本在于状态表示和状态转移。如何完整表示所有状态?如何不重不漏划分子集从而进行状态转移?

被动转移 vs 主动转移

【递推】反转字符串

https://www.acwing.com/problem/content/5574/

题意:给定 n 个字符串,每一个字符串对应一个代价 wiw_i,现在需要对这 n 个字符串进行可能的翻转操作使得最终的 n 个字符串呈现字典序上升的状态,给出最小翻转代价。

思路:很显然每一个字符串都有两种状态,我们可以进行二叉搜索或者二进制枚举。那么我们可以进行 dp 吗?答案是可以的。我们可以发现,对于第 i 个字符串,是否需要翻转仅仅取决于第 i-1 个字符串的大小,无后效性,可以进行递推。我们定义状态数组 f[i][j] 进行状态标识。其中

  • f[i][0] 表示第 i 个字符串不需要翻转时的最小代价
  • f[i][1] 表示第 i 个字符串需要翻转时的最小代价

状态转移就是当前一个字符串比当前字符串的字典序小时进行转移,即当前最小代价是前一个状态的最小代价加上当前翻转状态的代价。至于什么时候可以进行状态转移,一共有 4 种情况,即:

  • s[i-1] 不翻转,s[i] 不翻转。此时 f[i][0] = min(f[i][0], f[i-1][0])
  • s[i-1] 翻转,s[i] 不翻转。此时 f[i][0] = min(f[i][0], f[i-1][1])
  • s[i-1] 不翻转,s[i] 翻转。此时 f[i][1] = min(f[i][1], f[i-1][0] + w[i])
  • s[i-1] 翻转,s[i] 翻转。此时 f[i][1] = min(f[i][1], f[i-1][1] + w[i])

最终答案就在 f[n][0]f[n][1] 中取 min 即可

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
using ll = long long;

const int N = 100010;

int n, w[N];
string s[N][2]; // s[i][0] 表示原始字符串,s[i][1] 表示反转后的字符串
ll f[N][2]; // f[i][j] 表示第i个字符串在翻转状态为j的情况下的最小代价

ll dp() {
f[1][0] = 0, f[1][1] = w[1];
for (int i = 2; i <= n; i++) {
if (s[i - 1][0] <= s[i][0]) f[i][0] = min(f[i][0], f[i - 1][0]);
if (s[i - 1][1] <= s[i][0]) f[i][0] = min(f[i][0], f[i - 1][1]);
if (s[i - 1][0] <= s[i][1]) f[i][1] = min(f[i][1], f[i - 1][0] + w[i]);
if (s[i - 1][1] <= s[i][1]) f[i][1] = min(f[i][1], f[i - 1][1] + w[i]);
}
return min(f[n][0], f[n][1]);
}

int main() {
cin >> n;
for (int i = 1; i <= n; i++) cin >> w[i];
for (int i = 1; i <= n; i++) {
cin >> s[i][0];
s[i][1] = s[i][0];
reverse(s[i][1].begin(), s[i][1].end());
}
memset(f, 0x3f, sizeof f);

ll res = dp();
if (res == 0x3f3f3f3f3f3f3f3fll) res = -1;
cout << res << "\n";

return 0;
}

【递推】最大化子数组的总成本

https://leetcode.cn/problems/maximize-total-cost-of-alternating-subarrays/

题意:给定长度为 n 的序列,现在需要将其分割为子数组,并定义每一个子数组的价值为「奇数位置为原数值,偶数位置为相反数」,返回最大分割价值

思路:~~一开始还在想着如何贪心,即仅考虑连续的负数区间,但是显然的贪不出全局最优解。~~于是转而考虑暴力 dp。思路和朴素 01 背包有相似之处。

  • 状态定义。首先我们一定需要按照元素进行枚举,因此第一维度我们就定义为序列长度,但是仅仅这样定义就能表示全部的状态了吗?显然不行。根据题目的奇偶价值约束,我们在判定每一个负数是否可以「取相反数」价值时,是取决于上一个负数是否取了相反数价值。为了统一化,我们将正负数一起考虑前一个数是否取了相反价值的情况,因此我们需要再增加「前一个数是否取了相反价值」的状态表示,而这仅仅是一个二值逻辑,直接开两个空间即可。于是状态定义呼之欲出:

    • f[i][0] 表示第 i 个数取原始价值的情况下,序列 [0, i-1] 的最大价值
    • f[i][1] 表示第 i 个数取相反价值的情况下,序列 [0, i-1] 的最大价值
  • 状态转移。显然对于上述状态定义,仅仅需要对「连续负数区间」进行考虑,因此对于 nums[i] >= 0 的情况是没有选择约束的,原始价值和相反价值都是可行的方案那么为了最大化最终收益显然选择原始正价值,并且可以从上一个情况的任意状态转移过来那么同样为了最大化最终受益显然选择最大的基状态,于是可得:

    • f[i][0] = max(f[i-1][0], f[i-1][1]) + nums[i]
    • f[i][1] = max(f[i-1][0], f[i-1][1]) - nums[i]

    而对于 nunms[i] < 0 的情况,能否选择相反数取决于前一个数的正负性。当前一个数 nums[i-1] >= 0 时,显然当前负数原始价值和相反价值都可以取到;当前一个数 nums[i-1] < 0 时,则当前负数仅有在前一个负数取原始价值的情况下才能取相反价值,于是可得:

    if nums[i-1] >= 0

    • f[i][0] = max(f[i-1][0], f[i-1][1]) + nums[i]
    • f[i][1] = max(f[i-1][0], f[i-1][1] - nums[i])

    if nums[i-1] < 0

    • f[i][0] = max(f[i-1][0], f[i-1][1] + nums[i])
    • f[i][1] = f[i-1][0] - nums[i]
  • 答案表示。max(f[n-1][0], f[n-1][1])

时间复杂度:O(n)O(n)

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
long long maximumTotalCost(vector<int>& nums) {
using ll = long long;

int n = nums.size();

vector<vector<ll>> f(n, vector<ll>(2, 0));
f[0][0] = f[0][1] = nums[0];
for (int i = 1; i < n; i++) {
if (nums[i] >= 0 || nums[i-1] >= 0) {
f[i][0] = max(f[i-1][0], f[i-1][1]) + nums[i];
f[i][1] = max(f[i-1][0], f[i-1][1]) - nums[i];
} else {
f[i][0] = max(f[i-1][0], f[i-1][1]) + nums[i];
f[i][1] = f[i-1][0] - nums[i];
}
}

return max(f[n-1][0], f[n-1][1]);
}
};
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution:
def maximumTotalCost(self, nums: List[int]) -> int:
n = len(nums)

f = [[0] * 2 for _ in range(n)]
f[0][0] = f[0][1] = nums[0]
for i in range(1, n):
if nums[i] >= 0 or nums[i-1] >= 0:
f[i][0] = max(f[i-1][0], f[i-1][1]) + nums[i]
f[i][1] = max(f[i-1][0], f[i-1][1]) - nums[i]
else:
f[i][0] = max(f[i-1][0], f[i-1][1]) + nums[i]
f[i][1] = f[i-1][0] - nums[i]

return max(f[n-1][0], f[n-1][1])

【递推】费解的开关

https://www.acwing.com/problem/content/97/

题意:给定 n 个 5*5 的矩阵,代表当前局面。矩阵中每一个元素要么是 0 要么是 1,现在需要计算从当前状态操作到全 1 状态最少需要几次操作?操作描述为改变当前状态为相反状态后,四周的四个元素也需要改变为相反的状态

思路:我们采用递推的思路。为了尽可能少的进行按灯操作,我们从第二行开始考虑,若前一行的某一元素为 0,则下一行的同一列位置就需要按一下,以此类推将 252 \to 5 行全部按完。现在考虑两点,当前按下状态是否合法?当前按下状态是是否最优?

  1. 对于第一个问题:从上述思路可以看出,1n11 \to n-1 行一定全部都是 1 的状态,但是第 n1n-1 行不一定全 1,因此不合法状态就是第 n1n-1 行不全为 1
  2. 对于第二个问题:可以发现,上述算法思路中,对于第一行是没有任何操作的(可以将第一行看做递推的初始化条件),第一行的状态影响全局的总操作数,我们不能确定不对第一行进行任何操作得到的总操作数就是最优的,故我们需要对第一行 5 个灯进行枚举按下。我们采用 5 位二进制的方法对第一行的 5 个灯进行枚举按下操作,然后对于当前第一行的按下局面(递推初始化状态)进行 2n2 \to n 行的按下递推操作。对于每一种合法状态更新最小的操作数即可

时间复杂度:O(T×25×25×5)O(T \times 2^5 \times 25 \times 5)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 10;

char g[N][N], now[N][N];
int dx[] = {0, 1, -1, 0, 0}, dy[] = {0, 0, 0, 1, -1};

void turn(int x, int y) {
for (int k = 0; k < 5; k++) {
int nx = x + dx[k], ny = y + dy[k];
now[nx][ny] ^= 1;
}
}

void solve() {
for (int i = 1; i <= 5; i++) {
cin >> (g[i] + 1);
}

int res = 30;

for (int op = 0; op < (1 << 5); op++) {
memcpy(now, g, sizeof g);
int step = 0;

// 统计第 1 行的按下次数
for (int i = 0; i < 5; i++) {
if (op & (1 << i)) {
step++;
turn(1, 5 - i);
}
}

// 统计 2 ~ 5 行的按下次数
for (int i = 1; i <= 4; i++) {
for (int j = 1; j <= 5; j++) {
if (now[i][j] == '0') {
step++;
turn(i + 1, j);
}
}
}

// 判断当前操作方案是否合法
bool ok = true;
for (int j = 1; j <= 5; j++) {
if (now[5][j] == '0') {
ok = false;
}
}

// 若当前操作方案合法则更新最小操作次数
if (ok) {
res = min(res, step);
}
}

cout << (res > 6 ? -1 : res) << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
cin >> T;
while (T--) solve();
return 0;
}

【递推/线性dp/哈希】Decode

https://codeforces.com/contest/1996/problem/E

题意:给定一个01字符串,问所有区间中01数量相等的子串总共有多少个。

思路:

  • 我们可以最直接的想到 O(n5)O(n^5) 的暴力做法,即 O(n2)O(n^2) 枚举区间的左右端点,O(n2)O(n^2) 枚举区间中子串的左右端点,最后 O(n)O(n) 进行检查计数。当然可以用前缀和优化掉 O(n)O(n) 的检查计数。但这样做显然无法通过 10510^5 的数据量。考虑别的思路。
  • 容易想到动态规划的思路。我们定义 dp[i] 表示右端点是 s[i] 的所有区间中,合法01子串的数量,那么显然 dp[0] = s[i] == '1'。现在考虑 dp[i] 如何转移。不难发现以 s[i] 为右端点的所有区间一定包含以 s[i - 1] 为右端点的所有区间,多出来的合法子串进存在于以 s[i] 结尾的子串中,我们假设以 s[i] 结尾的合法子串数量为 t,则状态转移方程为:dp[i] = dp[i - 1] + t。显然我们可以用一个滚动变量来代替 dp 数组,记作now,每次维护完 now 以后,将其累加到答案即可。现在我们将枚举区间从 O(n2)O(n^2) 通过递推的思路优化到了 O(n)O(n)。那么如何求解递推式中的 t 呢?
  • 通过一个小 trick 求解「以 s[i] 结尾的合法子串」的数量。显然可以通过前缀和枚举 j[1,i]j \in [1,i] 统计使得 i - j + 1 == 2 * (pre[i] - pre[j - 1]) 的数量。但是这样就是 O(n2)O(n^2) 的了,仍然无法通过本题。引入 trick:我们稍微修改一下前缀和的维护逻辑,即当 s[i] == '0' 时,将其记作 -1,当 s[i] == '1' 时,保持不变仍然记作 1。这样我们在向前枚举 j 寻找合法区间时,本质上就是在寻找 pre[i] - pre[j - 1] == 0,即 pre[j - 1] == pre[i] 的个数。假设下标从 0 开始,则每找到一个合法的 j,都会对答案贡献 j + 1。因此我们只需要哈希存储每一个前缀和对应的下标累计值即可。

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

void solve() {
string s;
cin >> s;

int n = s.size();
vector<ll> pre(n + 1);
for (int i = 0; i < n; i++) {
pre[i + 1] = pre[i] + (s[i] == '0' ? -1 : 1);
}

auto add = [&](ll x, ll y) {
ll mod = 1e9 + 7;
return ((x % mod) + (y % mod)) % mod;
};

ll res = 0, now = 0;
unordered_map<ll, ll> f;
for (ll i = 0; i <= n; i++) {
now = add(now, f[pre[i]]);
res = add(res, now);
f[pre[i]] += i + 1;
}

cout << res << "\n";
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
std::cin >> T;
while (T--) solve();
return 0;
}

【递推/数学】Squaring

https://codeforces.com/contest/1995/problem/C

题意:给定一个序列,可以对其中任何一个数进行任意次平方操作,即 aiai2a_i \to a_i^2,问最少需要执行多少次操作可以使得序列不减。

思路:

  • 首先显然的我们不希望第一个数 a0a_0 变大,因此我们应该从第二个数开始和前一个数进行比较,从而进行可能的平方操作并计数。但是由于数字可能会很大,使用高精度显然会超时,而这些数字本身的意义是用来和前一个数进行大小比较的,并且变化的形式也仅仅是平方操作。我们不妨维护其指数,这样既可以进行大小比较,也不用存储数字本身而造成溢出。当然变为使用指数存储以后如何进行大小比较以及如何维护指数序列有所不同,我们先从一般的角度出发,进而从表达式本身的数学角度讨论特殊情况的处理。

  • 对于当前数字 aia_i 和前一个数字 ai1a_{i-1} 及其指数 zi1z_{i-1},如果想要当前数字经过可能的 k 次平方操作后不小于前一个数,即 ai12zi1ai2k\displaystyle a_{i-1}^{2^{z_{i-1}}}\le a_i^{2^{k}},则易得下面的表达式:

    k:=zi1+log2(log2ai1log2ai)k:= z_{i-1}+ \left\lceil \log_2(\frac{\log_2 {a_{i-1}}}{\log_2{a_i}}) \right \rceil

    由于 aj[1,106],zj0,k0a_j \in [1, 10^6],z_j\ge 0,k \ge 0,因此我们有必要:

    1. 讨论 aia_iai1a_{i-1}11 的大小关系。因为它们的对数计算结果在真数部分。
    2. 讨论 kk00 的大小关系。因为一旦 ai1<aia_{i-1} < a_i,则有可能出现计算结果为负的情况,这种情况表明需要对当前数 aia_i 进行开根的操作。又由于 ai1a_i\ge1,因此开根操作就表明减小当前的数,也就表明当前数在不进行平方操作的情况下就已经满足不减的条件了,也就不需要操作了。此时对应的指数 ziz_i 就是 00,对于答案的贡献也是 00

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

void solve() {
int n;
cin >> n;

vector<int> a(n);
for (int i = 0; i < n; i++) {
cin >> a[i];
}

vector<ll> z(n, 0);
ll res = 0;
for (int i = 1; i < n; i++) {
if (a[i] == 1 && a[i - 1] > 1) {
cout << -1 << "\n";
return;
} else if (a[i] == 1 && a[i - 1] == 1 || a[i - 1] == 1) {
continue;
}
ll t = z[i - 1] + ceil(log2(log2(a[i - 1]) / log2(a[i])));
z[i] = max(0ll, t);
res += z[i];
}

cout << res << "\n";
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
std::cin >> T;
while (T--) solve();
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from typing import List, Tuple
from collections import defaultdict, deque
from itertools import combinations, permutations
import math, heapq, queue

II = lambda: int(input())
FI = lambda: float(input())
MII = lambda: tuple(map(int, input().split()))
LII = lambda: list(map(int, input().split()))


def solve() -> None:
n = II()
a = LII()

z = [0] * n
for i in range(1, n):
if a[i] == 1 and a[i - 1] > 1:
print(-1)
return
elif a[i] == 1 and a[i - 1] == 1 or a[i - 1] == 1:
continue
t = z[i - 1] + math.ceil(math.log2(math.log2(a[i - 1]) / math.log2(a[i])))
z[i] = max(0, t)

print(sum(z))


if __name__ == '__main__':
T = 1
T = II()
while T: solve(); T -= 1

【递推/dfs】牛的语言学

https://www.acwing.com/problem/content/description/5559/

题意:已知一个字符串由前段的一个词根和后段的多个词缀组成。词根的要求是长度至少为 5,词缀的要求是长度要么是 2, 要么是 3,以及不允许连续的相同词缀组成后段。现在给定最终的字符串,问一共有多少种词缀?按照字典序输出所有可能的词缀

  • 思路一:搜索。很显然我们应该从字符串的最后开始枚举词缀进行搜索,因为词缀前面的所有字符全部都可以作为词根合法存在,我们只需要考虑当前划分出来的词缀是否合法即可。约束只有一个,不能与后面划分出来的词缀相同即可,由于我们是从后往前搜索,因此我们在搜索时保留后一个词缀即可。如果当前词缀和上一个词缀相同则返回,反之如果合法则加入 set 自动进行字典序排序。

    时间复杂度:O(2n2)O(2^{\frac{n}{2}})

  • 思路二:动态规划(递推)。动规其实就是 dfs 的逆过程,我们从已知结果判断当前局面是否合法。很显然词根是包罗万象的,只要长度合法,不管什么样的都是可行的,故我们在判断当前局面是否合法时,只需要判断当前词缀是否合法即可。于是便可知当前状态是从后面的字符串转移过来的。我们定义状态转移记忆数组 f[i] 表示字符串 s[1,i] 是否可以组成词根。如果 f[i] 为真,则表示 s[1,i] 为一个合法的词根,s[i+1,n] 为一个合法的词缀串,那么词根后面紧跟着的一定是一个长度为 2 或 3 的合法词缀。

    • 我们以紧跟着长度为 2 的合法词缀为例。如果 s[i+1,i+2] 为一个合法的词缀,则必须要满足以下两个条件之一

      1. s[i+1,i+2]s[i+3,i+4] 不相等,即后面的后面是一个长度也为 2 且合法的词缀
      2. s[1,i+5] 是一个合法的词根,即 f[i+5] 标记为真,即后面的后面是一个长度为 3 且合法的词缀
    • 以紧跟着长度为 3 的哈法词缀同理。如果 s[i+1,i+3] 为一个合法的词缀,则必须要满足以下两个条件之一

      1. s[i+1,i+3]s[i+4,i+6] 不相等,即后面的后面是一个长度也为 3 且合法的词缀
      2. s[1,i+5] 是一个合法的词根,即 f[i+5] 标记为真,即后面的后面是一个长度为 2 且合法的词缀

    时间复杂度:O(nlogn)O(n \log n) - dp 的过程是线性的,主要时间开销在 set 的自动排序上

dfs 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
#include <iostream>
#include <cstring>
#include <vector>
#include <queue>
#include <stack>
#include <algorithm>
#include <unordered_map>
#include <set>
using namespace std;

string s;
set<string> res;

// 当前词缀起始下标 idx,当前词缀长度 length,后方相邻的词缀 post
void dfs(int idx, int length, string post) {
if (idx <= 5) return;

string now = s.substr(idx, length);

if (now == post) {
// 不合法直接返回
return;
} else {
// 合法则将当前词缀加入集合自动排序,并继续搜索接下来可能的词缀
res.insert(now);
dfs(idx - 2, 2, now);
dfs(idx - 3, 3, now);
}
}

void solve() {
cin >> s;
s = "$" + s;

int tail_point = s.size();

dfs(tail_point - 2, 2, "");
dfs(tail_point - 3, 3, "");

cout << res.size() << "\n";
for (auto& str: res) cout << str << "\n";
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

dp 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
#include <iostream>
#include <cstring>
#include <vector>
#include <queue>
#include <stack>
#include <algorithm>
#include <unordered_map>
#include <set>
using namespace std;

const int N = 50010;

string s;
set<string> res;
bool f[N]; // f[i] 表示 s[1,i] 是否可以作为词根存在

void solve() {
cin >> s;
s = "$" + s;

// 字符串定义在 [1,n] 上
int n = s.size() - 1;

// 长度为 n 的字符串一定可以作为词根
f[n] = true;

for (int i = n; i >= 5; i--) {
for (int len = 2; len <= 3; len++) {
if (f[i + len]) {
string a = s.substr(i + 1, len);
string b = s.substr(i + 1 + len, len);
if (a != b || f[i + 5]) {
res.insert(a);
f[i] = true;
}
}
}
}

cout << res.size() << "\n";

for (auto& x: res) cout << x << "\n";
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【线性dp】最小化网络并发线程分配

https://vijos.org/d/nnu_contest/p/1492

题意:现在有一个线性网络需要分配并发线程,每一个网络有一个权重,现在有一个线程分配规则。对于当前网络,如果权重比相邻的网络大,则线程就必须比相邻的网络大。

思路:我们从答案角度来看,对于一个网络,我们想知道它的相邻的左边线程数和右边线程数,如果当前网络比左边和右边的权重都大,则就是左右线程数的最大值+1,当然这些的前提是左右线程数已经是最优的状态,因此我们要先求“左右线程”。分析可知,左线程只取决于左边的权重与线程数,右线程同样只取决于右边的权重和线程数,因此我们可以双向扫描一遍即可求得“左右线程”。最后根据“左右线程”即可求得每一个点的最优状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
void solve() {
int n; cin >> n;
vector<int> w(n + 1), l(n + 1, 1), r(n + 1, 1);
for (int i = 1; i <= n; i++) {
cin >> w[i];
}
for (int i = 2, j = n - 1; i <= n && j >= 1; i++, j--) {
if (w[i] > w[i - 1]) {
l[i] = l[i - 1] + 1;
}
if (w[j] > w[j + 1]) {
r[j] = r[j + 1] + 1;
}
}
int res = 0;
for (int i = 1; i <= n; i++) {
res += max(l[i], r[i]);
}
cout << res << "\n";
}

【线性dp】Block Sequence

https://codeforces.com/contest/1881/problem/E

题意:给定一个长为 n2×105n\le 2\times 10^5 的数组 a,问最少可以删除其中的几个元素,使得最终的数组可以被划分为连续的子数组且每一个子数组的元素个数均为「子数组首元素数值 1-1」的形式。

思路:

  • 首先可以看出,序列的首元素一定是对应子数组剩余元素的个数。我们不妨倒序枚举每一个元素并定义 f[i] 表示使数组 a[i:] 符合定义的最小删除次数,这样每次枚举到的元素都一定是所在子数组的首元素。
  • 接下来考虑状态转移。对于当前枚举到的元素 a[i],如果不删除,则 f[i] 将从 f[i+a[i]+1] 转移过来;如果删除,则 f[i] 将从 f[i+1] 转移过来。两者取最小值即可。

时间复杂度 O(n)O(n)

1
2
3
4
5
6
7
8
9
10
def solve() -> Optional:
n = II()
a = LII()
f = [INF] * (n + 1)
f[n] = 0
for i in range(n - 1, -1, -1):
f[i] = f[i + 1] + 1
if i + a[i] + 1 <= n:
f[i] = min(f[i], f[i + a[i] + 1])
return f[0]

【线性dp】覆盖墙壁

https://www.luogu.com.cn/problem/P1990

题意:给定两种砖块,分别为日字型与L型,问铺满 2n2*n 的地板一共有多少种铺法

思路:

  • 我们采用递推的思路
  • define:define: f[i] 表示铺满前 2i2*i 个地板的方案数,g[i] 表示铺满前 2i+12*i+1 块地板的方案数
  • base:base: f[0] = 1, f[1] = 1, g[0] = 0, g[1] = 1
  • dp:dp: f[i] = f[i-1] + f[i-2] + g[i-1] || g[i] = f[i-1] + g[i-1]
  • result:result: f[n]

手绘:

图例

图例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <bits/stdc++.h>
using namespace std;

const int N = 1000010, mod = 10000;

int n;
int f[N], g[N];

void solve() {
cin >> n;

// base
f[0] = 1, f[1] = 1;
g[0] = 0, g[1] = 1;

// dp
for (int i = 2; i <= n; i++) {
f[i] = (f[i - 1] + f[i - 2] + 2 * g[i - 2]) % mod;
g[i] = (f[i - 1] + g[i - 1]) % mod;
}

// result
cout << f[n] << "\n";
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【线性dp】施咒的最大总伤害

https://leetcode.cn/problems/maximum-total-damage-with-spell-casting/

标签:线性 dp

题意:给定一个数组,选择一个子序列使得总和最大。约束为:如果选择了值为 xx 的元素,则不允许选择值为 x2,x1,x+1,x+2x-2,x-1,x+1,x+2 的元素

思路:

  • 哈希计数。首先很显然的我们需要进行哈希计数,便于按数值升序枚举元素。我们定义哈希后的序列为 v,大小为 m,存储每一种数的数值 val: int 和个数 cnt: int
  • 状态定义。我们定义状态 f[i] 表示哈希序列 [1,i] 中子序列总和的最大值,则最终答案就是 f[m]
  • 状态转移。对于每一个哈希元素,都有选择和不选两种状态。假设当前枚举到的哈希元素为 i
    • 不选当前元素。就相当于没有当前元素,则 f[i] = f[i-1]
    • 选择当前元素。我们就需要从哈希序列 [1,i-1] 中,比当前哈希元素 v[i].val 小 2 且对应子序列之和最大的那个状态 f[j] 转移过来。显然我们可以直接枚举哈希序列 [1,i-1],但是这样就是 O(n2)O(n^2) 必然超时,考虑优化。不难发现 f[] 数组是单调递增的并且哈希序列的元素数值 v[].val 也是单调递增的。也就是说对于当前状态 f[i],在枚举哈希序列 [1,i-1] 时,其实并不需要从 1 开始枚举,可以从上一个状态枚举到的状态(记作 j)开始枚举(因为上一个状态对应的 f[j] 是上一个状态可转移的方案中最大的并且 v[j].val 一定比当前方案的哈希数值 v[i].val 小 2)。于是状态转移的时间开销就从 O(n)O(n) 优化到 O(1)O(1),可以通过此题

时间复杂度:O(n)O(n)

空间复杂度:O(n)O(n)

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
public:
long long maximumTotalDamage(vector<int>& power) {
using ll = long long;

// hash
map<int, int> a;
for (int x: power) {
a[x]++;
}
int m = a.size(), idx = 1;
struct node { ll val, cnt; };
vector<node> v(m + 1);
for (auto it: a) {
v[idx++] = {it.first, it.second};
}

// dp
vector<ll> f(m + 1);
f[1] = v[1].val * v[1].cnt;
for (int i = 2, j = 1; i <= m; i++) {
while (j < i && v[j].val < v[i].val - 2) {
j++;
}
j--;

f[i] = max(f[i - 1], f[j] + v[i].val * v[i].cnt);
}

return f[m];
}
};
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution:
def maximumTotalDamage(self, power: List[int]) -> int:
from collections import defaultdict

# hash
a = defaultdict(int)
for x in power:
a[x] += 1
a = sorted(a.items(), key=lambda x: x[0])

class node:
def __init__(self, val: int, cnt: int) -> None:
self.val = val
self.cnt = cnt

m, idx = len(a), 1
v = [node(0, 0)] * (m + 1)
for it in a:
v[idx] = node(it[0], it[1])
idx += 1

# dp
f = [0] * (m + 1)
f[1], j = v[1].val * v[1].cnt, 1
for i in range(2, m + 1):
while j < i and v[j].val < v[i].val - 2:
j += 1
j -= 1

f[i] = max(f[i - 1], f[j] + v[i].val * v[i].cnt)

return f[m]

【线性dp】奇怪的汉诺塔

https://www.acwing.com/problem/content/98/

题意:四塔汉诺塔问题。求在给定 nn 个圆盘的情况下的最少移动方案数。

思路:

  • 我们定义「最小完成单元」为:在满足游戏规则的情况下,将一座塔的所有圆盘移动到另一座塔所需的最少塔数。那么显然的最小完成单元为 3 座塔。定义 d[i] 表示三塔模式下 ii 个盘的最少移动次数,f[i] 表示四塔模式下 ii 个盘的最少移动次数。

  • 对于 4 座塔的情况,相当于最小完成单元又多了 1 座塔。那么显然多出来的这座塔有两个处置方案:

    • 如果我们不利用这座塔。那么还剩 3 座塔,也就是最小完成单元,此时的最少移动次数是唯一的,也就是 f[i] = d[i]

    • 如果我们要利用这座塔。那么我们只能在这一座塔上按规则放圆盘。因为要确保最小完成单元来让剩余的圆盘移动到另一座塔。如果还在别的塔上放置了圆盘,那么将不符合最小完成单元的定义,游戏无法结束。当然我们不能将所有的圆盘都先放到这座塔上,因为这种情况下是不可能成立的,我们不可能让一个「我们正在求解的问题」作为我们的答案。也就是说 j<ij<i

  • 至此四塔模式下 ii 个圆盘游戏方案的组成集合已全部确定,如下图所示,即:在四塔模式下移动 j[0,i1]j \in [0,i-1] 个圆盘到其中一座空塔上,方案数为 f[j]f[j];剩余的 iji-j 个圆盘处于最小完成单元的局面,方案数是唯一的 d[ij]d[i-j];最后再将一开始移动的 jj 个圆盘在四塔模式下移动到刚才剩余圆盘移动到的塔上,方案数为 f[j]f[j]

集合划分

于是最终的状态转移方程为:

fi=min{fj+dij+fj},i[1,n],j[0,i1]f_i = \min{ \{ f_j+d_{i-j}+f_j \} },\quad i \in [1,n],\quad j \in [0,i-1]

时间复杂度:O(n2)O(n^2)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

void solve() {
int n = 12;
vector<int> d(n + 1); // d[i] means move i pans to another tower with 3 towers
vector<int> f(n + 1, 1e9); // f[i] means move i pans to another tower with 4 towers

d[1] = 1;
for (int i = 1; i <= n; i++) {
d[i] = 1 + 2 * d[i - 1];
}
f[1] = 1;
for (int i = 1; i <= n; i++) {
for (int j = 0; j <= i - 1; j++) {
f[i] = min(f[i], f[j] + d[i - j] + f[j]);
}
cout << f[i] << "\n";
}
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
// std::cin >> T;
while (T--) solve();
return 0;
}

【线性dp/dfs】数的计算

https://www.luogu.com.cn/problem/P1028

题意:给定一个数和一种构造方法,即对于当前的数,可以在其后面添加一个最大为当前一半大的数,以此类推构造成一个数列。问一共可以构造出多少个这种数列

思路一:dfs

  • 非常显然的一个搜索树,答案就是结点数
  • 时间复杂度:O(方案数)O(\text{方案数})

思路二:dp

  • 非常显然的一个dp,我们定义一个dp记忆数组。其中 dp[i] 表示数字 i 的构造方案总数,那么状态转移方程就是

    dp[i]=j=1i/2dp[j]+1dp[i]=\sum_{j=1}^{\left\lfloor i/2 \right\rfloor}dp[j]+1

  • 时间复杂度:O(n2)O(n^2)

dfs代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int n, res;

void dfs(int x) {
res++;
for (int i = x >> 1; i >= 1; i--) {
dfs(i);
}
}

void solve() {
cin >> n;
dfs(n);
cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

dp代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int n;

void solve() {
cin >> n;

vector<ll> dp(n + 1, 1);
for (int i = 2; i <= n; i++) {
for (int j = 1; j <= i >> 1; j++) {
dp[i] += dp[j];
}
}

cout << dp[n] << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【线性dp/二分答案】规划兼职工作

https://leetcode.cn/problems/maximum-profit-in-job-scheduling/description/

题意:给定 n 份工作的起始时间、终止时间、收益值,现在需要不重叠时间的选择工作使得收益最大

思路:动态规划、二分答案。为了不重不漏的枚举每一份工作,我们将工作按照结束时间进行排序,然后就可以枚举每一份工作了。接下来要解决的问题是,如何根据起始时间和终止时间进行工作的选择。显然的,每一份工作有选与不选两种状态,是否选择取决于收益是否更优,我们考虑动态规划。我们定义状态表 f[i] 表示在前 i 个工作中选择的最大工作收益,返回值就是 f[n],先看图

图例

  1. 选择第 i 个工作:f[i] = f[r] + profit[i](其中 r 表示 [1,i-1] 份工作中,结束时间早于当前工作起始时间的最右边的工作,在 [1,i-1] 中二分查找即可)
  2. 不选第 i 个工作:f[i] = f[i - 1]

选择最大属性进行状态转移即可:f[i] = max(f[i - 1], f[r] + profit[i])

时间复杂度:O(nlogn)O(n\log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Solution {
public:
int jobScheduling(vector<int>& startTime, vector<int>& endTime, vector<int>& profit) {
int n = startTime.size();
vector<array<int, 3>> jobs(n + 1);

for (int i = 1; i <= n; i++) {
jobs[i] = {startTime[i - 1], endTime[i - 1], profit[i - 1]};
}

sort(jobs.begin() + 1, jobs.end(), [&](array<int, 3>& x, array<int, 3>& y){
return x[1] < y[1];
});

vector<int> f(n + 1);
for (int i = 1; i <= n; i++) {
int l = 0, r = i - 1;
while (l < r) {
int mid = (l + r + 1) >> 1;
if (jobs[mid][1] <= jobs[i][0]) l = mid;
else r = mid - 1;
}
f[i] = max(f[i - 1], f[r] + jobs[i][2]);
}

return f[n];
}
};

【线性dp/二分查找】最长上升子序列

https://www.luogu.com.cn/problem/B3637

题意:给定序列,求解其中最长上升子序列 (Longest Increasing Subsequence, 简称 LIS) 的长度。

思路:我们采用动态规划。

状态定义:定义状态数组 f[i] 表示以元素 a[i] 结尾的最长上升子序列的长度,显然的初始状态全都是 1

状态转移

  • 暴力枚举。首先很显然的 f[i] 一定是从 [0,i-1] 中比当前元素值小的状态转移过来,于是可以很轻松的写出双重循环的代码,时间复杂度 O(n2)O(n^2)

  • 贪心+二分优化

    • 回顾朴素。优化需要在理解转移本质的基础上展开,因此我们先来回顾一下朴素版的转移方案:从 [0,i-1] 中比当前元素小的元素 a[j] 对应的最长上升子序列长度 f[j] 转移过来。我们枚举了 [0,i-1] 所有的状态确保了不重不漏。对于 i = 6 来说,如下图所示。其中✔表示进入了 if 的判断逻辑。最终我们选择从 j = 5 对应的 f[j] = 3 转移而来。这里的判断准则为:对于每一个元素,需要找前面的元素中比当前元素小的元素。

      枚举元素

    • 转换视角。同样对于每一个元素 a[i],我们不再按顺序枚举前面已经计算出来的 f[1:i-1],而是预先按照长度存储所有的 f[1:i-1] 从而枚举长度 j,显然可以贪心的从最大长度 mxa_len 开始枚举。一旦遇到比 a[i] 小的元素,那么 f[i] 就等于当前枚举的长度+1,即 f[i] = j + 1。对于 i = 6 来说,如下图所示。这样也可以做到不重不漏的枚举所有状态。但是显然的,这种方法只是变相地枚举了所有的元素,仍然是 O(n2)O(n^2),并且和枚举元素相比还多了一点思维量和代码量。

      枚举长度

    • 优化诞生。在枚举元素时,并没有额外有价值的信息,比如元素并非单调排列,f[] 数组也并非单调。但转换到枚举长度,让我们有机会进行优化!不难发现,对于每一个元素 a[i],我们在按长度 [1,max_len] 枚举可能的尾元素 f[1:i-1] 时,由于 max_len 每一次更新时的增量都是 1,因此 [1,max_len] 的每一个取值都一定会有对应的元素存在!贪心的,对于同一个长度下所有子序列的尾元素,我们只关心其中值最小的那一个尾元素,因此我们定义一个 mi[] 数组来存储每一个长度下子序列尾元素的最小值,其中 mi[i] 表示长度为 i 的最长上升子序列中,最小的尾元素值。这样在枚举长度 [1,max_len] 时,就不再需要枚举每一个长度下所有的尾元素,仅需枚举每一个长度下唯一的那个最小尾元素。但如果每一个长度都刚好只有一个元素,那岂不是还是需要线性枚举?更进一步的,我们可以发现,此时每一个长度对应的唯一元素组成的序列是严格单调递增的(反证法易证)!有了这样单调的性质我们就可以利用二分加速枚举,这样可以将枚举的时间复杂度严格控制在 O(logn)O(\log n)。显然的,我们希望当前的元素可以拼接在尽可能长的子序列后面,因此此处的二分使用的是寻找右边界的板子。

      优化

    • 代码实现。枚举每一个元素肯定是少不了的,我们使用 mi[j] 表示最长上升子序列长度为 j 的序列尾元素的最小值。初始状态显然就是 mi[1] = a[0]。在每一个元素二分查询「最大的比当前元素小的元素」时,如果查出来的元素 mi[r] 确实比当前元素小,则需要更新 mi[r+1] 的值;反之如果查出来的元素 mi[r] 比当前元素还小,则表明二分到了左边界,此时 r 一定是 1,直接用当前元素覆盖 mi[1] 即可。

答案表示。最终答案就是 max_len

时间复杂度:O(nlogn)O(n \log n)

暴力枚举元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

void solve() {
int n;
cin >> n;

vector<int> a(n);
for (int i = 0; i < n; i++) {
cin >> a[i];
}

vector<int> f(n, 1);
for (int i = 0; i < n; i++) {
for (int j = 0; j < i; j++) {
if (a[i] > a[j]) {
f[i] = max(f[i], f[j] + 1);
}
}
}

cout << *max_element(f.begin(), f.end()) << "\n";
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
// std::cin >> T;
while (T--) solve();
return 0;
}

暴力枚举长度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

void solve() {
int n;
cin >> n;

vector<int> a(n);
for (int i = 0; i < n; i++) {
cin >> a[i];
}

vector<vector<int>> dp(n + 1, vector<int>());
int max_len = 1;
dp[1].push_back(a[0]);
for (int i = 1; i < n; i++) {
bool ok = false;
// 贪心的从已经出现过的最大长度开始枚举
for (int j = max_len; j >= 1; j--) {
// 对于当前长度下的所有元素
for (int x: dp[j]) {
if (a[i] > x) {
dp[j + 1].push_back(a[i]);
max_len = max(max_len, j + 1);
ok = true;
goto flag;
}
}
}
flag:
if (!ok) {
dp[1].push_back(a[i]);
}
}

cout << max_len << "\n";
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
// std::cin >> T;
while (T--) solve();
return 0;
}

贪心二分优化

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

void solve() {
int n;
cin >> n;

vector<int> a(n);
for (int i = 0; i < n; i++) {
cin >> a[i];
}

vector<int> mi(n + 1, INT_MAX);
int max_len = 1;
mi[1] = a[0];
for (int i = 1; i < n; i++) {
int l = 1, r = max_len;
while (l < r) {
int mid = (l + r + 1) >> 1;
if (a[i] > mi[mid]) l = mid;
else r = mid - 1;
}

if (a[i] > mi[r]) {
mi[r + 1] = min(mi[r + 1], a[i]);
max_len = max(max_len, r + 1);
} else {
// 此时长度 r 一定是 1 并且当前元素是长度为 1 的子序列尾元素中最小的
mi[1] = a[i];
}
}

cout << max_len << "\n";
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
// std::cin >> T;
while (T--) solve();
return 0;
}

【网格图dp/dfs】过河卒

https://www.luogu.com.cn/problem/P1002

题意:给定一个矩阵,现在需要从左上角走到右下角,问一共有多少种走法?有一个特殊限制是,对于图中的9个点是无法通过的。

思路一:dfs

  • 我们可以采用深搜的方法。但是会超时,我们可以这样估算时间复杂度:对于每一个点,我们都需要计算当前点的右下角的矩阵中的每一个点,那么总运算次数就近似为阶乘级别。当然实际的时间复杂度不会这么大,但是这种做法 nmn*m 一旦超过100就很容易tle

  • 时间复杂度:O(nm!)O(nm!)

思路二:dp

  • 我们可以考虑,对于当前的点,可以从哪些点走过来,很显然就是上面一个点和左边一个点,而对于走到当前这个点的路线就是走到上面的点和左边的点的路线之和,base状态就是 dp[1][1] = 1,即起点的路线数为1

  • 时间复杂度:O(nm)O(nm)

dfs代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 30;

int n, m, a, b;
int res;
bool notsafe[N][N];

void init() {
int px[9] = {0, -1, -2, -2, -1, 1, 2, 2, 1};
int py[9] = {0, 2, 1, -1, -2, -2, -1, 1, 2};
for (int i = 0; i < 9; i++) {
int na = a + px[i], nb = b + py[i];
if (na < 0 || nb < 0) continue;
notsafe[na][nb] = true;
}
}

void dfs(int x, int y) {
if (x > n || y > m || notsafe[x][y]) {
return;
}

if (x == n && y == m) {
res++;
return;
}

dfs(x, y + 1);
dfs(x + 1, y);
}

void solve() {
cin >> n >> m >> a >> b;
init();
dfs(0, 0);
cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

dp代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 30;

int n, m, a, b;
bool notsafe[N][N];
ll dp[N][N];

void solve() {
cin >> n >> m >> a >> b;

// 初始化
++n, ++m, ++a, ++b;
int px[9] = {0, -1, -2, -2, -1, 1, 2, 2, 1};
int py[9] = {0, 2, 1, -1, -2, -2, -1, 1, 2};
for (int i = 0; i < 9; i++) {
int na = a + px[i], nb = b + py[i];
if (na < 0 || nb < 0) continue;
notsafe[na][nb] = true;
}

// dp求解
dp[1][1] = 1;
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
if (!notsafe[i - 1][j]) dp[i][j] += dp[i - 1][j];
if (!notsafe[i][j - 1]) dp[i][j] += dp[i][j - 1];
}
}

cout << dp[n][m] << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【网格图dp】摘樱桃 II

https://leetcode.cn/problems/cherry-pickup-ii/

题意:给定一个 nnmm 列的网格图,每个格子拥有一个价值。现在有两个人分别从左上角和右上角开始移动,移动方式为「左下、下、右下」三个方向,问两人最终最多一共可以获得多少价值?如果两人同时经过一个位置,则只能算一次价值。

思路:

  • 状态定义。最终状态一定是两人都在最后一行的任意两列,我们如何定义状态可以不重不漏的表示两人的运动状态呢?可以发现两人始终在同一行,但是列数不一定相同,我们定义状态 f[i][j][k]f[i][j][k] 表示两人走到第 ii 行时,一人在第 jj 列,另一人在第 kk 列时的总价值。则答案就是:

    max(f[n1][j][k]),j,k[0,m]\max{(f[n-1][j][k])},j,k\in[0,m]

  • 状态转移。本题难点在于状态定义,一旦定义好了以后状态转移就不困难了。对于当前状态 f[i][j][k]f[i][j][k],根据乘法原理,两人均有 3 种走法,因此当前状态可以从 3×3=93\times 3=9 种合法状态转移过来。取最大值即可。

时间复杂度:O(nm2)O(nm^2)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution:
def cherryPickup(self, g: List[List[int]]) -> int:
n, m = len(g), len(g[0])
f = [[[-1] * m for _ in range(m)] for _ in range(n)]
f[0][0][m - 1] = g[0][0] + g[0][m - 1]
for i in range(1, n):
for j in range(m):
for k in range(m):
# 枚举 9 个子问题
for p in range(j - 1, j + 2):
for q in range(k - 1, k + 2):
if 0 <= p < m and 0 <= q < m and f[i - 1][p][q] != -1:
f[i][j][k] = max(f[i][j][k], f[i - 1][p][q] + g[i][j] + (0 if j == k else g[i][k]))

res = 0
for j in range(m):
for k in range(j, m):
res = max(res, f[n - 1][j][k])

return res

【网格图dp】摘樱桃

弱化版 (n50)(n\le 50)https://leetcode.cn/problems/cherry-pickup/

强化版 (n300)(n\le300)https://codeforces.com/problemset/problem/213/C

题意:给定一个 n 行 n 列的网格图,问从左上走到右下,再从右下走到左上最多可以获得多少价值?一个单元格只能被计算一次价值。

注:弱化版中 -1 表示不可达,强化版没有不可达的约束。强化版 AC 代码

思路一:暴力dp

  • 首先对于来回问题可以转化为两次去的问题,即问题等价于求解「两个人从左上角走到右下角」的最大收益。
  • 显然我们可以定义四维的状态,其中 f[i][j][p][q]f[i][j][p][q] 表示第一个人走到 (i,j)(i,j) 且第二个人走到 (p,q)(p,q) 时的最大收益。直接枚举这四个维度,然后从 4 个合法的子问题转移过来即可。
  • 时间复杂度:O(n4)O(n^4)

思路二:优化dp

  • 注意到问题可以进一步等价于「两个人同时从左上角走到右下角」的最大收益。也就是两个人到起点 (0,0)(0,0) 的曼哈顿距离应该是一样的,即 i+j=p+qi+j=p+q,也就是说如果其中一个人 (i,j)(i,j) 的位置确定了,则另一个人只需要枚举一个下标,另一个下标就可以 O(1)O(1) 的确定了。可以将时间复杂度降低一个维度。
  • 状态定义。我们定义 f[k][i1][i2]f[k][i_1][i_2] 表示第一个人走到 (i1,ki1)(i_1,k-i_1) 且第二个人走到 (i2,ki2)(i_2,k-i_2) 时的最大收益。则最终答案就是 f[2n2][n1][n1]f[2n-2][n-1][n-1]。状态转移同理,从 4 个合法子问题转移过来即可。
  • 时间复杂度:O(n3)O(n^3)

暴力dp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
template<class T>
void chmax(T& a, T b) {
a = max(a, b);
}

int di[4] = {-1, -1, 0, 0};
int dj[4] = {0, 0, -1, -1};
int dp[4] = {-1, 0, -1, 0};
int dq[4] = {0, -1, 0, -1};

class Solution {
public:
int cherryPickup(vector<vector<int>>& g) {
int n = g.size();
int f[n][n][n][n];
memset(f, -1, sizeof f);
f[0][0][0][0] = g[0][0] == -1 ? -1 : g[0][0];
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (g[i][j] == -1) {
continue;
}
for (int p = 0; p < n; p++) {
for (int q = 0; q < n; q++) {
if (g[p][q] == -1) {
continue;
}
// 枚举 4 个子问题
for (int k = 0; k < 4; k++) {
int ni = i + di[k], nj = j + dj[k];
int np = p + dp[k], nq = q + dq[k];
if (ni >= 0 && nj >= 0 && np >= 0 && nq >= 0 && f[ni][nj][np][nq] != -1) {
chmax(f[i][j][p][q], f[ni][nj][np][nq] + g[i][j] + (i == p && j == q ? 0 : g[p][q]));
}
}
}
}
}
}
return f[n - 1][n - 1][n - 1][n - 1] == -1 ? 0 : f[n - 1][n - 1][n - 1][n - 1];
}
};

优化dp:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
template<class T>
void chmax(T& a, T b) {
a = max(a, b);
}

int dx[] = {0, 0, -1, -1};
int dy[] = {0, -1, 0, -1};

class Solution {
public:
int cherryPickup(vector<vector<int>>& g) {
int n = g.size();
int f[2 * n - 1][n][n];
memset(f, -1, sizeof f);
f[0][0][0] = g[0][0] == -1 ? -1 : g[0][0];
for (int k = 1; k <= 2 * n - 2; k++) {
for (int i1 = 0; i1 < n; i1++) {
for (int i2 = 0; i2 < n; i2++) {
int j1 = k - i1, j2 = k - i2;
if (j1 < 0 || j1 >= n || j2 < 0 || j2 >= n || g[i1][j1] == -1 || g[i2][j2] == -1) {
continue;
}
// 枚举 4 个子问题
for (int t = 0; t < 4; t++) {
int ni1 = i1 + dx[t], nj1 = k - ni1;
int ni2 = i2 + dy[t], nj2 = k - ni2;
if (ni1 >= 0 && nj1 >= 0 && ni2 >= 0 && nj2 >= 0 && f[k - 1][ni1][ni2] != -1) {
chmax(f[k][i1][i2], f[k - 1][ni1][ni2] + g[i1][j1] + (i1 == i2 && j1 == j2 ? 0 : g[i2][j2]));
}
}
}
}
}
return f[2 * n - 2][n - 1][n - 1] == -1 ? 0 : f[2 * n - 2][n - 1][n - 1];
}
};

【树形dp】最大社交深度和

https://vijos.org/d/nnu_contest/p/1534

算法:树形dp、BFS

题意:给定一棵树,现在需要选择其中的一个结点为根节点,使得深度和最大。深度的定义是以每个结点到树根所经历的结点数

思路一:暴力

  • 显然可以直接遍历每一个结点,计算每个结点的深度和,然后取最大值即可

  • 时间复杂度:O(n2)O(n^2)

思路二:树形dp

树形dp图解

  • 我们可以发现,对于当前的根结点 fa,我们选择其中的一个子结点 ch,将 ch 作为新的根结点(如右图)。那么对于当前的 ch 的深度和,我们可以借助 fa 的深度和进行求解。我们假设以 ch 为子树的结点总数为 x,那么这 x 个结点在换根之后,相对于 ch 的深度和,贡献了 -x 的深度;而对于 fa 的剩下来的 n-x 个结点,相对于 ch 的深度和,贡献了 n-x 的深度。于是 ch 的深度和就是 fa的深度和 -x+n-x,即:

    dep[ch]=dep[fa]x+nx=dep[fa]+n2×xdep[ch] = dep[fa]-x+n-x = dep[fa]+n-2\times x

    于是我们很快就能想到利用前后层的递推关系,O(1)O(1) 的计算出所有子结点的深度和。

    代码实现:我们可以先计算出 base 的情况,即任选一个结点作为根结点,然后基于此进行迭代计算。在迭代计算的时候需要注意的点就是在一遍 dfs 计算某个结点的深度和 dep[root] 时,如果希望同时计算出每一个结点作为子树时,子树的结点数,显然需要分治计算一波。关于分治的计算我熟练度不够高,特此标注一下debug了3h的点:即在递归到最底层,进行回溯计算的时候,需要注意不能统计父结点的结点值(因为建的是双向图,所以一定会有从父结点回溯的情况),那么为了避开这个点,就需要在 O(1)O(1) 的时间复杂度内获得当前结点的父结点的编号,从而进行特判,采用的方式就是增加递归参数 fa

  • 没有考虑从父结点回溯的情况的dfs代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    void dfs(int now, int depth) {
    if (!st[now]) {
    st[now] = true;
    dep[root] += depth;
    for (auto& ch: G[now]) {
    dfs(ch, depth + 1);
    cnt[now] += cnt[ch];
    }
    }
    }
  • 考虑了从父结点回溯的情况的dfs代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    void dfs(int now, int fa, int depth) {
    if (!st[now]) {
    st[now] = true;
    dep[root] += depth;
    for (auto& ch: G[now]) {
    dfs(ch, now, depth + 1);
    if (ch != fa) {
    cnt[now] += cnt[ch];
    }
    }
    }
    }
  • 时间复杂度:Θ(2n)\Theta(2n)

暴力代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
const int N = 500010;

int n;
vector<int> G[N];
int st[N], dep[N];

void dfs(int id, int now, int depth) {
if (!st[now]) {
st[now] = 1;
dep[id] += depth;
for (auto& node: G[now]) {
dfs(id, node, depth + 1);
}
}
}

void solve() {
cin >> n;
for (int i = 1; i <= n - 1; i++) {
int a, b;
cin >> a >> b;
G[a].push_back(b);
G[b].push_back(a);
}

int res = 0;

for (int i = 1; i <= n; i++) {
memset(st, 0, sizeof st);
dfs(i, i, 1);
res = max(res, dep[i]);
}

cout << res << "\n";
}

优化代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
const int N = 500010;

int n, dep[N], root = 1;
vector<int> G[N], cnt(N, 1);;
bool st[N];

// 当前结点编号 now,当前结点的父结点 fa,当前结点深度 depth
void dfs(int now, int fa, int depth) {
if (!st[now]) {
st[now] = true;
dep[root] += depth;
for (auto& ch: G[now]) {
dfs(ch, now, depth + 1);
if (ch != fa) {
cnt[now] += cnt[ch];
}
}
}
}

void bfs() {
memset(st, 0, sizeof st);
queue<int> q;
q.push(root);
st[root] = true;

while (q.size()) {
int fa = q.front(); // 父结点编号 fa
q.pop();
for (auto& ch: G[fa]) {
if (!st[ch]) {
st[ch] = true;
dep[ch] = dep[fa] + n - 2 * cnt[ch];
q.push(ch);
}
}
}
}

void solve() {
cin >> n;
for (int i = 1; i <= n - 1; i++) {
int a, b;
cin >> a >> b;
G[a].push_back(b);
G[b].push_back(a);
}

dfs(root, -1, 1);
bfs();

cout << *max_element(dep, dep + n + 1) << "\n";
}

【高维dp/dfs】栈

https://www.luogu.com.cn/problem/P1044

题意:n个数依次进栈,随机出栈,问一共有多少种出栈序列?

思路一:dfs

  • 我们可以这么构造搜索树:已知对于当前的栈,一共有两种状态
    • 入栈 - 如果当前还有数没有入栈
    • 出栈 - 如果当前栈内还有元素
  • 搜索参数:i,j 表示入栈数为 i 出栈数为 j 的状态
  • 搜索终止条件
    • 入栈数 < 出栈数 - i<ji<j
    • 入栈数 > 总数 nn - i=ni = n
  • 答案状态:入栈数为n,出栈数也为n
  • 时间复杂度:O(方案数)O(\text{方案数})

思路二:dp

  • 采用上述dfs时的状态表示方法,i,j 表示入栈数为 i 出栈数为 j 的状态。

  • 我们在搜索的时候,考虑的是接下来可以搜索的状态

    1. 即出栈一个数的状态 - i+1,j

    2. 和入栈一个数的状态 - i,j+1

    如图:

    图解

    而我们在dp的时候,需要考虑的是子结构的解来得出当前状态的答案,就需要考虑之前的状态。即当前状态是从之前的哪些状态转移过来的。和上述dfs思路是相反的。我们需要考虑的是

    1. 上一个状态入栈一个数到当前状态 - i-1,j \to i,j
    2. 上一个状态出栈一个数到当前状态 - i,j-1 \to i,j
    • 特例:i=ji=j 时,只能是上述第二种状态转移而来,因为要始终保证入栈数大于等于出栈数,即 iji \ge j

    如图:

    图解

  • 我们知道,入栈数一定是大于等于出栈数的,即 iji\ge j。于是我们在枚举 jj 的时候,枚举的范围是 [1,i][1,i]

  • basebase 状态的构建取决于 j=0j=0 时的所有状态,我们知道没有任何数出栈也是一种状态,于是

    dp[i][0]=0,(i=1,2,3,...,n)dp[i][0]=0,(i=1,2,3,...,n)

  • 时间复杂度:O(n2)O(n^2)

dfs代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

int n, res;

// 入栈i个数,出栈j个数
void dfs(int i, int j) {
if (i < j || i > n) return;

if (i == n && j == n) res++;

dfs(i + 1, j);
dfs(i, j + 1);
}

void solve() {
cin >> n;
dfs(0, 0);
cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

dp代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <bits/stdc++.h>
using namespace std;
typedef long long ll;

const int N = 20;

int n;
ll dp[N][N]; // dp[i][j] 表示入栈数为i,出栈数为j的方案总数

void solve() {
cin >> n;

// base状态:没有数出栈也是一种状态
for (int i = 1; i <= n; i++) dp[i][0] = 1;

// dp转移
for (int i = 1; i <= n; i++)
for (int j = 1; j <= i; j++)
if (i == j) dp[i][j] = dp[i][j - 1];
else dp[i][j] = dp[i - 1][j] + dp[i][j - 1];

cout << dp[n][n] << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【高维dp】找出所有稳定的二进制数组 II

https://leetcode.cn/problems/find-all-possible-stable-binary-arrays-ii/

题意:构造一个仅含有 nn00mm11 的数组,且长度超过 kk 的子数组必须同时含有 0011。给出构造的总方案数对 109+710^9+7 取余后的结果。

思路:

  • 定义状态表。我们从左到右确定每一位的数字时,需要确定这么三个信息:当前是哪一位?还剩几个 0011?这一位填 00 还是 11?对于这三个信息,我们可以定义状态数组 f[i][j][k]f[i][j][k] 表示当前已经选了 ii00jj11,且第 i+ji+j 位填 kk 时的方案数。这样最终答案就是 f[n][m][0]+f[n][m][1]f[n][m][0]+f[n][m][1]

  • 定义子问题。以当前第 i+ji+j 位填 00 为例,填 11 同理,即当前需要维护的是 f[i][j][0]f[i][j][0]。显然的如果前一位是和当前位不同,即 11 时,可以直接转移过来;如果前一位和当前位相同,即 00 时,有可能会出现长度超过 kk 的连续 00 子数组,即前段数组刚好是以「一个 11kk00 结尾的」合法数组,此时再拼接一个 00 就不合法了。

  • 状态转移方程。综上所述,可以得到以下两个状态转移方程:

    f[i][j][0]=f[i1][j][1]+f[i1][j][0]f[ik1][j][1]f[i][j][1]=f[i][j1][0]+f[i][j1][1]f[i][jk1][0]\begin{aligned}f[i][j][0] = f[i - 1][j][1] + f[i - 1][j][0] - f[i - k - 1][j][1] \\f[i][j][1] = f[i][j - 1][0] + f[i][j - 1][1] - f[i][j - k - 1][0]\end{aligned}

时间复杂度:O(nm)O(nm)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Solution {
public:
int numberOfStableArrays(int n, int m, int k) {
const int mod = 1e9 + 7;
vector<vector<array<int, 2>>> f(n + 1, vector<array<int, 2>>(m + 1));

for (int i = 0; i <= min(n, k); i++) {
f[i][0][0] = 1;
}
for (int j = 0; j <= min(m, k); j++) {
f[0][j][1] = 1;
}

for (int i = 1; i <= n; i++) {
for (int j = 1; j <= m; j++) {
f[i][j][0] = (f[i - 1][j][1] + f[i - 1][j][0]) % mod;
if (i - k - 1 >= 0) {
f[i][j][0] = (f[i][j][0] - f[i - k - 1][j][1] + mod) % mod;
}
f[i][j][1] = (f[i][j - 1][0] + f[i][j - 1][1]) % mod;
if (j - k - 1 >= 0) {
f[i][j][1] = (f[i][j][1] - f[i][j - k - 1][0] + mod) % mod;
}
}
}

return (f[n][m][0] + f[n][m][1]) % mod;
}
};

【高维dp】学生出勤记录 II

https://leetcode.cn/problems/student-attendance-record-ii/description/

题意:构造一个仅含有 P,A,LP,A,L 三种字符的字符串,使得 AA 最多出现 11 次,同时 LL 最多连续出现 22 次。问一共有多少种构造方案?对 109+710^9+7 取模。

思路:

  • 模拟分析问题。我们从前往后构造。对于当前第 ii 个字符 s[i]s[i],有 33 种可能的选项,其中 PP 是一定合法的选项,AA 是否合法取决于 s[0:i1]s[0:i-1] 中的 AA 的数量,LL 是否合法取决于 s[0:i1]s[0:i-1] 的末尾连续 LL 的数量。因此我们可以通过被动转移的动态规划求解。
  • 状态定义。从上述分析不难发现需要 33 种状态来表示所有情况,因此我们定义 f[i][j][k]f[i][j][k] 表示构造第 ii 位时,s[0:i]s[0:i] 中含有 jjAA 且末尾含有连续 kkLL 时的方案数。
  • 初始化。每一位填什么取决于前缀 s[0:i1]s[0:i-1] 的情况,因此我们需要初始化 f[0][][]f[0][][] 即第 00 位的情况。显然的第 00P,A,LP,A,L 三种都可以填,对应的就是 f[0][0][0]=f[0][1][0]=f[0][0][1]=1f[0][0][0] = f[0][1][0] = f[0][0][1] = 1,其余初始化为 00 即可。
  • 状态转移。考虑第 ii 位的三种情况:
    1. PP:可以用前缀的所有状态来更新 f[i][j][0]f[i][j][0]
    2. AA:可以用前缀中不含 AA 的所有状态来更新 f[i][1][0]f[i][1][0]
    3. LL:可以用前缀中末尾不超过 11LL 的所有状态来更新 f[i][j][k]f[i][j][k]
  • 最终答案。为 j=01k=02f[n1][j][k]\displaystyle \sum_{j=0}^{1}\sum_{k=0}^{2}f[n-1][j][k]

时间复杂度:Θ(6n)\Theta (6n)

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class Solution {
public:
int checkRecord(int n) {
const int mod = 1e9 + 7;

// f[i][j][k]表示从左往右构造第i位时,s[0:i]中含有j个A且尾部含有连续k个L时的方案数
int f[n][2][3];
memset(f, 0, sizeof f);

// 初始化
f[0][0][0] = f[0][1][0] = f[0][0][1] = 1;

// 转移
for (int i = 1; i < n; i++) {
// s[i]填P
for (int j = 0; j <= 1; j++) {
for (int k = 0; k <= 2; k++) {
f[i][j][0] = (f[i][j][0] + f[i - 1][j][k]) % mod;
}
}
// s[i]填A
for (int k = 0; k <= 2; k++) {
f[i][1][0] = (f[i][1][0] + f[i - 1][0][k]) % mod;
}
// s[i]填L
for (int j = 0; j <= 1; j++) {
for (int k = 1; k <= 2; k++) {
f[i][j][k] = (f[i][j][k] + f[i - 1][j][k - 1]) % mod;
}
}
}

// 计算答案
int res = 0;
for (int j = 0; j <= 1; j++) {
for (int k = 0; k <= 2; k++) {
res = (res + f[n - 1][j][k]) % mod;
}
}
return res;
}
};
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution:
def checkRecord(self, n: int) -> int:
mod = int(1e9 + 7)
f = [[[0, 0, 0] for _ in range(2)] for _ in range(n)]
f[0][0][0] = f[0][1][0] = f[0][0][1] = 1
for i in range(1, n):
# P
for j in range(2):
for k in range(3):
f[i][j][0] = (f[i][j][0] + f[i - 1][j][k]) % mod
# A
for k in range(3):
f[i][1][0] = (f[i][1][0] + f[i - 1][0][k]) % mod
# L
for j in range(2):
for k in range(1, 3):
f[i][j][k] = (f[i][j][k] + f[i - 1][j][k - 1]) % mod
res = 0
for j in range(2):
for k in range(3):
res = (res + f[n - 1][j][k]) % mod
return res

【区间dp】对称山脉

https://www.acwing.com/problem/content/5169/

模拟,时间复杂度 O(n3)O(n^3)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <iostream>
#include <cmath>

using namespace std;

const int N = 5010;

int n;
int a[N];

int main() {
cin >> n;

for (int i = 1; i <= n; i++)
cin >> a[i];

// 枚举区间长度
for (int len = 1; len <= n; len++) {
int res = 2e9;
// 枚举相应长度的所有区间
for (int i = 1, j = i + len - 1; j <= n; i++, j++) {
// 计算区间的不对称值
int l = i, r = j;
int sum = 0;
while (l < r) {
sum += abs(a[l] - a[r]);
l++, r--;
}
res = min(res, sum);
}
cout << res << ' ';
}

return 0;
}

dp优化,时间复杂度 O(n2)O(n^2)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <iostream>
#include <cmath>
#include <cstring>

using namespace std;

const int N = 5010;

int n;
int a[N];

int dp[N][N]; // dp[i][j] 表示第 i 到 j 的不对称值
int res[N]; // res[len] 表示长度为 len 的山脉的最小不对称值

int main() {
cin >> n;

for (int i = 1; i <= n; i++)
cin >> a[i];

memset(res, 0x3f, sizeof res);

// 长度为 1 的情况
res[1] = 0;

// 长度为 2 的情况
for (int i = 1, j = i + 1; j <= n; i++, j++) {
dp[i][j] = abs(a[i] - a[j]);
res[2] = min(res[2], dp[i][j]);
}

// 长度 >= 3 的情况
for (int len = 3; len <= n; len++) {
for (int i = 1, j = i + len - 1; j <= n; i++, j++) {
dp[i][j] = dp[i + 1][j - 1] + abs(a[i] - a[j]);
res[len] = min(res[len], dp[i][j]);
}
}

for (int i = 1; i <= n; i++)
cout << res[i] << ' ';

return 0;
}

【状压dp】Avoid K Palindrome 🔥

https://atcoder.jp/contests/abc359/tasks/abc359_d

题意:给定一个长度为 n1000n\le 1000 的字符串 ss 和一个整数 k10k\le10,其中含有若干个 'A','B''?'。其中 '?' 可以转化为 'A''B',假设有 qq'?',则一共可以转化出 2q2^q 个不同的 ss。问所有转化出的 ss 中,有多少是不含有长度为 kk 的回文子串的。

思路:最暴力的做法就是 2^q 枚举所有可能的字符串,然后再 O(n) 的检查,这样时间复杂度为 O(n2n)O(n2^n),只能通过 20 以内的数据。一般字符串回文问题可以考虑 dp。

时间复杂度:

1

]]>
+ 数据结构

数据结构由 数据结构 两部分组成。我们主要讨论的是后者,即结构部分。

按照 逻辑结构 可以将其区分为 线性结构非线性结构

线性数据结构 与 非线性数据结构

按照 物理结构 可以将其区分为 连续结构分散结构

连续空间存储 与 分散空间存储

【模板】双链表

https://www.acwing.com/problem/content/829/

思路:用两个空结点作为起始状态的边界,避免所有边界讨论。

时间复杂度:插入、删除结点均为 O(1)O(1),遍历为 O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

template<class T>
class myList {
private:
int idx;
std::vector<T> val;
std::vector<int> left, right;

public:
myList(const int n) {
idx = 2;
val.resize(n + 10);
left.resize(n + 10);
right.resize(n + 10);
left[1] = 0, right[0] = 1;
}

void push_back(T x) {
insert_left(1, x);
}

void push_front(T x) {
insert_right(0, x);
}

void insert_left(int k, T x) {
insert_right(left[k], x);
}

void insert_right(int k, T x) {
val[idx] = x;
right[idx] = right[k];
left[right[k]] = idx;
left[idx] = k;
right[k] = idx++;
}

void erase(int k) {
right[left[k]] = right[k];
left[right[k]] = left[k];
}

void output() {
for (int i = right[0]; i != 1; i = right[i]) {
cout << val[i] << " \n"[i == 1];
}
}
};

void solve() {
int n;
cin >> n;

myList<int> ls(n);

while (n--) {
string op;
cin >> op;

int k, x;

if (op == "L") {
cin >> x;
ls.push_front(x);
} else if (op == "R") {
cin >> x;
ls.push_back(x);
} else if (op == "D") {
cin >> k;
ls.erase(k + 1);
} else if (op == "IL") {
cin >> k >> x;
ls.insert_left(k + 1, x);
} else {
cin >> k >> x;
ls.insert_right(k + 1, x);
}
}

ls.output();
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
// std::cin >> T;
while (T--) solve();
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
import heapq
from collections import defaultdict
from typing import List, Tuple
import math
from itertools import combinations

II = lambda: int(input())
FI = lambda: float(input())
MII = lambda: tuple(map(int, input().split()))
LII = lambda: list(map(int, input().split()))


class myList:
def __init__(self, n: int) -> None:
self.val = [0] * (n + 10)
self.left = [0] * (n + 10)
self.right = [0] * (n + 10)
self.idx = 2
self.right[0] = 1
self.left[1] = 0

def push_front(self, x: int):
self.insert_right(0, x)

def push_back(self, x: int):
self.insert_left(1, x)

def insert_left(self, k: int, x: int):
self.insert_right(self.left[k], x)

def insert_right(self, k: int, x: int):
self.val[self.idx] = x
self.right[self.idx] = self.right[k]
self.left[self.right[k]] = self.idx
self.left[self.idx] = k
self.right[k] = self.idx
self.idx += 1

def erase(self, k: int):
self.left[self.right[k]] = self.left[k]
self.right[self.left[k]] = self.right[k]

def output(self) -> None:
i = self.right[0]
while i != 1:
print(self.val[i], end=' ')
i = self.right[i]


def solve() -> None:
n = II()

ls = myList(n)

for _ in range(n):
op = input().split()

if op[0] == 'L':
ls.push_front(int(op[-1]))
elif op[0] == 'R':
ls.push_back(int(op[-1]))
elif op[0] == 'D':
ls.erase(int(op[-1]) + 1)
elif op[0] == 'IL':
ls.insert_left(int(op[1]) + 1, int(op[-1]))
else:
ls.insert_right(int(op[1]) + 1, int(op[-1]))

ls.output()


if __name__ == '__main__':
T = 1
# T = II()
while T: solve(); T -= 1

【模板】单调栈

https://www.acwing.com/problem/content/832/

题意:对于一个序列中的每一个元素,寻找每一个元素左侧最近的比其小的元素。

思路一:暴力枚举

  • 显然对于每一个元素 nums[i],我们可以枚举倒序 [0, i-1] 直到找到第一个 nums[j] < nums[i]
  • 时间复杂度:O(n2)O(n^2)

思路二:单调栈

  • 可以发现时间开销主要在倒序枚举上,我们能少枚举一些元素吗?答案是可以的。我们定义「寻找 [i-1, 0] 中比当前元素小的第一个元素」的行为叫做「寻找合法对象」。显然我们在枚举每一个元素时都需要 查询维护 这样的合法对象线性序列,可以理解为记忆化从而加速查询。那么如何高效查询和维护这样的线性序列呢?不妨考虑每一个合法对象对曾经的合法对象的影响:

    • 若当前元素 nums[i] 可以成为后续 [i+1, n-1] 元素的合法对象。则从 i-1 开始一直往左,只要比当前大的元素,都不可能成为 [i+1, n-1] 的合法对象,肯定都被 nums[i] “拦住了”。那么在「合法对象序列」中插入当前合法对象之前,需要不断尾弹出比当前大的元素
    • 若当前元素 nums[i] 不能成为后续 [i+1, n-1] 元素的合法对象。表明当前元素过大,此时就不用将当前元素插入「合法对象序列」
  • 经过上述两个角度的讨论,很容易发现这样维护出来的的合法序列是严格单调递增的。于是,在查询操作时仅需要进行尾比较与尾弹出即可,在维护操作时,仅需要尾插入即可。满足这样的线性数据结构有很多,如栈、队列、数组、链表,我们就使用栈来演示,与标题遥相呼应

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

void solve() {
int n;
cin >> n;

vector<int> a(n);
for (int i = 0; i < n; i++) {
cin >> a[i];
}

stack<int> s;
for (int i = 0; i < n; i++) {
// 查询
while (s.size() && s.top() >= a[i]) {
s.pop();
}
cout << (s.size() ? s.top() : -1) << " ";

// 维护
s.push(a[i]);
}
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
// std::cin >> T;
while (T--) solve();
return 0;
}

相似题:

下一个更大元素 II

【模板】单调队列

标签:双端队列、滑动窗口、单调队列

题意:给定一个含有 n 个元素的序列,求解其中每个长度为 k 的子数组中的最值。

思路:显然我们可以 O(nk)O(nk) 暴力求解,有没有什么方法可以将「求解子数组中最值」的时间开销从 O(k)O(k) 降低为 O(1)O(1) 呢?有的!我们重新定义一个队列就好了。为了做到线性时间复杂度的优化,我们对队列做以下自定义,以「求解子数组最小值」为例:

  1. 插入元素到队尾:此时和单调栈的逻辑类似。如果当前元素可以作为当前子数组或后续子数组的最小值,则需要从当前队尾开始依次弹出比当前元素严格大的元素,最后再将当前元素入队。注意:当遇到和当前元素值相等的元素时不能出队,因为每一个元素都会经历入队和出队的操作,一旦此时出队了,后续进行出队判定时会提前弹出本不应该出队的与其等值的元素。
  2. 弹出队头元素:如果队头元素和子数组左端点 nums[i-k] 的元素值相等,则弹出。
  3. 获得队头元素:O(1)O(1) 的获取队头元素,即队列中的最小值。

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
#include <bits/stdc++.h>

using ll = long long;
using namespace std;

template<class T>
struct minQueue {
std::deque<T> q;
void pushBack(T x) {
while (q.size() && x < q.back()) {
q.pop_back();
}
q.push_back(x);
}
void popFront(T x) {
if (q.size() && q.front() == x) {
q.pop_front();
}
}
T getMinValue() {
return q.front();
}
};

template<class T>
struct maxQueue {
std::deque<T> q;
void pushBack(T x) {
while (q.size() && x > q.back()) {
q.pop_back();
}
q.push_back(x);
}
void popFront(T x) {
if (q.size() && q.front() == x) {
q.pop_front();
}
}
T getMaxValue() {
return q.front();
}
};

void solve() {
int n, k;
cin >> n >> k;

vector<int> nums(n);
for (int i = 0; i < n; i++) {
cin >> nums[i];
}

minQueue<int> minq;
for (int i = 0; i < n; i++) {
minq.pushBack(nums[i]);
if (i >= k) {
minq.popFront(nums[i - k]);
}
if (i >= k - 1) {
cout << minq.getMinValue() << " \n"[i == n - 1];
}
}

maxQueue<int> maxq;
for (int i = 0; i < n; i++) {
maxq.pushBack(nums[i]);
if (i >= k) {
maxq.popFront(nums[i - k]);
}
if (i >= k - 1) {
cout << maxq.getMaxValue() << " \n"[i == n - 1];
}
}
}

signed main() {
std::ios::sync_with_stdio(false);
std::cin.tie(nullptr);
int T = 1;
// std::cin >> T;
while (T--) solve();
return 0;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
from collections import defaultdict, deque
from typing import List, Tuple
from itertools import combinations, permutations
import math, heapq, queue

II = lambda: int(input())
FI = lambda: float(input())
MII = lambda: tuple(map(int, input().split()))
LII = lambda: list(map(int, input().split()))


def solve() -> None:
n, k = MII()
nums = LII()

qa, qb = deque(), deque()
ra, rb = [], []
for i in range(n):
# push back
while len(qa) and nums[i] < qa[-1]:
qa.pop()
qa.append(nums[i])
while len(qb) and nums[i] > qb[-1]:
qb.pop()
qb.append(nums[i])
if i >= k:
# pop front
if len(qa) and qa[0] == nums[i - k]:
qa.popleft()
if len(qb) and qb[0] == nums[i - k]:
qb.popleft()
if i >= k - 1:
# get ans
ra.append(qa[0])
rb.append(qb[0])

print(' '.join(map(str, ra)))
print(' '.join(map(str, rb)))


if __name__ == '__main__':
T = 1
# T = II()
while T: solve(); T -= 1

【模板】最近公共祖先

https://www.luogu.com.cn/problem/P3379

题意:寻找树中指定两个结点的最近公共祖先 (Lowest Common Ancestor, 简称 LCA)\text{(Lowest Common Ancestor, 简称 LCA)}

思路:对于每次查询,我们可以从指定的两个结点开始往上跳,第一个公共结点就是目标的 LCA,每一次询问的时间复杂度均为 O(n)O(n),为了加速查询,我们可以采用倍增法,预处理出往上跳的结果,即 fa[i][j] 数组,表示 ii 号点向上跳 2j2^j 步后到达的结点。接下来在往上跳跃的过程中,利用二进制拼凑的思路,即可在 O(logn)O(\log n) 的时间内查询到 LCA。

预处理:可以发现,对于 fa[i][j],我们可以通过递推的方式获得,即 fa[i][j] = fa[fa[i][j-1]][j-1],当前结点向上跳跃 2j2^j 步可以拆分为先向上 2j12^{j-1} 步, 在此基础之上再向上 2j12^{j-1} 步.于是我们可以采用宽搜 oror 深搜的顺序维护 fafa 数组。

跳跃:我们首先需要将两个结点按照倍增的思路向上跳到同一个深度,接下来两个结点同时按照倍增的思路向上跳跃,为了确保求出最近的,我们需要确保在跳跃的步调一致的情况下,两者的祖先始终不相同,那么倍增结束后,两者的父结点就是最近公共祖先,即 fa[x][k]fa[y][k]

时间复杂度:Θ(nlogn+mlogn)\Theta(n \log n + m \log n)

  • nlognn \log n 为预处理每一个结点向上跳跃抵达的情况
  • mlognm \log nmm 次询问的情况
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
const int N = 5e5 + 10;

int n, Q, root;
vector<int> G[N];
int fa[N][20], dep[N];
queue<int> q;

void init() {
dep[root] = 1;
q.push(root);

while (q.size()) {
int now = q.front();
q.pop();
for (int ch: G[now]) {
if (!dep[ch]) {
dep[ch] = dep[now] + 1;
fa[ch][0] = now;
for (int k = 1; k <= 19; k++) {
fa[ch][k] = fa[ fa[ch][k-1] ][k-1];
}
q.push(ch);
}
}
}
}

int lca(int a, int b) {
if (dep[a] < dep[b]) swap(a, b);

// 二进制拼凑从而跳到一样高
for (int k = 19; k >= 0; k--)
if (dep[fa[a][k]] >= dep[b])
a = fa[a][k];

if (a == b) return a;

for (int k = 19; k >= 0; k--)
if (fa[a][k] != fa[b][k])
a = fa[a][k], b = fa[b][k];

return fa[a][0];
}

void solve() {
cin >> n >> Q >> root;
for (int i = 0; i < n - 1; ++i) {
int a, b;
cin >> a >> b;
G[a].push_back(b);
G[b].push_back(a);
}

init();

while (Q--) {
int a, b;
cin >> a >> b;
cout << lca(a, b) << "\n";
}
}

【栈】验证栈序列

https://www.luogu.com.cn/problem/P4387

  • 题意:给定入栈序列与出栈序列,问出栈序列是否合法

  • 思路:思路很简单,就是对于当前出栈的数,和入栈序列中最后已出栈的数之间,如果还有数没有出,那么就是不合法的出栈序列,反之合法。这是从入栈的结果来看的,如果这么判断就需要扫描入栈序列 n 次,时间复杂度为 O(n2)O(n^2)。我们按照入栈的顺序来看,对于当前待入栈的数,若与出栈序列的队头不等,则成功入栈等待后续出栈;若与出栈序列相等,则匹配成功直接出栈无需入栈,同时对已入栈的数与出栈序列队头不断匹配直到不相等。最后判断待入栈的数与出栈序列是否全部匹配掉了,如果全部匹配掉了说明该出栈序列合法,反之不合法

    抽象总结上述思路:为了判断出栈序列是否合法,我们不妨思考:对于每一个出栈的数,出栈的时机是什么?可以发现出栈的时机无非两种:

    • 一入栈就出栈(对应于枚举待入栈序列时发现待入栈的数与出栈序列队头相等)
    • 紧跟着刚出栈的数继续出栈(对应于枚举待入栈序列时发现待入栈的数与出栈序列队头相等之后,继续判断出栈序列队头与已入栈的数是否相等,若相等则不断判断并出栈)
  • 时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// #include <bits/stdc++.h>
// #define int long long
#include <iostream>
#include <unordered_map>
#include <stack>
#include <queue>
using namespace std;

void solve() {
int n;
cin >> n;

vector<int> a(n), b(n);
for (int i = 0; i < n; i++) cin >> a[i];
for (int i = 0; i < n; i++) cin >> b[i];

stack<int> stk;
int i = 0, j = 0;
while (i < n) {
if (a[i] != b[j]) stk.push(a[i++]);
else {
i++, j++;
while (!stk.empty() && b[j] == stk.top()) {
stk.pop();
j++;
}
}
}

cout << (stk.empty() ? "Yes" : "No") << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
cin >> T;
while (T--) solve();
return 0;
}

二叉搜索树 & 平衡树

如果我们想要在 O(logn)O(\log n) 时间复杂度内对数据进行增删查改的操作,就可以引入 二叉搜索树 (Binary Search Tree) 这一数据结构。然而,在某些极端的情况下,例如当插入的数据是单调不减或不增时,这棵树就会退化为一条链从而导致所有的增删查改操作退化到 O(n)O(n),这是我们不愿意看到的。因此我们引入 平衡二叉搜索树 (Balanced Binary Search Tree) 这一数据结构。

关于平衡二叉搜索树,有非常多的变种与实现,不同的应用场景会选择不同的平衡树类型。Treap 更灵活,通过随机化优先级实现预期的平衡,但在最坏情况下可能退化。AVL 树 严格保持平衡,保证了 O(logn)O(\log n) 的性能,但在频繁插入和删除的场景下可能有较大的旋转开销。红黑树 通过较宽松的平衡条件实现了较好的插入和删除性能,通常被广泛用于需要高效插入删除操作的系统(如 STL 中的 mapset)。一般来说,红黑树是一个较为通用的选择,而在需要严格平衡性时,AVL 树可能是更好的选择。

Treap

Treap 是二叉搜索树和堆的结合体。它通过维护两种性质来保持平衡:

  • 二叉搜索树性质:每个节点的左子树的所有节点值小于该节点的值,右子树的所有节点值大于该节点的值。
  • 堆性质:每个节点的优先级(通常随机生成)要大于或等于其子节点的优先级。

平衡机制

  • Treap 使用随机化优先级使得树的形状接近于理想的平衡树(期望树高为 O(logn)O(\log n))。
  • 通过旋转操作(左旋和右旋)在插入和删除时保持堆的性质。

优点

  • 实现相对简单。
  • 由于随机化的优先级,在期望情况下,树的高度是 O(logn)O(\log n)
  • 灵活性高,可以根据需要调整优先级函数。

缺点

  • 最坏情况下,树的高度可能退化为 O(n)O(n)(例如所有优先级相同或顺序生成的优先级),尽管发生概率很低。

AVL 树

AVL 树是最早发明的自平衡二叉搜索树之一,1962 年由 Adelson-Velsky 和 Landis 发明。

  • 平衡因子:每个节点的左右子树高度差不能超过 11,且需要记录每个节点的高度。

平衡机制

  • 插入或删除节点后,如果某个节点的平衡因子不再为 1-10011,就需要通过旋转(单旋转或双旋转)来恢复平衡。
  • 旋转操作包括:左旋转、右旋转、左右双旋转和右左双旋转。

优点

  • 严格的平衡条件保证了树的高度始终为 O(logn)O(\log n),因此搜索、插入和删除操作的时间复杂度为 O(logn)O(\log n)

缺点

  • 由于平衡条件严格,每次插入和删除后可能需要较多的旋转操作,从而导致实现较复杂,插入和删除操作的常数时间开销较大。

红黑树

红黑树是一种较为宽松的自平衡二叉搜索树,由 Rudolf Bayer 于 1972 年发明。

  • 颜色属性:每个节点都有红色或黑色两种颜色,通过这些颜色约束树的平衡性。

平衡机制

  • 通过遵循红黑树的五个性质来保持平衡:
    1. 每个节点要么是红色,要么是黑色。
    2. 根节点是黑色。
    3. 叶子节点(NIL 节点)是黑色。
    4. 如果一个节点是红色的,那么它的子节点必须是黑色(红节点不能连续出现)。
    5. 从任一节点到其每个叶子节点的所有路径都包含相同数量的黑色节点。
  • 插入和删除操作可能破坏红黑树的性质,需要通过重新着色和旋转来恢复平衡。

优点

  • 红黑树的高度最多是 Θ(2logn)\Theta (2 \log n),因此搜索、插入和删除操作的时间复杂度仍为 O(logn)O(\log n)
  • 由于平衡条件较为宽松,插入和删除操作需要的旋转操作通常比 AVL 树少,效率更高。

缺点

  • 实现较复杂,特别是插入和删除的平衡修复过程。
  • 虽然红黑树的搜索效率与 AVL 树相似,但由于平衡条件较宽松,实际应用中的树高度通常略高于 AVL 树,因此搜索操作的效率稍低。

【二叉树】美国血统

https://www.luogu.com.cn/problem/P1827

题意:给定二叉树的中序和先序序列,输出后序序列

思路:经典二叉树的题目,主要用于巩固加强对于递归的理解。指针其实是没有必要的,为了得到后序序列,我们只需要有一个 dfs 序即可,为了得到 dfs 序,我们只需要根据给出的中序和前序序列即可得到 dfs 序

时间复杂度:O(n)O(n)

指针做法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 30;

string mid, pre;

struct Node {
char data;
Node* le, * ri;
Node(char _data) : data(_data), le(nullptr), ri(nullptr) {}
};

Node* build(int i, int j, int p, int q) {
if (i > j) return nullptr;

Node* root = new Node(pre[i]);

int k; // 根结点在中序序列的下标
for (k = p; k <= q; k++)
if (mid[k] == root->data)
break;

root->le = build(i + 1, k - p + i, p, k - 1);
root->ri = build(k - p + i + 1, j, k + 1, q);

cout << root->data;

return root;
}

void solve() {
cin >> mid >> pre;

int i = 0, j = pre.size() - 1;
int p = 0, q = mid.size() - 1;

build(i, j, p, q);
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

构造出 dfs 序直接输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 30;

string mid, pre;

// 前序起始 i,前序末尾 j,中序起始 p,中序末尾 q
void build(int i, int j, int p, int q) {
if (i > j) return;

char root = pre[i];

int k;
for (k = p; k <= q; k++)
if (mid[k] == root)
break;

build(i + 1, k - p + i, p, k - 1);
build(k - p + i + 1, j, k + 1, q);

cout << root;
}

void solve() {
cin >> mid >> pre;

int i = 0, j = pre.size() - 1;
int p = 0, q = mid.size() - 1;

build(i, j, p, q);
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【二叉树】新二叉树

https://www.luogu.com.cn/problem/P1305

题意:给定一棵二叉树的 n 个结点信息,分别为当前结点的数据信息、左孩子结点信息和右结点信息,输出这棵二叉树的前序序列

思路:我们首先将这棵二叉树构建出来,接着遍历输出前序序列即可。关键在于如何构建二叉树?我们使用数组存储二叉树,对于每一个树上结点,我们将数组中元素的索引存储为树上结点信息,每一个结点再存储左孩子与右孩子的信息

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <bits/stdc++.h>
#define int long long
using namespace std;

int n;
string s;
char root;

struct Node {
char l, r;
} tree[200];

void pre(char now) {
if (now == '*') return;
cout << now;
pre(tree[now].l);
pre(tree[now].r);
}

void solve() {
cin >> n;

for (int i = 1; i <= n; i++) {
cin >> s;
if (i == 1) root = s[0];
tree[s[0]].l = s[1];
tree[s[0]].r = s[2];
}

pre(root);
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【二叉树】遍历问题

https://www.luogu.com.cn/problem/P1229

题意:给定一棵二叉树的前序序列与后序序列,问中序序列的可能情况有多少种

思路:我们采用从最小结构单元的思路进行考虑,即假设当前二叉树只有一个根结点与两个叶子结点,而非两棵子树。然后将题意进行等价变换,即问对于已经固定的前序和后序二叉树,该二叉树有多少种不同的形状?对于当前的最小结构二叉树,形状就是 左右根 or 根左右,现在的根可以直接确定,那么就只能从左右孩子进行变形,很显然只能进行左右交换的变形,但是问题是一旦左右变换,前序 or 后序都会变掉,说明这种左右孩子都存在的前后序固定的二叉树是唯一的,那么如何才是不唯一的呢?我们考虑减少孩子数量。假设没有孩子,那么很显然也只有一个形状,就是一个根结点,故排除。于是答案就呼之欲出了,就是当根结点只有一个孩子时,这个孩子无论是在左边还是右边,前后序都是相同的,但是中序序列就不同了,于是就产生了两种中序序列。于是最终的结论是:对于前后序确定的二叉树来说,中序序列的情况是就是 2单分支结点数2^{\text{单分支结点数}} 个。现在的问题就转变为了在给定前后序的二叉树中求解单分支结点个数的问题。

如何寻找单分支结点呢?根据下面的递归图可以发现,无论是左单分支还是右单分支,如果 pre 的连续两个结点与 post 的连续两个结点对称相同,那么就一定有一个单分支结点,故只需要寻找前后序序列中连续两个字符对称相同的情况数 cnt 即可。最终的答案数就是 2cnt2^{cnt}

图例

时间复杂度:O(nm)O(nm)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <bits/stdc++.h>
#define int long long
using namespace std;

string pre, post;

void solve() {
cin >> pre >> post;

int cnt = 0;

for (int i = 0; i < pre.size() - 1; i++)
for (int j = 0; j < post.size(); j++)
if (pre[i] == post[j + 1] && pre[i + 1] == post[j])
cnt++;

cout << (1 << cnt) << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【二叉树】医院设置 🔥

https://www.luogu.com.cn/problem/P1364

题意:给定一棵二叉树,树中每一个结点存储了一个数值表示一个医院的人数,现在需要在所有的结点中将一个结点设置为医院使得其余结点中的所有人到达该医院走的路总和最小。路程为结点到医院的最短路,边权均为 1。给出最终的最短路径总和

思路一:暴力

  • 显然的对于已经设置好医院的局面,需要求解的路径总和就直接将树遍历一边即可。每一个结点都可以作为医院进行枚举,每次遍历是 O(n)O(n)

  • 时间复杂度:O(n2)O(n^2)

思路二:带权树的重心

  • TODO
  • 时间复杂度:O(n)O(n)

暴力代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 110;

int n;

vector<int> G[N];
int cnt[N];

int bfs(int v) {
int res = 0;
vector<bool> vis(n + 1, false);
vector<int> d(n + 1, 0); // d[i] 表示点 i 到点 v 的距离

queue<int> q;
vis[v] = true;
d[v] = 0;
q.push(v);

while (q.size()) {
int now = q.front();
q.pop();

for (auto& ch: G[now]) {
if (!vis[ch]) {
vis[ch] = true;
d[ch] = d[now] + 1;
q.push(ch);

res += cnt[ch] * d[ch];
}
}
}

return res;
}

void solve() {
cin >> n;
for (int i = 1; i <= n; i++) {
int count, l, r;
cin >> count >> l >> r;
cnt[i] = count;

if (l) {
G[i].push_back(l);
G[l].push_back(i);
}

if (r) {
G[i].push_back(r);
G[r].push_back(i);
}
}

int res = 1e7 + 10;

for (int i = 1; i <= n; i++) {
res = min(res, bfs(i));
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

优化代码

1

【二叉树】二叉树深度

https://www.luogu.com.cn/problem/P4913

题意:给定一棵二叉树,求解这棵二叉树的深度

思路:有两个考点,一个是如何根据给定的信息(从根结点开始依次给出已存在树上结点的左右孩子的编号)构建二叉树,一个是如何求解已经构建好的二叉树的深度。对于构建二叉树,我们沿用 T5 数组模拟构建的思路,直接定义结点类型即可;对于求解深度,很显然的一个递归求解,即左右子树深度值 +1 即可

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 1000010;

int n;

struct Node {
int l, r;
} t[N];

int dep(int now) {
if (!now) return 0;
return max(dep(t[now].l), dep(t[now].r)) + 1;
}

void solve() {
cin >> n;
for (int i = 1; i <= n; i++) {
int x, y;
cin >> x >> y;
t[i].l = x, t[i].r = y;
}

cout << dep(1);
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【完全二叉树】淘汰赛

https://www.luogu.com.cn/problem/P1364

题意:给定 2n2^n 支球队的编号与能力值,进行淘汰赛,能力值者晋级下一轮直到赛出冠军。输出亚军编号

思路:很显然的一个完全二叉树的题目。我们都不需要进行递归操作,直接利用完全二叉树的下标性质利用数组模拟循环计算即可。给出的信息就是完全二叉树的全部叶子结点的信息,分别为球队编号 id 与球队能力值 val,我们从第 n-1 个结点开始循环枚举到第 1 个结点计算每一轮的胜者信息,最终输出最后一场的能力值较小者球队编号即可

时间复杂度:Θ(2n)\Theta(2n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 1 << 8;

struct Node {
int id, val;
} a[N];

int n;

void solve() {
cin >> n;

n = 1 << n;

for (int i = n; i <= 2 * n - 1; i++) {
a[i].id = i - n + 1;
cin >> a[i].val;
}

for (int i = n - 1; i >= 1; i--)
if (a[i * 2].val > a[i * 2 + 1].val) a[i] = a[i * 2];
else a[i] = a[i * 2 + 1];

if (a[2].val > a[3].val) cout << a[3].id;
else cout << a[2].id;
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【二叉树/LCA】二叉树问题

https://www.luogu.com.cn/problem/P3884

题意:给定一棵二叉树的结点关系信息,求出这棵二叉树的深度、宽度和两个指定结点之间的最短路径长度

思路:二叉树的构建直接采用有向图的构造方法。深度直接 dfs 即可,宽度直接在 dfs 遍历时哈希深度值即可。问题的关键在于如何求解两个给定结点之间的路径长度,很显然需要求解两个结点的 LCA,由于结点数 100\le 100 故直接采用暴力的方法,可以重定义结点,增加父结点域。也可以通过比对根结点到两个指定结点的路径信息得到 LCA 即最后一个相同的结点编号(本题采用),通过在 dfs 遍历树时存储路径即可得到根结点到两个指定结点的路径信息。之后直接根据题中新定义的路径长度输出即可,即

length=2×(dxdlca)+(dydlca)\text{length} = 2 \times (d_x - d_{lca}) + (d_y - d_{lca})

其中 did_i 表示:根结点到第 ii 号点之间的路径长度,在 dfs 时通过传递深度值维护得到

时间复杂度:O(n)O(n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
#include <bits/stdc++.h>
#define int long long
using namespace std;

const int N = 110;

int n, x, y;
vector<int> G[N];
int depth, width;
unordered_map<int, int> ha; // 将所有的深度值进行哈希
int d[N]; // d[i] 表示第 i 个点到根结点的边数
vector<int> temp, rx, ry; // 根结点到 x 号点与 y 号点直接的路径结点编号

// 当前结点编号 now,当前深度 level
void dfs(int now, int level) {
depth = max(depth, level);

temp.push_back(now);
if (now == x) rx = temp;
if (now == y) ry = temp;

ha[level]++;
d[now] = level - 1;

for (auto& ch: G[now]) {
dfs(ch, level + 1);
temp.pop_back();
}
}

// 暴力 lca + 计算路径长度
int len(int x, int y) {
int i = 0;
while (i < rx.size() && i < ry.size() && rx[i] == ry[i]) i++;

int lca = rx[--i];

return 2 * (d[x] - d[lca]) + (d[y] - d[lca]);
}

void solve() {
cin >> n;

for (int i = 1; i <= n - 1; i++) {
int a, b;
cin >> a >> b;
G[a].push_back(b);
}

cin >> x >> y;

// 二叉树的深度 depth
dfs(1, 1);
cout << depth << "\n";

// 二叉树的宽度 width
for (auto& item: ha) width = max(width, item.second);
cout << width << "\n";

// 两个结点之间的路径长度
cout << len(x, y) << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【set】营业额统计

https://www.luogu.com.cn/problem/P2234

题意:给定一个序列 a,需要计算 a1+i=2,1j<inminaiaja_1 + \displaystyle \sum_{i=2,1 \le j <i}^{n} \min {|a_i - a_j|} ,即计算每一个数与序列中当前数之前的数的最小差值之和

思路:很显然的思路,对于每一个数,我们需要对之前的序列在短时间内找到一个数值最接近当前数的数。

  • TLE:一开始的思路是每次对之前的序列进行排序,然后二分查找与当前值匹配的数,为了确保所有的情况都找到,就直接判断二分查到的数,查到的数之前的一个数,之后的一个数,但是时间复杂度极高(我居然没想到),是 O(n2logn)O(n^2 \log n)
  • AC:后来看了题解才知道 set 的正确用法,就是一个 平衡树的 STL。我们对于之前的序列不断的插入平衡树中(默认升序排序),每次利用 s.lower_bound(x) 返回「集合 s 中第一个 \ge 当前数的迭代器」,然后进行判断即可。lower_bound() 的时间复杂度为 O(logn)O(\log n) 。需要注意的是边界的判断,一开始的思路虽然会超时,但是二分后边界的判断很简单,使用 STL 后同样需要考虑边界的情况。分为三种(详情见代码)
    • 当前数比集合中所有的数都大,那么 lower_bound 就会返回 s.end() 答案就是当前数与集合中最后一个数的差值
    • 当前数比集合中所有的数都小,那么 lower_bound 就会返回 s.bigin() 答案就是集合中第一个数与当前数的差值
    • 当前数存在于集合中 or 集合中既有比当前数大的又有比当前数小的,那么就比较查到的数与查到的数前一个数和当前数的差值,取最小的差值即可

时间复杂度:O(nlogn)O(n \log n)

TLE 但逻辑清晰代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#include <iostream>
#include <algorithm>
using namespace std;

const int N = 1 << 16;

int n, a[N];

void solve() {
cin >> n;

int res = 0;
cin >> a[1];
res += a[1];

for (int i = 2; i <= n; i++) {
// 维护之前序列有序
sort(a + 1, a + i);
cin >> a[i];

// 二分查找目标数
int l = 1, r = i - 1;
while (l < r) {
int mid = (l + r) >> 1;
if (a[mid] < a[i]) l = mid + 1;
else r = mid;
}

// 边界判断
int ans = abs(a[i] - a[r]);
if (r + 1 >= 1 && r + 1 <= i - 1) ans = min(ans, abs(a[i] - a[r + 1]));
if (r - 1 >= 1 && r - 1 <= i - 1) ans = min(ans, abs(a[i] - a[r - 1]));

res += ans;
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

AC 的 set 代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <iostream>
#include <algorithm>
#include <set>
using namespace std;

int n, res;
set<int> s;

void solve() {
cin >> n;

int x;
cin >> x;
res += x;
s.insert(x);

while (--n) {
cin >> x;

auto it = s.lower_bound(x);

if (it == s.end()) {
// 没有比当前数大的
res += x - *s.rbegin();
} else if (it == s.begin()) {
// 没有比当前数小的
res += *s.begin() - x;
} else {
// 当前数已存在于集合中 or 既有比当前数大的也有比当前数小的
auto pre = it;
pre--;
res += min(abs(x - *it), abs(x - *pre));
}

s.insert(x);
}

cout << res << "\n";
}

signed main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【multiset】切蛋糕

https://www.acwing.com/problem/content/description/5581/

题意:给定一个矩形,由左下角和右上角的坐标确定。现在对这个矩形进行切割操作,要么竖着切,要么横着切。现在需要给出每次切割后最大子矩形的面积

思路:STL set。很容易想到,横纵坐标是相互独立的,最大子矩形面积一定产生于最大长度与最大宽度的乘积。因此我们只需要维护一个序列的最大值即可。对于二维可以直接当做一维来看,于是问题就变成了,需要在 logn\log n 的时间复杂度内,对一个序列完成下面三个操作:

  1. 删除一个数
  2. 增加一个数(执行两次)
  3. 获取最大值

如何实现呢?我们需要有序记录每一个子线段的长度,并且子线段的长度可能重复,因此我们用 std::multiset 来存储所有子线段的长度

  1. 使用 M.erase(M.find(value)) 实现:删除一个子线段长度值
  2. 使用 M.insert(value) 实现:增加子线段一个长度值
  3. 使用 *M.rbegin() 实现:获取当前所有子线段长度的最大值

由于给的是切割的位置坐标 x,因此上述操作 1 不能直接实现,我们需要利用给定的切割坐标 x 计算出当前切割位置对应子线段的长度。如何实现呢?我们知道,对于当前切割的坐标 x,对应的子线段的长度取决于当前切割坐标左右两个切割的位置 rp, lp,因此我们只需要存储每一个切割的坐标即可。由于切割位置不会重复,并且需要在 logn\log n 的时间复杂度内查询到,因此我们还是可以使用 std::set 来存储切割位置

时间复杂度:O(nlogn)O(n \log n)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#include <iostream>
#include <set>
using namespace std;
using ll = long long;

void work(int x, set<int>& S, multiset<int>& M) {
set<int>::iterator rp = S.upper_bound(x), lp = rp;
lp--;
S.insert(x);

M.erase(M.find(*rp - *lp));
M.insert(*rp - x);
M.insert(x - *lp);
}

void solve() {
int w, h, n;
cin >> w >> h >> n;

set<int> S1, S2;
multiset<int> M1, M2;
S1.insert(0), S1.insert(w), M1.insert(w);
S2.insert(0), S2.insert(h), M2.insert(h);

while (n--) {
char op;
int x;
cin >> op >> x;
if (op == 'X') work(x, S1, M1);
else work(x, S2, M2);

cout << (ll)*M1.rbegin() * *M2.rbegin() << "\n";
}
}

int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr), cout.tie(nullptr);
int T = 1;
// cin >> T;
while (T--) solve();
return 0;
}

【并查集】Milk Visits S

https://www.luogu.com.cn/problem/P5836

题意:给定一棵树,结点被标记成两种,一种是 H,一种是 G,在每一次查询中,需要知道指定的两个结点之间是否含有某一种标记

思路:对于树上标记,我们可以将相同颜色的分支连成一个连通块

  • 如果查询的两个结点在同一个连通块,则查询两个结点所在的颜色与所需的颜色是否匹配即可
  • 如果查询的两个结点不在同一个连通块,两个结点之间的路径一定是覆盖了两种颜色的标记,则答案一定是 1

时间复杂度:Θ(n+m)\Theta(n+m)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
const int N = 100010;

int n, m, p[N];
char col[N];

int find(int x) {
if (p[x] != x) {
p[x] = find(p[x]);
}
return p[x];
}

void solve() {
cin >> n >> m;
cin >> (col + 1);

for (int i = 1; i <= n; i++) {
p[i] = i;
}

for (int i = 1; i <= n - 1; i++) {
int a, b;
cin >> a >> b;
if (col[a] == col[b]) {
p[find(a)] = find(b);
}
}

string res;

while (m--) {
int u, v;
cin >> u >> v;

char cow;
cin >> cow;

if (find(u) == find(v)) {
res += to_string(col[u] == cow);
} else {
res += '1';
}
}

cout << res << "\n";
}

【并查集】尽量减少恶意软件的传播

https://leetcode.cn/problems/minimize-malware-spread/description/

题意:给定一个由邻接矩阵存储的无向图,其中某些结点具备感染能力,可以感染相连的所有结点,问消除哪一个结点的感染能力可以使得最终不被感染的结点数量尽可能少,给出消除的有感染能力的最小结点编号

思路:很显然我们可以将当前无向图的多个连通分量,共三种感染情况:

  1. 如果一个连通分量中含有 2\ge 2 个感染结点,则当前连通分量一定会被全部感染;

  2. 如果一个连通块中含有 00 个感染结点,则无需任何操作;

  3. 如果一个连通块中含有 11 个感染结点,则最佳实践就是移除该连通块中唯一的那个感染结点。

当然了,由于只能移走一个感染结点,我们需要从所有只含有 11 个感染结点的连通块中移走连通块结点最多的那个感染结点。因此我们需要统计每一个连通分量中感染结点的数量以及总结点数,采用并查集进行统计。需要注意的是题目中的“索引最小”指的是结点编号最小而非结点在序列 initialinitial 中的下标最小。算法流程见代码。

时间复杂度:O(n2)O(n^2)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
class Solution {
public:
int p[310];

int Find(int x) {
if (x != p[x]) p[x] = Find(p[x]);
return p[x];
}

int minMalwareSpread(vector<vector<int>>& graph, vector<int>& initial) {
// 1. 维护并查集数组:p[]
int n = graph.size();
for (int i = 0; i < n; i++) p[i] = i;
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
if (graph[i][j])
p[Find(i)] = Find(j);

// 2. 维护哈希表:每一个连通块中的感染结点数、总结点数
unordered_map<int, pair<int, int>> ha;
for (auto& x: initial) ha[Find(x)].first++;
for (int i = 0; i < n; i++) ha[Find(i)].second++;

// 3. 排序:按照感染结点数升序,总结点数降序
vector<pair<int, int>> v;
for (auto& it: ha) v.push_back(it.second);
sort(v.begin(), v.end(), [&](pair<int, int>& x, pair<int, int>& y){
if (x.first == y.first) return x.second > y.second;
return x.first < y.first;
});

// 4. 寻找符合条件的连通块属性:找到序列中第一个含有 1 个感染结点的连通块祖宗结点编号 idx
int idx = -1;
for (int i = 0; i < v.size(); i++) {
if (v[i].first == 1) {
idx = i;
break;
}
}

// 5. 返回答案:比对感染结点所在的连通块属性与目标连通块属性
if (idx == -1) {
// 特判没有连通块只含有 1 个感染结点的情况
return *min_element(initial.begin(), initial.end());
}

int res = n + 10;
for (auto& x: initial) {
int px = Find(x);
if (ha[px].first == v[idx].first && ha[px].second == v[idx].second) {
res = min(res, x);
}
}

return res;
}
};

【并查集】账户合并

https://leetcode.cn/problems/accounts-merge/

题意:给定 n 个账户,每一个账户含有一个用户名和最多 m 个绑定的邮箱。由于一个用户可能注册多个账户,因此我们需要对所有的账户进行合并使得一个用户对应一个账户。合并的规则是将所有「含有相同邮箱的账户」视作同一个用户注册的账户。返回合并后的账户列表。

思路:这道题的需求很显然,我们需要合并含有相同邮箱的账户。显然有一个暴力的做法,我们直接枚举每一个账户中所有的邮箱,接着枚举剩余账户中的邮箱进行匹配,匹配上就进行合并,但这样做显然会造成大量的冗余匹配和冗余合并,我们不妨将这两个过程进行拆分。我们需要解决两个问题:

  • 哪些账户需要合并?很容易想到并查集这样的数据结构。我们使用哈希表存储每一个邮箱的账户编号,最后进行集合合并即可维护好每一个账号归属的集合编号。O(nm)O(nm)
  • 如何合并指定账户?对于上述维护好的集合编号,我们需要合并所有含有相同“祖先”的账户。排序去重或使用有序列表均可实现。O(nlogn)O(n\log n)

时间复杂度:O(nlogn)O(n\log n)

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
struct dsu {
int n;
std::vector<int> p;
dsu(int _n) { n = _n; p.resize(n + 1); for (int i = 1; i <= n; i++) p[i] = i; }
int find(int x) { return (p[x] == x ? p[x] : p[x] = find(p[x])); }
void merge(int a, int b) { p[find(a)] = find(b); }
bool query(int a, int b) { return find(a) == find(b); }
int block() { int ret = 0; for (int i = 1; i <= n; i++) ret += p[i] == i; return ret; }
};

class Solution {
public:
vector<vector<string>> accountsMerge(vector<vector<string>>& accounts) {
// 维护每一个子账户归属的集合
int n = accounts.size();
unordered_map<string, vector<int>> hash;
for (int i = 1; i <= n; i++) {
for (int j = 1; j < accounts[i - 1].size(); j++) {
hash[accounts[i - 1][j]].push_back(i);
}
}
dsu d(n);
for (auto& it: hash) {
vector<int> v = it.second;
for (int i = 1; i < v.size(); i++) {
d.merge(v[i - 1], v[i]);
}
}

// 按照子账户归属的集合合并出最终的账户
unordered_set<int> fa;
for (int i = 1; i <= n; i++) {
fa.insert(d.find(i));
}
vector<vector<string>> res;
for (auto p: fa) {
set<string> se;
vector<string> ans;
for (int i = 1; i <= n; i++) {
if (d.find(i) == p) {
if (ans.empty()) {
ans.push_back(accounts[i - 1][0]);
}
for (int j = 1; j < accounts[i - 1].size(); j++) {
se.insert(accounts[i - 1][j]);
}
}
}
for (auto mail: se) {
ans.push_back(mail);
}
res.push_back(ans);
}

return res;
}
};
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class dsu:
def __init__(self, n: int) -> None:
self.n = n
self.p = [i for i in range(n + 1)]
def find(self, x: int) -> int:
if self.p[x] != x: self.p[x] = self.find(self.p[x])
return self.p[x]
def merge(self, a: int, b: int) -> None:
self.p[self.find(a)] = self.find(b)
def query(self, a: int, b: int) -> bool:
return self.find(a) == self.find(b)
def block(self) -> int:
return sum([1 for i in range(1, self.n + 1) if self.p[i] == i])

class Solution:
def accountsMerge(self, accounts: List[List[str]]) -> List[List[str]]:
from collections import defaultdict

n = len(accounts)
hash = defaultdict(list)
for i in range(1, n + 1):
for j in range(1, len(accounts[i - 1])):
hash[accounts[i - 1][j]].append(i)

d = dsu(n)
for _, ids in hash.items():
for i in range(1, len(ids)):
d.merge(ids[i - 1], ids[i])

fa = set()
for i in range(1, n + 1):
fa.add(d.find(i))

res = []
for p in fa:
ans = []
se = set()
for i in range(1, n + 1):
if d.find(i) == p:
if len(ans) == 0:
ans.append(accounts[i - 1][0])
for j in range(1, len(accounts[i - 1])):
se.add(accounts[i - 1][j])
ans += sorted(se)
res.append(ans)

return res

【树状数组】将元素分配到两个数组中 II

https://leetcode.cn/problems/distribute-elements-into-two-arrays-ii/description/

题意:给定 nn 个数,现在需要将这些数按照某种规则分配到两个数组 AABB 中。初始化分配 nums[0]AA 中,nums[1]BB 中,接下来对于剩余的每个元素 nums[i],分配取决于 AABB 中比当前元素 nums[i] 大的个数,最终返回两个分配好的数组

思路:首先每一个元素都需要进行枚举,那么本题需要考虑的就是如何在 log\log 时间复杂度内统计出两数组中比当前元素大的元素个数。针对 C++ 和 Python 分别讨论

  • C++
    • 法一:std::multiset<int>。可惜不行,因为统计比当前元素大的个数时,s.rbegin() - s.upper_bound(nums[i]) 是不合法的,因为 std::multiset<int> 的迭代器不是基于指针的,因此无法直接进行加减来计算地址差,遂作罢
    • 法二:树状数组。很容易想到利用前缀和统计比当前数大的数字个数,但是由于此处需要对前缀和进行单点修改,因此时间复杂度肯定会寄。有什么数据结构支持「单点修改,区间更新」呢?我们引入树状数组。我们将数组元素哈希到 [1, len(set(nums))] 区间,定义哈希后的当前元素 nums[i]x,对于当前哈希后的 x 而言想要知道两个数组中有多少数比当前数严格大,只需要计算前缀和数组 arrarr[n] - arr[x] 的结果即可
  • Python
    • SortedList。python 有一个 sortedcontainers 包其中有 SortedList 模块,可以实现 std::multiset<int> 所有 log\log 操作并且可以进行随机下标访问,于是就可以进行下标访问 O(1)O(1) 计算比当前数大的元素个数
    • 题外话。LeetCode 可以进行第三方库导入的操作,某些比赛不允许,需要手搓 SortedList 模块,当然可以用树状数组 or 线段树解决,板子链接:https://blog.dwj601.cn/Algorithm/a_template/#SortedList

时间复杂度:O(nlogn)O(n \log n)

[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
template<class T>
class BinaryIndexedTree {
private:
std::vector<T> _arr;
int _n;

int lowbit(int x) { return x & (-x); }

public:
BinaryIndexedTree(int n) :_n(n) {
_arr.resize(_n + 1, 0);
}

void add(int pos, T x) {
while (pos <= _n) {
_arr[pos] += x;
pos += lowbit(pos);
}
}

T sum(int pos) {
T ret = 0;
while (pos) {
ret += _arr[pos];
pos -= lowbit(pos);
}
return ret;
}
};


class Solution {
public:
vector<int> resultArray(vector<int>& nums) {
vector<int> copy = nums;
sort(copy.begin(), copy.end());
copy.erase(unique(copy.begin(), copy.end()), copy.end());

int n = copy.size(), cnt = 1;
unordered_map<int, int> a;
for (int i = 0; i < n; i++) {
a[copy[i]] = cnt++;
}

vector<int> v1, v2;
v1.push_back(nums[0]);
v2.push_back(nums[1]);

BinaryIndexedTree<int> t1(n), t2(n);
t1.add(a[nums[0]], 1);
t2.add(a[nums[1]], 1);

for (int i = 2; i < nums.size(); i++) {
int d1 = t1.sum(n) - t1.sum(a[nums[i]]);
int d2 = t2.sum(n) - t2.sum(a[nums[i]]);

if (d1 > d2) {
v1.push_back(nums[i]);
t1.add(a[nums[i]], 1);
} else if (d1 < d2) {
v2.push_back(nums[i]);
t2.add(a[nums[i]], 1);
} else if (d1 == d2 && v1.size() < v2.size()) {
v1.push_back(nums[i]);
t1.add(a[nums[i]], 1);
} else if (d1 == d2 && v1.size() > v2.size()) {
v2.push_back(nums[i]);
t2.add(a[nums[i]], 1);
} else {
v1.push_back(nums[i]);
t1.add(a[nums[i]], 1);
}
}

for (int x: v2) {
v1.push_back(x);
}

return v1;
}
};
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class BinaryIndexedTree:
def __init__(self, n: int):
self._n = n
self._arr = [0] * (n + 1)

def _lowbit(self, x: int) -> int:
return x & (-x)

def add(self, pos: int, x: int) -> None:
while pos <= self._n:
self._arr[pos] += x
pos += self._lowbit(pos)

def sum(self, pos: int) -> int:
ret = 0
while pos:
ret += self._arr[pos]
pos -= self._lowbit(pos)
return ret


class Solution:
def resultArray(self, nums: List[int]) -> List[int]:
copy = sorted(set(nums))

n, cnt, a = len(copy), 1, {}
for x in copy:
a[x] = cnt
cnt += 1

v1, v2 = [nums[0]], [nums[1]]
t1, t2 = BinaryIndexedTree(n), BinaryIndexedTree(n)
t1.add(a[nums[0]], 1)
t2.add(a[nums[1]], 1)

for x in nums[2:]:
d1, d2 = t1.sum(n) - t1.sum(a[x]), t2.sum(n) - t2.sum(a[x])

if d1 > d2:
v1.append(x)
t1.add(a[x], 1)
elif d1 < d2:
v2.append(x)
t2.add(a[x], 1)
elif d1 == d2 and len(v1) < len(v2):
v1.append(x)
t1.add(a[x], 1)
elif d1 == d2 and len(v1) > len(v2):
v2.append(x)
t2.add(a[x], 1)
else:
v1.append(x)
t1.add(a[x], 1)

return v1 + v2
[]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Solution:
def resultArray(self, nums: List[int]) -> List[int]:
from sortedcontainers import SortedList

v1, v2 = copy.deepcopy(nums[:1]), copy.deepcopy(nums[1:2])
s1, s2 = SortedList(v1), SortedList(v2)

for x in nums[2:]:
d1, d2 = len(v1) - s1.bisect_right(x), len(v2) - s2.bisect_right(x)

if d1 > d2:
v1.append(x)
s1.add(x)
elif d1 < d2:
v2.append(x)
s2.add(x)
elif d1 == d2 and len(v1) < len(v2):
v1.append(x)
s1.add(x)
elif d1 == d2 and len(v1) > len(v2):
v2.append(x)
s2.add(x)
else:
v1.append(x)
s1.add(x)

return v1 + v2

【线段树/二分】以组为单位订音乐会的门票 🔥

https://leetcode.cn/problems/booking-concert-tickets-in-groups/

题意:给定一个长为 n5×104n\le 5 \times 10^4 且初始值均为 00 的数组 aa,数组中的每个元素最多增加到 mm。现在需要以这个数组为基础进行 q5×104q\le 5 \times 10^4 次询问,每次询问是以下两者之一:

  1. 给定一个 kklimlim,找到最小的 i[0,lim]i \in [0,lim] 使得 maikm - a_i \ge k
  2. 给定一个 kklimlim,找到最小的 i[0,lim]i \in [0,lim] 使得 m×(i+1)j=0iajk\displaystyle m\times (i+1) - \sum_{j=0}^i a_j \ge k

思路一:暴力。对于询问 1,我们直接顺序遍历 a 数组直到找到第一个符合条件的即可;对于询问 2,同样直接顺序遍历 a 数组直到找到第一个符合条件的即可。时间复杂度为 O(qn)O(qn)

思路二:线段树上二分

暴力代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class BookMyShow:

def __init__(self, n: int, m: int):
self.a = [0] * n # a[i] 表示第 i 行已入座的人数
self.n = n
self.m = m

def gather(self, k: int, lim: int) -> List[int]:
# 在 [0, lim] 行中找到第一个可以容纳 k 人的行
for i in range(lim + 1):
if self.m - self.a[i] >= k:
l, r = i, self.a[i]
self.a[i] += k
return [l, r]
return []

def scatter(self, k: int, lim: int) -> bool:
# 在 [0, lim] 行中找到最小的 i 使得 [0, i] 行可以容纳 k 人
if self.m * (lim + 1) - sum(self.a[:lim+1]) < k:
return False

i = 0
while k > 0:
if self.m - self.a[i] >= k:
self.a[i] += k
k = 0
else:
k -= self.m - self.a[i]
self.a[i] = self.m
i += 1
return True

线段树上二分代码:

1

]]>
@@ -1685,33 +1685,33 @@ - 关于作者 + / - 教育经历

层次时间学校方向地址高中2019.092022.06江苏省南菁高级中学物化生江苏省无锡市江阴市龙定路 158 号本科2022.092026.06南京师范大学人工智能江苏省南京市栖霞区文苑路 1 号\begin{array}{ccccc}\hline\textbf{层次} & \textbf{时间} & \textbf{学校} & \textbf{方向} & \textbf{地址} \\\hline\text{高中} & 2019.09 - 2022.06 & \text{江苏省南菁高级中学} & \text{物化生} & \text{江苏省无锡市江阴市龙定路 158 号} \\\text{本科} & 2022.09 - 2026.06 & \text{南京师范大学} & \text{人工智能} & \text{江苏省南京市栖霞区文苑路 1 号} \\\hline\end{array}

网瘾青年

dwj601 dwj601

技术栈

技术栈

编程语言

编程语言

开发工具

开发工具]]>
+
- 心语 + 关于作者 / -

每当独自听着歌,或者看到一些人、一些场景时,脑海中总会有小人在低语,有时倒也能让自己受到启发。但很多想法转瞬即逝,故借此处将一些内容及时记录下来,权当留念。

]]>
+ 教育经历

层次时间学校方向地址高中2019.092022.06江苏省南菁高级中学物化生江苏省无锡市江阴市龙定路 158 号本科2022.092026.06南京师范大学人工智能江苏省南京市栖霞区文苑路 1 号\begin{array}{ccccc}\hline\textbf{层次} & \textbf{时间} & \textbf{学校} & \textbf{方向} & \textbf{地址} \\\hline\text{高中} & 2019.09 - 2022.06 & \text{江苏省南菁高级中学} & \text{物化生} & \text{江苏省无锡市江阴市龙定路 158 号} \\\text{本科} & 2022.09 - 2026.06 & \text{南京师范大学} & \text{人工智能} & \text{江苏省南京市栖霞区文苑路 1 号} \\\hline\end{array}

网瘾青年

dwj601 dwj601

技术栈

技术栈

编程语言

编程语言

开发工具

开发工具]]>
- + 心语 / - +

每当独自听着歌,或者看到一些人、一些场景时,脑海中总会有小人在低语,有时倒也能让自己受到启发。但很多想法转瞬即逝,故借此处将一些内容及时记录下来,权当留念。

]]>
diff --git a/page/17/index.html b/page/17/index.html index 137377c55..48d2cc32c 100644 --- a/page/17/index.html +++ b/page/17/index.html @@ -250,15 +250,15 @@

- - PyAlgo + + ProbAndStat

- +
- 算法设计与分析 前言 学科地位: 主讲教师 学分配额 学科类别 段博佳 3 自发课 成绩组成: 平时(5次作业+签到) 期末(书本+讲义) 60% 40% 教材情况: 课程名称 选用教材 版次 作者 出版社 ISBN号 算法设计与分析 《算法设计与分析(python版)》 1 王秋芬 清华大学出版社 978-7-302-57072-1 一 + 概率论与数理统计 前言 学科情况: 主讲教师 学分配额 学科类别 周效尧 4 学科基础课 成绩组成: 平时 测验(×2) 期末 10% 30% 60% 教材情况: 课程名称 选用教材 版次 作者 出版社 ISBN号 概率论与数理统计Ⅰ 《概率论与数理统计》 第一版 刘国祥王晓谦 等主编 科学出版社 978-7-03-038317-4
@@ -311,15 +311,15 @@

- - ProbAndStat + + PyAlgo

- +
- 概率论与数理统计 前言 学科情况: 主讲教师 学分配额 学科类别 周效尧 4 学科基础课 成绩组成: 平时 测验(×2) 期末 10% 30% 60% 教材情况: 课程名称 选用教材 版次 作者 出版社 ISBN号 概率论与数理统计Ⅰ 《概率论与数理统计》 第一版 刘国祥王晓谦 等主编 科学出版社 978-7-03-038317-4 + 算法设计与分析 前言 学科地位: 主讲教师 学分配额 学科类别 段博佳 3 自发课 成绩组成: 平时(5次作业+签到) 期末(书本+讲义) 60% 40% 教材情况: 课程名称 选用教材 版次 作者 出版社 ISBN号 算法设计与分析 《算法设计与分析(python版)》 1 王秋芬 清华大学出版社 978-7-302-57072-1 一
diff --git a/page/18/index.html b/page/18/index.html index 44c9bcfbb..faeb96289 100644 --- a/page/18/index.html +++ b/page/18/index.html @@ -372,15 +372,15 @@

- - DataStructureClassDesign + + LinearAlgebra

- +
- 数据结构课程设计 效果演示 [!note] 以下为课设报告内容 一、必做题 题目:编程实现希尔、快速、堆排序、归并排序算法。要求随机产生 10000 个数据存入磁盘文件,然后读入数据文件,分别采用不同的排序方法进行排序,并将结果存入文件中。 1.1 数据结构设计与算法思想 本程序涉及到四种排序算法,其中: 希尔排序:通过倍增方法逐渐扩大选择插入数据量的规模,而达到减小数据比较的次数,时间 + 线性代数 第1章 行列式 1.1 二阶与三阶行列式 1.1.1 二元线性方程组与二阶行列所式 线性代数是为了解决多元线性方程组而诞生的 1.1.2 三阶行列式 记住对角线法则即可 1.2 全排列和对换 1.2.1 排列及其逆序数 排列:就是每个数位上数字的排序方式 逆序数:就是一个排列 ttt 中每一个数位之前比其大的数字数量之和,即 ∑i=1nti\sum_{i=1}^{n}t_i i=1∑n​
diff --git a/page/19/index.html b/page/19/index.html index 60bc01297..1a79b40ee 100644 --- a/page/19/index.html +++ b/page/19/index.html @@ -311,15 +311,15 @@

- - LinearAlgebra + + DataStructureClassDesign

- +
- 线性代数 第1章 行列式 1.1 二阶与三阶行列式 1.1.1 二元线性方程组与二阶行列所式 线性代数是为了解决多元线性方程组而诞生的 1.1.2 三阶行列式 记住对角线法则即可 1.2 全排列和对换 1.2.1 排列及其逆序数 排列:就是每个数位上数字的排序方式 逆序数:就是一个排列 ttt 中每一个数位之前比其大的数字数量之和,即 ∑i=1nti\sum_{i=1}^{n}t_i i=1∑n​ + 数据结构课程设计 效果演示 [!note] 以下为课设报告内容 一、必做题 题目:编程实现希尔、快速、堆排序、归并排序算法。要求随机产生 10000 个数据存入磁盘文件,然后读入数据文件,分别采用不同的排序方法进行排序,并将结果存入文件中。 1.1 数据结构设计与算法思想 本程序涉及到四种排序算法,其中: 希尔排序:通过倍增方法逐渐扩大选择插入数据量的规模,而达到减小数据比较的次数,时间
diff --git a/page/22/index.html b/page/22/index.html index 845439dd9..a932dce8a 100644 --- a/page/22/index.html +++ b/page/22/index.html @@ -250,15 +250,15 @@

- - solve-clion-decoding-error + + self-config

- +
- CLion 解决中文输出乱码的问题 问题介绍 在 Clion 的默认设置下,输出中文会出现乱码,如下 1234567#include <iostream>using namespace std;int main() { cout << "你好" << endl; return 0;} 输出 123浣犲ソ Process finished with e + DevCpp 偏好配置 前言 为了在比赛中不因为编辑器而拖后腿,写一个配置说明。编辑器为 Dev-C++ 5.11 一、编译标准 进入以下目录: 添加以下编译命令: 二、环境选项 三、编辑器选项 进入以下目录: 四、快捷键选项 进入以下目录: 设置注释配置:
@@ -287,7 +287,7 @@

> - CLion + DevCpp @@ -311,15 +311,15 @@

- - self-config + + solve-clion-decoding-error

- +
- DevCpp 偏好配置 前言 为了在比赛中不因为编辑器而拖后腿,写一个配置说明。编辑器为 Dev-C++ 5.11 一、编译标准 进入以下目录: 添加以下编译命令: 二、环境选项 三、编辑器选项 进入以下目录: 四、快捷键选项 进入以下目录: 设置注释配置: + CLion 解决中文输出乱码的问题 问题介绍 在 Clion 的默认设置下,输出中文会出现乱码,如下 1234567#include <iostream>using namespace std;int main() { cout << "你好" << endl; return 0;} 输出 123浣犲ソ Process finished with e
@@ -348,7 +348,7 @@

> - DevCpp + CLion diff --git a/page/24/index.html b/page/24/index.html index ed45474ea..545e987c0 100644 --- a/page/24/index.html +++ b/page/24/index.html @@ -250,15 +250,15 @@

- - geometry + + games

- +
- 计算几何 【二维/数学】Minimum Manhattan Distance https://codeforces.com/gym/104639/problem/J 题意:给定两个圆的直径的两个点坐标,其中约束条件是两个圆一定是处在相离的两个角上。问如何在C2圆上或圆内找到一点p,使得点p到C1圆的所有点的曼哈顿距离的期望值最小 思路: 看似需要积分,其实我们可以发现,对于点p到C1中某个点 + 博弈论 思考如何必胜态和必败态是什么以及如何构造这样的局面。 【博弈/贪心/交互】Salyg1n and the MEX Game https://codeforces.com/contest/1867/problem/C 标签:博弈、贪心、交互 题面:对于给定n个数的数列,先手可以放入一个数列中不存在的数(0-1e9),后手可以从数列中拿掉一个数,但是这个数必须严格小于刚才先手放入的数。终止条
@@ -304,15 +304,15 @@

- - games + + graphs

- +
- 博弈论 思考如何必胜态和必败态是什么以及如何构造这样的局面。 【博弈/贪心/交互】Salyg1n and the MEX Game https://codeforces.com/contest/1867/problem/C 标签:博弈、贪心、交互 题面:对于给定n个数的数列,先手可以放入一个数列中不存在的数(0-1e9),后手可以从数列中拿掉一个数,但是这个数必须严格小于刚才先手放入的数。终止条 + 图论 【拓扑】有向图的拓扑序列 https://www.acwing.com/problem/content/850/ 题意:输出一个图的拓扑序,不存在则输出-1 思路: 首先我们要知道拓扑图的概念,感官上就是一张图可以从一个方向拓展到全图,用数学语言就是:若一个由图中所有点构成的序列 A 满足:对于图中的每条边 (x,y),x 在 A 中都出现在 y 之前,则称 A 是该图的一个拓扑序列 接
@@ -358,15 +358,15 @@

- - hashing + + greedy

- +
- 哈希 【哈希】分组 https://www.acwing.com/problem/content/5182/ 存储不想同组和想同组的人员信息:存入数组,数据类型为一对字符串 存储所有的组队信息:存入哈希表,数据类型为“键:字符串”“值:一对字符串” 想要知道最终的分组情况,只需要查询数组中的队员情况与想同组 or 不想同组的成员名字是否一致即可 时间复杂度 O(n)O(n)O(n),空间复杂度 + 贪心 大胆猜测,小心求证(不会证也没事,做下一题吧)。证明方法总结了以下几种 反证法:假设取一种方案比贪心方案更好,得出相反的结论 边界法:从边界开始考虑,因为满足边界条件更加容易枚举,从而进行后续的贪心 直觉法:遵循社会法则() 1. green_gold_dog, array and permutation https://codeforces.com/contest/1867/probl
diff --git a/page/25/index.html b/page/25/index.html index 049aa5795..e2c09a607 100644 --- a/page/25/index.html +++ b/page/25/index.html @@ -250,15 +250,15 @@

- - number-theory + + hashing

- +
- 数论 整数问题。 【质数】Divide and Equalize 题意:给定 nnn 个数,问能否找到一个数 numnumnum,使得 numn=∏i=1nainum^n = \prod_{i=1}^{n}a_inumn=∏i=1n​ai​ 原始思路:起初我的思路是二分,我们需要寻找一个数使得n个数相乘为原数组所有元素之积,那么我们预计算出所有数之积,并且在数组最大值和最小值之间进行二分,每次二 + 哈希 【哈希】分组 https://www.acwing.com/problem/content/5182/ 存储不想同组和想同组的人员信息:存入数组,数据类型为一对字符串 存储所有的组队信息:存入哈希表,数据类型为“键:字符串”“值:一对字符串” 想要知道最终的分组情况,只需要查询数组中的队员情况与想同组 or 不想同组的成员名字是否一致即可 时间复杂度 O(n)O(n)O(n),空间复杂度
@@ -304,15 +304,15 @@

- - graphs + + number-theory

- +
- 图论 【拓扑】有向图的拓扑序列 https://www.acwing.com/problem/content/850/ 题意:输出一个图的拓扑序,不存在则输出-1 思路: 首先我们要知道拓扑图的概念,感官上就是一张图可以从一个方向拓展到全图,用数学语言就是:若一个由图中所有点构成的序列 A 满足:对于图中的每条边 (x,y),x 在 A 中都出现在 y 之前,则称 A 是该图的一个拓扑序列 接 + 数论 整数问题。 【质数】Divide and Equalize 题意:给定 nnn 个数,问能否找到一个数 numnumnum,使得 numn=∏i=1nainum^n = \prod_{i=1}^{n}a_inumn=∏i=1n​ai​ 原始思路:起初我的思路是二分,我们需要寻找一个数使得n个数相乘为原数组所有元素之积,那么我们预计算出所有数之积,并且在数组最大值和最小值之间进行二分,每次二
@@ -358,15 +358,15 @@

- - greedy + + geometry

- +
- 贪心 大胆猜测,小心求证(不会证也没事,做下一题吧)。证明方法总结了以下几种 反证法:假设取一种方案比贪心方案更好,得出相反的结论 边界法:从边界开始考虑,因为满足边界条件更加容易枚举,从而进行后续的贪心 直觉法:遵循社会法则() 1. green_gold_dog, array and permutation https://codeforces.com/contest/1867/probl + 计算几何 【二维/数学】Minimum Manhattan Distance https://codeforces.com/gym/104639/problem/J 题意:给定两个圆的直径的两个点坐标,其中约束条件是两个圆一定是处在相离的两个角上。问如何在C2圆上或圆内找到一点p,使得点p到C1圆的所有点的曼哈顿距离的期望值最小 思路: 看似需要积分,其实我们可以发现,对于点p到C1中某个点
diff --git a/page/26/index.html b/page/26/index.html index bd33a5464..27ffc2aac 100644 --- a/page/26/index.html +++ b/page/26/index.html @@ -304,15 +304,15 @@

- - a_template + + binary-search

- +
- 板子 优雅的解法,少不了优雅的板子。目前仅编写 C++ 和 Python 语言对应的板子。前者用于备赛 Xcpc,后者用于备赛蓝桥杯。 基础算法 高精度 ▶C++ 1234567891011121314151617181920212223242526272829303132333435363 + 二分 二分本质上是一个线性的算法思维,只是比线性思维更进一步的是,二分思维需要提炼出题面中两个线性相关的变量,即单调变化的两个变量,从而采用二分加速检索。 【二分答案】Building an Aquarium https://codeforces.com/contest/1873/problem/E 题意:想象有一个二维平面,现在有一个数列,每一个数表示平面对应列的高度,现在要给这个平面在两边加
@@ -358,15 +358,15 @@

- - divide-and-conquer + + a_template

- +
- 分治 将大问题转化为等价小问题进行求解。 【分治】随机排列 https://www.acwing.com/problem/content/5469/ 题意:给定一个 n 个数的全排列序列,并将其进行一定的对换,问是对换了 3n 次还是 7n+1 次 思路:可以发现对于两种情况,就对应对换次数的奇偶性。当 n 为奇数:3n 为奇数,7n+1 为偶数;当 n 为偶数:3n 为偶数,7n+1 为奇数。 + 板子 优雅的解法,少不了优雅的板子。目前仅编写 C++ 和 Python 语言对应的板子。前者用于备赛 Xcpc,后者用于备赛蓝桥杯。 基础算法 高精度 ▶C++ 1234567891011121314151617181920212223242526272829303132333435363
diff --git a/page/27/index.html b/page/27/index.html index 132f7b3fc..54065ad96 100644 --- a/page/27/index.html +++ b/page/27/index.html @@ -250,15 +250,15 @@

- - binary-search + + dfs-and-similar

- +
- 二分 二分本质上是一个线性的算法思维,只是比线性思维更进一步的是,二分思维需要提炼出题面中两个线性相关的变量,即单调变化的两个变量,从而采用二分加速检索。 【二分答案】Building an Aquarium https://codeforces.com/contest/1873/problem/E 题意:想象有一个二维平面,现在有一个数列,每一个数表示平面对应列的高度,现在要给这个平面在两边加 + 搜索 无论是深搜还是宽搜,都逃不掉图的思维。我们将搜索图建立起来之后,剩余的编码过程就会跃然纸上。 【dfs】机器人的运动范围 https://www.acwing.com/problem/content/22/ 1234567891011121314151617181920212223242526272829303132333435class Solution {public: int r
@@ -304,15 +304,15 @@

- - data-structure + + divide-and-conquer

- +
- 数据结构 数据结构由 数据 和 结构 两部分组成。我们主要讨论的是后者,即结构部分。 按照 逻辑结构 可以将其区分为 线性结构 和 非线性结构。 按照 物理结构 可以将其区分为 连续结构 和 分散结构。 【模板】双链表 https://www.acwing.com/problem/content/829/ 思路:用两个空结点作为起始状态的边界,避免所有边界讨论。 时间复杂度:插入、删除结点均 + 分治 将大问题转化为等价小问题进行求解。 【分治】随机排列 https://www.acwing.com/problem/content/5469/ 题意:给定一个 n 个数的全排列序列,并将其进行一定的对换,问是对换了 3n 次还是 7n+1 次 思路:可以发现对于两种情况,就对应对换次数的奇偶性。当 n 为奇数:3n 为奇数,7n+1 为偶数;当 n 为偶数:3n 为偶数,7n+1 为奇数。
@@ -358,15 +358,15 @@

- - dfs-and-similar + + dp

- +
- 搜索 无论是深搜还是宽搜,都逃不掉图的思维。我们将搜索图建立起来之后,剩余的编码过程就会跃然纸上。 【dfs】机器人的运动范围 https://www.acwing.com/problem/content/22/ 1234567891011121314151617181920212223242526272829303132333435class Solution {public: int r + 动态规划 动态规划分为被动转移和主动转移,而其根本在于状态表示和状态转移。如何完整表示所有状态?如何不重不漏划分子集从而进行状态转移? 【递推】反转字符串 https://www.acwing.com/problem/content/5574/ 题意:给定 n 个字符串,每一个字符串对应一个代价 wiw_iwi​,现在需要对这 n 个字符串进行可能的翻转操作使得最终的 n 个字符串呈现字典序上
diff --git a/page/28/index.html b/page/28/index.html index a463e6fd6..0918ac6d8 100644 --- a/page/28/index.html +++ b/page/28/index.html @@ -250,15 +250,15 @@

- - dp + + data-structure

- +
- 动态规划 动态规划分为被动转移和主动转移,而其根本在于状态表示和状态转移。如何完整表示所有状态?如何不重不漏划分子集从而进行状态转移? 【递推】反转字符串 https://www.acwing.com/problem/content/5574/ 题意:给定 n 个字符串,每一个字符串对应一个代价 wiw_iwi​,现在需要对这 n 个字符串进行可能的翻转操作使得最终的 n 个字符串呈现字典序上 + 数据结构 数据结构由 数据 和 结构 两部分组成。我们主要讨论的是后者,即结构部分。 按照 逻辑结构 可以将其区分为 线性结构 和 非线性结构。 按照 物理结构 可以将其区分为 连续结构 和 分散结构。 【模板】双链表 https://www.acwing.com/problem/content/829/ 思路:用两个空结点作为起始状态的边界,避免所有边界讨论。 时间复杂度:插入、删除结点均